Run open-chat as a Prisma Composer app#1
Open
wmadden-electric wants to merge 5 commits into
Open
Conversation
Express open-chat as a Prisma Composer app without touching its business logic. Installs @prisma/composer + @prisma/composer-prisma-cloud from the pkg.pr.new preview of composer main (ac1e7b1) — npm 0.1.0 predates the streams module and envParam. module.ts wires storage() -> streams() -> pnPostgres(database) -> the chat compute service, matching the slice spec exact-ly: streamsKey binds to the same platform var as the streams module apiKey, appOrigin is env-sourced via envParam, GitHub/Google OAuth secrets are omitted (social sign-in off). src/composer/start.ts is the one structural addition: a launcher that maps Composer secrets/config onto the env names src/server/env.ts already reads, then imports the existing built server entry unchanged, preserving the chdir + client-asset-resolution behavior in src/start.ts/src/chdir.ts. D1 probe (raw connection-URL extraction): pnPostgres(contract) hydrates straight to a typed client with no accessor for the URL underneath it, and (newly discovered) that hydrate throws outright against this preview build because open-chat contract.json predates the bundled @prisma-next toolchain by two minors. The launcher reads both dependency URLs from the private, address-free COMPOSER_<INPUT>_URL env var the target stashes before boot(), bypassing service.load() entirely — recorded in FRICTION.md, not hacked around. Also newly found and recorded: the node() build adapters assemble() copies a single file, which cannot carry open-chats multi-file Bun HTML-import client bundle — blocks a real deploy (D3) until fixed upstream or worked around. Verified: bun run typecheck and bun run build both green; Load(module) resolves the full graph with correct secret/param bindings; a boot-time simulation (fabricated COMPOSER_* env, chatService.run() driving the real built launcher) starts the real open-chat server end-to-end. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
The operator revised the 2026-07-15 pnPostgres decision after D1 hit two
blocking findings: hydrate() discards the raw URL open-chat needs for its
own pg.Pool, and the frameworks bundled @prisma-next 0.15.0 rejects
open-chat 0.13.0-emitted contract.json at runtime. postgres()s binding IS
{ url } — exactly what open-chat needs, read through the public load().
open-chat keeps running its own db:init/db:push (ADR-0022: the contract
hash is the thing, migrations are only the means).
module.ts provisions postgres({ name: "database" }) instead of
pnPostgres(...); service.ts declares db: postgres() instead of
pnPostgres(contract); contract.ts (the AnyPnContract wrapper) is deleted,
nothing else needed it. start.ts now calls service.load() for both db and
streams and reads db.url/streams.url directly — the COMPOSER_*_URL
env-var reach-around and its @ts-expect-error widening are gone; load()
is now entirely public API.
Verified: bun run typecheck and bun run build both green; a boot
simulation (fabricated COMPOSER_* env, service.run() driving the real
built launcher) starts the real open-chat server end-to-end through
load().
FRICTION.md rewrites the pnPostgres entries: the workaround is gone but
the finding is sharper for having tried the fix. A pnPostgres resource
cannot satisfy a plain postgres() dependency (framework migrations + my
own client is inexpressible), and the converse (typed resource + my own
migrations) is blocked too, since the resource config field is required
and the descriptor always runs PnMigration. Root cause: Contract<Kind,
Cmp> welds kind-equality into both the provision-site assignability check
and satisfies()s signature, so a cross-kind subtype relation cant be
expressed. The version-skew finding is kept, marked not currently hit.
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
scripts/dev.ts boots open-chat through src/composer/service.ts and start.ts — the same code path a deploy uses — against local stand-ins only: prisma dev for Postgres, the streams module's own local stand-in (startLocalStreamsServer), and a rebuilt dist/server bundle. No cloud credentials required; OPENROUTER_API_KEY gets a local placeholder when unset so sign-in, history, and the live-tail SSE path still work (chat generation fails cleanly at OpenRouter instead). Wired as `dev:composer`, independent of the existing `dev`/`db:dev` scripts. Verified end to end: guest sign-in, chat creation, and GET /api/chats/:id/events all work against the local stand-ins; a message send durably records the user message and fails the assistant turn with OpenRouter's own auth error, not a wiring failure. FRICTION.md gained three D2 findings: no local-dev harness exists for a compute() node with real deps (the deploy env-var wire protocol has to be hand-replicated), a node's real deployment address is only derivable from load-module.ts internals, and service.secrets()'s all-or-nothing resolution forces a placeholder for the one real external credential. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Bump @prisma/composer and @prisma/composer-prisma-cloud to the pkg.pr.new
preview of prisma/composer main at 668c8b0, which adds node()s directory
form. Switch the chat services build() to it, so the deploy artifact ships
dist/server/ (the app server plus the client JS/CSS/image siblings its HTML
import emits) alongside the composer launcher, not just the launcher alone.
The launchers existing dynamic import of dist/server/start.js has to keep
resolving both at build time (against src/composer/, where bun actually
checks the specifier exists on disk) and at runtime (against wherever dir
lands once copied into bundle/) — those are two different physical
locations. Rather than edit the import (which would break the identical
resolution scripts/dev.ts relies on for local dev), a new build:pack step
copies dist/composer/ and dist/server/ into dist/pack/dist/{composer,server}/,
reproducing the same nesting the original import string already assumes, and
node()s dir/entry point at that tree.
Deployed to real Prisma Cloud (workspace project open-chat) and verified
end to end: the deployed URL serves the app shell and a client static asset
(the proof the whole directory arrived); guest sign-in and chat creation
write to Postgres; GET /api/chats/<id>/events returns 200 text/event-stream
with the ready event; message generation fails at OpenRouters auth error
with the placeholder key, as expected. No cron module exists in this ports
topology (FRICTION.md already records this as not applicable), so there is
nothing to verify registered there.
Added FRICTION.md #8: the directory forms dir/entry has to reproduce the
exact on-disk nesting a pre-existing dynamic import specifier assumed —
shipping referenced files as dir siblings is not sufficient on its own.
Signed-off-by: Will Madden <madden@prisma.io>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Deploy the same open-chat topology a second time to an isolated preview stage (prisma-composer deploy module.ts --stage preview-d4), then verify and tear it down. The stage mechanism is a Branch of the same open-chat Project (ADR-0023/0024): the CLI classifies preview-d4 as role "preview" on its own, with its own compute services, its own database resource, and its own branch-scoped APP_ORIGIN — none of it shared with production. APP_ORIGIN needs a real two-step deploy: the preview URL isn't known until the first deploy assigns it, so deploy #1 runs with a placeholder, then the platform variable is corrected via a direct Management API PATCH once the real URL is known. That correction alone doesn't survive a redeploy — Alchemy diffs the envParam pointer's NAME, which never changes, not the value, which it can't even read back — so picking up the correction needs a genuinely different artifact hash, not just a rerun. Verified end to end on the preview URL: app shell and a client asset both 200, guest sign-in, chat creation, and the SSE ready event. Verified isolation in both directions by querying each stage's database directly: the preview chat exists only in the preview database, D3's two production chats exist only in the production database, and the preview and production APP_ORIGIN rows are separate platform-variable records (one project-level "production" class, one branch-scoped "preview" class). Tore the preview stage down (destroy module.ts --stage preview-d4) and confirmed the branch, its apps, and its database are gone from the Management API, and that production still serves 200 throughout and after. Added FRICTION.md #9 (the APP_ORIGIN correction/redeploy gap above) and #10 (a clean isolation-verification writeup, plus a near-miss: the project's auto-provisioned default database — same name as the project — is a decoy; this port's schema lives in the separately-named "database" resource on each branch). Also gitignores .alchemy/ and .prisma-composer/, the CLI's own generated, overwritten-every-run local build cache — same treatment as dist/. Signed-off-by: Will Madden <madden@prisma.io> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Expresses open-chat as a Prisma Composer app: Composer owns wiring, deploy, and stage previews; the application code is unchanged (the entire diff is additive —
src/composer/, build wiring, config, and docs).What this adds
module.ts— the topology: storage module → streams module →postgres()→ thechatservice. The embedded streams service stays exactly as it is; its durable tier now comes from the storage module's binding instead of R2.src/composer/start.ts— the launcher, the one structural addition: maps Composer bindings (DB/streams URLs, params, secrets) onto the env namessrc/server/env.tsalready reads, then imports the built server unchanged.scripts/dev.ts/dev:composer— local dev through the same launcher path a deploy uses, with local stand-ins and no cloud credentials.build:pack— copiesdist/composer/+dist/server/into one tree so the deploy artifact is the directory the server actually serves from (client JS/CSS/images are emitted as siblings by Bun's HTML import). Deployed vianode()'s directory form (feat(node): node() can deploy a built directory, not just a single file composer#116).FRICTION.md— dated findings from the port; the gaps it surfaced were fixed upstream (compose#116) or filed.Decisions of note
postgres()on both ends. open-chat keeps its ownpg.Pool(shared with Better Auth) and runs its own migrations (prisma-next db initagainst the provisioned URL). The framework supplies only the connection URL.Verified
Local — full dev loop through the launcher: guest sign-in, chat via Postgres, SSE
readyevent through the streams stand-in. No cloud credentials.Deployed (real Prisma Cloud, production stage):
node()'s directory form.text/event-streamdeliveringready→checkpoint→heartbeat, where each heartbeat only fires after alive=true&timeout=4slong-poll read round-trips to the streams service and returns empty. Storage-backed streams service throughout.POST /api/billing/webhookrejects cleanly — 400Missing stripe-signature headerunsigned, 400Invalid webhook signaturewhen signed with a bogus value (signature verification runs against the wired secret);GETis 405.Preview stage (
--stage preview-d4, since destroyed) — branch-scoped isolation verified directly against the Management API and each branch's database, not just through the app: separate database resource per branch, separateAPP_ORIGINrows (class: productionat project level vsclass: previewscoped to the branch), and chats created on each stage present only in that stage's database. Production served 200 throughout and after teardown.Follow-ups
package.jsonpins pkg.pr.new preview builds of@prisma/composer*(668c8b0); swap to npm once the next release is cut.🤖 Generated with Claude Code