@jarenjs/db 0.56.0 → 0.66.1

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 (68) hide show
  1. package/ARCHITECTURE.md +393 -56
  2. package/README.md +585 -53
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +293 -45
  5. package/docs/LIVE-FORMAT.md +122 -14
  6. package/docs/MIGRATION-FORMAT.md +142 -17
  7. package/docs/MODEL-FORMAT.md +744 -64
  8. package/package.json +21 -7
  9. package/schemas/jaren-model.draft-07.schema.json +224 -162
  10. package/schemas/jaren-model.schema.json +224 -162
  11. package/src/algebra.js +227 -9
  12. package/src/backup.js +161 -0
  13. package/src/cancellation.js +48 -0
  14. package/src/capture.js +218 -45
  15. package/src/cli.js +165 -59
  16. package/src/cursor.js +411 -0
  17. package/src/dag-job.js +154 -21
  18. package/src/ddl.js +102 -8
  19. package/src/dialect.js +267 -112
  20. package/src/dialects/expression-read.js +158 -0
  21. package/src/dialects/postgres.js +618 -0
  22. package/src/dialects/rtree-ddl.js +129 -0
  23. package/src/dialects/sqlite.js +243 -11
  24. package/src/document-files.js +311 -0
  25. package/src/document-steps.js +422 -0
  26. package/src/documents.js +335 -0
  27. package/src/driver.js +448 -61
  28. package/src/drivers/bun.js +37 -1
  29. package/src/drivers/indexeddb-snapshot.js +149 -0
  30. package/src/drivers/node-pool.js +11 -0
  31. package/src/drivers/node-worker-endpoint.js +105 -0
  32. package/src/drivers/node-worker.js +204 -0
  33. package/src/drivers/node.js +41 -7
  34. package/src/drivers/postgres.js +331 -0
  35. package/src/drivers/wasm-oo1.js +97 -0
  36. package/src/drivers/wasm-session.js +67 -0
  37. package/src/drivers/wasm.js +17 -83
  38. package/src/drivers/worker-pool.js +183 -0
  39. package/src/drivers/worker-protocol.js +79 -0
  40. package/src/drivers/worker-queue.js +60 -0
  41. package/src/emit.js +339 -48
  42. package/src/entity.js +20 -22
  43. package/src/errors.js +422 -19
  44. package/src/expression.js +284 -0
  45. package/src/graph.js +64 -8
  46. package/src/index.js +46 -17
  47. package/src/introspect.js +583 -0
  48. package/src/jobs.js +843 -107
  49. package/src/json-bytes.js +58 -0
  50. package/src/maintenance.js +175 -0
  51. package/src/migrate.js +248 -181
  52. package/src/model.js +68 -0
  53. package/src/plan.js +1119 -138
  54. package/src/pragmas.js +314 -0
  55. package/src/profile.js +151 -3
  56. package/src/query.js +1634 -323
  57. package/src/residual.js +17 -0
  58. package/src/series.js +12 -4
  59. package/src/store.js +1505 -264
  60. package/src/tracker.js +203 -29
  61. package/src/udf.js +88 -7
  62. package/types/index.d.ts +1097 -25
  63. package/types/node-pool.d.ts +28 -0
  64. package/types/node-worker.d.ts +54 -0
  65. package/types/node.d.ts +69 -2
  66. package/types/postgres.d.ts +46 -0
  67. package/types/typed.d.ts +25 -3
  68. package/types/wasm.d.ts +14 -0
package/src/plan.js CHANGED
@@ -52,7 +52,7 @@ import {
52
52
  } from './derive.js';
53
53
  import { KNN_MARGIN } from './knn.js';
54
54
  import {
55
- SERIES_ROOT_OPS, NATIVE_AGGREGATES, seriesReason,
55
+ SERIES_ROOT_OPS, NATIVE_AGGREGATES, SERIES_REASONS, seriesReason,
56
56
  instantIndexesOver, seekingIndexFor, filterFacts, fixedLadder, instantRefusal,
57
57
  valueRefusal, seriesRecord, singularSelector,
58
58
  } from './series.js';
@@ -93,6 +93,105 @@ const KIND_REASONS = {
93
93
  quant: 'a quantifier over a nested sequence runs in the engine',
94
94
  };
95
95
 
96
+ /** Why one PREDICATE stayed in the engine. */
97
+ const PREDICATE_REASONS = {
98
+ notPredicate: 'not a predicate the planner translates',
99
+ negatedPrefilter: 'a negated predicate cannot ride an implied pre-filter '
100
+ + '(negating a superset drops rows)',
101
+ existence: 'existence tests translate only over a singular member path on the binding',
102
+ joinTerritory: 'comparisons where both sides are paths are join territory',
103
+ operands: 'comparisons translate only between a singular member path and a literal or external',
104
+ compoundLiteral: 'array and object literals have no guarded native comparison form',
105
+ stringSubject: 'string operators translate only over schema-typed string paths '
106
+ + '(the engine ERRORS on non-string subjects)',
107
+ stringPattern: 'string operators translate only with literal string patterns '
108
+ + "(an external pattern's type is unknowable at plan time)",
109
+ emptyPattern: "the empty pattern's vacuous-truth corner (true even on a missing member) "
110
+ + 'is not translated',
111
+ noSpelling: 'no native spelling of this operator is proven equivalent',
112
+ };
113
+
114
+ /** Why one FLWOR clause stayed in the engine. */
115
+ const FLWOR_REASONS = {
116
+ binding: 'only a single plain binding over the whole collection is translated',
117
+ as: 'type assertions run in the engine',
118
+ orderPath: 'ordering translates only over singular schema-typed paths '
119
+ + '(numbers and strings that cannot hold null)',
120
+ collation: 'a collation the dialect cannot reproduce is refused, not approximated',
121
+ projection: 'projections other than the bare binding or one member path run per row '
122
+ + '(the row residual)',
123
+ };
124
+
125
+ /** Why a whole DOCUMENT stayed in the engine, decided above the FLWOR. */
126
+ const PLAN_REASONS = {
127
+ windowBounds: 'window bounds must be literal numbers to push (non-negative integers)',
128
+ windowedAggregate: 'a windowed aggregate is not translated',
129
+ notFlwor: 'only a FLWOR over the collection is translated',
130
+ countProjection: 'count translates only over the bare binding or one member path '
131
+ + '(a projected return can change the item count)',
132
+ groupedAggregate: 'an aggregate over a grouped phrase folds its groups, which the engine does',
133
+ windowedGroup: 'a window over the GROUPS is engine work: the plan groups whole, '
134
+ + 'and a LIMIT over the groups would cut a different set',
135
+ aggregatePath: 'aggregates translate only over a singular schema-typed path '
136
+ + '(the engine ERRORS on non-conforming operands)',
137
+ };
138
+
139
+ /**
140
+ * Why the statement a call RUNS is not the one the planner planned:
141
+ * causes decided at bind time or forced by the harness, which the query
142
+ * engines report through the same `{ construct, reason }` shape a plan
143
+ * carries. They live beside the planner's own so the whole explanation
144
+ * vocabulary is one closed set (see {@link PLANNER_REASONS}).
145
+ */
146
+ export const BIND_REASONS = Object.freeze({
147
+ pushdown: 'disabled by the harness switch',
148
+ untranslated: 'the document did not translate',
149
+ wrappedWindow: "a chain's element window is one item — the whole array — "
150
+ + 'whatever the plan mode',
151
+ bucketWhole: 'a native bucket answers its groups whole: the groups are the result',
152
+ overflow: 'the pushed aggregate overflowed int64; the engine answered the document '
153
+ + 'over the fetched rows',
154
+ /**
155
+ * A value the database cannot bind: the call reads its whole source
156
+ * and the engine answers. `over` names what that source is — a
157
+ * collection's rows, or an entity query's fetched root.
158
+ * @param {string | null} name - the external, or `null` for a literal
159
+ * @param {'collection' | 'root'} over
160
+ */
161
+ external: (name, over) =>
162
+ `${name === null ? 'a literal' : `the external '${name}'`} is not a value the `
163
+ + 'database binds; the call runs in the residual over the '
164
+ + `${over === 'root' ? 'fetched root' : 'whole collection'}`,
165
+ });
166
+
167
+ /**
168
+ * Why a REGISTERED operator stayed in the engine. The sentence quotes
169
+ * the operators the document used, so the entry is the function that
170
+ * builds it — the vocabulary claims it by its stable opening.
171
+ */
172
+ const OPERATOR_REASONS = {
173
+ /** @param {string[]} used */
174
+ registered: (used) => {
175
+ const many = used.length > 1;
176
+ return `registered operator${many ? 's' : ''} `
177
+ + `${used.map((name) => `'${name}'`).join(', ')} run${many ? '' : 's'} in the residual `
178
+ + '(Ring 2 — correct, not pushed to SQL)';
179
+ },
180
+ };
181
+
182
+ /** Why an ENTITY document, or one of its clauses, stayed in the engine. */
183
+ const ENTITY_REASONS = {
184
+ notFlwor: 'only a FLWOR over entity arrays is translated',
185
+ bindingRoot: 'bindings must each range over one declared entity array ($.Entity[*])',
186
+ joinKey: 'every binding past the first needs a column equality to one already joined — '
187
+ + 'a binding nothing connects is a cartesian product, which is engine work',
188
+ conjunctBinding: 'a conjunct must belong to one binding (or be the single join equality)',
189
+ external: 'externals compare only against entity columns in this version',
190
+ projection: 'entity queries return one bare binding natively; projections run in the engine',
191
+ order: 'ordering translates only over typed entity paths (never a boolean, never a document '
192
+ + 'path that admits null)',
193
+ };
194
+
96
195
  /**
97
196
  * Assert a node kind is one this planner has decided. Called on every
98
197
  * dispatch; the throw names the kind and the AST version so a language
@@ -119,12 +218,6 @@ for (const kind of NODE_KINDS) {
119
218
  }
120
219
  }
121
220
 
122
- /**
123
- * One named refusal.
124
- * @param {string} construct
125
- * @param {string} reason
126
- * @returns {{ construct: string, reason: string }}
127
- */
128
221
  /** Whether a literal window bound is one SQL takes as written: a
129
222
  * non-negative safe integer. A negative, fractional or non-finite bound
130
223
  * is the ENGINE's to interpret (it answers `[]`, a truncation or a
@@ -150,6 +243,14 @@ function orderable(ref, schema) {
150
243
  return ref.type !== 'unknown' && ref.type !== 'boolean' && !admitsNull(schema, ref.segments);
151
244
  }
152
245
 
246
+ /**
247
+ * One named refusal: the construct that stayed behind and the cause,
248
+ * drawn from {@link PLANNER_REASONS} so an explanation's vocabulary is
249
+ * one closed set rather than prose invented at each site.
250
+ * @param {string} construct
251
+ * @param {string} reason
252
+ * @returns {{ construct: string, reason: string }}
253
+ */
153
254
  function refusal(construct, reason) {
154
255
  return { construct, reason };
155
256
  }
@@ -247,16 +348,10 @@ function prependRegisteredReason(planned, document, operators) {
247
348
  const residualPart = planned.mode === 'row' ? planned.rowReturn : document;
248
349
  const used = registeredOpsUsed(residualPart, registered);
249
350
  if (used.length === 0) return planned;
250
- const many = used.length > 1;
251
351
  return {
252
352
  ...planned,
253
353
  reasons: [
254
- {
255
- construct: used.join(', '),
256
- reason: `registered operator${many ? 's' : ''} `
257
- + `${used.map((n) => `'${n}'`).join(', ')} run${many ? '' : 's'} in the residual `
258
- + '(Ring 2 — correct, not pushed to SQL)',
259
- },
354
+ refusal(used.join(', '), OPERATOR_REASONS.registered(used)),
260
355
  ...planned.reasons,
261
356
  ],
262
357
  };
@@ -386,6 +481,25 @@ function isGeographicSchema(node) {
386
481
  /** `$geohash`'s default precision (QUERY-FORMAT §8.14). */
387
482
  const GEOHASH_DEFAULT_PRECISION = 9;
388
483
 
484
+ /**
485
+ * The interval promotion's reasons. `$overlaps` is §8.16's half-open
486
+ * interval test, and the same rule the spatial promotions live under
487
+ * applies to it: a pre-filter narrows, it never decides, and it may
488
+ * never exclude a row the engine would have RAISED on.
489
+ */
490
+ const INTERVAL_REASONS = {
491
+ overlap: 'a half-open bound pre-filter is pushed over the declared interval columns; '
492
+ + 'the exact overlap refines in the engine',
493
+ operands: '$overlaps translates only with one member path and one literal interval',
494
+ probe: 'the literal interval is not a half-open span of two instants '
495
+ + '(the engine ERRORS on an empty or reversed one, whatever the row holds)',
496
+ notInterval: 'the schema does not type the member as an object whose start and end are '
497
+ + 'both REQUIRED and both numeric (a bound the engine ERRORS on must not be '
498
+ + 'silently filtered away)',
499
+ noColumns: 'the interval bounds are not both declared columns (declare an index over '
500
+ + 'each of them)',
501
+ };
502
+
389
503
  const SPATIAL_REASONS = {
390
504
  within: 'a bounding-box pre-filter is pushed; exact containment refines in the engine',
391
505
  distance: 'a geodesic-circle box pre-filter is pushed; the exact distance refines in the engine',
@@ -406,6 +520,8 @@ const SPATIAL_REASONS = {
406
520
  rtreeBox: "the box is stored in an R*Tree, whose coordinates are 32-bit floats rounded "
407
521
  + 'OUTWARD, so the stored box is a superset of the row\'s; the exact box test refines '
408
522
  + 'in the engine',
523
+ radius: 'a distance bound is a finite, non-negative number of metres',
524
+ unboundedFar: 'only a BOUNDED distance is promoted; no box narrows "farther than r"',
409
525
  };
410
526
 
411
527
  /**
@@ -658,8 +774,7 @@ function planDistanceBound(node, itSlot, shape) {
658
774
  const at = probePosition(constant.value);
659
775
  if (at === null) return { refusal: refusal('$distance', SPATIAL_REASONS.unbounded) };
660
776
  if (!Number.isFinite(radius.value) || radius.value < 0) {
661
- return { refusal: refusal('$distance',
662
- 'a distance bound is a finite, non-negative number of metres') };
777
+ return { refusal: refusal('$distance', SPATIAL_REASONS.radius) };
663
778
  }
664
779
  const box = probeCircleBox(at, radius.value);
665
780
  if (box === null) return { refusal: refusal('$distance', SPATIAL_REASONS.pole) };
@@ -831,11 +946,74 @@ const KNN_REASONS = {
831
946
  /** @param {string} path @param {number} want @param {number[]} have */
832
947
  dims: (path, want, have) =>
833
948
  `the literal probe has ${want} components but the vector column over ${path} is declared at ${have.join(', ')}`,
834
- /** @param {string} path */
835
- widths: (path) =>
836
- `several vector widths are declared over ${path}; an external probe cannot choose one at plan time`,
837
949
  };
838
950
 
951
+ /**
952
+ * The planner's reason VOCABULARY (D6): every cause a plan can name for
953
+ * work it left in the engine, under a stable identifier. An explanation
954
+ * is public behaviour, so the sentences are a closed set — a new
955
+ * promotion adds an entry here, it does not write prose at the refusal
956
+ * site — and a test can then assert that every reason a corpus observes
957
+ * is one of these and nothing else.
958
+ *
959
+ * Most entries are the whole sentence. The four that quote the caller's
960
+ * own values — a vector width, a member path, the registered operators
961
+ * a document used — carry instead the stable opening they always begin
962
+ * with, which is what {@link reasonId} matches them by.
963
+ */
964
+ export const PLANNER_REASONS = Object.freeze({
965
+ ...Object.fromEntries(Object.entries(KIND_REASONS)
966
+ .map(([key, text]) => [`kind.${key}`, { text }])),
967
+ ...Object.fromEntries(Object.entries(PREDICATE_REASONS)
968
+ .map(([key, text]) => [`predicate.${key}`, { text }])),
969
+ ...Object.fromEntries(Object.entries(FLWOR_REASONS)
970
+ .map(([key, text]) => [`flwor.${key}`, { text }])),
971
+ ...Object.fromEntries(Object.entries(PLAN_REASONS)
972
+ .map(([key, text]) => [`plan.${key}`, { text }])),
973
+ ...Object.fromEntries(Object.entries(ENTITY_REASONS)
974
+ .map(([key, text]) => [`entity.${key}`, { text }])),
975
+ ...Object.fromEntries(Object.entries(SPATIAL_REASONS)
976
+ .map(([key, text]) => [`spatial.${key}`, { text }])),
977
+ ...Object.fromEntries(Object.entries(INTERVAL_REASONS)
978
+ .map(([key, text]) => [`interval.${key}`, { text }])),
979
+ ...Object.fromEntries(Object.entries(KNN_REASONS)
980
+ .filter(([, text]) => typeof text === 'string')
981
+ .map(([key, text]) => [`knn.${key}`, { text }])),
982
+ ...Object.fromEntries(Object.entries(SERIES_REASONS)
983
+ // the temporal planner spells its code into the sentence, so the
984
+ // reason a plan carries is the code and the sentence at once
985
+ .map(([code, text]) => [`series.${code}`, { text: `${code}: ${text}` }])),
986
+ ...Object.fromEntries(Object.entries(BIND_REASONS)
987
+ .filter(([, text]) => typeof text === 'string')
988
+ .map(([key, text]) => [`bind.${key}`, { text }])),
989
+ 'knn.noColumn': { prefix: 'no vector column over ' },
990
+ 'knn.dims': { prefix: 'the literal probe has ' },
991
+ 'operators.registered': { prefix: 'registered operator' },
992
+ 'bind.external': { prefix: 'the external ' },
993
+ 'bind.externalLiteral': { prefix: 'a literal is not a value the database binds' },
994
+ // the sentence itself is the dialect's, raised where the path is
995
+ // spelled; the vocabulary claims its stable opening
996
+ 'bind.path': { prefix: 'a member name the dialect' },
997
+ });
998
+
999
+ /**
1000
+ * The vocabulary identifier of one reason sentence, or `null` when no
1001
+ * entry claims it — an unnamed reason, which the explanation contract
1002
+ * treats as a defect rather than a variation.
1003
+ * @param {string} reason
1004
+ * @returns {string | null}
1005
+ */
1006
+ export function reasonId(reason) {
1007
+ for (const [id, entry] of Object.entries(PLANNER_REASONS)) {
1008
+ if (entry.text !== undefined) {
1009
+ if (entry.text === reason) return id;
1010
+ }
1011
+ else if (reason.startsWith(entry.prefix)) return id;
1012
+ }
1013
+ return null;
1014
+ }
1015
+
1016
+
839
1017
  /**
840
1018
  * Recognize the k-nearest ordering, or say why not. `null` when the
841
1019
  * first key is not a `$similarity` at all — an ordinary ordering the
@@ -871,10 +1049,14 @@ function planKnnOrder(orderby, itSlot, shape, selectionPushed) {
871
1049
 
872
1050
  if (probeNode.kind === 'var' && probeNode.external === true) {
873
1051
  if (declared.length === 0) return refuse(KNN_REASONS.noColumn(path, null));
874
- if (declared.length > 1) return refuse(KNN_REASONS.widths(path));
875
1052
  if (!selectionPushed) return refuse(KNN_REASONS.selection);
876
- return { rank: { column: declared[0].column, dims: declared[0].dims,
877
- probe: { ext: probeNode.name } } };
1053
+ // EVERY declared width is an alternative: the plan carries them all
1054
+ // and the BIND picks the one the probe's own width names. A `CASE`
1055
+ // across the columns would read every one of them per row, and a
1056
+ // statement per call would give up the prepared cache
1057
+ return { rank: { alternatives: declared.map((entry) =>
1058
+ ({ column: entry.column, dims: entry.dims })),
1059
+ probe: { ext: probeNode.name } } };
878
1060
  }
879
1061
  const constant = constantOf(probeNode);
880
1062
  if (constant === null || !Array.isArray(constant.value)) return refuse(KNN_REASONS.probe);
@@ -887,7 +1069,8 @@ function planKnnOrder(orderby, itSlot, shape, selectionPushed) {
887
1069
  : KNN_REASONS.dims(path, dims, declared.map((entry) => entry.dims)));
888
1070
  }
889
1071
  if (!selectionPushed) return refuse(KNN_REASONS.selection);
890
- return { rank: { column: /** @type {string} */ (column), dims, probe: { lit: constant.value } } };
1072
+ return { rank: { alternatives: [{ column: /** @type {string} */ (column), dims }],
1073
+ probe: { lit: constant.value } } };
891
1074
  }
892
1075
 
