@lunora/server 1.0.0-alpha.84 → 1.0.0-alpha.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
- import { Validator, Infer, ValidatorMap, InferValidatorMap, ColumnValidator, v } from '@lunora/values';
1
+ import { Validator, Infer, v, Id, ValidatorMap, InferValidatorMap, ColumnValidator } from '@lunora/values';
2
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, Secrets, LifecycleEvent, ShardInitEvent, RegisteredLifecycleHook, TableDefinition, RegisteredFunction, VectorIndexDefinition, Schema, AggregateOp, DurableObjectJurisdiction, RelationDefinition, GlobalBackend, OnDeleteAction, SearchLanguage, SearchStrategy, ExternalSourceDefinition, TriggerBuilder, TriggerDefinition, VectorEmbedder, VectorMetric, AggregateIndexDefinition, RankIndexDefinition } from "./types.mjs";
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
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 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 StorageObjectHead, 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
5
  import { LunoraError as LunoraError$1, LunoraErrorCode } from '@lunora/errors';
6
6
  export type { LunoraErrorCode } from '@lunora/errors';
@@ -9,24 +9,6 @@ import { WhereOperators, QueryArgs as QueryArgs$2 } from "./data-model.mjs";
9
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-B-pIn8rE.mjs";
10
10
  export type { d as PolicyContext, e as PolicyDecision, f as PolicyDecisionOf, P as PolicyOperation } from "./packem_shared/types.d-B-pIn8rE.mjs";
11
11
  export { type CronJob, type CronJobsBuilder, type CronScheduleKind, type DailySchedule, type IntervalSchedule, type MonthlySchedule, type WeeklySchedule, cronJobs } from '@lunora/scheduler';
12
- /**
13
- * Make any resolved storage capability bucket-aware so `ctx.storage.bucket(name)`
14
- * always resolves. A `createBucketStorage(...)` result already carries
15
- * `.bucket` / `.bucketName` and is returned as-is; a single `createStorage(...)`
16
- * (or the no-storage stub) is tagged as the `"default"` bucket, where
17
- * `.bucket(name)` is the identity — single-bucket apps address one binding under
18
- * every name.
19
- *
20
- * Lives here rather than in `@lunora/server` because two packages need it and
21
- * neither may depend on the other: `@lunora/server` re-exports it as the runtime
22
- * counterpart `_generated/shard.ts` imports, and `@lunora/runtime` uses it to
23
- * build `ctx.storage` for an HTTP action from the worker's own R2 bindings.
24
- * Inlined into each `dist` by the bundler, so no dependency edge is created.
25
- *
26
- * The input is genuinely heterogeneous (a thunk result cast through `unknown`),
27
- * so the signature is `unknown → unknown`; callers cast the result.
28
- */
29
- declare const asBucketStorage: (raw: unknown) => unknown;
30
12
  /**
31
13
  * Options for the `.stream()` terminal. `durable: true` is shorthand for
32
14
  * `durable: {}` — the runtime only ever sees the object form.
@@ -304,1704 +286,1959 @@ interface DataModelInit<DataModel> {
304
286
  create: (options?: CreateOptions) => LunoraBuilders;
305
287
  }
306
288
  /**
307
- * Entry point for the procedure builder. `dataModel<DM>()` binds the generated
308
- * `DataModel` (phantom for now), and `.create()` yields the public root builders
309
- * plus their `internal*` counterparts.
289
+ * The prefixed tables a single plugin `P` contributes, or an empty map when it
290
+ * ships no schema extension. Mirrors {@link PrefixedTables} at the plugin level
291
+ * so {@link InstalledTables} can fold a tuple of plugins.
310
292
  */
311
- declare const initLunora: {
312
- dataModel: <DataModel>() => DataModelInit<DataModel>;
313
- };
293
+ type ExtensionTablesOf<P> = P extends {
294
+ readonly extension: SchemaExtension<infer X> & {
295
+ readonly key: infer K;
296
+ };
297
+ } ? K extends string ? PrefixedTables<X, K> : Record<never, never> : Record<never, never>;
314
298
  /**
315
- * Build the `ctx.secrets` reader from the worker `env`. `get(name)` resolves
316
- * `env[name].get()` — the `secrets_store_secrets[]` binding of that name. An
317
- * absent or non-Secrets-Store binding throws a directed error pointing at the
318
- * wrangler config; the lookup is lazy, so an unused secret never resolves.
299
+ * Fold a tuple of plugins onto a base table map `T`, accumulating each plugin's
300
+ * auto-prefixed extension tables left-to-right — the type-level mirror of
301
+ * {@link installPlugins} applying `mergeSchemaExtension` for each plugin in turn.
319
302
  */
320
- declare const createSecrets: (env: Record<string, unknown>) => Secrets;
303
+ type InstalledTables<T extends Record<string, TableDefinition>, Plugins extends ReadonlyArray<unknown>> = Plugins extends readonly [infer Head, ...infer Rest] ? InstalledTables<ExtensionTablesOf<Head> & T, Rest> : T;
321
304
  /**
322
- * Wrap a storage facade so it also accepts `deleteAfterCommit(key)`.
323
- *
324
- * Call once per dispatch, outside any read-stamping wrapper: `bucket()` delegates
325
- * to the wrapped facade, so a sub-facade is still stamped by whatever wrapped it.
326
- * @param storage the bucket-aware facade from `asBucketStorage`
305
+ * Union every plugin's `ContextOut` in a tuple the type-level mirror of the
306
+ * `ctx.api.<key>` additions {@link composePluginMiddleware} accumulates as each
307
+ * plugin middleware runs. Independent of the incoming context, which the builder
308
+ * infers at the `.use(...)` site.
327
309
  */
