@rindle/optimistic 0.1.0-rc.5

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.
@@ -0,0 +1,257 @@
1
+ // The optimistic aggregate overlay (AGGREGATE-SYNC-DESIGN.md §4–§6) — the pure half of
2
+ // "displayed = server_base ⊕ watermark-gated local_pending_delta".
3
+ //
4
+ // The DISPLAY path (§3) makes a relationship `count(comments)` read a server-authoritative
5
+ // synthetic base table `__agg_*` (the reduce's `(group_key, count)` output) via a plain
6
+ // projected join. This module computes the CLIENT's own pending delta on top: a mini
7
+ // invertible counter over only the pending child mutations, correlated by the same group key
8
+ // (`+1` per pending child add, `−1` per remove, net per edit). The backend applies the delta
9
+ // as an optimistic add/edit/remove to the `__agg` head row, so the displayed scalar updates
10
+ // through the same join + projection with no special path.
11
+ //
12
+ // Two things are pure and live here: the per-aggregate DEFINITIONS derived from the AST
13
+ // (`AggDef`, incl. the §6.2 static `evaluable` gate), and the accumulator (`AggOverlay`) that
14
+ // folds observed child ops into a per-group delta. The engine-touching reconcile (reading
15
+ // `server_base`, writing the `__agg` head row) stays in `backend.ts`.
16
+
17
+ import type { Ast, Condition, ValuePosition, WireValue } from "@rindle/client";
18
+ import { aggTableName } from "@rindle/normalized";
19
+
20
+ /** The scalar comparison ops the v1 predicate evaluator (and the `evaluable` gate) cover.
21
+ * `LIKE`/`ILIKE`/`IN` are deliberately EXCLUDED — a filter using them is `evaluable: false`
22
+ * and falls back to the bare server value (avoids the LIKE-escape parity rabbit hole; always
23
+ * correct, just not optimistic for that aggregate). */
24
+ const SCALAR_OPS = new Set(["=", "!=", "<", "<=", ">", ">=", "IS", "IS NOT"]);
25
+
26
+ /** One aggregate's definition, derived from the original AST — everything needed to compute a
27
+ * per-group delta from the child table's pending mutations. */
28
+ export interface AggDef {
29
+ /** The synthetic table name (`__agg_*`) the delta is applied to. */
30
+ aggTable: string;
31
+ /** The child table whose mutations drive the count (e.g. `"comment"`). */
32
+ childTable: string;
33
+ /** The child column names (schema order) — resolves the group key + filter columns. */
34
+ childColumns: string[];
35
+ /** Indices into a positional child row of the correlation child fields = the group key. */
36
+ groupKeyCols: number[];
37
+ /** The child `where`, if any (already baked into the server count; re-evaluated locally
38
+ * to decide a pending child's membership). */
39
+ where?: Condition;
40
+ /** §6.2: whether the optimistic delta is computable — the `where` references only the
41
+ * child's own columns + literals over scalar ops. Unevaluable ⇒ no delta (server value). */
42
+ evaluable: boolean;
43
+ }
44
+
45
+ /** Collect every aggregate's `AggDef` from `ast` (recursively — a nested aggregate under a
46
+ * materialized relationship included), resolving child columns via `getColumns(table)`. The
47
+ * twin of `aggTableSchemas` (`@rindle/normalized`) but carrying the delta-computation fields. */
48
+ export function collectAggDefs(ast: Ast, getColumns: (table: string) => string[] | undefined): AggDef[] {
49
+ const out: AggDef[] = [];
50
+ for (const r of ast.related ?? []) {
51
+ if (r.subquery.aggregate) {
52
+ const childTable = r.subquery.table;
53
+ const childColumns = getColumns(childTable);
54
+ if (!childColumns) continue; // child table not in the client schema — cannot key locally
55
+ const groupKeyCols = r.correlation.childField.map((n) => childColumns.indexOf(n));
56
+ if (groupKeyCols.some((i) => i < 0)) continue; // a correlation field not in the schema
57
+ const where = r.subquery.where;
58
+ out.push({
59
+ aggTable: aggTableName(r),
60
+ childTable,
61
+ childColumns,
62
+ groupKeyCols,
63
+ where,
64
+ evaluable: where === undefined || isEvaluable(where, childColumns),
65
+ });
66
+ } else {
67
+ out.push(...collectAggDefs(r.subquery, getColumns));
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** §6.2 static gate: a condition is locally evaluable iff every leaf compares the child's own
74
+ * columns / literals with a scalar op. A `correlatedSubquery` (a join the client may not hold)
75
+ * or a column outside the child schema makes the whole aggregate unevaluable. */
76
+ export function isEvaluable(cond: Condition, childColumns: string[]): boolean {
77
+ switch (cond.type) {
78
+ case "simple":
79
+ return SCALAR_OPS.has(cond.op) && pos(cond.left, childColumns) && pos(cond.right, childColumns);
80
+ case "and":
81
+ case "or":
82
+ return cond.conditions.every((c) => isEvaluable(c, childColumns));
83
+ case "correlatedSubquery":
84
+ return false; // reaches into a join the client may not have
85
+ }
86
+ }
87
+
88
+ function pos(vp: ValuePosition, childColumns: string[]): boolean {
89
+ return vp.type === "literal" || childColumns.includes(vp.name);
90
+ }
91
+
92
+ /** Evaluate an EVALUABLE condition against a positional child row (SQL-ish three-valued logic
93
+ * collapsed to a boolean — an unknown comparison is `false`, i.e. the row is not a member).
94
+ * Only the grammar `isEvaluable` admits reaches here. */
95
+ export function evalCondition(cond: Condition, row: WireValue[], childColumns: string[]): boolean {
96
+ switch (cond.type) {
97
+ case "simple":
98
+ return evalSimple(cond.op, value(cond.left, row, childColumns), value(cond.right, row, childColumns));
99
+ case "and":
100
+ return cond.conditions.every((c) => evalCondition(c, row, childColumns));
101
+ case "or":
102
+ return cond.conditions.some((c) => evalCondition(c, row, childColumns));
103
+ case "correlatedSubquery":
104
+ return false; // never evaluable — defensive
105
+ }
106
+ }
107
+
108
+ function value(vp: ValuePosition, row: WireValue[], childColumns: string[]): WireValue {
109
+ return vp.type === "literal" ? (vp.value as WireValue) : row[childColumns.indexOf(vp.name)];
110
+ }
111
+
112
+ function evalSimple(op: string, l: WireValue, r: WireValue): boolean {
113
+ switch (op) {
114
+ case "IS":
115
+ return nullSafeEq(l, r); // null-safe: NULL IS NULL → true
116
+ case "IS NOT":
117
+ return !nullSafeEq(l, r);
118
+ case "=":
119
+ return l == null || r == null ? false : nullSafeEq(l, r);
120
+ case "!=":
121
+ return l == null || r == null ? false : !nullSafeEq(l, r);
122
+ case "<":
123
+ return less(l, r);
124
+ case "<=":
125
+ return less(l, r) || nullSafeEq(l, r);
126
+ case ">":
127
+ return less(r, l);
128
+ case ">=":
129
+ return less(r, l) || nullSafeEq(l, r);
130
+ default:
131
+ return false;
132
+ }
133
+ }
134
+
135
+ function nullSafeEq(l: WireValue, r: WireValue): boolean {
136
+ return l === r; // scalar cells: number/string/boolean/null compare by identity
137
+ }
138
+
139
+ function less(l: WireValue, r: WireValue): boolean {
140
+ if (l == null || r == null) return false; // ordered comparison with NULL → unknown → false
141
+ if (typeof l === "number" && typeof r === "number") return l < r;
142
+ if (typeof l === "string" && typeof r === "string") return l < r;
143
+ if (typeof l === "boolean" && typeof r === "boolean") return Number(l) < Number(r);
144
+ return false; // mixed types: not a member (matches no row optimistically; self-corrects)
145
+ }
146
+
147
+ /** One observed child-table op (the wire shape the collector feeds the overlay). */
148
+ export interface ChildOp {
149
+ table: string;
150
+ kind: "add" | "remove" | "edit";
151
+ row: WireValue[];
152
+ /** The pre-image (edit only). */
153
+ old?: WireValue[];
154
+ }
155
+
156
+ /** One group's accumulated optimistic delta. */
157
+ export interface DeltaEntry {
158
+ aggTable: string;
159
+ def: AggDef;
160
+ /** The group-key cells (PK of the synthetic row). */
161
+ cells: WireValue[];
162
+ /** The net optimistic count delta for this group over the pending child mutations. */
163
+ n: number;
164
+ }
165
+
166
+ /** The stateful accumulator: holds the aggregate definitions and the per-group pending delta.
167
+ * Engine-agnostic — the backend feeds it observed child ops and reads back the entries to
168
+ * reconcile against the `__agg` head rows. */
169
+ export class AggOverlay {
170
+ /** By synthetic table name. Sharing a definition across queries is automatic (idempotent
171
+ * {@link register}); the backend refcounts readers and calls {@link unregister} when the
172
+ * last query over an aggregate is gone, so a definition is reclaimed with its table — not
173
+ * kept for the backend's life. */
174
+ private readonly defs = new Map<string, AggDef>();
175
+ /** Child table → the defs it feeds, for O(1) dispatch in `observe`. */
176
+ private readonly byChild = new Map<string, AggDef[]>();
177
+ /** `aggTable\0JSON(groupCells)` → the group's accumulated delta. */
178
+ private readonly delta = new Map<string, DeltaEntry>();
179
+
180
+ /** Register aggregate definitions (idempotent per synthetic table name). */
181
+ register(defs: AggDef[]): void {
182
+ for (const d of defs) {
183
+ if (this.defs.has(d.aggTable)) continue;
184
+ this.defs.set(d.aggTable, d);
185
+ const list = this.byChild.get(d.childTable);
186
+ if (list) list.push(d);
187
+ else this.byChild.set(d.childTable, [d]);
188
+ }
189
+ }
190
+
191
+ /** Forget an aggregate definition (its synthetic table is being removed because the last
192
+ * query reading it was unregistered — `AGGREGATE-SYNC-DESIGN.md` §4). Drops the def, its
193
+ * child-dispatch entry, and any accumulated delta for it. The inverse of the per-table half
194
+ * of {@link register}; a no-op for an unknown table. */
195
+ unregister(aggTable: string): void {
196
+ const d = this.defs.get(aggTable);
197
+ if (!d) return;
198
+ this.defs.delete(aggTable);
199
+ const rest = (this.byChild.get(d.childTable) ?? []).filter((x) => x.aggTable !== aggTable);
200
+ if (rest.length) this.byChild.set(d.childTable, rest);
201
+ else this.byChild.delete(d.childTable);
202
+ // Drop this table's accumulated groups (match on the stored aggTable — separator-agnostic).
203
+ for (const [k, e] of this.delta) if (e.aggTable === aggTable) this.delta.delete(k);
204
+ }
205
+
206
+ /** Does any aggregate count rows of `table`? (cheap collector guard) */
207
+ hasChild(table: string): boolean {
208
+ return this.byChild.has(table);
209
+ }
210
+
211
+ /** Any aggregate definitions at all? */
212
+ get hasDefs(): boolean {
213
+ return this.defs.size > 0;
214
+ }
215
+
216
+ /** Drop all accumulated deltas — called at the start of a reconcile cycle rebuild, where the
217
+ * delta is recomputed from scratch off the (confirm-filtered) pending set. */
218
+ reset(): void {
219
+ this.delta.clear();
220
+ }
221
+
222
+ /** Fold one observed child op into the per-group delta, for every EVALUABLE aggregate over
223
+ * that child table. (Unevaluable defs contribute nothing — §6.2 server-value fallback.) */
224
+ observe(op: ChildOp): void {
225
+ const defs = this.byChild.get(op.table);
226
+ if (!defs) return;
227
+ for (const d of defs) {
228
+ if (!d.evaluable) continue;
229
+ if (op.kind === "add") this.bump(d, op.row, 1);
230
+ else if (op.kind === "remove") this.bump(d, op.row, -1);
231
+ else {
232
+ if (op.old) this.bump(d, op.old, -1);
233
+ this.bump(d, op.row, 1);
234
+ }
235
+ }
236
+ }
237
+
238
+ private bump(d: AggDef, childRow: WireValue[], sign: number): void {
239
+ if (d.where && !evalCondition(d.where, childRow, d.childColumns)) return; // not a member
240
+ const cells = d.groupKeyCols.map((i) => childRow[i]);
241
+ const key = `${d.aggTable}|${JSON.stringify(cells)}`;
242
+ const e = this.delta.get(key);
243
+ if (e) e.n += sign;
244
+ else this.delta.set(key, { aggTable: d.aggTable, def: d, cells, n: sign });
245
+ }
246
+
247
+ /** The current non-trivial deltas to reconcile against the `__agg` head rows. */
248
+ entries(): DeltaEntry[] {
249
+ return [...this.delta.values()];
250
+ }
251
+
252
+ /** Forget groups whose delta has returned to 0 (their head row was reverted to the server
253
+ * value by the reconcile that observed the 0) — keeps the map bounded between releases. */
254
+ pruneZeros(): void {
255
+ for (const [k, e] of this.delta) if (e.n === 0) this.delta.delete(k);
256
+ }
257
+ }