From 6ba74f7a04e10a72a30ff34d8db7c5c529899022 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 22 Jul 2026 22:55:55 +0200 Subject: [PATCH 01/34] feat(core): add the dev descriptor seam (ADR-0041) DevDescriptor + DevProvidersInput/DevEmulatorsInput/DevAttachInput/ DevAttachment, DEV_DIR, and ExtensionDescriptor.dev in app-config.ts; LowerOptions.dev and mergedDevProviders (an extension with no dev descriptor throws, unlike mergedProviders skipping) in deploy.ts, wired into lower() so opts.dev routes the stack at the dev providers layer instead of the hosted one. No nodes/provisions/state on the new type by design -- the lowering stays byte-identical between dev and deploy; only the Alchemy provider boundary is substituted. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- architecture.config.json | 12 +++++ .../1-core/core/src/control/app-config.ts | 50 +++++++++++++++++++ .../1-core/core/src/control/deploy.ts | 45 ++++++++++++++--- tsconfig.depcruise.json | 3 ++ 4 files changed, 104 insertions(+), 6 deletions(-) diff --git a/architecture.config.json b/architecture.config.json index 2a99dee36..eea08b388 100644 --- a/architecture.config.json +++ b/architecture.config.json @@ -318,6 +318,18 @@ "layer": "extensions", "plane": "shared" }, + { + "glob": "packages/1-prisma-cloud/1-extensions/target/src/preflight-names.ts", + "domain": "prisma-cloud", + "layer": "extensions", + "plane": "control" + }, + { + "glob": "packages/1-prisma-cloud/1-extensions/target/src/dev/**", + "domain": "prisma-cloud", + "layer": "extensions", + "plane": "control" + }, { "glob": "packages/1-prisma-cloud/2-shared-modules/email/src/contract.ts", "domain": "prisma-cloud", 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 a69130923..534f11469 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 @@ -64,6 +64,8 @@ export interface ExtensionDescriptor { * in container-transport.ts. */ readonly container?: ContainerDescriptor; + /** Local dev counterparts (ADR-0041). */ + readonly dev?: DevDescriptor; } /** @@ -95,6 +97,54 @@ export interface TeardownInput { readonly stage: string | undefined; } +/** Local counterparts for `prisma-composer dev` (ADR-0041). An extension without one is not dev-capable. */ +export interface DevDescriptor { + /** Local providers for the SAME resource types this extension's lowering emits. Receives the app identity — unlike deploy's env-arg-free `providers()`, local providers are emulator clients and must know which app they provision for. */ + providers(input: DevProvidersInput): Layer.Layer; + /** A stable local identity — resolved without any platform call. */ + readonly container: ContainerDescriptor; + /** Dev 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). */ + emulators?(input: DevEmulatorsInput): Promise; + /** The dev session's view of the running app. Core renders it and never learns an emulator's API. */ + attach(input: DevAttachInput): Promise; + /** `--fresh`: remove every local trace of the dev instance — emulator instances, state, data. */ + teardown?(input: TeardownInput): Promise; +} + +export interface DevProvidersInput { + /** This extension's resolved dev container (its `input.appName` is the emulator app namespace). */ + readonly container: ContainerInstance | undefined; + /** Absolute path of the dev state directory (`/.prisma-composer/dev`). */ + readonly devDir: string; +} + +export interface DevEmulatorsInput { + /** The loaded application graph — inspected for which node kinds need an emulator. */ + readonly graph: Graph; + readonly container: ContainerInstance | undefined; + /** Absolute path of the dev state directory (`/.prisma-composer/dev`). */ + readonly devDir: string; +} + +export interface DevAttachInput { + readonly container: ContainerInstance | undefined; + readonly devDir: string; +} + +export interface DevAttachment { + /** 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. */ + logs(signal: AbortSignal): AsyncIterable<{ readonly service: string; readonly line: string }>; + /** Stop the app's service instances (emulators and data persist). */ + stopServices(): Promise; +} + +/** `/.prisma-composer/dev` — the dev instance's app-scoped state directory (ADR-0041, ADR-0004's tool-state rule). */ +export const DEV_DIR = '.prisma-composer/dev'; + /** * What one registry entry can do. The `kind` discriminant is checked at every * lookup site against what the site needs — a resource node looked up against diff --git a/packages/0-framework/1-core/core/src/control/deploy.ts b/packages/0-framework/1-core/core/src/control/deploy.ts index e0ad52527..cf45c6824 100644 --- a/packages/0-framework/1-core/core/src/control/deploy.ts +++ b/packages/0-framework/1-core/core/src/control/deploy.ts @@ -1,5 +1,6 @@ /** Routes each graph node to its extension descriptor (by node.extension/type) and runs provision → serialize → package → deploy in dependency order. */ +import * as path from 'node:path'; import type { Input, StackServices } from 'alchemy'; import * as Alchemy from 'alchemy'; import type { State } from 'alchemy/State/State'; @@ -16,11 +17,12 @@ import { type ResourceNode, type ServiceNode, } from '../node.ts'; -import type { - ContainerInstance, - ExtensionDescriptor, - NodeDescriptor, - PrismaAppConfig, +import { + type ContainerInstance, + DEV_DIR, + type ExtensionDescriptor, + type NodeDescriptor, + type PrismaAppConfig, } from './app-config.ts'; /** The Layer shape every Alchemy state store must satisfy — what `LowerOptions.state` and `PrismaAppConfig.state` both traffic in. */ @@ -200,6 +202,8 @@ export interface LowerOptions { readonly stage?: string; /** Alchemy state store for the stack. Defaults to the config's own state layer. */ readonly state?: AlchemyStateLayer; + /** Retarget lowering at every extension's `dev` providers (ADR-0041) instead of its hosted `providers()`. */ + readonly dev?: boolean; /** * Invoked once per deploy, during apply, with the Deploy operation's result * — the app and every node it deployed, resolved, in topo order. @@ -531,6 +535,31 @@ export function mergedProviders(config: PrismaAppConfig): Layer.Layer { return first === undefined ? Layer.empty : Layer.mergeAll(first, ...rest); } +function noDevSupportError(id: string): LowerError { + return new LowerError( + `extension "${id}" has no dev support — it declares no \`dev\` descriptor (ADR-0041).`, + ); +} + +/** + * All configured extensions' DEV providers merged, config array order (ADR-0041). + * Unlike `mergedProviders`, an extension with no `dev` descriptor is not + * skipped — every configured extension must be dev-capable, or dev cannot + * bring the app up at all, so this throws naming the extension. + */ +export function mergedDevProviders( + config: PrismaAppConfig, + containers: ReadonlyMap, + devDir: string, +): Layer.Layer { + const layers = config.extensions.map((extension) => { + if (extension.dev === undefined) throw noDevSupportError(extension.id); + return extension.dev.providers({ container: containers.get(extension.id), devDir }); + }); + const [first, ...rest] = layers; + return first === undefined ? Layer.empty : Layer.mergeAll(first, ...rest); +} + /** * The deployment-report Action's input, declared in RESOLVED terms — this is * what the runner receives. The call site passes `Input`s, @@ -747,10 +776,14 @@ export function lower(root: ModuleNode, config: PrismaAppConfig, opts: LowerOpti // match what Alchemy.Stack accepts. const stackEffect = Effect.orDie(lowering(root, config, opts)) as Effect.Effect; const containers = deserializeContainers(config.extensions, process.env); + const providers = + opts.dev === true + ? mergedDevProviders(config, containers, path.join(process.cwd(), DEV_DIR)) + : mergedProviders(config); return Alchemy.Stack( opts.name, - { providers: mergedProviders(config), state: resolveStateLayer(opts, config, containers) }, + { providers, state: resolveStateLayer(opts, config, containers) }, stackEffect, ); } diff --git a/tsconfig.depcruise.json b/tsconfig.depcruise.json index ead16b6d2..1177ebe48 100644 --- a/tsconfig.depcruise.json +++ b/tsconfig.depcruise.json @@ -41,6 +41,9 @@ "@internal/lowering/state": [ "./packages/1-prisma-cloud/0-lowering/lowering/src/exports/state.ts" ], + "@internal/lowering/dev": [ + "./packages/1-prisma-cloud/0-lowering/lowering/src/exports/dev.ts" + ], "@internal/lowering": ["./packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts"], "@internal/s3-protocol": [ "./packages/1-prisma-cloud/0-lowering/s3-protocol/src/exports/index.ts" From 9f47c67557753253b967a503dd2c223cfd20139f Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 22 Jul 2026 22:56:06 +0200 Subject: [PATCH 02/34] feat(lowering): thread the deployment address for local dev DeploymentProps gains an optional serviceAddress, ignored by the hosted provider. descriptors/compute.ts populates ComputeSerialized.address from ctx.address at serialize and threads it into the Deployment call at deploy -- the local Deployment provider (added next) requires it to key the Compute emulators env materialization. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../0-lowering/lowering/src/compute/Deployment.ts | 8 ++++++++ .../1-extensions/target/src/descriptors/compute.ts | 12 ++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/Deployment.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/Deployment.ts index a71de7d0a..0fbc209a7 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/Deployment.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/Deployment.ts @@ -32,6 +32,14 @@ export interface DeploymentProps { * see docs/design/05-prisma-cloud/alchemy-lowering.md). */ environment?: readonly EnvironmentVariable[]; + /** + * The node's deployment address (local-dev spec § 4). This hosted provider + * never reads it — the Management API needs no address, only `computeServiceId`. + * It exists so the LOCAL Deployment provider (`@internal/lowering/dev`) can + * key the Compute emulator's env materialization without importing anything + * target-specific. + */ + serviceAddress?: string; } export interface DeploymentAttributes { 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 63d487e61..37b774a7b 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 @@ -45,6 +45,8 @@ export interface ComputeProvisioned { export interface ComputeSerialized { readonly environment: readonly Prisma.EnvironmentVariable[]; readonly port: number; + /** The node's deployment address — threaded into `Deployment`'s `serviceAddress` (local-dev spec § 4). */ + readonly address: string; } /** @@ -54,7 +56,7 @@ export interface ComputeSerialized { * Annotating this `NodeDescriptor` would force s3-store to cast them back. */ export function computeDescriptor( - o: ResolvedCloudOptions, + o: () => ResolvedCloudOptions, ): { readonly kind: 'service' } & ServiceLowering { return { kind: 'service' as const, @@ -68,7 +70,7 @@ export function computeDescriptor( const svc = yield* Prisma.ComputeService(`${id}-svc`, { projectId, name: id, - region: o.region ?? DEFAULT_REGION, + region: o().region ?? DEFAULT_REGION, ...(branchId !== undefined ? { branchId } : {}), }); return { serviceId: svc.id, projectId, endpointDomain: svc.endpointDomain }; @@ -163,7 +165,7 @@ export function computeDescriptor( refsByBrand.set(edge.brand, refs); } } - for (const [brand, entry] of o.providerParams) { + for (const [brand, entry] of o().providerParams) { const raw = 'valueForService' in entry ? entry.valueForService(provisioned, address) @@ -196,7 +198,7 @@ export function computeDescriptor( // This is the only place the raw, untyped config is read, so it is the // only place the fallback belongs — from here on `port` is a number. const port = typeof config.service['port'] === 'number' ? config.service['port'] : 3000; - return { environment: records, port }; + return { environment: records, port, address }; }), // Deterministic tar.gz (fixed mtimes/ordering) so unchanged inputs hash @@ -222,6 +224,8 @@ export function computeDescriptor( // Route to the port the app actually binds (the service's `port` // param, resolved by serialize) — not a hardcoded constant. port: serialized.port, + // Local-dev only (local-dev spec § 4) — the hosted provider ignores it. + serviceAddress: serialized.address, }); // `url` IS published here: a Compute service's deployed URL is a // public endpoint, and this descriptor is the only party that knows From 7ba751ca68b4d27b5509b21988cea4b472df5e17 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 22 Jul 2026 22:56:26 +0200 Subject: [PATCH 03/34] feat(lowering): add the local dev provider bundle (local-dev spec S4) New @internal/lowering/dev subpath: dev-store.ts (env.json/secrets.json/ postgres.json, temp-then-rename, one in-process queue per file), the eight local providers (compute cluster: ComputeService/Deployment/ EnvironmentVariable/Project; postgres cluster: Database/Connection over the ORM CLIs prisma dev, with the stop-then-start port-stability sequence verified live against prisma dev v0.16.22 -- the port holds, so no --db-port fallback is wired; bucket cluster: Bucket/BucketKey as bucket-emulator clients), devProviders() collecting them under the same Providers tag providers() uses, resolve-bin.ts (a generalized resolveAlchemyBin), and compute/artifact-extract.ts, a minimal ustar reader matching packageComputeArtifact byte for byte. Local providers read the app name off the generic ContainerInstance directly (container.input.appName) rather than through a targets narrowing helper: @internal/lowering sits below the extensions layer and cannot import one (ADR-0028s layer order), and the field is already on the base interface, so no narrowing is needed for it. Likewise, the Deployment providers env-materialization step needs the exact env-var key the deployed artifacts serializer.ts computes for the port param; the formula is duplicated inline (documented as a protocol match, not a convenience) because the real configKey() lives in the extensions layer and cannot be imported from here either. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../0-lowering/lowering/package.json | 3 + .../lowering/src/compute/artifact-extract.ts | 87 ++++++++ .../0-lowering/lowering/src/dev/app-name.ts | 21 ++ .../0-lowering/lowering/src/dev/bucket.ts | 82 +++++++ .../0-lowering/lowering/src/dev/compute.ts | 176 +++++++++++++++ .../0-lowering/lowering/src/dev/dev-store.ts | 146 +++++++++++++ .../0-lowering/lowering/src/dev/postgres.ts | 204 ++++++++++++++++++ .../0-lowering/lowering/src/dev/providers.ts | 55 +++++ .../lowering/src/dev/resolve-bin.ts | 20 ++ .../lowering/src/dev/spawn-utils.ts | 7 + .../0-lowering/lowering/src/dev/teardown.ts | 40 ++++ .../lowering/src/exports/compute.ts | 1 + .../0-lowering/lowering/src/exports/dev.ts | 9 + .../0-lowering/lowering/tsdown.config.ts | 1 + 14 files changed, 852 insertions(+) create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact-extract.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/dev/app-name.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/dev/bucket.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/dev/compute.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/dev/dev-store.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/dev/postgres.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/dev/providers.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/dev/resolve-bin.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/dev/spawn-utils.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/dev/teardown.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/exports/dev.ts diff --git a/packages/1-prisma-cloud/0-lowering/lowering/package.json b/packages/1-prisma-cloud/0-lowering/lowering/package.json index 9edcd2d93..332b4f8f6 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/package.json +++ b/packages/1-prisma-cloud/0-lowering/lowering/package.json @@ -6,6 +6,7 @@ ".": "./dist/index.mjs", "./buckets": "./dist/buckets.mjs", "./compute": "./dist/compute.mjs", + "./dev": "./dist/dev.mjs", "./postgres": "./dist/postgres.mjs", "./state": "./dist/state.mjs", "./package.json": "./package.json" @@ -18,7 +19,9 @@ }, "dependencies": { "@internal/core": "workspace:0.2.0", + "@internal/dev-emulators": "workspace:0.2.0", "@internal/foundation": "workspace:0.2.0", + "@internal/s3-protocol": "workspace:0.2.0", "@prisma/management-api-sdk": "^1.50.0", "alchemy": "2.0.0-beta.59", "effect": "4.0.0-beta.93", diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact-extract.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact-extract.ts new file mode 100644 index 000000000..0b322851c --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact-extract.ts @@ -0,0 +1,87 @@ +/** + * Reads the deterministic ustar tar.gz `artifact.ts`'s `packageComputeArtifact` + * writes (local-dev spec § 4) — a minimal reader matching exactly what that + * writer emits: regular files only, name+prefix path fields, fixed-size + * headers, no links. Extraction is temp-then-rename at the DIRECTORY level, so + * a concurrent reader of `destDir` never observes a partially-unpacked tree. + */ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as zlib from 'node:zlib'; + +const HEADER_SIZE = 512; +const REGULAR_FILE_TYPEFLAG = '0'; + +interface UstarEntry { + readonly relPath: string; + readonly content: Buffer; +} + +function readField(header: Buffer, offset: number, length: number): string { + const raw = header.subarray(offset, offset + length); + const nul = raw.indexOf(0); + return (nul === -1 ? raw : raw.subarray(0, nul)).toString('utf8'); +} + +function unsupportedTypeflagError(relPath: string, typeflag: string): Error { + return new Error( + `compute artifact entry "${relPath}" has typeflag "${typeflag}" — only regular files ` + + '(typeflag "0") are supported; this artifact was not produced by packageComputeArtifact.', + ); +} + +/** Parses every entry out of a raw (already gunzipped) ustar tar buffer. */ +function readUstarEntries(tar: Buffer): readonly UstarEntry[] { + const entries: UstarEntry[] = []; + let offset = 0; + while (offset + HEADER_SIZE <= tar.length) { + const header = tar.subarray(offset, offset + HEADER_SIZE); + if (header.every((b) => b === 0)) break; // end-of-archive block + + const name = readField(header, 0, 100); + const sizeOctal = readField(header, 124, 12).trim(); + const typeflag = readField(header, 156, 1) || REGULAR_FILE_TYPEFLAG; + const prefix = readField(header, 345, 155); + offset += HEADER_SIZE; + + const size = Number.parseInt(sizeOctal, 8); + const relPath = prefix.length > 0 ? `${prefix}/${name}` : name; + if (typeflag !== REGULAR_FILE_TYPEFLAG) throw unsupportedTypeflagError(relPath, typeflag); + + const content = tar.subarray(offset, offset + size); + entries.push({ relPath, content: Buffer.from(content) }); + offset += Math.ceil(size / HEADER_SIZE) * HEADER_SIZE; + } + return entries; +} + +/** + * Extracts `tarGzPath` (a `packageComputeArtifact` tar.gz) into `destDir`, + * temp-then-rename at the directory level: entries are written into a sibling + * temp directory first, then that directory is renamed onto `destDir` — a + * concurrent unpack of the SAME hash races benignly (last rename wins, both + * write identical bytes), and a reader of `destDir` never sees a partial tree. + */ +export function extractComputeArtifact(tarGzPath: string, destDir: string): void { + const gz = fs.readFileSync(tarGzPath); + const tar = zlib.gunzipSync(gz); + const entries = readUstarEntries(tar); + + const parentDir = path.dirname(destDir); + fs.mkdirSync(parentDir, { recursive: true }); + const tmpDir = path.join(parentDir, `.${path.basename(destDir)}.${crypto.randomUUID()}.tmp`); + fs.mkdirSync(tmpDir, { recursive: true }); + try { + for (const entry of entries) { + const target = path.join(tmpDir, entry.relPath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, entry.content); + } + fs.rmSync(destDir, { recursive: true, force: true }); + fs.renameSync(tmpDir, destDir); + } catch (error) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + throw error; + } +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/dev/app-name.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/dev/app-name.ts new file mode 100644 index 000000000..fc5021063 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/dev/app-name.ts @@ -0,0 +1,21 @@ +/** + * Every local provider's emulator app namespace is its resolved dev + * container's `input.appName` — a field already on core's generic + * `ContainerInstance` (no narrowing to a target-specific container class + * needed for it). Narrowing to e.g. the prisma-cloud target's + * `PrismaCloudContainer` is not available here: `@internal/lowering` sits + * below the extensions layer and cannot import it (ADR-0028's layer order). + */ +import type { ContainerInstance } from '@internal/core/config'; + +function containerNotResolvedError(): Error { + return new Error( + "local dev: no container was resolved for this provider — the extension's `dev.container` " + + 'descriptor did not run before converge.', + ); +} + +export function appNameOf(container: ContainerInstance | undefined): string { + if (container === undefined) throw containerNotResolvedError(); + return container.input.appName; +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/dev/bucket.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/dev/bucket.ts new file mode 100644 index 000000000..fd3503366 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/dev/bucket.ts @@ -0,0 +1,82 @@ +/** + * Local bucket-cluster providers (local-dev spec § 4): both `Bucket` and + * `BucketKey` are clients of the machine-global bucket emulator — already up + * by the time these run, since the extension's `dev.emulators` hook ensures + * it before converge. + */ +import * as path from 'node:path'; +import type { DevProvidersInput } from '@internal/core/config'; +import { bucketsClient } from '@internal/dev-emulators'; +import { mintKeyPair } from '@internal/s3-protocol'; +import * as Provider from 'alchemy/Provider'; +import * as Effect from 'effect/Effect'; +import type * as Layer from 'effect/Layer'; +import * as Redacted from 'effect/Redacted'; +import { Bucket } from '../buckets/Bucket.ts'; +import { BucketKey } from '../buckets/BucketKey.ts'; +import { appNameOf } from './app-name.ts'; + +/** `Bucket` → registers `--` with the bucket emulator, backed by an in-project data root. */ +export function LocalBucketProvider( + input: DevProvidersInput, +): Layer.Layer> { + const service: Provider.ProviderService = { + list: () => Effect.succeed([]), + reconcile: ({ news }) => + Effect.tryPromise({ + try: async () => { + const app = appNameOf(input.container); + const dir = path.join(input.devDir, 'buckets', news.name); + await bucketsClient().putBucket(app, news.name, dir); + return { id: news.name, name: news.name }; + }, + catch: (cause) => cause, + }), + // Objects belong to the developer; only `--fresh` removes them. + delete: () => Effect.void, + read: ({ output }) => Effect.succeed(output), + }; + return Provider.effect(Bucket, Effect.succeed(service)); +} + +/** + * `BucketKey` → mint-once-stable (the same lifecycle as `ServiceKey`/ + * `S3Credentials`: reuse `output`'s pair when present, mint only on first + * create) — but ALWAYS re-registers the (prior or fresh) pair with the + * emulator, so a bucket emulator whose own state was wiped self-heals on the + * next converge. + */ +export function LocalBucketKeyProvider( + input: DevProvidersInput, +): Layer.Layer> { + const service: Provider.ProviderService = { + list: () => Effect.succeed([]), + reconcile: ({ news, output }) => + Effect.tryPromise({ + try: async () => { + const app = appNameOf(input.container); + const pair = + output !== undefined + ? { + accessKeyId: output.accessKeyId, + secretAccessKey: Redacted.value(output.secretAccessKey), + } + : mintKeyPair(); + const client = bucketsClient(); + await client.putCredentials(app, pair.accessKeyId, pair.secretAccessKey); + return { + id: news.name, + bucketId: news.bucketId, + accessKeyId: pair.accessKeyId, + secretAccessKey: Redacted.make(pair.secretAccessKey), + endpoint: client.baseUrl, + bucketName: `${app}--${news.name}`, + }; + }, + catch: (cause) => cause, + }), + delete: () => Effect.void, + read: ({ output }) => Effect.succeed(output), + }; + return Provider.effect(BucketKey, Effect.succeed(service)); +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/dev/compute.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/dev/compute.ts new file mode 100644 index 000000000..3068b8bee --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/dev/compute.ts @@ -0,0 +1,176 @@ +/** + * Local compute-cluster providers (local-dev spec § 4): `ComputeService` and + * `Deployment` become clients of the machine-scoped Compute emulator; + * `EnvironmentVariable` becomes a row in the dev env store; `Project` is a + * total-but-unused identity stand-in (no lowering yields one today). Every + * factory takes `DevProvidersInput` — the app name is + * `input.container`'s `input.appName` (see `app-name.ts`), `devDir` is + * `input.devDir`; nothing here reads `process.cwd()` or the environment. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { DevProvidersInput } from '@internal/core/config'; +import { computeClient } from '@internal/dev-emulators'; +import * as Provider from 'alchemy/Provider'; +import * as Effect from 'effect/Effect'; +import type * as Layer from 'effect/Layer'; +import { extractComputeArtifact } from '../compute/artifact-extract.ts'; +import { ComputeService } from '../compute/ComputeService.ts'; +import { Deployment, type DeploymentAttributes } from '../compute/Deployment.ts'; +import { EnvironmentVariable } from '../compute/EnvironmentVariable.ts'; +import { Project } from '../postgres/Project.ts'; +import { appNameOf } from './app-name.ts'; +import { envStore, secretsStore } from './dev-store.ts'; + +/** + * The env-var key the app's own boot-side `deserialize()` reads for its + * `port` service param — `COMPOSER_
_PORT`. Mirrors + * `@prisma/composer-prisma-cloud`'s serializer.ts `configKey(address, { + * owner: 'service', name: 'port' })` byte-for-byte; duplicated rather than + * imported because `@internal/lowering` sits below the extensions layer and + * cannot import a target's serializer (ADR-0028's layer order) — the two + * encode one shared wire protocol (ADR-0029) and must never diverge. + */ +function servicePortEnvKey(address: string): string { + const segments = address.split('.').filter((s) => s.length > 0); + return ['COMPOSER', ...segments, 'PORT'].join('_').toUpperCase(); +} + +function missingServiceAddressError(computeServiceId: string): Error { + return new Error( + `Deployment for "${computeServiceId}" carries no serviceAddress — the lowering predates ` + + 'local dev support.', + ); +} + +async function materializeEnv( + devDir: string, + address: string, + port: number, +): Promise> { + const env: Record = { ...(await envStore(devDir).read()) }; + env[servicePortEnvKey(address)] = JSON.stringify(port); + const secrets = await secretsStore(devDir).read(); + for (const [key, value] of Object.entries(secrets)) env[key] = value; + if (process.env['PATH'] !== undefined) env['PATH'] = process.env['PATH']; + if (process.env['HOME'] !== undefined) env['HOME'] = process.env['HOME']; + return env; +} + +/** + * `ComputeService` → the Compute emulator: reserves (or returns) the + * service's stable port. `delete` is a no-op — instance removal belongs to + * `teardown` (`DELETE /apps/`), not per-resource Alchemy deletes. + */ +export function LocalComputeServiceProvider( + input: DevProvidersInput, +): Layer.Layer> { + const service: Provider.ProviderService = { + list: () => Effect.succeed([]), + reconcile: ({ news }) => + Effect.tryPromise({ + try: async () => { + const app = appNameOf(input.container); + const { url } = await computeClient().ensureService(app, news.name); + return { id: news.name, name: news.name, endpointDomain: url }; + }, + catch: (cause) => cause, + }), + delete: () => Effect.void, + read: ({ output }) => Effect.succeed(output), + }; + return Provider.effect(ComputeService, Effect.succeed(service)); +} + +/** `EnvironmentVariable` → a key/value row in `/env.json`. Parity with deploy: the poison `DATABASE_URL` rows land here like any other. */ +export function LocalEnvironmentVariableProvider( + input: DevProvidersInput, +): Layer.Layer> { + const service: Provider.ProviderService = { + list: () => Effect.succeed([]), + reconcile: ({ news }) => + Effect.tryPromise({ + try: async () => { + await envStore(input.devDir).update((current) => ({ + ...current, + [news.key]: news.value, + })); + return { id: news.key, key: news.key }; + }, + catch: (cause) => cause, + }), + delete: ({ output }) => + Effect.tryPromise({ + try: async () => { + await envStore(input.devDir).update((current) => { + const next = { ...current }; + delete next[output.key]; + return next; + }); + }, + catch: (cause) => cause, + }), + }; + return Provider.effect(EnvironmentVariable, Effect.succeed(service)); +} + +/** + * `Deployment` → unpacks the artifact once per hash, fetches the emulator's + * assigned port, materializes the child's full env (env store + secrets + + * the port override + `PATH`/`HOME`), and puts the deployment — the emulator + * (re)starts the child only when the hash or env actually changed. + */ +export function LocalDeploymentProvider( + input: DevProvidersInput, +): Layer.Layer> { + const service: Provider.ProviderService = { + list: () => Effect.succeed([]), + reconcile: ({ news }) => + Effect.tryPromise({ + try: async (): Promise => { + const app = appNameOf(input.container); + const id = news.computeServiceId; + if (news.serviceAddress === undefined) throw missingServiceAddressError(id); + const address = news.serviceAddress; + + const artifactDir = path.join(input.devDir, 'artifacts', news.artifactHash); + if (!fs.existsSync(artifactDir)) { + extractComputeArtifact(news.artifactPath, artifactDir); + } + + const { port } = await computeClient().ensureService(app, id); + const env = await materializeEnv(input.devDir, address, port); + await computeClient().putDeployment(app, id, { + address, + artifactDir, + artifactHash: news.artifactHash, + env, + port, + }); + + return { deploymentId: news.artifactHash, deployedUrl: `http://localhost:${port}` }; + }, + catch: (cause) => cause, + }), + // Content-addressed, cheap to leave unpacked; `--fresh` removes the whole + // dev dir and `teardown` removes the emulator's instance. + delete: () => Effect.void, + }; + return Provider.effect(Deployment, Effect.succeed(service)); +} + +/** + * `Project` — identity only; present so the provider collection stays total. + * No lowering yields a `Project` resource today (mirrors the hosted + * `Project` provider, which is also never exercised — see postgres.ts). + */ +export function LocalProjectProvider( + _input: DevProvidersInput, +): Layer.Layer> { + const service: Provider.ProviderService = { + list: () => Effect.succeed([]), + reconcile: ({ news }) => Effect.succeed({ id: 'local', name: news.name }), + delete: () => Effect.void, + }; + return Provider.effect(Project, Effect.succeed(service)); +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/dev/dev-store.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/dev/dev-store.ts new file mode 100644 index 000000000..e471a180e --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/dev/dev-store.ts @@ -0,0 +1,146 @@ +/** + * The shared dev-instance store (local-dev spec § 4): the three JSON files + * under `/` every local provider reads or writes. Every write is + * temp-then-rename; reads and writes on the SAME file are additionally + * serialized behind one in-process async queue per absolute path, because the + * local providers reconcile concurrently inside the one alchemy child and a + * naive read-modify-write would drop a concurrent writer's key. + */ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +function isEnoent(err: unknown): boolean { + return err instanceof Error && 'code' in err && err.code === 'ENOENT'; +} + +// One queue per absolute file path, at module scope — so every caller across +// every provider factory (each of which may construct its own `DevStore` +// handle) serializes through the SAME queue for a given file, not just +// callers sharing one JS object. +const queues = new Map>(); + +function enqueue(filePath: string, task: () => Promise): Promise { + const prior = (queues.get(filePath) ?? Promise.resolve()).then( + () => undefined, + () => undefined, + ); + const result = prior.then(task); + queues.set( + filePath, + result.then( + () => undefined, + () => undefined, + ), + ); + return result; +} + +async function readJson( + filePath: string, + isValid: (value: unknown) => value is T, + empty: T, +): Promise { + let raw: string; + try { + raw = await fs.readFile(filePath, 'utf8'); + } catch (err) { + if (isEnoent(err)) return empty; + throw err; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return empty; // a corrupt/foreign file is treated as absent, never trusted + } + return isValid(parsed) ? parsed : empty; +} + +async function writeJsonAtomic( + filePath: string, + value: unknown, + mode: number | undefined, +): Promise { + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); + const tmpPath = path.join(dir, `.${path.basename(filePath)}.${crypto.randomUUID()}.tmp`); + const json = `${JSON.stringify(value, null, 2)}\n`; + await fs.writeFile(tmpPath, json, mode !== undefined ? { mode } : undefined); + await fs.rename(tmpPath, filePath); + if (mode !== undefined) await fs.chmod(filePath, mode); +} + +export interface DevStore { + /** The current contents — the empty shape when the file has never been written. */ + read(): Promise; + /** Read-modify-write behind this file's queue: `mutate` sees the latest committed contents. */ + update(mutate: (current: T) => T): Promise; +} + +function createStore( + filePath: string, + isValid: (value: unknown) => value is T, + empty: T, + mode?: number, +): DevStore { + return { + read: () => enqueue(filePath, () => readJson(filePath, isValid, empty)), + update: (mutate) => + enqueue(filePath, async () => { + const current = await readJson(filePath, isValid, empty); + const next = mutate(current); + await writeJsonAtomic(filePath, next, mode); + return next; + }), + }; +} + +function isStringRecord(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.values(value).every((v) => typeof v === 'string') + ); +} + +/** `/env.json` — every `EnvironmentVariable` row, key → value. Last write wins. */ +export function envStore(devDir: string): DevStore> { + return createStore(path.join(devDir, 'env.json'), isStringRecord, {}); +} + +/** `/secrets.json` — platform var name → value (shell-sourced or minted placeholder). Mode 0o600. */ +export function secretsStore(devDir: string): DevStore> { + return createStore(path.join(devDir, 'secrets.json'), isStringRecord, {}, 0o600); +} + +export interface PostgresInstanceEntry { + readonly instance: string; + readonly url: string; +} + +function isPostgresInstanceEntry(value: unknown): value is PostgresInstanceEntry { + return ( + typeof value === 'object' && + value !== null && + 'instance' in value && + typeof value.instance === 'string' && + 'url' in value && + typeof value.url === 'string' + ); +} + +function isPostgresRecord(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.values(value).every(isPostgresInstanceEntry) + ); +} + +/** `/postgres.json` — Database resource name → its `prisma dev` instance name and URL. */ +export function postgresStore(devDir: string): DevStore> { + return createStore(path.join(devDir, 'postgres.json'), isPostgresRecord, {}); +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/dev/postgres.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/dev/postgres.ts new file mode 100644 index 000000000..39519305c --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/dev/postgres.ts @@ -0,0 +1,204 @@ +/** + * Local postgres-cluster providers (local-dev spec § 4): `Database` and + * `Connection` become clients of the ORM CLI's local Postgres (`prisma dev`) + * — one named, detached instance per `Database` resource. `PgWarm` and + * `PnMigration` are NOT here; the hosted ones run unchanged against + * whichever URL they are handed. + */ +import { spawnSync } from 'node:child_process'; +import * as net from 'node:net'; +import type { DevProvidersInput } from '@internal/core/config'; +import * as Provider from 'alchemy/Provider'; +import * as Effect from 'effect/Effect'; +import type * as Layer from 'effect/Layer'; +import * as Redacted from 'effect/Redacted'; +import { Connection } from '../postgres/Connection.ts'; +import { Database } from '../postgres/Database.ts'; +import { appNameOf } from './app-name.ts'; +import { postgresStore } from './dev-store.ts'; +import { resolveLocalBin } from './resolve-bin.ts'; +import { combinedOutput } from './spawn-utils.ts'; + +const TCP_PROBE_TIMEOUT_MS = 500; +const RESTART_PROBE_BUDGET_MS = 10_000; +const RESTART_PROBE_INTERVAL_MS = 500; + +/** Lowercases and collapses every run of non-`[a-z0-9]` chars to a single `-`. */ +function slug(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, '-'); +} + +/** `pcdev--`, each piece slugged independently, trimmed to 63 chars. */ +export function instanceName(app: string, databaseId: string): string { + return `pcdev-${slug(app)}-${slug(databaseId)}`.slice(0, 63); +} + +function noPrismaBinError(): Error { + return new Error( + "local dev needs the prisma CLI for its local Postgres emulator ('prisma dev') — add " + + '"prisma" to your app\'s devDependencies.', + ); +} + +function couldNotReadUrlError(instance: string, output: string): Error { + return new Error( + `could not read the database URL from "prisma dev --name ${instance} --detach"; output was: ${output}`, + ); +} + +function didNotComeBackError(instance: string, host: string, port: number): Error { + return new Error( + `the local Postgres instance "${instance}" did not come back on ${host}:${port} — run ` + + '`prisma dev rm ` and retry (or `prisma-composer dev --fresh`).', + ); +} + +function lastNonEmptyLine(output: string): string | undefined { + const lines = output.split('\n').map((line) => line.trim()); + return [...lines].reverse().find((line) => line.length > 0); +} + +function probeTcpOnce(host: string, port: number, timeoutMs: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ host, port }); + let settled = false; + const finish = (ok: boolean) => { + if (settled) return; + settled = true; + socket.removeAllListeners(); + socket.destroy(); + resolve(ok); + }; + const timer = setTimeout(() => finish(false), timeoutMs); + socket.once('connect', () => { + clearTimeout(timer); + finish(true); + }); + socket.once('error', () => { + clearTimeout(timer); + finish(false); + }); + }); +} + +async function probeTcpUntil( + host: string, + port: number, + budgetMs: number, + intervalMs: number, +): Promise { + const deadline = Date.now() + budgetMs; + for (;;) { + if (await probeTcpOnce(host, port, TCP_PROBE_TIMEOUT_MS)) return true; + if (Date.now() >= deadline) return false; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } +} + +function hostPortOf(url: string): { host: string; port: number } { + const parsed = new URL(url); + return { host: parsed.hostname, port: Number.parseInt(parsed.port, 10) }; +} + +async function createInstance(prismaBin: string, instance: string): Promise { + const result = spawnSync(prismaBin, ['dev', '--name', instance, '--detach'], { + encoding: 'utf8', + }); + const output = combinedOutput(result); + const url = lastNonEmptyLine(output); + if (url === undefined || result.status !== 0) throw couldNotReadUrlError(instance, output); + return url; +} + +async function startInstance(prismaBin: string, instance: string): Promise { + spawnSync(prismaBin, ['dev', 'start', instance], { encoding: 'utf8' }); +} + +/** + * Ensures the named `prisma dev` instance is up and returns its URL, + * following the pinned sequence: create on first sight; TCP-probe the + * recorded URL; `start` + re-probe if it went cold (a reboot, a manual + * `prisma dev stop`). + */ +async function ensurePostgresInstance( + devDir: string, + prismaBin: string, + instance: string, +): Promise { + const store = postgresStore(devDir); + const existing = (await store.read())[instance]; + + if (existing === undefined) { + const url = await createInstance(prismaBin, instance); + await store.update((current) => ({ ...current, [instance]: { instance, url } })); + return url; + } + + const { host, port } = hostPortOf(existing.url); + if (await probeTcpOnce(host, port, TCP_PROBE_TIMEOUT_MS)) return existing.url; + + await startInstance(prismaBin, instance); + const backUp = await probeTcpUntil( + host, + port, + RESTART_PROBE_BUDGET_MS, + RESTART_PROBE_INTERVAL_MS, + ); + if (!backUp) throw didNotComeBackError(instance, host, port); + return existing.url; +} + +/** + * `Database` → a named `prisma dev` instance. Bin resolution walks up from + * `process.cwd()` — the one place a local provider legitimately reads it, + * since finding the app's own installed `prisma` is inherently cwd-relative. + */ +export function LocalDatabaseProvider( + input: DevProvidersInput, +): Layer.Layer> { + const service: Provider.ProviderService = { + list: () => Effect.succeed([]), + reconcile: ({ news }) => + Effect.tryPromise({ + try: async () => { + const app = appNameOf(input.container); + const prismaBin = resolveLocalBin(process.cwd(), 'prisma'); + if (prismaBin === undefined) throw noPrismaBinError(); + const instance = instanceName(app, news.name); + await ensurePostgresInstance(input.devDir, prismaBin, instance); + return { id: instance, name: news.name }; + }, + catch: (cause) => cause, + }), + delete: () => Effect.void, + }; + return Provider.effect(Database, Effect.succeed(service)); +} + +function noRecordedInstanceError(databaseId: string): Error { + return new Error( + `no local Postgres instance recorded for databaseId "${databaseId}" — the Database provider ` + + 'did not run; converge is corrupt (try --fresh).', + ); +} + +/** `Connection` → the recorded instance's URL, looked up by the Database attributes' `id` (the instance name). */ +export function LocalConnectionProvider( + input: DevProvidersInput, +): Layer.Layer> { + const service: Provider.ProviderService = { + list: () => Effect.succeed([]), + reconcile: ({ news }) => + Effect.tryPromise({ + try: async () => { + const entries = Object.values(await postgresStore(input.devDir).read()); + const found = entries.find((entry) => entry.instance === news.databaseId); + if (found === undefined) throw noRecordedInstanceError(news.databaseId); + return { id: found.instance, connectionString: Redacted.make(found.url) }; + }, + catch: (cause) => cause, + }), + delete: () => Effect.void, + }; + return Provider.effect(Connection, Effect.succeed(service)); +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/dev/providers.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/dev/providers.ts new file mode 100644 index 000000000..8ab336080 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/dev/providers.ts @@ -0,0 +1,55 @@ +/** + * The dev provider bundle (local-dev spec § 4): the SAME `Providers` + * collection tag `providers()` uses, backed by the eight local providers + * instead of the hosted Management-API-backed ones. No `ManagementClient`, + * no credentials layer — the dev bundle must typecheck without either. + */ +import type { DevProvidersInput } from '@internal/core/config'; +import * as Provider from 'alchemy/Provider'; +import * as Layer from 'effect/Layer'; +import { Bucket } from '../buckets/Bucket.ts'; +import { BucketKey } from '../buckets/BucketKey.ts'; +import { ComputeService } from '../compute/ComputeService.ts'; +import { Deployment } from '../compute/Deployment.ts'; +import { EnvironmentVariable } from '../compute/EnvironmentVariable.ts'; +import { Connection } from '../postgres/Connection.ts'; +import { Database } from '../postgres/Database.ts'; +import { Project } from '../postgres/Project.ts'; +import { Providers } from '../providers.ts'; +import { LocalBucketKeyProvider, LocalBucketProvider } from './bucket.ts'; +import { + LocalComputeServiceProvider, + LocalDeploymentProvider, + LocalEnvironmentVariableProvider, + LocalProjectProvider, +} from './compute.ts'; +import { LocalConnectionProvider, LocalDatabaseProvider } from './postgres.ts'; + +export const devProviders = (input: DevProvidersInput): Layer.Layer => + Layer.effect( + Providers, + Provider.collection([ + Project, + Database, + Connection, + ComputeService, + Deployment, + EnvironmentVariable, + Bucket, + BucketKey, + ]), + ).pipe( + Layer.provide( + Layer.mergeAll( + LocalProjectProvider(input), + LocalDatabaseProvider(input), + LocalConnectionProvider(input), + LocalComputeServiceProvider(input), + LocalDeploymentProvider(input), + LocalEnvironmentVariableProvider(input), + LocalBucketProvider(input), + LocalBucketKeyProvider(input), + ), + ), + Layer.orDie, + ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/dev/resolve-bin.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/dev/resolve-bin.ts new file mode 100644 index 000000000..e247f7b1a --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/dev/resolve-bin.ts @@ -0,0 +1,20 @@ +/** + * Generalized form of the CLI's `resolveAlchemyBin` pattern (local-dev spec + * § 4): walk up from `startDir` looking for `node_modules/.bin/`. + * Returns `undefined` rather than throwing — callers name their own bin and + * their own fix in the pinned error message (e.g. postgres.ts's "add prisma + * to your app's devDependencies"), which a generic helper cannot phrase. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export function resolveLocalBin(startDir: string, binName: string): string | undefined { + let dir = startDir; + for (;;) { + const candidate = path.join(dir, 'node_modules', '.bin', binName); + if (fs.existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/dev/spawn-utils.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/dev/spawn-utils.ts new file mode 100644 index 000000000..8d5f3c192 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/dev/spawn-utils.ts @@ -0,0 +1,7 @@ +/** Tiny shared helper for the `prisma dev` child-process calls in `postgres.ts` and `teardown.ts`. */ +import type { SpawnSyncReturns } from 'node:child_process'; + +/** stdout + stderr of a `spawnSync` result, for parsing/error reporting. */ +export function combinedOutput(result: SpawnSyncReturns): string { + return `${result.stdout}${result.stderr}`; +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/dev/teardown.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/dev/teardown.ts new file mode 100644 index 000000000..9e0a11d24 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/dev/teardown.ts @@ -0,0 +1,40 @@ +/** + * The node/fs/process-touching half of `--fresh` teardown (local-dev spec + * § 5, ADR-0041 D12). The target extension's `dev/teardown.ts` orchestrates + * (which app, which emulator clients); this module owns every actual + * filesystem and child-process operation, so the extension's own source stays + * free of `node:`/`bun:` imports (its invariant 5). + */ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import { resolveLocalBin } from './resolve-bin.ts'; +import { combinedOutput } from './spawn-utils.ts'; + +const NOT_FOUND_PATTERN = /no .*servers? found/i; + +function runPrismaDevCommand(bin: string, args: readonly string[]): void { + const result = spawnSync(bin, args, { encoding: 'utf8' }); + if (result.status === 0 || result.status === null) return; + const output = combinedOutput(result); + if (NOT_FOUND_PATTERN.test(output)) return; + throw new Error(`\`${bin} ${args.join(' ')}\` failed (exit ${String(result.status)}): ${output}`); +} + +/** + * Stops and removes every `pcdev--*` local Postgres instance. A missing + * `prisma` bin means the app never used postgres — nothing to remove. + * `startDir` is the caller's `process.cwd()` (kept out of this package's own + * env/cwd reads elsewhere — only bin resolution is legitimately cwd-relative). + */ +export function removeLocalPostgresInstances(startDir: string, app: string): void { + const bin = resolveLocalBin(startDir, 'prisma'); + if (bin === undefined) return; + const glob = `pcdev-${app}-*`; + runPrismaDevCommand(bin, ['dev', 'stop', glob]); + runPrismaDevCommand(bin, ['dev', 'rm', glob]); +} + +/** `fs.rm(..., { recursive: true, force: true })` for every given path — tolerates absence. */ +export function removeLocalPaths(paths: readonly string[]): void { + for (const target of paths) fs.rmSync(target, { recursive: true, force: true }); +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts index 8a365d5da..353863a7f 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts @@ -1,4 +1,5 @@ export * from '../compute/artifact.ts'; +export * from '../compute/artifact-extract.ts'; export * from '../compute/ComputeService.ts'; export * from '../compute/Deployment.ts'; export * from '../compute/EnvironmentVariable.ts'; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/dev.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/dev.ts new file mode 100644 index 000000000..d347c4e5f --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/dev.ts @@ -0,0 +1,9 @@ +/** `@internal/lowering/dev`'s public surface: the local dev provider bundle plus the dev-instance store and shared plumbing. Implementation lives in `../dev/*`. */ +export * from '../dev/app-name.ts'; +export * from '../dev/bucket.ts'; +export * from '../dev/compute.ts'; +export * from '../dev/dev-store.ts'; +export * from '../dev/postgres.ts'; +export * from '../dev/providers.ts'; +export * from '../dev/resolve-bin.ts'; +export * from '../dev/teardown.ts'; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/tsdown.config.ts b/packages/1-prisma-cloud/0-lowering/lowering/tsdown.config.ts index c681a314d..1b77808eb 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/tsdown.config.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/tsdown.config.ts @@ -7,5 +7,6 @@ export default defineConfig({ compute: 'src/exports/compute.ts', postgres: 'src/exports/postgres.ts', state: 'src/exports/state.ts', + dev: 'src/exports/dev.ts', }, }); From c2ed7669bea9421553f573a647b7c70181518b3a Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 22 Jul 2026 22:56:44 +0200 Subject: [PATCH 04/34] refactor(prisma-cloud): extract the preflight name walk (local-dev spec S5) preflight-names.ts pulls the provisionManifest/paramManifest walk out of preflight.ts as collectPreflightNames(), returning secrets and envParams as two separate lists rather than one merged map -- deploy preflight still merges them (identical policy for both), but the upcoming dev preflight needs to tell them apart (a placeholder for a missing secret, a hard error for a missing env-sourced param). Sharing this walk is what keeps the two preflights from drifting on which names they check. No behavior change to runPreflight. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../target/src/preflight-names.ts | 50 +++++++++++++++++++ .../1-extensions/target/src/preflight.ts | 19 +++---- 2 files changed, 57 insertions(+), 12 deletions(-) create mode 100644 packages/1-prisma-cloud/1-extensions/target/src/preflight-names.ts diff --git a/packages/1-prisma-cloud/1-extensions/target/src/preflight-names.ts b/packages/1-prisma-cloud/1-extensions/target/src/preflight-names.ts new file mode 100644 index 000000000..7d25f581b --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/preflight-names.ts @@ -0,0 +1,50 @@ +/** + * The platform env-var NAMES deploy preflight and dev preflight must both + * check — extracted so the two walks (`runPreflight`, `runDevPreflight`) + * cannot drift (local-dev spec § 5). Deploy checks secrets and env-sourced + * params identically (one platform-existence check per name); dev applies a + * DIFFERENT policy to each (a placeholder for a missing secret, a hard error + * for a missing env-sourced param), so the two lists stay separate here + * rather than merged into one. + */ + +import type { Graph } from '@internal/core'; +import { paramManifest, provisionManifest } from '@internal/core'; +import { isEnvParamSource, paramName } from './param.ts'; +import { secretName } from './secret.ts'; + +export interface PreflightName { + readonly name: string; + readonly serviceAddress: string; +} + +export interface PreflightNames { + /** Every secret slot's bound platform var name, deduped by name (first service wins the reported address). */ + readonly secrets: readonly PreflightName[]; + /** Every env-sourced param binding's platform var name (envParam-bound only — a literal-bound param never touches the platform), deduped the same way. */ + readonly envParams: readonly PreflightName[]; +} + +function dedupedNames(entries: Iterable): readonly PreflightName[] { + const byName = new Map(); + for (const entry of entries) { + if (!byName.has(entry.name)) byName.set(entry.name, entry); + } + return [...byName.values()]; +} + +/** Walks `graph.secrets`/`graph.params` exactly as `runPreflight` did before this extraction. */ +export function collectPreflightNames(graph: Graph): PreflightNames { + const secrets = dedupedNames( + provisionManifest(graph).map((binding) => ({ + name: secretName(binding), + serviceAddress: binding.serviceAddress, + })), + ); + const envParams = dedupedNames( + paramManifest(graph) + .filter((binding) => isEnvParamSource(binding.binding)) + .map((binding) => ({ name: paramName(binding), serviceAddress: binding.serviceAddress })), + ); + return { secrets, envParams }; +} diff --git a/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts index 4f21d3c73..a76d6b69d 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts @@ -13,7 +13,6 @@ * in the CLI parent, so it builds its own Management API client from env — the * same credential path `container.ts`'s `ensure`/`locate` use. */ -import { paramManifest, provisionManifest } from '@internal/core'; import type { PreflightInput } from '@internal/core/config'; import { blindCast } from '@internal/foundation/casts'; import { @@ -25,8 +24,7 @@ import { import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import { prismaCloudContainerOf } from './container.ts'; -import { isEnvParamSource, paramName } from './param.ts'; -import { secretName } from './secret.ts'; +import { collectPreflightNames } from './preflight-names.ts'; type EnvClass = 'production' | 'preview'; @@ -198,16 +196,13 @@ export async function runPreflight( // One check per platform NAME (many slots/services, secret or param, may // bind the same one). Every wired secret is required — the forwarding model // has no optional slot; a param binding is only checked when it is - // env-sourced — a literal-bound param never touches the platform. + // env-sourced — a literal-bound param never touches the platform. Deploy + // treats the two lists identically (unlike dev preflight), so they're + // merged here, secrets first (matching the pre-extraction dedup order). + const collected = collectPreflightNames(input.graph); const names = new Map(); - for (const binding of provisionManifest(input.graph)) { - const name = secretName(binding); - if (!names.has(name)) names.set(name, { name, serviceAddress: binding.serviceAddress }); - } - for (const binding of paramManifest(input.graph)) { - if (!isEnvParamSource(binding.binding)) continue; - const name = paramName(binding); - if (!names.has(name)) names.set(name, { name, serviceAddress: binding.serviceAddress }); + for (const meta of [...collected.secrets, ...collected.envParams]) { + if (!names.has(meta.name)) names.set(meta.name, meta); } if (names.size === 0) return; From 3dadea19f03b1baae0954b7dd76813f44f993726 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 22 Jul 2026 22:57:40 +0200 Subject: [PATCH 05/34] feat(prisma-cloud): wire the dev descriptor + resolve deploy options lazily prismaCloud() now builds with no environment present: resolveOptions() no longer requires PRISMA_WORKSPACE_ID (nothing downstream ever read it, and container.ts already independently requires it where the value actually matters, at ensure/locate), and region resolution is wrapped in a memoized thunk threaded into every node descriptor instead of called eagerly at construction. A descriptor calls the thunk from inside provision/serialize, at lowering time rather than construction time, which is what makes dev (which runs the same node descriptors) safe: an absent PRISMA_REGION resolves to undefined without throwing, same as today; a garbage one still fails loudly, just at first use. container.ts exports its existing deserialize() so the new dev container descriptor can reuse it verbatim; dev and deploy containers produce and consume the identical wire shape. New src/dev/*: container.ts (a purely local identity, projectId literal "local", no platform call), preflight.ts (dev value-sourcing policy, shell env else a minted placeholder plus warning for secrets, shell env else a hard error for env-sourced params, over the shared preflight-names.ts walk), emulators.ts (ensures the compute daemon always, buckets only when the graph uses an s3-kinded resource), attach.ts (a Compute-emulator client: endpoints, merged logs, stopServices), teardown.ts (--fresh: removes this app's prisma dev instances and emulator records, delegating every actual fs/ child-process operation to @internal/lowering/dev so this package's own source stays free of node:/bun: imports, invariant 5). All wired onto prismaCloud()'s new dev field per spec. Test fallout: control-env.test.ts rewritten for the new construction- time behavior (succeeds in a scrubbed env; a bad PRISMA_REGION only fails at first lowering use, driven directly through the compute descriptor's provision()); invariants.test.ts allowlists dev/ preflight.ts's two process.env reads (its own shell-token read, dev's value-sourcing policy); one direct computeDescriptor(o) call site in control-lowering.test.ts updated for the thunk signature. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../1-extensions/target/package.json | 1 + .../target/src/__tests__/control-env.test.ts | 69 +++++++----- .../src/__tests__/control-lowering.test.ts | 2 +- .../target/src/__tests__/invariants.test.ts | 3 +- .../1-extensions/target/src/container.ts | 9 +- .../target/src/control/extension.ts | 58 ++++++++-- .../target/src/descriptors/bucket.ts | 2 +- .../target/src/descriptors/postgres.ts | 4 +- .../target/src/descriptors/prisma-next.ts | 4 +- .../target/src/descriptors/s3-credentials.ts | 2 +- .../target/src/descriptors/s3-store.ts | 2 +- .../1-extensions/target/src/dev/attach.ts | 101 ++++++++++++++++++ .../1-extensions/target/src/dev/container.ts | 24 +++++ .../1-extensions/target/src/dev/emulators.ts | 26 +++++ .../1-extensions/target/src/dev/preflight.ts | 76 +++++++++++++ .../1-extensions/target/src/dev/teardown.ts | 40 +++++++ 16 files changed, 381 insertions(+), 42 deletions(-) create mode 100644 packages/1-prisma-cloud/1-extensions/target/src/dev/attach.ts create mode 100644 packages/1-prisma-cloud/1-extensions/target/src/dev/container.ts create mode 100644 packages/1-prisma-cloud/1-extensions/target/src/dev/emulators.ts create mode 100644 packages/1-prisma-cloud/1-extensions/target/src/dev/preflight.ts create mode 100644 packages/1-prisma-cloud/1-extensions/target/src/dev/teardown.ts diff --git a/packages/1-prisma-cloud/1-extensions/target/package.json b/packages/1-prisma-cloud/1-extensions/target/package.json index df0c2df3f..c2852bafe 100644 --- a/packages/1-prisma-cloud/1-extensions/target/package.json +++ b/packages/1-prisma-cloud/1-extensions/target/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@internal/core": "workspace:0.2.0", + "@internal/dev-emulators": "workspace:0.2.0", "@internal/foundation": "workspace:0.2.0", "@internal/lowering": "workspace:0.2.0", "@internal/node": "workspace:0.2.0", diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-env.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-env.test.ts index 260a1a1c0..420871bb9 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-env.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-env.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from 'bun:test'; +import type { LowerContext } from '@internal/core/deploy'; +import * as Effect from 'effect/Effect'; import { prismaCloud } from '../exports/control.ts'; /** Sets env vars for the duration of `fn`, restoring whatever was there before. */ @@ -18,9 +20,28 @@ async function withEnv(values: Record, fn: () => } } -describe('prismaCloud() — env read + validation at construction (config evaluation)', () => { - test('builds a descriptor from PRISMA_WORKSPACE_ID alone', async () => { - await withEnv({ PRISMA_WORKSPACE_ID: 'ws-123', PRISMA_REGION: undefined }, () => { +const SCRUBBED = { + PRISMA_WORKSPACE_ID: undefined, + PRISMA_REGION: undefined, + PRISMA_SERVICE_TOKEN: undefined, +}; + +/** Minimal `LowerContext` for driving one node descriptor's `provision` in isolation. */ +function computeCtx(): LowerContext { + return { + id: 'auth', + application: { projectId: 'shop-project#cloud-id', branchId: undefined }, + } as unknown as LowerContext; +} + +/** The throw under test happens before any yield, so no Alchemy context is ever needed — collapse E/R for `runSync` like `control-lowering.test.ts`'s `run` helper does. */ +function runSync(eff: Effect.Effect): A { + return Effect.runSync(eff as Effect.Effect); +} + +describe('prismaCloud() — constructs with NO environment present (local-dev spec § 5)', () => { + test('succeeds in a fully scrubbed environment — no PRISMA_* var is required at construction', async () => { + await withEnv(SCRUBBED, () => { const descriptor = prismaCloud(); expect(descriptor.id).toBe('@prisma/composer-prisma-cloud'); expect(Object.keys(descriptor.nodes).sort()).toEqual([ @@ -31,34 +52,19 @@ describe('prismaCloud() — env read + validation at construction (config evalua 's3', 's3-store', ]); + // The dev descriptor is present unconditionally — an extension without + // one is not dev-capable, and this one always is (ADR-0041). + expect(descriptor.dev).toBeDefined(); }); }); - test('an explicit workspaceId option wins — no env needed', async () => { - await withEnv({ PRISMA_WORKSPACE_ID: undefined, PRISMA_REGION: undefined }, () => { + test('an explicit workspaceId option still works — no env needed either way', async () => { + await withEnv(SCRUBBED, () => { expect(() => prismaCloud({ workspaceId: 'ws-explicit' })).not.toThrow(); }); }); - test('accepts a known PRISMA_REGION', async () => { - await withEnv({ PRISMA_WORKSPACE_ID: 'ws-123', PRISMA_REGION: 'eu-west-3' }, () => { - expect(() => prismaCloud()).not.toThrow(); - }); - }); - - test('throws naming PRISMA_WORKSPACE_ID when it is missing', async () => { - await withEnv({ PRISMA_WORKSPACE_ID: undefined, PRISMA_REGION: undefined }, () => { - expect(() => prismaCloud()).toThrow(/PRISMA_WORKSPACE_ID/); - }); - }); - - test('throws naming the bad value when PRISMA_REGION is not a known region', async () => { - await withEnv({ PRISMA_WORKSPACE_ID: 'ws-123', PRISMA_REGION: 'mars-1' }, () => { - expect(() => prismaCloud()).toThrow(/PRISMA_REGION="mars-1"/); - }); - }); - - test('the descriptor carries the registry kinds the router checks', async () => { + test('builds a descriptor from PRISMA_WORKSPACE_ID alone', async () => { await withEnv({ PRISMA_WORKSPACE_ID: 'ws-123', PRISMA_REGION: undefined }, () => { const descriptor = prismaCloud(); expect(descriptor.nodes['postgres']?.kind).toBe('resource'); @@ -71,3 +77,18 @@ describe('prismaCloud() — env read + validation at construction (config evalua }); }); }); + +describe('prismaCloud() — region resolution is deferred to first lowering use, not construction', () => { + test('a bad PRISMA_REGION does not fail construction — only an actual lowering', async () => { + await withEnv({ PRISMA_WORKSPACE_ID: 'ws-123', PRISMA_REGION: 'mars-1' }, () => { + expect(() => prismaCloud()).not.toThrow(); + + const descriptor = prismaCloud(); + const compute = descriptor.nodes['compute']; + if (compute === undefined || compute.kind !== 'service') { + throw new Error('expected a service descriptor for "compute"'); + } + expect(() => runSync(compute.provision(computeCtx()))).toThrow(/PRISMA_REGION="mars-1"/); + }); + }); +}); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts index 1c1c37685..d0d230cd9 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts @@ -1655,7 +1655,7 @@ describe("descriptors/compute.ts's provider-param loop is generic over the regis // The three-brand options can't ride the shared registry, so erase the // precise descriptor to the registry's own type — the same assignment // control.ts makes when it registers the real one. - const descriptor: NodeDescriptor = computeDescriptor(o); + const descriptor: NodeDescriptor = computeDescriptor(() => o); if (descriptor.kind !== 'service') throw new Error('expected a service descriptor'); run(descriptor.serialize(ctx, provisioned, config)); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts index c307d5fea..143784b1a 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts @@ -91,7 +91,7 @@ describe('invariant 2: authoring imports stay lean (core + pack)', () => { }); describe('invariant 4: environment touches are confined to the config serializer, the control factory, and the container lifecycle', () => { - test("the process-env token appears only in serializer.ts (param read+stash, reserved-provider-param read+stash — the origin row rides that generic pair — secret double-lookup+stash, env-sourced param double-lookup, readOrigin's stash read), control/extension.ts's prismaCloud() (ADR-0017 — PRISMA_WORKSPACE_ID + optional PRISMA_REGION; the CLI-fed deploy identity now arrives via ctx.container, never env), container.ts (PRISMA_WORKSPACE_ID + PRISMA_SERVICE_TOKEN, ADR-0038's container lifecycle), preflight.ts (shell token + fill-missing lookup), teardown.ts (shell token), and compute.ts (exposes the resolved port as PORT)", () => { + test("the process-env token appears only in serializer.ts (param read+stash, reserved-provider-param read+stash — the origin row rides that generic pair — secret double-lookup+stash, env-sourced param double-lookup, readOrigin's stash read), control/extension.ts's prismaCloud() (ADR-0017 — optional PRISMA_WORKSPACE_ID + optional PRISMA_REGION, neither required — local-dev spec § 5's lazy restructure; the CLI-fed deploy identity now arrives via ctx.container, never env), container.ts (PRISMA_WORKSPACE_ID + PRISMA_SERVICE_TOKEN, ADR-0038's container lifecycle), preflight.ts (shell token + fill-missing lookup), dev/preflight.ts (dev's own shell-token read — the local-dev value-sourcing policy, ADR-0041), teardown.ts (shell token), and compute.ts (exposes the resolved port as PORT)", () => { const sources = shippedSources(); expect(sources.length).toBeGreaterThan(0); @@ -105,6 +105,7 @@ describe('invariant 4: environment touches are confined to the config serializer { file: 'compute.ts', count: 1 }, { file: 'container.ts', count: 3 }, { file: 'control/extension.ts', count: 2 }, + { file: 'dev/preflight.ts', count: 2 }, { file: 'preflight.ts', count: 2 }, { file: 'serializer.ts', count: 10 }, { file: 'teardown.ts', count: 1 }, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/container.ts b/packages/1-prisma-cloud/1-extensions/target/src/container.ts index 7777865c3..d8eeddaaa 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/container.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/container.ts @@ -67,8 +67,13 @@ function invalidPayloadError(reason: string): Error { ); } -/** Reconstructs a `PrismaCloudContainer` from `serialize()`'s JSON output — real narrowing, no casts. */ -function deserialize(serialized: string): PrismaCloudContainer { +/** + * Reconstructs a `PrismaCloudContainer` from `serialize()`'s JSON output — + * real narrowing, no casts. Exported so `dev/container.ts`'s + * `devContainerDescriptor` can reuse it verbatim (local-dev spec § 5) — the + * dev and deploy container descriptors deserialize the identical wire shape. + */ +export function deserialize(serialized: string): PrismaCloudContainer { let parsed: unknown; try { parsed = JSON.parse(serialized); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts b/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts index b00802f23..796cb0e76 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts @@ -7,6 +7,7 @@ import type { ExtensionDescriptor, StateDescriptor } from '@internal/core/config import type { ProvisionerDescriptor } from '@internal/core/deploy'; import { blindCast } from '@internal/foundation/casts'; import * as Prisma from '@internal/lowering'; +import { devProviders } from '@internal/lowering/dev'; /** The Prisma Cloud–hosted deploy state store; its implementation lives in @internal/lowering. */ import { prismaStateLayer } from '@internal/lowering/state'; import { RPC_PEER_KEY } from '@internal/service-rpc'; @@ -30,6 +31,11 @@ import type { ResolvedCloudOptions, ServiceProviderParam, } from '../descriptors/shared.ts'; +import { devAttach } from '../dev/attach.ts'; +import { devContainerDescriptor } from '../dev/container.ts'; +import { runDevEmulators } from '../dev/emulators.ts'; +import { runDevPreflight } from '../dev/preflight.ts'; +import { runDevTeardown } from '../dev/teardown.ts'; import { SELF_ORIGIN } from '../origin-key.ts'; import { PgWarmProvider } from '../pg-warm-resource.ts'; import { PnMigrationProvider } from '../pn-migration-resource.ts'; @@ -267,14 +273,18 @@ export const PROVIDER_PARAMS: ReadonlyMap ResolvedCloudOptions { + let cached: ResolvedCloudOptions | undefined; + return () => { + cached ??= resolveOptions(opts); + return cached; + }; +} + /** The Prisma Cloud extension descriptor — `prisma-composer.config.ts` lists it under `extensions`. */ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor => { - const o = resolveOptions(opts); + const o = lazyOptions(opts); return { id: PRISMA_CLOUD_EXTENSION_ID, @@ -365,5 +391,23 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor // bucket descriptor registers under 's3', not 'bucket'. s3: bucketDescriptor(o), }, + + dev: { + container: devContainerDescriptor(), + providers: (input) => + asProvidersLayer( + Layer.mergeAll( + devProviders(input), + PgWarmProvider(), + PnMigrationProvider(), + S3CredentialsProvider(), + Prisma.ServiceKeyProvider(), + ), + ), + preflight: (input) => runDevPreflight(input), + emulators: (input) => runDevEmulators(input), + attach: (input) => devAttach(input), + teardown: (input) => runDevTeardown(input), + }, }; }; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/bucket.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/bucket.ts index df219518e..10c539536 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/bucket.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/bucket.ts @@ -20,7 +20,7 @@ import { * carrier, and its attributes (endpoint, bucketName, accessKeyId, * secretAccessKey) become the four S3Config outputs consumers resolve by name. */ -export function bucketDescriptor(_o: ResolvedCloudOptions): NodeDescriptor { +export function bucketDescriptor(_o: () => ResolvedCloudOptions): NodeDescriptor { const lowering: Lowering = ({ id, application }) => Effect.gen(function* () { validateName(id, 'resource name (from provision id)'); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts index ead55c521..dc734ae2d 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts @@ -20,7 +20,7 @@ import { * module provision id, so a resource shared by several consumers is created * exactly once. */ -export function postgresDescriptor(o: ResolvedCloudOptions): NodeDescriptor { +export function postgresDescriptor(o: () => ResolvedCloudOptions): NodeDescriptor { const lowering: Lowering = ({ id, application }) => Effect.gen(function* () { validateName(id, 'resource name (from provision id)'); @@ -28,7 +28,7 @@ export function postgresDescriptor(o: ResolvedCloudOptions): NodeDescriptor { const db = yield* Prisma.Database(`${id}-db`, { projectId: projectIdOf(application), name: id, - region: o.region ?? DEFAULT_REGION, + region: o().region ?? DEFAULT_REGION, ...(branchId !== undefined ? { branchId } : {}), }); const conn = yield* Prisma.Connection(`${id}-conn`, { databaseId: db.id, name: id }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts index 0bdf38ecc..83e99bf1e 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts @@ -24,7 +24,7 @@ import { * target REF identity (hash + sorted invariants): unchanged redeploy is a * no-op, a contract or ref-invariant change re-migrates. */ -export function prismaNextDescriptor(o: ResolvedCloudOptions): NodeDescriptor { +export function prismaNextDescriptor(o: () => ResolvedCloudOptions): NodeDescriptor { const lowering: Lowering = ({ id, node, application }) => Effect.gen(function* () { validateName(id, 'resource name (from provision id)'); @@ -32,7 +32,7 @@ export function prismaNextDescriptor(o: ResolvedCloudOptions): NodeDescriptor { const db = yield* Prisma.Database(`${id}-db`, { projectId: projectIdOf(application), name: id, - region: o.region ?? DEFAULT_REGION, + region: o().region ?? DEFAULT_REGION, ...(branchId !== undefined ? { branchId } : {}), }); const conn = yield* Prisma.Connection(`${id}-conn`, { databaseId: db.id, name: id }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-credentials.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-credentials.ts index 0af35275d..d639f6985 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-credentials.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-credentials.ts @@ -13,7 +13,7 @@ import type { ResolvedCloudOptions } from './shared.ts'; * `_o` is unused today (the mint needs no region/project) but kept for symmetry * with the other descriptors' signature. */ -export function s3CredentialsDescriptor(_o: ResolvedCloudOptions): NodeDescriptor { +export function s3CredentialsDescriptor(_o: () => ResolvedCloudOptions): NodeDescriptor { const lowering: Lowering = ({ id }) => Effect.gen(function* () { const creds = yield* S3Credentials(`${id}-creds`, {}); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-store.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-store.ts index cc9706e32..13a8b1264 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-store.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-store.ts @@ -28,7 +28,7 @@ export interface S3StoreSerialized extends ComputeSerialized { readonly secretAccessKey: unknown; } -export function s3StoreDescriptor(o: ResolvedCloudOptions): NodeDescriptor { +export function s3StoreDescriptor(o: () => ResolvedCloudOptions): NodeDescriptor { // No `base.kind !== 'service'` check any more: computeDescriptor's return // type says `kind: 'service'`, so the discriminant is a compile-time fact // rather than something to re-test at runtime. diff --git a/packages/1-prisma-cloud/1-extensions/target/src/dev/attach.ts b/packages/1-prisma-cloud/1-extensions/target/src/dev/attach.ts new file mode 100644 index 000000000..09ac60389 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/dev/attach.ts @@ -0,0 +1,101 @@ +/** + * The dev session's view of the running app (local-dev spec § 5, ADR-0041 D5) + * — a Compute-emulator client scoped to this app. Core renders `endpoints()`/ + * `logs()`/`stopServices()` and never learns the emulator's HTTP API + * (ADR-0038's opacity pattern). + */ +import type { DevAttachInput, DevAttachment } from '@internal/core/config'; +import { computeClient } from '@internal/dev-emulators'; +import { prismaCloudContainerOf } from '../container.ts'; + +const RELIST_INTERVAL_MS = 2000; + +interface LogLine { + readonly service: string; + readonly line: string; +} + +/** + * Merges every listed service's `logs?follow=1` stream into one queue, + * re-listing every 2 s so a service that appears after a later converge gets + * its own follower attached, and re-attaching any follower whose connection + * dropped (an emulator restart shows a gap in that service's stream, never a + * dead session). + */ +async function* mergedLogs(app: string, signal: AbortSignal): AsyncIterable { + const client = computeClient(); + const followed = new Set(); + const queue: LogLine[] = []; + let wake: (() => void) | undefined; + + const push = (item: LogLine): void => { + queue.push(item); + wake?.(); + wake = undefined; + }; + + const follow = (id: string): void => { + if (followed.has(id)) return; + followed.add(id); + void (async () => { + try { + let buffer = ''; + for await (const chunk of client.followLogs(app, id, signal)) { + buffer += chunk; + let newlineAt = buffer.indexOf('\n'); + while (newlineAt !== -1) { + push({ service: id, line: buffer.slice(0, newlineAt) }); + buffer = buffer.slice(newlineAt + 1); + newlineAt = buffer.indexOf('\n'); + } + } + } catch { + // the connection dropped — the next relist re-attaches it below + } finally { + followed.delete(id); + } + })(); + }; + + const relist = async (): Promise => { + const services = await client.listServices(app); + for (const svc of services) follow(svc.id); + }; + + await relist(); + const timer = setInterval(() => void relist(), RELIST_INTERVAL_MS); + signal.addEventListener('abort', () => { + clearInterval(timer); + wake?.(); + wake = undefined; + }); + + try { + while (!signal.aborted) { + const next = queue.shift(); + if (next !== undefined) { + yield next; + continue; + } + await new Promise((resolve) => { + wake = resolve; + }); + } + } finally { + clearInterval(timer); + } +} + +export async function devAttach(input: DevAttachInput): Promise { + const app = prismaCloudContainerOf(input.container).input.appName; + const client = computeClient(); + + return { + endpoints: async () => { + const services = await client.listServices(app); + return services.map((svc) => ({ address: svc.address, url: svc.url })); + }, + logs: (signal) => mergedLogs(app, signal), + stopServices: () => client.stopApp(app), + }; +} diff --git a/packages/1-prisma-cloud/1-extensions/target/src/dev/container.ts b/packages/1-prisma-cloud/1-extensions/target/src/dev/container.ts new file mode 100644 index 000000000..ce62dfd2e --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/dev/container.ts @@ -0,0 +1,24 @@ +/** + * The extension's DEV container lifecycle (local-dev spec § 5) — a purely + * local identity, resolved with no platform call: `projectId` is the literal + * `'local'`, and there is never a Branch. `deserialize` reuses + * `container.ts`'s existing one — the dev and deploy container descriptors + * produce/consume the identical wire shape (ADR-0037's transport), so the + * SAME env var either narrows to a real hosted container or this local one + * depending on which one the CLI resolved. + */ +import type { ContainerDescriptor, LocateContainerInput } from '@internal/core/config'; +import { deserialize, PrismaCloudContainer } from '../container.ts'; + +function resolve(input: LocateContainerInput): PrismaCloudContainer { + return new PrismaCloudContainer({ appName: input.appName, stage: undefined }, 'local', undefined); +} + +export function devContainerDescriptor(): ContainerDescriptor { + return { + ensure: (input) => Promise.resolve(resolve(input)), + locate: (input) => Promise.resolve(resolve(input)), + remove: () => Promise.resolve(), + deserialize, + }; +} diff --git a/packages/1-prisma-cloud/1-extensions/target/src/dev/emulators.ts b/packages/1-prisma-cloud/1-extensions/target/src/dev/emulators.ts new file mode 100644 index 000000000..ed2b93010 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/dev/emulators.ts @@ -0,0 +1,26 @@ +/** + * Dev emulator bring-up (local-dev spec § 5, ADR-0041 D4): ensures the + * machine-scoped daemons this topology's node kinds need. Compute is always + * ensured — every app has services; buckets only when the graph actually + * uses the `s3` resource kind. Postgres needs no pre-start — its instances + * are created lazily by `Database`'s local provider through the ORM CLI. + * + * Idempotent: `ensureDaemon` itself adopts an already-healthy daemon, so + * repeated `prisma-composer dev` sessions are cheap. + */ +import type { DevEmulatorsInput } from '@internal/core/config'; +import { ensureDaemon } from '@internal/dev-emulators'; + +function usesBuckets(input: DevEmulatorsInput): boolean { + return input.graph.nodes.some((n) => n.node.kind === 'resource' && n.node.type === 's3'); +} + +export async function runDevEmulators(input: DevEmulatorsInput): Promise { + const { url: computeUrl } = await ensureDaemon('compute'); + console.log(`[dev] compute emulator ready at ${computeUrl}`); + + if (usesBuckets(input)) { + const { url: bucketsUrl } = await ensureDaemon('buckets'); + console.log(`[dev] buckets emulator ready at ${bucketsUrl}`); + } +} diff --git a/packages/1-prisma-cloud/1-extensions/target/src/dev/preflight.ts b/packages/1-prisma-cloud/1-extensions/target/src/dev/preflight.ts new file mode 100644 index 000000000..3a3f24964 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/dev/preflight.ts @@ -0,0 +1,76 @@ +/** + * Dev preflight (local-dev spec § 5, ADR-0041): the same name walk deploy's + * preflight uses (`preflight-names.ts`), but with dev's value-sourcing policy + * (ADR-0041's D7) instead of deploy's platform-existence check — a secret + * falls back to a minted, persisted placeholder with a warning; an + * env-sourced param falls back to nothing and is a hard error, because params + * feed boot-time schema validation and a placeholder there would produce a + * confusing crash instead of a legible degraded mode. + * + * Control-plane only, runs in the CLI parent (no CliError import — see + * container.ts). Reads no Management API — dev is credential-free. + */ + +import type { PreflightInput } from '@internal/core/config'; +import { DEV_DIR } from '@internal/core/config'; +import { secretsStore } from '@internal/lowering/dev'; +import { collectPreflightNames, type PreflightName } from '../preflight-names.ts'; + +/** `local-placeholder-<16 lowercase hex>` — Web Crypto only, matching the extension's other local mints (ServiceKey, S3Credentials). */ +function mintPlaceholder(): string { + const bytes = crypto.getRandomValues(new Uint8Array(8)); + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + return `local-placeholder-${hex}`; +} + +function missingEnvParamsError(missing: readonly PreflightName[]): Error { + const lines = missing.map((m) => ` - ${m.name} (required by service "${m.serviceAddress}")`); + return new Error( + `local dev preflight failed — ${missing.length} env-sourced param(s) are not set in this shell:\n` + + `${lines.join('\n')}\n\n` + + 'Set each in the shell you run `prisma-composer dev` from.', + ); +} + +export async function runDevPreflight(input: PreflightInput): Promise { + // No `node:path` import (invariant 5) — local dev is POSIX-only (spec's + // Windows note), so a plain `/`-join is exact, not an approximation. + const devDir = `${process.cwd()}/${DEV_DIR}`; + const store = secretsStore(devDir); + const { secrets, envParams } = collectPreflightNames(input.graph); + + // Secrets first, persisted immediately — a later env-param failure below + // must not cost an already-minted placeholder its stability. + await store.update((current) => { + const next = { ...current }; + for (const { name } of secrets) { + const shellValue = process.env[name]; + if (shellValue !== undefined && shellValue.length > 0) { + next[name] = shellValue; + continue; + } + if (next[name] !== undefined) continue; // reuse the persisted placeholder + next[name] = mintPlaceholder(); + console.warn( + `[dev] ${name} is not set in this shell — using a local placeholder. Anything that talks ` + + 'to the real service behind it will fail; everything else runs.', + ); + } + return next; + }); + + const resolved: Record = {}; + const missing: PreflightName[] = []; + for (const meta of envParams) { + const shellValue = process.env[meta.name]; + if (shellValue !== undefined && shellValue.length > 0) { + resolved[meta.name] = shellValue; + } else { + missing.push(meta); + } + } + if (Object.keys(resolved).length > 0) { + await store.update((current) => ({ ...current, ...resolved })); + } + if (missing.length > 0) throw missingEnvParamsError(missing); +} diff --git a/packages/1-prisma-cloud/1-extensions/target/src/dev/teardown.ts b/packages/1-prisma-cloud/1-extensions/target/src/dev/teardown.ts new file mode 100644 index 000000000..5e753b3ff --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/dev/teardown.ts @@ -0,0 +1,40 @@ +/** + * `--fresh` teardown (local-dev spec § 5, ADR-0041 D12): wholesale LOCAL + * deletion — never an `alchemy destroy`. Removes this app's `prisma dev` + * instances, its records on the two machine-global emulator daemons (never + * the daemons themselves — other apps may be using them), the dev state + * directory, and the dev stage's `localState()` directory. + * + * Every actual filesystem/child-process operation is delegated to + * `@internal/lowering/dev` (this extension's own source stays free of + * `node:`/`bun:` imports — invariant 5); control-plane only, runs in the CLI + * parent (no CliError import — see container.ts). + */ + +import type { TeardownInput } from '@internal/core/config'; +import { DEV_DIR } from '@internal/core/config'; +import { bucketsClient, computeClient } from '@internal/dev-emulators'; +import { removeLocalPaths, removeLocalPostgresInstances } from '@internal/lowering/dev'; +import { prismaCloudContainerOf } from '../container.ts'; + +async function tolerateUnreachable(action: () => Promise): Promise { + try { + await action(); + } catch { + // Unreachable or absent daemon — the daemon itself is never stopped by + // `--fresh` (other apps may be using it), and there is nothing left to + // remove on it if it isn't running at all. + } +} + +export async function runDevTeardown(input: TeardownInput): Promise { + const app = prismaCloudContainerOf(input.container).input.appName; + const cwd = process.cwd(); + + removeLocalPostgresInstances(cwd, app); + + await tolerateUnreachable(() => computeClient().deleteApp(app)); + await tolerateUnreachable(() => bucketsClient().deleteApp(app)); + + removeLocalPaths([`${cwd}/${DEV_DIR}`, `${cwd}/.alchemy/state/${app}/dev`]); +} From 4268b38808c888afef74913feb7decd534ba69ca Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 22 Jul 2026 23:00:13 +0200 Subject: [PATCH 06/34] test(local-dev): ustar round-trip and dev preflight value-sourcing artifact-extract.test.ts: extractComputeArtifact round-trips packageComputeArtifact output byte for byte, and rejects a non-regular-file tar entry (typeflag != "0") that the real writer never produces. dev-preflight.test.ts: a missing secret mints a placeholder, persists it, and warns exactly once; the placeholder is stable across two preflight runs (the pinned policy warns only on the mint branch, so a second run finding the persisted value stays silent); a shell-set secret is used directly with no placeholder or warning; a missing env-sourced param is a hard error listing the name/service and pointing at the dev shell; a shell-set env-sourced param is written with no error. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../src/__tests__/artifact-extract.test.ts | 91 ++++++++++++ .../src/__tests__/dev-preflight.test.ts | 129 ++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact-extract.test.ts create mode 100644 packages/1-prisma-cloud/1-extensions/target/src/__tests__/dev-preflight.test.ts diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact-extract.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact-extract.test.ts new file mode 100644 index 000000000..d4022195f --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact-extract.test.ts @@ -0,0 +1,91 @@ +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 * as zlib from 'node:zlib'; +import { packageComputeArtifact } from '../compute/artifact.ts'; +import { extractComputeArtifact } from '../compute/artifact-extract.ts'; + +function makeBundle(files: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'artifact-extract-test-')); + for (const [rel, content] of Object.entries(files)) { + const full = path.join(dir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + return dir; +} + +function readAll(dir: string): Record { + const out: Record = {}; + const walk = (sub: string): void => { + for (const entry of fs.readdirSync(path.join(dir, sub), { withFileTypes: true })) { + const rel = sub.length > 0 ? `${sub}/${entry.name}` : entry.name; + if (entry.isDirectory()) walk(rel); + else out[rel] = fs.readFileSync(path.join(dir, rel), 'utf8'); + } + }; + walk(''); + return out; +} + +describe('extractComputeArtifact', () => { + test('round-trips packageComputeArtifact output: every packaged file lands byte-identical', () => { + const bundleDir = makeBundle({ + 'main.js': 'export default { run: async () => {} };', + 'nested/asset.txt': 'hello world', + }); + const artifact = packageComputeArtifact({ + id: 'auth', + bundleDir, + appEntry: 'server.js', + address: 'auth', + }); + + const destDir = fs.mkdtempSync(path.join(os.tmpdir(), 'artifact-extract-dest-')); + fs.rmSync(destDir, { recursive: true, force: true }); // extractComputeArtifact creates it + extractComputeArtifact(artifact.path, destDir); + + const extracted = readAll(destDir); + expect(extracted['main.js']).toBe('export default { run: async () => {} };'); + expect(extracted['nested/asset.txt']).toBe('hello world'); + expect(extracted['bootstrap.js']).toContain( + 'await main.run("auth", () => import("./server.js"));', + ); + expect(JSON.parse(extracted['compute.manifest.json'] ?? '{}')).toEqual({ + manifestVersion: '1', + entrypoint: 'bootstrap.js', + }); + expect(extracted['bunfig.toml']).toContain('auto = "disable"'); + }); + + test('rejects a tar entry with a non-regular-file typeflag', () => { + // Hand-build a one-entry ustar archive with typeflag '5' (directory) to + // prove the reader rejects anything packageComputeArtifact never writes. + const header = Buffer.alloc(512); + header.write('somedir/', 0, 100, 'utf8'); + header.write('0000644\0', 100, 8, 'utf8'); + header.write('0000000\0', 108, 8, 'utf8'); + header.write('0000000\0', 116, 8, 'utf8'); + header.write('00000000000\0', 124, 12, 'utf8'); + header.write('00000000000\0', 136, 12, 'utf8'); + header.write(' ', 148, 8, 'utf8'); + header.write('5', 156, 1, 'utf8'); // typeflag: directory + header.write('ustar\0', 257, 6, 'utf8'); + header.write('00', 263, 2, 'utf8'); + let sum = 0; + for (const b of header) sum += b; + header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'utf8'); + const tar = Buffer.concat([header, Buffer.alloc(1024)]); + const gz = zlib.gzipSync(tar); + + const tmpGz = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'artifact-extract-bad-')), + 'bad.tar.gz', + ); + fs.writeFileSync(tmpGz, gz); + const destDir = path.join(path.dirname(tmpGz), 'dest'); + + expect(() => extractComputeArtifact(tmpGz, destDir)).toThrow(/typeflag "5"/); + }); +}); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/dev-preflight.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/dev-preflight.test.ts new file mode 100644 index 000000000..c185b9dab --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/dev-preflight.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, 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 { Load, module, secret, string } from '@internal/core'; +import { secretsStore } from '@internal/lowering/dev'; +import { PrismaCloudContainer } from '../container.ts'; +import { runDevPreflight } from '../dev/preflight.ts'; +import { compute } from '../exports/index.ts'; +import { envParam } from '../param.ts'; +import { envSecret } from '../secret.ts'; + +const build = { + extension: '@prisma/composer/node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', +}; + +const fakeContainer = () => + new PrismaCloudContainer({ appName: 'app', stage: undefined }, 'local', undefined); + +const secretGraph = () => + Load( + module('app', ({ provision }) => { + provision(compute({ name: 'ingest', deps: {}, secrets: { stripeKey: secret() }, build }), { + id: 'ingest', + secrets: { stripeKey: envSecret('STRIPE_SECRET_KEY') }, + }); + }), + ); + +const paramGraph = () => + Load( + module('app', ({ provision }) => { + provision(compute({ name: 'web', deps: {}, params: { appOrigin: string() }, build }), { + id: 'web', + params: { appOrigin: envParam('APP_ORIGIN') }, + }); + }), + ); + +describe('runDevPreflight (local-dev spec S5, ADR-0041 D7)', () => { + let cwd: string; + let previousCwd: string; + let warnings: string[]; + let originalWarn: typeof console.warn; + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'dev-preflight-test-')); + previousCwd = process.cwd(); + process.chdir(cwd); + delete process.env['STRIPE_SECRET_KEY']; + delete process.env['APP_ORIGIN']; + warnings = []; + originalWarn = console.warn; + console.warn = (msg: string) => { + warnings.push(msg); + }; + }); + + afterEach(() => { + process.chdir(previousCwd); + fs.rmSync(cwd, { recursive: true, force: true }); + console.warn = originalWarn; + }); + + test('a missing secret mints a placeholder, persists it, and warns exactly once', async () => { + await runDevPreflight({ graph: secretGraph(), container: fakeContainer(), stage: undefined }); + + const stored = await secretsStore(path.join(cwd, '.prisma-composer', 'dev')).read(); + expect(stored['STRIPE_SECRET_KEY']).toMatch(/^local-placeholder-[0-9a-f]{16}$/); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toBe( + '[dev] STRIPE_SECRET_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.', + ); + }); + + test('the minted placeholder is stable across two preflight runs', async () => { + await runDevPreflight({ graph: secretGraph(), container: fakeContainer(), stage: undefined }); + const first = (await secretsStore(path.join(cwd, '.prisma-composer', 'dev')).read())[ + 'STRIPE_SECRET_KEY' + ]; + + await runDevPreflight({ graph: secretGraph(), container: fakeContainer(), stage: undefined }); + const second = (await secretsStore(path.join(cwd, '.prisma-composer', 'dev')).read())[ + 'STRIPE_SECRET_KEY' + ]; + + expect(second).toBe(first); + // The pinned policy warns only on the MINT branch — reusing an already- + // persisted placeholder is silent, so the second run adds no warning. + expect(warnings).toHaveLength(1); + }); + + test('a secret set in the shell is used directly, with no placeholder and no warning', async () => { + process.env['STRIPE_SECRET_KEY'] = 'sk_live_from_shell'; + + await runDevPreflight({ graph: secretGraph(), container: fakeContainer(), stage: undefined }); + + const stored = await secretsStore(path.join(cwd, '.prisma-composer', 'dev')).read(); + expect(stored['STRIPE_SECRET_KEY']).toBe('sk_live_from_shell'); + expect(warnings).toEqual([]); + }); + + test('a missing env-sourced param is a hard error listing the name and service, instructing the shell fix', async () => { + const error: unknown = await runDevPreflight({ + graph: paramGraph(), + container: fakeContainer(), + stage: undefined, + }).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain('APP_ORIGIN'); + expect(message).toContain('service "web"'); + expect(message).toContain('Set each in the shell you run `prisma-composer dev` from.'); + }); + + test('an env-sourced param present in the shell is written to secrets.json with no error', async () => { + process.env['APP_ORIGIN'] = 'https://localhost:3000'; + + await runDevPreflight({ graph: paramGraph(), container: fakeContainer(), stage: undefined }); + + const stored = await secretsStore(path.join(cwd, '.prisma-composer', 'dev')).read(); + expect(stored['APP_ORIGIN']).toBe('https://localhost:3000'); + }); +}); From 915ff51daa2e15c3e04449ffa61e5bc3a5019794 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 22 Jul 2026 23:08:49 +0200 Subject: [PATCH 07/34] fix(core): keep deploy.ts free of node:path (invariant 5) + update the workspace-id CLI proof lower()'s dev devDir join used node:path, which core's own invariant test forbids (core stays runtime-agnostic, no Node/Bun coupling in its shipped surface). Local dev is POSIX-only already (the spec's own Windows note), so a plain `/`-join is exact, not an approximation -- same fix already applied to the target extension's dev/preflight.ts. test/integration/test/cli.extension-config.test.ts's second test asserted the OLD eager-throw behavior removed in the prior commit (prismaCloud() failing at config evaluation without PRISMA_WORKSPACE_ID). Updated to assert the new, correct behavior: config evaluation now succeeds regardless, and deploy fails at the same missing-built-entry point either way -- a stronger proof of the restructure, since it goes through the real CLI/config-loading path rather than a unit test of prismaCloud() alone. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../0-framework/1-core/core/src/control/deploy.ts | 6 ++++-- .../integration/test/cli.extension-config.test.ts | 15 +++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/0-framework/1-core/core/src/control/deploy.ts b/packages/0-framework/1-core/core/src/control/deploy.ts index cf45c6824..fbd72b78a 100644 --- a/packages/0-framework/1-core/core/src/control/deploy.ts +++ b/packages/0-framework/1-core/core/src/control/deploy.ts @@ -1,6 +1,5 @@ /** Routes each graph node to its extension descriptor (by node.extension/type) and runs provision → serialize → package → deploy in dependency order. */ -import * as path from 'node:path'; import type { Input, StackServices } from 'alchemy'; import * as Alchemy from 'alchemy'; import type { State } from 'alchemy/State/State'; @@ -778,7 +777,10 @@ export function lower(root: ModuleNode, config: PrismaAppConfig, opts: LowerOpti const containers = deserializeContainers(config.extensions, process.env); const providers = opts.dev === true - ? mergedDevProviders(config, containers, path.join(process.cwd(), DEV_DIR)) + ? // No `node:path` import (invariant 5 — core stays runtime-agnostic): + // dev is POSIX-only (the spec's own Windows note), so a plain + // `/`-join is exact here, not an approximation. + mergedDevProviders(config, containers, `${process.cwd()}/${DEV_DIR}`) : mergedProviders(config); return Alchemy.Stack( diff --git a/test/integration/test/cli.extension-config.test.ts b/test/integration/test/cli.extension-config.test.ts index 3c3b21abb..6e0b24bf4 100644 --- a/test/integration/test/cli.extension-config.test.ts +++ b/test/integration/test/cli.extension-config.test.ts @@ -42,7 +42,16 @@ describe('prisma-composer deploy — real extension-config resolution of prisma- expect(result.stderr).toContain('run your build first'); }, 30_000); - test('without PRISMA_WORKSPACE_ID, fails at the real prismaCloud() env check during config evaluation — proving the /control entry actually resolved and ran', () => { + // Local-dev spec § 5: prismaCloud() now constructs with NO environment + // present (its `dev` field must be buildable credential-free), so + // PRISMA_WORKSPACE_ID's absence no longer surfaces at config evaluation — + // config evaluation is exactly where the OLD eager check used to fire, so + // this proves the restructure through the real CLI path, not just a unit + // test of prismaCloud() in isolation. The pipeline still reaches the same + // "no built entry" failure assemble hits regardless of the env (container + // resolution, which DOES still require PRISMA_WORKSPACE_ID, runs after + // assemble and is never reached here either way). + test('without PRISMA_WORKSPACE_ID, config evaluation still succeeds — deploy fails at the same missing-built-entry point as with the env var present', () => { const env = { ...process.env }; delete env['PRISMA_WORKSPACE_ID']; @@ -54,6 +63,8 @@ describe('prisma-composer deploy — real extension-config resolution of prisma- expect(result.status).not.toBe(0); expect(result.stderr).not.toContain('Cannot resolve'); - expect(result.stderr).toContain('environment variable PRISMA_WORKSPACE_ID is required'); + expect(result.stderr).not.toContain('PRISMA_WORKSPACE_ID'); + expect(result.stderr).toContain('no built entry at'); + expect(result.stderr).toContain('run your build first'); }, 30_000); }); From f865a7a88217718debb88ea9900c1a1dc92cc01a Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 22 Jul 2026 23:13:42 +0200 Subject: [PATCH 08/34] fix(lowering): sanitize connection-URL credentials in the prisma dev error Mid-flight spec update (cherry-picked b1d7324): the could-not-read-URL error embeds captured `prisma dev --detach` output verbatim, which can contain a connection URL with a real password when the CLI's stdout happens to echo one back on failure. The no-value-logging behavior contract applies to embedded diagnostics too, not just stashed config values, so the output is masked (`://user:***@`) before it ever reaches the thrown message. Test drives LocalDatabaseProvider's reconcile against a fake failing `prisma` bin that echoes a password-bearing URL, asserting the thrown message keeps the URL legible but never contains the raw password. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5 --- .../__tests__/dev-postgres-sanitize.test.ts | 75 +++++++++++++++++++ .../0-lowering/lowering/src/dev/postgres.ts | 7 +- 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/dev-postgres-sanitize.test.ts diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/dev-postgres-sanitize.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/dev-postgres-sanitize.test.ts new file mode 100644 index 000000000..0e4f645bb --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/dev-postgres-sanitize.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, 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 type { ContainerInstance, DevProvidersInput } from '@internal/core/config'; +import * as Effect from 'effect/Effect'; +import { LocalDatabaseProvider } from '../dev/postgres.ts'; +import { Database } from '../postgres/Database.ts'; + +/** + * A failing `prisma dev --name ... --detach` never leaks the connection + * URL's password into the thrown message (the mid-flight spec update: the + * behavior contract's no-value-logging rule applies to embedded CLI + * diagnostics too, not just stashed config values). + */ +describe('LocalDatabaseProvider — the could-not-read-URL error sanitizes captured output', () => { + let cwd: string; + let previousCwd: string; + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'dev-postgres-sanitize-test-')); + previousCwd = process.cwd(); + process.chdir(cwd); + + const binDir = path.join(cwd, 'node_modules', '.bin'); + fs.mkdirSync(binDir, { recursive: true }); + const fakePrisma = path.join(binDir, 'prisma'); + fs.writeFileSync( + fakePrisma, + '#!/bin/sh\n' + + 'echo "connecting via postgres://myuser:hunter2secret@localhost:59999/db failed"\n' + + 'exit 1\n', + { mode: 0o755 }, + ); + }); + + afterEach(() => { + process.chdir(previousCwd); + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + test('the thrown message masks the password but keeps the rest of the output legible', async () => { + const container: ContainerInstance = { + input: { appName: 'sanitizetestapp', stage: undefined }, + serialize: () => 'x', + }; + const input: DevProvidersInput = { + container, + devDir: path.join(cwd, '.prisma-composer', 'dev'), + }; + + const service = await Effect.runPromise( + Database.Provider.pipe(Effect.provide(LocalDatabaseProvider(input))), + ); + + const error: unknown = await Effect.runPromise( + service.reconcile({ + id: 'db', + instanceId: 'db', + news: { projectId: 'p', name: 'db', region: 'us-east-1' }, + olds: undefined, + output: undefined, + session: undefined as never, + bindings: [], + }), + ).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(Error); + const text = (error as Error).message; + + expect(text).toContain('could not read the database URL'); + expect(text).toContain('postgres://myuser:***@localhost:59999/db'); + expect(text).not.toContain('hunter2secret'); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/dev/postgres.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/dev/postgres.ts index 39519305c..0e06eabe6 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/dev/postgres.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/dev/postgres.ts @@ -40,9 +40,14 @@ function noPrismaBinError(): Error { ); } +/** Masks every connection-URL credential in captured CLI output before it is ever embedded in a thrown message — the no-value-logging contract (behavior contracts) applies to diagnostics too. */ +function sanitizeOutput(output: string): string { + return output.replace(/:\/\/([^:@/\s]+):[^@/\s]+@/g, '://$1:***@'); +} + function couldNotReadUrlError(instance: string, output: string): Error { return new Error( - `could not read the database URL from "prisma dev --name ${instance} --detach"; output was: ${output}`, + `could not read the database URL from "prisma dev --name ${instance} --detach"; output was: ${sanitizeOutput(output)}`, ); } From 5a7891d593189939498b5bdc2360a2370872bc69 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 23 Jul 2026 08:36:55 +0200 Subject: [PATCH 09/34] test(local-dev): add the S4 integration proof (fixture + real converge) Fixture at test/integration/test/fixtures/local-dev/: compute x2 (web depends on postgres+bucket, bkg has no deps), a bare postgres() resource, and a bucket() resource, so a dev: true lowering exercises all eight local providers. Built entries live under built/ (dist/ is gitignored repo-wide) and are hand-written self-contained files (no bundler): each declares its own compute() node inline rather than importing the sibling authoring .ts file, because a relative import to a file outside the packaged artifact's own bundle breaks once the artifact is unpacked elsewhere -- confirmed live, not theoretical. dev-config.ts is the dev stack's own narrower config (prismaCloud() only, no nodeBuild()) -- see its doc comment and the spec's recorded Open Questions entry: mergedDevProviders throws for any configured extension with no dev descriptor, including a build-only one, which structurally can't implement one. lower() itself never needs the build registry (only assembleServices does, a CLI-tooling-level concern), so omitting nodeBuild() from the dev stack's own config is safe, not a behavior change. local-dev.integration.test.ts hand-writes the dev stack file per spec S6's template (S5 owns generate-dev-stack.ts) and drives it through the real alchemy binary and the real prisma dev CLI, importing only 9-public per ADR-0028 -- the emulator daemons and dev-store files are verified through their documented wire protocol and on-disk contracts (plain fetch + fs.readFileSync), never by importing @internal/lowering or @internal/dev-emulators. Covers: scrubbed-env construction, SERVING via HTTP round-trip, emulator listings, env/secrets/postgres store contents, a real running prisma dev instance, second-converge no-op (pid stability), and changed-artifact restart of exactly one service. Also fixes composer-prisma-cloud's own package.json: daemon.ts's import.meta.resolve('@internal/dev-emulators/...') is a genuine runtime resolution once bundled into this package's dist, so it needs to be a real "dependencies" entry, not bundled away like its devDependencies siblings (confirmed: without it, the published control.mjs throws Cannot find module at runtime). Flagged in the S4 report: this makes a private, unpublished workspace package a real runtime dependency of a published one -- a real concern for whenever local dev needs to work for actual npm consumers, not resolved here. KNOWN LIMITATION, recorded in the test file's own doc comment and the S4 report rather than worked around: under bun test specifically, the postgres local provider's `prisma dev --detach` subprocess (spawned from inside the alchemy child's own Effect runtime) runs and genuinely creates the instance, but its stdout is not reliably captured by the calling spawnSync, so the test's first converge can fail with an empty captured-output error. Confirmed NOT a defect in the local providers: the identical stack file converges successfully via a plain shell, via `bun run