@jarenjs/db 0.34.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.
Files changed (83) hide show
  1. package/ARCHITECTURE.md +397 -0
  2. package/README.md +218 -0
  3. package/dist/types/algebra.d.ts +133 -0
  4. package/dist/types/app.d.ts +49 -0
  5. package/dist/types/capture.d.ts +85 -0
  6. package/dist/types/cli.d.ts +2 -0
  7. package/dist/types/dag-job.d.ts +40 -0
  8. package/dist/types/ddl.d.ts +170 -0
  9. package/dist/types/dialect.d.ts +130 -0
  10. package/dist/types/dialects/sqlite.d.ts +9 -0
  11. package/dist/types/driver.d.ts +128 -0
  12. package/dist/types/drivers/bun.d.ts +47 -0
  13. package/dist/types/drivers/node.d.ts +37 -0
  14. package/dist/types/drivers/wasm.d.ts +65 -0
  15. package/dist/types/emit-model.d.ts +44 -0
  16. package/dist/types/emit.d.ts +72 -0
  17. package/dist/types/entity.d.ts +23 -0
  18. package/dist/types/errors.d.ts +165 -0
  19. package/dist/types/graph.d.ts +28 -0
  20. package/dist/types/index.d.ts +35 -0
  21. package/dist/types/jobs.d.ts +134 -0
  22. package/dist/types/live.d.ts +62 -0
  23. package/dist/types/migrate.d.ts +163 -0
  24. package/dist/types/model.d.ts +36 -0
  25. package/dist/types/patch-sql.d.ts +37 -0
  26. package/dist/types/plan.d.ts +119 -0
  27. package/dist/types/profile.d.ts +80 -0
  28. package/dist/types/query.d.ts +100 -0
  29. package/dist/types/residual.d.ts +50 -0
  30. package/dist/types/store.d.ts +53 -0
  31. package/dist/types/tracker.d.ts +43 -0
  32. package/dist/types/typed.d.ts +15 -0
  33. package/dist/types/types.d.ts +26 -0
  34. package/dist/types/udf.d.ts +70 -0
  35. package/dist/types/window.d.ts +52 -0
  36. package/docs/JOBS-FORMAT.md +218 -0
  37. package/docs/LIVE-FORMAT.md +348 -0
  38. package/docs/MIGRATION-FORMAT.md +302 -0
  39. package/docs/MODEL-FORMAT.md +928 -0
  40. package/package.json +81 -0
  41. package/schemas/jaren-migration.draft-07.schema.json +144 -0
  42. package/schemas/jaren-migration.schema.json +144 -0
  43. package/schemas/jaren-model.draft-07.schema.json +149 -0
  44. package/schemas/jaren-model.schema.json +149 -0
  45. package/src/algebra.js +105 -0
  46. package/src/app.js +108 -0
  47. package/src/capture.js +584 -0
  48. package/src/cli.js +264 -0
  49. package/src/dag-job.js +86 -0
  50. package/src/ddl.js +588 -0
  51. package/src/dialect.js +297 -0
  52. package/src/dialects/sqlite.js +175 -0
  53. package/src/driver.js +419 -0
  54. package/src/drivers/bun.js +101 -0
  55. package/src/drivers/node.js +93 -0
  56. package/src/drivers/wasm.js +178 -0
  57. package/src/emit-model.js +208 -0
  58. package/src/emit.js +393 -0
  59. package/src/entity.js +367 -0
  60. package/src/errors.js +173 -0
  61. package/src/graph.js +101 -0
  62. package/src/index.js +64 -0
  63. package/src/jobs.js +507 -0
  64. package/src/live.js +899 -0
  65. package/src/migrate.js +1411 -0
  66. package/src/model.js +476 -0
  67. package/src/patch-sql.js +150 -0
  68. package/src/plan.js +1038 -0
  69. package/src/profile.js +131 -0
  70. package/src/query.js +1010 -0
  71. package/src/residual.js +91 -0
  72. package/src/store.js +1422 -0
  73. package/src/tracker.js +776 -0
  74. package/src/typed.js +19 -0
  75. package/src/types.js +36 -0
  76. package/src/udf.js +132 -0
  77. package/src/window.js +125 -0
  78. package/types/app.d.ts +36 -0
  79. package/types/bun.d.ts +9 -0
  80. package/types/index.d.ts +592 -0
  81. package/types/node.d.ts +15 -0
  82. package/types/typed.d.ts +108 -0
  83. package/types/wasm.d.ts +5 -0
