|
| 1 | +# `meta migrate apply-pending` — replay committed migrations (#242) |
| 2 | + |
| 3 | +- **Date:** 2026-07-27 |
| 4 | +- **Status:** Design approved; ready for implementation plan |
| 5 | +- **Issue:** [#242](https://github.com/metaobjectsdev/metaobjects/issues/242) — add a CLI subcommand to replay committed migrations (`applyPending`) |
| 6 | +- **Surface:** `@metaobjectsdev/cli` (`meta migrate`). TS-only. No change to `@metaobjectsdev/migrate-ts` replay semantics. |
| 7 | + |
| 8 | +## Problem |
| 9 | + |
| 10 | +Provisioning a fresh (or partially-migrated) database from the **committed** migration |
| 11 | +files — local dev, CI test databases, scratch/replay databases — is a standard |
| 12 | +operation, but the CLI has no verb for it. |
| 13 | + |
| 14 | +`applyPending(db, dir, opts)` is exported from `@metaobjectsdev/migrate-ts` and is the |
| 15 | +correct primitive: it replays the migration files in order, ledger-tracked |
| 16 | +(`_metaobjects_migrations`), transactionally, creating tables **and** views. But the |
| 17 | +only CLI path that reaches it is `meta migrate --apply`, which is **diff-first** — it |
| 18 | +diffs metadata against the live DB and authors a migration for the difference before |
| 19 | +replaying. On an empty database that diff is non-empty, so: |
| 20 | + |
| 21 | +- with no `--slug`, `migrate` exits `2` before `applyPending` ever runs; |
| 22 | +- with `--slug`, it authors a redundant monolith migration that then collides with the |
| 23 | + already-committed migration files. |
| 24 | + |
| 25 | +So "replay the committed migrations against this DB" is currently reachable only by |
| 26 | +hand-writing a bespoke Kysely-construction script that calls `applyPending` directly — |
| 27 | +boilerplate every consumer re-invents. |
| 28 | + |
| 29 | +Authoring-from-a-diff and replaying-committed-files are two distinct operations (cf. |
| 30 | +Prisma `migrate dev` vs `migrate deploy`, Flyway `migrate`, Rails `db:migrate`). The |
| 31 | +toolchain ships the author half via CLI and the replay half only as a library |
| 32 | +function; this adds the missing replay verb. |
| 33 | + |
| 34 | +## Design |
| 35 | + |
| 36 | +Add a `meta migrate apply-pending` subcommand — a thin CLI wrapper over the existing |
| 37 | +public `applyPending`. It performs **no diff and loads no metadata**, so it works on an |
| 38 | +empty database where `--apply` cannot. |
| 39 | + |
| 40 | +### Subcommand parsing |
| 41 | + |
| 42 | +`apply-pending` is parsed as a positional in `parseMigrateArgs`, mirroring the existing |
| 43 | +`baseline` positional, into a new boolean `config.applyPending`. (`baseline` and |
| 44 | +`apply-pending` are mutually exclusive positionals; supplying both is a usage error.) |
| 45 | + |
| 46 | +### Dispatch + handler |
| 47 | + |
| 48 | +Dispatched early in `migrateCommand` (before the diff/emit pipeline), alongside the |
| 49 | +existing `runBaseline` / `runRollback` handlers, via a new |
| 50 | +`runApplyPending(config, metaRoot, fmt): Promise<number>`. It reuses the CLI's existing |
| 51 | +plumbing verbatim: |
| 52 | + |
| 53 | +1. **Reject `d1`.** `apply-pending` is postgres/sqlite only — D1 replays committed files |
| 54 | + through `wrangler d1 migrations apply`, not Kysely. Emit an error that points there |
| 55 | + and return `2`. Placed with the other d1 subcommand rejections (baseline/rollback). |
| 56 | +2. **Require a connection.** Reuse the existing `config.databaseUrl === undefined` → |
| 57 | + "`--db <url> required`" error path; return `2`. |
| 58 | +3. `const kysely = await buildKyselyFromUrl(config.databaseUrl, config.dialect)`. |
| 59 | +4. `const outDir = resolvePath(metaRoot, config.outDir)` — the same migrations-directory |
| 60 | + default every migrate subcommand uses (`MIGRATE_DEFAULT_OUT_DIR`). |
| 61 | +5. `const result = await applyPending(kysely.db, outDir, { dryRun: config.dryRun ?? false, dialect: kysely.dialect as "sqlite" | "postgres" })`. |
| 62 | +6. **Report** (text + structured, honoring `fmt`): |
| 63 | + - dry-run: the pending list (`N pending: <names>`), nothing applied; |
| 64 | + - real run with applied files: `applied N migration(s): <names>`; |
| 65 | + - real run with nothing pending: `already up to date` (a no-op success, not an error). |
| 66 | +7. `await kysely.db.destroy()` in a `finally`. |
| 67 | +8. Return `0` on success, `2` on usage error, `1` on an apply failure (an |
| 68 | + `applyPending` throw — e.g. the checksum tamper guard, or SQL that fails to apply). |
| 69 | + |
| 70 | +### Help text |
| 71 | + |
| 72 | +Add `apply-pending` to `MIGRATE_HELP_TEXT`: the subcommand line (a one-line description) |
| 73 | +and one example, e.g. |
| 74 | +`meta migrate apply-pending --db postgresql://localhost/mydb`. |
| 75 | + |
| 76 | +## Behavior notes |
| 77 | + |
| 78 | +- **Idempotent.** `applyPending` skips ledger-recorded migrations, so a second run applies |
| 79 | + nothing and reports "already up to date". Re-runnable safely. |
| 80 | +- **Drift is not checked.** `apply-pending` replays files; it does not compare against |
| 81 | + current metadata. `meta verify` remains the drift check. (This is inherent to |
| 82 | + `applyPending`, which is already public — not a new capability, just a discoverable CLI |
| 83 | + path to it.) |
| 84 | +- **`--dry-run`** lists pending migrations without applying (delegates to |
| 85 | + `applyPending`'s `dryRun`). |
| 86 | + |
| 87 | +## Non-goals (YAGNI) |
| 88 | + |
| 89 | +- No change to `applyPending`'s replay semantics. |
| 90 | +- No `--target` / partial-apply, no transaction-tuning flags, no `down`/rollback (that is |
| 91 | + `meta migrate --rollback`). |
| 92 | +- No `d1` support (use `wrangler d1 migrations apply`). |
| 93 | +- No metadata diffing (that is the diff-first `--apply` path). |
| 94 | + |
| 95 | +## Testing |
| 96 | + |
| 97 | +Mirror the existing `migrate` command tests (`packages/cli/test/…`), against a real |
| 98 | +libSQL/sqlite database (the harness the migrate integration tests already use): |
| 99 | + |
| 100 | +- **Provision from empty:** commit two migration files (a `CREATE TABLE` + a view), |
| 101 | + run `apply-pending` against an empty DB → both applied, tables + view exist, the |
| 102 | + `_metaobjects_migrations` ledger has both rows, exit `0`. |
| 103 | +- **Idempotent re-run:** a second `apply-pending` applies `0`, reports "already up to |
| 104 | + date", exit `0`. |
| 105 | +- **`--dry-run`:** lists the pending migration(s), applies nothing (DB unchanged), exit |
| 106 | + `0`. |
| 107 | +- **Missing `--db`:** exit `2` with the "`--db <url> required`" error. |
| 108 | +- **`d1` rejected:** `apply-pending --dialect d1` exits `2` with the wrangler pointer. |
| 109 | +- **Apply failure surfaces:** a committed migration whose already-applied `up.sql` was |
| 110 | + edited (checksum tamper guard) makes `apply-pending` exit `1` with the guard's error. |
0 commit comments