Skip to content

refactor(migration-tools): refs carry invariants, per-file layout#372

Open
saevarb wants to merge 5 commits intomainfrom
feat/ref-invariants
Open

refactor(migration-tools): refs carry invariants, per-file layout#372
saevarb wants to merge 5 commits intomainfrom
feat/ref-invariants

Conversation

@saevarb
Copy link
Copy Markdown
Contributor

@saevarb saevarb commented Apr 22, 2026

closes TML-2305
part of TML-2297

Intent

Lay the storage groundwork for invariant-aware migration routing. Environment refs need to carry required invariants alongside their target hash so that a future pathfinder (specced in the accompanying doc) can route through paths that satisfy those invariants. The ref format also moves from a single refs.json blob to a per-file directory layout — one file per ref — so refs are independently versionable, diffable, and editable.

This PR lands the M0 slice of the invariant-aware-routing spec. The spec + plan ship alongside it for context; subsequent PRs implement M1–M5.

Change map

The story

  1. Refs gain invariants as a first-class field. Before this PR, a ref was { name: hash } inside a monolithic migrations/refs.json. That format has nowhere to declare "to reach this target state, the database must have invariants X and Y satisfied" — which the invariant-aware routing spec needs. RefEntry = { hash, invariants: readonly string[] } replaces the bare-string value; the hash is still validated as sha256:empty | sha256:<64-hex>, invariants are free-form names validated by Arktype.

  2. One file per ref, not one blob. migrations/refs/<name>.json replaces the old migrations/refs.json. Ref names are validated against a conservative pattern (^[a-z0-9](...)?(/[a-z0-9]...)*$) — lowercase, hyphen-allowed, slash-namespaced, no dots or trailing slashes. The layout supports nested refs (feature/my-branch) without schema pain; each ref is an independently editable file in the working tree, which plays better with git review.

  3. Storage primitives + helpers. readRef(refsDir, name), readRefs(refsDir), writeRef(refsDir, name, entry), deleteRef(refsDir, name), resolveRef(refs, name), validateRefName, validateRefValue. Writes are atomic (tmp file + rename); deletes walk empty parent directories back up to refsDir; reads swallow ENOENT and return empty to keep callers simple. Arktype schema narrows on parse to reject invalid hashes at the storage boundary.

  4. CLI call sites threaded through. migration apply --ref, migration status --ref, and migration ref set/list/rm all switch from refsPath to refsDir. resolveRef now returns RefEntry, so the three callers that previously consumed a bare string (migration apply, migration status) unwrap .hash explicitly. No behavior change for users who never pass --ref.

  5. Spec + plan land alongside. The docs commit establishes the end-to-end roadmap so future reviewers can see where M0 fits. M1–M5 decompose the remaining work into independently shippable PRs, with the test-coverage matrix mapping every acceptance criterion to a verification.

Behavior changes & evidence

  • Refs are structured records, not strings. RefEntry = { hash, invariants: readonly string[] }. The CLI continues to accept a plain hash on migration ref set <name> <hash> and implicitly uses invariants: []; richer payloads are edited by hand in the per-file JSON for v1.

  • On-disk layout: per-file, not monolithic. migrations/refs/<name>.json replaces migrations/refs.json.

  • resolveRef return type changed from string to RefEntry. Callers unwrap .hash explicitly.

    • Why: the structured record is what routing downstream will want; returning the hash alone would throw away invariants.
    • Implementation: migration-apply.ts, migration-status.ts
    • Tests: CLI integration covers apply --ref and status --ref end-to-end.
  • migration ref set/list/rm rewritten against the per-file API.

  • 17 migration fixture directories migrated to the new layout. No behavior change — this is test data reflecting the new storage shape.

Compatibility / migration / risk

  • Breaking change to on-disk ref format. Any repo with an existing migrations/refs.json won't be found by the new reader — refs live in migrations/refs/*.json now. The system is pre-1.0, but downstream users with an older ref file need to migrate manually (one JSON file per entry). Error surface is clean: readRefs swallows ENOENT and returns {}, so a missing directory looks like "no refs" rather than a hard failure.
  • Internal API break: resolveRef now returns RefEntry, not string. All call sites in this PR are updated; any consumer outside the workspace (there aren't any yet, since the package isn't published) would need to unwrap .hash.
  • No runtime cost. Reading a directory of small JSON files is trivially fast at CLI scale.

Follow-ups / open questions

This PR is M0 of the invariant-aware-routing workstream (TML-2297). The remaining milestones land as separate PRs:

  • M1 — Ledger foundation. prisma_contract.ledger gets migration_id and invariants columns; ControlFamilyInstance gains readLedger(); deriveEdgeStatuses consults the ledger instead of inferring from graph paths. Closes TML-2130.
  • M2 — Edge-level invariants + suggested refactors. MigrationChainEntry.invariants populated from ops; DataTransformOperation.invariant?: boolean opt-out. Optional renames: dag.tsgraph.ts, MigrationChainEntryMigrationEdge.
  • M3 — Pathfinder primitive. findPathWithInvariants with state-level dedup + invariant-preferring neighbour ordering.
  • M4 — Decision surface + CLI integration with ledger subtraction. migration apply --ref and status --ref compute effective_required = ref.invariants − readLedger().applied and route through the pathfinder. NO_INVARIANT_PATH surfaces when unsatisfiable.
  • M5 — Close-out. Doc migration; spec/plan deletion; prune data-migrations-spec.md.

