@jarenjs/db 0.46.4 → 0.49.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plan.js CHANGED
@@ -41,6 +41,7 @@ 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';
@@ -50,6 +51,11 @@ import {
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([
@@ -1010,6 +1016,608 @@ function planPredicate(node, itSlot, shape) {
1010
1016
  'no native spelling of this operator is proven equivalent') };
1011
1017
  }
1012
1018
 
1019
+ // ————— Time series: the three closed shapes over a declared index —————
1020
+ //
1021
+ // The physical feature is one a model already has: a composite index
1022
+ // over `[$.series, $.at]`. There is no `derive: 'series'`, no column
1023
+ // type and no host function — D9's whole point is that a declared
1024
+ // numeric epoch column is already 52× reading the instant back out of
1025
+ // the document, so the work here is recognizing which questions that
1026
+ // index can answer rather than inventing a place to put time.
1027
+ //
1028
+ // Three shapes are recognized, and they are CLOSED:
1029
+ //
1030
+ // 1. **range** — every leading column of an instant index pinned by an
1031
+ // equality, a half-open range on the instant column, ordered by it.
1032
+ // Already a native selection; what this adds is the NAME of the
1033
+ // operation, the index it seeks, and the honest reason when the
1034
+ // prefix is missing.
1035
+ // 2. **as-of** — the same prefix with ONE instant bound, ordered by
1036
+ // the instant, cut to a finite window. §2.2's 0.003 ms row.
1037
+ // 3. **bucket** — a fixed-width ladder over the instant column with
1038
+ // the exact `sum|mean|min|max|count` aggregates, in both spellings
1039
+ // the language has for it: a `$groupby` whose key is
1040
+ // `$time-bucket`, and a `$resample` whose spec asks for nothing a
1041
+ // `GROUP BY` cannot do.
1042
+ //
1043
+ // Everything else is a NAMED core refinement: the fetch narrows through
1044
+ // the index and the residual — which is the engine running the caller's
1045
+ // own document — decides. That is what keeps a refinement idempotent,
1046
+ // and it is why a calendar ladder, a fill policy, a rolling window and
1047
+ // an as-of JOIN cost a bounded fetch rather than a wrong answer.
1048
+
1049
+ /** Is this node the whole collection — `$[*]` over the input document? */
1050
+ 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';
1056
+ }
1057
+
1058
+ /**
1059
+ * The collection-side operand of a root series operator: the bare
1060
+ * `$[*]`, or a FLWOR over it whose `$where` is the narrowing.
1061
+ * @param {any} node
1062
+ * @returns {{ flwor: any } | null}
1063
+ */
1064
+ function collectionOperand(node) {
1065
+ if (isCollectionSource(node)) return { flwor: null };
1066
+ if (node?.kind === 'flwor' && node.forBindings.length === 1
1067
+ && isCollectionSource(node.forBindings[0]?.expr))
1068
+ return { flwor: node };
1069
+ return null;
1070
+ }
1071
+
1072
+ /**
1073
+ * A typed reference to one top-level member of the collection's
1074
+ * documents — what a spec's row selector ultimately names.
1075
+ * @param {any} shape
1076
+ * @param {string} name
1077
+ * @returns {import('./algebra.js').PlanRef}
1078
+ */
1079
+ function memberRef(shape, name) {
1080
+ const segments = [{ name }];
1081
+ return {
1082
+ segments,
1083
+ type: typeOfPath(shape.schema, segments),
1084
+ column: shape.columnByCanonical.get(`.${name}`) ?? null,
1085
+ };
1086
+ }
1087
+
1088
+ /**
1089
+ * The instants a plan-time literal series carries, under one selector.
1090
+ * `null` when the operand is not a literal array of records, or when
1091
+ * one of them names no instant — either way the planner has no bound to
1092
+ * add and says so rather than guessing one.
1093
+ * @param {any} node
1094
+ * @param {any} selector - the spec's `leftAt`/`rightAt`, or undefined
1095
+ * @returns {{ min: number, max: number, keys: any[] | null } | null}
1096
+ */
1097
+ function literalInstants(node, selector, keySelector) {
1098
+ const constant = constantOf(node);
1099
+ if (constant === null || !Array.isArray(constant.value) || constant.value.length === 0)
1100
+ return null;
1101
+ const at = selector === undefined ? 'at' : singularSelector(selector);
1102
+ const by = keySelector === undefined ? null : singularSelector(keySelector);
1103
+ if (at === null || (keySelector !== undefined && by === null)) return null;
1104
+ let min = Infinity;
1105
+ let max = -Infinity;
1106
+ const keys = by === null ? null : [];
1107
+ for (const row of constant.value) {
1108
+ if (row === null || typeof row !== 'object') return null;
1109
+ const instant = row[at];
1110
+ if (typeof instant !== 'number' || !Number.isFinite(instant)) return null;
1111
+ if (instant < min) min = instant;
1112
+ if (instant > max) max = instant;
1113
+ if (keys !== null) {
1114
+ const key = row[by];
1115
+ if (typeof key !== 'string' && typeof key !== 'number') return null;
1116
+ if (!keys.includes(key)) keys.push(key);
1117
+ }
1118
+ }
1119
+ return { min, max, keys };
1120
+ }
1121
+
1122
+ /**
1123
+ * The native bucket a `$resample` spec asks for, or the FIRST reason it
1124
+ * is a refinement instead. The rules are asked in one fixed order, so a
1125
+ * spec always names the same reason on every host.
1126
+ * @param {any} spec - the frozen literal
1127
+ * @param {any} shape
1128
+ * @returns {{ bucket: import('./algebra.js').PlanBucket } | { code: string }}
1129
+ */
1130
+ function resampleBucket(spec, shape) {
1131
+ // the clock first, because a named zone is resolved by HOST code the
1132
+ // planner does not have: asking the kernel about it would report the
1133
+ // missing provider rather than the reason a ladder is not native
1134
+ if (spec.zone !== undefined && spec.zone !== 'UTC') return { code: 'named-zone' };
1135
+ // The kernel's own rules, asked once, by running it over NO rows —
1136
+ // order 04's trick, for order 04's reason. `analyzeQuery` does not
1137
+ // compile an operator, so a spec the kernel refuses reaches the
1138
+ // planner before the engine has had its say, and a plan that answered
1139
+ // where the engine raises is the one thing a pushdown may never do.
1140
+ try {
1141
+ resampleSeries([], spec);
1142
+ }
1143
+ catch {
1144
+ return { code: 'invalid-spec' };
1145
+ }
1146
+ if (spec.fill !== undefined && spec.fill !== 'omit') return { code: 'fill-policy' };
1147
+ const fn = NATIVE_AGGREGATES[spec.aggregate ?? 'mean'];
1148
+ if (fn === undefined) return { code: 'unsupported-aggregate' };
1149
+ const ladder = fixedLadder(spec, compileBuckets);
1150
+ if ('code' in ladder) return ladder;
1151
+ // a row selector is a singular path whose `$` is the ROW, so it names
1152
+ // a member — and a member is what a declared column stands for. One
1153
+ // that names a path INTO a member names no column, and says so
1154
+ const atName = spec.at === undefined ? 'at' : singularSelector(spec.at);
1155
+ const valueName = spec.value === undefined ? 'value' : singularSelector(spec.value);
1156
+ if (atName === null || valueName === null) return { code: 'row-selector' };
1157
+ const at = memberRef(shape, atName);
1158
+ const instantBad = instantRefusal(at);
1159
+ if (instantBad !== null) return { code: instantBad };
1160
+ const value = memberRef(shape, valueName);
1161
+ if (fn !== 'rows') {
1162
+ const valueBad = valueRefusal(value);
1163
+ if (valueBad !== null) return { code: valueBad };
1164
+ }
1165
+ // D5's shape, exactly: the bucket's start, its reading, and the count
1166
+ // of SOURCE rows — which is `COUNT(*)` whether or not it is also the
1167
+ // answer, because `aggregate: 'count'` returns that same number
1168
+ return { bucket: {
1169
+ ref: at,
1170
+ every: ladder.every,
1171
+ origin: ladder.origin,
1172
+ as: 'at',
1173
+ order: 'asc',
1174
+ aggregates: [
1175
+ { fn: /** @type {any} */ (fn), ref: fn === 'rows' ? null : value, as: 'value' },
1176
+ { fn: /** @type {any} */ ('rows'), ref: null, as: 'count' },
1177
+ ],
1178
+ } };
1179
+ }
1180
+
1181
+ /**
1182
+ * The ladder a `$groupby` key spells, when the key is a `$time-bucket`
1183
+ * over a member path with literal width and origin.
1184
+ * @param {any} key - the grouping key expression node
1185
+ * @param {number} itSlot
1186
+ * @param {any} shape
1187
+ * @returns {{ ref: any, every: number, origin: number } | { code: string } | null}
1188
+ * `null` when the key is not a `$time-bucket` at all
1189
+ */
1190
+ function groupLadder(key, itSlot, shape) {
1191
+ if (key?.kind !== 'op' || key.name !== '$time-bucket') return null;
1192
+ const [atNode, everyNode, originNode, contextNode] = key.args;
1193
+ if (everyNode.kind !== 'literal') return { code: 'nonliteral-spec' };
1194
+ if (originNode !== undefined && originNode.kind !== 'literal')
1195
+ return { code: 'nonliteral-spec' };
1196
+ /** @type {any} */
1197
+ const spec = { every: everyNode.value };
1198
+ if (originNode !== undefined && originNode.value !== null) spec.origin = originNode.value;
1199
+ if (contextNode !== undefined) {
1200
+ if (contextNode.kind !== 'raw') return { code: 'nonliteral-spec' };
1201
+ const context = contextNode.value;
1202
+ if (context?.zone !== undefined) return { code: 'named-zone' };
1203
+ if (context?.offset !== undefined) spec.offset = context.offset;
1204
+ }
1205
+ const ladder = fixedLadder(spec, compileBuckets);
1206
+ if ('code' in ladder) return ladder;
1207
+ const ref = pathRef(atNode, itSlot, shape);
1208
+ const instantBad = instantRefusal(ref);
1209
+ if (instantBad !== null) return { code: instantBad };
1210
+ return { ref, every: ladder.every, origin: ladder.origin };
1211
+ }
1212
+
1213
+ /**
1214
+ * The closed projection of a bucket grouping: one member per answered
1215
+ * value, each of them the group key, a `$count` of the whole binding,
1216
+ * or one of the four value aggregates over a schema-typed numeric path.
1217
+ * @param {any} ret - the `$return` node
1218
+ * @param {number} itSlot
1219
+ * @param {number} keySlot
1220
+ * @param {any} shape
1221
+ * @returns {{ as: string, aggregates: any[] } | { code: string }}
1222
+ */
1223
+ function bucketProjection(ret, itSlot, keySlot, shape) {
1224
+ if (ret?.kind !== 'object') return { code: 'nonnative-grouping' };
1225
+ let as = null;
1226
+ const aggregates = [];
1227
+ for (const entry of ret.entries) {
1228
+ const expr = entry.expr;
1229
+ if (expr.kind === 'var' && expr.external !== true && expr.slot === keySlot) {
1230
+ if (as !== null) return { code: 'nonnative-grouping' };
1231
+ as = entry.name;
1232
+ continue;
1233
+ }
1234
+ if (expr.kind !== 'op') return { code: 'nonnative-grouping' };
1235
+ if (expr.name === '$count') {
1236
+ // `$count` over the BINDING is the group's row count; over a path
1237
+ // it counts the rows that HAVE the member, which SQL's
1238
+ // `COUNT(column)` does not reproduce for a JSON `null`
1239
+ if (!isItVar(expr.args[0], itSlot)) return { code: 'nonnative-grouping' };
1240
+ aggregates.push({ fn: 'rows', ref: null, as: entry.name, empty: 'null' });
1241
+ continue;
1242
+ }
1243
+ const fn = { $sum: 'sum', $avg: 'avg', $min: 'min', $max: 'max' }[expr.name];
1244
+ if (fn === undefined) return { code: 'nonnative-grouping' };
1245
+ const ref = pathRef(expr.args[0], itSlot, shape);
1246
+ const valueBad = valueRefusal(ref);
1247
+ if (valueBad !== null) return { code: valueBad };
1248
+ // what an aggregate over NO numbers says, in the ENGINE's words:
1249
+ // `$sum` of an empty sequence is 0 and the other three are the
1250
+ // empty sequence, which an object constructor leaves the member out
1251
+ // for. SQL answers `NULL` for all four, so the mapping is the plan's
1252
+ aggregates.push({ fn, ref, as: entry.name, empty: fn === 'sum' ? 'zero' : 'omit' });
1253
+ }
1254
+ if (as === null || aggregates.length === 0) return { code: 'nonnative-grouping' };
1255
+ return { as, aggregates };
1256
+ }
1257
+
1258
+ /**
1259
+ * Which way the groups come out. The engine's own rule is order of
1260
+ * FIRST APPEARANCE (§6.5), which over a collection is the earliest row
1261
+ * identity in each group; an `$orderby` on the key alone replaces it.
1262
+ * @param {any} orderby
1263
+ * @param {number} keySlot
1264
+ * @returns {'asc' | 'desc' | 'first-seen' | null} `null` when the
1265
+ * ordering is one this plan cannot reproduce
1266
+ */
1267
+ function bucketOrder(orderby, keySlot) {
1268
+ if (orderby === null) return 'first-seen';
1269
+ if (orderby.specs.length !== 1) return null;
1270
+ const spec = orderby.specs[0];
1271
+ if (spec.collation !== null || spec.collationName !== null) return null;
1272
+ const key = spec.key;
1273
+ if (key.kind !== 'var' || key.external === true || key.slot !== keySlot) return null;
1274
+ return spec.desc === true ? 'desc' : 'asc';
1275
+ }
1276
+
1277
+ /**
1278
+ * The record a refused grouping leaves behind: the same question, named
1279
+ * and reasoned, over whatever the fetch still narrows.
1280
+ * @param {import('./algebra.js').Plan} plan
1281
+ * @param {any} shape
1282
+ * @param {string} code
1283
+ * @param {string} construct
1284
+ * @returns {any}
1285
+ */
1286
+ function refinedGrouping(plan, shape, code, construct) {
1287
+ const facts = filterFacts(plan.filter);
1288
+ let column = null;
1289
+ for (const [name] of facts.bounds) {
1290
+ if (instantIndexesOver(shape, name, 2).length > 0) column = name;
1291
+ }
1292
+ const index = seekingIndexFor(shape, column, facts, 2);
1293
+ const bound = column === null ? null : facts.bounds.get(column);
1294
+ return seriesRecord({
1295
+ mode: plan.filter === null ? 'engine' : 'hybrid',
1296
+ operation: 'bucket',
1297
+ index: index === null ? null : index.name,
1298
+ prefix: index === null ? [] : index.prefix,
1299
+ range: bound === undefined || bound === null ? null : { column, ...bound },
1300
+ refinement: 'resampleSeries',
1301
+ reasons: [seriesReason(code, construct)],
1302
+ });
1303
+ }
1304
+
1305
+ /**
1306
+ * Classify a planned selection as a temporal range or as-of lookup, or
1307
+ * answer `null` when the document asked no such question. The plan is
1308
+ * NOT changed: this names what the selection already is, and which
1309
+ * declared index it seeks through.
1310
+ * @param {import('./algebra.js').Plan} plan
1311
+ * @param {any} shape
1312
+ * @param {boolean} ordered - the ordering was pushed whole
1313
+ * @returns {any} the series record, or null
1314
+ */
1315
+ function classifySelection(plan, shape, ordered) {
1316
+ const facts = filterFacts(plan.filter);
1317
+ // the instant column is the one a DECLARED index ends with; without
1318
+ // such an index the collection has no instant and the question was
1319
+ // an ordinary one
1320
+ const candidates = [];
1321
+ for (const [column, bound] of facts.bounds) {
1322
+ if (instantIndexesOver(shape, column, 2).length === 0) continue;
1323
+ candidates.push([column, bound]);
1324
+ }
1325
+ if (candidates.length !== 1) return null;
1326
+ const [column, bound] = candidates[0];
1327
+ const index = seekingIndexFor(shape, column, facts, 2);
1328
+ const bounded = bound.from !== null || bound.to !== null;
1329
+ const twoSided = bound.from !== null && bound.to !== null;
1330
+ const orderedByInstant = ordered && plan.order !== null && plan.order.length === 1
1331
+ && plan.order[0].ref.column === column;
1332
+ const operation = twoSided ? 'range'
1333
+ : (orderedByInstant && plan.window !== null && plan.window.limit !== null) ? 'asof'
1334
+ : bounded ? 'range' : null;
1335
+ if (operation === null) return null;
1336
+ const reasons = index === null ? [seriesReason('missing-series-prefix', '$where')] : [];
1337
+ return seriesRecord({
1338
+ mode: index === null ? 'engine' : 'native',
1339
+ operation,
1340
+ index: index === null ? null : index.name,
1341
+ prefix: index === null ? [] : index.prefix,
1342
+ range: {
1343
+ from: bound.from, fromOp: bound.fromOp, to: bound.to, toOp: bound.toOp, column,
1344
+ },
1345
+ reasons,
1346
+ });
1347
+ }
1348
+
1349
+
1350
+ /**
1351
+ * The bucket a `$groupby` phrase spells, or the reason it is not one.
1352
+ * @param {any} node - the flwor node
1353
+ * @param {number} itSlot
1354
+ * @param {any} shape
1355
+ * @returns {{ bucket: any } | { code: string }}
1356
+ */
1357
+ function planBucketGrouping(node, itSlot, shape) {
1358
+ if (node.groupby.keys.length !== 1) return { code: 'nonnative-grouping' };
1359
+ const key = node.groupby.keys[0];
1360
+ const ladder = groupLadder(key.expr, itSlot, shape);
1361
+ if (ladder === null) return { code: 'nonnative-grouping' };
1362
+ if ('code' in ladder) return ladder;
1363
+ const projection = bucketProjection(node.ret, itSlot, key.slot, shape);
1364
+ if ('code' in projection) return projection;
1365
+ const order = bucketOrder(node.orderby, key.slot);
1366
+ if (order === null) return { code: 'nonnative-grouping' };
1367
+ return { bucket: {
1368
+ ref: ladder.ref,
1369
+ every: ladder.every,
1370
+ origin: ladder.origin,
1371
+ as: projection.as,
1372
+ order,
1373
+ aggregates: projection.aggregates,
1374
+ } };
1375
+ }
1376
+
1377
+ /**
1378
+ * The tolerance of an as-of spec in milliseconds, or `null` for one
1379
+ * that bounds nothing. A NEGATIVE tolerance is a broken document the
1380
+ * engine refuses, and narrowing by it would move the bounds INWARD —
1381
+ * so it bounds nothing here and the engine raises, which is the same
1382
+ * rule `safeEpoch` follows for an instant that names none.
1383
+ */
1384
+ function toleranceMs(tolerance) {
1385
+ if (tolerance === undefined) return null;
1386
+ if (typeof tolerance === 'number')
1387
+ return Number.isFinite(tolerance) && tolerance >= 0 ? tolerance : null;
1388
+ try {
1389
+ const span = compileBuckets({ every: tolerance }, {});
1390
+ return span.calendar || span.width < 0 ? null : span.width;
1391
+ }
1392
+ catch {
1393
+ return null;
1394
+ }
1395
+ }
1396
+
1397
+ /**
1398
+ * The epoch a spec member names, or `null` when it names none.
1399
+ *
1400
+ * A planner may never raise on the ENGINE's behalf: `analyzeQuery` does
1401
+ * not compile an operator, so a spec whose `start` is not an instant
1402
+ * reaches here before the engine has had its say. Refusing to narrow is
1403
+ * the right answer — the residual compiles the caller's own document
1404
+ * and raises the `JQ0003` it would have raised anyway.
1405
+ * @param {any} value
1406
+ * @returns {number | null}
1407
+ */
1408
+ function safeEpoch(value) {
1409
+ if (value === undefined) return null;
1410
+ try {
1411
+ const at = toEpoch(value);
1412
+ return Number.isFinite(at) ? at : null;
1413
+ }
1414
+ catch {
1415
+ return null;
1416
+ }
1417
+ }
1418
+
1419
+ /** An instant bound as a pushable conjunct over the instant column. */
1420
+ function instantBound(ref, op, value) {
1421
+ return { p: 'cmp', op, ref, operand: { lit: value } };
1422
+ }
1423
+
1424
+ /**
1425
+ * The bounds a frozen spec implies for the collection side, as pushable
1426
+ * conjuncts. Every one of them is an IMPLIED conjunct: it narrows the
1427
+ * fetch and decides nothing, because the residual re-runs the caller's
1428
+ * own document — the whole operator — over what comes back.
1429
+ * @returns {{ preds: any[], range: any }}
1430
+ */
1431
+ function impliedInstantBounds(ref, from, to) {
1432
+ const preds = [];
1433
+ if (from !== null) preds.push(instantBound(ref, 'ge', from));
1434
+ if (to !== null) preds.push(instantBound(ref, 'le', to));
1435
+ return { preds, range: { column: ref.column, from, fromOp: from === null ? null : 'ge',
1436
+ to, toOp: to === null ? null : 'le' } };
1437
+ }
1438
+
1439
+ /**
1440
+ * Plan a document that IS a series operator over the collection.
1441
+ *
1442
+ * The collection is one of the operator's operands, so the narrowing is
1443
+ * that operand's own `$where` plus what the frozen spec implies, and
1444
+ * the kernel — the engine running the caller's document over the
1445
+ * fetched candidates — decides. A `$resample` whose spec asks for
1446
+ * nothing a `GROUP BY` cannot do is the one exception: it is native,
1447
+ * and answers the bucket records itself.
1448
+ * @param {any} root
1449
+ * @param {any} shape
1450
+ * @returns {any} `null` when the operator is not over this collection
1451
+ */
1452
+ function planSeriesOperator(root, shape) {
1453
+ const name = root.name;
1454
+ /** @type {any} */
1455
+ let operandNode = null;
1456
+ /** @type {any} */
1457
+ let probesNode = null;
1458
+ /** @type {any} */
1459
+ let spec = null;
1460
+ if (name === '$resample' || name === '$rolling') {
1461
+ operandNode = root.args[0];
1462
+ spec = root.args[1]?.kind === 'raw' ? root.args[1].value : null;
1463
+ }
1464
+ else {
1465
+ spec = root.args.length === 3
1466
+ ? (root.args[2].kind === 'raw' ? root.args[2].value : null) : {};
1467
+ // narrowing is only sound on the RIGHT side: an as-of join answers
1468
+ // once per LEFT row, so every left row is needed whatever it matches
1469
+ if (collectionOperand(root.args[1]) !== null) {
1470
+ operandNode = root.args[1];
1471
+ probesNode = root.args[0];
1472
+ }
1473
+ else if (collectionOperand(root.args[0]) !== null) {
1474
+ operandNode = root.args[0];
1475
+ }
1476
+ }
1477
+ const operand = operandNode === null ? null : collectionOperand(operandNode);
1478
+ if (operand === null || spec === null || typeof spec !== 'object') return null;
1479
+
1480
+ const inner = operand.flwor === null
1481
+ ? { plan: selectPlan(shape.collection), reasons: [], prefilters: [] }
1482
+ : planFlwor(operand.flwor, shape, undefined, undefined);
1483
+ const plan = inner.plan;
1484
+ // the operand's own clauses stay the engine's: the residual runs the
1485
+ // WHOLE document, so a projection or an ordering inside it is applied
1486
+ // there and only its pushed conjuncts narrow
1487
+ plan.order = null;
1488
+ plan.window = null;
1489
+
1490
+ const reasons = [];
1491
+ const prefilters = [...(inner.prefilters ?? [])];
1492
+ const at = memberRef(shape,
1493
+ spec.at !== undefined ? (singularSelector(spec.at) ?? 'at')
1494
+ : (name === '$asof' && probesNode !== null && spec.rightAt !== undefined
1495
+ ? (singularSelector(spec.rightAt) ?? 'at') : 'at'));
1496
+
1497
+ if (name === '$resample') {
1498
+ const outcome = resampleBucket(spec, shape);
1499
+ if ('bucket' in outcome) {
1500
+ // native: the ladder, the aggregate and the count are the plan's
1501
+ const bucket = outcome.bucket;
1502
+ bucket.aggregates[0].empty = 'null';
1503
+ bucket.aggregates[1].empty = 'null';
1504
+ plan.bucket = bucket;
1505
+ const windowFrom = safeEpoch(spec.start);
1506
+ const windowTo = safeEpoch(spec.end);
1507
+ if (windowFrom !== null) plan.filter = conjoin(plan.filter,
1508
+ instantBound(bucket.ref, 'ge', windowFrom));
1509
+ if (windowTo !== null) plan.filter = conjoin(plan.filter,
1510
+ instantBound(bucket.ref, 'lt', windowTo));
1511
+ const facts = filterFacts(plan.filter);
1512
+ const index = seekingIndexFor(shape, bucket.ref.column, facts);
1513
+ const bound = facts.bounds.get(bucket.ref.column) ?? null;
1514
+ return {
1515
+ plan,
1516
+ native: inner.reasons.length === 0,
1517
+ reasons: inner.reasons,
1518
+ prefilters,
1519
+ series: seriesRecord({
1520
+ mode: 'native',
1521
+ operation: 'resample',
1522
+ index: index === null ? null : index.name,
1523
+ prefix: index === null ? [] : index.prefix,
1524
+ range: bound === null ? null : { column: bucket.ref.column, ...bound },
1525
+ ladder: { every: bucket.every, origin: bucket.origin, calendar: false },
1526
+ aggregates: bucket.aggregates.map((a) => a.as),
1527
+ reasons: index === null ? [seriesReason('missing-series-prefix', name)] : [],
1528
+ }),
1529
+ };
1530
+ }
1531
+ reasons.push(seriesReason(outcome.code, name));
1532
+ }
1533
+ else if (name === '$rolling') {
1534
+ reasons.push(seriesReason('rolling-refinement', name));
1535
+ }
1536
+ else {
1537
+ reasons.push(seriesReason('asof-refinement', name));
1538
+ }
1539
+
1540
+ // the refinement: narrow through the index by whatever the spec makes
1541
+ // provable, and let the engine's own kernel decide over what comes back
1542
+ let range = null;
1543
+ if (name === '$resample' && at.column !== null) {
1544
+ const from = safeEpoch(spec.start);
1545
+ const to = safeEpoch(spec.end);
1546
+ if (from !== null || to !== null) {
1547
+ if (from !== null) plan.filter = conjoin(plan.filter, instantBound(at, 'ge', from));
1548
+ if (to !== null) plan.filter = conjoin(plan.filter, instantBound(at, 'lt', to));
1549
+ range = { column: at.column, from, fromOp: from === null ? null : 'ge',
1550
+ to, toOp: to === null ? null : 'lt' };
1551
+ prefilters.push({ construct: name, via: 'columns', columns: [at.column], exact: false });
1552
+ }
1553
+ }
1554
+ else if (name === '$asof' && probesNode !== null) {
1555
+ const probes = literalInstants(probesNode, spec.leftAt, spec.by);
1556
+ if (probes !== null && at.column !== null) {
1557
+ const tolerance = toleranceMs(spec.tolerance);
1558
+ const direction = spec.direction ?? 'backward';
1559
+ let from = null;
1560
+ let to = null;
1561
+ if (direction === 'backward') {
1562
+ to = probes.max;
1563
+ if (tolerance !== null) from = probes.min - tolerance;
1564
+ }
1565
+ else if (direction === 'forward') {
1566
+ from = probes.min;
1567
+ if (tolerance !== null) to = probes.max + tolerance;
1568
+ }
1569
+ else if (tolerance !== null) {
1570
+ from = probes.min - tolerance;
1571
+ to = probes.max + tolerance;
1572
+ }
1573
+ const bounds = impliedInstantBounds(at, from, to);
1574
+ for (const pred of bounds.preds) plan.filter = conjoin(plan.filter, pred);
1575
+ if (bounds.preds.length > 0) {
1576
+ range = bounds.range;
1577
+ prefilters.push({ construct: name, via: 'columns', columns: [at.column], exact: false });
1578
+ }
1579
+ }
1580
+ // and the keys, whether or not the instant has a column of its own:
1581
+ // a right row whose group no left row names can match nothing, so a
1582
+ // membership test over the probes' own keys narrows and never drops
1583
+ 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) {
1586
+ 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 } })) });
1590
+ prefilters.push({ construct: name, via: 'columns',
1591
+ columns: [byRef.column], exact: false });
1592
+ }
1593
+ }
1594
+ }
1595
+
1596
+ const facts = filterFacts(plan.filter);
1597
+ const index = seekingIndexFor(shape, at.column, facts);
1598
+ // one code, once: an instant with no column of its own already said
1599
+ // this when the ladder refused, and saying it twice reads as two facts
1600
+ if (index === null && !reasons.some((r) => r.code === 'missing-series-prefix'))
1601
+ reasons.push(seriesReason('missing-series-prefix', name));
1602
+ const narrowed = plan.filter !== null;
1603
+ return {
1604
+ plan,
1605
+ native: false,
1606
+ reasons: [...reasons, ...inner.reasons],
1607
+ prefilters,
1608
+ series: seriesRecord({
1609
+ mode: narrowed ? 'hybrid' : 'engine',
1610
+ operation: { $resample: 'resample', $rolling: 'rolling', $asof: 'asof-join' }[name],
1611
+ index: narrowed && index !== null ? index.name : null,
1612
+ prefix: narrowed && index !== null ? index.prefix : [],
1613
+ range,
1614
+ refinement: { $resample: 'resampleSeries', $rolling: 'rollingSeries',
1615
+ $asof: 'asOfJoin' }[name],
1616
+ reasons,
1617
+ }),
1618
+ };
1619
+ }
1620
+
1013
1621
  /**
1014
1622
  * Plan a FLWOR node into a select plan, recording refusals. When a
1015
1623
  * conjunct refuses native translation, the injected `udf` hook may
@@ -1048,7 +1656,8 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1048
1656
  reasons.push(refusal('$for',
1049
1657
  'only a single plain binding over the whole collection is translated'));
1050
1658
  return { plan, reasons, whereFullyPushed: false, orderPushed: false,
1051
- projectionNative: false, itSlot: -1, itName: null, udfs: [], prefilters: [], knn: null };
1659
+ projectionNative: false, itSlot: -1, itName: null, udfs: [], prefilters: [],
1660
+ knn: null, bucket: null, bucketRefusal: null };
1052
1661
  }
1053
1662
  const itSlot = binding.slot;
1054
1663
  // the document's own name for the collection binding. The residual and
@@ -1060,7 +1669,22 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1060
1669
  if (node.fold !== null) reasons.push(refusal('$fold', KIND_REASONS.let));
1061
1670
  if (node.letBindings.length > 0) reasons.push(refusal('$let', KIND_REASONS.let));
1062
1671
  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));
1672
+ // A grouping is an unconditional residual EXCEPT in one closed shape:
1673
+ // a fixed-width `$time-bucket` key with the exact aggregates, which
1674
+ // is a `GROUP BY` over integer arithmetic. The bucket then owns the
1675
+ // ordering and the projection too, so it is decided before either.
1676
+ let bucket = null;
1677
+ let bucketRefusal = null;
1678
+ if (node.groupby !== null && node.fold === null && node.letBindings.length === 0
1679
+ && node.asChecks === null && node.count === null) {
1680
+ const grouped = planBucketGrouping(node, itSlot, shape);
1681
+ if ('bucket' in grouped) bucket = grouped.bucket;
1682
+ else {
1683
+ bucketRefusal = grouped.code;
1684
+ reasons.push(seriesReason(grouped.code, '$groupby'));
1685
+ }
1686
+ }
1687
+ else if (node.groupby !== null) reasons.push(refusal('$groupby', KIND_REASONS.let));
1064
1688
  if (node.count !== null) reasons.push(refusal('$count clause', KIND_REASONS.let));
1065
1689
  const structureClean = reasons.length === 0;
1066
1690
  // $let and $as run BEFORE $where in clause order: a row our pushed
@@ -1116,9 +1740,10 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1116
1740
  // caller's to name once the window is known)
1117
1741
  let orderPushed = false;
1118
1742
  let knn = null;
1119
- const ranked = node.orderby === null ? null
1743
+ const ranked = bucket !== null || node.orderby === null ? null
1120
1744
  : planKnnOrder(node.orderby, itSlot, shape, whereFullyPushed && structureClean);
1121
- if (ranked !== null) {
1745
+ if (bucket !== null) orderPushed = true; // the groups' order is the bucket's
1746
+ else if (ranked !== null) {
1122
1747
  if ('rank' in ranked) knn = ranked.rank;
1123
1748
  else reasons.push(ranked.refusal);
1124
1749
  }
@@ -1152,7 +1777,8 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1152
1777
  // RETURN: the bare binding is the native whole-document projection
1153
1778
  let projectionNative = false;
1154
1779
  assertDecidedKind(node.ret);
1155
- if (isItVar(node.ret, itSlot)) projectionNative = true;
1780
+ if (bucket !== null) projectionNative = true; // the bucket IS the projection
1781
+ else if (isItVar(node.ret, itSlot)) projectionNative = true;
1156
1782
  else {
1157
1783
  reasons.push(refusal('$return',
1158
1784
  'projections other than the bare binding run per row (the row residual)'));
@@ -1169,6 +1795,8 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
1169
1795
  udfs,
1170
1796
  prefilters,
1171
1797
  knn,
1798
+ bucket,
1799
+ bucketRefusal,
1172
1800
  };
1173
1801
  }
1174
1802
 
@@ -1212,7 +1840,10 @@ function composeWindows(windows) {
1212
1840
  * udfs: string[],
1213
1841
  * prefilters: { construct: string, via: 'columns' | 'rtree',
1214
1842
  * columns: string[], exact: boolean }[],
1843
+ * series: any,
1215
1844
  * }}
1845
+ * `series` is the temporal record (`series.js`) when the document
1846
+ * asked a §8.16 question, and `null` when it did not.
1216
1847
  */
