@lunora/server 1.0.0-alpha.8 → 1.0.0-alpha.80

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