@jarenjs/db 0.84.3 → 0.86.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 (51) hide show
  1. package/ARCHITECTURE.md +27 -3
  2. package/README.md +35 -14
  3. package/docs/HOSTS.md +131 -4
  4. package/docs/MODEL-FORMAT.md +15 -4
  5. package/docs/NATIVE-PLANS.md +24 -7
  6. package/docs/SQLITE-RELATIONAL.md +190 -0
  7. package/package.json +24 -4
  8. package/schemas/jaren-model.authoring.schema.json +157 -0
  9. package/schemas/jaren-model.draft-07.schema.json +157 -0
  10. package/schemas/jaren-model.schema.json +157 -0
  11. package/src/capture.js +4 -2
  12. package/src/ddl.js +8 -3
  13. package/src/dialects/sqlite-relational.js +314 -0
  14. package/src/dialects/sqlite-schema.js +142 -0
  15. package/src/dialects/sqlite.js +17 -0
  16. package/src/driver.js +3 -0
  17. package/src/drivers/bun.js +26 -15
  18. package/src/drivers/node-process-endpoint.js +13 -0
  19. package/src/drivers/node-process.js +177 -0
  20. package/src/drivers/node-worker-endpoint.js +3 -103
  21. package/src/drivers/node-worker.js +6 -178
  22. package/src/drivers/node.js +3 -0
  23. package/src/drivers/snapshot.js +49 -0
  24. package/src/drivers/sqlite-endpoint.js +113 -0
  25. package/src/drivers/worker-client.js +185 -0
  26. package/src/drivers/worker-protocol.js +15 -0
  27. package/src/emit.js +37 -5
  28. package/src/engine-metadata.js +18 -0
  29. package/src/errors.js +1 -0
  30. package/src/index.js +4 -1
  31. package/src/introspect.js +1 -2
  32. package/src/jobs.js +4 -2
  33. package/src/migrate.js +12 -16
  34. package/src/model-api.js +4 -0
  35. package/src/model.js +12 -0
  36. package/src/mutation.js +66 -14
  37. package/src/physical.js +37 -7
  38. package/src/plan.js +66 -3
  39. package/src/query-api.js +5 -0
  40. package/src/query.js +39 -6
  41. package/src/relational-api.js +6 -0
  42. package/src/store.js +6 -4
  43. package/src/table-migration.js +158 -0
  44. package/types/bun.d.ts +3 -0
  45. package/types/entity.d.ts +1 -0
  46. package/types/index.d.ts +11 -2
  47. package/types/model.d.ts +1 -0
  48. package/types/node-process.d.ts +33 -0
  49. package/types/node.d.ts +3 -0
  50. package/types/query.d.ts +2 -0
  51. package/types/relational.d.ts +114 -0
package/src/mutation.js CHANGED
@@ -8,6 +8,7 @@ import { entityShape, planEntityPredicate } from './plan.js';
8
8
  import { createEntityPredicateEmitters } from './emit.js';
9
9
  import { physicalSelection } from './physical.js';
10
10
  import { utf8Length } from './cursor.js';
11
+ import { relationalEmitter } from './dialects/sqlite-relational.js';
11
12
 
12
13
  /** Compile once per document, execute within the existing guarded transaction.
13
14
  * @param {any} connection @param {any} entity @param {any} mapping @param {any} core */
