@lunora/server 1.0.0-alpha.6 → 1.0.0-alpha.61

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 (73) hide show
  1. package/LICENSE.md +219 -0
  2. package/README.md +50 -0
  3. package/dist/data-model.d.mts +252 -156
  4. package/dist/data-model.d.ts +252 -156
  5. package/dist/data-model.mjs +0 -1
  6. package/dist/drizzle.mjs +1 -1
  7. package/dist/index.d.mts +1795 -977
  8. package/dist/index.d.ts +1795 -977
  9. package/dist/index.mjs +1 -25
  10. package/dist/otel.d.mts +543 -0
  11. package/dist/otel.d.ts +543 -0
  12. package/dist/otel.mjs +1 -0
  13. package/dist/packem_shared/DEFAULT_LIMIT-yHJ5O96W.mjs +1 -0
  14. package/dist/packem_shared/LunoraEnvError-CgpI2Mm_.mjs +3 -0
  15. package/dist/packem_shared/LunoraError-LVhdU0Lo.mjs +1 -0
  16. package/dist/packem_shared/PRESENCE_DEFAULT_TTL_MS-DmnVChTY.mjs +1 -0
  17. package/dist/packem_shared/allowAll-BnyNbJZT.mjs +1 -0
  18. package/dist/packem_shared/asBucketStorage-BthCnWop.mjs +1 -0
  19. package/dist/packem_shared/bindOrm-Bp9hsM2q.mjs +1 -0
  20. package/dist/packem_shared/buildMaskRegistry-BCIzKxdK.mjs +1 -0
  21. package/dist/packem_shared/buildRlsReadRegistry-CA-aUi7I.mjs +1 -0
  22. package/dist/packem_shared/composePluginMiddleware-B9NyOR1k.mjs +1 -0
  23. package/dist/packem_shared/context-identity-GunnA6La.mjs +1 -0
  24. package/dist/packem_shared/createPolicyDsl-sV1swpkD.mjs +1 -0
  25. package/dist/packem_shared/createSecrets-CgVPiW2C.mjs +1 -0
  26. package/dist/packem_shared/defineAggregateIndex-C3CMy5jn.mjs +1 -0
  27. package/dist/packem_shared/defineIdentity-B7gfAgxx.mjs +1 -0
  28. package/dist/packem_shared/defineMigration-Bfpwxv2f.mjs +1 -0
  29. package/dist/packem_shared/defineMutator-BgpQ-xUo.mjs +1 -0
  30. package/dist/packem_shared/defineShape-Ds8uNqzX.mjs +1 -0
  31. package/dist/packem_shared/defineStorageRule-BDu01PUn.mjs +1 -0
  32. package/dist/packem_shared/functions-CDC08CWY.mjs +1 -0
  33. package/dist/packem_shared/httpAction-C14NuF3V.mjs +4 -0
  34. package/dist/packem_shared/initLunora-DXCOVntr.mjs +1 -0
  35. package/dist/packem_shared/mask-DF7mtxQe.mjs +1 -0
  36. package/dist/packem_shared/onConnect-CEtRmUpJ.mjs +1 -0
  37. package/dist/packem_shared/optional-writer-override-CXXO30sZ.mjs +1 -0
  38. package/dist/packem_shared/plugin-CDBMGCeQ.mjs +1 -0
  39. package/dist/packem_shared/policy-tag-Dprt9JWo.mjs +1 -0
  40. package/dist/packem_shared/protectPublic-BhKewPqm.mjs +1 -0
  41. package/dist/packem_shared/rls-tRDkICWZ.mjs +1 -0
  42. package/dist/packem_shared/run-middleware-BeEEqmdE.mjs +1 -0
  43. package/dist/packem_shared/storageRules-BptPZbi8.mjs +1 -0
  44. package/dist/packem_shared/types.d-C4CMJK8x.d.mts +141 -0
  45. package/dist/packem_shared/types.d-DdYF8E18.d.ts +141 -0
  46. package/dist/rls/testing.d.mts +31 -31
  47. package/dist/rls/testing.d.ts +31 -31
  48. package/dist/rls/testing.mjs +1 -49
  49. package/dist/types.d.mts +1438 -475
  50. package/dist/types.d.ts +1438 -475
  51. package/dist/types.mjs +1 -31
  52. package/package.json +17 -4
  53. package/dist/packem_shared/LunoraEnvError-DjFkpkSP.mjs +0 -187
  54. package/dist/packem_shared/LunoraError-DhggBJZF.mjs +0 -51
  55. package/dist/packem_shared/PRESENCE_DEFAULT_TTL_MS-BgBQsqQ-.mjs +0 -114
  56. package/dist/packem_shared/asBucketStorage-Cnxd9y2q.mjs +0 -11
  57. package/dist/packem_shared/bindOrm-Ce57S3N9.mjs +0 -128
  58. package/dist/packem_shared/composePluginMiddleware-Ck5_TUO8.mjs +0 -100
  59. package/dist/packem_shared/createPolicyDsl-De67zPDS.mjs +0 -29
  60. package/dist/packem_shared/createSecrets-TsIP9lOa.mjs +0 -55
  61. package/dist/packem_shared/defineAggregateIndex-C2gT1GzM.mjs +0 -252
  62. package/dist/packem_shared/defineMigration-CAJLr6fx.mjs +0 -8
  63. package/dist/packem_shared/defineStorageRule-qu0mpilX.mjs +0 -20
  64. package/dist/packem_shared/httpAction-B7FYUEgr.mjs +0 -340
  65. package/dist/packem_shared/initLunora-CATvPsVt.mjs +0 -86
  66. package/dist/packem_shared/mask-eCUYOwhd.mjs +0 -211
  67. package/dist/packem_shared/onConnect-CIPXKPyw.mjs +0 -13
  68. package/dist/packem_shared/protectPublic-BjFkQ_Or.mjs +0 -15
  69. package/dist/packem_shared/rls-Bi9HiyDC.mjs +0 -567
  70. package/dist/packem_shared/run-middleware-CYQOuoV6.mjs +0 -18
  71. package/dist/packem_shared/storageRules-4a30FSpI.mjs +0 -88
  72. package/dist/packem_shared/types.d-BDY0FYHK.d.ts +0 -135
  73. package/dist/packem_shared/types.d-DmvyEMD6.d.mts +0 -135
package/dist/types.d.mts CHANGED
@@ -1,4 +1,68 @@
1
1
  import { ValidatorMap, InferValidatorMap, Id, Validator, Infer } from '@lunora/values';
2
+ /**
3
+ * The set of languages a `.searchIndex()` can declare, in one place.
4
+ *
5
+ * Three layers need it and none of them can import the others: `@lunora/server`
6
+ * types the option and validates it at schema-build time, and `@lunora/do` maps
7
+ * it onto a stopword list at analysis time — with no dependency edge between
8
+ * them (nor should there be: the schema builder must stay usable without the DO
9
+ * runtime). So it lived in three hand-maintained copies, where adding a
10
+ * language silently produced a schema the builder accepts and the analyzer
11
+ * treats as `"none"` — an index that quietly stops dropping stopwords.
12
+ *
13
+ * The list is the single source: the type is derived from it, so the two cannot
14
+ * drift. `@lunora/do` still owns the stopword *lists*; it keys them by these
15
+ * names, and its own tests assert the two agree.
16
+ *
17
+ * Inlined by the bundler into each `dist` rather than published, so this stays
18
+ * a shared *constant*, not a dependency edge. See the `shared/` section of
19
+ * AGENTS.md.
20
+ */
21
+ /**
22
+ * Every accepted value, sorted, so an error message can list what *is*
23
+ * accepted without re-sorting. `"none"` is explicit rather than absent: it
24
+ * means "fold, but drop nothing", which is also what an index with no declared
25
+ * language gets.
26
+ */
27
+ declare const SEARCH_LANGUAGES: readonly ["de", "en", "es", "fr", "it", "nl", "none", "pt"];
28
+ /** A declared analysis language, derived from the list above. */
29
+ type SearchLanguage = (typeof SEARCH_LANGUAGES)[number];
30
+ /**
31
+ * How a search index is stored. `"portable"` promises identical behaviour on
32
+ * every backend; `"native"` opts into the engine's own full-text index where it
33
+ * has one, trading that promise for its speed.
34
+ *
35
+ * Lives beside the languages for the same reason they do: it is declared in
36
+ * `@lunora/server`, acted on in the storage layer, and there is no dependency
37
+ * edge between them — so a third hand-maintained copy is how a typo turns into
38
+ * a silently different *physical layout*.
39
+ */
40
+ declare const SEARCH_STRATEGIES: readonly ["native", "portable"];
41
+ /** A declared storage strategy, derived from the list above. */
42
+ type SearchStrategy = (typeof SEARCH_STRATEGIES)[number];
43
+ /**
44
+ * Declared HTTP caching for an exposed endpoint (`.expose({ cache })`). Lives
45
+ * here, alongside the path/method contract, for the same reason: the runtime
46
+ * WRITES these headers and the OpenAPI emitter DESCRIBES them, and the two must
47
+ * not be able to disagree. Deriving both from {@link cacheControlValue} /
48
+ * {@link cacheVaryValue} makes "the published spec matches what the runtime
49
+ * actually sends" structural rather than a hand-kept invariant.
50
+ */
51
+ interface RestCachePolicy {
52
+ /**
53
+ * Extra request headers this app authenticates on, beyond
54
+ * {@link CREDENTIAL_HEADERS}. Declare these whenever `resolveIdentity` reads
55
+ * something else (`x-api-key`, a tenant header, …): they join both the
56
+ * credential check and the emitted `Vary`. Without them, a caller
57
+ * authenticating that way is treated as anonymous.
58
+ */
59
+ readonly credentialHeaders?: ReadonlyArray<string>;
60
+ readonly maxAge: number;
61
+ readonly scope: "private" | "public";
62
+ readonly staleWhileRevalidate?: number;
63
+ readonly tag?: string;
64
+ readonly vary?: string;
65
+ }
2
66
  /** Map of validators describing a function's args record. Alias of `@lunora/values`' shared {@link ValidatorMap}. */
3
67
  type ArgsValidator = ValidatorMap;
4
68
  /** Infer the args object type from an {@link ArgsValidator}. Alias of `@lunora/values`' shared {@link InferValidatorMap}. */
@@ -6,12 +70,12 @@ type InferArgs<A extends ArgsValidator> = InferValidatorMap<A>;
6
70
  /** Storage backend for a `.global()` table: D1 (default) or a Postgres/MySQL database via Cloudflare Hyperdrive (PlanetScale, Neon, …). */
7
71
  type GlobalBackend = "d1" | "hyperdrive";
8
72
  /**
9
- * Cloudflare Durable Object data-residency jurisdiction declared via
10
- * `defineSchema(...).jurisdiction("…")`. Restricts where every DO the app
11
- * reaches runs and persists data (GDPR, FedRAMP, US data residency). Widening
12
- * union — Cloudflare adds values over time.
13
- * @see https://developers.cloudflare.com/durable-objects/reference/data-location/
14
- */
73
+ * Cloudflare Durable Object data-residency jurisdiction declared via
74
+ * `defineSchema(...).jurisdiction("…")`. Restricts where every DO the app
75
+ * reaches runs and persists data (GDPR, FedRAMP, US data residency). Widening
76
+ * union — Cloudflare adds values over time.
77
+ * @see https://developers.cloudflare.com/durable-objects/reference/data-location/
78
+ */
15
79
  type DurableObjectJurisdiction = "eu" | "fedramp" | "us";
16
80
  /** How a table is routed at runtime. */