893
1076
  /**
@@ -910,8 +1093,7 @@ function planSpatial(node, itSlot, shape) {
910
1093
  // `$distance >= r` — "farther than" — is narrowed by no box at all
911
1094
  const other = upperOnLeft ? node.args[1] : node.args[0];
912
1095
  if (other?.kind === 'op' && other.name === '$distance') {
913
- return { refusal: refusal('$distance',
914
- 'only a BOUNDED distance is promoted; no box narrows "farther than r"') };
1096
+ return { refusal: refusal('$distance', SPATIAL_REASONS.unboundedFar) };
915
1097
  }
916
1098
  return null;
917
1099
  }
@@ -926,6 +1108,108 @@ function planSpatial(node, itSlot, shape) {
926
1108
  return null;
927
1109
  }
928
1110
 
1111
+ /**
1112
+ * P4 — `$overlaps` over a declared interval.
1113
+ *
1114
+ * The engine's rule (`overlapsInterval`) is one conjunction: two
1115
+ * half-open spans share an instant when each starts before the other
1116
+ * ends. Over a row whose bounds are declared columns that is two
1117
+ * ordinary comparisons, which is why the promotion exists at all.
1118
+ *
1119
+ * What makes it a PRE-FILTER rather than an exact translation is the
1120
+ * kernel's other half: a span that is empty or reversed is not `false`,
1121
+ * it RAISES (`JQ2001`, through `requireInterval`). A conjunction alone
1122
+ * would drop `[500, 100)` for a probe of `[100, 200)` — answering where
1123
+ * the engine errors, the one thing a pushdown may never do. JSON Schema
1124
+ * has no keyword that compares two members, so the store cannot make an
1125
+ * inverted span unstorable the way it makes a missing or mistyped bound
1126
+ * unstorable; the fetch therefore keeps every inverted row, and the
1127
+ * engine raises over the candidates exactly as it would have.
1128
+ *
1129
+ * That disjunct is a comparison between two COLUMNS, which no index
1130
+ * bounds, so the statement scans. What it still buys is the decode: the
1131
+ * rows that come back are the ones the operator can be true for, plus
1132
+ * the ones it must raise on, rather than the whole collection. A store
1133
+ * that could declare the pair as an interval — a `CHECK` on the two
1134
+ * columns — would make the conjunction exact and the fetch a seek;
1135
+ * ROADMAP carries that as open work.
1136
+ *
1137
+ * The rest of the malformed cases ARE schema ones, exactly as the
1138
+ * spatial promotions' precondition is: a bound the engine errors on
1139
+ * (absent, textual, null) cannot be written to a collection whose
1140
+ * schema requires two numeric bounds, because a write is validated
1141
+ * against that schema (`JD2003`).
1142
+ * @param {any} node
1143
+ * @param {number} itSlot
1144
+ * @param {any} shape
1145
+ * @returns {any}
1146
+ */
1147
+ function planIntervalOverlap(node, itSlot, shape) {
1148
+ if (node.args.length !== 2)
1149
+ return { refusal: refusal('$overlaps', INTERVAL_REASONS.operands) };
1150
+ // the operator is symmetric, so either side may be the row's
1151
+ let [subject, probeNode] = node.args;
1152
+ if (pathRef(subject, itSlot, shape) === null) [subject, probeNode] = [probeNode, subject];
1153
+ const spanRef = pathRef(subject, itSlot, shape);
1154
+ const probe = constantOf(probeNode);
1155
+ if (spanRef === null || probe === null || pathRef(probeNode, itSlot, shape) !== null)
1156
+ return { refusal: refusal('$overlaps', INTERVAL_REASONS.operands) };
1157
+
1158
+ const span = probe.value;
1159
+ const from = span === null || typeof span !== 'object' || Array.isArray(span)
1160
+ ? null : safeEpoch(span.start);
1161
+ const to = span === null || typeof span !== 'object' || Array.isArray(span)
1162
+ ? null : safeEpoch(span.end);
1163
+ // the probe is the caller's own literal: an empty or reversed one
1164
+ // raises for every row, and a plan that answered would hide it
1165
+ if (from === null || to === null || !(from < to))
1166
+ return { refusal: refusal('$overlaps', INTERVAL_REASONS.probe) };
1167
+
1168
+ const bounds = intervalBounds(spanRef, shape);
1169
+ if (bounds === null) return { refusal: refusal('$overlaps', INTERVAL_REASONS.notInterval) };
1170
+ if (bounds.start.column === null || bounds.end.column === null)
1171
+ return { refusal: refusal('$overlaps', INTERVAL_REASONS.noColumns) };
1172
+
1173
+ const pred = { p: 'interval',
1174
+ columns: { start: bounds.start.column, end: bounds.end.column },
1175
+ probe: { from, to } };
1176
+ return { ...promotion(pred,
1177
+ { construct: '$overlaps', via: 'columns',
1178
+ columns: [bounds.start.column, bounds.end.column], exact: false },
1179
+ [refusal('$overlaps', INTERVAL_REASONS.overlap)]), composable: true };
1180
+ }
1181
+
1182
+ /**
1183
+ * The two bound refs of a member the schema types as a half-open
1184
+ * interval, or `null` when it does not type it as one.
1185
+ *
1186
+ * "Types it as one" is the whole precondition: an object and only an
1187
+ * object, carrying `start` and `end`, both REQUIRED and both typed
1188
+ * numeric and only numeric. A union with `null` or `string` is not an
1189
+ * interval here — an RFC 3339 bound is a perfectly good instant to the
1190
+ * engine and no epoch column can compare against it.
1191
+ * @param {any} spanRef
1192
+ * @param {any} shape
1193
+ * @returns {{ start: any, end: any } | null}
1194
+ */
1195
+ function intervalBounds(spanRef, shape) {
1196
+ const node = schemaNodeAt(shape.schema, spanRef.segments);
1197
+ if (node === undefined || node.type !== 'object') return null;
1198
+ const required = Array.isArray(node.required) ? node.required : [];
1199
+ if (!required.includes('start') || !required.includes('end')) return null;
1200
+ const boundRef = (name) => {
1201
+ const bound = node.properties?.[name];
1202
+ const type = bound?.type;
1203
+ if (typeof type !== 'string' || !isNumericType(type)) return null;
1204
+ const segments = [...spanRef.segments, { name }];
1205
+ return { segments, type,
1206
+ column: shape.columnByCanonical.get(canonicalOf(segments)) ?? null };
1207
+ };
1208
+ const start = boundRef('start');
1209
+ const end = boundRef('end');
1210
+ return start === null || end === null ? null : { start, end };
1211
+ }
1212
+
929
1213
  /**
930
1214
  * Translate one predicate node, or explain why not.
931
1215
  *
@@ -943,8 +1227,8 @@ function planSpatial(node, itSlot, shape) {
943
1227
  function planPredicate(node, itSlot, shape) {
944
1228
  assertDecidedKind(node);
945
1229
  if (node.kind !== 'op') {
946
- return { refusal: refusal(node.kind, KIND_REASONS[node.kind]
947
- ?? 'not a predicate the planner translates') };
1230
+ return { refusal: refusal(node.kind,
1231
+ KIND_REASONS[node.kind] ?? PREDICATE_REASONS.notPredicate) };
948
1232
  }
949
1233
 
950
1234
  if (node.name === '$and' || node.name === '$or') {
@@ -969,8 +1253,7 @@ function planPredicate(node, itSlot, shape) {
969
1253
  if (inner.refinements.length > 0) {
970
1254
  // negating a superset is a SUBSET, which drops matching rows —
971
1255
  // the one composition an implied conjunct may never enter
972
- return { refusal: refusal('$not',
973
- 'a negated predicate cannot ride an implied pre-filter (negating a superset drops rows)') };
1256
+ return { refusal: refusal('$not', PREDICATE_REASONS.negatedPrefilter) };
974
1257
  }
975
1258
  return { pred: { p: 'not', item: inner.pred },
976
1259
  exact: true, prefilters: inner.prefilters, refinements: [] };
@@ -979,11 +1262,12 @@ function planPredicate(node, itSlot, shape) {
979
1262
  const spatial = planSpatial(node, itSlot, shape);
980
1263
  if (spatial !== null) return spatial;
981
1264
 
1265
+ if (node.name === '$overlaps') return planIntervalOverlap(node, itSlot, shape);
1266
+
982
1267
  if (node.name === '$exists' || node.name === '$empty') {
983
1268
  const ref = pathRef(node.args[0], itSlot, shape);
984
1269
  if (ref === null) {
985
- return { refusal: refusal(node.name,
986
- 'existence tests translate only over a singular member path on the binding') };
1270
+ return { refusal: refusal(node.name, PREDICATE_REASONS.existence) };
987
1271
  }
988
1272
  return exactly({ p: 'typeIs', ref, types: [], positive: node.name === '$exists' });
989
1273
  }
@@ -1001,14 +1285,12 @@ function planPredicate(node, itSlot, shape) {
1001
1285
  const operand = operandOf(right);
1002
1286
  if (ref === null || operand === null) {
1003
1287
  if (pathRef(left, itSlot, shape) !== null && pathRef(right, itSlot, shape) !== null)
1004
- return { refusal: refusal(node.name, 'comparisons where both sides are paths are join territory') };
1005
- return { refusal: refusal(node.name,
1006
- 'comparisons translate only between a singular member path and a literal or external') };
1288
+ return { refusal: refusal(node.name, PREDICATE_REASONS.joinTerritory) };
1289
+ return { refusal: refusal(node.name, PREDICATE_REASONS.operands) };
1007
1290
  }
1008
1291
  if ('lit' in operand) {
1009
1292
  if (!isScalarLiteral(operand.lit)) {
1010
- return { refusal: refusal(node.name,
1011
- 'array and object literals have no guarded native comparison form') };
1293
+ return { refusal: refusal(node.name, PREDICATE_REASONS.compoundLiteral) };
1012
1294
  }
1013
1295
  const lit = operand.lit;
1014
1296
  if (typeof lit === 'boolean' || lit === null) {
@@ -1025,22 +1307,18 @@ function planPredicate(node, itSlot, shape) {
1025
1307
  const ref = pathRef(node.args[0], itSlot, shape);
1026
1308
  const operand = operandOf(node.args[1]);
1027
1309
  if (ref === null || ref.type !== 'string') {
1028
- return { refusal: refusal(node.name,
1029
- 'string operators translate only over schema-typed string paths (the engine ERRORS on non-string subjects)') };
1310
+ return { refusal: refusal(node.name, PREDICATE_REASONS.stringSubject) };
1030
1311
  }
1031
1312
  if (operand === null || !('lit' in operand) || typeof operand.lit !== 'string') {
1032
- return { refusal: refusal(node.name,
1033
- "string operators translate only with literal string patterns (an external pattern's type is unknowable at plan time)") };
1313
+ return { refusal: refusal(node.name, PREDICATE_REASONS.stringPattern) };
1034
1314
  }
1035
1315
  if (operand.lit === '') {
1036
- return { refusal: refusal(node.name,
1037
- "the empty pattern's vacuous-truth corner (true even on a missing member) is not translated") };
1316
+ return { refusal: refusal(node.name, PREDICATE_REASONS.emptyPattern) };
1038
1317
  }
1039
1318
  return exactly({ p: 'strop', kind: /** @type {any} */ (stringOp), ref, operand });
1040
1319
  }
1041
1320
 
1042
- return { refusal: refusal(node.name,
1043
- 'no native spelling of this operator is proven equivalent') };
1321
+ return { refusal: refusal(node.name, PREDICATE_REASONS.noSpelling) };
1044
1322
  }
1045
1323
 
1046
1324
  // ————— Time series: the three closed shapes over a declared index —————
@@ -1460,9 +1738,30 @@ function safeEpoch(value) {
1460
1738
  }
1461
1739
  }
1462
1740
 
1463
- /** An instant bound as a pushable conjunct over the instant column. */
1741
+ /**
1742
+ * An instant bound as a pushable conjunct over the instant column.
1743
+ *
1744
+ * Every caller is a REFINEMENT — the bound narrows and the kernel
1745
+ * decides over what comes back — and the member is one the model
1746
+ * declared a column for, so the bound reads that column and nothing
1747
+ * else. Without the guard the statement stops parsing every row's
1748
+ * document to discriminate a member whose column already carries it,
1749
+ * which is the whole cost of the fetch on a large series.
1750
+ */
1464
1751
  function instantBound(ref, op, value) {
1465
- return { p: 'cmp', op, ref, operand: { lit: value } };
1752
+ return ref.column !== null
1753
+ ? { p: 'colCmp', op, column: ref.column, operand: { lit: value } }
1754
+ : { p: 'cmp', op, ref, operand: { lit: value } };
1755
+ }
1756
+
1757
+ /** The same, for a bound whose value the database itself answers. */
1758
+ function seekBound(ref, op, name) {
1759
+ return { p: 'colCmp', op, column: ref.column, operand: { seek: name } };
1760
+ }
1761
+
1762
+ /** One key of the probes' own membership test, over the key column. */
1763
+ function keyBound(ref, key) {
1764
+ return { p: 'colCmp', op: 'eq', column: ref.column, operand: { lit: key } };
1466
1765
  }
1467
1766
 
1468
1767
  /**
@@ -1480,6 +1779,56 @@ function impliedInstantBounds(ref, from, to) {
1480
1779
  to, toOp: to === null ? null : 'le' } };
1481
1780
  }
1482
1781
 
1782
+ /**
1783
+ * The anchors an UNTOLERANCED as-of batch can ask the store for.
1784
+ *
1785
+ * A tolerance already closes both sides by arithmetic — `probes.min -
1786
+ * tolerance` is a real instant bound — so a seek would buy nothing and
1787
+ * none is built. Without one, the open side has no arithmetic bound at
1788
+ * all: the row that answers the earliest probe is the last row at or
1789
+ * before it, however far back that lies, and today the whole history
1790
+ * below the probes is fetched.
1791
+ *
1792
+ * The tight bound is the data's own. Per group, the row answering the
1793
+ * earliest probe sits at `MAX(at) WHERE at <= probes.min`; matches only
1794
+ * move FORWARD as the probe does, so no row below that group's anchor
1795
+ * can answer any probe. A single global `MAX` would be unsound — it can
1796
+ * come from a group whose anchor is later than another's, dropping that
1797
+ * other group's only candidate — so the fold is the LEAST of the
1798
+ * groups' anchors, which every group's answer is at or above. The
1799
+ * comparison stays inclusive, so rows sharing the anchor instant reach
1800
+ * the kernel and its duplicate rule decides among them.
1801
+ *
1802
+ * Ungrouped (no `by`), there is one group and the inner fold is the
1803
+ * answer. A keyed batch whose key names no column of its own gets no
1804
+ * seek: grouping by an extracted member would read the very rows the
1805
+ * anchor exists to skip.
1806
+ * @param {any} at - the instant ref, known to carry a column
1807
+ * @param {any} byRef - the key ref, or null when the batch has no key
1808
+ * @param {{ min: number, max: number, keys: any[] | null }} probes
1809
+ * @param {number | null} tolerance
1810
+ * @param {string} direction
1811
+ * @returns {any[]}
1812
+ */
1813
+ function anchorSeeks(at, byRef, probes, tolerance, direction) {
1814
+ if (tolerance !== null) return [];
1815
+ if (byRef !== null && byRef.column === null) return [];
1816
+ const group = byRef === null ? null : byRef;
1817
+ const keys = byRef === null ? null : probes.keys;
1818
+ const kind = at.type === 'string' ? 'text' : 'number';
1819
+ /** @type {any[]} */
1820
+ const found = [];
1821
+ if (direction === 'backward' || direction === 'nearest') {
1822
+ found.push({ name: 'asof.lower', kind, ref: at,
1823
+ bound: { op: 'le', lit: probes.min }, inner: 'max', outer: 'min', group, keys });
1824
+ }
1825
+ if (direction === 'forward' || direction === 'nearest') {
1826
+ found.push({ name: 'asof.upper', kind, ref: at,
1827
+ bound: { op: 'ge', lit: probes.max }, inner: 'min', outer: 'max', group, keys });
1828
+ }
1829
+ return found;
1830
+ }
1831
+
1483
1832
  /**
1484
1833
  * Plan a document that IS a series operator over the collection.
1485
1834
  *
@@ -1584,6 +1933,8 @@ function planSeriesOperator(root, shape) {
1584
1933
  // the refinement: narrow through the index by whatever the spec makes
1585
1934
  // provable, and let the engine's own kernel decide over what comes back
1586
1935
  let range = null;
1936
+ /** @type {any[]} the anchors the plan asks the store for, for explain */
1937
+ const seeks = [];
1587
1938
  if (name === '$resample' && at.column !== null) {
1588
1939
  const from = safeEpoch(spec.start);
1589
1940
  const to = safeEpoch(spec.end);
@@ -1597,6 +1948,8 @@ function planSeriesOperator(root, shape) {
1597
1948
  }
1598
1949
  else if (name === '$asof' && probesNode !== null) {
1599
1950
  const probes = literalInstants(probesNode, spec.leftAt, spec.by);
1951
+ const byRef = probes === null || spec.by === undefined ? null
1952
+ : memberRef(shape, /** @type {string} */ (singularSelector(spec.by)));
1600
1953
  if (probes !== null && at.column !== null) {
1601
1954
  const tolerance = toleranceMs(spec.tolerance);
1602
1955
  const direction = spec.direction ?? 'backward';
@@ -1620,17 +1973,26 @@ function planSeriesOperator(root, shape) {
1620
1973
  range = bounds.range;
1621
1974
  prefilters.push({ construct: name, via: 'columns', columns: [at.column], exact: false });
1622
1975
  }
1976
+ // the OPEN side, which no arithmetic over the probes can close:
1977
+ // the row answering the earliest probe may lie arbitrarily far
1978
+ // before it, so the tight bound is the data's own anchor
1979
+ for (const seek of anchorSeeks(at, byRef, probes, tolerance, direction)) {
1980
+ plan.seeks.push(seek);
1981
+ plan.filter = conjoin(plan.filter,
1982
+ seekBound(at, seek.bound.op === 'le' ? 'ge' : 'le', seek.name));
1983
+ seeks.push({ side: seek.bound.op === 'le' ? 'lower' : 'upper',
1984
+ column: at.column, probe: seek.bound.lit, op: seek.bound.op === 'le' ? 'ge' : 'le' });
1985
+ prefilters.push({ construct: name, via: 'columns', columns: [at.column], exact: false });
1986
+ }
1623
1987
  }
1624
1988
  // and the keys, whether or not the instant has a column of its own:
1625
1989
  // a right row whose group no left row names can match nothing, so a
1626
1990
  // membership test over the probes' own keys narrows and never drops
1627
1991
  if (probes !== null && probes.keys !== null && probes.keys.length > 0) {
1628
- const byRef = memberRef(shape, /** @type {string} */ (singularSelector(spec.by)));
1629
- if (byRef.column !== null) {
1992
+ if (byRef !== null && byRef.column !== null) {
1630
1993
  plan.filter = conjoin(plan.filter, probes.keys.length === 1
1631
- ? { p: 'cmp', op: 'eq', ref: byRef, operand: { lit: probes.keys[0] } }
1632
- : { p: 'or', items: probes.keys.map((key) =>
1633
- ({ p: 'cmp', op: 'eq', ref: byRef, operand: { lit: key } })) });
1994
+ ? keyBound(byRef, probes.keys[0])
1995
+ : { p: 'or', items: probes.keys.map((key) => keyBound(byRef, key)) });
1634
1996
  prefilters.push({ construct: name, via: 'columns',
1635
1997
  columns: [byRef.column], exact: false });
1636
1998
  }
@@ -1655,6 +2017,7 @@ function planSeriesOperator(root, shape) {
1655
2017
  index: narrowed && index !== null ? index.name : null,
1656
2018
  prefix: narrowed && index !== null ? index.prefix : [],
1657
2019
  range,
2020
+ seeks,
1658
2021
  refinement: { $resample: 'resampleSeries', $rolling: 'rollingSeries',
1659
2022
  $asof: 'asOfJoin' }[name],
1660
2023
  reasons,
@@ -1662,6 +2025,206 @@ function planSeriesOperator(root, shape) {
1662
2025
  };
1663
2026
  }
1664
2027
 
2028
+ /**
2029
+ * The GENERAL grouping: a `$groupby` whose keys are safe member paths
2030
+ * and whose `$return` is built from those keys, the closed aggregate
2031
+ * set, literals and constructors. `null` when the shape is not one the
2032
+ * plan can rebuild — the fixed temporal bucket is tried FIRST and is a
2033
+ * different, narrower promotion; this is what the rest of the groupings
2034
+ * fall to instead of the engine.
2035
+ *
2036
+ * The rules that make it agree with the engine, each one a refusal
2037
+ * rather than an approximation:
2038
+ *
2039
+ * - a key is a singular schema-typed path that cannot hold `null`, so
2040
+ * the group SQL forms is the group the engine forms. An ABSENT key is
2041
+ * its own group, and its value comes back with its JSON type beside
2042
+ * it, so the decoder can leave the member out exactly as the object
2043
+ * constructor does;
2044
+ * - the `$return` may not read the binding: after a grouping the tuple
2045
+ * variable holds the group's rows, and an object member of several
2046
+ * items is the engine's own error, not something to reproduce;
2047
+ * - an aggregate over no values is the ENGINE's answer: `$count` and
2048
+ * `$sum` are `0`, the other three are the empty sequence and their
2049
+ * member is omitted. SQL answers `NULL` for all of them, so the
2050
+ * mapping rides the plan.
2051
+ * @param {any} node - the FLWOR node
2052
+ * @param {number} itSlot
2053
+ * @param {any} shape
2054
+ * @returns {any | null}
2055
+ */
2056
+ function planGeneralGrouping(node, itSlot, shape) {
2057
+ const keys = [];
2058
+ /** @type {Map<number, number>} */
2059
+ const keySlot = new Map();
2060
+ for (const key of node.groupby.keys) {
2061
+ const ref = pathRef(key.expr, itSlot, shape);
2062
+ if (ref === null || ref.type === 'unknown' || admitsNull(shape.schema, ref.segments))
2063
+ return null;
2064
+ keySlot.set(key.slot, keys.length);
2065
+ keys.push({ as: key.name, ref });
2066
+ }
2067
+ const aggregates = [];
2068
+ /** @type {Map<string, number>} */
2069
+ const byAggregate = new Map();
2070
+ const build = (child) => {
2071
+ assertDecidedKind(child);
2072
+ if (child.kind === 'literal') return { p: 'lit', value: child.value };
2073
+ if (child.kind === 'var' && child.external !== true && keySlot.has(child.slot))
2074
+ return { p: 'key', index: keySlot.get(child.slot) };
2075
+ if (child.kind === 'op') {
2076
+ const entry = groupAggregate(child, itSlot, shape);
2077
+ if (entry === null) return null;
2078
+ // one aggregate per distinct (function, path): two members that
2079
+ // ask the same question are one SQL aggregate
2080
+ const identity = `${entry.fn}:${entry.ref === null ? '' : canonicalOf(entry.ref.segments)}`;
2081
+ let index = byAggregate.get(identity);
2082
+ if (index === undefined) {
2083
+ index = aggregates.length;
2084
+ aggregates.push(entry);
2085
+ byAggregate.set(identity, index);
2086
+ }
2087
+ return { p: 'agg', index };
2088
+ }
2089
+ if (child.kind === 'object') {
2090
+ const members = [];
2091
+ for (const entry of child.entries) {
2092
+ const built = build(entry.expr);
2093
+ if (built === null) return null;
2094
+ members.push({ name: entry.name, node: built });
2095
+ }
2096
+ return { p: 'object', members };
2097
+ }
2098
+ if (child.kind === 'array') {
2099
+ const items = [];
2100
+ for (const element of child.elements) {
2101
+ const built = build(element);
2102
+ if (built === null) return null;
2103
+ items.push(built);
2104
+ }
2105
+ return { p: 'array', items };
2106
+ }
2107
+ return null;
2108
+ };
2109
+ const tree = build(node.ret);
2110
+ if (tree === null) return null;
2111
+ const order = groupOrder(node.orderby, keySlot);
2112
+ if (order === null) return null;
2113
+ return { keys, aggregates, tree, order };
2114
+ }
2115
+
2116
+ /**
2117
+ * One aggregate of a grouped `$return`, or `null`. `$count` over the
2118
+ * BINDING is the group's row count; over a path it counts the rows that
2119
+ * HAVE the member, which `COUNT(column)` does not reproduce for a
2120
+ * stored `null`.
2121
+ * @param {any} node - an `op` node
2122
+ * @param {number} itSlot
2123
+ * @param {any} shape
2124
+ * @returns {{ as: string, fn: string, ref: any, empty: string } | null}
2125
+ */
2126
+ function groupAggregate(node, itSlot, shape) {
2127
+ if (node.name === '$count') {
2128
+ return isItVar(node.args[0], itSlot)
2129
+ ? { fn: 'rows', ref: null, empty: 'zero' } : null;
2130
+ }
2131
+ const fn = AGGREGATES.get(node.name);
2132
+ if (fn === undefined || fn === 'count') return null;
2133
+ const ref = pathRef(node.args[0], itSlot, shape);
2134
+ const numeric = fn === 'sum' || fn === 'avg';
2135
+ const acceptable = ref !== null
2136
+ && (numeric ? isNumericType(ref.type) : ref.type !== 'unknown')
2137
+ && ref.type !== 'boolean' && !admitsNull(shape.schema, ref.segments);
2138
+ if (!acceptable) return null;
2139
+ // what an aggregate over NO values says, in the ENGINE's words
2140
+ return { fn, ref, empty: fn === 'sum' ? 'zero' : 'omit' };
2141
+ }
2142
+
2143
+ /**
2144
+ * How the groups come out: the engine's order of FIRST APPEARANCE
2145
+ * (§6.5) when nothing declares otherwise, else the group-key ordering
2146
+ * an `$orderby` asked for. `null` when the ordering names anything but
2147
+ * group keys — after a grouping there is nothing else a row can order
2148
+ * by that the plan could reproduce.
2149
+ * @param {any} orderby
2150
+ * @param {Map<number, number>} keySlot
2151
+ * @returns {'first-seen' | { index: number, desc: boolean, nullsFirst: boolean }[] | null}
2152
+ */
2153
+ function groupOrder(orderby, keySlot) {
2154
+ if (orderby === null) return 'first-seen';
2155
+ const terms = [];
2156
+ for (const spec of orderby.specs) {
2157
+ if (spec.collation !== null || spec.collationName !== null) return null;
2158
+ const key = spec.key;
2159
+ if (key.kind !== 'var' || key.external === true || !keySlot.has(key.slot)) return null;
2160
+ terms.push({ index: keySlot.get(key.slot), desc: spec.desc === true,
2161
+ nullsFirst: (spec.emptyGreatest === true) === (spec.desc === true) });
2162
+ }
2163
+ return terms;
2164
+ }
2165
+
2166
+ /**
2167
+ * The projection TREE one `$return` compiles to, or `null` when the
2168
+ * shape is not one the plan can rebuild. Object and array constructors,
2169
+ * literals and singular member paths compose; anything else — a
2170
+ * function call, a conditional, a dynamic member, a reference to the
2171
+ * binding itself — refuses the WHOLE projection, because a projection
2172
+ * that dropped part of what the caller asked for would be a wrong
2173
+ * answer, not a partial one.
2174
+ *
2175
+ * Distinct paths are collected once: a path named twice is one fetched
2176
+ * column and two leaves pointing at it. A projection with NO path is
2177
+ * refused too — a statement needs a column to select, and a projection
2178
+ * of pure literals has nothing the database could contribute.
2179
+ * @param {any} node - the `$return` AST node
2180
+ * @param {number} itSlot
2181
+ * @param {any} shape
2182
+ * @returns {{ tree: any, leaves: import('./algebra.js').PlanRef[] } | null}
2183
+ */
2184
+ function projectionTree(node, itSlot, shape) {
2185
+ /** @type {import('./algebra.js').PlanRef[]} */
2186
+ const leaves = [];
2187
+ /** @type {Map<string, number>} */
2188
+ const byCanonical = new Map();
2189
+ const build = (child) => {
2190
+ assertDecidedKind(child);
2191
+ if (child.kind === 'literal') return { p: 'lit', value: child.value };
2192
+ if (child.kind === 'path') {
2193
+ const ref = pathRef(child, itSlot, shape);
2194
+ if (ref === null) return null;
2195
+ const canonical = canonicalOf(ref.segments);
2196
+ let index = byCanonical.get(canonical);
2197
+ if (index === undefined) {
2198
+ index = leaves.length;
2199
+ leaves.push(ref);
2200
+ byCanonical.set(canonical, index);
2201
+ }
2202
+ return { p: 'leaf', index };
2203
+ }
2204
+ if (child.kind === 'object') {
2205
+ const members = [];
2206
+ for (const entry of child.entries) {
2207
+ const built = build(entry.expr);
2208
+ if (built === null) return null;
2209
+ members.push({ name: entry.name, node: built });
2210
+ }
2211
+ return { p: 'object', members };
2212
+ }
2213
+ if (child.kind === 'array') {
2214
+ const items = [];
2215
+ for (const element of child.elements) {
2216
+ const built = build(element);
2217
+ if (built === null) return null;
2218
+ items.push(built);
2219
+ }
2220
+ return { p: 'array', items };
2221
+ }
2222
+ return null;
2223
+ };
2224
+ const tree = build(node);
2225
+ return tree === null || leaves.length === 0 ? null : { tree, leaves };
2226
+ }
2227
+
1665
2228
  /**
1666
2229
  * Plan a FLWOR node into a select plan, recording refusals. When a
1667
2230
  * conjunct refuses native translation, the injected `udf` hook may
@@ -1694,11 +2257,11 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1694
2257
  && binding.window === null && binding.atSlot === -1
1695
2258
  && binding.allowingEmpty === false;
1696
2259
  if (!sourceIsCollection) {
1697
- reasons.push(refusal('$for',
1698
- 'only a single plain binding over the whole collection is translated'));
2260
+ reasons.push(refusal('$for', FLWOR_REASONS.binding));
1699
2261
  return { plan, reasons, whereFullyPushed: false, orderPushed: false,
1700
- projectionNative: false, itSlot: -1, itName: null, udfs: [], prefilters: [],
1701
- knn: null, bucket: null, bucketRefusal: null };
2262
+ projectionNative: false, projectedTree: null,
2263
+ itSlot: -1, itName: null, udfs: [], prefilters: [],
2264
+ knn: null, bucket: null, group: null, bucketRefusal: null };
1702
2265
  }
1703
2266
  const itSlot = binding.slot;
1704
2267
  // the document's own name for the collection binding. The residual and
@@ -1709,20 +2272,26 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1709
2272
 
1710
2273
  if (node.fold !== null) reasons.push(refusal('$fold', KIND_REASONS.let));
1711
2274
  if (node.letBindings.length > 0) reasons.push(refusal('$let', KIND_REASONS.let));
1712
- if (node.asChecks !== null) reasons.push(refusal('$as', 'type assertions run in the engine'));
2275
+ if (node.asChecks !== null) reasons.push(refusal('$as', FLWOR_REASONS.as));
1713
2276
  // A grouping is an unconditional residual EXCEPT in one closed shape:
1714
2277
  // a fixed-width `$time-bucket` key with the exact aggregates, which
1715
2278
  // is a `GROUP BY` over integer arithmetic. The bucket then owns the
1716
2279
  // ordering and the projection too, so it is decided before either.
1717
2280
  let bucket = null;
2281
+ let group = null;
1718
2282
  let bucketRefusal = null;
1719
2283
  if (node.groupby !== null && node.fold === null && node.letBindings.length === 0
1720
2284
  && node.asChecks === null && node.count === null) {
1721
2285
  const grouped = planBucketGrouping(node, itSlot, shape);
1722
2286
  if ('bucket' in grouped) bucket = grouped.bucket;
1723
2287
  else {
1724
- bucketRefusal = grouped.code;
1725
- reasons.push(seriesReason(grouped.code, '$groupby'));
2288
+ // not the fixed temporal ladder: the GENERAL grouping is the next
2289
+ // question, and only when it refuses too does the engine group
2290
+ group = planGeneralGrouping(node, itSlot, shape);
2291
+ if (group === null) {
2292
+ bucketRefusal = grouped.code;
2293
+ reasons.push(seriesReason(grouped.code, '$groupby'));
2294
+ }
1726
2295
  }
1727
2296
  }
1728
2297
  else if (node.groupby !== null) reasons.push(refusal('$groupby', KIND_REASONS.let));
@@ -1753,8 +2322,12 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1753
2322
  ? udfHook(rawConjuncts[i], itName)
1754
2323
  : null;
1755
2324
  if (promoted !== null) {
2325
+ // the conjunct's own place in the caller's document rides the
2326
+ // call as a literal, so the shared function can report an
2327
+ // engine error where the caller wrote the fragment
1756
2328
  plan.filter = conjoin(plan.filter,
1757
- { p: 'udf', name: promoted.name, key: promoted.key });
2329
+ { p: 'udf', name: promoted.name, key: promoted.key,
2330
+ mount: split ? `/$where/$and/${i}` : '/$where' });
1758
2331
  udfs.push(promoted.name);
1759
2332
  }
1760
2333
  else {
@@ -1766,7 +2339,24 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1766
2339
  plan.filter = conjoin(plan.filter, outcome.pred);
1767
2340
  prefilters.push(...outcome.prefilters);
1768
2341
  // an IMPLIED conjunct narrows and leaves the original predicate
1769
- // for the residual, which is why it is reported as forcing one
2342
+ // to be decided — by the residual, or, when the hatch takes the
2343
+ // same fragment, by the engine's own operator IN the statement.
2344
+ // The composition is the ordinary index-friendly one: the cheap
2345
+ // conjunct prunes, the exact one decides, and every row the
2346
+ // exact one would RAISE on is still handed to it. Only the
2347
+ // interval promotion asks for it today; the spatial ones have
2348
+ // not been proven under the hatch and keep their residual
2349
+ const composed = outcome.refinements.length > 0 && outcome.composable === true
2350
+ && udfHook !== undefined && rawConjuncts[i] !== undefined
2351
+ ? udfHook(rawConjuncts[i], itName)
2352
+ : null;
2353
+ if (composed !== null) {
2354
+ plan.filter = conjoin(plan.filter,
2355
+ { p: 'udf', name: composed.name, key: composed.key,
2356
+ mount: split ? `/$where/$and/${i}` : '/$where' });
2357
+ udfs.push(composed.name);
2358
+ continue;
2359
+ }
1770
2360
  for (const refinement of outcome.refinements) {
1771
2361
  reasons.push(refinement);
1772
2362
  whereFullyPushed = false;
@@ -1781,9 +2371,12 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1781
2371
  // caller's to name once the window is known)
1782
2372
  let orderPushed = false;
1783
2373
  let knn = null;
1784
- const ranked = bucket !== null || node.orderby === null ? null
2374
+ const grouped = bucket !== null || group !== null;
2375
+ const ranked = grouped || node.orderby === null ? null
1785
2376
  : planKnnOrder(node.orderby, itSlot, shape, whereFullyPushed && structureClean);
1786
- if (bucket !== null) orderPushed = true; // the groups' order is the bucket's
2377
+ // a grouping owns its own ordering: the groups' order is the bucket's
2378
+ // or the group's, and an `$orderby` over the keys is inside it
2379
+ if (grouped) orderPushed = true;
1787
2380
  else if (ranked !== null) {
1788
2381
  if ('rank' in ranked) knn = ranked.rank;
1789
2382
  else reasons.push(ranked.refusal);
@@ -1794,13 +2387,11 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1794
2387
  for (const spec of node.orderby.specs) {
1795
2388
  const ref = pathRef(spec.key, itSlot, shape);
1796
2389
  if (ref === null || !orderable(ref, shape.schema)) {
1797
- refused = refusal('$orderby',
1798
- 'ordering translates only over singular schema-typed paths (numbers and strings that cannot hold null)');
2390
+ refused = refusal('$orderby', FLWOR_REASONS.orderPath);
1799
2391
  break;
1800
2392
  }
1801
2393
  if (spec.collation !== null || spec.collationName !== null) {
1802
- refused = refusal('$collation',
1803
- 'a collation the dialect cannot reproduce is refused, not approximated');
2394
+ refused = refusal('$collation', FLWOR_REASONS.collation);
1804
2395
  break;
1805
2396
  }
1806
2397
  terms.push({ ref, desc: spec.desc === true, emptyGreatest: spec.emptyGreatest === true });
@@ -1815,14 +2406,38 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1815
2406
  orderPushed = true; // nothing to push
1816
2407
  }
1817
2408
 
1818
- // RETURN: the bare binding is the native whole-document projection
2409
+ // RETURN: the bare binding is the native whole-document projection,
2410
+ // and a SINGLE member path over the binding projects that path into
2411
+ // the statement (its value beside its JSON type, so a present null,
2412
+ // a boolean and an absent member each read back as the engine
2413
+ // answers them). Nothing else is projected: the residual rules that
2414
+ // make a projection safe hold only when the whole selection is
2415
+ // pushed, which the caller decides — a projection that dropped a
2416
+ // member a residual conjunct still needs would be a wrong answer
1819
2417
  let projectionNative = false;
2418
+ /** @type {import('./algebra.js').PlanRef | null} */
2419
+ let projectedPath = null;
2420
+ /** @type {{ tree: any, leaves: any[] } | null} */
2421
+ let projectedTree = null;
1820
2422
  assertDecidedKind(node.ret);
1821
- if (bucket !== null) projectionNative = true; // the bucket IS the projection
2423
+ const tree = grouped || isItVar(node.ret, itSlot)
2424
+ ? null : projectionTree(node.ret, itSlot, shape);
2425
+ // a grouping IS the projection: its own tree rebuilds each group
2426
+ if (grouped) projectionNative = true;
1822
2427
  else if (isItVar(node.ret, itSlot)) projectionNative = true;
1823
2428
  else {
1824
- reasons.push(refusal('$return',
1825
- 'projections other than the bare binding run per row (the row residual)'));
2429
+ const ref = pathRef(node.ret, itSlot, shape);
2430
+ if (ref !== null) {
2431
+ projectionNative = true;
2432
+ projectedPath = ref;
2433
+ }
2434
+ else if (tree !== null) {
2435
+ projectionNative = true;
2436
+ projectedTree = tree;
2437
+ }
2438
+ else {
2439
+ reasons.push(refusal('$return', FLWOR_REASONS.projection));
2440
+ }
1826
2441
  }
1827
2442
 
1828
2443
  return {
@@ -1831,12 +2446,15 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1831
2446
  whereFullyPushed: whereFullyPushed && structureClean,
1832
2447
  orderPushed: orderPushed && structureClean,
1833
2448
  projectionNative,
2449
+ projectedPath,
2450
+ projectedTree,
1834
2451
  itSlot,
1835
2452
  itName,
1836
2453
  udfs,
1837
2454
  prefilters,
1838
2455
  knn,
1839
2456
  bucket,
2457
+ group,
1840
2458
  bucketRefusal,
1841
2459
  };
1842
2460
  }
@@ -1886,7 +2504,17 @@ function composeWindows(windows) {
1886
2504
  * `series` is the temporal record (`series.js`) when the document
1887
2505
  * asked a §8.16 question, and `null` when it did not.
1888
2506
  */
2507
+ /**
2508
+ * The whole-collection scan a bare root wildcard means: the degenerate
2509
+ * query of every pen (`'$[*]'`, LINQ-FORMAT's "the degenerate query is a
2510
+ * JSONPath string") is planned as the `$for` phrase over the collection
2511
+ * it abbreviates — a row cursor from an open statement — instead of the
2512
+ * path barrier that would fetch the collection whole and walk it in JS.
2513
+ */
2514
+ const ROOT_SCAN = Object.freeze({ $for: Object.freeze({ it: '$[*]' }), $return: '$it' });
2515
+
1889
2516
  function planCollectionCore(document, shape, options = undefined) {
2517
+ if (document === '$[*]') document = ROOT_SCAN;
1890
2518
  const analysis = analyzeQuery(document, analyzeOptionsFor(shape?.operators));
1891
2519
  let root = analysis.root;
1892
2520
  assertDecidedKind(root);
@@ -1901,7 +2529,7 @@ function planCollectionCore(document, shape, options = undefined) {
1901
2529
  // non-literal bounds: the whole document is a set residual
1902
2530
  return {
1903
2531
  analysis, plan: null, mode: 'set',
1904
- reasons: [refusal('$subsequence', 'window bounds must be literal numbers to push (non-negative integers)')],
2532
+ reasons: [refusal('$subsequence', PLAN_REASONS.windowBounds)],
1905
2533
  rowReturn: null, udfs: [], prefilters: [], series: null,
1906
2534
  };
1907
2535
  }
@@ -1911,17 +2539,26 @@ function planCollectionCore(document, shape, options = undefined) {
1911
2539
  assertDecidedKind(root);
1912
2540
  }
1913
2541
 
1914
- // a top-level aggregate over a FLWOR
2542
+ // a top-level aggregate over a FLWOR: one of the core five, or a
2543
+ // REGISTERED aggregate the store declared pushable and the driver can
2544
+ // register (Ring 3). Only a one-operand aggregate can be declared
2545
+ // pushable at all — a SQL fold over zero rows never sees a second
2546
+ // operand, and `aggregateSpec` refuses the declaration at open — so
2547
+ // the recognizer here reads the phrase and nothing else
1915
2548
  let aggregate = null;
1916
- if (root.kind === 'op' && AGGREGATES.has(root.name)) {
2549
+ const registeredAggregate = root.kind === 'op' && !AGGREGATES.has(root.name)
2550
+ ? (options?.aggregate?.(root.name) ?? null) : null;
2551
+ if (root.kind === 'op' && (AGGREGATES.has(root.name) || registeredAggregate !== null)) {
1917
2552
  if (windows.length > 0) {
1918
2553
  return {
1919
2554
  analysis, plan: null, mode: 'set',
1920
- reasons: [refusal(root.name, 'a windowed aggregate is not translated')],
2555
+ reasons: [refusal(root.name, PLAN_REASONS.windowedAggregate)],
1921
2556
  rowReturn: null, udfs: [], prefilters: [], series: null,
1922
2557
  };
1923
2558
  }
1924
- aggregate = { name: root.name, fn: AGGREGATES.get(root.name) };
2559
+ aggregate = registeredAggregate === null
2560
+ ? { name: root.name, fn: AGGREGATES.get(root.name) }
2561
+ : { name: root.name, fn: 'registered', sql: registeredAggregate.sql };
1925
2562
  root = root.args[0];
1926
2563
  rawInner = rawInner?.[aggregate.name] ?? rawInner;
1927
2564
  assertDecidedKind(root);
@@ -1955,8 +2592,7 @@ function planCollectionCore(document, shape, options = undefined) {
1955
2592
  if (root.kind !== 'flwor') {
1956
2593
  return {
1957
2594
  analysis, plan: null, mode: 'set',
1958
- reasons: [refusal(root.kind, KIND_REASONS[root.kind]
1959
- ?? 'only a FLWOR over the collection is translated')],
2595
+ reasons: [refusal(root.kind, KIND_REASONS[root.kind] ?? PLAN_REASONS.notFlwor)],
1960
2596
  rowReturn: null, udfs: [], prefilters: [], series: null,
1961
2597
  };
1962
2598
  }
@@ -1988,13 +2624,12 @@ function planCollectionCore(document, shape, options = undefined) {
1988
2624
  return { analysis, plan: null, mode: 'set', reasons: flwor.reasons,
1989
2625
  rowReturn: null, udfs: [], prefilters: flwor.prefilters, series: null };
1990
2626
  }
1991
- if (flwor.bucket !== null || flwor.bucketRefusal != null) {
2627
+ if (flwor.bucket !== null || flwor.group !== null || flwor.bucketRefusal != null) {
1992
2628
  // the phrase's items are its GROUPS; a COUNT(*) over the rows
1993
2629
  // answered the row count for a `$count` of the groups
1994
2630
  return {
1995
2631
  analysis, plan: null, mode: 'set',
1996
- reasons: [refusal(aggregate.name,
1997
- 'an aggregate over a grouped phrase folds its groups, which the engine does')],
2632
+ reasons: [refusal(aggregate.name, PLAN_REASONS.groupedAggregate)],
1998
2633
  rowReturn: null, udfs: [], prefilters: [], series: null,
1999
2634
  };
2000
2635
  }
@@ -2002,32 +2637,46 @@ function planCollectionCore(document, shape, options = undefined) {
2002
2637
  if (!flwor.projectionNative) {
2003
2638
  return {
2004
2639
  analysis, plan: null, mode: 'set',
2005
- reasons: [refusal('$count',
2006
- 'count translates only over the bare binding (a projected return can change the item count)')],
2640
+ reasons: [refusal('$count', PLAN_REASONS.countProjection)],
2007
2641
  rowReturn: null, udfs: [], prefilters: [], series: null,
2008
2642
  };
2009
2643
  }
2644
+ // a count over one member path counts the rows where the member
2645
+ // is PRESENT — an absent member yields no item — so the presence
2646
+ // test rides the WHERE and the count stays a COUNT(*)
2647
+ if (flwor.projectedPath !== null) {
2648
+ plan.filter = conjoin(plan.filter,
2649
+ { p: 'typeIs', ref: flwor.projectedPath, types: [], positive: true });
2650
+ }
2010
2651
  plan.aggregate = { fn: 'count', ref: null };
2011
2652
  return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
2012
2653
  udfs: flwor.udfs, prefilters: flwor.prefilters,
2013
2654
  series: classifySelection(plan, shape, true) };
2014
2655
  }
2015
2656
  const ref = pathRef(root.ret, flwor.itSlot, shape);
2016
- const numeric = aggregate.fn === 'sum' || aggregate.fn === 'avg';
2657
+ // a registered aggregate declares `seq<number>`, so its input is the
2658
+ // numeric family too — and a path that admits `null` is refused for
2659
+ // every aggregate alike: SQL cannot tell a stored null from an
2660
+ // absent member, and the engine's sequence has an item for one and
2661
+ // not the other
2662
+ const numeric = aggregate.fn === 'sum' || aggregate.fn === 'avg'
2663
+ || aggregate.fn === 'registered';
2017
2664
  const acceptable = ref !== null
2018
2665
  && (numeric ? isNumericType(ref.type) : ref.type !== 'unknown')
2019
2666
  && ref.type !== 'boolean' && !admitsNull(shape.schema, ref.segments);
2020
2667
  if (!acceptable) {
2021
2668
  return {
2022
2669
  analysis, plan: null, mode: 'set',
2023
- reasons: [refusal(aggregate.name,
2024
- 'aggregates translate only over a singular schema-typed path (the engine ERRORS on non-conforming operands)')],
2670
+ reasons: [refusal(aggregate.name, PLAN_REASONS.aggregatePath)],
2025
2671
  rowReturn: null, udfs: [], prefilters: [], series: null,
2026
2672
  };
2027
2673
  }
2028
- plan.aggregate = { fn: /** @type {any} */ (aggregate.fn), ref };
2674
+ plan.aggregate = aggregate.fn === 'registered'
2675
+ ? { fn: 'registered', ref, operator: aggregate.name, sql: aggregate.sql }
2676
+ : { fn: /** @type {any} */ (aggregate.fn), ref };
2029
2677
  return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
2030
- udfs: flwor.udfs, prefilters: flwor.prefilters,
2678
+ udfs: aggregate.fn === 'registered' ? [...flwor.udfs, aggregate.sql] : flwor.udfs,
2679
+ prefilters: flwor.prefilters,
2031
2680
  series: classifySelection(plan, shape, true) };
2032
2681
  }
2033
2682
 
@@ -2037,6 +2686,12 @@ function planCollectionCore(document, shape, options = undefined) {
2037
2686
  // the temporal bucket: only over a WHOLE pushed selection, because a
2038
2687
  // conjunct the residual would still apply would arrive after the rows
2039
2688
  // were already summed
2689
+ if (flwor.group !== null && fullyPushed && windows.length === 0 && aggregate === null) {
2690
+ plan.group = flwor.group;
2691
+ return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
2692
+ udfs: flwor.udfs, prefilters: flwor.prefilters, series: null };
2693
+ }
2694
+
2040
2695
  if (flwor.bucket !== null && fullyPushed && (windows.length === 0 || plan.window !== null)) {
2041
2696
  plan.bucket = flwor.bucket;
2042
2697
  const facts = filterFacts(plan.filter);
@@ -2058,14 +2713,25 @@ function planCollectionCore(document, shape, options = undefined) {
2058
2713
  };
2059
2714
  }
2060
2715
 
2061
- if (fullyPushed && flwor.projectionNative && (windows.length === 0 || plan.window !== null)) {
2716
+ // a RECOGNIZED grouping that did not lower: the engine groups, and the
2717
+ // projection branches below must not claim the shape as their own —
2718
+ // a grouping IS the projection, and answering it as one would answer
2719
+ // per row instead of per group
2720
+ const groupedResidual = flwor.group !== null || flwor.bucket !== null;
2721
+ if (groupedResidual && windows.length > 0)
2722
+ flwor.reasons.push(refusal('$groupby', PLAN_REASONS.windowedGroup));
2723
+
2724
+ if (!groupedResidual && fullyPushed && flwor.projectionNative
2725
+ && (windows.length === 0 || plan.window !== null)) {
2726
+ if (flwor.projectedPath !== null) plan.project = { path: flwor.projectedPath };
2727
+ else if (flwor.projectedTree !== null) plan.project = flwor.projectedTree;
2062
2728
  return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
2063
2729
  udfs: flwor.udfs, prefilters: flwor.prefilters,
2064
2730
  series: classifySelection(plan, shape, flwor.orderPushed) };
2065
2731
  }
2066
2732
 
2067
2733
  // the row residual: everything but the projection pushed
2068
- if (fullyPushed && !flwor.projectionNative
2734
+ if (!groupedResidual && fullyPushed && !flwor.projectionNative
2069
2735
  && (windows.length === 0 || plan.window !== null)) {
2070
2736
  const rawFlwor = rawInner;
2071
2737
  const name = flwor.itName ?? 'it';
@@ -2089,9 +2755,12 @@ function planCollectionCore(document, shape, options = undefined) {
2089
2755
  };
2090
2756
  }
2091
2757
 
2092
- // the set residual: pushed conjuncts narrow, the engine answers
2758
+ // the set residual: pushed conjuncts narrow, the engine answers — so
2759
+ // a temporal selection here is never `native`, whatever index it
2760
+ // seeks through: the index narrows the fetch (hybrid) or nothing does
2761
+ // (engine), and the record follows the PLAN mode, not the index alone
2093
2762
  const narrowing = flwor.bucketRefusal == null
2094
- ? classifySelection(plan, shape, false)
2763
+ ? underSetMode(classifySelection(plan, shape, false))
2095
2764
  : refinedGrouping(plan, shape, flwor.bucketRefusal, '$groupby');
2096
2765
  plan.order = null;
2097
2766
  plan.window = null;
@@ -2099,6 +2768,17 @@ function planCollectionCore(document, shape, options = undefined) {
2099
2768
  udfs: flwor.udfs, prefilters: flwor.prefilters, series: narrowing };
2100
2769
  }
2101
2770
 
2771
+ /**
2772
+ * A temporal record under a set-mode plan: the database only narrows,
2773
+ * the engine answers.
2774
+ * @param {any} series - a `classifySelection` record, or null
2775
+ * @returns {any}
2776
+ */
2777
+ function underSetMode(series) {
2778
+ if (series === null || series.mode !== 'native') return series;
2779
+ return { ...series, mode: series.index === null ? 'engine' : 'hybrid' };
2780
+ }
2781
+
2102
2782
  /**
2103
2783
  * Plan a whole document against one collection. The store's registered
2104
2784
  * operators (Ring 2) ride in `shape.operators` — the planner recognises
@@ -2209,6 +2889,99 @@ export function entityPathRef(node, slot, shape) {
2209
2889
  return { ...ref, flavor: 'entity-doc' };
2210
2890
  }
2211
2891
 
2892
+ /**
2893
+ * The projection TREE of an ENTITY query: the same closed shape a
2894
+ * collection projection composes — objects, arrays, literals and
2895
+ * singular member paths — with each leaf carrying the BINDING it reads
2896
+ * from, so the statement extracts it from that binding's alias. `null`
2897
+ * when the shape is not one the plan can rebuild, and a projection with
2898
+ * no path at all is refused for the same reason a collection's is: a
2899
+ * statement needs a column to select.
2900
+ * @param {any} node - the `$return` AST node
2901
+ * @param {Map<number, any>} byName - binding slot → binding
2902
+ * @returns {{ tree: any, leaves: { binding: string, ref: any }[] } | null}
2903
+ */
2904
+ function entityProjectionTree(node, byName) {
2905
+ const leaves = [];
2906
+ /** @type {Map<string, number>} */
2907
+ const byCanonical = new Map();
2908
+ const build = (child) => {
2909
+ assertDecidedKind(child);
2910
+ if (child.kind === 'literal') return { p: 'lit', value: child.value };
2911
+ if (child.kind === 'path') {
2912
+ const binding = child.external === true ? undefined : byName.get(child.rootSlot);
2913
+ if (binding === undefined) return null;
2914
+ const ref = entityPathRef(child, child.rootSlot, binding.shape);
2915
+ if (ref === null) return null;
2916
+ const identity = `${binding.name}${canonicalOf(ref.segments)}`;
2917
+ let index = byCanonical.get(identity);
2918
+ if (index === undefined) {
2919
+ index = leaves.length;
2920
+ leaves.push({ binding: binding.name, ref });
2921
+ byCanonical.set(identity, index);
2922
+ }
2923
+ return { p: 'leaf', index };
2924
+ }
2925
+ if (child.kind === 'object') {
2926
+ const members = [];
2927
+ for (const entry of child.entries) {
2928
+ const built = build(entry.expr);
2929
+ if (built === null) return null;
2930
+ members.push({ name: entry.name, node: built });
2931
+ }
2932
+ return { p: 'object', members };
2933
+ }
2934
+ if (child.kind === 'array') {
2935
+ const items = [];
2936
+ for (const element of child.elements) {
2937
+ const built = build(element);
2938
+ if (built === null) return null;
2939
+ items.push(built);
2940
+ }
2941
+ return { p: 'array', items };
2942
+ }
2943
+ return null;
2944
+ };
2945
+ const tree = build(node);
2946
+ return tree === null || leaves.length === 0 ? null : { tree, leaves };
2947
+ }
2948
+
2949
+ /**
2950
+ * A comparison whose two sides are member paths on DIFFERENT bindings,
2951
+ * as a join refinement — or `null` when it is not one this plan can
2952
+ * prove. Both sides must be mapped columns of the same comparison
2953
+ * family: SQL compares by column affinity where the engine compares by
2954
+ * JSON type, so a string column against a number column would answer
2955
+ * differently on the two sides, and a column that admits `null` would
2956
+ * make the comparison neither true nor false where the engine has an
2957
+ * answer. An epoch column is refused too — it stores an integer beside
2958
+ * a document string the engine reads, and the two need not order alike
2959
+ * across mixed stored precisions.
2960
+ * @param {any} node - an `op` node whose name is a comparison
2961
+ * @param {Map<number, any>} byName - binding slot → binding
2962
+ * @returns {{ op: string, left: any, right: any } | null}
2963
+ */
2964
+ function crossBindingComparison(node, byName) {
2965
+ const [left, right] = node.args;
2966
+ const leftBinding = left?.kind === 'path' ? byName.get(left.rootSlot) : undefined;
2967
+ const rightBinding = right?.kind === 'path' ? byName.get(right.rootSlot) : undefined;
2968
+ if (leftBinding === undefined || rightBinding === undefined
2969
+ || leftBinding === rightBinding) return null;
2970
+ const leftRef = entityPathRef(left, left.rootSlot, leftBinding.shape);
2971
+ const rightRef = entityPathRef(right, right.rootSlot, rightBinding.shape);
2972
+ const usable = (binding, ref) => ref !== null && ref.flavor === 'entity-column'
2973
+ && ref.type !== 'unknown' && ref.type !== 'boolean'
2974
+ && !admitsNull(binding.shape.schema, ref.segments);
2975
+ if (!usable(leftBinding, leftRef) || !usable(rightBinding, rightRef)) return null;
2976
+ const family = (ref) => (isNumericType(ref.type) ? 'number' : ref.type);
2977
+ if (family(leftRef) !== family(rightRef)) return null;
2978
+ return {
2979
+ op: COMPARISONS.get(node.name),
2980
+ left: { binding: leftBinding, ref: leftRef },
2981
+ right: { binding: rightBinding, ref: rightRef },
2982
+ };
2983
+ }
2984
+
2212
2985
  /**
2213
2986
  * Plan one predicate over an entity binding: the same operator
2214
2987
  * grammar as phase A, with entity-flavored refs. Reuses
@@ -2235,8 +3008,7 @@ export function planEntityPredicate(node, slot, shape) {
2235
3008
  // externals against DOC paths are not translated here (the
2236
3009
  // phase-A external forms assume the collection layout)
2237
3010
  if (pred.p === 'cmp' && 'ext' in pred.operand) {
2238
- blocked = { construct: '$eq',
2239
- reason: 'externals compare only against entity columns in this version' };
3011
+ blocked = refusal('$eq', ENTITY_REASONS.external);
2240
3012
  }
2241
3013
  return { ...pred, ref: { ...pred.ref, flavor: 'entity-doc' } };
2242
3014
  }
@@ -2244,8 +3016,7 @@ export function planEntityPredicate(node, slot, shape) {
2244
3016
  flavor: flavored.flavor, storage: flavored.storage, format: flavored.format };
2245
3017
  if (flavored.flavor === 'entity-epoch' && pred.p === 'cmp') {
2246
3018
  if ('ext' in pred.operand) {
2247
- blocked = { construct: pred.op,
2248
- reason: 'externals compare only against entity columns in this version' };
3019
+ blocked = refusal(pred.op, ENTITY_REASONS.external);
2249
3020
  return { ...pred, ref };
2250
3021
  }
2251
3022
  // the plan-time instant translation: an ordering comparison
@@ -2301,7 +3072,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2301
3072
  const [inner, start, length] = root.args;
2302
3073
  if (start?.kind !== 'literal' || !isWindowBound(start.value)
2303
3074
  || (length !== undefined && (length.kind !== 'literal' || !isWindowBound(length.value))))
2304
- return residual('$subsequence', 'window bounds must be literal numbers to push (non-negative integers)');
3075
+ return residual('$subsequence', PLAN_REASONS.windowBounds);
2305
3076
  windows.push({ offset: start.value, limit: length === undefined ? null : length.value });
2306
3077
  root = inner;
2307
3078
  assertDecidedKind(root);
@@ -2313,27 +3084,18 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2313
3084
  assertDecidedKind(root);
2314
3085
  }
2315
3086
  if (root.kind !== 'flwor')
2316
- return residual(root.kind, 'only a FLWOR over entity arrays is translated');
3087
+ return residual(root.kind, ENTITY_REASONS.notFlwor);
2317
3088
  if (root.fold !== null || root.letBindings.length > 0 || root.asChecks !== null
2318
3089
  || root.groupby !== null || root.count !== null)
2319
- return residual('$let', 'no equivalence proof exists yet; residual by default');
3090
+ return residual('$let', KIND_REASONS.let);
2320
3091
 
2321
3092
  // bindings must each range over one entity's array
2322
3093
  const bindings = [];
2323
3094
  for (const binding of root.forBindings) {
2324
- const source = unpacked(binding.expr);
2325
- const sourceEntity = source?.kind === 'path' && source.name === '$'
2326
- && source.external !== true && source.segments.length === 2
2327
- && source.segments[0].descendant !== true
2328
- && source.segments[0].selectors.length === 1
2329
- && source.segments[0].selectors[0].kind === 'name'
2330
- && source.segments[1].selectors?.length === 1
2331
- && source.segments[1].selectors[0].kind === 'wildcard'
2332
- ? source.segments[0].selectors[0].name
2333
- : null;
2334
- if (sourceEntity === null || !entities.has(sourceEntity)
3095
+ const sourceEntity = bindingEntity(binding, entities);
3096
+ if (sourceEntity === null
2335
3097
  || binding.window !== null || binding.atSlot !== -1 || binding.allowingEmpty !== false)
2336
- return residual('$for', 'bindings must each range over one declared entity array ($.Entity[*])');
3098
+ return residual('$for', ENTITY_REASONS.bindingRoot);
2337
3099
  bindings.push({
2338
3100
  name: binding.name,
2339
3101
  slot: binding.slot,
@@ -2341,9 +3103,6 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2341
3103
  shape: entityShape(entities.get(sourceEntity), mapping.entities[sourceEntity]),
2342
3104
  });
2343
3105
  }
2344
- if (bindings.length > 2)
2345
- return residual('$for', 'at most two bindings are translated (one join per statement)');
2346
-
2347
3106
  const byName = new Map(bindings.map((binding) => [binding.slot, binding]));
2348
3107
  const conjuncts = root.where === null
2349
3108
  ? []
@@ -2351,14 +3110,21 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2351
3110
  ? root.where.args
2352
3111
  : [root.where];
2353
3112
 
2354
- let joinOn = null;
3113
+ /** Every column equality between two DIFFERENT bindings: the edges of
3114
+ * the relation graph the join order is built over. */
3115
+ const edges = [];
3116
+ /** Cross-binding comparisons that are not equalities. They REFINE a
3117
+ * match, they never make one: a binding still attaches by an
3118
+ * equality, so a range between two bindings can never be the thing
3119
+ * that turns a product into a join. */
3120
+ const refinements = [];
2355
3121
  const filters = new Map(bindings.map((binding) => [binding.slot, null]));
2356
3122
  const reasons = [];
2357
3123
  let whereFullyPushed = true;
2358
3124
  for (const conjunct of conjuncts) {
2359
- // a key equality between the two bindings is the join condition
2360
- if (bindings.length === 2 && joinOn === null
2361
- && conjunct.kind === 'op' && conjunct.name === '$eq') {
3125
+ // a column equality between two bindings is a join edge, whatever
3126
+ // the binding count several between one pair simply conjoin
3127
+ if (bindings.length > 1 && conjunct.kind === 'op' && conjunct.name === '$eq') {
2362
3128
  const [left, right] = conjunct.args;
2363
3129
  const leftBinding = left.kind === 'path' ? byName.get(left.rootSlot) : undefined;
2364
3130
  const rightBinding = right.kind === 'path' ? byName.get(right.rootSlot) : undefined;
@@ -2367,20 +3133,28 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2367
3133
  const leftRef = entityPathRef(left, left.rootSlot, leftBinding.shape);
2368
3134
  const rightRef = entityPathRef(right, right.rootSlot, rightBinding.shape);
2369
3135
  if (leftRef?.flavor === 'entity-column' && rightRef?.flavor === 'entity-column') {
2370
- joinOn = {
3136
+ edges.push({
2371
3137
  left: { binding: leftBinding, ref: leftRef },
2372
3138
  right: { binding: rightBinding, ref: rightRef },
2373
- };
3139
+ });
2374
3140
  continue;
2375
3141
  }
2376
3142
  }
2377
3143
  }
3144
+ // a cross-binding comparison that is not an equality: a refinement
3145
+ if (bindings.length > 1 && conjunct.kind === 'op'
3146
+ && COMPARISONS.has(conjunct.name) && conjunct.name !== '$eq') {
3147
+ const refinement = crossBindingComparison(conjunct, byName);
3148
+ if (refinement !== null) {
3149
+ refinements.push(refinement);
3150
+ continue;
3151
+ }
3152
+ }
2378
3153
  // otherwise the conjunct must belong wholly to ONE binding
2379
3154
  const slots = new Set();
2380
3155
  collectBindingSlots(conjunct, byName, slots);
2381
3156
  if (slots.size !== 1) {
2382
- reasons.push({ construct: '$where',
2383
- reason: 'a conjunct must belong to one binding (or be the single join equality)' });
3157
+ reasons.push(refusal('$where', ENTITY_REASONS.conjunctBinding));
2384
3158
  whereFullyPushed = false;
2385
3159
  continue;
2386
3160
  }
@@ -2394,15 +3168,61 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2394
3168
  }
2395
3169
  filters.set(slot, conjoin(filters.get(slot), outcome.pred));
2396
3170
  }
2397
- if (bindings.length === 2 && joinOn === null)
2398
- return residual('$for', 'two bindings need a key equality between them (the join condition)');
3171
+ // the join ORDER: start at the first binding and attach, one at a
3172
+ // time, any binding an edge connects to what is already attached.
3173
+ // A binding nothing connects would be a CARTESIAN product — the one
3174
+ // thing a nested-loop plan must never emit by accident — so a graph
3175
+ // that does not close is the residual, named
3176
+ const joins = [];
3177
+ if (bindings.length > 1) {
3178
+ const attached = new Set([bindings[0].name]);
3179
+ joins.push({ binding: bindings[0].name, on: [] });
3180
+ let progress = true;
3181
+ while (attached.size < bindings.length && progress) {
3182
+ progress = false;
3183
+ for (const binding of bindings) {
3184
+ if (attached.has(binding.name)) continue;
3185
+ const on = edges.filter((edge) =>
3186
+ (edge.left.binding === binding && attached.has(edge.right.binding.name))
3187
+ || (edge.right.binding === binding && attached.has(edge.left.binding.name)));
3188
+ if (on.length === 0) continue;
3189
+ joins.push({ binding: binding.name, on });
3190
+ attached.add(binding.name);
3191
+ progress = true;
3192
+ break;
3193
+ }
3194
+ }
3195
+ if (attached.size < bindings.length)
3196
+ return residual('$for', ENTITY_REASONS.joinKey);
3197
+ // an edge between two bindings that were BOTH already attached is a
3198
+ // further equality, not another join: it rides the later one's ON,
3199
+ // which is where a nested loop can use it. A non-equality refinement
3200
+ // rides the same place, for the same reason
3201
+ const place = (entry) => {
3202
+ const later = joins.findLast((join) =>
3203
+ join.binding === entry.left.binding.name || join.binding === entry.right.binding.name);
3204
+ later.on.push(entry);
3205
+ };
3206
+ for (const edge of edges) {
3207
+ if (!joins.some((join) => join.on.includes(edge))) place(edge);
3208
+ }
3209
+ for (const refinement of refinements) place(refinement);
3210
+ }
3211
+ else if (refinements.length > 0) {
3212
+ // one binding cannot have a cross-binding comparison; this is
3213
+ // unreachable, and the graph walk above is what makes it so
3214
+ reasons.push(refusal('$where', ENTITY_REASONS.conjunctBinding));
3215
+ whereFullyPushed = false;
3216
+ }
2399
3217
 
2400
- // the return must be one bare binding
3218
+ // the return is one bare binding — the entity's own documents — or a
3219
+ // SHAPE the projection tree rebuilds from the bindings' members
2401
3220
  const retBinding = root.ret.kind === 'var' && root.ret.external !== true
2402
3221
  ? byName.get(root.ret.slot) : undefined;
2403
- if (retBinding === undefined) {
2404
- reasons.push({ construct: '$return',
2405
- reason: 'entity queries return one bare binding natively; projections run in the engine' });
3222
+ const projection = retBinding === undefined && aggregate === null
3223
+ ? entityProjectionTree(root.ret, byName) : null;
3224
+ if (retBinding === undefined && projection === null) {
3225
+ reasons.push(refusal('$return', ENTITY_REASONS.projection));
2406
3226
  }
2407
3227
 
2408
3228
  // ordering over flavored refs of either binding
@@ -2423,8 +3243,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2423
3243
  || (ref.flavor === 'entity-doc' && admitsNull(binding.shape.schema, ref.segments))
2424
3244
  || spec.collation !== null || spec.collationName !== null) {
2425
3245
  orderPushed = false;
2426
- reasons.push({ construct: '$orderby',
2427
- reason: 'ordering translates only over typed entity paths (never a boolean, never a document path that admits null)' });
3246
+ reasons.push(refusal('$orderby', ENTITY_REASONS.order));
2428
3247
  break;
2429
3248
  }
2430
3249
  terms.push({ binding, ref, desc: spec.desc === true, emptyGreatest: spec.emptyGreatest === true });
@@ -2432,8 +3251,8 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2432
3251
  if (orderPushed) order = terms;
2433
3252
  }
2434
3253
 
2435
- const fullyPushed = whereFullyPushed && orderPushed && retBinding !== undefined
2436
- && (aggregate === null || retBinding !== undefined);
3254
+ const fullyPushed = whereFullyPushed && orderPushed
3255
+ && (retBinding !== undefined || projection !== null);
2437
3256
  if (!fullyPushed) {
2438
3257
  return { analysis, mode: 'set', plan: null, referenced, reasons };
2439
3258
  }
@@ -2458,12 +3277,19 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2458
3277
  reasons: [],
2459
3278
  plan: {
2460
3279
  planVersion: PLAN_VERSION,
2461
- alg: bindings.length === 2 ? 'entity-join' : 'entity-select',
3280
+ alg: bindings.length > 1 ? 'entity-join' : 'entity-select',
2462
3281
  bindings: bindings.map((binding) => ({ name: binding.name, entity: binding.entity })),
2463
- joinOn: joinOn === null ? null : {
2464
- left: { binding: joinOn.left.binding.name, column: joinOn.left.ref.column },
2465
- right: { binding: joinOn.right.binding.name, column: joinOn.right.ref.column },
2466
- },
3282
+ // the FROM order and each binding's join conditions; `bindings`
3283
+ // stays in the DOCUMENT's order, which is the nested-loop order
3284
+ // the ORDER BY reproduces
3285
+ joins: joins.map((join) => ({
3286
+ binding: join.binding,
3287
+ on: join.on.map((edge) => ({
3288
+ op: edge.op ?? 'eq',
3289
+ left: { binding: edge.left.binding.name, column: edge.left.ref.column },
3290
+ right: { binding: edge.right.binding.name, column: edge.right.ref.column },
3291
+ })),
3292
+ })),
2467
3293
  filters: bindings.map((binding) => ({
2468
3294
  binding: binding.name,
2469
3295
  filter: filters.get(binding.slot),
@@ -2474,7 +3300,13 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2474
3300
  })),
2475
3301
  window,
2476
3302
  aggregate,
2477
- ret: retBinding.name,
3303
+ ret: retBinding === undefined ? null : retBinding.name,
3304
+ // the projected shape, when the return is one: leaves that name
3305
+ // the binding they read from, and the tree the decoder rebuilds
3306
+ project: projection === null ? null : {
3307
+ tree: projection.tree,
3308
+ leaves: projection.leaves.map((leaf) => ({ binding: leaf.binding, ref: leaf.ref })),
3309
+ },
2478
3310
  },
2479
3311
  };
2480
3312
  }
@@ -2491,13 +3323,59 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2491
3323
  */
2492
3324
  export function planEntityQuery(document, entities, mapping, operators = null) {
2493
3325
  const peeled = peelWrappedResult(document);
3326
+ const core = planEntityQueryCore(peeled.document, entities, mapping, operators);
2494
3327
  const planned = {
2495
- ...planEntityQueryCore(peeled.document, entities, mapping, operators),
3328
+ ...core,
2496
3329
  wrapped: peeled.wrapped,
3330
+ // the entity whose documents the query yields, in EITHER mode: what
3331
+ // a tracked cursor registers, and `null` when there is nothing to
3332
+ // register — a projection, a count, a window handed over whole
3333
+ retEntity: peeled.wrapped ? null : returnedEntity(core.analysis, entities),
2497
3334
  };
2498
3335
  return prependRegisteredReason(planned, document, operators);
2499
3336
  }
2500
3337
 
3338
+ /**
3339
+ * The declared entity a `$for` binding ranges over — the array
3340
+ * `$.<Entity>[*]`, spelled exactly so — or `null` for any other source.
3341
+ * @param {any} binding - an analysed `$for` binding
3342
+ * @param {Map<string, any>} entities
3343
+ * @returns {string | null}
3344
+ */
3345
+ function bindingEntity(binding, entities) {
3346
+ const source = unpacked(binding.expr);
3347
+ const name = source?.kind === 'path' && source.name === '$'
3348
+ && source.external !== true && source.segments.length === 2
3349
+ && source.segments[0].descendant !== true
3350
+ && source.segments[0].selectors.length === 1
3351
+ && source.segments[0].selectors[0].kind === 'name'
3352
+ && source.segments[1].selectors?.length === 1
3353
+ && source.segments[1].selectors[0].kind === 'wildcard'
3354
+ ? source.segments[0].selectors[0].name
3355
+ : null;
3356
+ return name !== null && entities.has(name) ? name : null;
3357
+ }
3358
+
3359
+ /**
3360
+ * The entity whose documents a query RETURNS: under any literal
3361
+ * windows, a FLWOR whose `$return` is one bare binding over a declared
3362
+ * entity array. `null` for a projection, a count, or a binding over
3363
+ * anything else — the items are then not entity documents, and a
3364
+ * tracked cursor has nothing it may register.
3365
+ * @param {any} analysis - the engine's analysis of the document
3366
+ * @param {Map<string, any>} entities
3367
+ * @returns {string | null}
3368
+ */
3369
+ function returnedEntity(analysis, entities) {
3370
+ let root = analysis.root;
3371
+ while (root.kind === 'op' && root.name === '$subsequence') root = root.args[0];
3372
+ if (root.kind !== 'flwor') return null;
3373
+ const ret = root.ret;
3374
+ if (ret.kind !== 'var' || ret.external === true) return null;
3375
+ const binding = root.forBindings.find((candidate) => candidate.slot === ret.slot);
3376
+ return binding === undefined ? null : bindingEntity(binding, entities);
3377
+ }
3378
+
2501
3379
  /** Which binding slots a subtree references (via path roots). */
2502
3380
  function collectBindingSlots(node, byName, slots) {
2503
3381
  if (node === null || typeof node !== 'object') return;
@@ -2512,6 +3390,109 @@ function collectBindingSlots(node, byName, slots) {
2512
3390
  }
2513
3391
  }
2514
3392
 
3393
+ /**
3394
+ * Every member of one root a document READS, and whether it reads a
3395
+ * root item WHOLE. This walks the ANALYSIS — the normalized AST, where
3396
+ * a path is already resolved to the binding slot it hangs off — not the
3397
+ * document text, which cannot tell the path `$it.name` from a member
3398
+ * literally called `$it.name`.
3399
+ *
3400
+ * A path that is not singular (a wildcard, a slice, a descendant) reads
3401
+ * a SUBTREE, and its longest singular prefix is what a policy sees:
3402
+ * allowing a member allows everything under it, so the prefix is the
3403
+ * honest unit. A path with no singular prefix at all, and a bare
3404
+ * reference to the binding itself, read the whole item — reported as
3405
+ * `whole` with the construct that did it, because no member list can
3406
+ * cover them and narrowing one silently would be the wrong answer.
3407
+ *
3408
+ * @param {any} root - the analysis root node
3409
+ * @param {(expr: any) => boolean} isRootSource - whether one
3410
+ * `$for` binding ranges over the root being policed
3411
+ * @returns {{ members: { canonical: string, member: string, docPath: string }[],
3412
+ * whole: { construct: string, docPath: string } | null }}
3413
+ */
3414
+ export function collectMemberReads(root, isRootSource) {
3415
+ /** @type {Set<number>} */
3416
+ const slots = new Set();
3417
+ const walk = (node, visit) => {
3418
+ if (node === null || typeof node !== 'object') return;
3419
+ if (Array.isArray(node)) {
3420
+ for (const item of node) walk(item, visit);
3421
+ return;
3422
+ }
3423
+ visit(node);
3424
+ for (const key of Object.keys(node)) {
3425
+ if (key === 'docPath') continue;
3426
+ walk(node[key], visit);
3427
+ }
3428
+ };
3429
+ walk(root, (node) => {
3430
+ if (node.kind !== 'flwor' || !Array.isArray(node.forBindings)) return;
3431
+ for (const binding of node.forBindings) {
3432
+ if (isRootSource(binding?.expr)) slots.add(binding.slot);
3433
+ }
3434
+ });
3435
+
3436
+ /** @type {Map<string, { canonical: string, member: string, docPath: string }>} */
3437
+ const members = new Map();
3438
+ /** @type {{ construct: string, docPath: string } | null} */
3439
+ let whole = null;
3440
+ const readsWhole = (construct, docPath) => {
3441
+ if (whole === null) whole = { construct, docPath: docPath ?? '$' };
3442
+ };
3443
+ walk(root, (node) => {
3444
+ if (node.external === true) return;
3445
+ if (node.kind === 'var' && slots.has(node.slot)) {
3446
+ readsWhole('$' + (node.name ?? ''), node.docPath);
3447
+ return;
3448
+ }
3449
+ if (node.kind !== 'path' || !slots.has(node.rootSlot)) return;
3450
+ /** @type {({ name: string } | { index: number })[]} */
3451
+ const segments = [];
3452
+ for (const segment of node.segments ?? []) {
3453
+ if (segment.descendant === true || segment.selectors.length !== 1) break;
3454
+ const selector = segment.selectors[0];
3455
+ if (selector.kind === 'name') segments.push({ name: selector.name });
3456
+ else if (selector.kind === 'index') segments.push({ index: selector.index });
3457
+ else break;
3458
+ }
3459
+ if (segments.length === 0) {
3460
+ readsWhole('$' + (node.name ?? ''), node.docPath);
3461
+ return;
3462
+ }
3463
+ const canonical = canonicalOf(segments);
3464
+ if (!members.has(canonical)) {
3465
+ members.set(canonical, { canonical,
3466
+ member: segments.map((segment) => ('name' in segment ? segment.name : segment.index))
3467
+ .join('.'),
3468
+ docPath: node.docPath ?? '$' });
3469
+ }
3470
+ });
3471
+ return { members: [...members.values()], whole };
3472
+ }
3473
+
3474
+ /**
3475
+ * Whether one `$for` binding ranges over the whole collection — the one
3476
+ * spelling `$[*]`, bare or packed, that {@link collectMemberReads}
3477
+ * policies against on the collection side.
3478
+ * @param {any} expr
3479
+ * @returns {boolean}
3480
+ */
3481
+ export function isRootScanSource(expr) {
3482
+ return isCollectionSource(expr);
3483
+ }
3484
+
3485
+ /**
3486
+ * Whether one `$for` binding ranges over a named entity's array — the
3487
+ * entity-side counterpart of {@link isRootScanSource}, reading the one
3488
+ * spelling {@link entityRoot} publishes.
3489
+ * @param {string} name - a declared entity name
3490
+ * @returns {(expr: any) => boolean}
3491
+ */
3492
+ export function isEntityRootSource(name) {
3493
+ return (expr) => bindingEntity({ expr }, new Map([[name, true]])) === name;
3494
+ }
3495
+
2515
3496
  /**
2516
3497
  * The root expression an entity's rows are bound through — the ONE
2517
3498
  * spelling of `$.<Name>[*]`: what an entity set exposes as its `root`