@jarenjs/db 0.75.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 (50) hide show
  1. package/ARCHITECTURE.md +20 -0
  2. package/README.md +25 -0
  3. package/docs/HOSTS.md +17 -0
  4. package/docs/JOBS-FORMAT.md +26 -0
  5. package/docs/LIVE-FORMAT.md +4 -0
  6. package/docs/MIGRATION-FORMAT.md +34 -0
  7. package/docs/MODEL-FORMAT.md +132 -1
  8. package/docs/NATIVE-PLANS.md +111 -0
  9. package/docs/SEARCH.md +55 -0
  10. package/package.json +8 -4
  11. package/schemas/jaren-migration.draft-07.schema.json +54 -5
  12. package/schemas/jaren-migration.schema.json +49 -0
  13. package/schemas/jaren-model.authoring.schema.json +360 -0
  14. package/schemas/jaren-model.draft-07.schema.json +128 -0
  15. package/schemas/jaren-model.schema.json +128 -0
  16. package/src/algebra.js +17 -1
  17. package/src/backup.js +12 -7
  18. package/src/cursor.js +27 -4
  19. package/src/dag-job.js +2 -1
  20. package/src/ddl.js +13 -0
  21. package/src/dialect.js +10 -0
  22. package/src/dialects/check-read.js +3 -3
  23. package/src/dialects/invariant-sql.js +117 -0
  24. package/src/dialects/postgres.js +14 -2
  25. package/src/dialects/sqlite.js +18 -2
  26. package/src/driver.js +1 -0
  27. package/src/drivers/bun.js +22 -4
  28. package/src/emit.js +80 -13
  29. package/src/entity.js +98 -41
  30. package/src/errors.js +8 -0
  31. package/src/graph.js +8 -1
  32. package/src/index.js +3 -0
  33. package/src/introspect.js +44 -7
  34. package/src/invariants.js +45 -0
  35. package/src/jobs.js +39 -6
  36. package/src/live.js +4 -1
  37. package/src/migrate.js +136 -22
  38. package/src/model.js +12 -0
  39. package/src/mutation.js +165 -0
  40. package/src/physical.js +147 -0
  41. package/src/plan.js +114 -20
  42. package/src/query.js +144 -70
  43. package/src/search.js +144 -0
  44. package/src/sql.js +60 -0
  45. package/src/store.js +49 -13
  46. package/src/tracker.js +63 -39
  47. package/src/window.js +1 -0
  48. package/types/index.d.ts +58 -3
  49. package/types/search.d.ts +20 -0
  50. package/types/typed.d.ts +1 -0
