@jarenjs/db 0.46.5 → 0.56.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 (72) hide show
  1. package/ARCHITECTURE.md +133 -17
  2. package/README.md +270 -36
  3. package/docs/JOBS-FORMAT.md +24 -8
  4. package/docs/LIVE-FORMAT.md +139 -7
  5. package/docs/MIGRATION-FORMAT.md +118 -36
  6. package/docs/MODEL-FORMAT.md +251 -30
  7. package/package.json +4 -5
  8. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  9. package/schemas/jaren-migration.schema.json +73 -0
  10. package/src/algebra.js +22 -3
  11. package/src/capture.js +66 -28
  12. package/src/cli.js +225 -44
  13. package/src/ddl.js +23 -3
  14. package/src/dialect.js +13 -0
  15. package/src/dialects/sqlite.js +21 -1
  16. package/src/driver.js +63 -16
  17. package/src/drivers/wasm.js +1 -0
  18. package/src/emit-model.js +14 -0
  19. package/src/emit.js +42 -9
  20. package/src/entity.js +92 -47
  21. package/src/errors.js +28 -0
  22. package/src/index.js +2 -2
  23. package/src/jobs.js +40 -5
  24. package/src/live-time.js +605 -0
  25. package/src/live.js +52 -9
  26. package/src/migrate.js +397 -191
  27. package/src/model.js +173 -8
  28. package/src/plan.js +834 -47
  29. package/src/query.js +296 -22
  30. package/src/residual.js +15 -6
  31. package/src/series.js +349 -0
  32. package/src/store.js +243 -69
  33. package/src/tracker.js +173 -48
  34. package/types/index.d.ts +206 -12
  35. package/types/node.d.ts +3 -1
  36. package/types/typed.d.ts +58 -2
  37. package/types/wasm.d.ts +7 -0
  38. package/dist/types/algebra.d.ts +0 -199
  39. package/dist/types/app.d.ts +0 -49
  40. package/dist/types/capture.d.ts +0 -85
  41. package/dist/types/cli.d.ts +0 -2
  42. package/dist/types/dag-job.d.ts +0 -40
  43. package/dist/types/ddl.d.ts +0 -229
  44. package/dist/types/derive.d.ts +0 -250
  45. package/dist/types/dialect.d.ts +0 -149
  46. package/dist/types/dialects/sqlite.d.ts +0 -9
  47. package/dist/types/driver.d.ts +0 -110
  48. package/dist/types/drivers/bun.d.ts +0 -47
  49. package/dist/types/drivers/node.d.ts +0 -37
  50. package/dist/types/drivers/wasm.d.ts +0 -65
  51. package/dist/types/emit-model.d.ts +0 -44
  52. package/dist/types/emit.d.ts +0 -75
  53. package/dist/types/entity.d.ts +0 -23
  54. package/dist/types/errors.d.ts +0 -167
  55. package/dist/types/graph.d.ts +0 -28
  56. package/dist/types/index.d.ts +0 -37
  57. package/dist/types/jobs.d.ts +0 -140
  58. package/dist/types/knn.d.ts +0 -69
  59. package/dist/types/live.d.ts +0 -62
  60. package/dist/types/migrate.d.ts +0 -170
  61. package/dist/types/model.d.ts +0 -36
  62. package/dist/types/patch-sql.d.ts +0 -37
  63. package/dist/types/plan.d.ts +0 -140
  64. package/dist/types/profile.d.ts +0 -80
  65. package/dist/types/query.d.ts +0 -111
  66. package/dist/types/residual.d.ts +0 -61
  67. package/dist/types/store.d.ts +0 -53
  68. package/dist/types/tracker.d.ts +0 -43
  69. package/dist/types/typed.d.ts +0 -15
  70. package/dist/types/types.d.ts +0 -26
  71. package/dist/types/udf.d.ts +0 -75
  72. package/dist/types/window.d.ts +0 -52
package/src/query.js CHANGED
@@ -39,7 +39,7 @@ import { chain } from './driver.js';
39
39
  import {
40
40
  planQuery, planEntityQuery, entityShape, planEntityPredicate, entityPathRef,
41
41
  } from './plan.js';
42
- import { emitPlan, emitEntityPlan, createEntityPredicateEmitters } from './emit.js';
42
+ import { emitPlan, emitEntityPlan, createEntityPredicateEmitters, UnrepresentablePath } from './emit.js';
43
43
  import { selectPlan } from './algebra.js';
