@lunora/server 1.0.0-alpha.1 → 1.0.0-alpha.100

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