@jarenjs/db 0.46.5 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/ARCHITECTURE.md +133 -17
  2. package/README.md +270 -36
  3. package/docs/JOBS-FORMAT.md +24 -8
  4. package/docs/LIVE-FORMAT.md +139 -7
  5. package/docs/MIGRATION-FORMAT.md +118 -36
  6. package/docs/MODEL-FORMAT.md +251 -30
  7. package/package.json +4 -5
  8. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  9. package/schemas/jaren-migration.schema.json +73 -0
  10. package/src/algebra.js +22 -3
  11. package/src/capture.js +66 -28
  12. package/src/cli.js +225 -44
  13. package/src/ddl.js +23 -3
  14. package/src/dialect.js +13 -0
  15. package/src/dialects/sqlite.js +21 -1
  16. package/src/driver.js +63 -16
  17. package/src/drivers/wasm.js +1 -0
  18. package/src/emit-model.js +14 -0
  19. package/src/emit.js +42 -9
  20. package/src/entity.js +92 -47
  21. package/src/errors.js +28 -0
  22. package/src/index.js +2 -2
  23. package/src/jobs.js +40 -5
  24. package/src/live-time.js +605 -0
  25. package/src/live.js +52 -9
  26. package/src/migrate.js +397 -191
  27. package/src/model.js +173 -8
  28. package/src/plan.js +834 -47
  29. package/src/query.js +296 -22
  30. package/src/residual.js +15 -6
  31. package/src/series.js +349 -0
  32. package/src/store.js +243 -69
  33. package/src/tracker.js +173 -48
  34. package/types/index.d.ts +206 -12
  35. package/types/node.d.ts +3 -1
  36. package/types/typed.d.ts +58 -2
  37. package/types/wasm.d.ts +7 -0
  38. package/dist/types/algebra.d.ts +0 -199
  39. package/dist/types/app.d.ts +0 -49
  40. package/dist/types/capture.d.ts +0 -85
  41. package/dist/types/cli.d.ts +0 -2
  42. package/dist/types/dag-job.d.ts +0 -40
  43. package/dist/types/ddl.d.ts +0 -229
  44. package/dist/types/derive.d.ts +0 -250
  45. package/dist/types/dialect.d.ts +0 -149
  46. package/dist/types/dialects/sqlite.d.ts +0 -9
  47. package/dist/types/driver.d.ts +0 -110
  48. package/dist/types/drivers/bun.d.ts +0 -47
  49. package/dist/types/drivers/node.d.ts +0 -37
  50. package/dist/types/drivers/wasm.d.ts +0 -65
  51. package/dist/types/emit-model.d.ts +0 -44
  52. package/dist/types/emit.d.ts +0 -75
  53. package/dist/types/entity.d.ts +0 -23
  54. package/dist/types/errors.d.ts +0 -167
  55. package/dist/types/graph.d.ts +0 -28
  56. package/dist/types/index.d.ts +0 -37
  57. package/dist/types/jobs.d.ts +0 -140
  58. package/dist/types/knn.d.ts +0 -69
  59. package/dist/types/live.d.ts +0 -62
  60. package/dist/types/migrate.d.ts +0 -170
  61. package/dist/types/model.d.ts +0 -36
  62. package/dist/types/patch-sql.d.ts +0 -37
  63. package/dist/types/plan.d.ts +0 -140
  64. package/dist/types/profile.d.ts +0 -80
  65. package/dist/types/query.d.ts +0 -111
  66. package/dist/types/residual.d.ts +0 -61
  67. package/dist/types/store.d.ts +0 -53
  68. package/dist/types/tracker.d.ts +0 -43
  69. package/dist/types/typed.d.ts +0 -15
  70. package/dist/types/types.d.ts +0 -26
  71. package/dist/types/udf.d.ts +0 -75
  72. package/dist/types/window.d.ts +0 -52
package/src/plan.js CHANGED
@@ -41,15 +41,21 @@ import { analyzeQuery, AST_VERSION, NODE_KINDS } from '@jarenjs/json/query';
41
41
  import {
42
42
  getEpochOfDateTimeRFC3339, getEpochOfDateOnlyRFC3339,
43
43
  } from '@jarenjs/core/dates/rfc3339';
44
+ import { compileBuckets, resampleSeries, toEpoch } from '@jarenjs/core/series';
44
45
 
45
46
  import { selectPlan, conjoin, PLAN_VERSION } from './algebra.js';
46
47
  import { typeOfPath, isNumericType } from './types.js';
47
- import { schemaNodeAt } from './ddl.js';
48
+ import { schemaNodeAt, canonicalOf } from './ddl.js';
48
49
  import {
49
50
  BBOX_COMPONENTS, BBOX_INDEX_ORDER, PRECISION_MIN, PRECISION_MAX,
50
51
  probeBox, probePosition, probeCircleBox, cellNeighbourhood, probeVector,
51
52
  } from './derive.js';
52
53
  import { KNN_MARGIN } from './knn.js';
54
+ import {
55
+ SERIES_ROOT_OPS, NATIVE_AGGREGATES, seriesReason,
56
+ instantIndexesOver, seekingIndexFor, filterFacts, fixedLadder, instantRefusal,
57
+ valueRefusal, seriesRecord, singularSelector,
58
+ } from './series.js';
53
59
 
54
60
  /** Comparison operator names → plan ops. */
