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..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. +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/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 }], }; }); diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index 2fec2c83c..735777bda 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -276,6 +276,11 @@ 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. 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