@jarenjs/db 0.72.3 → 0.75.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 +50 -7
- package/README.md +44 -6
- package/docs/LIVE-FORMAT.md +48 -13
- package/docs/MODEL-FORMAT.md +31 -14
- package/docs/REPLICATION-FORMAT.md +19 -13
- package/package.json +4 -4
- package/src/algebra.js +9 -3
- package/src/dag-job.js +1 -1
- package/src/derive.js +14 -3
- package/src/dialect.js +2 -0
- package/src/dialects/check-read.js +151 -0
- package/src/dialects/postgres.js +14 -2
- package/src/dialects/sqlite.js +5 -1
- package/src/emit.js +54 -13
- package/src/introspect.js +37 -5
- package/src/live-nested.js +27 -10
- package/src/live.js +47 -135
- package/src/plan.js +163 -46
- package/src/query.js +31 -8
- package/types/index.d.ts +1 -1
package/src/live.js
CHANGED
|
@@ -112,11 +112,11 @@ function documentsSource(binding, where) {
|
|
|
112
112
|
|
|
113
113
|
/**
|
|
114
114
|
* Recognise the canonical single-level group form (§7): one binding
|
|
115
|
-
* over `$[*]`, optional `$where`,
|
|
115
|
+
* over `$[*]`, optional `$where`, one or more group keys, and a
|
|
116
116
|
* `$return` object whose members are the group key (`$g` plain or
|
|
117
117
|
* defaulted) or a single-member aggregate over the group sequence.
|
|
118
118
|
* @param {any} inner
|
|
119
|
-
* @returns {null | { binding: string,
|
|
119
|
+
* @returns {null | { binding: string, groups: string[], key: any,
|
|
120
120
|
* where: any, members: { name: string, kind: 'key' | 'aggregate',
|
|
121
121
|
* fn?: string, operand?: any, defaulted?: boolean }[] }}
|
|
122
122
|
*/
|
|
@@ -126,18 +126,30 @@ function recogniseGroupForm(inner) {
|
|
|
126
126
|
const allowed = new Set(['$for', '$where', '$groupby', '$return']);
|
|
127
127
|
if (!Object.keys(inner).every((key) => allowed.has(key))) return null;
|
|
128
128
|
const groupNames = Object.keys(inner.$groupby);
|
|
129
|
-
if (groupNames.length
|
|
130
|
-
|
|
129
|
+
if (groupNames.length === 0) return null;
|
|
130
|
+
// Subset evaluation must never read the global collection root.
|
|
131
|
+
const local = (node) => {
|
|
132
|
+
if (typeof node === 'string') return node !== '$' && !/^\$(?:\.|\[)/.test(node);
|
|
133
|
+
if (Array.isArray(node)) return node.every(local);
|
|
134
|
+
if (!isJsonObject(node) || Object.hasOwn(node, '$const')) return true;
|
|
135
|
+
return Object.values(node).every(local);
|
|
136
|
+
};
|
|
137
|
+
if (![inner.$where, inner.$groupby, inner.$return].every(local)) return null;
|
|
138
|
+
const scalar = groupNames.some((name) => inner.$return === `$${name}`)
|
|
139
|
+
|| (isJsonObject(inner.$return) && Object.keys(inner.$return).length === 1
|
|
140
|
+
&& AGGREGATE_MEMBERS.has(Object.keys(inner.$return)[0]));
|
|
141
|
+
if (scalar) return { binding, groups: groupNames,
|
|
142
|
+
key: groupNames.map((name) => [inner.$groupby[name]]), where: inner.$where, members: [] };
|
|
131
143
|
if (!isJsonObject(inner.$return)) return null;
|
|
132
144
|
const members = [];
|
|
133
145
|
for (const name of Object.keys(inner.$return)) {
|
|
134
146
|
const expr = inner.$return[name];
|
|
135
|
-
if (expr === `$${group}`) {
|
|
147
|
+
if (groupNames.some((group) => expr === `$${group}`)) {
|
|
136
148
|
members.push({ name, kind: 'key', defaulted: false });
|
|
137
149
|
continue;
|
|
138
150
|
}
|
|
139
151
|
if (isJsonObject(expr) && Array.isArray(expr.$default)
|
|
140
|
-
&& expr.$default.length === 2 && expr.$default[0] === `$${group}`
|
|
152
|
+
&& expr.$default.length === 2 && groupNames.some((group) => expr.$default[0] === `$${group}`)
|
|
141
153
|
&& expr.$default[1] === null && Object.keys(expr).length === 1) {
|
|
142
154
|
members.push({ name, kind: 'key', defaulted: true });
|
|
143
155
|
continue;
|
|
@@ -150,7 +162,8 @@ function recogniseGroupForm(inner) {
|
|
|
150
162
|
}
|
|
151
163
|
return null;
|
|
152
164
|
}
|
|
153
|
-
return { binding,
|
|
165
|
+
return { binding, groups: groupNames, key: groupNames.map((name) => [inner.$groupby[name]]),
|
|
166
|
+
where: inner.$where, members };
|
|
154
167
|
}
|
|
155
168
|
|
|
156
169
|
/**
|
|
@@ -276,10 +289,16 @@ export function classifyLiveQuery(document, queryShape, keyed, eventTime = null)
|
|
|
276
289
|
|
|
277
290
|
if (aggregate !== null) {
|
|
278
291
|
if (windowed) return rerun('a windowed aggregate maintains no accumulator');
|
|
292
|
+
if (recogniseGroupForm(inner) !== null) {
|
|
293
|
+
const grouped = classifyLiveQuery(inner, queryShape, keyed, eventTime);
|
|
294
|
+
if (grouped.strategy === 'group') return { ...grouped, aggregate: aggregate.name };
|
|
295
|
+
}
|
|
279
296
|
const planned = planQuery({ [aggregate.name]: inner }, queryShape, {});
|
|
280
297
|
if (planned.mode !== 'native' || planned.plan.aggregate === null) {
|
|
281
298
|
return rerun(refinedOnly(planned, false) ? SPATIAL_RERUN.aggregate : plannerReason(planned));
|
|
282
299
|
}
|
|
300
|
+
if (planned.plan.group !== null || planned.plan.bucket !== null)
|
|
301
|
+
return rerun('an aggregate over groups needs group-level maintenance');
|
|
283
302
|
if (!keyed) return rerun('rows without a document key cannot be tracked');
|
|
284
303
|
return {
|
|
285
304
|
strategy: 'accumulator',
|
|
@@ -300,19 +319,9 @@ export function classifyLiveQuery(document, queryShape, keyed, eventTime = null)
|
|
|
300
319
|
: `the group filter did not translate: ${plannerReason(planned)}`);
|
|
301
320
|
}
|
|
302
321
|
if (!keyed) return rerun('rows without a document key cannot be tracked');
|
|
303
|
-
const rowDocument = {
|
|
304
|
-
$for: { [group.binding]: '$[*]' },
|
|
305
|
-
...(group.where !== undefined ? { $where: group.where } : {}),
|
|
306
|
-
$return: {
|
|
307
|
-
k: [group.groupExpr],
|
|
308
|
-
...Object.fromEntries(group.members
|
|
309
|
-
.filter((member) => member.kind === 'aggregate')
|
|
310
|
-
.map((member) => [member.name, [member.operand]])),
|
|
311
|
-
},
|
|
312
|
-
};
|
|
313
322
|
return {
|
|
314
|
-
strategy: 'group', group,
|
|
315
|
-
deps: memberDeps(analyzeQuery(
|
|
323
|
+
strategy: 'group', inner, binding: group.binding, key: group.key, carrier,
|
|
324
|
+
deps: memberDeps(analyzeQuery(inner).root),
|
|
316
325
|
};
|
|
317
326
|
}
|
|
318
327
|
|
|
@@ -324,6 +333,16 @@ export function classifyLiveQuery(document, queryShape, keyed, eventTime = null)
|
|
|
324
333
|
return rerun('a k-nearest ranking re-runs (the vector column cuts the candidates and the engine orders them)');
|
|
325
334
|
|
|
326
335
|
const planned = planQuery(inner, queryShape, {});
|
|
336
|
+
const distinct = isJsonObject(inner) ? inner.$distinct : null;
|
|
337
|
+
if (!windowed && keyed && planned.mode === 'native' && isJsonObject(distinct)
|
|
338
|
+
&& distinct.$orderby === undefined && bindingNameOf(distinct) !== null) {
|
|
339
|
+
const binding = bindingNameOf(distinct);
|
|
340
|
+
const name = binding === '_distinct' ? '_distinctKey' : '_distinct';
|
|
341
|
+
const grouped = { ...distinct, $groupby: { [name]: distinct.$return }, $return: `$${name}` };
|
|
342
|
+
return { strategy: 'distinct', inner: grouped, binding, key: [distinct.$return],
|
|
343
|
+
carrier: documentsSource(binding, distinct.$where),
|
|
344
|
+
deps: memberDeps(analyzeQuery(grouped).root) };
|
|
345
|
+
}
|
|
327
346
|
if (planned.mode === 'set') {
|
|
328
347
|
// §7's spatial rows: the fetch is SQL-narrowed by the pushed box or
|
|
329
348
|
// cell range and the exact predicate is what per-row re-evaluation
|
|
@@ -346,6 +365,13 @@ export function classifyLiveQuery(document, queryShape, keyed, eventTime = null)
|
|
|
346
365
|
}
|
|
347
366
|
if (!keyed) return rerun('rows without a document key cannot be tracked');
|
|
348
367
|
|
|
368
|
+
// SQL can group a complete selection without proving that evaluating
|
|
369
|
+
// the document once per changed row maintains its result. Canonical
|
|
370
|
+
// maintained groups were handled above; the remaining groups and
|
|
371
|
+
// distinct projections need set-level invalidation.
|
|
372
|
+
if (planned.plan.group !== null || planned.plan.bucket !== null)
|
|
373
|
+
return rerun('this grouping or distinct projection needs set-level maintenance');
|
|
374
|
+
|
|
349
375
|
if (planned.plan.order !== null) {
|
|
350
376
|
if (offset > 0 || (planned.plan.window?.offset ?? 0) > 0) {
|
|
351
377
|
return rerun('an offset window re-runs');
|
|
@@ -713,121 +739,6 @@ function accumulatorStrategy(description, context) {
|
|
|
713
739
|
};
|
|
714
740
|
}
|
|
715
741
|
|
|
716
|
-
/**
|
|
717
|
-
* Canonical single-level `groupBy` with aggregate returns: §7's
|
|
718
|
-
* per-group deltas — the accumulator machinery once per group, groups
|
|
719
|
-
* in first-appearance order.
|
|
720
|
-
* @param {any} description
|
|
721
|
-
* @param {any} context
|
|
722
|
-
*/
|
|
723
|
-
function groupStrategy(description, context) {
|
|
724
|
-
const { group, rowDocument, carrier } = description;
|
|
725
|
-
const evaluate = compileJsonQuery([rowDocument]);
|
|
726
|
-
|
|
727
|
-
/** @type {Map<string, { key: any[], rows: Map<string, any>, row: any }>}
|
|
728
|
-
* group token → per-row contributions, in first-appearance order */
|
|
729
|
-
const groups = new Map();
|
|
730
|
-
|
|
731
|
-
const contributionOf = (doc) => {
|
|
732
|
-
const evaluated = /** @type {any[]} */ (evaluate([doc], context.externals));
|
|
733
|
-
return evaluated.length === 0 ? undefined : evaluated[0];
|
|
734
|
-
};
|
|
735
|
-
const buildRow = (entry) => {
|
|
736
|
-
/** @type {any} */
|
|
737
|
-
const row = {};
|
|
738
|
-
for (const member of group.members) {
|
|
739
|
-
if (member.kind === 'key') {
|
|
740
|
-
if (entry.key.length > 0) row[member.name] = entry.key[0];
|
|
741
|
-
else if (member.defaulted) row[member.name] = null;
|
|
742
|
-
continue;
|
|
743
|
-
}
|
|
744
|
-
let sum = 0;
|
|
745
|
-
let count = 0;
|
|
746
|
-
let extreme;
|
|
747
|
-
let n = 0;
|
|
748
|
-
for (const contribution of entry.rows.values()) {
|
|
749
|
-
const items = /** @type {any[]} */ (contribution[member.name] ?? []);
|
|
750
|
-
n += items.length;
|
|
751
|
-
for (const value of items) {
|
|
752
|
-
sum += value;
|
|
753
|
-
count += 1;
|
|
754
|
-
if (extreme === undefined
|
|
755
|
-
|| (member.fn === 'min' ? value < extreme : value > extreme)) extreme = value;
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
if (member.fn === 'count') row[member.name] = n;
|
|
759
|
-
else if (member.fn === 'sum') row[member.name] = sum;
|
|
760
|
-
else if (count > 0) row[member.name] = member.fn === 'avg' ? sum / count : extreme;
|
|
761
|
-
// an empty avg/min/max leaves the member absent, the engine's
|
|
762
|
-
// empty-sequence rule
|
|
763
|
-
}
|
|
764
|
-
return row;
|
|
765
|
-
};
|
|
766
|
-
const rowsOf = () => [...groups.values()].map((entry) => entry.row);
|
|
767
|
-
|
|
768
|
-
const placeRow = (token, doc, changedGroups) => {
|
|
769
|
-
const contribution = doc === undefined ? undefined : contributionOf(doc);
|
|
770
|
-
for (const [groupToken, entry] of groups) {
|
|
771
|
-
if (!entry.rows.has(token)) continue;
|
|
772
|
-
if (contribution !== undefined
|
|
773
|
-
&& stableStringify(entry.rows.get(token)) === stableStringify(contribution)) {
|
|
774
|
-
return; // unchanged in place
|
|
775
|
-
}
|
|
776
|
-
entry.rows.delete(token);
|
|
777
|
-
changedGroups.add(groupToken);
|
|
778
|
-
break;
|
|
779
|
-
}
|
|
780
|
-
if (contribution === undefined) return;
|
|
781
|
-
const groupToken = stableStringify(contribution.k) ?? '';
|
|
782
|
-
let entry = groups.get(groupToken);
|
|
783
|
-
if (entry === undefined) {
|
|
784
|
-
entry = { key: contribution.k, rows: new Map(), row: null };
|
|
785
|
-
groups.set(groupToken, entry);
|
|
786
|
-
}
|
|
787
|
-
entry.rows.set(token, contribution);
|
|
788
|
-
changedGroups.add(groupToken);
|
|
789
|
-
};
|
|
790
|
-
const settle = (changedGroups) => {
|
|
791
|
-
for (const groupToken of changedGroups) {
|
|
792
|
-
const entry = groups.get(groupToken);
|
|
793
|
-
if (entry === undefined) continue;
|
|
794
|
-
if (entry.rows.size === 0) groups.delete(groupToken);
|
|
795
|
-
else entry.row = sharedRow(entry.row, buildRow(entry));
|
|
796
|
-
}
|
|
797
|
-
};
|
|
798
|
-
|
|
799
|
-
return {
|
|
800
|
-
init: () => chain(context.execute([carrier], { externals: context.externals }),
|
|
801
|
-
(docs) => {
|
|
802
|
-
const changedGroups = new Set();
|
|
803
|
-
for (const doc of /** @type {any[]} */ (docs)) {
|
|
804
|
-
placeRow(context.keyOf(doc), doc, changedGroups);
|
|
805
|
-
}
|
|
806
|
-
settle(changedGroups);
|
|
807
|
-
return rowsOf();
|
|
808
|
-
}),
|
|
809
|
-
entries: () => {
|
|
810
|
-
let total = groups.size;
|
|
811
|
-
for (const entry of groups.values()) total += entry.rows.size;
|
|
812
|
-
return total;
|
|
813
|
-
},
|
|
814
|
-
apply(record, previousRows) {
|
|
815
|
-
const touched = touchedKeys(record, context.name, description.deps);
|
|
816
|
-
if (touched === null) return null;
|
|
817
|
-
const changedGroups = new Set();
|
|
818
|
-
for (const [token, change] of touched) {
|
|
819
|
-
const doc = change.kind === 'delete'
|
|
820
|
-
? undefined
|
|
821
|
-
: change.kind === 'insert' ? change.doc : context.readRow(token);
|
|
822
|
-
placeRow(token, doc, changedGroups);
|
|
823
|
-
}
|
|
824
|
-
if (changedGroups.size === 0) return null;
|
|
825
|
-
settle(changedGroups);
|
|
826
|
-
return diffAgainst(previousRows, rowsOf());
|
|
827
|
-
},
|
|
828
|
-
};
|
|
829
|
-
}
|
|
830
|
-
|
|
831
742
|
/**
|
|
832
743
|
* Everything outside the table: re-run the WHOLE query on
|
|
833
744
|
* invalidation and diff against the previous result with value-equal
|
|
@@ -903,7 +814,8 @@ export function createLiveRegistry(bounds) {
|
|
|
903
814
|
};
|
|
904
815
|
const STRATEGIES = {
|
|
905
816
|
rows: rowsStrategy, window: windowStrategy, accumulator: accumulatorStrategy,
|
|
906
|
-
group:
|
|
817
|
+
group: nestedGroupStrategy, distinct: nestedGroupStrategy,
|
|
818
|
+
bucket: bucketStrategy, rolling: rollingStrategy, join: joinStrategy, graph: joinStrategy,
|
|
907
819
|
'nested-group': nestedGroupStrategy,
|
|
908
820
|
};
|
|
909
821
|
const strategy = (STRATEGIES[classification.strategy] ?? rerunStrategy)(
|