55
61
  const COMPARISONS = new Map([
@@ -119,6 +125,31 @@ for (const kind of NODE_KINDS) {
119
125
  * @param {string} reason
120
126
  * @returns {{ construct: string, reason: string }}
121
127
  */
128
+ /** Whether a literal window bound is one SQL takes as written: a
129
+ * non-negative safe integer. A negative, fractional or non-finite bound
130
+ * is the ENGINE's to interpret (it answers `[]`, a truncation or a
131
+ * refusal), and interpolated into `LIMIT`/`OFFSET` it answered other
132
+ * rows or a raw database error. */
133
+ function isWindowBound(value) {
134
+ return Number.isSafeInteger(value) && value >= 0;
135
+ }
136
+
137
+ /** Whether a schema node admits `null` beside its scalar type. A present
138
+ * null orders and aggregates in SQL (NULLS FIRST, skipped by SUM) where
139
+ * the engine refuses it (`JQ2005`, `JQ2001`), so a promotion over such a
140
+ * path answers where the reference semantics throw. */
141
+ function admitsNull(schema, segments) {
142
+ const node = schemaNodeAt(schema, segments);
143
+ return Array.isArray(node?.type) && node.type.includes('null');
144
+ }
145
+
146
+ /** Whether a typed reference orders natively: numbers and strings that
147
+ * cannot hold a null. A boolean orders as 0/1 in SQL and is `JQ2005` in
148
+ * the engine. */
149
+ function orderable(ref, schema) {
150
+ return ref.type !== 'unknown' && ref.type !== 'boolean' && !admitsNull(schema, ref.segments);
151
+ }
152
+
122
153
  function refusal(construct, reason) {
123
154
  return { construct, reason };
124
155
  }
@@ -262,9 +293,7 @@ function memberPath(node, itSlot) {
262
293
  else return null;
263
294
  }
264
295
  if (segments.length === 0) return null;
265
- const canonical = segments
266
- .map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
267
- return { segments, canonical };
296
+ return { segments, canonical: canonicalOf(segments) };
268
297
  }
269
298
 
270
299
  /**
@@ -628,6 +657,10 @@ function planDistanceBound(node, itSlot, shape) {
628
657
  if (constant === null) return { refusal: refusal('$distance', SPATIAL_REASONS.operand) };
629
658
  const at = probePosition(constant.value);
630
659
  if (at === null) return { refusal: refusal('$distance', SPATIAL_REASONS.unbounded) };
660
+ if (!Number.isFinite(radius.value) || radius.value < 0) {
661
+ return { refusal: refusal('$distance',
662
+ 'a distance bound is a finite, non-negative number of metres') };
663
+ }
631
664
  const box = probeCircleBox(at, radius.value);
632
665
  if (box === null) return { refusal: refusal('$distance', SPATIAL_REASONS.pole) };
633
666
  if (box[0] < -180 || box[2] > 180)
@@ -1010,6 +1043,625 @@ function planPredicate(node, itSlot, shape) {
1010
1043
  'no native spelling of this operator is proven equivalent') };
1011
1044
  }
1012
1045
 
1046
+ // ————— Time series: the three closed shapes over a declared index —————
1047
+ //
1048
+ // The physical feature is one a model already has: a composite index
1049
+ // over `[$.series, $.at]`. There is no `derive: 'series'`, no column
1050
+ // type and no host function — D9's whole point is that a declared
1051
+ // numeric epoch column is already 52× reading the instant back out of
1052
+ // the document, so the work here is recognizing which questions that
1053
+ // index can answer rather than inventing a place to put time.
1054
+ //
1055
+ // Three shapes are recognized, and they are CLOSED:
1056
+ //
1057
+ // 1. **range** — every leading column of an instant index pinned by an
1058
+ // equality, a half-open range on the instant column, ordered by it.
1059
+ // Already a native selection; what this adds is the NAME of the
1060
+ // operation, the index it seeks, and the honest reason when the
1061
+ // prefix is missing.
1062
+ // 2. **as-of** — the same prefix with ONE instant bound, ordered by
1063
+ // the instant, cut to a finite window. §2.2's 0.003 ms row.
1064
+ // 3. **bucket** — a fixed-width ladder over the instant column with
1065
+ // the exact `sum|mean|min|max|count` aggregates, in both spellings
1066
+ // the language has for it: a `$groupby` whose key is
1067
+ // `$time-bucket`, and a `$resample` whose spec asks for nothing a
1068
+ // `GROUP BY` cannot do.
1069
+ //
1070
+ // Everything else is a NAMED core refinement: the fetch narrows through
1071
+ // the index and the residual — which is the engine running the caller's
1072
+ // own document — decides. That is what keeps a refinement idempotent,
1073
+ // and it is why a calendar ladder, a fill policy, a rolling window and
1074
+ // an as-of JOIN cost a bounded fetch rather than a wrong answer.
1075
+
1076
+ /**
1077
+ * The source a packed spelling stands for. `["$[*]"]` — an array
1078
+ * constructor of exactly one element — is what a `$for` unpacks back
1079
+ * into the rows (QUERY-FORMAT §6.2, D4): `@jarenjs/linq` binds every
1080
+ * iterated source that way so an array-valued ROW stays one item. Over
1081
+ * a collection or an entity array the rows are objects, so the packed
1082
+ * and the bare spelling are the same rows, and the planner reads
1083
+ * through the packing rather than sending the document to the residual.
1084
+ * @param {any} node
1085
+ * @returns {any}
1086
+ */
1087
+ function unpacked(node) {
1088
+ return node?.kind === 'array' && node.elements?.length === 1 ? node.elements[0] : node;
1089
+ }
1090
+
1091
+ /** Is this node the whole collection — `$[*]` over the input document,
1092
+ * bare or packed? */
1093
+ function isCollectionSource(node) {
1094
+ const path = unpacked(node);
1095
+ return path?.kind === 'path' && path.name === '$' && path.external !== true
1096
+ && path.rootSlot === 0 && path.singular !== true
1097
+ && path.segments.length === 1 && path.segments[0].descendant !== true
1098
+ && path.segments[0].selectors.length === 1
1099
+ && path.segments[0].selectors[0].kind === 'wildcard';
1100
+ }
1101
+
1102
+ /**
1103
+ * The collection-side operand of a root series operator: the bare
1104
+ * `$[*]`, or a FLWOR over it whose `$where` is the narrowing.
1105
+ * @param {any} node
1106
+ * @returns {{ flwor: any } | null}
1107
+ */
1108
+ function collectionOperand(node) {
1109
+ if (isCollectionSource(node)) return { flwor: null };
1110
+ if (node?.kind === 'flwor' && node.forBindings.length === 1
1111
+ && isCollectionSource(node.forBindings[0]?.expr))
1112
+ return { flwor: node };
1113
+ return null;
1114
+ }
1115
+
1116
+ /**
1117
+ * A typed reference to one top-level member of the collection's
1118
+ * documents — what a spec's row selector ultimately names.
1119
+ * @param {any} shape
1120
+ * @param {string} name
1121
+ * @returns {import('./algebra.js').PlanRef}
1122
+ */
1123
+ function memberRef(shape, name) {
1124
+ const segments = [{ name }];
1125
+ return {
1126
+ segments,
1127
+ type: typeOfPath(shape.schema, segments),
1128
+ column: shape.columnByCanonical.get(canonicalOf(segments)) ?? null,
1129
+ };
1130
+ }
1131
+
1132
+ /**
1133
+ * The instants a plan-time literal series carries, under one selector.
1134
+ * `null` when the operand is not a literal array of records, or when
1135
+ * one of them names no instant — either way the planner has no bound to
1136
+ * add and says so rather than guessing one.
1137
+ * @param {any} node
1138
+ * @param {any} selector - the spec's `leftAt`/`rightAt`, or undefined
1139
+ * @returns {{ min: number, max: number, keys: any[] | null } | null}
1140
+ */
1141
+ function literalInstants(node, selector, keySelector) {
1142
+ const constant = constantOf(node);
1143
+ if (constant === null || !Array.isArray(constant.value) || constant.value.length === 0)
1144
+ return null;
1145
+ const at = selector === undefined ? 'at' : singularSelector(selector);
1146
+ const by = keySelector === undefined ? null : singularSelector(keySelector);
1147
+ if (at === null || (keySelector !== undefined && by === null)) return null;
1148
+ let min = Infinity;
1149
+ let max = -Infinity;
1150
+ const keys = by === null ? null : [];
1151
+ for (const row of constant.value) {
1152
+ if (row === null || typeof row !== 'object') return null;
1153
+ const instant = row[at];
1154
+ if (typeof instant !== 'number' || !Number.isFinite(instant)) return null;
1155
+ if (instant < min) min = instant;
1156
+ if (instant > max) max = instant;
1157
+ if (keys !== null) {
1158
+ const key = row[by];
1159
+ if (typeof key !== 'string' && typeof key !== 'number') return null;
1160
+ if (!keys.includes(key)) keys.push(key);
1161
+ }
1162
+ }
1163
+ return { min, max, keys };
1164
+ }
1165
+
1166
+ /**
1167
+ * The native bucket a `$resample` spec asks for, or the FIRST reason it
1168
+ * is a refinement instead. The rules are asked in one fixed order, so a
1169
+ * spec always names the same reason on every host.
1170
+ * @param {any} spec - the frozen literal
1171
+ * @param {any} shape
1172
+ * @returns {{ bucket: import('./algebra.js').PlanBucket } | { code: string }}
1173
+ */
1174
+ function resampleBucket(spec, shape) {
1175
+ // the clock first, because a named zone is resolved by HOST code the
1176
+ // planner does not have: asking the kernel about it would report the
1177
+ // missing provider rather than the reason a ladder is not native
1178
+ if (spec.zone !== undefined && spec.zone !== 'UTC') return { code: 'named-zone' };
1179
+ // The kernel's own rules, asked once, by running it over NO rows —
1180
+ // order 04's trick, for order 04's reason. `analyzeQuery` does not
1181
+ // compile an operator, so a spec the kernel refuses reaches the
1182
+ // planner before the engine has had its say, and a plan that answered
1183
+ // where the engine raises is the one thing a pushdown may never do.
1184
+ try {
1185
+ resampleSeries([], spec);
1186
+ }
1187
+ catch {
1188
+ return { code: 'invalid-spec' };
1189
+ }
1190
+ if (spec.fill !== undefined && spec.fill !== 'omit') return { code: 'fill-policy' };
1191
+ const fn = NATIVE_AGGREGATES[spec.aggregate ?? 'mean'];
1192
+ if (fn === undefined) return { code: 'unsupported-aggregate' };
1193
+ const ladder = fixedLadder(spec, compileBuckets);
1194
+ if ('code' in ladder) return ladder;
1195
+ // a row selector is a singular path whose `$` is the ROW, so it names
1196
+ // a member — and a member is what a declared column stands for. One
1197
+ // that names a path INTO a member names no column, and says so
1198
+ const atName = spec.at === undefined ? 'at' : singularSelector(spec.at);
1199
+ const valueName = spec.value === undefined ? 'value' : singularSelector(spec.value);
1200
+ if (atName === null || valueName === null) return { code: 'row-selector' };
1201
+ const at = memberRef(shape, atName);
1202
+ const instantBad = instantRefusal(at);
1203
+ if (instantBad !== null) return { code: instantBad };
1204
+ const value = memberRef(shape, valueName);
1205
+ if (fn !== 'rows') {
1206
+ const valueBad = valueRefusal(value);
1207
+ if (valueBad !== null) return { code: valueBad };
1208
+ }
1209
+ // D5's shape, exactly: the bucket's start, its reading, and the count
1210
+ // of SOURCE rows — which is `COUNT(*)` whether or not it is also the
1211
+ // answer, because `aggregate: 'count'` returns that same number
1212
+ return { bucket: {
1213
+ ref: at,
1214
+ every: ladder.every,
1215
+ origin: ladder.origin,
1216
+ as: 'at',
1217
+ order: 'asc',
1218
+ aggregates: [
1219
+ { fn: /** @type {any} */ (fn), ref: fn === 'rows' ? null : value, as: 'value' },
1220
+ { fn: /** @type {any} */ ('rows'), ref: null, as: 'count' },
1221
+ ],
1222
+ } };
1223
+ }
1224
+
1225
+ /**
1226
+ * The ladder a `$groupby` key spells, when the key is a `$time-bucket`
1227
+ * over a member path with literal width and origin.
1228
+ * @param {any} key - the grouping key expression node
1229
+ * @param {number} itSlot
1230
+ * @param {any} shape
1231
+ * @returns {{ ref: any, every: number, origin: number } | { code: string } | null}
1232
+ * `null` when the key is not a `$time-bucket` at all
1233
+ */
1234
+ function groupLadder(key, itSlot, shape) {
1235
+ if (key?.kind !== 'op' || key.name !== '$time-bucket') return null;
1236
+ const [atNode, everyNode, originNode, contextNode] = key.args;
1237
+ if (everyNode.kind !== 'literal') return { code: 'nonliteral-spec' };
1238
+ if (originNode !== undefined && originNode.kind !== 'literal')
1239
+ return { code: 'nonliteral-spec' };
1240
+ /** @type {any} */
1241
+ const spec = { every: everyNode.value };
1242
+ if (originNode !== undefined && originNode.value !== null) spec.origin = originNode.value;
1243
+ if (contextNode !== undefined) {
1244
+ if (contextNode.kind !== 'raw') return { code: 'nonliteral-spec' };
1245
+ const context = contextNode.value;
1246
+ if (context?.zone !== undefined) return { code: 'named-zone' };
1247
+ if (context?.offset !== undefined) spec.offset = context.offset;
1248
+ }
1249
+ const ladder = fixedLadder(spec, compileBuckets);
1250
+ if ('code' in ladder) return ladder;
1251
+ const ref = pathRef(atNode, itSlot, shape);
1252
+ const instantBad = instantRefusal(ref);
1253
+ if (instantBad !== null) return { code: instantBad };
1254
+ return { ref, every: ladder.every, origin: ladder.origin };
1255
+ }
1256
+
1257
+ /**
1258
+ * The closed projection of a bucket grouping: one member per answered
1259
+ * value, each of them the group key, a `$count` of the whole binding,
1260
+ * or one of the four value aggregates over a schema-typed numeric path.
1261
+ * @param {any} ret - the `$return` node
1262
+ * @param {number} itSlot
1263
+ * @param {number} keySlot
1264
+ * @param {any} shape
1265
+ * @returns {{ as: string, aggregates: any[] } | { code: string }}
1266
+ */
1267
+ function bucketProjection(ret, itSlot, keySlot, shape) {
1268
+ if (ret?.kind !== 'object') return { code: 'nonnative-grouping' };
1269
+ let as = null;
1270
+ const aggregates = [];
1271
+ for (const entry of ret.entries) {
1272
+ const expr = entry.expr;
1273
+ if (expr.kind === 'var' && expr.external !== true && expr.slot === keySlot) {
1274
+ if (as !== null) return { code: 'nonnative-grouping' };
1275
+ as = entry.name;
1276
+ continue;
1277
+ }
1278
+ if (expr.kind !== 'op') return { code: 'nonnative-grouping' };
1279
+ if (expr.name === '$count') {
1280
+ // `$count` over the BINDING is the group's row count; over a path
1281
+ // it counts the rows that HAVE the member, which SQL's
1282
+ // `COUNT(column)` does not reproduce for a JSON `null`
1283
+ if (!isItVar(expr.args[0], itSlot)) return { code: 'nonnative-grouping' };
1284
+ aggregates.push({ fn: 'rows', ref: null, as: entry.name, empty: 'null' });
1285
+ continue;
1286
+ }
1287
+ const fn = { $sum: 'sum', $avg: 'avg', $min: 'min', $max: 'max' }[expr.name];
1288
+ if (fn === undefined) return { code: 'nonnative-grouping' };
1289
+ const ref = pathRef(expr.args[0], itSlot, shape);
1290
+ const valueBad = valueRefusal(ref);
1291
+ if (valueBad !== null) return { code: valueBad };
1292
+ // what an aggregate over NO numbers says, in the ENGINE's words:
1293
+ // `$sum` of an empty sequence is 0 and the other three are the
1294
+ // empty sequence, which an object constructor leaves the member out
1295
+ // for. SQL answers `NULL` for all four, so the mapping is the plan's
1296
+ aggregates.push({ fn, ref, as: entry.name, empty: fn === 'sum' ? 'zero' : 'omit' });
1297
+ }
1298
+ if (as === null || aggregates.length === 0) return { code: 'nonnative-grouping' };
1299
+ return { as, aggregates };
1300
+ }
1301
+
1302
+ /**
1303
+ * Which way the groups come out. The engine's own rule is order of
1304
+ * FIRST APPEARANCE (§6.5), which over a collection is the earliest row
1305
+ * identity in each group; an `$orderby` on the key alone replaces it.
1306
+ * @param {any} orderby
1307
+ * @param {number} keySlot
1308
+ * @returns {'asc' | 'desc' | 'first-seen' | null} `null` when the
1309
+ * ordering is one this plan cannot reproduce
1310
+ */
1311
+ function bucketOrder(orderby, keySlot) {
1312
+ if (orderby === null) return 'first-seen';
1313
+ if (orderby.specs.length !== 1) return null;
1314
+ const spec = orderby.specs[0];
1315
+ if (spec.collation !== null || spec.collationName !== null) return null;
1316
+ const key = spec.key;
1317
+ if (key.kind !== 'var' || key.external === true || key.slot !== keySlot) return null;
1318
+ return spec.desc === true ? 'desc' : 'asc';
1319
+ }
1320
+
1321
+ /**
1322
+ * The record a refused grouping leaves behind: the same question, named
1323
+ * and reasoned, over whatever the fetch still narrows.
1324
+ * @param {import('./algebra.js').Plan} plan
1325
+ * @param {any} shape
1326
+ * @param {string} code
1327
+ * @param {string} construct
1328
+ * @returns {any}
1329
+ */
1330
+ function refinedGrouping(plan, shape, code, construct) {
1331
+ const facts = filterFacts(plan.filter);
1332
+ let column = null;
1333
+ for (const [name] of facts.bounds) {
1334
+ if (instantIndexesOver(shape, name, 2).length > 0) column = name;
1335
+ }
1336
+ const index = seekingIndexFor(shape, column, facts, 2);
1337
+ const bound = column === null ? null : facts.bounds.get(column);
1338
+ return seriesRecord({
1339
+ mode: plan.filter === null ? 'engine' : 'hybrid',
1340
+ operation: 'bucket',
1341
+ index: index === null ? null : index.name,
1342
+ prefix: index === null ? [] : index.prefix,
1343
+ range: bound === undefined || bound === null ? null : { column, ...bound },
1344
+ refinement: 'resampleSeries',
1345
+ reasons: [seriesReason(code, construct)],
1346
+ });
1347
+ }
1348
+
1349
+ /**
1350
+ * Classify a planned selection as a temporal range or as-of lookup, or
1351
+ * answer `null` when the document asked no such question. The plan is
1352
+ * NOT changed: this names what the selection already is, and which
1353
+ * declared index it seeks through.
1354
+ * @param {import('./algebra.js').Plan} plan
1355
+ * @param {any} shape
1356
+ * @param {boolean} ordered - the ordering was pushed whole
1357
+ * @returns {any} the series record, or null
1358
+ */
1359
+ function classifySelection(plan, shape, ordered) {
1360
+ const facts = filterFacts(plan.filter);
1361
+ // the instant column is the one a DECLARED index ends with; without
1362
+ // such an index the collection has no instant and the question was
1363
+ // an ordinary one
1364
+ const candidates = [];
1365
+ for (const [column, bound] of facts.bounds) {
1366
+ if (instantIndexesOver(shape, column, 2).length === 0) continue;
1367
+ candidates.push([column, bound]);
1368
+ }
1369
+ if (candidates.length !== 1) return null;
1370
+ const [column, bound] = candidates[0];
1371
+ const index = seekingIndexFor(shape, column, facts, 2);
1372
+ const bounded = bound.from !== null || bound.to !== null;
1373
+ const twoSided = bound.from !== null && bound.to !== null;
1374
+ const orderedByInstant = ordered && plan.order !== null && plan.order.length === 1
1375
+ && plan.order[0].ref.column === column;
1376
+ const operation = twoSided ? 'range'
1377
+ : (orderedByInstant && plan.window !== null && plan.window.limit !== null) ? 'asof'
1378
+ : bounded ? 'range' : null;
1379
+ if (operation === null) return null;
1380
+ const reasons = index === null ? [seriesReason('missing-series-prefix', '$where')] : [];
1381
+ return seriesRecord({
1382
+ mode: index === null ? 'engine' : 'native',
1383
+ operation,
1384
+ index: index === null ? null : index.name,
1385
+ prefix: index === null ? [] : index.prefix,
1386
+ range: {
1387
+ from: bound.from, fromOp: bound.fromOp, to: bound.to, toOp: bound.toOp, column,
1388
+ },
1389
+ reasons,
1390
+ });
1391
+ }
1392
+
1393
+
1394
+ /**
1395
+ * The bucket a `$groupby` phrase spells, or the reason it is not one.
1396
+ * @param {any} node - the flwor node
1397
+ * @param {number} itSlot
1398
+ * @param {any} shape
1399
+ * @returns {{ bucket: any } | { code: string }}
1400
+ */
1401
+ function planBucketGrouping(node, itSlot, shape) {
1402
+ if (node.groupby.keys.length !== 1) return { code: 'nonnative-grouping' };
1403
+ const key = node.groupby.keys[0];
1404
+ const ladder = groupLadder(key.expr, itSlot, shape);
1405
+ if (ladder === null) return { code: 'nonnative-grouping' };
1406
+ if ('code' in ladder) return ladder;
1407
+ const projection = bucketProjection(node.ret, itSlot, key.slot, shape);
1408
+ if ('code' in projection) return projection;
1409
+ const order = bucketOrder(node.orderby, key.slot);
1410
+ if (order === null) return { code: 'nonnative-grouping' };
1411
+ return { bucket: {
1412
+ ref: ladder.ref,
1413
+ every: ladder.every,
1414
+ origin: ladder.origin,
1415
+ as: projection.as,
1416
+ order,
1417
+ aggregates: projection.aggregates,
1418
+ } };
1419
+ }
1420
+
1421
+ /**
1422
+ * The tolerance of an as-of spec in milliseconds, or `null` for one
1423
+ * that bounds nothing. A NEGATIVE tolerance is a broken document the
1424
+ * engine refuses, and narrowing by it would move the bounds INWARD —
1425
+ * so it bounds nothing here and the engine raises, which is the same
1426
+ * rule `safeEpoch` follows for an instant that names none.
1427
+ */
1428
+ function toleranceMs(tolerance) {
1429
+ if (tolerance === undefined) return null;
1430
+ if (typeof tolerance === 'number')
1431
+ return Number.isFinite(tolerance) && tolerance >= 0 ? tolerance : null;
1432
+ try {
1433
+ const span = compileBuckets({ every: tolerance }, {});
1434
+ return span.calendar || span.width < 0 ? null : span.width;
1435
+ }
1436
+ catch {
1437
+ return null;
1438
+ }
1439
+ }
1440
+
1441
+ /**
1442
+ * The epoch a spec member names, or `null` when it names none.
1443
+ *
1444
+ * A planner may never raise on the ENGINE's behalf: `analyzeQuery` does
1445
+ * not compile an operator, so a spec whose `start` is not an instant
1446
+ * reaches here before the engine has had its say. Refusing to narrow is
1447
+ * the right answer — the residual compiles the caller's own document
1448
+ * and raises the `JQ0003` it would have raised anyway.
1449
+ * @param {any} value
1450
+ * @returns {number | null}
1451
+ */
1452
+ function safeEpoch(value) {
1453
+ if (value === undefined) return null;
1454
+ try {
1455
+ const at = toEpoch(value);
1456
+ return Number.isFinite(at) ? at : null;
1457
+ }
1458
+ catch {
1459
+ return null;
1460
+ }
1461
+ }
1462
+
1463
+ /** An instant bound as a pushable conjunct over the instant column. */
1464
+ function instantBound(ref, op, value) {
1465
+ return { p: 'cmp', op, ref, operand: { lit: value } };
1466
+ }
1467
+
1468
+ /**
1469
+ * The bounds a frozen spec implies for the collection side, as pushable
1470
+ * conjuncts. Every one of them is an IMPLIED conjunct: it narrows the
1471
+ * fetch and decides nothing, because the residual re-runs the caller's
1472
+ * own document — the whole operator — over what comes back.
1473
+ * @returns {{ preds: any[], range: any }}
1474
+ */
1475
+ function impliedInstantBounds(ref, from, to) {
1476
+ const preds = [];
1477
+ if (from !== null) preds.push(instantBound(ref, 'ge', from));
1478
+ if (to !== null) preds.push(instantBound(ref, 'le', to));
1479
+ return { preds, range: { column: ref.column, from, fromOp: from === null ? null : 'ge',
1480
+ to, toOp: to === null ? null : 'le' } };
1481
+ }
1482
+
1483
+ /**
1484
+ * Plan a document that IS a series operator over the collection.
1485
+ *
1486
+ * The collection is one of the operator's operands, so the narrowing is
1487
+ * that operand's own `$where` plus what the frozen spec implies, and
1488
+ * the kernel — the engine running the caller's document over the
1489
+ * fetched candidates — decides. A `$resample` whose spec asks for
1490
+ * nothing a `GROUP BY` cannot do is the one exception: it is native,
1491
+ * and answers the bucket records itself.
1492
+ * @param {any} root
1493
+ * @param {any} shape
1494
+ * @returns {any} `null` when the operator is not over this collection
1495
+ */
1496
+ function planSeriesOperator(root, shape) {
1497
+ const name = root.name;
1498
+ /** @type {any} */
1499
+ let operandNode = null;
1500
+ /** @type {any} */
1501
+ let probesNode = null;
1502
+ /** @type {any} */
1503
+ let spec = null;
1504
+ if (name === '$resample' || name === '$rolling') {
1505
+ operandNode = root.args[0];
1506
+ spec = root.args[1]?.kind === 'raw' ? root.args[1].value : null;
1507
+ }
1508
+ else {
1509
+ spec = root.args.length === 3
1510
+ ? (root.args[2].kind === 'raw' ? root.args[2].value : null) : {};
1511
+ // narrowing is only sound on the RIGHT side: an as-of join answers
1512
+ // once per LEFT row, so every left row is needed whatever it matches
1513
+ if (collectionOperand(root.args[1]) !== null) {
1514
+ operandNode = root.args[1];
1515
+ probesNode = root.args[0];
1516
+ }
1517
+ else if (collectionOperand(root.args[0]) !== null) {
1518
+ operandNode = root.args[0];
1519
+ }
1520
+ }
1521
+ const operand = operandNode === null ? null : collectionOperand(operandNode);
1522
+ if (operand === null || spec === null || typeof spec !== 'object') return null;
1523
+
1524
+ const inner = operand.flwor === null
1525
+ ? { plan: selectPlan(shape.collection), reasons: [], prefilters: [] }
1526
+ : planFlwor(operand.flwor, shape, undefined, undefined);
1527
+ const plan = inner.plan;
1528
+ // the operand's own clauses stay the engine's: the residual runs the
1529
+ // WHOLE document, so a projection or an ordering inside it is applied
1530
+ // there and only its pushed conjuncts narrow
1531
+ plan.order = null;
1532
+ plan.window = null;
1533
+
1534
+ const reasons = [];
1535
+ const prefilters = [...(inner.prefilters ?? [])];
1536
+ const at = memberRef(shape,
1537
+ spec.at !== undefined ? (singularSelector(spec.at) ?? 'at')
1538
+ : (name === '$asof' && probesNode !== null && spec.rightAt !== undefined
1539
+ ? (singularSelector(spec.rightAt) ?? 'at') : 'at'));
1540
+
1541
+ if (name === '$resample') {
1542
+ const outcome = resampleBucket(spec, shape);
1543
+ if ('bucket' in outcome) {
1544
+ // native: the ladder, the aggregate and the count are the plan's
1545
+ const bucket = outcome.bucket;
1546
+ bucket.aggregates[0].empty = 'null';
1547
+ bucket.aggregates[1].empty = 'null';
1548
+ plan.bucket = bucket;
1549
+ const windowFrom = safeEpoch(spec.start);
1550
+ const windowTo = safeEpoch(spec.end);
1551
+ if (windowFrom !== null) plan.filter = conjoin(plan.filter,
1552
+ instantBound(bucket.ref, 'ge', windowFrom));
1553
+ if (windowTo !== null) plan.filter = conjoin(plan.filter,
1554
+ instantBound(bucket.ref, 'lt', windowTo));
1555
+ const facts = filterFacts(plan.filter);
1556
+ const index = seekingIndexFor(shape, bucket.ref.column, facts);
1557
+ const bound = facts.bounds.get(bucket.ref.column) ?? null;
1558
+ return {
1559
+ plan,
1560
+ native: inner.reasons.length === 0,
1561
+ reasons: inner.reasons,
1562
+ prefilters,
1563
+ series: seriesRecord({
1564
+ mode: 'native',
1565
+ operation: 'resample',
1566
+ index: index === null ? null : index.name,
1567
+ prefix: index === null ? [] : index.prefix,
1568
+ range: bound === null ? null : { column: bucket.ref.column, ...bound },
1569
+ ladder: { every: bucket.every, origin: bucket.origin, calendar: false },
1570
+ aggregates: bucket.aggregates.map((a) => a.as),
1571
+ reasons: index === null ? [seriesReason('missing-series-prefix', name)] : [],
1572
+ }),
1573
+ };
1574
+ }
1575
+ reasons.push(seriesReason(outcome.code, name));
1576
+ }
1577
+ else if (name === '$rolling') {
1578
+ reasons.push(seriesReason('rolling-refinement', name));
1579
+ }
1580
+ else {
1581
+ reasons.push(seriesReason('asof-refinement', name));
1582
+ }
1583
+
1584
+ // the refinement: narrow through the index by whatever the spec makes
1585
+ // provable, and let the engine's own kernel decide over what comes back
1586
+ let range = null;
1587
+ if (name === '$resample' && at.column !== null) {
1588
+ const from = safeEpoch(spec.start);
1589
+ const to = safeEpoch(spec.end);
1590
+ if (from !== null || to !== null) {
1591
+ if (from !== null) plan.filter = conjoin(plan.filter, instantBound(at, 'ge', from));
1592
+ if (to !== null) plan.filter = conjoin(plan.filter, instantBound(at, 'lt', to));
1593
+ range = { column: at.column, from, fromOp: from === null ? null : 'ge',
1594
+ to, toOp: to === null ? null : 'lt' };
1595
+ prefilters.push({ construct: name, via: 'columns', columns: [at.column], exact: false });
1596
+ }
1597
+ }
1598
+ else if (name === '$asof' && probesNode !== null) {
1599
+ const probes = literalInstants(probesNode, spec.leftAt, spec.by);
1600
+ if (probes !== null && at.column !== null) {
1601
+ const tolerance = toleranceMs(spec.tolerance);
1602
+ const direction = spec.direction ?? 'backward';
1603
+ let from = null;
1604
+ let to = null;
1605
+ if (direction === 'backward') {
1606
+ to = probes.max;
1607
+ if (tolerance !== null) from = probes.min - tolerance;
1608
+ }
1609
+ else if (direction === 'forward') {
1610
+ from = probes.min;
1611
+ if (tolerance !== null) to = probes.max + tolerance;
1612
+ }
1613
+ else if (tolerance !== null) {
1614
+ from = probes.min - tolerance;
1615
+ to = probes.max + tolerance;
1616
+ }
1617
+ const bounds = impliedInstantBounds(at, from, to);
1618
+ for (const pred of bounds.preds) plan.filter = conjoin(plan.filter, pred);
1619
+ if (bounds.preds.length > 0) {
1620
+ range = bounds.range;
1621
+ prefilters.push({ construct: name, via: 'columns', columns: [at.column], exact: false });
1622
+ }
1623
+ }
1624
+ // and the keys, whether or not the instant has a column of its own:
1625
+ // a right row whose group no left row names can match nothing, so a
1626
+ // membership test over the probes' own keys narrows and never drops
1627
+ if (probes !== null && probes.keys !== null && probes.keys.length > 0) {
1628
+ const byRef = memberRef(shape, /** @type {string} */ (singularSelector(spec.by)));
1629
+ if (byRef.column !== null) {
1630
+ plan.filter = conjoin(plan.filter, probes.keys.length === 1
1631
+ ? { p: 'cmp', op: 'eq', ref: byRef, operand: { lit: probes.keys[0] } }
1632
+ : { p: 'or', items: probes.keys.map((key) =>
1633
+ ({ p: 'cmp', op: 'eq', ref: byRef, operand: { lit: key } })) });
1634
+ prefilters.push({ construct: name, via: 'columns',
1635
+ columns: [byRef.column], exact: false });
1636
+ }
1637
+ }
1638
+ }
1639
+
1640
+ const facts = filterFacts(plan.filter);
1641
+ const index = seekingIndexFor(shape, at.column, facts);
1642
+ // one code, once: an instant with no column of its own already said
1643
+ // this when the ladder refused, and saying it twice reads as two facts
1644
+ if (index === null && !reasons.some((r) => r.code === 'missing-series-prefix'))
1645
+ reasons.push(seriesReason('missing-series-prefix', name));
1646
+ const narrowed = plan.filter !== null;
1647
+ return {
1648
+ plan,
1649
+ native: false,
1650
+ reasons: [...reasons, ...inner.reasons],
1651
+ prefilters,
1652
+ series: seriesRecord({
1653
+ mode: narrowed ? 'hybrid' : 'engine',
1654
+ operation: { $resample: 'resample', $rolling: 'rolling', $asof: 'asof-join' }[name],
1655
+ index: narrowed && index !== null ? index.name : null,
1656
+ prefix: narrowed && index !== null ? index.prefix : [],
1657
+ range,
1658
+ refinement: { $resample: 'resampleSeries', $rolling: 'rollingSeries',
1659
+ $asof: 'asOfJoin' }[name],
1660
+ reasons,
1661
+ }),
1662
+ };
1663
+ }
1664
+
1013
1665
  /**
1014
1666
  * Plan a FLWOR node into a select plan, recording refusals. When a
1015
1667
  * conjunct refuses native translation, the injected `udf` hook may
@@ -1035,20 +1687,18 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1035
1687
  const plan = selectPlan(shape.collection);
1036
1688
 
1037
1689
  // the one recognised source shape: a single plain binding over $[*]
1690
+ // (bare or packed — one check, `isCollectionSource`, for every site)
1038
1691
  const binding = node.forBindings[0];
1039
- const source = binding?.expr;
1040
1692
  const sourceIsCollection = node.forBindings.length === 1
1041
- && source?.kind === 'path' && source.name === '$' && source.external !== true
1042
- && source.segments.length === 1 && source.segments[0].descendant !== true
1043
- && source.segments[0].selectors.length === 1
1044
- && source.segments[0].selectors[0].kind === 'wildcard'
1693
+ && isCollectionSource(binding?.expr)
1045
1694
  && binding.window === null && binding.atSlot === -1
1046
1695
  && binding.allowingEmpty === false;
1047
1696
  if (!sourceIsCollection) {
1048
1697
  reasons.push(refusal('$for',
1049
1698
  'only a single plain binding over the whole collection is translated'));
1050
1699
  return { plan, reasons, whereFullyPushed: false, orderPushed: false,
1051
- projectionNative: false, itSlot: -1, itName: null, udfs: [], prefilters: [], knn: null };
1700
+ projectionNative: false, itSlot: -1, itName: null, udfs: [], prefilters: [],
1701
+ knn: null, bucket: null, bucketRefusal: null };
1052
1702
  }
1053
1703
  const itSlot = binding.slot;
1054
1704
  // the document's own name for the collection binding. The residual and
@@ -1060,7 +1710,22 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1060
1710
  if (node.fold !== null) reasons.push(refusal('$fold', KIND_REASONS.let));
1061
1711
  if (node.letBindings.length > 0) reasons.push(refusal('$let', KIND_REASONS.let));
1062
1712
  if (node.asChecks !== null) reasons.push(refusal('$as', 'type assertions run in the engine'));
1063
- if (node.groupby !== null) reasons.push(refusal('$groupby', KIND_REASONS.let));
1713
+ // A grouping is an unconditional residual EXCEPT in one closed shape:
1714
+ // a fixed-width `$time-bucket` key with the exact aggregates, which
1715
+ // is a `GROUP BY` over integer arithmetic. The bucket then owns the
1716
+ // ordering and the projection too, so it is decided before either.
1717
+ let bucket = null;
1718
+ let bucketRefusal = null;
1719
+ if (node.groupby !== null && node.fold === null && node.letBindings.length === 0
1720
+ && node.asChecks === null && node.count === null) {
1721
+ const grouped = planBucketGrouping(node, itSlot, shape);
1722
+ if ('bucket' in grouped) bucket = grouped.bucket;
1723
+ else {
1724
+ bucketRefusal = grouped.code;
1725
+ reasons.push(seriesReason(grouped.code, '$groupby'));
1726
+ }
1727
+ }
1728
+ else if (node.groupby !== null) reasons.push(refusal('$groupby', KIND_REASONS.let));
1064
1729
  if (node.count !== null) reasons.push(refusal('$count clause', KIND_REASONS.let));
1065
1730
  const structureClean = reasons.length === 0;
1066
1731
  // $let and $as run BEFORE $where in clause order: a row our pushed
@@ -1116,9 +1781,10 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1116
1781
  // caller's to name once the window is known)
1117
1782
  let orderPushed = false;
1118
1783
  let knn = null;
1119
- const ranked = node.orderby === null ? null
1784
+ const ranked = bucket !== null || node.orderby === null ? null
1120
1785
  : planKnnOrder(node.orderby, itSlot, shape, whereFullyPushed && structureClean);
1121
- if (ranked !== null) {
1786
+ if (bucket !== null) orderPushed = true; // the groups' order is the bucket's
1787
+ else if (ranked !== null) {
1122
1788
  if ('rank' in ranked) knn = ranked.rank;
1123
1789
  else reasons.push(ranked.refusal);
1124
1790
  }
@@ -1127,9 +1793,9 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1127
1793
  let refused = null;
1128
1794
  for (const spec of node.orderby.specs) {
1129
1795
  const ref = pathRef(spec.key, itSlot, shape);
1130
- if (ref === null || ref.type === 'unknown') {
1796
+ if (ref === null || !orderable(ref, shape.schema)) {
1131
1797
  refused = refusal('$orderby',
1132
- 'ordering translates only over singular schema-typed paths');
1798
+ 'ordering translates only over singular schema-typed paths (numbers and strings that cannot hold null)');
1133
1799
  break;
1134
1800
  }
1135
1801
  if (spec.collation !== null || spec.collationName !== null) {
@@ -1152,7 +1818,8 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1152
1818
  // RETURN: the bare binding is the native whole-document projection
1153
1819
  let projectionNative = false;
1154
1820
  assertDecidedKind(node.ret);
1155
- if (isItVar(node.ret, itSlot)) projectionNative = true;
1821
+ if (bucket !== null) projectionNative = true; // the bucket IS the projection
1822
+ else if (isItVar(node.ret, itSlot)) projectionNative = true;
1156
1823
  else {
1157
1824
  reasons.push(refusal('$return',
1158
1825
  'projections other than the bare binding run per row (the row residual)'));
@@ -1169,6 +1836,8 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1169
1836
  udfs,
1170
1837
  prefilters,
1171
1838
  knn,
1839
+ bucket,
1840
+ bucketRefusal,
1172
1841
  };
1173
1842
  }
1174
1843
 
@@ -1212,7 +1881,10 @@ function composeWindows(windows) {
1212
1881
  * udfs: string[],
1213
1882
  * prefilters: { construct: string, via: 'columns' | 'rtree',
1214
1883
  * columns: string[], exact: boolean }[],
1884
+ * series: any,
1215
1885
  * }}
1886
+ * `series` is the temporal record (`series.js`) when the document
1887
+ * asked a §8.16 question, and `null` when it did not.
1216
1888
  */
1217
1889
  function planCollectionCore(document, shape, options = undefined) {
1218
1890
  const analysis = analyzeQuery(document, analyzeOptionsFor(shape?.operators));
@@ -1224,13 +1896,13 @@ function planCollectionCore(document, shape, options = undefined) {
1224
1896
  const windows = [];
1225
1897
  while (root.kind === 'op' && root.name === '$subsequence') {
1226
1898
  const [inner, start, length] = root.args;
1227
- if (start?.kind !== 'literal' || typeof start.value !== 'number'
1228
- || (length !== undefined && (length.kind !== 'literal' || typeof length.value !== 'number'))) {
1899
+ if (start?.kind !== 'literal' || !isWindowBound(start.value)
1900
+ || (length !== undefined && (length.kind !== 'literal' || !isWindowBound(length.value)))) {
1229
1901
  // non-literal bounds: the whole document is a set residual
1230
1902
  return {
1231
1903
  analysis, plan: null, mode: 'set',
1232
- reasons: [refusal('$subsequence', 'window bounds must be literal numbers to push')],
1233
- rowReturn: null, udfs: [], prefilters: [],
1904
+ reasons: [refusal('$subsequence', 'window bounds must be literal numbers to push (non-negative integers)')],
1905
+ rowReturn: null, udfs: [], prefilters: [], series: null,
1234
1906
  };
1235
1907
  }
1236
1908
  windows.push({ offset: start.value, limit: length === undefined ? null : length.value });
@@ -1246,7 +1918,7 @@ function planCollectionCore(document, shape, options = undefined) {
1246
1918
  return {
1247
1919
  analysis, plan: null, mode: 'set',
1248
1920
  reasons: [refusal(root.name, 'a windowed aggregate is not translated')],
1249
- rowReturn: null, udfs: [], prefilters: [],
1921
+ rowReturn: null, udfs: [], prefilters: [], series: null,
1250
1922
  };
1251
1923
  }
1252
1924
  aggregate = { name: root.name, fn: AGGREGATES.get(root.name) };
@@ -1255,12 +1927,37 @@ function planCollectionCore(document, shape, options = undefined) {
1255
1927
  assertDecidedKind(root);
1256
1928
  }
1257
1929
 
1930
+ // a document that IS a series operator over the collection: the
1931
+ // operand's own conjuncts (and what the frozen spec implies) narrow
1932
+ // through the index, and the kernel decides over what comes back
1933
+ if (aggregate === null && root.kind === 'op' && SERIES_ROOT_OPS.includes(root.name)) {
1934
+ const temporal = planSeriesOperator(root, shape);
1935
+ if (temporal !== null) {
1936
+ // a peeled `$subsequence` composes as it does everywhere — over a
1937
+ // NATIVE bucket it is a LIMIT on the ascending groups, which is
1938
+ // the same items the kernel's own window would have kept; over a
1939
+ // refinement the residual applies it, so the plan keeps none
1940
+ const window = windows.length === 0 ? null : composeWindows(windows);
1941
+ if (temporal.native && window !== null) temporal.plan.window = window;
1942
+ return {
1943
+ analysis,
1944
+ plan: temporal.plan,
1945
+ mode: temporal.native ? 'native' : 'set',
1946
+ reasons: temporal.native ? [] : temporal.reasons,
1947
+ rowReturn: null,
1948
+ udfs: [],
1949
+ prefilters: temporal.prefilters,
1950
+ series: temporal.series,
1951
+ };
1952
+ }
1953
+ }
1954
+
1258
1955
  if (root.kind !== 'flwor') {
1259
1956
  return {
1260
1957
  analysis, plan: null, mode: 'set',
1261
1958
  reasons: [refusal(root.kind, KIND_REASONS[root.kind]
1262
1959
  ?? 'only a FLWOR over the collection is translated')],
1263
- rowReturn: null, udfs: [], prefilters: [],
1960
+ rowReturn: null, udfs: [], prefilters: [], series: null,
1264
1961
  };
1265
1962
  }
1266
1963
 
@@ -1279,7 +1976,7 @@ function planCollectionCore(document, shape, options = undefined) {
1279
1976
  margin: KNN_MARGIN };
1280
1977
  return { analysis, plan, mode: 'knn',
1281
1978
  reasons: [refusal('$orderby', KNN_REASONS.rank), ...flwor.reasons],
1282
- rowReturn: null, udfs: flwor.udfs, prefilters: flwor.prefilters };
1979
+ rowReturn: null, udfs: flwor.udfs, prefilters: flwor.prefilters, series: null };
1283
1980
  }
1284
1981
  flwor.reasons.unshift(refusal('$subsequence', KNN_REASONS.window));
1285
1982
  }
@@ -1289,7 +1986,17 @@ function planCollectionCore(document, shape, options = undefined) {
1289
1986
  // full sequence, not a narrowed candidate set)
1290
1987
  if (!fullyPushed) {
1291
1988
  return { analysis, plan: null, mode: 'set', reasons: flwor.reasons,
1292
- rowReturn: null, udfs: [], prefilters: flwor.prefilters };
1989
+ rowReturn: null, udfs: [], prefilters: flwor.prefilters, series: null };
1990
+ }
1991
+ if (flwor.bucket !== null || flwor.bucketRefusal != null) {
1992
+ // the phrase's items are its GROUPS; a COUNT(*) over the rows
1993
+ // answered the row count for a `$count` of the groups
1994
+ return {
1995
+ analysis, plan: null, mode: 'set',
1996
+ reasons: [refusal(aggregate.name,
1997
+ 'an aggregate over a grouped phrase folds its groups, which the engine does')],
1998
+ rowReturn: null, udfs: [], prefilters: [], series: null,
1999
+ };
1293
2000
  }
1294
2001
  if (aggregate.fn === 'count') {
1295
2002
  if (!flwor.projectionNative) {
@@ -1297,36 +2004,64 @@ function planCollectionCore(document, shape, options = undefined) {
1297
2004
  analysis, plan: null, mode: 'set',
1298
2005
  reasons: [refusal('$count',
1299
2006
  'count translates only over the bare binding (a projected return can change the item count)')],
1300
- rowReturn: null, udfs: [], prefilters: [],
2007
+ rowReturn: null, udfs: [], prefilters: [], series: null,
1301
2008
  };
1302
2009
  }
1303
2010
  plan.aggregate = { fn: 'count', ref: null };
1304
2011
  return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
1305
- udfs: flwor.udfs, prefilters: flwor.prefilters };
2012
+ udfs: flwor.udfs, prefilters: flwor.prefilters,
2013
+ series: classifySelection(plan, shape, true) };
1306
2014
  }
1307
2015
  const ref = pathRef(root.ret, flwor.itSlot, shape);
1308
2016
  const numeric = aggregate.fn === 'sum' || aggregate.fn === 'avg';
1309
2017
  const acceptable = ref !== null
1310
- && (numeric ? isNumericType(ref.type) : ref.type !== 'unknown');
2018
+ && (numeric ? isNumericType(ref.type) : ref.type !== 'unknown')
2019
+ && ref.type !== 'boolean' && !admitsNull(shape.schema, ref.segments);
1311
2020
  if (!acceptable) {
1312
2021
  return {
1313
2022
  analysis, plan: null, mode: 'set',
1314
2023
  reasons: [refusal(aggregate.name,
1315
2024
  'aggregates translate only over a singular schema-typed path (the engine ERRORS on non-conforming operands)')],
1316
- rowReturn: null, udfs: [], prefilters: [],
2025
+ rowReturn: null, udfs: [], prefilters: [], series: null,
1317
2026
  };
1318
2027
  }
1319
2028
  plan.aggregate = { fn: /** @type {any} */ (aggregate.fn), ref };
1320
2029
  return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
1321
- udfs: flwor.udfs, prefilters: flwor.prefilters };
2030
+ udfs: flwor.udfs, prefilters: flwor.prefilters,
2031
+ series: classifySelection(plan, shape, true) };
1322
2032
  }
