@jarenjs/db 0.49.2 → 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 (110) hide show
  1. package/ARCHITECTURE.md +420 -71
  2. package/README.md +711 -79
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +309 -45
  5. package/docs/LIVE-FORMAT.md +156 -19
  6. package/docs/MIGRATION-FORMAT.md +247 -40
  7. package/docs/MODEL-FORMAT.md +968 -86
  8. package/package.json +21 -8
  9. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  10. package/schemas/jaren-migration.schema.json +73 -0
  11. package/schemas/jaren-model.draft-07.schema.json +224 -162
  12. package/schemas/jaren-model.schema.json +224 -162
  13. package/src/algebra.js +227 -9
  14. package/src/backup.js +161 -0
  15. package/src/cancellation.js +48 -0
  16. package/src/capture.js +255 -44
  17. package/src/cli.js +337 -50
  18. package/src/cursor.js +411 -0
  19. package/src/dag-job.js +154 -21
  20. package/src/ddl.js +125 -11
  21. package/src/dialect.js +267 -112
  22. package/src/dialects/expression-read.js +158 -0
  23. package/src/dialects/postgres.js +618 -0
  24. package/src/dialects/rtree-ddl.js +129 -0
  25. package/src/dialects/sqlite.js +245 -12
  26. package/src/document-files.js +311 -0
  27. package/src/document-steps.js +422 -0
  28. package/src/documents.js +335 -0
  29. package/src/driver.js +503 -69
  30. package/src/drivers/bun.js +37 -1
  31. package/src/drivers/indexeddb-snapshot.js +149 -0
  32. package/src/drivers/node-pool.js +11 -0
  33. package/src/drivers/node-worker-endpoint.js +105 -0
  34. package/src/drivers/node-worker.js +204 -0
  35. package/src/drivers/node.js +41 -7
  36. package/src/drivers/postgres.js +331 -0
  37. package/src/drivers/wasm-oo1.js +97 -0
  38. package/src/drivers/wasm-session.js +67 -0
  39. package/src/drivers/wasm.js +18 -83
  40. package/src/drivers/worker-pool.js +183 -0
  41. package/src/drivers/worker-protocol.js +79 -0
  42. package/src/drivers/worker-queue.js +60 -0
  43. package/src/emit-model.js +14 -0
  44. package/src/emit.js +349 -51
  45. package/src/entity.js +102 -59
  46. package/src/errors.js +430 -2
  47. package/src/expression.js +284 -0
  48. package/src/graph.js +64 -8
  49. package/src/index.js +48 -19
  50. package/src/introspect.js +583 -0
  51. package/src/jobs.js +870 -99
  52. package/src/json-bytes.js +58 -0
  53. package/src/live-time.js +12 -3
  54. package/src/live.js +11 -1
  55. package/src/maintenance.js +175 -0
  56. package/src/migrate.js +606 -333
  57. package/src/model.js +241 -8
  58. package/src/plan.js +1238 -160
  59. package/src/pragmas.js +314 -0
  60. package/src/profile.js +151 -3
  61. package/src/query.js +1748 -312
  62. package/src/residual.js +17 -0
  63. package/src/series.js +12 -4
  64. package/src/store.js +1672 -276
  65. package/src/tracker.js +367 -68
  66. package/src/udf.js +88 -7
  67. package/types/index.d.ts +1246 -32
  68. package/types/node-pool.d.ts +28 -0
  69. package/types/node-worker.d.ts +54 -0
  70. package/types/node.d.ts +72 -3
  71. package/types/postgres.d.ts +46 -0
  72. package/types/typed.d.ts +81 -3
  73. package/types/wasm.d.ts +21 -0
  74. package/dist/types/algebra.d.ts +0 -230
  75. package/dist/types/app.d.ts +0 -49
  76. package/dist/types/capture.d.ts +0 -85
  77. package/dist/types/cli.d.ts +0 -2
  78. package/dist/types/dag-job.d.ts +0 -40
  79. package/dist/types/ddl.d.ts +0 -229
  80. package/dist/types/derive.d.ts +0 -250
  81. package/dist/types/dialect.d.ts +0 -154
  82. package/dist/types/dialects/sqlite.d.ts +0 -9
  83. package/dist/types/driver.d.ts +0 -110
  84. package/dist/types/drivers/bun.d.ts +0 -47
  85. package/dist/types/drivers/node.d.ts +0 -37
  86. package/dist/types/drivers/wasm.d.ts +0 -65
  87. package/dist/types/emit-model.d.ts +0 -44
  88. package/dist/types/emit.d.ts +0 -75
  89. package/dist/types/entity.d.ts +0 -23
  90. package/dist/types/errors.d.ts +0 -170
  91. package/dist/types/graph.d.ts +0 -28
  92. package/dist/types/index.d.ts +0 -37
  93. package/dist/types/jobs.d.ts +0 -140
  94. package/dist/types/knn.d.ts +0 -69
  95. package/dist/types/live-time.d.ts +0 -141
  96. package/dist/types/live.d.ts +0 -64
  97. package/dist/types/migrate.d.ts +0 -170
  98. package/dist/types/model.d.ts +0 -36
  99. package/dist/types/patch-sql.d.ts +0 -37
  100. package/dist/types/plan.d.ts +0 -142
  101. package/dist/types/profile.d.ts +0 -80
  102. package/dist/types/query.d.ts +0 -112
  103. package/dist/types/residual.d.ts +0 -64
  104. package/dist/types/series.d.ts +0 -227
  105. package/dist/types/store.d.ts +0 -60
  106. package/dist/types/tracker.d.ts +0 -43
  107. package/dist/types/typed.d.ts +0 -15
  108. package/dist/types/types.d.ts +0 -26
  109. package/dist/types/udf.d.ts +0 -75
  110. package/dist/types/window.d.ts +0 -52
package/src/plan.js CHANGED
@@ -45,14 +45,14 @@ import { compileBuckets, resampleSeries, toEpoch } from '@jarenjs/core/series';
45
45
 
46
46
  import { selectPlan, conjoin, PLAN_VERSION } from './algebra.js';
47
47
  import { typeOfPath, isNumericType } from './types.js';
48
- import { schemaNodeAt } from './ddl.js';
48
+ import { schemaNodeAt, canonicalOf } from './ddl.js';
49
49
  import {
50
50
  BBOX_COMPONENTS, BBOX_INDEX_ORDER, PRECISION_MIN, PRECISION_MAX,
51
51
  probeBox, probePosition, probeCircleBox, cellNeighbourhood, probeVector,
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,8 +218,35 @@ for (const kind of NODE_KINDS) {
119
218
  }
120
219
  }
121
220
 