1217
1848
  function planCollectionCore(document, shape, options = undefined) {
1218
1849
  const analysis = analyzeQuery(document, analyzeOptionsFor(shape?.operators));
@@ -1230,7 +1861,7 @@ function planCollectionCore(document, shape, options = undefined) {
1230
1861
  return {
1231
1862
  analysis, plan: null, mode: 'set',
1232
1863
  reasons: [refusal('$subsequence', 'window bounds must be literal numbers to push')],
1233
- rowReturn: null, udfs: [], prefilters: [],
1864
+ rowReturn: null, udfs: [], prefilters: [], series: null,
1234
1865
  };
1235
1866
  }
1236
1867
  windows.push({ offset: start.value, limit: length === undefined ? null : length.value });
@@ -1246,7 +1877,7 @@ function planCollectionCore(document, shape, options = undefined) {
1246
1877
  return {
1247
1878
  analysis, plan: null, mode: 'set',
1248
1879
  reasons: [refusal(root.name, 'a windowed aggregate is not translated')],
1249
- rowReturn: null, udfs: [], prefilters: [],
1880
+ rowReturn: null, udfs: [], prefilters: [], series: null,
1250
1881
  };
1251
1882
  }
1252
1883
  aggregate = { name: root.name, fn: AGGREGATES.get(root.name) };
@@ -1255,12 +1886,37 @@ function planCollectionCore(document, shape, options = undefined) {
1255
1886
  assertDecidedKind(root);
1256
1887
  }
1257
1888
 
1889
+ // a document that IS a series operator over the collection: the
1890
+ // operand's own conjuncts (and what the frozen spec implies) narrow
1891
+ // through the index, and the kernel decides over what comes back
1892
+ if (aggregate === null && root.kind === 'op' && SERIES_ROOT_OPS.includes(root.name)) {
1893
+ const temporal = planSeriesOperator(root, shape);
1894
+ if (temporal !== null) {
1895
+ // a peeled `$subsequence` composes as it does everywhere — over a
1896
+ // NATIVE bucket it is a LIMIT on the ascending groups, which is
1897
+ // the same items the kernel's own window would have kept; over a
1898
+ // refinement the residual applies it, so the plan keeps none
1899
+ const window = windows.length === 0 ? null : composeWindows(windows);
1900
+ if (temporal.native && window !== null) temporal.plan.window = window;
1901
+ return {
1902
+ analysis,
1903
+ plan: temporal.plan,
1904
+ mode: temporal.native ? 'native' : 'set',
1905
+ reasons: temporal.native ? [] : temporal.reasons,
1906
+ rowReturn: null,
1907
+ udfs: [],
1908
+ prefilters: temporal.prefilters,
1909
+ series: temporal.series,
1910
+ };
1911
+ }
1912
+ }
1913
+
1258
1914
  if (root.kind !== 'flwor') {
1259
1915
  return {
1260
1916
  analysis, plan: null, mode: 'set',
1261
1917
  reasons: [refusal(root.kind, KIND_REASONS[root.kind]
1262
1918
  ?? 'only a FLWOR over the collection is translated')],
1263
- rowReturn: null, udfs: [], prefilters: [],
1919
+ rowReturn: null, udfs: [], prefilters: [], series: null,
1264
1920
  };
1265
1921
  }
1266
1922
 
@@ -1279,7 +1935,7 @@ function planCollectionCore(document, shape, options = undefined) {
1279
1935
  margin: KNN_MARGIN };
1280
1936
  return { analysis, plan, mode: 'knn',
1281
1937
  reasons: [refusal('$orderby', KNN_REASONS.rank), ...flwor.reasons],
1282
- rowReturn: null, udfs: flwor.udfs, prefilters: flwor.prefilters };
1938
+ rowReturn: null, udfs: flwor.udfs, prefilters: flwor.prefilters, series: null };
1283
1939
  }