1323
2033
 
1324
2034
  // windows push only onto a fully pushed selection
1325
2035
  if (windows.length > 0 && fullyPushed) plan.window = composeWindows(windows);
1326
2036
 
2037
+ // the temporal bucket: only over a WHOLE pushed selection, because a
2038
+ // conjunct the residual would still apply would arrive after the rows
2039
+ // were already summed
2040
+ if (flwor.bucket !== null && fullyPushed && (windows.length === 0 || plan.window !== null)) {
2041
+ plan.bucket = flwor.bucket;
2042
+ const facts = filterFacts(plan.filter);
2043
+ const index = seekingIndexFor(shape, plan.bucket.ref.column, facts);
2044
+ const bound = facts.bounds.get(plan.bucket.ref.column) ?? null;
2045
+ return {
2046
+ analysis, plan, mode: 'native', reasons: [], rowReturn: null,
2047
+ udfs: flwor.udfs, prefilters: flwor.prefilters,
2048
+ series: seriesRecord({
2049
+ mode: 'native',
2050
+ operation: 'bucket',
2051
+ index: index === null ? null : index.name,
2052
+ prefix: index === null ? [] : index.prefix,
2053
+ range: bound === null ? null : { column: plan.bucket.ref.column, ...bound },
2054
+ ladder: { every: plan.bucket.every, origin: plan.bucket.origin, calendar: false },
2055
+ aggregates: plan.bucket.aggregates.map((a) => a.as),
2056
+ reasons: index === null ? [seriesReason('missing-series-prefix', '$groupby')] : [],
2057
+ }),
2058
+ };
2059
+ }
2060
+
1327
2061
  if (fullyPushed && flwor.projectionNative && (windows.length === 0 || plan.window !== null)) {
1328
2062
  return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
1329
- udfs: flwor.udfs, prefilters: flwor.prefilters };
2063
+ udfs: flwor.udfs, prefilters: flwor.prefilters,
2064
+ series: classifySelection(plan, shape, flwor.orderPushed) };
1330
2065
  }
