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
9 changes: 6 additions & 3 deletions docs/design/10-domains/core-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/design/90-decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/building-an-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <slug>` — 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).

Expand Down
Original file line number Diff line number Diff line change
@@ -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 = <A>(effect: Effect.Effect<A, unknown, unknown>): Effect.Effect<A> =>
effect.pipe(Effect.provideService(Stack, stack as never)) as Effect.Effect<A>;

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<LoweredResult>),
);

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<string>, {
'pndata-warm': { url: 'postgres://pndata' },
'pndata-migrate': { storageHash: 'sha256:target', invariants: [] },
}) as Effect.Effect<string>,
);
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<string>;
// 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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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 }],
};
});
Expand Down
5 changes: 5 additions & 0 deletions skills/prisma-composer-core-concepts/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading