@jarenjs/db 0.73.0 → 0.83.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.
Files changed (53) hide show
  1. package/ARCHITECTURE.md +70 -7
  2. package/README.md +69 -6
  3. package/docs/HOSTS.md +17 -0
  4. package/docs/JOBS-FORMAT.md +26 -0
  5. package/docs/LIVE-FORMAT.md +52 -13
  6. package/docs/MIGRATION-FORMAT.md +34 -0
  7. package/docs/MODEL-FORMAT.md +163 -15
  8. package/docs/NATIVE-PLANS.md +111 -0
  9. package/docs/REPLICATION-FORMAT.md +19 -13
  10. package/docs/SEARCH.md +55 -0
  11. package/package.json +8 -4
  12. package/schemas/jaren-migration.draft-07.schema.json +54 -5
  13. package/schemas/jaren-migration.schema.json +49 -0
  14. package/schemas/jaren-model.authoring.schema.json +360 -0
  15. package/schemas/jaren-model.draft-07.schema.json +128 -0
  16. package/schemas/jaren-model.schema.json +128 -0
  17. package/src/algebra.js +26 -4
  18. package/src/backup.js +12 -7
  19. package/src/cursor.js +27 -4
  20. package/src/dag-job.js +2 -1
  21. package/src/ddl.js +13 -0
  22. package/src/derive.js +14 -3
  23. package/src/dialect.js +12 -0
  24. package/src/dialects/check-read.js +151 -0
  25. package/src/dialects/invariant-sql.js +117 -0
  26. package/src/dialects/postgres.js +28 -4
  27. package/src/dialects/sqlite.js +23 -3
  28. package/src/driver.js +1 -0
  29. package/src/drivers/bun.js +22 -4
  30. package/src/emit.js +133 -25
  31. package/src/entity.js +98 -41
  32. package/src/errors.js +8 -0
  33. package/src/graph.js +8 -1
  34. package/src/index.js +3 -0
  35. package/src/introspect.js +81 -12
  36. package/src/invariants.js +45 -0
  37. package/src/jobs.js +39 -6
  38. package/src/live-nested.js +27 -10
  39. package/src/live.js +51 -136
  40. package/src/migrate.js +136 -22
  41. package/src/model.js +12 -0
  42. package/src/mutation.js +165 -0
  43. package/src/physical.js +147 -0
  44. package/src/plan.js +275 -64
  45. package/src/query.js +175 -78
  46. package/src/search.js +144 -0
  47. package/src/sql.js +60 -0
  48. package/src/store.js +49 -13
  49. package/src/tracker.js +63 -39
  50. package/src/window.js +1 -0
  51. package/types/index.d.ts +59 -4
  52. package/types/search.d.ts +20 -0
  53. package/types/typed.d.ts +1 -0
package/src/plan.js CHANGED
@@ -95,11 +95,12 @@ const KIND_REASONS = {
95
95
 
96
96
  /** Why one PREDICATE stayed in the engine. */
97
97
  const PREDICATE_REASONS = {
98
+ physicalCodec: 'this codec or NULL policy requires decoded-row evaluation',
98
99
  notPredicate: 'not a predicate the planner translates',
99
100
  negatedPrefilter: 'a negated predicate cannot ride an implied pre-filter '
100
101
  + '(negating a superset drops rows)',
101
102
  existence: 'existence tests translate only over a singular member path on the binding',
102
- joinTerritory: 'comparisons where both sides are paths are join territory',
103
+ joinTerritory: 'path comparisons require the same non-null numeric or string family',
103
104
  operands: 'comparisons translate only between a singular member path and a literal or external',
104
105
  compoundLiteral: 'array and object literals have no guarded native comparison form',
105
106
  stringSubject: 'string operators translate only over schema-typed string paths '
@@ -130,8 +131,8 @@ const PLAN_REASONS = {
130
131
  countProjection: 'count translates only over the bare binding or one member path '
131
132
  + '(a projected return can change the item count)',
132
133
  groupedAggregate: 'an aggregate over a grouped phrase folds its groups, which the engine does',
133
- windowedGroup: 'a window over the GROUPS is engine work: the plan groups whole, '
134
- + 'and a LIMIT over the groups would cut a different set',
134
+ distinctProjection: 'distinct translates over one typed scalar member path, unordered or ordered by that same path',
135
+ windowedGroup: 'a window over groups whose return can omit an item needs the engine cardinality',
135
136
  aggregatePath: 'aggregates translate only over a singular schema-typed path '
136
137
  + '(the engine ERRORS on non-conforming operands)',
137
138
  };
@@ -187,7 +188,8 @@ const ENTITY_REASONS = {
187
188
  + 'a binding nothing connects is a cartesian product, which is engine work',
188
189
  conjunctBinding: 'a conjunct must belong to one binding (or be the single join equality)',
189
190
  external: 'externals compare only against entity columns in this version',
190
- projection: 'entity queries return one bare binding natively; projections run in the engine',
191
+ projection: 'this entity return or grouping needs decoded-row evaluation',
192
+ groupOrder: 'first-seen grouping over a compound physical key requires tuple ordering',
191
193
  order: 'ordering translates only over typed entity paths (never a boolean, never a document '
192
194
  + 'path that admits null)',
193
195
  };
@@ -756,7 +758,8 @@ function planDistanceBound(node, itSlot, shape) {
756
758
  const distanceNode = upperOnLeft ? node.args[0] : node.args[1];
757
759
  const radiusNode = upperOnLeft ? node.args[1] : node.args[0];
758
760
  const radius = constantOf(radiusNode);
759
- if (radius === null || typeof radius.value !== 'number')
761
+ const radiusExternal = radiusNode.kind === 'var' && radiusNode.external === true;
762
+ if (!radiusExternal && (radius === null || typeof radius.value !== 'number'))
760
763
  return { refusal: refusal('$distance', SPATIAL_REASONS.operand) };
761
764
 
762
765
  let subjectAt = 0;
@@ -769,21 +772,33 @@ function planDistanceBound(node, itSlot, shape) {
769
772
 
770
773
  const probeNode = distanceNode.args[subjectAt === 0 ? 1 : 0];
771
774
  const constant = constantOf(probeNode);
772
- // an EXTERNAL centre would need a slot that composes the bound value
773
- // with the radius, and the derived slot kind is closed at one axis of
774
- // one bound value; such a query diverts to the full scan as before
775
- if (constant === null) return { refusal: refusal('$distance', SPATIAL_REASONS.operand) };
776
- const at = probePosition(constant.value);
777
- if (at === null) return { refusal: refusal('$distance', SPATIAL_REASONS.unbounded) };
778
- if (!Number.isFinite(radius.value) || radius.value < 0) {
775
+ const centreExternal = probeNode.kind === 'var' && probeNode.external === true;
776
+ if (!centreExternal && constant === null)
777
+ return { refusal: refusal('$distance', SPATIAL_REASONS.operand) };
778
+ if (!radiusExternal && (!Number.isFinite(radius.value) || radius.value < 0)) {
779
779
  return { refusal: refusal('$distance', SPATIAL_REASONS.radius) };
780
780
  }
781
- const box = probeCircleBox(at, radius.value);
782
- if (box === null) return { refusal: refusal('$distance', SPATIAL_REASONS.pole) };
783
- if (box[0] < -180 || box[2] > 180)
784
- return { refusal: refusal('$distance', SPATIAL_REASONS.wrapped) };
781
+ let probe;
782
+ if (centreExternal || radiusExternal) {
783
+ // Both inputs belong to one closed derived slot. Bind-time failure
784
+ // diverts the whole query, preserving the engine's errors and the
785
+ // candidates on the far side of a pole or the antimeridian.
786
+ probe = { circle: {
787
+ centre: centreExternal ? { external: probeNode.name } : { literal: constant.value },
788
+ radius: radiusExternal ? { external: radiusNode.name } : { literal: radius.value },
789
+ } };
790
+ }
791
+ else {
792
+ const at = probePosition(constant.value);
793
+ if (at === null) return { refusal: refusal('$distance', SPATIAL_REASONS.unbounded) };
794
+ const box = probeCircleBox(at, radius.value);
795
+ if (box === null) return { refusal: refusal('$distance', SPATIAL_REASONS.pole) };
796
+ if (box[0] < -180 || box[2] > 180)
797
+ return { refusal: refusal('$distance', SPATIAL_REASONS.wrapped) };
798
+ probe = { box };
799
+ }
785
800
 
786
- const conjunct = boxConjunct(columns, rtreeTableOf(shape, subject.canonical), { box });
801
+ const conjunct = boxConjunct(columns, rtreeTableOf(shape, subject.canonical), probe);
787
802
  return promotion(conjunct.pred,
788
803
  { construct: '$distance', via: conjunct.via, columns: conjunct.columns, exact: false },
789
804
  [refusal('$distance', SPATIAL_REASONS.distance)]);
@@ -1305,8 +1320,14 @@ function planPredicate(node, itSlot, shape) {
1305
1320
  const ref = pathRef(left, itSlot, shape);
1306
1321
  const operand = operandOf(right);
1307
1322
  if (ref === null || operand === null) {
1308
- if (pathRef(left, itSlot, shape) !== null && pathRef(right, itSlot, shape) !== null)
1323
+ const other = pathRef(right, itSlot, shape);
1324
+ if (ref !== null && other !== null) {
1325
+ const family = (item) => isNumericType(item.type) ? 'number' : item.type;
1326
+ if (orderable(ref, shape.schema) && orderable(other, shape.schema)
1327
+ && family(ref) === family(other))
1328
+ return exactly({ p: 'refCmp', op, left: ref, right: other });
1309
1329
  return { refusal: refusal(node.name, PREDICATE_REASONS.joinTerritory) };
1330
+ }
1310
1331
  return { refusal: refusal(node.name, PREDICATE_REASONS.operands) };
1311
1332
  }
1312
1333
  if ('lit' in operand) {
@@ -2057,7 +2078,7 @@ function planSeriesOperator(root, shape) {
2057
2078
  * The rules that make it agree with the engine, each one a refusal
2058
2079
  * rather than an approximation:
2059
2080
  *
2060
- * - a key is a singular schema-typed path that cannot hold `null`, so
2081
+ * - a key is a singular schema-typed scalar path, so
2061
2082
  * the group SQL forms is the group the engine forms. An ABSENT key is
2062
2083
  * its own group, and its value comes back with its JSON type beside
2063
2084
  * it, so the decoder can leave the member out exactly as the object
@@ -2074,13 +2095,13 @@ function planSeriesOperator(root, shape) {
2074
2095
  * @param {any} shape
2075
2096
  * @returns {any | null}
2076
2097
  */
2077
- function planGeneralGrouping(node, itSlot, shape) {
2098
+ function planGeneralGrouping(node, itSlot, shape, resolve = pathRef) {
2078
2099
  const keys = [];
2079
2100
  /** @type {Map<number, number>} */
2080
2101
  const keySlot = new Map();
2081
2102
  for (const key of node.groupby.keys) {
2082
- const ref = pathRef(key.expr, itSlot, shape);
2083
- if (ref === null || ref.type === 'unknown' || admitsNull(shape.schema, ref.segments))
2103
+ const ref = resolve(key.expr, itSlot, shape);
2104
+ if (ref === null || ref.type === 'unknown')
2084
2105
  return null;
2085
2106
  keySlot.set(key.slot, keys.length);
2086
2107
  keys.push({ as: key.name, ref });
@@ -2094,7 +2115,7 @@ function planGeneralGrouping(node, itSlot, shape) {
2094
2115
  if (child.kind === 'var' && child.external !== true && keySlot.has(child.slot))
2095
2116
  return { p: 'key', index: keySlot.get(child.slot) };
2096
2117
  if (child.kind === 'op') {
2097
- const entry = groupAggregate(child, itSlot, shape);
2118
+ const entry = groupAggregate(child, itSlot, shape, resolve);
2098
2119
  if (entry === null) return null;
2099
2120
  // one aggregate per distinct (function, path): two members that
2100
2121
  // ask the same question are one SQL aggregate
@@ -2129,8 +2150,12 @@ function planGeneralGrouping(node, itSlot, shape) {
2129
2150
  };
2130
2151
  const tree = build(node.ret);
2131
2152
  if (tree === null) return null;
2132
- const order = groupOrder(node.orderby, keySlot);
2153
+ const order = groupOrder(node.orderby, keySlot, build);
2133
2154
  if (order === null) return null;
2155
+ // Group equality accepts null and boolean keys; ordering them raises
2156
+ // JQ2005. SQL ordering must not hide that engine error.
2157
+ if (order !== 'first-seen' && order.some((term) =>
2158
+ term.aggregate === undefined && !orderable(keys[term.index].ref, shape.schema))) return null;
2134
2159
  return { keys, aggregates, tree, order };
2135
2160
  }
2136
2161
 
@@ -2144,14 +2169,36 @@ function planGeneralGrouping(node, itSlot, shape) {
2144
2169
  * @param {any} shape
2145
2170
  * @returns {{ as: string, fn: string, ref: any, empty: string } | null}
2146
2171
  */
2147
- function groupAggregate(node, itSlot, shape) {
2172
+ function groupAggregate(node, itSlot, shape, resolve = pathRef) {
2173
+ // SQL's nullable sum is authored explicitly: test for any non-null
2174
+ // values, sum exactly that sequence, otherwise return a literal null.
2175
+ if (node.name === '$if' && node.args[0]?.name === '$exists'
2176
+ && node.args[1]?.name === '$sum' && node.args[2]?.kind === 'literal' && node.args[2].value === null) {
2177
+ const filtered = (phrase) => {
2178
+ if (phrase?.kind !== 'flwor' || phrase.forBindings.length !== 1 || phrase.letBindings.length
2179
+ || phrase.groupby !== null || phrase.orderby !== null || phrase.fold !== null
2180
+ || phrase.count !== null || phrase.asChecks !== null) return null;
2181
+ const binding = phrase.forBindings[0];
2182
+ if (!isItVar(unpacked(binding.expr), itSlot) || binding.window !== null || binding.atSlot !== -1 || binding.allowingEmpty) return null;
2183
+ const ref = resolve(phrase.ret, binding.slot, shape);
2184
+ const where = phrase.where;
2185
+ if (ref === null || !isNumericType(ref.type) || where?.name !== '$ne') return null;
2186
+ const other = resolve(where.args[0], binding.slot, shape);
2187
+ return other !== null && canonicalOf(other.segments) === canonicalOf(ref.segments)
2188
+ && where.args[1]?.kind === 'literal' && where.args[1].value === null ? ref : null;
2189
+ };
2190
+ const test = filtered(node.args[0].args[0]);
2191
+ const sum = filtered(node.args[1].args[0]);
2192
+ return test !== null && sum !== null && canonicalOf(test.segments) === canonicalOf(sum.segments)
2193
+ ? { fn: 'sum', ref: sum, empty: 'null' } : null;
2194
+ }
2148
2195
  if (node.name === '$count') {
2149
2196
  return isItVar(node.args[0], itSlot)
2150
2197
  ? { fn: 'rows', ref: null, empty: 'zero' } : null;
2151
2198
  }
2152
2199
  const fn = AGGREGATES.get(node.name);
2153
2200
  if (fn === undefined || fn === 'count') return null;
2154
- const ref = pathRef(node.args[0], itSlot, shape);
2201
+ const ref = resolve(node.args[0], itSlot, shape);
2155
2202
  const numeric = fn === 'sum' || fn === 'avg';
2156
2203
  const acceptable = ref !== null
2157
2204
  && (numeric ? isNumericType(ref.type) : ref.type !== 'unknown')
@@ -2164,21 +2211,32 @@ function groupAggregate(node, itSlot, shape) {
2164
2211
  /**
2165
2212
  * How the groups come out: the engine's order of FIRST APPEARANCE
2166
2213
  * (§6.5) when nothing declares otherwise, else the group-key ordering
2167
- * an `$orderby` asked for. `null` when the ordering names anything but
2168
- * group keys after a grouping there is nothing else a row can order
2169
- * by that the plan could reproduce.
2214
+ * an `$orderby` asked for. Proven aggregate expressions share the
2215
+ * return's aggregate slots, including aggregates used only to order.
2170
2216
  * @param {any} orderby
2171
2217
  * @param {Map<number, number>} keySlot
2172
- * @returns {'first-seen' | { index: number, desc: boolean, nullsFirst: boolean }[] | null}
2218
+ * @param {(node: any) => any} build
2219
+ * @returns {any}
2173
2220
  */
2174
- function groupOrder(orderby, keySlot) {
2221
+ function groupOrder(orderby, keySlot, build) {
2175
2222
  if (orderby === null) return 'first-seen';
2176
2223
  const terms = [];
2177
2224
  for (const spec of orderby.specs) {
2178
2225
  if (spec.collation !== null || spec.collationName !== null) return null;
2179
2226
  const key = spec.key;
2180
- if (key.kind !== 'var' || key.external === true || !keySlot.has(key.slot)) return null;
2181
- terms.push({ index: keySlot.get(key.slot), desc: spec.desc === true,
2227
+ let target;
2228
+ if (key.kind === 'var' && key.external !== true && keySlot.has(key.slot))
2229
+ target = { index: keySlot.get(key.slot) };
2230
+ else {
2231
+ // SQL SUM/AVG may accumulate in a different order (SQLite also
2232
+ // compensates rounding). Such a value cannot safely decide group
2233
+ // order. Counts and extrema have no accumulation-order ambiguity.
2234
+ if (!['$count', '$min', '$max'].includes(key.name)) return null;
2235
+ const aggregate = key.kind === 'op' ? build(key) : null;
2236
+ if (aggregate?.p !== 'agg') return null;
2237
+ target = { aggregate: aggregate.index };
2238
+ }
2239
+ terms.push({ ...target, desc: spec.desc === true,
2182
2240
  nullsFirst: (spec.emptyGreatest === true) === (spec.desc === true) });
2183
2241
  }
2184
2242
  return terms;
@@ -2187,16 +2245,14 @@ function groupOrder(orderby, keySlot) {
2187
2245
  /**
2188
2246
  * The projection TREE one `$return` compiles to, or `null` when the
2189
2247
  * shape is not one the plan can rebuild. Object and array constructors,
2190
- * literals and singular member paths compose; anything else — a
2191
- * function call, a conditional, a dynamic member, a reference to the
2192
- * binding itself — refuses the WHOLE projection, because a projection
2248
+ * literals, whole collection bindings and singular member paths compose;
2249
+ * a function call, conditional or dynamic member refuses the WHOLE projection, because a projection
2193
2250
  * that dropped part of what the caller asked for would be a wrong
2194
2251
  * answer, not a partial one.
2195
2252
  *
2196
2253
  * Distinct paths are collected once: a path named twice is one fetched
2197
- * column and two leaves pointing at it. A projection with NO path is
2198
- * refused too a statement needs a column to select, and a projection
2199
- * of pure literals has nothing the database could contribute.
2254
+ * column and two leaves pointing at it. A constant tree needs only a
2255
+ * row marker from SQL: the selection contributes its cardinality.
2200
2256
  * @param {any} node - the `$return` AST node
2201
2257
  * @param {number} itSlot
2202
2258
  * @param {any} shape
@@ -2210,8 +2266,9 @@ function projectionTree(node, itSlot, shape) {
2210
2266
  const build = (child) => {
2211
2267
  assertDecidedKind(child);
2212
2268
  if (child.kind === 'literal') return { p: 'lit', value: child.value };
2213
- if (child.kind === 'path') {
2214
- const ref = pathRef(child, itSlot, shape);
2269
+ if (child.kind === 'path' || isItVar(child, itSlot)) {
2270
+ const ref = isItVar(child, itSlot)
2271
+ ? { segments: [], type: 'unknown', column: null } : pathRef(child, itSlot, shape);
2215
2272
  if (ref === null) return null;
2216
2273
  const canonical = canonicalOf(ref.segments);
2217
2274
  let index = byCanonical.get(canonical);
@@ -2243,7 +2300,7 @@ function projectionTree(node, itSlot, shape) {
2243
2300
  return null;
2244
2301
  };
2245
2302
  const tree = build(node);
2246
- return tree === null || leaves.length === 0 ? null : { tree, leaves };
2303
+ return tree === null ? null : { tree, leaves };
2247
2304
  }
2248
2305
 
2249
2306
  /**
@@ -2581,10 +2638,16 @@ function planCollectionCore(document, shape, options = undefined) {
2581
2638
  // pushable at all — a SQL fold over zero rows never sees a second
2582
2639
  // operand, and `aggregateSpec` refuses the declaration at open — so
2583
2640
  // the recognizer here reads the phrase and nothing else
2641
+ const distinct = root.kind === 'op' && root.name === '$distinct';
2642
+ if (distinct) {
2643
+ root = root.args[0];
2644
+ rawInner = rawInner?.$distinct;
2645
+ assertDecidedKind(root);
2646
+ }
2584
2647
  let aggregate = null;
2585
2648
  const registeredAggregate = root.kind === 'op' && !AGGREGATES.has(root.name)
2586
2649
  ? (options?.aggregate?.(root.name) ?? null) : null;
2587
- if (root.kind === 'op' && (AGGREGATES.has(root.name) || registeredAggregate !== null)) {
2650
+ if (!distinct && root.kind === 'op' && (AGGREGATES.has(root.name) || registeredAggregate !== null)) {
2588
2651
  if (windows.length > 0) {
2589
2652
  return {
2590
2653
  analysis, plan: null, mode: 'set',
@@ -2603,7 +2666,7 @@ function planCollectionCore(document, shape, options = undefined) {
2603
2666
  // a document that IS a series operator over the collection: the
2604
2667
  // operand's own conjuncts (and what the frozen spec implies) narrow
2605
2668
  // through the index, and the kernel decides over what comes back
2606
- if (aggregate === null && root.kind === 'op' && SERIES_ROOT_OPS.includes(root.name)) {
2669
+ if (!distinct && aggregate === null && root.kind === 'op' && SERIES_ROOT_OPS.includes(root.name)) {
2607
2670
  const temporal = planSeriesOperator(root, shape);
2608
2671
  if (temporal !== null) {
2609
2672
  // a peeled `$subsequence` composes as it does everywhere — over a
@@ -2637,6 +2700,29 @@ function planCollectionCore(document, shape, options = undefined) {
2637
2700
  const { plan } = flwor;
2638
2701
  const fullyPushed = flwor.whereFullyPushed && flwor.orderPushed;
2639
2702
 
2703
+ if (distinct) {
2704
+ const ref = flwor.projectedPath;
2705
+ const sameOrder = root.orderby === null || (ref !== null && plan.order !== null
2706
+ && plan.order.every((term) => canonicalOf(term.ref.segments) === canonicalOf(ref.segments)));
2707
+ if (fullyPushed && sameOrder && ref !== null && ref.type !== 'unknown'
2708
+ && flwor.group === null && flwor.bucket === null) {
2709
+ // DISTINCT and GROUP BY share the query language's key relation.
2710
+ // An absent path contributes no item, including under a window.
2711
+ plan.filter = conjoin(plan.filter, { p: 'typeIs', ref, types: [], positive: true });
2712
+ plan.group = { keys: [{ as: 'distinct', ref }], aggregates: [],
2713
+ tree: { p: 'key', index: 0 }, order: root.orderby === null ? 'first-seen'
2714
+ : plan.order.map((term) => ({ index: 0, desc: term.desc,
2715
+ nullsFirst: (term.emptyGreatest === true) === (term.desc === true) })) };
2716
+ plan.order = null;
2717
+ plan.window = windows.length === 0 ? null : composeWindows(windows);
2718
+ return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
2719
+ udfs: flwor.udfs, prefilters: flwor.prefilters, series: null };
2720
+ }
2721
+ return { analysis, plan: null, mode: 'set',
2722
+ reasons: [refusal('$distinct', PLAN_REASONS.distinctProjection)],
2723
+ rowReturn: null, udfs: [], prefilters: [], series: null };
2724
+ }
2725
+
2640
2726
  if (flwor.knn !== null) {
2641
2727
  // the k-nearest mode: the recognized ordering under a window with
2642
2728
  // a finite limit, composed exactly as pushed windows are. The plan
@@ -2661,6 +2747,14 @@ function planCollectionCore(document, shape, options = undefined) {
2661
2747
  rowReturn: null, udfs: [], prefilters: flwor.prefilters, series: null };
2662
2748
  }
2663
2749
  if (flwor.bucket !== null || flwor.group !== null || flwor.bucketRefusal != null) {
2750
+ if (aggregate.fn === 'count' && flwor.group !== null
2751
+ && ['object', 'array', 'lit'].includes(flwor.group.tree.p)
2752
+ && flwor.group.aggregates.every((entry) => entry.fn === 'rows')) {
2753
+ plan.group = flwor.group;
2754
+ plan.aggregate = { fn: 'count', ref: null };
2755
+ return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
2756
+ udfs: flwor.udfs, prefilters: flwor.prefilters, series: null };
2757
+ }
2664
2758
  // the phrase's items are its GROUPS; a COUNT(*) over the rows
2665
2759
  // answered the row count for a `$count` of the groups
2666
2760
  return {
@@ -2722,7 +2816,10 @@ function planCollectionCore(document, shape, options = undefined) {
2722
2816
  // the temporal bucket: only over a WHOLE pushed selection, because a
2723
2817
  // conjunct the residual would still apply would arrive after the rows
2724
2818
  // were already summed
2725
- if (flwor.group !== null && fullyPushed && windows.length === 0 && aggregate === null) {
2819
+ if (flwor.group !== null && fullyPushed && aggregate === null
2820
+ && (windows.length === 0 || ['object', 'array', 'lit'].includes(flwor.group.tree.p)
2821
+ || (flwor.group.tree.p === 'agg'
2822
+ && flwor.group.aggregates[flwor.group.tree.index].empty === 'zero'))) {
2726
2823
  plan.group = flwor.group;
2727
2824
  return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
2728
2825
  udfs: flwor.udfs, prefilters: flwor.prefilters, series: null };
@@ -2759,7 +2856,13 @@ function planCollectionCore(document, shape, options = undefined) {
2759
2856
 
2760
2857
  if (!groupedResidual && fullyPushed && flwor.projectionNative
2761
2858
  && (windows.length === 0 || plan.window !== null)) {
2762
- if (flwor.projectedPath !== null) plan.project = { path: flwor.projectedPath };
2859
+ if (flwor.projectedPath !== null) {
2860
+ plan.project = { path: flwor.projectedPath };
2861
+ // A path yields no item for an absent member. Windows count the
2862
+ // projected items, so discard those rows before applying LIMIT.
2863
+ if (windows.length > 0) plan.filter = conjoin(plan.filter,
2864
+ { p: 'typeIs', ref: flwor.projectedPath, types: [], positive: true });
2865
+ }
2763
2866
  else if (flwor.projectedTree !== null) plan.project = flwor.projectedTree;
2764
2867
  return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
2765
2868
  udfs: flwor.udfs, prefilters: flwor.prefilters,
@@ -2768,7 +2871,7 @@ function planCollectionCore(document, shape, options = undefined) {
2768
2871
 
2769
2872
  // the row residual: everything but the projection pushed
2770
2873
  if (!groupedResidual && fullyPushed && !flwor.projectionNative
2771
- && (windows.length === 0 || plan.window !== null)) {
2874
+ && windows.length === 0) {
2772
2875
  const rawFlwor = rawInner;
2773
2876
  const name = flwor.itName ?? 'it';
2774
2877
  return {
@@ -2879,10 +2982,14 @@ export function entityShape(entity, entityMapping) {
2879
2982
  for (const column of entityMapping.columns) {
2880
2983
  const epoch = column.source === 'epoch(document)';
2881
2984
  flavors.set(canonicalOf([{ name: column.name }]), {
2882
- column: column.name,
2985
+ column: column.physical ?? column.name,
2883
2986
  flavor: epoch ? 'entity-epoch' : 'entity-column',
2884
2987
  storage: column.storage,
2988
+ codec: column.codec,
2989
+ codecColumn: column.codec === undefined ? undefined : column,
2990
+ nullPolicy: column.null,
2885
2991
  format: epoch ? entity.properties.get(column.name)?.format : undefined,
2992
+ unsafe: column.codec !== undefined && !['text', 'integer', 'number', 'boolean', 'date', 'datetime'].includes(column.codec),
2886
2993
  });
2887
2994
  }
2888
2995
  for (const fk of entityMapping.foreignKeys) {
@@ -2895,6 +3002,7 @@ export function entityShape(entity, entityMapping) {
2895
3002
  schema: entity.schema,
2896
3003
  columnByCanonical: new Map(),
2897
3004
  entityFlavors: flavors,
3005
+ columnOnly: entityMapping.document === false,
2898
3006
  };
2899
3007
  }
2900
3008
 
@@ -2911,6 +3019,7 @@ export function entityPathRef(node, slot, shape) {
2911
3019
  if (ref === null) return null;
2912
3020
  const canonical = canonicalOf(ref.segments);
2913
3021
  const flavored = shape.entityFlavors.get(canonical);
3022
+ if (flavored?.unsafe || (shape.columnOnly && flavored === undefined)) return null;
2914
3023
  if (flavored !== undefined) {
2915
3024
  return {
2916
3025
  ...ref,
@@ -2918,6 +3027,9 @@ export function entityPathRef(node, slot, shape) {
2918
3027
  flavor: flavored.flavor,
2919
3028
  storage: flavored.storage,
2920
3029
  format: flavored.format,
3030
+ nullPolicy: flavored.nullPolicy,
3031
+ codec: flavored.codec,
3032
+ codecColumn: flavored.codecColumn,
2921
3033
  };
2922
3034
  }
2923
3035
  // a nested path rides the JSONB document with the phase-A guards;
@@ -2930,20 +3042,54 @@ export function entityPathRef(node, slot, shape) {
2930
3042
  * collection projection composes — objects, arrays, literals and
2931
3043
  * singular member paths — with each leaf carrying the BINDING it reads
2932
3044
  * from, so the statement extracts it from that binding's alias. `null`
2933
- * when the shape is not one the plan can rebuild, and a projection with
2934
- * no path at all is refused for the same reason a collection's is: a
2935
- * statement needs a column to select.
3045
+ * when the shape is not one the plan can rebuild. A constant tree
3046
+ * fetches a row marker, preserving the selection's cardinality.
2936
3047
  * @param {any} node - the `$return` AST node
2937
3048
  * @param {Map<number, any>} byName - binding slot → binding
2938
3049
  * @returns {{ tree: any, leaves: { binding: string, ref: any }[] } | null}
2939
3050
  */
2940
- function entityProjectionTree(node, byName) {
3051
+ function entityProjectionTree(node, byName, entities, mapping) {
2941
3052
  const leaves = [];
2942
3053
  /** @type {Map<string, number>} */
2943
3054
  const byCanonical = new Map();
2944
3055
  const build = (child) => {
2945
3056
  assertDecidedKind(child);
2946
3057
  if (child.kind === 'literal') return { p: 'lit', value: child.value };
3058
+ if (child.kind === 'op' && child.name === '$count') {
3059
+ const inner = child.args[0];
3060
+ if (inner?.kind !== 'flwor' || inner.forBindings.length !== 1 || inner.groupby !== null
3061
+ || inner.fold !== null || inner.orderby !== null || inner.letBindings.length
3062
+ || inner.asChecks !== null || inner.count !== null) return null;
3063
+ const binding = inner.forBindings[0];
3064
+ const entity = bindingEntity(binding, entities);
3065
+ if (entity === null || binding.window !== null || binding.atSlot !== -1 || binding.allowingEmpty
3066
+ || inner.ret.kind !== 'var' || inner.ret.slot !== binding.slot) return null;
3067
+ const shape = entityShape(entities.get(entity), mapping.entities[entity]);
3068
+ const innerBinding = { name: binding.name, slot: binding.slot, entity, shape };
3069
+ const scope = new Map([...byName, [binding.slot, innerBinding]]);
3070
+ const conjuncts = inner.where?.kind === 'op' && inner.where.name === '$and' ? inner.where.args : [inner.where];
3071
+ const edges = [];
3072
+ let filter = null;
3073
+ for (const conjunct of conjuncts) {
3074
+ if (conjunct === null) continue;
3075
+ const cross = conjunct.kind === 'op' && conjunct.name === '$eq'
3076
+ ? crossBindingComparison(conjunct, scope) : null;
3077
+ if (cross !== null && (cross.left.binding === innerBinding || cross.right.binding === innerBinding)) {
3078
+ edges.push({ left: { binding: cross.left.binding.name, ref: cross.left.ref },
3079
+ right: { binding: cross.right.binding.name, ref: cross.right.ref } });
3080
+ continue;
3081
+ }
3082
+ const slots = new Set(); collectBindingSlots(conjunct, scope, slots);
3083
+ if (slots.size !== 1 || !slots.has(binding.slot)) return null;
3084
+ const planned = planEntityPredicate(conjunct, binding.slot, shape);
3085
+ if ('refusal' in planned) return null;
3086
+ filter = conjoin(filter, planned.pred);
3087
+ }
3088
+ if (!edges.length) return null;
3089
+ const index = leaves.length;
3090
+ leaves.push({ count: { entity, binding: binding.name, edges, filter } });
3091
+ return { p: 'leaf', index };
3092
+ }
2947
3093
  if (child.kind === 'path') {
2948
3094
  const binding = child.external === true ? undefined : byName.get(child.rootSlot);
2949
3095
  if (binding === undefined) return null;
@@ -2979,7 +3125,7 @@ function entityProjectionTree(node, byName) {
2979
3125
  return null;
2980
3126
  };
2981
3127
  const tree = build(node);
2982
- return tree === null || leaves.length === 0 ? null : { tree, leaves };
3128
+ return tree === null ? null : { tree, leaves };
2983
3129
  }
2984
3130
 
2985
3131
  /**
@@ -3037,9 +3183,17 @@ export function planEntityPredicate(node, slot, shape) {
3037
3183
  if (pred.p === 'and' || pred.p === 'or')
3038
3184
  return { ...pred, items: pred.items.map(reflavor) };
3039
3185
  if (pred.p === 'not') return { ...pred, item: reflavor(pred.item) };
3186
+ if (pred.p === 'refCmp') {
3187
+ const flavor = (ref) => reflavor({ p: 'typeIs', ref, types: [], positive: true }).ref;
3188
+ return { ...pred, left: flavor(pred.left), right: flavor(pred.right) };
3189
+ }
3040
3190
  if (!('ref' in pred) || pred.ref === null) return pred;
3041
3191
  const canonical = canonicalOf(pred.ref.segments);
3042
3192
  const flavored = shape.entityFlavors.get(canonical);
3193
+ if (shape.columnOnly && (flavored === undefined || flavored.unsafe || flavored.nullPolicy === 'null')) {
3194
+ blocked = refusal('physical', PREDICATE_REASONS.physicalCodec);
3195
+ return pred;
3196
+ }
3043
3197
  if (flavored === undefined) {
3044
3198
  // externals against DOC paths are not translated here (the
3045
3199
  // phase-A external forms assume the collection layout)
@@ -3049,7 +3203,8 @@ export function planEntityPredicate(node, slot, shape) {
3049
3203
  return { ...pred, ref: { ...pred.ref, flavor: 'entity-doc' } };
3050
3204
  }
3051
3205
  const ref = { ...pred.ref, column: flavored.column,
3052
- flavor: flavored.flavor, storage: flavored.storage, format: flavored.format };
3206
+ flavor: flavored.flavor, storage: flavored.storage, format: flavored.format,
3207
+ codec: flavored.codec, codecColumn: flavored.codecColumn, nullPolicy: flavored.nullPolicy };
3053
3208
  if (flavored.flavor === 'entity-epoch' && pred.p === 'cmp') {
3054
3209
  if ('ext' in pred.operand) {
3055
3210
  blocked = refusal(pred.op, ENTITY_REASONS.external);
@@ -3122,7 +3277,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3122
3277
  if (root.kind !== 'flwor')
3123
3278
  return residual(root.kind, ENTITY_REASONS.notFlwor);
3124
3279
  if (root.fold !== null || root.letBindings.length > 0 || root.asChecks !== null
3125
- || root.groupby !== null || root.count !== null)
3280
+ || root.count !== null)
3126
3281
  return residual('$let', KIND_REASONS.let);
3127
3282
 
3128
3283
  // bindings must each range over one entity's array
@@ -3140,6 +3295,19 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3140
3295
  });
3141
3296
  }
3142
3297
  const byName = new Map(bindings.map((binding) => [binding.slot, binding]));
3298
+ const group = root.groupby === null ? null : bindings.length === 1 && aggregate === null && !windows.length
3299
+ ? planGeneralGrouping(root, bindings[0].slot, bindings[0].shape, entityPathRef) : null;
3300
+ if (root.groupby !== null && group === null) return residual('$groupby', ENTITY_REASONS.projection);
3301
+ if (group && [...group.keys, ...group.aggregates].some((entry) => entry.ref && entry.ref.flavor !== 'entity-column'))
3302
+ return residual('$groupby', ENTITY_REASONS.projection);
3303
+ // Integer accumulations carry a runtime exactness proof. Floating
3304
+ // accumulations and date-codec validation need the decoded evaluator.
3305
+ if (group && [...group.keys, ...group.aggregates].some((entry) => entry.ref
3306
+ && (['date', 'datetime'].includes(entry.ref.codec)
3307
+ || (['sum', 'avg'].includes(entry.fn) && entry.ref.type !== 'integer'))))
3308
+ return residual('$groupby', ENTITY_REASONS.projection);
3309
+ if (group?.order === 'first-seen' && entities.get(bindings[0].entity).physical?.keys.length > 1)
3310
+ return residual('$groupby', ENTITY_REASONS.groupOrder);
3143
3311
  const conjuncts = root.where === null
3144
3312
  ? []
3145
3313
  : root.where.kind === 'op' && root.where.name === '$and'
@@ -3155,6 +3323,29 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3155
3323
  * that turns a product into a join. */
3156
3324
  const refinements = [];
3157
3325
  const filters = new Map(bindings.map((binding) => [binding.slot, null]));
3326
+ const scopedFilters = [];
3327
+ // Boolean composition may span bindings once mandatory equijoin
3328
+ // edges establish the candidate tuples. Each leaf still has exactly
3329
+ // one owner and uses that binding's existing total predicate forms.
3330
+ const scopedPredicate = (node) => {
3331
+ if (node.kind === 'op' && (node.name === '$and' || node.name === '$or')) {
3332
+ const items = node.args.map(scopedPredicate);
3333
+ return items.some((item) => item === null) ? null
3334
+ : { p: node.name === '$and' ? 'and' : 'or', items };
3335
+ }
3336
+ if (node.kind === 'op' && node.name === '$not') {
3337
+ const item = scopedPredicate(node.args[0]);
3338
+ return item === null ? null : { p: 'not', item };
3339
+ }
3340
+ const slots = new Set();
3341
+ collectBindingSlots(node, byName, slots);
3342
+ if (slots.size !== 1) return null;
3343
+ const slot = [...slots][0];
3344
+ const binding = byName.get(slot);
3345
+ const outcome = planEntityPredicate(node, slot, binding.shape);
3346
+ return 'refusal' in outcome ? null
3347
+ : { p: 'binding', binding: binding.name, filter: outcome.pred };
3348
+ };
3158
3349
  const reasons = [];
3159
3350
  let whereFullyPushed = true;
3160
3351
  for (const conjunct of conjuncts) {
@@ -3190,6 +3381,8 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3190
3381
  const slots = new Set();
3191
3382
  collectBindingSlots(conjunct, byName, slots);
3192
3383
  if (slots.size !== 1) {
3384
+ const scoped = scopedPredicate(conjunct);
3385
+ if (scoped !== null) { scopedFilters.push(scoped); continue; }
3193
3386
  reasons.push(refusal('$where', ENTITY_REASONS.conjunctBinding));
3194
3387
  whereFullyPushed = false;
3195
3388
  continue;
@@ -3255,16 +3448,22 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3255
3448
  // SHAPE the projection tree rebuilds from the bindings' members
3256
3449
  const retBinding = root.ret.kind === 'var' && root.ret.external !== true
3257
3450
  ? byName.get(root.ret.slot) : undefined;
3258
- const projection = retBinding === undefined && aggregate === null
3259
- ? entityProjectionTree(root.ret, byName) : null;
3260
- if (retBinding === undefined && projection === null) {
3451
+ const projection = retBinding === undefined && group === null
3452
+ ? entityProjectionTree(root.ret, byName, entities, mapping) : null;
3453
+ if (retBinding === undefined && projection === null && group === null) {
3261
3454
  reasons.push(refusal('$return', ENTITY_REASONS.projection));
3262
3455
  }
3456
+ if (projection?.tree.p === 'leaf' && !projection.leaves[projection.tree.index].count && (aggregate === 'count' || windows.length > 0)) {
3457
+ const leaf = projection.leaves[projection.tree.index];
3458
+ const binding = bindings.find((entry) => entry.name === leaf.binding);
3459
+ filters.set(binding.slot, conjoin(filters.get(binding.slot),
3460
+ { p: 'typeIs', ref: leaf.ref, types: [], positive: true }));
3461
+ }
3263
3462
 
3264
3463
  // ordering over flavored refs of either binding
3265
3464
  let order = null;
3266
3465
  let orderPushed = true;
3267
- if (root.orderby !== null) {
3466
+ if (root.orderby !== null && group === null) {
3268
3467
  const terms = [];
3269
3468
  for (const spec of root.orderby.specs) {
3270
3469
  const slot = spec.key.kind === 'path' ? spec.key.rootSlot : -1;
@@ -3276,6 +3475,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3276
3475
  // (a COLUMN stores it absent, §9.3, so a nullable column pushes)
3277
3476
  if (ref === null || (ref.flavor === 'entity-doc' && ref.type === 'unknown')
3278
3477
  || ref.type === 'boolean'
3478
+ || ref.nullPolicy === 'null'
3279
3479
  || (ref.flavor === 'entity-doc' && admitsNull(binding.shape.schema, ref.segments))
3280
3480
  || spec.collation !== null || spec.collationName !== null) {
3281
3481
  orderPushed = false;
@@ -3288,10 +3488,14 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3288
3488
  }
3289
3489
 
3290
3490
  const fullyPushed = whereFullyPushed && orderPushed
3291
- && (retBinding !== undefined || projection !== null);
3491
+ && (retBinding !== undefined || projection !== null || group !== null);
3292
3492
  if (!fullyPushed) {
3293
3493
  return { analysis, mode: 'set', plan: null, referenced, reasons };
3294
3494
  }
3495
+ for (const filter of scopedFilters) {
3496
+ const slot = bindings[0].slot;
3497
+ filters.set(slot, conjoin(filters.get(slot), filter));
3498
+ }
3295
3499
 
3296
3500
  let window = null;
3297
3501
  if (windows.length > 0) {
@@ -3314,7 +3518,11 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3314
3518
  plan: {
3315
3519
  planVersion: PLAN_VERSION,
3316
3520
  alg: bindings.length > 1 ? 'entity-join' : 'entity-select',
3317
- bindings: bindings.map((binding) => ({ name: binding.name, entity: binding.entity })),
3521
+ bindings: bindings.map((binding) => ({ name: binding.name, entity: binding.entity,
3522
+ ...(entities.get(binding.entity).physical == null ? {} : {
3523
+ keys: mapping.entities[binding.entity].keys.map((key) =>
3524
+ mapping.entities[binding.entity].columns.find((c) => c.name === key).physical),
3525
+ }) })),
3318
3526
  // the FROM order and each binding's join conditions; `bindings`
3319
3527
  // stays in the DOCUMENT's order, which is the nested-loop order
3320
3528
  // the ORDER BY reproduces
@@ -3322,8 +3530,10 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3322
3530
  binding: join.binding,
3323
3531
  on: join.on.map((edge) => ({
3324
3532
  op: edge.op ?? 'eq',
3325
- left: { binding: edge.left.binding.name, column: edge.left.ref.column },
3326
- right: { binding: edge.right.binding.name, column: edge.right.ref.column },
3533
+ left: { binding: edge.left.binding.name, column: edge.left.ref.column,
3534
+ ...(edge.left.ref.codec === undefined ? {} : { codec: edge.left.ref.codec }) },
3535
+ right: { binding: edge.right.binding.name, column: edge.right.ref.column,
3536
+ ...(edge.right.ref.codec === undefined ? {} : { codec: edge.right.ref.codec }) },
3327
3537
  })),
3328
3538
  })),
3329
3539
  filters: bindings.map((binding) => ({
@@ -3336,12 +3546,13 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3336
3546
  })),
3337
3547
  window,
3338
3548
  aggregate,
3549
+ group,
3339
3550
  ret: retBinding === undefined ? null : retBinding.name,
3340
3551
  // the projected shape, when the return is one: leaves that name
3341
3552
  // the binding they read from, and the tree the decoder rebuilds
3342
3553
  project: projection === null ? null : {
3343
3554
  tree: projection.tree,
3344
- leaves: projection.leaves.map((leaf) => ({ binding: leaf.binding, ref: leaf.ref })),
3555
+ leaves: projection.leaves,
3345
3556
  },
3346
3557
  },
3347
3558
  };