1331
2066
 
1332
2067
  // the row residual: everything but the projection pushed
@@ -1348,14 +2083,20 @@ function planCollectionCore(document, shape, options = undefined) {
1348
2083
  },
1349
2084
  udfs: flwor.udfs,
1350
2085
  prefilters: flwor.prefilters,
2086
+ series: flwor.bucketRefusal == null
2087
+ ? classifySelection(plan, shape, flwor.orderPushed)
2088
+ : refinedGrouping(plan, shape, flwor.bucketRefusal, '$groupby'),
1351
2089
  };
1352
2090
  }
1353
2091
 
1354
2092
  // the set residual: pushed conjuncts narrow, the engine answers
2093
+ const narrowing = flwor.bucketRefusal == null
2094
+ ? classifySelection(plan, shape, false)
2095
+ : refinedGrouping(plan, shape, flwor.bucketRefusal, '$groupby');
1355
2096
  plan.order = null;
1356
2097
  plan.window = null;
1357
2098
  return { analysis, plan, mode: 'set', reasons: flwor.reasons, rowReturn: null,
1358
- udfs: flwor.udfs, prefilters: flwor.prefilters };
2099
+ udfs: flwor.udfs, prefilters: flwor.prefilters, series: narrowing };
1359
2100
  }
1360
2101
 