No open questions block this PR.

Non-goals / intentionally out of scope

  • Invariant-aware routing. Spec describes it; the pathfinder + decision surface + CLI integration arrive in M3–M4.
  • Ledger changes. Recording applied invariants in the ledger is M1's work.
  • CLI UX for editing ref.invariants. For v1, users edit the JSON files directly.
  • Migration of existing refs.json blobs. Pre-existing internal consumers need to manually split their blob into per-file entries; no automated migration tool.

Summary by CodeRabbit

  • Refactor
    • Migration refs moved from one refs.json file to per-ref JSON files in a refs/ directory.
    • Ref entries now include metadata (invariants) alongside content hashes.
    • Migration CLI (apply, ref, status) updated to use the new refs/ layout and updated help text.
    • Reference management now supports per-ref read/write/delete and more robust error handling.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 22, 2026

📝 Walkthrough

Walkthrough

Migration ref storage moved from single migrations/refs.json files to a directory-based layout migrations/refs/ containing per-ref JSON files. Refs are now structured RefEntry objects (hash + invariants). CLI, migration APIs, and tests updated to read/write/delete per-ref files and to handle RefEntry shapes.

Changes

Cohort / File(s) Summary
Fixture refs → per-file
examples/prisma-next-demo/migration-fixtures/*/refs.json (deleted), examples/prisma-next-demo/migration-fixtures/*/refs/*.json (created)
Replaced monolithic refs.json fixtures with per-ref files (prod.json, staging.json, feature.json, experiment.json) containing { "hash": "...", "invariants": [] }. Attention: many fixtures removed and replaced across example trees.
Core ref storage API
packages/1-framework/3-tooling/migration/src/refs.ts, packages/1-framework/3-tooling/migration/src/exports/refs.ts
Changed Refs shape to Record<string, RefEntry>; added exported RefEntry type. Replaced flat writeRefs with readRef/writeRef/deleteRef. readRefs now enumerates *.json files; resolveRef returns RefEntry. Validation and error codes updated (INVALID_REF_FILE, UNKNOWN_REF, INVALID_REF_NAME). High-density logic changes.
CLI: command surface & helpers
packages/1-framework/3-tooling/cli/src/commands/migration-ref.ts, packages/1-framework/3-tooling/cli/src/commands/migration-apply.ts, packages/1-framework/3-tooling/cli/src/commands/migration-status.ts, packages/1-framework/3-tooling/cli/src/utils/command-helpers.ts
Commands switched from refsPath/single-file model to refsDir/per-ref operations. migration-ref now uses readRef/writeRef/deleteRef, returns/display invariants, and help text updated. migration-apply/migration-status extract .hash from RefEntry. resolveMigrationPaths renamed property refsPathrefsDir. errorRefNotFound export removed.
Tests: update and rework
packages/1-framework/3-tooling/migration/test/refs.test.ts, packages/1-framework/3-tooling/cli/test/commands/migration-ref.test.ts, packages/1-framework/3-tooling/cli/test/commands/migration-status.test.ts (deleted)
Tests migrated from refs.json model to per-file model: use writeRef/readRef/deleteRef, validate nested ref names (subdirs), malformed file handling, and updated expected MigrationToolsError codes. migration-status.test.ts removed; migration-ref.test.ts extensively rewritten. Review test expectations carefully.

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant MigrationAPI
  participant FS
  CLI->>MigrationAPI: resolveMigrationPaths() -> refsDir
  CLI->>MigrationAPI: readRef(refsDir, name) / readRefs(refsDir)
  MigrationAPI->>FS: read file `${refsDir}/${name}.json`
  FS-->>MigrationAPI: file content (JSON)
  MigrationAPI->>MigrationAPI: validate parse -> RefEntry {hash,invariants}
  MigrationAPI-->>CLI: RefEntry (or Refs map)
  CLI->>MigrationAPI: writeRef(refsDir, name, RefEntry)
  MigrationAPI->>FS: write `${refsDir}/${name}.json`
  FS-->>MigrationAPI: write success
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped through folders, one by one,
Each ref now sleeps in its own little sun,
Hashes and invariants snug in a file,
The CLI hops neatly, mile after mile.
Tiny JSON burrows — organized and fun! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.35% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main structural and semantic changes in the PR: refs now carry invariants and use a per-file directory layout instead of a monolithic JSON file.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ref-invariants

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 22, 2026

Open in StackBlitz

@prisma-next/mongo-runtime

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-runtime@372

@prisma-next/family-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/family-mongo@372

@prisma-next/sql-runtime

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-runtime@372

@prisma-next/family-sql

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/family-sql@372

@prisma-next/middleware-telemetry

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/middleware-telemetry@372

@prisma-next/mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo@372

@prisma-next/extension-paradedb

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/extension-paradedb@372

@prisma-next/extension-pgvector

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/extension-pgvector@372

@prisma-next/postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/postgres@372

@prisma-next/sql-orm-client

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-orm-client@372

@prisma-next/sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sqlite@372

@prisma-next/target-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/target-mongo@372

@prisma-next/adapter-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/adapter-mongo@372

@prisma-next/driver-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/driver-mongo@372

@prisma-next/contract

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/contract@372

@prisma-next/utils

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/utils@372

@prisma-next/config

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/config@372

@prisma-next/errors

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/errors@372

@prisma-next/framework-components

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/framework-components@372

@prisma-next/operations

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/operations@372

@prisma-next/ts-render

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/ts-render@372

@prisma-next/contract-authoring

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/contract-authoring@372

@prisma-next/ids

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/ids@372

@prisma-next/psl-parser

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/psl-parser@372

@prisma-next/psl-printer

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/psl-printer@372

@prisma-next/cli

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/cli@372

@prisma-next/emitter

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/emitter@372

@prisma-next/migration-tools

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/migration-tools@372

prisma-next

npm i https://pkg.pr.new/prisma/prisma-next@372

@prisma-next/vite-plugin-contract-emit

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/vite-plugin-contract-emit@372

@prisma-next/runtime-executor

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/runtime-executor@372

@prisma-next/mongo-codec

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-codec@372

@prisma-next/mongo-contract

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-contract@372

@prisma-next/mongo-value

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-value@372

@prisma-next/mongo-contract-psl

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-contract-psl@372

@prisma-next/mongo-contract-ts

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-contract-ts@372

@prisma-next/mongo-emitter

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-emitter@372

@prisma-next/mongo-schema-ir

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-schema-ir@372

@prisma-next/mongo-query-ast

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-query-ast@372

@prisma-next/mongo-orm

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-orm@372

@prisma-next/mongo-query-builder

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-query-builder@372

@prisma-next/mongo-lowering

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-lowering@372

@prisma-next/mongo-wire

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-wire@372

@prisma-next/sql-contract

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract@372

@prisma-next/sql-errors

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-errors@372

@prisma-next/sql-operations

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-operations@372

@prisma-next/sql-schema-ir

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-schema-ir@372

@prisma-next/sql-contract-psl

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-psl@372

@prisma-next/sql-contract-ts

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-ts@372

@prisma-next/sql-contract-emitter

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-emitter@372

@prisma-next/sql-lane-query-builder

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-lane-query-builder@372

@prisma-next/sql-relational-core

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-relational-core@372

@prisma-next/sql-builder

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-builder@372

@prisma-next/target-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/target-postgres@372

@prisma-next/target-sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/target-sqlite@372

@prisma-next/adapter-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/adapter-postgres@372

@prisma-next/adapter-sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/adapter-sqlite@372

@prisma-next/driver-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/driver-postgres@372

@prisma-next/driver-sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/driver-sqlite@372

commit: 98f7bdc

@saevarb saevarb force-pushed the feat/ref-invariants branch from 49516ef to f52e08b Compare April 22, 2026 15:20
saevarb added 2 commits April 23, 2026 11:13
Spec covers the full end-to-end scope: ref-file refactor (P1,
completed as M0), ledger foundation (M1), edge-level invariants + the
suggested refactors (M2), the findPathWithInvariants pathfinder (M3),
and the decision surface / CLI integration with ledger subtraction
(M4).

Key decisions:
- D4: name is the single identity on DataTransformOperation; an
  invariant?: boolean flag (default true) controls routing visibility.
  Avoids the split-identity mental overhead of a separate invariantId
  field that would have duplicated name in ~95% of call sites.
- D10: state-level dedup only, no dominance pruning. Realistic graphs
  don't produce the state-space blowup pruning targets.
- D11: neighbour ordering prefers invariant-covering edges when
  required is non-empty; falls back to today's ordering otherwise.
- D12: findLeaf / findLatestMigration stay structural.

Plan ships each milestone as its own PR, M0 first.
Replaces the single top-level `refs.json` (a flat `{name → hash}` map)
with a per-ref file under `refs/<name>.json`. Each ref is now a
structured `RefEntry` that carries both its target hash and the list of
invariants the environment requires:

    { "hash": "sha256:…", "invariants": ["split-user-name"] }

This is the data-model foundation for invariant-aware (ref-aware)
routing. Routing itself still resolves to the hash — this commit does
not change how paths are selected. Follow-ups:

- Migration edges will carry "provides invariants" metadata (from
  dataTransform operation descriptors).
- `findPath` / `findPathWithDecision` will be extended to require that
  the cumulative provided-invariants along a path satisfies the ref's
  required set, failing with a hard error when no satisfying path exists.

CLI call sites (`migration apply`, `migration status`) updated to unwrap
`.hash` from the new `RefEntry`. A stale CLI-level test that exercised
the old flat-file shape was removed; the package-level `refs.test.ts`
has fuller coverage of the new read/write surface (per-file directory,
error codes `INVALID_REF_FILE` / `INVALID_REF_NAME` / `INVALID_REF_VALUE`).

Cherry-picked from the in-progress `feat/data-migrations-invariant-routing`
branch so the ref-aware routing work can proceed on a focused branch.

All tests pass: migration-tools (191), CLI (532).
@saevarb saevarb force-pushed the feat/ref-invariants branch from e4ef9dd to 6abbe53 Compare April 23, 2026 09:21
@saevarb saevarb changed the title feat(migrations): Invariant-aware routing refactor(migration-tools): refs carry invariants, per-file layout Apr 23, 2026
@saevarb saevarb marked this pull request as ready for review April 23, 2026 09:53
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (6)
packages/1-framework/3-tooling/cli/src/commands/migration-status.ts (1)

376-397: Redundant ref-lookup fallback.

The if (refEntry) { … } else { resolveRef(...) } split is equivalent to just calling resolveRef(allRefs, activeRefName).hash. resolveRef performs the same map lookup and raises MIGRATION.UNKNOWN_REF when missing, plus validates the ref-name shape — which the direct allRefs[activeRefName] path skips. Collapsing to a single call removes a branch and ensures invalid ref names are reported consistently.

♻️ Simplification
   if (options.ref) {
     activeRefName = options.ref;
-    const refEntry = allRefs[activeRefName];
-    if (refEntry) {
-      activeRefHash = refEntry.hash;
-    } else {
-      try {
-        activeRefHash = resolveRef(allRefs, activeRefName).hash;
-      } catch (error) {
-        if (MigrationToolsError.is(error)) {
-          return notOk(
-            errorRuntime(error.message, {
-              why: error.why,
-              fix: error.fix,
-              meta: { code: error.code },
-            }),
-          );
-        }
-        throw error;
-      }
-    }
+    try {
+      activeRefHash = resolveRef(allRefs, activeRefName).hash;
+    } catch (error) {
+      if (MigrationToolsError.is(error)) {
+        return notOk(
+          errorRuntime(error.message, {
+            why: error.why,
+            fix: error.fix,
+            meta: { code: error.code },
+          }),
+        );
+      }
+      throw error;
+    }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/1-framework/3-tooling/cli/src/commands/migration-status.ts` around
lines 376 - 397, The branch that directly reads allRefs[activeRefName] is
redundant and skips validate logic; replace the if/else with a single call to
resolveRef(allRefs, activeRefName).hash to obtain activeRefHash so invalid ref
names are handled uniformly by resolveRef. Keep the existing try/catch around
resolveRef and preserve the MigrationToolsError handling that returns
notOk(errorRuntime(...)), referencing the symbols activeRefName, activeRefHash,
allRefs, resolveRef, MigrationToolsError, notOk, and errorRuntime.
packages/1-framework/3-tooling/cli/src/commands/migration-ref.ts (2)

47-50: Duplication with resolveMigrationPaths.

resolveRefsDir reimplements logic already present in resolveMigrationPaths from ../utils/command-helpers.ts, which returns refsDir as part of its result. Reusing it keeps refs-dir resolution in one place and ensures all commands compute the same path.

♻️ Proposed refactor
-import { addGlobalOptions, setCommandDescriptions } from '../utils/command-helpers';
+import {
+  addGlobalOptions,
+  resolveMigrationPaths,
+  setCommandDescriptions,
+} from '../utils/command-helpers';
@@
-function resolveRefsDir(configPath?: string, config?: { migrations?: { dir?: string } }): string {
-  const base = configPath ? resolve(configPath, '..') : process.cwd();
-  return resolve(base, config?.migrations?.dir ?? 'migrations', 'refs');
-}

Then replace resolveRefsDir(options.config, config) call sites with resolveMigrationPaths(options.config, config).refsDir.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/1-framework/3-tooling/cli/src/commands/migration-ref.ts` around
lines 47 - 50, resolveRefsDir duplicates logic already implemented by
resolveMigrationPaths; remove the local resolveRefsDir function and replace its
call sites (e.g., resolveRefsDir(options.config, config)) with
resolveMigrationPaths(options.config, config).refsDir, ensuring you import
resolveMigrationPaths from ../utils/command-helpers.ts if not already imported
and update any references to use the refsDir property returned by
resolveMigrationPaths.

196-202: ref get (non-JSON) drops invariants from human-readable output.

Given refs now carry invariants, consider mirroring the list command by appending an [invariants: …] suffix when present. Otherwise users can only discover invariants via --json or ref list. Minor UX polish.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/1-framework/3-tooling/cli/src/commands/migration-ref.ts` around
lines 196 - 202, The non-JSON (human) output in the ref get flow currently only
prints value.hash and drops value.invariants; update the callback passed to
handleResult so that when flags.json is false it prints value.hash and, if
value.invariants exists and is non-empty, appends a human-readable suffix like "
[invariants: …]" (e.g., join the invariants array with commas or format
similarly to the ref list command). Locate the callback around
handleResult(result, flags, ui, (value) => { ... }) and change the ui.output
branch that currently does ui.output(value.hash) to build and output the
combined string including invariants.
packages/1-framework/3-tooling/migration/src/refs.ts (2)

141-146: Temp-file suffix can collide under concurrent writes.

Date.now() has millisecond granularity; two writeRef calls for the same name within the same tick (e.g., concurrent CLI invocations or tests) would both write to the same .name.json.<ms>.tmp and the subsequent rename on one of them could lose the other's data mid-flight. A random suffix (e.g., crypto.randomUUID() or randomBytes(8).toString('hex')) makes the temp path unique regardless of timing.

♻️ Proposed fix
-  const tmpPath = join(dir, `.${name.split('/').pop()}.json.${Date.now()}.tmp`);
+  const tmpPath = join(
+    dir,
+    `.${name.split('/').pop()}.json.${Date.now()}.${randomBytes(6).toString('hex')}.tmp`,
+  );

Plus import { randomBytes } from 'node:crypto';.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/1-framework/3-tooling/migration/src/refs.ts` around lines 141 - 146,
The temp-file suffix using Date.now() (in the tmpPath definition used before
writeFile/rename) can collide under concurrent writes; replace the timestamp
suffix with a cryptographically-unique suffix (e.g., crypto.randomUUID() or
randomBytes(8).toString('hex')) and add the corresponding import from
'node:crypto' so each tmpPath for the same name is unique, leaving the rest of
the logic (tmpPath variable, writeFile call, and rename to filePath) unchanged.

168-177: startsWith(refsDir) lacks a separator boundary.

If refsDir is /tmp/refs and a sibling dir /tmp/refs-backup exists, dir.startsWith(refsDir) returns true for that sibling. In practice dir is always derived from join(refsDir, name.json) so this won't trigger today, but the cleanup loop is robust only by accident. Consider guarding with a separator-aware check (e.g., dir === refsDir || dir.startsWith(refsDir + '/')) to make the invariant explicit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/1-framework/3-tooling/migration/src/refs.ts` around lines 168 - 177,
The cleanup loop using dir, refsDir, dirname and rmdir should guard against
false positives from startsWith by checking a path-separator boundary; replace
the condition that uses dir.startsWith(refsDir) with a separator-aware check
(e.g., require dir === refsDir or dir starts with refsDir + path.sep using
path.sep) so sibling paths like /tmp/refs-backup are not treated as children of
refsDir; update the while condition accordingly and keep the remainder of the
rmdir/try/catch logic unchanged.
packages/1-framework/3-tooling/migration/test/refs.test.ts (1)

273-348: Consider a test for files deeper than .json with non-.json siblings being pruned.

The ignores non-json files test only checks at the top level. Since readRefs walks recursively (readdir(..., { recursive: true })) and filters by .endsWith('.json'), a non-JSON file nested under a subdirectory (e.g., envs/README.md) would also be ignored — a quick nested variant would lock that behavior in.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/1-framework/3-tooling/migration/test/refs.test.ts` around lines 273
- 348, Add a test to readRefs that confirms non-.json files are ignored in
nested directories: create a subdirectory (e.g., 'envs'), write a valid JSON ref
file (e.g., 'envs/staging.json') and a non-JSON sibling (e.g.,
'envs/README.md'), call readRefs(refsDir) and assert the result only contains
the 'envs/staging' entry; reference the readRefs helper used in refs.test.ts and
mirror the style of the existing "ignores non-json files" test to ensure
recursive filtering by .endsWith('.json') is validated.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@examples/prisma-next-demo/migration-fixtures/linear/refs/prod.json`:
- Around line 2-6: The fixture's "hash" field is an object but must be a string
per the RefEntry schema and the nested "invariants" inside that object is
misplaced; update the "hash" property to the inner string value (e.g., replace
the object at "hash" with "contract-1" or the proper sha256 string) and move or
merge the array currently at "hash.invariants" into the top-level "invariants"
array (replacing or concatenating with the existing top-level "invariants" as
appropriate) so the file has a string "hash" and a single top-level "invariants"
array.

In `@packages/1-framework/3-tooling/cli/test/commands/migration-ref.test.ts`:
- Around line 442-448: The test currently contains a tautological assertion
`expect(markerAtB === refEntry.hash).toBe(true)`—replace it with an assertion
that verifies actual behavior: call findPath(graph, markerAtB, refEntry.hash)
and assert the expected result (e.g., an empty path or same-node result) such as
expect(findPath(graph, markerAtB, refEntry.hash)).toHaveLength(0) or
expect(...).toEqual([]); remove the original tautology and keep the check that
the path behavior for markerAtB matches the intended semantics.
- Around line 221-235: The test description "'apply does not mutate ref files'"
is inaccurate because the test never calls the migration apply path; either
change the test to invoke the actual apply flow (run the CLI command or function
that implements "migration apply" and then assert the ref files are
byte-identical before/after) referencing the migration apply entrypoint, or
rename the test to accurately describe what it does (e.g., "writeRef round-trips
through readRef/readRefs") and keep the existing assertions that
readRef/readRefs return the same objects written by writeRef; locate the current
test by the existing test name string and update it accordingly while keeping
the usage of writeRef, readRef, and readRefs intact.

In `@packages/1-framework/3-tooling/migration/src/refs.ts`:
- Around line 180-195: resolveRef currently looks up refs[name] which can return
inherited Object.prototype members (e.g., "constructor"); change resolveRef to
first check ownership with Object.hasOwn(refs, name) and throw the
MIGRATION.UNKNOWN_REF error if the property is not an own property, then read
refs[name]; alternatively ensure refs is created with no prototype
(Object.create(null)) in readRefs, but follow the repo guideline to prefer
Object.hasOwn in resolveRef (function resolveRef) to safely gate the lookup.

---

Nitpick comments:
In `@packages/1-framework/3-tooling/cli/src/commands/migration-ref.ts`:
- Around line 47-50: resolveRefsDir duplicates logic already implemented by
resolveMigrationPaths; remove the local resolveRefsDir function and replace its
call sites (e.g., resolveRefsDir(options.config, config)) with
resolveMigrationPaths(options.config, config).refsDir, ensuring you import
resolveMigrationPaths from ../utils/command-helpers.ts if not already imported
and update any references to use the refsDir property returned by
resolveMigrationPaths.
- Around line 196-202: The non-JSON (human) output in the ref get flow currently
only prints value.hash and drops value.invariants; update the callback passed to
handleResult so that when flags.json is false it prints value.hash and, if
value.invariants exists and is non-empty, appends a human-readable suffix like "
[invariants: …]" (e.g., join the invariants array with commas or format
similarly to the ref list command). Locate the callback around
handleResult(result, flags, ui, (value) => { ... }) and change the ui.output
branch that currently does ui.output(value.hash) to build and output the
combined string including invariants.

In `@packages/1-framework/3-tooling/cli/src/commands/migration-status.ts`:
- Around line 376-397: The branch that directly reads allRefs[activeRefName] is
redundant and skips validate logic; replace the if/else with a single call to
resolveRef(allRefs, activeRefName).hash to obtain activeRefHash so invalid ref
names are handled uniformly by resolveRef. Keep the existing try/catch around
resolveRef and preserve the MigrationToolsError handling that returns
notOk(errorRuntime(...)), referencing the symbols activeRefName, activeRefHash,
allRefs, resolveRef, MigrationToolsError, notOk, and errorRuntime.

In `@packages/1-framework/3-tooling/migration/src/refs.ts`:
- Around line 141-146: The temp-file suffix using Date.now() (in the tmpPath
definition used before writeFile/rename) can collide under concurrent writes;
replace the timestamp suffix with a cryptographically-unique suffix (e.g.,
crypto.randomUUID() or randomBytes(8).toString('hex')) and add the corresponding
import from 'node:crypto' so each tmpPath for the same name is unique, leaving
the rest of the logic (tmpPath variable, writeFile call, and rename to filePath)
unchanged.
- Around line 168-177: The cleanup loop using dir, refsDir, dirname and rmdir
should guard against false positives from startsWith by checking a
path-separator boundary; replace the condition that uses dir.startsWith(refsDir)
with a separator-aware check (e.g., require dir === refsDir or dir starts with
refsDir + path.sep using path.sep) so sibling paths like /tmp/refs-backup are
not treated as children of refsDir; update the while condition accordingly and
keep the remainder of the rmdir/try/catch logic unchanged.

In `@packages/1-framework/3-tooling/migration/test/refs.test.ts`:
- Around line 273-348: Add a test to readRefs that confirms non-.json files are
ignored in nested directories: create a subdirectory (e.g., 'envs'), write a
valid JSON ref file (e.g., 'envs/staging.json') and a non-JSON sibling (e.g.,
'envs/README.md'), call readRefs(refsDir) and assert the result only contains
the 'envs/staging' entry; reference the readRefs helper used in refs.test.ts and
mirror the style of the existing "ignores non-json files" test to ensure
recursive filtering by .endsWith('.json') is validated.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 900e4d47-62eb-4d9d-a988-40de89a98353

📥 Commits

Reviewing files that changed from the base of the PR and between 3134f51 and 6abbe53.

⛔ Files ignored due to path filters (2)
  • projects/graph-based-migrations/plans/invariant-aware-routing-plan.md is excluded by !projects/**
  • projects/graph-based-migrations/specs/invariant-aware-routing.spec.md is excluded by !projects/**
📒 Files selected for processing (44)
  • examples/prisma-next-demo/migration-fixtures/complex/refs.json
  • examples/prisma-next-demo/migration-fixtures/complex/refs/prod.json
  • examples/prisma-next-demo/migration-fixtures/complex/refs/staging.json
  • examples/prisma-next-demo/migration-fixtures/converging-branches/refs.json
  • examples/prisma-next-demo/migration-fixtures/converging-branches/refs/prod.json
  • examples/prisma-next-demo/migration-fixtures/diamond-sub-branch/refs.json
  • examples/prisma-next-demo/migration-fixtures/diamond-sub-branch/refs/experiment.json
  • examples/prisma-next-demo/migration-fixtures/diamond-sub-branch/refs/prod.json
  • examples/prisma-next-demo/migration-fixtures/diamond/refs.json
  • examples/prisma-next-demo/migration-fixtures/diamond/refs/prod.json
  • examples/prisma-next-demo/migration-fixtures/kitchen-sink/refs.json
  • examples/prisma-next-demo/migration-fixtures/kitchen-sink/refs/prod.json
  • examples/prisma-next-demo/migration-fixtures/linear/refs.json
  • examples/prisma-next-demo/migration-fixtures/linear/refs/prod.json
  • examples/prisma-next-demo/migration-fixtures/long-spine/refs.json
  • examples/prisma-next-demo/migration-fixtures/long-spine/refs/prod.json
  • examples/prisma-next-demo/migration-fixtures/long-spine/refs/staging.json
  • examples/prisma-next-demo/migration-fixtures/multi-branch/refs.json
  • examples/prisma-next-demo/migration-fixtures/multi-branch/refs/feature.json
  • examples/prisma-next-demo/migration-fixtures/multi-branch/refs/prod.json
  • examples/prisma-next-demo/migration-fixtures/multi-branch/refs/staging.json
  • examples/prisma-next-demo/migration-fixtures/multi-rollback-branch/refs.json
  • examples/prisma-next-demo/migration-fixtures/multi-rollback-branch/refs/prod.json
  • examples/prisma-next-demo/migration-fixtures/multi-rollback-branch/refs/staging.json
  • examples/prisma-next-demo/migration-fixtures/rollback-continue/refs.json
  • examples/prisma-next-demo/migration-fixtures/rollback-continue/refs/prod.json
  • examples/prisma-next-demo/migration-fixtures/sequential-diamonds/refs.json
  • examples/prisma-next-demo/migration-fixtures/sequential-diamonds/refs/prod.json
  • examples/prisma-next-demo/migration-fixtures/single-branch/refs.json
  • examples/prisma-next-demo/migration-fixtures/single-branch/refs/prod.json
  • examples/prisma-next-demo/migration-fixtures/single-branch/refs/staging.json
  • examples/prisma-next-demo/migration-fixtures/sub-branches/refs.json
  • examples/prisma-next-demo/migration-fixtures/sub-branches/refs/experiment.json
  • examples/prisma-next-demo/migration-fixtures/sub-branches/refs/feature.json
  • examples/prisma-next-demo/migration-fixtures/sub-branches/refs/prod.json
  • packages/1-framework/3-tooling/cli/src/commands/migration-apply.ts
  • packages/1-framework/3-tooling/cli/src/commands/migration-ref.ts
  • packages/1-framework/3-tooling/cli/src/commands/migration-status.ts
  • packages/1-framework/3-tooling/cli/src/utils/command-helpers.ts
  • packages/1-framework/3-tooling/cli/test/commands/migration-ref.test.ts
  • packages/1-framework/3-tooling/cli/test/commands/migration-status.test.ts
  • packages/1-framework/3-tooling/migration/src/exports/refs.ts
  • packages/1-framework/3-tooling/migration/src/refs.ts
  • packages/1-framework/3-tooling/migration/test/refs.test.ts
💤 Files with no reviewable changes (14)
  • examples/prisma-next-demo/migration-fixtures/kitchen-sink/refs.json
  • examples/prisma-next-demo/migration-fixtures/linear/refs.json
  • examples/prisma-next-demo/migration-fixtures/converging-branches/refs.json
  • examples/prisma-next-demo/migration-fixtures/rollback-continue/refs.json
  • examples/prisma-next-demo/migration-fixtures/diamond-sub-branch/refs.json
  • examples/prisma-next-demo/migration-fixtures/diamond/refs.json
  • examples/prisma-next-demo/migration-fixtures/long-spine/refs.json
  • examples/prisma-next-demo/migration-fixtures/single-branch/refs.json
  • examples/prisma-next-demo/migration-fixtures/complex/refs.json
  • examples/prisma-next-demo/migration-fixtures/multi-branch/refs.json
  • examples/prisma-next-demo/migration-fixtures/sub-branches/refs.json
  • examples/prisma-next-demo/migration-fixtures/sequential-diamonds/refs.json
  • packages/1-framework/3-tooling/cli/test/commands/migration-status.test.ts
  • examples/prisma-next-demo/migration-fixtures/multi-rollback-branch/refs.json

Comment thread examples/prisma-next-demo/migration-fixtures/linear/refs/prod.json Outdated
Comment thread packages/1-framework/3-tooling/cli/test/commands/migration-ref.test.ts Outdated
Comment thread packages/1-framework/3-tooling/migration/src/refs.ts
saevarb added 3 commits April 23, 2026 12:24
The file was corrupted during the per-file refs refactor — the hash
field was an object containing a placeholder 'contract-1' string and
experimental invariants ('foo', 'bar') not referenced anywhere else.
Restored to the original leaf-hash target with empty invariants, to
match every other fixture in the same directory.
- resolveRef: guard with Object.hasOwn so ref names like 'constructor'
  don't walk the Object.prototype chain and bypass UNKNOWN_REF. Added a
  regression test covering the 'constructor' lookup.
- migration-ref.test.ts: dropped a misnamed test ('apply does not
  mutate ref files') that never invoked apply — duplicative of the
  CRUD coverage in refs.test.ts.
- migration-ref.test.ts: replaced a tautological `markerAtB === refEntry.hash`
  assertion with an actual findPath check (path from B to B is empty).
Drops the local resolveRefsDir helper; its logic (`resolve(migrations
dir, 'refs')`) was identical to resolveMigrationPaths(...).refsDir.
All four call sites swapped to destructure refsDir from the shared
helper. Also removes the now-unused `resolve` import from pathe.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/1-framework/3-tooling/migration/src/refs.ts (1)

104-109: Silently swallowing non-ENOENT readFile errors in readRefs.

The empty catch { continue } treats every read failure as a missing-file case. That's fine for a benign race (file deleted between readdir and readFile), but it also silently drops EACCES, EPERM, EISDIR, etc., producing a Refs that silently omits entries — callers will then see a misleading UNKNOWN_REF downstream. Consider narrowing the swallow to ENOENT only:

♻️ Proposed change
     let raw: string;
     try {
       raw = await readFile(filePath, 'utf-8');
-    } catch {
-      continue;
+    } catch (error) {
+      if (error instanceof Error && (error as { code?: string }).code === 'ENOENT') {
+        continue;
+      }
+      throw error;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/1-framework/3-tooling/migration/src/refs.ts` around lines 104 - 109,
In readRefs, the catch block around readFile(filePath, 'utf-8') currently
swallows all errors (declared via raw variable), which hides real failures;
change the catch to inspect the thrown error (e.g., err.code) and only continue
when err.code === 'ENOENT' (or err?.code === 'ENOENT'), otherwise rethrow the
error so EACCES/EPERM/EISDIR/etc. bubble up; keep references to readRefs,
readFile, filePath and the raw variable when making this change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/1-framework/3-tooling/cli/test/commands/migration-ref.test.ts`:
- Around line 419-424: The assertion using atTarget (const atTarget = markerHash
=== refEntry.hash; expect(atTarget).toBe(false);) is tautological because
markerHash and refEntry.hash are test constants; remove these two lines and
either (a) drop the redundant check entirely since findPath(graph, markerHash,
refEntry.hash) already asserts behavior, or (b) replace them with a meaningful
assertion such as verifying the path contents or distance (e.g., assert the
nodes/length returned by findPath or check resolver/distance logic related to
markerHash and refEntry.hash) so the test actually validates runtime behavior;
update the assertions around findPath, markerHash, and refEntry to reflect the
chosen approach.

---

Nitpick comments:
In `@packages/1-framework/3-tooling/migration/src/refs.ts`:
- Around line 104-109: In readRefs, the catch block around readFile(filePath,
'utf-8') currently swallows all errors (declared via raw variable), which hides
real failures; change the catch to inspect the thrown error (e.g., err.code) and
only continue when err.code === 'ENOENT' (or err?.code === 'ENOENT'), otherwise
rethrow the error so EACCES/EPERM/EISDIR/etc. bubble up; keep references to
readRefs, readFile, filePath and the raw variable when making this change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: a3feccd0-cb6a-45f5-b6c6-1304628d5c81

📥 Commits

Reviewing files that changed from the base of the PR and between 6abbe53 and 98f7bdc.

📒 Files selected for processing (5)
  • examples/prisma-next-demo/migration-fixtures/linear/refs/prod.json
  • packages/1-framework/3-tooling/cli/src/commands/migration-ref.ts
  • packages/1-framework/3-tooling/cli/test/commands/migration-ref.test.ts
  • packages/1-framework/3-tooling/migration/src/refs.ts
  • packages/1-framework/3-tooling/migration/test/refs.test.ts
✅ Files skipped from review due to trivial changes (1)
  • examples/prisma-next-demo/migration-fixtures/linear/refs/prod.json

Comment on lines 419 to 424
const markerHash = EMPTY_CONTRACT_HASH;
const pathToRef = findPath(graph, markerHash, refHash);
const pathToRef = findPath(graph, markerHash, refEntry.hash);
expect(pathToRef).toHaveLength(2);

const atTarget = markerHash === refHash;
const atTarget = markerHash === refEntry.hash;
expect(atTarget).toBe(false);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Tautological atTarget assertion.

markerHash === refEntry.hash just compares EMPTY_CONTRACT_HASH to HASH_B — both are constants set up in this same test, so the assertion can never fail regardless of the production code's behavior. Same class of issue as the previously-addressed markerAtB === refEntry.hash check. Consider dropping this pair of lines (the findPath(graph, markerHash, refEntry.hash) check on L420–421 already exercises the meaningful behavior) or replacing with an assertion against the resolver/distance logic you actually care about.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/1-framework/3-tooling/cli/test/commands/migration-ref.test.ts`
around lines 419 - 424, The assertion using atTarget (const atTarget =
markerHash === refEntry.hash; expect(atTarget).toBe(false);) is tautological
because markerHash and refEntry.hash are test constants; remove these two lines
and either (a) drop the redundant check entirely since findPath(graph,
markerHash, refEntry.hash) already asserts behavior, or (b) replace them with a
meaningful assertion such as verifying the path contents or distance (e.g.,
assert the nodes/length returned by findPath or check resolver/distance logic
related to markerHash and refEntry.hash) so the test actually validates runtime
behavior; update the assertions around findPath, markerHash, and refEntry to
reflect the chosen approach.

try {
await rmdir(dir);
dir = dirname(dir);
} catch {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will ignore all js exceptions, not only ENOTEMPTY

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants