diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b7cf1c4f..037fb0f3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,25 @@ jobs: # under CI concurrency. - name: Build packages run: pnpm build + # `@prisma/dev` (the ORM's local-Postgres server — the local-dev + # integration proofs' postgres-main daemon hosts it programmatically + # via `startPrismaDevServer`) downloads its database engine binaries + # on its first-ever invocation on a machine. A dev workstation + # accumulates that cache across sessions; a CI runner starts genuinely + # cold every time. Isolating the download in its own step (the `prisma` + # CLI drives the same `@prisma/dev` engine cache) gives it a clear, + # attributable failure (network egress to the engine host, disk + # space, ...) instead of surfacing buried inside a 5-service + # `alchemy deploy` three provider frames deep — which is exactly what + # made the failure this step's own commit message references + # undiagnosable. The instance itself is disposable; only its download + # populates the shared engine cache every later invocation (the + # integration proofs') reuses. + - name: Warm the prisma dev engine cache + working-directory: test/integration + run: | + pnpm exec prisma dev --name ci-engine-warmup --detach + pnpm exec prisma dev rm ci-engine-warmup --force - name: Test env: STATE_TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres diff --git a/packages/0-framework/1-core/core/src/control/app-config.ts b/packages/0-framework/1-core/core/src/control/app-config.ts index f80ea4ea2..bbf842646 100644 --- a/packages/0-framework/1-core/core/src/control/app-config.ts +++ b/packages/0-framework/1-core/core/src/control/app-config.ts @@ -145,6 +145,8 @@ export interface LocalTargetAttachInput { } export interface LocalTargetAttachment { + /** Start every stopped service from its last deployment (the session-resume signal — a no-op converge cannot start anything). */ + startServices(): Promise; /** Every service's local endpoint, for the front door. */ endpoints(): Promise; /** Merged, line-oriented log stream across the app's services (including services that appear after later converges). Ends when `signal` aborts. */ diff --git a/packages/0-framework/3-tooling/cli/package.json b/packages/0-framework/3-tooling/cli/package.json index 655c6c484..e0a12777a 100644 --- a/packages/0-framework/3-tooling/cli/package.json +++ b/packages/0-framework/3-tooling/cli/package.json @@ -19,6 +19,7 @@ "@internal/core": "workspace:0.2.0", "@internal/foundation": "workspace:0.2.0", "c12": "^3.3.4", + "chokidar": "^4.0.3", "clipanion": "^3.2.1" }, "devDependencies": { diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/main.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/main.test.ts index 9889068c6..3385eee71 100644 --- a/packages/0-framework/3-tooling/cli/src/__tests__/main.test.ts +++ b/packages/0-framework/3-tooling/cli/src/__tests__/main.test.ts @@ -10,6 +10,7 @@ describe('parseArgs() (clipanion-backed)', () => { name: undefined, stage: undefined, production: false, + fresh: false, }); }); @@ -21,6 +22,7 @@ describe('parseArgs() (clipanion-backed)', () => { name: 'ci-run', stage: 'prod', production: false, + fresh: false, }, ); expect(parseArgs(['deploy', '--stage', 'prod', 'src/service.ts', '--name', 'ci-run'])).toEqual({ @@ -29,6 +31,7 @@ describe('parseArgs() (clipanion-backed)', () => { name: 'ci-run', stage: 'prod', production: false, + fresh: false, }); }); @@ -39,6 +42,7 @@ describe('parseArgs() (clipanion-backed)', () => { name: undefined, stage: undefined, production: true, + fresh: false, }); }); @@ -77,6 +81,37 @@ describe('parseArgs() (clipanion-backed)', () => { name: '', stage: undefined, production: false, + fresh: false, }); }); + + test('parses a bare dev invocation', () => { + expect(parseArgs(['dev', 'src/service.ts'])).toEqual({ + command: 'dev', + entry: 'src/service.ts', + name: undefined, + stage: undefined, + production: false, + fresh: false, + }); + }); + + test('parses dev --name and --fresh', () => { + expect(parseArgs(['dev', 'src/service.ts', '--name', 'ci-run', '--fresh'])).toEqual({ + command: 'dev', + entry: 'src/service.ts', + name: 'ci-run', + stage: undefined, + production: false, + fresh: true, + }); + }); + + test('throws UsageError when dev is passed --stage', () => { + expect(() => parseArgs(['dev', 'src/service.ts', '--stage', 'prod'])).toThrow(UsageError); + }); + + test('throws UsageError when dev is passed --production', () => { + expect(() => parseArgs(['dev', 'src/service.ts', '--production'])).toThrow(UsageError); + }); }); diff --git a/packages/0-framework/3-tooling/cli/src/dev/__tests__/generate-dev-stack.test.ts b/packages/0-framework/3-tooling/cli/src/dev/__tests__/generate-dev-stack.test.ts new file mode 100644 index 000000000..4d685a4e2 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/dev/__tests__/generate-dev-stack.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'bun:test'; +import { renderDevStackFile } from '../generate-dev-stack.ts'; + +describe('renderDevStackFile()', () => { + test('renders the config + app imports (relative), resolved dev providers, localState(), no report', () => { + const content = renderDevStackFile({ + entryPath: '/repo/app/module.ts', + cwd: '/repo/app', + configPath: '/repo/app/prisma-composer.config.ts', + name: 'app', + assembled: { + bundles: { + web: { dir: '/repo/app/modules/web/dist/bundle', entry: 'server.js' }, + }, + }, + }); + + expect(content).toContain("import { lower } from '@prisma/composer/deploy';"); + expect(content).toContain("import { deserializeContainers } from '@prisma/composer/config';"); + expect(content).toContain( + "import { DEV_DIR, localTargetProviders, resolveLocalTargets } from '@prisma/composer/local-target';", + ); + expect(content).toContain("import { localState } from 'alchemy/State/LocalState';"); + expect(content).toContain('import config from "../../prisma-composer.config.ts";'); + expect(content).toContain('import app from "../../module.ts";'); + // The one orchestration point (spec § 3 REVISED): containers + // deserialized, `localTarget` thunks resolved by top-level await, + // providers + state passed explicitly — and no `dev` flag, which no + // longer exists on LowerOptions. + expect(content).toContain( + 'const containers = deserializeContainers(config.extensions, process.env);', + ); + expect(content).toContain('const resolved = await resolveLocalTargets(config);'); + expect(content).toContain('const devDir = path.join(process.cwd(), DEV_DIR);'); + expect(content).toContain('lower(app, config, {'); + expect(content).toContain('name: "app"'); + expect(content).toContain('providers: localTargetProviders(resolved, containers, devDir)'); + expect(content).toContain('state: localState()'); + expect(content).not.toContain('dev: true'); + expect(content).toContain( + '"web": { dir: "/repo/app/modules/web/dist/bundle", entry: "server.js" }', + ); + // Dev prints its own front door — no report hook in the generated module. + expect(content).not.toContain('report:'); + expect(content).not.toContain('deploymentReport'); + }); + + test('the header comment names the --stage dev reproduction command', () => { + const content = renderDevStackFile({ + entryPath: '/repo/app/module.ts', + cwd: '/repo/app', + configPath: '/repo/app/prisma-composer.config.ts', + name: 'app', + assembled: { bundles: {} }, + }); + + expect(content).toContain('alchemy deploy .prisma-composer/dev/alchemy.run.ts --stage dev'); + }); +}); diff --git a/packages/0-framework/3-tooling/cli/src/dev/__tests__/run-dev.test.ts b/packages/0-framework/3-tooling/cli/src/dev/__tests__/run-dev.test.ts new file mode 100644 index 000000000..9c514c22c --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/dev/__tests__/run-dev.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'bun:test'; +import { renderFrontDoor } from '../run-dev.ts'; + +describe('renderFrontDoor()', () => { + test('starts with "[dev] ready:", then orders by address depth (fewest dots first), then lexicographic', () => { + const lines = renderFrontDoor([ + { address: 'storefront.web', url: 'http://localhost:3002' }, + { address: 'web', url: 'http://localhost:3000' }, + { address: 'api', url: 'http://localhost:3001' }, + { address: 'storefront.admin', url: 'http://localhost:3003' }, + ]); + + expect(lines).toEqual([ + '[dev] ready:', + '[dev] api http://localhost:3001', + '[dev] web http://localhost:3000', + '[dev] storefront.admin http://localhost:3003', + '[dev] storefront.web http://localhost:3002', + ]); + }); + + test('an empty endpoint list still prints the ready line alone', () => { + expect(renderFrontDoor([])).toEqual(['[dev] ready:']); + }); +}); diff --git a/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts b/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts new file mode 100644 index 000000000..da9756027 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { startWatch, watchTargetsFrom } from '../watch.ts'; + +function tempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'cli-watch-')); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Polls until `cond` holds or the deadline passes — assertions stay exact, only the waiting adapts to a loaded runner. */ +async function until(cond: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (!cond() && Date.now() < deadline) await sleep(25); +} + +describe('watchTargetsFrom()', () => { + test('a bundle with no watch field is reported unwatchable, not a target', () => { + const { targets, unwatchable } = watchTargetsFrom({ + web: { dir: '/tmp/web', entry: 'server.js' }, + }); + expect(targets).toEqual([]); + expect(unwatchable).toEqual(['web']); + }); +}); + +describe('startWatch()', () => { + test('debounces a burst of changes across several files into one callback, 300ms after the last change', async () => { + const dir = tempDir(); + const fileA = path.join(dir, 'a.txt'); + const fileB = path.join(dir, 'b.txt'); + fs.writeFileSync(fileA, 'a'); + fs.writeFileSync(fileB, 'b'); + + let calls = 0; + const watch = startWatch( + [ + { address: 'a', paths: [fileA] }, + { address: 'b', paths: [fileB] }, + ], + () => { + calls += 1; + }, + ); + await watch.ready; + + try { + // A burst across both files, all inside the 300ms debounce window. + fs.writeFileSync(fileA, 'a2'); + await sleep(50); + fs.writeFileSync(fileB, 'b2'); + await sleep(50); + fs.writeFileSync(fileA, 'a3'); + + // Still inside the debounce window from the last write — no callback yet. + await sleep(100); + expect(calls).toBe(0); + + // Past the 300ms debounce from the last write. + await until(() => calls === 1, 2000); + expect(calls).toBe(1); + } finally { + watch.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 10_000); + + test('a stopped watch fires no further callbacks', async () => { + const dir = tempDir(); + const file = path.join(dir, 'a.txt'); + fs.writeFileSync(file, 'a'); + + let calls = 0; + const watch = startWatch([{ address: 'a', paths: [file] }], () => { + calls += 1; + }); + watch.stop(); + + fs.writeFileSync(file, 'a2'); + await sleep(500); + expect(calls).toBe(0); + fs.rmSync(dir, { recursive: true, force: true }); + }, 10_000); + + test('survives a delete-then-recreate rebuild of the watched file (rm -rf && bun build --outfile)', async () => { + // A single-file `fs.watch` bound to the original inode goes silently + // dead after the file is unlinked and a new one created at the same + // path — exactly what `rm -rf dist && bun build --outfile dist/x.mjs` + // does on every rebuild. Found live against examples/store's catalog + // service during the S5 proving pass; the fix at the time was a + // hand-rolled parent-directory watch, since replaced by chokidar + // (design tip 74272d8), which absorbs this class of rename/inode-swap + // itself. The regression this test guards against is unchanged, so its + // assertions stay exactly as they were — only the engine underneath + // changed. + const dir = tempDir(); + const file = path.join(dir, 'server.mjs'); + fs.writeFileSync(file, 'v1'); + + let calls = 0; + const watch = startWatch([{ address: 'catalog', paths: [file] }], () => { + calls += 1; + }); + // chokidar attaches its OS-level watches asynchronously — a change made + // before `ready` can be missed entirely, so wait for real attachment + // rather than guessing a grace period. + await watch.ready; + + try { + fs.rmSync(file); + fs.writeFileSync(file, 'v2'); + await until(() => calls === 1, 3000); + expect(calls).toBe(1); + + // A SECOND delete+recreate — the exact case that broke a + // file-bound (not directory-bound) watch. + fs.rmSync(file); + fs.writeFileSync(file, 'v3'); + await until(() => calls === 2, 3000); + expect(calls).toBe(2); + } finally { + watch.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 10_000); +}); diff --git a/packages/0-framework/3-tooling/cli/src/dev/generate-dev-stack.ts b/packages/0-framework/3-tooling/cli/src/dev/generate-dev-stack.ts new file mode 100644 index 000000000..d118521bd --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/dev/generate-dev-stack.ts @@ -0,0 +1,100 @@ +/** + * Local-dev spec § 6 `generate-dev-stack.ts`: like generate-stack.ts but at + * `.prisma-composer/dev/alchemy.run.ts`, always Alchemy stage `dev`, using + * Alchemy's own `localState()` and no `report` — dev prints its own front + * door (run-dev.ts), so core's presentation-free lower() gets nothing to + * call back into. The generated module IS the one orchestration point where + * dev provenance is resolved (spec § 3 REVISED): it deserializes the + * containers, top-level-awaits `resolveLocalTargets(config)` (the lazy + * `localTarget` thunks, ADR-0041), and passes + * `providers: localTargetProviders(...)` + `state: localState()` explicitly + * — `lower()` learns nothing about dev. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { AssembledServices } from '@internal/assemble'; + +export interface DevStackFileInput { + /** Absolute path to the entry module (the app's default-exported node). */ + readonly entryPath: string; + /** The process's cwd — where `.prisma-composer/dev/` is written. */ + readonly cwd: string; + /** Absolute path to the discovered `prisma-composer.config.ts` — imported by relative path. */ + readonly configPath: string; + readonly name: string; + readonly assembled: AssembledServices; +} + +const DEV_GENERATED_DIR = '.prisma-composer/dev'; +const DEV_GENERATED_FILE = 'alchemy.run.ts'; + +/** A relative import specifier from `.prisma-composer/dev/alchemy.run.ts` to `target` (posix separators). */ +function relativeImportSpecifier(generatedDir: string, target: string): string { + const rel = path.relative(generatedDir, target).split(path.sep).join('/'); + return rel.startsWith('.') ? rel : `./${rel}`; +} + +function quote(value: string): string { + return JSON.stringify(value); +} + +function renderBundle(bundle: { dir: string; entry: string }): string { + return `{ dir: ${quote(bundle.dir)}, entry: ${quote(bundle.entry)} }`; +} + +function renderOptions(input: DevStackFileInput): string { + const lines: string[] = []; + lines.push(` name: ${quote(input.name)},`); + + lines.push(' bundles: {'); + for (const [id, bundle] of Object.entries(input.assembled.bundles)) { + lines.push(` ${quote(id)}: ${renderBundle(bundle)},`); + } + lines.push(' },'); + + lines.push(' providers: localTargetProviders(resolved, containers, devDir),'); + lines.push(' state: localState(),'); + + return lines.join('\n'); +} + +/** Renders the dev stack module's source (tests assert on it without touching disk). */ +export function renderDevStackFile(input: DevStackFileInput): string { + const generatedDir = path.join(input.cwd, DEV_GENERATED_DIR); + const appImport = relativeImportSpecifier(generatedDir, input.entryPath); + const configImport = relativeImportSpecifier(generatedDir, input.configPath); + + return `// Generated by \`prisma-composer dev\` — overwritten on every run; do not +// edit by hand. Independently runnable from ${quote(input.cwd)}: +// +// alchemy deploy ${DEV_GENERATED_DIR}/${DEV_GENERATED_FILE} --stage dev +// +// bisects a CLI bug from an Alchemy bug (deploy-cli.md § Implementation decisions). +import * as path from 'node:path'; +import { deserializeContainers } from '@prisma/composer/config'; +import { lower } from '@prisma/composer/deploy'; +import { DEV_DIR, localTargetProviders, resolveLocalTargets } from '@prisma/composer/local-target'; +import { localState } from 'alchemy/State/LocalState'; +import config from ${quote(configImport)}; +import app from ${quote(appImport)}; + +const containers = deserializeContainers(config.extensions, process.env); +const resolved = await resolveLocalTargets(config); +const devDir = path.join(process.cwd(), DEV_DIR); + +export default lower(app, config, { +${renderOptions(input)} +}); +`; +} + +/** Writes the dev stack file, returning its absolute path. */ +export function writeDevStackFile(input: DevStackFileInput): string { + const generatedDir = path.join(input.cwd, DEV_GENERATED_DIR); + fs.mkdirSync(generatedDir, { recursive: true }); + const filePath = path.join(generatedDir, DEV_GENERATED_FILE); + fs.writeFileSync(filePath, renderDevStackFile(input)); + return filePath; +} + +export const DEV_STACK_RELATIVE_PATH = path.join(DEV_GENERATED_DIR, DEV_GENERATED_FILE); diff --git a/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts new file mode 100644 index 000000000..0564c0bb7 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts @@ -0,0 +1,314 @@ +/** + * Local-dev spec § 6 `run-dev.ts`: `prisma-composer dev ` — steps 1–6 + * of `run()` reused via pipeline.ts, then the dev-only pipeline: capability + * check, containers, `--fresh` teardown, preflight, emulators, converge + * against a generated dev stack file, attach (front door + merged logs), + * watch loop until interrupted. + */ +import * as path from 'node:path'; +import type { RunAssembler } from '@internal/assemble'; +import type { ContainerInstance, PrismaAppConfig } from '@internal/core/config'; +import { containerEnv } from '@internal/core/config'; +import type { LocalTargetAttachment, LocalTargetDescriptor } from '@internal/core/local-target'; +import { DEV_DIR, resolveLocalTargets } from '@internal/core/local-target'; +import { CliError } from '../cli-error.ts'; +import { type PipelineDeps, runPipeline } from '../pipeline.ts'; +import { type RunAlchemyInput, runAlchemy } from '../run-alchemy.ts'; +import { DEV_STACK_RELATIVE_PATH, writeDevStackFile } from './generate-dev-stack.ts'; +import { startWatch, watchTargetsFrom } from './watch.ts'; + +/** The subset of `ParsedArgs` `run()` hands off for the `dev` command. */ +export interface DevArgs { + readonly entry: string; + readonly name: string | undefined; + readonly fresh: boolean; +} + +/** Injectable seams — the same shapes `run()`'s `RunDeps` offers deploy/destroy. */ +export interface DevRunDeps { + readonly runAssembler?: RunAssembler; + readonly alchemy?: (input: RunAlchemyInput) => number; + readonly config?: PrismaAppConfig; +} + +function toCliError(error: unknown): CliError { + return error instanceof CliError + ? error + : new CliError(error instanceof Error ? error.message : String(error)); +} + +/** `[dev] ready:` then one line per endpoint, ordered by address depth (fewest dots first) then lexicographic. Exported for tests. */ +export function renderFrontDoor( + endpoints: readonly { readonly address: string; readonly url: string }[], +): readonly string[] { + const sorted = [...endpoints].sort((a, b) => { + const depthA = a.address.split('.').length; + const depthB = b.address.split('.').length; + if (depthA !== depthB) return depthA - depthB; + return a.address < b.address ? -1 : a.address > b.address ? 1 : 0; + }); + return ['[dev] ready:', ...sorted.map((e) => `[dev] ${e.address} ${e.url}`)]; +} + +function printFrontDoor( + endpoints: readonly { readonly address: string; readonly url: string }[], +): void { + for (const line of renderFrontDoor(endpoints)) console.log(line); +} + +const EMULATOR_RETRY_ATTEMPTS = 5; +const EMULATOR_RETRY_DELAY_MS = 500; + +/** An emulator admin call right after a converge that just PUT dozens of resources through the same daemon can hit a transient refused/reset connection — a brief loopback hiccup under load, not a real failure. Retried before giving up. Applies to every attach admin call the dev session makes (`startServices`, `endpoints`). */ +async function withEmulatorRetry(call: () => Promise): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= EMULATOR_RETRY_ATTEMPTS; attempt += 1) { + try { + return await call(); + } catch (error) { + lastError = error; + if (attempt < EMULATOR_RETRY_ATTEMPTS) { + await new Promise((resolve) => setTimeout(resolve, EMULATOR_RETRY_DELAY_MS)); + } + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} + +async function mergedEndpoints( + attachments: readonly LocalTargetAttachment[], +): Promise { + const lists = await Promise.all(attachments.map((a) => withEmulatorRetry(() => a.endpoints()))); + return lists.flat(); +} + +/** Pumps every attachment's merged log stream to stdout, each line prefixed `[] `, until `signal` aborts. */ +function pumpLogs(attachments: readonly LocalTargetAttachment[], signal: AbortSignal): void { + for (const attachment of attachments) { + void (async () => { + try { + for await (const { service, line } of attachment.logs(signal)) { + if (signal.aborted) return; + console.log(`[${service}] ${line}`); + } + } catch (error) { + // Quiet only for the abort that ends every session; a genuinely + // broken stream is said out loud instead of logs just going silent. + if (!signal.aborted) { + console.error( + `[dev] log stream failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + })(); + } +} + +/** Runs the full dev pipeline; returns the process exit code. */ +export async function runDev(args: DevArgs, deps: DevRunDeps = {}): Promise { + if (process.platform === 'win32') { + throw new CliError('local dev is not supported on Windows yet.'); + } + + const cwd = process.cwd(); + const devDir = path.join(cwd, DEV_DIR); + + // 1–6. The shared prefix (pipeline.ts): config discovery/load, entry load, + // Load, registry coverage, name resolution, assemble. + const pipelineDeps: PipelineDeps = { runAssembler: deps.runAssembler, config: deps.config }; + const { configPath, config, entryModule, graph, name, assembled } = await runPipeline( + args.entry, + args.name, + cwd, + pipelineDeps, + ); + + // 2. Dev-capability check — resolve every non-build-only extension's lazy + // `localTarget` thunk ONCE (ADR-0041's lazy reference); its pinned error + // names any extension without local-target support, and build-only + // extensions are exempt inside it. Every subsequent hook call runs off + // this resolved map. + let resolved: ReadonlyMap; + try { + resolved = await resolveLocalTargets(config); + } catch (error) { + throw toCliError(error); + } + + // 3. Containers — purely local, resolved before anything else can fail. + const containers = new Map(); + for (const [id, dev] of resolved) { + try { + containers.set(id, await dev.container.ensure({ appName: name, stage: undefined })); + } catch (error) { + throw toCliError(error); + } + } + + // 4. `--fresh`: teardown every participant's dev instance, then continue cold. + if (args.fresh) { + for (const [id, dev] of resolved) { + if (dev.teardown === undefined) continue; + try { + await dev.teardown({ container: containers.get(id), stage: undefined }); + } catch (error) { + throw toCliError(error); + } + } + } + + // 5. Preflight — always (dev has no deploy/destroy split). + for (const [id, dev] of resolved) { + if (dev.preflight === undefined) continue; + try { + await dev.preflight({ graph, container: containers.get(id), stage: undefined }); + } catch (error) { + throw toCliError(error); + } + } + + // 6. Emulators — ensure the daemons this topology's node kinds need. + for (const [id, dev] of resolved) { + if (dev.emulators === undefined) continue; + try { + await dev.emulators({ graph, container: containers.get(id), devDir }); + } catch (error) { + throw toCliError(error); + } + } + + const converge = (): number => { + const stackPath = writeDevStackFile({ + entryPath: entryModule.path, + cwd, + configPath, + name, + assembled, + }); + const status = (deps.alchemy ?? runAlchemy)({ + command: 'deploy', + stackFileRelativePath: DEV_STACK_RELATIVE_PATH, + cwd, + stage: 'dev', + containerEnv: containerEnv(containers), + }); + if (status !== 0) { + console.error(`\nGenerated stack file: ${stackPath}`); + console.error( + `Run \`alchemy deploy ${DEV_STACK_RELATIVE_PATH} --yes --stage dev\` from ${cwd} ` + + 'to reproduce this directly.', + ); + } + return status; + }; + + // 7. Write the dev stack file and converge. + const firstStatus = converge(); + if (firstStatus !== 0) return firstStatus; + + // 8. Attach: start every stopped service (session resume — a no-op converge + // cannot restart what a previous session's Ctrl-C stopped), then print the + // front door and pump merged logs. + const attachments: LocalTargetAttachment[] = []; + for (const [id, dev] of resolved) { + attachments.push(await dev.attach({ container: containers.get(id), devDir })); + } + // On a partial failure, put the already-started attachments back to + // stopped — a session that never began should leave the machine exactly as + // the previous Ctrl-C did, not half-running. + const started: LocalTargetAttachment[] = []; + for (const attachment of attachments) { + try { + await withEmulatorRetry(() => attachment.startServices()); + started.push(attachment); + } catch (error) { + await Promise.all(started.map((a) => a.stopServices().catch(() => undefined))); + throw toCliError(error); + } + } + printFrontDoor(await mergedEndpoints(attachments)); + + const logsController = new AbortController(); + pumpLogs(attachments, logsController.signal); + + // 9. Watch loop until SIGINT/SIGTERM: rebuild → re-assemble → re-converge; + // a converge failure keeps the running app and keeps watching. + const { targets, unwatchable } = watchTargetsFrom(assembled.bundles); + for (const address of unwatchable) { + console.log(`[dev] ${address} has no watchable inputs`); + } + + const watch = startWatch(targets, () => { + // The whole rebuild is inside one try/catch: this runs fire-and-forget, + // so anything escaping it would be an unhandled rejection killing the + // process — the exact opposite of "a converge failure keeps the running + // app and keeps watching". + void (async () => { + try { + const rePipeline = await runPipeline(args.entry, args.name, cwd, pipelineDeps); + writeDevStackFile({ + entryPath: rePipeline.entryModule.path, + cwd, + configPath: rePipeline.configPath, + name: rePipeline.name, + assembled: rePipeline.assembled, + }); + const status = (deps.alchemy ?? runAlchemy)({ + command: 'deploy', + stackFileRelativePath: DEV_STACK_RELATIVE_PATH, + cwd, + stage: 'dev', + containerEnv: containerEnv(containers), + }); + if (status !== 0) { + console.error('[dev] converge failed — the running app is untouched; still watching.'); + return; + } + printFrontDoor(await mergedEndpoints(attachments)); + } catch (error) { + console.error( + `[dev] rebuild failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + })(); + }); + // A rebuild finishing before the OS-level watches attach would otherwise + // be missed entirely — wait until watching is real before handing over. + await watch.ready; + + await new Promise((resolve) => { + let stopping = false; + + const finish = (): void => { + if (stopping) return; + stopping = true; + console.log("[dev] stopping — the app's services are stopping; emulators and data stay up."); + watch.stop(); + void (async () => { + logsController.abort(); + for (const attachment of attachments) { + await attachment.stopServices().catch(() => undefined); + } + console.log('[dev] stopped.'); + resolve(); + })(); + }; + + // alchemy's own library code (imported transitively while loading the + // app's config/providers) registers its own process-level SIGINT/SIGTERM + // listeners for ITS OWN in-process resource bookkeeping — irrelevant + // here, since the actual converge runs in a separate spawned `alchemy` + // child process (run-alchemy.ts), never in this one. Left in place, + // whichever of its listeners runs first can call process.exit() + // synchronously and tear this process down before the watch loop's own + // async cleanup (stopping the app's services) ever gets a turn. This is + // this process's OWN signal handling from here on: strip whatever else + // is registered and become the only listener. + process.removeAllListeners('SIGINT'); + process.removeAllListeners('SIGTERM'); + process.on('SIGINT', finish); + process.on('SIGTERM', finish); + }); + + return 0; +} diff --git a/packages/0-framework/3-tooling/cli/src/dev/watch.ts b/packages/0-framework/3-tooling/cli/src/dev/watch.ts new file mode 100644 index 000000000..b97e9b0a5 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/dev/watch.ts @@ -0,0 +1,138 @@ +/** + * Local-dev spec § 6 `watch.ts`: watches each assembled bundle's declared + * `watch` paths (a directory watched recursively, a file watched through + * its parent — see `startWatch`), debounced 300 ms and coalesced across + * every service — one edit across several files/services still fires + * exactly one rebuild. + * + * The watch ENGINE is `chokidar` v4 (operator decision, design tip 74272d8: + * don't-reinvent-the-wheel beats the no-new-deps contract here — it absorbs + * the atomic-rename class and the cross-platform recursive-watch + * differences; v4 is pure JS, no native code, no glob surface — irrelevant + * here anyway, since every target is a literal file or directory path, + * never a pattern). The parent-directory indirection for FILE targets stays + * OURS (`startWatch` explains why), as does the debounce: chokidar's own + * `awaitWriteFinish` is a per-file "has this file's size stopped changing" + * poll, a different semantic from "coalesce a burst across many files into + * one callback," which is what the dev loop actually needs. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { Bundle } from '@internal/core/deploy'; +import chokidar, { type FSWatcher } from 'chokidar'; + +const DEBOUNCE_MS = 300; + +export interface WatchTarget { + readonly address: string; + readonly paths: readonly string[]; +} + +/** Bundles → watch targets, plus the addresses with nothing watchable (the pinned one-line startup note). */ +export function watchTargetsFrom(bundles: Readonly>): { + readonly targets: readonly WatchTarget[]; + readonly unwatchable: readonly string[]; +} { + const targets: WatchTarget[] = []; + const unwatchable: string[] = []; + for (const [address, bundle] of Object.entries(bundles)) { + const paths = bundle.watch; + if (paths === undefined || paths.length === 0) { + unwatchable.push(address); + continue; + } + targets.push({ address, paths }); + } + return { targets, unwatchable }; +} + +export interface WatchHandle { + /** Resolves once chokidar's OS-level watches are attached — a change made before this can be missed entirely. Also resolves on `stop()` so an awaiting caller can never hang. */ + readonly ready: Promise; + stop(): void; +} + +/** + * Watches every target's paths via chokidar, debounced 300 ms and coalesced + * across every service, invoking `onChange` once per burst. + * + * File targets are watched THROUGH their parent directory, filtered to the + * exact path: a watch bound directly to a file dies with the file's inode + * on Linux, so `rm -rf dist && bun build --outfile dist/x.mjs` — every + * rebuild's shape — would go silently unobserved after the first delete + * (chokidar absorbs atomic renames, not unlink+recreate of a directly + * watched file; proven by the delete-recreate test failing on Linux CI + * only). Directory targets are watched recursively as themselves; a + * nonexistent path is treated as a file target, so it starts reporting the + * moment something creates it. + */ +export function startWatch(targets: readonly WatchTarget[], onChange: () => void): WatchHandle { + let timer: ReturnType | undefined; + + const trigger = (): void => { + if (timer !== undefined) clearTimeout(timer); + timer = setTimeout(() => { + timer = undefined; + onChange(); + }, DEBOUNCE_MS); + }; + + const fileTargets = new Set(); + const directoryRoots = new Set(); + const parentRoots = new Set(); + for (const target of targets) { + for (const p of target.paths) { + const abs = path.resolve(p); + let isDirectory = false; + try { + isDirectory = fs.statSync(abs).isDirectory(); + } catch { + // nonexistent → file target via its parent + } + if (isDirectory) { + directoryRoots.add(abs); + } else { + fileTargets.add(abs); + parentRoots.add(path.dirname(abs)); + } + } + } + + // An 'error' emitted with no listener throws and would take the whole dev + // session down — a watch error (EMFILE, a vanished directory) is worth a + // line, not the process. + const reportError = (error: unknown): void => { + console.error(`[dev] watch error: ${error instanceof Error ? error.message : String(error)}`); + }; + + const watchers: FSWatcher[] = []; + if (directoryRoots.size > 0) { + const directoryWatcher = chokidar.watch([...directoryRoots], { ignoreInitial: true }); + directoryWatcher.on('all', () => trigger()); + directoryWatcher.on('error', reportError); + watchers.push(directoryWatcher); + } + if (parentRoots.size > 0) { + const parentWatcher = chokidar.watch([...parentRoots], { ignoreInitial: true, depth: 0 }); + parentWatcher.on('all', (_event, eventPath) => { + if (fileTargets.has(path.resolve(eventPath))) trigger(); + }); + parentWatcher.on('error', reportError); + watchers.push(parentWatcher); + } + + let markReady: () => void = () => {}; + const allReady = Promise.all( + watchers.map((watcher) => new Promise((resolve) => watcher.on('ready', () => resolve()))), + ).then(() => {}); + const ready = Promise.race([allReady, new Promise((resolve) => (markReady = resolve))]); + + return { + ready, + stop: () => { + if (timer !== undefined) clearTimeout(timer); + markReady(); + for (const watcher of watchers) void watcher.close(); + }, + }; +} diff --git a/packages/0-framework/3-tooling/cli/src/main.ts b/packages/0-framework/3-tooling/cli/src/main.ts index 1bf96d7ba..2908cdb9d 100644 --- a/packages/0-framework/3-tooling/cli/src/main.ts +++ b/packages/0-framework/3-tooling/cli/src/main.ts @@ -5,17 +5,15 @@ */ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { assembleServices, type RunAssembler } from '@internal/assemble'; -import { Load } from '@internal/core'; +import type { RunAssembler } from '@internal/assemble'; import type { ContainerInstance, PrismaAppConfig } from '@internal/core/config'; import { containerEnv } from '@internal/core/config'; import { Cli, Command, Option, UsageError } from 'clipanion'; import { CliError } from './cli-error.ts'; +import { runDev } from './dev/run-dev.ts'; import { GENERATED_STACK_RELATIVE_PATH, writeStackFile } from './generate-stack.ts'; -import { findConfigPathForEntry, loadAppConfig, missingConfigError } from './load-config.ts'; -import { loadEntry } from './load-entry.ts'; +import { type PipelineDeps, runPipeline } from './pipeline.ts'; import { type RunAlchemyInput, runAlchemy } from './run-alchemy.ts'; -import { validateRegistryCoverage } from './validate-coverage.ts'; import { validateStageName } from './validate-stage.ts'; const BINARY_NAME = 'prisma-composer'; @@ -63,8 +61,32 @@ class DestroyCommand extends DeployCliCommand { readonly action = 'destroy' as const; } +/** ``/`--name`/`--fresh` only — no `--stage`/`--production` (local-dev spec § 6: a working directory has exactly one dev instance, no stages). */ +class DevCommand extends Command { + static override paths = [['dev']]; + static override usage = Command.Usage({ + description: + "Bring up the application whose root node is 's default export, entirely on this machine, credential-free.", + examples: [['Run an app locally', '$0 dev src/service.ts']], + }); + + entry = Option.String({ name: 'entry' }); + + name = Option.String('--name', { + description: "Override the root node's name — the dev instance's application name.", + }); + + fresh = Option.Boolean('--fresh', false, { + description: 'Destroy the dev stack and wipe the dev state directory before starting.', + }); + + async execute(): Promise { + return 0; + } +} + function buildCli(): Cli { - return Cli.from([DeployCommand, DestroyCommand], { + return Cli.from([DeployCommand, DestroyCommand, DevCommand], { binaryName: BINARY_NAME, binaryLabel: 'The prisma-composer deploy CLI', }); @@ -81,11 +103,12 @@ function isUnknownSyntaxError(error: unknown): error is Error { } export interface ParsedArgs { - readonly command: 'deploy' | 'destroy'; + readonly command: 'deploy' | 'destroy' | 'dev'; readonly entry: string; readonly name: string | undefined; readonly stage: string | undefined; readonly production: boolean; + readonly fresh: boolean; } /** Exported for direct testing (main.test.ts) — not part of the package's public barrel (see index.ts). */ @@ -112,6 +135,18 @@ export function parseArgs(argv: readonly string[]): ParsedArgs { name: command.name, stage: command.stage, production: command.production, + fresh: false, + }; + } + + if (command instanceof DevCommand) { + return { + command: 'dev', + entry: command.entry, + name: command.name, + stage: undefined, + production: false, + fresh: command.fresh, }; } @@ -182,6 +217,11 @@ export async function run(argv: readonly string[], deps: RunDeps = {}): Promise< } throw error; } + + if (args.command === 'dev') { + return runDev(args, deps); + } + const stage = effectiveStage(args); if (stage !== undefined) validateStageName(stage); const cwd = process.cwd(); @@ -194,48 +234,24 @@ export async function run(argv: readonly string[], deps: RunDeps = {}): Promise< warnIfNoLocalDeployState(cwd); } - // 1. Find + load prisma-composer.config.ts — runs extension env validation before the entry import. - const resolvedEntryPath = path.resolve(cwd, args.entry); - const configPath = findConfigPathForEntry(resolvedEntryPath); - if (configPath === undefined) { - throw missingConfigError(resolvedEntryPath); - } - const config = deps.config ?? (await loadAppConfig(configPath)).config; - - // 2. Import the entry module; its default export must be a node. - const entryModule = await loadEntry(args.entry, cwd); - - // 3. Load — core's LoadError (unwired connection input, etc.) surfaces as-is. - const graph = Load(entryModule.root); - if (graph.root.node.kind !== 'module') { - throw new CliError( - 'The deploy root must be a module — wrap your service, e.g. ' + - "export default module('name', ({ provision }) => { provision(service); }).", - ); - } - - // 4. Registry coverage: every node/build in the graph has a matching descriptor in the config. - validateRegistryCoverage(graph, config); - - // 5. Resolve the name. - const name = args.name ?? entryModule.root.name; - if (name.length === 0) { - throw new CliError('The root node has no name — name it at authoring, or pass --name.'); - } - - // 6. Assemble each service through the config's registries. - let assembled: Awaited>; - try { - assembled = await assembleServices(graph, config, cwd, deps.runAssembler); - } catch (error) { - if (args.command === 'destroy' && error instanceof Error) { - throw new CliError( - `${error.message}\n\ndestroy evaluates the same stack program as deploy, which packages ` + - 'the built artifacts — so the app must be built first. Run the build, then retry the destroy.', - ); - } - throw error; - } + // 1–6. The shared prefix (pipeline.ts): config discovery/load, entry load, + // Load, registry coverage, name resolution, assemble. + const pipelineDeps: PipelineDeps = { runAssembler: deps.runAssembler, config: deps.config }; + const onAssembleError = + args.command === 'destroy' + ? (error: Error): CliError => + new CliError( + `${error.message}\n\ndestroy evaluates the same stack program as deploy, which packages ` + + 'the built artifacts — so the app must be built first. Run the build, then retry the destroy.', + ) + : undefined; + const { configPath, config, entryModule, graph, name, assembled } = await runPipeline( + args.entry, + args.name, + cwd, + pipelineDeps, + onAssembleError, + ); // 7. Resolve each extension's own container (e.g. Prisma Cloud's Project + // named-stage Branch) via its own descriptor — deploy ensures (creates if diff --git a/packages/0-framework/3-tooling/cli/src/pipeline.ts b/packages/0-framework/3-tooling/cli/src/pipeline.ts new file mode 100644 index 000000000..3fbaed50a --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/pipeline.ts @@ -0,0 +1,92 @@ +/** + * The shared prefix of `deploy`/`destroy`/`dev` (deploy-cli.md § The + * pipeline; local-dev spec § 6): config discovery/load, entry load, Load, + * registry coverage validation, name resolution, assemble. Deploy and dev + * diverge after this — deploy resolves containers/preflight/stack file + * against the hosted providers, dev against the local ones — so everything + * up to and including assemble lives here once, consumed verbatim by both + * `run()` (main.ts) and `runDev()` (dev/run-dev.ts), so the two pipelines + * cannot drift. + */ +import * as path from 'node:path'; +import { type AssembledServices, assembleServices, type RunAssembler } from '@internal/assemble'; +import type { Graph } from '@internal/core'; +import { Load } from '@internal/core'; +import type { PrismaAppConfig } from '@internal/core/config'; +import { CliError } from './cli-error.ts'; +import { findConfigPathForEntry, loadAppConfig, missingConfigError } from './load-config.ts'; +import { type LoadedEntry, loadEntry } from './load-entry.ts'; +import { validateRegistryCoverage } from './validate-coverage.ts'; + +/** Injectable seams so tests can drive the pipeline without a real wrapper build or config evaluation. */ +export interface PipelineDeps { + readonly runAssembler?: RunAssembler | undefined; + /** Substituted for the c12 evaluation of the discovered config file (discovery itself still runs). */ + readonly config?: PrismaAppConfig | undefined; +} + +export interface PipelineResult { + readonly configPath: string; + readonly config: PrismaAppConfig; + readonly entryModule: LoadedEntry; + readonly graph: Graph; + readonly name: string; + readonly assembled: AssembledServices; +} + +/** + * Runs config discovery/load, entry load, Load, registry coverage, name + * resolution, and assemble — steps 1–6 of `run()`. `onAssembleError`, when + * given, lets a caller decorate an assemble failure with command-specific + * guidance (destroy's "build first" hint) without this shared step knowing + * about any one command. + */ +export async function runPipeline( + entry: string, + overrideName: string | undefined, + cwd: string, + deps: PipelineDeps = {}, + onAssembleError?: (error: Error) => Error, +): Promise { + // 1. Find + load prisma-composer.config.ts — runs extension env validation before the entry import. + const resolvedEntryPath = path.resolve(cwd, entry); + const configPath = findConfigPathForEntry(resolvedEntryPath); + if (configPath === undefined) { + throw missingConfigError(resolvedEntryPath); + } + const config = deps.config ?? (await loadAppConfig(configPath)).config; + + // 2. Import the entry module; its default export must be a node. + const entryModule = await loadEntry(entry, cwd); + + // 3. Load — core's LoadError (unwired connection input, etc.) surfaces as-is. + const graph = Load(entryModule.root); + if (graph.root.node.kind !== 'module') { + throw new CliError( + 'The deploy root must be a module — wrap your service, e.g. ' + + "export default module('name', ({ provision }) => { provision(service); }).", + ); + } + + // 4. Registry coverage: every node/build in the graph has a matching descriptor in the config. + validateRegistryCoverage(graph, config); + + // 5. Resolve the name. + const name = overrideName ?? entryModule.root.name; + if (name.length === 0) { + throw new CliError('The root node has no name — name it at authoring, or pass --name.'); + } + + // 6. Assemble each service through the config's registries. + let assembled: AssembledServices; + try { + assembled = await assembleServices(graph, config, cwd, deps.runAssembler); + } catch (error) { + if (onAssembleError !== undefined && error instanceof Error) { + throw onAssembleError(error); + } + throw error; + } + + return { configPath, config, entryModule, graph, name, assembled }; +} diff --git a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/client.ts b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/client.ts index 5d03e659b..e20594927 100644 --- a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/client.ts +++ b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/client.ts @@ -65,6 +65,33 @@ function encodeSegment(segment: string): string { return encodeURIComponent(segment); } +const ADMIN_FETCH_ATTEMPTS = 3; +const ADMIN_FETCH_RETRY_DELAY_MS = 150; + +/** + * `fetch` for the loopback admin calls, retried on transient socket errors: + * a keep-alive reuse race (the runtime re-uses a pooled connection the + * daemon's HTTP server just closed idle — surfaces as "fetch failed" / + * "other side closed") or a briefly overloaded daemon. Every admin call is + * idempotent by design, so a short blind retry is safe. A caller-driven + * abort is never retried. + */ +async function adminFetch(url: string, init?: RequestInit): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= ADMIN_FETCH_ATTEMPTS; attempt += 1) { + try { + return await fetch(url, init); + } catch (error) { + if (init?.signal?.aborted === true) throw error; + lastError = error; + if (attempt < ADMIN_FETCH_ATTEMPTS) { + await new Promise((resolve) => setTimeout(resolve, ADMIN_FETCH_RETRY_DELAY_MS)); + } + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} + async function expectOk(res: Response): Promise { if (!res.ok) { const body = await res.text(); @@ -174,7 +201,7 @@ export function computeClient(opts: DaemonRootOptions = {}): ComputeClient { baseUrl, async health() { - const res = await expectOk(await fetch(`${baseUrl}/health`)); + const res = await expectOk(await adminFetch(`${baseUrl}/health`)); const body: unknown = await res.json(); if (!isHealthBody(body)) { throw new Error('malformed /health response from the compute emulator'); @@ -184,7 +211,7 @@ export function computeClient(opts: DaemonRootOptions = {}): ComputeClient { async ensureService(app, id) { const url = `${baseUrl}/apps/${encodeSegment(app)}/services/${encodeSegment(id)}`; - const res = await expectOk(await fetch(url, { method: 'PUT' })); + const res = await expectOk(await adminFetch(url, { method: 'PUT' })); const body: unknown = await res.json(); if (!isServiceReservation(body)) { throw new Error('malformed service-reservation response from the compute emulator'); @@ -195,7 +222,7 @@ export function computeClient(opts: DaemonRootOptions = {}): ComputeClient { async putDeployment(app, id, deployment) { const url = `${baseUrl}/apps/${encodeSegment(app)}/services/${encodeSegment(id)}/deployment`; await expectOk( - await fetch(url, { + await adminFetch(url, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(deployment), @@ -205,7 +232,7 @@ export function computeClient(opts: DaemonRootOptions = {}): ComputeClient { async listServices(app) { const url = `${baseUrl}/apps/${encodeSegment(app)}/services`; - const res = await expectOk(await fetch(url)); + const res = await expectOk(await adminFetch(url)); const body: unknown = await res.json(); if (!isServiceInfoArray(body)) { throw new Error('malformed services listing from the compute emulator'); @@ -215,7 +242,7 @@ export function computeClient(opts: DaemonRootOptions = {}): ComputeClient { async *followLogs(app, id, signal) { const url = `${baseUrl}/apps/${encodeSegment(app)}/services/${encodeSegment(id)}/logs?follow=1`; - const res = await expectOk(await fetch(url, signal ? { signal } : undefined)); + const res = await expectOk(await adminFetch(url, signal ? { signal } : undefined)); const body = res.body; if (!body) return; const reader = body.getReader(); @@ -233,17 +260,17 @@ export function computeClient(opts: DaemonRootOptions = {}): ComputeClient { async stopApp(app) { const url = `${baseUrl}/apps/${encodeSegment(app)}/stop`; - await expectOk(await fetch(url, { method: 'POST' })); + await expectOk(await adminFetch(url, { method: 'POST' })); }, async startApp(app) { const url = `${baseUrl}/apps/${encodeSegment(app)}/start`; - await expectOk(await fetch(url, { method: 'POST' })); + await expectOk(await adminFetch(url, { method: 'POST' })); }, async deleteApp(app) { const url = `${baseUrl}/apps/${encodeSegment(app)}`; - await expectOk(await fetch(url, { method: 'DELETE' })); + await expectOk(await adminFetch(url, { method: 'DELETE' })); }, }; } @@ -266,7 +293,7 @@ export function bucketsClient(opts: DaemonRootOptions = {}): BucketsClient { baseUrl, async health() { - const res = await expectOk(await fetch(`${baseUrl}/_pcdev/health`)); + const res = await expectOk(await adminFetch(`${baseUrl}/_pcdev/health`)); const body: unknown = await res.json(); if (!isHealthBody(body)) { throw new Error('malformed /_pcdev/health response from the buckets emulator'); @@ -277,7 +304,7 @@ export function bucketsClient(opts: DaemonRootOptions = {}): BucketsClient { async putBucket(app, name, dir) { const url = `${baseUrl}/_pcdev/apps/${encodeSegment(app)}/buckets/${encodeSegment(name)}`; await expectOk( - await fetch(url, { + await adminFetch(url, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ dir }), @@ -288,7 +315,7 @@ export function bucketsClient(opts: DaemonRootOptions = {}): BucketsClient { async putCredentials(app, accessKeyId, secretAccessKey) { const url = `${baseUrl}/_pcdev/apps/${encodeSegment(app)}/credentials`; await expectOk( - await fetch(url, { + await adminFetch(url, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ accessKeyId, secretAccessKey }), @@ -298,7 +325,7 @@ export function bucketsClient(opts: DaemonRootOptions = {}): BucketsClient { async deleteApp(app) { const url = `${baseUrl}/_pcdev/apps/${encodeSegment(app)}`; - await expectOk(await fetch(url, { method: 'DELETE' })); + await expectOk(await adminFetch(url, { method: 'DELETE' })); }, }; } @@ -357,7 +384,7 @@ export function postgresClient(opts: DaemonRootOptions = {}): PostgresClient { baseUrl, async health() { - const res = await expectOk(await fetch(`${baseUrl}/health`)); + const res = await expectOk(await adminFetch(`${baseUrl}/health`)); const body: unknown = await res.json(); if (!isHealthBody(body)) { throw new Error('malformed /health response from the postgres emulator'); @@ -368,7 +395,7 @@ export function postgresClient(opts: DaemonRootOptions = {}): PostgresClient { async ensureDatabase(app, id, prismaDevModulePath) { const url = `${baseUrl}/apps/${encodeSegment(app)}/databases/${encodeSegment(id)}`; const res = await expectOk( - await fetch(url, { + await adminFetch(url, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ prismaDevModulePath }), @@ -383,7 +410,7 @@ export function postgresClient(opts: DaemonRootOptions = {}): PostgresClient { async listDatabases(app) { const url = `${baseUrl}/apps/${encodeSegment(app)}/databases`; - const res = await expectOk(await fetch(url)); + const res = await expectOk(await adminFetch(url)); const body: unknown = await res.json(); if (!isDatabaseInfoArray(body)) { throw new Error('malformed databases listing from the postgres emulator'); @@ -393,7 +420,7 @@ export function postgresClient(opts: DaemonRootOptions = {}): PostgresClient { async deleteApp(app) { const url = `${baseUrl}/apps/${encodeSegment(app)}`; - await expectOk(await fetch(url, { method: 'DELETE' })); + await expectOk(await adminFetch(url, { method: 'DELETE' })); }, }; } diff --git a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts index 10c658dcc..42bad4642 100644 --- a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts +++ b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/postgres-main.ts @@ -143,9 +143,63 @@ function isPrismaDevModule(value: unknown): value is PrismaDevModule { * number matching the candidate we just tried — is what actually survives * that boundary. */ -function isPortConflict(err: unknown, port: number): boolean { - if (typeof err !== 'object' || err === null || !('port' in err)) return false; - return err.port === port; +/** + * The port a start refusal is about, when it is one of THIS attempt's ports. + * Duck-typed (`instanceof` fails across the dynamic-import boundary): every + * `@prisma/dev` port refusal — not-available, requested-twice, and + * belongs-to-another-server (its registry can claim a port no bind probe + * sees) — carries the offending port as `.port`, and any of the four ports + * we requested (database + the three aux listeners) can be the one refused. + */ +function portConflictOf(err: unknown, ports: readonly number[]): number | undefined { + if (typeof err !== 'object' || err === null || !('port' in err)) return undefined; + const port = err.port; + return typeof port === 'number' && ports.includes(port) ? port : undefined; +} + +function isPrismaDevServer(value: unknown): value is PrismaDevServer { + if (!isStringKeyedRecord(value) || typeof value['close'] !== 'function') return false; + const database = value['database']; + return isStringKeyedRecord(database) && typeof database['connectionString'] === 'string'; +} + +/** + * The live server behind a "name is already running" refusal, when there is + * one. A start that fails mid-boot (e.g. on a port another server's + * registry claims) can leave its state entry behind, so OUR OWN next + * attempt for the same name is refused. `@prisma/dev`'s + * `ServerAlreadyRunningError` carries the running server as a `.server` + * getter (duck-typed — `instanceof` fails across the dynamic-import + * boundary); its parent class (state entry exists but nothing actually + * runs) has no such getter, and a dead ghost resolves to null — both mean + * "nothing to adopt". + */ +async function adoptableServerOf(err: unknown): Promise { + if (!isStringKeyedRecord(err) || !('server' in err)) return undefined; + if (err['name'] !== 'ServerAlreadyRunningError') return undefined; + try { + const server: unknown = await err['server']; + return isPrismaDevServer(server) ? server : undefined; + } catch { + return undefined; + } +} + +function isNameAlreadyTaken(err: unknown): boolean { + return ( + isStringKeyedRecord(err) && + (err['name'] === 'ServerAlreadyRunningError' || err['name'] === 'ServerStateAlreadyExistsError') + ); +} + +/** The port of a postgres connection URL, when it parses as one. */ +function databasePortOf(connectionString: string): number | undefined { + try { + const port = Number(new URL(connectionString).port); + return Number.isInteger(port) && port > 0 ? port : undefined; + } catch { + return undefined; + } } /** @@ -324,8 +378,14 @@ function main(): void { return ports; } - async function smallestUnusedDatabasePort(min: number): Promise { - return getPort({ port: portNumbers(min, MAX_DATABASE_PORT), exclude: usedDatabasePorts() }); + async function smallestUnusedDatabasePort( + min: number, + alsoExclude: ReadonlySet = new Set(), + ): Promise { + return getPort({ + port: portNumbers(min, MAX_DATABASE_PORT), + exclude: [...usedDatabasePorts(), ...alsoExclude], + }); } /** Three fresh, mutually distinct ports for a server's own HTTP/shadow-database/streams listeners — never persisted, only unique right now. */ @@ -352,13 +412,31 @@ function main(): void { return { httpPort, shadowDatabasePort, streamsPort }; } - async function ensureDatabase( + const inflightEnsures = new Map>(); + + /** Concurrent PUTs for the same database coalesce onto one start — a second `startPrismaDevServer` for a name whose first start is still booting fails as "already running". */ + function ensureDatabase( app: string, id: string, prismaDevModulePath: string, ): Promise<{ url: string }> { - const appRec = getOrCreateApp(app); const instanceName = instanceNameFor(app, id); + const inflight = inflightEnsures.get(instanceName); + if (inflight) return inflight; + const run = ensureDatabaseSerialized(app, id, prismaDevModulePath, instanceName).finally(() => { + inflightEnsures.delete(instanceName); + }); + inflightEnsures.set(instanceName, run); + return run; + } + + async function ensureDatabaseSerialized( + app: string, + id: string, + prismaDevModulePath: string, + instanceName: string, + ): Promise<{ url: string }> { + const appRec = getOrCreateApp(app); const existingRecord = appRec.databases[id]; // Already live in THIS process — idempotent, nothing to do. @@ -369,12 +447,12 @@ function main(): void { const prismaDev = await importPrismaDev(prismaDevModulePath); const isFreshAllocation = existingRecord === undefined; - const maxAttempts = isFreshAllocation ? MAX_FRESH_PORT_CANDIDATES : 1; let dbPort = existingRecord?.databasePort ?? (await smallestUnusedDatabasePort(MIN_DATABASE_PORT)); + const conflicted = new Set(); for (let attempt = 1; ; attempt++) { - const aux = await allocateAuxPorts(new Set([dbPort])); + const aux = await allocateAuxPorts(new Set([dbPort, ...conflicted])); try { const server = await prismaDev.startPrismaDevServer({ name: instanceName, @@ -398,15 +476,55 @@ function main(): void { schedulePersist(); return { url }; } catch (err) { + if (isNameAlreadyTaken(err)) { + // Our own earlier attempt (this loop's, or a crashed run's) left the + // name behind — the name is namespaced to this app+database, so it + // is ours by construction. A live server is simply the goal already + // reached: adopt it. A dead state entry is cleared and the start + // retried. + const ghost = await adoptableServerOf(err); + if (ghost !== undefined) { + const url = ghost.database.connectionString; + runtimes.set(instanceName, ghost); + appRec.databases[id] = { + id, + instanceName, + databasePort: databasePortOf(url) ?? dbPort, + url, + }; + schedulePersist(); + return { url }; + } + if (attempt < MAX_FRESH_PORT_CANDIDATES) { + const internalState = await importPrismaDevInternalState(prismaDevModulePath); + await internalState.deleteServer(instanceName); + continue; + } + } + const conflictPort = portConflictOf(err, [ + dbPort, + aux.httpPort, + aux.shadowDatabasePort, + aux.streamsPort, + ]); + // The aux listener ports are never persisted, so a refusal of one is + // always retryable with fresh candidates. The DATABASE port is frozen + // once a record exists (endpoints in deploy state reference it) — + // only a fresh allocation may move it. const canRetryNextPort = - isFreshAllocation && isPortConflict(err, dbPort) && attempt < maxAttempts; + conflictPort !== undefined && + attempt < MAX_FRESH_PORT_CANDIDATES && + (conflictPort !== dbPort || isFreshAllocation); if (!canRetryNextPort) { const reason = err instanceof Error ? err.message : String(err); throw new Error( `postgres emulator failed to start database "${instanceName}" on port ${String(dbPort)}: ${maskCredentials(firstLine(reason))}`, ); } - dbPort = await smallestUnusedDatabasePort(dbPort + 1); + conflicted.add(conflictPort); + if (conflictPort === dbPort) { + dbPort = await smallestUnusedDatabasePort(dbPort + 1, conflicted); + } } } } @@ -419,16 +537,19 @@ function main(): void { if (!appRec) return; const entries = Object.values(appRec.databases); - await Promise.all( - entries.map(async (db) => { - const server = runtimes.get(db.instanceName); - if (server) { - await server.close(); - runtimes.delete(db.instanceName); - releaseAuxPorts(db.instanceName); - } - }), - ); + // Closed one at a time, never concurrently: the servers run pglite's + // native/WASM runtime in THIS process, and the daemon has died silently + // (no JS error, no guard output — a native abort) during exactly this + // teardown window under CI load. Sequential closes remove the only + // concurrency we control there. + for (const db of entries) { + const server = runtimes.get(db.instanceName); + if (server) { + await server.close().catch(() => undefined); + runtimes.delete(db.instanceName); + releaseAuxPorts(db.instanceName); + } + } // Deleting the persisted PGlite data needs SOME resolved `@prisma/dev` // module — the app that owns the databases being deleted is the same @@ -437,7 +558,7 @@ function main(): void { // no body, so there is nothing more specific to prefer. if (entries.length > 0 && prismaDevModulePathHint) { const { deleteServer } = await importPrismaDevInternalState(prismaDevModulePathHint); - await Promise.all(entries.map((db) => deleteServer(db.instanceName))); + for (const db of entries) await deleteServer(db.instanceName); } delete state[app]; @@ -545,9 +666,9 @@ function main(): void { // children (a separate OS process the OS reclaims on its own) or // buckets' plain filesystem store. async function shutdown(): Promise { - await Promise.all( - [...runtimes.values()].map((server) => server.close().catch(() => undefined)), - ); + for (const server of runtimes.values()) { + await server.close().catch(() => undefined); + } await stateFile.flush(); server.close(); process.exit(0); @@ -555,6 +676,26 @@ function main(): void { process.on('SIGTERM', () => void shutdown()); process.on('SIGINT', () => void shutdown()); + // This daemon hosts `@prisma/dev`'s runtime in-process, and a failed or + // abandoned start attempt can leave background async work behind whose + // eventual rejection would otherwise kill the process (bun and node both + // exit on an unhandled rejection). A machine-shared daemon serving every + // app's databases must not die because one attempt's debris rejected — + // log it (the daemon's stdio is teed to its registry log) and keep + // serving; every REQUEST path still reports its own errors as 500s. + process.on('unhandledRejection', (reason) => { + console.error( + 'postgres-main: unhandled rejection from background work:', + reason instanceof Error ? maskCredentials(reason.stack ?? reason.message) : reason, + ); + }); + process.on('uncaughtException', (err) => { + console.error( + 'postgres-main: uncaught exception from background work:', + err instanceof Error ? maskCredentials(err.stack ?? err.message) : err, + ); + }); + readJsonFile(appsJsonPath, isAppsState) .then((loaded) => { if (loaded) state = loaded; diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-scoped-env.test.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-scoped-env.test.ts new file mode 100644 index 000000000..df7140e5a --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-scoped-env.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from 'bun:test'; +import { scopedEnvRows } from '../compute.ts'; + +/** + * Local-dev spec § 4's pinned parity note: the hosted platform diffs a + * deployment only on its own referenced rows, so an app-wide LOCAL + * materialization restart-amplifies (an early-deployed service's snapshot + * looks "changed" on the very next converge, purely from a sibling's row + * landing afterward). `scopedEnvRows` is the fix — every service's + * materialized env keeps only what it owns (`COMPOSER__*`) + * plus every row OUTSIDE the `COMPOSER_` namespace (the poison rows, which + * are deliberately app-wide). + */ +describe('scopedEnvRows()', () => { + test("keeps only the service's own COMPOSER_ rows plus every non-COMPOSER_ row", () => { + const all = { + COMPOSER_WEB_PORT: '3000', + COMPOSER_WEB_ORIGIN: 'http://localhost:3000', + COMPOSER_ORDERS_SERVICE_PORT: '3001', + COMPOSER_ORDERS_SERVICE_CATALOG_URL: 'http://localhost:3002', + DATABASE_URL: '-', + DATABASE_URL_POOLED: '-', + }; + + expect(scopedEnvRows(all, 'web')).toEqual({ + COMPOSER_WEB_PORT: '3000', + COMPOSER_WEB_ORIGIN: 'http://localhost:3000', + DATABASE_URL: '-', + DATABASE_URL_POOLED: '-', + }); + }); + + test('a nested address only matches its own dotted prefix, never a sibling module', () => { + const all = { + COMPOSER_ORDERS_SERVICE_PORT: '3001', + // A DIFFERENT service under the same "orders" module — must not leak + // into "orders.service"'s scoped env just because both start with + // "COMPOSER_ORDERS_". + COMPOSER_ORDERS_WORKER_PORT: '3005', + }; + + expect(scopedEnvRows(all, 'orders.service')).toEqual({ + COMPOSER_ORDERS_SERVICE_PORT: '3001', + }); + }); + + test('a service with no rows of its own still gets every poison/app-wide row', () => { + const all = { + COMPOSER_OTHER_PORT: '4000', + DATABASE_URL: '-', + }; + + expect(scopedEnvRows(all, 'web')).toEqual({ DATABASE_URL: '-' }); + }); + + test('an empty env store scopes to an empty object', () => { + expect(scopedEnvRows({}, 'web')).toEqual({}); + }); + + test('an address that is a string-prefix of another address never matches its rows (underscore boundary)', () => { + // "web" is a literal string prefix of "web2" — without the trailing + // underscore on the match prefix, "web"'s scope would also swallow + // "web2"'s own rows. COMPOSER_WEB_PORT vs COMPOSER_WEB2_PORT: the + // prefix "COMPOSER_WEB_" (with its trailing underscore) does not match + // "COMPOSER_WEB2_PORT" — the characters diverge at "WEB" + "_" vs "2". + const all = { + COMPOSER_WEB_PORT: '3000', + COMPOSER_WEB2_PORT: '3001', + }; + + expect(scopedEnvRows(all, 'web')).toEqual({ COMPOSER_WEB_PORT: '3000' }); + expect(scopedEnvRows(all, 'web2')).toEqual({ COMPOSER_WEB2_PORT: '3001' }); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts index 63e962a90..47522aa8c 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts @@ -1,7 +1,14 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import type { ContainerInstance, LocalTargetProvidersInput } from '@internal/core/config'; -import { ensureDaemon, instanceNameFor, postgresClient } from '@internal/dev-emulators'; +import { + defaultRegistryRoot, + ensureDaemon, + instanceNameFor, + postgresClient, +} from '@internal/dev-emulators'; import { Connection, Database } from '@internal/lowering/postgres'; import * as Effect from 'effect/Effect'; import * as Redacted from 'effect/Redacted'; @@ -27,6 +34,22 @@ import { LocalConnectionProvider, LocalDatabaseProvider } from '../postgres.ts'; const APP = 'pgdrifttestapp-'; const DATABASE_ID = 'orders-'; +/** On failure, print the machine-global postgres daemon's log tail — a daemon that died mid-request is otherwise undiagnosable from the test's own connection error. */ +async function withDaemonLogOnFailure(run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + try { + const log = fs.readFileSync(path.join(defaultRegistryRoot(), 'postgres.log'), 'utf8'); + console.error('--- postgres daemon log (tail) ---'); + console.error(log.split('\n').slice(-80).join('\n')); + } catch { + console.error('--- postgres daemon log unavailable ---'); + } + throw error; + } +} + function fakeContainer(appName: string): ContainerInstance { return { input: { appName, stage: undefined }, serialize: () => 'x' }; } @@ -49,61 +72,127 @@ afterAll(async () => { }); describe('instance-name drift (delta review finding A, #160)', () => { - test('a database id and app name with leading/trailing non-alphanumerics: Database ensures, Connection resolves the SAME server', async () => { - const input: LocalTargetProvidersInput = { - container: fakeContainer(APP), - devDir: '/dev/null/unused', - }; - - // 1. Ensure through the real daemon (LocalDatabaseProvider's own reconcile). - const databaseService = await Effect.runPromise( - Database.Provider.pipe(Effect.provide(LocalDatabaseProvider(input))), - ); - const databaseAttributes = await Effect.runPromise( - databaseService.reconcile({ - id: 'db', - instanceId: 'db', - news: { projectId: 'p', name: DATABASE_ID, region: 'us-east-1' }, - olds: undefined, - output: undefined, - session: undefined as never, - bindings: [], - }), - ); - - // The provider-derived id is exactly the daemon's own derivation — no - // second implementation to drift from it. - expect(databaseAttributes.id).toBe(instanceNameFor(APP, DATABASE_ID)); - expect(databaseAttributes.id).toBe('pcdev-pgdrifttestapp-orders'); - // Proves the trim/collapse actually happened — the pre-fix drift left a - // doubled dash at the "pgdrifttestapp-" + "-" + "orders-" boundary. - expect(databaseAttributes.id.includes('--')).toBe(false); - - // 2. Connection-resolve through the listing (LocalConnectionProvider's - // own reconcile) — before the fix, this threw noRecordedInstanceError - // for this exact pathological pair. - const connectionService = await Effect.runPromise( - Connection.Provider.pipe(Effect.provide(LocalConnectionProvider(input))), - ); - const connectionAttributes = await Effect.runPromise( - connectionService.reconcile({ - id: 'conn', - instanceId: 'conn', - news: { databaseId: databaseAttributes.id, name: 'conn' }, - olds: undefined, - output: undefined, - session: undefined as never, - bindings: [], + test( + 'a database id and app name with leading/trailing non-alphanumerics: Database ensures, Connection resolves the SAME server', + () => + withDaemonLogOnFailure(async () => { + const input: LocalTargetProvidersInput = { + container: fakeContainer(APP), + devDir: '/dev/null/unused', + }; + + // 1. Ensure through the real daemon (LocalDatabaseProvider's own reconcile). + const databaseService = await Effect.runPromise( + Database.Provider.pipe(Effect.provide(LocalDatabaseProvider(input))), + ); + const databaseAttributes = await Effect.runPromise( + databaseService.reconcile({ + id: 'db', + instanceId: 'db', + news: { projectId: 'p', name: DATABASE_ID, region: 'us-east-1' }, + olds: undefined, + output: undefined, + session: undefined as never, + bindings: [], + }), + ); + + // The provider-derived id is exactly the daemon's own derivation — no + // second implementation to drift from it. + expect(databaseAttributes.id).toBe(instanceNameFor(APP, DATABASE_ID)); + expect(databaseAttributes.id).toBe('pcdev-pgdrifttestapp-orders'); + // Proves the trim/collapse actually happened — the pre-fix drift left a + // doubled dash at the "pgdrifttestapp-" + "-" + "orders-" boundary. + expect(databaseAttributes.id.includes('--')).toBe(false); + + // 2. Connection-resolve through the listing (LocalConnectionProvider's + // own reconcile) — before the fix, this threw noRecordedInstanceError + // for this exact pathological pair. + const connectionService = await Effect.runPromise( + Connection.Provider.pipe(Effect.provide(LocalConnectionProvider(input))), + ); + const connectionAttributes = await Effect.runPromise( + connectionService.reconcile({ + id: 'conn', + instanceId: 'conn', + news: { databaseId: databaseAttributes.id, name: 'conn' }, + olds: undefined, + output: undefined, + session: undefined as never, + bindings: [], + }), + ); + + expect(connectionAttributes.id).toBe(databaseAttributes.id); + expect(Redacted.value(connectionAttributes.connectionString)).toMatch(/^postgres:\/\//); + + // 3. The daemon's own listing agrees on the same name too — the third + // independent read of the same value. + const listed = await postgresClient().listDatabases(APP); + const entry = listed.find((d) => d.instanceName === databaseAttributes.id); + expect(entry).toBeDefined(); }), - ); + 30_000, + ); + + test( + 'a nested module\'s dotted database name ("catalog.database"): Database ensures, Connection resolves the SAME server', + () => + withDaemonLogOnFailure(async () => { + // A nested module's Database resource name is hierarchical and + // dot-separated — but the daemon's `` path segment rule + // (/^[a-z0-9][a-z0-9-]*$/) forbids dots, so the provider must slug the + // daemon-facing id (compute.ts's `slugServiceId` seam, applied to + // postgres). Pre-fix, the client's own `encodeSegment` threw + // `invalid path segment "catalog.database"` before the request ever + // left the process — observed live against examples/store. + const DOTTED_ID = 'catalog.database'; + const input: LocalTargetProvidersInput = { + container: fakeContainer(APP), + devDir: '/dev/null/unused', + }; + + const databaseService = await Effect.runPromise( + Database.Provider.pipe(Effect.provide(LocalDatabaseProvider(input))), + ); + const databaseAttributes = await Effect.runPromise( + databaseService.reconcile({ + id: 'db', + instanceId: 'db', + news: { projectId: 'p', name: DOTTED_ID, region: 'us-east-1' }, + olds: undefined, + output: undefined, + session: undefined as never, + bindings: [], + }), + ); - expect(connectionAttributes.id).toBe(databaseAttributes.id); - expect(Redacted.value(connectionAttributes.connectionString)).toMatch(/^postgres:\/\//); + // slug() is idempotent, so the daemon's instanceNameFor(app, slug(name)) + // equals the provider-recorded instanceNameFor(app, name). + expect(databaseAttributes.id).toBe(instanceNameFor(APP, DOTTED_ID)); + expect(databaseAttributes.id).toBe('pcdev-pgdrifttestapp-catalog-database'); - // 3. The daemon's own listing agrees on the same name too — the third - // independent read of the same value. - const listed = await postgresClient().listDatabases(APP); - const entry = listed.find((d) => d.instanceName === databaseAttributes.id); - expect(entry).toBeDefined(); - }, 30_000); + const connectionService = await Effect.runPromise( + Connection.Provider.pipe(Effect.provide(LocalConnectionProvider(input))), + ); + const connectionAttributes = await Effect.runPromise( + connectionService.reconcile({ + id: 'conn', + instanceId: 'conn', + news: { databaseId: databaseAttributes.id, name: 'conn' }, + olds: undefined, + output: undefined, + session: undefined as never, + bindings: [], + }), + ); + + expect(connectionAttributes.id).toBe(databaseAttributes.id); + expect(Redacted.value(connectionAttributes.connectionString)).toMatch(/^postgres:\/\//); + + const listed = await postgresClient().listDatabases(APP); + expect(listed.find((d) => d.instanceName === databaseAttributes.id)).toBeDefined(); + }), + 30_000, + ); }); diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts index 579de3e20..6bf2bdb49 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts @@ -39,6 +39,43 @@ function servicePortEnvKey(address: string): string { return ['COMPOSER', ...segments, 'PORT'].join('_').toUpperCase(); } +/** The `COMPOSER_
_` prefix every env row this address OWNS carries — `configKey`'s convention (see `servicePortEnvKey`'s doc comment). */ +function ownEnvKeyPrefix(address: string): string { + const segments = address.split('.').filter((s) => s.length > 0); + return `${['COMPOSER', ...segments].join('_').toUpperCase()}_`; +} + +const COMPOSER_NAMESPACE_PREFIX = 'COMPOSER_'; + +/** + * Scopes `env.json` to what THIS service is allowed to see: rows it owns + * (`COMPOSER__*`) plus every row OUTSIDE the `COMPOSER_` + * namespace entirely — the poison `DATABASE_URL(_POOLED)` rows are + * deliberately unprefixed and app-wide (local-dev spec § 4's pinned parity + * note). The hosted platform materializes the app-wide row set into every + * deployment but DIFFS a deployment only on its own referenced rows; an + * app-wide LOCAL materialization restart-amplifies instead — an + * early-deployed service's snapshot is incomplete on the first converge, + * "completes" on the second, and diffs as changed. Scoping the content here + * aligns local restart behavior with the platform's diff scope. The dropped + * sibling rows have no sanctioned reader: `run()`/`load()` consume only + * own-address rows, and ambient sibling reads are exactly what the poison + * rows exist to punish. + */ +export function scopedEnvRows( + allRows: Readonly>, + address: string, +): Record { + const ownPrefix = ownEnvKeyPrefix(address); + const scoped: Record = {}; + for (const [key, value] of Object.entries(allRows)) { + if (key.startsWith(ownPrefix) || !key.startsWith(COMPOSER_NAMESPACE_PREFIX)) { + scoped[key] = value; + } + } + return scoped; +} + function manifestMissingAddressError(): Error { return new Error( 'artifact manifest carries no address — repackage with a current @prisma/composer.', @@ -83,12 +120,32 @@ function readManifestAddress(artifactDir: string): string { return parsed.address; } +/** + * The Compute emulator's `` path segment must match + * `/^[a-z0-9][a-z0-9-]*$/` (its API hygiene rule, local-dev spec § 2) — but a + * service's own address (`news.name`/`news.computeServiceId`) is + * hierarchical and dot-separated (e.g. `"orders.service"`, a nested + * module's service). This is the seam: every dot (or other disallowed char) + * becomes a dash, runs collapse, and the result is what both `ensureService` + * and `putDeployment` address the emulator with — the REAL address still + * rides the deployment body's `address` field untouched, so the front door + * and every listing still show it verbatim (compute-main.ts's `svc.address` + * is set from that field, not from the id). + */ +function slugServiceId(address: string): string { + const slug = address + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return slug.length > 0 ? slug : 'svc'; +} + async function materializeEnv( devDir: string, address: string, port: number, ): Promise> { - const env: Record = { ...(await envStore(devDir).read()) }; + const env: Record = scopedEnvRows(await envStore(devDir).read(), address); env[servicePortEnvKey(address)] = JSON.stringify(port); const secrets = await secretsStore(devDir).read(); for (const [key, value] of Object.entries(secrets)) env[key] = value; @@ -111,7 +168,7 @@ export function LocalComputeServiceProvider( Effect.tryPromise({ try: async () => { const app = appNameOf(input.container); - const { url } = await computeClient().ensureService(app, news.name); + const { url } = await computeClient().ensureService(app, slugServiceId(news.name)); return { id: news.name, name: news.name, endpointDomain: url }; }, catch: (cause) => cause, @@ -170,6 +227,7 @@ export function LocalDeploymentProvider( try: async (): Promise => { const app = appNameOf(input.container); const id = news.computeServiceId; + const emulatorId = slugServiceId(id); const artifactDir = path.join(input.devDir, 'artifacts', news.artifactHash); if (!fs.existsSync(artifactDir)) { @@ -177,9 +235,9 @@ export function LocalDeploymentProvider( } const address = readManifestAddress(artifactDir); - const { port } = await computeClient().ensureService(app, id); + const { port } = await computeClient().ensureService(app, emulatorId); const env = await materializeEnv(input.devDir, address, port); - await computeClient().putDeployment(app, id, { + await computeClient().putDeployment(app, emulatorId, { address, artifactDir, artifactHash: news.artifactHash, diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/exports/index.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/exports/index.ts index 9f0c0a04c..621f20fa5 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/exports/index.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/exports/index.ts @@ -1,4 +1,4 @@ -/** `@internal/local-target`'s public surface (local-dev spec § 4): the local deploy target's provider suite — the dev provider bundle plus the dev-instance store and shared plumbing. Implementation lives in `../dev/*`. */ +/** `@internal/local-target`'s public surface (local-dev spec § 4): the local deploy target's provider suite — the dev provider bundle plus the dev-instance store and shared plumbing. Implementation sits beside this barrel at `src/`. */ export * from '../app-name.ts'; export * from '../bucket.ts'; export * from '../compute.ts'; diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/postgres.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/postgres.ts index 0891f0772..757346c2b 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/postgres.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/postgres.ts @@ -22,7 +22,7 @@ import { createRequire } from 'node:module'; import * as path from 'node:path'; import type { LocalTargetProvidersInput } from '@internal/core/config'; -import { instanceNameFor, postgresClient } from '@internal/dev-emulators'; +import { instanceNameFor, postgresClient, slug } from '@internal/dev-emulators'; import { Connection, Database } from '@internal/lowering/postgres'; import * as Provider from 'alchemy/Provider'; import * as Effect from 'effect/Effect'; @@ -75,9 +75,18 @@ export function LocalDatabaseProvider( try: async () => { const app = appNameOf(input.container); const prismaDevModulePath = resolvePrismaDevModulePath(process.cwd()); + // The daemon's `` path segment must match + // /^[a-z0-9][a-z0-9-]*$/ (spec § 2's API hygiene rule) — but a + // Database resource's name is hierarchical and dot-separated for + // a nested module (e.g. "catalog.database"). Same seam as + // compute.ts's `slugServiceId`: the daemon-facing id is the slug; + // `slug` is idempotent, so the daemon's own + // `instanceNameFor(app, slug(name))` equals + // `instanceNameFor(app, name)` — the very name the attributes + // below record and `Connection` looks up. const { url } = await postgresClient().ensureDatabase( app, - news.name, + slug(news.name), prismaDevModulePath, ); const attributes = { id: instanceNameFor(app, news.name), name: news.name, url }; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/local-target/attach.ts b/packages/1-prisma-cloud/1-extensions/target/src/local-target/attach.ts index f84440910..5fb85b92e 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/local-target/attach.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/local-target/attach.ts @@ -34,7 +34,7 @@ async function* mergedLogs(app: string, signal: AbortSignal): AsyncIterable { + const follow = (id: string, address: string): void => { if (followed.has(id)) return; followed.add(id); void (async () => { @@ -44,7 +44,7 @@ async function* mergedLogs(app: string, signal: AbortSignal): AsyncIterable => { const services = await client.listServices(app); - for (const svc of services) follow(svc.id); + // The emulator's `id` is the (path-segment-safe) key `followLogs` needs; + // the log line is labelled with the real, dotted address — the same + // identity the front door prints, so a service's endpoint line and its + // log lines name it identically (a nested module's service address + // contains dots the emulator's own id path segment cannot carry — see + // compute.ts's `slugServiceId`). + for (const svc of services) follow(svc.id, svc.address); }; await relist(); @@ -91,6 +97,7 @@ export async function devAttach(input: LocalTargetAttachInput): Promise client.startApp(app), endpoints: async () => { const services = await client.listServices(app); return services.map((svc) => ({ address: svc.address, url: svc.url })); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9ab23f58..d53302838 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -646,6 +646,9 @@ importers: c12: specifier: ^3.3.4 version: 3.3.4 + chokidar: + specifier: ^4.0.3 + version: 4.0.3 clipanion: specifier: ^3.2.1 version: 3.2.1(typanion@3.14.0) @@ -1239,12 +1242,18 @@ importers: test/integration: devDependencies: + '@aws-sdk/client-s3': + specifier: ^3.1085.0 + version: 3.1087.0 '@prisma/composer': specifier: workspace:0.2.0 version: link:../../packages/9-public/composer '@prisma/composer-prisma-cloud': specifier: workspace:0.2.0 version: link:../../packages/9-public/composer-prisma-cloud + '@prisma/example-store': + specifier: workspace:0.2.0 + version: link:../../examples/store '@types/bun': specifier: ^1.3.13 version: 1.3.14 @@ -3647,6 +3656,10 @@ packages: character-entities-legacy@3.0.0: resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -4715,6 +4728,10 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -8031,6 +8048,10 @@ snapshots: character-entities-legacy@3.0.0: {} + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + chokidar@5.0.0: dependencies: readdirp: 5.0.0 @@ -9092,6 +9113,8 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readdirp@4.1.2: {} + readdirp@5.0.0: {} rechoir@0.8.0: diff --git a/test/integration/package.json b/test/integration/package.json index bf52f3a69..fef8f0a04 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -5,11 +5,13 @@ "type": "module", "scripts": { "typecheck": "tsc --noEmit", - "test": "bun test && bun run test/local-dev.integration.ts" + "test": "bun test && bun run test/local-dev.integration.ts && bun run test/local-dev-store.integration.ts && bun run test/local-dev-criteria-4-5.integration.ts" }, "devDependencies": { + "@aws-sdk/client-s3": "^3.1085.0", "@prisma/composer": "workspace:0.2.0", "@prisma/composer-prisma-cloud": "workspace:0.2.0", + "@prisma/example-store": "workspace:0.2.0", "@types/bun": "^1.3.13", "@types/node": "^25.9.3", "alchemy": "2.0.0-beta.59", diff --git a/test/integration/test/fixtures/local-dev/bg-service.ts b/test/integration/test/fixtures/local-dev/bg-service.ts index e65b82e0b..ad7d0621e 100644 --- a/test/integration/test/fixtures/local-dev/bg-service.ts +++ b/test/integration/test/fixtures/local-dev/bg-service.ts @@ -3,12 +3,19 @@ * "a changed artifact restarts exactly one service" (S4 proof): only * `web-service.ts`'s artifact changes between converges, so this one's pid * must stay stable across both the no-op and the changed-artifact converge. + * + * Also carries the fixture's secret slot + env-sourced param (S5 proof, + * acceptance criterion 5) — bound in module.ts to `LOCALDEV_FIXTURE_API_KEY` + * / `LOCALDEV_FIXTURE_GREETING`. */ +import { secret, string } from '@prisma/composer'; import node from '@prisma/composer/node'; import { compute } from '@prisma/composer-prisma-cloud'; export default compute({ name: 'bkg', deps: {}, + secrets: { apiKey: secret() }, + params: { greeting: string() }, build: node({ module: import.meta.url, entry: 'built/bg-server.mjs' }), }); diff --git a/test/integration/test/fixtures/local-dev/built/bg-server.mjs b/test/integration/test/fixtures/local-dev/built/bg-server.mjs index a98f51e31..2e3e046f7 100644 --- a/test/integration/test/fixtures/local-dev/built/bg-server.mjs +++ b/test/integration/test/fixtures/local-dev/built/bg-server.mjs @@ -1,23 +1,42 @@ -// Hand-written "built output" for bg-service.ts (S4 fixture) -- a +// Hand-written "built output" for bg-service.ts (S4/S5 fixture) -- a // dependency-free second service that must NOT restart when only web's // artifact changes. Self-contained (see web-server.mjs's doc comment for // why this doesn't import the sibling .ts source). +// +// Also carries the secret/env-param proof (S5, acceptance criterion 5): +// GET /status reports whether the secret looks like a locally-minted +// placeholder (`local-placeholder-`) and echoes the bound greeting — +// dev never reaches this far at all when the env-param is unset (preflight +// hard-errors first), so this endpoint's mere reachability is half the proof. +import { secret, string } from '@prisma/composer'; import node from '@prisma/composer/node'; import { compute } from '@prisma/composer-prisma-cloud'; const service = compute({ name: 'bkg', deps: {}, + secrets: { apiKey: secret() }, + params: { greeting: string() }, build: node({ module: import.meta.url, entry: 'bg-server.mjs' }), }); -const { port } = service.config(); +const { port, greeting } = service.config(); +const { apiKey } = service.secrets(); Bun.serve({ port, hostname: '0.0.0.0', - fetch: () => new Response('local-dev fixture: bg', { status: 200 }), + fetch(request) { + const url = new URL(request.url); + if (url.pathname === '/status') { + return Response.json({ + greeting, + apiKeyIsPlaceholder: apiKey.expose().startsWith('local-placeholder-'), + }); + } + return new Response('local-dev fixture: bg', { status: 200 }); + }, }); console.log(`[fixture] bg listening on ${port}`); diff --git a/test/integration/test/fixtures/local-dev/built/web-server.mjs b/test/integration/test/fixtures/local-dev/built/web-server.mjs index bdea5c971..c4ce5e19d 100644 --- a/test/integration/test/fixtures/local-dev/built/web-server.mjs +++ b/test/integration/test/fixtures/local-dev/built/web-server.mjs @@ -8,6 +8,7 @@ // what the "changed artifact restarts exactly one service" proof edits // between converges. +import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import node from '@prisma/composer/node'; import { bucket, compute, postgres } from '@prisma/composer-prisma-cloud'; @@ -22,10 +23,26 @@ const VERSION = 'v1'; const { port } = service.config(); const { db, store } = service.load(); +// The bucket round-trip proof (S5, acceptance criterion 4): a real +// @aws-sdk/client-s3 client against the bucket binding, exactly as +// examples/bucket's blob app does. +const s3 = new S3Client({ + region: 'auto', + endpoint: store.url, + forcePathStyle: true, + credentials: { accessKeyId: store.accessKeyId, secretAccessKey: store.secretAccessKey }, + requestChecksumCalculation: 'WHEN_REQUIRED', + responseChecksumValidation: 'WHEN_REQUIRED', +}); + +async function collectBytes(body) { + return body.transformToByteArray(); +} + Bun.serve({ port, hostname: '0.0.0.0', - fetch(request) { + async fetch(request) { const url = new URL(request.url); if (url.pathname === '/health') { return Response.json({ @@ -41,6 +58,38 @@ Bun.serve({ portEnv: process.env['COMPOSER_WEB_PORT'] ?? null, }); } + if (url.pathname.startsWith('/blobs/')) { + const key = decodeURIComponent(url.pathname.slice('/blobs/'.length)); + if (key.length === 0) return new Response('missing key', { status: 400 }); + if (request.method === 'PUT') { + const body = new Uint8Array(await request.arrayBuffer()); + const contentType = request.headers.get('content-type') ?? 'application/octet-stream'; + await s3.send( + new PutObjectCommand({ + Bucket: store.bucket, + Key: key, + Body: body, + ContentType: contentType, + }), + ); + return Response.json({ key, size: body.byteLength }, { status: 201 }); + } + if (request.method === 'GET') { + try { + const res = await s3.send(new GetObjectCommand({ Bucket: store.bucket, Key: key })); + const bytes = await collectBytes(res.Body); + return new Response(bytes, { + status: 200, + headers: { 'content-type': res.ContentType ?? 'application/octet-stream' }, + }); + } catch (error) { + const status = error?.$metadata?.httpStatusCode; + if (status === 404) return new Response('not found', { status: 404 }); + throw error; + } + } + return new Response('method not allowed', { status: 405 }); + } return new Response('local-dev fixture: web', { status: 200 }); }, }); diff --git a/test/integration/test/fixtures/local-dev/module.ts b/test/integration/test/fixtures/local-dev/module.ts index 3df8c3371..41a431ebb 100644 --- a/test/integration/test/fixtures/local-dev/module.ts +++ b/test/integration/test/fixtures/local-dev/module.ts @@ -4,9 +4,15 @@ * lowering exercises every one of the eight local providers. Discovered by * walking up from this file (a real `prisma-composer.config.ts` sits at the * repo root of this package). + * + * `bgService`'s secret/env-param binding (S5 proof, spec's acceptance + * criterion 5): `apiKey` is a secret slot — absent from the shell, dev mints + * a placeholder and warns; `greeting` is an env-sourced param — absent from + * the shell, dev hard-errors. Both names are fixture-private, chosen not to + * collide with any real platform env var. */ import { module } from '@prisma/composer'; -import { bucket, postgres } from '@prisma/composer-prisma-cloud'; +import { bucket, envParam, envSecret, postgres } from '@prisma/composer-prisma-cloud'; import bgService from './bg-service.ts'; import webService from './web-service.ts'; @@ -14,5 +20,8 @@ export default module('localdevs4fixture', ({ provision }) => { const db = provision(postgres({ name: 'appdb' })); const store = provision(bucket({ name: 'files' })); provision(webService, { deps: { db, store } }); - provision(bgService, {}); + provision(bgService, { + secrets: { apiKey: envSecret('LOCALDEV_FIXTURE_API_KEY') }, + params: { greeting: envParam('LOCALDEV_FIXTURE_GREETING') }, + }); }); diff --git a/test/integration/test/local-dev-criteria-4-5.integration.ts b/test/integration/test/local-dev-criteria-4-5.integration.ts new file mode 100644 index 000000000..4cc491862 --- /dev/null +++ b/test/integration/test/local-dev-criteria-4-5.integration.ts @@ -0,0 +1,373 @@ +/** + * The plan-S5 proving script for acceptance criteria 4 and 5 (spec's + * acceptance criteria list): drives the real `prisma-composer` binary + * against the S4 fixture (`test/fixtures/local-dev/`), extended for this + * proof with a bucket consumer flow on `web` and a secret/env-param on + * `bkg` (module.ts's `apiKey`/`greeting` binding). Same pattern as + * `local-dev-store.integration.ts` — a real child process, teed output, + * bounded waits. + * + * Criterion 4 (bucket round-trip): PUT an object through the app's own + * `/blobs/:key` route, confirm it lands as a plain file under + * `.prisma-composer/dev/buckets/files/`; drop a file directly into + * that directory and GET it back through the app. + * + * Criterion 5 (value sourcing): with `LOCALDEV_FIXTURE_API_KEY` unset, the + * pinned placeholder warning appears and the topology still serves; with + * `LOCALDEV_FIXTURE_GREETING` (an `envParam`) unset, dev exits nonzero with + * the pinned missing-env-param listing error naming it — checked in two + * separate sessions, since the second failure happens in preflight, before + * anything converges. + */ + +import { type ChildProcess, spawn, spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { prismaCloud } from '@prisma/composer-prisma-cloud/control'; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(`assertion failed: ${message}`); +} + +function assertEqual(actual: T, expected: T, message: string): void { + const same = JSON.stringify(actual) === JSON.stringify(expected); + if (!same) { + throw new Error( + `assertion failed: ${message}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`, + ); + } +} + +const integrationDir = path.resolve(import.meta.dir, '..'); +const fixtureEntry = path.join('test', 'fixtures', 'local-dev', 'module.ts'); +const devDir = path.join(integrationDir, '.prisma-composer', 'dev'); +const logDir = path.join(integrationDir, '.local-dev-criteria-4-5-logs'); +const CLI_BIN = path.join(integrationDir, 'node_modules', '.bin', 'prisma-composer'); +const READY_TIMEOUT_MS = 90_000; +const EXIT_TIMEOUT_MS = 30_000; +const SHUTDOWN_TIMEOUT_MS = 15_000; + +let sessionCount = 0; + +interface Endpoint { + readonly address: string; + readonly url: string; +} + +interface DevSession { + readonly child: ChildProcess; + readonly logPath: string; + readonly endpoints: readonly Endpoint[]; +} + +function readLog(logPath: string): string { + try { + return fs.readFileSync(logPath, 'utf8'); + } catch { + return ''; + } +} + +/** `~/.prisma-composer/emulators/` — the one real, machine-global root, same as `defaultRegistryRoot()` resolves. */ +function emulatorRegistryRoot(): string { + return path.join(os.homedir(), '.prisma-composer', 'emulators'); +} + +function tailOf(filePath: string, n = 60): string { + let content: string; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + return ``; + } + const lines = content.split('\n'); + return lines.slice(-n).join('\n'); +} + +/** + * Everything a failure needs to be diagnosable from CI's own log output + * alone — the teed session logs (carrying the CLI's inherited `alchemy` + * converge output inline) are on the runner, gone the moment the job ends. + * Bounded, never throws. + */ +function dumpDiagnostics(): void { + console.error('\n=== diagnostics ==='); + for (let i = 1; i <= sessionCount; i += 1) { + const logPath = path.join(logDir, `session-${i}.log`); + console.error(`\n--- session-${i}.log (tail) ---`); + console.error(tailOf(logPath)); + } + for (const name of ['compute', 'buckets', 'postgres'] as const) { + console.error(`\n--- ${name} emulator log (tail) ---`); + console.error(tailOf(path.join(emulatorRegistryRoot(), `${name}.log`))); + } + console.error('=== end diagnostics ===\n'); +} + +function parseFrontDoor(log: string): readonly Endpoint[] | undefined { + const lines = log.split('\n'); + const readyAt = lines.findIndex((l) => l.trim() === '[dev] ready:'); + if (readyAt === -1) return undefined; + const endpoints: Endpoint[] = []; + for (let i = readyAt + 1; i < lines.length; i += 1) { + const m = /^\[dev\] (\S+)\s\s(\S+)$/.exec(lines[i] ?? ''); + if (m === null) break; + endpoints.push({ address: m[1] as string, url: m[2] as string }); + } + return endpoints.length > 0 ? endpoints : undefined; +} + +async function waitForAsync( + fn: () => Promise, + timeoutMs: number, + intervalMs = 250, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + for (;;) { + try { + const result = await fn(); + if (result !== undefined) return result; + } catch (error) { + lastError = error; + } + if (Date.now() >= deadline) { + throw lastError instanceof Error + ? lastError + : new Error(`waitFor: not met within ${timeoutMs}ms`); + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } +} + +function baseEnv(extra: Record): NodeJS.ProcessEnv { + const env = { ...process.env }; + delete env['PRISMA_WORKSPACE_ID']; + delete env['PRISMA_SERVICE_TOKEN']; + delete env['PRISMA_REGION']; + for (const [key, value] of Object.entries(extra)) { + if (value === undefined) delete env[key]; + else env[key] = value; + } + return env; +} + +/** Starts `prisma-composer dev [--fresh]` against the fixture, teed to a log file. Does not wait for readiness — callers pick the wait strategy (ready vs. exit). */ +function startDevRaw( + env: NodeJS.ProcessEnv, + opts: { readonly fresh?: boolean } = {}, +): { child: ChildProcess; logPath: string } { + sessionCount += 1; + fs.mkdirSync(logDir, { recursive: true }); + const logPath = path.join(logDir, `session-${sessionCount}.log`); + const logFd = fs.openSync(logPath, 'w'); + const args = ['dev', fixtureEntry, ...(opts.fresh === true ? ['--fresh'] : [])]; + const child = spawn(CLI_BIN, args, { + cwd: integrationDir, + env, + stdio: ['ignore', logFd, logFd], + }); + fs.closeSync(logFd); + return { child, logPath }; +} + +async function startDevUntilReady( + env: NodeJS.ProcessEnv, + opts: { readonly fresh?: boolean } = {}, +): Promise { + const { child, logPath } = startDevRaw(env, opts); + const endpoints = await waitForAsync( + async () => parseFrontDoor(readLog(logPath)), + READY_TIMEOUT_MS, + 500, + ); + return { child, logPath, endpoints }; +} + +/** Waits for the process to exit on its own (the hard-error path never reaches "ready"). */ +async function waitForExit(child: ChildProcess, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`process did not exit within ${timeoutMs}ms`)); + }, timeoutMs); + child.once('exit', (code) => { + clearTimeout(timer); + resolve(code); + }); + }); +} + +async function stopDev(session: DevSession): Promise { + if (session.child.exitCode !== null || session.child.signalCode !== null) return; + session.child.kill('SIGINT'); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`session did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGINT`)); + }, SHUTDOWN_TIMEOUT_MS); + session.child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +async function main(): Promise { + console.log( + 'local dev (S5 proving): criteria 4 (bucket) and 5 (placeholder/env-param) against the S4 fixture', + ); + + fs.rmSync(path.join(integrationDir, '.prisma-composer'), { recursive: true, force: true }); + fs.rmSync(path.join(integrationDir, '.alchemy'), { recursive: true, force: true }); + + let session: DevSession | undefined; + try { + // ——— Criterion 5a: the secret is unset — a placeholder warning, topology still serves ——— + session = await startDevUntilReady( + baseEnv({ + LOCALDEV_FIXTURE_API_KEY: undefined, + LOCALDEV_FIXTURE_GREETING: 'hi-from-s5-proving', + }), + ); + console.log(`[proving] session 1 ready: ${JSON.stringify(session.endpoints)}`); + const log1 = readLog(session.logPath); + assert( + log1.includes( + '[dev] LOCALDEV_FIXTURE_API_KEY is not set in this shell — using a local placeholder. Anything that talks to the real service behind it will fail; everything else runs.', + ), + 'the pinned placeholder warning must appear for the unset secret', + ); + console.log('[proving] PASS criterion 5a: placeholder warning printed, topology still serves'); + + // ——— Criterion 4: bucket round-trip through the app ——— + const web = session.endpoints.find((e) => e.address === 'web'); + assert(web !== undefined, 'the web service must appear in the front door'); + if (web === undefined) throw new Error('unreachable'); + + const putRes = await waitForAsync( + () => + fetch(`${web.url}/blobs/proving-key.txt`, { + method: 'PUT', + headers: { 'content-type': 'text/plain' }, + body: 'hello from the S5 bucket proof', + }).then((r) => (r.status === 201 ? r : undefined)), + 15_000, + ); + assertEqual(putRes.status, 201, 'PUT /blobs/proving-key.txt through the app'); + + const objectPath = path.join(devDir, 'buckets', 'files', 'proving-key.txt'); + const onDisk = await waitForAsync( + async () => (fs.existsSync(objectPath) ? fs.readFileSync(objectPath, 'utf8') : undefined), + 5_000, + 200, + ); + assertEqual(onDisk, 'hello from the S5 bucket proof', 'the PUT object is a plain file on disk'); + console.log(`[proving] PASS criterion 4 (write direction): ${objectPath}`); + + // Drop a file directly into the bucket directory, read it back through the app. + const droppedPath = path.join(devDir, 'buckets', 'files', 'dropped-key.txt'); + fs.writeFileSync(droppedPath, 'dropped straight onto disk'); + const getRes = await waitForAsync( + () => + fetch(`${web.url}/blobs/dropped-key.txt`).then((r) => (r.status === 200 ? r : undefined)), + 10_000, + ); + const getBody = await getRes.text(); + assertEqual( + getBody, + 'dropped straight onto disk', + 'a file dropped on disk is readable back through the app', + ); + console.log( + '[proving] PASS criterion 4 (read direction): a dropped file is visible through the app', + ); + + await stopDev(session); + console.log('[proving] session 1 stopped cleanly on SIGINT'); + + // ——— Criterion 5b: the envParam is unset — a hard error naming it, nonzero exit ——— + const { child: child2, logPath: logPath2 } = startDevRaw( + baseEnv({ + LOCALDEV_FIXTURE_API_KEY: 'a-real-looking-key', + LOCALDEV_FIXTURE_GREETING: undefined, + }), + ); + const exitCode = await waitForExit(child2, EXIT_TIMEOUT_MS); + const log2 = readLog(logPath2); + assert( + exitCode !== 0 && exitCode !== null, + 'dev must exit nonzero when a required envParam is unset', + ); + assert( + log2.includes( + 'local dev preflight failed — 1 env-sourced param(s) are not set in this shell:', + ), + 'the pinned listing-error header must appear', + ); + assert( + /LOCALDEV_FIXTURE_GREETING\s+\(required by service "bkg"\)/.test(log2), + 'the error must name LOCALDEV_FIXTURE_GREETING and the service that requires it', + ); + assert( + log2.includes('Set each in the shell you run `prisma-composer dev` from.'), + 'the pinned fix instruction must appear', + ); + console.log( + `[proving] PASS criterion 5b: exit ${String(exitCode)}, pinned listing error naming LOCALDEV_FIXTURE_GREETING`, + ); + + console.log('PASS: local dev (S5 proving) — criteria 4 and 5'); + } finally { + if (session !== undefined) { + await stopDev(session).catch(() => undefined); + } + // Full app-scoped teardown through the CLI's own --fresh, exactly as + // local-dev-store.integration.ts does — removes the postgres-main-hosted + // server this fixture's `postgres({ name: 'appdb' })` created, the + // emulators' app-scoped records, and the local state dir, never the + // machine-global daemons. `--fresh` teardown runs BEFORE that session's + // own (mandatory) converge, so a direct `dev.teardown` afterwards removes + // what the cleanup session's converge itself just recreated. + try { + const cleanup = await startDevUntilReady( + baseEnv({ LOCALDEV_FIXTURE_API_KEY: 'cleanup-key', LOCALDEV_FIXTURE_GREETING: 'cleanup' }), + { fresh: true }, + ); + await stopDev(cleanup); + const descriptor = prismaCloud(); + const dev = descriptor.localTarget === undefined ? undefined : await descriptor.localTarget(); + if (dev?.teardown !== undefined) { + const container = await dev.container.ensure({ + appName: 'localdevs4fixture', + stage: undefined, + }); + await dev.teardown({ container, stage: undefined }); + } + } catch (error) { + console.error( + `[proving] final cleanup did not complete cleanly: ${error instanceof Error ? error.message : String(error)}`, + ); + } + const stray = spawnSync('pgrep', ['-f', `prisma-composer dev ${fixtureEntry}`]); + for (const line of (stray.stdout?.toString() ?? '').split('\n')) { + const pid = Number(line.trim()); + if (Number.isFinite(pid) && pid > 0) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // already gone + } + } + } + } +} + +main() + .then(() => { + process.exitCode = 0; + }) + .catch((error: unknown) => { + console.error(error instanceof Error ? (error.stack ?? error.message) : error); + dumpDiagnostics(); + process.exitCode = 1; + }); diff --git a/test/integration/test/local-dev-store.integration.ts b/test/integration/test/local-dev-store.integration.ts new file mode 100644 index 000000000..e853f6781 --- /dev/null +++ b/test/integration/test/local-dev-store.integration.ts @@ -0,0 +1,695 @@ +/** + * The plan-S5 proving script (plan.md's S5 outcome; local-dev spec's + * acceptance criteria 1, 2, 3, 6): drives the REAL, published + * `prisma-composer` binary — `examples/store/node_modules/.bin/prisma-composer` + * — as a real child process against `examples/store`, a real multi-module + * app (four services, two Postgres-backed modules, cron), exactly as an + * operator would run it. Every other integration script in this package + * drives the pipeline's own functions directly; this one is deliberately at + * arm's length — the CLI's argv parsing, its own SIGINT handling, its own + * watch loop, and its own process lifetime are themselves what's under + * test. + * + * Criteria proved here: + * 1. Credential-free bring-up: PRISMA_WORKSPACE_ID/PRISMA_SERVICE_TOKEN/ + * PRISMA_REGION are stripped from the child's env; the front door + * (`[dev] ready:` + one line per service) is parsed from real stdout; + * an HTTP round-trip against the storefront's URL succeeds. + * 2. Rebuild restarts exactly one service: touching ONLY catalog's built + * artifact (so its hash moves, nothing else's) restarts `catalog.service` + * alone — every other service's pid is unchanged, including its own RPC + * consumers (`orders.service`, `cron.runner`) and everything unrelated + * (`storefront`, `cron.scheduler`). Run twice in a row against the same + * live session — this is the regression proof for the + * restart-amplification defect fixed in compute.ts's `materializeEnv` + * (see its `scopedEnv` doc comment): before that fix, the FIRST rebuild + * after a cold start restarted catalog's RPC consumers too, even though + * nothing about them had changed. + * + * Attempt 1 drives the CLI's own real chokidar-based watch loop: the + * catalog build adapter declares `Bundle.watch: [runnable.source]` — + * the built artifact path itself (build.ts) — so touching + * `modules/catalog/dist/server.mjs` is exactly the file session 1's own + * running `prisma-composer dev` process is already watching. The script + * only touches the file and polls the compute emulator for the new pid + * — it never calls assemble or alchemy itself; the running process's + * own debounce, re-assemble, and re-converge are what's under test. + * Attempt 2 is the direct-converge variant kept as a fallback assertion: + * the artifact is touched, then the SAME dev stack file is re-converged + * directly with the real `alchemy` binary (matching + * local-dev.integration.ts's own pattern), bypassing the watch loop + * entirely — this re-hashes catalog's now-different bundle bytes and + * PUTs a fresh deployment for every service, letting the emulator's own + * (untouched) hash/env diffing decide which one(s) actually restart. + * Session 1's services are NEVER stopped in between (SIGINT is + * app-scoped and unconditionally restarts every service on the next + * redeploy — S3's own pinned "a stopped service always starts on + * redeploy" behavior — which would defeat this proof entirely). + * 3. Postgres persistence: a row is written directly against the real + * local Postgres URL (read from the postgres-main daemon's own + * database listing — the daemon owns instance state, REVISED — + * operator review of #162; never through the app's own RPC surface — + * this is a storage-layer proof, not a business-logic one) before + * Ctrl-C; a second `dev` start reads it back (warm); `--fresh` wipes + * it. + * 6. Warm restart: the second start's front-door ports match the first's + * exactly — no re-provisioning — AND an HTTP round-trip against the + * front door succeeds (the services genuinely serve after the resume; + * port-stability alone missed a live resume bug — spec § Acceptance). + * + * Not proved here (see the S5 report): criteria 4/5 (bucket, placeholder/ + * env-param) are proved against the S4 fixture instead — store declares + * neither a bucket nor a secret/env-param. + * + * WHY THIS IS A STANDALONE SCRIPT, NOT bun:test: same reasoning as + * local-dev.integration.ts — a detached grandchild a converge causes to + * spawn (the emulator daemon programs) loses stdout under `bun test`'s own + * process tree. Invoked by package.json's `test` script as a second + * `bun run` step. + */ + +import { type ChildProcess, spawn, spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { containerEnv } from '@prisma/composer/config'; +import { nodeBuild } from '@prisma/composer/node/control'; +import { prismaCloud } from '@prisma/composer-prisma-cloud/control'; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(`assertion failed: ${message}`); +} + +function assertEqual(actual: T, expected: T, message: string): void { + const same = JSON.stringify(actual) === JSON.stringify(expected); + if (!same) { + throw new Error( + `assertion failed: ${message}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`, + ); + } +} + +const integrationDir = path.resolve(import.meta.dir, '..'); +const repoRoot = path.resolve(integrationDir, '..', '..'); +const storeDir = path.join(repoRoot, 'examples', 'store'); +// Deliberately OUTSIDE `.prisma-composer/dev` — `--fresh` recursively +// removes that whole directory (runDevTeardown), which would delete these +// logs mid-run otherwise. +const logDir = path.join(integrationDir, '.local-dev-store-proving-logs'); +const CLI_BIN = path.join(storeDir, 'node_modules', '.bin', 'prisma-composer'); +const READY_TIMEOUT_MS = 90_000; +const SHUTDOWN_TIMEOUT_MS = 15_000; + +let sessionCount = 0; + +interface Endpoint { + readonly address: string; + readonly url: string; +} + +interface DevSession { + readonly child: ChildProcess; + readonly logPath: string; + readonly endpoints: readonly Endpoint[]; +} + +function credentialFreeEnv(): NodeJS.ProcessEnv { + const env = { ...process.env }; + delete env['PRISMA_WORKSPACE_ID']; + delete env['PRISMA_SERVICE_TOKEN']; + delete env['PRISMA_REGION']; + return env; +} + +/** `~/.prisma-composer/emulators/` — the one real, machine-global root, same as `defaultRegistryRoot()` resolves. */ +function emulatorRegistryRoot(): string { + return path.join(os.homedir(), '.prisma-composer', 'emulators'); +} + +function tailOf(filePath: string, n = 60): string { + let content: string; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + return ``; + } + const lines = content.split('\n'); + return lines.slice(-n).join('\n'); +} + +/** + * Everything a failure needs to be diagnosable from CI's own log output + * alone — the teed session logs (which themselves carry the CLI's inherited + * `alchemy` converge output inline) are on the runner, gone the moment the + * job ends. Bounded, never throws. + */ +async function dumpDiagnostics(): Promise { + console.error('\n=== diagnostics ==='); + for (let i = 1; i <= sessionCount; i += 1) { + const logPath = path.join(logDir, `session-${i}.log`); + console.error(`\n--- session-${i}.log (tail) ---`); + console.error(tailOf(logPath)); + } + for (const name of ['compute', 'buckets', 'postgres'] as const) { + console.error(`\n--- ${name} emulator log (tail) ---`); + console.error(tailOf(path.join(emulatorRegistryRoot(), `${name}.log`))); + } + console.error(`\n--- postgres-main databases (app ${APP_NAME}) ---`); + try { + console.error(JSON.stringify(await listPostgresDatabases(), null, 2)); + } catch (error) { + console.error( + ``, + ); + } + console.error('=== end diagnostics ===\n'); +} + +function readLog(logPath: string): string { + try { + return fs.readFileSync(logPath, 'utf8'); + } catch { + return ''; + } +} + +/** Parses the pinned front-door block out of the tee'd log: `[dev] ready:` then `[dev]
` lines, until a non-matching line. */ +function parseFrontDoor(log: string): readonly Endpoint[] | undefined { + const lines = log.split('\n'); + const readyAt = lines.findIndex((l) => l.trim() === '[dev] ready:'); + if (readyAt === -1) return undefined; + const endpoints: Endpoint[] = []; + for (let i = readyAt + 1; i < lines.length; i += 1) { + const m = /^\[dev\] (\S+)\s\s(\S+)$/.exec(lines[i] ?? ''); + if (m === null) break; + endpoints.push({ address: m[1] as string, url: m[2] as string }); + } + return endpoints.length > 0 ? endpoints : undefined; +} + +interface EmulatorRegistryEntry { + readonly pid: number; + readonly port: number; +} + +function isRegistryEntry(value: unknown): value is EmulatorRegistryEntry { + return ( + typeof value === 'object' && + value !== null && + 'pid' in value && + typeof value.pid === 'number' && + 'port' in value && + typeof value.port === 'number' + ); +} + +/** The documented on-disk registry contract (spec § 2 daemon.ts) — read directly, same as local-dev.integration.ts. */ +function readEmulatorEntry(name: 'compute' | 'postgres'): EmulatorRegistryEntry | undefined { + const parsed = readJson(path.join(emulatorRegistryRoot(), `${name}.json`)); + return isRegistryEntry(parsed) ? parsed : undefined; +} + +interface ServiceInfo { + readonly address: string; + readonly status: string; + readonly pid?: number; +} + +/** The documented Compute-emulator wire protocol (spec § 2 compute-main.ts) — plain fetch, never a typed client. */ +async function listComputeServices(app: string): Promise { + const entry = readEmulatorEntry('compute'); + if (entry === undefined) throw new Error('compute emulator registry entry not found'); + const res = await fetch(`http://127.0.0.1:${entry.port}/apps/${app}/services`); + if (!res.ok) throw new Error(`compute emulator listServices failed: ${res.status}`); + return (await res.json()) as ServiceInfo[]; +} + +interface PostgresDatabaseInfo { + readonly instanceName: string; + readonly url: string; +} + +/** The documented postgres-emulator wire protocol (spec § 4, REVISED — operator review of #162: the daemon owns instance state, there is no dev-store postgres.json) — plain fetch, never the typed client. */ +async function listPostgresDatabases(): Promise { + const entry = readEmulatorEntry('postgres'); + if (entry === undefined) throw new Error('postgres emulator registry entry not found'); + const res = await fetch(`http://127.0.0.1:${entry.port}/apps/${APP_NAME}/databases`); + if (!res.ok) throw new Error(`postgres emulator listDatabases failed: ${res.status}`); + return (await res.json()) as PostgresDatabaseInfo[]; +} + +/** Every address's pid, keyed by address — undefined for a stopped/absent service. */ +async function pidsByAddress(app: string): Promise> { + const services = await listComputeServices(app); + return Object.fromEntries(services.map((s) => [s.address, s.pid])); +} + +async function waitForAsync( + fn: () => Promise, + timeoutMs: number, + intervalMs = 250, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + for (;;) { + try { + const result = await fn(); + if (result !== undefined) return result; + } catch (error) { + lastError = error; + } + if (Date.now() >= deadline) { + throw lastError instanceof Error + ? lastError + : new Error(`waitFor: not met within ${timeoutMs}ms`); + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } +} + +/** + * Starts `prisma-composer dev module.ts [--fresh]` as a real, teed child + * process (stdout/stderr appended to a log FILE by raw fd — the same reason + * local-dev.integration.ts avoids piped capture: nested grandchildren losing + * output under some parent process trees). Bounded: the ready-wait itself is + * the hang guard — nothing here can wait forever. + */ +async function startDev(fresh: boolean): Promise { + sessionCount += 1; + fs.mkdirSync(logDir, { recursive: true }); + const logPath = path.join(logDir, `session-${sessionCount}.log`); + const logFd = fs.openSync(logPath, 'w'); + const args = ['dev', 'module.ts', ...(fresh ? ['--fresh'] : [])]; + const child = spawn(CLI_BIN, args, { + cwd: storeDir, + env: credentialFreeEnv(), + stdio: ['ignore', logFd, logFd], + }); + fs.closeSync(logFd); + + child.on('exit', (code, signal) => { + // Visible in the log for post-mortem; the log file itself already + // records everything the process printed before this. + console.log( + `[proving] session ${sessionCount} exited (code=${String(code)} signal=${String(signal)})`, + ); + }); + + const endpoints = await waitForAsync( + async () => parseFrontDoor(readLog(logPath)), + READY_TIMEOUT_MS, + 500, + ); + return { child, logPath, endpoints }; +} + +/** SIGINT, then wait for the process to actually exit (bounded) — the pinned Ctrl-C contract: stop, exit 0, emulators survive. */ +async function stopDev(session: DevSession): Promise { + if (session.child.exitCode !== null || session.child.signalCode !== null) return; + session.child.kill('SIGINT'); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`session did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGINT`)); + }, SHUTDOWN_TIMEOUT_MS); + session.child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +function readJson(file: string): unknown { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch { + return undefined; + } +} + +async function readCatalogDbUrl(): Promise { + const databases = await listPostgresDatabases(); + const entry = databases.find((d) => d.instanceName === 'pcdev-store-catalog-database'); + assert( + entry?.url !== undefined, + 'the postgres-main listing must carry a pcdev-store-catalog-database entry', + ); + return entry.url; +} + +/** Runs `bun`'s built-in SQL client against the real local Postgres URL — the storage layer, never the app's own RPC. */ +/** + * `prepare: false` — this script opens a fresh `Bun.SQL` client per call + * against the same URL; Bun's prepared-statement cache is keyed by query + * TEXT, and a rapid open/close/reopen cycle against the same connection + * string can race the previous connection's prepare cleanup, surfacing as + * `prepared statement "..." already exists` — confirmed live. This is a + * storage-layer proof, not a prepared-statement-caching one, so caching is + * simply off. + */ +async function withSql(url: string, fn: (sql: Bun.SQL) => Promise): Promise { + const sql = new Bun.SQL({ url, prepare: false }); + try { + return await fn(sql); + } finally { + await sql.close(); + } +} + +const PROVING_PRODUCT_ID = 'proving-row'; +const APP_NAME = 'store'; +const catalogDist = path.join(storeDir, 'modules', 'catalog', 'dist', 'server.mjs'); +const DEV_STACK_FILE = path.join(storeDir, '.prisma-composer', 'dev', 'alchemy.run.ts'); + +/** Walks up from `startDir` looking for `node_modules/.bin/alchemy` — same as local-dev.integration.ts's own helper. */ +function alchemyBin(startDir: string): string { + let dir = startDir; + for (;;) { + const candidate = path.join(dir, 'node_modules', '.bin', 'alchemy'); + if (fs.existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) + throw new Error(`could not find node_modules/.bin/alchemy above ${startDir}`); + dir = parent; + } +} + +/** + * Touches ONLY catalog's built artifact (append a harmless comment — its + * content hash moves, nothing else's does), re-runs the node build + * adapter's OWN `assemble()` for catalog specifically (it copies the built + * runnable into `.prisma-composer/artifacts/catalog.service/bundle/` — + * touching the source alone does not move anything alchemy actually reads; + * the copy must be refreshed), then re-converges the SAME dev stack file + * session 1's own `prisma-composer dev` already wrote, directly with the + * real `alchemy` binary — no CLI re-invocation, no SIGINT, session 1's + * services stay running throughout. The stack file's `bundles` map needs no + * edit: `assemble()`'s output directory is a deterministic function of the + * address, so it already points at the freshly-copied bytes. Re-hashing + * catalog's now-different bundle and PUTting a fresh deployment for every + * service lets the emulator's own (untouched) hash/env diffing decide which + * one(s) restart. Returns every service's pid AFTER the converge. + */ +const REAL_WATCH_TIMEOUT_MS = 30_000; + +/** + * Touches ONLY catalog's built artifact — the same file the running + * `prisma-composer dev` process's own chokidar watch loop is already + * watching (`Bundle.watch: [runnable.source]`, build.ts). This does not call + * assemble or alchemy itself: the running process's own debounce (300ms), + * re-assemble, and re-converge are what's being proved. + * + * Waits for the watch loop's OWN completion signal — run-dev.ts reprints the + * front door (`[dev] ready:` + one line per service) after a successful + * reconverge — rather than just for catalog.service's pid to move: the spec + * re-runs assemble for EVERY service on a fire (not just the touched one), + * so waiting on catalog's pid alone can return while the running process is + * still mid-converge for other services, racing this script's own later use + * of the same on-disk artifact directories (confirmed live: a direct + * fallback reconverge started right after the pid-only wait hit "no + * main.js/main.mjs found in bundle dir .../artifacts/storefront" — the real + * process's own converge was still writing it). Bounded by + * `REAL_WATCH_TIMEOUT_MS`. + */ +async function touchCatalogAndAwaitRealWatchLoop( + session: DevSession, + pidsBefore: Record, +): Promise> { + const beforeLineCount = readLog(session.logPath).split('\n').length; + + fs.appendFileSync(catalogDist, `\n// proving-script touch (real watch loop) ${Date.now()}\n`); + + await waitForAsync(async () => { + const newLines = readLog(session.logPath).split('\n').slice(beforeLineCount); + return parseFrontDoor(newLines.join('\n')); + }, REAL_WATCH_TIMEOUT_MS); + + return waitForAsync(async () => { + const pids = await pidsByAddress(APP_NAME); + return pids['catalog.service'] !== undefined && + pids['catalog.service'] !== pidsBefore['catalog.service'] + ? pids + : undefined; + }, 15_000); +} + +async function rebuildCatalogAndReconverge(): Promise> { + fs.appendFileSync(catalogDist, `\n// proving-script touch ${Date.now()}\n`); + + const catalogServiceModule = path.join(storeDir, 'modules', 'catalog', 'src', 'service.ts'); + const buildDescriptor = nodeBuild().nodes['node']; + assert( + buildDescriptor !== undefined && buildDescriptor.kind === 'build', + 'nodeBuild() must declare a "node" build descriptor', + ); + await buildDescriptor.assemble({ + build: { + extension: '@prisma/composer/node', + type: 'node', + module: `file://${catalogServiceModule}`, + entry: '../dist/server.mjs', + }, + address: 'catalog.service', + cwd: storeDir, + }); + + const descriptor = prismaCloud(); + if (descriptor.localTarget === undefined) throw new Error('no localTarget descriptor'); + const dev = await descriptor.localTarget(); + const container = await dev.container.ensure({ appName: APP_NAME, stage: undefined }); + const envVars = containerEnv(new Map([[descriptor.id, container]])); + + const result = spawnSync( + alchemyBin(storeDir), + ['deploy', path.relative(storeDir, DEV_STACK_FILE), '--yes', '--stage', 'dev'], + { cwd: storeDir, stdio: 'inherit', env: { ...process.env, ...envVars } }, + ); + assert( + result.status === 0, + `re-converge (alchemy deploy) failed with status ${String(result.status)}`, + ); + + // The pid the emulator reports settles a moment after the converge + // completes (the restarted child still needs to actually start). + return waitForAsync(async () => { + const pids = await pidsByAddress(APP_NAME); + return pids['catalog.service'] !== undefined ? pids : undefined; + }, 15_000); +} + +async function main(): Promise { + console.log('local dev (S5 proving): examples/store via the real prisma-composer dev binary'); + + fs.rmSync(path.join(storeDir, '.prisma-composer'), { recursive: true, force: true }); + fs.rmSync(path.join(storeDir, '.alchemy'), { recursive: true, force: true }); + + let session: DevSession | undefined; + try { + // ——— Criterion 1: credential-free bring-up + HTTP round-trip ——— + session = await startDev(false); + console.log(`[proving] session 1 ready: ${JSON.stringify(session.endpoints)}`); + assert( + session.endpoints.some((e) => e.address === 'storefront'), + 'the front door must list the storefront service', + ); + const storefront = session.endpoints.find((e) => e.address === 'storefront'); + if (storefront === undefined) throw new Error('unreachable'); + const health = await waitForAsync( + () => fetch(storefront.url).then((r) => (r.ok ? r : undefined)), + 15_000, + ); + assertEqual(health.status, 200, 'the storefront HTTP round-trip'); + console.log('[proving] PASS criterion 1: credential-free bring-up + HTTP round-trip'); + + const firstPortsByAddress = Object.fromEntries( + session.endpoints.map((e) => [e.address, new URL(e.url).port]), + ); + + // ——— Criterion 2: rebuild restarts exactly one service ——— + // Regression proof for the restart-amplification defect: run it TWICE + // against the SAME live session — the defect this fixes was specific to + // the FIRST rebuild after a cold start (env.json's per-service snapshot + // was complete only from the second converge onward), so a single + // rebuild alone would not have caught it. + const ADDRESSES = [ + 'catalog.service', + 'storefront', + 'cron.runner', + 'orders.service', + 'cron.scheduler', + ]; + const pidsBaseline = await waitForAsync(async () => { + const pids = await pidsByAddress(APP_NAME); + return ADDRESSES.every((a) => pids[a] !== undefined) ? pids : undefined; + }, 15_000); + + const activeSession = session; + assert(activeSession !== undefined, 'session 1 must be running before criterion 2'); + + let pidsBefore = pidsBaseline; + const RECONVERGE_STRATEGIES = [ + { + label: 'real watch loop', + run: (before: Record) => + touchCatalogAndAwaitRealWatchLoop(activeSession, before), + }, + { label: 'direct converge (fallback)', run: () => rebuildCatalogAndReconverge() }, + ] as const; + for (const [index, strategy] of RECONVERGE_STRATEGIES.entries()) { + const attempt = index + 1; + const pidsAfter = await strategy.run(pidsBefore); + assert( + pidsAfter['catalog.service'] !== pidsBefore['catalog.service'], + `criterion 2 (attempt ${attempt}, ${strategy.label}): catalog.service must restart (new pid)`, + ); + for (const address of ['storefront', 'cron.runner', 'orders.service', 'cron.scheduler']) { + assertEqual( + pidsAfter[address], + pidsBefore[address], + `criterion 2 (attempt ${attempt}, ${strategy.label}): ${address}'s pid must NOT change — only catalog.service was rebuilt`, + ); + } + console.log( + `[proving] PASS criterion 2 (attempt ${attempt}, ${strategy.label}): exactly catalog.service restarted, all four others unchanged`, + ); + pidsBefore = pidsAfter; + } + + // ——— Criterion 3 (write half): a row through the real local Postgres URL ——— + const dbUrl = await readCatalogDbUrl(); + await withSql(dbUrl, async (sql) => { + // prisma-next's migration DDL folds the table name and single-word + // columns to lowercase (unquoted identifiers); "priceCents" alone + // stays quoted, mixed-case. + await sql`insert into product (id, name, description, "priceCents") + values (${PROVING_PRODUCT_ID}, 'Proving Row', 'S5 proving script', 100) + on conflict (id) do update set name = excluded.name`; + }); + console.log('[proving] wrote the proving row through the real local Postgres URL'); + + // ——— Ctrl-C ——— + await stopDev(session); + console.log('[proving] session 1 stopped cleanly on SIGINT'); + + // ——— Criterion 3 (warm read) + criterion 6 (same ports) ——— + session = await startDev(false); + console.log(`[proving] session 2 (warm) ready: ${JSON.stringify(session.endpoints)}`); + const secondPortsByAddress = Object.fromEntries( + session.endpoints.map((e) => [e.address, new URL(e.url).port]), + ); + assertEqual( + secondPortsByAddress, + firstPortsByAddress, + 'criterion 6: warm restart keeps the same ports', + ); + + // The pinned half of criterion 6 (spec § Acceptance): after the warm + // restart the services must GENUINELY SERVE, not merely be listed with + // stable ports — a converge that diffs nothing starts nothing, so only + // the attach step's startServices() resume makes this round-trip + // succeed. Port stability alone let exactly that resume bug ship. + const warmStorefront = session.endpoints.find((e) => e.address === 'storefront'); + assert(warmStorefront !== undefined, 'the warm front door must list the storefront service'); + const warmHealth = await waitForAsync( + () => fetch(warmStorefront.url).then((r) => (r.ok ? r : undefined)), + 15_000, + ); + assertEqual( + warmHealth.status, + 200, + 'criterion 6: the HTTP round-trip succeeds after a warm restart (services resumed)', + ); + + const warmDbUrl = await readCatalogDbUrl(); + assertEqual(warmDbUrl, dbUrl, 'criterion 6: the Postgres URL is stable across a warm restart'); + const warmRow = await withSql( + warmDbUrl, + (sql) => sql`select name from product where id = ${PROVING_PRODUCT_ID}`, + ); + assertEqual( + (warmRow as unknown as { name: string }[])[0]?.name, + 'Proving Row', + 'criterion 3: the row survives a warm (non---fresh) restart', + ); + console.log('[proving] PASS criterion 3 (warm): row survived a Ctrl-C restart'); + console.log( + '[proving] PASS criterion 6: same ports, no re-provisioning, and a serving front door across a warm restart', + ); + + await stopDev(session); + console.log('[proving] session 2 stopped cleanly on SIGINT'); + + // ——— Criterion 3 (--fresh half): the instance and its data are gone ——— + session = await startDev(true); + console.log(`[proving] session 3 (--fresh) ready: ${JSON.stringify(session.endpoints)}`); + const freshDbUrl = await readCatalogDbUrl(); + // --fresh removes the app's postgres-main-hosted servers and their + // persisted data (DELETE /apps/ on the daemon, runDevTeardown) and + // recreates them on next provision — migrations reapply as part of THIS + // SAME converge (PnMigration runs on every converge), so the "product" + // table exists again by the time the front door prints; the proof is + // that it's freshly migrated and EMPTY — the proving row from before + // --fresh is gone. + const freshRow = await withSql( + freshDbUrl, + (sql) => sql`select name from product where id = ${PROVING_PRODUCT_ID}`, + ); + assertEqual( + (freshRow as unknown as { name: string }[]).length, + 0, + 'criterion 3 (--fresh): the proving row must be gone from a fresh instance', + ); + console.log('[proving] PASS criterion 3 (--fresh): the prior instance and its data are gone'); + + console.log('PASS: local dev (S5 proving) — examples/store, criteria 1/2/3/6'); + } finally { + if (session !== undefined) { + await stopDev(session).catch(() => undefined); + } + // Final app-scoped teardown, mirroring local-dev.integration.ts: never + // touch the machine-global emulator daemons, only this app's own + // records. `--fresh` teardown only runs BEFORE that session's own + // (mandatory) converge, never after — so `startDev(true)` + `stopDev` + // alone still leaves the postgres-main-hosted servers THIS cleanup + // session's own converge just recreated. Call `dev.teardown` directly + // afterward to actually remove them (`--fresh` then Ctrl-C then + // teardown leaves nothing running and no data behind for the next run). + try { + const cleanup = await startDev(true); + await stopDev(cleanup); + const descriptor = prismaCloud(); + const dev = descriptor.localTarget === undefined ? undefined : await descriptor.localTarget(); + if (dev?.teardown !== undefined) { + const container = await dev.container.ensure({ + appName: APP_NAME, + stage: undefined, + }); + await dev.teardown({ container, stage: undefined }); + } + } catch (error) { + console.error( + `[proving] final --fresh cleanup did not complete cleanly: ${error instanceof Error ? error.message : String(error)}`, + ); + } + const stray = spawnSync('pgrep', ['-f', 'prisma-composer dev module.ts']); + for (const line of (stray.stdout?.toString() ?? '').split('\n')) { + const pid = Number(line.trim()); + if (Number.isFinite(pid) && pid > 0) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // already gone + } + } + } + } +} + +main() + .then(() => { + process.exitCode = 0; + }) + .catch(async (error: unknown) => { + console.error(error instanceof Error ? (error.stack ?? error.message) : error); + await dumpDiagnostics(); + process.exitCode = 1; + }); diff --git a/test/integration/test/local-dev.integration.ts b/test/integration/test/local-dev.integration.ts index 74da2f150..57addca0d 100644 --- a/test/integration/test/local-dev.integration.ts +++ b/test/integration/test/local-dev.integration.ts @@ -107,19 +107,67 @@ const ALCHEMY_TIMEOUT_MS = 60_000; let convergeCount = 0; -function alchemyBin(): string { +function resolveBin(name: string): string | undefined { let dir = integrationDir; for (;;) { - const candidate = path.join(dir, 'node_modules', '.bin', 'alchemy'); + const candidate = path.join(dir, 'node_modules', '.bin', name); if (fs.existsSync(candidate)) return candidate; const parent = path.dirname(dir); - if (parent === dir) { - throw new Error(`could not find node_modules/.bin/alchemy above ${integrationDir}`); - } + if (parent === dir) return undefined; dir = parent; } } +function alchemyBin(): string { + const bin = resolveBin('alchemy'); + if (bin === undefined) { + throw new Error(`could not find node_modules/.bin/alchemy above ${integrationDir}`); + } + return bin; +} + +/** Last `n` lines of a file, or a note explaining why there's nothing to show — never throws. */ +function tailOf(filePath: string, n = 60): string { + let content: string; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + return ``; + } + const lines = content.split('\n'); + return lines.slice(-n).join('\n'); +} + +/** + * Prints everything a failure needs to be diagnosable from CI's own log + * output alone — the teed files a failed run left behind are on the runner, + * gone the moment the job ends, so their content has to reach stdout/stderr + * BEFORE the process exits, not just live on disk. Bounded (tails only), + * never throws (a missing file/daemon is itself diagnostic, not fatal to + * the diagnostic dump). + */ +async function dumpDiagnostics(): Promise { + console.error('\n=== diagnostics ==='); + for (let i = 1; i <= convergeCount; i += 1) { + const logFile = path.join(logDir, `converge-${i}.log`); + console.error(`\n--- converge-${i}.log (tail) ---`); + console.error(tailOf(logFile)); + } + for (const name of ['compute', 'buckets', 'postgres'] as const) { + console.error(`\n--- ${name} emulator log (tail) ---`); + console.error(tailOf(path.join(emulatorRegistryRoot(), `${name}.log`))); + } + console.error(`\n--- postgres-main databases (app ${APP_NAME}) ---`); + try { + console.error(JSON.stringify(await listPostgresDatabases(), null, 2)); + } catch (error) { + console.error( + ``, + ); + } + console.error('=== end diagnostics ===\n'); +} + function relImportSpecifier(fromDir: string, toFile: string): string { const rel = path.relative(fromDir, toFile).split(path.sep).join('/'); return rel.startsWith('.') ? rel : `./${rel}`; @@ -377,6 +425,16 @@ async function main(): Promise { bucketsPreExisting = readEmulatorEntry('buckets') !== undefined; postgresPreExisting = readEmulatorEntry('postgres') !== undefined; + // dev.preflight() (unlike every other dev hook here) computes its own + // state directory from `process.cwd()` rather than accepting one — every + // other call in this script is handed `devDir` explicitly (fixtureDir- + // based). This script runs from the package root (test/integration), not + // the fixture dir, so preflight must be run with cwd temporarily pointed + // at the fixture — otherwise its secrets.json write (S5's secret/env-param + // proof) lands somewhere this script never looks. Restored in `finally`. + const originalCwd = process.cwd(); + process.chdir(fixtureDir); + try { // 1. Construct the extension with NO PRISMA_* present — proves the // scrubbed-env construction requirement through the real factory. @@ -413,10 +471,16 @@ async function main(): Promise { // 4. Assemble both services (real build adapter, hand-written "built" entries). const bundles = await assembleFixture(); - // 5. Preflight — this fixture binds no secrets/env-params; must be a no-op. + // 5. Preflight — the fixture's secret/env-param proof lives in the + // dedicated S5 script (local-dev-criteria-4-5.integration.ts), which + // deliberately runs WITHOUT these set; this script sets both so its own + // (unrelated) proofs keep passing regardless of shell state. + process.env['LOCALDEV_FIXTURE_API_KEY'] = 'test-api-key'; + process.env['LOCALDEV_FIXTURE_GREETING'] = 'hello'; if (dev.preflight !== undefined) { await dev.preflight({ graph, container: devContainer, stage: undefined }); } + process.chdir(originalCwd); // 6. Emulators — ensures compute always, buckets because the graph has // an `s3`-kinded resource, postgres because the graph has a @@ -492,12 +556,21 @@ async function main(): Promise { 'the deployed web child carries COMPOSER_WEB_PORT = the emulator-assigned port, JSON-encoded', ); - // secrets.json: no secrets/params in this fixture, so it is either - // absent or empty — never populated. - const secrets = readJson(path.join(devDir, 'secrets.json')); - assert( - secrets === undefined || Object.keys(secrets as object).length === 0, - 'secrets.json must be absent or empty for this fixture', + // secrets.json: the shell-sourced secret + env-param this run set above, + // verbatim (S5 addition — the placeholder/hard-error paths have their + // own dedicated script). + const secrets = readJson(path.join(devDir, 'secrets.json')) as Record; + // Compared as a boolean so the secret's value never rides an assertion + // error into the harness's failure log (CodeQL js/clear-text-logging). + assertEqual( + secrets['LOCALDEV_FIXTURE_API_KEY'] === 'test-api-key', + true, + 'secrets.json carries the shell-sourced secret verbatim', + ); + assertEqual( + secrets['LOCALDEV_FIXTURE_GREETING'], + 'hello', + 'secrets.json carries the shell-sourced env-param', ); // 11. The postgres-main daemon's own listing (REVISED — operator review @@ -589,6 +662,7 @@ async function main(): Promise { console.log('PASS: local dev (S4) integration proof'); } finally { + process.chdir(originalCwd); await attachment?.stopServices().catch(() => undefined); // App-scoped teardown through the extension's own public @@ -636,7 +710,11 @@ main() .then(() => { process.exitCode = 0; }) - .catch((error: unknown) => { - console.error(error instanceof Error ? (error.stack ?? error.message) : error); + .catch(async (error: unknown) => { + // Mask credentialed URLs (spec's masking contract) — a converge error can + // embed a live connection string, and this line reaches CI's public log. + const text = error instanceof Error ? (error.stack ?? error.message) : String(error); + console.error(text.replace(/:\/\/([^:@/\s]+):[^@/\s]+@/g, '://$1:***@')); + await dumpDiagnostics(); process.exitCode = 1; });