1361
2102
  /**
@@ -1376,13 +2117,34 @@ function planCollectionCore(document, shape, options = undefined) {
1376
2117
  * udfs: string[],
1377
2118
  * prefilters: { construct: string, via: 'columns' | 'rtree',
1378
2119
  * columns: string[], exact: boolean }[],
2120
+ * series: any,
1379
2121
  * }}
1380
2122
  */
1381
2123
  export function planQuery(document, shape, options = undefined) {
1382
- const planned = planCollectionCore(document, shape, options);
2124
+ const peeled = peelWrappedResult(document);
2125
+ const planned = { ...planCollectionCore(peeled.document, shape, options), wrapped: peeled.wrapped };
1383
2126
  return prependRegisteredReason(planned, document, shape?.operators);
1384
2127
  }
1385
2128
 
2129
+ /**
2130
+ * A chain's element terminal wraps its phrase in a one-item array
2131
+ * constructor — `[<phrase>]`, the window that keeps an array-valued item
2132
+ * one item (QUERY-PEN §6) — so the document a store receives from
2133
+ * `toArray()`/`first()` is that constructor around the phrase. Read
2134
+ * through it: the phrase inside plans as it would bare, and the engines
2135
+ * answer its rows as the ONE array the constructor yields (`wrapped`),
2136
+ * which is exactly the engine's own answer for the document. Anything
2137
+ * else inside the brackets plans as itself and falls to the residual,
2138
+ * where the whole document — brackets included — runs in the engine.
2139
+ * @param {any} document
2140
+ * @returns {{ document: any, wrapped: boolean }}
2141
+ */
2142
+ function peelWrappedResult(document) {
2143
+ return Array.isArray(document) && document.length === 1
2144
+ ? { document: document[0], wrapped: true }
2145
+ : { document, wrapped: false };
2146
+ }
2147
+
1386
2148
  // ————— The entity document kind (one planner, two document kinds) —————