1284
1940
  flwor.reasons.unshift(refusal('$subsequence', KNN_REASONS.window));
1285
1941
  }
@@ -1289,7 +1945,7 @@ function planCollectionCore(document, shape, options = undefined) {
1289
1945
  // full sequence, not a narrowed candidate set)
1290
1946
  if (!fullyPushed) {
1291
1947
  return { analysis, plan: null, mode: 'set', reasons: flwor.reasons,
1292
- rowReturn: null, udfs: [], prefilters: flwor.prefilters };
1948
+ rowReturn: null, udfs: [], prefilters: flwor.prefilters, series: null };
1293
1949
  }
1294
1950
  if (aggregate.fn === 'count') {
1295
1951
  if (!flwor.projectionNative) {
@@ -1297,12 +1953,13 @@ function planCollectionCore(document, shape, options = undefined) {
1297
1953
  analysis, plan: null, mode: 'set',
1298
1954
  reasons: [refusal('$count',
1299
1955
  'count translates only over the bare binding (a projected return can change the item count)')],
1300
- rowReturn: null, udfs: [], prefilters: [],
1956
+ rowReturn: null, udfs: [], prefilters: [], series: null,
1301
1957
  };
1302
1958
  }
1303
1959
  plan.aggregate = { fn: 'count', ref: null };
1304
1960
  return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
1305
- udfs: flwor.udfs, prefilters: flwor.prefilters };
1961
+ udfs: flwor.udfs, prefilters: flwor.prefilters,
1962
+ series: classifySelection(plan, shape, true) };
1306
1963
  }