221
+ /** Whether a literal window bound is one SQL takes as written: a
222
+ * non-negative safe integer. A negative, fractional or non-finite bound
223
+ * is the ENGINE's to interpret (it answers `[]`, a truncation or a
224
+ * refusal), and interpolated into `LIMIT`/`OFFSET` it answered other
225
+ * rows or a raw database error. */
226
+ function isWindowBound(value) {
227
+ return Number.isSafeInteger(value) && value >= 0;
228
+ }
229
+
230
+ /** Whether a schema node admits `null` beside its scalar type. A present
231
+ * null orders and aggregates in SQL (NULLS FIRST, skipped by SUM) where
232
+ * the engine refuses it (`JQ2005`, `JQ2001`), so a promotion over such a
233
+ * path answers where the reference semantics throw. */
234
+ function admitsNull(schema, segments) {
235
+ const node = schemaNodeAt(schema, segments);
236
+ return Array.isArray(node?.type) && node.type.includes('null');
237
+ }
238
+
239
+ /** Whether a typed reference orders natively: numbers and strings that
240
+ * cannot hold a null. A boolean orders as 0/1 in SQL and is `JQ2005` in
241
+ * the engine. */
242
+ function orderable(ref, schema) {
243
+ return ref.type !== 'unknown' && ref.type !== 'boolean' && !admitsNull(schema, ref.segments);
244
+ }
245
+
122
246
  /**
123
- * One named refusal.
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.
124
250
  * @param {string} construct
125
251
  * @param {string} reason
126
252
  * @returns {{ construct: string, reason: string }}
@@ -222,16 +348,10 @@ function prependRegisteredReason(planned, document, operators) {
222
348
  const residualPart = planned.mode === 'row' ? planned.rowReturn : document;
223
349
  const used = registeredOpsUsed(residualPart, registered);
224
350
  if (used.length === 0) return planned;
225
- const many = used.length > 1;
226
351
  return {
227
352
  ...planned,
228
353
  reasons: [
229
- {
230
- construct: used.join(', '),
231
- reason: `registered operator${many ? 's' : ''} `
232
- + `${used.map((n) => `'${n}'`).join(', ')} run${many ? '' : 's'} in the residual `
233
- + '(Ring 2 — correct, not pushed to SQL)',
234
- },
354
+ refusal(used.join(', '), OPERATOR_REASONS.registered(used)),
235
355
  ...planned.reasons,
236
356
  ],
237
357
  };
@@ -268,9 +388,7 @@ function memberPath(node, itSlot) {
268
388
  else return null;
269
389
  }
270
390
  if (segments.length === 0) return null;
271
- const canonical = segments
272
- .map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
273
- return { segments, canonical };
391
+ return { segments, canonical: canonicalOf(segments) };
274
392
  }
275
393
 
276
394
  /**
@@ -363,6 +481,25 @@ function isGeographicSchema(node) {
363
481
  /** `$geohash`'s default precision (QUERY-FORMAT §8.14). */
364
482
  const GEOHASH_DEFAULT_PRECISION = 9;
365
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
+
366
503
  const SPATIAL_REASONS = {
367
504
  within: 'a bounding-box pre-filter is pushed; exact containment refines in the engine',
368
505
  distance: 'a geodesic-circle box pre-filter is pushed; the exact distance refines in the engine',
@@ -383,6 +520,8 @@ const SPATIAL_REASONS = {
383
520
  rtreeBox: "the box is stored in an R*Tree, whose coordinates are 32-bit floats rounded "
384
521
  + 'OUTWARD, so the stored box is a superset of the row\'s; the exact box test refines '
385
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"',
386
525
  };
387
526
 
388
527
  /**
@@ -634,6 +773,9 @@ function planDistanceBound(node, itSlot, shape) {
634
773
  if (constant === null) return { refusal: refusal('$distance', SPATIAL_REASONS.operand) };
635
774
  const at = probePosition(constant.value);
636
775
  if (at === null) return { refusal: refusal('$distance', SPATIAL_REASONS.unbounded) };
776
+ if (!Number.isFinite(radius.value) || radius.value < 0) {
777
+ return { refusal: refusal('$distance', SPATIAL_REASONS.radius) };
778
+ }
637
779
  const box = probeCircleBox(at, radius.value);
638
780
  if (box === null) return { refusal: refusal('$distance', SPATIAL_REASONS.pole) };
639
781
  if (box[0] < -180 || box[2] > 180)
@@ -804,11 +946,74 @@ const KNN_REASONS = {
804
946
  /** @param {string} path @param {number} want @param {number[]} have */
805
947
  dims: (path, want, have) =>
806
948
  `the literal probe has ${want} components but the vector column over ${path} is declared at ${have.join(', ')}`,
807
- /** @param {string} path */
808
- widths: (path) =>
809
- `several vector widths are declared over ${path}; an external probe cannot choose one at plan time`,
810
949
  };
811
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
+
812
1017
  /**
813
1018
  * Recognize the k-nearest ordering, or say why not. `null` when the
814
1019
  * first key is not a `$similarity` at all — an ordinary ordering the
@@ -844,10 +1049,14 @@ function planKnnOrder(orderby, itSlot, shape, selectionPushed) {
844
1049
 
845
1050
  if (probeNode.kind === 'var' && probeNode.external === true) {
846
1051
  if (declared.length === 0) return refuse(KNN_REASONS.noColumn(path, null));
847
- if (declared.length > 1) return refuse(KNN_REASONS.widths(path));
848
1052
  if (!selectionPushed) return refuse(KNN_REASONS.selection);
849
- return { rank: { column: declared[0].column, dims: declared[0].dims,
850
- 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 } } };
851
1060
  }
852
1061
  const constant = constantOf(probeNode);
853
1062
  if (constant === null || !Array.isArray(constant.value)) return refuse(KNN_REASONS.probe);
@@ -860,7 +1069,8 @@ function planKnnOrder(orderby, itSlot, shape, selectionPushed) {
860
1069
  : KNN_REASONS.dims(path, dims, declared.map((entry) => entry.dims)));
861
1070
  }
862
1071
  if (!selectionPushed) return refuse(KNN_REASONS.selection);
863
- 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 } } };
864
1074
  }
865
1075
 
866
1076
  /**
@@ -883,8 +1093,7 @@ function planSpatial(node, itSlot, shape) {
883
1093
  // `$distance >= r` — "farther than" — is narrowed by no box at all
884
1094
  const other = upperOnLeft ? node.args[1] : node.args[0];
885
1095
  if (other?.kind === 'op' && other.name === '$distance') {
886
- return { refusal: refusal('$distance',
887
- 'only a BOUNDED distance is promoted; no box narrows "farther than r"') };
1096
+ return { refusal: refusal('$distance', SPATIAL_REASONS.unboundedFar) };
888
1097
  }
889
1098
  return null;
890
1099
  }
@@ -899,6 +1108,108 @@ function planSpatial(node, itSlot, shape) {
899
1108
  return null;
900
1109
  }
901
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
+
902
1213
  /**
903
1214
  * Translate one predicate node, or explain why not.
904
1215
  *
@@ -916,8 +1227,8 @@ function planSpatial(node, itSlot, shape) {
916
1227
  function planPredicate(node, itSlot, shape) {
917
1228
  assertDecidedKind(node);
918
1229
  if (node.kind !== 'op') {
919
- return { refusal: refusal(node.kind, KIND_REASONS[node.kind]
920
- ?? 'not a predicate the planner translates') };
1230
+ return { refusal: refusal(node.kind,
1231
+ KIND_REASONS[node.kind] ?? PREDICATE_REASONS.notPredicate) };
921
1232
  }
922
1233
 
923
1234
  if (node.name === '$and' || node.name === '$or') {
@@ -942,8 +1253,7 @@ function planPredicate(node, itSlot, shape) {
942
1253
  if (inner.refinements.length > 0) {
943
1254
  // negating a superset is a SUBSET, which drops matching rows —
944
1255
  // the one composition an implied conjunct may never enter
945
- return { refusal: refusal('$not',
946
- 'a negated predicate cannot ride an implied pre-filter (negating a superset drops rows)') };
1256
+ return { refusal: refusal('$not', PREDICATE_REASONS.negatedPrefilter) };
947
1257
  }
948
1258
  return { pred: { p: 'not', item: inner.pred },
949
1259
  exact: true, prefilters: inner.prefilters, refinements: [] };
@@ -952,11 +1262,12 @@ function planPredicate(node, itSlot, shape) {
952
1262
  const spatial = planSpatial(node, itSlot, shape);
953
1263
  if (spatial !== null) return spatial;
954
1264
 
1265
+ if (node.name === '$overlaps') return planIntervalOverlap(node, itSlot, shape);
1266
+
955
1267
  if (node.name === '$exists' || node.name === '$empty') {
956
1268
  const ref = pathRef(node.args[0], itSlot, shape);
957
1269
  if (ref === null) {
958
- return { refusal: refusal(node.name,
959
- 'existence tests translate only over a singular member path on the binding') };
1270
+ return { refusal: refusal(node.name, PREDICATE_REASONS.existence) };
960
1271
  }
961
1272
  return exactly({ p: 'typeIs', ref, types: [], positive: node.name === '$exists' });
962
1273
  }
@@ -974,14 +1285,12 @@ function planPredicate(node, itSlot, shape) {
974
1285
  const operand = operandOf(right);
975
1286
  if (ref === null || operand === null) {
976
1287
  if (pathRef(left, itSlot, shape) !== null && pathRef(right, itSlot, shape) !== null)
977
- return { refusal: refusal(node.name, 'comparisons where both sides are paths are join territory') };
978
- return { refusal: refusal(node.name,
979
- '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) };
980
1290
  }
981
1291
  if ('lit' in operand) {
982
1292
  if (!isScalarLiteral(operand.lit)) {
983
- return { refusal: refusal(node.name,
984
- 'array and object literals have no guarded native comparison form') };
1293
+ return { refusal: refusal(node.name, PREDICATE_REASONS.compoundLiteral) };
985
1294
  }
986
1295
  const lit = operand.lit;
987
1296
  if (typeof lit === 'boolean' || lit === null) {
@@ -998,22 +1307,18 @@ function planPredicate(node, itSlot, shape) {
998
1307
  const ref = pathRef(node.args[0], itSlot, shape);
999
1308
  const operand = operandOf(node.args[1]);
1000
1309
  if (ref === null || ref.type !== 'string') {
1001
- return { refusal: refusal(node.name,
1002
- '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) };
1003
1311
  }
1004
1312
  if (operand === null || !('lit' in operand) || typeof operand.lit !== 'string') {
1005
- return { refusal: refusal(node.name,
1006
- "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) };
1007
1314
  }
1008
1315
  if (operand.lit === '') {
1009
- return { refusal: refusal(node.name,
1010
- "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) };
1011
1317
  }
1012
1318
  return exactly({ p: 'strop', kind: /** @type {any} */ (stringOp), ref, operand });
1013
1319
  }
1014
1320
 
1015
- return { refusal: refusal(node.name,
1016
- 'no native spelling of this operator is proven equivalent') };
1321
+ return { refusal: refusal(node.name, PREDICATE_REASONS.noSpelling) };
1017
1322
  }
1018
1323
 
1019
1324
  // ————— Time series: the three closed shapes over a declared index —————
@@ -1046,13 +1351,30 @@ function planPredicate(node, itSlot, shape) {
1046
1351
  // and it is why a calendar ladder, a fill policy, a rolling window and
1047
1352
  // an as-of JOIN cost a bounded fetch rather than a wrong answer.
1048
1353
 
1049
- /** Is this node the whole collection — `$[*]` over the input document? */
1354
+ /**
1355
+ * The source a packed spelling stands for. `["$[*]"]` — an array
1356
+ * constructor of exactly one element — is what a `$for` unpacks back
1357
+ * into the rows (QUERY-FORMAT §6.2, D4): `@jarenjs/linq` binds every
1358
+ * iterated source that way so an array-valued ROW stays one item. Over
1359
+ * a collection or an entity array the rows are objects, so the packed
1360
+ * and the bare spelling are the same rows, and the planner reads
1361
+ * through the packing rather than sending the document to the residual.
1362
+ * @param {any} node
1363
+ * @returns {any}
1364
+ */
1365
+ function unpacked(node) {
1366
+ return node?.kind === 'array' && node.elements?.length === 1 ? node.elements[0] : node;
1367
+ }
1368
+
1369
+ /** Is this node the whole collection — `$[*]` over the input document,
1370
+ * bare or packed? */
1050
1371
  function isCollectionSource(node) {
1051
- return node?.kind === 'path' && node.name === '$' && node.external !== true
1052
- && node.rootSlot === 0 && node.singular !== true
1053
- && node.segments.length === 1 && node.segments[0].descendant !== true
1054
- && node.segments[0].selectors.length === 1
1055
- && node.segments[0].selectors[0].kind === 'wildcard';
1372
+ const path = unpacked(node);
1373
+ return path?.kind === 'path' && path.name === '$' && path.external !== true
1374
+ && path.rootSlot === 0 && path.singular !== true
1375
+ && path.segments.length === 1 && path.segments[0].descendant !== true
1376
+ && path.segments[0].selectors.length === 1
1377
+ && path.segments[0].selectors[0].kind === 'wildcard';
1056
1378
  }
1057
1379
 
1058
1380
  /**
@@ -1081,7 +1403,7 @@ function memberRef(shape, name) {
1081
1403
  return {
1082
1404
  segments,
1083
1405
  type: typeOfPath(shape.schema, segments),
1084
- column: shape.columnByCanonical.get(`.${name}`) ?? null,
1406
+ column: shape.columnByCanonical.get(canonicalOf(segments)) ?? null,
1085
1407
  };
1086
1408
  }
1087
1409
 
@@ -1416,9 +1738,30 @@ function safeEpoch(value) {
1416
1738
  }
1417
1739
  }
1418
1740
 
1419
- /** 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
+ */
1420
1751
  function instantBound(ref, op, value) {
1421
- 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 } };
1422
1765
  }
1423
1766
 