1387
2149
 
1388
2150
  /**
@@ -1400,7 +2162,7 @@ export function entityShape(entity, entityMapping) {
1400
2162
  const flavors = new Map();
1401
2163
  for (const column of entityMapping.columns) {
1402
2164
  const epoch = column.source === 'epoch(document)';
1403
- flavors.set(`.${column.name}`, {
2165
+ flavors.set(canonicalOf([{ name: column.name }]), {
1404
2166
  column: column.name,
1405
2167
  flavor: epoch ? 'entity-epoch' : 'entity-column',
1406
2168
  storage: column.storage,
@@ -1408,8 +2170,9 @@ export function entityShape(entity, entityMapping) {
1408
2170
  });
1409
2171
  }
1410
2172
  for (const fk of entityMapping.foreignKeys) {
1411
- if (!flavors.has(`.${fk.column}`))
1412
- flavors.set(`.${fk.column}`, { column: fk.column, flavor: 'entity-column', storage: 'string' });
2173
+ const fkCanonical = canonicalOf([{ name: fk.column }]);
2174
+ if (!flavors.has(fkCanonical))
2175
+ flavors.set(fkCanonical, { column: fk.column, flavor: 'entity-column', storage: 'string' });
1413
2176
  }
1414
2177
  return {
1415
2178
  collection: entity.name,
@@ -1430,8 +2193,7 @@ export function entityShape(entity, entityMapping) {
1430
2193
  export function entityPathRef(node, slot, shape) {
1431
2194
  const ref = pathRef(node, slot, shape);
1432
2195
  if (ref === null) return null;
1433
- const canonical = ref.segments
1434
- .map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
2196
+ const canonical = canonicalOf(ref.segments);
1435
2197
  const flavored = shape.entityFlavors.get(canonical);
1436
2198
  if (flavored !== undefined) {
1437
2199
  return {
@@ -1467,8 +2229,7 @@ export function planEntityPredicate(node, slot, shape) {
1467
2229
  return { ...pred, items: pred.items.map(reflavor) };
1468
2230
  if (pred.p === 'not') return { ...pred, item: reflavor(pred.item) };
1469
2231
  if (!('ref' in pred) || pred.ref === null) return pred;
1470
- const canonical = pred.ref.segments
1471
- .map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
2232
+ const canonical = canonicalOf(pred.ref.segments);
1472
2233
  const flavored = shape.entityFlavors.get(canonical);
1473
2234
  if (flavored === undefined) {
1474
2235
  // externals against DOC paths are not translated here (the
@@ -1538,9 +2299,9 @@ function planEntityQueryCore(document, entities, mapping, operators) {
1538
2299
  const windows = [];
1539
2300
  while (root.kind === 'op' && root.name === '$subsequence') {
1540
2301
  const [inner, start, length] = root.args;
1541
- if (start?.kind !== 'literal' || typeof start.value !== 'number'
1542
- || (length !== undefined && (length.kind !== 'literal' || typeof length.value !== 'number')))
1543
- return residual('$subsequence', 'window bounds must be literal numbers to push');
2302
+ if (start?.kind !== 'literal' || !isWindowBound(start.value)
2303
+ || (length !== undefined && (length.kind !== 'literal' || !isWindowBound(length.value))))
2304
+ return residual('$subsequence', 'window bounds must be literal numbers to push (non-negative integers)');
1544
2305
  windows.push({ offset: start.value, limit: length === undefined ? null : length.value });
1545
2306
  root = inner;
1546
2307
  assertDecidedKind(root);
@@ -1560,7 +2321,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
1560
2321
  // bindings must each range over one entity's array
1561
2322
  const bindings = [];
1562
2323
  for (const binding of root.forBindings) {
1563
- const source = binding.expr;
2324
+ const source = unpacked(binding.expr);
1564
2325
  const sourceEntity = source?.kind === 'path' && source.name === '$'
1565
2326
  && source.external !== true && source.segments.length === 2
1566
2327
  && source.segments[0].descendant !== true
@@ -1654,11 +2415,16 @@ function planEntityQueryCore(document, entities, mapping, operators) {
1654
2415
  const binding = byName.get(slot);
1655
2416
  const ref = binding === undefined
1656
2417
  ? null : entityPathRef(spec.key, slot, binding.shape);
2418
+ // a boolean orders in SQL and is `JQ2005` in the engine on either
2419
+ // flavor; a document path that admits null stores a present null
2420
+ // (a COLUMN stores it absent, §9.3, so a nullable column pushes)
1657
2421
  if (ref === null || (ref.flavor === 'entity-doc' && ref.type === 'unknown')
2422
+ || ref.type === 'boolean'
2423
+ || (ref.flavor === 'entity-doc' && admitsNull(binding.shape.schema, ref.segments))
1658
2424
  || spec.collation !== null || spec.collationName !== null) {
1659
2425
  orderPushed = false;
1660
2426
  reasons.push({ construct: '$orderby',
1661
- reason: 'ordering translates only over typed entity paths' });
2427
+ reason: 'ordering translates only over typed entity paths (never a boolean, never a document path that admits null)' });
1662
2428
  break;
1663
2429
  }
1664
2430
  terms.push({ binding, ref, desc: spec.desc === true, emptyGreatest: spec.emptyGreatest === true });
@@ -1724,7 +2490,11 @@ function planEntityQueryCore(document, entities, mapping, operators) {
1724
2490
  * @returns {any}
1725
2491
  */
