@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.
- package/ARCHITECTURE.md +397 -0
- package/README.md +218 -0
- package/dist/types/algebra.d.ts +133 -0
- package/dist/types/app.d.ts +49 -0
- package/dist/types/capture.d.ts +85 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/dag-job.d.ts +40 -0
- package/dist/types/ddl.d.ts +170 -0
- package/dist/types/dialect.d.ts +130 -0
- package/dist/types/dialects/sqlite.d.ts +9 -0
- package/dist/types/driver.d.ts +128 -0
- package/dist/types/drivers/bun.d.ts +47 -0
- package/dist/types/drivers/node.d.ts +37 -0
- package/dist/types/drivers/wasm.d.ts +65 -0
- package/dist/types/emit-model.d.ts +44 -0
- package/dist/types/emit.d.ts +72 -0
- package/dist/types/entity.d.ts +23 -0
- package/dist/types/errors.d.ts +165 -0
- package/dist/types/graph.d.ts +28 -0
- package/dist/types/index.d.ts +35 -0
- package/dist/types/jobs.d.ts +134 -0
- package/dist/types/live.d.ts +62 -0
- package/dist/types/migrate.d.ts +163 -0
- package/dist/types/model.d.ts +36 -0
- package/dist/types/patch-sql.d.ts +37 -0
- package/dist/types/plan.d.ts +119 -0
- package/dist/types/profile.d.ts +80 -0
- package/dist/types/query.d.ts +100 -0
- package/dist/types/residual.d.ts +50 -0
- package/dist/types/store.d.ts +53 -0
- package/dist/types/tracker.d.ts +43 -0
- package/dist/types/typed.d.ts +15 -0
- package/dist/types/types.d.ts +26 -0
- package/dist/types/udf.d.ts +70 -0
- package/dist/types/window.d.ts +52 -0
- package/docs/JOBS-FORMAT.md +218 -0
- package/docs/LIVE-FORMAT.md +348 -0
- package/docs/MIGRATION-FORMAT.md +302 -0
- package/docs/MODEL-FORMAT.md +928 -0
- package/package.json +81 -0
- package/schemas/jaren-migration.draft-07.schema.json +144 -0
- package/schemas/jaren-migration.schema.json +144 -0
- package/schemas/jaren-model.draft-07.schema.json +149 -0
- package/schemas/jaren-model.schema.json +149 -0
- package/src/algebra.js +105 -0
- package/src/app.js +108 -0
- package/src/capture.js +584 -0
- package/src/cli.js +264 -0
- package/src/dag-job.js +86 -0
- package/src/ddl.js +588 -0
- package/src/dialect.js +297 -0
- package/src/dialects/sqlite.js +175 -0
- package/src/driver.js +419 -0
- package/src/drivers/bun.js +101 -0
- package/src/drivers/node.js +93 -0
- package/src/drivers/wasm.js +178 -0
- package/src/emit-model.js +208 -0
- package/src/emit.js +393 -0
- package/src/entity.js +367 -0
- package/src/errors.js +173 -0
- package/src/graph.js +101 -0
- package/src/index.js +64 -0
- package/src/jobs.js +507 -0
- package/src/live.js +899 -0
- package/src/migrate.js +1411 -0
- package/src/model.js +476 -0
- package/src/patch-sql.js +150 -0
- package/src/plan.js +1038 -0
- package/src/profile.js +131 -0
- package/src/query.js +1010 -0
- package/src/residual.js +91 -0
- package/src/store.js +1422 -0
- package/src/tracker.js +776 -0
- package/src/typed.js +19 -0
- package/src/types.js +36 -0
- package/src/udf.js +132 -0
- package/src/window.js +125 -0
- package/types/app.d.ts +36 -0
- package/types/bun.d.ts +9 -0
- package/types/index.d.ts +592 -0
- package/types/node.d.ts +15 -0
- package/types/typed.d.ts +108 -0
- 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
|
+
}
|