Skip to content
6 changes: 6 additions & 0 deletions architecture.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 1 addition & 5 deletions docs/design/10-domains/local-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/guides/running-locally.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/0-framework/1-core/core/src/control/app-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
/** 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<void>;
/** The dev session's view of the running app. Core renders it and never learns an emulator's API. */
attach(input: LocalTargetAttachInput): Promise<LocalTargetAttachment>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) =>
({
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<number> {
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<Record<string, number>> {
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);
});
26 changes: 25 additions & 1 deletion packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> {
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 `<devDir>/env.json`. Upstream's value is `Redacted`; env.json holds the plain string the child process is given. */
export function LocalEnvironmentVariableProvider(
input: LocalTargetProvidersInput,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<never, never>> = {
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']);
});
Loading
Loading