@rindle/api-server 0.4.3 → 0.5.0
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/README.md +31 -60
- package/dist/index.d.ts +326 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +857 -24
- package/dist/index.js.map +1 -1
- package/dist/rooms.d.ts +194 -0
- package/dist/rooms.d.ts.map +1 -0
- package/dist/rooms.js +393 -0
- package/dist/rooms.js.map +1 -0
- package/package.json +6 -5
- package/src/index.ts +1216 -25
- package/src/rooms.ts +578 -0
package/src/rooms.ts
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
// Room profiles — the DECLARATION layer of Rindle Realtime query enablement
|
|
2
|
+
// (designs/RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §2): the api-server owns a small set of
|
|
3
|
+
// NAMED room profiles (key derivation + canonical unwindowed footprint + read-only context
|
|
4
|
+
// tables), and client queries opt into one via their `defineQuery` realtime label. This module is
|
|
5
|
+
// the pure-TS compile/validate half — "loud at registration" (§2.3): every rule checkable at
|
|
6
|
+
// `createRindleApiServer` construction throws (or warns) THERE, not at a 3am room boot.
|
|
7
|
+
//
|
|
8
|
+
// The serve DECISION (covering proof, lease wire changes, room tokens) is the NEXT slice
|
|
9
|
+
// (G-iv-b); it consumes the {@link CompiledRoomProfile} records this module produces. Only
|
|
10
|
+
// {@link RoomProfile}, {@link queryResultToAst} and {@link queryRealtimeLabel} are re-exported
|
|
11
|
+
// from the package index — the compiled shapes and the wire-key helpers stay internal.
|
|
12
|
+
|
|
13
|
+
import type { Ast, Condition, CorrelatedSubquery, RealtimeQueryLabel, Schema } from "@rindle/client";
|
|
14
|
+
|
|
15
|
+
// Type-only (erased at runtime) — no import cycle with ./index.ts.
|
|
16
|
+
import type { ApiContext, ApiQuery, ApiQueryResult, MaybePromise } from "./index.ts";
|
|
17
|
+
|
|
18
|
+
// ------------------------------------------------------------------------------- the declaration
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One NAMED room profile (§2.1). Rooms are document-scoped, not query-scoped: a profile is what
|
|
22
|
+
* 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-side
|
|
24
|
+
* as `"<profile>/<key>"`, and `/room-boot` splits it back to resolve the footprint.
|
|
25
|
+
*/
|
|
26
|
+
export interface RoomProfile<User = unknown> {
|
|
27
|
+
/** Derive the profile-local room key from the (label-mapped) query args. Runs server-side under
|
|
28
|
+
* 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` receives
|
|
32
|
+
* after the prefix split). MUST be unwindowed (§2.3): no `limit`/`start`/`one` anywhere in the
|
|
33
|
+
* 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, the
|
|
37
|
+
* daemon stays write-authoritative for them). Must be a subset of the footprint's tables; the
|
|
38
|
+
* room's writable scope is footprint minus context. Defaults to `[]`. */
|
|
39
|
+
context?: readonly string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export 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
|
+
}
|
|
48
|
+
|
|
49
|
+
// ------------------------------------------------------------------------------- wire room keys
|
|
50
|
+
|
|
51
|
+
/** The wire room-key delimiter: a named profile's doc is `"<profile>/<key>"`; a doc with no known
|
|
52
|
+
* profile prefix belongs to the legacy anonymous profile (`resolveFootprint`, bare-key form). */
|
|
53
|
+
export const ROOM_DOC_SEPARATOR = "/";
|
|
54
|
+
|
|
55
|
+
/** Mint the wire room doc for a named profile (the lease path — G-iv-b — mints these). */
|
|
56
|
+
export function mintRoomDoc(profile: string, key: string): string {
|
|
57
|
+
return `${profile}${ROOM_DOC_SEPARATOR}${key}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Split a wire room doc at the FIRST separator. `undefined` for a bare doc (no separator, or an
|
|
61
|
+
* 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. */
|
|
63
|
+
export 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
|
+
}
|
|
68
|
+
|
|
69
|
+
// --------------------------------------------------------------------- §2.3: unwindowed footprints
|
|
70
|
+
|
|
71
|
+
interface WindowHit {
|
|
72
|
+
kind: string;
|
|
73
|
+
path: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
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 the
|
|
79
|
+
* footprint tree — the root, a related subquery, or an EXISTS child. A window computed over an
|
|
80
|
+
* unwindowed superset equals the window over the full store, which is what makes room-serving of
|
|
81
|
+
* windowed *queries* sound; a windowed *footprint* would silently break that, so it is rejected
|
|
82
|
+
* by construction. Windows belong on the labeled queries a room serves, never on the footprint.
|
|
83
|
+
*/
|
|
84
|
+
export 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
|
+
}
|
|
93
|
+
|
|
94
|
+
function 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
|
+
}
|
|
106
|
+
|
|
107
|
+
function 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
|
+
}
|
|
127
|
+
|
|
128
|
+
// -------------------------------------------------------- footprint facts (tables + join keys)
|
|
129
|
+
|
|
130
|
+
export interface FootprintScan {
|
|
131
|
+
tables: Set<string>;
|
|
132
|
+
joinKeys: Map<string, Set<string>>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Walk the footprint: every base table it draws rows from, and — per table — the correlation
|
|
136
|
+
* (join-key) columns every related/EXISTS edge binds on it. These are §3.2's routing metadata
|
|
137
|
+
* and slice H's "room mutators never write join keys" enforcement input; G-iv-b ships them to
|
|
138
|
+
* the room engine as part of the RoomTableSpec compilation ({@link compileRoomTableSpecs}). */
|
|
139
|
+
export function scanFootprint(ast: Ast): FootprintScan {
|
|
140
|
+
const scan: FootprintScan = { tables: new Set(), joinKeys: new Map() };
|
|
141
|
+
scanAst(ast, scan);
|
|
142
|
+
return scan;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function 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
|
+
}
|
|
153
|
+
|
|
154
|
+
function 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
|
+
}
|
|
160
|
+
|
|
161
|
+
function 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
|
+
}
|
|
166
|
+
|
|
167
|
+
function 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
|
+
}
|
|
181
|
+
|
|
182
|
+
// ------------------------------------------------------- RoomTableSpec (the lease wire, G-iv-b)
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* One footprint table's spec on the lease wire (`QueryLeaseResponse.realtime.tables`): the §2.2
|
|
186
|
+
* owned/followed split plus §3.2's per-table routing metadata, compiled from the RESOLVED
|
|
187
|
+
* 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; the
|
|
191
|
+
* daemon stays write-authoritative.
|
|
192
|
+
* - `predicate` — a writable table. `where` is the ROW-LOCAL part of the footprint's predicate
|
|
193
|
+
* 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 writable
|
|
195
|
+
* scope. `joinKeyCols` are the correlation columns the footprint binds on this table (slice
|
|
196
|
+
* 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 footprint
|
|
200
|
+
* materialized, so the held-row set — not this predicate — is the real outer bound; `where` is a
|
|
201
|
+
* row-local refinement of it, and a wider refinement can never mark a held footprint row
|
|
202
|
+
* non-writable that the full predicate would have allowed. (An OR with any non-row-local
|
|
203
|
+
* 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's
|
|
207
|
+
* table specs and the boot wire's scopes are now the SAME objects). The client's §3 router
|
|
208
|
+
* consumes it for the pk-membership read proof; it is advisory routing metadata, never a
|
|
209
|
+
* credential (the room gate re-proves engine-side, H-iv).
|
|
210
|
+
*/
|
|
211
|
+
export type RoomTableSpec = {
|
|
212
|
+
table: string;
|
|
213
|
+
footprintWhere?: Condition;
|
|
214
|
+
writable:
|
|
215
|
+
| { kind: "none" }
|
|
216
|
+
| { kind: "predicate"; where?: Condition; joinKeyCols: string[] };
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* One footprint table's FULL scope spec (slice H-iv-b): byte-compatible with
|
|
221
|
+
* `rindle-room-core`'s `TableScopeSpec` (scope.rs), the wasm room's `enableWritesV2` input. It
|
|
222
|
+
* rides the boot wire (`RoomBootResponse.scopes`); since slice H-iii the LEASE wire's
|
|
223
|
+
* {@link RoomTableSpec} carries `footprintWhere` too (the client-side routing proof), so the two
|
|
224
|
+
* shapes are now structurally IDENTICAL — kept as two names because they are two wires with two
|
|
225
|
+
* consumers (the room gate vs the client router).
|
|
226
|
+
*
|
|
227
|
+
* `footprintWhere` is computed for EVERY footprint table — context (`kind: "none"`) ones
|
|
228
|
+
* included, because the gate proves ABSENT READS on any readable table with it (§3.1: an absent
|
|
229
|
+
* read is covered only when the footprint predicate is decidable from the pk columns alone and
|
|
230
|
+
* evaluates true on the read key). ABSENT (no exact membership predicate exists) ⇒ no absent
|
|
231
|
+
* 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`.** The
|
|
234
|
+
* writable side ships the WIDENING extraction ({@link tablePredicate}) and that is sound (the
|
|
235
|
+
* held-row set is the real outer bound). The absent-read proof points the other way: it uses
|
|
236
|
+
* `footprintWhere` to conclude "a row with this pk would have been IN the footprint, so the
|
|
237
|
+
* room's absence is truth's absence" — a WIDENED predicate over-claims exactly there (a root
|
|
238
|
+
* `where` mixing pk-column conjuncts with a dropped EXISTS, or a correlated child whose own
|
|
239
|
+
* `where` reads only its pk, would prove an absence the dropped part can contradict). So
|
|
240
|
+
* `footprintWhere` is emitted ONLY when the membership predicate is exact
|
|
241
|
+
* ({@link exactMembershipPredicate}): every node reading the table is the footprint ROOT (a
|
|
242
|
+
* 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 whole
|
|
244
|
+
* table rides the footprint) emits the vacuous-true `{type:"and",conditions:[]}` — the gate's
|
|
245
|
+
* 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
|
+
*/
|
|
249
|
+
export type RoomScopeSpec = {
|
|
250
|
+
table: string;
|
|
251
|
+
footprintWhere?: Condition;
|
|
252
|
+
writable: RoomTableSpec["writable"];
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/** Compile the per-table {@link RoomScopeSpec}s from a RESOLVED footprint AST + the profile's
|
|
256
|
+
* context set — the ONE compiler both wires derive from (the boot wire ships it whole; the
|
|
257
|
+
* lease wire serializes the {@link RoomTableSpec} projection via {@link compileRoomTableSpecs}).
|
|
258
|
+
* Deterministic output order (tables sorted by name). */
|
|
259
|
+
export 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 the
|
|
266
|
+
// 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
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The vacuous-true wire condition (`empty AND` — the room gate's combinator pins it true): what
|
|
280
|
+
* an EXACT but unconstrained footprint root emits as its membership predicate. */
|
|
281
|
+
const CONDITION_TRUE: Condition = { type: "and", conditions: [] };
|
|
282
|
+
|
|
283
|
+
/** The footprint's EXACT membership predicate for `table`, or `undefined` when none exists (see
|
|
284
|
+
* 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 implicit
|
|
286
|
+
* 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 the
|
|
288
|
+
* footprint) yields {@link CONDITION_TRUE}. */
|
|
289
|
+
function 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
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Compile the per-table {@link RoomTableSpec}s from a RESOLVED footprint AST + the profile's
|
|
300
|
+
* context set — since H-iii the lease wire carries `footprintWhere` exactly where the boot wire
|
|
301
|
+
* does, so this IS {@link compileRoomScopeSpecs} (one compiler, two wires; the identity is
|
|
302
|
+
* pinned by serve-proof.test.ts). Kept as a named entry point for the lease call site. */
|
|
303
|
+
export function compileRoomTableSpecs(footprint: Ast, context: ReadonlySet<string>): RoomTableSpec[] {
|
|
304
|
+
return compileRoomScopeSpecs(footprint, context);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The footprint's row-local predicate for `table`: per NODE reading the table, the row-local
|
|
308
|
+
* extraction of its `where`; multiple nodes OR together (a row held under EITHER node's
|
|
309
|
+
* predicate is in the footprint's row set). Any unconstrained node ⇒ `undefined` (no row-local
|
|
310
|
+
* constraint at all — the widest sound answer). */
|
|
311
|
+
function 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 unconstrained
|
|
318
|
+
parts.push(local);
|
|
319
|
+
}
|
|
320
|
+
if (parts.length === 0) return undefined;
|
|
321
|
+
return parts.length === 1 ? parts[0] : { type: "or", conditions: parts };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** One occurrence of a table in the footprint tree. `isRoot` is true only for the TOP-LEVEL node
|
|
325
|
+
* — 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). */
|
|
327
|
+
interface CollectedNode {
|
|
328
|
+
node: Ast;
|
|
329
|
+
isRoot: boolean;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function 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
|
+
}
|
|
338
|
+
|
|
339
|
+
function 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
|
+
}
|
|
353
|
+
|
|
354
|
+
/** 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 proofs
|
|
356
|
+
* use {@link exactMembershipPredicate}, never this. */
|
|
357
|
+
function rowLocalCondition(cond: Condition | undefined): Condition | undefined {
|
|
358
|
+
return rowLocalExtraction(cond).cond;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** 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 the
|
|
364
|
+
* rest widens ⇒ inexact); `or` survives only when EVERY disjunct is extractable (dropping a
|
|
365
|
+
* disjunct would narrow — the whole OR drops instead, inexact); `correlatedSubquery` always
|
|
366
|
+
* drops (inexact). */
|
|
367
|
+
function 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 whole
|
|
386
|
+
// OR drops. But a disjunct that merely WIDENED (a partially-extracted AND) stays: widening
|
|
387
|
+
// 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 rows
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ------------------------------------------------------------------------- the compiled profile
|
|
401
|
+
|
|
402
|
+
/** Statically-derived footprint facts (symbolic-key resolution at construction). Absent when the
|
|
403
|
+
* 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 underivable
|
|
405
|
+
* until boot. @internal — consumed by G-iv-b; not part of the package's public API. */
|
|
406
|
+
export interface CompiledFootprintFacts {
|
|
407
|
+
/** The footprint AST resolved with {@link PROBE_DOC_KEY} — its SHAPE is representative, its
|
|
408
|
+
* 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
|
+
}
|
|
417
|
+
|
|
418
|
+
/** One compiled room profile — the internal record the G-iv-b serve decision + RoomTableSpec
|
|
419
|
+
* compilation consume (name, key fn, footprint resolver, context set, per-table join keys).
|
|
420
|
+
* @internal — deliberately NOT re-exported from the package index. */
|
|
421
|
+
export 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
|
+
}
|
|
430
|
+
|
|
431
|
+
/** The symbolic doc key construction-time validation resolves each footprint with. A profile
|
|
432
|
+
* 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. */
|
|
434
|
+
export const PROBE_DOC_KEY = "__rindle-room-profile-probe__";
|
|
435
|
+
|
|
436
|
+
export 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
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
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 the
|
|
447
|
+
* offending AST path); (c) context tables exist in the schema and are a subset of the footprint's
|
|
448
|
+
* tables (throw); (d) per-table join-key columns are derived and RECORDED on the compiled profile
|
|
449
|
+
* (a loud WARNING when the footprint isn't statically resolvable and they can't be).
|
|
450
|
+
*/
|
|
451
|
+
export 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 / throwing
|
|
481
|
+
// 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 path
|
|
499
|
+
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
|
+
}
|
|
526
|
+
|
|
527
|
+
function isThenable(v: unknown): v is PromiseLike<unknown> {
|
|
528
|
+
return v !== null && typeof v === "object" && typeof (v as PromiseLike<unknown>).then === "function";
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ------------------------------------------------------------------ realtime-label carry-through
|
|
532
|
+
//
|
|
533
|
+
// `registerQueries` erases everything but a bare resolve function at its seam; the realtime label
|
|
534
|
+
// 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 ONE
|
|
536
|
+
// accessor — G-iv-b reads through it, never through ad-hoc properties.
|
|
537
|
+
|
|
538
|
+
const API_QUERY_REALTIME_LABEL: unique symbol = Symbol("rindle.apiQuery.realtimeLabel");
|
|
539
|
+
|
|
540
|
+
/** @internal Stamp a registered query wrapper with its `defineQuery` realtime label. */
|
|
541
|
+
export function attachRealtimeLabel<F extends object>(wrapper: F, label: RealtimeQueryLabel): F {
|
|
542
|
+
return Object.assign(wrapper, { [API_QUERY_REALTIME_LABEL]: label });
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** The realtime label a registered query carries (stamped by `registerQueries` from its
|
|
546
|
+
* `defineQuery` options), or `undefined` for an unlabeled query. The lease path uses this to
|
|
547
|
+
* derive `(room profile, args mapping)` for a named query. */
|
|
548
|
+
export 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
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Rule (a) of the §2.3 "loud at registration" checks: every labeled registered query must name
|
|
556
|
+
* an existing room profile — a label pointing at nothing is config drift that would otherwise
|
|
557
|
+
* surface as a query that silently never room-serves. */
|
|
558
|
+
export 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 > 0
|
|
571
|
+
? `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
|
+
}
|