44
44
  import { compileSetResidual, compileRowResidual, sequenceResult } from './residual.js';
45
45
  import { derivedSlotValue, probeBox, probeVector, columnScore } from './derive.js';
@@ -58,18 +58,52 @@ 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
 
80
+ /**
81
+ * The answer for a native selection's items: the engine's result shape
82
+ * (`undefined | item | items`), or — when the document is a chain's
83
+ * element WINDOW, `[<phrase>]` (plan.js, `wrapped`) — the items as the
84
+ * ONE array that constructor yields, never singleton-unwrapped: an empty
85
+ * selection is `[]`, one row is `[row]`. Exactly the engine's answer for
86
+ * the same document, which is what lets a chain's `toArray()` push.
87
+ * @param {any} entry
88
+ * @param {any[]} items
89
+ * @returns {any}
90
+ */
91
+ function answerOf(entry, items) {
92
+ return entry.planned.wrapped === true ? items : sequenceResult(items);
93
+ }
94
+
95
+ /**
96
+ * The same for one aggregate value: `[value]` under the window, and `[]`
97
+ * for an aggregate that answers nothing.
98
+ * @param {any} entry
99
+ * @param {any} value
100
+ * @returns {any}
101
+ */
102
+ function wrapValue(entry, value) {
103
+ if (entry.planned.wrapped !== true) return value;
104
+ return value === undefined ? [] : [value];
105
+ }
106
+
73
107
  /** @param {any} value - a bindable native parameter? */