@@ -0,0 +1,147 @@
1
+ //@ts-check
2
+ /** Explicit column layouts and lossless JSON-facing column codecs. */
3
+ import { getEpochOfDateTimeRFC3339, getEpochOfDateOnlyRFC3339 } from '@jarenjs/core/dates/rfc3339';
4
+ import { DbCompileError, DbRuntimeError } from './errors.js';
5
+ import { chain } from './driver.js';
6
+ import { canonicalizeJson } from '@jarenjs/json/canonical';
7
+
8
+ const compiledCodecs = new WeakMap();
9
+ const CODECS = new Set(['text', 'integer', 'number', 'boolean', 'json', 'date', 'datetime', 'epoch-ms', 'bigint', 'decimal', 'blob-hex']);
10
+ const STORAGE = { text: 'string', integer: 'integer', number: 'number', boolean: 'boolean',
11
+ json: 'string', date: 'string', datetime: 'string', 'epoch-ms': 'integer', bigint: 'integer', decimal: 'string', 'blob-hex': 'string' };
12
+ const identifier = (v) => typeof v === 'string' && v.length > 0 && !v.includes('\0');
13
+
14
+ /** Normalize a declared layout; implicit conversions are never adoption policy.
15
+ * @param {any} physical @param {Map<string, any>} properties @param {string[]} keys
16
+ * @param {string} path @returns {any} */
17
+ export function normalizePhysical(physical, properties, keys, path) {
18
+ if (physical === undefined) return null;
19
+ const fail = (reason) => { throw new DbCompileError('JD0005', reason, `${path}/physical`); };
20
+ if (!physical || typeof physical !== 'object' || Array.isArray(physical)) fail('physical must be an object');
21
+ for (const key of Object.keys(physical))
22
+ if (!['table', 'kind', 'keys', 'columns'].includes(key)) fail(`unknown physical member '${key}'`);
23
+ if (!identifier(physical.table)) fail('physical.table must be a nonempty SQL identifier');
24
+ if (physical.kind !== undefined && !['table', 'view'].includes(physical.kind)) fail('physical.kind is table or view');
25
+ if (!physical.columns || typeof physical.columns !== 'object' || Array.isArray(physical.columns)) fail('physical.columns is required');
26
+ const ordered = physical.keys ?? keys;
27
+ if (!Array.isArray(ordered) || ordered.length !== keys.length || new Set(ordered).size !== keys.length
28
+ || ordered.some((key) => !keys.includes(key))) fail('physical.keys must order every declared key exactly once');
29
+ const used = new Set();
30
+ const columns = [];
31
+ for (const [name, property] of properties) {
32
+ if (property.relation) continue;
33
+ const c = physical.columns[name];
34
+ if (!c || typeof c !== 'object' || Array.isArray(c) || !CODECS.has(c.codec)) fail(`'${name}' needs an explicit supported column codec`);
35
+ for (const key of Object.keys(c))
36
+ if (!['name', 'codec', 'null', 'default', 'generated'].includes(key)) fail(`unknown column member '${key}'`);
37
+ if (!identifier(c.name) || used.has(c.name.toLowerCase())) fail(`'${name}' needs a distinct physical column name`);
38
+ if (!['null', 'absent', 'reject'].includes(c.null)) fail(`'${name}' must declare SQL NULL as null, absent or reject`);
39
+ if (c.default !== undefined && c.default !== 'database') fail('column default ownership is database');
40
+ if (c.generated !== undefined && typeof c.generated !== 'boolean') fail('generated must be boolean');
41
+ if (property.key && (!['text', 'integer', 'bigint'].includes(c.codec) || c.null !== 'reject')) fail('keys require non-null text, integer or bigint codecs');
42
+ if (property.column !== undefined) fail('physical codecs replace hybrid column overrides');
43
+ if (c.default === 'database' && property.default !== undefined && property.default !== 'auto') fail('a default has exactly one owner');
44
+ used.add(c.name.toLowerCase());
45
+ columns.push({ name, physical: c.name, codec: c.codec, null: c.null, databaseDefault: c.default === 'database',
46
+ generated: c.generated === true, storage: STORAGE[c.codec], source: 'column', key: property.key });
47
+ }
48
+ for (const name of Object.keys(physical.columns))
49
+ if (!properties.has(name) || properties.get(name).relation) fail(`column '${name}' is not a stored property`);
50
+ return { table: physical.table, kind: physical.kind ?? 'table', keys: [...ordered], columns };
51
+ }
52
+
53
+ /** Compile one codec once, with a JSON-safe public value and a bound SQL value.
54
+ * @param {any} column @returns {{ encode: Function, decode: Function, normalize: Function }} */
55
+ export function columnCodec(column) {
56
+ if (compiledCodecs.has(column)) return compiledCodecs.get(column);
57
+ const fail = () => { throw new DbRuntimeError('JD2003', `column '${column.name}' refuses a lossy or invalid ${column.codec} value`); };
58
+ const date = (v, only) => typeof v === 'string' && Number.isFinite(only
59
+ ? getEpochOfDateOnlyRFC3339(v) : getEpochOfDateTimeRFC3339(v));
60
+ const convert = (v, reading) => {
61
+ if (v === undefined || v === null) {
62
+ if (!reading && v === null && column.codec === 'json') return 'null';
63
+ if (column.null === 'reject') return fail();
64
+ return reading ? (column.null === 'absent' ? undefined : null) : null;
65
+ }
66
+ switch (column.codec) {
67
+ case 'text': if (typeof v === 'string') return v; break;
68
+ case 'integer': if (typeof v === 'number' && Number.isSafeInteger(v)) return v; break;
69
+ case 'number': if (typeof v === 'number' && Number.isFinite(v) && (!Number.isInteger(v) || Number.isSafeInteger(v))) return v; break;
70
+ case 'boolean':
71
+ if (reading && (v === 0 || v === 1 || typeof v === 'boolean')) return v === 1 || v === true;
72
+ if (!reading && typeof v === 'boolean') return v ? 1 : 0;
73
+ break;
74
+ case 'bigint': if (typeof v === 'string' && /^(?:0|-?[1-9][0-9]*)$/.test(v)
75
+ && BigInt(v) >= -9223372036854775808n && BigInt(v) <= 9223372036854775807n) return v; break;
76
+ case 'decimal': if (typeof v === 'string' && /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(v)) return v; break;
77
+ case 'date': if (date(v, true)) return v; break;
78
+ case 'datetime': if (date(v, false)) return v; break;
79
+ case 'epoch-ms':
80
+ if (reading && typeof v === 'number' && Number.isSafeInteger(v) && Number.isFinite(new Date(v).getTime())) return new Date(v).toISOString();
81
+ if (!reading && date(v, false) && new Date(v).toISOString() === v) return getEpochOfDateTimeRFC3339(v);
82
+ break;
83
+ case 'blob-hex':
84
+ if (typeof v === 'string' && /^(?:[0-9a-fA-F]{2})*$/.test(v))
85
+ return reading ? v.toLowerCase() : Uint8Array.from(v.match(/../g) ?? [], (b) => parseInt(b, 16));
86
+ break;
87
+ case 'json':
88
+ try {
89
+ if (reading) return JSON.parse(v);
90
+ canonicalizeJson(v);
91
+ const text = JSON.stringify(v);
92
+ if (text !== undefined && JSON.stringify(JSON.parse(text)) === text) return text;
93
+ }
94
+ catch { return fail(); }
95
+ break;
96
+ }
97
+ return fail();
98
+ };
99
+ const codec = { encode: (v) => convert(v, false), decode: (v) => convert(v, true), normalize: (v) => {
100
+ const encoded = convert(v, false);
101
+ return column.codec === 'blob-hex' && encoded !== null ? v.toLowerCase() : convert(encoded, true);
102
+ } };
103
+ compiledCodecs.set(column, codec);
104
+ return codec;
105
+ }
106
+
107
+ /** SQL selection keeps unsafe integers and byte handles out of application state.
108
+ * @param {any} column @param {any} dialect @param {string} [prefix] @returns {string} */
109
+ export function physicalRead(column, dialect, prefix = '') {
110
+ const sql = `${prefix}${dialect.quoteIdentifier(column.physical ?? column.name)}`;
111
+ return dialect.physicalRead(column.codec, sql);
112
+ }
113
+
114
+ /** Verify an existing mapped object without executing DDL. Unmapped columns and
115
+ * all application-owned programs remain physical facts, never inferred drops.
116
+ * @param {any} connection @param {any} mapping @param {any} schema @returns {any} */
117
+ export function verifyPhysical(connection, mapping, schema) {
118
+ const fail = (why) => { throw new DbCompileError('JD0002', `physical '${mapping.table}': ${why}`); };
119
+ if (connection.dialect.name !== 'sqlite') fail('column adoption is qualified only for SQLite');
120
+ const object = schema.objects.find((o) => o.name === mapping.table && o.type === mapping.kind);
121
+ if (!object) fail(`declared ${mapping.kind} does not exist`);
122
+ for (const trigger of mapping.triggers ?? []) {
123
+ const actual = schema.objects.find((o) => o.type === 'trigger' && o.name === trigger.name);
124
+ if (!actual || actual.sql?.trim().replace(/;$/, '') !== trigger.sql.trim().replace(/;$/, '')) fail(`invariant trigger '${trigger.name}' is missing or changed; apply an explicit migration`);
125
+ }
126
+ const read = mapping.kind === 'view'
127
+ ? chain(connection.prepare(connection.dialect.introspect.columns(mapping.table)), (s) =>
128
+ chain(s.all([]), (columns) => ({ columns: columns.map((c) => ({ ...c, generated: !!c.hidden })), primaryKey: [] })))
129
+ : schema.tables.find((t) => t.name === mapping.table);
130
+ return chain(read, (table) => {
131
+ if (mapping.kind !== 'view' && JSON.stringify(table.primaryKey) !== JSON.stringify(mapping.keys.map((k) => mapping.columns.find((c) => c.name === k).physical))) fail('ordered primary key disagrees');
132
+ for (const column of mapping.columns) {
133
+ const actual = table.columns.find((c) => c.name === column.physical);
134
+ if (!actual) fail(`column '${column.physical}' does not exist`);
135
+ const type = actual.type.toUpperCase();
136
+ if (!connection.dialect.physicalTypeMatches(column.codec, type)) fail(`'${column.physical}' type ${type} cannot guarantee codec ${column.codec}`);
137
+ if (mapping.kind !== 'view' && actual.generated !== column.generated) fail(`'${column.physical}' generated ownership disagrees`);
138
+ }
139
+ return null;
140
+ });
141
+ }
142
+
143
+ /** Select mapped columns with their physical aliases for the shared row merger.
144
+ * @param {any} mapping @param {any} dialect @param {string} [prefix] @returns {string} */
145
+ export function physicalSelection(mapping, dialect, prefix = '') {
146
+ return mapping.columns.map((c) => `${physicalRead(c, dialect, prefix)} AS ${dialect.quoteIdentifier(c.physical ?? c.name)}`).join(', ');
147
+ }
package/src/plan.js CHANGED
@@ -95,6 +95,7 @@ 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)',
@@ -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
  };