1726
2492
  export function planEntityQuery(document, entities, mapping, operators = null) {
1727
- const planned = planEntityQueryCore(document, entities, mapping, operators);
2493
+ const peeled = peelWrappedResult(document);
2494
+ const planned = {
2495
+ ...planEntityQueryCore(peeled.document, entities, mapping, operators),
2496
+ wrapped: peeled.wrapped,
2497
+ };
1728
2498
  return prependRegisteredReason(planned, document, operators);
1729
2499
  }
1730
2500
 
@@ -1742,13 +2512,30 @@ function collectBindingSlots(node, byName, slots) {
1742
2512
  }
1743
2513
  }
1744
2514
 
2515
+ /**
2516
+ * The root expression an entity's rows are bound through — the ONE
2517
+ * spelling of `$.<Name>[*]`: what an entity set exposes as its `root`
2518
+ * (the hint a chain reads), and what {@link collectEntityRoots}
2519
+ * recognises in a document. Two spellings would let a handle publish a
2520
+ * root the planner does not read.
2521
+ * @param {string} name - a declared entity name
2522
+ * @returns {string}
2523
+ */
2524
+ export function entityRoot(name) {
2525
+ return `$.${name}[*]`;
2526
+ }
2527
+
1745
2528
  /** The entity names a document's root paths reference (`$.Name[*]`). */
1746
2529
  export function collectEntityRoots(document, entities) {
1747
2530
  const found = new Set();
1748
2531
  const walk = (node) => {
1749
2532
  if (typeof node === 'string') {
1750
- const match = /^\$\.([A-Za-z_][A-Za-z0-9_]*)\[\*\]/.exec(node);
1751
- if (match !== null && entities.has(match[1])) found.add(match[1]);
2533
+ for (const name of entities.keys()) {
2534
+ if (node.startsWith(entityRoot(name))) {
2535
+ found.add(name);
2536
+ break;
2537
+ }
2538
+ }
1752
2539
  return;
1753
2540
  }
1754
2541
  if (Array.isArray(node)) {