@jarenjs/db 0.46.4 → 0.49.2

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/query.js CHANGED
@@ -58,15 +58,22 @@ import {
58
58
  * residual.
59
59
  * @param {number} [bound]
60
60
  * @param {{ functions?: any, extensions?: any } | null} [operators]
61
+ * @param {any} [zoneProvider] - D7's injected clock, or absent
61
62
  * @returns {any}
62
63
  */
63
- export function createQueryState(bound = undefined, operators = null) {
64
+ export function createQueryState(bound = undefined, operators = null,
65
+ zoneProvider = undefined) {
64
66
  return {
65
67
  cache: createSemanticCache(bound ?? 128),
66
68
  counters: { hits: 0, misses: 0, evictions: 0 },
67
69
  /** Fragment identity → the SQL function name registered for it. */
68
70
  registered: new Map(),
69
71
  operators: operators ?? null,
72
+ // D7's injected clock: a named zone is host code a database does
73
+ // not have, so a calendar ladder over one walks in the residual —
74
+ // and the residual is the caller's OWN document, so the frozen spec
75
+ // reaches the kernel unchanged rather than being rebuilt in UTC
76
+ zoneProvider: zoneProvider ?? null,
70
77
  };
71
78
  }
72
79
 
@@ -135,11 +142,16 @@ export function createQueryEngine(context) {
135
142
  // residual compilation here. `null` when the store opened with no
136
143
  // registry — the whole engine is then byte-identical to before.
137
144
  const operators = state.operators ?? null;
145
+ const zoneProvider = state.zoneProvider ?? null;
138
146
  const dialect = connection.dialect;
139
147
  const shape = {
140
148
  collection: collection.name,
141
149
  schema: collection.schema,
142
150
  columnByCanonical: physicalPlan.columnByCanonical,
151
+ // the DECLARED indexes, in their declared column order: a temporal
152
+ // plan is recognized by the index a model already has, so the
153
+ // planner needs to see the list rather than guess a column's role
154
+ indexes: physicalPlan.expected?.indexes ?? [],
143
155
  // the R*Tree virtual tables this collection's `bbox` column sets are
144
156
  // realized as, by stem — empty under `physical: 'columns'`, and
145
157
  // empty on a driver whose build carries no R*Tree module, because
@@ -163,6 +175,14 @@ export function createQueryEngine(context) {
163
175
  * a k-nearest query into a full scan that `explain()` still calls
164
176
  * `knn`, which is the one thing this mode may not do quietly. */
165
177
  const knnStats = { queries: 0, rows: 0, candidates: 0, fullFetches: 0, diverted: 0 };
178
+ /** The temporal counters `stats()` reports, and the ONLY place a
179
+ * candidate or a result count comes from: `explain()` reads the LAST
180
+ * ACTUAL run's numbers off the cache entry rather than estimating
181
+ * any of them. `statements` is what makes the as-of bound checkable —
182
+ * one narrowing fetch per call, whatever the probes number — and
183
+ * `diverted` counts the calls whose native bucket met a group with no
184
+ * instant and handed the whole question back to the engine. */
185
+ const seriesStats = { queries: 0, statements: 0, candidates: 0, results: 0, diverted: 0 };
166
186
  /** The by-identities fetch statements, one per batch size. */
167
187
  const identityFetch = new Map();
168
188
 
@@ -171,13 +191,15 @@ export function createQueryEngine(context) {
171
191
  const residualCompileOptions = (limits) => {
172
192
  const functions = operators?.functions;
173
193
  const extensions = operators?.extensions;
174
- if (limits === undefined && functions === undefined && extensions === undefined)
194
+ if (limits === undefined && functions === undefined && extensions === undefined
195
+ && zoneProvider === null)
175
196
  return undefined;
176
197
  /** @type {any} */
177
198
  const options = {};
178
199
  if (limits !== undefined) options.limits = limits;
179
200
  if (functions !== undefined) options.functions = functions;
180
201
  if (extensions !== undefined) options.extensions = extensions;
202
+ if (zoneProvider !== null) options.zoneProvider = zoneProvider;
181
203
  return options;
182
204
  };
183
205
 
@@ -302,10 +324,13 @@ export function createQueryEngine(context) {
302
324
  setResidual: null,
303
325
  packedResidual: null,
304
326
  rowResidual: planned.mode === 'row'
305
- ? compileRowResidual(planned.rowReturn, limits, operators)
327
+ ? compileRowResidual(planned.rowReturn, limits, operators, zoneProvider)
306
328
  : null,
307
329
  fullScanSql: null,
308
330
  fullScanShape: () => shapePlan(selectPlan(collection.name)),
331
+ // the LAST actual execution's numbers, never an estimate: `null`
332
+ // until this document has run once
333
+ seriesCounts: null,
309
334
  };
310
335
 
311
336
  const sizeBefore = state.cache.size();
@@ -320,7 +345,8 @@ export function createQueryEngine(context) {
320
345
  };
321
346
  const setResidualOf = (entry, document) => {
322
347
  if (entry.setResidual === null)
323
- entry.setResidual = compileSetResidual(document, entry.residualLimits, operators);
348
+ entry.setResidual = compileSetResidual(document, entry.residualLimits, operators,
349
+ zoneProvider);
324
350
  return entry.setResidual;
325
351
  };
326
352
  /** The item-packing variant for cursors: `[document]` packs the
@@ -397,6 +423,68 @@ export function createQueryEngine(context) {
397
423
 
398
424
  const rowsToDocs = (rows) => rows.map((row) => JSON.parse(row.doc));
399
425
 
426
+ /**
427
+ * The bucket records a native temporal group answers: the ladder's
428
+ * start under the name the document asked for it by, then one member
429
+ * per aggregate with the caller's own word for "no numbers".
430
+ *
431
+ * `null` when a group has no instant at all — a row whose instant
432
+ * member is missing or is not a number groups under SQL `NULL`, and
433
+ * the kernel REFUSES such a row (`JQ2001`). SQL cannot refuse, so the
434
+ * call diverts and the engine answers, exactly as it does everywhere.
435
+ * @param {any} entry
436
+ * @param {any[]} rows
437
+ * @returns {any[] | null}
438
+ */
439
+ const bucketItems = (entry, rows) => {
440
+ const bucket = entry.plan.bucket;
441
+ const items = [];
442
+ for (const row of rows) {
443
+ const start = row[bucket.as] ?? null;
444
+ if (start === null) return null;
445
+ /** @type {any} */
446
+ const item = { [bucket.as]: start };
447
+ for (const aggregate of bucket.aggregates) {
448
+ const value = row[aggregate.as] ?? null;
449
+ if (value === null && aggregate.empty === 'omit') continue;
450
+ item[aggregate.as] = value === null && aggregate.empty === 'zero' ? 0 : value;
451
+ }
452
+ items.push(item);
453
+ }
454
+ return items;
455
+ };
456
+
457
+ /** How many ITEMS an engine result carries (its own shape rule). */
458
+ const itemCount = (answer) => (answer === undefined ? 0
459
+ : Array.isArray(answer) ? answer.length : 1);
460
+
461
+ /**
462
+ * A native bucket met a group with no instant. The kernel refuses
463
+ * such a row, and SQL cannot, so the whole question goes back to the
464
+ * engine over the whole collection — the same diversion a k-nearest
465
+ * probe of the wrong width takes, and counted the same way.
466
+ */
467
+ const divertBucket = (entry, document, externals) => {
468
+ seriesStats.diverted++;
469
+ return chain(fullScanOf(entry), (statement) =>
470
+ chain(statement.all(fullScanParams(entry)), (rows) => {
471
+ const docs = rowsToDocs(checkRowBound(entry, rows));
472
+ const answer = setResidualOf(entry, document)(docs, externals);
473
+ countSeries(entry, 2, docs.length, itemCount(answer));
474
+ return answer;
475
+ }));
476
+ };
477
+
478
+ /** Record one actual execution against the entry and the store. */
479
+ const countSeries = (entry, statements, candidates, results) => {
480
+ if (entry.planned.series === null) return;
481
+ seriesStats.queries++;
482
+ seriesStats.statements += statements;
483
+ seriesStats.candidates += candidates;
484
+ seriesStats.results += results;
485
+ entry.seriesCounts = { statements, candidates, results };
486
+ };
487
+
400
488
  /**
401
489
  * The documents of the given row identities, in identity order,
402
490
  * through the dialect's by-identities statement — batched, and each
@@ -512,8 +600,11 @@ export function createQueryEngine(context) {
512
600
  // the k-nearest cut — and the full document over them, which
513
601
  // re-applies its own predicates and ordering (idempotent
514
602
  // narrowing: the fetch decided nothing)
515
- return chain(candidatesOf(entry, externals, diverted), (docs) =>
516
- setResidualOf(entry, document)(docs, externals));
603
+ return chain(candidatesOf(entry, externals, diverted), (docs) => {
604
+ const answer = setResidualOf(entry, document)(docs, externals);
605
+ countSeries(entry, 1, docs.length, itemCount(answer));
606
+ return answer;
607
+ });
517
608
  }
518
609
  if (entry.planned.mode === 'row') {
519
610
  return chain(statementOf(entry), (statement) =>
@@ -529,8 +620,19 @@ export function createQueryEngine(context) {
529
620
  return chain(statement.get(bindParams(entry, externals)),
530
621
  (row) => aggregateResult(entry, row));
531
622
  }
532
- return chain(statement.all(bindParams(entry, externals)),
533
- (rows) => sequenceResult(rowsToDocs(checkRowBound(entry, rows))));
623
+ if (entry.plan.bucket !== null) {
624
+ return chain(statement.all(bindParams(entry, externals)), (rows) => {
625
+ const items = bucketItems(entry, checkRowBound(entry, rows));
626
+ if (items === null) return divertBucket(entry, document, externals);
627
+ countSeries(entry, 1, rows.length, items.length);
628
+ return sequenceResult(items);
629
+ });
630
+ }
631
+ return chain(statement.all(bindParams(entry, externals)), (rows) => {
632
+ const docs = rowsToDocs(checkRowBound(entry, rows));
633
+ countSeries(entry, 1, docs.length, docs.length);
634
+ return sequenceResult(docs);
635
+ });
534
636
  });
535
637
  });
536
638
  };
@@ -571,6 +673,7 @@ export function createQueryEngine(context) {
571
673
  chain(candidatesOf(entry, externals, diverted), (docs) => {
572
674
  buffered = packedResidualOf(entry, document)(docs, externals);
573
675
  bufferedAt = 0;
676
+ countSeries(entry, 1, docs.length, buffered.length);
574
677
  })));
575
678
  }
576
679
  return materialized.then(() => {
@@ -579,6 +682,33 @@ export function createQueryEngine(context) {
579
682
  return { done: true, value: undefined };
580
683
  });
581
684
  }
685
+ if (entry.plan.bucket !== null) {
686
+ // a native bucket is a barrier: the groups are the answer
687
+ if (materialized === null) {
688
+ materialized = Promise.resolve(chain(guardScan(entry), () =>
689
+ chain(statementOf(entry), (statement) =>
690
+ chain(statement.all(bindParams(entry, externals)), (rows) => {
691
+ const items = bucketItems(entry, checkRowBound(entry, rows));
692
+ if (items === null) {
693
+ const answer = divertBucket(entry, document, externals);
694
+ return chain(answer, (value) => {
695
+ buffered = value === undefined ? []
696
+ : Array.isArray(value) ? value : [value];
697
+ bufferedAt = 0;
698
+ });
699
+ }
700
+ countSeries(entry, 1, rows.length, items.length);
701
+ buffered = items;
702
+ bufferedAt = 0;
703
+ return null;
704
+ }))));
705
+ }
706
+ return materialized.then(() => {
707
+ if (bufferedAt < buffered.length) return nextFromBuffer();
708
+ done = true;
709
+ return { done: true, value: undefined };
710
+ });
711
+ }
582
712
  if (entry.plan.aggregate !== null) {
583
713
  // a native aggregate yields exactly one item
584
714
  if (materialized === null) {
@@ -639,7 +769,9 @@ export function createQueryEngine(context) {
639
769
  * The explanation record: the engine explain shape plus the pushdown
640
770
  * facts. `estimatedRows` is deliberately ABSENT — the capability
641
771
  * slot is empty on SQLite and no number is fabricated; the
642
- * database's own plan prose rides in `scanNarrative` instead.
772
+ * database's own plan prose rides in `scanNarrative` instead. So is
773
+ * every count in `series`: they are the LAST ACTUAL execution's, and
774
+ * `null` until this document has run once.
643
775
  * @param {any} document
644
776
  * @param {{ externals?: any, strict?: boolean }} [options]
645
777
  */
@@ -668,6 +800,12 @@ export function createQueryEngine(context) {
668
800
  if (term.ref.column !== null) touchedColumns.add(term.ref.column);
669
801
  }
670
802
  if (entry.plan.aggregate?.ref?.column) touchedColumns.add(entry.plan.aggregate.ref.column);
803
+ if (entry.plan.bucket !== null) {
804
+ if (entry.plan.bucket.ref.column) touchedColumns.add(entry.plan.bucket.ref.column);
805
+ for (const aggregate of entry.plan.bucket.aggregates) {
806
+ if (aggregate.ref?.column) touchedColumns.add(aggregate.ref.column);
807
+ }
808
+ }
671
809
  const indexes = [
672
810
  ...physicalPlan.expected.indexes
673
811
  .filter((index) => index.columns.some((column) => touchedColumns.has(column)))
@@ -718,11 +856,21 @@ export function createQueryEngine(context) {
718
856
  ? entry.planned.reasons.map((r) => ({ operator: r.construct, reason: r.reason }))
719
857
  : [],
720
858
  udfs: [...entry.planned.udfs],
859
+ // the temporal record: what the document asked, which declared
860
+ // index the fetch seeks through, and which kernel finished it.
861
+ // The counts are the LAST ACTUAL execution's — `null` before
862
+ // this document has run — because an estimate mislabelled as a
863
+ // count is exactly the thing an honest explain may not print
864
+ series: entry.planned.series === null ? null : {
865
+ ...entry.planned.series,
866
+ counts: entry.seriesCounts === null ? null : { ...entry.seriesCounts },
867
+ },
721
868
  scanNarrative: rows.map((row) => String(row.detail)).join('; '),
722
869
  })));
723
870
  };
724
871
 
725
- return { execute, query, explain, shape, stats: () => ({ knn: { ...knnStats } }) };
872
+ return { execute, query, explain, shape,
873
+ stats: () => ({ knn: { ...knnStats }, series: { ...seriesStats } }) };
726
874
  }
727
875
 
728
876
  // ————— The entity query surface (the second document kind) —————
@@ -745,6 +893,7 @@ export const INCLUDE_DEPTH_DEFAULT = 3;
745
893
  export function createEntityQueryEngine(context) {
746
894
  const { connection, entities, mapping, state } = context;
747
895
  const operators = state.operators ?? null;
896
+ const zoneProvider = state.zoneProvider ?? null;
748
897
  const dialect = connection.dialect;
749
898
  const q = dialect.quoteIdentifier;
750
899
  const physicalOf = (name) => ({ table: mapping.entities[name].table });
@@ -810,7 +959,7 @@ export function createEntityQueryEngine(context) {
810
959
 
811
960
  const runResidual = (entry, document, externals) => {
812
961
  if (entry.setResidual === null)
813
- entry.setResidual = compileSetResidual(document, undefined, operators);
962
+ entry.setResidual = compileSetResidual(document, undefined, operators, zoneProvider);
814
963
  return chain(fetchRoot(entry), (root) => entry.setResidual(root, externals));
815
964
  };
816
965
 
package/src/residual.js CHANGED
@@ -36,16 +36,22 @@ import { compileJsonQuery } from '@jarenjs/json/query';
36
36
  * @param {{ functions?: any, extensions?: any } | null} [operators]
37
37
  * @returns {any}
38
38
  */
39
- function residualOptions(limits, operators) {
39
+ function residualOptions(limits, operators, zoneProvider) {
40
40
  const functions = operators?.functions;
41
41
  const extensions = operators?.extensions;
42
- if (limits === undefined && functions === undefined && extensions === undefined)
42
+ const clock = zoneProvider ?? null;
43
+ if (limits === undefined && functions === undefined && extensions === undefined
44
+ && clock === null)
43
45
  return undefined;
44
46
  /** @type {any} */
45
47
  const options = {};
46
48
  if (limits !== undefined) options.limits = limits;
47
49
  if (functions !== undefined) options.functions = functions;
48
50
  if (extensions !== undefined) options.extensions = extensions;
51
+ // D7's injected clock: without it a named zone is a compile refusal,
52
+ // which is what the language wants — being right for eight months of
53
+ // the year is exactly what a silent UTC fallback would be
54
+ if (clock !== null) options.zoneProvider = clock;
49
55
  return options;
50
56
  }
51
57
 
@@ -57,10 +63,12 @@ function residualOptions(limits, operators) {
57
63
  * @param {any} [limits]
58
64
  * @param {{ functions?: any, extensions?: any } | null} [operators] -
59
65
  * the store's registered operators, so the residual can evaluate them
66
+ * @param {any} [zoneProvider] - D7's injected clock, so a calendar
67
+ * ladder on a named zone compiles rather than being refused
60
68
  * @returns {(candidates: any[], externals: any) => any}
61
69
  */
62
- export function compileSetResidual(document, limits, operators) {
63
- const compiled = compileJsonQuery(document, residualOptions(limits, operators));
70
+ export function compileSetResidual(document, limits, operators, zoneProvider) {
71
+ const compiled = compileJsonQuery(document, residualOptions(limits, operators, zoneProvider));
64
72
  return (candidates, externals) => compiled(candidates, externals);
65
73
  }
66
74
 
@@ -73,10 +81,11 @@ export function compileSetResidual(document, limits, operators) {
73
81
  * the only place that knows the name.
74
82
  * @param {any} [limits]
75
83
  * @param {{ functions?: any, extensions?: any } | null} [operators]
84
+ * @param {any} [zoneProvider] - D7's injected clock
76
85
  * @returns {(row: any, externals: any) => any[]} the row's items
77
86
  */
78
- export function compileRowResidual(rowDocument, limits, operators) {
79
- const compiled = compileJsonQuery(rowDocument, residualOptions(limits, operators));
87
+ export function compileRowResidual(rowDocument, limits, operators, zoneProvider) {
88
+ const compiled = compileJsonQuery(rowDocument, residualOptions(limits, operators, zoneProvider));
80
89
  return (row, externals) => {
81
90
  const packed = compiled([row], externals);
82
91
  // one binding → exactly one packed array of that row's items