Rindle

API index and search · Build metadata

Source snapshot

packages/api-server/src/rooms.ts

Source revision 05d0bf2c2e56 · build details
Source revision: 05d0bf2c2e56.
TypeScript input SHA-256: aabe6cfcc4172b870d5e272142958e9ea8d8784c2aa23133156e5a7ee633318e
Generated 2026-09-04T23:58:25.590Z with TypeScript 6.0.3. Public TypeScript checks and declaration emit passed. Package runtime tests are separate.
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