@@ -35,7 +36,9 @@ export function createEntityMutation(connection, entity, mapping, core) {
35
36
  if (entity.physical == null || dialect.name !== 'sqlite') fail('native mutations require a declared SQLite column layout');
36
37
  core.plan.writable();
37
38
  if (!document || typeof document !== 'object' || Array.isArray(document)) fail('a mutation is an object');
38
- const allowed = { update: ['key', 'expectedRevision', 'set'], upsert: ['values', 'conflict', 'update'],
39
+ const allowed = { update: ['key', 'expectedRevision', 'set', 'where', 'reporting', 'expressions'],
40
+ delete: ['key', 'where', 'expectedRevision'],
41
+ upsert: ['values', 'conflict', 'conflictWhere', 'update', 'onConflict', 'reporting'],
39
42
  'insert-select': ['source', 'where', 'select', 'conflict', 'onConflict'] }[document.op];
40
43
  if (!allowed) fail('op must be update, upsert or insert-select');
41
44
  for (const key of Object.keys(document))
@@ -52,29 +55,75 @@ export function createEntityMutation(connection, entity, mapping, core) {
52
55
  const params = [];
53
56
  const param = (value) => { params.push(value); return dialect.parameterRef(params.length, 'v'); };
54
57
  const table = q(mapping.table);
58
+ const sqlExpression = (expression, inline = false) => {
59
+ const mapped = (value) => {
60
+ if (Array.isArray(value)) return value.map(mapped);
61
+ if (value === null || typeof value !== 'object') return value;
62
+ if (value.$sql === 'value') return value;
63
+ if (value.$sql === 'column') {
64
+ if (value.table !== undefined && value.table !== 'it') fail('mutation column expressions refer to the current entity');
65
+ return { $sql: 'column', name: column(value.name).physical };
66
+ }
67
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, mapped(item)]));
68
+ };
69
+ const emitter = relationalEmitter({ inline });
70
+ const result = emitter.expr(mapped(expression));
71
+ params.push(...emitter.params);
72
+ return result;
73
+ };
74
+ const predicate = (expression) => {
75
+ if (expression?.$sql !== undefined || typeof expression === 'number') return sqlExpression(expression);
76
+ const analyzed = analyzeQuery({ $for: { it: '$[*]' }, $where: expression, $return: '$it' });
77
+ const planned = planEntityPredicate(analyzed.root.where, analyzed.root.forBindings[0].slot, entityShape(entity, mapping));
78
+ if ('refusal' in planned) fail(planned.refusal.reason);
79
+ const emitter = createEntityPredicateEmitters(dialect, (slot) => {
80
+ if (!Object.hasOwn(slot, 'literal')) return fail('mutation predicates use literal values');
81
+ return param(slot.literal);
82
+ });
83
+ return emitter.emitPred(table, `${table}.${q('doc')}`, planned.pred);
84
+ };
55
85
  let sql;
56
86
  let prefix = '';
