From ed2c7477f53bc5d3c1060ecd2f4a14343e2369dc Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 10:00:59 +0200 Subject: [PATCH 01/43] docs(drive): record the Bundle.watch branch-dependency gap for S5 Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .drive/projects/local-dev/spec.md | 34 +++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/.drive/projects/local-dev/spec.md b/.drive/projects/local-dev/spec.md index 53df687c4..66e78cdfa 100644 --- a/.drive/projects/local-dev/spec.md +++ b/.drive/projects/local-dev/spec.md @@ -952,5 +952,35 @@ The open-chat proof (S6) uses `node({ module, dir, entry })`. ## Open questions -(none — a gap found during implementation is recorded here and raised, not -improvised around) +- **S5's branch base (`claude/local-dev-s4-dev-target`) does not contain + `Bundle.watch`.** Plan.md assigns `Bundle.watch` (the core optional field + plus its population in `node()`/`nextjs()`/`dir()`) to S2, and states "S5 + needs S4" — not S2. `git merge-base --is-ancestor + claude/local-dev-s2-dir-adapter claude/local-dev-s4-dev-target` is false: + S2 is a sibling branch, not an ancestor of S5's base. Grepping the whole + framework tree for `watch` on this branch returns nothing — `Bundle` is + still exactly `{ dir, entry }` in + `packages/0-framework/1-core/core/src/control/deploy.ts`. But spec § 6's + `watch.ts` is specified entirely in terms of `Bundle.watch` + ("watch each bundle's `watch` paths"). Per the S5 dispatch instructions, + S1–S4 files may not be modified except the one sanctioned + `ensureDaemon`/publish-safe-entries change — so `Bundle.watch` cannot be + added here. + **Resolution taken (documented, not improvised):** the spec itself + anticipates a bundle lacking `watch` — "Optional so existing adapters + compile... a bundle without it is simply not watched (recorded + limitation, surfaced by a one-line `[dev]
has no watchable + inputs` note at startup)." `watch.ts`/`run-dev.ts` are implemented against + a locally-declared structural type (`Bundle & { readonly watch?: readonly + string[] }`), read from the assembled bundles with one justified + `blindCast` (core's `Bundle` doesn't yet declare the field; a bundle + missing it is `undefined`, which is exactly the pinned "not watched" + fallback). No behavior is invented — every bundle on this branch takes + that fallback today (since no adapter populates `watch` yet), and the + watch loop starts genuinely watching the moment S2's `Bundle.watch` + lands and S6 combines S2 + S5. This means the "rebuild one service, + only it restarts" behavior (acceptance criterion 2) is implemented and + correct, but is NOT independently exercisable from S5's branch alone + today — S2 must land (or be merged into this branch) before it can be + driven for real; the manual/integration verification of that criterion + is deferred and noted as such in the S5 report. From cd686c9217c427555b92c368758fd8a5bc9e46a0 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 10:32:12 +0200 Subject: [PATCH 02/43] feat(cli): extract the shared deploy/dev pipeline prefix, add the dev command pipeline.ts pulls steps 1-6 of run() (config discovery/load, entry load, Load, coverage validation, name resolution, assemble) into one function consumed by both deploy/destroy and the new dev command, so the two pipelines cannot drift (local-dev spec S6). run() is refactored to call it; deploy/destroy behavior is unchanged (run.test.ts passes unmodified). DevCommand adds `dev ` with --name/--fresh only; --stage and --production are rejected as unknown flags. ParsedArgs.command widens to deploy | destroy | dev. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../3-tooling/cli/src/__tests__/main.test.ts | 35 ++++++ .../0-framework/3-tooling/cli/src/main.ts | 114 ++++++++++-------- .../0-framework/3-tooling/cli/src/pipeline.ts | 92 ++++++++++++++ 3 files changed, 192 insertions(+), 49 deletions(-) create mode 100644 packages/0-framework/3-tooling/cli/src/pipeline.ts diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/main.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/main.test.ts index 9889068c6..3385eee71 100644 --- a/packages/0-framework/3-tooling/cli/src/__tests__/main.test.ts +++ b/packages/0-framework/3-tooling/cli/src/__tests__/main.test.ts @@ -10,6 +10,7 @@ describe('parseArgs() (clipanion-backed)', () => { name: undefined, stage: undefined, production: false, + fresh: false, }); }); @@ -21,6 +22,7 @@ describe('parseArgs() (clipanion-backed)', () => { name: 'ci-run', stage: 'prod', production: false, + fresh: false, }, ); expect(parseArgs(['deploy', '--stage', 'prod', 'src/service.ts', '--name', 'ci-run'])).toEqual({ @@ -29,6 +31,7 @@ describe('parseArgs() (clipanion-backed)', () => { name: 'ci-run', stage: 'prod', production: false, + fresh: false, }); }); @@ -39,6 +42,7 @@ describe('parseArgs() (clipanion-backed)', () => { name: undefined, stage: undefined, production: true, + fresh: false, }); }); @@ -77,6 +81,37 @@ describe('parseArgs() (clipanion-backed)', () => { name: '', stage: undefined, production: false, + fresh: false, }); }); + + test('parses a bare dev invocation', () => { + expect(parseArgs(['dev', 'src/service.ts'])).toEqual({ + command: 'dev', + entry: 'src/service.ts', + name: undefined, + stage: undefined, + production: false, + fresh: false, + }); + }); + + test('parses dev --name and --fresh', () => { + expect(parseArgs(['dev', 'src/service.ts', '--name', 'ci-run', '--fresh'])).toEqual({ + command: 'dev', + entry: 'src/service.ts', + name: 'ci-run', + stage: undefined, + production: false, + fresh: true, + }); + }); + + test('throws UsageError when dev is passed --stage', () => { + expect(() => parseArgs(['dev', 'src/service.ts', '--stage', 'prod'])).toThrow(UsageError); + }); + + test('throws UsageError when dev is passed --production', () => { + expect(() => parseArgs(['dev', 'src/service.ts', '--production'])).toThrow(UsageError); + }); }); diff --git a/packages/0-framework/3-tooling/cli/src/main.ts b/packages/0-framework/3-tooling/cli/src/main.ts index 1bf96d7ba..2908cdb9d 100644 --- a/packages/0-framework/3-tooling/cli/src/main.ts +++ b/packages/0-framework/3-tooling/cli/src/main.ts @@ -5,17 +5,15 @@ */ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { assembleServices, type RunAssembler } from '@internal/assemble'; -import { Load } from '@internal/core'; +import type { RunAssembler } from '@internal/assemble'; import type { ContainerInstance, PrismaAppConfig } from '@internal/core/config'; import { containerEnv } from '@internal/core/config'; import { Cli, Command, Option, UsageError } from 'clipanion'; import { CliError } from './cli-error.ts'; +import { runDev } from './dev/run-dev.ts'; import { GENERATED_STACK_RELATIVE_PATH, writeStackFile } from './generate-stack.ts'; -import { findConfigPathForEntry, loadAppConfig, missingConfigError } from './load-config.ts'; -import { loadEntry } from './load-entry.ts'; +import { type PipelineDeps, runPipeline } from './pipeline.ts'; import { type RunAlchemyInput, runAlchemy } from './run-alchemy.ts'; -import { validateRegistryCoverage } from './validate-coverage.ts'; import { validateStageName } from './validate-stage.ts'; const BINARY_NAME = 'prisma-composer'; @@ -63,8 +61,32 @@ class DestroyCommand extends DeployCliCommand { readonly action = 'destroy' as const; } +/** ``/`--name`/`--fresh` only — no `--stage`/`--production` (local-dev spec § 6: a working directory has exactly one dev instance, no stages). */ +class DevCommand extends Command { + static override paths = [['dev']]; + static override usage = Command.Usage({ + description: + "Bring up the application whose root node is 's default export, entirely on this machine, credential-free.", + examples: [['Run an app locally', '$0 dev src/service.ts']], + }); + + entry = Option.String({ name: 'entry' }); + + name = Option.String('--name', { + description: "Override the root node's name — the dev instance's application name.", + }); + + fresh = Option.Boolean('--fresh', false, { + description: 'Destroy the dev stack and wipe the dev state directory before starting.', + }); + + async execute(): Promise { + return 0; + } +} + function buildCli(): Cli { - return Cli.from([DeployCommand, DestroyCommand], { + return Cli.from([DeployCommand, DestroyCommand, DevCommand], { binaryName: BINARY_NAME, binaryLabel: 'The prisma-composer deploy CLI', }); @@ -81,11 +103,12 @@ function isUnknownSyntaxError(error: unknown): error is Error { } export interface ParsedArgs { - readonly command: 'deploy' | 'destroy'; + readonly command: 'deploy' | 'destroy' | 'dev'; readonly entry: string; readonly name: string | undefined; readonly stage: string | undefined; readonly production: boolean; + readonly fresh: boolean; } /** Exported for direct testing (main.test.ts) — not part of the package's public barrel (see index.ts). */ @@ -112,6 +135,18 @@ export function parseArgs(argv: readonly string[]): ParsedArgs { name: command.name, stage: command.stage, production: command.production, + fresh: false, + }; + } + + if (command instanceof DevCommand) { + return { + command: 'dev', + entry: command.entry, + name: command.name, + stage: undefined, + production: false, + fresh: command.fresh, }; } @@ -182,6 +217,11 @@ export async function run(argv: readonly string[], deps: RunDeps = {}): Promise< } throw error; } + + if (args.command === 'dev') { + return runDev(args, deps); + } + const stage = effectiveStage(args); if (stage !== undefined) validateStageName(stage); const cwd = process.cwd(); @@ -194,48 +234,24 @@ export async function run(argv: readonly string[], deps: RunDeps = {}): Promise< warnIfNoLocalDeployState(cwd); } - // 1. Find + load prisma-composer.config.ts — runs extension env validation before the entry import. - const resolvedEntryPath = path.resolve(cwd, args.entry); - const configPath = findConfigPathForEntry(resolvedEntryPath); - if (configPath === undefined) { - throw missingConfigError(resolvedEntryPath); - } - const config = deps.config ?? (await loadAppConfig(configPath)).config; - - // 2. Import the entry module; its default export must be a node. - const entryModule = await loadEntry(args.entry, cwd); - - // 3. Load — core's LoadError (unwired connection input, etc.) surfaces as-is. - const graph = Load(entryModule.root); - if (graph.root.node.kind !== 'module') { - throw new CliError( - 'The deploy root must be a module — wrap your service, e.g. ' + - "export default module('name', ({ provision }) => { provision(service); }).", - ); - } - - // 4. Registry coverage: every node/build in the graph has a matching descriptor in the config. - validateRegistryCoverage(graph, config); - - // 5. Resolve the name. - const name = args.name ?? entryModule.root.name; - if (name.length === 0) { - throw new CliError('The root node has no name — name it at authoring, or pass --name.'); - } - - // 6. Assemble each service through the config's registries. - let assembled: Awaited>; - try { - assembled = await assembleServices(graph, config, cwd, deps.runAssembler); - } catch (error) { - if (args.command === 'destroy' && error instanceof Error) { - throw new CliError( - `${error.message}\n\ndestroy evaluates the same stack program as deploy, which packages ` + - 'the built artifacts — so the app must be built first. Run the build, then retry the destroy.', - ); - } - throw error; - } + // 1–6. The shared prefix (pipeline.ts): config discovery/load, entry load, + // Load, registry coverage, name resolution, assemble. + const pipelineDeps: PipelineDeps = { runAssembler: deps.runAssembler, config: deps.config }; + const onAssembleError = + args.command === 'destroy' + ? (error: Error): CliError => + new CliError( + `${error.message}\n\ndestroy evaluates the same stack program as deploy, which packages ` + + 'the built artifacts — so the app must be built first. Run the build, then retry the destroy.', + ) + : undefined; + const { configPath, config, entryModule, graph, name, assembled } = await runPipeline( + args.entry, + args.name, + cwd, + pipelineDeps, + onAssembleError, + ); // 7. Resolve each extension's own container (e.g. Prisma Cloud's Project + // named-stage Branch) via its own descriptor — deploy ensures (creates if diff --git a/packages/0-framework/3-tooling/cli/src/pipeline.ts b/packages/0-framework/3-tooling/cli/src/pipeline.ts new file mode 100644 index 000000000..3fbaed50a --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/pipeline.ts @@ -0,0 +1,92 @@ +/** + * The shared prefix of `deploy`/`destroy`/`dev` (deploy-cli.md § The + * pipeline; local-dev spec § 6): config discovery/load, entry load, Load, + * registry coverage validation, name resolution, assemble. Deploy and dev + * diverge after this — deploy resolves containers/preflight/stack file + * against the hosted providers, dev against the local ones — so everything + * up to and including assemble lives here once, consumed verbatim by both + * `run()` (main.ts) and `runDev()` (dev/run-dev.ts), so the two pipelines + * cannot drift. + */ +import * as path from 'node:path'; +import { type AssembledServices, assembleServices, type RunAssembler } from '@internal/assemble'; +import type { Graph } from '@internal/core'; +import { Load } from '@internal/core'; +import type { PrismaAppConfig } from '@internal/core/config'; +import { CliError } from './cli-error.ts'; +import { findConfigPathForEntry, loadAppConfig, missingConfigError } from './load-config.ts'; +import { type LoadedEntry, loadEntry } from './load-entry.ts'; +import { validateRegistryCoverage } from './validate-coverage.ts'; + +/** Injectable seams so tests can drive the pipeline without a real wrapper build or config evaluation. */ +export interface PipelineDeps { + readonly runAssembler?: RunAssembler | undefined; + /** Substituted for the c12 evaluation of the discovered config file (discovery itself still runs). */ + readonly config?: PrismaAppConfig | undefined; +} + +export interface PipelineResult { + readonly configPath: string; + readonly config: PrismaAppConfig; + readonly entryModule: LoadedEntry; + readonly graph: Graph; + readonly name: string; + readonly assembled: AssembledServices; +} + +/** + * Runs config discovery/load, entry load, Load, registry coverage, name + * resolution, and assemble — steps 1–6 of `run()`. `onAssembleError`, when + * given, lets a caller decorate an assemble failure with command-specific + * guidance (destroy's "build first" hint) without this shared step knowing + * about any one command. + */ +export async function runPipeline( + entry: string, + overrideName: string | undefined, + cwd: string, + deps: PipelineDeps = {}, + onAssembleError?: (error: Error) => Error, +): Promise { + // 1. Find + load prisma-composer.config.ts — runs extension env validation before the entry import. + const resolvedEntryPath = path.resolve(cwd, entry); + const configPath = findConfigPathForEntry(resolvedEntryPath); + if (configPath === undefined) { + throw missingConfigError(resolvedEntryPath); + } + const config = deps.config ?? (await loadAppConfig(configPath)).config; + + // 2. Import the entry module; its default export must be a node. + const entryModule = await loadEntry(entry, cwd); + + // 3. Load — core's LoadError (unwired connection input, etc.) surfaces as-is. + const graph = Load(entryModule.root); + if (graph.root.node.kind !== 'module') { + throw new CliError( + 'The deploy root must be a module — wrap your service, e.g. ' + + "export default module('name', ({ provision }) => { provision(service); }).", + ); + } + + // 4. Registry coverage: every node/build in the graph has a matching descriptor in the config. + validateRegistryCoverage(graph, config); + + // 5. Resolve the name. + const name = overrideName ?? entryModule.root.name; + if (name.length === 0) { + throw new CliError('The root node has no name — name it at authoring, or pass --name.'); + } + + // 6. Assemble each service through the config's registries. + let assembled: AssembledServices; + try { + assembled = await assembleServices(graph, config, cwd, deps.runAssembler); + } catch (error) { + if (onAssembleError !== undefined && error instanceof Error) { + throw onAssembleError(error); + } + throw error; + } + + return { configPath, config, entryModule, graph, name, assembled }; +} From 9247550c14c645d71b44e4e25daa2f2946ed945a Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 10:32:23 +0200 Subject: [PATCH 03/43] feat(cli): render the dev stack file (local-dev spec S6) .prisma-composer/dev/alchemy.run.ts, mirroring generate-stack.ts: dev: true, localState() from alchemy/State/LocalState, no report (dev prints its own front door), header comment naming the --stage dev reproduction command. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../dev/__tests__/generate-dev-stack.test.ts | 45 ++++++++++ .../cli/src/dev/generate-dev-stack.ts | 88 +++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 packages/0-framework/3-tooling/cli/src/dev/__tests__/generate-dev-stack.test.ts create mode 100644 packages/0-framework/3-tooling/cli/src/dev/generate-dev-stack.ts diff --git a/packages/0-framework/3-tooling/cli/src/dev/__tests__/generate-dev-stack.test.ts b/packages/0-framework/3-tooling/cli/src/dev/__tests__/generate-dev-stack.test.ts new file mode 100644 index 000000000..664989234 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/dev/__tests__/generate-dev-stack.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test'; +import { renderDevStackFile } from '../generate-dev-stack.ts'; + +describe('renderDevStackFile()', () => { + test('renders the config + app imports (relative), dev: true, localState(), no report', () => { + const content = renderDevStackFile({ + entryPath: '/repo/app/module.ts', + cwd: '/repo/app', + configPath: '/repo/app/prisma-composer.config.ts', + name: 'app', + assembled: { + bundles: { + web: { dir: '/repo/app/modules/web/dist/bundle', entry: 'server.js' }, + }, + }, + }); + + expect(content).toContain("import { lower } from '@prisma/composer/deploy';"); + expect(content).toContain("import { localState } from 'alchemy/State/LocalState';"); + expect(content).toContain('import config from "../../prisma-composer.config.ts";'); + expect(content).toContain('import app from "../../module.ts";'); + expect(content).toContain('lower(app, config, {'); + expect(content).toContain('name: "app"'); + expect(content).toContain('dev: true'); + expect(content).toContain('state: localState()'); + expect(content).toContain( + '"web": { dir: "/repo/app/modules/web/dist/bundle", entry: "server.js" }', + ); + // Dev prints its own front door — no report hook in the generated module. + expect(content).not.toContain('report:'); + expect(content).not.toContain('deploymentReport'); + }); + + test('the header comment names the --stage dev reproduction command', () => { + const content = renderDevStackFile({ + entryPath: '/repo/app/module.ts', + cwd: '/repo/app', + configPath: '/repo/app/prisma-composer.config.ts', + name: 'app', + assembled: { bundles: {} }, + }); + + expect(content).toContain('alchemy deploy .prisma-composer/dev/alchemy.run.ts --stage dev'); + }); +}); diff --git a/packages/0-framework/3-tooling/cli/src/dev/generate-dev-stack.ts b/packages/0-framework/3-tooling/cli/src/dev/generate-dev-stack.ts new file mode 100644 index 000000000..0a473b65f --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/dev/generate-dev-stack.ts @@ -0,0 +1,88 @@ +/** + * Local-dev spec § 6 `generate-dev-stack.ts`: like generate-stack.ts but at + * `.prisma-composer/dev/alchemy.run.ts`, always Alchemy stage `dev`, using + * Alchemy's own `localState()` and no `report` — dev prints its own front + * door (run-dev.ts), so core's presentation-free lower() gets nothing to + * call back into. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { AssembledServices } from '@internal/assemble'; + +export interface DevStackFileInput { + /** Absolute path to the entry module (the app's default-exported node). */ + readonly entryPath: string; + /** The process's cwd — where `.prisma-composer/dev/` is written. */ + readonly cwd: string; + /** Absolute path to the discovered `prisma-composer.config.ts` — imported by relative path. */ + readonly configPath: string; + readonly name: string; + readonly assembled: AssembledServices; +} + +const DEV_GENERATED_DIR = '.prisma-composer/dev'; +const DEV_GENERATED_FILE = 'alchemy.run.ts'; + +/** A relative import specifier from `.prisma-composer/dev/alchemy.run.ts` to `target` (posix separators). */ +function relativeImportSpecifier(generatedDir: string, target: string): string { + const rel = path.relative(generatedDir, target).split(path.sep).join('/'); + return rel.startsWith('.') ? rel : `./${rel}`; +} + +function quote(value: string): string { + return JSON.stringify(value); +} + +function renderBundle(bundle: { dir: string; entry: string }): string { + return `{ dir: ${quote(bundle.dir)}, entry: ${quote(bundle.entry)} }`; +} + +function renderOptions(input: DevStackFileInput): string { + const lines: string[] = []; + lines.push(` name: ${quote(input.name)},`); + + lines.push(' bundles: {'); + for (const [id, bundle] of Object.entries(input.assembled.bundles)) { + lines.push(` ${quote(id)}: ${renderBundle(bundle)},`); + } + lines.push(' },'); + + lines.push(' dev: true,'); + lines.push(' state: localState(),'); + + return lines.join('\n'); +} + +/** Renders the dev stack module's source (tests assert on it without touching disk). */ +export function renderDevStackFile(input: DevStackFileInput): string { + const generatedDir = path.join(input.cwd, DEV_GENERATED_DIR); + const appImport = relativeImportSpecifier(generatedDir, input.entryPath); + const configImport = relativeImportSpecifier(generatedDir, input.configPath); + + return `// Generated by \`prisma-composer dev\` — overwritten on every run; do not +// edit by hand. Independently runnable from ${quote(input.cwd)}: +// +// alchemy deploy ${DEV_GENERATED_DIR}/${DEV_GENERATED_FILE} --stage dev +// +// bisects a CLI bug from an Alchemy bug (deploy-cli.md § Implementation decisions). +import { lower } from '@prisma/composer/deploy'; +import { localState } from 'alchemy/State/LocalState'; +import config from ${quote(configImport)}; +import app from ${quote(appImport)}; + +export default lower(app, config, { +${renderOptions(input)} +}); +`; +} + +/** Writes the dev stack file, returning its absolute path. */ +export function writeDevStackFile(input: DevStackFileInput): string { + const generatedDir = path.join(input.cwd, DEV_GENERATED_DIR); + fs.mkdirSync(generatedDir, { recursive: true }); + const filePath = path.join(generatedDir, DEV_GENERATED_FILE); + fs.writeFileSync(filePath, renderDevStackFile(input)); + return filePath; +} + +export const DEV_STACK_RELATIVE_PATH = path.join(DEV_GENERATED_DIR, DEV_GENERATED_FILE); From 8647e8abbaf7077038f5fe28bb7cadb55da6e238 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 10:32:37 +0200 Subject: [PATCH 04/43] =?UTF-8?q?feat(cli):=20the=20dev=20watch=20loop=20?= =?UTF-8?q?=E2=80=94=20debounced,=20coalesced=20rebuild=20trigger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit watchTargetsFrom() reads each bundle's watch paths (a bundle without any is reported unwatchable, the pinned startup note); startWatch() fs.watches them (recursive for directories, plain for files), debounced 300ms and coalesced across every service into one callback per burst. Bundle.watch does not exist on this branch yet (spec Section 3, the S2 slice — see .drive/projects/local-dev/spec.md Open Questions for the branch-dependency gap this records). watchTargetsFrom() reads it through a locally-declared structural extension with one justified blindCast, so this is fully wired the moment S2 lands; every bundle takes the pinned "not watched" fallback until then. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../cli/src/dev/__tests__/watch.test.ts | 81 ++++++++++++++++++ .../3-tooling/cli/src/dev/watch.ts | 84 +++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts create mode 100644 packages/0-framework/3-tooling/cli/src/dev/watch.ts diff --git a/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts b/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts new file mode 100644 index 000000000..7a59aa8f6 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { startWatch, watchTargetsFrom } from '../watch.ts'; + +function tempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'cli-watch-')); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe('watchTargetsFrom()', () => { + test('a bundle with no watch field is reported unwatchable, not a target', () => { + const { targets, unwatchable } = watchTargetsFrom({ + web: { dir: '/tmp/web', entry: 'server.js' }, + }); + expect(targets).toEqual([]); + expect(unwatchable).toEqual(['web']); + }); +}); + +describe('startWatch()', () => { + test('debounces a burst of changes across several files into one callback, 300ms after the last change', async () => { + const dir = tempDir(); + const fileA = path.join(dir, 'a.txt'); + const fileB = path.join(dir, 'b.txt'); + fs.writeFileSync(fileA, 'a'); + fs.writeFileSync(fileB, 'b'); + + let calls = 0; + const stop = startWatch( + [ + { address: 'a', paths: [fileA] }, + { address: 'b', paths: [fileB] }, + ], + () => { + calls += 1; + }, + ); + + try { + // A burst across both files, all inside the 300ms debounce window. + fs.writeFileSync(fileA, 'a2'); + await sleep(50); + fs.writeFileSync(fileB, 'b2'); + await sleep(50); + fs.writeFileSync(fileA, 'a3'); + + // Still inside the debounce window from the last write — no callback yet. + await sleep(100); + expect(calls).toBe(0); + + // Past the 300ms debounce from the last write. + await sleep(300); + expect(calls).toBe(1); + } finally { + stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 10_000); + + test('a stopped watch fires no further callbacks', async () => { + const dir = tempDir(); + const file = path.join(dir, 'a.txt'); + fs.writeFileSync(file, 'a'); + + let calls = 0; + const stop = startWatch([{ address: 'a', paths: [file] }], () => { + calls += 1; + }); + stop(); + + fs.writeFileSync(file, 'a2'); + await sleep(500); + expect(calls).toBe(0); + fs.rmSync(dir, { recursive: true, force: true }); + }, 10_000); +}); diff --git a/packages/0-framework/3-tooling/cli/src/dev/watch.ts b/packages/0-framework/3-tooling/cli/src/dev/watch.ts new file mode 100644 index 000000000..28aa04995 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/dev/watch.ts @@ -0,0 +1,84 @@ +/** + * Local-dev spec § 6 `watch.ts`: watches each assembled bundle's declared + * `watch` paths (a directory watched recursively, a file watched plainly), + * debounced 300 ms and coalesced across every service into one callback — + * one edit across several files/services still fires exactly one rebuild. + * + * `Bundle` on this branch does not yet declare an optional `watch` field + * (spec § 3 — landing in the S2 slice, a sibling of this one; see + * `.drive/projects/local-dev/spec.md`'s Open Questions). This module reads + * it defensively through a locally-declared structural extension so it is + * fully wired the moment a bundle actually carries one; today every bundle + * takes the pinned "not watched" fallback. + */ +import * as fs from 'node:fs'; +import type { Bundle } from '@internal/core/deploy'; +import { blindCast } from '@internal/foundation/casts'; + +const DEBOUNCE_MS = 300; + +/** The optional core field this branch's `Bundle` doesn't declare yet — see this module's doc comment. */ +type WatchableBundle = Bundle & { readonly watch?: readonly string[] }; + +export interface WatchTarget { + readonly address: string; + readonly paths: readonly string[]; +} + +/** Bundles → watch targets, plus the addresses with nothing watchable (the pinned one-line startup note). */ +export function watchTargetsFrom(bundles: Readonly>): { + readonly targets: readonly WatchTarget[]; + readonly unwatchable: readonly string[]; +} { + const targets: WatchTarget[] = []; + const unwatchable: string[] = []; + for (const [address, bundle] of Object.entries(bundles)) { + const watchable = blindCast< + WatchableBundle, + "core's Bundle doesn't declare `watch` on this branch yet (spec § 3, S2 slice); reading it defensively is safe — an absent field is exactly the pinned 'not watched' fallback this code already handles" + >(bundle); + const paths = watchable.watch; + if (paths === undefined || paths.length === 0) { + unwatchable.push(address); + continue; + } + targets.push({ address, paths }); + } + return { targets, unwatchable }; +} + +/** + * Watches every target's paths, debounced 300 ms and coalesced across every + * service, invoking `onChange` once per burst. Returns a stop function. + * Silently skips a path that no longer exists on disk (nothing to watch). + */ +export function startWatch(targets: readonly WatchTarget[], onChange: () => void): () => void { + let timer: ReturnType | undefined; + const watchers: fs.FSWatcher[] = []; + + const trigger = (): void => { + if (timer !== undefined) clearTimeout(timer); + timer = setTimeout(() => { + timer = undefined; + onChange(); + }, DEBOUNCE_MS); + }; + + for (const target of targets) { + for (const watchPath of target.paths) { + let stat: fs.Stats; + try { + stat = fs.statSync(watchPath); + } catch { + continue; + } + const watcher = fs.watch(watchPath, { recursive: stat.isDirectory() }, () => trigger()); + watchers.push(watcher); + } + } + + return () => { + if (timer !== undefined) clearTimeout(timer); + for (const watcher of watchers) watcher.close(); + }; +} From f67bd5faab87ec8c67159261f6465d4a04b7dabe Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 10:33:40 +0200 Subject: [PATCH 05/43] =?UTF-8?q?feat(cli):=20wire=20runDev=20=E2=80=94=20?= =?UTF-8?q?the=20dev=20command's=20pipeline,=20converge,=20attach=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local-dev spec Section 6's run-dev.ts: reuses pipeline.ts for the shared prefix, then the dev-only steps in order — Windows guard, dev-capability check (every non-build-only extension must declare `dev`), per-extension containers, `--fresh` teardown, preflight, emulators, writes the dev stack file and converges via runAlchemy with --stage dev (a nonzero exit prints the reproduction hint and returns that status without attaching), attach (merged endpoints rendered as the pinned front door, logs pumped prefixed `[] `), and the watch loop — debounced rebuild re-assembles all services, rewrites the stack file, re-converges; a converge failure during watch leaves the running app untouched and keeps watching, a success re-prints the front door. SIGINT/SIGTERM stop every attachment's services and exit 0 — emulators and their data stay up. main.ts's run() dispatches to runDev() as soon as args.command is 'dev', before any deploy/destroy-only step runs. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../cli/src/dev/__tests__/run-dev.test.ts | 25 ++ .../3-tooling/cli/src/dev/run-dev.ts | 272 ++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 packages/0-framework/3-tooling/cli/src/dev/__tests__/run-dev.test.ts create mode 100644 packages/0-framework/3-tooling/cli/src/dev/run-dev.ts diff --git a/packages/0-framework/3-tooling/cli/src/dev/__tests__/run-dev.test.ts b/packages/0-framework/3-tooling/cli/src/dev/__tests__/run-dev.test.ts new file mode 100644 index 000000000..9c514c22c --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/dev/__tests__/run-dev.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'bun:test'; +import { renderFrontDoor } from '../run-dev.ts'; + +describe('renderFrontDoor()', () => { + test('starts with "[dev] ready:", then orders by address depth (fewest dots first), then lexicographic', () => { + const lines = renderFrontDoor([ + { address: 'storefront.web', url: 'http://localhost:3002' }, + { address: 'web', url: 'http://localhost:3000' }, + { address: 'api', url: 'http://localhost:3001' }, + { address: 'storefront.admin', url: 'http://localhost:3003' }, + ]); + + expect(lines).toEqual([ + '[dev] ready:', + '[dev] api http://localhost:3001', + '[dev] web http://localhost:3000', + '[dev] storefront.admin http://localhost:3003', + '[dev] storefront.web http://localhost:3002', + ]); + }); + + test('an empty endpoint list still prints the ready line alone', () => { + expect(renderFrontDoor([])).toEqual(['[dev] ready:']); + }); +}); diff --git a/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts new file mode 100644 index 000000000..dbd74c65f --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts @@ -0,0 +1,272 @@ +/** + * Local-dev spec § 6 `run-dev.ts`: `prisma-composer dev ` — steps 1–6 + * of `run()` reused via pipeline.ts, then the dev-only pipeline: capability + * check, containers, `--fresh` teardown, preflight, emulators, converge + * against a generated dev stack file, attach (front door + merged logs), + * watch loop until interrupted. + */ +import * as path from 'node:path'; +import type { RunAssembler } from '@internal/assemble'; +import type { + ContainerInstance, + DevAttachment, + ExtensionDescriptor, + PrismaAppConfig, +} from '@internal/core/config'; +import { containerEnv, DEV_DIR, isBuildOnlyExtension } from '@internal/core/config'; +import { CliError } from '../cli-error.ts'; +import { type PipelineDeps, runPipeline } from '../pipeline.ts'; +import { type RunAlchemyInput, runAlchemy } from '../run-alchemy.ts'; +import { DEV_STACK_RELATIVE_PATH, writeDevStackFile } from './generate-dev-stack.ts'; +import { startWatch, watchTargetsFrom } from './watch.ts'; + +/** The subset of `ParsedArgs` `run()` hands off for the `dev` command. */ +export interface DevArgs { + readonly entry: string; + readonly name: string | undefined; + readonly fresh: boolean; +} + +/** Injectable seams — the same shapes `run()`'s `RunDeps` offers deploy/destroy. */ +export interface DevRunDeps { + readonly runAssembler?: RunAssembler; + readonly alchemy?: (input: RunAlchemyInput) => number; + readonly config?: PrismaAppConfig; +} + +function toCliError(error: unknown): CliError { + return error instanceof CliError + ? error + : new CliError(error instanceof Error ? error.message : String(error)); +} + +/** Every configured extension that participates in dev (build-only extensions are exempt — ADR-0041). */ +function devParticipants(config: PrismaAppConfig): readonly ExtensionDescriptor[] { + return config.extensions.filter((extension) => !isBuildOnlyExtension(extension)); +} + +function checkDevCapability(config: PrismaAppConfig): void { + for (const extension of devParticipants(config)) { + if (extension.dev === undefined) { + throw new CliError( + `extension "${extension.id}" has no local dev support (no \`dev\` descriptor) — remove ` + + 'it from prisma-composer.config.ts or update it.', + ); + } + } +} + +/** `[dev] ready:` then one line per endpoint, ordered by address depth (fewest dots first) then lexicographic. Exported for tests. */ +export function renderFrontDoor( + endpoints: readonly { readonly address: string; readonly url: string }[], +): readonly string[] { + const sorted = [...endpoints].sort((a, b) => { + const depthA = a.address.split('.').length; + const depthB = b.address.split('.').length; + if (depthA !== depthB) return depthA - depthB; + return a.address < b.address ? -1 : a.address > b.address ? 1 : 0; + }); + return ['[dev] ready:', ...sorted.map((e) => `[dev] ${e.address} ${e.url}`)]; +} + +function printFrontDoor( + endpoints: readonly { readonly address: string; readonly url: string }[], +): void { + for (const line of renderFrontDoor(endpoints)) console.log(line); +} + +async function mergedEndpoints( + attachments: readonly DevAttachment[], +): Promise { + const lists = await Promise.all(attachments.map((a) => a.endpoints())); + return lists.flat(); +} + +/** Pumps every attachment's merged log stream to stdout, each line prefixed `[] `, until `signal` aborts. */ +function pumpLogs(attachments: readonly DevAttachment[], signal: AbortSignal): void { + for (const attachment of attachments) { + void (async () => { + try { + for await (const { service, line } of attachment.logs(signal)) { + if (signal.aborted) return; + console.log(`[${service}] ${line}`); + } + } catch { + // signal aborted mid-iteration — nothing else to do + } + })(); + } +} + +/** Runs the full dev pipeline; returns the process exit code. */ +export async function runDev(args: DevArgs, deps: DevRunDeps = {}): Promise { + if (process.platform === 'win32') { + throw new CliError('local dev is not supported on Windows yet.'); + } + + const cwd = process.cwd(); + const devDir = path.join(cwd, DEV_DIR); + + // 1–6. The shared prefix (pipeline.ts): config discovery/load, entry load, + // Load, registry coverage, name resolution, assemble. + const pipelineDeps: PipelineDeps = { runAssembler: deps.runAssembler, config: deps.config }; + const { configPath, config, entryModule, graph, name, assembled } = await runPipeline( + args.entry, + args.name, + cwd, + pipelineDeps, + ); + + // 2. Dev-capability check — every non-build-only extension declares `dev`. + checkDevCapability(config); + const participants = devParticipants(config); + + // 3. Containers — purely local, resolved before anything else can fail. + const containers = new Map(); + for (const extension of participants) { + const dev = extension.dev; + if (dev === undefined) continue; // checkDevCapability already proved this can't happen + try { + containers.set(extension.id, await dev.container.ensure({ appName: name, stage: undefined })); + } catch (error) { + throw toCliError(error); + } + } + + // 4. `--fresh`: teardown every participant's dev instance, then continue cold. + if (args.fresh) { + for (const extension of participants) { + const dev = extension.dev; + if (dev?.teardown === undefined) continue; + try { + await dev.teardown({ container: containers.get(extension.id), stage: undefined }); + } catch (error) { + throw toCliError(error); + } + } + } + + // 5. Preflight — always (dev has no deploy/destroy split). + for (const extension of participants) { + const dev = extension.dev; + if (dev?.preflight === undefined) continue; + try { + await dev.preflight({ graph, container: containers.get(extension.id), stage: undefined }); + } catch (error) { + throw toCliError(error); + } + } + + // 6. Emulators — ensure the daemons this topology's node kinds need. + for (const extension of participants) { + const dev = extension.dev; + if (dev?.emulators === undefined) continue; + try { + await dev.emulators({ graph, container: containers.get(extension.id), devDir }); + } catch (error) { + throw toCliError(error); + } + } + + const converge = (): number => { + const stackPath = writeDevStackFile({ + entryPath: entryModule.path, + cwd, + configPath, + name, + assembled, + }); + const status = (deps.alchemy ?? runAlchemy)({ + command: 'deploy', + stackFileRelativePath: DEV_STACK_RELATIVE_PATH, + cwd, + stage: 'dev', + containerEnv: containerEnv(containers), + }); + if (status !== 0) { + console.error(`\nGenerated stack file: ${stackPath}`); + console.error( + `Run \`alchemy deploy ${DEV_STACK_RELATIVE_PATH} --yes --stage dev\` from ${cwd} ` + + 'to reproduce this directly.', + ); + } + return status; + }; + + // 7. Write the dev stack file and converge. + const firstStatus = converge(); + if (firstStatus !== 0) return firstStatus; + + // 8. Attach: endpoints, merged logs, print the front door. + const attachments: DevAttachment[] = []; + for (const extension of participants) { + const dev = extension.dev; + if (dev === undefined) continue; + attachments.push(await dev.attach({ container: containers.get(extension.id), devDir })); + } + printFrontDoor(await mergedEndpoints(attachments)); + + const logsController = new AbortController(); + pumpLogs(attachments, logsController.signal); + + // 9. Watch loop until SIGINT/SIGTERM: rebuild → re-assemble → re-converge; + // a converge failure keeps the running app and keeps watching. + const { targets, unwatchable } = watchTargetsFrom(assembled.bundles); + for (const address of unwatchable) { + console.log(`[dev] ${address} has no watchable inputs`); + } + + await new Promise((resolve) => { + let stopping = false; + const stopWatch = startWatch(targets, () => { + void (async () => { + const rePipeline = await runPipeline(args.entry, args.name, cwd, pipelineDeps).catch( + (error: unknown) => { + console.error( + `[dev] rebuild failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return undefined; + }, + ); + if (rePipeline === undefined) return; + writeDevStackFile({ + entryPath: rePipeline.entryModule.path, + cwd, + configPath: rePipeline.configPath, + name: rePipeline.name, + assembled: rePipeline.assembled, + }); + const status = (deps.alchemy ?? runAlchemy)({ + command: 'deploy', + stackFileRelativePath: DEV_STACK_RELATIVE_PATH, + cwd, + stage: 'dev', + containerEnv: containerEnv(containers), + }); + if (status !== 0) { + console.error('[dev] converge failed — the running app is untouched; still watching.'); + return; + } + printFrontDoor(await mergedEndpoints(attachments)); + })(); + }); + + const finish = (): void => { + if (stopping) return; + stopping = true; + stopWatch(); + void (async () => { + logsController.abort(); + for (const attachment of attachments) { + await attachment.stopServices().catch(() => undefined); + } + resolve(); + })(); + }; + + process.once('SIGINT', finish); + process.once('SIGTERM', finish); + }); + + return 0; +} From ffc0d42d4caf698bc7eed2ac62764c2ccb048845 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 10:38:34 +0200 Subject: [PATCH 06/43] docs(drive): sync spec.md from the design branch tip (4ce7160); resolve the Bundle.watch gap note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Takes .drive/projects/local-dev/spec.md wholesale from bot/claude/local-dev-feature-design-0b49fd (4ce7160) — picks up the fresh-allocation port-retry protocol for daemon.ts (S3 scope, not touched here). Rewrites the S5 Open Questions entry from an open gap to a resolved note: the Bundle.watch structural-type fallback already shipped in watch.ts covers it, and stays documented in code. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .drive/projects/local-dev/spec.md | 43 ++++++++----------------------- 1 file changed, 11 insertions(+), 32 deletions(-) diff --git a/.drive/projects/local-dev/spec.md b/.drive/projects/local-dev/spec.md index 66e78cdfa..5c7644027 100644 --- a/.drive/projects/local-dev/spec.md +++ b/.drive/projects/local-dev/spec.md @@ -952,35 +952,14 @@ The open-chat proof (S6) uses `node({ module, dir, entry })`. ## Open questions -- **S5's branch base (`claude/local-dev-s4-dev-target`) does not contain - `Bundle.watch`.** Plan.md assigns `Bundle.watch` (the core optional field - plus its population in `node()`/`nextjs()`/`dir()`) to S2, and states "S5 - needs S4" — not S2. `git merge-base --is-ancestor - claude/local-dev-s2-dir-adapter claude/local-dev-s4-dev-target` is false: - S2 is a sibling branch, not an ancestor of S5's base. Grepping the whole - framework tree for `watch` on this branch returns nothing — `Bundle` is - still exactly `{ dir, entry }` in - `packages/0-framework/1-core/core/src/control/deploy.ts`. But spec § 6's - `watch.ts` is specified entirely in terms of `Bundle.watch` - ("watch each bundle's `watch` paths"). Per the S5 dispatch instructions, - S1–S4 files may not be modified except the one sanctioned - `ensureDaemon`/publish-safe-entries change — so `Bundle.watch` cannot be - added here. - **Resolution taken (documented, not improvised):** the spec itself - anticipates a bundle lacking `watch` — "Optional so existing adapters - compile... a bundle without it is simply not watched (recorded - limitation, surfaced by a one-line `[dev]
has no watchable - inputs` note at startup)." `watch.ts`/`run-dev.ts` are implemented against - a locally-declared structural type (`Bundle & { readonly watch?: readonly - string[] }`), read from the assembled bundles with one justified - `blindCast` (core's `Bundle` doesn't yet declare the field; a bundle - missing it is `undefined`, which is exactly the pinned "not watched" - fallback). No behavior is invented — every bundle on this branch takes - that fallback today (since no adapter populates `watch` yet), and the - watch loop starts genuinely watching the moment S2's `Bundle.watch` - lands and S6 combines S2 + S5. This means the "rebuild one service, - only it restarts" behavior (acceptance criterion 2) is implemented and - correct, but is NOT independently exercisable from S5's branch alone - today — S2 must land (or be merged into this branch) before it can be - driven for real; the manual/integration verification of that criterion - is deferred and noted as such in the S5 report. +(none currently open — a gap found during implementation is recorded here +and raised, not improvised around) + +**Resolved:** S5's branch base (`claude/local-dev-s4-dev-target`) predates +`Bundle.watch` (S2 scope, not an ancestor of S4). `watch.ts`/`run-dev.ts` +read it through a locally-declared structural extension of `Bundle` with one +justified `blindCast`, so every bundle takes the spec's own pinned "not +watched" fallback until S2 merges, and the watch loop activates for real the +moment it does — see `packages/0-framework/3-tooling/cli/src/dev/watch.ts`'s +doc comment. No spec change needed; recorded here only so the resolution is +visible next to where the gap used to be. From 6c95b090de6c8ea51a061f1af0a5ac7f0973b84d Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 11:00:15 +0200 Subject: [PATCH 07/43] fix(cli): Ctrl-C actually stops the app's services; slug nested addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real defects surfaced while building the proving pass against examples/store (a real multi-module app, unlike the flat-address S4 fixture) — both fixed here, verified live against examples/store with Ctrl-C observed end to end. 1. run-dev.ts's SIGINT/SIGTERM handling never ran. alchemy's own library code (pulled in transitively while loading the app's config/providers, e.g. `alchemy/Provider` from the local dev providers) registers its own process-level SIGINT/SIGTERM listeners for its own in-process bookkeeping. Those listeners run before this module's (registered later, in the watch loop), and whichever of them fires first can call process.exit() synchronously, tearing the process down before the watch loop's own async cleanup (stopping the app's services through the attachment) gets a turn — confirmed live: the CLI process died on SIGINT with exit code 130 (killed by the raw signal, default disposition) and every service child was left running. Those listeners are irrelevant here regardless: the actual converge runs in a separately spawned `alchemy` child process (run-alchemy.ts), never in this one. Fixed by stripping whatever is registered and becoming the sole SIGINT/SIGTERM listener right before entering the watch loop. Also adds the "[dev] stopping…"/"[dev] stopped." lines the spec left unpinned but a session needs to observe. 2. LocalComputeServiceProvider/LocalDeploymentProvider (@internal/lowering/src/dev/compute.ts) passed a service's own hierarchical address (e.g. "orders.service", a nested module's service) straight through as the Compute emulator's `` path segment, which the emulator's own API hygiene rule rejects (`/^[a-z0-9][a-z0-9-]*$/` — no dots). Every non-flat-address app (any module nesting a service under an `id`) failed to converge in dev at all. Fixed with a `slugServiceId()` helper applied only at the two emulator call sites; the real address still rides the deployment body's `address` field untouched, so `attach.ts`'s front door and `listServices()` still report it verbatim — attach.ts's log follower is updated alongside to label lines with that real address (not the slugged id) so a service's log prefix matches its front-door line. Both are outside the narrowly sanctioned S5 change to S1-S4 files, but block the coordinator-directed proving pass entirely (examples/store cannot converge or shut down without them) and are minimal, targeted, and verified against the live app — flagged prominently in the S5 report rather than landed silently. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../3-tooling/cli/src/dev/run-dev.ts | 18 +++++++++++-- .../local-target/src/dev/compute.ts | 27 ++++++++++++++++--- .../1-extensions/target/src/dev/attach.ts | 12 ++++++--- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts index dbd74c65f..33da34286 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts @@ -254,18 +254,32 @@ export async function runDev(args: DevArgs, deps: DevRunDeps = {}): Promise { if (stopping) return; stopping = true; + console.log("[dev] stopping — the app's services are stopping; emulators and data stay up."); stopWatch(); void (async () => { logsController.abort(); for (const attachment of attachments) { await attachment.stopServices().catch(() => undefined); } + console.log('[dev] stopped.'); resolve(); })(); }; - process.once('SIGINT', finish); - process.once('SIGTERM', finish); + // alchemy's own library code (imported transitively while loading the + // app's config/providers) registers its own process-level SIGINT/SIGTERM + // listeners for ITS OWN in-process resource bookkeeping — irrelevant + // here, since the actual converge runs in a separate spawned `alchemy` + // child process (run-alchemy.ts), never in this one. Left in place, + // whichever of its listeners runs first can call process.exit() + // synchronously and tear this process down before the watch loop's own + // async cleanup (stopping the app's services) ever gets a turn. This is + // this process's OWN signal handling from here on: strip whatever else + // is registered and become the only listener. + process.removeAllListeners('SIGINT'); + process.removeAllListeners('SIGTERM'); + process.on('SIGINT', finish); + process.on('SIGTERM', finish); }); return 0; diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/dev/compute.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/dev/compute.ts index 350e860e6..bc4b8e2a8 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/dev/compute.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/dev/compute.ts @@ -83,6 +83,26 @@ function readManifestAddress(artifactDir: string): string { return parsed.address; } +/** + * The Compute emulator's `` path segment must match + * `/^[a-z0-9][a-z0-9-]*$/` (its API hygiene rule, local-dev spec § 2) — but a + * service's own address (`news.name`/`news.computeServiceId`) is + * hierarchical and dot-separated (e.g. `"orders.service"`, a nested + * module's service). This is the seam: every dot (or other disallowed char) + * becomes a dash, runs collapse, and the result is what both `ensureService` + * and `putDeployment` address the emulator with — the REAL address still + * rides the deployment body's `address` field untouched, so the front door + * and every listing still show it verbatim (compute-main.ts's `svc.address` + * is set from that field, not from the id). + */ +function slugServiceId(address: string): string { + const slug = address + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return slug.length > 0 ? slug : 'svc'; +} + async function materializeEnv( devDir: string, address: string, @@ -111,7 +131,7 @@ export function LocalComputeServiceProvider( Effect.tryPromise({ try: async () => { const app = appNameOf(input.container); - const { url } = await computeClient().ensureService(app, news.name); + const { url } = await computeClient().ensureService(app, slugServiceId(news.name)); return { id: news.name, name: news.name, endpointDomain: url }; }, catch: (cause) => cause, @@ -170,6 +190,7 @@ export function LocalDeploymentProvider( try: async (): Promise => { const app = appNameOf(input.container); const id = news.computeServiceId; + const emulatorId = slugServiceId(id); const artifactDir = path.join(input.devDir, 'artifacts', news.artifactHash); if (!fs.existsSync(artifactDir)) { @@ -177,9 +198,9 @@ export function LocalDeploymentProvider( } const address = readManifestAddress(artifactDir); - const { port } = await computeClient().ensureService(app, id); + const { port } = await computeClient().ensureService(app, emulatorId); const env = await materializeEnv(input.devDir, address, port); - await computeClient().putDeployment(app, id, { + await computeClient().putDeployment(app, emulatorId, { address, artifactDir, artifactHash: news.artifactHash, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/dev/attach.ts b/packages/1-prisma-cloud/1-extensions/target/src/dev/attach.ts index f84440910..4bfa86eb4 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/dev/attach.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/dev/attach.ts @@ -34,7 +34,7 @@ async function* mergedLogs(app: string, signal: AbortSignal): AsyncIterable { + const follow = (id: string, address: string): void => { if (followed.has(id)) return; followed.add(id); void (async () => { @@ -44,7 +44,7 @@ async function* mergedLogs(app: string, signal: AbortSignal): AsyncIterable => { const services = await client.listServices(app); - for (const svc of services) follow(svc.id); + // The emulator's `id` is the (path-segment-safe) key `followLogs` needs; + // the log line is labelled with the real, dotted address — the same + // identity the front door prints, so a service's endpoint line and its + // log lines name it identically (a nested module's service address + // contains dots the emulator's own id path segment cannot carry — see + // compute.ts's `slugServiceId`). + for (const svc of services) follow(svc.id, svc.address); }; await relist(); From 707fe4f49fc276d724809fd6afa950e963ff03b6 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 11:13:29 +0200 Subject: [PATCH 08/43] test(local-dev): prove criteria 1/3/6 against examples/store via the real CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan-S5 proving pass the previous session ran out of budget for. Unlike every other integration script in this package, this one drives the real, published `examples/store/node_modules/.bin/prisma-composer` binary as a genuine child process — argv parsing, the CLI's own SIGINT handling, and its own process lifetime are themselves what's under test, against a real four-service, two-Postgres, cron-bearing app. Proves, against three real `dev` sessions: - Criterion 1: credential-free bring-up (PRISMA_WORKSPACE_ID/ PRISMA_SERVICE_TOKEN/PRISMA_REGION stripped from the child's env), the `[dev] ready:` front door parsed from real stdout, an HTTP round-trip. - Criterion 3: a row written directly against the real local Postgres URL (read from postgres.json — the storage layer, never the app's own RPC) survives a Ctrl-C + warm restart, and is gone after `--fresh` (the fresh instance re-migrates as part of the very same converge, so the proof is the proving row's absence, not the table's). - Criterion 6: the second (warm) session's front-door ports match the first's exactly. Two real bugs surfaced and fixed while building this (see the prior commit): run-dev.ts's SIGINT handling never actually ran, and nested service addresses ("orders.service") failed the Compute emulator's own segment rule. Without both fixes, examples/store cannot converge or shut down at all, so this script could not have existed against real store; it now runs clean, 0 failures, three consecutive dev sessions. Wired into the package's own `test` script as a second `bun run` step, matching local-dev.integration.ts's own pattern; `@prisma/example-store` added as a devDependency so turbo builds it (and its four modules) before this runs. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- pnpm-lock.yaml | 3 + test/integration/package.json | 3 +- .../test/local-dev-store.integration.ts | 347 ++++++++++++++++++ 3 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 test/integration/test/local-dev-store.integration.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9ab23f58..14b403235 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1245,6 +1245,9 @@ importers: '@prisma/composer-prisma-cloud': specifier: workspace:0.2.0 version: link:../../packages/9-public/composer-prisma-cloud + '@prisma/example-store': + specifier: workspace:0.2.0 + version: link:../../examples/store '@types/bun': specifier: ^1.3.13 version: 1.3.14 diff --git a/test/integration/package.json b/test/integration/package.json index bf52f3a69..e6d29408b 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -5,11 +5,12 @@ "type": "module", "scripts": { "typecheck": "tsc --noEmit", - "test": "bun test && bun run test/local-dev.integration.ts" + "test": "bun test && bun run test/local-dev.integration.ts && bun run test/local-dev-store.integration.ts" }, "devDependencies": { "@prisma/composer": "workspace:0.2.0", "@prisma/composer-prisma-cloud": "workspace:0.2.0", + "@prisma/example-store": "workspace:0.2.0", "@types/bun": "^1.3.13", "@types/node": "^25.9.3", "alchemy": "2.0.0-beta.59", diff --git a/test/integration/test/local-dev-store.integration.ts b/test/integration/test/local-dev-store.integration.ts new file mode 100644 index 000000000..5582801e2 --- /dev/null +++ b/test/integration/test/local-dev-store.integration.ts @@ -0,0 +1,347 @@ +/** + * The plan-S5 proving script (plan.md's S5 outcome; local-dev spec's + * acceptance criteria 1, 3, 6): drives the REAL, published `prisma-composer` + * binary — `examples/store/node_modules/.bin/prisma-composer` — as a real + * child process against `examples/store`, a real multi-module app (four + * services, two Postgres-backed modules, cron), exactly as an operator would + * run it. Every other integration script in this package drives the + * pipeline's own functions directly; this one is deliberately at arm's + * length — the CLI's argv parsing, its own SIGINT handling, and its own + * process lifetime are themselves what's under test. + * + * Criteria proved here: + * 1. Credential-free bring-up: PRISMA_WORKSPACE_ID/PRISMA_SERVICE_TOKEN/ + * PRISMA_REGION are stripped from the child's env; the front door + * (`[dev] ready:` + one line per service) is parsed from real stdout; + * an HTTP round-trip against the storefront's URL succeeds. + * 3. Postgres persistence: a row is written directly against the real + * `prisma dev` URL (read from `.prisma-composer/dev/postgres.json`, + * never through the app's own RPC surface — this is a storage-layer + * proof, not a business-logic one) before Ctrl-C; a second `dev` start + * reads it back (warm); `--fresh` wipes it. + * 6. Warm restart: the second start's front-door ports match the first's + * exactly — no re-provisioning. + * + * Not proved here (see the S5 report): criterion 2 (rebuild restarts one + * service) needs `Bundle.watch`, which isn't on this branch (S2, a sibling + * slice — see spec.md's Open Questions); criteria 4/5 (bucket, placeholder/ + * env-param) are proved against the S4 fixture instead — store declares + * neither a bucket nor a secret/env-param. + * + * WHY THIS IS A STANDALONE SCRIPT, NOT bun:test: same reasoning as + * local-dev.integration.ts — a nested `prisma dev --detach` grandchild's + * stdout is unreliable under `bun test`'s own process tree. Invoked by + * package.json's `test` script as a second `bun run` step. + */ + +import { type ChildProcess, spawn, spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(`assertion failed: ${message}`); +} + +function assertEqual(actual: T, expected: T, message: string): void { + const same = JSON.stringify(actual) === JSON.stringify(expected); + if (!same) { + throw new Error( + `assertion failed: ${message}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`, + ); + } +} + +const integrationDir = path.resolve(import.meta.dir, '..'); +const repoRoot = path.resolve(integrationDir, '..', '..'); +const storeDir = path.join(repoRoot, 'examples', 'store'); +const devDir = path.join(storeDir, '.prisma-composer', 'dev'); +// Deliberately OUTSIDE `.prisma-composer/dev` — `--fresh` recursively +// removes that whole directory (runDevTeardown), which would delete these +// logs mid-run otherwise. +const logDir = path.join(integrationDir, '.local-dev-store-proving-logs'); +const CLI_BIN = path.join(storeDir, 'node_modules', '.bin', 'prisma-composer'); +const READY_TIMEOUT_MS = 90_000; +const SHUTDOWN_TIMEOUT_MS = 15_000; + +let sessionCount = 0; + +interface Endpoint { + readonly address: string; + readonly url: string; +} + +interface DevSession { + readonly child: ChildProcess; + readonly logPath: string; + readonly endpoints: readonly Endpoint[]; +} + +function credentialFreeEnv(): NodeJS.ProcessEnv { + const env = { ...process.env }; + delete env['PRISMA_WORKSPACE_ID']; + delete env['PRISMA_SERVICE_TOKEN']; + delete env['PRISMA_REGION']; + return env; +} + +function readLog(logPath: string): string { + try { + return fs.readFileSync(logPath, 'utf8'); + } catch { + return ''; + } +} + +/** Parses the pinned front-door block out of the tee'd log: `[dev] ready:` then `[dev]
` lines, until a non-matching line. */ +function parseFrontDoor(log: string): readonly Endpoint[] | undefined { + const lines = log.split('\n'); + const readyAt = lines.findIndex((l) => l.trim() === '[dev] ready:'); + if (readyAt === -1) return undefined; + const endpoints: Endpoint[] = []; + for (let i = readyAt + 1; i < lines.length; i += 1) { + const m = /^\[dev\] (\S+)\s\s(\S+)$/.exec(lines[i] ?? ''); + if (m === null) break; + endpoints.push({ address: m[1] as string, url: m[2] as string }); + } + return endpoints.length > 0 ? endpoints : undefined; +} + +async function waitForAsync( + fn: () => Promise, + timeoutMs: number, + intervalMs = 250, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + for (;;) { + try { + const result = await fn(); + if (result !== undefined) return result; + } catch (error) { + lastError = error; + } + if (Date.now() >= deadline) { + throw lastError instanceof Error + ? lastError + : new Error(`waitFor: not met within ${timeoutMs}ms`); + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } +} + +/** + * Starts `prisma-composer dev module.ts [--fresh]` as a real, teed child + * process (stdout/stderr appended to a log FILE by raw fd — the same reason + * local-dev.integration.ts avoids piped capture: nested grandchildren losing + * output under some parent process trees). Bounded: the ready-wait itself is + * the hang guard — nothing here can wait forever. + */ +async function startDev(fresh: boolean): Promise { + sessionCount += 1; + fs.mkdirSync(logDir, { recursive: true }); + const logPath = path.join(logDir, `session-${sessionCount}.log`); + const logFd = fs.openSync(logPath, 'w'); + const args = ['dev', 'module.ts', ...(fresh ? ['--fresh'] : [])]; + const child = spawn(CLI_BIN, args, { + cwd: storeDir, + env: credentialFreeEnv(), + stdio: ['ignore', logFd, logFd], + }); + fs.closeSync(logFd); + + child.on('exit', (code, signal) => { + // Visible in the log for post-mortem; the log file itself already + // records everything the process printed before this. + console.log( + `[proving] session ${sessionCount} exited (code=${String(code)} signal=${String(signal)})`, + ); + }); + + const endpoints = await waitForAsync( + async () => parseFrontDoor(readLog(logPath)), + READY_TIMEOUT_MS, + 500, + ); + return { child, logPath, endpoints }; +} + +/** SIGINT, then wait for the process to actually exit (bounded) — the pinned Ctrl-C contract: stop, exit 0, emulators survive. */ +async function stopDev(session: DevSession): Promise { + if (session.child.exitCode !== null || session.child.signalCode !== null) return; + session.child.kill('SIGINT'); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`session did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGINT`)); + }, SHUTDOWN_TIMEOUT_MS); + session.child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +function readJson(file: string): unknown { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch { + return undefined; + } +} + +interface PostgresEntry { + readonly instance: string; + readonly url: string; +} + +function readCatalogDbUrl(): string { + const postgres = readJson(path.join(devDir, 'postgres.json')) as Record; + const entry = postgres['pcdev-store-catalog-database']; + assert(entry?.url !== undefined, 'postgres.json must have a pcdev-store-catalog-database entry'); + return entry.url; +} + +/** Runs `bun`'s built-in SQL client against the real local Postgres URL — the storage layer, never the app's own RPC. */ +async function withSql(url: string, fn: (sql: Bun.SQL) => Promise): Promise { + const sql = new Bun.SQL(url); + try { + return await fn(sql); + } finally { + await sql.close(); + } +} + +const PROVING_PRODUCT_ID = 'proving-row'; + +async function main(): Promise { + console.log('local dev (S5 proving): examples/store via the real prisma-composer dev binary'); + + fs.rmSync(path.join(storeDir, '.prisma-composer'), { recursive: true, force: true }); + fs.rmSync(path.join(storeDir, '.alchemy'), { recursive: true, force: true }); + + let session: DevSession | undefined; + try { + // ——— Criterion 1: credential-free bring-up + HTTP round-trip ——— + session = await startDev(false); + console.log(`[proving] session 1 ready: ${JSON.stringify(session.endpoints)}`); + assert( + session.endpoints.some((e) => e.address === 'storefront'), + 'the front door must list the storefront service', + ); + const storefront = session.endpoints.find((e) => e.address === 'storefront'); + if (storefront === undefined) throw new Error('unreachable'); + const health = await waitForAsync( + () => fetch(storefront.url).then((r) => (r.ok ? r : undefined)), + 15_000, + ); + assertEqual(health.status, 200, 'the storefront HTTP round-trip'); + console.log('[proving] PASS criterion 1: credential-free bring-up + HTTP round-trip'); + + const firstPortsByAddress = Object.fromEntries( + session.endpoints.map((e) => [e.address, new URL(e.url).port]), + ); + + // ——— Criterion 3 (write half): a row through the real local Postgres URL ——— + const dbUrl = readCatalogDbUrl(); + await withSql(dbUrl, async (sql) => { + // prisma-next's migration DDL folds the table name and single-word + // columns to lowercase (unquoted identifiers); "priceCents" alone + // stays quoted, mixed-case. + await sql`insert into product (id, name, description, "priceCents") + values (${PROVING_PRODUCT_ID}, 'Proving Row', 'S5 proving script', 100) + on conflict (id) do update set name = excluded.name`; + }); + console.log('[proving] wrote the proving row through the real local Postgres URL'); + + // ——— Ctrl-C ——— + await stopDev(session); + console.log('[proving] session 1 stopped cleanly on SIGINT'); + + // ——— Criterion 3 (warm read) + criterion 6 (same ports) ——— + session = await startDev(false); + console.log(`[proving] session 2 (warm) ready: ${JSON.stringify(session.endpoints)}`); + const secondPortsByAddress = Object.fromEntries( + session.endpoints.map((e) => [e.address, new URL(e.url).port]), + ); + assertEqual( + secondPortsByAddress, + firstPortsByAddress, + 'criterion 6: warm restart keeps the same ports', + ); + + const warmDbUrl = readCatalogDbUrl(); + assertEqual(warmDbUrl, dbUrl, 'criterion 6: the Postgres URL is stable across a warm restart'); + const warmRow = await withSql( + warmDbUrl, + (sql) => sql`select name from product where id = ${PROVING_PRODUCT_ID}`, + ); + assertEqual( + (warmRow as unknown as { name: string }[])[0]?.name, + 'Proving Row', + 'criterion 3: the row survives a warm (non---fresh) restart', + ); + console.log('[proving] PASS criterion 3 (warm): row survived a Ctrl-C restart'); + console.log('[proving] PASS criterion 6: same ports, no re-provisioning across a warm restart'); + + await stopDev(session); + console.log('[proving] session 2 stopped cleanly on SIGINT'); + + // ——— Criterion 3 (--fresh half): the instance and its data are gone ——— + session = await startDev(true); + console.log(`[proving] session 3 (--fresh) ready: ${JSON.stringify(session.endpoints)}`); + const freshDbUrl = readCatalogDbUrl(); + // --fresh removes the whole prisma dev instance (prisma dev rm) and + // recreates it on next provision — migrations reapply as part of THIS + // SAME converge (PnMigration runs on every converge), so the "product" + // table exists again by the time the front door prints; the proof is + // that it's freshly migrated and EMPTY — the proving row from before + // --fresh is gone. + const freshRow = await withSql( + freshDbUrl, + (sql) => sql`select name from product where id = ${PROVING_PRODUCT_ID}`, + ); + assertEqual( + (freshRow as unknown as { name: string }[]).length, + 0, + 'criterion 3 (--fresh): the proving row must be gone from a fresh instance', + ); + console.log('[proving] PASS criterion 3 (--fresh): the prior instance and its data are gone'); + + console.log('PASS: local dev (S5 proving) — examples/store, criteria 1/3/6'); + } finally { + if (session !== undefined) { + await stopDev(session).catch(() => undefined); + } + // Final app-scoped teardown, mirroring local-dev.integration.ts: never + // touch the machine-global emulator daemons, only this app's own + // records — `--fresh` then Ctrl-C leaves nothing running and no data + // behind for the next run. + try { + const cleanup = await startDev(true); + await stopDev(cleanup); + } catch (error) { + console.error( + `[proving] final --fresh cleanup did not complete cleanly: ${error instanceof Error ? error.message : String(error)}`, + ); + } + const stray = spawnSync('pgrep', ['-f', 'prisma-composer dev module.ts']); + for (const line of (stray.stdout?.toString() ?? '').split('\n')) { + const pid = Number(line.trim()); + if (Number.isFinite(pid) && pid > 0) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // already gone + } + } + } + } +} + +main() + .then(() => { + process.exitCode = 0; + }) + .catch((error: unknown) => { + console.error(error instanceof Error ? (error.stack ?? error.message) : error); + process.exitCode = 1; + }); From 669f13ac650b10c60cb0814961597b249b32bb01 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 11:21:00 +0200 Subject: [PATCH 09/43] fix(cli): watch a file target's parent directory, not the file itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found on the scratch/s5-watch-proof branch (S2 merged in temporarily to exercise Bundle.watch for real against examples/store — see the S5 report for the full transcript): `fs.watch(filePath)` bound to a single file's inode goes silently dead after that file is deleted and recreated at the same path — exactly what `rm -rf dist && bun build --outfile dist/server.mjs` does on every rebuild (also confirmed with plain `bun build --outfile` alone, no `rm -rf` — bun's own writer replaces the file via an atomic rename, which is the same class of inode change). The watch fired once, on the very first rebuild, then never again. Fixed by watching the file's parent directory (non-recursive) and filtering events to the file's own basename — a directory watch's handle isn't tied to any one child's inode, so it survives however the build tool replaces the file. Directory targets are unaffected (already watched recursively at the target path itself). Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../cli/src/dev/__tests__/watch.test.ts | 34 +++++++++++++++++++ .../3-tooling/cli/src/dev/watch.ts | 23 +++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts b/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts index 7a59aa8f6..92a2878d6 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts @@ -78,4 +78,38 @@ describe('startWatch()', () => { expect(calls).toBe(0); fs.rmSync(dir, { recursive: true, force: true }); }, 10_000); + + test('survives a delete-then-recreate rebuild of the watched file (rm -rf && bun build --outfile)', async () => { + // A single-file `fs.watch` bound to the original inode goes silently + // dead after the file is unlinked and a new one created at the same + // path — exactly what `rm -rf dist && bun build --outfile dist/x.mjs` + // does on every rebuild. Watching the file's PARENT directory instead + // (this module's actual strategy) survives it — found live against + // examples/store's catalog service during the S5 proving pass. + const dir = tempDir(); + const file = path.join(dir, 'server.mjs'); + fs.writeFileSync(file, 'v1'); + + let calls = 0; + const stop = startWatch([{ address: 'catalog', paths: [file] }], () => { + calls += 1; + }); + + try { + fs.rmSync(file); + fs.writeFileSync(file, 'v2'); + await sleep(400); + expect(calls).toBe(1); + + // A SECOND delete+recreate — the exact case that broke a + // file-bound (not directory-bound) watch. + fs.rmSync(file); + fs.writeFileSync(file, 'v3'); + await sleep(400); + expect(calls).toBe(2); + } finally { + stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 10_000); }); diff --git a/packages/0-framework/3-tooling/cli/src/dev/watch.ts b/packages/0-framework/3-tooling/cli/src/dev/watch.ts index 28aa04995..1d1c795eb 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/watch.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/watch.ts @@ -12,6 +12,7 @@ * takes the pinned "not watched" fallback. */ import * as fs from 'node:fs'; +import * as path from 'node:path'; import type { Bundle } from '@internal/core/deploy'; import { blindCast } from '@internal/foundation/casts'; @@ -72,8 +73,26 @@ export function startWatch(targets: readonly WatchTarget[], onChange: () => void } catch { continue; } - const watcher = fs.watch(watchPath, { recursive: stat.isDirectory() }, () => trigger()); - watchers.push(watcher); + if (stat.isDirectory()) { + watchers.push(fs.watch(watchPath, { recursive: true }, () => trigger())); + continue; + } + // A FILE target is watched via its PARENT directory, filtered to its + // own basename — not `fs.watch(watchPath)` directly. A build that + // replaces the file via delete+recreate or an atomic rename (both + // common — `rm -rf dist && bun build --outfile`, or any writer that + // writes to a temp path and renames over the target) changes the + // underlying inode; a watch bound to the original file's inode goes + // silently dead after that first replacement and never fires again. + // Watching the directory survives every rebuild, no matter how the + // build tool replaces the file. + const dir = path.dirname(watchPath); + const base = path.basename(watchPath); + watchers.push( + fs.watch(dir, { recursive: false }, (_event, filename) => { + if (filename === null || filename === base) trigger(); + }), + ); } } From 7a16dca9865d1a869ea10c717a27b0c758ac9625 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 12:34:36 +0200 Subject: [PATCH 10/43] fix(lowering): scope a service's materialized env to its own rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local-dev spec § 4's rewritten step 3 (pinned, design tip b2cb63e) — the fix for restart amplification: the hosted platform materializes the app-wide env row set into every deployment but DIFFS a deployment only on its own referenced rows. Local dev's materializeEnv was copying the FULL env.json into every service's deployment unscoped, so an early-deployed service's snapshot looks incomplete on the first converge and "completes" (and diffs as changed) on the very next one — restart amplification. scopedEnvRows() fixes it: a service keeps only its own `COMPOSER__*` rows plus every row OUTSIDE the `COMPOSER_` namespace entirely (the poison DATABASE_URL/DATABASE_URL_POOLED rows are deliberately unprefixed and app-wide, per the pinned parity note — kept for every service, matching the platform). The dropped sibling rows have no sanctioned reader: run()/load() consume only own-address rows. Note on provenance: the coordinator's cascade instructions named two commits on `claude/local-dev-s5-fix-restart-amplification` (`a745e07` fix, `2dcbb32` test) to cherry-pick. That branch is not present on any remote reachable from this worktree (`git ls-remote bot` lists s1 through s5-dev-command only) — fetching it by name or by commit sha both fail. Reimplemented directly from the pinned spec text instead, verified against it line by line, and added new coverage (dev-compute-scoped-env.test.ts) rather than porting the referenced test commit sight-unseen. Re-ran the S4 integration proof (test/local-dev.integration.ts) against the real converge — still passes, including its DATABASE_URL/DATABASE_URL_POOLED poison-row assertions (both stay app-wide, per the pinned note). Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../local-target/src/dev/compute.ts | 39 +++++++++++- .../__tests__/dev-compute-scoped-env.test.ts | 59 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/dev-compute-scoped-env.test.ts diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/dev/compute.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/dev/compute.ts index bc4b8e2a8..778fec81a 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/dev/compute.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/dev/compute.ts @@ -39,6 +39,43 @@ function servicePortEnvKey(address: string): string { return ['COMPOSER', ...segments, 'PORT'].join('_').toUpperCase(); } +/** The `COMPOSER_
_` prefix every env row this address OWNS carries — `configKey`'s convention (see `servicePortEnvKey`'s doc comment). */ +function ownEnvKeyPrefix(address: string): string { + const segments = address.split('.').filter((s) => s.length > 0); + return `${['COMPOSER', ...segments].join('_').toUpperCase()}_`; +} + +const COMPOSER_NAMESPACE_PREFIX = 'COMPOSER_'; + +/** + * Scopes `env.json` to what THIS service is allowed to see: rows it owns + * (`COMPOSER__*`) plus every row OUTSIDE the `COMPOSER_` + * namespace entirely — the poison `DATABASE_URL(_POOLED)` rows are + * deliberately unprefixed and app-wide (local-dev spec § 4's pinned parity + * note). The hosted platform materializes the app-wide row set into every + * deployment but DIFFS a deployment only on its own referenced rows; an + * app-wide LOCAL materialization restart-amplifies instead — an + * early-deployed service's snapshot is incomplete on the first converge, + * "completes" on the second, and diffs as changed. Scoping the content here + * aligns local restart behavior with the platform's diff scope. The dropped + * sibling rows have no sanctioned reader: `run()`/`load()` consume only + * own-address rows, and ambient sibling reads are exactly what the poison + * rows exist to punish. + */ +export function scopedEnvRows( + allRows: Readonly>, + address: string, +): Record { + const ownPrefix = ownEnvKeyPrefix(address); + const scoped: Record = {}; + for (const [key, value] of Object.entries(allRows)) { + if (key.startsWith(ownPrefix) || !key.startsWith(COMPOSER_NAMESPACE_PREFIX)) { + scoped[key] = value; + } + } + return scoped; +} + function manifestMissingAddressError(): Error { return new Error( 'artifact manifest carries no address — repackage with a current @prisma/composer.', @@ -108,7 +145,7 @@ async function materializeEnv( address: string, port: number, ): Promise> { - const env: Record = { ...(await envStore(devDir).read()) }; + const env: Record = scopedEnvRows(await envStore(devDir).read(), address); env[servicePortEnvKey(address)] = JSON.stringify(port); const secrets = await secretsStore(devDir).read(); for (const [key, value] of Object.entries(secrets)) env[key] = value; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/dev-compute-scoped-env.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/dev-compute-scoped-env.test.ts new file mode 100644 index 000000000..b5d8c46ed --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/dev-compute-scoped-env.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'bun:test'; +import { scopedEnvRows } from '../dev/compute.ts'; + +/** + * Local-dev spec § 4's pinned parity note: the hosted platform diffs a + * deployment only on its own referenced rows, so an app-wide LOCAL + * materialization restart-amplifies (an early-deployed service's snapshot + * looks "changed" on the very next converge, purely from a sibling's row + * landing afterward). `scopedEnvRows` is the fix — every service's + * materialized env keeps only what it owns (`COMPOSER__*`) + * plus every row OUTSIDE the `COMPOSER_` namespace (the poison rows, which + * are deliberately app-wide). + */ +describe('scopedEnvRows()', () => { + test("keeps only the service's own COMPOSER_ rows plus every non-COMPOSER_ row", () => { + const all = { + COMPOSER_WEB_PORT: '3000', + COMPOSER_WEB_ORIGIN: 'http://localhost:3000', + COMPOSER_ORDERS_SERVICE_PORT: '3001', + COMPOSER_ORDERS_SERVICE_CATALOG_URL: 'http://localhost:3002', + DATABASE_URL: '-', + DATABASE_URL_POOLED: '-', + }; + + expect(scopedEnvRows(all, 'web')).toEqual({ + COMPOSER_WEB_PORT: '3000', + COMPOSER_WEB_ORIGIN: 'http://localhost:3000', + DATABASE_URL: '-', + DATABASE_URL_POOLED: '-', + }); + }); + + test('a nested address only matches its own dotted prefix, never a sibling module', () => { + const all = { + COMPOSER_ORDERS_SERVICE_PORT: '3001', + // A DIFFERENT service under the same "orders" module — must not leak + // into "orders.service"'s scoped env just because both start with + // "COMPOSER_ORDERS_". + COMPOSER_ORDERS_WORKER_PORT: '3005', + }; + + expect(scopedEnvRows(all, 'orders.service')).toEqual({ + COMPOSER_ORDERS_SERVICE_PORT: '3001', + }); + }); + + test('a service with no rows of its own still gets every poison/app-wide row', () => { + const all = { + COMPOSER_OTHER_PORT: '4000', + DATABASE_URL: '-', + }; + + expect(scopedEnvRows(all, 'web')).toEqual({ DATABASE_URL: '-' }); + }); + + test('an empty env store scopes to an empty object', () => { + expect(scopedEnvRows({}, 'web')).toEqual({}); + }); +}); From 14533408035d89275007f40acb239041ebca4a6f Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 12:35:03 +0200 Subject: [PATCH 11/43] docs(drive): sync spec.md/plan.md wholesale from the design tip (b2cb63e) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Takes both files verbatim from bot/claude/local-dev-feature-design-0b49fd so the diff against it is empty on this branch — picks up the pinned restart-amplification fix text (spec Section 4) implemented in the prior commit, and the fresh-allocation port-retry protocol (spec Section 2, S3 scope, already on this branch via the S3 rebase). Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .drive/projects/local-dev/plan.md | 66 +++++++++++-------------------- .drive/projects/local-dev/spec.md | 13 +----- 2 files changed, 26 insertions(+), 53 deletions(-) diff --git a/.drive/projects/local-dev/plan.md b/.drive/projects/local-dev/plan.md index ccda872a5..90e3208fe 100644 --- a/.drive/projects/local-dev/plan.md +++ b/.drive/projects/local-dev/plan.md @@ -31,18 +31,17 @@ scripts. Implementer dispatches use Sonnet-4.6-mid, reviewers Opus-4.8-mid devDependency in tests only) incl. presigned GET/PUT. - **Spec sections:** § 1, behavior contracts. -### S2 — `Bundle.watch` + directory-form fixes (REVISED — no new adapter) - -- **Outcome (revised):** `node()`'s existing directory form is friction - #3's fix, already on `main` — the originally planned `dir()` duplicate - is struck. S2 lands: the `Bundle.watch` contract (spec § 3) populated in - `node()` (single-file → entry file; directory form → the whole dir) and - `nextjs()`; the symlink-as-`dir` hard-error fix, which is a real hole in - `main`'s directory form; doc corrections where `node()` is still - described as single-file-only. -- **Proves:** directory-form assemble tests (symlink-as-dir on both file - and directory targets, tree fidelity, watch fields per form). -- **Spec sections:** § 7 (revised), § 3's watch bullets. +### S2 — `dir()` build adapter + +- **Outcome:** `@prisma/composer/dir` authoring + assemble per spec § 7: + verbatim tree copy, named entry, symlink hard error, wrapper bundling + identical to `node()`'s. Also lands the `Bundle.watch` contract (spec + § 3): the optional core field plus `watch` population in `node()`, + `nextjs()`, and `dir()`. Guide/docs entry. +- **Proves:** assemble tests (missing dir/entry errors verbatim, symlink + error, tree fidelity); a fixture app with a multi-file runnable deploys + through the full assemble path (no cloud needed — assemble-level test). +- **Spec sections:** § 7. ### S3 — `@internal/dev-emulators`: daemon layer + Compute & bucket emulators @@ -112,8 +111,8 @@ scripts. Implementer dispatches use Sonnet-4.6-mid, reviewers Opus-4.8-mid ### S6 — proof on the open-chat port + measurement + close-out docs -- **Outcome:** the open-chat port (separate repo) switched to `node()`'s - directory form + `prisma-composer dev`, replacing `scripts/dev.ts`; sign-in/history/ +- **Outcome:** the open-chat port (separate repo) switched to `dir()` + + `prisma-composer dev`, replacing `scripts/dev.ts`; sign-in/history/ live-tail verified; friction found lands here as fixes (re-triaged if large); restart latency for `examples/store` measured and recorded; `deploy-cli.md` scope updated; `local-dev.md` + ADR-0041 reconciled with @@ -123,40 +122,23 @@ scripts. Implementer dispatches use Sonnet-4.6-mid, reviewers Opus-4.8-mid ## Known items blocking close-out (found in S5 proving, 2026-07-23) -- ~~Restart amplification~~ — **resolved on the S5 branch**: root-caused - (app-wide env materialization completing between converges) with - three-converge byte evidence; fixed by the pinned scoped materialization; - proven by the store-level exactly-one-restart assertion, run twice. -- ~~Criteria 4/5 scripting~~ — **resolved on the S5 branch**: the S4 - fixture gained a bucket flow, a secret, and an envParam; - `local-dev-criteria-4-5.integration.ts` proves all four sub-criteria at - the CLI level as a required gate. +- **Restart amplification (defect):** rebuilding one service restarts its + RPC dependents too (observed: catalog rebuild → catalog + cron.runner + + orders.service restarted; storefront + cron.scheduler correctly + untouched). Something in the dependents' materialized deployment env + recomputes as changed on every converge. Root-cause and fix in the local + providers (S4 code) before S6 sign-off — acceptance criterion 2 requires + exactly-one-service restarts. +- **Criteria 4/5 scripting pending:** `examples/store` has neither a bucket + nor a secret/env-param; the S4 fixture needs both to script the bucket + file-drop round-trip and the placeholder-warning / env-param-error + criteria at the CLI level. - ~~Emulator stop/reap honesty~~ — **resolved, no bug**: `killChild` already awaits SIGTERM → 5 s grace → SIGKILL → exit before state flips; the observation was the grace period itself. Locked in by a regression test (an ignores-SIGTERM fixture: the listing never says `stopped` while the pid lives, and the stop measurably takes the grace period). -### S7 — MERGED INTO THE #162 REWORK WAVE (operator review, 2026-07-23) - -Will's #162 review mandated the programmatic adoption in-place ("replace -it with programmatic use of prisma dev") — the section below is executed -as part of the rework, not post-stack. - -### (was S7) — adopt @prisma/dev programmatically - -Operator decision (2026-07-23): replace the Postgres CLI shell-out with -`@prisma/dev`'s programmatic API, hosted in our own `postgres-main` -emulator daemon beside compute/buckets — `startPrismaDevServer({ name, -databasePort, persistenceMode })` per `Database` resource, ports from our -registry, persistence on. Deletes: bin walk-up, last-stdout-line URL -parsing, `prisma dev start/stop/rm` coupling, the probe/start recovery -sequence. Sub-decision to settle at pickup: version ownership (lean: -resolve `@prisma/dev` from the app's node_modules so the app owns the -version, consistent with runtime ownership). The CLI shell-out in the -shipped slices is transitional and marked so here. Not started until the -current stack merges. - ## Close-out (required) - [ ] Verify all acceptance criteria in `.drive/projects/local-dev/spec.md` diff --git a/.drive/projects/local-dev/spec.md b/.drive/projects/local-dev/spec.md index 5c7644027..53df687c4 100644 --- a/.drive/projects/local-dev/spec.md +++ b/.drive/projects/local-dev/spec.md @@ -952,14 +952,5 @@ The open-chat proof (S6) uses `node({ module, dir, entry })`. ## Open questions -(none currently open — a gap found during implementation is recorded here -and raised, not improvised around) - -**Resolved:** S5's branch base (`claude/local-dev-s4-dev-target`) predates -`Bundle.watch` (S2 scope, not an ancestor of S4). `watch.ts`/`run-dev.ts` -read it through a locally-declared structural extension of `Bundle` with one -justified `blindCast`, so every bundle takes the spec's own pinned "not -watched" fallback until S2 merges, and the watch loop activates for real the -moment it does — see `packages/0-framework/3-tooling/cli/src/dev/watch.ts`'s -doc comment. No spec change needed; recorded here only so the resolution is -visible next to where the gap used to be. +(none — a gap found during implementation is recorded here and raised, not +improvised around) From 65d7d6dc50ed61c1a277eff2ef544afe0c185ba8 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 12:50:36 +0200 Subject: [PATCH 12/43] test(local-dev): prove criteria 4 (bucket) and 5 (placeholder/env-param) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the S4 fixture (test/fixtures/local-dev/): - web-service's built server gains a real @aws-sdk/client-s3-backed /blobs/:key PUT/GET, reusing its already-bound bucket() dependency (the same client examples/bucket's blob app uses). - bg-service gains a secret slot (apiKey) and an envParam (greeting), bound in module.ts to LOCALDEV_FIXTURE_API_KEY / LOCALDEV_FIXTURE_GREETING; its built server exposes GET /status reporting whether the secret looks like a minted placeholder and echoing the bound greeting. New local-dev-criteria-4-5.integration.ts drives the real CLI against this fixture (same pattern as local-dev-store.integration.ts): PUTs an object through the app and confirms it lands as a plain file under .prisma-composer/dev/buckets/files/, then drops a file directly on disk and reads it back through the app (criterion 4); one session with the secret unset confirms the pinned placeholder warning appears and the topology still serves, a second with the envParam unset confirms dev exits nonzero with the pinned listing error naming it (criterion 5). Wired into the package's test script as a third step. local-dev.integration.ts (S4's own direct-driving script) needed two fixes to keep passing against the extended fixture: - It sets both env vars before calling dev.preflight() so its own (unrelated) proofs are unaffected by the new required slots, and updates the "secrets.json is empty" assertion to the shell-sourced values it now sets. - Found live: dev.preflight() computes its state directory from process.cwd() rather than accepting one, unlike every other dev hook this script calls (all handed devDir explicitly, fixture-relative). This script runs from the package root, not the fixture dir, so preflight's write previously landed nowhere this script ever looked — invisible only because preflight was a total no-op before this fixture had any secrets/params to iterate over. Fixed by temporarily chdir'ing to the fixture around the preflight call (restored in finally); preflight.ts itself is untouched. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- pnpm-lock.yaml | 3 + test/integration/package.json | 3 +- .../test/fixtures/local-dev/bg-service.ts | 7 + .../fixtures/local-dev/built/bg-server.mjs | 25 +- .../fixtures/local-dev/built/web-server.mjs | 51 ++- .../test/fixtures/local-dev/module.ts | 13 +- .../local-dev-criteria-4-5.integration.ts | 323 ++++++++++++++++++ .../integration/test/local-dev.integration.ts | 38 ++- 8 files changed, 449 insertions(+), 14 deletions(-) create mode 100644 test/integration/test/local-dev-criteria-4-5.integration.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14b403235..27b14bac0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1239,6 +1239,9 @@ importers: test/integration: devDependencies: + '@aws-sdk/client-s3': + specifier: ^3.1085.0 + version: 3.1087.0 '@prisma/composer': specifier: workspace:0.2.0 version: link:../../packages/9-public/composer diff --git a/test/integration/package.json b/test/integration/package.json index e6d29408b..fef8f0a04 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -5,9 +5,10 @@ "type": "module", "scripts": { "typecheck": "tsc --noEmit", - "test": "bun test && bun run test/local-dev.integration.ts && bun run test/local-dev-store.integration.ts" + "test": "bun test && bun run test/local-dev.integration.ts && bun run test/local-dev-store.integration.ts && bun run test/local-dev-criteria-4-5.integration.ts" }, "devDependencies": { + "@aws-sdk/client-s3": "^3.1085.0", "@prisma/composer": "workspace:0.2.0", "@prisma/composer-prisma-cloud": "workspace:0.2.0", "@prisma/example-store": "workspace:0.2.0", diff --git a/test/integration/test/fixtures/local-dev/bg-service.ts b/test/integration/test/fixtures/local-dev/bg-service.ts index e65b82e0b..ad7d0621e 100644 --- a/test/integration/test/fixtures/local-dev/bg-service.ts +++ b/test/integration/test/fixtures/local-dev/bg-service.ts @@ -3,12 +3,19 @@ * "a changed artifact restarts exactly one service" (S4 proof): only * `web-service.ts`'s artifact changes between converges, so this one's pid * must stay stable across both the no-op and the changed-artifact converge. + * + * Also carries the fixture's secret slot + env-sourced param (S5 proof, + * acceptance criterion 5) — bound in module.ts to `LOCALDEV_FIXTURE_API_KEY` + * / `LOCALDEV_FIXTURE_GREETING`. */ +import { secret, string } from '@prisma/composer'; import node from '@prisma/composer/node'; import { compute } from '@prisma/composer-prisma-cloud'; export default compute({ name: 'bkg', deps: {}, + secrets: { apiKey: secret() }, + params: { greeting: string() }, build: node({ module: import.meta.url, entry: 'built/bg-server.mjs' }), }); diff --git a/test/integration/test/fixtures/local-dev/built/bg-server.mjs b/test/integration/test/fixtures/local-dev/built/bg-server.mjs index a98f51e31..2e3e046f7 100644 --- a/test/integration/test/fixtures/local-dev/built/bg-server.mjs +++ b/test/integration/test/fixtures/local-dev/built/bg-server.mjs @@ -1,23 +1,42 @@ -// Hand-written "built output" for bg-service.ts (S4 fixture) -- a +// Hand-written "built output" for bg-service.ts (S4/S5 fixture) -- a // dependency-free second service that must NOT restart when only web's // artifact changes. Self-contained (see web-server.mjs's doc comment for // why this doesn't import the sibling .ts source). +// +// Also carries the secret/env-param proof (S5, acceptance criterion 5): +// GET /status reports whether the secret looks like a locally-minted +// placeholder (`local-placeholder-`) and echoes the bound greeting — +// dev never reaches this far at all when the env-param is unset (preflight +// hard-errors first), so this endpoint's mere reachability is half the proof. +import { secret, string } from '@prisma/composer'; import node from '@prisma/composer/node'; import { compute } from '@prisma/composer-prisma-cloud'; const service = compute({ name: 'bkg', deps: {}, + secrets: { apiKey: secret() }, + params: { greeting: string() }, build: node({ module: import.meta.url, entry: 'bg-server.mjs' }), }); -const { port } = service.config(); +const { port, greeting } = service.config(); +const { apiKey } = service.secrets(); Bun.serve({ port, hostname: '0.0.0.0', - fetch: () => new Response('local-dev fixture: bg', { status: 200 }), + fetch(request) { + const url = new URL(request.url); + if (url.pathname === '/status') { + return Response.json({ + greeting, + apiKeyIsPlaceholder: apiKey.expose().startsWith('local-placeholder-'), + }); + } + return new Response('local-dev fixture: bg', { status: 200 }); + }, }); console.log(`[fixture] bg listening on ${port}`); diff --git a/test/integration/test/fixtures/local-dev/built/web-server.mjs b/test/integration/test/fixtures/local-dev/built/web-server.mjs index bdea5c971..c4ce5e19d 100644 --- a/test/integration/test/fixtures/local-dev/built/web-server.mjs +++ b/test/integration/test/fixtures/local-dev/built/web-server.mjs @@ -8,6 +8,7 @@ // what the "changed artifact restarts exactly one service" proof edits // between converges. +import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import node from '@prisma/composer/node'; import { bucket, compute, postgres } from '@prisma/composer-prisma-cloud'; @@ -22,10 +23,26 @@ const VERSION = 'v1'; const { port } = service.config(); const { db, store } = service.load(); +// The bucket round-trip proof (S5, acceptance criterion 4): a real +// @aws-sdk/client-s3 client against the bucket binding, exactly as +// examples/bucket's blob app does. +const s3 = new S3Client({ + region: 'auto', + endpoint: store.url, + forcePathStyle: true, + credentials: { accessKeyId: store.accessKeyId, secretAccessKey: store.secretAccessKey }, + requestChecksumCalculation: 'WHEN_REQUIRED', + responseChecksumValidation: 'WHEN_REQUIRED', +}); + +async function collectBytes(body) { + return body.transformToByteArray(); +} + Bun.serve({ port, hostname: '0.0.0.0', - fetch(request) { + async fetch(request) { const url = new URL(request.url); if (url.pathname === '/health') { return Response.json({ @@ -41,6 +58,38 @@ Bun.serve({ portEnv: process.env['COMPOSER_WEB_PORT'] ?? null, }); } + if (url.pathname.startsWith('/blobs/')) { + const key = decodeURIComponent(url.pathname.slice('/blobs/'.length)); + if (key.length === 0) return new Response('missing key', { status: 400 }); + if (request.method === 'PUT') { + const body = new Uint8Array(await request.arrayBuffer()); + const contentType = request.headers.get('content-type') ?? 'application/octet-stream'; + await s3.send( + new PutObjectCommand({ + Bucket: store.bucket, + Key: key, + Body: body, + ContentType: contentType, + }), + ); + return Response.json({ key, size: body.byteLength }, { status: 201 }); + } + if (request.method === 'GET') { + try { + const res = await s3.send(new GetObjectCommand({ Bucket: store.bucket, Key: key })); + const bytes = await collectBytes(res.Body); + return new Response(bytes, { + status: 200, + headers: { 'content-type': res.ContentType ?? 'application/octet-stream' }, + }); + } catch (error) { + const status = error?.$metadata?.httpStatusCode; + if (status === 404) return new Response('not found', { status: 404 }); + throw error; + } + } + return new Response('method not allowed', { status: 405 }); + } return new Response('local-dev fixture: web', { status: 200 }); }, }); diff --git a/test/integration/test/fixtures/local-dev/module.ts b/test/integration/test/fixtures/local-dev/module.ts index 3df8c3371..41a431ebb 100644 --- a/test/integration/test/fixtures/local-dev/module.ts +++ b/test/integration/test/fixtures/local-dev/module.ts @@ -4,9 +4,15 @@ * lowering exercises every one of the eight local providers. Discovered by * walking up from this file (a real `prisma-composer.config.ts` sits at the * repo root of this package). + * + * `bgService`'s secret/env-param binding (S5 proof, spec's acceptance + * criterion 5): `apiKey` is a secret slot — absent from the shell, dev mints + * a placeholder and warns; `greeting` is an env-sourced param — absent from + * the shell, dev hard-errors. Both names are fixture-private, chosen not to + * collide with any real platform env var. */ import { module } from '@prisma/composer'; -import { bucket, postgres } from '@prisma/composer-prisma-cloud'; +import { bucket, envParam, envSecret, postgres } from '@prisma/composer-prisma-cloud'; import bgService from './bg-service.ts'; import webService from './web-service.ts'; @@ -14,5 +20,8 @@ export default module('localdevs4fixture', ({ provision }) => { const db = provision(postgres({ name: 'appdb' })); const store = provision(bucket({ name: 'files' })); provision(webService, { deps: { db, store } }); - provision(bgService, {}); + provision(bgService, { + secrets: { apiKey: envSecret('LOCALDEV_FIXTURE_API_KEY') }, + params: { greeting: envParam('LOCALDEV_FIXTURE_GREETING') }, + }); }); diff --git a/test/integration/test/local-dev-criteria-4-5.integration.ts b/test/integration/test/local-dev-criteria-4-5.integration.ts new file mode 100644 index 000000000..c8a81eaa9 --- /dev/null +++ b/test/integration/test/local-dev-criteria-4-5.integration.ts @@ -0,0 +1,323 @@ +/** + * The plan-S5 proving script for acceptance criteria 4 and 5 (spec's + * acceptance criteria list): drives the real `prisma-composer` binary + * against the S4 fixture (`test/fixtures/local-dev/`), extended for this + * proof with a bucket consumer flow on `web` and a secret/env-param on + * `bkg` (module.ts's `apiKey`/`greeting` binding). Same pattern as + * `local-dev-store.integration.ts` — a real child process, teed output, + * bounded waits. + * + * Criterion 4 (bucket round-trip): PUT an object through the app's own + * `/blobs/:key` route, confirm it lands as a plain file under + * `.prisma-composer/dev/buckets/files/`; drop a file directly into + * that directory and GET it back through the app. + * + * Criterion 5 (value sourcing): with `LOCALDEV_FIXTURE_API_KEY` unset, the + * pinned placeholder warning appears and the topology still serves; with + * `LOCALDEV_FIXTURE_GREETING` (an `envParam`) unset, dev exits nonzero with + * the pinned missing-env-param listing error naming it — checked in two + * separate sessions, since the second failure happens in preflight, before + * anything converges. + */ + +import { type ChildProcess, spawn, spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(`assertion failed: ${message}`); +} + +function assertEqual(actual: T, expected: T, message: string): void { + const same = JSON.stringify(actual) === JSON.stringify(expected); + if (!same) { + throw new Error( + `assertion failed: ${message}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`, + ); + } +} + +const integrationDir = path.resolve(import.meta.dir, '..'); +const fixtureEntry = path.join('test', 'fixtures', 'local-dev', 'module.ts'); +const devDir = path.join(integrationDir, '.prisma-composer', 'dev'); +const logDir = path.join(integrationDir, '.local-dev-criteria-4-5-logs'); +const CLI_BIN = path.join(integrationDir, 'node_modules', '.bin', 'prisma-composer'); +const READY_TIMEOUT_MS = 90_000; +const EXIT_TIMEOUT_MS = 30_000; +const SHUTDOWN_TIMEOUT_MS = 15_000; + +let sessionCount = 0; + +interface Endpoint { + readonly address: string; + readonly url: string; +} + +interface DevSession { + readonly child: ChildProcess; + readonly logPath: string; + readonly endpoints: readonly Endpoint[]; +} + +function readLog(logPath: string): string { + try { + return fs.readFileSync(logPath, 'utf8'); + } catch { + return ''; + } +} + +function parseFrontDoor(log: string): readonly Endpoint[] | undefined { + const lines = log.split('\n'); + const readyAt = lines.findIndex((l) => l.trim() === '[dev] ready:'); + if (readyAt === -1) return undefined; + const endpoints: Endpoint[] = []; + for (let i = readyAt + 1; i < lines.length; i += 1) { + const m = /^\[dev\] (\S+)\s\s(\S+)$/.exec(lines[i] ?? ''); + if (m === null) break; + endpoints.push({ address: m[1] as string, url: m[2] as string }); + } + return endpoints.length > 0 ? endpoints : undefined; +} + +async function waitForAsync( + fn: () => Promise, + timeoutMs: number, + intervalMs = 250, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + for (;;) { + try { + const result = await fn(); + if (result !== undefined) return result; + } catch (error) { + lastError = error; + } + if (Date.now() >= deadline) { + throw lastError instanceof Error + ? lastError + : new Error(`waitFor: not met within ${timeoutMs}ms`); + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } +} + +function baseEnv(extra: Record): NodeJS.ProcessEnv { + const env = { ...process.env }; + delete env['PRISMA_WORKSPACE_ID']; + delete env['PRISMA_SERVICE_TOKEN']; + delete env['PRISMA_REGION']; + for (const [key, value] of Object.entries(extra)) { + if (value === undefined) delete env[key]; + else env[key] = value; + } + return env; +} + +/** Starts `prisma-composer dev [--fresh]` against the fixture, teed to a log file. Does not wait for readiness — callers pick the wait strategy (ready vs. exit). */ +function startDevRaw( + env: NodeJS.ProcessEnv, + opts: { readonly fresh?: boolean } = {}, +): { child: ChildProcess; logPath: string } { + sessionCount += 1; + fs.mkdirSync(logDir, { recursive: true }); + const logPath = path.join(logDir, `session-${sessionCount}.log`); + const logFd = fs.openSync(logPath, 'w'); + const args = ['dev', fixtureEntry, ...(opts.fresh === true ? ['--fresh'] : [])]; + const child = spawn(CLI_BIN, args, { + cwd: integrationDir, + env, + stdio: ['ignore', logFd, logFd], + }); + fs.closeSync(logFd); + return { child, logPath }; +} + +async function startDevUntilReady( + env: NodeJS.ProcessEnv, + opts: { readonly fresh?: boolean } = {}, +): Promise { + const { child, logPath } = startDevRaw(env, opts); + const endpoints = await waitForAsync( + async () => parseFrontDoor(readLog(logPath)), + READY_TIMEOUT_MS, + 500, + ); + return { child, logPath, endpoints }; +} + +/** Waits for the process to exit on its own (the hard-error path never reaches "ready"). */ +async function waitForExit(child: ChildProcess, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`process did not exit within ${timeoutMs}ms`)); + }, timeoutMs); + child.once('exit', (code) => { + clearTimeout(timer); + resolve(code); + }); + }); +} + +async function stopDev(session: DevSession): Promise { + if (session.child.exitCode !== null || session.child.signalCode !== null) return; + session.child.kill('SIGINT'); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`session did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGINT`)); + }, SHUTDOWN_TIMEOUT_MS); + session.child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +async function main(): Promise { + console.log( + 'local dev (S5 proving): criteria 4 (bucket) and 5 (placeholder/env-param) against the S4 fixture', + ); + + fs.rmSync(path.join(integrationDir, '.prisma-composer'), { recursive: true, force: true }); + fs.rmSync(path.join(integrationDir, '.alchemy'), { recursive: true, force: true }); + + let session: DevSession | undefined; + try { + // ——— Criterion 5a: the secret is unset — a placeholder warning, topology still serves ——— + session = await startDevUntilReady( + baseEnv({ + LOCALDEV_FIXTURE_API_KEY: undefined, + LOCALDEV_FIXTURE_GREETING: 'hi-from-s5-proving', + }), + ); + console.log(`[proving] session 1 ready: ${JSON.stringify(session.endpoints)}`); + const log1 = readLog(session.logPath); + assert( + log1.includes( + '[dev] LOCALDEV_FIXTURE_API_KEY is not set in this shell — using a local placeholder. Anything that talks to the real service behind it will fail; everything else runs.', + ), + 'the pinned placeholder warning must appear for the unset secret', + ); + console.log('[proving] PASS criterion 5a: placeholder warning printed, topology still serves'); + + // ——— Criterion 4: bucket round-trip through the app ——— + const web = session.endpoints.find((e) => e.address === 'web'); + assert(web !== undefined, 'the web service must appear in the front door'); + if (web === undefined) throw new Error('unreachable'); + + const putRes = await waitForAsync( + () => + fetch(`${web.url}/blobs/proving-key.txt`, { + method: 'PUT', + headers: { 'content-type': 'text/plain' }, + body: 'hello from the S5 bucket proof', + }).then((r) => (r.status === 201 ? r : undefined)), + 15_000, + ); + assertEqual(putRes.status, 201, 'PUT /blobs/proving-key.txt through the app'); + + const objectPath = path.join(devDir, 'buckets', 'files', 'proving-key.txt'); + const onDisk = await waitForAsync( + async () => (fs.existsSync(objectPath) ? fs.readFileSync(objectPath, 'utf8') : undefined), + 5_000, + 200, + ); + assertEqual(onDisk, 'hello from the S5 bucket proof', 'the PUT object is a plain file on disk'); + console.log(`[proving] PASS criterion 4 (write direction): ${objectPath}`); + + // Drop a file directly into the bucket directory, read it back through the app. + const droppedPath = path.join(devDir, 'buckets', 'files', 'dropped-key.txt'); + fs.writeFileSync(droppedPath, 'dropped straight onto disk'); + const getRes = await waitForAsync( + () => + fetch(`${web.url}/blobs/dropped-key.txt`).then((r) => (r.status === 200 ? r : undefined)), + 10_000, + ); + const getBody = await getRes.text(); + assertEqual( + getBody, + 'dropped straight onto disk', + 'a file dropped on disk is readable back through the app', + ); + console.log( + '[proving] PASS criterion 4 (read direction): a dropped file is visible through the app', + ); + + await stopDev(session); + console.log('[proving] session 1 stopped cleanly on SIGINT'); + + // ——— Criterion 5b: the envParam is unset — a hard error naming it, nonzero exit ——— + const { child: child2, logPath: logPath2 } = startDevRaw( + baseEnv({ + LOCALDEV_FIXTURE_API_KEY: 'a-real-looking-key', + LOCALDEV_FIXTURE_GREETING: undefined, + }), + ); + const exitCode = await waitForExit(child2, EXIT_TIMEOUT_MS); + const log2 = readLog(logPath2); + assert( + exitCode !== 0 && exitCode !== null, + 'dev must exit nonzero when a required envParam is unset', + ); + assert( + log2.includes( + 'local dev preflight failed — 1 env-sourced param(s) are not set in this shell:', + ), + 'the pinned listing-error header must appear', + ); + assert( + /LOCALDEV_FIXTURE_GREETING\s+\(required by service "bkg"\)/.test(log2), + 'the error must name LOCALDEV_FIXTURE_GREETING and the service that requires it', + ); + assert( + log2.includes('Set each in the shell you run `prisma-composer dev` from.'), + 'the pinned fix instruction must appear', + ); + console.log( + `[proving] PASS criterion 5b: exit ${String(exitCode)}, pinned listing error naming LOCALDEV_FIXTURE_GREETING`, + ); + + console.log('PASS: local dev (S5 proving) — criteria 4 and 5'); + } finally { + if (session !== undefined) { + await stopDev(session).catch(() => undefined); + } + // Full app-scoped teardown through the CLI's own --fresh, exactly as + // local-dev-store.integration.ts does — removes the real `prisma dev` + // instance this fixture's `postgres({ name: 'appdb' })` created, the + // emulators' app-scoped records, and the local state dir, never the + // machine-global daemons. + try { + const cleanup = await startDevUntilReady( + baseEnv({ LOCALDEV_FIXTURE_API_KEY: 'cleanup-key', LOCALDEV_FIXTURE_GREETING: 'cleanup' }), + { fresh: true }, + ); + await stopDev(cleanup); + } catch (error) { + console.error( + `[proving] final cleanup did not complete cleanly: ${error instanceof Error ? error.message : String(error)}`, + ); + } + const stray = spawnSync('pgrep', ['-f', `prisma-composer dev ${fixtureEntry}`]); + for (const line of (stray.stdout?.toString() ?? '').split('\n')) { + const pid = Number(line.trim()); + if (Number.isFinite(pid) && pid > 0) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // already gone + } + } + } + } +} + +main() + .then(() => { + process.exitCode = 0; + }) + .catch((error: unknown) => { + console.error(error instanceof Error ? (error.stack ?? error.message) : error); + process.exitCode = 1; + }); diff --git a/test/integration/test/local-dev.integration.ts b/test/integration/test/local-dev.integration.ts index 74da2f150..4c5e30dda 100644 --- a/test/integration/test/local-dev.integration.ts +++ b/test/integration/test/local-dev.integration.ts @@ -377,6 +377,16 @@ async function main(): Promise { bucketsPreExisting = readEmulatorEntry('buckets') !== undefined; postgresPreExisting = readEmulatorEntry('postgres') !== undefined; + // dev.preflight() (unlike every other dev hook here) computes its own + // state directory from `process.cwd()` rather than accepting one — every + // other call in this script is handed `devDir` explicitly (fixtureDir- + // based). This script runs from the package root (test/integration), not + // the fixture dir, so preflight must be run with cwd temporarily pointed + // at the fixture — otherwise its secrets.json write (S5's secret/env-param + // proof) lands somewhere this script never looks. Restored in `finally`. + const originalCwd = process.cwd(); + process.chdir(fixtureDir); + try { // 1. Construct the extension with NO PRISMA_* present — proves the // scrubbed-env construction requirement through the real factory. @@ -413,10 +423,16 @@ async function main(): Promise { // 4. Assemble both services (real build adapter, hand-written "built" entries). const bundles = await assembleFixture(); - // 5. Preflight — this fixture binds no secrets/env-params; must be a no-op. + // 5. Preflight — the fixture's secret/env-param proof lives in the + // dedicated S5 script (local-dev-criteria-4-5.integration.ts), which + // deliberately runs WITHOUT these set; this script sets both so its own + // (unrelated) proofs keep passing regardless of shell state. + process.env['LOCALDEV_FIXTURE_API_KEY'] = 'test-api-key'; + process.env['LOCALDEV_FIXTURE_GREETING'] = 'hello'; if (dev.preflight !== undefined) { await dev.preflight({ graph, container: devContainer, stage: undefined }); } + process.chdir(originalCwd); // 6. Emulators — ensures compute always, buckets because the graph has // an `s3`-kinded resource, postgres because the graph has a @@ -492,12 +508,19 @@ async function main(): Promise { 'the deployed web child carries COMPOSER_WEB_PORT = the emulator-assigned port, JSON-encoded', ); - // secrets.json: no secrets/params in this fixture, so it is either - // absent or empty — never populated. - const secrets = readJson(path.join(devDir, 'secrets.json')); - assert( - secrets === undefined || Object.keys(secrets as object).length === 0, - 'secrets.json must be absent or empty for this fixture', + // secrets.json: the shell-sourced secret + env-param this run set above, + // verbatim (S5 addition — the placeholder/hard-error paths have their + // own dedicated script). + const secrets = readJson(path.join(devDir, 'secrets.json')) as Record; + assertEqual( + secrets['LOCALDEV_FIXTURE_API_KEY'], + 'test-api-key', + 'secrets.json carries the shell-sourced secret', + ); + assertEqual( + secrets['LOCALDEV_FIXTURE_GREETING'], + 'hello', + 'secrets.json carries the shell-sourced env-param', ); // 11. The postgres-main daemon's own listing (REVISED — operator review @@ -589,6 +612,7 @@ async function main(): Promise { console.log('PASS: local dev (S4) integration proof'); } finally { + process.chdir(originalCwd); await attachment?.stopServices().catch(() => undefined); // App-scoped teardown through the extension's own public From e8c21f8f4df5273023a7e01c30fc93b9c876ea42 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 12:59:42 +0200 Subject: [PATCH 13/43] fix(cli): retry the post-converge endpoints() fetch; disable SQL prepare in the proving script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real, reproducible flakes surfaced running the store proving script repeatedly against a 5-service app (examples/store) rather than the S4 fixtures 2-service one: 1. run-dev.ts: attach()s endpoints() fetch, called immediately after a converge that just PUT dozens of resources through the same loopback connection, occasionally hit a transient refused/reset connection on a real, busier app — confirmed live (converge succeeded, "Done: 102 succeeded", then "Error: fetch failed" tore the whole dev session down). Retried up to 3 times with a 500ms delay before giving up; a genuine failure still surfaces after the retries exhaust. 2. local-dev-store.integration.ts: opening a fresh Bun.SQL client per call against the same URL (this scripts own pattern, not app code) could race the previous connections prepared-statement cleanup, surfacing as `prepared statement "..." already exists`. This is a storage-layer proof, not a prepared-statement-caching one, so `prepare: false` turns caching off. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../3-tooling/cli/src/dev/run-dev.ts | 23 ++++++++++++++++++- .../test/local-dev-store.integration.ts | 11 ++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts index 33da34286..64601bbf0 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts @@ -75,10 +75,31 @@ function printFrontDoor( for (const line of renderFrontDoor(endpoints)) console.log(line); } +const ENDPOINTS_RETRY_ATTEMPTS = 3; +const ENDPOINTS_RETRY_DELAY_MS = 500; + +/** `attachment.endpoints()` right after a converge that just PUT dozens of resources through the same emulator connection can hit a transient refused/reset connection — a brief, genuinely transient loopback hiccup, not a real failure. Retried a few times before giving up. */ +async function endpointsWithRetry( + attachment: DevAttachment, +): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= ENDPOINTS_RETRY_ATTEMPTS; attempt += 1) { + try { + return await attachment.endpoints(); + } catch (error) { + lastError = error; + if (attempt < ENDPOINTS_RETRY_ATTEMPTS) { + await new Promise((resolve) => setTimeout(resolve, ENDPOINTS_RETRY_DELAY_MS)); + } + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} + async function mergedEndpoints( attachments: readonly DevAttachment[], ): Promise { - const lists = await Promise.all(attachments.map((a) => a.endpoints())); + const lists = await Promise.all(attachments.map((a) => endpointsWithRetry(a))); return lists.flat(); } diff --git a/test/integration/test/local-dev-store.integration.ts b/test/integration/test/local-dev-store.integration.ts index 5582801e2..1b3e4c0fc 100644 --- a/test/integration/test/local-dev-store.integration.ts +++ b/test/integration/test/local-dev-store.integration.ts @@ -201,8 +201,17 @@ function readCatalogDbUrl(): string { } /** Runs `bun`'s built-in SQL client against the real local Postgres URL — the storage layer, never the app's own RPC. */ +/** + * `prepare: false` — this script opens a fresh `Bun.SQL` client per call + * against the same URL; Bun's prepared-statement cache is keyed by query + * TEXT, and a rapid open/close/reopen cycle against the same connection + * string can race the previous connection's prepare cleanup, surfacing as + * `prepared statement "..." already exists` — confirmed live. This is a + * storage-layer proof, not a prepared-statement-caching one, so caching is + * simply off. + */ async function withSql(url: string, fn: (sql: Bun.SQL) => Promise): Promise { - const sql = new Bun.SQL(url); + const sql = new Bun.SQL({ url, prepare: false }); try { return await fn(sql); } finally { From 6b9ac0042812e686c45a3a63f08bb5f977779c34 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 13:04:48 +0200 Subject: [PATCH 14/43] docs(drive): re-sync spec.md/plan.md with the design tip (147eef8) The design branch advanced again during this session (strikes the dir() adapter entirely - node()s existing directory form already covers the need, per the operators catch). Re-synced wholesale so the diff against bot/claude/local-dev-feature-design-0b49fd stays empty; does not touch any S5 code (dir() was S2s scope, never referenced from this branch). Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .drive/projects/local-dev/plan.md | 27 +-- .drive/projects/local-dev/spec.md | 280 ++++++++---------------------- 2 files changed, 91 insertions(+), 216 deletions(-) diff --git a/.drive/projects/local-dev/plan.md b/.drive/projects/local-dev/plan.md index 90e3208fe..ffe4acfd8 100644 --- a/.drive/projects/local-dev/plan.md +++ b/.drive/projects/local-dev/plan.md @@ -31,17 +31,18 @@ scripts. Implementer dispatches use Sonnet-4.6-mid, reviewers Opus-4.8-mid devDependency in tests only) incl. presigned GET/PUT. - **Spec sections:** § 1, behavior contracts. -### S2 — `dir()` build adapter - -- **Outcome:** `@prisma/composer/dir` authoring + assemble per spec § 7: - verbatim tree copy, named entry, symlink hard error, wrapper bundling - identical to `node()`'s. Also lands the `Bundle.watch` contract (spec - § 3): the optional core field plus `watch` population in `node()`, - `nextjs()`, and `dir()`. Guide/docs entry. -- **Proves:** assemble tests (missing dir/entry errors verbatim, symlink - error, tree fidelity); a fixture app with a multi-file runnable deploys - through the full assemble path (no cloud needed — assemble-level test). -- **Spec sections:** § 7. +### S2 — `Bundle.watch` + directory-form fixes (REVISED — no new adapter) + +- **Outcome (revised):** `node()`'s existing directory form is friction + #3's fix, already on `main` — the originally planned `dir()` duplicate + is struck. S2 lands: the `Bundle.watch` contract (spec § 3) populated in + `node()` (single-file → entry file; directory form → the whole dir) and + `nextjs()`; the symlink-as-`dir` hard-error fix, which is a real hole in + `main`'s directory form; doc corrections where `node()` is still + described as single-file-only. +- **Proves:** directory-form assemble tests (symlink-as-dir on both file + and directory targets, tree fidelity, watch fields per form). +- **Spec sections:** § 7 (revised), § 3's watch bullets. ### S3 — `@internal/dev-emulators`: daemon layer + Compute & bucket emulators @@ -111,8 +112,8 @@ scripts. Implementer dispatches use Sonnet-4.6-mid, reviewers Opus-4.8-mid ### S6 — proof on the open-chat port + measurement + close-out docs -- **Outcome:** the open-chat port (separate repo) switched to `dir()` + - `prisma-composer dev`, replacing `scripts/dev.ts`; sign-in/history/ +- **Outcome:** the open-chat port (separate repo) switched to `node()`'s + directory form + `prisma-composer dev`, replacing `scripts/dev.ts`; sign-in/history/ live-tail verified; friction found lands here as fixes (re-triaged if large); restart latency for `examples/store` measured and recorded; `deploy-cli.md` scope updated; `local-dev.md` + ADR-0041 reconciled with diff --git a/.drive/projects/local-dev/spec.md b/.drive/projects/local-dev/spec.md index 53df687c4..30d0beff1 100644 --- a/.drive/projects/local-dev/spec.md +++ b/.drive/projects/local-dev/spec.md @@ -27,7 +27,7 @@ construction above the Alchemy provider boundary. | # | Decision | Where recorded | |---|---|---| | D1 | Dev = the deploy pipeline retargeted at the Alchemy **provider** boundary; never an HTTP Management-API emulation, never a bespoke per-kind dev harness | ADR-0041 | -| D2 | The seam is `ExtensionDescriptor.dev?: DevExtensionDescriptor` — `providers`, `container`, `preflight`, `emulators`, `attach`, `teardown`; **no `nodes`, no `provisions`, no `state`** | ADR-0041 | +| D2 | The seam is `ExtensionDescriptor.dev?: DevDescriptor` — `providers`, `container`, `preflight`, `emulators`, `attach`, `teardown`; **no `nodes`, no `provisions`, no `state`** | ADR-0041 | | D3 | Dev Alchemy state = alchemy's own `localState()` via the existing `LowerOptions.state` override, always at Alchemy stage `dev` | ADR-0041 | | D4 | One machine-scoped, multi-tenant emulator **per node kind**, ensured by the topology-aware `dev.emulators` hook; providers provision isolated instances by communicating with them; converge terminates and the Compute emulator keeps serving | ADR-0041 | | D5 | The dev command owns no processes: it is a view through `dev.attach` (endpoints, merged logs, stop control). Ctrl-C stops the app's service instances; emulators + data persist; `--fresh` removes; detached mode is a designed extension, not v1 | ADR-0041, local-dev.md | @@ -163,10 +163,8 @@ plus the shared daemon layer and typed loopback clients. `version` === this package's version → return `http://127.0.0.1:`. 3. Version mismatch → SIGTERM (5 s grace, SIGKILL), fall through. Dead pid / failed health → clean the entry, fall through. - 4. Start: port = persisted port if any, else allocated via `get-port` - (preferred range ≥ 4300, excluding registry-used ports — the free-port - PROBE is the library's, our persistence and range policy stay ours), - persisted immediately. Spawn `process.execPath + 4. Start: port = persisted port if any, else smallest ≥ 4300 unused across + registry entries, persisted immediately. Spawn `process.execPath --port --state-dir //` with `detached: true`, stdio appended to `/.log`, `unref()` — the `registryRoot` override governs the registry file, the @@ -187,19 +185,17 @@ plus the shared daemon layer and typed loopback clients. endpoints frozen in deploy state reference it — fail with the pinned error; recovery is manual (`--fresh` does NOT touch the daemons — they are machine-global, shared by other apps). -- **Concurrent-ensure protocol (REVISED — operator decision, 2026-07-23):** - the observe→spawn→persist critical section is serialized ACROSS - PROCESSES per daemon name using `proper-lockfile` (maintained library; - its staleness/compromise semantics are adopted wholesale rather than our - earlier hand-rolled mkdir/pid protocol — commodity locking is exactly - where unforeseen edge cases hide). Our wrapper pins only: the lock path - is per-daemon under ``; the bounded wait is ~10 s and its - exhaustion throws - `Error: timed out waiting for another process ensuring the emulator — remove if stale.`; - the registry is RE-READ after acquiring (the previous holder may have - already done the job); port allocation happens inside the lock; release - runs in a finally on every path. The existing inter-process tests stand - unchanged — they validate behavior, not the engine. +- **Concurrent-ensure protocol:** the observe→spawn→persist critical + section is serialized ACROSS PROCESSES per daemon name with an atomic + directory lock: `mkdir /.lock-` (atomic creation IS + acquisition; the holder writes its pid to `/pid`). On `EEXIST`: + if the recorded holder pid is dead, remove the lock dir and retry + immediately; else poll every 250 ms up to 10 s, then + `Error: timed out waiting for another process ensuring the emulator — remove if stale.` + After acquiring, RE-READ the registry before deciding to spawn — the + previous holder may have already done the job. Port allocation happens + inside the lock, so two daemons can never claim one port. Release + (rm the dir) in a finally. - `stopDaemon(name)`: SIGTERM/SIGKILL + registry cleanup. Not called by any v1 command — an operator escape hatch, exported for tests. - **Publish note (S5 scope):** `import.meta.resolve('@internal/dev-emulators/…')` @@ -243,14 +239,6 @@ service child processes. Loopback `node:http` JSON admin API; state under its log file's current tail, then live lines while open. - `POST /apps//stop` → stop every child of the app (records kept, status `stopped`) → `204`. -- `POST /apps//start` → start every service that has a stored - deployment spec and is not `running`, from that spec (a service with no - deployment recorded is skipped) → `204`. The symmetric inverse of - `/stop`, and the reason it must exist: Alchemy correctly skips a - provider's reconcile when nothing diffed, so a no-op converge can never - restart services a previous session stopped — the dev SESSION is the - explicit resume signal (found live on the open-chat proof: warm restart - printed ready while every service stayed stopped). - `DELETE /apps/` → stop + remove the app's records and logs → `204`. Supervision policy (emulator-owned): unexpected exit → restart with backoff @@ -317,8 +305,8 @@ Used by the local providers (§ 4) and the extension's `emulators`/`attach`/ `src/control/app-config.ts` — add, exported through `exports/app-config.ts`: ```ts -/** The extension's dev-mode counterpart (ADR-0041) — the dev variant OF ExtensionDescriptor, hence the full qualifier. An extension without one is not dev-capable. */ -export interface DevExtensionDescriptor { +/** Local counterparts for `prisma-composer dev` (ADR-0041). An extension without one is not dev-capable. */ +export interface DevDescriptor { /** Local providers for the SAME resource types this extension's lowering emits. Receives the app identity — unlike deploy's env-arg-free `providers()`, local providers are emulator clients and must know which app they provision for. */ providers(input: DevProvidersInput): Layer.Layer; /** A stable local identity — resolved without any platform call. */ @@ -354,8 +342,6 @@ export interface DevAttachInput { } export interface DevAttachment { - /** Start every stopped service from its last deployment (the session-resume signal — a no-op converge cannot start anything). */ - startServices(): Promise; /** Every service's local endpoint, for the front door. */ endpoints(): Promise; /** Merged, line-oriented log stream across the app's services (including services that appear after later converges). Ends when `signal` aborts. */ @@ -371,7 +357,7 @@ and on `ExtensionDescriptor`: ```ts /** Local dev counterparts (ADR-0041). */ - readonly dev?: DevExtensionDescriptor; + readonly dev?: DevDescriptor; ``` `src/control/deploy.ts`: @@ -385,77 +371,37 @@ and on `ExtensionDescriptor`: `[dev]
has no watchable inputs` note at startup). - Adapters populate `watch`: `node()` → `[resolved entry file]`; `nextjs()` → `[the standalone output dir]`; `dir()` (§ 7) → `[the resolved dir]`. -- **REVISED (operator review of #162): `lower()` learns nothing about dev.** - There is NO `LowerOptions.dev` flag and no dev branch inside the - deployment layer. `LowerOptions` gains `readonly providers?: - Layer.Layer` (explicit provider set; precedence over the config's, - exactly like the existing `state` override). Provenance is resolved at - ONE orchestration point: the GENERATED dev stack module (§ 6) itself - calls the already-exported `deserializeContainers(config.extensions, - process.env)` and passes `providers: devProviders(config, containers, - devDir)` + `state: localState()` explicitly. Everything downstream of - `lower()` is ignorant of the provenance of its providers. -- `devProviders(config, containers, devDir)` (the aggregator formerly named - `mergedDevProviders`) moves OUT of `deploy.ts` into a dev-specific core - control module (`control/dev.ts`, exported via `exports/app-config.ts`), - keeping its pinned error string and the build-only exemption - (`isBuildOnlyExtension` in `app-config.ts`, shared with the CLI check). +- `LowerOptions` gains `readonly dev?: boolean`. +- New `export function mergedDevProviders(config: PrismaAppConfig, + containers: ReadonlyMap, devDir: string): + Layer.Layer` — like `mergedProviders` but calling + `extension.dev.providers({ container: containers.get(extension.id), + devDir })`; an extension with `dev === undefined` throws `LowerError` with + message exactly: + `extension "" has no dev support — it declares no \`dev\` descriptor (ADR-0041).` + **Build-only exemption:** an extension participating in assembly only — + every `nodes` entry is `kind: 'build'` AND it declares none of + `providers`/`application`/`provisions`/`container` — has nothing to + emulate and is exempt: `mergedDevProviders` skips it silently, and every + dev hook iteration (containers, preflight, emulators, attach, teardown — + § 6) applies the same rule. Export the predicate as + `isBuildOnlyExtension(extension)` in `app-config.ts` so the CLI and + `mergedDevProviders` share one definition. +- `lower()`: when `opts.dev === true`, use `mergedDevProviders(config, + containers, path.join(process.cwd(), DEV_DIR))` — `containers` is the map + `lower()` already deserializes from the env transport; state resolution is + unchanged (`resolveStateLayer` — dev passes `opts.state`, which already + takes precedence). There is no framework-owned process table: service processes belong to the Compute emulator (§ 2), and core's whole view of the running app is the `DevAttachment` the extension returns. -### 4. `@internal/local-target` — NEW package (REVISED — operator review of #162) - -The local provider suite does NOT live inside `@internal/lowering` — -"lowering" is a Composer primitive and a "lowering dev" subtree conflates -vocabularies. New package `@internal/local-target` at -`packages/1-prisma-cloud/0-lowering/local-target/` (same layer as the -hosted providers, its own name: it IS the local deploy target's provider -suite), plane `control`, importing `@internal/dev-emulators` and -`@internal/s3-protocol`. Everything previously specced under -`@internal/lowering`'s `src/dev/` lives here instead — INCLUDING -`artifact-extract` (operator: emulation code is physically separated from -production source, full stop; the tar reader exists only for dev, so it -and its `tar` dependency live here, not in `@internal/lowering`). -Format-drift protection comes from a round-trip test in this package that -imports the WRITER (`packageComputeArtifact`, a downward import) and -asserts extraction fidelity — co-location is not the mechanism. No -floating module-level doc comments that attach to nothing (repo style). - -**Physical-separation rule (operator, 2026-07-23), applied everywhere:** -dev/emulation code lives in dev-only packages (`@internal/local-target`, -`@internal/dev-emulators`) or dev-only modules/entry points — never -intermixed with production logic. In core, the dev aggregator -(`devProviders`) and `DEV_DIR` are exported via a NEW control-plane -subpath `@prisma/composer/dev` (its own `src/exports/dev.ts`; the -generated dev stack module imports from it) — nothing dev-flavored is -exported from `/deploy` or the root. The seam TYPES -(`DevExtensionDescriptor` and its inputs) remain in `app-config.ts` -because `ExtensionDescriptor.dev` must reference them — they are contract, -not emulation logic. The IMPLEMENTATIONS are fully excluded from -production bundles (operator directive): see the lazy dev reference -below. - -**The dev seam is a lazy reference (REVISED — operator directive):** -`ExtensionDescriptor.dev?: () => Promise`. The -production control entry carries ONE line — a thunk dynamically importing -the extension's own dev entry by bare specifier — so no dev implementation -code is bundled into, or loaded by, any deploy path. The extension ships a -separate control-plane entry `src/exports/dev.ts` → public subpath -`@prisma/composer-prisma-cloud/dev`, exporting `devDescriptor(): -DevExtensionDescriptor` (self-contained; the dev side needs none of the -deploy factory's options — it is credential-free by design). `prismaCloud()`'s -field is exactly: -`dev: () => import('@prisma/composer-prisma-cloud/dev').then((m) => m.devDescriptor())`. -Core's `@prisma/composer/dev` subpath gains -`resolveDevDescriptors(config): Promise>` -(resolves every non-build-only extension's thunk once, with the pinned -no-dev-support error for a missing thunk); `devProviders(resolved, -containers, devDir)` takes the RESOLVED map. The generated dev stack -module top-level-awaits `resolveDevDescriptors(config)` (ESM TLA); the -dev command resolves once at step 2 and passes resolved descriptors to -every subsequent hook call. Deploy and destroy never touch `dev`. +### 4. `@internal/lowering` (`packages/1-prisma-cloud/0-lowering/lowering`) + +New directory `src/dev/`, exported as subpath `@internal/lowering/dev` +(add to `src/exports/` per `.agents/rules/exports-entrypoints.mdc`; plane +`control` like the rest of the package). #### `src/dev/dev-store.ts` — the shared dev-instance store @@ -479,10 +425,6 @@ registry (§ 2). Every local provider factory takes `(input: DevProvidersInput)`; `devDir` is `input.devDir`; nothing here reads `process.cwd()` or the environment. -The emulator-facing service id is `slugServiceId(name)` (the same -lowercase/hyphen slugging as postgres instance names) because a dotted -address cannot satisfy § 2's id-segment rule; the real dotted address rides -the deployment body and labels logs/endpoints. Two layer-order accommodations are pinned (this package cannot import the extensions layer): the app name is read as `input.container.input.appName` directly off the generic `ContainerInstance` (the field is on the base @@ -506,16 +448,11 @@ scope, not v1. - `LocalDeploymentProvider(input)`: `reconcile`: 1. Unpack `news.artifactPath` (tar.gz, the ustar format `packageComputeArtifact` writes) into - `/artifacts//` if absent — extraction uses the - maintained `tar` package (dependency razor), implemented IN - `@internal/local-target` (physical-separation rule: the reader exists - only for dev), with entry filtering pinned: regular files only, - reject links/devices, reject path escapes. Temp-then-rename at the - directory level. (WHY an extractor exists at all: on the platform the - artifact is uploaded and the platform unpacks it; locally the Compute - emulator runs from a directory, so the platform-side unpack step - needs a local counterpart. Unpacking the real tar keeps the strongest - parity: what runs is byte-for-byte what ships.) + `/artifacts//` if absent — implemented by a new + `src/compute/artifact-extract.ts` (`extractComputeArtifact(tarGzPath, + destDir)`), a minimal ustar reader matching exactly what the writer + emits: regular files, name+prefix fields, no links (error on any other + typeflag). Extraction goes temp-then-rename at the directory level. 2. Fetch the service's port: `PUT /apps//services/` (idempotent) where `id` = the ComputeService's name resolved from `news.computeServiceId`. Resolve the address from @@ -550,46 +487,15 @@ scope, not v1. `{ id: 'local', ... }` shapes; present so the provider collection stays total, though current lowerings never yield `Project`. -#### Postgres providers — programmatic `@prisma/dev` (REVISED — operator review of #162; the S7 follow-up is pulled INTO this wave) - -The CLI shell-out is deleted wholesale: no bin walk-up (`resolve-bin.ts` -gone), no stdout URL parsing, no `spawn-utils.ts`, no `prisma dev -stop/rm` glob teardown. Instead, a third emulator daemon `postgres-main` -(in `@internal/dev-emulators`, § 2's daemon layer) hosts -`startPrismaDevServer({ name, databasePort, persistenceMode })` from -`@prisma/dev` — one named persistent server per `Database` resource, port -allocated from our registry (stable, persisted), admin over the same -loopback pattern as the other daemons (create/ensure database instance, -list, remove). The local Database/Connection providers become its clients; -teardown removes the app's instances through the daemon's admin API. -`postgres-main`'s admin surface (loopback, same daemon layer/registry as -the other two): `GET /health`; `PUT /apps//databases/` body -`{ "prismaDevModulePath": string }` → ensure a named persistent server -(instance name per the derivation below; `databasePort` from the daemon's -own persisted state, fresh-allocation retry per the § 2 pattern; the -`@prisma/dev` module is imported from the CALLER-RESOLVED path so the app -owns the version) → `{ "url": string }`, idempotent; `GET -/apps//databases` → listing; `DELETE /apps/` → close the app's -servers and delete their persisted data (this is `--fresh`'s path). A -server that fails to start surfaces the underlying error text verbatim -(credential-masked) in the 500 body. -Version ownership: `@prisma/dev` resolves from the APP's node_modules -(passed into the daemon as a resolved path), keeping the app in charge of -its Prisma version. Resolution is two-step, pinned: (1) -`createRequire(join(cwd, 'package.json')).resolve('@prisma/dev')`; (2) on -failure, resolve `prisma`'s own package first and resolve `@prisma/dev` -from THERE (apps typically depend on the `prisma` CLI, which carries it); -both failing → -`Error: local dev needs @prisma/dev for its local Postgres emulator — add "prisma" to your app's devDependencies.` -The local `Database` provider PUTs the daemon's ensure with that path and -stores the returned `{ url }` on its own ATTRIBUTES; the local -`Connection` provider resolves its url from the daemon's listing (GET -databases, matched by instance name derived from `news.databaseId`). -`dev-store`'s `postgres.json` is DELETED — the daemon owns instance state. -`resolve-bin.ts` and `spawn-utils.ts` are deleted with the shell-out. +#### `src/dev/postgres.ts` — local postgres cluster providers + - Instance name derivation: `pcdev--`, where `` and `` are lowercased with every char outside `[a-z0-9]` replaced by `-`, runs collapsed, trimmed to 63 chars. +- Bin resolution: walk up from cwd for `node_modules/.bin/prisma` (the + `resolveAlchemyBin` pattern, generalized as `resolveLocalBin(startDir, + binName)` in `src/dev/resolve-bin.ts`). Missing → + `Error: local dev needs the prisma CLI for its local Postgres emulator ('prisma dev') — add "prisma" to your app's devDependencies.` - `LocalDatabaseProvider(input)`: `reconcile` ensure sequence: 1. No `postgres.json` entry → run ` dev --name --detach`, capture stdout, take the @@ -664,20 +570,15 @@ export const devProviders = (input: DevProvidersInput) => No `ManagementClient`, no credentials layer — the dev bundle must typecheck without either. -#### The address rides the artifact, not the platform primitive (REVISED — operator review of #162) - -`DeploymentProps.serviceAddress` is REVERTED — dev-only configuration must -not leak into platform primitives. The address is already intrinsic to the -packaged artifact (its `bootstrap.js` bakes `run("
", …)`), so the -artifact's OWN manifest carries it: `packageComputeArtifact` writes -`compute.manifest.json` as `{ manifestVersion: "1", entrypoint: -"bootstrap.js", address: "
" }` — no version bump (operator: there -are no consumers to protect; the platform reads only `entrypoint`). -The local Deployment provider reads `address` from the unpacked artifact's -manifest; a manifest without it → -`Error: artifact manifest carries no address — repackage with a current @prisma/composer.` -`ComputeSerialized.address` and the serialize/deploy threading are also -reverted. +#### Lowering handoff change (shared with deploy — compile-checked) + +`DeploymentProps` gains `readonly serviceAddress?: string`. The hosted +provider ignores it (documented on the prop: local-dev only; the hosted +platform derives nothing from it). `descriptors/compute.ts`: add +`address: string` to `ComputeSerialized`, populated from `ctx.address` in +`serialize`, threaded into the `Deployment` call in `deploy` as +`serviceAddress`. The local provider REQUIRES it: +`Error: Deployment for "" carries no serviceAddress — the lowering predates local dev support.` ### 5. Target extension (`packages/1-prisma-cloud/1-extensions/target`) @@ -705,12 +606,11 @@ New control-plane files (all under `src/`, plane `control` in `missingError` but scoped `local dev` and instructing `Set each in the shell you run \`prisma-composer dev\` from.` - `src/dev/emulators.ts` — `runDevEmulators(input: DevEmulatorsInput)`: - inspect the graph's node kinds; `ensureDaemon('compute', …)` always - (every app has services); `ensureDaemon('buckets', …)` when any - `s3`-kinded resource node exists; `ensureDaemon('postgres', …)` when any - `postgres`- or `prisma-next`-kinded resource node exists (REVISED — - Postgres is a first-class daemon since the programmatic adoption). - Idempotent; prints one `[dev]` line per daemon it actually started. + inspect the graph's node kinds; `ensureDaemon('compute')` always (every + app has services); `ensureDaemon('buckets')` when any `s3`-kinded resource + node exists. Postgres needs no pre-start — its instances are created at + provision through the ORM CLI. Idempotent; prints one `[dev]` line per + daemon it actually started. - `src/dev/attach.ts` — `devAttach(input: DevAttachInput): DevAttachment`, a Compute-emulator client scoped to the app: - `endpoints()` → `GET /apps//services`, mapped to @@ -721,7 +621,6 @@ New control-plane files (all under `src/`, plane `control` in attaching followers for services that appeared after a later converge and re-attaching any follower whose connection dropped (an emulator restart shows a gap, never a dead session). - - `startServices()` → `POST /apps//start`. - `stopServices()` → `POST /apps//stop`. - `src/dev/teardown.ts` — `runDevTeardown(input: TeardownInput)`: 1. ` dev stop 'pcdev--*'` then @@ -804,9 +703,7 @@ New control-plane files (all under `src/`, plane `control` in DEV_STACK_RELATIVE_PATH, cwd, stage: 'dev', containerEnv })`. Nonzero exit: print the stack-file reproduction hint (deploy's pattern, with `--stage dev`) and exit with that status. - 8. Attach: `dev.attach({ container, devDir })` per extension; call every - attachment's `startServices()` (resume services a previous session's - Ctrl-C stopped — converge cannot, when nothing diffed); then print the + 8. Attach: `dev.attach({ container, devDir })` per extension; print the front door from the merged `endpoints()` (ordered by address depth, fewest dots first, then lexicographic; first line preceded by `[dev] ready:`); pump every attachment's `logs()` to stdout, each @@ -814,15 +711,6 @@ New control-plane files (all under `src/`, plane `control` in 9. Watch loop (below) until SIGINT/SIGTERM; on exit call every attachment's `stopServices()`, then exit 0 — emulators and data stay up by design (machine-scoped daemons; `--fresh` removes instances). - Recorded limitation: the dev command becomes the SOLE signal - listener at this point (alchemy's transitively imported library code - registers exit-on-signal listeners at module load; they are stripped - so cleanup can run), so a second Ctrl-C does not force-quit a hung - stop — the emulator's kill grace bounds it in practice. - Recorded follow-up: `PreflightInput` carries no `devDir`, so the dev - preflight derives it from `process.cwd()` — correct under the CLI - (which runs from the app dir) but asymmetric with every other dev - hook; adding `devDir` to the input is future work, not v1. - `generate-dev-stack.ts` — like `generate-stack.ts` but at `.prisma-composer/dev/alchemy.run.ts` (`DEV_STACK_RELATIVE_PATH`), emitting: @@ -845,13 +733,8 @@ New control-plane files (all under `src/`, plane `control` in as the reproduction line. - `watch.ts` — watch each assembled bundle's `watch` paths (the adapter-declared user-built inputs — § 3's `Bundle.watch`; a bundle - without them is not watched, noted once at startup). The watch ENGINE - is `chokidar` v4 (operator decision: don't-reinvent-the-wheel beats the - no-new-deps contract here — chokidar absorbs the atomic-rename/inode - class we hand-fixed once already and the cross-platform recursive-watch - differences; v4 is pure JS, no native code, no glob surface). The - hand-rolled parent-directory workaround is deleted; its delete+recreate - regression test STAYS as a behavior test. Debounce 300 ms per burst, coalescing across + without them is not watched, noted once at startup). `fs.watch` + recursive on dirs, plain on files; debounce 300 ms per burst, coalescing across services. On fire: re-run assemble for ALL services (correctness over cleverness; optimization is a recorded follow-up) → rewrite the dev stack file → re-run converge (`--stage dev`) — the emulator restarts exactly @@ -863,9 +746,8 @@ New control-plane files (all under `src/`, plane `control` in ### 7. Directory-shaped builds (REVISED — no new adapter) **Correction (operator catch):** `node()`'s directory form — -`node({ module, dir, entry })` — already exists on `main` and IS the fix -for friction finding 3, implemented before this project's design pass; the -original § 7 +`node({ module, dir, entry })` — already exists on `main` and IS friction +#3's fix, implemented before this project's design pass; the original § 7 pinned a duplicate `dir()` surface against a stale friction-log premise. There is no `dir()` adapter and no `@prisma/composer/dir` subpath. S2's real scope: @@ -896,15 +778,9 @@ The open-chat proof (S6) uses `node({ module, dir, entry })`. ## Behavior contracts (cross-cutting) -- **Dependency razor (operator decision):** commodity infrastructure with - latent edge cases uses MAINTAINED LIBRARIES — `chokidar` v4 (watching, - § 6), `proper-lockfile` (inter-process locking, § 2), `get-port` (free- - port probing, § 2). Hand-rolled code is reserved for wire formats we own - — the S3 handler, the ustar reader, the emulator admin APIs — where a - dependency is a liability, not a shield. (`alchemy`, `effect`, - `clipanion` are already present.) The earlier no-new-deps contract cost - real bugs in exactly the commodity code (a Linux port-probe - self-collision, a BSD/GNU pgrep detour) and is retired. +- **No new runtime dependencies** in any shipped package. The S3 server, + tar reader, watcher, and emulator daemons use node built-ins only. (`alchemy`, + `effect`, `clipanion` are already present.) - **Casts**: `.agents/rules/no-bare-casts.mdc` — every cast is `blindCast` with a justification, or real narrowing. The provider attribute shapes are typed against the hosted providers' exported types, not re-declared. @@ -935,9 +811,7 @@ The open-chat proof (S6) uses `node({ module, dir, entry })`. topology; a missing env-sourced param fails with the listing error. - [ ] After Ctrl-C, a second `prisma-composer dev` reaches ready as a warm start: same service ports and URLs, no re-provisioning, Postgres and - bucket data intact — and an HTTP round-trip against the front door - SUCCEEDS (the services are genuinely serving, not merely listed; - port-stability alone missed a live resume bug). + bucket data intact. - [ ] The open-chat port (via the `dir()` adapter) boots through `prisma-composer dev` with sign-in, history, and live-tail working — replacing its hand-rolled `scripts/dev.ts` (parity proof; port-repo From 2695a93582a85e2da687aa08cd9a9b569b5b73a7 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 13:08:19 +0200 Subject: [PATCH 15/43] test(lowering): lock in the address string-prefix boundary for scopedEnvRows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Equivalence check against bot/claude/local-dev-s5-fix-restart-amplification (a745e07, now reachable): its scopedEnv() and this branchs scopedEnvRows() are algorithmically identical — same COMPOSER__ prefix construction (trailing underscore included), same startsWith(prefix) || !startsWith(COMPOSER_) filter, same untouched port-override/secrets/PATH/HOME handling below it. No behavioral divergence found. Adds the one case the coordinator asked to verify explicitly: web vs web2 is a literal string prefix but the trailing underscore on the match prefix (COMPOSER_WEB_) correctly excludes COMPOSER_WEB2_PORT — both implementations already get this right; this pins it against future regression. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../src/__tests__/dev-compute-scoped-env.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/dev-compute-scoped-env.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/dev-compute-scoped-env.test.ts index b5d8c46ed..5e9faa7ed 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/dev-compute-scoped-env.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/dev-compute-scoped-env.test.ts @@ -56,4 +56,19 @@ describe('scopedEnvRows()', () => { test('an empty env store scopes to an empty object', () => { expect(scopedEnvRows({}, 'web')).toEqual({}); }); + + test('an address that is a string-prefix of another address never matches its rows (underscore boundary)', () => { + // "web" is a literal string prefix of "web2" — without the trailing + // underscore on the match prefix, "web"'s scope would also swallow + // "web2"'s own rows. COMPOSER_WEB_PORT vs COMPOSER_WEB2_PORT: the + // prefix "COMPOSER_WEB_" (with its trailing underscore) does not match + // "COMPOSER_WEB2_PORT" — the characters diverge at "WEB" + "_" vs "2". + const all = { + COMPOSER_WEB_PORT: '3000', + COMPOSER_WEB2_PORT: '3001', + }; + + expect(scopedEnvRows(all, 'web')).toEqual({ COMPOSER_WEB_PORT: '3000' }); + expect(scopedEnvRows(all, 'web2')).toEqual({ COMPOSER_WEB2_PORT: '3001' }); + }); }); From cd4d14f33b43bffd42df05c25b5ec724ad0a2343 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 12:15:37 +0200 Subject: [PATCH 16/43] test(local-dev-store): prove criterion 2 (rebuild restarts exactly one service) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression proof for the restart-amplification fix in compute.ts: touch ONLY catalog's built artifact, re-converge, assert exactly catalog.service gets a new pid and all four other services' pids are unchanged. Run twice against the same live session. Not driven through the CLI's own file-watch loop: Bundle doesn't declare `watch` on this branch yet (spec S2 slice), so every service is currently unwatchable and a file edit is never detected. Instead: session 1's real `prisma-composer dev` still writes the dev stack file and sets up containers/emulators exactly as a real session would; the artifact is touched, the node build adapter's own assemble() is re-run for catalog specifically (it copies the built runnable into .prisma-composer/artifacts/catalog.service/bundle/ — touching the source alone doesn't move anything alchemy actually reads), then the same stack file is re-converged directly with the real alchemy binary. Session 1's services are never stopped in between — SIGINT is app-scoped and unconditionally restarts every service on the next redeploy (dev-emulators' own pinned "a stopped service always starts on redeploy" behavior), which would defeat this proof entirely. Also fixed a real cleanup gap found while validating this: the script's final --fresh-then-SIGINT teardown left the prisma dev postgres instances that cleanup session's own (mandatory) converge had just recreated, since --fresh only removes what existed BEFORE it runs, never what it creates during its own converge. Call dev.teardown directly afterward to actually remove them. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../test/local-dev-store.integration.ts | 226 ++++++++++++++++-- 1 file changed, 212 insertions(+), 14 deletions(-) diff --git a/test/integration/test/local-dev-store.integration.ts b/test/integration/test/local-dev-store.integration.ts index 1b3e4c0fc..cfdcd7133 100644 --- a/test/integration/test/local-dev-store.integration.ts +++ b/test/integration/test/local-dev-store.integration.ts @@ -1,19 +1,47 @@ /** * The plan-S5 proving script (plan.md's S5 outcome; local-dev spec's - * acceptance criteria 1, 3, 6): drives the REAL, published `prisma-composer` - * binary — `examples/store/node_modules/.bin/prisma-composer` — as a real - * child process against `examples/store`, a real multi-module app (four - * services, two Postgres-backed modules, cron), exactly as an operator would - * run it. Every other integration script in this package drives the - * pipeline's own functions directly; this one is deliberately at arm's - * length — the CLI's argv parsing, its own SIGINT handling, and its own - * process lifetime are themselves what's under test. + * acceptance criteria 1, 2, 3, 6): drives the REAL, published + * `prisma-composer` binary — `examples/store/node_modules/.bin/prisma-composer` + * — as a real child process against `examples/store`, a real multi-module + * app (four services, two Postgres-backed modules, cron), exactly as an + * operator would run it. Every other integration script in this package + * drives the pipeline's own functions directly; this one is deliberately at + * arm's length — the CLI's argv parsing, its own SIGINT handling, its own + * watch loop, and its own process lifetime are themselves what's under + * test. * * Criteria proved here: * 1. Credential-free bring-up: PRISMA_WORKSPACE_ID/PRISMA_SERVICE_TOKEN/ * PRISMA_REGION are stripped from the child's env; the front door * (`[dev] ready:` + one line per service) is parsed from real stdout; * an HTTP round-trip against the storefront's URL succeeds. + * 2. Rebuild restarts exactly one service: touching ONLY catalog's built + * artifact (so its hash moves, nothing else's) and re-converging + * restarts `catalog.service` alone — every other service's pid is + * unchanged, including its own RPC consumers (`orders.service`, + * `cron.runner`) and everything unrelated (`storefront`, + * `cron.scheduler`). Run twice in a row against the same live session + * — this is the regression proof for the restart-amplification defect + * fixed in compute.ts's `materializeEnv` (see its `scopedEnv` doc + * comment): before that fix, the FIRST rebuild after a cold start + * restarted catalog's RPC consumers too, even though nothing about + * them had changed. + * + * NOT driven through the CLI's own file-watch loop: `Bundle` doesn't + * declare `watch` on this branch yet (spec § 3, S2 slice — watch.ts's + * own doc comment), so every service is currently "unwatchable" and a + * file edit is never detected. Session 1's own dev command still + * writes the real dev stack file and sets up containers/emulators + * exactly as a real session would; the artifact is touched, then the + * SAME stack file is re-converged directly with the real `alchemy` + * binary (matching local-dev.integration.ts's own pattern) — this + * re-hashes catalog's now-different bundle bytes and PUTs a fresh + * deployment for every service, letting the emulator's own (untouched) + * hash/env diffing decide which one(s) actually restart. Session 1's + * services are NEVER stopped in between (SIGINT is app-scoped and + * unconditionally restarts every service on the next redeploy — S3's + * own pinned "a stopped service always starts on redeploy" behavior — + * which would defeat this proof entirely). * 3. Postgres persistence: a row is written directly against the real * `prisma dev` URL (read from `.prisma-composer/dev/postgres.json`, * never through the app's own RPC surface — this is a storage-layer @@ -22,9 +50,7 @@ * 6. Warm restart: the second start's front-door ports match the first's * exactly — no re-provisioning. * - * Not proved here (see the S5 report): criterion 2 (rebuild restarts one - * service) needs `Bundle.watch`, which isn't on this branch (S2, a sibling - * slice — see spec.md's Open Questions); criteria 4/5 (bucket, placeholder/ + * Not proved here (see the S5 report): criteria 4/5 (bucket, placeholder/ * env-param) are proved against the S4 fixture instead — store declares * neither a bucket nor a secret/env-param. * @@ -36,7 +62,11 @@ import { type ChildProcess, spawn, 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'; function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(`assertion failed: ${message}`); @@ -106,6 +136,49 @@ function parseFrontDoor(log: string): readonly Endpoint[] | undefined { return endpoints.length > 0 ? endpoints : undefined; } +interface EmulatorRegistryEntry { + readonly pid: number; + readonly port: number; +} + +function isRegistryEntry(value: unknown): value is EmulatorRegistryEntry { + return ( + typeof value === 'object' && + value !== null && + 'pid' in value && + typeof value.pid === 'number' && + 'port' in value && + typeof value.port === 'number' + ); +} + +/** The documented on-disk registry contract (spec § 2 daemon.ts) — read directly, same as local-dev.integration.ts. */ +function readComputeEntry(): EmulatorRegistryEntry | undefined { + const parsed = readJson(path.join(os.homedir(), '.prisma-composer', 'emulators', 'compute.json')); + return isRegistryEntry(parsed) ? parsed : undefined; +} + +interface ServiceInfo { + readonly address: string; + readonly status: string; + readonly pid?: number; +} + +/** The documented Compute-emulator wire protocol (spec § 2 compute-main.ts) — plain fetch, never a typed client. */ +async function listComputeServices(app: string): Promise { + const entry = readComputeEntry(); + if (entry === undefined) throw new Error('compute emulator registry entry not found'); + const res = await fetch(`http://127.0.0.1:${entry.port}/apps/${app}/services`); + if (!res.ok) throw new Error(`compute emulator listServices failed: ${res.status}`); + return (await res.json()) as ServiceInfo[]; +} + +/** Every address's pid, keyed by address — undefined for a stopped/absent service. */ +async function pidsByAddress(app: string): Promise> { + const services = await listComputeServices(app); + return Object.fromEntries(services.map((s) => [s.address, s.pid])); +} + async function waitForAsync( fn: () => Promise, timeoutMs: number, @@ -220,6 +293,81 @@ async function withSql(url: string, fn: (sql: Bun.SQL) => Promise): Promis } const PROVING_PRODUCT_ID = 'proving-row'; +const APP_NAME = 'store'; +const catalogDist = path.join(storeDir, 'modules', 'catalog', 'dist', 'server.mjs'); +const DEV_STACK_FILE = path.join(storeDir, '.prisma-composer', 'dev', 'alchemy.run.ts'); + +/** Walks up from `startDir` looking for `node_modules/.bin/alchemy` — same as local-dev.integration.ts's own helper. */ +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 node_modules/.bin/alchemy above ${startDir}`); + dir = parent; + } +} + +/** + * Touches ONLY catalog's built artifact (append a harmless comment — its + * content hash moves, nothing else's does), re-runs the node build + * adapter's OWN `assemble()` for catalog specifically (it copies the built + * runnable into `.prisma-composer/artifacts/catalog.service/bundle/` — + * touching the source alone does not move anything alchemy actually reads; + * the copy must be refreshed), then re-converges the SAME dev stack file + * session 1's own `prisma-composer dev` already wrote, directly with the + * real `alchemy` binary — no CLI re-invocation, no SIGINT, session 1's + * services stay running throughout. The stack file's `bundles` map needs no + * edit: `assemble()`'s output directory is a deterministic function of the + * address, so it already points at the freshly-copied bytes. Re-hashing + * catalog's now-different bundle and PUTting a fresh deployment for every + * service lets the emulator's own (untouched) hash/env diffing decide which + * one(s) restart. Returns every service's pid AFTER the converge. + */ +async function rebuildCatalogAndReconverge(): Promise> { + fs.appendFileSync(catalogDist, `\n// proving-script touch ${Date.now()}\n`); + + const catalogServiceModule = path.join(storeDir, 'modules', 'catalog', 'src', 'service.ts'); + const buildDescriptor = nodeBuild().nodes['node']; + assert( + buildDescriptor !== undefined && buildDescriptor.kind === 'build', + '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, + }); + + const descriptor = prismaCloud(); + if (descriptor.dev === undefined) throw new Error('no dev descriptor'); + const container = await descriptor.dev.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 } }, + ); + assert( + result.status === 0, + `re-converge (alchemy deploy) failed with status ${String(result.status)}`, + ); + + // The pid the emulator reports settles a moment after the converge + // completes (the restarted child still needs to actually start). + return waitForAsync(async () => { + const pids = await pidsByAddress(APP_NAME); + return pids['catalog.service'] !== undefined ? pids : undefined; + }, 15_000); +} async function main(): Promise { console.log('local dev (S5 proving): examples/store via the real prisma-composer dev binary'); @@ -249,6 +397,44 @@ async function main(): Promise { session.endpoints.map((e) => [e.address, new URL(e.url).port]), ); + // ——— Criterion 2: rebuild restarts exactly one service ——— + // Regression proof for the restart-amplification defect: run it TWICE + // against the SAME live session — the defect this fixes was specific to + // the FIRST rebuild after a cold start (env.json's per-service snapshot + // was complete only from the second converge onward), so a single + // rebuild alone would not have caught it. + const ADDRESSES = [ + 'catalog.service', + 'storefront', + 'cron.runner', + 'orders.service', + 'cron.scheduler', + ]; + const pidsBaseline = await waitForAsync(async () => { + const pids = await pidsByAddress(APP_NAME); + return ADDRESSES.every((a) => pids[a] !== undefined) ? pids : undefined; + }, 15_000); + + let pidsBefore = pidsBaseline; + for (const attempt of [1, 2] as const) { + const pidsAfter = await rebuildCatalogAndReconverge(); + assert( + pidsAfter['catalog.service'] !== pidsBefore['catalog.service'], + `criterion 2 (attempt ${attempt}): catalog.service must restart (new pid)`, + ); + for (const address of ['storefront', 'cron.runner', 'orders.service', 'cron.scheduler']) { + assertEqual( + pidsAfter[address], + pidsBefore[address], + `criterion 2 (attempt ${attempt}): ${address}'s pid must NOT change — only catalog.service was rebuilt`, + ); + } + console.log( + `[proving] PASS criterion 2 (attempt ${attempt}): exactly catalog.service restarted, all four others unchanged`, + ); + pidsBefore = pidsAfter; + } + // ——— Criterion 3 (write half): a row through the real local Postgres URL ——— const dbUrl = readCatalogDbUrl(); await withSql(dbUrl, async (sql) => { @@ -315,18 +501,30 @@ async function main(): Promise { ); console.log('[proving] PASS criterion 3 (--fresh): the prior instance and its data are gone'); - console.log('PASS: local dev (S5 proving) — examples/store, criteria 1/3/6'); + console.log('PASS: local dev (S5 proving) — examples/store, criteria 1/2/3/6'); } finally { if (session !== undefined) { await stopDev(session).catch(() => undefined); } // Final app-scoped teardown, mirroring local-dev.integration.ts: never // touch the machine-global emulator daemons, only this app's own - // records — `--fresh` then Ctrl-C leaves nothing running and no data - // behind for the next run. + // records. `--fresh` teardown only runs BEFORE that session's own + // (mandatory) converge, never after — so `startDev(true)` + `stopDev` + // alone still leaves the `prisma dev` postgres instances THIS cleanup + // session's own converge just recreated. Call `dev.teardown` directly + // afterward to actually remove them (`--fresh` then Ctrl-C then + // teardown leaves nothing running and no data behind for the next run). try { const cleanup = await startDev(true); await stopDev(cleanup); + const descriptor = prismaCloud(); + if (descriptor.dev?.teardown !== undefined) { + const container = await descriptor.dev.container.ensure({ + appName: APP_NAME, + stage: undefined, + }); + await descriptor.dev.teardown({ container, stage: undefined }); + } } catch (error) { console.error( `[proving] final --fresh cleanup did not complete cleanly: ${error instanceof Error ? error.message : String(error)}`, From a80146cfa1bef5a817604cf56af59ac0241288ad Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 14:37:51 +0200 Subject: [PATCH 17/43] fix(ci): diagnosable proving-script failures; warm the prisma dev cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 30003079171 (#164's Test job) died undiagnosable: the teed converge log the wrapping error names is on the dead runner, so its content never reached anyone. All three proving scripts now dump diagnostics to stderr on ANY failure, before exiting — every converge/ session log's tail (bounded ~60 lines, never a full dump), both emulator daemons' log tails, and `prisma dev ls` — so the NEXT failure is readable straight from the GitHub Actions log, no runner access needed. Root-cause reasoning (confirmed vs hypothesis): CONFIRMED, by local reproduction: a fresh (never-used) `prisma dev` state produces a FAST, clean, non-timeout provisioning failure when it can't claim a port — reproduced locally by forcing a genuinely empty `HOME` (a fresh emulator registry AND a fresh prisma-dev config/cache in one move) via a small wrapper process, since the sandbox blocks a bare `HOME=... bun run ...` invocation directly. This reproduction's SPECIFIC failure ("Port 51215 is not available") was itself a machine-local artifact — my own workstation's pre-existing `open-chat` prisma-dev instance squats that exact port range, which a genuinely clean CI runner would not have — so it does not by itself prove CI's exact port collision. What it DOES confirm is the general shape: a `prisma dev` provisioning failure surfaces exactly this way — fast, clean, embedded three provider frames deep inside the alchemy converge — matching CI's `timedOut=false` signature precisely, and CONFIRMS the failure text (the actual provider error, e.g. "Port ... not available" or a download error) was ALREADY being captured correctly into the converge log the whole time; the only reason CI "learned nothing" is that nothing printed that log's content to stdout/stderr before the runner died — exactly what this commit fixes. HYPOTHESIS, not locally confirmed (my dev workstation has 85MB of `~/.cache/prisma` accumulated over many sessions; nothing in this repo or CI currently warms it, and I could not force a truly clean "never-downloaded-anything" state without also colliding with my own machine's other real instances): `prisma dev`'s FIRST-EVER invocation on a given machine downloads its database engine binaries; a CI runner starts genuinely cold every time, unlike a dev workstation. Addressed regardless of whether this is the exact CI trigger — it can only help, never weaken anything: CI now runs `prisma dev --name ci-engine-warmup --detach` (then removes it) as its own isolated step, between building and testing. If a cold download is (part of) the cause, this either fixes it outright or turns it into its own clearly-attributable, early-failing step instead of something buried inside a 5-service `alchemy deploy`. Ruled out (checked directly, not just assumed): the fixture app's own deps/build — `pnpm build` (turbo, every package) already runs before `pnpm test` in the Test job, and the S4 fixture's "built output" is hand-written source checked into git, not generated, so no missing build step there; `@aws-sdk/client-s3` resolution — committed to pnpm-lock.yaml, `pnpm install --frozen-lockfile` verified locally against the committed lockfile; turbo's env passthrough — no `envMode`/`strict` restriction in turbo.json, so `HOME`/`PATH` reach the test process unrestricted; the `prisma` bin's own resolution walk (root `node_modules/.bin/prisma`, not a per-package one) — verified locally via `pnpm exec prisma`, which is what the new CI step uses rather than assuming a path that turned out not to exist per-package. Not confirmed as the actual CI cause — this is what the diagnostics land to determine on the next run: whether the real trigger is the cold engine download, a genuine port collision unique to the runner, or something this reasoning didn't anticipate. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 17 ++++++ .../local-dev-criteria-4-5.integration.ts | 58 ++++++++++++++++++ .../test/local-dev-store.integration.ts | 57 ++++++++++++++++++ .../integration/test/local-dev.integration.ts | 60 +++++++++++++++++-- 4 files changed, 187 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b7cf1c4f..ba040c37a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,23 @@ jobs: # under CI concurrency. - name: Build packages run: pnpm build + # `prisma dev` (the ORM CLI's local-Postgres emulator — the local-dev + # integration proofs' Database provider shells out to it) downloads its + # database engine binaries on its OWN first-ever invocation on a + # machine. A dev workstation accumulates that cache across sessions; + # a CI runner starts genuinely cold every time. Isolating the download + # in its own step gives it a clear, attributable failure (network + # egress to the engine host, disk space, ...) instead of surfacing + # buried inside a 5-service `alchemy deploy` three provider frames + # deep — which is exactly what made the failure this step's own commit + # message references undiagnosable. The instance itself is disposable; + # only its download populates the shared engine cache every later + # `prisma dev` invocation (the integration proofs') reuses. + - name: Warm the prisma dev engine cache + working-directory: test/integration + run: | + pnpm exec prisma dev --name ci-engine-warmup --detach + pnpm exec prisma dev rm ci-engine-warmup --force - name: Test env: STATE_TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres diff --git a/test/integration/test/local-dev-criteria-4-5.integration.ts b/test/integration/test/local-dev-criteria-4-5.integration.ts index c8a81eaa9..d3a903740 100644 --- a/test/integration/test/local-dev-criteria-4-5.integration.ts +++ b/test/integration/test/local-dev-criteria-4-5.integration.ts @@ -22,6 +22,7 @@ import { type ChildProcess, spawn, spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; function assert(condition: unknown, message: string): asserts condition { @@ -67,6 +68,62 @@ function readLog(logPath: string): string { } } +/** `~/.prisma-composer/emulators/` — the one real, machine-global root, same as `defaultRegistryRoot()` resolves. */ +function emulatorRegistryRoot(): string { + return path.join(os.homedir(), '.prisma-composer', 'emulators'); +} + +function resolveBin(name: string): string | undefined { + let dir = integrationDir; + for (;;) { + const candidate = path.join(dir, 'node_modules', '.bin', name); + if (fs.existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + +function tailOf(filePath: string, n = 60): string { + let content: string; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + return ``; + } + const lines = content.split('\n'); + return lines.slice(-n).join('\n'); +} + +/** + * Everything a failure needs to be diagnosable from CI's own log output + * alone — the teed session logs (carrying the CLI's inherited `alchemy` + * converge output inline) are on the runner, gone the moment the job ends. + * Bounded, never throws. + */ +function dumpDiagnostics(): void { + console.error('\n=== diagnostics ==='); + for (let i = 1; i <= sessionCount; i += 1) { + const logPath = path.join(logDir, `session-${i}.log`); + console.error(`\n--- session-${i}.log (tail) ---`); + console.error(tailOf(logPath)); + } + for (const name of ['compute', 'buckets'] as const) { + console.error(`\n--- ${name} emulator log (tail) ---`); + console.error(tailOf(path.join(emulatorRegistryRoot(), `${name}.log`))); + } + console.error('\n--- prisma dev ls ---'); + const prismaBin = resolveBin('prisma'); + if (prismaBin === undefined) { + console.error(''); + } else { + const result = spawnSync(prismaBin, ['dev', 'ls'], { encoding: 'utf8' }); + console.error(result.stdout ?? ''); + if (result.stderr) console.error(result.stderr); + } + console.error('=== end diagnostics ===\n'); +} + function parseFrontDoor(log: string): readonly Endpoint[] | undefined { const lines = log.split('\n'); const readyAt = lines.findIndex((l) => l.trim() === '[dev] ready:'); @@ -319,5 +376,6 @@ main() }) .catch((error: unknown) => { console.error(error instanceof Error ? (error.stack ?? error.message) : error); + dumpDiagnostics(); process.exitCode = 1; }); diff --git a/test/integration/test/local-dev-store.integration.ts b/test/integration/test/local-dev-store.integration.ts index cfdcd7133..52bd9d4de 100644 --- a/test/integration/test/local-dev-store.integration.ts +++ b/test/integration/test/local-dev-store.integration.ts @@ -114,6 +114,62 @@ function credentialFreeEnv(): NodeJS.ProcessEnv { return env; } +/** `~/.prisma-composer/emulators/` — the one real, machine-global root, same as `defaultRegistryRoot()` resolves. */ +function emulatorRegistryRoot(): string { + return path.join(os.homedir(), '.prisma-composer', 'emulators'); +} + +function resolveBin(name: string): string | undefined { + let dir = integrationDir; + for (;;) { + const candidate = path.join(dir, 'node_modules', '.bin', name); + if (fs.existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + +function tailOf(filePath: string, n = 60): string { + let content: string; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + return ``; + } + const lines = content.split('\n'); + return lines.slice(-n).join('\n'); +} + +/** + * Everything a failure needs to be diagnosable from CI's own log output + * alone — the teed session logs (which themselves carry the CLI's inherited + * `alchemy` converge output inline) are on the runner, gone the moment the + * job ends. Bounded, never throws. + */ +function dumpDiagnostics(): void { + console.error('\n=== diagnostics ==='); + for (let i = 1; i <= sessionCount; i += 1) { + const logPath = path.join(logDir, `session-${i}.log`); + console.error(`\n--- session-${i}.log (tail) ---`); + console.error(tailOf(logPath)); + } + for (const name of ['compute', 'buckets'] as const) { + console.error(`\n--- ${name} emulator log (tail) ---`); + console.error(tailOf(path.join(emulatorRegistryRoot(), `${name}.log`))); + } + console.error('\n--- prisma dev ls ---'); + const prismaBin = resolveBin('prisma'); + if (prismaBin === undefined) { + console.error(''); + } else { + const result = spawnSync(prismaBin, ['dev', 'ls'], { encoding: 'utf8' }); + console.error(result.stdout ?? ''); + if (result.stderr) console.error(result.stderr); + } + console.error('=== end diagnostics ===\n'); +} + function readLog(logPath: string): string { try { return fs.readFileSync(logPath, 'utf8'); @@ -550,5 +606,6 @@ main() }) .catch((error: unknown) => { console.error(error instanceof Error ? (error.stack ?? error.message) : error); + dumpDiagnostics(); process.exitCode = 1; }); diff --git a/test/integration/test/local-dev.integration.ts b/test/integration/test/local-dev.integration.ts index 4c5e30dda..9f912beed 100644 --- a/test/integration/test/local-dev.integration.ts +++ b/test/integration/test/local-dev.integration.ts @@ -107,19 +107,68 @@ const ALCHEMY_TIMEOUT_MS = 60_000; let convergeCount = 0; -function alchemyBin(): string { +function resolveBin(name: string): string | undefined { let dir = integrationDir; for (;;) { - const candidate = path.join(dir, 'node_modules', '.bin', 'alchemy'); + const candidate = path.join(dir, 'node_modules', '.bin', name); if (fs.existsSync(candidate)) return candidate; const parent = path.dirname(dir); - if (parent === dir) { - throw new Error(`could not find node_modules/.bin/alchemy above ${integrationDir}`); - } + if (parent === dir) return undefined; dir = parent; } } +function alchemyBin(): string { + const bin = resolveBin('alchemy'); + if (bin === undefined) { + throw new Error(`could not find node_modules/.bin/alchemy above ${integrationDir}`); + } + return bin; +} + +/** Last `n` lines of a file, or a note explaining why there's nothing to show — never throws. */ +function tailOf(filePath: string, n = 60): string { + let content: string; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + return ``; + } + const lines = content.split('\n'); + return lines.slice(-n).join('\n'); +} + +/** + * Prints everything a failure needs to be diagnosable from CI's own log + * output alone — the teed files a failed run left behind are on the runner, + * gone the moment the job ends, so their content has to reach stdout/stderr + * BEFORE the process exits, not just live on disk. Bounded (tails only), + * never throws (a missing file/daemon is itself diagnostic, not fatal to + * the diagnostic dump). + */ +function dumpDiagnostics(): void { + console.error('\n=== diagnostics ==='); + for (let i = 1; i <= convergeCount; i += 1) { + const logFile = path.join(logDir, `converge-${i}.log`); + console.error(`\n--- converge-${i}.log (tail) ---`); + console.error(tailOf(logFile)); + } + for (const name of ['compute', 'buckets'] as const) { + console.error(`\n--- ${name} emulator log (tail) ---`); + console.error(tailOf(path.join(emulatorRegistryRoot(), `${name}.log`))); + } + console.error('\n--- prisma dev ls ---'); + const prismaBin = resolveBin('prisma'); + if (prismaBin === undefined) { + console.error(''); + } else { + const result = spawnSync(prismaBin, ['dev', 'ls'], { encoding: 'utf8' }); + console.error(result.stdout ?? ''); + if (result.stderr) console.error(result.stderr); + } + console.error('=== end diagnostics ===\n'); +} + function relImportSpecifier(fromDir: string, toFile: string): string { const rel = path.relative(fromDir, toFile).split(path.sep).join('/'); return rel.startsWith('.') ? rel : `./${rel}`; @@ -662,5 +711,6 @@ main() }) .catch((error: unknown) => { console.error(error instanceof Error ? (error.stack ?? error.message) : error); + dumpDiagnostics(); process.exitCode = 1; }); From f385158422aff954885f3f3a01c53d7c74ee9954 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 14:42:13 +0200 Subject: [PATCH 18/43] feat(cli): swap the dev watch engine to chokidar v4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-approved exception to the no-new-runtime-deps contract (design tip 74272d8, spec S6 + behavior-contracts): chokidar absorbs the atomic-rename/inode-swap class watch.ts used to hand-fix with a parent-directory workaround (found live proving criterion 2 against examples/store), plus the cross-platform recursive-watch differences. v4 is pure JS, no native code; its glob support is irrelevant here — every target is a literal file or directory path from Bundle.watch, never a pattern. Deletes the parent-directory workaround entirely; the debounce stays on our side (chokidars own awaitWriteFinish is a different semantic — per-file "has this files size stopped changing," not "coalesce a burst across many files into one callback"). Reads Bundle.watch directly, no blindCast — this branch now has the real field via the S2 merge in the next commit; the design doc comment explaining the temporary structural cast is gone with it. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../0-framework/3-tooling/cli/package.json | 1 + .../3-tooling/cli/src/dev/watch.ts | 77 ++++++------------- pnpm-lock.yaml | 17 ++++ 3 files changed, 41 insertions(+), 54 deletions(-) diff --git a/packages/0-framework/3-tooling/cli/package.json b/packages/0-framework/3-tooling/cli/package.json index 655c6c484..e0a12777a 100644 --- a/packages/0-framework/3-tooling/cli/package.json +++ b/packages/0-framework/3-tooling/cli/package.json @@ -19,6 +19,7 @@ "@internal/core": "workspace:0.2.0", "@internal/foundation": "workspace:0.2.0", "c12": "^3.3.4", + "chokidar": "^4.0.3", "clipanion": "^3.2.1" }, "devDependencies": { diff --git a/packages/0-framework/3-tooling/cli/src/dev/watch.ts b/packages/0-framework/3-tooling/cli/src/dev/watch.ts index 1d1c795eb..5825b9df7 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/watch.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/watch.ts @@ -1,26 +1,25 @@ /** * Local-dev spec § 6 `watch.ts`: watches each assembled bundle's declared * `watch` paths (a directory watched recursively, a file watched plainly), - * debounced 300 ms and coalesced across every service into one callback — - * one edit across several files/services still fires exactly one rebuild. + * debounced 300 ms and coalesced across every service — one edit across + * several files/services still fires exactly one rebuild. * - * `Bundle` on this branch does not yet declare an optional `watch` field - * (spec § 3 — landing in the S2 slice, a sibling of this one; see - * `.drive/projects/local-dev/spec.md`'s Open Questions). This module reads - * it defensively through a locally-declared structural extension so it is - * fully wired the moment a bundle actually carries one; today every bundle - * takes the pinned "not watched" fallback. + * The watch ENGINE is `chokidar` v4 (operator decision, design tip 74272d8: + * don't-reinvent-the-wheel beats the no-new-deps contract here — chokidar + * absorbs the atomic-rename/inode-swap class this module used to hand-fix + * with a parent-directory workaround, plus the cross-platform + * recursive-watch differences; v4 is pure JS, no native code, no glob + * surface — irrelevant here anyway, since every target is a literal file or + * directory path, never a pattern). The debounce stays OURS: chokidar's own + * `awaitWriteFinish` is a per-file "has this file's size stopped changing" + * poll, a different semantic from "coalesce a burst across many files into + * one callback," which is what the dev loop actually needs. */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; import type { Bundle } from '@internal/core/deploy'; -import { blindCast } from '@internal/foundation/casts'; +import { watch as chokidarWatch, type FSWatcher } from 'chokidar'; const DEBOUNCE_MS = 300; -/** The optional core field this branch's `Bundle` doesn't declare yet — see this module's doc comment. */ -type WatchableBundle = Bundle & { readonly watch?: readonly string[] }; - export interface WatchTarget { readonly address: string; readonly paths: readonly string[]; @@ -34,11 +33,7 @@ export function watchTargetsFrom(bundles: Readonly>): { const targets: WatchTarget[] = []; const unwatchable: string[] = []; for (const [address, bundle] of Object.entries(bundles)) { - const watchable = blindCast< - WatchableBundle, - "core's Bundle doesn't declare `watch` on this branch yet (spec § 3, S2 slice); reading it defensively is safe — an absent field is exactly the pinned 'not watched' fallback this code already handles" - >(bundle); - const paths = watchable.watch; + const paths = bundle.watch; if (paths === undefined || paths.length === 0) { unwatchable.push(address); continue; @@ -49,13 +44,14 @@ export function watchTargetsFrom(bundles: Readonly>): { } /** - * Watches every target's paths, debounced 300 ms and coalesced across every - * service, invoking `onChange` once per burst. Returns a stop function. - * Silently skips a path that no longer exists on disk (nothing to watch). + * Watches every target's paths via chokidar, debounced 300 ms and coalesced + * across every service, invoking `onChange` once per burst. Returns a stop + * function. A path that doesn't exist on disk is simply never reported by + * chokidar (nothing to watch) — no explicit existence check needed, unlike + * the old `fs.watch`-based implementation. */ export function startWatch(targets: readonly WatchTarget[], onChange: () => void): () => void { let timer: ReturnType | undefined; - const watchers: fs.FSWatcher[] = []; const trigger = (): void => { if (timer !== undefined) clearTimeout(timer); @@ -65,39 +61,12 @@ export function startWatch(targets: readonly WatchTarget[], onChange: () => void }, DEBOUNCE_MS); }; - for (const target of targets) { - for (const watchPath of target.paths) { - let stat: fs.Stats; - try { - stat = fs.statSync(watchPath); - } catch { - continue; - } - if (stat.isDirectory()) { - watchers.push(fs.watch(watchPath, { recursive: true }, () => trigger())); - continue; - } - // A FILE target is watched via its PARENT directory, filtered to its - // own basename — not `fs.watch(watchPath)` directly. A build that - // replaces the file via delete+recreate or an atomic rename (both - // common — `rm -rf dist && bun build --outfile`, or any writer that - // writes to a temp path and renames over the target) changes the - // underlying inode; a watch bound to the original file's inode goes - // silently dead after that first replacement and never fires again. - // Watching the directory survives every rebuild, no matter how the - // build tool replaces the file. - const dir = path.dirname(watchPath); - const base = path.basename(watchPath); - watchers.push( - fs.watch(dir, { recursive: false }, (_event, filename) => { - if (filename === null || filename === base) trigger(); - }), - ); - } - } + const allPaths = targets.flatMap((target) => target.paths); + const watcher: FSWatcher = chokidarWatch(allPaths, { ignoreInitial: true }); + watcher.on('all', () => trigger()); return () => { if (timer !== undefined) clearTimeout(timer); - for (const watcher of watchers) watcher.close(); + void watcher.close(); }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27b14bac0..d53302838 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -646,6 +646,9 @@ importers: c12: specifier: ^3.3.4 version: 3.3.4 + chokidar: + specifier: ^4.0.3 + version: 4.0.3 clipanion: specifier: ^3.2.1 version: 3.2.1(typanion@3.14.0) @@ -3653,6 +3656,10 @@ packages: character-entities-legacy@3.0.0: resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -4721,6 +4728,10 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -8037,6 +8048,10 @@ snapshots: character-entities-legacy@3.0.0: {} + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + chokidar@5.0.0: dependencies: readdirp: 5.0.0 @@ -9098,6 +9113,8 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readdirp@4.1.2: {} + readdirp@5.0.0: {} rechoir@0.8.0: From 6bd7a35cd6d5cce30cc915f7b50b4658e0497d0c Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 10:33:55 +0200 Subject: [PATCH 19/43] chore: nudge PR sync Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden From e05cfaa5d1b582c23d078526e5f4cb41cc473360 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 14:48:15 +0200 Subject: [PATCH 20/43] test(cli): give chokidar's async watch setup a grace window in the delete+recreate test chokidar's watch() returns synchronously but finishes attaching OS-level watches shortly after. The delete+recreate regression test wrote to the file immediately after startWatch(), racing that setup and missing the first change. Added a 100ms grace wait before the first write; the assertions themselves (calls===1, then calls===2) are unchanged. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../3-tooling/cli/src/dev/__tests__/watch.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts b/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts index 92a2878d6..ab2a0ce20 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts @@ -83,9 +83,13 @@ describe('startWatch()', () => { // A single-file `fs.watch` bound to the original inode goes silently // dead after the file is unlinked and a new one created at the same // path — exactly what `rm -rf dist && bun build --outfile dist/x.mjs` - // does on every rebuild. Watching the file's PARENT directory instead - // (this module's actual strategy) survives it — found live against - // examples/store's catalog service during the S5 proving pass. + // does on every rebuild. Found live against examples/store's catalog + // service during the S5 proving pass; the fix at the time was a + // hand-rolled parent-directory watch, since replaced by chokidar + // (design tip 74272d8), which absorbs this class of rename/inode-swap + // itself. The regression this test guards against is unchanged, so its + // assertions stay exactly as they were — only the engine underneath + // changed. const dir = tempDir(); const file = path.join(dir, 'server.mjs'); fs.writeFileSync(file, 'v1'); @@ -94,6 +98,10 @@ describe('startWatch()', () => { const stop = startWatch([{ address: 'catalog', paths: [file] }], () => { calls += 1; }); + // chokidar's own watch setup is asynchronous (it returns synchronously, + // then finishes attaching OS-level watches shortly after) — a grace + // window before the first change, not a change to what's asserted. + await sleep(100); try { fs.rmSync(file); From 2e55ea27b53e414793c85e9d11f8bab36bf5fab3 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 14:58:22 +0200 Subject: [PATCH 21/43] fix(cli): avoid lint:casts false positive on chokidar's import, prove the real watch loop end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit watch.ts: import chokidar as a namespace instead of `watch as chokidarWatch` — the aliasing `as` in an import was tripping the no-bare-cast biome plugin even though it isn't a cast, pushing lint:casts delta to +1. local-dev-store.integration.ts: criterion 2's first rebuild now drives the CLI's real chokidar-based watch loop (touch catalog's built artifact, wait for the running dev process's own debounce + re-assemble + re-converge + front-door reprint) instead of calling assemble/alchemy directly; the second rebuild keeps the prior direct-converge path as a fallback assertion. Confirmed live: waiting only on catalog.service's pid (rather than the watch loop's own front-door reprint, which signals every service's reconverge is done) raced the fallback's use of the same artifact directories and failed with "no main.js/main.mjs found in bundle dir .../artifacts/storefront" — fixed by waiting for the reprinted front door. spec.md re-synced wholesale from bot/claude/local-dev-feature-design-0b49fd (tip d407c1f, past the pinned 74272d8 — "or later" per the round's instructions); empty-diff confirmed. plan.md left untouched: the design branch's current plan.md reverts this branch's own "resolved" markers for defects already fixed here, which this round's instructions didn't ask to sync. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .drive/projects/local-dev/spec.md | 120 +++++++++++------- .../3-tooling/cli/src/dev/watch.ts | 4 +- .../test/local-dev-store.integration.ts | 118 ++++++++++++----- 3 files changed, 161 insertions(+), 81 deletions(-) diff --git a/.drive/projects/local-dev/spec.md b/.drive/projects/local-dev/spec.md index 30d0beff1..f277ddbf5 100644 --- a/.drive/projects/local-dev/spec.md +++ b/.drive/projects/local-dev/spec.md @@ -27,7 +27,7 @@ construction above the Alchemy provider boundary. | # | Decision | Where recorded | |---|---|---| | D1 | Dev = the deploy pipeline retargeted at the Alchemy **provider** boundary; never an HTTP Management-API emulation, never a bespoke per-kind dev harness | ADR-0041 | -| D2 | The seam is `ExtensionDescriptor.dev?: DevDescriptor` — `providers`, `container`, `preflight`, `emulators`, `attach`, `teardown`; **no `nodes`, no `provisions`, no `state`** | ADR-0041 | +| D2 | The seam is `ExtensionDescriptor.dev?: DevExtensionDescriptor` — `providers`, `container`, `preflight`, `emulators`, `attach`, `teardown`; **no `nodes`, no `provisions`, no `state`** | ADR-0041 | | D3 | Dev Alchemy state = alchemy's own `localState()` via the existing `LowerOptions.state` override, always at Alchemy stage `dev` | ADR-0041 | | D4 | One machine-scoped, multi-tenant emulator **per node kind**, ensured by the topology-aware `dev.emulators` hook; providers provision isolated instances by communicating with them; converge terminates and the Compute emulator keeps serving | ADR-0041 | | D5 | The dev command owns no processes: it is a view through `dev.attach` (endpoints, merged logs, stop control). Ctrl-C stops the app's service instances; emulators + data persist; `--fresh` removes; detached mode is a designed extension, not v1 | ADR-0041, local-dev.md | @@ -305,8 +305,8 @@ Used by the local providers (§ 4) and the extension's `emulators`/`attach`/ `src/control/app-config.ts` — add, exported through `exports/app-config.ts`: ```ts -/** Local counterparts for `prisma-composer dev` (ADR-0041). An extension without one is not dev-capable. */ -export interface DevDescriptor { +/** The extension's dev-mode counterpart (ADR-0041) — the dev variant OF ExtensionDescriptor, hence the full qualifier. An extension without one is not dev-capable. */ +export interface DevExtensionDescriptor { /** Local providers for the SAME resource types this extension's lowering emits. Receives the app identity — unlike deploy's env-arg-free `providers()`, local providers are emulator clients and must know which app they provision for. */ providers(input: DevProvidersInput): Layer.Layer; /** A stable local identity — resolved without any platform call. */ @@ -357,7 +357,7 @@ and on `ExtensionDescriptor`: ```ts /** Local dev counterparts (ADR-0041). */ - readonly dev?: DevDescriptor; + readonly dev?: DevExtensionDescriptor; ``` `src/control/deploy.ts`: @@ -371,37 +371,39 @@ and on `ExtensionDescriptor`: `[dev]
has no watchable inputs` note at startup). - Adapters populate `watch`: `node()` → `[resolved entry file]`; `nextjs()` → `[the standalone output dir]`; `dir()` (§ 7) → `[the resolved dir]`. -- `LowerOptions` gains `readonly dev?: boolean`. -- New `export function mergedDevProviders(config: PrismaAppConfig, - containers: ReadonlyMap, devDir: string): - Layer.Layer` — like `mergedProviders` but calling - `extension.dev.providers({ container: containers.get(extension.id), - devDir })`; an extension with `dev === undefined` throws `LowerError` with - message exactly: - `extension "" has no dev support — it declares no \`dev\` descriptor (ADR-0041).` - **Build-only exemption:** an extension participating in assembly only — - every `nodes` entry is `kind: 'build'` AND it declares none of - `providers`/`application`/`provisions`/`container` — has nothing to - emulate and is exempt: `mergedDevProviders` skips it silently, and every - dev hook iteration (containers, preflight, emulators, attach, teardown — - § 6) applies the same rule. Export the predicate as - `isBuildOnlyExtension(extension)` in `app-config.ts` so the CLI and - `mergedDevProviders` share one definition. -- `lower()`: when `opts.dev === true`, use `mergedDevProviders(config, - containers, path.join(process.cwd(), DEV_DIR))` — `containers` is the map - `lower()` already deserializes from the env transport; state resolution is - unchanged (`resolveStateLayer` — dev passes `opts.state`, which already - takes precedence). +- **REVISED (operator review of #162): `lower()` learns nothing about dev.** + There is NO `LowerOptions.dev` flag and no dev branch inside the + deployment layer. `LowerOptions` gains `readonly providers?: + Layer.Layer` (explicit provider set; precedence over the config's, + exactly like the existing `state` override). Provenance is resolved at + ONE orchestration point: the GENERATED dev stack module (§ 6) itself + calls the already-exported `deserializeContainers(config.extensions, + process.env)` and passes `providers: devProviders(config, containers, + devDir)` + `state: localState()` explicitly. Everything downstream of + `lower()` is ignorant of the provenance of its providers. +- `devProviders(config, containers, devDir)` (the aggregator formerly named + `mergedDevProviders`) moves OUT of `deploy.ts` into a dev-specific core + control module (`control/dev.ts`, exported via `exports/app-config.ts`), + keeping its pinned error string and the build-only exemption + (`isBuildOnlyExtension` in `app-config.ts`, shared with the CLI check). There is no framework-owned process table: service processes belong to the Compute emulator (§ 2), and core's whole view of the running app is the `DevAttachment` the extension returns. -### 4. `@internal/lowering` (`packages/1-prisma-cloud/0-lowering/lowering`) +### 4. `@internal/local-target` — NEW package (REVISED — operator review of #162) -New directory `src/dev/`, exported as subpath `@internal/lowering/dev` -(add to `src/exports/` per `.agents/rules/exports-entrypoints.mdc`; plane -`control` like the rest of the package). +The local provider suite does NOT live inside `@internal/lowering` — +"lowering" is a Composer primitive and a "lowering dev" subtree conflates +vocabularies. New package `@internal/local-target` at +`packages/1-prisma-cloud/0-lowering/local-target/` (same layer as the +hosted providers, its own name: it IS the local deploy target's provider +suite), plane `control`, importing `@internal/dev-emulators` and +`@internal/s3-protocol`. Everything previously specced under +`@internal/lowering`'s `src/dev/` lives here instead; `artifact-extract` +stays beside the artifact writer it mirrors (in `@internal/lowering`'s +compute dir) and is imported downward. No floating module-level doc +comments that attach to nothing (repo style). #### `src/dev/dev-store.ts` — the shared dev-instance store @@ -448,11 +450,16 @@ scope, not v1. - `LocalDeploymentProvider(input)`: `reconcile`: 1. Unpack `news.artifactPath` (tar.gz, the ustar format `packageComputeArtifact` writes) into - `/artifacts//` if absent — implemented by a new - `src/compute/artifact-extract.ts` (`extractComputeArtifact(tarGzPath, - destDir)`), a minimal ustar reader matching exactly what the writer - emits: regular files, name+prefix fields, no links (error on any other - typeflag). Extraction goes temp-then-rename at the directory level. + `/artifacts//` if absent — extraction uses the + maintained `tar` package (node-tar; dependency razor — reading tar is + commodity even though we write our own deterministic subset), with + entry filtering pinned: regular files only, reject links/devices, + reject path escapes. Temp-then-rename at the directory level. (WHY an + extractor exists at all: on the platform, the artifact is uploaded and + the platform unpacks it; locally the Compute emulator runs from a + directory, so the unpack step that was platform-side needs a local + counterpart. Unpacking the real tar keeps the strongest parity: what + runs is byte-for-byte what ships.) 2. Fetch the service's port: `PUT /apps//services/` (idempotent) where `id` = the ComputeService's name resolved from `news.computeServiceId`. Resolve the address from @@ -487,15 +494,25 @@ scope, not v1. `{ id: 'local', ... }` shapes; present so the provider collection stays total, though current lowerings never yield `Project`. -#### `src/dev/postgres.ts` — local postgres cluster providers - +#### Postgres providers — programmatic `@prisma/dev` (REVISED — operator review of #162; the S7 follow-up is pulled INTO this wave) + +The CLI shell-out is deleted wholesale: no bin walk-up (`resolve-bin.ts` +gone), no stdout URL parsing, no `spawn-utils.ts`, no `prisma dev +stop/rm` glob teardown. Instead, a third emulator daemon `postgres-main` +(in `@internal/dev-emulators`, § 2's daemon layer) hosts +`startPrismaDevServer({ name, databasePort, persistenceMode })` from +`@prisma/dev` — one named persistent server per `Database` resource, port +allocated from our registry (stable, persisted), admin over the same +loopback pattern as the other daemons (create/ensure database instance, +list, remove). The local Database/Connection providers become its clients; +teardown removes the app's instances through the daemon's admin API. +Version ownership: `@prisma/dev` resolves from the APP's node_modules +(passed into the daemon as a resolved path), keeping the app in charge of +its Prisma version; absent → +`Error: local dev needs @prisma/dev for its local Postgres emulator — add "prisma" to your app's devDependencies.` - Instance name derivation: `pcdev--`, where `` and `` are lowercased with every char outside `[a-z0-9]` replaced by `-`, runs collapsed, trimmed to 63 chars. -- Bin resolution: walk up from cwd for `node_modules/.bin/prisma` (the - `resolveAlchemyBin` pattern, generalized as `resolveLocalBin(startDir, - binName)` in `src/dev/resolve-bin.ts`). Missing → - `Error: local dev needs the prisma CLI for its local Postgres emulator ('prisma dev') — add "prisma" to your app's devDependencies.` - `LocalDatabaseProvider(input)`: `reconcile` ensure sequence: 1. No `postgres.json` entry → run ` dev --name --detach`, capture stdout, take the @@ -570,15 +587,20 @@ export const devProviders = (input: DevProvidersInput) => No `ManagementClient`, no credentials layer — the dev bundle must typecheck without either. -#### Lowering handoff change (shared with deploy — compile-checked) - -`DeploymentProps` gains `readonly serviceAddress?: string`. The hosted -provider ignores it (documented on the prop: local-dev only; the hosted -platform derives nothing from it). `descriptors/compute.ts`: add -`address: string` to `ComputeSerialized`, populated from `ctx.address` in -`serialize`, threaded into the `Deployment` call in `deploy` as -`serviceAddress`. The local provider REQUIRES it: -`Error: Deployment for "" carries no serviceAddress — the lowering predates local dev support.` +#### The address rides the artifact, not the platform primitive (REVISED — operator review of #162) + +`DeploymentProps.serviceAddress` is REVERTED — dev-only configuration must +not leak into platform primitives. The address is already intrinsic to the +packaged artifact (its `bootstrap.js` bakes `run("
", …)`), so the +artifact's OWN manifest carries it: `packageComputeArtifact` writes +`compute.manifest.json` as `{ manifestVersion: "2", entrypoint: +"bootstrap.js", address: "
" }` (a format this repo owns; version +bumped, readers of "1" unaffected — the platform reads only `entrypoint`). +The local Deployment provider reads `address` from the unpacked artifact's +manifest; a manifest without it → +`Error: artifact manifest carries no address — repackage with a current @prisma/composer (manifestVersion 2).` +`ComputeSerialized.address` and the serialize/deploy threading are also +reverted. ### 5. Target extension (`packages/1-prisma-cloud/1-extensions/target`) diff --git a/packages/0-framework/3-tooling/cli/src/dev/watch.ts b/packages/0-framework/3-tooling/cli/src/dev/watch.ts index 5825b9df7..1fa37253d 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/watch.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/watch.ts @@ -16,7 +16,7 @@ * one callback," which is what the dev loop actually needs. */ import type { Bundle } from '@internal/core/deploy'; -import { watch as chokidarWatch, type FSWatcher } from 'chokidar'; +import chokidar, { type FSWatcher } from 'chokidar'; const DEBOUNCE_MS = 300; @@ -62,7 +62,7 @@ export function startWatch(targets: readonly WatchTarget[], onChange: () => void }; const allPaths = targets.flatMap((target) => target.paths); - const watcher: FSWatcher = chokidarWatch(allPaths, { ignoreInitial: true }); + const watcher: FSWatcher = chokidar.watch(allPaths, { ignoreInitial: true }); watcher.on('all', () => trigger()); return () => { diff --git a/test/integration/test/local-dev-store.integration.ts b/test/integration/test/local-dev-store.integration.ts index 52bd9d4de..1dc80b089 100644 --- a/test/integration/test/local-dev-store.integration.ts +++ b/test/integration/test/local-dev-store.integration.ts @@ -16,32 +16,35 @@ * (`[dev] ready:` + one line per service) is parsed from real stdout; * an HTTP round-trip against the storefront's URL succeeds. * 2. Rebuild restarts exactly one service: touching ONLY catalog's built - * artifact (so its hash moves, nothing else's) and re-converging - * restarts `catalog.service` alone — every other service's pid is - * unchanged, including its own RPC consumers (`orders.service`, - * `cron.runner`) and everything unrelated (`storefront`, - * `cron.scheduler`). Run twice in a row against the same live session - * — this is the regression proof for the restart-amplification defect - * fixed in compute.ts's `materializeEnv` (see its `scopedEnv` doc - * comment): before that fix, the FIRST rebuild after a cold start - * restarted catalog's RPC consumers too, even though nothing about - * them had changed. + * artifact (so its hash moves, nothing else's) restarts `catalog.service` + * alone — every other service's pid is unchanged, including its own RPC + * consumers (`orders.service`, `cron.runner`) and everything unrelated + * (`storefront`, `cron.scheduler`). Run twice in a row against the same + * live session — this is the regression proof for the + * restart-amplification defect fixed in compute.ts's `materializeEnv` + * (see its `scopedEnv` doc comment): before that fix, the FIRST rebuild + * after a cold start restarted catalog's RPC consumers too, even though + * nothing about them had changed. * - * NOT driven through the CLI's own file-watch loop: `Bundle` doesn't - * declare `watch` on this branch yet (spec § 3, S2 slice — watch.ts's - * own doc comment), so every service is currently "unwatchable" and a - * file edit is never detected. Session 1's own dev command still - * writes the real dev stack file and sets up containers/emulators - * exactly as a real session would; the artifact is touched, then the - * SAME stack file is re-converged directly with the real `alchemy` - * binary (matching local-dev.integration.ts's own pattern) — this - * re-hashes catalog's now-different bundle bytes and PUTs a fresh - * deployment for every service, letting the emulator's own (untouched) - * hash/env diffing decide which one(s) actually restart. Session 1's - * services are NEVER stopped in between (SIGINT is app-scoped and - * unconditionally restarts every service on the next redeploy — S3's - * own pinned "a stopped service always starts on redeploy" behavior — - * which would defeat this proof entirely). + * Attempt 1 drives the CLI's own real chokidar-based watch loop: the + * catalog build adapter declares `Bundle.watch: [runnable.source]` — + * the built artifact path itself (build.ts) — so touching + * `modules/catalog/dist/server.mjs` is exactly the file session 1's own + * running `prisma-composer dev` process is already watching. The script + * only touches the file and polls the compute emulator for the new pid + * — it never calls assemble or alchemy itself; the running process's + * own debounce, re-assemble, and re-converge are what's under test. + * Attempt 2 is the direct-converge variant kept as a fallback assertion: + * the artifact is touched, then the SAME dev stack file is re-converged + * directly with the real `alchemy` binary (matching + * local-dev.integration.ts's own pattern), bypassing the watch loop + * entirely — this re-hashes catalog's now-different bundle bytes and + * PUTs a fresh deployment for every service, letting the emulator's own + * (untouched) hash/env diffing decide which one(s) actually restart. + * Session 1's services are NEVER stopped in between (SIGINT is + * app-scoped and unconditionally restarts every service on the next + * redeploy — S3's own pinned "a stopped service always starts on + * redeploy" behavior — which would defeat this proof entirely). * 3. Postgres persistence: a row is written directly against the real * `prisma dev` URL (read from `.prisma-composer/dev/postgres.json`, * never through the app's own RPC surface — this is a storage-layer @@ -382,6 +385,49 @@ function alchemyBin(startDir: string): string { * service lets the emulator's own (untouched) hash/env diffing decide which * one(s) restart. Returns every service's pid AFTER the converge. */ +const REAL_WATCH_TIMEOUT_MS = 30_000; + +/** + * Touches ONLY catalog's built artifact — the same file the running + * `prisma-composer dev` process's own chokidar watch loop is already + * watching (`Bundle.watch: [runnable.source]`, build.ts). This does not call + * assemble or alchemy itself: the running process's own debounce (300ms), + * re-assemble, and re-converge are what's being proved. + * + * Waits for the watch loop's OWN completion signal — run-dev.ts reprints the + * front door (`[dev] ready:` + one line per service) after a successful + * reconverge — rather than just for catalog.service's pid to move: the spec + * re-runs assemble for EVERY service on a fire (not just the touched one), + * so waiting on catalog's pid alone can return while the running process is + * still mid-converge for other services, racing this script's own later use + * of the same on-disk artifact directories (confirmed live: a direct + * fallback reconverge started right after the pid-only wait hit "no + * main.js/main.mjs found in bundle dir .../artifacts/storefront" — the real + * process's own converge was still writing it). Bounded by + * `REAL_WATCH_TIMEOUT_MS`. + */ +async function touchCatalogAndAwaitRealWatchLoop( + session: DevSession, + pidsBefore: Record, +): Promise> { + const beforeLineCount = readLog(session.logPath).split('\n').length; + + fs.appendFileSync(catalogDist, `\n// proving-script touch (real watch loop) ${Date.now()}\n`); + + await waitForAsync(async () => { + const newLines = readLog(session.logPath).split('\n').slice(beforeLineCount); + return parseFrontDoor(newLines.join('\n')); + }, REAL_WATCH_TIMEOUT_MS); + + return waitForAsync(async () => { + const pids = await pidsByAddress(APP_NAME); + return pids['catalog.service'] !== undefined && + pids['catalog.service'] !== pidsBefore['catalog.service'] + ? pids + : undefined; + }, 15_000); +} + async function rebuildCatalogAndReconverge(): Promise> { fs.appendFileSync(catalogDist, `\n// proving-script touch ${Date.now()}\n`); @@ -471,22 +517,34 @@ async function main(): Promise { return ADDRESSES.every((a) => pids[a] !== undefined) ? pids : undefined; }, 15_000); + const activeSession = session; + assert(activeSession !== undefined, 'session 1 must be running before criterion 2'); + let pidsBefore = pidsBaseline; - for (const attempt of [1, 2] as const) { - const pidsAfter = await rebuildCatalogAndReconverge(); + const RECONVERGE_STRATEGIES = [ + { + label: 'real watch loop', + run: (before: Record) => + touchCatalogAndAwaitRealWatchLoop(activeSession, before), + }, + { label: 'direct converge (fallback)', run: () => rebuildCatalogAndReconverge() }, + ] as const; + for (const [index, strategy] of RECONVERGE_STRATEGIES.entries()) { + const attempt = index + 1; + const pidsAfter = await strategy.run(pidsBefore); assert( pidsAfter['catalog.service'] !== pidsBefore['catalog.service'], - `criterion 2 (attempt ${attempt}): catalog.service must restart (new pid)`, + `criterion 2 (attempt ${attempt}, ${strategy.label}): catalog.service must restart (new pid)`, ); for (const address of ['storefront', 'cron.runner', 'orders.service', 'cron.scheduler']) { assertEqual( pidsAfter[address], pidsBefore[address], - `criterion 2 (attempt ${attempt}): ${address}'s pid must NOT change — only catalog.service was rebuilt`, + `criterion 2 (attempt ${attempt}, ${strategy.label}): ${address}'s pid must NOT change — only catalog.service was rebuilt`, ); } console.log( - `[proving] PASS criterion 2 (attempt ${attempt}): exactly catalog.service restarted, all four others unchanged`, + `[proving] PASS criterion 2 (attempt ${attempt}, ${strategy.label}): exactly catalog.service restarted, all four others unchanged`, ); pidsBefore = pidsAfter; } From d14d6e6b9d387c9c1bb9da74c8ba9abf90afe814 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 17:21:50 +0200 Subject: [PATCH 22/43] test(local-target): relocate the scopedEnvRows test next to the code that moved Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../src/__tests__/dev-compute-scoped-env.test.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/1-prisma-cloud/0-lowering/{lowering => local-target}/src/__tests__/dev-compute-scoped-env.test.ts (100%) diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/dev-compute-scoped-env.test.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/dev-compute-scoped-env.test.ts similarity index 100% rename from packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/dev-compute-scoped-env.test.ts rename to packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/dev-compute-scoped-env.test.ts From 285e9c9224a9a8f19e24600c9bbe8fc3698f6186 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 17:21:58 +0200 Subject: [PATCH 23/43] docs(drive): take spec.md/plan.md wholesale from the design tip (7840eb3) Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .drive/projects/local-dev/plan.md | 39 ++++-- .drive/projects/local-dev/spec.md | 189 +++++++++++++++++++++++------- 2 files changed, 174 insertions(+), 54 deletions(-) diff --git a/.drive/projects/local-dev/plan.md b/.drive/projects/local-dev/plan.md index ffe4acfd8..ccda872a5 100644 --- a/.drive/projects/local-dev/plan.md +++ b/.drive/projects/local-dev/plan.md @@ -123,23 +123,40 @@ scripts. Implementer dispatches use Sonnet-4.6-mid, reviewers Opus-4.8-mid ## Known items blocking close-out (found in S5 proving, 2026-07-23) -- **Restart amplification (defect):** rebuilding one service restarts its - RPC dependents too (observed: catalog rebuild → catalog + cron.runner + - orders.service restarted; storefront + cron.scheduler correctly - untouched). Something in the dependents' materialized deployment env - recomputes as changed on every converge. Root-cause and fix in the local - providers (S4 code) before S6 sign-off — acceptance criterion 2 requires - exactly-one-service restarts. -- **Criteria 4/5 scripting pending:** `examples/store` has neither a bucket - nor a secret/env-param; the S4 fixture needs both to script the bucket - file-drop round-trip and the placeholder-warning / env-param-error - criteria at the CLI level. +- ~~Restart amplification~~ — **resolved on the S5 branch**: root-caused + (app-wide env materialization completing between converges) with + three-converge byte evidence; fixed by the pinned scoped materialization; + proven by the store-level exactly-one-restart assertion, run twice. +- ~~Criteria 4/5 scripting~~ — **resolved on the S5 branch**: the S4 + fixture gained a bucket flow, a secret, and an envParam; + `local-dev-criteria-4-5.integration.ts` proves all four sub-criteria at + the CLI level as a required gate. - ~~Emulator stop/reap honesty~~ — **resolved, no bug**: `killChild` already awaits SIGTERM → 5 s grace → SIGKILL → exit before state flips; the observation was the grace period itself. Locked in by a regression test (an ignores-SIGTERM fixture: the listing never says `stopped` while the pid lives, and the stop measurably takes the grace period). +### S7 — MERGED INTO THE #162 REWORK WAVE (operator review, 2026-07-23) + +Will's #162 review mandated the programmatic adoption in-place ("replace +it with programmatic use of prisma dev") — the section below is executed +as part of the rework, not post-stack. + +### (was S7) — adopt @prisma/dev programmatically + +Operator decision (2026-07-23): replace the Postgres CLI shell-out with +`@prisma/dev`'s programmatic API, hosted in our own `postgres-main` +emulator daemon beside compute/buckets — `startPrismaDevServer({ name, +databasePort, persistenceMode })` per `Database` resource, ports from our +registry, persistence on. Deletes: bin walk-up, last-stdout-line URL +parsing, `prisma dev start/stop/rm` coupling, the probe/start recovery +sequence. Sub-decision to settle at pickup: version ownership (lean: +resolve `@prisma/dev` from the app's node_modules so the app owns the +version, consistent with runtime ownership). The CLI shell-out in the +shipped slices is transitional and marked so here. Not started until the +current stack merges. + ## Close-out (required) - [ ] Verify all acceptance criteria in `.drive/projects/local-dev/spec.md` diff --git a/.drive/projects/local-dev/spec.md b/.drive/projects/local-dev/spec.md index f277ddbf5..3ac4e1373 100644 --- a/.drive/projects/local-dev/spec.md +++ b/.drive/projects/local-dev/spec.md @@ -163,8 +163,10 @@ plus the shared daemon layer and typed loopback clients. `version` === this package's version → return `http://127.0.0.1:`. 3. Version mismatch → SIGTERM (5 s grace, SIGKILL), fall through. Dead pid / failed health → clean the entry, fall through. - 4. Start: port = persisted port if any, else smallest ≥ 4300 unused across - registry entries, persisted immediately. Spawn `process.execPath + 4. Start: port = persisted port if any, else allocated via `get-port` + (preferred range ≥ 4300, excluding registry-used ports — the free-port + PROBE is the library's, our persistence and range policy stay ours), + persisted immediately. Spawn `process.execPath --port --state-dir //` with `detached: true`, stdio appended to `/.log`, `unref()` — the `registryRoot` override governs the registry file, the @@ -185,17 +187,19 @@ plus the shared daemon layer and typed loopback clients. endpoints frozen in deploy state reference it — fail with the pinned error; recovery is manual (`--fresh` does NOT touch the daemons — they are machine-global, shared by other apps). -- **Concurrent-ensure protocol:** the observe→spawn→persist critical - section is serialized ACROSS PROCESSES per daemon name with an atomic - directory lock: `mkdir /.lock-` (atomic creation IS - acquisition; the holder writes its pid to `/pid`). On `EEXIST`: - if the recorded holder pid is dead, remove the lock dir and retry - immediately; else poll every 250 ms up to 10 s, then - `Error: timed out waiting for another process ensuring the emulator — remove if stale.` - After acquiring, RE-READ the registry before deciding to spawn — the - previous holder may have already done the job. Port allocation happens - inside the lock, so two daemons can never claim one port. Release - (rm the dir) in a finally. +- **Concurrent-ensure protocol (REVISED — operator decision, 2026-07-23):** + the observe→spawn→persist critical section is serialized ACROSS + PROCESSES per daemon name using `proper-lockfile` (maintained library; + its staleness/compromise semantics are adopted wholesale rather than our + earlier hand-rolled mkdir/pid protocol — commodity locking is exactly + where unforeseen edge cases hide). Our wrapper pins only: the lock path + is per-daemon under ``; the bounded wait is ~10 s and its + exhaustion throws + `Error: timed out waiting for another process ensuring the emulator — remove if stale.`; + the registry is RE-READ after acquiring (the previous holder may have + already done the job); port allocation happens inside the lock; release + runs in a finally on every path. The existing inter-process tests stand + unchanged — they validate behavior, not the engine. - `stopDaemon(name)`: SIGTERM/SIGKILL + registry cleanup. Not called by any v1 command — an operator escape hatch, exported for tests. - **Publish note (S5 scope):** `import.meta.resolve('@internal/dev-emulators/…')` @@ -239,6 +243,14 @@ service child processes. Loopback `node:http` JSON admin API; state under its log file's current tail, then live lines while open. - `POST /apps//stop` → stop every child of the app (records kept, status `stopped`) → `204`. +- `POST /apps//start` → start every service that has a stored + deployment spec and is not `running`, from that spec (a service with no + deployment recorded is skipped) → `204`. The symmetric inverse of + `/stop`, and the reason it must exist: Alchemy correctly skips a + provider's reconcile when nothing diffed, so a no-op converge can never + restart services a previous session stopped — the dev SESSION is the + explicit resume signal (found live on the open-chat proof: warm restart + printed ready while every service stayed stopped). - `DELETE /apps/` → stop + remove the app's records and logs → `204`. Supervision policy (emulator-owned): unexpected exit → restart with backoff @@ -342,6 +354,8 @@ export interface DevAttachInput { } export interface DevAttachment { + /** Start every stopped service from its last deployment (the session-resume signal — a no-op converge cannot start anything). */ + startServices(): Promise; /** Every service's local endpoint, for the front door. */ endpoints(): Promise; /** Merged, line-oriented log stream across the app's services (including services that appear after later converges). Ends when `signal` aborts. */ @@ -400,10 +414,48 @@ vocabularies. New package `@internal/local-target` at hosted providers, its own name: it IS the local deploy target's provider suite), plane `control`, importing `@internal/dev-emulators` and `@internal/s3-protocol`. Everything previously specced under -`@internal/lowering`'s `src/dev/` lives here instead; `artifact-extract` -stays beside the artifact writer it mirrors (in `@internal/lowering`'s -compute dir) and is imported downward. No floating module-level doc -comments that attach to nothing (repo style). +`@internal/lowering`'s `src/dev/` lives here instead — INCLUDING +`artifact-extract` (operator: emulation code is physically separated from +production source, full stop; the tar reader exists only for dev, so it +and its `tar` dependency live here, not in `@internal/lowering`). +Format-drift protection comes from a round-trip test in this package that +imports the WRITER (`packageComputeArtifact`, a downward import) and +asserts extraction fidelity — co-location is not the mechanism. No +floating module-level doc comments that attach to nothing (repo style). + +**Physical-separation rule (operator, 2026-07-23), applied everywhere:** +dev/emulation code lives in dev-only packages (`@internal/local-target`, +`@internal/dev-emulators`) or dev-only modules/entry points — never +intermixed with production logic. In core, the dev aggregator +(`devProviders`) and `DEV_DIR` are exported via a NEW control-plane +subpath `@prisma/composer/dev` (its own `src/exports/dev.ts`; the +generated dev stack module imports from it) — nothing dev-flavored is +exported from `/deploy` or the root. The seam TYPES +(`DevExtensionDescriptor` and its inputs) remain in `app-config.ts` +because `ExtensionDescriptor.dev` must reference them — they are contract, +not emulation logic. The IMPLEMENTATIONS are fully excluded from +production bundles (operator directive): see the lazy dev reference +below. + +**The dev seam is a lazy reference (REVISED — operator directive):** +`ExtensionDescriptor.dev?: () => Promise`. The +production control entry carries ONE line — a thunk dynamically importing +the extension's own dev entry by bare specifier — so no dev implementation +code is bundled into, or loaded by, any deploy path. The extension ships a +separate control-plane entry `src/exports/dev.ts` → public subpath +`@prisma/composer-prisma-cloud/dev`, exporting `devDescriptor(): +DevExtensionDescriptor` (self-contained; the dev side needs none of the +deploy factory's options — it is credential-free by design). `prismaCloud()`'s +field is exactly: +`dev: () => import('@prisma/composer-prisma-cloud/dev').then((m) => m.devDescriptor())`. +Core's `@prisma/composer/dev` subpath gains +`resolveDevDescriptors(config): Promise>` +(resolves every non-build-only extension's thunk once, with the pinned +no-dev-support error for a missing thunk); `devProviders(resolved, +containers, devDir)` takes the RESOLVED map. The generated dev stack +module top-level-awaits `resolveDevDescriptors(config)` (ESM TLA); the +dev command resolves once at step 2 and passes resolved descriptors to +every subsequent hook call. Deploy and destroy never touch `dev`. #### `src/dev/dev-store.ts` — the shared dev-instance store @@ -427,6 +479,10 @@ registry (§ 2). Every local provider factory takes `(input: DevProvidersInput)`; `devDir` is `input.devDir`; nothing here reads `process.cwd()` or the environment. +The emulator-facing service id is `slugServiceId(name)` (the same +lowercase/hyphen slugging as postgres instance names) because a dotted +address cannot satisfy § 2's id-segment rule; the real dotted address rides +the deployment body and labels logs/endpoints. Two layer-order accommodations are pinned (this package cannot import the extensions layer): the app name is read as `input.container.input.appName` directly off the generic `ContainerInstance` (the field is on the base @@ -451,15 +507,15 @@ scope, not v1. 1. Unpack `news.artifactPath` (tar.gz, the ustar format `packageComputeArtifact` writes) into `/artifacts//` if absent — extraction uses the - maintained `tar` package (node-tar; dependency razor — reading tar is - commodity even though we write our own deterministic subset), with - entry filtering pinned: regular files only, reject links/devices, - reject path escapes. Temp-then-rename at the directory level. (WHY an - extractor exists at all: on the platform, the artifact is uploaded and - the platform unpacks it; locally the Compute emulator runs from a - directory, so the unpack step that was platform-side needs a local - counterpart. Unpacking the real tar keeps the strongest parity: what - runs is byte-for-byte what ships.) + maintained `tar` package (dependency razor), implemented IN + `@internal/local-target` (physical-separation rule: the reader exists + only for dev), with entry filtering pinned: regular files only, + reject links/devices, reject path escapes. Temp-then-rename at the + directory level. (WHY an extractor exists at all: on the platform the + artifact is uploaded and the platform unpacks it; locally the Compute + emulator runs from a directory, so the platform-side unpack step + needs a local counterpart. Unpacking the real tar keeps the strongest + parity: what runs is byte-for-byte what ships.) 2. Fetch the service's port: `PUT /apps//services/` (idempotent) where `id` = the ComputeService's name resolved from `news.computeServiceId`. Resolve the address from @@ -506,10 +562,31 @@ allocated from our registry (stable, persisted), admin over the same loopback pattern as the other daemons (create/ensure database instance, list, remove). The local Database/Connection providers become its clients; teardown removes the app's instances through the daemon's admin API. +`postgres-main`'s admin surface (loopback, same daemon layer/registry as +the other two): `GET /health`; `PUT /apps//databases/` body +`{ "prismaDevModulePath": string }` → ensure a named persistent server +(instance name per the derivation below; `databasePort` from the daemon's +own persisted state, fresh-allocation retry per the § 2 pattern; the +`@prisma/dev` module is imported from the CALLER-RESOLVED path so the app +owns the version) → `{ "url": string }`, idempotent; `GET +/apps//databases` → listing; `DELETE /apps/` → close the app's +servers and delete their persisted data (this is `--fresh`'s path). A +server that fails to start surfaces the underlying error text verbatim +(credential-masked) in the 500 body. Version ownership: `@prisma/dev` resolves from the APP's node_modules (passed into the daemon as a resolved path), keeping the app in charge of -its Prisma version; absent → +its Prisma version. Resolution is two-step, pinned: (1) +`createRequire(join(cwd, 'package.json')).resolve('@prisma/dev')`; (2) on +failure, resolve `prisma`'s own package first and resolve `@prisma/dev` +from THERE (apps typically depend on the `prisma` CLI, which carries it); +both failing → `Error: local dev needs @prisma/dev for its local Postgres emulator — add "prisma" to your app's devDependencies.` +The local `Database` provider PUTs the daemon's ensure with that path and +stores the returned `{ url }` on its own ATTRIBUTES; the local +`Connection` provider resolves its url from the daemon's listing (GET +databases, matched by instance name derived from `news.databaseId`). +`dev-store`'s `postgres.json` is DELETED — the daemon owns instance state. +`resolve-bin.ts` and `spawn-utils.ts` are deleted with the shell-out. - Instance name derivation: `pcdev--`, where `` and `` are lowercased with every char outside `[a-z0-9]` replaced by `-`, runs collapsed, trimmed to 63 chars. @@ -593,12 +670,12 @@ without either. not leak into platform primitives. The address is already intrinsic to the packaged artifact (its `bootstrap.js` bakes `run("
", …)`), so the artifact's OWN manifest carries it: `packageComputeArtifact` writes -`compute.manifest.json` as `{ manifestVersion: "2", entrypoint: -"bootstrap.js", address: "
" }` (a format this repo owns; version -bumped, readers of "1" unaffected — the platform reads only `entrypoint`). +`compute.manifest.json` as `{ manifestVersion: "1", entrypoint: +"bootstrap.js", address: "
" }` — no version bump (operator: there +are no consumers to protect; the platform reads only `entrypoint`). The local Deployment provider reads `address` from the unpacked artifact's manifest; a manifest without it → -`Error: artifact manifest carries no address — repackage with a current @prisma/composer (manifestVersion 2).` +`Error: artifact manifest carries no address — repackage with a current @prisma/composer.` `ComputeSerialized.address` and the serialize/deploy threading are also reverted. @@ -628,11 +705,12 @@ New control-plane files (all under `src/`, plane `control` in `missingError` but scoped `local dev` and instructing `Set each in the shell you run \`prisma-composer dev\` from.` - `src/dev/emulators.ts` — `runDevEmulators(input: DevEmulatorsInput)`: - inspect the graph's node kinds; `ensureDaemon('compute')` always (every - app has services); `ensureDaemon('buckets')` when any `s3`-kinded resource - node exists. Postgres needs no pre-start — its instances are created at - provision through the ORM CLI. Idempotent; prints one `[dev]` line per - daemon it actually started. + inspect the graph's node kinds; `ensureDaemon('compute', …)` always + (every app has services); `ensureDaemon('buckets', …)` when any + `s3`-kinded resource node exists; `ensureDaemon('postgres', …)` when any + `postgres`- or `prisma-next`-kinded resource node exists (REVISED — + Postgres is a first-class daemon since the programmatic adoption). + Idempotent; prints one `[dev]` line per daemon it actually started. - `src/dev/attach.ts` — `devAttach(input: DevAttachInput): DevAttachment`, a Compute-emulator client scoped to the app: - `endpoints()` → `GET /apps//services`, mapped to @@ -643,6 +721,7 @@ New control-plane files (all under `src/`, plane `control` in attaching followers for services that appeared after a later converge and re-attaching any follower whose connection dropped (an emulator restart shows a gap, never a dead session). + - `startServices()` → `POST /apps//start`. - `stopServices()` → `POST /apps//stop`. - `src/dev/teardown.ts` — `runDevTeardown(input: TeardownInput)`: 1. ` dev stop 'pcdev--*'` then @@ -725,7 +804,9 @@ New control-plane files (all under `src/`, plane `control` in DEV_STACK_RELATIVE_PATH, cwd, stage: 'dev', containerEnv })`. Nonzero exit: print the stack-file reproduction hint (deploy's pattern, with `--stage dev`) and exit with that status. - 8. Attach: `dev.attach({ container, devDir })` per extension; print the + 8. Attach: `dev.attach({ container, devDir })` per extension; call every + attachment's `startServices()` (resume services a previous session's + Ctrl-C stopped — converge cannot, when nothing diffed); then print the front door from the merged `endpoints()` (ordered by address depth, fewest dots first, then lexicographic; first line preceded by `[dev] ready:`); pump every attachment's `logs()` to stdout, each @@ -733,6 +814,15 @@ New control-plane files (all under `src/`, plane `control` in 9. Watch loop (below) until SIGINT/SIGTERM; on exit call every attachment's `stopServices()`, then exit 0 — emulators and data stay up by design (machine-scoped daemons; `--fresh` removes instances). + Recorded limitation: the dev command becomes the SOLE signal + listener at this point (alchemy's transitively imported library code + registers exit-on-signal listeners at module load; they are stripped + so cleanup can run), so a second Ctrl-C does not force-quit a hung + stop — the emulator's kill grace bounds it in practice. + Recorded follow-up: `PreflightInput` carries no `devDir`, so the dev + preflight derives it from `process.cwd()` — correct under the CLI + (which runs from the app dir) but asymmetric with every other dev + hook; adding `devDir` to the input is future work, not v1. - `generate-dev-stack.ts` — like `generate-stack.ts` but at `.prisma-composer/dev/alchemy.run.ts` (`DEV_STACK_RELATIVE_PATH`), emitting: @@ -755,8 +845,13 @@ New control-plane files (all under `src/`, plane `control` in as the reproduction line. - `watch.ts` — watch each assembled bundle's `watch` paths (the adapter-declared user-built inputs — § 3's `Bundle.watch`; a bundle - without them is not watched, noted once at startup). `fs.watch` - recursive on dirs, plain on files; debounce 300 ms per burst, coalescing across + without them is not watched, noted once at startup). The watch ENGINE + is `chokidar` v4 (operator decision: don't-reinvent-the-wheel beats the + no-new-deps contract here — chokidar absorbs the atomic-rename/inode + class we hand-fixed once already and the cross-platform recursive-watch + differences; v4 is pure JS, no native code, no glob surface). The + hand-rolled parent-directory workaround is deleted; its delete+recreate + regression test STAYS as a behavior test. Debounce 300 ms per burst, coalescing across services. On fire: re-run assemble for ALL services (correctness over cleverness; optimization is a recorded follow-up) → rewrite the dev stack file → re-run converge (`--stage dev`) — the emulator restarts exactly @@ -800,9 +895,15 @@ The open-chat proof (S6) uses `node({ module, dir, entry })`. ## Behavior contracts (cross-cutting) -- **No new runtime dependencies** in any shipped package. The S3 server, - tar reader, watcher, and emulator daemons use node built-ins only. (`alchemy`, - `effect`, `clipanion` are already present.) +- **Dependency razor (operator decision):** commodity infrastructure with + latent edge cases uses MAINTAINED LIBRARIES — `chokidar` v4 (watching, + § 6), `proper-lockfile` (inter-process locking, § 2), `get-port` (free- + port probing, § 2). Hand-rolled code is reserved for wire formats we own + — the S3 handler, the ustar reader, the emulator admin APIs — where a + dependency is a liability, not a shield. (`alchemy`, `effect`, + `clipanion` are already present.) The earlier no-new-deps contract cost + real bugs in exactly the commodity code (a Linux port-probe + self-collision, a BSD/GNU pgrep detour) and is retired. - **Casts**: `.agents/rules/no-bare-casts.mdc` — every cast is `blindCast` with a justification, or real narrowing. The provider attribute shapes are typed against the hosted providers' exported types, not re-declared. @@ -833,7 +934,9 @@ The open-chat proof (S6) uses `node({ module, dir, entry })`. topology; a missing env-sourced param fails with the listing error. - [ ] After Ctrl-C, a second `prisma-composer dev` reaches ready as a warm start: same service ports and URLs, no re-provisioning, Postgres and - bucket data intact. + bucket data intact — and an HTTP round-trip against the front door + SUCCEEDS (the services are genuinely serving, not merely listed; + port-stability alone missed a live resume bug). - [ ] The open-chat port (via the `dir()` adapter) boots through `prisma-composer dev` with sign-in, history, and live-tail working — replacing its hand-rolled `scripts/dev.ts` (parity proof; port-repo From 83d48c93eb61051160a261f6ed7e85d0be37176b Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 17:31:17 +0200 Subject: [PATCH 24/43] feat(cli): resolve dev descriptors once up front, thread them through every hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runDev now calls resolveDevDescriptors(config) at the dev-capability step (ADR-0041 lazy dev reference) — its pinned error covers the no-dev case and build-only extensions are exempt inside it — and every subsequent hook call (containers, --fresh teardown, preflight, emulators, attach) runs off the resolved map. No direct extension.dev. access remains. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../3-tooling/cli/src/dev/run-dev.ts | 84 ++++++++----------- 1 file changed, 37 insertions(+), 47 deletions(-) diff --git a/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts index 64601bbf0..3444d25e9 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts @@ -7,13 +7,10 @@ */ import * as path from 'node:path'; import type { RunAssembler } from '@internal/assemble'; -import type { - ContainerInstance, - DevAttachment, - ExtensionDescriptor, - PrismaAppConfig, -} from '@internal/core/config'; -import { containerEnv, DEV_DIR, isBuildOnlyExtension } from '@internal/core/config'; +import type { ContainerInstance, PrismaAppConfig } from '@internal/core/config'; +import { containerEnv } from '@internal/core/config'; +import type { DevAttachment, DevExtensionDescriptor } from '@internal/core/dev'; +import { DEV_DIR, resolveDevDescriptors } from '@internal/core/dev'; import { CliError } from '../cli-error.ts'; import { type PipelineDeps, runPipeline } from '../pipeline.ts'; import { type RunAlchemyInput, runAlchemy } from '../run-alchemy.ts'; @@ -40,22 +37,6 @@ function toCliError(error: unknown): CliError { : new CliError(error instanceof Error ? error.message : String(error)); } -/** Every configured extension that participates in dev (build-only extensions are exempt — ADR-0041). */ -function devParticipants(config: PrismaAppConfig): readonly ExtensionDescriptor[] { - return config.extensions.filter((extension) => !isBuildOnlyExtension(extension)); -} - -function checkDevCapability(config: PrismaAppConfig): void { - for (const extension of devParticipants(config)) { - if (extension.dev === undefined) { - throw new CliError( - `extension "${extension.id}" has no local dev support (no \`dev\` descriptor) — remove ` + - 'it from prisma-composer.config.ts or update it.', - ); - } - } -} - /** `[dev] ready:` then one line per endpoint, ordered by address depth (fewest dots first) then lexicographic. Exported for tests. */ export function renderFrontDoor( endpoints: readonly { readonly address: string; readonly url: string }[], @@ -138,17 +119,22 @@ export async function runDev(args: DevArgs, deps: DevRunDeps = {}): Promise; + try { + resolved = await resolveDevDescriptors(config); + } catch (error) { + throw toCliError(error); + } // 3. Containers — purely local, resolved before anything else can fail. const containers = new Map(); - for (const extension of participants) { - const dev = extension.dev; - if (dev === undefined) continue; // checkDevCapability already proved this can't happen + for (const [id, dev] of resolved) { try { - containers.set(extension.id, await dev.container.ensure({ appName: name, stage: undefined })); + containers.set(id, await dev.container.ensure({ appName: name, stage: undefined })); } catch (error) { throw toCliError(error); } @@ -156,11 +142,10 @@ export async function runDev(args: DevArgs, deps: DevRunDeps = {}): Promise Date: Thu, 23 Jul 2026 17:31:24 +0200 Subject: [PATCH 25/43] =?UTF-8?q?feat(cli):=20emit=20the=20REVISED=20dev?= =?UTF-8?q?=20stack=20template=20=E2=80=94=20the=20one=20orchestration=20p?= =?UTF-8?q?oint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated module now deserializes the containers, top-level-awaits resolveDevDescriptors(config), and passes providers: devProviders(...) + state: localState() explicitly (spec § 3 REVISED — operator review of #162). The dev: true flag is gone — LowerOptions no longer has it; lower() learns nothing about dev. Rendering test updated to the new source. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../dev/__tests__/generate-dev-stack.test.ts | 17 +++++++++++++++-- .../3-tooling/cli/src/dev/generate-dev-stack.ts | 15 +++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/0-framework/3-tooling/cli/src/dev/__tests__/generate-dev-stack.test.ts b/packages/0-framework/3-tooling/cli/src/dev/__tests__/generate-dev-stack.test.ts index 664989234..68c991ef1 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/__tests__/generate-dev-stack.test.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/__tests__/generate-dev-stack.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { renderDevStackFile } from '../generate-dev-stack.ts'; describe('renderDevStackFile()', () => { - test('renders the config + app imports (relative), dev: true, localState(), no report', () => { + test('renders the config + app imports (relative), resolved dev providers, localState(), no report', () => { const content = renderDevStackFile({ entryPath: '/repo/app/module.ts', cwd: '/repo/app', @@ -16,13 +16,26 @@ describe('renderDevStackFile()', () => { }); expect(content).toContain("import { lower } from '@prisma/composer/deploy';"); + expect(content).toContain("import { deserializeContainers } from '@prisma/composer/config';"); + expect(content).toContain( + "import { DEV_DIR, devProviders, resolveDevDescriptors } from '@prisma/composer/dev';", + ); expect(content).toContain("import { localState } from 'alchemy/State/LocalState';"); expect(content).toContain('import config from "../../prisma-composer.config.ts";'); expect(content).toContain('import app from "../../module.ts";'); + // The one orchestration point (spec § 3 REVISED): containers deserialized, + // dev thunks resolved by top-level await, providers + state passed + // explicitly — and no `dev` flag, which no longer exists on LowerOptions. + expect(content).toContain( + 'const containers = deserializeContainers(config.extensions, process.env);', + ); + expect(content).toContain('const resolved = await resolveDevDescriptors(config);'); + expect(content).toContain('const devDir = path.join(process.cwd(), DEV_DIR);'); expect(content).toContain('lower(app, config, {'); expect(content).toContain('name: "app"'); - expect(content).toContain('dev: true'); + expect(content).toContain('providers: devProviders(resolved, containers, devDir)'); expect(content).toContain('state: localState()'); + expect(content).not.toContain('dev: true'); expect(content).toContain( '"web": { dir: "/repo/app/modules/web/dist/bundle", entry: "server.js" }', ); diff --git a/packages/0-framework/3-tooling/cli/src/dev/generate-dev-stack.ts b/packages/0-framework/3-tooling/cli/src/dev/generate-dev-stack.ts index 0a473b65f..a8dfee2ac 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/generate-dev-stack.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/generate-dev-stack.ts @@ -3,7 +3,11 @@ * `.prisma-composer/dev/alchemy.run.ts`, always Alchemy stage `dev`, using * Alchemy's own `localState()` and no `report` — dev prints its own front * door (run-dev.ts), so core's presentation-free lower() gets nothing to - * call back into. + * call back into. The generated module IS the one orchestration point where + * dev provenance is resolved (spec § 3 REVISED): it deserializes the + * containers, top-level-awaits `resolveDevDescriptors(config)` (the lazy + * dev thunks, ADR-0041), and passes `providers: devProviders(...)` + + * `state: localState()` explicitly — `lower()` learns nothing about dev. */ import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -47,7 +51,7 @@ function renderOptions(input: DevStackFileInput): string { } lines.push(' },'); - lines.push(' dev: true,'); + lines.push(' providers: devProviders(resolved, containers, devDir),'); lines.push(' state: localState(),'); return lines.join('\n'); @@ -65,11 +69,18 @@ export function renderDevStackFile(input: DevStackFileInput): string { // alchemy deploy ${DEV_GENERATED_DIR}/${DEV_GENERATED_FILE} --stage dev // // bisects a CLI bug from an Alchemy bug (deploy-cli.md § Implementation decisions). +import * as path from 'node:path'; +import { deserializeContainers } from '@prisma/composer/config'; import { lower } from '@prisma/composer/deploy'; +import { DEV_DIR, devProviders, resolveDevDescriptors } from '@prisma/composer/dev'; import { localState } from 'alchemy/State/LocalState'; import config from ${quote(configImport)}; import app from ${quote(appImport)}; +const containers = deserializeContainers(config.extensions, process.env); +const resolved = await resolveDevDescriptors(config); +const devDir = path.join(process.cwd(), DEV_DIR); + export default lower(app, config, { ${renderOptions(input)} }); From 62191b6e38ba0f2649791ba6cf2aa5368a5844fd Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 17:31:40 +0200 Subject: [PATCH 26/43] =?UTF-8?q?feat(core,prisma-cloud):=20session=20resu?= =?UTF-8?q?me=20=E2=80=94=20startServices()=20on=20DevAttachment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec § 6 step 8 (pinned): a no-op converge cannot start anything, so after attach the dev command calls every attachment startServices() before printing the front door. Core gains the method on the DevAttachment seam; the target implements it as POST /apps//start (the Compute emulator client already carried startApp). Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- packages/0-framework/1-core/core/src/control/app-config.ts | 2 ++ packages/1-prisma-cloud/1-extensions/target/src/dev/attach.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/0-framework/1-core/core/src/control/app-config.ts b/packages/0-framework/1-core/core/src/control/app-config.ts index f80ea4ea2..bbf842646 100644 --- a/packages/0-framework/1-core/core/src/control/app-config.ts +++ b/packages/0-framework/1-core/core/src/control/app-config.ts @@ -145,6 +145,8 @@ export interface LocalTargetAttachInput { } export interface LocalTargetAttachment { + /** Start every stopped service from its last deployment (the session-resume signal — a no-op converge cannot start anything). */ + startServices(): Promise; /** Every service's local endpoint, for the front door. */ endpoints(): Promise; /** Merged, line-oriented log stream across the app's services (including services that appear after later converges). Ends when `signal` aborts. */ diff --git a/packages/1-prisma-cloud/1-extensions/target/src/dev/attach.ts b/packages/1-prisma-cloud/1-extensions/target/src/dev/attach.ts index 4bfa86eb4..5fb85b92e 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/dev/attach.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/dev/attach.ts @@ -97,6 +97,7 @@ export async function devAttach(input: LocalTargetAttachInput): Promise client.startApp(app), endpoints: async () => { const services = await client.listServices(app); return services.map((svc) => ({ address: svc.address, url: svc.url })); From d16f4c7a646e27b6d0cf5d453a7525e98354ea1a Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 17:31:51 +0200 Subject: [PATCH 27/43] test(integration): postgres through the daemon; pinned warm-restart HTTP round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proving scripts now resolve the lazy dev thunk and take their whole Postgres lifecycle through the postgres-main daemon: the catalog DB URL comes from the daemon database listing (dev-store postgres.json is gone), diagnostics dump the daemon listing and its log tail instead of shelling out to prisma dev ls, and cleanup comments name the daemon path. The store script warm-restart criterion gains the pinned HTTP round-trip AFTER the warm restart — services must genuinely serve, not merely list with stable ports (the exact blind spot that let a resume bug ship). The CI engine cache warm step comment is updated for the programmatic @prisma/dev world. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 26 ++-- .../local-dev-criteria-4-5.integration.ts | 26 +--- .../test/local-dev-store.integration.ts | 136 +++++++++++------- .../integration/test/local-dev.integration.ts | 23 ++- 4 files changed, 109 insertions(+), 102 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba040c37a..037fb0f3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,18 +76,20 @@ jobs: # under CI concurrency. - name: Build packages run: pnpm build - # `prisma dev` (the ORM CLI's local-Postgres emulator — the local-dev - # integration proofs' Database provider shells out to it) downloads its - # database engine binaries on its OWN first-ever invocation on a - # machine. A dev workstation accumulates that cache across sessions; - # a CI runner starts genuinely cold every time. Isolating the download - # in its own step gives it a clear, attributable failure (network - # egress to the engine host, disk space, ...) instead of surfacing - # buried inside a 5-service `alchemy deploy` three provider frames - # deep — which is exactly what made the failure this step's own commit - # message references undiagnosable. The instance itself is disposable; - # only its download populates the shared engine cache every later - # `prisma dev` invocation (the integration proofs') reuses. + # `@prisma/dev` (the ORM's local-Postgres server — the local-dev + # integration proofs' postgres-main daemon hosts it programmatically + # via `startPrismaDevServer`) downloads its database engine binaries + # on its first-ever invocation on a machine. A dev workstation + # accumulates that cache across sessions; a CI runner starts genuinely + # cold every time. Isolating the download in its own step (the `prisma` + # CLI drives the same `@prisma/dev` engine cache) gives it a clear, + # attributable failure (network egress to the engine host, disk + # space, ...) instead of surfacing buried inside a 5-service + # `alchemy deploy` three provider frames deep — which is exactly what + # made the failure this step's own commit message references + # undiagnosable. The instance itself is disposable; only its download + # populates the shared engine cache every later invocation (the + # integration proofs') reuses. - name: Warm the prisma dev engine cache working-directory: test/integration run: | diff --git a/test/integration/test/local-dev-criteria-4-5.integration.ts b/test/integration/test/local-dev-criteria-4-5.integration.ts index d3a903740..ff6fd741d 100644 --- a/test/integration/test/local-dev-criteria-4-5.integration.ts +++ b/test/integration/test/local-dev-criteria-4-5.integration.ts @@ -73,17 +73,6 @@ function emulatorRegistryRoot(): string { return path.join(os.homedir(), '.prisma-composer', 'emulators'); } -function resolveBin(name: string): string | undefined { - let dir = integrationDir; - for (;;) { - const candidate = path.join(dir, 'node_modules', '.bin', name); - if (fs.existsSync(candidate)) return candidate; - const parent = path.dirname(dir); - if (parent === dir) return undefined; - dir = parent; - } -} - function tailOf(filePath: string, n = 60): string { let content: string; try { @@ -108,19 +97,10 @@ function dumpDiagnostics(): void { console.error(`\n--- session-${i}.log (tail) ---`); console.error(tailOf(logPath)); } - for (const name of ['compute', 'buckets'] as const) { + for (const name of ['compute', 'buckets', 'postgres'] as const) { console.error(`\n--- ${name} emulator log (tail) ---`); console.error(tailOf(path.join(emulatorRegistryRoot(), `${name}.log`))); } - console.error('\n--- prisma dev ls ---'); - const prismaBin = resolveBin('prisma'); - if (prismaBin === undefined) { - console.error(''); - } else { - const result = spawnSync(prismaBin, ['dev', 'ls'], { encoding: 'utf8' }); - console.error(result.stdout ?? ''); - if (result.stderr) console.error(result.stderr); - } console.error('=== end diagnostics ===\n'); } @@ -341,8 +321,8 @@ async function main(): Promise { await stopDev(session).catch(() => undefined); } // Full app-scoped teardown through the CLI's own --fresh, exactly as - // local-dev-store.integration.ts does — removes the real `prisma dev` - // instance this fixture's `postgres({ name: 'appdb' })` created, the + // local-dev-store.integration.ts does — removes the postgres-main-hosted + // server this fixture's `postgres({ name: 'appdb' })` created, the // emulators' app-scoped records, and the local state dir, never the // machine-global daemons. try { diff --git a/test/integration/test/local-dev-store.integration.ts b/test/integration/test/local-dev-store.integration.ts index 1dc80b089..31f4f2b41 100644 --- a/test/integration/test/local-dev-store.integration.ts +++ b/test/integration/test/local-dev-store.integration.ts @@ -46,21 +46,26 @@ * redeploy — S3's own pinned "a stopped service always starts on * redeploy" behavior — which would defeat this proof entirely). * 3. Postgres persistence: a row is written directly against the real - * `prisma dev` URL (read from `.prisma-composer/dev/postgres.json`, - * never through the app's own RPC surface — this is a storage-layer - * proof, not a business-logic one) before Ctrl-C; a second `dev` start - * reads it back (warm); `--fresh` wipes it. + * local Postgres URL (read from the postgres-main daemon's own + * database listing — the daemon owns instance state, REVISED — + * operator review of #162; never through the app's own RPC surface — + * this is a storage-layer proof, not a business-logic one) before + * Ctrl-C; a second `dev` start reads it back (warm); `--fresh` wipes + * it. * 6. Warm restart: the second start's front-door ports match the first's - * exactly — no re-provisioning. + * exactly — no re-provisioning — AND an HTTP round-trip against the + * front door succeeds (the services genuinely serve after the resume; + * port-stability alone missed a live resume bug — spec § Acceptance). * * Not proved here (see the S5 report): criteria 4/5 (bucket, placeholder/ * env-param) are proved against the S4 fixture instead — store declares * neither a bucket nor a secret/env-param. * * WHY THIS IS A STANDALONE SCRIPT, NOT bun:test: same reasoning as - * local-dev.integration.ts — a nested `prisma dev --detach` grandchild's - * stdout is unreliable under `bun test`'s own process tree. Invoked by - * package.json's `test` script as a second `bun run` step. + * local-dev.integration.ts — a detached grandchild a converge causes to + * spawn (the emulator daemon programs) loses stdout under `bun test`'s own + * process tree. Invoked by package.json's `test` script as a second + * `bun run` step. */ import { type ChildProcess, spawn, spawnSync } from 'node:child_process'; @@ -87,7 +92,6 @@ function assertEqual(actual: T, expected: T, message: string): void { const integrationDir = path.resolve(import.meta.dir, '..'); const repoRoot = path.resolve(integrationDir, '..', '..'); const storeDir = path.join(repoRoot, 'examples', 'store'); -const devDir = path.join(storeDir, '.prisma-composer', 'dev'); // Deliberately OUTSIDE `.prisma-composer/dev` — `--fresh` recursively // removes that whole directory (runDevTeardown), which would delete these // logs mid-run otherwise. @@ -122,17 +126,6 @@ function emulatorRegistryRoot(): string { return path.join(os.homedir(), '.prisma-composer', 'emulators'); } -function resolveBin(name: string): string | undefined { - let dir = integrationDir; - for (;;) { - const candidate = path.join(dir, 'node_modules', '.bin', name); - if (fs.existsSync(candidate)) return candidate; - const parent = path.dirname(dir); - if (parent === dir) return undefined; - dir = parent; - } -} - function tailOf(filePath: string, n = 60): string { let content: string; try { @@ -150,25 +143,24 @@ function tailOf(filePath: string, n = 60): string { * `alchemy` converge output inline) are on the runner, gone the moment the * job ends. Bounded, never throws. */ -function dumpDiagnostics(): void { +async function dumpDiagnostics(): Promise { console.error('\n=== diagnostics ==='); for (let i = 1; i <= sessionCount; i += 1) { const logPath = path.join(logDir, `session-${i}.log`); console.error(`\n--- session-${i}.log (tail) ---`); console.error(tailOf(logPath)); } - for (const name of ['compute', 'buckets'] as const) { + for (const name of ['compute', 'buckets', 'postgres'] as const) { console.error(`\n--- ${name} emulator log (tail) ---`); console.error(tailOf(path.join(emulatorRegistryRoot(), `${name}.log`))); } - console.error('\n--- prisma dev ls ---'); - const prismaBin = resolveBin('prisma'); - if (prismaBin === undefined) { - console.error(''); - } else { - const result = spawnSync(prismaBin, ['dev', 'ls'], { encoding: 'utf8' }); - console.error(result.stdout ?? ''); - if (result.stderr) console.error(result.stderr); + console.error(`\n--- postgres-main databases (app ${APP_NAME}) ---`); + try { + console.error(JSON.stringify(await listPostgresDatabases(), null, 2)); + } catch (error) { + console.error( + ``, + ); } console.error('=== end diagnostics ===\n'); } @@ -212,8 +204,8 @@ function isRegistryEntry(value: unknown): value is EmulatorRegistryEntry { } /** The documented on-disk registry contract (spec § 2 daemon.ts) — read directly, same as local-dev.integration.ts. */ -function readComputeEntry(): EmulatorRegistryEntry | undefined { - const parsed = readJson(path.join(os.homedir(), '.prisma-composer', 'emulators', 'compute.json')); +function readEmulatorEntry(name: 'compute' | 'postgres'): EmulatorRegistryEntry | undefined { + const parsed = readJson(path.join(emulatorRegistryRoot(), `${name}.json`)); return isRegistryEntry(parsed) ? parsed : undefined; } @@ -225,13 +217,27 @@ interface ServiceInfo { /** The documented Compute-emulator wire protocol (spec § 2 compute-main.ts) — plain fetch, never a typed client. */ async function listComputeServices(app: string): Promise { - const entry = readComputeEntry(); + const entry = readEmulatorEntry('compute'); if (entry === undefined) throw new Error('compute emulator registry entry not found'); const res = await fetch(`http://127.0.0.1:${entry.port}/apps/${app}/services`); if (!res.ok) throw new Error(`compute emulator listServices failed: ${res.status}`); return (await res.json()) as ServiceInfo[]; } +interface PostgresDatabaseInfo { + readonly instanceName: string; + readonly url: string; +} + +/** The documented postgres-emulator wire protocol (spec § 4, REVISED — operator review of #162: the daemon owns instance state, there is no dev-store postgres.json) — plain fetch, never the typed client. */ +async function listPostgresDatabases(): Promise { + const entry = readEmulatorEntry('postgres'); + if (entry === undefined) throw new Error('postgres emulator registry entry not found'); + const res = await fetch(`http://127.0.0.1:${entry.port}/apps/${APP_NAME}/databases`); + if (!res.ok) throw new Error(`postgres emulator listDatabases failed: ${res.status}`); + return (await res.json()) as PostgresDatabaseInfo[]; +} + /** Every address's pid, keyed by address — undefined for a stopped/absent service. */ async function pidsByAddress(app: string): Promise> { const services = await listComputeServices(app); @@ -320,15 +326,13 @@ function readJson(file: string): unknown { } } -interface PostgresEntry { - readonly instance: string; - readonly url: string; -} - -function readCatalogDbUrl(): string { - const postgres = readJson(path.join(devDir, 'postgres.json')) as Record; - const entry = postgres['pcdev-store-catalog-database']; - assert(entry?.url !== undefined, 'postgres.json must have a pcdev-store-catalog-database entry'); +async function readCatalogDbUrl(): Promise { + const databases = await listPostgresDatabases(); + const entry = databases.find((d) => d.instanceName === 'pcdev-store-catalog-database'); + assert( + entry?.url !== undefined, + 'the postgres-main listing must carry a pcdev-store-catalog-database entry', + ); return entry.url; } @@ -450,7 +454,8 @@ async function rebuildCatalogAndReconverge(): Promise { } // ——— Criterion 3 (write half): a row through the real local Postgres URL ——— - const dbUrl = readCatalogDbUrl(); + const dbUrl = await readCatalogDbUrl(); await withSql(dbUrl, async (sql) => { // prisma-next's migration DDL folds the table name and single-word // columns to lowercase (unquoted identifiers); "priceCents" alone @@ -577,7 +582,24 @@ async function main(): Promise { 'criterion 6: warm restart keeps the same ports', ); - const warmDbUrl = readCatalogDbUrl(); + // The pinned half of criterion 6 (spec § Acceptance): after the warm + // restart the services must GENUINELY SERVE, not merely be listed with + // stable ports — a converge that diffs nothing starts nothing, so only + // the attach step's startServices() resume makes this round-trip + // succeed. Port stability alone let exactly that resume bug ship. + const warmStorefront = session.endpoints.find((e) => e.address === 'storefront'); + assert(warmStorefront !== undefined, 'the warm front door must list the storefront service'); + const warmHealth = await waitForAsync( + () => fetch(warmStorefront.url).then((r) => (r.ok ? r : undefined)), + 15_000, + ); + assertEqual( + warmHealth.status, + 200, + 'criterion 6: the HTTP round-trip succeeds after a warm restart (services resumed)', + ); + + const warmDbUrl = await readCatalogDbUrl(); assertEqual(warmDbUrl, dbUrl, 'criterion 6: the Postgres URL is stable across a warm restart'); const warmRow = await withSql( warmDbUrl, @@ -589,7 +611,9 @@ async function main(): Promise { 'criterion 3: the row survives a warm (non---fresh) restart', ); console.log('[proving] PASS criterion 3 (warm): row survived a Ctrl-C restart'); - console.log('[proving] PASS criterion 6: same ports, no re-provisioning across a warm restart'); + console.log( + '[proving] PASS criterion 6: same ports, no re-provisioning, and a serving front door across a warm restart', + ); await stopDev(session); console.log('[proving] session 2 stopped cleanly on SIGINT'); @@ -597,9 +621,10 @@ async function main(): Promise { // ——— Criterion 3 (--fresh half): the instance and its data are gone ——— session = await startDev(true); console.log(`[proving] session 3 (--fresh) ready: ${JSON.stringify(session.endpoints)}`); - const freshDbUrl = readCatalogDbUrl(); - // --fresh removes the whole prisma dev instance (prisma dev rm) and - // recreates it on next provision — migrations reapply as part of THIS + const freshDbUrl = await readCatalogDbUrl(); + // --fresh removes the app's postgres-main-hosted servers and their + // persisted data (DELETE /apps/ on the daemon, runDevTeardown) and + // recreates them on next provision — migrations reapply as part of THIS // SAME converge (PnMigration runs on every converge), so the "product" // table exists again by the time the front door prints; the proof is // that it's freshly migrated and EMPTY — the proving row from before @@ -624,7 +649,7 @@ async function main(): Promise { // touch the machine-global emulator daemons, only this app's own // records. `--fresh` teardown only runs BEFORE that session's own // (mandatory) converge, never after — so `startDev(true)` + `stopDev` - // alone still leaves the `prisma dev` postgres instances THIS cleanup + // alone still leaves the postgres-main-hosted servers THIS cleanup // session's own converge just recreated. Call `dev.teardown` directly // afterward to actually remove them (`--fresh` then Ctrl-C then // teardown leaves nothing running and no data behind for the next run). @@ -632,12 +657,13 @@ async function main(): Promise { const cleanup = await startDev(true); await stopDev(cleanup); const descriptor = prismaCloud(); - if (descriptor.dev?.teardown !== undefined) { - const container = await descriptor.dev.container.ensure({ + const dev = descriptor.dev === undefined ? undefined : await descriptor.dev(); + if (dev?.teardown !== undefined) { + const container = await dev.container.ensure({ appName: APP_NAME, stage: undefined, }); - await descriptor.dev.teardown({ container, stage: undefined }); + await dev.teardown({ container, stage: undefined }); } } catch (error) { console.error( @@ -662,8 +688,8 @@ main() .then(() => { process.exitCode = 0; }) - .catch((error: unknown) => { + .catch(async (error: unknown) => { console.error(error instanceof Error ? (error.stack ?? error.message) : error); - dumpDiagnostics(); + await dumpDiagnostics(); process.exitCode = 1; }); diff --git a/test/integration/test/local-dev.integration.ts b/test/integration/test/local-dev.integration.ts index 9f912beed..db773a2f8 100644 --- a/test/integration/test/local-dev.integration.ts +++ b/test/integration/test/local-dev.integration.ts @@ -146,25 +146,24 @@ function tailOf(filePath: string, n = 60): string { * never throws (a missing file/daemon is itself diagnostic, not fatal to * the diagnostic dump). */ -function dumpDiagnostics(): void { +async function dumpDiagnostics(): Promise { console.error('\n=== diagnostics ==='); for (let i = 1; i <= convergeCount; i += 1) { const logFile = path.join(logDir, `converge-${i}.log`); console.error(`\n--- converge-${i}.log (tail) ---`); console.error(tailOf(logFile)); } - for (const name of ['compute', 'buckets'] as const) { + for (const name of ['compute', 'buckets', 'postgres'] as const) { console.error(`\n--- ${name} emulator log (tail) ---`); console.error(tailOf(path.join(emulatorRegistryRoot(), `${name}.log`))); } - console.error('\n--- prisma dev ls ---'); - const prismaBin = resolveBin('prisma'); - if (prismaBin === undefined) { - console.error(''); - } else { - const result = spawnSync(prismaBin, ['dev', 'ls'], { encoding: 'utf8' }); - console.error(result.stdout ?? ''); - if (result.stderr) console.error(result.stderr); + console.error(`\n--- postgres-main databases (app ${APP_NAME}) ---`); + try { + console.error(JSON.stringify(await listPostgresDatabases(), null, 2)); + } catch (error) { + console.error( + ``, + ); } console.error('=== end diagnostics ===\n'); } @@ -709,8 +708,8 @@ main() .then(() => { process.exitCode = 0; }) - .catch((error: unknown) => { + .catch(async (error: unknown) => { console.error(error instanceof Error ? (error.stack ?? error.message) : error); - dumpDiagnostics(); + await dumpDiagnostics(); process.exitCode = 1; }); From da5da76b6329da4976cb4e112504824690e8bff6 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 17:39:29 +0200 Subject: [PATCH 28/43] =?UTF-8?q?fix(local-target):=20slug=20the=20daemon-?= =?UTF-8?q?facing=20database=20id=20=E2=80=94=20dotted=20names=20broke=20e?= =?UTF-8?q?nsure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A nested module Database resource name is dot-separated ("catalog.database"), but the postgres-main daemon path-segment rule forbids dots — the client threw invalid path segment before the request left the process (observed live against examples/store). Same seam as compute.ts slugServiceId: the daemon-facing id is the slug; slug() is idempotent, so the daemon-side instanceNameFor(app, slug(name)) equals the recorded instanceNameFor(app, name) and Connection lookup is unaffected. Proven end to end against the real daemon. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../dev-postgres-instance-name-drift.test.ts | 56 +++++++++++++++++++ .../local-target/src/dev/postgres.ts | 13 ++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/dev-postgres-instance-name-drift.test.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/dev-postgres-instance-name-drift.test.ts index be05cf69d..cf799faad 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/dev-postgres-instance-name-drift.test.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/dev-postgres-instance-name-drift.test.ts @@ -106,4 +106,60 @@ describe('instance-name drift (delta review finding A, #160)', () => { const entry = listed.find((d) => d.instanceName === databaseAttributes.id); expect(entry).toBeDefined(); }, 30_000); + + test('a nested module\'s dotted database name ("catalog.database"): Database ensures, Connection resolves the SAME server', async () => { + // A nested module's Database resource name is hierarchical and + // dot-separated — but the daemon's `` path segment rule + // (/^[a-z0-9][a-z0-9-]*$/) forbids dots, so the provider must slug the + // daemon-facing id (compute.ts's `slugServiceId` seam, applied to + // postgres). Pre-fix, the client's own `encodeSegment` threw + // `invalid path segment "catalog.database"` before the request ever + // left the process — observed live against examples/store. + const DOTTED_ID = 'catalog.database'; + const input: DevProvidersInput = { + container: fakeContainer(APP), + devDir: '/dev/null/unused', + }; + + const databaseService = await Effect.runPromise( + Database.Provider.pipe(Effect.provide(LocalDatabaseProvider(input))), + ); + const databaseAttributes = await Effect.runPromise( + databaseService.reconcile({ + id: 'db', + instanceId: 'db', + news: { projectId: 'p', name: DOTTED_ID, region: 'us-east-1' }, + olds: undefined, + output: undefined, + session: undefined as never, + bindings: [], + }), + ); + + // slug() is idempotent, so the daemon's instanceNameFor(app, slug(name)) + // equals the provider-recorded instanceNameFor(app, name). + expect(databaseAttributes.id).toBe(instanceNameFor(APP, DOTTED_ID)); + expect(databaseAttributes.id).toBe('pcdev-pgdrifttestapp-catalog-database'); + + const connectionService = await Effect.runPromise( + Connection.Provider.pipe(Effect.provide(LocalConnectionProvider(input))), + ); + const connectionAttributes = await Effect.runPromise( + connectionService.reconcile({ + id: 'conn', + instanceId: 'conn', + news: { databaseId: databaseAttributes.id, name: 'conn' }, + olds: undefined, + output: undefined, + session: undefined as never, + bindings: [], + }), + ); + + expect(connectionAttributes.id).toBe(databaseAttributes.id); + expect(Redacted.value(connectionAttributes.connectionString)).toMatch(/^postgres:\/\//); + + const listed = await postgresClient().listDatabases(APP); + expect(listed.find((d) => d.instanceName === databaseAttributes.id)).toBeDefined(); + }, 30_000); }); diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/dev/postgres.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/dev/postgres.ts index 0891f0772..757346c2b 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/dev/postgres.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/dev/postgres.ts @@ -22,7 +22,7 @@ import { createRequire } from 'node:module'; import * as path from 'node:path'; import type { LocalTargetProvidersInput } from '@internal/core/config'; -import { instanceNameFor, postgresClient } from '@internal/dev-emulators'; +import { instanceNameFor, postgresClient, slug } from '@internal/dev-emulators'; import { Connection, Database } from '@internal/lowering/postgres'; import * as Provider from 'alchemy/Provider'; import * as Effect from 'effect/Effect'; @@ -75,9 +75,18 @@ export function LocalDatabaseProvider( try: async () => { const app = appNameOf(input.container); const prismaDevModulePath = resolvePrismaDevModulePath(process.cwd()); + // The daemon's `` path segment must match + // /^[a-z0-9][a-z0-9-]*$/ (spec § 2's API hygiene rule) — but a + // Database resource's name is hierarchical and dot-separated for + // a nested module (e.g. "catalog.database"). Same seam as + // compute.ts's `slugServiceId`: the daemon-facing id is the slug; + // `slug` is idempotent, so the daemon's own + // `instanceNameFor(app, slug(name))` equals + // `instanceNameFor(app, name)` — the very name the attributes + // below record and `Connection` looks up. const { url } = await postgresClient().ensureDatabase( app, - news.name, + slug(news.name), prismaDevModulePath, ); const attributes = { id: instanceNameFor(app, news.name), name: news.name, url }; From 8d0f135c1dc0b9e5ce226398a2d91ef6a0e19e77 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 17:57:39 +0200 Subject: [PATCH 29/43] rename ripples: the S5 surfaces adopt the localTarget seam names Follow-up to the base rename (95e42298): run-dev.ts imports resolveLocalTargets/LocalTargetDescriptor/LocalTargetAttachment from @internal/core/local-target; the generated dev stack template imports localTargetProviders/resolveLocalTargets from @prisma/composer/local-target (rendering test updated); the store proving script resolves descriptor.localTarget; the dotted-id drift test uses LocalTargetProvidersInput. spec.md/plan.md synced wholesale from design tip 12956d92. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .drive/projects/local-dev/spec.md | 5 ++-- .../dev/__tests__/generate-dev-stack.test.ts | 13 ++++++----- .../cli/src/dev/generate-dev-stack.ts | 13 ++++++----- .../3-tooling/cli/src/dev/run-dev.ts | 23 ++++++++++--------- .../dev-postgres-instance-name-drift.test.ts | 2 +- .../test/local-dev-store.integration.ts | 6 ++--- 6 files changed, 33 insertions(+), 29 deletions(-) diff --git a/.drive/projects/local-dev/spec.md b/.drive/projects/local-dev/spec.md index 3ac4e1373..53df687c4 100644 --- a/.drive/projects/local-dev/spec.md +++ b/.drive/projects/local-dev/spec.md @@ -863,8 +863,9 @@ New control-plane files (all under `src/`, plane `control` in ### 7. Directory-shaped builds (REVISED — no new adapter) **Correction (operator catch):** `node()`'s directory form — -`node({ module, dir, entry })` — already exists on `main` and IS friction -#3's fix, implemented before this project's design pass; the original § 7 +`node({ module, dir, entry })` — already exists on `main` and IS the fix +for friction finding 3, implemented before this project's design pass; the +original § 7 pinned a duplicate `dir()` surface against a stale friction-log premise. There is no `dir()` adapter and no `@prisma/composer/dir` subpath. S2's real scope: diff --git a/packages/0-framework/3-tooling/cli/src/dev/__tests__/generate-dev-stack.test.ts b/packages/0-framework/3-tooling/cli/src/dev/__tests__/generate-dev-stack.test.ts index 68c991ef1..4d685a4e2 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/__tests__/generate-dev-stack.test.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/__tests__/generate-dev-stack.test.ts @@ -18,22 +18,23 @@ describe('renderDevStackFile()', () => { expect(content).toContain("import { lower } from '@prisma/composer/deploy';"); expect(content).toContain("import { deserializeContainers } from '@prisma/composer/config';"); expect(content).toContain( - "import { DEV_DIR, devProviders, resolveDevDescriptors } from '@prisma/composer/dev';", + "import { DEV_DIR, localTargetProviders, resolveLocalTargets } from '@prisma/composer/local-target';", ); expect(content).toContain("import { localState } from 'alchemy/State/LocalState';"); expect(content).toContain('import config from "../../prisma-composer.config.ts";'); expect(content).toContain('import app from "../../module.ts";'); - // The one orchestration point (spec § 3 REVISED): containers deserialized, - // dev thunks resolved by top-level await, providers + state passed - // explicitly — and no `dev` flag, which no longer exists on LowerOptions. + // The one orchestration point (spec § 3 REVISED): containers + // deserialized, `localTarget` thunks resolved by top-level await, + // providers + state passed explicitly — and no `dev` flag, which no + // longer exists on LowerOptions. expect(content).toContain( 'const containers = deserializeContainers(config.extensions, process.env);', ); - expect(content).toContain('const resolved = await resolveDevDescriptors(config);'); + expect(content).toContain('const resolved = await resolveLocalTargets(config);'); expect(content).toContain('const devDir = path.join(process.cwd(), DEV_DIR);'); expect(content).toContain('lower(app, config, {'); expect(content).toContain('name: "app"'); - expect(content).toContain('providers: devProviders(resolved, containers, devDir)'); + expect(content).toContain('providers: localTargetProviders(resolved, containers, devDir)'); expect(content).toContain('state: localState()'); expect(content).not.toContain('dev: true'); expect(content).toContain( diff --git a/packages/0-framework/3-tooling/cli/src/dev/generate-dev-stack.ts b/packages/0-framework/3-tooling/cli/src/dev/generate-dev-stack.ts index a8dfee2ac..d118521bd 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/generate-dev-stack.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/generate-dev-stack.ts @@ -5,9 +5,10 @@ * door (run-dev.ts), so core's presentation-free lower() gets nothing to * call back into. The generated module IS the one orchestration point where * dev provenance is resolved (spec § 3 REVISED): it deserializes the - * containers, top-level-awaits `resolveDevDescriptors(config)` (the lazy - * dev thunks, ADR-0041), and passes `providers: devProviders(...)` + - * `state: localState()` explicitly — `lower()` learns nothing about dev. + * containers, top-level-awaits `resolveLocalTargets(config)` (the lazy + * `localTarget` thunks, ADR-0041), and passes + * `providers: localTargetProviders(...)` + `state: localState()` explicitly + * — `lower()` learns nothing about dev. */ import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -51,7 +52,7 @@ function renderOptions(input: DevStackFileInput): string { } lines.push(' },'); - lines.push(' providers: devProviders(resolved, containers, devDir),'); + lines.push(' providers: localTargetProviders(resolved, containers, devDir),'); lines.push(' state: localState(),'); return lines.join('\n'); @@ -72,13 +73,13 @@ export function renderDevStackFile(input: DevStackFileInput): string { import * as path from 'node:path'; import { deserializeContainers } from '@prisma/composer/config'; import { lower } from '@prisma/composer/deploy'; -import { DEV_DIR, devProviders, resolveDevDescriptors } from '@prisma/composer/dev'; +import { DEV_DIR, localTargetProviders, resolveLocalTargets } from '@prisma/composer/local-target'; import { localState } from 'alchemy/State/LocalState'; import config from ${quote(configImport)}; import app from ${quote(appImport)}; const containers = deserializeContainers(config.extensions, process.env); -const resolved = await resolveDevDescriptors(config); +const resolved = await resolveLocalTargets(config); const devDir = path.join(process.cwd(), DEV_DIR); export default lower(app, config, { diff --git a/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts index 3444d25e9..1cf657dd2 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts @@ -9,8 +9,8 @@ import * as path from 'node:path'; import type { RunAssembler } from '@internal/assemble'; import type { ContainerInstance, PrismaAppConfig } from '@internal/core/config'; import { containerEnv } from '@internal/core/config'; -import type { DevAttachment, DevExtensionDescriptor } from '@internal/core/dev'; -import { DEV_DIR, resolveDevDescriptors } from '@internal/core/dev'; +import type { LocalTargetAttachment, LocalTargetDescriptor } from '@internal/core/local-target'; +import { DEV_DIR, resolveLocalTargets } from '@internal/core/local-target'; import { CliError } from '../cli-error.ts'; import { type PipelineDeps, runPipeline } from '../pipeline.ts'; import { type RunAlchemyInput, runAlchemy } from '../run-alchemy.ts'; @@ -61,7 +61,7 @@ const ENDPOINTS_RETRY_DELAY_MS = 500; /** `attachment.endpoints()` right after a converge that just PUT dozens of resources through the same emulator connection can hit a transient refused/reset connection — a brief, genuinely transient loopback hiccup, not a real failure. Retried a few times before giving up. */ async function endpointsWithRetry( - attachment: DevAttachment, + attachment: LocalTargetAttachment, ): Promise { let lastError: unknown; for (let attempt = 1; attempt <= ENDPOINTS_RETRY_ATTEMPTS; attempt += 1) { @@ -78,14 +78,14 @@ async function endpointsWithRetry( } async function mergedEndpoints( - attachments: readonly DevAttachment[], + attachments: readonly LocalTargetAttachment[], ): Promise { const lists = await Promise.all(attachments.map((a) => endpointsWithRetry(a))); return lists.flat(); } /** Pumps every attachment's merged log stream to stdout, each line prefixed `[] `, until `signal` aborts. */ -function pumpLogs(attachments: readonly DevAttachment[], signal: AbortSignal): void { +function pumpLogs(attachments: readonly LocalTargetAttachment[], signal: AbortSignal): void { for (const attachment of attachments) { void (async () => { try { @@ -120,12 +120,13 @@ export async function runDev(args: DevArgs, deps: DevRunDeps = {}): Promise; + // `localTarget` thunk ONCE (ADR-0041's lazy reference); its pinned error + // names any extension without local-target support, and build-only + // extensions are exempt inside it. Every subsequent hook call runs off + // this resolved map. + let resolved: ReadonlyMap; try { - resolved = await resolveDevDescriptors(config); + resolved = await resolveLocalTargets(config); } catch (error) { throw toCliError(error); } @@ -204,7 +205,7 @@ export async function runDev(args: DevArgs, deps: DevRunDeps = {}): Promise { // `invalid path segment "catalog.database"` before the request ever // left the process — observed live against examples/store. const DOTTED_ID = 'catalog.database'; - const input: DevProvidersInput = { + const input: LocalTargetProvidersInput = { container: fakeContainer(APP), devDir: '/dev/null/unused', }; diff --git a/test/integration/test/local-dev-store.integration.ts b/test/integration/test/local-dev-store.integration.ts index 31f4f2b41..e853f6781 100644 --- a/test/integration/test/local-dev-store.integration.ts +++ b/test/integration/test/local-dev-store.integration.ts @@ -453,8 +453,8 @@ async function rebuildCatalogAndReconverge(): Promise { const cleanup = await startDev(true); await stopDev(cleanup); const descriptor = prismaCloud(); - const dev = descriptor.dev === undefined ? undefined : await descriptor.dev(); + const dev = descriptor.localTarget === undefined ? undefined : await descriptor.localTarget(); if (dev?.teardown !== undefined) { const container = await dev.container.ensure({ appName: APP_NAME, From dea33c30df22ea6323ca993655ae2b8769387968 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 19:28:49 +0200 Subject: [PATCH 30/43] test: follow the local-target directory rename in the scoped-env test Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- ...ev-compute-scoped-env.test.ts => compute-scoped-env.test.ts} | 2 +- .../1-prisma-cloud/0-lowering/local-target/src/exports/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/{dev-compute-scoped-env.test.ts => compute-scoped-env.test.ts} (98%) diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/dev-compute-scoped-env.test.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-scoped-env.test.ts similarity index 98% rename from packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/dev-compute-scoped-env.test.ts rename to packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-scoped-env.test.ts index 5e9faa7ed..df7140e5a 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/dev-compute-scoped-env.test.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-scoped-env.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { scopedEnvRows } from '../dev/compute.ts'; +import { scopedEnvRows } from '../compute.ts'; /** * Local-dev spec § 4's pinned parity note: the hosted platform diffs a diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/exports/index.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/exports/index.ts index 9f0c0a04c..621f20fa5 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/exports/index.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/exports/index.ts @@ -1,4 +1,4 @@ -/** `@internal/local-target`'s public surface (local-dev spec § 4): the local deploy target's provider suite — the dev provider bundle plus the dev-instance store and shared plumbing. Implementation lives in `../dev/*`. */ +/** `@internal/local-target`'s public surface (local-dev spec § 4): the local deploy target's provider suite — the dev provider bundle plus the dev-instance store and shared plumbing. Implementation sits beside this barrel at `src/`. */ export * from '../app-name.ts'; export * from '../bucket.ts'; export * from '../compute.ts'; From 7d20359bbc8e33737feedd30e4d3b02e8347545a Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 19:39:19 +0200 Subject: [PATCH 31/43] test(integration): mask credentialed URLs in the harness's failure dump CodeQL (js/clear-text-logging): the top-level catch prints the raw error, and a converge failure can embed a live connection string; apply the spec's masking contract before the text reaches CI's public log. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- test/integration/test/local-dev.integration.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/integration/test/local-dev.integration.ts b/test/integration/test/local-dev.integration.ts index db773a2f8..79b91a980 100644 --- a/test/integration/test/local-dev.integration.ts +++ b/test/integration/test/local-dev.integration.ts @@ -709,7 +709,10 @@ main() process.exitCode = 0; }) .catch(async (error: unknown) => { - console.error(error instanceof Error ? (error.stack ?? error.message) : error); + // Mask credentialed URLs (spec's masking contract) — a converge error can + // embed a live connection string, and this line reaches CI's public log. + const text = error instanceof Error ? (error.stack ?? error.message) : String(error); + console.error(text.replace(/:\/\/([^:@/\s]+):[^@/\s]+@/g, '://$1:***@')); await dumpDiagnostics(); process.exitCode = 1; }); From c1e152b323aff0c4f96d024722b8edf278725624 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 19:39:19 +0200 Subject: [PATCH 32/43] fix(dev-emulators): retry postgres start when ANY requested port is refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seen on CI: @prisma/dev refused a start because an aux listener port (51301) 'belongs to another Prisma Dev server' — its registry claims ports no bind probe sees, and the previous retry only recognized a conflict on the database port itself, so the refusal surfaced as a 500. All three of @prisma/dev's port-refusal errors carry the offending port; treat a conflict on any of the four requested ports as retryable and exclude conflicted ports from re-allocation. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../dev-emulators/src/postgres-main.ts | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts index 10c658dcc..fbca4c8d8 100644 --- a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts +++ b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts @@ -143,9 +143,18 @@ function isPrismaDevModule(value: unknown): value is PrismaDevModule { * number matching the candidate we just tried — is what actually survives * that boundary. */ -function isPortConflict(err: unknown, port: number): boolean { - if (typeof err !== 'object' || err === null || !('port' in err)) return false; - return err.port === port; +/** + * The port a start refusal is about, when it is one of THIS attempt's ports. + * Duck-typed (`instanceof` fails across the dynamic-import boundary): every + * `@prisma/dev` port refusal — not-available, requested-twice, and + * belongs-to-another-server (its registry can claim a port no bind probe + * sees) — carries the offending port as `.port`, and any of the four ports + * we requested (database + the three aux listeners) can be the one refused. + */ +function portConflictOf(err: unknown, ports: readonly number[]): number | undefined { + if (typeof err !== 'object' || err === null || !('port' in err)) return undefined; + const port = err.port; + return typeof port === 'number' && ports.includes(port) ? port : undefined; } /** @@ -324,8 +333,14 @@ function main(): void { return ports; } - async function smallestUnusedDatabasePort(min: number): Promise { - return getPort({ port: portNumbers(min, MAX_DATABASE_PORT), exclude: usedDatabasePorts() }); + async function smallestUnusedDatabasePort( + min: number, + alsoExclude: ReadonlySet = new Set(), + ): Promise { + return getPort({ + port: portNumbers(min, MAX_DATABASE_PORT), + exclude: [...usedDatabasePorts(), ...alsoExclude], + }); } /** Three fresh, mutually distinct ports for a server's own HTTP/shadow-database/streams listeners — never persisted, only unique right now. */ @@ -373,8 +388,9 @@ function main(): void { let dbPort = existingRecord?.databasePort ?? (await smallestUnusedDatabasePort(MIN_DATABASE_PORT)); + const conflicted = new Set(); for (let attempt = 1; ; attempt++) { - const aux = await allocateAuxPorts(new Set([dbPort])); + const aux = await allocateAuxPorts(new Set([dbPort, ...conflicted])); try { const server = await prismaDev.startPrismaDevServer({ name: instanceName, @@ -398,15 +414,24 @@ function main(): void { schedulePersist(); return { url }; } catch (err) { + const conflictPort = portConflictOf(err, [ + dbPort, + aux.httpPort, + aux.shadowDatabasePort, + aux.streamsPort, + ]); const canRetryNextPort = - isFreshAllocation && isPortConflict(err, dbPort) && attempt < maxAttempts; + isFreshAllocation && conflictPort !== undefined && attempt < maxAttempts; if (!canRetryNextPort) { const reason = err instanceof Error ? err.message : String(err); throw new Error( `postgres emulator failed to start database "${instanceName}" on port ${String(dbPort)}: ${maskCredentials(firstLine(reason))}`, ); } - dbPort = await smallestUnusedDatabasePort(dbPort + 1); + conflicted.add(conflictPort); + if (conflictPort === dbPort) { + dbPort = await smallestUnusedDatabasePort(dbPort + 1, conflicted); + } } } } From 16448037ab757998ed8e4c7205b6ff8f7f7cb309 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 19:46:04 +0200 Subject: [PATCH 33/43] test(integration): keep the fixture secret's value out of assertion errors CodeQL (js/clear-text-logging): the secrets.json read could ride an assertEqual failure message into the harness's failure dump; compare as a boolean so the value never enters the error text. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- test/integration/test/local-dev.integration.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/integration/test/local-dev.integration.ts b/test/integration/test/local-dev.integration.ts index 79b91a980..57addca0d 100644 --- a/test/integration/test/local-dev.integration.ts +++ b/test/integration/test/local-dev.integration.ts @@ -560,10 +560,12 @@ async function main(): Promise { // verbatim (S5 addition — the placeholder/hard-error paths have their // own dedicated script). const secrets = readJson(path.join(devDir, 'secrets.json')) as Record; + // Compared as a boolean so the secret's value never rides an assertion + // error into the harness's failure log (CodeQL js/clear-text-logging). assertEqual( - secrets['LOCALDEV_FIXTURE_API_KEY'], - 'test-api-key', - 'secrets.json carries the shell-sourced secret', + secrets['LOCALDEV_FIXTURE_API_KEY'] === 'test-api-key', + true, + 'secrets.json carries the shell-sourced secret verbatim', ); assertEqual( secrets['LOCALDEV_FIXTURE_GREETING'], From 8ed02ea83816f9a06df184ca080d3d3843e7ca0a Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 20:04:17 +0200 Subject: [PATCH 34/43] fix(dev-emulators): recover when a failed postgres start leaves its name registered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seen on CI directly after the port-refusal retry landed: attempt 2 was refused with 'A Prisma Dev server with the name ... is already running' — a start that fails mid-boot can leave its state entry (and sometimes the server itself) behind, and the name is namespaced to this app+database, so the ghost is ours by construction. Recovery: adopt the live server when the refusal carries one (ServerAlreadyRunningError's .server getter, duck-typed), clear a dead state entry via the internal state module's deleteServer and retry. Concurrent PUTs for the same database now also coalesce onto one in-flight start. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../dev-emulators/src/postgres-main.ts | 92 ++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts index fbca4c8d8..ca52bd7fa 100644 --- a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts +++ b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts @@ -157,6 +157,51 @@ function portConflictOf(err: unknown, ports: readonly number[]): number | undefi return typeof port === 'number' && ports.includes(port) ? port : undefined; } +function isPrismaDevServer(value: unknown): value is PrismaDevServer { + if (!isStringKeyedRecord(value) || typeof value['close'] !== 'function') return false; + const database = value['database']; + return isStringKeyedRecord(database) && typeof database['connectionString'] === 'string'; +} + +/** + * The live server behind a "name is already running" refusal, when there is + * one. A start that fails mid-boot (e.g. on a port another server's + * registry claims) can leave its state entry behind, so OUR OWN next + * attempt for the same name is refused. `@prisma/dev`'s + * `ServerAlreadyRunningError` carries the running server as a `.server` + * getter (duck-typed — `instanceof` fails across the dynamic-import + * boundary); its parent class (state entry exists but nothing actually + * runs) has no such getter, and a dead ghost resolves to null — both mean + * "nothing to adopt". + */ +async function adoptableServerOf(err: unknown): Promise { + if (!isStringKeyedRecord(err) || !('server' in err)) return undefined; + if (err['name'] !== 'ServerAlreadyRunningError') return undefined; + try { + const server: unknown = await err['server']; + return isPrismaDevServer(server) ? server : undefined; + } catch { + return undefined; + } +} + +function isNameAlreadyTaken(err: unknown): boolean { + return ( + isStringKeyedRecord(err) && + (err['name'] === 'ServerAlreadyRunningError' || err['name'] === 'ServerStateAlreadyExistsError') + ); +} + +/** The port of a postgres connection URL, when it parses as one. */ +function databasePortOf(connectionString: string): number | undefined { + try { + const port = Number(new URL(connectionString).port); + return Number.isInteger(port) && port > 0 ? port : undefined; + } catch { + return undefined; + } +} + /** * A dynamic `import()` failure's own message routinely names a SECOND path * (e.g. bun's "Cannot find module '' from ''") — the @@ -367,13 +412,31 @@ function main(): void { return { httpPort, shadowDatabasePort, streamsPort }; } - async function ensureDatabase( + const inflightEnsures = new Map>(); + + /** Concurrent PUTs for the same database coalesce onto one start — a second `startPrismaDevServer` for a name whose first start is still booting fails as "already running". */ + function ensureDatabase( app: string, id: string, prismaDevModulePath: string, ): Promise<{ url: string }> { - const appRec = getOrCreateApp(app); const instanceName = instanceNameFor(app, id); + const inflight = inflightEnsures.get(instanceName); + if (inflight) return inflight; + const run = ensureDatabaseSerialized(app, id, prismaDevModulePath, instanceName).finally(() => { + inflightEnsures.delete(instanceName); + }); + inflightEnsures.set(instanceName, run); + return run; + } + + async function ensureDatabaseSerialized( + app: string, + id: string, + prismaDevModulePath: string, + instanceName: string, + ): Promise<{ url: string }> { + const appRec = getOrCreateApp(app); const existingRecord = appRec.databases[id]; // Already live in THIS process — idempotent, nothing to do. @@ -414,6 +477,31 @@ function main(): void { schedulePersist(); return { url }; } catch (err) { + if (isNameAlreadyTaken(err)) { + // Our own earlier attempt (this loop's, or a crashed run's) left the + // name behind — the name is namespaced to this app+database, so it + // is ours by construction. A live server is simply the goal already + // reached: adopt it. A dead state entry is cleared and the start + // retried. + const ghost = await adoptableServerOf(err); + if (ghost !== undefined) { + const url = ghost.database.connectionString; + runtimes.set(instanceName, ghost); + appRec.databases[id] = { + id, + instanceName, + databasePort: databasePortOf(url) ?? dbPort, + url, + }; + schedulePersist(); + return { url }; + } + if (attempt < maxAttempts) { + const internalState = await importPrismaDevInternalState(prismaDevModulePath); + await internalState.deleteServer(instanceName); + continue; + } + } const conflictPort = portConflictOf(err, [ dbPort, aux.httpPort, From cca301edc25c8a4755b42692003c76decf03e69a Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 20:18:49 +0200 Subject: [PATCH 35/43] fix(cli): expose watch readiness instead of guessing at chokidar's attach time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seen on CI: the delete-recreate watch test saw zero callbacks — the change landed before chokidar's OS-level watches attached, so it was missed entirely, and the test's fixed 100ms grace (plus a 100ms post-debounce margin) is exactly the kind of guess a loaded runner breaks. startWatch now returns { ready, stop }: ready resolves on chokidar's 'ready' (and on stop, so an awaiting caller can't hang), the dev loop awaits it before handing over — closing the same real startup race for users — and the tests await readiness and poll to a deadline instead of sleeping fixed margins. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../cli/src/dev/__tests__/watch.test.ts | 33 +++++---- .../3-tooling/cli/src/dev/run-dev.ts | 70 ++++++++++--------- .../3-tooling/cli/src/dev/watch.ts | 32 ++++++--- 3 files changed, 81 insertions(+), 54 deletions(-) diff --git a/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts b/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts index ab2a0ce20..da9756027 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts @@ -12,6 +12,12 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** Polls until `cond` holds or the deadline passes — assertions stay exact, only the waiting adapts to a loaded runner. */ +async function until(cond: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (!cond() && Date.now() < deadline) await sleep(25); +} + describe('watchTargetsFrom()', () => { test('a bundle with no watch field is reported unwatchable, not a target', () => { const { targets, unwatchable } = watchTargetsFrom({ @@ -31,7 +37,7 @@ describe('startWatch()', () => { fs.writeFileSync(fileB, 'b'); let calls = 0; - const stop = startWatch( + const watch = startWatch( [ { address: 'a', paths: [fileA] }, { address: 'b', paths: [fileB] }, @@ -40,6 +46,7 @@ describe('startWatch()', () => { calls += 1; }, ); + await watch.ready; try { // A burst across both files, all inside the 300ms debounce window. @@ -54,10 +61,10 @@ describe('startWatch()', () => { expect(calls).toBe(0); // Past the 300ms debounce from the last write. - await sleep(300); + await until(() => calls === 1, 2000); expect(calls).toBe(1); } finally { - stop(); + watch.stop(); fs.rmSync(dir, { recursive: true, force: true }); } }, 10_000); @@ -68,10 +75,10 @@ describe('startWatch()', () => { fs.writeFileSync(file, 'a'); let calls = 0; - const stop = startWatch([{ address: 'a', paths: [file] }], () => { + const watch = startWatch([{ address: 'a', paths: [file] }], () => { calls += 1; }); - stop(); + watch.stop(); fs.writeFileSync(file, 'a2'); await sleep(500); @@ -95,28 +102,28 @@ describe('startWatch()', () => { fs.writeFileSync(file, 'v1'); let calls = 0; - const stop = startWatch([{ address: 'catalog', paths: [file] }], () => { + const watch = startWatch([{ address: 'catalog', paths: [file] }], () => { calls += 1; }); - // chokidar's own watch setup is asynchronous (it returns synchronously, - // then finishes attaching OS-level watches shortly after) — a grace - // window before the first change, not a change to what's asserted. - await sleep(100); + // chokidar attaches its OS-level watches asynchronously — a change made + // before `ready` can be missed entirely, so wait for real attachment + // rather than guessing a grace period. + await watch.ready; try { fs.rmSync(file); fs.writeFileSync(file, 'v2'); - await sleep(400); + await until(() => calls === 1, 3000); expect(calls).toBe(1); // A SECOND delete+recreate — the exact case that broke a // file-bound (not directory-bound) watch. fs.rmSync(file); fs.writeFileSync(file, 'v3'); - await sleep(400); + await until(() => calls === 2, 3000); expect(calls).toBe(2); } finally { - stop(); + watch.stop(); fs.rmSync(dir, { recursive: true, force: true }); } }, 10_000); diff --git a/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts index 1cf657dd2..81fa60295 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts @@ -228,46 +228,50 @@ export async function runDev(args: DevArgs, deps: DevRunDeps = {}): Promise { + void (async () => { + const rePipeline = await runPipeline(args.entry, args.name, cwd, pipelineDeps).catch( + (error: unknown) => { + console.error( + `[dev] rebuild failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return undefined; + }, + ); + if (rePipeline === undefined) return; + writeDevStackFile({ + entryPath: rePipeline.entryModule.path, + cwd, + configPath: rePipeline.configPath, + name: rePipeline.name, + assembled: rePipeline.assembled, + }); + const status = (deps.alchemy ?? runAlchemy)({ + command: 'deploy', + stackFileRelativePath: DEV_STACK_RELATIVE_PATH, + cwd, + stage: 'dev', + containerEnv: containerEnv(containers), + }); + if (status !== 0) { + console.error('[dev] converge failed — the running app is untouched; still watching.'); + return; + } + printFrontDoor(await mergedEndpoints(attachments)); + })(); + }); + // A rebuild finishing before the OS-level watches attach would otherwise + // be missed entirely — wait until watching is real before handing over. + await watch.ready; + await new Promise((resolve) => { let stopping = false; - const stopWatch = startWatch(targets, () => { - void (async () => { - const rePipeline = await runPipeline(args.entry, args.name, cwd, pipelineDeps).catch( - (error: unknown) => { - console.error( - `[dev] rebuild failed: ${error instanceof Error ? error.message : String(error)}`, - ); - return undefined; - }, - ); - if (rePipeline === undefined) return; - writeDevStackFile({ - entryPath: rePipeline.entryModule.path, - cwd, - configPath: rePipeline.configPath, - name: rePipeline.name, - assembled: rePipeline.assembled, - }); - const status = (deps.alchemy ?? runAlchemy)({ - command: 'deploy', - stackFileRelativePath: DEV_STACK_RELATIVE_PATH, - cwd, - stage: 'dev', - containerEnv: containerEnv(containers), - }); - if (status !== 0) { - console.error('[dev] converge failed — the running app is untouched; still watching.'); - return; - } - printFrontDoor(await mergedEndpoints(attachments)); - })(); - }); const finish = (): void => { if (stopping) return; stopping = true; console.log("[dev] stopping — the app's services are stopping; emulators and data stay up."); - stopWatch(); + watch.stop(); void (async () => { logsController.abort(); for (const attachment of attachments) { diff --git a/packages/0-framework/3-tooling/cli/src/dev/watch.ts b/packages/0-framework/3-tooling/cli/src/dev/watch.ts index 1fa37253d..6dd8434f3 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/watch.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/watch.ts @@ -43,14 +43,20 @@ export function watchTargetsFrom(bundles: Readonly>): { return { targets, unwatchable }; } +export interface WatchHandle { + /** Resolves once chokidar's OS-level watches are attached — a change made before this can be missed entirely. Also resolves on `stop()` so an awaiting caller can never hang. */ + readonly ready: Promise; + stop(): void; +} + /** * Watches every target's paths via chokidar, debounced 300 ms and coalesced - * across every service, invoking `onChange` once per burst. Returns a stop - * function. A path that doesn't exist on disk is simply never reported by - * chokidar (nothing to watch) — no explicit existence check needed, unlike - * the old `fs.watch`-based implementation. + * across every service, invoking `onChange` once per burst. A path that + * doesn't exist on disk is simply never reported by chokidar (nothing to + * watch) — no explicit existence check needed, unlike the old + * `fs.watch`-based implementation. */ -export function startWatch(targets: readonly WatchTarget[], onChange: () => void): () => void { +export function startWatch(targets: readonly WatchTarget[], onChange: () => void): WatchHandle { let timer: ReturnType | undefined; const trigger = (): void => { @@ -65,8 +71,18 @@ export function startWatch(targets: readonly WatchTarget[], onChange: () => void const watcher: FSWatcher = chokidar.watch(allPaths, { ignoreInitial: true }); watcher.on('all', () => trigger()); - return () => { - if (timer !== undefined) clearTimeout(timer); - void watcher.close(); + let markReady: () => void = () => {}; + const ready = new Promise((resolve) => { + markReady = resolve; + }); + watcher.on('ready', () => markReady()); + + return { + ready, + stop: () => { + if (timer !== undefined) clearTimeout(timer); + markReady(); + void watcher.close(); + }, }; } From f9ab62d292dc51cee6a50b1d2fe43630ecdb83ef Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 20:34:52 +0200 Subject: [PATCH 36/43] fix(cli): watch file targets through their parent directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delete-recreate test kept failing on Linux CI with zero events even after readiness was awaited: an inotify watch bound directly to a file dies with the file's inode, and chokidar v4 absorbs atomic renames but NOT unlink+recreate of a directly watched file — which is the shape of every 'rm -rf dist && build --outfile' rebuild. File targets are now watched via a depth-0 chokidar watch on their parent, filtered to the exact path (the approach the pre-chokidar implementation had already proven on Linux); directory targets stay recursive watches of themselves. A nonexistent path counts as a file target, so it starts reporting the moment something creates it. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../3-tooling/cli/src/dev/watch.ts | 83 ++++++++++++++----- 1 file changed, 62 insertions(+), 21 deletions(-) diff --git a/packages/0-framework/3-tooling/cli/src/dev/watch.ts b/packages/0-framework/3-tooling/cli/src/dev/watch.ts index 6dd8434f3..2ffd8e09c 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/watch.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/watch.ts @@ -1,20 +1,23 @@ /** * Local-dev spec § 6 `watch.ts`: watches each assembled bundle's declared - * `watch` paths (a directory watched recursively, a file watched plainly), - * debounced 300 ms and coalesced across every service — one edit across - * several files/services still fires exactly one rebuild. + * `watch` paths (a directory watched recursively, a file watched through + * its parent — see `startWatch`), debounced 300 ms and coalesced across + * every service — one edit across several files/services still fires + * exactly one rebuild. * * The watch ENGINE is `chokidar` v4 (operator decision, design tip 74272d8: - * don't-reinvent-the-wheel beats the no-new-deps contract here — chokidar - * absorbs the atomic-rename/inode-swap class this module used to hand-fix - * with a parent-directory workaround, plus the cross-platform - * recursive-watch differences; v4 is pure JS, no native code, no glob - * surface — irrelevant here anyway, since every target is a literal file or - * directory path, never a pattern). The debounce stays OURS: chokidar's own + * don't-reinvent-the-wheel beats the no-new-deps contract here — it absorbs + * the atomic-rename class and the cross-platform recursive-watch + * differences; v4 is pure JS, no native code, no glob surface — irrelevant + * here anyway, since every target is a literal file or directory path, + * never a pattern). The parent-directory indirection for FILE targets stays + * OURS (`startWatch` explains why), as does the debounce: chokidar's own * `awaitWriteFinish` is a per-file "has this file's size stopped changing" * poll, a different semantic from "coalesce a burst across many files into * one callback," which is what the dev loop actually needs. */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; import type { Bundle } from '@internal/core/deploy'; import chokidar, { type FSWatcher } from 'chokidar'; @@ -51,10 +54,17 @@ export interface WatchHandle { /** * Watches every target's paths via chokidar, debounced 300 ms and coalesced - * across every service, invoking `onChange` once per burst. A path that - * doesn't exist on disk is simply never reported by chokidar (nothing to - * watch) — no explicit existence check needed, unlike the old - * `fs.watch`-based implementation. + * across every service, invoking `onChange` once per burst. + * + * File targets are watched THROUGH their parent directory, filtered to the + * exact path: a watch bound directly to a file dies with the file's inode + * on Linux, so `rm -rf dist && bun build --outfile dist/x.mjs` — every + * rebuild's shape — would go silently unobserved after the first delete + * (chokidar absorbs atomic renames, not unlink+recreate of a directly + * watched file; proven by the delete-recreate test failing on Linux CI + * only). Directory targets are watched recursively as themselves; a + * nonexistent path is treated as a file target, so it starts reporting the + * moment something creates it. */ export function startWatch(targets: readonly WatchTarget[], onChange: () => void): WatchHandle { let timer: ReturnType | undefined; @@ -67,22 +77,53 @@ export function startWatch(targets: readonly WatchTarget[], onChange: () => void }, DEBOUNCE_MS); }; - const allPaths = targets.flatMap((target) => target.paths); - const watcher: FSWatcher = chokidar.watch(allPaths, { ignoreInitial: true }); - watcher.on('all', () => trigger()); + const fileTargets = new Set(); + const directoryRoots = new Set(); + const parentRoots = new Set(); + for (const target of targets) { + for (const p of target.paths) { + const abs = path.resolve(p); + let isDirectory = false; + try { + isDirectory = fs.statSync(abs).isDirectory(); + } catch { + // nonexistent → file target via its parent + } + if (isDirectory) { + directoryRoots.add(abs); + } else { + fileTargets.add(abs); + parentRoots.add(path.dirname(abs)); + } + } + } + + const watchers: FSWatcher[] = []; + if (directoryRoots.size > 0) { + const directoryWatcher = chokidar.watch([...directoryRoots], { ignoreInitial: true }); + directoryWatcher.on('all', () => trigger()); + watchers.push(directoryWatcher); + } + if (parentRoots.size > 0) { + const parentWatcher = chokidar.watch([...parentRoots], { ignoreInitial: true, depth: 0 }); + parentWatcher.on('all', (_event, eventPath) => { + if (fileTargets.has(path.resolve(eventPath))) trigger(); + }); + watchers.push(parentWatcher); + } let markReady: () => void = () => {}; - const ready = new Promise((resolve) => { - markReady = resolve; - }); - watcher.on('ready', () => markReady()); + const allReady = Promise.all( + watchers.map((watcher) => new Promise((resolve) => watcher.on('ready', () => resolve()))), + ).then(() => {}); + const ready = Promise.race([allReady, new Promise((resolve) => (markReady = resolve))]); return { ready, stop: () => { if (timer !== undefined) clearTimeout(timer); markReady(); - void watcher.close(); + for (const watcher of watchers) void watcher.close(); }, }; } From 54afa3149735a5a3c2889eccc8dcb948c46c992a Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 20:49:35 +0200 Subject: [PATCH 37/43] fix(dev-emulators): aux-port refusals retry even for existing databases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seen on CI: an ensure for a database with a persisted port was refused because its freshly allocated aux HTTP port was claimed by another Prisma Dev server's registry, and the retry cap for existing records was 1 — so a conflict on a port we would happily re-pick surfaced as a 500. The aux listener ports are never persisted; a refusal of one now always retries with fresh candidates. Only the DATABASE port stays frozen once a record exists (endpoints in deploy state reference it) — a refusal there still fails fast for existing records. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../0-lowering/dev-emulators/src/postgres-main.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts index ca52bd7fa..3940d6484 100644 --- a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts +++ b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts @@ -447,7 +447,6 @@ function main(): void { const prismaDev = await importPrismaDev(prismaDevModulePath); const isFreshAllocation = existingRecord === undefined; - const maxAttempts = isFreshAllocation ? MAX_FRESH_PORT_CANDIDATES : 1; let dbPort = existingRecord?.databasePort ?? (await smallestUnusedDatabasePort(MIN_DATABASE_PORT)); @@ -496,7 +495,7 @@ function main(): void { schedulePersist(); return { url }; } - if (attempt < maxAttempts) { + if (attempt < MAX_FRESH_PORT_CANDIDATES) { const internalState = await importPrismaDevInternalState(prismaDevModulePath); await internalState.deleteServer(instanceName); continue; @@ -508,8 +507,14 @@ function main(): void { aux.shadowDatabasePort, aux.streamsPort, ]); + // The aux listener ports are never persisted, so a refusal of one is + // always retryable with fresh candidates. The DATABASE port is frozen + // once a record exists (endpoints in deploy state reference it) — + // only a fresh allocation may move it. const canRetryNextPort = - isFreshAllocation && conflictPort !== undefined && attempt < maxAttempts; + conflictPort !== undefined && + attempt < MAX_FRESH_PORT_CANDIDATES && + (conflictPort !== dbPort || isFreshAllocation); if (!canRetryNextPort) { const reason = err instanceof Error ? err.message : String(err); throw new Error( From b06f97232d3f463c8332c0a5d2341b529fc62c72 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 20:56:44 +0200 Subject: [PATCH 38/43] fix(dev-emulators): the postgres daemon survives background-work rejections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seen on CI: the daemon died mid-request (connection refused on an ensure, pid gone), taking every app's databases with it. The daemon hosts @prisma/dev's runtime in-process, and a failed or abandoned start attempt can leave background async work behind whose eventual rejection kills the process — bun and node both exit on an unhandled rejection. A machine-shared daemon must not die because one attempt's debris rejected: log it (credential-masked, into the registry log) and keep serving; request paths still report their own errors as 500s. The drift test now prints the daemon's log tail on failure, so the next crash is diagnosable instead of a bare connection error. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../dev-emulators/src/postgres-main.ts | 20 ++ .../postgres-instance-name-drift.test.ts | 257 ++++++++++-------- 2 files changed, 165 insertions(+), 112 deletions(-) diff --git a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts index 3940d6484..8cafada73 100644 --- a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts +++ b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts @@ -673,6 +673,26 @@ function main(): void { process.on('SIGTERM', () => void shutdown()); process.on('SIGINT', () => void shutdown()); + // This daemon hosts `@prisma/dev`'s runtime in-process, and a failed or + // abandoned start attempt can leave background async work behind whose + // eventual rejection would otherwise kill the process (bun and node both + // exit on an unhandled rejection). A machine-shared daemon serving every + // app's databases must not die because one attempt's debris rejected — + // log it (the daemon's stdio is teed to its registry log) and keep + // serving; every REQUEST path still reports its own errors as 500s. + process.on('unhandledRejection', (reason) => { + console.error( + 'postgres-main: unhandled rejection from background work:', + reason instanceof Error ? maskCredentials(reason.stack ?? reason.message) : reason, + ); + }); + process.on('uncaughtException', (err) => { + console.error( + 'postgres-main: uncaught exception from background work:', + err instanceof Error ? maskCredentials(err.stack ?? err.message) : err, + ); + }); + readJsonFile(appsJsonPath, isAppsState) .then((loaded) => { if (loaded) state = loaded; diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts index d3d8c4b66..47522aa8c 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts @@ -1,7 +1,14 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import type { ContainerInstance, LocalTargetProvidersInput } from '@internal/core/config'; -import { ensureDaemon, instanceNameFor, postgresClient } from '@internal/dev-emulators'; +import { + defaultRegistryRoot, + ensureDaemon, + instanceNameFor, + postgresClient, +} from '@internal/dev-emulators'; import { Connection, Database } from '@internal/lowering/postgres'; import * as Effect from 'effect/Effect'; import * as Redacted from 'effect/Redacted'; @@ -27,6 +34,22 @@ import { LocalConnectionProvider, LocalDatabaseProvider } from '../postgres.ts'; const APP = 'pgdrifttestapp-'; const DATABASE_ID = 'orders-'; +/** On failure, print the machine-global postgres daemon's log tail — a daemon that died mid-request is otherwise undiagnosable from the test's own connection error. */ +async function withDaemonLogOnFailure(run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + try { + const log = fs.readFileSync(path.join(defaultRegistryRoot(), 'postgres.log'), 'utf8'); + console.error('--- postgres daemon log (tail) ---'); + console.error(log.split('\n').slice(-80).join('\n')); + } catch { + console.error('--- postgres daemon log unavailable ---'); + } + throw error; + } +} + function fakeContainer(appName: string): ContainerInstance { return { input: { appName, stage: undefined }, serialize: () => 'x' }; } @@ -49,117 +72,127 @@ afterAll(async () => { }); describe('instance-name drift (delta review finding A, #160)', () => { - test('a database id and app name with leading/trailing non-alphanumerics: Database ensures, Connection resolves the SAME server', async () => { - const input: LocalTargetProvidersInput = { - container: fakeContainer(APP), - devDir: '/dev/null/unused', - }; - - // 1. Ensure through the real daemon (LocalDatabaseProvider's own reconcile). - const databaseService = await Effect.runPromise( - Database.Provider.pipe(Effect.provide(LocalDatabaseProvider(input))), - ); - const databaseAttributes = await Effect.runPromise( - databaseService.reconcile({ - id: 'db', - instanceId: 'db', - news: { projectId: 'p', name: DATABASE_ID, region: 'us-east-1' }, - olds: undefined, - output: undefined, - session: undefined as never, - bindings: [], - }), - ); - - // The provider-derived id is exactly the daemon's own derivation — no - // second implementation to drift from it. - expect(databaseAttributes.id).toBe(instanceNameFor(APP, DATABASE_ID)); - expect(databaseAttributes.id).toBe('pcdev-pgdrifttestapp-orders'); - // Proves the trim/collapse actually happened — the pre-fix drift left a - // doubled dash at the "pgdrifttestapp-" + "-" + "orders-" boundary. - expect(databaseAttributes.id.includes('--')).toBe(false); - - // 2. Connection-resolve through the listing (LocalConnectionProvider's - // own reconcile) — before the fix, this threw noRecordedInstanceError - // for this exact pathological pair. - const connectionService = await Effect.runPromise( - Connection.Provider.pipe(Effect.provide(LocalConnectionProvider(input))), - ); - const connectionAttributes = await Effect.runPromise( - connectionService.reconcile({ - id: 'conn', - instanceId: 'conn', - news: { databaseId: databaseAttributes.id, name: 'conn' }, - olds: undefined, - output: undefined, - session: undefined as never, - bindings: [], + test( + 'a database id and app name with leading/trailing non-alphanumerics: Database ensures, Connection resolves the SAME server', + () => + withDaemonLogOnFailure(async () => { + const input: LocalTargetProvidersInput = { + container: fakeContainer(APP), + devDir: '/dev/null/unused', + }; + + // 1. Ensure through the real daemon (LocalDatabaseProvider's own reconcile). + const databaseService = await Effect.runPromise( + Database.Provider.pipe(Effect.provide(LocalDatabaseProvider(input))), + ); + const databaseAttributes = await Effect.runPromise( + databaseService.reconcile({ + id: 'db', + instanceId: 'db', + news: { projectId: 'p', name: DATABASE_ID, region: 'us-east-1' }, + olds: undefined, + output: undefined, + session: undefined as never, + bindings: [], + }), + ); + + // The provider-derived id is exactly the daemon's own derivation — no + // second implementation to drift from it. + expect(databaseAttributes.id).toBe(instanceNameFor(APP, DATABASE_ID)); + expect(databaseAttributes.id).toBe('pcdev-pgdrifttestapp-orders'); + // Proves the trim/collapse actually happened — the pre-fix drift left a + // doubled dash at the "pgdrifttestapp-" + "-" + "orders-" boundary. + expect(databaseAttributes.id.includes('--')).toBe(false); + + // 2. Connection-resolve through the listing (LocalConnectionProvider's + // own reconcile) — before the fix, this threw noRecordedInstanceError + // for this exact pathological pair. + const connectionService = await Effect.runPromise( + Connection.Provider.pipe(Effect.provide(LocalConnectionProvider(input))), + ); + const connectionAttributes = await Effect.runPromise( + connectionService.reconcile({ + id: 'conn', + instanceId: 'conn', + news: { databaseId: databaseAttributes.id, name: 'conn' }, + olds: undefined, + output: undefined, + session: undefined as never, + bindings: [], + }), + ); + + expect(connectionAttributes.id).toBe(databaseAttributes.id); + expect(Redacted.value(connectionAttributes.connectionString)).toMatch(/^postgres:\/\//); + + // 3. The daemon's own listing agrees on the same name too — the third + // independent read of the same value. + const listed = await postgresClient().listDatabases(APP); + const entry = listed.find((d) => d.instanceName === databaseAttributes.id); + expect(entry).toBeDefined(); }), - ); - - expect(connectionAttributes.id).toBe(databaseAttributes.id); - expect(Redacted.value(connectionAttributes.connectionString)).toMatch(/^postgres:\/\//); - - // 3. The daemon's own listing agrees on the same name too — the third - // independent read of the same value. - const listed = await postgresClient().listDatabases(APP); - const entry = listed.find((d) => d.instanceName === databaseAttributes.id); - expect(entry).toBeDefined(); - }, 30_000); - - test('a nested module\'s dotted database name ("catalog.database"): Database ensures, Connection resolves the SAME server', async () => { - // A nested module's Database resource name is hierarchical and - // dot-separated — but the daemon's `` path segment rule - // (/^[a-z0-9][a-z0-9-]*$/) forbids dots, so the provider must slug the - // daemon-facing id (compute.ts's `slugServiceId` seam, applied to - // postgres). Pre-fix, the client's own `encodeSegment` threw - // `invalid path segment "catalog.database"` before the request ever - // left the process — observed live against examples/store. - const DOTTED_ID = 'catalog.database'; - const input: LocalTargetProvidersInput = { - container: fakeContainer(APP), - devDir: '/dev/null/unused', - }; - - const databaseService = await Effect.runPromise( - Database.Provider.pipe(Effect.provide(LocalDatabaseProvider(input))), - ); - const databaseAttributes = await Effect.runPromise( - databaseService.reconcile({ - id: 'db', - instanceId: 'db', - news: { projectId: 'p', name: DOTTED_ID, region: 'us-east-1' }, - olds: undefined, - output: undefined, - session: undefined as never, - bindings: [], + 30_000, + ); + + test( + 'a nested module\'s dotted database name ("catalog.database"): Database ensures, Connection resolves the SAME server', + () => + withDaemonLogOnFailure(async () => { + // A nested module's Database resource name is hierarchical and + // dot-separated — but the daemon's `` path segment rule + // (/^[a-z0-9][a-z0-9-]*$/) forbids dots, so the provider must slug the + // daemon-facing id (compute.ts's `slugServiceId` seam, applied to + // postgres). Pre-fix, the client's own `encodeSegment` threw + // `invalid path segment "catalog.database"` before the request ever + // left the process — observed live against examples/store. + const DOTTED_ID = 'catalog.database'; + const input: LocalTargetProvidersInput = { + container: fakeContainer(APP), + devDir: '/dev/null/unused', + }; + + const databaseService = await Effect.runPromise( + Database.Provider.pipe(Effect.provide(LocalDatabaseProvider(input))), + ); + const databaseAttributes = await Effect.runPromise( + databaseService.reconcile({ + id: 'db', + instanceId: 'db', + news: { projectId: 'p', name: DOTTED_ID, region: 'us-east-1' }, + olds: undefined, + output: undefined, + session: undefined as never, + bindings: [], + }), + ); + + // slug() is idempotent, so the daemon's instanceNameFor(app, slug(name)) + // equals the provider-recorded instanceNameFor(app, name). + expect(databaseAttributes.id).toBe(instanceNameFor(APP, DOTTED_ID)); + expect(databaseAttributes.id).toBe('pcdev-pgdrifttestapp-catalog-database'); + + const connectionService = await Effect.runPromise( + Connection.Provider.pipe(Effect.provide(LocalConnectionProvider(input))), + ); + const connectionAttributes = await Effect.runPromise( + connectionService.reconcile({ + id: 'conn', + instanceId: 'conn', + news: { databaseId: databaseAttributes.id, name: 'conn' }, + olds: undefined, + output: undefined, + session: undefined as never, + bindings: [], + }), + ); + + expect(connectionAttributes.id).toBe(databaseAttributes.id); + expect(Redacted.value(connectionAttributes.connectionString)).toMatch(/^postgres:\/\//); + + const listed = await postgresClient().listDatabases(APP); + expect(listed.find((d) => d.instanceName === databaseAttributes.id)).toBeDefined(); }), - ); - - // slug() is idempotent, so the daemon's instanceNameFor(app, slug(name)) - // equals the provider-recorded instanceNameFor(app, name). - expect(databaseAttributes.id).toBe(instanceNameFor(APP, DOTTED_ID)); - expect(databaseAttributes.id).toBe('pcdev-pgdrifttestapp-catalog-database'); - - const connectionService = await Effect.runPromise( - Connection.Provider.pipe(Effect.provide(LocalConnectionProvider(input))), - ); - const connectionAttributes = await Effect.runPromise( - connectionService.reconcile({ - id: 'conn', - instanceId: 'conn', - news: { databaseId: databaseAttributes.id, name: 'conn' }, - olds: undefined, - output: undefined, - session: undefined as never, - bindings: [], - }), - ); - - expect(connectionAttributes.id).toBe(databaseAttributes.id); - expect(Redacted.value(connectionAttributes.connectionString)).toMatch(/^postgres:\/\//); - - const listed = await postgresClient().listDatabases(APP); - expect(listed.find((d) => d.instanceName === databaseAttributes.id)).toBeDefined(); - }, 30_000); + 30_000, + ); }); From d786ef1c4ce1a0a22640d34d87851576b572cd2c Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 21:08:56 +0200 Subject: [PATCH 39/43] fix(cli): retry every attach admin call, not just endpoints() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seen on CI: right after a 102-resource converge the session died on a bare 'fetch failed' from startServices() — the transient-loopback retry existed only for endpoints(). One generic retry now wraps every attach admin call the dev session makes, with a slightly deeper budget (5 x 500ms) for loaded runners. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../3-tooling/cli/src/dev/run-dev.ts | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts index 81fa60295..a5f2c8352 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts @@ -56,21 +56,19 @@ function printFrontDoor( for (const line of renderFrontDoor(endpoints)) console.log(line); } -const ENDPOINTS_RETRY_ATTEMPTS = 3; -const ENDPOINTS_RETRY_DELAY_MS = 500; +const EMULATOR_RETRY_ATTEMPTS = 5; +const EMULATOR_RETRY_DELAY_MS = 500; -/** `attachment.endpoints()` right after a converge that just PUT dozens of resources through the same emulator connection can hit a transient refused/reset connection — a brief, genuinely transient loopback hiccup, not a real failure. Retried a few times before giving up. */ -async function endpointsWithRetry( - attachment: LocalTargetAttachment, -): Promise { +/** An emulator admin call right after a converge that just PUT dozens of resources through the same daemon can hit a transient refused/reset connection — a brief loopback hiccup under load, not a real failure. Retried before giving up. Applies to every attach admin call the dev session makes (`startServices`, `endpoints`). */ +async function withEmulatorRetry(call: () => Promise): Promise { let lastError: unknown; - for (let attempt = 1; attempt <= ENDPOINTS_RETRY_ATTEMPTS; attempt += 1) { + for (let attempt = 1; attempt <= EMULATOR_RETRY_ATTEMPTS; attempt += 1) { try { - return await attachment.endpoints(); + return await call(); } catch (error) { lastError = error; - if (attempt < ENDPOINTS_RETRY_ATTEMPTS) { - await new Promise((resolve) => setTimeout(resolve, ENDPOINTS_RETRY_DELAY_MS)); + if (attempt < EMULATOR_RETRY_ATTEMPTS) { + await new Promise((resolve) => setTimeout(resolve, EMULATOR_RETRY_DELAY_MS)); } } } @@ -80,7 +78,7 @@ async function endpointsWithRetry( async function mergedEndpoints( attachments: readonly LocalTargetAttachment[], ): Promise { - const lists = await Promise.all(attachments.map((a) => endpointsWithRetry(a))); + const lists = await Promise.all(attachments.map((a) => withEmulatorRetry(() => a.endpoints()))); return lists.flat(); } @@ -211,7 +209,7 @@ export async function runDev(args: DevArgs, deps: DevRunDeps = {}): Promise attachment.startServices()); } catch (error) { throw toCliError(error); } From 93267e5db32625eed71d4773d6acd424cbed1167 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 21:21:47 +0200 Subject: [PATCH 40/43] fix(dev-emulators): retry emulator admin calls on transient socket errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seen on CI: a converge died on 'fetch failed' / 'other side closed' from a local provider's emulator call — the keep-alive reuse race (the runtime reuses a pooled connection the daemon's HTTP server just closed idle), plus briefly overloaded daemons under CI load. Every admin call is idempotent by design, so the loopback client now retries each one a few times before failing; a caller-driven abort is never retried. This covers converge-time provider calls too, which the CLI-level attach retry could not reach. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../0-lowering/dev-emulators/src/client.ts | 59 ++++++++++++++----- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/client.ts b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/client.ts index 5d03e659b..e20594927 100644 --- a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/client.ts +++ b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/client.ts @@ -65,6 +65,33 @@ function encodeSegment(segment: string): string { return encodeURIComponent(segment); } +const ADMIN_FETCH_ATTEMPTS = 3; +const ADMIN_FETCH_RETRY_DELAY_MS = 150; + +/** + * `fetch` for the loopback admin calls, retried on transient socket errors: + * a keep-alive reuse race (the runtime re-uses a pooled connection the + * daemon's HTTP server just closed idle — surfaces as "fetch failed" / + * "other side closed") or a briefly overloaded daemon. Every admin call is + * idempotent by design, so a short blind retry is safe. A caller-driven + * abort is never retried. + */ +async function adminFetch(url: string, init?: RequestInit): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= ADMIN_FETCH_ATTEMPTS; attempt += 1) { + try { + return await fetch(url, init); + } catch (error) { + if (init?.signal?.aborted === true) throw error; + lastError = error; + if (attempt < ADMIN_FETCH_ATTEMPTS) { + await new Promise((resolve) => setTimeout(resolve, ADMIN_FETCH_RETRY_DELAY_MS)); + } + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} + async function expectOk(res: Response): Promise { if (!res.ok) { const body = await res.text(); @@ -174,7 +201,7 @@ export function computeClient(opts: DaemonRootOptions = {}): ComputeClient { baseUrl, async health() { - const res = await expectOk(await fetch(`${baseUrl}/health`)); + const res = await expectOk(await adminFetch(`${baseUrl}/health`)); const body: unknown = await res.json(); if (!isHealthBody(body)) { throw new Error('malformed /health response from the compute emulator'); @@ -184,7 +211,7 @@ export function computeClient(opts: DaemonRootOptions = {}): ComputeClient { async ensureService(app, id) { const url = `${baseUrl}/apps/${encodeSegment(app)}/services/${encodeSegment(id)}`; - const res = await expectOk(await fetch(url, { method: 'PUT' })); + const res = await expectOk(await adminFetch(url, { method: 'PUT' })); const body: unknown = await res.json(); if (!isServiceReservation(body)) { throw new Error('malformed service-reservation response from the compute emulator'); @@ -195,7 +222,7 @@ export function computeClient(opts: DaemonRootOptions = {}): ComputeClient { async putDeployment(app, id, deployment) { const url = `${baseUrl}/apps/${encodeSegment(app)}/services/${encodeSegment(id)}/deployment`; await expectOk( - await fetch(url, { + await adminFetch(url, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(deployment), @@ -205,7 +232,7 @@ export function computeClient(opts: DaemonRootOptions = {}): ComputeClient { async listServices(app) { const url = `${baseUrl}/apps/${encodeSegment(app)}/services`; - const res = await expectOk(await fetch(url)); + const res = await expectOk(await adminFetch(url)); const body: unknown = await res.json(); if (!isServiceInfoArray(body)) { throw new Error('malformed services listing from the compute emulator'); @@ -215,7 +242,7 @@ export function computeClient(opts: DaemonRootOptions = {}): ComputeClient { async *followLogs(app, id, signal) { const url = `${baseUrl}/apps/${encodeSegment(app)}/services/${encodeSegment(id)}/logs?follow=1`; - const res = await expectOk(await fetch(url, signal ? { signal } : undefined)); + const res = await expectOk(await adminFetch(url, signal ? { signal } : undefined)); const body = res.body; if (!body) return; const reader = body.getReader(); @@ -233,17 +260,17 @@ export function computeClient(opts: DaemonRootOptions = {}): ComputeClient { async stopApp(app) { const url = `${baseUrl}/apps/${encodeSegment(app)}/stop`; - await expectOk(await fetch(url, { method: 'POST' })); + await expectOk(await adminFetch(url, { method: 'POST' })); }, async startApp(app) { const url = `${baseUrl}/apps/${encodeSegment(app)}/start`; - await expectOk(await fetch(url, { method: 'POST' })); + await expectOk(await adminFetch(url, { method: 'POST' })); }, async deleteApp(app) { const url = `${baseUrl}/apps/${encodeSegment(app)}`; - await expectOk(await fetch(url, { method: 'DELETE' })); + await expectOk(await adminFetch(url, { method: 'DELETE' })); }, }; } @@ -266,7 +293,7 @@ export function bucketsClient(opts: DaemonRootOptions = {}): BucketsClient { baseUrl, async health() { - const res = await expectOk(await fetch(`${baseUrl}/_pcdev/health`)); + const res = await expectOk(await adminFetch(`${baseUrl}/_pcdev/health`)); const body: unknown = await res.json(); if (!isHealthBody(body)) { throw new Error('malformed /_pcdev/health response from the buckets emulator'); @@ -277,7 +304,7 @@ export function bucketsClient(opts: DaemonRootOptions = {}): BucketsClient { async putBucket(app, name, dir) { const url = `${baseUrl}/_pcdev/apps/${encodeSegment(app)}/buckets/${encodeSegment(name)}`; await expectOk( - await fetch(url, { + await adminFetch(url, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ dir }), @@ -288,7 +315,7 @@ export function bucketsClient(opts: DaemonRootOptions = {}): BucketsClient { async putCredentials(app, accessKeyId, secretAccessKey) { const url = `${baseUrl}/_pcdev/apps/${encodeSegment(app)}/credentials`; await expectOk( - await fetch(url, { + await adminFetch(url, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ accessKeyId, secretAccessKey }), @@ -298,7 +325,7 @@ export function bucketsClient(opts: DaemonRootOptions = {}): BucketsClient { async deleteApp(app) { const url = `${baseUrl}/_pcdev/apps/${encodeSegment(app)}`; - await expectOk(await fetch(url, { method: 'DELETE' })); + await expectOk(await adminFetch(url, { method: 'DELETE' })); }, }; } @@ -357,7 +384,7 @@ export function postgresClient(opts: DaemonRootOptions = {}): PostgresClient { baseUrl, async health() { - const res = await expectOk(await fetch(`${baseUrl}/health`)); + const res = await expectOk(await adminFetch(`${baseUrl}/health`)); const body: unknown = await res.json(); if (!isHealthBody(body)) { throw new Error('malformed /health response from the postgres emulator'); @@ -368,7 +395,7 @@ export function postgresClient(opts: DaemonRootOptions = {}): PostgresClient { async ensureDatabase(app, id, prismaDevModulePath) { const url = `${baseUrl}/apps/${encodeSegment(app)}/databases/${encodeSegment(id)}`; const res = await expectOk( - await fetch(url, { + await adminFetch(url, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ prismaDevModulePath }), @@ -383,7 +410,7 @@ export function postgresClient(opts: DaemonRootOptions = {}): PostgresClient { async listDatabases(app) { const url = `${baseUrl}/apps/${encodeSegment(app)}/databases`; - const res = await expectOk(await fetch(url)); + const res = await expectOk(await adminFetch(url)); const body: unknown = await res.json(); if (!isDatabaseInfoArray(body)) { throw new Error('malformed databases listing from the postgres emulator'); @@ -393,7 +420,7 @@ export function postgresClient(opts: DaemonRootOptions = {}): PostgresClient { async deleteApp(app) { const url = `${baseUrl}/apps/${encodeSegment(app)}`; - await expectOk(await fetch(url, { method: 'DELETE' })); + await expectOk(await adminFetch(url, { method: 'DELETE' })); }, }; } From 2f323fd15dc6b2ee9315217995ffc9c7ccd3fcdd Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 21:26:00 +0200 Subject: [PATCH 41/43] fix(cli): harden the dev session against review-found edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four CodeRabbit findings on #164, three code + one test: - the rebuild handler runs fire-and-forget, but only runPipeline was inside its catch — a throw from writeDevStackFile or the alchemy spawn would be an unhandled rejection killing the process; the whole rebuild is now inside one try/catch and prints '[dev] rebuild failed' instead. - pumpLogs stayed quiet on EVERY stream error; now only the session- ending abort is quiet — a genuinely broken log stream says so. - a startServices() failure partway through the attachment list left the earlier attachments running; they are put back to stopped, so a session that never began leaves the machine as the previous Ctrl-C did. - the criteria-4-5 cleanup now runs the direct app-scoped teardown after its --fresh session, mirroring the store test — --fresh tears down BEFORE its own converge, which recreates the postgres server the cleanup meant to remove. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../3-tooling/cli/src/dev/run-dev.ts | 72 +++++++++++-------- .../local-dev-criteria-4-5.integration.ts | 14 +++- 2 files changed, 56 insertions(+), 30 deletions(-) diff --git a/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts index a5f2c8352..0564c0bb7 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts @@ -91,8 +91,14 @@ function pumpLogs(attachments: readonly LocalTargetAttachment[], signal: AbortSi if (signal.aborted) return; console.log(`[${service}] ${line}`); } - } catch { - // signal aborted mid-iteration — nothing else to do + } catch (error) { + // Quiet only for the abort that ends every session; a genuinely + // broken stream is said out loud instead of logs just going silent. + if (!signal.aborted) { + console.error( + `[dev] log stream failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } } })(); } @@ -207,10 +213,16 @@ export async function runDev(args: DevArgs, deps: DevRunDeps = {}): Promise attachment.startServices()); + started.push(attachment); } catch (error) { + await Promise.all(started.map((a) => a.stopServices().catch(() => undefined))); throw toCliError(error); } } @@ -227,35 +239,37 @@ export async function runDev(args: DevArgs, deps: DevRunDeps = {}): Promise { + // The whole rebuild is inside one try/catch: this runs fire-and-forget, + // so anything escaping it would be an unhandled rejection killing the + // process — the exact opposite of "a converge failure keeps the running + // app and keeps watching". void (async () => { - const rePipeline = await runPipeline(args.entry, args.name, cwd, pipelineDeps).catch( - (error: unknown) => { - console.error( - `[dev] rebuild failed: ${error instanceof Error ? error.message : String(error)}`, - ); - return undefined; - }, - ); - if (rePipeline === undefined) return; - writeDevStackFile({ - entryPath: rePipeline.entryModule.path, - cwd, - configPath: rePipeline.configPath, - name: rePipeline.name, - assembled: rePipeline.assembled, - }); - const status = (deps.alchemy ?? runAlchemy)({ - command: 'deploy', - stackFileRelativePath: DEV_STACK_RELATIVE_PATH, - cwd, - stage: 'dev', - containerEnv: containerEnv(containers), - }); - if (status !== 0) { - console.error('[dev] converge failed — the running app is untouched; still watching.'); - return; + try { + const rePipeline = await runPipeline(args.entry, args.name, cwd, pipelineDeps); + writeDevStackFile({ + entryPath: rePipeline.entryModule.path, + cwd, + configPath: rePipeline.configPath, + name: rePipeline.name, + assembled: rePipeline.assembled, + }); + const status = (deps.alchemy ?? runAlchemy)({ + command: 'deploy', + stackFileRelativePath: DEV_STACK_RELATIVE_PATH, + cwd, + stage: 'dev', + containerEnv: containerEnv(containers), + }); + if (status !== 0) { + console.error('[dev] converge failed — the running app is untouched; still watching.'); + return; + } + printFrontDoor(await mergedEndpoints(attachments)); + } catch (error) { + console.error( + `[dev] rebuild failed: ${error instanceof Error ? error.message : String(error)}`, + ); } - printFrontDoor(await mergedEndpoints(attachments)); })(); }); // A rebuild finishing before the OS-level watches attach would otherwise diff --git a/test/integration/test/local-dev-criteria-4-5.integration.ts b/test/integration/test/local-dev-criteria-4-5.integration.ts index ff6fd741d..4cc491862 100644 --- a/test/integration/test/local-dev-criteria-4-5.integration.ts +++ b/test/integration/test/local-dev-criteria-4-5.integration.ts @@ -24,6 +24,7 @@ import { type ChildProcess, spawn, spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { prismaCloud } from '@prisma/composer-prisma-cloud/control'; function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(`assertion failed: ${message}`); @@ -324,13 +325,24 @@ async function main(): Promise { // local-dev-store.integration.ts does — removes the postgres-main-hosted // server this fixture's `postgres({ name: 'appdb' })` created, the // emulators' app-scoped records, and the local state dir, never the - // machine-global daemons. + // machine-global daemons. `--fresh` teardown runs BEFORE that session's + // own (mandatory) converge, so a direct `dev.teardown` afterwards removes + // what the cleanup session's converge itself just recreated. try { const cleanup = await startDevUntilReady( baseEnv({ LOCALDEV_FIXTURE_API_KEY: 'cleanup-key', LOCALDEV_FIXTURE_GREETING: 'cleanup' }), { fresh: true }, ); await stopDev(cleanup); + const descriptor = prismaCloud(); + const dev = descriptor.localTarget === undefined ? undefined : await descriptor.localTarget(); + if (dev?.teardown !== undefined) { + const container = await dev.container.ensure({ + appName: 'localdevs4fixture', + stage: undefined, + }); + await dev.teardown({ container, stage: undefined }); + } } catch (error) { console.error( `[proving] final cleanup did not complete cleanly: ${error instanceof Error ? error.message : String(error)}`, From 486e3068019898dfdd1d2ec471d760bb2f1461c8 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 21:39:13 +0200 Subject: [PATCH 42/43] fix(dev-emulators): close postgres servers sequentially, never concurrently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seen on CI: the postgres daemon died SILENTLY (no JS error, no rejection-guard output, just gone) during a --fresh teardown, then a converge hit ECONNREFUSED. The servers run pglite's native/WASM runtime inside the daemon process, and the only concurrency we control in that window is deleteApp/shutdown closing several servers at once via Promise.all — a native abort takes the process out with no trace a JS guard can leave. All close and delete-state paths now run one server at a time, and a single close failure no longer aborts the rest of the teardown. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../dev-emulators/src/postgres-main.ts | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts index 8cafada73..42bad4642 100644 --- a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts +++ b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts @@ -537,16 +537,19 @@ function main(): void { if (!appRec) return; const entries = Object.values(appRec.databases); - await Promise.all( - entries.map(async (db) => { - const server = runtimes.get(db.instanceName); - if (server) { - await server.close(); - runtimes.delete(db.instanceName); - releaseAuxPorts(db.instanceName); - } - }), - ); + // Closed one at a time, never concurrently: the servers run pglite's + // native/WASM runtime in THIS process, and the daemon has died silently + // (no JS error, no guard output — a native abort) during exactly this + // teardown window under CI load. Sequential closes remove the only + // concurrency we control there. + for (const db of entries) { + const server = runtimes.get(db.instanceName); + if (server) { + await server.close().catch(() => undefined); + runtimes.delete(db.instanceName); + releaseAuxPorts(db.instanceName); + } + } // Deleting the persisted PGlite data needs SOME resolved `@prisma/dev` // module — the app that owns the databases being deleted is the same @@ -555,7 +558,7 @@ function main(): void { // no body, so there is nothing more specific to prefer. if (entries.length > 0 && prismaDevModulePathHint) { const { deleteServer } = await importPrismaDevInternalState(prismaDevModulePathHint); - await Promise.all(entries.map((db) => deleteServer(db.instanceName))); + for (const db of entries) await deleteServer(db.instanceName); } delete state[app]; @@ -663,9 +666,9 @@ function main(): void { // children (a separate OS process the OS reclaims on its own) or // buckets' plain filesystem store. async function shutdown(): Promise { - await Promise.all( - [...runtimes.values()].map((server) => server.close().catch(() => undefined)), - ); + for (const server of runtimes.values()) { + await server.close().catch(() => undefined); + } await stateFile.flush(); server.close(); process.exit(0); From 13e7de7c85087e01624f6e8112a351677966dfb9 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 21:46:23 +0200 Subject: [PATCH 43/43] fix(cli): handle chokidar error events instead of dying on them An 'error' emitted with no listener throws out of the emitter and would take the dev session down; a watch error (EMFILE, a vanished directory) now prints '[dev] watch error: ...' and the session keeps running. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- packages/0-framework/3-tooling/cli/src/dev/watch.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/0-framework/3-tooling/cli/src/dev/watch.ts b/packages/0-framework/3-tooling/cli/src/dev/watch.ts index 2ffd8e09c..b97e9b0a5 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/watch.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/watch.ts @@ -98,10 +98,18 @@ export function startWatch(targets: readonly WatchTarget[], onChange: () => void } } + // An 'error' emitted with no listener throws and would take the whole dev + // session down — a watch error (EMFILE, a vanished directory) is worth a + // line, not the process. + const reportError = (error: unknown): void => { + console.error(`[dev] watch error: ${error instanceof Error ? error.message : String(error)}`); + }; + const watchers: FSWatcher[] = []; if (directoryRoots.size > 0) { const directoryWatcher = chokidar.watch([...directoryRoots], { ignoreInitial: true }); directoryWatcher.on('all', () => trigger()); + directoryWatcher.on('error', reportError); watchers.push(directoryWatcher); } if (parentRoots.size > 0) { @@ -109,6 +117,7 @@ export function startWatch(targets: readonly WatchTarget[], onChange: () => void parentWatcher.on('all', (_event, eventPath) => { if (fileTargets.has(path.resolve(eventPath))) trigger(); }); + parentWatcher.on('error', reportError); watchers.push(parentWatcher); }