Skip to content

Commit 417b405

Browse files
authored
TML-2690: make rollback edges plannable and applyable via --to (#635)
1 parent 96eb587 commit 417b405

26 files changed

Lines changed: 2265 additions & 501 deletions

File tree

docs/architecture docs/subsystems/7. Migration System.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ Additive structure is covered by core operations: create table, add nullable col
140140

141141
### `migration plan`
142142

143-
`prisma-next migration plan` diffs a `from` contract against the current emitted contract and writes one or two attested migration packages. It is fully offline — no database connection. See [ADR 218](../adrs/ADR%20218%20-%20Refs%20with%20paired%20contract%20snapshots%20and%20universal%20graph-node%20invariant.md).
143+
`prisma-next migration plan` diffs a `from` contract against a `to` contract and writes one or two attested migration packages. It is fully offline — no database connection. See [ADR 218](../adrs/ADR%20218%20-%20Refs%20with%20paired%20contract%20snapshots%20and%20universal%20graph-node%20invariant.md).
144144

145145
**Default `from` resolution** ([`resolveFromForPlan`](../../../packages/1-framework/3-tooling/cli/src/utils/plan-resolution.ts)):
146146

@@ -150,16 +150,20 @@ Additive structure is covered by core operations: create table, add nullable col
150150

151151
The from-contract materialises from the ref's paired snapshot (ref-resolved `from`) or from a bundle's `end-contract.json` (hash-resolved `from` on a graph node).
152152

153+
**Default `to` resolution:** when `--to` is omitted, the destination is the emitted `contract.json`. When `--to <ref-or-hash>` is supplied, the same [contract-reference grammar](#refs-environment-targets) as `--from` applies (hash / prefix, ref name, migration directory, `<dir>^`, or filesystem path); the resolved contract becomes the planner destination and the source of `end-contract.json` / `.d.ts`. Use `--to <migration-dir>^` to plan a reverse (rollback) edge toward a predecessor state.
154+
153155
**Emission cases:**
154156

155157
| Case | Condition | Output |
156158
|---|---|---|
157-
| Greenfield | Graph empty, `from` = `null` | One bundle: `null → current_contract` |
158-
| Auto-baseline | Graph empty, `from` non-null, paired snapshot available | Two bundles: baseline `null → from` + delta `from → current` |
159-
| Normal delta | Graph non-empty, `from` is a graph node | One bundle: `from → current_contract` |
159+
| Greenfield | Graph empty, `from` = `null` | One bundle: `null → to_contract` |
160+
| Auto-baseline | Graph empty, `from` non-null, paired snapshot available | Two bundles: baseline `null → from` + delta `from → to_contract` |
161+
| Normal delta | Graph non-empty, `from` is a graph node | One bundle: `from → to_contract` |
160162
| Forgot-the-flag | Graph non-empty, `from` not a graph node | Refuse: `MIGRATION.HASH_NOT_IN_GRAPH` |
161163
| Snapshot missing | `from` non-null, no contract source | Refuse: `MIGRATION.SNAPSHOT_MISSING` |
162164

165+
When `--to` is omitted, `to_contract` is the emitted contract; when `--to` is supplied, `to_contract` is the resolved destination.
166+
163167
Plan-time refuse diagnostics name the resolved hash, list refs pointing at graph nodes, and suggest `--from <reachable-ref>`. Snapshot-missing diagnostics suggest `db update --advance-ref <name>` to repopulate or `ref delete <name>` to clear an orphan pointer.
164168

165169
## Authoring Surface
@@ -539,7 +543,7 @@ Top-level verbs:
539543

540544
Migration namespace (artifacts and graph):
541545

542-
- `prisma-next migration plan [--from <contract>] --name <slug>` — diff contracts and write a fully attested package (`migration.ts` + `migration.json` + `ops.json`) offline. Defaults `--from` to the `db` ref (falls back to greenfield when absent). Re-run `./migration.ts` after filling any `placeholder(...)` slots to rewrite `ops.json` and the `migrationHash`.
546+
- `prisma-next migration plan [--from <contract>] [--to <contract>] --name <slug>` — diff contracts and write a fully attested package (`migration.ts` + `migration.json` + `ops.json`) offline. Defaults `--from` to the `db` ref (falls back to greenfield when absent) and `--to` to the emitted contract. Both flags accept the full [contract-reference grammar](#refs-environment-targets). Re-run `./migration.ts` after filling any `placeholder(...)` slots to rewrite `ops.json` and the `migrationHash`.
543547
- `prisma-next migration new [--from <hash> --to <hash>] --name <slug>` — scaffold an empty `migration.ts` for hand-authoring.
544548
- `prisma-next migration status [--db <url>] [--to <contract>] [--from <contract>]` — path/pending question. Live (uses marker) or offline (uses `--from`).
545549
- `prisma-next migration log --db <url>` — applied execution history (reads the marker; offline reading of the ledger is also supported).
@@ -590,7 +594,7 @@ Structured diagnostics from plan-time and apply-time checks suggest concrete rec
590594
| `MIGRATION.HASH_NOT_IN_GRAPH` | `migration plan` or `ref set`: resolved hash not in graph | `migration plan --from <reachable-ref>` (e.g. `--from production`) |
591595
| `MIGRATION.SNAPSHOT_MISSING` | `migration plan`: ref pointer exists but paired snapshot absent | `db update --advance-ref <name>` to repopulate, or `ref delete <name>` to clear orphan pointer |
592596
| `MIGRATION.MARKER_MISMATCH` | `migrate`: live marker hash not a graph node (pre-DDL check) | `migration plan --from <graph-tip>`, or `ref set db <marker-hash>` if on-disk graph is canonical |
593-
| `MIGRATION.PATH_UNREACHABLE` | `migrate`: no path from marker to target in on-disk graph | `migration plan --from <marker-or-reachable> --to <target>` per improved `fix` payload |
597+
| `MIGRATION.PATH_UNREACHABLE` | `migrate`: no path from marker to target in on-disk graph | Plan the missing edge with `migration plan --from <marker-or-reachable> --to <target> --name <slug>`, then apply with `migrate --to <target>`. For a rollback, use `--to <migration-dir>^` 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 <target> --name <slug>` first. |
594598

595599
After plain `migrate`, refresh a stale `db` ref with `db update` (no-op on DB when marker matches) or `migrate --advance-ref db` in the same invocation.
596600

packages/1-framework/3-tooling/cli/README.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -932,25 +932,26 @@ The `contract.output` field specifies the path to `contract.json`. This is the c
932932

933933
### `prisma-next migration plan`
934934

935-
Plan a migration from contract changes. Compares the emitted contract against the latest on-disk migration state and produces a new migration package with the required operations. No database connection is needed — fully offline.
935+
Plan a migration from contract changes. Compares a starting contract against a destination contract and produces a new migration package with the required operations. No database connection is needed — fully offline.
936936

937937
```bash
938-
prisma-next migration plan [--config <path>] [--name <slug>] [--from <hash>] [--json] [-v] [-q] [--color/--no-color]
938+
prisma-next migration plan [--config <path>] [--name <slug>] [--from <contract>] [--to <contract>] [--json] [-v] [-q] [--color/--no-color]
939939
```
940940

941941
**Options:**
942942
- `--config <path>`: Path to `prisma-next.config.ts`
943943
- `--name <slug>`: Name slug for the migration directory (default: `migration`)
944-
- `--from <hash>`: Explicit starting contract hash (overrides latest migration target detection)
944+
- `--from <contract>`: Starting contract reference (hash, prefix, ref name, migration directory, `<dir>^`, or filesystem path). Defaults to the `db` ref (greenfield when absent).
945+
- `--to <contract>`: Destination contract reference (same grammar as `--from`). Defaults to the emitted `contract.json`. Use `--to <migration-dir>^` to plan a rollback toward a predecessor state.
945946
- `--json`: Output as JSON object
946947
- `-q, --quiet`: Quiet mode (errors only)
947948
- `-v, --verbose`: Verbose output (debug info, timings)
948949

949950
**What it does:**
950-
1. Loads config and reads `contract.json` (the "to" contract)
951+
1. Loads config and resolves the destination contract: `--to <contract>` if provided, otherwise `contract.json`
951952
2. Reads existing migrations from `config.migrations.dir` (default: `migrations/`)
952-
3. Determines the starting point: `--from <hash>` if provided, otherwise the latest migration target
953-
4. Diffs the starting contract against the new contract using the target's migration planner
953+
3. Determines the starting point: `--from <contract>` if provided, otherwise the `db` ref (greenfield when absent)
954+
4. Diffs the starting contract against the destination using the target's migration planner
954955
5. Scaffolds a new migration package: `migration.ts` (containing `placeholder(...)` lambdas for any data transforms), `migration.json` (with a content-addressed `migrationHash` over the planned ops, or over `[]` when the planner could not lower any calls because of placeholders), `ops.json` (the planned ops, or `[]` in the placeholder-blocked case), and contract bookends. The package is **always** fully attested — there is no draft state on disk.
955956
6. If the plan has unfilled `placeholder(...)` slots, the command returns a successful `pendingPlaceholders` envelope (a warning, not a failure) asking the developer to fill in the slots before re-emitting. The on-disk `ops.json` is `[]` and `migrationHash` is the hash of `(metadata, [])`, so applying the migration as-written will not advance the storage hash to the intended destination — the runner's destination-hash post-check surfaces this as a state mismatch. After filling in the placeholders, run `node migrations/<dir>/migration.ts` to re-emit `ops.json` and the corresponding `migrationHash`. `PN-MIG-2001` is raised only at self-emit time when a slot is still unfilled.
956957

@@ -961,7 +962,7 @@ prisma-next migration plan [--config <path>] [--name <slug>] [--from <hash>] [--
961962
- `migrations/<dir>/start-contract.{json,d.ts}` — bookend from the "from" side (when applicable)
962963
- `migrations/<dir>/end-contract.{json,d.ts}` — bookend from the "to" side
963964

964-
**Branching with `--from`:** Use `--from` to create a migration edge from a specific contract hash instead of the latest migration target. This enables branched migration graphs where multiple environments diverge from a common ancestor.
965+
**Branching with `--from` and `--to`:** Use `--from` to create a migration edge from a specific contract hash instead of the default starting point. Use `--to` to plan toward any resolved contract — including a rollback via `<migration-dir>^` — instead of the emitted contract. This enables branched migration graphs and arbitrary-target (including reverse) edges without editing contract source.
965966

966967
### `prisma-next migration show`
967968

@@ -1027,6 +1028,7 @@ prisma-next migrate [--db <url>] [--to <contract>] [--config <path>] [--json] [-
10271028

10281029
**Options:**
10291030
- `--db <url>`: Database connection string (optional; defaults to `config.db.connection`)
1031+
- `--to <contract>`: Target contract reference (hash, prefix, ref name, migration directory, `<dir>^`, or filesystem path). When omitted, applies toward the emitted `contract.json`. When `--to` resolves to an on-disk graph node, verification and apply use that bundle's `end-contract.json` — so a planned rollback or other arbitrary-target edge applies without editing contract source.
10301032
- `--ref <name>`: Target a named ref from `migrations/refs.json` instead of the current contract hash
10311033
- `--config <path>`: Path to `prisma-next.config.ts`
10321034
- `--json`: Output as JSON object
@@ -1036,12 +1038,14 @@ prisma-next migrate [--db <url>] [--to <contract>] [--config <path>] [--json] [-
10361038
**What it does:**
10371039
1. Reads migration packages from `config.migrations.dir`. Every package is attested — there is no on-disk draft state. The loader (`readMigrationPackage` in `@prisma-next/migration-tools/io`) rehashes `(metadata, ops)` for each `MigrationPackage` it returns and confirms the result matches the stored `migrationHash`. If a package has been hand-edited or partially written since emit, the load fails with `MIGRATION.HASH_MISMATCH` pointing at the offending directory and asks the developer to re-run `node migrations/<dir>/migration.ts` (or restore from version control).
10381040
2. Reconstructs the migration graph from all loaded packages
1039-
3. Determines the destination hash: from `--ref` (via `refs.json`) or from `contract.json`
1041+
3. Determines the destination hash and apply contract: from `--to` / `--ref`, or from `contract.json` when neither is supplied
10401042
4. Connects to the database and reads the current marker hash
10411043
5. Finds the shortest path from the marker hash to the destination using graph pathfinding
10421044
6. Executes each pending migration in order using the target's `MigrationRunner`
10431045
7. Each migration runs in its own transaction with prechecks, postchecks, and idempotency checks enabled
1044-
8. After each migration, the runner verifies the schema and updates the marker/ledger
1046+
8. After each migration, the runner runs the migration's post-checks and verifies the resulting state matches the target contract's storage hash, then updates the marker/ledger
1047+
1048+
**Rollback workflow:** When no on-disk edge reaches the target (for example `migrate --to <migration-dir>^`), the command refuses with `MIGRATION.PATH_UNREACHABLE` and suggests planning the missing edge with `migration plan --from <current> --to <target> --name <slug>`, then re-running `migrate --to <target>`. No contract-source edit is required.
10451049

10461050
**Config requirements:** Requires `driver` and `db.connection` (or `--db`). `migrations.dir` is optional and defaults to `migrations/`.
10471051

packages/1-framework/3-tooling/cli/src/commands/migrate.ts

Lines changed: 37 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,9 @@ import { errorUnknownInvariant, MigrationToolsError } from '@prisma-next/migrati
55
import { findLatestMigration, isGraphNode } from '@prisma-next/migration-tools/migration-graph';
66
import { parseContractRef } from '@prisma-next/migration-tools/ref-resolution';
77
import type { RefEntry } from '@prisma-next/migration-tools/refs';
8-
import { readRefs } from '@prisma-next/migration-tools/refs';
98
import { ifDefined } from '@prisma-next/utils/defined';
109
import { notOk, ok, type Result } from '@prisma-next/utils/result';
1110
import { Command } from 'commander';
12-
import { join } from 'pathe';
1311
import { loadConfig } from '../config-loader';
1412
import { createControlClient } from '../control-api/client';
1513
import type {
@@ -42,6 +40,7 @@ import {
4240
setCommandExamples,
4341
targetSupportsMigrations,
4442
} from '../utils/command-helpers';
43+
import { mapContractAtError } from '../utils/contract-at-errors';
4544
import {
4645
loadContractSpaceAggregateForCli,
4746
refuseContractSpaceIntegrity,
@@ -198,14 +197,16 @@ async function executeMigrateCommand(
198197
}
199198

200199
let refEntry: RefEntry | undefined;
200+
let refName: string | undefined;
201201
if (toArg) {
202-
const refs = await readRefs(refsDir);
202+
const refs = aggregate.app.refs;
203203
const refResult = parseContractRef(toArg, { graph: aggregate.app.graph(), refs });
204204
if (!refResult.ok) {
205205
return notOk(mapRefResolutionError(refResult.failure));
206206
}
207207
if (refResult.value.provenance.kind === 'ref') {
208-
const resolved = refs[refResult.value.provenance.refName];
208+
refName = refResult.value.provenance.refName;
209+
const resolved = refs[refName];
209210
if (resolved) refEntry = resolved;
210211
} else {
211212
refEntry = { hash: refResult.value.hash, invariants: [] };
@@ -237,7 +238,6 @@ async function executeMigrateCommand(
237238
}
238239

239240
const appGraph = aggregate.app.graph();
240-
const appBundles = aggregate.app.packages;
241241

242242
const client = createControlClient({
243243
family: config.family,
@@ -285,8 +285,35 @@ async function executeMigrateCommand(
285285
ui.step('Loading contract spaces…');
286286
}
287287

288+
// When `--to` resolves to an on-disk graph node with a matching bundle,
289+
// verify and apply against THAT bundle's destination contract via
290+
// `contractAt` — not the emitted `contract.json`. With `--to` omitted,
291+
// or a target with no matching bundle, the emitted contract stays the
292+
// apply contract (the only migrate-specific default). The same
293+
// `contractAt` artifacts feed the optional ref-advancement snapshot.
294+
let applyContract: Contract = contractRaw;
295+
let snapshotContractJson: Record<string, unknown> = JSON.parse(contractContent);
296+
let snapshotContractDts: string | undefined;
297+
if (toArg && refEntry) {
298+
const targetHash = refEntry.hash;
299+
const matchingBundle = aggregate.app.packages.find((p) => p.metadata.to === targetHash);
300+
if (matchingBundle) {
301+
try {
302+
const at = await aggregate.app.contractAt(
303+
targetHash,
304+
refName !== undefined ? { refName } : undefined,
305+
);
306+
applyContract = at.contract;
307+
snapshotContractJson = at.contractJson as Record<string, unknown>;
308+
snapshotContractDts = at.contractDts;
309+
} catch (error) {
310+
return mapContractAtError(error, { artifactRole: 'to' });
311+
}
312+
}
313+
}
314+
288315
const applyResult = await client.migrationApply({
289-
contract: contractRaw,
316+
contract: applyContract,
290317
migrationsDir,
291318
...ifDefined('refHash', refEntry?.hash),
292319
...(refEntry?.invariants ? { refInvariants: refEntry.invariants } : {}),
@@ -301,37 +328,11 @@ async function executeMigrateCommand(
301328

302329
let advancedRef: { name: string; hash: string } | null = null;
303330
if (options.advanceRef !== undefined) {
304-
let contractJsonPathForSnapshot = contractPathAbsolute;
305-
let contractJsonForSnapshot: Record<string, unknown> = JSON.parse(contractContent) as Record<
306-
string,
307-
unknown
308-
>;
309-
if (toArg && refEntry) {
310-
const matchingBundle = appBundles.find((p) => p.metadata.to === refEntry.hash);
311-
if (matchingBundle) {
312-
const endContractPath = join(matchingBundle.dirPath, 'end-contract.json');
313-
contractJsonPathForSnapshot = endContractPath;
314-
try {
315-
const raw = await readFile(endContractPath, 'utf-8');
316-
contractJsonForSnapshot = JSON.parse(raw) as Record<string, unknown>;
317-
} catch (error) {
318-
if (error instanceof Error && (error as { code?: string }).code === 'ENOENT') {
319-
return notOk(
320-
errorFileNotFound(endContractPath, {
321-
why: `Bundle end-contract not found at ${endContractPath}`,
322-
fix: 'Re-emit the migration bundle or pick a different --to target.',
323-
}),
324-
);
325-
}
326-
throw error;
327-
}
328-
}
329-
}
330331
try {
331-
const contractIR = await readContractIR(
332-
contractJsonForSnapshot,
333-
contractJsonPathForSnapshot,
334-
);
332+
const contractIR =
333+
snapshotContractDts !== undefined
334+
? { contract: snapshotContractJson, contractDts: snapshotContractDts }
335+
: await readContractIR(snapshotContractJson, contractPathAbsolute);
335336
advancedRef = await executeRefAdvancement(
336337
refsDir,
337338
options.advanceRef,

0 commit comments

Comments
 (0)