74
108
  function bindable(value) {
75
109
  return typeof value === 'string'
@@ -135,11 +169,16 @@ export function createQueryEngine(context) {
135
169
  // residual compilation here. `null` when the store opened with no
136
170
  // registry — the whole engine is then byte-identical to before.
137
171
  const operators = state.operators ?? null;
172
+ const zoneProvider = state.zoneProvider ?? null;
138
173
  const dialect = connection.dialect;
139
174
  const shape = {
140
175
  collection: collection.name,
141
176
  schema: collection.schema,
142
177
  columnByCanonical: physicalPlan.columnByCanonical,
178
+ // the DECLARED indexes, in their declared column order: a temporal
179
+ // plan is recognized by the index a model already has, so the
180
+ // planner needs to see the list rather than guess a column's role
181
+ indexes: physicalPlan.expected?.indexes ?? [],
143
182
  // the R*Tree virtual tables this collection's `bbox` column sets are
144
183
  // realized as, by stem — empty under `physical: 'columns'`, and
145
184
  // empty on a driver whose build carries no R*Tree module, because
@@ -163,6 +202,14 @@ export function createQueryEngine(context) {
163
202
  * a k-nearest query into a full scan that `explain()` still calls
164
203
  * `knn`, which is the one thing this mode may not do quietly. */
165
204
  const knnStats = { queries: 0, rows: 0, candidates: 0, fullFetches: 0, diverted: 0 };
205
+ /** The temporal counters `stats()` reports, and the ONLY place a
206
+ * candidate or a result count comes from: `explain()` reads the LAST
207
+ * ACTUAL run's numbers off the cache entry rather than estimating
208
+ * any of them. `statements` is what makes the as-of bound checkable —
209
+ * one narrowing fetch per call, whatever the probes number — and
210
+ * `diverted` counts the calls whose native bucket met a group with no
211
+ * instant and handed the whole question back to the engine. */
212
+ const seriesStats = { queries: 0, statements: 0, candidates: 0, results: 0, diverted: 0 };
166
213
  /** The by-identities fetch statements, one per batch size. */
167
214
  const identityFetch = new Map();
168
215
 
@@ -171,13 +218,15 @@ export function createQueryEngine(context) {
171
218
  const residualCompileOptions = (limits) => {
172
219
  const functions = operators?.functions;
173
220
  const extensions = operators?.extensions;
174
- if (limits === undefined && functions === undefined && extensions === undefined)
221
+ if (limits === undefined && functions === undefined && extensions === undefined
222
+ && zoneProvider === null)
175
223
  return undefined;
176
224
  /** @type {any} */
177
225
  const options = {};
178
226
  if (limits !== undefined) options.limits = limits;
179
227
  if (functions !== undefined) options.functions = functions;
180
228
  if (extensions !== undefined) options.extensions = extensions;
229
+ if (zoneProvider !== null) options.zoneProvider = zoneProvider;
181
230
  return options;
182
231
  };
183
232
 
@@ -277,8 +326,26 @@ export function createQueryEngine(context) {
277
326
  return out;
278
327
  };
279
328
 
280
- const plan = shapePlan(planned.plan ?? selectPlan(collection.name));
281
- const emitted = emitPlan(plan, dialect, physical);
329
+ let plan = shapePlan(planned.plan ?? selectPlan(collection.name));
330
+ let emitted;
331
+ try {
332
+ emitted = emitPlan(plan, dialect, physical);
333
+ }
334
+ catch (error) {
335
+ if (!(error instanceof UnrepresentablePath)) throw error;
336
+ // a member name the dialect cannot spell: the whole document runs
337
+ // in the set residual, named — and strict mode refuses it by name
338
+ if (strict) {
339
+ throw new DbCompileError('JD0010',
340
+ `strict mode refused a residual: 'path' — ${error.message}`, collection.docPath);
341
+ }
342
+ planned = {
343
+ ...planned, plan: null, mode: 'set', rowReturn: null, udfs: [], prefilters: [],
344
+ series: null, reasons: [{ construct: 'path', reason: error.message }, ...planned.reasons],
345
+ };
346
+ plan = shapePlan(selectPlan(collection.name));
347
+ emitted = emitPlan(plan, dialect, physical);
348
+ }
282
349
  const externalNames = planned.analysis.externals.map((e) => e.name);
283
350
  const limits = profile === null ? undefined : profile.limits;
284
351
  const entry = {
@@ -302,10 +369,13 @@ export function createQueryEngine(context) {
302
369
  setResidual: null,
303
370
  packedResidual: null,
304
371
  rowResidual: planned.mode === 'row'
305
- ? compileRowResidual(planned.rowReturn, limits, operators)
372
+ ? compileRowResidual(planned.rowReturn, limits, operators, zoneProvider)
306
373
  : null,
307
374
  fullScanSql: null,
308
375
  fullScanShape: () => shapePlan(selectPlan(collection.name)),
376
+ // the LAST actual execution's numbers, never an estimate: `null`
377
+ // until this document has run once
378
+ seriesCounts: null,
309
379
  };
310
380
 
311
381
  const sizeBefore = state.cache.size();
@@ -320,7 +390,8 @@ export function createQueryEngine(context) {
320
390
  };
321
391
  const setResidualOf = (entry, document) => {
322
392
  if (entry.setResidual === null)
323
- entry.setResidual = compileSetResidual(document, entry.residualLimits, operators);
393
+ entry.setResidual = compileSetResidual(document, entry.residualLimits, operators,
394
+ zoneProvider);
324
395
  return entry.setResidual;
325
396
  };
326
397
  /** The item-packing variant for cursors: `[document]` packs the
@@ -397,6 +468,68 @@ export function createQueryEngine(context) {
397
468
 
398
469
  const rowsToDocs = (rows) => rows.map((row) => JSON.parse(row.doc));
399
470
 
471
+ /**
472
+ * The bucket records a native temporal group answers: the ladder's
473
+ * start under the name the document asked for it by, then one member
474
+ * per aggregate with the caller's own word for "no numbers".
475
+ *
476
+ * `null` when a group has no instant at all — a row whose instant
477
+ * member is missing or is not a number groups under SQL `NULL`, and
478
+ * the kernel REFUSES such a row (`JQ2001`). SQL cannot refuse, so the
479
+ * call diverts and the engine answers, exactly as it does everywhere.
480
+ * @param {any} entry
481
+ * @param {any[]} rows
482
+ * @returns {any[] | null}
483
+ */
484
+ const bucketItems = (entry, rows) => {
485
+ const bucket = entry.plan.bucket;
486
+ const items = [];
487
+ for (const row of rows) {
488
+ const start = row[bucket.as] ?? null;
489
+ if (start === null) return null;
490
+ /** @type {any} */
491
+ const item = { [bucket.as]: start };
492
+ for (const aggregate of bucket.aggregates) {
493
+ const value = row[aggregate.as] ?? null;
494
+ if (value === null && aggregate.empty === 'omit') continue;
495
+ item[aggregate.as] = value === null && aggregate.empty === 'zero' ? 0 : value;
496
+ }
497
+ items.push(item);
498
+ }
499
+ return items;
500
+ };
501
+
502
+ /** How many ITEMS an engine result carries (its own shape rule). */
503
+ const itemCount = (answer) => (answer === undefined ? 0
504
+ : Array.isArray(answer) ? answer.length : 1);
505
+
506
+ /**
507
+ * A native bucket met a group with no instant. The kernel refuses
508
+ * such a row, and SQL cannot, so the whole question goes back to the
509
+ * engine over the whole collection — the same diversion a k-nearest
510
+ * probe of the wrong width takes, and counted the same way.
511
+ */
512
+ const divertBucket = (entry, document, externals) => {
513
+ seriesStats.diverted++;
514
+ return chain(fullScanOf(entry), (statement) =>
515
+ chain(statement.all(fullScanParams(entry)), (rows) => {
516
+ const docs = rowsToDocs(checkRowBound(entry, rows));
517
+ const answer = setResidualOf(entry, document)(docs, externals);
518
+ countSeries(entry, 2, docs.length, itemCount(answer));
519
+ return answer;
520
+ }));
521
+ };
522
+
523
+ /** Record one actual execution against the entry and the store. */
524
+ const countSeries = (entry, statements, candidates, results) => {
525
+ if (entry.planned.series === null) return;
526
+ seriesStats.queries++;
527
+ seriesStats.statements += statements;
528
+ seriesStats.candidates += candidates;
529
+ seriesStats.results += results;
530
+ entry.seriesCounts = { statements, candidates, results };
531
+ };
532
+
400
533
  /**
401
534
  * The documents of the given row identities, in identity order,
402
535
  * through the dialect's by-identities statement — batched, and each
@@ -461,6 +594,12 @@ export function createQueryEngine(context) {
461
594
  */
462
595
  const candidatesOf = (entry, externals, diverted) => {
463
596
  if (diverted) {
597
+ // a diversion IS a full-table scan; the plan-shape check above
598
+ // only ever saw the native statement
599
+ if (entry.needsScanCheck) {
600
+ throw profileRefusal(`the profile refuses a full-table scan of '${collection.name}' `
601
+ + '(a bound external the database cannot take diverted the call to the whole collection)');
602
+ }
464
603
  if (entry.planned.mode === 'knn') knnStats.diverted++;
465
604
  return chain(fullScanOf(entry), (statement) =>
466
605
  chain(statement.all(fullScanParams(entry)), (rows) =>
@@ -512,8 +651,11 @@ export function createQueryEngine(context) {
512
651
  // the k-nearest cut — and the full document over them, which
513
652
  // re-applies its own predicates and ordering (idempotent
514
653
  // narrowing: the fetch decided nothing)
515
- return chain(candidatesOf(entry, externals, diverted), (docs) =>
516
- setResidualOf(entry, document)(docs, externals));
654
+ return chain(candidatesOf(entry, externals, diverted), (docs) => {
655
+ const answer = setResidualOf(entry, document)(docs, externals);
656
+ countSeries(entry, 1, docs.length, itemCount(answer));
657
+ return answer;
658
+ });
517
659
  }
518
660
  if (entry.planned.mode === 'row') {
519
661
  return chain(statementOf(entry), (statement) =>
@@ -521,16 +663,27 @@ export function createQueryEngine(context) {
521
663
  const items = [];
522
664
  for (const row of checkRowBound(entry, rows))
523
665
  items.push(...entry.rowResidual(JSON.parse(row.doc), externals));
524
- return sequenceResult(items);
666
+ return answerOf(entry, items);
525
667
  }));
526
668
  }
527
669
  return chain(statementOf(entry), (statement) => {
528
670
  if (entry.plan.aggregate !== null) {
529
671
  return chain(statement.get(bindParams(entry, externals)),
530
- (row) => aggregateResult(entry, row));
672
+ (row) => wrapValue(entry, aggregateResult(entry, row)));
673
+ }
674
+ if (entry.plan.bucket !== null) {
675
+ return chain(statement.all(bindParams(entry, externals)), (rows) => {
676
+ const items = bucketItems(entry, checkRowBound(entry, rows));
677
+ if (items === null) return divertBucket(entry, document, externals);
678
+ countSeries(entry, 1, rows.length, items.length);
679
+ return answerOf(entry, items);
680
+ });
531
681
  }
532
- return chain(statement.all(bindParams(entry, externals)),
533
- (rows) => sequenceResult(rowsToDocs(checkRowBound(entry, rows))));
682
+ return chain(statement.all(bindParams(entry, externals)), (rows) => {
683
+ const docs = rowsToDocs(checkRowBound(entry, rows));
684
+ countSeries(entry, 1, docs.length, docs.length);
685
+ return answerOf(entry, docs);
686
+ });
534
687
  });
535
688
  });
536
689
  };
@@ -562,6 +715,21 @@ export function createQueryEngine(context) {
562
715
  if (done) return Promise.resolve({ done: true, value: undefined });
563
716
  if (bufferedAt < buffered.length) return Promise.resolve(nextFromBuffer());
564
717
 
718
+ if (entry.planned.wrapped === true) {
719
+ // a chain's element window is ONE item — the array — whatever
720
+ // the plan mode; the cursor hands it over as `execute` answers it
721
+ if (materialized === null) {
722
+ materialized = Promise.resolve(chain(execute(document, options), (value) => {
723
+ buffered = [value];
724
+ bufferedAt = 0;
725
+ }));
726
+ }
727
+ return materialized.then(() => {
728
+ if (bufferedAt < buffered.length) return nextFromBuffer();
729
+ done = true;
730
+ return { done: true, value: undefined };
731
+ });
732
+ }
565
733
  if (entry.planned.mode === 'set' || entry.planned.mode === 'knn'
566
734
  || mustDivert(entry, externals)) {
567
735
  // the barrier: materialize candidates, pack the result items
@@ -571,6 +739,7 @@ export function createQueryEngine(context) {
571
739
  chain(candidatesOf(entry, externals, diverted), (docs) => {
572
740
  buffered = packedResidualOf(entry, document)(docs, externals);
573
741
  bufferedAt = 0;
742
+ countSeries(entry, 1, docs.length, buffered.length);
574
743
  })));
575
744
  }
576
745
  return materialized.then(() => {
@@ -579,6 +748,33 @@ export function createQueryEngine(context) {
579
748
  return { done: true, value: undefined };
580
749
  });
581
750
  }
751
+ if (entry.plan.bucket !== null) {
752
+ // a native bucket is a barrier: the groups are the answer
753
+ if (materialized === null) {
754
+ materialized = Promise.resolve(chain(guardScan(entry), () =>
755
+ chain(statementOf(entry), (statement) =>
756
+ chain(statement.all(bindParams(entry, externals)), (rows) => {
757
+ const items = bucketItems(entry, checkRowBound(entry, rows));
758
+ if (items === null) {
759
+ const answer = divertBucket(entry, document, externals);
760
+ return chain(answer, (value) => {
761
+ buffered = value === undefined ? []
762
+ : Array.isArray(value) ? value : [value];
763
+ bufferedAt = 0;
764
+ });
765
+ }
766
+ countSeries(entry, 1, rows.length, items.length);
767
+ buffered = items;
768
+ bufferedAt = 0;
769
+ return null;
770
+ }))));
771
+ }
772
+ return materialized.then(() => {
773
+ if (bufferedAt < buffered.length) return nextFromBuffer();
774
+ done = true;
775
+ return { done: true, value: undefined };
776
+ });
777
+ }
582
778
  if (entry.plan.aggregate !== null) {
583
779
  // a native aggregate yields exactly one item
584
780
  if (materialized === null) {
@@ -639,7 +835,9 @@ export function createQueryEngine(context) {
639
835
  * The explanation record: the engine explain shape plus the pushdown
640
836
  * facts. `estimatedRows` is deliberately ABSENT — the capability
641
837
  * slot is empty on SQLite and no number is fabricated; the
642
- * database's own plan prose rides in `scanNarrative` instead.
838
+ * database's own plan prose rides in `scanNarrative` instead. So is
839
+ * every count in `series`: they are the LAST ACTUAL execution's, and
840
+ * `null` until this document has run once.
643
841
  * @param {any} document
644
842
  * @param {{ externals?: any, strict?: boolean }} [options]
645
843
  */
@@ -668,6 +866,12 @@ export function createQueryEngine(context) {
668
866
  if (term.ref.column !== null) touchedColumns.add(term.ref.column);
669
867
  }
670
868
  if (entry.plan.aggregate?.ref?.column) touchedColumns.add(entry.plan.aggregate.ref.column);
869
+ if (entry.plan.bucket !== null) {
870
+ if (entry.plan.bucket.ref.column) touchedColumns.add(entry.plan.bucket.ref.column);
871
+ for (const aggregate of entry.plan.bucket.aggregates) {
872
+ if (aggregate.ref?.column) touchedColumns.add(aggregate.ref.column);
873
+ }
874
+ }
671
875
  const indexes = [
672
876
  ...physicalPlan.expected.indexes
673
877
  .filter((index) => index.columns.some((column) => touchedColumns.has(column)))
@@ -689,11 +893,14 @@ export function createQueryEngine(context) {
689
893
  return chain(connection.prepare(dialect.explainQuery(entry.sql)), (statement) =>
690
894
  chain(statement.all(eqpParams), (rows) => ({
691
895
  mode: entry.planned.mode,
896
+ // a chain's element window (`[<phrase>]`): the phrase planned as
897
+ // if bare, its rows answered as the one array item
898
+ wrapped: entry.planned.wrapped === true,
692
899
  externals: [...entry.externalNames],
693
900
  operators: [...entry.dependencies.operators],
694
901
  functions: [...entry.dependencies.functions],
695
902
  collations: [...entry.dependencies.collations],
696
- limits: entry.limits,
903
+ limits: entry.residualLimits ?? entry.limits,
697
904
  sql: entry.sql,
698
905
  params,
699
906
  indexes,
@@ -718,16 +925,27 @@ export function createQueryEngine(context) {
718
925
  ? entry.planned.reasons.map((r) => ({ operator: r.construct, reason: r.reason }))
719
926
  : [],
720
927
  udfs: [...entry.planned.udfs],
928
+ // the temporal record: what the document asked, which declared
929
+ // index the fetch seeks through, and which kernel finished it.
930
+ // The counts are the LAST ACTUAL execution's — `null` before
931
+ // this document has run — because an estimate mislabelled as a
932
+ // count is exactly the thing an honest explain may not print
933
+ series: entry.planned.series === null ? null : {
934
+ ...entry.planned.series,
935
+ counts: entry.seriesCounts === null ? null : { ...entry.seriesCounts },
936
+ },
721
937
  scanNarrative: rows.map((row) => String(row.detail)).join('; '),
722
938
  })));
723
939
  };
724
940
 
725
- return { execute, query, explain, shape, stats: () => ({ knn: { ...knnStats } }) };
941
+ return { execute, query, explain, shape,
942
+ stats: () => ({ knn: { ...knnStats }, series: { ...seriesStats } }) };
726
943
  }
727
944
 
728
945
  // ————— The entity query surface (the second document kind) —————
729
946
 
730
947
  import { mergeEntityRow, parseGraphRow } from './graph.js';
948
+ import { relationTables } from './model.js';
731
949
 
732
950
  /** The default include depth bound (D14: printed, never silent). */
733
951
  export const INCLUDE_DEPTH_DEFAULT = 3;
@@ -745,9 +963,14 @@ export const INCLUDE_DEPTH_DEFAULT = 3;
745
963
  export function createEntityQueryEngine(context) {
746
964
  const { connection, entities, mapping, state } = context;
747
965
  const operators = state.operators ?? null;
966
+ const zoneProvider = state.zoneProvider ?? null;
748
967
  const dialect = connection.dialect;
749
968
  const q = dialect.quoteIdentifier;
750
969
  const physicalOf = (name) => ({ table: mapping.entities[name].table });
970
+ // the relation tables of every root this engine serves (§10.1): the
971
+ // engine is the scope every entity set of the store shares, so a
972
+ // producer holding one set can follow a hop into another root
973
+ const relations = relationTables(entities);
751
974
 
752
975
  const entryFor = (document, pushdown) => {
753
976
  const key = ['E', document, dialect.name, pushdown];
@@ -758,6 +981,14 @@ export function createEntityQueryEngine(context) {
758
981
  }
759
982
  state.counters.misses++;
760
983
  let planned = planEntityQuery(document, entities, mapping, operators);
984
+ if (planned.referenced.length === 0) {
985
+ // `$[*]` over the entity MAP answered the rows of every entity,
986
+ // mixed, and explain() named no table read; the root is the map
987
+ // of entity arrays, and a query ranges over one of them by name
988
+ throw new DbCompileError('JD0033',
989
+ 'an entity query ranges over a declared entity array ($.<Entity>[*]); this '
990
+ + 'document names none, so it has no rows to answer', '/entities');
991
+ }
761
992
  if (!pushdown) {
762
993
  planned = { ...planned, mode: 'set', plan: null,
763
994
  reasons: [{ construct: 'pushdown', reason: 'disabled by the harness switch' }] };
@@ -810,7 +1041,7 @@ export function createEntityQueryEngine(context) {
810
1041
 
811
1042
  const runResidual = (entry, document, externals) => {
812
1043
  if (entry.setResidual === null)
813
- entry.setResidual = compileSetResidual(document, undefined, operators);
1044
+ entry.setResidual = compileSetResidual(document, undefined, operators, zoneProvider);
814
1045
  return chain(fetchRoot(entry), (root) => entry.setResidual(root, externals));
815
1046
  };
816
1047
 
@@ -837,11 +1068,11 @@ export function createEntityQueryEngine(context) {
837
1068
  if (entry.statement === null) entry.statement = connection.prepare(entry.sql);
838
1069
  return chain(entry.statement, (statement) => {
839
1070
  if (entry.planned.plan.aggregate === 'count')
840
- return chain(statement.get(params), (row) => row?.value ?? 0);
1071
+ return chain(statement.get(params), (row) => wrapValue(entry, row?.value ?? 0));
841
1072
  return chain(statement.all(params), (rows) => {
842
1073
  const retEntity = entry.planned.plan.bindings
843
1074
  .find((binding) => binding.name === entry.planned.plan.ret).entity;
844
- return sequenceResult(rows.map((row) =>
1075
+ return answerOf(entry, rows.map((row) =>
845
1076
  mergeEntityRow(mapping.entities[retEntity], row, '__doc')));
846
1077
  });
847
1078
  });
@@ -852,6 +1083,7 @@ export function createEntityQueryEngine(context) {
852
1083
  const entry = entryFor(document, pushdown);
853
1084
  const base = {
854
1085
  mode: entry.planned.mode,
1086
+ wrapped: entry.planned.wrapped === true,
855
1087
  referenced: [...entry.planned.referenced],
856
1088
  reasons: entry.planned.reasons,
857
1089
  sql: entry.sql,
@@ -869,7 +1101,7 @@ export function createEntityQueryEngine(context) {
869
1101
  })));
870
1102
  };
871
1103
 
872
- return { execute, explain };
1104
+ return { execute, explain, relations };
873
1105
  }
874
1106
 
875
1107
  /**
@@ -899,6 +1131,11 @@ export function createLoadEngine(context, entityName) {
899
1131
  const refuse = (reason, path) => new DbCompileError('JD0032',
900
1132
  `${reason} (include path: ${path.join('.') || '<root>'})`,
901
1133
  entities.get(entityName)?.docPath);
1134
+ const isWindowBound = (value) => Number.isSafeInteger(value) && value >= 0;
1135
+ /** An include's window inside its subquery: LIMIT, and OFFSET for a
1136
+ * `skip` — per parent row, since the subquery is correlated (§10.4). */
1137
+ const windowClause = (child) => (child.take !== undefined || (child.skip !== undefined && child.skip > 0)
1138
+ ? ` ${dialect.limitClause(child.take ?? null, child.skip)}` : '');
902
1139
 
903
1140
  /** Compile a where EXPRESSION over `$it` against one entity. */
904
1141
  const compileWhere = (expression, entity, path) => {
@@ -969,6 +1206,7 @@ export function createLoadEngine(context, entityName) {
969
1206
  where: spec?.where !== undefined ? compileWhere(spec.where, entity, path) : null,
970
1207
  order: spec?.orderBy !== undefined ? compileOrder(spec.orderBy, entity, path) : null,
971
1208
  take: spec?.take,
1209
+ skip: spec?.skip,
972
1210
  includes: [],
973
1211
  };
974
1212
  const includeSpec = spec?.include;
@@ -984,6 +1222,26 @@ export function createLoadEngine(context, entityName) {
984
1222
  [...path, relationName]);
985
1223
  }
986
1224
  const childSpec = includeSpec[relationName] === true ? {} : includeSpec[relationName];
1225
+ if (childSpec.count === true) {
1226
+ // a count counts EVERY related row; a where/take beside it was
1227
+ // dropped without a word, and the number answered was the total
1228
+ const dropped = ['where', 'orderBy', 'take', 'skip', 'include', 'after']
1229
+ .filter((member) => childSpec[member] !== undefined);
1230
+ if (dropped.length > 0) {
1231
+ throw refuse(`count: true counts every related row and takes no ${dropped.join('/')} — `
1232
+ + 'load the rows to count a subset', [...path, relationName]);
1233
+ }
1234
+ }
1235
+ for (const member of ['take', 'skip']) {
1236
+ if (childSpec[member] !== undefined && !isWindowBound(childSpec[member]))
1237
+ throw refuse(`${member} must be a non-negative integer`, [...path, relationName]);
1238
+ }
1239
+ // a keyset cursor is one position in ONE ordered set; an include is
1240
+ // a set per parent, so it windows with skip/take and never seeks
1241
+ if (childSpec.after !== undefined) {
1242
+ throw refuse("'after' (keyset pagination) paginates the root — an include windows with skip and take",
1243
+ [...path, relationName]);
1244
+ }
987
1245
  const childName = relation.to;
988
1246
  const include = {
989
1247
  name: relationName,
@@ -1037,6 +1295,17 @@ export function createLoadEngine(context, entityName) {
1037
1295
  const parentKey = parentNode.entityMapping.keys[0];
1038
1296
  if (include.count === true) {
1039
1297
  const childTable = mapping.entities[relation.to].table;
1298
+ if (relation.kind === 'manyToMany') {
1299
+ const join = mapping.joinTables[relation.joinTable];
1300
+ const own = join.left.entity === parentNode.entity.name ? join.left : join.right;
1301
+ return `(SELECT COUNT(*) FROM ${q(relation.joinTable)} AS ${q(childAlias)} `
1302
+ + `WHERE ${q(childAlias)}.${q(own.column)} = ${q(parentAlias)}.${q(parentKey)})`;
1303
+ }
1304
+ if (relation.kind === 'oneToOne') {
1305
+ const childKey = mapping.entities[relation.to].keys[0];
1306
+ return `(SELECT COUNT(*) FROM ${q(childTable)} AS ${q(childAlias)} `
1307
+ + `WHERE ${q(childAlias)}.${q(childKey)} = ${q(parentAlias)}.${q(relation.via)})`;
1308
+ }
1040
1309
  return `(SELECT COUNT(*) FROM ${q(childTable)} AS ${q(childAlias)} `
1041
1310
  + `WHERE ${q(childAlias)}.${q(relation.via)} = ${q(parentAlias)}.${q(parentKey)})`;
1042
1311
  }
@@ -1071,10 +1340,10 @@ export function createLoadEngine(context, entityName) {
1071
1340
  + (child.where !== null
1072
1341
  ? ` AND ${emitters.emitPred(rendered.aliasSql, rendered.docSql, child.where)}` : '')
1073
1342
  + ` ORDER BY ${orderSql.join(', ')}`
1074
- + (child.take !== undefined ? ` ${dialect.limitClause(child.take, undefined)}` : '')
1343
+ + windowClause(child)
1075
1344
  : `SELECT ${rendered.aliasSql}.* FROM ${q(childTable)} AS ${q(childAlias)} `
1076
1345
  + `WHERE ${conditions.join(' AND ')} ORDER BY ${orderSql.join(', ')}`
1077
- + (child.take !== undefined ? ` ${dialect.limitClause(child.take, undefined)}` : '');
1346
+ + windowClause(child);
1078
1347
  if (relation.kind === 'oneToOne') {
1079
1348
  return `(SELECT json_object(${rendered.projection()}) FROM `
1080
1349
  + `(${inner} ${dialect.limitClause(1, undefined)}) AS ${q(childAlias)})`;
@@ -1101,6 +1370,11 @@ export function createLoadEngine(context, entityName) {
1101
1370
  };
1102
1371
  const emitters = createEntityPredicateEmitters(dialect, param);
1103
1372
  const maxDepth = spec?.maxDepth ?? INCLUDE_DEPTH_DEFAULT;
1373
+ for (const member of ['take', 'skip']) {
1374
+ // interpolated into LIMIT/OFFSET as written: a string ran as SQL
1375
+ if (spec?.[member] !== undefined && !isWindowBound(spec[member]))
1376
+ throw refuse(`${member} must be a non-negative integer`, []);
1377
+ }
1104
1378
  const tree = buildTree(entityName, spec ?? {}, 0, maxDepth, [], new Set());
1105
1379
  const rendered = render(tree, 'r', param, emitters);
1106
1380
 
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