Skip to content

Run open-chat as a Prisma Composer app#1

Open
wmadden-electric wants to merge 5 commits into
mainfrom
claude/composer-port
Open

Run open-chat as a Prisma Composer app#1
wmadden-electric wants to merge 5 commits into
mainfrom
claude/composer-port

Conversation

@wmadden-electric

@wmadden-electric wmadden-electric commented Jul 17, 2026

Copy link
Copy Markdown

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() → the chat service. 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 names src/server/env.ts already 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 — copies dist/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 via node()'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

  • Plain postgres() on both ends. open-chat keeps its own pg.Pool (shared with Better Auth) and runs its own migrations (prisma-next db init against the provisioned URL). The framework supplies only the connection URL.
  • No cron module — open-chat has no scheduled work in this topology.
  • Secrets are secret slots bound at the root; Stripe stays plain code + secret bindings.

Verified

Local — full dev loop through the launcher: guest sign-in, chat via Postgres, SSE ready event through the streams stand-in. No cloud credentials.

Deployed (real Prisma Cloud, production stage):

  • App shell and client static assets (JS + CSS) served from the deployed URL — the whole built directory arrives, via node()'s directory form.
  • Guest sign-in; chat row written to Postgres.
  • Live tail, both mechanisms: text/event-stream delivering readycheckpointheartbeat, where each heartbeat only fires after a live=true&timeout=4s long-poll read round-trips to the streams service and returns empty. Storage-backed streams service throughout.
  • Stripe webhook path reachable: POST /api/billing/webhook rejects cleanly — 400 Missing stripe-signature header unsigned, 400 Invalid webhook signature when signed with a bogus value (signature verification runs against the wired secret); GET is 405.
  • Message generation reaches OpenRouter and stops at its auth boundary (placeholder key) — every link Composer owns in that path is exercised.

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, separate APP_ORIGIN rows (class: production at project level vs class: preview scoped 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.json pins pkg.pr.new preview builds of @prisma/composer* (668c8b0); swap to npm once the next release is cut.

🤖 Generated with Claude Code

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant