UNPKG

@lunora/cli

Version:

The Lunora CLI: init, dev, deploy, codegen, migrate, seed, doctor, insights, logs, registry, and the rest of the project commands

226 lines (172 loc) • 11 kB
--- name: lunora-migration-helper description: Plans and runs Lunora schema and data migrations with the widen → migrate → narrow pattern. Covers online data migrations (`defineMigration` in `lunora/migrations.ts`, `lunora migrate create | up | down | status`), what the pre-deploy schema-drift gate treats as breaking and how a migration covers it, `.global()` table DDL on D1 (`lunora migrate generate`) and Hyperdrive, moving a dataset with `lunora migrate d1-to-hyperdrive` (and back with `hyperdrive-to-d1`), and re-keying a `.shardBy()` table. Use when the user wants to make a field required, rename or drop a field or table, change a field's type, add a unique index to a populated table, backfill data, switch a table between root / `.shardBy()` / `.global()`, or when `lunora deploy` reports "schema drift gate blocked". --- # Lunora Migration Helper Change a Lunora schema that already has data in it without breaking the deploy or losing rows. Not needed for a greenfield schema with no data, a new optional field, a new table, or a new non-unique index. Those are additive and deploy as-is. ## Which storage layer are you migrating? Check the table's modifier in `lunora/schema.ts` first; the tools differ. | Table | Where rows live | Structural DDL | Data backfill | | ------------------------------------ | ------------------------------------ | -------------------------------------------------------------- | -------------------------------------- | | default (root) or `.shardBy(key)` | SQLite in the root / per-key ShardDO | Applied by the runtime from the schema | `defineMigration` + `lunora migrate` | | `.global()` | D1 (`DB` binding) | Additive DDL auto-provisioned; destructive SQL applied by hand | SQL `UPDATE` via `wrangler d1 execute` | | `.global({ backend: "hyperdrive" })` | Postgres/MySQL via Hyperdrive | Additive DDL auto-provisioned; no `migrate generate` support | SQL `UPDATE` against the database | ## The pattern: widen, migrate, narrow The schema-drift gate blocks a breaking change; one that needs a backfill is let through once a new migration on that table covers it (the rest are listed below). Stage every such change across deploys: 1. **Widen.** Make the schema accept both shapes: add the new field as `v.optional(...)`, keep the old one. Make reads handle both, and start writing the new shape on every insert/patch so rows created during the migration aren't missed. Deploy. 2. **Migrate.** Backfill existing rows with a `defineMigration`, run it with `lunora migrate up`, and confirm with `lunora migrate status`. On a `.global()` table, backfill with SQL instead (see below). 3. **Narrow.** Make the field required / drop the old one, remove the both-shapes code. Deploy. Prefer adding a new field to changing an existing field's type: it rolls back cleanly. Keep a deprecated field as `v.optional` with a `// deprecated:` comment until nothing reads it. ## What the drift gate considers breaking The gate (run by `lunora deploy`, `prepare`, `build` and, read-only, `verify`) diffs the schema against the committed `lunora/.lunora-schema.json`. - **Safe:** new table, new optional field, new non-unique index or relation, a required field made optional. - **Breaking, fixable by a migration:** dropped field or table, changed field type, optional → required, new required field on an existing table, a new unique index or `.unique()` column (existing duplicates would make the shard fail to open). - **Breaking, not fixable by a migration:** changing a table's shard mode (root ↔ `.shardBy()` ↔ `.global()`, or the `.global()` backend) moves rows to other storage; use export → import. A dropped index or relation needs call sites changed, then `--update-schema-baseline`. A migration covers a change only if it is new since the baseline and its `table` is a string literal naming the affected table. The baseline advances only after a successful deploy. ## Online data migrations A migration transforms one document at a time, runs inside each shard's Durable Object in keyset batches, and is resumable: per-shard progress is kept in a reserved `__lunora_migrations` table. Scaffold it rather than writing the file from scratch: ```bash lunora migrate create backfill-display-name --table users ``` This appends to `lunora/migrations.ts`. Codegen only discovers `export const x = defineMigration({...})` with a literal `id`. An `export default` is silently skipped, and `lunora migrate up` then can't find it. ```ts // lunora/migrations.ts import { defineMigration } from "@lunora/server"; // or "lunorash/server" export const backfillDisplayName = defineMigration({ id: "backfill-display-name", table: "users", batchSize: 200, // optional up: (doc) => { if (typeof doc.displayName === "string") { return undefined; // skip: not rewritten, not counted as changed } return { ...doc, displayName: doc.name ?? "Anonymous" }; }, // optional; applied by `lunora migrate down` down: (doc) => { const { displayName, ...rest } = doc; return rest; }, }); ``` The runner keeps each row's original `_id` and `_creationTime`. ### Reading another table The transform's second argument is a shard-scoped, read-only `ctx.db` (`get`, `findFirst`, `findMany`, `count`), and the transform may be `async`. That covers the common case of copying a field down from a parent: ```ts up: async (doc, ctx) => { const thread = await ctx.db.get("threads", String(doc.threadId)); return thread ? { ...doc, userId: thread.userId } : undefined; }, ``` To change rows in a second table, write a second migration over that table. ### A shard key can't be backfilled If the field is the table's `.shardBy()` key, a migration can't help: a row with no shard key belongs to no shard (so the runner never sees it), and setting the key would mean moving the row to another Durable Object. Re-key with `lunora export` → rewrite the NDJSON → `lunora import`. Ids are preserved, so foreign keys survive. The same route applies to any shard-mode change. ### Run it ```bash lunora migrate up backfill-display-name --dry-run # preview, rewrites nothing lunora migrate up backfill-display-name # against the dev worker (localhost:8787) lunora migrate status backfill-display-name # per-shard progress lunora migrate down backfill-display-name # only if `down` is defined ``` Options: `--batch-size <n>`, `--steps <n>` (cap batches per run), `--format json`. Production runs rewrite live data, so they require an explicit confirmation: ```bash LUNORA_ADMIN_TOKEN=… lunora migrate up backfill-display-name --prod --url https://app.example.com --yes ``` `--url` can be omitted when the checkout is linked to production. Alternatively, `lunora deploy --migrate --migrate-yes --migrate-url <url>` runs every declared migration right after the deploy (`up` is idempotent, so finished ones no-op). ## `.global()` tables The runtime provisions `.global()` tables itself on first use, on both D1 and Hyperdrive: `CREATE TABLE IF NOT EXISTS`, additive `ADD COLUMN`, and declared indexes. Additive changes therefore need no DDL step. For **D1-backed** global tables, `lunora migrate generate` diffs the schema against `lunora/migrations/.snapshot.json` and writes a reviewable `lunora/migrations/<timestamp>_<name>.sql`: ```bash lunora migrate generate --name add_user_status ``` No command applies that file, `lunora deploy` included. Its value is the statements the runtime never issues (`DROP TABLE`, `DROP INDEX`) plus a comment block of changes it couldn't generate (renames, backfilling `UPDATE`s), which you write yourself. Apply it deliberately, after review: ```bash wrangler d1 execute DB --remote --file=lunora/migrations/<file>.sql ``` The file is multi-statement, so it is not a `@lunora/d1` `Migration`. Commit `.snapshot.json` with it, or the next diff won't see a dropped table. Hyperdrive-backed tables are left out of `migrate generate` (it emits SQLite DDL). Write destructive DDL for them by hand against your database. See `lunora-setup-hyperdrive-global`. `defineMigration` does not reach `.global()` rows on either backend: it runs inside each shard's Durable Object against that shard's SQLite, never D1 or Hyperdrive. Backfill a global table with a SQL `UPDATE` (D1: `wrangler d1 execute`; Hyperdrive: your Postgres/MySQL client). The drift gate still keys on a migration naming the table, so once the SQL backfill is done, deploy the narrowing change with `--allow-schema-drift` rather than a no-op migration. ### Moving a dataset from D1 to Hyperdrive ```bash lunora migrate d1-to-hyperdrive --from-url https://old-worker --to-url https://new-worker ``` `--tables a,b` limits it to some tables, and `--out <path>` keeps the intermediate NDJSON dump. `lunora migrate hyperdrive-to-d1` takes the same flags and moves the data back, for a rollback. Both are insert-only by default: a row whose `_id` already exists in the target is reported as a conflict and left unchanged, so a rollback restores missing rows, not later edits. Add `--replace` (with `--tables` naming the `.global()` tables; it is refused without one) to overwrite those rows with the source's values, or keep the dump with `--out` and reconcile by hand. This moves the data to another backend; any reshaping still follows widen → migrate → narrow. ## Common pitfalls 1. **Making a field required before backfilling.** The gate blocks it unless a new migration on that table is part of the same deploy. Widen first. 2. **Backfilling with a hand-written mutation.** A mutation that `.collect()`s a large table runs into transaction limits and can't resume. `defineMigration` batches per shard and resumes. 3. **Not dual-writing during the window.** Rows inserted mid-migration keep the old shape. Write the new shape from the widen step on. 4. **A non-literal `table`.** `table: TABLE_NAME` covers nothing in the gate. 5. **Bypassing the gate.** `--allow-schema-drift` / `--update-schema-baseline` ship the shape without touching data; use them only when existing rows are already compatible. ## Checklist - [ ] Identified the layer (root / `.shardBy()` / `.global()` D1 / Hyperdrive). - [ ] Widened the schema; reads handle both shapes; writes use the new shape. Deployed. - [ ] `lunora migrate create <id> --table <t>`; transform filled in; `--dry-run` looks right. - [ ] `lunora migrate up <id>` (prod: `--prod --yes`); `lunora migrate status <id>` complete on every shard. - [ ] D1 destructive DDL: `migrate generate` output reviewed and applied with `wrangler d1 execute`. - [ ] Narrowed the schema and removed both-shapes code; drift gate green on deploy.