@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/index.d.mts CHANGED
@@ -1,37 +1,30 @@
1
- import { Validator, Infer, v } from '@lunora/values';
2
- export { type ColumnValidator, type Id, type Infer, ValidationError, type Validator, type ValidatorKind, v } from '@lunora/values';
3
- import { ArgsValidator, InferArgs, RegisteredAction, ActionCtx, MutationCtx, RegisteredMutation, QueryCtx, RegisteredQuery, RegisteredStream, FunctionKind, LifecycleEvent, RegisteredLifecycleHook, TableDefinition, RegisteredFunction, VectorIndexDefinition, Schema, AggregateOp, RelationDefinition, GlobalBackend, OnDeleteAction, TriggerBuilder, TriggerDefinition, VectorEmbedder, VectorMetric, AggregateIndexDefinition, RankIndexDefinition } from "./types.mjs";
4
- export { type AnyApi, type AuthState, type DatabaseReader, type DatabaseWriter, type FunctionVisibility, type IndexDefinition, type IndexRangeBuilder, type LifecycleEventKind, type LunoraLogger, type PaginationOptions, type PaginationResult, type RankSortKey, type ReadOnlyStorage, type ScheduledFunctionDoc, type ScheduledJob, type Scheduler, type SearchFilterBuilder, type SearchIndexDefinition, type ShardMode, type Storage, type StorageMetadata, type SystemDatabaseReader, type SystemDoc, type SystemQuery, type SystemTableName, type TableReader, type TableVectorIndex, type TriggerAggregateOptions, type TriggerCtx, type TriggerDatabase, 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 VectorMatch, type VectorMatches, type VectorQueryInput, type VectorRecord, type VectorSearch, type VectorSearchReader, type VectorUpsertInput, type WorkflowCreateOptions, type WorkflowHandle, type WorkflowInstance, type WorkflowInstanceStatus, type WorkflowStatusResult, type Workflows, anyApi } from "./types.mjs";
1
+ import { Validator, Infer, v, Id, ValidatorMap, InferValidatorMap, ColumnValidator } from '@lunora/values';
2
+ export { type ColumnValidator, type GeoPoint, type Id, type Infer, ValidationError, type Validator, type ValidatorKind, v } from '@lunora/values';
3
+ import { ArgsValidator, InferArgs, RegisteredAction, ExposeConfig, X402ProcedureConfig, ActionCtx, MutationCtx, RegisteredMutation, QueryCtx, RegisteredQuery, DurableStreamOptions, RegisteredStream, FunctionKind, TableDefinition, RegisteredFunction, VectorIndexDefinition, Schema, AggregateOp, GlobalBackend, RelationDefinition, OnDeleteAction, SearchLanguage, SearchStrategy, ExternalSourceDefinition, TriggerBuilder, TriggerDefinition, VectorEmbedder, VectorMetric, DurableObjectJurisdiction, AggregateIndexDefinition, RankIndexDefinition, Secrets, LifecycleEvent, ShardInitEvent, RegisteredLifecycleHook } from "./types.mjs";
4
+ export { type AnyApi, type AuthState, type CachePurge, type DatabaseReader, type DatabaseWriter, type FunctionVisibility, type GeoBoundingBox, type GeoFilterBuilder, type GeoIndexDefinition, type GeoPointInput, type IndexDefinition, type IndexRangeBuilder, type LifecycleEventKind, type LogFields, type LunoraLogMethod, type LunoraLogger, type LunoraMetrics, type LunoraTracer, type LunoraWideEvent, type MutationStorage, type PaginationOptions, type PaginationResult, type RankSortKey, type ReadOnlyStorage, type RestCacheConfig, type RetryPolicy, type RunQueryOptions, type ScheduledFunctionDoc, type ScheduledJob, type Scheduler, type SearchFilterBuilder, type SearchIndexDefinition, 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 TableReader, type TableVectorIndex, type TriggerAggregateOptions, type TriggerCtx, type TriggerDatabase, 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 VectorMatch, type VectorMatches, type VectorQueryInput, type VectorRecord, type VectorSearch, type VectorSearchReader, type VectorUpsertInput, type WorkflowCreateOptions, type WorkflowEventDefinition, type WorkflowHandle, type WorkflowInstance, type WorkflowInstanceStatus, type WorkflowStatusResult, type Workflows, anyApi } from "./types.mjs";
5
+ import { LunoraError } from '@lunora/errors';
6
+ export { LunoraError, type LunoraErrorCode } from '@lunora/errors';
5
7
  import { Context, Hono } from 'hono';
6
- import { b as Permission, R as Role, T as TypedDefinePolicyInput, a as Policy, D as DefinePolicyInput, W as WhereInput, c as RlsOptions } from "./packem_shared/types.d-DmvyEMD6.mjs";
7
- export type { d as PolicyContext, e as PolicyDecision, f as PolicyDecisionOf, P as PolicyOperation } from "./packem_shared/types.d-DmvyEMD6.mjs";
8
+ import { WhereOperators, QueryArgs as QueryArgs$2 } from "./data-model.mjs";
9
+ import { W as WhereInput, a as Policy, b as RlsOptions, c as Permission, R as Role, T as TypedDefinePolicyInput, D as DefinePolicyInput } from "./packem_shared/types.d-B7FLdYUD.mjs";
10
+ export type { d as PolicyContext, e as PolicyDecision, f as PolicyDecisionOf, P as PolicyOperation } from "./packem_shared/types.d-B7FLdYUD.mjs";
8
11
  export { type CronJob, type CronJobsBuilder, type CronScheduleKind, type DailySchedule, type IntervalSchedule, type MonthlySchedule, type WeeklySchedule, cronJobs } from '@lunora/scheduler';
9
- import "./data-model.mjs";
10
- /**
11
- * Make any `config.storage` result bucket-aware so `ctx.storage.bucket(name)`
12
- * always resolves. A `createBucketStorage(...)` result already carries
13
- * `.bucket` / `.bucketName` and is returned as-is; a single `createStorage(...)`
14
- * (or the no-storage stub) is tagged as the `"default"` bucket, where
15
- * `.bucket(name)` is the identity — single-bucket apps address one binding under
16
- * every name.
17
- *
18
- * This is the runtime counterpart the generated `_generated/shard.ts` imports to
19
- * wrap `ctx.storage`; it lives here (the single source) rather than being stamped
20
- * inline into every generated file, so the bucket-tagging behaviour has one home
21
- * alongside the storage ctx types. The input is genuinely heterogeneous (a thunk
22
- * result cast through `unknown`), so the signature is `unknown → unknown`; the
23
- * generated caller casts the result to its storage type.
24
- */
25
- declare const asBucketStorage: (raw: unknown) => unknown;
12
+ /**
13
+ * Options for the `.stream()` terminal. `durable: true` is shorthand for
14
+ * `durable: {}` the runtime only ever sees the object form.
15
+ */
16
+ interface StreamOptions {
17
+ durable?: boolean | DurableStreamOptions;
18
+ }
26
19
  /** Builder discriminator. Codegen reads this kind. */
27
20
  type TerminalKind = FunctionKind;
28
21
  /** Initial (empty) accumulated args for a fresh builder. */
29
22
  type EmptyArgs = Record<never, never>;
30
23
  /**
31
- * `next()` advances the middleware chain. Called with no argument it forwards
32
- * the current context unchanged; called with `{ ctx }` it shallow-merges the
33
- * extension, and the result type reflects the widened context.
34
- */
24
+ * `next()` advances the middleware chain. Called with no argument it forwards
25
+ * the current context unchanged; called with `{ ctx }` it shallow-merges the
26
+ * extension, and the result type reflects the widened context.
27
+ */
35
28
  interface MiddlewareNext<ContextIn> {
36
29
  (): Promise<ContextIn>;
37
30
  <Extension extends Record<string, unknown>>(options: {
@@ -39,28 +32,69 @@ interface MiddlewareNext<ContextIn> {
39
32
  }): Promise<ContextIn & Extension>;
40
33
  }
41
34
  /**
42
- * A middleware receives the current context and a `next` continuation. Its
43
- * return type becomes the builder's new context, so `return next({ ctx })`
44
- * propagates the extension into every downstream `.use()` and the handler.
45
- */
35
+ * A middleware receives the current context and a `next` continuation. Its
36
+ * return type becomes the builder's new context, so `return next({ ctx })`
37
+ * propagates the extension into every downstream `.use()` and the handler.
38
+ */
46
39
  type Middleware<ContextIn, ContextOut> = (options: {
47
40
  ctx: ContextIn;
48
41
  next: MiddlewareNext<ContextIn>;
49
42
  }) => ContextOut | Promise<ContextOut>;
50
- /** Options accepted by `initLunora.dataModel&lt;DM>().create(...)`. Reserved for transformer/error-formatter wiring. */
43
+ /**
44
+ * The context a `.use(...)` step actually receives: the procedure context plus
45
+ * `args`, the call's arguments as declared by `.input(...)` up to this point in
46
+ * the chain.
47
+ *
48
+ * A middleware that gates on the payload — a CAPTCHA token, a signup email —
49
+ * has nowhere else to read it from: the procedure context carries the resolved
50
+ * identity, not the request body. `args` is surfaced only AFTER the validators
51
+ * have run, and as a frozen shallow copy, so a middleware cannot rewrite what
52
+ * the handler is then handed.
53
+ *
54
+ * This is a PROCEDURE-builder surface. `httpAction` / `httpRoute` have no
55
+ * `.use()` chain at all (`HttpActionCtx` is not a builder context) — an HTTP
56
+ * handler reads its own `request` / `searchParams` / `body` and calls the
57
+ * underlying helper (`verifyTurnstile(...)`, `assertEmailAllowed(...)`) inline,
58
+ * or mounts a hono middleware.
59
+ */
60
+ type MiddlewareContext<Context, Args extends ArgsValidator> = Context & {
61
+ readonly args: Readonly<InferArgs<Args>>;
62
+ };
63
+ /** Options accepted by `initLunora.dataModel<DM>().create(...)`. Reserved for transformer/error-formatter wiring. */
51
64
  type CreateOptions = Record<never, never>;
52
65
  /**
53
- * `Output` carries the type declared by `.output(validator)`. It defaults to
54
- * the `undefined` sentinel meaning "not declared": in that state the terminal
55
- * stays generic over the handler's own return type. Once `.output()` sets it to
56
- * a concrete type, the terminal requires the handler to return that type and
57
- * the registration is typed to it (the runtime parses the result through the
58
- * validator). `[Output] extends [undefined]` is wrapped in a tuple so a union
59
- * `Output` doesn't distribute and so the test is for the exact sentinel.
60
- */
66
+ * `Output` carries the type declared by `.output(validator)`. It defaults to
67
+ * the `undefined` sentinel meaning "not declared": in that state the terminal
68
+ * stays generic over the handler's own return type. Once `.output()` sets it to
69
+ * a concrete type, the terminal requires the handler to return that type and
70
+ * the registration is typed to it (the runtime parses the result through the
71
+ * validator). `[Output] extends [undefined]` is wrapped in a tuple so a union
72
+ * `Output` doesn't distribute and so the test is for the exact sentinel.
73
+ */
61
74
  interface QueryBuilder<Context, Args extends ArgsValidator, Output = undefined> {
62
75
  readonly __lunoraProcedure: "query";
76
+ /**
77
+ * Publish this query on the opt-in public REST surface (plan 167) — the
78
+ * runtime mints `GET /_lunora/rest/<namespace>/<fn>` (and `POST`), dispatching
79
+ * THROUGH the procedure so `ctx.auth` / RLS / validators are enforced, and the
80
+ * generated OpenAPI describes it. Default-closed: omit to keep it RPC-only.
81
+ */
82
+ expose: (config: ExposeConfig) => QueryBuilder<Context, Args, Output>;
63
83
  input: <A extends ArgsValidator>(validators: A) => QueryBuilder<Context, A & Args, Output>;
84
+ /**
85
+ * Attach static, per-procedure metadata. Merges across calls and is readable
86
+ * from middleware as `ctx.meta`.
87
+ *
88
+ * The point is policy that is DATA rather than a call: `.meta({ rateLimit:
89
+ * "pins/create" })` lets ONE generic middleware read the policy it is meant
90
+ * to enforce off `ctx.meta`, where the same policy expressed only as
91
+ * `.use(rateLimit("pins/create"))` has to be re-parameterised at every
92
+ * `.use()` site. Mirrors tRPC's `.meta()`. The value is structured-cloned
93
+ * and deep-frozen: the same copy reaches every request, so it must not be
94
+ * mutable, and the object you passed stays yours (unfrozen). It must be
95
+ * structured-cloneable data — a function or class instance is rejected.
96
+ */
97
+ meta: (value: Record<string, unknown>) => QueryBuilder<Context, Args, Output>;
64
98
  output: <V extends Validator>(validator: V) => QueryBuilder<Context, Args, Infer<V>>;
65
99
  query: [Output] extends [undefined] ? <R>(handler: (options: {
66
100
  args: InferArgs<Args>;
@@ -70,23 +104,63 @@ interface QueryBuilder<Context, Args extends ArgsValidator, Output = undefined>
70
104
  ctx: Context;
71
105
  }) => Output | Promise<Output>) => RegisteredQuery<Args, Output>;
72
106
  /**
73
- * Terminal: declare this procedure as a streaming query. The handler is an
74
- * async generator (or any function returning an `AsyncIterable&lt;R>`) that
75
- * yields one chunk per server-pushed frame. The third `signal` argument is
76
- * tripped when the client cancels — break out of the loop or check
77
- * `signal.aborted` between yields. `.output()` does not apply: per-chunk
78
- * validation is opt-in via the handler itself.
79
- */
80
- stream: <R>(handler: (options: {
107
+ * Terminal: declare this procedure as a streaming query. The handler is an
108
+ * async generator (or any function returning an `AsyncIterable<R>`) that
109
+ * yields one chunk per server-pushed frame. The third `signal` argument is
110
+ * tripped when the client cancels — break out of the loop or check
111
+ * `signal.aborted` between yields.
112
+ *
113
+ * **Unavailable after `.output()`, deliberately.** Chunks are yielded as-is —
114
+ * there is no per-chunk validation — so `.output(...).stream(...)` used to
115
+ * compile and quietly enforce nothing, which is worse than not offering the
116
+ * combination: the author asked for validation and was told yes. Declaring an
117
+ * output on a stream is now a type error rather than a false promise. Validate
118
+ * inside the handler, or return the whole payload from `.query()` instead.
119
+ *
120
+ * Pass `{ durable: true }` to make the run outlive the socket that opened
121
+ * it: chunks are persisted as they are produced, so a reload resumes the
122
+ * same run from where it left off instead of dropping the work, and a
123
+ * second client with the same arguments attaches to the same transcript.
124
+ * That is what an LLM response wants; a progress ticker does not need it.
125
+ */
126
+ stream: [Output] extends [undefined] ? <R>(handler: (options: {
81
127
  args: InferArgs<Args>;
82
128
  ctx: Context;
83
129
  signal: AbortSignal;
84
- }) => AsyncGenerator<R, void, void> | AsyncIterable<R>) => RegisteredStream<Args, R>;
85
- use: <ContextOut>(middleware: Middleware<Context, ContextOut>) => QueryBuilder<ContextOut, Args, Output>;
130
+ }) => AsyncGenerator<R, void, void> | AsyncIterable<R>, options?: StreamOptions) => RegisteredStream<Args, R> : never;
131
+ use: <ContextOut>(middleware: Middleware<MiddlewareContext<Context, Args>, ContextOut>) => QueryBuilder<ContextOut, Args, Output>;
132
+ /**
133
+ * Mark this query as paid. The origin worker answers an unpaid client RPC
134
+ * with HTTP 402, verifies + settles the x402 payment, then dispatches. `price`
135
+ * is USD (a number of dollars or a `"0.01"`/`"$0.01"` string); the network,
136
+ * recipient, and facilitator come from the worker-level x402 charge config.
137
+ */
138
+ x402: (config: X402ProcedureConfig) => QueryBuilder<Context, Args, Output>;
86
139
  }
87
140
  interface MutationBuilder<Context, Args extends ArgsValidator, Output = undefined> {
88
141
  readonly __lunoraProcedure: "mutation";
142
+ /**
143
+ * Publish this mutation on the opt-in public REST surface (plan 167) — the
144
+ * runtime mints `POST /_lunora/rest/<namespace>/<fn>`, dispatching THROUGH the
145
+ * procedure so `ctx.auth` / RLS / validators are enforced, and the generated
146
+ * OpenAPI describes it. Default-closed: omit to keep it RPC-only.
147
+ */
148
+ expose: (config: ExposeConfig) => MutationBuilder<Context, Args, Output>;
89
149
  input: <A extends ArgsValidator>(validators: A) => MutationBuilder<Context, A & Args, Output>;
150
+ /**
151
+ * Attach static, per-procedure metadata. Merges across calls and is readable
152
+ * from middleware as `ctx.meta`.
153
+ *
154
+ * The point is policy that is DATA rather than a call: `.meta({ rateLimit:
155
+ * "pins/create" })` lets ONE generic middleware read the policy it is meant
156
+ * to enforce off `ctx.meta`, where the same policy expressed only as
157
+ * `.use(rateLimit("pins/create"))` has to be re-parameterised at every
158
+ * `.use()` site. Mirrors tRPC's `.meta()`. The value is structured-cloned
159
+ * and deep-frozen: the same copy reaches every request, so it must not be
160
+ * mutable, and the object you passed stays yours (unfrozen). It must be
161
+ * structured-cloneable data — a function or class instance is rejected.
162
+ */
163
+ meta: (value: Record<string, unknown>) => MutationBuilder<Context, Args, Output>;
90
164
  mutation: [Output] extends [undefined] ? <R>(handler: (options: {
91
165
  args: InferArgs<Args>;
92
166
  ctx: Context;
@@ -95,7 +169,14 @@ interface MutationBuilder<Context, Args extends ArgsValidator, Output = undefine
95
169
  ctx: Context;
96
170
  }) => Output | Promise<Output>) => RegisteredMutation<Args, Output>;
97
171
  output: <V extends Validator>(validator: V) => MutationBuilder<Context, Args, Infer<V>>;
98
- use: <ContextOut>(middleware: Middleware<Context, ContextOut>) => MutationBuilder<ContextOut, Args, Output>;
172
+ use: <ContextOut>(middleware: Middleware<MiddlewareContext<Context, Args>, ContextOut>) => MutationBuilder<ContextOut, Args, Output>;
173
+ /**
174
+ * Mark this mutation as paid. The origin worker answers an unpaid client RPC
175
+ * with HTTP 402, verifies + settles the x402 payment, then dispatches. `price`
176
+ * is USD (a number of dollars or a `"0.01"`/`"$0.01"` string); the network,
177
+ * recipient, and facilitator come from the worker-level x402 charge config.
178
+ */
179
+ x402: (config: X402ProcedureConfig) => MutationBuilder<Context, Args, Output>;
99
180
  }
100
181
  interface ActionBuilder<Context, Args extends ArgsValidator, Output = undefined> {
101
182
  readonly __lunoraProcedure: "action";
@@ -106,20 +187,62 @@ interface ActionBuilder<Context, Args extends ArgsValidator, Output = undefined>
106
187
  args: InferArgs<Args>;
107
188
  ctx: Context;
108
189
  }) => Output | Promise<Output>) => RegisteredAction<Args, Output>;
190
+ /**
191
+ * Publish this action on the opt-in public REST surface (plan 167) — the
192
+ * runtime mints `POST /_lunora/rest/<namespace>/<fn>`, dispatching THROUGH the
193
+ * procedure so `ctx.auth` / RLS / validators are enforced, and the generated
194
+ * OpenAPI describes it. Default-closed: omit to keep it RPC-only.
195
+ */
196
+ expose: (config: ExposeConfig) => ActionBuilder<Context, Args, Output>;
109
197
  input: <A extends ArgsValidator>(validators: A) => ActionBuilder<Context, A & Args, Output>;
198
+ /**
199
+ * Attach static, per-procedure metadata. Merges across calls and is readable
200
+ * from middleware as `ctx.meta`.
201
+ *
202
+ * The point is policy that is DATA rather than a call: `.meta({ rateLimit:
203
+ * "pins/create" })` lets ONE generic middleware read the policy it is meant
204
+ * to enforce off `ctx.meta`, where the same policy expressed only as
205
+ * `.use(rateLimit("pins/create"))` has to be re-parameterised at every
206
+ * `.use()` site. Mirrors tRPC's `.meta()`. The value is structured-cloned
207
+ * and deep-frozen: the same copy reaches every request, so it must not be
208
+ * mutable, and the object you passed stays yours (unfrozen). It must be
209
+ * structured-cloneable data — a function or class instance is rejected.
210
+ */
211
+ meta: (value: Record<string, unknown>) => ActionBuilder<Context, Args, Output>;
110
212
  output: <V extends Validator>(validator: V) => ActionBuilder<Context, Args, Infer<V>>;
111
- use: <ContextOut>(middleware: Middleware<Context, ContextOut>) => ActionBuilder<ContextOut, Args, Output>;
112
- }
113
- /**
114
- * Internal builder variants. Identical to their public counterparts but carry
115
- * the `__lunoraVisibility: "internal"` brand codegen keys off to route the
116
- * registration into the `internal` object (and keep it off `api`). `input`/`use`
117
- * return the internal builder type so the brand survives the whole chain.
118
- */
213
+ use: <ContextOut>(middleware: Middleware<MiddlewareContext<Context, Args>, ContextOut>) => ActionBuilder<ContextOut, Args, Output>;
214
+ /**
215
+ * Mark this action as paid. The origin worker answers an unpaid client RPC
216
+ * with HTTP 402, verifies + settles the x402 payment, then dispatches. `price`
217
+ * is USD (a number of dollars or a `"0.01"`/`"$0.01"` string); the network,
218
+ * recipient, and facilitator come from the worker-level x402 charge config.
219
+ */
220
+ x402: (config: X402ProcedureConfig) => ActionBuilder<Context, Args, Output>;
221
+ }
222
+ /**
223
+ * Internal builder variants. Identical to their public counterparts but carry
224
+ * the `__lunoraVisibility: "internal"` brand codegen keys off to route the
225
+ * registration into the `internal` object (and keep it off `api`). `input`/`use`
226
+ * return the internal builder type so the brand survives the whole chain.
227
+ */
119
228
  interface InternalQueryBuilder<Context, Args extends ArgsValidator, Output = undefined> {
120
229
  readonly __lunoraProcedure: "query";
121
230
  readonly __lunoraVisibility: "internal";
122
231
  input: <A extends ArgsValidator>(validators: A) => InternalQueryBuilder<Context, A & Args, Output>;
232
+ /**
233
+ * Attach static, per-procedure metadata. Merges across calls and is readable
234
+ * from middleware as `ctx.meta`.
235
+ *
236
+ * The point is policy that is DATA rather than a call: `.meta({ rateLimit:
237
+ * "pins/create" })` lets ONE generic middleware read the policy it is meant
238
+ * to enforce off `ctx.meta`, where the same policy expressed only as
239
+ * `.use(rateLimit("pins/create"))` has to be re-parameterised at every
240
+ * `.use()` site. Mirrors tRPC's `.meta()`. The value is structured-cloned
241
+ * and deep-frozen: the same copy reaches every request, so it must not be
242
+ * mutable, and the object you passed stays yours (unfrozen). It must be
243
+ * structured-cloneable data — a function or class instance is rejected.
244
+ */
245
+ meta: (value: Record<string, unknown>) => InternalQueryBuilder<Context, Args, Output>;
123
246
  output: <V extends Validator>(validator: V) => InternalQueryBuilder<Context, Args, Infer<V>>;
124
247
  query: [Output] extends [undefined] ? <R>(handler: (options: {
125
248
  args: InferArgs<Args>;
@@ -133,13 +256,27 @@ interface InternalQueryBuilder<Context, Args extends ArgsValidator, Output = und
133
256
  args: InferArgs<Args>;
134
257
  ctx: Context;
135
258
  signal: AbortSignal;
136
- }) => AsyncGenerator<R, void, void> | AsyncIterable<R>) => RegisteredStream<Args, R>;
137
- use: <ContextOut>(middleware: Middleware<Context, ContextOut>) => InternalQueryBuilder<ContextOut, Args, Output>;
259
+ }) => AsyncGenerator<R, void, void> | AsyncIterable<R>, options?: StreamOptions) => RegisteredStream<Args, R>;
260
+ use: <ContextOut>(middleware: Middleware<MiddlewareContext<Context, Args>, ContextOut>) => InternalQueryBuilder<ContextOut, Args, Output>;
138
261
  }
139
262
  interface InternalMutationBuilder<Context, Args extends ArgsValidator, Output = undefined> {
140
263
  readonly __lunoraProcedure: "mutation";
141
264
  readonly __lunoraVisibility: "internal";
142
265
  input: <A extends ArgsValidator>(validators: A) => InternalMutationBuilder<Context, A & Args, Output>;
266
+ /**
267
+ * Attach static, per-procedure metadata. Merges across calls and is readable
268
+ * from middleware as `ctx.meta`.
269
+ *
270
+ * The point is policy that is DATA rather than a call: `.meta({ rateLimit:
271
+ * "pins/create" })` lets ONE generic middleware read the policy it is meant
272
+ * to enforce off `ctx.meta`, where the same policy expressed only as
273
+ * `.use(rateLimit("pins/create"))` has to be re-parameterised at every
274
+ * `.use()` site. Mirrors tRPC's `.meta()`. The value is structured-cloned
275
+ * and deep-frozen: the same copy reaches every request, so it must not be
276
+ * mutable, and the object you passed stays yours (unfrozen). It must be
277
+ * structured-cloneable data — a function or class instance is rejected.
278
+ */
279
+ meta: (value: Record<string, unknown>) => InternalMutationBuilder<Context, Args, Output>;
143
280
  mutation: [Output] extends [undefined] ? <R>(handler: (options: {
144
281
  args: InferArgs<Args>;
145
282
  ctx: Context;
@@ -148,7 +285,7 @@ interface InternalMutationBuilder<Context, Args extends ArgsValidator, Output =
148
285
  ctx: Context;
149
286
  }) => Output | Promise<Output>) => RegisteredMutation<Args, Output>;
150
287
  output: <V extends Validator>(validator: V) => InternalMutationBuilder<Context, Args, Infer<V>>;
151
- use: <ContextOut>(middleware: Middleware<Context, ContextOut>) => InternalMutationBuilder<ContextOut, Args, Output>;
288
+ use: <ContextOut>(middleware: Middleware<MiddlewareContext<Context, Args>, ContextOut>) => InternalMutationBuilder<ContextOut, Args, Output>;
152
289
  }
153
290
  interface InternalActionBuilder<Context, Args extends ArgsValidator, Output = undefined> {
154
291
  readonly __lunoraProcedure: "action";
@@ -161,8 +298,22 @@ interface InternalActionBuilder<Context, Args extends ArgsValidator, Output = un
161
298
  ctx: Context;
162
299
  }) => Output | Promise<Output>) => RegisteredAction<Args, Output>;
163
300
  input: <A extends ArgsValidator>(validators: A) => InternalActionBuilder<Context, A & Args, Output>;
301
+ /**
302
+ * Attach static, per-procedure metadata. Merges across calls and is readable
303
+ * from middleware as `ctx.meta`.
304
+ *
305
+ * The point is policy that is DATA rather than a call: `.meta({ rateLimit:
306
+ * "pins/create" })` lets ONE generic middleware read the policy it is meant
307
+ * to enforce off `ctx.meta`, where the same policy expressed only as
308
+ * `.use(rateLimit("pins/create"))` has to be re-parameterised at every
309
+ * `.use()` site. Mirrors tRPC's `.meta()`. The value is structured-cloned
310
+ * and deep-frozen: the same copy reaches every request, so it must not be
311
+ * mutable, and the object you passed stays yours (unfrozen). It must be
312
+ * structured-cloneable data — a function or class instance is rejected.
313
+ */
314
+ meta: (value: Record<string, unknown>) => InternalActionBuilder<Context, Args, Output>;
164
315
  output: <V extends Validator>(validator: V) => InternalActionBuilder<Context, Args, Infer<V>>;
165
- use: <ContextOut>(middleware: Middleware<Context, ContextOut>) => InternalActionBuilder<ContextOut, Args, Output>;
316
+ use: <ContextOut>(middleware: Middleware<MiddlewareContext<Context, Args>, ContextOut>) => InternalActionBuilder<ContextOut, Args, Output>;
166
317
  }
167
318
  /** The public root builders plus their `internal*` counterparts, returned by `.create()`. */