1307
1964
  const ref = pathRef(root.ret, flwor.itSlot, shape);
1308
1965
  const numeric = aggregate.fn === 'sum' || aggregate.fn === 'avg';
@@ -1313,20 +1970,46 @@ function planCollectionCore(document, shape, options = undefined) {
1313
1970
  analysis, plan: null, mode: 'set',
1314
1971
  reasons: [refusal(aggregate.name,
1315
1972
  'aggregates translate only over a singular schema-typed path (the engine ERRORS on non-conforming operands)')],
1316
- rowReturn: null, udfs: [], prefilters: [],
1973
+ rowReturn: null, udfs: [], prefilters: [], series: null,
1317
1974
  };
1318
1975
  }
1319
1976
  plan.aggregate = { fn: /** @type {any} */ (aggregate.fn), ref };
1320
1977
  return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
1321
- udfs: flwor.udfs, prefilters: flwor.prefilters };
1978
+ udfs: flwor.udfs, prefilters: flwor.prefilters,
1979
+ series: classifySelection(plan, shape, true) };
1322
1980
  }
1323
1981
 
1324
1982
  // windows push only onto a fully pushed selection
1325
1983
  if (windows.length > 0 && fullyPushed) plan.window = composeWindows(windows);
1326
1984
 
1985
+ // the temporal bucket: only over a WHOLE pushed selection, because a
1986
+ // conjunct the residual would still apply would arrive after the rows
1987
+ // were already summed
1988
+ if (flwor.bucket !== null && fullyPushed && (windows.length === 0 || plan.window !== null)) {
1989
+ plan.bucket = flwor.bucket;
1990
+ const facts = filterFacts(plan.filter);
1991
+ const index = seekingIndexFor(shape, plan.bucket.ref.column, facts);
1992
+ const bound = facts.bounds.get(plan.bucket.ref.column) ?? null;
1993
+ return {
1994
+ analysis, plan, mode: 'native', reasons: [], rowReturn: null,
1995
+ udfs: flwor.udfs, prefilters: flwor.prefilters,
1996
+ series: seriesRecord({
1997
+ mode: 'native',
1998
+ operation: 'bucket',
1999
+ index: index === null ? null : index.name,
2000
+ prefix: index === null ? [] : index.prefix,
2001
+ range: bound === null ? null : { column: plan.bucket.ref.column, ...bound },
2002
+ ladder: { every: plan.bucket.every, origin: plan.bucket.origin, calendar: false },
2003
+ aggregates: plan.bucket.aggregates.map((a) => a.as),
2004
+ reasons: index === null ? [seriesReason('missing-series-prefix', '$groupby')] : [],
2005
+ }),
2006
+ };
2007
+ }
2008
+
1327
2009
  if (fullyPushed && flwor.projectionNative && (windows.length === 0 || plan.window !== null)) {
1328
2010
  return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
1329
- udfs: flwor.udfs, prefilters: flwor.prefilters };
2011
+ udfs: flwor.udfs, prefilters: flwor.prefilters,
2012
+ series: classifySelection(plan, shape, flwor.orderPushed) };
1330
2013
  }
