Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions docs/design/05-prisma-cloud/alchemy-lowering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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
Comment thread
sampolahtinen marked this conversation as resolved.
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
Expand All @@ -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 —
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions docs/guides/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, Prisma.Types.EnvironmentVariable>();
const values = new Map<string, string>();
const deployments = new Map<string, Prisma.Types.Deployment>();
const snapshots: Record<string, string>[] = [];
const events: string[] = [];
const updateStarted = Deferred.makeUnsafe<void>();
const releaseUpdate = Deferred.makeUnsafe<void>();
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,
);
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
);
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,14 @@ 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';
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 => ({
Expand Down Expand Up @@ -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<A>,
}).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<A>,
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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<A>,
}).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<A>,
);
};

Expand Down
Loading