168
319
  interface LunoraBuilders {
@@ -179,421 +330,1502 @@ interface DataModelInit<DataModel> {
179
330
  create: (options?: CreateOptions) => LunoraBuilders;
180
331
  }
181
332
  /**
182
- * Entry point for the procedure builder. `dataModel&lt;DM>()` binds the generated
183
- * `DataModel` (phantom for now), and `.create()` yields the public root builders
184
- * plus their `internal*` counterparts.
185
- */
186
- declare const initLunora: {
187
- dataModel: <DataModel>() => DataModelInit<DataModel>;
188
- };
333
+ * The prefixed tables a single plugin `P` contributes, or an empty map when it
334
+ * ships no schema extension. Mirrors {@link PrefixedTables} at the plugin level
335
+ * so {@link InstalledTables} can fold a tuple of plugins.
336
+ */
337
+ type ExtensionTablesOf<P> = P extends {
338
+ readonly extension: SchemaExtension<infer X> & {
339
+ readonly key: infer K;
340
+ };
341
+ } ? K extends string ? PrefixedTables<X, K> : Record<never, never> : Record<never, never>;
189
342
  /**
190
- * Redact secrets from a free-form message. Masks, in order: any quoted value
191
- * whose contents look like a credential (so a value surfaced as `received string
192
- * "sk_live_…"` is masked even though the surrounding text is not a token); a
193
- * `scheme://user:password@host` URL credential (the password segment); any
194
- * known-prefix credential token wherever it appears, at any length; any value
195
- * following a secret-named key in `KEY=value` / `KEY: value` form; and any
196
- * remaining bare high-entropy ≥24-char token run anywhere in the message.
197
- *
198
- * This is BEST-EFFORT defense-in-depth, NOT a guarantee: a short, prefix-less
199
- * secret under a non-secret-named key (and embedded credentials in shapes not
200
- * enumerated here) can still slip through. Treat it as a backstop — prefer
201
- * structured logging that never serializes raw env/secret fields in the first
202
- * place over relying on post-hoc scrubbing of untrusted data.
203
- *
204
- * Exported because it is independently useful — call it before logging anything
205
- * derived from `env`, request bodies, or thrown errors.
206
- */
207
- declare const redactSecrets: (message: string) => string;
208
- /** One key's validation failure, secrets already redacted out of `message`. */
209
- interface EnvKeyFailure {
210
- /** The env key that failed. */
211
- key: string;
212
- /** Redacted human-readable reason. */
213
- message: string;
214
- }
343
+ * Fold a tuple of plugins onto a base table map `T`, accumulating each plugin's
344
+ * auto-prefixed extension tables left-to-right the type-level mirror of
345
+ * {@link installPlugins} applying `mergeSchemaExtension` for each plugin in turn.
346
+ */
347
+ type InstalledTables<T extends Record<string, TableDefinition>, Plugins extends ReadonlyArray<unknown>> = Plugins extends readonly [infer Head, ...infer Rest] ? InstalledTables<ExtensionTablesOf<Head> & T, Rest> : T;
215
348
  /**
216
- * Thrown when one or more env keys are missing or fail validation. Carries the
217
- * structured list of `failures` (each with the offending `key`) so callers can
218
- * react programmatically; `message` is the joined, secret-redacted summary.
219
- *
220
- * Named export only (no default) per the repo export convention.
221
- */
222
- declare class LunoraEnvError extends Error {
223
- override readonly name = "LunoraEnvError";
224
- readonly failures: ReadonlyArray<EnvKeyFailure>;
225
- constructor(failures: ReadonlyArray<EnvKeyFailure>);
226
- }
227
- /** A record of `v.*` validators describing the expected env shape. */
228
- type EnvShape = Record<string, Validator>;
349
+ * Union every plugin's `ContextOut` in a tuple the type-level mirror of the
350
+ * `ctx.api.<key>` additions {@link composePluginMiddleware} accumulates as each
351
+ * plugin middleware runs. Independent of the incoming context, which the builder
352
+ * infers at the `.use(...)` site.
353
+ */
354
+ type ComposedOut<Plugins extends ReadonlyArray<unknown>> = Plugins extends readonly [infer Head, ...infer Rest] ? ComposedOut<Rest> & (Head extends Plugin<any, any, infer Out> ? Out : unknown) : unknown;
229
355
  /**
230
- * The typed output of {@link defineEnv}. Optional validators (`v.optional(...)`)
231
- * become optional keys; everything else is required. Mirrors how `InferArgs`
232
- * derives an args object from a validator map.
233
- */
234
- type InferEnv<S extends EnvShape> = { [K in keyof S as undefined extends Infer<S[K]> ? K : never]?: Infer<S[K]> } & { [K in keyof S as undefined extends Infer<S[K]> ? never : K]: Infer<S[K]> };
235
- /**
236
- * The accessor returned by {@link defineEnv}. A typed view over an `env` object
237
- * plus a `.parse(env)` escape hatch that validates every key eagerly.
238
- *
239
- * Call the accessor with the worker's `env` to get the typed, lazily-validated
240
- * proxy: `const config = defineEnv({ … }); const { PORT } = config(env);`.
241
- */
242
- interface EnvAccessor<S extends EnvShape> {
243
- /** Validate every key eagerly and return the typed, plain (non-proxy) object. Use for fail-fast-at-boot. */
244
- parse: (env: unknown) => InferEnv<S>;
245
- /** Lazily-validated, per-key-cached typed view over `env`. Keys are validated on first access. */
246
- (env: unknown): InferEnv<S>;
356
+ * Schema fragment a plugin contributes. Same shape as the `tables` map
357
+ * passed to `defineSchema`. Optional `vectorIndexes` mirror the top-level
358
+ * `defineSchema` argument so a plugin can ship vector decls alongside its
359
+ * tables.
360
+ */
361
+ interface SchemaExtension<T extends Record<string, TableDefinition> = Record<string, TableDefinition>> {
362
+ /** Stable key identifying the plugin that owns this extension. */
363
+ readonly key: string;
364
+ /**
365
+ * Extension tables, keyed by **bare** name (e.g. `buckets`). At merge time
366
+ * each is auto-prefixed with `key` (`ratelimit_buckets`) so it can't
367
+ * collide with an app table; do **not** namespace manually.
368
+ */
369
+ readonly tables: T;
370
+ /**
371
+ * Optional standalone vector indexes the plugin ships, keyed by index
372
+ * name. Merged into the host schema's `vectorIndexes`; a key collision
373
+ * with the base schema is a hard error (same policy as tables).
374
+ */
375
+ readonly vectorIndexes?: Record<string, VectorIndexDefinition>;
247
376
  }
248
377
  /**
249
- * Define a typed, validated accessor over a Worker's `env`. Pass a record of
250
- * `v.*` validators; receive an accessor that validates lazily per key (cached
251
- * per `env` identity) and infers its output type from the validators.
252
- *
253
- * ```ts
254
- * import { defineEnv, v } from "@lunora/server";
255
- *
256
- * const config = defineEnv({
257
- * STRIPE_KEY: v.string(),
258
- * PORT: v.optional(v.number()),
259
- * });
260
- *
261
- * export default {
262
- * fetch(request, env) {
263
- * const { STRIPE_KEY, PORT } = config(env); // STRIPE_KEY: string, PORT?: number
264
- * // …
265
- * },
266
- * };
267
- * ```
268
- *
269
- * Throws {@link LunoraEnvError} (secrets redacted) when a key is missing or
270
- * invalid — lazily on first access of that key, or eagerly via `config.parse(env)`.
271
- */
272
- declare const defineEnv: <S extends EnvShape>(shape: S) => EnvAccessor<S>;
378
+ * Build a {@link SchemaExtension}. The `key` is a runtime tag (used for
379
+ * error messages on collision) and a type-level brand.
380
+ */
381
+ declare const defineSchemaExtension: <T extends Record<string, TableDefinition>>(key: string, options: {
382
+ tables: T;
383
+ vectorIndexes?: Record<string, VectorIndexDefinition>;
384
+ }) => SchemaExtension<T>;
273
385
  /**
274
- * Canonical error type for Lunora procedures and middleware.
275
- *
276
- * The runtime's structural error mapper keys off `name === "LunoraError"` plus
277
- * the numeric `status`, so throwing one of these from a handler or middleware
278
- * yields the right RPC/HTTP status without any further wiring. `code` carries
279
- * the machine-readable reason for clients.
280
- */
281
- declare const CODE_STATUS: {
282
- readonly BAD_REQUEST: 400;
283
- readonly CONFLICT: 409;
284
- /**
285
- * `count()` invoked against a table whose context carries an active RLS
286
- * policy. The operation itself is unsupported in an RLS-restricted reader
287
- * (kitcn's documented constraint) — the request is well-formed and the
288
- * caller is authorized, so this is a 422 (semantic conflict) rather than a
289
- * 403 (policy denial).
290
- */
291
- readonly COUNT_RLS_UNSUPPORTED: 422;
292
- readonly FORBIDDEN: 403;
293
- readonly INTERNAL_SERVER_ERROR: 500;
294
- /**
295
- * An analytical reduction (`aggregate` / `groupBy`) was invoked over a
296
- * column that the procedure's `mask()` middleware redacts. A masked column
297
- * can't be summed, averaged, or grouped without leaking the very values the
298
- * mask hides (a group key *is* the raw value; an aggregate is computed from
299
- * it), so the operation fails closed. The request is well-formed and the
300
- * caller is authorized — this is a 422 (semantic conflict), mirroring
301
- * `COUNT_RLS_UNSUPPORTED`.
302
- */
303
- readonly MASK_UNSUPPORTED: 422;
304
- readonly NOT_FOUND: 404;
305
- readonly NOT_IMPLEMENTED: 501;
306
- /**
307
- * A write policy's `when` returned a relation-crossing predicate
308
- * (`some`/`none`/`every`/`is`/`isNot`). The in-memory write-policy evaluator
309
- * has no child fetcher and cannot resolve a relation node, so the policy is
310
- * unsupported as written. Relation predicates are valid in *read* policies
311
- * and query `where` clauses (the pre-resolver handles them there). The
312
- * request is well-formed; this is a 422 (semantic conflict), mirroring the
313
- * sibling `*_UNSUPPORTED` codes.
314
- */
315
- readonly RELATION_PREDICATE_UNSUPPORTED: 422;
316
- readonly TOO_MANY_REQUESTS: 429;
317
- readonly UNAUTHORIZED: 401;
318
- readonly UNPROCESSABLE: 422;
319
- };
320
- type LunoraErrorCode = keyof typeof CODE_STATUS;
321
- declare class LunoraError extends Error {
322
- override readonly name = "LunoraError";
323
- readonly code: LunoraErrorCode;
324
- readonly status: number;
325
- constructor(code: LunoraErrorCode, message?: string);
326
- }
327
- /**
328
- * The per-table `ctx.db` accessor (the `ctx.db.messages.findMany(...)` form) and
329
- * the kitcn-style `ctx.orm` namespace, as plain runtime helpers. This is the ONE
330
- * source of truth for the facade shape, shared by two callers so they can never
331
- * drift (a drift here is security-relevant — a facade accessor the RLS
332
- * middleware forgot to re-bind would read around policy). `@lunora/codegen`
333
- * emits `ctx.db`/`ctx.orm` by calling these over the raw shard writer (and the
334
- * D1 `globalDb` writer for `.global()` tables); the RLS middleware re-binds the
335
- * policy tables by calling them over the policy-enforcing wrapped writer.
336
- *
337
- * `bindTableFacade(writer, table)` pins `tableName` on the structural writer so
338
- * callers address rows by id (`get`/`delete`/`patch`/`replace`) or by the bound
339
- * table (everything else). The binding is identical regardless of which writer
340
- * is passed — that's the whole point.
341
- */
342
- /**
343
- * Minimal structural writer the facade binds over. Declared with **method**
344
- * syntax (not arrow properties) so a more-specifically-typed writer — both
345
- * `@lunora/do`'s `DatabaseWriterLike` and the RLS middleware's wrapped writer —
346
- * stays assignable under bivariant parameter checking. That is the whole reason
347
- * the shared helper can serve both callers, hence the rule exemption.
348
- */
349
- interface FacadeWriterLike {
350
- aggregate(tableName: string, options: unknown): Promise<unknown>;
351
- count(tableName: string, where?: unknown): Promise<number>;
352
- delete(id: string, expectedTable?: string): Promise<void>;
353
- deleteMany?(ids: ReadonlyArray<string>, options?: {
354
- limit?: number;
355
- }, expectedTable?: string): Promise<{
356
- deleted: number;
357
- }>;
358
- findFirst(tableName: string, args?: unknown): Promise<unknown>;
359
- findFirstOrThrow(tableName: string, args?: unknown): Promise<unknown>;
360
- findMany(tableName: string, args?: unknown): Promise<unknown>;
361
- get(id: string, expectedTable?: string): Promise<unknown>;
362
- groupBy(tableName: string, options: unknown): Promise<unknown>;
363
- insert(tableName: string, document: Record<string, unknown>): Promise<string>;
364
- insertMany?(tableName: string, documents: ReadonlyArray<Record<string, unknown>>, options?: {
365
- limit?: number;
366
- }): Promise<string[]>;
367
- patch(id: string, patch: Record<string, unknown>, expectedTable?: string): Promise<void>;
368
- patchMany?(patches: ReadonlyArray<{
369
- id: string;
370
- patch: Record<string, unknown>;
371
- }>, options?: {
372
- limit?: number;
373
- }, expectedTable?: string): Promise<void>;
374
- query(tableName: string): {
375
- withSearchIndex(indexName: string, search: (q: unknown) => unknown): unknown;
376
- };
377
- rank(tableName: string, indexName: string, options: unknown): Promise<unknown>;
378
- rankPage(tableName: string, indexName: string, options?: unknown): Promise<unknown>;
379
- replace(id: string, document: Record<string, unknown>, expectedTable?: string): Promise<void>;
386
+ * A plugin packages an optional schema extension and optional middleware.
387
+ * Both are independently usable: an app can install only the schema (e.g.
388
+ * for plugins that ship background workers but no per-request behavior)
389
+ * or only the middleware (plugins that augment ctx without persistent
390
+ * state).
391
+ */
392
+ interface Plugin<TExtension extends Record<string, TableDefinition> = Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn> {
393
+ /**
394
+ * Optional schema extension. Apps install via
395
+ * `defineSchema(...).extend(plugin.extension)`.
396
+ */
397
+ readonly extension?: SchemaExtension<TExtension>;
398
+ /** Stable key identifying the plugin. Matches `extension.key` when set. */
399
+ readonly key: string;
400
+ /**
401
+ * Optional middleware. Users attach with `c.query.use(plugin.middleware)`.
402
+ * The middleware can extend `ctx`; convention is to attach helpers under
403
+ * `ctx.api.<key>`, e.g.
404
+ *
405
+ * ```ts
406
+ * middleware: ({ ctx, next }) =>
407
+ * next({ ctx: { api: { ...ctx.api, ratelimit: api } } })
408
+ * ```
409
+ */
410
+ readonly middleware?: Middleware<TContextIn, TContextOut>;
380
411
  }
381
- /** The per-table accessor object returned for the `ctx.db` table form. */
382
- interface FacadeEntry {
383
- aggregate: (options: unknown) => Promise<unknown>;
384
- count: (where?: unknown) => Promise<number>;
385
- delete: (id: string) => Promise<void>;
386
- deleteMany: (ids: ReadonlyArray<string>, options?: {
387
- limit?: number;
388
- }) => Promise<{
389
- deleted: number;
390
- }>;
391
- findFirst: (args?: unknown) => Promise<unknown>;
392
- findFirstOrThrow: (args?: unknown) => Promise<unknown>;
393
- findMany: (args?: unknown) => Promise<unknown>;
394
- get: (id: string) => Promise<unknown>;
395
- groupBy: (options: unknown) => Promise<unknown>;
396
- insert: (document: Record<string, unknown>) => Promise<string>;
397
- insertMany: (documents: ReadonlyArray<Record<string, unknown>>, options?: {
398
- limit?: number;
399
- }) => Promise<string[]>;
400
- patch: (id: string, patch: Record<string, unknown>) => Promise<void>;
401
- patchMany: (patches: ReadonlyArray<{
402
- id: string;
403
- values: Record<string, unknown>;
404
- }>, options?: {
405
- limit?: number;
406
- }) => Promise<void>;
407
- rank: (indexName: string, options: unknown) => Promise<unknown>;
408
- rankPage: (indexName: string, options?: unknown) => Promise<unknown>;
409
- replace: (id: string, document: Record<string, unknown>) => Promise<void>;
410
- withSearchIndex: (indexName: string, search: (q: unknown) => unknown) => unknown;
412
+ /** Options to {@link definePlugin}. */
413
+ interface DefinePluginOptions<TExtension extends Record<string, TableDefinition>, TContextIn, TContextOut> {
414
+ extension?: SchemaExtension<TExtension>;
415
+ middleware?: Middleware<TContextIn, TContextOut>;
411
416
  }
412
417
  /**
413
- * Bind a structural writer to one table, producing its `ctx.db` table accessor.
414
- *
415
- * The by-id accessors (`get`/`delete`/`patch`/`replace`) forward the bound
416
- * `tableName` as `expectedTable` so the underlying writer scopes its id lookup
417
- * to this table. Without it, a branded `Id&lt;"posts">` carrying another table's
418
- * id would resolve cross-table (the writer probes every table by id), letting
419
- * `ctx.db.posts.get(foreignId)` read — or `.delete`/`.patch`/`.replace`
420
- * mutate — a row in an unrelated table (IDOR). Writers that ignore the second
421
- * argument keep their previous global behaviour; the scoping is opt-in via this
422
- * forwarded name.
423
- */
424
- declare const bindTableFacade: (writer: FacadeWriterLike, tableName: string) => FacadeEntry;
425
- /** The kitcn-style `ctx.orm` namespace over a per-table facade map. */
426
- interface OrmLike {
427
- delete: (table: string, id: string) => Promise<void>;
428
- insert: (table: string) => {
429
- values: (document: Record<string, unknown>) => Promise<string>;
430
- };
431
- query: Record<string, FacadeEntry>;
432
- replace: (table: string, id: string) => {
433
- with: (document: Record<string, unknown>) => Promise<void>;
434
- };
435
- update: (table: string, id: string) => {
436
- set: (values: Record<string, unknown>) => Promise<void>;
418
+ * Call signatures for {@link definePlugin}. When `extension` is supplied the
419
+ * returned plugin's `extension` is typed as PRESENT (not `?`), so the
420
+ * canonical install pattern `defineSchema(...).extend(plugin.extension)`
421
+ * typechecks without a non-null assertion the shape every scaffold template
422
+ * ships. The bare-options signature keeps `extension` optional for plugins
423
+ * that carry only middleware.
424
+ */
425
+ interface DefinePluginFunction {
426
+ <TExtension extends Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn>(key: string, options: DefinePluginOptions<TExtension, TContextIn, TContextOut> & {
427
+ extension: SchemaExtension<TExtension>;
428
+ }): Plugin<TExtension, TContextIn, TContextOut> & {
429
+ readonly extension: SchemaExtension<TExtension>;
437
430
  };
431
+ <TExtension extends Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn>(key: string, options: DefinePluginOptions<TExtension, TContextIn, TContextOut>): Plugin<TExtension, TContextIn, TContextOut>;
432
+ }
433
+ /**
434
+ * Package a schema extension + middleware as a reusable plugin. Either
435
+ * field is optional — `definePlugin("foo", {})` is valid but degenerate.
436
+ */
437
+ declare const definePlugin: DefinePluginFunction;
438
+ /**
439
+ * Bundle of registered functions a {@link Component} ships. Keys are the
440
+ * function's local name (e.g. `check`, `reset`); the registered function
441
+ * value carries its own kind / args / handler.
442
+ *
443
+ * Users re-export from their own lunora module so codegen picks them up:
444
+ *
445
+ * ```ts
446
+ * // lunora/ratelimit.ts
447
+ * import { ratelimit } from "@vendor/ratelimit-component";
448
+ * export const { check, reset } = ratelimit.functions;
449
+ * // Emits as `ratelimit:check` / `ratelimit:reset` in the generated `api`.
450
+ * ```
451
+ *
452
+ * Codegen follows the re-export back to the bundled `query/mutation/action`
453
+ * call (property access or destructuring both work), so the functions land in
454
+ * the generated `api` under the re-exporting file's namespace.
455
+ */
456
+ type ComponentFunctions = Readonly<Record<string, RegisteredFunction<any, any, FunctionKind>>>;
457
+ /**
458
+ * Component = {@link Plugin} with a bundle of registered functions. The
459
+ * extension + middleware + functions are independent: a component can ship
460
+ * functions without a schema (e.g. a stateless utility), or a schema
461
+ * without functions (e.g. shared table definitions), and any combination.
462
+ */
463
+ interface Component<TExtension extends Record<string, TableDefinition> = Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn, F extends ComponentFunctions = ComponentFunctions> extends Plugin<TExtension, TContextIn, TContextOut> {
464
+ readonly functions: F;
465
+ }
466
+ interface DefineComponentOptions<TExtension extends Record<string, TableDefinition>, TContextIn, TContextOut, F extends ComponentFunctions> extends DefinePluginOptions<TExtension, TContextIn, TContextOut> {
467
+ /** Registered functions the component ships. Keys are the function's local name. */
468
+ functions?: F;
438
469
  }
439
- /** Build `ctx.orm` over a per-table facade map (table name → FacadeEntry). */
440
- declare const bindOrm: (facade: Record<string, FacadeEntry>) => OrmLike;
441
- /** HTTP verbs the typed {@link httpRoute} builder can bind to. */
442
- type HttpMethod = "DELETE" | "GET" | "HEAD" | "OPTIONS" | "PATCH" | "POST" | "PUT";
443
470
  /**
444
- * Context handed to an HTTP action handler. A narrower view of {@link ActionContext}:
445
- * HTTP actions run in the worker (the "action runtime"), separate from the
446
- * transactional store, so there is no direct `db` / `vectors` / `scheduler` /
447
- * `storage` surface reach the data layer through `runQuery` / `runMutation` /
448
- * `runAction`, which forward to the owning shard.
449
- */
450
- type HttpActionCtx = Pick<ActionCtx, "auth" | "fetch" | "runAction" | "runMutation" | "runQuery">;
451
- /** A raw handler wrapped by {@link httpAction}. Receives the raw request, returns the raw response. */
452
- type HttpActionHandler = (context: HttpActionCtx, request: Request) => Promise<Response> | Response;
471
+ * Convenience wrapper around {@link definePlugin} that also bundles a set
472
+ * of registered functions. The resulting `component.functions` object is a
473
+ * record of `name registered query/mutation/action`; consumers
474
+ * re-export entries so codegen discovers them as user functions:
475
+ *
476
+ * ```ts
477
+ * export const ratelimit = defineComponent("ratelimit", {
478
+ * // Bare `buckets` merges in as `ratelimit_buckets`.
479
+ * extension: defineSchemaExtension("ratelimit", { tables: { buckets } }),
480
+ * middleware: ({ ctx, next }) => next({ ctx: { ...ctx, ratelimit: api(ctx) } }),
481
+ * functions: {
482
+ * check: query.input({ key: v.string() }).query(async ({ ctx, args }) => ...),
483
+ * reset: mutation.input({ key: v.string() }).mutation(async ({ ctx, args }) => ...),
484
+ * },
485
+ * });
486
+ * ```
487
+ *
488
+ * Re-exporting an entry (by property access or destructuring) is enough for
489
+ * codegen to discover it in the host app's namespace — the discovery resolver
490
+ * chases the re-export back to the bundled registration call.
491
+ */
492
+ declare const defineComponent: <TExtension extends Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn, F extends ComponentFunctions = ComponentFunctions>(key: string, options: DefineComponentOptions<TExtension, TContextIn, TContextOut, F>) => Component<TExtension, TContextIn, TContextOut, F>;
453
493
  /**
454
- * The hono {@link https://hono.dev | Hono} environment used by {@link httpRouter}.
455
- * The runtime injects the per-request {@link HttpActionCtx} on the private
456
- * `__lunoraCtx` binding; the router's lifting middleware promotes it to
457
- * `c.var.lunora` so handlers can read it as a typed variable.
458
- */
459
- interface LunoraHttpEnv {
460
- Bindings: Record<string, unknown> & {
461
- __lunoraCtx?: HttpActionCtx;
462
- };
463
- Variables: {
464
- lunora: HttpActionCtx;
465
- };
466
- }
467
- /** The hono app type {@link httpRouter} returns. */
468
- type LunoraHttpApp = Hono<LunoraHttpEnv>;
469
- /** A compiled route handler: a hono handler that resolves to a raw {@link Response}. */
470
- type LunoraRouteHandler = (c: Context<LunoraHttpEnv>) => Promise<Response>;
494
+ * Map every key `K` of an extension's table map `X` to its auto-prefixed name
495
+ * `${Key}_${K}`. Mirrors the runtime prefixing in {@link mergeSchemaExtension}
496
+ * so the typed `.extend(...)` chain reflects the real merged table names.
497
+ */
498
+ type PrefixedTables<X extends Record<string, TableDefinition>, Key extends string> = { [K in keyof X as K extends string ? `${Key}_${K}` : K]: X[K]; };
499
+ /**
500
+ * Merge a {@link SchemaExtension} into an existing schema. Returns a new
501
+ * schema object — never mutates the input.
502
+ *
503
+ * Extension tables are auto-namespaced: each bare table name is prefixed with
504
+ * the extension `key` (`buckets` → `ratelimit_buckets`), Convex-Components
505
+ * style, and every intra-extension reference (relation targets, aggregate /
506
+ * rank index `on`, standalone vector index `table`) is rewritten to match.
507
+ * References to base/app tables are left untouched.
508
+ *
509
+ * Because each extension lives in its own `key` namespace, app↔component
510
+ * collisions are impossible. The only remaining hard error is two extensions
511
+ * sharing the same `key` and producing the same prefixed table (or vector
512
+ * index) name — silent shadow would let one plugin hijack another's data.
513
+ *
514
+ * Re-runs {@link validateIndexFields} against the merged table set before
515
+ * returning: `defineSchema` only validates the tables it was called with, so
516
+ * without this an extension-contributed index with a typo'd/out-of-shape
517
+ * field (or a duplicate name within one kind) would never be checked at all.
518
+ * Re-validating the whole merged set (base + prefixed extension tables) is
519
+ * cheap and idempotent for the base tables, which already passed this same
520
+ * check when the base schema was built. Both callers of this function —
521
+ * `withExtend.extend()` (`./schema`) and `installPlugins` (below) — get the
522
+ * re-validation for free from this single call site (plan 258 §4/§9 Q3).
523
+ */
524
+ declare const mergeSchemaExtension: <T extends Record<string, TableDefinition>, X extends Record<string, TableDefinition>, Key extends string = string>(base: Schema<T>, extension: SchemaExtension<X> & {
525
+ readonly key: Key;
526
+ }) => Schema<PrefixedTables<X, Key> & T>;
471
527
  /**
472
- * Wrap a `(ctx, request) => Response` handler as a hono handler. The raw escape
473
- * hatch mount it with `app.all(path, httpAction(fn))`. `ctx` is the
474
- * runtime-injected {@link HttpActionCtx} lifted into `c.var.lunora` by
475
- * {@link httpRouter}; `request` is the underlying `c.req.raw`.
476
- */
477
- declare const httpAction: (handler: HttpActionHandler) => LunoraRouteHandler;
528
+ * Install several plugins' schema extensions in one call the one-shot
529
+ * counterpart to chaining `defineSchema(...).extend(a).extend(b)`. Plugins
530
+ * without an `extension` (middleware-only) are skipped; tables from those that
531
+ * do are auto-prefixed and reference-rewritten exactly as
532
+ * {@link mergeSchemaExtension} does for a single `.extend(...)`.
533
+ *
534
+ * ```ts
535
+ * const schema = installPlugins(defineSchema({ todos }), [ratelimit, audit]);
536
+ * // → todos + ratelimit_* + audit_*
537
+ * ```
538
+ *
539
+ * Pair it with {@link composePluginMiddleware} to attach every plugin's
540
+ * middleware in a single `.use(...)`, so installing N plugins is two calls
541
+ * rather than N `.extend(...)` + N `.use(...)`.
542
+ */
543
+ declare const installPlugins: <T extends Record<string, TableDefinition>, const Plugins extends ReadonlyArray<Plugin<any, any, any>>>(base: Schema<T>, plugins: Plugins) => Schema<InstalledTables<T, Plugins>>;
478
544
  /**
479
- * Create the hono app for HTTP actions. Pre-wired with a middleware that lifts
480
- * the runtime-injected `c.env.__lunoraCtx` into `c.var.lunora`, so both
481
- * {@link httpAction} and the typed {@link httpRoute} builder can read the action
482
- * context. The full hono surface is available plugins, path params, `.route`:
483
- *
484
- * ```ts
485
- * const app = httpRouter();
486
- * app.use("*", cors());
487
- * app.post("/webhook", httpAction(onWebhook));
488
- * app.get("/users/:id", getUser);
489
- * export default createWorker({ httpRouter: app, ... });
490
- * ```
491
- *
492
- * The lifting middleware throws if the context is absent. `createWorker` injects
493
- * it on every request the router sees, so this only trips when the app is run
494
- * outside the runtime — a misconfiguration we surface loudly rather than let
495
- * `c.var.lunora` be silently `undefined` despite its non-optional type.
496
- */
497
- declare const httpRouter: () => LunoraHttpApp;
498
- /** The `{ ctx, searchParams, body, params }` a typed route handler receives. */
499
- interface HttpRouteHandlerOptions<SearchParams extends ArgsValidator, Body extends ArgsValidator, Params extends ArgsValidator> {
500
- body: InferArgs<Body>;
501
- ctx: HttpActionCtx;
502
- params: InferArgs<Params>;
503
- searchParams: InferArgs<SearchParams>;
545
+ * Compose every plugin's middleware into a single middleware you attach with one
546
+ * `.use(...)`. Plugins without middleware (schema-only) are skipped; the rest run
547
+ * in array order, each seeing the context the previous one widened, so the final
548
+ * `next({ ctx })` the builder receives carries every plugin's `ctx.api.<key>`
549
+ * additions. Equivalent to `.use(a.middleware).use(b.middleware)…` but as one
550
+ * value, the middleware sibling of {@link installPlugins}.
551
+ *
552
+ * `ContextIn` is left free so the builder infers it from the context at the
553
+ * `.use(...)` site; the result type widens it by the union of the plugins'
554
+ * outputs.
555
+ */
556
+ declare const composePluginMiddleware: <ContextIn = unknown, const Plugins extends ReadonlyArray<Plugin<any, any, any>> = ReadonlyArray<Plugin<any, any, any>>>(plugins: Plugins) => Middleware<ContextIn, ComposedOut<Plugins> & ContextIn>;
557
+ /** Options for `.vectorize(field, opts)` (DSL Shape A). */
558
+ interface VectorizeOptions<Shape extends Record<string, Validator> = Record<string, Validator>> {
559
+ dimensions: number;
560
+ embed: VectorEmbedder;
561
+ /** Logical index name; must match a `[[vectorize]]` binding in wrangler. */
562
+ index: string;
563
+ /** Fields mirrored into Vectorize metadata for filtering. */
564
+ metadata?: ReadonlyArray<keyof Shape & string>;
565
+ metric: VectorMetric;
504
566
  }
505
- /**
506
- * The `{ ctx, searchParams, params, request, signal }` a streaming HTTP
507
- * handler receives. There is no parsed `body` — streams are typically GET, and
508
- * the raw `request` is exposed if a handler needs to read the body itself.
509
- * `signal` is tripped when the client disconnects.
510
- */
511
- interface HttpStreamHandlerOptions<SearchParams extends ArgsValidator, Params extends ArgsValidator> {
512
- ctx: HttpActionCtx;
513
- params: InferArgs<Params>;
514
- request: Request;
515
- searchParams: InferArgs<SearchParams>;
516
- signal: AbortSignal;
567
+ /** A `one` (many-to-one) relation descriptor; phantom `Target` carries the target table name. */
568
+ interface OneRelation<Target extends string = string> extends RelationDefinition {
569
+ readonly __target?: Target;
570
+ readonly kind: "one";
517
571
  }
518
- /**
519
- * A typed REST route under construction. `.searchParams()` / `.body()` /
520
- * `.params()` accumulate validator maps (later calls merge, a colliding key
521
- * wins) that decode the URL query, JSON body, and hono path params into the
522
- * handler's typed `searchParams` / `body` / `params`. Like the procedure
523
- * builder, `.output(validator)` defaults to the `undefined` sentinel — while
524
- * unset the handler is generic over its own return; once set the handler must
525
- * return that type and the result is parsed through the validator before
526
- * serialization. `[Output] extends [undefined]` is tuple-wrapped so a union
527
- * `Output` doesn't distribute and the test is for the exact sentinel.
528
- *
529
- * The terminal `.handler()` yields a {@link LunoraRouteHandler} — mount it
530
- * directly with `app.get(path, route)`.
531
- */
532
- interface HttpRouteBuilder<SearchParams extends ArgsValidator, Body extends ArgsValidator, Params extends ArgsValidator, Output = undefined> {
533
- body: <B extends ArgsValidator>(validators: B) => HttpRouteBuilder<SearchParams, B & Body, Params, Output>;
534
- handler: [Output] extends [undefined] ? <R>(handler: (options: HttpRouteHandlerOptions<SearchParams, Body, Params>) => Promise<R> | R) => LunoraRouteHandler : (handler: (options: HttpRouteHandlerOptions<SearchParams, Body, Params>) => Output | Promise<Output>) => LunoraRouteHandler;
535
- output: <V extends Validator>(validator: V) => HttpRouteBuilder<SearchParams, Body, Params, Infer<V>>;
536
- params: <P extends ArgsValidator>(validators: P) => HttpRouteBuilder<SearchParams, Body, P & Params, Output>;
537
- searchParams: <S extends ArgsValidator>(validators: S) => HttpRouteBuilder<S & SearchParams, Body, Params, Output>;
538
- /**
539
- * Terminal: declare this route as a streaming Server-Sent Events endpoint.
540
- * The handler is an async generator (or any function returning an
541
- * `AsyncIterable&lt;R>`) that yields one chunk per SSE `data:` frame; on
542
- * iterator completion the route writes a final `event: complete` frame; on
543
- * throw, an `event: error` frame is written with `{code, message}` before
544
- * the stream closes. The chunks are JSON-encoded; `R` is inferred from the
545
- * handler's yielded type.
546
- */
547
- stream: <R>(handler: (options: HttpStreamHandlerOptions<SearchParams, Params>) => AsyncGenerator<R, void, void> | AsyncIterable<R>) => LunoraRouteHandler;
572
+ /** A `many` (one-to-many) relation descriptor; phantom `Target` carries the target table name. */
573
+ interface ManyRelation<Target extends string = string> extends RelationDefinition {
574
+ readonly __target?: Target;
575
+ readonly kind: "many";
548
576
  }
549
- /** Opens a fresh {@link HttpRouteBuilder}. The `path` documents intent; hono owns the actual routing at mount. */
550
- type HttpRouteFactory = (path: string) => HttpRouteBuilder<EmptyArgs, EmptyArgs, EmptyArgs>;
551
- /** The verb-keyed entry point: `httpRoute.get("/api/todos")…`. */
552
- interface HttpRoute {
553
- delete: HttpRouteFactory;
554
- get: HttpRouteFactory;
555
- head: HttpRouteFactory;
556
- options: HttpRouteFactory;
557
- patch: HttpRouteFactory;
558
- post: HttpRouteFactory;
559
- put: HttpRouteFactory;
577
+ /** The `r` argument passed to `.relations((r) => …)`. */
578
+ interface RelationBuilder {
579
+ /** One-to-many: the FK `field` lives on the target table, matching this table's `references` (default `_id`). */
580
+ many: <Target extends string>(table: Target, options: {
581
+ field: string;
582
+ references?: string;
583
+ }) => ManyRelation<Target>;
584
+ /** Many-to-one: the FK `field` lives on this table, pointing at `table`.`references` (default `_id`). */
585
+ one: <Target extends string>(table: Target, options: {
586
+ field: string;
587
+ onDelete?: OnDeleteAction;
588
+ references?: string;
589
+ }) => OneRelation<Target>;
560
590
  }
561
591
  /**
562
- * Typed REST route builder. Compiles down to a {@link LunoraRouteHandler}, so a
563
- * typed route and a hand-written {@link httpAction} are interchangeable when
564
- * mounted on {@link httpRouter}:
565
- *
566
- * ```ts
567
- * export const listTodos = httpRoute
568
- * .get("/api/todos")
569
- * .searchParams({ limit: v.number(), q: v.optional(v.string()) })
570
- * .output(v.array(v.object({ id: v.string(), text: v.string() })))
571
- * .handler(async ({ ctx, searchParams }) => ctx.runQuery(api.todos.list, searchParams));
572
- *
573
- * export const getTodo = httpRoute
574
- * .get("/api/todos/:id")
575
- * .params({ id: v.string() })
576
- * .handler(async ({ ctx, params }) => ctx.runQuery(api.todos.get, params));
577
- *
578
- * const app = httpRouter();
579
- * app.get("/api/todos", listTodos);
580
- * app.get("/api/todos/:id", getTodo);
581
- * ```
582
- */
583
- declare const httpRoute: HttpRoute;
592
+ * Options for the inline `.aggregateIndex(name, opts)` builder. `op` defaults to
593
+ * `count` so `aggregateIndex("byUser", { by: ["userId"] })` is a single-line
594
+ * `COUNT(*) GROUP BY userId` accelerator.
595
+ */
596
+ interface InlineAggregateIndexOptions<Shape extends Record<string, Validator> = Record<string, Validator>> {
597
+ /** Group keys; counter rows are one per distinct tuple. Omitted = single-row aggregate over the whole table. */
598
+ by?: ReadonlyArray<keyof Shape & string>;
599
+ /** The column the reducer applies to. Required for `sum`/`min`/`max`/`avg`; ignored for `count`. */
600
+ field?: keyof Shape & string;
601
+ /** Reducer (default `count`). */
602
+ op?: AggregateOp;
603
+ /** Static predicate baked into the counter — only matching rows are aggregated. */
604
+ where?: Record<string, unknown>;
605
+ }
584
606
  /**
585
- * Structural view of an R2 object body, as returned by `@lunora/storage`'s
586
- * `download()`. Re-declared here (not imported) so `@lunora/server` takes no
587
- * runtime dependency on `@lunora/storage`; the real binding satisfies the shape.
588
- */
589
- interface StorageObjectBody {
590
- /** The object body stream (`null` for a zero-byte object). */
591
- body: ReadableStream | null;
592
- etag: string;
593
- httpMetadata?: {
594
- contentType?: string;
595
- };
596
- key: string;
607
+ * Options for the inline `.rankIndex(name, opts)` builder. `sortBy` is required;
608
+ * accepts either an array of `{ field, direction }` keys, or the shorthand
609
+ * `["field"]` (asc) / `{ field: "desc" }` map entries. `partitionBy` scopes the
610
+ * rank — omitted ⇒ one global rank over the whole table.
611
+ */
612
+ interface InlineRankIndexOptions<Shape extends Record<string, Validator> = Record<string, Validator>> {
613
+ /** Columns that scope each ranking; omitted ⇒ one global rank. */
614
+ partitionBy?: ReadonlyArray<keyof Shape & string>;
615
+ /** Ordered sort keys driving the rank. Required. */
616
+ sortBy: ReadonlyArray<{
617
+ direction?: "asc" | "desc";
618
+ field: keyof Shape & string;
619
+ }>;
620
+ /** Static predicate baked into the index; only matching rows enter. */
621
+ where?: Record<string, unknown>;
622
+ }
623
+ interface TableBuilder<Shape extends Record<string, Validator> = Record<string, Validator>> extends TableDefinition<Shape> {
624
+ /** Declare an aggregate (counter/sum/…) maintained by triggers for O(1) reads. */
625
+ aggregateIndex: (name: string, options?: InlineAggregateIndexOptions<Shape>) => TableBuilder<Shape>;
626
+ /**
627
+ * Stamp every row with `_commitSeq` — a per-shard integer, allocated once
628
+ * per mutation and strictly increasing in **commit order**, refreshed on
629
+ * every write to the row (insert, patch, replace, and the marker flip a
630
+ * `.softDelete()` performs).
631
+ *
632
+ * `_creationTime` is wall-clock and therefore cannot order commits: the
633
+ * clock is read when the handler runs, the write lands when the transaction
634
+ * commits, and nothing ties those instants together. A changefeed paging on
635
+ * `_creationTime` can skip a row permanently. Paging on `_commitSeq`
636
+ * (`where: { _commitSeq: { gt: cursor } }, orderBy: ["_commitSeq"]`) cannot.
637
+ *
638
+ * It orders COMMITS, not rows: one mutation's rows share a value. A bounded
639
+ * page can therefore end mid-group, so a consumer must checkpoint at a
640
+ * sequence it has seen the whole of, never at the last row of a full page.
641
+ * An action's writes are the exception to the grouping — they commit
642
+ * independently, so each gets its own sequence.
643
+ *
644
+ * Ordered, not contiguous — read a gap as "nothing to see", never as loss.
645
+ * Per-shard, not global: two shards allocate independently, so a cursor is
646
+ * only meaningful against the shard it came from. Rejected on `.global()`
647
+ * tables, which have no shard-local transaction to allocate inside.
648
+ *
649
+ * **A hard delete is invisible to the feed.** The sequence lives on the row,
650
+ * so a physically removed row takes it along: the row stops appearing, but
651
+ * no event says it went away. Pair `.commitOrdered()` with `.softDelete()`
652
+ * when the feed must observe deletes — the tombstone flip is an UPDATE, so
653
+ * it advances the sequence and pages through like any other change.
654
+ */
655
+ commitOrdered: () => TableBuilder<Shape>;
656
+ /**
657
+ * Mark this table as written outside Lunora's discoverable insert path —
658
+ * by an adapter, a migration, or framework middleware (e.g. `@lunora/auth`'s
659
+ * better-auth tables, `@lunora/ratelimit`'s store). Advisor insert-path lints
660
+ * (`table_without_insert`) then skip it instead of flagging the absent
661
+ * `ctx.db.insert(...)`.
662
+ */
663
+ externallyManaged: () => TableBuilder<Shape>;
664
+ /**
665
+ * Declare a geospatial index over a `v.geoPoint()` column. The runtime keeps
666
+ * a geohash companion so `withGeoIndex(name, q => q.near(point, radius))` and
667
+ * `.within(bbox)` resolve as a geohash-prefix range scan + Haversine
668
+ * refine/sort. `options.precision` tunes the geohash length (default 9).
669
+ */
670
+ geoIndex: (name: string, options: {
671
+ field: keyof Shape & string;
672
+ precision?: number;
673
+ }) => TableBuilder<Shape>;
674
+ /**
675
+ * Mark this table as global (cross-shard). Backed by **D1** by default;
676
+ * pass `{ backend: "hyperdrive" }` to store it in a Postgres/MySQL database
677
+ * via Cloudflare Hyperdrive (PlanetScale, Neon, …) instead. Either way the
678
+ * table stays reactive — live queries re-run on write.
679
+ */
680
+ global: (options?: {
681
+ backend?: GlobalBackend;
682
+ }) => TableBuilder<Shape>;
683
+ /** Add a secondary index. */
684
+ index: (name: string, fields: ReadonlyArray<(keyof Shape & string) | (typeof SYSTEM_INDEX_FIELDS)[number]>, options?: {
685
+ unique?: boolean;
686
+ }) => TableBuilder<Shape>;
687
+ /**
688
+ * Declare this table EPHEMERAL — state the shard rebuilds rather than
689
+ * remembers.
690
+ *
691
+ * A memory table is a full `ctx.db` table: indexes, `where`, `orderBy`,
692
+ * pagination, relations, live queries. What it is not is durable. Its rows
693
+ * are wiped the moment the Durable Object is reconstructed — which happens
694
+ * on every eviction, and a WebSocket-hibernating shard is evicted often — so
695
+ * a memory table holds only what can be derived again: presence and cursors,
696
+ * a live participant list, a rate-limit window, an actor's scratch state.
697
+ *
698
+ * Pair it with `onShardInit` to rebuild whatever the app needs present.
699
+ * The framework guarantees the ordering: every memory table is cleared, and
700
+ * every init hook has run, before any handler can read one. Without a hook a
701
+ * memory table simply comes back empty, which is a correct state for
702
+ * presence and a wrong one for a cache someone is treating as authoritative.
703
+ *
704
+ * **On Cloudflare the rows still transit the DO's SQLite.** workerd exposes
705
+ * exactly one SQL handle and no memory-backed database, so `.memory()` buys
706
+ * the LIFETIME (and skips the CDC changelog, so an append-heavy presence
707
+ * table does not grow the op-log), not the write. Treat it as "state I am
708
+ * happy to lose", not as "state that is free to write" — see
709
+ * `PlatformCapabilities.memoryTables`, rated `emulated` for exactly this
710
+ * reason.
711
+ *
712
+ * Rejected alongside `.global()` (a D1 table is not this shard's to clear),
713
+ * `.commitOrdered()` (a sequence that resets is not a sequence), and
714
+ * `.source()` (an externally-materialized table is not ours to wipe).
715
+ */
716
+ memory: () => TableBuilder<Shape>;
717
+ /**
718
+ * Name the column holding the owning user's id, so "only the owner sees these
719
+ * rows" is declared once here rather than restated in every shape.
720
+ *
721
+ * A `defineShape({ table, owner: true })` over this table derives its predicate
722
+ * from the field: the subscriber's verified `ctx.auth.userId` must match, and an
723
+ * anonymous subscriber is denied. Pairs naturally with `.shardBy(field)` on the
724
+ * same column — the shard key routes the storage, `ownedBy` states who the rows
725
+ * belong to — but the two are independent and either can be used alone.
726
+ *
727
+ * This is a *shape* declaration, not an RLS policy: it narrows what a shape
728
+ * replicates. Guarding procedure reads/writes is still `rls(...)`'s job.
729
+ */
730
+ ownedBy: (field: keyof Shape & string) => TableBuilder<Shape>;
731
+ /**
732
+ * Opt this table OUT of secure-by-default RLS. Under a schema marked
733
+ * `.rls("required")`, every table is protected (the write path denies raw,
734
+ * non-RLS `ctx.db` access); calling `.public()` exempts this one table so a
735
+ * plain `query`/`mutation` may read/write it without an RLS policy. No effect
736
+ * when the schema does not require RLS.
737
+ */
738
+ public: () => TableBuilder<Shape>;
739
+ /**
740
+ * Declare a rank index (sorted companion table, btree-backed) for
741
+ * `rank(row)` / `rankPage()` reads in O(log n). See {@link RankIndexDefinition}.
742
+ */
743
+ rankIndex: (name: string, options: InlineRankIndexOptions<Shape>) => TableBuilder<Shape>;
744
+ /** Declare relations to other tables, loaded via `findMany({ with })`. */
745
+ relations: (build: (r: RelationBuilder) => Record<string, RelationDefinition>) => TableBuilder<Shape>;
746
+ /**
747
+ * Add a full-text search index over `field`, queried with
748
+ * `.withSearchIndex(name, q => q.search(field, term))`. `field` may be a
749
+ * dot-separated path into a nested object (`"properties.name"`).
750
+ * `filterFields` (at most 16) lists the columns `.eq()` may narrow by inside
751
+ * the search. `language` selects the text analysis (accent folding always,
752
+ * plus that language's stopwords). `staged: true` skips the migration-time
753
+ * backfill on a large existing table — pre-existing rows stay unsearchable
754
+ * until `__lunora_admin__:backfillSearch` is run against the deployment. `strategy: "native"` uses the engine's
755
+ * own full-text index where it has one (Postgres) — faster on large corpora,
756
+ * at the cost of the engine ranking rather than the shared scorer.
757
+ */
758
+ searchIndex: (name: string, options: {
759
+ field: string;
760
+ filterFields?: ReadonlyArray<string>;
761
+ language?: SearchLanguage;
762
+ staged?: boolean;
763
+ strategy?: SearchStrategy;
764
+ }) => TableBuilder<Shape>;
765
+ /** Route storage by the named field — one DO per distinct value. */
766
+ shardBy: (field: keyof Shape & string) => TableBuilder<Shape>;
767
+ /**
768
+ * Turn on soft delete. Adds a nullable timestamp column (`options.field`,
769
+ * default `deletedAt`) and changes `ctx.db.<table>.delete()` to **set** it
770
+ * instead of removing the row; `onDelete: "cascade"` children are recursively
771
+ * soft-deleted too. **List reads** (`findMany`/`findFirst`/`query()`/`count`/
772
+ * `aggregate`/relation loads) then hide soft-deleted rows unless they pass
773
+ * `includeDeleted: true`; by-id `get`/`patch`/`replace` and the new
774
+ * `restore()` still address the row directly. `hardDelete()` physically
775
+ * removes it (cascading as a real delete). Note: `includeDeleted` is a read
776
+ * scope, not access control — anyone who can run the read can set it; a unique
777
+ * index still rejects a new row that collides with a soft-deleted one (the row
778
+ * physically persists).
779
+ */
780
+ softDelete: (options?: {
781
+ field?: string;
782
+ }) => TableBuilder<Shape>;
783
+ /**
784
+ * Materialize this table from an external Postgres/MySQL behind Cloudflare
785
+ * Hyperdrive (plan 077). A system-driven poll loop reads the tenant slice
786
+ * (`query`, with params bound from `tenantBy`) and lands it in the DO's SQLite,
787
+ * after which `defineShape` carries it to clients unchanged. Implies
788
+ * `.externallyManaged()` (rows come from the ingest loop, not user mutations).
789
+ *
790
+ * Orthogonal to `.shardBy()` — combine them for per-tenant DOs. **Under
791
+ * `.shardBy()` `tenantBy` is mandatory** (the tenant-isolation boundary); the
792
+ * `external_source_unscoped` advisor lint fails the build when it is absent, and
793
+ * `external_source_on_global` rejects combining `.source()` with `.global()`.
794
+ */
795
+ source: (definition: ExternalSourceDefinition) => TableBuilder<Shape>;
796
+ /** Declare named lifecycle triggers fired inline within the write path. */
797
+ triggers: (build: (t: TriggerBuilder<Shape>) => Record<string, TriggerDefinition>) => TableBuilder<Shape>;
798
+ /**
799
+ * Declare a table-level TTL: a DO alarm-driven sweep auto-deletes rows whose
800
+ * expiry has passed (or soft-deletes them when the table also
801
+ * `.softDelete()`s). `field` is an epoch-millisecond column; without
802
+ * `options.after` its value is the absolute expiry instant, with `after` the
803
+ * row expires `after` ms past `field` (`field + after`). Coarse, cheap,
804
+ * table-level — for per-row schedules use `@lunora/scheduler`.
805
+ */
806
+ ttl: (field: keyof Shape & string, options?: {
807
+ after?: number;
808
+ }) => TableBuilder<Shape>;
809
+ /** Declare a vector index over a single text field on this table. */
810
+ vectorize: (field: keyof Shape & string, options: VectorizeOptions<Shape>) => TableBuilder<Shape>;
811
+ }
812
+ /** Options for `defineVectorIndex(...)` (DSL Shape B). */
813
+ interface VectorIndexOptions {
814
+ dimensions: number;
815
+ embed: VectorEmbedder;
816
+ /** Optional projection of the source row into Vectorize metadata. */
817
+ metadata?: (row: Record<string, unknown>) => Record<string, unknown>;
818
+ metric: VectorMetric;
819
+ /** The vector source: which table, and how to derive the embedded text. */
820
+ source: {
821
+ select: (row: Record<string, unknown>) => string;
822
+ table: string;
823
+ };
824
+ }
825
+ /**
826
+ * Build a table definition. Returned object is both the table definition (for
827
+ * `defineSchema`) and a fluent builder for indexes + sharding metadata.
828
+ */
829
+ declare const defineTable: <Shape extends Record<string, Validator>>(inputShape: Shape) => TableBuilder<Shape>;
830
+ /**
831
+ * Declare a standalone vector index (DSL Shape B). Pass the returned value in
832
+ * the `vectorIndexes` map of {@link defineSchema} when the source is derived
833
+ * from multiple fields or a computation rather than a single column.
834
+ */
835
+ declare const defineVectorIndex: (options: VectorIndexOptions) => VectorIndexDefinition;
836
+ /**
837
+ * Options for the standalone `defineAggregateIndex(name, opts)` helper (DSL
838
+ * Shape B). Unlike the inline `.aggregateIndex(...)` builder, this form takes
839
+ * the owning table explicitly via `on` — handy when a single counter wants to
840
+ * live next to the schema map rather than inside a table chain.
841
+ */
842
+ interface AggregateIndexOptions {
843
+ by?: ReadonlyArray<string>;
844
+ field?: string;
845
+ on: string;
846
+ op?: AggregateOp;
847
+ where?: Record<string, unknown>;
848
+ }
849
+ /**
850
+ * Declare a standalone aggregate index. Pass the returned value to
851
+ * `defineSchema(tables, vectorIndexes, aggregateIndexes)` keyed by index name —
852
+ * the schema attaches it to `tables[on].aggregateIndexes` so runtime consumers
853
+ * (DO + D1) read every index uniformly off the table definition.
854
+ */
855
+ declare const defineAggregateIndex: (name: string, options: AggregateIndexOptions) => AggregateIndexDefinition;
856
+ /**
857
+ * Options for the standalone `defineRankIndex(name, opts)` helper (DSL Shape B).
858
+ * Mirrors the inline `.rankIndex(...)` builder but takes the owning table via
859
+ * `table` so it can sit next to the schema map.
860
+ */
861
+ interface RankIndexOptions {
862
+ partitionBy?: ReadonlyArray<string>;
863
+ sortBy: ReadonlyArray<{
864
+ direction?: "asc" | "desc";
865
+ field: string;
866
+ }>;
867
+ table: string;
868
+ where?: Record<string, unknown>;
869
+ }
870
+ /**
871
+ * Declare a standalone rank index. Pass the returned value to
872
+ * `defineSchema(tables, vectorIndexes, aggregateIndexes, rankIndexes)` keyed
873
+ * by index name — the schema attaches it to `tables[on].rankIndexes`.
874
+ */
875
+ declare const defineRankIndex: (name: string, options: RankIndexOptions) => RankIndexDefinition;
876
+ /**
877
+ * Build the application schema. The first argument is the table map; the
878
+ * optional second argument registers standalone `defineVectorIndex(...)`
879
+ * declarations (DSL Shape B) keyed by index name. The optional third argument
880
+ * registers standalone `defineAggregateIndex(...)` declarations (DSL Shape B);
881
+ * the optional fourth argument registers standalone `defineRankIndex(...)`
882
+ * declarations. Both are folded into the matching `tables[on].*Indexes` array
883
+ * so runtime backends read every index uniformly off the table definition.
884
+ */
885
+ /**
886
+ * Schema with an in-place `.extend(plugin.extension)` method. Used so apps
887
+ * can compose plugin schemas: `defineSchema({...}).extend(authPlugin.extension)`.
888
+ *
889
+ * `extend` is non-mutating — returns a fresh `ExtendableSchema` containing
890
+ * the merged tables. Extension tables are auto-namespaced by the extension
891
+ * `key` (`buckets` → `ratelimit_buckets`), so the merged type carries the
892
+ * prefixed names via {@link PrefixedTables}. Chains:
893
+ * `defineSchema(...).extend(a).extend(b)` is the typed equivalent of merging
894
+ * `a`'s prefixed tables then `b`'s.
895
+ */
896
+ type ExtendableSchema<T extends Record<string, TableDefinition>> = {
897
+ extend: <X extends Record<string, TableDefinition>, Key extends string>(extension: SchemaExtension<X> & {
898
+ readonly key: Key;
899
+ }) => ExtendableSchema<PrefixedTables<X, Key> & T>;
900
+ /**
901
+ * Pin every Durable Object the app reaches — shards, fan-out, subscriptions,
902
+ * the scheduler, and `ctx.containers` — to a Cloudflare data-residency
903
+ * jurisdiction (`"eu"`, `"us"`, `"fedramp"`). Codegen reads this off the
904
+ * schema and emits it into the generated worker's `createWorker({ jurisdiction })`
905
+ * (and `ctx.scheduler` / `ctx.containers`). Non-mutating: returns a fresh
906
+ * `ExtendableSchema`, so it composes with `.rls(...)` / `.extend(...)` in any order.
907
+ *
908
+ * ⚠️ **Set this once, before your first deploy — changing or removing it
909
+ * strands data.** A Durable Object name maps to a *different* ID in each
910
+ * jurisdiction, so toggling this on an existing app makes every shard, scheduler
911
+ * job, and session DO resolve to a NEW, empty DO; the previous data stays in the
912
+ * old jurisdiction's DOs and is no longer reachable. There is no in-place
913
+ * migration — you would have to export from the old jurisdiction and import
914
+ * into the new one.
915
+ *
916
+ * Note: this pins **DO-backed** state only. D1-backed state — `.global()`
917
+ * tables and `@lunora/auth` sessions alike — is governed by D1's own location
918
+ * settings, not this option.
919
+ * @see https://developers.cloudflare.com/durable-objects/reference/data-location/
920
+ */
921
+ jurisdiction: (jurisdiction: DurableObjectJurisdiction) => ExtendableSchema<T>;
922
+ /**
923
+ * Turn on secure-by-default RLS for the whole schema. Every table is then
924
+ * protected — the DO/D1 write path denies raw, non-RLS `ctx.db` access, so a
925
+ * procedure that forgets `.use(rls(...))` fails closed. Opt a table out with
926
+ * `.public()`. Non-mutating: returns a fresh `ExtendableSchema` carrying the
927
+ * mode, so `.rls("required")` composes with `.extend(...)` either order.
928
+ */
929
+ rls: (mode: "required") => ExtendableSchema<T>;
930
+ } & Schema<T>;
931
+ /**
932
+ * Columns every row carries implicitly (never part of a table's declared
933
+ * `shape`), so `.index()` may legitimately name them. The single source for
934
+ * both the compile-time allow-list (`TableBuilder["index"]`'s `fields` type,
935
+ * via `(typeof SYSTEM_INDEX_FIELDS)[number]`) and the runtime cross-check
936
+ * below (via `SYSTEM_INDEX_FIELDS_SET`) — declared once so the two can't
937
+ * drift apart.
938
+ */
939
+ declare const SYSTEM_INDEX_FIELDS: readonly ["_commitSeq", "_creationTime", "_id"];
940
+ /**
941
+ * Per-table, per-KIND index→declared-fields map: for each table, each index
942
+ * KIND (`index` | `rank` | `geo`) that has at least one declared index maps
943
+ * to a name→fields record for that kind only. Distilled by
944
+ * {@link indexFieldsFromSchema}; this is the shape `mask()`'s
945
+ * `MaskOptions.indexFields` expects (see `./mask/types`), so a table not
946
+ * present here (no declared indexes of any kind) is simply absent from the
947
+ * map rather than mapped to `{}`, and a kind with no declared indexes on a
948
+ * table that HAS other kinds is simply absent from that table's entry.
949
+ *
950
+ * Kept per kind (rather than one flat name→fields record) because the engine
951
+ * resolves `withIndex`/`withGeoIndex`/rank reads in THREE separate
952
+ * namespaces (`tableDefinition.indexes` / `.geoIndexes` / `.rankIndexes` —
953
+ * see `@lunora/shard-engine`'s `ctx-db.ts`), so the same name can legally and
954
+ * unambiguously denote a different index per kind. A flat map would let one
955
+ * kind's fields silently shadow another's for a colliding name, producing a
956
+ * wrong-namespace answer from the mask guard (checking the wrong index's
957
+ * fields) instead of the documented fail-open (missing lookup) — see plan 258.
958
+ */
959
+ type IndexFieldsByTable = Readonly<Record<string, {
960
+ readonly geo?: Readonly<Record<string, ReadonlyArray<string>>>;
961
+ readonly index?: Readonly<Record<string, ReadonlyArray<string>>>;
962
+ readonly rank?: Readonly<Record<string, ReadonlyArray<string>>>;
963
+ }>>;
964
+ declare const indexFieldsFromSchema: (schema: Schema) => IndexFieldsByTable;
965
+ declare const defineSchema: <T extends Record<string, TableDefinition>>(tables: T, vectorIndexes?: Record<string, VectorIndexDefinition>, aggregateIndexes?: Record<string, AggregateIndexDefinition>, rankIndexes?: Record<string, RankIndexDefinition>) => ExtendableSchema<T>;
966
+ /** Default lifetime of a cached entry: one hour. */
967
+ declare const DEFAULT_ACTION_CACHE_TTL_MS: number;
968
+ declare const ACTION_CACHE_BARE_TABLE = "entries";
969
+ /**
970
+ * The prefixed table name the extension produces at merge time. The handlers and
971
+ * helpers read/write this name directly so they always agree with the merged
972
+ * schema.
973
+ */
974
+ declare const ACTION_CACHE_TABLE: "actionCache_entries";
975
+ /**
976
+ * The slice of an index-range builder this preset uses. Mirrors `IndexRangeBuilder`
977
+ * field-for-field so the real `ctx.db` query builder is assignable — the generated
978
+ * `ctx.db.query` is typed per table, and a helper holding a runtime string needs
979
+ * the wide overload.
980
+ */
981
+ interface ActionCacheIndexRange {
982
+ eq: (field: string, value: unknown) => ActionCacheIndexRange;
983
+ gt: (field: string, value: unknown) => ActionCacheIndexRange;
984
+ gte: (field: string, value: unknown) => ActionCacheIndexRange;
985
+ lt: (field: string, value: unknown) => ActionCacheIndexRange;
986
+ lte: (field: string, value: unknown) => ActionCacheIndexRange;
987
+ }
988
+ /** The slice of a `ctx.db` table query this preset relies on. */
989
+ interface ActionCacheQuery {
990
+ first: () => Promise<Record<string, unknown> | null>;
991
+ order: (direction: "asc" | "desc") => ActionCacheQuery;
992
+ take: (limit: number) => Promise<Record<string, unknown>[]>;
993
+ withIndex: (indexName: string, range?: (q: ActionCacheIndexRange) => ActionCacheIndexRange) => ActionCacheQuery;
994
+ }
995
+ /**
996
+ * The slice of the ORM writer (`ctx.db` on an action or mutation) the helpers
997
+ * need. The real `DatabaseWriter` is structurally assignable, so pass `ctx.db`
998
+ * directly.
999
+ */
1000
+ interface ActionCacheDatabase {
1001
+ delete: <T extends string>(id: Id<T>) => Promise<void>;
1002
+ insert: (table: string, document: Record<string, unknown>) => Promise<unknown>;
1003
+ patch: <T extends string>(id: Id<T>, patch: Record<string, unknown>) => Promise<void>;
1004
+ query: (table: string) => ActionCacheQuery;
1005
+ }
1006
+ /** The slice of a function context the helpers need — just its writer. */
1007
+ interface ActionCacheContext {
1008
+ db: ActionCacheDatabase;
1009
+ }
1010
+ /** Options for {@link defineActionCache}. */
1011
+ interface DefineActionCacheOptions {
1012
+ /**
1013
+ * Cap on the serialized size (bytes) of one cached value. A larger result is
1014
+ * returned but not stored. Defaults to 512 KB; a non-finite value falls back
1015
+ * to the default.
1016
+ */
1017
+ maxValueBytes?: number;
1018
+ /**
1019
+ * How long (ms) an entry stays fresh. A read past it reports a miss.
1020
+ * Defaults to one hour.
1021
+ */
1022
+ ttlMs?: number;
1023
+ }
1024
+ /** The registered functions an action-cache component ships. */
1025
+ interface ActionCacheFunctions {
1026
+ /**
1027
+ * Internal mutation that hard-deletes expired entries, oldest first, and
1028
+ * reports how many it removed. Every miss already reaps a few rows
1029
+ * opportunistically, so an app with steady traffic stays bounded without
1030
+ * this; schedule it on a cron to reclaim entries whose names went quiet.
1031
+ *
1032
+ * Returns `{ deleted }`; compare it against `limit` to decide whether to run
1033
+ * again rather than assuming one pass drained the table. A caller-supplied
1034
+ * `limit` is clamped — `take()` has no ceiling of its own.
1035
+ */
1036
+ purgeExpired: RegisteredMutation<{
1037
+ limit: ReturnType<typeof v.optional>;
1038
+ }, {
1039
+ deleted: number;
1040
+ }>;
1041
+ }
1042
+ /**
1043
+ * SHA-256 of `name` and the serialized args, hex.
1044
+ *
1045
+ * Hashed rather than stored: call sites routinely pass an entire model request,
1046
+ * which is tens of kilobytes. As an indexed column that would be wasteful and
1047
+ * would run into column-size limits; a digest is fixed-width and indexes cleanly.
1048
+ * The NUL separator keeps `("ab", "c")` and `("a", "bc")` from colliding.
1049
+ * @param name the logical cache namespace (usually the action's name)
1050
+ * @param argumentsKey the arguments, already serialized by {@link serializeArgs}
1051
+ */
1052
+ declare const cacheKeyFor: (name: string, argumentsKey: string) => Promise<string>;
1053
+ /**
1054
+ * The component shape {@link defineActionCache} returns: the extension, the
1055
+ * registered functions, and the helpers an action calls directly.
1056
+ */
1057
+ type ActionCacheComponent = {
1058
+ functions: ActionCacheFunctions;
1059
+ /** Drop the entry for exactly this `name` + `args`. Resolves whether or not one existed. */
1060
+ invalidate: (context: ActionCacheContext, name: string, args: unknown) => Promise<void>;
1061
+ /**
1062
+ * Drop every entry under `name`, whatever its arguments.
1063
+ *
1064
+ * `complete` is `false` when the paging bound was reached with rows still
1065
+ * matching — call again. A plain count could not tell "dropped all of them"
1066
+ * apart from "dropped the first few thousand".
1067
+ */
1068
+ invalidateAll: (context: ActionCacheContext, name: string) => Promise<{
1069
+ complete: boolean;
1070
+ deleted: number;
1071
+ }>;
1072
+ /**
1073
+ * Return the cached result for `name` + `args`, or run `compute` and cache it.
1074
+ *
1075
+ * `args` is keyed through the wire codec with sorted keys, so two calls agree
1076
+ * whenever their arguments are structurally equal — key order does not matter,
1077
+ * and `bigint` / `Date` / bytes arguments key distinctly rather than colliding.
1078
+ */
1079
+ wrap: <T>(context: ActionCacheContext, name: string, args: unknown, compute: () => Promise<T>) => Promise<T>;
1080
+ } & Component<{
1081
+ [ACTION_CACHE_BARE_TABLE]: ReturnType<typeof defineTable>;
1082
+ }>;
1083
+ /**
1084
+ * The action-cache schema extension: one `entries` table, auto-namespaced to
1085
+ * `actionCache_entries` at merge time.
1086
+ */
1087
+ declare const actionCacheExtension: SchemaExtension<{
1088
+ [ACTION_CACHE_BARE_TABLE]: ReturnType<typeof defineTable>;
1089
+ }>;
1090
+ /**
1091
+ * Build an action-cache {@link Component} — schema extension, `purgeExpired`, and
1092
+ * the `wrap` / `invalidate` / `invalidateAll` helpers — wired to one TTL.
1093
+ * @param options cache configuration (TTL, value-size cap).
1094
+ * @returns a component bundling the extension, the functions, and the helpers.
1095
+ */
1096
+ declare const defineActionCache: (options?: DefineActionCacheOptions) => ActionCacheComponent;
1097
+ /**
1098
+ * Make any resolved storage capability bucket-aware so `ctx.storage.bucket(name)`
1099
+ * always resolves. A `createBucketStorage(...)` result already carries
1100
+ * `.bucket` / `.bucketName` and is returned as-is; a single `createStorage(...)`
1101
+ * (or the no-storage stub) gets `.bucket(name)` as the identity — single-bucket
1102
+ * apps address one binding under every name.
1103
+ *
1104
+ * Its `bucketName` tag is the storage's OWN name when it has one, and only
1105
+ * `"default"` when it does not. The tag is what `storageRules(...)` matches
1106
+ * `(bucket, operation)` rules against and what `assertRuleBucketsReachable`
1107
+ * checks, while `getSignedUrl` signs with the name the storage was built with —
1108
+ * stamping `"default"` over a `createStorage({ bucketName: "avatars" })` split
1109
+ * those two apart, so URLs were signed as `avatars` while an `avatars` rule was
1110
+ * rejected as unreachable and the storage was governed as `default`.
1111
+ *
1112
+ * Lives here rather than in `@lunora/server` because two packages need it and
1113
+ * neither may depend on the other: `@lunora/server` re-exports it as the runtime
1114
+ * counterpart `_generated/shard.ts` imports, and `@lunora/runtime` uses it to
1115
+ * build `ctx.storage` for an HTTP action from the worker's own R2 bindings.
1116
+ * Inlined into each `dist` by the bundler, so no dependency edge is created.
1117
+ *
1118
+ * The input is genuinely heterogeneous (a thunk result cast through `unknown`),
1119
+ * so the signature is `unknown → unknown`; callers cast the result.
1120
+ */
1121
+ declare const asBucketStorage: (raw: unknown) => unknown;
1122
+ /**
1123
+ * Entry point for the procedure builder. `dataModel<DM>()` binds the generated
1124
+ * `DataModel` (phantom for now), and `.create()` yields the public root builders
1125
+ * plus their `internal*` counterparts.
1126
+ */
1127
+ declare const initLunora: {
1128
+ dataModel: <DataModel>() => DataModelInit<DataModel>;
1129
+ };
1130
+ /**
1131
+ * Build the `ctx.secrets` reader from the worker `env`. `get(name)` resolves
1132
+ * `env[name].get()` — the `secrets_store_secrets[]` binding of that name. An
1133
+ * absent or non-Secrets-Store binding throws a directed error pointing at the
1134
+ * wrangler config; the lookup is lazy, so an unused secret never resolves.
1135
+ */
1136
+ declare const createSecrets: (env: Record<string, unknown>) => Secrets;
1137
+ /**
1138
+ * Wrap a storage facade so it also accepts `deleteAfterCommit(key)`.
1139
+ *
1140
+ * Call once per dispatch, outside any read-stamping wrapper: `bucket()` delegates
1141
+ * to the wrapped facade, so a sub-facade is still stamped by whatever wrapped it.
1142
+ * @param storage the bucket-aware facade from `asBucketStorage`
1143
+ */
1144
+ declare const withDeferredDeletes: (storage: unknown) => unknown;
1145
+ /** How a flush ended, so a caller can assert on it without this module owning a logger. */
1146
+ interface DeferredDeleteFlushResult {
1147
+ /** How many keys were attempted. */
1148
+ attempted: number;
1149
+ /** Keys whose delete did not happen, with the reason. Empty when everything succeeded. */
1150
+ failures: {
1151
+ error: unknown;
1152
+ key: string;
1153
+ }[];
1154
+ }
1155
+ /**
1156
+ * Delete everything queued on `context.storage`, draining the queue as it goes,
1157
+ * and report each failure through `context.log`.
1158
+ *
1159
+ * Draining first means a second flush of the same dispatch is a no-op rather than
1160
+ * a second round of deletes.
1161
+ *
1162
+ * Never throws. A flush runs after the write committed, so there is no longer
1163
+ * anything to fail into: rejecting here could only turn a leaked object into a
1164
+ * failed response for a write that already succeeded. Failures are logged and
1165
+ * also returned, so a dispatch can call this and ignore the result.
1166
+ * @param context the dispatch context; a context with no queued deletes is a no-op
1167
+ */
1168
+ declare const flushDeferredDeletes: (context: unknown) => Promise<DeferredDeleteFlushResult>;
1169
+ /** The slice of a function context the window needs. */
1170
+ interface DeferredScheduleContext {
1171
+ scheduler?: unknown;
1172
+ }
1173
+ /**
1174
+ * The scheduler surface the facade wraps — the two methods it intercepts, plus
1175
+ * whatever else the concrete scheduler carries (spread through untouched).
1176
+ *
1177
+ * A structural mirror rather than an import: `@lunora/scheduler`'s `Scheduler`,
1178
+ * `@lunora/shard-engine`'s `SchedulerLike` and the test harness's fake all reach
1179
+ * here, and their `target`/`args` parameters disagree. `never` parameters accept
1180
+ * every one of them while still pinning the shape and the `Promise<string>` a
1181
+ * caller depends on.
1182
+ *
1183
+ * `args` is REQUIRED here and that is load-bearing, not a typo. A parameter
1184
+ * declared optional widens to `never | undefined` — i.e. `undefined` — and
1185
+ * `undefined` is not assignable to the `ScheduleTargetArgs<T>` that
1186
+ * `@lunora/scheduler`'s `Scheduler.runAfter`/`runAt` require, so with `args?`
1187
+ * the one scheduler this facade exists to wrap was the one scheduler that could
1188
+ * not be passed to it: `withDeferredSchedules(createScheduler(env))` failed to
1189
+ * compile and every real call site had to cast. Required, `never` is assignable
1190
+ * to anything, so the real `Scheduler`, the shard-engine mirror (whose `args` IS
1191
+ * optional) and the harness fake all satisfy it.
1192
+ */
1193
+ interface SchedulerLike {
1194
+ runAfter: (delayMs: number, target: never, args: never, options?: never) => Promise<string>;
1195
+ runAt: (timestampMs: number, target: never, args: never, options?: never) => Promise<string>;
1196
+ }
1197
+ /**
1198
+ * Wrap a scheduler so `runAfter`/`runAt` can be held for a transaction.
1199
+ *
1200
+ * Call once per dispatch, OUTSIDE the read-stamping wrapper: the spread reads
1201
+ * through that proxy's `get` trap, so `get`/`list` stay stamped.
1202
+ *
1203
+ * Generic so the facade comes back as the SAME scheduler type it was handed:
1204
+ * every method but `runAfter`/`runAt` is spread through unchanged, and a caller
1205
+ * that had to widen to `unknown` and cast back — the generated shard among them
1206
+ * — could not be type-checked against the contract this docblock describes.
1207
+ * @param scheduler the `ctx.scheduler` implementation to wrap
1208
+ */
1209
+ declare const withDeferredSchedules: <S extends SchedulerLike>(scheduler: S) => S;
1210
+ /**
1211
+ * Open a deferral window on `context.scheduler` and return its settle function.
1212
+ *
1213
+ * Call it around the transaction, then settle with `true` once the commit has
1214
+ * landed (the buffered calls are dispatched, in order) or `false` when it rolled
1215
+ * back (they are dropped). The window owns the calls made while IT was the
1216
+ * innermost open one, so a rollback drops those and nothing else; on a commit an
1217
+ * enclosing window takes them over, which is how a `ctx.runMutation` inside an
1218
+ * open transaction hands its schedules to the span that actually commits them.
1219
+ *
1220
+ * A context whose scheduler was never wrapped gets an inert settle, so a caller
1221
+ * needs no branch.
1222
+ *
1223
+ * The settle NEVER stops early. It runs after the commit, so a job that fails to
1224
+ * enqueue can no longer fail the write that queued it — and abandoning the rest
1225
+ * of the batch on the first failure loses jobs the caller was told (by the id it
1226
+ * was handed) were scheduled. So every buffered call is attempted, in declaration
1227
+ * order, and what failed is reported once at the end — as itself when one job
1228
+ * failed, so its code and status survive, and as an `AggregateError` when several
1229
+ * did.
1230
+ * The caller's own post-commit work must still run: a settle that throws does not
1231
+ * excuse skipping the deferred-delete flush behind it.
1232
+ * @param context the dispatch context whose `scheduler` carries the queue
1233
+ */
1234
+ declare const beginDeferredSchedules: (context: DeferredScheduleContext) => ((committed: boolean) => Promise<void>);
1235
+ /**
1236
+ * Fields never written into a snapshot, whatever table they appear on.
1237
+ *
1238
+ * Names rather than types, because that is the only signal available here: a
1239
+ * trigger sees values, not the column metadata that would say "this one is a
1240
+ * secret". Extend it per app rather than relying on this list being complete.
1241
+ */
1242
+ declare const DEFAULT_REDACTED_FIELDS: ReadonlyArray<string>;
1243
+ declare const DOCUMENT_HISTORY_BARE_TABLE = "versions";
1244
+ /** The prefixed table name the extension produces at merge time. */
1245
+ declare const DOCUMENT_HISTORY_TABLE: "documentHistory_versions";
1246
+ /** One recorded version, as {@link DocumentHistoryFunctions.listForDocument} returns it. */
1247
+ interface DocumentHistoryEntry {
1248
+ /** The row as it stood after the write, redacted. Absent for a delete, and when `truncated`. */
1249
+ doc?: Record<string, unknown>;
1250
+ /** The row this version belongs to. */
1251
+ documentId: string;
1252
+ /** Which write produced this version. */
1253
+ op: "delete" | "insert" | "update";
1254
+ /** The row as it stood before the write, redacted. Absent for an insert, and when `truncated`. */
1255
+ previous?: Record<string, unknown>;
1256
+ /** When the write happened (epoch ms). */
1257
+ recordedAt: number;
1258
+ /** The table the row lives in. */
1259
+ tableName: string;
1260
+ /** `true` when the snapshots were dropped for exceeding `maxSnapshotBytes`. */
1261
+ truncated?: boolean;
1262
+ }
1263
+ /** Options for {@link defineDocumentHistory}. */
1264
+ interface DefineDocumentHistoryOptions {
1265
+ /**
1266
+ * Cap on one serialized snapshot (bytes). Past it the entry is written
1267
+ * without its snapshots and marked `truncated`. Defaults to 64 KB.
1268
+ */
1269
+ maxSnapshotBytes?: number;
1270
+ /**
1271
+ * Extra field names to drop from every snapshot, on top of the built-in
1272
+ * secret-shaped defaults.
1273
+ */
1274
+ redact?: ReadonlyArray<string>;
1275
+ /**
1276
+ * How long (ms) an entry is kept. `vacuum` deletes entries older than this.
1277
+ * Defaults to 90 days.
1278
+ */
1279
+ retentionMs?: number;
1280
+ }
1281
+ /** The registered functions a document-history component ships. */
1282
+ interface DocumentHistoryFunctions {
1283
+ /**
1284
+ * **Internal** query: the recorded versions of one row, newest first.
1285
+ *
1286
+ * Internal because an entry is a full row snapshot, including columns the
1287
+ * table's own RLS hides — wrap it in a procedure of your own that applies
1288
+ * whatever authorization the surface needs.
1289
+ *
1290
+ * Pass `before` to read the history as of an instant: the first entry back is
1291
+ * the last version at or before it, which is what a point-in-time
1292
+ * reconstruction needs.
1293
+ */
1294
+ listForDocument: RegisteredQuery<{
1295
+ before: ReturnType<typeof v.optional>;
1296
+ documentId: ReturnType<typeof v.string>;
1297
+ limit: ReturnType<typeof v.optional>;
1298
+ }, DocumentHistoryEntry[]>;
1299
+ /**
1300
+ * Internal mutation that deletes entries older than the retention window,
1301
+ * oldest first, and reports how many it removed. Schedule it on a cron.
1302
+ *
1303
+ * Compare `deleted` against `limit` to decide whether to run again rather
1304
+ * than assuming one pass drained the backlog.
1305
+ */
1306
+ vacuum: RegisteredMutation<{
1307
+ limit: ReturnType<typeof v.optional>;
1308
+ }, {
1309
+ deleted: number;
1310
+ }>;
1311
+ }
1312
+ /** The component shape {@link defineDocumentHistory} returns. */
1313
+ type DocumentHistoryComponent = {
1314
+ functions: DocumentHistoryFunctions;
1315
+ /**
1316
+ * The `.triggers(...)` argument that records this table's versions —
1317
+ * `.triggers(history.record)`.
1318
+ *
1319
+ * `after*` on all three ops: a `before*` handler runs while the write can
1320
+ * still be aborted, and a history entry for a write that never happened is a
1321
+ * lie the reader has no way to detect.
1322
+ */
1323
+ record: (t: TriggerBuilder) => Record<string, TriggerDefinition>;
1324
+ } & Component<{
1325
+ [DOCUMENT_HISTORY_BARE_TABLE]: ReturnType<typeof defineTable>;
1326
+ }>;
1327
+ /**
1328
+ * The document-history schema extension: one `versions` table, auto-namespaced
1329
+ * to `documentHistory_versions` at merge time.
1330
+ */
1331
+ declare const documentHistoryExtension: SchemaExtension<{
1332
+ [DOCUMENT_HISTORY_BARE_TABLE]: ReturnType<typeof defineTable>;
1333
+ }>;
1334
+ /**
1335
+ * Build a document-history {@link Component} — schema extension, the
1336
+ * `.triggers()` recorder, and the `listForDocument` / `vacuum` functions.
1337
+ * @param options history configuration (retention, redaction, snapshot cap).
1338
+ * @returns a component bundling the extension, the functions, and the recorder.
1339
+ */
1340
+ declare const defineDocumentHistory: (options?: DefineDocumentHistoryOptions) => DocumentHistoryComponent;
1341
+ /**
1342
+ * Redact secrets from a free-form message. Masks, in order: any quoted value
1343
+ * whose contents look like a credential (so a value surfaced as `received string
1344
+ * "sk_live_…"` is masked even though the surrounding text is not a token); a
1345
+ * `scheme://user:password@host` URL credential (the password segment); any
1346
+ * known-prefix credential token wherever it appears, at any length; any value
1347
+ * following a secret-named key in `KEY=value` / `KEY: value` form; and any
1348
+ * remaining bare high-entropy ≥24-char token run anywhere in the message.
1349
+ *
1350
+ * This is BEST-EFFORT defense-in-depth, NOT a guarantee: a short, prefix-less
1351
+ * secret under a non-secret-named key (and embedded credentials in shapes not
1352
+ * enumerated here) can still slip through. Treat it as a backstop — prefer
1353
+ * structured logging that never serializes raw env/secret fields in the first
1354
+ * place over relying on post-hoc scrubbing of untrusted data.
1355
+ *
1356
+ * Exported because it is independently useful — call it before logging anything
1357
+ * derived from `env`, request bodies, or thrown errors.
1358
+ */
1359
+ declare const redactSecrets: (message: string) => string;
1360
+ /** One key's validation failure, secrets already redacted out of `message`. */
1361
+ interface EnvKeyFailure {
1362
+ /** The env key that failed. */
1363
+ key: string;
1364
+ /** Redacted human-readable reason. */
1365
+ message: string;
1366
+ }
1367
+ /**
1368
+ * Thrown when one or more env keys are missing or fail validation. Carries the
1369
+ * structured list of `failures` (each with the offending `key`) so callers can
1370
+ * react programmatically; `message` is the joined, secret-redacted summary.
1371
+ *
1372
+ * Named export only (no default) per the repo export convention.
1373
+ */
1374
+ declare class LunoraEnvError extends LunoraError {
1375
+ readonly failures: ReadonlyArray<EnvKeyFailure>;
1376
+ constructor(failures: ReadonlyArray<EnvKeyFailure>);
1377
+ }
1378
+ /** A record of `v.*` validators describing the expected env shape. */
1379
+ type EnvShape = Record<string, Validator>;
1380
+ /**
1381
+ * The typed output of {@link defineEnv}. Optional validators (`v.optional(...)`)
1382
+ * become optional keys; everything else is required. Mirrors how `InferArgs`
1383
+ * derives an args object from a validator map.
1384
+ */
1385
+ type InferEnv<S extends EnvShape> = { [K in keyof S as undefined extends Infer<S[K]> ? K : never]?: Infer<S[K]>; } & { [K in keyof S as undefined extends Infer<S[K]> ? never : K]: Infer<S[K]>; };
1386
+ /**
1387
+ * The accessor returned by {@link defineEnv}. A typed view over an `env` object
1388
+ * plus a `.parse(env)` escape hatch that validates every key eagerly.
1389
+ *
1390
+ * Call the accessor with the worker's `env` to get the typed, lazily-validated
1391
+ * proxy: `const config = defineEnv({ … }); const { PORT } = config(env);`.
1392
+ */
1393
+ interface EnvAccessor<S extends EnvShape> {
1394
+ /** Validate every key eagerly and return the typed, plain (non-proxy) object. Use for fail-fast-at-boot. */
1395
+ parse: (env: unknown) => InferEnv<S>;
1396
+ /** Lazily-validated, per-key-cached typed view over `env`. Keys are validated on first access. */
1397
+ (env: unknown): InferEnv<S>;
1398
+ }
1399
+ /**
1400
+ * Define a typed, validated accessor over a Worker's `env`. Pass a record of
1401
+ * `v.*` validators; receive an accessor that validates lazily per key (cached
1402
+ * per `env` identity) and infers its output type from the validators.
1403
+ *
1404
+ * ```ts
1405
+ * import { defineEnv, v } from "@lunora/server";
1406
+ *
1407
+ * const config = defineEnv({
1408
+ * STRIPE_KEY: v.string(),
1409
+ * PORT: v.optional(v.number()),
1410
+ * });
1411
+ *
1412
+ * export default {
1413
+ * fetch(request, env) {
1414
+ * const { STRIPE_KEY, PORT } = config(env); // STRIPE_KEY: string, PORT?: number
1415
+ * // …
1416
+ * },
1417
+ * };
1418
+ * ```
1419
+ *
1420
+ * Throws {@link LunoraEnvError} (secrets redacted) when a key is missing or
1421
+ * invalid — lazily on first access of that key, or eagerly via `config.parse(env)`.
1422
+ */
1423
+ declare const defineEnv: <S extends EnvShape>(shape: S) => EnvAccessor<S>;
1424
+ /**
1425
+ * Minimal structural writer the facade binds over. Declared with **method**
1426
+ * syntax (not arrow properties) so a more-specifically-typed writer — both
1427
+ * `@lunora/do`'s `DatabaseWriterLike` and the RLS middleware's wrapped writer —
1428
+ * stays assignable under bivariant parameter checking. That is the whole reason
1429
+ * the shared helper can serve both callers, hence the rule exemption.
1430
+ */
1431
+ interface FacadeWriterLike {
1432
+ aggregate(tableName: string, options: unknown): Promise<unknown>;
1433
+ count(tableName: string, where?: unknown): Promise<number>;
1434
+ delete(id: string, expectedTable?: string, options?: {
1435
+ hard?: boolean;
1436
+ }): Promise<void>;
1437
+ deleteMany?(ids: ReadonlyArray<string>, options?: {
1438
+ limit?: number;
1439
+ }, expectedTable?: string): Promise<{
1440
+ deleted: number;
1441
+ }>;
1442
+ deleteWhere?(tableName: string, where: Record<string, unknown>, options?: {
1443
+ limit?: number;
1444
+ }): Promise<{
1445
+ deleted: number;
1446
+ }>;
1447
+ findFirst(tableName: string, args?: unknown): Promise<unknown>;
1448
+ findFirstOrThrow(tableName: string, args?: unknown): Promise<unknown>;
1449
+ findMany(tableName: string, args?: unknown): Promise<unknown>;
1450
+ get(id: string, expectedTable?: string): Promise<unknown>;
1451
+ groupBy(tableName: string, options: unknown): Promise<unknown>;
1452
+ insert(tableName: string, document: Record<string, unknown>): Promise<string>;
1453
+ insertMany?(tableName: string, documents: ReadonlyArray<Record<string, unknown>>, options?: {
1454
+ limit?: number;
1455
+ skipDuplicates?: boolean;
1456
+ }): Promise<(string | null)[]>;
1457
+ patch(id: string, patch: Record<string, unknown>, expectedTable?: string): Promise<void>;
1458
+ patchMany?(patches: ReadonlyArray<{
1459
+ id: string;
1460
+ patch: Record<string, unknown>;
1461
+ }>, options?: {
1462
+ limit?: number;
1463
+ }, expectedTable?: string): Promise<{
1464
+ patched: number;
1465
+ }>;
1466
+ patchWhere?(tableName: string, args: {
1467
+ patch: Record<string, unknown>;
1468
+ where: Record<string, unknown>;
1469
+ }, options?: {
1470
+ limit?: number;
1471
+ }): Promise<{
1472
+ patched: number;
1473
+ }>;
1474
+ query(tableName: string): {
1475
+ withGeoIndex(indexName: string, build: (q: unknown) => unknown): unknown;
1476
+ withSearchIndex(indexName: string, search: (q: unknown) => unknown): unknown;
1477
+ };
1478
+ rank(tableName: string, indexName: string, options: unknown): Promise<unknown>;
1479
+ rankPage(tableName: string, indexName: string, options?: unknown): Promise<unknown>;
1480
+ replace(id: string, document: Record<string, unknown>, expectedTable?: string): Promise<void>;
1481
+ restore?(id: string, expectedTable?: string): Promise<void>;
1482
+ }
1483
+ /** The per-table accessor object returned for the `ctx.db` table form. */
1484
+ interface FacadeEntry {
1485
+ aggregate: (options: unknown) => Promise<unknown>;
1486
+ count: (where?: unknown) => Promise<number>;
1487
+ delete: (id: string) => Promise<void>;
1488
+ deleteMany: {
1489
+ (ids: ReadonlyArray<string>, options?: {
1490
+ limit?: number;
1491
+ }): Promise<{
1492
+ deleted: number;
1493
+ }>;
1494
+ (args: {
1495
+ limit?: number;
1496
+ where: Record<string, unknown>;
1497
+ }): Promise<{
1498
+ deleted: number;
1499
+ }>;
1500
+ };
1501
+ /** `true` when at least one row matches `where` (or any row exists when omitted). Honors RLS like `findFirst`. */
1502
+ exists: (where?: unknown) => Promise<boolean>;
1503
+ findFirst: (args?: unknown) => Promise<unknown>;
1504
+ findFirstOrThrow: (args?: unknown) => Promise<unknown>;
1505
+ findMany: (args?: unknown) => Promise<unknown>;
1506
+ get: (id: string) => Promise<unknown>;
1507
+ groupBy: (options: unknown) => Promise<unknown>;
1508
+ /** Physically remove a row (and physically cascade), bypassing `.softDelete()`. */
1509
+ hardDelete: (id: string) => Promise<void>;
1510
+ insert: (document: Record<string, unknown>, options?: FacadeInsertOptions) => Promise<null | string>;
1511
+ /**
1512
+ * Insert many documents into this table in one call. With
1513
+ * `{ skipDuplicates: true }`, UNIQUE breaches resolve to `null` for that row
1514
+ * instead of failing the batch. The typed facade narrows the return to
1515
+ * `Id<T>[]` when skipDuplicates is not requested.
1516
+ */
1517
+ insertMany: (documents: ReadonlyArray<Record<string, unknown>>, options?: {
1518
+ limit?: number;
1519
+ skipDuplicates?: boolean;
1520
+ }) => Promise<(string | null)[]>;
1521
+ patch: (id: string, patch: Record<string, unknown>) => Promise<void>;
1522
+ patchMany: {
1523
+ (patches: ReadonlyArray<{
1524
+ id: string;
1525
+ values: Record<string, unknown>;
1526
+ }>, options?: {
1527
+ limit?: number;
1528
+ }): Promise<{
1529
+ patched: number;
1530
+ }>;
1531
+ (args: {
1532
+ limit?: number;
1533
+ values: Record<string, unknown>;
1534
+ where: Record<string, unknown>;
1535
+ }): Promise<{
1536
+ patched: number;
1537
+ }>;
1538
+ };
1539
+ rank: (indexName: string, options: unknown) => Promise<unknown>;
1540
+ rankPage: (indexName: string, options?: unknown) => Promise<unknown>;
1541
+ replace: (id: string, document: Record<string, unknown>) => Promise<void>;
1542
+ /** Un-soft-delete a row: clears the `.softDelete()` marker (by-id, so it reaches a row list reads hide). */
1543
+ restore: (id: string) => Promise<void>;
1544
+ /** Insert when no row matches `target`, else patch the match. Composes `findFirst` + `insert`/`patch`, so RLS applies to each step. */
1545
+ upsert: (args: UpsertArgs) => Promise<UpsertResult>;
1546
+ /** Sequential `upsert` over many rows sharing one `target`; returns one result per input row in order. */
1547
+ upsertMany: (args: UpsertManyArgs) => Promise<UpsertResult[]>;
1548
+ withGeoIndex: (indexName: string, build: (q: unknown) => unknown) => unknown;
1549
+ withSearchIndex: (indexName: string, search: (q: unknown) => unknown) => unknown;
1550
+ }
1551
+ /** Options accepted by the per-table `insert` accessor. */
1552
+ interface FacadeInsertOptions {
1553
+ /**
1554
+ * When `true`, a UNIQUE-constraint breach is swallowed: the insert becomes a
1555
+ * silent no-op and resolves to `null` instead of throwing a `CONFLICT`. Any
1556
+ * other error still propagates. Mirrors better-drizzle's `create({ skipDuplicates })`.
1557
+ */
1558
+ skipDuplicates?: boolean;
1559
+ }
1560
+ /** The conflict target for `upsert`/`upsertMany`: one field name or a tuple of them. */
1561
+ type UpsertTarget = ReadonlyArray<string> | string;
1562
+ /** Argument to the per-table `upsert` accessor. */
1563
+ interface UpsertArgs {
1564
+ /** Document inserted when no existing row matches the `target`. */
1565
+ create: Record<string, unknown>;
1566
+ /** Field(s) — typically a `.unique()` column or unique index — used to look up an existing row. */
1567
+ target: UpsertTarget;
1568
+ /** Patch applied when an existing row matches the `target`. Defaults to `create`. */
1569
+ update?: Record<string, unknown>;
1570
+ }
1571
+ /** Result of an `upsert`: the row's id and whether it was freshly inserted (`true`) or updated (`false`). */
1572
+ interface UpsertResult {
1573
+ created: boolean;
1574
+ id: string;
1575
+ }
1576
+ /** Argument to the per-table `upsertMany` accessor — a shared `target` plus per-row create/update payloads. */
1577
+ interface UpsertManyArgs {
1578
+ rows: ReadonlyArray<{
1579
+ create: Record<string, unknown>;
1580
+ update?: Record<string, unknown>;
1581
+ }>;
1582
+ target: UpsertTarget;
1583
+ }
1584
+ /**
1585
+ * Bind a structural writer to one table, producing its `ctx.db` table accessor.
1586
+ *
1587
+ * The by-id accessors (`get`/`delete`/`patch`/`replace`) forward the bound
1588
+ * `tableName` as `expectedTable` so the underlying writer scopes its id lookup
1589
+ * to this table. Without it, a branded `Id<"posts">` carrying another table's
1590
+ * id would resolve cross-table (the writer probes every table by id), letting
1591
+ * `ctx.db.posts.get(foreignId)` read — or `.delete`/`.patch`/`.replace`
1592
+ * mutate — a row in an unrelated table (IDOR). Writers that ignore the second
1593
+ * argument keep their previous global behaviour; the scoping is opt-in via this
1594
+ * forwarded name.
1595
+ */
1596
+ declare const bindTableFacade: (writer: FacadeWriterLike, tableName: string) => FacadeEntry;
1597
+ /** The kitcn-style `ctx.orm` namespace over a per-table facade map. */
1598
+ interface OrmLike {
1599
+ delete: (table: string, id: string) => Promise<void>;
1600
+ insert: (table: string) => {
1601
+ values: (document: Record<string, unknown>) => Promise<null | string>;
1602
+ };
1603
+ query: Record<string, FacadeEntry>;
1604
+ replace: (table: string, id: string) => {
1605
+ with: (document: Record<string, unknown>) => Promise<void>;
1606
+ };
1607
+ update: (table: string, id: string) => {
1608
+ set: (values: Record<string, unknown>) => Promise<void>;
1609
+ };
1610
+ }
1611
+ /** Build `ctx.orm` over a per-table facade map (table name → FacadeEntry). */
1612
+ declare const bindOrm: (facade: Record<string, FacadeEntry>) => OrmLike;
1613
+ /** HTTP verbs the typed {@link httpRoute} builder can bind to. */
1614
+ type HttpMethod = "DELETE" | "GET" | "HEAD" | "OPTIONS" | "PATCH" | "POST" | "PUT";
1615
+ /**
1616
+ * Context handed to an HTTP action handler. A narrower view of {@link ActionContext}:
1617
+ * HTTP actions run in the worker (the "action runtime"), separate from the
1618
+ * transactional store, so there is no direct `db` / `vectors` surface — reach the
1619
+ * data layer through `runQuery` / `runMutation` / `runAction`, which forward to
1620
+ * the owning shard. `db`'s absence is principled: an HTTP handler is not
1621
+ * transactional.
1622
+ *
1623
+ * `scheduler` and `storage` ARE present, because neither needs the shard — the
1624
+ * scheduler talks to the scheduler DO, and R2 is a worker binding an HTTP
1625
+ * handler can reach where an action does. Both are optional: each exists only
1626
+ * when the app declared the matching capability (`.scheduler(...)` /
1627
+ * `.storage(...)`) on the generated app builder.
1628
+ *
1629
+ * Omitting them was costly out of proportion to the gap. Without `scheduler`,
1630
+ * "receive webhook → enqueue the real work → return 200" — the shape HTTP
1631
+ * actions exist for — forced a hop through a mutation plus a closed allow-list
1632
+ * of target strings, because a function reference cannot cross the RPC boundary
1633
+ * and a free-form target on an unauthenticated endpoint is a "call any internal
1634
+ * function" primitive. Without `storage`, any helper the ctx was threaded into
1635
+ * had to be typed for its storage-touching branch, so a handler was barred from
1636
+ * the helper even on the branches that never went near storage.
1637
+ */
1638
+ type HttpActionCtx = {
1639
+ readonly scheduler?: ActionCtx["scheduler"];
1640
+ readonly storage?: ActionCtx["storage"];
1641
+ /**
1642
+ * The request's `ExecutionContext.waitUntil` — keep a promise alive past the
1643
+ * returned `Response`. Present whenever the host supplied one (a real
1644
+ * Cloudflare `ExecutionContext` always does; a partial context from a
1645
+ * framework mount seam or a unit test may not), hence optional.
1646
+ *
1647
+ * An HTTP action is the one ctx that routinely returns before its work is
1648
+ * done — "acknowledge the webhook in 200ms, then do the real thing" — and
1649
+ * without this a handler had no way to defer anything: work started and not
1650
+ * awaited is cancelled when the response resolves. Wrappers that need it
1651
+ * (`@lunora/x402`'s `withX402`, whose receipt sink must outlive the
1652
+ * response) read it structurally off whatever context they are handed, so
1653
+ * its absence made them silently no-op rather than fail.
1654
+ */
1655
+ readonly waitUntil?: (promise: Promise<unknown>) => void;
1656
+ } & Pick<ActionCtx, "auth" | "cache" | "fetch" | "runAction" | "runMutation" | "runQuery">;
1657
+ /** A raw handler wrapped by {@link httpAction}. Receives the raw request, returns the raw response. */
1658
+ type HttpActionHandler = (context: HttpActionCtx, request: Request) => Promise<Response> | Response;
1659
+ /**
1660
+ * The hono {@link https://hono.dev | Hono} environment used by {@link httpRouter}.
1661
+ * The runtime injects the per-request {@link HttpActionCtx} on the private
1662
+ * `__lunoraCtx` binding; the router's lifting middleware promotes it to
1663
+ * `c.var.lunora` so handlers can read it as a typed variable.
1664
+ */
1665
+ interface LunoraHttpEnv {
1666
+ Bindings: Record<string, unknown> & {
1667
+ __lunoraCtx?: HttpActionCtx;
1668
+ };
1669
+ Variables: {
1670
+ lunora: HttpActionCtx;
1671
+ };
1672
+ }
1673
+ /** The hono app type {@link httpRouter} returns. */
1674
+ type LunoraHttpApp = Hono<LunoraHttpEnv>;
1675
+ /** A compiled route handler: a hono handler that resolves to a raw {@link Response}. */
1676
+ type LunoraRouteHandler = (c: Context<LunoraHttpEnv>) => Promise<Response>;
1677
+ /**
1678
+ * Wrap a `(ctx, request) => Response` handler as a hono handler. The raw escape
1679
+ * hatch — mount it with `app.all(path, httpAction(fn))`. `ctx` is the
1680
+ * runtime-injected {@link HttpActionCtx} lifted into `c.var.lunora` by
1681
+ * {@link httpRouter}; `request` is the underlying `c.req.raw`.
1682
+ */
1683
+ declare const httpAction: (handler: HttpActionHandler) => LunoraRouteHandler;
1684
+ /**
1685
+ * Create the hono app for HTTP actions. Pre-wired with a middleware that lifts
1686
+ * the runtime-injected `c.env.__lunoraCtx` into `c.var.lunora`, so both
1687
+ * {@link httpAction} and the typed {@link httpRoute} builder can read the action
1688
+ * context. The full hono surface is available — plugins, path params, `.route`:
1689
+ *
1690
+ * ```ts
1691
+ * const app = httpRouter();
1692
+ * app.use("*", cors());
1693
+ * app.post("/webhook", httpAction(onWebhook));
1694
+ * app.get("/users/:id", getUser);
1695
+ * export default createWorker({ httpRouter: app, ... });
1696
+ * ```
1697
+ *
1698
+ * The lifting middleware throws if the context is absent. `createWorker` injects
1699
+ * it on every request the router sees, so this only trips when the app is run
1700
+ * outside the runtime — a misconfiguration we surface loudly rather than let
1701
+ * `c.var.lunora` be silently `undefined` despite its non-optional type.
1702
+ */
1703
+ declare const httpRouter: () => LunoraHttpApp;
1704
+ /** The `{ ctx, searchParams, body, params }` a typed route handler receives. */
1705
+ interface HttpRouteHandlerOptions<SearchParams extends ArgsValidator, Body extends ArgsValidator, Params extends ArgsValidator> {
1706
+ body: InferArgs<Body>;
1707
+ ctx: HttpActionCtx;
1708
+ params: InferArgs<Params>;
1709
+ searchParams: InferArgs<SearchParams>;
1710
+ }
1711
+ /**
1712
+ * The `{ ctx, searchParams, params, request, signal }` a streaming HTTP
1713
+ * handler receives. There is no parsed `body` — streams are typically GET, and
1714
+ * the raw `request` is exposed if a handler needs to read the body itself.
1715
+ * `signal` is tripped when the client disconnects.
1716
+ * @experimental Part of the HTTP-SSE stream surface; reconnect/POST-body design questions are still open.
1717
+ */
1718
+ interface HttpStreamHandlerOptions<SearchParams extends ArgsValidator, Params extends ArgsValidator> {
1719
+ ctx: HttpActionCtx;
1720
+ params: InferArgs<Params>;
1721
+ request: Request;
1722
+ searchParams: InferArgs<SearchParams>;
1723
+ signal: AbortSignal;
1724
+ }
1725
+ /**
1726
+ * A typed REST route under construction. `.searchParams()` / `.body()` /
1727
+ * `.params()` accumulate validator maps (later calls merge, a colliding key
1728
+ * wins) that decode the URL query, JSON body, and hono path params into the
1729
+ * handler's typed `searchParams` / `body` / `params`. Like the procedure
1730
+ * builder, `.output(validator)` defaults to the `undefined` sentinel — while
1731
+ * unset the handler is generic over its own return; once set the handler must
1732
+ * return that type and the result is parsed through the validator before
1733
+ * serialization. `[Output] extends [undefined]` is tuple-wrapped so a union
1734
+ * `Output` doesn't distribute and the test is for the exact sentinel.
1735
+ *
1736
+ * The terminal `.handler()` yields a {@link LunoraRouteHandler} — mount it
1737
+ * directly with `app.get(path, route)`.
1738
+ */
1739
+ interface HttpRouteBuilder<SearchParams extends ArgsValidator, Body extends ArgsValidator, Params extends ArgsValidator, Output = undefined> {
1740
+ body: <B extends ArgsValidator>(validators: B) => HttpRouteBuilder<SearchParams, B & Body, Params, Output>;
1741
+ /**
1742
+ * Attach a `Cache-Control` header to the response. Only meaningful when
1743
+ * Workers Cache is enabled in `wrangler.jsonc` (`"cache": { "enabled": true }`).
1744
+ */
1745
+ cacheControl: (value: string) => HttpRouteBuilder<SearchParams, Body, Params, Output>;
1746
+ /**
1747
+ * Attach a `Cache-Tag` header to the response for tag-based purging via
1748
+ * `ctx.cache.purge({ tags: [...] })`.
1749
+ */
1750
+ cacheTag: (value: string) => HttpRouteBuilder<SearchParams, Body, Params, Output>;
1751
+ handler: [Output] extends [undefined] ? <R>(handler: (options: HttpRouteHandlerOptions<SearchParams, Body, Params>) => Promise<R> | R) => LunoraRouteHandler : (handler: (options: HttpRouteHandlerOptions<SearchParams, Body, Params>) => Output | Promise<Output>) => LunoraRouteHandler;
1752
+ output: <V extends Validator>(validator: V) => HttpRouteBuilder<SearchParams, Body, Params, Infer<V>>;
1753
+ params: <P extends ArgsValidator>(validators: P) => HttpRouteBuilder<SearchParams, Body, P & Params, Output>;
1754
+ searchParams: <S extends ArgsValidator>(validators: S) => HttpRouteBuilder<S & SearchParams, Body, Params, Output>;
1755
+ /**
1756
+ * Terminal: declare this route as a streaming Server-Sent Events endpoint.
1757
+ * The handler is an async generator (or any function returning an
1758
+ * `AsyncIterable<R>`) that yields one chunk per SSE `data:` frame; on
1759
+ * iterator completion the route writes a final `event: complete` frame; on
1760
+ * throw, an `event: error` frame is written with `{code, message}` before
1761
+ * the stream closes. The chunks are JSON-encoded; `R` is inferred from the
1762
+ * handler's yielded type.
1763
+ * @experimental Reconnect/POST-body/wire-fidelity design questions are still open, so the shape may change.
1764
+ */
1765
+ stream: <R>(handler: (options: HttpStreamHandlerOptions<SearchParams, Params>) => AsyncGenerator<R, void, void> | AsyncIterable<R>) => LunoraRouteHandler;
1766
+ /**
1767
+ * Attach a `Vary` header to the response so Cloudflare stores separate
1768
+ * cached variants per distinct value of the listed request headers.
1769
+ */
1770
+ vary: (value: string) => HttpRouteBuilder<SearchParams, Body, Params, Output>;
1771
+ }
1772
+ /** Opens a fresh {@link HttpRouteBuilder}. The `path` documents intent; hono owns the actual routing at mount. */
1773
+ type HttpRouteFactory = (path: string) => HttpRouteBuilder<EmptyArgs, EmptyArgs, EmptyArgs>;
1774
+ /** The verb-keyed entry point: `httpRoute.get("/api/todos")…`. */
1775
+ interface HttpRoute {
1776
+ delete: HttpRouteFactory;
1777
+ get: HttpRouteFactory;
1778
+ head: HttpRouteFactory;
1779
+ options: HttpRouteFactory;
1780
+ patch: HttpRouteFactory;
1781
+ post: HttpRouteFactory;
1782
+ put: HttpRouteFactory;
1783
+ }
1784
+ /**
1785
+ * Typed REST route builder. Compiles down to a {@link LunoraRouteHandler}, so a
1786
+ * typed route and a hand-written {@link httpAction} are interchangeable when
1787
+ * mounted on {@link httpRouter}:
1788
+ *
1789
+ * ```ts
1790
+ * export const listTodos = httpRoute
1791
+ * .get("/api/todos")
1792
+ * .searchParams({ limit: v.number(), q: v.optional(v.string()) })
1793
+ * .output(v.array(v.object({ id: v.string(), text: v.string() })))
1794
+ * .handler(async ({ ctx, searchParams }) => ctx.runQuery(api.todos.list, searchParams));
1795
+ *
1796
+ * export const getTodo = httpRoute
1797
+ * .get("/api/todos/:id")
1798
+ * .params({ id: v.string() })
1799
+ * .handler(async ({ ctx, params }) => ctx.runQuery(api.todos.get, params));
1800
+ *
1801
+ * const app = httpRouter();
1802
+ * app.get("/api/todos", listTodos);
1803
+ * app.get("/api/todos/:id", getTodo);
1804
+ * ```
1805
+ */
1806
+ declare const httpRoute: HttpRoute;
1807
+ /**
1808
+ * True when `value` is safe to use as an HTTP header field-value: no CR, LF, or
1809
+ * NUL. Guards against response-header injection / `Headers`-construction throws
1810
+ * when reflecting attacker-influenced object metadata (e.g. a stored
1811
+ * `Content-Type`). Exported (see the `export {}` at the file end) so an `httpAction`
1812
+ * handler can guard a request-derived header value before writing it — the fix the
1813
+ * `http_action_response_header_injection` advisor lint points to.
1814
+ */
1815
+ declare const isSafeHeaderValue: (value: string) => boolean;
1816
+ /**
1817
+ * Structural view of an R2 object body, as returned by `@lunora/storage`'s
1818
+ * `download()`. Re-declared here (not imported) so `@lunora/server` takes no
1819
+ * runtime dependency on `@lunora/storage`; the real binding satisfies the shape.
1820
+ */
1821
+ interface StorageObjectBody {
1822
+ /** The object body stream (`null` for a zero-byte object). */
1823
+ body: ReadableStream | null;
1824
+ etag: string;
1825
+ httpMetadata?: {
1826
+ contentType?: string;
1827
+ };
1828
+ key: string;
597
1829
  /** Hex SHA-256, when R2 carries a checksum (surfaced by `@lunora/storage`). */
598
1830
  sha256?: string;
599
1831
  /** Base64 SHA-256 (RFC 9530 digest encoding), when R2 carries a checksum. */
@@ -605,8 +1837,22 @@ interface StorageRange {
605
1837
  length: number;
606
1838
  offset: number;
607
1839
  }
608
- /** The minimal storage surface {@link serveStorageObject} needs: a metadata-rich `download`. */
609
- interface StorageDownloader {
1840
+ /**
1841
+ * The minimal storage surface {@link serveStorageObject} needs: a metadata-rich
1842
+ * `download`, plus the body-free `head` a range request resolves against.
1843
+ *
1844
+ * `head` is required rather than optional-with-a-fallback because the fallback
1845
+ * is the bug: without it a ranged request has to start a full-object `download`
1846
+ * just to learn the size, then throw that body away. `@lunora/storage`'s `head`
1847
+ * already degrades internally to a 0-length ranged `get()` on a binding with no
1848
+ * HEAD, so there is nothing a caller here could usefully do that it does not.
1849
+ */
1850
+ interface StorageHead {
1851
+ /** Object metadata with no body. `size` is the FULL object size (mirrors R2). */
1852
+ head: (key: string) => Promise<Omit<StorageObjectBody, "body"> | null>;
1853
+ }
1854
+ /** The storage surface {@link serveStorageObject} reads through. */
1855
+ interface StorageDownloader extends StorageHead {
610
1856
  download: (key: string, options?: {
611
1857
  range?: StorageRange;
612
1858
  }) => Promise<StorageObjectBody | null>;
@@ -615,149 +1861,428 @@ interface StorageDownloader {
615
1861
  interface ContextWithStorage {
616
1862
  storage: StorageDownloader;
617
1863
  }
1864
+ /** The decision a {@link StorageServeAuthorizer} is asked to make. */
1865
+ interface StorageServeAuthzContext {
1866
+ /** The object key the caller asked for — already resolved by the route. */
1867
+ key: string;
1868
+ /** The inbound request, so the gate can read the signed-URL query, a cookie, or a bearer. */
1869
+ request: Request;
1870
+ }
1871
+ /**
1872
+ * The authorization decision for THIS object — {@link serveStorageObject}'s
1873
+ * required fourth argument, because the helper has no other way to know whether
1874
+ * the caller may read `key`. Return `true` to stream, anything else for a
1875
+ * **403**; a throwing gate is a denial too, never a 500 (fail closed, mirroring
1876
+ * `@lunora/storage`'s `createUploadHandler`).
1877
+ *
1878
+ * For a signed-URL topology this is where `verifySignedUrl` goes:
1879
+ * `async ({ request }) => (await verifySignedUrl(new URL(request.url), secret)).valid`.
1880
+ */
1881
+ type StorageServeAuthorizer = (context: StorageServeAuthzContext) => boolean | Promise<boolean>;
1882
+ /**
1883
+ * Stream a stored object as an HTTP {@link Response} from an `httpAction`
1884
+ * handler, with correct `Content-Type`, `ETag`, and `Accept-Ranges: bytes`.
1885
+ * Honors a single-range `Range` request → **206 Partial Content** with
1886
+ * `Content-Range` + `Content-Length`; otherwise **200**. A missing object is a
1887
+ * **404**; an out-of-bounds range is a **416** with a `Content-Range` of
1888
+ * `bytes` star-slash-size.
1889
+ *
1890
+ * `authorize` runs FIRST and is mandatory: this helper reads bytes out of a
1891
+ * bucket and hands them to whoever asked, so without a gate every mounted route
1892
+ * is an open object store. It does not verify signed URLs by itself — pass
1893
+ * `verifySignedUrl` (or a session check) as the gate.
1894
+ *
1895
+ * Every response carries `x-content-type-options: nosniff`, and any object whose
1896
+ * content type is not a raster image or media file also carries
1897
+ * `content-disposition: attachment` — an uploader-pinned `text/html` or
1898
+ * `image/svg+xml` must never render on the serving origin.
1899
+ *
1900
+ * A range request resolves its window against a body-free `head()`, then issues
1901
+ * ONE `download()` with the resolved `{ offset, length }` so R2 streams just
1902
+ * those bytes — the slice is never buffered in the isolate, and no full-object
1903
+ * body transfer is started only to be cancelled. A request that cannot produce a
1904
+ * 206 at all (no `Range`, multi-range, malformed) skips the `head()` entirely and
1905
+ * streams straight from a single `download()`. For very
1906
+ * large objects a signed URL (`ctx.storage.getSignedUrl`) is still cheaper since
1907
+ * the client then ranges against R2/CDN directly with no Worker hop.
1908
+ *
1909
+ * `cacheControl` defaults to `no-store`, which is right whenever `authorize`
1910
+ * scopes the bytes to an identity. It is a parameter rather than something
1911
+ * derived from `authorize` because a stub returning `true` during development
1912
+ * would then silently publish a cache. An app fronting a genuinely public
1913
+ * bucket passes its own value; `content-disposition` and `nosniff` stay
1914
+ * unconditional either way. Note the default also defeats conditional
1915
+ * revalidation, so the `etag` computed here can never answer a 304.
1916
+ */
1917
+ declare const serveStorageObject: (context: ContextWithStorage, key: string, request: Request, authorize: StorageServeAuthorizer, cacheControl?: string) => Promise<Response>;
1918
+ /**
1919
+ * What the worker does with a resolver's identity when it fails contract
1920
+ * validation (a forged / malformed claim set arriving from an untrusted token).
1921
+ * `"anonymous"` (default, safe) treats the request as anonymous, so the bad
1922
+ * identity never reaches a policy as a valid identity (`ctx.auth.userId`
1923
+ * becomes `undefined`). `"reject"` fails the request closed (a `401`) — use
1924
+ * when a malformed credential should be a hard error, not a silent downgrade.
1925
+ */
1926
+ type IdentityRejectMode = "anonymous" | "reject";
1927
+ /** Options for {@link defineIdentity}. */
1928
+ interface DefineIdentityOptions {
1929
+ /**
1930
+ * How to handle a resolver identity that violates the contract at the trust
1931
+ * boundary. Defaults to `"anonymous"` (a forged claim set is downgraded to
1932
+ * anonymous rather than flowing in as an unchecked cast).
1933
+ */
1934
+ readonly onInvalid?: IdentityRejectMode;
1935
+ }
1936
+ /** Result of validating a candidate identity against the contract. */
1937
+ type IdentityValidation = {
1938
+ ok: true;
1939
+ } | {
1940
+ error: string;
1941
+ ok: false;
1942
+ };
618
1943
  /**
619
- * Stream a stored object as an HTTP {@link Response} from an `httpAction`
620
- * handler, with correct `Content-Type`, `ETag`, and `Accept-Ranges: bytes`.
621
- * Honors a single-range `Range` request **206 Partial Content** with
622
- * `Content-Range` + `Content-Length`; otherwise **200**. A missing object is a
623
- * **404**; an out-of-bounds range is a **416** with a `Content-Range` of
624
- * `bytes` star-slash-size.
625
- *
626
- * A range request re-issues the `download()` with the resolved `{ offset, length }`
627
- * window so R2 streams only those bytes back to the Worker — the slice is never
628
- * buffered in the isolate. The first `download()` is used only for the object's
629
- * size + metadata (its body is left unread and cancelled). For very large
630
- * objects a signed URL (`ctx.storage.getSignedUrl`) is still cheaper since the
631
- * client then ranges against R2/CDN directly with no Worker hop.
632
- */
633
- declare const serveStorageObject: (context: ContextWithStorage, key: string, request: Request) => Promise<Response>;
1944
+ * A declared identity claim contract. Carries the codegen discovery brand, the
1945
+ * declared claim validators, the reject policy, and a runtime `validate`. The
1946
+ * `TClaims` type parameter is the inferred claim shape (always extending
1947
+ * `{ userId: string }`); it is phantom (no runtime field) and exists so
1948
+ * `@lunora/codegen` and {@link InferIdentity} can recover the type.
1949
+ */
1950
+ interface IdentityContract<TClaims extends {
1951
+ userId: string;
1952
+ } = {
1953
+ userId: string;
1954
+ }> {
1955
+ /**
1956
+ * Phantom carrier for the inferred claim type. Never populated at runtime
1957
+ * (`undefined`); present only so the type flows to codegen / {@link InferIdentity}.
1958
+ */
1959
+ readonly __claimType?: TClaims;
1960
+ readonly __lunoraIdentity: true;
1961
+ /** The declared claim validators (a `@lunora/values` validator map). */
1962
+ readonly claims: ValidatorMap;
1963
+ /** Reject policy applied at the trust boundary. See {@link IdentityRejectMode}. */
1964
+ readonly onInvalid: IdentityRejectMode;
1965
+ /**
1966
+ * Validate a resolver's returned identity against the declared claims. On
1967
+ * success the caller keeps the original identity untouched (so undeclared
1968
+ * claims are forwarded verbatim, preserving today's behaviour); on failure
1969
+ * the worker applies the `onInvalid` policy.
1970
+ */
1971
+ validate: (identity: Record<string, unknown>) => IdentityValidation;
1972
+ }
1973
+ /** Recover the declared claim type from a {@link defineIdentity} contract. */
1974
+ type InferIdentity<T> = T extends IdentityContract<infer TClaims> ? TClaims : never;
1975
+ /**
1976
+ * Declare the identity claim contract. `claims` is a `@lunora/values` validator
1977
+ * map whose inferred type must extend `{ userId: string }` — if it does not
1978
+ * (e.g. `userId` is missing or not a required string), the argument type
1979
+ * collapses to `never` and the call fails to typecheck.
1980
+ * @example
1981
+ * export const identity = defineIdentity({ userId: v.string(), tenantId: v.optional(v.string()), scopes: v.optional(v.array(v.string())) });
1982
+ */
1983
+ declare const defineIdentity: <A extends ValidatorMap>(claims: InferValidatorMap<A> extends {
1984
+ userId: string;
1985
+ } ? A : never, options?: DefineIdentityOptions) => IdentityContract<InferValidatorMap<A> & {
1986
+ userId: string;
1987
+ }>;
634
1988
  /** Handler for a connection-lifecycle hook. */
635
1989
  type LifecycleHandler = (context: MutationCtx, event: LifecycleEvent) => Promise<void> | void;
1990
+ /** Handler for a shard-init hook. */
1991
+ type ShardInitHandler = (context: MutationCtx, event: ShardInitEvent) => Promise<void> | void;
636
1992
  /** Register a hook that fires once when a client's WebSocket connects. */
637
1993
  declare const onConnect: (handler: LifecycleHandler) => RegisteredLifecycleHook;
638
1994
  /** Register a hook that fires once when a client's WebSocket disconnects. */
639
1995
  declare const onDisconnect: (handler: LifecycleHandler) => RegisteredLifecycleHook;
640
1996
  /**
641
- * Context handed to a {@link MaskFn} (and to {@link MaskOptions.bypass}). The
642
- * `auth` shape mirrors RLS's `PolicyContext.auth` one-for-onesame identity
643
- * resolver, same `can(...)` permission check — so an author can branch a mask
644
- * on the caller's role/permission. `row` is the full pre-mask row the column
645
- * belongs to; `column` is the column currently being masked. Both are absent
646
- * when the context is used for the procedure-wide `bypass` check (no specific
647
- * cell is in play yet).
648
- */
649
- interface MaskContext<Context = unknown> {
650
- readonly auth: {
651
- /** `true` when any of the request's `roles` grants `permission` (see {@link MaskOptions.roles}). Fails closed for unregistered roles. */
652
- readonly can: (permission: Permission | string) => boolean;
653
- readonly identity?: Record<string, unknown> | null;
654
- readonly roles: ReadonlyArray<string>;
655
- readonly userId: null | string;
656
- };
657
- /** The column currently being masked. Present only inside a per-cell {@link MaskFn}. */
658
- readonly column?: string;
659
- readonly ctx: Context;
660
- /** The full pre-mask row the masked cell belongs to. Present only inside a per-cell {@link MaskFn}. */
661
- readonly row?: Record<string, unknown>;
1997
+ * Register a hook that fires ONCE per Durable Object instance, before any
1998
+ * handler on that instance can runthe re-init half of `.memory()` tables.
1999
+ *
2000
+ * A shard is not a process that stays up. Cloudflare reconstructs the Durable
2001
+ * Object after every eviction, and a shard whose sockets are hibernating is
2002
+ * evicted routinely, so "cold start" is a steady-state event rather than a rare
2003
+ * one. Everything the shard held in memory is gone at that moment: the JS heap,
2004
+ * and every `.memory()` table, which the framework has already cleared by the
2005
+ * time this hook runs.
2006
+ *
2007
+ * ```ts
2008
+ * // lunora/init.ts
2009
+ * import { onShardInit } from "@lunora/server";
2010
+ *
2011
+ * export const warm = onShardInit(async (ctx, event) => {
2012
+ * // Rebuild ephemeral state from the durable tables that outlived us.
2013
+ * for await (const member of ctx.db.roomMembers.iterate({ where: { roomId: event.shardKey } })) {
2014
+ * await ctx.db.presence.insert({ userId: member.userId, status: "away" });
2015
+ * }
2016
+ * });
2017
+ * ```
2018
+ *
2019
+ * **Ordering is the guarantee.** Memory tables are cleared, then every init hook
2020
+ * runs to completion, and only then does the dispatch that triggered the cold
2021
+ * start proceed. No handler, subscription refresh, alarm, or shape poke can
2022
+ * observe a memory table in the gap. Hooks run sequentially in manifest order,
2023
+ * so one may depend on state an earlier one wrote.
2024
+ *
2025
+ * **It is a mutation, and it runs on every cold start.** Keep it cheap and keep
2026
+ * it idempotent: it is on the latency path of the request that woke the shard,
2027
+ * and it will run again — many times — over the shard's life. Writing to durable
2028
+ * tables from here is legal and occasionally right, but remember it is a
2029
+ * rebuild, not a migration; use `defineMigration` for anything that should
2030
+ * happen once.
2031
+ *
2032
+ * **No caller identity.** The hook dispatches as a trusted system call with no
2033
+ * request identity — `ctx.auth` is anonymous and RLS does not apply even under
2034
+ * `.rls("required")`, exactly as for a cron tick or a migration. RLS scopes rows
2035
+ * to a user and an init hook has none, so `ctx.db` here sees every row: scope
2036
+ * your reads yourself. (`onConnect`/`onDisconnect` are the opposite case — they
2037
+ * carry the socket's verified identity and stay RLS-guarded.)
2038
+ *
2039
+ * A throw is logged and does NOT fail the dispatch that woke the shard — an init
2040
+ * hook that cannot rebuild presence must not take the whole shard down with it.
2041
+ * The table is then cleared but not refilled, so reads see nothing. A failure
2042
+ * EARLIER, before the framework's clear runs, instead leaves the previous
2043
+ * instance's rows in place: a memory table's rows live in SQLite until they are
2044
+ * deleted, so an eviction on its own does not remove them.
2045
+ */
2046
+ declare const onShardInit: (handler: ShardInitHandler) => RegisteredLifecycleHook;
2047
+ /** Default `limit` when the caller doesn't ask for one. */
2048
+ declare const DEFAULT_LIMIT = 25;
2049
+ /** Default ceiling on `limit`, so one request can't ask for an unbounded page. */
2050
+ declare const DEFAULT_MAX_LIMIT = 100;
2051
+ /**
2052
+ * Per-field predicate accepted for a declared filter column. An alias of the
2053
+ * `ctx.db` `where` DSL's own operator type rather than a copy, so the two cannot
2054
+ * drift as operators are added.
2055
+ */
2056
+ type ListFilterOperators<T> = WhereOperators<T>;
2057
+ /**
2058
+ * The filter allow-list a caller may declare: a subset of the document's own
2059
+ * columns, each with a validator for that column's type. Constraining the KEYS to
2060
+ * `keyof Doc` is what turns a typo'd or renamed column into a compile error
2061
+ * instead of a predicate that silently never matches.
2062
+ */
2063
+ type ListFilterShape<TDocument> = { [K in keyof TDocument & string]?: Validator<TDocument[K]>; };
2064
+ /** The `where` argument: each declared filter column, optionally, as a bare value or an operator object. */
2065
+ type ListWhere<F> = { [K in keyof F]?: Infer<NonNullable<F[K]>> | ListFilterOperators<Infer<NonNullable<F[K]>>>; };
2066
+ /** One `orderBy` entry. `direction` defaults to `"asc"`. */
2067
+ interface ListOrderByEntry<O extends string> {
2068
+ direction?: "asc" | "desc";
2069
+ field: O;
2070
+ }
2071
+ /** The decoded arguments a {@link defineListArgs} endpoint receives. */
2072
+ interface ListArgsValue<F, O extends string> {
2073
+ cursor?: null | number | string;
2074
+ limit?: number;
2075
+ orderBy?: ListOrderByEntry<O>[];
2076
+ where?: ListWhere<F>;
662
2077
  }
663
- /**
664
- * A custom masking function. Receives the raw cell value and the
665
- * {@link MaskContext}, returns the value to surface. Use it for partial masks
666
- * (`maskMiddle(phone)`), role-aware reveals (`ctx.auth.can(...) ? value : null`),
667
- * or format-preserving tokens. A function that **throws** fails closed — the
668
- * cell is redacted to `null`, never leaked raw.
669
- */
670
- type MaskFn<Context = unknown> = (value: unknown, context: MaskContext<Context>) => unknown;
671
- /**
672
- * How a column is masked:
673
- *
674
- * - `"redact"` — drop the value to `null`. The simplest, safest strategy, and
675
- * the right choice for any value that must actually be kept secret.
676
- * - `"hash"` — replace with a stable token (unsalted 32-bit FNV-1a hex) so the
677
- * same input always yields the same token (joinable/groupable client-side).
678
- * **This is NOT a confidentiality control.** It is a non-cryptographic,
679
- * unsalted, deterministic, narrow (~2^32) digest: low-entropy values (emails,
680
- * phone numbers, SSNs) are brute-force-recoverable by the very caller you are
681
- * masking from, and identical values always produce identical tokens across
682
- * rows/columns/tenants (enabling correlation). Use `"hash"` ONLY when you want a
683
- * stable pseudonym for grouping/joining and leaking the value is acceptable —
684
- * never to hide sensitive PII. For PII that must stay hidden, use `"redact"`.
685
- * - a {@link MaskFn} author-defined transform (partial mask, role-aware reveal).
686
- */
687
- type MaskStrategy<Context = unknown> = "hash" | "redact" | MaskFn<Context>;
688
- /** Per-column strategy map for one table: `{ email: "redact", phone: maskMiddle }`. */
689
- type MaskColumns<Context = unknown> = Record<string, MaskStrategy<Context>>;
690
- /**
691
- * The mask declaration passed to `mask(...)`: a table → column → strategy map.
692
- * Deliberately a plain object literal so the codegen feeder can statically read
693
- * which columns a procedure masks (powering the `mask_uncovered_pii_column`
694
- * advisor lint), exactly as the RLS feeder reads policy tables.
695
- */
696
- type MaskPolicies<Context = unknown> = Record<string, MaskColumns<Context>>;
697
- /**
698
- * Options for `mask(policies, options)`.
699
- *
700
- * - `roles` registers the role→permission grants that back `ctx.auth.can(...)`
701
- * inside a {@link MaskFn} identical to `rls(policies, { roles })`. A role
702
- * not listed grants no permissions (fails closed for unknown roles).
703
- * - `bypass` is a procedure-wide escape hatch: when it returns `true` the whole
704
- * mask is skipped (the caller sees raw values). Use it for a privileged
705
- * viewer `bypass: ({ auth }) => auth.can("pii:view")`. Prefer this over
706
- * branching every column when an entire class of caller should see clear data.
707
- */
708
- interface MaskOptions<Context = unknown> {
709
- readonly bypass?: (context: MaskContext<Context>) => boolean;
710
- readonly roles?: ReadonlyArray<Role>;
2078
+ interface DefineListArgsConfig<F, O extends string> {
2079
+ /** `limit` applied when the caller omits one. Defaults to 25. */
2080
+ readonly defaultLimit?: number;
2081
+ /**
2082
+ * Allow-list of filterable columns. Publish only columns an index can serve;
2083
+ * anything absent here is unreachable from the client.
2084
+ */
2085
+ readonly filter: F;
2086
+ /** Ceiling on `in` / `notIn` array length — one bound parameter each. Defaults to 100. */
2087
+ readonly maxInValues?: number;
2088
+ /** Ceiling on `limit`; a larger request is clamped down, not rejected. Defaults to 100. */
2089
+ readonly maxLimit?: number;
2090
+ /** Ceiling on how many `orderBy` entries a request may ask for. Defaults to 8. */
2091
+ readonly maxOrderBy?: number;
2092
+ /** Allow-list of sortable columns. Pass `[]` to fix the order server-side. */
2093
+ readonly orderBy: ReadonlyArray<O>;
2094
+ }
2095
+ /** The validator map handed to `.input()`. Typed precisely so `args` infers end-to-end. */
2096
+ interface ListArgsValidators<F, O extends string> {
2097
+ cursor: ColumnValidator<null | number | string | undefined, null | number | string | undefined>;
2098
+ limit: ColumnValidator<number | undefined, number | undefined>;
2099
+ orderBy: ColumnValidator<ListOrderByEntry<O>[] | undefined, ListOrderByEntry<O>[] | undefined>;
2100
+ where: ColumnValidator<ListWhere<F> | undefined, ListWhere<F> | undefined>;
2101
+ }
2102
+ interface ListArgsSpec<TDocument, F, O extends string> {
2103
+ /** Spread into `.input(...)` `{ cursor, limit, orderBy, where }`. */
2104
+ readonly args: ListArgsValidators<F, O>;
2105
+ /**
2106
+ * Translate the decoded arguments into the `findMany` options object:
2107
+ * `limit` clamped into `[1, maxLimit]`, `orderBy` reshaped from
2108
+ * `{ field, direction }[]` into `ctx.db`'s `{ column: direction }[]`.
2109
+ *
2110
+ * Returns `QueryArgs<Doc>` — bound to the table, not free — so a mismatch
2111
+ * between what this helper declares and what the table actually holds is a
2112
+ * compile error at the `findMany` call site.
2113
+ */
2114
+ readonly toQueryArgs: (args: ListArgsValue<F, O>) => QueryArgs$2<TDocument>;
2115
+ }
2116
+ /** Clamp a caller-supplied `limit` into `[1, maxLimit]`; a non-finite value falls back to `fallback`. */
2117
+ declare const clampLimit: (limit: number | undefined, fallback: number, maxLimit: number) => number;
2118
+ /**
2119
+ * Declare the filter / sort / page arguments for a list endpoint, plus the
2120
+ * translation into `ctx.db.<table>.findMany(...)` options. See the module docs
2121
+ * for the shape and the reasoning behind it.
2122
+ *
2123
+ * Curried on the document type: `defineListArgs<Doc<"messages">>()({ })`. The
2124
+ * extra `()` buys the thing that matters — with `Doc` bound, `filter` keys and
2125
+ * `orderBy` entries are checked against the table's real columns, so a typo or a
2126
+ * column renamed out from under the endpoint is a COMPILE error instead of a
2127
+ * predicate that silently matches nothing. TypeScript has no partial type-argument
2128
+ * inference, so binding `Doc` explicitly while still inferring `F` and `O` from
2129
+ * the config requires the second call.
2130
+ */
2131
+ declare const defineListArgs: <TDocument>() => <F extends ListFilterShape<TDocument>, O extends keyof TDocument & string>(config: DefineListArgsConfig<F, O>) => ListArgsSpec<TDocument, F, O>;
2132
+ /**
2133
+ * Structural mirrors of `@lunora/shard-engine`'s rank-page-row shapes
2134
+ * (`RankPageRowKey` / `RankPageRow` / `ShardRankPageResult`) — the return type
2135
+ * of the writer's `rankPageRows` seam, the cross-shard companion to
2136
+ * `rankPage`.
2137
+ *
2138
+ * Shared by `../rls/middleware` and `../mask/middleware`: both wrap
2139
+ * `rankPageRows` structurally (no `@lunora/shard-engine` import, mirroring how
2140
+ * every other method on their `DatabaseWriterLike`/`MaskDatabase` projections
2141
+ * is hand-mirrored rather than imported) and both need the exact same result
2142
+ * shape to type their overrides. A single copy here means the two wrappers
2143
+ * can't drift out of lockstep with each other — see AGENTS.md's platform
2144
+ * parity note on `ShardSqlExec` and the canonical binding `*Like` projections
2145
+ * shipping wrong for exactly this reason (two hand-maintained mirrors of one
2146
+ * upstream type).
2147
+ */
2148
+ /** Structural mirror of `@lunora/shard-engine`'s `RankPageRowKey`. */
2149
+ interface RankPageRowKeyLike {
2150
+ partitionKey: string;
2151
+ rowId: string;
2152
+ sortValues: ReadonlyArray<unknown>;
711
2153
  }
712
- interface QueryPage$1 {
713
- continueCursor: null | string;
714
- isDone: boolean;
715
- page: Record<string, unknown>[];
2154
+ /** Structural mirror of `@lunora/shard-engine`'s `RankPageRow`. */
2155
+ interface RankPageRowLike {
2156
+ doc: Record<string, unknown>;
2157
+ key: RankPageRowKeyLike;
2158
+ }
2159
+ /** Structural mirror of `@lunora/shard-engine`'s `ShardRankPageResult` — the `rankPageRows` return shape. */
2160
+ interface ShardRankPageResultLike {
2161
+ directions: ReadonlyArray<"asc" | "desc">;
2162
+ hasMore: boolean;
2163
+ rows: ReadonlyArray<RankPageRowLike>;
716
2164
  }
2165
+ /**
2166
+ * Structural mirror of `@lunora/do`'s `QueryArgs` and `CountArgs`. The
2167
+ * runtime ORM in `@lunora/do`/`@lunora/d1` reads `baseWhere` /
2168
+ * `restrictsCounts` straight off these option objects, so as long as the
2169
+ * fields here stay name-compatible the wrapper is portable across the two
2170
+ * dialects without an inter-package dependency.
2171
+ */
717
2172
  interface QueryArgs$1 {
718
- baseWhere?: unknown;
2173
+ baseWhere?: WhereInput;
719
2174
  cursor?: null | string;
720
2175
  limit?: number;
721
- where?: unknown;
2176
+ orderBy?: ReadonlyArray<unknown>;
2177
+ /**
2178
+ * Per-target-table read filter the RLS wrapper attaches so a `with` relation
2179
+ * is policy-filtered on its own hop (see `@lunora/do`'s `QueryArgs`). Mirrors
2180
+ * the top-level read: `(table) => readBase(table).baseWhere`.
2181
+ */
2182
+ relationBaseWhere?: (table: string) => undefined | WhereInput;
2183
+ restrictsCounts?: boolean;
2184
+ where?: WhereInput;
722
2185
  with?: Record<string, unknown>;
723
2186
  }
2187
+ interface CountArgs {
2188
+ baseWhere?: WhereInput;
2189
+ relationBaseWhere?: (table: string) => undefined | WhereInput;
2190
+ restrictsCounts?: boolean;
2191
+ where?: WhereInput;
2192
+ }
2193
+ /** Structural mirror of `@lunora/do`'s `AggregateOptions` — only the fields the wrapper touches. */
724
2194
  interface AggregateArgs$1 {
2195
+ baseWhere?: WhereInput;
725
2196
  field?: string;
726
2197
  op: string;
2198
+ relationBaseWhere?: (table: string) => undefined | WhereInput;
2199
+ restrictsCounts?: boolean;
2200
+ where?: WhereInput;
727
2201
  }
2202
+ /** Structural mirror of `@lunora/do`'s `GroupByOptions`. */
728
2203
  interface GroupByArgs$1 {
729
2204
  agg?: {
730
2205
  field?: string;
731
2206
  op: string;
732
2207
  };
2208
+ baseWhere?: WhereInput;
733
2209
  by: ReadonlyArray<string>;
2210
+ relationBaseWhere?: (table: string) => undefined | WhereInput;
2211
+ restrictsCounts?: boolean;
2212
+ where?: WhereInput;
2213
+ }
2214
+ /** Structural mirror of `@lunora/do`'s `RankOptions`. */
2215
+ interface RankArgs {
2216
+ baseWhere?: WhereInput;
2217
+ restrictsCounts?: boolean;
2218
+ row: Record<string, unknown> | string;
2219
+ where?: WhereInput;
2220
+ }
2221
+ /** Structural mirror of `@lunora/do`'s `RankBeforeOptions`. */
2222
+ interface RankBeforeArgs {
2223
+ partitionKey: string;
2224
+ restrictsCounts?: boolean;
2225
+ rowId: string;
2226
+ sortValues: ReadonlyArray<unknown>;
2227
+ }
2228
+ /** Structural mirror of `@lunora/do`'s `RankPageOptions`. */
2229
+ interface RankPageArgs {
2230
+ baseWhere?: WhereInput;
2231
+ cursor?: null | string;
2232
+ restrictsCounts?: boolean;
2233
+ take?: number;
2234
+ where?: WhereInput;
2235
+ }
2236
+ interface QueryPage$1 {
2237
+ continueCursor: null | string;
2238
+ isDone: boolean;
2239
+ page: Record<string, unknown>[];
734
2240
  }
735
2241
  interface TableReaderLike$1 {
736
2242
  collect: () => Promise<Record<string, unknown>[]>;
737
2243
  filter: (predicate: (document: Record<string, unknown>) => boolean) => TableReaderLike$1;
738
2244
  first: () => Promise<Record<string, unknown> | null>;
739
- order: (direction: "asc" | "desc") => TableReaderLike$1;
740
2245
  paginate: (options: {
741
2246
  cursor?: null | string;
742
2247
  numItems: number;
743
2248
  }) => Promise<QueryPage$1>;
744
2249
  take: (limit: number) => Promise<Record<string, unknown>[]>;
745
- unique: () => Promise<Record<string, unknown> | null>;
2250
+ withGeoIndex: (indexName: string, build: (q: unknown) => unknown) => TableReaderLike$1;
746
2251
  withIndex: (indexName: string, range?: (q: unknown) => unknown) => TableReaderLike$1;
747
2252
  withSearchIndex: (indexName: string, search: (q: unknown) => unknown) => TableReaderLike$1;
748
2253
  }
749
2254
  /**
750
- * Structural projection of the runtime ORM writer the same subset
751
- * `../rls/middleware` mirrors, so the wrapper is interchangeable between
752
- * `@lunora/do`'s and `@lunora/d1`'s `DatabaseWriterLike` without an
753
- * inter-package dependency. `rankBefore` is optional (the D1 twin omits it).
754
- */
755
- interface MaskDatabase {
2255
+ * Structural projection of the runtime ORM writer. The wrapper relies only
2256
+ * on these fields, so it's interchangeable between `@lunora/do`'s
2257
+ * `DatabaseWriterLike` and `@lunora/d1`'s `DatabaseWriterLike`.
2258
+ */
2259
+ interface DatabaseWriterLike {
2260
+ /**
2261
+ * Reduce matching rows to a scalar. The RLS wrapper AND-merges the read
2262
+ * `baseWhere` into `options` so the reduction only sees policy-visible rows
2263
+ * (safe: an aggregate scoped to `where` never reveals a hidden row — see
2264
+ * `@lunora/do`'s `RestrictableQueryOptions`). Required: the only writer ever
2265
+ * wrapped is `@lunora/do`'s `createShardCtxDb`, which always implements it.
2266
+ */
756
2267
  aggregate: (tableName: string, options: AggregateArgs$1) => Promise<null | number>;
757
- count: (tableName: string, whereOrArgs?: unknown) => Promise<number>;
758
- delete: (id: string, expectedTable?: string) => Promise<void>;
2268
+ count: (tableName: string, whereOrArgs?: CountArgs | WhereInput) => Promise<number>;
2269
+ delete: (id: string, expectedTable?: string, options?: {
2270
+ hard?: boolean;
2271
+ }) => Promise<void>;
2272
+ /** Uncapped, chunked erase of a whole table. The RLS wrapper gates each row like a single delete. */
2273
+ deleteAll?: (tableName: string, options?: {
2274
+ chunkSize?: number;
2275
+ hard?: boolean;
2276
+ }) => Promise<{
2277
+ deleted: number;
2278
+ }>;
759
2279
  deleteMany: (ids: ReadonlyArray<string>, options?: {
760
2280
  limit?: number;
2281
+ }, expectedTable?: string) => Promise<{
2282
+ deleted: number;
2283
+ }>;
2284
+ deleteWhere?: (tableName: string, where: WhereInput, options?: {
2285
+ limit?: number;
761
2286
  }) => Promise<{
762
2287
  deleted: number;
763
2288
  }>;
@@ -765,6 +2290,11 @@ interface MaskDatabase {
765
2290
  findFirstOrThrow: (tableName: string, args?: QueryArgs$1) => Promise<Record<string, unknown>>;
766
2291
  findMany: (tableName: string, args?: QueryArgs$1) => Promise<QueryPage$1>;
767
2292
  get: (id: string, expectedTable?: string) => Promise<Record<string, unknown> | null>;
2293
+ /**
2294
+ * Group + reduce. Same `baseWhere` injection as `aggregate`: the per-group
2295
+ * reduction is scoped to policy-visible rows, so a group count tallies only
2296
+ * rows the caller may read. Required for the same reason as `aggregate`.
2297
+ */
768
2298
  groupBy: (tableName: string, options: GroupByArgs$1) => Promise<ReadonlyArray<{
769
2299
  key: Record<string, unknown>;
770
2300
  value: null | number;
@@ -772,7 +2302,20 @@ interface MaskDatabase {
772
2302
  insert: (tableName: string, document: Record<string, unknown>) => Promise<string>;
773
2303
  insertMany: (tableName: string, documents: ReadonlyArray<Record<string, unknown>>, options?: {
774
2304
  limit?: number;
2305
+ skipDuplicates?: boolean;
2306
+ }) => Promise<(string | null)[]>;
2307
+ insertManyUnsafe: (tableName: string, documents: ReadonlyArray<Record<string, unknown>>, options?: {
2308
+ allowExplicitId?: boolean;
2309
+ limit?: number;
775
2310
  }) => Promise<string[]>;
2311
+ /**
2312
+ * Optional table-aware lookup. The underlying writer (e.g. `@lunora/do`)
2313
+ * already knows the owning table of an id internally, so it can return
2314
+ * `{ row, tableName }` in a single round-trip. When present, the RLS wrapper
2315
+ * uses it to collapse the per-call membership-probe fan-out (1 `get` + N
2316
+ * `findFirst` across every policy table) down to one lookup. Writers that
2317
+ * don't implement it fall back to the probe path.
2318
+ */
776
2319
  lookupById?: (id: string, expectedTable?: string) => Promise<null | {
777
2320
  row: Record<string, unknown>;
778
2321
  tableName: string;
@@ -783,511 +2326,497 @@ interface MaskDatabase {
783
2326
  patch: Record<string, unknown>;
784
2327
  }>, options?: {
785
2328
  limit?: number;
786
- }) => Promise<void>;
2329
+ }, expectedTable?: string) => Promise<{
2330
+ patched: number;
2331
+ }>;
2332
+ patchWhere?: (tableName: string, args: {
2333
+ patch: Record<string, unknown>;
2334
+ where: WhereInput;
2335
+ }, options?: {
2336
+ limit?: number;
2337
+ }) => Promise<{
2338
+ patched: number;
2339
+ }>;
787
2340
  query: (tableName: string) => TableReaderLike$1;
788
- rank: (tableName: string, indexName: string, options: unknown) => Promise<null | {
2341
+ /**
2342
+ * Rank a row within its partition. A position is a count-of-rows-before, so
2343
+ * — exactly like `count()` — it can't be trusted in an RLS-restricted
2344
+ * reader: the wrapper fails it closed with `COUNT_RLS_UNSUPPORTED`. Required
2345
+ * for the same reason as `aggregate`.
2346
+ */
2347
+ rank: (tableName: string, indexName: string, options: RankArgs) => Promise<null | {
789
2348
  position: number;
790
2349
  total: number;
791
2350
  }>;
792
- rankBefore?: (tableName: string, indexName: string, options: unknown) => Promise<{
2351
+ /** Cross-shard rank primitive same count-of-before RLS hazard as `rank`; failed closed under a read policy. */
2352
+ rankBefore?: (tableName: string, indexName: string, options: RankBeforeArgs) => Promise<{
793
2353
  before: number;
794
2354
  total: number;
795
2355
  }>;
796
- rankPage: (tableName: string, indexName: string, options?: unknown) => Promise<QueryPage$1>;
2356
+ /**
2357
+ * Sorted pagination over a rank companion. The companion stores only the
2358
+ * partition + sort keys + id, so an arbitrary read `baseWhere` can't be
2359
+ * enforced against it (and re-filtering the fetched rows would break page
2360
+ * sizing). RLS therefore fails it closed rather than leak hidden rows.
2361
+ * Required for the same reason as `aggregate`.
2362
+ */
2363
+ rankPage: (tableName: string, indexName: string, options?: RankPageArgs) => Promise<QueryPage$1>;
2364
+ /**
2365
+ * Cross-shard companion to `rankPage`: same ranked slice, but each row
2366
+ * keeps its rank-key tuple for the query coordinator's k-way merge. Same
2367
+ * count-of-partition RLS hazard as `rankPage` — failed closed under a read
2368
+ * policy for the identical reason (see `rankPage` above).
2369
+ */
2370
+ rankPageRows?: (tableName: string, indexName: string, options?: RankPageArgs) => Promise<ShardRankPageResultLike>;
797
2371
  replace: (id: string, document: Record<string, unknown>, expectedTable?: string) => Promise<void>;
2372
+ restore?: (id: string, expectedTable?: string) => Promise<void>;
2373
+ /**
2374
+ * Whole-shard erase. The RLS wrapper deliberately **fails this closed** rather
2375
+ * than wrapping it — see the wrapper's `wipeShard`.
2376
+ */
2377
+ wipeShard?: (options?: {
2378
+ chunkSize?: number;
2379
+ exclude?: ReadonlyArray<string>;
2380
+ tables?: ReadonlyArray<string>;
2381
+ }) => Promise<{
2382
+ deleted: number;
2383
+ tables: Record<string, number>;
2384
+ }>;
798
2385
  }
799
- /** Roles list source on the context. Tolerant of older auth states (mirrors RLS's `AuthLike`). */
800
- type AuthLike$1 = {
2386
+ /**
2387
+ * What a procedure's `ctx.db` must structurally satisfy for the middleware
2388
+ * to wrap it. We deliberately mirror `@lunora/do`'s `DatabaseWriterLike`
2389
+ * rather than `@lunora/server`'s nominal `DatabaseWriter`/`DatabaseReader`:
2390
+ * the runtime adapter that flows in is the `DatabaseWriterLike`-shaped one,
2391
+ * and structural matching keeps this module free of an `@lunora/do`-typed
2392
+ * `ctx`.
2393
+ */
2394
+ type RlsDatabase = DatabaseWriterLike;
2395
+ /**
2396
+ * The slice of `ctx.auth` the policy middlewares read.
2397
+ *
2398
+ * `roles` has TWO sources and the effective list is their union, because the two
2399
+ * answer different questions and neither subsumes the other.
2400
+ *
2401
+ * The `roles` CLAIM on the resolved identity ({@link readIdentityRoles}) is what
2402
+ * the identity provider asserted about the caller. `ctx.auth.roles`, set by an
2403
+ * upstream middleware, is what a request-time mapping derived —
2404
+ * `@lunora/cloudflare-access`'s `accessRoles()` is the shipped example: the
2405
+ * Access envelope carries `groups`, never `roles`, and that middleware's entire
2406
+ * job is to map verified groups onto role labels here.
2407
+ *
2408
+ * Reading only the claim silently drops the second, which does not fail loudly —
2409
+ * a role-gated ALLOW branch stops firing and users lose access, and a role-gated
2410
+ * DENY branch stops firing and rows LEAK. `accessRoles` declares its own context
2411
+ * type, so there is no compile-time signal either way.
2412
+ *
2413
+ * What is deliberately absent is the same field on the TEST harness: see
2414
+ * `TestIdentity` in `./testing`. A middleware setting `ctx.auth.roles` is a real
2415
+ * request-path producer; a test setting it directly is a world with no producer
2416
+ * at all, which is how role-gated policies passed their tests while evaluating
2417
+ * against an empty list in production.
2418
+ */
2419
+ type AuthLike = {
801
2420
  getIdentity?: () => Promise<Record<string, unknown> | null>;
2421
+ /** Role labels contributed by an upstream middleware, unioned with the identity's `roles` claim. */
802
2422
  roles?: ReadonlyArray<string>;
803
2423
  userId?: null | string;
804
2424
  };
805
- interface MaskContextIn {
806
- auth?: AuthLike$1;
807
- db: MaskDatabase;
808
- }
809
- /**
810
- * Procedure-builder middleware. Apply per-request via `.use(mask(policies))`.
811
- * Closes over the policy map at builder-construction time; resolves identity +
812
- * the `bypass` decision per call against the live ctx.
813
- *
814
- * IMPORTANT: a mask is in scope only for procedures whose builder chain
815
- * includes this middleware — opt-in, never global (the same invariant as RLS).
816
- */
817
- declare const mask: <Context extends MaskContextIn = MaskContextIn>(policies: MaskPolicies<Context>, options?: MaskOptions<Context>) => Middleware<Context, Context>;
818
- /**
819
- * Online data-migration authoring API.
820
- *
821
- * `defineMigration` declares a per-document backfill over one table: `up`
822
- * transforms every existing row, `down` (optional) reverses it. Unlike the D1
823
- * SQL schema migrations in `@lunora/d1`, these run *inside each shard's*
824
- * Durable Object against live documents, in keyset batches, and are resumable —
825
- * the per-shard runner in `@lunora/do` tracks progress in a reserved
826
- * `__lunora_migrations` table so an interrupted run picks up where it stopped.
827
- *
828
- * The returned object carries a `__lunoraMigration` brand so codegen can
829
- * discover declarations through the type checker (mirroring the procedure
830
- * builder's `__lunoraProcedure` brand) and emit them into a `LUNORA_MIGRATIONS`
831
- * registry the DO and CLI look migrations up by id.
832
- */
833
- /** A document handed to a migration transform: the stored row including `_id`/`_creationTime`. */
834
- type MigrationDocument = Record<string, unknown>;
835
- /**
836
- * Transform applied to one document. Return a new document to rewrite the row,
837
- * or `undefined` to leave it untouched (skipped, not counted as changed). The
838
- * runner always preserves the original `_id` and `_creationTime`, so the
839
- * returned document neither needs to nor should change row identity.
840
- */
841
- type MigrationTransform = (document: MigrationDocument) => MigrationDocument | undefined | void;
842
- interface MigrationDefinition {
843
- /** Rows fetched and rewritten per batch. Defaults to the runner's batch size when omitted. */
844
- readonly batchSize?: number;
845
- /** Optional reverse transform, applied by `migrate down`. */
846
- readonly down?: MigrationTransform;
847
- /** Stable, unique identifier — the key per-shard run-state is tracked under. */
848
- readonly id: string;
849
- /** Table whose documents this migration iterates. */
850
- readonly table: string;
851
- /** Forward transform, applied to every row by `migrate up`. */
852
- readonly up: MigrationTransform;
853
- }
854
- /** A {@link MigrationDefinition} plus the codegen discovery marker. */
855
- interface RegisteredMigration extends MigrationDefinition {
856
- readonly __lunoraMigration: true;
2425
+ /** Minimal shape the middleware needs on the incoming ctx. */
2426
+ interface RlsContextIn {
2427
+ auth?: AuthLike;
2428
+ db: RlsDatabase;
857
2429
  }
858
- /** Declare an online data migration. See the module docs for runtime semantics. */
859
- declare const defineMigration: (definition: MigrationDefinition) => RegisteredMigration;
2430
+ declare const rls: <Context extends RlsContextIn = RlsContextIn>(policies: ReadonlyArray<Policy<Context>>, options?: RlsOptions) => Middleware<Context, Context>;
860
2431
  /**
861
- * The prefixed tables a single plugin `P` contributes, or an empty map when it
862
- * ships no schema extension. Mirrors {@link PrefixedTables} at the plugin level
863
- * so {@link InstalledTables} can fold a tuple of plugins.
864
- */
865
- type ExtensionTablesOf<P> = P extends {
866
- readonly extension: SchemaExtension<infer X> & {
867
- readonly key: infer K;
2432
+ * Context handed to a {@link MaskFn} (and to {@link MaskOptions.bypass}). The
2433
+ * `auth` shape mirrors RLS's `PolicyContext.auth` one-for-one same identity
2434
+ * resolver, same `can(...)` permission check — so an author can branch a mask
2435
+ * on the caller's role/permission. `row` is the full pre-mask row the column
2436
+ * belongs to; `column` is the column currently being masked. Both are absent
2437
+ * when the context is used for the procedure-wide `bypass` check (no specific
2438
+ * cell is in play yet).
2439
+ */
2440
+ interface MaskContext<Context = unknown> {
2441
+ readonly auth: {
2442
+ /** `true` when any of the request's `roles` grants `permission` (see {@link MaskOptions.roles}). Fails closed for unregistered roles. */
2443
+ readonly can: (permission: Permission | string) => boolean;
2444
+ readonly identity?: Record<string, unknown> | null;
2445
+ /** Role labels from the identity's `roles` claim (see `PolicyContext.auth.roles`). */
2446
+ readonly roles: ReadonlyArray<string>;
2447
+ readonly userId: null | string;
868
2448
  };
869
- } ? K extends string ? PrefixedTables<X, K> : Record<never, never> : Record<never, never>;
870
- /**
871
- * Fold a tuple of plugins onto a base table map `T`, accumulating each plugin's
872
- * auto-prefixed extension tables left-to-right the type-level mirror of
873
- * {@link installPlugins} applying `mergeSchemaExtension` for each plugin in turn.
874
- */
875
- type InstalledTables<T extends Record<string, TableDefinition>, Plugins extends ReadonlyArray<unknown>> = Plugins extends readonly [infer Head, ...infer Rest] ? InstalledTables<ExtensionTablesOf<Head> & T, Rest> : T;
876
- /**
877
- * Union every plugin's `ContextOut` in a tuple — the type-level mirror of the
878
- * `ctx.api.&lt;key>` additions {@link composePluginMiddleware} accumulates as each
879
- * plugin middleware runs. Independent of the incoming context, which the builder
880
- * infers at the `.use(...)` site.
881
- */
882
- type ComposedOut<Plugins extends ReadonlyArray<unknown>> = Plugins extends readonly [infer Head, ...infer Rest] ? ComposedOut<Rest> & (Head extends Plugin<any, any, infer Out> ? Out : unknown) : unknown;
883
- /**
884
- * Schema fragment a plugin contributes. Same shape as the `tables` map
885
- * passed to `defineSchema`. Optional `vectorIndexes` mirror the top-level
886
- * `defineSchema` argument so a plugin can ship vector decls alongside its
887
- * tables.
888
- */
889
- interface SchemaExtension<T extends Record<string, TableDefinition> = Record<string, TableDefinition>> {
890
- /** Stable key identifying the plugin that owns this extension. */
891
- readonly key: string;
892
- /**
893
- * Extension tables, keyed by **bare** name (e.g. `buckets`). At merge time
894
- * each is auto-prefixed with `key` (`ratelimit_buckets`) so it can't
895
- * collide with an app table; do **not** namespace manually.
896
- */
897
- readonly tables: T;
898
- /**
899
- * Optional standalone vector indexes the plugin ships, keyed by index
900
- * name. Merged into the host schema's `vectorIndexes`; a key collision
901
- * with the base schema is a hard error (same policy as tables).
902
- */
903
- readonly vectorIndexes?: Record<string, VectorIndexDefinition>;
904
- }
905
- /**
906
- * Build a {@link SchemaExtension}. The `key` is a runtime tag (used for
907
- * error messages on collision) and a type-level brand.
908
- */
909
- declare const defineSchemaExtension: <T extends Record<string, TableDefinition>>(key: string, options: {
910
- tables: T;
911
- vectorIndexes?: Record<string, VectorIndexDefinition>;
912
- }) => SchemaExtension<T>;
913
- /**
914
- * A plugin packages an optional schema extension and optional middleware.
915
- * Both are independently usable: an app can install only the schema (e.g.
916
- * for plugins that ship background workers but no per-request behavior)
917
- * or only the middleware (plugins that augment ctx without persistent
918
- * state).
919
- */
920
- interface Plugin<TExtension extends Record<string, TableDefinition> = Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn> {
921
- /**
922
- * Optional schema extension. Apps install via
923
- * `defineSchema(...).extend(plugin.extension)`.
924
- */
925
- readonly extension?: SchemaExtension<TExtension>;
926
- /** Stable key identifying the plugin. Matches `extension.key` when set. */
927
- readonly key: string;
928
- /**
929
- * Optional middleware. Users attach with `c.query.use(plugin.middleware)`.
930
- * The middleware can extend `ctx`; convention is to attach helpers under
931
- * `ctx.api.&lt;key>`, e.g.
932
- *
933
- * ```ts
934
- * middleware: ({ ctx, next }) =>
935
- * next({ ctx: { api: { ...ctx.api, ratelimit: api } } })
936
- * ```
937
- */
938
- readonly middleware?: Middleware<TContextIn, TContextOut>;
939
- }
940
- /** Options to {@link definePlugin}. */
941
- interface DefinePluginOptions<TExtension extends Record<string, TableDefinition>, TContextIn, TContextOut> {
942
- extension?: SchemaExtension<TExtension>;
943
- middleware?: Middleware<TContextIn, TContextOut>;
944
- }
945
- /**
946
- * Package a schema extension + middleware as a reusable plugin. Either
947
- * field is optional — `definePlugin("foo", {})` is valid but degenerate.
948
- */
949
- declare const definePlugin: <TExtension extends Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn>(key: string, options: DefinePluginOptions<TExtension, TContextIn, TContextOut>) => Plugin<TExtension, TContextIn, TContextOut>;
950
- /**
951
- * Bundle of registered functions a {@link Component} ships. Keys are the
952
- * function's local name (e.g. `check`, `reset`); the registered function
953
- * value carries its own kind / args / handler.
954
- *
955
- * Users re-export from their own lunora module so codegen picks them up:
956
- *
957
- * ```ts
958
- * // lunora/ratelimit.ts
959
- * import { ratelimit } from "@vendor/ratelimit-component";
960
- * export const { check, reset } = ratelimit.functions;
961
- * // Emits as `ratelimit:check` / `ratelimit:reset` in the generated `api`.
962
- * ```
963
- *
964
- * Codegen follows the re-export back to the bundled `query/mutation/action`
965
- * call (property access or destructuring both work), so the functions land in
966
- * the generated `api` under the re-exporting file's namespace.
967
- */
968
- type ComponentFunctions = Readonly<Record<string, RegisteredFunction<any, any, FunctionKind>>>;
969
- /**
970
- * Component = {@link Plugin} with a bundle of registered functions. The
971
- * extension + middleware + functions are independent: a component can ship
972
- * functions without a schema (e.g. a stateless utility), or a schema
973
- * without functions (e.g. shared table definitions), and any combination.
974
- */
975
- interface Component<TExtension extends Record<string, TableDefinition> = Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn, F extends ComponentFunctions = ComponentFunctions> extends Plugin<TExtension, TContextIn, TContextOut> {
976
- readonly functions: F;
977
- }
978
- interface DefineComponentOptions<TExtension extends Record<string, TableDefinition>, TContextIn, TContextOut, F extends ComponentFunctions> extends DefinePluginOptions<TExtension, TContextIn, TContextOut> {
979
- /** Registered functions the component ships. Keys are the function's local name. */
980
- functions?: F;
2449
+ /** The column currently being masked. Present only inside a per-cell {@link MaskFn}. */
2450
+ readonly column?: string;
2451
+ readonly ctx: Context;
2452
+ /** The full pre-mask row the masked cell belongs to. Present only inside a per-cell {@link MaskFn}. */
2453
+ readonly row?: Record<string, unknown>;
981
2454
  }
982
2455
  /**
983
- * Convenience wrapper around {@link definePlugin} that also bundles a set
984
- * of registered functions. The resulting `component.functions` object is a
985
- * record of `name registered query/mutation/action`; consumers
986
- * re-export entries so codegen discovers them as user functions:
987
- *
988
- * ```ts
989
- * export const ratelimit = defineComponent("ratelimit", {
990
- * // Bare `buckets` merges in as `ratelimit_buckets`.
991
- * extension: defineSchemaExtension("ratelimit", { tables: { buckets } }),
992
- * middleware: ({ ctx, next }) => next({ ctx: { ...ctx, ratelimit: api(ctx) } }),
993
- * functions: {
994
- * check: query.input({ key: v.string() }).query(async ({ ctx, args }) => ...),
995
- * reset: mutation.input({ key: v.string() }).mutation(async ({ ctx, args }) => ...),
996
- * },
997
- * });
998
- * ```
999
- *
1000
- * Re-exporting an entry (by property access or destructuring) is enough for
1001
- * codegen to discover it in the host app's namespace — the discovery resolver
1002
- * chases the re-export back to the bundled registration call.
1003
- */
1004
- declare const defineComponent: <TExtension extends Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn, F extends ComponentFunctions = ComponentFunctions>(key: string, options: DefineComponentOptions<TExtension, TContextIn, TContextOut, F>) => Component<TExtension, TContextIn, TContextOut, F>;
2456
+ * A custom masking function. Receives the raw cell value and the
2457
+ * {@link MaskContext}, returns the value to surface. Use it for partial masks
2458
+ * (`maskMiddle(phone)`), role-aware reveals (`ctx.auth.can(...) ? value : null`),
2459
+ * or format-preserving tokens. A function that **throws** fails closed — the
2460
+ * cell is redacted to `null`, never leaked raw.
2461
+ */
2462
+ type MaskFn<Context = unknown> = (value: unknown, context: MaskContext<Context>) => unknown;
1005
2463
  /**
1006
- * Map every key `K` of an extension's table map `X` to its auto-prefixed name
1007
- * `${Key}_${K}`. Mirrors the runtime prefixing in {@link mergeSchemaExtension}
1008
- * so the typed `.extend(...)` chain reflects the real merged table names.
1009
- */
1010
- type PrefixedTables<X extends Record<string, TableDefinition>, Key extends string> = { [K in keyof X as K extends string ? `${Key}_${K}` : K]: X[K] };
1011
- /**
1012
- * Merge a {@link SchemaExtension} into an existing schema. Returns a new
1013
- * schema object never mutates the input.
1014
- *
1015
- * Extension tables are auto-namespaced: each bare table name is prefixed with
1016
- * the extension `key` (`buckets` `ratelimit_buckets`), Convex-Components
1017
- * style, and every intra-extension reference (relation targets, aggregate /
1018
- * rank index `on`, standalone vector index `table`) is rewritten to match.
1019
- * References to base/app tables are left untouched.
1020
- *
1021
- * Because each extension lives in its own `key` namespace, app↔component
1022
- * collisions are impossible. The only remaining hard error is two extensions
1023
- * sharing the same `key` and producing the same prefixed table (or vector
1024
- * index) name — silent shadow would let one plugin hijack another's data.
1025
- */
1026
- declare const mergeSchemaExtension: <T extends Record<string, TableDefinition>, X extends Record<string, TableDefinition>, Key extends string = string>(base: Schema<T>, extension: SchemaExtension<X> & {
1027
- readonly key: Key;
1028
- }) => Schema<PrefixedTables<X, Key> & T>;
2464
+ * How a column is masked:
2465
+ *
2466
+ * - `"redact"` — drop the value to `null`. The simplest, safest strategy, and
2467
+ * the right choice for any value that must actually be kept secret.
2468
+ * - `"hash"` replace with a stable token (unsalted 32-bit FNV-1a hex) so the
2469
+ * same input always yields the same token (joinable/groupable client-side).
2470
+ * **This is NOT a confidentiality control.** It is a non-cryptographic,
2471
+ * unsalted, deterministic, narrow (~2^32) digest: low-entropy values (emails,
2472
+ * phone numbers, SSNs) are brute-force-recoverable by the very caller you are
2473
+ * masking from, and identical values always produce identical tokens across
2474
+ * rows/columns/tenants (enabling correlation). Use `"hash"` ONLY when you want a
2475
+ * stable pseudonym for grouping/joining and leaking the value is acceptable
2476
+ * never to hide sensitive PII. For PII that must stay hidden, use `"redact"`.
2477
+ * - a {@link MaskFn} author-defined transform (partial mask, role-aware reveal).
2478
+ */
2479
+ type MaskStrategy<Context = unknown> = "hash" | "redact" | MaskFn<Context>;
2480
+ /** Per-column strategy map for one table: `{ email: "redact", phone: maskMiddle }`. */
2481
+ type MaskColumns<Context = unknown> = Record<string, MaskStrategy<Context>>;
1029
2482
  /**
1030
- * Install several plugins' schema extensions in one call the one-shot
1031
- * counterpart to chaining `defineSchema(...).extend(a).extend(b)`. Plugins
1032
- * without an `extension` (middleware-only) are skipped; tables from those that
1033
- * do are auto-prefixed and reference-rewritten exactly as
1034
- * {@link mergeSchemaExtension} does for a single `.extend(...)`.
1035
- *
1036
- * ```ts
1037
- * const schema = installPlugins(defineSchema({ todos }), [ratelimit, audit]);
1038
- * // → todos + ratelimit_* + audit_*
1039
- * ```
1040
- *
1041
- * Pair it with {@link composePluginMiddleware} to attach every plugin's
1042
- * middleware in a single `.use(...)`, so installing N plugins is two calls
1043
- * rather than N `.extend(...)` + N `.use(...)`.
1044
- */
1045
- declare const installPlugins: <T extends Record<string, TableDefinition>, const Plugins extends ReadonlyArray<Plugin<any, any, any>>>(base: Schema<T>, plugins: Plugins) => Schema<InstalledTables<T, Plugins>>;
2483
+ * The mask declaration passed to `mask(...)`: a table column → strategy map.
2484
+ * Deliberately a plain object literal so the codegen feeder can statically read
2485
+ * which columns a procedure masks (powering the `mask_uncovered_pii_column`
2486
+ * advisor lint), exactly as the RLS feeder reads policy tables.
2487
+ */
2488
+ type MaskPolicies<Context = unknown> = Record<string, MaskColumns<Context>>;
1046
2489
  /**
1047
- * Compose every plugin's middleware into a single middleware you attach with one
1048
- * `.use(...)`. Plugins without middleware (schema-only) are skipped; the rest run
1049
- * in array order, each seeing the context the previous one widened, so the final
1050
- * `next({ ctx })` the builder receives carries every plugin's `ctx.api.&lt;key>`
1051
- * additions. Equivalent to `.use(a.middleware).use(b.middleware)…` but as one
1052
- * value, the middleware sibling of {@link installPlugins}.
1053
- *
1054
- * `ContextIn` is left free so the builder infers it from the context at the
1055
- * `.use(...)` site; the result type widens it by the union of the plugins'
1056
- * outputs.
1057
- */
1058
- declare const composePluginMiddleware: <ContextIn = unknown, const Plugins extends ReadonlyArray<Plugin<any, any, any>> = ReadonlyArray<Plugin<any, any, any>>>(plugins: Plugins) => Middleware<ContextIn, ComposedOut<Plugins> & ContextIn>;
1059
- /** Options for `.vectorize(field, opts)` (DSL Shape A). */
1060
- interface VectorizeOptions<Shape extends Record<string, Validator> = Record<string, Validator>> {
1061
- dimensions: number;
1062
- embed: VectorEmbedder;
1063
- /** Logical index name; must match a `[[vectorize]]` binding in wrangler. */
1064
- index: string;
1065
- /** Fields mirrored into Vectorize metadata for filtering. */
1066
- metadata?: ReadonlyArray<keyof Shape & string>;
1067
- metric: VectorMetric;
2490
+ * Options for `mask(policies, options)`.
2491
+ *
2492
+ * - `roles` registers the role→permission grants that back `ctx.auth.can(...)`
2493
+ * inside a {@link MaskFn} identical to `rls(policies, { roles })`. A role
2494
+ * not listed grants no permissions (fails closed for unknown roles).
2495
+ * - `bypass` is a procedure-wide escape hatch: when it returns `true` the whole
2496
+ * mask is skipped (the caller sees raw values). Use it for a privileged
2497
+ * viewer `bypass: ({ auth }) => auth.can("pii:view")`. Prefer this over
2498
+ * branching every column when an entire class of caller should see clear data.
2499
+ * - `indexFields` closes the bare-index-scan / rank / geo position oracle (see
2500
+ * the `mask/middleware` module docblock's "Residual read-position oracles" section).
2501
+ */
2502
+ interface MaskOptions<Context = unknown> {
2503
+ readonly bypass?: (context: MaskContext<Context>) => boolean;
2504
+ /**
2505
+ * Per-table, per-KIND index→declared-fields map (regular index `fields`
2506
+ * under `index`; rank index `sortBy` `partitionBy` under `rank`; geo
2507
+ * index `field` under `geo`). Supplied to close the bare-index-scan /
2508
+ * rank / geo position oracle: a `withIndex(name)` with no range callback,
2509
+ * a `rank`/`rankPage`/`rankBefore` read, or a `withGeoIndex` read, over an
2510
+ * index whose DECLARED fields (for that read's own kind) intersect a
2511
+ * masked column, is rejected. Kept per kind — rather than one flat
2512
+ * name→fields map — because the engine resolves `withIndex` /
2513
+ * `withGeoIndex` / rank reads in three separate namespaces, so the same
2514
+ * index name can legally denote a different index per kind; a flat map
2515
+ * would let one kind's fields shadow another's for a colliding name.
2516
+ * OPTIONAL and additive — omit it and behaviour is unchanged (the oracle
2517
+ * stays open, exactly as before this option existed). Build it with
2518
+ * `indexFieldsFromSchema` (exported from `@lunora/server`):
2519
+ * `mask(policies, { indexFields: indexFieldsFromSchema(schema) })`.
2520
+ */
2521
+ readonly indexFields?: IndexFieldsByTable;
2522
+ readonly roles?: ReadonlyArray<Role>;
1068
2523
  }
1069
- /** A `one` (many-to-one) relation descriptor; phantom `Target` carries the target table name. */
1070
- interface OneRelation<Target extends string = string> extends RelationDefinition {
1071
- readonly __target?: Target;
1072
- readonly kind: "one";
2524
+ interface QueryPage {
2525
+ continueCursor: null | string;
2526
+ isDone: boolean;
2527
+ page: Record<string, unknown>[];
1073
2528
  }
1074
- /** A `many` (one-to-many) relation descriptor; phantom `Target` carries the target table name. */
1075
- interface ManyRelation<Target extends string = string> extends RelationDefinition {
1076
- readonly __target?: Target;
1077
- readonly kind: "many";
2529
+ interface QueryArgs {
2530
+ baseWhere?: unknown;
2531
+ cursor?: null | string;
2532
+ limit?: number;
2533
+ orderBy?: ReadonlyArray<Record<string, unknown>>;
2534
+ /** Per-target-table mask applied to `with`-hydrated children — mirrors `@lunora/shard-engine`'s `RelationMask`. */
2535
+ relationMask?: (table: string, rows: Record<string, unknown>[]) => Record<string, unknown>[];
2536
+ where?: unknown;
2537
+ with?: Record<string, unknown>;
1078
2538
  }
1079
- /** The `r` argument passed to `.relations((r) => …)`. */
1080
- interface RelationBuilder {
1081
- /** One-to-many: the FK `field` lives on the target table, matching this table's `references` (default `_id`). */
1082
- many: <Target extends string>(table: Target, options: {
1083
- field: string;
1084
- references?: string;
1085
- }) => ManyRelation<Target>;
1086
- /** Many-to-one: the FK `field` lives on this table, pointing at `table`.`references` (default `_id`). */
1087
- one: <Target extends string>(table: Target, options: {
1088
- field: string;
1089
- onDelete?: OnDeleteAction;
1090
- references?: string;
1091
- }) => OneRelation<Target>;
2539
+ interface AggregateArgs {
2540
+ field?: string;
2541
+ op: string;
2542
+ where?: unknown;
1092
2543
  }
1093
- /**
1094
- * Options for the inline `.aggregateIndex(name, opts)` builder. `op` defaults to
1095
- * `count` so `aggregateIndex("byUser", { by: ["userId"] })` is a single-line
1096
- * `COUNT(*) GROUP BY userId` accelerator.
1097
- */
1098
- interface InlineAggregateIndexOptions<Shape extends Record<string, Validator> = Record<string, Validator>> {
1099
- /** Group keys; counter rows are one per distinct tuple. Omitted = single-row aggregate over the whole table. */
1100
- by?: ReadonlyArray<keyof Shape & string>;
1101
- /** The column the reducer applies to. Required for `sum`/`min`/`max`/`avg`; ignored for `count`. */
1102
- field?: keyof Shape & string;
1103
- /** Reducer (default `count`). */
1104
- op?: AggregateOp;
1105
- /** Static predicate baked into the counter — only matching rows are aggregated. */
1106
- where?: Record<string, unknown>;
2544
+ interface GroupByArgs {
2545
+ agg?: {
2546
+ field?: string;
2547
+ op: string;
2548
+ };
2549
+ by: ReadonlyArray<string>;
2550
+ where?: unknown;
1107
2551
  }
1108
- /**
1109
- * Options for the inline `.rankIndex(name, opts)` builder. `sortBy` is required;
1110
- * accepts either an array of `{ field, direction }` keys, or the shorthand
1111
- * `["field"]` (asc) / `{ field: "desc" }` map entries. `partitionBy` scopes the
1112
- * rank omitted ⇒ one global rank over the whole table.
1113
- */
1114
- interface InlineRankIndexOptions<Shape extends Record<string, Validator> = Record<string, Validator>> {
1115
- /** Columns that scope each ranking; omitted ⇒ one global rank. */
1116
- partitionBy?: ReadonlyArray<keyof Shape & string>;
1117
- /** Ordered sort keys driving the rank. Required. */
1118
- sortBy: ReadonlyArray<{
1119
- direction?: "asc" | "desc";
1120
- field: keyof Shape & string;
1121
- }>;
1122
- /** Static predicate baked into the index; only matching rows enter. */
1123
- where?: Record<string, unknown>;
2552
+ /** One row of a `.collectWithScores()` result — mirrors `@lunora/shard-engine`'s `ScoredDocument`. */
2553
+ type ScoredDocument = GeoScoredDocument | SearchScoredDocument;
2554
+ /** A `.withGeoIndex()` row mirrors `@lunora/shard-engine`'s `GeoScoredDocument`. */
2555
+ interface GeoScoredDocument {
2556
+ distanceMeters: null | number;
2557
+ document: Record<string, unknown>;
2558
+ score?: never;
1124
2559
  }
1125
- interface TableBuilder<Shape extends Record<string, Validator> = Record<string, Validator>> extends TableDefinition<Shape> {
1126
- /** Declare an aggregate (counter/sum/…) maintained by triggers for O(1) reads. */
1127
- aggregateIndex: (name: string, options?: InlineAggregateIndexOptions<Shape>) => TableBuilder<Shape>;
1128
- /**
1129
- * Mark this table as written outside Lunora's discoverable insert path —
1130
- * by an adapter, a migration, or framework middleware (e.g. `@lunora/auth`'s
1131
- * better-auth tables, `@lunora/ratelimit`'s store). Advisor insert-path lints
1132
- * (`table_without_insert`) then skip it instead of flagging the absent
1133
- * `ctx.db.insert(...)`.
1134
- */
1135
- externallyManaged: () => TableBuilder<Shape>;
1136
- /**
1137
- * Mark this table as global (cross-shard). Backed by **D1** by default;
1138
- * pass `{ backend: "hyperdrive" }` to store it in a Postgres/MySQL database
1139
- * via Cloudflare Hyperdrive (PlanetScale, Neon, …) instead. Either way the
1140
- * table stays reactive — live queries re-run on write.
1141
- */
1142
- global: (options?: {
1143
- backend?: GlobalBackend;
1144
- }) => TableBuilder<Shape>;
1145
- /** Add a secondary index. */
1146
- index: (name: string, fields: ReadonlyArray<string>, options?: {
1147
- unique?: boolean;
1148
- }) => TableBuilder<Shape>;
1149
- /**
1150
- * Opt this table OUT of secure-by-default RLS. Under a schema marked
1151
- * `.rls("required")`, every table is protected (the write path denies raw,
1152
- * non-RLS `ctx.db` access); calling `.public()` exempts this one table so a
1153
- * plain `query`/`mutation` may read/write it without an RLS policy. No effect
1154
- * when the schema does not require RLS.
1155
- */
1156
- public: () => TableBuilder<Shape>;
1157
- /**
1158
- * Declare a rank index (sorted companion table, btree-backed) for
1159
- * `rank(row)` / `rankPage()` reads in O(log n). See {@link RankIndexDefinition}.
1160
- */
1161
- rankIndex: (name: string, options: InlineRankIndexOptions<Shape>) => TableBuilder<Shape>;
1162
- /** Declare relations to other tables, loaded via `findMany({ with })`. */
1163
- relations: (build: (r: RelationBuilder) => Record<string, RelationDefinition>) => TableBuilder<Shape>;
1164
- /** Add a search index over a field with optional filter fields. */
1165
- searchIndex: (name: string, options: {
1166
- field: string;
1167
- filterFields?: ReadonlyArray<string>;
1168
- }) => TableBuilder<Shape>;
1169
- /** Route storage by the named field — one DO per distinct value. */
1170
- shardBy: (field: keyof Shape & string) => TableBuilder<Shape>;
1171
- /** Declare named lifecycle triggers fired inline within the write path. */
1172
- triggers: (build: (t: TriggerBuilder<Shape>) => Record<string, TriggerDefinition>) => TableBuilder<Shape>;
1173
- /** Declare a vector index over a single text field on this table. */
1174
- vectorize: (field: keyof Shape & string, options: VectorizeOptions<Shape>) => TableBuilder<Shape>;
2560
+ /** A `.withSearchIndex()` row mirrors `@lunora/shard-engine`'s `SearchScoredDocument`. */
2561
+ interface SearchScoredDocument {
2562
+ distanceMeters?: never;
2563
+ document: Record<string, unknown>;
2564
+ score: number;
1175
2565
  }
1176
- /** Options for `defineVectorIndex(...)` (DSL Shape B). */
1177
- interface VectorIndexOptions {
1178
- dimensions: number;
1179
- embed: VectorEmbedder;
1180
- /** Optional projection of the source row into Vectorize metadata. */
1181
- metadata?: (row: Record<string, unknown>) => Record<string, unknown>;
1182
- metric: VectorMetric;
1183
- /** The vector source: which table, and how to derive the embedded text. */
1184
- source: {
1185
- select: (row: Record<string, unknown>) => string;
1186
- table: string;
1187
- };
2566
+ interface TableReaderLike {
2567
+ [Symbol.asyncIterator]: () => AsyncIterator<Record<string, unknown>>;
2568
+ collect: () => Promise<Record<string, unknown>[]>;
2569
+ collectWithScores: () => Promise<ScoredDocument[]>;
2570
+ filter: (predicate: (document: Record<string, unknown>) => boolean) => TableReaderLike;
2571
+ first: () => Promise<Record<string, unknown> | null>;
2572
+ order: (direction: "asc" | "desc") => TableReaderLike;
2573
+ paginate: (options: {
2574
+ cursor?: null | string;
2575
+ numItems: number;
2576
+ }) => Promise<QueryPage>;
2577
+ take: (limit: number) => Promise<Record<string, unknown>[]>;
2578
+ unique: () => Promise<Record<string, unknown> | null>;
2579
+ withGeoIndex: (indexName: string, build: (q: unknown) => unknown) => TableReaderLike;
2580
+ withIndex: (indexName: string, range?: (q: unknown) => unknown) => TableReaderLike;
2581
+ withSearchIndex: (indexName: string, search: (q: unknown) => unknown) => TableReaderLike;
1188
2582
  }
1189
2583
  /**
1190
- * Build a table definition. Returned object is both the table definition (for
1191
- * `defineSchema`) and a fluent builder for indexes + sharding metadata.
1192
- */
1193
- declare const defineTable: <Shape extends Record<string, Validator>>(shape: Shape) => TableBuilder<Shape>;
1194
- /**
1195
- * Declare a standalone vector index (DSL Shape B). Pass the returned value in
1196
- * the `vectorIndexes` map of {@link defineSchema} when the source is derived
1197
- * from multiple fields or a computation rather than a single column.
1198
- */
1199
- declare const defineVectorIndex: (options: VectorIndexOptions) => VectorIndexDefinition;
1200
- /**
1201
- * Options for the standalone `defineAggregateIndex(name, opts)` helper (DSL
1202
- * Shape B). Unlike the inline `.aggregateIndex(...)` builder, this form takes
1203
- * the owning table explicitly via `on` — handy when a single counter wants to
1204
- * live next to the schema map rather than inside a table chain.
1205
- */
1206
- interface AggregateIndexOptions {
1207
- by?: ReadonlyArray<string>;
1208
- field?: string;
1209
- on: string;
1210
- op?: AggregateOp;
1211
- where?: Record<string, unknown>;
2584
+ * Structural projection of the runtime ORM writer the same subset
2585
+ * `../rls/middleware` mirrors, so the wrapper is interchangeable between
2586
+ * `@lunora/do`'s and `@lunora/d1`'s `DatabaseWriterLike` without an
2587
+ * inter-package dependency. `rankBefore` is optional (the D1 twin omits it).
2588
+ */
2589
+ interface MaskDatabase {
2590
+ aggregate: (tableName: string, options: AggregateArgs) => Promise<null | number>;
2591
+ count: (tableName: string, whereOrArgs?: unknown) => Promise<number>;
2592
+ delete: (id: string, expectedTable?: string) => Promise<void>;
2593
+ deleteMany: (ids: ReadonlyArray<string>, options?: {
2594
+ limit?: number;
2595
+ }) => Promise<{
2596
+ deleted: number;
2597
+ }>;
2598
+ deleteWhere?: (tableName: string, where: Record<string, unknown>, options?: {
2599
+ limit?: number;
2600
+ }) => Promise<{
2601
+ deleted: number;
2602
+ }>;
2603
+ findFirst: (tableName: string, args?: QueryArgs) => Promise<Record<string, unknown> | null>;
2604
+ findFirstOrThrow: (tableName: string, args?: QueryArgs) => Promise<Record<string, unknown>>;
2605
+ findMany: (tableName: string, args?: QueryArgs) => Promise<QueryPage>;
2606
+ get: (id: string, expectedTable?: string) => Promise<Record<string, unknown> | null>;
2607
+ groupBy: (tableName: string, options: GroupByArgs) => Promise<ReadonlyArray<{
2608
+ key: Record<string, unknown>;
2609
+ value: null | number;
2610
+ }>>;
2611
+ insert: (tableName: string, document: Record<string, unknown>) => Promise<string>;
2612
+ insertMany: (tableName: string, documents: ReadonlyArray<Record<string, unknown>>, options?: {
2613
+ limit?: number;
2614
+ skipDuplicates?: boolean;
2615
+ }) => Promise<(string | null)[]>;
2616
+ lookupById?: (id: string, expectedTable?: string) => Promise<null | {
2617
+ row: Record<string, unknown>;
2618
+ tableName: string;
2619
+ }>;
2620
+ patch: (id: string, patch: Record<string, unknown>, expectedTable?: string) => Promise<void>;
2621
+ patchMany: (patches: ReadonlyArray<{
2622
+ id: string;
2623
+ patch: Record<string, unknown>;
2624
+ }>, options?: {
2625
+ limit?: number;
2626
+ }) => Promise<{
2627
+ patched: number;
2628
+ }>;
2629
+ patchWhere?: (tableName: string, args: {
2630
+ patch: Record<string, unknown>;
2631
+ where: Record<string, unknown>;
2632
+ }, options?: {
2633
+ limit?: number;
2634
+ }) => Promise<{
2635
+ patched: number;
2636
+ }>;
2637
+ query: (tableName: string) => TableReaderLike;
2638
+ rank: (tableName: string, indexName: string, options: unknown) => Promise<null | {
2639
+ position: number;
2640
+ total: number;
2641
+ }>;
2642
+ rankBefore?: (tableName: string, indexName: string, options: unknown) => Promise<{
2643
+ before: number;
2644
+ total: number;
2645
+ }>;
2646
+ rankPage: (tableName: string, indexName: string, options?: unknown) => Promise<QueryPage>;
2647
+ /** Cross-shard companion to `rankPage`, gated the same way `rankPage` is masked below. */
2648
+ rankPageRows?: (tableName: string, indexName: string, options?: unknown) => Promise<ShardRankPageResultLike>;
2649
+ replace: (id: string, document: Record<string, unknown>, expectedTable?: string) => Promise<void>;
2650
+ }
2651
+ interface MaskContextIn {
2652
+ auth?: AuthLike;
2653
+ db: MaskDatabase;
1212
2654
  }
1213
2655
  /**
1214
- * Declare a standalone aggregate index. Pass the returned value to
1215
- * `defineSchema(tables, vectorIndexes, aggregateIndexes)` keyed by index name
1216
- * the schema attaches it to `tables[on].aggregateIndexes` so runtime consumers
1217
- * (DO + D1) read every index uniformly off the table definition.
1218
- */
1219
- declare const defineAggregateIndex: (name: string, options: AggregateIndexOptions) => AggregateIndexDefinition;
2656
+ * Procedure-builder middleware. Apply per-request via `.use(mask(policies))`.
2657
+ * Closes over the policy map at builder-construction time; resolves identity +
2658
+ * the `bypass` decision per call against the live ctx.
2659
+ *
2660
+ * IMPORTANT: a mask is in scope only for procedures whose builder chain
2661
+ * includes this middleware — opt-in, never global (the same invariant as RLS).
2662
+ */
2663
+ declare const mask: <Context extends MaskContextIn = MaskContextIn>(policies: MaskPolicies<Context>, options?: MaskOptions<Context>) => Middleware<Context, Context>;
2664
+ /** Project-wide masked-column registry: every table any registered function masks, unioned. */
2665
+ type MaskRegistry = ReadonlyMap<string, ReadonlySet<string>>;
2666
+ /**
2667
+ * Build the project-wide masked-column registry from the registered functions
2668
+ * (pass `Object.values(LUNORA_FUNCTIONS)`) — the mask-column twin of
2669
+ * `buildRlsReadRegistry`. Unions every function's `.use(mask(...))` columns
2670
+ * per table: a column masked by ANY registered function counts as masked in
2671
+ * the registry — there is no "which procedure would this shape have gone
2672
+ * through" question to narrow by, so the union is the only safe answer.
2673
+ */
2674
+ declare const buildMaskRegistry: (functions: Iterable<unknown>) => MaskRegistry;
2675
+ /** A document handed to a migration transform: the stored row including `_id`/`_creationTime`. */
2676
+ type MigrationDocument = Record<string, unknown>;
1220
2677
  /**
1221
- * Options for the standalone `defineRankIndex(name, opts)` helper (DSL Shape B).
1222
- * Mirrors the inline `.rankIndex(...)` builder but takes the owning table via
1223
- * `table` so it can sit next to the schema map.
1224
- */
1225
- interface RankIndexOptions {
1226
- partitionBy?: ReadonlyArray<string>;
1227
- sortBy: ReadonlyArray<{
1228
- direction?: "asc" | "desc";
1229
- field: string;
2678
+ * The read surface a transform reaches through its `ctx`.
2679
+ *
2680
+ * Read-only by design: the runner accounts for exactly one rewrite per row read,
2681
+ * and a transform writing directly would make that count describe something
2682
+ * other than what happened. Scoped to the shard the runner is walking.
2683
+ */
2684
+ interface MigrationReader {
2685
+ count: (table: string, where?: Record<string, unknown>) => Promise<number>;
2686
+ findFirst: (table: string, args?: Record<string, unknown>) => Promise<MigrationDocument | null>;
2687
+ findMany: (table: string, args?: Record<string, unknown>) => Promise<{
2688
+ isDone: boolean;
2689
+ page: MigrationDocument[];
1230
2690
  }>;
1231
- table: string;
1232
- where?: Record<string, unknown>;
2691
+ get: (id: string, expectedTable?: string) => Promise<MigrationDocument | null>;
2692
+ }
2693
+ /** The context handed to a transform alongside the row. */
2694
+ interface MigrationCtx {
2695
+ db: MigrationReader;
2696
+ }
2697
+ /**
2698
+ * Transform applied to one document. Return a new document to rewrite the row,
2699
+ * or `undefined` to leave it untouched (skipped, not counted as changed). The
2700
+ * runner always preserves the original `_id` and `_creationTime`, so the
2701
+ * returned document neither needs to nor should change row identity.
2702
+ *
2703
+ * The second parameter carries a shard-scoped reader. Without it a transform
2704
+ * could only rewrite the row it was handed — enough for a backfill whose new
2705
+ * value is a pure function of the old row (`displayName = name ?? "Anonymous"`),
2706
+ * but not for the shape people actually write: read the parent, copy a field
2707
+ * down onto its children.
2708
+ *
2709
+ * May return a promise, since a cross-table read is asynchronous.
2710
+ *
2711
+ * **A shard key cannot be backfilled this way, even with a reader.** A row whose
2712
+ * shard-key field is unset does not belong to any shard, so a shard-scoped query
2713
+ * will not enumerate it; and writing the key would have to MOVE the row to a
2714
+ * different Durable Object, which a per-shard runner cannot do. Re-keying is an
2715
+ * export → transform → import, not a migration.
2716
+ */
2717
+ type MigrationTransform = (document: MigrationDocument, ctx: MigrationCtx) => MigrationDocument | Promise<MigrationDocument | undefined | void> | undefined | void;
2718
+ interface MigrationDefinition {
2719
+ /** Rows fetched and rewritten per batch. Defaults to the runner's batch size when omitted. */
2720
+ readonly batchSize?: number;
2721
+ /** Optional reverse transform, applied by `migrate down`. */
2722
+ readonly down?: MigrationTransform;
2723
+ /** Stable, unique identifier — the key per-shard run-state is tracked under. */
2724
+ readonly id: string;
2725
+ /** Table whose documents this migration iterates. */
2726
+ readonly table: string;
2727
+ /** Forward transform, applied to every row by `migrate up`. */
2728
+ readonly up: MigrationTransform;
1233
2729
  }
2730
+ /** A {@link MigrationDefinition} plus the codegen discovery marker. */
2731
+ interface RegisteredMigration extends MigrationDefinition {
2732
+ readonly __lunoraMigration: true;
2733
+ }
2734
+ /** Declare an online data migration. See the module docs for runtime semantics. */
2735
+ declare const defineMigration: (definition: MigrationDefinition) => RegisteredMigration;
1234
2736
  /**
1235
- * Declare a standalone rank index. Pass the returned value to
1236
- * `defineSchema(tables, vectorIndexes, aggregateIndexes, rankIndexes)` keyed
1237
- * by index name the schema attaches it to `tables[on].rankIndexes`.
1238
- */
1239
- declare const defineRankIndex: (name: string, options: RankIndexOptions) => RankIndexDefinition;
1240
- /**
1241
- * Build the application schema. The first argument is the table map; the
1242
- * optional second argument registers standalone `defineVectorIndex(...)`
1243
- * declarations (DSL Shape B) keyed by index name. The optional third argument
1244
- * registers standalone `defineAggregateIndex(...)` declarations (DSL Shape B);
1245
- * the optional fourth argument registers standalone `defineRankIndex(...)`
1246
- * declarations. Both are folded into the matching `tables[on].*Indexes` array
1247
- * so runtime backends read every index uniformly off the table definition.
1248
- */
1249
- /**
1250
- * Schema with an in-place `.extend(plugin.extension)` method. Used so apps
1251
- * can compose plugin schemas: `defineSchema({...}).extend(authPlugin.extension)`.
1252
- *
1253
- * `extend` is non-mutating — returns a fresh `ExtendableSchema` containing
1254
- * the merged tables. Extension tables are auto-namespaced by the extension
1255
- * `key` (`buckets` → `ratelimit_buckets`), so the merged type carries the
1256
- * prefixed names via {@link PrefixedTables}. Chains:
1257
- * `defineSchema(...).extend(a).extend(b)` is the typed equivalent of merging
1258
- * `a`'s prefixed tables then `b`'s.
1259
- */
1260
- type ExtendableSchema<T extends Record<string, TableDefinition>> = {
1261
- extend: <X extends Record<string, TableDefinition>, Key extends string>(extension: SchemaExtension<X> & {
1262
- readonly key: Key;
1263
- }) => ExtendableSchema<PrefixedTables<X, Key> & T>;
2737
+ * A mutator declaration. `server` is authoritative; `client` is the optimistic
2738
+ * twin (optional omit it to let the optimistic write fall through to the
2739
+ * server round-trip with no local preview). Both receive the same validated
2740
+ * `args`.
2741
+ */
2742
+ interface MutatorDefinition<Args extends ValidatorMap = ValidatorMap, ServerContext = MutationCtx, ClientTx = unknown, R = unknown> {
1264
2743
  /**
1265
- * Turn on secure-by-default RLS for the whole schema. Every table is then
1266
- * protected the DO/D1 write path denies raw, non-RLS `ctx.db` access, so a
1267
- * procedure that forgets `.use(rls(...))` fails closed. Opt a table out with
1268
- * `.public()`. Non-mutating: returns a fresh `ExtendableSchema` carrying the
1269
- * mode, so `.rls("required")` composes with `.extend(...)` either order.
1270
- */
1271
- rls: (mode: "required") => ExtendableSchema<T>;
1272
- } & Schema<T>;
1273
- declare const defineSchema: <T extends Record<string, TableDefinition>>(tables: T, vectorIndexes?: Record<string, VectorIndexDefinition>, aggregateIndexes?: Record<string, AggregateIndexDefinition>, rankIndexes?: Record<string, RankIndexDefinition>) => ExtendableSchema<T>;
2744
+ * Validator for the mutator's arguments. Validated on the DO before `server`
2745
+ * runs and (when present) on the client before `client` runs, so both impls
2746
+ * see the same parsed shape. Omit for a parameterless mutator.
2747
+ */
2748
+ readonly args?: Args;
2749
+ /**
2750
+ * Optimistic client implementation. Runs in a TanStack DB transaction
2751
+ * against the local collections; its writes are applied immediately and
2752
+ * automatically rolled back / rebased as the authoritative result syncs
2753
+ * back. Pure and side-effect-free beyond the local store. Omit to skip the
2754
+ * local preview.
2755
+ */
2756
+ readonly client?: (tx: ClientTx, args: InferValidatorMap<Args>) => Promise<void> | void;
2757
+ /**
2758
+ * Owner-scope the write: names the column carrying the row owner (e.g.
2759
+ * `owner: "userId"`). Before `server` runs, the mutator requires a verified
2760
+ * identity, rejects a client-supplied owner that disagrees with it, and sets
2761
+ * the column to the verified value — so the impl reads `args[owner]` without
2762
+ * trusting the client and never repeats the check by hand.
2763
+ *
2764
+ * This replaces the "every mutator opens with `assertOwner(ctx, args.userId)`"
2765
+ * pattern, and is the write-side counterpart to an `owner`-scoped
2766
+ * {@link import("./shapes").ShapeDefinition}. Unlike a shape it takes the column
2767
+ * NAME rather than `true`: a shape is bound to one `table`, so the table's
2768
+ * `.ownedBy(field)` resolves unambiguously, whereas one mutator may write
2769
+ * several tables and has no single owning table to read it from.
2770
+ *
2771
+ * Declare the column `v.optional(...)` to leave it off the wire entirely; it is
2772
+ * injected either way.
2773
+ */
2774
+ readonly owner?: string;
2775
+ /**
2776
+ * Authoritative server implementation. Runs inside the shard DO with a full
2777
+ * {@link MutationContext} (`ctx.db` writer); its writes append to `__cdc_log`
2778
+ * and poke back to subscribers. This is the source of truth — the client
2779
+ * impl is only a prediction of it.
2780
+ */
2781
+ readonly server: (context: ServerContext, args: InferValidatorMap<Args>) => Promise<R> | R;
2782
+ }
2783
+ /**
2784
+ * A {@link MutatorDefinition} plus the codegen discovery marker and a
2785
+ * dispatch-shaped `handler` (validates `args`, then runs `server`) so the DO
2786
+ * invokes a mutator exactly like a registered procedure.
2787
+ */
2788
+ interface RegisteredMutator<Args extends ValidatorMap = ValidatorMap, ServerContext = MutationCtx, ClientTx = unknown, R = unknown> extends MutatorDefinition<Args, ServerContext, ClientTx, R> {
2789
+ readonly __lunoraMutator: true;
2790
+ /** Validate `rawArgs`, then run the authoritative `server` impl. Used by the DO push path. */
2791
+ readonly handler: (context: ServerContext, rawArgs: Record<string, unknown>) => Promise<R>;
2792
+ /**
2793
+ * Marks the dispatch kind so codegen can register the mutator in the same
2794
+ * `LUNORA_FUNCTIONS` table queries/mutations use — the DO's `handleRpc`
2795
+ * reads `kind === "mutation"` to wrap the authoritative `server` impl in the
2796
+ * shard's BEGIN/COMMIT span (all-or-nothing writes), exactly like an
2797
+ * ordinary `mutation`.
2798
+ */
2799
+ readonly kind: "mutation";
2800
+ }
2801
+ /** Declare a custom mutator. See the module docs for runtime semantics. */
2802
+ declare const defineMutator: <Args extends ValidatorMap = ValidatorMap, ServerContext = MutationCtx, ClientTx = unknown, R = unknown>(definition: MutatorDefinition<Args, ServerContext, ClientTx, R>) => RegisteredMutator<Args, ServerContext, ClientTx, R>;
1274
2803
  /** Default time-to-live for a presence row: a heartbeat keeps a member "present" for this long. */
1275
2804
  declare const DEFAULT_TTL_MS = 3e4;
1276
2805
  declare const PRESENCE_BARE_TABLE = "present";
1277
2806
  /**
1278
- * The prefixed table name the extension produces at merge time. The handlers
1279
- * read/write this name directly so they always agree with the merged schema.
1280
- */
2807
+ * The prefixed table name the extension produces at merge time. The handlers
2808
+ * read/write this name directly so they always agree with the merged schema.
2809
+ */
1281
2810
  declare const PRESENCE_TABLE: "presence_present";
1282
2811
  /**
1283
- * A single present member as returned by `listPresent`.
1284
- *
1285
- * Note: the raw client-chosen `sessionId` is deliberately NOT surfaced. It is a
1286
- * connection secret — disclosing every member's `sessionId` would let any
1287
- * subscriber enumerate them and target the heartbeat / disconnect write paths.
1288
- * A "who's here" UI needs only `userId` + awareness `data`; the caller already
1289
- * knows its own session id locally (the `usePresence` hook returns it).
1290
- */
2812
+ * A single present member as returned by `listPresent`.
2813
+ *
2814
+ * Note: the raw client-chosen `sessionId` is deliberately NOT surfaced. It is a
2815
+ * connection secret — disclosing every member's `sessionId` would let any
2816
+ * subscriber enumerate them and target the heartbeat / disconnect write paths.
2817
+ * A "who's here" UI needs only `userId` + awareness `data`; the caller already
2818
+ * knows its own session id locally (the `usePresence` hook returns it).
2819
+ */
1291
2820
  interface PresenceMember {
1292
2821
  /** Opaque awareness blob (selection, cursor, name, color…). */
1293
2822
  data?: Record<string, unknown>;
@@ -1301,41 +2830,55 @@ interface PresenceMember {
1301
2830
  /** Options for {@link definePresence}. */
1302
2831
  interface DefinePresenceOptions {
1303
2832
  /**
1304
- * Grace window (ms) before a gracefully-closed session is dropped from the
1305
- * present list. When `0` (the default), `onDisconnect` hard-deletes the
1306
- * session's row the instant its socket closes. When `> 0`, the row is
1307
- * instead aged so the read-time TTL filter hides it `disconnectGraceMs`
1308
- * from now — a reconnect with the same `sessionId` within the window
1309
- * re-heartbeats and restores full presence with no visible flicker (the
1310
- * AnyCable `presence_ttl` behaviour). Clamped to `ttlMs`.
1311
- */
2833
+ * Grace window (ms) before a gracefully-closed session is dropped from the
2834
+ * present list. When `0` (the default), `onDisconnect` hard-deletes the
2835
+ * session's row the instant its socket closes. When `> 0`, the row is
2836
+ * instead aged so the read-time TTL filter hides it `disconnectGraceMs`
2837
+ * from now — a reconnect with the same `sessionId` within the window
2838
+ * re-heartbeats and restores full presence with no visible flicker (the
2839
+ * AnyCable `presence_ttl` behaviour). Clamped to `ttlMs`.
2840
+ */
1312
2841
  disconnectGraceMs?: number;
1313
2842
  /**
1314
- * How long (ms) a heartbeat keeps a member present. `listPresent` excludes
1315
- * rows whose `lastSeen` is older than `now - ttlMs`. Defaults to 30s.
1316
- */
2843
+ * Upper bound on SESSION ROWS `listPresent` reads per call one row per
2844
+ * `(roomId, sessionId)`, so one per open tab, NOT one per person. The read
2845
+ * is newest-first over the `(roomId, lastSeen)` index, so the rows kept are
2846
+ * always the freshest heartbeats and a room with more sessions than this
2847
+ * has its stalest ones truncated.
2848
+ *
2849
+ * Because the multi-tab dedup collapses rows only AFTER the read, a cap
2850
+ * below the room's live session count drops real, currently-heartbeating
2851
+ * members off the bottom of the list. Size it against expected tabs (a
2852
+ * 300-person room at two tabs each is 600 rows), not expected people.
2853
+ * Defaults to 1024. A non-finite value falls back to the default.
2854
+ */
2855
+ maxSessions?: number;
2856
+ /**
2857
+ * How long (ms) a heartbeat keeps a member present. `listPresent` excludes
2858
+ * rows whose `lastSeen` is older than `now - ttlMs`. Defaults to 30s.
2859
+ */
1317
2860
  ttlMs?: number;
1318
2861
  }
1319
2862
  /** The registered functions a presence component ships. */
1320
2863
  interface PresenceFunctions {
1321
2864
  /**
1322
- * Connection-lifecycle hook: the instant a client's WebSocket drops, hard-
1323
- * delete its presence row so it disappears from `listPresent` with no TTL
1324
- * lag. Targets the row by the `{ roomId, sessionId }` the client passed as
1325
- * the connection `context`, and only deletes it when the disconnecting
1326
- * VERIFIED identity owns the row (so a forged context can't evict another
1327
- * member). The TTL filter + `sweep` remain the fallback for ungraceful drops
1328
- * where no `context` was recorded.
1329
- */
2865
+ * Connection-lifecycle hook: the instant a client's WebSocket drops, hard-
2866
+ * delete its presence row so it disappears from `listPresent` with no TTL
2867
+ * lag. Targets the row by the `{ roomId, sessionId }` the client passed as
2868
+ * the connection `context`, and only deletes it when the disconnecting
2869
+ * VERIFIED identity owns the row (so a forged context can't evict another
2870
+ * member). The TTL filter + `sweep` remain the fallback for ungraceful drops
2871
+ * where no `context` was recorded.
2872
+ */
1330
2873
  disconnect: RegisteredLifecycleHook;
1331
2874
  /**
1332
- * Upsert the caller's presence row for `roomId` and stamp `lastSeen = now`.
1333
- * Keyed by `(roomId, sessionId)` — re-heartbeats patch the existing row so
1334
- * subscribers receive a single-row delta, not a churn of insert/delete. A
1335
- * heartbeat may only patch a row owned by the same identity (an existing row
1336
- * held by a different `userId` is refused with `FORBIDDEN`), so a client
1337
- * can't overwrite another member's awareness data via a guessed `sessionId`.
1338
- */
2875
+ * Upsert the caller's presence row for `roomId` and stamp `lastSeen = now`.
2876
+ * Keyed by `(roomId, sessionId)` — re-heartbeats patch the existing row so
2877
+ * subscribers receive a single-row delta, not a churn of insert/delete. A
2878
+ * heartbeat may only patch a row owned by the same identity (an existing row
2879
+ * held by a different `userId` is refused with `FORBIDDEN`), so a client
2880
+ * can't overwrite another member's awareness data via a guessed `sessionId`.
2881
+ */
1339
2882
  heartbeat: RegisteredMutation<{
1340
2883
  data: ReturnType<typeof v.optional>;
1341
2884
  roomId: ReturnType<typeof v.string>;
@@ -1344,17 +2887,19 @@ interface PresenceFunctions {
1344
2887
  lastSeen: number;
1345
2888
  }>;
1346
2889
  /**
1347
- * Live query returning the non-expired members of `roomId`, newest heartbeat
1348
- * first. Subscribe to it for a reactive "who's here" list.
1349
- */
2890
+ * Live query returning the non-expired members of `roomId`, newest heartbeat
2891
+ * first. Subscribe to it for a reactive "who's here" list.
2892
+ */
1350
2893
  listPresent: RegisteredQuery<{
1351
2894
  roomId: ReturnType<typeof v.string>;
1352
2895
  }, PresenceMember[]>;
1353
2896
  /**
1354
- * Internal mutation that hard-deletes every expired row for `roomId`. Stale
1355
- * rows already vanish from `listPresent` via the read-time TTL filter; this
1356
- * only reclaims storage. Schedule it (cron / `runAfter`) if you care.
1357
- */
2897
+ * Internal mutation that hard-deletes every expired row for `roomId`. Stale
2898
+ * rows already vanish from `listPresent` via the read-time TTL filter, and
2899
+ * active rooms self-clean via the heartbeat's opportunistic reap; this is
2900
+ * optional hardening for bulk cleanup of rooms that went quiet with stale
2901
+ * rows left behind (schedule it on a cron / `runAfter` if you care).
2902
+ */
1358
2903
  sweep: RegisteredMutation<{
1359
2904
  roomId: ReturnType<typeof v.string>;
1360
2905
  }, {
@@ -1368,313 +2913,320 @@ type PresenceComponent = Component<{
1368
2913
  functions: PresenceFunctions;
1369
2914
  };
1370
2915
  /**
1371
- * The presence schema extension: a single `present` table, auto-namespaced to
1372
- * `presence_present` at merge time, indexed by `(roomId, sessionId)` for the
1373
- * heartbeat upsert and by `roomId` for `listPresent`.
1374
- */
2916
+ * The presence schema extension: a single `present` table, auto-namespaced to
2917
+ * `presence_present` at merge time, indexed by `(roomId, sessionId)` for the
2918
+ * heartbeat upsert and by `roomId` for `listPresent`.
2919
+ */
1375
2920
  declare const presenceExtension: SchemaExtension<{
1376
2921
  [PRESENCE_BARE_TABLE]: ReturnType<typeof defineTable>;
1377
2922
  }>;
1378
2923
  declare const definePresence: (options?: DefinePresenceOptions) => PresenceComponent;
1379
2924
  /**
1380
- * The middlewares `protectPublic` chains, in the order they run. Every field is
1381
- * optional, so a bundle can be just a rate limit, just a captcha, or any mix —
1382
- * pass the already-constructed middlewares (e.g. `rateLimit(limiter, "signup")`
1383
- * from `@lunora/ratelimit`, `verifyTurnstileMiddleware({...})` from
1384
- * `@lunora/auth`). They are accepted as values rather than imported here so
1385
- * `@lunora/server` keeps no dependency on those packages (which depend on it).
1386
- */
2925
+ * The middlewares `protectPublic` chains, in the order they run. Every field is
2926
+ * optional, so a bundle can be just a rate limit, just a captcha, or any mix —
2927
+ * pass the already-constructed middlewares (e.g. `rateLimit(limiter, "signup")`
2928
+ * from `@lunora/ratelimit`, `verifyTurnstileMiddleware({...})` from
2929
+ * `@lunora/auth`). They are accepted as values rather than imported here so
2930
+ * `@lunora/server` keeps no dependency on those packages (which depend on it).
2931
+ */
1387
2932
  interface ProtectPublicOptions<Context> {
1388
2933
  /**
1389
- * A CAPTCHA / bot check, run after the rate limit. Placed second on purpose:
1390
- * an obvious flood is cheaper to reject with the in-memory limiter than with
1391
- * a Turnstile siteverify round-trip.
1392
- */
2934
+ * A CAPTCHA / bot check, run after the rate limit. Placed second on purpose:
2935
+ * an obvious flood is cheaper to reject with the in-memory limiter than with
2936
+ * a Turnstile siteverify round-trip.
2937
+ */
1393
2938
  captcha?: Middleware<Context, Context>;
1394
2939
  /**
1395
- * A rate limit, run first. Cheapest gate, so it sheds obvious abuse before
1396
- * any network-bound check below it runs.
1397
- */
2940
+ * A rate limit, run first. Cheapest gate, so it sheds obvious abuse before
2941
+ * any network-bound check below it runs.
2942
+ */
1398
2943
  rateLimit?: Middleware<Context, Context>;
1399
2944
  /** Extra middlewares appended after `rateLimit` and `captcha`, in order. */
1400
2945
  use?: ReadonlyArray<Middleware<Context, Context>>;
1401
2946
  }
1402
2947
  /**
1403
- * Compose the recommended public-procedure protections into a single
1404
- * `.use()`-able middleware. It is thin sugar over middleware composition — no
1405
- * new enforcement engine — chaining (in order) a rate limit, a CAPTCHA check,
1406
- * and any extra middlewares so a public mutation that creates users, sends
1407
- * mail, or consumes credits is guarded in one attachment:
1408
- *
1409
- * ```ts
1410
- * export const signUp = mutation
1411
- * .use(protectPublic({
1412
- * rateLimit: rateLimit(limiter, "signup"),
1413
- * captcha: verifyTurnstileMiddleware({ secret: env.TURNSTILE_SECRET_KEY, token: (c) => c.args.captchaToken }),
1414
- * }))
1415
- * .handler(async (ctx, args) => { ... });
1416
- * ```
1417
- *
1418
- * The bundle is context-preserving — each inner middleware leaves the context
1419
- * unchanged — so it slots into any `.use()` chain without reshaping the
1420
- * procedure context. Omitted fields are skipped; an empty bundle is a
1421
- * transparent pass-through.
1422
- */
2948
+ * Compose the recommended public-procedure protections into a single
2949
+ * `.use()`-able middleware. It is thin sugar over middleware composition — no
2950
+ * new enforcement engine — chaining (in order) a rate limit, a CAPTCHA check,
2951
+ * and any extra middlewares so a public mutation that creates users, sends
2952
+ * mail, or consumes credits is guarded in one attachment:
2953
+ *
2954
+ * ```ts
2955
+ * export const signUp = mutation
2956
+ * .use(protectPublic({
2957
+ * rateLimit: rateLimit(limiter, "signup"),
2958
+ * captcha: verifyTurnstileMiddleware({ secret: env.TURNSTILE_SECRET_KEY, token: (c) => c.args.captchaToken }),
2959
+ * }))
2960
+ * .handler(async (ctx, args) => { ... });
2961
+ * ```
2962
+ *
2963
+ * The bundle is context-preserving — each inner middleware leaves the context
2964
+ * unchanged — so it slots into any `.use()` chain without reshaping the
2965
+ * procedure context. Omitted fields are skipped; an empty bundle is a
2966
+ * transparent pass-through.
2967
+ */
1423
2968
  declare const protectPublic: <Context>(options: ProtectPublicOptions<Context>) => Middleware<Context, Context>;
2969
+ /**
2970
+ * The read a reactor watches. Runs on every flush that touched a table it read
2971
+ * last time, so keep it indexed and bounded — this is the reactor's steady-state
2972
+ * cost, paid whether or not the handler ends up running.
2973
+ */
2974
+ type ReactorSelect<T> = (context: QueryCtx) => Promise<T> | T;
2975
+ /** What runs when {@link ReactorSelect}'s result changes. Receives the current result. */
2976
+ type ReactorHandler<T> = (context: MutationCtx, result: T) => Promise<void> | void;
2977
+ /**
2978
+ * A registered reactor — an internal mutation tagged `lifecycle: "reactor"`.
2979
+ *
2980
+ * Typed separately from `RegisteredLifecycleHook` (which returns `void`) because
2981
+ * a reactor dispatch reports a {@link ReactorOutcome} back to the shard: the
2982
+ * digest becomes the next baseline, and the shard cannot compute it itself
2983
+ * without re-running the read.
2984
+ */
2985
+ type RegisteredReactor = RegisteredFunction<Record<string, never>, ReactorOutcome, "mutation"> & {
2986
+ readonly lifecycle: "reactor";
2987
+ };
2988
+ /** What a reactor dispatch reports back so the shard can update the baseline. */
2989
+ interface ReactorOutcome {
2990
+ /** Digest of the result the handler was given (or would have been). */
2991
+ digest: string;
2992
+ /** Whether the handler actually ran — `false` when the result was unchanged. */
2993
+ ran: boolean;
2994
+ }
2995
+ /**
2996
+ * Register a reactor: run `handler` after a write flush, whenever `select`'s
2997
+ * result differs from the last result the handler saw. See the module docblock
2998
+ * for the trigger comparison and the convergence contract.
2999
+ *
3000
+ * The read is an inline callback rather than a reference to a registered query
3001
+ * on purpose. It keeps the declaration statically discoverable with no import
3002
+ * resolution and no dependency on `_generated/api`, and it lets the read and the
3003
+ * handler share one transaction and one read footprint.
3004
+ *
3005
+ * That shared footprint is deliberately conservative: it is the union of what
3006
+ * `select` AND `handler` read, so a table the handler merely consults can wake
3007
+ * the reactor later. The cost of that is one extra `select` — whose digest then
3008
+ * matches and suppresses the handler — never a missed reaction.
3009
+ */
3010
+ declare const onQueryChange: <T>(select: ReactorSelect<T>, handler: ReactorHandler<T>) => RegisteredReactor;
1424
3011
  declare const definePolicy: <Context = unknown>(input: DefinePolicyInput<Context>) => Policy<Context>;
1425
3012
  /**
1426
- * Build a project-bound, relation-aware `definePolicy` typed against the
1427
- * generated `DataModel` (`DM`) + `Relations` (`REL`) maps. Codegen emits a
1428
- * `createPolicyDsl&lt;DataModel, Relations>()` binding into `_generated/server.ts`,
1429
- * so importing `definePolicy` from the generated module constrains `table` to a
1430
- * real table name and type-checks the `when` predicate — including Prisma-style
1431
- * relation predicates (`is`/`some`/…) the `@lunora/do` pre-resolver now resolves
1432
- * on reads. The runtime is byte-for-byte the untyped {@link definePolicy}; only
1433
- * the compile-time surface narrows, so a policy authored either way is
1434
- * discovered identically by the `rls()` chain.
1435
- */
1436
- declare const createPolicyDsl: <DM, REL extends Record<keyof DM, object>>() => <T extends keyof DM, Context = unknown>(input: TypedDefinePolicyInput<DM, REL, T, Context>) => Policy<Context>;
1437
- /**
1438
- * Declare a named permission a policy can check with `ctx.auth.can(...)`. Grant
1439
- * it to a role through `defineRole`'s `permissions`, register those roles with
1440
- * the middleware via `rls(policies, { roles })`, then check it in a policy with
1441
- * `when: ({ auth }) => auth.can(permission)`. See the `./index` JSDoc for a
1442
- * worked example.
1443
- */
3013
+ * Build a project-bound, relation-aware `definePolicy` typed against the
3014
+ * generated `DataModel` (`DM`) + `Relations` (`REL`) maps. Codegen emits a
3015
+ * `createPolicyDsl<DataModel, Relations>()` binding into `_generated/server.ts`,
3016
+ * so importing `definePolicy` from the generated module constrains `table` to a
3017
+ * real table name and type-checks the `when` predicate — including Prisma-style
3018
+ * relation predicates (`is`/`some`/…) the `@lunora/do` pre-resolver now resolves
3019
+ * on reads. The runtime is byte-for-byte the untyped {@link definePolicy}; only
3020
+ * the compile-time surface narrows, so a policy authored either way is
3021
+ * discovered identically by the `rls()` chain.
3022
+ */
3023
+ declare const createPolicyDsl: <DM, REL extends Record<keyof DM, object>, Identity = Record<string, unknown>>() => <T extends keyof DM, Context = unknown>(input: TypedDefinePolicyInput<DM, REL, T, Context, Identity>) => Policy<Context>;
3024
+ /**
3025
+ * Declare a named permission a policy can check with `ctx.auth.can(...)`. Grant
3026
+ * it to a role through `defineRole`'s `permissions`, register those roles with
3027
+ * the middleware via `rls(policies, { roles })`, then check it in a policy with
3028
+ * `when: ({ auth }) => auth.can(permission)`. See the `./index` JSDoc for a
3029
+ * worked example.
3030
+ */
1444
3031
  declare const definePermission: (name: string, options?: Omit<Permission, "name">) => Permission;
1445
3032
  /**
1446
- * Collect a list of policies into the structure the `rls()` middleware
1447
- * consumes. Multiple read policies on the same table OR together (any one
1448
- * matching reveals the row); multiple write policies for the same `(table,
1449
- * op)` AND together (every one must allow). The middleware keeps them in order
1450
- * and decides — see `./middleware`.
1451
- *
1452
- * Validates against an **accidentally duplicated policy** — the same
1453
- * `(table, on)` registered with the *same* decision function (a copy-paste, or
1454
- * the same policy object spread in twice). Because multiple DISTINCT policies
1455
- * per `(table, on)` are intentional, the check keys on the `when` reference too:
1456
- * only a reference-identical `when` for the same `(table, on)` is a real
1457
- * duplicate. Throws at module load so the misconfiguration surfaces immediately
1458
- * rather than as a silently double-evaluated predicate at request time.
1459
- */
3033
+ * Collect a list of policies into the structure the `rls()` middleware
3034
+ * consumes. Multiple read policies on the same table OR together (any one
3035
+ * matching reveals the row); multiple write policies for the same `(table,
3036
+ * op)` AND together (every one must allow). The middleware keeps them in order
3037
+ * and decides — see `./middleware`.
3038
+ *
3039
+ * Validates against an **accidentally duplicated policy** — the same
3040
+ * `(table, on)` registered with the *same* decision function (a copy-paste, or
3041
+ * the same policy object spread in twice). Because multiple DISTINCT policies
3042
+ * per `(table, on)` are intentional, the check keys on the `when` reference too:
3043
+ * only a reference-identical `when` for the same `(table, on)` is a real
3044
+ * duplicate. Throws at module load so the misconfiguration surfaces immediately
3045
+ * rather than as a silently double-evaluated predicate at request time.
3046
+ */
1460
3047
  declare const definePolicies: <Context = unknown>(policies: ReadonlyArray<Policy<Context>>) => ReadonlyArray<Policy<Context>>;
1461
3048
  declare const defineRole: (name: string, options?: Omit<Role, "name">) => Role;
1462
3049
  /**
1463
- * Structural mirror of `@lunora/do`'s `QueryArgs` and `CountArgs`. The
1464
- * runtime ORM in `@lunora/do`/`@lunora/d1` reads `baseWhere` /
1465
- * `restrictsCounts` straight off these option objects, so as long as the
1466
- * fields here stay name-compatible the wrapper is portable across the two
1467
- * dialects without an inter-package dependency.
1468
- */
1469
- interface QueryArgs {
1470
- baseWhere?: WhereInput;
1471
- cursor?: null | string;
1472
- limit?: number;
1473
- orderBy?: ReadonlyArray<unknown>;
1474
- /**
1475
- * Per-target-table read filter the RLS wrapper attaches so a `with` relation
1476
- * is policy-filtered on its own hop (see `@lunora/do`'s `QueryArgs`). Mirrors
1477
- * the top-level read: `(table) => readBase(table).baseWhere`.
1478
- */
1479
- relationBaseWhere?: (table: string) => undefined | WhereInput;
1480
- restrictsCounts?: boolean;
1481
- where?: WhereInput;
1482
- with?: Record<string, unknown>;
1483
- }
1484
- interface CountArgs {
1485
- baseWhere?: WhereInput;
1486
- relationBaseWhere?: (table: string) => undefined | WhereInput;
1487
- restrictsCounts?: boolean;
1488
- where?: WhereInput;
1489
- }
1490
- /** Structural mirror of `@lunora/do`'s `AggregateOptions` — only the fields the wrapper touches. */
1491
- interface AggregateArgs {
1492
- baseWhere?: WhereInput;
1493
- field?: string;
1494
- op: string;
1495
- relationBaseWhere?: (table: string) => undefined | WhereInput;
1496
- restrictsCounts?: boolean;
1497
- where?: WhereInput;
1498
- }
1499
- /** Structural mirror of `@lunora/do`'s `GroupByOptions`. */
1500
- interface GroupByArgs {
1501
- agg?: {
1502
- field?: string;
1503
- op: string;
1504
- };
1505
- baseWhere?: WhereInput;
1506
- by: ReadonlyArray<string>;
1507
- relationBaseWhere?: (table: string) => undefined | WhereInput;
1508
- restrictsCounts?: boolean;
1509
- where?: WhereInput;
1510
- }
1511
- /** Structural mirror of `@lunora/do`'s `RankOptions`. */
1512
- interface RankArgs {
1513
- baseWhere?: WhereInput;
1514
- restrictsCounts?: boolean;
1515
- row: Record<string, unknown> | string;
1516
- where?: WhereInput;
1517
- }
1518
- /** Structural mirror of `@lunora/do`'s `RankBeforeOptions`. */
1519
- interface RankBeforeArgs {
1520
- partitionKey: string;
1521
- restrictsCounts?: boolean;
1522
- rowId: string;
1523
- sortValues: ReadonlyArray<unknown>;
1524
- }
1525
- /** Structural mirror of `@lunora/do`'s `RankPageOptions`. */
1526
- interface RankPageArgs {
1527
- baseWhere?: WhereInput;
1528
- cursor?: null | string;
1529
- restrictsCounts?: boolean;
1530
- take?: number;
1531
- where?: WhereInput;
1532
- }
1533
- interface QueryPage {
1534
- continueCursor: null | string;
1535
- isDone: boolean;
1536
- page: Record<string, unknown>[];
1537
- }
1538
- interface TableReaderLike {
1539
- collect: () => Promise<Record<string, unknown>[]>;
1540
- filter: (predicate: (document: Record<string, unknown>) => boolean) => TableReaderLike;
1541
- first: () => Promise<Record<string, unknown> | null>;
1542
- paginate: (options: {
1543
- cursor?: null | string;
1544
- numItems: number;
1545
- }) => Promise<QueryPage>;
1546
- take: (limit: number) => Promise<Record<string, unknown>[]>;
1547
- withIndex: (indexName: string, range?: (q: unknown) => unknown) => TableReaderLike;
1548
- withSearchIndex: (indexName: string, search: (q: unknown) => unknown) => TableReaderLike;
1549
- }
1550
- /**
1551
- * Structural projection of the runtime ORM writer. The wrapper relies only
1552
- * on these fields, so it's interchangeable between `@lunora/do`'s
1553
- * `DatabaseWriterLike` and `@lunora/d1`'s `DatabaseWriterLike`.
1554
- */
1555
- interface DatabaseWriterLike {
3050
+ * A predicate matching **no** rows the deny decision.
3051
+ *
3052
+ * `{ OR: [] }` is a disjunction over zero branches, which the where-compiler
3053
+ * folds to a constant false. Use it (never a bare `{}`) for the unauthorized
3054
+ * branch of a shape or policy predicate:
3055
+ *
3056
+ * ```ts
3057
+ * where: (ctx, { userId }) => (ctx.auth.userId === userId ? { userId } : deny())
3058
+ * ```
3059
+ *
3060
+ * A fresh object per call, so a caller can safely spread or extend the result.
3061
+ */
3062
+ declare const deny: () => WhereInput;
3063
+ /**
3064
+ * A predicate matching **every** row — no restriction beyond whatever it is
3065
+ * AND-composed with. The honest spelling of "this branch adds no filter", where a
3066
+ * bare `{}` reads like an oversight.
3067
+ */
3068
+ declare const allowAll: () => WhereInput;
3069
+ /**
3070
+ * `true` when `where` is the vacuously-false deny predicate — i.e. an `OR` over
3071
+ * zero branches, carrying no other constraint. Used by the runtime to short-circuit
3072
+ * a denied read instead of compiling and running SQL that provably matches nothing.
3073
+ */
3074
+ declare const isDeny: (where: WhereInput) => boolean;
3075
+ /**
3076
+ * Normalize a boolean-or-predicate decision to a {@link WhereInput}.
3077
+ *
3078
+ * The authoring surfaces accept `true`/`false` as sugar — they read far better
3079
+ * than the sentinel objects in an ownership check — and every consumer downstream
3080
+ * wants a plain predicate. `undefined` means "no opinion" and maps to
3081
+ * {@link allowAll}, matching the RLS `PolicyDecision` contract.
3082
+ */
3083
+ declare const toWhereInput: (decision: WhereInput | boolean | undefined) => WhereInput;
3084
+ /**
3085
+ * One `rls()` tag's read policies for a table, paired with the role→permission
3086
+ * grants of that SAME middleware. Keeping the role map per-group is what lets a
3087
+ * policy's `auth.can(...)` resolve against its own middleware's roles — never a
3088
+ * permission registered on a different `rls()` step.
3089
+ */
3090
+ interface ScopedReadPolicies {
3091
+ readonly policies: ReadonlyArray<Policy>;
3092
+ readonly rolePermissions: ReadonlyMap<string, ReadonlySet<string>>;
3093
+ }
3094
+ /** Table-indexed read-policy groups, each scoped to the roles of the rls() middleware that declared it. */
3095
+ interface RlsReadRegistry {
3096
+ readonly byTable: ReadonlyMap<string, ReadonlyArray<ScopedReadPolicies>>;
3097
+ }
3098
+ /** The trusted, server-resolved facts a shape's RLS evaluation runs under. */
3099
+ interface ShapeReadWhereRequest {
3100
+ /** The shape ctx (the procedure context a policy `when` reads as `ctx`). */
3101
+ readonly ctx: unknown;
3102
+ /** Resolved identity claims (the socket's verified identity), or `null` when anonymous. */
3103
+ readonly identity: Record<string, unknown> | null;
3104
+ /** `true` when the schema is `.rls("required")` — gates the fail-closed branch. */
3105
+ readonly rlsRequired: boolean;
3106
+ /** The shape's own predicate (`where(ctx, args)`). */
3107
+ readonly shapeWhere: WhereInput;
3108
+ /** Logical table the shape replicates. */
3109
+ readonly table: string;
3110
+ /** `true` when the table is `.public()` (exempt from `.rls("required")` denial). */
3111
+ readonly tablePublic: boolean;
3112
+ /** Verified user id, or `null` when anonymous. */
3113
+ readonly userId: null | string;
3114
+ }
3115
+ /**
3116
+ * Build the read-policy registry from the registered functions (pass
3117
+ * `Object.values(LUNORA_FUNCTIONS)`). Only `on: "read"` policies are collected,
3118
+ * grouped per `rls()` middleware so each group keeps its own role→permission map
3119
+ * (a `(table, when)` pair is de-duplicated within a tag). A tag reused across
3120
+ * several procedures (a shared `const guard = rls(...)`) is folded once. This
3121
+ * mirrors the request-time `rls()` path exactly: a policy's `auth.can(...)`
3122
+ * resolves against the roles of the middleware that declared it, never a union.
3123
+ */
3124
+ declare const buildRlsReadRegistry: (functions: Iterable<unknown>) => RlsReadRegistry;
3125
+ /**
3126
+ * Compute the effective `where` a shape replicates: the table's RLS read
3127
+ * base-where AND the shape's own predicate. Returns the shape predicate
3128
+ * unchanged for a table with no read policy (a `.public()` or non-RLS table),
3129
+ * and the FALSE sentinel (replicate nothing) when a `.rls("required")` schema
3130
+ * exposes a protected, policy-less table.
3131
+ */
3132
+ declare const composeShapeReadWhere: (registry: RlsReadRegistry, request: ShapeReadWhereRequest) => WhereInput;
3133
+ /**
3134
+ * A shape declaration. `where` receives the trusted procedure context and the
3135
+ * validated client args and returns the same {@link WhereInput} shape the RLS
3136
+ * DSL uses, so the DO can AND-merge it with the table's read base-where via the
3137
+ * existing where-compiler (zero second predicate implementation).
3138
+ */
3139
+ interface ShapeDefinition<Args extends ValidatorMap = ValidatorMap, Context = QueryCtx> {
1556
3140
  /**
1557
- * Reduce matching rows to a scalar. The RLS wrapper AND-merges the read
1558
- * `baseWhere` into `options` so the reduction only sees policy-visible rows
1559
- * (safe: an aggregate scoped to `where` never reveals a hidden row — see
1560
- * `@lunora/do`'s `RestrictableQueryOptions`). Required: the only writer ever
1561
- * wrapped is `@lunora/do`'s `createShardCtxDb`, which always implements it.
1562
- */
1563
- aggregate: (tableName: string, options: AggregateArgs) => Promise<null | number>;
1564
- count: (tableName: string, whereOrArgs?: CountArgs | WhereInput) => Promise<number>;
1565
- delete: (id: string, expectedTable?: string) => Promise<void>;
1566
- deleteMany: (ids: ReadonlyArray<string>, options?: {
1567
- limit?: number;
1568
- }, expectedTable?: string) => Promise<{
1569
- deleted: number;
1570
- }>;
1571
- findFirst: (tableName: string, args?: QueryArgs) => Promise<Record<string, unknown> | null>;
1572
- findFirstOrThrow: (tableName: string, args?: QueryArgs) => Promise<Record<string, unknown>>;
1573
- findMany: (tableName: string, args?: QueryArgs) => Promise<QueryPage>;
1574
- get: (id: string, expectedTable?: string) => Promise<Record<string, unknown> | null>;
3141
+ * Validator for the client-supplied shape parameters. Validated on the DO
3142
+ * before `where` runs, so a malformed `args` envelope is rejected at the
3143
+ * subscription boundary rather than silently widening the partition. Omit
3144
+ * for a parameterless shape.
3145
+ */
3146
+ readonly args?: Args;
1575
3147
  /**
1576
- * Group + reduce. Same `baseWhere` injection as `aggregate`: the per-group
1577
- * reduction is scoped to policy-visible rows, so a group count tallies only
1578
- * rows the caller may read. Required for the same reason as `aggregate`.
1579
- */
1580
- groupBy: (tableName: string, options: GroupByArgs) => Promise<ReadonlyArray<{
1581
- key: Record<string, unknown>;
1582
- value: null | number;
1583
- }>>;
1584
- insert: (tableName: string, document: Record<string, unknown>) => Promise<string>;
1585
- insertMany: (tableName: string, documents: ReadonlyArray<Record<string, unknown>>, options?: {
1586
- limit?: number;
1587
- }) => Promise<string[]>;
1588
- insertManyUnsafe: (tableName: string, documents: ReadonlyArray<Record<string, unknown>>, options?: {
1589
- allowExplicitId?: boolean;
1590
- limit?: number;
1591
- }) => Promise<string[]>;
3148
+ * Project the replicated rows to these columns (the system columns `_id` and
3149
+ * `_creationTime` are always included). Omit to replicate every column. An
3150
+ * empty array is rejected it would replicate no data, which is never the
3151
+ * intent.
3152
+ */
3153
+ readonly columns?: ReadonlyArray<string>;
1592
3154
  /**
1593
- * Optional table-aware lookup. The underlying writer (e.g. `@lunora/do`)
1594
- * already knows the owning table of an id internally, so it can return
1595
- * `{ row, tableName }` in a single round-trip. When present, the RLS wrapper
1596
- * uses it to collapse the per-call membership-probe fan-out (1 `get` + N
1597
- * `findFirst` across every policy table) down to one lookup. Writers that
1598
- * don't implement it fall back to the probe path.
1599
- */
1600
- lookupById?: (id: string, expectedTable?: string) => Promise<null | {
1601
- row: Record<string, unknown>;
1602
- tableName: string;
1603
- }>;
1604
- patch: (id: string, patch: Record<string, unknown>, expectedTable?: string) => Promise<void>;
1605
- patchMany: (patches: ReadonlyArray<{
1606
- id: string;
1607
- patch: Record<string, unknown>;
1608
- }>, options?: {
1609
- limit?: number;
1610
- }, expectedTable?: string) => Promise<void>;
1611
- query: (tableName: string) => TableReaderLike;
3155
+ * Restrict this shape to rows the **subscriber owns**, without writing the
3156
+ * predicate by hand.
3157
+ *
3158
+ * `true` uses the owning column declared by `.ownedBy(field)` on the table;
3159
+ * `"field"` names the owning column here instead (for a table that has no
3160
+ * `.ownedBy()`, or a shape that scopes by a different column).
3161
+ *
3162
+ * The derived predicate is `{ [field]: ctx.auth.userId }`, AND-composed with
3163
+ * the shape's own `where` when both are present, and an anonymous subscriber is denied
3164
+ * outright. Because the value comes from the socket's verified identity rather
3165
+ * than from `args`, a client cannot ask for another user's partition at all —
3166
+ * strictly safer than accepting a `userId` arg and comparing it.
3167
+ *
3168
+ * This is the primitive for the "every shape repeats the same ownership check"
3169
+ * pattern: with it, an owner-scoped shape needs no `where` at all.
3170
+ */
3171
+ readonly owner?: string | true;
3172
+ /** Logical table this shape replicates a partition of. */
3173
+ readonly table: string;
1612
3174
  /**
1613
- * Rank a row within its partition. A position is a count-of-rows-before, so
1614
- * exactly like `count()` it can't be trusted in an RLS-restricted
1615
- * reader: the wrapper fails it closed with `COUNT_RLS_UNSUPPORTED`. Required
1616
- * for the same reason as `aggregate`.
1617
- */
1618
- rank: (tableName: string, indexName: string, options: RankArgs) => Promise<null | {
1619
- position: number;
1620
- total: number;
1621
- }>;
1622
- /** Cross-shard rank primitive — same count-of-before RLS hazard as `rank`; failed closed under a read policy. */
1623
- rankBefore?: (tableName: string, indexName: string, options: RankBeforeArgs) => Promise<{
1624
- before: number;
1625
- total: number;
1626
- }>;
3175
+ * Predicate selecting the rows this shape replicates. AND-composed with the
3176
+ * table's RLS read base-where on the DO. Runs server-side with a trusted
3177
+ * `ctx` (identity/auth the client can't forge) and the validated client
3178
+ * `args`; returns a {@link WhereInput} using the same operator set as the
3179
+ * SQL compiler (`eq`/`in`/`lt`/… + `AND`/`OR`/`NOT`).
3180
+ *
3181
+ * `true`/`false` are accepted as sugar for "no further restriction" / "deny" —
3182
+ * `false` compiles to the vacuously-false predicate, so a denial branch can
3183
+ * never be mistyped as the everything-matches `{}`. Optional when `owner` is
3184
+ * set; otherwise required.
3185
+ */
3186
+ readonly where?: (context: Context, args: InferValidatorMap<Args>) => WhereInput | boolean;
3187
+ }
3188
+ /** A {@link ShapeDefinition} plus the codegen discovery marker and a dispatch-shaped `compileWhere`. */
3189
+ interface RegisteredShape<Args extends ValidatorMap = ValidatorMap, Context = QueryCtx> extends ShapeDefinition<Args, Context> {
3190
+ readonly __lunoraShape: true;
1627
3191
  /**
1628
- * Sorted pagination over a rank companion. The companion stores only the
1629
- * partition + sort keys + id, so an arbitrary read `baseWhere` can't be
1630
- * enforced against it (and re-filtering the fetched rows would break page
1631
- * sizing). RLS therefore fails it closed rather than leak hidden rows.
1632
- * Required for the same reason as `aggregate`.
1633
- */
1634
- rankPage: (tableName: string, indexName: string, options?: RankPageArgs) => Promise<QueryPage>;
1635
- replace: (id: string, document: Record<string, unknown>, expectedTable?: string) => Promise<void>;
1636
- }
1637
- /**
1638
- * What a procedure's `ctx.db` must structurally satisfy for the middleware
1639
- * to wrap it. We deliberately mirror `@lunora/do`'s `DatabaseWriterLike`
1640
- * rather than `@lunora/server`'s nominal `DatabaseWriter`/`DatabaseReader`:
1641
- * the runtime adapter that flows in is the `DatabaseWriterLike`-shaped one,
1642
- * and structural matching keeps this module free of an `@lunora/do`-typed
1643
- * `ctx`.
1644
- */
1645
- type RlsDatabase = DatabaseWriterLike;
1646
- /** Roles list source on the context. Tolerant of older auth states. */
1647
- type AuthLike = {
1648
- getIdentity?: () => Promise<Record<string, unknown> | null>;
1649
- roles?: ReadonlyArray<string>;
1650
- userId?: null | string;
1651
- };
1652
- /** Minimal shape the middleware needs on the incoming ctx. */
1653
- interface RlsContextIn {
1654
- auth?: AuthLike;
1655
- db: RlsDatabase;
1656
- }
1657
- declare const rls: <Context extends RlsContextIn = RlsContextIn>(policies: ReadonlyArray<Policy<Context>>, options?: RlsOptions) => Middleware<Context, Context>;
1658
- /**
1659
- * Operations a storage rule can gate. `read` covers `download` / `getMetadata`
1660
- * / `getSignedUrl` / `getUrl`; `write` covers `store` / `generateUploadUrl`;
1661
- * `delete` is `delete`; `list` is a prefix listing (governed via the file
1662
- * browser / admin path, not `ctx.storage` which has no `list`).
1663
- */
3192
+ * Validate `rawArgs`, then evaluate the shape's predicate under the trusted
3193
+ * `ctx` and return its {@link WhereInput}. Used by the generated DO's
3194
+ * `resolveShape` override: `ctx` is erased to `unknown` at this dispatch
3195
+ * boundary (the DO builds it from the socket's verified identity and hands it
3196
+ * back as the concrete {@link QueryContext} the predicate expects), exactly like
3197
+ * `RegisteredLunoraFunction.handler` erases its context.
3198
+ *
3199
+ * `options.ownerField` carries the table's `.ownedBy(field)` column, which only
3200
+ * the caller (the DO, holding the schema) can look up — it is what resolves an
3201
+ * `owner: true` shape. Omitted for an `owner: "field"` or plain `where` shape.
3202
+ */
3203
+ readonly compileWhere: (context: unknown, rawArgs: Record<string, unknown>, options?: {
3204
+ ownerField?: string;
3205
+ }) => WhereInput;
3206
+ }
3207
+ /** Declare a replication shape. See the module docs for runtime semantics. */
3208
+ declare const defineShape: <Args extends ValidatorMap = ValidatorMap, Context = QueryCtx>(definition: ShapeDefinition<Args, Context>) => RegisteredShape<Args, Context>;
3209
+ /**
3210
+ * Operations a storage rule can gate. `read` covers `download` / `getMetadata`
3211
+ * / `getSignedUrl` / `getUrl`; `write` covers `store` / `generateUploadUrl`;
3212
+ * `delete` is `delete`; `list` is a prefix listing (governed via the file
3213
+ * browser / admin path, not `ctx.storage` which has no `list`).
3214
+ */
1664
3215
  type StorageOperation = "delete" | "list" | "read" | "write";
1665
3216
  /** A rule's decision. `true` allows, `false` denies, `undefined` opts this rule out. */
1666
3217
  type StorageRuleDecision = boolean | undefined;
1667
3218
  /**
1668
- * Context handed to a storage rule. `auth` mirrors RLS's `PolicyContext.auth`
1669
- * (the per-request userId / roles / identity and the `can(permission)` helper),
1670
- * so a rule reads `({ auth, key }) => key.startsWith(`user/${auth.userId}/`)`.
1671
- * `key` is the object key the operation targets (for `list`, the listing
1672
- * prefix). `ctx` is the full procedure context the middleware closed over.
1673
- */
3219
+ * Context handed to a storage rule. `auth` mirrors RLS's `PolicyContext.auth`
3220
+ * (the per-request userId / roles / identity and the `can(permission)` helper),
3221
+ * so a rule reads `({ auth, key }) => key.startsWith(`user/${auth.userId}/`)`.
3222
+ * `key` is the object key the operation targets (for `list`, the listing
3223
+ * prefix). `ctx` is the full procedure context the middleware closed over.
3224
+ */
1674
3225
  interface StorageRuleContext<Context = unknown> {
1675
3226
  readonly auth: {
1676
3227
  readonly can: (permission: Permission | string) => boolean;
1677
3228
  readonly identity?: Record<string, unknown> | null;
3229
+ /** Role labels from the identity's `roles` claim (see `PolicyContext.auth.roles`). */
1678
3230
  readonly roles: ReadonlyArray<string>;
1679
3231
  readonly userId: null | string;
1680
3232
  };
@@ -1685,14 +3237,29 @@ interface StorageRuleContext<Context = unknown> {
1685
3237
  /** A registered storage rule as stored in the rule table. */
1686
3238
  interface StorageRule<Context = unknown> {
1687
3239
  /**
1688
- * Logical bucket the rule governs — matched against the accessor's bucket
1689
- * (`ctx.storage.bucketName`, or the bucket selected via `ctx.storage.bucket(name)`).
1690
- * A rule only applies to operations on its own bucket. The unnamed bucket is
1691
- * `"default"`. Also surfaced in the studio's access-rules view.
1692
- */
3240
+ * Logical bucket the rule governs — matched against the accessor's bucket
3241
+ * (`ctx.storage.bucketName`, or the bucket selected via `ctx.storage.bucket(name)`).
3242
+ * A rule only applies to operations on its own bucket. The unnamed bucket is
3243
+ * `"default"`. Also surfaced in the studio's access-rules view.
3244
+ *
3245
+ * Must name a bucket the request's storage can address, or the middleware
3246
+ * throws `INTERNAL`: an unaddressable rule governs nothing, which leaves its
3247
+ * operation open rather than locked down. Typed `string` rather than the
3248
+ * generated `StorageBucketName` union deliberately — that union is not the
3249
+ * set of registered buckets (it is seeded partly from these very rules), so
3250
+ * narrowing to it would reject valid names and still admit typos. The
3251
+ * runtime check in `./middleware` is the one that has the real set.
3252
+ */
1693
3253
  readonly bucket: string;
1694
3254
  readonly on: StorageOperation;
1695
- /** Optional key-prefix scope; the rule only governs keys under it. Absent ⇒ the whole bucket. */
3255
+ /**
3256
+ * Optional key-prefix scope; the rule only governs keys under it. Absent (or
3257
+ * empty) ⇒ the whole bucket.
3258
+ *
3259
+ * Matched on a **path-segment boundary**: `users/1` governs `users/1` and
3260
+ * `users/1/avatar.png`, but NOT `users/10/avatar.png`. A trailing slash is
3261
+ * cosmetic — `users/1` and `users/1/` scope the same subtree.
3262
+ */
1696
3263
  readonly prefix?: string;
1697
3264
  readonly when: (context: StorageRuleContext<Context>) => StorageRuleDecision;
1698
3265
  }
@@ -1704,38 +3271,32 @@ interface DefineStorageRuleInput<Context = unknown> {
1704
3271
  when: (context: StorageRuleContext<Context>) => StorageRuleDecision;
1705
3272
  }
1706
3273
  /**
1707
- * Options for the `storageRules(rules, options)` middleware. `roles` registers
1708
- * the role→permission grants that back `ctx.auth.can(...)`, exactly as RLS's
1709
- * `RlsOptions.roles` does — fail-closed for unlisted roles.
1710
- */
3274
+ * Options for the `storageRules(rules, options)` middleware. `roles` registers
3275
+ * the role→permission grants that back `ctx.auth.can(...)`, exactly as RLS's
3276
+ * `RlsOptions.roles` does — fail-closed for unlisted roles.
3277
+ */
1711
3278
  interface StorageRulesOptions {
1712
3279
  readonly roles?: ReadonlyArray<Role>;
1713
3280
  }
1714
3281
  declare const defineStorageRule: <Context = unknown>(input: DefineStorageRuleInput<Context>) => StorageRule<Context>;
1715
3282
  /**
1716
- * Collect a list of storage rules into the structure the `storageRules()`
1717
- * middleware consumes. Multiple rules for the same `(bucket, on)` OR together —
1718
- * any one allowing grants the operation (each rule grants a slice of the
1719
- * keyspace).
1720
- *
1721
- * Validates against an **accidentally duplicated rule** — the same
1722
- * `(bucket, on, prefix)` registered with the *same* decision function (a
1723
- * copy-paste, or the same rule object spread in twice). Because multiple
1724
- * DISTINCT rules per `(bucket, on)` are intentional, the check keys on the
1725
- * `when` reference too. Throws at module load so the misconfiguration surfaces
1726
- * immediately rather than as a silently double-evaluated predicate.
1727
- */
3283
+ * Collect a list of storage rules into the structure the `storageRules()`
3284
+ * middleware consumes. Multiple rules for the same `(bucket, on)` OR together —
3285
+ * any one allowing grants the operation (each rule grants a slice of the
3286
+ * keyspace).
3287
+ *
3288
+ * Validates against an **accidentally duplicated rule** — the same
3289
+ * `(bucket, on, prefix)` registered with the *same* decision function (a
3290
+ * copy-paste, or the same rule object spread in twice). Because multiple
3291
+ * DISTINCT rules per `(bucket, on)` are intentional, the check keys on the
3292
+ * `when` reference too. Throws at module load so the misconfiguration surfaces
3293
+ * immediately rather than as a silently double-evaluated predicate.
3294
+ */
1728
3295
  declare const defineStorageRules: <Context = unknown>(rules: ReadonlyArray<StorageRule<Context>>) => ReadonlyArray<StorageRule<Context>>;
1729
- /** The minimal `ctx.auth` shape the middleware reads — a structural subset that the full AuthState satisfies. Tolerant of older auth states (mirrors RLS's `AuthLike`). */
1730
- type StorageAuthLike = {
1731
- getIdentity?: () => Promise<Record<string, unknown> | null>;
1732
- roles?: ReadonlyArray<string>;
1733
- userId?: null | string;
1734
- };
1735
3296
  interface StorageContextIn {
1736
- auth?: StorageAuthLike;
3297
+ auth?: AuthLike;
1737
3298
  storage?: unknown;
1738
3299
  }
1739
3300
  declare const storageRules: <Context extends StorageContextIn = StorageContextIn>(rules: ReadonlyArray<StorageRule<Context>>, options?: StorageRulesOptions) => Middleware<Context, Context>;
1740
3301
  declare const VERSION = "0.0.0";
1741
- export { type ActionBuilder, type ActionCtx, type AggregateIndexDefinition, type AggregateIndexOptions, type AggregateOp, type ArgsValidator, type Component, type ComponentFunctions, type CreateOptions, type DataModelInit, type DefineComponentOptions, type DefinePluginOptions, type DefinePolicyInput, type DefinePresenceOptions, type DefineStorageRuleInput, type EmptyArgs, type EnvAccessor, type EnvKeyFailure, type EnvShape, type ExtendableSchema, type FacadeEntry, type FacadeWriterLike, type FunctionKind, type HttpActionCtx, type HttpActionHandler, type HttpMethod, type HttpRoute, type HttpRouteBuilder, type HttpRouteFactory, type HttpRouteHandlerOptions, type HttpStreamHandlerOptions, type InferArgs, type InferEnv, type InlineAggregateIndexOptions, type InlineRankIndexOptions, type InternalActionBuilder, type InternalMutationBuilder, type InternalQueryBuilder, type LifecycleEvent, type LifecycleHandler, type LunoraBuilders, LunoraEnvError, LunoraError, type LunoraErrorCode, type LunoraHttpApp, type LunoraHttpEnv, type LunoraRouteHandler, type ManyRelation, type MaskColumns, type MaskContext, type MaskFn, type MaskOptions, type MaskPolicies, type MaskStrategy, type Middleware, type MiddlewareNext, type MigrationDefinition, type MigrationDocument, type MigrationTransform, type MutationBuilder, type MutationCtx, type OnDeleteAction, type OneRelation, type OrmLike, DEFAULT_TTL_MS as PRESENCE_DEFAULT_TTL_MS, PRESENCE_TABLE, type Permission, type Plugin, type Policy, type PrefixedTables, type PresenceComponent, type PresenceFunctions, type PresenceMember, type ProtectPublicOptions, type QueryBuilder, type QueryCtx, type RankIndexDefinition, type RankIndexOptions, type RegisteredAction, type RegisteredFunction, type RegisteredLifecycleHook, type RegisteredMigration, type RegisteredMutation, type RegisteredQuery, type RegisteredStream, type RelationBuilder, type RelationDefinition, type RlsOptions, type Role, type Schema, type SchemaExtension, type StorageOperation, type StorageRule, type StorageRuleContext, type StorageRuleDecision, type StorageRulesOptions, type TableBuilder, type TableDefinition, type TerminalKind, type TriggerBuilder, type TriggerDefinition, type TypedDefinePolicyInput, VERSION, type VectorEmbedder, type VectorIndexDefinition, type VectorIndexOptions, type VectorMetric, type VectorizeOptions, type WhereInput, asBucketStorage, bindOrm, bindTableFacade, composePluginMiddleware, createPolicyDsl, defineAggregateIndex, defineComponent, defineEnv, defineMigration, definePermission, definePlugin, definePolicies, definePolicy, definePresence, defineRankIndex, defineRole, defineSchema, defineSchemaExtension, defineStorageRule, defineStorageRules, defineTable, defineVectorIndex, httpAction, httpRoute, httpRouter, initLunora, installPlugins, mask, mergeSchemaExtension, onConnect, onDisconnect, presenceExtension, protectPublic, redactSecrets, rls, serveStorageObject, storageRules };
3302
+ export { DEFAULT_ACTION_CACHE_TTL_MS as ACTION_CACHE_DEFAULT_TTL_MS, ACTION_CACHE_TABLE, type ActionBuilder, type ActionCacheComponent, type ActionCacheContext, type ActionCacheDatabase, type ActionCacheFunctions, type ActionCtx, type AggregateIndexDefinition, type AggregateIndexOptions, type AggregateOp, type ArgsValidator, type Component, type ComponentFunctions, type CreateOptions, DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, DEFAULT_REDACTED_FIELDS as DOCUMENT_HISTORY_REDACTED_FIELDS, DOCUMENT_HISTORY_TABLE, type DataModelInit, type DeferredDeleteFlushResult, type DefineActionCacheOptions, type DefineComponentOptions, type DefineDocumentHistoryOptions, type DefineIdentityOptions, type DefineListArgsConfig, type DefinePluginOptions, type DefinePolicyInput, type DefinePresenceOptions, type DefineStorageRuleInput, type DocumentHistoryComponent, type DocumentHistoryEntry, type DocumentHistoryFunctions, type DurableObjectJurisdiction, type DurableStreamOptions, type EmptyArgs, type EnvAccessor, type EnvKeyFailure, type EnvShape, type ExposeConfig, type ExtendableSchema, type FacadeEntry, type FacadeWriterLike, type FunctionKind, type HttpActionCtx, type HttpActionHandler, type HttpMethod, type HttpRoute, type HttpRouteBuilder, type HttpRouteFactory, type HttpRouteHandlerOptions, type HttpStreamHandlerOptions, type IdentityContract, type IdentityRejectMode, type IdentityValidation, type IndexFieldsByTable, type InferArgs, type InferEnv, type InferIdentity, type InlineAggregateIndexOptions, type InlineRankIndexOptions, type InternalActionBuilder, type InternalMutationBuilder, type InternalQueryBuilder, type LifecycleEvent, type LifecycleHandler, type ListArgsSpec, type ListArgsValidators, type ListArgsValue, type ListFilterOperators, type ListOrderByEntry, type ListWhere, type LunoraBuilders, LunoraEnvError, type LunoraHttpApp, type LunoraHttpEnv, type LunoraRouteHandler, type ManyRelation, type MaskColumns, type MaskContext, type MaskFn, type MaskOptions, type MaskPolicies, type MaskRegistry, type MaskStrategy, type Middleware, type MiddlewareContext, type MiddlewareNext, type MigrationCtx, type MigrationDefinition, type MigrationDocument, type MigrationReader, type MigrationTransform, type MutationBuilder, type MutationCtx, type MutatorDefinition, type OnDeleteAction, type OneRelation, type OrmLike, DEFAULT_TTL_MS as PRESENCE_DEFAULT_TTL_MS, PRESENCE_TABLE, type Permission, type Plugin, type Policy, type PrefixedTables, type PresenceComponent, type PresenceFunctions, type PresenceMember, type ProtectPublicOptions, type QueryBuilder, type QueryCtx, type RankIndexDefinition, type RankIndexOptions, type ReactorHandler, type ReactorOutcome, type ReactorSelect, type RegisteredAction, type RegisteredFunction, type RegisteredLifecycleHook, type RegisteredMigration, type RegisteredMutation, type RegisteredMutator, type RegisteredQuery, type RegisteredReactor, type RegisteredShape, type RegisteredStream, type RelationBuilder, type RelationDefinition, type RlsOptions, type RlsReadRegistry, type Role, type Schema, type SchemaExtension, type ShapeDefinition, type ShapeReadWhereRequest, type ShardInitEvent, type ShardInitHandler, type StorageOperation, type StorageRule, type StorageRuleContext, type StorageRuleDecision, type StorageRulesOptions, type StorageServeAuthorizer, type StorageServeAuthzContext, type TableBuilder, type TableDefinition, type TerminalKind, type TriggerBuilder, type TriggerDefinition, type TypedDefinePolicyInput, VERSION, type VectorEmbedder, type VectorIndexDefinition, type VectorIndexOptions, type VectorMetric, type VectorizeOptions, type WhereInput, actionCacheExtension, allowAll, asBucketStorage, beginDeferredSchedules, bindOrm, bindTableFacade, buildMaskRegistry, buildRlsReadRegistry, cacheKeyFor, clampLimit, composePluginMiddleware, composeShapeReadWhere, createPolicyDsl, createSecrets, defineActionCache, defineAggregateIndex, defineComponent, defineDocumentHistory, defineEnv, defineIdentity, defineListArgs, defineMigration, defineMutator, definePermission, definePlugin, definePolicies, definePolicy, definePresence, defineRankIndex, defineRole, defineSchema, defineSchemaExtension, defineShape, defineStorageRule, defineStorageRules, defineTable, defineVectorIndex, deny, documentHistoryExtension, flushDeferredDeletes, httpAction, httpRoute, httpRouter, indexFieldsFromSchema, initLunora, installPlugins, isDeny, isSafeHeaderValue, mask, mergeSchemaExtension, onConnect, onDisconnect, onQueryChange, onShardInit, presenceExtension, protectPublic, redactSecrets, rls, serveStorageObject, storageRules, toWhereInput, withDeferredDeletes, withDeferredSchedules };