@lunora/cli 1.0.0-alpha.362 → 1.0.0-alpha.363

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,140 +1,113 @@
1
1
  ---
2
2
  name: lunora-migration-helper
3
- description: Plans Lunora schema and data migrations with widen-migrate-narrow. Use for
4
- breaking schema changes, backfills, table reshaping, online data migrations
5
- (`defineMigration` + `lunora migrate up`), the `.global()` D1 / Hyperdrive
6
- structural flows, and the
7
- pre-deploy schema-drift gate.
3
+ 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 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".
8
4
  ---
9
5
 
10
6
  # Lunora Migration Helper
11
7
 
12
- Safely change a Lunora schema and migrate data when making breaking changes.
8
+ Change a Lunora schema that already has data in it without breaking the deploy
9
+ or losing rows.
13
10
 
14
- ## When to Use
11
+ Not needed for a greenfield schema with no data, a new optional field, a new
12
+ table, or a new non-unique index. Those are additive and deploy as-is.
15
13
 
16
- - Adding required fields to existing tables.
17
- - Changing field types or structure.
18
- - Splitting/merging tables, renaming/removing fields.
19
- - Reshaping `.global()` tables (D1- or Hyperdrive-backed).
14
+ ## Which storage layer are you migrating?
20
15
 
21
- ## When Not to Use
16
+ Check the table's modifier in `lunora/schema.ts` first; the tools differ.
22
17
 
23
- - Greenfield schema with no data at rest.
24
- - Adding **optional** fields that need no backfill.
25
- - Adding new tables or indexes with no correctness concern.
18
+ | Table | Where rows live | Structural DDL | Data backfill |
19
+ | ------------------------------------ | ------------------------------------ | -------------------------------------------------------------- | -------------------------------------- |
20
+ | default (root) or `.shardBy(key)` | SQLite in the root / per-key ShardDO | Applied by the runtime from the schema | `defineMigration` + `lunora migrate` |
21
+ | `.global()` | D1 (`DB` binding) | Additive DDL auto-provisioned; destructive SQL applied by hand | SQL `UPDATE` via `wrangler d1 execute` |
22
+ | `.global({ backend: "hyperdrive" })` | Postgres/MySQL via Hyperdrive | Additive DDL auto-provisioned; no `migrate generate` support | SQL `UPDATE` against the database |
26
23
 
27
- ## Storage Layers — Know Which You Are Migrating
24
+ ## The pattern: widen, migrate, narrow
28
25
 
29
- Lunora tables live in one of three backends, and they migrate differently:
26
+ The schema-drift gate blocks a breaking change; one that needs a backfill is
27
+ let through once a new migration on that table covers it (the rest are listed
28
+ below). Stage every such change across deploys:
30
29
 
31
- - **ShardDO SQLite (default `root`, and `.shardBy(key)` tables).** State lives in
32
- the per-app / per-shard Durable Object. Data is reshaped with **online data
33
- migrations** — `defineMigration` declarations run by `lunora migrate up`,
34
- resumable per shard.
35
- - **`.global()` on D1 (the default global backend).** Replicated to D1 for
36
- cross-region reads. Structural DDL gets versioned **SQL migrations** via
37
- `lunora migrate generate`, applied by `@lunora/d1`'s runner at deploy time.
38
- - **`.global({ backend: "hyperdrive" })` on Postgres/MySQL.** The same reactive
39
- `.global()` contract served over Cloudflare Hyperdrive. Structural DDL works
40
- differently here: tables **auto-provision on first use** — the runtime applies
41
- the DDL through the dialect — so there is no `lunora migrate generate` step
42
- and no versioned SQL file to commit. See `lunora-setup-hyperdrive-global`.
30
+ 1. **Widen.** Make the schema accept both shapes: add the new field as
31
+ `v.optional(...)`, keep the old one. Make reads handle both, and start
32
+ writing the new shape on every insert/patch so rows created during the
33
+ migration aren't missed. Deploy.
34
+ 2. **Migrate.** Backfill existing rows with a `defineMigration`, run it with
35
+ `lunora migrate up`, and confirm with `lunora migrate status`. On a
36
+ `.global()` table, backfill with SQL instead (see below).
37
+ 3. **Narrow.** Make the field required / drop the old one, remove the
38
+ both-shapes code. Deploy.
43
39
 
