tml-3059: deduplicate migration contracts into a content-addressed migrations/snapshots/ store - #1018
Merged
Merged
Conversation
Deduplicate migration contract snapshots into a content-addressed migrations/snapshots/ store. Spec and plan carry the fully-settled design (operator discussion 2026-07-20); zero design freedom for implementation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
D1: contract-snapshot-layout.ts (framework-components/control) — storageHashHex and the json/types specifier builders; single source of the snapshots dirname and specifier shapes. D2: contract-snapshot-store.ts (migration-tools) — write-if-absent store keyed by storage hash, canonicalizing JSON to match the current snapshot writer, with tolerant + strict readers and two structured errors (MIGRATION.CONTRACT_SNAPSHOT_MISSING / _HASH_MISMATCH). Tests-first for both modules (TC1-TC3). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Renderer specifier switch (spec D3): the three renderers contractImports()
(postgres, sqlite, mongo) now build specifiers via
contractSnapshotJsonSpecifier/contractSnapshotTypesSpecifier from
@prisma-next/framework-components/control instead of hardcoding
./end-contract.json etc. RenderMigrationMeta gains a required
snapshotsImportPath: string.
Threading approach for #meta vs a separate param: the three
planner-produced classes (TypeScriptRenderablePostgresMigration,
TypeScriptRenderableSqliteMigration, PlannerProducedMongoMigration) each
hold #meta: MigrationMeta and expose it verbatim via describe().
migration-tools buildMigrationArtifacts validates that return value
against MigrationMetaSchema = type({ from, to }) and writes it straight
into migration.json (from/to only). Adding snapshotsImportPath to
MigrationMeta would have leaked a render-only concern into that
persisted metadata and the arktype-validated describe() contract. Each
class instead takes a dedicated snapshotsImportPath constructor
parameter and private field, passed separately into the render-meta
object alongside from/to. SqlMigrationPlannerPlanOptions (family-sql)
and the framework MigrationPlanner.plan()/MigrationScaffoldContext
(framework-components) gained the same required field so postgres,
sqlite, and mongo planners can read options.snapshotsImportPath and
context.snapshotsImportPath and forward it. The framework interface
change is required, not just the family-sql one: TypeScript method
implements check is contravariant on parameters even for method-shorthand
members once a genuinely new required property is added (verified by
running tsc, not assumed) — leaving the framework plan() signature
unchanged broke every concrete planners implements MigrationPlanner<...>
check. The one caller that goes through the loose framework interface
without a migration-package directory (plan-from-diff.ts db-init and
db-update reconciliation path, which wraps its result as a plain
MigrationPlan and never calls renderTypeScript()) passes a documented
empty-string placeholder.
D4: DataTransformCall.importRequirements() (postgres) no longer declares
an endContract default import. buildImports always prepends
contractImports(meta), which already emits it, and a DataTransformCall
only ever renders inside that scaffold. One comment at the deletion site
records the invariant.
E4 outcome: render-imports.ts needed a fix. When from === to,
contractSnapshotJsonSpecifier and contractSnapshotTypesSpecifier collapse
the end and start snapshot paths onto the same two module specifiers. The
named-import path already merged Contract as End plus Contract as Start
into one import type line (pre-existing distinct-alias handling). The
default-import path did not: mergeRequirementIntoGroup threw a
Conflicting default imports error for two different default symbols
(endContract, startContract) on the same specifier. Fixed by replacing
the single defaultSymbol/defaultTypeOnly fields with a Map from symbol to
typeOnly; a module with more than one distinct default symbol now renders
one import line per symbol (JS permits re-importing a module under
different default bindings), sorted alphabetically, with any named
bindings following as their own line. Pinned with a new
render-imports.test.ts case plus a dedicated from === to (E4) test in
each of the three renderer test files, verifying the merged block: two
import lines for endContract and startContract off the same contract.json
specifier, one import type line combining Contract as End and Contract as
Start off the same contract specifier.
Tests travel with the change: pinned specifier assertions across all
three renderer test suites now use real sha256 64-hex hashes (the new
storageHashHex validation rejects short placeholders) and a
snapshotsImportPath literal; every .plan / .emptyMigration / direct
planner-produced-class constructor call site across the target and
adapter test suites (postgres, sqlite, mongo) now supplies
snapshotsImportPath; the postgres op-factory-call tests assert
DataTransformCall.importRequirements() returns only the placeholder
facade entry and that a full rendered migration containing a data
transform imports endContract exactly once; the four render-typescript
roundtrip e2e tests (postgres, sqlite, mongo, which execute the rendered
migration.ts via tsx) now write their contract fixtures into a
snapshots/<hex>/contract.{json,ts} layout instead of sibling
{start,end}-contract.* files.
Gates green: framework-components, ts-render, migration-tools,
family-sql, target-postgres, adapter-postgres, target-sqlite,
adapter-sqlite, target-mongo, adapter-mongo (typecheck and test each);
pnpm lint:deps and node scripts/lint-casts.mjs (delta -1) both clean.
@prisma-next/cli typecheck is red with exactly the two expected
snapshotsImportPath-missing errors, in migration-new.ts and
migration-plan.ts — owned by T3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…to the snapshot store (T3)
Replaces sibling-file writes (end-contract.*/start-contract.*) with
writeContractSnapshot(migrationsDir, hash, {contractJson, contractDts})
calls keyed by storage hash, in migration-plan.ts (destination, baseline,
delta, and ref-paired start legs), migration-new.ts (destination), and
the contract-space seed phase (emit-contract-space-artefacts.ts, keyed
by headRef.hash). Deletes the predecessor sibling-copy block entirely:
the predecessor end contract is already in the store under its own
hash, so migration plan/new no longer re-copy it. Threads the new
required snapshotsImportPath field into every planner.plan()/
emptyMigration() call site via snapshotsImportPathFrom(packageDir,
migrationsDir), resolving the T2 CLI-red state.
Also reserves the snapshots/ directory name against phantom-space
enumeration (space-layout.ts RESERVED_SPACE_SUBDIR_NAMES,
verify-contract-spaces.ts listContractSpaceDirectories filter) and
threads snapshotsImportPath into pgvector/e2e/integration test call
sites the SqlMigrationPlannerPlanOptions/MongoMigrationPlanner type
change from T2 left red outside the CLI package, so that the
workspace-wide pnpm typecheck gate for this task is genuinely green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
… store (T4)
io.ts: readMigrationPackage/readMigrationsDir take a required
{ migrationsDir } option; endContractJson now comes from
readContractSnapshotJsonTolerant(migrationsDir, metadata.to) instead of
a sibling end-contract.json, with the tolerant missing/unparseable/null
semantics preserved exactly. readMigrationPackageRaw is unchanged (it
deliberately never populates endContractJson).
aggregate/aggregate.ts: readGraphNodeEndContract now reads
readContractSnapshotJson/readContractSnapshotDts keyed by the graph
nodes hash instead of a package directorys end-contract.* files.
createAggregateContractSpace takes a new migrationsDir field, threaded
through resolveContractAt/resolveGraphNodeContractAt. provenance:
graph-node and its sourceDir field are unchanged (plan-resolution.ts
still consumes sourceDir; T5 removes it alongside the CLI readers).
aggregate/loader.ts: loadExtensionSpaces contract resolution
(readRawContractDeferred) now reads readContractSnapshotJson(
migrationsDir, headRef.hash) instead of the deleted
read-contract-space-contract.ts. When headRef is null there is no hash
to resolve against, so the deferred read throws descriptively; this
still surfaces as the existing contractUnreadable integrity problem
when checkContracts runs, and headRefMissing continues to fire
independently. read-contract-space-contract.ts is deleted (it had no
other callers) along with its export from exports/spaces.ts.
compute-extension-space-apply-path.ts: threads migrationsDir into its
readMigrationsDir call (the param was already available as
projectMigrationsDir).
contract-snapshot-store.ts: readContractSnapshotJsonTolerant now also
tolerates a malformed storageHash (previously it called
contractSnapshotDir, which validates the hash format, outside its
try/catch, so a non-sha256:<64hex> hash threw instead of resolving to
undefined). This was needed for readMigrationPackage to preserve
runner-independence against the many existing test fixtures across the
repo that use short placeholder hashes as metadata.to the write path
(writeContractSnapshot) keeps strict validation.
Every readMigrationPackage/readMigrationsDir/createAggregateContractSpace
call site in the repo (migration-tools and CLI package tests, the two
integration e2e tests) is threaded with an explicit migrationsDir/store
root; none of them derive it by walking up from a package directory.
Also fixed three CLI test fixtures (migrate-show.test.ts,
migrate-to-contract.test.ts, migration-read-commands-parity.test.ts)
that wrote extension contracts as sibling files instead of into the
snapshot store, and one fixture missing a head ref that the old
sibling-file model did not require.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
… sourceDir (T5) Rewires db sign, db update, ref set, and migration check to read migration end-contracts through the contract-snapshot store instead of the per-package end-contract.json sibling that T3 stopped writing: - db-sign.ts / db-update.ts / ref.ts: replace the sibling readFile(join(dirPath, "end-contract.json")) reads with readContractSnapshotJson(migrationsDir, hash). db-update.ts also repoints contractJsonPathForSnapshot at the store contract.json (verified: readContractIR only uses that path to derive the sibling .d.ts path by string replacement, which the store satisfies exactly). - migration-check.ts: checkSnapshotConsistency now reads the store entry for pkg.metadata.to. Absent entry is not an issue (runner independence, ADR 199); present-but-mismatched keeps firing PN-MIG-CHECK-005; unparseable keeps firing PN-MIG-CHECK-006. The check is now async (store reads are async), so runMigrationCheck / checkSpace and their callers/tests are threaded with await. PN-MIG-CHECK-002s required-file list was already migration.json + ops.json only no change needed there. - contract-at-errors.ts: added an explicit MIGRATION.CONTRACT_SNAPSHOT_MISSING case (the code the store now throws for a missing contract) instead of relying on the MIGRATION.FILE_MISSING branch, which still names end-contract.json and is no longer reachable for a missing contract (io.ts only throws FILE_MISSING for migration.json/ops.json now). - plan-resolution.ts: removed the graph-node arms sourceDir field (confirmed via repo-wide grep that migration-plan.ts, the only consumer, already stopped reading it in T3) and stopped reading at.sourceDir. Correspondingly removed sourceDir from ContractAtResult (aggregate/types.ts) and resolveGraphNodeContractAt (aggregate.ts), which T4 had deliberately kept for this task. Updated CLI unit tests, the ref/migration-check/db-update e2e journeys, and the aggregate contractAt tests to seed the store instead of sibling files and to drop sourceDir assertions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…d storage hashes (T5) The sqlite and postgres db-init-update.cli(.integration).test.ts fixtures seeded an extension contract space via emitContractSpaceArtefacts with a placeholder storage.storageHash (e.g. sha256:ext-contract-v1) that is not a well-formed sha256:<64hex> value. That now correctly fails the contract-snapshot stores storageHashHex validation instead of silently truncating a bogus directory name, so both suites started failing 7 tests total once the store enforces the format. Fix: give buildExtensionContract stable 64-hex-char storage hashes (sha256:<hex digit repeated 64 times>, distinct per test file and per contract version) instead of the placeholder string. headRef.hash was already derived from the same contract.storage.storageHash value in both files, so no further change was needed to keep the store entry discoverable by the hash the aggregate loader reads back. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
… fixtures glob (T6) D8 of the migration-snapshot dedup slice. The store now lives at migrations/snapshots/<hex>/, so the per-package sibling files (end-contract.*, start-contract.*) no longer exist to hygiene-check, clean up, or diff. - hygiene-gitattributes.ts: drop the four end-/start-contract entries from ARTEFACT_FILENAMES; add two migrations-root-anchored linguist-generated lines for the store (migrations/snapshots/**/contract.json, contract.d.ts), since the existing entries are schema-dir-relative and cannot reach the store. - reinit-cleanup.ts: drop the same four entries from its own ARTEFACT_FILENAMES, keeping the schema-dir list in lockstep with hygiene-gitattributes.ts as documented. Reinit cleanup only ever touches the schema directory (findStaleArtefacts has one caller, passed schemaDir, never migrationsDir) — it never reaches into migrations/ today, so no snapshots/ cleanup was added; that is outside this command's current scope, not an oversight. - package.json: drop the now-redundant start-contract.*/end-contract.* fixtures:check pathspecs; **/contract.* already matches snapshots/<hex>/contract.json and contract.d.ts. - output.ts: fix a stale FR9.1 doc comment listing start-/end-contract among the files reinit can delete. Also adds a unit test (TC12) confirming listContractSpaceDirectories skips a snapshots/ directory, verifying the D7 reserved-name filter that landed earlier. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…apshot store layout (T7) Journey-helper store-import fix: injectMigrationSqlDbSetup in test/integration/test/utils/journey-test-helpers.ts previously hard-coded ./end-contract.json, which no longer exists under the new migrations/snapshots/<hex>/ layout. It now captures the CLI-emitted scaffolds actual endContract specifier (a store path) via a regex group and re-injects an import from that same path, throwing a clear error if the scaffold shape ever changes instead of silently emitting a broken file. Hand-authored migration.ts templates in invariant-routing.e2e.test.ts (Journeys S and T), migration-round-trip.e2e.test.ts, and invariant-routing.mongo.e2e.test.ts (Journey Q) hard-coded ./end-contract.json; each now computes the real store path via storageHashHex(<hash>). The mongo Journey Q template also relied on a synthetic, content-mismatched destination hash that the old path-keyed sibling-file layout tolerated but the new content-addressed store cannot (a store entry is keyed by the real hash of its content); fixed by adding a genuinely distinct third mongo contract fixture (contract-branch-b.ts) so branch B gets a real store entry instead of a fabricated one. mongo-migration.e2e.test.ts asserted byte-identical end-contract.json/ .d.ts siblings; updated to read the store entry via storageHashHex and compare parsed JSON (the store canonicalizes to compact/sorted-key JSON, while the emitted project artifact stays pretty-printed, so byte comparison no longer applies). postgres-issue-planner.test.ts used placeholder hashes (sha256:aaa / sha256:bbb) that fail storageHashHex validation; replaced with valid 64-hex-char hashes. New regression tests: - Runner independence (AC6): new tests in packages/3-targets/6-adapters/postgres/test/migrations/runner-independence.integration.test.ts and .../sqlite/test/migrations/runner-independence.test.ts build a two-migration app-space chain via materialiseMigrationPackage + writeContractSnapshot, apply the first migration, delete migrations/snapshots/ entirely, then read and apply the second migration via the real readMigrationPackage reader and target runner — proving apply depends on migration.json + ops.json only, never on the snapshot store, and that a package directory legitimately contains only those two files. - Extension-space store resolution (AC8): extended cli.migrate-external-space.e2e.test.ts to assert the seed phase (run via migration plan) populates migrations/snapshots/<hex>/contract.json for the external spaces head hash instead of a per-space sibling copy, and that the subsequent migrate command only succeeds by resolving that same store entry through the aggregate loader. No src/ production files were changed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Contributor
|
Important Review skippedToo many files! This PR contains 420 files, which is 320 over the limit of 100. To get a review, narrow the scope: Upgrade to a paid plan to raise the limit. Usage-priced reviews support at most 300 files. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yml Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (2)
📒 Files selected for processing (637)
You can disable this status message by setting the ✨ Finishing Touches🧪 Generate unit tests (beta)
|
@prisma-next/extension-author-tools
@prisma-next/mongo-runtime
@prisma-next/family-mongo
@prisma-next/sql-runtime
@prisma-next/family-sql
@prisma-next/extension-arktype-json
@prisma-next/middleware-cache
@prisma-next/mongo
@prisma-next/extension-paradedb
@prisma-next/extension-pgvector
@prisma-next/extension-postgis
@prisma-next/postgres
@prisma-next/sql-orm-client
@prisma-next/sqlite
@prisma-next/extension-supabase
@prisma-next/target-mongo
@prisma-next/adapter-mongo
@prisma-next/driver-mongo
@prisma-next/contract
@prisma-next/utils
@prisma-next/config
@prisma-next/errors
@prisma-next/framework-components
@prisma-next/operations
@prisma-next/ts-render
@prisma-next/contract-authoring
@prisma-next/ids
@prisma-next/psl-parser
@prisma-next/psl-printer
@prisma-next/cli
@prisma-next/cli-telemetry
@prisma-next/config-loader
@prisma-next/emitter
@prisma-next/language-server
@prisma-next/migration-tools
prisma-next
@prisma-next/vite-plugin-contract-emit
@prisma-next/mongo-codec
@prisma-next/mongo-contract
@prisma-next/mongo-value
@prisma-next/mongo-contract-psl
@prisma-next/mongo-contract-ts
@prisma-next/mongo-emitter
@prisma-next/mongo-schema-ir
@prisma-next/mongo-query-ast
@prisma-next/mongo-orm
@prisma-next/mongo-query-builder
@prisma-next/mongo-lowering
@prisma-next/mongo-wire
@prisma-next/sql-contract
@prisma-next/sql-errors
@prisma-next/sql-operations
@prisma-next/sql-schema-ir
@prisma-next/sql-contract-psl
@prisma-next/sql-contract-ts
@prisma-next/sql-contract-emitter
@prisma-next/sql-lane-query-builder
@prisma-next/sql-relational-core
@prisma-next/sql-builder
@prisma-next/target-postgres
@prisma-next/target-sqlite
@prisma-next/adapter-postgres
@prisma-next/adapter-sqlite
@prisma-next/driver-postgres
@prisma-next/driver-sqlite
commit: |
size-limit report 📦
|
…llow-up slice Operator decision (2026-07-21): deduplicate ref-paired snapshots (refs/<name>.contract.*) into the same content-addressed store as a follow-up slice sequenced before the RC layout freeze. The ref keeps only its hash pointer; the contract resolves through migrations/snapshots/. Recorded in the spec (out-of-scope note) and plan (Follow-up slice: 1 reader + 4 writers + migrator + ADR-218 amendment, invariant, and the F05/ADR-239 wins). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
F01: delete the dead MIGRATION.FILE_MISSING branch in contract-at-errors.ts. contractAt never raises this code under the store-based layout (it resolves through CONTRACT_SNAPSHOT_MISSING or SNAPSHOT_MISSING instead), so the branch was unreachable and pointed users at a removed end-contract.json sibling file. It now falls through to the existing mapMigrationToolsError default. F02: checkSnapshotConsistency in migration-check.ts computed the store path once via contractSnapshotDir before the read, instead of recomputing it inside the CHECK-006 catch block. A malformed pkg.metadata.to threw a plain Error out of storageHashHex on the first call; the catch then called contractSnapshotDir again while building its message, throwing the same error a second time, uncaught, which crashed migration check into a generic PRECONDITION exit. The path is now computed once, guarded, and reused in both the read and the message; a malformed to now yields a clean PN-MIG-CHECK-006 failure. F04: removed the redundant `!RESERVED_SPACE_SUBDIR_NAMES.has(name)` filters in loader.ts, migration-check.ts, and migration-list.ts. Their input (listContractSpaceDirectories) already filters reserved names internally, so the reserved-name check now lives in exactly one place. F05: writeContractSnapshot now writes both contract.json and contract.d.ts into a fresh temp directory under snapshots/ and atomically renames it into place, instead of mkdir-ing the final directory and writing the two files into it directly. A crash between the two writes can no longer leave a partial snapshots/<hex>/ entry. If the rename loses a race to a concurrent writer (EEXIST/ENOTEMPTY), it's treated as write-if-absent success and the temp dir is cleaned up. F06: readContractSnapshotJsonTolerant swallowed every read failure to undefined, including non-ENOENT fs errors like EACCES. It now only swallows ENOENT, a JSON parse error, the JSON literal null, and a malformed storageHash; any other fs error propagates instead of silently loading a contract-less package. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Standardizes the British "artefact" spelling to American "artifact"
across everything this PR (deduplicate-migration-snapshots) introduced
or touched, per code-review finding F07 (operator-requested BLOCKER).
- File renames (git mv): emit-contract-space-artefacts.ts ->
emit-contract-space-artifacts.ts, and its test file, plus every
import specifier that referenced the old path.
- Identifier renames, repo-wide (these are exported/shared symbols so
every reference had to move in lockstep): emitContractSpaceArtefacts,
ContractSpaceArtefactInputs, ContractSpaceArtefactSetup,
writeExtensionContractSpaceArtefacts, findStaleArtefacts,
ARTEFACT_FILENAMES.
- Prose (comments, doc strings, test descriptions) in this PR's
touched files, plus projects/deduplicate-migration-snapshots/{spec.md,plan.md}.
Left one CLI-printed message ("Files deleted (stale contract
artefacts):" in cli/src/commands/init/output.ts) unchanged — it is
shown to users and the dispatch asked to stop and report rather than
change user-facing string literals.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Adds scripts/migrate-migrations-layout.mjs, the one-shot migrator spec D9 describes: for a migrations root, it moves each migration package's start-contract.*/end-contract.* pair and each space's per-space contract.json/contract.d.ts into the migrations-root-wide migrations/snapshots/<hex>/ store, rewrites migration.ts's quoted import specifiers to the store paths, and deletes the sibling files. It calls the production writeContractSnapshot / snapshotsImportPathFrom (migration-tools) and contractSnapshotJsonSpecifier / contractSnapshotTypesSpecifier (framework-components) so its output is byte-identical to what migration plan / the regen scripts produce. Safety: the migrator plans every root read-only first, asserting each contract's inner storage.storageHash against the hash it is filed under (migration.json's from/to, or refs/head.json's hash) before any write or delete happens anywhere; a mismatch aborts the whole run with nothing touched. After mutating each package it re-reads migration.json/ops.json byte-for-byte and recomputes migrationHash via computeMigrationHash, aborting on any drift — the migrator never edits those files. Verified directly against real fixture chains (retail-store's 4-migration chain, prisma-next-postgis-demo's app + postgis space) copied into a scratch tmpdir; the real committed tree was never touched (git status stays clean for examples/ and packages/3-extensions/ throughout). Resolution problem: scripts/*.mjs cannot resolve @prisma-next/* packages (pnpm links per-consumer, not at the workspace root). Adds @prisma-next/migration-tools and @prisma-next/framework-components as root devDependencies (workspace:0.15.0, via pnpm add -Dw + pnpm install — no hand-edited lockfile) so the migrator and the regen scripts can import the production functions. Root package.json is private and excluded from check-publish-deps' tarball scan, and devDependencies are outside its @prisma-next/* pin check, so this does not trip that gate; both it and lint:deps stay green. Repoints scripts/regen-example-migrations.mjs and scripts/regen-extension-migrations.mjs to emit into a scratch directory (or read the extension's src/contract.json) and writeContractSnapshot the result into the store, deleting the old rename-to-end-contract.* and predecessor-copy-to-start-contract steps. Both scripts gain a rewriteContractSnapshotSpecifiers helper that retargets the endContract / startContract (and Contract as End/Start) import specifiers by binding name rather than by literal old-path string, so a regen that produces a new hash keeps migration.ts pointed at the right store entry -- the old rewriteMigrationHashes/rewriteMigrationToHash (hash-literal rewrites) no longer have any specifier work to do post-dedup, since the store encodes the hash in the import path itself; they are kept as a defensive no-op for any pre-dedup hash-literal shape. scripts/regen-mongo-end-contract-dts.mjs needed no logic change -- it already takes a *.json path argument and writes the sibling .d.ts beside it, so pointing it at a store entry's contract.json works unchanged; only its docs were updated. New scripts/migrate-migrations-layout.test.mjs (11 cases, node:test) covers the store population + sibling deletion + specifier rewrite + byte-identical migration.json/head.json happy path, second-run idempotence, and the abort-before-any-write-or-delete behavior on a corrupted inner hash. Wired into the package.json test:scripts list. Per the dispatch's explicit CRITICAL CONSTRAINT, the real committed migrations tree (examples/, packages/3-extensions/) is untouched -- running the migrator against it is TML-3059 T9, a separate dispatch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Ran scripts/migrate-migrations-layout.mjs across all 17 migrations roots: 62 store writes (76 dedup hits), 276 sibling contract files deleted, 4 per-space copies folded into the store, 71 migration.ts import blocks repointed at ../../snapshots/<hex>/contract(.json). Every migration.json is byte-identical (79 migrationHash re-verifications passed); refs/head.json unchanged. Workspace typecheck green — all rewritten store imports resolve. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
apps/telemetry-backend/migrations/app/20260601T1236_contract_hash_advance/
held only end-contract.{json,d.ts} — no migration.json, ops.json, or
migration.ts. It is an inert, unloadable leftover from a re-planned migration
(superseded by the complete 20260601T1347_contract_hash_advance/, a different
target hash); its contract hash appears nowhere else and nothing references the
dir. The one-shot migrator skips it (no manifest to key the store), so it is
removed here to satisfy AC1 (no end-contract.json anywhere).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
New ADR 239 records the decision: contract snapshots move from
per-package start-/end-contract.* siblings and per-space head copies
into a single content-addressed store at
migrations/snapshots/<hex>/contract.{json,d.ts} per migrations root,
keyed by storage hash, write-if-absent. Amends ADR 197 and ADR 232;
references ADR 199 and ADR 218. Indexed in ADR-INDEX.md under Migration
System.
Updates docs/architecture docs/subsystems/7. Migration System.md (File
Layout, the edge/marker model paragraph, Contract snapshot,
Runner-side independence, Pinned per-space artefacts, ref/PN-MIG-CHECK
mentions) to the store reality.
Repo-wide prose/comment/message sweep of stale start-contract/
end-contract references: docs/glossary.md, docs/onboarding/
fixtures-emit-and-check.md, gotchas.md, drive/calibration/
failure-modes.md, .agents/rules/contract-space-package-layout.mdc,
.agents/rules/never-hand-edit-contract-fixtures.mdc,
skills/prisma-next-migrations, skills/prisma-next-quickstart, the CLI
and extension READMEs, production JSDoc/comments/error messages across
framework-components, migration-tools, the SQL/Mongo/Postgres/SQLite
migration packages, and CLI commands, plus stale test titles/fixtures
in migration-plan-command, migrate-to-contract, plan-resolution,
contract-at, io, mongo-migration, and retail-store migration-chain
tests (the retail-store test read a pre-store on-disk path that no
longer exists after the tree migration landed; repointed to the
store). Removes the now-redundant start-/end-contract ignore globs from
biome.jsonc (the existing **/contract.json glob already covers store
entries).
Appends upgrade-note entries to the existing 0.15-to-0.16
instructions.md for both the prisma-next-upgrade and
prisma-next-extension-upgrade skills, documenting the layout change and
scripts/migrate-migrations-layout.mjs as the conversion path (the
extension-author note covers the shallow ../snapshots import depth).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The design is recorded permanently in ADR 239; the ref-paired-snapshot consolidation follow-up is tracked as its own ticket (TML-3072). Per projects/README.md, the delivering PR deletes projects/<project>/ at close-out. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…tion-snapshots # Conflicts: # package.json # packages/1-framework/3-tooling/cli/src/commands/migration-check.ts # skills/extension-author/prisma-next-extension-upgrade/upgrades/0.15-to-0.16/instructions.md # skills/upgrade/prisma-next-upgrade/upgrades/0.15-to-0.16/instructions.md # test/integration/test/cli-journeys/migration-check.e2e.test.ts
…tion-snapshots # Conflicts: # skills/extension-author/prisma-next-extension-upgrade/upgrades/0.16-to-0.17/instructions.md
origin/main already carries two ADR 239 files ("Errors are structural envelopes
with dotted namespace codes" and "Option arguments and select templates for
authoring helpers"), so this ADR takes the next free number, 240. Updated the
title, the ADR-INDEX row, and the subsystem-doc cross-reference.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The Lint job runs pnpm test:scripts without building, and the new
migrate-migrations-layout test imports workspace packages that resolve to
built dist. Add the same cache-restore build step the typecheck job uses.
The multi-extension-monorepo e2e intermittently dies on CI with the known
PGlite WASM abort ("Connection terminated unexpectedly"). Add the CI retry
layer, and absorb the async pg client error event in the control driver
(same defect class as cc4b5af in withClient): without a listener the
connection-level event surfaces as an unhandled error that fails the run
even after the retried test passes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ments, close frontmatter storageHashHex threw a bare Error (lint:throws ratchet +1); a malformed generated hash is a bug, so it becomes InternalError. Three new doc-comment lines named SQL/Mongo inside packages/1-framework (family-vocabulary lint); reworded family-blind, and the net reduction lowers the ratchet threshold to 835. The 0.16-to-0.17 upgrade instructions opened frontmatter without ever closing it, so check:upgrade-coverage read the file as having no frontmatter at all; close it and add the short prose body the sibling files carry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
5 tasks
JSON.stringify leaves the line/paragraph separators unescaped; embedded in generated migration.ts source they can terminate the string literal (CodeQL js/bad-code-sanitization, alerts 24/25 on op-factory-call.ts render sites). Escape them explicitly in the one shared printer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
wmadden
approved these changes
Jul 22, 2026
This was referenced Jul 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
We now store each migration contract once, in a content-addressed store shared by the whole migrations tree — instead of copying it into every migration package that references it.
A migration's bookend contracts move out of its own directory into
migrations/snapshots/<hex>/, and the emittedmigration.tsimports them from there by hash:A migration package directory is now just
migration.ts+migration.json+ops.json.Why
A contract that sits between two migrations was stored twice: once as the earlier migration's
end-contract(its destination) and again as the next migration'sstart-contract(its source) — the same bytes, in two files. Across a chain of N migrations that is roughly 2N copies of N+1 distinct contracts. This repo committed 78end-contract.jsonand 60start-contract.json; the store holds each distinct contract exactly once.The timing is not incidental: the
migrations/directory layout freezes as public surface at RC, so this is the window to change it.How the store works
Keyed by storage hash.
migration.jsonalready records a migration'sfromandtoas storage hashes, so the hash is the address — no link file is needed to find a migration's bookend contracts. (This mirrors the Postgres control plane, which already keeps one row per distinct contract in a content-addressedprisma_contracttable.)Write-if-absent, made sound by canonicalization. Writing a snapshot only checks whether
snapshots/<hex>/already exists; if it does, the write is skipped. That is safe only because every writer canonicalizes the contract first, so two producers writing the entry for the same hash always agree on the bytes. Each write lands in a temp directory and isrenamed into place, so an interrupted write can't leave a half-written entry.The import path is threaded, never guessed. The relative path from a package to the store differs by repo shape (
../../snapshotsfor an app migration,../snapshotsfor an extension's own repo). It's computed once per package and threaded through the planners into the renderer, so no reader ever walks up the directory tree to find the store.Clean break. There is no fallback reader for the old sibling files. A committed tree that hasn't been converted fails to load with a structured
MIGRATION.CONTRACT_SNAPSHOT_MISSINGthat names the missing hash and path — pointing you at the migrator (below) rather than failing silently.What does not change
migration.json,ops.json, and everymigrationHashare byte-identical. The contract snapshot was never part of migration identity (ADR 199), so moving it changes no migration's hash — the committed tree's history is untouched.The apply path is likewise unaffected:
migration applyreads onlymigration.json+ops.jsonper package and never touchessnapshots/. You can deletemigrations/snapshots/entirely and still apply an app-space chain end to end —snapshots/is authoring and typechecking surface, not a runtime input. New postgres and sqlite regression tests delete the store and assert apply still succeeds.Converting the committed tree
The bulk of the diff is the one-time conversion of every committed migrations tree, done by a committed migrator (
scripts/migrate-migrations-layout.mjs, referenced by the upgrade notes so downstream projects can run it). It plans every migrations root read-only first and only applies once all roots plan cleanly; per migration it asserts the contract's innerstorage.storageHashagainst the hash it's filed under before writing, and re-verifies everymigrationHashis unchanged after — any drift aborts the whole run before deleting anything.Across 17 migrations roots that came to ~140 deleted sibling files, ~71 rewritten
migration.tsimport blocks, and the new store entries, with zeromigration.jsonchanges. The regeneration scripts produce byte-identical output, sofixtures:checkstays green.One deliberate deviation: no gzip
The RC plan item floated gzip tolerance (".json.gz accepted from day one"). It's dropped: TypeScript can't resolve a gzipped
.d.ts, and the emittedmigration.ts's ESM JSON import can't decompress at import time — so gzip would break every committedmigration.tswhile only ever helping tooling that reads store files directly. (Recorded on #986.)Alternatives considered
migration.json'sfrom/toalready are that link.storage.storageHash. Rejected: readers already hold the storage hash; a second hash would be computed for no added safety.Full rationale and the accepted trade-offs (domain-surface drift under one hash; contract source deliberately left out of the store) are in ADR 240. Ships in 0.17.0; a follow-up (TML-3072) folds the ADR-218 ref-paired snapshots into this same store so
migrations/ends up with a single snapshot concept.