57
- if (document.op === 'update') {
58
- if (!document.set || typeof document.set !== 'object' || Array.isArray(document.set) || !Object.keys(document.set).length) fail('update needs a nonempty set object');
59
- const assignments = Object.entries(document.set).map(([name, value]) => {
87
+ if (document.reporting !== undefined && !['matched', 'changed'].includes(document.reporting)) fail('reporting is matched or changed');
88
+ if (document.op === 'update' || document.op === 'delete') {
89
+ const set = document.set ?? {}, expressions = document.expressions ?? {};
90
+ if (document.op === 'update' && (!set || typeof set !== 'object' || Array.isArray(set)
91
+ || !expressions || typeof expressions !== 'object' || Array.isArray(expressions)
92
+ || !Object.keys(set).length && !Object.keys(expressions).length)) fail('update needs set or expressions assignments');
93
+ const assignments = Object.entries(set).map(([name, value]) => {
60
94
  const c = writableColumn(name);
61
95
  const encoded = core.plan.encodeColumn(name, value);
62
- return { name: q(c.physical), compare: comparison(c), value: param(encoded), encoded };
96
+ return { name: q(c.physical), compare: comparison(c), value: param(encoded), different: () => param(encoded) };
63
97
  });
64
- const parts = core.normalizeKey(document.key);
65
- const where = core.plan.keys.map((key, i) => `${q(column(key).physical)} = ${param(core.plan.encodeColumn(key, parts[i]))}`);
98
+ for (const [name, expression] of Object.entries(expressions)) {
99
+ if (Object.hasOwn(set, name)) fail('an assignment has exactly one owner');
100
+ const c = writableColumn(name);
101
+ assignments.push({ name: q(c.physical), compare: comparison(c), value: sqlExpression(expression), different: () => sqlExpression(expression) });
102
+ }
103
+ const where = [];
104
+ if (Object.hasOwn(document, 'key')) {
105
+ const parts = core.normalizeKey(document.key);
106
+ where.push(...core.plan.keys.map((key, i) => `${q(column(key).physical)} = ${param(core.plan.encodeColumn(key, parts[i]))}`));
107
+ }
108
+ if (Object.hasOwn(document, 'where')) where.push(predicate(document.where));
109
+ if (!where.length) fail('update/delete require a key or an explicit predicate');
66
110
  if (entity.version !== null) {
67
111
  if (!Number.isSafeInteger(document.expectedRevision) || document.expectedRevision < 0) fail('a versioned update needs expectedRevision');
68
112
  where.push(`${q(column(entity.version).physical)} = ${param(document.expectedRevision)}`);
69
113
  }
70
114
  else if (document.expectedRevision !== undefined) fail('expectedRevision needs a declared version member');
71
- where.push(`(${assignments.map((a) => `${a.compare} IS NOT ${param(a.encoded)}`).join(' OR ')})`);
115
+ if (document.op === 'update' && document.reporting !== 'matched')
116
+ where.push(`(${assignments.map((a) => `${a.compare} IS NOT ${a.different()}`).join(' OR ')})`);
72
117
  const sets = assignments.map((a) => `${a.name} = ${a.value}`);
73
118
  if (entity.version !== null) sets.push(`${q(column(entity.version).physical)} = ${q(column(entity.version).physical)} + 1`);
74
- sql = `UPDATE ${table} SET ${sets.join(', ')} WHERE ${where.join(' AND ')}`;
119
+ sql = document.op === 'delete' ? `DELETE FROM ${table} WHERE ${where.map((part) => `(${part})`).join(' AND ')}`
120
+ : `UPDATE ${table} SET ${sets.join(', ')} WHERE ${where.map((part) => `(${part})`).join(' AND ')}`;
75
121
  }
76
122
  else {
77
- if (JSON.stringify(document.conflict) !== JSON.stringify(core.plan.keys)) fail('conflict must name the complete ordered primary key');
123
+ if (!Array.isArray(document.conflict) || !document.conflict.length || new Set(document.conflict).size !== document.conflict.length)
124
+ fail('conflict must name distinct mapped columns');
125
+ document.conflict.forEach(column);
126
+ if (document.op === 'insert-select' && JSON.stringify(document.conflict) !== JSON.stringify(core.plan.keys)) fail('insert-select conflict must name the complete ordered primary key');
78
127
  let names;
79
128
  let source;
80
129
  if (document.op === 'upsert') {
@@ -82,7 +131,7 @@ export function createEntityMutation(connection, entity, mapping, core) {
82
131
  const complete = core.complete(document.values, { updating: false });
83
132
  const split = core.plan.split(complete);
84
133
  names = split.values.map((v) => v.name);
85
- if (core.plan.keys.some((key) => !names.includes(key))) fail('upsert requires every primary-key value');
134
+ if (document.conflict.some((key) => !names.includes(key))) fail('upsert requires every conflict value');
86
135
  source = `VALUES (${split.values.map((v) => param(v.value)).join(', ')})`;
87
136
  }
88
137
  else {
@@ -120,8 +169,10 @@ export function createEntityMutation(connection, entity, mapping, core) {
120
169
  + dialect.mutationRowGuard(`(SELECT COUNT(*) FROM ${q('_jaren_source')})`, maxRows);
121
170
  }
122
171
  sql = `INSERT INTO ${table} (${names.map((name) => q(column(name).physical)).join(', ')}) ${source}`
123
- + ` ON CONFLICT (${core.plan.keys.map((key) => q(column(key).physical)).join(', ')})`;
124
- if (document.op === 'insert-select') sql += ' DO NOTHING';
172
+ + ` ON CONFLICT (${document.conflict.map((key) => q(column(key).physical)).join(', ')})`;
173
+ if (document.conflictWhere !== undefined) sql += ` WHERE ${sqlExpression(document.conflictWhere, true)}`;
174
+ if (document.onConflict !== undefined && !['nothing', 'update'].includes(document.onConflict)) fail('onConflict is nothing or update');
175
+ if (document.op === 'insert-select' || document.onConflict === 'nothing') sql += ' DO NOTHING';
125
176
  else {
126
177
  if (!Array.isArray(document.update) || !document.update.length || new Set(document.update).size !== document.update.length) fail('upsert update names distinct stored members');
127
178
  const changes = document.update.map((name) => {
@@ -131,7 +182,8 @@ export function createEntityMutation(connection, entity, mapping, core) {
131
182
  });
132
183
  const sets = changes.map(({ name }) => `${name} = excluded.${name}`);
133
184
  if (entity.version !== null) sets.push(`${q(column(entity.version).physical)} = ${table}.${q(column(entity.version).physical)} + 1`);
134
- sql += ` DO UPDATE SET ${sets.join(', ')} WHERE ${changes.map(({ name, compare }) => `${compare} IS NOT excluded.${name}`).join(' OR ')}`;
185
+ sql += ` DO UPDATE SET ${sets.join(', ')}`;
186
+ if (document.reporting !== 'matched') sql += ` WHERE ${changes.map(({ name, compare }) => `${compare} IS NOT excluded.${name}`).join(' OR ')}`;
135
187
  }
136
188
  }
137
189
  sql = prefix + sql + ` RETURNING ${physicalSelection(mapping, dialect)}`;
package/src/physical.js CHANGED
@@ -4,6 +4,8 @@ import { getEpochOfDateTimeRFC3339, getEpochOfDateOnlyRFC3339 } from '@jarenjs/c
4
4
  import { DbCompileError, DbRuntimeError } from './errors.js';
5
5
  import { chain } from './driver.js';
6
6
  import { canonicalizeJson } from '@jarenjs/json/canonical';
7
+ import { planTable } from './dialects/sqlite-schema.js';
8
+ import { sqlitePhysicalColumnType } from './dialects/sqlite.js';
7
9
 
8
10
  const compiledCodecs = new WeakMap();
9
11
  const CODECS = new Set(['text', 'integer', 'number', 'boolean', 'json', 'date', 'datetime', 'epoch-ms', 'bigint', 'decimal', 'blob-hex']);
@@ -19,7 +21,7 @@ export function normalizePhysical(physical, properties, keys, path) {
19
21
  const fail = (reason) => { throw new DbCompileError('JD0005', reason, `${path}/physical`); };
20
22
  if (!physical || typeof physical !== 'object' || Array.isArray(physical)) fail('physical must be an object');
21
23
  for (const key of Object.keys(physical))
22
- if (!['table', 'kind', 'keys', 'columns'].includes(key)) fail(`unknown physical member '${key}'`);
24
+ if (!['table', 'kind', 'keys', 'columns', 'constraints', 'indexes', 'triggers', 'strict', 'withoutRowid'].includes(key)) fail(`unknown physical member '${key}'`);
23
25
  if (!identifier(physical.table)) fail('physical.table must be a nonempty SQL identifier');
24
26
  if (physical.kind !== undefined && !['table', 'view'].includes(physical.kind)) fail('physical.kind is table or view');
25
27
  if (!physical.columns || typeof physical.columns !== 'object' || Array.isArray(physical.columns)) fail('physical.columns is required');
@@ -28,26 +30,54 @@ export function normalizePhysical(physical, properties, keys, path) {
28
30
  || ordered.some((key) => !keys.includes(key))) fail('physical.keys must order every declared key exactly once');
29
31
  const used = new Set();
30
32
  const columns = [];
31
- for (const [name, property] of properties) {
33
+ for (const name of Object.keys(physical.columns))
34
+ if (!properties.has(name) || properties.get(name).relation) fail(`column '${name}' is not a stored property`);
35
+ for (const [name, property] of properties)
36
+ if (!property.relation && !Object.hasOwn(physical.columns, name)) fail(`'${name}' needs an explicit supported column codec`);
37
+ const definitions = [];
38
+ for (const name of Object.keys(physical.columns)) {
39
+ const property = properties.get(name);
32
40
  if (property.relation) continue;
33
41
  const c = physical.columns[name];
34
42
  if (!c || typeof c !== 'object' || Array.isArray(c) || !CODECS.has(c.codec)) fail(`'${name}' needs an explicit supported column codec`);
35
43
  for (const key of Object.keys(c))
36
- if (!['name', 'codec', 'null', 'default', 'generated'].includes(key)) fail(`unknown column member '${key}'`);
44
+ if (!['name', 'codec', 'null', 'default', 'generated', 'type', 'defaultValue', 'collation', 'identity', 'check', 'generatedExpression', 'stored'].includes(key)) fail(`unknown column member '${key}'`);
37
45
  if (!identifier(c.name) || used.has(c.name.toLowerCase())) fail(`'${name}' needs a distinct physical column name`);
38
46
  if (!['null', 'absent', 'reject'].includes(c.null)) fail(`'${name}' must declare SQL NULL as null, absent or reject`);
47
+ if (c.type !== undefined && c.type !== sqlitePhysicalColumnType(c.codec)) fail(`'${name}' declares an affinity incompatible with its codec`);
48
+ if (c.stored !== undefined && (typeof c.stored !== 'boolean' || c.generatedExpression === undefined))
49
+ fail(`'${name}' needs an explicit generated expression for stored ownership`);
39
50
  if (c.default !== undefined && c.default !== 'database') fail('column default ownership is database');
40
51
  if (c.generated !== undefined && typeof c.generated !== 'boolean') fail('generated must be boolean');
41
52
  if (property.key && (!['text', 'integer', 'bigint'].includes(c.codec) || c.null !== 'reject')) fail('keys require non-null text, integer or bigint codecs');
42
53
  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');
54
+ if ((c.default === 'database' || Object.hasOwn(c, 'defaultValue')) && property.default !== undefined && property.default !== 'auto') fail('a default has exactly one owner');
55
+ if (c.generatedExpression !== undefined && c.generated === false) fail('a generated expression requires generated ownership');
44
56
  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 });
57
+ columns.push({ name, physical: c.name, codec: c.codec, null: c.null, databaseDefault: c.default === 'database' || Object.hasOwn(c, 'defaultValue'),
58
+ generated: c.generated === true || c.generatedExpression !== undefined, storage: STORAGE[c.codec], source: 'column', key: property.key });
59
+ definitions.push({ name: c.name,
60
+ type: c.type ?? sqlitePhysicalColumnType(c.codec),
61
+ nullable: c.null !== 'reject',
62
+ ...(Object.hasOwn(c, 'defaultValue') ? { default: c.defaultValue } : {}),
63
+ ...(c.identity !== undefined || property.default === 'auto' ? { identity: c.identity ?? 'rowid' } : {}),
64
+ ...(c.collation === undefined ? {} : { collation: c.collation }),
65
+ ...(c.check === undefined ? {} : { check: c.check }),
66
+ ...(c.generatedExpression === undefined ? {} : { generated: c.generatedExpression, stored: c.stored === true }),
67
+ });
47
68
  }
48
69
  for (const name of Object.keys(physical.columns))
49
70
  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 };
71
+ const definition = { name: physical.table, columns: definitions,
72
+ primaryKey: ordered.map((key) => physical.columns[key].name),
73
+ ...Object.fromEntries(['constraints', 'indexes', 'triggers', 'strict', 'withoutRowid']
74
+ .filter((key) => physical[key] !== undefined).map((key) => [key, physical[key]])) };
75
+ if (physical.kind !== 'view') planTable(definition);
76
+ // Database-owned expressions must be explicit before DDL can own them.
77
+ const ddl = physical.kind === 'view' || columns.some((c, i) => (c.generated && definitions[i].generated === undefined)
78
+ || (c.databaseDefault && !Object.hasOwn(definitions[i], 'default')))
79
+ ? null : definition;
80
+ return { table: physical.table, kind: physical.kind ?? 'table', keys: [...ordered], columns, ddl };
51
81
  }
52
82
 
53
83
  /** Compile one codec once, with a JSON-safe public value and a bound SQL value.
package/src/plan.js CHANGED
@@ -182,6 +182,7 @@ const OPERATOR_REASONS = {
182
182
 
183
183
  /** Why an ENTITY document, or one of its clauses, stayed in the engine. */
184
184
  const ENTITY_REASONS = {
185
+ scalarAggregate: 'native scalar aggregation requires a scalar physical projection; sums and averages require exact integer accumulation',
185
186
  notFlwor: 'only a FLWOR over entity arrays is translated',
186
187
  bindingRoot: 'bindings must each range over one declared entity array ($.Entity[*])',
187
188
  joinKey: 'every binding past the first needs a column equality to one already joined — '
@@ -3190,7 +3191,7 @@ export function planEntityPredicate(node, slot, shape) {
3190
3191
  if (!('ref' in pred) || pred.ref === null) return pred;
3191
3192
  const canonical = canonicalOf(pred.ref.segments);
3192
3193
  const flavored = shape.entityFlavors.get(canonical);
3193
- if (shape.columnOnly && (flavored === undefined || flavored.unsafe || flavored.nullPolicy === 'null')) {
3194
+ if (shape.columnOnly && (flavored === undefined || flavored.unsafe)) {
3194
3195
  blocked = refusal('physical', PREDICATE_REASONS.physicalCodec);
3195
3196
  return pred;
3196
3197
  }
@@ -3246,6 +3247,57 @@ export function planEntityPredicate(node, slot, shape) {
3246
3247
  * kept in the set residual over the fetched root
3247
3248
  * @returns {any}
3248
3249
  */
3250
+ function flattenEntityProjection(root) {
3251
+ const simple = (node) => node.kind === 'literal' || node.kind === 'var'
3252
+ || (node.kind === 'path' && node.singular)
3253
+ || (node.kind === 'object' && node.entries.every((e) => simple(e.expr)));
3254
+ const plain = (node) => node?.kind === 'flwor' && node.fold === null
3255
+ && node.letBindings.length === 0 && node.asChecks === null && node.count === null
3256
+ && node.groupby === null && node.limits === null;
3257
+ if (!plain(root)) return root;
3258
+ while (root.forBindings.length === 1) {
3259
+ const outer = root.forBindings[0];
3260
+ if (outer.atSlot !== -1 || outer.allowingEmpty || outer.window !== null) break;
3261
+ const inner = flattenEntityProjection(unpacked(outer.expr));
3262
+ if (!plain(inner) || inner.orderby !== null || !simple(inner.ret)
3263
+ || !['object', 'var'].includes(inner.ret.kind)) break;
3264
+ let blocked = false;
3265
+ const resolve = (projection, segments) => {
3266
+ if (!segments.length) return projection;
3267
+ if (projection.kind === 'object') {
3268
+ const [part, ...rest] = segments;
3269
+ const name = !part.descendant && part.selectors.length === 1 && part.selectors[0].kind === 'name'
3270
+ ? part.selectors[0].name : null;
3271
+ const member = projection.entries.find((e) => e.name === name);
3272
+ if (member) return resolve(member.expr, rest);
3273
+ }
3274
+ if (projection.kind === 'var' && !projection.external)
3275
+ return { kind: 'path', card: 2, name: projection.name, rootSlot: projection.slot,
3276
+ external: false, rootCard: 1, segments, singular: true, docPath: projection.docPath };
3277
+ if (projection.kind === 'path' && projection.singular)
3278
+ return { ...projection, segments: [...projection.segments, ...segments] };
3279
+ blocked = true; return projection;
3280
+ };
3281
+ const substitute = (node) => {
3282
+ if (node === null || typeof node !== 'object') return node;
3283
+ if (Array.isArray(node)) return node.map(substitute);
3284
+ if (node.kind === 'literal') return node;
3285
+ if (node.kind === 'var' && !node.external && node.slot === outer.slot) return inner.ret;
3286
+ if (node.kind === 'path' && !node.external && node.rootSlot === outer.slot) {
3287
+ if (!node.singular) { blocked = true; return node; }
3288
+ return resolve(inner.ret, node.segments);
3289
+ }
3290
+ return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, substitute(value)]));
3291
+ };
3292
+ const where = substitute(root.where), ret = substitute(root.ret), orderby = substitute(root.orderby);
3293
+ if (blocked) break;
3294
+ root = { ...root, forBindings: inner.forBindings, ret, orderby,
3295
+ where: inner.where === null ? where : where === null ? inner.where
3296
+ : { kind: 'op', card: 1, name: '$and', args: [inner.where, where], docPath: root.docPath } };
3297
+ }
3298
+ return root;
3299
+ }
3300
+
3249
3301
  function planEntityQueryCore(document, entities, mapping, operators) {
3250
3302
  const analysis = analyzeQuery(document, analyzeOptionsFor(operators));
3251
3303
  let root = analysis.root;
@@ -3269,13 +3321,14 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3269
3321
  assertDecidedKind(root);
3270
3322
  }
3271
3323
  let aggregate = null;
3272
- if (root.kind === 'op' && root.name === '$count' && windows.length === 0) {
3273
- aggregate = 'count';
3324
+ if (root.kind === 'op' && ['$count', '$sum', '$avg', '$min', '$max'].includes(root.name) && windows.length === 0) {
3325
+ aggregate = root.name.slice(1);
3274
3326
  root = root.args[0];
3275
3327
  assertDecidedKind(root);
3276
3328
  }
3277
3329
  if (root.kind !== 'flwor')
3278
3330
  return residual(root.kind, ENTITY_REASONS.notFlwor);
3331
+ root = flattenEntityProjection(root);
3279
3332
  if (root.fold !== null || root.letBindings.length > 0 || root.asChecks !== null
3280
3333
  || root.count !== null)
3281
3334
  return residual('$let', KIND_REASONS.let);
@@ -3453,6 +3506,15 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3453
3506
  if (retBinding === undefined && projection === null && group === null) {
3454
3507
  reasons.push(refusal('$return', ENTITY_REASONS.projection));
3455
3508
  }
3509
+ let scalarAggregate = null;
3510
+ if (aggregate !== null && aggregate !== 'count') {
3511
+ const leaf = projection?.tree.p === 'leaf' ? projection.leaves[projection.tree.index] : null;
3512
+ if (bindings.length !== 1 || !leaf?.ref || leaf.ref.flavor !== 'entity-column'
3513
+ || !['text', 'integer', 'number'].includes(leaf.ref.codec)
3514
+ || (['sum', 'avg'].includes(aggregate) && leaf.ref.type !== 'integer'))
3515
+ return residual(`$${aggregate}`, ENTITY_REASONS.scalarAggregate);
3516
+ scalarAggregate = { fn: aggregate, ref: leaf.ref };
3517
+ }
3456
3518
  if (projection?.tree.p === 'leaf' && !projection.leaves[projection.tree.index].count && (aggregate === 'count' || windows.length > 0)) {
3457
3519
  const leaf = projection.leaves[projection.tree.index];
3458
3520
  const binding = bindings.find((entry) => entry.name === leaf.binding);
@@ -3546,6 +3608,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3546
3608
  })),
3547
3609
  window,
3548
3610
  aggregate,
3611
+ ...(scalarAggregate === null ? {} : { scalarAggregate }),
3549
3612
  group,
3550
3613
  ret: retBinding === undefined ? null : retBinding.name,
3551
3614
  // the projected shape, when the return is one: leaves that name
@@ -0,0 +1,5 @@
1
+ //@ts-check
2
+ /** Public query engines without store, jobs, replication or host bindings. */
3
+ export { createQueryEngine, createEntityQueryEngine, createQueryState, createLoadEngine,
4
+ INCLUDE_DEPTH_DEFAULT, INCLUDE_ROWS_DEFAULT, INCLUDE_BYTES_DEFAULT } from './query.js';
5
+ export { collectEntityRoots, entityRoot } from './plan.js';
package/src/query.js CHANGED
@@ -34,7 +34,7 @@
34
34
  import { physicalSelection, columnCodec } from './physical.js';
35
35
 
36
36
  import { createSemanticCache } from '@jarenjs/core/cache';
37
- import { analyzeQuery } from '@jarenjs/json/query';
37
+ import { analyzeQuery, JsonQueryRuntimeError } from '@jarenjs/json/query';
38
38
 
39
39
  import { DbCompileError, DbRuntimeError, wrapDriverError, classifyDriverError } from './errors.js';
40
40
  import { chain, attempt, isThenable } from './driver.js';
@@ -1653,7 +1653,7 @@ export function createEntityQueryEngine(context) {
1653
1653
  planned = { ...planned, mode: 'set', plan: null,
1654
1654
  reasons: [{ construct: 'pushdown', reason: BIND_REASONS.pushdown }] };
1655
1655
  }
1656
- if (planned.plan?.group && dialect.name !== 'sqlite') {
1656
+ if ((planned.plan?.group || planned.plan?.scalarAggregate) && dialect.name !== 'sqlite') {
1657
1657
  planned = { ...planned, mode: 'set', plan: null,
1658
1658
  reasons: [{ construct: '$groupby', reason: 'entity grouping runtime guards are qualified for SQLite' }] };
1659
1659
  }
@@ -1872,11 +1872,30 @@ export function createEntityQueryEngine(context) {
1872
1872
  // the database cannot take (a missing external, a boolean, a null,
1873
1873
  // a region with no box) sends the call to the residual, where the
1874
1874
  // ENGINE raises its own error or answers with its own semantics
1875
- const params = entry.slots.map((slot) => slotValue(slot, externals));
1876
- if (params.some((value) => !bindable(value)))
1875
+ const diverted = divertReason(entry, externals);
1876
+ if (diverted !== null) {
1877
+ entry.runtimeReason = diverted;
1878
+ if (strict) throw new DbCompileError('JD0010',
1879
+ `strict mode refused a residual: '${diverted.construct}' — ${diverted.reason}`);
1880
+ if (profile?.refuseFullScan) throw profileEntityRefusal(
1881
+ 'the profile refuses the decoded scan required by the external binding', '/entities');
1877
1882
  return runResidual(entry, document, externals);
1883
+ }
1884
+ const params = entry.slots.map((slot) => slotValue(slot, externals));
1878
1885
  if (entry.statement === null) entry.statement = connection.prepare(entry.sql, { readOnly: true });
1879
1886
  return chain(guardEntityScan(entry), () => chain(entry.statement, (statement) => {
1887
+ if (entry.planned.plan.scalarAggregate) return chain(statement.get(params), (row) => {
1888
+ admittedRows(entry, row ? [row] : []);
1889
+ if (row?._valid === 0) throw new DbRuntimeError('JD2003', 'an aggregate column refuses a lossy or invalid value');
1890
+ if (row?._nulls > 0) throw new JsonQueryRuntimeError('JQ2001', 'an aggregate requires numbers or strings, got null');
1891
+ if (row?._safe === 0) {
1892
+ entry.runtimeReason = { construct: 'aggregate', reason: 'integer accumulation exceeded its runtime exactness bound' };
1893
+ if (strict) throw new DbCompileError('JD0010', entry.runtimeReason.reason);
1894
+ if (profile?.refuseFullScan) throw profileEntityRefusal('the profile refuses the decoded scan required by integer accumulation', '/entities');
1895
+ return runResidual(entry, document, externals);
1896
+ }
1897
+ return wrapValue(entry, row?.value ?? (entry.planned.plan.aggregate === 'sum' ? 0 : undefined));
1898
+ });
1880
1899
  if (entry.planned.plan.aggregate === 'count')
1881
1900
  return chain(statement.get(params), (row) => { admittedRows(entry, row ? [row] : []); return wrapValue(entry, row?.value ?? 0); });
1882
1901
  return chain(statement.all(params), (rows) => {
@@ -1928,7 +1947,8 @@ export function createEntityQueryEngine(context) {
1928
1947
  */
1929
1948
  const divertReason = (entry, externals) => {
1930
1949
  for (const slot of entry.slots) {
1931
- if (bindable(slotValue(slot, externals))) continue;
1950
+ const value = slotValue(slot, externals);
1951
+ if (bindable(value) || (slot.nullable === true && value === null)) continue;
1932
1952
  const name = 'external' in slot ? slot.external
1933
1953
  : 'derived' in slot ? (slot.derived.kind === 'bboxAxis' ? slot.derived.external
1934
1954
  : [slot.derived.centre, slot.derived.radius].find((input) => 'external' in input)?.external) : null;
@@ -1949,6 +1969,10 @@ export function createEntityQueryEngine(context) {
1949
1969
  */
1950
1970
  const cursorClass = (entry, externals) => {
1951
1971
  const buffered = (barrier) => ({ streaming: 'buffered', barrier });
1972
+ if (entry.planned.mode === 'native' && externals !== null) {
1973
+ const diverted = divertReason(entry, externals);
1974
+ if (diverted !== null) return buffered(diverted);
1975
+ }
1952
1976
  if (entry.planned.wrapped === true) {
1953
1977
  return buffered({ construct: 'window', reason: BIND_REASONS.wrappedWindow });
1954
1978
  }
@@ -1997,11 +2021,20 @@ export function createEntityQueryEngine(context) {
1997
2021
  + 'entity binding — read it untracked, or return the binding itself');
1998
2022
  }
1999
2023
  const each = register === undefined ? (item) => item : (item) => register(retEntity, item);
2024
+ entry.runtimeReason = null;
2000
2025
  const classified = cursorClass(entry, externals);
2026
+ if (classified.barrier?.construct === 'external') {
2027
+ entry.admitted = { statements: 0, rows: 0, bytes: 0 };
2028
+ entry.runtimeReason = classified.barrier;
2029
+ if (strict) throw new DbCompileError('JD0010',
2030
+ `strict mode refused a residual: 'external' — ${classified.barrier.reason}`);
2031
+ if (profile?.refuseFullScan) throw profileEntityRefusal(
2032
+ 'the profile refuses the decoded scan required by the external binding', '/entities');
2033
+ }
2001
2034
  refuseBuffered(options, classified, entities.get(entry.planned.retEntity ?? '')?.docPath);
2002
2035
  const signal = options?.signal;
2003
2036
  const deadline = options?.deadline;
2004
- if (entry.planned.wrapped === true || entry.planned.plan?.group) {
2037
+ if (entry.planned.wrapped === true || entry.planned.plan?.group || entry.planned.plan?.scalarAggregate) {
2005
2038
  return cursorFactory({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
2006
2039
  materialize: () => chain(execute(document, options), (value) => entry.planned.wrapped === true
2007
2040
  ? [value] : value === undefined ? [] : Array.isArray(value) ? value : [value]) });
@@ -0,0 +1,6 @@
1
+ //@ts-check
2
+ /** Lightweight column-first SQLite authoring and execution. */
3
+ export { sql, planRelational, relational } from './dialects/sqlite-relational.js';
4
+ export { defineTable, planTable } from './dialects/sqlite-schema.js';
5
+ export { planTableMigration, applyTableMigration, withForeignKeysSuspended } from './table-migration.js';
6
+ export { sqliteDialect } from './dialects/sqlite.js';
package/src/store.js CHANGED
@@ -34,7 +34,7 @@ import { refuseUnsupportedPragmaKeys, resolvePragmaRequests, configurePragmas }
34
34
  import { createMaintenance } from './maintenance.js';
35
35
  import { createBackup } from './backup.js';
36
36
  import { normalizeProfile, assertProfileRoots } from './profile.js';
37
- import { normalizeEntities, explainMapping, joinTableRoots } from './model.js';
37
+ import { compileEntityModel, joinTableRoots } from './model.js';
38
38
  import { entityCore } from './entity.js';
39
39
  import { verifyPhysical } from './physical.js';
40
40
  import { trustedSql, synchronousBody } from './sql.js';
@@ -59,7 +59,8 @@ import {
59
59
  } from './expression.js';
60
60
 
61
61
  /** The model format version this store implements. */
62
- export const MODEL_VERSION = '0.1';
62
+ import { MODEL_VERSION } from './engine-metadata.js';
63
+ export { MODEL_VERSION };
63
64
 
64
65
  const COLLECTION_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
65
66
  const IDENTITIES = new Set(['uuid', 'integer']);
@@ -975,8 +976,9 @@ export function openStore(model, options) {
975
976
  let mapping;
976
977
  try {
977
978
  collections = normalizeModel(model, options.expressions);
978
- entities = normalizeEntities(model);
979
- mapping = entities.size > 0 ? explainMapping(model) : null;
979
+ const compiled = compileEntityModel(model);
980
+ entities = compiled.entities;
981
+ mapping = entities.size > 0 ? compiled.mapping : null;
980
982
  if ([...entities.values()].some((e) => e.physical !== null) && (options.capture || options.replication))
981
983
  throw new DbCompileError('JD0051', 'column adoption preserves application triggers; complete capture is not qualified');
982
984
  if (options.adopt === true && (options.capture || options.replication))