44
- So: a breaking structural change to a **D1-backed** `.global()` table needs a
45
- generated SQL migration; the Hyperdrive-backed equivalent provisions itself. A
46
- data backfill (any layer) is always an online `defineMigration`. All three
47
- follow the same **widen → migrate → narrow** discipline — check the table's
48
- backend before assuming which structural path applies.
40
+ Prefer adding a new field to changing an existing field's type: it rolls back
41
+ cleanly. Keep a deprecated field as `v.optional` with a `// deprecated:` comment
42
+ until nothing reads it.
49
43
 
50
- > **Moving an existing dataset between global backends.** To move a `.global()`
51
- > dataset from D1 onto Hyperdrive, use
52
- > `lunora migrate d1-to-hyperdrive --from-url <d1-worker> --to-url <hd-worker>`
53
- > (`--tables` scopes it; `--out` keeps the intermediate NDJSON dump). This is a
54
- > backend move, not a schema change — the widen → migrate → narrow discipline
55
- > below still governs any reshaping you do on either side of it.
44
+ ## What the drift gate considers breaking
56
45
 
57
- ## Key Principle: Widen, Migrate, Narrow
46
+ The gate (run by `lunora deploy`, `prepare`, `build` and, read-only, `verify`)
47
+ diffs the schema against the committed `lunora/.lunora-schema.json`.
58
48
 
59
- The schema-drift gate (and D1 itself) will not let a breaking change deploy
60
- without an accompanying migration. So every breaking change is staged:
49
+ - **Safe:** new table, new optional field, new non-unique index or relation, a
50
+ required field made optional.
51
+ - **Breaking, fixable by a migration:** dropped field or table, changed field
52
+ type, optional → required, new required field on an existing table, a new
53
+ unique index or `.unique()` column (existing duplicates would make the shard
54
+ fail to open).
55
+ - **Breaking, not fixable by a migration:** changing a table's shard mode
56
+ (root ↔ `.shardBy()` ↔ `.global()`, or the `.global()` backend) moves rows to
57
+ other storage; use export → import. A dropped index or relation needs call
58
+ sites changed, then `--update-schema-baseline`.
61
59
 
62
- 1. **Widen** — make the schema accept both old and new shapes (add the new field
63
- as `v.optional`, keep the old one). Update reads to handle both; start writing
64
- the new shape for new rows. Deploy.
65
- 2. **Migrate** — backfill existing rows to the new shape (an online
66
- `defineMigration` run with `lunora migrate up`; plus `lunora migrate generate`
67
- for `.global()` structural DDL). Verify completeness with `lunora migrate
68
- status`.
69
- 3. **Narrow** — make the field required / drop the old field, remove the
70
- both-shapes read code. Deploy.
60
+ A migration covers a change only if it is new since the baseline and its
61
+ `table` is a string literal naming the affected table. The baseline advances
62
+ only after a successful deploy.
71
63
 
72
- ### Prefer new fields over changing types
64
+ ## Online data migrations
73
65
 
74
- When changing a field's shape, add a new field rather than mutating the existing
75
- one — safer transition, easier rollback.
66
+ A migration transforms one document at a time, runs inside each shard's Durable
67
+ Object in keyset batches, and is resumable: per-shard progress is kept in a
68
+ reserved `__lunora_migrations` table.
76
69
 
77
- ### Don't delete data prematurely
70
+ Scaffold it rather than writing the file from scratch:
78
71
 
79
- Prefer deprecating: mark the old field `v.optional` with a `// deprecated:` code
80
- comment explaining why it existed. Delete only once you are sure nothing reads
81
- it.
82
-
83
- ## Safe Changes (No Migration Needed)
84
-
85
- ```ts
86
- // Adding an optional field — safe.
87
- users: defineTable({
88
- name: v.string(),
89
- bio: v.optional(v.string()),
90
- });
91
-
92
- // Adding a new table — safe.
93
- posts: defineTable({ userId: v.id("users"), title: v.string() }).index("by_user", ["userId"]);
94
-
95
- // Adding an index — safe.
96
- users: defineTable({ name: v.string(), email: v.string() }).index("by_email", ["email"]);
72
+ ```bash
73
+ lunora migrate create backfill-display-name --table users
97
74
  ```
98
75
 
99
- ## Online Data Migrations (the backfill workhorse)
100
-
101
- For backfilling/reshaping rows, declare a migration with `defineMigration` from
102
- `@lunora/server`. It transforms one document at a time, runs **inside each
103
- shard's** Durable Object in keyset batches, and is **resumable** — per-shard
104
- progress is tracked in a reserved `__lunora_migrations` table, so an interrupted
105
- run resumes where it stopped. Codegen discovers declarations and emits them into
106
- the registry the DO and CLI look up by `id`.
76
+ This appends to `lunora/migrations.ts`. Codegen only discovers
77
+ `export const x = defineMigration({...})` with a literal `id`. An
78
+ `export default` is silently skipped, and `lunora migrate up` then can't find it.
107
79
 
108
80
  ```ts
