@lunora/server 1.0.0-alpha.56 → 1.0.0-alpha.57

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
@@ -980,860 +980,986 @@ declare const clampLimit: (limit: number | undefined, fallback: number, maxLimit
980
980
  */
981
981
  declare const defineListArgs: <TDocument>() => <F extends ListFilterShape<TDocument>, O extends keyof TDocument & string>(config: DefineListArgsConfig<F, O>) => ListArgsSpec<TDocument, F, O>;
982
982
  /**
983
- * Context handed to a {@link MaskFn} (and to {@link MaskOptions.bypass}). The
984
- * `auth` shape mirrors RLS's `PolicyContext.auth` one-for-one same identity
985
- * resolver, same `can(...)` permission check so an author can branch a mask
986
- * on the caller's role/permission. `row` is the full pre-mask row the column
987
- * belongs to; `column` is the column currently being masked. Both are absent
988
- * when the context is used for the procedure-wide `bypass` check (no specific
989
- * cell is in play yet).
990
- */
991
- interface MaskContext<Context = unknown> {
992
- readonly auth: {
993
- /** `true` when any of the request's `roles` grants `permission` (see {@link MaskOptions.roles}). Fails closed for unregistered roles. */
994
- readonly can: (permission: Permission | string) => boolean;
995
- readonly identity?: Record<string, unknown> | null;
996
- readonly roles: ReadonlyArray<string>;
997
- readonly userId: null | string;
998
- };
999
- /** The column currently being masked. Present only inside a per-cell {@link MaskFn}. */
1000
- readonly column?: string;
1001
- readonly ctx: Context;
1002
- /** The full pre-mask row the masked cell belongs to. Present only inside a per-cell {@link MaskFn}. */
1003
- readonly row?: Record<string, unknown>;
983
+ * Structural mirrors of `@lunora/shard-engine`'s rank-page-row shapes
984
+ * (`RankPageRowKey` / `RankPageRow` / `ShardRankPageResult`)the return type
985
+ * of the writer's `rankPageRows` seam, the cross-shard companion to
986
+ * `rankPage`.
987
+ *
988
+ * Shared by `../rls/middleware` and `../mask/middleware`: both wrap
989
+ * `rankPageRows` structurally (no `@lunora/shard-engine` import, mirroring how
990
+ * every other method on their `DatabaseWriterLike`/`MaskDatabase` projections
991
+ * is hand-mirrored rather than imported) and both need the exact same result
992
+ * shape to type their overrides. A single copy here means the two wrappers
993
+ * can't drift out of lockstep with each other see AGENTS.md's platform
994
+ * parity note on `ShardSqlExec` and the canonical binding `*Like` projections
995
+ * shipping wrong for exactly this reason (two hand-maintained mirrors of one
996
+ * upstream type).
997
+ */
998
+ /** Structural mirror of `@lunora/shard-engine`'s `RankPageRowKey`. */
999
+ interface RankPageRowKeyLike {
1000
+ partitionKey: string;
1001
+ rowId: string;
1002
+ sortValues: ReadonlyArray<unknown>;
1003
+ }
1004
+ /** Structural mirror of `@lunora/shard-engine`'s `RankPageRow`. */
1005
+ interface RankPageRowLike {
1006
+ doc: Record<string, unknown>;
1007
+ key: RankPageRowKeyLike;
1008
+ }
1009
+ /** Structural mirror of `@lunora/shard-engine`'s `ShardRankPageResult` — the `rankPageRows` return shape. */
1010
+ interface ShardRankPageResultLike {
1011
+ directions: ReadonlyArray<"asc" | "desc">;
1012
+ hasMore: boolean;
1013
+ rows: ReadonlyArray<RankPageRowLike>;
1004
1014
  }
1005
1015
  /**
1006
- * A custom masking function. Receives the raw cell value and the
1007
- * {@link MaskContext}, returns the value to surface. Use it for partial masks
1008
- * (`maskMiddle(phone)`), role-aware reveals (`ctx.auth.can(...) ? value : null`),
1009
- * or format-preserving tokens. A function that **throws** fails closed — the
1010
- * cell is redacted to `null`, never leaked raw.
1016
+ * The prefixed tables a single plugin `P` contributes, or an empty map when it
1017
+ * ships no schema extension. Mirrors {@link PrefixedTables} at the plugin level
1018
+ * so {@link InstalledTables} can fold a tuple of plugins.
1011
1019
  */
1012
- type MaskFn<Context = unknown> = (value: unknown, context: MaskContext<Context>) => unknown;
1020
+ type ExtensionTablesOf<P> = P extends {
1021
+ readonly extension: SchemaExtension<infer X> & {
1022
+ readonly key: infer K;
1023
+ };
1024
+ } ? K extends string ? PrefixedTables<X, K> : Record<never, never> : Record<never, never>;
1013
1025
  /**
1014
- * How a column is masked:
1015
- *
1016
- * - `"redact"` drop the value to `null`. The simplest, safest strategy, and
1017
- * the right choice for any value that must actually be kept secret.
1018
- * - `"hash"` — replace with a stable token (unsalted 32-bit FNV-1a hex) so the
1019
- * same input always yields the same token (joinable/groupable client-side).
1020
- * **This is NOT a confidentiality control.** It is a non-cryptographic,
1021
- * unsalted, deterministic, narrow (~2^32) digest: low-entropy values (emails,
1022
- * phone numbers, SSNs) are brute-force-recoverable by the very caller you are
1023
- * masking from, and identical values always produce identical tokens across
1024
- * rows/columns/tenants (enabling correlation). Use `"hash"` ONLY when you want a
1025
- * stable pseudonym for grouping/joining and leaking the value is acceptable —
1026
- * never to hide sensitive PII. For PII that must stay hidden, use `"redact"`.
1027
- * - a {@link MaskFn} — author-defined transform (partial mask, role-aware reveal).
1026
+ * Fold a tuple of plugins onto a base table map `T`, accumulating each plugin's
1027
+ * auto-prefixed extension tables left-to-right — the type-level mirror of
1028
+ * {@link installPlugins} applying `mergeSchemaExtension` for each plugin in turn.
1028
1029
  */
1029
- type MaskStrategy<Context = unknown> = "hash" | "redact" | MaskFn<Context>;
1030
- /** Per-column strategy map for one table: `{ email: "redact", phone: maskMiddle }`. */
1031
- type MaskColumns<Context = unknown> = Record<string, MaskStrategy<Context>>;
1030
+ type InstalledTables<T extends Record<string, TableDefinition>, Plugins extends ReadonlyArray<unknown>> = Plugins extends readonly [infer Head, ...infer Rest] ? InstalledTables<ExtensionTablesOf<Head> & T, Rest> : T;
1032
1031
  /**
1033
- * The mask declaration passed to `mask(...)`: a table column strategy map.
1034
- * Deliberately a plain object literal so the codegen feeder can statically read
1035
- * which columns a procedure masks (powering the `mask_uncovered_pii_column`
1036
- * advisor lint), exactly as the RLS feeder reads policy tables.
1032
+ * Union every plugin's `ContextOut` in a tuple the type-level mirror of the
1033
+ * `ctx.api.&lt;key>` additions {@link composePluginMiddleware} accumulates as each
1034
+ * plugin middleware runs. Independent of the incoming context, which the builder
1035
+ * infers at the `.use(...)` site.
1037
1036
  */
1038
- type MaskPolicies<Context = unknown> = Record<string, MaskColumns<Context>>;
1037
+ 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;
1039
1038
  /**
1040
- * Options for `mask(policies, options)`.
1041
- *
1042
- * - `roles` registers the role→permission grants that back `ctx.auth.can(...)`
1043
- * inside a {@link MaskFn} — identical to `rls(policies, { roles })`. A role
1044
- * not listed grants no permissions (fails closed for unknown roles).
1045
- * - `bypass` is a procedure-wide escape hatch: when it returns `true` the whole
1046
- * mask is skipped (the caller sees raw values). Use it for a privileged
1047
- * viewer — `bypass: ({ auth }) => auth.can("pii:view")`. Prefer this over
1048
- * branching every column when an entire class of caller should see clear data.
1039
+ * Schema fragment a plugin contributes. Same shape as the `tables` map
1040
+ * passed to `defineSchema`. Optional `vectorIndexes` mirror the top-level
1041
+ * `defineSchema` argument so a plugin can ship vector decls alongside its
1042
+ * tables.
1049
1043
  */
1050
- interface MaskOptions<Context = unknown> {
1051
- readonly bypass?: (context: MaskContext<Context>) => boolean;
1052
- readonly roles?: ReadonlyArray<Role>;
1053
- }
1054
- interface QueryPage$1 {
1055
- continueCursor: null | string;
1056
- isDone: boolean;
1057
- page: Record<string, unknown>[];
1044
+ interface SchemaExtension<T extends Record<string, TableDefinition> = Record<string, TableDefinition>> {
1045
+ /** Stable key identifying the plugin that owns this extension. */
1046
+ readonly key: string;
1047
+ /**
1048
+ * Extension tables, keyed by **bare** name (e.g. `buckets`). At merge time
1049
+ * each is auto-prefixed with `key` (`ratelimit_buckets`) so it can't
1050
+ * collide with an app table; do **not** namespace manually.
1051
+ */
1052
+ readonly tables: T;
1053
+ /**
1054
+ * Optional standalone vector indexes the plugin ships, keyed by index
1055
+ * name. Merged into the host schema's `vectorIndexes`; a key collision
1056
+ * with the base schema is a hard error (same policy as tables).
1057
+ */
1058
+ readonly vectorIndexes?: Record<string, VectorIndexDefinition>;
1058
1059
  }
1059
- interface QueryArgs$1 {
1060
- baseWhere?: unknown;
1061
- cursor?: null | string;
1062
- limit?: number;
1063
- orderBy?: ReadonlyArray<Record<string, unknown>>;
1064
- where?: unknown;
1065
- with?: Record<string, unknown>;
1060
+ /**
1061
+ * Build a {@link SchemaExtension}. The `key` is a runtime tag (used for
1062
+ * error messages on collision) and a type-level brand.
1063
+ */
1064
+ declare const defineSchemaExtension: <T extends Record<string, TableDefinition>>(key: string, options: {
1065
+ tables: T;
1066
+ vectorIndexes?: Record<string, VectorIndexDefinition>;
1067
+ }) => SchemaExtension<T>;
1068
+ /**
1069
+ * A plugin packages an optional schema extension and optional middleware.
1070
+ * Both are independently usable: an app can install only the schema (e.g.
1071
+ * for plugins that ship background workers but no per-request behavior)
1072
+ * or only the middleware (plugins that augment ctx without persistent
1073
+ * state).
1074
+ */
1075
+ interface Plugin<TExtension extends Record<string, TableDefinition> = Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn> {
1076
+ /**
1077
+ * Optional schema extension. Apps install via
1078
+ * `defineSchema(...).extend(plugin.extension)`.
1079
+ */
1080
+ readonly extension?: SchemaExtension<TExtension>;
1081
+ /** Stable key identifying the plugin. Matches `extension.key` when set. */
1082
+ readonly key: string;
1083
+ /**
1084
+ * Optional middleware. Users attach with `c.query.use(plugin.middleware)`.
1085
+ * The middleware can extend `ctx`; convention is to attach helpers under
1086
+ * `ctx.api.&lt;key>`, e.g.
1087
+ *
1088
+ * ```ts
1089
+ * middleware: ({ ctx, next }) =>
1090
+ * next({ ctx: { api: { ...ctx.api, ratelimit: api } } })
1091
+ * ```
1092
+ */
1093
+ readonly middleware?: Middleware<TContextIn, TContextOut>;
1066
1094
  }
1067
- interface AggregateArgs$1 {
1068
- field?: string;
1069
- op: string;
1070
- where?: unknown;
1095
+ /** Options to {@link definePlugin}. */
1096
+ interface DefinePluginOptions<TExtension extends Record<string, TableDefinition>, TContextIn, TContextOut> {
1097
+ extension?: SchemaExtension<TExtension>;
1098
+ middleware?: Middleware<TContextIn, TContextOut>;
1071
1099
  }
1072
- interface GroupByArgs$1 {
1073
- agg?: {
1074
- field?: string;
1075
- op: string;
1100
+ /**
1101
+ * Call signatures for {@link definePlugin}. When `extension` is supplied the
1102
+ * returned plugin's `extension` is typed as PRESENT (not `?`), so the
1103
+ * canonical install pattern `defineSchema(...).extend(plugin.extension)`
1104
+ * typechecks without a non-null assertion — the shape every scaffold template
1105
+ * ships. The bare-options signature keeps `extension` optional for plugins
1106
+ * that carry only middleware.
1107
+ */
1108
+ interface DefinePluginFunction {
1109
+ <TExtension extends Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn>(key: string, options: DefinePluginOptions<TExtension, TContextIn, TContextOut> & {
1110
+ extension: SchemaExtension<TExtension>;
1111
+ }): Plugin<TExtension, TContextIn, TContextOut> & {
1112
+ readonly extension: SchemaExtension<TExtension>;
1076
1113
  };
1077
- by: ReadonlyArray<string>;
1078
- where?: unknown;
1114
+ <TExtension extends Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn>(key: string, options: DefinePluginOptions<TExtension, TContextIn, TContextOut>): Plugin<TExtension, TContextIn, TContextOut>;
1079
1115
  }
1080
- interface TableReaderLike$1 {
1081
- collect: () => Promise<Record<string, unknown>[]>;
1082
- filter: (predicate: (document: Record<string, unknown>) => boolean) => TableReaderLike$1;
1083
- first: () => Promise<Record<string, unknown> | null>;
1084
- order: (direction: "asc" | "desc") => TableReaderLike$1;
1085
- paginate: (options: {
1086
- cursor?: null | string;
1087
- numItems: number;
1088
- }) => Promise<QueryPage$1>;
1089
- take: (limit: number) => Promise<Record<string, unknown>[]>;
1090
- unique: () => Promise<Record<string, unknown> | null>;
1091
- withGeoIndex: (indexName: string, build: (q: unknown) => unknown) => TableReaderLike$1;
1092
- withIndex: (indexName: string, range?: (q: unknown) => unknown) => TableReaderLike$1;
1093
- withSearchIndex: (indexName: string, search: (q: unknown) => unknown) => TableReaderLike$1;
1116
+ /**
1117
+ * Package a schema extension + middleware as a reusable plugin. Either
1118
+ * field is optional — `definePlugin("foo", {})` is valid but degenerate.
1119
+ */
1120
+ declare const definePlugin: DefinePluginFunction;
1121
+ /**
1122
+ * Bundle of registered functions a {@link Component} ships. Keys are the
1123
+ * function's local name (e.g. `check`, `reset`); the registered function
1124
+ * value carries its own kind / args / handler.
1125
+ *
1126
+ * Users re-export from their own lunora module so codegen picks them up:
1127
+ *
1128
+ * ```ts
1129
+ * // lunora/ratelimit.ts
1130
+ * import { ratelimit } from "@vendor/ratelimit-component";
1131
+ * export const { check, reset } = ratelimit.functions;
1132
+ * // Emits as `ratelimit:check` / `ratelimit:reset` in the generated `api`.
1133
+ * ```
1134
+ *
1135
+ * Codegen follows the re-export back to the bundled `query/mutation/action`
1136
+ * call (property access or destructuring both work), so the functions land in
1137
+ * the generated `api` under the re-exporting file's namespace.
1138
+ */
1139
+ type ComponentFunctions = Readonly<Record<string, RegisteredFunction<any, any, FunctionKind>>>;
1140
+ /**
1141
+ * Component = {@link Plugin} with a bundle of registered functions. The
1142
+ * extension + middleware + functions are independent: a component can ship
1143
+ * functions without a schema (e.g. a stateless utility), or a schema
1144
+ * without functions (e.g. shared table definitions), and any combination.
1145
+ */
1146
+ interface Component<TExtension extends Record<string, TableDefinition> = Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn, F extends ComponentFunctions = ComponentFunctions> extends Plugin<TExtension, TContextIn, TContextOut> {
1147
+ readonly functions: F;
1148
+ }
1149
+ interface DefineComponentOptions<TExtension extends Record<string, TableDefinition>, TContextIn, TContextOut, F extends ComponentFunctions> extends DefinePluginOptions<TExtension, TContextIn, TContextOut> {
1150
+ /** Registered functions the component ships. Keys are the function's local name. */
1151
+ functions?: F;
1094
1152
  }
1095
1153
  /**
1096
- * Structural projection of the runtime ORM writer the same subset
1097
- * `../rls/middleware` mirrors, so the wrapper is interchangeable between
1098
- * `@lunora/do`'s and `@lunora/d1`'s `DatabaseWriterLike` without an
1099
- * inter-package dependency. `rankBefore` is optional (the D1 twin omits it).
1154
+ * Convenience wrapper around {@link definePlugin} that also bundles a set
1155
+ * of registered functions. The resulting `component.functions` object is a
1156
+ * record of `name registered query/mutation/action`; consumers
1157
+ * re-export entries so codegen discovers them as user functions:
1158
+ *
1159
+ * ```ts
1160
+ * export const ratelimit = defineComponent("ratelimit", {
1161
+ * // Bare `buckets` merges in as `ratelimit_buckets`.
1162
+ * extension: defineSchemaExtension("ratelimit", { tables: { buckets } }),
1163
+ * middleware: ({ ctx, next }) => next({ ctx: { ...ctx, ratelimit: api(ctx) } }),
1164
+ * functions: {
1165
+ * check: query.input({ key: v.string() }).query(async ({ ctx, args }) => ...),
1166
+ * reset: mutation.input({ key: v.string() }).mutation(async ({ ctx, args }) => ...),
1167
+ * },
1168
+ * });
1169
+ * ```
1170
+ *
1171
+ * Re-exporting an entry (by property access or destructuring) is enough for
1172
+ * codegen to discover it in the host app's namespace — the discovery resolver
1173
+ * chases the re-export back to the bundled registration call.
1100
1174
  */
1101
- interface MaskDatabase {
1102
- aggregate: (tableName: string, options: AggregateArgs$1) => Promise<null | number>;
1103
- count: (tableName: string, whereOrArgs?: unknown) => Promise<number>;
1104
- delete: (id: string, expectedTable?: string) => Promise<void>;
1105
- deleteMany: (ids: ReadonlyArray<string>, options?: {
1106
- limit?: number;
1107
- }) => Promise<{
1108
- deleted: number;
1109
- }>;
1110
- deleteWhere?: (tableName: string, where: Record<string, unknown>, options?: {
1111
- limit?: number;
1112
- }) => Promise<{
1113
- deleted: number;
1114
- }>;
1115
- findFirst: (tableName: string, args?: QueryArgs$1) => Promise<Record<string, unknown> | null>;
1116
- findFirstOrThrow: (tableName: string, args?: QueryArgs$1) => Promise<Record<string, unknown>>;
1117
- findMany: (tableName: string, args?: QueryArgs$1) => Promise<QueryPage$1>;
1118
- get: (id: string, expectedTable?: string) => Promise<Record<string, unknown> | null>;
1119
- groupBy: (tableName: string, options: GroupByArgs$1) => Promise<ReadonlyArray<{
1120
- key: Record<string, unknown>;
1121
- value: null | number;
1122
- }>>;
1123
- insert: (tableName: string, document: Record<string, unknown>) => Promise<string>;
1124
- insertMany: (tableName: string, documents: ReadonlyArray<Record<string, unknown>>, options?: {
1125
- limit?: number;
1126
- skipDuplicates?: boolean;
1127
- }) => Promise<(string | null)[]>;
1128
- lookupById?: (id: string, expectedTable?: string) => Promise<null | {
1129
- row: Record<string, unknown>;
1130
- tableName: string;
1131
- }>;
1132
- patch: (id: string, patch: Record<string, unknown>, expectedTable?: string) => Promise<void>;
1133
- patchMany: (patches: ReadonlyArray<{
1134
- id: string;
1135
- patch: Record<string, unknown>;
1136
- }>, options?: {
1137
- limit?: number;
1138
- }) => Promise<{
1139
- patched: number;
1140
- }>;
1141
- patchWhere?: (tableName: string, args: {
1142
- patch: Record<string, unknown>;
1143
- where: Record<string, unknown>;
1144
- }, options?: {
1145
- limit?: number;
1146
- }) => Promise<{
1147
- patched: number;
1148
- }>;
1149
- query: (tableName: string) => TableReaderLike$1;
1150
- rank: (tableName: string, indexName: string, options: unknown) => Promise<null | {
1151
- position: number;
1152
- total: number;
1153
- }>;
1154
- rankBefore?: (tableName: string, indexName: string, options: unknown) => Promise<{
1155
- before: number;
1156
- total: number;
1157
- }>;
1158
- rankPage: (tableName: string, indexName: string, options?: unknown) => Promise<QueryPage$1>;
1159
- replace: (id: string, document: Record<string, unknown>, expectedTable?: string) => Promise<void>;
1160
- }
1161
- /** Roles list source on the context. Tolerant of older auth states (mirrors RLS's `AuthLike`). */
1162
- type AuthLike$1 = {
1163
- getIdentity?: () => Promise<Record<string, unknown> | null>;
1164
- roles?: ReadonlyArray<string>;
1165
- userId?: null | string;
1166
- };
1167
- interface MaskContextIn {
1168
- auth?: AuthLike$1;
1169
- db: MaskDatabase;
1170
- }
1175
+ 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>;
1171
1176
  /**
1172
- * Procedure-builder middleware. Apply per-request via `.use(mask(policies))`.
1173
- * Closes over the policy map at builder-construction time; resolves identity +
1174
- * the `bypass` decision per call against the live ctx.
1175
- *
1176
- * IMPORTANT: a mask is in scope only for procedures whose builder chain
1177
- * includes this middleware — opt-in, never global (the same invariant as RLS).
1177
+ * Map every key `K` of an extension's table map `X` to its auto-prefixed name
1178
+ * `${Key}_${K}`. Mirrors the runtime prefixing in {@link mergeSchemaExtension}
1179
+ * so the typed `.extend(...)` chain reflects the real merged table names.
1178
1180
  */
1179
- declare const mask: <Context extends MaskContextIn = MaskContextIn>(policies: MaskPolicies<Context>, options?: MaskOptions<Context>) => Middleware<Context, Context>;
1180
- /** A document handed to a migration transform: the stored row including `_id`/`_creationTime`. */
1181
- type MigrationDocument = Record<string, unknown>;
1181
+ type PrefixedTables<X extends Record<string, TableDefinition>, Key extends string> = { [K in keyof X as K extends string ? `${Key}_${K}` : K]: X[K]; };
1182
1182
  /**
1183
- * The read surface a transform reaches through its `ctx`.
1183
+ * Merge a {@link SchemaExtension} into an existing schema. Returns a new
1184
+ * schema object — never mutates the input.
1184
1185
  *
1185
- * Read-only by design: the runner accounts for exactly one rewrite per row read,
1186
- * and a transform writing directly would make that count describe something
1187
- * other than what happened. Scoped to the shard the runner is walking.
1186
+ * Extension tables are auto-namespaced: each bare table name is prefixed with
1187
+ * the extension `key` (`buckets` `ratelimit_buckets`), Convex-Components
1188
+ * style, and every intra-extension reference (relation targets, aggregate /
1189
+ * rank index `on`, standalone vector index `table`) is rewritten to match.
1190
+ * References to base/app tables are left untouched.
1191
+ *
1192
+ * Because each extension lives in its own `key` namespace, app↔component
1193
+ * collisions are impossible. The only remaining hard error is two extensions
1194
+ * sharing the same `key` and producing the same prefixed table (or vector
1195
+ * index) name — silent shadow would let one plugin hijack another's data.
1196
+ *
1197
+ * Re-runs {@link validateIndexFields} against the merged table set before
1198
+ * returning: `defineSchema` only validates the tables it was called with, so
1199
+ * without this an extension-contributed index with a typo'd/out-of-shape
1200
+ * field (or a duplicate name within one kind) would never be checked at all.
1201
+ * Re-validating the whole merged set (base + prefixed extension tables) is
1202
+ * cheap and idempotent for the base tables, which already passed this same
1203
+ * check when the base schema was built. Both callers of this function —
1204
+ * `withExtend.extend()` (`./schema`) and `installPlugins` (below) — get the
1205
+ * re-validation for free from this single call site (plan 258 §4/§9 Q3).
1188
1206
  */
1189
- interface MigrationReader {
1190
- count: (table: string, where?: Record<string, unknown>) => Promise<number>;
1191
- findFirst: (table: string, args?: Record<string, unknown>) => Promise<MigrationDocument | null>;
1192
- findMany: (table: string, args?: Record<string, unknown>) => Promise<{
1193
- isDone: boolean;
1194
- page: MigrationDocument[];
1195
- }>;
1196
- get: (id: string, expectedTable?: string) => Promise<MigrationDocument | null>;
1197
- }
1198
- /** The context handed to a transform alongside the row. */
1199
- interface MigrationCtx {
1200
- db: MigrationReader;
1201
- }
1207
+ declare const mergeSchemaExtension: <T extends Record<string, TableDefinition>, X extends Record<string, TableDefinition>, Key extends string = string>(base: Schema<T>, extension: SchemaExtension<X> & {
1208
+ readonly key: Key;
1209
+ }) => Schema<PrefixedTables<X, Key> & T>;
1202
1210
  /**
1203
- * Transform applied to one document. Return a new document to rewrite the row,
1204
- * or `undefined` to leave it untouched (skipped, not counted as changed). The
1205
- * runner always preserves the original `_id` and `_creationTime`, so the
1206
- * returned document neither needs to nor should change row identity.
1211
+ * Install several plugins' schema extensions in one call the one-shot
1212
+ * counterpart to chaining `defineSchema(...).extend(a).extend(b)`. Plugins
1213
+ * without an `extension` (middleware-only) are skipped; tables from those that
1214
+ * do are auto-prefixed and reference-rewritten exactly as
1215
+ * {@link mergeSchemaExtension} does for a single `.extend(...)`.
1207
1216
  *
1208
- * The second parameter carries a shard-scoped reader. Without it a transform
1209
- * could only rewrite the row it was handed — enough for a backfill whose new
1210
- * value is a pure function of the old row (`displayName = name ?? "Anonymous"`),
1211
- * but not for the shape people actually write: read the parent, copy a field
1212
- * down onto its children.
1217
+ * ```ts
1218
+ * const schema = installPlugins(defineSchema({ todos }), [ratelimit, audit]);
1219
+ * // todos + ratelimit_* + audit_*
1220
+ * ```
1213
1221
  *
1214
- * May return a promise, since a cross-table read is asynchronous.
1222
+ * Pair it with {@link composePluginMiddleware} to attach every plugin's
1223
+ * middleware in a single `.use(...)`, so installing N plugins is two calls
1224
+ * rather than N `.extend(...)` + N `.use(...)`.
1225
+ */
1226
+ 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>>;
1227
+ /**
1228
+ * Compose every plugin's middleware into a single middleware you attach with one
1229
+ * `.use(...)`. Plugins without middleware (schema-only) are skipped; the rest run
1230
+ * in array order, each seeing the context the previous one widened, so the final
1231
+ * `next({ ctx })` the builder receives carries every plugin's `ctx.api.&lt;key>`
1232
+ * additions. Equivalent to `.use(a.middleware).use(b.middleware)…` but as one
1233
+ * value, the middleware sibling of {@link installPlugins}.
1215
1234
  *
1216
- * **A shard key cannot be backfilled this way, even with a reader.** A row whose
1217
- * shard-key field is unset does not belong to any shard, so a shard-scoped query
1218
- * will not enumerate it; and writing the key would have to MOVE the row to a
1219
- * different Durable Object, which a per-shard runner cannot do. Re-keying is an
1220
- * export → transform → import, not a migration.
1235
+ * `ContextIn` is left free so the builder infers it from the context at the
1236
+ * `.use(...)` site; the result type widens it by the union of the plugins'
1237
+ * outputs.
1221
1238
  */
1222
- type MigrationTransform = (document: MigrationDocument, ctx: MigrationCtx) => MigrationDocument | Promise<MigrationDocument | undefined | void> | undefined | void;
1223
- interface MigrationDefinition {
1224
- /** Rows fetched and rewritten per batch. Defaults to the runner's batch size when omitted. */
1225
- readonly batchSize?: number;
1226
- /** Optional reverse transform, applied by `migrate down`. */
1227
- readonly down?: MigrationTransform;
1228
- /** Stable, unique identifier — the key per-shard run-state is tracked under. */
1229
- readonly id: string;
1230
- /** Table whose documents this migration iterates. */
1231
- readonly table: string;
1232
- /** Forward transform, applied to every row by `migrate up`. */
1233
- readonly up: MigrationTransform;
1239
+ 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>;
1240
+ /** Options for `.vectorize(field, opts)` (DSL Shape A). */
1241
+ interface VectorizeOptions<Shape extends Record<string, Validator> = Record<string, Validator>> {
1242
+ dimensions: number;
1243
+ embed: VectorEmbedder;
1244
+ /** Logical index name; must match a `[[vectorize]]` binding in wrangler. */
1245
+ index: string;
1246
+ /** Fields mirrored into Vectorize metadata for filtering. */
1247
+ metadata?: ReadonlyArray<keyof Shape & string>;
1248
+ metric: VectorMetric;
1234
1249
  }
1235
- /** A {@link MigrationDefinition} plus the codegen discovery marker. */
1236
- interface RegisteredMigration extends MigrationDefinition {
1237
- readonly __lunoraMigration: true;
1250
+ /** A `one` (many-to-one) relation descriptor; phantom `Target` carries the target table name. */
1251
+ interface OneRelation<Target extends string = string> extends RelationDefinition {
1252
+ readonly __target?: Target;
1253
+ readonly kind: "one";
1254
+ }
1255
+ /** A `many` (one-to-many) relation descriptor; phantom `Target` carries the target table name. */
1256
+ interface ManyRelation<Target extends string = string> extends RelationDefinition {
1257
+ readonly __target?: Target;
1258
+ readonly kind: "many";
1259
+ }
1260
+ /** The `r` argument passed to `.relations((r) => …)`. */
1261
+ interface RelationBuilder {
1262
+ /** One-to-many: the FK `field` lives on the target table, matching this table's `references` (default `_id`). */
1263
+ many: <Target extends string>(table: Target, options: {
1264
+ field: string;
1265
+ references?: string;
1266
+ }) => ManyRelation<Target>;
1267
+ /** Many-to-one: the FK `field` lives on this table, pointing at `table`.`references` (default `_id`). */
1268
+ one: <Target extends string>(table: Target, options: {
1269
+ field: string;
1270
+ onDelete?: OnDeleteAction;
1271
+ references?: string;
1272
+ }) => OneRelation<Target>;
1238
1273
  }
1239
- /** Declare an online data migration. See the module docs for runtime semantics. */
1240
- declare const defineMigration: (definition: MigrationDefinition) => RegisteredMigration;
1241
1274
  /**
1242
- * A mutator declaration. `server` is authoritative; `client` is the optimistic
1243
- * twin (optional omit it to let the optimistic write fall through to the
1244
- * server round-trip with no local preview). Both receive the same validated
1245
- * `args`.
1275
+ * Options for the inline `.aggregateIndex(name, opts)` builder. `op` defaults to
1276
+ * `count` so `aggregateIndex("byUser", { by: ["userId"] })` is a single-line
1277
+ * `COUNT(*) GROUP BY userId` accelerator.
1246
1278
  */
1247
- interface MutatorDefinition<Args extends ValidatorMap = ValidatorMap, ServerContext = MutationCtx, ClientTx = unknown, R = unknown> {
1279
+ interface InlineAggregateIndexOptions<Shape extends Record<string, Validator> = Record<string, Validator>> {
1280
+ /** Group keys; counter rows are one per distinct tuple. Omitted = single-row aggregate over the whole table. */
1281
+ by?: ReadonlyArray<keyof Shape & string>;
1282
+ /** The column the reducer applies to. Required for `sum`/`min`/`max`/`avg`; ignored for `count`. */
1283
+ field?: keyof Shape & string;
1284
+ /** Reducer (default `count`). */
1285
+ op?: AggregateOp;
1286
+ /** Static predicate baked into the counter — only matching rows are aggregated. */
1287
+ where?: Record<string, unknown>;
1288
+ }
1289
+ /**
1290
+ * Options for the inline `.rankIndex(name, opts)` builder. `sortBy` is required;
1291
+ * accepts either an array of `{ field, direction }` keys, or the shorthand
1292
+ * `["field"]` (asc) / `{ field: "desc" }` map entries. `partitionBy` scopes the
1293
+ * rank — omitted ⇒ one global rank over the whole table.
1294
+ */
1295
+ interface InlineRankIndexOptions<Shape extends Record<string, Validator> = Record<string, Validator>> {
1296
+ /** Columns that scope each ranking; omitted ⇒ one global rank. */
1297
+ partitionBy?: ReadonlyArray<keyof Shape & string>;
1298
+ /** Ordered sort keys driving the rank. Required. */
1299
+ sortBy: ReadonlyArray<{
1300
+ direction?: "asc" | "desc";
1301
+ field: keyof Shape & string;
1302
+ }>;
1303
+ /** Static predicate baked into the index; only matching rows enter. */
1304
+ where?: Record<string, unknown>;
1305
+ }
1306
+ interface TableBuilder<Shape extends Record<string, Validator> = Record<string, Validator>> extends TableDefinition<Shape> {
1307
+ /** Declare an aggregate (counter/sum/…) maintained by triggers for O(1) reads. */
1308
+ aggregateIndex: (name: string, options?: InlineAggregateIndexOptions<Shape>) => TableBuilder<Shape>;
1248
1309
  /**
1249
- * Validator for the mutator's arguments. Validated on the DO before `server`
1250
- * runs and (when present) on the client before `client` runs, so both impls
1251
- * see the same parsed shape. Omit for a parameterless mutator.
1310
+ * Mark this table as written outside Lunora's discoverable insert path
1311
+ * by an adapter, a migration, or framework middleware (e.g. `@lunora/auth`'s
1312
+ * better-auth tables, `@lunora/ratelimit`'s store). Advisor insert-path lints
1313
+ * (`table_without_insert`) then skip it instead of flagging the absent
1314
+ * `ctx.db.insert(...)`.
1252
1315
  */
1253
- readonly args?: Args;
1316
+ externallyManaged: () => TableBuilder<Shape>;
1254
1317
  /**
1255
- * Optimistic client implementation. Runs in a TanStack DB transaction
1256
- * against the local collections; its writes are applied immediately and
1257
- * automatically rolled back / rebased as the authoritative result syncs
1258
- * back. Pure and side-effect-free beyond the local store. Omit to skip the
1259
- * local preview.
1260
- */
1261
- readonly client?: (tx: ClientTx, args: InferValidatorMap<Args>) => Promise<void> | void;
1318
+ * Declare a geospatial index over a `v.geoPoint()` column. The runtime keeps
1319
+ * a geohash companion so `withGeoIndex(name, q => q.near(point, radius))` and
1320
+ * `.within(bbox)` resolve as a geohash-prefix range scan + Haversine
1321
+ * refine/sort. `options.precision` tunes the geohash length (default 9).
1322
+ */
1323
+ geoIndex: (name: string, options: {
1324
+ field: keyof Shape & string;
1325
+ precision?: number;
1326
+ }) => TableBuilder<Shape>;
1262
1327
  /**
1263
- * Owner-scope the write: names the column carrying the row owner (e.g.
1264
- * `owner: "userId"`). Before `server` runs, the mutator requires a verified
1265
- * identity, rejects a client-supplied owner that disagrees with it, and sets
1266
- * the column to the verified value so the impl reads `args[owner]` without
1267
- * trusting the client and never repeats the check by hand.
1328
+ * Mark this table as global (cross-shard). Backed by **D1** by default;
1329
+ * pass `{ backend: "hyperdrive" }` to store it in a Postgres/MySQL database
1330
+ * via Cloudflare Hyperdrive (PlanetScale, Neon, …) instead. Either way the
1331
+ * table stays reactivelive queries re-run on write.
1332
+ */
1333
+ global: (options?: {
1334
+ backend?: GlobalBackend;
1335
+ }) => TableBuilder<Shape>;
1336
+ /** Add a secondary index. */
1337
+ index: (name: string, fields: ReadonlyArray<(keyof Shape & string) | (typeof SYSTEM_INDEX_FIELDS)[number]>, options?: {
1338
+ unique?: boolean;
1339
+ }) => TableBuilder<Shape>;
1340
+ /**
1341
+ * Name the column holding the owning user's id, so "only the owner sees these
1342
+ * rows" is declared once here rather than restated in every shape.
1268
1343
  *
1269
- * This replaces the "every mutator opens with `assertOwner(ctx, args.userId)`"
1270
- * pattern, and is the write-side counterpart to an `owner`-scoped
1271
- * {@link import("./shapes").ShapeDefinition}. Unlike a shape it takes the column
1272
- * NAME rather than `true`: a shape is bound to one `table`, so the table's
1273
- * `.ownedBy(field)` resolves unambiguously, whereas one mutator may write
1274
- * several tables and has no single owning table to read it from.
1344
+ * A `defineShape({ table, owner: true })` over this table derives its predicate
1345
+ * from the field: the subscriber's verified `ctx.auth.userId` must match, and an
1346
+ * anonymous subscriber is denied. Pairs naturally with `.shardBy(field)` on the
1347
+ * same column the shard key routes the storage, `ownedBy` states who the rows
1348
+ * belong to but the two are independent and either can be used alone.
1275
1349
  *
1276
- * Declare the column `v.optional(...)` to leave it off the wire entirely; it is
1277
- * injected either way.
1350
+ * This is a *shape* declaration, not an RLS policy: it narrows what a shape
1351
+ * replicates. Guarding procedure reads/writes is still `rls(...)`'s job.
1278
1352
  */
1279
- readonly owner?: string;
1353
+ ownedBy: (field: keyof Shape & string) => TableBuilder<Shape>;
1280
1354
  /**
1281
- * Authoritative server implementation. Runs inside the shard DO with a full
1282
- * {@link MutationContext} (`ctx.db` writer); its writes append to `__cdc_log`
1283
- * and poke back to subscribers. This is the source of truth the client
1284
- * impl is only a prediction of it.
1355
+ * Opt this table OUT of secure-by-default RLS. Under a schema marked
1356
+ * `.rls("required")`, every table is protected (the write path denies raw,
1357
+ * non-RLS `ctx.db` access); calling `.public()` exempts this one table so a
1358
+ * plain `query`/`mutation` may read/write it without an RLS policy. No effect
1359
+ * when the schema does not require RLS.
1285
1360
  */
1286
- readonly server: (context: ServerContext, args: InferValidatorMap<Args>) => Promise<R> | R;
1287
- }
1288
- /**
1289
- * A {@link MutatorDefinition} plus the codegen discovery marker and a
1290
- * dispatch-shaped `handler` (validates `args`, then runs `server`) so the DO
1291
- * invokes a mutator exactly like a registered procedure.
1292
- */
1293
- interface RegisteredMutator<Args extends ValidatorMap = ValidatorMap, ServerContext = MutationCtx, ClientTx = unknown, R = unknown> extends MutatorDefinition<Args, ServerContext, ClientTx, R> {
1294
- readonly __lunoraMutator: true;
1295
- /** Validate `rawArgs`, then run the authoritative `server` impl. Used by the DO push path. */
1296
- readonly handler: (context: ServerContext, rawArgs: Record<string, unknown>) => Promise<R>;
1361
+ public: () => TableBuilder<Shape>;
1297
1362
  /**
1298
- * Marks the dispatch kind so codegen can register the mutator in the same
1299
- * `LUNORA_FUNCTIONS` table queries/mutations use the DO's `handleRpc`
1300
- * reads `kind === "mutation"` to wrap the authoritative `server` impl in the
1301
- * shard's BEGIN/COMMIT span (all-or-nothing writes), exactly like an
1302
- * ordinary `mutation`.
1363
+ * Declare a rank index (sorted companion table, btree-backed) for
1364
+ * `rank(row)` / `rankPage()` reads in O(log n). See {@link RankIndexDefinition}.
1303
1365
  */
1304
- readonly kind: "mutation";
1305
- }
1306
- /** Declare a custom mutator. See the module docs for runtime semantics. */
1307
- declare const defineMutator: <Args extends ValidatorMap = ValidatorMap, ServerContext = MutationCtx, ClientTx = unknown, R = unknown>(definition: MutatorDefinition<Args, ServerContext, ClientTx, R>) => RegisteredMutator<Args, ServerContext, ClientTx, R>;
1308
- /**
1309
- * The prefixed tables a single plugin `P` contributes, or an empty map when it
1310
- * ships no schema extension. Mirrors {@link PrefixedTables} at the plugin level
1311
- * so {@link InstalledTables} can fold a tuple of plugins.
1312
- */
1313
- type ExtensionTablesOf<P> = P extends {
1314
- readonly extension: SchemaExtension<infer X> & {
1315
- readonly key: infer K;
1316
- };
1317
- } ? K extends string ? PrefixedTables<X, K> : Record<never, never> : Record<never, never>;
1318
- /**
1319
- * Fold a tuple of plugins onto a base table map `T`, accumulating each plugin's
1320
- * auto-prefixed extension tables left-to-right — the type-level mirror of
1321
- * {@link installPlugins} applying `mergeSchemaExtension` for each plugin in turn.
1322
- */
1323
- type InstalledTables<T extends Record<string, TableDefinition>, Plugins extends ReadonlyArray<unknown>> = Plugins extends readonly [infer Head, ...infer Rest] ? InstalledTables<ExtensionTablesOf<Head> & T, Rest> : T;
1324
- /**
1325
- * Union every plugin's `ContextOut` in a tuple — the type-level mirror of the
1326
- * `ctx.api.&lt;key>` additions {@link composePluginMiddleware} accumulates as each
1327
- * plugin middleware runs. Independent of the incoming context, which the builder
1328
- * infers at the `.use(...)` site.
1329
- */
1330
- 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;
1331
- /**
1332
- * Schema fragment a plugin contributes. Same shape as the `tables` map
1333
- * passed to `defineSchema`. Optional `vectorIndexes` mirror the top-level
1334
- * `defineSchema` argument so a plugin can ship vector decls alongside its
1335
- * tables.
1336
- */
1337
- interface SchemaExtension<T extends Record<string, TableDefinition> = Record<string, TableDefinition>> {
1338
- /** Stable key identifying the plugin that owns this extension. */
1339
- readonly key: string;
1366
+ rankIndex: (name: string, options: InlineRankIndexOptions<Shape>) => TableBuilder<Shape>;
1367
+ /** Declare relations to other tables, loaded via `findMany({ with })`. */
1368
+ relations: (build: (r: RelationBuilder) => Record<string, RelationDefinition>) => TableBuilder<Shape>;
1340
1369
  /**
1341
- * Extension tables, keyed by **bare** name (e.g. `buckets`). At merge time
1342
- * each is auto-prefixed with `key` (`ratelimit_buckets`) so it can't
1343
- * collide with an app table; do **not** namespace manually.
1370
+ * Add a full-text search index over `field`, queried with
1371
+ * `.withSearchIndex(name, q => q.search(field, term))`. `field` may be a
1372
+ * dot-separated path into a nested object (`"properties.name"`).
1373
+ * `filterFields` (at most 16) lists the columns `.eq()` may narrow by inside
1374
+ * the search. `language` selects the text analysis (accent folding always,
1375
+ * plus that language's stopwords). `staged: true` skips the migration-time
1376
+ * backfill on a large existing table. `strategy: "native"` uses the engine's
1377
+ * own full-text index where it has one (Postgres) — faster on large corpora,
1378
+ * at the cost of the engine ranking rather than the shared scorer.
1344
1379
  */
1345
- readonly tables: T;
1380
+ searchIndex: (name: string, options: {
1381
+ field: string;
1382
+ filterFields?: ReadonlyArray<string>;
1383
+ language?: SearchLanguage;
1384
+ staged?: boolean;
1385
+ strategy?: SearchStrategy;
1386
+ }) => TableBuilder<Shape>;
1387
+ /** Route storage by the named field — one DO per distinct value. */
1388
+ shardBy: (field: keyof Shape & string) => TableBuilder<Shape>;
1346
1389
  /**
1347
- * Optional standalone vector indexes the plugin ships, keyed by index
1348
- * name. Merged into the host schema's `vectorIndexes`; a key collision
1349
- * with the base schema is a hard error (same policy as tables).
1390
+ * Turn on soft delete. Adds a nullable timestamp column (`options.field`,
1391
+ * default `deletedAt`) and changes `ctx.db.&lt;table>.delete()` to **set** it
1392
+ * instead of removing the row; `onDelete: "cascade"` children are recursively
1393
+ * soft-deleted too. **List reads** (`findMany`/`findFirst`/`query()`/`count`/
1394
+ * `aggregate`/relation loads) then hide soft-deleted rows unless they pass
1395
+ * `includeDeleted: true`; by-id `get`/`patch`/`replace` and the new
1396
+ * `restore()` still address the row directly. `hardDelete()` physically
1397
+ * removes it (cascading as a real delete). Note: `includeDeleted` is a read
1398
+ * scope, not access control — anyone who can run the read can set it; a unique
1399
+ * index still rejects a new row that collides with a soft-deleted one (the row
1400
+ * physically persists).
1350
1401
  */
1351
- readonly vectorIndexes?: Record<string, VectorIndexDefinition>;
1352
- }
1353
- /**
1354
- * Build a {@link SchemaExtension}. The `key` is a runtime tag (used for
1355
- * error messages on collision) and a type-level brand.
1356
- */
1357
- declare const defineSchemaExtension: <T extends Record<string, TableDefinition>>(key: string, options: {
1358
- tables: T;
1359
- vectorIndexes?: Record<string, VectorIndexDefinition>;
1360
- }) => SchemaExtension<T>;
1361
- /**
1362
- * A plugin packages an optional schema extension and optional middleware.
1363
- * Both are independently usable: an app can install only the schema (e.g.
1364
- * for plugins that ship background workers but no per-request behavior)
1365
- * or only the middleware (plugins that augment ctx without persistent
1366
- * state).
1367
- */
1368
- interface Plugin<TExtension extends Record<string, TableDefinition> = Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn> {
1402
+ softDelete: (options?: {
1403
+ field?: string;
1404
+ }) => TableBuilder<Shape>;
1369
1405
  /**
1370
- * Optional schema extension. Apps install via
1371
- * `defineSchema(...).extend(plugin.extension)`.
1406
+ * Materialize this table from an external Postgres/MySQL behind Cloudflare
1407
+ * Hyperdrive (plan 077). A system-driven poll loop reads the tenant slice
1408
+ * (`query`, with params bound from `tenantBy`) and lands it in the DO's SQLite,
1409
+ * after which `defineShape` carries it to clients unchanged. Implies
1410
+ * `.externallyManaged()` (rows come from the ingest loop, not user mutations).
1411
+ *
1412
+ * Orthogonal to `.shardBy()` — combine them for per-tenant DOs. **Under
1413
+ * `.shardBy()` `tenantBy` is mandatory** (the tenant-isolation boundary); the
1414
+ * `external_source_unscoped` advisor lint fails the build when it is absent, and
1415
+ * `external_source_on_global` rejects combining `.source()` with `.global()`.
1372
1416
  */
1373
- readonly extension?: SchemaExtension<TExtension>;
1374
- /** Stable key identifying the plugin. Matches `extension.key` when set. */
1375
- readonly key: string;
1417
+ source: (definition: ExternalSourceDefinition) => TableBuilder<Shape>;
1418
+ /** Declare named lifecycle triggers fired inline within the write path. */
1419
+ triggers: (build: (t: TriggerBuilder<Shape>) => Record<string, TriggerDefinition>) => TableBuilder<Shape>;
1376
1420
  /**
1377
- * Optional middleware. Users attach with `c.query.use(plugin.middleware)`.
1378
- * The middleware can extend `ctx`; convention is to attach helpers under
1379
- * `ctx.api.&lt;key>`, e.g.
1380
- *
1381
- * ```ts
1382
- * middleware: ({ ctx, next }) =>
1383
- * next({ ctx: { api: { ...ctx.api, ratelimit: api } } })
1384
- * ```
1421
+ * Declare a table-level TTL: a DO alarm-driven sweep auto-deletes rows whose
1422
+ * expiry has passed (or soft-deletes them when the table also
1423
+ * `.softDelete()`s). `field` is an epoch-millisecond column; without
1424
+ * `options.after` its value is the absolute expiry instant, with `after` the
1425
+ * row expires `after` ms past `field` (`field + after`). Coarse, cheap,
1426
+ * table-level for per-row schedules use `@lunora/scheduler`.
1385
1427
  */
1386
- readonly middleware?: Middleware<TContextIn, TContextOut>;
1428
+ ttl: (field: keyof Shape & string, options?: {
1429
+ after?: number;
1430
+ }) => TableBuilder<Shape>;
1431
+ /** Declare a vector index over a single text field on this table. */
1432
+ vectorize: (field: keyof Shape & string, options: VectorizeOptions<Shape>) => TableBuilder<Shape>;
1387
1433
  }
1388
- /** Options to {@link definePlugin}. */
1389
- interface DefinePluginOptions<TExtension extends Record<string, TableDefinition>, TContextIn, TContextOut> {
1390
- extension?: SchemaExtension<TExtension>;
1391
- middleware?: Middleware<TContextIn, TContextOut>;
1434
+ /** Options for `defineVectorIndex(...)` (DSL Shape B). */
1435
+ interface VectorIndexOptions {
1436
+ dimensions: number;
1437
+ embed: VectorEmbedder;
1438
+ /** Optional projection of the source row into Vectorize metadata. */
1439
+ metadata?: (row: Record<string, unknown>) => Record<string, unknown>;
1440
+ metric: VectorMetric;
1441
+ /** The vector source: which table, and how to derive the embedded text. */
1442
+ source: {
1443
+ select: (row: Record<string, unknown>) => string;
1444
+ table: string;
1445
+ };
1392
1446
  }
1393
1447
  /**
1394
- * Call signatures for {@link definePlugin}. When `extension` is supplied the
1395
- * returned plugin's `extension` is typed as PRESENT (not `?`), so the
1396
- * canonical install pattern `defineSchema(...).extend(plugin.extension)`
1397
- * typechecks without a non-null assertion — the shape every scaffold template
1398
- * ships. The bare-options signature keeps `extension` optional for plugins
1399
- * that carry only middleware.
1448
+ * Build a table definition. Returned object is both the table definition (for
1449
+ * `defineSchema`) and a fluent builder for indexes + sharding metadata.
1400
1450
  */
1401
- interface DefinePluginFunction {
1402
- <TExtension extends Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn>(key: string, options: DefinePluginOptions<TExtension, TContextIn, TContextOut> & {
1403
- extension: SchemaExtension<TExtension>;
1404
- }): Plugin<TExtension, TContextIn, TContextOut> & {
1405
- readonly extension: SchemaExtension<TExtension>;
1406
- };
1407
- <TExtension extends Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn>(key: string, options: DefinePluginOptions<TExtension, TContextIn, TContextOut>): Plugin<TExtension, TContextIn, TContextOut>;
1408
- }
1451
+ declare const defineTable: <Shape extends Record<string, Validator>>(inputShape: Shape) => TableBuilder<Shape>;
1409
1452
  /**
1410
- * Package a schema extension + middleware as a reusable plugin. Either
1411
- * field is optional `definePlugin("foo", {})` is valid but degenerate.
1453
+ * Declare a standalone vector index (DSL Shape B). Pass the returned value in
1454
+ * the `vectorIndexes` map of {@link defineSchema} when the source is derived
1455
+ * from multiple fields or a computation rather than a single column.
1412
1456
  */
1413
- declare const definePlugin: DefinePluginFunction;
1457
+ declare const defineVectorIndex: (options: VectorIndexOptions) => VectorIndexDefinition;
1414
1458
  /**
1415
- * Bundle of registered functions a {@link Component} ships. Keys are the
1416
- * function's local name (e.g. `check`, `reset`); the registered function
1417
- * value carries its own kind / args / handler.
1418
- *
1419
- * Users re-export from their own lunora module so codegen picks them up:
1420
- *
1421
- * ```ts
1422
- * // lunora/ratelimit.ts
1423
- * import { ratelimit } from "@vendor/ratelimit-component";
1424
- * export const { check, reset } = ratelimit.functions;
1425
- * // Emits as `ratelimit:check` / `ratelimit:reset` in the generated `api`.
1426
- * ```
1427
- *
1428
- * Codegen follows the re-export back to the bundled `query/mutation/action`
1429
- * call (property access or destructuring both work), so the functions land in
1430
- * the generated `api` under the re-exporting file's namespace.
1459
+ * Options for the standalone `defineAggregateIndex(name, opts)` helper (DSL
1460
+ * Shape B). Unlike the inline `.aggregateIndex(...)` builder, this form takes
1461
+ * the owning table explicitly via `on` handy when a single counter wants to
1462
+ * live next to the schema map rather than inside a table chain.
1431
1463
  */
1432
- type ComponentFunctions = Readonly<Record<string, RegisteredFunction<any, any, FunctionKind>>>;
1464
+ interface AggregateIndexOptions {
1465
+ by?: ReadonlyArray<string>;
1466
+ field?: string;
1467
+ on: string;
1468
+ op?: AggregateOp;
1469
+ where?: Record<string, unknown>;
1470
+ }
1433
1471
  /**
1434
- * Component = {@link Plugin} with a bundle of registered functions. The
1435
- * extension + middleware + functions are independent: a component can ship
1436
- * functions without a schema (e.g. a stateless utility), or a schema
1437
- * without functions (e.g. shared table definitions), and any combination.
1472
+ * Declare a standalone aggregate index. Pass the returned value to
1473
+ * `defineSchema(tables, vectorIndexes, aggregateIndexes)` keyed by index name
1474
+ * the schema attaches it to `tables[on].aggregateIndexes` so runtime consumers
1475
+ * (DO + D1) read every index uniformly off the table definition.
1438
1476
  */
1439
- interface Component<TExtension extends Record<string, TableDefinition> = Record<string, TableDefinition>, TContextIn = unknown, TContextOut = TContextIn, F extends ComponentFunctions = ComponentFunctions> extends Plugin<TExtension, TContextIn, TContextOut> {
1440
- readonly functions: F;
1441
- }
1442
- interface DefineComponentOptions<TExtension extends Record<string, TableDefinition>, TContextIn, TContextOut, F extends ComponentFunctions> extends DefinePluginOptions<TExtension, TContextIn, TContextOut> {
1443
- /** Registered functions the component ships. Keys are the function's local name. */
1444
- functions?: F;
1477
+ declare const defineAggregateIndex: (name: string, options: AggregateIndexOptions) => AggregateIndexDefinition;
1478
+ /**
1479
+ * Options for the standalone `defineRankIndex(name, opts)` helper (DSL Shape B).
1480
+ * Mirrors the inline `.rankIndex(...)` builder but takes the owning table via
1481
+ * `table` so it can sit next to the schema map.
1482
+ */
1483
+ interface RankIndexOptions {
1484
+ partitionBy?: ReadonlyArray<string>;
1485
+ sortBy: ReadonlyArray<{
1486
+ direction?: "asc" | "desc";
1487
+ field: string;
1488
+ }>;
1489
+ table: string;
1490
+ where?: Record<string, unknown>;
1445
1491
  }
1446
1492
  /**
1447
- * Convenience wrapper around {@link definePlugin} that also bundles a set
1448
- * of registered functions. The resulting `component.functions` object is a
1449
- * record of `name registered query/mutation/action`; consumers
1450
- * re-export entries so codegen discovers them as user functions:
1451
- *
1452
- * ```ts
1453
- * export const ratelimit = defineComponent("ratelimit", {
1454
- * // Bare `buckets` merges in as `ratelimit_buckets`.
1455
- * extension: defineSchemaExtension("ratelimit", { tables: { buckets } }),
1456
- * middleware: ({ ctx, next }) => next({ ctx: { ...ctx, ratelimit: api(ctx) } }),
1457
- * functions: {
1458
- * check: query.input({ key: v.string() }).query(async ({ ctx, args }) => ...),
1459
- * reset: mutation.input({ key: v.string() }).mutation(async ({ ctx, args }) => ...),
1460
- * },
1461
- * });
1462
- * ```
1463
- *
1464
- * Re-exporting an entry (by property access or destructuring) is enough for
1465
- * codegen to discover it in the host app's namespace — the discovery resolver
1466
- * chases the re-export back to the bundled registration call.
1493
+ * Declare a standalone rank index. Pass the returned value to
1494
+ * `defineSchema(tables, vectorIndexes, aggregateIndexes, rankIndexes)` keyed
1495
+ * by index name the schema attaches it to `tables[on].rankIndexes`.
1467
1496
  */
1468
- 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>;
1497
+ declare const defineRankIndex: (name: string, options: RankIndexOptions) => RankIndexDefinition;
1469
1498
  /**
1470
- * Map every key `K` of an extension's table map `X` to its auto-prefixed name
1471
- * `${Key}_${K}`. Mirrors the runtime prefixing in {@link mergeSchemaExtension}
1472
- * so the typed `.extend(...)` chain reflects the real merged table names.
1499
+ * Build the application schema. The first argument is the table map; the
1500
+ * optional second argument registers standalone `defineVectorIndex(...)`
1501
+ * declarations (DSL Shape B) keyed by index name. The optional third argument
1502
+ * registers standalone `defineAggregateIndex(...)` declarations (DSL Shape B);
1503
+ * the optional fourth argument registers standalone `defineRankIndex(...)`
1504
+ * declarations. Both are folded into the matching `tables[on].*Indexes` array
1505
+ * so runtime backends read every index uniformly off the table definition.
1473
1506
  */
1474
- type PrefixedTables<X extends Record<string, TableDefinition>, Key extends string> = { [K in keyof X as K extends string ? `${Key}_${K}` : K]: X[K]; };
1475
1507
  /**
1476
- * Merge a {@link SchemaExtension} into an existing schema. Returns a new
1477
- * schema object never mutates the input.
1478
- *
1479
- * Extension tables are auto-namespaced: each bare table name is prefixed with
1480
- * the extension `key` (`buckets` → `ratelimit_buckets`), Convex-Components
1481
- * style, and every intra-extension reference (relation targets, aggregate /
1482
- * rank index `on`, standalone vector index `table`) is rewritten to match.
1483
- * References to base/app tables are left untouched.
1508
+ * Schema with an in-place `.extend(plugin.extension)` method. Used so apps
1509
+ * can compose plugin schemas: `defineSchema({...}).extend(authPlugin.extension)`.
1484
1510
  *
1485
- * Because each extension lives in its own `key` namespace, app↔component
1486
- * collisions are impossible. The only remaining hard error is two extensions
1487
- * sharing the same `key` and producing the same prefixed table (or vector
1488
- * index) name silent shadow would let one plugin hijack another's data.
1511
+ * `extend` is non-mutating returns a fresh `ExtendableSchema` containing
1512
+ * the merged tables. Extension tables are auto-namespaced by the extension
1513
+ * `key` (`buckets` `ratelimit_buckets`), so the merged type carries the
1514
+ * prefixed names via {@link PrefixedTables}. Chains:
1515
+ * `defineSchema(...).extend(a).extend(b)` is the typed equivalent of merging
1516
+ * `a`'s prefixed tables then `b`'s.
1489
1517
  */
1490
- declare const mergeSchemaExtension: <T extends Record<string, TableDefinition>, X extends Record<string, TableDefinition>, Key extends string = string>(base: Schema<T>, extension: SchemaExtension<X> & {
1491
- readonly key: Key;
1492
- }) => Schema<PrefixedTables<X, Key> & T>;
1518
+ type ExtendableSchema<T extends Record<string, TableDefinition>> = {
1519
+ extend: <X extends Record<string, TableDefinition>, Key extends string>(extension: SchemaExtension<X> & {
1520
+ readonly key: Key;
1521
+ }) => ExtendableSchema<PrefixedTables<X, Key> & T>;
1522
+ /**
1523
+ * Pin every Durable Object the app reaches — shards, fan-out, subscriptions,
1524
+ * the scheduler, and `ctx.containers` — to a Cloudflare data-residency
1525
+ * jurisdiction (`"eu"`, `"us"`, `"fedramp"`). Codegen reads this off the
1526
+ * schema and emits it into the generated worker's `createWorker({ jurisdiction })`
1527
+ * (and `ctx.scheduler` / `ctx.containers`). Non-mutating: returns a fresh
1528
+ * `ExtendableSchema`, so it composes with `.rls(...)` / `.extend(...)` in any order.
1529
+ *
1530
+ * ⚠️ **Set this once, before your first deploy — changing or removing it
1531
+ * strands data.** A Durable Object name maps to a *different* ID in each
1532
+ * jurisdiction, so toggling this on an existing app makes every shard, scheduler
1533
+ * job, and session DO resolve to a NEW, empty DO; the previous data stays in the
1534
+ * old jurisdiction's DOs and is no longer reachable. There is no in-place
1535
+ * migration — you would have to export from the old jurisdiction and import
1536
+ * into the new one.
1537
+ *
1538
+ * Note: this pins **DO-backed** state only. D1-backed state — `.global()`
1539
+ * tables and `@lunora/auth` sessions alike — is governed by D1's own location
1540
+ * settings, not this option.
1541
+ * @see https://developers.cloudflare.com/durable-objects/reference/data-location/
1542
+ */
1543
+ jurisdiction: (jurisdiction: DurableObjectJurisdiction) => ExtendableSchema<T>;
1544
+ /**
1545
+ * Turn on secure-by-default RLS for the whole schema. Every table is then
1546
+ * protected — the DO/D1 write path denies raw, non-RLS `ctx.db` access, so a
1547
+ * procedure that forgets `.use(rls(...))` fails closed. Opt a table out with
1548
+ * `.public()`. Non-mutating: returns a fresh `ExtendableSchema` carrying the
1549
+ * mode, so `.rls("required")` composes with `.extend(...)` either order.
1550
+ */
1551
+ rls: (mode: "required") => ExtendableSchema<T>;
1552
+ } & Schema<T>;
1493
1553
  /**
1494
- * Install several plugins' schema extensions in one call the one-shot
1495
- * counterpart to chaining `defineSchema(...).extend(a).extend(b)`. Plugins
1496
- * without an `extension` (middleware-only) are skipped; tables from those that
1497
- * do are auto-prefixed and reference-rewritten exactly as
1498
- * {@link mergeSchemaExtension} does for a single `.extend(...)`.
1554
+ * Columns every row carries implicitly (never part of a table's declared
1555
+ * `shape`), so `.index()` may legitimately name them. The single source for
1556
+ * both the compile-time allow-list (`TableBuilder["index"]`'s `fields` type,
1557
+ * via `(typeof SYSTEM_INDEX_FIELDS)[number]`) and the runtime cross-check
1558
+ * below (via `SYSTEM_INDEX_FIELDS_SET`) declared once so the two can't
1559
+ * drift apart.
1560
+ */
1561
+ declare const SYSTEM_INDEX_FIELDS: readonly ["_creationTime", "_id"];
1562
+ /**
1563
+ * Per-table, per-KIND index→declared-fields map: for each table, each index
1564
+ * KIND (`index` | `rank` | `geo`) that has at least one declared index maps
1565
+ * to a name→fields record for that kind only. Distilled by
1566
+ * {@link indexFieldsFromSchema}; this is the shape `mask()`'s
1567
+ * `MaskOptions.indexFields` expects (see `./mask/types`), so a table not
1568
+ * present here (no declared indexes of any kind) is simply absent from the
1569
+ * map rather than mapped to `{}`, and a kind with no declared indexes on a
1570
+ * table that HAS other kinds is simply absent from that table's entry.
1499
1571
  *
1500
- * ```ts
1501
- * const schema = installPlugins(defineSchema({ todos }), [ratelimit, audit]);
1502
- * // todos + ratelimit_* + audit_*
1503
- * ```
1572
+ * Kept per kind (rather than one flat name→fields record) because the engine
1573
+ * resolves `withIndex`/`withGeoIndex`/rank reads in THREE separate
1574
+ * namespaces (`tableDefinition.indexes` / `.geoIndexes` / `.rankIndexes`
1575
+ * see `@lunora/shard-engine`'s `ctx-db.ts`), so the same name can legally and
1576
+ * unambiguously denote a different index per kind. A flat map would let one
1577
+ * kind's fields silently shadow another's for a colliding name, producing a
1578
+ * wrong-namespace answer from the mask guard (checking the wrong index's
1579
+ * fields) instead of the documented fail-open (missing lookup) — see plan 258.
1580
+ */
1581
+ type IndexFieldsByTable = Readonly<Record<string, {
1582
+ readonly geo?: Readonly<Record<string, ReadonlyArray<string>>>;
1583
+ readonly index?: Readonly<Record<string, ReadonlyArray<string>>>;
1584
+ readonly rank?: Readonly<Record<string, ReadonlyArray<string>>>;
1585
+ }>>;
1586
+ declare const indexFieldsFromSchema: (schema: Schema) => IndexFieldsByTable;
1587
+ declare const defineSchema: <T extends Record<string, TableDefinition>>(tables: T, vectorIndexes?: Record<string, VectorIndexDefinition>, aggregateIndexes?: Record<string, AggregateIndexDefinition>, rankIndexes?: Record<string, RankIndexDefinition>) => ExtendableSchema<T>;
1588
+ /**
1589
+ * Context handed to a {@link MaskFn} (and to {@link MaskOptions.bypass}). The
1590
+ * `auth` shape mirrors RLS's `PolicyContext.auth` one-for-one — same identity
1591
+ * resolver, same `can(...)` permission check — so an author can branch a mask
1592
+ * on the caller's role/permission. `row` is the full pre-mask row the column
1593
+ * belongs to; `column` is the column currently being masked. Both are absent
1594
+ * when the context is used for the procedure-wide `bypass` check (no specific
1595
+ * cell is in play yet).
1596
+ */
1597
+ interface MaskContext<Context = unknown> {
1598
+ readonly auth: {
1599
+ /** `true` when any of the request's `roles` grants `permission` (see {@link MaskOptions.roles}). Fails closed for unregistered roles. */
1600
+ readonly can: (permission: Permission | string) => boolean;
1601
+ readonly identity?: Record<string, unknown> | null;
1602
+ readonly roles: ReadonlyArray<string>;
1603
+ readonly userId: null | string;
1604
+ };
1605
+ /** The column currently being masked. Present only inside a per-cell {@link MaskFn}. */
1606
+ readonly column?: string;
1607
+ readonly ctx: Context;
1608
+ /** The full pre-mask row the masked cell belongs to. Present only inside a per-cell {@link MaskFn}. */
1609
+ readonly row?: Record<string, unknown>;
1610
+ }
1611
+ /**
1612
+ * A custom masking function. Receives the raw cell value and the
1613
+ * {@link MaskContext}, returns the value to surface. Use it for partial masks
1614
+ * (`maskMiddle(phone)`), role-aware reveals (`ctx.auth.can(...) ? value : null`),
1615
+ * or format-preserving tokens. A function that **throws** fails closed — the
1616
+ * cell is redacted to `null`, never leaked raw.
1617
+ */
1618
+ type MaskFn<Context = unknown> = (value: unknown, context: MaskContext<Context>) => unknown;
1619
+ /**
1620
+ * How a column is masked:
1504
1621
  *
1505
- * Pair it with {@link composePluginMiddleware} to attach every plugin's
1506
- * middleware in a single `.use(...)`, so installing N plugins is two calls
1507
- * rather than N `.extend(...)` + N `.use(...)`.
1622
+ * - `"redact"` drop the value to `null`. The simplest, safest strategy, and
1623
+ * the right choice for any value that must actually be kept secret.
1624
+ * - `"hash"` replace with a stable token (unsalted 32-bit FNV-1a hex) so the
1625
+ * same input always yields the same token (joinable/groupable client-side).
1626
+ * **This is NOT a confidentiality control.** It is a non-cryptographic,
1627
+ * unsalted, deterministic, narrow (~2^32) digest: low-entropy values (emails,
1628
+ * phone numbers, SSNs) are brute-force-recoverable by the very caller you are
1629
+ * masking from, and identical values always produce identical tokens across
1630
+ * rows/columns/tenants (enabling correlation). Use `"hash"` ONLY when you want a
1631
+ * stable pseudonym for grouping/joining and leaking the value is acceptable —
1632
+ * never to hide sensitive PII. For PII that must stay hidden, use `"redact"`.
1633
+ * - a {@link MaskFn} — author-defined transform (partial mask, role-aware reveal).
1508
1634
  */
1509
- 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>>;
1635
+ type MaskStrategy<Context = unknown> = "hash" | "redact" | MaskFn<Context>;
1636
+ /** Per-column strategy map for one table: `{ email: "redact", phone: maskMiddle }`. */
1637
+ type MaskColumns<Context = unknown> = Record<string, MaskStrategy<Context>>;
1510
1638
  /**
1511
- * Compose every plugin's middleware into a single middleware you attach with one
1512
- * `.use(...)`. Plugins without middleware (schema-only) are skipped; the rest run
1513
- * in array order, each seeing the context the previous one widened, so the final
1514
- * `next({ ctx })` the builder receives carries every plugin's `ctx.api.&lt;key>`
1515
- * additions. Equivalent to `.use(a.middleware).use(b.middleware)…` but as one
1516
- * value, the middleware sibling of {@link installPlugins}.
1639
+ * The mask declaration passed to `mask(...)`: a table column strategy map.
1640
+ * Deliberately a plain object literal so the codegen feeder can statically read
1641
+ * which columns a procedure masks (powering the `mask_uncovered_pii_column`
1642
+ * advisor lint), exactly as the RLS feeder reads policy tables.
1643
+ */
1644
+ type MaskPolicies<Context = unknown> = Record<string, MaskColumns<Context>>;
1645
+ /**
1646
+ * Options for `mask(policies, options)`.
1517
1647
  *
1518
- * `ContextIn` is left free so the builder infers it from the context at the
1519
- * `.use(...)` site; the result type widens it by the union of the plugins'
1520
- * outputs.
1648
+ * - `roles` registers the role→permission grants that back `ctx.auth.can(...)`
1649
+ * inside a {@link MaskFn} identical to `rls(policies, { roles })`. A role
1650
+ * not listed grants no permissions (fails closed for unknown roles).
1651
+ * - `bypass` is a procedure-wide escape hatch: when it returns `true` the whole
1652
+ * mask is skipped (the caller sees raw values). Use it for a privileged
1653
+ * viewer — `bypass: ({ auth }) => auth.can("pii:view")`. Prefer this over
1654
+ * branching every column when an entire class of caller should see clear data.
1655
+ * - `indexFields` closes the bare-index-scan / rank / geo position oracle (see
1656
+ * the `mask/middleware` module docblock's "Residual read-position oracles" section).
1521
1657
  */
1522
- 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>;
1523
- /** Options for `.vectorize(field, opts)` (DSL Shape A). */
1524
- interface VectorizeOptions<Shape extends Record<string, Validator> = Record<string, Validator>> {
1525
- dimensions: number;
1526
- embed: VectorEmbedder;
1527
- /** Logical index name; must match a `[[vectorize]]` binding in wrangler. */
1528
- index: string;
1529
- /** Fields mirrored into Vectorize metadata for filtering. */
1530
- metadata?: ReadonlyArray<keyof Shape & string>;
1531
- metric: VectorMetric;
1658
+ interface MaskOptions<Context = unknown> {
1659
+ readonly bypass?: (context: MaskContext<Context>) => boolean;
1660
+ /**
1661
+ * Per-table, per-KIND index→declared-fields map (regular index `fields`
1662
+ * under `index`; rank index `sortBy` ∪ `partitionBy` under `rank`; geo
1663
+ * index `field` under `geo`). Supplied to close the bare-index-scan /
1664
+ * rank / geo position oracle: a `withIndex(name)` with no range callback,
1665
+ * a `rank`/`rankPage`/`rankBefore` read, or a `withGeoIndex` read, over an
1666
+ * index whose DECLARED fields (for that read's own kind) intersect a
1667
+ * masked column, is rejected. Kept per kind — rather than one flat
1668
+ * name→fields map — because the engine resolves `withIndex` /
1669
+ * `withGeoIndex` / rank reads in three separate namespaces, so the same
1670
+ * index name can legally denote a different index per kind; a flat map
1671
+ * would let one kind's fields shadow another's for a colliding name.
1672
+ * OPTIONAL and additive — omit it and behaviour is unchanged (the oracle
1673
+ * stays open, exactly as before this option existed). Build it with
1674
+ * `indexFieldsFromSchema` (exported from `@lunora/server`):
1675
+ * `mask(policies, { indexFields: indexFieldsFromSchema(schema) })`.
1676
+ */
1677
+ readonly indexFields?: IndexFieldsByTable;
1678
+ readonly roles?: ReadonlyArray<Role>;
1532
1679
  }
1533
- /** A `one` (many-to-one) relation descriptor; phantom `Target` carries the target table name. */
1534
- interface OneRelation<Target extends string = string> extends RelationDefinition {
1535
- readonly __target?: Target;
1536
- readonly kind: "one";
1680
+ interface QueryPage$1 {
1681
+ continueCursor: null | string;
1682
+ isDone: boolean;
1683
+ page: Record<string, unknown>[];
1537
1684
  }
1538
- /** A `many` (one-to-many) relation descriptor; phantom `Target` carries the target table name. */
1539
- interface ManyRelation<Target extends string = string> extends RelationDefinition {
1540
- readonly __target?: Target;
1541
- readonly kind: "many";
1685
+ interface QueryArgs$1 {
1686
+ baseWhere?: unknown;
1687
+ cursor?: null | string;
1688
+ limit?: number;
1689
+ orderBy?: ReadonlyArray<Record<string, unknown>>;
1690
+ where?: unknown;
1691
+ with?: Record<string, unknown>;
1542
1692
  }
1543
- /** The `r` argument passed to `.relations((r) => …)`. */
1544
- interface RelationBuilder {
1545
- /** One-to-many: the FK `field` lives on the target table, matching this table's `references` (default `_id`). */
1546
- many: <Target extends string>(table: Target, options: {
1547
- field: string;
1548
- references?: string;
1549
- }) => ManyRelation<Target>;
1550
- /** Many-to-one: the FK `field` lives on this table, pointing at `table`.`references` (default `_id`). */
1551
- one: <Target extends string>(table: Target, options: {
1552
- field: string;
1553
- onDelete?: OnDeleteAction;
1554
- references?: string;
1555
- }) => OneRelation<Target>;
1693
+ interface AggregateArgs$1 {
1694
+ field?: string;
1695
+ op: string;
1696
+ where?: unknown;
1556
1697
  }
1557
- /**
1558
- * Options for the inline `.aggregateIndex(name, opts)` builder. `op` defaults to
1559
- * `count` so `aggregateIndex("byUser", { by: ["userId"] })` is a single-line
1560
- * `COUNT(*) GROUP BY userId` accelerator.
1561
- */
1562
- interface InlineAggregateIndexOptions<Shape extends Record<string, Validator> = Record<string, Validator>> {
1563
- /** Group keys; counter rows are one per distinct tuple. Omitted = single-row aggregate over the whole table. */
1564
- by?: ReadonlyArray<keyof Shape & string>;
1565
- /** The column the reducer applies to. Required for `sum`/`min`/`max`/`avg`; ignored for `count`. */
1566
- field?: keyof Shape & string;
1567
- /** Reducer (default `count`). */
1568
- op?: AggregateOp;
1569
- /** Static predicate baked into the counter — only matching rows are aggregated. */
1570
- where?: Record<string, unknown>;
1698
+ interface GroupByArgs$1 {
1699
+ agg?: {
1700
+ field?: string;
1701
+ op: string;
1702
+ };
1703
+ by: ReadonlyArray<string>;
1704
+ where?: unknown;
1705
+ }
1706
+ /** One row of a `.collectWithScores()` result mirrors `@lunora/shard-engine`'s `ScoredDocument`. */
1707
+ type ScoredDocument = GeoScoredDocument | SearchScoredDocument;
1708
+ /** A `.withGeoIndex()` row — mirrors `@lunora/shard-engine`'s `GeoScoredDocument`. */
1709
+ interface GeoScoredDocument {
1710
+ distanceMeters: null | number;
1711
+ document: Record<string, unknown>;
1712
+ score?: never;
1713
+ }
1714
+ /** A `.withSearchIndex()` row — mirrors `@lunora/shard-engine`'s `SearchScoredDocument`. */
1715
+ interface SearchScoredDocument {
1716
+ distanceMeters?: never;
1717
+ document: Record<string, unknown>;
1718
+ score: number;
1719
+ }
1720
+ interface TableReaderLike$1 {
1721
+ [Symbol.asyncIterator]: () => AsyncIterator<Record<string, unknown>>;
1722
+ collect: () => Promise<Record<string, unknown>[]>;
1723
+ collectWithScores: () => Promise<ScoredDocument[]>;
1724
+ filter: (predicate: (document: Record<string, unknown>) => boolean) => TableReaderLike$1;
1725
+ first: () => Promise<Record<string, unknown> | null>;
1726
+ order: (direction: "asc" | "desc") => TableReaderLike$1;
1727
+ paginate: (options: {
1728
+ cursor?: null | string;
1729
+ numItems: number;
1730
+ }) => Promise<QueryPage$1>;
1731
+ take: (limit: number) => Promise<Record<string, unknown>[]>;
1732
+ unique: () => Promise<Record<string, unknown> | null>;
1733
+ withGeoIndex: (indexName: string, build: (q: unknown) => unknown) => TableReaderLike$1;
1734
+ withIndex: (indexName: string, range?: (q: unknown) => unknown) => TableReaderLike$1;
1735
+ withSearchIndex: (indexName: string, search: (q: unknown) => unknown) => TableReaderLike$1;
1571
1736
  }
1572
1737
  /**
1573
- * Options for the inline `.rankIndex(name, opts)` builder. `sortBy` is required;
1574
- * accepts either an array of `{ field, direction }` keys, or the shorthand
1575
- * `["field"]` (asc) / `{ field: "desc" }` map entries. `partitionBy` scopes the
1576
- * rank omitted one global rank over the whole table.
1738
+ * Structural projection of the runtime ORM writer the same subset
1739
+ * `../rls/middleware` mirrors, so the wrapper is interchangeable between
1740
+ * `@lunora/do`'s and `@lunora/d1`'s `DatabaseWriterLike` without an
1741
+ * inter-package dependency. `rankBefore` is optional (the D1 twin omits it).
1577
1742
  */
1578
- interface InlineRankIndexOptions<Shape extends Record<string, Validator> = Record<string, Validator>> {
1579
- /** Columns that scope each ranking; omitted one global rank. */
1580
- partitionBy?: ReadonlyArray<keyof Shape & string>;
1581
- /** Ordered sort keys driving the rank. Required. */
1582
- sortBy: ReadonlyArray<{
1583
- direction?: "asc" | "desc";
1584
- field: keyof Shape & string;
1743
+ interface MaskDatabase {
1744
+ aggregate: (tableName: string, options: AggregateArgs$1) => Promise<null | number>;
1745
+ count: (tableName: string, whereOrArgs?: unknown) => Promise<number>;
1746
+ delete: (id: string, expectedTable?: string) => Promise<void>;
1747
+ deleteMany: (ids: ReadonlyArray<string>, options?: {
1748
+ limit?: number;
1749
+ }) => Promise<{
1750
+ deleted: number;
1585
1751
  }>;
1586
- /** Static predicate baked into the index; only matching rows enter. */
1587
- where?: Record<string, unknown>;
1588
- }
1589
- interface TableBuilder<Shape extends Record<string, Validator> = Record<string, Validator>> extends TableDefinition<Shape> {
1590
- /** Declare an aggregate (counter/sum/…) maintained by triggers for O(1) reads. */
1591
- aggregateIndex: (name: string, options?: InlineAggregateIndexOptions<Shape>) => TableBuilder<Shape>;
1592
- /**
1593
- * Mark this table as written outside Lunora's discoverable insert path —
1594
- * by an adapter, a migration, or framework middleware (e.g. `@lunora/auth`'s
1595
- * better-auth tables, `@lunora/ratelimit`'s store). Advisor insert-path lints
1596
- * (`table_without_insert`) then skip it instead of flagging the absent
1597
- * `ctx.db.insert(...)`.
1598
- */
1599
- externallyManaged: () => TableBuilder<Shape>;
1600
- /**
1601
- * Declare a geospatial index over a `v.geoPoint()` column. The runtime keeps
1602
- * a geohash companion so `withGeoIndex(name, q => q.near(point, radius))` and
1603
- * `.within(bbox)` resolve as a geohash-prefix range scan + Haversine
1604
- * refine/sort. `options.precision` tunes the geohash length (default 9).
1605
- */
1606
- geoIndex: (name: string, options: {
1607
- field: keyof Shape & string;
1608
- precision?: number;
1609
- }) => TableBuilder<Shape>;
1610
- /**
1611
- * Mark this table as global (cross-shard). Backed by **D1** by default;
1612
- * pass `{ backend: "hyperdrive" }` to store it in a Postgres/MySQL database
1613
- * via Cloudflare Hyperdrive (PlanetScale, Neon, …) instead. Either way the
1614
- * table stays reactive — live queries re-run on write.
1615
- */
1616
- global: (options?: {
1617
- backend?: GlobalBackend;
1618
- }) => TableBuilder<Shape>;
1619
- /** Add a secondary index. */
1620
- index: (name: string, fields: ReadonlyArray<string>, options?: {
1621
- unique?: boolean;
1622
- }) => TableBuilder<Shape>;
1623
- /**
1624
- * Name the column holding the owning user's id, so "only the owner sees these
1625
- * rows" is declared once here rather than restated in every shape.
1626
- *
1627
- * A `defineShape({ table, owner: true })` over this table derives its predicate
1628
- * from the field: the subscriber's verified `ctx.auth.userId` must match, and an
1629
- * anonymous subscriber is denied. Pairs naturally with `.shardBy(field)` on the
1630
- * same column the shard key routes the storage, `ownedBy` states who the rows
1631
- * belong to — but the two are independent and either can be used alone.
1632
- *
1633
- * This is a *shape* declaration, not an RLS policy: it narrows what a shape
1634
- * replicates. Guarding procedure reads/writes is still `rls(...)`'s job.
1635
- */
1636
- ownedBy: (field: keyof Shape & string) => TableBuilder<Shape>;
1637
- /**
1638
- * Opt this table OUT of secure-by-default RLS. Under a schema marked
1639
- * `.rls("required")`, every table is protected (the write path denies raw,
1640
- * non-RLS `ctx.db` access); calling `.public()` exempts this one table so a
1641
- * plain `query`/`mutation` may read/write it without an RLS policy. No effect
1642
- * when the schema does not require RLS.
1643
- */
1644
- public: () => TableBuilder<Shape>;
1645
- /**
1646
- * Declare a rank index (sorted companion table, btree-backed) for
1647
- * `rank(row)` / `rankPage()` reads in O(log n). See {@link RankIndexDefinition}.
1648
- */
1649
- rankIndex: (name: string, options: InlineRankIndexOptions<Shape>) => TableBuilder<Shape>;
1650
- /** Declare relations to other tables, loaded via `findMany({ with })`. */
1651
- relations: (build: (r: RelationBuilder) => Record<string, RelationDefinition>) => TableBuilder<Shape>;
1652
- /**
1653
- * Add a full-text search index over `field`, queried with
1654
- * `.withSearchIndex(name, q => q.search(field, term))`. `field` may be a
1655
- * dot-separated path into a nested object (`"properties.name"`).
1656
- * `filterFields` (at most 16) lists the columns `.eq()` may narrow by inside
1657
- * the search. `language` selects the text analysis (accent folding always,
1658
- * plus that language's stopwords). `staged: true` skips the migration-time
1659
- * backfill on a large existing table. `strategy: "native"` uses the engine's
1660
- * own full-text index where it has one (Postgres) — faster on large corpora,
1661
- * at the cost of the engine ranking rather than the shared scorer.
1662
- */
1663
- searchIndex: (name: string, options: {
1664
- field: string;
1665
- filterFields?: ReadonlyArray<string>;
1666
- language?: SearchLanguage;
1667
- staged?: boolean;
1668
- strategy?: SearchStrategy;
1669
- }) => TableBuilder<Shape>;
1670
- /** Route storage by the named field — one DO per distinct value. */
1671
- shardBy: (field: keyof Shape & string) => TableBuilder<Shape>;
1672
- /**
1673
- * Turn on soft delete. Adds a nullable timestamp column (`options.field`,
1674
- * default `deletedAt`) and changes `ctx.db.&lt;table>.delete()` to **set** it
1675
- * instead of removing the row; `onDelete: "cascade"` children are recursively
1676
- * soft-deleted too. **List reads** (`findMany`/`findFirst`/`query()`/`count`/
1677
- * `aggregate`/relation loads) then hide soft-deleted rows unless they pass
1678
- * `includeDeleted: true`; by-id `get`/`patch`/`replace` and the new
1679
- * `restore()` still address the row directly. `hardDelete()` physically
1680
- * removes it (cascading as a real delete). Note: `includeDeleted` is a read
1681
- * scope, not access control — anyone who can run the read can set it; a unique
1682
- * index still rejects a new row that collides with a soft-deleted one (the row
1683
- * physically persists).
1684
- */
1685
- softDelete: (options?: {
1686
- field?: string;
1687
- }) => TableBuilder<Shape>;
1688
- /**
1689
- * Materialize this table from an external Postgres/MySQL behind Cloudflare
1690
- * Hyperdrive (plan 077). A system-driven poll loop reads the tenant slice
1691
- * (`query`, with params bound from `tenantBy`) and lands it in the DO's SQLite,
1692
- * after which `defineShape` carries it to clients unchanged. Implies
1693
- * `.externallyManaged()` (rows come from the ingest loop, not user mutations).
1694
- *
1695
- * Orthogonal to `.shardBy()` — combine them for per-tenant DOs. **Under
1696
- * `.shardBy()` `tenantBy` is mandatory** (the tenant-isolation boundary); the
1697
- * `external_source_unscoped` advisor lint fails the build when it is absent, and
1698
- * `external_source_on_global` rejects combining `.source()` with `.global()`.
1699
- */
1700
- source: (definition: ExternalSourceDefinition) => TableBuilder<Shape>;
1701
- /** Declare named lifecycle triggers fired inline within the write path. */
1702
- triggers: (build: (t: TriggerBuilder<Shape>) => Record<string, TriggerDefinition>) => TableBuilder<Shape>;
1703
- /**
1704
- * Declare a table-level TTL: a DO alarm-driven sweep auto-deletes rows whose
1705
- * expiry has passed (or soft-deletes them when the table also
1706
- * `.softDelete()`s). `field` is an epoch-millisecond column; without
1707
- * `options.after` its value is the absolute expiry instant, with `after` the
1708
- * row expires `after` ms past `field` (`field + after`). Coarse, cheap,
1709
- * table-level — for per-row schedules use `@lunora/scheduler`.
1710
- */
1711
- ttl: (field: keyof Shape & string, options?: {
1712
- after?: number;
1713
- }) => TableBuilder<Shape>;
1714
- /** Declare a vector index over a single text field on this table. */
1715
- vectorize: (field: keyof Shape & string, options: VectorizeOptions<Shape>) => TableBuilder<Shape>;
1752
+ deleteWhere?: (tableName: string, where: Record<string, unknown>, options?: {
1753
+ limit?: number;
1754
+ }) => Promise<{
1755
+ deleted: number;
1756
+ }>;
1757
+ findFirst: (tableName: string, args?: QueryArgs$1) => Promise<Record<string, unknown> | null>;
1758
+ findFirstOrThrow: (tableName: string, args?: QueryArgs$1) => Promise<Record<string, unknown>>;
1759
+ findMany: (tableName: string, args?: QueryArgs$1) => Promise<QueryPage$1>;
1760
+ get: (id: string, expectedTable?: string) => Promise<Record<string, unknown> | null>;
1761
+ groupBy: (tableName: string, options: GroupByArgs$1) => Promise<ReadonlyArray<{
1762
+ key: Record<string, unknown>;
1763
+ value: null | number;
1764
+ }>>;
1765
+ insert: (tableName: string, document: Record<string, unknown>) => Promise<string>;
1766
+ insertMany: (tableName: string, documents: ReadonlyArray<Record<string, unknown>>, options?: {
1767
+ limit?: number;
1768
+ skipDuplicates?: boolean;
1769
+ }) => Promise<(string | null)[]>;
1770
+ lookupById?: (id: string, expectedTable?: string) => Promise<null | {
1771
+ row: Record<string, unknown>;
1772
+ tableName: string;
1773
+ }>;
1774
+ patch: (id: string, patch: Record<string, unknown>, expectedTable?: string) => Promise<void>;
1775
+ patchMany: (patches: ReadonlyArray<{
1776
+ id: string;
1777
+ patch: Record<string, unknown>;
1778
+ }>, options?: {
1779
+ limit?: number;
1780
+ }) => Promise<{
1781
+ patched: number;
1782
+ }>;
1783
+ patchWhere?: (tableName: string, args: {
1784
+ patch: Record<string, unknown>;
1785
+ where: Record<string, unknown>;
1786
+ }, options?: {
1787
+ limit?: number;
1788
+ }) => Promise<{
1789
+ patched: number;
1790
+ }>;
1791
+ query: (tableName: string) => TableReaderLike$1;
1792
+ rank: (tableName: string, indexName: string, options: unknown) => Promise<null | {
1793
+ position: number;
1794
+ total: number;
1795
+ }>;
1796
+ rankBefore?: (tableName: string, indexName: string, options: unknown) => Promise<{
1797
+ before: number;
1798
+ total: number;
1799
+ }>;
1800
+ rankPage: (tableName: string, indexName: string, options?: unknown) => Promise<QueryPage$1>;
1801
+ /** Cross-shard companion to `rankPage`, gated the same way `rankPage` is masked below. */
1802
+ rankPageRows?: (tableName: string, indexName: string, options?: unknown) => Promise<ShardRankPageResultLike>;
1803
+ replace: (id: string, document: Record<string, unknown>, expectedTable?: string) => Promise<void>;
1716
1804
  }
1717
- /** Options for `defineVectorIndex(...)` (DSL Shape B). */
1718
- interface VectorIndexOptions {
1719
- dimensions: number;
1720
- embed: VectorEmbedder;
1721
- /** Optional projection of the source row into Vectorize metadata. */
1722
- metadata?: (row: Record<string, unknown>) => Record<string, unknown>;
1723
- metric: VectorMetric;
1724
- /** The vector source: which table, and how to derive the embedded text. */
1725
- source: {
1726
- select: (row: Record<string, unknown>) => string;
1727
- table: string;
1728
- };
1805
+ /** Roles list source on the context. Tolerant of older auth states (mirrors RLS's `AuthLike`). */
1806
+ type AuthLike$1 = {
1807
+ getIdentity?: () => Promise<Record<string, unknown> | null>;
1808
+ roles?: ReadonlyArray<string>;
1809
+ userId?: null | string;
1810
+ };
1811
+ interface MaskContextIn {
1812
+ auth?: AuthLike$1;
1813
+ db: MaskDatabase;
1729
1814
  }
1730
1815
  /**
1731
- * Build a table definition. Returned object is both the table definition (for
1732
- * `defineSchema`) and a fluent builder for indexes + sharding metadata.
1733
- */
1734
- declare const defineTable: <Shape extends Record<string, Validator>>(inputShape: Shape) => TableBuilder<Shape>;
1735
- /**
1736
- * Declare a standalone vector index (DSL Shape B). Pass the returned value in
1737
- * the `vectorIndexes` map of {@link defineSchema} when the source is derived
1738
- * from multiple fields or a computation rather than a single column.
1739
- */
1740
- declare const defineVectorIndex: (options: VectorIndexOptions) => VectorIndexDefinition;
1741
- /**
1742
- * Options for the standalone `defineAggregateIndex(name, opts)` helper (DSL
1743
- * Shape B). Unlike the inline `.aggregateIndex(...)` builder, this form takes
1744
- * the owning table explicitly via `on` — handy when a single counter wants to
1745
- * live next to the schema map rather than inside a table chain.
1816
+ * Procedure-builder middleware. Apply per-request via `.use(mask(policies))`.
1817
+ * Closes over the policy map at builder-construction time; resolves identity +
1818
+ * the `bypass` decision per call against the live ctx.
1819
+ *
1820
+ * IMPORTANT: a mask is in scope only for procedures whose builder chain
1821
+ * includes this middleware opt-in, never global (the same invariant as RLS).
1746
1822
  */
1747
- interface AggregateIndexOptions {
1748
- by?: ReadonlyArray<string>;
1749
- field?: string;
1750
- on: string;
1751
- op?: AggregateOp;
1752
- where?: Record<string, unknown>;
1753
- }
1823
+ declare const mask: <Context extends MaskContextIn = MaskContextIn>(policies: MaskPolicies<Context>, options?: MaskOptions<Context>) => Middleware<Context, Context>;
1824
+ /** Project-wide masked-column registry: every table any registered function masks, unioned. */
1825
+ type MaskRegistry = ReadonlyMap<string, ReadonlySet<string>>;
1754
1826
  /**
1755
- * Declare a standalone aggregate index. Pass the returned value to
1756
- * `defineSchema(tables, vectorIndexes, aggregateIndexes)` keyed by index name
1757
- * the schema attaches it to `tables[on].aggregateIndexes` so runtime consumers
1758
- * (DO + D1) read every index uniformly off the table definition.
1827
+ * Build the project-wide masked-column registry from the registered functions
1828
+ * (pass `Object.values(LUNORA_FUNCTIONS)`) the mask-column twin of
1829
+ * `buildRlsReadRegistry`. Unions every function's `.use(mask(...))` columns
1830
+ * per table: a column masked by ANY registered function counts as masked in
1831
+ * the registry — there is no "which procedure would this shape have gone
1832
+ * through" question to narrow by, so the union is the only safe answer.
1759
1833
  */
1760
- declare const defineAggregateIndex: (name: string, options: AggregateIndexOptions) => AggregateIndexDefinition;
1834
+ declare const buildMaskRegistry: (functions: Iterable<unknown>) => MaskRegistry;
1835
+ /** A document handed to a migration transform: the stored row including `_id`/`_creationTime`. */
1836
+ type MigrationDocument = Record<string, unknown>;
1761
1837
  /**
1762
- * Options for the standalone `defineRankIndex(name, opts)` helper (DSL Shape B).
1763
- * Mirrors the inline `.rankIndex(...)` builder but takes the owning table via
1764
- * `table` so it can sit next to the schema map.
1838
+ * The read surface a transform reaches through its `ctx`.
1839
+ *
1840
+ * Read-only by design: the runner accounts for exactly one rewrite per row read,
1841
+ * and a transform writing directly would make that count describe something
1842
+ * other than what happened. Scoped to the shard the runner is walking.
1765
1843
  */
1766
- interface RankIndexOptions {
1767
- partitionBy?: ReadonlyArray<string>;
1768
- sortBy: ReadonlyArray<{
1769
- direction?: "asc" | "desc";
1770
- field: string;
1844
+ interface MigrationReader {
1845
+ count: (table: string, where?: Record<string, unknown>) => Promise<number>;
1846
+ findFirst: (table: string, args?: Record<string, unknown>) => Promise<MigrationDocument | null>;
1847
+ findMany: (table: string, args?: Record<string, unknown>) => Promise<{
1848
+ isDone: boolean;
1849
+ page: MigrationDocument[];
1771
1850
  }>;
1772
- table: string;
1773
- where?: Record<string, unknown>;
1851
+ get: (id: string, expectedTable?: string) => Promise<MigrationDocument | null>;
1852
+ }
1853
+ /** The context handed to a transform alongside the row. */
1854
+ interface MigrationCtx {
1855
+ db: MigrationReader;
1774
1856
  }
1775
1857
  /**
1776
- * Declare a standalone rank index. Pass the returned value to
1777
- * `defineSchema(tables, vectorIndexes, aggregateIndexes, rankIndexes)` keyed
1778
- * by index name the schema attaches it to `tables[on].rankIndexes`.
1779
- */
1780
- declare const defineRankIndex: (name: string, options: RankIndexOptions) => RankIndexDefinition;
1781
- /**
1782
- * Build the application schema. The first argument is the table map; the
1783
- * optional second argument registers standalone `defineVectorIndex(...)`
1784
- * declarations (DSL Shape B) keyed by index name. The optional third argument
1785
- * registers standalone `defineAggregateIndex(...)` declarations (DSL Shape B);
1786
- * the optional fourth argument registers standalone `defineRankIndex(...)`
1787
- * declarations. Both are folded into the matching `tables[on].*Indexes` array
1788
- * so runtime backends read every index uniformly off the table definition.
1858
+ * Transform applied to one document. Return a new document to rewrite the row,
1859
+ * or `undefined` to leave it untouched (skipped, not counted as changed). The
1860
+ * runner always preserves the original `_id` and `_creationTime`, so the
1861
+ * returned document neither needs to nor should change row identity.
1862
+ *
1863
+ * The second parameter carries a shard-scoped reader. Without it a transform
1864
+ * could only rewrite the row it was handed enough for a backfill whose new
1865
+ * value is a pure function of the old row (`displayName = name ?? "Anonymous"`),
1866
+ * but not for the shape people actually write: read the parent, copy a field
1867
+ * down onto its children.
1868
+ *
1869
+ * May return a promise, since a cross-table read is asynchronous.
1870
+ *
1871
+ * **A shard key cannot be backfilled this way, even with a reader.** A row whose
1872
+ * shard-key field is unset does not belong to any shard, so a shard-scoped query
1873
+ * will not enumerate it; and writing the key would have to MOVE the row to a
1874
+ * different Durable Object, which a per-shard runner cannot do. Re-keying is an
1875
+ * export → transform → import, not a migration.
1789
1876
  */
1877
+ type MigrationTransform = (document: MigrationDocument, ctx: MigrationCtx) => MigrationDocument | Promise<MigrationDocument | undefined | void> | undefined | void;
1878
+ interface MigrationDefinition {
1879
+ /** Rows fetched and rewritten per batch. Defaults to the runner's batch size when omitted. */
1880
+ readonly batchSize?: number;
1881
+ /** Optional reverse transform, applied by `migrate down`. */
1882
+ readonly down?: MigrationTransform;
1883
+ /** Stable, unique identifier — the key per-shard run-state is tracked under. */
1884
+ readonly id: string;
1885
+ /** Table whose documents this migration iterates. */
1886
+ readonly table: string;
1887
+ /** Forward transform, applied to every row by `migrate up`. */
1888
+ readonly up: MigrationTransform;
1889
+ }
1890
+ /** A {@link MigrationDefinition} plus the codegen discovery marker. */
1891
+ interface RegisteredMigration extends MigrationDefinition {
1892
+ readonly __lunoraMigration: true;
1893
+ }
1894
+ /** Declare an online data migration. See the module docs for runtime semantics. */
1895
+ declare const defineMigration: (definition: MigrationDefinition) => RegisteredMigration;
1790
1896
  /**
1791
- * Schema with an in-place `.extend(plugin.extension)` method. Used so apps
1792
- * can compose plugin schemas: `defineSchema({...}).extend(authPlugin.extension)`.
1793
- *
1794
- * `extend` is non-mutating — returns a fresh `ExtendableSchema` containing
1795
- * the merged tables. Extension tables are auto-namespaced by the extension
1796
- * `key` (`buckets` → `ratelimit_buckets`), so the merged type carries the
1797
- * prefixed names via {@link PrefixedTables}. Chains:
1798
- * `defineSchema(...).extend(a).extend(b)` is the typed equivalent of merging
1799
- * `a`'s prefixed tables then `b`'s.
1897
+ * A mutator declaration. `server` is authoritative; `client` is the optimistic
1898
+ * twin (optional omit it to let the optimistic write fall through to the
1899
+ * server round-trip with no local preview). Both receive the same validated
1900
+ * `args`.
1800
1901
  */
1801
- type ExtendableSchema<T extends Record<string, TableDefinition>> = {
1802
- extend: <X extends Record<string, TableDefinition>, Key extends string>(extension: SchemaExtension<X> & {
1803
- readonly key: Key;
1804
- }) => ExtendableSchema<PrefixedTables<X, Key> & T>;
1902
+ interface MutatorDefinition<Args extends ValidatorMap = ValidatorMap, ServerContext = MutationCtx, ClientTx = unknown, R = unknown> {
1805
1903
  /**
1806
- * Pin every Durable Object the app reaches shards, fan-out, subscriptions,
1807
- * the scheduler, and `ctx.containers` to a Cloudflare data-residency
1808
- * jurisdiction (`"eu"`, `"us"`, `"fedramp"`). Codegen reads this off the
1809
- * schema and emits it into the generated worker's `createWorker({ jurisdiction })`
1810
- * (and `ctx.scheduler` / `ctx.containers`). Non-mutating: returns a fresh
1811
- * `ExtendableSchema`, so it composes with `.rls(...)` / `.extend(...)` in any order.
1904
+ * Validator for the mutator's arguments. Validated on the DO before `server`
1905
+ * runs and (when present) on the client before `client` runs, so both impls
1906
+ * see the same parsed shape. Omit for a parameterless mutator.
1907
+ */
1908
+ readonly args?: Args;
1909
+ /**
1910
+ * Optimistic client implementation. Runs in a TanStack DB transaction
1911
+ * against the local collections; its writes are applied immediately and
1912
+ * automatically rolled back / rebased as the authoritative result syncs
1913
+ * back. Pure and side-effect-free beyond the local store. Omit to skip the
1914
+ * local preview.
1915
+ */
1916
+ readonly client?: (tx: ClientTx, args: InferValidatorMap<Args>) => Promise<void> | void;
1917
+ /**
1918
+ * Owner-scope the write: names the column carrying the row owner (e.g.
1919
+ * `owner: "userId"`). Before `server` runs, the mutator requires a verified
1920
+ * identity, rejects a client-supplied owner that disagrees with it, and sets
1921
+ * the column to the verified value — so the impl reads `args[owner]` without
1922
+ * trusting the client and never repeats the check by hand.
1812
1923
  *
1813
- * ⚠️ **Set this once, before your first deploy — changing or removing it
1814
- * strands data.** A Durable Object name maps to a *different* ID in each
1815
- * jurisdiction, so toggling this on an existing app makes every shard, scheduler
1816
- * job, and session DO resolve to a NEW, empty DO; the previous data stays in the
1817
- * old jurisdiction's DOs and is no longer reachable. There is no in-place
1818
- * migration you would have to export from the old jurisdiction and import
1819
- * into the new one.
1924
+ * This replaces the "every mutator opens with `assertOwner(ctx, args.userId)`"
1925
+ * pattern, and is the write-side counterpart to an `owner`-scoped
1926
+ * {@link import("./shapes").ShapeDefinition}. Unlike a shape it takes the column
1927
+ * NAME rather than `true`: a shape is bound to one `table`, so the table's
1928
+ * `.ownedBy(field)` resolves unambiguously, whereas one mutator may write
1929
+ * several tables and has no single owning table to read it from.
1820
1930
  *
1821
- * Note: this pins **DO-backed** state only. D1-backed state `.global()`
1822
- * tables and `@lunora/auth` sessions alike — is governed by D1's own location
1823
- * settings, not this option.
1824
- * @see https://developers.cloudflare.com/durable-objects/reference/data-location/
1931
+ * Declare the column `v.optional(...)` to leave it off the wire entirely; it is
1932
+ * injected either way.
1825
1933
  */
1826
- jurisdiction: (jurisdiction: DurableObjectJurisdiction) => ExtendableSchema<T>;
1934
+ readonly owner?: string;
1827
1935
  /**
1828
- * Turn on secure-by-default RLS for the whole schema. Every table is then
1829
- * protected the DO/D1 write path denies raw, non-RLS `ctx.db` access, so a
1830
- * procedure that forgets `.use(rls(...))` fails closed. Opt a table out with
1831
- * `.public()`. Non-mutating: returns a fresh `ExtendableSchema` carrying the
1832
- * mode, so `.rls("required")` composes with `.extend(...)` either order.
1936
+ * Authoritative server implementation. Runs inside the shard DO with a full
1937
+ * {@link MutationContext} (`ctx.db` writer); its writes append to `__cdc_log`
1938
+ * and poke back to subscribers. This is the source of truth — the client
1939
+ * impl is only a prediction of it.
1833
1940
  */
1834
- rls: (mode: "required") => ExtendableSchema<T>;
1835
- } & Schema<T>;
1836
- declare const defineSchema: <T extends Record<string, TableDefinition>>(tables: T, vectorIndexes?: Record<string, VectorIndexDefinition>, aggregateIndexes?: Record<string, AggregateIndexDefinition>, rankIndexes?: Record<string, RankIndexDefinition>) => ExtendableSchema<T>;
1941
+ readonly server: (context: ServerContext, args: InferValidatorMap<Args>) => Promise<R> | R;
1942
+ }
1943
+ /**
1944
+ * A {@link MutatorDefinition} plus the codegen discovery marker and a
1945
+ * dispatch-shaped `handler` (validates `args`, then runs `server`) so the DO
1946
+ * invokes a mutator exactly like a registered procedure.
1947
+ */
1948
+ interface RegisteredMutator<Args extends ValidatorMap = ValidatorMap, ServerContext = MutationCtx, ClientTx = unknown, R = unknown> extends MutatorDefinition<Args, ServerContext, ClientTx, R> {
1949
+ readonly __lunoraMutator: true;
1950
+ /** Validate `rawArgs`, then run the authoritative `server` impl. Used by the DO push path. */
1951
+ readonly handler: (context: ServerContext, rawArgs: Record<string, unknown>) => Promise<R>;
1952
+ /**
1953
+ * Marks the dispatch kind so codegen can register the mutator in the same
1954
+ * `LUNORA_FUNCTIONS` table queries/mutations use — the DO's `handleRpc`
1955
+ * reads `kind === "mutation"` to wrap the authoritative `server` impl in the
1956
+ * shard's BEGIN/COMMIT span (all-or-nothing writes), exactly like an
1957
+ * ordinary `mutation`.
1958
+ */
1959
+ readonly kind: "mutation";
1960
+ }
1961
+ /** Declare a custom mutator. See the module docs for runtime semantics. */
1962
+ declare const defineMutator: <Args extends ValidatorMap = ValidatorMap, ServerContext = MutationCtx, ClientTx = unknown, R = unknown>(definition: MutatorDefinition<Args, ServerContext, ClientTx, R>) => RegisteredMutator<Args, ServerContext, ClientTx, R>;
1837
1963
  /** Default time-to-live for a presence row: a heartbeat keeps a member "present" for this long. */
1838
1964
  declare const DEFAULT_TTL_MS = 3e4;
1839
1965
  declare const PRESENCE_BARE_TABLE = "present";
@@ -2221,6 +2347,13 @@ interface DatabaseWriterLike {
2221
2347
  * Required for the same reason as `aggregate`.
2222
2348
  */
2223
2349
  rankPage: (tableName: string, indexName: string, options?: RankPageArgs) => Promise<QueryPage>;
2350
+ /**
2351
+ * Cross-shard companion to `rankPage`: same ranked slice, but each row
2352
+ * keeps its rank-key tuple for the query coordinator's k-way merge. Same
2353
+ * count-of-partition RLS hazard as `rankPage` — failed closed under a read
2354
+ * policy for the identical reason (see `rankPage` above).
2355
+ */
2356
+ rankPageRows?: (tableName: string, indexName: string, options?: RankPageArgs) => Promise<ShardRankPageResultLike>;
2224
2357
  replace: (id: string, document: Record<string, unknown>, expectedTable?: string) => Promise<void>;
2225
2358
  restore?: (id: string, expectedTable?: string) => Promise<void>;
2226
2359
  /**
@@ -2502,4 +2635,4 @@ interface StorageContextIn {
2502
2635
  }
2503
2636
  declare const storageRules: <Context extends StorageContextIn = StorageContextIn>(rules: ReadonlyArray<StorageRule<Context>>, options?: StorageRulesOptions) => Middleware<Context, Context>;
2504
2637
  declare const VERSION = "0.0.0";
2505
- 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 DefineComponentOptions, type DefineIdentityOptions, type DefineListArgsConfig, type DefinePluginOptions, type DefinePolicyInput, type DefinePresenceOptions, type DefineStorageRuleInput, type DurableObjectJurisdiction, 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 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 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 RegisteredAction, type RegisteredFunction, type RegisteredLifecycleHook, type RegisteredMigration, type RegisteredMutation, type RegisteredMutator, type RegisteredQuery, type RegisteredShape, type RegisteredStream, type RelationBuilder, type RelationDefinition, type RlsOptions, type RlsReadRegistry, type Role, type Schema, type SchemaExtension, type ShapeDefinition, type ShapeReadWhereRequest, 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, 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, httpAction, httpRoute, httpRouter, initLunora, installPlugins, isDeny, isSafeHeaderValue, mask, mergeSchemaExtension, onConnect, onDisconnect, presenceExtension, protectPublic, redactSecrets, rls, serveStorageObject, storageRules, toWhereInput };
2638
+ 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 DefineComponentOptions, type DefineIdentityOptions, type DefineListArgsConfig, type DefinePluginOptions, type DefinePolicyInput, type DefinePresenceOptions, type DefineStorageRuleInput, type DurableObjectJurisdiction, 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 RegisteredAction, type RegisteredFunction, type RegisteredLifecycleHook, type RegisteredMigration, type RegisteredMutation, type RegisteredMutator, type RegisteredQuery, type RegisteredShape, type RegisteredStream, type RelationBuilder, type RelationDefinition, type RlsOptions, type RlsReadRegistry, type Role, type Schema, type SchemaExtension, type ShapeDefinition, type ShapeReadWhereRequest, 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, httpAction, httpRoute, httpRouter, indexFieldsFromSchema, initLunora, installPlugins, isDeny, isSafeHeaderValue, mask, mergeSchemaExtension, onConnect, onDisconnect, presenceExtension, protectPublic, redactSecrets, rls, serveStorageObject, storageRules, toWhereInput };