@lunora/cli 1.0.0-alpha.42 → 1.0.0-alpha.420
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 +33 -0
- package/README.md +3 -1
- package/dist/bin.mjs +2 -10
- package/dist/index.d.mts +1069 -374
- package/dist/index.d.ts +1069 -374
- package/dist/index.mjs +1 -19
- package/dist/packem_chunks/dispatch.mjs +1 -0
- package/dist/packem_chunks/handler.mjs +23 -158
- package/dist/packem_chunks/handler10.mjs +1 -16
- package/dist/packem_chunks/handler11.mjs +1 -22
- package/dist/packem_chunks/handler12.mjs +1 -192
- package/dist/packem_chunks/handler13.mjs +1 -131
- package/dist/packem_chunks/handler14.mjs +3 -65
- package/dist/packem_chunks/handler15.mjs +1 -58
- package/dist/packem_chunks/handler16.mjs +1 -80
- package/dist/packem_chunks/handler17.mjs +1 -43
- package/dist/packem_chunks/handler18.mjs +7 -105
- package/dist/packem_chunks/handler19.mjs +2 -170
- package/dist/packem_chunks/handler2.mjs +1 -114
- package/dist/packem_chunks/handler20.mjs +1 -94
- package/dist/packem_chunks/handler21.mjs +3 -94
- package/dist/packem_chunks/handler22.mjs +3 -0
- package/dist/packem_chunks/handler23.mjs +1 -0
- package/dist/packem_chunks/handler24.mjs +2 -0
- package/dist/packem_chunks/handler25.mjs +99 -0
- package/dist/packem_chunks/handler26.mjs +2 -0
- package/dist/packem_chunks/handler27.mjs +9 -0
- package/dist/packem_chunks/handler28.mjs +1 -0
- package/dist/packem_chunks/handler3.mjs +1 -204
- package/dist/packem_chunks/handler4.mjs +1 -33
- package/dist/packem_chunks/handler5.mjs +3 -49
- package/dist/packem_chunks/handler6.mjs +1 -91
- package/dist/packem_chunks/handler7.mjs +1 -42
- package/dist/packem_chunks/handler8.mjs +1 -174
- package/dist/packem_chunks/handler9.mjs +2 -315
- package/dist/packem_chunks/runCodegenCommand.mjs +4 -52
- package/dist/packem_chunks/runDevCommand.mjs +2 -0
- package/dist/packem_chunks/runInitCommand.mjs +165 -1432
- package/dist/packem_chunks/runResetCommand.mjs +1 -41
- package/dist/packem_chunks/runRpcCommand.mjs +1 -68
- package/dist/packem_shared/COMMANDS-BCSDC-pv.mjs +1 -0
- package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-CyIsI3S1.mjs +1 -0
- package/dist/packem_shared/EXIT_CODE-D_XCixv8.mjs +1 -0
- package/dist/packem_shared/admin-token-VdUnvnKW.mjs +1 -0
- package/dist/packem_shared/admin-url-DJB8P6Jh.mjs +1 -0
- package/dist/packem_shared/api-spec-lYyq4vL6.mjs +1 -0
- package/dist/packem_shared/binding-manifest-file-CQ1eQYy1.mjs +2 -0
- package/dist/packem_shared/buildRegistryIndex-Dc8D8AR6.mjs +1 -0
- package/dist/packem_shared/cap-error-body-YBKO32BF.mjs +1 -0
- package/dist/packem_shared/catalog-CahzmDLV.mjs +1 -0
- package/dist/packem_shared/cli-BbgDNGew.mjs +2 -0
- package/dist/packem_shared/cli-manifest-DoQ_3bzs.mjs +1 -0
- package/dist/packem_shared/cloudflare-credentials-BVmZnDZk.mjs +1 -0
- package/dist/packem_shared/codegen-error-AmH54ofi.mjs +3 -0
- package/dist/packem_shared/command-CB4JM-Jl.mjs +1 -0
- package/dist/packem_shared/commands-DvSfLvyt.mjs +13 -0
- package/dist/packem_shared/createLogger-4TujevyB.mjs +2 -0
- package/dist/packem_shared/createRecordingSpawner-Ch7zuQQE.mjs +1 -0
- package/dist/packem_shared/deploy-target-resolve-BTCW1Iv4.mjs +1 -0
- package/dist/packem_shared/diffSnapshots-htqNZT8Q.mjs +7 -0
- package/dist/packem_shared/docker-DpVxvYpL.mjs +1 -0
- package/dist/packem_shared/free-port-DjbFhaaT.mjs +1 -0
- package/dist/packem_shared/health-probe-Bw9JFfIy.mjs +1 -0
- package/dist/packem_shared/import-BRM1hs7j.mjs +12 -0
- package/dist/packem_shared/index-oHZBGBwB.mjs +1 -0
- package/dist/packem_shared/insertSchemaExtension-DuiV6cba.mjs +8 -0
- package/dist/packem_shared/lint-ignore-report-DKZagpqk.mjs +2 -0
- package/dist/packem_shared/mcp-config-file-CcDTeXIq.mjs +2 -0
- package/dist/packem_shared/open-url-EnKy--w-.mjs +1 -0
- package/dist/packem_shared/output-format-DWe_tkTR.mjs +2 -0
- package/dist/packem_shared/parseManifest-CwPTKdtS.mjs +1 -0
- package/dist/packem_shared/path-containment-CgxYZggb.mjs +1 -0
- package/dist/packem_shared/plan-DFY3CqHD.mjs +5 -0
- package/dist/packem_shared/planDevCommand-BDoJUgQ4.mjs +1 -0
- package/dist/packem_shared/platform-diagnostics-Cn2g6Jh-.mjs +4 -0
- package/dist/packem_shared/prompt-cancelled-BvsNxg_Q.mjs +1 -0
- package/dist/packem_shared/render-lunora-error-B6893vbU.mjs +2 -0
- package/dist/packem_shared/resolve-Bdc9KEUv.mjs +5 -0
- package/dist/packem_shared/resolve-target-C_ZloTvd.mjs +1 -0
- package/dist/packem_shared/runAddCommand-BChdJQ5L.mjs +1 -0
- package/dist/packem_shared/runDeployCommand-D40F3PLQ.mjs +1 -0
- package/dist/packem_shared/runExportCommand-slAq5BCF.mjs +5 -0
- package/dist/packem_shared/runMigrateGenerateCommand-BwemPIOz.mjs +11 -0
- package/dist/packem_shared/schemaIrToSnapshot-BHuNOfgs.mjs +1 -0
- package/dist/packem_shared/secret-key-8DPOH98z.mjs +1 -0
- package/dist/packem_shared/shared-D-zCOmgY.mjs +1 -0
- package/dist/packem_shared/storage-DNzYhXR-.mjs +1 -0
- package/dist/packem_shared/tui-prompts-B3YwUhGw.mjs +4 -0
- package/dist/packem_shared/variables-CPYyJtsL.mjs +1 -0
- package/dist/packem_shared/wrangler-name-Dsk5K1f-.mjs +1 -0
- package/dist/packem_shared/wrangler-secrets-CTQPpalP.mjs +1 -0
- package/package.json +39 -19
- package/skills/README.md +36 -17
- package/skills/lunora/SKILL.md +158 -65
- package/skills/lunora-cloudflare-audit/SKILL.md +163 -0
- package/skills/lunora-create-package/SKILL.md +97 -84
- package/skills/lunora-deploy/SKILL.md +175 -105
- package/skills/lunora-functions/SKILL.md +363 -56
- package/skills/lunora-migration-helper/SKILL.md +162 -137
- package/skills/lunora-performance-audit/SKILL.md +137 -95
- package/skills/lunora-quickstart/SKILL.md +136 -136
- package/skills/lunora-realtime/SKILL.md +217 -97
- package/skills/lunora-setup-auth/SKILL.md +162 -102
- package/skills/lunora-setup-hyperdrive/SKILL.md +130 -109
- package/skills/lunora-setup-hyperdrive-global/SKILL.md +93 -90
- package/skills/lunora-setup-mail/SKILL.md +85 -113
- package/skills/lunora-setup-scheduler/SKILL.md +132 -96
- package/skills/lunora-setup-storage/SKILL.md +189 -95
- package/dist/packem_chunks/planDevCommand.mjs +0 -541
- package/dist/packem_chunks/runDeployCommand.mjs +0 -594
- package/dist/packem_chunks/runMigrateGenerateCommand.mjs +0 -397
- package/dist/packem_shared/COMMANDS-B0ftFD_3.mjs +0 -948
- package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-Ck-2bU08.mjs +0 -244
- package/dist/packem_shared/admin-url-4UzT-CI4.mjs +0 -19
- package/dist/packem_shared/api-spec-CtA6ilu4.mjs +0 -13
- package/dist/packem_shared/buildRegistryIndex-BcYe607_.mjs +0 -38
- package/dist/packem_shared/codegen-error-DJG-ghs_.mjs +0 -31
- package/dist/packem_shared/command-D3lB_4Az.mjs +0 -19
- package/dist/packem_shared/commands-B-gR09Z_.mjs +0 -845
- package/dist/packem_shared/createLogger-B40gPzQo.mjs +0 -78
- package/dist/packem_shared/createRecordingSpawner-DxI3mebw.mjs +0 -43
- package/dist/packem_shared/detect-package-manager-DYp7n3mJ.mjs +0 -61
- package/dist/packem_shared/diffSnapshots-BeDvvNiF.mjs +0 -161
- package/dist/packem_shared/docker-hMQ97KSQ.mjs +0 -21
- package/dist/packem_shared/insertSchemaExtension-DAqbfr9Z.mjs +0 -64
- package/dist/packem_shared/open-url-Dfq6fAyT.mjs +0 -41
- package/dist/packem_shared/output-format-wUvAN6AL.mjs +0 -17
- package/dist/packem_shared/parseArgs-YXFuKdEk.mjs +0 -56
- package/dist/packem_shared/parseManifest--vZf2FY1.mjs +0 -94
- package/dist/packem_shared/prompt-cancelled-APzX1Im-.mjs +0 -9
- package/dist/packem_shared/resolve-target-qbsJ_5sF.mjs +0 -16
- package/dist/packem_shared/runAddCommand-bnY6-HKb.mjs +0 -4
- package/dist/packem_shared/schema-drift-gate-BtBt0as0.mjs +0 -79
- package/dist/packem_shared/schemaIrToSnapshot-DdsljJT-.mjs +0 -43
- package/dist/packem_shared/storage-BIsph-Vk.mjs +0 -84
- package/dist/packem_shared/tui-prompts-BjEN8XgP.mjs +0 -658
- package/dist/packem_shared/wrangler-name-cy4yhm9j.mjs +0 -12
- package/dist/packem_shared/wrangler-secrets-P2_ZUR-k.mjs +0 -47
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: lunora-functions
|
|
3
|
-
description: Authoring rules for Lunora schema and functions
|
|
4
|
-
`
|
|
5
|
-
mutation
|
|
6
|
-
|
|
3
|
+
description: Authoring rules for Lunora schema and server functions under `lunora/` —
|
|
4
|
+
`defineSchema`/`defineTable`, `v.*` validators, the generated
|
|
5
|
+
`query`/`mutation`/`action` builders (and `internal*`), indexes and
|
|
6
|
+
`withIndex`, the `ctx.db` API, pagination, `ctx.db.related`, opt-in `ctx.*`
|
|
7
|
+
capabilities, topics, modules, services, and `httpAction`. Use when writing or
|
|
8
|
+
reviewing files in `lunora/` (`schema.ts`, function files, `http.ts`,
|
|
9
|
+
`queues.ts`), when asked to "add a query/mutation/action", "add an index",
|
|
10
|
+
"call one function from another", or "add a webhook endpoint", or when
|
|
11
|
+
`lunora codegen` reports an error in a function.
|
|
7
12
|
---
|
|
8
13
|
|
|
9
14
|
# Lunora Functions
|
|
10
15
|
|
|
11
|
-
The core authoring rules for Lunora backend code.
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
The core authoring rules for Lunora backend code. The verify loop after every
|
|
17
|
+
edit:
|
|
18
|
+
|
|
19
|
+
1. `lunora codegen` — regenerates `lunora/_generated/` and prints advisor
|
|
20
|
+
findings (it does not run `tsc`).
|
|
21
|
+
2. `lunora verify` — codegen dry-run plus `tsc --noEmit`, no files written. The
|
|
22
|
+
type-check needs a `tsconfig.json` (without one it warns and skips) and is
|
|
23
|
+
off under `--no-typecheck`.
|
|
14
24
|
|
|
15
25
|
## When to Use
|
|
16
26
|
|
|
@@ -30,7 +40,7 @@ regenerates `lunora/_generated/` and typechecks your schema + functions.
|
|
|
30
40
|
a `v.*` validator. Declare an index for every access pattern you query by.
|
|
31
41
|
|
|
32
42
|
```ts
|
|
33
|
-
import { defineSchema, defineTable, v } from "
|
|
43
|
+
import { defineSchema, defineTable, v } from "lunorash/server";
|
|
34
44
|
|
|
35
45
|
export default defineSchema({
|
|
36
46
|
messages: defineTable({
|
|
@@ -46,8 +56,9 @@ export default defineSchema({
|
|
|
46
56
|
});
|
|
47
57
|
```
|
|
48
58
|
|
|
49
|
-
- Lunora injects `_id` and `_creationTime` on every row
|
|
50
|
-
|
|
59
|
+
- Lunora injects `_id` and `_creationTime` on every row, so don't declare them.
|
|
60
|
+
- `lunorash/*` is the umbrella package templates install; `@lunora/server` is
|
|
61
|
+
the same module if the project depends on it directly.
|
|
51
62
|
- `.index("name", ["a", "b"])` — columns are ordered; put equality columns
|
|
52
63
|
first, then the range/sort column.
|
|
53
64
|
- `.shardBy("ownerId")` partitions the table across Durable Objects by key;
|
|
@@ -60,34 +71,51 @@ export default defineSchema({
|
|
|
60
71
|
|
|
61
72
|
`string`, `number`, `boolean`, `id("table")`, `null`, `any`, `bigint`, `bytes`,
|
|
62
73
|
`literal(value)`, `array(item)`, `object({...})`, `record(key, value)`,
|
|
63
|
-
`union(a, b, …)`, `optional(inner)`,
|
|
64
|
-
`timestamp`, and `storage` (an R2 object key)
|
|
65
|
-
|
|
74
|
+
`union(a, b, …)`, `optional(inner)`, `partial({...})`, `geoPoint()`, the
|
|
75
|
+
convenience types `date`, `timestamp`, and `storage` (an R2 object key), and
|
|
76
|
+
`v.from(schema)` to adopt any Standard Schema v1 validator (Zod, Valibot, …).
|
|
66
77
|
|
|
67
|
-
|
|
78
|
+
- Required is the default. `v.optional(x)` means the field may be absent;
|
|
79
|
+
`x.nullable()` means it may be `null`. They are different.
|
|
80
|
+
- Refinements chain on the validator and are enforced at the trust boundary:
|
|
81
|
+
`v.string().min(1).max(4096)`, `.email()`, `.url()`, `.pattern(re)`;
|
|
82
|
+
`v.number().int().min(0)`, `.positive()`; `v.array(x).max(n)`. Bound every
|
|
83
|
+
client-supplied string and array — templates cap text at `.max(4096)`.
|
|
68
84
|
|
|
69
|
-
|
|
70
|
-
terminal `.query` / `.mutation` / `.action` handler. Export them as named
|
|
71
|
-
consts from `lunora/*.ts`; codegen surfaces them as `api.<file>.<name>`.
|
|
85
|
+
## Functions: query / mutation / action
|
|
72
86
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
87
|
+
Each function declares its inputs with `.input(...)` (a `v.*` map), optionally
|
|
88
|
+
adds `.use(middleware)` (rate limiting, RLS, masking) and `.output(validator)`,
|
|
89
|
+
and ends with a terminal `.query` / `.mutation` / `.action` taking a
|
|
90
|
+
`({ ctx, args }) => …` handler. Export them as named consts from `lunora/*.ts`.
|
|
91
|
+
Codegen nests references by path: `lunora/messages.ts` → `api.messages.<fn>`,
|
|
92
|
+
`lunora/billing/invoices.ts` → `api.billing.invoices.<fn>`. Public functions
|
|
93
|
+
land in `_generated/api.ts`, `internal*` ones in `_generated/internal.ts`.
|
|
94
|
+
|
|
95
|
+
| Kind | Reads `ctx.db` | Writes `ctx.db` | Side effects / `fetch` | Reactive |
|
|
96
|
+
| ---------- | -------------- | ------------------- | ---------------------- | -------- |
|
|
97
|
+
| `query` | yes | no | no | yes |
|
|
98
|
+
| `mutation` | yes | yes, transactional | no | — |
|
|
99
|
+
| `action` | yes | yes, autocommitting | yes | — |
|
|
100
|
+
|
|
101
|
+
Import the builders from `_generated/server`, not from `lunorash/server`: codegen
|
|
102
|
+
binds them to your schema, which is what types `ctx.db`, `v.id("channels")`, and
|
|
103
|
+
index names. `lunorash/server` exports the schema/HTTP/validator surface and
|
|
104
|
+
`LunoraError`, never `query` / `mutation` / `action`.
|
|
78
105
|
|
|
79
106
|
```ts
|
|
80
|
-
|
|
81
|
-
import {
|
|
107
|
+
// lunora/messages.ts
|
|
108
|
+
import { LunoraError } from "lunorash/server";
|
|
82
109
|
|
|
83
|
-
|
|
84
|
-
|
|
110
|
+
import { api } from "./_generated/api.js";
|
|
111
|
+
import type { Doc, Id } from "./_generated/server.js";
|
|
112
|
+
import { action, mutation, query, v } from "./_generated/server.js";
|
|
85
113
|
|
|
86
|
-
export const listByChannel = query.input({ channelId: v.id("channels") }).query(async ({ ctx, args: { channelId } }) =>
|
|
114
|
+
export const listByChannel = query.input({ channelId: v.id("channels") }).query(async ({ ctx, args: { channelId } }): Promise<Doc<"messages">[]> =>
|
|
87
115
|
ctx.db
|
|
88
116
|
.query("messages")
|
|
89
117
|
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
|
|
90
|
-
.
|
|
118
|
+
.take(100),
|
|
91
119
|
);
|
|
92
120
|
|
|
93
121
|
export const send = mutation
|
|
@@ -104,21 +132,37 @@ export const send = mutation
|
|
|
104
132
|
});
|
|
105
133
|
});
|
|
106
134
|
|
|
107
|
-
export const notifySlack = action.input({
|
|
108
|
-
|
|
109
|
-
|
|
135
|
+
export const notifySlack = action.input({ channelId: v.id("channels") }).action(async ({ ctx, args: { channelId } }) => {
|
|
136
|
+
// A public action is callable by anyone: gate it like a mutation (and check
|
|
137
|
+
// channel access too, if your app has per-channel membership).
|
|
138
|
+
if (!ctx.auth.userId) {
|
|
139
|
+
throw new LunoraError("UNAUTHORIZED", "not signed in");
|
|
140
|
+
}
|
|
141
|
+
const messages = await ctx.runQuery(api.messages.listByChannel, { channelId });
|
|
142
|
+
const response = await fetch(String(ctx.env?.SLACK_WEBHOOK_URL), { method: "POST", body: JSON.stringify(messages) });
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
throw new Error(`Slack webhook failed: ${String(response.status)}`); // fetch resolves on 4xx/5xx
|
|
145
|
+
}
|
|
110
146
|
});
|
|
111
147
|
```
|
|
112
148
|
|
|
149
|
+
Templates also map `#lunora/*` to `./lunora/*` in `package.json` `imports`, so
|
|
150
|
+
`#lunora/_generated/server.js` works from anywhere in the project.
|
|
151
|
+
|
|
113
152
|
- **Pick the right kind.** Reactive read → `query`. Transactional write →
|
|
114
153
|
`mutation`. External I/O (`fetch`, third-party SDKs, calling other functions)
|
|
115
|
-
→ `action`. An action has
|
|
116
|
-
|
|
154
|
+
→ `action`. An action has a `ctx.db`, but its own writes are not
|
|
155
|
+
transactional: each autocommits as it runs, so a later throw rolls nothing
|
|
156
|
+
back. Reach data through `ctx.runQuery` / `ctx.runMutation` — a mutation
|
|
157
|
+
called that way runs in the same all-or-nothing transaction a top-level one
|
|
158
|
+
gets, so put every write that has to land together in one mutation rather
|
|
159
|
+
than sequencing several from the action.
|
|
117
160
|
- **`internal*` variants** (`internalQuery`, `internalMutation`,
|
|
118
161
|
`internalAction`) are not exposed to clients — use them for server-only logic
|
|
119
162
|
called from actions, crons, or other functions.
|
|
120
|
-
- **Throw `LunoraError
|
|
121
|
-
|
|
163
|
+
- **Throw `LunoraError(code, message?, options?)`** from `lunorash/server` for
|
|
164
|
+
expected failures (`"UNAUTHORIZED"`, `"NOT_FOUND"`, …); it serializes with its
|
|
165
|
+
code to the client, where `isUnauthorizedError` and friends branch on it.
|
|
122
166
|
|
|
123
167
|
## The `ctx.db` API
|
|
124
168
|
|
|
@@ -127,15 +171,16 @@ Reads:
|
|
|
127
171
|
```ts
|
|
128
172
|
await ctx.db.get(id); // one row by id (or null)
|
|
129
173
|
ctx.db.query("t").withIndex("by_x", (q) => q.eq("x", v)); // indexed query
|
|
174
|
+
.order("asc" | "desc") // by the active index (or _creationTime)
|
|
175
|
+
.filter((doc) => …) // JS predicate, applied after the index range
|
|
130
176
|
.collect(); // all matching rows
|
|
131
|
-
.first(); // first row or null
|
|
132
|
-
.unique(); // exactly one (throws if 0 or >1)
|
|
133
177
|
.take(n); // first n rows
|
|
134
|
-
.
|
|
135
|
-
.
|
|
178
|
+
.first(); // first row or null
|
|
179
|
+
.unique(); // the one match, null if none; throws if more than one
|
|
180
|
+
.paginate({ numItems, cursor }); // { page, isDone, continueCursor }
|
|
136
181
|
```
|
|
137
182
|
|
|
138
|
-
Writes (mutations
|
|
183
|
+
Writes (mutations, and autocommitting in actions):
|
|
139
184
|
|
|
140
185
|
```ts
|
|
141
186
|
await ctx.db.insert("t", { ...fields }); // returns the new Id
|
|
@@ -144,39 +189,301 @@ await ctx.db.replace(id, { ...allFields }); // full overwrite
|
|
|
144
189
|
await ctx.db.delete(id);
|
|
145
190
|
```
|
|
146
191
|
|
|
147
|
-
|
|
148
|
-
scans the whole table
|
|
149
|
-
|
|
192
|
+
Prefer `withIndex` over `.filter`: a `.filter(...)` without a covering index
|
|
193
|
+
scans the whole table (advisor: `filter_without_index`), and a bare `.collect()`
|
|
194
|
+
grows with the table and with every subscriber's payload (`unbounded_collect`).
|
|
195
|
+
Constrain with `.withIndex`, cap with `.take(n)`, or paginate.
|
|
196
|
+
|
|
197
|
+
### Pagination
|
|
198
|
+
|
|
199
|
+
A paginated query takes a `paginationOpts` arg and returns `.paginate(...)`
|
|
200
|
+
as-is; the client hook (`usePaginatedQuery`, see `lunora-realtime`) supplies it:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
// `v.any()` would type the arg `unknown`, which `.paginate()` rejects. The hook
|
|
204
|
+
// also sends `endCursor`; an undeclared key is stripped, so declare it. Bound
|
|
205
|
+
// `numItems` too: it is caller input and sets the read limit.
|
|
206
|
+
const cursor = v.optional(v.union(v.string(), v.null()));
|
|
207
|
+
|
|
208
|
+
export const page = query
|
|
209
|
+
.input({ channelId: v.id("channels"), paginationOpts: v.object({ cursor, endCursor: cursor, numItems: v.number().int().min(1).max(100) }) })
|
|
210
|
+
.query(async ({ ctx, args: { channelId, paginationOpts } }) =>
|
|
211
|
+
ctx.db
|
|
212
|
+
.query("messages")
|
|
213
|
+
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
|
|
214
|
+
.order("desc")
|
|
215
|
+
.paginate(paginationOpts),
|
|
216
|
+
);
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Following foreign keys: `ctx.db.related`
|
|
220
|
+
|
|
221
|
+
Every `v.id("table")` column is an edge in a graph your schema already declares.
|
|
222
|
+
`ctx.db.related` walks it, so "this customer's tickets, and those tickets'
|
|
223
|
+
messages" is one call instead of a hand-written chain of `withIndex` lookups.
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
const { continueCursor, isDone, nodes } = await ctx.db.related(
|
|
227
|
+
{ table: "customers", id: customerId }, // or a row you already loaded
|
|
228
|
+
{ depth: 2, direction: "in", edges: ["tickets.customerId", "messages.ticketId"], limit: 50 },
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
for (const node of nodes) {
|
|
232
|
+
node.table; // "messages"
|
|
233
|
+
node.document; // the row itself
|
|
234
|
+
node.depth; // 2
|
|
235
|
+
node.score; // 0.5 — 1 at depth 1, halving per hop
|
|
236
|
+
node.path; // ["tickets.customerId", "messages.ticketId"]
|
|
237
|
+
node.pathIds; // ids along the way, start included
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
- **Edge names are `"<table>.<column>"`.** `edges` restricts the walk to the
|
|
242
|
+
named ones; a name the schema does not declare is refused, not ignored.
|
|
243
|
+
- **`direction`** — `"out"` follows the ids this row holds, `"in"` the rows that
|
|
244
|
+
point at it, `"both"` (the default) does both.
|
|
245
|
+
- **`depth`** defaults to `1`, max `4`. **`limit`** defaults to `50`, max `200`.
|
|
246
|
+
Both caps refuse rather than clamp, so a probe for the ceiling just errors.
|
|
247
|
+
- **Only a column is an edge**: a bare `v.id(...)`, `v.optional(v.id(...))` or
|
|
248
|
+
`v.array(v.id(...))`. An id nested in a `v.object` / `v.union` / `v.record` is
|
|
249
|
+
not. An array FK is followed outward only.
|
|
250
|
+
- **It is an ordinary read** — RLS, column masks, soft delete, `.global()`
|
|
251
|
+
routing and reactivity all apply, because every hop goes back through
|
|
252
|
+
`ctx.db`. Under a `.rls("required")` schema each hop gets exactly the verdict a
|
|
253
|
+
direct read of that table would, so declare a read policy for every table the
|
|
254
|
+
walk can reach — or narrow it with `edges`.
|
|
255
|
+
- Index the foreign keys. Each inward hop is a `WHERE fk IN (…)` read, and
|
|
256
|
+
unindexed it scans — see the `lunora-performance-audit` skill for the cost
|
|
257
|
+
model and the traversal caps.
|
|
150
258
|
|
|
151
259
|
## Other `ctx` capabilities
|
|
152
260
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
261
|
+
Always available:
|
|
262
|
+
|
|
263
|
+
- `ctx.auth` — the resolved session (`ctx.auth.userId`, `null` when anonymous).
|
|
264
|
+
- `ctx.secrets` — Cloudflare Secrets Store.
|
|
265
|
+
- `ctx.ip` — the caller's `CF-Connecting-IP`; `undefined` off Cloudflare.
|
|
266
|
+
- `ctx.runQuery` (all kinds), `ctx.runMutation` (mutations, actions),
|
|
267
|
+
`ctx.runAction` (actions) — call another function by its `api.*` /
|
|
268
|
+
`internal.*` reference.
|
|
269
|
+
- `ctx.log`, `ctx.metrics`, `ctx.span` / `ctx.trace` — logging, metrics, the
|
|
270
|
+
current wide-event span and a scoped tracing helper.
|
|
271
|
+
|
|
272
|
+
Wired by codegen on use. A dependency in `package.json` is not enough: codegen
|
|
273
|
+
scans the `lunora/` source set and turns a capability on only when a file there
|
|
274
|
+
imports its `@lunora/*` package or reads its `ctx.*` helper. Write the call
|
|
275
|
+
first, then run `lunora codegen` to wire the binding and typed context:
|
|
276
|
+
|
|
277
|
+
| `ctx.*` | Package |
|
|
278
|
+
| ------------------------------------------------------------------- | ----------------------------- |
|
|
279
|
+
| `ctx.scheduler` (mutations + actions; see `lunora-setup-scheduler`) | `@lunora/scheduler` |
|
|
280
|
+
| `ctx.storage` (R2; see `lunora-setup-storage`) | `@lunora/storage` |
|
|
281
|
+
| `ctx.ai` | `@lunora/ai` (Workers AI) |
|
|
282
|
+
| `ctx.flags` | `@lunora/flags` (OpenFeature) |
|
|
283
|
+
| `ctx.queues.<name>` | `@lunora/queue` |
|
|
284
|
+
| `ctx.topics.<name>` (pub/sub, see below) | `@lunora/queue` |
|
|
285
|
+
| `ctx.workflows` / `ctx.runStep` | `@lunora/workflow` |
|
|
286
|
+
| `ctx.containers` | `@lunora/container` |
|
|
287
|
+
| `ctx.browser` (action-only) | `@lunora/browser` |
|
|
288
|
+
| `ctx.live` (action-side only; see `lunora-realtime`) | `@lunora/server` |
|
|
289
|
+
| `ctx.sql` (action-only) | `@lunora/hyperdrive` |
|
|
290
|
+
| `ctx.kv` / `ctx.images` / `ctx.analytics` | `@lunora/bindings` subpaths |
|
|
291
|
+
| `ctx.pipelines` / `ctx.vectors` / `ctx.r2sql` | `@lunora/bindings` subpaths |
|
|
292
|
+
|
|
293
|
+
One exception to the usage scan, and one extra requirement:
|
|
294
|
+
|
|
295
|
+
- **`ctx.flags` gates on a declaration file**, not on usage — codegen wires it
|
|
296
|
+
only when `lunora/flags.ts` exists. `ctx.notify` / `ctx.push` work the same
|
|
297
|
+
way via `lunora/notify.ts`.
|
|
298
|
+
- **`ctx.sql` also needs the real resource.** Codegen types the field, but the
|
|
299
|
+
connection needs a `HYPERDRIVE` binding (`wrangler hyperdrive create`) and an
|
|
300
|
+
explicit `createHyperdrive(ctx.env.HYPERDRIVE)` + driver adapter in the
|
|
301
|
+
action — see `lunora-setup-hyperdrive`. Bindings codegen can provision on its
|
|
302
|
+
own (e.g. `BROWSER` for `ctx.browser`) need no manual wrangler step.
|
|
303
|
+
|
|
304
|
+
`ctx.browser` and `ctx.sql` are **action-only** by design. They are external,
|
|
305
|
+
non-deterministic I/O: a query is re-run on every subscription re-evaluation, so
|
|
306
|
+
a non-deterministic read makes reactivity wrong, and a mutation's writes are
|
|
307
|
+
transactional — a rollback cannot un-send a network call.
|
|
308
|
+
|
|
309
|
+
### Topics: one event, many consumers
|
|
310
|
+
|
|
311
|
+
A queue has one consumer. To fan one event out to several independent
|
|
312
|
+
consumers, declare a topic and subscribe to it — in `lunora/queues.ts`, next to
|
|
313
|
+
your queues. Each subscription is its own queue with its own retries and
|
|
314
|
+
dead-letter queue.
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
// lunora/queues.ts
|
|
318
|
+
import { defineSubscription, defineTopic } from "@lunora/queue";
|
|
319
|
+
|
|
320
|
+
import { internal } from "./_generated/internal.js";
|
|
321
|
+
|
|
322
|
+
export const signups = defineTopic<{ userId: string }>();
|
|
323
|
+
|
|
324
|
+
export const welcomeEmail = defineSubscription(signups, {
|
|
325
|
+
handler: async (_ctx, batch) => {
|
|
326
|
+
for (const message of batch.messages) {
|
|
327
|
+
await message.run(internal.email.welcome, { userId: message.body.userId });
|
|
328
|
+
message.ack();
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
maxRetries: 5,
|
|
332
|
+
deadLetterQueue: "welcome-email-dlq",
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// in a mutation or action
|
|
336
|
+
await ctx.topics.signups.publish({ userId });
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
- Delivery is **at-least-once per subscription** and unordered — make handlers
|
|
340
|
+
idempotent. A failed send rejects `publish`, and a retry re-delivers to the
|
|
341
|
+
subscriptions that already got it.
|
|
342
|
+
- A subscription is published to only through its topic, never `ctx.queues`.
|
|
343
|
+
The topic passed to `defineSubscription` must be a `defineTopic` export of
|
|
344
|
+
`lunora/queues.ts` or a module's `queues.ts` (import it from there).
|
|
345
|
+
- Rate-limit public procedures that publish: one publish is one send per
|
|
346
|
+
subscription (`privileged_fanout_from_public_procedure` flags it).
|
|
347
|
+
|
|
348
|
+
## Modules: grouping `lunora/` folders
|
|
349
|
+
|
|
350
|
+
A folder whose `module.ts` default-exports `defineModule(...)` is a module.
|
|
351
|
+
Modules are metadata — no `api.*` path or runtime change, still one Worker —
|
|
352
|
+
that drive the Studio **Architecture** view, OpenAPI tags, and table ownership:
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
// lunora/billing/module.ts
|
|
356
|
+
import { defineModule } from "lunorash/server";
|
|
357
|
+
|
|
358
|
+
export default defineModule({ description: "Invoices and payments", tables: ["invoices"] });
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
- Write `description` and `tables` inline; codegen reads them without running
|
|
362
|
+
the file. Modules do not nest.
|
|
363
|
+
- A module can declare its own queues, topics and subscriptions in
|
|
364
|
+
`lunora/<module>/queues.ts`. Export names stay app-wide (`ctx.queues.<name>`),
|
|
365
|
+
so two queues files exporting the same name is a codegen error.
|
|
366
|
+
- `cross_module_table_write` warns when a function outside the owning module
|
|
367
|
+
writes an owned table (insert, `patch`/`replace`/`delete`, batch or facade),
|
|
368
|
+
including through a same-file helper. Move the write into the owning module:
|
|
369
|
+
an exported helper there taking the caller's `ctx` (`openInvoice(ctx, …)`), or
|
|
370
|
+
a registered owner mutation via `ctx.runMutation(internal.<owner>.<fn>, …)`.
|
|
371
|
+
- Installed components (`.extend(...)` schema extensions) count as modules that
|
|
372
|
+
own their prefixed tables: write them through the component's functions, not
|
|
373
|
+
`ctx.db` directly.
|
|
374
|
+
- Keep ids typed `Id<"table">` — a write through an untyped `string` id cannot
|
|
375
|
+
be attributed to a table.
|
|
376
|
+
|
|
377
|
+
## Services: calling sibling Workers
|
|
378
|
+
|
|
379
|
+
A Worker the app calls (a parser, an LLM gateway) is declared in
|
|
380
|
+
`lunora.config.ts`, not discovered. Each one becomes a typed `ctx.services.<key>`
|
|
381
|
+
on **actions** (not queries/mutations: a cross-Worker call cannot be replayed or
|
|
382
|
+
rolled back; HTTP actions reach it through `ctx.runAction`):
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
// lunora.config.ts
|
|
386
|
+
export default {
|
|
387
|
+
services: {
|
|
388
|
+
documentParser: { dir: "services/document-parser" }, // fetch service
|
|
389
|
+
llmGateway: { dir: "services/llm-gateway", entrypoint: "Gateway" }, // RPC
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// in an action
|
|
394
|
+
const parsed = await ctx.services.documentParser.fetch("https://parser/parse", { body, method: "POST" });
|
|
395
|
+
const text = await ctx.services.llmGateway.complete(prompt); // typed from the Gateway class
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
- The Worker name and entry come from `<dir>/wrangler.jsonc`; Lunora writes the
|
|
399
|
+
`services[]` binding (`SERVICE_<KEY>`), runs it in the same `lunora dev` /
|
|
400
|
+
`vite dev` session, and deploys it before the app.
|
|
401
|
+
- A bound service needs no URL, no `*_URL` var and no HMAC: set
|
|
402
|
+
`"workers_dev": false` on it when it has no public route (`lunora doctor` warns
|
|
403
|
+
otherwise). Keep its HMAC check if it has a custom domain or route.
|
|
156
404
|
|
|
157
405
|
## HTTP endpoints
|
|
158
406
|
|
|
159
407
|
For webhooks or non-RPC HTTP, use `httpRouter` / `httpRoute` + `httpAction`:
|
|
160
408
|
|
|
409
|
+
`httpRouter()` takes **no arguments** — it returns a [Hono](https://hono.dev)
|
|
410
|
+
app you mount routes on, and you export the app. Passing it a routes object is a
|
|
411
|
+
type error (`Expected 0 arguments`), and from untyped code the routes simply
|
|
412
|
+
never mount.
|
|
413
|
+
|
|
161
414
|
```ts
|
|
162
|
-
|
|
415
|
+
// lunora/http.ts
|
|
416
|
+
import { httpRouter } from "lunorash/server";
|
|
163
417
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
418
|
+
import { internal } from "./_generated/internal.js";
|
|
419
|
+
|
|
420
|
+
const app = httpRouter();
|
|
421
|
+
const encoder = new TextEncoder();
|
|
422
|
+
|
|
423
|
+
// Stripe's scheme: `stripe-signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`.
|
|
424
|
+
// The Stripe SDK's `stripe.webhooks.constructEventAsync` does the same check.
|
|
425
|
+
const verifyStripe = async (secret: string, header: string | undefined, payload: string): Promise<boolean> => {
|
|
426
|
+
const fields = header?.split(",").map((part) => part.split("=", 2)) ?? [];
|
|
427
|
+
const t = fields.find(([k]) => k === "t")?.[1];
|
|
428
|
+
// While a secret is being rolled Stripe sends one `v1` per secret; accept any.
|
|
429
|
+
const v1s = fields.filter(([k, value]) => k === "v1" && /^[\da-f]{64}$/.test(value ?? "")).map(([, value]) => value ?? "");
|
|
430
|
+
|
|
431
|
+
if (!t || v1s.length === 0 || Math.abs(Date.now() / 1000 - Number(t)) > 300) {
|
|
432
|
+
return false; // missing, malformed, or outside the 5-minute replay window
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
|
|
436
|
+
const signed = encoder.encode(`${t}.${payload}`);
|
|
437
|
+
|
|
438
|
+
for (const v1 of v1s) {
|
|
439
|
+
const signature = Uint8Array.from(v1.match(/../g) ?? [], (byte) => Number.parseInt(byte, 16));
|
|
440
|
+
|
|
441
|
+
if (await crypto.subtle.verify("HMAC", key, signature, signed)) {
|
|
442
|
+
return true; // constant-time compare
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return false;
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// A plain Hono handler, not `httpAction`: the action ctx (`c.var.lunora`) has
|
|
450
|
+
// no env, while `c.env` is the Worker env.
|
|
451
|
+
app.post("/webhooks/stripe", async (c) => {
|
|
452
|
+
const secret = c.env.STRIPE_WEBHOOK_SECRET;
|
|
453
|
+
|
|
454
|
+
if (typeof secret !== "string") {
|
|
455
|
+
throw new Error("STRIPE_WEBHOOK_SECRET is not set");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const payload = await c.req.text(); // the raw bytes the signature covers
|
|
459
|
+
|
|
460
|
+
if (!(await verifyStripe(secret, c.req.header("stripe-signature"), payload))) {
|
|
461
|
+
return c.text("invalid signature", 401);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
await c.var.lunora.runMutation(internal.billing.record, { event: JSON.parse(payload) as unknown });
|
|
465
|
+
|
|
466
|
+
return c.text("ok");
|
|
170
467
|
});
|
|
468
|
+
|
|
469
|
+
export default app;
|
|
171
470
|
```
|
|
172
471
|
|
|
472
|
+
A webhook route is public: verify the provider's signature over the raw body
|
|
473
|
+
before any write, or anyone can forge events into `internal.*` functions.
|
|
474
|
+
|
|
173
475
|
## Checklist
|
|
174
476
|
|
|
175
477
|
- [ ] Schema columns are `v.*` validators; `_id`/`_creationTime` not declared.
|
|
176
478
|
- [ ] An index exists for every access pattern; queries use `withIndex`, not
|
|
177
|
-
`.filter`.
|
|
479
|
+
`.filter`, and are bounded (`.take(n)` / `.paginate`).
|
|
480
|
+
- [ ] Client-supplied strings and arrays carry a `.max(...)`.
|
|
178
481
|
- [ ] Right function kind: `query` (reactive read) / `mutation` (write) /
|
|
179
482
|
`action` (side effects via `runQuery`/`runMutation`).
|
|
180
483
|
- [ ] Server-only logic uses `internal*`; expected failures throw `LunoraError`.
|
|
181
|
-
- [ ]
|
|
182
|
-
- [ ]
|
|
484
|
+
- [ ] Writes that must land together sit in one mutation; ids typed `Id<"table">`.
|
|
485
|
+
- [ ] Any `ctx.db.related` walk is narrowed with `edges` / `direction`, and its
|
|
486
|
+
foreign keys are indexed.
|
|
487
|
+
- [ ] Writes to a module- or component-owned table go through the owner's
|
|
488
|
+
functions (`cross_module_table_write` is clean).
|
|
489
|
+
- [ ] `lunora codegen` shows no new advisor findings; `lunora verify` is clean.
|