From 1ab6d1f076ec649776176a9b777a407947753cff Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 19:55:05 +0200 Subject: [PATCH 1/7] fix(dev): reserve service ports in graph order before the deploy engine runs prisma dev gave each service the next free port in whatever order Alchemy applied the App resources. Those resources are unlinked, so Alchemy applies them concurrently, and a fresh start of the getting-started app put quotes on 3001 and gateway on 3000 about half the time, against the docs. The emulators hook now reserves every Prisma Cloud service's port one at a time, in graph.nodes order (dependencies first, ties in declaration order), right after the Compute emulator is up. The App providers then find their port already reserved. A service that already has a port keeps it, so warm starts are unchanged. Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/__tests__/compute-port-order.test.ts | 94 +++++++++++++++++++ .../0-lowering/local-target/src/compute.ts | 19 +++- .../__tests__/local-target-emulators.test.ts | 89 ++++++++++++++++++ .../target/src/local-target/emulators.ts | 15 ++- 4 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-port-order.test.ts create mode 100644 packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-emulators.test.ts diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-port-order.test.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-port-order.test.ts new file mode 100644 index 000000000..5385e124b --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-port-order.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { ContainerInstance } from '@internal/core/config'; +import { + computeClient, + EMULATORS_DIR_ENV, + ensureDaemon, + stopDaemon, +} from '@internal/dev-emulators'; +import { App } from 'alchemy/Prisma'; +import * as Effect from 'effect/Effect'; +import { LocalAppProvider, reserveServicePorts } from '../compute.ts'; + +const savedEmulatorsDir = process.env[EMULATORS_DIR_ENV]; +let registryRoot: string; + +beforeEach(async () => { + registryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'local-target-port-order-')); + process.env[EMULATORS_DIR_ENV] = registryRoot; + const entry = fileURLToPath(import.meta.resolve('@internal/dev-emulators/compute-main')); + await ensureDaemon('compute', entry, { registryRoot }); +}); + +afterEach(async () => { + await stopDaemon('compute', { registryRoot }).catch(() => undefined); + if (savedEmulatorsDir === undefined) delete process.env[EMULATORS_DIR_ENV]; + else process.env[EMULATORS_DIR_ENV] = savedEmulatorsDir; +}); + +function fakeContainer(appName: string): ContainerInstance { + return { input: { appName, stage: undefined }, serialize: () => 'x' }; +} + +const reconcileInput = (id: string, news: Record) => + ({ + id, + fqn: id, + instanceId: id, + news, + olds: undefined, + output: undefined, + session: undefined as never, + bindings: [], + }) as never; + +async function reconcileApp(container: ContainerInstance, address: string): Promise { + const provider = await Effect.runPromise( + App.Provider.pipe(Effect.provide(LocalAppProvider({ container, devDir: '/dev/null/unused' }))), + ); + const attributes: App['Attributes'] = await Effect.runPromise( + provider.reconcile( + reconcileInput(`${address}-svc`, { project: 'local', displayName: address }), + ), + ); + return Number(new URL(attributes.appEndpointDomain ?? '').port); +} + +test('services reserved in graph order keep that order when the App providers then race', async () => { + for (let run = 0; run < 10; run++) { + const container = fakeContainer(`port-order-${String(run)}`); + await reserveServicePorts(container, ['quotes', 'gateway']); + + const [gateway, quotes] = await Promise.all([ + reconcileApp(container, 'gateway'), + reconcileApp(container, 'quotes'), + ]); + + expect(quotes).toBeLessThan(gateway); + } +}); + +test('reserving the services of an app that already has ports keeps those ports', async () => { + const container = fakeContainer('port-order-warm'); + const client = computeClient(); + const gateway = await client.ensureService('port-order-warm', 'gateway'); + const quotes = await client.ensureService('port-order-warm', 'quotes'); + + await reserveServicePorts(container, ['quotes', 'gateway']); + + expect(await reconcileApp(container, 'quotes')).toBe(quotes.port); + expect(await reconcileApp(container, 'gateway')).toBe(gateway.port); +}); + +test('a dotted service address reserves the same emulator service the App provider uses', async () => { + const container = fakeContainer('port-order-nested'); + await reserveServicePorts(container, ['shop.quotes']); + + const listed = await computeClient().listServices('port-order-nested'); + expect(await reconcileApp(container, 'shop.quotes')).toBe(listed[0]?.port ?? -1); + expect(listed).toHaveLength(1); +}); 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 a368c3577..fc9f67569 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 @@ -10,7 +10,7 @@ import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import type { LocalTargetProvidersInput } from '@internal/core/config'; +import type { ContainerInstance, LocalTargetProvidersInput } from '@internal/core/config'; import { computeClient } from '@internal/dev-emulators'; import { App, Deployment, EnvironmentVariable, Project } from 'alchemy/Prisma'; import * as Provider from 'alchemy/Provider'; @@ -213,6 +213,23 @@ export function LocalAppProvider( return Provider.effect(App, Effect.succeed(service)); } +/** + * Reserves each service's emulator port one at a time, in the order given, + * before Alchemy applies the `App` resources concurrently. A new service gets + * the smallest free port, so on a fresh start the ports follow this order; a + * service that already has a port keeps it. `addresses` are the ids the + * `App` resources carry as `displayName`. + */ +export async function reserveServicePorts( + container: ContainerInstance | undefined, + addresses: readonly string[], +): Promise { + const appName = appNameOf(container); + for (const address of addresses) { + await computeClient().ensureService(appName, slugServiceId(address)); + } +} + /** `Prisma.EnvironmentVariable` → a key/value row in `/env.json`. Upstream's value is `Redacted`; env.json holds the plain string the child process is given. */ export function LocalEnvironmentVariableProvider( input: LocalTargetProvidersInput, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-emulators.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-emulators.test.ts new file mode 100644 index 000000000..184fedcc1 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-emulators.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, expect, mock, test } from 'bun:test'; +import type { Contract } from '@internal/core'; +import { dependency, Load, module, string } from '@internal/core'; +import type { ContainerInstance } from '@internal/core/config'; +import * as RealDevEmulators from '@internal/dev-emulators'; +import * as RealLocalTarget from '@internal/local-target'; +import { compute, rawPostgres } from '../exports/index.ts'; + +const events: string[] = []; + +mock.module('@internal/dev-emulators', () => ({ + ...RealDevEmulators, + ensureDaemon: async (name: string) => { + events.push(`daemon ${name}`); + return { url: `http://127.0.0.1/${name}` }; + }, +})); + +mock.module('@internal/local-target', () => ({ + ...RealLocalTarget, + resolvePackageEntry: (specifier: string) => specifier, + reserveServicePorts: async ( + container: ContainerInstance | undefined, + addresses: readonly string[], + ) => { + events.push(`reserve ${container?.input.appName ?? '?'}: ${addresses.join(', ')}`); + }, +})); + +const { runDevEmulators } = await import('../local-target/emulators.ts'); + +const build = { + extension: '@prisma/composer/node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', +}; + +const quotesContract: Contract<'rpc', Record> = { + kind: 'rpc', + __cmp: {}, + satisfies: () => true, +}; + +const rpcDep = () => + dependency({ + type: 'rpc', + connection: { params: { url: string() }, hydrate: (v) => v }, + }); + +const container: ContainerInstance = { + input: { appName: 'my-app', stage: undefined }, + serialize: () => 'x', +}; + +beforeEach(() => { + events.length = 0; +}); + +test('reserves the getting-started services in dependency order once the compute emulator is up', async () => { + const graph = Load( + module('my-app', ({ provision }) => { + const quotes = provision( + compute({ name: 'quotes', deps: {}, build, expose: { rpc: quotesContract } }), + ); + provision(compute({ name: 'gateway', deps: { quotes: rpcDep() }, build }), { + deps: { quotes: quotes.rpc }, + }); + }), + ); + + await runDevEmulators({ graph, container, devDir: '/dev/null/unused' }); + + expect(events).toEqual(['daemon compute', 'reserve my-app: quotes, gateway']); +}); + +test('reserves only services, in declaration order when nothing orders them', async () => { + const graph = Load( + module('my-app', ({ provision }) => { + provision(compute({ name: 'web', deps: {}, build })); + provision(rawPostgres({ name: 'db' }), { id: 'db' }); + provision(compute({ name: 'admin', deps: {}, build })); + }), + ); + + await runDevEmulators({ graph, container, devDir: '/dev/null/unused' }); + + expect(events).toEqual(['daemon compute', 'reserve my-app: web, admin', 'daemon postgres']); +}); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/local-target/emulators.ts b/packages/1-prisma-cloud/1-extensions/target/src/local-target/emulators.ts index 03b7b270c..51c4a6328 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/local-target/emulators.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/local-target/emulators.ts @@ -6,6 +6,10 @@ * `postgres` resource kind (REVISED — Postgres is a first-class daemon * since the programmatic `@prisma/dev` adoption, operator review of #162). * + * Once Compute is up, every service's port is reserved in graph order, so a + * fresh start numbers ports dependencies first; Alchemy's concurrent `App` + * applies would otherwise pick them in arrival order. + * * Idempotent: `ensureDaemon` itself adopts an already-healthy daemon, so * repeated `prisma dev` sessions are cheap. * @@ -21,7 +25,8 @@ import type { LocalTargetEmulatorsInput } from '@internal/core/config'; import type { DaemonName } from '@internal/dev-emulators'; import { ensureDaemon } from '@internal/dev-emulators'; -import { resolvePackageEntry } from '@internal/local-target'; +import { reserveServicePorts, resolvePackageEntry } from '@internal/local-target'; +import { PRISMA_CLOUD_EXTENSION_ID } from '../container.ts'; function usesBuckets(input: LocalTargetEmulatorsInput): boolean { return input.graph.nodes.some((n) => n.node.kind === 'resource' && n.node.type === 's3'); @@ -34,6 +39,13 @@ function usesPostgres(input: LocalTargetEmulatorsInput): boolean { ); } +/** This extension's services, in `graph.nodes` order: dependencies first, ties in declaration order. */ +function serviceAddresses(input: LocalTargetEmulatorsInput): string[] { + return input.graph.nodes + .filter((n) => n.node.kind === 'service' && n.node.extension === PRISMA_CLOUD_EXTENSION_ID) + .map((n) => n.id); +} + /** The resolved absolute path to this daemon's published entrypoint. */ function daemonEntry(name: DaemonName): string { return resolvePackageEntry(`@prisma/composer-prisma-cloud/local-target/${name}-main`); @@ -42,6 +54,7 @@ function daemonEntry(name: DaemonName): string { export async function runDevEmulators(input: LocalTargetEmulatorsInput): Promise { const { url: computeUrl } = await ensureDaemon('compute', daemonEntry('compute')); console.log(`[dev] compute emulator ready at ${computeUrl}`); + await reserveServicePorts(input.container, serviceAddresses(input)); if (usesBuckets(input)) { const { url: bucketsUrl } = await ensureDaemon('buckets', daemonEntry('buckets')); From 5a2e9d3d9b64b2e13b542fdc3716844618fe6d22 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 19:55:08 +0200 Subject: [PATCH 2/7] docs(dev): say that fresh starts number service ports in dependency order The design doc, the running-locally guide and the skill now state the rule: on a fresh start, services get ports from 3000 up in dependency order, ties in declaration order; a warm start keeps saved ports. Signed-off-by: willbot Signed-off-by: Will Madden --- docs/design/10-domains/local-dev.md | 6 +----- docs/guides/running-locally.md | 2 ++ skills/prisma-composer-core-concepts/SKILL.md | 4 +--- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/docs/design/10-domains/local-dev.md b/docs/design/10-domains/local-dev.md index c5c5cc60c..583b0a7d1 100644 --- a/docs/design/10-domains/local-dev.md +++ b/docs/design/10-domains/local-dev.md @@ -299,11 +299,7 @@ Deploy's rule holds: every failure names its fix. (none outstanding from the design phase — see Known limitations below for gaps found during implementation.) -(Settled since the first draft: Postgres runs one named `@prisma/dev` server -per `Database` resource; the front door prints every service URL ordered by -address depth then name, shallowest first; port allocation and the remaining -mechanics are described in this document and [ADR-0041](../90-decisions/ADR-0041-local-dev-runs-the-deploy-pipeline-against-local-providers.md). -Restart latency is measured — see Known limitations.) +(Settled since the first draft: Postgres runs one named `@prisma/dev` server per `Database` resource; the front door prints every service URL ordered by address depth then name, shallowest first; on a fresh start, services get their ports in dependency order, ties in declaration order — the emulators hook reserves them one at a time from `graph.nodes` before Alchemy applies the `App` resources concurrently — and a warm start keeps each service's saved port; port allocation and the remaining mechanics are described in this document and [ADR-0041](../90-decisions/ADR-0041-local-dev-runs-the-deploy-pipeline-against-local-providers.md). Restart latency is measured — see Known limitations.) ## Proven against a real app diff --git a/docs/guides/running-locally.md b/docs/guides/running-locally.md index 8bd161527..da66b7e04 100644 --- a/docs/guides/running-locally.md +++ b/docs/guides/running-locally.md @@ -57,6 +57,8 @@ buckets, and their data stay up, so the next `prisma dev` is a warm start — same ports, same data. `--fresh` is what wipes this app's local instances and data before starting. +On a fresh start, each service gets the lowest free port from 3000 up, in dependency order: a service gets its port before the services that call it, and services with no dependency between them go in the order you provisioned them. A warm start keeps the ports services already have. + `--fresh` is also the fix when a framework upgrade leaves stale rows in this app's local dev state — the symptom is a plan-time error naming an unregistered resource type (for example diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index e94e930ca..5d6183c3a 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -433,9 +433,7 @@ that surprise: 1. It runs the same pipeline as deploy, so **build first**, exactly like deploy. It watches built output and restarts a service when its build changes. -2. Ctrl-C stops the app's processes but leaves local databases, buckets, and - their data up: the next `dev` is a warm start. Starting clean, wiping - this app's local instances and data first, is an explicit opt-in flag. +2. Ctrl-C stops the app's processes but leaves local databases, buckets, and their data up: the next `dev` is a warm start. Starting clean, wiping this app's local instances and data first, is an explicit opt-in flag. On a fresh start, service ports count up from 3000 in dependency order (a callee before its callers, otherwise provision order); a warm start keeps them. 3. `dev` does not print service logs. The `log` operation follows the already-running app's merged logs; it never builds, provisions, starts, or stops anything: From a96189dd6630507fcf3c9bd2be2b48066c9a1010 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 20:14:23 +0200 Subject: [PATCH 3/7] fix(dev): make --fresh give back the lowest ports Two cases kept old or higher ports after --fresh. If the compute emulator was stopped, teardown skipped its delete, and the restarted daemon reloaded the saved ports. If the delete ran, get-port still held the freed ports for up to 30 seconds, so the next reservation skipped them. Teardown now starts the compute emulator before deleting the app. The emulator clears get-port's held ports when it deletes an app; its own saved allocations already keep other apps' ports out. Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/__tests__/compute-multi-app.test.ts | 11 ++++ .../dev-emulators/src/compute-main.ts | 6 +- .../__tests__/local-target-teardown.test.ts | 55 +++++++++++++++++++ .../target/src/local-target/daemon-entry.ts | 7 +++ .../target/src/local-target/emulators.ts | 18 +++--- .../target/src/local-target/teardown.ts | 13 ++++- 6 files changed, 96 insertions(+), 14 deletions(-) create mode 100644 packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-teardown.test.ts create mode 100644 packages/1-prisma-cloud/1-extensions/target/src/local-target/daemon-entry.ts diff --git a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/__tests__/compute-multi-app.test.ts b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/__tests__/compute-multi-app.test.ts index 7bf9d8f2b..871f5510f 100644 --- a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/__tests__/compute-multi-app.test.ts +++ b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/__tests__/compute-multi-app.test.ts @@ -129,6 +129,17 @@ test('deleting one app leaves the other app registered', async () => { expect(twoList).toHaveLength(1); }); +test('an app deleted and reserved again gets back the ports it freed', async () => { + const client = computeClient({ registryRoot }); + const quotes = await client.ensureService('tenant-one', 'quotes'); + const gateway = await client.ensureService('tenant-one', 'gateway'); + + await client.deleteApp('tenant-one'); + + expect((await client.ensureService('tenant-one', 'quotes')).port).toBe(quotes.port); + expect((await client.ensureService('tenant-one', 'gateway')).port).toBe(gateway.port); +}); + describe('path-segment hygiene', () => { test('an app name with an uppercase letter is rejected with 400', async () => { const { url } = await ensureDaemon('compute', entryFor('compute'), { registryRoot }); diff --git a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/compute-main.ts b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/compute-main.ts index e432ee738..174e273a4 100644 --- a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/compute-main.ts +++ b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/compute-main.ts @@ -14,7 +14,7 @@ import { type ChildProcess, spawn } from 'node:child_process'; import * as fs from 'node:fs'; import * as http from 'node:http'; import * as path from 'node:path'; -import getPort, { portNumbers } from 'get-port'; +import getPort, { clearLockedPorts, portNumbers } from 'get-port'; import { readOwnVersion } from './daemon.ts'; import { isValidSegment } from './segments.ts'; import { readJsonFile, StateFile } from './state-file.ts'; @@ -757,6 +757,10 @@ function main(): void { } delete state[app]; schedulePersist(); + // get-port holds every port it returns for up to 30 seconds; the + // freed ports must be free again for a `--fresh` restart. + // `usedServicePorts()` already keeps other apps' ports out. + clearLockedPorts(); } await fs.promises.rm(path.join(stateDir, 'logs', app), { recursive: true, force: true }); res.writeHead(204); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-teardown.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-teardown.test.ts new file mode 100644 index 000000000..269b5dae5 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-teardown.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, expect, mock, test } from 'bun:test'; +import * as RealDevEmulators from '@internal/dev-emulators'; +import * as RealLocalTarget from '@internal/local-target'; +import { PrismaCloudContainer } from '../container.ts'; + +const events: string[] = []; + +const unreachable = (name: string) => () => ({ + deleteApp: async () => { + throw new Error(`${name} emulator is not running`); + }, +}); + +mock.module('@internal/dev-emulators', () => ({ + ...RealDevEmulators, + ensureDaemon: async (name: string) => { + events.push(`daemon ${name}`); + return { url: `http://127.0.0.1/${name}` }; + }, + computeClient: () => ({ + deleteApp: async (app: string) => { + events.push(`delete compute ${app}`); + }, + }), + postgresClient: unreachable('postgres'), + bucketsClient: unreachable('buckets'), +})); + +mock.module('@internal/local-target', () => ({ + ...RealLocalTarget, + resolvePackageEntry: (specifier: string) => specifier, + removeLocalPaths: () => { + events.push('remove local paths'); + }, +})); + +const { runDevTeardown } = await import('../local-target/teardown.ts'); + +const container = new PrismaCloudContainer( + { appName: 'my-app', stage: undefined }, + 'local', + undefined, + undefined, + true, +); + +beforeEach(() => { + events.length = 0; +}); + +test('starts the compute emulator before deleting the app from it, so a stopped emulator still forgets the old ports', async () => { + await runDevTeardown({ container, stage: undefined }); + + expect(events).toEqual(['daemon compute', 'delete compute my-app', 'remove local paths']); +}); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/local-target/daemon-entry.ts b/packages/1-prisma-cloud/1-extensions/target/src/local-target/daemon-entry.ts new file mode 100644 index 000000000..3f5a706fb --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/local-target/daemon-entry.ts @@ -0,0 +1,7 @@ +import type { DaemonName } from '@internal/dev-emulators'; +import { resolvePackageEntry } from '@internal/local-target'; + +/** The resolved absolute path to this daemon's published entrypoint. */ +export function daemonEntry(name: DaemonName): string { + return resolvePackageEntry(`@prisma/composer-prisma-cloud/local-target/${name}-main`); +} diff --git a/packages/1-prisma-cloud/1-extensions/target/src/local-target/emulators.ts b/packages/1-prisma-cloud/1-extensions/target/src/local-target/emulators.ts index 51c4a6328..236d8cdbb 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/local-target/emulators.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/local-target/emulators.ts @@ -23,10 +23,11 @@ * source stays free of `node:`/`bun:` imports — invariant 5). */ import type { LocalTargetEmulatorsInput } from '@internal/core/config'; -import type { DaemonName } from '@internal/dev-emulators'; import { ensureDaemon } from '@internal/dev-emulators'; -import { reserveServicePorts, resolvePackageEntry } from '@internal/local-target'; +import { reserveServicePorts } from '@internal/local-target'; import { PRISMA_CLOUD_EXTENSION_ID } from '../container.ts'; +import { serviceAppName } from '../service-app.ts'; +import { daemonEntry } from './daemon-entry.ts'; function usesBuckets(input: LocalTargetEmulatorsInput): boolean { return input.graph.nodes.some((n) => n.node.kind === 'resource' && n.node.type === 's3'); @@ -39,22 +40,17 @@ function usesPostgres(input: LocalTargetEmulatorsInput): boolean { ); } -/** This extension's services, in `graph.nodes` order: dependencies first, ties in declaration order. */ -function serviceAddresses(input: LocalTargetEmulatorsInput): string[] { +/** The `App` name of each of this extension's services, in `graph.nodes` order: dependencies first, ties in declaration order. */ +function serviceAppNames(input: LocalTargetEmulatorsInput): string[] { return input.graph.nodes .filter((n) => n.node.kind === 'service' && n.node.extension === PRISMA_CLOUD_EXTENSION_ID) - .map((n) => n.id); -} - -/** The resolved absolute path to this daemon's published entrypoint. */ -function daemonEntry(name: DaemonName): string { - return resolvePackageEntry(`@prisma/composer-prisma-cloud/local-target/${name}-main`); + .map((n) => serviceAppName(n.id)); } export async function runDevEmulators(input: LocalTargetEmulatorsInput): Promise { const { url: computeUrl } = await ensureDaemon('compute', daemonEntry('compute')); console.log(`[dev] compute emulator ready at ${computeUrl}`); - await reserveServicePorts(input.container, serviceAddresses(input)); + await reserveServicePorts(input.container, serviceAppNames(input)); if (usesBuckets(input)) { const { url: bucketsUrl } = await ensureDaemon('buckets', daemonEntry('buckets')); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/local-target/teardown.ts b/packages/1-prisma-cloud/1-extensions/target/src/local-target/teardown.ts index d31674e8e..c516b27d1 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/local-target/teardown.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/local-target/teardown.ts @@ -14,9 +14,15 @@ import type { TeardownInput } from '@internal/core/config'; import { DEV_DIR } from '@internal/core/config'; -import { bucketsClient, computeClient, postgresClient } from '@internal/dev-emulators'; +import { + bucketsClient, + computeClient, + ensureDaemon, + postgresClient, +} from '@internal/dev-emulators'; import { removeLocalPaths } from '@internal/local-target'; import { prismaCloudContainerOf } from '../container.ts'; +import { daemonEntry } from './daemon-entry.ts'; async function tolerateUnreachable(action: () => Promise): Promise { try { @@ -33,7 +39,10 @@ export async function runDevTeardown(input: TeardownInput): Promise { const cwd = process.cwd(); await tolerateUnreachable(() => postgresClient().deleteApp(app)); - await tolerateUnreachable(() => computeClient().deleteApp(app)); + // Compute persists port allocations across restarts, so a stopped daemon + // would hand the old ports back; start it so the delete always lands. + await ensureDaemon('compute', daemonEntry('compute')); + await computeClient().deleteApp(app); await tolerateUnreachable(() => bucketsClient().deleteApp(app)); removeLocalPaths([`${cwd}/${DEV_DIR}`, `${cwd}/.alchemy/state/${app}/dev`]); From a5c81b3898e0960a8ee0363f65d0aec165fdbd5f Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 20:14:26 +0200 Subject: [PATCH 4/7] fix(dev): reserve ports under the App name the descriptor uses, and name the failing service serviceAppName is now the one source for both the App displayName and the name the emulators hook reserves under, so the two cannot drift. A failed reservation names its service. The port-order test now checks that each App provider returns the port its reservation got, which does not depend on other processes on the machine, and removes its temp directories. Signed-off-by: willbot Signed-off-by: Will Madden --- architecture.config.json | 6 +++++ .../src/__tests__/compute-port-order.test.ts | 23 ++++++++++++++++--- .../0-lowering/local-target/src/compute.ts | 17 ++++++++++---- .../target/src/descriptors/compute.ts | 3 ++- .../1-extensions/target/src/service-app.ts | 4 ++++ 5 files changed, 44 insertions(+), 9 deletions(-) create mode 100644 packages/1-prisma-cloud/1-extensions/target/src/service-app.ts diff --git a/architecture.config.json b/architecture.config.json index cf1d11c60..90866ed2f 100644 --- a/architecture.config.json +++ b/architecture.config.json @@ -312,6 +312,12 @@ "layer": "extensions", "plane": "control" }, + { + "glob": "packages/1-prisma-cloud/1-extensions/target/src/service-app.ts", + "domain": "prisma-cloud", + "layer": "extensions", + "plane": "control" + }, { "glob": "packages/1-prisma-cloud/1-extensions/target/src/container.ts", "domain": "prisma-cloud", diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-port-order.test.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-port-order.test.ts index 5385e124b..ecdbc46b9 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-port-order.test.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-port-order.test.ts @@ -26,6 +26,7 @@ beforeEach(async () => { afterEach(async () => { await stopDaemon('compute', { registryRoot }).catch(() => undefined); + fs.rmSync(registryRoot, { recursive: true, force: true }); if (savedEmulatorsDir === undefined) delete process.env[EMULATORS_DIR_ENV]; else process.env[EMULATORS_DIR_ENV] = savedEmulatorsDir; }); @@ -58,20 +59,36 @@ async function reconcileApp(container: ContainerInstance, address: string): Prom return Number(new URL(attributes.appEndpointDomain ?? '').port); } -test('services reserved in graph order keep that order when the App providers then race', async () => { +async function reservedPorts(appName: string): Promise> { + const services = await computeClient().listServices(appName); + return Object.fromEntries(services.map((svc) => [svc.id, svc.port])); +} + +test('each App provider returns the port reserved for its service, even when the providers race', async () => { for (let run = 0; run < 10; run++) { - const container = fakeContainer(`port-order-${String(run)}`); + const appName = `port-order-${String(run)}`; + const container = fakeContainer(appName); await reserveServicePorts(container, ['quotes', 'gateway']); + const reserved = await reservedPorts(appName); const [gateway, quotes] = await Promise.all([ reconcileApp(container, 'gateway'), reconcileApp(container, 'quotes'), ]); - expect(quotes).toBeLessThan(gateway); + expect({ quotes, gateway }).toEqual({ + quotes: reserved['quotes'] ?? -1, + gateway: reserved['gateway'] ?? -1, + }); } }); +test('a failed reservation names the service', async () => { + await expect(reserveServicePorts(fakeContainer('Bad-App'), ['quotes'])).rejects.toThrow( + 'service "quotes"', + ); +}); + test('reserving the services of an app that already has ports keeps those ports', async () => { const container = fakeContainer('port-order-warm'); const client = computeClient(); 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 fc9f67569..bd92cf4fd 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 @@ -217,16 +217,23 @@ export function LocalAppProvider( * Reserves each service's emulator port one at a time, in the order given, * before Alchemy applies the `App` resources concurrently. A new service gets * the smallest free port, so on a fresh start the ports follow this order; a - * service that already has a port keeps it. `addresses` are the ids the - * `App` resources carry as `displayName`. + * service that already has a port keeps it. `appNames` are the `App` + * resources' `displayName`s. */ export async function reserveServicePorts( container: ContainerInstance | undefined, - addresses: readonly string[], + appNames: readonly string[], ): Promise { const appName = appNameOf(container); - for (const address of addresses) { - await computeClient().ensureService(appName, slugServiceId(address)); + for (const name of appNames) { + try { + await computeClient().ensureService(appName, slugServiceId(name)); + } catch (cause) { + const reason = cause instanceof Error ? cause.message : String(cause); + throw new Error(`reserving a local port for service "${name}" failed: ${reason}`, { + cause, + }); + } } } diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts index c5e685abd..8777eec10 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts @@ -24,6 +24,7 @@ import { paramEntries, serializeInput, } from '../serializer.ts'; +import { serviceAppName } from '../service-app.ts'; import { cloudApplicationOf, projectIdOf, @@ -149,7 +150,7 @@ export function computeDescriptor( const branchId = cloudApplicationOf(application).branchId; const svc = yield* Prisma.App(`${id}-svc`, { project: projectId, - displayName: id, + displayName: serviceAppName(id), ...(o().region !== undefined ? { regionId: o().region } : {}), ...(branchId !== undefined ? { branchId } : {}), }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/service-app.ts b/packages/1-prisma-cloud/1-extensions/target/src/service-app.ts new file mode 100644 index 000000000..f9faf3389 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/service-app.ts @@ -0,0 +1,4 @@ +/** The `displayName` of a service's `Prisma.App`. Local dev reserves each service's emulator port under this name before converge, so both must come from here. */ +export function serviceAppName(address: string): string { + return address; +} From 9021eb8fefd7cbf796cf81f35ccc7f4859da4dd0 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 20:14:28 +0200 Subject: [PATCH 5/7] docs(dev): match the skill to the guide, and widen the emulators hook description The emulators hook now also sets up per-node emulator state before converge; its description in core and the dev command says so. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/0-framework/1-core/core/src/control/app-config.ts | 2 +- .../0-framework/3-tooling/cli/src/operations/execute-dev.ts | 2 +- skills/prisma-composer-core-concepts/SKILL.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 1fee23638..9a7301f30 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 @@ -247,7 +247,7 @@ export interface LocalTargetDescriptor { readonly container: ContainerDescriptor; /** Value sourcing (secrets/env-params) — runs where deploy's preflight runs. */ preflight?(input: PreflightInput): Promise; - /** Ensure the emulator daemons this topology's node kinds need are running (idempotent; they persist across sessions). */ + /** Ensure the emulator daemons this topology's node kinds need are running, plus any per-node emulator state that must exist before converge (idempotent; daemons persist across sessions). */ emulators?(input: LocalTargetEmulatorsInput): Promise; /** The dev session's view of the running app. Core renders it and never learns an emulator's API. */ attach(input: LocalTargetAttachInput): Promise; diff --git a/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts b/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts index 7db1a68aa..b5b9b19ea 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-dev.ts @@ -121,7 +121,7 @@ export async function executeDev( } } - // Emulators — ensure the daemons this topology's node kinds need. + // Emulators — ensure the daemons this topology's node kinds need, and their per-node state. for (const [id, dev] of resolved) { if (dev.emulators === undefined) continue; try { diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index 5d6183c3a..bf8ac81c0 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -433,7 +433,7 @@ that surprise: 1. It runs the same pipeline as deploy, so **build first**, exactly like deploy. It watches built output and restarts a service when its build changes. -2. Ctrl-C stops the app's processes but leaves local databases, buckets, and their data up: the next `dev` is a warm start. Starting clean, wiping this app's local instances and data first, is an explicit opt-in flag. On a fresh start, service ports count up from 3000 in dependency order (a callee before its callers, otherwise provision order); a warm start keeps them. +2. Ctrl-C stops the app's processes but leaves local databases, buckets, and their data up: the next `dev` is a warm start. Starting clean, wiping this app's local instances and data first, is an explicit opt-in flag. On a fresh start, each service gets the lowest free port from 3000 up, in dependency order: a service before the services that call it, otherwise provision order. A warm start keeps the ports services already have. 3. `dev` does not print service logs. The `log` operation follows the already-running app's merged logs; it never builds, provisions, starts, or stops anything: From 34fb156ecb503a839e939c20e786db51273aeae6 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 20:18:56 +0200 Subject: [PATCH 6/7] refactor(dev): name reserveServicePorts' list serviceAppNames, and describe what teardown now does The parameter sat next to appName, the Composer app, and read as the same kind of thing. The teardown module comment now says compute is started first and must succeed, while Postgres and buckets are skipped when unreachable. Signed-off-by: willbot Signed-off-by: Will Madden --- .../0-lowering/local-target/src/compute.ts | 12 ++++++------ .../1-extensions/target/src/local-target/teardown.ts | 5 ++++- 2 files changed, 10 insertions(+), 7 deletions(-) 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 bd92cf4fd..d8fe3754c 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 @@ -217,20 +217,20 @@ export function LocalAppProvider( * Reserves each service's emulator port one at a time, in the order given, * before Alchemy applies the `App` resources concurrently. A new service gets * the smallest free port, so on a fresh start the ports follow this order; a - * service that already has a port keeps it. `appNames` are the `App` - * resources' `displayName`s. + * service that already has a port keeps it. `serviceAppNames` are the + * `App` resources' `displayName`s. */ export async function reserveServicePorts( container: ContainerInstance | undefined, - appNames: readonly string[], + serviceAppNames: readonly string[], ): Promise { const appName = appNameOf(container); - for (const name of appNames) { + for (const service of serviceAppNames) { try { - await computeClient().ensureService(appName, slugServiceId(name)); + await computeClient().ensureService(appName, slugServiceId(service)); } catch (cause) { const reason = cause instanceof Error ? cause.message : String(cause); - throw new Error(`reserving a local port for service "${name}" failed: ${reason}`, { + throw new Error(`reserving a local port for service "${service}" failed: ${reason}`, { cause, }); } diff --git a/packages/1-prisma-cloud/1-extensions/target/src/local-target/teardown.ts b/packages/1-prisma-cloud/1-extensions/target/src/local-target/teardown.ts index c516b27d1..f73a9e47b 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/local-target/teardown.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/local-target/teardown.ts @@ -2,9 +2,12 @@ * `--fresh` teardown (local-dev spec § 5, ADR-0041 D12, REVISED — operator * review of #162): wholesale LOCAL deletion — never an `alchemy destroy`. * Removes this app's records on the three machine-global emulator daemons - * (never the daemons themselves — other apps may be using them; postgres's + * (never stops the daemons — other apps may be using them; postgres's * `DELETE /apps/` closes its servers and deletes their persisted data), * the dev state directory, and the dev stage's `localState()` directory. + * Compute is started first if it is down, and its delete must succeed, + * because it persists port allocations; Postgres and buckets are skipped + * when unreachable. * * Every actual filesystem operation is delegated to `@internal/local-target` * (this extension's own source stays free of `node:`/`bun:` imports — From 88aa3d3d018bce23d8391c81398ac4a0033fab46 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 23:13:02 +0200 Subject: [PATCH 7/7] fix(dev): still wipe local dev state when the --fresh compute delete fails Teardown now removes the dev state directory and the local Alchemy state, and tries the buckets delete, in a finally around the compute step. A compute failure still fails --fresh, but no longer leaves stale state on disk. Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/__tests__/local-target-teardown.test.ts | 12 ++++++++++++ .../target/src/local-target/teardown.ts | 15 +++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-teardown.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-teardown.test.ts index 269b5dae5..179ba52da 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-teardown.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-teardown.test.ts @@ -4,6 +4,7 @@ import * as RealLocalTarget from '@internal/local-target'; import { PrismaCloudContainer } from '../container.ts'; const events: string[] = []; +let computeDeleteFails = false; const unreachable = (name: string) => () => ({ deleteApp: async () => { @@ -20,6 +21,7 @@ mock.module('@internal/dev-emulators', () => ({ computeClient: () => ({ deleteApp: async (app: string) => { events.push(`delete compute ${app}`); + if (computeDeleteFails) throw new Error('compute delete failed'); }, }), postgresClient: unreachable('postgres'), @@ -46,6 +48,7 @@ const container = new PrismaCloudContainer( beforeEach(() => { events.length = 0; + computeDeleteFails = false; }); test('starts the compute emulator before deleting the app from it, so a stopped emulator still forgets the old ports', async () => { @@ -53,3 +56,12 @@ test('starts the compute emulator before deleting the app from it, so a stopped expect(events).toEqual(['daemon compute', 'delete compute my-app', 'remove local paths']); }); + +test('a failed compute delete still removes the local state, then fails the teardown', async () => { + computeDeleteFails = true; + + await expect(runDevTeardown({ container, stage: undefined })).rejects.toThrow( + 'compute delete failed', + ); + expect(events).toEqual(['daemon compute', 'delete compute my-app', 'remove local paths']); +}); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/local-target/teardown.ts b/packages/1-prisma-cloud/1-extensions/target/src/local-target/teardown.ts index f73a9e47b..0b9befc7b 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/local-target/teardown.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/local-target/teardown.ts @@ -7,7 +7,8 @@ * the dev state directory, and the dev stage's `localState()` directory. * Compute is started first if it is down, and its delete must succeed, * because it persists port allocations; Postgres and buckets are skipped - * when unreachable. + * when unreachable. The local directories are removed even when the + * Compute step fails, and the failure is then reported. * * Every actual filesystem operation is delegated to `@internal/local-target` * (this extension's own source stays free of `node:`/`bun:` imports — @@ -44,9 +45,11 @@ export async function runDevTeardown(input: TeardownInput): Promise { await tolerateUnreachable(() => postgresClient().deleteApp(app)); // Compute persists port allocations across restarts, so a stopped daemon // would hand the old ports back; start it so the delete always lands. - await ensureDaemon('compute', daemonEntry('compute')); - await computeClient().deleteApp(app); - await tolerateUnreachable(() => bucketsClient().deleteApp(app)); - - removeLocalPaths([`${cwd}/${DEV_DIR}`, `${cwd}/.alchemy/state/${app}/dev`]); + try { + await ensureDaemon('compute', daemonEntry('compute')); + await computeClient().deleteApp(app); + } finally { + await tolerateUnreachable(() => bucketsClient().deleteApp(app)); + removeLocalPaths([`${cwd}/${DEV_DIR}`, `${cwd}/.alchemy/state/${app}/dev`]); + } }