109
- // lunora/migrations/backfill-display-name.ts
110
- import { defineMigration } from "@lunora/server";
81
+ // lunora/migrations.ts
82
+ import { defineMigration } from "@lunora/server"; // or "lunorash/server"
111
83
 
112
- export default defineMigration({
84
+ export const backfillDisplayName = defineMigration({
113
85
  id: "backfill-display-name",
114
86
  table: "users",
115
- batchSize: 200, // optional; defaults to the runner's batch size
87
+ batchSize: 200, // optional
116
88
  up: (doc) => {
117
89
  if (typeof doc.displayName === "string") {
118
- return; // already migrated — return undefined to skip (not counted as changed)
90
+ return undefined; // skip: not rewritten, not counted as changed
119
91
  }
92
+
120
93
  return { ...doc, displayName: doc.name ?? "Anonymous" };
121
94
  },
122
- // optional reverse transform applied by `lunora migrate down`
95
+ // optional; applied by `lunora migrate down`
123
96
  down: (doc) => {
124
- const { displayName, ...rest } = doc as Record<string, unknown>;
97
+ const { displayName, ...rest } = doc;
98
+
125
99
  return rest;
126
100
  },
127
101
  });
128
102
  ```
129
103
 
130
- The transform must preserve row identity — the runner always keeps the original
131
- `_id` / `_creationTime`, so do not change them.
104
+ The runner keeps each row's original `_id` and `_creationTime`.
132
105
 
133
106
  ### Reading another table
134
107
 
135
- The transform's second argument is a **shard-scoped reader** (`ctx.db` with
136
- `get` / `findFirst` / `findMany` / `count`), so the common backfill — read the
137
- parent, copy a field down onto its children — is expressible. It may be `async`.
108
+ The transform's second argument is a shard-scoped, read-only `ctx.db`
109
+ (`get`, `findFirst`, `findMany`, `count`), and the transform may be `async`.
110
+ That covers the common case of copying a field down from a parent:
138
111
 
139
112
  ```ts
140
113
  up: async (doc, ctx) => {
@@ -144,104 +117,103 @@ up: async (doc, ctx) => {
144
117
  },
145
118
  ```
146
119
 
147
- It is a reader, not a writer: the runner accounts for exactly one rewrite per
148
- row read, and a transform writing directly would make that count describe
149
- something other than what happened. To touch a second table, run a second
150
- migration over that table.
120
+ To change rows in a second table, write a second migration over that table.
151
121
 
152
- ### A shard key cannot be backfilled by a migration
122
+ ### A shard key can't be backfilled
153
123
 
154
- If the field you are backfilling **is** the table's `.shardBy()` key, this is the
155
- wrong tool, and no amount of reader access fixes it:
124
+ If the field is the table's `.shardBy()` key, a migration can't help: a row with
125
+ no shard key belongs to no shard (so the runner never sees it), and setting the
126
+ key would mean moving the row to another Durable Object. Re-key with
127
+ `lunora export` → rewrite the NDJSON → `lunora import`. Ids are preserved, so
128
+ foreign keys survive. The same route applies to any shard-mode change.
156
129
 
157
- - a row whose shard key is unset does not belong to any shard, so a shard-scoped
158
- query will never enumerate it; and
159
- - writing the key would have to **move the row to a different Durable Object**,
160
- which a per-shard runner has no way to do.
130
+ ### Run it
131
+
132
+ ```bash
133
+ lunora migrate up backfill-display-name --dry-run # preview, rewrites nothing
134
+ lunora migrate up backfill-display-name # against the dev worker (localhost:8787)
135
+ lunora migrate status backfill-display-name # per-shard progress
136
+ lunora migrate down backfill-display-name # only if `down` is defined
137
+ ```
161
138
 
162
- Re-keying is an export → transform → import (`lunora export`, rewrite the NDJSON,
163
- `lunora import` — ids are preserved, so foreign keys survive), not a migration.
164
- So the rule elsewhere in this skill that a data backfill is always an online
165
- `defineMigration` has this one exception.
139
+ Options: `--batch-size <n>`, `--steps <n>` (cap batches per run), `--format json`.
166
140
 
167
- ### Run it
141
+ Production runs rewrite live data, so they require an explicit confirmation:
168
142
 
169
143
  ```bash
170
- lunora migrate create backfill-display-name # scaffold the migration file
171
- lunora codegen # discover + register it
172
- lunora migrate up backfill-display-name --dry-run # preview, no rows rewritten
173
- lunora migrate up backfill-display-name # run across shards (keyset batches)
174
- lunora migrate status backfill-display-name # per-shard progress
175
- lunora migrate down backfill-display-name # revert (if `down` defined)
144
+ LUNORA_ADMIN_TOKEN=… lunora migrate up backfill-display-name --prod --url https://app.example.com --yes
176
145
  ```
177
146
 
178
- Useful flags: `--batch-size <n>`, `--steps <n>` (cap batches this run), and
179
- `--prod --url <worker> --yes` to target production (with `LUNORA_ADMIN_TOKEN`).
147
+ `--url` can be omitted when the checkout is linked to production. Alternatively,
148
+ `lunora deploy --migrate --migrate-yes --migrate-url <url>` runs every declared
149
+ migration right after the deploy (`up` is idempotent, so finished ones no-op).
180
150
 
181
- ## `.global()` on D1 — Structural Migration Flow
151
+ ## `.global()` tables
182
152
 
183
- This flow is **D1-specific**. Hyperdrive-backed globals auto-provision their DDL
184
- at runtime and skip it entirely.
153
+ The runtime provisions `.global()` tables itself on first use, on both D1 and
154
+ Hyperdrive: `CREATE TABLE IF NOT EXISTS`, additive `ADD COLUMN`, and declared
155
+ indexes. Additive changes therefore need no DDL step.
156
+
157
+ For **D1-backed** global tables, `lunora migrate generate` diffs the schema
158
+ against `lunora/migrations/.snapshot.json` and writes a reviewable
159
+ `lunora/migrations/<timestamp>_<name>.sql`:
185
160
 
186
161
  ```bash
187
- # 1. Edit lunora/schema.ts (widen: add the optional new field to the .global() table).
188
- lunora codegen
162
+ lunora migrate generate --name add_user_status
163
+ ```
189
164
 
190
- # 2. Generate the SQL migration by diffing schema against the snapshot baseline.
191
- lunora migrate generate --name=add_user_status
165
+ No command applies that file, `lunora deploy` included. Its value is the
166
+ statements the runtime never issues (`DROP TABLE`, `DROP INDEX`) plus a comment
167
+ block of changes it couldn't generate (renames, backfilling `UPDATE`s), which you
168
+ write yourself. Apply it deliberately, after review:
169
+
170
+ ```bash
171
+ wrangler d1 execute DB --remote --file=lunora/migrations/<file>.sql
172
+ ```
192
173
 
193
- # Writes lunora/migrations/<timestamp>_add_user_status.sql and updates
194
- # lunora/migrations/.snapshot.json. Review the SQL before committing.
174
+ The file is multi-statement, so it is not a `@lunora/d1` `Migration`. Commit
175
+ `.snapshot.json` with it, or the next diff won't see a dropped table.
195
176
 
196
- # 3. Deploy — @lunora/d1's runner applies pending migrations.
197
- lunora deploy
177
+ Hyperdrive-backed tables are left out of `migrate generate` (it emits SQLite
178
+ DDL). Write destructive DDL for them by hand against your database. See
179
+ `lunora-setup-hyperdrive-global`.
180
+
181
+ `defineMigration` does not reach `.global()` rows on either backend: it runs
182
+ inside each shard's Durable Object against that shard's SQLite, never D1 or
183
+ Hyperdrive. Backfill a global table with a SQL `UPDATE` (D1: `wrangler d1
184
+ execute`; Hyperdrive: your Postgres/MySQL client). The drift gate still keys on
185
+ a migration naming the table, so once the SQL backfill is done, deploy the
186
+ narrowing change with `--allow-schema-drift` rather than a no-op migration.
187
+
188
+ ### Moving a dataset from D1 to Hyperdrive
189
+
190
+ ```bash
191
+ lunora migrate d1-to-hyperdrive --from-url https://old-worker --to-url https://new-worker
198
192
  ```
199
193
 
200
- `lunora migrate generate` only considers `.global()` tables (root/sharded tables
201
- live in ShardDO SQLite, not D1). Run it after each schema edit in the widen and
202
- narrow steps; backfill data with an online migration between them.
203
-
204
- ## The Schema-Drift Gate
205
-
206
- `lunora deploy` (and `verify` / `prepare`) run a **pre-deploy schema-drift gate**:
207
- it compares the committed structural baseline (`lunora/.lunora-schema.json`)
208
- against the snapshot codegen produced this run. Breaking drift **without an
209
- accompanying data migration blocks the deploy**. The baseline is only re-blessed
210
- _after_ the deploy succeeds, so a failed deploy never advances it past a change
211
- that never shipped.
212
-
213
- If the gate blocks you: that is the signal to stage the change (widen first) or
214
- add the migration — not to bypass it.
215
-
216
- ## Common Pitfalls
217
-
218
- 1. **Making a field required before backfilling.** The drift gate / D1 rejects
219
- the deploy because existing rows lack it. Widen first.
220
- 2. **Reshaping rows by hand instead of `defineMigration`.** A hand-rolled
221
- `internalMutation` that `.collect()`s a large table hits transaction limits
222
- and is not resumable. Use `defineMigration` — it batches and tracks per-shard
223
- progress.
224
- 3. **Not writing the new shape during the migration window.** Rows created mid-
225
- migration get missed, leaving unmigrated data after it "completes." Start
226
- dual-writing in the widen step.
227
- 4. **Skipping the dry run.** `lunora migrate up <id> --dry-run` validates the
228
- transform before it touches real rows.
229
- 5. **Deleting a field prematurely.** Deprecate with `v.optional` + a comment;
230
- delete only once nothing references it.
231
- 6. **Migrating the wrong layer.** A D1-backed `.global()` structural change needs
232
- `lunora migrate generate` (SQL); a Hyperdrive-backed one auto-provisions; a
233
- data backfill needs a `defineMigration`. Check the table's `.global()` /
234
- `.shardBy()` modifier — and, for `.global()`, its `backend` — first.
194
+ `--tables a,b` limits it to some tables, and `--out <path>` keeps the
195
+ intermediate NDJSON dump. This moves the data to another backend; any
196
+ reshaping still follows widen → migrate → narrow.
197
+
198
+ ## Common pitfalls
199
+
200
+ 1. **Making a field required before backfilling.** The gate blocks it unless a
201
+ new migration on that table is part of the same deploy. Widen first.
202
+ 2. **Backfilling with a hand-written mutation.** A mutation that `.collect()`s
203
+ a large table runs into transaction limits and can't resume.
204
+ `defineMigration` batches per shard and resumes.
205
+ 3. **Not dual-writing during the window.** Rows inserted mid-migration keep the
206
+ old shape. Write the new shape from the widen step on.
207
+ 4. **A non-literal `table`.** `table: TABLE_NAME` covers nothing in the gate.
208
+ 5. **Bypassing the gate.** `--allow-schema-drift` / `--update-schema-baseline`
209
+ ship the shape without touching data; use them only when existing rows are
210
+ already compatible.
235
211
 
236
212
  ## Checklist
237
213
 
238
- - [ ] Identified the change and which layer it touches (ShardDO vs `.global()`).
239
- - [ ] Widened the schema to accept both shapes; `lunora codegen` clean.
240
- - [ ] Updated reads to handle both shapes; started writing the new shape.
241
- - [ ] Deployed the widened schema.
242
- - [ ] Authored a `defineMigration`; previewed with `lunora migrate up <id> --dry-run`.
243
- - [ ] Ran `lunora migrate up <id>`; `lunora migrate generate` + deploy for
244
- `.global()` structural changes.
245
- - [ ] Verified completion with `lunora migrate status <id>`.
246
- - [ ] Narrowed the schema (required / drop old field); removed both-shapes code.
247
- - [ ] Deployed the final schema; schema-drift gate passed.
214
+ - [ ] Identified the layer (root / `.shardBy()` / `.global()` D1 / Hyperdrive).
215
+ - [ ] Widened the schema; reads handle both shapes; writes use the new shape. Deployed.
216
+ - [ ] `lunora migrate create <id> --table <t>`; transform filled in; `--dry-run` looks right.
217
+ - [ ] `lunora migrate up <id>` (prod: `--prod --yes`); `lunora migrate status <id>` complete on every shard.
218
+ - [ ] D1 destructive DDL: `migrate generate` output reviewed and applied with `wrangler d1 execute`.
219
+ - [ ] Narrowed the schema and removed both-shapes code; drift gate green on deploy.