API index and search · Build metadata
Source snapshot
packages/api-server/src/rooms.ts
1// Room profiles — the DECLARATION layer of Rindle Realtime query enablement2// (designs-deprecated/RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §2): the api-server owns a small set of3// NAMED room profiles (key derivation + canonical unwindowed footprint + read-only context4// tables), and client queries opt into one via their `defineQuery` realtime label. This module is5// the pure-TS compile/validate half — "loud at registration" (§2.3): every rule checkable at6// `createRindleApiServer` construction throws (or warns) THERE, not at a 3am room boot.7//8// The serve DECISION (lease wire changes, room tokens) consumes the {@link CompiledRoomProfile}9// records this module produces — room-serving trusts the declaration (302 §5), no coverage proof. Only10// {@link RoomProfile}, {@link queryResultToAst} and {@link queryRealtimeLabel} are re-exported11// from the package index — the compiled shapes and the wire-key helpers stay internal.1213import type { Ast, Condition, CorrelatedSubquery, RealtimeQueryLabel, Schema } from "@rindle/client";1415// Type-only (erased at runtime) — no import cycle with ./index.ts.16import type { ApiContext, ApiQuery, ApiQueryResult, MaybePromise } from "./index.ts";1718// ------------------------------------------------------------------------------- the declaration1920/**21 * One NAMED room profile (§2.1). Rooms are document-scoped, not query-scoped: a profile is what22 * stands up and feeds a room — the flat `resolveFootprint` promoted to a named, reusable unit.23 * Labeled queries opt into a profile; the wire room key for a named profile is minted SERVER-side24 * as `"<profile>/<key>"`, and `/room-boot` splits it back to resolve the footprint.25 */26export interface RoomProfile<User = unknown> {27 /** Derive the profile-local room key from the (label-mapped) query args. Runs server-side under28 * authoritative inputs — the client learns the key from the lease, it never computes one. */29 key: (args: any) => string;30 /** Build the room's canonical footprint — the replica boundary the room loads and follows —31 * from the profile-local doc key (this profile's `key` output; what `/room-boot` receives32 * after the prefix split). MUST be unwindowed (§2.3): no `limit`/`start`/`one` anywhere in the33 * AST — enforced loudly at construction when statically resolvable, and again at every boot.34 * Should be a TOTAL builder over any key (a shape constructor, not an authorizer). */35 footprint: (docKey: string, ctx: ApiContext<User>) => MaybePromise<ApiQueryResult>;36 /** Loaded-but-never-room-written tables (§2.2 — the "followed" set: real external writers, the37 * daemon stays write-authoritative for them). Must be a subset of the footprint's tables; the38 * room's writable scope is footprint minus context. Defaults to `[]`. */39 context?: readonly string[];40}4142export function queryResultToAst(result: ApiQueryResult): Ast {43 if (result && typeof result === "object" && "ast" in result && typeof result.ast === "function") {44 return result.ast();45 }46 return result as Ast;47}4849// ------------------------------------------------------------------------------- wire room keys5051/** The wire room-key delimiter: a named profile's doc is `"<profile>/<key>"`; a doc with no known52 * profile prefix belongs to the legacy anonymous profile (`resolveFootprint`, bare-key form). */53export const ROOM_DOC_SEPARATOR = "/";5455/** Mint the wire room doc for a named profile (the lease path — G-iv-b — mints these). */56export function mintRoomDoc(profile: string, key: string): string {57 return `${profile}${ROOM_DOC_SEPARATOR}${key}`;58}5960/** Split a wire room doc at the FIRST separator. `undefined` for a bare doc (no separator, or an61 * empty would-be profile name). The CALLER decides whether the prefix actually names a profile —62 * a legacy doc may itself contain `/`, and then falls through to the legacy resolver whole. */63export function splitRoomDoc(doc: string): { profile: string; key: string } | undefined {64 const i = doc.indexOf(ROOM_DOC_SEPARATOR);65 if (i <= 0) return undefined;66 return { profile: doc.slice(0, i), key: doc.slice(i + 1) };67}6869// --------------------------------------------------------------------- §2.3: unwindowed footprints7071interface WindowHit {72 kind: string;73 path: string;74}7576/**77 * §2.3 "profile footprints are unwindowed", enforced: throw loudly (with the offending AST path)78 * if `limit` / `start` (cursor paging — the skip lowering) / `one` appears ANYWHERE in the79 * footprint tree — the root, a related subquery, or an EXISTS child. A window computed over an80 * unwindowed superset equals the window over the full store, which is what makes room-serving of81 * windowed *queries* sound; a windowed *footprint* would silently break that, so it is rejected82 * by construction. Windows belong on the labeled queries a room serves, never on the footprint.83 */84export function assertUnwindowedFootprint(ast: Ast, profile: string): void {85 const hit = findWindow(ast, "footprint");86 if (hit === undefined) return;87 throw new Error(88 `room profile "${profile}": the footprint must be UNWINDOWED — found ${hit.kind} at ${hit.path}. ` +89 `Windows (limit/start/one) belong on the labeled queries a room serves, never on the room's ` +90 `footprint (RINDLE-REALTIME-QUERY-ENABLEMENT §2.3).`,91 );92}9394function findWindow(ast: Ast, path: string): WindowHit | undefined {95 if (ast.limit !== undefined) return { kind: `limit(${ast.limit})`, path: `${path}.limit` };96 if (ast.start !== undefined) return { kind: "start (cursor paging)", path: `${path}.start` };97 if (ast.one === true) return { kind: "one()", path: `${path}.one` };98 for (const [i, rel] of (ast.related ?? []).entries()) {99 const hit = findWindow(rel.subquery, `${path}.related[${rel.subquery.alias ?? i}]`);100 if (hit !== undefined) return hit;101 }102 return (103 findWindowInCondition(ast.where, `${path}.where`) ?? findWindowInCondition(ast.having, `${path}.having`)104 );105}106107function findWindowInCondition(cond: Condition | undefined, path: string): WindowHit | undefined {108 if (cond === undefined) return undefined;109 switch (cond.type) {110 case "simple":111 return undefined;112 case "and":113 case "or": {114 for (const [i, c] of cond.conditions.entries()) {115 const hit = findWindowInCondition(c, `${path}.${cond.type}[${i}]`);116 if (hit !== undefined) return hit;117 }118 return undefined;119 }120 case "correlatedSubquery": {121 const label = cond.op === "EXISTS" ? "exists" : "notExists";122 const sub = cond.related.subquery;123 return findWindow(sub, `${path}.${label}[${sub.alias ?? sub.table}]`);124 }125 }126}127128// -------------------------------------------------------- footprint facts (tables + join keys)129130export interface FootprintScan {131 tables: Set<string>;132 joinKeys: Map<string, Set<string>>;133}134135/** Walk the footprint: every base table it draws rows from, and — per table — the correlation136 * (join-key) columns every related/EXISTS edge binds on it. These are §3.2's routing metadata137 * and slice H's "room mutators never write join keys" enforcement input; G-iv-b ships them to138 * the room engine as part of the RoomTableSpec compilation ({@link compileRoomTableSpecs}). */139export function scanFootprint(ast: Ast): FootprintScan {140 const scan: FootprintScan = { tables: new Set(), joinKeys: new Map() };141 scanAst(ast, scan);142 return scan;143}144145function addJoinKeys(scan: FootprintScan, table: string, cols: readonly string[]): void {146 let set = scan.joinKeys.get(table);147 if (set === undefined) {148 set = new Set();149 scan.joinKeys.set(table, set);150 }151 for (const c of cols) set.add(c);152}153154function scanAst(ast: Ast, scan: FootprintScan): void {155 scan.tables.add(ast.table);156 for (const rel of ast.related ?? []) scanEdge(ast.table, rel, scan);157 scanCondition(ast.table, ast.where, scan);158 scanCondition(ast.table, ast.having, scan);159}160161function scanEdge(parentTable: string, edge: CorrelatedSubquery, scan: FootprintScan): void {162 addJoinKeys(scan, parentTable, edge.correlation.parentField);163 addJoinKeys(scan, edge.subquery.table, edge.correlation.childField);164 scanAst(edge.subquery, scan);165}166167function scanCondition(table: string, cond: Condition | undefined, scan: FootprintScan): void {168 if (cond === undefined) return;169 switch (cond.type) {170 case "simple":171 return;172 case "and":173 case "or":174 for (const c of cond.conditions) scanCondition(table, c, scan);175 return;176 case "correlatedSubquery":177 scanEdge(table, cond.related, scan);178 return;179 }180}181182// ------------------------------------------------------- RoomTableSpec (the lease wire, G-iv-b)183184/**185 * One footprint table's spec on the lease wire (`QueryLeaseResponse.realtime.tables`): the §2.2186 * owned/followed split plus §3.2's per-table routing metadata, compiled from the RESOLVED187 * footprint AST at lease time (so key-dependent and non-static profiles compile too).188 *189 * `writable`:190 * - `none` — a context table (§2.2 "followed"): loaded by the room, never room-written; the191 * daemon stays write-authoritative.192 * - `predicate` — a writable table. `where` is the ROW-LOCAL part of the footprint's predicate193 * for this table (simple column-vs-literal conditions composed with and/or); an ABSENT `where`194 * means no row-local constraint — every row the room holds for this table is in the writable195 * scope. `joinKeyCols` are the correlation columns the footprint binds on this table (slice196 * H's "room mutators never write join keys" enforcement input).197 *198 * Dropping the non-row-local parts (correlated/EXISTS conditions, column-vs-column comparisons)199 * only ever WIDENS the predicate, which is sound here: the room only relays rows the footprint200 * materialized, so the held-row set — not this predicate — is the real outer bound; `where` is a201 * row-local refinement of it, and a wider refinement can never mark a held footprint row202 * non-writable that the full predicate would have allowed. (An OR with any non-row-local203 * disjunct is dropped WHOLE — keeping only some disjuncts would narrow, which is not sound.)204 *205 * `footprintWhere` (H-iii — the lease-wire flip): the EXACT footprint-membership predicate,206 * identical to {@link RoomScopeSpec.footprintWhere} (ONE compiler feeds both wires — the lease's207 * table specs and the boot wire's scopes are now the SAME objects). The client's §3 router208 * consumes it for the pk-membership read proof; it is advisory routing metadata, never a209 * credential (the room gate re-proves engine-side, H-iv).210 */211export type RoomTableSpec = {212 table: string;213 footprintWhere?: Condition;214 writable:215 | { kind: "none" }216 | { kind: "predicate"; where?: Condition; joinKeyCols: string[] };217};218219/**220 * One footprint table's FULL scope spec (slice H-iv-b): byte-compatible with221 * `rindle-room-core`'s `TableScopeSpec` (scope.rs), the wasm room's `enableWritesV2` input. It222 * rides the boot wire (`RoomBootResponse.scopes`); since slice H-iii the LEASE wire's223 * {@link RoomTableSpec} carries `footprintWhere` too (the client-side routing proof), so the two224 * shapes are now structurally IDENTICAL — kept as two names because they are two wires with two225 * consumers (the room gate vs the client router).226 *227 * `footprintWhere` is computed for EVERY footprint table — context (`kind: "none"`) ones228 * included, because the gate proves ABSENT READS on any readable table with it (§3.1: an absent229 * read is covered only when the footprint predicate is decidable from the pk columns alone and230 * evaluates true on the read key). ABSENT (no exact membership predicate exists) ⇒ no absent231 * read on that table is provable — the room gate fails closed to a deopt.232 *233 * **`footprintWhere` is EXACT-only — the opposite discipline from the writable `where`.** The234 * writable side ships the WIDENING extraction ({@link tablePredicate}) and that is sound (the235 * held-row set is the real outer bound). The absent-read proof points the other way: it uses236 * `footprintWhere` to conclude "a row with this pk would have been IN the footprint, so the237 * room's absence is truth's absence" — a WIDENED predicate over-claims exactly there (a root238 * `where` mixing pk-column conjuncts with a dropped EXISTS, or a correlated child whose own239 * `where` reads only its pk, would prove an absence the dropped part can contradict). So240 * `footprintWhere` is emitted ONLY when the membership predicate is exact241 * ({@link exactMembershipPredicate}): every node reading the table is the footprint ROOT (a242 * child node's implicit correlation to its parent is itself a dropped, non-row-local constraint)243 * and the root's `where` extraction dropped nothing. An unconstrained exact root (the whole244 * table rides the footprint) emits the vacuous-true `{type:"and",conditions:[]}` — the gate's245 * combinator pins empty-AND = true — so whole-table footprints keep provable absent reads.246 * Child/correlated tables get NO `footprintWhere` and their absent reads deopt (fail closed);247 * propagating a parent constraint through a pk-covering correlation is a future refinement.248 */249export type RoomScopeSpec = {250 table: string;251 footprintWhere?: Condition;252 writable: RoomTableSpec["writable"];253};254255/** Compile the per-table {@link RoomScopeSpec}s from a RESOLVED footprint AST + the profile's256 * context set — the ONE compiler both wires derive from (the boot wire ships it whole; the257 * lease wire serializes the {@link RoomTableSpec} projection via {@link compileRoomTableSpecs}).258 * Deterministic output order (tables sorted by name). */259export function compileRoomScopeSpecs(footprint: Ast, context: ReadonlySet<string>): RoomScopeSpec[] {260 const scan = scanFootprint(footprint);261 const specs: RoomScopeSpec[] = [];262 for (const table of [...scan.tables].sort()) {263 const where = tablePredicate(footprint, table);264 const spec: RoomScopeSpec = { table, writable: { kind: "none" } };265 // footprintWhere is EXACT-only (see the RoomScopeSpec doc): the widened `where` feeds the266 // writable side below, never the absent-read proof.267 const membership = exactMembershipPredicate(footprint, table);268 if (membership !== undefined) spec.footprintWhere = membership;269 if (!context.has(table)) {270 const joinKeyCols = [...(scan.joinKeys.get(table) ?? [])].sort();271 spec.writable =272 where === undefined ? { kind: "predicate", joinKeyCols } : { kind: "predicate", where, joinKeyCols };273 }274 specs.push(spec);275 }276 return specs;277}278279/** The vacuous-true wire condition (`empty AND` — the room gate's combinator pins it true): what280 * an EXACT but unconstrained footprint root emits as its membership predicate. */281const CONDITION_TRUE: Condition = { type: "and", conditions: [] };282283/** The footprint's EXACT membership predicate for `table`, or `undefined` when none exists (see284 * the {@link RoomScopeSpec} doc for why absent-read proofs must never see a widened one):285 * defined iff every node reading the table is the footprint ROOT node (a child node's implicit286 * parent correlation is a dropped constraint, so any child/EXISTS occurrence forfeits exactness)287 * and the root's `where` extraction is LOSSLESS. An exact unconstrained root (whole table in the288 * footprint) yields {@link CONDITION_TRUE}. */289function exactMembershipPredicate(footprint: Ast, table: string): Condition | undefined {290 const nodes: CollectedNode[] = [];291 collectTableNodes(footprint, table, nodes);292 if (nodes.length === 0 || !nodes.every((n) => n.isRoot)) return undefined;293 // isRoot can only be true for the top-level node, so an all-root list has exactly one entry.294 const { cond, exact } = rowLocalExtraction(nodes[0].node.where);295 if (!exact) return undefined;296 return cond ?? CONDITION_TRUE;297}298299/** Compile the per-table {@link RoomTableSpec}s from a RESOLVED footprint AST + the profile's300 * context set — since H-iii the lease wire carries `footprintWhere` exactly where the boot wire301 * does, so this IS {@link compileRoomScopeSpecs} (one compiler, two wires; the identity is302 * pinned by serve-proof.test.ts). Kept as a named entry point for the lease call site. */303export function compileRoomTableSpecs(footprint: Ast, context: ReadonlySet<string>): RoomTableSpec[] {304 return compileRoomScopeSpecs(footprint, context);305}306307/** The footprint's row-local predicate for `table`: per NODE reading the table, the row-local308 * extraction of its `where`; multiple nodes OR together (a row held under EITHER node's309 * predicate is in the footprint's row set). Any unconstrained node ⇒ `undefined` (no row-local310 * constraint at all — the widest sound answer). */311function tablePredicate(ast: Ast, table: string): Condition | undefined {312 const nodes: CollectedNode[] = [];313 collectTableNodes(ast, table, nodes);314 const parts: Condition[] = [];315 for (const { node } of nodes) {316 const local = rowLocalCondition(node.where);317 if (local === undefined) return undefined; // one unconstrained node ⇒ the table is unconstrained318 parts.push(local);319 }320 if (parts.length === 0) return undefined;321 return parts.length === 1 ? parts[0] : { type: "or", conditions: parts };322}323324/** One occurrence of a table in the footprint tree. `isRoot` is true only for the TOP-LEVEL node325 * — a `related`/EXISTS child carries an implicit correlation to its parent, which is a dropped,326 * non-row-local membership constraint ({@link exactMembershipPredicate} forfeits on it). */327interface CollectedNode {328 node: Ast;329 isRoot: boolean;330}331332function collectTableNodes(ast: Ast, table: string, out: CollectedNode[], isRoot = true): void {333 if (ast.table === table) out.push({ node: ast, isRoot });334 for (const rel of ast.related ?? []) collectTableNodes(rel.subquery, table, out, false);335 collectTableNodesInCondition(ast.where, table, out);336 collectTableNodesInCondition(ast.having, table, out);337}338339function collectTableNodesInCondition(cond: Condition | undefined, table: string, out: CollectedNode[]): void {340 if (cond === undefined) return;341 switch (cond.type) {342 case "simple":343 return;344 case "and":345 case "or":346 for (const c of cond.conditions) collectTableNodesInCondition(c, table, out);347 return;348 case "correlatedSubquery":349 collectTableNodes(cond.related.subquery, table, out, false);350 return;351 }352}353354/** Extract the row-local part of one node's condition tree, WIDENING (see {@link RoomTableSpec}):355 * `undefined` = "no constraint kept". The writable side's entry point — for absent-read proofs356 * use {@link exactMembershipPredicate}, never this. */357function rowLocalCondition(cond: Condition | undefined): Condition | undefined {358 return rowLocalExtraction(cond).cond;359}360361/** The row-local extraction WITH exactness: `exact` means `cond` is equivalent to the input —362 * nothing was dropped (an `undefined` input is exactly the vacuous-true constraint). Kept:363 * simple column-vs-literal comparisons; `and` keeps its extractable conjuncts (dropping the364 * rest widens ⇒ inexact); `or` survives only when EVERY disjunct is extractable (dropping a365 * disjunct would narrow — the whole OR drops instead, inexact); `correlatedSubquery` always366 * drops (inexact). */367function rowLocalExtraction(cond: Condition | undefined): { cond: Condition | undefined; exact: boolean } {368 if (cond === undefined) return { cond: undefined, exact: true };369 switch (cond.type) {370 case "simple": {371 const columnVsLiteral =372 (cond.left.type === "column" && cond.right.type === "literal") ||373 (cond.left.type === "literal" && cond.right.type === "column");374 return columnVsLiteral ? { cond, exact: true } : { cond: undefined, exact: false };375 }376 case "and": {377 const parts = cond.conditions.map(rowLocalExtraction);378 const kept = parts.map((p) => p.cond).filter((c): c is Condition => c !== undefined);379 const exact = parts.every((p) => p.exact);380 if (kept.length === 0) return { cond: undefined, exact };381 return { cond: kept.length === 1 ? kept[0] : { type: "and", conditions: kept }, exact };382 }383 case "or": {384 const parts = cond.conditions.map(rowLocalExtraction);385 // DROPPING a disjunct would narrow (unsound even for the widening direction) — the whole386 // OR drops. But a disjunct that merely WIDENED (a partially-extracted AND) stays: widening387 // every disjunct widens the OR, which is fine for the writable side and marked inexact.388 if (parts.some((p) => p.cond === undefined)) return { cond: undefined, exact: false };389 const kept = parts.map((p) => p.cond as Condition);390 return {391 cond: kept.length === 1 ? kept[0] : { type: "or", conditions: kept },392 exact: parts.every((p) => p.exact),393 };394 }395 case "correlatedSubquery":396 return { cond: undefined, exact: false }; // never row-local; the room relays footprint rows397 }398}399400// ------------------------------------------------------------------------- the compiled profile401402/** Statically-derived footprint facts (symbolic-key resolution at construction). Absent when the403 * footprint could not be statically resolved (async / ctx-dependent / throwing on the probe) —404 * then the unwindowed rule is enforced at each boot instead and the join keys are underivable405 * until boot. @internal — consumed by G-iv-b; not part of the package's public API. */406export interface CompiledFootprintFacts {407 /** The footprint AST resolved with {@link PROBE_DOC_KEY} — its SHAPE is representative, its408 * literals are not (they embed the probe key). */409 readonly ast: Ast;410 /** Every base table the footprint draws rows from. */411 readonly tables: ReadonlySet<string>;412 /** §2.2 writable scope = footprint tables minus context. */413 readonly writableTables: ReadonlySet<string>;414 /** table → the correlation (join-key) columns the footprint binds on it. */415 readonly joinKeys: ReadonlyMap<string, ReadonlySet<string>>;416}417418/** One compiled room profile — the internal record the G-iv-b serve decision + RoomTableSpec419 * compilation consume (name, key fn, footprint resolver, context set, per-table join keys).420 * @internal — deliberately NOT re-exported from the package index. */421export interface CompiledRoomProfile<User = unknown> {422 readonly name: string;423 readonly key: (args: any) => string;424 readonly footprint: (docKey: string, ctx: ApiContext<User>) => MaybePromise<ApiQueryResult>;425 /** §2.2 followed set (room-read-only; the daemon stays write-authoritative for these). */426 readonly context: ReadonlySet<string>;427 /** Present iff the footprint was statically resolvable at construction. */428 readonly static: CompiledFootprintFacts | undefined;429}430431/** The symbolic doc key construction-time validation resolves each footprint with. A profile432 * footprint should be a TOTAL builder over any key; one that throws on this key (or is async /433 * ctx-dependent) simply defers its unwindowed check to boot, with a loud warning. */434export const PROBE_DOC_KEY = "__rindle-room-profile-probe__";435436export interface CompileRoomProfilesInput<User> {437 rooms: Record<string, RoomProfile<User>> | undefined;438 /** The typed schema, when configured — enables the context-table existence check. */439 schema: Schema | undefined;440 /** Loud-diagnostics sink (defaults to `console.warn`); injectable for tests. */441 warn?: (message: string) => void;442}443444/**445 * Compile + validate the named room profiles — §2.3 "loud at registration". Rules enforced here:446 * the profile name is wire-key-safe (no `/`); (b) the footprint is UNWINDOWED (throw, with the447 * offending AST path); (c) context tables exist in the schema and are a subset of the footprint's448 * tables (throw); (d) per-table join-key columns are derived and RECORDED on the compiled profile449 * (a loud WARNING when the footprint isn't statically resolvable and they can't be).450 */451export function compileRoomProfiles<User>(452 input: CompileRoomProfilesInput<User>,453): Map<string, CompiledRoomProfile<User>> {454 const warn = input.warn ?? ((message: string) => console.warn(message));455 const out = new Map<string, CompiledRoomProfile<User>>();456 for (const [name, profile] of Object.entries(input.rooms ?? {})) {457 if (name.length === 0 || name.includes(ROOM_DOC_SEPARATOR)) {458 throw new Error(459 `realtime.rooms: invalid profile name ${JSON.stringify(name)} — profile names are non-empty and may ` +460 `not contain "${ROOM_DOC_SEPARATOR}" (it delimits the wire room key "<profile>${ROOM_DOC_SEPARATOR}<key>").`,461 );462 }463 if (typeof profile.key !== "function") {464 throw new Error(`realtime.rooms.${name}: \`key\` must be a function (args) => string.`);465 }466 if (typeof profile.footprint !== "function") {467 throw new Error(`realtime.rooms.${name}: \`footprint\` must be a function (docKey, ctx) => Query | Ast.`);468 }469 const context: ReadonlySet<string> = new Set(profile.context ?? []);470 if (input.schema !== undefined) {471 for (const t of context) {472 if (input.schema.tables[t] === undefined) {473 throw new Error(474 `realtime.rooms.${name}: context table "${t}" is not in the schema — \`context\` lists the ` +475 `footprint tables the room follows read-only (§2.2).`,476 );477 }478 }479 }480 // Statically resolve the footprint with a symbolic key. An async / ctx-dependent / throwing481 // footprint can't be checked here — warn loudly and defer the unwindowed rule to boot time.482 let ast: Ast | undefined;483 let unresolvable: string | undefined;484 try {485 const result = profile.footprint(PROBE_DOC_KEY, { user: undefined as User });486 if (isThenable(result)) {487 // The probe's promise is deliberately abandoned — swallow a late rejection.488 void Promise.resolve(result).then(undefined, () => undefined);489 unresolvable = "the footprint is async";490 } else {491 ast = queryResultToAst(result);492 }493 } catch (e) {494 unresolvable = `the footprint threw on the symbolic probe key: ${String((e as Error)?.message ?? e)}`;495 }496 let staticFacts: CompiledFootprintFacts | undefined;497 if (ast !== undefined) {498 assertUnwindowedFootprint(ast, name); // (b) — throws with the offending path499 const scan = scanFootprint(ast);500 for (const t of context) {501 if (!scan.tables.has(t)) {502 throw new Error(503 `realtime.rooms.${name}: context table "${t}" is not loaded by the footprint (footprint tables: ` +504 `${[...scan.tables].sort().map((x) => `"${x}"`).join(", ")}) — context must be a SUBSET of the ` +505 `footprint's tables: followed means loaded-but-room-read-only (§2.2).`,506 );507 }508 }509 staticFacts = {510 ast,511 tables: scan.tables,512 writableTables: new Set([...scan.tables].filter((t) => !context.has(t))),513 joinKeys: scan.joinKeys,514 };515 } else {516 warn(517 `realtime.rooms.${name}: the footprint could not be statically resolved at registration ` +518 `(${unresolvable}) — the unwindowed check (§2.3) will run at each room boot instead, and the ` +519 `per-table join-key columns (§3.2 routing metadata) could not be derived.`,520 );521 }522 out.set(name, { name, key: profile.key, footprint: profile.footprint, context, static: staticFacts });523 }524 return out;525}526527function isThenable(v: unknown): v is PromiseLike<unknown> {528 return v !== null && typeof v === "object" && typeof (v as PromiseLike<unknown>).then === "function";529}530531// ------------------------------------------------------------------ realtime-label carry-through532//533// `registerQueries` erases everything but a bare resolve function at its seam; the realtime label534// must survive it so the lease path (G-iv-b) can look up (profile, args mapping) by query name.535// It rides a symbol property on the registered wrapper; {@link queryRealtimeLabel} is the ONE536// accessor — G-iv-b reads through it, never through ad-hoc properties.537538const API_QUERY_REALTIME_LABEL: unique symbol = Symbol("rindle.apiQuery.realtimeLabel");539540/** @internal Stamp a registered query wrapper with its `defineQuery` realtime label. */541export function attachRealtimeLabel<F extends object>(wrapper: F, label: RealtimeQueryLabel): F {542 return Object.assign(wrapper, { [API_QUERY_REALTIME_LABEL]: label });543}544545/** The realtime label a registered query carries (stamped by `registerQueries` from its546 * `defineQuery` options), or `undefined` for an unlabeled query. The lease path uses this to547 * derive `(room profile, args mapping)` for a named query. */548export function queryRealtimeLabel(549 query: ApiQuery<any, any> | undefined,550): RealtimeQueryLabel | undefined {551 if (typeof query !== "function") return undefined;552 return (query as { [API_QUERY_REALTIME_LABEL]?: RealtimeQueryLabel })[API_QUERY_REALTIME_LABEL];553}554555/** Rule (a) of the §2.3 "loud at registration" checks: every labeled registered query must name556 * an existing room profile — a label pointing at nothing is config drift that would otherwise557 * surface as a query that silently never room-serves. */558export function assertLabeledProfilesExist<User>(559 queries: Record<string, ApiQuery<User, any>> | undefined,560 profiles: ReadonlyMap<string, CompiledRoomProfile<User>>,561): void {562 for (const [name, q] of Object.entries(queries ?? {})) {563 const label = queryRealtimeLabel(q);564 if (label === undefined) continue;565 if (!profiles.has(label.room)) {566 const configured = [...profiles.keys()];567 throw new Error(568 `query "${name}" is labeled realtime (room profile "${label.room}") but no such profile is ` +569 `configured — ` +570 (configured.length > 0571 ? `configured profiles: ${configured.map((p) => `"${p}"`).join(", ")}. `572 : `no realtime.rooms profiles are configured. `) +573 `Add realtime.rooms["${label.room}"] to createRindleApiServer or remove the label ` +574 `(RINDLE-REALTIME-QUERY-ENABLEMENT §2.1).`,575 );576 }577 }578}579