@lunora/cli
Version:
The Lunora CLI: init, dev, deploy, codegen, migrate, seed, doctor, insights, logs, registry, and the rest of the project commands
248 lines (194 loc) • 10.8 kB
Markdown
---
name: lunora-migration-helper
description: Plans Lunora schema and data migrations with widen-migrate-narrow. Use for
breaking schema changes, backfills, table reshaping, online data migrations
(`defineMigration` + `lunora migrate up`), the `.global()` D1 / Hyperdrive
structural flows, and the
pre-deploy schema-drift gate.
---
# Lunora Migration Helper
Safely change a Lunora schema and migrate data when making breaking changes.
## When to Use
- Adding required fields to existing tables.
- Changing field types or structure.
- Splitting/merging tables, renaming/removing fields.
- Reshaping `.global()` tables (D1- or Hyperdrive-backed).
## When Not to Use
- Greenfield schema with no data at rest.
- Adding **optional** fields that need no backfill.
- Adding new tables or indexes with no correctness concern.
## Storage Layers — Know Which You Are Migrating
Lunora tables live in one of three backends, and they migrate differently:
- **ShardDO SQLite (default `root`, and `.shardBy(key)` tables).** State lives in
the per-app / per-shard Durable Object. Data is reshaped with **online data
migrations** — `defineMigration` declarations run by `lunora migrate up`,
resumable per shard.
- **`.global()` on D1 (the default global backend).** Replicated to D1 for
cross-region reads. Structural DDL gets versioned **SQL migrations** via
`lunora migrate generate`, applied by `@lunora/d1`'s runner at deploy time.
- **`.global({ backend: "hyperdrive" })` on Postgres/MySQL.** The same reactive
`.global()` contract served over Cloudflare Hyperdrive. Structural DDL works
differently here: tables **auto-provision on first use** — the runtime applies
the DDL through the dialect — so there is no `lunora migrate generate` step
and no versioned SQL file to commit. See `lunora-setup-hyperdrive-global`.
So: a breaking structural change to a **D1-backed** `.global()` table needs a
generated SQL migration; the Hyperdrive-backed equivalent provisions itself. A
data backfill (any layer) is always an online `defineMigration`. All three
follow the same **widen → migrate → narrow** discipline — check the table's
backend before assuming which structural path applies.
> **Moving an existing dataset between global backends.** To move a `.global()`
> dataset from D1 onto Hyperdrive, use
> `lunora migrate d1-to-hyperdrive --from-url <d1-worker> --to-url <hd-worker>`
> (`--tables` scopes it; `--out` keeps the intermediate NDJSON dump). This is a
> backend move, not a schema change — the widen → migrate → narrow discipline
> below still governs any reshaping you do on either side of it.
## Key Principle: Widen, Migrate, Narrow
The schema-drift gate (and D1 itself) will not let a breaking change deploy
without an accompanying migration. So every breaking change is staged:
1. **Widen** — make the schema accept both old and new shapes (add the new field
as `v.optional`, keep the old one). Update reads to handle both; start writing
the new shape for new rows. Deploy.
2. **Migrate** — backfill existing rows to the new shape (an online
`defineMigration` run with `lunora migrate up`; plus `lunora migrate generate`
for `.global()` structural DDL). Verify completeness with `lunora migrate
status`.
3. **Narrow** — make the field required / drop the old field, remove the
both-shapes read code. Deploy.
### Prefer new fields over changing types
When changing a field's shape, add a new field rather than mutating the existing
one — safer transition, easier rollback.
### Don't delete data prematurely
Prefer deprecating: mark the old field `v.optional` with a `// deprecated:` code
comment explaining why it existed. Delete only once you are sure nothing reads
it.
## Safe Changes (No Migration Needed)
```ts
// Adding an optional field — safe.
users: defineTable({
name: v.string(),
bio: v.optional(v.string()),
});
// Adding a new table — safe.
posts: defineTable({ userId: v.id("users"), title: v.string() }).index("by_user", ["userId"]);
// Adding an index — safe.
users: defineTable({ name: v.string(), email: v.string() }).index("by_email", ["email"]);
```
## Online Data Migrations (the backfill workhorse)
For backfilling/reshaping rows, declare a migration with `defineMigration` from
`@lunora/server`. It transforms one document at a time, runs **inside each
shard's** Durable Object in keyset batches, and is **resumable** — per-shard
progress is tracked in a reserved `__lunora_migrations` table, so an interrupted
run resumes where it stopped. Codegen discovers declarations and emits them into
the registry the DO and CLI look up by `id`.
```ts
// lunora/migrations/backfill-display-name.ts
import { defineMigration } from "@lunora/server";
export default defineMigration({
id: "backfill-display-name",
table: "users",
batchSize: 200, // optional; defaults to the runner's batch size
up: (doc) => {
if (typeof doc.displayName === "string") {
return; // already migrated — return undefined to skip (not counted as changed)
}
return { ...doc, displayName: doc.name ?? "Anonymous" };
},
// optional reverse transform applied by `lunora migrate down`
down: (doc) => {
const { displayName, ...rest } = doc as Record<string, unknown>;
return rest;
},
});
```
The transform must preserve row identity — the runner always keeps the original
`_id` / `_creationTime`, so do not change them.
### Reading another table
The transform's second argument is a **shard-scoped reader** (`ctx.db` with
`get` / `findFirst` / `findMany` / `count`), so the common backfill — read the
parent, copy a field down onto its children — is expressible. It may be `async`.
```ts
up: async (doc, ctx) => {
const thread = await ctx.db.get(String(doc.threadId), "threads");
return thread ? { ...doc, userId: thread.userId } : undefined;
},
```
It is a reader, not a writer: the runner accounts for exactly one rewrite per
row read, and a transform writing directly would make that count describe
something other than what happened. To touch a second table, run a second
migration over that table.
### A shard key cannot be backfilled by a migration
If the field you are backfilling **is** the table's `.shardBy()` key, this is the
wrong tool, and no amount of reader access fixes it:
- a row whose shard key is unset does not belong to any shard, so a shard-scoped
query will never enumerate it; and
- writing the key would have to **move the row to a different Durable Object**,
which a per-shard runner has no way to do.
Re-keying is an export → transform → import (`lunora export`, rewrite the NDJSON,
`lunora import` — ids are preserved, so foreign keys survive), not a migration.
So the rule elsewhere in this skill that a data backfill is always an online
`defineMigration` has this one exception.
### Run it
```bash
lunora migrate create backfill-display-name # scaffold the migration file
lunora codegen # discover + register it
lunora migrate up backfill-display-name --dry-run # preview, no rows rewritten
lunora migrate up backfill-display-name # run across shards (keyset batches)
lunora migrate status backfill-display-name # per-shard progress
lunora migrate down backfill-display-name # revert (if `down` defined)
```
Useful flags: `--batch-size <n>`, `--steps <n>` (cap batches this run), and
`--prod --url <worker> --yes` to target production (with `LUNORA_ADMIN_TOKEN`).
## `.global()` on D1 — Structural Migration Flow
This flow is **D1-specific**. Hyperdrive-backed globals auto-provision their DDL
at runtime and skip it entirely.
```bash
# 1. Edit lunora/schema.ts (widen: add the optional new field to the .global() table).
lunora codegen
# 2. Generate the SQL migration by diffing schema against the snapshot baseline.
lunora migrate generate --name=add_user_status
# Writes lunora/migrations/<timestamp>_add_user_status.sql and updates
# lunora/migrations/.snapshot.json. Review the SQL before committing.
# 3. Deploy — @lunora/d1's runner applies pending migrations.
lunora deploy
```
`lunora migrate generate` only considers `.global()` tables (root/sharded tables
live in ShardDO SQLite, not D1). Run it after each schema edit in the widen and
narrow steps; backfill data with an online migration between them.
## The Schema-Drift Gate
`lunora deploy` (and `verify` / `prepare`) run a **pre-deploy schema-drift gate**:
it compares the committed structural baseline (`lunora/.lunora-schema.json`)
against the snapshot codegen produced this run. Breaking drift **without an
accompanying data migration blocks the deploy**. The baseline is only re-blessed
_after_ the deploy succeeds, so a failed deploy never advances it past a change
that never shipped.
If the gate blocks you: that is the signal to stage the change (widen first) or
add the migration — not to bypass it.
## Common Pitfalls
1. **Making a field required before backfilling.** The drift gate / D1 rejects
the deploy because existing rows lack it. Widen first.
2. **Reshaping rows by hand instead of `defineMigration`.** A hand-rolled
`internalMutation` that `.collect()`s a large table hits transaction limits
and is not resumable. Use `defineMigration` — it batches and tracks per-shard
progress.
3. **Not writing the new shape during the migration window.** Rows created mid-
migration get missed, leaving unmigrated data after it "completes." Start
dual-writing in the widen step.
4. **Skipping the dry run.** `lunora migrate up <id> --dry-run` validates the
transform before it touches real rows.
5. **Deleting a field prematurely.** Deprecate with `v.optional` + a comment;
delete only once nothing references it.
6. **Migrating the wrong layer.** A D1-backed `.global()` structural change needs
`lunora migrate generate` (SQL); a Hyperdrive-backed one auto-provisions; a
data backfill needs a `defineMigration`. Check the table's `.global()` /
`.shardBy()` modifier — and, for `.global()`, its `backend` — first.
## Checklist
- [ ] Identified the change and which layer it touches (ShardDO vs `.global()`).
- [ ] Widened the schema to accept both shapes; `lunora codegen` clean.
- [ ] Updated reads to handle both shapes; started writing the new shape.
- [ ] Deployed the widened schema.
- [ ] Authored a `defineMigration`; previewed with `lunora migrate up --dry-run`.
- [ ] Ran `lunora migrate up`; `lunora migrate generate` + deploy for `.global()`
structural changes.
- [ ] Verified completion with `lunora migrate status`.
- [ ] Narrowed the schema (required / drop old field); removed both-shapes code.
- [ ] Deployed the final schema; schema-drift gate passed.