1424
1767
  /**
@@ -1436,6 +1779,56 @@ function impliedInstantBounds(ref, from, to) {
1436
1779
  to, toOp: to === null ? null : 'le' } };
1437
1780
  }
1438
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
+
1439
1832
  /**
1440
1833
  * Plan a document that IS a series operator over the collection.
1441
1834
  *
@@ -1540,6 +1933,8 @@ function planSeriesOperator(root, shape) {
1540
1933
  // the refinement: narrow through the index by whatever the spec makes
1541
1934
  // provable, and let the engine's own kernel decide over what comes back
1542
1935
  let range = null;
1936
+ /** @type {any[]} the anchors the plan asks the store for, for explain */
1937
+ const seeks = [];
1543
1938
  if (name === '$resample' && at.column !== null) {
1544
1939
  const from = safeEpoch(spec.start);
1545
1940
  const to = safeEpoch(spec.end);
@@ -1553,6 +1948,8 @@ function planSeriesOperator(root, shape) {
1553
1948
  }
1554
1949
  else if (name === '$asof' && probesNode !== null) {
1555
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)));
1556
1953
  if (probes !== null && at.column !== null) {
1557
1954
  const tolerance = toleranceMs(spec.tolerance);
1558
1955
  const direction = spec.direction ?? 'backward';
@@ -1576,17 +1973,26 @@ function planSeriesOperator(root, shape) {
1576
1973
  range = bounds.range;
1577
1974
  prefilters.push({ construct: name, via: 'columns', columns: [at.column], exact: false });
1578
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
+ }
1579
1987
  }
1580
1988
  // and the keys, whether or not the instant has a column of its own:
1581
1989
  // a right row whose group no left row names can match nothing, so a
1582
1990
  // membership test over the probes' own keys narrows and never drops
1583
1991
  if (probes !== null && probes.keys !== null && probes.keys.length > 0) {
1584
- const byRef = memberRef(shape, /** @type {string} */ (singularSelector(spec.by)));
1585
- if (byRef.column !== null) {
1992
+ if (byRef !== null && byRef.column !== null) {
1586
1993
  plan.filter = conjoin(plan.filter, probes.keys.length === 1
1587
- ? { p: 'cmp', op: 'eq', ref: byRef, operand: { lit: probes.keys[0] } }
1588
- : { p: 'or', items: probes.keys.map((key) =>
1589
- ({ 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)) });
1590
1996
  prefilters.push({ construct: name, via: 'columns',
1591
1997
  columns: [byRef.column], exact: false });
1592
1998
  }
@@ -1611,6 +2017,7 @@ function planSeriesOperator(root, shape) {
1611
2017
  index: narrowed && index !== null ? index.name : null,
1612
2018
  prefix: narrowed && index !== null ? index.prefix : [],
1613
2019
  range,
2020
+ seeks,
1614
2021
  refinement: { $resample: 'resampleSeries', $rolling: 'rollingSeries',
1615
2022
  $asof: 'asOfJoin' }[name],
1616
2023
  reasons,
@@ -1618,6 +2025,206 @@ function planSeriesOperator(root, shape) {
1618
2025
  };
1619
2026
  }
1620
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
+
1621
2228
  /**
1622
2229
  * Plan a FLWOR node into a select plan, recording refusals. When a
1623
2230
  * conjunct refuses native translation, the injected `udf` hook may
@@ -1643,21 +2250,18 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1643
2250
  const plan = selectPlan(shape.collection);
1644
2251
 
1645
2252
  // the one recognised source shape: a single plain binding over $[*]
2253
+ // (bare or packed — one check, `isCollectionSource`, for every site)
1646
2254
  const binding = node.forBindings[0];
1647
- const source = binding?.expr;
1648
2255
  const sourceIsCollection = node.forBindings.length === 1
1649
- && source?.kind === 'path' && source.name === '$' && source.external !== true
1650
- && source.segments.length === 1 && source.segments[0].descendant !== true
1651
- && source.segments[0].selectors.length === 1
1652
- && source.segments[0].selectors[0].kind === 'wildcard'
2256
+ && isCollectionSource(binding?.expr)
1653
2257
  && binding.window === null && binding.atSlot === -1
1654
2258
  && binding.allowingEmpty === false;
1655
2259
  if (!sourceIsCollection) {
1656
- reasons.push(refusal('$for',
1657
- 'only a single plain binding over the whole collection is translated'));
2260
+ reasons.push(refusal('$for', FLWOR_REASONS.binding));
1658
2261
  return { plan, reasons, whereFullyPushed: false, orderPushed: false,
1659
- projectionNative: false, itSlot: -1, itName: null, udfs: [], prefilters: [],
1660
- 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 };
1661
2265
  }
1662
2266
  const itSlot = binding.slot;
1663
2267
  // the document's own name for the collection binding. The residual and
@@ -1668,20 +2272,26 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1668
2272
 
1669
2273
  if (node.fold !== null) reasons.push(refusal('$fold', KIND_REASONS.let));
1670
2274
  if (node.letBindings.length > 0) reasons.push(refusal('$let', KIND_REASONS.let));
1671
- 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));
1672
2276
  // A grouping is an unconditional residual EXCEPT in one closed shape:
1673
2277
  // a fixed-width `$time-bucket` key with the exact aggregates, which
1674
2278
  // is a `GROUP BY` over integer arithmetic. The bucket then owns the
1675
2279
  // ordering and the projection too, so it is decided before either.
1676
2280
  let bucket = null;
2281
+ let group = null;
1677
2282
  let bucketRefusal = null;
1678
2283
  if (node.groupby !== null && node.fold === null && node.letBindings.length === 0
1679
2284
  && node.asChecks === null && node.count === null) {
1680
2285
  const grouped = planBucketGrouping(node, itSlot, shape);
1681
2286
  if ('bucket' in grouped) bucket = grouped.bucket;
1682
2287
  else {
1683
- bucketRefusal = grouped.code;
1684
- 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
+ }
1685
2295
  }
1686
2296
  }
1687
2297
  else if (node.groupby !== null) reasons.push(refusal('$groupby', KIND_REASONS.let));
@@ -1712,8 +2322,12 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1712
2322
  ? udfHook(rawConjuncts[i], itName)
1713
2323
  : null;
1714
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
1715
2328
  plan.filter = conjoin(plan.filter,
1716
- { p: 'udf', name: promoted.name, key: promoted.key });
2329
+ { p: 'udf', name: promoted.name, key: promoted.key,
2330
+ mount: split ? `/$where/$and/${i}` : '/$where' });
1717
2331
  udfs.push(promoted.name);
1718
2332
  }
1719
2333
  else {
@@ -1725,7 +2339,24 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1725
2339
  plan.filter = conjoin(plan.filter, outcome.pred);
1726
2340
  prefilters.push(...outcome.prefilters);
1727
2341
  // an IMPLIED conjunct narrows and leaves the original predicate
1728
- // 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
+ }
1729
2360
  for (const refinement of outcome.refinements) {
1730
2361
  reasons.push(refinement);
1731
2362
  whereFullyPushed = false;
@@ -1740,9 +2371,12 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1740
2371
  // caller's to name once the window is known)
1741
2372
  let orderPushed = false;
1742
2373
  let knn = null;
1743
- const ranked = bucket !== null || node.orderby === null ? null
2374
+ const grouped = bucket !== null || group !== null;
2375
+ const ranked = grouped || node.orderby === null ? null
1744
2376
  : planKnnOrder(node.orderby, itSlot, shape, whereFullyPushed && structureClean);
1745
- 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;
1746
2380
  else if (ranked !== null) {
1747
2381
  if ('rank' in ranked) knn = ranked.rank;
1748
2382
  else reasons.push(ranked.refusal);
@@ -1752,14 +2386,12 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1752
2386
  let refused = null;
1753
2387
  for (const spec of node.orderby.specs) {
1754
2388
  const ref = pathRef(spec.key, itSlot, shape);
1755
- if (ref === null || ref.type === 'unknown') {
1756
- refused = refusal('$orderby',
1757
- 'ordering translates only over singular schema-typed paths');
2389
+ if (ref === null || !orderable(ref, shape.schema)) {
2390
+ refused = refusal('$orderby', FLWOR_REASONS.orderPath);
1758
2391
  break;
1759
2392
  }
1760
2393
  if (spec.collation !== null || spec.collationName !== null) {
1761
- refused = refusal('$collation',
1762
- 'a collation the dialect cannot reproduce is refused, not approximated');
2394
+ refused = refusal('$collation', FLWOR_REASONS.collation);
1763
2395
  break;
1764
2396
  }
1765
2397
  terms.push({ ref, desc: spec.desc === true, emptyGreatest: spec.emptyGreatest === true });
@@ -1774,14 +2406,38 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1774
2406
  orderPushed = true; // nothing to push
1775
2407
  }
1776
2408
 
1777
- // 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
1778
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;
1779
2422
  assertDecidedKind(node.ret);
1780
- 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;
1781
2427
  else if (isItVar(node.ret, itSlot)) projectionNative = true;
1782
2428
  else {
1783
- reasons.push(refusal('$return',
1784
- '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
+ }
1785
2441
  }
1786
2442
 
1787
2443
  return {
@@ -1790,12 +2446,15 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1790
2446
  whereFullyPushed: whereFullyPushed && structureClean,
1791
2447
  orderPushed: orderPushed && structureClean,
1792
2448
  projectionNative,
2449
+ projectedPath,
2450
+ projectedTree,
1793
2451
  itSlot,
1794
2452
  itName,
1795
2453
  udfs,
1796
2454
  prefilters,
1797
2455
  knn,
1798
2456
  bucket,
2457
+ group,
1799
2458
  bucketRefusal,
1800
2459
  };
1801
2460
  }
@@ -1845,7 +2504,17 @@ function composeWindows(windows) {
1845
2504
  * `series` is the temporal record (`series.js`) when the document
1846
2505
  * asked a §8.16 question, and `null` when it did not.
1847
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
+
1848
2516
  function planCollectionCore(document, shape, options = undefined) {
2517
+ if (document === '$[*]') document = ROOT_SCAN;
1849
2518
  const analysis = analyzeQuery(document, analyzeOptionsFor(shape?.operators));
1850
2519
  let root = analysis.root;
1851
2520
  assertDecidedKind(root);
@@ -1855,12 +2524,12 @@ function planCollectionCore(document, shape, options = undefined) {
1855
2524
  const windows = [];
1856
2525
  while (root.kind === 'op' && root.name === '$subsequence') {
1857
2526
  const [inner, start, length] = root.args;
1858
- if (start?.kind !== 'literal' || typeof start.value !== 'number'
1859
- || (length !== undefined && (length.kind !== 'literal' || typeof length.value !== 'number'))) {
2527
+ if (start?.kind !== 'literal' || !isWindowBound(start.value)
2528
+ || (length !== undefined && (length.kind !== 'literal' || !isWindowBound(length.value)))) {
1860
2529
  // non-literal bounds: the whole document is a set residual
1861
2530
  return {
1862
2531
  analysis, plan: null, mode: 'set',
1863
- reasons: [refusal('$subsequence', 'window bounds must be literal numbers to push')],
2532
+ reasons: [refusal('$subsequence', PLAN_REASONS.windowBounds)],
1864
2533
  rowReturn: null, udfs: [], prefilters: [], series: null,
1865
2534
  };
1866
2535
  }
@@ -1870,17 +2539,26 @@ function planCollectionCore(document, shape, options = undefined) {
1870
2539
  assertDecidedKind(root);
1871
2540
  }
1872
2541
 
1873
- // 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
1874
2548
  let aggregate = null;
1875
- 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)) {
1876
2552
  if (windows.length > 0) {
1877
2553
  return {
1878
2554
  analysis, plan: null, mode: 'set',
1879
- reasons: [refusal(root.name, 'a windowed aggregate is not translated')],
2555
+ reasons: [refusal(root.name, PLAN_REASONS.windowedAggregate)],
1880
2556
  rowReturn: null, udfs: [], prefilters: [], series: null,
1881
2557
  };
1882
2558
  }
1883
- 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 };
1884
2562
  root = root.args[0];
1885
2563
  rawInner = rawInner?.[aggregate.name] ?? rawInner;
1886
2564
  assertDecidedKind(root);
@@ -1914,8 +2592,7 @@ function planCollectionCore(document, shape, options = undefined) {
1914
2592
  if (root.kind !== 'flwor') {
1915
2593
  return {
1916
2594
  analysis, plan: null, mode: 'set',
1917
- reasons: [refusal(root.kind, KIND_REASONS[root.kind]
1918
- ?? 'only a FLWOR over the collection is translated')],
2595
+ reasons: [refusal(root.kind, KIND_REASONS[root.kind] ?? PLAN_REASONS.notFlwor)],
1919
2596
  rowReturn: null, udfs: [], prefilters: [], series: null,
1920
2597
  };
1921
2598
  }
@@ -1947,35 +2624,59 @@ function planCollectionCore(document, shape, options = undefined) {
1947
2624
  return { analysis, plan: null, mode: 'set', reasons: flwor.reasons,
1948
2625
  rowReturn: null, udfs: [], prefilters: flwor.prefilters, series: null };
1949
2626
  }
2627
+ if (flwor.bucket !== null || flwor.group !== null || flwor.bucketRefusal != null) {
2628
+ // the phrase's items are its GROUPS; a COUNT(*) over the rows
2629
+ // answered the row count for a `$count` of the groups
2630
+ return {
2631
+ analysis, plan: null, mode: 'set',
2632
+ reasons: [refusal(aggregate.name, PLAN_REASONS.groupedAggregate)],
2633
+ rowReturn: null, udfs: [], prefilters: [], series: null,
2634
+ };
2635
+ }
1950
2636
  if (aggregate.fn === 'count') {
1951
2637
  if (!flwor.projectionNative) {
1952
2638
  return {
1953
2639
  analysis, plan: null, mode: 'set',
1954
- reasons: [refusal('$count',
1955
- 'count translates only over the bare binding (a projected return can change the item count)')],
2640
+ reasons: [refusal('$count', PLAN_REASONS.countProjection)],
1956
2641
  rowReturn: null, udfs: [], prefilters: [], series: null,
1957
2642
  };
1958
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
+ }
1959
2651
  plan.aggregate = { fn: 'count', ref: null };
1960
2652
  return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
1961
2653
  udfs: flwor.udfs, prefilters: flwor.prefilters,
1962
2654
  series: classifySelection(plan, shape, true) };
1963
2655
  }
1964
2656
  const ref = pathRef(root.ret, flwor.itSlot, shape);
1965
- 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';
1966
2664
  const acceptable = ref !== null
1967
- && (numeric ? isNumericType(ref.type) : ref.type !== 'unknown');
2665
+ && (numeric ? isNumericType(ref.type) : ref.type !== 'unknown')
2666
+ && ref.type !== 'boolean' && !admitsNull(shape.schema, ref.segments);
1968
2667
  if (!acceptable) {
1969
2668
  return {
1970
2669
  analysis, plan: null, mode: 'set',
1971
- reasons: [refusal(aggregate.name,
1972
- 'aggregates translate only over a singular schema-typed path (the engine ERRORS on non-conforming operands)')],
2670
+ reasons: [refusal(aggregate.name, PLAN_REASONS.aggregatePath)],
1973
2671
  rowReturn: null, udfs: [], prefilters: [], series: null,
1974
2672
  };
1975
2673
  }
1976
- 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 };
1977
2677
  return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
1978
- udfs: flwor.udfs, prefilters: flwor.prefilters,
2678
+ udfs: aggregate.fn === 'registered' ? [...flwor.udfs, aggregate.sql] : flwor.udfs,
2679
+ prefilters: flwor.prefilters,
1979
2680
  series: classifySelection(plan, shape, true) };
1980
2681
  }
1981
2682
 
@@ -1985,6 +2686,12 @@ function planCollectionCore(document, shape, options = undefined) {
1985
2686
  // the temporal bucket: only over a WHOLE pushed selection, because a
1986
2687
  // conjunct the residual would still apply would arrive after the rows
1987
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
+
1988
2695
  if (flwor.bucket !== null && fullyPushed && (windows.length === 0 || plan.window !== null)) {
1989
2696
  plan.bucket = flwor.bucket;
1990
2697
  const facts = filterFacts(plan.filter);
@@ -2006,14 +2713,25 @@ function planCollectionCore(document, shape, options = undefined) {
2006
2713
  };
2007
2714
  }
2008
2715
 
2009
- 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;
2010
2728
  return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
2011
2729
  udfs: flwor.udfs, prefilters: flwor.prefilters,
2012
2730
  series: classifySelection(plan, shape, flwor.orderPushed) };
2013
2731
  }
2014
2732
 
2015
2733
  // the row residual: everything but the projection pushed
2016
- if (fullyPushed && !flwor.projectionNative
2734
+ if (!groupedResidual && fullyPushed && !flwor.projectionNative
2017
2735
  && (windows.length === 0 || plan.window !== null)) {
2018
2736
  const rawFlwor = rawInner;
2019
2737
  const name = flwor.itName ?? 'it';
@@ -2037,9 +2755,12 @@ function planCollectionCore(document, shape, options = undefined) {
2037
2755
  };
2038
2756
  }
2039
2757
 
2040
- // 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
2041
2762
  const narrowing = flwor.bucketRefusal == null
2042
- ? classifySelection(plan, shape, false)
2763
+ ? underSetMode(classifySelection(plan, shape, false))
2043
2764
  : refinedGrouping(plan, shape, flwor.bucketRefusal, '$groupby');
2044
2765
  plan.order = null;
2045
2766
  plan.window = null;
@@ -2047,6 +2768,17 @@ function planCollectionCore(document, shape, options = undefined) {
2047
2768
  udfs: flwor.udfs, prefilters: flwor.prefilters, series: narrowing };
2048
2769
  }
2049
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
+
2050
2782
  /**
2051
2783
  * Plan a whole document against one collection. The store's registered
2052
2784
  * operators (Ring 2) ride in `shape.operators` — the planner recognises
@@ -2069,10 +2801,30 @@ function planCollectionCore(document, shape, options = undefined) {
2069
2801
  * }}
2070
2802
  */
2071
2803
  export function planQuery(document, shape, options = undefined) {
2072
- const planned = planCollectionCore(document, shape, options);
2804
+ const peeled = peelWrappedResult(document);
2805
+ const planned = { ...planCollectionCore(peeled.document, shape, options), wrapped: peeled.wrapped };
2073
2806
  return prependRegisteredReason(planned, document, shape?.operators);
2074
2807
  }
2075
2808
 
2809
+ /**
2810
+ * A chain's element terminal wraps its phrase in a one-item array
2811
+ * constructor — `[<phrase>]`, the window that keeps an array-valued item
2812
+ * one item (QUERY-PEN §6) — so the document a store receives from
2813
+ * `toArray()`/`first()` is that constructor around the phrase. Read
2814
+ * through it: the phrase inside plans as it would bare, and the engines
2815
+ * answer its rows as the ONE array the constructor yields (`wrapped`),
2816
+ * which is exactly the engine's own answer for the document. Anything
2817
+ * else inside the brackets plans as itself and falls to the residual,
2818
+ * where the whole document — brackets included — runs in the engine.
2819
+ * @param {any} document
2820
+ * @returns {{ document: any, wrapped: boolean }}
2821
+ */
2822
+ function peelWrappedResult(document) {
2823
+ return Array.isArray(document) && document.length === 1
2824
+ ? { document: document[0], wrapped: true }
2825
+ : { document, wrapped: false };
2826
+ }
2827
+
2076
2828
  // ————— The entity document kind (one planner, two document kinds) —————
2077
2829
 
2078
2830
  /**
@@ -2090,7 +2842,7 @@ export function entityShape(entity, entityMapping) {
2090
2842
  const flavors = new Map();
2091
2843
  for (const column of entityMapping.columns) {
2092
2844
  const epoch = column.source === 'epoch(document)';
2093
- flavors.set(`.${column.name}`, {
2845
+ flavors.set(canonicalOf([{ name: column.name }]), {
2094
2846
  column: column.name,
2095
2847
  flavor: epoch ? 'entity-epoch' : 'entity-column',
2096
2848
  storage: column.storage,
@@ -2098,8 +2850,9 @@ export function entityShape(entity, entityMapping) {
2098
2850
  });
2099
2851
  }
2100
2852
  for (const fk of entityMapping.foreignKeys) {
2101
- if (!flavors.has(`.${fk.column}`))
2102
- flavors.set(`.${fk.column}`, { column: fk.column, flavor: 'entity-column', storage: 'string' });
2853
+ const fkCanonical = canonicalOf([{ name: fk.column }]);
2854
+ if (!flavors.has(fkCanonical))
2855
+ flavors.set(fkCanonical, { column: fk.column, flavor: 'entity-column', storage: 'string' });
2103
2856
  }
2104
2857
  return {
2105
2858
  collection: entity.name,
@@ -2120,8 +2873,7 @@ export function entityShape(entity, entityMapping) {
2120
2873
  export function entityPathRef(node, slot, shape) {
2121
2874
  const ref = pathRef(node, slot, shape);
2122
2875
  if (ref === null) return null;
2123
- const canonical = ref.segments
2124
- .map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
2876
+ const canonical = canonicalOf(ref.segments);
2125
2877
  const flavored = shape.entityFlavors.get(canonical);
2126
2878
  if (flavored !== undefined) {
2127
2879
  return {
@@ -2137,6 +2889,99 @@ export function entityPathRef(node, slot, shape) {
2137
2889
  return { ...ref, flavor: 'entity-doc' };
2138
2890
  }
2139
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
+
2140
2985
  /**
2141
2986
  * Plan one predicate over an entity binding: the same operator
2142
2987
  * grammar as phase A, with entity-flavored refs. Reuses
@@ -2157,15 +3002,13 @@ export function planEntityPredicate(node, slot, shape) {
2157
3002
  return { ...pred, items: pred.items.map(reflavor) };
2158
3003
  if (pred.p === 'not') return { ...pred, item: reflavor(pred.item) };
2159
3004
  if (!('ref' in pred) || pred.ref === null) return pred;
2160
- const canonical = pred.ref.segments
2161
- .map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
3005
+ const canonical = canonicalOf(pred.ref.segments);
2162
3006
  const flavored = shape.entityFlavors.get(canonical);
2163
3007
  if (flavored === undefined) {
2164
3008
  // externals against DOC paths are not translated here (the
2165
3009
  // phase-A external forms assume the collection layout)
2166
3010
  if (pred.p === 'cmp' && 'ext' in pred.operand) {
2167
- blocked = { construct: '$eq',
2168
- reason: 'externals compare only against entity columns in this version' };
3011
+ blocked = refusal('$eq', ENTITY_REASONS.external);
2169
3012
  }
2170
3013
  return { ...pred, ref: { ...pred.ref, flavor: 'entity-doc' } };
2171
3014
  }
@@ -2173,8 +3016,7 @@ export function planEntityPredicate(node, slot, shape) {
2173
3016
  flavor: flavored.flavor, storage: flavored.storage, format: flavored.format };
2174
3017
  if (flavored.flavor === 'entity-epoch' && pred.p === 'cmp') {
2175
3018
  if ('ext' in pred.operand) {
2176
- blocked = { construct: pred.op,
2177
- reason: 'externals compare only against entity columns in this version' };
3019
+ blocked = refusal(pred.op, ENTITY_REASONS.external);
2178
3020
  return { ...pred, ref };
2179
3021
  }
2180
3022
  // the plan-time instant translation: an ordering comparison
@@ -2228,9 +3070,9 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2228
3070
  const windows = [];
2229
3071
  while (root.kind === 'op' && root.name === '$subsequence') {
2230
3072
  const [inner, start, length] = root.args;
2231
- if (start?.kind !== 'literal' || typeof start.value !== 'number'
2232
- || (length !== undefined && (length.kind !== 'literal' || typeof length.value !== 'number')))
2233
- return residual('$subsequence', 'window bounds must be literal numbers to push');
3073
+ if (start?.kind !== 'literal' || !isWindowBound(start.value)
3074
+ || (length !== undefined && (length.kind !== 'literal' || !isWindowBound(length.value))))
3075
+ return residual('$subsequence', PLAN_REASONS.windowBounds);
2234
3076
  windows.push({ offset: start.value, limit: length === undefined ? null : length.value });
2235
3077
  root = inner;
2236
3078
  assertDecidedKind(root);
@@ -2242,27 +3084,18 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2242
3084
  assertDecidedKind(root);
2243
3085
  }
2244
3086
  if (root.kind !== 'flwor')
2245
- return residual(root.kind, 'only a FLWOR over entity arrays is translated');
3087
+ return residual(root.kind, ENTITY_REASONS.notFlwor);
2246
3088
  if (root.fold !== null || root.letBindings.length > 0 || root.asChecks !== null
2247
3089
  || root.groupby !== null || root.count !== null)
2248
- return residual('$let', 'no equivalence proof exists yet; residual by default');
3090
+ return residual('$let', KIND_REASONS.let);
2249
3091
 
2250
3092
  // bindings must each range over one entity's array
2251
3093
  const bindings = [];
2252
3094
  for (const binding of root.forBindings) {
2253
- const source = binding.expr;
2254
- const sourceEntity = source?.kind === 'path' && source.name === '$'
2255
- && source.external !== true && source.segments.length === 2
2256
- && source.segments[0].descendant !== true
2257
- && source.segments[0].selectors.length === 1
2258
- && source.segments[0].selectors[0].kind === 'name'
2259
- && source.segments[1].selectors?.length === 1
2260
- && source.segments[1].selectors[0].kind === 'wildcard'
2261
- ? source.segments[0].selectors[0].name
2262
- : null;
2263
- if (sourceEntity === null || !entities.has(sourceEntity)
3095
+ const sourceEntity = bindingEntity(binding, entities);
3096
+ if (sourceEntity === null
2264
3097
  || binding.window !== null || binding.atSlot !== -1 || binding.allowingEmpty !== false)
2265
- return residual('$for', 'bindings must each range over one declared entity array ($.Entity[*])');
3098
+ return residual('$for', ENTITY_REASONS.bindingRoot);
2266
3099
  bindings.push({
2267
3100
  name: binding.name,
2268
3101
  slot: binding.slot,
@@ -2270,9 +3103,6 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2270
3103
  shape: entityShape(entities.get(sourceEntity), mapping.entities[sourceEntity]),
2271
3104
  });
2272
3105
  }
2273
- if (bindings.length > 2)
2274
- return residual('$for', 'at most two bindings are translated (one join per statement)');
2275
-
2276
3106
  const byName = new Map(bindings.map((binding) => [binding.slot, binding]));
2277
3107
  const conjuncts = root.where === null
2278
3108
  ? []
@@ -2280,14 +3110,21 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2280
3110
  ? root.where.args
2281
3111
  : [root.where];
2282
3112
 
2283
- 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 = [];
2284
3121
  const filters = new Map(bindings.map((binding) => [binding.slot, null]));
2285
3122
  const reasons = [];
2286
3123
  let whereFullyPushed = true;
2287
3124
  for (const conjunct of conjuncts) {
2288
- // a key equality between the two bindings is the join condition
2289
- if (bindings.length === 2 && joinOn === null
2290
- && 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') {
2291
3128
  const [left, right] = conjunct.args;
2292
3129
  const leftBinding = left.kind === 'path' ? byName.get(left.rootSlot) : undefined;
2293
3130
  const rightBinding = right.kind === 'path' ? byName.get(right.rootSlot) : undefined;
@@ -2296,20 +3133,28 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2296
3133
  const leftRef = entityPathRef(left, left.rootSlot, leftBinding.shape);
2297
3134
  const rightRef = entityPathRef(right, right.rootSlot, rightBinding.shape);
2298
3135
  if (leftRef?.flavor === 'entity-column' && rightRef?.flavor === 'entity-column') {
2299
- joinOn = {
3136
+ edges.push({
2300
3137
  left: { binding: leftBinding, ref: leftRef },
2301
3138
  right: { binding: rightBinding, ref: rightRef },
2302
- };
3139
+ });
2303
3140
  continue;
2304
3141
  }
2305
3142
  }
2306
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
+ }
2307
3153
  // otherwise the conjunct must belong wholly to ONE binding
2308
3154
  const slots = new Set();
2309
3155
  collectBindingSlots(conjunct, byName, slots);
2310
3156
  if (slots.size !== 1) {
2311
- reasons.push({ construct: '$where',
2312
- reason: 'a conjunct must belong to one binding (or be the single join equality)' });
3157
+ reasons.push(refusal('$where', ENTITY_REASONS.conjunctBinding));
2313
3158
  whereFullyPushed = false;
2314
3159
  continue;
2315
3160
  }
@@ -2323,15 +3168,61 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2323
3168
  }
2324
3169
  filters.set(slot, conjoin(filters.get(slot), outcome.pred));
2325
3170
  }
2326
- if (bindings.length === 2 && joinOn === null)
2327
- 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
+ }
2328
3217
 
2329
- // 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
2330
3220
  const retBinding = root.ret.kind === 'var' && root.ret.external !== true
2331
3221
  ? byName.get(root.ret.slot) : undefined;
2332
- if (retBinding === undefined) {
2333
- reasons.push({ construct: '$return',
2334
- 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));
2335
3226
  }
2336
3227
 
2337
3228
  // ordering over flavored refs of either binding
@@ -2344,11 +3235,15 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2344
3235
  const binding = byName.get(slot);
2345
3236
  const ref = binding === undefined
2346
3237
  ? null : entityPathRef(spec.key, slot, binding.shape);
3238
+ // a boolean orders in SQL and is `JQ2005` in the engine on either
3239
+ // flavor; a document path that admits null stores a present null
3240
+ // (a COLUMN stores it absent, §9.3, so a nullable column pushes)
2347
3241
  if (ref === null || (ref.flavor === 'entity-doc' && ref.type === 'unknown')
3242
+ || ref.type === 'boolean'
3243
+ || (ref.flavor === 'entity-doc' && admitsNull(binding.shape.schema, ref.segments))
2348
3244
  || spec.collation !== null || spec.collationName !== null) {
2349
3245
  orderPushed = false;
2350
- reasons.push({ construct: '$orderby',
2351
- reason: 'ordering translates only over typed entity paths' });
3246
+ reasons.push(refusal('$orderby', ENTITY_REASONS.order));
2352
3247
  break;
2353
3248
  }
2354
3249
  terms.push({ binding, ref, desc: spec.desc === true, emptyGreatest: spec.emptyGreatest === true });
@@ -2356,8 +3251,8 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2356
3251
  if (orderPushed) order = terms;
2357
3252
  }
2358
3253
 
2359
- const fullyPushed = whereFullyPushed && orderPushed && retBinding !== undefined
2360
- && (aggregate === null || retBinding !== undefined);
3254
+ const fullyPushed = whereFullyPushed && orderPushed
3255
+ && (retBinding !== undefined || projection !== null);
2361
3256
  if (!fullyPushed) {
2362
3257
  return { analysis, mode: 'set', plan: null, referenced, reasons };
2363
3258
  }
@@ -2382,12 +3277,19 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2382
3277
  reasons: [],
2383
3278
  plan: {
2384
3279
  planVersion: PLAN_VERSION,
2385
- alg: bindings.length === 2 ? 'entity-join' : 'entity-select',
3280
+ alg: bindings.length > 1 ? 'entity-join' : 'entity-select',
2386
3281
  bindings: bindings.map((binding) => ({ name: binding.name, entity: binding.entity })),
2387
- joinOn: joinOn === null ? null : {
2388
- left: { binding: joinOn.left.binding.name, column: joinOn.left.ref.column },
2389
- right: { binding: joinOn.right.binding.name, column: joinOn.right.ref.column },
2390
- },
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
+ })),
2391
3293
  filters: bindings.map((binding) => ({
2392
3294
  binding: binding.name,
2393
3295
  filter: filters.get(binding.slot),
@@ -2398,7 +3300,13 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2398
3300
  })),
2399
3301
  window,
2400
3302
  aggregate,
2401
- 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
+ },
2402
3310
  },
2403
3311
  };
2404
3312
  }
@@ -2414,10 +3322,60 @@ function planEntityQueryCore(document, entities, mapping, operators) {
2414
3322
  * @returns {any}
2415
3323
  */
2416
3324
  export function planEntityQuery(document, entities, mapping, operators = null) {
2417
- const planned = planEntityQueryCore(document, entities, mapping, operators);
3325
+ const peeled = peelWrappedResult(document);
3326
+ const core = planEntityQueryCore(peeled.document, entities, mapping, operators);
3327
+ const planned = {
3328
+ ...core,
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),
3334
+ };
2418
3335
  return prependRegisteredReason(planned, document, operators);
2419
3336
  }
2420
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
+
2421
3379
  /** Which binding slots a subtree references (via path roots). */
2422
3380
  function collectBindingSlots(node, byName, slots) {
2423
3381
  if (node === null || typeof node !== 'object') return;
@@ -2432,13 +3390,133 @@ function collectBindingSlots(node, byName, slots) {
2432
3390
  }
2433
3391
  }
2434
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
+
3496
+ /**
3497
+ * The root expression an entity's rows are bound through — the ONE
3498
+ * spelling of `$.<Name>[*]`: what an entity set exposes as its `root`
3499
+ * (the hint a chain reads), and what {@link collectEntityRoots}
3500
+ * recognises in a document. Two spellings would let a handle publish a
3501
+ * root the planner does not read.
3502
+ * @param {string} name - a declared entity name
3503
+ * @returns {string}
3504
+ */
3505
+ export function entityRoot(name) {
3506
+ return `$.${name}[*]`;
3507
+ }
3508
+
2435
3509
  /** The entity names a document's root paths reference (`$.Name[*]`). */
2436
3510
  export function collectEntityRoots(document, entities) {
2437
3511
  const found = new Set();
2438
3512
  const walk = (node) => {
2439
3513
  if (typeof node === 'string') {
2440
- const match = /^\$\.([A-Za-z_][A-Za-z0-9_]*)\[\*\]/.exec(node);
2441
- if (match !== null && entities.has(match[1])) found.add(match[1]);
3514
+ for (const name of entities.keys()) {
3515
+ if (node.startsWith(entityRoot(name))) {
3516
+ found.add(name);
3517
+ break;
3518
+ }
3519
+ }
2442
3520
  return;
2443
3521
  }
2444
3522
  if (Array.isArray(node)) {