1331
2014
 
1332
2015
  // the row residual: everything but the projection pushed
@@ -1348,14 +2031,20 @@ function planCollectionCore(document, shape, options = undefined) {
1348
2031
  },
1349
2032
  udfs: flwor.udfs,
1350
2033
  prefilters: flwor.prefilters,
2034
+ series: flwor.bucketRefusal == null
2035
+ ? classifySelection(plan, shape, flwor.orderPushed)
2036
+ : refinedGrouping(plan, shape, flwor.bucketRefusal, '$groupby'),
1351
2037
  };
1352
2038
  }
1353
2039
 
1354
2040
  // the set residual: pushed conjuncts narrow, the engine answers
2041
+ const narrowing = flwor.bucketRefusal == null
2042
+ ? classifySelection(plan, shape, false)
2043
+ : refinedGrouping(plan, shape, flwor.bucketRefusal, '$groupby');
1355
2044
  plan.order = null;
1356
2045
  plan.window = null;
1357
2046
  return { analysis, plan, mode: 'set', reasons: flwor.reasons, rowReturn: null,
1358
- udfs: flwor.udfs, prefilters: flwor.prefilters };
2047
+ udfs: flwor.udfs, prefilters: flwor.prefilters, series: narrowing };
1359
2048
  }
1360
2049
 
1361
2050
  /**
@@ -1376,6 +2065,7 @@ function planCollectionCore(document, shape, options = undefined) {
1376
2065
  * udfs: string[],
1377
2066
  * prefilters: { construct: string, via: 'columns' | 'rtree',
1378
2067
  * columns: string[], exact: boolean }[],
2068
+ * series: any,
1379
2069
  * }}
1380
2070
  */
1381
2071
  export function planQuery(document, shape, options = undefined) {