@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/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`, `$groupby` with ONE binding, and a
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, group: string, groupExpr: any,
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 !== 1) return null;
130
- const group = groupNames[0];
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, group, groupExpr: inner.$groupby[group], where: inner.$where, members };
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, carrier, rowDocument,
315
- deps: memberDeps(analyzeQuery([rowDocument]).root),
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: groupStrategy, bucket: bucketStrategy, rolling: rollingStrategy, join: joinStrategy, graph: joinStrategy,
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)(