diff --git a/docs/CLI Style Guide.md b/docs/CLI Style Guide.md index cfb052591ff7..e4309b07cf04 100644 --- a/docs/CLI Style Guide.md +++ b/docs/CLI Style Guide.md @@ -245,7 +245,7 @@ Concrete examples (from the migration CLI verb refactor, TML-2546). Each entry b - Writes or updates the marker of every space that verified, in one transaction on PostgreSQL and SQLite: missing marker → insert; same hash → no‑op; different hash → overwrite, reporting the previous hash. - Then writes each signed space's contract into its snapshot store and advances its `db` ref to the signed hash (`--advance-ref ` picks another ref). Unlike `db init` / `db update`, `--db` does not suppress this — signing never mutates the schema, and adoption normally runs against the real database via `--db`. `--no-advance-ref` signs without writing any ref or snapshot; combining it with `--advance-ref` is `CLI.ADVANCE_REF_ARG_CONFLICT` (exit code 2). Human output names the advanced ref and, when it existed, the previous hash; JSON is `{ ok, summary, spaces, advancedRefs }`: one outcome per space (`signed`, `unchanged` or `failed`) and one `{ space, name, hash }` per advanced ref. - No migration package is written. - - Options: `[contract]` positional or `--contract ` (hash, prefix, ref name, migration dir name, `^`, or `./path`; the positional accepts only the first four; defaults to the emitted `contract.json`; both together is `CLI.CONTRACT_ARG_CONFLICT`), `--db `, `--advance-ref `, `--no-advance-ref`. + - Options: `[contract]` positional or `--contract ` (hash, prefix, ref name, migration dir name, or `^`; both accept the same forms; defaults to the emitted `contract.json`; both together is `CLI.CONTRACT_ARG_CONFLICT`), `--db `, `--advance-ref `, `--no-advance-ref`. - Exit codes: 0 signed; 2 the command could not run (unresolvable contract reference, no emitted contract, unreachable database, conflicting flags); 4 verification failed for at least one space. ## Init Flow diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index 71200819a0d9..eada4fb17ba0 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -148,13 +148,13 @@ Additive structure is covered by core operations: create table, add nullable col **Default `from` resolution** ([`resolveFromForPlan`](../../../packages/1-framework/3-tooling/cli/src/control-api/operations/plan-resolution.ts)): -1. Explicit `--from ` — ref name, full hash, prefix, migration directory, `^`, or filesystem path. +1. Explicit `--from ` — any [contract reference](#contract-reference-grammar) that names a recorded contract, or `@empty`. 2. No `--from` — resolve the `db` ref via `migrations/app/refs/db.json`. 3. No `db` ref — resolve `from` to the `null` empty-graph sentinel (greenfield). When the graph is also empty, the human output adds a muted notice (`No db ref set — planning from an empty database. Run db init, db update, or db sign if a database already exists.`) and the JSON document carries `fromDefaulted: true`; an explicit `--from @empty` prints neither. The from-contract always materialises by reading the content-addressed snapshot store entry for the resolved hash — the ref-resolved hash comes from the ref's pointer, the hash-resolved `from` on a graph node from the matching bundle; either way the store is keyed by hash, so no bundle lookup is needed. -**Default `to` resolution:** when `--to` is omitted, the destination is the emitted `contract.json`. When `--to ` is supplied, the same [contract-reference grammar](#refs-environment-targets) as `--from` applies (hash / prefix, ref name, migration directory, `^`, or filesystem path); the resolved contract becomes the planner destination and is written into the snapshot store keyed by its storage hash. Use `--to ^` to plan a reverse (rollback) edge toward a predecessor state. +**Default `to` resolution:** when `--to` is omitted, the destination is the emitted `contract.json`. When `--to ` is supplied, it takes the [contract references](#contract-reference-grammar) that name a recorded contract (`@empty` is an origin only); the resolved contract becomes the planner destination and is written into the snapshot store keyed by its storage hash. Use `--to ^` to plan a reverse (rollback) edge toward a predecessor state. **Emission cases:** @@ -331,6 +331,19 @@ Migrations form a directed graph (not necessarily acyclic) via their `from` / `t Refs map logical environment names to contract hashes in `migrations//refs/.json` (e.g., `{ "hash": "...", "invariants": [] }`). They are version-controlled alongside migration artifacts. `db migrate --to production` uses the ref hash as the target instead of the current contract. `migration status --to staging` reports state relative to that ref. Refs are managed via `prisma migration ref set `, `prisma migration ref list`, and `prisma migration ref delete `. See [ADR 169 — On-disk migration persistence](../adrs/ADR%20169%20-%20On-disk%20migration%20persistence.md). +#### Contract-reference grammar + +A contract reference names one contract. Five forms name a contract recorded in the migrations directory: a full storage hash, a unique hash prefix, a ref name, a migration directory name (the migration's `to` contract), and `^` (the migration's `from` contract). Three reserved references, written as tokens, resolve without the migrations directory: `@contract` is the hash of the emitted `contract.json`, `@db` is the database marker (so it needs a connection), or the empty contract when the database has none, and `@empty` is the empty contract. When an input matches more than one form, the command refuses it as ambiguous; a longer or different form, such as a full hash, resolves it. + +| Argument | `@contract` | `@db` | `@empty` | +|---|---|---|---| +| `migration status --from`, `--to` | yes | yes | yes | +| `db migrate --to`, `db migrate --show --from`, `--to` | yes | yes | yes | +| `migration plan --from` | no | no | yes | +| `migration plan --to`, `migration ref set`, `db update --to`, `db sign` | no | no | no | + +`db update --to` and `db sign` refuse the reserved references with `MIGRATION.REF_WRONG_GRAMMAR`, as `migration plan --to` refuses `@empty`. In `migration status`, `db migrate` and `db migrate --show`, `--from` and `--to` apply to the app space. Each extension space goes to its own head: from its own marker when the command reads the database for the origin (no `--from`, or `--from @db`), and from the empty contract when `--from` names a contract. + #### Contract resolution through the snapshot store A ref is only its pointer file — `{ hash, invariants }`. It carries no contract copy of its own; the contract it names resolves through the shared content-addressed store at `migrations/snapshots//contract.{json,d.ts}` by that hash, the same store every graph node resolves through. See [ADR 218 — Refs with paired contract snapshots and universal graph-node invariant](../adrs/ADR%20218%20-%20Refs%20with%20paired%20contract%20snapshots%20and%20universal%20graph-node%20invariant.md) (its paired-snapshot part is superseded — see the ADR's Status note) and [ADR 240 — Contract snapshots live in a content-addressed store](../adrs/ADR%20240%20-%20Contract%20snapshots%20live%20in%20a%20content-addressed%20store.md). @@ -539,7 +552,7 @@ The remedy in every case is the same: edit the slots in `migration.ts`, run the Top-level verbs: -- `prisma db migrate --db [--to ] [--advance-ref ]` — execute pending migrations against a live database. Ref advancement is **opt-in only** via `--advance-ref`; plain `db migrate` does not advance any ref. The `` argument accepts the full [contract-reference grammar](#refs-environment-targets): hash / prefix, ref name, migration directory name, `^`, or filesystem path. +- `prisma db migrate --db [--to ] [--advance-ref ]` — execute pending migrations against a live database. Ref advancement is **opt-in only** via `--advance-ref`; plain `db migrate` does not advance any ref. The `` argument accepts every [contract reference](#contract-reference-grammar), including `@contract`, `@db` and `@empty`. - `prisma db init --db [--advance-ref ]` — bootstrap a database under contract control. When run against the default `--db` URL (no explicit `--db`), implicitly advances the `db` ref (write-if-absenting its contract into the snapshot store, then writing the pointer). With any explicit `--db` (even one naming the default URL), ref advancement is suppressed unless `--advance-ref` is explicit. - `prisma db update --db [--to ] [--advance-ref ]` — reconcile a live database to the named (or emitted) contract via live introspection. Same implicit `db` ref default and `--db` opt-out as `db init`. Off-graph; dev-only. - `prisma db sign --db [] [--advance-ref ] [--no-advance-ref]` (or `--contract `) — sign the marker of every contract space the live DB already satisfies: the application's (with no argument, the emitted `contract.json`) and each extension's. Each space is verified without strict mode; the markers of the spaces that verified are written in one transaction on PostgreSQL and SQLite, and a space that fails is reported with its drift and makes the command exit 4. After signing, writes each signed space's contract into its snapshot store and advances its `db` ref (or `--advance-ref `) to the signed hash; an existing ref is overwritten and the previous hash is reported in the human output (the JSON `advancedRefs` lists `{ space, name, hash }` per advanced ref). Unlike `db init` / `db update`, `--db` does not suppress this — sign never mutates the schema, and adoption is normally done against the real database via `--db`. `--no-advance-ref` is the opt-out: it signs without writing any ref or snapshot (JSON `advancedRefs` is empty), and combining it with `--advance-ref` is refused with `CLI.ADVANCE_REF_ARG_CONFLICT`. No migration package is written. After `--no-advance-ref` there is no `db` ref, so the next default `migration plan` starts from the empty contract (with the muted `No db ref set` notice) on an empty graph, or refuses with `MIGRATION.PLAN_ORIGIN_UNKNOWN` on a non-empty graph. @@ -548,9 +561,9 @@ Top-level verbs: Migration namespace (artifacts and graph): -- `prisma migration plan [--from ] [--to ] --name ` — diff contracts and write a fully attested package (`migration.ts` + `migration.json` + `ops.json`) offline. Defaults `--from` to the `db` ref and `--to` to the emitted contract; when the `db` ref is absent, planning proceeds from greenfield only on an empty graph (with a muted `No db ref set` notice and `fromDefaulted: true` in JSON) — with existing migrations on disk the command refuses (`MIGRATION.PLAN_ORIGIN_UNKNOWN`) unless `--from @empty` names the empty-database origin explicitly. 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`. +- `prisma migration plan [--from ] [--to ] --name ` — diff contracts and write a fully attested package (`migration.ts` + `migration.json` + `ops.json`) offline. Defaults `--from` to the `db` ref and `--to` to the emitted contract; when the `db` ref is absent, planning proceeds from greenfield only on an empty graph (with a muted `No db ref set` notice and `fromDefaulted: true` in JSON) — with existing migrations on disk the command refuses (`MIGRATION.PLAN_ORIGIN_UNKNOWN`) unless `--from @empty` names the empty-database origin explicitly. Both flags take [contract references](#contract-reference-grammar); `--from` also accepts `@empty`. Re-run `./migration.ts` after filling any `placeholder(...)` slots to rewrite `ops.json` and the `migrationHash`. - `prisma migration new [--from ] --name ` — scaffold an empty `migration.ts` for hand-authoring. -- `prisma migration status [--db ] [--to ] [--from ]` — path/pending question. Live (uses marker) or offline (uses `--from`). +- `prisma migration status [--db ] [--to ] [--from ]` — path/pending question. Reads the database marker by default; `--from` names the origin instead and runs offline, unless `--from` or `--to` is `@db`, which reads the database. - `prisma migration log --db ` — applied execution history (reads the marker; offline reading of the ledger is also supported). - `prisma migration list` — enumerate migrations on disk in topological order. Offline. - `prisma migration graph` — render the migration graph (ASCII tree by default; `--json` / `--dot` for other formats). Offline. @@ -596,7 +609,7 @@ Structured diagnostics from plan-time and apply-time checks suggest concrete rec |---|---|---| | `MIGRATION.HASH_NOT_IN_GRAPH` | `migration plan` or `migration ref set`: resolved hash not in graph | `migration plan --from ` (e.g. `--from production`) | | `MIGRATION.SNAPSHOT_MISSING` | `migration plan`: a named ref has no pointer file, and the hash being resolved isn't a graph node either | `migration ref set ` to create the ref, `db update --advance-ref ` to advance it, or pass a hash that is a graph node | -| `MIGRATION.MARKER_MISMATCH` | `db migrate`: live marker hash not a graph node (pre-DDL check) | `migration plan --from `, or `migration ref set db ` if on-disk graph is canonical | +| `MIGRATION.MARKER_MISMATCH` | `db migrate` (pre-DDL check) and `db migrate --show`: live marker hash not a graph node | `migration plan --from `, or `migration ref set db ` if on-disk graph is canonical | | `MIGRATION.PATH_UNREACHABLE` | `db migrate`: no path from marker to target in on-disk graph | Plan the missing edge with `migration plan --from --to --name `, then apply with `db migrate --to `. For a rollback, use `--to ^` in both steps — the planned reverse edge applies and moves the marker back without editing contract source. Review destructive (`DROP`) ops in the plan before applying. When the space has no on-disk migrations yet, omit `--from` and use `migration plan --to --name ` first. | After plain `db migrate`, refresh a stale `db` ref with `db update` (no-op on DB when marker matches) or `db migrate --advance-ref db` in the same invocation. @@ -776,7 +789,7 @@ Errors use the stable category/code envelope (see [ADR 027 — Error Envelope & - `MIGRATION.SAME_SOURCE_AND_TARGET` — migration edge has `from === to` (graph invariant violation) - `MIGRATION.HASH_NOT_IN_GRAPH` — resolved hash is not a node in the on-disk graph (plan-time / `migration ref set`) - `MIGRATION.SNAPSHOT_MISSING` — a named ref has no pointer file, and the hash being resolved isn't a graph node either (plan-time) -- `MIGRATION.MARKER_MISMATCH` — live DB marker hash is not a graph node (apply-time, pre-DDL) +- `MIGRATION.MARKER_MISMATCH` — live DB marker hash is not a graph node (`db migrate` before any DDL, and `db migrate --show`) - `MIGRATION.PATH_UNREACHABLE` — no migration path from marker to target (apply-time; improved `fix` payload) **Authoring errors** (`PN-MIG-*`): diff --git a/docs/design/10-domains/migration/README.md b/docs/design/10-domains/migration/README.md index 489f3f803a54..b4c2d17f42e9 100644 --- a/docs/design/10-domains/migration/README.md +++ b/docs/design/10-domains/migration/README.md @@ -378,8 +378,8 @@ The choices below are the load-bearing ones — the ones that, if reversed, woul - **`db init` vs `db sign`.** Distinct: init lays down structure (live, mutates); sign verifies + writes marker (no structural mutation, refuses if DB doesn't already satisfy the contract). - **"Freeze"** rejected. `migration plan` is the verb for the freeze-and-promise act. - **Dev/deploy split** rejected. The safety semantics belong to the DB URL, not the verb. -- **Contract references and migration references.** Two parallel grammars sharing forms but resolving in different namespaces. `` resolves to a contract storage hash (accepts: hash, ref name, migration directory name → to-contract, `^` → from-contract, filesystem path). `` resolves to a migration (accepts: migration hash or directory name). The command's argument type determines which grammar applies — same hash-shaped input resolves in different namespaces depending on whether the command expects a `` or a ``. **In CLI argument syntax the placeholder is `` or ``** — no umbrella shorthand. A **ref** is a specific kind of contract reference (named, persisted, file-backed); the umbrella is **contract reference**. -- **Directory names are user-controlled.** The default `T_` is convention, not invariant. Ambiguity between a directory name and a hash prefix is an explicit ambiguity error with candidate listing — same Git rule for short SHAs that collide with branch names. Disambiguate with `./` for filesystem paths or with a longer / different form. +- **Contract references and migration references.** Two parallel grammars sharing forms but resolving in different namespaces. `` resolves to a contract storage hash (accepts: hash or hash prefix, ref name, migration directory name → to-contract, `^` → from-contract, and the reserved references `@contract`, `@db` and `@empty` where the command allows them; see the [contract-reference grammar](../../../architecture%20docs/subsystems/7.%20Migration%20System.md#contract-reference-grammar)). `` resolves to a migration (accepts: migration hash or directory name). The command's argument type determines which grammar applies — same hash-shaped input resolves in different namespaces depending on whether the command expects a `` or a ``. **In CLI argument syntax the placeholder is `` or ``** — no umbrella shorthand. A **ref** is a specific kind of contract reference (named, persisted, file-backed); the umbrella is **contract reference**. +- **Directory names are user-controlled.** The default `T_` is convention, not invariant. Ambiguity between a directory name and a hash prefix is an explicit ambiguity error with candidate listing — same Git rule for short SHAs that collide with branch names. Disambiguate with a longer or different form, such as a full hash. - **`db sign []` (positional) or `db sign --contract ` (explicit).** The argument names *the thing being signed* — neither `--to` (movement) nor `--at` (position) carries the right meaning. Defaults to the current `contract.json` when omitted. - **`ref set `** is the direct-ref-write verb. `move` was rejected because refs are stored values, not entities that traverse the graph — the spatial-movement vocabulary is reserved for `migrate`. - **`head` ref dropped.** Refs are exclusively environment-named (`production`, `staging`, ...). The emitted `contract.json` already plays the role of "what the repo is working toward"; a `head` ref would have been redundant. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 2992c256f505..d516d67204b4 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -1612,7 +1612,7 @@ A ref name resolves to nothing: no pointer file with that name exists, and the f ### MIGRATION.REF_WRONG_GRAMMAR -A reference parsed, but as the wrong kind for the argument position, e.g. a migration-only reference where a contract reference is required (raised by the shared ref-resolution mapper). The message and fix come from the resolver's own diagnosis. Payload: `input`, `expectedGrammar`. +A reference parsed, but as the wrong kind for the argument position, e.g. a migration-only reference where a contract reference is required (raised by the shared ref-resolution mapper). The message and fix come from the resolver's own diagnosis. `db sign` and `db update --to` raise it for the reserved references `@contract`, `@db`, and `@empty`, which they do not accept, and `migration plan --to @empty` raises it because `@empty` is only valid as an origin. Payload: `input`, `expectedGrammar`. ### MIGRATION.RUNNER_FAILED diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 80a5fd1177f7..89eb370604d9 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -664,7 +664,7 @@ prisma db sign [ | --contract ] [--db ] [--advance-ref ``` Options: -- `` / `--contract `: Optional. The application contract to sign with: a hash, hash prefix, ref name, migration directory name, `^`, or `./path`. Defaults to the emitted `contract.json` +- `` / `--contract `: Optional. The application contract to sign with: a hash, hash prefix, ref name, migration directory name, or `^`. Defaults to the emitted `contract.json` - `--db `: Database connection string (optional; defaults to `config.db.connection` if set) - `--advance-ref `: Advance the named ref of every signed space instead of `db` - `--no-advance-ref`: Sign without writing any ref or snapshot @@ -1006,8 +1006,8 @@ prisma migration plan [--config ] [--name ] [--from ] [--t **Options:** - `--config `: Path to `prisma.config.ts` - `--name `: Name slug for the migration directory (default: `migration`) -- `--from `: Starting contract reference (hash, prefix, ref name, migration directory, `^`, `@empty`, or filesystem path). `@empty` names the empty-database origin deliberately. Defaults to the `db` ref; when the ref is absent, greenfield only on an empty graph — over existing migrations the command refuses (`MIGRATION.PLAN_ORIGIN_UNKNOWN`) unless `--from @empty` is passed. -- `--to `: Destination contract reference (same grammar as `--from`). Defaults to the emitted `contract.json`. Use `--to ^` to plan a rollback toward a predecessor state. +- `--from `: Starting contract reference (hash, prefix, ref name, migration directory, `^`, or `@empty`). `@empty` names the empty-database origin deliberately. Defaults to the `db` ref; when the ref is absent, greenfield only on an empty graph — over existing migrations the command refuses (`MIGRATION.PLAN_ORIGIN_UNKNOWN`) unless `--from @empty` is passed. +- `--to `: Destination contract reference (hash, prefix, ref name, migration directory, or `^`). Defaults to the emitted `contract.json`. Use `--to ^` to plan a rollback toward a predecessor state. - `--json`: Output as JSON object - `-q, --quiet`: Quiet mode (errors only) - `-v, --verbose`: Verbose output (debug info, timings) @@ -1053,45 +1053,47 @@ prisma migration show [target] [--config ] [--json] [-v] [-q] [--color/--n ### `prisma migration status` -Show the migration graph and applied status. Adapts based on context: - -- **With DB connection**: Shows applied/pending markers and "you are here" indicators -- **Without DB connection**: Shows the graph structure from disk only -- **With `--ref`**: Targets a specific ref instead of the contract hash; all refs from `refs.json` are rendered on the graph +Shows which migrations are pending between the database marker and the target contract. It reads the database marker by default and needs a connection. `--from` names the origin instead and runs offline, unless `--from` or `--to` is `@db`, which reads the database. ```bash -prisma migration status [--db ] [--ref ] [--config ] [--json] [-v] [-q] [--color/--no-color] +prisma migration status [--db ] [--to ] [--from ] [--space ] [--legend] [--ascii] [--config ] [--json] [-v] [-q] [--color/--no-color] ``` **Options:** -- `--db `: Database connection string (enables online mode) -- `--ref `: Target a named ref from `migrations/refs.json` instead of the current contract hash +- `--db `: Database connection string +- `--to `: Target contract reference (hash, prefix, ref name, migration dir name, `^`, `@contract`, `@db`, or `@empty`). Defaults to the emitted contract. +- `--from `: Origin contract reference, with the same forms as `--to`. Defaults to the database marker. With `--from`, the path is computed without reading the database, unless `--from` or `--to` is `@db`. +- `--space `: Narrow output to a single contract space +- `--legend`: Print a key for the tree glyphs and lane colors +- `--ascii`: Use ASCII glyphs - `--config `: Path to `prisma.config.ts` - `--json`: Output as JSON object - `-q, --quiet`: Quiet mode (errors only) - `-v, --verbose`: Verbose output +`@db` in either `--to` or `--from` resolves to the database marker, so the command reads the database and needs a connection. `--from` and `--to` apply to the app space. Each extension space goes to its own head: from its own marker when the command reads the database for the origin (no `--from`, or `--from @db`), and from the empty contract when `--from` names a contract. + **What it does:** -1. Reads migration packages from disk and reconstructs the migration graph -2. Loads all refs from `migrations/refs.json` (if present) and renders them on the graph -3. If `--ref` is provided, uses the ref's hash as the target instead of the contract hash; the active ref is highlighted in bold, other refs are dimmed -4. If a DB connection is available, reads the marker to determine applied/pending status and shows distance from the ref target (e.g., "2 edge(s) behind ref") -5. Displays the graph with `◄ DB`, `◄ Contract`, and `◄ ref:` markers -6. Shows operation summaries with destructive operation highlighting -7. In `--ref` mode, the `CONTRACT.AHEAD` warning is suppressed — contract being ahead of a ref target is expected in multi-environment workflows +1. Reads migration packages from disk and reconstructs each space's migration graph +2. Resolves the origin (the database marker, or `--from`) and the target (the emitted contract, or `--to`) +3. With a database connection, reads each space's marker and ledger to mark migrations applied or pending +4. Draws each space's graph with `@db`, `@contract` and ref labels, and summarises what is pending +5. Warns `MIGRATION.MARKER_NOT_IN_HISTORY` when a marker is not in its space's history (a graph node, or the head of a space with no migrations) ### `prisma db migrate` Apply planned migrations to the database. Executes previously planned migrations (created by `migration plan`). Compares the database marker against the migration graph to determine which migrations are pending, then executes them sequentially. Each migration runs in its own transaction. Does not plan new migrations — run `migration plan` first. ```bash -prisma db migrate [--db ] [--to ] [--config ] [--json] [-v] [-q] [--color/--no-color] +prisma db migrate [--db ] [--to ] [--advance-ref ] [--show] [--from ] [--config ] [--json] [-v] [-q] [--color/--no-color] ``` **Options:** - `--db `: Database connection string (optional; defaults to `config.db.connection`) -- `--to `: Target contract reference (hash, prefix, ref name, migration directory, `^`, or filesystem path). When omitted, applies toward the emitted `contract.json`. When `--to` resolves to an on-disk graph node, verification and apply use the snapshot store entry for that node's hash — so a planned rollback or other arbitrary-target edge applies without editing contract source. -- `--ref `: Target a named ref from `migrations/refs.json` instead of the current contract hash +- `--to `: Target contract reference (hash, prefix, ref name, migration directory, `^`, `@contract`, `@db`, or `@empty`). When omitted, applies toward the emitted `contract.json`; `--to @contract` does the same. When `--to` resolves to another on-disk graph node, verification and apply use the snapshot store entry for that node's hash — so a planned rollback or other arbitrary-target edge applies without editing contract source. A ref name is a `--to` form; refs live in `migrations//refs/.json`. +- `--advance-ref `: After a successful apply, advance the named ref to the new marker +- `--show`: Preview the migration route without applying anything (read-only) +- `--from `: The origin for the `--show` preview, with the same forms as `--to`. Defaults to the database marker. A contract other than `@db` makes the preview start offline; it still reads the database when `--to` is `@db`. - `--config `: Path to `prisma.config.ts` - `--json`: Output as JSON object - `-q, --quiet`: Quiet mode (errors only) @@ -1100,7 +1102,7 @@ prisma db migrate [--db ] [--to ] [--config ] [--json] [-v] **What it does:** 1. Reads migration packages from `config.migrations.dir`. Every package is attested — there is no on-disk draft state. The loader (`readMigrationPackage` in `@internal/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//migration.ts` (or restore from version control). 2. Reconstructs the migration graph from all loaded packages -3. Determines the destination hash and apply contract: from `--to` / `--ref`, or from `contract.json` when neither is supplied +3. Determines the destination hash and apply contract: from `--to`, or from `contract.json` when `--to` is omitted or `@contract` 4. Connects to the database and reads the current marker hash 5. Finds the shortest path from the marker hash to the destination using graph pathfinding 6. Executes each pending migration in order using the target's `MigrationRunner` @@ -1113,8 +1115,6 @@ prisma db migrate [--db ] [--to ] [--config ] [--json] [-v] **Resume semantics:** If a migration fails, previously applied migrations are preserved. Re-running `db migrate` resumes from the last successful migration. -**Ref-based routing:** With `--ref`, apply targets the ref's hash instead of the contract hash. This enables multi-environment workflows where staging and production track different points in the migration graph. - ### Emitting `ops.json` and computing `migrationHash` There is no dedicated CLI command for emitting a migration — migrations @@ -1132,7 +1132,7 @@ The scaffolded `migration.ts` calls `MigrationCLI.run(import.meta.url, ...)` fro ### `prisma migration ref` -Manage named refs in `migrations/refs.json`. Refs map logical environment names (e.g., `staging`, `production`) to contract hashes, enabling multi-environment migration workflows where different environments track different points in the migration graph. +Manage named refs, one file per ref at `migrations//refs/.json`. Refs map logical environment names (e.g., `staging`, `production`) to contract hashes, enabling multi-environment migration workflows where different environments track different points in the migration graph. ```bash prisma migration ref set # Set a ref to a contract (hash, ref, dir, ...) @@ -1149,7 +1149,7 @@ prisma migration ref delete # Delete a ref **Ref values:** Must be valid contract hashes (64 lowercase hex chars, or the `empty` sentinel). -**Atomic writes:** `refs.json` is written atomically via temp file + rename to prevent corruption from concurrent writes. +**Atomic writes:** each ref file is written atomically via temp file + rename to prevent corruption from concurrent writes. ## Architecture diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts index 997d482308d4..b8478b66e725 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts @@ -12,7 +12,11 @@ import { errorContractDeserializationFailed, MigrationToolsError, } from '@internal/migration-tools/errors'; -import { parseContractRef } from '@internal/migration-tools/ref-resolution'; +import { + isReservedContractRef, + parseContractRef, + type RefResolutionWrongGrammar, +} from '@internal/migration-tools/ref-resolution'; import { blindCast, castAs } from '@internal/utils/casts'; import { notOk, ok, type Result } from '@internal/utils/result'; import { join } from 'pathe'; @@ -23,6 +27,7 @@ import { errorUnexpected, mapRefResolutionError, } from '../../utils/cli-errors'; +import { RECORDED_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; import { snapshotVerifierFor } from '../../utils/snapshot-content-verification'; import { errorFromCaught } from './caught-errors'; import { buildReadAggregate } from './contract-space-aggregate-loader'; @@ -34,24 +39,25 @@ function isEnoent(error: unknown): boolean { interface ResolveContractRefToSnapshotBaseOptions { readonly config: PrismaNextConfig; readonly migrationsDir: string; - /** User-supplied contract reference (hash, prefix, ref name, migration dir name, ^, or ./path). */ + /** User-supplied contract reference (hash, prefix, ref name, migration dir name, or ^). */ readonly refInput: string; - /** Absolute path of the emitted contract.json (fallback source + snapshot-path derivation). */ - readonly contractPathAbsolute: string; + /** How errors name the argument that carried `refInput`, for example `--to`. */ + readonly argument: string; } /** - * `fallbackToEmitted` discriminates the missing-bundle behavior: - * true (db sign): fall back to the emitted contract when no bundle matches and its - * storage.storageHash matches; else the 'No contract file found for hash ""' errorRuntime. - * false (db update --to): missing bundle = the errorUnexpected 'No migration bundle found for - * "" (resolved hash: )' envelope, so `missingBundleFlag` (the flag label - * for that message) is required in this branch. + * `fallbackToEmitted` decides what happens when no migration bundle ends at the resolved hash: + * true (db sign) falls back to the emitted contract when its storage hash matches; false + * (db update --to) fails, because the argument must name a migration destination. */ export type ResolveContractRefToSnapshotOptions = ResolveContractRefToSnapshotBaseOptions & ( - | { readonly fallbackToEmitted: true; readonly missingBundleFlag?: never } - | { readonly fallbackToEmitted: false; readonly missingBundleFlag: '--to' } + | { + readonly fallbackToEmitted: true; + /** Absolute path of the emitted contract.json, the fallback source. */ + readonly contractPathAbsolute: string; + } + | { readonly fallbackToEmitted: false } ); export interface ResolveContractRefToSnapshotSuccess { @@ -62,9 +68,26 @@ export interface ResolveContractRefToSnapshotSuccess { readonly source: 'snapshot' | 'emitted'; } +function reservedRefRefusal( + options: ResolveContractRefToSnapshotOptions, +): RefResolutionWrongGrammar { + const { refInput: input, argument } = options; + const accepted = `${options.fallbackToEmitted ? 'a contract' : 'a migration destination'} recorded in the migrations directory`; + return { + kind: 'wrong-grammar', + input, + expectedGrammar: 'contract', + message: `"${input}" is a reserved reference; ${argument} takes ${accepted} (${RECORDED_CONTRACT_REF_FORMS})`, + fix: `Name ${accepted}, or omit ${argument} to use the emitted contract.`, + }; +} + export async function resolveContractRefToSnapshot( options: ResolveContractRefToSnapshotOptions, ): Promise> { + if (isReservedContractRef(options.refInput)) { + return notOk(mapRefResolutionError(reservedRefRefusal(options))); + } try { const loaded = await buildReadAggregate(options.config, { migrationsDir: options.migrationsDir, @@ -105,7 +128,7 @@ export async function resolveContractRefToSnapshot( if (!options.fallbackToEmitted) { return notOk( errorUnexpected( - `No migration bundle found for ${options.missingBundleFlag} "${options.refInput}" (resolved hash: ${targetHash})`, + `No migration bundle found for ${options.argument} "${options.refInput}" (resolved hash: ${targetHash})`, { why: `The ref resolved successfully but no on-disk migration package has a destination (\`to\`) hash matching ${targetHash}.`, fix: 'Provide a ref or hash that corresponds to an existing migration package, or run `migration list` to see available migrations.', diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts index 83b2ae937dd4..50605066fc80 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts @@ -6,29 +6,33 @@ import type { PrismaNextConfig } from '@internal/config/config-types'; import { type AggregateContractSpace, type ContractSpaceAggregate, + contractHashAtMarker, requireHeadRef, spacesInApplyOrder, } from '@internal/migration-tools/aggregate'; import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; import { MigrationToolsError } from '@internal/migration-tools/errors'; -import { parseContractRef } from '@internal/migration-tools/ref-resolution'; import type { Refs } from '@internal/migration-tools/refs'; import { readRefs } from '@internal/migration-tools/refs'; +import { ifDefined } from '@internal/utils/defined'; import { notOk, ok, type Result } from '@internal/utils/result'; import { type CliStructuredError, - errorDatabaseConnectionRequired, errorPathUnreachable, errorRuntime, - mapRefResolutionError, - requireLiveDatabase, } from '../../utils/cli-errors'; import { closeQuietly, resolveMigrationPaths } from '../../utils/command-helpers'; import { createControlClient } from '../client'; import type { CreateControlClient } from '../types'; import { errorFromCaught } from './caught-errors'; import { buildReadAggregate } from './contract-space-aggregate-loader'; +import { refuseMarkerOutsideGraph } from './graph-queries'; import { planSpacePath } from './migrate'; +import { + liveMarkerUse, + requireDatabaseForLiveMarkerUse, + resolveContractRef, +} from './ref-resolution'; /** * One migration that will run in a `migrate --show` preview, in execution order. @@ -67,10 +71,8 @@ export interface MigrateShowPlanSuccess { readonly contractHash: string; readonly migrations: readonly MigrateShowMigration[]; readonly summary: string; - /** Per-space render hash: live/override marker storageHash, pre-defaulted to the empty sentinel. */ - readonly renderMarkerHashBySpace: ReadonlyMap; - /** True when the live DB marker was read — gates the ★ db marker in the tree. */ - readonly usedLiveMarker: boolean; + /** Present when the preview read the database: the marker hash, or the empty contract, of each space whose plan uses the marker. */ + readonly databaseMarkerHashBySpace?: ReadonlyMap; } /** @@ -94,21 +96,22 @@ export async function executeMigrateShowPlan( ); const dbConnection = options.db ?? config.db?.connection; - const hasDriver = !!config.driver; + const driver = config.driver; const hasExplicitFrom = options.from !== undefined; + const { liveOrigin, liveTarget, needsDatabase } = liveMarkerUse({ + from: options.from, + to: options.to, + }); - // When --from is omitted we read the live DB marker (same as migrate's default). - // When --from is given, we're in offline hypothetical mode — no connection needed. - if (!hasExplicitFrom) { - const missingDb = requireLiveDatabase({ - dbConnection, - hasDriver, - why: 'migrate --show needs a database connection to read the live marker (or pass --from for an offline preview)', - retryCommand: '{bin} db migrate --show --from ', - }); - if (missingDb) { - return notOk(missingDb); - } + const missingDb = requireDatabaseForLiveMarkerUse({ + from: options.from, + to: options.to, + dbConnection, + hasDriver: driver !== undefined, + commandName: 'db migrate --show', + }); + if (missingDb) { + return notOk(missingDb); } let allRefs: Refs = {}; @@ -133,22 +136,11 @@ export async function executeMigrateShowPlan( // same target invariants that real migrate would use (refInvariants ?? headRef.invariants). let targetHash: string = contractHash; let refInvariants: readonly string[] | undefined; - if (options.to) { - const toResult = parseContractRef(options.to, { - graph: appGraph, - refs: allRefs, - contractHash, - }); + const refContext = { graph: appGraph, refs: allRefs, contractHash }; + if (options.to && !liveTarget) { + const toResult = resolveContractRef(options.to, refContext); if (!toResult.ok) { - return notOk(mapRefResolutionError(toResult.failure)); - } - if (toResult.value.provenance.kind === 'reserved-db') { - return notOk( - errorDatabaseConnectionRequired({ - why: '@db is not valid as a --to target; it names the live database state, not a target contract.', - commandName: 'migrate --show', - }), - ); + return notOk(toResult.failure); } targetHash = toResult.value.hash; if (toResult.value.provenance.kind === 'ref') { @@ -165,8 +157,8 @@ export async function executeMigrateShowPlan( }); // Resolve the from-state. - // - Explicit --from: parse it offline (no connection). - // - Omitted: read the live DB marker via readAllMarkers() — the same source migrate uses. + // - Explicit --from other than @db: parse it offline (no connection). + // - Omitted or @db: read the live DB marker via readAllMarkers() — the same source migrate uses. // // Full marker records (storageHash + invariants) are preserved so planSpacePath // can feed resolveRecordedPath the complete currentMarker — exactly as executeMigrate @@ -174,79 +166,55 @@ export async function executeMigrateShowPlan( // marker would produce a different `required` set and a different (incorrect) path. type LiveMarker = { readonly storageHash: string; readonly invariants: readonly string[] }; const markerBySpace = new Map(); + let databaseMarkers: ReadonlyMap | undefined; const allSpaces: ReadonlyArray = [aggregate.app, ...aggregate.extensions]; - if (hasExplicitFrom) { - // @db with explicit --from requires a connection - if (options.from === '@db') { - const missingDb = requireLiveDatabase({ - dbConnection, - hasDriver, - why: '@db resolves to the live database marker and requires a --db connection', - retryCommand: '{bin} db migrate --show --from @db --db $DATABASE_URL', - }); - if (missingDb) { - return notOk(missingDb); - } - // Fall through to the connection path below - } else { - const fromResult = parseContractRef(options.from, { - graph: appGraph, - refs: allRefs, - contractHash, - }); - if (!fromResult.ok) { - return notOk(mapRefResolutionError(fromResult.failure)); - } - if (fromResult.value.provenance.kind === 'reserved-db') { - // Unreachable given the @db branch above, but guard for safety - const missingDb = requireLiveDatabase({ - dbConnection, - hasDriver, - why: '@db resolves to the live database marker and requires a --db connection', - }); - if (missingDb) { - return notOk(missingDb); - } - } else { - // Offline hypothetical: the --from ref only carries a hash (no live invariants). - // Apply the from-hash marker to the APP space only. Extension spaces are left - // absent from markerBySpace (treated as null / greenfield by planSpacePath), - // so they plan from their own marker → own head — exactly as executeMigrate does. - const fromHash = fromResult.value.hash; - const offlineMarker: LiveMarker | null = - fromHash === EMPTY_CONTRACT_HASH ? null : { storageHash: fromHash, invariants: [] }; - markerBySpace.set(aggregate.app.spaceId, offlineMarker); - } + if (options.from !== undefined && !liveOrigin) { + const fromResult = resolveContractRef(options.from, refContext); + if (!fromResult.ok) { + return notOk(fromResult.failure); } + // The --from contract carries a hash and no invariants, and applies to the app + // space only. Extension spaces stay out of markerBySpace, so they plan from the + // empty contract to their own head. + const fromHash = fromResult.value.hash; + const offlineMarker: LiveMarker | null = + fromHash === EMPTY_CONTRACT_HASH ? null : { storageHash: fromHash, invariants: [] }; + markerBySpace.set(aggregate.app.spaceId, offlineMarker); } - // If we need the live DB marker (no --from, or --from @db), connect and read. - const needsLiveMarker = !hasExplicitFrom || options.from === '@db'; - if (needsLiveMarker) { - if (!dbConnection || !hasDriver) { - return notOk( - errorDatabaseConnectionRequired({ - why: 'A database connection is required to read the live marker for migrate --show', - commandName: 'migrate --show', - }), - ); - } + if (needsDatabase && driver !== undefined) { const client = (options.createClient ?? createControlClient)({ family: config.family, target: config.target, adapter: config.adapter, - driver: config.driver!, + driver, extensions: config.extensions ?? [], }); try { await client.connect(dbConnection); const allMarkers = await client.readAllMarkers(); + databaseMarkers = allMarkers; + const appMarker = allMarkers.get(aggregate.app.spaceId); + if (appMarker !== undefined) { + const refusal = refuseMarkerOutsideGraph({ + markerHash: appMarker.storageHash, + graph: appGraph, + }); + if (refusal) { + return notOk(refusal); + } + } // Store the full marker record (storageHash + invariants) per space. // This is the same data executeMigrate uses via familyInstance.readAllMarkers(). - for (const space of allSpaces) { - const marker = allMarkers.get(space.spaceId); - markerBySpace.set(space.spaceId, marker ?? null); + if (liveOrigin) { + for (const space of allSpaces) { + const marker = allMarkers.get(space.spaceId); + markerBySpace.set(space.spaceId, marker ?? null); + } + } + if (liveTarget) { + targetHash = contractHashAtMarker(appMarker); } } catch (error) { return notOk( @@ -335,19 +303,22 @@ export async function executeMigrateShowPlan( ? 'Already up to date — nothing to run' : `${count} migration${count === 1 ? '' : 's'} will run`; - const renderMarkerHashBySpace = new Map( - allSpaces.map((s) => [ - s.spaceId, - markerBySpace.get(s.spaceId)?.storageHash ?? EMPTY_CONTRACT_HASH, - ]), - ); + const spacesUsingMarker = liveOrigin ? allSpaces : [aggregate.app]; + const databaseMarkerHashBySpace = + databaseMarkers === undefined + ? undefined + : new Map( + spacesUsingMarker.map((space) => [ + space.spaceId, + contractHashAtMarker(databaseMarkers.get(space.spaceId)), + ]), + ); return ok({ aggregate, contractHash, migrations: orderedMigrations, summary, - renderMarkerHashBySpace, - usedLiveMarker: needsLiveMarker, + ...ifDefined('databaseMarkerHashBySpace', databaseMarkerHashBySpace), }); } diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts index 452b69995119..effb0f383617 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts @@ -16,6 +16,7 @@ import { buildFabricatedMigrationEdge, type ContractMarkerRecordLike, type ContractSpaceAggregate, + contractHashAtMarker, type PerSpacePlan, requireHeadRef, resolveRecordedPath, @@ -156,12 +157,10 @@ export async function executeMigrate = [aggregate.app, ...aggregate.extensions]; const perSpacePlans = new Map(); - // Already-at-head empty-graph spaces (typically extensions whose - // head ref is the empty sentinel, or whose live marker already - // matches the target). Kept out of the runner schedule so we don't - // write spurious markers for greenfield extensions, but merged back - // into the success envelope so every loaded space is represented. - const atHeadResolutions = new Map(); + // An empty-graph space already at its target, or a space with no marker + // whose plan moves nothing. The runner would write a marker for each, so + // they stay out of its schedule; the success envelope still lists them. + const plansKeptFromRunner = new Map(); for (const space of allSpaces) { const isAppSpace = space.spaceId === aggregate.app.spaceId; // The aggregate passed the integrity gate, so every space's head ref @@ -181,11 +180,7 @@ export async function executeMigrate space.spaceId); const applyOrder = canonicalOrder.filter((spaceId) => perSpacePlans.has(spaceId)); - // Short-circuit: nothing pending across any space (no runner-bound - // plans). Surfaces every loaded space — including at-head empty- - // graph extensions — in `perSpace[]` so the result reflects the - // full aggregate, not just the spaces the runner would have touched. - // A zero-op plan still counts as pending when it advances a marker - // (declared-state resolution for an all-external extension space). + // Short-circuit when no space has work. `perSpace[]` still lists every + // loaded space, including those kept from the runner. A zero-op plan + // counts as work when it advances a marker (declared-state resolution for + // an all-external extension space). const hasPendingWork = applyOrder.some((spaceId) => { const entry = perSpacePlans.get(spaceId); return entry !== undefined && planRequiresExecution(entry); }); if (!hasPendingWork) { const ordered = canonicalOrder - .filter((spaceId) => perSpacePlans.has(spaceId) || atHeadResolutions.has(spaceId)) + .filter((spaceId) => perSpacePlans.has(spaceId) || plansKeptFromRunner.has(spaceId)) .map((spaceId) => { - const entry = perSpacePlans.get(spaceId) ?? atHeadResolutions.get(spaceId); + const entry = perSpacePlans.get(spaceId) ?? plansKeptFromRunner.get(spaceId); if (entry === undefined) { throw new InternalError(`Unreachable: missing per-space plan for "${spaceId}"`); } @@ -301,17 +298,17 @@ export async function executeMigrate perSpacePlans.has(spaceId) || atHeadResolutions.has(spaceId)) + .filter((spaceId) => perSpacePlans.has(spaceId) || plansKeptFromRunner.has(spaceId)) .map((spaceId) => { if (perSpacePlans.has(spaceId)) { const fromRunner = applied.value.orderedResolutions.find((r) => r.spaceId === spaceId); if (fromRunner !== undefined) return fromRunner; } - const entry = atHeadResolutions.get(spaceId); + const entry = plansKeptFromRunner.get(spaceId); if (entry === undefined) { throw new InternalError(`Unreachable: missing per-space plan for "${spaceId}"`); } @@ -521,6 +518,11 @@ function buildAtHeadResolution(args: { }; } +/** Handing this plan to the runner would write a marker the database never had. */ +function leavesUnmarkedSpaceUntouched(entry: PerSpacePlan): boolean { + return !entry.plan.origin && !planRequiresExecution(entry); +} + /** * A plan needs the runner when it executes operations or advances the * space's marker (a declared-state resolution has zero operations but a @@ -528,7 +530,7 @@ function buildAtHeadResolution(args: { */ function planRequiresExecution(entry: PerSpacePlan): boolean { if (entry.plan.operations.length > 0) return true; - return entry.plan.origin?.storageHash !== entry.plan.destination.storageHash; + return contractHashAtMarker(entry.plan.origin) !== entry.plan.destination.storageHash; } interface BuildSuccessArgs { diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-status-overlay.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-status-overlay.ts index c1384381458b..0418a762aafe 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-status-overlay.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-status-overlay.ts @@ -1,13 +1,7 @@ -import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; import type { MigrationGraph } from '@internal/migration-tools/graph'; import { findPath } from '@internal/migration-tools/migration-graph'; import type { MigrationEdgeAnnotation } from '../../utils/formatters/migration-graph-labels'; -/** Origin hash for status path computation: the live/override marker, or the empty-contract sentinel. */ -export function originHashForStatus(markerHash: string | undefined): string { - return markerHash ?? EMPTY_CONTRACT_HASH; -} - export interface DeriveStatusEdgeAnnotationsInput { readonly graph: MigrationGraph; readonly targetHash: string; diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts index f00ad0bac42f..c2acd022512c 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts @@ -4,10 +4,19 @@ import type { MigrationGraph } from '@internal/migration-tools/graph'; import type { ContractRef, MigrationRef } from '@internal/migration-tools/ref-resolution'; -import { parseContractRef, parseMigrationRef } from '@internal/migration-tools/ref-resolution'; +import { + isLiveMarkerRef, + LIVE_MARKER_REF, + parseContractRef, + parseMigrationRef, +} from '@internal/migration-tools/ref-resolution'; import type { Refs } from '@internal/migration-tools/refs'; import { notOk, ok, type Result } from '@internal/utils/result'; -import { type CliStructuredError, mapRefResolutionError } from '../../utils/cli-errors'; +import { + type CliStructuredError, + mapRefResolutionError, + requireLiveDatabase, +} from '../../utils/cli-errors'; export interface RefResolutionContext { readonly graph: MigrationGraph; @@ -30,3 +39,69 @@ export function resolveMigrationRef( const result = parseMigrationRef(input, context); return result.ok ? ok(result.value) : notOk(mapRefResolutionError(result.failure)); } + +export interface LiveMarkerUse { + readonly liveOrigin: boolean; + readonly liveTarget: boolean; + readonly needsDatabase: boolean; +} + +/** Where `--from`/`--to` read the live marker: an omitted or `@db` origin, and an `@db` target. */ +export function liveMarkerUse(flags: { + readonly from: string | undefined; + readonly to: string | undefined; +}): LiveMarkerUse { + const liveOrigin = flags.from === undefined || isLiveMarkerRef(flags.from); + const liveTarget = isLiveMarkerRef(flags.to); + return { liveOrigin, liveTarget, needsDatabase: liveOrigin || liveTarget }; +} + +/** The command a missing-connection error suggests: the user's flags as given, plus what the retry needs to run. */ +export function retryCommandFor(args: { + readonly commandName: string; + readonly from?: string | undefined; + readonly to: string | undefined; + readonly advanceRef?: string | undefined; + /** The command runs without a database when `--from` names a contract. */ + readonly canRunOffline: boolean; +}): string { + const namesLiveMarker = isLiveMarkerRef(args.from) || isLiveMarkerRef(args.to); + const suggestsOffline = args.canRunOffline && args.from === undefined && !namesLiveMarker; + const needsConnection = !args.canRunOffline || namesLiveMarker; + return [ + `{bin} ${args.commandName}`, + ...(args.from === undefined ? [] : [`--from ${args.from}`]), + ...(suggestsOffline ? ['--from '] : []), + ...(args.to === undefined ? [] : [`--to ${args.to}`]), + ...(args.advanceRef === undefined ? [] : [`--advance-ref ${args.advanceRef}`]), + ...(needsConnection ? ['--db $DATABASE_URL'] : []), + ].join(' '); +} + +/** The missing-connection error for a command whose `--from`/`--to` read the live marker, or `null`. */ +export function requireDatabaseForLiveMarkerUse(args: { + readonly from: string | undefined; + readonly to: string | undefined; + readonly dbConnection: unknown; + readonly hasDriver: boolean; + readonly commandName: string; +}): CliStructuredError | null { + if (!liveMarkerUse(args).needsDatabase) { + return null; + } + return requireLiveDatabase({ + dbConnection: args.dbConnection, + hasDriver: args.hasDriver, + why: + isLiveMarkerRef(args.from) || isLiveMarkerRef(args.to) + ? `${LIVE_MARKER_REF} resolves to the live database marker and requires a --db connection` + : `${args.commandName} needs a database connection to read the live marker (or pass --from to run offline)`, + commandName: args.commandName, + retryCommand: retryCommandFor({ + commandName: args.commandName, + from: args.from, + to: args.to, + canRunOffline: true, + }), + }); +} diff --git a/packages/1-framework/3-tooling/cli/src/exports/control-api.ts b/packages/1-framework/3-tooling/cli/src/exports/control-api.ts index c3dabf7554b6..edc7f2ecaefe 100644 --- a/packages/1-framework/3-tooling/cli/src/exports/control-api.ts +++ b/packages/1-framework/3-tooling/cli/src/exports/control-api.ts @@ -109,7 +109,6 @@ export { export { appliedHashesFromLedger, deriveStatusEdgeAnnotations, - originHashForStatus, statusForMigrationHash, } from '../control-api/operations/migration-status-overlay'; export { diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/prepare.ts b/packages/1-framework/3-tooling/cli/src/orm/db/prepare.ts index ea52ba8e9b28..2dec9e5b6997 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/prepare.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/prepare.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises'; import type { PrismaNextConfig } from '@internal/config/config-types'; import { castAs } from '@internal/utils/casts'; +import { ifDefined } from '@internal/utils/defined'; import type { CliStructuredError, Result } from '@prisma/cli-engine/protocol'; import { notOk, ok } from '@prisma/cli-engine/protocol'; import { errorFromCaught } from '../../control-api/operations/caught-errors'; @@ -73,6 +74,8 @@ export async function prepareMigrationRun(inputs: { readonly db: string | undefined; readonly commandName: string; readonly createClient: CreateControlClient; + /** The command the missing-connection error suggests; defaults to `{bin} --db `. */ + readonly retryCommand?: string; }): Promise> { const { config, cwd, commandName } = inputs; const contractPath = contractPathFor(config); @@ -100,6 +103,7 @@ export async function prepareMigrationRun(inputs: { why: `Database connection is required for ${commandName} (set db.connection in prisma.config.ts, or pass --db )`, commandName, missingFlags: ['--db'], + ...ifDefined('retryCommand', inputs.retryCommand), }), ), ); diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts b/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts index 19e739056cdf..d9943c2de5b1 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts @@ -28,6 +28,7 @@ import { type UnwrittenRef, } from '../../utils/cli-errors'; import { closeQuietly, maskConnectionUrl } from '../../utils/command-helpers'; +import { RECORDED_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; import { appRefsDirFor, baseDirFor, migrationsDirFor } from '../migration/paths'; @@ -57,6 +58,8 @@ const FINDINGS_EXIT_CODE = 4; */ const DEFAULT_ADVANCE_REF = 'db'; +const CONTRACT_REF_BRIEF = `Contract reference (${RECORDED_CONTRACT_REF_FORMS})`; + interface AdvancedRef { readonly space: string; readonly name: string; @@ -408,17 +411,13 @@ export function createDbSignCommand( args: { positionals: { contract: positional.optionalString({ - brief: 'Contract reference (hash, prefix, ref name, or migration dir name)', + brief: CONTRACT_REF_BRIEF, placeholder: 'contract', }), }, flags: { db: dbFlag, - contract: flag.string({ - brief: - 'Contract reference (hash, prefix, ref name, migration dir name, ^, or ./path)', - placeholder: 'contract', - }), + contract: flag.string({ brief: CONTRACT_REF_BRIEF, placeholder: 'contract' }), advanceRef: flag.string({ brief: 'Advance the named ref to the post-command contract hash', placeholder: 'name', @@ -466,6 +465,7 @@ export function createDbSignCommand( config: ctx.config, migrationsDir, refInput: contractRef, + argument: 'the contract argument', contractPathAbsolute: emitted.value.path, fallbackToEmitted: true, }); diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts index b42ee859868f..beb98f76e9eb 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts @@ -10,7 +10,10 @@ import { } from '@prisma/cli-engine/protocol'; import { createControlClient } from '../../control-api/client'; import { errorFromCaught } from '../../control-api/operations/caught-errors'; -import { resolveContractRefToSnapshot } from '../../control-api/operations/contract-snapshot-resolution'; +import { + type ResolveContractRefToSnapshotSuccess, + resolveContractRefToSnapshot, +} from '../../control-api/operations/contract-snapshot-resolution'; import { buildRefAdvancementFields, type ContractIR, @@ -18,14 +21,16 @@ import { NO_REF_ADVANCEMENT, preflightRefAdvancement, } from '../../control-api/operations/ref-advancement'; +import { retryCommandFor } from '../../control-api/operations/ref-resolution'; import type { CreateControlClient, DbUpdateResult, DbUpdateSuccess } from '../../control-api/types'; import { CliStructuredError, errorContractValidationFailed } from '../../utils/cli-errors'; import { closeQuietly } from '../../utils/command-helpers'; +import { RECORDED_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; import { mapDbUpdateFailure } from '../../utils/db-update-failure'; import type { MigrationCommandResult } from '../../utils/formatters/migrations'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; -import { baseDirFor } from '../migration/paths'; +import { baseDirFor, migrationsDirFor } from '../migration/paths'; import { normalizeError } from '../normalize-error'; import { controlProgressReporter } from '../progress'; import { @@ -136,7 +141,7 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { db: dbFlag, dryRun: flag.boolean({ brief: 'Preview the planned operations without applying them' }), to: flag.string({ - brief: 'Contract to update to (hash, prefix, ref name, migration dir name, or ./path)', + brief: `Contract to update to (${RECORDED_CONTRACT_REF_FORMS})`, placeholder: 'contract', }), advanceRef: flag.string({ @@ -148,35 +153,40 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { needs: { config: ormConfigSection }, handler: async (args, ctx) => { const startedAt = Date.now(); + let destination: ResolveContractRefToSnapshotSuccess | undefined; + if (args.flags.to !== undefined) { + const resolved = await resolveContractRefToSnapshot({ + config: ctx.config, + migrationsDir: migrationsDirFor(ctx.config), + refInput: args.flags.to, + argument: '--to', + fallbackToEmitted: false, + }); + if (!resolved.ok) { + return notOk(normalizeError(resolved.failure)); + } + destination = resolved.value; + } + const prepared = await prepareMigrationRun({ config: ctx.config, cwd: ctx.cwd, db: args.flags.db, commandName: 'db update', createClient, + retryCommand: retryCommandFor({ + commandName: args.flags.dryRun ? 'db update --dry-run' : 'db update', + to: args.flags.to, + advanceRef: args.flags.advanceRef, + canRunOffline: false, + }), }); if (!prepared.ok) { return notOk(prepared.failure); } const { client, contractPath, dbConnection, migrationsDir, refsDir } = prepared.value; - - let contractJson = prepared.value.contractJson; - let snapshotContractPath = contractPath; - if (args.flags.to !== undefined) { - const resolved = await resolveContractRefToSnapshot({ - config: ctx.config, - migrationsDir, - refInput: args.flags.to, - contractPathAbsolute: contractPath, - fallbackToEmitted: false, - missingBundleFlag: '--to', - }); - if (!resolved.ok) { - return notOk(normalizeError(resolved.failure)); - } - contractJson = resolved.value.contractJson; - snapshotContractPath = resolved.value.contractJsonPath; - } + const contractJson = destination?.contractJson ?? prepared.value.contractJson; + const snapshotContractPath = destination?.contractJsonPath ?? contractPath; const refName = computeRefAdvancementName({ ...ifDefined('advanceRef', args.flags.advanceRef), diff --git a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts index c90868e05757..638f0284716b 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts @@ -1,9 +1,10 @@ import { ormConfigSection } from '@internal/config-loader'; import type { Contract } from '@internal/contract/types'; import { createControlStack } from '@internal/framework-components/control'; +import { contractHashAtMarker } from '@internal/migration-tools/aggregate'; import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; -import type { MigrationGraph } from '@internal/migration-tools/graph'; -import type { RefEntry, Refs } from '@internal/migration-tools/refs'; +import { isLiveMarkerRef } from '@internal/migration-tools/ref-resolution'; +import type { RefEntry } from '@internal/migration-tools/refs'; import { blindCast, castAs } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; import type { Block, Presentations } from '@prisma/cli-engine'; @@ -29,7 +30,11 @@ import { type ContractIR, preflightRefAdvancement, } from '../control-api/operations/ref-advancement'; -import { resolveContractRef } from '../control-api/operations/ref-resolution'; +import { + type RefResolutionContext, + resolveContractRef, + retryCommandFor, +} from '../control-api/operations/ref-resolution'; import type { CreateControlClient, MigratePathDecision, @@ -37,6 +42,7 @@ import type { } from '../control-api/types'; import { errorContractValidationFailed } from '../utils/cli-errors'; import { closeQuietly, maskConnectionUrl } from '../utils/command-helpers'; +import { ALL_CONTRACT_REF_FORMS } from '../utils/contract-ref-forms'; import { toDeclaredExtensionsFromRaw } from '../utils/extension-pack-inputs'; import { migrateShowRunListRows, @@ -179,28 +185,38 @@ interface RequestedTarget { readonly refName: string | undefined; } +const EMITTED_CONTRACT_TARGET: RequestedTarget = { entry: undefined, refName: undefined }; + /** * `--to` as a contract the app graph knows. A ref target keeps the invariants - * the ref declares; a bare hash carries none. Omitting `--to` targets the - * emitted contract, which needs no resolution at all. + * the ref declares; a bare hash or `@empty` carries none. An omitted `--to` + * and `@contract` both target the emitted contract. `@db` is not resolved + * here: it needs the live marker, which is read only once the connection is + * open. */ function resolveRequestedTarget( to: string | undefined, - refs: Refs, - graph: MigrationGraph, + context: RefResolutionContext, ): Result { if (to === undefined) { - return ok({ entry: undefined, refName: undefined }); + return ok(EMITTED_CONTRACT_TARGET); } - const resolved = resolveContractRef(to, { graph, refs }); + const resolved = resolveContractRef(to, context); if (!resolved.ok) { return notOk(normalizeError(resolved.failure)); } + if (resolved.value.provenance.kind === 'reserved-contract') { + return ok(EMITTED_CONTRACT_TARGET); + } if (resolved.value.provenance.kind !== 'ref') { return ok({ entry: { hash: resolved.value.hash, invariants: [] }, refName: undefined }); } const refName = resolved.value.provenance.refName; - return ok({ entry: refs[refName], refName }); + return ok({ entry: context.refs[refName], refName }); +} + +function liveMarkerTarget(appMarker: { readonly storageHash: string } | null): RequestedTarget { + return { entry: { hash: contractHashAtMarker(appMarker), invariants: [] }, refName: undefined }; } export function createMigrateCommand(createClient: CreateControlClient) { @@ -211,8 +227,8 @@ export function createMigrateCommand(createClient: CreateControlClient) { 'Walks every contract space (app + extensions) and applies pending on-disk\n' + 'migrations in canonical order (extensions alphabetically, then app). It\n' + 'replays the on-disk migration graph and never invents an edge. Use --to to\n' + - 'target a specific contract (hash, ref name, or migration directory) and\n' + - '--show for a read-only preview of the route it would take.', + 'target a specific contract and --show for a read-only preview of the route\n' + + 'it would take.', examples: [ 'db migrate', 'db migrate --db $DATABASE_URL', @@ -225,8 +241,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { flags: { db: dbFlag, to: flag.string({ - brief: - 'Target contract reference (hash, prefix, ref name, migration dir name, ^, or ./path)', + brief: `Target contract reference (${ALL_CONTRACT_REF_FORMS})`, placeholder: 'contract', }), advanceRef: flag.string({ @@ -235,7 +250,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { }), show: flag.boolean({ brief: 'Preview the migration route without applying (read-only)' }), from: flag.string({ - brief: 'From-state for the --show preview (@contract, @db, hash, ref name, or dir)', + brief: `From-state for the --show preview (${ALL_CONTRACT_REF_FORMS})`, placeholder: 'contract', }), }, @@ -281,7 +296,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { runList: migrateShowRunListRows(plan.migrations, rendering, paint), migrationsDir: migrationsRelative, database: - args.flags.from === undefined && typeof dbConnection === 'string' + plan.databaseMarkerHashBySpace !== undefined && typeof dbConnection === 'string' ? maskConnectionUrl(dbConnection) : undefined, from: args.flags.from, @@ -291,6 +306,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { ); } + const liveTarget = isLiveMarkerRef(args.flags.to); const startedAt = Date.now(); const prepared = await prepareMigrationRun({ config: ctx.config, @@ -298,6 +314,12 @@ export function createMigrateCommand(createClient: CreateControlClient) { db: args.flags.db, commandName: 'db migrate', createClient, + retryCommand: retryCommandFor({ + commandName: 'db migrate', + to: args.flags.to, + advanceRef: args.flags.advanceRef, + canRunOffline: false, + }), }); if (!prepared.ok) { return notOk(prepared.failure); @@ -346,13 +368,15 @@ export function createMigrateCommand(createClient: CreateControlClient) { return notOk(normalizeError(integrityFailure)); } - const target = resolveRequestedTarget( - args.flags.to, - aggregate.app.refs, - aggregate.app.graph(), - ); - if (!target.ok) { - return notOk(target.failure); + const offlineTarget = liveTarget + ? undefined + : resolveRequestedTarget(args.flags.to, { + graph: aggregate.app.graph(), + refs: aggregate.app.refs, + contractHash: aggregate.app.contract().storage.storageHash, + }); + if (offlineTarget !== undefined && !offlineTarget.ok) { + return notOk(offlineTarget.failure); } let document: MigrateDocument; @@ -371,13 +395,15 @@ export function createMigrateCommand(createClient: CreateControlClient) { } } - const refEntry = target.value.entry; + const target: RequestedTarget = + offlineTarget === undefined ? liveMarkerTarget(appMarker) : offlineTarget.value; + const refEntry = target.entry; if (refEntry !== undefined && refEntry.invariants.length > 0) { const invariantRefusal = refuseUnknownInvariants({ graph: appGraph, markerInvariants: appMarker?.invariants ?? [], refInvariants: refEntry.invariants, - ...ifDefined('refName', args.flags.to), + ...ifDefined('refName', target.refName), }); if (invariantRefusal) { return notOk(normalizeError(invariantRefusal)); @@ -395,7 +421,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { try { const at = await aggregate.app.contractAt( refEntry.hash, - target.value.refName === undefined ? undefined : { refName: target.value.refName }, + target.refName === undefined ? undefined : { refName: target.refName }, ); applyContract = at.contract; snapshotContractJson = blindCast< @@ -440,7 +466,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { onProgress: controlProgressReporter(ctx.report), ...ifDefined('refHash', refEntry?.hash), ...(refEntry?.invariants === undefined ? {} : { refInvariants: refEntry.invariants }), - ...(refEntry === undefined ? {} : ifDefined('refName', args.flags.to)), + ...ifDefined('refName', target.refName), }); if (!applied.ok) { return notOk(normalizeError(mapMigrateFailure(applied.failure))); diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts index 3eb007be78fd..ec3032f7caa7 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts @@ -15,6 +15,10 @@ import type { import { executeMigrationPlanCommand } from '../../control-api/operations/migration-plan'; import type { CreateControlClient, DestructivePlanOperation } from '../../control-api/types'; import { ERROR_CODE_DESTRUCTIVE_CHANGES } from '../../utils/cli-errors'; +import { + RECORDED_CONTRACT_REF_FORMS, + RECORDED_OR_EMPTY_CONTRACT_REF_FORMS, +} from '../../utils/contract-ref-forms'; import { previewBlockHeader } from '../../utils/formatters/migrations'; import { runCommandAction } from '../../utils/next-actions'; import { destructiveOperationList, errorConsentOperationsMissing } from '../db/consent'; @@ -280,13 +284,11 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { flags: { name: flag.string({ brief: 'Name slug for the migration directory', placeholder: 'slug' }), from: flag.string({ - brief: - 'Starting contract reference (hash, prefix, ref name, migration dir name, ^, @empty, or ./path)', + brief: `Starting contract reference (${RECORDED_OR_EMPTY_CONTRACT_REF_FORMS})`, placeholder: 'contract', }), to: flag.string({ - brief: - 'Destination contract reference; defaults to the emitted contract. Same grammar as --from', + brief: `Destination contract reference (${RECORDED_CONTRACT_REF_FORMS}); defaults to the emitted contract`, placeholder: 'contract', }), }, diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts index 465901e8dd8b..2001a07da71a 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts @@ -1,9 +1,12 @@ import { ormConfigSection } from '@internal/config-loader'; import type { LedgerEntryRecord } from '@internal/contract/types'; -import type { - AggregateContractSpace, - ContractMarkerRecordLike, +import { + type AggregateContractSpace, + type ContractMarkerRecordLike, + contractHashAtMarker, } from '@internal/migration-tools/aggregate'; +import { isInSpaceHistory } from '@internal/migration-tools/migration-graph'; +import type { ContractRef } from '@internal/migration-tools/ref-resolution'; import type { RefEntry, Refs } from '@internal/migration-tools/refs'; import { ifDefined } from '@internal/utils/defined'; import type { Block, Presentations, Text } from '@prisma/cli-engine'; @@ -35,13 +38,16 @@ import { import { appliedHashesFromLedger, deriveStatusEdgeAnnotations, - originHashForStatus, statusForMigrationHash, } from '../../control-api/operations/migration-status-overlay'; -import { resolveContractRef } from '../../control-api/operations/ref-resolution'; +import { + liveMarkerUse, + requireDatabaseForLiveMarkerUse, + resolveContractRef, +} from '../../control-api/operations/ref-resolution'; import { readMigrationRefs } from '../../control-api/operations/refs'; -import { requireLiveDatabase } from '../../utils/cli-errors'; import { closeQuietly, maskConnectionUrl, readContractEnvelope } from '../../utils/command-helpers'; +import { ALL_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; import { renderMigrationGraphLegend } from '../../utils/formatters/migration-graph-labels'; import { TONE_MIGRATION_GRAPH_PALETTE } from '../../utils/formatters/migration-graph-palette'; import { @@ -112,23 +118,54 @@ async function readDatabaseState(inputs: { } } +/** Where a space's status path starts: the marker the database holds, or a contract `--from` names. */ +export type StatusOrigin = + | { readonly kind: 'database'; readonly marker: ContractMarkerRecordLike | undefined } + | { readonly kind: 'offline'; readonly hash: string }; + +function originHashOf(origin: StatusOrigin | undefined): string { + return origin?.kind === 'offline' ? origin.hash : contractHashAtMarker(origin?.marker); +} + +function currentContractOf(origin: StatusOrigin | undefined): string | null { + return origin?.kind === 'offline' ? origin.hash : (origin?.marker?.storageHash ?? null); +} + +function describeOrigin(origin: StatusOrigin): string { + if (origin.kind === 'offline') { + return `the --from contract (${shortDisplayHash(origin.hash)})`; + } + return origin.marker !== undefined + ? `the database state (${shortDisplayHash(origin.marker.storageHash)})` + : 'the database state'; +} + +/** What a status path aims at: the app space's target, which `--to` may name, or an extension space's head. */ +export type StatusTarget = + | { + readonly kind: 'app'; + readonly explicitTarget: boolean; + readonly refName: string | undefined; + } + | { readonly kind: 'extension'; readonly spaceId: string }; + export function buildNoPathSummary(args: { - readonly markerHash: string | undefined; + readonly origin: StatusOrigin; readonly targetHash: string; - readonly explicitTarget: boolean; - readonly refName: string | undefined; + readonly target: StatusTarget; }): string { - const markerPart = - args.markerHash !== undefined - ? `the database state (${shortDisplayHash(args.markerHash)})` - : 'the database state'; + const markerPart = describeOrigin(args.origin); const targetShort = shortDisplayHash(args.targetHash); - if (!args.explicitTarget) { + const { target } = args; + if (target.kind === 'extension') { + return `No migration path from ${markerPart} to the head of extension space \`${target.spaceId}\` (${targetShort}).`; + } + if (!target.explicitTarget) { return `No migration path from ${markerPart} to the application's contract (${targetShort}). Run \`{bin} migration plan --name \` to author one.`; } const targetLabel = - args.refName !== undefined - ? `the target (${targetShort} via \`${args.refName}\`)` + target.refName !== undefined + ? `the target (${targetShort} via \`${target.refName}\`)` : `the target (${targetShort})`; return `No migration path from ${markerPart} to ${targetLabel}. Run \`{bin} migration plan --name \` to author one, or pass \`--to \` to pick a reachable target.`; } @@ -210,10 +247,10 @@ export const migrationStatusCommand = defineOrmCommand({ summary: 'Show migration path and pending status', description: 'Shows which migrations are pending between the database marker and the\n' + - 'target contract. Requires a database connection. Pass --from for an\n' + - 'offline path preview without a database. Use `migration graph` for\n' + - 'topology, `migration log` for history, and `migration list` for on-disk\n' + - 'enumeration.', + 'target contract. Reads the database marker by default. --from names the\n' + + 'origin instead and runs offline, unless --from or --to is @db, which reads\n' + + 'the database. Use `migration graph` for topology, `migration log` for\n' + + 'history, and `migration list` for on-disk enumeration.', examples: [ 'migration status', 'migration status --db $DATABASE_URL', @@ -228,13 +265,11 @@ export const migrationStatusCommand = defineOrmCommand({ db: dbFlag, space: flag.string({ brief: 'Narrow output to a single contract space', placeholder: 'id' }), to: flag.string({ - brief: - 'Target contract reference (hash, prefix, ref name, migration dir name, ^, or ./path)', + brief: `Target contract reference (${ALL_CONTRACT_REF_FORMS})`, placeholder: 'contract', }), from: flag.string({ - brief: - 'Origin contract reference; same grammar as --to. Supplying it switches to offline path computation', + brief: `Origin contract reference (${ALL_CONTRACT_REF_FORMS}). With --from the path is computed offline, unless --from or --to is @db`, placeholder: 'contract', }), legend: flag.boolean({ brief: 'Print a key for the tree glyphs and lane colors' }), @@ -246,18 +281,17 @@ export const migrationStatusCommand = defineOrmCommand({ const migrationsDir = migrationsDirFor(ctx.config); const dbConnection = args.flags.db ?? ctx.config.db?.connection; const hasDriver = ctx.config.driver !== undefined; - const usingFromOverride = args.flags.from !== undefined; - - if (!usingFromOverride) { - const missingDb = requireLiveDatabase({ - dbConnection, - hasDriver, - why: 'migration status needs a database connection to read the marker and ledger (or pass --from for an offline path preview)', - retryCommand: '{bin} migration status --from ', - }); - if (missingDb !== null) { - return notOk(normalizeError(missingDb)); - } + const { from, to } = args.flags; + const { liveOrigin, liveTarget, needsDatabase } = liveMarkerUse({ from, to }); + const missingDb = requireDatabaseForLiveMarkerUse({ + from, + to, + dbConnection, + hasDriver, + commandName: 'migration status', + }); + if (missingDb !== null) { + return notOk(normalizeError(missingDb)); } const refsResult = await readMigrationRefs(appRefsDirFor(ctx.config)); @@ -293,25 +327,20 @@ export const migrationStatusCommand = defineOrmCommand({ } const appGraph = aggregate.app.graph(); + const refContext = { graph: appGraph, refs, contractHash }; - let activeRefHash: string | undefined; - let activeRefName: string | undefined; - let activeRefEntry: RefEntry | undefined; - if (args.flags.to !== undefined) { - const resolved = resolveContractRef(args.flags.to, { graph: appGraph, refs }); + let toRef: ContractRef | undefined; + if (to !== undefined && !liveTarget) { + const resolved = resolveContractRef(to, refContext); if (!resolved.ok) { return notOk(normalizeError(resolved.failure)); } - activeRefHash = resolved.value.hash; - if (resolved.value.provenance.kind === 'ref') { - activeRefName = resolved.value.provenance.refName; - activeRefEntry = refs[activeRefName]; - } + toRef = resolved.value; } let fromOverrideHash: string | undefined; - if (args.flags.from !== undefined) { - const resolved = resolveContractRef(args.flags.from, { graph: appGraph, refs }); + if (from !== undefined && !liveOrigin) { + const resolved = resolveContractRef(from, refContext); if (!resolved.ok) { return notOk(normalizeError(resolved.failure)); } @@ -328,7 +357,7 @@ export const migrationStatusCommand = defineOrmCommand({ } const scopedSpaces = listed.value.spaces; - const connects = dbConnection !== undefined && hasDriver && !usingFromOverride; + const connects = needsDatabase && dbConnection !== undefined && hasDriver; let database: DatabaseState = NO_DATABASE_STATE; if (connects) { const read = await readDatabaseState({ @@ -349,7 +378,11 @@ export const migrationStatusCommand = defineOrmCommand({ } const appMarker = database.markersBySpace.get(aggregate.app.spaceId); - if (activeRefEntry !== undefined && activeRefEntry.invariants.length > 0 && connects) { + const activeRefHash = liveTarget ? contractHashAtMarker(appMarker) : toRef?.hash; + const activeRefName = toRef?.provenance.kind === 'ref' ? toRef.provenance.refName : undefined; + const activeRefEntry: RefEntry | undefined = + activeRefName === undefined ? undefined : refs[activeRefName]; + if (activeRefEntry !== undefined && activeRefEntry.invariants.length > 0 && liveOrigin) { const unknown = refuseUnknownInvariants({ graph: appGraph, markerInvariants: appMarker?.invariants ?? [], @@ -371,7 +404,11 @@ export const migrationStatusCommand = defineOrmCommand({ const emptySpaces: string[] = []; let divergedMarker: { readonly space: string; readonly markerHash: string } | undefined; let noPath: - | { readonly markerHash: string | undefined; readonly targetHash: string } + | { + readonly origin: StatusOrigin; + readonly targetHash: string; + readonly target: StatusTarget; + } | undefined; let headlineTargetHash = activeRefHash ?? contractHash; let totalPending = 0; @@ -383,30 +420,43 @@ export const migrationStatusCommand = defineOrmCommand({ } const graph = space.graph(); const spaceContractHash = space.contract().storage.storageHash; - const targetHash = activeRefHash ?? spaceContractHash; - if (entry.space === aggregate.app.spaceId) { + const isAppSpace = entry.space === aggregate.app.spaceId; + const targetHash = isAppSpace ? (activeRefHash ?? spaceContractHash) : spaceContractHash; + if (isAppSpace) { headlineTargetHash = targetHash; } - const markerHash = usingFromOverride - ? fromOverrideHash - : database.markersBySpace.get(entry.space)?.storageHash; - const originHash = originHashForStatus(markerHash); - const markerInGraph = - markerHash === undefined || graph.nodes.has(markerHash) || markerHash === spaceContractHash; - + const origin: StatusOrigin | undefined = liveOrigin + ? { kind: 'database', marker: database.markersBySpace.get(entry.space) } + : isAppSpace && fromOverrideHash !== undefined + ? { kind: 'offline', hash: fromOverrideHash } + : undefined; + const originHash = originHashOf(origin); + const markerRead = origin?.kind === 'database' || (isAppSpace && liveTarget); + const readMarker = + origin?.kind === 'database' ? origin.marker : markerRead ? appMarker : undefined; + const markerDiverged = + readMarker !== undefined && + !isInSpaceHistory(readMarker.storageHash, { graph, headHash: space.headRef?.hash }); + + if (markerDiverged) { + divergedMarker ??= { space: entry.space, markerHash: readMarker.storageHash }; + findings.push(markerNotInHistoryFinding(entry.space)); + } if ( - connects && - markerInGraph && + origin !== undefined && + !markerDiverged && originHash !== targetHash && noPath === undefined && !hasMigrationPath(graph, originHash, targetHash) ) { - noPath = { markerHash, targetHash }; - } - if (connects && markerHash !== undefined && !markerInGraph) { - divergedMarker ??= { space: entry.space, markerHash }; - findings.push(markerNotInHistoryFinding(entry.space)); + noPath = { + origin, + targetHash, + target: isAppSpace + ? { kind: 'app', explicitTarget: to !== undefined, refName: activeRefName } + : { kind: 'extension', spaceId: entry.space }, + }; } const ledger = database.ledgersBySpace.get(entry.space) ?? []; @@ -414,8 +464,8 @@ export const migrationStatusCommand = defineOrmCommand({ graph, targetHash, originHash, - appliedMigrationHashes: connects ? appliedHashesFromLedger(ledger) : new Set(), - showAppliedOverlay: connects, + appliedMigrationHashes: liveOrigin ? appliedHashesFromLedger(ledger) : new Set(), + showAppliedOverlay: liveOrigin, }); const migrations = entry.migrations.map((migration: MigrationListEntry) => ({ ...migration, @@ -425,7 +475,7 @@ export const migrationStatusCommand = defineOrmCommand({ statusSpaces.push({ space: entry.space, - currentContract: markerHash ?? null, + currentContract: currentContractOf(origin), targetContract: targetHash, migrations, }); @@ -445,13 +495,13 @@ export const migrationStatusCommand = defineOrmCommand({ glyphMode, styler, palette: TONE_MIGRATION_GRAPH_PALETTE, - isAppSpace: entry.space === aggregate.app.spaceId, - ...(connects && markerHash !== undefined ? { dbHash: markerHash } : {}), + isAppSpace, + ...(markerRead ? { dbHash: contractHashAtMarker(readMarker) } : {}), }); } const requiredInvariants = [...(activeRefEntry?.invariants ?? [])].sort(); - if (connects && requiredInvariants.length > 0) { + if (liveOrigin && requiredInvariants.length > 0) { const held = new Set(appMarker?.invariants ?? []); const missing = requiredInvariants.filter((id) => !held.has(id)); if (missing.length > 0) { @@ -459,7 +509,7 @@ export const migrationStatusCommand = defineOrmCommand({ if (activeRefHash !== undefined) { const unreachable = refuseMissingInvariantPath({ graph: appGraph, - originHash: originHashForStatus(appMarker?.storageHash), + originHash: contractHashAtMarker(appMarker), targetHash: activeRefHash, missing, ...ifDefined('refName', activeRefName), @@ -489,12 +539,7 @@ export const migrationStatusCommand = defineOrmCommand({ const summary = everySpaceEmpty ? 'No migrations found' : noPath !== undefined - ? buildNoPathSummary({ - markerHash: noPath.markerHash, - targetHash: noPath.targetHash, - explicitTarget: args.flags.to !== undefined, - refName: activeRefName, - }) + ? buildNoPathSummary(noPath) : buildStatusHeadline({ pendingCount: totalPending, targetHash: headlineTargetHash, diff --git a/packages/1-framework/3-tooling/cli/src/orm/ref/set.ts b/packages/1-framework/3-tooling/cli/src/orm/ref/set.ts index d5974f948caa..0e607c16d1a5 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/ref/set.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/ref/set.ts @@ -4,6 +4,7 @@ import { positional } from '@prisma/cli-engine'; import { notOk, ok } from '@prisma/cli-engine/protocol'; import type { RefSetResult } from '../../control-api/operations/ref'; import { executeRefSetCommand } from '../../control-api/operations/ref'; +import { RECORDED_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; @@ -50,7 +51,7 @@ export function createRefSetCommand(execute: typeof executeRefSetCommand = execu placeholder: 'name', }), contract: positional.string({ - brief: 'Contract reference: hash, prefix, ref name, migration dir name, or ^', + brief: `Contract reference (${RECORDED_CONTRACT_REF_FORMS})`, placeholder: 'contract', }), }, diff --git a/packages/1-framework/3-tooling/cli/src/utils/contract-ref-forms.ts b/packages/1-framework/3-tooling/cli/src/utils/contract-ref-forms.ts new file mode 100644 index 000000000000..a65d357aadfd --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/utils/contract-ref-forms.ts @@ -0,0 +1,22 @@ +import { + EMPTY_CONTRACT_REF, + RESERVED_CONTRACT_REFS, +} from '@internal/migration-tools/ref-resolution'; + +const RECORDED_FORMS = ['hash', 'prefix', 'ref name', 'migration dir name', '^'] as const; + +function listForms(forms: readonly string[]): string { + return `${forms.slice(0, -1).join(', ')}, or ${forms.at(-1)}`; +} + +/** The forms that name a contract recorded in the migrations directory. */ +export const RECORDED_CONTRACT_REF_FORMS = listForms(RECORDED_FORMS); + +/** The recorded forms plus `@empty`. */ +export const RECORDED_OR_EMPTY_CONTRACT_REF_FORMS = listForms([ + ...RECORDED_FORMS, + EMPTY_CONTRACT_REF, +]); + +/** The recorded forms plus every reserved reference. */ +export const ALL_CONTRACT_REF_FORMS = listForms([...RECORDED_FORMS, ...RESERVED_CONTRACT_REFS]); diff --git a/packages/1-framework/3-tooling/cli/src/utils/formatters/migrate-show-render.ts b/packages/1-framework/3-tooling/cli/src/utils/formatters/migrate-show-render.ts index 7ac7971d7aea..19127a33dbe8 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/formatters/migrate-show-render.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/formatters/migrate-show-render.ts @@ -101,13 +101,13 @@ export function renderMigrateShowGraph( const sections: string[] = []; for (const { space, isApp, rowModel, grid, edgeAnnotations } of spaceLayouts) { - const liveMarkerHash = plan.renderMarkerHashBySpace.get(space.spaceId); + const databaseMarkerHash = plan.databaseMarkerHashBySpace?.get(space.spaceId); const tree = renderMigrationGraphCommand({ grid, rowModel, contractHash, isAppSpace: isApp, - ...(plan.usedLiveMarker && liveMarkerHash !== undefined ? { dbHash: liveMarkerHash } : {}), + ...(databaseMarkerHash === undefined ? {} : { dbHash: databaseMarkerHash }), refsByHash: listRefsByContractHash(space), edgeAnnotationsByHash: edgeAnnotations, colorize: options.colorize, diff --git a/packages/1-framework/3-tooling/cli/test/control-api/contract-snapshot-resolution.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/contract-snapshot-resolution.test.ts index 5cc639e2fef1..415c7b104702 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/contract-snapshot-resolution.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/contract-snapshot-resolution.test.ts @@ -99,6 +99,7 @@ describe('resolveContractRefToSnapshot', () => { migrationsDir, refInput: HASH_A, contractPathAbsolute, + argument: 'the contract argument', fallbackToEmitted: true, }); expect(result.ok).toBe(true); @@ -120,6 +121,7 @@ describe('resolveContractRefToSnapshot', () => { migrationsDir, refInput: 'floating', contractPathAbsolute, + argument: 'the contract argument', fallbackToEmitted: true, }); expect(result.ok).toBe(true); @@ -142,6 +144,7 @@ describe('resolveContractRefToSnapshot', () => { migrationsDir, refInput: 'floating', contractPathAbsolute, + argument: 'the contract argument', fallbackToEmitted: true, }); expect(result.ok).toBe(false); @@ -164,9 +167,8 @@ describe('resolveContractRefToSnapshot', () => { config, migrationsDir, refInput: 'floating', - contractPathAbsolute, + argument: '--to', fallbackToEmitted: false, - missingBundleFlag: '--to', }); expect(result.ok).toBe(false); if (!result.ok) { @@ -193,6 +195,7 @@ describe('resolveContractRefToSnapshot', () => { migrationsDir, refInput: 'floating', contractPathAbsolute, + argument: 'the contract argument', fallbackToEmitted: true, }); expect(result.ok).toBe(false); @@ -213,6 +216,7 @@ describe('resolveContractRefToSnapshot', () => { migrationsDir, refInput: 'floating', contractPathAbsolute, + argument: 'the contract argument', fallbackToEmitted: true, }); expect(result.ok).toBe(false); @@ -233,6 +237,7 @@ describe('resolveContractRefToSnapshot', () => { migrationsDir, refInput: 'floating', contractPathAbsolute, + argument: 'the contract argument', fallbackToEmitted: true, }); expect(result.ok).toBe(false); @@ -244,15 +249,15 @@ describe('resolveContractRefToSnapshot', () => { } }); - it('requires missingBundleFlag when fallbackToEmitted is false (type-level)', () => { + it('requires the emitted contract path when fallbackToEmitted is true (type-level)', () => { const build = (o: ResolveContractRefToSnapshotOptions) => o; - // @ts-expect-error missingBundleFlag is required when fallbackToEmitted is false + // @ts-expect-error contractPathAbsolute is required when fallbackToEmitted is true build({ config, migrationsDir, refInput: 'x', - contractPathAbsolute, - fallbackToEmitted: false, + argument: 'the contract argument', + fallbackToEmitted: true, }); expect(true).toBe(true); }); @@ -264,6 +269,7 @@ describe('resolveContractRefToSnapshot', () => { migrationsDir, refInput: 'no-such-ref', contractPathAbsolute, + argument: 'the contract argument', fallbackToEmitted: true, }); expect(result.ok).toBe(false); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/migrate-runner-schedule.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/migrate-runner-schedule.test.ts new file mode 100644 index 000000000000..eccad7308466 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/control-api/migrate-runner-schedule.test.ts @@ -0,0 +1,264 @@ +import { rm } from 'node:fs/promises'; +import type { Contract, ContractMarkerRecord } from '@internal/contract/types'; +import type { + ControlDriverInstance, + ControlExtensionDescriptor, + ControlFamilyInstance, + MigrationPlanOperation, + MigrationRunner, + MigrationRunnerPerSpaceOptions, + TargetMigrationsCapability, +} from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; +import { writeContractSnapshot } from '@internal/migration-tools/contract-snapshot-store'; +import { computeMigrationHash } from '@internal/migration-tools/hash'; +import { writeMigrationPackage } from '@internal/migration-tools/io'; +import type { MigrationMetadata } from '@internal/migration-tools/metadata'; +import { writeRef } from '@internal/migration-tools/refs'; +import { blindCast } from '@internal/utils/casts'; +import { ok } from '@internal/utils/result'; +import { createSqlContract } from '@repo/test-utils'; +import { join } from 'pathe'; +import { afterEach, describe, expect, it } from 'vitest'; +import { executeMigrate } from '../../src/control-api/operations/migrate'; +import { createTestProjectDir } from '../utils/test-project-dir'; + +const EXTERNAL_SPACE = 'external'; + +const APP_CONTRACT: Contract = createSqlContract(); +const APP_HEAD = APP_CONTRACT.storage.storageHash; + +const EXTERNAL_CONTRACT: Contract = { + ...createSqlContract({ + storage: { + namespaces: { + [UNBOUND_NAMESPACE_ID]: { id: UNBOUND_NAMESPACE_ID, entries: { table: { users: {} } } }, + }, + }, + }), + defaultControlPolicy: 'external', +}; +const EXTERNAL_HEAD = EXTERNAL_CONTRACT.storage.storageHash; + +const CREATE_TABLE: MigrationPlanOperation = { + id: 'table.post', + label: 'Create table post', + operationClass: 'additive', +}; + +const projectDirs: string[] = []; + +afterEach(async () => { + await Promise.all(projectDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +/** A migrations directory whose app space carries one migration ∅ → APP_HEAD. */ +async function migrationsDirWithOneAppEdge(): Promise { + const projectDir = createTestProjectDir('migrate-runner-schedule'); + projectDirs.push(projectDir); + const migrationsDir = join(projectDir, 'migrations'); + const ops = [CREATE_TABLE]; + const base: Omit = { + from: EMPTY_CONTRACT_HASH, + to: APP_HEAD, + providedInvariants: [], + createdAt: '2026-01-01T00:00:00.000Z', + }; + await writeMigrationPackage( + join(migrationsDir, 'app', '20260101T0000_initial'), + { ...base, migrationHash: computeMigrationHash(base, ops) }, + ops, + ); + return migrationsDir; +} + +/** Adds an all-external extension space: a head ref and a snapshot, no migration packages. */ +async function addAllExternalSpace(migrationsDir: string): Promise { + await writeRef(join(migrationsDir, EXTERNAL_SPACE, 'refs'), 'head', { + hash: EXTERNAL_HEAD, + invariants: [], + }); + await writeContractSnapshot(migrationsDir, EXTERNAL_HEAD, { + contractJson: EXTERNAL_CONTRACT, + contractDts: 'export type Contract = never;\n', + }); +} + +function allExternalExtension(): ControlExtensionDescriptor<'sql', 'postgres'> { + return { + kind: 'extension', + id: EXTERNAL_SPACE, + familyId: 'sql', + targetId: 'postgres', + version: '1.0.0', + contractSpace: { + contractJson: EXTERNAL_CONTRACT, + headRef: { hash: EXTERNAL_HEAD, invariants: [] }, + migrations: [], + }, + create: () => ({ familyId: 'sql', targetId: 'postgres' }), + }; +} + +function markerAt(storageHash: string): ContractMarkerRecord { + return { + storageHash, + profileHash: '', + contractJson: null, + canonicalVersion: null, + updatedAt: new Date(0), + appTag: null, + meta: {}, + invariants: [], + }; +} + +function fakeDriver(): ControlDriverInstance<'sql', 'postgres'> { + return blindCast< + ControlDriverInstance<'sql', 'postgres'>, + 'executeMigrate hands the driver to the family and runner fakes, which never touch it' + >({ familyId: 'sql', targetId: 'postgres', close: async () => {} }); +} + +function fakeFamily( + markers: ReadonlyMap, +): ControlFamilyInstance<'sql', unknown> { + const used: Pick< + ControlFamilyInstance<'sql', unknown>, + 'familyId' | 'deserializeContract' | 'readAllMarkers' + > = { + familyId: 'sql', + deserializeContract: (json) => + blindCast(json), + readAllMarkers: async () => markers, + }; + return blindCast< + ControlFamilyInstance<'sql', unknown>, + 'executeMigrate reads only the family members picked above' + >(used); +} + +/** A runner that records which spaces it was handed and reports each as applied. */ +function recordingMigrations() { + const runnerCalls: string[][] = []; + const runner: MigrationRunner<'sql', 'postgres'> = { + execute: async ({ perSpaceOptions }) => { + const spaces = perSpaceOptions.map( + (option: MigrationRunnerPerSpaceOptions<'sql', 'postgres'>) => option.space, + ); + runnerCalls.push(spaces); + return ok({ + perSpaceResults: perSpaceOptions.map((option) => ({ + space: option.space, + value: { + operationsPlanned: option.plan.operations.length, + operationsExecuted: option.plan.operations.length, + }, + })), + }); + }, + }; + const migrations = blindCast< + TargetMigrationsCapability<'sql', 'postgres', ControlFamilyInstance<'sql', unknown>>, + 'executeMigrate only creates a runner' + >({ createRunner: () => runner }); + return { runnerCalls, migrations }; +} + +function migrateOptions(args: { + readonly migrationsDir: string; + readonly markers: ReadonlyMap; + readonly migrations: TargetMigrationsCapability< + 'sql', + 'postgres', + ControlFamilyInstance<'sql', unknown> + >; + readonly extensions?: ReadonlyArray>; + readonly refHash?: string; +}) { + return { + driver: fakeDriver(), + familyInstance: fakeFamily(args.markers), + contract: APP_CONTRACT, + migrations: args.migrations, + frameworkComponents: [], + migrationsDir: args.migrationsDir, + extensions: args.extensions ?? [], + targetId: 'postgres' as const, + ...(args.refHash === undefined ? {} : { refHash: args.refHash }), + }; +} + +describe('executeMigrate runner schedule', () => { + it('leaves a database with no marker alone when the target is the empty contract', async () => { + const migrationsDir = await migrationsDirWithOneAppEdge(); + const { runnerCalls, migrations } = recordingMigrations(); + + const result = await executeMigrate( + migrateOptions({ + migrationsDir, + markers: new Map(), + migrations, + refHash: EMPTY_CONTRACT_HASH, + }), + ); + + expect(result.ok).toBe(true); + expect(result.ok && result.value.summary).toBe('Already up to date'); + expect(runnerCalls).toEqual([]); + }); + + it('runs an all-external extension that needs its marker and keeps the unmarked app space out', async () => { + const migrationsDir = await migrationsDirWithOneAppEdge(); + await addAllExternalSpace(migrationsDir); + const { runnerCalls, migrations } = recordingMigrations(); + + const result = await executeMigrate( + migrateOptions({ + migrationsDir, + markers: new Map(), + migrations, + extensions: [allExternalExtension()], + refHash: EMPTY_CONTRACT_HASH, + }), + ); + + expect(result.ok).toBe(true); + expect(runnerCalls).toEqual([[EXTERNAL_SPACE]]); + }); + + it('does not call the runner when the app space is already at its head', async () => { + const migrationsDir = await migrationsDirWithOneAppEdge(); + const { runnerCalls, migrations } = recordingMigrations(); + + const result = await executeMigrate( + migrateOptions({ + migrationsDir, + markers: new Map([['app', markerAt(APP_HEAD)]]), + migrations, + }), + ); + + expect(result.ok && result.value.summary).toBe('Already up to date'); + expect(runnerCalls).toEqual([]); + }); + + it('hands an app space already at its head to the runner beside an extension that needs work', async () => { + const migrationsDir = await migrationsDirWithOneAppEdge(); + await addAllExternalSpace(migrationsDir); + const { runnerCalls, migrations } = recordingMigrations(); + + const result = await executeMigrate( + migrateOptions({ + migrationsDir, + markers: new Map([['app', markerAt(APP_HEAD)]]), + migrations, + extensions: [allExternalExtension()], + }), + ); + + expect(result.ok).toBe(true); + expect(runnerCalls).toEqual([[EXTERNAL_SPACE, 'app']]); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/migrate-show-plan.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/migrate-show-plan.test.ts index 18e7fb9b2329..fffce8d677b4 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/migrate-show-plan.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/migrate-show-plan.test.ts @@ -136,13 +136,13 @@ describe('executeMigrateShowPlan', () => { }, ]); expect(result.value.summary).toBe('1 migration will run'); - expect(result.value.usedLiveMarker).toBe(false); + expect(result.value.databaseMarkerHashBySpace).toBeUndefined(); expect(result.value.contractHash).toBe(HASH_B); } expect(mocks.createControlClient).not.toHaveBeenCalled(); }); - it('defaults the per-space render marker hash to the empty sentinel', async () => { + it('plans every migration from the empty contract offline', async () => { const result = await executeMigrateShowPlan({ config, cwd: tempDir, @@ -150,7 +150,6 @@ describe('executeMigrateShowPlan', () => { }); expect(result.ok).toBe(true); if (result.ok) { - expect(result.value.renderMarkerHashBySpace.get('app')).toBe(EMPTY_CONTRACT_HASH); expect(result.value.migrations.map((m) => m.dirName)).toEqual([firstDirName, secondDirName]); expect(result.value.migrations.map((m) => m.migrationHash)).toEqual([ firstMigrationHash, diff --git a/packages/1-framework/3-tooling/cli/test/control-api/ref-resolution.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/ref-resolution.test.ts index 52fa1eca9119..4848e5d0f420 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/ref-resolution.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/ref-resolution.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'; import { resolveContractRef, resolveMigrationRef, + retryCommandFor, } from '../../src/control-api/operations/ref-resolution'; import { mapRefResolutionError } from '../../src/utils/cli-errors'; import { buildGraph, entry } from '../utils/graph-helpers'; @@ -79,3 +80,34 @@ describe('resolveMigrationRef', () => { } }); }); + +describe('retryCommandFor', () => { + const status = { commandName: 'migration status', canRunOffline: true }; + const migrate = { commandName: 'db migrate', from: undefined, canRunOffline: false }; + + it.each([ + { + args: { ...status, from: undefined, to: undefined }, + retry: '{bin} migration status --from ', + }, + { + args: { ...status, from: undefined, to: 'prod' }, + retry: '{bin} migration status --from --to prod', + }, + { + args: { ...status, from: '@db', to: 'prod' }, + retry: '{bin} migration status --from @db --to prod --db $DATABASE_URL', + }, + { + args: { ...status, from: HASH_A, to: '@db' }, + retry: `{bin} migration status --from ${HASH_A} --to @db --db $DATABASE_URL`, + }, + { + args: { ...migrate, to: 'prod', advanceRef: 'staging' }, + retry: '{bin} db migrate --to prod --advance-ref staging --db $DATABASE_URL', + }, + { args: { ...migrate, to: undefined }, retry: '{bin} db migrate --db $DATABASE_URL' }, + ])('repeats the flags as given: $retry', ({ args, retry }) => { + expect(retryCommandFor(args)).toBe(retry); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts index 5bddd656489b..eb94de0e97f3 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts @@ -365,6 +365,27 @@ describe('db sign', () => { expect(mocks.dbSign).not.toHaveBeenCalled(); }); + it.each(['@empty', '@contract', '@db'])( + 'refuses the reserved reference %s with the wrong-grammar envelope', + async (input) => { + const dir = await projectDir(); + + const run = await harness(ormConfig()).run(['db', 'sign', input, '--json'], { cwd: dir }); + + expect(run.exitCode).toBe(2); + expect(envelopeOf(run)).toMatchObject({ + ok: false, + error: { + code: 'MIGRATION.REF_WRONG_GRAMMAR', + why: `"${input}" is a reserved reference; the contract argument takes a contract recorded in the migrations directory (hash, prefix, ref name, migration dir name, or ^)`, + meta: { input, expectedGrammar: 'contract' }, + }, + }); + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.dbSign).not.toHaveBeenCalled(); + }, + ); + it('reports a refused connection as every command does, with its driver code', async () => { const dir = await projectDir(); mocks.dbSign.mockRejectedValue(refusedConnection()); diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts index 430783c1c6d8..b850a7ebf7b3 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts @@ -139,6 +139,85 @@ function harness(cwd: string) { } describe('db update --to bundle resolution', () => { + it.each(['@empty', '@contract', '@db'])( + 'refuses the reserved reference %s with a structured envelope', + async (input) => { + const { cwd } = await setupFixture(); + + const run = await harness(cwd).run(['db', 'update', '--to', input, '--dry-run', '--json'], { + cwd, + }); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { + code: 'MIGRATION.REF_WRONG_GRAMMAR', + why: `"${input}" is a reserved reference; --to takes a migration destination recorded in the migrations directory (hash, prefix, ref name, migration dir name, or ^)`, + meta: { input, expectedGrammar: 'contract' }, + }, + }, + }); + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.dbUpdate).not.toHaveBeenCalled(); + }, + ); + + it('refuses @db before it asks for a connection', async () => { + const { cwd } = await setupFixture(); + + const run = await createOrmTestCli({ + commands, + groups: BIN_GROUPS, + orm: { ...ormConfig(cwd), db: undefined }, + }).run(['db', 'update', '--to', '@db', '--json'], { cwd }); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { ok: false, error: { code: 'MIGRATION.REF_WRONG_GRAMMAR' } }, + }); + }); + + it.each([ + { flags: [], command: 'db update', after: '' }, + { flags: ['--advance-ref', 'staging'], command: 'db update', after: ' --advance-ref staging' }, + { flags: ['--dry-run'], command: 'db update --dry-run', after: '' }, + ])( + 'keeps --to and $flags in the retry command when no connection is configured', + async ({ flags, command, after }) => { + const { cwd, dirNext } = await setupFixture(); + + const run = await createOrmTestCli({ + commands, + groups: BIN_GROUPS, + orm: { ...ormConfig(cwd), db: undefined }, + }).run(['db', 'update', '--to', dirNext, ...flags, '--json'], { cwd }); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { + code: 'CONFIG.DB_CONNECTION_REQUIRED', + meta: { missingFlags: ['--db'] }, + nextActions: [ + expect.objectContaining({ + label: expect.stringContaining( + `Run \`prisma-test ${command} --to ${dirNext}${after} --db $DATABASE_URL\``, + ), + }), + ], + }, + }, + }); + expect(mocks.connect).not.toHaveBeenCalled(); + }, + ); + it('errors on an invalid --advance-ref name with the structured ref envelope', async () => { const { cwd, dirNext } = await setupFixture(); mocks.dbUpdate.mockResolvedValue( diff --git a/packages/1-framework/3-tooling/cli/test/orm/fixtures/migrate-show-project.ts b/packages/1-framework/3-tooling/cli/test/orm/fixtures/migrate-show-project.ts new file mode 100644 index 000000000000..e7f93cb12b80 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/orm/fixtures/migrate-show-project.ts @@ -0,0 +1,208 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import type { MigrationPlanOperation } from '@internal/framework-components/control'; +import { writeContractSnapshot } from '@internal/migration-tools/contract-snapshot-store'; +import { computeMigrationHash } from '@internal/migration-tools/hash'; +import { writeMigrationPackage } from '@internal/migration-tools/io'; +import type { MigrationMetadata } from '@internal/migration-tools/metadata'; +import { writeRef } from '@internal/migration-tools/refs'; +import type { Block } from '@prisma/cli-engine'; +import { join } from 'pathe'; +import { type Mock, vi } from 'vitest'; +import type { ControlClient } from '../../../src/control-api/types'; +import { BIN_GROUPS, createBinCommands } from '../../../src/orm/cli'; +import { createOrmTestCli } from '../../helpers/orm-test-cli'; +import { createTestProjectDir } from '../../utils/test-project-dir'; + +/** The control-client double every `db migrate --show` test runs against. */ +export const mocks: Readonly> = { + connect: vi.fn(), + readAllMarkers: vi.fn(), + migrate: vi.fn(), + close: vi.fn(), +}; + +const commands = createBinCommands( + () => + ({ + connect: mocks.connect, + readAllMarkers: mocks.readAllMarkers, + migrate: mocks.migrate, + close: mocks.close, + }) as unknown as ControlClient, +); + +export const EMPTY = 'empty'; +export const C1 = '1'.repeat(64); +export const C2 = '2'.repeat(64); +export const EXT_C1 = 'e'.repeat(64); +export const UNKNOWN = 'd'.repeat(64); +const TARGET = 'mock'; +const FAMILY = 'mock'; + +const OPS: readonly MigrationPlanOperation[] = [ + { id: 'table.users', label: 'Create table users', operationClass: 'additive' }, +]; + +export function contractEnvelope(storageHash: string): Record { + return { + storage: { storageHash, namespaces: {} }, + schemaVersion: '1.0.0', + target: TARGET, + targetFamily: FAMILY, + }; +} + +const tempDirs: string[] = []; + +export async function removeMigrateShowProjects(): Promise { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +} + +export function resetMigrateShowMocks(): void { + mocks.connect.mockReset().mockResolvedValue(undefined); + mocks.close.mockReset().mockResolvedValue(undefined); + mocks.readAllMarkers.mockReset().mockResolvedValue(new Map()); + mocks.migrate.mockReset(); +} + +export async function writePkg( + dir: string, + base: Omit, +): Promise { + const dirName = `20260101_100000_${base.to.slice(7, 13)}`; + const metadata: MigrationMetadata = { + ...base, + migrationHash: computeMigrationHash(base, [...OPS]), + }; + await writeMigrationPackage(join(dir, dirName), metadata, [...OPS]); + return dirName; +} + +/** A linear app history: empty → C1 → C2, with the emitted contract at C2. */ +export async function buildProject(): Promise { + const cwd = createTestProjectDir('orm-migrate-show'); + tempDirs.push(cwd); + const appDir = join(cwd, 'migrations', 'app'); + await mkdir(appDir, { recursive: true }); + await writePkg(appDir, { + from: EMPTY, + to: C1, + providedInvariants: [], + createdAt: '2026-01-01T10:00:00.000Z', + }); + await writePkg(appDir, { + from: C1, + to: C2, + providedInvariants: [], + createdAt: '2026-01-01T10:01:00.000Z', + }); + await writeFile(join(cwd, 'contract.json'), JSON.stringify(contractEnvelope(C2))); + return cwd; +} + +/** A project whose app space has no migrations, with the emitted contract at C2. */ +export async function buildProjectWithoutMigrations(): Promise { + const cwd = createTestProjectDir('orm-migrate-show'); + tempDirs.push(cwd); + await mkdir(join(cwd, 'migrations', 'app'), { recursive: true }); + await writeFile(join(cwd, 'contract.json'), JSON.stringify(contractEnvelope(C2))); + return cwd; +} + +/** Adds a declared pgvector space with its own empty → EXT_C1 graph. */ +export async function addExtensionSpace(cwd: string): Promise { + const extDir = join(cwd, 'migrations', 'pgvector'); + const dirName = await writePkg(extDir, { + from: EMPTY, + to: EXT_C1, + providedInvariants: [], + createdAt: '2026-01-01T09:00:00.000Z', + }); + await writeRef(join(extDir, 'refs'), 'head', { hash: EXT_C1, invariants: [] }); + await writeContractSnapshot(join(cwd, 'migrations'), EXT_C1, { + contractJson: contractEnvelope(EXT_C1), + contractDts: 'export type Contract = unknown;\n', + }); + return dirName; +} + +export function pgvectorExtension(): Record { + return { + kind: 'extension', + id: 'pgvector', + familyId: FAMILY, + targetId: TARGET, + version: '1.0.0', + create: () => ({}), + contractSpace: { + contractJson: contractEnvelope(EXT_C1), + headRef: { hash: EXT_C1, invariants: [] }, + migrations: [], + }, + }; +} + +export function ormConfig( + cwd: string, + overrides: Record = {}, +): Record { + return { + family: { + kind: 'family', + id: FAMILY, + familyId: FAMILY, + version: '1.0.0', + emission: {}, + create: () => ({ deserializeContract: (json: unknown) => json }), + }, + target: { + kind: 'target', + id: TARGET, + familyId: FAMILY, + targetId: TARGET, + version: '1.0.0', + create: () => ({}), + migrations: {}, + }, + adapter: { + kind: 'adapter', + id: 'mock', + familyId: FAMILY, + targetId: TARGET, + version: '1.0.0', + create: () => ({}), + }, + driver: { + kind: 'driver', + id: 'mock', + familyId: FAMILY, + targetId: TARGET, + version: '1.0.0', + create: () => ({}), + }, + db: { connection: 'postgres://user:secret@localhost:5432/appdb' }, + contract: { + source: { format: 'typescript', inputs: [], load: async () => ({}) }, + output: join(cwd, 'contract.json'), + }, + migrations: { dir: 'migrations' }, + ...overrides, + }; +} + +export function harness(config: Record) { + return createOrmTestCli({ commands, groups: BIN_GROUPS, orm: config }); +} + +/** Flattens a drawing block's span lines into plain strings. */ +export function drawingLines(blocks: readonly Block[]): readonly string[] { + return blocks + .filter((block) => block.kind === 'drawing') + .flatMap((block) => + block.lines.map((line) => + typeof line === 'string' + ? line + : line.map((span) => (typeof span === 'string' ? span : span.text)).join(''), + ), + ); +} diff --git a/packages/1-framework/3-tooling/cli/test/orm/fixtures/status-database.ts b/packages/1-framework/3-tooling/cli/test/orm/fixtures/status-database.ts new file mode 100644 index 000000000000..10e1e9a2cd92 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/orm/fixtures/status-database.ts @@ -0,0 +1,175 @@ +import { writeRef } from '@internal/migration-tools/refs'; +import type { Diagnostic } from '@prisma/cli-engine/protocol'; +import { join } from 'pathe'; +import { BIN_COMMANDS, BIN_GROUPS } from '../../../src/orm/cli'; +import { createOrmTestCli } from '../../helpers/orm-test-cli'; +import { + contractJson, + createOfflineProject, + type OfflineProject, + offlineConfig, + seedContractSnapshot, + seedMigrationPackage, +} from './offline-project'; + +export const HASH_HEAD = `c0ffee${'0'.repeat(58)}`; +export const HASH_BASE = `beef${'1'.repeat(60)}`; +export const HASH_UNKNOWN = `dead${'2'.repeat(60)}`; +const CONNECTION = 'postgres://user:secret@localhost:5432/appdb'; + +interface FakeDatabaseScript { + readonly markers?: ReadonlyMap< + string, + { readonly storageHash: string; readonly invariants: readonly string[] } + >; + readonly ledger?: ReadonlyArray<{ readonly migrationHash: string }>; + readonly readMarkersError?: Error; + readonly closeError?: Error; +} + +/** + * The database the real control client talks to: the family instance answers + * marker and ledger reads from the script, and the driver descriptor counts + * connections so tests can assert none was opened. No module mocks — the + * command builds the real client over these descriptors. + */ +export function fakeDatabase(script: FakeDatabaseScript = {}) { + const counters = { connections: 0, closes: 0 }; + const familyInstance = { + deserializeContract: (json: unknown) => json, + readAllMarkers: async () => { + if (script.readMarkersError !== undefined) { + throw script.readMarkersError; + } + return script.markers ?? new Map(); + }, + readLedger: async () => script.ledger ?? [], + }; + const driver = { + close: async () => { + counters.closes += 1; + if (script.closeError !== undefined) { + throw script.closeError; + } + }, + }; + return { counters, familyInstance, driver }; +} + +type FakeDatabase = ReturnType; + +export function driverConfig( + project: OfflineProject, + db: FakeDatabase = fakeDatabase(), +): Record { + const base = offlineConfig({ project }); + return { + ...base, + family: { ...(base['family'] as Record), create: () => db.familyInstance }, + driver: { + kind: 'driver', + id: 'pg', + familyId: 'sql', + targetId: 'postgres', + version: '1.0.0', + create: async () => { + db.counters.connections += 1; + return db.driver; + }, + }, + db: { connection: CONNECTION }, + }; +} + +export function harness(config: Record) { + return createOrmTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, orm: config }); +} + +/** A project whose app space carries one migration ∅ → HASH_HEAD. */ +export async function projectWithOneMigration(): Promise< + OfflineProject & { readonly migrationHash: string } +> { + const project = await createOfflineProject({ storageHash: HASH_HEAD }); + const seeded = await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: '20260101T0000_initial', + from: null, + to: HASH_HEAD, + }); + return { ...project, migrationHash: seeded.migrationHash }; +} + +export const DIR_BASE = '20260101T0000_base'; +export const DIR_HEAD = '20260102T0000_head'; + +/** A project whose app space carries ∅ → HASH_BASE → HASH_HEAD, with the contract at HASH_HEAD. */ +export async function projectWithTwoMigrations(): Promise< + OfflineProject & { readonly baseMigrationHash: string } +> { + const project = await createOfflineProject({ storageHash: HASH_HEAD }); + const base = await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: DIR_BASE, + from: null, + to: HASH_BASE, + }); + await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: DIR_HEAD, + from: HASH_BASE, + to: HASH_HEAD, + }); + return { ...project, baseMigrationHash: base.migrationHash }; +} + +export function markersAt(storageHash: string) { + return new Map([['app', { storageHash, invariants: [] as readonly string[] }]]); +} + +export const EXTERNAL_SPACE = 'external'; +export const HASH_EXTERNAL_HEAD = `e0e0${'3'.repeat(60)}`; + +/** An all-external extension space: a head ref on disk and no migration packages. */ +export async function addAllExternalSpace(project: OfflineProject): Promise { + await writeRef(join(project.migrationsDir, EXTERNAL_SPACE, 'refs'), 'head', { + hash: HASH_EXTERNAL_HEAD, + invariants: [], + }); + await seedContractSnapshot({ + migrationsDir: project.migrationsDir, + storageHash: HASH_EXTERNAL_HEAD, + }); +} + +function allExternalExtension(): Record { + return { + kind: 'extension', + id: EXTERNAL_SPACE, + familyId: 'sql', + targetId: 'postgres', + version: '1.0.0', + create: () => ({}), + contractSpace: { + contractJson: contractJson(HASH_EXTERNAL_HEAD), + headRef: { hash: HASH_EXTERNAL_HEAD, invariants: [] }, + migrations: [], + }, + }; +} + +export function withAllExternalExtension(config: Record): Record { + return { ...config, extensions: [allExternalExtension()] }; +} + +export function markersWithExternalAtHead(appHash: string) { + return new Map([ + ['app', { storageHash: appHash, invariants: [] as readonly string[] }], + [EXTERNAL_SPACE, { storageHash: HASH_EXTERNAL_HEAD, invariants: [] as readonly string[] }], + ]); +} + +export function codesAndSeverities( + diagnostics: readonly Diagnostic[], +): ReadonlyArray<{ code: string; severity: string }> { + return diagnostics.map(({ code, severity }) => ({ code, severity })); +} diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate-show-extensions.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate-show-extensions.test.ts new file mode 100644 index 000000000000..8d0949ce77b4 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate-show-extensions.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + addExtensionSpace, + buildProject, + C1, + C2, + drawingLines, + EMPTY, + EXT_C1, + harness, + mocks, + ormConfig, + pgvectorExtension, + removeMigrateShowProjects, + resetMigrateShowMocks, +} from './fixtures/migrate-show-project'; + +afterEach(removeMigrateShowProjects); +beforeEach(resetMigrateShowMocks); + +describe('migrate --show with extension spaces', () => { + it('plans extensions from the empty contract, never from the app --from hash', async () => { + const cwd = await buildProject(); + const extDirName = await addExtensionSpace(cwd); + + const run = await harness(ormConfig(cwd, { extensions: [pgvectorExtension()] })).run( + ['db', 'migrate', '--show', '--from', C1.slice(7, 13), '--to', C2.slice(7, 13), '--json'], + { cwd }, + ); + const document = run.presented?.data as { + migrations: ReadonlyArray<{ spaceId: string; dirName: string; from: string }>; + }; + + expect(run.exitCode).toBe(0); + expect(document.migrations).toContainEqual( + expect.objectContaining({ spaceId: 'pgvector', dirName: extDirName, from: EMPTY }), + ); + expect(document.migrations).not.toContainEqual( + expect.objectContaining({ spaceId: 'app', from: EMPTY }), + ); + }); + + it('orders extension migrations before app migrations, matching the runner', async () => { + const cwd = await buildProject(); + await addExtensionSpace(cwd); + + const run = await harness(ormConfig(cwd, { extensions: [pgvectorExtension()] })).run( + ['db', 'migrate', '--show', '--from', EMPTY, '--json'], + { cwd }, + ); + const document = run.presented?.data as { + migrations: ReadonlyArray<{ spaceId: string }>; + }; + + expect(run.exitCode).toBe(0); + expect(document.migrations.map((migration) => migration.spaceId)).toEqual([ + 'pgvector', + 'app', + 'app', + ]); + }); + + it.each([ + { argv: [], extensionLabelled: true }, + { argv: ['--from', '@db'], extensionLabelled: true }, + { argv: ['--from', '@empty', '--to', '@db'], extensionLabelled: false }, + ])( + 'labels @db in an extension tree only when the plan starts from its marker: $argv', + async ({ argv, extensionLabelled }) => { + const cwd = await buildProject(); + await addExtensionSpace(cwd); + mocks.readAllMarkers.mockResolvedValue( + new Map([ + ['app', { storageHash: C1, invariants: [] }], + ['pgvector', { storageHash: EXT_C1, invariants: [] }], + ]), + ); + + const run = await harness(ormConfig(cwd, { extensions: [pgvectorExtension()] })).run( + ['db', 'migrate', '--show', ...argv], + { cwd, isTty: { stdout: true } }, + ); + const graph = run.presented?.presentation.human.find((block) => block.kind === 'drawing'); + const lines = drawingLines(graph === undefined ? [] : [graph]); + const extensionStart = lines.indexOf('pgvector:'); + const appLines = lines.slice(0, extensionStart); + const extensionLines = lines.slice(extensionStart); + + expect(run.exitCode).toBe(0); + expect(extensionStart).toBeGreaterThan(0); + expect(appLines.filter((line) => line.includes('@db'))).toHaveLength(1); + expect(extensionLines.some((line) => line.includes('@db'))).toBe(extensionLabelled); + }, + ); +}); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts index 548176375c5e..f9640f12aaec 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts @@ -1,197 +1,29 @@ -import { mkdir, rm, writeFile } from 'node:fs/promises'; -import type { MigrationPlanOperation } from '@internal/framework-components/control'; -import { writeContractSnapshot } from '@internal/migration-tools/contract-snapshot-store'; -import { computeMigrationHash } from '@internal/migration-tools/hash'; -import { writeMigrationPackage } from '@internal/migration-tools/io'; -import type { MigrationMetadata } from '@internal/migration-tools/metadata'; +import { rm } from 'node:fs/promises'; import { writeRef } from '@internal/migration-tools/refs'; -import type { Block } from '@prisma/cli-engine'; import { join } from 'pathe'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ControlClient } from '../../src/control-api/types'; -import { BIN_GROUPS, createBinCommands } from '../../src/orm/cli'; -import { createOrmTestCli } from '../helpers/orm-test-cli'; -import { createTestProjectDir } from '../utils/test-project-dir'; - -const mocks = { - connect: vi.fn(), - readAllMarkers: vi.fn(), - migrate: vi.fn(), - close: vi.fn(), -}; - -const commands = createBinCommands( - () => - ({ - connect: mocks.connect, - readAllMarkers: mocks.readAllMarkers, - migrate: mocks.migrate, - close: mocks.close, - }) as unknown as ControlClient, -); - -const EMPTY = 'empty'; -const C1 = '1'.repeat(64); -const C2 = '2'.repeat(64); -const EXT_C1 = 'e'.repeat(64); -const TARGET = 'mock'; -const FAMILY = 'mock'; - -const OPS: readonly MigrationPlanOperation[] = [ - { id: 'table.users', label: 'Create table users', operationClass: 'additive' }, -]; - -function contractEnvelope(storageHash: string): Record { - return { - storage: { storageHash, namespaces: {} }, - schemaVersion: '1.0.0', - target: TARGET, - targetFamily: FAMILY, - }; -} - -const tempDirs: string[] = []; - -afterEach(async () => { - await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); -}); - -beforeEach(() => { - mocks.connect.mockReset().mockResolvedValue(undefined); - mocks.close.mockReset().mockResolvedValue(undefined); - mocks.readAllMarkers.mockReset().mockResolvedValue(new Map()); - mocks.migrate.mockReset(); -}); - -async function writePkg( - dir: string, - base: Omit, -): Promise { - const dirName = `20260101_100000_${base.to.slice(7, 13)}`; - const metadata: MigrationMetadata = { - ...base, - migrationHash: computeMigrationHash(base, [...OPS]), - }; - await writeMigrationPackage(join(dir, dirName), metadata, [...OPS]); - return dirName; -} - -/** A linear app history: empty → C1 → C2, with the emitted contract at C2. */ -async function buildProject(): Promise { - const cwd = createTestProjectDir('orm-migrate-show'); - tempDirs.push(cwd); - const appDir = join(cwd, 'migrations', 'app'); - await mkdir(appDir, { recursive: true }); - await writePkg(appDir, { - from: EMPTY, - to: C1, - providedInvariants: [], - createdAt: '2026-01-01T10:00:00.000Z', - }); - await writePkg(appDir, { - from: C1, - to: C2, - providedInvariants: [], - createdAt: '2026-01-01T10:01:00.000Z', - }); - await writeFile(join(cwd, 'contract.json'), JSON.stringify(contractEnvelope(C2))); - return cwd; -} - -/** Adds a declared pgvector space with its own empty → EXT_C1 graph. */ -async function addExtensionSpace(cwd: string): Promise { - const extDir = join(cwd, 'migrations', 'pgvector'); - const dirName = await writePkg(extDir, { - from: EMPTY, - to: EXT_C1, - providedInvariants: [], - createdAt: '2026-01-01T09:00:00.000Z', - }); - await writeRef(join(extDir, 'refs'), 'head', { hash: EXT_C1, invariants: [] }); - await writeContractSnapshot(join(cwd, 'migrations'), EXT_C1, { - contractJson: contractEnvelope(EXT_C1), - contractDts: 'export type Contract = unknown;\n', - }); - return dirName; -} - -function pgvectorExtension(): Record { - return { - kind: 'extension', - id: 'pgvector', - familyId: FAMILY, - targetId: TARGET, - version: '1.0.0', - create: () => ({}), - contractSpace: { - contractJson: contractEnvelope(EXT_C1), - headRef: { hash: EXT_C1, invariants: [] }, - migrations: [], - }, - }; -} - -function ormConfig(cwd: string, overrides: Record = {}): Record { - return { - family: { - kind: 'family', - id: FAMILY, - familyId: FAMILY, - version: '1.0.0', - emission: {}, - create: () => ({ deserializeContract: (json: unknown) => json }), - }, - target: { - kind: 'target', - id: TARGET, - familyId: FAMILY, - targetId: TARGET, - version: '1.0.0', - create: () => ({}), - migrations: {}, - }, - adapter: { - kind: 'adapter', - id: 'mock', - familyId: FAMILY, - targetId: TARGET, - version: '1.0.0', - create: () => ({}), - }, - driver: { - kind: 'driver', - id: 'mock', - familyId: FAMILY, - targetId: TARGET, - version: '1.0.0', - create: () => ({}), - }, - db: { connection: 'postgres://user:secret@localhost:5432/appdb' }, - contract: { - source: { format: 'typescript', inputs: [], load: async () => ({}) }, - output: join(cwd, 'contract.json'), - }, - migrations: { dir: 'migrations' }, - ...overrides, - }; -} - -function harness(config: Record) { - return createOrmTestCli({ commands, groups: BIN_GROUPS, orm: config }); -} - -/** Flattens a drawing block's span lines into plain strings. */ -function drawingLines(blocks: readonly Block[]): readonly string[] { - return blocks - .filter((block) => block.kind === 'drawing') - .flatMap((block) => - block.lines.map((line) => - typeof line === 'string' - ? line - : line.map((span) => (typeof span === 'string' ? span : span.text)).join(''), - ), - ); -} +import stripAnsi from 'strip-ansi'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + addExtensionSpace, + buildProject, + buildProjectWithoutMigrations, + C1, + C2, + drawingLines, + EMPTY, + EXT_C1, + harness, + mocks, + ormConfig, + pgvectorExtension, + removeMigrateShowProjects, + resetMigrateShowMocks, + UNKNOWN, + writePkg, +} from './fixtures/migrate-show-project'; + +afterEach(removeMigrateShowProjects); +beforeEach(resetMigrateShowMocks); describe('migrate --show', () => { it('shows nothing to run when the from-state is already the target', async () => { @@ -229,11 +61,17 @@ describe('migrate --show', () => { expect(run.exitCode).not.toBe(0); expect(run.json.at(-1)).toMatchObject({ kind: 'result', - envelope: { ok: false, error: { code: 'CONFIG.DB_CONNECTION_REQUIRED' } }, + envelope: { + ok: false, + error: { + code: 'CONFIG.DB_CONNECTION_REQUIRED', + why: expect.stringContaining('db migrate --show'), + }, + }, }); }); - it('errors structurally for --from @db without a connection', async () => { + it('requires a connection for --from @db and repeats --from @db in the retry', async () => { const cwd = await buildProject(); const run = await harness(ormConfig(cwd, { db: undefined })).run( @@ -241,11 +79,22 @@ describe('migrate --show', () => { { cwd }, ); - expect(run.exitCode).not.toBe(0); - const terminal = run.json.at(-1) as - | { kind: string; envelope?: { ok: boolean; error?: { code: string } } } - | undefined; - expect(terminal?.envelope?.error?.code).toMatch(/^[A-Z]+\.[A-Z_]+$/); + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { + code: 'CONFIG.DB_CONNECTION_REQUIRED', + meta: { missingFlags: ['--db'] }, + nextActions: [ + expect.objectContaining({ + label: expect.stringContaining('db migrate --show --from @db --db $DATABASE_URL'), + }), + ], + }, + }, + }); }); it('previews a ref target whose invariants ride the ref, not the contract head', async () => { @@ -312,46 +161,276 @@ describe('migrate --show', () => { }); }); - describe('extension spaces', () => { - it('plans extensions from their own state, never from the app --from hash', async () => { + describe('the @db marker', () => { + it('resolves --to @db to the live marker', async () => { const cwd = await buildProject(); - const extDirName = await addExtensionSpace(cwd); + mocks.readAllMarkers.mockResolvedValue( + new Map([['app', { storageHash: C1, invariants: [] }]]), + ); - const run = await harness(ormConfig(cwd, { extensions: [pgvectorExtension()] })).run( - ['db', 'migrate', '--show', '--from', C1.slice(7, 13), '--to', C2.slice(7, 13), '--json'], + const run = await harness(ormConfig(cwd)).run( + ['db', 'migrate', '--show', '--from', EMPTY, '--to', '@db', '--json'], { cwd }, ); - const document = run.presented?.data as { - migrations: ReadonlyArray<{ spaceId: string; dirName: string; from: string }>; - }; expect(run.exitCode).toBe(0); - expect(document.migrations).toContainEqual( - expect.objectContaining({ spaceId: 'pgvector', dirName: extDirName, from: EMPTY }), + expect(run.presented?.data).toEqual({ + ok: true, + migrations: [expect.objectContaining({ from: EMPTY, to: C1 })], + summary: '1 migration will run', + }); + }); + + it('shows nothing to run for --to @db when the from-state is the live marker too', async () => { + const cwd = await buildProject(); + mocks.readAllMarkers.mockResolvedValue( + new Map([['app', { storageHash: C1, invariants: [] }]]), ); - expect(document.migrations).not.toContainEqual( - expect.objectContaining({ spaceId: 'app', from: EMPTY }), + + const run = await harness(ormConfig(cwd)).run( + ['db', 'migrate', '--show', '--to', '@db', '--json'], + { cwd }, ); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ ok: true, migrations: [] }); }); - it('orders extension migrations before app migrations, matching the runner', async () => { + it('labels the target @db for --from @empty --to @db', async () => { const cwd = await buildProject(); - await addExtensionSpace(cwd); + mocks.readAllMarkers.mockResolvedValue( + new Map([['app', { storageHash: C1, invariants: [] }]]), + ); - const run = await harness(ormConfig(cwd, { extensions: [pgvectorExtension()] })).run( - ['db', 'migrate', '--show', '--from', EMPTY, '--json'], + const run = await harness(ormConfig(cwd)).run( + ['db', 'migrate', '--show', '--from', '@empty', '--to', '@db'], + { cwd, isTty: { stdout: true } }, + ); + const dbLines = drawingLines(run.presented?.presentation.human ?? []).filter((line) => + line.includes('@db'), + ); + + expect(run.exitCode).toBe(0); + expect(dbLines).toHaveLength(1); + expect(dbLines[0]).toContain(C1.slice(0, 7)); + }); + + it.each([ + { argv: [] }, + { argv: ['--to', '@db'] }, + { argv: ['--from', '@empty', '--to', '@db'] }, + ])('refuses a marker outside the migration graph: $argv', async ({ argv }) => { + const cwd = await buildProject(); + mocks.readAllMarkers.mockResolvedValue( + new Map([['app', { storageHash: UNKNOWN, invariants: [] }]]), + ); + + const run = await harness(ormConfig(cwd)).run( + ['db', 'migrate', '--show', ...argv, '--json'], { cwd }, ); - const document = run.presented?.data as { - migrations: ReadonlyArray<{ spaceId: string }>; - }; + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { ok: false, error: { code: 'MIGRATION.MARKER_MISMATCH' } }, + }); + }); + + it('refuses a marker at the emitted contract when the app space has no migrations', async () => { + const cwd = await buildProjectWithoutMigrations(); + mocks.readAllMarkers.mockResolvedValue( + new Map([['app', { storageHash: C2, invariants: [] }]]), + ); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show', '--json'], { + cwd, + }); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { + code: 'MIGRATION.MARKER_MISMATCH', + meta: { markerHash: C2, reachableHashes: [] }, + }, + }, + }); + }); + + it.each([ + { argv: ['--from', '@db'], named: true }, + { argv: ['--from', EMPTY, '--to', '@db'], named: true }, + { argv: ['--from', EMPTY], named: false }, + ])( + 'names the database in the header only when the preview reads it: $argv', + async ({ argv, named }) => { + const cwd = await buildProject(); + mocks.readAllMarkers.mockResolvedValue( + new Map([['app', { storageHash: C1, invariants: [] }]]), + ); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show', ...argv], { + cwd, + isTty: { stdout: true }, + }); + const header = run.presented?.presentation.human.find((block) => block.kind === 'fields'); + const labels = header?.kind === 'fields' ? header.rows.map((row) => row.label) : []; + + expect(labels.includes('database')).toBe(named); + }, + ); + + it('errors structurally for --to @db without a connection', async () => { + const cwd = await buildProject(); + + const run = await harness(ormConfig(cwd, { db: undefined })).run( + ['db', 'migrate', '--show', '--from', EMPTY, '--to', '@db', '--json'], + { cwd }, + ); + + expect(run.exitCode).not.toBe(0); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { + code: 'CONFIG.DB_CONNECTION_REQUIRED', + meta: { missingFlags: ['--db'] }, + nextActions: [ + expect.objectContaining({ + label: expect.stringContaining( + `db migrate --show --from ${EMPTY} --to @db --db $DATABASE_URL`, + ), + }), + ], + }, + }, + }); + }); + }); + + describe('the preview', () => { + it('previews the route without applying anything', async () => { + const cwd = await buildProject(); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show', '--json'], { + cwd, + }); expect(run.exitCode).toBe(0); - expect(document.migrations.map((migration) => migration.spaceId)).toEqual([ - 'pgvector', - 'app', - 'app', + expect(mocks.migrate).not.toHaveBeenCalled(); + expect(run.presented?.data).toMatchObject({ + ok: true, + migrations: [ + expect.objectContaining({ spaceId: 'app', from: EMPTY, to: C1 }), + expect.objectContaining({ spaceId: 'app', from: C1, to: C2 }), + ], + }); + }); + + it('keeps the human-only rendering out of the result document', async () => { + const cwd = await buildProject(); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show', '--json'], { + cwd, + }); + + expect(Object.keys(run.presented?.data ?? {}).sort()).toEqual([ + 'migrations', + 'ok', + 'summary', ]); }); + + it('ships the topology as a drawing whose spans carry tone', async () => { + const cwd = await buildProject(); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show'], { + cwd, + isTty: { stdout: true }, + }); + const blocks = run.presented?.presentation.human ?? []; + const drawings = blocks.filter((block) => block.kind === 'drawing'); + + expect(blocks[0]).toMatchObject({ kind: 'fields', rail: true }); + expect(drawings).toHaveLength(2); + expect(JSON.stringify(drawings)).not.toContain('\\u001b'); + expect(JSON.stringify(drawings)).toContain('"tone"'); + }); + + it('announces how many migrations will run', async () => { + const cwd = await buildProject(); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show'], { + cwd, + isTty: { stdout: true }, + }); + + expect(run.presented?.presentation.human).toContainEqual({ + kind: 'summary', + status: 'info', + text: 'The following 2 migrations will run:', + }); + }); + + it('keeps every arrow in the run list in one column', async () => { + const cwd = await buildProject(); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show'], { + cwd, + isTty: { stdout: true, stderr: true }, + }); + const rendered = stripAnsi(run.stderr).split('\n'); + const runList = rendered.slice(rendered.findIndex((line) => line.includes('will run:')) + 1); + const arrowColumns = new Set( + runList.filter((line) => line.includes('\u2192')).map((line) => line.indexOf('\u2192')), + ); + + expect(run.stdout).toBe(''); + expect(runList.filter((line) => line.includes('\u2192'))).toHaveLength(2); + expect(arrowColumns.size).toBe(1); + }); + + it('plans offline when --from names a contract', async () => { + const cwd = await buildProject(); + + const run = await harness(ormConfig(cwd)).run( + ['db', 'migrate', '--show', '--from', C1, '--json'], + { + cwd, + }, + ); + + expect(run.exitCode).toBe(0); + expect(mocks.connect).not.toHaveBeenCalled(); + expect(run.presented?.data).toMatchObject({ + migrations: [expect.objectContaining({ from: C1, to: C2 })], + }); + }); + + it('names the from-state and the target in the header', async () => { + const cwd = await buildProject(); + + const run = await harness(ormConfig(cwd)).run( + ['db', 'migrate', '--show', '--from', C1, '--to', C2], + { + cwd, + isTty: { stdout: true }, + }, + ); + + expect(run.presented?.presentation.human[0]).toEqual({ + kind: 'fields', + rail: true, + rows: [ + { label: 'migrations', value: 'migrations' }, + { label: 'from', value: C1 }, + { label: 'to', value: C2 }, + ], + }); + }); }); }); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate-to-contract.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate-to-contract.test.ts index f28fd7342756..476e855ccf1d 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate-to-contract.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate-to-contract.test.ts @@ -7,6 +7,7 @@ import { import { computeMigrationHash } from '@internal/migration-tools/hash'; import { writeMigrationPackage } from '@internal/migration-tools/io'; import type { MigrationMetadata } from '@internal/migration-tools/metadata'; +import { writeRef } from '@internal/migration-tools/refs'; import { ok } from '@internal/utils/result'; import { join, relative } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -119,7 +120,7 @@ async function buildAppliedProject(): Promise { return cwd; } -function ormConfig(cwd: string): Record { +function ormConfig(cwd: string, overrides: Record = {}): Record { return { family: { kind: 'family', @@ -160,9 +161,14 @@ function ormConfig(cwd: string): Record { output: join(cwd, 'contract.json'), }, migrations: { dir: 'migrations' }, + ...overrides, }; } +function markerAt(storageHash: string): Map { + return new Map([['app', { storageHash, invariants: [] }]]); +} + function harness(config: Record) { return createOrmTestCli({ commands, groups: BIN_GROUPS, orm: config }); } @@ -254,3 +260,144 @@ describe('migrate --to resolves the apply contract', () => { expect(mocks.migrate).not.toHaveBeenCalled(); }); }); + +describe('migrate --to reserved references and refs', () => { + it('treats @contract like an omitted --to and applies the emitted contract', async () => { + const cwd = await buildAppliedProject(); + const emitted = { ...contractEnvelope(C2), models: { onlyInContractJson: {} } }; + await writeFile(join(cwd, 'contract.json'), JSON.stringify(emitted)); + mocks.readAllMarkers.mockResolvedValue(markerAt(C1)); + + const run = await harness(ormConfig(cwd)).run( + ['db', 'migrate', '--to', '@contract', '--json'], + { cwd }, + ); + + expect(run.exitCode).toBe(0); + const migrateOptions = mocks.migrate.mock.calls[0]?.[0]; + expect(migrateOptions).toMatchObject({ contract: emitted }); + expect(migrateOptions).not.toHaveProperty('refHash'); + expect(migrateOptions).not.toHaveProperty('refInvariants'); + expect(migrateOptions).not.toHaveProperty('refName'); + }); + + it('resolves @db to the live marker so there is nothing to run', async () => { + const cwd = await buildAppliedProject(); + mocks.readAllMarkers.mockResolvedValue(markerAt(C1)); + mocks.migrate.mockResolvedValue( + ok({ + migrationsApplied: 0, + markerHash: C1, + applied: [], + summary: 'Already up to date', + perSpace: [], + }), + ); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--to', '@db', '--json'], { + cwd, + }); + + expect(run.exitCode).toBe(0); + expect(mocks.migrate).toHaveBeenCalledWith( + expect.objectContaining({ + refHash: C1, + refInvariants: [], + contract: expect.objectContaining({ + storage: expect.objectContaining({ storageHash: C1 }), + }), + }), + ); + expect(mocks.migrate.mock.calls[0]?.[0]).not.toHaveProperty('refName'); + expect(run.presented?.data).toMatchObject({ + ok: true, + migrationsApplied: 0, + markerHash: C1, + summary: 'Already up to date', + }); + }); + + it.each([ + { to: '@db', reason: 'a database with no marker' }, + { to: '@empty', reason: 'the empty contract' }, + ])('passes the empty contract as the target for --to $to ($reason)', async ({ to }) => { + const cwd = await buildAppliedProject(); + mocks.readAllMarkers.mockResolvedValue(new Map()); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--to', to, '--json'], { + cwd, + }); + + expect(run.exitCode).toBe(0); + expect(mocks.migrate).toHaveBeenCalledWith(expect.objectContaining({ refHash: EMPTY })); + }); + + it('passes the resolved ref name for a ref target', async () => { + const cwd = await buildAppliedProject(); + await writeRef(join(cwd, 'migrations', 'app', 'refs'), 'prod', { hash: C1, invariants: [] }); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--to', 'prod', '--json'], { + cwd, + }); + + expect(run.exitCode).toBe(0); + expect(mocks.migrate).toHaveBeenCalledWith( + expect.objectContaining({ refHash: C1, refName: 'prod' }), + ); + }); + + it.each([ + { argv: ['--to', '@db'], retry: 'db migrate --to @db --db $DATABASE_URL' }, + { argv: ['--to', 'prod'], retry: 'db migrate --to prod --db $DATABASE_URL' }, + { + argv: ['--to', '@db', '--advance-ref', 'staging'], + retry: 'db migrate --to @db --advance-ref staging --db $DATABASE_URL', + }, + { argv: [], retry: 'db migrate --db $DATABASE_URL' }, + ])( + 'repeats $argv in the retry command when no connection is configured', + async ({ argv, retry }) => { + const cwd = await buildAppliedProject(); + + const run = await harness(ormConfig(cwd, { db: undefined })).run( + ['db', 'migrate', ...argv, '--json'], + { cwd }, + ); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { + code: 'CONFIG.DB_CONNECTION_REQUIRED', + meta: { missingFlags: ['--db'] }, + nextActions: [ + expect.objectContaining({ + label: expect.stringContaining(`Run \`prisma-test ${retry}\``), + }), + ], + }, + }, + }); + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.migrate).not.toHaveBeenCalled(); + }, + ); + + it('reports a missing driver for @db as a missing driver', async () => { + const cwd = await buildAppliedProject(); + + const run = await harness(ormConfig(cwd, { driver: undefined })).run( + ['db', 'migrate', '--to', '@db', '--json'], + { cwd }, + ); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { ok: false, error: { code: 'CONFIG.DRIVER_REQUIRED' } }, + }); + expect(mocks.connect).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts index 16431f6c4e9a..96d12115ea87 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts @@ -441,126 +441,4 @@ describe('migrate', () => { expect(envelopeOf(run.json)).toMatchObject({ ok: true }); }); }); - - describe('--show', () => { - it('previews the route without applying anything', async () => { - const cwd = await buildProject(); - - const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show', '--json'], { - cwd, - }); - - expect(run.exitCode).toBe(0); - expect(mocks.migrate).not.toHaveBeenCalled(); - expect(run.presented?.data).toMatchObject({ - ok: true, - migrations: [ - expect.objectContaining({ spaceId: 'app', from: EMPTY, to: C1 }), - expect.objectContaining({ spaceId: 'app', from: C1, to: C2 }), - ], - }); - }); - - it('keeps the human-only rendering out of the result document', async () => { - const cwd = await buildProject(); - - const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show', '--json'], { - cwd, - }); - - expect(Object.keys(run.presented?.data ?? {}).sort()).toEqual([ - 'migrations', - 'ok', - 'summary', - ]); - }); - - it('ships the topology as a drawing whose spans carry tone', async () => { - const cwd = await buildProject(); - - const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show'], { - cwd, - isTty: { stdout: true }, - }); - const blocks = run.presented?.presentation.human ?? []; - const drawings = blocks.filter((block) => block.kind === 'drawing'); - - expect(blocks[0]).toMatchObject({ kind: 'fields', rail: true }); - expect(drawings).toHaveLength(2); - expect(JSON.stringify(drawings)).not.toContain('\\u001b'); - expect(JSON.stringify(drawings)).toContain('"tone"'); - }); - - it('announces how many migrations will run', async () => { - const cwd = await buildProject(); - - const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show'], { - cwd, - isTty: { stdout: true }, - }); - - expect(run.presented?.presentation.human).toContainEqual({ - kind: 'summary', - status: 'info', - text: 'The following 2 migrations will run:', - }); - }); - - it('keeps every arrow in the run list in one column', async () => { - const cwd = await buildProject(); - - const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show'], { - cwd, - isTty: { stdout: true, stderr: true }, - }); - const rendered = stripAnsi(run.stderr).split('\n'); - const runList = rendered.slice(rendered.findIndex((line) => line.includes('will run:')) + 1); - const arrowColumns = new Set( - runList.filter((line) => line.includes('\u2192')).map((line) => line.indexOf('\u2192')), - ); - - expect(run.stdout).toBe(''); - expect(runList.filter((line) => line.includes('\u2192'))).toHaveLength(2); - expect(arrowColumns.size).toBe(1); - }); - - it('plans offline when --from names a contract', async () => { - const cwd = await buildProject(); - - const run = await harness(ormConfig(cwd)).run( - ['db', 'migrate', '--show', '--from', C1, '--json'], - { - cwd, - }, - ); - - expect(run.exitCode).toBe(0); - expect(mocks.connect).not.toHaveBeenCalled(); - expect(run.presented?.data).toMatchObject({ - migrations: [expect.objectContaining({ from: C1, to: C2 })], - }); - }); - - it('names the from-state and the target in the header', async () => { - const cwd = await buildProject(); - - const run = await harness(ormConfig(cwd)).run( - ['db', 'migrate', '--show', '--from', C1, '--to', C2], - { - cwd, - isTty: { stdout: true }, - }, - ); - - expect(run.presented?.presentation.human[0]).toEqual({ - kind: 'fields', - rail: true, - rows: [ - { label: 'migrations', value: 'migrations' }, - { label: 'from', value: C1 }, - { label: 'to', value: C2 }, - ], - }); - }); - }); }); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-status-contract-refs.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-status-contract-refs.test.ts new file mode 100644 index 000000000000..a391722f97f2 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-status-contract-refs.test.ts @@ -0,0 +1,296 @@ +import stripAnsi from 'strip-ansi'; +import { afterEach, describe, expect, it } from 'vitest'; +import { removeOfflineProjects } from './fixtures/offline-project'; +import { + addAllExternalSpace, + codesAndSeverities, + DIR_BASE, + DIR_HEAD, + driverConfig, + EXTERNAL_SPACE, + fakeDatabase, + HASH_BASE, + HASH_EXTERNAL_HEAD, + HASH_HEAD, + HASH_UNKNOWN, + harness, + markersAt, + markersWithExternalAtHead, + projectWithOneMigration, + projectWithTwoMigrations, + withAllExternalExtension, +} from './fixtures/status-database'; + +afterEach(removeOfflineProjects); + +describe('migration status with reserved contract references', () => { + it('resolves --to @contract to the emitted contract, the same as no --to', async () => { + const project = await projectWithOneMigration(); + const db = fakeDatabase({ + markers: markersAt(HASH_HEAD), + ledger: [{ migrationHash: project.migrationHash }], + }); + const config = driverConfig(project, db); + + const implicit = await harness(config).run(['migration', 'status', '--json'], { + cwd: project.dir, + }); + const explicit = await harness(config).run( + ['migration', 'status', '--to', '@contract', '--json'], + { cwd: project.dir }, + ); + + expect(explicit.exitCode).toBe(0); + expect(explicit.presented?.data).toEqual(implicit.presented?.data); + expect(explicit.presented?.data).toMatchObject({ + summary: 'Up to date', + spaces: [{ targetContract: HASH_HEAD }], + }); + }); + + it('targets each extension space at its own contract for --to @contract', async () => { + const project = await projectWithOneMigration(); + await addAllExternalSpace(project); + const db = fakeDatabase({ + markers: markersWithExternalAtHead(HASH_HEAD), + ledger: [{ migrationHash: project.migrationHash }], + }); + + const config = withAllExternalExtension(driverConfig(project, db)); + + const implicit = await harness(config).run(['migration', 'status', '--json'], { + cwd: project.dir, + }); + const run = await harness(config).run(['migration', 'status', '--to', '@contract', '--json'], { + cwd: project.dir, + }); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toEqual(implicit.presented?.data); + expect(run.presented?.data).toMatchObject({ + summary: 'Up to date', + diagnostics: [], + spaces: expect.arrayContaining([ + expect.objectContaining({ space: 'app', targetContract: HASH_HEAD }), + expect.objectContaining({ + space: EXTERNAL_SPACE, + currentContract: HASH_EXTERNAL_HEAD, + targetContract: HASH_EXTERNAL_HEAD, + }), + ]), + }); + }); + + it('resolves --from @contract offline', async () => { + const project = await projectWithOneMigration(); + const db = fakeDatabase(); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--from', '@contract', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(db.counters.connections).toBe(0); + expect(run.presented?.data).toMatchObject({ + summary: 'Up to date', + spaces: [{ currentContract: HASH_HEAD, targetContract: HASH_HEAD }], + }); + }); + + it('resolves --to @db to the live marker and reports up to date', async () => { + const project = await projectWithTwoMigrations(); + const db = fakeDatabase({ + markers: markersAt(HASH_BASE), + ledger: [{ migrationHash: project.baseMigrationHash }], + }); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--to', '@db', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ + summary: 'Up to date', + diagnostics: [], + spaces: [{ currentContract: HASH_BASE, targetContract: HASH_BASE }], + }); + }); + + it('reports the migration pending between the live marker and --to when --from is @db', async () => { + const project = await projectWithTwoMigrations(); + const db = fakeDatabase({ + markers: markersAt(HASH_BASE), + ledger: [{ migrationHash: project.baseMigrationHash }], + }); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--from', '@db', '--to', DIR_HEAD, '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(db.counters.connections).toBe(1); + expect(run.presented?.data).toMatchObject({ + summary: `1 pending — run \`{bin} db migrate --to ${HASH_HEAD.slice(0, 12)}\``, + spaces: [ + { + currentContract: HASH_BASE, + targetContract: HASH_HEAD, + migrations: expect.arrayContaining([ + expect.objectContaining({ name: DIR_BASE, status: 'applied' }), + expect.objectContaining({ name: DIR_HEAD, status: 'pending' }), + ]), + }, + ], + }); + }); + + it('reads the database only for the target when --from is a hash and --to is @db', async () => { + const project = await projectWithTwoMigrations(); + const db = fakeDatabase({ + markers: markersAt(HASH_HEAD), + ledger: [{ migrationHash: project.baseMigrationHash }], + }); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--from', HASH_BASE, '--to', '@db', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(db.counters.connections).toBe(1); + expect(run.presented?.data).toMatchObject({ + spaces: [ + { + currentContract: HASH_BASE, + targetContract: HASH_HEAD, + migrations: expect.not.arrayContaining([expect.objectContaining({ status: 'applied' })]), + }, + ], + }); + }); + + it('labels the database marker @db in the tree when --from is a hash and --to is @db', async () => { + const project = await projectWithTwoMigrations(); + const db = fakeDatabase({ markers: markersAt(HASH_BASE) }); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--from', HASH_BASE, '--to', '@db'], + { cwd: project.dir, isTty: { stdout: true, stderr: true } }, + ); + const dbLines = stripAnsi(run.stderr) + .split('\n') + .filter((line) => line.includes('@db')); + + expect(run.exitCode).toBe(0); + expect(dbLines).toHaveLength(1); + expect(dbLines[0]).toContain(HASH_BASE.slice(0, 7)); + }); + + it('labels the empty node @db when --to @db reads a database with no marker', async () => { + const project = await projectWithTwoMigrations(); + const db = fakeDatabase(); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--to', '@db'], + { + cwd: project.dir, + isTty: { stdout: true, stderr: true }, + }, + ); + const dbLines = stripAnsi(run.stderr) + .split('\n') + .filter((line) => line.includes('@db')); + + expect(run.exitCode).toBe(0); + expect(dbLines).toHaveLength(1); + expect(dbLines[0]).toContain('∅'); + }); + + it('reports no path when --from @contract is ahead of the database named by --to @db', async () => { + const project = await projectWithTwoMigrations(); + const db = fakeDatabase({ + markers: markersAt(HASH_BASE), + ledger: [{ migrationHash: project.baseMigrationHash }], + }); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--from', '@contract', '--to', '@db', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ + summary: `No migration path from the --from contract (${HASH_HEAD.slice(0, 12)}) to the target (${HASH_BASE.slice(0, 12)}). Run \`{bin} migration plan --name \` to author one, or pass \`--to \` to pick a reachable target.`, + }); + }); + + it('warns when --to @db reads a marker outside the graph and --from is a hash', async () => { + const project = await projectWithTwoMigrations(); + const db = fakeDatabase({ markers: markersAt(HASH_UNKNOWN) }); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--from', HASH_BASE, '--to', '@db', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(codesAndSeverities(run.presented?.diagnostics ?? [])).toEqual([ + { code: 'MIGRATION.MARKER_NOT_IN_HISTORY', severity: 'warn' }, + ]); + expect(run.presented?.data).toMatchObject({ + summary: `Database marker ${HASH_UNKNOWN.slice(0, 12)} is not in the on-disk migration graph`, + }); + }); + + it('errors with the connection-required envelope for --to @db without a connection', async () => { + const project = await projectWithOneMigration(); + const config = driverConfig(project); + + const run = await harness({ ...config, db: undefined }).run( + ['migration', 'status', '--from', HASH_HEAD, '--to', '@db', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { + code: 'CONFIG.DB_CONNECTION_REQUIRED', + why: expect.stringContaining('@db'), + meta: { missingFlags: ['--db'] }, + nextActions: [ + expect.objectContaining({ + label: expect.stringContaining( + `migration status --from ${HASH_HEAD} --to @db --db $DATABASE_URL`, + ), + }), + ], + }, + }, + }); + }); + + it('errors with the connection-required envelope for --from @db without a connection', async () => { + const project = await projectWithOneMigration(); + const config = driverConfig(project); + + const run = await harness({ ...config, db: undefined }).run( + ['migration', 'status', '--from', '@db', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { code: 'CONFIG.DB_CONNECTION_REQUIRED', meta: { missingFlags: ['--db'] } }, + }, + }); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts index 610a3f76e76f..c855a60ace11 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts @@ -1,120 +1,35 @@ import { rm } from 'node:fs/promises'; import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; import { writeRef } from '@internal/migration-tools/refs'; -import type { Diagnostic } from '@prisma/cli-engine/protocol'; import { join } from 'pathe'; import stripAnsi from 'strip-ansi'; import { afterEach, describe, expect, it } from 'vitest'; -import { BIN_COMMANDS, BIN_GROUPS } from '../../src/orm/cli'; -import { createOrmTestCli } from '../helpers/orm-test-cli'; import { createOfflineProject, invariantOp, - type OfflineProject, - offlineConfig, removeOfflineProjects, seedMigrationPackage, } from './fixtures/offline-project'; +import { + addAllExternalSpace, + codesAndSeverities, + DIR_BASE, + driverConfig, + EXTERNAL_SPACE, + fakeDatabase, + HASH_BASE, + HASH_EXTERNAL_HEAD, + HASH_HEAD, + HASH_UNKNOWN, + harness, + markersAt, + markersWithExternalAtHead, + projectWithOneMigration, + withAllExternalExtension, +} from './fixtures/status-database'; afterEach(removeOfflineProjects); -const HASH_HEAD = `c0ffee${'0'.repeat(58)}`; -const HASH_BASE = `beef${'1'.repeat(60)}`; -const HASH_UNKNOWN = `dead${'2'.repeat(60)}`; -const CONNECTION = 'postgres://user:secret@localhost:5432/appdb'; - -interface FakeDatabaseScript { - readonly markers?: ReadonlyMap< - string, - { readonly storageHash: string; readonly invariants: readonly string[] } - >; - readonly ledger?: ReadonlyArray<{ readonly migrationHash: string }>; - readonly readMarkersError?: Error; - readonly closeError?: Error; -} - -/** - * The database the real control client talks to: the family instance answers - * marker and ledger reads from the script, and the driver descriptor counts - * connections so tests can assert none was opened. No module mocks — the - * command builds the real client over these descriptors. - */ -function fakeDatabase(script: FakeDatabaseScript = {}) { - const counters = { connections: 0, closes: 0 }; - const familyInstance = { - deserializeContract: (json: unknown) => json, - readAllMarkers: async () => { - if (script.readMarkersError !== undefined) { - throw script.readMarkersError; - } - return script.markers ?? new Map(); - }, - readLedger: async () => script.ledger ?? [], - }; - const driver = { - close: async () => { - counters.closes += 1; - if (script.closeError !== undefined) { - throw script.closeError; - } - }, - }; - return { counters, familyInstance, driver }; -} - -type FakeDatabase = ReturnType; - -function driverConfig( - project: OfflineProject, - db: FakeDatabase = fakeDatabase(), -): Record { - const base = offlineConfig({ project }); - return { - ...base, - family: { ...(base['family'] as Record), create: () => db.familyInstance }, - driver: { - kind: 'driver', - id: 'pg', - familyId: 'sql', - targetId: 'postgres', - version: '1.0.0', - create: async () => { - db.counters.connections += 1; - return db.driver; - }, - }, - db: { connection: CONNECTION }, - }; -} - -function harness(config: Record) { - return createOrmTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, orm: config }); -} - -/** A project whose app space carries one migration ∅ → HASH_HEAD. */ -async function projectWithOneMigration(): Promise< - OfflineProject & { readonly migrationHash: string } -> { - const project = await createOfflineProject({ storageHash: HASH_HEAD }); - const seeded = await seedMigrationPackage({ - appMigrationsDir: project.appMigrationsDir, - dirName: '20260101T0000_initial', - from: null, - to: HASH_HEAD, - }); - return { ...project, migrationHash: seeded.migrationHash }; -} - -function markersAt(storageHash: string) { - return new Map([['app', { storageHash, invariants: [] as readonly string[] }]]); -} - -function codesAndSeverities( - diagnostics: readonly Diagnostic[], -): ReadonlyArray<{ code: string; severity: string }> { - return diagnostics.map(({ code, severity }) => ({ code, severity })); -} - describe('migration status', () => { it('settles as a completed envelope carrying the status document', async () => { const project = await projectWithOneMigration(); @@ -181,6 +96,109 @@ describe('migration status', () => { }); }); + it('warns when the marker equals the emitted contract but no migration ends there', async () => { + const project = await createOfflineProject({ storageHash: HASH_HEAD }); + await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: DIR_BASE, + from: null, + to: HASH_BASE, + }); + const db = fakeDatabase({ markers: markersAt(HASH_HEAD) }); + + const run = await harness(driverConfig(project, db)).run(['migration', 'status', '--json'], { + cwd: project.dir, + }); + + expect(run.exitCode).toBe(0); + expect(codesAndSeverities(run.presented?.diagnostics ?? [])).toEqual([ + { code: 'MIGRATION.MARKER_NOT_IN_HISTORY', severity: 'warn' }, + ]); + expect(run.presented?.data).toMatchObject({ + summary: `Database marker ${HASH_HEAD.slice(0, 12)} is not in the on-disk migration graph`, + spaces: [{ currentContract: HASH_HEAD, targetContract: HASH_HEAD }], + }); + }); + + it('stays quiet when the app space has no migrations and the marker is the emitted contract', async () => { + const project = await createOfflineProject({ storageHash: HASH_HEAD }); + const db = fakeDatabase({ markers: markersAt(HASH_HEAD) }); + + const run = await harness(driverConfig(project, db)).run(['migration', 'status'], { + cwd: project.dir, + isTty: { stdout: true }, + }); + + expect(run.exitCode).toBe(0); + expect(run.presented?.diagnostics ?? []).toEqual([]); + expect(run.presented?.data).toMatchObject({ summary: 'No migrations found' }); + expect(run.presented?.presentation.human.at(-1)).toEqual({ + kind: 'summary', + status: 'ok', + text: 'No migrations found', + }); + }); + + it('warns when the app space has no migrations and the marker is another contract', async () => { + const project = await createOfflineProject({ storageHash: HASH_HEAD }); + const db = fakeDatabase({ markers: markersAt(HASH_UNKNOWN) }); + + const run = await harness(driverConfig(project, db)).run(['migration', 'status', '--json'], { + cwd: project.dir, + }); + + expect(run.exitCode).toBe(0); + expect(codesAndSeverities(run.presented?.diagnostics ?? [])).toEqual([ + { code: 'MIGRATION.MARKER_NOT_IN_HISTORY', severity: 'warn' }, + ]); + }); + + it('stays quiet about an all-external extension space whose marker is at its head', async () => { + const project = await projectWithOneMigration(); + await addAllExternalSpace(project); + const db = fakeDatabase({ + markers: markersWithExternalAtHead(HASH_HEAD), + ledger: [{ migrationHash: project.migrationHash }], + }); + + const run = await harness(withAllExternalExtension(driverConfig(project, db))).run( + ['migration', 'status', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(run.presented?.diagnostics).toEqual([]); + expect(run.presented?.data).toMatchObject({ + summary: 'Up to date', + spaces: expect.arrayContaining([ + expect.objectContaining({ + space: EXTERNAL_SPACE, + currentContract: HASH_EXTERNAL_HEAD, + targetContract: HASH_EXTERNAL_HEAD, + }), + ]), + }); + }); + + it('names the extension head, not the app --to, when an extension space has no path', async () => { + const project = await projectWithOneMigration(); + await addAllExternalSpace(project); + const db = fakeDatabase({ + markers: markersAt(HASH_HEAD), + ledger: [{ migrationHash: project.migrationHash }], + }); + + const run = await harness(withAllExternalExtension(driverConfig(project, db))).run( + ['migration', 'status', '--to', HASH_HEAD, '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ + summary: `No migration path from the database state to the head of extension space \`${EXTERNAL_SPACE}\` (${HASH_EXTERNAL_HEAD.slice(0, 12)}).`, + }); + }); + it('records invariants the marker is missing as a warn diagnostic and still exits 0', async () => { const project = await createOfflineProject({ storageHash: HASH_HEAD }); await seedMigrationPackage({ @@ -335,6 +353,35 @@ describe('migration status', () => { }); }); + it('keeps --to in the retry command it suggests when no connection is configured', async () => { + const project = await projectWithOneMigration(); + const config = driverConfig(project); + + const run = await harness({ ...config, db: undefined }).run( + ['migration', 'status', '--to', HASH_HEAD, '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { + code: 'CONFIG.DB_CONNECTION_REQUIRED', + meta: { missingFlags: ['--db'] }, + nextActions: [ + expect.objectContaining({ + label: expect.stringContaining( + `migration status --from --to ${HASH_HEAD}`, + ), + }), + ], + }, + }, + }); + }); + it('uses the same envelope with no missing flags when only the driver is absent', async () => { const project = await projectWithOneMigration(); const config = driverConfig(project); diff --git a/packages/1-framework/3-tooling/cli/test/orm/status-summary.test.ts b/packages/1-framework/3-tooling/cli/test/orm/status-summary.test.ts index 6fd102a8e1db..4b873d3ded6b 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/status-summary.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/status-summary.test.ts @@ -5,10 +5,9 @@ describe('buildNoPathSummary', () => { it('names the live contract when no --to was passed', () => { expect( buildNoPathSummary({ - markerHash: 'a'.repeat(64), + origin: { kind: 'database', marker: { storageHash: 'a'.repeat(64), invariants: [] } }, targetHash: 'b'.repeat(64), - explicitTarget: false, - refName: undefined, + target: { kind: 'app', explicitTarget: false, refName: undefined }, }), ).toBe( "No migration path from the database state (aaaaaaaaaaaa) to the application's contract (bbbbbbbbbbbb). Run `{bin} migration plan --name ` to author one.", @@ -18,10 +17,9 @@ describe('buildNoPathSummary', () => { it('names the ref when --to resolved via ref', () => { expect( buildNoPathSummary({ - markerHash: 'a'.repeat(64), + origin: { kind: 'database', marker: { storageHash: 'a'.repeat(64), invariants: [] } }, targetHash: 'b'.repeat(64), - explicitTarget: true, - refName: 'prod', + target: { kind: 'app', explicitTarget: true, refName: 'prod' }, }), ).toBe( 'No migration path from the database state (aaaaaaaaaaaa) to the target (bbbbbbbbbbbb via `prod`). Run `{bin} migration plan --name ` to author one, or pass `--to ` to pick a reachable target.', @@ -31,10 +29,9 @@ describe('buildNoPathSummary', () => { it('omits via ref when --to was a raw hash', () => { expect( buildNoPathSummary({ - markerHash: 'a'.repeat(64), + origin: { kind: 'database', marker: { storageHash: 'a'.repeat(64), invariants: [] } }, targetHash: 'b'.repeat(64), - explicitTarget: true, - refName: undefined, + target: { kind: 'app', explicitTarget: true, refName: undefined }, }), ).toBe( 'No migration path from the database state (aaaaaaaaaaaa) to the target (bbbbbbbbbbbb). Run `{bin} migration plan --name ` to author one, or pass `--to ` to pick a reachable target.', @@ -44,15 +41,38 @@ describe('buildNoPathSummary', () => { it('omits the marker parenthetical when the marker hash is unknown', () => { expect( buildNoPathSummary({ - markerHash: undefined, + origin: { kind: 'database', marker: undefined }, targetHash: 'b'.repeat(64), - explicitTarget: false, - refName: undefined, + target: { kind: 'app', explicitTarget: false, refName: undefined }, }), ).toBe( "No migration path from the database state to the application's contract (bbbbbbbbbbbb). Run `{bin} migration plan --name ` to author one.", ); }); + + it('names the --from contract when the origin is offline', () => { + expect( + buildNoPathSummary({ + origin: { kind: 'offline', hash: 'a'.repeat(64) }, + targetHash: 'b'.repeat(64), + target: { kind: 'app', explicitTarget: true, refName: undefined }, + }), + ).toBe( + 'No migration path from the --from contract (aaaaaaaaaaaa) to the target (bbbbbbbbbbbb). Run `{bin} migration plan --name ` to author one, or pass `--to ` to pick a reachable target.', + ); + }); + + it('names the head of an extension space and leaves out the app remedies', () => { + expect( + buildNoPathSummary({ + origin: { kind: 'database', marker: undefined }, + targetHash: 'b'.repeat(64), + target: { kind: 'extension', spaceId: 'pgvector' }, + }), + ).toBe( + 'No migration path from the database state to the head of extension space `pgvector` (bbbbbbbbbbbb).', + ); + }); }); describe('buildStatusHeadline', () => { diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/marker-types.ts b/packages/1-framework/3-tooling/migration/src/aggregate/marker-types.ts index 4016fd5ca103..6f73bacfa694 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/marker-types.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/marker-types.ts @@ -1,3 +1,5 @@ +import { EMPTY_CONTRACT_HASH } from '../constants'; + /** * Structural shape the aggregate planner / verifier accept for marker * rows. Mirrors `family.readAllMarkers(...)` outputs across SQL and @@ -14,3 +16,10 @@ export interface ContractMarkerRecordLike { readonly invariants: readonly string[]; readonly profileHash?: string; } + +/** The contract hash a database is at: its marker's storage hash, or the empty contract when it has no marker. */ +export function contractHashAtMarker( + marker: Pick | null | undefined, +): string { + return marker?.storageHash ?? EMPTY_CONTRACT_HASH; +} diff --git a/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts b/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts index f7afe56167d6..54a0e45a5de1 100644 --- a/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts +++ b/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts @@ -14,7 +14,7 @@ export { } from '../aggregate/check-integrity'; export { buildFabricatedMigrationEdge } from '../aggregate/fabricated-migration-edge'; export { type LoadAggregateInput, loadContractSpaceAggregate } from '../aggregate/loader'; -export type { ContractMarkerRecordLike } from '../aggregate/marker-types'; +export { type ContractMarkerRecordLike, contractHashAtMarker } from '../aggregate/marker-types'; export { type AggregateCurrentDBState, type AggregateMigrationEdgeRef, diff --git a/packages/1-framework/3-tooling/migration/src/exports/migration-graph.ts b/packages/1-framework/3-tooling/migration/src/exports/migration-graph.ts index f355de0b1aca..3255d76d9e3c 100644 --- a/packages/1-framework/3-tooling/migration/src/exports/migration-graph.ts +++ b/packages/1-framework/3-tooling/migration/src/exports/migration-graph.ts @@ -1,4 +1,4 @@ -export { assertHashIsGraphNode, isGraphNode } from '../graph-membership'; +export { assertHashIsGraphNode, isGraphNode, isInSpaceHistory } from '../graph-membership'; export type { PathDecision } from '../migration-graph'; export { detectCycles, diff --git a/packages/1-framework/3-tooling/migration/src/exports/ref-resolution.ts b/packages/1-framework/3-tooling/migration/src/exports/ref-resolution.ts index 232ffef39554..742fee9ce595 100644 --- a/packages/1-framework/3-tooling/migration/src/exports/ref-resolution.ts +++ b/packages/1-framework/3-tooling/migration/src/exports/ref-resolution.ts @@ -1,4 +1,12 @@ -export { parseContractRef } from '../refs/contract-ref'; +export { + EMPTY_CONTRACT_REF, + isLiveMarkerRef, + isReservedContractRef, + LIVE_MARKER_REF, + parseContractRef, + RESERVED_CONTRACT_REFS, + WORKING_CONTRACT_REF, +} from '../refs/contract-ref'; export { parseMigrationRef } from '../refs/migration-ref'; export type { ContractRef, diff --git a/packages/1-framework/3-tooling/migration/src/graph-membership.ts b/packages/1-framework/3-tooling/migration/src/graph-membership.ts index ed1d1b374899..6c277dd44396 100644 --- a/packages/1-framework/3-tooling/migration/src/graph-membership.ts +++ b/packages/1-framework/3-tooling/migration/src/graph-membership.ts @@ -9,6 +9,17 @@ export function isGraphNode(hash: string, graph: MigrationGraph): boolean { return graph.nodes.has(hash); } +/** True when a marker hash is in a space's history: a graph node, or the head of a space that has no migrations. */ +export function isInSpaceHistory( + hash: string, + space: { readonly graph: MigrationGraph; readonly headHash: string | undefined }, +): boolean { + if (isGraphNode(hash, space.graph)) { + return true; + } + return space.graph.nodes.size === 0 && hash === space.headHash; +} + export function assertHashIsGraphNode(hash: string, graph: MigrationGraph): asserts hash is string { if (isGraphNode(hash, graph)) { return; diff --git a/packages/1-framework/3-tooling/migration/src/refs/contract-ref.ts b/packages/1-framework/3-tooling/migration/src/refs/contract-ref.ts index 7c58fd628638..1169d84917e8 100644 --- a/packages/1-framework/3-tooling/migration/src/refs/contract-ref.ts +++ b/packages/1-framework/3-tooling/migration/src/refs/contract-ref.ts @@ -10,6 +10,27 @@ import type { } from './types'; import { findEdgeByDirName, isFullHash, isHexPrefix } from './types'; +export const WORKING_CONTRACT_REF = '@contract'; +export const LIVE_MARKER_REF = '@db'; +export const EMPTY_CONTRACT_REF = '@empty'; + +/** The reserved references, in a fixed order: `@contract`, `@db`, `@empty`. */ +export const RESERVED_CONTRACT_REFS: readonly string[] = [ + WORKING_CONTRACT_REF, + LIVE_MARKER_REF, + EMPTY_CONTRACT_REF, +]; + +/** True for a reserved reference, which resolves from contract.json, the database, or the empty contract rather than from a contract recorded in the migrations directory. */ +export function isReservedContractRef(input: string): boolean { + return RESERVED_CONTRACT_REFS.includes(input); +} + +/** True for `@db`, the only reserved reference that needs a database read to resolve. */ +export function isLiveMarkerRef(input: string | undefined): boolean { + return input === LIVE_MARKER_REF; +} + /** * Resolve a user-supplied string to a contract hash using the unified * contract-reference grammar. @@ -17,9 +38,9 @@ import { findEdgeByDirName, isFullHash, isHexPrefix } from './types'; * Accepted forms: * - `@contract` — the on-disk working contract hash (offline; requires * `ctx.contractHash` to be set) - * - `@db` — the live database marker (connection-required); callers MUST - * check `result.value.provenance.kind === 'reserved-db'` and resolve the - * actual hash via `readAllMarkers()` before using `result.value.hash` + * - `@db` — the live database marker. Callers that accept `@db` test + * `isLiveMarkerRef(input)` before parsing and resolve it from the marker; + * the `reserved-db` result carries a placeholder hash that must not be used * - `@empty` — the empty contract (offline; resolves to * `EMPTY_CONTRACT_HASH`, the origin with no prior storage state) * - Full storage hash (64 hex chars or `empty`) @@ -36,7 +57,7 @@ export function parseContractRef( return notOk({ kind: 'invalid-format', input, reason: 'Reference cannot be empty' }); } - if (input === '@contract') { + if (input === WORKING_CONTRACT_REF) { if (ctx.contractHash === undefined) { return notOk({ kind: 'not-found', @@ -47,16 +68,11 @@ export function parseContractRef( return ok({ hash: ctx.contractHash, provenance: { kind: 'reserved-contract' } }); } - if (input === '@db') { - // The live DB marker is not available offline. Return a sentinel result with - // a `reserved-db` provenance; callers must resolve the actual hash via - // `readAllMarkers()`. The `hash` placeholder is intentionally empty — it - // must NOT be used directly. This is enforced by convention; callers - // should check `provenance.kind` before using the hash. + if (input === LIVE_MARKER_REF) { return ok({ hash: '', provenance: { kind: 'reserved-db' } }); } - if (input === '@empty') { + if (input === EMPTY_CONTRACT_REF) { return ok({ hash: EMPTY_CONTRACT_HASH, provenance: { kind: 'reserved-empty' } }); } diff --git a/packages/1-framework/3-tooling/migration/src/refs/types.ts b/packages/1-framework/3-tooling/migration/src/refs/types.ts index 33b92119e759..190c884d4511 100644 --- a/packages/1-framework/3-tooling/migration/src/refs/types.ts +++ b/packages/1-framework/3-tooling/migration/src/refs/types.ts @@ -7,7 +7,7 @@ export interface RefResolutionContext { readonly refs: Refs; /** * Hash of the on-disk contract (`contract.json`). Required to resolve the - * `@contract` reserved token, which is an offline-resolvable alias for + * `@contract` reserved reference, which is an offline-resolvable alias for * "the working contract the app carries." */ readonly contractHash?: string; @@ -19,19 +19,18 @@ export type ContractRefProvenance = | { readonly kind: 'migration-to'; readonly dirName: string } | { readonly kind: 'migration-from'; readonly dirName: string } /** - * Resolved from the `@contract` reserved token — the hash of the on-disk + * Resolved from the `@contract` reserved reference — the hash of the on-disk * working contract (`contract.json`). Offline-resolvable. */ | { readonly kind: 'reserved-contract' } /** - * Resolved from the `@db` reserved token — the live database marker. - * The `hash` field is a placeholder; callers must resolve the actual hash - * via `readAllMarkers()` before using it. Check `provenance.kind === - * 'reserved-db'` to detect this case and perform the DB lookup. + * Resolved from the `@db` reserved reference. The `hash` field is a placeholder + * that must not be used: callers that accept `@db` test `isLiveMarkerRef` + * before parsing and resolve it from the live marker. */ | { readonly kind: 'reserved-db' } /** - * Resolved from the `@empty` reserved token — the empty contract + * Resolved from the `@empty` reserved reference — the empty contract * (`EMPTY_CONTRACT_HASH`), the origin with no prior storage state. * Offline-resolvable. */ diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/marker-types.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/marker-types.test.ts new file mode 100644 index 000000000000..23b36c89a94f --- /dev/null +++ b/packages/1-framework/3-tooling/migration/test/aggregate/marker-types.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { contractHashAtMarker } from '../../src/aggregate/marker-types'; +import { EMPTY_CONTRACT_HASH } from '../../src/constants'; + +describe('contractHashAtMarker', () => { + it('is the marker storage hash when a marker exists', () => { + expect(contractHashAtMarker({ storageHash: 'a'.repeat(64) })).toBe('a'.repeat(64)); + }); + + it.each([null, undefined])('is the empty contract when the marker is %s', (marker) => { + expect(contractHashAtMarker(marker)).toBe(EMPTY_CONTRACT_HASH); + }); +}); diff --git a/packages/1-framework/3-tooling/migration/test/graph-membership.test.ts b/packages/1-framework/3-tooling/migration/test/graph-membership.test.ts index 428884534e26..5c521fec28fc 100644 --- a/packages/1-framework/3-tooling/migration/test/graph-membership.test.ts +++ b/packages/1-framework/3-tooling/migration/test/graph-membership.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { EMPTY_CONTRACT_HASH } from '../src/constants'; import { MigrationToolsError } from '../src/errors'; -import { assertHashIsGraphNode, isGraphNode } from '../src/graph-membership'; +import { assertHashIsGraphNode, isGraphNode, isInSpaceHistory } from '../src/graph-membership'; import { computeMigrationHash } from '../src/hash'; import { reconstructGraph } from '../src/migration-graph'; import type { OnDiskMigrationPackage } from '../src/package'; @@ -55,6 +55,28 @@ describe('isGraphNode', () => { }); }); +describe('isInSpaceHistory', () => { + it('counts a node of the space graph', () => { + const graph = reconstructGraph(chain([E, 'aaa', 'm1'])); + expect(isInSpaceHistory('aaa', { graph, headHash: 'aaa' })).toBe(true); + }); + + it('counts the head of a space that has no migrations', () => { + const graph = reconstructGraph([]); + expect(isInSpaceHistory('aaa', { graph, headHash: 'aaa' })).toBe(true); + }); + + it('does not count another hash in a space that has no migrations', () => { + const graph = reconstructGraph([]); + expect(isInSpaceHistory('bbb', { graph, headHash: 'aaa' })).toBe(false); + }); + + it('does not count a head that is not a node when the space has migrations', () => { + const graph = reconstructGraph(chain([E, 'aaa', 'm1'])); + expect(isInSpaceHistory('bbb', { graph, headHash: 'bbb' })).toBe(false); + }); +}); + describe('assertHashIsGraphNode', () => { it('is a no-op for a graph-node hash', () => { const graph = reconstructGraph(chain([E, 'aaa', 'm1'])); diff --git a/packages/1-framework/3-tooling/migration/test/refs/contract-ref.test.ts b/packages/1-framework/3-tooling/migration/test/refs/contract-ref.test.ts index cd653d98f746..cfe7ba70b348 100644 --- a/packages/1-framework/3-tooling/migration/test/refs/contract-ref.test.ts +++ b/packages/1-framework/3-tooling/migration/test/refs/contract-ref.test.ts @@ -3,7 +3,12 @@ import { EMPTY_CONTRACT_HASH } from '../../src/constants'; import { reconstructGraph } from '../../src/migration-graph'; import type { OnDiskMigrationPackage } from '../../src/package'; import type { Refs } from '../../src/refs'; -import { parseContractRef } from '../../src/refs/contract-ref'; +import { + isLiveMarkerRef, + isReservedContractRef, + parseContractRef, + RESERVED_CONTRACT_REFS, +} from '../../src/refs/contract-ref'; import type { RefResolutionContext, RefResolutionError } from '../../src/refs/types'; const HASH_A = `${'a'.repeat(64)}`; @@ -224,7 +229,7 @@ describe('parseContractRef', () => { }); }); - describe('@contract reserved token', () => { + describe('@contract reserved reference', () => { it('resolves @contract to the contractHash in context', () => { const ctx = createContext(); const result = parseContractRef('@contract', { ...ctx, contractHash: HASH_B }); @@ -242,7 +247,7 @@ describe('parseContractRef', () => { }); }); - describe('@db reserved token', () => { + describe('@db reserved reference', () => { it('returns a reserved-db provenance that callers must resolve via readAllMarkers', () => { const ctx = createContext(); const result = parseContractRef('@db', ctx); @@ -264,7 +269,7 @@ describe('parseContractRef', () => { }); }); - describe('@empty reserved token', () => { + describe('@empty reserved reference', () => { it('resolves @empty to the empty contract hash offline', () => { const ctx = createContext(); const result = parseContractRef('@empty', ctx); @@ -316,3 +321,26 @@ describe('parseContractRef', () => { }); }); }); + +describe('reserved contract references', () => { + it('keeps the reserved references in a fixed order', () => { + expect(RESERVED_CONTRACT_REFS).toEqual(['@contract', '@db', '@empty']); + }); + + it.each(['@contract', '@db', '@empty'])('%s is reserved', (input) => { + expect(isReservedContractRef(input)).toBe(true); + }); + + it.each(['contract', 'db', '@other', HASH_A])('%s is not reserved', (input) => { + expect(isReservedContractRef(input)).toBe(false); + }); + + it('only @db names the live marker', () => { + expect([undefined, '@contract', '@db', '@empty'].map(isLiveMarkerRef)).toEqual([ + false, + false, + true, + false, + ]); + }); +}); diff --git a/skills/prisma-8/references/debug.md b/skills/prisma-8/references/debug.md index f5c554f65b0c..8ceccb1cee2d 100644 --- a/skills/prisma-8/references/debug.md +++ b/skills/prisma-8/references/debug.md @@ -100,7 +100,7 @@ The single source of truth: read the envelope, find the row by `code`, follow th | `MIGRATION.NO_INVARIANT_PATH` / `MIGRATION.UNKNOWN_INVARIANT` | `db migrate` | Concurrent-migration and invariant flows — `references/migration-review.md`. | | `DRIVER.NOT_CONNECTED` with message `Runtime is closed` | A query, prepared statement or `db.runtime().connection()` that starts after `db.close()` and after the runtime was idle for one turn of the event loop, or after the `await using` scope that held a serverless connection ended | The work started after the runtime closed. Almost always it is a lazy read (`db.orm...all()`, `db.runtime().query(...)`) returned without `await`, which starts only when the caller awaits it, or work that waited on something other than the database first. Write `return await ...`, or move the call before `close()`. See `references/runtime.md` § *Workflow — Serverless and per-request runtimes*. | | `await db.close()` or the end of an `await using` scope never settles in a test | A test that installed fake timers (`vi.useFakeTimers()`, Jest's modern fake timers) before the Prisma runtime module was imported | `close()` waits one turn of the event loop through a `setTimeout(0)` that the runtime module takes when it loads. Install fake timers after the imports, or advance the fake clock (`await vi.runAllTimersAsync()`) before awaiting the close. | -| `MIGRATION.PATH_UNREACHABLE` / `MIGRATION.MARKER_MISMATCH` | `db migrate` | Run `db migrate --show --db $URL` to inspect the path, then `migration plan --from --to ` or `migration list` to audit the graph — see `references/migration-review.md`. | +| `MIGRATION.PATH_UNREACHABLE` / `MIGRATION.MARKER_MISMATCH` | `db migrate`; `db migrate --show` raises `MARKER_MISMATCH` too | For `PATH_UNREACHABLE`, run `db migrate --show --db $URL` to inspect the path. Then run `migration plan --from --to `, or `migration list` to audit the graph — see `references/migration-review.md`. | | `MIGRATION.PLAN_ORIGIN_UNKNOWN` / `MIGRATION.HASH_NOT_IN_GRAPH` / `MIGRATION.SNAPSHOT_MISSING` | `migration plan`, `migration new` | Origin resolution — `references/migration-model.md` § *The trap* and `references/migrations.md` § *Dev → ship transition*. | | `MIGRATION.MISSING_INVARIANTS` | `migration status` `warn` diagnostic (exit 0) | The live marker reached the destination hash structurally but doesn't carry all invariants the target ref requires. Run `db migrate --to --db $URL` to take a path that covers the missing invariants. See `references/migration-review.md`. | | `MIGRATION.MARKER_NOT_IN_HISTORY` / `CONTRACT.UNREADABLE` | `migration status` `warn` diagnostics (exit 0) | Read `severity` *and* `code`. Up-to-date / pending / no-marker states are not codes — read `spaces[].currentContract` and `migrations[].status` in the `--json` document. `references/migration-review.md` covers the marker-out-of-history flow. | diff --git a/skills/prisma-8/references/migration-model.md b/skills/prisma-8/references/migration-model.md index 5e0320a84451..ad9ba0646156 100644 --- a/skills/prisma-8/references/migration-model.md +++ b/skills/prisma-8/references/migration-model.md @@ -65,7 +65,7 @@ pnpm prisma migration ref delete `migration plan` resolves its origin in exactly this order: -1. Explicit `--from ` — `@empty` names the empty database deliberately. The reserved forms `@db` and `@contract` exist in the shared ref grammar but do not resolve here: `migration plan` is offline, so `@db` (the live marker) has nothing to read, and `@contract` needs a contract hash the plan resolver does not pass. Use them with `db migrate --show` / `migration status`, not with `plan`. +1. Explicit `--from ` — `@empty` names the empty database deliberately. The reserved forms `@db` and `@contract` exist in the shared ref grammar but do not resolve here: `migration plan` is offline, so `@db` (the live marker) has nothing to read, and `@contract` needs a contract hash the plan resolver does not pass. Use them with `db migrate --show` / `migration status`, not with `plan`. 2. No `--from` → the `db` ref (`migrations/app/refs/db.json`). 3. No `db` ref → **greenfield: the plan starts from the empty database.** On an empty graph the human output adds a muted notice beneath the summary — `No db ref set — planning from an empty database. Run db init, db update, or db sign if a database already exists.` — and the JSON document carries `fromDefaulted: true`, so this case is distinguishable from an explicit `--from @empty`. diff --git a/skills/prisma-8/references/migration-review.md b/skills/prisma-8/references/migration-review.md index 35ce48dc0470..5247ad5fc7c4 100644 --- a/skills/prisma-8/references/migration-review.md +++ b/skills/prisma-8/references/migration-review.md @@ -58,7 +58,7 @@ The graph is a static, committed artifact. Several branch tips may coexist, roll | Code | Meaning in the navigation model | Next move | |---|---|---| -| `MIGRATION.MARKER_NOT_IN_HISTORY` | Online; marker hash is not a node in the graph. The database was changed outside the migration system. | Decide which side is truth: `db sign` (accept DB as truth), `db update` (push contract to DB), `contract infer` (re-derive contract from DB), or `db verify` (inspect first). **Not** the same as `MIGRATION.MARKER_MISMATCH`, which `db migrate` raises as an error before any DDL when the marker hash is not a graph node. | +| `MIGRATION.MARKER_NOT_IN_HISTORY` | Online; marker hash is not a node in the graph. The database was changed outside the migration system. | Decide which side is truth: `db sign` (accept DB as truth), `db update` (push contract to DB), `contract infer` (re-derive contract from DB), or `db verify` (inspect first). **Not** the same as `MIGRATION.MARKER_MISMATCH`, which `db migrate` (before any DDL) and `db migrate --show` raise as an error when the marker hash is not a graph node. | | `MIGRATION.MISSING_INVARIANTS` | Marker reached the destination structurally but lacks invariants the target ref declares. | `db migrate --to --db $URL` to take a path that covers them. | | `CONTRACT.UNREADABLE` | `contract.json` couldn't be read. | `contract emit` to regenerate it. | @@ -81,7 +81,7 @@ These codes surface on `migration plan`, `migration ref set`, and `db migrate` |---|---|---|---| | `MIGRATION.HASH_NOT_IN_GRAPH` | `migration plan` (non-empty graph) or `migration ref set` | Resolved hash is not a node in the on-disk migration graph — typical when the default `db` ref points past the graph tip after dev-only `db update` cycles. | `migration plan --from ` (e.g. `--from production`); or realign the ref with `migration ref set db `. | | `MIGRATION.SNAPSHOT_MISSING` | `migration plan` | A named ref has no pointer file (`.json`), and the hash being resolved isn't a node in the migration graph either. | `migration ref set ` to create the ref, `db update --advance-ref ` to advance it, or pass a hash that is a graph node. | -| `MIGRATION.MARKER_MISMATCH` | `db migrate` (pre-DDL, before the runner) | Live DB marker hash is not a graph node — drift the offline planner cannot see. | `migration plan --from ` if the marker is canonical; `migration ref set db ` if the on-disk graph is canonical; investigate out-of-band applies. | +| `MIGRATION.MARKER_MISMATCH` | `db migrate` (pre-DDL, before the runner), `db migrate --show` | Live DB marker hash is not a graph node — drift the offline planner cannot see. | `migration plan --from ` if the marker is canonical; `migration ref set db ` if the on-disk graph is canonical; investigate out-of-band applies. | | `MIGRATION.PATH_UNREACHABLE` | `db migrate` (path resolution) | No migration path from the current marker to the resolved target in the on-disk graph. | Read the improved `fix` payload — it names `fromHash` / `targetHash` and suggests `migration plan --from --to `; run `migration list` to inspect the graph. | ## Workflow — *"What's about to run on deploy?"* diff --git a/test/integration/test/cli-journeys/migration-status-diagnostics.e2e.test.ts b/test/integration/test/cli-journeys/migration-status-diagnostics.e2e.test.ts index 47f44075781d..27e76f01f211 100644 --- a/test/integration/test/cli-journeys/migration-status-diagnostics.e2e.test.ts +++ b/test/integration/test/cli-journeys/migration-status-diagnostics.e2e.test.ts @@ -291,16 +291,15 @@ withTempDir(({ createTempDir }) => { * Scenario: the database was updated directly via `db update` instead * of through the migration system. * - * The DB marker matches the live contract after db update, but that - * hash may not be a migration-graph node. Status defaults to the live - * contract as target (same as migrate); when DB and contract align, - * the headline is up to date while MARKER_NOT_IN_HISTORY still warns. + * The DB marker matches the live contract after db update, but no + * migration ends at that hash. Status warns MARKER_NOT_IN_HISTORY and + * says so in the headline, as `db migrate` refuses in the same state. */ describe('DB updated directly — marker ahead of graph', () => { const db = useDevDatabase(); it( - 'emit → plan → apply → swap → emit → db update → up to date with divergence warn', + 'emit → plan → apply → swap → emit → db update → marker-not-in-history warning', async () => { const ctx: JourneyContext = setupJourney({ connectionString: db.connectionString, @@ -325,7 +324,9 @@ withTempDir(({ createTempDir }) => { expect(status.exitCode).toBe(0); expect(out).toContain('@contract @db (db)'); - expect(out).toContain('Up to date'); + expect(out).toContain('is not in the on-disk migration graph'); + expect(out).toContain('MIGRATION.MARKER_NOT_IN_HISTORY'); + expect(out).not.toContain('Up to date'); }, timeouts.spinUpPpgDev, );