17
81
  type ShardMode = {
@@ -23,31 +87,181 @@ type ShardMode = {
23
87
  } | {
24
88
  kind: "root";
25
89
  };
90
+ /** Poll cadence for a sourced table — `"manual"` (pull only on an explicit trigger) or a fixed interval. */
91
+ type ExternalSourceRefresh = "manual" | {
92
+ everyMs: number;
93
+ };
94
+ /**
95
+ * Delete-detection mode for external-source ingest (plan 077 / 136).
96
+ *
97
+ * `"full-pull"` (the default) reads the **whole** tenant membership each tick and
98
+ * diffs it, so it observes upstream deletes for free — but costs a full read per tick
99
+ * (the Phase-0 bench put the ceiling at ~10k rows).
100
+ *
101
+ * `"incremental"` pulls **only rows past a durable watermark** (`cursor`), cheap for
102
+ * large low-churn tables above the full-pull cap. Because an absent row then means
103
+ * "unchanged", not "deleted", incremental requires a delete-visibility path: either a
104
+ * `reconcileEveryMs` periodic full-pull sweep, or a `softDeleteColumn` whose
105
+ * tombstones the pull returns. `defineSchema` throws (and the
106
+ * `external_source_incremental_no_delete_path` advisor lint fails the build) when an
107
+ * incremental source declares neither.
108
+ */
109
+ type ExternalSourceMode = "full-pull" | "incremental";
110
+ /**
111
+ * Incremental-ingest cursor (plan 136): the monotonic watermark column plus the
112
+ * watermark-parameterized pull query. `column` names the field in the pulled rows
113
+ * whose max becomes the next watermark (e.g. `"updated_at"`). `query` is a second
114
+ * SQL that returns only rows changed since the watermark — the watermark binds as
115
+ * the parameter AFTER `tenantBy`'s params (e.g. Postgres
116
+ * `... WHERE tenant_id = $1 AND updated_at >= $2 ORDER BY updated_at`). Prefer `>=`
117
+ * with the idempotent upsert apply so rows sharing the boundary timestamp are never
118
+ * skipped (re-pulling them is a no-op).
119
+ */
120
+ interface ExternalSourceCursor {
121
+ /** The monotonic watermark column in the pulled rows; its max advances the stored watermark. */
122
+ column: string;
123
+ /** The incremental pull SQL. `tenantBy`'s params bind first, then the watermark as the trailing param. */
124
+ query: string;
125
+ }
126
+ /**
127
+ * Config for `.source(...)` (plan 077): declares a table as **materialized from an
128
+ * external Postgres/MySQL behind Cloudflare Hyperdrive**, not written by user
129
+ * mutations. A system-driven poll loop reads the tenant slice and lands it in the
130
+ * DO's SQLite (via the validated CDC writer), after which `defineShape` carries it
131
+ * to clients unchanged. Orthogonal to `shardMode` — a sourced table almost always
132
+ * also `.shardBy()`s, in which case `tenantBy` is the mandatory tenant-isolation
133
+ * boundary (enforced by the `external_source_unscoped` advisor lint).
134
+ */
135
+ interface ExternalSourceDefinition {
136
+ /** The wrangler Hyperdrive binding name the poll loop reads from. */
137
+ binding: string;
138
+ /** Project the materialized rows to these columns (passed to the membership diff). Omit ⇒ the full mapped document. */
139
+ columns?: ReadonlyArray<string>;
140
+ /** **Required for `mode: "incremental"`**: the watermark column + watermark-parameterized pull query (plan 136). Rejected on a `"full-pull"` source. */
141
+ cursor?: ExternalSourceCursor;
142
+ /** Column whose value becomes the Lunora `_id`. Defaults to `"id"`. */
143
+ idColumn?: string;
144
+ /** Transform an external row into the stored document body. Omit ⇒ every selected column except `idColumn` is copied. */
145
+ map?: (row: Record<string, unknown>) => Record<string, unknown>;
146
+ /** Delete-detection mode. `"full-pull"` (the default) diffs the whole membership; `"incremental"` pulls past a `cursor` watermark. */
147
+ mode?: ExternalSourceMode;
148
+ /** The full tenant-membership query, with driver-native placeholders (`$1` / `?`). `tenantBy` binds its params. */
149
+ query: string;
150
+ /**
151
+ * **Incremental delete-visibility (plan 136)**: run a full-pull sweep at most
152
+ * this often (millis) to GC upstream deletes an incremental slice can't see.
153
+ * One of `reconcileEveryMs` / `softDeleteColumn` is required for incremental;
154
+ * rejected on a `"full-pull"` source.
155
+ */
156
+ reconcileEveryMs?: number;
157
+ /** Poll cadence, or `"manual"`. Omit ⇒ the runtime's size-scaled default. */
158
+ refresh?: ExternalSourceRefresh;
159
+ /**
160
+ * **Incremental delete-visibility (plan 136)**: the upstream soft-delete
161
+ * tombstone column (e.g. `"deleted_at"`). When set, the incremental pull must
162
+ * return tombstoned rows and the ingest turns each into a local delete — an
163
+ * alternative to `reconcileEveryMs`. Rejected on a `"full-pull"` source.
164
+ */
165
+ softDeleteColumn?: string;
166
+ /**
167
+ * **Mandatory under `.shardBy()`**: map this DO's shard key → the query's bound
168
+ * params, so a tenant DO can only ever pull its own rows. An unscoped sourced +
169
+ * sharded table replicates the whole multitenant table into every shard — the
170
+ * `external_source_unscoped` advisor lint fails the build when this is absent.
171
+ */
172
+ tenantBy?: (shardKey: string) => ReadonlyArray<unknown>;
173
+ }
26
174
  interface IndexDefinition {
27
175
  fields: ReadonlyArray<string>;
28
176
  name: string;
29
177
  unique?: boolean;
30
178
  }
31
179
  interface SearchIndexDefinition {
180
+ /** Indexed text column; a dot-separated path (`"properties.name"`) reads a nested field. */
32
181
  field: string;
182
+ /** Columns `.eq()` may narrow by inside the search builder. At most 16. */
33
183
  filterFields?: ReadonlyArray<string>;
184
+ /**
185
+ * Text analysis for this index. Accent folding is always applied — it is
186
+ * what makes `café` and `cafe` the same token on every backend, which they
187
+ * otherwise are not. Naming a language additionally drops that language's
188
+ * stopwords from both documents and queries.
189
+ *
190
+ * Analysis is baked into the stored index, so changing this rebuilds it:
191
+ * the runtime records which profile a companion was built with and
192
+ * re-indexes when it no longer matches.
193
+ */
194
+ language?: SearchLanguage;
195
+ name: string;
196
+ /**
197
+ * Skip the migration-time backfill. By default, creating the index's
198
+ * companion also indexes the rows already in the table, so a search index
199
+ * added to a populated table works immediately. On a very large table that
200
+ * scan is expensive to run inside a deploy: `staged: true` maintains the
201
+ * index on write only and leaves the initial population to an out-of-band
202
+ * `backfillSearchIndexes`.
203
+ */
204
+ staged?: boolean;
205
+ /**
206
+ * How the index is stored and matched.
207
+ *
208
+ * `"portable"` (default) keeps one implementation on every backend, with
209
+ * identical matching *and* ranking — the invariant the rest of the search
210
+ * docs rest on.
211
+ *
212
+ * `"native"` hands matching to the engine's own full-text index where it has
213
+ * one (Postgres `tsvector` + GIN today; ignored elsewhere). It scales far
214
+ * better on large corpora and common terms, because the portable path
215
+ * aggregates every matching token row before ranking. The trade: the engine
216
+ * ranks, so results are ordered by its formula rather than the shared
217
+ * scorer. Matching still agrees — the vector is built from the same analyzed
218
+ * tokens — but a `.global()` table using it will not return rows in the same
219
+ * order as the sharded twin.
220
+ */
221
+ strategy?: SearchStrategy;
222
+ }
223
+ /**
224
+ * A geospatial index declared via `.geoIndex(name, { field })`. The runtime
225
+ * maintains a geohash companion table over the `v.geoPoint()` column `field` so
226
+ * `withGeoIndex(name, q => q.near(point, radius) | q.within(bbox))` resolves a
227
+ * proximity / bounding-box read as a geohash-prefix range scan plus a Haversine
228
+ * refine/sort on the candidate rows.
229
+ *
230
+ * - `field` — the `v.geoPoint()` column whose lat/lng feed the geohash.
231
+ * - `precision` — geohash character length on the companion (default 9, ~4.8 m cells); higher precision narrows each cell.
232
+ */
233
+ interface GeoIndexDefinition {
234
+ field: string;
34
235
  name: string;
236
+ precision?: number;
237
+ }
238
+ /**
239
+ * Declarative table-level TTL declared via `.ttl(field, { after? })`. A DO
240
+ * alarm-driven sweep deletes (or, when the table also `.softDelete()`s,
241
+ * soft-deletes) rows whose expiry timestamp has passed.
242
+ *
243
+ * - `field` — an epoch-millisecond column. Without `after`, its value is the absolute expiry instant; with `after`, `field` is a base timestamp and the row expires `after` milliseconds later (`field + after`).
244
+ * - `after` — optional millisecond offset added to `field` to derive the expiry.
245
+ */
246
+ interface TtlDefinition {
247
+ after?: number;
248
+ field: string;
35
249
  }
36
250
  /** Reducer applied by an aggregate index. */
37
251
  type AggregateOp = "avg" | "count" | "max" | "min" | "sum";
38
252
  /**
39
- * Declared aggregate index — the schema-level seam that lets the runtime keep
40
- * O(1) counters/sums in step with row writes (via the trigger runner) and
41
- * route matching reads through them.
42
- *
43
- * - `on` — the table whose rows feed the aggregate.
44
- * - `op` — the reducer. `count` is field-less; the others take `field`.
45
- * - `field` — the column the reducer applies to (required for non-count ops).
46
- * - `by` — group keys. When all `where` keys in a read participate in `by`, the
47
- * reader can answer from the counter table without scanning rows.
48
- * - `where` — optional static predicate baked into the counter (only the rows
49
- * matching it ever land in the counter).
50
- */
253
+ * Declared aggregate index — the schema-level seam that lets the runtime keep
254
+ * O(1) counters/sums in step with row writes (via the trigger runner) and
255
+ * route matching reads through them.
256
+ *
257
+ * - `on` — the table whose rows feed the aggregate.
258
+ * - `op` — the reducer. `count` is field-less; the others take `field`.
259
+ * - `field` — the column the reducer applies to (required for non-count ops).
260
+ * - `by` — group keys. When all `where` keys in a read participate in `by`, the
261
+ * reader can answer from the counter table without scanning rows.
262
+ * - `where` — optional static predicate baked into the counter (only the rows
263
+ * matching it ever land in the counter).
264
+ */
51
265
  interface AggregateIndexDefinition {
52
266
  by?: ReadonlyArray<string>;
53
267
  field?: string;
@@ -57,32 +271,32 @@ interface AggregateIndexDefinition {
57
271
  where?: Record<string, unknown>;
58
272
  }
59
273
  /**
60
- * One ordering key on a `rankIndex.sortBy`: which column to sort by, and the
61
- * direction. The runtime breaks ties on the row's `_id` ASC so the order is
62
- * total and `rank()` always returns a deterministic 1-based position.
63
- */
274
+ * One ordering key on a `rankIndex.sortBy`: which column to sort by, and the
275
+ * direction. The runtime breaks ties on the row's `_id` ASC so the order is
276
+ * total and `rank()` always returns a deterministic 1-based position.
277
+ */
64
278
  interface RankSortKey {
65
279
  direction: "asc" | "desc";
66
280
  field: string;
67
281
  }
68
282
  /**
69
- * Declared rank index — a sorted companion table per `(partition tuple, sortBy)`
70
- * maintained by triggers, so:
71
- *
72
- * - `rank(row)` returns the row's 1-based position within its partition under
73
- * the declared `sortBy` order, plus the partition's total row count, in
74
- * O(log n) lookups against the SQLite btree on the companion table.
75
- * - `rankPage({ where, take, from })` walks the same companion table to return
76
- * rows in the declared order — a sorted-pagination accelerator.
77
- *
78
- * Fields mirror `AggregateIndexDefinition`:
79
- *
80
- * - `on` — the source table whose rows feed the rank.
81
- * - `sortBy` — ordered keys driving the rank. Required.
82
- * - `partitionBy` — columns that scope each rank context (e.g. `["channelId"]`
83
- * to rank within a channel). Omitted ⇒ one global rank across the table.
84
- * - `where` — static predicate baked into the index; only matching rows enter.
85
- */
283
+ * Declared rank index — a sorted companion table per `(partition tuple, sortBy)`
284
+ * maintained by triggers, so:
285
+ *
286
+ * - `rank(row)` returns the row's 1-based position within its partition under
287
+ * the declared `sortBy` order, plus the partition's total row count, in
288
+ * O(log n) lookups against the SQLite btree on the companion table.
289
+ * - `rankPage({ where, take, from })` walks the same companion table to return
290
+ * rows in the declared order — a sorted-pagination accelerator.
291
+ *
292
+ * Fields mirror `AggregateIndexDefinition`:
293
+ *
294
+ * - `on` — the source table whose rows feed the rank.
295
+ * - `sortBy` — ordered keys driving the rank. Required.
296
+ * - `partitionBy` — columns that scope each rank context (e.g. `["channelId"]`
297
+ * to rank within a channel). Omitted ⇒ one global rank across the table.
298
+ * - `where` — static predicate baked into the index; only matching rows enter.
299
+ */
86
300
  interface RankIndexDefinition {
87
301
  name: string;
88
302
  on: string;
@@ -93,16 +307,16 @@ interface RankIndexDefinition {
93
307
  /** FK behavior when a referenced parent row is deleted (mirrors SQL `ON DELETE`). */
94
308
  type OnDeleteAction = "cascade" | "restrict" | "set null";
95
309
  /**
96
- * A declared relation between two tables, recorded by `.relations((r) => …)`.
97
- *
98
- * - `one` (many-to-one): the FK column `field` lives on **this** table and
99
- * points at `table`.`references` (default `_id`). Loads a single doc.
100
- * - `many` (one-to-many): the FK column `field` lives on the **target** table
101
- * and points back at this table's `references` (default `_id`). Loads an array.
102
- *
103
- * `onDelete` is meaningful only on `one`: it is the action applied to the
104
- * holder rows when the referenced parent row is deleted.
105
- */
310
+ * A declared relation between two tables, recorded by `.relations((r) => …)`.
311
+ *
312
+ * - `one` (many-to-one): the FK column `field` lives on **this** table and
313
+ * points at `table`.`references` (default `_id`). Loads a single doc.
314
+ * - `many` (one-to-many): the FK column `field` lives on the **target** table
315
+ * and points back at this table's `references` (default `_id`). Loads an array.
316
+ *
317
+ * `onDelete` is meaningful only on `one`: it is the action applied to the
318
+ * holder rows when the referenced parent row is deleted.
319
+ */
106
320
  interface RelationDefinition {
107
321
  field: string;
108
322
  kind: "many" | "one";
@@ -113,15 +327,15 @@ interface RelationDefinition {
113
327
  /** Distance metric used by a Vectorize index. */
114
328
  type VectorMetric = "cosine" | "dot-product" | "euclidean";
115
329
  /**
116
- * Bring-your-own-embedder: a user-supplied fn turning a source string into a
117
- * numeric vector. The runtime calls it at upsert/query time so the framework
118
- * never couples to a single embedding provider.
119
- */
330
+ * Bring-your-own-embedder: a user-supplied fn turning a source string into a
331
+ * numeric vector. The runtime calls it at upsert/query time so the framework
332
+ * never couples to a single embedding provider.
333
+ */
120
334
  type VectorEmbedder = (input: string) => Promise<ReadonlyArray<number>> | ReadonlyArray<number>;
121
335
  /**
122
- * Vector index declared inline on a table via `.vectorize(field, opts)`
123
- * (DSL Shape A). The source is always a single column on the owning table.
124
- */
336
+ * Vector index declared inline on a table via `.vectorize(field, opts)`
337
+ * (DSL Shape A). The source is always a single column on the owning table.
338
+ */
125
339
  interface TableVectorIndex {
126
340
  dimensions: number;
127
341
  embed: VectorEmbedder;
@@ -132,74 +346,110 @@ interface TableVectorIndex {
132
346
  }
133
347
  interface TableDefinition<Shape extends Record<string, Validator> = Record<string, Validator>> {
134
348
  /**
135
- * Aggregate indexes declared via `.aggregateIndex(name, opts)`. The runtime
136
- * maintains a counter row per `by` group via the trigger seam, so reads
137
- * whose `where` keys all participate in the index's `by` set are answered
138
- * without scanning the underlying table.
139
- */
349
+ * Aggregate indexes declared via `.aggregateIndex(name, opts)`. The runtime
350
+ * maintains a counter row per `by` group via the trigger seam, so reads
351
+ * whose `where` keys all participate in the index's `by` set are answered
352
+ * without scanning the underlying table.
353
+ */
140
354
  aggregateIndexes: ReadonlyArray<AggregateIndexDefinition>;
355
+ /**
356
+ * Set by `.source(...)` (named `externalSource`, not `source`, so the data
357
+ * field doesn't collide with the fluent `.source()` builder method — same
358
+ * convention as `shardBy()`/`shardMode`). When present, the table is
359
+ * materialized from an external Hyperdrive-backed database by a system poll
360
+ * loop rather than user mutations. Implies `isExternallyManaged`.
361
+ */
362
+ externalSource?: ExternalSourceDefinition;
363
+ /**
364
+ * Geospatial indexes declared via `.geoIndex(name, { field })`. The runtime
365
+ * maintains a geohash companion over the named `v.geoPoint()` column so
366
+ * `withGeoIndex(name, q => q.near(point, radius) | q.within(bbox))` resolves
367
+ * a proximity/bounding-box read as a geohash-prefix range scan plus a
368
+ * Haversine refine/sort. Empty unless `.geoIndex()` was called.
369
+ */
370
+ geoIndexes: ReadonlyArray<GeoIndexDefinition>;
141
371
  indexes: ReadonlyArray<IndexDefinition>;
142
372
  /**
143
- * `true` when `.externallyManaged()` was called — the table's rows are
144
- * written outside Lunora's discoverable insert path (an adapter, a
145
- * migration, or framework middleware), e.g. `@lunora/auth`'s better-auth
146
- * tables or `@lunora/ratelimit`'s store. Advisor insert-path lints
147
- * (`table_without_insert`) skip such tables instead of flagging the absent
148
- * `ctx.db.insert(...)`.
149
- */
373
+ * `true` when `.externallyManaged()` was called — the table's rows are
374
+ * written outside Lunora's discoverable insert path (an adapter, a
375
+ * migration, or framework middleware), e.g. `@lunora/auth`'s better-auth
376
+ * tables or `@lunora/ratelimit`'s store. Advisor insert-path lints
377
+ * (`table_without_insert`) skip such tables instead of flagging the absent
378
+ * `ctx.db.insert(...)`.
379
+ */
150
380
  isExternallyManaged?: boolean;
151
381
  /**
152
- * `true` when `.public()` was called — the table opts OUT of secure-by-default
153
- * RLS. Under a schema marked `.rls("required")`, every table is protected (the
154
- * DO/D1 write path denies raw, non-RLS `ctx.db` access) UNLESS it is `isPublic`.
155
- * Has no effect when the schema does not require RLS.
156
- */
382
+ * `true` when `.public()` was called — the table opts OUT of secure-by-default
383
+ * RLS. Under a schema marked `.rls("required")`, every table is protected (the
384
+ * DO/D1 write path denies raw, non-RLS `ctx.db` access) UNLESS it is `isPublic`.
385
+ * Has no effect when the schema does not require RLS.
386
+ */
157
387
  isPublic?: boolean;
158
388
  /**
159
- * Rank indexes declared via `.rankIndex(name, opts)`. The runtime maintains
160
- * a sorted companion table per declared rank with a btree on
161
- * `(partition, sortBy)` so `rank(row)` returns the row's 1-based position
162
- * within its partition in O(log n), and `rankPage()` walks the index for
163
- * sorted pagination.
164
- */
389
+ * Set by `.ownedBy(field)` the column holding the owning user's id (named
390
+ * `ownerField`, a data field, rather than colliding with the fluent
391
+ * `.ownedBy()` builder method same convention as `shardBy()`/`shardMode`).
392
+ *
393
+ * A shape over this table with `owner: true` derives its predicate from this
394
+ * field, so "only the owner may replicate these rows" is declared once on the
395
+ * table instead of being restated in every shape's `where`. Absent ⇒ the table
396
+ * has no single owning column and a shape must spell its predicate out.
397
+ */
398
+ ownerField?: string;
399
+ /**
400
+ * Rank indexes declared via `.rankIndex(name, opts)`. The runtime maintains
401
+ * a sorted companion table per declared rank with a btree on
402
+ * `(partition, sortBy)` so `rank(row)` returns the row's 1-based position
403
+ * within its partition in O(log n), and `rankPage()` walks the index for
404
+ * sorted pagination.
405
+ */
165
406
  rankIndexes: ReadonlyArray<RankIndexDefinition>;
166
407
  /**
167
- * Declared relations keyed by accessor name; empty unless `.relations()`
168
- * was called. Named `relationMap` (not `relations`) so the fluent
169
- * `.relations((r) => …)` builder method doesn't collide with this field.
170
- */
408
+ * Declared relations keyed by accessor name; empty unless `.relations()`
409
+ * was called. Named `relationMap` (not `relations`) so the fluent
410
+ * `.relations((r) => …)` builder method doesn't collide with this field.
411
+ */
171
412
  relationMap: Record<string, RelationDefinition>;
172
413
  searchIndexes: ReadonlyArray<SearchIndexDefinition>;
173
414
  shape: Shape;
174
415
  shardMode: ShardMode;
175
416
  /**
176
- * Set by `.softDelete()` (named `softDeleteMode`, not `softDelete`, so the
177
- * data field doesn't collide with the fluent `.softDelete()` builder method —
178
- * same convention as `shardBy()`/`shardMode`). When present, the table carries
179
- * a nullable timestamp column (`field`, default `deletedAt`):
180
- * `ctx.db.&lt;table>.delete()` flips it instead of physically removing the row,
181
- * and **list reads** (`findMany`/`findFirst`/`query()`/`count`/`aggregate`/
182
- * relation loads) hide rows whose `field` is set unless
183
- * `includeDeleted: true` is passed. By-id `get`/`patch`/`replace` and
184
- * `restore` are unaffected. Absent ⇒ deletes are physical, as before.
185
- */
417
+ * Set by `.softDelete()` (named `softDeleteMode`, not `softDelete`, so the
418
+ * data field doesn't collide with the fluent `.softDelete()` builder method —
419
+ * same convention as `shardBy()`/`shardMode`). When present, the table carries
420
+ * a nullable timestamp column (`field`, default `deletedAt`):
421
+ * `ctx.db.&lt;table>.delete()` flips it instead of physically removing the row,
422
+ * and **list reads** (`findMany`/`findFirst`/`query()`/`count`/`aggregate`/
423
+ * relation loads) hide rows whose `field` is set unless
424
+ * `includeDeleted: true` is passed. By-id `get`/`patch`/`replace` and
425
+ * `restore` are unaffected. Absent ⇒ deletes are physical, as before.
426
+ */
186
427
  softDeleteMode?: {
187
428
  field: string;
188
429
  };
189
430
  /**
190
- * Declared lifecycle triggers keyed by accessor name; empty unless
191
- * `.triggers()` was called. Named `triggerMap` (not `triggers`) so the
192
- * fluent `.triggers((t) => …)` builder method doesn't collide with this
193
- * field — same reasoning as {@link TableDefinition.relationMap}.
194
- */
431
+ * Declared lifecycle triggers keyed by accessor name; empty unless
432
+ * `.triggers()` was called. Named `triggerMap` (not `triggers`) so the
433
+ * fluent `.triggers((t) => …)` builder method doesn't collide with this
434
+ * field — same reasoning as {@link TableDefinition.relationMap}.
435
+ */
195
436
  triggerMap: Record<string, TriggerDefinition>;
437
+ /**
438
+ * Set by `.ttl(field, { after })` — the declarative auto-expiry policy. A DO
439
+ * alarm-driven sweep deletes rows whose expiry timestamp has passed (or
440
+ * soft-deletes them when the table also `.softDelete()`s). Named `ttlPolicy`
441
+ * (a data field) rather than colliding with the fluent `.ttl()` builder
442
+ * method — same convention as `shardBy()`/`shardMode`. Absent ⇒ rows never
443
+ * auto-expire.
444
+ */
445
+ ttlPolicy?: TtlDefinition;
196
446
  vectorIndexes: ReadonlyArray<TableVectorIndex>;
197
447
  }
198
448
  /**
199
- * Standalone vector index declared via `defineVectorIndex(...)` (DSL Shape B).
200
- * Unlike {@link TableVectorIndex}, the source is a `select` function so it can
201
- * derive the embedded text from any computation (e.g. `title + body`).
202
- */
449
+ * Standalone vector index declared via `defineVectorIndex(...)` (DSL Shape B).
450
+ * Unlike {@link TableVectorIndex}, the source is a `select` function so it can
451
+ * derive the embedded text from any computation (e.g. `title + body`).
452
+ */
203
453
  interface VectorIndexDefinition {
204
454
  readonly dimensions: number;
205
455
  readonly embed: VectorEmbedder;
@@ -211,50 +461,164 @@ interface VectorIndexDefinition {
211
461
  }
212
462
  interface Schema<T extends Record<string, TableDefinition> = Record<string, TableDefinition>> {
213
463
  /**
214
- * Secure-by-default RLS mode declared via `.rls("required")`. When
215
- * `"required"`, every table is protected: the DO/D1 write path denies raw
216
- * (non-RLS-wrapped) `ctx.db` access at runtime, so a procedure that forgets
217
- * `.use(rls(...))` fails closed instead of silently exposing the table. A
218
- * table opts out with `.public()` (→ {@link TableDefinition.isPublic}).
219
- * Absent ⇒ legacy opt-in behavior (RLS only where a policy is applied).
220
- */
464
+ * Secure-by-default RLS mode declared via `.rls("required")`. When
465
+ * `"required"`, every table is protected: the DO/D1 write path denies raw
466
+ * (non-RLS-wrapped) `ctx.db` access at runtime, so a procedure that forgets
467
+ * `.use(rls(...))` fails closed instead of silently exposing the table. A
468
+ * table opts out with `.public()` (→ {@link TableDefinition.isPublic}).
469
+ * Absent ⇒ legacy opt-in behavior (RLS only where a policy is applied).
470
+ */
221
471
  readonly rlsMode?: "required";
222
472
  readonly tables: T;
223
473
  readonly vectorIndexes: Record<string, VectorIndexDefinition>;
224
474
  }
225
475
  type FunctionKind = "action" | "mutation" | "query" | "stream";
226
476
  /**
227
- * Call surface a function is exposed on. `public` functions are reachable from
228
- * clients via the generated `api`; `internal` functions are reachable only
229
- * server-to-server (`ctx.runQuery`/`runMutation`/`runAction`) and are rejected
230
- * by the DO's external RPC path. Absence is treated as `public` for
231
- * back-compat with functions registered before visibility existed.
232
- */
477
+ * Call surface a function is exposed on. `public` functions are reachable from
478
+ * clients via the generated `api`; `internal` functions are reachable only
479
+ * server-to-server (`ctx.runQuery`/`runMutation`/`runAction`) and are rejected
480
+ * by the DO's external RPC path. Absence is treated as `public` for
481
+ * back-compat with functions registered before visibility existed.
482
+ */
233
483
  type FunctionVisibility = "internal" | "public";
484
+ /**
485
+ * x402 payment tag attached by the `.x402({ price })` builder modifier. Marks a
486
+ * public procedure as paid: the origin worker answers an unpaid client RPC with
487
+ * HTTP 402, verifies + settles the payment, and only then dispatches to the
488
+ * shard. The runtime reads only `price` from here — the network, recipient, and
489
+ * facilitator live in the worker-level x402 charge config, so `@lunora/runtime`
490
+ * never has to import `@lunora/x402` (and its viem/solana deps).
491
+ */
492
+ interface X402ProcedureConfig {
493
+ /**
494
+ * USD-denominated price: a number of dollars (`0.01`) or a decimal string
495
+ * (`"0.01"`, or the `"$0.01"` shorthand). Resolved to the network
496
+ * stablecoin's base units (USDC has 6 decimals) at challenge time.
497
+ */
498
+ readonly price: number | string;
499
+ }
500
+ /**
501
+ * HTTP caching for an exposed REST endpoint, declared as
502
+ * `.expose({ rest: true, cache: { scope: "public", maxAge: 60 } })`. The runtime
503
+ * turns this into `Cache-Control` / `Cache-Tag` / `Vary` response headers, and a
504
+ * `cache.tag` is purgeable through `ctx.cache.purge({ tags: [...] })`.
505
+ *
506
+ * This is NOT equivalent to `httpRoute(...).cacheControl()`, which writes whatever
507
+ * value the author passes with no credential downgrade. That is the unguarded
508
+ * escape hatch; this is the guarded surface.
509
+ *
510
+ * Caching a procedure whose result depends on the caller is how a REST cache
511
+ * turns into a data leak, so `scope` is enforced at runtime rather than trusted:
512
+ * a request that carries credentials (an `Authorization` header or any `Cookie`)
513
+ * is ALWAYS answered `private`, even under `scope: "public"` — see
514
+ * {@link https://developer.mozilla.org/docs/Web/HTTP/Headers/Cache-Control MDN}
515
+ * for what `private` forbids. So a per-user response can never be stored in a
516
+ * shared/edge cache, and the worst a mis-declared `scope` can cost is a missed
517
+ * cache hit. `scope: "public"` additionally emits `Vary: authorization, cookie`
518
+ * so an intermediary can't hand a stored anonymous variant to a signed-in caller.
519
+ *
520
+ * Only ever applied to a cacheable exchange: a `GET` (so `query` procedures — a
521
+ * `mutation` / `action` is `POST`-only) that produced a 2xx.
522
+ */
523
+ type RestCacheConfig = RestCachePolicy;
524
+ /**
525
+ * Opt-in public-surface tag attached by the `.expose({ rest: true })` builder
526
+ * modifier (plan 167). Marks a procedure as deliberately published over the
527
+ * public REST surface: the runtime mints a `/_lunora/rest/&lt;namespace>/&lt;fn>` route
528
+ * that dispatches THROUGH the procedure (so `ctx.auth` / RLS / validators are
529
+ * enforced), and the generated OpenAPI describes it. Everything is default-closed
530
+ * — a procedure without this tag is unreachable over REST.
531
+ */
532
+ interface ExposeConfig {
533
+ /** Opt this endpoint's responses into HTTP caching. Omit to leave them uncached. */
534
+ readonly cache?: RestCacheConfig;
535
+ /** Publish this procedure over the public REST surface. */
536
+ readonly rest?: boolean;
537
+ }
234
538
  interface RegisteredFunction<A extends ArgsValidator, R, Kind extends FunctionKind> {
235
539
  readonly args: A;
540
+ /**
541
+ * Set by the `.expose({ rest: true })` builder modifier. Marks the procedure
542
+ * as published on the public REST surface (plan 167). Absent on procedures that
543
+ * are reachable only via typed RPC (the default).
544
+ */
545
+ readonly expose?: ExposeConfig;
236
546
  readonly handler: (context: unknown, args: InferArgs<A>) => Promise<R> | R;
237
547
  readonly kind: Kind;
238
548
  /**
239
- * Set on connection-lifecycle hooks (`onConnect` / `onDisconnect`).
240
- * Marks the function for the generated `LUNORA_LIFECYCLE_HOOKS` manifest so the
241
- * DO dispatches it on socket connect/disconnect rather than via a client RPC.
242
- * Absent on ordinary registrations.
243
- */
549
+ * Set on connection-lifecycle hooks (`onConnect` / `onDisconnect`).
550
+ * Marks the function for the generated `LUNORA_LIFECYCLE_HOOKS` manifest so the
551
+ * DO dispatches it on socket connect/disconnect rather than via a client RPC.
552
+ * Absent on ordinary registrations.
553
+ */
244
554
  readonly lifecycle?: LifecycleEventKind;
555
+ /**
556
+ * Static per-procedure metadata declared with `.meta(...)`. Present so
557
+ * middleware (via `ctx.meta`) and tooling can read the same object; absent
558
+ * when the chain never called `.meta()`.
559
+ */
560
+ readonly meta?: Record<string, unknown>;
245
561
  readonly visibility?: FunctionVisibility;
562
+ /**
563
+ * Set by the `.x402({ price })` builder modifier. Marks the procedure as paid
564
+ * so the origin worker gates it behind an x402 402-challenge before dispatch.
565
+ * Absent on unpaid functions.
566
+ */
567
+ readonly x402?: X402ProcedureConfig;
246
568
  }
247
569
  type RegisteredQuery<A extends ArgsValidator, R> = RegisteredFunction<A, R, "query">;
248
570
  type RegisteredMutation<A extends ArgsValidator, R> = RegisteredFunction<A, R, "mutation">;
249
571
  type RegisteredAction<A extends ArgsValidator, R> = RegisteredFunction<A, R, "action">;
572
+ /**
573
+ * Structural mirror of `@lunora/client`'s `FunctionReference` — the handle the
574
+ * generated `api` / `internal` objects hand you, carrying `&lt;file>:&lt;function>`
575
+ * in `__lunoraRef`. Redeclared here so `@lunora/server` needs no dependency on
576
+ * the client package, exactly as {@link Scheduler} avoids one on
577
+ * `@lunora/scheduler`. `RegisteredFunction` has no `__lunoraRef`, so the two
578
+ * shapes never overlap.
579
+ */
580
+ interface FunctionHandle<Kind extends "action" | "mutation" | "query" | "stream", Args, Return> {
581
+ /** Phantom marker carrying the type parameters; never present at runtime. */
582
+ readonly __lunoraPhantom?: {
583
+ args: Args;
584
+ kind: Kind;
585
+ returns: Return;
586
+ };
587
+ readonly __lunoraRef: string;
588
+ }
589
+ /**
590
+ * `ctx.runQuery` — overloaded, see the note above.
591
+ *
592
+ * A single generic signature over the reference would be nicer (TS will not
593
+ * contextually type a parameter against a multi-signature type, so a hand-built
594
+ * ctx object must annotate its `(reference, args)` explicitly — see
595
+ * `@lunora/testing`'s harness). It does not work: a concrete
596
+ * `RegisteredQuery&lt;{…}, number>` is not assignable to a
597
+ * `RegisteredFunction&lt;ArgsValidator, …>` constraint, because `handler`'s args
598
+ * are in a contravariant position. Two inference sites it is.
599
+ */
600
+ interface RunQuery {
601
+ <A extends ArgsValidator, R>(reference: RegisteredQuery<A, R>, args: InferArgs<A>): Promise<R>;
602
+ <Args, R>(reference: FunctionHandle<"query", Args, R>, args: Args): Promise<R>;
603
+ }
604
+ /** `ctx.runMutation` — overloaded for the same reason as {@link RunQuery}. */
605
+ interface RunMutation {
606
+ <A extends ArgsValidator, R>(reference: RegisteredMutation<A, R>, args: InferArgs<A>): Promise<R>;
607
+ <Args, R>(reference: FunctionHandle<"mutation", Args, R>, args: Args): Promise<R>;
608
+ }
609
+ /** `ctx.runAction` — overloaded for the same reason as {@link RunQuery}. */
610
+ interface RunAction {
611
+ <A extends ArgsValidator, R>(reference: RegisteredAction<A, R>, args: InferArgs<A>): Promise<R>;
612
+ <Args, R>(reference: FunctionHandle<"action", Args, R>, args: Args): Promise<R>;
613
+ }
250
614
  /** Which side of the WebSocket lifecycle a hook fires on. */
251
615
  type LifecycleEventKind = "connect" | "disconnect";
252
616
  /**
253
- * The event a connection-lifecycle hook receives as its second argument. It is
254
- * the JSON-serializable payload the DO forwards on socket connect/disconnect;
255
- * the verified caller identity is also reflected on `ctx.auth` (the hook runs
256
- * under the connecting user via `resolveIdentity`).
257
- */
617
+ * The event a connection-lifecycle hook receives as its second argument. It is
618
+ * the JSON-serializable payload the DO forwards on socket connect/disconnect;
619
+ * the verified caller identity is also reflected on `ctx.auth` (the hook runs
620
+ * under the connecting user via `resolveIdentity`).
621
+ */
258
622
  interface LifecycleEvent {
259
623
  /** Stable per-socket id, minted at upgrade and replayed verbatim on disconnect. */
260
624
  readonly connectionId: string;
@@ -266,20 +630,20 @@ interface LifecycleEvent {
266
630
  readonly userId: string | null;
267
631
  }
268
632
  /**
269
- * A registered connection-lifecycle hook — an internal mutation tagged with the
270
- * lifecycle side it fires on. Produced by `onConnect` / `onDisconnect`.
271
- */
633
+ * A registered connection-lifecycle hook — an internal mutation tagged with the
634
+ * lifecycle side it fires on. Produced by `onConnect` / `onDisconnect`.
635
+ */
272
636
  type RegisteredLifecycleHook = RegisteredFunction<Record<string, never>, void, "mutation"> & {
273
637
  readonly lifecycle: LifecycleEventKind;
274
638
  };
275
639
  /**
276
- * A streaming query registration. Unlike {@link RegisteredFunction} the handler
277
- * returns an `AsyncIterable&lt;R>` synchronously (it does NOT `Promise&lt;R>`); the
278
- * runtime drives it frame by frame and forwards each chunk to the caller. The
279
- * third `signal` argument is wired to the caller's cancel signal so the handler
280
- * can stop early — break out of the loop or check `signal.aborted` between
281
- * yields.
282
- */
640
+ * A streaming query registration. Unlike {@link RegisteredFunction} the handler
641
+ * returns an `AsyncIterable&lt;R>` synchronously (it does NOT `Promise&lt;R>`); the
642
+ * runtime drives it frame by frame and forwards each chunk to the caller. The
643
+ * third `signal` argument is wired to the caller's cancel signal so the handler
644
+ * can stop early — break out of the loop or check `signal.aborted` between
645
+ * yields.
646
+ */
283
647
  interface RegisteredStream<A extends ArgsValidator, R> {
284
648
  readonly args: A;
285
649
  readonly handler: (context: unknown, args: InferArgs<A>, signal: AbortSignal) => AsyncIterable<R>;
@@ -289,10 +653,10 @@ interface RegisteredStream<A extends ArgsValidator, R> {
289
653
  /** The system tables `ctx.db.system` can read. */
290
654
  type SystemTableName = "_scheduled_functions" | "_storage";
291
655
  /**
292
- * A pending scheduled invocation as surfaced by the `_scheduled_functions`
293
- * system table. Mirrors {@link ScheduledJob} (the `ctx.scheduler` view); the
294
- * separate name keeps the system-table read surface self-describing.
295
- */
656
+ * A pending scheduled invocation as surfaced by the `_scheduled_functions`
657
+ * system table. Mirrors {@link ScheduledJob} (the `ctx.scheduler` view); the
658
+ * separate name keeps the system-table read surface self-describing.
659
+ */
296
660
  interface ScheduledFunctionDoc {
297
661
  /** Function arguments the job will be dispatched with. */
298
662
  args: Record<string, unknown>;
@@ -322,55 +686,79 @@ interface SystemQuery<T extends SystemTableName> {
322
686
  collect: () => Promise<SystemDoc<T>[]>;
323
687
  }
324
688
  /**
325
- * Read-only reader over Lunora's system tables (`_scheduled_functions`,
326
- * `_storage`), exposed as `ctx.db.system`. Mirrors Convex's `ctx.db.system`.
327
- *
328
- * **Best-effort and eventually consistent.** Unlike `ctx.db.&lt;table>` — which
329
- * reads the shard's transactional SQLite snapshot — the data behind these tables
330
- * lives OUTSIDE the shard (scheduled functions in the `SchedulerDO`, storage
331
- * objects in R2). Every `collect()` / `get()` reaches across to that source.
332
- *
333
- * It is **not part of the mutation transaction snapshot** (no OCC guard, no
334
- * subscription dependency recorded — reading it inside a mutation does not pin
335
- * it), and results are **eventually consistent** with writes a mutation just
336
- * made (e.g. a freshly scheduled job may not appear yet).
337
- *
338
- * Read-only by design: mutate scheduled jobs via `ctx.scheduler`, storage
339
- * objects via `ctx.storage`.
340
- */
689
+ * Read-only reader over Lunora's system tables (`_scheduled_functions`,
690
+ * `_storage`), exposed as `ctx.db.system`. Mirrors Convex's `ctx.db.system`.
691
+ *
692
+ * **Best-effort and eventually consistent.** Unlike `ctx.db.&lt;table>` — which
693
+ * reads the shard's transactional SQLite snapshot — the data behind these tables
694
+ * lives OUTSIDE the shard (scheduled functions in the `SchedulerDO`, storage
695
+ * objects in R2). Every `collect()` / `get()` reaches across to that source.
696
+ *
697
+ * It is **not part of the mutation transaction snapshot** (no OCC guard, no
698
+ * subscription dependency recorded — reading it inside a mutation does not pin
699
+ * it), and results are **eventually consistent** with writes a mutation just
700
+ * made (e.g. a freshly scheduled job may not appear yet).
701
+ *
702
+ * Read-only by design: mutate scheduled jobs via `ctx.scheduler`, storage
703
+ * objects via `ctx.storage`.
704
+ */
341
705
  interface SystemDatabaseReader {
342
706
  /**
343
- * Resolve a single system-table row by id, or `null` when absent.
344
- * (`_scheduled_functions` → job id; `_storage` → object key.)
345
- */
707
+ * Resolve a single system-table row by id, or `null` when absent.
708
+ * (`_scheduled_functions` → job id; `_storage` → object key.)
709
+ */
346
710
  get: <T extends SystemTableName>(table: T, id: string) => Promise<SystemDoc<T> | null>;
347
711
  /**
348
- * Begin a read over a system table; call `.collect()` to resolve the full
349
- * list. No filtering, indexing, or pagination — the backing source is remote
350
- * and the surface stays deliberately minimal.
351
- */
712
+ * Begin a read over a system table; call `.collect()` to resolve the full
713
+ * list. No filtering, indexing, or pagination — the backing source is remote
714
+ * and the surface stays deliberately minimal.
715
+ */
352
716
  query: <T extends SystemTableName>(table: T) => SystemQuery<T>;
353
717
  }
354
718
  /**
355
- * Read-only handle bound to a table. Used by `query`/`mutation`/`action`. The
356
- * actual SQL implementation lives in `@lunora/do`; these are signatures only.
357
- */
719
+ * Read-only handle bound to a table. Used by `query`/`mutation`/`action`. The
720
+ * actual SQL implementation lives in `@lunora/do`; these are signatures only.
721
+ */
358
722
  interface DatabaseReader {
723
+ /**
724
+ * The throwing sibling of {@link DatabaseReader.normalizeId}: brand `id` as an
725
+ * {@link Id} for `tableName`, or throw `BAD_REQUEST` when it is not structurally
726
+ * an id. Pure — it never reads the database, so a valid id for a row that
727
+ * doesn't exist still returns.
728
+ *
729
+ * This is the **parse boundary** for an id that arrived as a plain `string`: a
730
+ * wire payload, a mutator's args, a change plan computed on the client. The
731
+ * alternative is `value as Id&lt;"table">` at every such call site — an assertion,
732
+ * not a check, and one that has to be repeated for every table a helper is
733
+ * generic over:
734
+ *
735
+ * ```ts
736
+ * for (const patch of plan.patches) {
737
+ * await ctx.db.patch(ctx.db.asId("nodes", patch.id), patch.fields);
738
+ * }
739
+ * ```
740
+ *
741
+ * Ids are opaque strings, so the check is exactly `normalizeId`'s: it rejects
742
+ * empty, whitespace-bearing, and NUL-bearing values, not "an id that isn't in
743
+ * this table". Use it to get the brand honestly, and `get()` to learn whether
744
+ * the row exists.
745
+ */
746
+ asId: <T extends string>(tableName: T, id: string) => Id<T>;
359
747
  get: <T extends string>(id: Id<T>) => Promise<Record<string, unknown> | null>;
360
748
  /**
361
- * Validate an untrusted `id` string against the structural shape of an id
362
- * for `tableName`, returning the branded {@link Id} when it is well-formed
363
- * and `null` otherwise. Pure structural validation — it never reads the
364
- * database, so a structurally valid id for a row that doesn't exist still
365
- * returns the branded id (mirrors Convex's `db.normalizeId`).
366
- */
749
+ * Validate an untrusted `id` string against the structural shape of an id
750
+ * for `tableName`, returning the branded {@link Id} when it is well-formed
751
+ * and `null` otherwise. Pure structural validation — it never reads the
752
+ * database, so a structurally valid id for a row that doesn't exist still
753
+ * returns the branded id (mirrors Convex's `db.normalizeId`).
754
+ */
367
755
  normalizeId: <T extends string>(tableName: T, id: string) => Id<T> | null;
368
756
  query: (tableName: string) => TableReader;
369
757
  /**
370
- * Best-effort, read-only reader over Lunora's system tables
371
- * (`_scheduled_functions`, `_storage`). Eventually consistent and **not**
372
- * part of the transaction snapshot — see {@link SystemDatabaseReader}.
373
- */
758
+ * Best-effort, read-only reader over Lunora's system tables
759
+ * (`_scheduled_functions`, `_storage`). Eventually consistent and **not**
760
+ * part of the transaction snapshot — see {@link SystemDatabaseReader}.
761
+ */
374
762
  readonly system: SystemDatabaseReader;
375
763
  }
376
764
  /** Options for {@link TableReader.paginate} — Convex-compatible page request. */
@@ -378,14 +766,14 @@ interface PaginationOptions {
378
766
  /** Opaque cursor from the prior page's `continueCursor`; `null`/omitted starts at the first page. */
379
767
  cursor?: null | string;
380
768
  /**
381
- * Optional inclusive upper bound for reactive pagination. When supplied the
382
- * page covers the fixed half-open range `(cursor, endCursor]` (ignoring
383
- * `numItems`): every row strictly after `cursor` up to and including the
384
- * boundary row `endCursor` encodes. The page's `isDone` is `true` and its
385
- * `continueCursor` echoes `endCursor`, so the next page keeps starting where
386
- * this one ends even as rows are inserted/deleted inside the range. Omit (or
387
- * pass `null`) for the legacy "first `numItems` after `cursor`" behaviour.
388
- */
769
+ * Optional inclusive upper bound for reactive pagination. When supplied the
770
+ * page covers the fixed half-open range `(cursor, endCursor]` (ignoring
771
+ * `numItems`): every row strictly after `cursor` up to and including the
772
+ * boundary row `endCursor` encodes. The page's `isDone` is `true` and its
773
+ * `continueCursor` echoes `endCursor`, so the next page keeps starting where
774
+ * this one ends even as rows are inserted/deleted inside the range. Omit (or
775
+ * pass `null`) for the legacy "first `numItems` after `cursor`" behaviour.
776
+ */
389
777
  endCursor?: null | string;
390
778
  /** Maximum rows to return for this page. */
391
779
  numItems: number;
@@ -398,47 +786,106 @@ interface PaginationResult<T = Record<string, unknown>> {
398
786
  isDone: boolean;
399
787
  page: T[];
400
788
  /**
401
- * Reactive-pagination only: the midpoint cursor of a bounded
402
- * `(cursor, endCursor]` page, used by the client to split an over-grown page
403
- * into two adjacent ranges. Absent on legacy (open-ended) pages.
404
- */
789
+ * Reactive-pagination only: the midpoint cursor of a bounded
790
+ * `(cursor, endCursor]` page, used by the client to split an over-grown page
791
+ * into two adjacent ranges. Absent on legacy (open-ended) pages.
792
+ */
405
793
  splitCursor?: null | string;
406
794
  }
407
795
  /**
408
- * The fluent `ctx.db.query(table)` reader. Generic over the document type
409
- * `Row` so the generated `ctx.db` can bind it to `Doc&lt;table>` (the chain and
410
- * every terminal then resolve typed rows — no `as unknown as Doc&lt;...>` casts).
411
- * Defaults to the untyped `Record&lt;string, unknown>` shape for the base
412
- * (schema-agnostic) `@lunora/server` reader.
413
- */
414
- interface TableReader<Row = Record<string, unknown>> {
796
+ * The fluent `ctx.db.query(table)` reader. Generic over the document type
797
+ * `Row` so the generated `ctx.db` can bind it to `Doc&lt;table>` (the chain and
798
+ * every terminal then resolve typed rows — no `as unknown as Doc&lt;...>` casts),
799
+ * and over the table's declared index names so `.withIndex()` / `.withSearchIndex()`
800
+ * / `.withGeoIndex()` reject a name the table does not declare.
801
+ *
802
+ * All four default to the untyped shape for the base (schema-agnostic)
803
+ * `@lunora/server` reader, which is also the wide `(table: string) => TableReader`
804
+ * overload the generated `ctx.db` intersects in — so a caller holding a runtime
805
+ * string (e.g. `@lunora/ratelimit`'s `createDbStore`) is unaffected.
806
+ *
807
+ * The index-name parameters are what make a stale index name a compile error.
808
+ * They resolve to `never` for a table that declares none of that kind, so the
809
+ * only way to satisfy the call is to declare the index. Before this, a renamed
810
+ * or dropped index left its call sites typechecking, and the query either threw
811
+ * at runtime or silently degraded to a full table scan — the second being the
812
+ * worse outcome, since it stays green in tests and surfaces months later as a
813
+ * latency regression.
814
+ *
815
+ * **Why `with*` are method signatures and everything else is a property.**
816
+ * Narrowing a parameter makes the enclosing type contravariant in it, so as
817
+ * function properties these would make a BOUND reader
818
+ * (`TableReader&lt;Doc, "by_x">`, what `ctx.db.query(t)` returns) unassignable to
819
+ * the unbound `TableReader&lt;Doc>` — quietly breaking every helper factored as
820
+ * `(reader: TableReader&lt;Doc&lt;"users">>) => …`, which is the obvious way to share
821
+ * query logic. A method signature is bivariant in its parameters, which keeps
822
+ * that direction working while the narrow parameter still rejects an undeclared
823
+ * name at the call site. Both directions are pinned in `types.test-d.ts`.
824
+ */
825
+ interface TableReader<Row = Record<string, unknown>, Indexes extends string = string, SearchIndexes extends string = string, GeoIndexes extends string = string> {
826
+ /**
827
+ * Iterate rows lazily: `for await (const row of ctx.db.query("t").withIndex(…))`.
828
+ *
829
+ * Pages through the result set behind the scenes and yields row by row, so
830
+ * a consumer that stops early stops the reads too. `.collect()` is still the
831
+ * right terminal when you want the whole set; this exists for the cases
832
+ * where you cannot know up front how far you need to read.
833
+ *
834
+ * That is what merged/ordered index streams need. Reimplementing Convex's
835
+ * `convex-helpers/server/stream` in userland previously meant materialising
836
+ * each branch with a bounded `.take(1024)` before merging, so asking a
837
+ * merged stream for ONE row read up to 1,024 rows per branch. The k-way merge itself is application code and stays there — only
838
+ * the laziness had to come from the database layer.
839
+ *
840
+ * Iteration pages through `.paginate()`, so it follows the same order —
841
+ * which is `.collect()`'s order whenever the sort key is unique. Under a
842
+ * TIED sort key (an unindexed read whose rows share `_creationTime`) the
843
+ * two can disagree, because the tie-break is left to SQLite. Read through
844
+ * an index when order matters, exactly as you would for `.paginate()`.
845
+ */
846
+ [Symbol.asyncIterator]: () => AsyncIterator<Row>;
415
847
  collect: () => Promise<Row[]>;
416
- filter: (predicate: (document: Row) => boolean) => TableReader<Row>;
848
+ filter: (predicate: (document: Row) => boolean) => TableReader<Row, Indexes, SearchIndexes, GeoIndexes>;
417
849
  first: () => Promise<Row | null>;
418
850
  /**
419
- * Set the result order. Orders by the active `.withIndex()` (or by
420
- * `_creationTime` when none is staged), `"asc"` by default; `"desc"`
421
- * reverses it. Composes with `.withIndex()`, `.filter()`, and every
422
- * terminal (`collect`/`first`/`take`/`paginate`/`unique`). Mirrors Convex's
423
- * `.order("asc" | "desc")`.
424
- */
425
- order: (direction: "asc" | "desc") => TableReader<Row>;
851
+ * Set the result order. Orders by the active `.withIndex()` (or by
852
+ * `_creationTime` when none is staged), `"asc"` by default; `"desc"`
853
+ * reverses it. Composes with `.withIndex()`, `.filter()`, and every
854
+ * terminal (`collect`/`first`/`take`/`paginate`/`unique`). Mirrors Convex's
855
+ * `.order("asc" | "desc")`.
856
+ */
857
+ order: (direction: "asc" | "desc") => TableReader<Row, Indexes, SearchIndexes, GeoIndexes>;
426
858
  paginate: (options: PaginationOptions) => Promise<PaginationResult<Row>>;
427
859
  take: (limit: number) => Promise<Row[]>;
428
860
  /**
429
- * Return the single matching document. Returns `null` when nothing matches
430
- * and throws when more than one row matches. Mirrors Convex's `.unique()`.
431
- */
861
+ * Return the single matching document. Returns `null` when nothing matches
862
+ * and throws when more than one row matches. Mirrors Convex's `.unique()`.
863
+ */
432
864
  unique: () => Promise<Row | null>;
433
- withIndex: (indexName: string, range?: (q: IndexRangeBuilder) => IndexRangeBuilder) => TableReader<Row>;
434
865
  /**
435
- * Restrict the query to a declared `.searchIndex()`. The builder's
436
- * `.search(field, query)` runs a full-text match against the index's
437
- * searchable field; `.eq(field, value)` narrows by a declared filter
438
- * field. Results come back ordered by relevance pair with `.take(n)`
439
- * (`.paginate()` is not supported on a search query).
440
- */
441
- withSearchIndex: (indexName: string, search: (q: SearchFilterBuilder) => SearchFilterBuilder) => TableReader<Row>;
866
+ * Restrict the query to a declared `.geoIndex()`. The builder's
867
+ * `.near(point, radiusMeters)` returns rows within `radiusMeters` of `point`,
868
+ * ordered nearest-first; `.within(bbox)` returns rows inside the
869
+ * latitude/longitude bounding box. Both resolve as a geohash-prefix range
870
+ * scan over the index's companion followed by a Haversine refine. Pair with
871
+ * `.take(n)` to cap results (`.paginate()` is not supported on a geo query).
872
+ */
873
+ withGeoIndex(indexName: GeoIndexes, build: (q: GeoFilterBuilder) => GeoFilterBuilder): TableReader<Row, Indexes, SearchIndexes, GeoIndexes>;
874
+ /**
875
+ * Restrict the query to a declared `.index()`. `indexName` is constrained to
876
+ * this table's declared index names (`never` when it declares none), so a
877
+ * renamed, dropped, or mistyped index is a compile error rather than a
878
+ * runtime throw or a silent full-table scan.
879
+ */
880
+ withIndex(indexName: Indexes, range?: (q: IndexRangeBuilder) => IndexRangeBuilder): TableReader<Row, Indexes, SearchIndexes, GeoIndexes>;
881
+ /**
882
+ * Restrict the query to a declared `.searchIndex()`. The builder's
883
+ * `.search(field, query)` runs a full-text match against the index's
884
+ * searchable field; `.eq(field, value)` narrows by a declared filter
885
+ * field. Results come back ordered by relevance — pair with `.take(n)`
886
+ * (`.paginate()` is not supported on a search query).
887
+ */
888
+ withSearchIndex(indexName: SearchIndexes, search: (q: SearchFilterBuilder) => SearchFilterBuilder): TableReader<Row, Indexes, SearchIndexes, GeoIndexes>;
442
889
  }
443
890
  interface IndexRangeBuilder {
444
891
  eq: (field: string, value: unknown) => IndexRangeBuilder;
@@ -454,87 +901,201 @@ interface SearchFilterBuilder {
454
901
  /** Full-text match `query` against the index's searchable `field`. Call exactly once. */
455
902
  search: (field: string, query: string) => SearchFilterBuilder;
456
903
  }
904
+ /** A latitude/longitude point (WGS84 decimal degrees) accepted by geo queries. */
905
+ interface GeoPointInput {
906
+ lat: number;
907
+ lng: number;
908
+ }
909
+ /**
910
+ * An axis-aligned latitude/longitude bounding box: `sw` is the south-west
911
+ * (min lat, min lng) corner, `ne` the north-east (max lat, max lng) corner.
912
+ */
913
+ interface GeoBoundingBox {
914
+ ne: GeoPointInput;
915
+ sw: GeoPointInput;
916
+ }
917
+ /**
918
+ * Builder passed to {@link TableReader.withGeoIndex}. Call exactly one of
919
+ * `.near(...)` / `.within(...)` — the two are mutually exclusive proximity vs
920
+ * bounding-box modes.
921
+ */
922
+ interface GeoFilterBuilder {
923
+ /** Rows within `radiusMeters` of `point`, resolved nearest-first. Call exactly once. */
924
+ near: (point: GeoPointInput, radiusMeters: number) => GeoFilterBuilder;
925
+ /** Rows whose point falls inside the bounding `box`. Call exactly once. */
926
+ within: (box: GeoBoundingBox) => GeoFilterBuilder;
927
+ }
457
928
  /**
458
- * Options shared by the batch-write methods (`insertMany`/`deleteMany`/
459
- * `patchMany`) — a per-call payload cap. The default cap (500) rejects an
460
- * oversized call up front so an accidental O(n²) or a payload past the Durable
461
- * Object request limit fails loudly instead of degrading the mutation. Callers
462
- * with larger sets should chunk their own loop or raise `limit`.
463
- */
929
+ * Options shared by the batch-write methods (`insertMany`/`deleteMany`/
930
+ * `patchMany`) — a per-call payload cap. The default cap (500) rejects an
931
+ * oversized call up front so an accidental O(n²) or a payload past the Durable
932
+ * Object request limit fails loudly instead of degrading the mutation. Callers
933
+ * with larger sets should chunk their own loop or raise `limit`.
934
+ */
464
935
  interface BatchWriteOptions {
465
936
  /** Reject the call when the batch size exceeds this value (default 500). */
466
937
  limit?: number;
467
938
  }
939
+ /** Options accepted by {@link DatabaseWriter.insertMany} and the per-table facade. */
940
+ interface InsertManyOptions extends BatchWriteOptions {
941
+ /**
942
+ * When `true`, a UNIQUE-constraint breach for a row resolves to `null`
943
+ * instead of throwing — the rest of the batch is still inserted. Skipped rows
944
+ * keep their input-order slot with `null` in the returned array. Mirrors
945
+ * better-drizzle's `createMany({ skipDuplicates: true })`.
946
+ */
947
+ skipDuplicates?: boolean;
948
+ }
468
949
  interface DatabaseWriter extends DatabaseReader {
469
950
  delete: <T extends string>(id: Id<T>) => Promise<void>;
470
951
  /**
471
- * Delete many rows by id in one call. Each id is deleted through the full
472
- * single-row pipeline (triggers + per-row RLS). The returned `deleted` is the
473
- * number of ids **requested**, not the rows actually removed — an unknown or
474
- * duplicated id is a silent no-op.
475
- *
476
- * **Atomic within a mutation:** the DO wraps a mutation's dispatch in a
477
- * BEGIN/COMMIT span, so a mid-batch failure (a later RLS denial or handler
478
- * error) rolls back the whole mutation. (In an action there is no transaction
479
- * span, so the prior deletes persist; the in-memory test harness mirrors the span.)
480
- */
952
+ * Delete EVERY row in `tableName`, chunking internally until the table is
953
+ * empty the erasure primitive.
954
+ *
955
+ * Unlike `deleteWhere(tableName, {})` there is **no batch cap**: a
956
+ * `BATCH_LIMIT_EXCEEDED` at row 501 of an account deletion is a bug, not a
957
+ * safety rail. Rows still go through the single-row delete pipeline, so
958
+ * triggers, cascades, companions, CDC, and live subscriptions stay correct.
959
+ *
960
+ * On a `.softDelete()` table the default flips the marker column; pass
961
+ * `{ hard: true }` to remove the rows physically (what GDPR erasure means).
962
+ */
963
+ deleteAll: (tableName: string, options?: {
964
+ chunkSize?: number;
965
+ hard?: boolean;
966
+ }) => Promise<{
967
+ deleted: number;
968
+ }>;
969
+ /**
970
+ * Delete many rows by id in one call. Each id is deleted through the full
971
+ * single-row pipeline (triggers + per-row RLS). The returned `deleted` is the
972
+ * number of ids **requested**, not the rows actually removed — an unknown or
973
+ * duplicated id is a silent no-op.
974
+ *
975
+ * **Atomic within a mutation:** the DO wraps a mutation's dispatch in a
976
+ * BEGIN/COMMIT span, so a mid-batch failure (a later RLS denial or handler
977
+ * error) rolls back the whole mutation. (In an action there is no transaction
978
+ * span, so the prior deletes persist; the in-memory test harness mirrors the span.)
979
+ */
481
980
  deleteMany: <T extends string>(ids: ReadonlyArray<Id<T>>, options?: BatchWriteOptions) => Promise<{
482
981
  deleted: number;
483
982
  }>;
484
983
  /**
485
- * Insert a document, returning its server id.
486
- *
487
- * Pass `options.clientId` (a UUID) to key the row yourself for an
488
- * optimistic client that needs the persisted row to match the key it
489
- * already rendered. It's validated for shape and still subject to the
490
- * primary-key uniqueness constraint; omit it and the server mints the id.
491
- */
984
+ * Delete every row matching `where` in one call. Matching rows are resolved
985
+ * first, then each row is deleted through the single-row delete pipeline
986
+ * (triggers, companion sync, CDC, broadcast) so reactive subscriptions and
987
+ * search/aggregate companions stay correct.
988
+ *
989
+ * **Atomic within a mutation:** the DO wraps a mutation's dispatch in a
990
+ * BEGIN/COMMIT span, so a mid-batch failure rolls back the whole mutation.
991
+ */
992
+ deleteWhere: (tableName: string, where: Record<string, unknown>, options?: BatchWriteOptions) => Promise<{
993
+ deleted: number;
994
+ }>;
995
+ /**
996
+ * Insert a document, returning its server id.
997
+ *
998
+ * Pass `options.clientId` (a UUID) to key the row yourself — for an
999
+ * optimistic client that needs the persisted row to match the key it
1000
+ * already rendered. It's validated for shape and still subject to the
1001
+ * primary-key uniqueness constraint; omit it and the server mints the id.
1002
+ */
492
1003
  insert: <T extends string>(tableName: T, document: Record<string, unknown>, options?: {
493
1004
  clientId?: string;
494
1005
  }) => Promise<Id<T>>;
495
1006
  /**
496
- * Insert many documents into one table in a single call, returning the
497
- * minted ids in input order. Equivalent to a per-row `insert()` loop — each
498
- * row gets defaults, validators, triggers, and a per-row RLS check — but the
499
- * caller pays one round-trip instead of N.
500
- *
501
- * **Atomic within a mutation:** the DO wraps a mutation's dispatch in a
502
- * BEGIN/COMMIT span, so a mid-batch failure (an invalid or RLS-denied row)
503
- * rolls back the whole mutation. (In an action there is no transaction span,
504
- * so the prior inserts persist; the in-memory test harness mirrors the span.)
505
- */
506
- insertMany: <T extends string>(tableName: T, documents: ReadonlyArray<Record<string, unknown>>, options?: BatchWriteOptions) => Promise<Id<T>[]>;
507
- /**
508
- * **Trusted** bulk insert: one multi-row `INSERT` that **skips per-row
509
- * `.check()` validators and before/after triggers** for throughput on data you
510
- * control (seed, migration, admin import). Defaults, ids, and every companion
511
- * (search/aggregate/rank/CDC + live subscriptions) are still applied, so reads
512
- * stay correct.
513
- *
514
- * It is **"unsafe" only in that it bypasses the validation/trigger pipeline** —
515
- * RLS is **not** bypassed: secure-by-default and the table's insert policy still
516
- * apply (the framework ships no RLS-bypassing writer). Pass `allowExplicitId` to
517
- * preserve a supplied `_id` (import). Use only for data you trust; prefer
518
- * `insertMany` for anything user-supplied.
519
- */
1007
+ * Insert many documents into one table in a single call, returning the
1008
+ * minted ids in input order. Equivalent to a per-row `insert()` loop — each
1009
+ * row gets defaults, validators, triggers, and a per-row RLS check — but the
1010
+ * caller pays one round-trip instead of N.
1011
+ *
1012
+ * Pass `{ skipDuplicates: true }` to turn UNIQUE-constraint breaches into
1013
+ * `null` results for that row instead of failing the whole batch; the rest of
1014
+ * the batch is still inserted and order is preserved.
1015
+ *
1016
+ * **Atomic within a mutation:** the DO wraps a mutation's dispatch in a
1017
+ * BEGIN/COMMIT span, so a mid-batch failure (an invalid or RLS-denied row)
1018
+ * rolls back the whole mutation. (In an action there is no transaction span,
1019
+ * so the prior inserts persist; the in-memory test harness mirrors the span.)
1020
+ */
1021
+ insertMany: {
1022
+ <T extends string>(tableName: T, documents: ReadonlyArray<Record<string, unknown>>, options: BatchWriteOptions & {
1023
+ skipDuplicates: true;
1024
+ }): Promise<(Id<T> | null)[]>;
1025
+ <T extends string>(tableName: T, documents: ReadonlyArray<Record<string, unknown>>, options?: InsertManyOptions): Promise<Id<T>[]>;
1026
+ };
1027
+ /**
1028
+ * **Trusted** bulk insert: one multi-row `INSERT` that **skips per-row
1029
+ * `.check()` validators and before/after triggers** for throughput on data you
1030
+ * control (seed, migration, admin import). Defaults, ids, and every companion
1031
+ * (search/aggregate/rank/CDC + live subscriptions) are still applied, so reads
1032
+ * stay correct.
1033
+ *
1034
+ * It is **"unsafe" only in that it bypasses the validation/trigger pipeline** —
1035
+ * RLS is **not** bypassed: secure-by-default and the table's insert policy still
1036
+ * apply (the framework ships no RLS-bypassing writer). Pass `allowExplicitId` to
1037
+ * preserve a supplied `_id` (import). Use only for data you trust; prefer
1038
+ * `insertMany` for anything user-supplied.
1039
+ */
520
1040
  insertManyUnsafe: <T extends string>(tableName: T, documents: ReadonlyArray<Record<string, unknown>>, options?: BatchWriteOptions & {
521
1041
  allowExplicitId?: boolean;
522
1042
  }) => Promise<Id<T>[]>;
523
1043
  patch: <T extends string>(id: Id<T>, patch: Record<string, unknown>) => Promise<void>;
524
1044
  /**
525
- * Patch many rows by id in one call. Each `{ id, patch }` is applied like a
526
- * single `patch()` (per-row triggers + RLS).
527
- *
528
- * **Atomic within a mutation:** the DO wraps a mutation's dispatch in a
529
- * BEGIN/COMMIT span, so a mid-batch failure rolls back the whole mutation.
530
- * (In an action there is no transaction span, so the prior patches persist;
531
- * the in-memory test harness mirrors the span.)
532
- */
1045
+ * Patch many rows by id in one call. Each `{ id, patch }` is applied like a
1046
+ * single `patch()` (per-row triggers + RLS). Returns the number of rows
1047
+ * actually patched.
1048
+ *
1049
+ * **Atomic within a mutation:** the DO wraps a mutation's dispatch in a
1050
+ * BEGIN/COMMIT span, so a mid-batch failure rolls back the whole mutation.
1051
+ * (In an action there is no transaction span, so the prior patches persist;
1052
+ * the in-memory test harness mirrors the span.)
1053
+ */
533
1054
  patchMany: <T extends string>(patches: ReadonlyArray<{
534
1055
  id: Id<T>;
535
1056
  patch: Record<string, unknown>;
536
- }>, options?: BatchWriteOptions) => Promise<void>;
1057
+ }>, options?: BatchWriteOptions) => Promise<{
1058
+ patched: number;
1059
+ }>;
1060
+ /**
1061
+ * Patch every row matching `where` with the same `patch` in one call. The
1062
+ * matching rows are resolved first, then each row is updated through the
1063
+ * single-row patch pipeline (OCC, triggers, companion sync, CDC, broadcast)
1064
+ * so reactive subscriptions and search/aggregate companions stay correct.
1065
+ *
1066
+ * **Atomic within a mutation:** the DO wraps a mutation's dispatch in a
1067
+ * BEGIN/COMMIT span, so a mid-batch failure rolls back the whole mutation.
1068
+ */
1069
+ patchWhere: (tableName: string, args: {
1070
+ patch: Record<string, unknown>;
1071
+ where: Record<string, unknown>;
1072
+ }, options?: BatchWriteOptions) => Promise<{
1073
+ patched: number;
1074
+ }>;
537
1075
  replace: <T extends string>(id: Id<T>, document: Record<string, unknown>) => Promise<void>;
1076
+ /**
1077
+ * Erase every shard-local table — the account-deletion / tenant-teardown
1078
+ * primitive. Sweeps the schema's non-`.global()` tables with
1079
+ * {@link DatabaseWriter.deleteAll}`({ hard: true })` and returns the per-table
1080
+ * counts.
1081
+ *
1082
+ * `.global()` tables are skipped by design: their rows live in D1 and are shared
1083
+ * across shards, so "wipe this shard" must not reach them. Restrict the sweep
1084
+ * with `options.tables`, or spare one with `options.exclude` (e.g. an audit log
1085
+ * that must outlive the data).
1086
+ *
1087
+ * ```ts
1088
+ * export const deleteAccount = internalMutation({ handler: async ({ ctx }) => ctx.db.wipeShard() });
1089
+ * ```
1090
+ */
1091
+ wipeShard: (options?: {
1092
+ chunkSize?: number;
1093
+ exclude?: ReadonlyArray<string>;
1094
+ tables?: ReadonlyArray<string>;
1095
+ }) => Promise<{
1096
+ deleted: number;
1097
+ tables: Record<string, number>;
1098
+ }>;
538
1099
  }
539
1100
  /** Authenticated identity surfaced into every context. */
540
1101
  interface AuthState {
@@ -542,11 +1103,11 @@ interface AuthState {
542
1103
  readonly userId: string | null;
543
1104
  }
544
1105
  /**
545
- * A pending scheduled invocation as surfaced by {@link Scheduler.list} /
546
- * {@link Scheduler.get}. A clean public mirror of `@lunora/scheduler`'s internal
547
- * `ScheduleRecord` — re-declared here so the public ctx surface carries no
548
- * dependency on the scheduler package's internal types.
549
- */
1106
+ * A pending scheduled invocation as surfaced by {@link Scheduler.list} /
1107
+ * {@link Scheduler.get}. A clean public mirror of `@lunora/scheduler`'s internal
1108
+ * `ScheduleRecord` — re-declared here so the public ctx surface carries no
1109
+ * dependency on the scheduler package's internal types.
1110
+ */
550
1111
  interface ScheduledJob {
551
1112
  args: Record<string, unknown>;
552
1113
  /** Number of dispatch attempts already made (absent until the first retry). */
@@ -560,6 +1121,21 @@ interface ScheduledJob {
560
1121
  /** Routing hint forwarded so dispatch lands on the right shard. */
561
1122
  shardKey?: string;
562
1123
  }
1124
+ /**
1125
+ * A schedulable durable-workflow reference — the generated `workflows.&lt;name>` /
1126
+ * `agents.&lt;name>` object, which carries its `WORKFLOW_*`/`AGENT_*` binding and
1127
+ * stable name. Structural mirror of `@lunora/scheduler`'s `WorkflowReference` so
1128
+ * `ctx.scheduler` can target a workflow/agent without a dependency on
1129
+ * `@lunora/scheduler` / `@lunora/workflow`. A scheduled workflow target starts a
1130
+ * fresh instance on fire (the args become its `params`).
1131
+ */
1132
+ interface SchedulableWorkflowReference {
1133
+ /** The `WORKFLOW_*`/`AGENT_*` binding name (present on a generated ref). */
1134
+ readonly binding?: string;
1135
+ readonly isLunoraWorkflow: true;
1136
+ /** The workflow/agent export/stable name (present on a generated ref). */
1137
+ readonly name?: string;
1138
+ }
563
1139
  interface Scheduler {
564
1140
  /** Cancel a pending job by id. `cancelled` is `false` when no such job exists. */
565
1141
  cancel: (id: string) => Promise<{
@@ -569,16 +1145,23 @@ interface Scheduler {
569
1145
  get: (id: string) => Promise<ScheduledJob | null>;
570
1146
  /** List all pending scheduled jobs. */
571
1147
  list: () => Promise<ScheduledJob[]>;
572
- runAfter: (delayMs: number, functionPath: string, args?: Record<string, unknown>) => Promise<string>;
573
- runAt: (timestampMs: number, functionPath: string, args?: Record<string, unknown>) => Promise<string>;
1148
+ /**
1149
+ * Schedule a one-shot run `delayMs` from now. `target` is a function path
1150
+ * (`"ns:fn"`) dispatched as a one-shot, or a generated `workflows.&lt;name>` /
1151
+ * `agents.&lt;name>` reference which starts a fresh durable instance on fire
1152
+ * (the args become its `params`).
1153
+ */
1154
+ runAfter: (delayMs: number, target: SchedulableWorkflowReference | string, args?: Record<string, unknown>) => Promise<string>;
1155
+ /** Like {@link Scheduler.runAfter} but fires at an absolute epoch-ms timestamp. */
1156
+ runAt: (timestampMs: number, target: SchedulableWorkflowReference | string, args?: Record<string, unknown>) => Promise<string>;
574
1157
  }
575
1158
  /**
576
- * A workflow instance's lifecycle status. Clean public mirror of
577
- * `@lunora/workflow`'s `WorkflowInstanceStatus` (itself a mirror of Cloudflare's
578
- * `WorkflowInstanceStatus`) — re-declared here so the ctx surface carries no
579
- * dependency on the workflow package, exactly as {@link Scheduler} avoids a
580
- * dependency on `@lunora/scheduler`.
581
- */
1159
+ * A workflow instance's lifecycle status. Clean public mirror of
1160
+ * `@lunora/workflow`'s `WorkflowInstanceStatus` (itself a mirror of Cloudflare's
1161
+ * `WorkflowInstanceStatus`) — re-declared here so the ctx surface carries no
1162
+ * dependency on the workflow package, exactly as {@link Scheduler} avoids a
1163
+ * dependency on `@lunora/scheduler`.
1164
+ */
582
1165
  type WorkflowInstanceStatus = "complete" | "errored" | "paused" | "queued" | "running" | "terminated" | "unknown" | "waiting" | "waitingForPause";
583
1166
  /** Result of {@link WorkflowInstance.status}. Mirrors `@lunora/workflow`'s `WorkflowStatusResult`. */
584
1167
  interface WorkflowStatusResult {
@@ -615,9 +1198,9 @@ interface WorkflowInstance {
615
1198
  terminate: () => Promise<void>;
616
1199
  }
617
1200
  /**
618
- * A typed handle to one declared workflow, addressable from `ctx.workflows`.
619
- * Mirrors `@lunora/workflow`'s `WorkflowHandle`.
620
- */
1201
+ * A typed handle to one declared workflow, addressable from `ctx.workflows`.
1202
+ * Mirrors `@lunora/workflow`'s `WorkflowHandle`.
1203
+ */
621
1204
  interface WorkflowHandle<Params = Record<string, unknown>> {
622
1205
  /** Start a new instance (optionally with an id + params). */
623
1206
  create: (options?: WorkflowCreateOptions<Params>) => Promise<WorkflowInstance>;
@@ -627,36 +1210,51 @@ interface WorkflowHandle<Params = Record<string, unknown>> {
627
1210
  get: (id: string) => Promise<WorkflowInstance>;
628
1211
  }
629
1212
  /**
630
- * The `ctx.workflows` surface on {@link MutationCtx} / {@link ActionCtx}. Each
631
- * workflow declared in `lunora/workflows.ts` is reachable by its export name;
632
- * codegen narrows the `get(name)` overloads to the known workflow names + their
633
- * inferred param types. Mirrors `@lunora/workflow`'s `Workflows`.
634
- */
1213
+ * The `ctx.workflows` surface on {@link MutationCtx} / {@link ActionCtx}. Each
1214
+ * workflow declared in `lunora/workflows.ts` is reachable by its export name;
1215
+ * codegen narrows the `get(name)` overloads to the known workflow names + their
1216
+ * inferred param types. Mirrors `@lunora/workflow`'s `Workflows`.
1217
+ */
635
1218
  interface Workflows {
636
1219
  /** Resolve the handle for a declared workflow by export name. */
637
1220
  get: <Params = Record<string, unknown>>(name: string) => WorkflowHandle<Params>;
638
1221
  }
639
1222
  /**
640
- * Structural projection of workers-types' `SecretsStoreSecret` binding the
641
- * per-secret `secrets_store_secrets[]` binding whose `.get()` resolves the
642
- * secret value (or throws if it does not exist). Mirrored structurally so the
643
- * runtime resolves it without a workerd type dependency.
644
- */
1223
+ * Programmatic cache purge surface exposed on {@link ActionCtx}. Actions run
1224
+ * in the Worker (not the DO), so they can reach the Worker's `ctx.cache.purge`.
1225
+ * Queries and mutations do not expose this they run inside the Durable Object.
1226
+ */
1227
+ interface CachePurge {
1228
+ /**
1229
+ * Purge cached responses matching the given tags, or everything when
1230
+ * `purgeEverything` is true. Only available in action handlers.
1231
+ */
1232
+ purge: (options: {
1233
+ purgeEverything?: boolean;
1234
+ tags?: string[];
1235
+ }) => Promise<unknown>;
1236
+ }
1237
+ /**
1238
+ * Structural projection of workers-types' `SecretsStoreSecret` binding — the
1239
+ * per-secret `secrets_store_secrets[]` binding whose `.get()` resolves the
1240
+ * secret value (or throws if it does not exist). Mirrored structurally so the
1241
+ * runtime resolves it without a workerd type dependency.
1242
+ */
645
1243
  interface SecretsStoreSecretLike {
646
1244
  get: () => Promise<string>;
647
1245
  }
648
1246
  /**
649
- * `ctx.secrets` — read account-level secrets bound via Cloudflare Secrets Store.
650
- * A core built-in (always present on every context, like `ctx.log`): a binding
651
- * named in wrangler's `secrets_store_secrets[]` is read by its binding name.
652
- *
653
- * ```ts
654
- * const apiKey = await ctx.secrets.get("STRIPE_KEY");
655
- * ```
656
- *
657
- * The lookup is async (the platform fetches and decrypts on first read);
658
- * reading an undeclared name throws a directed error naming the bound secrets.
659
- */
1247
+ * `ctx.secrets` — read account-level secrets bound via Cloudflare Secrets Store.
1248
+ * A core built-in (always present on every context, like `ctx.log`): a binding
1249
+ * named in wrangler's `secrets_store_secrets[]` is read by its binding name.
1250
+ *
1251
+ * ```ts
1252
+ * const apiKey = await ctx.secrets.get("STRIPE_KEY");
1253
+ * ```
1254
+ *
1255
+ * The lookup is async (the platform fetches and decrypts on first read);
1256
+ * reading an undeclared name throws a directed error naming the bound secrets.
1257
+ */
660
1258
  interface Secrets {
661
1259
  /** Resolve a Secrets Store secret by its wrangler binding name. */
662
1260
  get: (name: string) => Promise<string>;
@@ -666,11 +1264,11 @@ type TriggerTiming = "after" | "before";
666
1264
  /** The CRUD operation a trigger reacts to. `patch` and `replace` both map to `update`. */
667
1265
  type TriggerOp = "delete" | "insert" | "update";
668
1266
  /**
669
- * A row as observed by a trigger handler: the table's `Shape` (with the same
670
- * optionality rules as {@link InferArgs}) plus the system columns every stored
671
- * doc carries.
672
- */
673
- type TriggerRow<Shape extends Record<string, Validator>> = { [K in keyof Shape as undefined extends Infer<Shape[K]> ? K : never]?: Infer<Shape[K]> } & { [K in keyof Shape as undefined extends Infer<Shape[K]> ? never : K]: Infer<Shape[K]> } & {
1267
+ * A row as observed by a trigger handler: the table's `Shape` (with the same
1268
+ * optionality rules as {@link InferArgs}) plus the system columns every stored
1269
+ * doc carries.
1270
+ */
1271
+ type TriggerRow<Shape extends Record<string, Validator>> = { [K in keyof Shape as undefined extends Infer<Shape[K]> ? K : never]?: Infer<Shape[K]>; } & { [K in keyof Shape as undefined extends Infer<Shape[K]> ? never : K]: Infer<Shape[K]>; } & {
674
1272
  readonly _creationTime: number;
675
1273
  readonly _id: string;
676
1274
  };
@@ -682,11 +1280,11 @@ interface TriggerInsertEvent<Shape extends Record<string, Validator> = Record<st
682
1280
  readonly table: string;
683
1281
  }
684
1282
  /**
685
- * What an `update` trigger observes: the merged row plus the pre-write row.
686
- * `previous` is typed as always present (the row must exist to be updated); the
687
- * runtime supplies it best-effort and only omits it in the unreachable
688
- * row-vanished-mid-write case.
689
- */
1283
+ * What an `update` trigger observes: the merged row plus the pre-write row.
1284
+ * `previous` is typed as always present (the row must exist to be updated); the
1285
+ * runtime supplies it best-effort and only omits it in the unreachable
1286
+ * row-vanished-mid-write case.
1287
+ */
690
1288
  interface TriggerUpdateEvent<Shape extends Record<string, Validator> = Record<string, Validator>> {
691
1289
  readonly doc: TriggerRow<Shape>;
692
1290
  readonly id: string;
@@ -695,10 +1293,10 @@ interface TriggerUpdateEvent<Shape extends Record<string, Validator> = Record<st
695
1293
  readonly table: string;
696
1294
  }
697
1295
  /**
698
- * What a `delete` trigger observes: the row about to be (or just) removed.
699
- * `previous` is typed as always present; the runtime supplies it best-effort
700
- * and only omits it in the unreachable row-vanished-mid-write case.
701
- */
1296
+ * What a `delete` trigger observes: the row about to be (or just) removed.
1297
+ * `previous` is typed as always present; the runtime supplies it best-effort
1298
+ * and only omits it in the unreachable row-vanished-mid-write case.
1299
+ */
702
1300
  interface TriggerDeleteEvent<Shape extends Record<string, Validator> = Record<string, Validator>> {
703
1301
  readonly id: string;
704
1302
  readonly op: "delete";
@@ -722,10 +1320,10 @@ interface TriggerQueryArgs {
722
1320
  with?: Record<string, unknown>;
723
1321
  }
724
1322
  /**
725
- * Args accepted by {@link TriggerDatabase.aggregate} — structural mirror of
726
- * `@lunora/do`'s `AggregateOptions`, kept local so trigger handlers in
727
- * `@lunora/server` don't take a hard dep on the DO runtime.
728
- */
1323
+ * Args accepted by {@link TriggerDatabase.aggregate} — structural mirror of
1324
+ * `@lunora/do`'s `AggregateOptions`, kept local so trigger handlers in
1325
+ * `@lunora/server` don't take a hard dep on the DO runtime.
1326
+ */
729
1327
  interface TriggerAggregateOptions {
730
1328
  baseWhere?: Record<string, unknown>;
731
1329
  field?: string;
@@ -770,17 +1368,17 @@ interface TriggerRankPageOptions {
770
1368
  where?: Record<string, unknown>;
771
1369
  }
772
1370
  /**
773
- * Portable, table/id-addressed ORM writer handed to trigger handlers via
774
- * `ctx.db`. Mirrors `@lunora/do`'s runtime `DatabaseWriterLike` surface — it is
775
- * **not** the generated per-table `ctx.db.&lt;table>` facade (which can't be typed
776
- * from inside `defineTable`, where the full schema isn't known).
777
- *
778
- * `aggregate`/`groupBy`/`count`/`rank`/`rankPage` route through the same
779
- * trigger-maintained counter and rank tables the user-facing reader uses, so
780
- * a handler's `ctx.db.&lt;table>.aggregate(...)` observes the just-staged write
781
- * within the same DO transaction (the counter step happens before the trigger
782
- * fires).
783
- */
1371
+ * Portable, table/id-addressed ORM writer handed to trigger handlers via
1372
+ * `ctx.db`. Mirrors `@lunora/do`'s runtime `DatabaseWriterLike` surface — it is
1373
+ * **not** the generated per-table `ctx.db.&lt;table>` facade (which can't be typed
1374
+ * from inside `defineTable`, where the full schema isn't known).
1375
+ *
1376
+ * `aggregate`/`groupBy`/`count`/`rank`/`rankPage` route through the same
1377
+ * trigger-maintained counter and rank tables the user-facing reader uses, so
1378
+ * a handler's `ctx.db.&lt;table>.aggregate(...)` observes the just-staged write
1379
+ * within the same DO transaction (the counter step happens before the trigger
1380
+ * fires).
1381
+ */
784
1382
  interface TriggerDatabase {
785
1383
  aggregate: (tableName: string, options: TriggerAggregateOptions) => Promise<null | number>;
786
1384
  count: (tableName: string, where?: Record<string, unknown>) => Promise<number>;
@@ -796,10 +1394,10 @@ interface TriggerDatabase {
796
1394
  replace: (id: string, document: Record<string, unknown>) => Promise<void>;
797
1395
  }
798
1396
  /**
799
- * Handle injected into every trigger handler. `db` is the portable ORM writer;
800
- * `scheduler` enqueues async / cross-shard follow-up work (cross-shard work is
801
- * **not** transactional with the firing write).
802
- */
1397
+ * Handle injected into every trigger handler. `db` is the portable ORM writer;
1398
+ * `scheduler` enqueues async / cross-shard follow-up work (cross-shard work is
1399
+ * **not** transactional with the firing write).
1400
+ */
803
1401
  interface TriggerCtx {
804
1402
  readonly db: TriggerDatabase;
805
1403
  readonly scheduler: Scheduler;
@@ -807,20 +1405,20 @@ interface TriggerCtx {
807
1405
  /** A user-declared trigger handler. Throwing from a `before*` handler aborts the write. */
808
1406
  type TriggerHandler<Event> = (context: TriggerCtx, event: Event) => Promise<void> | void;
809
1407
  /**
810
- * A single declared trigger, as stored in {@link TableDefinition.triggerMap}.
811
- * The handler's event type is erased to the {@link TriggerEvent} union here; the
812
- * per-op {@link TriggerBuilder} methods recover the precise event type for
813
- * authors.
814
- */
1408
+ * A single declared trigger, as stored in {@link TableDefinition.triggerMap}.
1409
+ * The handler's event type is erased to the {@link TriggerEvent} union here; the
1410
+ * per-op {@link TriggerBuilder} methods recover the precise event type for
1411
+ * authors.
1412
+ */
815
1413
  interface TriggerDefinition {
816
1414
  readonly handler: TriggerHandler<TriggerEvent>;
817
1415
  readonly op: TriggerOp;
818
1416
  readonly timing: TriggerTiming;
819
1417
  }
820
1418
  /**
821
- * The `t` argument passed to `.triggers((t) => …)`. Each method binds a handler
822
- * to one `timing`+`op` pair, typing the event against the table's `Shape`.
823
- */
1419
+ * The `t` argument passed to `.triggers((t) => …)`. Each method binds a handler
1420
+ * to one `timing`+`op` pair, typing the event against the table's `Shape`.
1421
+ */
824
1422
  interface TriggerBuilder<Shape extends Record<string, Validator> = Record<string, Validator>> {
825
1423
  afterDelete: (handler: TriggerHandler<TriggerDeleteEvent<Shape>>) => TriggerDefinition;
826
1424
  afterInsert: (handler: TriggerHandler<TriggerInsertEvent<Shape>>) => TriggerDefinition;
@@ -830,11 +1428,11 @@ interface TriggerBuilder<Shape extends Record<string, Validator> = Record<string
830
1428
  beforeUpdate: (handler: TriggerHandler<TriggerUpdateEvent<Shape>>) => TriggerDefinition;
831
1429
  }
832
1430
  /**
833
- * Per-file metadata returned by {@link ReadOnlyStorage.getMetadata}. A clean
834
- * public mirror of `@lunora/storage`'s `ObjectMetadata` — re-declared here so
835
- * the ctx surface carries no dependency on the storage package's types. Matches
836
- * the columns Convex surfaces for `ctx.storage.getMetadata` / `_storage`.
837
- */
1431
+ * Per-file metadata returned by {@link ReadOnlyStorage.getMetadata}. A clean
1432
+ * public mirror of `@lunora/storage`'s `ObjectMetadata` — re-declared here so
1433
+ * the ctx surface carries no dependency on the storage package's types. Matches
1434
+ * the columns Convex surfaces for `ctx.storage.getMetadata` / `_storage`.
1435
+ */
838
1436
  interface StorageMetadata {
839
1437
  /** The object's `Content-Type`, when recorded. */
840
1438
  contentType?: string;
@@ -850,31 +1448,31 @@ interface StorageMetadata {
850
1448
  uploaded?: number;
851
1449
  }
852
1450
  /**
853
- * Read-only projection of `Storage` exposed on `QueryCtx` / `MutationCtx`.
854
- *
855
- * Queries are pure reads, and mutations run inside a transactional scope —
856
- * neither is allowed to perform side-effectful R2 writes (`upload`) or
857
- * deletes (`delete`). They can, however, **read** existing objects and
858
- * resolve signed URLs (the URL signing itself is HMAC-only — no R2 round
859
- * trip), so the read-only surface keeps `download` and `getSignedUrl`. The
860
- * full {@link Storage} surface stays on `ActionCtx`.
861
- */
1451
+ * Read-only projection of `Storage` exposed on `QueryCtx` / `MutationCtx`.
1452
+ *
1453
+ * Queries are pure reads, and mutations run inside a transactional scope —
1454
+ * neither is allowed to perform side-effectful R2 writes (`upload`) or
1455
+ * deletes (`delete`). They can, however, **read** existing objects and
1456
+ * resolve signed URLs (the URL signing itself is HMAC-only — no R2 round
1457
+ * trip), so the read-only surface keeps `download` and `getSignedUrl`. The
1458
+ * full {@link Storage} surface stays on `ActionCtx`.
1459
+ */
862
1460
  interface ReadOnlyStorage<Buckets extends string = string> {
863
1461
  /**
864
- * Select a named bucket (declared via `v.storage("name")`). The returned
865
- * accessor's operations target that bucket — `ctx.storage.bucket("avatars")
866
- * .download(key)`. The bare `ctx.storage` targets the default bucket.
867
- */
1462
+ * Select a named bucket (declared via `v.storage("name")`). The returned
1463
+ * accessor's operations target that bucket — `ctx.storage.bucket("avatars")
1464
+ * .download(key)`. The bare `ctx.storage` targets the default bucket.
1465
+ */
868
1466
  bucket: (name: Buckets) => ReadOnlyStorage<Buckets>;
869
1467
  /** The bucket this accessor's operations target (the default for the bare `ctx.storage`). */
870
1468
  readonly bucketName: string;
871
1469
  /** Fetch the body of an existing object. Returns `null` when absent. */
872
1470
  download: (key: string) => Promise<ReadableStream | null>;
873
1471
  /**
874
- * Read a file's metadata (size, content-type, sha256, upload time, custom
875
- * metadata) without fetching its body. Returns `null` when the object is
876
- * absent. Mirrors Convex's `ctx.storage.getMetadata`.
877
- */
1472
+ * Read a file's metadata (size, content-type, sha256, upload time, custom
1473
+ * metadata) without fetching its body. Returns `null` when the object is
1474
+ * absent. Mirrors Convex's `ctx.storage.getMetadata`.
1475
+ */
878
1476
  getMetadata: (key: string) => Promise<StorageMetadata | null>;
879
1477
  /** Resolve a short-lived signed URL for an existing object. */
880
1478
  getSignedUrl: (key: string, options?: {
@@ -888,20 +1486,20 @@ interface Storage<Buckets extends string = string> extends ReadOnlyStorage<Bucke
888
1486
  bucket: (name: Buckets) => Storage<Buckets>;
889
1487
  delete: (key: string) => Promise<void>;
890
1488
  /**
891
- * Mint a short-lived signed `PUT` URL a client can upload directly to,
892
- * optionally pinning the `Content-Type` the uploader must send. Mirrors
893
- * Convex's `storage.generateUploadUrl`.
894
- */
1489
+ * Mint a short-lived signed `PUT` URL a client can upload directly to,
1490
+ * optionally pinning the `Content-Type` the uploader must send. Mirrors
1491
+ * Convex's `storage.generateUploadUrl`.
1492
+ */
895
1493
  generateUploadUrl: (key: string, options?: {
896
1494
  contentType?: string;
897
1495
  expiresInSeconds?: number;
898
1496
  }) => Promise<string>;
899
1497
  /**
900
- * Upload `body` to `key` from the server, returning the stored object's key
901
- * and etag. Mirrors Convex's `storage.store`. Accepts the same guard fields
902
- * as `@lunora/storage`'s `UploadOptions` so `maxSize` /
903
- * `allowedContentTypes` enforcement isn't lost behind the Convex-style alias.
904
- */
1498
+ * Upload `body` to `key` from the server, returning the stored object's key
1499
+ * and etag. Mirrors Convex's `storage.store`. Accepts the same guard fields
1500
+ * as `@lunora/storage`'s `UploadOptions` so `maxSize` /
1501
+ * `allowedContentTypes` enforcement isn't lost behind the Convex-style alias.
1502
+ */
905
1503
  store: (key: string, body: ReadableStream | ArrayBuffer | Blob, options?: {
906
1504
  allowedContentTypes?: ReadonlyArray<string>;
907
1505
  contentType?: string;
@@ -945,160 +1543,525 @@ interface VectorRecord {
945
1543
  values: ReadonlyArray<number>;
946
1544
  }
947
1545
  /**
948
- * Read-only vector surface exposed on {@link QueryCtx}. Mirrors the read half
949
- * of `@lunora/bindings/vectors`' `LunoraVectors` so the live adapter is assignable.
950
- */
951
- interface VectorSearchReader {
952
- getByIds: (indexName: string, ids: ReadonlyArray<string>) => Promise<ReadonlyArray<VectorRecord>>;
953
- query: (indexName: string, input: VectorQueryInput) => Promise<VectorMatches>;
1546
+ * Read-only vector surface exposed on {@link QueryCtx}. Mirrors the read half
1547
+ * of `@lunora/bindings/vectors`' `LunoraVectors` so the live adapter is assignable.
1548
+ */
1549
+ interface VectorSearchReader<IndexName extends string = string> {
1550
+ getByIds: (indexName: IndexName, ids: ReadonlyArray<string>) => Promise<ReadonlyArray<VectorRecord>>;
1551
+ query: (indexName: IndexName, input: VectorQueryInput) => Promise<VectorMatches>;
954
1552
  }
955
1553
  /**
956
- * Mutating vector surface on {@link MutationCtx} / {@link ActionCtx}. `upsert`
957
- * is queued post-commit by default; `upsertNow` forces a synchronous write.
958
- * `db.delete` on a vectorized table auto-propagates the matching `deleteByIds`.
959
- */
960
- interface VectorSearch extends VectorSearchReader {
961
- deleteByIds: (indexName: string, ids: ReadonlyArray<string>) => Promise<void>;
962
- upsert: (indexName: string, input: VectorUpsertInput) => Promise<void>;
963
- upsertNow: (indexName: string, input: VectorUpsertInput) => Promise<void>;
1554
+ * Mutating vector surface on {@link MutationCtx} / {@link ActionCtx}. `upsert`
1555
+ * is queued post-commit by default; `upsertNow` forces a synchronous write.
1556
+ * `db.delete` on a vectorized table auto-propagates the matching `deleteByIds`.
1557
+ */
1558
+ interface VectorSearch<IndexName extends string = string> extends VectorSearchReader<IndexName> {
1559
+ deleteByIds: (indexName: IndexName, ids: ReadonlyArray<string>) => Promise<void>;
1560
+ upsert: (indexName: IndexName, input: VectorUpsertInput) => Promise<void>;
1561
+ upsertNow: (indexName: IndexName, input: VectorUpsertInput) => Promise<void>;
964
1562
  }
965
1563
  /**
966
- * Structured logger on every function `ctx`. Each call emits one attributed log
967
- * line tagged with the function path on the server that flows to an
968
- * `ObservabilitySink`'s `onLog` (where you route it in production) and, in
969
- * development, to the dev server terminal via the CLI / Vite plugin formatter.
970
- * Mirrors the `console` method names so it's a drop-in for `console.log` inside a
971
- * handler, but with attribution and a routable transport.
972
- *
973
- * Accepts any number of values per call, exactly like `console`; objects are
974
- * rendered into the human-readable message. The raw, un-rendered arguments are
975
- * preserved ONLY on the in-process `onLog` sink (which you opt into and control);
976
- * the rendered message — not the structured args — is what reaches the dev
977
- * terminal and the platform's Workers Logs.
978
- *
979
- * Attribution follows the dispatched function: a log emitted inside an internal
980
- * function invoked via `ctx.runQuery`/`runMutation`/`runAction` is attributed to
981
- * the outer request entrypoint, since the composed call reuses its context.
982
- */
1564
+ * Structured, filterable key/value fields attached to a log line the second
1565
+ * argument of a `ctx.log.&lt;level>(message, fields)` call, or the fields bound by
1566
+ * `ctx.log.with(fields)`. They travel to an `ObservabilitySink`'s `onLog` and,
1567
+ * for a network sink, become OTLP log-record attributes a log pipeline (or the
1568
+ * Cloud log viewer) can filter and index on. Primitive values pass through;
1569
+ * objects/arrays are JSON-encoded at the sink boundary.
1570
+ */
1571
+ type LogFields = Record<string, unknown>;
1572
+ /**
1573
+ * One `ctx.log` severity method. Two call forms:
1574
+ *
1575
+ * - **Structured** `ctx.log.info("order placed", { orderId, total })`: a message string plus a `fields` object. The fields are indexed as attributes.
1576
+ * - **Console-style** — `ctx.log.info("state", value, other)`: any number of values, joined into the display message exactly like `console.log`.
1577
+ *
1578
+ * The structured form is matched when the second argument is a plain object;
1579
+ * otherwise the call is treated as console-style, so existing `console`-shaped
1580
+ * calls keep working unchanged.
1581
+ */
1582
+ interface LunoraLogMethod {
1583
+ (message: string, fields?: LogFields): void;
1584
+ (...args: unknown[]): void;
1585
+ }
1586
+ /**
1587
+ * Structured logger on every function `ctx`. Each call emits one attributed log
1588
+ * line — tagged with the function path on the server — that flows to an
1589
+ * `ObservabilitySink`'s `onLog` (where you route it in production) and, in
1590
+ * development, to the dev server terminal via the CLI / Vite plugin formatter.
1591
+ * Mirrors the `console` method names so it's a drop-in for `console.log` inside a
1592
+ * handler, but with attribution, structured fields, and a routable transport.
1593
+ *
1594
+ * Six severities spanning the OpenTelemetry ramp: `trace`, `debug`, `info` (and
1595
+ * its `log` alias), `warn`, `error`, `fatal`.
1596
+ *
1597
+ * Two ways to attach structured {@link LogFields}: pass them per call
1598
+ * (`ctx.log.info(message, fields)`) or bind them once with {@link with} for a
1599
+ * child logger that stamps every line. The rendered `message` and the structured
1600
+ * `fields` reach the dev terminal and the platform's Workers Logs; the raw,
1601
+ * un-rendered console-style arguments are preserved ONLY on the in-process
1602
+ * `onLog` sink (which you opt into and control).
1603
+ *
1604
+ * Attribution follows the dispatched function: a log emitted inside an internal
1605
+ * function invoked via `ctx.runQuery`/`runMutation`/`runAction` is attributed to
1606
+ * the outer request entrypoint, since the composed call reuses its context.
1607
+ */
983
1608
  interface LunoraLogger {
984
- readonly debug: (...args: unknown[]) => void;
985
- readonly error: (...args: unknown[]) => void;
986
- readonly info: (...args: unknown[]) => void;
987
- readonly log: (...args: unknown[]) => void;
988
- readonly warn: (...args: unknown[]) => void;
1609
+ readonly debug: LunoraLogMethod;
1610
+ readonly error: LunoraLogMethod;
1611
+ /**
1612
+ * Emit a **structured event** instead of a log line OpenTelemetry's Events
1613
+ * API, on the wire as `LogRecord.eventName` (plus an `event.name` attribute
1614
+ * for collectors predating that field).
1615
+ *
1616
+ * ```ts
1617
+ * ctx.log.event("checkout.completed", { plan: user.plan, total, currency });
1618
+ * ```
1619
+ *
1620
+ * The difference from `ctx.log.info("checkout completed", { … })` is what a
1621
+ * backend can do with it. A log line's payload is its message: prose, written
1622
+ * for a human, free to be reworded next sprint — so "how many checkouts
1623
+ * completed, by plan, this hour" degrades into a substring search over
1624
+ * English. An event's payload is its `fields` under a **stable name**, which a
1625
+ * collector can index, group, and alert on directly.
1626
+ *
1627
+ * Rule of thumb: `log.*` for narration you'd read while debugging, `event` for
1628
+ * anything you'd ever put on a dashboard. And for facts about the request as a
1629
+ * whole, prefer `ctx.span` — one wide event beats a dozen
1630
+ * events, however well named.
1631
+ */
1632
+ readonly event: (name: string, fields?: LogFields) => void;
1633
+ readonly fatal: LunoraLogMethod;
1634
+ readonly info: LunoraLogMethod;
1635
+ readonly log: LunoraLogMethod;
1636
+ readonly trace: LunoraLogMethod;
1637
+ readonly warn: LunoraLogMethod;
1638
+ /**
1639
+ * Return a child logger that stamps `fields` onto every line it emits,
1640
+ * merged under any per-call fields (per-call wins on a key clash). Chainable
1641
+ * — `ctx.log.with({ requestId }).with({ step })` accumulates both. Use it to
1642
+ * bind request-scoped context once instead of repeating it per call.
1643
+ */
1644
+ readonly with: (fields: LogFields) => LunoraLogger;
1645
+ }
1646
+ /**
1647
+ * Handle the enclosing `ctx.trace` span hands its body, so the body can attach
1648
+ * attributes only known *after* it resolves (an AI call's token usage / dollar
1649
+ * cost, a downstream status, a computed count). Declared structurally here to
1650
+ * mirror `shared/span-event.ts`'s `SpanHandle` and `@lunora/do`'s implementation;
1651
+ * a cross-package assignability guard in `@lunora/testing` fails the build if the
1652
+ * three drift apart. Start attributes are snapshotted before the body runs;
1653
+ * handle writes are merged over them at record time, post-hoc winning on a clash.
1654
+ */
1655
+ /**
1656
+ * One AI **evaluation** verdict — a scorer's `{name, score, label?}` — to attach
1657
+ * to a generation span via {@link SpanHandle.recordEvaluation}. Declared
1658
+ * structurally to mirror `shared/evaluation-attributes.ts`'s `EvaluationInput`
1659
+ * without a dependency edge.
1660
+ */
1661
+ interface SpanEvaluation {
1662
+ /** Optional categorical label (e.g. `"pass"` / `"fail"`), emitted as `.label`. */
1663
+ label?: string;
1664
+ /** Scorer name — the key's name segment; non-`[A-Za-z0-9._-]` chars become `_`. */
1665
+ name: string;
1666
+ /** Numeric score (typically `[0, 1]`), emitted as `.score`. */
1667
+ score: number;
1668
+ }
1669
+ interface SpanHandle {
1670
+ /**
1671
+ * Record a timestamped event on the enclosing span — a retry, a cache miss, a
1672
+ * state transition. Prefer this over an extra `ctx.log` line for anything that
1673
+ * only makes sense *relative to this span*: it rides the span's own export, so
1674
+ * it costs no additional record and can never be separated from its context.
1675
+ */
1676
+ addEvent: (name: string, attributes?: LogFields) => void;
1677
+ /**
1678
+ * Link this span to one in another trace — how a queue consumer points back at
1679
+ * the request that enqueued its message without collapsing every producer into
1680
+ * one giant trace.
1681
+ */
1682
+ addLink: (link: SpanLink) => void;
1683
+ /**
1684
+ * Attach an AI **evaluation** verdict to this (generation) span as the
1685
+ * `gen_ai.evaluation.&lt;name>.score` / `.label` OpenTelemetry attributes, so a
1686
+ * scorer's grade rides the same trace as the generation it graded. Convenience
1687
+ * over {@link SpanHandle.setAttributes} that owns the key format; privacy-safe —
1688
+ * only the name, score, and optional label are emitted. Throws on an empty name
1689
+ * or a non-finite score.
1690
+ */
1691
+ recordEvaluation: (evaluation: SpanEvaluation) => void;
1692
+ /**
1693
+ * Record a **handled** exception as the OTel-conventional `exception` span
1694
+ * event (`exception.type` / `exception.message` / `exception.stacktrace`),
1695
+ * without marking the span failed.
1696
+ *
1697
+ * For an error you swallowed — a retried request, a fallback that worked. An
1698
+ * error that escapes the span body is recorded automatically and *does* set
1699
+ * the error status, so don't call this for one you're re-throwing.
1700
+ */
1701
+ recordException: (error: unknown) => void;
1702
+ /** Set one attribute on the enclosing span (merged at record time; post-hoc wins on key clash). */
1703
+ setAttribute: (key: string, value: LogFields[string]) => void;
1704
+ /** Merge attributes onto the enclosing span (post-hoc wins on key clash). */
1705
+ setAttributes: (fields: LogFields) => void;
1706
+ /**
1707
+ * The W3C ids of the span this handle refers to (32-hex trace, 16-hex span).
1708
+ *
1709
+ * On `ctx.span` these are the DISPATCH's ids — the trace the whole request
1710
+ * belongs to. Use it to echo a trace id back to a caller so a user can quote
1711
+ * it in a bug report, to build a `traceparent` for a hand-rolled outbound
1712
+ * call, or to parent a third-party library's spans onto this request.
1713
+ */
1714
+ spanContext: () => {
1715
+ spanId: string;
1716
+ traceId: string;
1717
+ };
1718
+ }
1719
+ /**
1720
+ * A causal reference to a span in another trace (OTel `Span.links`). Ids are
1721
+ * lowercase hex — 32 chars for `traceId`, 16 for `spanId`.
1722
+ */
1723
+ interface SpanLink {
1724
+ /** Attributes describing the relationship, e.g. `{ "link.kind": "enqueued_by" }`. */
1725
+ attributes?: LogFields;
1726
+ spanId: string;
1727
+ traceId: string;
1728
+ }
1729
+ /** OTel `SpanKind`. Drives a collector's service map — see {@link SpanOptions.kind}. */
1730
+ type SpanKind = "client" | "consumer" | "internal" | "producer" | "server";
1731
+ /** Options accepted by `ctx.trace(name, fn, options)` beyond a plain attribute bag. */
1732
+ interface SpanOptions {
1733
+ /** Start attributes, snapshotted before the body runs. */
1734
+ attributes?: LogFields;
1735
+ /**
1736
+ * OTel `SpanKind`, default `"internal"`. Set `"client"` for a call OUT to
1737
+ * another service and `"producer"`/`"consumer"` for queue hops: a collector
1738
+ * builds its service map from this, so leaving everything `"internal"` yields
1739
+ * a trace with no topology.
1740
+ */
1741
+ kind?: SpanKind;
1742
+ /** Links to spans in other traces, known at span start. */
1743
+ links?: SpanLink[];
1744
+ }
1745
+ /**
1746
+ * Span factory on every function `ctx`. Wraps a sub-operation so it becomes its
1747
+ * own **span** nested under the dispatch's RPC span, giving a trace real shape:
1748
+ * without it a slow request is one opaque bar, with it you see which part was
1749
+ * slow.
1750
+ *
1751
+ * ```ts
1752
+ * const charge = await ctx.trace("stripe.charge", () => stripe.charges.create(…), { orderId });
1753
+ * ```
1754
+ *
1755
+ * **Nesting is explicit.** The body receives a tracer bound to its own span;
1756
+ * calling *that* is what makes a child:
1757
+ *
1758
+ * ```ts
1759
+ * await ctx.trace("fulfil", async (trace) => {
1760
+ * // Children of "fulfil" — including under Promise.all, where an ambient
1761
+ * // "currently open span" would mis-record these as nested inside each other.
1762
+ * await Promise.all([trace("reserve.stock", …), trace("email.receipt", …)]);
1763
+ * });
1764
+ * ```
1765
+ *
1766
+ * Calling `ctx.trace` again inside a body (rather than the passed tracer) is not
1767
+ * an error — that span is simply parented to the dispatch instead of to the
1768
+ * enclosing span, which is flatter but never wrong.
1769
+ *
1770
+ * Spans share the dispatch's trace id with its `ctx.log` lines and any container
1771
+ * the handler calls (the same `traceparent` is propagated), so one trace spans
1772
+ * worker, shard, and container.
1773
+ *
1774
+ * The span is recorded when the body settles, and the body's value is returned
1775
+ * unchanged. A throw is recorded as an error span and then **re-thrown** — this
1776
+ * is instrumentation, never flow control. Recording is best-effort: a failing
1777
+ * sink can't turn a working handler into a broken one.
1778
+ *
1779
+ * **Post-hoc attributes.** The body also receives a {@link SpanHandle} as its
1780
+ * second argument. The `attributes` passed here are stamped at span start (and
1781
+ * snapshotted, so a later mutation can't rewrite them); anything the body sets
1782
+ * through the handle — `span.setAttribute(k, v)` / `span.setAttributes({…})` — is
1783
+ * merged over that snapshot when the span is recorded, so a value known only once
1784
+ * the body has resolved (an AI call's token usage / dollar cost, a computed
1785
+ * count) still lands on the span. Post-hoc wins on a key clash. The handle is a
1786
+ * trailing parameter, so every existing `(trace) => …` body keeps working
1787
+ * unchanged.
1788
+ * @param name Span name, e.g. `"stripe.charge"`. Prefer a low-cardinality name
1789
+ * and put the varying part in `attributes` — a name built from an id makes every
1790
+ * span its own group in a collector.
1791
+ * @param fn The body to time, receiving a tracer bound to this span for any
1792
+ * nested spans and the enclosing span's {@link SpanHandle} for post-hoc
1793
+ * attributes. May be sync or async; the result is awaited.
1794
+ * @param attributes Either a plain attribute bag to stamp on the span at start
1795
+ * (normalized like a log line's `fields`), or a {@link SpanOptions} object when
1796
+ * you need `kind` or `links`. It is read as options only when *every* key is one
1797
+ * of `attributes`/`kind`/`links`; `{ attributes: { kind: "premium" } }` is the
1798
+ * explicit form if your own attributes happen to be named that.
1799
+ */
1800
+ type LunoraTracer = <T>(name: string, function_: (trace: LunoraTracer, span: SpanHandle) => Promise<T> | T, attributes?: LogFields | SpanOptions) => Promise<T>;
1801
+ /**
1802
+ * `ctx.span` — a handle onto **this request's own span**, and with it the
1803
+ * wide-event API.
1804
+ *
1805
+ * ```ts
1806
+ * export const checkout = mutation({ handler: async (ctx, args) => {
1807
+ * ctx.span.setAttributes({ "user.plan": user.plan, "cart.items": cart.length });
1808
+ * const payment = await charge(cart);
1809
+ * ctx.span.setAttributes({ "payment.provider": payment.provider, "payment.total": payment.total });
1810
+ * if (payment.retried) ctx.span.addEvent("payment.retried", { attempts: payment.attempts });
1811
+ * return payment;
1812
+ * }});
1813
+ * ```
1814
+ *
1815
+ * **Why this instead of more log lines.** The usual way to make a handler
1816
+ * observable is to sprinkle `ctx.log.info` through it, which costs one record per
1817
+ * call, scatters one request's facts across a dozen rows, and forces every
1818
+ * question to be answered by correlating them back together. The wide-event
1819
+ * pattern inverts that: accumulate the facts as you learn them, and emit **one**
1820
+ * richly-attributed record per unit of work. Cost is flat — one span per request
1821
+ * no matter how much you attach — and every question ("p99 checkout latency for
1822
+ * pro-plan users with >10 items") becomes a single filter over one table instead
1823
+ * of a join across log lines.
1824
+ *
1825
+ * **This is plain OpenTelemetry, not a Lunora convention.** The attributes land
1826
+ * on the span the dispatch already emits, and are additionally exported as an
1827
+ * OTel Event record named `lunora.dispatch`, correlated by `trace_id`/`span_id`.
1828
+ * Any OTLP backend groups and aggregates them with no special configuration.
1829
+ *
1830
+ * **`span` vs `trace`.** `ctx.trace(name, fn)` creates a NEW child span to time a
1831
+ * sub-operation; `ctx.span` attaches to the one that already exists for the
1832
+ * request. Use `trace` for "how long did this part take", `span` for "what was
1833
+ * true about this request". Inside a `ctx.trace` body, the handle passed as the
1834
+ * body's second argument is that child span's equivalent of this.
1835
+ *
1836
+ * Attributes are normalized exactly like `ctx.log` fields, and recording is
1837
+ * best-effort — a telemetry failure never breaks the handler.
1838
+ */
1839
+ type LunoraWideEvent = SpanHandle;
1840
+ /**
1841
+ * Application metrics on every function `ctx` — the third signal alongside
1842
+ * `ctx.log` and `ctx.trace`. Each call records one measurement that flows to an
1843
+ * `ObservabilitySink`'s `onMetric`, and from `otlpSink` to a collector's
1844
+ * `/v1/metrics`.
1845
+ *
1846
+ * ```ts
1847
+ * ctx.metrics.count("orders.placed", 1, { plan: user.plan });
1848
+ * ctx.metrics.record("checkout.latency_ms", Date.now() - started);
1849
+ * ctx.metrics.gauge("cart.items", cart.items.length);
1850
+ * ```
1851
+ *
1852
+ * Pick the instrument by the question you want to answer: `count` for "how many"
1853
+ * (summed over time), `gauge` for "how many right now" (replaces the last
1854
+ * reading), `record` for "what's the distribution" (percentiles, not just a
1855
+ * mean).
1856
+ *
1857
+ * `attributes` are the metric's dimensions. Keep them **low-cardinality** — an
1858
+ * attribute valued by user id or order id creates a distinct time series per id,
1859
+ * which is how a metrics backend gets expensive. Put identifiers on a log line or
1860
+ * a span instead.
1861
+ *
1862
+ * No pre-aggregation happens: one call is one exported measurement, with counter
1863
+ * deltas for the collector to sum. In a hot loop, sum locally and record once
1864
+ * rather than calling per iteration.
1865
+ */
1866
+ interface LunoraMetrics {
1867
+ /**
1868
+ * Add to a monotonic counter (default `1`) — requests served, retries,
1869
+ * bytes sent. The collector sums successive deltas.
1870
+ */
1871
+ readonly count: (name: string, value?: number, attributes?: LogFields) => void;
1872
+ /**
1873
+ * Report a point-in-time reading that replaces the previous one — queue
1874
+ * depth, cache size, connections open.
1875
+ */
1876
+ readonly gauge: (name: string, value: number, attributes?: LogFields) => void;
1877
+ /**
1878
+ * Observe one sample of a distribution — latency, payload size. Use this,
1879
+ * not a counter, when percentiles matter.
1880
+ */
1881
+ readonly record: (name: string, value: number, attributes?: LogFields) => void;
989
1882
  }
990
1883
  interface QueryCtx {
991
1884
  readonly auth: AuthState;
992
1885
  readonly db: DatabaseReader;
993
1886
  /**
994
- * The caller's IP for this request Cloudflare's trusted `CF-Connecting-IP`,
995
- * forwarded server-side (never read from a client header). `undefined` when
996
- * unknown: a live-subscription re-run, a server-initiated dispatch, or
997
- * non-Cloudflare hosting. A convenient rate-limit key for anonymous traffic.
998
- */
1887
+ * The validated, typed environment. Populated only when the project declares
1888
+ * a `defineEnv(...)` contract in `lunora/env.ts`; codegen then narrows this to
1889
+ * the validated `InferEnv` shape so `ctx.env.STRIPE_KEY` is parsed and
1890
+ * coercion-aware. Absent (optional) without a contract declare
1891
+ * `lunora/env.ts` to populate and type it.
1892
+ */
1893
+ readonly env?: Record<string, unknown>;
1894
+ /**
1895
+ * The caller's IP for this request — Cloudflare's trusted `CF-Connecting-IP`,
1896
+ * forwarded server-side (never read from a client header). `undefined` when
1897
+ * unknown: a live-subscription re-run, a server-initiated dispatch, or
1898
+ * non-Cloudflare hosting. A convenient rate-limit key for anonymous traffic.
1899
+ */
999
1900
  readonly ip?: string;
1000
1901
  /** Structured, function-attributed logger; see {@link LunoraLogger}. */
1001
1902
  readonly log: LunoraLogger;
1903
+ /** Application counters, gauges, and histograms; see {@link LunoraMetrics}. */
1002
1904
  /**
1003
- * Wall-clock time (epoch ms) the function began, captured once so the whole
1004
- * handler sees a single stable value. Query/mutation handlers must be
1005
- * deterministic they may be re-run on OCC retry / subscription re-eval so
1006
- * read time through `ctx.now` instead of `Date.now()` (the latter is flagged
1007
- * by the `nondeterministic_query_mutation` advisor). Actions may use `Date.now()`.
1008
- */
1905
+ * Static metadata declared on this procedure with `.meta(...)`, merged
1906
+ * across calls. Present so middleware can read the policy it is meant to
1907
+ * enforce (`ctx.meta.rateLimit`, …) instead of having it hard-wired at each
1908
+ * `.use()` site; absent when the procedure never called `.meta()`.
1909
+ */
1910
+ readonly meta?: Record<string, unknown>;
1911
+ readonly metrics: LunoraMetrics;
1912
+ /**
1913
+ * Wall-clock time (epoch ms) the function began, captured once so the whole
1914
+ * handler sees a single stable value. Query/mutation handlers must be
1915
+ * deterministic — they may be re-run on OCC retry / subscription re-eval — so
1916
+ * read time through `ctx.now` instead of `Date.now()` (the latter is flagged
1917
+ * by the `nondeterministic_query_mutation` advisor). Actions may use `Date.now()`.
1918
+ */
1009
1919
  readonly now: number;
1010
1920
  /**
1011
- * Compose a read-only subquery in-process, reusing this query's read
1012
- * context (same transaction, same `db`). Executes the referenced query's
1013
- * handler directly — no fresh DO RPC round-trip — so it observes the exact
1014
- * same snapshot. A query may only call other queries; there is no
1015
- * `runMutation` on a `QueryCtx` (writes are not allowed from a query).
1016
- * Mirrors Convex's `ctx.runQuery`.
1017
- */
1018
- readonly runQuery: <A extends ArgsValidator, R>(reference: RegisteredQuery<A, R>, args: InferArgs<A>) => Promise<R>;
1921
+ * Compose a read-only subquery in-process, reusing this query's read
1922
+ * context (same transaction, same `db`). Executes the referenced query's
1923
+ * handler directly — no fresh DO RPC round-trip — so it observes the exact
1924
+ * same snapshot. A query may only call other queries; there is no
1925
+ * `runMutation` on a `QueryCtx` (writes are not allowed from a query).
1926
+ * Mirrors Convex's `ctx.runQuery`.
1927
+ */
1928
+ readonly runQuery: RunQuery;
1019
1929
  /** Read account-level secrets from Cloudflare Secrets Store; see {@link Secrets}. */
1020
1930
  readonly secrets: Secrets;
1931
+ /** Attach facts to THIS request's span — the wide event; see {@link LunoraWideEvent}. */
1932
+ readonly span: LunoraWideEvent;
1021
1933
  readonly storage: ReadOnlyStorage;
1934
+ /** Wrap a sub-operation in its own nested span; see {@link LunoraTracer}. */
1935
+ readonly trace: LunoraTracer;
1022
1936
  readonly vectors: VectorSearchReader;
1023
1937
  }
1024
1938
  interface MutationCtx {
1025
1939
  readonly auth: AuthState;
1026
1940
  readonly db: DatabaseWriter;
1027
1941
  /**
1028
- * The caller's IP for this request Cloudflare's trusted `CF-Connecting-IP`,
1029
- * forwarded server-side (never read from a client header). `undefined` when
1030
- * unknown: a live-subscription re-run, a server-initiated dispatch, or
1031
- * non-Cloudflare hosting. A convenient rate-limit key for anonymous traffic.
1032
- */
1942
+ * The validated, typed environment. Populated only when the project declares
1943
+ * a `defineEnv(...)` contract in `lunora/env.ts`; codegen then narrows this to
1944
+ * the validated `InferEnv` shape so `ctx.env.STRIPE_KEY` is parsed and
1945
+ * coercion-aware. Absent (optional) without a contract declare
1946
+ * `lunora/env.ts` to populate and type it.
1947
+ */
1948
+ readonly env?: Record<string, unknown>;
1949
+ /**
1950
+ * The caller's IP for this request — Cloudflare's trusted `CF-Connecting-IP`,
1951
+ * forwarded server-side (never read from a client header). `undefined` when
1952
+ * unknown: a live-subscription re-run, a server-initiated dispatch, or
1953
+ * non-Cloudflare hosting. A convenient rate-limit key for anonymous traffic.
1954
+ */
1033
1955
  readonly ip?: string;
1034
1956
  /** Structured, function-attributed logger; see {@link LunoraLogger}. */
1035
1957
  readonly log: LunoraLogger;
1958
+ /** Application counters, gauges, and histograms; see {@link LunoraMetrics}. */
1036
1959
  /**
1037
- * Wall-clock time (epoch ms) the function began, captured once so the whole
1038
- * handler sees a single stable value. Mutation handlers must be deterministic
1039
- * they may be re-run on OCC retry so read time through `ctx.now` instead
1040
- * of `Date.now()` (the latter is flagged by the `nondeterministic_query_mutation`
1041
- * advisor). Actions may use `Date.now()`.
1042
- */
1960
+ * Static metadata declared on this procedure with `.meta(...)`, merged
1961
+ * across calls. Present so middleware can read the policy it is meant to
1962
+ * enforce (`ctx.meta.rateLimit`, …) instead of having it hard-wired at each
1963
+ * `.use()` site; absent when the procedure never called `.meta()`.
1964
+ */
1965
+ readonly meta?: Record<string, unknown>;
1966
+ readonly metrics: LunoraMetrics;
1967
+ /**
1968
+ * Wall-clock time (epoch ms) the function began, captured once so the whole
1969
+ * handler sees a single stable value. Mutation handlers must be deterministic
1970
+ * — they may be re-run on OCC retry — so read time through `ctx.now` instead
1971
+ * of `Date.now()` (the latter is flagged by the `nondeterministic_query_mutation`
1972
+ * advisor). Actions may use `Date.now()`.
1973
+ */
1043
1974
  readonly now: number;
1044
1975
  /**
1045
- * Compose a submutation in-process, reusing this mutation's `db` writer.
1046
- * Executes the referenced mutation's handler directly — no fresh DO RPC —
1047
- * so its writes apply through the same shard invocation as the enclosing
1048
- * mutation. Note: writes are not wrapped in a SQL transaction, so a partial
1049
- * failure does not roll back earlier writes (the same as a top-level
1050
- * mutation). Mirrors Convex's `ctx.runMutation`.
1051
- */
1052
- readonly runMutation: <A extends ArgsValidator, R>(reference: RegisteredMutation<A, R>, args: InferArgs<A>) => Promise<R>;
1053
- /**
1054
- * Compose a read-only subquery in-process, reusing this mutation's `db`.
1055
- * Executes the referenced query's handler directly — no fresh DO RPC — so
1056
- * it observes this mutation's in-flight writes. Mirrors Convex's
1057
- * `ctx.runQuery`.
1058
- */
1059
- readonly runQuery: <A extends ArgsValidator, R>(reference: RegisteredQuery<A, R>, args: InferArgs<A>) => Promise<R>;
1976
+ * Compose a submutation in-process, reusing this mutation's `db` writer.
1977
+ * Executes the referenced mutation's handler directly — no fresh DO RPC —
1978
+ * so its writes apply through the same shard invocation as the enclosing
1979
+ * mutation. Note: writes are not wrapped in a SQL transaction, so a partial
1980
+ * failure does not roll back earlier writes (the same as a top-level
1981
+ * mutation). Mirrors Convex's `ctx.runMutation`.
1982
+ */
1983
+ readonly runMutation: RunMutation;
1984
+ /**
1985
+ * Compose a read-only subquery in-process, reusing this mutation's `db`.
1986
+ * Executes the referenced query's handler directly — no fresh DO RPC — so
1987
+ * it observes this mutation's in-flight writes. Mirrors Convex's
1988
+ * `ctx.runQuery`.
1989
+ */
1990
+ readonly runQuery: RunQuery;
1060
1991
  readonly scheduler: Scheduler;
1061
1992
  /** Read account-level secrets from Cloudflare Secrets Store; see {@link Secrets}. */
1062
1993
  readonly secrets: Secrets;
1994
+ /** Attach facts to THIS request's span — the wide event; see {@link LunoraWideEvent}. */
1995
+ readonly span: LunoraWideEvent;
1063
1996
  readonly storage: ReadOnlyStorage;
1997
+ /** Wrap a sub-operation in its own nested span; see {@link LunoraTracer}. */
1998
+ readonly trace: LunoraTracer;
1064
1999
  readonly vectors: VectorSearch;
1065
2000
  /** Start / resume / inspect durable workflows; see {@link Workflows}. */
1066
2001
  readonly workflows: Workflows;
1067
2002
  }
1068
2003
  interface ActionCtx {
1069
2004
  readonly auth: AuthState;
2005
+ /**
2006
+ * Programmatic Workers Cache purge; see {@link CachePurge}.
2007
+ * **Action-only** — actions run in the Worker, which has a `cache` binding.
2008
+ * Queries and mutations run inside the Durable Object and do not expose this.
2009
+ * Optional at runtime because Workers Cache is only present when enabled.
2010
+ */
2011
+ readonly cache?: CachePurge;
1070
2012
  readonly db: DatabaseWriter;
2013
+ /**
2014
+ * The validated, typed environment. Populated only when the project declares
2015
+ * a `defineEnv(...)` contract in `lunora/env.ts`; codegen then narrows this to
2016
+ * the validated `InferEnv` shape so `ctx.env.STRIPE_KEY` is parsed and
2017
+ * coercion-aware. Absent (optional) without a contract — declare
2018
+ * `lunora/env.ts` to populate and type it.
2019
+ */
2020
+ readonly env?: Record<string, unknown>;
1071
2021
  readonly fetch: typeof globalThis.fetch;
1072
2022
  /**
1073
- * The caller's IP for this request — Cloudflare's trusted `CF-Connecting-IP`,
1074
- * forwarded server-side (never read from a client header). `undefined` when
1075
- * unknown: a live-subscription re-run, a server-initiated dispatch, or
1076
- * non-Cloudflare hosting. A convenient rate-limit key for anonymous traffic.
1077
- */
2023
+ * The caller's IP for this request — Cloudflare's trusted `CF-Connecting-IP`,
2024
+ * forwarded server-side (never read from a client header). `undefined` when
2025
+ * unknown: a live-subscription re-run, a server-initiated dispatch, or
2026
+ * non-Cloudflare hosting. A convenient rate-limit key for anonymous traffic.
2027
+ */
1078
2028
  readonly ip?: string;
1079
2029
  /** Structured, function-attributed logger; see {@link LunoraLogger}. */
1080
2030
  readonly log: LunoraLogger;
2031
+ /** Application counters, gauges, and histograms; see {@link LunoraMetrics}. */
2032
+ /**
2033
+ * Static metadata declared on this procedure with `.meta(...)`, merged
2034
+ * across calls. Present so middleware can read the policy it is meant to
2035
+ * enforce (`ctx.meta.rateLimit`, …) instead of having it hard-wired at each
2036
+ * `.use()` site; absent when the procedure never called `.meta()`.
2037
+ */
2038
+ readonly meta?: Record<string, unknown>;
2039
+ readonly metrics: LunoraMetrics;
1081
2040
  /**
1082
- * Wall-clock time (epoch ms) the action began, captured once for convenience
1083
- * and parity with query/mutation `ctx.now`. Actions run exactly once, so they
1084
- * may also use ambient `Date.now()` freely.
1085
- */
2041
+ * Wall-clock time (epoch ms) the action began, captured once for convenience
2042
+ * and parity with query/mutation `ctx.now`. Actions run exactly once, so they
2043
+ * may also use ambient `Date.now()` freely.
2044
+ */
1086
2045
  readonly now: number;
1087
- readonly runAction: <A extends ArgsValidator, R>(reference: RegisteredAction<A, R>, args: InferArgs<A>) => Promise<R>;
1088
- readonly runMutation: <A extends ArgsValidator, R>(reference: RegisteredMutation<A, R>, args: InferArgs<A>) => Promise<R>;
1089
- readonly runQuery: <A extends ArgsValidator, R>(reference: RegisteredQuery<A, R>, args: InferArgs<A>) => Promise<R>;
2046
+ readonly runAction: RunAction;
2047
+ readonly runMutation: RunMutation;
2048
+ readonly runQuery: RunQuery;
1090
2049
  readonly scheduler: Scheduler;
1091
2050
  /** Read account-level secrets from Cloudflare Secrets Store; see {@link Secrets}. */
1092
2051
  readonly secrets: Secrets;
2052
+ /** Attach facts to THIS request's span — the wide event; see {@link LunoraWideEvent}. */
2053
+ readonly span: LunoraWideEvent;
1093
2054
  readonly storage: Storage;
2055
+ /** Wrap a sub-operation in its own nested span; see {@link LunoraTracer}. */
2056
+ readonly trace: LunoraTracer;
1094
2057
  readonly vectors: VectorSearch;
1095
2058
  /** Start / resume / inspect durable workflows; see {@link Workflows}. */
1096
2059
  readonly workflows: Workflows;
1097
2060
  }
1098
2061
  /**
1099
- * Stand-in returned by codegen so projects can `import { api } from "./_generated/api"`.
1100
- * The runtime value is opaque; the types are filled in by generated declarations.
1101
- */
2062
+ * Stand-in returned by codegen so projects can `import { api } from "./_generated/api"`.
2063
+ * The runtime value is opaque; the types are filled in by generated declarations.
2064
+ */
1102
2065
  type AnyApi = Record<string, Record<string, RegisteredFunction<ArgsValidator, unknown, FunctionKind>>>;
1103
2066
  declare const anyApi: AnyApi;
1104
- export { type ActionCtx, type AggregateIndexDefinition, type AggregateOp, type AnyApi, type ArgsValidator, type AuthState, type DatabaseReader, type DatabaseWriter, type DurableObjectJurisdiction, type FunctionKind, type FunctionVisibility, type GlobalBackend, type IndexDefinition, type IndexRangeBuilder, type InferArgs, type LifecycleEvent, type LifecycleEventKind, type LunoraLogger, type MutationCtx, type OnDeleteAction, type PaginationOptions, type PaginationResult, type QueryCtx, type RankIndexDefinition, type RankSortKey, type ReadOnlyStorage, type RegisteredAction, type RegisteredFunction, type RegisteredLifecycleHook, type RegisteredMutation, type RegisteredQuery, type RegisteredStream, type RelationDefinition, type ScheduledFunctionDoc, type ScheduledJob, type Scheduler, type Schema, type SearchFilterBuilder, type SearchIndexDefinition, type Secrets, type SecretsStoreSecretLike, type ShardMode, type Storage, type StorageMetadata, type SystemDatabaseReader, type SystemDoc, type SystemQuery, type SystemTableName, type TableDefinition, type TableReader, type TableVectorIndex, type TriggerAggregateOptions, type TriggerBuilder, type TriggerCtx, type TriggerDatabase, type TriggerDefinition, type TriggerDeleteEvent, type TriggerEvent, type TriggerGroupByEntry, type TriggerGroupByOptions, type TriggerHandler, type TriggerInsertEvent, type TriggerOp, type TriggerQueryArgs, type TriggerQueryPage, type TriggerRankOptions, type TriggerRankPageOptions, type TriggerRankResult, type TriggerRow, type TriggerTiming, type TriggerUpdateEvent, type VectorEmbedder, type VectorIndexDefinition, type VectorMatch, type VectorMatches, type VectorMetric, type VectorQueryInput, type VectorRecord, type VectorSearch, type VectorSearchReader, type VectorUpsertInput, type WorkflowCreateOptions, type WorkflowHandle, type WorkflowInstance, type WorkflowInstanceStatus, type WorkflowStatusResult, type Workflows, anyApi };
2067
+ export { type ActionCtx, type AggregateIndexDefinition, type AggregateOp, type AnyApi, type ArgsValidator, type AuthState, type CachePurge, type DatabaseReader, type DatabaseWriter, type DurableObjectJurisdiction, type ExposeConfig, type ExternalSourceCursor, type ExternalSourceDefinition, type ExternalSourceMode, type ExternalSourceRefresh, type FunctionKind, type FunctionVisibility, type GeoBoundingBox, type GeoFilterBuilder, type GeoIndexDefinition, type GeoPointInput, type GlobalBackend, type IndexDefinition, type IndexRangeBuilder, type InferArgs, type LifecycleEvent, type LifecycleEventKind, type LogFields, type LunoraLogMethod, type LunoraLogger, type LunoraMetrics, type LunoraTracer, type LunoraWideEvent, type MutationCtx, type OnDeleteAction, type PaginationOptions, type PaginationResult, type QueryCtx, type RankIndexDefinition, type RankSortKey, type ReadOnlyStorage, type RegisteredAction, type RegisteredFunction, type RegisteredLifecycleHook, type RegisteredMutation, type RegisteredQuery, type RegisteredStream, type RelationDefinition, type RestCacheConfig, type ScheduledFunctionDoc, type ScheduledJob, type Scheduler, type Schema, type SearchFilterBuilder, type SearchIndexDefinition, type SearchLanguage, type SearchStrategy, type Secrets, type SecretsStoreSecretLike, type ShardMode, type SpanEvaluation, type SpanHandle, type SpanKind, type SpanLink, type SpanOptions, type Storage, type StorageMetadata, type SystemDatabaseReader, type SystemDoc, type SystemQuery, type SystemTableName, type TableDefinition, type TableReader, type TableVectorIndex, type TriggerAggregateOptions, type TriggerBuilder, type TriggerCtx, type TriggerDatabase, type TriggerDefinition, type TriggerDeleteEvent, type TriggerEvent, type TriggerGroupByEntry, type TriggerGroupByOptions, type TriggerHandler, type TriggerInsertEvent, type TriggerOp, type TriggerQueryArgs, type TriggerQueryPage, type TriggerRankOptions, type TriggerRankPageOptions, type TriggerRankResult, type TriggerRow, type TriggerTiming, type TriggerUpdateEvent, type TtlDefinition, type VectorEmbedder, type VectorIndexDefinition, type VectorMatch, type VectorMatches, type VectorMetric, type VectorQueryInput, type VectorRecord, type VectorSearch, type VectorSearchReader, type VectorUpsertInput, type WorkflowCreateOptions, type WorkflowHandle, type WorkflowInstance, type WorkflowInstanceStatus, type WorkflowStatusResult, type Workflows, type X402ProcedureConfig, anyApi };