@lunora/cli 0.0.0 → 1.0.0-alpha.2
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.
- package/LICENSE.md +105 -0
- package/README.md +109 -9
- package/__assets__/package-og.svg +14 -0
- package/dist/bin.mjs +11 -0
- package/dist/index.d.mts +852 -0
- package/dist/index.d.ts +852 -0
- package/dist/index.mjs +19 -0
- package/dist/packem_chunks/handler.mjs +76 -0
- package/dist/packem_chunks/handler10.mjs +22 -0
- package/dist/packem_chunks/handler11.mjs +192 -0
- package/dist/packem_chunks/handler12.mjs +131 -0
- package/dist/packem_chunks/handler13.mjs +65 -0
- package/dist/packem_chunks/handler14.mjs +58 -0
- package/dist/packem_chunks/handler15.mjs +79 -0
- package/dist/packem_chunks/handler16.mjs +41 -0
- package/dist/packem_chunks/handler17.mjs +105 -0
- package/dist/packem_chunks/handler18.mjs +172 -0
- package/dist/packem_chunks/handler19.mjs +89 -0
- package/dist/packem_chunks/handler2.mjs +114 -0
- package/dist/packem_chunks/handler20.mjs +94 -0
- package/dist/packem_chunks/handler21.mjs +311 -0
- package/dist/packem_chunks/handler3.mjs +204 -0
- package/dist/packem_chunks/handler4.mjs +33 -0
- package/dist/packem_chunks/handler5.mjs +49 -0
- package/dist/packem_chunks/handler6.mjs +91 -0
- package/dist/packem_chunks/handler7.mjs +42 -0
- package/dist/packem_chunks/handler8.mjs +174 -0
- package/dist/packem_chunks/handler9.mjs +16 -0
- package/dist/packem_chunks/planDevCommand.mjs +543 -0
- package/dist/packem_chunks/runCodegenCommand.mjs +52 -0
- package/dist/packem_chunks/runDeployCommand.mjs +504 -0
- package/dist/packem_chunks/runInitCommand.mjs +652 -0
- package/dist/packem_chunks/runMigrateGenerateCommand.mjs +397 -0
- package/dist/packem_chunks/runResetCommand.mjs +41 -0
- package/dist/packem_chunks/runRpcCommand.mjs +68 -0
- package/dist/packem_shared/COMMANDS-1V_KEx35.mjs +905 -0
- package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-Ck-2bU08.mjs +244 -0
- package/dist/packem_shared/admin-url-4UzT-CI4.mjs +19 -0
- package/dist/packem_shared/api-spec-CtA6ilu4.mjs +13 -0
- package/dist/packem_shared/buildRegistryIndex-BcYe607_.mjs +38 -0
- package/dist/packem_shared/command-BDXcJCCJ.mjs +14 -0
- package/dist/packem_shared/createLogger-CHPNjFw2.mjs +73 -0
- package/dist/packem_shared/defaultSpawner-DxI3mebw.mjs +43 -0
- package/dist/packem_shared/diffSnapshots-RR2ZE8Ya.mjs +161 -0
- package/dist/packem_shared/docker-hMQ97KSQ.mjs +21 -0
- package/dist/packem_shared/features-ocSSpZtS.mjs +24 -0
- package/dist/packem_shared/insertSchemaExtension-BuzF6-t2.mjs +59 -0
- package/dist/packem_shared/open-url-Dfq6fAyT.mjs +41 -0
- package/dist/packem_shared/output-format-7gyGR3h8.mjs +17 -0
- package/dist/packem_shared/parseArgs-YXFuKdEk.mjs +56 -0
- package/dist/packem_shared/parseManifest--vZf2FY1.mjs +94 -0
- package/dist/packem_shared/resolve-target-qbsJ_5sF.mjs +16 -0
- package/dist/packem_shared/runAddCommand-BZGkRnBs.mjs +693 -0
- package/dist/packem_shared/schema-drift-gate-BtBt0as0.mjs +79 -0
- package/dist/packem_shared/schemaIrToSnapshot-aBTo7TM5.mjs +43 -0
- package/dist/packem_shared/wrangler-name-cy4yhm9j.mjs +12 -0
- package/package.json +61 -18
- package/skills/README.md +29 -0
- package/skills/lunora/SKILL.md +83 -0
- package/skills/lunora-create-package/SKILL.md +129 -0
- package/skills/lunora-deploy/SKILL.md +150 -0
- package/skills/lunora-functions/SKILL.md +182 -0
- package/skills/lunora-migration-helper/SKILL.md +194 -0
- package/skills/lunora-performance-audit/SKILL.md +143 -0
- package/skills/lunora-quickstart/SKILL.md +240 -0
- package/skills/lunora-realtime/SKILL.md +177 -0
- package/skills/lunora-setup-auth/SKILL.md +170 -0
- package/skills/lunora-setup-hyperdrive/SKILL.md +154 -0
- package/skills/lunora-setup-hyperdrive-global/SKILL.md +171 -0
- package/skills/lunora-setup-mail/SKILL.md +151 -0
- package/skills/lunora-setup-scheduler/SKILL.md +157 -0
- package/skills/lunora-setup-storage/SKILL.md +154 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lunora-functions
|
|
3
|
+
description: Authoring rules for Lunora schema and functions. Use when writing or reviewing
|
|
4
|
+
`lunora/` code — `defineSchema`/`defineTable`, `v.*` validators, query vs
|
|
5
|
+
mutation vs action (and `internal*`), indexes & `withIndex`, the `ctx.db` API,
|
|
6
|
+
pagination, scheduling, and `httpAction`.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Lunora Functions
|
|
10
|
+
|
|
11
|
+
The core authoring rules for Lunora backend code. Read this before writing or
|
|
12
|
+
changing anything under `lunora/`. After every edit, run `lunora codegen` — it
|
|
13
|
+
regenerates `lunora/_generated/` and typechecks your schema + functions.
|
|
14
|
+
|
|
15
|
+
## When to Use
|
|
16
|
+
|
|
17
|
+
- Writing or editing schema, queries, mutations, or actions.
|
|
18
|
+
- Reviewing `lunora/` code for correctness and idiom.
|
|
19
|
+
- Deciding query vs mutation vs action, or public vs internal.
|
|
20
|
+
|
|
21
|
+
## When Not to Use
|
|
22
|
+
|
|
23
|
+
- Setting up a new project (`lunora-quickstart`) or auth (`lunora-setup-auth`).
|
|
24
|
+
- Diagnosing a slow query or write conflict (`lunora-performance-audit`).
|
|
25
|
+
- Changing an existing schema with data at rest (`lunora-migration-helper`).
|
|
26
|
+
|
|
27
|
+
## Schema: `defineSchema` + `defineTable`
|
|
28
|
+
|
|
29
|
+
`lunora/schema.ts` exports `defineSchema` as the default export. Every column is
|
|
30
|
+
a `v.*` validator. Declare an index for every access pattern you query by.
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { defineSchema, defineTable, v } from "@lunora/server";
|
|
34
|
+
|
|
35
|
+
export default defineSchema({
|
|
36
|
+
messages: defineTable({
|
|
37
|
+
channelId: v.id("channels"),
|
|
38
|
+
authorId: v.id("users"),
|
|
39
|
+
body: v.string(),
|
|
40
|
+
createdAt: v.number(),
|
|
41
|
+
}).index("by_channel", ["channelId", "createdAt"]),
|
|
42
|
+
|
|
43
|
+
channels: defineTable({
|
|
44
|
+
name: v.string(),
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- Lunora injects `_id` and `_creationTime` on every row — do **not** declare
|
|
50
|
+
them.
|
|
51
|
+
- `.index("name", ["a", "b"])` — columns are ordered; put equality columns
|
|
52
|
+
first, then the range/sort column.
|
|
53
|
+
- `.shardBy("ownerId")` partitions the table across Durable Objects by key;
|
|
54
|
+
`.global()` replicates it to D1 for cross-region reads. Default (neither) is a
|
|
55
|
+
single root-scoped ShardDO. They are not combined on one table — for choosing
|
|
56
|
+
between them, see the side-by-side comparison in the `lunora-performance-audit`
|
|
57
|
+
skill.
|
|
58
|
+
|
|
59
|
+
### Validators (`v.*`)
|
|
60
|
+
|
|
61
|
+
`string`, `number`, `boolean`, `id("table")`, `null`, `any`, `bigint`, `bytes`,
|
|
62
|
+
`literal(value)`, `array(item)`, `object({...})`, `record(key, value)`,
|
|
63
|
+
`union(a, b, …)`, `optional(inner)`, plus the convenience types `date`,
|
|
64
|
+
`timestamp`, and `storage` (an R2 object key). Use `v.optional(...)` for nullable
|
|
65
|
+
fields — required is the default.
|
|
66
|
+
|
|
67
|
+
## Functions: query / mutation / action
|
|
68
|
+
|
|
69
|
+
Each function declares its inputs with `.input(...)` (a `v.*` map) and ends with a
|
|
70
|
+
terminal `.query` / `.mutation` / `.action` handler. Export them as named
|
|
71
|
+
consts from `lunora/*.ts`; codegen surfaces them as `api.<file>.<name>`.
|
|
72
|
+
|
|
73
|
+
| Kind | Reads `ctx.db` | Writes `ctx.db` | Side effects / `fetch` | Reactive |
|
|
74
|
+
| ---------- | --------------------------------- | --------------- | ---------------------- | -------- |
|
|
75
|
+
| `query` | yes | no | no | yes |
|
|
76
|
+
| `mutation` | yes | yes | no | — |
|
|
77
|
+
| `action` | no (use `runQuery`/`runMutation`) | no | yes | — |
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import type { Id } from "@lunora/server";
|
|
81
|
+
import { action, LunoraError, mutation, query, v } from "@lunora/server";
|
|
82
|
+
|
|
83
|
+
// `api` / `internal` come from codegen:
|
|
84
|
+
// import { api, internal } from "./_generated/api";
|
|
85
|
+
|
|
86
|
+
export const listByChannel = query.input({ channelId: v.id("channels") }).query(async ({ ctx, args: { channelId } }) =>
|
|
87
|
+
ctx.db
|
|
88
|
+
.query("messages")
|
|
89
|
+
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
|
|
90
|
+
.collect(),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
export const send = mutation
|
|
94
|
+
.input({ channelId: v.id("channels"), body: v.string() })
|
|
95
|
+
.mutation(async ({ ctx, args: { channelId, body } }): Promise<Id<"messages">> => {
|
|
96
|
+
if (!ctx.auth.userId) {
|
|
97
|
+
throw new LunoraError("UNAUTHORIZED", "not signed in");
|
|
98
|
+
}
|
|
99
|
+
return ctx.db.insert("messages", {
|
|
100
|
+
channelId,
|
|
101
|
+
authorId: ctx.auth.userId as Id<"users">,
|
|
102
|
+
body,
|
|
103
|
+
createdAt: Date.now(),
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export const notifySlack = action.input({ messageId: v.id("messages") }).action(async ({ ctx, args: { messageId } }) => {
|
|
108
|
+
const message = await ctx.runQuery(api.messages.getById, { messageId });
|
|
109
|
+
await fetch(SLACK_WEBHOOK, { method: "POST", body: JSON.stringify(message) });
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
- **Pick the right kind.** Reactive read → `query`. Transactional write →
|
|
114
|
+
`mutation`. External I/O (`fetch`, third-party SDKs, calling other functions)
|
|
115
|
+
→ `action`. An action has no `ctx.db`; it reaches data via `ctx.runQuery` /
|
|
116
|
+
`ctx.runMutation`.
|
|
117
|
+
- **`internal*` variants** (`internalQuery`, `internalMutation`,
|
|
118
|
+
`internalAction`) are not exposed to clients — use them for server-only logic
|
|
119
|
+
called from actions, crons, or other functions.
|
|
120
|
+
- **Throw `LunoraError`** (`import { LunoraError } from "@lunora/server"`) with a
|
|
121
|
+
code + message for expected failures; it serializes cleanly to the client.
|
|
122
|
+
|
|
123
|
+
## The `ctx.db` API
|
|
124
|
+
|
|
125
|
+
Reads:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
await ctx.db.get(id); // one row by id (or null)
|
|
129
|
+
ctx.db.query("t").withIndex("by_x", (q) => q.eq("x", v)); // indexed query
|
|
130
|
+
.collect(); // all matching rows
|
|
131
|
+
.first(); // first row or null
|
|
132
|
+
.unique(); // exactly one (throws if 0 or >1)
|
|
133
|
+
.take(n); // first n rows
|
|
134
|
+
.order("asc" | "desc") // sort by the index range
|
|
135
|
+
.paginate(opts); // cursor page (pair with usePaginatedQuery)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Writes (mutations only):
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
await ctx.db.insert("t", { ...fields }); // returns the new Id
|
|
142
|
+
await ctx.db.patch(id, { field: next }); // shallow-merge update
|
|
143
|
+
await ctx.db.replace(id, { ...allFields }); // full overwrite
|
|
144
|
+
await ctx.db.delete(id);
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Prefer `withIndex` over `.filter`.** A `.filter(...)` with no covering index
|
|
148
|
+
scans the whole table — `@lunora/advisor` flags it as `filter-without-index`.
|
|
149
|
+
Declare the index and constrain with `.withIndex`.
|
|
150
|
+
|
|
151
|
+
## Other `ctx` capabilities
|
|
152
|
+
|
|
153
|
+
`ctx.auth` (the resolved session: `ctx.auth.userId`), `ctx.scheduler`
|
|
154
|
+
(`runAfter` / `runAt` for deferred work), `ctx.storage` (R2), `ctx.vectors`
|
|
155
|
+
(Vectorize), and — when their packages are wired — `ctx.ai` and `ctx.containers`.
|
|
156
|
+
|
|
157
|
+
## HTTP endpoints
|
|
158
|
+
|
|
159
|
+
For webhooks or non-RPC HTTP, use `httpRouter` / `httpRoute` + `httpAction`:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import { httpAction, httpRouter } from "@lunora/server";
|
|
163
|
+
|
|
164
|
+
export default httpRouter({
|
|
165
|
+
"/webhooks/stripe": httpAction(async (ctx, request) => {
|
|
166
|
+
const event = await request.json();
|
|
167
|
+
await ctx.runMutation(internal.billing.record, { event });
|
|
168
|
+
return new Response("ok");
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Checklist
|
|
174
|
+
|
|
175
|
+
- [ ] Schema columns are `v.*` validators; `_id`/`_creationTime` not declared.
|
|
176
|
+
- [ ] An index exists for every access pattern; queries use `withIndex`, not
|
|
177
|
+
`.filter`.
|
|
178
|
+
- [ ] Right function kind: `query` (reactive read) / `mutation` (write) /
|
|
179
|
+
`action` (side effects via `runQuery`/`runMutation`).
|
|
180
|
+
- [ ] Server-only logic uses `internal*`; expected failures throw `LunoraError`.
|
|
181
|
+
- [ ] `ctx.db` writes only inside mutations; ids typed with `Id<"table">`.
|
|
182
|
+
- [ ] Ran `lunora codegen`; typecheck is clean.
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
---
|
|
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 SQL flow, and the
|
|
6
|
+
pre-deploy schema-drift gate.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Lunora Migration Helper
|
|
10
|
+
|
|
11
|
+
Safely change a Lunora schema and migrate data when making breaking changes.
|
|
12
|
+
|
|
13
|
+
## When to Use
|
|
14
|
+
|
|
15
|
+
- Adding required fields to existing tables.
|
|
16
|
+
- Changing field types or structure.
|
|
17
|
+
- Splitting/merging tables, renaming/removing fields.
|
|
18
|
+
- Reshaping `.global()` (D1-backed) tables.
|
|
19
|
+
|
|
20
|
+
## When Not to Use
|
|
21
|
+
|
|
22
|
+
- Greenfield schema with no data at rest.
|
|
23
|
+
- Adding **optional** fields that need no backfill.
|
|
24
|
+
- Adding new tables or indexes with no correctness concern.
|
|
25
|
+
|
|
26
|
+
## Two Storage Layers — Know Which You Are Migrating
|
|
27
|
+
|
|
28
|
+
Lunora tables live in one of two backends, and they migrate differently:
|
|
29
|
+
|
|
30
|
+
- **ShardDO SQLite (default `root`, and `.shardBy(key)` tables).** State lives in
|
|
31
|
+
the per-app / per-shard Durable Object. Data is reshaped with **online data
|
|
32
|
+
migrations** — `defineMigration` declarations run by `lunora migrate up`,
|
|
33
|
+
resumable per shard.
|
|
34
|
+
- **`.global()` tables (D1).** Replicated to D1 for cross-region reads. Their
|
|
35
|
+
structural DDL gets versioned **SQL migrations** via `lunora migrate generate`,
|
|
36
|
+
applied by `@lunora/d1`'s runner at deploy time.
|
|
37
|
+
|
|
38
|
+
A breaking change to a `.global()` table needs a generated SQL migration; a data
|
|
39
|
+
backfill (either layer) is an online `defineMigration`. Both follow the same
|
|
40
|
+
**widen → migrate → narrow** discipline.
|
|
41
|
+
|
|
42
|
+
## Key Principle: Widen, Migrate, Narrow
|
|
43
|
+
|
|
44
|
+
The schema-drift gate (and D1 itself) will not let a breaking change deploy
|
|
45
|
+
without an accompanying migration. So every breaking change is staged:
|
|
46
|
+
|
|
47
|
+
1. **Widen** — make the schema accept both old and new shapes (add the new field
|
|
48
|
+
as `v.optional`, keep the old one). Update reads to handle both; start writing
|
|
49
|
+
the new shape for new rows. Deploy.
|
|
50
|
+
2. **Migrate** — backfill existing rows to the new shape (an online
|
|
51
|
+
`defineMigration` run with `lunora migrate up`; plus `lunora migrate generate`
|
|
52
|
+
for `.global()` structural DDL). Verify completeness with `lunora migrate
|
|
53
|
+
status`.
|
|
54
|
+
3. **Narrow** — make the field required / drop the old field, remove the
|
|
55
|
+
both-shapes read code. Deploy.
|
|
56
|
+
|
|
57
|
+
### Prefer new fields over changing types
|
|
58
|
+
|
|
59
|
+
When changing a field's shape, add a new field rather than mutating the existing
|
|
60
|
+
one — safer transition, easier rollback.
|
|
61
|
+
|
|
62
|
+
### Don't delete data prematurely
|
|
63
|
+
|
|
64
|
+
Prefer deprecating: mark the old field `v.optional` with a `// deprecated:` code
|
|
65
|
+
comment explaining why it existed. Delete only once you are sure nothing reads
|
|
66
|
+
it.
|
|
67
|
+
|
|
68
|
+
## Safe Changes (No Migration Needed)
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// Adding an optional field — safe.
|
|
72
|
+
users: defineTable({
|
|
73
|
+
name: v.string(),
|
|
74
|
+
bio: v.optional(v.string()),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Adding a new table — safe.
|
|
78
|
+
posts: defineTable({ userId: v.id("users"), title: v.string() }).index("by_user", ["userId"]);
|
|
79
|
+
|
|
80
|
+
// Adding an index — safe.
|
|
81
|
+
users: defineTable({ name: v.string(), email: v.string() }).index("by_email", ["email"]);
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Online Data Migrations (the backfill workhorse)
|
|
85
|
+
|
|
86
|
+
For backfilling/reshaping rows, declare a migration with `defineMigration` from
|
|
87
|
+
`@lunora/server`. It transforms one document at a time, runs **inside each
|
|
88
|
+
shard's** Durable Object in keyset batches, and is **resumable** — per-shard
|
|
89
|
+
progress is tracked in a reserved `__lunora_migrations` table, so an interrupted
|
|
90
|
+
run resumes where it stopped. Codegen discovers declarations and emits them into
|
|
91
|
+
the registry the DO and CLI look up by `id`.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
// lunora/migrations/backfill-display-name.ts
|
|
95
|
+
import { defineMigration } from "@lunora/server";
|
|
96
|
+
|
|
97
|
+
export default defineMigration({
|
|
98
|
+
id: "backfill-display-name",
|
|
99
|
+
table: "users",
|
|
100
|
+
batchSize: 200, // optional; defaults to the runner's batch size
|
|
101
|
+
up: (doc) => {
|
|
102
|
+
if (typeof doc.displayName === "string") {
|
|
103
|
+
return; // already migrated — return undefined to skip (not counted as changed)
|
|
104
|
+
}
|
|
105
|
+
return { ...doc, displayName: doc.name ?? "Anonymous" };
|
|
106
|
+
},
|
|
107
|
+
// optional reverse transform applied by `lunora migrate down`
|
|
108
|
+
down: (doc) => {
|
|
109
|
+
const { displayName, ...rest } = doc as Record<string, unknown>;
|
|
110
|
+
return rest;
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The transform must preserve row identity — the runner always keeps the original
|
|
116
|
+
`_id` / `_creationTime`, so do not change them.
|
|
117
|
+
|
|
118
|
+
### Run it
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
lunora migrate create backfill-display-name # scaffold the migration file
|
|
122
|
+
lunora codegen # discover + register it
|
|
123
|
+
lunora migrate up backfill-display-name --dry-run # preview, no rows rewritten
|
|
124
|
+
lunora migrate up backfill-display-name # run across shards (keyset batches)
|
|
125
|
+
lunora migrate status backfill-display-name # per-shard progress
|
|
126
|
+
lunora migrate down backfill-display-name # revert (if `down` defined)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Useful flags: `--batch-size <n>`, `--steps <n>` (cap batches this run), and
|
|
130
|
+
`--prod --url <worker> --yes` to target production (with `LUNORA_ADMIN_TOKEN`).
|
|
131
|
+
|
|
132
|
+
## `.global()` (D1) Structural Migration Flow
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
# 1. Edit lunora/schema.ts (widen: add the optional new field to the .global() table).
|
|
136
|
+
lunora codegen
|
|
137
|
+
|
|
138
|
+
# 2. Generate the SQL migration by diffing schema against the snapshot baseline.
|
|
139
|
+
lunora migrate generate --name=add_user_status
|
|
140
|
+
|
|
141
|
+
# Writes lunora/migrations/<timestamp>_add_user_status.sql and updates
|
|
142
|
+
# lunora/migrations/.snapshot.json. Review the SQL before committing.
|
|
143
|
+
|
|
144
|
+
# 3. Deploy — @lunora/d1's runner applies pending migrations.
|
|
145
|
+
lunora deploy
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`lunora migrate generate` only considers `.global()` tables (root/sharded tables
|
|
149
|
+
are not D1-backed). Run it after each schema edit in the widen and narrow steps;
|
|
150
|
+
backfill data with an online migration between them.
|
|
151
|
+
|
|
152
|
+
## The Schema-Drift Gate
|
|
153
|
+
|
|
154
|
+
`lunora deploy` (and `verify` / `prepare`) run a **pre-deploy schema-drift gate**:
|
|
155
|
+
it compares the committed structural baseline (`lunora/.lunora-schema.json`)
|
|
156
|
+
against the snapshot codegen produced this run. Breaking drift **without an
|
|
157
|
+
accompanying data migration blocks the deploy**. The baseline is only re-blessed
|
|
158
|
+
_after_ the deploy succeeds, so a failed deploy never advances it past a change
|
|
159
|
+
that never shipped.
|
|
160
|
+
|
|
161
|
+
If the gate blocks you: that is the signal to stage the change (widen first) or
|
|
162
|
+
add the migration — not to bypass it.
|
|
163
|
+
|
|
164
|
+
## Common Pitfalls
|
|
165
|
+
|
|
166
|
+
1. **Making a field required before backfilling.** The drift gate / D1 rejects
|
|
167
|
+
the deploy because existing rows lack it. Widen first.
|
|
168
|
+
2. **Reshaping rows by hand instead of `defineMigration`.** A hand-rolled
|
|
169
|
+
`internalMutation` that `.collect()`s a large table hits transaction limits
|
|
170
|
+
and is not resumable. Use `defineMigration` — it batches and tracks per-shard
|
|
171
|
+
progress.
|
|
172
|
+
3. **Not writing the new shape during the migration window.** Rows created mid-
|
|
173
|
+
migration get missed, leaving unmigrated data after it "completes." Start
|
|
174
|
+
dual-writing in the widen step.
|
|
175
|
+
4. **Skipping the dry run.** `lunora migrate up <id> --dry-run` validates the
|
|
176
|
+
transform before it touches real rows.
|
|
177
|
+
5. **Deleting a field prematurely.** Deprecate with `v.optional` + a comment;
|
|
178
|
+
delete only once nothing references it.
|
|
179
|
+
6. **Migrating the wrong layer.** A `.global()` structural change needs `lunora
|
|
180
|
+
migrate generate` (SQL); a data backfill needs a `defineMigration`. Check the
|
|
181
|
+
table's `.global()` / `.shardBy()` modifier first.
|
|
182
|
+
|
|
183
|
+
## Checklist
|
|
184
|
+
|
|
185
|
+
- [ ] Identified the change and which layer it touches (ShardDO vs `.global()`).
|
|
186
|
+
- [ ] Widened the schema to accept both shapes; `lunora codegen` clean.
|
|
187
|
+
- [ ] Updated reads to handle both shapes; started writing the new shape.
|
|
188
|
+
- [ ] Deployed the widened schema.
|
|
189
|
+
- [ ] Authored a `defineMigration`; previewed with `lunora migrate up --dry-run`.
|
|
190
|
+
- [ ] Ran `lunora migrate up`; `lunora migrate generate` + deploy for `.global()`
|
|
191
|
+
structural changes.
|
|
192
|
+
- [ ] Verified completion with `lunora migrate status`.
|
|
193
|
+
- [ ] Narrowed the schema (required / drop old field); removed both-shapes code.
|
|
194
|
+
- [ ] Deployed the final schema; schema-drift gate passed.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lunora-performance-audit
|
|
3
|
+
description: Diagnoses and fixes Lunora performance problems — full-table scans, missing
|
|
4
|
+
indexes, OCC write conflicts, oversized subscriptions, and sharding/`.global()`
|
|
5
|
+
scaling. Use when queries are slow, mutations conflict, or `@lunora/advisor`
|
|
6
|
+
flags a table.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Lunora Performance Audit
|
|
10
|
+
|
|
11
|
+
Systematically diagnose Lunora performance issues, route to the right fix, and
|
|
12
|
+
apply it across sibling functions consistently.
|
|
13
|
+
|
|
14
|
+
## When to Use
|
|
15
|
+
|
|
16
|
+
- Queries feel slow or read far more rows than they return.
|
|
17
|
+
- Mutations retry or conflict under load (OCC).
|
|
18
|
+
- Subscriptions fan out updates too broadly or re-run too often.
|
|
19
|
+
- The Lunora Studio **Advisors** tab or `@lunora/advisor` flags a table.
|
|
20
|
+
|
|
21
|
+
## When Not to Use
|
|
22
|
+
|
|
23
|
+
- Scale is small, traffic is modest, and there is no measured problem — prefer
|
|
24
|
+
simpler code. Do not introduce sharding, digests, or splits on speculation.
|
|
25
|
+
|
|
26
|
+
## Core Workflow
|
|
27
|
+
|
|
28
|
+
1. **Scope** one concrete user flow with a clear entry and exit.
|
|
29
|
+
2. **Trace** every `ctx.db` read and write in that flow.
|
|
30
|
+
3. **Route** to the matching problem class below.
|
|
31
|
+
4. **Fix siblings** consistently — every function touching the same table should
|
|
32
|
+
read it the same indexed way.
|
|
33
|
+
5. **Verify** behavior is unchanged and the advisor finding clears.
|
|
34
|
+
|
|
35
|
+
## Signal Gathering
|
|
36
|
+
|
|
37
|
+
Start with the static advisors — they need no traffic:
|
|
38
|
+
|
|
39
|
+
- **Lunora Studio → Advisors tab** surfaces `@lunora/advisor` findings live in
|
|
40
|
+
dev.
|
|
41
|
+
- `@lunora/advisor` runs static lints over `defineSchema` + discovered query
|
|
42
|
+
reads / insert writes. Relevant performance/schema rules:
|
|
43
|
+
- `filter-without-index` — a query filters a table with no covering index.
|
|
44
|
+
- `unindexed-foreign-key` — a relation/FK column has no index.
|
|
45
|
+
- `duplicate-index` / `empty-index` — wasted or malformed indexes.
|
|
46
|
+
- `index-references-unknown-field`, `relation-references-unknown-field`,
|
|
47
|
+
`relation-references-unknown-table` — broken index/relation definitions.
|
|
48
|
+
- `table-without-insert` — a table is read but never written (often a
|
|
49
|
+
schema/typo signal).
|
|
50
|
+
|
|
51
|
+
## Problem Class: Read Amplification (the common one)
|
|
52
|
+
|
|
53
|
+
**Symptom:** a query reads the whole table to return a few rows;
|
|
54
|
+
`filter-without-index` fires.
|
|
55
|
+
|
|
56
|
+
**Fix:** read through an index, not `.filter()`. Declare the index on the table
|
|
57
|
+
and use `.withIndex()` with an equality/range on the leading columns.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// Bad — scans every row, then filters in memory.
|
|
61
|
+
const mine = (await ctx.db.query("documents").collect()).filter((d) => d.orgId === orgId);
|
|
62
|
+
|
|
63
|
+
// Good — declare the index…
|
|
64
|
+
documents: defineTable({ orgId: v.string(), createdAt: v.number() /* … */ }).index("by_org_created", ["orgId", "createdAt"]);
|
|
65
|
+
|
|
66
|
+
// …and read through it.
|
|
67
|
+
const mine = await ctx.db
|
|
68
|
+
.query("documents")
|
|
69
|
+
.withIndex("by_org_created", (q) => q.eq("orgId", orgId))
|
|
70
|
+
.collect();
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Index columns are ordered: put equality columns first, then the range/sort
|
|
74
|
+
column. Fix every sibling query on the table the same way.
|
|
75
|
+
|
|
76
|
+
## Problem Class: Write Conflicts (OCC)
|
|
77
|
+
|
|
78
|
+
**Symptom:** mutations on hot rows retry or fail under concurrency. ShardDO uses
|
|
79
|
+
optimistic concurrency control — concurrent writes to the same DO that touch
|
|
80
|
+
overlapping state conflict and retry.
|
|
81
|
+
|
|
82
|
+
**Fixes, in order of preference:**
|
|
83
|
+
|
|
84
|
+
1. **Narrow the write.** Patch only the fields that changed; avoid read-modify-
|
|
85
|
+
write over rows another mutation also touches.
|
|
86
|
+
2. **Partition with `.shardBy(key)`.** Move per-user / per-tenant / per-room
|
|
87
|
+
state into its own DO so writes for different keys never contend. This is the
|
|
88
|
+
primary horizontal-scale lever — most write contention is a single-DO
|
|
89
|
+
hotspot.
|
|
90
|
+
3. **Avoid unbounded counters/aggregates in the hot path.** Accumulate in a
|
|
91
|
+
sharded/append shape and fold lazily rather than serializing every writer
|
|
92
|
+
through one row.
|
|
93
|
+
|
|
94
|
+
## Problem Class: Subscription Cost
|
|
95
|
+
|
|
96
|
+
**Symptom:** a `useQuery` re-runs and re-pushes to many clients on unrelated
|
|
97
|
+
writes.
|
|
98
|
+
|
|
99
|
+
**Fixes:**
|
|
100
|
+
|
|
101
|
+
- **Scope query args tightly** so a subscription only depends on the rows it
|
|
102
|
+
renders — a query keyed by `orgId` should not re-run for another org's write.
|
|
103
|
+
- **Read through indexes** (above) so the reactive dependency is the narrow
|
|
104
|
+
index range, not the whole table.
|
|
105
|
+
- ShardDO subscriptions are **hibernated WebSockets** — idle connections cost
|
|
106
|
+
nothing; the lever is _how many rows each live query depends on_, not raw
|
|
107
|
+
connection count.
|
|
108
|
+
|
|
109
|
+
## Problem Class: Cross-Region Reads
|
|
110
|
+
|
|
111
|
+
**Symptom:** read-heavy, rarely-written data is slow for far-away users.
|
|
112
|
+
|
|
113
|
+
**Fix:** chain `.global()` on the table to replicate it to D1 for low-latency
|
|
114
|
+
cross-region reads (with read-your-writes via the Sessions API). Reserve it for
|
|
115
|
+
read-mostly tables — `.global()` adds the D1 migration flow (see the
|
|
116
|
+
`lunora-migration-helper` skill) and write-path cost.
|
|
117
|
+
|
|
118
|
+
### `.shardBy(key)` vs `.global()` — choose one per table
|
|
119
|
+
|
|
120
|
+
- `.shardBy(key)`: partitions a table across Durable Objects by key — scales
|
|
121
|
+
_writes_ (e.g. messages per room). Reads are per-shard.
|
|
122
|
+
- `.global()`: replicates a table to D1 — scales _cross-region reads_ with
|
|
123
|
+
read-your-writes (e.g. a mostly-read catalog).
|
|
124
|
+
- They are not combined on the same table; the default (neither) is a single
|
|
125
|
+
root-scoped ShardDO.
|
|
126
|
+
|
|
127
|
+
## Guardrails
|
|
128
|
+
|
|
129
|
+
- Prefer simpler code when scale is small or the signal is weak.
|
|
130
|
+
- Do not recommend structural changes (digest tables, document splitting,
|
|
131
|
+
sharding) without a measured signal or a known hot path.
|
|
132
|
+
- When you change how one function reads/writes a table, change its siblings to
|
|
133
|
+
match — half-migrated access patterns are their own bug.
|
|
134
|
+
|
|
135
|
+
## Checklist
|
|
136
|
+
|
|
137
|
+
- [ ] Scoped one concrete flow; traced every `ctx.db` read/write.
|
|
138
|
+
- [ ] Checked the Studio Advisors tab / `@lunora/advisor` findings.
|
|
139
|
+
- [ ] Read amplification: replaced `.filter()` with an indexed `.withIndex()`.
|
|
140
|
+
- [ ] Write conflicts: narrowed writes and/or partitioned with `.shardBy(key)`.
|
|
141
|
+
- [ ] Subscription cost: scoped query args so live queries depend on few rows.
|
|
142
|
+
- [ ] Cross-region: applied `.global()` only to read-mostly tables.
|
|
143
|
+
- [ ] Fixed sibling functions consistently; verified behavior unchanged.
|