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/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/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/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/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..ecdbc46b9 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-port-order.test.ts @@ -0,0 +1,111 @@ +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); + fs.rmSync(registryRoot, { recursive: true, force: true }); + 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); +} + +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 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, 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(); + 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..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 @@ -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,30 @@ 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. `serviceAppNames` are the + * `App` resources' `displayName`s. + */ +export async function reserveServicePorts( + container: ContainerInstance | undefined, + serviceAppNames: readonly string[], +): Promise { + const appName = appNameOf(container); + for (const service of serviceAppNames) { + try { + 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 "${service}" failed: ${reason}`, { + cause, + }); + } + } +} + /** `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/__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..179ba52da --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-teardown.test.ts @@ -0,0 +1,67 @@ +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[] = []; +let computeDeleteFails = false; + +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}`); + if (computeDeleteFails) throw new Error('compute delete failed'); + }, + }), + 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; + computeDeleteFails = false; +}); + +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']); +}); + +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/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/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 03b7b270c..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 @@ -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. * @@ -19,9 +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 { 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'); @@ -34,14 +40,17 @@ function usesPostgres(input: LocalTargetEmulatorsInput): boolean { ); } -/** 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`); +/** 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) => 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, 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..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 @@ -2,9 +2,13 @@ * `--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. 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 — @@ -14,9 +18,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,8 +43,13 @@ export async function runDevTeardown(input: TeardownInput): Promise { const cwd = process.cwd(); await tolerateUnreachable(() => postgresClient().deleteApp(app)); - await tolerateUnreachable(() => computeClient().deleteApp(app)); - await tolerateUnreachable(() => bucketsClient().deleteApp(app)); - - removeLocalPaths([`${cwd}/${DEV_DIR}`, `${cwd}/.alchemy/state/${app}/dev`]); + // Compute persists port allocations across restarts, so a stopped daemon + // would hand the old ports back; start it so the delete always lands. + 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`]); + } } 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; +} diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index e94e930ca..bf8ac81c0 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, 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: