@jarenjs/db 0.34.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 (83) hide show
  1. package/ARCHITECTURE.md +397 -0
  2. package/README.md +218 -0
  3. package/dist/types/algebra.d.ts +133 -0
  4. package/dist/types/app.d.ts +49 -0
  5. package/dist/types/capture.d.ts +85 -0
  6. package/dist/types/cli.d.ts +2 -0
  7. package/dist/types/dag-job.d.ts +40 -0
  8. package/dist/types/ddl.d.ts +170 -0
  9. package/dist/types/dialect.d.ts +130 -0
  10. package/dist/types/dialects/sqlite.d.ts +9 -0
  11. package/dist/types/driver.d.ts +128 -0
  12. package/dist/types/drivers/bun.d.ts +47 -0
  13. package/dist/types/drivers/node.d.ts +37 -0
  14. package/dist/types/drivers/wasm.d.ts +65 -0
  15. package/dist/types/emit-model.d.ts +44 -0
  16. package/dist/types/emit.d.ts +72 -0
  17. package/dist/types/entity.d.ts +23 -0
  18. package/dist/types/errors.d.ts +165 -0
  19. package/dist/types/graph.d.ts +28 -0
  20. package/dist/types/index.d.ts +35 -0
  21. package/dist/types/jobs.d.ts +134 -0
  22. package/dist/types/live.d.ts +62 -0
  23. package/dist/types/migrate.d.ts +163 -0
  24. package/dist/types/model.d.ts +36 -0
  25. package/dist/types/patch-sql.d.ts +37 -0
  26. package/dist/types/plan.d.ts +119 -0
  27. package/dist/types/profile.d.ts +80 -0
  28. package/dist/types/query.d.ts +100 -0
  29. package/dist/types/residual.d.ts +50 -0
  30. package/dist/types/store.d.ts +53 -0
  31. package/dist/types/tracker.d.ts +43 -0
  32. package/dist/types/typed.d.ts +15 -0
  33. package/dist/types/types.d.ts +26 -0
  34. package/dist/types/udf.d.ts +70 -0
  35. package/dist/types/window.d.ts +52 -0
  36. package/docs/JOBS-FORMAT.md +218 -0
  37. package/docs/LIVE-FORMAT.md +348 -0
  38. package/docs/MIGRATION-FORMAT.md +302 -0
  39. package/docs/MODEL-FORMAT.md +928 -0
  40. package/package.json +81 -0
  41. package/schemas/jaren-migration.draft-07.schema.json +144 -0
  42. package/schemas/jaren-migration.schema.json +144 -0
  43. package/schemas/jaren-model.draft-07.schema.json +149 -0
  44. package/schemas/jaren-model.schema.json +149 -0
  45. package/src/algebra.js +105 -0
  46. package/src/app.js +108 -0
  47. package/src/capture.js +584 -0
  48. package/src/cli.js +264 -0
  49. package/src/dag-job.js +86 -0
  50. package/src/ddl.js +588 -0
  51. package/src/dialect.js +297 -0
  52. package/src/dialects/sqlite.js +175 -0
  53. package/src/driver.js +419 -0
  54. package/src/drivers/bun.js +101 -0
  55. package/src/drivers/node.js +93 -0
  56. package/src/drivers/wasm.js +178 -0
  57. package/src/emit-model.js +208 -0
  58. package/src/emit.js +393 -0
  59. package/src/entity.js +367 -0
  60. package/src/errors.js +173 -0
  61. package/src/graph.js +101 -0
  62. package/src/index.js +64 -0
  63. package/src/jobs.js +507 -0
  64. package/src/live.js +899 -0
  65. package/src/migrate.js +1411 -0
  66. package/src/model.js +476 -0
  67. package/src/patch-sql.js +150 -0
  68. package/src/plan.js +1038 -0
  69. package/src/profile.js +131 -0
  70. package/src/query.js +1010 -0
  71. package/src/residual.js +91 -0
  72. package/src/store.js +1422 -0
  73. package/src/tracker.js +776 -0
  74. package/src/typed.js +19 -0
  75. package/src/types.js +36 -0
  76. package/src/udf.js +132 -0
  77. package/src/window.js +125 -0
  78. package/types/app.d.ts +36 -0
  79. package/types/bun.d.ts +9 -0
  80. package/types/index.d.ts +592 -0
  81. package/types/node.d.ts +15 -0
  82. package/types/typed.d.ts +108 -0
  83. package/types/wasm.d.ts +5 -0
package/src/plan.js ADDED
@@ -0,0 +1,1038 @@
1
+ //@ts-check
2
+ /**
3
+ * @file AST → Plan. The planner walks the engine's PUBLISHED normalized
4
+ * AST (never the raw document), dispatches EXHAUSTIVELY on node kind —
5
+ * an unrecognised kind is an internal error naming the kind and the
6
+ * `AST_VERSION`, never a silent residual — and promotes constructs to
7
+ * native form strictly residual-by-default: everything starts as a
8
+ * residual and earns native status only where the equivalence proof
9
+ * exists (the truth table in ARCHITECTURE.md, pinned by the
10
+ * differential tests).
11
+ *
12
+ * The outcome of planning one document:
13
+ *
14
+ * { plan, mode: 'native' | 'row' | 'set', reasons, rowReturn }
15
+ *
16
+ * - `native` — everything translated; the plan alone answers.
17
+ * - `row` — predicates, ordering and window pushed; only the
18
+ * projection runs in the engine, per fetched row (streams).
19
+ * - `set` — the pushed conjuncts narrow candidates; the WHOLE
20
+ * compiled document runs over the materialized candidates.
21
+ *
22
+ * `reasons` names every construct that forced work off the database,
23
+ * with reason text drawn from the deliberate-residual table.
24
+ */
25
+
26
+ import { analyzeQuery, AST_VERSION, NODE_KINDS } from '@jarenjs/json/query';
27
+
28
+ import {
29
+ getEpochOfDateTimeRFC3339, getEpochOfDateOnlyRFC3339,
30
+ } from '@jarenjs/core/dates/rfc3339';
31
+
32
+ import { selectPlan, conjoin, PLAN_VERSION } from './algebra.js';
33
+ import { typeOfPath, isNumericType } from './types.js';
34
+
35
+ /** Comparison operator names → plan ops. */
36
+ const COMPARISONS = new Map([
37
+ ['$eq', 'eq'], ['$ne', 'ne'],
38
+ ['$lt', 'lt'], ['$le', 'le'], ['$gt', 'gt'], ['$ge', 'ge'],
39
+ ]);
40
+ const ORDERING_OPS = new Set(['lt', 'le', 'gt', 'ge']);
41
+ const STRING_OPS = new Map([
42
+ ['$starts-with', 'starts'], ['$ends-with', 'ends'], ['$contains', 'contains'],
43
+ ]);
44
+ const AGGREGATES = new Map([
45
+ ['$count', 'count'], ['$sum', 'sum'], ['$avg', 'avg'],
46
+ ['$min', 'min'], ['$max', 'max'],
47
+ ]);
48
+
49
+ /**
50
+ * The exhaustiveness backstop: every kind the AST can produce must be
51
+ * DECIDED here — handled by the planner or listed as a deliberate
52
+ * residual. A kind outside this union is a language change this
53
+ * planner has not seen, and it throws rather than degrades.
54
+ */
55
+ const DECIDED_KINDS = new Set([
56
+ 'flwor', 'op', 'path', 'var', 'literal',
57
+ // deliberate residuals, one reason each
58
+ 'object', 'map', 'array', 'raw', 'call', 'let', 'quant',
59
+ ]);
60
+
61
+ const KIND_REASONS = {
62
+ object: 'an object constructor runs in the engine (projection territory)',
63
+ map: 'a computed-member constructor runs in the engine',
64
+ array: 'an array constructor runs in the engine (projection territory)',
65
+ raw: 'a raw value passes through the engine untouched',
66
+ call: 'a host function call cannot run in the database',
67
+ let: 'no equivalence proof exists yet; residual by default',
68
+ quant: 'a quantifier over a nested sequence runs in the engine',
69
+ };
70
+
71
+ /**
72
+ * Assert a node kind is one this planner has decided. Called on every
73
+ * dispatch; the throw names the kind and the AST version so a language
74
+ * change breaks the build instead of becoming an accidental residual.
75
+ * Exported so the throw itself is pinned by a test.
76
+ * @param {any} node
77
+ */
78
+ export function assertDecidedKind(node) {
79
+ const kind = node?.kind;
80
+ if (!DECIDED_KINDS.has(kind)) {
81
+ throw new Error(
82
+ `pushdown planner: unrecognised AST node kind '${String(kind)}' `
83
+ + `(AST_VERSION ${AST_VERSION}) — the planner must be taught this construct`);
84
+ }
85
+ }
86
+
87
+ // the exhaustiveness pact: if the engine adds a kind, this module
88
+ // fails to load until the planner decides it
89
+ for (const kind of NODE_KINDS) {
90
+ if (!DECIDED_KINDS.has(kind)) {
91
+ throw new Error(
92
+ `pushdown planner: NODE_KINDS declares '${kind}' (AST_VERSION ${AST_VERSION}) `
93
+ + 'but the planner has not decided it');
94
+ }
95
+ }
96
+
97
+ /**
98
+ * One named refusal.
99
+ * @param {string} construct
100
+ * @param {string} reason
101
+ * @returns {{ construct: string, reason: string }}
102
+ */
103
+ function refusal(construct, reason) {
104
+ return { construct, reason };
105
+ }
106
+
107
+ // ————— Registered operators (Ring 2) —————
108
+ //
109
+ // A store may open with a registry (createJsltRegistry()) whose
110
+ // operators become engine vocabulary. Ring 2 treats every registered
111
+ // operator as CORRECT but UN-pushable: the planner must KNOW its name
112
+ // (so the AST analysis does not fail `JQ0002`) yet still route it to the
113
+ // residual, where the compilation carries the same `{ functions,
114
+ // extensions }`. Ring 3 will promote the pushable subset to SQL; here
115
+ // everything registered runs in JavaScript over the fetched rows.
116
+
117
+ /**
118
+ * The analyze options carrying the store's registered operators. A
119
+ * registered operator is engine vocabulary, so the AST analysis must be
120
+ * told its `{ functions, extensions }` or it rejects the document as an
121
+ * unknown operator. `null`/absent operators → `undefined`, so a store
122
+ * without a registry analyses byte-identically to before.
123
+ * @param {{ functions?: any, extensions?: any } | null | undefined} operators
124
+ * @returns {any}
125
+ */
126
+ function analyzeOptionsFor(operators) {
127
+ if (operators == null) return undefined;
128
+ /** @type {any} */
129
+ const options = {};
130
+ if (operators.functions !== undefined) options.functions = operators.functions;
131
+ if (operators.extensions !== undefined) options.extensions = operators.extensions;
132
+ return options;
133
+ }
134
+
135
+ /**
136
+ * The set of registered first-class operator names (the `op`/`agg`
137
+ * entries that appear as document keys), or `null` when none.
138
+ * @param {{ extensions?: any } | null | undefined} operators
139
+ * @returns {Set<string> | null}
140
+ */
141
+ function registeredNamesOf(operators) {
142
+ if (operators == null) return null;
143
+ const names = new Set(Object.keys(operators.extensions ?? {}));
144
+ return names.size === 0 ? null : names;
145
+ }
146
+
147
+ /**
148
+ * Which registered operator names a raw document mentions (a `$`-key is
149
+ * an operator call). Robust across where/return/root placement, since it
150
+ * walks the document rather than the AST.
151
+ * @param {any} document
152
+ * @param {Set<string>} registered
153
+ * @returns {string[]}
154
+ */
155
+ function registeredOpsUsed(document, registered) {
156
+ const found = new Set();
157
+ const walk = (node) => {
158
+ if (Array.isArray(node)) { for (const item of node) walk(item); return; }
159
+ if (node !== null && typeof node === 'object') {
160
+ for (const key of Object.keys(node)) {
161
+ if (registered.has(key)) found.add(key);
162
+ walk(node[key]);
163
+ }
164
+ }
165
+ };
166
+ walk(document);
167
+ return [...found];
168
+ }
169
+
170
+ /**
171
+ * Prepend an honest, named residual reason when a non-native plan used a
172
+ * registered operator: `explain()` then says plainly that the operator
173
+ * forced the residual, never silently. Native plans and no-registry
174
+ * stores pass through untouched.
175
+ * @param {any} planned - a planner result carrying `mode` and `reasons`
176
+ * @param {any} document
177
+ * @param {{ extensions?: any } | null | undefined} operators
178
+ * @returns {any}
179
+ */
180
+ function prependRegisteredReason(planned, document, operators) {
181
+ if (planned.mode === 'native') return planned;
182
+ const registered = registeredNamesOf(operators);
183
+ if (registered === null) return planned;
184
+ // only the part that actually runs in the residual can name an operator
185
+ // as "residual": in `row` mode the where/order are pushed (a scalar
186
+ // operator there may even be a Ring 3 UDF) and just the projection runs
187
+ // per row; in `set` mode the whole document re-runs over the candidates
188
+ const residualPart = planned.mode === 'row' ? planned.rowReturn : document;
189
+ const used = registeredOpsUsed(residualPart, registered);
190
+ if (used.length === 0) return planned;
191
+ const many = used.length > 1;
192
+ return {
193
+ ...planned,
194
+ reasons: [
195
+ {
196
+ construct: used.join(', '),
197
+ reason: `registered operator${many ? 's' : ''} `
198
+ + `${used.map((n) => `'${n}'`).join(', ')} run${many ? '' : 's'} in the residual `
199
+ + '(Ring 2 — correct, not pushed to SQL)',
200
+ },
201
+ ...planned.reasons,
202
+ ],
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Is this node the bare binding variable (the whole item)?
208
+ * @param {any} node
209
+ * @param {number} itSlot
210
+ */
211
+ function isItVar(node, itSlot) {
212
+ return node.kind === 'var' && node.external !== true && node.slot === itSlot;
213
+ }
214
+
215
+ /**
216
+ * A singular member path rooted on the binding → a PlanRef, or null.
217
+ * @param {any} node
218
+ * @param {number} itSlot
219
+ * @param {any} shape - { schema, columnByCanonical }
220
+ * @returns {import('./algebra.js').PlanRef | null}
221
+ */
222
+ function pathRef(node, itSlot, shape) {
223
+ if (node.kind !== 'path' || node.external === true) return null;
224
+ if (node.rootSlot !== itSlot || node.singular !== true) return null;
225
+ /** @type {({ name: string } | { index: number })[]} */
226
+ const segments = [];
227
+ for (const segment of node.segments) {
228
+ if (segment.descendant === true || segment.selectors.length !== 1) return null;
229
+ const selector = segment.selectors[0];
230
+ if (selector.kind === 'name') segments.push({ name: selector.name });
231
+ else if (selector.kind === 'index') segments.push({ index: selector.index });
232
+ else return null;
233
+ }
234
+ if (segments.length === 0) return null;
235
+ const canonical = segments
236
+ .map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
237
+ return {
238
+ segments,
239
+ type: typeOfPath(shape.schema, segments),
240
+ column: shape.columnByCanonical.get(canonical) ?? null,
241
+ };
242
+ }
243
+
244
+ /**
245
+ * A literal or external operand, or null.
246
+ * @param {any} node
247
+ * @returns {import('./algebra.js').PlanOperand | null}
248
+ */
249
+ function operandOf(node) {
250
+ if (node.kind === 'literal') return { lit: node.value };
251
+ if (node.kind === 'var' && node.external === true) return { ext: node.name };
252
+ return null;
253
+ }
254
+
255
+ /** @param {any} value */
256
+ function isScalarLiteral(value) {
257
+ return value === null || typeof value === 'string'
258
+ || typeof value === 'number' || typeof value === 'boolean';
259
+ }
260
+
261
+ /**
262
+ * Translate one predicate node, or explain why not.
263
+ * @param {any} node
264
+ * @param {number} itSlot
265
+ * @param {any} shape
266
+ * @returns {{ pred: import('./algebra.js').PlanPredicate } |
267
+ * { refusal: { construct: string, reason: string } }}
268
+ */
269
+ function planPredicate(node, itSlot, shape) {
270
+ assertDecidedKind(node);
271
+ if (node.kind !== 'op') {
272
+ return { refusal: refusal(node.kind, KIND_REASONS[node.kind]
273
+ ?? 'not a predicate the planner translates') };
274
+ }
275
+
276
+ if (node.name === '$and' || node.name === '$or') {
277
+ const items = [];
278
+ for (const arg of node.args) {
279
+ const inner = planPredicate(arg, itSlot, shape);
280
+ if ('refusal' in inner) return inner; // partial $or/$and is not splittable here
281
+ items.push(inner.pred);
282
+ }
283
+ return { pred: { p: node.name === '$and' ? 'and' : 'or', items } };
284
+ }
285
+ if (node.name === '$not') {
286
+ const inner = planPredicate(node.args[0], itSlot, shape);
287
+ if ('refusal' in inner) return inner;
288
+ return { pred: { p: 'not', item: inner.pred } };
289
+ }
290
+
291
+ if (node.name === '$exists' || node.name === '$empty') {
292
+ const ref = pathRef(node.args[0], itSlot, shape);
293
+ if (ref === null) {
294
+ return { refusal: refusal(node.name,
295
+ 'existence tests translate only over a singular member path on the binding') };
296
+ }
297
+ return { pred: { p: 'typeIs', ref, types: [], positive: node.name === '$exists' } };
298
+ }
299
+
300
+ const comparison = COMPARISONS.get(node.name);
301
+ if (comparison !== undefined) {
302
+ let [left, right] = node.args;
303
+ let op = comparison;
304
+ // literal/external on the left: flip the operator around the path
305
+ if (pathRef(left, itSlot, shape) === null && operandOf(left) !== null) {
306
+ [left, right] = [right, left];
307
+ op = /** @type {any} */ ({ lt: 'gt', le: 'ge', gt: 'lt', ge: 'le' })[op] ?? op;
308
+ }
309
+ const ref = pathRef(left, itSlot, shape);
310
+ const operand = operandOf(right);
311
+ if (ref === null || operand === null) {
312
+ if (pathRef(left, itSlot, shape) !== null && pathRef(right, itSlot, shape) !== null)
313
+ return { refusal: refusal(node.name, 'comparisons where both sides are paths are join territory') };
314
+ return { refusal: refusal(node.name,
315
+ 'comparisons translate only between a singular member path and a literal or external') };
316
+ }
317
+ if ('lit' in operand) {
318
+ if (!isScalarLiteral(operand.lit)) {
319
+ return { refusal: refusal(node.name,
320
+ 'array and object literals have no guarded native comparison form') };
321
+ }
322
+ const lit = operand.lit;
323
+ if (typeof lit === 'boolean' || lit === null) {
324
+ if (ORDERING_OPS.has(op)) return { pred: { p: 'const', value: false } };
325
+ const typeName = lit === null ? 'null' : lit ? 'true' : 'false';
326
+ return { pred: { p: 'typeIs', ref, types: [typeName], positive: op === 'eq' } };
327
+ }
328
+ }
329
+ return { pred: { p: 'cmp', op: /** @type {any} */ (op), ref, operand } };
330
+ }
331
+
332
+ const stringOp = STRING_OPS.get(node.name);
333
+ if (stringOp !== undefined) {
334
+ const ref = pathRef(node.args[0], itSlot, shape);
335
+ const operand = operandOf(node.args[1]);
336
+ if (ref === null || ref.type !== 'string') {
337
+ return { refusal: refusal(node.name,
338
+ 'string operators translate only over schema-typed string paths (the engine ERRORS on non-string subjects)') };
339
+ }
340
+ if (operand === null || !('lit' in operand) || typeof operand.lit !== 'string') {
341
+ return { refusal: refusal(node.name,
342
+ "string operators translate only with literal string patterns (an external pattern's type is unknowable at plan time)") };
343
+ }
344
+ if (operand.lit === '') {
345
+ return { refusal: refusal(node.name,
346
+ "the empty pattern's vacuous-truth corner (true even on a missing member) is not translated") };
347
+ }
348
+ return { pred: { p: 'strop', kind: /** @type {any} */ (stringOp), ref, operand } };
349
+ }
350
+
351
+ return { refusal: refusal(node.name,
352
+ 'no native spelling of this operator is proven equivalent') };
353
+ }
354
+
355
+ /**
356
+ * Plan a FLWOR node into a select plan, recording refusals. When a
357
+ * conjunct refuses native translation, the injected `udf` hook may
358
+ * promote it to a deterministic-function predicate instead (the D9
359
+ * hatch — the hook is supplied by the query layer, capability-gated,
360
+ * and absent means no hatch).
361
+ * @param {any} node
362
+ * @param {any} shape - { collection, schema, columnByCanonical }
363
+ * @param {any} rawFlwor - The raw FLWOR document (conjunct fragments
364
+ * for the hook — the AST has no unparser)
365
+ * @param {((fragment: any) => { name: string, key: string } | null) | undefined} udfHook
366
+ * @returns {{ plan: import('./algebra.js').Plan,
367
+ * reasons: { construct: string, reason: string }[],
368
+ * whereFullyPushed: boolean, orderPushed: boolean,
369
+ * projectionNative: boolean, itSlot: number, udfs: string[] }}
370
+ */
371
+ function planFlwor(node, shape, rawFlwor, udfHook) {
372
+ const reasons = [];
373
+ const plan = selectPlan(shape.collection);
374
+
375
+ // the one recognised source shape: a single plain binding over $[*]
376
+ const binding = node.forBindings[0];
377
+ const source = binding?.expr;
378
+ const sourceIsCollection = node.forBindings.length === 1
379
+ && source?.kind === 'path' && source.name === '$' && source.external !== true
380
+ && source.segments.length === 1 && source.segments[0].descendant !== true
381
+ && source.segments[0].selectors.length === 1
382
+ && source.segments[0].selectors[0].kind === 'wildcard'
383
+ && binding.window === null && binding.atSlot === -1
384
+ && binding.allowingEmpty === false;
385
+ if (!sourceIsCollection) {
386
+ reasons.push(refusal('$for',
387
+ 'only a single plain binding over the whole collection is translated'));
388
+ return { plan, reasons, whereFullyPushed: false, orderPushed: false,
389
+ projectionNative: false, itSlot: -1, udfs: [] };
390
+ }
391
+ const itSlot = binding.slot;
392
+
393
+ if (node.fold !== null) reasons.push(refusal('$fold', KIND_REASONS.let));
394
+ if (node.letBindings.length > 0) reasons.push(refusal('$let', KIND_REASONS.let));
395
+ if (node.asChecks !== null) reasons.push(refusal('$as', 'type assertions run in the engine'));
396
+ if (node.groupby !== null) reasons.push(refusal('$groupby', KIND_REASONS.let));
397
+ if (node.count !== null) reasons.push(refusal('$count clause', KIND_REASONS.let));
398
+ const structureClean = reasons.length === 0;
399
+ // $let and $as run BEFORE $where in clause order: a row our pushed
400
+ // conjunct would exclude could still make the engine throw inside a
401
+ // binding — narrowing is only sound when nothing precedes the where
402
+ const narrowingSound = node.letBindings.length === 0 && node.asChecks === null;
403
+
404
+ // WHERE: a top-level $and splits — translated conjuncts push, the
405
+ // rest stay for the residual (pure narrowing). A refused conjunct
406
+ // may still ride the deterministic-function hatch when the hook
407
+ // accepts its raw fragment.
408
+ let whereFullyPushed = true;
409
+ const udfs = [];
410
+ if (!narrowingSound) whereFullyPushed = false;
411
+ else if (node.where !== null) {
412
+ const split = node.where.kind === 'op' && node.where.name === '$and';
413
+ const conjuncts = split ? node.where.args : [node.where];
414
+ const rawWhere = rawFlwor?.$where;
415
+ const rawConjuncts = split ? rawWhere?.$and ?? [] : [rawWhere];
416
+ for (let i = 0; i < conjuncts.length; i++) {
417
+ const outcome = planPredicate(conjuncts[i], itSlot, shape);
418
+ if ('refusal' in outcome) {
419
+ const promoted = udfHook !== undefined && rawConjuncts[i] !== undefined
420
+ ? udfHook(rawConjuncts[i])
421
+ : null;
422
+ if (promoted !== null) {
423
+ plan.filter = conjoin(plan.filter,
424
+ { p: 'udf', name: promoted.name, key: promoted.key });
425
+ udfs.push(promoted.name);
426
+ }
427
+ else {
428
+ reasons.push(outcome.refusal);
429
+ whereFullyPushed = false;
430
+ }
431
+ }
432
+ else {
433
+ plan.filter = conjoin(plan.filter, outcome.pred);
434
+ }
435
+ }
436
+ }
437
+
438
+ // ORDER BY: all terms or none — a partially pushed ordering is wrong
439
+ let orderPushed = false;
440
+ if (node.orderby !== null) {
441
+ const terms = [];
442
+ let refused = null;
443
+ for (const spec of node.orderby.specs) {
444
+ const ref = pathRef(spec.key, itSlot, shape);
445
+ if (ref === null || ref.type === 'unknown') {
446
+ refused = refusal('$orderby',
447
+ 'ordering translates only over singular schema-typed paths');
448
+ break;
449
+ }
450
+ if (spec.collation !== null || spec.collationName !== null) {
451
+ refused = refusal('$collation',
452
+ 'a collation the dialect cannot reproduce is refused, not approximated');
453
+ break;
454
+ }
455
+ terms.push({ ref, desc: spec.desc === true, emptyGreatest: spec.emptyGreatest === true });
456
+ }
457
+ if (refused !== null) reasons.push(refused);
458
+ else if (terms.length > 0) {
459
+ plan.order = terms;
460
+ orderPushed = true;
461
+ }
462
+ }
463
+ else {
464
+ orderPushed = true; // nothing to push
465
+ }
466
+
467
+ // RETURN: the bare binding is the native whole-document projection
468
+ let projectionNative = false;
469
+ assertDecidedKind(node.ret);
470
+ if (isItVar(node.ret, itSlot)) projectionNative = true;
471
+ else {
472
+ reasons.push(refusal('$return',
473
+ 'projections other than the bare binding run per row (the row residual)'));
474
+ }
475
+
476
+ return {
477
+ plan,
478
+ reasons,
479
+ whereFullyPushed: whereFullyPushed && structureClean,
480
+ orderPushed: orderPushed && structureClean,
481
+ projectionNative,
482
+ itSlot,
483
+ udfs,
484
+ };
485
+ }
486
+
487
+ /**
488
+ * Plan a whole document against one collection.
489
+ * @param {any} document - The raw query document (kept beside the AST
490
+ * for residual construction — the AST has no unparser)
491
+ * @param {any} shape - { collection, schema, columnByCanonical }
492
+ * @param {{ udf?: (fragment: any) => { name: string, key: string } | null }} [options]
493
+ * @returns {{
494
+ * analysis: any,
495
+ * plan: import('./algebra.js').Plan | null,
496
+ * mode: 'native' | 'row' | 'set',
497
+ * reasons: { construct: string, reason: string }[],
498
+ * rowReturn: any,
499
+ * udfs: string[],
500
+ * }}
501
+ */
502
+ function planCollectionCore(document, shape, options = undefined) {
503
+ const analysis = analyzeQuery(document, analyzeOptionsFor(shape?.operators));
504
+ let root = analysis.root;
505
+ assertDecidedKind(root);
506
+
507
+ // peel top-level $subsequence windows (0-based start[, length])
508
+ let rawInner = document;
509
+ const windows = [];
510
+ while (root.kind === 'op' && root.name === '$subsequence') {
511
+ const [inner, start, length] = root.args;
512
+ if (start?.kind !== 'literal' || typeof start.value !== 'number'
513
+ || (length !== undefined && (length.kind !== 'literal' || typeof length.value !== 'number'))) {
514
+ // non-literal bounds: the whole document is a set residual
515
+ return {
516
+ analysis, plan: null, mode: 'set',
517
+ reasons: [refusal('$subsequence', 'window bounds must be literal numbers to push')],
518
+ rowReturn: null, udfs: [],
519
+ };
520
+ }
521
+ windows.push({ offset: start.value, limit: length === undefined ? null : length.value });
522
+ root = inner;
523
+ rawInner = Array.isArray(rawInner?.$subsequence) ? rawInner.$subsequence[0] : rawInner;
524
+ assertDecidedKind(root);
525
+ }
526
+
527
+ // a top-level aggregate over a FLWOR
528
+ let aggregate = null;
529
+ if (root.kind === 'op' && AGGREGATES.has(root.name)) {
530
+ if (windows.length > 0) {
531
+ return {
532
+ analysis, plan: null, mode: 'set',
533
+ reasons: [refusal(root.name, 'a windowed aggregate is not translated')],
534
+ rowReturn: null, udfs: [],
535
+ };
536
+ }
537
+ aggregate = { name: root.name, fn: AGGREGATES.get(root.name) };
538
+ root = root.args[0];
539
+ rawInner = rawInner?.[aggregate.name] ?? rawInner;
540
+ assertDecidedKind(root);
541
+ }
542
+
543
+ if (root.kind !== 'flwor') {
544
+ return {
545
+ analysis, plan: null, mode: 'set',
546
+ reasons: [refusal(root.kind, KIND_REASONS[root.kind]
547
+ ?? 'only a FLWOR over the collection is translated')],
548
+ rowReturn: null, udfs: [],
549
+ };
550
+ }
551
+
552
+ const flwor = planFlwor(root, shape, rawInner, options?.udf);
553
+ const { plan } = flwor;
554
+ const fullyPushed = flwor.whereFullyPushed && flwor.orderPushed;
555
+
556
+ if (aggregate !== null) {
557
+ // aggregates need the WHOLE selection native (their input is the
558
+ // full sequence, not a narrowed candidate set)
559
+ if (!fullyPushed) {
560
+ return { analysis, plan: null, mode: 'set', reasons: flwor.reasons,
561
+ rowReturn: null, udfs: [] };
562
+ }
563
+ if (aggregate.fn === 'count') {
564
+ if (!flwor.projectionNative) {
565
+ return {
566
+ analysis, plan: null, mode: 'set',
567
+ reasons: [refusal('$count',
568
+ 'count translates only over the bare binding (a projected return can change the item count)')],
569
+ rowReturn: null, udfs: [],
570
+ };
571
+ }
572
+ plan.aggregate = { fn: 'count', ref: null };
573
+ return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
574
+ udfs: flwor.udfs };
575
+ }
576
+ const ref = pathRef(root.ret, flwor.itSlot, shape);
577
+ const numeric = aggregate.fn === 'sum' || aggregate.fn === 'avg';
578
+ const acceptable = ref !== null
579
+ && (numeric ? isNumericType(ref.type) : ref.type !== 'unknown');
580
+ if (!acceptable) {
581
+ return {
582
+ analysis, plan: null, mode: 'set',
583
+ reasons: [refusal(aggregate.name,
584
+ 'aggregates translate only over a singular schema-typed path (the engine ERRORS on non-conforming operands)')],
585
+ rowReturn: null, udfs: [],
586
+ };
587
+ }
588
+ plan.aggregate = { fn: /** @type {any} */ (aggregate.fn), ref };
589
+ return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
590
+ udfs: flwor.udfs };
591
+ }
592
+
593
+ // windows push only onto a fully pushed selection
594
+ if (windows.length > 0 && fullyPushed) {
595
+ // innermost window applies first; compose offsets/limits
596
+ let offset = 0;
597
+ let limit = null;
598
+ for (let i = windows.length - 1; i >= 0; i--) {
599
+ const w = windows[i];
600
+ offset += w.offset;
601
+ if (w.limit !== null) {
602
+ limit = limit === null ? w.limit : Math.min(Math.max(limit - w.offset, 0), w.limit);
603
+ }
604
+ else if (limit !== null) {
605
+ limit = Math.max(limit - w.offset, 0);
606
+ }
607
+ }
608
+ plan.window = { offset, limit };
609
+ }
610
+
611
+ if (fullyPushed && flwor.projectionNative && (windows.length === 0 || plan.window !== null)) {
612
+ return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
613
+ udfs: flwor.udfs };
614
+ }
615
+
616
+ // the row residual: everything but the projection pushed
617
+ if (fullyPushed && !flwor.projectionNative
618
+ && (windows.length === 0 || plan.window !== null)) {
619
+ const rawFlwor = rawInner;
620
+ return {
621
+ analysis,
622
+ plan,
623
+ mode: 'row',
624
+ reasons: flwor.reasons,
625
+ rowReturn: rawFlwor?.$return ?? '$it',
626
+ udfs: flwor.udfs,
627
+ };
628
+ }
629
+
630
+ // the set residual: pushed conjuncts narrow, the engine answers
631
+ plan.order = null;
632
+ plan.window = null;
633
+ return { analysis, plan, mode: 'set', reasons: flwor.reasons, rowReturn: null,
634
+ udfs: flwor.udfs };
635
+ }
636
+
637
+ /**
638
+ * Plan a whole document against one collection. The store's registered
639
+ * operators (Ring 2) ride in `shape.operators` — the planner recognises
640
+ * them as vocabulary but keeps them in the residual, and names them in
641
+ * the reasons when it does.
642
+ * @param {any} document - The raw query document (kept beside the AST
643
+ * for residual construction — the AST has no unparser)
644
+ * @param {any} shape - { collection, schema, columnByCanonical, operators? }
645
+ * @param {{ udf?: (fragment: any) => { name: string, key: string } | null }} [options]
646
+ * @returns {{
647
+ * analysis: any,
648
+ * plan: import('./algebra.js').Plan | null,
649
+ * mode: 'native' | 'row' | 'set',
650
+ * reasons: { construct: string, reason: string }[],
651
+ * rowReturn: any,
652
+ * udfs: string[],
653
+ * }}
654
+ */
655
+ export function planQuery(document, shape, options = undefined) {
656
+ const planned = planCollectionCore(document, shape, options);
657
+ return prependRegisteredReason(planned, document, shape?.operators);
658
+ }
659
+
660
+ // ————— The entity document kind (one planner, two document kinds) —————
661
+
662
+ /**
663
+ * Build the planner shape for one entity: canonical top-level paths
664
+ * map to REAL columns (flavor `entity-column`), epoch date columns to
665
+ * their derived integer twins (flavor `entity-epoch`), and everything
666
+ * else stays a document path over the entity's JSONB column (the
667
+ * phase-A guarded forms).
668
+ * @param {any} entity - normalized entity (model.js)
669
+ * @param {any} entityMapping - explainMapping(...).entities[name]
670
+ * @returns {any}
671
+ */
672
+ export function entityShape(entity, entityMapping) {
673
+ /** @type {Map<string, any>} */
674
+ const flavors = new Map();
675
+ for (const column of entityMapping.columns) {
676
+ const epoch = column.source === 'epoch(document)';
677
+ flavors.set(`.${column.name}`, {
678
+ column: column.name,
679
+ flavor: epoch ? 'entity-epoch' : 'entity-column',
680
+ storage: column.storage,
681
+ format: epoch ? entity.properties.get(column.name)?.format : undefined,
682
+ });
683
+ }
684
+ for (const fk of entityMapping.foreignKeys) {
685
+ if (!flavors.has(`.${fk.column}`))
686
+ flavors.set(`.${fk.column}`, { column: fk.column, flavor: 'entity-column', storage: 'string' });
687
+ }
688
+ return {
689
+ collection: entity.name,
690
+ schema: entity.schema,
691
+ columnByCanonical: new Map(),
692
+ entityFlavors: flavors,
693
+ };
694
+ }
695
+
696
+ /**
697
+ * Resolve a singular member path on an entity binding to a flavored
698
+ * PlanRef.
699
+ * @param {any} node - a path AST node
700
+ * @param {number} slot
701
+ * @param {any} shape - from {@link entityShape}
702
+ * @returns {any | null}
703
+ */
704
+ export function entityPathRef(node, slot, shape) {
705
+ const ref = pathRef(node, slot, shape);
706
+ if (ref === null) return null;
707
+ const canonical = ref.segments
708
+ .map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
709
+ const flavored = shape.entityFlavors.get(canonical);
710
+ if (flavored !== undefined) {
711
+ return {
712
+ ...ref,
713
+ column: flavored.column,
714
+ flavor: flavored.flavor,
715
+ storage: flavored.storage,
716
+ format: flavored.format,
717
+ };
718
+ }
719
+ // a nested path rides the JSONB document with the phase-A guards;
720
+ // strip nothing — jsonb_extract addresses the doc column directly
721
+ return { ...ref, flavor: 'entity-doc' };
722
+ }
723
+
724
+ /**
725
+ * Plan one predicate over an entity binding: the same operator
726
+ * grammar as phase A, with entity-flavored refs. Reuses
727
+ * {@link planPredicate} for the recognition, then re-resolves refs
728
+ * through the flavor table.
729
+ * @param {any} node
730
+ * @param {number} slot
731
+ * @param {any} shape
732
+ * @returns {{ pred: any } | { refusal: { construct: string, reason: string } }}
733
+ */
734
+ export function planEntityPredicate(node, slot, shape) {
735
+ const outcome = planPredicate(node, slot, shape);
736
+ if ('refusal' in outcome) return outcome;
737
+ /** @type {{ construct: string, reason: string } | null} */
738
+ let blocked = null;
739
+ const reflavor = (pred) => {
740
+ if (pred.p === 'and' || pred.p === 'or')
741
+ return { ...pred, items: pred.items.map(reflavor) };
742
+ if (pred.p === 'not') return { ...pred, item: reflavor(pred.item) };
743
+ if (!('ref' in pred) || pred.ref === null) return pred;
744
+ const canonical = pred.ref.segments
745
+ .map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
746
+ const flavored = shape.entityFlavors.get(canonical);
747
+ if (flavored === undefined) {
748
+ // externals against DOC paths are not translated here (the
749
+ // phase-A external forms assume the collection layout)
750
+ if (pred.p === 'cmp' && 'ext' in pred.operand) {
751
+ blocked = { construct: '$eq',
752
+ reason: 'externals compare only against entity columns in this version' };
753
+ }
754
+ return { ...pred, ref: { ...pred.ref, flavor: 'entity-doc' } };
755
+ }
756
+ const ref = { ...pred.ref, column: flavored.column,
757
+ flavor: flavored.flavor, storage: flavored.storage, format: flavored.format };
758
+ if (flavored.flavor === 'entity-epoch' && pred.p === 'cmp') {
759
+ if ('ext' in pred.operand) {
760
+ blocked = { construct: pred.op,
761
+ reason: 'externals compare only against entity columns in this version' };
762
+ return { ...pred, ref };
763
+ }
764
+ // the plan-time instant translation: an ordering comparison
765
+ // against a literal of the column's own family (Z-normalized
766
+ // date-time, or a plain date on a date column) gains the ±1s
767
+ // epoch range the emitter narrows the index with; anything else
768
+ // simply keeps the guarded document forms — sound, unassisted
769
+ if (pred.op !== 'ne' && typeof pred.operand.lit === 'string') {
770
+ const lit = pred.operand.lit;
771
+ const epoch = flavored.format === 'date'
772
+ ? (/^\d{4}-\d{2}-\d{2}$/.test(lit) ? getEpochOfDateOnlyRFC3339(lit) : NaN)
773
+ : (lit.includes('T') && lit.endsWith('Z') ? getEpochOfDateTimeRFC3339(lit) : NaN);
774
+ if (typeof epoch === 'number' && Number.isFinite(epoch))
775
+ return { ...pred, ref, epoch };
776
+ }
777
+ }
778
+ return { ...pred, ref };
779
+ };
780
+ const pred = reflavor(outcome.pred);
781
+ if (blocked !== null) return { refusal: blocked };
782
+ return { pred };
783
+ }
784
+
785
+ /**
786
+ * Plan an ENTITY query document: a FLWOR whose bindings range over
787
+ * `$.<Entity>[*]` arrays of the multi-entity root. One binding is a
788
+ * guarded selection; two bindings joined by a key equality become an
789
+ * INNER equijoin (exactly the engine's cross-product-plus-filter
790
+ * semantics, which is what keeps the oracle honest). Everything else
791
+ * is the set residual over the fetched root.
792
+ * @param {any} document
793
+ * @param {Map<string, any>} entities - normalized entities
794
+ * @param {any} mapping - explainMapping result
795
+ * @param {{ functions?: any, extensions?: any } | null} [operators] -
796
+ * the store's registered operators (Ring 2); recognised as vocabulary,
797
+ * kept in the set residual over the fetched root
798
+ * @returns {any}
799
+ */
800
+ function planEntityQueryCore(document, entities, mapping, operators) {
801
+ const analysis = analyzeQuery(document, analyzeOptionsFor(operators));
802
+ let root = analysis.root;
803
+ assertDecidedKind(root);
804
+
805
+ const referenced = [...collectEntityRoots(document, entities)];
806
+ const residual = (construct, reason) => ({
807
+ analysis, mode: 'set', plan: null, referenced,
808
+ reasons: [{ construct, reason }],
809
+ });
810
+
811
+ // peel literal windows exactly as the collection planner does
812
+ const windows = [];
813
+ while (root.kind === 'op' && root.name === '$subsequence') {
814
+ const [inner, start, length] = root.args;
815
+ if (start?.kind !== 'literal' || typeof start.value !== 'number'
816
+ || (length !== undefined && (length.kind !== 'literal' || typeof length.value !== 'number')))
817
+ return residual('$subsequence', 'window bounds must be literal numbers to push');
818
+ windows.push({ offset: start.value, limit: length === undefined ? null : length.value });
819
+ root = inner;
820
+ assertDecidedKind(root);
821
+ }
822
+ let aggregate = null;
823
+ if (root.kind === 'op' && root.name === '$count' && windows.length === 0) {
824
+ aggregate = 'count';
825
+ root = root.args[0];
826
+ assertDecidedKind(root);
827
+ }
828
+ if (root.kind !== 'flwor')
829
+ return residual(root.kind, 'only a FLWOR over entity arrays is translated');
830
+ if (root.fold !== null || root.letBindings.length > 0 || root.asChecks !== null
831
+ || root.groupby !== null || root.count !== null)
832
+ return residual('$let', 'no equivalence proof exists yet; residual by default');
833
+
834
+ // bindings must each range over one entity's array
835
+ const bindings = [];
836
+ for (const binding of root.forBindings) {
837
+ const source = binding.expr;
838
+ const sourceEntity = source?.kind === 'path' && source.name === '$'
839
+ && source.external !== true && source.segments.length === 2
840
+ && source.segments[0].descendant !== true
841
+ && source.segments[0].selectors.length === 1
842
+ && source.segments[0].selectors[0].kind === 'name'
843
+ && source.segments[1].selectors?.length === 1
844
+ && source.segments[1].selectors[0].kind === 'wildcard'
845
+ ? source.segments[0].selectors[0].name
846
+ : null;
847
+ if (sourceEntity === null || !entities.has(sourceEntity)
848
+ || binding.window !== null || binding.atSlot !== -1 || binding.allowingEmpty !== false)
849
+ return residual('$for', 'bindings must each range over one declared entity array ($.Entity[*])');
850
+ bindings.push({
851
+ name: binding.name,
852
+ slot: binding.slot,
853
+ entity: sourceEntity,
854
+ shape: entityShape(entities.get(sourceEntity), mapping.entities[sourceEntity]),
855
+ });
856
+ }
857
+ if (bindings.length > 2)
858
+ return residual('$for', 'at most two bindings are translated (one join per statement)');
859
+
860
+ const byName = new Map(bindings.map((binding) => [binding.slot, binding]));
861
+ const conjuncts = root.where === null
862
+ ? []
863
+ : root.where.kind === 'op' && root.where.name === '$and'
864
+ ? root.where.args
865
+ : [root.where];
866
+
867
+ let joinOn = null;
868
+ const filters = new Map(bindings.map((binding) => [binding.slot, null]));
869
+ const reasons = [];
870
+ let whereFullyPushed = true;
871
+ for (const conjunct of conjuncts) {
872
+ // a key equality between the two bindings is the join condition
873
+ if (bindings.length === 2 && joinOn === null
874
+ && conjunct.kind === 'op' && conjunct.name === '$eq') {
875
+ const [left, right] = conjunct.args;
876
+ const leftBinding = left.kind === 'path' ? byName.get(left.rootSlot) : undefined;
877
+ const rightBinding = right.kind === 'path' ? byName.get(right.rootSlot) : undefined;
878
+ if (leftBinding !== undefined && rightBinding !== undefined
879
+ && leftBinding !== rightBinding) {
880
+ const leftRef = entityPathRef(left, left.rootSlot, leftBinding.shape);
881
+ const rightRef = entityPathRef(right, right.rootSlot, rightBinding.shape);
882
+ if (leftRef?.flavor === 'entity-column' && rightRef?.flavor === 'entity-column') {
883
+ joinOn = {
884
+ left: { binding: leftBinding, ref: leftRef },
885
+ right: { binding: rightBinding, ref: rightRef },
886
+ };
887
+ continue;
888
+ }
889
+ }
890
+ }
891
+ // otherwise the conjunct must belong wholly to ONE binding
892
+ const slots = new Set();
893
+ collectBindingSlots(conjunct, byName, slots);
894
+ if (slots.size !== 1) {
895
+ reasons.push({ construct: '$where',
896
+ reason: 'a conjunct must belong to one binding (or be the single join equality)' });
897
+ whereFullyPushed = false;
898
+ continue;
899
+ }
900
+ const slot = [...slots][0];
901
+ const binding = byName.get(slot);
902
+ const outcome = planEntityPredicate(conjunct, slot, binding.shape);
903
+ if ('refusal' in outcome) {
904
+ reasons.push(outcome.refusal);
905
+ whereFullyPushed = false;
906
+ continue;
907
+ }
908
+ filters.set(slot, conjoin(filters.get(slot), outcome.pred));
909
+ }
910
+ if (bindings.length === 2 && joinOn === null)
911
+ return residual('$for', 'two bindings need a key equality between them (the join condition)');
912
+
913
+ // the return must be one bare binding
914
+ const retBinding = root.ret.kind === 'var' && root.ret.external !== true
915
+ ? byName.get(root.ret.slot) : undefined;
916
+ if (retBinding === undefined) {
917
+ reasons.push({ construct: '$return',
918
+ reason: 'entity queries return one bare binding natively; projections run in the engine' });
919
+ }
920
+
921
+ // ordering over flavored refs of either binding
922
+ let order = null;
923
+ let orderPushed = true;
924
+ if (root.orderby !== null) {
925
+ const terms = [];
926
+ for (const spec of root.orderby.specs) {
927
+ const slot = spec.key.kind === 'path' ? spec.key.rootSlot : -1;
928
+ const binding = byName.get(slot);
929
+ const ref = binding === undefined
930
+ ? null : entityPathRef(spec.key, slot, binding.shape);
931
+ if (ref === null || (ref.flavor === 'entity-doc' && ref.type === 'unknown')
932
+ || spec.collation !== null || spec.collationName !== null) {
933
+ orderPushed = false;
934
+ reasons.push({ construct: '$orderby',
935
+ reason: 'ordering translates only over typed entity paths' });
936
+ break;
937
+ }
938
+ terms.push({ binding, ref, desc: spec.desc === true, emptyGreatest: spec.emptyGreatest === true });
939
+ }
940
+ if (orderPushed) order = terms;
941
+ }
942
+
943
+ const fullyPushed = whereFullyPushed && orderPushed && retBinding !== undefined
944
+ && (aggregate === null || retBinding !== undefined);
945
+ if (!fullyPushed) {
946
+ return { analysis, mode: 'set', plan: null, referenced, reasons };
947
+ }
948
+
949
+ let window = null;
950
+ if (windows.length > 0) {
951
+ let offset = 0;
952
+ let limit = null;
953
+ for (let i = windows.length - 1; i >= 0; i--) {
954
+ const w = windows[i];
955
+ offset += w.offset;
956
+ if (w.limit !== null) limit = limit === null ? w.limit : Math.min(Math.max(limit - w.offset, 0), w.limit);
957
+ else if (limit !== null) limit = Math.max(limit - w.offset, 0);
958
+ }
959
+ window = { offset, limit };
960
+ }
961
+
962
+ return {
963
+ analysis,
964
+ mode: 'native',
965
+ referenced,
966
+ reasons: [],
967
+ plan: {
968
+ planVersion: PLAN_VERSION,
969
+ alg: bindings.length === 2 ? 'entity-join' : 'entity-select',
970
+ bindings: bindings.map((binding) => ({ name: binding.name, entity: binding.entity })),
971
+ joinOn: joinOn === null ? null : {
972
+ left: { binding: joinOn.left.binding.name, column: joinOn.left.ref.column },
973
+ right: { binding: joinOn.right.binding.name, column: joinOn.right.ref.column },
974
+ },
975
+ filters: bindings.map((binding) => ({
976
+ binding: binding.name,
977
+ filter: filters.get(binding.slot),
978
+ })),
979
+ order: order === null ? null : order.map((term) => ({
980
+ binding: term.binding.name, ref: term.ref,
981
+ desc: term.desc, emptyGreatest: term.emptyGreatest,
982
+ })),
983
+ window,
984
+ aggregate,
985
+ ret: retBinding.name,
986
+ },
987
+ };
988
+ }
989
+
990
+ /**
991
+ * Plan an ENTITY query document (one planner, two document kinds). The
992
+ * store's registered operators (Ring 2) are recognised as vocabulary and
993
+ * kept in the set residual over the fetched root, named in the reasons.
994
+ * @param {any} document
995
+ * @param {Map<string, any>} entities - normalized entities
996
+ * @param {any} mapping - explainMapping result
997
+ * @param {{ functions?: any, extensions?: any } | null} [operators]
998
+ * @returns {any}
999
+ */
1000
+ export function planEntityQuery(document, entities, mapping, operators = null) {
1001
+ const planned = planEntityQueryCore(document, entities, mapping, operators);
1002
+ return prependRegisteredReason(planned, document, operators);
1003
+ }
1004
+
1005
+ /** Which binding slots a subtree references (via path roots). */
1006
+ function collectBindingSlots(node, byName, slots) {
1007
+ if (node === null || typeof node !== 'object') return;
1008
+ if (Array.isArray(node)) {
1009
+ for (const item of node) collectBindingSlots(item, byName, slots);
1010
+ return;
1011
+ }
1012
+ if (node.kind === 'path' && byName.has(node.rootSlot)) slots.add(node.rootSlot);
1013
+ for (const key of Object.keys(node)) {
1014
+ if (key === 'docPath') continue;
1015
+ collectBindingSlots(node[key], byName, slots);
1016
+ }
1017
+ }
1018
+
1019
+ /** The entity names a document's root paths reference (`$.Name[*]`). */
1020
+ export function collectEntityRoots(document, entities) {
1021
+ const found = new Set();
1022
+ const walk = (node) => {
1023
+ if (typeof node === 'string') {
1024
+ const match = /^\$\.([A-Za-z_][A-Za-z0-9_]*)\[\*\]/.exec(node);
1025
+ if (match !== null && entities.has(match[1])) found.add(match[1]);
1026
+ return;
1027
+ }
1028
+ if (Array.isArray(node)) {
1029
+ node.forEach(walk);
1030
+ return;
1031
+ }
1032
+ if (node !== null && typeof node === 'object') {
1033
+ for (const key of Object.keys(node)) walk(node[key]);
1034
+ }
1035
+ };
1036
+ walk(document);
1037
+ return found;
1038
+ }