| 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:
- 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.
- 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.
- 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)
users: defineTable({
name: v.string(),
bio: v.optional(v.string()),
});
posts: defineTable({ userId: v.id("users"), title: v.string() }).index("by_user", ["userId"]);
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.
import { defineMigration } from "@lunora/server";
export default defineMigration({
id: "backfill-display-name",
table: "users",
batchSize: 200,
up: (doc) => {
if (typeof doc.displayName === "string") {
return;
}
return { ...doc, displayName: doc.name ?? "Anonymous" };
},
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.
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
lunora migrate create backfill-display-name
lunora codegen
lunora migrate up backfill-display-name --dry-run
lunora migrate up backfill-display-name
lunora migrate status backfill-display-name
lunora migrate down backfill-display-name
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.
lunora codegen
lunora migrate generate --name=add_user_status
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
- Making a field required before backfilling. The drift gate / D1 rejects
the deploy because existing rows lack it. Widen first.
- 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.
- 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.
- Skipping the dry run.
lunora migrate up <id> --dry-run validates the
transform before it touches real rows.
- Deleting a field prematurely. Deprecate with
v.optional + a comment;
delete only once nothing references it.
- 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