diff --git a/docs/design/10-domains/local-dev.md b/docs/design/10-domains/local-dev.md index 4e9f3648..5d0737ee 100644 --- a/docs/design/10-domains/local-dev.md +++ b/docs/design/10-domains/local-dev.md @@ -185,7 +185,7 @@ SQLite test server remains a testing utility, not part of the dev loop. ### Postgres -The emulator is the ORM CLI's local Postgres (`prisma dev`), **one named, detached instance per `Database` resource** — instance names are derived from the app and database ids, so instances are isolated, discoverable (`prisma dev ls`), and survive across dev sessions for warm starts. Migrations are not special-cased: `OrmMigration` runs exactly as it does in a deploy, against the local URL — replay-only (ADR-0022 as revised), so it applies committed migrations and never synthesizes schema. Dev-loop schema iteration therefore happens through the ORM's own `prisma db update`, run directly against the emulator database: `db update` moves the database and its marker to the current contract, and the pipeline's migration step no-ops because the marker matches the target. A dev run against a database that was neither updated nor covered by a planned migration hits the same structured refusal a deploy would, naming both exits (`prisma db update` to iterate, `contract emit` + `migration plan` to author the path). `PgWarm` is near-instant locally and is kept (not stubbed) so the provider set stays uniform. +The emulator is the ORM CLI's local Postgres (`prisma dev`), **one named, detached instance per `Database` resource** — instance names are derived from the app and database ids, so instances are isolated, discoverable (`prisma dev ls`), and survive across dev sessions for warm starts. Migrations are not special-cased: `OrmMigration` runs exactly as it does in a deploy, against the local URL — replay-only (ADR-0022 as revised), so it applies committed migrations and never synthesizes schema. At reconcile time it reloads the emitted `contract.json` identified by `prisma.config.ts`, attests its `storageHash` against the compact contract identity persisted in deploy state, and only then opens the database. Dev-loop schema iteration therefore happens through the ORM's own `prisma db update`, run directly against the emulator database: `db update` moves the database and its marker to the current contract, and the pipeline's migration step no-ops because the marker matches the target. A dev run against a database that was neither updated nor covered by a planned migration hits the same structured refusal a deploy would, naming both exits (`prisma db update` to iterate, `contract emit` + `migration plan` to author the path). `PgWarm` is near-instant locally and is kept (not stubbed) so the provider set stays uniform. ### Buckets: a disk-backed S3 emulator 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 dcbee856..c4c10796 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 @@ -49,9 +49,10 @@ The two ends of the resource pull in opposite directions, so they enter by different doors. The **contract** is *consumed*: it types and wires the resource and gives the deploy the schema version to migrate to. The **`prisma.config.ts`** is *located*, by path only — deploy-only metadata -the migration step reads to find the migrations directory. The app build never -imports it, because importing it would pull Prisma ORM's CLI, migration engine, -and source providers into the user's bundle. One contract per database. +the migration step reads to find the emitted `contract.json` and the +migrations directory. The app build never imports it, because importing it +would pull Prisma ORM's CLI, migration engine, and source providers into the +user's bundle. One contract per database. At deploy, the lowering gains a migration step per `postgres` resource. Its target is a **ref** — `{ hash, invariants }` — and the live database carries a @@ -80,9 +81,11 @@ otherwise → migrate (replay The ref comes from the resource's optional `targetRef` (naming a `migrations/app/refs/.json` file), or defaults to the head: the emitted contract's hash with zero invariants. The tracked migration resource is keyed on -the ref's identity (hash plus sorted invariants), so a data-only change still -produces a distinct deploy step. Synthesized diff-and-apply (`dbUpdate`) is never -run against a deployed database — only `migrate` is. +the ref's identity (hash plus sorted invariants), and its persisted props carry +only compact contract identity plus the config/migrations paths — not the full +emitted contract — so a data-only change still produces a distinct deploy step +without pushing `contract.json` into Alchemy state. Synthesized diff-and-apply +(`dbUpdate`) is never run against a deployed database — only `migrate` is. Bare `postgres()` is unchanged: the untyped escape hatch, the `any` of data deps, the same role `http()` plays for communication. @@ -111,19 +114,21 @@ own subpath entry, never re-exported from the index — so a service that opts o never loads `@prisma/orm-postgres` or `pg` at runtime. **Consume the contract; locate the config.** The runtime and the type system -only need to *consume* the contract: `contract.json` (the data the framework -hands the runtime at *hydrate* — the boot-time step that builds each -dependency's client) and `contract.d.ts` (types), both lightweight and -importable into the app build with no deploy machinery attached. The deploy migration step needs to -*locate* the config — the `prisma.config.ts` from which Prisma ORM resolves -the migrations directory — but it needs only the **path**, a string, read at -deploy time. Passing the config as a path rather than an import is what keeps -Prisma ORM's CLI and migration engine out of the user's bundle while still -giving the deploy lowering what it needs. A single contract is Prisma ORM's -mainline single-space model, so the user authors one contract that serves every -consuming module. Each consumer sees the full contract type; per-consumer -least-privilege slices are the deferred multi-contract extension (see -Alternatives). +need to *consume* the contract: `contract.json` (the data the framework hands +the runtime at *hydrate* — the boot-time step that builds each dependency's +client) and `contract.d.ts` (types), both lightweight and importable into the +app build with no deploy machinery attached. The deploy migration step needs to +*locate* the config — the `prisma.config.ts` from which Prisma ORM resolves the +emitted `contract.json` output and the migrations directory — but it needs only +the **path**, a string, read at deploy time. Passing the config as a path +rather than an import is what keeps Prisma ORM's CLI and migration engine out +of the user's bundle while still giving the deploy lowering what it needs. The +migration resource therefore persists only a compact attestation of the current +declared contract and reloads the full emitted contract at reconcile time. A +single contract is Prisma ORM's mainline single-space model, so the user +authors one contract that serves every consuming module. Each consumer sees the +full contract type; per-consumer least-privilege slices are the deferred +multi-contract extension (see Alternatives). **Schema checking is a build/deploy-time job, not a runtime one.** The authoritative check is the deploy. `migrate` walks the authored graph from the diff --git a/docs/design/90-decisions/ADR-0040-the-orm-binding-carries-the-url-and-a-lazy-client.md b/docs/design/90-decisions/ADR-0040-the-orm-binding-carries-the-url-and-a-lazy-client.md index ab1cc50d..743be39d 100644 --- a/docs/design/90-decisions/ADR-0040-the-orm-binding-carries-the-url-and-a-lazy-client.md +++ b/docs/design/90-decisions/ADR-0040-the-orm-binding-carries-the-url-and-a-lazy-client.md @@ -89,9 +89,11 @@ or invalidity. run against a contract the runtime's validator would reject — the storage-hash check at wiring remains the compatibility check that matters (ADR-0022). -3. `OrmMigration` and the deploy lowering are untouched: provisioning - `postgres({ name, contract, config })` still migrates at deploy. An app - owning its client gets framework-run migrations with no operator step. +3. `postgres({ name, contract, config })` still migrates at deploy. The + migration resource now persists only compact contract identity and reloads + the emitted contract artifact from `prisma.config.ts` at reconcile time, so + an app owning its client still gets framework-run migrations with no + operator step. ## Alternatives considered diff --git a/docs/guides/building-an-app.md b/docs/guides/building-an-app.md index a18d73ec..408edbfd 100644 --- a/docs/guides/building-an-app.md +++ b/docs/guides/building-an-app.md @@ -182,7 +182,8 @@ deps: { db: postgres(catalogData) } An options object is the resource end — the module that owns the database provisions it, naming the `prisma.config.ts` path (relative to the -module file) so the deploy can find `migrations/`: +module file) so the deploy can reload the emitted `contract.json` and find +`migrations/`: ```ts const db = provision( @@ -191,7 +192,11 @@ const db = provision( ``` Because both ends share the contract value, the deploy refuses to wire a -service against a database whose schema doesn't match. +service against a database whose schema doesn't match. The migration resource +persists only compact contract identity in deploy state; the full emitted +contract is reloaded from `prisma.config.ts` at reconcile time. If that +artifact is missing, unreadable, or no longer matches the declared contract, +the deploy fails before touching the database. [`examples/orm-demo`](../../examples/orm-demo/) is the minimal working version; [`examples/store/modules/catalog`](../../examples/store/modules/catalog/) is diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts index 624d125f..100d2e1f 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts @@ -610,7 +610,7 @@ describe("prismaCloud().nodes['postgres'] — the resource descriptor", () => { 'prisma.config.ts', ); - test('default stage: the Database attaches the default Branch; the migration runs on its warmed url', async () => { + test('default stage: the Database attaches the default Branch; the migration persists compact contract attestation on its warmed url', async () => { await withEnv({}, async () => { const target = prismaCloud({ workspaceId: 'ws_1' }); const node = postgres({ @@ -640,11 +640,56 @@ describe("prismaCloud().nodes['postgres'] — the resource descriptor", () => { ], ]); const [migrateId, migrateProps] = recorded.pnMigrate[before.migrate] ?? ['', {}]; + const persisted = migrateProps as Record; expect(migrateId).toBe('pndata-migrate'); - expect((migrateProps as { url: unknown }).url).toBe('postgres://pndata-conn'); + expect(persisted['url']).toBe('postgres://pndata-conn'); + expect(persisted['currentContractHash']).toBe(widgetContractJson.storage.storageHash); + expect(persisted['targetHash']).toBe(widgetContractJson.storage.storageHash); + expect(persisted['migrationsDir']).toBe(path.join(path.dirname(widgetConfig), 'migrations')); + expect(persisted['configPath']).toBe(widgetConfig); + expect(persisted['packHeadRefHashes']).toEqual([]); + expect('contractJson' in persisted).toBe(false); expect(result.entities).toEqual([{ kind: 'postgres-database', id: 'pndata-db#cloud-id' }]); }); }); + + test('a contract larger than 100 KB does not enlarge newly persisted migration props', async () => { + await withEnv({}, async () => { + const oversizedProof = 'x'.repeat(110_001); + expect(oversizedProof.length).toBeGreaterThan(100_000); + const target = prismaCloud({ workspaceId: 'ws_1' }); + const lower = async (contractJson: unknown) => { + const node = postgres({ + name: 'oversized', + contract: dataContract(contractJson), + config: widgetConfig, + }); + const ctx = { + id: 'oversized', + node, + graph: { edges: [], nodes: [] }, + application: { + projectId: 'shop-project#cloud-id', + branchId: undefined, + defaultBranchId: 'br_default', + branchless: false, + }, + } as unknown as LowerContext; + const before = recorded.pnMigrate.length; + await runAsync(resourceDescriptorOf(target, 'postgres')(ctx)); + return recorded.pnMigrate[before]?.[1]; + }; + + const compact = await lower(widgetContractJson); + const oversized = await lower({ ...widgetContractJson, oversizedProof }); + const compactJson = JSON.stringify(compact); + const oversizedJson = JSON.stringify(oversized); + + expect(oversizedJson).toBe(compactJson); + expect(oversizedJson).not.toContain(oversizedProof); + expect('contractJson' in ((oversized ?? {}) as Record)).toBe(false); + }); + }); }); describe("prismaCloud().nodes['credentials'] — the resource descriptor", () => { diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-config.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-config.test.ts index 94e33e3e..8321b0a0 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-config.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-config.test.ts @@ -9,6 +9,7 @@ import { describe, expect, test } from 'bun:test'; import * as path from 'node:path'; import { + loadContractJson, type PnExtensionPack, packHeadRefHashes, resolveMigrationsDir, @@ -19,6 +20,9 @@ import { GADGET_PACK_ID, gadgetPack, } from './fixtures/packed-contract/pack.ts'; +import widgetContractJson from './fixtures/widget-contract/emitted/contract.json' with { + type: 'json', +}; const widgetConfig = path.join( import.meta.dir, @@ -50,6 +54,26 @@ describe('resolveOrmConfig', () => { expect(project.extensionPacks).toEqual([]); }); + test('resolves the emitted contract artifact path from the config output', async () => { + const project = await resolveOrmConfig(widgetConfig); + expect(project.contractArtifactPath).toBe( + path.join(path.dirname(widgetConfig), '..', 'emitted', 'contract.json'), + ); + expect(path.isAbsolute(project.contractArtifactPath)).toBe(true); + }); + + test('without explicit output, the emitted contract path defaults next to the contract source', async () => { + const project = await resolveOrmConfig(packedConfig); + expect(project.contractArtifactPath).toBe( + path.join(path.dirname(packedConfig), 'contract.json'), + ); + }); + + test('loadContractJson reads the emitted contract the config identifies', async () => { + const project = await resolveOrmConfig(widgetConfig); + expect(await loadContractJson(project.contractArtifactPath)).toEqual(widgetContractJson); + }); + test('surfaces declared extension packs with their contract-space heads', async () => { const project = await resolveOrmConfig(packedConfig); expect(project.extensionPacks.map((p) => p.id)).toEqual([GADGET_PACK_ID]); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-migration-resource.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-migration-resource.test.ts index af9e2dc6..55d2a369 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-migration-resource.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-migration-resource.test.ts @@ -133,6 +133,123 @@ describe("the real providers' tags resolve by type (direct context, no cross-mod }); }); +const widgetConfig = path.join( + import.meta.dir, + 'fixtures', + 'widget-contract', + 'source', + 'prisma.config.ts', +); +const gadgetConfig = path.join( + import.meta.dir, + 'fixtures', + 'gadget-contract', + 'source', + 'prisma.config.ts', +); +const widgetHash = targetStorageHash(widgetContractJson); +const gadgetHash = targetStorageHash(gadgetContractJson); + +const reconcile = (input: { + readonly url: string; + readonly migrationsDir: string; + readonly configPath: string; + readonly currentContractHash: string; + readonly targetHash: string; + readonly invariants?: readonly string[]; + readonly packHeadRefHashes?: readonly string[]; + readonly refName?: string; +}) => + ormMigrationProviderService.reconcile({ + id: 'db', + fqn: 'db', + instanceId: 'db', + news: { + url: input.url, + migrationsDir: input.migrationsDir, + configPath: input.configPath, + currentContractHash: input.currentContractHash, + targetHash: input.targetHash, + invariants: input.invariants ?? [], + packHeadRefHashes: input.packHeadRefHashes ?? [], + ...(input.refName !== undefined ? { refName: input.refName } : {}), + }, + olds: undefined, + output: undefined, + // The plan session / bindings are unused by this provider's reconcile. + session: undefined as never, + bindings: undefined as never, + }); + +const matchFailure = (effect: Effect.Effect) => + Effect.runPromise( + effect.pipe( + Effect.match({ + onSuccess: () => ({ failed: false as const, error: undefined }), + onFailure: (error: unknown) => ({ failed: true as const, error }), + }), + ), + ); + +describe('OrmMigration contract loading and attestation', () => { + const impossibleUrl = 'postgres://127.0.0.1:1/never-opened'; + + test('a mismatched config-loaded contract fails before any database access', async () => { + const outcome = await matchFailure( + reconcile({ + url: impossibleUrl, + migrationsDir: path.join(path.dirname(widgetConfig), 'migrations'), + configPath: widgetConfig, + currentContractHash: gadgetHash, + targetHash: widgetHash, + }), + ); + + expect(outcome.failed).toBe(true); + expect(outcome.error).toBeInstanceOf(OrmMigrationError); + expect((outcome.error as OrmMigrationError).code).toBe('CONTRACT_IDENTITY_MISMATCH'); + }); + + test('a missing emitted contract artifact fails before any database access', async () => { + const dir = fs.mkdtempSync(path.join(import.meta.dir, 'tmp-missing-contract-')); + try { + const configPath = path.join(dir, 'prisma.config.ts'); + const contractPath = path.relative( + dir, + path.join(import.meta.dir, 'fixtures', 'widget-contract', 'source', 'contract.ts'), + ); + fs.writeFileSync( + configPath, + `import { definePrismaConfig } from '@prisma/cli-engine';\n` + + "import { defineConfig } from '@prisma/orm-postgres/config';\n\n" + + 'export default definePrismaConfig({\n' + + ' orm: defineConfig({\n' + + ` contract: ${JSON.stringify(contractPath)},\n` + + " output: './missing',\n" + + " db: { connection: 'postgres://localhost:5432/placeholder' },\n" + + ' }),\n' + + '});\n', + ); + + const outcome = await matchFailure( + reconcile({ + url: impossibleUrl, + migrationsDir: path.join(dir, 'migrations'), + configPath, + currentContractHash: widgetHash, + targetHash: widgetHash, + }), + ); + + expect(outcome.failed).toBe(true); + expect(outcome.error).toBeInstanceOf(OrmMigrationError); + expect((outcome.error as OrmMigrationError).code).toBe('CONTRACT_ARTIFACT_UNREADABLE'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + const pg: TestPostgres | undefined = startTestPostgres(); if (pg === undefined) { @@ -148,32 +265,6 @@ describe.skipIf(pg === undefined)('OrmMigration reconcile routes through applyOr let testDb: TestDatabase; let url: string; - // Drive the reconcile through the exported provider service directly — no - // Effect layer to build, so the routing assertion can't be flaked by - // environment-specific layer internals. - const reconcile = (contractJson: unknown) => - ormMigrationProviderService.reconcile({ - id: 'db', - fqn: 'db', - instanceId: 'db', - news: { - url, - contractJson, - migrationsDir, - targetHash: targetStorageHash(contractJson), - invariants: [], - // No packs declared: reconcile must not touch configPath (the path - // deliberately points nowhere). - packHeadRefHashes: [], - configPath: path.join(migrationsDir, 'no-such-prisma.config.ts'), - }, - olds: undefined, - output: undefined, - // The plan session / bindings are unused by this provider's reconcile. - session: undefined as never, - bindings: undefined as never, - }); - beforeAll(async () => { migrationsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-pn-res-')); // Replay-only: the deploy pipeline applies only authored migrations, so @@ -188,28 +279,52 @@ describe.skipIf(pg === undefined)('OrmMigration reconcile routes through applyOr if (migrationsDir !== undefined) fs.rmSync(migrationsDir, { recursive: true, force: true }); }); - test('reconcile applies the contract then no-ops on the resolved props', async () => { - const targetHash = targetStorageHash(widgetContractJson); - const first = await Effect.runPromise(reconcile(widgetContractJson)); - expect(first.storageHash).toBe(targetHash); - const second = await Effect.runPromise(reconcile(widgetContractJson)); - expect(second.storageHash).toBe(targetHash); + test('reconcile loads the config-declared contract, applies it, then no-ops on the resolved props', async () => { + const first = await Effect.runPromise( + reconcile({ + url, + migrationsDir, + configPath: widgetConfig, + currentContractHash: widgetHash, + targetHash: widgetHash, + }), + ); + expect(first.storageHash).toBe(widgetHash); + const second = await Effect.runPromise( + reconcile({ + url, + migrationsDir, + configPath: widgetConfig, + currentContractHash: widgetHash, + targetHash: widgetHash, + }), + ); + expect(second.storageHash).toBe(widgetHash); }); test('reconcile re-throws a no-path failure: the Effect REJECTS with OrmMigrationError', async () => { // Ensure the DB is signed at widgetHash (idempotent if already there). - await Effect.runPromise(reconcile(widgetContractJson)); + await Effect.runPromise( + reconcile({ + url, + migrationsDir, + configPath: widgetConfig, + currentContractHash: widgetHash, + targetHash: widgetHash, + }), + ); // Target a DIFFERENT contract (gadget) with no authored migration path. The // provider's `catch: (e) => e` must route the thrown OrmMigrationError into // the Effect's error channel — so the reconcile FAILS, not succeeds. - const outcome = await Effect.runPromise( - reconcile(gadgetContractJson).pipe( - Effect.match({ - onSuccess: () => ({ failed: false as const, error: undefined }), - onFailure: (error: unknown) => ({ failed: true as const, error }), - }), - ), + const outcome = await matchFailure( + reconcile({ + url, + migrationsDir, + configPath: gadgetConfig, + currentContractHash: gadgetHash, + targetHash: gadgetHash, + }), ); expect(outcome.failed).toBe(true); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-target-ref.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-target-ref.test.ts index 0719c758..ad7358f5 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-target-ref.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-target-ref.test.ts @@ -113,6 +113,18 @@ describe('resolveTargetRef', () => { }); }); + test('a named targetRef can point behind the current contract head', async () => { + await withTempDir(async (dir) => { + const refsDir = spaceRefsDirectory(spaceMigrationDirectory(dir, APP_SPACE_ID)); + fs.mkdirSync(refsDir, { recursive: true }); + await writeRef(refsDir, 'baseline', { hash: B, invariants: [] }); + expect(await resolveTargetRef(dir, contractJson, 'baseline')).toEqual({ + hash: B, + invariants: [], + }); + }); + }); + test('a missing named targetRef fails loudly with TARGET_REF_NOT_FOUND', async () => { await withTempDir(async (dir) => { let thrown: unknown; 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 f3d96917..615d0fe9 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 @@ -4,7 +4,7 @@ import type { NodeDescriptor } from '@internal/core/config'; import type { Lowering } from '@internal/core/deploy'; import * as Effect from 'effect/Effect'; import { packHeadRefHashes, resolveOrmConfig } from '../orm-config.ts'; -import { resolveTargetRef } from '../orm-migrate.ts'; +import { resolveTargetRef, targetStorageHash } from '../orm-migrate.ts'; import { OrmMigration } from '../orm-migration-resource.ts'; import { isPostgresResourceNode } from '../orm-postgres.ts'; import { PgWarm } from '../pg-warm-resource.ts'; @@ -28,6 +28,7 @@ export function postgresDescriptor(o: () => ResolvedCloudOptions): NodeDescripto throw new Error(`postgres lowering received a non-postgres node (${id}).`); } const contractJson = node.provides.__cmp.contractJson; + const currentContractHash = targetStorageHash(contractJson); const { migrationsDir, extensionPacks } = yield* Effect.promise(() => resolveOrmConfig(node.config), ); @@ -51,8 +52,8 @@ export function postgresDescriptor(o: () => ResolvedCloudOptions): NodeDescripto // invariant) still triggers reconcile. yield* OrmMigration(`${id}-migrate`, { url: warm.url, - contractJson, migrationsDir, + currentContractHash, targetHash: ref.hash, invariants: [...ref.invariants].sort(), packHeadRefHashes: packHeadRefHashes(extensionPacks), diff --git a/packages/1-prisma-cloud/1-extensions/target/src/orm-config.ts b/packages/1-prisma-cloud/1-extensions/target/src/orm-config.ts index b52b8d1c..8f9ebfb5 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/orm-config.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/orm-config.ts @@ -1,8 +1,9 @@ /** * Resolves a `postgres` resource's `prisma.config.ts` path to the - * project facts the deploy needs (ADR-0022, slice 2): the on-disk migrations - * directory the control client's `migrate` reads, and the declared - * extension packs. Deploy-time only: loads PN's config (via c12) and applies + * project facts the deploy needs (ADR-0022, slice 2): the emitted + * `contract.json` artifact path, the on-disk migrations directory the control + * client's `migrate` reads, and the declared extension packs. Deploy-time + * only: loads PN's config (via c12) and applies * PN's own convention — `migrations.dir`, or the default `migrations/`, * relative to the config file's directory (mirrors the CLI's * `resolveMigrationPaths`). Imported by `control.ts` + tests, never by @@ -37,6 +38,8 @@ export type PnExtensionPack = NonNullable< export interface ResolvedOrmConfig { /** The absolute migrations directory PN reads authored migration packages from. */ readonly migrationsDir: string; + /** The absolute emitted contract artifact path the config identifies. */ + readonly contractArtifactPath: string; /** The config's declared extension packs (`[]` when it declares none). */ readonly extensionPacks: readonly PnExtensionPack[]; } @@ -48,10 +51,20 @@ export async function resolveOrmConfig(configPath: string): Promise return (await resolveOrmConfig(configPath)).migrationsDir; } +/** Loads the emitted `contract.json` at the resolved artifact path. */ +export async function loadContractJson(contractArtifactPath: string): Promise { + // Freshen the specifier so repeated dev-loop reconciles re-read the file + // after `prisma contract emit` updates it in place. + const loaded = await import(`${contractArtifactPath}?t=${Date.now()}`, { + with: { type: 'json' }, + }); + return loaded.default; +} + /** * The pack-head identity entries the `OrmMigration` resource folds into its * diff key: `":"` — each pack's contract-space head ref, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/orm-migrate.ts b/packages/1-prisma-cloud/1-extensions/target/src/orm-migrate.ts index 32d99927..b6576890 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/orm-migrate.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/orm-migrate.ts @@ -67,14 +67,19 @@ export interface OrmMigrationOutcome { * migration path from the marker's state (or from empty, for a fresh * database) to the target ref. `RUNNER_FAILED` — a migration errored while * applying. `CONTRACT_INVALID` — the contract carries no - * `storage.storageHash`, so no target can be resolved. `TARGET_REF_NOT_FOUND` - * — the resource named a `targetRef` with no readable - * `migrations/app/refs/.json`. + * `storage.storageHash`, so no target can be resolved. + * `CONTRACT_ARTIFACT_UNREADABLE` — the emitted `contract.json` identified by + * `prisma.config.ts` could not be loaded. `CONTRACT_IDENTITY_MISMATCH` — the + * config-loaded emitted contract's storage hash does not match the database + * resource's declared current contract. `TARGET_REF_NOT_FOUND` — the resource + * named a `targetRef` with no readable `migrations/app/refs/.json`. */ export type OrmMigrationFailureCode = | 'MIGRATION_PATH_NOT_FOUND' | 'RUNNER_FAILED' | 'CONTRACT_INVALID' + | 'CONTRACT_ARTIFACT_UNREADABLE' + | 'CONTRACT_IDENTITY_MISMATCH' | 'TARGET_REF_NOT_FOUND'; /** A deploy-failing migration error — surfaced, never swallowed. */ diff --git a/packages/1-prisma-cloud/1-extensions/target/src/orm-migration-resource.ts b/packages/1-prisma-cloud/1-extensions/target/src/orm-migration-resource.ts index 806a9f0a..77b40156 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/orm-migration-resource.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/orm-migration-resource.ts @@ -21,16 +21,21 @@ import { Resource } from 'alchemy'; import * as Provider from 'alchemy/Provider'; import * as Effect from 'effect/Effect'; -import { resolveOrmConfig } from './orm-config.ts'; -import { applyOrmMigration } from './orm-migrate.ts'; +import { loadContractJson, resolveOrmConfig } from './orm-config.ts'; +import { applyOrmMigration, OrmMigrationError, targetStorageHash } from './orm-migrate.ts'; export interface OrmMigrationProps { /** The live DB connection string (an Alchemy Output at wiring time, resolved at apply). */ readonly url: string; - /** The deserialized contract (`node.provides.__cmp.contractJson`) — what migrate applies. */ - readonly contractJson: unknown; /** On-disk migrations root, resolved from the resource's `prisma.config.ts`. */ readonly migrationsDir: string; + /** + * The current declared contract's `storage.storageHash`, persisted as compact + * attestation and checked against the config-loaded emitted contract before + * any database access. Distinct from `targetHash`: a named ref can point + * behind the current contract head. + */ + readonly currentContractHash: string; /** The target ref's hash — half the diff/identity key. */ readonly targetHash: string; /** @@ -53,8 +58,7 @@ export interface OrmMigrationProps { readonly packHeadRefHashes: readonly string[]; /** * The resource's `prisma.config.ts` path — where reconcile reloads the - * declared extension-pack descriptors from when `packHeadRefHashes` is - * non-empty. + * emitted contract artifact and any declared extension-pack descriptors. */ readonly configPath: string; } @@ -89,17 +93,32 @@ export const ormMigrationProviderService: Provider.ProviderService reconcile: ({ news }) => Effect.tryPromise({ try: async () => { - // Descriptors are reloaded here, not carried in props: props persist - // in Alchemy state and a descriptor is live code. Loaded only when - // the key says packs are declared, so a pack-free project never pays - // (or depends on) the config load at apply time. - const extensionPacks = - news.packHeadRefHashes.length > 0 - ? (await resolveOrmConfig(news.configPath)).extensionPacks - : []; + // The config is reloaded here, not carried in props: props persist in + // Alchemy state and the emitted contract is a deploy-time artifact. + const { contractArtifactPath, extensionPacks } = await resolveOrmConfig(news.configPath); + let contractJson: unknown; + try { + contractJson = await loadContractJson(contractArtifactPath); + } catch (error) { + const summary = error instanceof Error ? error.message : String(error); + throw new OrmMigrationError( + 'CONTRACT_ARTIFACT_UNREADABLE', + `could not load the emitted contract identified by "${news.configPath}" at ` + + `"${contractArtifactPath}": ${summary}`, + ); + } + const loadedContractHash = targetStorageHash(contractJson); + if (loadedContractHash !== news.currentContractHash) { + throw new OrmMigrationError( + 'CONTRACT_IDENTITY_MISMATCH', + `the emitted contract identified by "${news.configPath}" has storage hash ` + + `"${loadedContractHash}", but the database resource declared ` + + `"${news.currentContractHash}"`, + ); + } return applyOrmMigration({ url: news.url, - contractJson: news.contractJson, + contractJson, migrationsDir: news.migrationsDir, ref: { hash: news.targetHash, invariants: news.invariants }, extensionPacks, diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index 22d2254d..f46c3cc5 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -239,7 +239,7 @@ Two kinds of Postgres dependency: `prisma contract emit`) is referenced by both the dependency end (`deps: { db: postgres(catalogData) }`) and the resource end, which also names the `prisma.config.ts` path so the deploy's migration step can - find `migrations/`. + reload the emitted `contract.json` and find `migrations/`. **Deploys are replay-only**: they apply the migrations committed under `migrations/` and never create schema themselves. Every schema change, @@ -256,8 +256,12 @@ 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 against a local -database only, `prisma db update`. Never skip step 3 before a deploy. See -`examples/store/modules/catalog` for the complete pattern. +database only, `prisma db update`. The tracked migration resource persists only +compact contract identity in deploy state; if the emitted contract artifact +named by `prisma.config.ts` is missing, unreadable, or no longer matches the +declared `dataContract(...)` value, deploy fails before touching the database. +Never skip step 3 before a deploy. See `examples/store/modules/catalog` for the +complete pattern. ## Deploy model: converge, don't script