Run open-chat as a native Prisma Composer app#1
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>
…0-dev.1 (D5)
Move the port to @prisma/composer{,-prisma-cloud}@0.2.0-dev.1 — the npm dev
release of prisma/composer merge ace693b (PR #147 / ADR-0039) — replacing
the pkg.pr.new preview pins. Two framework changes land together:
The app's own public URL stops being operator config. ADR-0039 makes a
service's origin a platform-resolved property: the deploy writes the
reserved COMPOSER_<addr>_ORIGIN row from the service's real endpoint
domain, and service.origin() reads it back at boot. The appOrigin
envParam is deleted end to end (module.ts, service.ts params, .env);
start.ts sets APP_ORIGIN from service.origin(). This removes FRICTION.md
#9's placeholder-first-deploy + manual PATCH + artifact-marker dance
outright — the origin is correct on the first deploy.
The streams bearer key stops being a secret anyone supplies. The target
mints it per streams module (ADR-0031); the old two-consumer
STREAMS_API_KEY wiring is gone. The launcher reads the dependency's
resolved url/apiKey connection params through configKey() —
rawStreamsParam() — because StreamsClient exposes no public accessor for
them (recorded in FRICTION.md).
scripts/dev.ts follows both: the streams apiKey is bound as a dependency
connection param (placeholder — the local stand-in doesn't check auth),
and the origin is bound as the addressed ORIGIN row, exercising the same
run() stash path a deploy uses.
@effect/platform-node and @effect/platform-bun join devDependencies:
alchemy's CLI hard-requires both at import time while declaring them
optional peers, and a standalone install has no parent workspace to
resolve them from — FRICTION.md #11, which earlier dispatches masked by
this clone living inside a prisma/composer worktree.
Deployed to real Prisma Cloud and verified end to end on the
deploy-reported URL: shell + client JS/CSS 200; guest sign-in 200 on the
FIRST deploy (a __Secure- session cookie scoped to the real origin — the
ADR-0039 payoff); chat create 201 with the row confirmed by direct query
against the provisioned database resource; SSE tail 200 delivering
ready → checkpoint → 3 heartbeats over 20s (the minted streams key
authenticates); Stripe webhook 400 unsigned / 400 bad-signature / 405 GET;
message generation stops at OpenRouter's auth boundary with the
placeholder key. One operator step remains outside the deploy — the app's
own schema (prisma-next db init) against a Management-API-minted
connection, since plain postgres() surfaces no URL to the deploy shell —
recorded as FRICTION.md #12.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…6 (D6)
Flip the database edge from plain postgres() to the contract-carrying
pnPostgres: the resource is pnPostgres({ name: "database", contract,
config }) and the service consumes pnPostgres(chatData), so the deploy
migrates the freshly provisioned database itself (a PrismaNext.Migration
resource, [database-migrate] in the plan) before the chat service starts.
The launcher reads only db.url from the { url, client } binding
(prisma/composer#154, ADR-0040) — open-chat keeps its own pg.Pool shared
with Better Auth, and the framework-built typed client is never
constructed. FRICTION.md #12's by-hand schema step is gone.
The app toolchain moves to @prisma-next 0.15 (the version the framework
bundles): contract re-emitted, migrations store regenerated as
migrations/app/20260722T1415_init/ (from: null to the re-emitted
contract's hash; the 0.13-vintage refs are deleted). Two mechanical
consequences in app code: regenerated src/prisma/contract.{json,d.ts},
and the 0.15 runtime's schema-namespaced ORM paths (db.orm.<Model> →
db.orm.public.<Model>, 32 call sites, no behavioral change).
Composer packages pin npm 0.2.0-dev.6. The @effect/platform-* dev
dependencies added as a deploy workaround are removed —
@prisma/composer-prisma-cloud carries them itself since
prisma/composer#151 (FRICTION.md #11). New FRICTION.md #13 records a bun
resolver failure when two pkg.pr.new previews reference each other,
hit while proving against the PR preview and moot on npm pins.
Deployed fresh (destroy + deploy, zero manual steps) and verified end to
end on the deploy-reported URL: [database-migrate] ran in the deploy;
shell + client asset 200; guest sign-in 200 against the brand-new
database with no schema step ever run; chat create 201 with the row in
Postgres; SSE tail ready + 4 heartbeats over ~22s; Stripe webhook 400
unsigned / 400 bad-signature / 405 GET; message generation stops at
OpenRouter's auth boundary with the placeholder key. Local dev loop
re-verified on the npm pin (shell + sign-in 200).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The framework bumped its bundled @prisma-next/* to 0.16.0 (prisma/composer#156, shipped in 0.2.0-dev.7); the app follows as a matched pair so the emitted contract and the deploy-side migration tooling stay the same version: composer packages to 0.2.0-dev.7, prisma-next + @prisma-next/postgres to 0.16.0. The 0.16 emit changes the contract format, so the storage hash moved and the init migration was regenerated (same 30 operations, from: null to the new hash). contract.prisma and all app code are untouched — the 0.16 ORM surface is unchanged. Destroyed and redeployed fresh: [database-migrate] ran on the 0.16 tooling, and the abbreviated end-to-end check passed on the deploy-reported URL (shell 200, guest sign-in 200 against the new database with zero manual steps, chat create 201, SSE ready + 3 heartbeats over ~17s). Local dev loop green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
FRICTION.md was Prisma Composer dogfooding process output, not part of the port itself — it does not belong in this repository. It now lives in prisma/composer as open-chat-port-friction.md (prisma/composer#157), verbatim. The three comments that pointed at it drop the pointer and keep their substance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
The mission changed: instead of a strict port that leaves the app untouched, open-chat now showcases idiomatic Composer usage. The src/composer/ directory, the launcher, and the env-name mapping layer — all scaffolding the old constraint forced — are gone. The service node is the app's config surface. src/service.ts (the compute() node) and src/data.ts (the wrapped contract) are first-class app files; module.ts stays the composition root. Each consumer resolves its values at the natural site: db.ts builds the shared pg.Pool from service.load().db.url (the lazy typed client is never constructed), auth.ts takes service.origin() and the Better Auth secret, billing takes the Stripe secrets and origin (its "not configured" guards were structurally dead once the secrets became required slots), openrouter.ts centralizes headers from config() and secrets(), and the listener takes the reserved port param. src/server/env.ts is deleted; there is no APP_ORIGIN, DATABASE_URL, or STREAMS_* operator configuration left. The node() build entry is the app's own server entry: dist/server/ (with the client-asset siblings Bun's HTML import emits) is the artifact, and build:launcher/build:pack/build:chat collapse into one build step. bun run dev is the one local path — it provisions a local Postgres and the streams module's stand-in, applies the schema, and boots the same service.run() path a deploy uses (hot reload traded for a zero-configuration loop; plain bun --hot cannot boot a server that consumes the service node). The embedded @prisma/streams-local fallback and STREAMS_PORT are gone with it. One deliberate non-native seam remains: the app multiplexes each user's chats over one durable stream with a routing key, which the hydrated StreamsClient cannot express (no routing-key parameter, no public accessor for its connection values), so src/server/streams.ts keeps its own client fed through the framework's configKey() rows — recorded upstream as friction finding #14 with two framework recommendations. README leads with the one-command deploy (the per-app CLI story is preserved in a collapsed section until oss.chat cuts over, its build command repointed at the collapsed build script); .env.example shrinks to the envSecret() inputs plus optional social sign-in; docs/architecture.md describes the Composer-wired topology. Destroyed and redeployed fresh, zero manual steps, full end-to-end on the deploy-reported URL: [database-migrate] applied the schema; shell + client asset 200; guest sign-in 200 on the brand-new database; chat create 201; SSE ready + 4 heartbeats over ~21s; Stripe webhook 400 unsigned / 400 bad-signature / 405 GET; message generation stops at OpenRouter's auth boundary (placeholder key); /api/config serves the structural flags. Local loop green; bun test baseline unchanged (32 pass, 1 pre-existing browserless visual-test failure). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
| const githubClientId = process.env["GITHUB_CLIENT_ID"]; | ||
| const githubClientSecret = process.env["GITHUB_CLIENT_SECRET"]; | ||
| const googleClientId = process.env["GOOGLE_CLIENT_ID"]; | ||
| const googleClientSecret = process.env["GOOGLE_CLIENT_SECRET"]; |
There was a problem hiding this comment.
Why not take these from service as well?
There was a problem hiding this comment.
The framework cannot express an optional secret yet: every secret() slot is required — secrets() resolves all slots eagerly and one unbound slot fails the whole call — so declaring these four would force operators to invent placeholders for an off-by-default feature (and a placeholder defeats this presence check). Filed as friction #15 with a secret({ optional: true }) recommendation; once that lands these move onto the node and the process.env reads disappear. A comment now records this at the read site (541d45b).
| params: { | ||
| openrouterAppName: string({ default: "Open Chat Local" }), | ||
| openrouterSiteUrl: string({ default: "http://localhost:3000" }), | ||
| }, | ||
| secrets: { | ||
| openrouterApiKey: secret(), | ||
| betterAuthSecret: secret(), | ||
| stripeSecretKey: secret(), | ||
| stripeWebhookSecret: secret(), | ||
| }, |
There was a problem hiding this comment.
there were a lot of vars in env.ts which are not present here now. why not?
There was a problem hiding this comment.
Full accounting of the old env.ts surface: APP_ORIGIN → service.origin(); DATABASE_URL → the db dependency; STREAMS_URL/STREAMS_API_KEY → the streams dependency; STREAMS_PORT → deleted with the embedded fallback; PORT → the reserved port param; Better Auth/OpenRouter/Stripe secrets → secret() slots; OPENROUTER_APP_NAME/SITE_URL → string() params. The only values not on the node are the four GitHub/Google OAuth credentials — see the sibling thread: required-only secret slots (friction #15).
A secret() slot is required — secrets() fails if any slot is unbound — so an off-by-default feature cannot declare its credentials without forcing placeholder values, which would also defeat the presence check. Filed upstream as friction #15 (optional secret slots). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Makes open-chat a native Prisma Composer application. The entire topology — chat server, durable Streams service, its storage tier, and the contract-carrying Postgres — is declared in
module.ts, and one command deploys it:The deploy provisions everything, applies the app's migrations to the fresh database before the service starts, resolves the app's own public URL, and mints the streams bearer key — zero manual steps, verified end to end on every round below.
The shape
module.ts— the composition root: storage module → streams module →pnPostgres({ name, contract, config })→ thechatservice.src/service.ts— the chat service'scompute()node: dependencies (pnPostgres(chatData),durableStreams()), params, secrets, and thenode()build pointing at the app's own server entry. The service node is the app's config surface: consumers readservice.load()/config()/secrets()/origin()directly at the natural site —src/prisma/db.tsbuilds the app'spg.Poolfromservice.load().db.url(shared with Better Auth; the ADR-0040 lazy typed client is never constructed),src/server/auth.tstakesservice.origin()+ the Better Auth secret,portparam.There is no env-var mapping layer and no
APP_ORIGIN/DATABASE_URL/STREAMS_*operator configuration — those values are platform-resolved.src/data.ts— the app's emitted Prisma Next contract wrapped once; both the database resource and the service reference this one value, so a service can't wire against a mismatched schema, and the deploy migrates to exactly this contract.scripts/dev.ts(=bun run dev) — local dev with no cloud credentials and no.env: provisions a local Postgres + the streams module's stand-in, applies the schema, and boots the sameservice.run()path a deploy uses.What the platform now owns that the previous deploy story hand-carried
db:initagainst a hand-minted connectionmigrations/before the service starts (PrismaNext.Migration)APP_ORIGIN→ deploy againservice.origin()(ADR-0039)STREAMS_API_KEY, copy to both servicesOperator input is reduced to the app's genuine secrets (
.env.example: OpenRouter, Better Auth, Stripe, optional social sign-in) plus platform credentials.One deliberate non-native seam
The app multiplexes all of a user's chats over one durable stream with a routing key (
stream-keyon append,key=filter on read). The hydratedStreamsClientdoesn't support routing keys and doesn't expose its raw connection values, sosrc/server/streams.tskeeps its own client and reads the dependency's resolvedurl/apiKeythrough the framework'sconfigKey()rows. Recorded as finding #14 in the friction log with two concrete framework recommendations.History
This branch began as a strict port ("app code unchanged, Composer only as the deploy tool") and was re-scoped to a native showcase once the port had served its purpose: it surfaced and drove four framework fixes (compose#116 directory-form builds, compose#147/ADR-0039
service.origin(), compose#151 alchemy peer deps, compose#154/ADR-0040 the lazy{ url, client }PN binding). The dated record of every finding lives in the Composer repo's friction log. The pre-Composer per-app deploy story is preserved in the README's collapsed "Previous deployment story" section until oss.chat cuts over.Verified (every round on a fresh cloud stack, zero manual steps)
Latest round (nativized layout,
0.2.0-dev.7/@prisma-next0.16):[database-migrate]ran inside the deploy; guest sign-in 200 against the brand-new database.ready+ 4 heartbeats (minted streams key authenticates)./api/configserves the reworked structural flags (billingEnabled: true,streamsRemote: true).bun run dev): shell + guest sign-in 200.bun test: 32 pass, 1 pre-existing visual-test failure (baseline unchanged).Earlier rounds: D3/D4 (port proof + preview-stage isolation), D5 (ADR-0039 origin), D6 (framework-run migrations) — full history in the friction log.
Follow-ups
@prisma/composer*pinned to0.2.0-dev.7; move to the next stable when cut.bun run deploy, then delete the legacyprisma.compute.ts+src/streams-app/target and the README's collapsed legacy section.🤖 Generated with Claude Code