@@ -2093,12 +2095,12 @@ function planSeriesOperator(root, shape) {
2093
2095
  * @param {any} shape
2094
2096
  * @returns {any | null}
2095
2097
  */
2096
- function planGeneralGrouping(node, itSlot, shape) {
2098
+ function planGeneralGrouping(node, itSlot, shape, resolve = pathRef) {
2097
2099
  const keys = [];
2098
2100
  /** @type {Map<number, number>} */
2099
2101
  const keySlot = new Map();
2100
2102
  for (const key of node.groupby.keys) {
2101
- const ref = pathRef(key.expr, itSlot, shape);
2103
+ const ref = resolve(key.expr, itSlot, shape);
2102
2104
  if (ref === null || ref.type === 'unknown')
2103
2105
  return null;
2104
2106
  keySlot.set(key.slot, keys.length);
@@ -2113,7 +2115,7 @@ function planGeneralGrouping(node, itSlot, shape) {
2113
2115
  if (child.kind === 'var' && child.external !== true && keySlot.has(child.slot))
2114
2116
  return { p: 'key', index: keySlot.get(child.slot) };
2115
2117
  if (child.kind === 'op') {
2116
- const entry = groupAggregate(child, itSlot, shape);
2118
+ const entry = groupAggregate(child, itSlot, shape, resolve);
2117
2119
  if (entry === null) return null;
2118
2120
  // one aggregate per distinct (function, path): two members that
2119
2121
  // ask the same question are one SQL aggregate
@@ -2167,14 +2169,36 @@ function planGeneralGrouping(node, itSlot, shape) {
2167
2169
  * @param {any} shape
2168
2170
  * @returns {{ as: string, fn: string, ref: any, empty: string } | null}
2169
2171
  */
2170
- 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
+ }
2171
2195
  if (node.name === '$count') {
2172
2196
  return isItVar(node.args[0], itSlot)
2173
2197
  ? { fn: 'rows', ref: null, empty: 'zero' } : null;
2174
2198
  }
2175
2199
  const fn = AGGREGATES.get(node.name);
2176
2200
  if (fn === undefined || fn === 'count') return null;
2177
- const ref = pathRef(node.args[0], itSlot, shape);
2201
+ const ref = resolve(node.args[0], itSlot, shape);
2178
2202
  const numeric = fn === 'sum' || fn === 'avg';
2179
2203
  const acceptable = ref !== null
2180
2204
  && (numeric ? isNumericType(ref.type) : ref.type !== 'unknown')
@@ -2958,10 +2982,14 @@ export function entityShape(entity, entityMapping) {
2958
2982
  for (const column of entityMapping.columns) {
2959
2983
  const epoch = column.source === 'epoch(document)';
2960
2984
  flavors.set(canonicalOf([{ name: column.name }]), {
2961
- column: column.name,
2985
+ column: column.physical ?? column.name,
2962
2986
  flavor: epoch ? 'entity-epoch' : 'entity-column',
2963
2987
  storage: column.storage,
2988
+ codec: column.codec,
2989
+ codecColumn: column.codec === undefined ? undefined : column,
2990
+ nullPolicy: column.null,
2964
2991
  format: epoch ? entity.properties.get(column.name)?.format : undefined,
2992
+ unsafe: column.codec !== undefined && !['text', 'integer', 'number', 'boolean', 'date', 'datetime'].includes(column.codec),
2965
2993
  });
2966
2994
  }
2967
2995
  for (const fk of entityMapping.foreignKeys) {
@@ -2974,6 +3002,7 @@ export function entityShape(entity, entityMapping) {
2974
3002
  schema: entity.schema,
2975
3003
  columnByCanonical: new Map(),
2976
3004
  entityFlavors: flavors,
3005
+ columnOnly: entityMapping.document === false,
2977
3006
  };
2978
3007
  }
2979
3008
 
@@ -2990,6 +3019,7 @@ export function entityPathRef(node, slot, shape) {
2990
3019
  if (ref === null) return null;
2991
3020
  const canonical = canonicalOf(ref.segments);
2992
3021
  const flavored = shape.entityFlavors.get(canonical);
3022
+ if (flavored?.unsafe || (shape.columnOnly && flavored === undefined)) return null;
2993
3023
  if (flavored !== undefined) {
2994
3024
  return {
2995
3025
  ...ref,
@@ -2997,6 +3027,9 @@ export function entityPathRef(node, slot, shape) {
2997
3027
  flavor: flavored.flavor,
2998
3028
  storage: flavored.storage,
2999
3029
  format: flavored.format,
3030
+ nullPolicy: flavored.nullPolicy,
3031
+ codec: flavored.codec,
3032
+ codecColumn: flavored.codecColumn,
3000
3033
  };
3001
3034
  }
3002
3035
  // a nested path rides the JSONB document with the phase-A guards;
@@ -3015,13 +3048,48 @@ export function entityPathRef(node, slot, shape) {
3015
3048
  * @param {Map<number, any>} byName - binding slot → binding
3016
3049
  * @returns {{ tree: any, leaves: { binding: string, ref: any }[] } | null}
3017
3050
  */
3018
- function entityProjectionTree(node, byName) {
3051
+ function entityProjectionTree(node, byName, entities, mapping) {
3019
3052
  const leaves = [];
3020
3053
  /** @type {Map<string, number>} */
3021
3054
  const byCanonical = new Map();
3022
3055
  const build = (child) => {
3023
3056
  assertDecidedKind(child);
3024
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
+ }
3025
3093
  if (child.kind === 'path') {
3026
3094
  const binding = child.external === true ? undefined : byName.get(child.rootSlot);
3027
3095
  if (binding === undefined) return null;
@@ -3122,6 +3190,10 @@ export function planEntityPredicate(node, slot, shape) {
3122
3190
  if (!('ref' in pred) || pred.ref === null) return pred;
3123
3191
  const canonical = canonicalOf(pred.ref.segments);
3124
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
+ }
3125
3197
  if (flavored === undefined) {
3126
3198
  // externals against DOC paths are not translated here (the
3127
3199
  // phase-A external forms assume the collection layout)
@@ -3131,7 +3203,8 @@ export function planEntityPredicate(node, slot, shape) {
3131
3203
  return { ...pred, ref: { ...pred.ref, flavor: 'entity-doc' } };
3132
3204
  }
3133
3205
  const ref = { ...pred.ref, column: flavored.column,
3134
- 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 };
3135
3208
  if (flavored.flavor === 'entity-epoch' && pred.p === 'cmp') {
3136
3209
  if ('ext' in pred.operand) {
3137
3210
  blocked = refusal(pred.op, ENTITY_REASONS.external);
@@ -3204,7 +3277,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3204
3277
  if (root.kind !== 'flwor')
3205
3278
  return residual(root.kind, ENTITY_REASONS.notFlwor);
3206
3279
  if (root.fold !== null || root.letBindings.length > 0 || root.asChecks !== null
3207
- || root.groupby !== null || root.count !== null)
3280
+ || root.count !== null)
3208
3281
  return residual('$let', KIND_REASONS.let);
3209
3282
 
3210
3283
  // bindings must each range over one entity's array
@@ -3222,6 +3295,19 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3222
3295
  });
3223
3296
  }
3224
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);
3225
3311
  const conjuncts = root.where === null
3226
3312
  ? []
3227
3313
  : root.where.kind === 'op' && root.where.name === '$and'
@@ -3362,12 +3448,12 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3362
3448
  // SHAPE the projection tree rebuilds from the bindings' members
3363
3449
  const retBinding = root.ret.kind === 'var' && root.ret.external !== true
3364
3450
  ? byName.get(root.ret.slot) : undefined;
3365
- const projection = retBinding === undefined
3366
- ? entityProjectionTree(root.ret, byName) : null;
3367
- 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) {
3368
3454
  reasons.push(refusal('$return', ENTITY_REASONS.projection));
3369
3455
  }
3370
- if (projection?.tree.p === 'leaf' && (aggregate === 'count' || windows.length > 0)) {
3456
+ if (projection?.tree.p === 'leaf' && !projection.leaves[projection.tree.index].count && (aggregate === 'count' || windows.length > 0)) {
3371
3457
  const leaf = projection.leaves[projection.tree.index];
3372
3458
  const binding = bindings.find((entry) => entry.name === leaf.binding);
3373
3459
  filters.set(binding.slot, conjoin(filters.get(binding.slot),
@@ -3377,7 +3463,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3377
3463
  // ordering over flavored refs of either binding
3378
3464
  let order = null;
3379
3465
  let orderPushed = true;
3380
- if (root.orderby !== null) {
3466
+ if (root.orderby !== null && group === null) {
3381
3467
  const terms = [];
3382
3468
  for (const spec of root.orderby.specs) {
3383
3469
  const slot = spec.key.kind === 'path' ? spec.key.rootSlot : -1;
@@ -3389,6 +3475,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3389
3475
  // (a COLUMN stores it absent, §9.3, so a nullable column pushes)
3390
3476
  if (ref === null || (ref.flavor === 'entity-doc' && ref.type === 'unknown')
3391
3477
  || ref.type === 'boolean'
3478
+ || ref.nullPolicy === 'null'
3392
3479
  || (ref.flavor === 'entity-doc' && admitsNull(binding.shape.schema, ref.segments))
3393
3480
  || spec.collation !== null || spec.collationName !== null) {
3394
3481
  orderPushed = false;
@@ -3401,7 +3488,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3401
3488
  }
3402
3489
 
3403
3490
  const fullyPushed = whereFullyPushed && orderPushed
3404
- && (retBinding !== undefined || projection !== null);
3491
+ && (retBinding !== undefined || projection !== null || group !== null);
3405
3492
  if (!fullyPushed) {
3406
3493
  return { analysis, mode: 'set', plan: null, referenced, reasons };
3407
3494
  }
@@ -3431,7 +3518,11 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3431
3518
  plan: {
3432
3519
  planVersion: PLAN_VERSION,
3433
3520
  alg: bindings.length > 1 ? 'entity-join' : 'entity-select',
3434
- 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
+ }) })),
3435
3526
  // the FROM order and each binding's join conditions; `bindings`
3436
3527
  // stays in the DOCUMENT's order, which is the nested-loop order
3437
3528
  // the ORDER BY reproduces
@@ -3439,8 +3530,10 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3439
3530
  binding: join.binding,
3440
3531
  on: join.on.map((edge) => ({
3441
3532
  op: edge.op ?? 'eq',
3442
- left: { binding: edge.left.binding.name, column: edge.left.ref.column },
3443
- 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 }) },
3444
3537
  })),
3445
3538
  })),
3446
3539
  filters: bindings.map((binding) => ({
@@ -3453,12 +3546,13 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3453
3546
  })),
3454
3547
  window,
3455
3548
  aggregate,
3549
+ group,
3456
3550
  ret: retBinding === undefined ? null : retBinding.name,
3457
3551
  // the projected shape, when the return is one: leaves that name
3458
3552
  // the binding they read from, and the tree the decoder rebuilds
3459
3553
  project: projection === null ? null : {
3460
3554
  tree: projection.tree,
3461
- leaves: projection.leaves.map((leaf) => ({ binding: leaf.binding, ref: leaf.ref })),
3555
+ leaves: projection.leaves,
3462
3556
  },
3463
3557
  },
3464
3558
  };