package/src/live.js ADDED
@@ -0,0 +1,899 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Live queries (LIVE-FORMAT §§7–12): a registered query document
4
+ * whose result is maintained as capture records arrive, emitting
5
+ * RFC 6902 patches against its own `{ rows }` result document.
6
+ *
7
+ * The CLASSIFIER implements §7's normative table and nothing more —
8
+ * it unwraps the one-element array pack and literal `$subsequence`
9
+ * windows the same way the planner does, then reads the compiled
10
+ * plan: translated filters, order terms and aggregates are exactly
11
+ * the planner's, never a re-implementation. Everything outside the
12
+ * table re-runs on invalidation with the reason named (`live.mode`).
13
+ *
14
+ * Maintenance is synchronous inside capture delivery (§8): inserts
15
+ * carry their document in the patch, updates point-read the touched
16
+ * row, deletes are answered from maintained state. Per-row semantics
17
+ * reuse the ENGINE via packed one-row compilation (the residual
18
+ * discipline) — a live row evaluates exactly as the query would.
19
+ */
20
+
21
+ import { compileJsonQuery, analyzeQuery } from '@jarenjs/json/query';
22
+ import { decodeJSONPointerSegment } from '@jarenjs/json/pointer';
23
+ import { isJsonObject, stableStringify } from '@jarenjs/core/object';
24
+
25
+ import { DbCompileError, DbRuntimeError } from './errors.js';
26
+ import { chain } from './driver.js';
27
+ import { planQuery } from './plan.js';
28
+ import { createSortedWindow } from './window.js';
29
+
30
+ /** The store-level live bounds and their defaults (§12: printed,
31
+ * never silent). */
32
+ export const LIVE_DEFAULTS = Object.freeze({ maxQueries: 64, maxMaintained: 10_000 });
33
+
34
+ const AGGREGATE_MEMBERS = new Map([
35
+ ['$count', 'count'], ['$sum', 'sum'], ['$avg', 'avg'],
36
+ ['$min', 'min'], ['$max', 'max'],
37
+ ]);
38
+
39
+ /** Split an emitted pointer into unescaped segments. */
40
+ const segmentsOf = (path) => path.split('/').slice(1).map(decodeJSONPointerSegment);
41
+
42
+ /**
43
+ * Peel the canonical wrappers off a document the way the planner
44
+ * does: one-element array pack, then literal `$subsequence` windows,
45
+ * then a top-level aggregate member.
46
+ * @param {any} document
47
+ */
48
+ function unwrapDocument(document) {
49
+ let doc = Array.isArray(document) && document.length === 1 ? document[0] : document;
50
+ let offset = 0;
51
+ let limit = null;
52
+ let windowed = false;
53
+ while (isJsonObject(doc) && Array.isArray(doc.$subsequence)
54
+ && Object.keys(doc).length === 1
55
+ && typeof doc.$subsequence[1] === 'number'
56
+ && (doc.$subsequence[2] === undefined || typeof doc.$subsequence[2] === 'number')) {
57
+ windowed = true;
58
+ offset += doc.$subsequence[1];
59
+ const length = doc.$subsequence[2];
60
+ if (length !== undefined) limit = limit === null ? length : Math.min(limit, length);
61
+ doc = doc.$subsequence[0];
62
+ }
63
+ let aggregate = null;
64
+ if (isJsonObject(doc)) {
65
+ const keys = Object.keys(doc);
66
+ if (keys.length === 1 && AGGREGATE_MEMBERS.has(keys[0])) {
67
+ aggregate = { name: keys[0], fn: AGGREGATE_MEMBERS.get(keys[0]) };
68
+ doc = doc[keys[0]];
69
+ // a window INSIDE the aggregate is still a windowed aggregate
70
+ while (isJsonObject(doc) && Array.isArray(doc.$subsequence)
71
+ && Object.keys(doc).length === 1) {
72
+ windowed = true;
73
+ doc = doc.$subsequence[0];
74
+ }
75
+ }
76
+ }
77
+ return { inner: doc, windowed, offset, limit, aggregate };
78
+ }
79
+
80
+ /** The single for-binding name of a canonical flwor, or null. */
81
+ function bindingNameOf(inner) {
82
+ if (!isJsonObject(inner) || !isJsonObject(inner.$for)) return null;
83
+ const names = Object.keys(inner.$for);
84
+ if (names.length !== 1) return null;
85
+ return inner.$for[names[0]] === '$[*]' ? names[0] : null;
86
+ }
87
+
88
+ /** The whole-documents read behind a flwor: same binding, same
89
+ * filter, the bare document returned — the SQL-narrowed source every
90
+ * strategy initializes from. */
91
+ function documentsSource(binding, where) {
92
+ return {
93
+ $for: { [binding]: '$[*]' },
94
+ ...(where !== undefined ? { $where: where } : {}),
95
+ $return: `$${binding}`,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Recognise the canonical single-level group form (§7): one binding
101
+ * over `$[*]`, optional `$where`, `$groupby` with ONE binding, and a
102
+ * `$return` object whose members are the group key (`$g` plain or
103
+ * defaulted) or a single-member aggregate over the group sequence.
104
+ * @param {any} inner
105
+ * @returns {null | { binding: string, group: string, groupExpr: any,
106
+ * where: any, members: { name: string, kind: 'key' | 'aggregate',
107
+ * fn?: string, operand?: any, defaulted?: boolean }[] }}
108
+ */
109
+ function recogniseGroupForm(inner) {
110
+ const binding = bindingNameOf(inner);
111
+ if (binding === null || !isJsonObject(inner.$groupby)) return null;
112
+ const allowed = new Set(['$for', '$where', '$groupby', '$return']);
113
+ if (!Object.keys(inner).every((key) => allowed.has(key))) return null;
114
+ const groupNames = Object.keys(inner.$groupby);
115
+ if (groupNames.length !== 1) return null;
116
+ const group = groupNames[0];
117
+ if (!isJsonObject(inner.$return)) return null;
118
+ const members = [];
119
+ for (const name of Object.keys(inner.$return)) {
120
+ const expr = inner.$return[name];
121
+ if (expr === `$${group}`) {
122
+ members.push({ name, kind: 'key', defaulted: false });
123
+ continue;
124
+ }
125
+ if (isJsonObject(expr) && Array.isArray(expr.$default)
126
+ && expr.$default.length === 2 && expr.$default[0] === `$${group}`
127
+ && expr.$default[1] === null && Object.keys(expr).length === 1) {
128
+ members.push({ name, kind: 'key', defaulted: true });
129
+ continue;
130
+ }
131
+ if (isJsonObject(expr) && Object.keys(expr).length === 1
132
+ && AGGREGATE_MEMBERS.has(Object.keys(expr)[0])) {
133
+ const op = Object.keys(expr)[0];
134
+ members.push({ name, kind: 'aggregate', fn: AGGREGATE_MEMBERS.get(op), operand: expr[op] });
135
+ continue;
136
+ }
137
+ return null;
138
+ }
139
+ return { binding, group, groupExpr: inner.$groupby[group], where: inner.$where, members };
140
+ }
141
+
142
+ /**
143
+ * Collect the top-level document members a compiled expression reads
144
+ * through an item binding — the §8 member-level dependency set. Any
145
+ * non-simple first step (wildcard, descendant, index) widens to the
146
+ * whole collection.
147
+ * @param {any} node - an analysis AST node
148
+ * @param {{ whole: boolean, members: Set<string> }} into
149
+ */
150
+ function collectTopMembers(node, into) {
151
+ if (node === null || typeof node !== 'object') return;
152
+ if (Array.isArray(node)) {
153
+ for (const item of node) collectTopMembers(item, into);
154
+ return;
155
+ }
156
+ if (node.kind === 'path' && node.rootSlot > 0 && node.external !== true) {
157
+ const first = node.segments[0];
158
+ if (first === undefined) {
159
+ into.whole = true; // the bare binding: the whole document is read
160
+ }
161
+ else if (first.descendant !== true && first.selectors.length === 1
162
+ && first.selectors[0].kind === 'name') {
163
+ into.members.add(first.selectors[0].name);
164
+ }
165
+ else {
166
+ into.whole = true;
167
+ }
168
+ }
169
+ for (const key of Object.keys(node)) {
170
+ if (key === 'docPath') continue;
171
+ collectTopMembers(node[key], into);
172
+ }
173
+ }
174
+
175
+ /** Derive the member dependency set from an analysis root. */
176
+ function memberDeps(root) {
177
+ const deps = { whole: false, members: new Set() };
178
+ collectTopMembers(root, deps);
179
+ return deps;
180
+ }
181
+
182
+ /**
183
+ * Classify a collection query document against §7's table. Pure —
184
+ * given the document and the collection's planner shape, returns the
185
+ * strategy description, or a re-run description with the reason.
186
+ * @param {any} document
187
+ * @param {any} queryShape - the planner shape (collection, schema,
188
+ * columnByCanonical)
189
+ * @param {boolean} keyed - whether documents carry their key (a
190
+ * declared key pointer); unkeyed rows cannot be tracked by key
191
+ * @returns {any}
192
+ */
193
+ export function classifyLiveQuery(document, queryShape, keyed) {
194
+ const rerun = (reason) => ({ strategy: 'rerun', reason });
195
+ const plannerReason = (planned) => {
196
+ const forcing = planned.reasons[0]
197
+ ?? { construct: 'residual', reason: 'the document did not translate' };
198
+ return `'${forcing.construct}' — ${forcing.reason}`;
199
+ };
200
+ const { inner, windowed, offset, limit, aggregate } = unwrapDocument(document);
201
+
202
+ if (aggregate !== null) {
203
+ if (windowed) return rerun('a windowed aggregate maintains no accumulator');
204
+ const planned = planQuery({ [aggregate.name]: inner }, queryShape, {});
205
+ if (planned.mode !== 'native' || planned.plan.aggregate === null) {
206
+ return rerun(plannerReason(planned));
207
+ }
208
+ if (!keyed) return rerun('rows without a document key cannot be tracked');
209
+ return {
210
+ strategy: 'accumulator',
211
+ fn: planned.plan.aggregate.fn,
212
+ operand: inner,
213
+ deps: memberDeps(planned.analysis.root),
214
+ };
215
+ }
216
+
217
+ const group = recogniseGroupForm(inner);
218
+ if (group !== null) {
219
+ if (windowed) return rerun('a windowed group re-runs');
220
+ const carrier = documentsSource(group.binding, group.where);
221
+ const planned = planQuery(carrier, queryShape, {});
222
+ if (planned.mode !== 'native') {
223
+ return rerun(`the group filter did not translate: ${plannerReason(planned)}`);
224
+ }
225
+ if (!keyed) return rerun('rows without a document key cannot be tracked');
226
+ const rowDocument = {
227
+ $for: { [group.binding]: '$[*]' },
228
+ ...(group.where !== undefined ? { $where: group.where } : {}),
229
+ $return: {
230
+ k: [group.groupExpr],
231
+ ...Object.fromEntries(group.members
232
+ .filter((member) => member.kind === 'aggregate')
233
+ .map((member) => [member.name, [member.operand]])),
234
+ },
235
+ };
236
+ return {
237
+ strategy: 'group', group, carrier, rowDocument,
238
+ deps: memberDeps(analyzeQuery([rowDocument]).root),
239
+ };
240
+ }
241
+
242
+ const planned = planQuery(inner, queryShape, {});
243
+ if (planned.mode === 'set') return rerun(plannerReason(planned));
244
+ if (!keyed) return rerun('rows without a document key cannot be tracked');
245
+
246
+ if (planned.plan.order !== null) {
247
+ if (offset > 0 || (planned.plan.window?.offset ?? 0) > 0) {
248
+ return rerun('an offset window re-runs');
249
+ }
250
+ const returnCard = planned.analysis.root.return?.card ?? 1;
251
+ if (returnCard > 2) return rerun('a one-to-many projection under an order re-runs');
252
+ return {
253
+ strategy: 'window',
254
+ inner,
255
+ order: planned.plan.order,
256
+ limit: planned.plan.window?.limit ?? limit,
257
+ deps: { whole: true, members: new Set() },
258
+ };
259
+ }
260
+ if (planned.plan.window !== null || windowed) {
261
+ return rerun('a limit without an order is not deterministic to maintain');
262
+ }
263
+ return {
264
+ strategy: 'rows',
265
+ inner,
266
+ projected: planned.mode === 'row',
267
+ deps: { whole: true, members: new Set() },
268
+ };
269
+ }
270
+
271
+ // ————— shared machinery —————
272
+
273
+ /**
274
+ * Diff two row arrays into sequential add/remove/replace ops under
275
+ * `/rows`, relying on REFERENCE identity for unchanged rows (the §9
276
+ * sharing contract makes identity the equality that matters). A
277
+ * working copy is replayed op by op, so the emitted patch transforms
278
+ * the old array into the new one BY CONSTRUCTION; a remove re-filled
279
+ * at the same index merges into a replace.
280
+ * @param {any[]} oldRows
281
+ * @param {any[]} newRows
282
+ * @returns {any[]} ops
283
+ */
284
+ export function diffRows(oldRows, newRows) {
285
+ const ops = [];
286
+ const work = oldRows.slice();
287
+ const wanted = new Set(newRows);
288
+ for (let i = work.length - 1; i >= 0; i--) {
289
+ if (!wanted.has(work[i])) {
290
+ ops.push({ op: 'remove', path: `/rows/${i}` });
291
+ work.splice(i, 1);
292
+ }
293
+ }
294
+ for (let i = 0; i < newRows.length; i++) {
295
+ if (work[i] === newRows[i]) continue;
296
+ const found = work.indexOf(newRows[i], i + 1);
297
+ if (found !== -1) {
298
+ ops.push({ op: 'remove', path: `/rows/${found}` });
299
+ work.splice(found, 1);
300
+ }
301
+ ops.push({ op: 'add', path: `/rows/${i}`, value: newRows[i] });
302
+ work.splice(i, 0, newRows[i]);
303
+ }
304
+ // duplicate primitive values can leave a surplus tail (the Set
305
+ // collapsed them): trim it
306
+ for (let i = work.length - 1; i >= newRows.length; i--) {
307
+ ops.push({ op: 'remove', path: `/rows/${i}` });
308
+ work.splice(i, 1);
309
+ }
310
+ const merged = [];
311
+ for (let i = 0; i < ops.length; i++) {
312
+ const here = ops[i];
313
+ const next = ops[i + 1];
314
+ if (next !== undefined && here.op === 'remove' && next.op === 'add'
315
+ && here.path === next.path) {
316
+ merged.push({ op: 'replace', path: here.path, value: next.value });
317
+ i++;
318
+ continue;
319
+ }
320
+ merged.push(here);
321
+ }
322
+ return merged;
323
+ }
324
+
325
+ /** The shared emission tail: ops plus the next rows, or null when
326
+ * nothing visibly changed. */
327
+ function diffAgainst(previousRows, nextRows) {
328
+ const ops = diffRows(previousRows, nextRows);
329
+ return ops.length === 0 ? null : { ops, rows: nextRows };
330
+ }
331
+
332
+ /** Reuse the previous row object when a fresh one is value-equal —
333
+ * the structural-sharing half of §9 for re-evaluated rows. */
334
+ function sharedRow(previous, fresh) {
335
+ return stableStringify(previous) === stableStringify(fresh) ? previous : fresh;
336
+ }
337
+
338
+ /**
339
+ * Map fresh rows onto previous references where value-equal — the
340
+ * sharing pass for strategies that rebuild their row list (window
341
+ * re-placements, re-runs).
342
+ * @param {any[]} previousRows
343
+ * @param {any[]} nextRows
344
+ */
345
+ function shareByValue(previousRows, nextRows) {
346
+ const kept = new Set(nextRows.filter((row) => previousRows.includes(row)));
347
+ /** @type {Map<string, any[]>} */
348
+ const pool = new Map();
349
+ for (const row of previousRows) {
350
+ if (kept.has(row)) continue;
351
+ const key = stableStringify(row) ?? '';
352
+ const bucket = pool.get(key);
353
+ if (bucket === undefined) pool.set(key, [row]);
354
+ else bucket.push(row);
355
+ }
356
+ return nextRows.map((row) => {
357
+ if (kept.has(row)) return row;
358
+ const bucket = pool.get(stableStringify(row) ?? '');
359
+ return bucket !== undefined && bucket.length > 0 ? bucket.shift() : row;
360
+ });
361
+ }
362
+
363
+ /**
364
+ * Extract this collection's touched keys from one capture record,
365
+ * respecting the member-level dependency set (§8).
366
+ * @param {any} record
367
+ * @param {string} name - collection name
368
+ * @param {{ whole: boolean, members: Set<string> }} deps
369
+ * @returns {Map<string, { kind: 'insert' | 'delete' | 'update', doc?: any }> | null}
370
+ */
371
+ function touchedKeys(record, name, deps) {
372
+ /** @type {Map<string, any>} */
373
+ const touched = new Map();
374
+ for (const op of record.patch) {
375
+ const segments = segmentsOf(op.path);
376
+ if (segments[0] !== name) continue;
377
+ const token = segments[1];
378
+ if (segments.length === 2) {
379
+ if (op.op === 'add') touched.set(token, { kind: 'insert', doc: op.value });
380
+ else if (op.op === 'remove') touched.set(token, { kind: 'delete' });
381
+ else touched.set(token, { kind: 'update' });
382
+ continue;
383
+ }
384
+ if (!deps.whole && !deps.members.has(segments[2])) continue;
385
+ if (!touched.has(token)) touched.set(token, { kind: 'update' });
386
+ }
387
+ return touched.size === 0 ? null : touched;
388
+ }
389
+
390
+ // ————— strategies —————
391
+
392
+ /**
393
+ * `where` (+ per-row `select`): §7's incremental rows. Bookkeeping is
394
+ * per source key — a projected row may yield 0..n items — and the
395
+ * result keeps arrival order (§9).
396
+ * @param {any} description
397
+ * @param {any} context
398
+ */
399
+ function rowsStrategy(description, context) {
400
+ const { inner } = description;
401
+ const binding = /** @type {string} */ (bindingNameOf(inner));
402
+ const evaluate = compileJsonQuery([inner]);
403
+ const source = documentsSource(binding, inner.$where);
404
+
405
+ /** @type {Map<string, any[]>} arrival-ordered per-key items */
406
+ const itemsByKey = new Map();
407
+ const flatten = () => {
408
+ const rows = [];
409
+ for (const items of itemsByKey.values()) rows.push(...items);
410
+ return rows;
411
+ };
412
+
413
+ return {
414
+ init: () => chain(context.execute([source], { externals: context.externals }),
415
+ (docs) => {
416
+ for (const doc of /** @type {any[]} */ (docs)) {
417
+ const items = /** @type {any[]} */ (evaluate([doc], context.externals));
418
+ if (items.length > 0) itemsByKey.set(context.keyOf(doc), items);
419
+ }
420
+ return flatten();
421
+ }),
422
+ entries: () => flatten().length,
423
+ apply(record, previousRows) {
424
+ const touched = touchedKeys(record, context.name, description.deps);
425
+ if (touched === null) return null;
426
+ let changed = false;
427
+ for (const [token, change] of touched) {
428
+ const previous = itemsByKey.get(token);
429
+ const doc = change.kind === 'delete'
430
+ ? undefined
431
+ : change.kind === 'insert' ? change.doc : context.readRow(token);
432
+ const fresh = doc === undefined
433
+ ? []
434
+ : /** @type {any[]} */ (evaluate([doc], context.externals));
435
+ if (fresh.length === 0) {
436
+ if (previous !== undefined) {
437
+ itemsByKey.delete(token);
438
+ changed = true;
439
+ }
440
+ continue;
441
+ }
442
+ if (previous === undefined) {
443
+ itemsByKey.set(token, fresh);
444
+ changed = true;
445
+ continue;
446
+ }
447
+ const next = fresh.map((item, index) => sharedRow(previous[index], item));
448
+ if (next.length !== previous.length
449
+ || next.some((item, index) => item !== previous[index])) {
450
+ itemsByKey.set(token, next);
451
+ changed = true;
452
+ }
453
+ }
454
+ return changed ? diffAgainst(previousRows, flatten()) : null;
455
+ },
456
+ };
457
+ }
458
+
459
+ /**
460
+ * `orderBy` (+ `limit`): §7's maintained window over ALL matching
461
+ * rows — which is what answers a delete inside the visible window
462
+ * without a re-query — with the first `limit` entries visible and
463
+ * ties broken by the key token.
464
+ * @param {any} description
465
+ * @param {any} context
466
+ */
467
+ function windowStrategy(description, context) {
468
+ const { inner, order, limit } = description;
469
+ const binding = /** @type {string} */ (bindingNameOf(inner));
470
+ const rowDocument = { ...inner };
471
+ delete rowDocument.$orderby;
472
+ const evaluate = compileJsonQuery([rowDocument]);
473
+ const source = documentsSource(binding, inner.$where);
474
+ const getters = order.map((term) => {
475
+ const steps = term.ref.segments.map((segment) =>
476
+ ('name' in segment ? segment.name : segment.index));
477
+ return (doc) => {
478
+ let value = doc;
479
+ for (const step of steps) {
480
+ if (value === null || typeof value !== 'object') return undefined;
481
+ value = value[step];
482
+ }
483
+ return value;
484
+ };
485
+ });
486
+ const sortedWindow = createSortedWindow(order, limit);
487
+
488
+ const place = (token, doc) => {
489
+ const items = /** @type {any[]} */ (evaluate([doc], context.externals));
490
+ if (items.length === 0) return;
491
+ sortedWindow.insert(token, getters.map((get) => get(doc)), items[0]);
492
+ };
493
+ const visibleRows = () => sortedWindow.visible().map((entry) => entry.item);
494
+
495
+ return {
496
+ init: () => chain(context.execute([source], { externals: context.externals }),
497
+ (docs) => {
498
+ for (const doc of /** @type {any[]} */ (docs)) place(context.keyOf(doc), doc);
499
+ return visibleRows();
500
+ }),
501
+ entries: () => sortedWindow.size(),
502
+ apply(record, previousRows) {
503
+ const touched = touchedKeys(record, context.name, description.deps);
504
+ if (touched === null) return null;
505
+ for (const [token, change] of touched) {
506
+ sortedWindow.remove(token);
507
+ if (change.kind === 'delete') continue;
508
+ const doc = change.kind === 'insert' ? change.doc : context.readRow(token);
509
+ if (doc !== undefined) place(token, doc);
510
+ }
511
+ return diffAgainst(previousRows, shareByValue(previousRows, visibleRows()));
512
+ },
513
+ };
514
+ }
515
+
516
+ /**
517
+ * Whole-query aggregates: §7's running accumulator with per-row
518
+ * contributions. `min`/`max` FALL BACK to a recompute over the
519
+ * retained contributions when the current extremum's last holder
520
+ * leaves — the documented fallback, counted in `stats().fallbacks`.
521
+ * @param {any} description
522
+ * @param {any} context
523
+ */
524
+ function accumulatorStrategy(description, context) {
525
+ const { fn, operand } = description;
526
+ const isCount = fn === 'count';
527
+ const evaluate = compileJsonQuery([operand]);
528
+ const binding = bindingNameOf(operand);
529
+ const source = binding === null
530
+ ? { $for: { it: '$[*]' }, $return: '$it' }
531
+ : documentsSource(binding, operand.$where);
532
+
533
+ /** @type {Map<string, number | number[]>} */
534
+ const contributions = new Map();
535
+ const stats = { fallbacks: 0 };
536
+
537
+ const contributionOf = (doc) => {
538
+ const items = /** @type {any[]} */ (evaluate([doc], context.externals));
539
+ if (items.length === 0) return undefined;
540
+ return isCount ? items.length : items;
541
+ };
542
+ const fold = () => {
543
+ let sum = 0;
544
+ let count = 0;
545
+ let extreme;
546
+ for (const contribution of contributions.values()) {
547
+ if (isCount) {
548
+ count += /** @type {number} */ (contribution);
549
+ continue;
550
+ }
551
+ for (const value of /** @type {number[]} */ (contribution)) {
552
+ sum += value;
553
+ count += 1;
554
+ if (extreme === undefined
555
+ || (fn === 'min' ? value < extreme : value > extreme)) extreme = value;
556
+ }
557
+ }
558
+ if (fn === 'count') return count;
559
+ if (fn === 'sum') return sum;
560
+ if (count === 0) return undefined;
561
+ return fn === 'avg' ? sum / count : extreme;
562
+ };
563
+
564
+ /** @type {any} */
565
+ let current;
566
+ const rowsOf = () => (current === undefined ? [] : [current]);
567
+
568
+ return {
569
+ init: () => chain(context.execute([source], { externals: context.externals }),
570
+ (docs) => {
571
+ for (const doc of /** @type {any[]} */ (docs)) {
572
+ const contribution = contributionOf(doc);
573
+ if (contribution !== undefined) {
574
+ contributions.set(context.keyOf(doc), contribution);
575
+ }
576
+ }
577
+ current = fold();
578
+ return rowsOf();
579
+ }),
580
+ entries: () => contributions.size,
581
+ stats: () => ({ ...stats }),
582
+ apply(record, previousRows) {
583
+ const touched = touchedKeys(record, context.name, description.deps);
584
+ if (touched === null) return null;
585
+ let changed = false;
586
+ let extremumLeft = false;
587
+ for (const [token, change] of touched) {
588
+ const previous = contributions.get(token);
589
+ const doc = change.kind === 'delete'
590
+ ? undefined
591
+ : change.kind === 'insert' ? change.doc : context.readRow(token);
592
+ const next = doc === undefined ? undefined : contributionOf(doc);
593
+ if (stableStringify(previous ?? null) === stableStringify(next ?? null)) continue;
594
+ changed = true;
595
+ if ((fn === 'min' || fn === 'max') && previous !== undefined
596
+ && current !== undefined
597
+ && /** @type {number[]} */ (previous).includes(current)) {
598
+ extremumLeft = true;
599
+ }
600
+ if (next === undefined) contributions.delete(token);
601
+ else contributions.set(token, next);
602
+ }
603
+ if (!changed) return null;
604
+ if (extremumLeft) stats.fallbacks += 1;
605
+ // count/sum/avg fold in O(state); the same recompute IS the
606
+ // min/max fallback when the extremum's holder left
607
+ current = fold();
608
+ return diffAgainst(previousRows, shareByValue(previousRows, rowsOf()));
609
+ },
610
+ };
611
+ }
612
+
613
+ /**
614
+ * Canonical single-level `groupBy` with aggregate returns: §7's
615
+ * per-group deltas — the accumulator machinery once per group, groups
616
+ * in first-appearance order.
617
+ * @param {any} description
618
+ * @param {any} context
619
+ */
620
+ function groupStrategy(description, context) {
621
+ const { group, rowDocument, carrier } = description;
622
+ const evaluate = compileJsonQuery([rowDocument]);
623
+
624
+ /** @type {Map<string, { key: any[], rows: Map<string, any>, row: any }>}
625
+ * group token → per-row contributions, in first-appearance order */
626
+ const groups = new Map();
627
+
628
+ const contributionOf = (doc) => {
629
+ const evaluated = /** @type {any[]} */ (evaluate([doc], context.externals));
630
+ return evaluated.length === 0 ? undefined : evaluated[0];
631
+ };
632
+ const buildRow = (entry) => {
633
+ /** @type {any} */
634
+ const row = {};
635
+ for (const member of group.members) {
636
+ if (member.kind === 'key') {
637
+ if (entry.key.length > 0) row[member.name] = entry.key[0];
638
+ else if (member.defaulted) row[member.name] = null;
639
+ continue;
640
+ }
641
+ let sum = 0;
642
+ let count = 0;
643
+ let extreme;
644
+ let n = 0;
645
+ for (const contribution of entry.rows.values()) {
646
+ const items = /** @type {any[]} */ (contribution[member.name] ?? []);
647
+ n += items.length;
648
+ for (const value of items) {
649
+ sum += value;
650
+ count += 1;
651
+ if (extreme === undefined
652
+ || (member.fn === 'min' ? value < extreme : value > extreme)) extreme = value;
653
+ }
654
+ }
655
+ if (member.fn === 'count') row[member.name] = n;
656
+ else if (member.fn === 'sum') row[member.name] = sum;
657
+ else if (count > 0) row[member.name] = member.fn === 'avg' ? sum / count : extreme;
658
+ // an empty avg/min/max leaves the member absent, the engine's
659
+ // empty-sequence rule
660
+ }
661
+ return row;
662
+ };
663
+ const rowsOf = () => [...groups.values()].map((entry) => entry.row);
664
+
665
+ const placeRow = (token, doc, changedGroups) => {
666
+ const contribution = doc === undefined ? undefined : contributionOf(doc);
667
+ for (const [groupToken, entry] of groups) {
668
+ if (!entry.rows.has(token)) continue;
669
+ if (contribution !== undefined
670
+ && stableStringify(entry.rows.get(token)) === stableStringify(contribution)) {
671
+ return; // unchanged in place
672
+ }
673
+ entry.rows.delete(token);
674
+ changedGroups.add(groupToken);
675
+ break;
676
+ }
677
+ if (contribution === undefined) return;
678
+ const groupToken = stableStringify(contribution.k) ?? '';
679
+ let entry = groups.get(groupToken);
680
+ if (entry === undefined) {
681
+ entry = { key: contribution.k, rows: new Map(), row: null };
682
+ groups.set(groupToken, entry);
683
+ }
684
+ entry.rows.set(token, contribution);
685
+ changedGroups.add(groupToken);
686
+ };
687
+ const settle = (changedGroups) => {
688
+ for (const groupToken of changedGroups) {
689
+ const entry = groups.get(groupToken);
690
+ if (entry === undefined) continue;
691
+ if (entry.rows.size === 0) groups.delete(groupToken);
692
+ else entry.row = sharedRow(entry.row, buildRow(entry));
693
+ }
694
+ };
695
+
696
+ return {
697
+ init: () => chain(context.execute([carrier], { externals: context.externals }),
698
+ (docs) => {
699
+ const changedGroups = new Set();
700
+ for (const doc of /** @type {any[]} */ (docs)) {
701
+ placeRow(context.keyOf(doc), doc, changedGroups);
702
+ }
703
+ settle(changedGroups);
704
+ return rowsOf();
705
+ }),
706
+ entries: () => {
707
+ let total = groups.size;
708
+ for (const entry of groups.values()) total += entry.rows.size;
709
+ return total;
710
+ },
711
+ apply(record, previousRows) {
712
+ const touched = touchedKeys(record, context.name, description.deps);
713
+ if (touched === null) return null;
714
+ const changedGroups = new Set();
715
+ for (const [token, change] of touched) {
716
+ const doc = change.kind === 'delete'
717
+ ? undefined
718
+ : change.kind === 'insert' ? change.doc : context.readRow(token);
719
+ placeRow(token, doc, changedGroups);
720
+ }
721
+ if (changedGroups.size === 0) return null;
722
+ settle(changedGroups);
723
+ return diffAgainst(previousRows, rowsOf());
724
+ },
725
+ };
726
+ }
727
+
728
+ /**
729
+ * Everything outside the table: re-run the WHOLE query on
730
+ * invalidation and diff against the previous result with value-equal
731
+ * reference reuse — declared, honest, reported through `live.mode`.
732
+ * @param {any} description
733
+ * @param {any} context
734
+ */
735
+ function rerunStrategy(description, context) {
736
+ const stats = { reruns: 0 };
737
+ const normalise = (result) => (result === undefined ? []
738
+ : Array.isArray(result) ? result : [result]);
739
+ return {
740
+ init: () => chain(
741
+ context.execute(context.document, { externals: context.externals }),
742
+ normalise),
743
+ entries: (rows) => rows.length,
744
+ stats: () => ({ ...stats }),
745
+ apply(record, previousRows) {
746
+ stats.reruns += 1;
747
+ const result = context.execute(context.document, { externals: context.externals });
748
+ const next = shareByValue(previousRows, normalise(result));
749
+ return diffAgainst(previousRows, next);
750
+ },
751
+ };
752
+ }
753
+
754
+ // ————— the registry —————
755
+
756
+ /**
757
+ * The store-level live-query registry: registration against the §12
758
+ * bounds, capture-record delivery in commit order, lifecycle.
759
+ * @param {{ maxQueries: number, maxMaintained: number }} bounds
760
+ */
761
+ export function createLiveRegistry(bounds) {
762
+ /** @type {Set<any>} */
763
+ const queries = new Set();
764
+
765
+ /**
766
+ * @param {any} definition - `{ name, tables, document, externals,
767
+ * demanded, classification, execute, readRow, keyOf }`; entity
768
+ * re-runs pass `readRow`/`keyOf` as null and their bound tables.
769
+ */
770
+ const register = (definition) => {
771
+ if (queries.size >= bounds.maxQueries) {
772
+ throw new DbCompileError('JD0052',
773
+ `the store's live.maxQueries bound of ${bounds.maxQueries} was reached`);
774
+ }
775
+ const classification = definition.classification;
776
+ if (definition.demanded === 'incremental' && classification.strategy === 'rerun') {
777
+ throw new DbCompileError('JD0051',
778
+ `the demanded incremental mode is unavailable: ${classification.reason}`);
779
+ }
780
+
781
+ const context = {
782
+ name: definition.name,
783
+ document: definition.document,
784
+ externals: definition.externals ?? {},
785
+ execute: definition.execute,
786
+ readRow: definition.readRow,
787
+ keyOf: definition.keyOf,
788
+ };
789
+ const strategy = classification.strategy === 'rows' ? rowsStrategy(classification, context)
790
+ : classification.strategy === 'window' ? windowStrategy(classification, context)
791
+ : classification.strategy === 'accumulator'
792
+ ? accumulatorStrategy(classification, context)
793
+ : classification.strategy === 'group' ? groupStrategy(classification, context)
794
+ : rerunStrategy(classification, context);
795
+
796
+ /** @type {Set<Function>} */
797
+ const observers = new Set();
798
+ const state = {
799
+ status: 'live',
800
+ result: { rows: /** @type {any[]} */ ([]) },
801
+ /** @type {any} */
802
+ error: null,
803
+ stats: { records: 0, matched: 0, emissions: 0 },
804
+ };
805
+ const checkBound = (entries) => {
806
+ if (entries > bounds.maxMaintained) {
807
+ throw new DbRuntimeError('JD2060',
808
+ `the maintained live state (${entries} entries) exceeded the `
809
+ + `live.maxMaintained bound of ${bounds.maxMaintained}`,
810
+ { collection: definition.name });
811
+ }
812
+ };
813
+
814
+ const query = {
815
+ deliver(record) {
816
+ if (state.status !== 'live') return;
817
+ let relevant = false;
818
+ for (const table of record.collections) {
819
+ if (definition.tables.has(table)) {
820
+ relevant = true;
821
+ break;
822
+ }
823
+ }
824
+ if (!relevant) return;
825
+ state.stats.records += 1;
826
+ let outcome;
827
+ try {
828
+ outcome = strategy.apply(record, state.result.rows);
829
+ if (outcome !== null) checkBound(strategy.entries(outcome.rows));
830
+ }
831
+ catch (error) {
832
+ state.status = 'errored';
833
+ state.error = error;
834
+ queries.delete(query);
835
+ const failure = { error };
836
+ for (const observer of observers) {
837
+ try {
838
+ observer(failure);
839
+ }
840
+ catch { /* observer isolation, the capture precedent */ }
841
+ }
842
+ observers.clear();
843
+ return;
844
+ }
845
+ if (outcome === null) return;
846
+ state.stats.matched += 1;
847
+ state.stats.emissions += 1;
848
+ state.result = { rows: outcome.rows };
849
+ const event = { patch: outcome.ops, seq: record.seq };
850
+ for (const observer of observers) {
851
+ try {
852
+ observer(event);
853
+ }
854
+ catch { /* isolation */ }
855
+ }
856
+ },
857
+ close() {
858
+ if (state.status === 'live') state.status = 'closed';
859
+ queries.delete(query);
860
+ observers.clear();
861
+ },
862
+ };
863
+
864
+ return chain(strategy.init(), (rows) => {
865
+ checkBound(strategy.entries(rows));
866
+ state.result = { rows };
867
+ queries.add(query);
868
+ const mode = Object.freeze({
869
+ strategy: classification.strategy,
870
+ mode: classification.strategy === 'rerun' ? 'rerun' : 'incremental',
871
+ ...(classification.strategy === 'rerun' ? { reason: classification.reason } : {}),
872
+ });
873
+ return Object.freeze({
874
+ get result() { return state.result; },
875
+ get state() { return state.status; },
876
+ get error() { return state.error; },
877
+ mode,
878
+ stats: () => ({ ...state.stats, ...(strategy.stats?.() ?? {}) }),
879
+ subscribe(observer) {
880
+ if (state.status !== 'live') throw new TypeError('the live query is closed');
881
+ observers.add(observer);
882
+ return () => observers.delete(observer);
883
+ },
884
+ close: () => query.close(),
885
+ });
886
+ });
887
+ };
888
+
889
+ return {
890
+ register,
891
+ count: () => queries.size,
892
+ deliver(record) {
893
+ for (const query of [...queries]) query.deliver(record);
894
+ },
895
+ closeAll() {
896
+ for (const query of [...queries]) query.close();
897
+ },
898
+ };
899
+ }