From 73437c8eb1c602b277c84fe0e1b73df9b95715d7 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 15 Oct 2026 16:23:58 +0200 Subject: [PATCH 01/10] docs(drive): slices for prisma dev --detach and piped output as data Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../emulator-commands/design-notes.md | 5 ++ .../slices/s6-dev-detach/spec.md | 46 +++++++++++++++++++ .../slices/s7-piped-output/spec.md | 43 +++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 .drive/projects/emulator-commands/slices/s6-dev-detach/spec.md create mode 100644 .drive/projects/emulator-commands/slices/s7-piped-output/spec.md diff --git a/.drive/projects/emulator-commands/design-notes.md b/.drive/projects/emulator-commands/design-notes.md index 3db8fe0c5..49914e63a 100644 --- a/.drive/projects/emulator-commands/design-notes.md +++ b/.drive/projects/emulator-commands/design-notes.md @@ -61,3 +61,8 @@ Composer remembers each node's port in the directory's mapping and requests it o - **Make the local identity "app name + directory hash" and keep app-aware emulators.** Fixes collisions but keeps Composer concepts inside the emulators, which is the opposite of the model. - **`emulator list` through the core seam.** Requires a project and config for a machine-level command. - **Print a bare URL even when piped.** An engine output change Will deferred. + +## Rulings (Will, 2026-10-15) + +- `prisma dev` stays in the foreground, watching, and Ctrl-C stops the app. `prisma dev --detach` waits until every service answers, prints the URLs, exits 0 and leaves the app running for `prisma dev stop`. Prior art (Docker Compose, .NET Aspire, Sail, and Prisma ORM's own `prisma dev`) defaults to foreground with an opt-in detach. Slice S6. +- A piped stdout gets the command's plain data lines, and JSON requires `--json`. No CLI surveyed (gh, aws, kubectl, gcloud, az, docker, supabase) switches to JSON when piped. This supersedes "Piped output stays JSON; docs use `jq`" above. Slice S7. diff --git a/.drive/projects/emulator-commands/slices/s6-dev-detach/spec.md b/.drive/projects/emulator-commands/slices/s6-dev-detach/spec.md new file mode 100644 index 000000000..92bf0a553 --- /dev/null +++ b/.drive/projects/emulator-commands/slices/s6-dev-detach/spec.md @@ -0,0 +1,46 @@ +# S6 — `prisma dev --detach` + +Repo: prisma/composer. Branch `feat/dev-detach`, stacked on `feat/emulator-commands` (prisma/composer#357). + +## At a glance + +```bash +$ prisma dev module.ts --detach # returns once every service answers +dev: ready +gateway: http://localhost:3001 +quotes: http://localhost:3000 +$ curl "$(prisma dev url gateway)" # same terminal +$ prisma dev stop # the only thing that stops a detached app +``` + +Will ruled on 2026-10-15 that `prisma dev` stays in the foreground, watching, and Ctrl-C stops the app, as today. `--detach` is opt-in. + +This follows prior art: Docker Compose `up -d --wait`, .NET Aspire `run --detach`, and Prisma ORM's own `prisma dev --detach`. Agents and scripts use `--detach` so they don't have to manage a long-running foreground process. + +## Chosen design + +- `--detach` runs the same pipeline as `prisma dev` up to "ready": converge, wait for every service to accept connections (prisma/composer#353), and print the front door. Then it exits 0. +- A detached run starts no watch loop and installs no stop-on-exit. +- The app keeps running on the emulators until `prisma dev stop`, or until the next `prisma dev` in the directory, which reconverges as today. +- A failure before ready stops what this run started, exactly as the foreground path does, and exits non-zero with the same error. +- Ctrl-C before ready stops the app and exits, as today. +- `--detach` combines with `--fresh` and `--name`. +- The `dev` operation in `@prisma/composer/control` gains an equivalent option. The command renders it, per the rule that the CLI's commands are thin renderers over the operations. The session handle's `stop()`, `closed` and events behave sensibly for a detached session: it is already closed and owns nothing. +- Running `prisma dev` in the foreground while the app is already running detached just takes over. It converges, which is a no-op when nothing changed, watches, and Ctrl-C stops the app. No special case. + +## Docs + +- Update the ADR that records `dev url`, `dev stop` and `emulator list` in place, since it is unreleased: `--detach` and why foreground stays the default, with the prior-art comparison as the alternative. +- `local-dev.md`: the session lifetimes section. +- The running-locally guide and the core-concepts skill: a "run it in the background" subsection. Agents and CI use `--detach` then `prisma dev stop`. +- The `dev` command's help: a `--detach` flag brief that meets prisma-cli's help standard. + +## Slice-specific done conditions + +- [ ] Against real emulators in a temporary directory, `prisma dev --detach` exits 0 once every service answers, and the services keep answering after it exits. +- [ ] `prisma dev stop` then stops them. +- [ ] A detached run that fails before ready leaves nothing of the app running. + +## Out of scope + +A run-then-stop mode (`--exec ""`, like Firebase `emulators:exec`). Record it in `deferred.md` as a likely next step for CI. diff --git a/.drive/projects/emulator-commands/slices/s7-piped-output/spec.md b/.drive/projects/emulator-commands/slices/s7-piped-output/spec.md new file mode 100644 index 000000000..a41f8d274 --- /dev/null +++ b/.drive/projects/emulator-commands/slices/s7-piped-output/spec.md @@ -0,0 +1,43 @@ +# S7 — A piped stdout gets the command's data, not JSON + +Repo: prisma/prisma-cli. Branch `feat/piped-output-is-data` off `main`, which now includes prisma/prisma-cli#349. + +## At a glance + +```bash +$ prisma project list | head -3 # today: one NDJSON result frame +proj_cm3x… shop +proj_k91… blog +$ curl "$(prisma dev url gateway)" # today: curl gets a JSON frame +$ prisma project list --json | jq '…' # JSON is always explicit +``` + +Will ruled on 2026-10-15 that when stdout is not a terminal and no format flag is given, the CLI prints the command's plain data lines, and JSON requires `--json` (or `--format json`). + +This replaces the engine's rule from the PR that created it ("a terminal gets human output and a pipe gets JSON"). None of the CLIs surveyed switch to JSON when piped (gh, aws, kubectl, gcloud, az, docker, supabase). Single-value commands print the value bare: `docker port`, `encore db conn-uri`. + +## Chosen design + +**Engine (`packages/cli-engine`).** +- With no `--json`/`--format` flag, the format is `human` whether or not stdout is a TTY. +- In human format, stdout carries only the command's data lines (`presentation.stdout`), as the product docs already require. Human blocks go to stderr, undecorated when stderr isn't a TTY, as today. +- The existing rule that skips the stdout data lines when stdout and stderr are the same terminal stays. +- The pre-parse format sniff (`sniffFormat`) and `resolveAutoFormat` change accordingly. Errors in a pipe render as human errors on stderr with the exit code, unless `--json` is given. +- `--format markdown` is unchanged. +- This is a breaking change to the engine's output contract, so the engine gets a minor version bump with `pnpm bump-cli-engine-version`, and conformance exceptions as the repo's process requires. + +**Every mounted command defines its data lines.** About 34 of 58 presentations define no stdout today, so in a pipe they would print nothing. For each mounted command, decide what a pipe user expects and define `stdout`, following `docs/product/output-conventions.md`: +- A list: one line per item, the subject a follow-up command takes (normally its id), plus the name, tab-separated. +- A show: the resource's identifying value, or `key\tvalue` lines when there is no single value. +- A command whose result is one value: the bare value. Examples: a URL, a connection string, a created id. +- A command with no data (logout, delete): nothing on stdout. Its success is the exit code. + +Write the rules into the product docs first, as ARCHITECTURE.md requires, then implement. Composer and ORM family commands are owned by those repos. List any whose stdout needs work as follow-ups, with the specific command. + +**Docs.** Update every statement that a pipe gets JSON: `output-conventions.md` (TTY and piped behaviour, the `--format markdown` paragraph), `cli-style-guide.md`, `error-conventions.md`, the engine README and AGENTS.md, and the agent skills in `skills/` that tell agents to rely on piped JSON. Agents use `--json`. The e2e harness and tests that rely on piped JSON pass `--json` explicitly. + +## Slice-specific done conditions + +- [ ] Engine tests: no flag and piped stdout gives the data lines and no JSON; `--json` piped gives one NDJSON result frame as today; an error piped without `--json` gives a human error on stderr and the right exit code. +- [ ] A test that fails when a mounted command defines no stdout data lines and isn't on an explicit, reasoned list of commands with no data. +- [ ] The e2e suite still passes (`pnpm --filter @prisma/cli test:e2e`, if credentials are available; otherwise say so). From 2be373e96efe8a3d3b671149cc024215b3a53f61 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 15 Oct 2026 16:26:08 +0200 Subject: [PATCH 02/10] feat(cli): prisma dev --detach returns once the app answers and leaves it running The `dev` operation takes `detach`. A detached run converges, waits until every service accepts connections, reports the front door, and returns a session that is already closed: no watch loop, and stop() stops nothing. The app keeps running on the emulators until `prisma dev stop` or the next `prisma dev` in the directory. `prisma dev --detach` renders it and exits 0. A failure before ready stops what the run started, as the foreground path does. A Ctrl-C before ready stops the app through the devStop operation and settles 130. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/family/__tests__/dev-detach.test.ts | 71 +++++++++++++++++++ .../3-tooling/cli/src/family/commands/dev.ts | 41 ++++++++++- .../operations/__tests__/operations.test.ts | 44 ++++++++++++ .../3-tooling/cli/src/operations/dev.ts | 10 ++- .../cli/src/operations/execute-dev.ts | 4 ++ 5 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 packages/0-framework/3-tooling/cli/src/family/__tests__/dev-detach.test.ts diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/dev-detach.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/dev-detach.test.ts new file mode 100644 index 000000000..01fb35308 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/dev-detach.test.ts @@ -0,0 +1,71 @@ +/** `dev --detach`: returns once the app is ready and leaves it running; Ctrl-C before then stops it. */ +import { describe, expect, test } from 'bun:test'; +import type { EngineEvent } from '@prisma/cli-engine'; +import { createTestCli } from '@prisma/cli-engine/testing'; +import { createOperationsDouble } from '../../testing/operations-double.ts'; +import { createComposerFamily } from '../family.ts'; +import { commandAt } from './command-at.ts'; +import { validComposerSection } from './fixtures/composer-section.ts'; + +const AS_TTY = { isTty: { stdout: true, stderr: true } } as const; + +function cliOver(double: ReturnType) { + const family = createComposerFamily({ operations: double.operations }); + return createTestCli({ + commandFamilies: [family], + commands: { dev: commandAt(family, 'dev') }, + config: { composer: validComposerSection() }, + }); +} + +describe('dev --detach', () => { + test('exits 0 once the app is ready, printing its URLs, and leaves it running', async () => { + const double = createOperationsDouble({ + devEndpoints: [{ address: 'gateway', url: 'http://localhost:3001' }], + }); + + const result = await cliOver(double).run(['dev', 'src/service.ts', '--detach'], AS_TTY); + + expect(result.exitCode).toBe(0); + expect(double.calls.dev[0]?.detach).toBe(true); + expect(result.events).toContainEqual({ + kind: 'endpoint', + name: 'gateway', + url: 'http://localhost:3001', + }); + expect(result.events.some((e) => e.kind === 'step-started')).toBe(false); + expect(double.calls.devStop).toHaveLength(0); + }); + + test('without --detach, dev asks the operation for a watching session', async () => { + const double = createOperationsDouble({}); + const interrupt = new AbortController(); + + await cliOver(double).run(['dev', 'src/service.ts'], { + ...AS_TTY, + abort: interrupt.signal, + onEvent: (event: EngineEvent) => { + if (event.kind === 'status' && event.status === 'ready') interrupt.abort(); + }, + }); + + expect(double.calls.dev[0]?.detach).toBe(false); + }); + + test('Ctrl-C before the detached app is ready stops it and settles 130', async () => { + const double = createOperationsDouble({}); + const interrupt = new AbortController(); + + const result = await cliOver(double).run(['dev', 'src/service.ts', '--detach'], { + ...AS_TTY, + abort: interrupt.signal, + onEvent: (event: EngineEvent) => { + if (event.kind === 'status' && event.status === 'ready') interrupt.abort(); + }, + }); + + expect(result.exitCode).toBe(130); + expect(double.calls.devStop).toHaveLength(1); + expect(double.calls.devStop[0]?.config).toEqual(double.calls.dev[0]?.config); + }); +}); diff --git a/packages/0-framework/3-tooling/cli/src/family/commands/dev.ts b/packages/0-framework/3-tooling/cli/src/family/commands/dev.ts index f092f31bb..83df93edf 100644 --- a/packages/0-framework/3-tooling/cli/src/family/commands/dev.ts +++ b/packages/0-framework/3-tooling/cli/src/family/commands/dev.ts @@ -16,7 +16,7 @@ import type { ChildResult, EngineEvent } from '@prisma/cli-engine'; import { defineSessionCommand, flag, positional } from '@prisma/cli-engine'; import { ok } from '@prisma/cli-engine/protocol'; -import type { DevEvent } from '../../operations/dev.ts'; +import type { DevEvent, DevInput } from '../../operations/dev.ts'; import type { ServiceEndpoint } from '../../operations/shared.ts'; import type { AlchemyInvocation, AlchemyOutcome, RunAlchemy } from '../../run-alchemy.ts'; import { convergeSpawn, settleConvergeFailure } from '../converge.ts'; @@ -176,6 +176,24 @@ function reportDevEvent( }; } +/** A detached session owns nothing to stop, so a Ctrl-C before it was ready stops the app the way `prisma dev stop` does. */ +async function stopDetachedApp( + operations: ComposerOperations, + ctx: { + readonly config: DevInput['config']; + readonly cwd: string; + readonly report: (event: EngineEvent) => void; + }, +): Promise { + const stopped = await operations.devStop({ config: ctx.config, cwd: ctx.cwd }); + if (stopped.ok) return; + ctx.report({ + kind: 'message', + severity: 'warn', + text: `The app could not be stopped: ${stopped.failure.message} Run \`prisma dev stop\`.`, + }); +} + export const createDevCommand = (operations: ComposerOperations) => defineSessionCommand({ help: { @@ -190,8 +208,14 @@ export const createDevCommand = (operations: ComposerOperations) => 'app, with dots for a nested node (catalog.database). The app and where each node ' + 'runs are recorded in this directory: `prisma dev url` works from here while ' + '`prisma dev` runs, and `prisma dev stop` also works after `prisma dev` was killed. ' + - 'Ending `prisma dev` stops its services and database servers; data and ports are kept.', - examples: ['{bin} dev module.ts', '{bin} dev module.ts --fresh'], + 'Ending `prisma dev` stops its services and database servers; data and ports are kept. ' + + 'With --detach it returns once every service answers and leaves the app running ' + + 'without watching for changes, until `prisma dev stop`.', + examples: [ + '{bin} dev module.ts', + '{bin} dev module.ts --fresh', + '{bin} dev module.ts --detach', + ], }, args: { positionals: { @@ -209,6 +233,11 @@ export const createDevCommand = (operations: ComposerOperations) => fresh: flag.boolean({ brief: 'Destroy the dev stack and wipe the dev state directory before starting.', }), + detach: flag.boolean({ + brief: + 'Return once every service answers and leave the app running in the background, ' + + 'without watching for changes. `prisma dev stop` stops it.', + }), }, }, needs: { config: composerSection }, @@ -221,6 +250,7 @@ export const createDevCommand = (operations: ComposerOperations) => config: ctx.config, name: args.flags.name, fresh: args.flags.fresh, + detach: args.flags.detach, cwd: ctx.cwd, onEvent: reportDevEvent(ctx.report, ctx.lastChild), }, @@ -233,6 +263,11 @@ export const createDevCommand = (operations: ComposerOperations) => // engine settles the run from its own record of the signal. if (!result.ok) return settleConvergeFailure(result.failure, ctx); + if (args.flags.detach) { + if (ctx.signal.aborted) await stopDetachedApp(operations, ctx); + return ok(undefined); + } + await stopRequested(ctx.signal); await result.value.stop(); // Ctrl-C settles 130 because the engine records the signal that fired and diff --git a/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts index 2e10e4ec2..643fa2526 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts @@ -1778,6 +1778,50 @@ describe.skipIf(process.platform === 'win32')('dev()', () => { expect(process.listenerCount('SIGTERM')).toBe(sigtermBefore); }, 15_000); + test('a detached session is ready, watches nothing, is already closed, and stopping it stops nothing', async () => { + const app = makeAppDir('hello-dev'); + let stops = 0; + const attachment: LocalTargetAttachment = { + startServices: () => Promise.resolve(), + stopApp: () => { + stops += 1; + return Promise.resolve(); + }, + endpoints: () => Promise.resolve([{ address: 'app', url: 'http://localhost:3000' }]), + logs: async function* () {}, + }; + const events: string[] = []; + let watches = 0; + + await silently(async () => { + const start = await devWithDeps( + { + config: composerConfig(devConfigWith(attachment)), + entry: app.entryPath, + cwd: app.dir, + detach: true, + onEvent: (event) => void events.push(event.kind), + }, + { + runAssembler: fakeAssembler, + alchemy: async () => ({ exitCode: 0, signal: null }), + watch: () => { + watches += 1; + throw new Error('a detached session must not watch'); + }, + }, + ); + if (!start.ok) throw new Error('expected a started session'); + expect(start.value.endpoints).toEqual([{ address: 'app', url: 'http://localhost:3000' }]); + await start.value.closed; + await start.value.stop(); + }); + + expect(watches).toBe(0); + expect(stops).toBe(0); + expect(events).toEqual(['ready']); + }, 15_000); + test('a host onEvent that throws cannot prevent closed from settling', async () => { const app = makeAppDir('hello-dev'); const attachment: LocalTargetAttachment = { diff --git a/packages/0-framework/3-tooling/cli/src/operations/dev.ts b/packages/0-framework/3-tooling/cli/src/operations/dev.ts index db423f539..9a4cb6663 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/dev.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/dev.ts @@ -41,6 +41,12 @@ export interface DevInput { readonly entry: string; readonly name?: string | undefined; readonly fresh?: boolean | undefined; + /** + * Return once every service answers and leave the app running, with no + * watch loop: the session is already closed and stopping it stops nothing. + * `devStop` stops the app later. + */ + readonly detach?: boolean | undefined; readonly cwd?: string | undefined; readonly onEvent?: ((event: DevEvent) => void) | undefined; } @@ -52,9 +58,9 @@ export interface DevSession { /** The initial front door, already merged across attachments. */ readonly endpoints: readonly ServiceEndpoint[]; /** Stop the watch loop and the app's services (emulators and data stay up). - * Idempotent; emits 'stopping'/'stopped'; resolves `closed`. */ + * Idempotent; emits 'stopping'/'stopped'; resolves `closed`. A no-op for a detached session. */ stop(): Promise; - /** Settles when the session has fully stopped (via stop()). */ + /** Settles when the session has fully stopped (via stop()); already settled for a detached session. */ readonly closed: Promise; } diff --git a/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts b/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts index cb8138faa..de97670a9 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts @@ -279,6 +279,10 @@ export async function executeDev( throw toStructured('DEV.ATTACH_FAILED', error); } emit({ kind: 'ready', endpoints }); + if (input.detach === true) { + const closed = Promise.resolve(); + return ok({ endpoints, stop: () => closed, closed }); + } // Watch loop until the session is stopped: rebuild → re-assemble → // re-converge; a converge failure keeps the running app and keeps watching. From 6fd92cbb492b39a4c53e5c1791577d041287090a Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 15 Oct 2026 16:29:27 +0200 Subject: [PATCH 03/10] test(integration): prisma dev --detach on real emulators Runs the installed `prisma` host against the local-dev fixture with a temporary emulators directory. `prisma dev --detach` exits 0 once every service answers and both services still answer after it exits. Stopping the app then closes every service and database port; the host does not mount `prisma dev stop` yet, so the script calls the devStop operation the command renders. With the Compute emulator restarted on a one-millisecond readiness timeout, a detached run fails before ready and leaves no port of the app open. The target's own teardown deletes the app's instances and data at the end. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- test/integration/package.json | 2 +- .../test/local-dev-detach.integration.ts | 220 ++++++++++++++++++ 2 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 test/integration/test/local-dev-detach.integration.ts diff --git a/test/integration/package.json b/test/integration/package.json index eb918dbd5..4bbe4cf15 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "typecheck": "tsc --noEmit", - "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" + "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 && bun run test/local-dev-detach.integration.ts" }, "devDependencies": { "@aws-sdk/client-s3": "^3.1085.0", diff --git a/test/integration/test/local-dev-detach.integration.ts b/test/integration/test/local-dev-detach.integration.ts new file mode 100644 index 000000000..1ebbe0b16 --- /dev/null +++ b/test/integration/test/local-dev-detach.integration.ts @@ -0,0 +1,220 @@ +/** + * `prisma dev --detach` against the local-dev fixture on real emulators in a + * temporary emulators directory, run through the installed `prisma` host: + * + * 1. It exits 0 once every service answers, and the services keep answering + * after it exits. + * 2. Stopping the app then stops them. The installed host does not mount + * `prisma dev stop` yet, so this calls the `devStop` operation of + * `@prisma/composer/control`, which that command renders. + * 3. A detached run that fails before ready leaves nothing of the app + * running. The Compute emulator is restarted with a one-millisecond + * readiness timeout, so the first service fails after the converge has + * started the app's databases. + * + * A standalone script, not a `bun test` file, for the reason given in + * local-dev.integration.ts: output of processes started under the deploy + * engine is lost when the root process is `bun test`. + */ + +import { spawn } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { devStop } from '@prisma/composer/control'; +import { prismaCloud } from '@prisma/composer-prisma-cloud/control'; +import prismaConfig from '../prisma.config.ts'; +import { prismaBinDir } from './spawn-prisma.ts'; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(`assertion failed: ${message}`); +} + +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-detach-logs'); +const CLI_BIN = path.join(prismaBinDir(integrationDir), 'prisma'); +const EXIT_TIMEOUT_MS = 120_000; +const config = { + value: prismaConfig.composer, + file: path.join(integrationDir, 'prisma.config.ts'), +}; +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'local-dev-detach-')); +const emulatorsDir = path.join(tempRoot, 'emulators'); +const APP = 'localdevs4fixture'; +let runCount = 0; + +interface Endpoint { + readonly address: string; + readonly url: string; +} + +function env(extra: Record = {}): NodeJS.ProcessEnv { + const result: NodeJS.ProcessEnv = { + ...process.env, + PRISMA_COMPOSER_EMULATORS_DIR: emulatorsDir, + LOCALDEV_FIXTURE_API_KEY: 'detach-proof', + LOCALDEV_FIXTURE_GREETING: 'hello', + ...extra, + }; + delete result['PRISMA_WORKSPACE_ID']; + delete result['PRISMA_SERVICE_TOKEN']; + delete result['PRISMA_REGION']; + return result; +} + +/** Runs `prisma dev --detach --fresh` to completion; returns its exit code and output. */ +async function runDetached( + runEnv: NodeJS.ProcessEnv, +): Promise<{ code: number | null; log: string }> { + runCount += 1; + fs.mkdirSync(logDir, { recursive: true }); + const logPath = path.join(logDir, `run-${String(runCount)}.log`); + const logFd = fs.openSync(logPath, 'w'); + const child = spawn(CLI_BIN, ['dev', fixtureEntry, '--detach', '--fresh'], { + cwd: integrationDir, + env: runEnv, + stdio: ['ignore', logFd, logFd], + }); + fs.closeSync(logFd); + const code = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`prisma dev --detach did not exit within ${String(EXIT_TIMEOUT_MS)}ms`)); + }, EXIT_TIMEOUT_MS); + child.once('exit', (exitCode) => { + clearTimeout(timer); + resolve(exitCode); + }); + }); + return { code, log: fs.readFileSync(logPath, 'utf8') }; +} + +/** The front door, from the human lines or, for a piped stdout, the NDJSON frames. */ +function frontDoor(log: string): readonly Endpoint[] { + const endpoints: Endpoint[] = []; + for (const line of log.split('\n')) { + if (!line.startsWith('{')) continue; + try { + const frame = JSON.parse(line) as { kind?: string; name?: string; url?: string }; + if (frame.kind === 'endpoint' && frame.name !== undefined && frame.url !== undefined) { + endpoints.push({ address: frame.name, url: frame.url }); + } + } catch { + // Converge output is interleaved with the frames. + } + } + return endpoints; +} + +function acceptsConnection(port: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ port, host: '127.0.0.1' }); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('error', () => resolve(false)); + }); +} + +/** The ports the directory's record holds for the current app's services and databases. */ +function recordedPorts(): readonly number[] { + const record = JSON.parse( + fs.readFileSync(path.join(devDir, 'emulator-instances.json'), 'utf8'), + ) as { current: string; apps: Record }> }; + const nodes = record.apps[record.current]?.nodes ?? {}; + return Object.values(nodes).flatMap((node) => (node.port === undefined ? [] : [node.port])); +} + +async function main(): Promise { + console.log('local dev: prisma dev --detach against the local-dev fixture'); + fs.rmSync(path.join(integrationDir, '.prisma-composer'), { recursive: true, force: true }); + fs.rmSync(path.join(integrationDir, '.alchemy'), { recursive: true, force: true }); + + // ——— 1. Ready, exit 0, still answering afterwards ——— + process.env['PRISMA_COMPOSER_EMULATORS_DIR'] = emulatorsDir; + const detached = await runDetached(env()); + assert(detached.code === 0, `prisma dev --detach exits 0 (got ${String(detached.code)})`); + const endpoints = frontDoor(detached.log); + assert( + endpoints + .map((e) => e.address) + .sort() + .join(',') === 'bkg,web', + `the front door lists bkg and web (got ${JSON.stringify(endpoints)})`, + ); + for (const endpoint of endpoints) { + const res = await fetch(endpoint.url); + assert(res.status === 200, `${endpoint.address} answers after dev exited`); + } + console.log('[detach] PASS: exit 0 once ready, and every service answers after it exits'); + + // ——— 2. Stopping the app stops them ——— + const ports = recordedPorts(); + const stopped = await devStop({ config, cwd: integrationDir }); + assert(stopped.ok, `devStop succeeds (${stopped.ok ? '' : stopped.failure.message})`); + for (const port of ports) { + assert(!(await acceptsConnection(port)), `port ${String(port)} is closed after stopping`); + } + console.log('[detach] PASS: stopping the app closes every service and database port'); + + // ——— 3. A failure before ready leaves nothing running ——— + // The Compute emulator reads the timeout when it starts, so end it first. + stopEmulator('compute'); + const failed = await runDetached(env({ PRISMA_COMPOSER_SERVICE_READY_TIMEOUT_MS: '1' })); + assert(failed.code !== 0, 'a detached run whose services never become ready exits non-zero'); + for (const port of recordedPorts()) { + assert( + !(await acceptsConnection(port)), + `port ${String(port)} is closed after the failed detached run`, + ); + } + console.log('[detach] PASS: a detached run that fails before ready leaves nothing running'); + + console.log('PASS: local dev --detach'); +} + +function stopEmulator(name: string): void { + const file = path.join(emulatorsDir, `${name}.json`); + if (!fs.existsSync(file)) return; + try { + process.kill((JSON.parse(fs.readFileSync(file, 'utf8')) as { pid: number }).pid, 'SIGTERM'); + } catch { + // already gone + } + fs.rmSync(file, { force: true }); +} + +/** Deletes the app's instances, including its databases' data, through the target's own teardown; then ends the emulators. */ +async function cleanUp(): Promise { + process.chdir(integrationDir); + process.env['PRISMA_COMPOSER_EMULATORS_DIR'] = emulatorsDir; + const localTarget = prismaCloud().localTarget; + if (localTarget !== undefined) { + const target = await localTarget(); + const container = await target.container.ensure({ appName: APP, stage: undefined }); + await target.teardown?.({ container, stage: undefined }).catch((error: unknown) => { + console.error(`teardown failed: ${String(error)}`); + }); + } + for (const name of ['compute', 'buckets', 'postgres']) stopEmulator(name); + fs.rmSync(tempRoot, { recursive: true, force: true }); +} + +main() + .then(() => { + process.exitCode = 0; + }) + .catch((error: unknown) => { + console.error(error instanceof Error ? (error.stack ?? error.message) : String(error)); + for (let i = 1; i <= runCount; i += 1) { + const logPath = path.join(logDir, `run-${String(i)}.log`); + console.error(`\n--- run-${String(i)}.log (tail) ---`); + console.error(fs.readFileSync(logPath, 'utf8').split('\n').slice(-60).join('\n')); + } + process.exitCode = 1; + }) + .finally(cleanUp); From e105a7cf16ea2258acf2a6ce0eb2f01fdcfaf21c Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 15 Oct 2026 16:30:26 +0200 Subject: [PATCH 04/10] docs: prisma dev --detach The ADR that records dev url, dev stop and emulator list now also records --detach: what it does, why the foreground stays the default, the prior art (Docker Compose, .NET Aspire, Laravel Sail, Prisma ORM's prisma dev), and detached-by-default and a run-then-stop mode as alternatives. local-dev.md describes the app's process lifetime in both modes. The running-locally guide and the core-concepts skill tell agents, scripts and CI to use --detach and then prisma dev stop. The run-then-stop mode is recorded in deferred.md. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/emulator-commands/deferred.md | 1 + docs/design/10-domains/local-dev.md | 15 +++++++++++++++ ...-0053-dev-url-dev-stop-and-emulator-list.md | 12 ++++++++++-- docs/design/90-decisions/README.md | 2 +- docs/guides/running-locally.md | 18 ++++++++++++++++++ skills/prisma-composer-core-concepts/SKILL.md | 6 ++++++ 6 files changed, 51 insertions(+), 3 deletions(-) diff --git a/.drive/projects/emulator-commands/deferred.md b/.drive/projects/emulator-commands/deferred.md index b8c48ba6f..abefb7b6f 100644 --- a/.drive/projects/emulator-commands/deferred.md +++ b/.drive/projects/emulator-commands/deferred.md @@ -8,3 +8,4 @@ - **A service that never listens fails `prisma dev` under prisma/composer#353** (the cron scheduler; `examples/store`). Raised with Will; the fix belongs in #353. - **`prisma dev url` doesn't retry an emulator that is briefly unreachable.** Retry policy is #353's (`withEmulatorRetry`); apply it here once that settles. - **`prisma dev url` loads the config's extensions, and so Alchemy, to read one JSON file.** It only matters when a script calls it in a loop. +- **A run-then-stop mode for CI: `prisma dev --exec ""`, like Firebase `emulators:exec`.** It would bring the app up as `--detach` does, run the command, stop the app and exit with the command's code. Likely next step after S6. diff --git a/docs/design/10-domains/local-dev.md b/docs/design/10-domains/local-dev.md index 89bd0c313..284486be8 100644 --- a/docs/design/10-domains/local-dev.md +++ b/docs/design/10-domains/local-dev.md @@ -21,6 +21,9 @@ command: streaming them all inline drowns the front door and the rebuild notices. To read them, call the `log` operation from a script (see [docs/guides/running-locally.md](../../guides/running-locally.md)). + With `--detach` (the `dev` operation's `detach`), it returns once every + service answers and leaves the app running without watching; see + § Process lifetimes. - **`log`**, an operation on `@prisma/composer/control` with no command (ADR-0050) — tail the merged logs of that already-running app: every service, or one named by its dotted `address` (`catalog.service`). Follows @@ -356,6 +359,18 @@ dev state is never migrated. 3. **The dev session owns no processes at all** — it is a view (`attach`): endpoints, the stop control, and the watch loop. That same view's merged logs back the separate `log` operation. +4. **The app's processes** — the service children and database servers the + emulators run for the app. In the foreground, `prisma dev` stops them when + it ends (Ctrl-C, SIGTERM or a failure). `prisma dev --detach` converges, + waits until every service accepts connections, prints the front door and + exits 0, starting no watch loop and no stop on exit, so they keep running + until `prisma dev stop` or the next `prisma dev` in the directory, which + reconverges as usual. A detached session handle is already closed and its + `stop()` stops nothing. A detached run that fails before ready stops what + it started, as the foreground run does, and a Ctrl-C before ready stops the + app through `devStop`. A foreground `prisma dev` started while the app runs + detached takes it over: its converge changes nothing, it watches, and its + Ctrl-C stops the app. The emulator daemon bookkeeping (registry, stable ports, readiness, version-skew restart) is framework-owned and minimal, and the Postgres diff --git a/docs/design/90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md b/docs/design/90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md index 2e6ed24f3..dd6818e25 100644 --- a/docs/design/90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md +++ b/docs/design/90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md @@ -1,8 +1,8 @@ -# ADR-0053: `prisma dev url`, `prisma dev stop` and `prisma emulator list` +# ADR-0053: `prisma dev url`, `prisma dev stop`, `prisma dev --detach` and `prisma emulator list` ## Decision -Composer's command family ships three more commands, and `@prisma/composer/control` two more operations behind them: +Composer's command family ships three more commands and a `--detach` flag on `prisma dev`, and `@prisma/composer/control` two more operations and a `detach` option behind them: ```bash $ prisma dev module.ts # terminal 1: runs the app @@ -11,11 +11,14 @@ http://localhost:3001 $ url=$(prisma dev url gateway | jq -er 'select(.kind=="result").envelope.result.url') && curl "$url" $ prisma dev stop # stops this directory's app: services and database servers; data stays $ prisma emulator list # every local emulator on this machine and every instance on it +$ prisma dev module.ts --detach # returns once every service answers; the app keeps running +$ prisma dev stop # the only thing that stops a detached app ``` - **`dev url
`** prints where a node of the app `prisma dev` runs from the current directory answers: a service's `http://localhost:`, a database's connection string, or a bucket's S3 endpoint plus `/`. Its JSON result is `{ address, kind, url, state }`. In human format the URL alone goes to stdout, so `$(prisma dev url gateway --format human)` is the URL. It is the `devUrl` operation. - **`dev stop`** stops that app's service processes and database servers, as ending `prisma dev` does, for a `prisma dev` that was killed without the chance. Data and ports stay. It is the `devStop` operation, and returns `{ app, stopped }`. - **`emulator list`** lists the local emulators — the programs `prisma dev` starts to run services, databases and buckets on this machine — whether each runs, its admin URL, and its instances with id, kind, URL, state and labels. It needs no project. Its JSON result is `{ emulators: [{ kind, running, url, instances: [{ id, kind, url, state, labels }], error }] }`. `url` is `null` for an emulator that is not running. `error` is `null` unless the emulator runs but could not be listed; then it is `{ code, message }`, with `DEV.EMULATOR_UNREACHABLE` when the emulator did not answer and `DEV.EMULATOR_FAILED` when it answered with an error, and the other emulators are still listed. The command uses the emulator client bundled into `@prisma/composer-cli`, because it must work without a project. +- **`dev --detach`** runs `prisma dev` up to `ready`: it converges, waits until every service accepts connections, prints the front door and exits 0. It starts no watch loop and installs no stop on exit, so the app keeps running until `prisma dev stop` or the next `prisma dev` in the directory, which reconverges as usual. A failure before ready stops what the run started and exits non-zero, as the foreground run does; a Ctrl-C before ready stops the app. It combines with `--fresh` and `--name`. It is the `dev` operation's `detach` option, whose session is already closed and whose `stop()` stops nothing. A foreground `prisma dev` started while the app runs detached takes it over: it converges, which changes nothing, watches, and its Ctrl-C stops the app. The family keys each command by the path it is meant to mount at (`dev url`, `dev stop`, `emulator list`); the `prisma` CLI decides where it mounts them. This amends [ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md), under which the family was exactly `deploy` and `dev`, and [ADR-0043](ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md), whose `control` subpath had four operations. @@ -37,6 +40,8 @@ Docs, scripts and agents need to reach a locally running node without assuming i `emulator list` is a question about the machine, not a project: it needs no `prisma.config.ts`, so it cannot go through the seam, which starts from the configuration, and it cannot load the project's `@prisma/composer-prisma-cloud`, because there may be no project. Its operation lives in the Prisma Cloud domain beside the emulator clients, and `@prisma/composer-cli`, a public package that may import both domains, adds the command to the family and bundles that client. Nothing running is a valid answer: every emulator is listed with `running: false`. +`prisma dev` stays in the foreground by default, watching, with Ctrl-C stopping the app, because a developer at a terminal wants rebuilds picked up and one key to end the session. Agents, scripts and CI want the opposite: no long-running process to manage, and an app that answers once the command returns. `--detach` gives them that, and `dev url` and `dev stop` already give them the rest. The tools developers already use have the same split: Docker Compose (`up` and `up -d --wait`), .NET Aspire (`run` and `run --detach`), Laravel Sail (`up` and `up -d`) and Prisma ORM's own `prisma dev` (`--detach`) all default to the foreground with detach as an opt-in. + ## Consequences - `@prisma/composer/control` has six operations: `deploy`, `destroy`, `dev`, `devUrl`, `devStop`, `log`. @@ -55,6 +60,9 @@ Docs, scripts and agents need to reach a locally running node without assuming i - **`emulator list` through the core seam.** Would require a project and a configuration for a question about the machine. - **Printing a bare URL even when stdout is piped.** Would change the CLI's rule that a piped stdout carries JSON frames; scripts use `jq` or `--format human` instead. +- **Detached by default, with a `--watch` or `--attach` flag for the foreground.** Matches what agents need, but differs from every tool above and from what `prisma dev` does today, and leaves an app running after a developer closes the terminal. +- **A run-then-stop mode (`--exec ""`, like Firebase `emulators:exec`).** Useful for CI, and compatible with `--detach`; not part of this decision. + ## Related - [ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md) — the commands Composer ships; amended here. diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index f9ea35ca0..edaabcccb 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -74,4 +74,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md) — Composer has no binary of its own: the `prisma` CLI mounts exactly two Composer commands, `prisma deploy ` and `prisma dev `, mounted at the root as bare verbs on purpose rather than under a noun. `destroy` and `log` are operations on `@prisma/composer/control`, called from a script; a teardown script reads `PRISMA_SERVICE_TOKEN` and `PRISMA_WORKSPACE_ID`. Where teardown and logs belong in the `prisma` command tree is not decided; until it is, agents and users need a script for both. A `destroy` command was rejected: the CLI's destructive verb is `delete`, and no Composer command takes `--production`. Retires the `prisma-composer` binary; a CI lint keeps its name out of user-facing files. *(Amended by ADR-0053: the family also ships `dev url`, `dev stop` and `emulator list`.)* - [ADR-0051](ADR-0051-a-nodes-address-is-its-logical-id-on-the-platform.md) — A node's address is its identity on Prisma Cloud: Composer writes it, byte for byte, as the `logicalId` of the node's one platform row (Project, App, Database, Bucket) and as the node's `logicalId` in the application topology, so the platform joins topology nodes to rows, and one node across branches, by string equality. A provision ID defaults to the node's name, so changing a name changes identity. The value is never an Alchemy resource ID, a display name or a platform `id`; App, Database and Bucket rows get it as a prop on the upstream resource that creates them, and the Project from container resolution; no separate resource writes it. Changing an address changes the entity's identity; changing a display name changes nothing. - [ADR-0052](ADR-0052-composer-maps-its-nodes-to-emulator-instances-per-directory.md) — The local emulators know nothing about Composer: the Compute, Postgres and bucket emulators key their instances by ids they generate and store labels they never read. Composer records per working directory, in `.prisma-composer/dev/emulator-instances.json`, which instance backs each node of each app run there, and nothing else maps Composer's names to instances, so two directories running one app get separate services, databases and buckets. An instance keeps its port until it is deleted, because the port is in the deployment's environment and its dependents' URLs and a warm start does not redeploy; a remembered port is requested, as a preference, only when an instance is re-created. When `prisma dev` ends it stops the app's services and database servers, keeping their data. Running a different app from a directory stops the previous app's instances and keeps them; `--fresh` deletes only the current app's. Amends ADR-0041's app-name local identity. -- [ADR-0053](ADR-0053-dev-url-dev-stop-and-emulator-list.md) — Composer's command family ships `dev url
` (where a node of the app `prisma dev` runs from this directory answers: a service's URL, a database's connection string, a bucket's S3 endpoint; the URL alone on stdout in human format), `dev stop` (stops that app's services and database servers, data kept) and `emulator list` (every local emulator and its instances, with labels; needs no project). `devUrl` and `devStop` are `@prisma/composer/control` operations that go through a new optional `current({ devDir })` on the local-target seam, so core never reads the target's record; `listEmulators` lives in the Prisma Cloud domain and `@prisma/composer-cli` adds its command. New codes: `DEV.NOT_STARTED_HERE`, `DEV.ADDRESS_UNKNOWN`, `DEV.NOT_RUNNING`. Amends ADR-0050 and ADR-0043. +- [ADR-0053](ADR-0053-dev-url-dev-stop-and-emulator-list.md) — Composer's command family ships `dev url
` (where a node of the app `prisma dev` runs from this directory answers: a service's URL, a database's connection string, a bucket's S3 endpoint; the URL alone on stdout in human format), `dev stop` (stops that app's services and database servers, data kept), `dev --detach` (returns once every service answers and leaves the app running until `dev stop`; the foreground stays the default) and `emulator list` (every local emulator and its instances, with labels; needs no project). `devUrl` and `devStop` are `@prisma/composer/control` operations that go through a new optional `current({ devDir })` on the local-target seam, so core never reads the target's record; `listEmulators` lives in the Prisma Cloud domain and `@prisma/composer-cli` adds its command. New codes: `DEV.NOT_STARTED_HERE`, `DEV.ADDRESS_UNKNOWN`, `DEV.NOT_RUNNING`. Amends ADR-0050 and ADR-0043. diff --git a/docs/guides/running-locally.md b/docs/guides/running-locally.md index dcaa17536..02325cdf0 100644 --- a/docs/guides/running-locally.md +++ b/docs/guides/running-locally.md @@ -66,6 +66,24 @@ so do the ports, so the next `prisma dev` is a warm start — same ports, same data. `--fresh` is what wipes this app's local instances and data before starting. +### Run it in the background + +`prisma dev --detach` does everything `prisma dev` does up to `ready`, prints +the front door, and exits 0, leaving the app running. It does not watch for +changes, and nothing stops the app until you run `prisma dev stop`: + +```sh +prisma dev module.ts --detach +curl "$(prisma dev url gateway --format human)" +prisma dev stop +``` + +This is the form for agents, scripts and CI, which then have no long-running +process to manage. It combines with `--fresh` and `--name`. If it fails before +`ready`, it stops what it started and exits non-zero, as `prisma dev` does. To +pick up a rebuild, run `prisma dev --detach` again, or run `prisma dev` in the +foreground: it takes over the running app, watches, and `Ctrl-C` stops it. + On a fresh start, each service gets the lowest free port from 3000 up, in dependency order: a service gets its port before the services that call it, and services with no dependency between them go in the order you provisioned them. A warm start keeps the ports services already have. ### Each directory runs its own services, databases and buckets diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index ef378a611..ebc472e93 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -462,6 +462,12 @@ that surprise: shows every local emulator and its instances, from any directory; one that runs but cannot be listed carries an `error` in its own entry. `prisma emulator start` and `stop` are planned. + For agents, scripts and CI, `prisma dev --detach` runs the app in + the background: it exits 0 once every service answers and prints the URLs, + does not watch for changes, and leaves the app running until + `prisma dev stop`. Use it, then `prisma dev stop`, instead of keeping a + foreground `prisma dev` alive. A failure before ready stops what it + started and exits non-zero. 4. `dev` reads `prisma.config.ts` once, at start, and watches it: after an edit it says so and pauses rebuilds until you restart `dev`. 5. An unset secret doesn't block a local run: it becomes a placeholder plus a From b65bcd0d7e5e7fd9dd789fc7bf7a55b41602fce2 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 15 Oct 2026 16:42:40 +0200 Subject: [PATCH 05/10] docs: a failed detached run stops the whole app, and --detach is for scripts A detached run that fails before ready stops the whole app, including one an earlier --detach left running: a failed redeploy has already replaced that service's old process, so the previous app cannot be kept. The guide, the skill, local-dev.md and ADR-0053 now say so, and say to fix the build and run `prisma dev --detach` again. local-dev.md no longer calls a detached mode a later extension and lists --detach among dev's flags. ADR-0041 gets an amendment note, and the index's note on ADR-0050 mentions --detach. The guide says `prisma dev stop` is how to stop an app started with --detach. The --detach flag brief says to use it in scripts, agents and CI. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/design/10-domains/local-dev.md | 18 +++++++++++------- ...-deploy-pipeline-against-local-providers.md | 2 +- ...-0053-dev-url-dev-stop-and-emulator-list.md | 2 +- docs/design/90-decisions/README.md | 2 +- docs/guides/running-locally.md | 6 +++--- .../3-tooling/cli/src/family/commands/dev.ts | 4 ++-- skills/prisma-composer-core-concepts/SKILL.md | 6 ++++-- 7 files changed, 23 insertions(+), 17 deletions(-) diff --git a/docs/design/10-domains/local-dev.md b/docs/design/10-domains/local-dev.md index 284486be8..9c69bfc68 100644 --- a/docs/design/10-domains/local-dev.md +++ b/docs/design/10-domains/local-dev.md @@ -62,9 +62,10 @@ Cloud target implements it from `emulator-instances.json`; core never reads the record. `dev url` reports a node only while it is running, so every URL it prints answers. -Flags: `dev` takes `--name` (override the root's name, as on deploy) and +Flags: `dev` takes `--name` (override the root's name, as on deploy), `--fresh` (destroy the dev stack and wipe the dev state directory before -starting). Stages do not apply — a working directory has +starting) and `--detach` (return once every service answers and leave the app +running; see § Process lifetimes). Stages do not apply — a working directory has exactly one dev instance; parallel instances are parallel checkouts. **Naming.** The `prisma` CLI mounts this as `prisma dev ` @@ -177,8 +178,8 @@ service's instance, and the emulator owns the processes: instances and its database servers, and detaches. Every emulator — Compute, Postgres, buckets — stays up, and every instance keeps its data and its port; the next start reuses the same instances (same ports, same - data). `--fresh` is what removes instances and data. (A detached mode where services keep serving with no - session is a designed extension, not v1.) + data). `--fresh` is what removes instances and data. `prisma dev --detach` leaves the app running with no + session; see § Process lifetimes. **Known gap (proving-pass finding, fixed in #164):** a service actually only restarts when its `Deployment` resource is re-put — and Alchemy skips calling the @@ -366,9 +367,12 @@ dev state is never migrated. exits 0, starting no watch loop and no stop on exit, so they keep running until `prisma dev stop` or the next `prisma dev` in the directory, which reconverges as usual. A detached session handle is already closed and its - `stop()` stops nothing. A detached run that fails before ready stops what - it started, as the foreground run does, and a Ctrl-C before ready stops the - app through `devStop`. A foreground `prisma dev` started while the app runs + `stop()` stops nothing. A detached run that fails before ready stops the + whole app, including one an earlier `--detach` left running, as the + foreground run does: a failed redeploy has already replaced that service's + old process, so the previous app cannot be kept; fix the build and run + `prisma dev --detach` again. A Ctrl-C before ready stops the app through + `devStop`. A foreground `prisma dev` started while the app runs detached takes it over: its converge changes nothing, it watches, and its Ctrl-C stops the app. diff --git a/docs/design/90-decisions/ADR-0041-local-dev-runs-the-deploy-pipeline-against-local-providers.md b/docs/design/90-decisions/ADR-0041-local-dev-runs-the-deploy-pipeline-against-local-providers.md index bb5050b71..97f4fe02d 100644 --- a/docs/design/90-decisions/ADR-0041-local-dev-runs-the-deploy-pipeline-against-local-providers.md +++ b/docs/design/90-decisions/ADR-0041-local-dev-runs-the-deploy-pipeline-against-local-providers.md @@ -118,7 +118,7 @@ every attachment after each converge, before printing the front door — so a warm start restarts stopped services independent of Alchemy's props diff. Details in [local-dev.md](../10-domains/local-dev.md). A detached mode (services keep serving with no session, full platform parity) is a -designed extension, not v1. +designed extension, not v1. *(Amended by ADR-0053: `prisma dev --detach`.)* **Rebuilds stay the user's** (ADR-0005). Dev watches the *built* output that assembly consumes, never sources; the user's own watcher (`next dev` is not diff --git a/docs/design/90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md b/docs/design/90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md index dd6818e25..6952ed985 100644 --- a/docs/design/90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md +++ b/docs/design/90-decisions/ADR-0053-dev-url-dev-stop-and-emulator-list.md @@ -18,7 +18,7 @@ $ prisma dev stop # the only thing that stops a detached app - **`dev url
`** prints where a node of the app `prisma dev` runs from the current directory answers: a service's `http://localhost:`, a database's connection string, or a bucket's S3 endpoint plus `/`. Its JSON result is `{ address, kind, url, state }`. In human format the URL alone goes to stdout, so `$(prisma dev url gateway --format human)` is the URL. It is the `devUrl` operation. - **`dev stop`** stops that app's service processes and database servers, as ending `prisma dev` does, for a `prisma dev` that was killed without the chance. Data and ports stay. It is the `devStop` operation, and returns `{ app, stopped }`. - **`emulator list`** lists the local emulators — the programs `prisma dev` starts to run services, databases and buckets on this machine — whether each runs, its admin URL, and its instances with id, kind, URL, state and labels. It needs no project. Its JSON result is `{ emulators: [{ kind, running, url, instances: [{ id, kind, url, state, labels }], error }] }`. `url` is `null` for an emulator that is not running. `error` is `null` unless the emulator runs but could not be listed; then it is `{ code, message }`, with `DEV.EMULATOR_UNREACHABLE` when the emulator did not answer and `DEV.EMULATOR_FAILED` when it answered with an error, and the other emulators are still listed. The command uses the emulator client bundled into `@prisma/composer-cli`, because it must work without a project. -- **`dev --detach`** runs `prisma dev` up to `ready`: it converges, waits until every service accepts connections, prints the front door and exits 0. It starts no watch loop and installs no stop on exit, so the app keeps running until `prisma dev stop` or the next `prisma dev` in the directory, which reconverges as usual. A failure before ready stops what the run started and exits non-zero, as the foreground run does; a Ctrl-C before ready stops the app. It combines with `--fresh` and `--name`. It is the `dev` operation's `detach` option, whose session is already closed and whose `stop()` stops nothing. A foreground `prisma dev` started while the app runs detached takes it over: it converges, which changes nothing, watches, and its Ctrl-C stops the app. +- **`dev --detach`** runs `prisma dev` up to `ready`: it converges, waits until every service accepts connections, prints the front door and exits 0. It starts no watch loop and installs no stop on exit, so the app keeps running until `prisma dev stop` or the next `prisma dev` in the directory, which reconverges as usual. A failure before ready stops the whole app, including one an earlier `--detach` left running, and exits non-zero, as the foreground run does: a failed redeploy has already replaced that service's old process, so the previous app cannot be kept, and the fix is to repair the build and run `prisma dev --detach` again; a Ctrl-C before ready stops the app. It combines with `--fresh` and `--name`. It is the `dev` operation's `detach` option, whose session is already closed and whose `stop()` stops nothing. A foreground `prisma dev` started while the app runs detached takes it over: it converges, which changes nothing, watches, and its Ctrl-C stops the app. The family keys each command by the path it is meant to mount at (`dev url`, `dev stop`, `emulator list`); the `prisma` CLI decides where it mounts them. This amends [ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md), under which the family was exactly `deploy` and `dev`, and [ADR-0043](ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md), whose `control` subpath had four operations. diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index edaabcccb..91ef1c66d 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -71,7 +71,7 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0047](ADR-0047-compute-assembly-preserves-safe-runtime-topology.md) — Compute assembly traces runtime files from the author-declared Node entry without rebundling app code, preserves only symlinks whose resolved targets remain inside the staged bundle, and installs narrowly runtime-gated bootstrap compatibility when a framework needs Node semantics. Each local mechanism is removed once the upstream Alchemy Compute provider owns the equivalent guarantee. Supersedes ADR-0005's blanket ban on symlinks and its assumption that directory output is already self-contained. - [ADR-0048](ADR-0048-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md) — The six Management-API resource families (project, database, connection, app, deployment, environment variable) are the upstream `alchemy/Prisma` provider's classes, registered in Composer's `PrismaComposer` collection; Composer defines resources only where the upstream provider has no support yet (buckets) or no Management API exists behind them. Compute binds the low-level App/Deployment/EnvironmentVariable trio (the `COMPOSER_*_ORIGIN` self-edge and ADR-0005 rule out composite `Compute`); the env→deployment ordering edge rides the deployment's `app` prop, and a deployment is replaced exactly when its artifact or environment fingerprint changes — unchanged services are reused. Legacy rows in the platform state API (retired type-ids and shapes) migrate on read — the older retired SQL state stores stay destroy-only under ADR-0045 — branch-stage databases take generated physical names, and the platform's seeded `DATABASE_URL` is left system-managed. - [ADR-0049](ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md) — Composer's configuration is the `composer` section of `prisma.config.ts`, loaded by the Prisma CLI engine. The validator checks only the fields that identify extension and state descriptors, passes descriptors through as the file's own objects, rejects unknown keys, and takes the nearest declaring file's section whole so the generated stack imports exactly one file. The old `prisma-composer.config.ts` and the `configPath` pointer are refused with `CONFIG.SECTION_MISSING`, `CONFIG.FIELD_RETIRED` and `CONFIG.FILE_RETIRED`, never migrated. The validator returns the section with its declaring file, and the programmatic operations take the same `ComposerConfigSource` (`{ value, file }`) and run the same checks. The Alchemy child evaluates the whole `prisma.config.ts`. The effect pre-flight and `DEPS.EFFECT_VERSION_CONFLICT` are retired: a broken tree fails evaluation of `prisma.config.ts` with the engine's `CLI.CONFIG_UNREADABLE`. *(Amended: the alternatives section lists the rejected ways to keep a Composer-owned effect check.)* -- [ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md) — Composer has no binary of its own: the `prisma` CLI mounts exactly two Composer commands, `prisma deploy ` and `prisma dev `, mounted at the root as bare verbs on purpose rather than under a noun. `destroy` and `log` are operations on `@prisma/composer/control`, called from a script; a teardown script reads `PRISMA_SERVICE_TOKEN` and `PRISMA_WORKSPACE_ID`. Where teardown and logs belong in the `prisma` command tree is not decided; until it is, agents and users need a script for both. A `destroy` command was rejected: the CLI's destructive verb is `delete`, and no Composer command takes `--production`. Retires the `prisma-composer` binary; a CI lint keeps its name out of user-facing files. *(Amended by ADR-0053: the family also ships `dev url`, `dev stop` and `emulator list`.)* +- [ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md) — Composer has no binary of its own: the `prisma` CLI mounts exactly two Composer commands, `prisma deploy ` and `prisma dev `, mounted at the root as bare verbs on purpose rather than under a noun. `destroy` and `log` are operations on `@prisma/composer/control`, called from a script; a teardown script reads `PRISMA_SERVICE_TOKEN` and `PRISMA_WORKSPACE_ID`. Where teardown and logs belong in the `prisma` command tree is not decided; until it is, agents and users need a script for both. A `destroy` command was rejected: the CLI's destructive verb is `delete`, and no Composer command takes `--production`. Retires the `prisma-composer` binary; a CI lint keeps its name out of user-facing files. *(Amended by ADR-0053: the family also ships `dev url`, `dev stop` and `emulator list`, and `prisma dev` gains `--detach`.)* - [ADR-0051](ADR-0051-a-nodes-address-is-its-logical-id-on-the-platform.md) — A node's address is its identity on Prisma Cloud: Composer writes it, byte for byte, as the `logicalId` of the node's one platform row (Project, App, Database, Bucket) and as the node's `logicalId` in the application topology, so the platform joins topology nodes to rows, and one node across branches, by string equality. A provision ID defaults to the node's name, so changing a name changes identity. The value is never an Alchemy resource ID, a display name or a platform `id`; App, Database and Bucket rows get it as a prop on the upstream resource that creates them, and the Project from container resolution; no separate resource writes it. Changing an address changes the entity's identity; changing a display name changes nothing. - [ADR-0052](ADR-0052-composer-maps-its-nodes-to-emulator-instances-per-directory.md) — The local emulators know nothing about Composer: the Compute, Postgres and bucket emulators key their instances by ids they generate and store labels they never read. Composer records per working directory, in `.prisma-composer/dev/emulator-instances.json`, which instance backs each node of each app run there, and nothing else maps Composer's names to instances, so two directories running one app get separate services, databases and buckets. An instance keeps its port until it is deleted, because the port is in the deployment's environment and its dependents' URLs and a warm start does not redeploy; a remembered port is requested, as a preference, only when an instance is re-created. When `prisma dev` ends it stops the app's services and database servers, keeping their data. Running a different app from a directory stops the previous app's instances and keeps them; `--fresh` deletes only the current app's. Amends ADR-0041's app-name local identity. - [ADR-0053](ADR-0053-dev-url-dev-stop-and-emulator-list.md) — Composer's command family ships `dev url
` (where a node of the app `prisma dev` runs from this directory answers: a service's URL, a database's connection string, a bucket's S3 endpoint; the URL alone on stdout in human format), `dev stop` (stops that app's services and database servers, data kept), `dev --detach` (returns once every service answers and leaves the app running until `dev stop`; the foreground stays the default) and `emulator list` (every local emulator and its instances, with labels; needs no project). `devUrl` and `devStop` are `@prisma/composer/control` operations that go through a new optional `current({ devDir })` on the local-target seam, so core never reads the target's record; `listEmulators` lives in the Prisma Cloud domain and `@prisma/composer-cli` adds its command. New codes: `DEV.NOT_STARTED_HERE`, `DEV.ADDRESS_UNKNOWN`, `DEV.NOT_RUNNING`. Amends ADR-0050 and ADR-0043. diff --git a/docs/guides/running-locally.md b/docs/guides/running-locally.md index 02325cdf0..c09804a70 100644 --- a/docs/guides/running-locally.md +++ b/docs/guides/running-locally.md @@ -80,7 +80,7 @@ prisma dev stop This is the form for agents, scripts and CI, which then have no long-running process to manage. It combines with `--fresh` and `--name`. If it fails before -`ready`, it stops what it started and exits non-zero, as `prisma dev` does. To +`ready`, it stops the whole app, including one an earlier `--detach` left running, and exits non-zero, as `prisma dev` does. A failed redeploy has already replaced that service's old process, so the previous app cannot be kept. Fix the build and run `prisma dev --detach` again. To pick up a rebuild, run `prisma dev --detach` again, or run `prisma dev` in the foreground: it takes over the running app, watches, and `Ctrl-C` stops it. @@ -164,8 +164,8 @@ a code a script can branch on: next `prisma dev`. `prisma dev stop` stops the app's service processes and database servers, as -`Ctrl-C` does. Use it when `prisma dev` was killed without the chance to stop -them. Data and ports are kept, so the next `prisma dev` is still a warm start. +`Ctrl-C` does. Use it to stop an app started with `--detach`, or when +`prisma dev` was killed without the chance to stop it. Data and ports are kept, so the next `prisma dev` is still a warm start. `prisma emulator list` shows every local emulator on the machine, whether it is running, and every instance on it, with the directory, app and address it diff --git a/packages/0-framework/3-tooling/cli/src/family/commands/dev.ts b/packages/0-framework/3-tooling/cli/src/family/commands/dev.ts index 83df93edf..e1c29e42e 100644 --- a/packages/0-framework/3-tooling/cli/src/family/commands/dev.ts +++ b/packages/0-framework/3-tooling/cli/src/family/commands/dev.ts @@ -235,8 +235,8 @@ export const createDevCommand = (operations: ComposerOperations) => }), detach: flag.boolean({ brief: - 'Return once every service answers and leave the app running in the background, ' + - 'without watching for changes. `prisma dev stop` stops it.', + 'Return once every service answers and leave the app running without watching for ' + + 'changes. Use it in scripts, agents and CI. `prisma dev stop` stops the app.', }), }, }, diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index ebc472e93..4812d62c7 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -466,8 +466,10 @@ that surprise: the background: it exits 0 once every service answers and prints the URLs, does not watch for changes, and leaves the app running until `prisma dev stop`. Use it, then `prisma dev stop`, instead of keeping a - foreground `prisma dev` alive. A failure before ready stops what it - started and exits non-zero. + foreground `prisma dev` alive. A failure before ready stops the whole + app, including one an earlier `--detach` left running (a failed redeploy + has already replaced that service's process), and exits non-zero: fix the + build and run `prisma dev --detach` again. 4. `dev` reads `prisma.config.ts` once, at start, and watches it: after an edit it says so and pauses rebuilds until you restart `dev`. 5. An unset secret doesn't block a local run: it becomes a placeholder plus a From 3253324db2be40fc670c7d0e60593c2998d04317 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 15 Oct 2026 16:42:48 +0200 Subject: [PATCH 06/10] test(integration): the detach proof checks real ports and waits for the emulators to exit Both port checks now require the record to hold a service port and a database port before asserting none accepts connections, so an empty record can no longer pass them. Ending an emulator waits until its process has exited, with a SIGKILL after five seconds, so it cannot write into the temporary directory after the script removes it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../test/local-dev-detach.integration.ts | 63 +++++++++++++------ 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/test/integration/test/local-dev-detach.integration.ts b/test/integration/test/local-dev-detach.integration.ts index 1ebbe0b16..d278290de 100644 --- a/test/integration/test/local-dev-detach.integration.ts +++ b/test/integration/test/local-dev-detach.integration.ts @@ -121,12 +121,30 @@ function acceptsConnection(port: number): Promise { } /** The ports the directory's record holds for the current app's services and databases. */ -function recordedPorts(): readonly number[] { +function recordedPorts(): readonly { readonly kind: string; readonly port: number }[] { const record = JSON.parse( fs.readFileSync(path.join(devDir, 'emulator-instances.json'), 'utf8'), - ) as { current: string; apps: Record }> }; + ) as { + current: string; + apps: Record }>; + }; const nodes = record.apps[record.current]?.nodes ?? {}; - return Object.values(nodes).flatMap((node) => (node.port === undefined ? [] : [node.port])); + return Object.values(nodes).flatMap(({ kind, port }) => + port === undefined ? [] : [{ kind, port }], + ); +} + +/** Asserts the record holds a service and a database port, and that none of them accepts connections. */ +async function assertNothingListens(when: string): Promise { + const ports = recordedPorts(); + const kinds = new Set(ports.map((p) => p.kind)); + assert( + kinds.has('service') && kinds.has('database'), + `the record holds service and database ports ${when} (got ${JSON.stringify(ports)})`, + ); + for (const { kind, port } of ports) { + assert(!(await acceptsConnection(port)), `${kind} port ${String(port)} is closed ${when}`); + } } async function main(): Promise { @@ -153,38 +171,43 @@ async function main(): Promise { console.log('[detach] PASS: exit 0 once ready, and every service answers after it exits'); // ——— 2. Stopping the app stops them ——— - const ports = recordedPorts(); const stopped = await devStop({ config, cwd: integrationDir }); assert(stopped.ok, `devStop succeeds (${stopped.ok ? '' : stopped.failure.message})`); - for (const port of ports) { - assert(!(await acceptsConnection(port)), `port ${String(port)} is closed after stopping`); - } + await assertNothingListens('after stopping'); console.log('[detach] PASS: stopping the app closes every service and database port'); // ——— 3. A failure before ready leaves nothing running ——— // The Compute emulator reads the timeout when it starts, so end it first. - stopEmulator('compute'); + await stopEmulator('compute'); const failed = await runDetached(env({ PRISMA_COMPOSER_SERVICE_READY_TIMEOUT_MS: '1' })); assert(failed.code !== 0, 'a detached run whose services never become ready exits non-zero'); - for (const port of recordedPorts()) { - assert( - !(await acceptsConnection(port)), - `port ${String(port)} is closed after the failed detached run`, - ); - } + await assertNothingListens('after the failed detached run'); console.log('[detach] PASS: a detached run that fails before ready leaves nothing running'); console.log('PASS: local dev --detach'); } -function stopEmulator(name: string): void { - const file = path.join(emulatorsDir, `${name}.json`); - if (!fs.existsSync(file)) return; +function isAlive(pid: number): boolean { try { - process.kill((JSON.parse(fs.readFileSync(file, 'utf8')) as { pid: number }).pid, 'SIGTERM'); + process.kill(pid, 0); + return true; } catch { - // already gone + return false; + } +} + +/** Ends an emulator and waits until its process has exited, so it writes nothing after this returns. */ +async function stopEmulator(name: string): Promise { + const file = path.join(emulatorsDir, `${name}.json`); + if (!fs.existsSync(file)) return; + const { pid } = JSON.parse(fs.readFileSync(file, 'utf8')) as { pid: number }; + if (isAlive(pid)) process.kill(pid, 'SIGTERM'); + const deadline = Date.now() + 5_000; + while (isAlive(pid) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)); } + if (isAlive(pid)) process.kill(pid, 'SIGKILL'); + while (isAlive(pid)) await new Promise((resolve) => setTimeout(resolve, 50)); fs.rmSync(file, { force: true }); } @@ -200,7 +223,7 @@ async function cleanUp(): Promise { console.error(`teardown failed: ${String(error)}`); }); } - for (const name of ['compute', 'buckets', 'postgres']) stopEmulator(name); + for (const name of ['compute', 'buckets', 'postgres']) await stopEmulator(name); fs.rmSync(tempRoot, { recursive: true, force: true }); } From 755744de8a2a9295d33d7736a227d2356e736b90 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 15 Oct 2026 16:50:31 +0200 Subject: [PATCH 07/10] test(integration): ask the prisma host for --json wherever a script parses its output The local-dev scripts read the front door from `prisma dev`'s piped output, relying on a piped stdout being NDJSON. That stops being true once a pipe gets plain data lines and JSON only with --json. The detach, criteria 4 and 5, and store scripts now pass --json and parse only the status and endpoint frames; their comments say so. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../local-dev-criteria-4-5.integration.ts | 20 +++---------- .../test/local-dev-detach.integration.ts | 6 ++-- .../test/local-dev-store.integration.ts | 30 ++++--------------- 3 files changed, 13 insertions(+), 43 deletions(-) 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 54b14bd04..889e09a5b 100644 --- a/test/integration/test/local-dev-criteria-4-5.integration.ts +++ b/test/integration/test/local-dev-criteria-4-5.integration.ts @@ -117,23 +117,11 @@ function dumpDiagnostics(): void { console.error('=== end diagnostics ===\n'); } +/** The front door, from the `--json` run's NDJSON frames: a `status` frame marks readiness and each service is an `endpoint` frame. */ function parseFrontDoor(log: string): readonly Endpoint[] | undefined { - const lines = log.split('\n'); - const readyAt = lines.findIndex((l) => l.trim() === 'dev: ready'); - if (readyAt !== -1) { - const endpoints: Endpoint[] = []; - for (let i = readyAt + 1; i < lines.length; i += 1) { - const m = /^(\S+): (\S+)$/.exec(lines[i]?.trim() ?? ''); - if (m === null) break; - endpoints.push({ address: m[1] as string, url: m[2] as string }); - } - return endpoints.length > 0 ? endpoints : undefined; - } - // Engine 0.2.0 renders a non-TTY run as NDJSON frames instead: readiness - // is a `status` frame and each service is an `endpoint` frame. let ready = false; const endpoints: Endpoint[] = []; - for (const line of lines) { + for (const line of log.split('\n')) { if (!line.startsWith('{')) continue; try { const frame = JSON.parse(line) as { @@ -147,7 +135,7 @@ function parseFrontDoor(log: string): readonly Endpoint[] | undefined { } if (frame.kind === 'status' && frame.status === 'ready') ready = true; } catch { - // Child output interleaved with the frames; not every line is JSON. + // Converge output is interleaved with the frames; not every line is JSON. } } return ready && endpoints.length > 0 ? endpoints : undefined; @@ -197,7 +185,7 @@ function startDevRaw( 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 args = ['dev', fixtureEntry, '--json', ...(opts.fresh === true ? ['--fresh'] : [])]; const child = spawn(CLI_BIN, args, { cwd: integrationDir, env, diff --git a/test/integration/test/local-dev-detach.integration.ts b/test/integration/test/local-dev-detach.integration.ts index d278290de..005190d6c 100644 --- a/test/integration/test/local-dev-detach.integration.ts +++ b/test/integration/test/local-dev-detach.integration.ts @@ -65,7 +65,7 @@ function env(extra: Record = {}): NodeJS.ProcessEnv { return result; } -/** Runs `prisma dev --detach --fresh` to completion; returns its exit code and output. */ +/** Runs `prisma dev --detach --fresh --json` to completion; returns its exit code and output. */ async function runDetached( runEnv: NodeJS.ProcessEnv, ): Promise<{ code: number | null; log: string }> { @@ -73,7 +73,7 @@ async function runDetached( fs.mkdirSync(logDir, { recursive: true }); const logPath = path.join(logDir, `run-${String(runCount)}.log`); const logFd = fs.openSync(logPath, 'w'); - const child = spawn(CLI_BIN, ['dev', fixtureEntry, '--detach', '--fresh'], { + const child = spawn(CLI_BIN, ['dev', fixtureEntry, '--detach', '--fresh', '--json'], { cwd: integrationDir, env: runEnv, stdio: ['ignore', logFd, logFd], @@ -92,7 +92,7 @@ async function runDetached( return { code, log: fs.readFileSync(logPath, 'utf8') }; } -/** The front door, from the human lines or, for a piped stdout, the NDJSON frames. */ +/** The front door, from the `--json` run's `endpoint` frames. */ function frontDoor(log: string): readonly Endpoint[] { const endpoints: Endpoint[] = []; for (const line of log.split('\n')) { diff --git a/test/integration/test/local-dev-store.integration.ts b/test/integration/test/local-dev-store.integration.ts index 024f0242e..ae25fa1b0 100644 --- a/test/integration/test/local-dev-store.integration.ts +++ b/test/integration/test/local-dev-store.integration.ts @@ -13,7 +13,7 @@ * 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; + * (the `--json` run's `ready` status frame and `endpoint` frames) 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) restarts `catalog.service` @@ -171,29 +171,11 @@ function readLog(logPath: string): string { } } -/** - * Parses the front-door block out of the tee'd log. The engine renders `dev`'s - * ready status and its endpoint events as `dev: ready` followed by one - * `
: ` line per service; the old `[dev] `-prefixed block was the - * legacy CLI adapter's own console formatting and went with it. - */ +/** The front door, from the `--json` run's NDJSON frames: a `status` frame marks readiness and each service is an `endpoint` frame. */ function parseFrontDoor(log: string): readonly Endpoint[] | undefined { - const lines = log.split('\n'); - const readyAt = lines.findIndex((l) => l.trim() === 'dev: ready'); - if (readyAt !== -1) { - const endpoints: Endpoint[] = []; - for (let i = readyAt + 1; i < lines.length; i += 1) { - const m = /^(\S+): (\S+)$/.exec(lines[i]?.trim() ?? ''); - if (m === null) break; - endpoints.push({ address: m[1] as string, url: m[2] as string }); - } - return endpoints.length > 0 ? endpoints : undefined; - } - // Engine 0.2.0 renders a non-TTY run as NDJSON frames instead: readiness - // is a `status` frame and each service is an `endpoint` frame. let ready = false; const endpoints: Endpoint[] = []; - for (const line of lines) { + for (const line of log.split('\n')) { if (!line.startsWith('{')) continue; try { const frame = JSON.parse(line) as { @@ -207,7 +189,7 @@ function parseFrontDoor(log: string): readonly Endpoint[] | undefined { } if (frame.kind === 'status' && frame.status === 'ready') ready = true; } catch { - // Child output interleaved with the frames; not every line is JSON. + // Converge output is interleaved with the frames; not every line is JSON. } } return ready && endpoints.length > 0 ? endpoints : undefined; @@ -328,7 +310,7 @@ async function startDev(fresh: boolean): Promise { 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 args = ['dev', 'module.ts', '--json', ...(fresh ? ['--fresh'] : [])]; const child = spawn(CLI_BIN, args, { cwd: storeDir, env: credentialFreeEnv(), @@ -474,7 +456,7 @@ const REAL_WATCH_TIMEOUT_MS = 30_000; * 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 + * front door (a `ready` status frame and `endpoint` frames) 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 From 95b730f6c41fe34030641837c60e71cd26c876e9 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 15 Oct 2026 16:53:23 +0200 Subject: [PATCH 08/10] test(integration): keep the detach proof's services off busy ports The proof asserts a service's port is closed after the app stops, which failed once when something else on the machine bound port 3000. Before the first run the script now starts the Compute emulator and fills every port below 3200 with placeholder instances, which the emulator keeps across its restart in part 3, so the fixture's services get 3200 and up. Both closed-port checks assert the service ports are at or above 3200. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../test/local-dev-detach.integration.ts | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/test/integration/test/local-dev-detach.integration.ts b/test/integration/test/local-dev-detach.integration.ts index 005190d6c..fe6518faa 100644 --- a/test/integration/test/local-dev-detach.integration.ts +++ b/test/integration/test/local-dev-detach.integration.ts @@ -12,6 +12,9 @@ * readiness timeout, so the first service fails after the converge has * started the app's databases. * + * The fixture's services are pushed to ports from 3200 up, so a process + * binding a busy port such as 3000 cannot make a closed-port check fail. + * * A standalone script, not a `bun test` file, for the reason given in * local-dev.integration.ts: output of processes started under the deploy * engine is lost when the root process is `bun test`. @@ -22,6 +25,8 @@ import * as fs from 'node:fs'; import * as net from 'node:net'; import * as os from 'node:os'; import * as path from 'node:path'; +import { Load, module } from '@prisma/composer'; +import { DEV_DIR } from '@prisma/composer/config'; import { devStop } from '@prisma/composer/control'; import { prismaCloud } from '@prisma/composer-prisma-cloud/control'; import prismaConfig from '../prisma.config.ts'; @@ -44,6 +49,14 @@ const config = { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'local-dev-detach-')); const emulatorsDir = path.join(tempRoot, 'emulators'); const APP = 'localdevs4fixture'; +/** + * Where the fixture's services start. Port 3000 and the ports just above it + * are the busiest on a development machine, and a port another process binds + * after a stop would read as a service that was not stopped. Ports below this + * one go to placeholder instances, which the Compute emulator keeps across + * its restart in part 3. + */ +const FIRST_SERVICE_PORT = 3200; let runCount = 0; interface Endpoint { @@ -137,6 +150,13 @@ function recordedPorts(): readonly { readonly kind: string; readonly port: numbe /** Asserts the record holds a service and a database port, and that none of them accepts connections. */ async function assertNothingListens(when: string): Promise { const ports = recordedPorts(); + for (const { kind, port } of ports) { + if (kind !== 'service') continue; + assert( + port >= FIRST_SERVICE_PORT, + `service port ${String(port)} is at or above ${String(FIRST_SERVICE_PORT)} ${when}`, + ); + } const kinds = new Set(ports.map((p) => p.kind)); assert( kinds.has('service') && kinds.has('database'), @@ -147,13 +167,41 @@ async function assertNothingListens(when: string): Promise { } } +/** Starts the Compute emulator with an app that has no nodes, then fills every port below `port` with a placeholder instance. */ +async function reserveServicePortsBelow(port: number): Promise { + const localTarget = prismaCloud().localTarget; + assert(localTarget !== undefined, 'prismaCloud() has a local target'); + const target = await localTarget(); + await target.emulators?.({ + graph: Load( + module('portskip', () => {}), + { id: 'portskip' }, + ), + container: await target.container.ensure({ appName: 'portskip', stage: undefined }), + devDir: path.join(tempRoot, 'placeholders', DEV_DIR), + }); + const { port: adminPort } = JSON.parse( + fs.readFileSync(path.join(emulatorsDir, 'compute.json'), 'utf8'), + ) as { port: number }; + for (let given = 0; given < port - 1; ) { + const created = await fetch(`http://127.0.0.1:${String(adminPort)}/instances`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ labels: { purpose: 'port-skip' } }), + }); + given = ((await created.json()) as { port: number }).port; + } +} + async function main(): Promise { console.log('local dev: prisma dev --detach against the local-dev fixture'); fs.rmSync(path.join(integrationDir, '.prisma-composer'), { recursive: true, force: true }); fs.rmSync(path.join(integrationDir, '.alchemy'), { recursive: true, force: true }); - // ——— 1. Ready, exit 0, still answering afterwards ——— process.env['PRISMA_COMPOSER_EMULATORS_DIR'] = emulatorsDir; + await reserveServicePortsBelow(FIRST_SERVICE_PORT); + + // ——— 1. Ready, exit 0, still answering afterwards ——— const detached = await runDetached(env()); assert(detached.code === 0, `prisma dev --detach exits 0 (got ${String(detached.code)})`); const endpoints = frontDoor(detached.log); From 9d48ce177572f774ffab6926ad5b7c1a79e7ec97 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 15 Oct 2026 16:55:00 +0200 Subject: [PATCH 09/10] docs(drive): record commands that need data lines for piped output Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/projects/emulator-commands/deferred.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.drive/projects/emulator-commands/deferred.md b/.drive/projects/emulator-commands/deferred.md index abefb7b6f..c43cb4911 100644 --- a/.drive/projects/emulator-commands/deferred.md +++ b/.drive/projects/emulator-commands/deferred.md @@ -2,10 +2,13 @@ - **A start can join a crash-backoff restart that has already exited (prisma/composer#353).** When the fifth fast exit marks an instance `held`, the readiness probe only notices the exit after its current connection attempt. A start request arriving in that gap joins the dying start and gets its old failure, so nothing resumes. Seen once as an intermittent failure of "a held service resumes on start" in `compute-deployment.test.ts`; not reproduced in 10 runs under CPU load. Likely fix: a start must not join an in-flight start whose process has already exited. Belongs in #353, not this project. - **What a previous app's databases and buckets become when another app runs from the same directory.** Compute instances are deleted (S1a). Databases and buckets hold data; decide in S1b. -- **`[dev]` notices bypass the CLI engine's output.** The emulators hook writes `[dev] compute emulator ready`, the port-change line and the app-switch line with `console.log` to stdout, so a piped `prisma dev` mixes them into the JSON frames. Fixing it needs a channel for the local target's hooks to report through the engine. +- **`[dev]` notices bypass the CLI engine's output.** The emulators hook writes `[dev] compute emulator ready`, the port-change line and the app-switch line with `console.log` to stdout, so once a pipe gets plain data (prisma-cli's piped-output change) they land among a piped `prisma dev`'s data lines; with `--json` they still mix into the JSON frames. Fixing it needs a channel for the local target's hooks to report through the engine. - **A deployment that arrives while its instance is being deleted can start a process the emulator no longer tracks.** Same race as #353's app delete. - **`env.json` and `secrets.json` in `.prisma-composer/dev/` are shared by every app run from a directory, and `--fresh` wipes them.** One app's `--fresh` can drop rows another app in the same directory needs on its next redeploy. Pre-existing. Making them per app, like bucket objects (S1b moved those to `buckets///`), would fix it. - **A service that never listens fails `prisma dev` under prisma/composer#353** (the cron scheduler; `examples/store`). Raised with Will; the fix belongs in #353. - **`prisma dev url` doesn't retry an emulator that is briefly unreachable.** Retry policy is #353's (`withEmulatorRetry`); apply it here once that settles. - **`prisma dev url` loads the config's extensions, and so Alchemy, to read one JSON file.** It only matters when a script calls it in a loop. - **A run-then-stop mode for CI: `prisma dev --exec ""`, like Firebase `emulators:exec`.** It would bring the app up as `--detach` does, run the command, stop the app and exit with the command's code. Likely next step after S6. +- **Commands that print nothing to a pipe once prisma-cli's piped output becomes plain data.** Each needs data lines in its owning repo: + - Composer `prisma deploy`: the deployed addresses (and URLs where public), one per line. + - ORM (`@prisma/orm-toolchain`): `migration list` and `migration ref list` (one row per item), `migration status` (`keyvalue`), `migration log`, `migration show`, `migration plan` and `migration new` (the created migration's id or path), `migration check`, `migration graph`, `migration ref set` and `migration ref delete`, `contract infer`, `contract format`, `contract print` (the printed contract is the data), `db init`, `db schema`, `db sign`, `db update`, `db verify`, `db migrate`, `orm init`. Matched by function name in the built bundle, so approximate. From 48032f2d5af88bd380e0e1074f9aa0c44ae41c62 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 15 Oct 2026 17:59:51 +0200 Subject: [PATCH 10/10] test(integration): a failed placeholder instance fails the detach proof at its cause reserveServicePortsBelow read .port from any response to POST /instances. It now throws with the emulator's status and body when creating a placeholder fails. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- test/integration/test/local-dev-detach.integration.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/integration/test/local-dev-detach.integration.ts b/test/integration/test/local-dev-detach.integration.ts index fe6518faa..a43bc93c9 100644 --- a/test/integration/test/local-dev-detach.integration.ts +++ b/test/integration/test/local-dev-detach.integration.ts @@ -189,6 +189,11 @@ async function reserveServicePortsBelow(port: number): Promise { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ labels: { purpose: 'port-skip' } }), }); + if (!created.ok) { + throw new Error( + `creating a placeholder instance failed (${String(created.status)}): ${await created.text()}`, + ); + } given = ((await created.json()) as { port: number }).port; } }