diff --git a/.drive/projects/local-dev/assets/latency-probe.ts b/.drive/projects/local-dev/assets/latency-probe.ts new file mode 100644 index 000000000..9ec7e5d43 --- /dev/null +++ b/.drive/projects/local-dev/assets/latency-probe.ts @@ -0,0 +1,162 @@ +// One-off restart-latency measurement (S6 Part B). Mirrors +// test/integration/test/local-dev-store.integration.ts's +// `rebuildCatalogAndReconverge` technique exactly: touch catalog's built +// artifact, re-run the node build adapter's own `assemble()` for +// catalog.service, re-converge the SAME dev stack file directly with the +// real `alchemy` binary (session stays up throughout — no CLI +// re-invocation, no SIGINT), then poll the compute emulator's services +// listing until catalog.service's pid changes. Not committed as a test; +// run once by hand to produce the numbers recorded in latency.md. +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { containerEnv } from '@prisma/composer/config'; +import { nodeBuild } from '@prisma/composer/node/control'; +import { prismaCloud } from '@prisma/composer-prisma-cloud/control'; + +// Run this script with `examples/store` as cwd (module resolution needs its +// node_modules; the S6 proof ran it as a temp copy inside that directory). +const storeDir = process.cwd(); +const DEV_STACK_FILE = path.join(storeDir, '.prisma-composer', 'dev', 'alchemy.run.ts'); +const catalogServiceModule = path.join(storeDir, 'modules', 'catalog', 'src', 'service.ts'); +const APP_NAME = 'store'; + +function alchemyBin(startDir: string): string { + let dir = startDir; + for (;;) { + const candidate = path.join(dir, 'node_modules', '.bin', 'alchemy'); + if (fs.existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) throw new Error(`could not find alchemy above ${startDir}`); + dir = parent; + } +} + +interface EmulatorRegistryEntry { + readonly pid: number; + readonly port: number; +} +function isRegistryEntry(value: unknown): value is EmulatorRegistryEntry { + if (typeof value !== 'object' || value === null) return false; + if (!('pid' in value) || !('port' in value)) return false; + return typeof value.pid === 'number' && typeof value.port === 'number'; +} +function readComputeEntry(): EmulatorRegistryEntry { + const p = path.join(os.homedir(), '.prisma-composer', 'emulators', 'compute.json'); + const parsed: unknown = JSON.parse(fs.readFileSync(p, 'utf8')); + if (!isRegistryEntry(parsed)) throw new Error('bad compute registry entry'); + return parsed; +} + +interface ServiceInfo { + readonly address: string; + readonly status: string; + readonly pid?: number; +} +function isServiceInfoArray(value: unknown): value is ServiceInfo[] { + return ( + Array.isArray(value) && + value.every( + (v) => typeof v === 'object' && v !== null && 'address' in v && typeof v.address === 'string', + ) + ); +} +async function pidsByAddress(): Promise> { + const entry = readComputeEntry(); + const res = await fetch(`http://127.0.0.1:${entry.port}/apps/${APP_NAME}/services`); + if (!res.ok) throw new Error(`listServices failed: ${res.status}`); + const parsed: unknown = await res.json(); + if (!isServiceInfoArray(parsed)) throw new Error('unexpected services listing shape'); + return Object.fromEntries(parsed.map((s) => [s.address, s.pid])); +} + +async function waitForAsync(fn: () => Promise, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const result = await fn(); + if (result !== undefined) return result; + if (Date.now() >= deadline) throw new Error(`not met within ${timeoutMs}ms`); + await new Promise((r) => setTimeout(r, 100)); + } +} + +async function oneRun(runNumber: number): Promise { + const before = await pidsByAddress(); + const pidBefore = before['catalog.service']; + + const t0 = performance.now(); + + // 1. Edit the source (a real edit, not just a rebuild trigger). A plain + // comment doesn't survive `bun build --production`'s minifier, so the + // rebuilt bytes (and thus the artifact hash) wouldn't actually move — + // append a side-effecting statement instead, guaranteed to survive + // minification (dead-code elimination can't drop a console.log). + const marker = `latency-probe-run-${runNumber}-${Date.now()}`; + const serverSrc = catalogServiceModule.replace('service.ts', 'server.ts'); + fs.appendFileSync(serverSrc, `\nconsole.log("${marker}");\n`); + + // 2. Rebuild via the example's own build script. + const build = spawnSync('bun', ['run', 'build'], { + cwd: path.join(storeDir, 'modules', 'catalog'), + stdio: 'inherit', + }); + if (build.status !== 0) throw new Error('catalog build failed'); + + // 3. Re-assemble (copies the freshly built dist/ into the dev bundle dir). + const buildDescriptor = nodeBuild().nodes['node']; + if (buildDescriptor === undefined || buildDescriptor.kind !== 'build') { + throw new Error('nodeBuild() must declare a "node" build descriptor'); + } + await buildDescriptor.assemble({ + build: { + extension: '@prisma/composer/node', + type: 'node', + module: `file://${catalogServiceModule}`, + entry: '../dist/server.mjs', + }, + address: 'catalog.service', + cwd: storeDir, + }); + + // 4. Re-converge (this is what the CLI's own watch loop would do). + // (The measured run used the pre-rename seam, `descriptor.dev`, on the S5 + // branch; updated here to main's `localTarget` thunk — same call, new name.) + const descriptor = prismaCloud(); + if (descriptor.localTarget === undefined) throw new Error('no local-target descriptor'); + const localTarget = await descriptor.localTarget(); + const container = await localTarget.container.ensure({ appName: APP_NAME, stage: undefined }); + const envVars = containerEnv(new Map([[descriptor.id, container]])); + const result = spawnSync( + alchemyBin(storeDir), + ['deploy', path.relative(storeDir, DEV_STACK_FILE), '--yes', '--stage', 'dev'], + { cwd: storeDir, stdio: 'inherit', env: { ...process.env, ...envVars } }, + ); + if (result.status !== 0) throw new Error(`converge failed: ${result.status}`); + + // 5. Poll until catalog.service's pid actually changes (new process serving). + await waitForAsync(async () => { + const pids = await pidsByAddress(); + const pidAfter = pids['catalog.service']; + return pidAfter !== undefined && pidAfter !== pidBefore ? pidAfter : undefined; + }, 30_000); + + const t1 = performance.now(); + return t1 - t0; +} + +async function main(): Promise { + const runs = 5; + const results: number[] = []; + for (let i = 1; i <= runs; i += 1) { + const ms = await oneRun(i); + console.log(`[latency] run ${i}: ${(ms / 1000).toFixed(2)}s`); + results.push(ms); + } + const sorted = [...results].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)]; + console.log(`[latency] median: ${((median ?? 0) / 1000).toFixed(2)}s`); + console.log(JSON.stringify(results)); +} + +await main(); diff --git a/.drive/projects/local-dev/assets/latency.md b/.drive/projects/local-dev/assets/latency.md new file mode 100644 index 000000000..a34d93c65 --- /dev/null +++ b/.drive/projects/local-dev/assets/latency.md @@ -0,0 +1,86 @@ +# Restart latency measurement (S6 Part B) + +Acceptance criterion 8: "Restart-latency measurement for `examples/store` +recorded in the close-out notes (target: single-digit seconds; a miss is a +recorded follow-up, not a DoD failure)." + +## Method + +Mirrors `test/integration/test/local-dev-store.integration.ts`'s +`rebuildCatalogAndReconverge` technique (S5 store proving script), timed: + +1. Start a real `prisma-composer dev module.ts` session against + `examples/store` (fresh state, credential-free) and wait for + `[dev] ready:`. +2. For each of 5 runs, against the **still-running** session (no CLI + restart, no SIGINT — `Bundle.watch` isn't populated on this branch, so + the file-watch loop can't fire; the manual re-assemble + re-converge + below is the documented stand-in the S5 script also uses): + - `t0 = performance.now()`. + - Append a real source edit to `modules/catalog/src/server.ts` — a + `console.log("latency-probe-run-N-")` statement (a plain comment + doesn't survive `bun build --production`'s minifier, so the artifact + hash wouldn't move; a side-effecting statement can't be dead-code + eliminated). + - Rebuild via the module's own build script: `bun run build` in + `modules/catalog` (its `package.json`'s `build: + "rm -rf dist && bun build src/server.ts --target=bun --outfile dist/server.mjs"`). + - Re-run the `node()` build adapter's own `assemble()` for + `catalog.service` (copies the freshly built `dist/server.mjs` into the + dev bundle dir the stack file already points at). + - Re-converge the same dev stack file directly with the real `alchemy` + binary (`alchemy deploy .prisma-composer/dev/alchemy.run.ts --yes + --stage dev`) — this re-hashes catalog's now-different artifact and + PUTs a fresh deployment; the emulator's own hash diff decides which + service(s) actually restart (only `catalog.service`, per criterion 2). + - Poll the compute emulator's `GET /apps/store/services` (the same + wire protocol the S5 script polls) until `catalog.service`'s `pid` + changes from its pre-edit value — the new process is actually up and + the emulator has recorded it. + - `t1 = performance.now()`; record `t1 - t0`. +3. Report each run and the median. + +Script: `.drive/projects/local-dev/assets/latency-probe.ts` (not part of the +shipped test suite — run once by hand, from `examples/store` as cwd so +workspace module resolution finds `@prisma/composer`/`@prisma/composer-prisma-cloud`). + +## Machine + +Apple M3 Max, macOS (Darwin 25.5.0), arm64. + +## Results + +| Run | Latency (edit → new pid observed) | +| --- | --- | +| 1 | 3.16s | +| 2 | 3.35s | +| 3 | 3.26s | +| 4 | 3.24s | +| 5 | 3.09s | + +**Median: 3.24s.** + +Meets the single-digit-seconds target comfortably. The bulk of the time is +`bun run build` (a real production bundle of the whole catalog service, ~20ms +per its own reported bundle time — negligible) plus Alchemy's converge pass +over all 51 resources in the graph (only 10 actually update; the rest are +diffed as no-ops) plus the emulator's own child-restart (SIGTERM the old +process, spawn the new one, wait for it to report ready). No component of +this path is watch-loop-dependent — it's the same re-assemble + re-converge +`watch.ts` would trigger automatically once `Bundle.watch` lands (tracked +separately; not on this branch — see FRICTION-S6.md and plan.md's "Known +items blocking close-out"). + +## Update (S6 close-out, after the merge to main) + +The numbers above were measured on the pre-merge S5 branch. Two things +changed on main since and do not invalidate them: + +- **`Bundle.watch` is now populated by every build adapter** (node: + `watch: [runnable.source]`; Next.js: `watch: [standaloneRoot]`), so the + file-watch loop fires on its own — the manual re-assemble + re-converge + this method used is exactly what the loop now triggers automatically. +- **The seam was renamed** (`ExtensionDescriptor.dev` → + `ExtensionDescriptor.localTarget`, now a lazy thunk). `latency-probe.ts` + is updated to the renamed API; the rename touches no part of the measured + path. diff --git a/.drive/projects/local-dev/assets/open-chat-port/FRICTION-S6.md b/.drive/projects/local-dev/assets/open-chat-port/FRICTION-S6.md new file mode 100644 index 000000000..70fd22f9c --- /dev/null +++ b/.drive/projects/local-dev/assets/open-chat-port/FRICTION-S6.md @@ -0,0 +1,259 @@ +# Friction log — S6 (open-chat port onto `prisma-composer dev`) + +Everything hit while pointing the open-chat port at locally-built +`@prisma/composer` / `@prisma/composer-prisma-cloud`, switching its build +descriptor to `node()`'s directory form, and replacing `scripts/dev.ts` with +`prisma-composer dev`. Framework version under test: the compose worktree's +own build (`0.2.0`, packed as tarballs — see the S6 report for the exact +mechanism), on top of `claude/local-dev-s5-dev-command`. + +Mode: the port repo (`/Users/wmadden/Projects/prisma/open-chat`) is owned by +a different user and not writable from this session (`touch` there returned +`Permission denied`). Worked on a full copy (including `.git`) in the +scratchpad, producing real commits there. What the commit changed is +summarized in [PORT.md](PORT.md) beside this file (the raw diff stays in +the copy — a patch blob is not reviewable material). + +## 1. `defineConfig`'s `state` field is a descriptor, not a thunk + +**Where hit:** first `prisma-composer dev module.ts` run. + +**Symptom:** +`Error: prisma-composer.config.ts: \`state\` must be a state descriptor +(e.g. prismaState()) — see defineConfig() in '@prisma/composer/config'.` + +**Cause:** the port's `prisma-composer.config.ts` (written during D1, +against an earlier framework preview) had `state: () => prismaState()`. +`PrismaAppConfig.state` is typed `StateDescriptor`, not a function returning +one — the current API wants the descriptor directly. + +**Fix (port-side):** `state: prismaState()`. + +## 2. The streams module no longer accepts a `secrets` option, and a + consumer no longer declares a `streamsKey` secret slot (ADR-0031) + +**Where hit:** second `prisma-composer dev` run, after fix #1. + +**Symptom:** +`Error: The secrets for "streams" name "apiKey", which is not a secret slot +of that module (module "open-chat").` + +**Cause:** the port's `module.ts` (D1/D1b) predates a framework change: the +streams module's bearer key is now an ADR-0031 provisioning need, minted +once per provider and carried automatically by a consumer's +`durableStreams()` dependency — `streams()` takes only `{ name? }`, and +`examples/streams`' own `jobsService` confirms the current pattern declares +no secret at all for it ("no secret slot, nothing to bind at the root — +declaring the dependency IS what causes the key to exist"). + +**Fix (port-side):** dropped `secrets: { apiKey: envSecret(...) }` from the +streams module's `provision()` call in `module.ts`, and dropped +`streamsKey: secret()` from the chat service's secret slots +(`src/composer/service.ts`) and its binding in `module.ts`. + +**Compounding find — no public API for a consumer that needs the raw +connection.** `durableStreams()` (bare form) now hydrates to a typed +`StreamsClient` wrapper with no accessor for the underlying `{ url, apiKey }` +— by design (ADR-0031 hides it deliberately). open-chat's own server talks +to Durable Streams through its own client +(`@prisma/streams-local`/`@prisma/streams-server`), which needs the raw +values, not the wrapper, and there's no public "raw connection" form of +`durableStreams()` and no public re-export of the low-level `dependency()` +authoring primitive an app could use to build one. Not a bug exactly — this +app predates the typed-client design and was never the intended consumer +shape — but it is a real gap for any app that wants to plug its own client +into a module-provisioned dependency. **Workaround (port-side, in +`src/composer/start.ts`, which is Composer launcher glue, not app business +logic):** read the `streams` dependency's two connection params directly +off the same address-free env channel `run()` re-stashes them onto, via the +public `configKey()` helper (`@prisma/composer-prisma-cloud`) — +`configKey('', { owner: { input: 'streams' }, name: 'url' | 'apiKey' })`. +This is the same "reconstruct the deploy-shaped env-var protocol by hand" +technique `scripts/dev.ts` already used for other values, just narrower now +that most of the surface has typed accessors. + +**Recommendation:** either a documented raw-connection form of +`durableStreams()` for apps with their own streams client, or a note in the +module's docs that a non-typed consumer has to fall back to `configKey()` +in launcher glue (worth writing down — this isn't obvious from the +compute()/durableStreams() API surface alone). + +## 3. `node()`'s directory form: a launcher's relative import baked at + build time can't resolve both "in-tree" and "as an assembled bundle" + +**Where hit:** switching `service.ts`'s `build` from the single-file form +to `node({ module, dir: '../../dist', entry: 'composer/start.js' })`. + +**Symptom:** `[chat] error: Cannot find module '../../dist/server/start.js' +from '.../bundle/composer/start.js'`. + +**Cause:** `src/composer/start.ts`'s last line was +`await import("../../dist/server/start.js")` — a path written relative to +the SOURCE file's own location (`src/composer/start.ts`, two directories +above the project's `dist/`), which only happens to resolve correctly when +this built file runs from its unmoved, original location +(`dist/composer/start.js`, itself two directories below the project root). +`node()`'s directory form copies the whole `dir` (`../../dist`) verbatim +into the deploy/dev bundle as one unit, so `composer/` and `server/` land +as *immediate siblings* inside it — but under whatever name the bundle +mount gets (a content-hashed artifact directory), not literally `dist`. A +build-time-literal specifier can't satisfy both "resolves to a real file so +`bun build --external` accepts it" (only the two-level-up path does, from +the source location) and "resolves correctly once assembled" (only a +one-level-up path does, from wherever `composer/start.js` ends up at +runtime) — the two locations differ by a directory level in exactly the +cases that matter (assembled bundle vs. in-place `dist/`). + +**Not a framework bug** — this is a pre-existing assumption in the port's +own launcher script, from before the directory-form adapter existed (see +`FRICTION.md` finding #3, "no build adapter fits an app whose built runnable +is a directory" — the fix that finding asked for). The old single-file +`node()` form never actually carried `dist/server/` into a deploy bundle at +all (finding #3's whole point); this bug was latent because nothing ran the +launcher from a MOVED location until directory-form assembly made that +possible. + +**Fix (port-side):** resolve the import at runtime, relative to the +launcher's own `import.meta.url`, instead of a build-time-literal specifier: + +```ts +const serverStartUrl = new URL("../server/start.js", import.meta.url); +await import(serverStartUrl.href); +``` + +One level up from `composer/` is `server/` in both the source-adjacent +`dist/` and the copied bundle, so this resolves correctly in both places. +Also dropped the now-unnecessary `bun build --external './dist/server/start.js'` +flag on `build:launcher` (a `new URL(...)`-computed specifier isn't a +literal bun's bundler statically resolves, so nothing needs excluding). + +## 4. Packing `@prisma/composer-prisma-cloud` as a tarball still let bun + resolve a stale, separately-sourced `@prisma/composer` underneath it + +**Where hit:** wiring the port to locally-built packages via `file:` +tarball dependencies. + +**Symptom:** after switching both `@prisma/composer` and +`@prisma/composer-prisma-cloud` to local `file:./vendor/*.tgz` tarballs and +running `bun install`, `node_modules/@prisma/composer-prisma-cloud/node_modules/@prisma/composer` +existed as a SEPARATE, older copy (missing the `dev` command's exports, +e.g. no `DEV_DIR` export) even though its `package.json` claimed the same +version (`0.2.0`) as the top-level tarball. + +**Cause:** `@prisma/composer-prisma-cloud`'s own `package.json` declares +`"@prisma/composer": "0.2.0"` (a plain semver, from `workspace:0.2.0` +rewritten at pack time) — bun apparently doesn't treat "top-level package +installed from a `file:` tarball" and "nested package's plain-semver +requirement of the same version" as provably the same artifact (different +resolution sources), so it kept a separately-resolved nested copy instead +of hoisting/deduping — most likely a stale one from an earlier `bun install` +against the pkg.pr.new preview, before this session switched to local +tarballs (`bun install` alone, even after `rm -rf node_modules bun.lock`, +reproduced it). + +**Fix (port-side):** added a top-level `"overrides": { "@prisma/composer": +"file:./vendor/prisma-composer-0.2.0.tgz" }` to `package.json`, which pins +every nested resolution too. Confirmed after a clean reinstall: no nested +`@prisma/composer` copy. + +**Recommendation:** not a framework bug — an operational note worth adding +to whatever local-testing doc tells a port author how to point their +`package.json` at a locally-built framework: use `overrides`/`resolutions` +alongside the direct `file:` dependency, not the direct dependency alone. + +## 5. `prisma-composer dev` doesn't run an app's own migrations + +**Where hit:** first sign-up attempt against a fresh dev instance. + +**Symptom:** `error: relation "user" does not exist` (Better Auth). + +**Cause:** by design — `dev` provisions the local Postgres instance and its +connection, but running an app's OWN migrations (as opposed to a +framework-run `PnMigration`, which this app doesn't use — module.ts's own +comment: "the app runs its own migrations") is squarely the app's job, same +as a deploy. `scripts/dev.ts` used to do this automatically +(`prisma-next db init`); `prisma-composer dev` has no equivalent hook. + +**Not a bug** — recorded because it's exactly the kind of thing an operator +would burn time on without a note. **Workaround:** run +`bunx prisma-next db init --db -y` +once per fresh instance. Documented in the port's README (see the S6 report). + +## 6. BLOCKING FRAMEWORK BUG — warm restart after Ctrl-C can leave every + service `stopped` while `prisma-composer dev` reports the app ready + +**Since FIXED on #164 — see the Update at the end of this finding. The +text below records the bug as found.** + +**Where hit:** proving criterion 6 (warm restart) against this port, +independently of the store's own S5 proving script. + +**Symptom:** after a clean Ctrl-C (`SIGINT`, `stopServices()` ran, CLI +exited 0), a second `prisma-composer dev module.ts` (no source edit) +converged with `Plan: 43 to noop` / `Done: 0 succeeded`, printed +`[dev] ready:` with all three services' URLs, and then every one of those +URLs refused connections. `GET /apps/open-chat/services` on the compute +emulator showed `chat`, `streams.service`, and `storage.service` all +`"status": "stopped"`. + +**Cause:** the local `Deployment` provider (`LocalDeploymentProvider` — at +the time in `@internal/lowering`'s `src/dev/compute.ts`; after the +localTarget rename it lives in `@internal/local-target`, +`packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts`) only +calls the emulator's +`PUT .../deployment` — which is what triggers the emulator's documented +"a stopped/held service always starts on a deployment PUT" rule — from +inside its `reconcile`. Alchemy itself decides whether to call `reconcile` +at all, by diffing the resource's PROPS (`artifactHash`, `env`, …) against +its last recorded apply; a Ctrl-C stop changes neither of those (it's a +side channel — the emulator's live process state, which Alchemy's own state +file never tracks), so Alchemy sees no diff, treats the resource as a +no-op, and never calls `reconcile` — never issuing the PUT that would have +restarted the service. + +**Why the existing S5 proving script (`test/integration/test/local-dev-store.integration.ts`) +doesn't catch this:** its own criterion-6 assertion checks that a warm +restart's front-door PORTS match the first session's (`assertEqual(..., +'criterion 6: warm restart keeps the same ports')`) and reads Postgres +DIRECTLY via a raw SQL connection (`withSql`), never an HTTP round-trip +against the actual restarted service — a stopped service still has a +persisted, stable port, so the port-equality assertion passes even though +nothing is listening on it. + +**Confirmed twice** against this port (a clean `--fresh` run works every +time — the deployment PUT is issued as a genuine `create`; only a +plain-restart-after-stop breaks) and traced to the mechanism above by +reading `Deployment.ts`'s reconcile function, `compute.ts`'s +`LocalDeploymentProvider`, and the emulator's own documented restart rule +(`compute-main.ts`'s `PUT .../deployment` semantics, spec § 2). + +**Not worked around here** — per this dispatch's instructions, a framework +bug that blocks the proof gets reported, not silently patched around. The +rest of this proof (sign-in/history/live-tail/generation-fails-as-expected) +was completed against a `--fresh` run instead, which is unaffected (every +deployment there is a genuine `create`, always reconciled). Documented in +`docs/design/10-domains/local-dev.md` ("Known gap (S6 finding, +unresolved)") and `ADR-0041` (a footnote on the same paragraph that +originally claimed "only the changed service restarts — Alchemy's diff +already limited which deployments were re-put"). + +**Recommendation:** either (a) `run-dev.ts`'s pipeline forces a converge +that always re-puts every deployment on a plain `dev` start (not just +`--fresh`), independent of Alchemy's own props diff, or (b) the local +`Deployment` provider grows an `observe` step that compares desired state +against the emulator's ACTUAL reported status (not just stored props) and +treats "provider says stopped, desired says running" as itself a diff. This +is very likely the SAME root cause across every local app, not just this +port — `examples/store`'s own criterion-6 check should be tightened to an +HTTP round-trip so a regression here is caught in the framework's own test +suite, not rediscovered by the next port. + +**Update (S6 close-out): FIXED on #164**, by a third route close to +recommendation (b)'s spirit: the attachment seam grew a session-resume +call. `LocalTargetAttachment.startServices()` ("start every stopped +service from its last deployment — a no-op converge cannot start +anything") is called by the dev command on every attachment after each +converge, before it prints the front door (`run-dev.ts`, step 8, with a +rollback to stopped on partial failure). A warm start therefore restarts +whatever the previous session's Ctrl-C stopped, regardless of Alchemy's +props diff. diff --git a/.drive/projects/local-dev/assets/open-chat-port/PORT.md b/.drive/projects/local-dev/assets/open-chat-port/PORT.md new file mode 100644 index 000000000..3ca238072 --- /dev/null +++ b/.drive/projects/local-dev/assets/open-chat-port/PORT.md @@ -0,0 +1,42 @@ +# What the open-chat port changed + +The port repo (`open-chat`) is owned by a different user and was not +writable from the proving session, so the work happened on a full copy +(including `.git`) and produced one real commit there. This file is the +reviewable summary of that commit; the raw diff lives in the copy, not in +this repo. + +One commit: **feat(composer): switch to `prisma-composer dev`, drop +`scripts/dev.ts`** — 10 files, +423/−621 (most of it `bun.lock` churn from +the dependency switch and the 169-line `scripts/dev.ts` deletion). + +By file: + +- `package.json` + `bun.lock` — point `@prisma/composer` and + `@prisma/composer-prisma-cloud` at locally packed tarballs (`file:` + deps plus `overrides`, so bun cannot resolve a stale nested copy of the + published version). This is the "framework under test" mechanism; a + published release would make it plain version bumps. +- `module.ts` + `src/composer/service.ts` — switch the chat service's + build descriptor to `node()`'s directory form: ship `dist/` as a whole + with entry `composer/start.js`, instead of a single bundled file. +- `src/composer/start.ts` — the launcher resolved its dynamic import of + the app server against a source-tree-relative path that only worked + when the file was never moved; it now resolves against its own runtime + location. Also: the typed `StreamsClient` has no raw url/apiKey + accessor, so the launcher reads those off the address-free env channel + via the public `configKey()` helper. +- `prisma-composer.config.ts` — `state:` takes a state descriptor + directly (`prismaState()`), not a thunk returning one; the port + predated that API settling. +- `module.ts` (streams) — the streams module no longer takes a `secrets` + option and the consumer needs no `streamsKey` slot: the bearer key + rides the `durableStreams()` dependency automatically (ADR-0031). +- `scripts/dev.ts` — deleted (169 lines). Its replacement is the whole + point: `prisma-composer dev module.ts`. The app's own fast hot-reload + loop (`bun run dev`) is untouched. +- `.gitignore`, `README.md`, `FRICTION.md` — ignore + `.prisma-composer/`/`.alchemy/`/`vendor/`, document the new dev + command, port-side friction notes. + +Every friction item hit on the way is in [FRICTION-S6.md](FRICTION-S6.md). diff --git a/docs/design/10-domains/deploy-cli.md b/docs/design/10-domains/deploy-cli.md index aa999fc3d..bbfb612a0 100644 --- a/docs/design/10-domains/deploy-cli.md +++ b/docs/design/10-domains/deploy-cli.md @@ -21,19 +21,26 @@ root's name names the application), ## Scope -Two commands: +Three commands: - **`prisma-composer deploy `** — deploy the application whose root node is `entry`'s default export, to a stage (default: production). - **`prisma-composer destroy `** — tear a stage down (same derivation, Alchemy destroy); the target stage is always explicit (see § Stages and containers). - -Flags: `--name` (override the root's name — per-run ephemeral deploys in -shared workspaces), `--stage ` (target a named, isolated environment -instead of production), `--production` (destroy-only — explicitly target the -production environment). Nothing else. `prisma-composer build`, `prisma-composer -dev`, and topology emission are out of scope (see § Out of scope). +- **`prisma-composer dev `** — bring up the application whose root node + is `entry`'s default export, entirely on this machine, credential-free — + local counterparts of every provisioned resource, real service processes, + no platform account. See [local-dev.md](local-dev.md) (ADR-0041) for the + design; flags: `--name` (same override semantics as deploy), `--fresh` + (tear down and reprovision every local instance before starting). + +Deploy/destroy flags: `--name` (override the root's name — per-run ephemeral +deploys in shared workspaces), `--stage ` (target a named, isolated +environment instead of production), `--production` (destroy-only — +explicitly target the production environment). `--stage`/`--production` do +not exist on `dev` (clipanion rejects them as unknown flags). `prisma-composer +build` and topology emission are out of scope (see § Out of scope). **Runtime.** The bin is runtime-agnostic — no bun-only APIs anywhere in the CLI or assembly code — so it runs under both bun and node (≥ 22.18, where @@ -209,9 +216,6 @@ The CLI's quality lives in its errors; each failure names its fix: ## Out of scope (designed around) - **`prisma-composer build`** — and with it any build-command convention or override. -- **`prisma-composer dev`** — the local loop; designed in - [local-dev.md](local-dev.md) (ADR-0041) as its own command re-running this - pipeline against local providers. - **Topology emission** — the serialized-topology artifact for agents/tooling; when it lands it must strip the machine-specific `build.module` (ADR-0004). - **Config-file escape hatch** — a `prisma-composer.config.ts` may exist one day as diff --git a/docs/design/10-domains/local-dev.md b/docs/design/10-domains/local-dev.md index 712af06db..e0f2698b1 100644 --- a/docs/design/10-domains/local-dev.md +++ b/docs/design/10-domains/local-dev.md @@ -3,7 +3,8 @@ The local dev loop: one command brings up the whole topology from the root module, credential-free, with deploy parity everywhere above the Alchemy provider boundary. The architectural decision — dev runs the deploy pipeline -against local providers, substituted through an extension's `dev` descriptor — +against local providers, substituted through an extension's `localTarget` +descriptor — is recorded in [ADR-0041](../90-decisions/ADR-0041-local-dev-runs-the-deploy-pipeline-against-local-providers.md); this doc is the mechanics. @@ -33,8 +34,9 @@ Dev re-runs [deploy's pipeline](deploy-cli.md#the-pipeline) with these deltas: 1. **Import + Load** — identical, same errors. 2. **Config** — identical, except: every extension in the config must carry a - `dev` descriptor; a missing one fails naming the extension ("`` has no - dev support"). The extension factory must resolve **no** platform + `localTarget` descriptor; a missing one fails naming the extension + ("`` has no dev support — it declares no `localTarget` descriptor"). + The extension factory must resolve **no** platform environment on this path (no workspace id, no region, no token). 3. **Assemble** — identical. Dev consumes the user's built output through the same adapters and produces the same bundles; missing output produces the @@ -79,9 +81,30 @@ emulator, which owns the processes: and remove. - **Ctrl-C on the dev command** → the attachment stops the app's service instances and detaches. Every emulator — Compute, Postgres instances, the - bucket emulator — stays up with its data; the next start is warm. - `--fresh` is what removes instances and data. (A detached mode where - services keep serving with no session is a designed extension, not v1.) + bucket emulator — stays up with its data; the next start reprovisions the + same instances (same ports, same data). `--fresh` is what removes + instances and data. (A detached mode where services keep serving with no + session is a designed extension, not v1.) + + **Known gap (S6 finding, fixed in #164):** a service actually only restarts + when its `Deployment` resource is re-put — and Alchemy skips calling the + provider at all when a resource's props (artifact hash, env) are + unchanged from its last recorded apply. A Ctrl-C stop is invisible to that + diff: nothing about the resource's *props* changed, only the process's + live status, which Alchemy's state file does not track. So a second + `prisma-composer dev` after a plain Ctrl-C can converge with everything + reported "noop" and leave every previously-stopped service `stopped` — + the CLI still prints `[dev] ready:` with each service's URL, but nothing + is listening on them. Confirmed against the open-chat port (`.drive/projects/local-dev/assets/open-chat-port/FRICTION-S6.md` + finding "warm-restart-noop"); the existing store proving script's own + criterion-6 check doesn't catch it because it asserts port *stability* + and reads Postgres directly, never an HTTP round-trip against the + restarted service. **Fixed in #164** by a session-resume call on the + attachment seam: `LocalTargetAttachment.startServices()` starts every + stopped service from its last deployment, and the dev command calls it + on every attachment after each converge, before printing the front door + (`run-dev.ts`) — so a warm start restarts what a previous session's + Ctrl-C stopped even when the converge is all-noop. The env materialization is the one platform-side behavior the local target implements itself: the hosted platform joins the branch's config variables @@ -215,7 +238,7 @@ Deploy's rule holds: every failure names its fix. | Failure | Error tells the user | | --- | --- | -| extension has no `dev` descriptor | which extension, and that it does not support local dev | +| extension has no `localTarget` descriptor | which extension, and that it does not support local dev | | built output missing | same as deploy: the expected path, "run your build" | | `bun` not on PATH | that dev runs services under bun (the Compute runtime) and how to install it | | no installed `prisma` bin (the local-Postgres emulator) | what was searched for and to add `prisma` to devDependencies | @@ -243,15 +266,34 @@ Deploy's rule holds: every failure names its fix. ## Open questions -- **Restart latency budget.** Assemble + package + converge per edit is - unmeasured. The artifact cache (unpack once per hash) is designed; whether - package's tar step needs a dev bypass for very large trees (Next standalone) - is a measurement away. Decide with numbers, not in advance. +(none outstanding from the design phase — see Known limitations below for +gaps found during implementation.) (Settled since the first draft: Postgres runs one named `prisma dev` instance per `Database` resource; the front door prints every service URL ordered by address depth then name, shallowest first; port allocation and the remaining -mechanics are pinned in the implementation spec.) +mechanics are pinned in the implementation spec. Restart latency is measured +— see Known limitations.) + +## Known limitations (found in implementation, S6) + +- **Restart latency: ~3.2s median** on one edit-rebuild-converge cycle + against `examples/store` (Apple M3 Max) — comfortably inside the + single-digit-seconds target. Method and full numbers: + `.drive/projects/local-dev/assets/latency.md`. +- **Warm-restart-after-Ctrl-C could leave services stopped** (S6 finding, + fixed in #164) — see the Compute-emulator section above ("Known gap"). +- **`Bundle.watch` was not populated everywhere during S6** — resolved: + both build adapters now populate it (node watches the entry file or the + whole `dir`; Next.js watches the standalone root), so the file-watch + loop picks rebuilds up on its own. A build descriptor that still returns + no `watch` entries reports `[dev]
has no watchable inputs` at + startup and needs its rebuilds triggered manually. +- **App-owned migrations are not run by `dev`** (by design — ADR-0022, spec + § 4's `PnMigration` line is for framework-run migrations only). An app that + runs its own migrations (e.g. via `prisma-next db init`, like the + open-chat port) needs that as a manual step against the local Postgres URL + on a fresh dev instance; `dev` does not know to run it automatically. ## Related diff --git a/docs/design/90-decisions/ADR-0041-local-dev-runs-the-deploy-pipeline-against-local-providers.md b/docs/design/90-decisions/ADR-0041-local-dev-runs-the-deploy-pipeline-against-local-providers.md index b2aad010f..d198ab0b5 100644 --- a/docs/design/90-decisions/ADR-0041-local-dev-runs-the-deploy-pipeline-against-local-providers.md +++ b/docs/design/90-decisions/ADR-0041-local-dev-runs-the-deploy-pipeline-against-local-providers.md @@ -6,34 +6,45 @@ credentials, by running the **same pipeline as `prisma-composer deploy`** — import the entry, Load the graph, assemble each service, lower, converge through Alchemy — with substitution at exactly one boundary: the Alchemy resource -**providers**. An extension declares its local counterparts on an optional `dev` -field of its descriptor, the same pattern as `container`/`preflight`/`teardown` -(ADR-0017, ADR-0038): +**providers**. An extension declares its local counterparts on an optional +`localTarget` field of its descriptor, the same pattern as +`container`/`preflight`/`teardown` (ADR-0017, ADR-0038). The field is a lazy +async thunk, never the descriptor object itself, so no deploy path ever +imports local-target implementation code: ```ts export interface ExtensionDescriptor { // ... existing fields unchanged ... - /** Local counterparts for `prisma-composer dev`. An extension without one is not dev-capable. */ - readonly dev?: DevDescriptor; + /** The extension's LOCAL TARGET counterpart — a lazy thunk. An extension without one is not local-target-capable (cannot back `prisma-composer dev`). */ + readonly localTarget?: () => Promise; } -export interface DevDescriptor { +export interface LocalTargetDescriptor { /** Local providers for the SAME resource types the extension's lowering emits — handed the app identity, since local providers are emulator clients. */ - providers(input: DevProvidersInput): ProvidersLayer; + providers(input: LocalTargetProvidersInput): Layer.Layer; /** A stable local identity — resolved without any platform call. */ readonly container: ContainerDescriptor; /** Dev value-sourcing policy: secrets from the shell else minted placeholders; env-sourced params from the shell else a hard error. */ preflight?(input: PreflightInput): Promise; /** Ensure the emulator daemons this topology's node kinds need are running (idempotent — they persist across sessions). */ - emulators?(input: DevEmulatorsInput): Promise; + emulators?(input: LocalTargetEmulatorsInput): Promise; /** The dev session's view of the running app: endpoints, merged logs, and the stop control. */ - attach(input: DevAttachInput): Promise; + attach(input: LocalTargetAttachInput): Promise; /** `--fresh`: remove every local trace of the dev instance — emulator instances, state, data. */ teardown?(input: TeardownInput): Promise; } ``` -`DevDescriptor` deliberately has **no `nodes` and no `provisions`**: the +(Naming, operator 2026-07-23: "dev" names user-facing surfaces only — the +`prisma-composer dev` command, the `[dev]` log prefix, the +`.prisma-composer/dev/` state dir. The seam takes the concept's real noun: +the field is `localTarget`, the core subpath is +`@prisma/composer/local-target` with `resolveLocalTargets` and +`localTargetProviders`, and the extension subpath is +`@prisma/composer-prisma-cloud/local-target` exporting +`localTargetDescriptor()`.) + +`LocalTargetDescriptor` deliberately has **no `nodes` and no `provisions`**: the lowering — node descriptors, address derivation, config serialization, provisioners — is byte-identical between dev and deploy. Dev cannot diverge the semantics of the graph; it can only substitute what the lowered resources *do*. @@ -91,9 +102,19 @@ streams, a stop control — and runs the watch loop (rebuild → re-assemble → re-converge). Core renders what the hook returns and never learns any emulator's API (the ADR-0038 opacity pattern). Ctrl-C stops the app's service instances through the attachment and exits; the emulators, the -databases, and the bucket data persist, making the next start warm. A -detached mode (services keep serving with no session, full platform parity) -is a designed extension, not v1. +databases, and the bucket data persist, making the next start warm — the +data and ports are warm. **What "falls out of Alchemy's own diffing" (above) +cuts both ways**, found while proving this ADR against a real app (S6): a +Ctrl-C stop changes a service's live status, not its `Deployment` resource's +props, so a subsequent `dev` with no source edit can converge as all-noop +and never re-put the stopped service — it stays down even though the CLI +reports the app ready. Fixed in #164: `LocalTargetAttachment` carries a +session-resume call, `startServices()`, which the dev command invokes on +every attachment after each converge, before printing the front door — so +a warm start restarts stopped services independent of Alchemy's props +diff. Details in [local-dev.md](../10-domains/local-dev.md). A detached +mode (services keep serving with no session, full platform parity) is a +designed extension, not v1. **Rebuilds stay the user's** (ADR-0005). Dev watches the *built* output that assembly consumes, never sources; the user's own watcher (`next dev` is not @@ -188,7 +209,8 @@ this repo owns. every supported app shape; the general directory-runnable build adapter is a prerequisite. Restart latency is bounded by assemble + package + converge; the local `Deployment` provider owns artifact caching (unpack once per hash). - Measure before optimizing further. + Measured (S6) at ~3.2s median for one edit-rebuild-converge cycle against + `examples/store` — see `.drive/projects/local-dev/assets/latency.md`. - **The extension factory must not require platform environment for dev.** The hosted factory's required workspace variable stays deploy-only; the `dev` path resolves no credentials at all. @@ -268,7 +290,7 @@ this repo owns. `localState()` through `LowerOptions.state` (tool state, ADR-0004). - [ADR-0017](ADR-0017-control-plane-loads-through-the-app-config.md) / [ADR-0038](ADR-0038-containers-are-an-extension-descriptor.md) — the - extension-descriptor pattern `dev` extends. + extension-descriptor pattern `localTarget` extends. - [ADR-0020](ADR-0020-scheduled-work-is-a-driver-not-a-resource.md) — why cron fires for real in dev. - [ADR-0029](ADR-0029-secrets-are-a-forwardable-slot.md) / diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index 1da172d4d..bec6a8b3a 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -62,4 +62,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0038](ADR-0038-containers-are-an-extension-descriptor.md) — Container lifecycle (ensure/locate/remove) is an optional `container` descriptor on `ExtensionDescriptor`, the same pattern as `preflight`/`teardown`; the resolved instance is opaque to core and crosses the CLI parent→alchemy child boundary as one framework-named environment variable per extension, via the extension's own `serialize()`/`deserialize()`. `StateDescriptor` names its owning extension so core can hand it that extension's resolved container. Deletes the `crossDomainExceptions` entry that let the CLI import `@internal/lowering` directly — `0-framework` imports nothing again. - [ADR-0039](ADR-0039-a-compute-services-own-origin-is-a-target-resolved-property.md) — A compute service's own platform-assigned origin is a target-resolved property, read as `ComputeService.origin()` — never a declared param, never operator config, never in `config()`. It rides ADR-0031's reserved provider-param channel as the first *service-derived* entry (`valueForService(provisioned, address)`, written for every compute service, exposing or not), sourced from the provisioned service's own `endpointDomain` — made trustworthy pre-promote by the upstream PRO-200 fix. `envParam(…)` remains correct for operator-known origins (custom domains); narrows ADR-0032's `appOrigin` example accordingly. - [ADR-0040](ADR-0040-the-pn-binding-carries-the-url-and-a-lazy-client.md) — `pnPostgres(contract)`'s dependency binding is `{ url, client }`: the raw connection string plus the typed client, constructed lazily and memoized on first `client` access — `hydrate` builds nothing. The contract remains the compatibility interface (hash check and deploy-time migration unchanged, ADR-0022); the binding becomes a strict superset of plain `postgres()`'s `{ url }`, so an app that owns its database client still gets framework-run migrations. Contract validation cost and failure move from `load()` (where one bad input poisoned every input, unattributed) to the first `client` access. Cross-kind satisfaction (`'prisma-next'` satisfying `'postgres'`) rejected in its favor. -- [ADR-0041](ADR-0041-local-dev-runs-the-deploy-pipeline-against-local-providers.md) — `prisma-composer dev` runs the **same deploy pipeline** (Load → assemble → lower → Alchemy converge) against local implementations of the same Alchemy resource types, declared on an optional `dev` field of `ExtensionDescriptor` (providers, container, preflight, emulators, attach, teardown — **no** `nodes`/`provisions`, so the lowering cannot diverge; no `state` either — dev uses Alchemy's own `localState()` through `LowerOptions.state`). The target runs **emulators per node kind**: Compute and buckets are machine-global, multi-tenant daemons (the Compute emulator owns the service child processes — deployment PUTs, crash supervision, logs; buckets serve the S3 wire over plain files on disk), while Postgres runs one detached ORM `prisma dev` instance per `Database` resource under the ORM CLI's own manager. Providers provision instances by communicating with the emulators during converge, and the dev command is a view through `attach`; `ServiceKey`/`S3Credentials`/`PgWarm`/`PnMigration` are shared verbatim. Credential-free by requirement. Rejects a local Management API (reimplements another team's server-side semantics, drifts silently) and per-kind dev descriptors (an open-set parallel seam). +- [ADR-0041](ADR-0041-local-dev-runs-the-deploy-pipeline-against-local-providers.md) — `prisma-composer dev` runs the **same deploy pipeline** (Load → assemble → lower → Alchemy converge) against local implementations of the same Alchemy resource types, declared on an optional `localTarget` field of `ExtensionDescriptor` (a lazy thunk resolving a `LocalTargetDescriptor`; subpaths `@prisma/composer/local-target` and `@prisma/composer-prisma-cloud/local-target` — "dev" names only the user-facing command/prefix/state dir) (providers, container, preflight, emulators, attach, teardown — **no** `nodes`/`provisions`, so the lowering cannot diverge; no `state` either — dev uses Alchemy's own `localState()` through `LowerOptions.state`). The target runs **emulators per node kind**: Compute and buckets are machine-global, multi-tenant daemons (the Compute emulator owns the service child processes — deployment PUTs, crash supervision, logs; buckets serve the S3 wire over plain files on disk), while Postgres runs one detached ORM `prisma dev` instance per `Database` resource under the ORM CLI's own manager. Providers provision instances by communicating with the emulators during converge, and the dev command is a view through `attach`; `ServiceKey`/`S3Credentials`/`PgWarm`/`PnMigration` are shared verbatim. Credential-free by requirement. Rejects a local Management API (reimplements another team's server-side semantics, drifts silently) and per-kind dev descriptors (an open-set parallel seam).