328
- declare const withDeferredDeletes: (storage: unknown) => unknown;
329
- /** How a flush ended, so a caller can assert on it without this module owning a logger. */
330
- interface DeferredDeleteFlushResult {
331
- /** How many keys were attempted. */
332
- attempted: number;
333
- /** Keys whose delete did not happen, with the reason. Empty when everything succeeded. */
334
- failures: {
335
- error: unknown;
336
- key: string;
337
- }[];
310
+ 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;
311
+ /**
312
+ * Schema fragment a plugin contributes. Same shape as the `tables` map
313
+ * passed to `defineSchema`. Optional `vectorIndexes` mirror the top-level
314
+ * `defineSchema` argument so a plugin can ship vector decls alongside its
315
+ * tables.
316
+ */
317
+ interface SchemaExtension<T extends Record<string, TableDefinition> = Record<string, TableDefinition>> {
318
+ /** Stable key identifying the plugin that owns this extension. */
319
+ readonly key: string;
320
+ /**
321
+ * Extension tables, keyed by **bare** name (e.g. `buckets`). At merge time
322
+ * each is auto-prefixed with `key` (`ratelimit_buckets`) so it can't
323
+ * collide with an app table; do **not** namespace manually.
324
+ */
325
+ readonly tables: T;
326
+ /**
327
+ * Optional standalone vector indexes the plugin ships, keyed by index
328
+ * name. Merged into the host schema's `vectorIndexes`; a key collision
329
+ * with the base schema is a hard error (same policy as tables).
330
+ */
331
+ readonly vectorIndexes?: Record<string, VectorIndexDefinition>;
338
332
  }
339
333
  /**
340
- * Delete everything queued on `context.storage`, draining the queue as it goes,
341
- * and report each failure through `context.log`.
342
- *
343
- * Draining first means a second flush of the same dispatch is a no-op rather than
344
- * a second round of deletes.
345
- *
346
- * Never throws. A flush runs after the write committed, so there is no longer
347
- * anything to fail into: rejecting here could only turn a leaked object into a
348
- * failed response for a write that already succeeded. Failures are logged and
349
- * also returned, so a dispatch can call this and ignore the result.
350
- * @param context the dispatch context; a context with no queued deletes is a no-op
334
+ * Build a {@link SchemaExtension}. The `key` is a runtime tag (used for
335
+ * error messages on collision) and a type-level brand.
351
336
  */
352
- declare const flushDeferredDeletes: (context: unknown) => Promise<DeferredDeleteFlushResult>;
337
+ declare const defineSchemaExtension: <T extends Record<string, TableDefinition>>(key: string, options: {
338
+ tables: T;
339
+ vectorIndexes?: Record<string, VectorIndexDefinition>;
340
+ }) => SchemaExtension<T>;
353
341
  /**
354
- * Redact secrets from a free-form message. Masks, in order: any quoted value
355
- * whose contents look like a credential (so a value surfaced as `received string
356
- * "sk_live_…"` is masked even though the surrounding text is not a token); a
357
- * `scheme://user:password@host` URL credential (the password segment); any
358
- * known-prefix credential token wherever it appears, at any length; any value
359
- * following a secret-named key in `KEY=value` / `KEY: value` form; and any
360
- * remaining bare high-entropy ≥24-char token run anywhere in the message.
361
- *
362
- * This is BEST-EFFORT defense-in-depth, NOT a guarantee: a short, prefix-less
363
- * secret under a non-secret-named key (and embedded credentials in shapes not
364
- * enumerated here) can still slip through. Treat it as a backstop — prefer
365
- * structured logging that never serializes raw env/secret fields in the first
366
- * place over relying on post-hoc scrubbing of untrusted data.
367
- *
368
- * Exported because it is independently useful — call it before logging anything
369
- * derived from `env`, request bodies, or thrown errors.
342
+ * A plugin packages an optional schema extension and optional middleware.
343
+ * Both are independently usable: an app can install only the schema (e.g.
344
+ * for plugins that ship background workers but no per-request behavior)
345
+ * or only the middleware (plugins that augment ctx without persistent
346
+ * state).
370
347
  */
371
- declare const redactSecrets: (message: string) => string;
372
- /** One key's validation failure, secrets already redacted out of `message`. */
373
- interface EnvKeyFailure {
374
- /** The env key that failed. */
375
- key: string;
376
- /** Redacted human-readable reason. */
377
- message: string;
348
+ interface Plugin<TExtension extends Record<string, TableDefinition> = Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn> {
349
+ /**
350
+ * Optional schema extension. Apps install via
351
+ * `defineSchema(...).extend(plugin.extension)`.
352
+ */
353
+ readonly extension?: SchemaExtension<TExtension>;
354
+ /** Stable key identifying the plugin. Matches `extension.key` when set. */
355
+ readonly key: string;
356
+ /**
357
+ * Optional middleware. Users attach with `c.query.use(plugin.middleware)`.
358
+ * The middleware can extend `ctx`; convention is to attach helpers under
359
+ * `ctx.api.<key>`, e.g.
360
+ *
361
+ * ```ts
362
+ * middleware: ({ ctx, next }) =>
363
+ * next({ ctx: { api: { ...ctx.api, ratelimit: api } } })
364
+ * ```
365
+ */
366
+ readonly middleware?: Middleware<TContextIn, TContextOut>;
367
+ }
368
+ /** Options to {@link definePlugin}. */
369
+ interface DefinePluginOptions<TExtension extends Record<string, TableDefinition>, TContextIn, TContextOut> {
370
+ extension?: SchemaExtension<TExtension>;
371
+ middleware?: Middleware<TContextIn, TContextOut>;
378
372
  }
379
373
  /**
380
- * Thrown when one or more env keys are missing or fail validation. Carries the
381
- * structured list of `failures` (each with the offending `key`) so callers can
382
- * react programmatically; `message` is the joined, secret-redacted summary.
383
- *
384
- * Named export only (no default) per the repo export convention.
374
+ * Call signatures for {@link definePlugin}. When `extension` is supplied the
375
+ * returned plugin's `extension` is typed as PRESENT (not `?`), so the
376
+ * canonical install pattern `defineSchema(...).extend(plugin.extension)`
377
+ * typechecks without a non-null assertion — the shape every scaffold template
378
+ * ships. The bare-options signature keeps `extension` optional for plugins
379
+ * that carry only middleware.
385
380
  */
386
- declare class LunoraEnvError extends LunoraError$1 {
387
- readonly failures: ReadonlyArray<EnvKeyFailure>;
388
- constructor(failures: ReadonlyArray<EnvKeyFailure>);
381
+ interface DefinePluginFunction {
382
+ <TExtension extends Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn>(key: string, options: DefinePluginOptions<TExtension, TContextIn, TContextOut> & {
383
+ extension: SchemaExtension<TExtension>;
384
+ }): Plugin<TExtension, TContextIn, TContextOut> & {
385
+ readonly extension: SchemaExtension<TExtension>;
386
+ };
387
+ <TExtension extends Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn>(key: string, options: DefinePluginOptions<TExtension, TContextIn, TContextOut>): Plugin<TExtension, TContextIn, TContextOut>;
389
388
  }
390
- /** A record of `v.*` validators describing the expected env shape. */
391
- type EnvShape = Record<string, Validator>;
392
389
  /**
393
- * The typed output of {@link defineEnv}. Optional validators (`v.optional(...)`)
394
- * become optional keys; everything else is required. Mirrors how `InferArgs`
395
- * derives an args object from a validator map.
390
+ * Package a schema extension + middleware as a reusable plugin. Either
391
+ * field is optional `definePlugin("foo", {})` is valid but degenerate.
396
392
  */
397
- 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]>; };
393
+ declare const definePlugin: DefinePluginFunction;
398
394
  /**
399
- * The accessor returned by {@link defineEnv}. A typed view over an `env` object
400
- * plus a `.parse(env)` escape hatch that validates every key eagerly.
395
+ * Bundle of registered functions a {@link Component} ships. Keys are the
396
+ * function's local name (e.g. `check`, `reset`); the registered function
397
+ * value carries its own kind / args / handler.
401
398
  *
402
- * Call the accessor with the worker's `env` to get the typed, lazily-validated
403
- * proxy: `const config = defineEnv({ … }); const { PORT } = config(env);`.
399
+ * Users re-export from their own lunora module so codegen picks them up:
400
+ *
401
+ * ```ts
402
+ * // lunora/ratelimit.ts
403
+ * import { ratelimit } from "@vendor/ratelimit-component";
404
+ * export const { check, reset } = ratelimit.functions;
405
+ * // Emits as `ratelimit:check` / `ratelimit:reset` in the generated `api`.
406
+ * ```
407
+ *
408
+ * Codegen follows the re-export back to the bundled `query/mutation/action`
409
+ * call (property access or destructuring both work), so the functions land in
410
+ * the generated `api` under the re-exporting file's namespace.
404
411
  */
405
- interface EnvAccessor<S extends EnvShape> {
406
- /** Validate every key eagerly and return the typed, plain (non-proxy) object. Use for fail-fast-at-boot. */
407
- parse: (env: unknown) => InferEnv<S>;
408
- /** Lazily-validated, per-key-cached typed view over `env`. Keys are validated on first access. */
409
- (env: unknown): InferEnv<S>;
412
+ type ComponentFunctions = Readonly<Record<string, RegisteredFunction<any, any, FunctionKind>>>;
413
+ /**
414
+ * Component = {@link Plugin} with a bundle of registered functions. The
415
+ * extension + middleware + functions are independent: a component can ship
416
+ * functions without a schema (e.g. a stateless utility), or a schema
417
+ * without functions (e.g. shared table definitions), and any combination.
418
+ */
419
+ interface Component<TExtension extends Record<string, TableDefinition> = Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn, F extends ComponentFunctions = ComponentFunctions> extends Plugin<TExtension, TContextIn, TContextOut> {
420
+ readonly functions: F;
421
+ }
422
+ interface DefineComponentOptions<TExtension extends Record<string, TableDefinition>, TContextIn, TContextOut, F extends ComponentFunctions> extends DefinePluginOptions<TExtension, TContextIn, TContextOut> {
423
+ /** Registered functions the component ships. Keys are the function's local name. */
424
+ functions?: F;
410
425
  }
411
426
  /**
412
- * Define a typed, validated accessor over a Worker's `env`. Pass a record of
413
- * `v.*` validators; receive an accessor that validates lazily per key (cached
414
- * per `env` identity) and infers its output type from the validators.
427
+ * Convenience wrapper around {@link definePlugin} that also bundles a set
428
+ * of registered functions. The resulting `component.functions` object is a
429
+ * record of `name registered query/mutation/action`; consumers
430
+ * re-export entries so codegen discovers them as user functions:
415
431
  *
416
432
  * ```ts
417
- * import { defineEnv, v } from "@lunora/server";
418
- *
419
- * const config = defineEnv({
420
- * STRIPE_KEY: v.string(),
421
- * PORT: v.optional(v.number()),
422
- * });
423
- *
424
- * export default {
425
- * fetch(request, env) {
426
- * const { STRIPE_KEY, PORT } = config(env); // STRIPE_KEY: string, PORT?: number
427
- * // …
433
+ * export const ratelimit = defineComponent("ratelimit", {
434
+ * // Bare `buckets` merges in as `ratelimit_buckets`.
435
+ * extension: defineSchemaExtension("ratelimit", { tables: { buckets } }),
436
+ * middleware: ({ ctx, next }) => next({ ctx: { ...ctx, ratelimit: api(ctx) } }),
437
+ * functions: {
438
+ * check: query.input({ key: v.string() }).query(async ({ ctx, args }) => ...),
439
+ * reset: mutation.input({ key: v.string() }).mutation(async ({ ctx, args }) => ...),
428
440
  * },
429
- * };
441
+ * });
430
442
  * ```
431
443
  *
432
- * Throws {@link LunoraEnvError} (secrets redacted) when a key is missing or
433
- * invalid lazily on first access of that key, or eagerly via `config.parse(env)`.
444
+ * Re-exporting an entry (by property access or destructuring) is enough for
445
+ * codegen to discover it in the host app's namespace the discovery resolver
446
+ * chases the re-export back to the bundled registration call.
434
447
  */
435
- declare const defineEnv: <S extends EnvShape>(shape: S) => EnvAccessor<S>;
436
- declare class LunoraError extends LunoraError$1 {
437
- constructor(code: LunoraErrorCode, message?: string, data?: unknown);
438
- }
448
+ 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>;
439
449
  /**
440
- * Minimal structural writer the facade binds over. Declared with **method**
441
- * syntax (not arrow properties) so a more-specifically-typed writer — both
442
- * `@lunora/do`'s `DatabaseWriterLike` and the RLS middleware's wrapped writer —
443
- * stays assignable under bivariant parameter checking. That is the whole reason
444
- * the shared helper can serve both callers, hence the rule exemption.
450
+ * Map every key `K` of an extension's table map `X` to its auto-prefixed name
451
+ * `${Key}_${K}`. Mirrors the runtime prefixing in {@link mergeSchemaExtension}
452
+ * so the typed `.extend(...)` chain reflects the real merged table names.
445
453
  */
446
- interface FacadeWriterLike {
447
- aggregate(tableName: string, options: unknown): Promise<unknown>;
448
- count(tableName: string, where?: unknown): Promise<number>;
449
- delete(id: string, expectedTable?: string, options?: {
450
- hard?: boolean;
451
- }): Promise<void>;
452
- deleteMany?(ids: ReadonlyArray<string>, options?: {
453
- limit?: number;
454
- }, expectedTable?: string): Promise<{
455
- deleted: number;
456
- }>;
457
- deleteWhere?(tableName: string, where: Record<string, unknown>, options?: {
458
- limit?: number;
459
- }): Promise<{
460
- deleted: number;
461
- }>;
462
- findFirst(tableName: string, args?: unknown): Promise<unknown>;
463
- findFirstOrThrow(tableName: string, args?: unknown): Promise<unknown>;
464
- findMany(tableName: string, args?: unknown): Promise<unknown>;
465
- get(id: string, expectedTable?: string): Promise<unknown>;
466
- groupBy(tableName: string, options: unknown): Promise<unknown>;
467
- insert(tableName: string, document: Record<string, unknown>): Promise<string>;
468
- insertMany?(tableName: string, documents: ReadonlyArray<Record<string, unknown>>, options?: {
469
- limit?: number;
470
- skipDuplicates?: boolean;
471
- }): Promise<(string | null)[]>;
472
- patch(id: string, patch: Record<string, unknown>, expectedTable?: string): Promise<void>;
473
- patchMany?(patches: ReadonlyArray<{
474
- id: string;
475
- patch: Record<string, unknown>;
476
- }>, options?: {
477
- limit?: number;
478
- }, expectedTable?: string): Promise<{
479
- patched: number;
480
- }>;
481
- patchWhere?(tableName: string, args: {
482
- patch: Record<string, unknown>;
483
- where: Record<string, unknown>;
484
- }, options?: {
485
- limit?: number;
486
- }): Promise<{
487
- patched: number;
488
- }>;
489
- query(tableName: string): {
490
- withGeoIndex(indexName: string, build: (q: unknown) => unknown): unknown;
491
- withSearchIndex(indexName: string, search: (q: unknown) => unknown): unknown;
492
- };
493
- rank(tableName: string, indexName: string, options: unknown): Promise<unknown>;
494
- rankPage(tableName: string, indexName: string, options?: unknown): Promise<unknown>;
495
- replace(id: string, document: Record<string, unknown>, expectedTable?: string): Promise<void>;
496
- restore?(id: string, expectedTable?: string): Promise<void>;
497
- }
498
- /** The per-table accessor object returned for the `ctx.db` table form. */
499
- interface FacadeEntry {
500
- aggregate: (options: unknown) => Promise<unknown>;
501
- count: (where?: unknown) => Promise<number>;
502
- delete: (id: string) => Promise<void>;
503
- deleteMany: {
504
- (ids: ReadonlyArray<string>, options?: {
505
- limit?: number;
506
- }): Promise<{
507
- deleted: number;
508
- }>;
509
- (args: {
510
- limit?: number;
511
- where: Record<string, unknown>;
512
- }): Promise<{
513
- deleted: number;
514
- }>;
515
- };
516
- /** `true` when at least one row matches `where` (or any row exists when omitted). Honors RLS like `findFirst`. */
517
- exists: (where?: unknown) => Promise<boolean>;
518
- findFirst: (args?: unknown) => Promise<unknown>;
519
- findFirstOrThrow: (args?: unknown) => Promise<unknown>;
520
- findMany: (args?: unknown) => Promise<unknown>;
521
- get: (id: string) => Promise<unknown>;
522
- groupBy: (options: unknown) => Promise<unknown>;
523
- /** Physically remove a row (and physically cascade), bypassing `.softDelete()`. */
524
- hardDelete: (id: string) => Promise<void>;
525
- insert: (document: Record<string, unknown>, options?: FacadeInsertOptions) => Promise<null | string>;
526
- /**
527
- * Insert many documents into this table in one call. With
528
- * `{ skipDuplicates: true }`, UNIQUE breaches resolve to `null` for that row
529
- * instead of failing the batch. The typed facade narrows the return to
530
- * `Id<T>[]` when skipDuplicates is not requested.
531
- */
532
- insertMany: (documents: ReadonlyArray<Record<string, unknown>>, options?: {
533
- limit?: number;
534
- skipDuplicates?: boolean;
535
- }) => Promise<(string | null)[]>;
536
- patch: (id: string, patch: Record<string, unknown>) => Promise<void>;
537
- patchMany: {
538
- (patches: ReadonlyArray<{
539
- id: string;
540
- values: Record<string, unknown>;
541
- }>, options?: {
542
- limit?: number;
543
- }): Promise<{
544
- patched: number;
545
- }>;
546
- (args: {
547
- limit?: number;
548
- values: Record<string, unknown>;
549
- where: Record<string, unknown>;
550
- }): Promise<{
551
- patched: number;
552
- }>;
553
- };
554
- rank: (indexName: string, options: unknown) => Promise<unknown>;
555
- rankPage: (indexName: string, options?: unknown) => Promise<unknown>;
556
- replace: (id: string, document: Record<string, unknown>) => Promise<void>;
557
- /** Un-soft-delete a row: clears the `.softDelete()` marker (by-id, so it reaches a row list reads hide). */
558
- restore: (id: string) => Promise<void>;
559
- /** Insert when no row matches `target`, else patch the match. Composes `findFirst` + `insert`/`patch`, so RLS applies to each step. */
560
- upsert: (args: UpsertArgs) => Promise<UpsertResult>;
561
- /** Sequential `upsert` over many rows sharing one `target`; returns one result per input row in order. */
562
- upsertMany: (args: UpsertManyArgs) => Promise<UpsertResult[]>;
563
- withGeoIndex: (indexName: string, build: (q: unknown) => unknown) => unknown;
564
- withSearchIndex: (indexName: string, search: (q: unknown) => unknown) => unknown;
565
- }
566
- /** Options accepted by the per-table `insert` accessor. */
567
- interface FacadeInsertOptions {
568
- /**
569
- * When `true`, a UNIQUE-constraint breach is swallowed: the insert becomes a
570
- * silent no-op and resolves to `null` instead of throwing a `CONFLICT`. Any
571
- * other error still propagates. Mirrors better-drizzle's `create({ skipDuplicates })`.
572
- */
573
- skipDuplicates?: boolean;
574
- }
575
- /** The conflict target for `upsert`/`upsertMany`: one field name or a tuple of them. */
576
- type UpsertTarget = ReadonlyArray<string> | string;
577
- /** Argument to the per-table `upsert` accessor. */
578
- interface UpsertArgs {
579
- /** Document inserted when no existing row matches the `target`. */
580
- create: Record<string, unknown>;
581
- /** Field(s) — typically a `.unique()` column or unique index — used to look up an existing row. */
582
- target: UpsertTarget;
583
- /** Patch applied when an existing row matches the `target`. Defaults to `create`. */
584
- update?: Record<string, unknown>;
585
- }
586
- /** Result of an `upsert`: the row's id and whether it was freshly inserted (`true`) or updated (`false`). */
587
- interface UpsertResult {
588
- created: boolean;
589
- id: string;
590
- }
591
- /** Argument to the per-table `upsertMany` accessor — a shared `target` plus per-row create/update payloads. */
592
- interface UpsertManyArgs {
593
- rows: ReadonlyArray<{
594
- create: Record<string, unknown>;
595
- update?: Record<string, unknown>;
596
- }>;
597
- target: UpsertTarget;
598
- }
454
+ type PrefixedTables<X extends Record<string, TableDefinition>, Key extends string> = { [K in keyof X as K extends string ? `${Key}_${K}` : K]: X[K]; };
599
455
  /**
600
- * Bind a structural writer to one table, producing its `ctx.db` table accessor.
456
+ * Merge a {@link SchemaExtension} into an existing schema. Returns a new
457
+ * schema object — never mutates the input.
601
458
  *
602
- * The by-id accessors (`get`/`delete`/`patch`/`replace`) forward the bound
603
- * `tableName` as `expectedTable` so the underlying writer scopes its id lookup
604
- * to this table. Without it, a branded `Id<"posts">` carrying another table's
605
- * id would resolve cross-table (the writer probes every table by id), letting
606
- * `ctx.db.posts.get(foreignId)` read or `.delete`/`.patch`/`.replace`
607
- * mutate — a row in an unrelated table (IDOR). Writers that ignore the second
608
- * argument keep their previous global behaviour; the scoping is opt-in via this
609
- * forwarded name.
459
+ * Extension tables are auto-namespaced: each bare table name is prefixed with
460
+ * the extension `key` (`buckets` `ratelimit_buckets`), Convex-Components
461
+ * style, and every intra-extension reference (relation targets, aggregate /
462
+ * rank index `on`, standalone vector index `table`) is rewritten to match.
463
+ * References to base/app tables are left untouched.
464
+ *
465
+ * Because each extension lives in its own `key` namespace, app↔component
466
+ * collisions are impossible. The only remaining hard error is two extensions
467
+ * sharing the same `key` and producing the same prefixed table (or vector
468
+ * index) name — silent shadow would let one plugin hijack another's data.
469
+ *
470
+ * Re-runs {@link validateIndexFields} against the merged table set before
471
+ * returning: `defineSchema` only validates the tables it was called with, so
472
+ * without this an extension-contributed index with a typo'd/out-of-shape
473
+ * field (or a duplicate name within one kind) would never be checked at all.
474
+ * Re-validating the whole merged set (base + prefixed extension tables) is
475
+ * cheap and idempotent for the base tables, which already passed this same
476
+ * check when the base schema was built. Both callers of this function —
477
+ * `withExtend.extend()` (`./schema`) and `installPlugins` (below) — get the
478
+ * re-validation for free from this single call site (plan 258 §4/§9 Q3).
610
479
  */
611
- declare const bindTableFacade: (writer: FacadeWriterLike, tableName: string) => FacadeEntry;
612
- /** The kitcn-style `ctx.orm` namespace over a per-table facade map. */
613
- interface OrmLike {
614
- delete: (table: string, id: string) => Promise<void>;
615
- insert: (table: string) => {
616
- values: (document: Record<string, unknown>) => Promise<null | string>;
617
- };
618
- query: Record<string, FacadeEntry>;
619
- replace: (table: string, id: string) => {
620
- with: (document: Record<string, unknown>) => Promise<void>;
621
- };
622
- update: (table: string, id: string) => {
623
- set: (values: Record<string, unknown>) => Promise<void>;
624
- };
625
- }
626
- /** Build `ctx.orm` over a per-table facade map (table name → FacadeEntry). */
627
- declare const bindOrm: (facade: Record<string, FacadeEntry>) => OrmLike;
628
- /** HTTP verbs the typed {@link httpRoute} builder can bind to. */
629
- type HttpMethod = "DELETE" | "GET" | "HEAD" | "OPTIONS" | "PATCH" | "POST" | "PUT";
480
+ declare const mergeSchemaExtension: <T extends Record<string, TableDefinition>, X extends Record<string, TableDefinition>, Key extends string = string>(base: Schema<T>, extension: SchemaExtension<X> & {
481
+ readonly key: Key;
482
+ }) => Schema<PrefixedTables<X, Key> & T>;
630
483
  /**
631
- * Context handed to an HTTP action handler. A narrower view of {@link ActionContext}:
632
- * HTTP actions run in the worker (the "action runtime"), separate from the
633
- * transactional store, so there is no direct `db` / `vectors` surface reach the
634
- * data layer through `runQuery` / `runMutation` / `runAction`, which forward to
635
- * the owning shard. `db`'s absence is principled: an HTTP handler is not
636
- * transactional.
484
+ * Install several plugins' schema extensions in one call the one-shot
485
+ * counterpart to chaining `defineSchema(...).extend(a).extend(b)`. Plugins
486
+ * without an `extension` (middleware-only) are skipped; tables from those that
487
+ * do are auto-prefixed and reference-rewritten exactly as
488
+ * {@link mergeSchemaExtension} does for a single `.extend(...)`.
637
489
  *
638
- * `scheduler` and `storage` ARE present, because neither needs the shard — the
639
- * scheduler talks to the scheduler DO, and R2 is a worker binding an HTTP
640
- * handler can reach where an action does. Both are optional: each exists only
641
- * when the app declared the matching capability (`.scheduler(...)` /
642
- * `.storage(...)`) on the generated app builder.
490
+ * ```ts
491
+ * const schema = installPlugins(defineSchema({ todos }), [ratelimit, audit]);
492
+ * // todos + ratelimit_* + audit_*
493
+ * ```
643
494
  *
644
- * Omitting them was costly out of proportion to the gap. Without `scheduler`,
645
- * "receive webhook enqueue the real work return 200" the shape HTTP
646
- * actions exist for forced a hop through a mutation plus a closed allow-list
647
- * of target strings, because a function reference cannot cross the RPC boundary
648
- * and a free-form target on an unauthenticated endpoint is a "call any internal
649
- * function" primitive. Without `storage`, any helper the ctx was threaded into
650
- * had to be typed for its storage-touching branch, so a handler was barred from
651
- * the helper even on the branches that never went near storage.
495
+ * Pair it with {@link composePluginMiddleware} to attach every plugin's
496
+ * middleware in a single `.use(...)`, so installing N plugins is two calls
497
+ * rather than N `.extend(...)` + N `.use(...)`.
652
498
  */
653
- type HttpActionCtx = Pick<ActionCtx, "auth" | "cache" | "fetch" | "runAction" | "runMutation" | "runQuery"> & {
654
- readonly scheduler?: ActionCtx["scheduler"];
655
- readonly storage?: ActionCtx["storage"];
656
- };
657
- /** A raw handler wrapped by {@link httpAction}. Receives the raw request, returns the raw response. */
658
- type HttpActionHandler = (context: HttpActionCtx, request: Request) => Promise<Response> | Response;
499
+ 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>>;
659
500
  /**
660
- * The hono {@link https://hono.dev | Hono} environment used by {@link httpRouter}.
661
- * The runtime injects the per-request {@link HttpActionCtx} on the private
662
- * `__lunoraCtx` binding; the router's lifting middleware promotes it to
663
- * `c.var.lunora` so handlers can read it as a typed variable.
501
+ * Compose every plugin's middleware into a single middleware you attach with one
502
+ * `.use(...)`. Plugins without middleware (schema-only) are skipped; the rest run
503
+ * in array order, each seeing the context the previous one widened, so the final
504
+ * `next({ ctx })` the builder receives carries every plugin's `ctx.api.<key>`
505
+ * additions. Equivalent to `.use(a.middleware).use(b.middleware)…` but as one
506
+ * value, the middleware sibling of {@link installPlugins}.
507
+ *
508
+ * `ContextIn` is left free so the builder infers it from the context at the
509
+ * `.use(...)` site; the result type widens it by the union of the plugins'
510
+ * outputs.
664
511
  */
665
- interface LunoraHttpEnv {
666
- Bindings: Record<string, unknown> & {
667
- __lunoraCtx?: HttpActionCtx;
668
- };
669
- Variables: {
670
- lunora: HttpActionCtx;
512
+ 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>;
513
+ /** Options for `.vectorize(field, opts)` (DSL Shape A). */
514
+ interface VectorizeOptions<Shape extends Record<string, Validator> = Record<string, Validator>> {
515
+ dimensions: number;
516
+ embed: VectorEmbedder;
517
+ /** Logical index name; must match a `[[vectorize]]` binding in wrangler. */
518
+ index: string;
519
+ /** Fields mirrored into Vectorize metadata for filtering. */
520
+ metadata?: ReadonlyArray<keyof Shape & string>;
521
+ metric: VectorMetric;
522
+ }
523
+ /** A `one` (many-to-one) relation descriptor; phantom `Target` carries the target table name. */
524
+ interface OneRelation<Target extends string = string> extends RelationDefinition {
525
+ readonly __target?: Target;
526
+ readonly kind: "one";
527
+ }
528
+ /** A `many` (one-to-many) relation descriptor; phantom `Target` carries the target table name. */
529
+ interface ManyRelation<Target extends string = string> extends RelationDefinition {
530
+ readonly __target?: Target;
531
+ readonly kind: "many";
532
+ }
533
+ /** The `r` argument passed to `.relations((r) => …)`. */
534
+ interface RelationBuilder {
535
+ /** One-to-many: the FK `field` lives on the target table, matching this table's `references` (default `_id`). */
536
+ many: <Target extends string>(table: Target, options: {
537
+ field: string;
538
+ references?: string;
539
+ }) => ManyRelation<Target>;
540
+ /** Many-to-one: the FK `field` lives on this table, pointing at `table`.`references` (default `_id`). */
541
+ one: <Target extends string>(table: Target, options: {
542
+ field: string;
543
+ onDelete?: OnDeleteAction;
544
+ references?: string;
545
+ }) => OneRelation<Target>;
546
+ }
547
+ /**
548
+ * Options for the inline `.aggregateIndex(name, opts)` builder. `op` defaults to
549
+ * `count` so `aggregateIndex("byUser", { by: ["userId"] })` is a single-line
550
+ * `COUNT(*) GROUP BY userId` accelerator.
551
+ */
552
+ interface InlineAggregateIndexOptions<Shape extends Record<string, Validator> = Record<string, Validator>> {
553
+ /** Group keys; counter rows are one per distinct tuple. Omitted = single-row aggregate over the whole table. */
554
+ by?: ReadonlyArray<keyof Shape & string>;
555
+ /** The column the reducer applies to. Required for `sum`/`min`/`max`/`avg`; ignored for `count`. */
556
+ field?: keyof Shape & string;
557
+ /** Reducer (default `count`). */
558
+ op?: AggregateOp;
559
+ /** Static predicate baked into the counter — only matching rows are aggregated. */
560
+ where?: Record<string, unknown>;
561
+ }
562
+ /**
563
+ * Options for the inline `.rankIndex(name, opts)` builder. `sortBy` is required;
564
+ * accepts either an array of `{ field, direction }` keys, or the shorthand
565
+ * `["field"]` (asc) / `{ field: "desc" }` map entries. `partitionBy` scopes the
566
+ * rank — omitted ⇒ one global rank over the whole table.
567
+ */
568
+ interface InlineRankIndexOptions<Shape extends Record<string, Validator> = Record<string, Validator>> {
569
+ /** Columns that scope each ranking; omitted ⇒ one global rank. */
570
+ partitionBy?: ReadonlyArray<keyof Shape & string>;
571
+ /** Ordered sort keys driving the rank. Required. */
572
+ sortBy: ReadonlyArray<{
573
+ direction?: "asc" | "desc";
574
+ field: keyof Shape & string;
575
+ }>;
576
+ /** Static predicate baked into the index; only matching rows enter. */
577
+ where?: Record<string, unknown>;
578
+ }
579
+ interface TableBuilder<Shape extends Record<string, Validator> = Record<string, Validator>> extends TableDefinition<Shape> {
580
+ /** Declare an aggregate (counter/sum/…) maintained by triggers for O(1) reads. */
581
+ aggregateIndex: (name: string, options?: InlineAggregateIndexOptions<Shape>) => TableBuilder<Shape>;
582
+ /**
583
+ * Stamp every row with `_commitSeq` — a per-shard integer, allocated once
584
+ * per mutation and strictly increasing in **commit order**, refreshed on
585
+ * every write to the row (insert, patch, replace, and the marker flip a
586
+ * `.softDelete()` performs).
587
+ *
588
+ * `_creationTime` is wall-clock and therefore cannot order commits: the
589
+ * clock is read when the handler runs, the write lands when the transaction
590
+ * commits, and nothing ties those instants together. A changefeed paging on
591
+ * `_creationTime` can skip a row permanently. Paging on `_commitSeq`
592
+ * (`where: { _commitSeq: { gt: cursor } }, orderBy: ["_commitSeq"]`) cannot.
593
+ *
594
+ * It orders COMMITS, not rows: one mutation's rows share a value. A bounded
595
+ * page can therefore end mid-group, so a consumer must checkpoint at a
596
+ * sequence it has seen the whole of, never at the last row of a full page.
597
+ * An action's writes are the exception to the grouping — they commit
598
+ * independently, so each gets its own sequence.
599
+ *
600
+ * Ordered, not contiguous — read a gap as "nothing to see", never as loss.
601
+ * Per-shard, not global: two shards allocate independently, so a cursor is
602
+ * only meaningful against the shard it came from. Rejected on `.global()`
603
+ * tables, which have no shard-local transaction to allocate inside.
604
+ *
605
+ * **A hard delete is invisible to the feed.** The sequence lives on the row,
606
+ * so a physically removed row takes it along: the row stops appearing, but
607
+ * no event says it went away. Pair `.commitOrdered()` with `.softDelete()`
608
+ * when the feed must observe deletes — the tombstone flip is an UPDATE, so
609
+ * it advances the sequence and pages through like any other change.
610
+ */
611
+ commitOrdered: () => TableBuilder<Shape>;
612
+ /**
613
+ * Mark this table as written outside Lunora's discoverable insert path —
614
+ * by an adapter, a migration, or framework middleware (e.g. `@lunora/auth`'s
615
+ * better-auth tables, `@lunora/ratelimit`'s store). Advisor insert-path lints
616
+ * (`table_without_insert`) then skip it instead of flagging the absent
617
+ * `ctx.db.insert(...)`.
618
+ */
619
+ externallyManaged: () => TableBuilder<Shape>;
620
+ /**
621
+ * Declare a geospatial index over a `v.geoPoint()` column. The runtime keeps
622
+ * a geohash companion so `withGeoIndex(name, q => q.near(point, radius))` and
623
+ * `.within(bbox)` resolve as a geohash-prefix range scan + Haversine
624
+ * refine/sort. `options.precision` tunes the geohash length (default 9).
625
+ */
626
+ geoIndex: (name: string, options: {
627
+ field: keyof Shape & string;
628
+ precision?: number;
629
+ }) => TableBuilder<Shape>;
630
+ /**
631
+ * Mark this table as global (cross-shard). Backed by **D1** by default;
632
+ * pass `{ backend: "hyperdrive" }` to store it in a Postgres/MySQL database
633
+ * via Cloudflare Hyperdrive (PlanetScale, Neon, …) instead. Either way the
634
+ * table stays reactive — live queries re-run on write.
635
+ */
636
+ global: (options?: {
637
+ backend?: GlobalBackend;
638
+ }) => TableBuilder<Shape>;
639
+ /** Add a secondary index. */
640
+ index: (name: string, fields: ReadonlyArray<(keyof Shape & string) | (typeof SYSTEM_INDEX_FIELDS)[number]>, options?: {
641
+ unique?: boolean;
642
+ }) => TableBuilder<Shape>;
643
+ /**
644
+ * Declare this table EPHEMERAL — state the shard rebuilds rather than
645
+ * remembers.
646
+ *
647
+ * A memory table is a full `ctx.db` table: indexes, `where`, `orderBy`,
648
+ * pagination, relations, live queries. What it is not is durable. Its rows
649
+ * are wiped the moment the Durable Object is reconstructed — which happens
650
+ * on every eviction, and a WebSocket-hibernating shard is evicted often — so
651
+ * a memory table holds only what can be derived again: presence and cursors,
652
+ * a live participant list, a rate-limit window, an actor's scratch state.
653
+ *
654
+ * Pair it with `onShardInit` to rebuild whatever the app needs present.
655
+ * The framework guarantees the ordering: every memory table is cleared, and
656
+ * every init hook has run, before any handler can read one. Without a hook a
657
+ * memory table simply comes back empty, which is a correct state for
658
+ * presence and a wrong one for a cache someone is treating as authoritative.
659
+ *
660
+ * **On Cloudflare the rows still transit the DO's SQLite.** workerd exposes
661
+ * exactly one SQL handle and no memory-backed database, so `.memory()` buys
662
+ * the LIFETIME (and skips the CDC changelog, so an append-heavy presence
663
+ * table does not grow the op-log), not the write. Treat it as "state I am
664
+ * happy to lose", not as "state that is free to write" — see
665
+ * `PlatformCapabilities.memoryTables`, rated `emulated` for exactly this
666
+ * reason.
667
+ *
668
+ * Rejected alongside `.global()` (a D1 table is not this shard's to clear),
669
+ * `.commitOrdered()` (a sequence that resets is not a sequence), and
670
+ * `.source()` (an externally-materialized table is not ours to wipe).
671
+ */
672
+ memory: () => TableBuilder<Shape>;
673
+ /**
674
+ * Name the column holding the owning user's id, so "only the owner sees these
675
+ * rows" is declared once here rather than restated in every shape.
676
+ *
677
+ * A `defineShape({ table, owner: true })` over this table derives its predicate
678
+ * from the field: the subscriber's verified `ctx.auth.userId` must match, and an
679
+ * anonymous subscriber is denied. Pairs naturally with `.shardBy(field)` on the
680
+ * same column — the shard key routes the storage, `ownedBy` states who the rows
681
+ * belong to — but the two are independent and either can be used alone.
682
+ *
683
+ * This is a *shape* declaration, not an RLS policy: it narrows what a shape
684
+ * replicates. Guarding procedure reads/writes is still `rls(...)`'s job.
685
+ */
686
+ ownedBy: (field: keyof Shape & string) => TableBuilder<Shape>;
687
+ /**
688
+ * Opt this table OUT of secure-by-default RLS. Under a schema marked
689
+ * `.rls("required")`, every table is protected (the write path denies raw,
690
+ * non-RLS `ctx.db` access); calling `.public()` exempts this one table so a
691
+ * plain `query`/`mutation` may read/write it without an RLS policy. No effect
692
+ * when the schema does not require RLS.
693
+ */
694
+ public: () => TableBuilder<Shape>;
695
+ /**
696
+ * Declare a rank index (sorted companion table, btree-backed) for
697
+ * `rank(row)` / `rankPage()` reads in O(log n). See {@link RankIndexDefinition}.
698
+ */
699
+ rankIndex: (name: string, options: InlineRankIndexOptions<Shape>) => TableBuilder<Shape>;
700
+ /** Declare relations to other tables, loaded via `findMany({ with })`. */
701
+ relations: (build: (r: RelationBuilder) => Record<string, RelationDefinition>) => TableBuilder<Shape>;
702
+ /**
703
+ * Add a full-text search index over `field`, queried with
704
+ * `.withSearchIndex(name, q => q.search(field, term))`. `field` may be a
705
+ * dot-separated path into a nested object (`"properties.name"`).
706
+ * `filterFields` (at most 16) lists the columns `.eq()` may narrow by inside
707
+ * the search. `language` selects the text analysis (accent folding always,
708
+ * plus that language's stopwords). `staged: true` skips the migration-time
709
+ * backfill on a large existing table — pre-existing rows stay unsearchable
710
+ * until `__lunora_admin__:backfillSearch` is run against the deployment. `strategy: "native"` uses the engine's
711
+ * own full-text index where it has one (Postgres) — faster on large corpora,
712
+ * at the cost of the engine ranking rather than the shared scorer.
713
+ */
714
+ searchIndex: (name: string, options: {
715
+ field: string;
716
+ filterFields?: ReadonlyArray<string>;
717
+ language?: SearchLanguage;
718
+ staged?: boolean;
719
+ strategy?: SearchStrategy;
720
+ }) => TableBuilder<Shape>;
721
+ /** Route storage by the named field — one DO per distinct value. */
722
+ shardBy: (field: keyof Shape & string) => TableBuilder<Shape>;
723
+ /**
724
+ * Turn on soft delete. Adds a nullable timestamp column (`options.field`,
725
+ * default `deletedAt`) and changes `ctx.db.<table>.delete()` to **set** it
726
+ * instead of removing the row; `onDelete: "cascade"` children are recursively
727
+ * soft-deleted too. **List reads** (`findMany`/`findFirst`/`query()`/`count`/
728
+ * `aggregate`/relation loads) then hide soft-deleted rows unless they pass
729
+ * `includeDeleted: true`; by-id `get`/`patch`/`replace` and the new
730
+ * `restore()` still address the row directly. `hardDelete()` physically
731
+ * removes it (cascading as a real delete). Note: `includeDeleted` is a read
732
+ * scope, not access control — anyone who can run the read can set it; a unique
733
+ * index still rejects a new row that collides with a soft-deleted one (the row
734
+ * physically persists).
735
+ */
736
+ softDelete: (options?: {
737
+ field?: string;
738
+ }) => TableBuilder<Shape>;
739
+ /**
740
+ * Materialize this table from an external Postgres/MySQL behind Cloudflare
741
+ * Hyperdrive (plan 077). A system-driven poll loop reads the tenant slice
742
+ * (`query`, with params bound from `tenantBy`) and lands it in the DO's SQLite,
743
+ * after which `defineShape` carries it to clients unchanged. Implies
744
+ * `.externallyManaged()` (rows come from the ingest loop, not user mutations).
745
+ *
746
+ * Orthogonal to `.shardBy()` — combine them for per-tenant DOs. **Under
747
+ * `.shardBy()` `tenantBy` is mandatory** (the tenant-isolation boundary); the
748
+ * `external_source_unscoped` advisor lint fails the build when it is absent, and
749
+ * `external_source_on_global` rejects combining `.source()` with `.global()`.
750
+ */
751
+ source: (definition: ExternalSourceDefinition) => TableBuilder<Shape>;
752
+ /** Declare named lifecycle triggers fired inline within the write path. */
753
+ triggers: (build: (t: TriggerBuilder<Shape>) => Record<string, TriggerDefinition>) => TableBuilder<Shape>;
754
+ /**
755
+ * Declare a table-level TTL: a DO alarm-driven sweep auto-deletes rows whose
756
+ * expiry has passed (or soft-deletes them when the table also
757
+ * `.softDelete()`s). `field` is an epoch-millisecond column; without
758
+ * `options.after` its value is the absolute expiry instant, with `after` the
759
+ * row expires `after` ms past `field` (`field + after`). Coarse, cheap,
760
+ * table-level — for per-row schedules use `@lunora/scheduler`.
761
+ */
762
+ ttl: (field: keyof Shape & string, options?: {
763
+ after?: number;
764
+ }) => TableBuilder<Shape>;
765
+ /** Declare a vector index over a single text field on this table. */
766
+ vectorize: (field: keyof Shape & string, options: VectorizeOptions<Shape>) => TableBuilder<Shape>;
767
+ }
768
+ /** Options for `defineVectorIndex(...)` (DSL Shape B). */
769
+ interface VectorIndexOptions {
770
+ dimensions: number;
771
+ embed: VectorEmbedder;
772
+ /** Optional projection of the source row into Vectorize metadata. */
773
+ metadata?: (row: Record<string, unknown>) => Record<string, unknown>;
774
+ metric: VectorMetric;
775
+ /** The vector source: which table, and how to derive the embedded text. */
776
+ source: {
777
+ select: (row: Record<string, unknown>) => string;
778
+ table: string;
671
779
  };
672
780
  }
673
- /** The hono app type {@link httpRouter} returns. */
674
- type LunoraHttpApp = Hono<LunoraHttpEnv>;
675
- /** A compiled route handler: a hono handler that resolves to a raw {@link Response}. */
676
- type LunoraRouteHandler = (c: Context<LunoraHttpEnv>) => Promise<Response>;
677
781
  /**
678
- * Wrap a `(ctx, request) => Response` handler as a hono handler. The raw escape
679
- * hatch mount it with `app.all(path, httpAction(fn))`. `ctx` is the
680
- * runtime-injected {@link HttpActionCtx} lifted into `c.var.lunora` by
681
- * {@link httpRouter}; `request` is the underlying `c.req.raw`.
782
+ * Build a table definition. Returned object is both the table definition (for
783
+ * `defineSchema`) and a fluent builder for indexes + sharding metadata.
682
784
  */
683
- declare const httpAction: (handler: HttpActionHandler) => LunoraRouteHandler;
785
+ declare const defineTable: <Shape extends Record<string, Validator>>(inputShape: Shape) => TableBuilder<Shape>;
684
786
  /**
685
- * Create the hono app for HTTP actions. Pre-wired with a middleware that lifts
686
- * the runtime-injected `c.env.__lunoraCtx` into `c.var.lunora`, so both
687
- * {@link httpAction} and the typed {@link httpRoute} builder can read the action
688
- * context. The full hono surface is available — plugins, path params, `.route`:
689
- *
690
- * ```ts
691
- * const app = httpRouter();
692
- * app.use("*", cors());
693
- * app.post("/webhook", httpAction(onWebhook));
694
- * app.get("/users/:id", getUser);
695
- * export default createWorker({ httpRouter: app, ... });
696
- * ```
697
- *
698
- * The lifting middleware throws if the context is absent. `createWorker` injects
699
- * it on every request the router sees, so this only trips when the app is run
700
- * outside the runtime — a misconfiguration we surface loudly rather than let
701
- * `c.var.lunora` be silently `undefined` despite its non-optional type.
787
+ * Declare a standalone vector index (DSL Shape B). Pass the returned value in
788
+ * the `vectorIndexes` map of {@link defineSchema} when the source is derived
789
+ * from multiple fields or a computation rather than a single column.
702
790
  */
703
- declare const httpRouter: () => LunoraHttpApp;
704
- /** The `{ ctx, searchParams, body, params }` a typed route handler receives. */
705
- interface HttpRouteHandlerOptions<SearchParams extends ArgsValidator, Body extends ArgsValidator, Params extends ArgsValidator> {
706
- body: InferArgs<Body>;
707
- ctx: HttpActionCtx;
708
- params: InferArgs<Params>;
709
- searchParams: InferArgs<SearchParams>;
710
- }
791
+ declare const defineVectorIndex: (options: VectorIndexOptions) => VectorIndexDefinition;
711
792
  /**
712
- * The `{ ctx, searchParams, params, request, signal }` a streaming HTTP
713
- * handler receives. There is no parsed `body` streams are typically GET, and
714
- * the raw `request` is exposed if a handler needs to read the body itself.
715
- * `signal` is tripped when the client disconnects.
716
- * @experimental Part of the HTTP-SSE stream surface; reconnect/POST-body design questions are still open.
793
+ * Options for the standalone `defineAggregateIndex(name, opts)` helper (DSL
794
+ * Shape B). Unlike the inline `.aggregateIndex(...)` builder, this form takes
795
+ * the owning table explicitly via `on` handy when a single counter wants to
796
+ * live next to the schema map rather than inside a table chain.
717
797
  */
718
- interface HttpStreamHandlerOptions<SearchParams extends ArgsValidator, Params extends ArgsValidator> {
719
- ctx: HttpActionCtx;
720
- params: InferArgs<Params>;
721
- request: Request;
722
- searchParams: InferArgs<SearchParams>;
723
- signal: AbortSignal;
798
+ interface AggregateIndexOptions {
799
+ by?: ReadonlyArray<string>;
800
+ field?: string;
801
+ on: string;
802
+ op?: AggregateOp;
803
+ where?: Record<string, unknown>;
724
804
  }
725
805
  /**
726
- * A typed REST route under construction. `.searchParams()` / `.body()` /
727
- * `.params()` accumulate validator maps (later calls merge, a colliding key
728
- * wins) that decode the URL query, JSON body, and hono path params into the
729
- * handler's typed `searchParams` / `body` / `params`. Like the procedure
730
- * builder, `.output(validator)` defaults to the `undefined` sentinel — while
731
- * unset the handler is generic over its own return; once set the handler must
732
- * return that type and the result is parsed through the validator before
733
- * serialization. `[Output] extends [undefined]` is tuple-wrapped so a union
734
- * `Output` doesn't distribute and the test is for the exact sentinel.
735
- *
736
- * The terminal `.handler()` yields a {@link LunoraRouteHandler} — mount it
737
- * directly with `app.get(path, route)`.
806
+ * Declare a standalone aggregate index. Pass the returned value to
807
+ * `defineSchema(tables, vectorIndexes, aggregateIndexes)` keyed by index name
808
+ * the schema attaches it to `tables[on].aggregateIndexes` so runtime consumers
809
+ * (DO + D1) read every index uniformly off the table definition.
738
810
  */
739
- interface HttpRouteBuilder<SearchParams extends ArgsValidator, Body extends ArgsValidator, Params extends ArgsValidator, Output = undefined> {
740
- body: <B extends ArgsValidator>(validators: B) => HttpRouteBuilder<SearchParams, B & Body, Params, Output>;
741
- /**
742
- * Attach a `Cache-Control` header to the response. Only meaningful when
743
- * Workers Cache is enabled in `wrangler.jsonc` (`"cache": { "enabled": true }`).
744
- */
745
- cacheControl: (value: string) => HttpRouteBuilder<SearchParams, Body, Params, Output>;
746
- /**
747
- * Attach a `Cache-Tag` header to the response for tag-based purging via
748
- * `ctx.cache.purge({ tags: [...] })`.
749
- */
750
- cacheTag: (value: string) => HttpRouteBuilder<SearchParams, Body, Params, Output>;
751
- 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;
752
- output: <V extends Validator>(validator: V) => HttpRouteBuilder<SearchParams, Body, Params, Infer<V>>;
753
- params: <P extends ArgsValidator>(validators: P) => HttpRouteBuilder<SearchParams, Body, P & Params, Output>;
754
- searchParams: <S extends ArgsValidator>(validators: S) => HttpRouteBuilder<S & SearchParams, Body, Params, Output>;
755
- /**
756
- * Terminal: declare this route as a streaming Server-Sent Events endpoint.
757
- * The handler is an async generator (or any function returning an
758
- * `AsyncIterable<R>`) that yields one chunk per SSE `data:` frame; on
759
- * iterator completion the route writes a final `event: complete` frame; on
760
- * throw, an `event: error` frame is written with `{code, message}` before
761
- * the stream closes. The chunks are JSON-encoded; `R` is inferred from the
762
- * handler's yielded type.
763
- * @experimental Reconnect/POST-body/wire-fidelity design questions are still open, so the shape may change.
764
- */
765
- stream: <R>(handler: (options: HttpStreamHandlerOptions<SearchParams, Params>) => AsyncGenerator<R, void, void> | AsyncIterable<R>) => LunoraRouteHandler;
766
- /**
767
- * Attach a `Vary` header to the response so Cloudflare stores separate
768
- * cached variants per distinct value of the listed request headers.
769
- */
770
- vary: (value: string) => HttpRouteBuilder<SearchParams, Body, Params, Output>;
771
- }
772
- /** Opens a fresh {@link HttpRouteBuilder}. The `path` documents intent; hono owns the actual routing at mount. */
773
- type HttpRouteFactory = (path: string) => HttpRouteBuilder<EmptyArgs, EmptyArgs, EmptyArgs>;
774
- /** The verb-keyed entry point: `httpRoute.get("/api/todos")…`. */
775
- interface HttpRoute {
776
- delete: HttpRouteFactory;
777
- get: HttpRouteFactory;
778
- head: HttpRouteFactory;
779
- options: HttpRouteFactory;
780
- patch: HttpRouteFactory;
781
- post: HttpRouteFactory;
782
- put: HttpRouteFactory;
811
+ declare const defineAggregateIndex: (name: string, options: AggregateIndexOptions) => AggregateIndexDefinition;
812
+ /**
813
+ * Options for the standalone `defineRankIndex(name, opts)` helper (DSL Shape B).
814
+ * Mirrors the inline `.rankIndex(...)` builder but takes the owning table via
815
+ * `table` so it can sit next to the schema map.
816
+ */
817
+ interface RankIndexOptions {
818
+ partitionBy?: ReadonlyArray<string>;
819
+ sortBy: ReadonlyArray<{
820
+ direction?: "asc" | "desc";
821
+ field: string;
822
+ }>;
823
+ table: string;
824
+ where?: Record<string, unknown>;
783
825
  }
784
826
  /**
785
- * Typed REST route builder. Compiles down to a {@link LunoraRouteHandler}, so a
786
- * typed route and a hand-written {@link httpAction} are interchangeable when
787
- * mounted on {@link httpRouter}:
788
- *
789
- * ```ts
790
- * export const listTodos = httpRoute
791
- * .get("/api/todos")
792
- * .searchParams({ limit: v.number(), q: v.optional(v.string()) })
793
- * .output(v.array(v.object({ id: v.string(), text: v.string() })))
794
- * .handler(async ({ ctx, searchParams }) => ctx.runQuery(api.todos.list, searchParams));
795
- *
796
- * export const getTodo = httpRoute
797
- * .get("/api/todos/:id")
798
- * .params({ id: v.string() })
799
- * .handler(async ({ ctx, params }) => ctx.runQuery(api.todos.get, params));
800
- *
801
- * const app = httpRouter();
802
- * app.get("/api/todos", listTodos);
803
- * app.get("/api/todos/:id", getTodo);
804
- * ```
827
+ * Declare a standalone rank index. Pass the returned value to
828
+ * `defineSchema(tables, vectorIndexes, aggregateIndexes, rankIndexes)` keyed
829
+ * by index name — the schema attaches it to `tables[on].rankIndexes`.
805
830
  */
806
- declare const httpRoute: HttpRoute;
831
+ declare const defineRankIndex: (name: string, options: RankIndexOptions) => RankIndexDefinition;
807
832
  /**
808
- * Structural view of an R2 object body, as returned by `@lunora/storage`'s
809
- * `download()`. Re-declared here (not imported) so `@lunora/server` takes no
810
- * runtime dependency on `@lunora/storage`; the real binding satisfies the shape.
833
+ * Build the application schema. The first argument is the table map; the
834
+ * optional second argument registers standalone `defineVectorIndex(...)`
835
+ * declarations (DSL Shape B) keyed by index name. The optional third argument
836
+ * registers standalone `defineAggregateIndex(...)` declarations (DSL Shape B);
837
+ * the optional fourth argument registers standalone `defineRankIndex(...)`
838
+ * declarations. Both are folded into the matching `tables[on].*Indexes` array
839
+ * so runtime backends read every index uniformly off the table definition.
811
840
  */
812
- interface StorageObjectBody {
813
- /** The object body stream (`null` for a zero-byte object). */
814
- body: ReadableStream | null;
815
- etag: string;
816
- httpMetadata?: {
817
- contentType?: string;
818
- };
819
- key: string;
820
- /** Hex SHA-256, when R2 carries a checksum (surfaced by `@lunora/storage`). */
821
- sha256?: string;
822
- /** Base64 SHA-256 (RFC 9530 digest encoding), when R2 carries a checksum. */
823
- sha256Base64?: string;
824
- size: number;
825
- }
826
- /** Byte window forwarded to `download()` so R2 streams just the requested slice. */
827
- interface StorageRange {
828
- length: number;
829
- offset: number;
830
- }
831
841
  /**
832
- * The minimal storage surface {@link serveStorageObject} needs: a metadata-rich
833
- * `download`, plus the body-free `head` a range request resolves against.
842
+ * Schema with an in-place `.extend(plugin.extension)` method. Used so apps
843
+ * can compose plugin schemas: `defineSchema({...}).extend(authPlugin.extension)`.
834
844
  *
835
- * `head` is required rather than optional-with-a-fallback because the fallback
836
- * is the bug: without it a ranged request has to start a full-object `download`
837
- * just to learn the size, then throw that body away. `@lunora/storage`'s `head`
838
- * already degrades internally to a 0-length ranged `get()` on a binding with no
839
- * HEAD, so there is nothing a caller here could usefully do that it does not.
845
+ * `extend` is non-mutating returns a fresh `ExtendableSchema` containing
846
+ * the merged tables. Extension tables are auto-namespaced by the extension
847
+ * `key` (`buckets` `ratelimit_buckets`), so the merged type carries the
848
+ * prefixed names via {@link PrefixedTables}. Chains:
849
+ * `defineSchema(...).extend(a).extend(b)` is the typed equivalent of merging
850
+ * `a`'s prefixed tables then `b`'s.
840
851
  */
841
- interface StorageHead {
842
- /** Object metadata with no body. `size` is the FULL object size (mirrors R2). */
843
- head: (key: string) => Promise<Omit<StorageObjectBody, "body"> | null>;
844
- }
845
- /** The storage surface {@link serveStorageObject} reads through. */
846
- interface StorageDownloader extends StorageHead {
847
- download: (key: string, options?: {
848
- range?: StorageRange;
849
- }) => Promise<StorageObjectBody | null>;
850
- }
851
- /** Any ctx that carries a {@link StorageDownloader} on `.storage` (Query/Mutation/Action ctx all do). */
852
- interface ContextWithStorage {
853
- storage: StorageDownloader;
854
- }
852
+ type ExtendableSchema<T extends Record<string, TableDefinition>> = {
853
+ extend: <X extends Record<string, TableDefinition>, Key extends string>(extension: SchemaExtension<X> & {
854
+ readonly key: Key;
855
+ }) => ExtendableSchema<PrefixedTables<X, Key> & T>;
856
+ /**
857
+ * Pin every Durable Object the app reaches — shards, fan-out, subscriptions,
858
+ * the scheduler, and `ctx.containers` — to a Cloudflare data-residency
859
+ * jurisdiction (`"eu"`, `"us"`, `"fedramp"`). Codegen reads this off the
860
+ * schema and emits it into the generated worker's `createWorker({ jurisdiction })`
861
+ * (and `ctx.scheduler` / `ctx.containers`). Non-mutating: returns a fresh
862
+ * `ExtendableSchema`, so it composes with `.rls(...)` / `.extend(...)` in any order.
863
+ *
864
+ * ⚠️ **Set this once, before your first deploy — changing or removing it
865
+ * strands data.** A Durable Object name maps to a *different* ID in each
866
+ * jurisdiction, so toggling this on an existing app makes every shard, scheduler
867
+ * job, and session DO resolve to a NEW, empty DO; the previous data stays in the
868
+ * old jurisdiction's DOs and is no longer reachable. There is no in-place
869
+ * migration — you would have to export from the old jurisdiction and import
870
+ * into the new one.
871
+ *
872
+ * Note: this pins **DO-backed** state only. D1-backed state — `.global()`
873
+ * tables and `@lunora/auth` sessions alike — is governed by D1's own location
874
+ * settings, not this option.
875
+ * @see https://developers.cloudflare.com/durable-objects/reference/data-location/
876
+ */
877
+ jurisdiction: (jurisdiction: DurableObjectJurisdiction) => ExtendableSchema<T>;
878
+ /**
879
+ * Turn on secure-by-default RLS for the whole schema. Every table is then
880
+ * protected — the DO/D1 write path denies raw, non-RLS `ctx.db` access, so a
881
+ * procedure that forgets `.use(rls(...))` fails closed. Opt a table out with
882
+ * `.public()`. Non-mutating: returns a fresh `ExtendableSchema` carrying the
883
+ * mode, so `.rls("required")` composes with `.extend(...)` either order.
884
+ */
885
+ rls: (mode: "required") => ExtendableSchema<T>;
886
+ } & Schema<T>;
855
887
  /**
856
- * True when `value` is safe to use as an HTTP header field-value: no CR, LF, or
857
- * NUL. Guards against response-header injection / `Headers`-construction throws
858
- * when reflecting attacker-influenced object metadata (e.g. a stored
859
- * `Content-Type`). Exported (see the `export {}` at the file end) so an `httpAction`
860
- * handler can guard a request-derived header value before writing it — the fix the
861
- * `http_action_response_header_injection` advisor lint points to.
888
+ * Columns every row carries implicitly (never part of a table's declared
889
+ * `shape`), so `.index()` may legitimately name them. The single source for
890
+ * both the compile-time allow-list (`TableBuilder["index"]`'s `fields` type,
891
+ * via `(typeof SYSTEM_INDEX_FIELDS)[number]`) and the runtime cross-check
892
+ * below (via `SYSTEM_INDEX_FIELDS_SET`) declared once so the two can't
893
+ * drift apart.
862
894
  */
863
- declare const isSafeHeaderValue: (value: string) => boolean;
895
+ declare const SYSTEM_INDEX_FIELDS: readonly ["_commitSeq", "_creationTime", "_id"];
864
896
  /**
865
- * Stream a stored object as an HTTP {@link Response} from an `httpAction`
866
- * handler, with correct `Content-Type`, `ETag`, and `Accept-Ranges: bytes`.
867
- * Honors a single-range `Range` request **206 Partial Content** with
868
- * `Content-Range` + `Content-Length`; otherwise **200**. A missing object is a
869
- * **404**; an out-of-bounds range is a **416** with a `Content-Range` of
870
- * `bytes` star-slash-size.
897
+ * Per-table, per-KIND index→declared-fields map: for each table, each index
898
+ * KIND (`index` | `rank` | `geo`) that has at least one declared index maps
899
+ * to a name→fields record for that kind only. Distilled by
900
+ * {@link indexFieldsFromSchema}; this is the shape `mask()`'s
901
+ * `MaskOptions.indexFields` expects (see `./mask/types`), so a table not
902
+ * present here (no declared indexes of any kind) is simply absent from the
903
+ * map rather than mapped to `{}`, and a kind with no declared indexes on a
904
+ * table that HAS other kinds is simply absent from that table's entry.
871
905
  *
872
- * A range request resolves its window against a body-free `head()`, then issues
873
- * ONE `download()` with the resolved `{ offset, length }` so R2 streams just
874
- * those bytes the slice is never buffered in the isolate, and no full-object
875
- * body transfer is started only to be cancelled. A request that cannot produce a
876
- * 206 at all (no `Range`, multi-range, malformed) skips the `head()` entirely and
877
- * streams straight from a single `download()`. For very
878
- * large objects a signed URL (`ctx.storage.getSignedUrl`) is still cheaper since
879
- * the client then ranges against R2/CDN directly with no Worker hop.
906
+ * Kept per kind (rather than one flat name→fields record) because the engine
907
+ * resolves `withIndex`/`withGeoIndex`/rank reads in THREE separate
908
+ * namespaces (`tableDefinition.indexes` / `.geoIndexes` / `.rankIndexes`
909
+ * see `@lunora/shard-engine`'s `ctx-db.ts`), so the same name can legally and
910
+ * unambiguously denote a different index per kind. A flat map would let one
911
+ * kind's fields silently shadow another's for a colliding name, producing a
912
+ * wrong-namespace answer from the mask guard (checking the wrong index's
913
+ * fields) instead of the documented fail-open (missing lookup) see plan 258.
880
914
  */
881
- declare const serveStorageObject: (context: ContextWithStorage, key: string, request: Request) => Promise<Response>;
915
+ type IndexFieldsByTable = Readonly<Record<string, {
916
+ readonly geo?: Readonly<Record<string, ReadonlyArray<string>>>;
917
+ readonly index?: Readonly<Record<string, ReadonlyArray<string>>>;
918
+ readonly rank?: Readonly<Record<string, ReadonlyArray<string>>>;
919
+ }>>;
920
+ declare const indexFieldsFromSchema: (schema: Schema) => IndexFieldsByTable;
921
+ declare const defineSchema: <T extends Record<string, TableDefinition>>(tables: T, vectorIndexes?: Record<string, VectorIndexDefinition>, aggregateIndexes?: Record<string, AggregateIndexDefinition>, rankIndexes?: Record<string, RankIndexDefinition>) => ExtendableSchema<T>;
922
+ /** Default lifetime of a cached entry: one hour. */
923
+ declare const DEFAULT_ACTION_CACHE_TTL_MS: number;
924
+ declare const ACTION_CACHE_BARE_TABLE = "entries";
925
+ /**
926
+ * The prefixed table name the extension produces at merge time. The handlers and
927
+ * helpers read/write this name directly so they always agree with the merged
928
+ * schema.
929
+ */
930
+ declare const ACTION_CACHE_TABLE: "actionCache_entries";
931
+ /**
932
+ * The slice of an index-range builder this preset uses. Mirrors `IndexRangeBuilder`
933
+ * field-for-field so the real `ctx.db` query builder is assignable — the generated
934
+ * `ctx.db.query` is typed per table, and a helper holding a runtime string needs
935
+ * the wide overload.
936
+ */
937
+ interface ActionCacheIndexRange {
938
+ eq: (field: string, value: unknown) => ActionCacheIndexRange;
939
+ gt: (field: string, value: unknown) => ActionCacheIndexRange;
940
+ gte: (field: string, value: unknown) => ActionCacheIndexRange;
941
+ lt: (field: string, value: unknown) => ActionCacheIndexRange;
942
+ lte: (field: string, value: unknown) => ActionCacheIndexRange;
943
+ }
944
+ /** The slice of a `ctx.db` table query this preset relies on. */
945
+ interface ActionCacheQuery {
946
+ first: () => Promise<Record<string, unknown> | null>;
947
+ order: (direction: "asc" | "desc") => ActionCacheQuery;
948
+ take: (limit: number) => Promise<Record<string, unknown>[]>;
949
+ withIndex: (indexName: string, range?: (q: ActionCacheIndexRange) => ActionCacheIndexRange) => ActionCacheQuery;
950
+ }
882
951
  /**
883
- * What the worker does with a resolver's identity when it fails contract
884
- * validation (a forged / malformed claim set arriving from an untrusted token).
885
- * `"anonymous"` (default, safe) treats the request as anonymous, so the bad
886
- * identity never reaches a policy as a valid identity (`ctx.auth.userId`
887
- * becomes `undefined`). `"reject"` fails the request closed (a `401`) — use
888
- * when a malformed credential should be a hard error, not a silent downgrade.
952
+ * The slice of the ORM writer (`ctx.db` on an action or mutation) the helpers
953
+ * need. The real `DatabaseWriter` is structurally assignable, so pass `ctx.db`
954
+ * directly.
889
955
  */
890
- type IdentityRejectMode = "anonymous" | "reject";
891
- /** Options for {@link defineIdentity}. */
892
- interface DefineIdentityOptions {
956
+ interface ActionCacheDatabase {
957
+ delete: <T extends string>(id: Id<T>) => Promise<void>;
958
+ insert: (table: string, document: Record<string, unknown>) => Promise<unknown>;
959
+ patch: <T extends string>(id: Id<T>, patch: Record<string, unknown>) => Promise<void>;
960
+ query: (table: string) => ActionCacheQuery;
961
+ }
962
+ /** The slice of a function context the helpers need — just its writer. */
963
+ interface ActionCacheContext {
964
+ db: ActionCacheDatabase;
965
+ }
966
+ /** Options for {@link defineActionCache}. */
967
+ interface DefineActionCacheOptions {
893
968
  /**
894
- * How to handle a resolver identity that violates the contract at the trust
895
- * boundary. Defaults to `"anonymous"` (a forged claim set is downgraded to
896
- * anonymous rather than flowing in as an unchecked cast).
969
+ * Cap on the serialized size (bytes) of one cached value. A larger result is
970
+ * returned but not stored. Defaults to 512 KB; a non-finite value falls back
971
+ * to the default.
897
972
  */
898
- readonly onInvalid?: IdentityRejectMode;
973
+ maxValueBytes?: number;
974
+ /**
975
+ * How long (ms) an entry stays fresh. A read past it reports a miss.
976
+ * Defaults to one hour.
977
+ */
978
+ ttlMs?: number;
979
+ }
980
+ /** The registered functions an action-cache component ships. */
981
+ interface ActionCacheFunctions {
982
+ /**
983
+ * Internal mutation that hard-deletes expired entries, oldest first, and
984
+ * reports how many it removed. Every miss already reaps a few rows
985
+ * opportunistically, so an app with steady traffic stays bounded without
986
+ * this; schedule it on a cron to reclaim entries whose names went quiet.
987
+ *
988
+ * Returns `{ deleted }`; compare it against `limit` to decide whether to run
989
+ * again rather than assuming one pass drained the table. A caller-supplied
990
+ * `limit` is clamped — `take()` has no ceiling of its own.
991
+ */
992
+ purgeExpired: RegisteredMutation<{
993
+ limit: ReturnType<typeof v.optional>;
994
+ }, {
995
+ deleted: number;
996
+ }>;
899
997
  }
900
- /** Result of validating a candidate identity against the contract. */
901
- type IdentityValidation = {
902
- ok: true;
903
- } | {
904
- error: string;
905
- ok: false;
906
- };
907
998
  /**
908
- * A declared identity claim contract. Carries the codegen discovery brand, the
909
- * declared claim validators, the reject policy, and a runtime `validate`. The
910
- * `TClaims` type parameter is the inferred claim shape (always extending
911
- * `{ userId: string }`); it is phantom (no runtime field) and exists so
912
- * `@lunora/codegen` and {@link InferIdentity} can recover the type.
999
+ * SHA-256 of `name` and the serialized args, hex.
1000
+ *
1001
+ * Hashed rather than stored: call sites routinely pass an entire model request,
1002
+ * which is tens of kilobytes. As an indexed column that would be wasteful and
1003
+ * would run into column-size limits; a digest is fixed-width and indexes cleanly.
1004
+ * The NUL separator keeps `("ab", "c")` and `("a", "bc")` from colliding.
1005
+ * @param name the logical cache namespace (usually the action's name)
1006
+ * @param argumentsKey the arguments, already serialized by {@link serializeArgs}
913
1007
  */
914
- interface IdentityContract<TClaims extends {
915
- userId: string;
916
- } = {
917
- userId: string;
918
- }> {
1008
+ declare const cacheKeyFor: (name: string, argumentsKey: string) => Promise<string>;
1009
+ /**
1010
+ * The component shape {@link defineActionCache} returns: the extension, the
1011
+ * registered functions, and the helpers an action calls directly.
1012
+ */
1013
+ type ActionCacheComponent = {
1014
+ functions: ActionCacheFunctions;
1015
+ /** Drop the entry for exactly this `name` + `args`. Resolves whether or not one existed. */
1016
+ invalidate: (context: ActionCacheContext, name: string, args: unknown) => Promise<void>;
919
1017
  /**
920
- * Phantom carrier for the inferred claim type. Never populated at runtime
921
- * (`undefined`); present only so the type flows to codegen / {@link InferIdentity}.
1018
+ * Drop every entry under `name`, whatever its arguments.
1019
+ *
1020
+ * `complete` is `false` when the paging bound was reached with rows still
1021
+ * matching — call again. A plain count could not tell "dropped all of them"
1022
+ * apart from "dropped the first few thousand".
922
1023
  */
923
- readonly __claimType?: TClaims;
924
- readonly __lunoraIdentity: true;
925
- /** The declared claim validators (a `@lunora/values` validator map). */
926
- readonly claims: ValidatorMap;
927
- /** Reject policy applied at the trust boundary. See {@link IdentityRejectMode}. */
928
- readonly onInvalid: IdentityRejectMode;
1024
+ invalidateAll: (context: ActionCacheContext, name: string) => Promise<{
1025
+ complete: boolean;
1026
+ deleted: number;
1027
+ }>;
929
1028
  /**
930
- * Validate a resolver's returned identity against the declared claims. On
931
- * success the caller keeps the original identity untouched (so undeclared
932
- * claims are forwarded verbatim, preserving today's behaviour); on failure
933
- * the worker applies the `onInvalid` policy.
1029
+ * Return the cached result for `name` + `args`, or run `compute` and cache it.
1030
+ *
1031
+ * `args` is keyed through the wire codec with sorted keys, so two calls agree
1032
+ * whenever their arguments are structurally equal — key order does not matter,
1033
+ * and `bigint` / `Date` / bytes arguments key distinctly rather than colliding.
934
1034
  */
935
- validate: (identity: Record<string, unknown>) => IdentityValidation;
936
- }
937
- /** Recover the declared claim type from a {@link defineIdentity} contract. */
938
- type InferIdentity<T> = T extends IdentityContract<infer TClaims> ? TClaims : never;
1035
+ wrap: <T>(context: ActionCacheContext, name: string, args: unknown, compute: () => Promise<T>) => Promise<T>;
1036
+ } & Component<{
1037
+ [ACTION_CACHE_BARE_TABLE]: ReturnType<typeof defineTable>;
1038
+ }>;
939
1039
  /**
940
- * Declare the identity claim contract. `claims` is a `@lunora/values` validator
941
- * map whose inferred type must extend `{ userId: string }` if it does not
942
- * (e.g. `userId` is missing or not a required string), the argument type
943
- * collapses to `never` and the call fails to typecheck.
944
- * @example
945
- * export const identity = defineIdentity({ userId: v.string(), tenantId: v.optional(v.string()), scopes: v.optional(v.array(v.string())) });
1040
+ * The action-cache schema extension: one `entries` table, auto-namespaced to
1041
+ * `actionCache_entries` at merge time.
946
1042
  */
947
- declare const defineIdentity: <A extends ValidatorMap>(claims: InferValidatorMap<A> extends {
948
- userId: string;
949
- } ? A : never, options?: DefineIdentityOptions) => IdentityContract<InferValidatorMap<A> & {
950
- userId: string;
1043
+ declare const actionCacheExtension: SchemaExtension<{
1044
+ [ACTION_CACHE_BARE_TABLE]: ReturnType<typeof defineTable>;
951
1045
  }>;
952
- /** Handler for a connection-lifecycle hook. */
953
- type LifecycleHandler = (context: MutationCtx, event: LifecycleEvent) => Promise<void> | void;
954
- /** Handler for a shard-init hook. */
955
- type ShardInitHandler = (context: MutationCtx, event: ShardInitEvent) => Promise<void> | void;
956
- /** Register a hook that fires once when a client's WebSocket connects. */
957
- declare const onConnect: (handler: LifecycleHandler) => RegisteredLifecycleHook;
958
- /** Register a hook that fires once when a client's WebSocket disconnects. */
959
- declare const onDisconnect: (handler: LifecycleHandler) => RegisteredLifecycleHook;
960
1046
  /**
961
- * Register a hook that fires ONCE per Durable Object instance, before any
962
- * handler on that instance can runthe re-init half of `.memory()` tables.
963
- *
964
- * A shard is not a process that stays up. Cloudflare reconstructs the Durable
965
- * Object after every eviction, and a shard whose sockets are hibernating is
966
- * evicted routinely, so "cold start" is a steady-state event rather than a rare
967
- * one. Everything the shard held in memory is gone at that moment: the JS heap,
968
- * and every `.memory()` table, which the framework has already cleared by the
969
- * time this hook runs.
970
- *
971
- * ```ts
972
- * // lunora/init.ts
973
- * import { onShardInit } from "@lunora/server";
974
- *
975
- * export const warm = onShardInit(async (ctx, event) => {
976
- * // Rebuild ephemeral state from the durable tables that outlived us.
977
- * for await (const member of ctx.db.roomMembers.iterate({ where: { roomId: event.shardKey } })) {
978
- * await ctx.db.presence.insert({ userId: member.userId, status: "away" });
979
- * }
980
- * });
981
- * ```
982
- *
983
- * **Ordering is the guarantee.** Memory tables are cleared, then every init hook
984
- * runs to completion, and only then does the dispatch that triggered the cold
985
- * start proceed. No handler, subscription refresh, alarm, or shape poke can
986
- * observe a memory table in the gap. Hooks run sequentially in manifest order,
987
- * so one may depend on state an earlier one wrote.
988
- *
989
- * **It is a mutation, and it runs on every cold start.** Keep it cheap and keep
990
- * it idempotent: it is on the latency path of the request that woke the shard,
991
- * and it will run again — many times — over the shard's life. Writing to durable
992
- * tables from here is legal and occasionally right, but remember it is a
993
- * rebuild, not a migration; use `defineMigration` for anything that should
994
- * happen once.
1047
+ * Build an action-cache {@link Component} schema extension, `purgeExpired`, and
1048
+ * the `wrap` / `invalidate` / `invalidateAll` helpers wired to one TTL.
1049
+ * @param options cache configuration (TTL, value-size cap).
1050
+ * @returns a component bundling the extension, the functions, and the helpers.
1051
+ */
1052
+ declare const defineActionCache: (options?: DefineActionCacheOptions) => ActionCacheComponent;
1053
+ /**
1054
+ * Make any resolved storage capability bucket-aware so `ctx.storage.bucket(name)`
1055
+ * always resolves. A `createBucketStorage(...)` result already carries
1056
+ * `.bucket` / `.bucketName` and is returned as-is; a single `createStorage(...)`
1057
+ * (or the no-storage stub) is tagged as the `"default"` bucket, where
1058
+ * `.bucket(name)` is the identity — single-bucket apps address one binding under
1059
+ * every name.
995
1060
  *
996
- * **No caller identity.** The hook dispatches as a trusted system call with no
997
- * request identity `ctx.auth` is anonymous and RLS does not apply even under
998
- * `.rls("required")`, exactly as for a cron tick or a migration. RLS scopes rows
999
- * to a user and an init hook has none, so `ctx.db` here sees every row: scope
1000
- * your reads yourself. (`onConnect`/`onDisconnect` are the opposite case they
1001
- * carry the socket's verified identity and stay RLS-guarded.)
1061
+ * Lives here rather than in `@lunora/server` because two packages need it and
1062
+ * neither may depend on the other: `@lunora/server` re-exports it as the runtime
1063
+ * counterpart `_generated/shard.ts` imports, and `@lunora/runtime` uses it to
1064
+ * build `ctx.storage` for an HTTP action from the worker's own R2 bindings.
1065
+ * Inlined into each `dist` by the bundler, so no dependency edge is created.
1002
1066
  *
1003
- * A throw is logged and does NOT fail the dispatch that woke the shard — an init
1004
- * hook that cannot rebuild presence must not take the whole shard down with it.
1005
- * The table is then cleared but not refilled, so reads see nothing. A failure
1006
- * EARLIER, before the framework's clear runs, instead leaves the previous
1007
- * instance's rows in place: a memory table's rows live in SQLite until they are
1008
- * deleted, so an eviction on its own does not remove them.
1067
+ * The input is genuinely heterogeneous (a thunk result cast through `unknown`),
1068
+ * so the signature is `unknown unknown`; callers cast the result.
1009
1069
  */
1010
- declare const onShardInit: (handler: ShardInitHandler) => RegisteredLifecycleHook;
1011
- /** Default `limit` when the caller doesn't ask for one. */
1012
- declare const DEFAULT_LIMIT = 25;
1013
- /** Default ceiling on `limit`, so one request can't ask for an unbounded page. */
1014
- declare const DEFAULT_MAX_LIMIT = 100;
1070
+ declare const asBucketStorage: (raw: unknown) => unknown;
1015
1071
  /**
1016
- * Per-field predicate accepted for a declared filter column. An alias of the
1017
- * `ctx.db` `where` DSL's own operator type rather than a copy, so the two cannot
1018
- * drift as operators are added.
1072
+ * Entry point for the procedure builder. `dataModel<DM>()` binds the generated
1073
+ * `DataModel` (phantom for now), and `.create()` yields the public root builders
1074
+ * plus their `internal*` counterparts.
1019
1075
  */
1020
- type ListFilterOperators<T> = WhereOperators<T>;
1076
+ declare const initLunora: {
1077
+ dataModel: <DataModel>() => DataModelInit<DataModel>;
1078
+ };
1021
1079
  /**
1022
- * The filter allow-list a caller may declare: a subset of the document's own
1023
- * columns, each with a validator for that column's type. Constraining the KEYS to
1024
- * `keyof Doc` is what turns a typo'd or renamed column into a compile error
1025
- * instead of a predicate that silently never matches.
1080
+ * Build the `ctx.secrets` reader from the worker `env`. `get(name)` resolves
1081
+ * `env[name].get()` the `secrets_store_secrets[]` binding of that name. An
1082
+ * absent or non-Secrets-Store binding throws a directed error pointing at the
1083
+ * wrangler config; the lookup is lazy, so an unused secret never resolves.
1084
+ */
1085
+ declare const createSecrets: (env: Record<string, unknown>) => Secrets;
1086
+ /**
1087
+ * Wrap a storage facade so it also accepts `deleteAfterCommit(key)`.
1088
+ *
1089
+ * Call once per dispatch, outside any read-stamping wrapper: `bucket()` delegates
1090
+ * to the wrapped facade, so a sub-facade is still stamped by whatever wrapped it.
1091
+ * @param storage the bucket-aware facade from `asBucketStorage`
1092
+ */
1093
+ declare const withDeferredDeletes: (storage: unknown) => unknown;
1094
+ /** How a flush ended, so a caller can assert on it without this module owning a logger. */
1095
+ interface DeferredDeleteFlushResult {
1096
+ /** How many keys were attempted. */
1097
+ attempted: number;
1098
+ /** Keys whose delete did not happen, with the reason. Empty when everything succeeded. */
1099
+ failures: {
1100
+ error: unknown;
1101
+ key: string;
1102
+ }[];
1103
+ }
1104
+ /**
1105
+ * Delete everything queued on `context.storage`, draining the queue as it goes,
1106
+ * and report each failure through `context.log`.
1107
+ *
1108
+ * Draining first means a second flush of the same dispatch is a no-op rather than
1109
+ * a second round of deletes.
1110
+ *
1111
+ * Never throws. A flush runs after the write committed, so there is no longer
1112
+ * anything to fail into: rejecting here could only turn a leaked object into a
1113
+ * failed response for a write that already succeeded. Failures are logged and
1114
+ * also returned, so a dispatch can call this and ignore the result.
1115
+ * @param context the dispatch context; a context with no queued deletes is a no-op
1026
1116
  */
1027
- type ListFilterShape<TDocument> = { [K in keyof TDocument & string]?: Validator<TDocument[K]>; };
1028
- /** The `where` argument: each declared filter column, optionally, as a bare value or an operator object. */
1029
- type ListWhere<F> = { [K in keyof F]?: Infer<NonNullable<F[K]>> | ListFilterOperators<Infer<NonNullable<F[K]>>>; };
1030
- /** One `orderBy` entry. `direction` defaults to `"asc"`. */
1031
- interface ListOrderByEntry<O extends string> {
1032
- direction?: "asc" | "desc";
1033
- field: O;
1034
- }
1035
- /** The decoded arguments a {@link defineListArgs} endpoint receives. */
1036
- interface ListArgsValue<F, O extends string> {
1037
- cursor?: null | number | string;
1038
- limit?: number;
1039
- orderBy?: ListOrderByEntry<O>[];
1040
- where?: ListWhere<F>;
1117
+ declare const flushDeferredDeletes: (context: unknown) => Promise<DeferredDeleteFlushResult>;
1118
+ /**
1119
+ * Fields never written into a snapshot, whatever table they appear on.
1120
+ *
1121
+ * Names rather than types, because that is the only signal available here: a
1122
+ * trigger sees values, not the column metadata that would say "this one is a
1123
+ * secret". Extend it per app rather than relying on this list being complete.
1124
+ */
1125
+ declare const DEFAULT_REDACTED_FIELDS: ReadonlyArray<string>;
1126
+ declare const DOCUMENT_HISTORY_BARE_TABLE = "versions";
1127
+ /** The prefixed table name the extension produces at merge time. */
1128
+ declare const DOCUMENT_HISTORY_TABLE: "documentHistory_versions";
1129
+ /** One recorded version, as {@link DocumentHistoryFunctions.listForDocument} returns it. */
1130
+ interface DocumentHistoryEntry {
1131
+ /** The row as it stood after the write, redacted. Absent for a delete, and when `truncated`. */
1132
+ doc?: Record<string, unknown>;
1133
+ /** The row this version belongs to. */
1134
+ documentId: string;
1135
+ /** Which write produced this version. */
1136
+ op: "delete" | "insert" | "update";
1137
+ /** The row as it stood before the write, redacted. Absent for an insert, and when `truncated`. */
1138
+ previous?: Record<string, unknown>;
1139
+ /** When the write happened (epoch ms). */
1140
+ recordedAt: number;
1141
+ /** The table the row lives in. */
1142
+ tableName: string;
1143
+ /** `true` when the snapshots were dropped for exceeding `maxSnapshotBytes`. */
1144
+ truncated?: boolean;
1041
1145
  }
1042
- interface DefineListArgsConfig<F, O extends string> {
1043
- /** `limit` applied when the caller omits one. Defaults to 25. */
1044
- readonly defaultLimit?: number;
1146
+ /** Options for {@link defineDocumentHistory}. */
1147
+ interface DefineDocumentHistoryOptions {
1045
1148
  /**
1046
- * Allow-list of filterable columns. Publish only columns an index can serve;
1047
- * anything absent here is unreachable from the client.
1149
+ * Cap on one serialized snapshot (bytes). Past it the entry is written
1150
+ * without its snapshots and marked `truncated`. Defaults to 64 KB.
1048
1151
  */
1049
- readonly filter: F;
1050
- /** Ceiling on `in` / `notIn` array length — one bound parameter each. Defaults to 100. */
1051
- readonly maxInValues?: number;
1052
- /** Ceiling on `limit`; a larger request is clamped down, not rejected. Defaults to 100. */
1053
- readonly maxLimit?: number;
1054
- /** Ceiling on how many `orderBy` entries a request may ask for. Defaults to 8. */
1055
- readonly maxOrderBy?: number;
1056
- /** Allow-list of sortable columns. Pass `[]` to fix the order server-side. */
1057
- readonly orderBy: ReadonlyArray<O>;
1058
- }
1059
- /** The validator map handed to `.input()`. Typed precisely so `args` infers end-to-end. */
1060
- interface ListArgsValidators<F, O extends string> {
1061
- cursor: ColumnValidator<null | number | string | undefined, null | number | string | undefined>;
1062
- limit: ColumnValidator<number | undefined, number | undefined>;
1063
- orderBy: ColumnValidator<ListOrderByEntry<O>[] | undefined, ListOrderByEntry<O>[] | undefined>;
1064
- where: ColumnValidator<ListWhere<F> | undefined, ListWhere<F> | undefined>;
1152
+ maxSnapshotBytes?: number;
1153
+ /**
1154
+ * Extra field names to drop from every snapshot, on top of the built-in
1155
+ * secret-shaped defaults.
1156
+ */
1157
+ redact?: ReadonlyArray<string>;
1158
+ /**
1159
+ * How long (ms) an entry is kept. `vacuum` deletes entries older than this.
1160
+ * Defaults to 90 days.
1161
+ */
1162
+ retentionMs?: number;
1065
1163
  }
1066
- interface ListArgsSpec<TDocument, F, O extends string> {
1067
- /** Spread into `.input(...)` — `{ cursor, limit, orderBy, where }`. */
1068
- readonly args: ListArgsValidators<F, O>;
1164
+ /** The registered functions a document-history component ships. */
1165
+ interface DocumentHistoryFunctions {
1069
1166
  /**
1070
- * Translate the decoded arguments into the `findMany` options object:
1071
- * `limit` clamped into `[1, maxLimit]`, `orderBy` reshaped from
1072
- * `{ field, direction }[]` into `ctx.db`'s `{ column: direction }[]`.
1167
+ * **Internal** query: the recorded versions of one row, newest first.
1073
1168
  *
1074
- * Returns `QueryArgs<Doc>` bound to the table, not free so a mismatch
1075
- * between what this helper declares and what the table actually holds is a
1076
- * compile error at the `findMany` call site.
1169
+ * Internal because an entry is a full row snapshot, including columns the
1170
+ * table's own RLS hides wrap it in a procedure of your own that applies
1171
+ * whatever authorization the surface needs.
1172
+ *
1173
+ * Pass `before` to read the history as of an instant: the first entry back is
1174
+ * the last version at or before it, which is what a point-in-time
1175
+ * reconstruction needs.
1176
+ */
1177
+ listForDocument: RegisteredQuery<{
1178
+ before: ReturnType<typeof v.optional>;
1179
+ documentId: ReturnType<typeof v.string>;
1180
+ limit: ReturnType<typeof v.optional>;
1181
+ }, DocumentHistoryEntry[]>;
1182
+ /**
1183
+ * Internal mutation that deletes entries older than the retention window,
1184
+ * oldest first, and reports how many it removed. Schedule it on a cron.
1185
+ *
1186
+ * Compare `deleted` against `limit` to decide whether to run again rather
1187
+ * than assuming one pass drained the backlog.
1077
1188
  */
1078
- readonly toQueryArgs: (args: ListArgsValue<F, O>) => QueryArgs$2<TDocument>;
1189
+ vacuum: RegisteredMutation<{
1190
+ limit: ReturnType<typeof v.optional>;
1191
+ }, {
1192
+ deleted: number;
1193
+ }>;
1079
1194
  }
1080
- /** Clamp a caller-supplied `limit` into `[1, maxLimit]`; a non-finite value falls back to `fallback`. */
1081
- declare const clampLimit: (limit: number | undefined, fallback: number, maxLimit: number) => number;
1195
+ /** The component shape {@link defineDocumentHistory} returns. */
1196
+ type DocumentHistoryComponent = {
1197
+ functions: DocumentHistoryFunctions;
1198
+ /**
1199
+ * The `.triggers(...)` argument that records this table's versions —
1200
+ * `.triggers(history.record)`.
1201
+ *
1202
+ * `after*` on all three ops: a `before*` handler runs while the write can
1203
+ * still be aborted, and a history entry for a write that never happened is a
1204
+ * lie the reader has no way to detect.
1205
+ */
1206
+ record: (t: TriggerBuilder) => Record<string, TriggerDefinition>;
1207
+ } & Component<{
1208
+ [DOCUMENT_HISTORY_BARE_TABLE]: ReturnType<typeof defineTable>;
1209
+ }>;
1082
1210
  /**
1083
- * Declare the filter / sort / page arguments for a list endpoint, plus the
1084
- * translation into `ctx.db.<table>.findMany(...)` options. See the module docs
1085
- * for the shape and the reasoning behind it.
1086
- *
1087
- * Curried on the document type: `defineListArgs<Doc<"messages">>()({ … })`. The
1088
- * extra `()` buys the thing that matters — with `Doc` bound, `filter` keys and
1089
- * `orderBy` entries are checked against the table's real columns, so a typo or a
1090
- * column renamed out from under the endpoint is a COMPILE error instead of a
1091
- * predicate that silently matches nothing. TypeScript has no partial type-argument
1092
- * inference, so binding `Doc` explicitly while still inferring `F` and `O` from
1093
- * the config requires the second call.
1211
+ * The document-history schema extension: one `versions` table, auto-namespaced
1212
+ * to `documentHistory_versions` at merge time.
1094
1213
  */
1095
- declare const defineListArgs: <TDocument>() => <F extends ListFilterShape<TDocument>, O extends keyof TDocument & string>(config: DefineListArgsConfig<F, O>) => ListArgsSpec<TDocument, F, O>;
1214
+ declare const documentHistoryExtension: SchemaExtension<{
1215
+ [DOCUMENT_HISTORY_BARE_TABLE]: ReturnType<typeof defineTable>;
1216
+ }>;
1096
1217
  /**
1097
- * Structural mirrors of `@lunora/shard-engine`'s rank-page-row shapes
1098
- * (`RankPageRowKey` / `RankPageRow` / `ShardRankPageResult`) — the return type
1099
- * of the writer's `rankPageRows` seam, the cross-shard companion to
1100
- * `rankPage`.
1218
+ * Build a document-history {@link Component} — schema extension, the
1219
+ * `.triggers()` recorder, and the `listForDocument` / `vacuum` functions.
1220
+ * @param options history configuration (retention, redaction, snapshot cap).
1221
+ * @returns a component bundling the extension, the functions, and the recorder.
1222
+ */
1223
+ declare const defineDocumentHistory: (options?: DefineDocumentHistoryOptions) => DocumentHistoryComponent;
1224
+ /**
1225
+ * Redact secrets from a free-form message. Masks, in order: any quoted value
1226
+ * whose contents look like a credential (so a value surfaced as `received string
1227
+ * "sk_live_…"` is masked even though the surrounding text is not a token); a
1228
+ * `scheme://user:password@host` URL credential (the password segment); any
1229
+ * known-prefix credential token wherever it appears, at any length; any value
1230
+ * following a secret-named key in `KEY=value` / `KEY: value` form; and any
1231
+ * remaining bare high-entropy ≥24-char token run anywhere in the message.
1101
1232
  *
1102
- * Shared by `../rls/middleware` and `../mask/middleware`: both wrap
1103
- * `rankPageRows` structurally (no `@lunora/shard-engine` import, mirroring how
1104
- * every other method on their `DatabaseWriterLike`/`MaskDatabase` projections
1105
- * is hand-mirrored rather than imported) and both need the exact same result
1106
- * shape to type their overrides. A single copy here means the two wrappers
1107
- * can't drift out of lockstep with each other — see AGENTS.md's platform
1108
- * parity note on `ShardSqlExec` and the canonical binding `*Like` projections
1109
- * shipping wrong for exactly this reason (two hand-maintained mirrors of one
1110
- * upstream type).
1233
+ * This is BEST-EFFORT defense-in-depth, NOT a guarantee: a short, prefix-less
1234
+ * secret under a non-secret-named key (and embedded credentials in shapes not
1235
+ * enumerated here) can still slip through. Treat it as a backstop — prefer
1236
+ * structured logging that never serializes raw env/secret fields in the first
1237
+ * place over relying on post-hoc scrubbing of untrusted data.
1238
+ *
1239
+ * Exported because it is independently useful call it before logging anything
1240
+ * derived from `env`, request bodies, or thrown errors.
1111
1241
  */
1112
- /** Structural mirror of `@lunora/shard-engine`'s `RankPageRowKey`. */
1113
- interface RankPageRowKeyLike {
1114
- partitionKey: string;
1115
- rowId: string;
1116
- sortValues: ReadonlyArray<unknown>;
1117
- }
1118
- /** Structural mirror of `@lunora/shard-engine`'s `RankPageRow`. */
1119
- interface RankPageRowLike {
1120
- doc: Record<string, unknown>;
1121
- key: RankPageRowKeyLike;
1122
- }
1123
- /** Structural mirror of `@lunora/shard-engine`'s `ShardRankPageResult` — the `rankPageRows` return shape. */
1124
- interface ShardRankPageResultLike {
1125
- directions: ReadonlyArray<"asc" | "desc">;
1126
- hasMore: boolean;
1127
- rows: ReadonlyArray<RankPageRowLike>;
1242
+ declare const redactSecrets: (message: string) => string;
1243
+ /** One key's validation failure, secrets already redacted out of `message`. */
1244
+ interface EnvKeyFailure {
1245
+ /** The env key that failed. */
1246
+ key: string;
1247
+ /** Redacted human-readable reason. */
1248
+ message: string;
1128
1249
  }
1129
1250
  /**
1130
- * Structural mirror of `@lunora/do`'s `QueryArgs` and `CountArgs`. The
1131
- * runtime ORM in `@lunora/do`/`@lunora/d1` reads `baseWhere` /
1132
- * `restrictsCounts` straight off these option objects, so as long as the
1133
- * fields here stay name-compatible the wrapper is portable across the two
1134
- * dialects without an inter-package dependency.
1251
+ * Thrown when one or more env keys are missing or fail validation. Carries the
1252
+ * structured list of `failures` (each with the offending `key`) so callers can
1253
+ * react programmatically; `message` is the joined, secret-redacted summary.
1254
+ *
1255
+ * Named export only (no default) per the repo export convention.
1135
1256
  */
1136
- interface QueryArgs$1 {
1137
- baseWhere?: WhereInput;
1138
- cursor?: null | string;
1139
- limit?: number;
1140
- orderBy?: ReadonlyArray<unknown>;
1141
- /**
1142
- * Per-target-table read filter the RLS wrapper attaches so a `with` relation
1143
- * is policy-filtered on its own hop (see `@lunora/do`'s `QueryArgs`). Mirrors
1144
- * the top-level read: `(table) => readBase(table).baseWhere`.
1145
- */
1146
- relationBaseWhere?: (table: string) => undefined | WhereInput;
1147
- restrictsCounts?: boolean;
1148
- where?: WhereInput;
1149
- with?: Record<string, unknown>;
1150
- }
1151
- interface CountArgs {
1152
- baseWhere?: WhereInput;
1153
- relationBaseWhere?: (table: string) => undefined | WhereInput;
1154
- restrictsCounts?: boolean;
1155
- where?: WhereInput;
1156
- }
1157
- /** Structural mirror of `@lunora/do`'s `AggregateOptions` — only the fields the wrapper touches. */
1158
- interface AggregateArgs$1 {
1159
- baseWhere?: WhereInput;
1160
- field?: string;
1161
- op: string;
1162
- relationBaseWhere?: (table: string) => undefined | WhereInput;
1163
- restrictsCounts?: boolean;
1164
- where?: WhereInput;
1165
- }
1166
- /** Structural mirror of `@lunora/do`'s `GroupByOptions`. */
1167
- interface GroupByArgs$1 {
1168
- agg?: {
1169
- field?: string;
1170
- op: string;
1171
- };
1172
- baseWhere?: WhereInput;
1173
- by: ReadonlyArray<string>;
1174
- relationBaseWhere?: (table: string) => undefined | WhereInput;
1175
- restrictsCounts?: boolean;
1176
- where?: WhereInput;
1177
- }
1178
- /** Structural mirror of `@lunora/do`'s `RankOptions`. */
1179
- interface RankArgs {
1180
- baseWhere?: WhereInput;
1181
- restrictsCounts?: boolean;
1182
- row: Record<string, unknown> | string;
1183
- where?: WhereInput;
1184
- }
1185
- /** Structural mirror of `@lunora/do`'s `RankBeforeOptions`. */
1186
- interface RankBeforeArgs {
1187
- partitionKey: string;
1188
- restrictsCounts?: boolean;
1189
- rowId: string;
1190
- sortValues: ReadonlyArray<unknown>;
1191
- }
1192
- /** Structural mirror of `@lunora/do`'s `RankPageOptions`. */
1193
- interface RankPageArgs {
1194
- baseWhere?: WhereInput;
1195
- cursor?: null | string;
1196
- restrictsCounts?: boolean;
1197
- take?: number;
1198
- where?: WhereInput;
1257
+ declare class LunoraEnvError extends LunoraError$1 {
1258
+ readonly failures: ReadonlyArray<EnvKeyFailure>;
1259
+ constructor(failures: ReadonlyArray<EnvKeyFailure>);
1199
1260
  }
1200
- interface QueryPage$1 {
1201
- continueCursor: null | string;
1202
- isDone: boolean;
1203
- page: Record<string, unknown>[];
1261
+ /** A record of `v.*` validators describing the expected env shape. */
1262
+ type EnvShape = Record<string, Validator>;
1263
+ /**
1264
+ * The typed output of {@link defineEnv}. Optional validators (`v.optional(...)`)
1265
+ * become optional keys; everything else is required. Mirrors how `InferArgs`
1266
+ * derives an args object from a validator map.
1267
+ */
1268
+ 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]>; };
1269
+ /**
1270
+ * The accessor returned by {@link defineEnv}. A typed view over an `env` object
1271
+ * plus a `.parse(env)` escape hatch that validates every key eagerly.
1272
+ *
1273
+ * Call the accessor with the worker's `env` to get the typed, lazily-validated
1274
+ * proxy: `const config = defineEnv({ … }); const { PORT } = config(env);`.
1275
+ */
1276
+ interface EnvAccessor<S extends EnvShape> {
1277
+ /** Validate every key eagerly and return the typed, plain (non-proxy) object. Use for fail-fast-at-boot. */
1278
+ parse: (env: unknown) => InferEnv<S>;
1279
+ /** Lazily-validated, per-key-cached typed view over `env`. Keys are validated on first access. */
1280
+ (env: unknown): InferEnv<S>;
1204
1281
  }
1205
- interface TableReaderLike$1 {
1206
- collect: () => Promise<Record<string, unknown>[]>;
1207
- filter: (predicate: (document: Record<string, unknown>) => boolean) => TableReaderLike$1;
1208
- first: () => Promise<Record<string, unknown> | null>;
1209
- paginate: (options: {
1210
- cursor?: null | string;
1211
- numItems: number;
1212
- }) => Promise<QueryPage$1>;
1213
- take: (limit: number) => Promise<Record<string, unknown>[]>;
1214
- withGeoIndex: (indexName: string, build: (q: unknown) => unknown) => TableReaderLike$1;
1215
- withIndex: (indexName: string, range?: (q: unknown) => unknown) => TableReaderLike$1;
1216
- withSearchIndex: (indexName: string, search: (q: unknown) => unknown) => TableReaderLike$1;
1282
+ /**
1283
+ * Define a typed, validated accessor over a Worker's `env`. Pass a record of
1284
+ * `v.*` validators; receive an accessor that validates lazily per key (cached
1285
+ * per `env` identity) and infers its output type from the validators.
1286
+ *
1287
+ * ```ts
1288
+ * import { defineEnv, v } from "@lunora/server";
1289
+ *
1290
+ * const config = defineEnv({
1291
+ * STRIPE_KEY: v.string(),
1292
+ * PORT: v.optional(v.number()),
1293
+ * });
1294
+ *
1295
+ * export default {
1296
+ * fetch(request, env) {
1297
+ * const { STRIPE_KEY, PORT } = config(env); // STRIPE_KEY: string, PORT?: number
1298
+ * // …
1299
+ * },
1300
+ * };
1301
+ * ```
1302
+ *
1303
+ * Throws {@link LunoraEnvError} (secrets redacted) when a key is missing or
1304
+ * invalid — lazily on first access of that key, or eagerly via `config.parse(env)`.
1305
+ */
1306
+ declare const defineEnv: <S extends EnvShape>(shape: S) => EnvAccessor<S>;
1307
+ declare class LunoraError extends LunoraError$1 {
1308
+ constructor(code: LunoraErrorCode, message?: string, data?: unknown);
1217
1309
  }
1218
1310
  /**
1219
- * Structural projection of the runtime ORM writer. The wrapper relies only
1220
- * on these fields, so it's interchangeable between `@lunora/do`'s
1221
- * `DatabaseWriterLike` and `@lunora/d1`'s `DatabaseWriterLike`.
1311
+ * Minimal structural writer the facade binds over. Declared with **method**
1312
+ * syntax (not arrow properties) so a more-specifically-typed writer — both
1313
+ * `@lunora/do`'s `DatabaseWriterLike` and the RLS middleware's wrapped writer —
1314
+ * stays assignable under bivariant parameter checking. That is the whole reason
1315
+ * the shared helper can serve both callers, hence the rule exemption.
1222
1316
  */
1223
- interface DatabaseWriterLike {
1224
- /**
1225
- * Reduce matching rows to a scalar. The RLS wrapper AND-merges the read
1226
- * `baseWhere` into `options` so the reduction only sees policy-visible rows
1227
- * (safe: an aggregate scoped to `where` never reveals a hidden row — see
1228
- * `@lunora/do`'s `RestrictableQueryOptions`). Required: the only writer ever
1229
- * wrapped is `@lunora/do`'s `createShardCtxDb`, which always implements it.
1230
- */
1231
- aggregate: (tableName: string, options: AggregateArgs$1) => Promise<null | number>;
1232
- count: (tableName: string, whereOrArgs?: CountArgs | WhereInput) => Promise<number>;
1233
- delete: (id: string, expectedTable?: string, options?: {
1234
- hard?: boolean;
1235
- }) => Promise<void>;
1236
- /** Uncapped, chunked erase of a whole table. The RLS wrapper gates each row like a single delete. */
1237
- deleteAll?: (tableName: string, options?: {
1238
- chunkSize?: number;
1317
+ interface FacadeWriterLike {
1318
+ aggregate(tableName: string, options: unknown): Promise<unknown>;
1319
+ count(tableName: string, where?: unknown): Promise<number>;
1320
+ delete(id: string, expectedTable?: string, options?: {
1239
1321
  hard?: boolean;
1240
- }) => Promise<{
1241
- deleted: number;
1242
- }>;
1243
- deleteMany: (ids: ReadonlyArray<string>, options?: {
1322
+ }): Promise<void>;
1323
+ deleteMany?(ids: ReadonlyArray<string>, options?: {
1244
1324
  limit?: number;
1245
- }, expectedTable?: string) => Promise<{
1325
+ }, expectedTable?: string): Promise<{
1246
1326
  deleted: number;
1247
1327
  }>;
1248
- deleteWhere?: (tableName: string, where: WhereInput, options?: {
1328
+ deleteWhere?(tableName: string, where: Record<string, unknown>, options?: {
1249
1329
  limit?: number;
1250
- }) => Promise<{
1330
+ }): Promise<{
1251
1331
  deleted: number;
1252
1332
  }>;
1253
- findFirst: (tableName: string, args?: QueryArgs$1) => Promise<Record<string, unknown> | null>;
1254
- findFirstOrThrow: (tableName: string, args?: QueryArgs$1) => Promise<Record<string, unknown>>;
1255
- findMany: (tableName: string, args?: QueryArgs$1) => Promise<QueryPage$1>;
1256
- get: (id: string, expectedTable?: string) => Promise<Record<string, unknown> | null>;
1257
- /**
1258
- * Group + reduce. Same `baseWhere` injection as `aggregate`: the per-group
1259
- * reduction is scoped to policy-visible rows, so a group count tallies only
1260
- * rows the caller may read. Required for the same reason as `aggregate`.
1261
- */
1262
- groupBy: (tableName: string, options: GroupByArgs$1) => Promise<ReadonlyArray<{
1263
- key: Record<string, unknown>;
1264
- value: null | number;
1265
- }>>;
1266
- insert: (tableName: string, document: Record<string, unknown>) => Promise<string>;
1267
- insertMany: (tableName: string, documents: ReadonlyArray<Record<string, unknown>>, options?: {
1333
+ findFirst(tableName: string, args?: unknown): Promise<unknown>;
1334
+ findFirstOrThrow(tableName: string, args?: unknown): Promise<unknown>;
1335
+ findMany(tableName: string, args?: unknown): Promise<unknown>;
1336
+ get(id: string, expectedTable?: string): Promise<unknown>;
1337
+ groupBy(tableName: string, options: unknown): Promise<unknown>;
1338
+ insert(tableName: string, document: Record<string, unknown>): Promise<string>;
1339
+ insertMany?(tableName: string, documents: ReadonlyArray<Record<string, unknown>>, options?: {
1268
1340
  limit?: number;
1269
1341
  skipDuplicates?: boolean;
1270
- }) => Promise<(string | null)[]>;
1271
- insertManyUnsafe: (tableName: string, documents: ReadonlyArray<Record<string, unknown>>, options?: {
1272
- allowExplicitId?: boolean;
1273
- limit?: number;
1274
- }) => Promise<string[]>;
1275
- /**
1276
- * Optional table-aware lookup. The underlying writer (e.g. `@lunora/do`)
1277
- * already knows the owning table of an id internally, so it can return
1278
- * `{ row, tableName }` in a single round-trip. When present, the RLS wrapper
1279
- * uses it to collapse the per-call membership-probe fan-out (1 `get` + N
1280
- * `findFirst` across every policy table) down to one lookup. Writers that
1281
- * don't implement it fall back to the probe path.
1282
- */
1283
- lookupById?: (id: string, expectedTable?: string) => Promise<null | {
1284
- row: Record<string, unknown>;
1285
- tableName: string;
1286
- }>;
1287
- patch: (id: string, patch: Record<string, unknown>, expectedTable?: string) => Promise<void>;
1288
- patchMany: (patches: ReadonlyArray<{
1342
+ }): Promise<(string | null)[]>;
1343
+ patch(id: string, patch: Record<string, unknown>, expectedTable?: string): Promise<void>;
1344
+ patchMany?(patches: ReadonlyArray<{
1289
1345
  id: string;
1290
1346
  patch: Record<string, unknown>;
1291
1347
  }>, options?: {
1292
1348
  limit?: number;
1293
- }, expectedTable?: string) => Promise<{
1349
+ }, expectedTable?: string): Promise<{
1294
1350
  patched: number;
1295
1351
  }>;
1296
- patchWhere?: (tableName: string, args: {
1352
+ patchWhere?(tableName: string, args: {
1297
1353
  patch: Record<string, unknown>;
1298
- where: WhereInput;
1354
+ where: Record<string, unknown>;
1299
1355
  }, options?: {
1300
1356
  limit?: number;
1301
- }) => Promise<{
1357
+ }): Promise<{
1302
1358
  patched: number;
1303
1359
  }>;
1304
- query: (tableName: string) => TableReaderLike$1;
1305
- /**
1306
- * Rank a row within its partition. A position is a count-of-rows-before, so
1307
- * — exactly like `count()` — it can't be trusted in an RLS-restricted
1308
- * reader: the wrapper fails it closed with `COUNT_RLS_UNSUPPORTED`. Required
1309
- * for the same reason as `aggregate`.
1310
- */
1311
- rank: (tableName: string, indexName: string, options: RankArgs) => Promise<null | {
1312
- position: number;
1313
- total: number;
1314
- }>;
1315
- /** Cross-shard rank primitive — same count-of-before RLS hazard as `rank`; failed closed under a read policy. */
1316
- rankBefore?: (tableName: string, indexName: string, options: RankBeforeArgs) => Promise<{
1317
- before: number;
1318
- total: number;
1319
- }>;
1320
- /**
1321
- * Sorted pagination over a rank companion. The companion stores only the
1322
- * partition + sort keys + id, so an arbitrary read `baseWhere` can't be
1323
- * enforced against it (and re-filtering the fetched rows would break page
1324
- * sizing). RLS therefore fails it closed rather than leak hidden rows.
1325
- * Required for the same reason as `aggregate`.
1326
- */
1327
- rankPage: (tableName: string, indexName: string, options?: RankPageArgs) => Promise<QueryPage$1>;
1360
+ query(tableName: string): {
1361
+ withGeoIndex(indexName: string, build: (q: unknown) => unknown): unknown;
1362
+ withSearchIndex(indexName: string, search: (q: unknown) => unknown): unknown;
1363
+ };
1364
+ rank(tableName: string, indexName: string, options: unknown): Promise<unknown>;
1365
+ rankPage(tableName: string, indexName: string, options?: unknown): Promise<unknown>;
1366
+ replace(id: string, document: Record<string, unknown>, expectedTable?: string): Promise<void>;
1367
+ restore?(id: string, expectedTable?: string): Promise<void>;
1368
+ }
1369
+ /** The per-table accessor object returned for the `ctx.db` table form. */
1370
+ interface FacadeEntry {
1371
+ aggregate: (options: unknown) => Promise<unknown>;
1372
+ count: (where?: unknown) => Promise<number>;
1373
+ delete: (id: string) => Promise<void>;
1374
+ deleteMany: {
1375
+ (ids: ReadonlyArray<string>, options?: {
1376
+ limit?: number;
1377
+ }): Promise<{
1378
+ deleted: number;
1379
+ }>;
1380
+ (args: {
1381
+ limit?: number;
1382
+ where: Record<string, unknown>;
1383
+ }): Promise<{
1384
+ deleted: number;
1385
+ }>;
1386
+ };
1387
+ /** `true` when at least one row matches `where` (or any row exists when omitted). Honors RLS like `findFirst`. */
1388
+ exists: (where?: unknown) => Promise<boolean>;
1389
+ findFirst: (args?: unknown) => Promise<unknown>;
1390
+ findFirstOrThrow: (args?: unknown) => Promise<unknown>;
1391
+ findMany: (args?: unknown) => Promise<unknown>;
1392
+ get: (id: string) => Promise<unknown>;
1393
+ groupBy: (options: unknown) => Promise<unknown>;
1394
+ /** Physically remove a row (and physically cascade), bypassing `.softDelete()`. */
1395
+ hardDelete: (id: string) => Promise<void>;
1396
+ insert: (document: Record<string, unknown>, options?: FacadeInsertOptions) => Promise<null | string>;
1328
1397
  /**
1329
- * Cross-shard companion to `rankPage`: same ranked slice, but each row
1330
- * keeps its rank-key tuple for the query coordinator's k-way merge. Same
1331
- * count-of-partition RLS hazard as `rankPage` failed closed under a read
1332
- * policy for the identical reason (see `rankPage` above).
1398
+ * Insert many documents into this table in one call. With
1399
+ * `{ skipDuplicates: true }`, UNIQUE breaches resolve to `null` for that row
1400
+ * instead of failing the batch. The typed facade narrows the return to
1401
+ * `Id<T>[]` when skipDuplicates is not requested.
1333
1402
  */
1334
- rankPageRows?: (tableName: string, indexName: string, options?: RankPageArgs) => Promise<ShardRankPageResultLike>;
1335
- replace: (id: string, document: Record<string, unknown>, expectedTable?: string) => Promise<void>;
1336
- restore?: (id: string, expectedTable?: string) => Promise<void>;
1403
+ insertMany: (documents: ReadonlyArray<Record<string, unknown>>, options?: {
1404
+ limit?: number;
1405
+ skipDuplicates?: boolean;
1406
+ }) => Promise<(string | null)[]>;
1407
+ patch: (id: string, patch: Record<string, unknown>) => Promise<void>;
1408
+ patchMany: {
1409
+ (patches: ReadonlyArray<{
1410
+ id: string;
1411
+ values: Record<string, unknown>;
1412
+ }>, options?: {
1413
+ limit?: number;
1414
+ }): Promise<{
1415
+ patched: number;
1416
+ }>;
1417
+ (args: {
1418
+ limit?: number;
1419
+ values: Record<string, unknown>;
1420
+ where: Record<string, unknown>;
1421
+ }): Promise<{
1422
+ patched: number;
1423
+ }>;
1424
+ };
1425
+ rank: (indexName: string, options: unknown) => Promise<unknown>;
1426
+ rankPage: (indexName: string, options?: unknown) => Promise<unknown>;
1427
+ replace: (id: string, document: Record<string, unknown>) => Promise<void>;
1428
+ /** Un-soft-delete a row: clears the `.softDelete()` marker (by-id, so it reaches a row list reads hide). */
1429
+ restore: (id: string) => Promise<void>;
1430
+ /** Insert when no row matches `target`, else patch the match. Composes `findFirst` + `insert`/`patch`, so RLS applies to each step. */
1431
+ upsert: (args: UpsertArgs) => Promise<UpsertResult>;
1432
+ /** Sequential `upsert` over many rows sharing one `target`; returns one result per input row in order. */
1433
+ upsertMany: (args: UpsertManyArgs) => Promise<UpsertResult[]>;
1434
+ withGeoIndex: (indexName: string, build: (q: unknown) => unknown) => unknown;
1435
+ withSearchIndex: (indexName: string, search: (q: unknown) => unknown) => unknown;
1436
+ }
1437
+ /** Options accepted by the per-table `insert` accessor. */
1438
+ interface FacadeInsertOptions {
1337
1439
  /**
1338
- * Whole-shard erase. The RLS wrapper deliberately **fails this closed** rather
1339
- * than wrapping it see the wrapper's `wipeShard`.
1440
+ * When `true`, a UNIQUE-constraint breach is swallowed: the insert becomes a
1441
+ * silent no-op and resolves to `null` instead of throwing a `CONFLICT`. Any
1442
+ * other error still propagates. Mirrors better-drizzle's `create({ skipDuplicates })`.
1340
1443
  */
1341
- wipeShard?: (options?: {
1342
- chunkSize?: number;
1343
- exclude?: ReadonlyArray<string>;
1344
- tables?: ReadonlyArray<string>;
1345
- }) => Promise<{
1346
- deleted: number;
1347
- tables: Record<string, number>;
1444
+ skipDuplicates?: boolean;
1445
+ }
1446
+ /** The conflict target for `upsert`/`upsertMany`: one field name or a tuple of them. */
1447
+ type UpsertTarget = ReadonlyArray<string> | string;
1448
+ /** Argument to the per-table `upsert` accessor. */
1449
+ interface UpsertArgs {
1450
+ /** Document inserted when no existing row matches the `target`. */
1451
+ create: Record<string, unknown>;
1452
+ /** Field(s) — typically a `.unique()` column or unique index — used to look up an existing row. */
1453
+ target: UpsertTarget;
1454
+ /** Patch applied when an existing row matches the `target`. Defaults to `create`. */
1455
+ update?: Record<string, unknown>;
1456
+ }
1457
+ /** Result of an `upsert`: the row's id and whether it was freshly inserted (`true`) or updated (`false`). */
1458
+ interface UpsertResult {
1459
+ created: boolean;
1460
+ id: string;
1461
+ }
1462
+ /** Argument to the per-table `upsertMany` accessor — a shared `target` plus per-row create/update payloads. */
1463
+ interface UpsertManyArgs {
1464
+ rows: ReadonlyArray<{
1465
+ create: Record<string, unknown>;
1466
+ update?: Record<string, unknown>;
1348
1467
  }>;
1468
+ target: UpsertTarget;
1469
+ }
1470
+ /**
1471
+ * Bind a structural writer to one table, producing its `ctx.db` table accessor.
1472
+ *
1473
+ * The by-id accessors (`get`/`delete`/`patch`/`replace`) forward the bound
1474
+ * `tableName` as `expectedTable` so the underlying writer scopes its id lookup
1475
+ * to this table. Without it, a branded `Id<"posts">` carrying another table's
1476
+ * id would resolve cross-table (the writer probes every table by id), letting
1477
+ * `ctx.db.posts.get(foreignId)` read — or `.delete`/`.patch`/`.replace`
1478
+ * mutate — a row in an unrelated table (IDOR). Writers that ignore the second
1479
+ * argument keep their previous global behaviour; the scoping is opt-in via this
1480
+ * forwarded name.
1481
+ */
1482
+ declare const bindTableFacade: (writer: FacadeWriterLike, tableName: string) => FacadeEntry;
1483
+ /** The kitcn-style `ctx.orm` namespace over a per-table facade map. */
1484
+ interface OrmLike {
1485
+ delete: (table: string, id: string) => Promise<void>;
1486
+ insert: (table: string) => {
1487
+ values: (document: Record<string, unknown>) => Promise<null | string>;
1488
+ };
1489
+ query: Record<string, FacadeEntry>;
1490
+ replace: (table: string, id: string) => {
1491
+ with: (document: Record<string, unknown>) => Promise<void>;
1492
+ };
1493
+ update: (table: string, id: string) => {
1494
+ set: (values: Record<string, unknown>) => Promise<void>;
1495
+ };
1349
1496
  }
1497
+ /** Build `ctx.orm` over a per-table facade map (table name → FacadeEntry). */
1498
+ declare const bindOrm: (facade: Record<string, FacadeEntry>) => OrmLike;
1499
+ /** HTTP verbs the typed {@link httpRoute} builder can bind to. */
1500
+ type HttpMethod = "DELETE" | "GET" | "HEAD" | "OPTIONS" | "PATCH" | "POST" | "PUT";
1350
1501
  /**
1351
- * What a procedure's `ctx.db` must structurally satisfy for the middleware
1352
- * to wrap it. We deliberately mirror `@lunora/do`'s `DatabaseWriterLike`
1353
- * rather than `@lunora/server`'s nominal `DatabaseWriter`/`DatabaseReader`:
1354
- * the runtime adapter that flows in is the `DatabaseWriterLike`-shaped one,
1355
- * and structural matching keeps this module free of an `@lunora/do`-typed
1356
- * `ctx`.
1502
+ * Context handed to an HTTP action handler. A narrower view of {@link ActionContext}:
1503
+ * HTTP actions run in the worker (the "action runtime"), separate from the
1504
+ * transactional store, so there is no direct `db` / `vectors` surface — reach the
1505
+ * data layer through `runQuery` / `runMutation` / `runAction`, which forward to
1506
+ * the owning shard. `db`'s absence is principled: an HTTP handler is not
1507
+ * transactional.
1508
+ *
1509
+ * `scheduler` and `storage` ARE present, because neither needs the shard — the
1510
+ * scheduler talks to the scheduler DO, and R2 is a worker binding an HTTP
1511
+ * handler can reach where an action does. Both are optional: each exists only
1512
+ * when the app declared the matching capability (`.scheduler(...)` /
1513
+ * `.storage(...)`) on the generated app builder.
1514
+ *
1515
+ * Omitting them was costly out of proportion to the gap. Without `scheduler`,
1516
+ * "receive webhook → enqueue the real work → return 200" — the shape HTTP
1517
+ * actions exist for — forced a hop through a mutation plus a closed allow-list
1518
+ * of target strings, because a function reference cannot cross the RPC boundary
1519
+ * and a free-form target on an unauthenticated endpoint is a "call any internal
1520
+ * function" primitive. Without `storage`, any helper the ctx was threaded into
1521
+ * had to be typed for its storage-touching branch, so a handler was barred from
1522
+ * the helper even on the branches that never went near storage.
1357
1523
  */
1358
- type RlsDatabase = DatabaseWriterLike;
1359
- /** Roles list source on the context. Tolerant of older auth states. */
1360
- type AuthLike = {
1361
- getIdentity?: () => Promise<Record<string, unknown> | null>;
1362
- roles?: ReadonlyArray<string>;
1363
- userId?: null | string;
1524
+ type HttpActionCtx = Pick<ActionCtx, "auth" | "cache" | "fetch" | "runAction" | "runMutation" | "runQuery"> & {
1525
+ readonly scheduler?: ActionCtx["scheduler"];
1526
+ readonly storage?: ActionCtx["storage"];
1364
1527
  };
1365
- /** Minimal shape the middleware needs on the incoming ctx. */
1366
- interface RlsContextIn {
1367
- auth?: AuthLike;
1368
- db: RlsDatabase;
1369
- }
1370
- declare const rls: <Context extends RlsContextIn = RlsContextIn>(policies: ReadonlyArray<Policy<Context>>, options?: RlsOptions) => Middleware<Context, Context>;
1528
+ /** A raw handler wrapped by {@link httpAction}. Receives the raw request, returns the raw response. */
1529
+ type HttpActionHandler = (context: HttpActionCtx, request: Request) => Promise<Response> | Response;
1371
1530
  /**
1372
- * The prefixed tables a single plugin `P` contributes, or an empty map when it
1373
- * ships no schema extension. Mirrors {@link PrefixedTables} at the plugin level
1374
- * so {@link InstalledTables} can fold a tuple of plugins.
1531
+ * The hono {@link https://hono.dev | Hono} environment used by {@link httpRouter}.
1532
+ * The runtime injects the per-request {@link HttpActionCtx} on the private
1533
+ * `__lunoraCtx` binding; the router's lifting middleware promotes it to
1534
+ * `c.var.lunora` so handlers can read it as a typed variable.
1375
1535
  */
1376
- type ExtensionTablesOf<P> = P extends {
1377
- readonly extension: SchemaExtension<infer X> & {
1378
- readonly key: infer K;
1536
+ interface LunoraHttpEnv {
1537
+ Bindings: Record<string, unknown> & {
1538
+ __lunoraCtx?: HttpActionCtx;
1379
1539
  };
1380
- } ? K extends string ? PrefixedTables<X, K> : Record<never, never> : Record<never, never>;
1540
+ Variables: {
1541
+ lunora: HttpActionCtx;
1542
+ };
1543
+ }
1544
+ /** The hono app type {@link httpRouter} returns. */
1545
+ type LunoraHttpApp = Hono<LunoraHttpEnv>;
1546
+ /** A compiled route handler: a hono handler that resolves to a raw {@link Response}. */
1547
+ type LunoraRouteHandler = (c: Context<LunoraHttpEnv>) => Promise<Response>;
1381
1548
  /**
1382
- * Fold a tuple of plugins onto a base table map `T`, accumulating each plugin's
1383
- * auto-prefixed extension tables left-to-right the type-level mirror of
1384
- * {@link installPlugins} applying `mergeSchemaExtension` for each plugin in turn.
1549
+ * Wrap a `(ctx, request) => Response` handler as a hono handler. The raw escape
1550
+ * hatch mount it with `app.all(path, httpAction(fn))`. `ctx` is the
1551
+ * runtime-injected {@link HttpActionCtx} lifted into `c.var.lunora` by
1552
+ * {@link httpRouter}; `request` is the underlying `c.req.raw`.
1385
1553
  */
1386
- type InstalledTables<T extends Record<string, TableDefinition>, Plugins extends ReadonlyArray<unknown>> = Plugins extends readonly [infer Head, ...infer Rest] ? InstalledTables<ExtensionTablesOf<Head> & T, Rest> : T;
1554
+ declare const httpAction: (handler: HttpActionHandler) => LunoraRouteHandler;
1387
1555
  /**
1388
- * Union every plugin's `ContextOut` in a tuple the type-level mirror of the
1389
- * `ctx.api.<key>` additions {@link composePluginMiddleware} accumulates as each
1390
- * plugin middleware runs. Independent of the incoming context, which the builder
1391
- * infers at the `.use(...)` site.
1556
+ * Create the hono app for HTTP actions. Pre-wired with a middleware that lifts
1557
+ * the runtime-injected `c.env.__lunoraCtx` into `c.var.lunora`, so both
1558
+ * {@link httpAction} and the typed {@link httpRoute} builder can read the action
1559
+ * context. The full hono surface is available — plugins, path params, `.route`:
1560
+ *
1561
+ * ```ts
1562
+ * const app = httpRouter();
1563
+ * app.use("*", cors());
1564
+ * app.post("/webhook", httpAction(onWebhook));
1565
+ * app.get("/users/:id", getUser);
1566
+ * export default createWorker({ httpRouter: app, ... });
1567
+ * ```
1568
+ *
1569
+ * The lifting middleware throws if the context is absent. `createWorker` injects
1570
+ * it on every request the router sees, so this only trips when the app is run
1571
+ * outside the runtime — a misconfiguration we surface loudly rather than let
1572
+ * `c.var.lunora` be silently `undefined` despite its non-optional type.
1392
1573
  */
1393
- 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;
1574
+ declare const httpRouter: () => LunoraHttpApp;
1575
+ /** The `{ ctx, searchParams, body, params }` a typed route handler receives. */
1576
+ interface HttpRouteHandlerOptions<SearchParams extends ArgsValidator, Body extends ArgsValidator, Params extends ArgsValidator> {
1577
+ body: InferArgs<Body>;
1578
+ ctx: HttpActionCtx;
1579
+ params: InferArgs<Params>;
1580
+ searchParams: InferArgs<SearchParams>;
1581
+ }
1394
1582
  /**
1395
- * Schema fragment a plugin contributes. Same shape as the `tables` map
1396
- * passed to `defineSchema`. Optional `vectorIndexes` mirror the top-level
1397
- * `defineSchema` argument so a plugin can ship vector decls alongside its
1398
- * tables.
1583
+ * The `{ ctx, searchParams, params, request, signal }` a streaming HTTP
1584
+ * handler receives. There is no parsed `body` streams are typically GET, and
1585
+ * the raw `request` is exposed if a handler needs to read the body itself.
1586
+ * `signal` is tripped when the client disconnects.
1587
+ * @experimental Part of the HTTP-SSE stream surface; reconnect/POST-body design questions are still open.
1399
1588
  */
1400
- interface SchemaExtension<T extends Record<string, TableDefinition> = Record<string, TableDefinition>> {
1401
- /** Stable key identifying the plugin that owns this extension. */
1402
- readonly key: string;
1589
+ interface HttpStreamHandlerOptions<SearchParams extends ArgsValidator, Params extends ArgsValidator> {
1590
+ ctx: HttpActionCtx;
1591
+ params: InferArgs<Params>;
1592
+ request: Request;
1593
+ searchParams: InferArgs<SearchParams>;
1594
+ signal: AbortSignal;
1595
+ }
1596
+ /**
1597
+ * A typed REST route under construction. `.searchParams()` / `.body()` /
1598
+ * `.params()` accumulate validator maps (later calls merge, a colliding key
1599
+ * wins) that decode the URL query, JSON body, and hono path params into the
1600
+ * handler's typed `searchParams` / `body` / `params`. Like the procedure
1601
+ * builder, `.output(validator)` defaults to the `undefined` sentinel — while
1602
+ * unset the handler is generic over its own return; once set the handler must
1603
+ * return that type and the result is parsed through the validator before
1604
+ * serialization. `[Output] extends [undefined]` is tuple-wrapped so a union
1605
+ * `Output` doesn't distribute and the test is for the exact sentinel.
1606
+ *
1607
+ * The terminal `.handler()` yields a {@link LunoraRouteHandler} — mount it
1608
+ * directly with `app.get(path, route)`.
1609
+ */
1610
+ interface HttpRouteBuilder<SearchParams extends ArgsValidator, Body extends ArgsValidator, Params extends ArgsValidator, Output = undefined> {
1611
+ body: <B extends ArgsValidator>(validators: B) => HttpRouteBuilder<SearchParams, B & Body, Params, Output>;
1403
1612
  /**
1404
- * Extension tables, keyed by **bare** name (e.g. `buckets`). At merge time
1405
- * each is auto-prefixed with `key` (`ratelimit_buckets`) so it can't
1406
- * collide with an app table; do **not** namespace manually.
1613
+ * Attach a `Cache-Control` header to the response. Only meaningful when
1614
+ * Workers Cache is enabled in `wrangler.jsonc` (`"cache": { "enabled": true }`).
1407
1615
  */
1408
- readonly tables: T;
1616
+ cacheControl: (value: string) => HttpRouteBuilder<SearchParams, Body, Params, Output>;
1409
1617
  /**
1410
- * Optional standalone vector indexes the plugin ships, keyed by index
1411
- * name. Merged into the host schema's `vectorIndexes`; a key collision
1412
- * with the base schema is a hard error (same policy as tables).
1618
+ * Attach a `Cache-Tag` header to the response for tag-based purging via
1619
+ * `ctx.cache.purge({ tags: [...] })`.
1413
1620
  */
1414
- readonly vectorIndexes?: Record<string, VectorIndexDefinition>;
1621
+ cacheTag: (value: string) => HttpRouteBuilder<SearchParams, Body, Params, Output>;
1622
+ 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;
1623
+ output: <V extends Validator>(validator: V) => HttpRouteBuilder<SearchParams, Body, Params, Infer<V>>;
1624
+ params: <P extends ArgsValidator>(validators: P) => HttpRouteBuilder<SearchParams, Body, P & Params, Output>;
1625
+ searchParams: <S extends ArgsValidator>(validators: S) => HttpRouteBuilder<S & SearchParams, Body, Params, Output>;
1626
+ /**
1627
+ * Terminal: declare this route as a streaming Server-Sent Events endpoint.
1628
+ * The handler is an async generator (or any function returning an
1629
+ * `AsyncIterable<R>`) that yields one chunk per SSE `data:` frame; on
1630
+ * iterator completion the route writes a final `event: complete` frame; on
1631
+ * throw, an `event: error` frame is written with `{code, message}` before
1632
+ * the stream closes. The chunks are JSON-encoded; `R` is inferred from the
1633
+ * handler's yielded type.
1634
+ * @experimental Reconnect/POST-body/wire-fidelity design questions are still open, so the shape may change.
1635
+ */
1636
+ stream: <R>(handler: (options: HttpStreamHandlerOptions<SearchParams, Params>) => AsyncGenerator<R, void, void> | AsyncIterable<R>) => LunoraRouteHandler;
1637
+ /**
1638
+ * Attach a `Vary` header to the response so Cloudflare stores separate
1639
+ * cached variants per distinct value of the listed request headers.
1640
+ */
1641
+ vary: (value: string) => HttpRouteBuilder<SearchParams, Body, Params, Output>;
1642
+ }
1643
+ /** Opens a fresh {@link HttpRouteBuilder}. The `path` documents intent; hono owns the actual routing at mount. */
1644
+ type HttpRouteFactory = (path: string) => HttpRouteBuilder<EmptyArgs, EmptyArgs, EmptyArgs>;
1645
+ /** The verb-keyed entry point: `httpRoute.get("/api/todos")…`. */
1646
+ interface HttpRoute {
1647
+ delete: HttpRouteFactory;
1648
+ get: HttpRouteFactory;
1649
+ head: HttpRouteFactory;
1650
+ options: HttpRouteFactory;
1651
+ patch: HttpRouteFactory;
1652
+ post: HttpRouteFactory;
1653
+ put: HttpRouteFactory;
1654
+ }
1655
+ /**
1656
+ * Typed REST route builder. Compiles down to a {@link LunoraRouteHandler}, so a
1657
+ * typed route and a hand-written {@link httpAction} are interchangeable when
1658
+ * mounted on {@link httpRouter}:
1659
+ *
1660
+ * ```ts
1661
+ * export const listTodos = httpRoute
1662
+ * .get("/api/todos")
1663
+ * .searchParams({ limit: v.number(), q: v.optional(v.string()) })
1664
+ * .output(v.array(v.object({ id: v.string(), text: v.string() })))
1665
+ * .handler(async ({ ctx, searchParams }) => ctx.runQuery(api.todos.list, searchParams));
1666
+ *
1667
+ * export const getTodo = httpRoute
1668
+ * .get("/api/todos/:id")
1669
+ * .params({ id: v.string() })
1670
+ * .handler(async ({ ctx, params }) => ctx.runQuery(api.todos.get, params));
1671
+ *
1672
+ * const app = httpRouter();
1673
+ * app.get("/api/todos", listTodos);
1674
+ * app.get("/api/todos/:id", getTodo);
1675
+ * ```
1676
+ */
1677
+ declare const httpRoute: HttpRoute;
1678
+ /**
1679
+ * Structural view of an R2 object body, as returned by `@lunora/storage`'s
1680
+ * `download()`. Re-declared here (not imported) so `@lunora/server` takes no
1681
+ * runtime dependency on `@lunora/storage`; the real binding satisfies the shape.
1682
+ */
1683
+ interface StorageObjectBody {
1684
+ /** The object body stream (`null` for a zero-byte object). */
1685
+ body: ReadableStream | null;
1686
+ etag: string;
1687
+ httpMetadata?: {
1688
+ contentType?: string;
1689
+ };
1690
+ key: string;
1691
+ /** Hex SHA-256, when R2 carries a checksum (surfaced by `@lunora/storage`). */
1692
+ sha256?: string;
1693
+ /** Base64 SHA-256 (RFC 9530 digest encoding), when R2 carries a checksum. */
1694
+ sha256Base64?: string;
1695
+ size: number;
1696
+ }
1697
+ /** Byte window forwarded to `download()` so R2 streams just the requested slice. */
1698
+ interface StorageRange {
1699
+ length: number;
1700
+ offset: number;
1415
1701
  }
1416
1702
  /**
1417
- * Build a {@link SchemaExtension}. The `key` is a runtime tag (used for
1418
- * error messages on collision) and a type-level brand.
1419
- */
1420
- declare const defineSchemaExtension: <T extends Record<string, TableDefinition>>(key: string, options: {
1421
- tables: T;
1422
- vectorIndexes?: Record<string, VectorIndexDefinition>;
1423
- }) => SchemaExtension<T>;
1424
- /**
1425
- * A plugin packages an optional schema extension and optional middleware.
1426
- * Both are independently usable: an app can install only the schema (e.g.
1427
- * for plugins that ship background workers but no per-request behavior)
1428
- * or only the middleware (plugins that augment ctx without persistent
1429
- * state).
1703
+ * The minimal storage surface {@link serveStorageObject} needs: a metadata-rich
1704
+ * `download`, plus the body-free `head` a range request resolves against.
1705
+ *
1706
+ * `head` is required rather than optional-with-a-fallback because the fallback
1707
+ * is the bug: without it a ranged request has to start a full-object `download`
1708
+ * just to learn the size, then throw that body away. `@lunora/storage`'s `head`
1709
+ * already degrades internally to a 0-length ranged `get()` on a binding with no
1710
+ * HEAD, so there is nothing a caller here could usefully do that it does not.
1430
1711
  */
1431
- interface Plugin<TExtension extends Record<string, TableDefinition> = Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn> {
1432
- /**
1433
- * Optional schema extension. Apps install via
1434
- * `defineSchema(...).extend(plugin.extension)`.
1435
- */
1436
- readonly extension?: SchemaExtension<TExtension>;
1437
- /** Stable key identifying the plugin. Matches `extension.key` when set. */
1438
- readonly key: string;
1439
- /**
1440
- * Optional middleware. Users attach with `c.query.use(plugin.middleware)`.
1441
- * The middleware can extend `ctx`; convention is to attach helpers under
1442
- * `ctx.api.<key>`, e.g.
1443
- *
1444
- * ```ts
1445
- * middleware: ({ ctx, next }) =>
1446
- * next({ ctx: { api: { ...ctx.api, ratelimit: api } } })
1447
- * ```
1448
- */
1449
- readonly middleware?: Middleware<TContextIn, TContextOut>;
1712
+ interface StorageHead {
1713
+ /** Object metadata with no body. `size` is the FULL object size (mirrors R2). */
1714
+ head: (key: string) => Promise<Omit<StorageObjectBody, "body"> | null>;
1450
1715
  }
1451
- /** Options to {@link definePlugin}. */
1452
- interface DefinePluginOptions<TExtension extends Record<string, TableDefinition>, TContextIn, TContextOut> {
1453
- extension?: SchemaExtension<TExtension>;
1454
- middleware?: Middleware<TContextIn, TContextOut>;
1716
+ /** The storage surface {@link serveStorageObject} reads through. */
1717
+ interface StorageDownloader extends StorageHead {
1718
+ download: (key: string, options?: {
1719
+ range?: StorageRange;
1720
+ }) => Promise<StorageObjectBody | null>;
1455
1721
  }
1456
- /**
1457
- * Call signatures for {@link definePlugin}. When `extension` is supplied the
1458
- * returned plugin's `extension` is typed as PRESENT (not `?`), so the
1459
- * canonical install pattern `defineSchema(...).extend(plugin.extension)`
1460
- * typechecks without a non-null assertion — the shape every scaffold template
1461
- * ships. The bare-options signature keeps `extension` optional for plugins
1462
- * that carry only middleware.
1463
- */
1464
- interface DefinePluginFunction {
1465
- <TExtension extends Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn>(key: string, options: DefinePluginOptions<TExtension, TContextIn, TContextOut> & {
1466
- extension: SchemaExtension<TExtension>;
1467
- }): Plugin<TExtension, TContextIn, TContextOut> & {
1468
- readonly extension: SchemaExtension<TExtension>;
1469
- };
1470
- <TExtension extends Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn>(key: string, options: DefinePluginOptions<TExtension, TContextIn, TContextOut>): Plugin<TExtension, TContextIn, TContextOut>;
1722
+ /** Any ctx that carries a {@link StorageDownloader} on `.storage` (Query/Mutation/Action ctx all do). */
1723
+ interface ContextWithStorage {
1724
+ storage: StorageDownloader;
1471
1725
  }
1472
1726
  /**
1473
- * Package a schema extension + middleware as a reusable plugin. Either
1474
- * field is optional `definePlugin("foo", {})` is valid but degenerate.
1727
+ * True when `value` is safe to use as an HTTP header field-value: no CR, LF, or
1728
+ * NUL. Guards against response-header injection / `Headers`-construction throws
1729
+ * when reflecting attacker-influenced object metadata (e.g. a stored
1730
+ * `Content-Type`). Exported (see the `export {}` at the file end) so an `httpAction`
1731
+ * handler can guard a request-derived header value before writing it — the fix the
1732
+ * `http_action_response_header_injection` advisor lint points to.
1475
1733
  */
1476
- declare const definePlugin: DefinePluginFunction;
1734
+ declare const isSafeHeaderValue: (value: string) => boolean;
1477
1735
  /**
1478
- * Bundle of registered functions a {@link Component} ships. Keys are the
1479
- * function's local name (e.g. `check`, `reset`); the registered function
1480
- * value carries its own kind / args / handler.
1481
- *
1482
- * Users re-export from their own lunora module so codegen picks them up:
1483
- *
1484
- * ```ts
1485
- * // lunora/ratelimit.ts
1486
- * import { ratelimit } from "@vendor/ratelimit-component";
1487
- * export const { check, reset } = ratelimit.functions;
1488
- * // Emits as `ratelimit:check` / `ratelimit:reset` in the generated `api`.
1489
- * ```
1736
+ * Stream a stored object as an HTTP {@link Response} from an `httpAction`
1737
+ * handler, with correct `Content-Type`, `ETag`, and `Accept-Ranges: bytes`.
1738
+ * Honors a single-range `Range` request **206 Partial Content** with
1739
+ * `Content-Range` + `Content-Length`; otherwise **200**. A missing object is a
1740
+ * **404**; an out-of-bounds range is a **416** with a `Content-Range` of
1741
+ * `bytes` star-slash-size.
1490
1742
  *
1491
- * Codegen follows the re-export back to the bundled `query/mutation/action`
1492
- * call (property access or destructuring both work), so the functions land in
1493
- * the generated `api` under the re-exporting file's namespace.
1743
+ * A range request resolves its window against a body-free `head()`, then issues
1744
+ * ONE `download()` with the resolved `{ offset, length }` so R2 streams just
1745
+ * those bytes — the slice is never buffered in the isolate, and no full-object
1746
+ * body transfer is started only to be cancelled. A request that cannot produce a
1747
+ * 206 at all (no `Range`, multi-range, malformed) skips the `head()` entirely and
1748
+ * streams straight from a single `download()`. For very
1749
+ * large objects a signed URL (`ctx.storage.getSignedUrl`) is still cheaper since
1750
+ * the client then ranges against R2/CDN directly with no Worker hop.
1494
1751
  */
1495
- type ComponentFunctions = Readonly<Record<string, RegisteredFunction<any, any, FunctionKind>>>;
1752
+ declare const serveStorageObject: (context: ContextWithStorage, key: string, request: Request) => Promise<Response>;
1496
1753
  /**
1497
- * Component = {@link Plugin} with a bundle of registered functions. The
1498
- * extension + middleware + functions are independent: a component can ship
1499
- * functions without a schema (e.g. a stateless utility), or a schema
1500
- * without functions (e.g. shared table definitions), and any combination.
1754
+ * What the worker does with a resolver's identity when it fails contract
1755
+ * validation (a forged / malformed claim set arriving from an untrusted token).
1756
+ * `"anonymous"` (default, safe) treats the request as anonymous, so the bad
1757
+ * identity never reaches a policy as a valid identity (`ctx.auth.userId`
1758
+ * becomes `undefined`). `"reject"` fails the request closed (a `401`) — use
1759
+ * when a malformed credential should be a hard error, not a silent downgrade.
1501
1760
  */
1502
- interface Component<TExtension extends Record<string, TableDefinition> = Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn, F extends ComponentFunctions = ComponentFunctions> extends Plugin<TExtension, TContextIn, TContextOut> {
1503
- readonly functions: F;
1504
- }
1505
- interface DefineComponentOptions<TExtension extends Record<string, TableDefinition>, TContextIn, TContextOut, F extends ComponentFunctions> extends DefinePluginOptions<TExtension, TContextIn, TContextOut> {
1506
- /** Registered functions the component ships. Keys are the function's local name. */
1507
- functions?: F;
1761
+ type IdentityRejectMode = "anonymous" | "reject";
1762
+ /** Options for {@link defineIdentity}. */
1763
+ interface DefineIdentityOptions {
1764
+ /**
1765
+ * How to handle a resolver identity that violates the contract at the trust
1766
+ * boundary. Defaults to `"anonymous"` (a forged claim set is downgraded to
1767
+ * anonymous rather than flowing in as an unchecked cast).
1768
+ */
1769
+ readonly onInvalid?: IdentityRejectMode;
1508
1770
  }
1771
+ /** Result of validating a candidate identity against the contract. */
1772
+ type IdentityValidation = {
1773
+ ok: true;
1774
+ } | {
1775
+ error: string;
1776
+ ok: false;
1777
+ };
1509
1778
  /**
1510
- * Convenience wrapper around {@link definePlugin} that also bundles a set
1511
- * of registered functions. The resulting `component.functions` object is a
1512
- * record of `name registered query/mutation/action`; consumers
1513
- * re-export entries so codegen discovers them as user functions:
1514
- *
1515
- * ```ts
1516
- * export const ratelimit = defineComponent("ratelimit", {
1517
- * // Bare `buckets` merges in as `ratelimit_buckets`.
1518
- * extension: defineSchemaExtension("ratelimit", { tables: { buckets } }),
1519
- * middleware: ({ ctx, next }) => next({ ctx: { ...ctx, ratelimit: api(ctx) } }),
1520
- * functions: {
1521
- * check: query.input({ key: v.string() }).query(async ({ ctx, args }) => ...),
1522
- * reset: mutation.input({ key: v.string() }).mutation(async ({ ctx, args }) => ...),
1523
- * },
1524
- * });
1525
- * ```
1526
- *
1527
- * Re-exporting an entry (by property access or destructuring) is enough for
1528
- * codegen to discover it in the host app's namespace — the discovery resolver
1529
- * chases the re-export back to the bundled registration call.
1779
+ * A declared identity claim contract. Carries the codegen discovery brand, the
1780
+ * declared claim validators, the reject policy, and a runtime `validate`. The
1781
+ * `TClaims` type parameter is the inferred claim shape (always extending
1782
+ * `{ userId: string }`); it is phantom (no runtime field) and exists so
1783
+ * `@lunora/codegen` and {@link InferIdentity} can recover the type.
1530
1784
  */
1531
- 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>;
1785
+ interface IdentityContract<TClaims extends {
1786
+ userId: string;
1787
+ } = {
1788
+ userId: string;
1789
+ }> {
1790
+ /**
1791
+ * Phantom carrier for the inferred claim type. Never populated at runtime
1792
+ * (`undefined`); present only so the type flows to codegen / {@link InferIdentity}.
1793
+ */
1794
+ readonly __claimType?: TClaims;
1795
+ readonly __lunoraIdentity: true;
1796
+ /** The declared claim validators (a `@lunora/values` validator map). */
1797
+ readonly claims: ValidatorMap;
1798
+ /** Reject policy applied at the trust boundary. See {@link IdentityRejectMode}. */
1799
+ readonly onInvalid: IdentityRejectMode;
1800
+ /**
1801
+ * Validate a resolver's returned identity against the declared claims. On
1802
+ * success the caller keeps the original identity untouched (so undeclared
1803
+ * claims are forwarded verbatim, preserving today's behaviour); on failure
1804
+ * the worker applies the `onInvalid` policy.
1805
+ */
1806
+ validate: (identity: Record<string, unknown>) => IdentityValidation;
1807
+ }
1808
+ /** Recover the declared claim type from a {@link defineIdentity} contract. */
1809
+ type InferIdentity<T> = T extends IdentityContract<infer TClaims> ? TClaims : never;
1532
1810
  /**
1533
- * Map every key `K` of an extension's table map `X` to its auto-prefixed name
1534
- * `${Key}_${K}`. Mirrors the runtime prefixing in {@link mergeSchemaExtension}
1535
- * so the typed `.extend(...)` chain reflects the real merged table names.
1811
+ * Declare the identity claim contract. `claims` is a `@lunora/values` validator
1812
+ * map whose inferred type must extend `{ userId: string }` — if it does not
1813
+ * (e.g. `userId` is missing or not a required string), the argument type
1814
+ * collapses to `never` and the call fails to typecheck.
1815
+ * @example
1816
+ * export const identity = defineIdentity({ userId: v.string(), tenantId: v.optional(v.string()), scopes: v.optional(v.array(v.string())) });
1536
1817
  */
1537
- type PrefixedTables<X extends Record<string, TableDefinition>, Key extends string> = { [K in keyof X as K extends string ? `${Key}_${K}` : K]: X[K]; };
1818
+ declare const defineIdentity: <A extends ValidatorMap>(claims: InferValidatorMap<A> extends {
1819
+ userId: string;
1820
+ } ? A : never, options?: DefineIdentityOptions) => IdentityContract<InferValidatorMap<A> & {
1821
+ userId: string;
1822
+ }>;
1823
+ /** Handler for a connection-lifecycle hook. */
1824
+ type LifecycleHandler = (context: MutationCtx, event: LifecycleEvent) => Promise<void> | void;
1825
+ /** Handler for a shard-init hook. */
1826
+ type ShardInitHandler = (context: MutationCtx, event: ShardInitEvent) => Promise<void> | void;
1827
+ /** Register a hook that fires once when a client's WebSocket connects. */
1828
+ declare const onConnect: (handler: LifecycleHandler) => RegisteredLifecycleHook;
1829
+ /** Register a hook that fires once when a client's WebSocket disconnects. */
1830
+ declare const onDisconnect: (handler: LifecycleHandler) => RegisteredLifecycleHook;
1538
1831
  /**
1539
- * Merge a {@link SchemaExtension} into an existing schema. Returns a new
1540
- * schema object never mutates the input.
1541
- *
1542
- * Extension tables are auto-namespaced: each bare table name is prefixed with
1543
- * the extension `key` (`buckets` → `ratelimit_buckets`), Convex-Components
1544
- * style, and every intra-extension reference (relation targets, aggregate /
1545
- * rank index `on`, standalone vector index `table`) is rewritten to match.
1546
- * References to base/app tables are left untouched.
1547
- *
1548
- * Because each extension lives in its own `key` namespace, app↔component
1549
- * collisions are impossible. The only remaining hard error is two extensions
1550
- * sharing the same `key` and producing the same prefixed table (or vector
1551
- * index) name — silent shadow would let one plugin hijack another's data.
1832
+ * Register a hook that fires ONCE per Durable Object instance, before any
1833
+ * handler on that instance can run — the re-init half of `.memory()` tables.
1552
1834
  *
1553
- * Re-runs {@link validateIndexFields} against the merged table set before
1554
- * returning: `defineSchema` only validates the tables it was called with, so
1555
- * without this an extension-contributed index with a typo'd/out-of-shape
1556
- * field (or a duplicate name within one kind) would never be checked at all.
1557
- * Re-validating the whole merged set (base + prefixed extension tables) is
1558
- * cheap and idempotent for the base tables, which already passed this same
1559
- * check when the base schema was built. Both callers of this function —
1560
- * `withExtend.extend()` (`./schema`) and `installPlugins` (below) — get the
1561
- * re-validation for free from this single call site (plan 258 §4/§9 Q3).
1562
- */
1563
- declare const mergeSchemaExtension: <T extends Record<string, TableDefinition>, X extends Record<string, TableDefinition>, Key extends string = string>(base: Schema<T>, extension: SchemaExtension<X> & {
1564
- readonly key: Key;
1565
- }) => Schema<PrefixedTables<X, Key> & T>;
1566
- /**
1567
- * Install several plugins' schema extensions in one call — the one-shot
1568
- * counterpart to chaining `defineSchema(...).extend(a).extend(b)`. Plugins
1569
- * without an `extension` (middleware-only) are skipped; tables from those that
1570
- * do are auto-prefixed and reference-rewritten exactly as
1571
- * {@link mergeSchemaExtension} does for a single `.extend(...)`.
1835
+ * A shard is not a process that stays up. Cloudflare reconstructs the Durable
1836
+ * Object after every eviction, and a shard whose sockets are hibernating is
1837
+ * evicted routinely, so "cold start" is a steady-state event rather than a rare
1838
+ * one. Everything the shard held in memory is gone at that moment: the JS heap,
1839
+ * and every `.memory()` table, which the framework has already cleared by the
1840
+ * time this hook runs.
1572
1841
  *
1573
1842
  * ```ts
1574
- * const schema = installPlugins(defineSchema({ todos }), [ratelimit, audit]);
1575
- * // todos + ratelimit_* + audit_*
1843
+ * // lunora/init.ts
1844
+ * import { onShardInit } from "@lunora/server";
1845
+ *
1846
+ * export const warm = onShardInit(async (ctx, event) => {
1847
+ * // Rebuild ephemeral state from the durable tables that outlived us.
1848
+ * for await (const member of ctx.db.roomMembers.iterate({ where: { roomId: event.shardKey } })) {
1849
+ * await ctx.db.presence.insert({ userId: member.userId, status: "away" });
1850
+ * }
1851
+ * });
1576
1852
  * ```
1577
1853
  *
1578
- * Pair it with {@link composePluginMiddleware} to attach every plugin's
1579
- * middleware in a single `.use(...)`, so installing N plugins is two calls
1580
- * rather than N `.extend(...)` + N `.use(...)`.
1581
- */
1582
- 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>>;
1583
- /**
1584
- * Compose every plugin's middleware into a single middleware you attach with one
1585
- * `.use(...)`. Plugins without middleware (schema-only) are skipped; the rest run
1586
- * in array order, each seeing the context the previous one widened, so the final
1587
- * `next({ ctx })` the builder receives carries every plugin's `ctx.api.<key>`
1588
- * additions. Equivalent to `.use(a.middleware).use(b.middleware)…` but as one
1589
- * value, the middleware sibling of {@link installPlugins}.
1854
+ * **Ordering is the guarantee.** Memory tables are cleared, then every init hook
1855
+ * runs to completion, and only then does the dispatch that triggered the cold
1856
+ * start proceed. No handler, subscription refresh, alarm, or shape poke can
1857
+ * observe a memory table in the gap. Hooks run sequentially in manifest order,
1858
+ * so one may depend on state an earlier one wrote.
1590
1859
  *
1591
- * `ContextIn` is left free so the builder infers it from the context at the
1592
- * `.use(...)` site; the result type widens it by the union of the plugins'
1593
- * outputs.
1860
+ * **It is a mutation, and it runs on every cold start.** Keep it cheap and keep
1861
+ * it idempotent: it is on the latency path of the request that woke the shard,
1862
+ * and it will run again — many times — over the shard's life. Writing to durable
1863
+ * tables from here is legal and occasionally right, but remember it is a
1864
+ * rebuild, not a migration; use `defineMigration` for anything that should
1865
+ * happen once.
1866
+ *
1867
+ * **No caller identity.** The hook dispatches as a trusted system call with no
1868
+ * request identity — `ctx.auth` is anonymous and RLS does not apply even under
1869
+ * `.rls("required")`, exactly as for a cron tick or a migration. RLS scopes rows
1870
+ * to a user and an init hook has none, so `ctx.db` here sees every row: scope
1871
+ * your reads yourself. (`onConnect`/`onDisconnect` are the opposite case — they
1872
+ * carry the socket's verified identity and stay RLS-guarded.)
1873
+ *
1874
+ * A throw is logged and does NOT fail the dispatch that woke the shard — an init
1875
+ * hook that cannot rebuild presence must not take the whole shard down with it.
1876
+ * The table is then cleared but not refilled, so reads see nothing. A failure
1877
+ * EARLIER, before the framework's clear runs, instead leaves the previous
1878
+ * instance's rows in place: a memory table's rows live in SQLite until they are
1879
+ * deleted, so an eviction on its own does not remove them.
1594
1880
  */
1595
- 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>;
1596
- /** Options for `.vectorize(field, opts)` (DSL Shape A). */
1597
- interface VectorizeOptions<Shape extends Record<string, Validator> = Record<string, Validator>> {
1598
- dimensions: number;
1599
- embed: VectorEmbedder;
1600
- /** Logical index name; must match a `[[vectorize]]` binding in wrangler. */
1601
- index: string;
1602
- /** Fields mirrored into Vectorize metadata for filtering. */
1603
- metadata?: ReadonlyArray<keyof Shape & string>;
1604
- metric: VectorMetric;
1605
- }
1606
- /** A `one` (many-to-one) relation descriptor; phantom `Target` carries the target table name. */
1607
- interface OneRelation<Target extends string = string> extends RelationDefinition {
1608
- readonly __target?: Target;
1609
- readonly kind: "one";
1610
- }
1611
- /** A `many` (one-to-many) relation descriptor; phantom `Target` carries the target table name. */
1612
- interface ManyRelation<Target extends string = string> extends RelationDefinition {
1613
- readonly __target?: Target;
1614
- readonly kind: "many";
1615
- }
1616
- /** The `r` argument passed to `.relations((r) => …)`. */
1617
- interface RelationBuilder {
1618
- /** One-to-many: the FK `field` lives on the target table, matching this table's `references` (default `_id`). */
1619
- many: <Target extends string>(table: Target, options: {
1620
- field: string;
1621
- references?: string;
1622
- }) => ManyRelation<Target>;
1623
- /** Many-to-one: the FK `field` lives on this table, pointing at `table`.`references` (default `_id`). */
1624
- one: <Target extends string>(table: Target, options: {
1625
- field: string;
1626
- onDelete?: OnDeleteAction;
1627
- references?: string;
1628
- }) => OneRelation<Target>;
1629
- }
1881
+ declare const onShardInit: (handler: ShardInitHandler) => RegisteredLifecycleHook;
1882
+ /** Default `limit` when the caller doesn't ask for one. */
1883
+ declare const DEFAULT_LIMIT = 25;
1884
+ /** Default ceiling on `limit`, so one request can't ask for an unbounded page. */
1885
+ declare const DEFAULT_MAX_LIMIT = 100;
1630
1886
  /**
1631
- * Options for the inline `.aggregateIndex(name, opts)` builder. `op` defaults to
1632
- * `count` so `aggregateIndex("byUser", { by: ["userId"] })` is a single-line
1633
- * `COUNT(*) GROUP BY userId` accelerator.
1887
+ * Per-field predicate accepted for a declared filter column. An alias of the
1888
+ * `ctx.db` `where` DSL's own operator type rather than a copy, so the two cannot
1889
+ * drift as operators are added.
1634
1890
  */
1635
- interface InlineAggregateIndexOptions<Shape extends Record<string, Validator> = Record<string, Validator>> {
1636
- /** Group keys; counter rows are one per distinct tuple. Omitted = single-row aggregate over the whole table. */
1637
- by?: ReadonlyArray<keyof Shape & string>;
1638
- /** The column the reducer applies to. Required for `sum`/`min`/`max`/`avg`; ignored for `count`. */
1639
- field?: keyof Shape & string;
1640
- /** Reducer (default `count`). */
1641
- op?: AggregateOp;
1642
- /** Static predicate baked into the counter — only matching rows are aggregated. */
1643
- where?: Record<string, unknown>;
1644
- }
1891
+ type ListFilterOperators<T> = WhereOperators<T>;
1645
1892
  /**
1646
- * Options for the inline `.rankIndex(name, opts)` builder. `sortBy` is required;
1647
- * accepts either an array of `{ field, direction }` keys, or the shorthand
1648
- * `["field"]` (asc) / `{ field: "desc" }` map entries. `partitionBy` scopes the
1649
- * rank omitted one global rank over the whole table.
1893
+ * The filter allow-list a caller may declare: a subset of the document's own
1894
+ * columns, each with a validator for that column's type. Constraining the KEYS to
1895
+ * `keyof Doc` is what turns a typo'd or renamed column into a compile error
1896
+ * instead of a predicate that silently never matches.
1650
1897
  */
1651
- interface InlineRankIndexOptions<Shape extends Record<string, Validator> = Record<string, Validator>> {
1652
- /** Columns that scope each ranking; omitted one global rank. */
1653
- partitionBy?: ReadonlyArray<keyof Shape & string>;
1654
- /** Ordered sort keys driving the rank. Required. */
1655
- sortBy: ReadonlyArray<{
1656
- direction?: "asc" | "desc";
1657
- field: keyof Shape & string;
1658
- }>;
1659
- /** Static predicate baked into the index; only matching rows enter. */
1660
- where?: Record<string, unknown>;
1898
+ type ListFilterShape<TDocument> = { [K in keyof TDocument & string]?: Validator<TDocument[K]>; };
1899
+ /** The `where` argument: each declared filter column, optionally, as a bare value or an operator object. */
1900
+ type ListWhere<F> = { [K in keyof F]?: Infer<NonNullable<F[K]>> | ListFilterOperators<Infer<NonNullable<F[K]>>>; };
1901
+ /** One `orderBy` entry. `direction` defaults to `"asc"`. */
1902
+ interface ListOrderByEntry<O extends string> {
1903
+ direction?: "asc" | "desc";
1904
+ field: O;
1661
1905
  }
1662
- interface TableBuilder<Shape extends Record<string, Validator> = Record<string, Validator>> extends TableDefinition<Shape> {
1663
- /** Declare an aggregate (counter/sum/…) maintained by triggers for O(1) reads. */
1664
- aggregateIndex: (name: string, options?: InlineAggregateIndexOptions<Shape>) => TableBuilder<Shape>;
1665
- /**
1666
- * Stamp every row with `_commitSeq` — a per-shard integer, allocated once
1667
- * per mutation and strictly increasing in **commit order**, refreshed on
1668
- * every write to the row (insert, patch, replace, and the marker flip a
1669
- * `.softDelete()` performs).
1670
- *
1671
- * `_creationTime` is wall-clock and therefore cannot order commits: the
1672
- * clock is read when the handler runs, the write lands when the transaction
1673
- * commits, and nothing ties those instants together. A changefeed paging on
1674
- * `_creationTime` can skip a row permanently. Paging on `_commitSeq`
1675
- * (`where: { _commitSeq: { gt: cursor } }, orderBy: ["_commitSeq"]`) cannot.
1676
- *
1677
- * It orders COMMITS, not rows: one mutation's rows share a value. A bounded
1678
- * page can therefore end mid-group, so a consumer must checkpoint at a
1679
- * sequence it has seen the whole of, never at the last row of a full page.
1680
- * An action's writes are the exception to the grouping — they commit
1681
- * independently, so each gets its own sequence.
1682
- *
1683
- * Ordered, not contiguous — read a gap as "nothing to see", never as loss.
1684
- * Per-shard, not global: two shards allocate independently, so a cursor is
1685
- * only meaningful against the shard it came from. Rejected on `.global()`
1686
- * tables, which have no shard-local transaction to allocate inside.
1687
- *
1688
- * **A hard delete is invisible to the feed.** The sequence lives on the row,
1689
- * so a physically removed row takes it along: the row stops appearing, but
1690
- * no event says it went away. Pair `.commitOrdered()` with `.softDelete()`
1691
- * when the feed must observe deletes — the tombstone flip is an UPDATE, so
1692
- * it advances the sequence and pages through like any other change.
1693
- */
1694
- commitOrdered: () => TableBuilder<Shape>;
1695
- /**
1696
- * Mark this table as written outside Lunora's discoverable insert path —
1697
- * by an adapter, a migration, or framework middleware (e.g. `@lunora/auth`'s
1698
- * better-auth tables, `@lunora/ratelimit`'s store). Advisor insert-path lints
1699
- * (`table_without_insert`) then skip it instead of flagging the absent
1700
- * `ctx.db.insert(...)`.
1701
- */
1702
- externallyManaged: () => TableBuilder<Shape>;
1703
- /**
1704
- * Declare a geospatial index over a `v.geoPoint()` column. The runtime keeps
1705
- * a geohash companion so `withGeoIndex(name, q => q.near(point, radius))` and
1706
- * `.within(bbox)` resolve as a geohash-prefix range scan + Haversine
1707
- * refine/sort. `options.precision` tunes the geohash length (default 9).
1708
- */
1709
- geoIndex: (name: string, options: {
1710
- field: keyof Shape & string;
1711
- precision?: number;
1712
- }) => TableBuilder<Shape>;
1713
- /**
1714
- * Mark this table as global (cross-shard). Backed by **D1** by default;
1715
- * pass `{ backend: "hyperdrive" }` to store it in a Postgres/MySQL database
1716
- * via Cloudflare Hyperdrive (PlanetScale, Neon, …) instead. Either way the
1717
- * table stays reactive — live queries re-run on write.
1718
- */
1719
- global: (options?: {
1720
- backend?: GlobalBackend;
1721
- }) => TableBuilder<Shape>;
1722
- /** Add a secondary index. */
1723
- index: (name: string, fields: ReadonlyArray<(keyof Shape & string) | (typeof SYSTEM_INDEX_FIELDS)[number]>, options?: {
1724
- unique?: boolean;
1725
- }) => TableBuilder<Shape>;
1906
+ /** The decoded arguments a {@link defineListArgs} endpoint receives. */
1907
+ interface ListArgsValue<F, O extends string> {
1908
+ cursor?: null | number | string;
1909
+ limit?: number;
1910
+ orderBy?: ListOrderByEntry<O>[];
1911
+ where?: ListWhere<F>;
1912
+ }
1913
+ interface DefineListArgsConfig<F, O extends string> {
1914
+ /** `limit` applied when the caller omits one. Defaults to 25. */
1915
+ readonly defaultLimit?: number;
1726
1916
  /**
1727
- * Declare this table EPHEMERAL state the shard rebuilds rather than
1728
- * remembers.
1729
- *
1730
- * A memory table is a full `ctx.db` table: indexes, `where`, `orderBy`,
1731
- * pagination, relations, live queries. What it is not is durable. Its rows
1732
- * are wiped the moment the Durable Object is reconstructed — which happens
1733
- * on every eviction, and a WebSocket-hibernating shard is evicted often — so
1734
- * a memory table holds only what can be derived again: presence and cursors,
1735
- * a live participant list, a rate-limit window, an actor's scratch state.
1736
- *
1737
- * Pair it with `onShardInit` to rebuild whatever the app needs present.
1738
- * The framework guarantees the ordering: every memory table is cleared, and
1739
- * every init hook has run, before any handler can read one. Without a hook a
1740
- * memory table simply comes back empty, which is a correct state for
1741
- * presence and a wrong one for a cache someone is treating as authoritative.
1742
- *
1743
- * **On Cloudflare the rows still transit the DO's SQLite.** workerd exposes
1744
- * exactly one SQL handle and no memory-backed database, so `.memory()` buys
1745
- * the LIFETIME (and skips the CDC changelog, so an append-heavy presence
1746
- * table does not grow the op-log), not the write. Treat it as "state I am
1747
- * happy to lose", not as "state that is free to write" — see
1748
- * `PlatformCapabilities.memoryTables`, rated `emulated` for exactly this
1749
- * reason.
1750
- *
1751
- * Rejected alongside `.global()` (a D1 table is not this shard's to clear),
1752
- * `.commitOrdered()` (a sequence that resets is not a sequence), and
1753
- * `.source()` (an externally-materialized table is not ours to wipe).
1917
+ * Allow-list of filterable columns. Publish only columns an index can serve;
1918
+ * anything absent here is unreachable from the client.
1754
1919
  */
1755
- memory: () => TableBuilder<Shape>;
1920
+ readonly filter: F;
1921
+ /** Ceiling on `in` / `notIn` array length — one bound parameter each. Defaults to 100. */
1922
+ readonly maxInValues?: number;
1923
+ /** Ceiling on `limit`; a larger request is clamped down, not rejected. Defaults to 100. */
1924
+ readonly maxLimit?: number;
1925
+ /** Ceiling on how many `orderBy` entries a request may ask for. Defaults to 8. */
1926
+ readonly maxOrderBy?: number;
1927
+ /** Allow-list of sortable columns. Pass `[]` to fix the order server-side. */
1928
+ readonly orderBy: ReadonlyArray<O>;
1929
+ }
1930
+ /** The validator map handed to `.input()`. Typed precisely so `args` infers end-to-end. */
1931
+ interface ListArgsValidators<F, O extends string> {
1932
+ cursor: ColumnValidator<null | number | string | undefined, null | number | string | undefined>;
1933
+ limit: ColumnValidator<number | undefined, number | undefined>;
1934
+ orderBy: ColumnValidator<ListOrderByEntry<O>[] | undefined, ListOrderByEntry<O>[] | undefined>;
1935
+ where: ColumnValidator<ListWhere<F> | undefined, ListWhere<F> | undefined>;
1936
+ }
1937
+ interface ListArgsSpec<TDocument, F, O extends string> {
1938
+ /** Spread into `.input(...)` — `{ cursor, limit, orderBy, where }`. */
1939
+ readonly args: ListArgsValidators<F, O>;
1756
1940
  /**
1757
- * Name the column holding the owning user's id, so "only the owner sees these
1758
- * rows" is declared once here rather than restated in every shape.
1759
- *
1760
- * A `defineShape({ table, owner: true })` over this table derives its predicate
1761
- * from the field: the subscriber's verified `ctx.auth.userId` must match, and an
1762
- * anonymous subscriber is denied. Pairs naturally with `.shardBy(field)` on the
1763
- * same column — the shard key routes the storage, `ownedBy` states who the rows
1764
- * belong to — but the two are independent and either can be used alone.
1941
+ * Translate the decoded arguments into the `findMany` options object:
1942
+ * `limit` clamped into `[1, maxLimit]`, `orderBy` reshaped from
1943
+ * `{ field, direction }[]` into `ctx.db`'s `{ column: direction }[]`.
1765
1944
  *
1766
- * This is a *shape* declaration, not an RLS policy: it narrows what a shape
1767
- * replicates. Guarding procedure reads/writes is still `rls(...)`'s job.
1945
+ * Returns `QueryArgs<Doc>` bound to the table, not free so a mismatch
1946
+ * between what this helper declares and what the table actually holds is a
1947
+ * compile error at the `findMany` call site.
1768
1948
  */
1769
- ownedBy: (field: keyof Shape & string) => TableBuilder<Shape>;
1949
+ readonly toQueryArgs: (args: ListArgsValue<F, O>) => QueryArgs$2<TDocument>;
1950
+ }
1951
+ /** Clamp a caller-supplied `limit` into `[1, maxLimit]`; a non-finite value falls back to `fallback`. */
1952
+ declare const clampLimit: (limit: number | undefined, fallback: number, maxLimit: number) => number;
1953
+ /**
1954
+ * Declare the filter / sort / page arguments for a list endpoint, plus the
1955
+ * translation into `ctx.db.<table>.findMany(...)` options. See the module docs
1956
+ * for the shape and the reasoning behind it.
1957
+ *
1958
+ * Curried on the document type: `defineListArgs<Doc<"messages">>()({ … })`. The
1959
+ * extra `()` buys the thing that matters — with `Doc` bound, `filter` keys and
1960
+ * `orderBy` entries are checked against the table's real columns, so a typo or a
1961
+ * column renamed out from under the endpoint is a COMPILE error instead of a
1962
+ * predicate that silently matches nothing. TypeScript has no partial type-argument
1963
+ * inference, so binding `Doc` explicitly while still inferring `F` and `O` from
1964
+ * the config requires the second call.
1965
+ */
1966
+ declare const defineListArgs: <TDocument>() => <F extends ListFilterShape<TDocument>, O extends keyof TDocument & string>(config: DefineListArgsConfig<F, O>) => ListArgsSpec<TDocument, F, O>;
1967
+ /**
1968
+ * Structural mirrors of `@lunora/shard-engine`'s rank-page-row shapes
1969
+ * (`RankPageRowKey` / `RankPageRow` / `ShardRankPageResult`) — the return type
1970
+ * of the writer's `rankPageRows` seam, the cross-shard companion to
1971
+ * `rankPage`.
1972
+ *
1973
+ * Shared by `../rls/middleware` and `../mask/middleware`: both wrap
1974
+ * `rankPageRows` structurally (no `@lunora/shard-engine` import, mirroring how
1975
+ * every other method on their `DatabaseWriterLike`/`MaskDatabase` projections
1976
+ * is hand-mirrored rather than imported) and both need the exact same result
1977
+ * shape to type their overrides. A single copy here means the two wrappers
1978
+ * can't drift out of lockstep with each other — see AGENTS.md's platform
1979
+ * parity note on `ShardSqlExec` and the canonical binding `*Like` projections
1980
+ * shipping wrong for exactly this reason (two hand-maintained mirrors of one
1981
+ * upstream type).
1982
+ */
1983
+ /** Structural mirror of `@lunora/shard-engine`'s `RankPageRowKey`. */
1984
+ interface RankPageRowKeyLike {
1985
+ partitionKey: string;
1986
+ rowId: string;
1987
+ sortValues: ReadonlyArray<unknown>;
1988
+ }
1989
+ /** Structural mirror of `@lunora/shard-engine`'s `RankPageRow`. */
1990
+ interface RankPageRowLike {
1991
+ doc: Record<string, unknown>;
1992
+ key: RankPageRowKeyLike;
1993
+ }
1994
+ /** Structural mirror of `@lunora/shard-engine`'s `ShardRankPageResult` — the `rankPageRows` return shape. */
1995
+ interface ShardRankPageResultLike {
1996
+ directions: ReadonlyArray<"asc" | "desc">;
1997
+ hasMore: boolean;
1998
+ rows: ReadonlyArray<RankPageRowLike>;
1999
+ }
2000
+ /**
2001
+ * Structural mirror of `@lunora/do`'s `QueryArgs` and `CountArgs`. The
2002
+ * runtime ORM in `@lunora/do`/`@lunora/d1` reads `baseWhere` /
2003
+ * `restrictsCounts` straight off these option objects, so as long as the
2004
+ * fields here stay name-compatible the wrapper is portable across the two
2005
+ * dialects without an inter-package dependency.
2006
+ */
2007
+ interface QueryArgs$1 {
2008
+ baseWhere?: WhereInput;
2009
+ cursor?: null | string;
2010
+ limit?: number;
2011
+ orderBy?: ReadonlyArray<unknown>;
1770
2012
  /**
1771
- * Opt this table OUT of secure-by-default RLS. Under a schema marked
1772
- * `.rls("required")`, every table is protected (the write path denies raw,
1773
- * non-RLS `ctx.db` access); calling `.public()` exempts this one table so a
1774
- * plain `query`/`mutation` may read/write it without an RLS policy. No effect
1775
- * when the schema does not require RLS.
2013
+ * Per-target-table read filter the RLS wrapper attaches so a `with` relation
2014
+ * is policy-filtered on its own hop (see `@lunora/do`'s `QueryArgs`). Mirrors
2015
+ * the top-level read: `(table) => readBase(table).baseWhere`.
1776
2016
  */
1777
- public: () => TableBuilder<Shape>;
2017
+ relationBaseWhere?: (table: string) => undefined | WhereInput;
2018
+ restrictsCounts?: boolean;
2019
+ where?: WhereInput;
2020
+ with?: Record<string, unknown>;
2021
+ }
2022
+ interface CountArgs {
2023
+ baseWhere?: WhereInput;
2024
+ relationBaseWhere?: (table: string) => undefined | WhereInput;
2025
+ restrictsCounts?: boolean;
2026
+ where?: WhereInput;
2027
+ }
2028
+ /** Structural mirror of `@lunora/do`'s `AggregateOptions` — only the fields the wrapper touches. */
2029
+ interface AggregateArgs$1 {
2030
+ baseWhere?: WhereInput;
2031
+ field?: string;
2032
+ op: string;
2033
+ relationBaseWhere?: (table: string) => undefined | WhereInput;
2034
+ restrictsCounts?: boolean;
2035
+ where?: WhereInput;
2036
+ }
2037
+ /** Structural mirror of `@lunora/do`'s `GroupByOptions`. */
2038
+ interface GroupByArgs$1 {
2039
+ agg?: {
2040
+ field?: string;
2041
+ op: string;
2042
+ };
2043
+ baseWhere?: WhereInput;
2044
+ by: ReadonlyArray<string>;
2045
+ relationBaseWhere?: (table: string) => undefined | WhereInput;
2046
+ restrictsCounts?: boolean;
2047
+ where?: WhereInput;
2048
+ }
2049
+ /** Structural mirror of `@lunora/do`'s `RankOptions`. */
2050
+ interface RankArgs {
2051
+ baseWhere?: WhereInput;
2052
+ restrictsCounts?: boolean;
2053
+ row: Record<string, unknown> | string;
2054
+ where?: WhereInput;
2055
+ }
2056
+ /** Structural mirror of `@lunora/do`'s `RankBeforeOptions`. */
2057
+ interface RankBeforeArgs {
2058
+ partitionKey: string;
2059
+ restrictsCounts?: boolean;
2060
+ rowId: string;
2061
+ sortValues: ReadonlyArray<unknown>;
2062
+ }
2063
+ /** Structural mirror of `@lunora/do`'s `RankPageOptions`. */
2064
+ interface RankPageArgs {
2065
+ baseWhere?: WhereInput;
2066
+ cursor?: null | string;
2067
+ restrictsCounts?: boolean;
2068
+ take?: number;
2069
+ where?: WhereInput;
2070
+ }
2071
+ interface QueryPage$1 {
2072
+ continueCursor: null | string;
2073
+ isDone: boolean;
2074
+ page: Record<string, unknown>[];
2075
+ }
2076
+ interface TableReaderLike$1 {
2077
+ collect: () => Promise<Record<string, unknown>[]>;
2078
+ filter: (predicate: (document: Record<string, unknown>) => boolean) => TableReaderLike$1;
2079
+ first: () => Promise<Record<string, unknown> | null>;
2080
+ paginate: (options: {
2081
+ cursor?: null | string;
2082
+ numItems: number;
2083
+ }) => Promise<QueryPage$1>;
2084
+ take: (limit: number) => Promise<Record<string, unknown>[]>;
2085
+ withGeoIndex: (indexName: string, build: (q: unknown) => unknown) => TableReaderLike$1;
2086
+ withIndex: (indexName: string, range?: (q: unknown) => unknown) => TableReaderLike$1;
2087
+ withSearchIndex: (indexName: string, search: (q: unknown) => unknown) => TableReaderLike$1;
2088
+ }
2089
+ /**
2090
+ * Structural projection of the runtime ORM writer. The wrapper relies only
2091
+ * on these fields, so it's interchangeable between `@lunora/do`'s
2092
+ * `DatabaseWriterLike` and `@lunora/d1`'s `DatabaseWriterLike`.
2093
+ */
2094
+ interface DatabaseWriterLike {
1778
2095
  /**
1779
- * Declare a rank index (sorted companion table, btree-backed) for
1780
- * `rank(row)` / `rankPage()` reads in O(log n). See {@link RankIndexDefinition}.
2096
+ * Reduce matching rows to a scalar. The RLS wrapper AND-merges the read
2097
+ * `baseWhere` into `options` so the reduction only sees policy-visible rows
2098
+ * (safe: an aggregate scoped to `where` never reveals a hidden row — see
2099
+ * `@lunora/do`'s `RestrictableQueryOptions`). Required: the only writer ever
2100
+ * wrapped is `@lunora/do`'s `createShardCtxDb`, which always implements it.
1781
2101
  */
1782
- rankIndex: (name: string, options: InlineRankIndexOptions<Shape>) => TableBuilder<Shape>;
1783
- /** Declare relations to other tables, loaded via `findMany({ with })`. */
1784
- relations: (build: (r: RelationBuilder) => Record<string, RelationDefinition>) => TableBuilder<Shape>;
2102
+ aggregate: (tableName: string, options: AggregateArgs$1) => Promise<null | number>;
2103
+ count: (tableName: string, whereOrArgs?: CountArgs | WhereInput) => Promise<number>;
2104
+ delete: (id: string, expectedTable?: string, options?: {
2105
+ hard?: boolean;
2106
+ }) => Promise<void>;
2107
+ /** Uncapped, chunked erase of a whole table. The RLS wrapper gates each row like a single delete. */
2108
+ deleteAll?: (tableName: string, options?: {
2109
+ chunkSize?: number;
2110
+ hard?: boolean;
2111
+ }) => Promise<{
2112
+ deleted: number;
2113
+ }>;
2114
+ deleteMany: (ids: ReadonlyArray<string>, options?: {
2115
+ limit?: number;
2116
+ }, expectedTable?: string) => Promise<{
2117
+ deleted: number;
2118
+ }>;
2119
+ deleteWhere?: (tableName: string, where: WhereInput, options?: {
2120
+ limit?: number;
2121
+ }) => Promise<{
2122
+ deleted: number;
2123
+ }>;
2124
+ findFirst: (tableName: string, args?: QueryArgs$1) => Promise<Record<string, unknown> | null>;
2125
+ findFirstOrThrow: (tableName: string, args?: QueryArgs$1) => Promise<Record<string, unknown>>;
2126
+ findMany: (tableName: string, args?: QueryArgs$1) => Promise<QueryPage$1>;
2127
+ get: (id: string, expectedTable?: string) => Promise<Record<string, unknown> | null>;
1785
2128
  /**
1786
- * Add a full-text search index over `field`, queried with
1787
- * `.withSearchIndex(name, q => q.search(field, term))`. `field` may be a
1788
- * dot-separated path into a nested object (`"properties.name"`).
1789
- * `filterFields` (at most 16) lists the columns `.eq()` may narrow by inside
1790
- * the search. `language` selects the text analysis (accent folding always,
1791
- * plus that language's stopwords). `staged: true` skips the migration-time
1792
- * backfill on a large existing table — pre-existing rows stay unsearchable
1793
- * until `__lunora_admin__:backfillSearch` is run against the deployment. `strategy: "native"` uses the engine's
1794
- * own full-text index where it has one (Postgres) — faster on large corpora,
1795
- * at the cost of the engine ranking rather than the shared scorer.
2129
+ * Group + reduce. Same `baseWhere` injection as `aggregate`: the per-group
2130
+ * reduction is scoped to policy-visible rows, so a group count tallies only
2131
+ * rows the caller may read. Required for the same reason as `aggregate`.
1796
2132
  */
1797
- searchIndex: (name: string, options: {
1798
- field: string;
1799
- filterFields?: ReadonlyArray<string>;
1800
- language?: SearchLanguage;
1801
- staged?: boolean;
1802
- strategy?: SearchStrategy;
1803
- }) => TableBuilder<Shape>;
1804
- /** Route storage by the named field — one DO per distinct value. */
1805
- shardBy: (field: keyof Shape & string) => TableBuilder<Shape>;
2133
+ groupBy: (tableName: string, options: GroupByArgs$1) => Promise<ReadonlyArray<{
2134
+ key: Record<string, unknown>;
2135
+ value: null | number;
2136
+ }>>;
2137
+ insert: (tableName: string, document: Record<string, unknown>) => Promise<string>;
2138
+ insertMany: (tableName: string, documents: ReadonlyArray<Record<string, unknown>>, options?: {
2139
+ limit?: number;
2140
+ skipDuplicates?: boolean;
2141
+ }) => Promise<(string | null)[]>;
2142
+ insertManyUnsafe: (tableName: string, documents: ReadonlyArray<Record<string, unknown>>, options?: {
2143
+ allowExplicitId?: boolean;
2144
+ limit?: number;
2145
+ }) => Promise<string[]>;
1806
2146
  /**
1807
- * Turn on soft delete. Adds a nullable timestamp column (`options.field`,
1808
- * default `deletedAt`) and changes `ctx.db.<table>.delete()` to **set** it
1809
- * instead of removing the row; `onDelete: "cascade"` children are recursively
1810
- * soft-deleted too. **List reads** (`findMany`/`findFirst`/`query()`/`count`/
1811
- * `aggregate`/relation loads) then hide soft-deleted rows unless they pass
1812
- * `includeDeleted: true`; by-id `get`/`patch`/`replace` and the new
1813
- * `restore()` still address the row directly. `hardDelete()` physically
1814
- * removes it (cascading as a real delete). Note: `includeDeleted` is a read
1815
- * scope, not access control — anyone who can run the read can set it; a unique
1816
- * index still rejects a new row that collides with a soft-deleted one (the row
1817
- * physically persists).
2147
+ * Optional table-aware lookup. The underlying writer (e.g. `@lunora/do`)
2148
+ * already knows the owning table of an id internally, so it can return
2149
+ * `{ row, tableName }` in a single round-trip. When present, the RLS wrapper
2150
+ * uses it to collapse the per-call membership-probe fan-out (1 `get` + N
2151
+ * `findFirst` across every policy table) down to one lookup. Writers that
2152
+ * don't implement it fall back to the probe path.
1818
2153
  */
1819
- softDelete: (options?: {
1820
- field?: string;
1821
- }) => TableBuilder<Shape>;
2154
+ lookupById?: (id: string, expectedTable?: string) => Promise<null | {
2155
+ row: Record<string, unknown>;
2156
+ tableName: string;
2157
+ }>;
2158
+ patch: (id: string, patch: Record<string, unknown>, expectedTable?: string) => Promise<void>;
2159
+ patchMany: (patches: ReadonlyArray<{
2160
+ id: string;
2161
+ patch: Record<string, unknown>;
2162
+ }>, options?: {
2163
+ limit?: number;
2164
+ }, expectedTable?: string) => Promise<{
2165
+ patched: number;
2166
+ }>;
2167
+ patchWhere?: (tableName: string, args: {
2168
+ patch: Record<string, unknown>;
2169
+ where: WhereInput;
2170
+ }, options?: {
2171
+ limit?: number;
2172
+ }) => Promise<{
2173
+ patched: number;
2174
+ }>;
2175
+ query: (tableName: string) => TableReaderLike$1;
1822
2176
  /**
1823
- * Materialize this table from an external Postgres/MySQL behind Cloudflare
1824
- * Hyperdrive (plan 077). A system-driven poll loop reads the tenant slice
1825
- * (`query`, with params bound from `tenantBy`) and lands it in the DO's SQLite,
1826
- * after which `defineShape` carries it to clients unchanged. Implies
1827
- * `.externallyManaged()` (rows come from the ingest loop, not user mutations).
1828
- *
1829
- * Orthogonal to `.shardBy()` — combine them for per-tenant DOs. **Under
1830
- * `.shardBy()` `tenantBy` is mandatory** (the tenant-isolation boundary); the
1831
- * `external_source_unscoped` advisor lint fails the build when it is absent, and
1832
- * `external_source_on_global` rejects combining `.source()` with `.global()`.
2177
+ * Rank a row within its partition. A position is a count-of-rows-before, so
2178
+ * exactly like `count()` it can't be trusted in an RLS-restricted
2179
+ * reader: the wrapper fails it closed with `COUNT_RLS_UNSUPPORTED`. Required
2180
+ * for the same reason as `aggregate`.
1833
2181
  */
1834
- source: (definition: ExternalSourceDefinition) => TableBuilder<Shape>;
1835
- /** Declare named lifecycle triggers fired inline within the write path. */
1836
- triggers: (build: (t: TriggerBuilder<Shape>) => Record<string, TriggerDefinition>) => TableBuilder<Shape>;
2182
+ rank: (tableName: string, indexName: string, options: RankArgs) => Promise<null | {
2183
+ position: number;
2184
+ total: number;
2185
+ }>;
2186
+ /** Cross-shard rank primitive — same count-of-before RLS hazard as `rank`; failed closed under a read policy. */
2187
+ rankBefore?: (tableName: string, indexName: string, options: RankBeforeArgs) => Promise<{
2188
+ before: number;
2189
+ total: number;
2190
+ }>;
1837
2191
  /**
1838
- * Declare a table-level TTL: a DO alarm-driven sweep auto-deletes rows whose
1839
- * expiry has passed (or soft-deletes them when the table also
1840
- * `.softDelete()`s). `field` is an epoch-millisecond column; without
1841
- * `options.after` its value is the absolute expiry instant, with `after` the
1842
- * row expires `after` ms past `field` (`field + after`). Coarse, cheap,
1843
- * table-level — for per-row schedules use `@lunora/scheduler`.
2192
+ * Sorted pagination over a rank companion. The companion stores only the
2193
+ * partition + sort keys + id, so an arbitrary read `baseWhere` can't be
2194
+ * enforced against it (and re-filtering the fetched rows would break page
2195
+ * sizing). RLS therefore fails it closed rather than leak hidden rows.
2196
+ * Required for the same reason as `aggregate`.
1844
2197
  */
1845
- ttl: (field: keyof Shape & string, options?: {
1846
- after?: number;
1847
- }) => TableBuilder<Shape>;
1848
- /** Declare a vector index over a single text field on this table. */
1849
- vectorize: (field: keyof Shape & string, options: VectorizeOptions<Shape>) => TableBuilder<Shape>;
1850
- }
1851
- /** Options for `defineVectorIndex(...)` (DSL Shape B). */
1852
- interface VectorIndexOptions {
1853
- dimensions: number;
1854
- embed: VectorEmbedder;
1855
- /** Optional projection of the source row into Vectorize metadata. */
1856
- metadata?: (row: Record<string, unknown>) => Record<string, unknown>;
1857
- metric: VectorMetric;
1858
- /** The vector source: which table, and how to derive the embedded text. */
1859
- source: {
1860
- select: (row: Record<string, unknown>) => string;
1861
- table: string;
1862
- };
1863
- }
1864
- /**
1865
- * Build a table definition. Returned object is both the table definition (for
1866
- * `defineSchema`) and a fluent builder for indexes + sharding metadata.
1867
- */
1868
- declare const defineTable: <Shape extends Record<string, Validator>>(inputShape: Shape) => TableBuilder<Shape>;
1869
- /**
1870
- * Declare a standalone vector index (DSL Shape B). Pass the returned value in
1871
- * the `vectorIndexes` map of {@link defineSchema} when the source is derived
1872
- * from multiple fields or a computation rather than a single column.
1873
- */
1874
- declare const defineVectorIndex: (options: VectorIndexOptions) => VectorIndexDefinition;
1875
- /**
1876
- * Options for the standalone `defineAggregateIndex(name, opts)` helper (DSL
1877
- * Shape B). Unlike the inline `.aggregateIndex(...)` builder, this form takes
1878
- * the owning table explicitly via `on` — handy when a single counter wants to
1879
- * live next to the schema map rather than inside a table chain.
1880
- */
1881
- interface AggregateIndexOptions {
1882
- by?: ReadonlyArray<string>;
1883
- field?: string;
1884
- on: string;
1885
- op?: AggregateOp;
1886
- where?: Record<string, unknown>;
1887
- }
1888
- /**
1889
- * Declare a standalone aggregate index. Pass the returned value to
1890
- * `defineSchema(tables, vectorIndexes, aggregateIndexes)` keyed by index name —
1891
- * the schema attaches it to `tables[on].aggregateIndexes` so runtime consumers
1892
- * (DO + D1) read every index uniformly off the table definition.
1893
- */
1894
- declare const defineAggregateIndex: (name: string, options: AggregateIndexOptions) => AggregateIndexDefinition;
1895
- /**
1896
- * Options for the standalone `defineRankIndex(name, opts)` helper (DSL Shape B).
1897
- * Mirrors the inline `.rankIndex(...)` builder but takes the owning table via
1898
- * `table` so it can sit next to the schema map.
1899
- */
1900
- interface RankIndexOptions {
1901
- partitionBy?: ReadonlyArray<string>;
1902
- sortBy: ReadonlyArray<{
1903
- direction?: "asc" | "desc";
1904
- field: string;
1905
- }>;
1906
- table: string;
1907
- where?: Record<string, unknown>;
1908
- }
1909
- /**
1910
- * Declare a standalone rank index. Pass the returned value to
1911
- * `defineSchema(tables, vectorIndexes, aggregateIndexes, rankIndexes)` keyed
1912
- * by index name — the schema attaches it to `tables[on].rankIndexes`.
1913
- */
1914
- declare const defineRankIndex: (name: string, options: RankIndexOptions) => RankIndexDefinition;
1915
- /**
1916
- * Build the application schema. The first argument is the table map; the
1917
- * optional second argument registers standalone `defineVectorIndex(...)`
1918
- * declarations (DSL Shape B) keyed by index name. The optional third argument
1919
- * registers standalone `defineAggregateIndex(...)` declarations (DSL Shape B);
1920
- * the optional fourth argument registers standalone `defineRankIndex(...)`
1921
- * declarations. Both are folded into the matching `tables[on].*Indexes` array
1922
- * so runtime backends read every index uniformly off the table definition.
1923
- */
1924
- /**
1925
- * Schema with an in-place `.extend(plugin.extension)` method. Used so apps
1926
- * can compose plugin schemas: `defineSchema({...}).extend(authPlugin.extension)`.
1927
- *
1928
- * `extend` is non-mutating — returns a fresh `ExtendableSchema` containing
1929
- * the merged tables. Extension tables are auto-namespaced by the extension
1930
- * `key` (`buckets` → `ratelimit_buckets`), so the merged type carries the
1931
- * prefixed names via {@link PrefixedTables}. Chains:
1932
- * `defineSchema(...).extend(a).extend(b)` is the typed equivalent of merging
1933
- * `a`'s prefixed tables then `b`'s.
1934
- */
1935
- type ExtendableSchema<T extends Record<string, TableDefinition>> = {
1936
- extend: <X extends Record<string, TableDefinition>, Key extends string>(extension: SchemaExtension<X> & {
1937
- readonly key: Key;
1938
- }) => ExtendableSchema<PrefixedTables<X, Key> & T>;
2198
+ rankPage: (tableName: string, indexName: string, options?: RankPageArgs) => Promise<QueryPage$1>;
1939
2199
  /**
1940
- * Pin every Durable Object the app reaches shards, fan-out, subscriptions,
1941
- * the scheduler, and `ctx.containers` to a Cloudflare data-residency
1942
- * jurisdiction (`"eu"`, `"us"`, `"fedramp"`). Codegen reads this off the
1943
- * schema and emits it into the generated worker's `createWorker({ jurisdiction })`
1944
- * (and `ctx.scheduler` / `ctx.containers`). Non-mutating: returns a fresh
1945
- * `ExtendableSchema`, so it composes with `.rls(...)` / `.extend(...)` in any order.
1946
- *
1947
- * ⚠️ **Set this once, before your first deploy — changing or removing it
1948
- * strands data.** A Durable Object name maps to a *different* ID in each
1949
- * jurisdiction, so toggling this on an existing app makes every shard, scheduler
1950
- * job, and session DO resolve to a NEW, empty DO; the previous data stays in the
1951
- * old jurisdiction's DOs and is no longer reachable. There is no in-place
1952
- * migration — you would have to export from the old jurisdiction and import
1953
- * into the new one.
1954
- *
1955
- * Note: this pins **DO-backed** state only. D1-backed state — `.global()`
1956
- * tables and `@lunora/auth` sessions alike — is governed by D1's own location
1957
- * settings, not this option.
1958
- * @see https://developers.cloudflare.com/durable-objects/reference/data-location/
2200
+ * Cross-shard companion to `rankPage`: same ranked slice, but each row
2201
+ * keeps its rank-key tuple for the query coordinator's k-way merge. Same
2202
+ * count-of-partition RLS hazard as `rankPage` failed closed under a read
2203
+ * policy for the identical reason (see `rankPage` above).
1959
2204
  */
1960
- jurisdiction: (jurisdiction: DurableObjectJurisdiction) => ExtendableSchema<T>;
2205
+ rankPageRows?: (tableName: string, indexName: string, options?: RankPageArgs) => Promise<ShardRankPageResultLike>;
2206
+ replace: (id: string, document: Record<string, unknown>, expectedTable?: string) => Promise<void>;
2207
+ restore?: (id: string, expectedTable?: string) => Promise<void>;
1961
2208
  /**
1962
- * Turn on secure-by-default RLS for the whole schema. Every table is then
1963
- * protected the DO/D1 write path denies raw, non-RLS `ctx.db` access, so a
1964
- * procedure that forgets `.use(rls(...))` fails closed. Opt a table out with
1965
- * `.public()`. Non-mutating: returns a fresh `ExtendableSchema` carrying the
1966
- * mode, so `.rls("required")` composes with `.extend(...)` either order.
2209
+ * Whole-shard erase. The RLS wrapper deliberately **fails this closed** rather
2210
+ * than wrapping it see the wrapper's `wipeShard`.
1967
2211
  */
1968
- rls: (mode: "required") => ExtendableSchema<T>;
1969
- } & Schema<T>;
1970
- /**
1971
- * Columns every row carries implicitly (never part of a table's declared
1972
- * `shape`), so `.index()` may legitimately name them. The single source for
1973
- * both the compile-time allow-list (`TableBuilder["index"]`'s `fields` type,
1974
- * via `(typeof SYSTEM_INDEX_FIELDS)[number]`) and the runtime cross-check
1975
- * below (via `SYSTEM_INDEX_FIELDS_SET`) — declared once so the two can't
1976
- * drift apart.
1977
- */
1978
- declare const SYSTEM_INDEX_FIELDS: readonly ["_commitSeq", "_creationTime", "_id"];
2212
+ wipeShard?: (options?: {
2213
+ chunkSize?: number;
2214
+ exclude?: ReadonlyArray<string>;
2215
+ tables?: ReadonlyArray<string>;
2216
+ }) => Promise<{
2217
+ deleted: number;
2218
+ tables: Record<string, number>;
2219
+ }>;
2220
+ }
1979
2221
  /**
1980
- * Per-table, per-KIND index→declared-fields map: for each table, each index
1981
- * KIND (`index` | `rank` | `geo`) that has at least one declared index maps
1982
- * to a name→fields record for that kind only. Distilled by
1983
- * {@link indexFieldsFromSchema}; this is the shape `mask()`'s
1984
- * `MaskOptions.indexFields` expects (see `./mask/types`), so a table not
1985
- * present here (no declared indexes of any kind) is simply absent from the
1986
- * map rather than mapped to `{}`, and a kind with no declared indexes on a
1987
- * table that HAS other kinds is simply absent from that table's entry.
1988
- *
1989
- * Kept per kind (rather than one flat name→fields record) because the engine
1990
- * resolves `withIndex`/`withGeoIndex`/rank reads in THREE separate
1991
- * namespaces (`tableDefinition.indexes` / `.geoIndexes` / `.rankIndexes` —
1992
- * see `@lunora/shard-engine`'s `ctx-db.ts`), so the same name can legally and
1993
- * unambiguously denote a different index per kind. A flat map would let one
1994
- * kind's fields silently shadow another's for a colliding name, producing a
1995
- * wrong-namespace answer from the mask guard (checking the wrong index's
1996
- * fields) instead of the documented fail-open (missing lookup) — see plan 258.
2222
+ * What a procedure's `ctx.db` must structurally satisfy for the middleware
2223
+ * to wrap it. We deliberately mirror `@lunora/do`'s `DatabaseWriterLike`
2224
+ * rather than `@lunora/server`'s nominal `DatabaseWriter`/`DatabaseReader`:
2225
+ * the runtime adapter that flows in is the `DatabaseWriterLike`-shaped one,
2226
+ * and structural matching keeps this module free of an `@lunora/do`-typed
2227
+ * `ctx`.
1997
2228
  */
1998
- type IndexFieldsByTable = Readonly<Record<string, {
1999
- readonly geo?: Readonly<Record<string, ReadonlyArray<string>>>;
2000
- readonly index?: Readonly<Record<string, ReadonlyArray<string>>>;
2001
- readonly rank?: Readonly<Record<string, ReadonlyArray<string>>>;
2002
- }>>;
2003
- declare const indexFieldsFromSchema: (schema: Schema) => IndexFieldsByTable;
2004
- declare const defineSchema: <T extends Record<string, TableDefinition>>(tables: T, vectorIndexes?: Record<string, VectorIndexDefinition>, aggregateIndexes?: Record<string, AggregateIndexDefinition>, rankIndexes?: Record<string, RankIndexDefinition>) => ExtendableSchema<T>;
2229
+ type RlsDatabase = DatabaseWriterLike;
2230
+ /** Roles list source on the context. Tolerant of older auth states. */
2231
+ type AuthLike = {
2232
+ getIdentity?: () => Promise<Record<string, unknown> | null>;
2233
+ roles?: ReadonlyArray<string>;
2234
+ userId?: null | string;
2235
+ };
2236
+ /** Minimal shape the middleware needs on the incoming ctx. */
2237
+ interface RlsContextIn {
2238
+ auth?: AuthLike;
2239
+ db: RlsDatabase;
2240
+ }
2241
+ declare const rls: <Context extends RlsContextIn = RlsContextIn>(policies: ReadonlyArray<Policy<Context>>, options?: RlsOptions) => Middleware<Context, Context>;
2005
2242
  /**
2006
2243
  * Context handed to a {@link MaskFn} (and to {@link MaskOptions.bypass}). The
2007
2244
  * `auth` shape mirrors RLS's `PolicyContext.auth` one-for-one — same identity
@@ -2858,4 +3095,4 @@ interface StorageContextIn {
2858
3095
  }
2859
3096
  declare const storageRules: <Context extends StorageContextIn = StorageContextIn>(rules: ReadonlyArray<StorageRule<Context>>, options?: StorageRulesOptions) => Middleware<Context, Context>;
2860
3097
  declare const VERSION = "0.0.0";
2861
- export { type ActionBuilder, type ActionCtx, type AggregateIndexDefinition, type AggregateIndexOptions, type AggregateOp, type ArgsValidator, type Component, type ComponentFunctions, type CreateOptions, DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, type DataModelInit, type DeferredDeleteFlushResult, type DefineComponentOptions, type DefineIdentityOptions, type DefineListArgsConfig, type DefinePluginOptions, type DefinePolicyInput, type DefinePresenceOptions, type DefineStorageRuleInput, 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, LunoraError, 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 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 TableBuilder, type TableDefinition, type TerminalKind, type TriggerBuilder, type TriggerDefinition, type TypedDefinePolicyInput, VERSION, type VectorEmbedder, type VectorIndexDefinition, type VectorIndexOptions, type VectorMetric, type VectorizeOptions, type WhereInput, allowAll, asBucketStorage, bindOrm, bindTableFacade, buildMaskRegistry, buildRlsReadRegistry, clampLimit, composePluginMiddleware, composeShapeReadWhere, createPolicyDsl, createSecrets, defineAggregateIndex, defineComponent, defineEnv, defineIdentity, defineListArgs, defineMigration, defineMutator, definePermission, definePlugin, definePolicies, definePolicy, definePresence, defineRankIndex, defineRole, defineSchema, defineSchemaExtension, defineShape, defineStorageRule, defineStorageRules, defineTable, defineVectorIndex, deny, flushDeferredDeletes, httpAction, httpRoute, httpRouter, indexFieldsFromSchema, initLunora, installPlugins, isDeny, isSafeHeaderValue, mask, mergeSchemaExtension, onConnect, onDisconnect, onQueryChange, onShardInit, presenceExtension, protectPublic, redactSecrets, rls, serveStorageObject, storageRules, toWhereInput, withDeferredDeletes };
3098
+ 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, LunoraError, 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 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 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, 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 };