From 2dff33f9cbd2d8e4b5366fa89c7b78b710bda64c Mon Sep 17 00:00:00 2001 From: Kristof Siket Date: Fri, 2 Oct 2026 12:02:48 +0200 Subject: [PATCH 1/3] fix(prisma-cloud): deploy services only after their database migration A service that uses a postgres() database could go live before the database's migration finished. The lowering published the warm-up's url to consumers, and nothing a consumer references pointed at the migration, so Alchemy ran the migration and the service deployment in parallel. The url output now also references the whole migration resource. Each consumer's environment row and deployment triggers carry that url, so the deployment waits until the schema is at the target, and a failed migration stops the new code from shipping. When the migration is a no-op, the url resolves from state at plan time and adds no wait. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kristof Siket --- .../__tests__/orm-postgres-ordering.test.ts | 87 +++++++++++++++++++ .../target/src/descriptors/orm-postgres.ts | 9 +- 2 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-postgres-ordering.test.ts diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-postgres-ordering.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-postgres-ordering.test.ts new file mode 100644 index 000000000..2cbd1d61f --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-postgres-ordering.test.ts @@ -0,0 +1,87 @@ +/** + * What a consumer of a `postgres()` database waits for, against the REAL + * Output machinery and the real resource constructors (no cloud, nothing + * applies). Every consumer reads the database through the lowering's `url` + * output — its environment row writes it and its deployment's triggers carry + * it — so whatever `url` references is what Alchemy schedules them after. + */ +import { describe, expect, test } from 'bun:test'; +import * as path from 'node:path'; +import type { LowerContext, LoweredResult } from '@internal/core/deploy'; +import * as Output from 'alchemy/Output'; +import * as Prisma from 'alchemy/Prisma'; +import { Stack } from 'alchemy/Stack'; +import * as Effect from 'effect/Effect'; +import * as Redacted from 'effect/Redacted'; +import { postgresDescriptor } from '../descriptors/orm-postgres.ts'; +import { dataContract, postgres } from '../exports/orm.ts'; +import widgetContractJson from './fixtures/widget-contract/emitted/contract.json' with { + type: 'json', +}; + +/** A stack the resource constructors register into; nothing ever applies it. */ +const stack = { name: 'shop', stage: 'prod', resources: {}, bindings: {}, actions: {} }; + +const registered = (effect: Effect.Effect): Effect.Effect => + effect.pipe(Effect.provideService(Stack, stack as never)) as Effect.Effect; + +const lowering = postgresDescriptor(() => ({ + workspaceId: 'ws_1', + providerParams: new Map(), + pointerUpdatedAt: () => undefined, +})); +if (lowering.kind !== 'resource') throw new Error('postgres must lower as a resource'); + +const ctx = { + id: 'pndata', + node: postgres({ + name: 'pndata', + contract: dataContract(widgetContractJson), + config: path.join(import.meta.dir, 'fixtures', 'widget-contract', 'source', 'prisma.config.ts'), + }), + graph: { edges: [], nodes: [] }, + application: { + projectId: 'proj-1', + branchId: undefined, + defaultBranchId: 'br_default', + branchless: false, + }, +} as unknown as LowerContext; + +const { outputs } = await Effect.runPromise( + registered(lowering(ctx) as Effect.Effect), +); + +describe("the postgres lowering's url — what every consumer is scheduled after", () => { + test('the url references the migration as well as the warm-up', () => { + expect(Object.keys(Output.upstreamAny(outputs['url'])).sort()).toEqual( + ['pndata-migrate', 'pndata-warm'].sort(), + ); + }); + + test('the url still resolves to the connection string, not the migration attributes', () => { + const resolved = Effect.runSync( + Output.evaluate(outputs['url'] as Output.Output, { + 'pndata-warm': { url: 'postgres://pndata' }, + 'pndata-migrate': { storageHash: 'sha256:target', invariants: [] }, + }) as Effect.Effect, + ); + expect(resolved).toBe('postgres://pndata'); + }); + + test("a consumer's environment row built from the url waits for the migration", () => { + const url = outputs['url'] as Output.Output; + // Built the way the compute descriptor builds a dependency-input row. + const row = Effect.runSync( + registered( + Prisma.EnvironmentVariable('COMPOSER_WIDGETS_DB_URL-var', { + project: 'proj-1', + class: 'production', + key: 'COMPOSER_WIDGETS_DB_URL', + value: Output.map(url, Redacted.make), + }), + ), + ); + expect(Object.keys(Output.upstreamAny(row.Props))).toContain('pndata-migrate'); + }); +}); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/orm-postgres.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/orm-postgres.ts index 615d0fe9e..fac4c7645 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/orm-postgres.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/orm-postgres.ts @@ -2,6 +2,7 @@ import type { NodeDescriptor } from '@internal/core/config'; import type { Lowering } from '@internal/core/deploy'; +import * as Output from 'alchemy/Output'; import * as Effect from 'effect/Effect'; import { packHeadRefHashes, resolveOrmConfig } from '../orm-config.ts'; import { resolveTargetRef, targetStorageHash } from '../orm-migrate.ts'; @@ -50,7 +51,7 @@ export function postgresDescriptor(o: () => ResolvedCloudOptions): NodeDescripto // Keyed on the ref identity so a data-only change (same hash, new // invariant) still triggers reconcile. - yield* OrmMigration(`${id}-migrate`, { + const migration = yield* OrmMigration(`${id}-migrate`, { url: warm.url, migrationsDir, currentContractHash, @@ -64,7 +65,11 @@ export function postgresDescriptor(o: () => ResolvedCloudOptions): NodeDescripto // No `url` entity field — same reason as postgres: a connection string is // not a public endpoint, and only the descriptor can know that. return { - outputs: { url: warm.url }, + // Consumers deploy after the url's upstreams, so it also references + // the whole migration: a service must not boot before its schema. + outputs: { + url: Output.map(Output.all(warm.url, Output.of(migration)), ([value]) => value), + }, entities: [{ kind: 'postgres-database', id: db.databaseId }], }; }); From a1abb114d029cfa802ddd29085b17092066048dc Mon Sep 17 00:00:00 2001 From: Kristof Siket Date: Fri, 2 Oct 2026 12:02:48 +0200 Subject: [PATCH 2/3] docs: state that services deploy after their database migration Amend ADR-0022 with the ordering rule its "no runtime schema check" guarantee depends on, and note why the window where old code runs against the new schema is safe. Update the core-model sketch of the postgres lowering, and tell users in the guide and the skill what the order means for them: a failed migration blocks the new code, and a new migration target redeploys the services that use the database. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kristof Siket --- docs/design/10-domains/core-model.md | 9 ++++++--- .../ADR-0022-data-deps-carry-a-prisma-orm-contract.md | 10 ++++++++++ docs/design/90-decisions/README.md | 2 +- docs/guides/building-an-app.md | 2 +- skills/prisma-composer-core-concepts/SKILL.md | 7 +++++++ 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/design/10-domains/core-model.md b/docs/design/10-domains/core-model.md index 7b1fb3b1f..29dfa7327 100644 --- a/docs/design/10-domains/core-model.md +++ b/docs/design/10-domains/core-model.md @@ -1029,14 +1029,17 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor // deployment target. No `url` entity — a connection string is not a public // endpoint, and only this descriptor could know that (ADR-0033). `id` is the // module provision id (e.g. "db"), so a resource shared by several consumers - // is created exactly once. + // is created exactly once. The `url` output references the whole migration, + // so every consumer deploys only after the schema is at the target (ADR-0022). postgres: Object.assign( ({ id, application }) => Effect.gen(function* () { const db = yield* Prisma.Database(`${id}-db`, { project: projectIdOf(application), name: id, region }) const conn = yield* Prisma.Connection(`${id}-conn`, { database: db, name: id }) - const warm = yield* Prisma.PgWarm(`${id}-warm`, { url: conn.directConnectionString }) // FT-5226 cold-start - return { outputs: { url: warm.url }, entities: [{ kind: "postgres-database", id: db.databaseId }] } + const warm = yield* PgWarm(`${id}-warm`, { url: conn.directConnectionString }) // FT-5226 cold-start + const migration = yield* OrmMigration(`${id}-migrate`, { url: warm.url, /* target ref, config path */ }) + const url = Output.map(Output.all(warm.url, Output.of(migration)), ([value]) => value) + return { outputs: { url }, entities: [{ kind: "postgres-database", id: db.databaseId }] } }), { kind: "resource" as const }, ), diff --git a/docs/design/90-decisions/ADR-0022-data-deps-carry-a-prisma-orm-contract.md b/docs/design/90-decisions/ADR-0022-data-deps-carry-a-prisma-orm-contract.md index c4c10796b..dc11dc1fc 100644 --- a/docs/design/90-decisions/ADR-0022-data-deps-carry-a-prisma-orm-contract.md +++ b/docs/design/90-decisions/ADR-0022-data-deps-carry-a-prisma-orm-contract.md @@ -142,6 +142,16 @@ place that can actually enforce it, and it means a running service can never be crashed (nor meaningfully warned) by a runtime marker check. The framework injects the connection URL at hydrate, so user code never reads the environment. +> Amended 2026-10-02: **a service that uses a database deploys only after that +> database's migration has completed.** The guarantee above needs this order: +> without it, new code can start against a database that is not yet at its +> target. The lowering enforces it by making the database's `url` output +> reference the migration step, so every consumer's environment rows and +> deployment wait for it, and a failed migration stops the new code from +> shipping. Old code still runs against the new schema in the window between +> the migration finishing and the new deployment going live. What keeps that +> window safe is the rule that a destructive step needs an explicit opt-in. + **The target must be a ref, not a bare hash.** A marker's invariants only ever accumulate — a step's postcondition, once recorded, is never removed. Keying the migration on `storageHash` alone would silently skip a pure data-invariant change: it is an A→A self-edge (the same hash), which a hash-keyed deploy reads as "already there". Making the target a ref — hash equality plus invariant subset, mirroring Prisma ORM's own verifier — closes this. (Before the replay-only revision, the ref also ruled `dbInit` out for invariant-bearing targets, since additive-only synthesis never runs the data steps that establish invariants; with synthesis gone, replay covers that case by construction.) ## Consequences diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index 412e896f8..9795c50dd 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -43,7 +43,7 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0019](ADR-0019-the-target-owns-config-serialization.md) — The deploy target owns config serialization entirely — logic, encoding, and medium; core builds the typed Config and never encodes or reads storage. Params are target-agnostic. - [ADR-0020](ADR-0020-scheduled-work-is-a-driver-not-a-resource.md) — Scheduled work is a driver, not a resource: a scheduler service depends on the `trigger(jobId)` endpoint it calls, with the schedule as build-time config. - [ADR-0021](ADR-0021-params-are-read-through-config-not-load.md) — A service reads dependencies through `load()` and config params through a sibling `config()`; the two never share a namespace. *(Superseded by ADR-0042: `config()` and the `params` declaration are replaced by one `input` schema read through `service.input()`; `load()` stays separate.)* -- [ADR-0022](ADR-0022-data-deps-carry-a-prisma-orm-contract.md) — Data deps carry a Prisma ORM contract: typed client binding, one contract per database via its config, deploys migrate along authored edges to the contract hash. Revised 2026-08-25: deploys are replay-only — the first-deploy `dbInit` synthesis path is removed; a fresh database replays the committed baseline from empty, and a missing authored path is a refusal naming the fix (`prisma db update` locally, `contract emit` + `migration plan` to ship). *(Proposed)* +- [ADR-0022](ADR-0022-data-deps-carry-a-prisma-orm-contract.md) — Data deps carry a Prisma ORM contract: typed client binding, one contract per database via its config, deploys migrate along authored edges to the contract hash. Revised 2026-08-25: deploys are replay-only — the first-deploy `dbInit` synthesis path is removed; a fresh database replays the committed baseline from empty, and a missing authored path is a refusal naming the fix (`prisma db update` locally, `contract emit` + `migration plan` to ship). *(Proposed)* *(Amended 2026-10-02: a service that uses a database deploys only after that database's migration has completed.)* - [ADR-0023](ADR-0023-a-prisma-app-is-one-project-a-stage-is-a-branch.md) — A Prisma App is one Prisma Cloud Project; Modules are Apps/Databases inside it; a Stage is a Branch, and deploy state is per `(Project, Branch)`. - [ADR-0024](ADR-0024-a-stage-is-a-deploy-time-environment-resolved-to-project-and-branch.md) — A stage is a deploy-time environment; the CLI resolves the app's Project (by root-module name) and the stage's Branch outside Alchemy before the stack runs. - [ADR-0025](ADR-0025-name-the-unit-of-composition-module.md) — The unit of composition is a **Module**, authored with `module()`; supersedes ADR-0014's unit noun ("System"). Registers: package (npm's word) / extension (config slot) / Module (composition). diff --git a/docs/guides/building-an-app.md b/docs/guides/building-an-app.md index 5a92d64f3..08ff5a6bb 100644 --- a/docs/guides/building-an-app.md +++ b/docs/guides/building-an-app.md @@ -157,7 +157,7 @@ The workflow, once per schema change (Prisma ORM commands, via the `prisma` CLI 1. Edit `contract.prisma` — your schema. 2. `prisma contract emit` — regenerates `contract.json` + `contract.d.ts` from it. 3. `prisma migration plan --name ` — authors the migration into `migrations/`. On a brand-new project this authors the baseline (empty → your schema); commit `migrations/` with the change. -4. Deploy. The deploy applies `migrations/` before the service starts — there's no `CREATE TABLE IF NOT EXISTS` anywhere in app code. +4. Deploy. The deploy applies `migrations/` before the service starts — there's no `CREATE TABLE IF NOT EXISTS` anywhere in app code. Every service that uses the database waits for its migration: if the migration fails, the new service code does not ship. A deploy that moves the migration target (a new contract or ref) redeploys those services even when their code is unchanged. Old code keeps running against the new schema until the new deployment is live, so a migration must not break the code it replaces. The deploy is replay-only: it applies the migrations you authored and committed, and it never creates schema itself — a fresh database is brought up by replaying the committed baseline. If no authored path reaches the target contract, the deploy refuses and names the fix: author the missing migration as above, or — when you're just iterating against a local database — bring it along directly with `prisma db update`. Databases whose schema an older framework version synthesized at first deploy need a one-time retrofit; see [Deploying and operating](deploying.md#updating-a-database-whose-schema-an-older-version-synthesized). diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index 2fec2c83c..57920af27 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -276,6 +276,13 @@ including the first schema of a new database, follows one loop: 4. Commit `migrations/` with the change, then deploy. A fresh database replays the whole path from empty. +Every service that uses the database deploys only after its migration +completes: a failed migration means the new code does not ship, and a +change that moves the migration target (a new contract or ref) redeploys +those services even when their code is unchanged. The old code serves +against the new schema until the new deployment is live, so keep each +migration compatible with the code it replaces. + If no authored path reaches the target contract, deploy (and `dev` against a stale local database) refuses with `MIGRATION_PATH_NOT_FOUND`; its message lists the two ways out: author the missing migration, or, when iterating From 868ff7ad4fd3a4335b296f0a0dbbbf25661a23cf Mon Sep 17 00:00:00 2001 From: Kristof Siket Date: Fri, 2 Oct 2026 13:55:14 +0200 Subject: [PATCH 3/3] docs: describe only the enforced migration ordering The guide and the skill promised that moving the migration target redeploys the consuming services. That happens today only because a trigger member left unresolved at plan time replaces the deployment conservatively; this change does not guarantee it. Keep the docs to what the ordering edge enforces: services deploy after the migration, and a failed migration stops the new code from shipping. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kristof Siket --- docs/guides/building-an-app.md | 2 +- skills/prisma-composer-core-concepts/SKILL.md | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/guides/building-an-app.md b/docs/guides/building-an-app.md index 08ff5a6bb..a5f4b8f5e 100644 --- a/docs/guides/building-an-app.md +++ b/docs/guides/building-an-app.md @@ -157,7 +157,7 @@ The workflow, once per schema change (Prisma ORM commands, via the `prisma` CLI 1. Edit `contract.prisma` — your schema. 2. `prisma contract emit` — regenerates `contract.json` + `contract.d.ts` from it. 3. `prisma migration plan --name ` — authors the migration into `migrations/`. On a brand-new project this authors the baseline (empty → your schema); commit `migrations/` with the change. -4. Deploy. The deploy applies `migrations/` before the service starts — there's no `CREATE TABLE IF NOT EXISTS` anywhere in app code. Every service that uses the database waits for its migration: if the migration fails, the new service code does not ship. A deploy that moves the migration target (a new contract or ref) redeploys those services even when their code is unchanged. Old code keeps running against the new schema until the new deployment is live, so a migration must not break the code it replaces. +4. Deploy. The deploy applies `migrations/` before the service starts — there's no `CREATE TABLE IF NOT EXISTS` anywhere in app code. Every service that uses the database waits for its migration: if the migration fails, the new service code does not ship. Old code keeps running against the new schema until the new deployment is live, so a migration must not break the code it replaces. The deploy is replay-only: it applies the migrations you authored and committed, and it never creates schema itself — a fresh database is brought up by replaying the committed baseline. If no authored path reaches the target contract, the deploy refuses and names the fix: author the missing migration as above, or — when you're just iterating against a local database — bring it along directly with `prisma db update`. Databases whose schema an older framework version synthesized at first deploy need a one-time retrofit; see [Deploying and operating](deploying.md#updating-a-database-whose-schema-an-older-version-synthesized). diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index 57920af27..735777bda 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -277,11 +277,9 @@ including the first schema of a new database, follows one loop: replays the whole path from empty. Every service that uses the database deploys only after its migration -completes: a failed migration means the new code does not ship, and a -change that moves the migration target (a new contract or ref) redeploys -those services even when their code is unchanged. The old code serves -against the new schema until the new deployment is live, so keep each -migration compatible with the code it replaces. +completes: a failed migration means the new code does not ship. The old +code serves against the new schema until the new deployment is live, so +keep each migration compatible with the code it replaces. If no authored path reaches the target contract, deploy (and `dev` against a stale local database) refuses with `MIGRATION_PATH_NOT_FOUND`; its message