@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.
Files changed (72) hide show
  1. package/LICENSE.md +105 -0
  2. package/README.md +109 -9
  3. package/__assets__/package-og.svg +14 -0
  4. package/dist/bin.mjs +11 -0
  5. package/dist/index.d.mts +852 -0
  6. package/dist/index.d.ts +852 -0
  7. package/dist/index.mjs +19 -0
  8. package/dist/packem_chunks/handler.mjs +76 -0
  9. package/dist/packem_chunks/handler10.mjs +22 -0
  10. package/dist/packem_chunks/handler11.mjs +192 -0
  11. package/dist/packem_chunks/handler12.mjs +131 -0
  12. package/dist/packem_chunks/handler13.mjs +65 -0
  13. package/dist/packem_chunks/handler14.mjs +58 -0
  14. package/dist/packem_chunks/handler15.mjs +79 -0
  15. package/dist/packem_chunks/handler16.mjs +41 -0
  16. package/dist/packem_chunks/handler17.mjs +105 -0
  17. package/dist/packem_chunks/handler18.mjs +172 -0
  18. package/dist/packem_chunks/handler19.mjs +89 -0
  19. package/dist/packem_chunks/handler2.mjs +114 -0
  20. package/dist/packem_chunks/handler20.mjs +94 -0
  21. package/dist/packem_chunks/handler21.mjs +311 -0
  22. package/dist/packem_chunks/handler3.mjs +204 -0
  23. package/dist/packem_chunks/handler4.mjs +33 -0
  24. package/dist/packem_chunks/handler5.mjs +49 -0
  25. package/dist/packem_chunks/handler6.mjs +91 -0
  26. package/dist/packem_chunks/handler7.mjs +42 -0
  27. package/dist/packem_chunks/handler8.mjs +174 -0
  28. package/dist/packem_chunks/handler9.mjs +16 -0
  29. package/dist/packem_chunks/planDevCommand.mjs +543 -0
  30. package/dist/packem_chunks/runCodegenCommand.mjs +52 -0
  31. package/dist/packem_chunks/runDeployCommand.mjs +504 -0
  32. package/dist/packem_chunks/runInitCommand.mjs +652 -0
  33. package/dist/packem_chunks/runMigrateGenerateCommand.mjs +397 -0
  34. package/dist/packem_chunks/runResetCommand.mjs +41 -0
  35. package/dist/packem_chunks/runRpcCommand.mjs +68 -0
  36. package/dist/packem_shared/COMMANDS-1V_KEx35.mjs +905 -0
  37. package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-Ck-2bU08.mjs +244 -0
  38. package/dist/packem_shared/admin-url-4UzT-CI4.mjs +19 -0
  39. package/dist/packem_shared/api-spec-CtA6ilu4.mjs +13 -0
  40. package/dist/packem_shared/buildRegistryIndex-BcYe607_.mjs +38 -0
  41. package/dist/packem_shared/command-BDXcJCCJ.mjs +14 -0
  42. package/dist/packem_shared/createLogger-CHPNjFw2.mjs +73 -0
  43. package/dist/packem_shared/defaultSpawner-DxI3mebw.mjs +43 -0
  44. package/dist/packem_shared/diffSnapshots-RR2ZE8Ya.mjs +161 -0
  45. package/dist/packem_shared/docker-hMQ97KSQ.mjs +21 -0
  46. package/dist/packem_shared/features-ocSSpZtS.mjs +24 -0
  47. package/dist/packem_shared/insertSchemaExtension-BuzF6-t2.mjs +59 -0
  48. package/dist/packem_shared/open-url-Dfq6fAyT.mjs +41 -0
  49. package/dist/packem_shared/output-format-7gyGR3h8.mjs +17 -0
  50. package/dist/packem_shared/parseArgs-YXFuKdEk.mjs +56 -0
  51. package/dist/packem_shared/parseManifest--vZf2FY1.mjs +94 -0
  52. package/dist/packem_shared/resolve-target-qbsJ_5sF.mjs +16 -0
  53. package/dist/packem_shared/runAddCommand-BZGkRnBs.mjs +693 -0
  54. package/dist/packem_shared/schema-drift-gate-BtBt0as0.mjs +79 -0
  55. package/dist/packem_shared/schemaIrToSnapshot-aBTo7TM5.mjs +43 -0
  56. package/dist/packem_shared/wrangler-name-cy4yhm9j.mjs +12 -0
  57. package/package.json +61 -18
  58. package/skills/README.md +29 -0
  59. package/skills/lunora/SKILL.md +83 -0
  60. package/skills/lunora-create-package/SKILL.md +129 -0
  61. package/skills/lunora-deploy/SKILL.md +150 -0
  62. package/skills/lunora-functions/SKILL.md +182 -0
  63. package/skills/lunora-migration-helper/SKILL.md +194 -0
  64. package/skills/lunora-performance-audit/SKILL.md +143 -0
  65. package/skills/lunora-quickstart/SKILL.md +240 -0
  66. package/skills/lunora-realtime/SKILL.md +177 -0
  67. package/skills/lunora-setup-auth/SKILL.md +170 -0
  68. package/skills/lunora-setup-hyperdrive/SKILL.md +154 -0
  69. package/skills/lunora-setup-hyperdrive-global/SKILL.md +171 -0
  70. package/skills/lunora-setup-mail/SKILL.md +151 -0
  71. package/skills/lunora-setup-scheduler/SKILL.md +157 -0
  72. 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.