Skip to content

Commit d6d4a25

Browse files
dmealingclaude
andcommitted
docs(#242): design — meta migrate apply-pending (replay committed migrations)
Thin CLI wrapper over the public applyPending: a first-class verb to provision a fresh DB from committed migration files, since the diff-first --apply can't bootstrap an empty DB. postgres/sqlite only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8d228fa commit d6d4a25

1 file changed

Lines changed: 110 additions & 0 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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

Comments
 (0)