From 725ff9f05fb31912ee77e2e71648c18c511e37c5 Mon Sep 17 00:00:00 2001 From: Sampo Lahtinen Date: Thu, 10 Sep 2026 16:02:54 +0300 Subject: [PATCH 1/3] fix(prisma-cloud): wait for environment updates before deployment Preserve whole environment-resource dependencies through the deployment app input so pending writes finish before Compute captures its environment. Add real-provider lifecycle regressions and update the ordering documentation. Validation: build, typecheck, lint, dependency checks, 192 lowering tests, and 56 control-lowering tests pass. CodeRabbit found no issues. The full test run stops at the local-target PostgreSQL emulator because the sandbox cannot write its machine-global lock file; affected suites pass independently. Signed-off-by: Sampo Lahtinen --- .../05-prisma-cloud/alchemy-lowering.md | 11 +- ...come-from-the-upstream-alchemy-provider.md | 2 +- docs/guides/deploying.md | 5 + .../deployment-environment-lifecycle.test.ts | 235 ++++++++++++++++++ .../lowering/src/compute/deployment-edge.ts | 5 +- 5 files changed, 251 insertions(+), 7 deletions(-) create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-environment-lifecycle.test.ts diff --git a/docs/design/05-prisma-cloud/alchemy-lowering.md b/docs/design/05-prisma-cloud/alchemy-lowering.md index 6d5321f6..414b9b6a 100644 --- a/docs/design/05-prisma-cloud/alchemy-lowering.md +++ b/docs/design/05-prisma-cloud/alchemy-lowering.md @@ -181,7 +181,7 @@ How the pieces map: `Database → Connection`, whose url is written as that service's **explicitly named** variable — the same `serialize` path as any other config value. - **The connection** lowers to two edges: the producer's endpoint domain flows - into a named `EnvironmentVariable`, and that variable's **id flows into the + into a named `EnvironmentVariable`, and that **whole resource flows into the consumer's `Deployment`** through its `app` prop. - Every `EnvironmentVariable` a Deployment boots with is threaded into its `app` — database URLs and connection URLs alike — so the deployment @@ -196,14 +196,17 @@ deployment-create call literally contains the materialized env map, so the environment is genuinely an input to a deployment (see the [config lifecycle](pdp-data-model.md#the-config-lifecycle--what-is-resolved-when)). The edge's job is **ordering**: the variable write completes before -deployment-create, so the first deployment boots with a complete environment. +deployment-create, so new and replacement deployments boot with the completed +configuration updates. Without it the two race — the failure documented as PRO-211 in `gotchas.md`. **Why the edge rides `app`.** Upstream's `Prisma.Deployment` has no `environment` prop (Composer's deleted one did). Alchemy derives its dependency graph from the resource references a prop's *value* is built from, so the -descriptor builds `app` as an Output over the app id AND every variable's id, -resolving to the app id itself: the graph gains the edges. It cannot ride +descriptor builds `app` as an Output over the app id AND every whole variable +resource, resolving to the app id itself: the graph gains the edges. A persisted +variable ID can resolve before its pending value update finishes; the whole +resource keeps that update as a dependency. The edge cannot ride `artifactPath` (or any of upstream's other replacement-block props): the diff reads that block as one unit and gives no opinion the moment any member is unresolved — and a brand-new variable's reference IS unresolved at plan time — diff --git a/docs/design/90-decisions/ADR-0048-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md b/docs/design/90-decisions/ADR-0048-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md index 851596d9..846b80d5 100644 --- a/docs/design/90-decisions/ADR-0048-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md +++ b/docs/design/90-decisions/ADR-0048-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md @@ -52,7 +52,7 @@ What the trio costs: `Compute`'s preview/stable health checks and automatic roll ## The ordering edge rides the `app` prop -Environment rows must be written before the deployment is created, because the platform snapshots the branch environment into a deployment at create. Upstream's `Deployment` has no prop for that dependency, so Composer builds the edge into the `app` prop: an Output over the app id *and* every environment row's id, resolving to the app id (`lowering/src/compute/deployment-edge.ts`). Alchemy derives its graph from the resource references inside prop values, so every row is scheduled first. +Environment rows must be written before the deployment is created, because the platform snapshots the branch environment into a deployment at create. Upstream's `Deployment` has no prop for that dependency, so Composer builds the edge into the `app` prop: an Output over the app id *and* every whole environment resource, resolving to the app id (`lowering/src/compute/deployment-edge.ts`). A persisted variable ID is stable and can resolve while its value update is still pending; depending on that ID loses the ordering edge. Whole-resource references preserve the dependency on completion of the writes, including updates to existing input documents. Alchemy derives its graph from those resource references inside prop values, so every row is scheduled first. The edge must not ride `artifactPath`. Upstream's diff reads `{portMapping, skipCodeUpload, artifactPath, artifactContentType}` as one block and offers no opinion when any member is unresolved — and a brand-new environment row is always unresolved at plan time. The consequence of getting this wrong is severe and quiet: the artifact comparison never runs, the engine falls back to a plain update, and the reconcile keeps the running deployment while recording the new artifact's fingerprint as deployed — a code change silently never ships, and every later deploy agrees it already did. The `app` prop sits outside that block and tolerates being unresolved. `compute/__tests__/deployment-edge.test.ts` drives upstream's real diff and real Output machinery and fails if the edge ever moves back. diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index c894d477..73912994 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -63,6 +63,11 @@ Re-deploying any environment is idempotent — it updates the resources in place. A stage name must be a valid git ref name (`git check-ref-format`); an invalid name is a hard error, never a silent rename. +Compute deployments capture their environment when they are created. Composer +waits for its environment-variable updates to finish before creating a +deployment, including updates to an existing input document. Later variable +updates do not change an already-created deployment's environment. + After a deploy, each service is a Compute service in the Project; its public URL is its service endpoint domain — printed when the deploy finishes, and also shown in the Console. diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-environment-lifecycle.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-environment-lifecycle.test.ts new file mode 100644 index 00000000..c01fa692 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-environment-lifecycle.test.ts @@ -0,0 +1,235 @@ +import { expect, test } from 'bun:test'; +import * as Output from 'alchemy/Output'; +import * as Prisma from 'alchemy/Prisma'; +import * as Provider from 'alchemy/Provider'; +import { inMemoryState } from 'alchemy/State/InMemoryState'; +import * as Core from 'alchemy/Test/Core'; +import * as Deferred from 'effect/Deferred'; +import * as Effect from 'effect/Effect'; +import * as Exit from 'effect/Exit'; +import * as Fiber from 'effect/Fiber'; +import * as Layer from 'effect/Layer'; +import * as Redacted from 'effect/Redacted'; +import { appAfterEnvironment } from '../deployment-edge.ts'; + +const scenarios: { + name: string; + input: string; + addVariable?: boolean; + repairDrift?: boolean; + failUpdate?: boolean; +}[] = [ + { name: 'changed existing input', input: '{"required":"new"}' }, + { name: 'new required field', input: '{"required":"old","added":"new"}' }, + { name: 'unchanged input repaired after drift', input: '{"required":"old"}', repairDrift: true }, + { name: 'changed, unchanged, and new variables', input: '{"required":"new"}', addVariable: true }, + { name: 'failed input update', input: '{"required":"new"}', failUpdate: true }, +]; + +test.each(scenarios)( + 'deployment snapshots completed environment writes: $name', + async ({ input, addVariable = false, repairDrift = false, failUpdate = false }) => { + const variables = new Map(); + const values = new Map(); + const deployments = new Map(); + const snapshots: Record[] = []; + const events: string[] = []; + const updateStarted = Deferred.makeUnsafe(); + const releaseUpdate = Deferred.makeUnsafe(); + let generation = 0; + + const client = { + listEnvironmentVariables: ({ key } = {}) => + Effect.sync(() => + [...variables.values()].filter((variable) => !key || variable.key === key), + ), + getEnvironmentVariable: (id) => Effect.sync(() => variables.get(id)!), + createEnvironmentVariable: (props) => + Effect.sync(() => { + const variable: Prisma.Types.EnvironmentVariable = { + id: `var-${props.key}`, + type: 'environment-variable', + url: `https://api.example.test/environment-variables/${props.key}`, + projectId: props.projectId, + branchId: props.branchId ?? null, + class: props.class, + key: props.key, + valueKid: 'test-key', + isManagedBySystem: false, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + variables.set(variable.id, variable); + values.set(variable.key, props.value); + events.push(`write:${variable.key}`); + return variable; + }), + updateEnvironmentVariable: (id, props) => + Effect.gen(function* () { + const variable = variables.get(id)!; + if (variable.key === 'COMPOSER_INPUT') { + events.push('input-update-started'); + yield* Deferred.succeed(updateStarted, undefined); + yield* Deferred.await(releaseUpdate); + if (failUpdate) { + events.push('input-update-failed'); + return yield* Effect.die(new Error('input update failed')); + } + } + const updated = { ...variable, updatedAt: '2026-01-02T00:00:00.000Z' }; + variables.set(id, updated); + values.set(variable.key, props.value); + events.push(`write:${variable.key}`); + return updated; + }), + listAppDeployments: () => Effect.sync(() => [...deployments.values()]), + getDeployment: (id) => Effect.sync(() => deployments.get(id)!), + createAppDeployment: () => + Effect.sync(() => { + const id = `deployment-${++generation}`; + const deployment: Prisma.Types.Deployment = { + id, + type: 'deployment', + url: `https://api.example.test/deployments/${id}`, + foundryVersionId: `version-${generation}`, + status: 'new', + previewDomain: null, + createdAt: '2026-01-01T00:00:00.000Z', + }; + deployments.set(id, deployment); + snapshots.push(Object.fromEntries(values)); + events.push('deployment-snapshot'); + return { ...deployment, uploadUrl: null }; + }), + deleteDeployment: (id) => + Effect.sync(() => { + events.push(`delete:${id}`); + deployments.delete(id); + }), + } satisfies Pick< + Prisma.PrismaManagementClient, + | 'listEnvironmentVariables' + | 'getEnvironmentVariable' + | 'createEnvironmentVariable' + | 'updateEnvironmentVariable' + | 'listAppDeployments' + | 'getDeployment' + | 'createAppDeployment' + | 'deleteDeployment' + >; + + const providers = Layer.effect( + Prisma.Providers, + Provider.collection([Prisma.EnvironmentVariable, Prisma.Deployment]), + ).pipe( + Layer.provideMerge( + Layer.mergeAll(Prisma.EnvironmentVariableProvider(), Prisma.DeploymentProvider()), + ), + Layer.provide( + Layer.succeed(Prisma.PrismaClient, client as unknown as Prisma.PrismaManagementClient), + ), + ); + const options = { providers, state: inMemoryState(), dev: false }; + const stack = Core.scratchStack(options, 'deployment-input-order'); + + const program = (inputValue: string, release: number, includeNewVariable: boolean) => + Effect.gen(function* () { + const desired = { + COMPOSER_INPUT: inputValue, + UNCHANGED: 'keep-me', + ...(includeNewVariable ? { NEW_VARIABLE: 'new-value' } : {}), + }; + const environment: Prisma.EnvironmentVariable[] = []; + for (const [key, value] of Object.entries(desired)) { + environment.push( + yield* Prisma.EnvironmentVariable(key, { + project: 'project-1', + class: 'preview', + branchId: 'branch-1', + key, + value: Redacted.make(value), + }), + ); + } + const deployment = yield* Prisma.Deployment('deployment', { + app: appAfterEnvironment(Output.asOutput('app-1'), environment), + skipCodeUpload: true, + start: false, + // Environment values alone must replace the deployment. The drift case + // models a separate replacement reason while the desired input is unchanged. + triggers: { + ...Object.fromEntries( + Object.entries(desired).map(([key, value]) => [key, Redacted.make(value)]), + ), + ...(repairDrift ? { release } : {}), + }, + }); + return { deployment }; + }); + + await Core.run( + Effect.gen(function* () { + yield* stack.deploy(program('{"required":"old"}', 1, false)); + if (repairDrift) values.set('COMPOSER_INPUT', 'out-of-band-drift'); + events.length = 0; + + const plan = yield* stack.plan(program(input, 2, addVariable)); + const deploymentPlan = plan.resources['deployment']!; + expect(deploymentPlan.action).toBe('replace'); + if (deploymentPlan.action !== 'replace') throw new Error('Expected deployment replacement'); + expect(plan.resources['COMPOSER_INPUT']!.action).toBe('update'); + expect(plan.resources['UNCHANGED']!.action).toBe('update'); + if (addVariable) expect(plan.resources['NEW_VARIABLE']!.action).toBe('create'); + + const deploymentFiber = yield* Effect.forkChild( + stack.deploy(program(input, 2, addVariable)), + ); + yield* Deferred.await(updateStarted); + + expect(snapshots).toHaveLength(1); + expect(deployments.has('deployment-1')).toBe(true); + + yield* Deferred.succeed(releaseUpdate, undefined); + const result = yield* Effect.exit(Fiber.join(deploymentFiber)); + + if (failUpdate) { + expect(Exit.isFailure(result)).toBe(true); + expect(events).toContain('input-update-failed'); + expect(snapshots).toHaveLength(1); + expect([...deployments.keys()]).toEqual(['deployment-1']); + expect(events.some((event) => event.startsWith('delete:'))).toBe(false); + return; + } + + expect(Exit.isSuccess(result)).toBe(true); + expect(snapshots).toHaveLength(2); + + expect(snapshots[1]).toEqual({ + COMPOSER_INPUT: input, + UNCHANGED: 'keep-me', + ...(addVariable ? { NEW_VARIABLE: 'new-value' } : {}), + }); + expect(Object.keys(Output.upstreamAny(deploymentPlan.props)).sort()).toEqual( + ['COMPOSER_INPUT', 'UNCHANGED', ...(addVariable ? ['NEW_VARIABLE'] : [])].sort(), + ); + expect(events.indexOf('input-update-started')).toBeLessThan( + events.indexOf('write:COMPOSER_INPUT'), + ); + for (const key of [ + 'COMPOSER_INPUT', + 'UNCHANGED', + ...(addVariable ? ['NEW_VARIABLE'] : []), + ]) { + expect(events).toContain(`write:${key}`); + expect(events.indexOf(`write:${key}`)).toBeLessThan( + events.indexOf('deployment-snapshot'), + ); + } + + yield* stack.deploy(program(input, 2, addVariable)); + expect(snapshots).toHaveLength(2); + }), + options, + ); + }, +); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts index 058408a6..3311cb9f 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts @@ -3,7 +3,8 @@ * materializes rows into a deployment at create time and never re-reads them * (PRO-211). Alchemy schedules only on resource references inside prop * values, and upstream's `Prisma.Deployment` has no environment prop, so the - * edge rides `app`: every variable's id threads through it, and the platform + * edge rides `app`: whole variable resources preserve their update edges; + * stable variable IDs alone are folded away during planning. The platform * still receives the app id. `app` is the ONLY safe prop — upstream's diff * treats `{portMapping, skipCodeUpload, artifactPath, artifactContentType}` * as one block and returns "no opinion" if any is unresolved (a brand-new @@ -26,6 +27,6 @@ export const appAfterEnvironment = ( // built FROM, never inside the function, so an app referenced only by // the closure would leave the deployment with no edge to its own app. Output.flatMap( - Output.all(app, ...environment.map((variable) => variable.environmentVariableId)), + Output.all(app, ...environment.map((variable) => Output.of(variable))), () => app, ); From 819a1da75bc92df068e93f9f7d7b47a920278016 Mon Sep 17 00:00:00 2001 From: Sampo Lahtinen Date: Mon, 14 Sep 2026 15:31:53 +0200 Subject: [PATCH 2/3] test(prisma-cloud): isolate state test configuration Effect caches its default environment provider on first use. Deployment lifecycle tests can initialize it before state tests assign their token, causing ten order-dependent failures. Provide the test token through a scoped ConfigProvider instead of mutating process.env. Verified all 192 lowering tests in default order and randomized seeds 1 and 2 with PRISMA_SERVICE_TOKEN unset, plus typecheck and Biome. Signed-off-by: Sampo Lahtinen --- .../src/state/__tests__/legacy-resources.test.ts | 12 +++++++++--- .../lowering/src/state/__tests__/state-api.test.ts | 12 +++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts index 004b07bd..643b11b3 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts @@ -28,6 +28,7 @@ import { type StateService, } from 'alchemy/State'; import { PlatformServices } from 'alchemy/Util/PlatformServices'; +import * as ConfigProvider from 'effect/ConfigProvider'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import * as Redacted from 'effect/Redacted'; @@ -35,8 +36,6 @@ import { stateLayerAgainst } from '../layer.ts'; import { migrateLegacyResourceState } from '../legacy-resources.ts'; import { FakeStateApi } from './fake-state-api.ts'; -process.env['PRISMA_SERVICE_TOKEN'] ??= 'test-service-token'; - const DIRECT_URL = 'postgres://user:pass@db.prisma.io:5432/postgres'; const legacyDatabaseRow = (): CreatedResourceState => ({ @@ -1034,7 +1033,14 @@ describe('state round-trip of legacy rows through the hosted state layer', () => Effect.gen(function* () { const service = yield* yield* State; return yield* use(service).pipe(Effect.orDie); - }).pipe(Effect.provide(layer)) as Effect.Effect, + }).pipe( + Effect.provide(layer), + // Do not depend on when another test first snapshots the process environment. + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnvRecord({ PRISMA_SERVICE_TOKEN: 'test-service-token' }), + ), + ) as Effect.Effect, ); }; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts index 5235f63e..7801ef8f 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts @@ -8,6 +8,7 @@ import { State, type StateService, } from 'alchemy/State'; +import * as ConfigProvider from 'effect/ConfigProvider'; import * as Effect from 'effect/Effect'; import * as Fiber from 'effect/Fiber'; import * as Layer from 'effect/Layer'; @@ -26,8 +27,6 @@ import { } from '../lease.ts'; import { FakeStateApi } from './fake-state-api.ts'; -process.env['PRISMA_SERVICE_TOKEN'] = 'test-service-token'; - const PROJECT_ID = 'proj-1'; const BRANCH_ID = 'br-1'; const STACK = 'demo-stack'; @@ -312,7 +311,14 @@ describe('prismaStateLayer against the platform state API', () => { Effect.gen(function* () { const service = yield* yield* State; return yield* use(service).pipe(Effect.orDie); - }).pipe(Effect.provide(layer)) as Effect.Effect, + }).pipe( + Effect.provide(layer), + // Do not depend on when another test first snapshots the process environment. + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnvRecord({ PRISMA_SERVICE_TOKEN: 'test-service-token' }), + ), + ) as Effect.Effect, ); }; From 6f6b62182b153da664850d49361f28c9f48d1763 Mon Sep 17 00:00:00 2001 From: Sampo Lahtinen Date: Mon, 14 Sep 2026 17:05:56 +0200 Subject: [PATCH 3/3] docs(prisma-cloud): correct deployment dependency diagram Address review feedback by labeling environment dependency edges as resource references, matching the implementation and surrounding prose. Signed-off-by: Sampo Lahtinen --- docs/design/05-prisma-cloud/alchemy-lowering.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/design/05-prisma-cloud/alchemy-lowering.md b/docs/design/05-prisma-cloud/alchemy-lowering.md index 414b9b6a..6804e321 100644 --- a/docs/design/05-prisma-cloud/alchemy-lowering.md +++ b/docs/design/05-prisma-cloud/alchemy-lowering.md @@ -166,10 +166,10 @@ flowchart TB DBs[(Database storefront-db)] --> Cs[Connection] -- url --> EVs["EnvironmentVariable(STOREFRONT_DB_URL)"] Sa[App auth] --> Da[Deployment_a] Ss[App storefront] --> Ds[Deployment_s] - EVa -- id ref --> Da - EVs -- id ref --> Ds + EVa -- resource ref --> Da + EVs -- resource ref --> Ds Da -- appEndpointDomain --> EVu["EnvironmentVariable(STOREFRONT_AUTH_URL)"] - EVu -- id ref --> Ds + EVu -- resource ref --> Ds end ```