From 13a42b1dff0fe01b2cc0e4549de86e9192f0540b Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 21 Jul 2026 20:12:47 +0200 Subject: [PATCH] =?UTF-8?q?TML-3072:=20refs=20become=20pure=20pointers=20?= =?UTF-8?q?=E2=80=94=20fold=20ref-paired=20snapshots=20into=20the=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ref is now only its refs/.json pointer ({hash, invariants}); the contract it names resolves through migrations/snapshots// by the pointer's hash. The ADR-218 paired sibling files (refs/.contract.json / .contract.d.ts) are neither written nor read: contractAt's ref branch reads pointer-then-store (provenance retagged 'snapshot' -> 'ref'), ref-advancement and ref set write store-then-pointer (write-if-absent), and ref delete removes only the pointer — store entries are shared by hash. refs/snapshot.ts is deleted; a pointer whose store entry is missing is a hard MIGRATION.CONTRACT_SNAPSHOT_MISSING error, and the pointer-absent error is renamed MIGRATION.REF_NOT_RESOLVABLE to describe what it now means. The one-shot migrator folds existing ref pairs into the store, and the 0.16-to-0.17 upgrade instructions (both skills) document the layout change. ADR 218 carries a status note; ADR 240's path-to-one-store section now states the fold as shipped. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- ...hots and universal graph-node invariant.md | 2 + ...shots live in a content-addressed store.md | 2 +- .../subsystems/7. Migration System.md | 46 ++-- docs/glossary.md | 2 +- .../3-tooling/cli/src/commands/db-init.ts | 1 + .../3-tooling/cli/src/commands/db-update.ts | 1 + .../3-tooling/cli/src/commands/migrate.ts | 1 + .../cli/src/commands/migration-plan.ts | 2 +- .../3-tooling/cli/src/commands/ref.ts | 16 +- .../3-tooling/cli/src/utils/cli-errors.ts | 18 +- .../cli/src/utils/contract-at-errors.ts | 13 +- .../cli/src/utils/plan-resolution.ts | 10 +- .../cli/src/utils/ref-advancement.ts | 25 +- .../commands/migration-plan-command.test.ts | 7 +- .../read-commands-json-golden.test.ts | 6 +- .../3-tooling/cli/test/commands/ref.test.ts | 129 +++------- .../cli/test/utils/plan-resolution.test.ts | 71 +++--- .../cli/test/utils/ref-advancement.test.ts | 83 +++--- .../migration/src/aggregate/aggregate.ts | 49 ++-- .../migration/src/aggregate/types.ts | 7 +- .../3-tooling/migration/src/errors.ts | 18 +- .../3-tooling/migration/src/exports/errors.ts | 1 + .../3-tooling/migration/src/exports/refs.ts | 8 - .../3-tooling/migration/src/refs/snapshot.ts | 199 --------------- .../test/aggregate/contract-at.test.ts | 62 +++-- .../test/refs/snapshot-failure.test.ts | 241 ------------------ .../migration/test/refs/snapshot.test.ts | 206 --------------- scripts/migrate-migrations-layout.mjs | 138 +++++++++- scripts/migrate-migrations-layout.test.mjs | 122 +++++++++ .../upgrades/0.16-to-0.17/instructions.md | 28 ++ skills/prisma-next-migration-review/SKILL.md | 2 +- skills/prisma-next-migrations/SKILL.md | 18 +- .../upgrades/0.16-to-0.17/instructions.md | 26 ++ .../test/cli.db-ref-advancement.e2e.test.ts | 37 ++- .../cli.migrate-ref-advancement.e2e.test.ts | 50 ++-- .../cli.migration-plan-ref-aware.e2e.test.ts | 71 ++---- ...> cli.ref-pointer-integration.e2e.test.ts} | 53 ++-- 37 files changed, 707 insertions(+), 1064 deletions(-) delete mode 100644 packages/1-framework/3-tooling/migration/src/refs/snapshot.ts delete mode 100644 packages/1-framework/3-tooling/migration/test/refs/snapshot-failure.test.ts delete mode 100644 packages/1-framework/3-tooling/migration/test/refs/snapshot.test.ts rename test/integration/test/{cli.ref-snapshot-integration.e2e.test.ts => cli.ref-pointer-integration.e2e.test.ts} (82%) diff --git a/docs/architecture docs/adrs/ADR 218 - Refs with paired contract snapshots and universal graph-node invariant.md b/docs/architecture docs/adrs/ADR 218 - Refs with paired contract snapshots and universal graph-node invariant.md index 85cce6c139..6ee7760482 100644 --- a/docs/architecture docs/adrs/ADR 218 - Refs with paired contract snapshots and universal graph-node invariant.md +++ b/docs/architecture docs/adrs/ADR 218 - Refs with paired contract snapshots and universal graph-node invariant.md @@ -4,6 +4,8 @@ **Accepted** — closes the dev → ship transition trap tracked in [TML-2629](https://linear.app/prisma-company/issue/TML-2629/dev-ship-transition-broken-first-migration-plan-after-db-update). +The **paired contract snapshot** part of this decision (a ref carrying its own `.contract.json` / `.contract.d.ts` copy) was folded into the shared content-addressed store introduced by [ADR 240](ADR%20240%20-%20Contract%20snapshots%20live%20in%20a%20content-addressed%20store.md) (TML-3072). A ref is now only its pointer file (`{hash, invariants}`); the contract bytes it names resolve through `migrations/snapshots//contract.{json,d.ts}` by that hash, the same store every graph node resolves through. The **universal graph-node invariant** and **asymmetric ref-advancement** decisions below are unaffected and remain current. + ## Context Prisma Next's migration workflow is deliberately Git-shaped: named **refs** point at contract hashes, the on-disk **migration graph** records committed edges between hashes, and the live database **marker** records which hash the database was last brought up to. In healthy operation those three views agree. In the dev-shaped loop — iterate locally with `db init` / `db update`, then publish with `migration plan` and `migrate` — they can drift apart with no precise diagnostic. diff --git a/docs/architecture docs/adrs/ADR 240 - Contract snapshots live in a content-addressed store.md b/docs/architecture docs/adrs/ADR 240 - Contract snapshots live in a content-addressed store.md index 98ead8a511..edfdec628e 100644 --- a/docs/architecture docs/adrs/ADR 240 - Contract snapshots live in a content-addressed store.md +++ b/docs/architecture docs/adrs/ADR 240 - Contract snapshots live in a content-addressed store.md @@ -96,7 +96,7 @@ One planner caller never renders a `migration.ts` at all: the `db init` / `db up This store is a distinct concept from the ref-paired snapshot [ADR 218](ADR%20218%20-%20Refs%20with%20paired%20contract%20snapshots%20and%20universal%20graph-node%20invariant.md) introduced: `refs/.contract.json` / `.contract.d.ts` are mutable working state paired to a live ref (`db`, `production`, …), rewritten whenever the ref advances. The content-addressed store this ADR describes holds immutable migration-chain history, written once per distinct contract and never rewritten. Both are called "snapshot" today — a boundary worth naming precisely because it is not meant to be permanent. -The two are folding into one. Ref-paired snapshots are the immediate next piece of work: `refs/.json` becomes a pure `{ hash, invariants }` pointer, and the ref's contract resolves through this same store instead of through paired sibling files. Every ref-advance path already resolves the contract bytes it would pair with the ref, so writing them into the store instead changes no call site's inputs — only where the bytes land. Once that lands, `migrations/` holds exactly one snapshot concept: the content-addressed store, with every consumer — a migration bookend, a ref, an extension head — reaching it by hash. +The two have folded into one, in this same release (TML-3072): `refs/.json` is now a pure `{ hash, invariants }` pointer, and a ref's contract resolves through this same store instead of through paired sibling files. Every ref-advance path already resolved the contract bytes it would pair with the ref, so writing them into the store instead changed no call site's inputs — only where the bytes land. `migrations/` now holds exactly one snapshot concept: the content-addressed store, with every consumer — a migration bookend, a ref, an extension head — reaching it by hash. ## Alternatives considered diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index 21d04e4fa0..239c8a2707 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -148,7 +148,7 @@ Additive structure is covered by core operations: create table, add nullable col 2. No `--from` — resolve the `db` ref via `migrations/app/refs/db.json`. 3. No `db` ref — resolve `from` to the `null` empty-graph sentinel (greenfield). -The from-contract materialises from the ref's paired snapshot (ref-resolved `from`) or by reading the snapshot store entry for the resolved hash directly (hash-resolved `from` on a graph node) — the store is keyed by hash, so no bundle lookup is needed. +The from-contract always materialises by reading the content-addressed snapshot store entry for the resolved hash — the ref-resolved hash comes from the ref's pointer, the hash-resolved `from` on a graph node from the matching bundle; either way the store is keyed by hash, so no bundle lookup is needed. **Default `to` resolution:** when `--to` is omitted, the destination is the emitted `contract.json`. When `--to ` is supplied, the same [contract-reference grammar](#refs-environment-targets) as `--from` applies (hash / prefix, ref name, migration directory, `^`, or filesystem path); the resolved contract becomes the planner destination and is written into the snapshot store keyed by its storage hash. Use `--to ^` to plan a reverse (rollback) edge toward a predecessor state. @@ -157,7 +157,7 @@ The from-contract materialises from the ref's paired snapshot (ref-resolved `fro | Case | Condition | Output | |---|---|---| | Greenfield | Graph empty, `from` = `null` | One bundle: `null → to_contract` | -| Auto-baseline | Graph empty, `from` non-null, paired snapshot available | Two bundles: baseline `null → from` + delta `from → to_contract` | +| Auto-baseline | Graph empty, `from` non-null, store entry available | Two bundles: baseline `null → from` + delta `from → to_contract` | | Normal delta | Graph non-empty, `from` is a graph node | One bundle: `from → to_contract` | | Forgot-the-flag | Graph non-empty, `from` not a graph node | Refuse: `MIGRATION.HASH_NOT_IN_GRAPH` | | Snapshot missing | `from` non-null, no contract source | Refuse: `MIGRATION.SNAPSHOT_MISSING` | @@ -261,7 +261,7 @@ When path resolution fails, `MIGRATION.PATH_UNREACHABLE` payloads include action ### Opt-in ref advancement -`migrate --advance-ref ` writes the named ref and paired snapshot to the post-apply marker hash after successful apply. There is **no implicit** `db` ref advancement — unlike `db init` / `db update`. Use `--advance-ref db` to advance the dev ref in the same step, or run `db update` afterward to refresh it. +`migrate --advance-ref ` writes the named ref's pointer (and its snapshot store entry, write-if-absent) to the post-apply marker hash after successful apply. There is **no implicit** `db` ref advancement — unlike `db init` / `db update`. Use `--advance-ref db` to advance the dev ref in the same step, or run `db update` afterward to refresh it. ### Advisory Locks and Concurrency @@ -305,9 +305,7 @@ migrations/ migration.json ops.json refs/ - db.json # named ref pointer (see § Refs) - db.contract.json # paired contract snapshot - db.contract.d.ts + db.json # named ref pointer (see § Refs) — resolves through snapshots/ by hash ``` The folder name is human-friendly; identity lives in `migration.json`. The optional `graph.index.json` is a lockfile-style cache and can be regenerated deterministically (see ADR 039). Most teams won't need it with squash-first hygiene (ADR 102). @@ -328,27 +326,21 @@ Migrations form a directed graph (not necessarily acyclic) via their `from` / `t Refs map logical environment names to contract hashes in `migrations//refs/.json` (e.g., `{ "hash": "sha256:...", "invariants": [] }`). They are version-controlled alongside migration artifacts. `migrate --to production` uses the ref hash as the target instead of the current contract. `migration status --to staging` reports state relative to that ref. Refs are managed at the top level via `prisma-next ref set `, `prisma-next ref list`, and `prisma-next ref delete `. See [ADR 169 — On-disk migration persistence](../adrs/ADR%20169%20-%20On-disk%20migration%20persistence.md). -#### Paired contract snapshots +#### Contract resolution through the snapshot store -Each ref may carry a paired contract snapshot alongside its pointer file. See [ADR 218 — Refs with paired contract snapshots and universal graph-node invariant](../adrs/ADR%20218%20-%20Refs%20with%20paired%20contract%20snapshots%20and%20universal%20graph-node%20invariant.md). +A ref is only its pointer file — `{ hash, invariants }`. It carries no contract copy of its own; the contract it names resolves through the shared content-addressed store at `migrations/snapshots//contract.{json,d.ts}` by that hash, the same store every graph node resolves through. See [ADR 218 — Refs with paired contract snapshots and universal graph-node invariant](../adrs/ADR%20218%20-%20Refs%20with%20paired%20contract%20snapshots%20and%20universal%20graph-node%20invariant.md) (its paired-snapshot part is superseded — see the ADR's Status note) and [ADR 240 — Contract snapshots live in a content-addressed store](../adrs/ADR%20240%20-%20Contract%20snapshots%20live%20in%20a%20content-addressed%20store.md). ```text migrations/app/refs/ ├── db.json -├── db.contract.json -├── db.contract.d.ts -├── production.json -├── production.contract.json -└── production.contract.d.ts +└── production.json ``` -- `.json` — pointer: `{ hash, invariants }`. -- `.contract.json` — full contract IR at the ref's hash. -- `.contract.d.ts` — typed import handle (same purpose as the migration snapshot store's `contract.d.ts`, but paired directly with the ref rather than content-addressed). +- `.json` — pointer: `{ hash, invariants }`. This is the entire on-disk footprint of a ref. -**Write rule:** ref writes and deletes go through atomic paired primitives ([`writeRefPaired`](../../../packages/1-framework/3-tooling/migration/src/refs/snapshot.ts), [`deleteRefPaired`](../../../packages/1-framework/3-tooling/migration/src/refs/snapshot.ts)). A ref write always refreshes its snapshot; a ref delete cascades to snapshot files. Orphan-tolerant delete heals partial states (pointer without snapshot, or snapshot without pointer). +**Write rule:** advancing or setting a ref writes the store entry write-if-absent ([`writeContractSnapshot`](../../../packages/1-framework/3-tooling/migration/src/contract-snapshot-store.ts)), then writes the pointer ([`writeRef`](../../../packages/1-framework/3-tooling/migration/src/refs.ts)) — store first, so a crash between the two steps leaves at worst a harmless orphan store entry, never a dangling pointer. `ref delete` removes only the pointer; store entries are shared by hash across every ref and graph node that names it, so delete never touches them. -**Read rule:** when `migration plan` resolves `from` via a ref name, it reads the paired snapshot directly — no fallback to the snapshot store for ref-resolved hashes. +**Read rule:** when `migration plan` resolves `from` via a ref name, it reads the pointer, then reads the store entry keyed by the pointer's hash ([`resolveContractAt`](../../../packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts)). A pointer that names a hash with no store entry is a hard error (`MIGRATION.CONTRACT_SNAPSHOT_MISSING`) — it is never swallowed into the graph-node fallback. When the pointer itself doesn't exist and the hash is a graph node, resolution falls back to the graph-node bundle's store entry instead. The `db` ref records which contract hash the project's dev database has been brought up to. It is a default write target for dev-mode commands, not a reserved name. @@ -528,11 +520,11 @@ The remedy in every case is the same: edit the slots in `migration.ts`, run the Top-level verbs: - `prisma-next migrate --db [--to ] [--advance-ref ]` — execute pending migrations against a live database. Ref advancement is **opt-in only** via `--advance-ref`; plain `migrate` does not advance any ref. The `` argument accepts the full [contract-reference grammar](#refs-environment-targets): hash / prefix, ref name, migration directory name, `^`, or filesystem path. -- `prisma-next db init --db [--advance-ref ]` — bootstrap a database under contract control. When run against the default `--db` URL (no explicit `--db`), implicitly advances the `db` ref and writes its paired snapshot. With `--db `, ref advancement is suppressed unless `--advance-ref` is explicit. +- `prisma-next db init --db [--advance-ref ]` — bootstrap a database under contract control. When run against the default `--db` URL (no explicit `--db`), implicitly advances the `db` ref (write-if-absenting its contract into the snapshot store, then writing the pointer). With `--db `, ref advancement is suppressed unless `--advance-ref` is explicit. - `prisma-next db update --db [--to ] [--advance-ref ]` — reconcile a live database to the named (or emitted) contract via live introspection. Same implicit `db` ref default and `--db` opt-out as `db init`. Off-graph; dev-only. - `prisma-next db sign --db []` (or `--contract `) — sign the marker with a contract the live DB already satisfies. With no argument, signs with the emitted `contract.json`. - `prisma-next db verify --db ` — verify the live DB satisfies the contract (modes: `--schema-only`, `--marker-only`, `--strict`). -- `prisma-next ref set ` / `prisma-next ref list` / `prisma-next ref delete ` — manage named refs at the top level. `ref set` enforces the graph-node invariant and writes a paired snapshot synthesised from the snapshot store entry for the matching bundle's `to` hash. `ref delete` cascades to snapshot files. +- `prisma-next ref set ` / `prisma-next ref list` / `prisma-next ref delete ` — manage named refs at the top level. `ref set` enforces the graph-node invariant and writes the ref's pointer file — the contract already lives in the snapshot store, keyed by the matching bundle's `to` hash. `ref delete` removes only the pointer; the store entry is shared by hash (other refs or graph nodes may reference it) and is never touched. Migration namespace (artifacts and graph): @@ -565,9 +557,9 @@ Each redirect is intercepted during the pre-parse argv scan, exits `2`, and prin ### `--advance-ref` flag family -Three commands accept `--advance-ref ` to write a ref pointer and paired contract snapshot after success: +Three commands accept `--advance-ref ` to write a ref pointer after success (write-if-absenting the matching contract into the snapshot store first, then the pointer): -| Command | Default when flag omitted | Snapshot source | +| Command | Default when flag omitted | Contract source | |---|---|---| | `db init` | `db` (when using default `--db` URL) | Post-command contract IR | | `db update` | `db` (when using default `--db` URL) | Post-command contract IR | @@ -575,7 +567,7 @@ Three commands accept `--advance-ref ` to write a ref pointer and paired c Implicit default logic lives in [`computeRefAdvancementName`](../../../packages/1-framework/3-tooling/cli/src/utils/ref-advancement.ts): omitting both `--advance-ref` and `--db` yields `db`; supplying `--db ` without `--advance-ref` yields no advancement. -`ref set` always writes a paired snapshot (no flag — snapshot synthesis is part of the command contract). +`ref set` always writes the pointer (no flag — this is part of the command contract); the store entry already exists, since `ref set` reads it before writing the pointer. ## Recovery affordances @@ -585,7 +577,7 @@ Structured diagnostics from plan-time and apply-time checks suggest concrete rec | Diagnostic | When | Suggested recovery | |---|---|---| | `MIGRATION.HASH_NOT_IN_GRAPH` | `migration plan` or `ref set`: resolved hash not in graph | `migration plan --from ` (e.g. `--from production`) | -| `MIGRATION.SNAPSHOT_MISSING` | `migration plan`: ref pointer exists but paired snapshot absent | `db update --advance-ref ` to repopulate, or `ref delete ` to clear orphan pointer | +| `MIGRATION.SNAPSHOT_MISSING` | `migration plan`: a named ref has no pointer file, and the hash being resolved isn't a graph node either | `ref set ` to create the ref, `db update --advance-ref ` to advance it, or pass a hash that is a graph node | | `MIGRATION.MARKER_MISMATCH` | `migrate`: live marker hash not a graph node (pre-DDL check) | `migration plan --from `, or `ref set db ` if on-disk graph is canonical | | `MIGRATION.PATH_UNREACHABLE` | `migrate`: no path from marker to target in on-disk graph | Plan the missing edge with `migration plan --from --to --name `, then apply with `migrate --to `. For a rollback, use `--to ^` in both steps — the planned reverse edge applies and moves the marker back without editing contract source. Review destructive (`DROP`) ops in the plan before applying. When the space has no on-disk migrations yet, omit `--from` and use `migration plan --to --name ` first. | @@ -717,7 +709,7 @@ See [ADR 051 — PPg preflight-as-a-service contract](../adrs/ADR%20051%20-%20PP `prisma-next db init` is the **bootstrap** entrypoint for bringing a database under contract control using **additive-only** operations. It plans missing schema objects across every loaded contract space (the application plus each schema-contributing extension), applies them, then verifies the post-state and writes one marker row per space. -When run against the project's default dev database URL (no explicit `--db`), `db init` implicitly advances the `db` ref and writes its paired contract snapshot via [`buildRefAdvancementFields`](../../../packages/1-framework/3-tooling/cli/src/utils/ref-advancement.ts) in apply mode. Override with `--advance-ref `. With `--db `, ref advancement is suppressed unless `--advance-ref` is explicit — reconciling a different database is not checkpointing this project's dev state. +When run against the project's default dev database URL (no explicit `--db`), `db init` implicitly advances the `db` ref via [`buildRefAdvancementFields`](../../../packages/1-framework/3-tooling/cli/src/utils/ref-advancement.ts) in apply mode: it write-if-absents the post-command contract into the snapshot store, then writes the ref's pointer. Override with `--advance-ref `. With `--db `, ref advancement is suppressed unless `--advance-ref` is explicit — reconciling a different database is not checkpointing this project's dev state. Both `db init` and `db update` are per-space applications of [ADR 208 — Invariant-aware migration routing](../adrs/ADR%20208%20-%20Invariant-aware%20migration%20routing.md)'s `findPathWithDecision` primitive, fanned out across loaded spaces and concatenated in the cross-space ordering convention (extensions alphabetical first, app-space last): @@ -741,7 +733,7 @@ Greenfield, brownfield-conservative, and brownfield-incremental paths are suppor `prisma-next db update` is the **reconciliation** entrypoint. It introspects the live database schema, diffs it against the destination contract, and applies the resulting operations. Same per-space `findPathWithDecision` recipe as `db init`, parameterised for "advance current marker → headRef.hash" semantics; the introspected schema is pruned of extension-owned tables before app-space planning so the planner doesn't emit `DROP TABLE` ops against extension-owned objects (see [Disjoint per-space ownership: schema-prune](#disjoint-per-space-ownership-schema-prune)). -When run against the default dev database URL, `db update` implicitly advances the `db` ref and refreshes its paired snapshot — this is the primary mechanism for recording "the contract hash this dev database has been brought up to" on disk for offline planning. Same `--advance-ref` override and `--db ` opt-out as `db init`. See [ADR 218](../adrs/ADR%20218%20-%20Refs%20with%20paired%20contract%20snapshots%20and%20universal%20graph-node%20invariant.md). +When run against the default dev database URL, `db update` implicitly advances the `db` ref — this is the primary mechanism for recording "the contract hash this dev database has been brought up to" on disk for offline planning. Same `--advance-ref` override and `--db ` opt-out as `db init`. See [ADR 218](../adrs/ADR%20218%20-%20Refs%20with%20paired%20contract%20snapshots%20and%20universal%20graph-node%20invariant.md) (superseded in part — see its Status note) and [ADR 240](../adrs/ADR%20240%20-%20Contract%20snapshots%20live%20in%20a%20content-addressed%20store.md). Unlike `migrate`, which replays serialized edges, `db update` always works from a live introspection. Plans produced by the planner carry `origin: null` (no prior-state assertion). The runner treats this as "trust the planner": operations are always applied regardless of marker state. This ensures `db update` can recover from schema drift — for example, when a DBA manually drops a column, the planner detects the missing column and re-adds it even though the contract marker already matches the destination hash. @@ -778,7 +770,7 @@ Errors use the stable category/code envelope (see [ADR 027 — Error Envelope & - `MIGRATION.SAME_SOURCE_AND_TARGET` — migration edge has `from === to` (graph invariant violation) - `MIGRATION.AMBIGUOUS_TARGET` — multiple branch tips in migration graph (diverged branches) - `MIGRATION.HASH_NOT_IN_GRAPH` — resolved hash is not a node in the on-disk graph (plan-time / `ref set`) -- `MIGRATION.SNAPSHOT_MISSING` — ref pointer exists but paired contract snapshot is absent (plan-time) +- `MIGRATION.SNAPSHOT_MISSING` — a named ref has no pointer file, and the hash being resolved isn't a graph node either (plan-time) - `MIGRATION.MARKER_MISMATCH` — live DB marker hash is not a graph node (apply-time, pre-DDL) - `MIGRATION.PATH_UNREACHABLE` — no migration path from marker to target (apply-time; improved `fix` payload) diff --git a/docs/glossary.md b/docs/glossary.md index a2426eb2b9..5906ec06f9 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -227,7 +227,7 @@ The directed graph of contracts (nodes) connected by migrations (edges). Built b ### Ref (Contract Ref) -A named pointer at a contract, stored as `migrations//refs/.json`, optionally with a paired contract snapshot (`.contract.json`). Refs are environment-named (`production`, `staging`) and describe *where CD will `migrate --to` next* in that environment. The default `db` ref records dev-database checkpoint state for offline planning. Managed via `prisma-next ref set|list|delete`. See [ADR 218 — Refs with paired contract snapshots](architecture%20docs/adrs/ADR%20218%20-%20Refs%20with%20paired%20contract%20snapshots%20and%20universal%20graph-node%20invariant.md). +A named pointer at a contract, stored as `migrations//refs/.json` (`{hash, invariants}`); the contract it names resolves through the shared content-addressed store at `migrations/snapshots//contract.json` by that hash. Refs are environment-named (`production`, `staging`) and describe *where CD will `migrate --to` next* in that environment. The default `db` ref records dev-database checkpoint state for offline planning. Managed via `prisma-next ref set|list|delete`. See [ADR 218 — Refs with paired contract snapshots](architecture%20docs/adrs/ADR%20218%20-%20Refs%20with%20paired%20contract%20snapshots%20and%20universal%20graph-node%20invariant.md) (paired-snapshot part superseded — see its Status note) and [ADR 240 — Contract snapshots live in a content-addressed store](architecture%20docs/adrs/ADR%20240%20-%20Contract%20snapshots%20live%20in%20a%20content-addressed%20store.md). A ref is a specific kind of [contract reference](#contract-reference) — the named, file-backed, persistent kind. diff --git a/packages/1-framework/3-tooling/cli/src/commands/db-init.ts b/packages/1-framework/3-tooling/cli/src/commands/db-init.ts index 2b413bcc9f..4fd306fbdc 100644 --- a/packages/1-framework/3-tooling/cli/src/commands/db-init.ts +++ b/packages/1-framework/3-tooling/cli/src/commands/db-init.ts @@ -176,6 +176,7 @@ async function executeDbInitCommand( ...ifDefined('advanceRef', options.advanceRef), ...ifDefined('db', options.db), refsDir, + migrationsDir, contractIR, mode: result.value.mode, hash: advancementHash, diff --git a/packages/1-framework/3-tooling/cli/src/commands/db-update.ts b/packages/1-framework/3-tooling/cli/src/commands/db-update.ts index 58399214ef..e207a59e02 100644 --- a/packages/1-framework/3-tooling/cli/src/commands/db-update.ts +++ b/packages/1-framework/3-tooling/cli/src/commands/db-update.ts @@ -200,6 +200,7 @@ async function executeDbUpdateCommand( ...ifDefined('advanceRef', options.advanceRef), ...ifDefined('db', options.db), refsDir, + migrationsDir, contractIR, mode: result.value.mode, hash: advancementHash, diff --git a/packages/1-framework/3-tooling/cli/src/commands/migrate.ts b/packages/1-framework/3-tooling/cli/src/commands/migrate.ts index f91639a9c1..0e217ffb4c 100644 --- a/packages/1-framework/3-tooling/cli/src/commands/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/commands/migrate.ts @@ -831,6 +831,7 @@ async function executeMigrateCommand( : await readContractIR(snapshotContractJson, contractPathAbsolute); advancedRef = await executeRefAdvancement( refsDir, + migrationsDir, options.advanceRef, value.markerHash, contractIR, diff --git a/packages/1-framework/3-tooling/cli/src/commands/migration-plan.ts b/packages/1-framework/3-tooling/cli/src/commands/migration-plan.ts index 06dac5caab..845eb7191d 100644 --- a/packages/1-framework/3-tooling/cli/src/commands/migration-plan.ts +++ b/packages/1-framework/3-tooling/cli/src/commands/migration-plan.ts @@ -331,7 +331,7 @@ async function executeMigrationPlanCommand( fromHash = resolutionResult.value.fromHash; fromContract = resolutionResult.value.fromContract; break; - case 'snapshot': + case 'ref': fromHash = resolutionResult.value.fromHash; fromContract = resolutionResult.value.fromContract; snapshotStartContract = { diff --git a/packages/1-framework/3-tooling/cli/src/commands/ref.ts b/packages/1-framework/3-tooling/cli/src/commands/ref.ts index 1447b5bc72..68cb7b8c69 100644 --- a/packages/1-framework/3-tooling/cli/src/commands/ref.ts +++ b/packages/1-framework/3-tooling/cli/src/commands/ref.ts @@ -9,11 +9,11 @@ import { findLatestMigration, isGraphNode } from '@prisma-next/migration-tools/m import { parseContractRef } from '@prisma-next/migration-tools/ref-resolution'; import type { RefEntry } from '@prisma-next/migration-tools/refs'; import { - deleteRefPaired, + deleteRef, readRefs, validateRefName, validateRefValue, - writeRefPaired, + writeRef, } from '@prisma-next/migration-tools/refs'; import { notOk, ok, type Result } from '@prisma-next/utils/result'; import { Command } from 'commander'; @@ -37,7 +37,6 @@ import { import { buildReadAggregate } from '../utils/contract-space-aggregate-loader'; import { formatCommandHelp } from '../utils/formatters/help'; import { parseGlobalFlags, parseGlobalFlagsOrExit } from '../utils/global-flags'; -import { readContractIR } from '../utils/ref-advancement'; import { handleResult } from '../utils/result-handler'; import { createTerminalUI } from '../utils/terminal-ui'; @@ -121,12 +120,8 @@ export async function executeRefSetCommand( contractSnapshotDir(migrationsDir, resolvedHash), 'contract.json', ); - let contractJson: Record; try { - contractJson = (await readContractSnapshotJson(migrationsDir, resolvedHash)) as Record< - string, - unknown - >; + await readContractSnapshotJson(migrationsDir, resolvedHash); } catch (readError) { if ( MigrationToolsError.is(readError) && @@ -142,9 +137,8 @@ export async function executeRefSetCommand( throw readError; } - const contractIR = await readContractIR(contractJson, contractJsonPath); const entry: RefEntry = { hash: resolvedHash, invariants: [] }; - await writeRefPaired(refsDir, name, entry, contractIR); + await writeRef(refsDir, name, entry); return ok({ ok: true as const, ref: name, hash: resolvedHash, invariants: [] }); } catch (error) { if (error instanceof CliStructuredError) return notOk(error); @@ -159,7 +153,7 @@ export async function executeRefDeleteCommand( try { const config = await loadConfig(options.config); const { refsDir } = resolveMigrationPaths(options.config, config); - await deleteRefPaired(refsDir, name); + await deleteRef(refsDir, name); return ok({ ok: true as const, ref: name, deleted: true as const }); } catch (error) { if (error instanceof CliStructuredError) return notOk(error); diff --git a/packages/1-framework/3-tooling/cli/src/utils/cli-errors.ts b/packages/1-framework/3-tooling/cli/src/utils/cli-errors.ts index 97d30a24bb..d56ba6a0d6 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/cli-errors.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/cli-errors.ts @@ -215,22 +215,30 @@ export function errorPlanForgotTheFlag( }); } +/** + * `viaRef: true` (the default) mirrors migration-tools' `errorRefNotResolvable`: + * a ref name with no pointer file, where the fallback hash isn't a graph + * node either — there's nothing to materialize a contract from. + * `viaRef: false` is a distinct, ref-independent case: an explicit `--from + * ` that doesn't name a ref, on an empty migration graph, so there is + * no graph node and no ref to resolve a contract through. + */ export function errorSnapshotMissing( identifier: string, options?: { readonly viaRef?: boolean }, ): CliStructuredError { const viaRef = options?.viaRef !== false; const fix = viaRef - ? `Run "prisma-next db update --advance-ref ${identifier}" to repopulate the snapshot, or "prisma-next ref delete ${identifier}" to clear the orphan pointer.` - : `No contract source exists for hash "${identifier}" on an empty migration graph. Use --from with a ref name that has a paired snapshot, or run db update first.`; + ? `Create the ref with "prisma-next ref set ${identifier} " (or advance it via "prisma-next db update --advance-ref ${identifier}"), or pass a hash that is a node in the migration graph.` + : `No contract source exists for hash "${identifier}" on an empty migration graph. Use --from with a ref name (its contract resolves through the snapshot store), or run db update first.`; return errorRuntime( viaRef - ? `Ref "${identifier}" has no paired contract snapshot` + ? `Ref "${identifier}" is not resolvable` : `No contract source for from-hash "${identifier}"`, { why: viaRef - ? `Ref "${identifier}" exists but its paired snapshot files are missing.` - : `Hash "${identifier}" is not a graph node and no paired ref snapshot supplies a contract.`, + ? `Ref "${identifier}" has no pointer file, and the hash being resolved is not a node in the migration graph either.` + : `Hash "${identifier}" is not a node in the migration graph (the graph is empty), and it does not name a ref either.`, fix, meta: { code: 'MIGRATION.SNAPSHOT_MISSING', diff --git a/packages/1-framework/3-tooling/cli/src/utils/contract-at-errors.ts b/packages/1-framework/3-tooling/cli/src/utils/contract-at-errors.ts index 4360bb2fcd..b5091e422a 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/contract-at-errors.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/contract-at-errors.ts @@ -15,7 +15,7 @@ export function mapContractAtError( ): Result { if (MigrationToolsError.is(error)) { switch (error.code) { - case 'MIGRATION.SNAPSHOT_MISSING': { + case 'MIGRATION.REF_NOT_RESOLVABLE': { const refName = typeof error.details?.['refName'] === 'string' ? error.details['refName'] @@ -26,18 +26,13 @@ export function mapContractAtError( } case 'MIGRATION.CONTRACT_DESERIALIZATION_FAILED': { const filePath = - typeof error.details?.['filePath'] === 'string' - ? error.details['filePath'] - : 'ref-snapshot'; + typeof error.details?.['filePath'] === 'string' ? error.details['filePath'] : 'unknown'; const message = typeof error.details?.['message'] === 'string' ? error.details['message'] : error.message; - const isRefSnapshot = filePath.endsWith('.contract.json'); return notOk( errorContractValidationFailed( - isRefSnapshot - ? `Ref snapshot contract failed to deserialize: ${message}` - : `Predecessor contract at ${filePath} failed to deserialize: ${message}`, - { where: { path: isRefSnapshot ? 'ref-snapshot' : filePath } }, + `Predecessor contract at ${filePath} failed to deserialize: ${message}`, + { where: { path: filePath } }, ), ); } diff --git a/packages/1-framework/3-tooling/cli/src/utils/plan-resolution.ts b/packages/1-framework/3-tooling/cli/src/utils/plan-resolution.ts index 7c66de3234..000df3fdc3 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/plan-resolution.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/plan-resolution.ts @@ -29,7 +29,7 @@ export type FromResolution = | { kind: 'greenfield'; fromHash: null; fromContract: null } | { kind: 'graph-node'; fromHash: string; fromContract: Contract } | { - kind: 'snapshot'; + kind: 'ref'; fromHash: string; fromContract: Contract; contractDts: string; @@ -81,7 +81,7 @@ export function assertFromIsGraphNode( type RefContractResolution = | { - kind: 'snapshot'; + kind: 'ref'; hash: string; contract: Contract; contractJson: unknown; @@ -106,9 +106,9 @@ async function resolveContractRef( try { const at = await space.contractAt(hash, refName !== undefined ? { refName } : undefined); - if (at.provenance === 'snapshot') { + if (at.provenance === 'ref') { return ok({ - kind: 'snapshot', + kind: 'ref', hash: at.hash, contract: at.contract, contractJson: at.contractJson, @@ -175,7 +175,7 @@ async function resolveFromPolicy( throw error; } return ok({ - kind: 'snapshot', + kind: 'ref', fromHash: hash, fromContract: contract, contractDts, diff --git a/packages/1-framework/3-tooling/cli/src/utils/ref-advancement.ts b/packages/1-framework/3-tooling/cli/src/utils/ref-advancement.ts index aa3e75bb6c..30328f99a1 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/ref-advancement.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/ref-advancement.ts @@ -1,8 +1,14 @@ import { readFile } from 'node:fs/promises'; -import type { ContractIR } from '@prisma-next/migration-tools/refs'; -import { writeRefPaired } from '@prisma-next/migration-tools/refs'; +import { writeContractSnapshot } from '@prisma-next/migration-tools/contract-snapshot-store'; +import { errorInvalidRefName } from '@prisma-next/migration-tools/errors'; +import { validateRefName, writeRef } from '@prisma-next/migration-tools/refs'; import { ifDefined } from '@prisma-next/utils/defined'; +export interface ContractIR { + readonly contract: unknown; + readonly contractDts: string; +} + export interface RefAdvancementFields { readonly advancedRef: { readonly name: string; readonly hash: string } | null; readonly plannedAdvanceRef: { readonly name: string; readonly hash: string } | null; @@ -32,11 +38,22 @@ export async function readContractIR( export async function executeRefAdvancement( refsDir: string, + migrationsDir: string, name: string, hash: string, contractIR: ContractIR, ): Promise<{ name: string; hash: string }> { - await writeRefPaired(refsDir, name, { hash, invariants: [] }, contractIR); + // Validate the ref name before writing anything: writeRef validates it too, + // but only after the store write below, which would otherwise leave a + // (harmless, but pointless) orphan store entry on an invalid name. + if (!validateRefName(name)) { + throw errorInvalidRefName(name); + } + await writeContractSnapshot(migrationsDir, hash, { + contractJson: contractIR.contract, + contractDts: contractIR.contractDts, + }); + await writeRef(refsDir, name, { hash, invariants: [] }); return { name, hash }; } @@ -44,6 +61,7 @@ export async function buildRefAdvancementFields(options: { readonly advanceRef?: string; readonly db?: string; readonly refsDir: string; + readonly migrationsDir: string; readonly contractIR: ContractIR; readonly mode: 'plan' | 'apply'; readonly hash: string; @@ -60,6 +78,7 @@ export async function buildRefAdvancementFields(options: { } const advancedRef = await executeRefAdvancement( options.refsDir, + options.migrationsDir, name, options.hash, options.contractIR, diff --git a/packages/1-framework/3-tooling/cli/test/commands/migration-plan-command.test.ts b/packages/1-framework/3-tooling/cli/test/commands/migration-plan-command.test.ts index 3d74922bcc..f5cd15bdf2 100644 --- a/packages/1-framework/3-tooling/cli/test/commands/migration-plan-command.test.ts +++ b/packages/1-framework/3-tooling/cli/test/commands/migration-plan-command.test.ts @@ -28,7 +28,6 @@ const mocks = vi.hoisted(() => ({ mkdir: vi.fn(), writeFile: vi.fn(), readRefs: vi.fn(), - readRefSnapshot: vi.fn(), writeMigrationPackage: vi.fn(), writeContractSnapshot: vi.fn(), writeMigrationTs: vi.fn(), @@ -67,7 +66,7 @@ vi.mock('@prisma-next/migration-tools/refs', async () => { const actual = await vi.importActual( '@prisma-next/migration-tools/refs', ); - return { ...actual, readRefs: mocks.readRefs, readRefSnapshot: mocks.readRefSnapshot }; + return { ...actual, readRefs: mocks.readRefs }; }); vi.mock('@prisma-next/migration-tools/io', async () => { @@ -150,7 +149,7 @@ function buildResolutionSpace( contract: snap.contract as Contract, contractJson: snap.contract, contractDts: snap.contractDts, - provenance: 'snapshot', + provenance: 'ref', }; } @@ -678,7 +677,7 @@ describe('migration plan command', () => { expect(result).not.toHaveProperty('migrationHash'); // With no --from flag, resolution goes through the named 'db' ref - // (snapshot provenance), which carries its own contract snapshot — + // (ref provenance), which carries its own contract snapshot — // migration plan writes both the destination and that start snapshot. expect(mocks.writeContractSnapshot).toHaveBeenCalledTimes(2); expect(mocks.writeContractSnapshot).toHaveBeenCalledWith( diff --git a/packages/1-framework/3-tooling/cli/test/commands/read-commands-json-golden.test.ts b/packages/1-framework/3-tooling/cli/test/commands/read-commands-json-golden.test.ts index 0093d639a9..8708576587 100644 --- a/packages/1-framework/3-tooling/cli/test/commands/read-commands-json-golden.test.ts +++ b/packages/1-framework/3-tooling/cli/test/commands/read-commands-json-golden.test.ts @@ -25,7 +25,7 @@ import { createTerminalUI } from '../../src/utils/terminal-ui'; import { executeCommand, setupCommandMocks } from '../utils/test-helpers'; const mocks = vi.hoisted(() => ({ - writeRefPaired: vi.fn(), + writeRef: vi.fn(), readMarker: vi.fn(), readLedger: vi.fn(), connect: vi.fn(), @@ -38,7 +38,7 @@ vi.mock('@prisma-next/config-loader', { spy: true }); vi.mock('@prisma-next/migration-tools/refs', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, writeRefPaired: mocks.writeRefPaired }; + return { ...actual, writeRef: mocks.writeRef }; }); vi.mock('../../src/control-api/client', () => ({ @@ -189,7 +189,7 @@ describe('read commands --json golden', () => { vi.clearAllMocks(); mocks.connect.mockResolvedValue(undefined); mocks.close.mockResolvedValue(undefined); - mocks.writeRefPaired.mockResolvedValue(undefined); + mocks.writeRef.mockResolvedValue(undefined); mocks.schemaVerify.mockResolvedValue({ ok: true, summary: 'Schema matches contract' }); mocks.sign.mockResolvedValue({ ok: true, diff --git a/packages/1-framework/3-tooling/cli/test/commands/ref.test.ts b/packages/1-framework/3-tooling/cli/test/commands/ref.test.ts index e3e47a9abe..cc6a9ef2ef 100644 --- a/packages/1-framework/3-tooling/cli/test/commands/ref.test.ts +++ b/packages/1-framework/3-tooling/cli/test/commands/ref.test.ts @@ -1,21 +1,23 @@ import { existsSync } from 'node:fs'; -import { mkdir, readFile, rm, unlink, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { MigrationPlanOperation } from '@prisma-next/framework-components/control'; import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants'; -import { writeContractSnapshot } from '@prisma-next/migration-tools/contract-snapshot-store'; +import { + contractSnapshotDir, + writeContractSnapshot, +} from '@prisma-next/migration-tools/contract-snapshot-store'; import { computeMigrationHash } from '@prisma-next/migration-tools/hash'; import { formatMigrationDirName, writeMigrationPackage } from '@prisma-next/migration-tools/io'; import type { MigrationMetadata } from '@prisma-next/migration-tools/metadata'; -import type { ContractIR } from '@prisma-next/migration-tools/refs'; import { writeRef } from '@prisma-next/migration-tools/refs'; import { timeouts } from '@prisma-next/test-utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ loadConfig: vi.fn(), - writeRefPaired: vi.fn(), + writeRef: vi.fn(), })); vi.mock('@prisma-next/config-loader', () => ({ @@ -26,7 +28,7 @@ vi.mock('@prisma-next/migration-tools/refs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - writeRefPaired: mocks.writeRefPaired, + writeRef: mocks.writeRef, }; }); @@ -44,37 +46,33 @@ function createTableOp(table: string): MigrationPlanOperation { }; } -function contractIRForHash(storageHash: string): ContractIR { +function contractJsonForHash(storageHash: string): unknown { return { - contract: { - schemaVersion: '1', - targetFamily: 'sql', - target: 'postgres', - profileHash: PROFILE_HASH, - storage: { storageHash }, - models: { - User: { - fields: { - id: { - nullable: false, - type: { kind: 'scalar', codecId: 'sql/int4@1' }, - }, + schemaVersion: '1', + targetFamily: 'sql', + target: 'postgres', + profileHash: PROFILE_HASH, + storage: { storageHash }, + models: { + User: { + fields: { + id: { + nullable: false, + type: { kind: 'scalar', codecId: 'sql/int4@1' }, }, - relations: {}, - storage: { namespaceId: '__unbound__', table: 'users', namespace: 'public' }, }, + relations: {}, + storage: { namespaceId: '__unbound__', table: 'users', namespace: 'public' }, }, - roots: {}, }, - contractDts: 'export type Contract = unknown;\n', + roots: {}, }; } async function writeEndContract(migrationsRootDir: string, storageHash: string): Promise { - const ir = contractIRForHash(storageHash); await writeContractSnapshot(migrationsRootDir, storageHash, { - contractJson: ir.contract, - contractDts: ir.contractDts, + contractJson: contractJsonForHash(storageHash), + contractDts: 'export type Contract = unknown;\n', }); } @@ -111,15 +109,11 @@ function refPointerPath(refsDir: string, name: string): string { return join(refsDir, `${name}.json`); } -function snapshotJsonPath(refsDir: string, name: string): string { - return join(refsDir, `${name}.contract.json`); +function storeContractJsonPath(migrationsRootDir: string, storageHash: string): string { + return join(contractSnapshotDir(migrationsRootDir, storageHash), 'contract.json'); } -function snapshotDtsPath(refsDir: string, name: string): string { - return join(refsDir, `${name}.contract.d.ts`); -} - -describe('ref commands snapshot integration', { timeout: timeouts.databaseOperation }, () => { +describe('ref commands', { timeout: timeouts.databaseOperation }, () => { let tempDir: string; let configPath: string; let migrationsRootDir: string; @@ -128,11 +122,11 @@ describe('ref commands snapshot integration', { timeout: timeouts.databaseOperat beforeEach(async () => { mocks.loadConfig.mockReset(); - mocks.writeRefPaired.mockReset(); - const { writeRefPaired: realWriteRefPaired } = await vi.importActual< + mocks.writeRef.mockReset(); + const { writeRef: realWriteRef } = await vi.importActual< typeof import('@prisma-next/migration-tools/refs') >('@prisma-next/migration-tools/refs'); - mocks.writeRefPaired.mockImplementation(realWriteRefPaired); + mocks.writeRef.mockImplementation(realWriteRef); tempDir = join(tmpdir(), `ref-cmd-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); migrationsRootDir = join(tempDir, 'migrations'); @@ -209,7 +203,7 @@ describe('ref commands snapshot integration', { timeout: timeouts.databaseOperat }; } - it('sets a ref to a graph-node hash with paired snapshot files', async () => { + it('sets a ref to a graph-node hash, writing only the pointer', async () => { const { hashB } = await seedLinearGraph(); const prev = process.cwd(); process.chdir(tempDir); @@ -220,8 +214,7 @@ describe('ref commands snapshot integration', { timeout: timeouts.databaseOperat if (!result.ok) return; expect(result.value.hash).toBe(hashB); expect(existsSync(refPointerPath(refsDir, 'staging'))).toBe(true); - expect(existsSync(snapshotJsonPath(refsDir, 'staging'))).toBe(true); - expect(existsSync(snapshotDtsPath(refsDir, 'staging'))).toBe(true); + expect(existsSync(storeContractJsonPath(migrationsRootDir, hashB))).toBe(true); } finally { process.chdir(prev); } @@ -282,7 +275,7 @@ describe('ref commands snapshot integration', { timeout: timeouts.databaseOperat } }); - it('resolves another ref name and writes the paired snapshot', async () => { + it('resolves another ref name and writes only the pointer', async () => { const { hashC } = await seedLinearGraph(); await writeRef(refsDir, 'production', { hash: hashC, invariants: [] }); const prev = process.cwd(); @@ -293,7 +286,7 @@ describe('ref commands snapshot integration', { timeout: timeouts.databaseOperat expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.hash).toBe(hashC); - expect(existsSync(snapshotJsonPath(refsDir, 'staging'))).toBe(true); + expect(existsSync(refPointerPath(refsDir, 'staging'))).toBe(true); } finally { process.chdir(prev); } @@ -344,7 +337,7 @@ describe('ref commands snapshot integration', { timeout: timeouts.databaseOperat } }); - it('overwrites an existing ref atomically via writeRefPaired', async () => { + it('overwrites an existing ref pointer', async () => { const { hashA, hashB } = await seedLinearGraph(); const prev = process.cwd(); process.chdir(tempDir); @@ -357,7 +350,6 @@ describe('ref commands snapshot integration', { timeout: timeouts.databaseOperat if (!second.ok) return; const pointer = JSON.parse(await readFile(refPointerPath(refsDir, 'staging'), 'utf-8')); expect(pointer.hash).toBe(hashB); - expect(existsSync(snapshotJsonPath(refsDir, 'staging'))).toBe(true); } finally { process.chdir(prev); } @@ -385,9 +377,9 @@ describe('ref commands snapshot integration', { timeout: timeouts.databaseOperat } }); - it('cleans up when writeRefPaired fails mid-write', async () => { + it('does not write a pointer when the pointer write fails', async () => { const { hashA } = await seedLinearGraph(); - mocks.writeRefPaired.mockRejectedValueOnce(new Error('simulated writeRefPaired failure')); + mocks.writeRef.mockRejectedValueOnce(new Error('simulated writeRef failure')); const prev = process.cwd(); process.chdir(tempDir); try { @@ -395,13 +387,12 @@ describe('ref commands snapshot integration', { timeout: timeouts.databaseOperat const result = await executeRefSetCommand('staging', hashA, { config: configPath }); expect(result.ok).toBe(false); expect(existsSync(refPointerPath(refsDir, 'staging'))).toBe(false); - expect(existsSync(snapshotJsonPath(refsDir, 'staging'))).toBe(false); } finally { process.chdir(prev); } }); - it('deletes pointer and paired snapshot files', async () => { + it('deletes only the pointer, leaving the store entry', async () => { const { hashA } = await seedLinearGraph(); const prev = process.cwd(); process.chdir(tempDir); @@ -413,8 +404,7 @@ describe('ref commands snapshot integration', { timeout: timeouts.databaseOperat const result = await executeRefDeleteCommand('staging', { config: configPath }); expect(result.ok).toBe(true); expect(existsSync(refPointerPath(refsDir, 'staging'))).toBe(false); - expect(existsSync(snapshotJsonPath(refsDir, 'staging'))).toBe(false); - expect(existsSync(snapshotDtsPath(refsDir, 'staging'))).toBe(false); + expect(existsSync(storeContractJsonPath(migrationsRootDir, hashA))).toBe(true); } finally { process.chdir(prev); } @@ -436,44 +426,7 @@ describe('ref commands snapshot integration', { timeout: timeouts.databaseOperat } }); - it('heals an orphan snapshot when the pointer is missing', async () => { - const { hashA } = await seedLinearGraph(); - const prev = process.cwd(); - process.chdir(tempDir); - try { - const { executeRefSetCommand, executeRefDeleteCommand } = await import( - '../../src/commands/ref' - ); - await executeRefSetCommand('staging', hashA, { config: configPath }); - await unlink(refPointerPath(refsDir, 'staging')); - const result = await executeRefDeleteCommand('staging', { config: configPath }); - expect(result.ok).toBe(true); - expect(existsSync(snapshotJsonPath(refsDir, 'staging'))).toBe(false); - } finally { - process.chdir(prev); - } - }); - - it('deletes the pointer when the snapshot is missing', async () => { - const { hashA } = await seedLinearGraph(); - const prev = process.cwd(); - process.chdir(tempDir); - try { - const { executeRefSetCommand, executeRefDeleteCommand } = await import( - '../../src/commands/ref' - ); - await executeRefSetCommand('staging', hashA, { config: configPath }); - await unlink(snapshotJsonPath(refsDir, 'staging')); - await unlink(snapshotDtsPath(refsDir, 'staging')); - const result = await executeRefDeleteCommand('staging', { config: configPath }); - expect(result.ok).toBe(true); - expect(existsSync(refPointerPath(refsDir, 'staging'))).toBe(false); - } finally { - process.chdir(prev); - } - }); - - it('refuses delete when neither pointer nor snapshot exists', async () => { + it('refuses delete for an unknown ref', async () => { const prev = process.cwd(); process.chdir(tempDir); try { @@ -499,7 +452,7 @@ describe('ref commands snapshot integration', { timeout: timeouts.databaseOperat } }); - it('lists only pointer refs when paired snapshot files exist', async () => { + it('lists refs by pointer file', async () => { const { hashA, hashB } = await seedLinearGraph(); const prev = process.cwd(); process.chdir(tempDir); diff --git a/packages/1-framework/3-tooling/cli/test/utils/plan-resolution.test.ts b/packages/1-framework/3-tooling/cli/test/utils/plan-resolution.test.ts index 20f030c6cb..3dd4c2adc0 100644 --- a/packages/1-framework/3-tooling/cli/test/utils/plan-resolution.test.ts +++ b/packages/1-framework/3-tooling/cli/test/utils/plan-resolution.test.ts @@ -8,7 +8,7 @@ import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants'; import { MigrationToolsError } from '@prisma-next/migration-tools/errors'; import { reconstructGraph } from '@prisma-next/migration-tools/migration-graph'; import type { OnDiskMigrationPackage } from '@prisma-next/migration-tools/package'; -import type { ContractIR, Refs } from '@prisma-next/migration-tools/refs'; +import type { Refs } from '@prisma-next/migration-tools/refs'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -19,6 +19,7 @@ import { resolveFromForPlan, resolveToForPlan, } from '../../src/utils/plan-resolution'; +import type { ContractIR } from '../../src/utils/ref-advancement'; const E = EMPTY_CONTRACT_HASH; const HASH_A = `sha256:${'a'.repeat(64)}`; @@ -60,16 +61,16 @@ function sampleContractIR(storageHash: string): ContractIR { function contractAtResult( storageHash: string, - opts?: { readonly provenance?: 'snapshot' | 'graph-node' }, + opts?: { readonly provenance?: 'ref' | 'graph-node' }, ): { hash: string; contract: Contract; contractJson: unknown; contractDts: string; - provenance: 'snapshot' | 'graph-node'; + provenance: 'ref' | 'graph-node'; } { const ir = sampleContractIR(storageHash); - const provenance = opts?.provenance ?? 'snapshot'; + const provenance = opts?.provenance ?? 'ref'; return { hash: storageHash, contract: ir.contract as Contract, @@ -134,7 +135,7 @@ describe('resolveFromForPlan', () => { } }); - it('returns auto-baseline when graph is empty and db ref has a paired snapshot', async () => { + it('returns auto-baseline when graph is empty and the db ref resolves through the store', async () => { const space = makeSpace( [], { db: { hash: HASH_ORPHAN, invariants: [] } }, @@ -167,7 +168,7 @@ describe('resolveFromForPlan', () => { } }); - it('returns snapshot for db ref at graph tip with paired snapshot', async () => { + it('returns ref-provenance for db ref at graph tip', async () => { const bundles = [makePkg(E, HASH_A, 'm1'), makePkg(HASH_A, HASH_B, 'm2')]; const space = makeSpace( bundles, @@ -178,11 +179,11 @@ describe('resolveFromForPlan', () => { expect(result.ok).toBe(true); if (result.ok) { - expect(result.value).toMatchObject({ kind: 'snapshot', fromHash: HASH_B }); + expect(result.value).toMatchObject({ kind: 'ref', fromHash: HASH_B }); } }); - it('returns snapshot for db ref at a non-tip graph node with paired snapshot', async () => { + it('returns ref-provenance for db ref at a non-tip graph node', async () => { const bundles = [makePkg(E, HASH_A, 'm1'), makePkg(HASH_A, HASH_B, 'm2')]; const space = makeSpace( bundles, @@ -193,7 +194,7 @@ describe('resolveFromForPlan', () => { expect(result.ok).toBe(true); if (result.ok) { - expect(result.value).toMatchObject({ kind: 'snapshot', fromHash: HASH_A }); + expect(result.value).toMatchObject({ kind: 'ref', fromHash: HASH_A }); } }); @@ -271,20 +272,16 @@ describe('resolveFromForPlan', () => { expect(contractAt).toHaveBeenCalledWith(HASH_A, undefined); }); - it('refuses snapshot-missing for legacy db ref without snapshot when hash is not a graph node', async () => { + it('refuses ref-not-resolvable for db ref whose pointer is absent and hash is not a graph node', async () => { const space = makeSpace( [], { db: { hash: HASH_ORPHAN, invariants: [] } }, vi.fn().mockRejectedValue( - new MigrationToolsError( - 'MIGRATION.SNAPSHOT_MISSING', - `Ref "db" has no paired contract snapshot`, - { - why: 'Ref "db" exists but its paired snapshot files are missing.', - fix: 'Run "prisma-next db update --advance-ref db" to repopulate the snapshot, or "prisma-next ref delete db" to clear the orphan pointer.', - details: { refName: 'db' }, - }, - ), + new MigrationToolsError('MIGRATION.REF_NOT_RESOLVABLE', `Ref "db" is not resolvable`, { + why: 'Ref "db" has no pointer file, and the hash being resolved is not a node in the migration graph either.', + fix: 'Create the ref with "prisma-next ref set db " (or advance it via "prisma-next db update --advance-ref db"), or pass a hash that is a node in the migration graph.', + details: { refName: 'db' }, + }), ), ); const result = await resolveFromForPlan(baseInput({ space })); @@ -292,11 +289,11 @@ describe('resolveFromForPlan', () => { expect(result.ok).toBe(false); if (!result.ok) { expectRefuse(result.failure, 'MIGRATION.SNAPSHOT_MISSING', 'db update --advance-ref db'); - expect(result.failure.fix).toContain('ref delete db'); + expect(result.failure.fix).toContain('ref set db'); } }); - it('falls back to graph-node bundle source for legacy db ref without snapshot when hash is in graph', async () => { + it('falls back to graph-node bundle source when the db ref pointer is absent but the hash is a graph node', async () => { const bundles = [makePkg(E, HASH_A, 'm1')]; const space = makeSpace( bundles, @@ -314,7 +311,7 @@ describe('resolveFromForPlan', () => { } }); - it('returns graph-node for explicit ref when snapshot is missing but hash is a graph node', async () => { + it('returns graph-node for explicit ref when the pointer is absent but hash is a graph node', async () => { const bundles = [makePkg(E, HASH_A, 'm1')]; const space = makeSpace( bundles, @@ -333,20 +330,16 @@ describe('resolveFromForPlan', () => { expect(space.contractAt).toHaveBeenCalledWith(HASH_A, { refName: 'staging' }); }); - it('refuses snapshot-missing for explicit ref name without snapshot when hash is not a graph node', async () => { + it('refuses ref-not-resolvable for an explicit ref whose pointer is absent and hash is not a graph node', async () => { const space = makeSpace( [], { staging: { hash: HASH_ORPHAN, invariants: [] } }, vi.fn().mockRejectedValue( - new MigrationToolsError( - 'MIGRATION.SNAPSHOT_MISSING', - `Ref "staging" has no paired contract snapshot`, - { - why: 'Ref "staging" exists but its paired snapshot files are missing.', - fix: 'Run "prisma-next db update --advance-ref staging" to repopulate the snapshot, or "prisma-next ref delete staging" to clear the orphan pointer.', - details: { refName: 'staging' }, - }, - ), + new MigrationToolsError('MIGRATION.REF_NOT_RESOLVABLE', `Ref "staging" is not resolvable`, { + why: 'Ref "staging" has no pointer file, and the hash being resolved is not a node in the migration graph either.', + fix: 'Create the ref with "prisma-next ref set staging " (or advance it via "prisma-next db update --advance-ref staging"), or pass a hash that is a node in the migration graph.', + details: { refName: 'staging' }, + }), ), ); const result = await resolveFromForPlan(baseInput({ space, optionsFrom: 'staging' })); @@ -357,7 +350,7 @@ describe('resolveFromForPlan', () => { } }); - it('surfaces contract validation failure for bad snapshot contract shape', async () => { + it('surfaces contract validation failure for a bad store contract shape', async () => { const space = makeSpace( [], { db: { hash: HASH_A, invariants: [] } }, @@ -366,10 +359,10 @@ describe('resolveFromForPlan', () => { 'MIGRATION.CONTRACT_DESERIALIZATION_FAILED', 'Contract failed to deserialize', { - why: 'Contract at "/project/migrations/refs/db.contract.json" failed to deserialize: unsupported legacy shape', + why: `Contract at "/project/migrations/snapshots/${'a'.repeat(64)}/contract.json" failed to deserialize: unsupported legacy shape`, fix: 'Re-emit.', details: { - filePath: '/project/migrations/refs/db.contract.json', + filePath: `/project/migrations/snapshots/${'a'.repeat(64)}/contract.json`, message: 'unsupported legacy shape', }, }, @@ -384,14 +377,14 @@ describe('resolveFromForPlan', () => { } }); - it('surfaces INVALID_REF_FILE when paired contract.d.ts is missing', async () => { + it('surfaces INVALID_REF_FILE when the ref pointer file is malformed', async () => { const space = makeSpace( [], { db: { hash: HASH_A, invariants: [] } }, vi.fn().mockRejectedValue( new MigrationToolsError('MIGRATION.INVALID_REF_FILE', 'Invalid ref file', { - why: 'Missing paired contract.d.ts snapshot file', - fix: 'Re-run db update.', + why: 'Failed to parse as JSON', + fix: 'Fix the malformed ref pointer file.', }), ), ); @@ -431,7 +424,7 @@ describe('resolveToForPlan', () => { migrationCounter = 0; }); - it('resolves a ref name with a paired snapshot to its materialized contract', async () => { + it('resolves a ref name to its materialized contract through the snapshot store', async () => { const bundles = [makePkg(E, HASH_A, 'm1'), makePkg(HASH_A, HASH_B, 'm2')]; const space = makeSpace( bundles, diff --git a/packages/1-framework/3-tooling/cli/test/utils/ref-advancement.test.ts b/packages/1-framework/3-tooling/cli/test/utils/ref-advancement.test.ts index 5bb0530896..62cbf5cc2f 100644 --- a/packages/1-framework/3-tooling/cli/test/utils/ref-advancement.test.ts +++ b/packages/1-framework/3-tooling/cli/test/utils/ref-advancement.test.ts @@ -1,23 +1,27 @@ import { existsSync } from 'node:fs'; -import { rm } from 'node:fs/promises'; +import { readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; +import { contractSnapshotDir } from '@prisma-next/migration-tools/contract-snapshot-store'; import { MigrationToolsError } from '@prisma-next/migration-tools/errors'; -import type { ContractIR } from '@prisma-next/migration-tools/refs'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { computeRefAdvancementName, executeRefAdvancement } from '../../src/utils/ref-advancement'; +import { + type ContractIR, + computeRefAdvancementName, + executeRefAdvancement, +} from '../../src/utils/ref-advancement'; const HASH_A = `sha256:${'a'.repeat(64)}`; const PROFILE_HASH = `sha256:${'c'.repeat(64)}`; -function sampleContractIR(): ContractIR { +function sampleContractIR(storageHash: string = HASH_A): ContractIR { return { contract: { schemaVersion: '1', targetFamily: 'sql', target: 'postgres', profileHash: PROFILE_HASH, - storage: { storageHash: HASH_A }, + storage: { storageHash }, models: { User: { fields: { @@ -40,14 +44,6 @@ function refPointerPath(refsDir: string, name: string): string { return join(refsDir, `${name}.json`); } -function snapshotJsonPath(refsDir: string, name: string): string { - return join(refsDir, `${name}.contract.json`); -} - -function snapshotDtsPath(refsDir: string, name: string): string { - return join(refsDir, `${name}.contract.d.ts`); -} - describe('computeRefAdvancementName', () => { it('returns the explicit name when advanceRef is set without db', () => { expect(computeRefAdvancementName({ advanceRef: 'staging' })).toBe('staging'); @@ -73,48 +69,73 @@ describe('computeRefAdvancementName', () => { }); describe('executeRefAdvancement', () => { + let migrationsDir: string; let refsDir: string; beforeEach(async () => { - refsDir = join( + migrationsDir = join( tmpdir(), `test-ref-advancement-${Date.now()}-${Math.random().toString(36).slice(2)}`, - 'refs', ); + refsDir = join(migrationsDir, 'app', 'refs'); }); afterEach(async () => { - await rm(join(refsDir, '..'), { recursive: true, force: true }); + await rm(migrationsDir, { recursive: true, force: true }); }); - it('writes pointer and paired snapshot files and returns the advanced ref', async () => { + it('writes the store entry and pointer, returning the advanced ref', async () => { expect(existsSync(refsDir)).toBe(false); - const result = await executeRefAdvancement(refsDir, 'db', HASH_A, sampleContractIR()); + const result = await executeRefAdvancement( + refsDir, + migrationsDir, + 'db', + HASH_A, + sampleContractIR(), + ); expect(result).toEqual({ name: 'db', hash: HASH_A }); expect(existsSync(refPointerPath(refsDir, 'db'))).toBe(true); - expect(existsSync(snapshotJsonPath(refsDir, 'db'))).toBe(true); - expect(existsSync(snapshotDtsPath(refsDir, 'db'))).toBe(true); + expect(existsSync(join(contractSnapshotDir(migrationsDir, HASH_A), 'contract.json'))).toBe( + true, + ); + expect(existsSync(join(contractSnapshotDir(migrationsDir, HASH_A), 'contract.d.ts'))).toBe( + true, + ); + }); + + it('is a write-if-absent no-op on the store when advancing to the same hash again', async () => { + await executeRefAdvancement(refsDir, migrationsDir, 'db', HASH_A, sampleContractIR()); + const storeJsonPath = join(contractSnapshotDir(migrationsDir, HASH_A), 'contract.json'); + const firstContent = await readFile(storeJsonPath, 'utf-8'); + + await executeRefAdvancement(refsDir, migrationsDir, 'db', HASH_A, sampleContractIR()); + const secondContent = await readFile(storeJsonPath, 'utf-8'); + + expect(secondContent).toBe(firstContent); }); - it('propagates writeRefPaired failures', async () => { + it('propagates a hash mismatch between the argument and the contract IR from the store write', async () => { + const HASH_B = `sha256:${'b'.repeat(64)}`; await expect( - executeRefAdvancement(refsDir, 'db', 'sha256:not-a-valid-hash', sampleContractIR()), + executeRefAdvancement(refsDir, migrationsDir, 'db', HASH_A, sampleContractIR(HASH_B)), ).rejects.toSatisfy((error) => { expect(MigrationToolsError.is(error)).toBe(true); - expect((error as MigrationToolsError).code).toBe('MIGRATION.INVALID_REF_VALUE'); + expect((error as MigrationToolsError).code).toBe('MIGRATION.CONTRACT_SNAPSHOT_HASH_MISMATCH'); return true; }); + expect(existsSync(refPointerPath(refsDir, 'db'))).toBe(false); }); - it('surfaces MIGRATION.INVALID_REF_NAME for an invalid ref name', async () => { - await expect(executeRefAdvancement(refsDir, '', HASH_A, sampleContractIR())).rejects.toSatisfy( - (error) => { - expect(MigrationToolsError.is(error)).toBe(true); - expect((error as MigrationToolsError).code).toBe('MIGRATION.INVALID_REF_NAME'); - return true; - }, - ); + it('surfaces MIGRATION.INVALID_REF_NAME for an invalid ref name without writing a store entry', async () => { + await expect( + executeRefAdvancement(refsDir, migrationsDir, '', HASH_A, sampleContractIR()), + ).rejects.toSatisfy((error) => { + expect(MigrationToolsError.is(error)).toBe(true); + expect((error as MigrationToolsError).code).toBe('MIGRATION.INVALID_REF_NAME'); + return true; + }); + expect(existsSync(contractSnapshotDir(migrationsDir, HASH_A))).toBe(false); }); }); diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts b/packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts index 3c14cd56d7..cc0943edd1 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts @@ -11,7 +11,7 @@ import { errorBundleNotFoundForGraphNode, errorContractDeserializationFailed, errorHashNotInGraph, - errorSnapshotMissing, + errorRefNotResolvable, MigrationToolsError, } from '../errors'; import type { MigrationGraph } from '../graph'; @@ -19,8 +19,8 @@ import { isGraphNode } from '../graph-membership'; import type { IntegrityQueryOptions, IntegrityViolation } from '../integrity-violation'; import { reconstructGraph } from '../migration-graph'; import type { OnDiskMigrationPackage } from '../package'; -import type { Refs } from '../refs'; -import { readRefSnapshot } from '../refs/snapshot'; +import type { RefEntry, Refs } from '../refs'; +import { readRef } from '../refs'; import type { ContractSpaceHeadRecord } from '../verify-contract-spaces'; import type { AggregateContractSpace, @@ -49,7 +49,7 @@ function deserializeContractAtPath( } } -async function readGraphNodeEndContract( +async function readContractSnapshotEntry( migrationsDir: string, hash: string, deserializeContract: (raw: unknown) => Contract, @@ -74,15 +74,29 @@ async function resolveContractAt(args: { const refName = opts?.refName; if (refName !== undefined) { - const snapshot = await readRefSnapshot(refsDir, refName); - if (snapshot) { - const jsonPath = join(refsDir, `${refName}.contract.json`); + let refEntry: RefEntry | undefined; + try { + refEntry = await readRef(refsDir, refName); + } catch (error) { + if (MigrationToolsError.is(error) && error.code === 'MIGRATION.UNKNOWN_REF') { + refEntry = undefined; + } else { + throw error; + } + } + + if (refEntry) { + const { contractJson, contractDts, contract } = await readContractSnapshotEntry( + migrationsDir, + refEntry.hash, + deserializeContract, + ); return { - hash, - contractJson: snapshot.contract, - contractDts: snapshot.contractDts, - contract: deserializeContractAtPath(jsonPath, snapshot.contract, deserializeContract), - provenance: 'snapshot', + hash: refEntry.hash, + contractJson, + contractDts, + contract, + provenance: 'ref', }; } @@ -96,7 +110,7 @@ async function resolveContractAt(args: { }); } - throw errorSnapshotMissing(refName); + throw errorRefNotResolvable(refName); } if (isGraphNode(hash, graph)) { @@ -119,7 +133,7 @@ async function resolveGraphNodeContractAt(args: { throw errorBundleNotFoundForGraphNode(hash, explicitLabel); } - const { contractJson, contractDts, contract } = await readGraphNodeEndContract( + const { contractJson, contractDts, contract } = await readContractSnapshotEntry( migrationsDir, hash, deserializeContract, @@ -160,9 +174,10 @@ export function requireHeadRef(space: AggregateContractSpace): ContractSpaceHead * undeserializable on-disk contract) re-throws on each call rather than * caching a value — `checkIntegrity` surfaces that as `contractUnreadable`. * `contractAt()` materializes the contract at an arbitrary graph node with - * the same resolution order as plan-time ref resolution: ref snapshot first - * (when `opts.refName` is set), else the contract snapshot store entry - * keyed by the matching package's `to` hash. + * the same resolution order as plan-time ref resolution: the ref's pointer + * plus the store entry keyed by the pointer's hash first (when + * `opts.refName` is set), else the contract snapshot store entry keyed by + * the matching package's `to` hash. */ export function createAggregateContractSpace(args: { readonly spaceId: string; diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/types.ts b/packages/1-framework/3-tooling/migration/src/aggregate/types.ts index 0fcf628945..ec8f7d928f 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/types.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/types.ts @@ -15,7 +15,7 @@ export interface ContractAtOptions { export type ContractAtResult = | { - readonly provenance: 'snapshot'; + readonly provenance: 'ref'; readonly hash: string; readonly contractJson: unknown; readonly contractDts: string; @@ -61,8 +61,9 @@ export type ContractAtResult = * `checkIntegrity` under `checkContracts`); callers gate before * querying it. * - `contractAt(hash, opts?)`: materializes the contract at an arbitrary - * graph node — when `opts.refName` is set, prefer the ref's paired - * snapshot; else find the package whose `metadata.to === hash` and read + * graph node — when `opts.refName` is set, resolve the ref's pointer + * and read the contract snapshot store entry keyed by the pointer's + * hash; else find the package whose `metadata.to === hash` and read * the contract snapshot store entry keyed by that hash. Lazy per * `(hash, refName?)` memoisation; throws typed {@link MigrationToolsError} * values compatible with CLI mappers. diff --git a/packages/1-framework/3-tooling/migration/src/errors.ts b/packages/1-framework/3-tooling/migration/src/errors.ts index cf85e7d3c4..274a2474f8 100644 --- a/packages/1-framework/3-tooling/migration/src/errors.ts +++ b/packages/1-framework/3-tooling/migration/src/errors.ts @@ -401,14 +401,20 @@ export function errorMigrationHashMismatch( }); } -export function errorSnapshotMissing(refName: string): MigrationToolsError { +/** + * A ref name that resolves to nothing: no pointer file named `refName` + * exists, and the hash being resolved (the `contractAt` argument, used as a + * fallback when the pointer is absent) is not a node in the migration graph + * either. There is no contract to materialize. + */ +export function errorRefNotResolvable(refName: string): MigrationToolsError { return new MigrationToolsError( - 'MIGRATION.SNAPSHOT_MISSING', - `Ref "${refName}" has no paired contract snapshot`, + 'MIGRATION.REF_NOT_RESOLVABLE', + `Ref "${refName}" is not resolvable`, { - why: `Ref "${refName}" exists but its paired snapshot files are missing.`, - fix: `Run "prisma-next db update --advance-ref ${refName}" to repopulate the snapshot, or "prisma-next ref delete ${refName}" to clear the orphan pointer.`, - details: { refName, identifier: refName, viaRef: true }, + why: `Ref "${refName}" has no pointer file, and the hash being resolved is not a node in the migration graph either — there is nothing to materialize a contract from.`, + fix: `Create the ref with "prisma-next ref set ${refName} " (or advance it via "prisma-next db update --advance-ref ${refName}"), or pass a hash that is a node in the migration graph.`, + details: { refName, identifier: refName }, }, ); } diff --git a/packages/1-framework/3-tooling/migration/src/exports/errors.ts b/packages/1-framework/3-tooling/migration/src/exports/errors.ts index 0a90fd30a0..8340152f34 100644 --- a/packages/1-framework/3-tooling/migration/src/exports/errors.ts +++ b/packages/1-framework/3-tooling/migration/src/exports/errors.ts @@ -3,6 +3,7 @@ export { errorContractSnapshotMissing, errorDescriptorHeadHashMismatch, errorInvalidJson, + errorInvalidRefName, errorNoInvariantPath, errorUnknownInvariant, MigrationToolsError, diff --git a/packages/1-framework/3-tooling/migration/src/exports/refs.ts b/packages/1-framework/3-tooling/migration/src/exports/refs.ts index fde1707605..eee73027e9 100644 --- a/packages/1-framework/3-tooling/migration/src/exports/refs.ts +++ b/packages/1-framework/3-tooling/migration/src/exports/refs.ts @@ -11,11 +11,3 @@ export { validateRefValue, writeRef, } from '../refs'; -export type { ContractIR } from '../refs/snapshot'; -export { - deleteRefPaired, - deleteRefSnapshot, - readRefSnapshot, - writeRefPaired, - writeRefSnapshot, -} from '../refs/snapshot'; diff --git a/packages/1-framework/3-tooling/migration/src/refs/snapshot.ts b/packages/1-framework/3-tooling/migration/src/refs/snapshot.ts deleted file mode 100644 index 611f57b64e..0000000000 --- a/packages/1-framework/3-tooling/migration/src/refs/snapshot.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { randomBytes } from 'node:crypto'; -import { access, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; -import { canonicalizeJson } from '@prisma-next/framework-components/utils'; -import { type } from 'arktype'; -import { basename, dirname, join } from 'pathe'; -import { errorInvalidRefFile, errorInvalidRefName, MigrationToolsError } from '../errors'; -import { deleteRef, type RefEntry, validateRefName, writeRef } from '../refs'; - -export interface ContractIR { - readonly contract: unknown; - readonly contractDts: string; -} - -const ContractIrSchema = type({ - targetFamily: 'string', - target: 'string', - profileHash: 'string', - storage: type({ - storageHash: 'string', - }), - domain: type({ - namespaces: 'object', - }), -}); - -function hasErrnoCode(error: unknown, code: string): boolean { - return error instanceof Error && (error as { code?: string }).code === code; -} - -function snapshotJsonPath(refsDir: string, name: string): string { - return join(refsDir, `${name}.contract.json`); -} - -function snapshotDtsPath(refsDir: string, name: string): string { - return join(refsDir, `${name}.contract.d.ts`); -} - -function tmpPathFor(finalPath: string): string { - const dir = dirname(finalPath); - const fileName = basename(finalPath); - return join(dir, `.${fileName}.${Date.now()}-${randomBytes(4).toString('hex')}.tmp`); -} - -async function atomicWriteFile(finalPath: string, content: string): Promise { - const dir = dirname(finalPath); - await mkdir(dir, { recursive: true }); - const tmpPath = tmpPathFor(finalPath); - await writeFile(tmpPath, content); - await rename(tmpPath, finalPath); -} - -async function unlinkIfExists(filePath: string): Promise { - try { - await unlink(filePath); - } catch (error) { - if (hasErrnoCode(error, 'ENOENT')) return; - throw error; - } -} - -function parseContractSnapshotJson(filePath: string, raw: string): unknown { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - throw errorInvalidRefFile(filePath, 'Failed to parse as JSON'); - } - - const result = ContractIrSchema(parsed); - if (result instanceof type.errors) { - throw errorInvalidRefFile(filePath, result.summary); - } - - return result; -} - -export async function writeRefSnapshot( - refsDir: string, - name: string, - snapshot: ContractIR, -): Promise { - if (!validateRefName(name)) { - throw errorInvalidRefName(name); - } - - const jsonPath = snapshotJsonPath(refsDir, name); - const dtsPath = snapshotDtsPath(refsDir, name); - const jsonContent = `${canonicalizeJson(snapshot.contract)}\n`; - const dtsContent = snapshot.contractDts.endsWith('\n') - ? snapshot.contractDts - : `${snapshot.contractDts}\n`; - - try { - await atomicWriteFile(jsonPath, jsonContent); - } catch (error) { - await unlinkIfExists(jsonPath); - throw error; - } - - try { - await atomicWriteFile(dtsPath, dtsContent); - } catch (error) { - await unlinkIfExists(jsonPath); - await unlinkIfExists(dtsPath); - throw error; - } -} - -export async function readRefSnapshot(refsDir: string, name: string): Promise { - if (!validateRefName(name)) { - throw errorInvalidRefName(name); - } - - const jsonPath = snapshotJsonPath(refsDir, name); - const dtsPath = snapshotDtsPath(refsDir, name); - - let raw: string; - try { - raw = await readFile(jsonPath, 'utf-8'); - } catch (error) { - if (hasErrnoCode(error, 'ENOENT')) { - return null; - } - throw error; - } - - const contract = parseContractSnapshotJson(jsonPath, raw); - - let contractDts: string; - try { - contractDts = await readFile(dtsPath, 'utf-8'); - } catch (error) { - if (hasErrnoCode(error, 'ENOENT')) { - throw errorInvalidRefFile(dtsPath, 'Missing paired contract.d.ts snapshot file'); - } - throw error; - } - - return { contract, contractDts }; -} - -export async function deleteRefSnapshot(refsDir: string, name: string): Promise { - if (!validateRefName(name)) { - throw errorInvalidRefName(name); - } - - await unlinkIfExists(snapshotJsonPath(refsDir, name)); - await unlinkIfExists(snapshotDtsPath(refsDir, name)); -} - -export async function writeRefPaired( - refsDir: string, - name: string, - entry: RefEntry, - snapshot: ContractIR, -): Promise { - await writeRefSnapshot(refsDir, name, snapshot); - try { - await writeRef(refsDir, name, entry); - } catch (writeError) { - try { - await deleteRefSnapshot(refsDir, name); - } catch { - // Rollback failure is secondary; preserve the original writeRef error. - } - throw writeError; - } -} - -function isUnknownRefError(error: unknown): boolean { - return MigrationToolsError.is(error) && error.code === 'MIGRATION.UNKNOWN_REF'; -} - -async function snapshotFilesExist(refsDir: string, name: string): Promise { - if (!validateRefName(name)) { - throw errorInvalidRefName(name); - } - - const paths = [snapshotJsonPath(refsDir, name), snapshotDtsPath(refsDir, name)]; - const checks = await Promise.allSettled(paths.map((filePath) => access(filePath))); - return checks.some((result) => result.status === 'fulfilled'); -} - -export async function deleteRefPaired(refsDir: string, name: string): Promise { - if (await snapshotFilesExist(refsDir, name)) { - try { - await deleteRef(refsDir, name); - } catch (error) { - if (!isUnknownRefError(error)) { - throw error; - } - } - await deleteRefSnapshot(refsDir, name); - return; - } - - await deleteRef(refsDir, name); - await deleteRefSnapshot(refsDir, name); -} diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/contract-at.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/contract-at.test.ts index 9697a57ee5..de432dd220 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/contract-at.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/contract-at.test.ts @@ -5,7 +5,7 @@ import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createAggregateContractSpace } from '../../src/aggregate/aggregate'; import { contractSnapshotDir, writeContractSnapshot } from '../../src/contract-snapshot-store'; -import { writeRefSnapshot } from '../../src/refs/snapshot'; +import { writeRef } from '../../src/refs'; import { createAttestedPackage, createTestContract, writeTestPackage } from '../fixtures'; const HASH_A = `sha256:${'a'.repeat(64)}`; @@ -91,11 +91,9 @@ describe('AggregateContractSpace.contractAt', () => { }); } - it('prefers a ref paired snapshot when refName is supplied', async () => { - await writeRefSnapshot(refsDir, 'staging', { - contract: sampleContractJson(HASH_A), - contractDts: sampleContractDts('snapshot'), - }); + it('resolves via the ref pointer and its store entry when refName is supplied', async () => { + await writeEndContract(workDir, HASH_A, 'ref'); + await writeRef(refsDir, 'staging', { hash: HASH_A, invariants: [] }); const space = spaceWithPackages([ createAttestedPackage('20260101T0000_init', { from: null, to: HASH_A }), @@ -104,13 +102,27 @@ describe('AggregateContractSpace.contractAt', () => { const result = await space.contractAt(HASH_A, { refName: 'staging' }); expect(result.hash).toBe(HASH_A); - expect(result.provenance).toBe('snapshot'); - expect(result.contractDts).toBe(sampleContractDts('snapshot')); + expect(result.provenance).toBe('ref'); + expect(result.contractDts).toBe(sampleContractDts('ref')); expect((result.contractJson as { storage: { storageHash: string } }).storage.storageHash).toBe( HASH_A, ); }); + it("returns the pointer's hash, not the hash argument, when they differ", async () => { + await writeEndContract(workDir, HASH_A, 'ref'); + await writeRef(refsDir, 'staging', { hash: HASH_A, invariants: [] }); + + const space = spaceWithPackages([ + createAttestedPackage('20260101T0000_init', { from: null, to: HASH_B }), + ]); + + const result = await space.contractAt(HASH_B, { refName: 'staging' }); + + expect(result.hash).toBe(HASH_A); + expect(result.provenance).toBe('ref'); + }); + it('reads the destination contract from the matching graph-node package without refName', async () => { const space = spaceWithPackages([ createAttestedPackage('20260101T0000_init', { from: null, to: HASH_B }), @@ -127,7 +139,7 @@ describe('AggregateContractSpace.contractAt', () => { ); }); - it('falls back to the graph-node bundle when the ref snapshot is absent', async () => { + it('falls back to the graph-node bundle when the ref pointer is absent', async () => { const space = spaceWithPackages([ createAttestedPackage('20260101T0000_init', { from: null, to: HASH_B }), ]); @@ -162,6 +174,19 @@ describe('AggregateContractSpace.contractAt', () => { }); }); + it('throws contract snapshot missing when the ref pointer exists but the store entry is missing', async () => { + await writeRef(refsDir, 'staging', { hash: HASH_A, invariants: [] }); + + const space = spaceWithPackages([ + createAttestedPackage('20260101T0000_init', { from: null, to: HASH_B }), + ]); + + await expect(space.contractAt(HASH_A, { refName: 'staging' })).rejects.toMatchObject({ + code: 'MIGRATION.CONTRACT_SNAPSHOT_MISSING', + details: { storageHash: HASH_A }, + }); + }); + it('throws when the store entry contract.json is invalid JSON', async () => { await writeFile(join(contractSnapshotDir(workDir, HASH_B), 'contract.json'), '{not json'); @@ -187,13 +212,13 @@ describe('AggregateContractSpace.contractAt', () => { }); }); - it('throws snapshot missing when refName is set and hash is not a graph node', async () => { + it('throws ref not resolvable when refName is set and hash is not a graph node', async () => { const space = spaceWithPackages([ createAttestedPackage('20260101T0000_init', { from: null, to: HASH_B }), ]); await expect(space.contractAt(HASH_A, { refName: 'staging' })).rejects.toMatchObject({ - code: 'MIGRATION.SNAPSHOT_MISSING', + code: 'MIGRATION.REF_NOT_RESOLVABLE', details: { refName: 'staging' }, }); }); @@ -219,20 +244,19 @@ describe('AggregateContractSpace.contractAt', () => { expect(second).toBe(first); }); - it('memoises snapshot and bundle resolutions under separate keys', async () => { - await writeRefSnapshot(refsDir, 'staging', { - contract: sampleContractJson(HASH_B), - contractDts: sampleContractDts('snapshot'), - }); + it('memoises ref and bundle resolutions under separate keys', async () => { + await writeRef(refsDir, 'staging', { hash: HASH_B, invariants: [] }); const space = spaceWithPackages([ createAttestedPackage('20260101T0000_init', { from: null, to: HASH_B }), ]); - const fromSnapshot = await space.contractAt(HASH_B, { refName: 'staging' }); + const fromRef = await space.contractAt(HASH_B, { refName: 'staging' }); const fromBundle = await space.contractAt(HASH_B); - expect(fromSnapshot.contractDts).toBe(sampleContractDts('snapshot')); - expect(fromBundle.contractDts).toBe(sampleContractDts('bundle')); + expect(fromRef).not.toBe(fromBundle); + expect(fromRef.provenance).toBe('ref'); + expect(fromBundle.provenance).toBe('graph-node'); + expect(fromRef.contractDts).toBe(fromBundle.contractDts); }); }); diff --git a/packages/1-framework/3-tooling/migration/test/refs/snapshot-failure.test.ts b/packages/1-framework/3-tooling/migration/test/refs/snapshot-failure.test.ts deleted file mode 100644 index 203c3bf423..0000000000 --- a/packages/1-framework/3-tooling/migration/test/refs/snapshot-failure.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { existsSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'pathe'; -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ContractIR } from '../../src/refs/snapshot'; - -const fsMocks = vi.hoisted(() => ({ - renameFailOnCall: null as number | null, - renameCount: 0, - unlinkFailOnCall: null as number | null, - unlinkCount: 0, -})); - -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - rename: async (src: string, dest: string) => { - fsMocks.renameCount += 1; - if (fsMocks.renameFailOnCall === fsMocks.renameCount) { - throw new Error(`simulated rename failure on call ${fsMocks.renameCount}`); - } - return actual.rename(src, dest); - }, - unlink: async (path: string) => { - fsMocks.unlinkCount += 1; - if (fsMocks.unlinkFailOnCall === fsMocks.unlinkCount) { - throw new Error(`simulated unlink failure on call ${fsMocks.unlinkCount}`); - } - return actual.unlink(path); - }, - }; -}); - -afterAll(() => { - vi.doUnmock('node:fs/promises'); -}); - -import { rm, unlink } from 'node:fs/promises'; -import { MigrationToolsError } from '../../src/errors'; -import { writeRef } from '../../src/refs'; -import { deleteRefPaired, writeRefPaired, writeRefSnapshot } from '../../src/refs/snapshot'; - -const HASH_A = `sha256:${'a'.repeat(64)}`; -const PROFILE_HASH = `sha256:${'c'.repeat(64)}`; - -const ENTRY_A = { hash: HASH_A, invariants: [] as string[] }; - -function sampleContractIR(): ContractIR { - return { - contract: { - schemaVersion: '1', - targetFamily: 'sql', - target: 'postgres', - profileHash: PROFILE_HASH, - storage: { storageHash: HASH_A }, - models: { - User: { - fields: { - id: { - nullable: false, - type: { kind: 'scalar', codecId: 'sql/int4@1' }, - }, - }, - relations: {}, - storage: { namespaceId: '__unbound__', table: 'users', namespace: 'public' }, - }, - }, - roots: {}, - }, - contractDts: '// generated\nexport type Contract = unknown;\n', - }; -} - -function snapshotJsonPath(refsDir: string, name: string): string { - return join(refsDir, `${name}.contract.json`); -} - -function snapshotDtsPath(refsDir: string, name: string): string { - return join(refsDir, `${name}.contract.d.ts`); -} - -function refPointerPath(refsDir: string, name: string): string { - return join(refsDir, `${name}.json`); -} - -describe('writeRefSnapshot partial-write cleanup', () => { - let refsDir: string; - - beforeEach(async () => { - fsMocks.renameCount = 0; - fsMocks.renameFailOnCall = null; - fsMocks.unlinkCount = 0; - fsMocks.unlinkFailOnCall = null; - refsDir = join( - tmpdir(), - `test-ref-snapshot-failure-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - }); - - afterEach(async () => { - await rm(refsDir, { recursive: true, force: true }); - }); - - it('cleans up json when dts rename fails', async () => { - fsMocks.renameFailOnCall = 2; - const input = sampleContractIR(); - - await expect(writeRefSnapshot(refsDir, 'staging', input)).rejects.toThrow( - 'simulated rename failure on call 2', - ); - - expect(existsSync(snapshotJsonPath(refsDir, 'staging'))).toBe(false); - expect(existsSync(snapshotDtsPath(refsDir, 'staging'))).toBe(false); - }); - - it('cleans up dts when json rename fails', async () => { - fsMocks.renameFailOnCall = 1; - const input = sampleContractIR(); - - await expect(writeRefSnapshot(refsDir, 'staging', input)).rejects.toThrow( - 'simulated rename failure on call 1', - ); - - expect(existsSync(snapshotJsonPath(refsDir, 'staging'))).toBe(false); - expect(existsSync(snapshotDtsPath(refsDir, 'staging'))).toBe(false); - }); -}); - -describe('writeRefPaired cross-boundary failure handling', () => { - let refsDir: string; - - beforeEach(async () => { - fsMocks.renameCount = 0; - fsMocks.renameFailOnCall = null; - fsMocks.unlinkCount = 0; - fsMocks.unlinkFailOnCall = null; - refsDir = join( - tmpdir(), - `test-ref-paired-failure-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - }); - - afterEach(async () => { - await rm(refsDir, { recursive: true, force: true }); - }); - - it('leaves no pointer when writeRefSnapshot fails before writeRef', async () => { - fsMocks.renameFailOnCall = 1; - const input = sampleContractIR(); - - await expect(writeRefPaired(refsDir, 'staging', ENTRY_A, input)).rejects.toThrow( - 'simulated rename failure on call 1', - ); - - expect(existsSync(refPointerPath(refsDir, 'staging'))).toBe(false); - expect(existsSync(snapshotJsonPath(refsDir, 'staging'))).toBe(false); - expect(existsSync(snapshotDtsPath(refsDir, 'staging'))).toBe(false); - }); - - it('rolls back snapshot when writeRef fails after writeRefSnapshot succeeded', async () => { - fsMocks.renameFailOnCall = 3; - const input = sampleContractIR(); - - await expect(writeRefPaired(refsDir, 'staging', ENTRY_A, input)).rejects.toThrow( - 'simulated rename failure on call 3', - ); - - expect(existsSync(refPointerPath(refsDir, 'staging'))).toBe(false); - expect(existsSync(snapshotJsonPath(refsDir, 'staging'))).toBe(false); - expect(existsSync(snapshotDtsPath(refsDir, 'staging'))).toBe(false); - }); - - it('preserves writeRef failure when rollback unlink also fails', async () => { - fsMocks.renameFailOnCall = 3; - fsMocks.unlinkFailOnCall = 1; - const input = sampleContractIR(); - - await expect(writeRefPaired(refsDir, 'staging', ENTRY_A, input)).rejects.toThrow( - 'simulated rename failure on call 3', - ); - }); -}); - -describe('deleteRefPaired cross-boundary recovery', () => { - let refsDir: string; - - beforeEach(async () => { - fsMocks.renameCount = 0; - fsMocks.renameFailOnCall = null; - fsMocks.unlinkCount = 0; - fsMocks.unlinkFailOnCall = null; - refsDir = join( - tmpdir(), - `test-ref-paired-delete-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - }); - - afterEach(async () => { - await rm(refsDir, { recursive: true, force: true }); - }); - - it('recovers partial state when pointer exists without a paired snapshot', async () => { - await writeRef(refsDir, 'staging', ENTRY_A); - - await expect(deleteRefPaired(refsDir, 'staging')).resolves.toBeUndefined(); - - expect(existsSync(refPointerPath(refsDir, 'staging'))).toBe(false); - expect(existsSync(snapshotJsonPath(refsDir, 'staging'))).toBe(false); - expect(existsSync(snapshotDtsPath(refsDir, 'staging'))).toBe(false); - }); - - it('removes orphan snapshot when pointer is missing', async () => { - await writeRefSnapshot(refsDir, 'staging', sampleContractIR()); - - await expect(deleteRefPaired(refsDir, 'staging')).resolves.toBeUndefined(); - - expect(existsSync(refPointerPath(refsDir, 'staging'))).toBe(false); - expect(existsSync(snapshotJsonPath(refsDir, 'staging'))).toBe(false); - expect(existsSync(snapshotDtsPath(refsDir, 'staging'))).toBe(false); - }); - - it('removes dts-only orphan when pointer and json are missing', async () => { - await writeRefSnapshot(refsDir, 'staging', sampleContractIR()); - await unlink(snapshotJsonPath(refsDir, 'staging')); - - await expect(deleteRefPaired(refsDir, 'staging')).resolves.toBeUndefined(); - - expect(existsSync(refPointerPath(refsDir, 'staging'))).toBe(false); - expect(existsSync(snapshotJsonPath(refsDir, 'staging'))).toBe(false); - expect(existsSync(snapshotDtsPath(refsDir, 'staging'))).toBe(false); - }); - - it('throws MIGRATION.UNKNOWN_REF when both pointer and snapshot are missing', async () => { - await expect(deleteRefPaired(refsDir, 'missing')).rejects.toSatisfy((error) => { - expect(MigrationToolsError.is(error)).toBe(true); - expect((error as MigrationToolsError).code).toBe('MIGRATION.UNKNOWN_REF'); - return true; - }); - }); -}); diff --git a/packages/1-framework/3-tooling/migration/test/refs/snapshot.test.ts b/packages/1-framework/3-tooling/migration/test/refs/snapshot.test.ts deleted file mode 100644 index 4f562cab60..0000000000 --- a/packages/1-framework/3-tooling/migration/test/refs/snapshot.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { existsSync } from 'node:fs'; -import * as fs from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { canonicalizeJson } from '@prisma-next/framework-components/utils'; -import { join } from 'pathe'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { MigrationToolsError } from '../../src/errors'; -import { writeRef } from '../../src/refs'; -import type { ContractIR } from '../../src/refs/snapshot'; -import { deleteRefSnapshot, readRefSnapshot, writeRefSnapshot } from '../../src/refs/snapshot'; - -const HASH_A = `sha256:${'a'.repeat(64)}`; -const HASH_B = `sha256:${'b'.repeat(64)}`; -const PROFILE_HASH = `sha256:${'c'.repeat(64)}`; - -function sampleContractDts(label: string): string { - return `// generated ${label}\nexport type Contract = unknown;\n`; -} - -function sampleContractIR(variant: 'a' | 'b' = 'a'): ContractIR { - const storageHash = variant === 'a' ? HASH_A : HASH_B; - return { - contract: { - schemaVersion: '1', - targetFamily: 'sql', - target: 'postgres', - profileHash: PROFILE_HASH, - storage: { storageHash }, - domain: { - namespaces: { - __unbound__: { - models: { - User: { - fields: { - id: { - nullable: false, - type: { kind: 'scalar', codecId: 'sql/int4@1' }, - }, - }, - relations: {}, - storage: { namespaceId: '__unbound__', table: 'users', namespace: 'public' }, - }, - }, - }, - }, - }, - roots: {}, - }, - contractDts: sampleContractDts(variant), - }; -} - -function snapshotJsonPath(refsDir: string, name: string): string { - return join(refsDir, `${name}.contract.json`); -} - -function snapshotDtsPath(refsDir: string, name: string): string { - return join(refsDir, `${name}.contract.d.ts`); -} - -function expectInvalidRefFile(error: unknown, filePath: string) { - expect(MigrationToolsError.is(error)).toBe(true); - const migrationError = error as MigrationToolsError; - expect(migrationError.code).toBe('MIGRATION.INVALID_REF_FILE'); - expect(migrationError.details).toEqual(expect.objectContaining({ path: filePath })); -} - -describe('writeRefSnapshot + readRefSnapshot', () => { - let refsDir: string; - - beforeEach(async () => { - refsDir = join( - tmpdir(), - `test-ref-snapshot-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - }); - - afterEach(async () => { - await fs.rm(refsDir, { recursive: true, force: true }); - }); - - it('round-trips contract json and dts', async () => { - const input = sampleContractIR(); - await writeRefSnapshot(refsDir, 'staging', input); - - const read = await readRefSnapshot(refsDir, 'staging'); - expect(read).toEqual(input); - }); - - it('writes canonical json bytes', async () => { - const input = sampleContractIR(); - await writeRefSnapshot(refsDir, 'staging', input); - - const raw = await fs.readFile(snapshotJsonPath(refsDir, 'staging'), 'utf-8'); - expect(raw).toBe(`${canonicalizeJson(input.contract)}\n`); - }); - - it('idempotently rewrites identical content with byte equality', async () => { - const input = sampleContractIR(); - await writeRefSnapshot(refsDir, 'staging', input); - const firstJson = await fs.readFile(snapshotJsonPath(refsDir, 'staging')); - const firstDts = await fs.readFile(snapshotDtsPath(refsDir, 'staging')); - - await writeRefSnapshot(refsDir, 'staging', input); - - const secondJson = await fs.readFile(snapshotJsonPath(refsDir, 'staging')); - const secondDts = await fs.readFile(snapshotDtsPath(refsDir, 'staging')); - expect(secondJson.equals(firstJson)).toBe(true); - expect(secondDts.equals(firstDts)).toBe(true); - }); - - it('overwrites with different contract content', async () => { - await writeRefSnapshot(refsDir, 'staging', sampleContractIR()); - const updated = sampleContractIR('b'); - await writeRefSnapshot(refsDir, 'staging', updated); - - const read = await readRefSnapshot(refsDir, 'staging'); - expect(read).toEqual(updated); - }); - - it('creates parent directories for slashed ref names', async () => { - const input = sampleContractIR(); - await writeRefSnapshot(refsDir, 'refs/staging/v1', input); - - expect(existsSync(snapshotJsonPath(refsDir, 'refs/staging/v1'))).toBe(true); - expect(existsSync(snapshotDtsPath(refsDir, 'refs/staging/v1'))).toBe(true); - expect(await readRefSnapshot(refsDir, 'refs/staging/v1')).toEqual(input); - }); -}); - -describe('readRefSnapshot', () => { - let refsDir: string; - - beforeEach(async () => { - refsDir = join( - tmpdir(), - `test-ref-snapshot-read-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - }); - - afterEach(async () => { - await fs.rm(refsDir, { recursive: true, force: true }); - }); - - it('returns null when no snapshot exists', async () => { - await writeRef(refsDir, 'staging', { hash: HASH_A, invariants: [] }); - - await expect(readRefSnapshot(refsDir, 'staging')).resolves.toBeNull(); - }); - - it('throws on malformed contract json', async () => { - const jsonPath = snapshotJsonPath(refsDir, 'staging'); - await fs.mkdir(refsDir, { recursive: true }); - await fs.writeFile(jsonPath, JSON.stringify({ not: 'a contract' })); - await fs.writeFile(snapshotDtsPath(refsDir, 'staging'), sampleContractDts('malformed')); - - await expect(readRefSnapshot(refsDir, 'staging')).rejects.toSatisfy((error) => { - expectInvalidRefFile(error, jsonPath); - return true; - }); - }); - - it('throws when contract json exists but contract dts is missing', async () => { - const jsonPath = snapshotJsonPath(refsDir, 'staging'); - const dtsPath = snapshotDtsPath(refsDir, 'staging'); - await fs.mkdir(refsDir, { recursive: true }); - await fs.writeFile(jsonPath, `${canonicalizeJson(sampleContractIR().contract)}\n`); - - await expect(readRefSnapshot(refsDir, 'staging')).rejects.toSatisfy((error) => { - expectInvalidRefFile(error, dtsPath); - return true; - }); - }); -}); - -describe('deleteRefSnapshot', () => { - let refsDir: string; - - beforeEach(async () => { - refsDir = join( - tmpdir(), - `test-ref-snapshot-delete-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - }); - - afterEach(async () => { - await fs.rm(refsDir, { recursive: true, force: true }); - }); - - it('deletes both snapshot files', async () => { - await writeRefSnapshot(refsDir, 'staging', sampleContractIR()); - await deleteRefSnapshot(refsDir, 'staging'); - - expect(existsSync(snapshotJsonPath(refsDir, 'staging'))).toBe(false); - expect(existsSync(snapshotDtsPath(refsDir, 'staging'))).toBe(false); - }); - - it('is idempotent when no snapshot exists', async () => { - await expect(deleteRefSnapshot(refsDir, 'missing')).resolves.toBeUndefined(); - }); - - it('is idempotent when only pointer ref exists', async () => { - await writeRef(refsDir, 'staging', { hash: HASH_A, invariants: [] }); - await expect(deleteRefSnapshot(refsDir, 'staging')).resolves.toBeUndefined(); - }); -}); diff --git a/scripts/migrate-migrations-layout.mjs b/scripts/migrate-migrations-layout.mjs index 07682a2f32..ffaad87d6e 100644 --- a/scripts/migrate-migrations-layout.mjs +++ b/scripts/migrate-migrations-layout.mjs @@ -18,6 +18,12 @@ * still carries a per-space `contract.json`/`contract.d.ts`: the pair is * written into the store under `refs/head.json`'s hash, then deleted. * + * For every `refs/.contract.json` + `refs/.contract.d.ts` pair + * (written by `ref set` / `--advance-ref` pre-TML-3072): the pair is written + * into the store under the sibling pointer `refs/.json`'s hash, then + * deleted. The pointer itself is never written — a ref is now only its + * pointer file, resolving its contract through the store by hash. + * * Every contract's inner `storage.storageHash` is asserted against the * hash it is being filed under before anything is written or deleted — * the whole run aborts on the first mismatch, before any root's plan is @@ -216,6 +222,43 @@ async function findSpaceHeadContractDirs(root) { return spaceDirs.sort(); } +/** + * Every `refs/` directory under a migrations root: the root itself and its + * immediate children (excluding the store), filtered to those with a `refs/` + * subdirectory. Same candidate set as {@link findSpaceHeadContractDirs}, but + * without requiring a sibling `contract.json` — a `refs/` directory can + * carry ref-paired snapshots independently of a per-space head contract. + */ +async function findRefsDirs(root) { + const candidates = [root, ...(await listSubdirs(root))].filter( + (dir) => basename(dir) !== CONTRACT_SNAPSHOTS_DIRNAME, + ); + + const refsDirs = []; + for (const dir of candidates) { + const refsDir = join(dir, 'refs'); + if (await isDirectory(refsDir)) refsDirs.push(refsDir); + } + return refsDirs.sort(); +} + +const CONTRACT_JSON_SUFFIX = '.contract.json'; + +/** + * Every `.contract.json` file under a `refs/` directory, found + * recursively (ref names may contain `/`, e.g. `staging/v1`). Returns paths + * relative to `refsDir`, sorted. + */ +async function findRefContractJsonPaths(refsDir) { + let entries; + try { + entries = await readdir(refsDir, { recursive: true, encoding: 'utf-8' }); + } catch { + return []; + } + return entries.filter((entry) => entry.endsWith(CONTRACT_JSON_SUFFIX)).sort(); +} + // --------------------------------------------------------------------------- // Planning (read-only; throws before anything is written or deleted) // --------------------------------------------------------------------------- @@ -369,15 +412,68 @@ async function planSpaceDir(dir) { } /** - * Build the full plan for one migrations root: read every package and - * space, asserting every inner hash against the value it is recorded - * under. Throws {@link MigrationLayoutAbortError} (or lets a production - * `readMigrationPackage` error propagate) on the first inconsistency — - * nothing is written or deleted while planning. + * Plan one ref-paired snapshot: `refsDir/.contract.json` (found at + * `contractJsonRelPath`, relative to `refsDir`) plus its sibling + * `refsDir/.contract.d.ts`. A `.contract.json` with no sibling pointer + * (`refsDir/.json`) is an abort-worthy inconsistency — it can't be + * filed under any hash. The pointer's `hash` is asserted against the + * contract's inner `storage.storageHash`, same style as + * {@link planContractSide} / {@link planSpaceDir}. The pointer is read but + * never written by this migrator — folding leaves it byte-identical. + */ +async function planRefPair(refsDir, contractJsonRelPath) { + const name = contractJsonRelPath.slice(0, -CONTRACT_JSON_SUFFIX.length); + const contractJsonPath = join(refsDir, contractJsonRelPath); + const pointerPath = join(refsDir, `${name}.json`); + + if (!(await pathExists(pointerPath))) { + throw new MigrationLayoutAbortError( + `${contractJsonPath} exists but its pointer ${pointerPath} does not. ` + + 'Aborting before writing or deleting anything.', + ); + } + const pointerBefore = await readFile(pointerPath, 'utf8'); + const hash = JSON.parse(pointerBefore).hash; + + const contractJson = await readJsonFile(contractJsonPath); + const actualHash = contractJson?.storage?.storageHash; + if (actualHash !== hash) { + throw new MigrationLayoutAbortError( + `${contractJsonPath}: inner storage.storageHash "${actualHash}" does not match ` + + `${pointerPath}'s hash "${hash}". Aborting before writing or deleting anything.`, + ); + } + + const dtsPath = join(refsDir, `${name}.contract.d.ts`); + if (!(await pathExists(dtsPath))) { + throw new MigrationLayoutAbortError( + `${contractJsonPath} exists but ${dtsPath} does not. ` + + 'Aborting before writing or deleting anything.', + ); + } + const contractDts = await readFile(dtsPath, 'utf8'); + + return { + pointerPath, + pointerBefore, + hash, + contractJson, + contractDts, + filesToDelete: [contractJsonPath, dtsPath], + }; +} + +/** + * Build the full plan for one migrations root: read every package, space, + * and ref-paired snapshot, asserting every inner hash against the value it + * is recorded under. Throws {@link MigrationLayoutAbortError} (or lets a + * production `readMigrationPackage` error propagate) on the first + * inconsistency — nothing is written or deleted while planning. */ export async function planMigrationsRoot(root) { const packageDirs = await findMigrationPackageDirs(root); const spaceDirs = await findSpaceHeadContractDirs(root); + const refsDirs = await findRefsDirs(root); const packages = []; for (const dir of packageDirs) { @@ -387,8 +483,14 @@ export async function planMigrationsRoot(root) { for (const dir of spaceDirs) { spaces.push(await planSpaceDir(dir)); } + const refs = []; + for (const refsDir of refsDirs) { + for (const contractJsonRelPath of await findRefContractJsonPaths(refsDir)) { + refs.push(await planRefPair(refsDir, contractJsonRelPath)); + } + } - return { root, packages, spaces }; + return { root, packages, spaces, refs }; } // --------------------------------------------------------------------------- @@ -401,6 +503,7 @@ function emptySummary(root) { root, packagesProcessed: 0, spacesProcessed: 0, + refsProcessed: 0, storeEntriesWritten: 0, storeEntriesAlreadyPresent: 0, filesDeleted: 0, @@ -478,6 +581,28 @@ export async function applyMigrationsRootPlan(plan) { summary.spacesProcessed++; } + for (const ref of plan.refs) { + const { written } = await writeContractSnapshot(root, ref.hash, { + contractJson: ref.contractJson, + contractDts: ref.contractDts, + }); + written ? summary.storeEntriesWritten++ : summary.storeEntriesAlreadyPresent++; + + for (const file of ref.filesToDelete) { + await rm(file); + summary.filesDeleted++; + } + + const pointerAfter = await readFile(ref.pointerPath, 'utf8'); + if (pointerAfter !== ref.pointerBefore) { + throw new MigrationLayoutAbortError( + `${ref.pointerPath} changed during migration; the migrator never writes this file.`, + ); + } + + summary.refsProcessed++; + } + return summary; } @@ -516,6 +641,7 @@ export function formatSummary(summaries) { lines.push(` ${relativeToRepoRoot(s.root)}`); lines.push( ` packages: ${s.packagesProcessed} spaces: ${s.spacesProcessed} ` + + `refs: ${s.refsProcessed} ` + `store writes: ${s.storeEntriesWritten} (${s.storeEntriesAlreadyPresent} already present) ` + `files deleted: ${s.filesDeleted} migrationHash verified: ${s.migrationHashesVerified}`, ); diff --git a/scripts/migrate-migrations-layout.test.mjs b/scripts/migrate-migrations-layout.test.mjs index 810035188e..54e853c8f0 100644 --- a/scripts/migrate-migrations-layout.test.mjs +++ b/scripts/migrate-migrations-layout.test.mjs @@ -133,6 +133,18 @@ async function buildFixture(root) { return { hashA, hashB, hashC, pkg1Dir, pkg2Dir, extPkgDir, extDir }; } +async function writeRefPair(refsDir, name, hash) { + await writeFile( + join(refsDir, `${name}.json`), + `${JSON.stringify({ hash, invariants: [] }, null, 2)}\n`, + ); + await writeFile( + join(refsDir, `${name}.contract.json`), + `${JSON.stringify(fakeContractJson(hash))}\n`, + ); + await writeFile(join(refsDir, `${name}.contract.d.ts`), CONTRACT_DTS); +} + async function withTempDir(fn) { const dir = await mkdtemp(join(tmpdir(), 'migrate-migrations-layout-test-')); try { @@ -341,6 +353,114 @@ describe('migrateMigrationsRoots — abort on inner hash mismatch', () => { }); }); +describe('migrateMigrationsRoots — ref-paired snapshot folding', () => { + it('folds ref-paired snapshots into the store, deletes the pair, leaves the pointer untouched', async () => { + await withTempDir(async (root) => { + const { hashA, extDir } = await buildFixture(root); + const hashD = fakeHash('contract-d'); + const refsDir = join(extDir, 'refs'); + // 'db' names hashA — already written to the store by pkg1's `to` + // side, so folding it is a write-if-absent hit. 'staging' names a + // hash no package uses, so folding it is the only writer. + await writeRefPair(refsDir, 'db', hashA); + await writeRefPair(refsDir, 'staging', hashD); + + const dbPointerBefore = await readFile(join(refsDir, 'db.json'), 'utf8'); + const stagingPointerBefore = await readFile(join(refsDir, 'staging.json'), 'utf8'); + + const [summary] = await migrateMigrationsRoots([root]); + + assert.equal(summary.refsProcessed, 2); + + // Pointers are untouched — folding never writes them. + assert.equal(await readFile(join(refsDir, 'db.json'), 'utf8'), dbPointerBefore); + assert.equal(await readFile(join(refsDir, 'staging.json'), 'utf8'), stagingPointerBefore); + + // The paired snapshot files are gone. + for (const name of [ + 'db.contract.json', + 'db.contract.d.ts', + 'staging.contract.json', + 'staging.contract.d.ts', + ]) { + await assert.rejects(readFile(join(refsDir, name)), { code: 'ENOENT' }); + } + + // hashD's store entry exists only because the ref fold wrote it. + const storeDir = join(root, 'snapshots', storageHashHexOf(hashD)); + assert.deepEqual( + JSON.parse(await readFile(join(storeDir, 'contract.json'), 'utf8')), + fakeContractJson(hashD), + ); + assert.equal(await readFile(join(storeDir, 'contract.d.ts'), 'utf8'), CONTRACT_DTS); + + // Second run: no ref pairs remain, so nothing to fold. + const [summary2] = await migrateMigrationsRoots([root]); + assert.equal(summary2.refsProcessed, 0); + assert.equal(summary2.storeEntriesWritten, 0); + assert.equal(summary2.filesDeleted, 0); + }); + }); + + it('aborts when a ref-paired contract.json has no sibling pointer', async () => { + await withTempDir(async (root) => { + const { extDir } = await buildFixture(root); + const refsDir = join(extDir, 'refs'); + const hash = fakeHash('orphan'); + await writeFile( + join(refsDir, 'orphan.contract.json'), + `${JSON.stringify(fakeContractJson(hash))}\n`, + ); + await writeFile(join(refsDir, 'orphan.contract.d.ts'), CONTRACT_DTS); + + await assert.rejects(migrateMigrationsRoots([root]), MigrationLayoutAbortError); + + await assert.rejects(readdir(join(root, 'snapshots')), { code: 'ENOENT' }); + await assert.doesNotReject(readFile(join(refsDir, 'orphan.contract.json'))); + await assert.doesNotReject(readFile(join(refsDir, 'orphan.contract.d.ts'))); + }); + }); + + it('aborts when the ref-paired contract hash disagrees with the pointer', async () => { + await withTempDir(async (root) => { + const { hashA, extDir } = await buildFixture(root); + const refsDir = join(extDir, 'refs'); + await writeRefPair(refsDir, 'db', hashA); + await writeFile( + join(refsDir, 'db.contract.json'), + `${JSON.stringify(fakeContractJson(fakeHash('wrong-hash')))}\n`, + ); + + await assert.rejects(migrateMigrationsRoots([root]), MigrationLayoutAbortError); + + await assert.rejects(readdir(join(root, 'snapshots')), { code: 'ENOENT' }); + await assert.doesNotReject(readFile(join(refsDir, 'db.json'))); + await assert.doesNotReject(readFile(join(refsDir, 'db.contract.json'))); + }); + }); + + it('aborts when the ref-paired contract.d.ts is missing', async () => { + await withTempDir(async (root) => { + const { extDir } = await buildFixture(root); + const refsDir = join(extDir, 'refs'); + const hash = fakeHash('nodts'); + await writeFile( + join(refsDir, 'nodts.json'), + `${JSON.stringify({ hash, invariants: [] }, null, 2)}\n`, + ); + await writeFile( + join(refsDir, 'nodts.contract.json'), + `${JSON.stringify(fakeContractJson(hash))}\n`, + ); + + await assert.rejects(migrateMigrationsRoots([root]), MigrationLayoutAbortError); + + await assert.rejects(readdir(join(root, 'snapshots')), { code: 'ENOENT' }); + await assert.doesNotReject(readFile(join(refsDir, 'nodts.contract.json'))); + }); + }); +}); + describe('discoverMigrationsRoots', () => { it('finds a deep consumer-project root via app/*/migration.json, not its space subdir', async () => { await withTempDir(async (start) => { @@ -387,6 +507,7 @@ describe('formatSummary', () => { root: '/repo/examples/demo/migrations', packagesProcessed: 2, spacesProcessed: 1, + refsProcessed: 2, storeEntriesWritten: 3, storeEntriesAlreadyPresent: 1, filesDeleted: 8, @@ -395,6 +516,7 @@ describe('formatSummary', () => { ]); assert.match(text, /processed 1 root\(s\)/); + assert.match(text, /refs: 2/); assert.match(text, /store writes: 3 \(1 already present\)/); assert.match(text, /TOTAL: 1 root\(s\), 3 store writes \(1 already present\), 8 files deleted/); }); diff --git a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.16-to-0.17/instructions.md b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.16-to-0.17/instructions.md index 419c667ca7..0460410a58 100644 --- a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.16-to-0.17/instructions.md +++ b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.16-to-0.17/instructions.md @@ -47,6 +47,34 @@ changes: - "./start-contract'" - "./end-contract'" anyMatch: true + - id: ref-paired-snapshots-moved-to-content-addressed-store + summary: | + Ref-paired contract snapshot files (`refs/.contract.json` / + `refs/.contract.d.ts`) are no longer written or read. A ref is now + only its pointer file, `refs/.json` (`{ hash, invariants }`); the + contract it names resolves through the same content-addressed store as + every migration graph node, `migrations/snapshots//contract.json` + + `contract.d.ts`, by that hash. Your extension repo's `migrations/refs/` + normally carries only the system `head.json` pointer, which was never + ref-paired — this only matters if your repo also carries named refs + (e.g. from testing `ref set` against the extension's own migrations + root). A pointer whose store entry is missing now fails with + `MIGRATION.CONTRACT_SNAPSHOT_MISSING` naming the expected hash and path, + rather than silently falling back to the migration graph. The same + one-shot migrator that folds per-package sibling snapshots (see the + entry above) also folds any existing `refs/.contract.json` / + `refs/.contract.d.ts` pairs: it write-if-absents the pair into the + store under the sibling pointer's `hash`, then deletes the pair — the + pointer file itself is read but never written, so it stays + byte-identical. A `.contract.json` with no sibling pointer, or whose + inner `storage.storageHash` disagrees with the pointer's `hash`, aborts + the whole run before anything is written or deleted. Run `node + scripts/migrate-migrations-layout.mjs ` + (same invocation as above; one run folds both migration-package and + ref-paired snapshots), then review the diff. + detection: + glob: "**/refs/*.contract.json" + anyMatch: true --- Also in this release, the ORM client's internal `throw new Error(...)` sites diff --git a/skills/prisma-next-migration-review/SKILL.md b/skills/prisma-next-migration-review/SKILL.md index 43a6afdc5a..89f0320337 100644 --- a/skills/prisma-next-migration-review/SKILL.md +++ b/skills/prisma-next-migration-review/SKILL.md @@ -85,7 +85,7 @@ These codes surface on `migration plan`, `ref set`, and `migrate` — not on `mi | Code | When | Meaning | Next move | |---|---|---|---| | `MIGRATION.HASH_NOT_IN_GRAPH` | `migration plan` (non-empty graph) or `ref set` | Resolved hash is not a node in the on-disk migration graph — typical when the default `db` ref points past the graph tip after dev-only `db update` cycles. | `migration plan --from ` (e.g. `--from production`); or realign the ref with `ref set db `. | -| `MIGRATION.SNAPSHOT_MISSING` | `migration plan` | Ref pointer exists but paired snapshot files (`.contract.json`) are absent. | `db update --advance-ref ` to repopulate, or `ref delete ` to clear the orphan pointer. | +| `MIGRATION.SNAPSHOT_MISSING` | `migration plan` | A named ref has no pointer file (`.json`), and the hash being resolved isn't a node in the migration graph either. | `ref set ` to create the ref, `db update --advance-ref ` to advance it, or pass a hash that is a graph node. | | `MIGRATION.MARKER_MISMATCH` | `migrate` (pre-DDL, before the runner) | Live DB marker hash is not a graph node — drift the offline planner cannot see. | `migration plan --from ` if the marker is canonical; `ref set db ` if the on-disk graph is canonical; investigate out-of-band applies. | | `MIGRATION.PATH_UNREACHABLE` | `migrate` (path resolution) | No migration path from the current marker to the resolved target in the on-disk graph. | Read the improved `fix` payload — it names `fromHash` / `targetHash` and suggests `migration plan --from --to `; run `migration list` to inspect the graph. | diff --git a/skills/prisma-next-migrations/SKILL.md b/skills/prisma-next-migrations/SKILL.md index 0947bd387c..077531dee3 100644 --- a/skills/prisma-next-migrations/SKILL.md +++ b/skills/prisma-next-migrations/SKILL.md @@ -99,20 +99,18 @@ pnpm prisma-next migrate --db $DATABASE_URL pnpm prisma-next db verify --db $DATABASE_URL ``` -The `db` ref is a named pointer at `migrations/app/refs/db.json` plus a **paired contract snapshot** (`db.contract.json`, `db.contract.d.ts`). It records which contract hash the project's dev database has been brought up to — the offline planner's stand-in for "where is my local DB?" without opening a connection at plan time. +The `db` ref is a named pointer at `migrations/app/refs/db.json` — just `{ hash, invariants }`. It records which contract hash the project's dev database has been brought up to — the offline planner's stand-in for "where is my local DB?" without opening a connection at plan time. The contract it names resolves through the shared content-addressed store at `migrations/snapshots//contract.json` by that hash, the same store every migration graph node resolves through. -**What `db init` / `db update` write.** When run against the project's default `--db` URL (no explicit `--db` flag), both commands implicitly advance the `db` ref and refresh its paired snapshot from the post-command contract IR. Override the ref name with `--advance-ref `. When you pass `--db `, ref advancement is suppressed unless `--advance-ref` is explicit — reconciling a different database is not the same as checkpointing this project's dev state. +**What `db init` / `db update` write.** When run against the project's default `--db` URL (no explicit `--db` flag), both commands implicitly advance the `db` ref: they write-if-absent the post-command contract IR into the snapshot store, then write the ref's pointer. Override the ref name with `--advance-ref `. When you pass `--db `, ref advancement is suppressed unless `--advance-ref` is explicit — reconciling a different database is not the same as checkpointing this project's dev state. -The on-disk layout mirrors migration bundle snapshots: +The on-disk layout is just the pointer: ```text migrations/app/refs/ -├── db.json # { "hash": "sha256:…", "invariants": [] } -├── db.contract.json # full contract IR at that hash -└── db.contract.d.ts # typed import handle +└── db.json # { "hash": "sha256:…", "invariants": [] } ``` -**First `migration plan` after dev iteration.** `migration plan` defaults `--from` to the `db` ref. When the on-disk migration graph is still **empty** and the `db` ref points at a non-null hash with a paired snapshot (typical after one or more `db update` cycles), the planner emits **two** bundles instead of one: +**First `migration plan` after dev iteration.** `migration plan` defaults `--from` to the `db` ref. When the on-disk migration graph is still **empty** and the `db` ref points at a non-null hash with a store entry (typical after one or more `db update` cycles), the planner emits **two** bundles instead of one: 1. Baseline: `null → from-hash` (introduces `from-hash` as a graph node) 2. Delta: `from-hash → current_contract` @@ -132,19 +130,19 @@ pnpm prisma-next ref set db pnpm prisma-next migration plan --name my_change ``` -If the paired snapshot is missing (`MIGRATION.SNAPSHOT_MISSING`), repopulate with `db update --advance-ref db` or delete the orphan pointer with `ref delete db`. +If the `db` ref's pointer is itself missing and the hash isn't a graph node either (`MIGRATION.SNAPSHOT_MISSING`), create it with `ref set db ` or advance it with `db update --advance-ref db`. **After plain `migrate`.** `migrate` does not implicitly advance the `db` ref (production-shaped commands stay explicit). The live marker advances while the ref may lag. Refresh with `db update` (no-op on DB when already current) or `migrate --advance-ref db` in the same invocation. **When to switch paths.** Use `db update` while the schema is in flux on a solo dev database. Switch to `migration plan` + `migrate` when the change needs a reviewable, replayable migration — typically before opening a PR or touching any shared environment. The `db` ref bridges the two: it captures dev iteration state on disk so the first formal plan knows where you left off. -**Graph-node rule (plan time).** Any hash used as a `from` end — explicit `--from`, default `db` ref, or ref name — must already be a node in the on-disk migration graph once the graph is non-empty. The auto-baseline two-bundle emission is the one exception: it applies only on an **empty** graph with a non-null ref-resolved `from` and an available paired snapshot. If you deleted the snapshot files or the ref pointer without the graph, plan refuses with `MIGRATION.SNAPSHOT_MISSING` instead. +**Graph-node rule (plan time).** Any hash used as a `from` end — explicit `--from`, default `db` ref, or ref name — must already be a node in the on-disk migration graph once the graph is non-empty. The auto-baseline two-bundle emission is the one exception: it applies only on an **empty** graph with a non-null ref-resolved `from` and an available store entry. If the ref's pointer is missing and the hash isn't a graph node either, plan refuses with `MIGRATION.SNAPSHOT_MISSING` instead. **Apply-time complement.** `migrate` reads the live marker before DDL. If the marker hash is not a graph node, the command refuses with `MIGRATION.MARKER_MISMATCH` — catching drift the offline planner cannot see. This is separate from `MIGRATION.MARKER_NOT_IN_HISTORY`, which fires later during the runner's graph walk when the marker is off the path being traversed. See `prisma-next-migration-review` for the full diagnostic catalog. `db` is a **default ref name**, not a reserved one. The framework overwrites it on the next dev cycle; you may `ref set db ` explicitly and accept that a subsequent `db update` replaces it when run against the default URL. -Canonical detail: [Migration System § Refs (paired contract snapshots)](../../docs/architecture%20docs/subsystems/7.%20Migration%20System.md#paired-contract-snapshots), [§ `migration plan`](../../docs/architecture%20docs/subsystems/7.%20Migration%20System.md#migration-plan), [§ Recovery affordances](../../docs/architecture%20docs/subsystems/7.%20Migration%20System.md#recovery-affordances), and [ADR 218 — Refs with paired contract snapshots and universal graph-node invariant](../../docs/architecture%20docs/adrs/ADR%20218%20-%20Refs%20with%20paired%20contract%20snapshots%20and%20universal%20graph-node%20invariant.md) (TML-2629). +Canonical detail: [Migration System § Contract resolution through the snapshot store](../../docs/architecture%20docs/subsystems/7.%20Migration%20System.md#contract-resolution-through-the-snapshot-store), [§ `migration plan`](../../docs/architecture%20docs/subsystems/7.%20Migration%20System.md#migration-plan), [§ Recovery affordances](../../docs/architecture%20docs/subsystems/7.%20Migration%20System.md#recovery-affordances), [ADR 218 — Refs with paired contract snapshots and universal graph-node invariant](../../docs/architecture%20docs/adrs/ADR%20218%20-%20Refs%20with%20paired%20contract%20snapshots%20and%20universal%20graph-node%20invariant.md) (TML-2629, its paired-snapshot part superseded — see the ADR's Status note), and [ADR 240 — Contract snapshots live in a content-addressed store](../../docs/architecture%20docs/adrs/ADR%20240%20-%20Contract%20snapshots%20live%20in%20a%20content-addressed%20store.md). ## Workflow — `db update` (quick path) diff --git a/skills/upgrade/prisma-next-upgrade/upgrades/0.16-to-0.17/instructions.md b/skills/upgrade/prisma-next-upgrade/upgrades/0.16-to-0.17/instructions.md index 7971d697a7..884a59d785 100644 --- a/skills/upgrade/prisma-next-upgrade/upgrades/0.16-to-0.17/instructions.md +++ b/skills/upgrade/prisma-next-upgrade/upgrades/0.16-to-0.17/instructions.md @@ -47,6 +47,32 @@ changes: - "./start-contract'" - "./end-contract'" anyMatch: true + - id: ref-paired-snapshots-moved-to-content-addressed-store + summary: | + Ref-paired contract snapshot files (`refs/.contract.json` / + `refs/.contract.d.ts`, written by `ref set` and `--advance-ref`) are + no longer written or read. A ref is now only its pointer file, + `refs/.json` (`{ hash, invariants }`); the contract it names + resolves through the same content-addressed store as every migration + graph node, `migrations/snapshots//contract.json` + `contract.d.ts`, + by that hash. This is a clean break: a pointer whose store entry is + missing now fails with `MIGRATION.CONTRACT_SNAPSHOT_MISSING` naming the + expected hash and path, rather than silently falling back to the + migration graph. The same one-shot migrator that folds per-package and + per-space sibling snapshots (see the entry above) also folds any + existing `refs/.contract.json` / `refs/.contract.d.ts` + pairs: it write-if-absents the pair into the store under the sibling + pointer's `hash`, then deletes the pair — the pointer file itself is + read but never written, so it stays byte-identical. A `.contract.json` + with no sibling pointer, or whose inner `storage.storageHash` disagrees + with the pointer's `hash`, aborts the whole run before anything is + written or deleted. Run `node scripts/migrate-migrations-layout.mjs + [migrationsRoot...]` (same invocation as above; one run folds both + migration-package and ref-paired snapshots), review the diff, then + re-run `prisma-next ref list` to confirm your refs are unaffected. + detection: + glob: "**/refs/*.contract.json" + anyMatch: true --- Also in this release, the ORM client's internal `throw new Error(...)` sites diff --git a/test/integration/test/cli.db-ref-advancement.e2e.test.ts b/test/integration/test/cli.db-ref-advancement.e2e.test.ts index 72b5197753..ed86c08854 100644 --- a/test/integration/test/cli.db-ref-advancement.e2e.test.ts +++ b/test/integration/test/cli.db-ref-advancement.e2e.test.ts @@ -1,5 +1,6 @@ -import { existsSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { contractSnapshotDir } from '@prisma-next/migration-tools/contract-snapshot-store'; import { timeouts, withDevDatabase } from '@prisma-next/test-utils'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { @@ -21,28 +22,27 @@ function refPointerPath(refsDir: string, name: string): string { return join(refsDir, `${name}.json`); } -function snapshotJsonPath(refsDir: string, name: string): string { - return join(refsDir, `${name}.contract.json`); +function migrationsDirFromRefsDir(refsDir: string): string { + return dirname(dirname(refsDir)); } -function snapshotDtsPath(refsDir: string, name: string): string { - return join(refsDir, `${name}.contract.d.ts`); +function refPointerHash(refsDir: string, name: string): string | undefined { + const pointerPath = refPointerPath(refsDir, name); + if (!existsSync(pointerPath)) return undefined; + return (JSON.parse(readFileSync(pointerPath, 'utf-8')) as { hash: string }).hash; } +// A ref now consists of just its pointer file; the contract bytes resolve +// through the content-addressed store keyed by the pointer's hash. function refFilesExist(refsDir: string, name: string): boolean { - return ( - existsSync(refPointerPath(refsDir, name)) && - existsSync(snapshotJsonPath(refsDir, name)) && - existsSync(snapshotDtsPath(refsDir, name)) - ); + const hash = refPointerHash(refsDir, name); + if (hash === undefined) return false; + const storeDir = contractSnapshotDir(migrationsDirFromRefsDir(refsDir), hash); + return existsSync(join(storeDir, 'contract.json')) && existsSync(join(storeDir, 'contract.d.ts')); } function refFilesAbsent(refsDir: string, name: string): boolean { - return ( - !existsSync(refPointerPath(refsDir, name)) && - !existsSync(snapshotJsonPath(refsDir, name)) && - !existsSync(snapshotDtsPath(refsDir, name)) - ); + return !existsSync(refPointerPath(refsDir, name)); } function noRefFilesUnder(refsDir: string): boolean { @@ -50,10 +50,7 @@ function noRefFilesUnder(refsDir: string): boolean { return true; } const entries = readdirSync(refsDir, { recursive: true }); - return !entries.some((entry) => { - const fileName = String(entry); - return fileName.endsWith('.json') && !fileName.includes('.contract.'); - }); + return !entries.some((entry) => String(entry).endsWith('.json')); } withTempDir(({ createTempDir }) => { diff --git a/test/integration/test/cli.migrate-ref-advancement.e2e.test.ts b/test/integration/test/cli.migrate-ref-advancement.e2e.test.ts index afede3949e..ffcdbcaaf4 100644 --- a/test/integration/test/cli.migrate-ref-advancement.e2e.test.ts +++ b/test/integration/test/cli.migrate-ref-advancement.e2e.test.ts @@ -1,6 +1,6 @@ import { execFile } from 'node:child_process'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { promisify } from 'node:util'; import { createContractEmitCommand } from '@prisma-next/cli/commands/contract-emit'; import type { MigrateResult } from '@prisma-next/cli/commands/migrate'; @@ -99,28 +99,33 @@ function refPointerPath(refsDir: string, name: string): string { return join(refsDir, `${name}.json`); } -function snapshotJsonPath(refsDir: string, name: string): string { - return join(refsDir, `${name}.contract.json`); +function migrationsDirFromRefsDir(refsDir: string): string { + return dirname(dirname(refsDir)); } -function snapshotDtsPath(refsDir: string, name: string): string { - return join(refsDir, `${name}.contract.d.ts`); +function refPointerHash(refsDir: string, name: string): string | undefined { + const pointerPath = refPointerPath(refsDir, name); + if (!existsSync(pointerPath)) return undefined; + return (JSON.parse(readFileSync(pointerPath, 'utf-8')) as { hash: string }).hash; } +function storeContractJsonPath(refsDir: string, name: string): string { + const hash = refPointerHash(refsDir, name); + if (hash === undefined) throw new Error(`ref "${name}" has no pointer`); + return join(contractSnapshotDir(migrationsDirFromRefsDir(refsDir), hash), 'contract.json'); +} + +// A ref now consists of just its pointer file; the contract bytes resolve +// through the content-addressed store keyed by the pointer's hash. function refFilesExist(refsDir: string, name: string): boolean { - return ( - existsSync(refPointerPath(refsDir, name)) && - existsSync(snapshotJsonPath(refsDir, name)) && - existsSync(snapshotDtsPath(refsDir, name)) - ); + const hash = refPointerHash(refsDir, name); + if (hash === undefined) return false; + const storeDir = contractSnapshotDir(migrationsDirFromRefsDir(refsDir), hash); + return existsSync(join(storeDir, 'contract.json')) && existsSync(join(storeDir, 'contract.d.ts')); } function refFilesAbsent(refsDir: string, name: string): boolean { - return ( - !existsSync(refPointerPath(refsDir, name)) && - !existsSync(snapshotJsonPath(refsDir, name)) && - !existsSync(snapshotDtsPath(refsDir, name)) - ); + return !existsSync(refPointerPath(refsDir, name)); } function noRefFilesUnder(refsDir: string): boolean { @@ -262,9 +267,9 @@ withTempDir(({ createTempDir }) => { ]); expect(refFilesExist(refsDir, 'staging')).toBe(true); - expect(JSON.parse(readFileSync(snapshotJsonPath(refsDir, 'staging'), 'utf-8'))).toEqual( - JSON.parse(readFileSync(bundleEndContract, 'utf-8')), - ); + expect( + JSON.parse(readFileSync(storeContractJsonPath(refsDir, 'staging'), 'utf-8')), + ).toEqual(JSON.parse(readFileSync(bundleEndContract, 'utf-8'))); }); }, timeouts.spinUpPpgDev, @@ -394,7 +399,10 @@ withTempDir(({ createTempDir }) => { '--no-color', ]); const firstPointer = readFileSync(refPointerPath(refsDir, 'staging'), 'utf-8'); - const firstSnapshot = readFileSync(snapshotJsonPath(refsDir, 'staging'), 'utf-8'); + const firstStoreContract = readFileSync( + storeContractJsonPath(refsDir, 'staging'), + 'utf-8', + ); await runMigrate(testDir, [ '--config', @@ -405,7 +413,9 @@ withTempDir(({ createTempDir }) => { ]); expect(readFileSync(refPointerPath(refsDir, 'staging'), 'utf-8')).toBe(firstPointer); - expect(readFileSync(snapshotJsonPath(refsDir, 'staging'), 'utf-8')).toBe(firstSnapshot); + expect(readFileSync(storeContractJsonPath(refsDir, 'staging'), 'utf-8')).toBe( + firstStoreContract, + ); }); }, timeouts.spinUpPpgDev, diff --git a/test/integration/test/cli.migration-plan-ref-aware.e2e.test.ts b/test/integration/test/cli.migration-plan-ref-aware.e2e.test.ts index 7a81da2740..8a471e9e2b 100644 --- a/test/integration/test/cli.migration-plan-ref-aware.e2e.test.ts +++ b/test/integration/test/cli.migration-plan-ref-aware.e2e.test.ts @@ -1,12 +1,6 @@ -import { - copyFileSync, - existsSync, - readFileSync, - statSync, - unlinkSync, - writeFileSync, -} from 'node:fs'; +import { copyFileSync, existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { contractSnapshotDir } from '@prisma-next/migration-tools/contract-snapshot-store'; import { timeouts, withDevDatabase } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { withTempDir } from './utils/cli-test-helpers'; @@ -73,7 +67,9 @@ interface PlanJsonResult { readonly dir?: string; readonly baselineDir?: string; readonly noOp?: boolean; + readonly code?: string; readonly meta?: { readonly code?: string }; + readonly why?: string; readonly fix?: string; } @@ -96,11 +92,11 @@ function readDbRefHash(ctx: JourneyContext): string { } function dbRefSnapshotExists(ctx: JourneyContext): boolean { - const dir = refsDir(ctx); + const storeDir = contractSnapshotDir(join(ctx.testDir, 'migrations'), readDbRefHash(ctx)); return ( - existsSync(join(dir, 'db.contract.json')) && - existsSync(join(dir, 'db.contract.d.ts')) && - statSync(join(dir, 'db.contract.json')).size > 0 + existsSync(join(storeDir, 'contract.json')) && + existsSync(join(storeDir, 'contract.d.ts')) && + statSync(join(storeDir, 'contract.json')).size > 0 ); } @@ -183,14 +179,12 @@ export default defineConfig({ return ctx; } +// Simulates a store entry that has gone missing (e.g. `migrations/snapshots/` +// was not restored from version control) while the db ref's pointer still +// exists and still names that hash. function stripDbSnapshot(ctx: JourneyContext): void { - const dir = refsDir(ctx); - for (const name of ['db.contract.json', 'db.contract.d.ts']) { - const path = join(dir, name); - if (existsSync(path)) { - unlinkSync(path); - } - } + const storeDir = contractSnapshotDir(join(ctx.testDir, 'migrations'), readDbRefHash(ctx)); + rmSync(storeDir, { recursive: true, force: true }); } async function withJourney( @@ -281,7 +275,7 @@ withTempDir(({ createTempDir }) => { ); it( - 'explicit --from staging ref resolves via paired snapshot', + 'explicit --from staging ref resolves through the snapshot store', async () => { await withDevDatabase(async ({ connectionString }) => { await withJourney(createTempDir, connectionString, async (ctx) => { @@ -386,7 +380,7 @@ withTempDir(({ createTempDir }) => { ); it( - 'refuses snapshot-missing when db pointer exists without paired snapshot', + 'refuses contract-snapshot-missing when db pointer exists but its store entry is gone', async () => { await withDevDatabase(async ({ connectionString }) => { await withJourney(createTempDir, connectionString, async (ctx) => { @@ -396,38 +390,9 @@ withTempDir(({ createTempDir }) => { const plan = await runMigrationPlan(ctx, ['--json']); expect(plan.exitCode).toBe(2); const err = parseJsonOutput(plan); - expect(err.meta?.code).toBe('MIGRATION.SNAPSHOT_MISSING'); - expect(err.fix).toMatch(/db update --advance-ref db/); - }); - }); - }, - timeouts.spinUpPpgDev, - ); - - it( - 'legacy db pointer without snapshot falls back to bundle source when hash is in graph', - async () => { - await withDevDatabase(async ({ connectionString }) => { - await withJourney(createTempDir, connectionString, async (ctx) => { - expect((await runContractEmit(ctx)).exitCode).toBe(0); - expect((await runDbInit(ctx)).exitCode).toBe(0); - - swapContract(ctx, 'contract-additive'); - expect((await runContractEmit(ctx)).exitCode).toBe(0); - const plan0 = await runAutoBaselinePlanAndEmit(ctx, ['--name', 'init', '--json']); - expect(plan0.exitCode).toBe(0); - expect((await runMigrate(ctx)).exitCode).toBe(0); - - stripDbSnapshot(ctx); - - swapContract(ctx, 'contract-phone'); - expect((await runContractEmit(ctx)).exitCode).toBe(0); - - const plan = await runMigrationPlan(ctx, ['--name', 'legacy-fallback', '--json']); - expect(plan.exitCode).toBe(0); - const planJson = parseJsonOutput(plan); - expect(planJson.baselineDir).toBeUndefined(); - expect(listAppMigrationBundleDirs(ctx)).toHaveLength(3); + expect(err.code).toBe('CLI.FILE_NOT_FOUND'); + expect(err.why).toMatch(/missing its contract snapshot/); + expect(err.fix).toMatch(/migrations\/snapshots/); }); }); }, diff --git a/test/integration/test/cli.ref-snapshot-integration.e2e.test.ts b/test/integration/test/cli.ref-pointer-integration.e2e.test.ts similarity index 82% rename from test/integration/test/cli.ref-snapshot-integration.e2e.test.ts rename to test/integration/test/cli.ref-pointer-integration.e2e.test.ts index bcb18dbe64..28d9e94890 100644 --- a/test/integration/test/cli.ref-snapshot-integration.e2e.test.ts +++ b/test/integration/test/cli.ref-pointer-integration.e2e.test.ts @@ -86,28 +86,29 @@ function refPointerPath(refsDir: string, name: string): string { return join(refsDir, `${name}.json`); } -function snapshotJsonPath(refsDir: string, name: string): string { - return join(refsDir, `${name}.contract.json`); +function refPointerHash(refsDir: string, name: string): string | undefined { + const pointerPath = refPointerPath(refsDir, name); + if (!existsSync(pointerPath)) return undefined; + return (JSON.parse(readFileSync(pointerPath, 'utf-8')) as { hash: string }).hash; } -function snapshotDtsPath(refsDir: string, name: string): string { - return join(refsDir, `${name}.contract.d.ts`); +function storeContractJsonPath(testDir: string, refsDir: string, name: string): string { + const hash = refPointerHash(refsDir, name); + if (hash === undefined) throw new Error(`ref "${name}" has no pointer`); + return join(contractSnapshotDir(join(testDir, 'migrations'), hash), 'contract.json'); } -function refFilesExist(refsDir: string, name: string): boolean { - return ( - existsSync(refPointerPath(refsDir, name)) && - existsSync(snapshotJsonPath(refsDir, name)) && - existsSync(snapshotDtsPath(refsDir, name)) - ); +// A ref now consists of just its pointer file; the contract bytes resolve +// through the content-addressed store keyed by the pointer's hash. +function refFilesExist(testDir: string, refsDir: string, name: string): boolean { + const hash = refPointerHash(refsDir, name); + if (hash === undefined) return false; + const storeDir = contractSnapshotDir(join(testDir, 'migrations'), hash); + return existsSync(join(storeDir, 'contract.json')) && existsSync(join(storeDir, 'contract.d.ts')); } function refFilesAbsent(refsDir: string, name: string): boolean { - return ( - !existsSync(refPointerPath(refsDir, name)) && - !existsSync(snapshotJsonPath(refsDir, name)) && - !existsSync(snapshotDtsPath(refsDir, name)) - ); + return !existsSync(refPointerPath(refsDir, name)); } async function seedPlannedMigration( @@ -130,7 +131,7 @@ async function seedPlannedMigration( } withTempDir(({ createTempDir }) => { - describe('ref snapshot integration (e2e)', () => { + describe('ref pointer integration (e2e)', () => { let consoleOutput: string[]; let consoleErrors: string[]; let cleanupMocks: () => void; @@ -176,7 +177,7 @@ withTempDir(({ createTempDir }) => { } it( - 'ref set writes paired snapshots, ref list ignores them, ref delete removes all files', + 'ref set writes only the pointer, ref list lists it, ref delete removes the pointer but leaves the store entry', async () => { await withDevDatabase(async ({ connectionString }) => { const { testDir, configPath, toHash } = await seedPlannedMigration( @@ -197,24 +198,22 @@ withTempDir(({ createTempDir }) => { configPath, ]); expect(setResult.exitCode, 'ref set exit code').toBe(0); - expect(refFilesExist(refsDir, 'staging')).toBe(true); - expect(JSON.parse(readFileSync(snapshotJsonPath(refsDir, 'staging'), 'utf-8'))).toEqual( - JSON.parse(readFileSync(bundleEndContract, 'utf-8')), - ); + expect(refFilesExist(testDir, refsDir, 'staging')).toBe(true); + expect( + JSON.parse(readFileSync(storeContractJsonPath(testDir, refsDir, 'staging'), 'utf-8')), + ).toEqual(JSON.parse(readFileSync(bundleEndContract, 'utf-8'))); const listResult = await runRef(testDir, ['list', '--config', configPath]); expect(listResult.exitCode, 'ref list exit code').toBe(0); expect(listResult.output).toContain('staging'); - expect(listResult.output).not.toContain('staging.contract.json'); - expect( - readdirSync(refsDir).filter( - (name) => name.endsWith('.json') && !name.includes('.contract.'), - ), - ).toEqual(['staging.json']); + expect(readdirSync(refsDir).filter((name) => name.endsWith('.json'))).toEqual([ + 'staging.json', + ]); const deleteResult = await runRef(testDir, ['delete', 'staging', '--config', configPath]); expect(deleteResult.exitCode, 'ref delete exit code').toBe(0); expect(refFilesAbsent(refsDir, 'staging')).toBe(true); + expect(existsSync(bundleEndContract)).toBe(true); }); }, timeouts.spinUpPpgDev,