@jarenjs/db 0.83.3 → 0.85.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.
package/src/emit.js CHANGED
@@ -41,7 +41,7 @@ function compareRefs(pred, read) {
41
41
  }
42
42
 
43
43
  /**
44
- * @typedef {{ external: string } | { literal: unknown } |
44
+ * @typedef {{ external: string, nullable?: boolean } | { literal: unknown } |
45
45
  * { derived: { kind: 'bboxAxis', external: string,
46
46
  * axis: 'w' | 's' | 'e' | 'n' } } |
47
47
  * { derived: { kind: 'circleAxis', centre: { external: string } | { literal: unknown },
@@ -659,15 +659,20 @@ export function createEntityPredicateEmitters(dialect, param) {
659
659
 
660
660
  const emitColumnPred = (aliasSql, pred) => {
661
661
  const column = physicalComparable(pred.ref, `${aliasSql}.${q(pred.ref.column)}`, dialect);
662
+ const presentNull = pred.ref.nullPolicy === 'null';
663
+ const present = presentNull ? dialect.booleanLiteral(true) : `${column} IS NOT NULL`;
662
664
  if (pred.p === 'typeIs') {
663
665
  if (pred.types.length === 0)
664
- return pred.positive ? `${column} IS NOT NULL` : `${column} IS NULL`;
666
+ return pred.positive ? present : presentNull ? dialect.booleanLiteral(false) : `${column} IS NULL`;
665
667
  if (pred.types[0] === 'null')
666
- return pred.positive ? dialect.booleanLiteral(false) : `${column} IS NOT NULL`;
668
+ return pred.positive ? presentNull ? `${column} IS NULL` : dialect.booleanLiteral(false) : `${column} IS NOT NULL`;
669
+ if (pred.ref.storage !== 'boolean')
670
+ return pred.positive ? dialect.booleanLiteral(false) : present;
667
671
  const wanted = pred.types[0] === 'true' ? 1 : 0;
668
672
  return pred.positive
669
673
  ? `(${column} IS NOT NULL AND ${column} = ${param({ literal: wanted })})`
670
- : `(${column} IS NOT NULL AND ${column} <> ${param({ literal: wanted })})`;
674
+ : presentNull ? `${column} IS NOT ${param({ literal: wanted })}`
675
+ : `(${column} IS NOT NULL AND ${column} <> ${param({ literal: wanted })})`;
671
676
  }
672
677
  if (pred.p === 'strop') {
673
678
  const form = stropForm(dialect, param, column, pred);
@@ -675,6 +680,26 @@ export function createEntityPredicateEmitters(dialect, param) {
675
680
  }
676
681
  const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
677
682
  if ('ext' in pred.operand) {
683
+ if (dialect.name === 'sqlite') {
684
+ // SQL NULL is a present Jaren null only for an explicit null
685
+ // policy. Keep type guards: SQLite affinity must not turn a
686
+ // numeric lookup into a string lookup, or invert inequality.
687
+ const value = () => param({ external: pred.operand.ext, nullable: true });
688
+ const kind = pred.ref.storage === 'string' ? 'text' : 'number';
689
+ const guard = () => kind === 'text'
690
+ ? `${dialect.valueTypeOf(value())} = ${sl('text')}`
691
+ : pred.ref.storage === 'boolean' ? dialect.booleanLiteral(false)
692
+ : `${dialect.valueTypeOf(value())} IN ${NUMERIC()}`;
693
+ if (pred.op === 'eq') {
694
+ const nullCase = presentNull ? `(${column} IS NULL AND ${value()} IS NULL) OR ` : '';
695
+ return `(${nullCase}(${column} IS NOT NULL AND ${guard()} AND ${column} = ${value()}))`;
696
+ }
697
+ if (pred.op === 'ne') {
698
+ const nullCase = presentNull ? `(${column} IS NULL AND ${value()} IS NOT NULL) OR ` : '';
699
+ return `(${nullCase}(${column} IS NOT NULL AND (NOT (${guard()}) OR ${column} <> ${value()})))`;
700
+ }
701
+ return `(${column} IS NOT NULL AND ${guard()} AND ${column} ${symbol} ${value()})`;
702
+ }
678
703
  const kind = pred.ref.storage === 'string' ? 'text' : 'number';
679
704
  const guard = kind === 'text'
680
705
  ? `${dialect.valueTypeOf(externalSlot(pred.operand.ext))} = ${sl('text')}`
@@ -688,7 +713,8 @@ export function createEntityPredicateEmitters(dialect, param) {
688
713
  const storageKind = pred.ref.storage === 'string' ? 'string'
689
714
  : pred.ref.storage === 'boolean' ? 'boolean' : 'number';
690
715
  if (storageKind === 'boolean' || litKind === 'other' || storageKind !== litKind)
691
- return pred.op === 'ne' ? `${column} IS NOT NULL` : dialect.booleanLiteral(false);
716
+ return pred.op === 'ne' ? present : dialect.booleanLiteral(false);
717
+ if (presentNull && pred.op === 'ne') return `${column} IS NOT ${param({ literal: lit })}`;
692
718
  return `(${column} IS NOT NULL AND ${column} ${symbol} ${param({ literal: lit })})`;
693
719
  };
694
720
 
@@ -875,6 +901,12 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
875
901
  ? `CAST(${groupValue(entry.ref)} AS REAL)` : groupValue(entry.ref))} AS ${q(`a${i}`)}`),
876
902
  ...group.aggregates.flatMap((entry, i) => numericGroup(entry) ? [`${groupSafe(entry)} AS ${q(`_safe${i}`)}`] : []),
877
903
  ...(groupRefs.length ? [`${groupRefs.map(groupValid).join(' AND ')} AS ${q('_valid')}`] : [])].join(', ')
904
+ : plan.scalarAggregate
905
+ ? `${dialect.groupAggregate(plan.scalarAggregate.fn, plan.scalarAggregate.ref.type === 'string'
906
+ ? groupValue(plan.scalarAggregate.ref) : `CAST(${groupValue(plan.scalarAggregate.ref)} AS REAL)`)} AS ${q('value')}, `
907
+ + `${groupValid(plan.scalarAggregate.ref)} AS ${q('_valid')}, `
908
+ + `${plan.scalarAggregate.ref.nullPolicy === 'null' ? `COUNT(*) - COUNT(${groupValue(plan.scalarAggregate.ref)})` : '0'} AS ${q('_nulls')}, `
909
+ + `${numericGroup(plan.scalarAggregate) ? groupSafe(plan.scalarAggregate) : '1'} AS ${q('_safe')}`
878
910
  : plan.aggregate === 'count'
879
911
  ? `COUNT(*) AS ${q('value')}`
880
912
  : plan.project != null
@@ -0,0 +1,18 @@
1
+ //@ts-check
2
+ /** Inert metadata: inspecting a schema must not load its runtime owners. */
3
+ import { REPLICATION_TABLES } from './replication-tables.js';
4
+ /** Current model document version. */
5
+ export const MODEL_VERSION = '0.1';
6
+ /** Migration receipt table. */
7
+ export const HISTORY_TABLE = '_jaren_migrations';
8
+ /** Change ledger table. */
9
+ export const CHANGES_TABLE = '_jaren_changes';
10
+ /** Change ledger retention state. */
11
+ export const CHANGES_STATE_TABLE = '_jaren_changes_state';
12
+ /** Durable job queue. */
13
+ export const JOBS_TABLE = '_jaren_jobs';
14
+ /** Durable job checkpoints. */
15
+ export const JOB_CHECKPOINTS_TABLE = '_jaren_job_checkpoints';
16
+ /** Tables excluded from model adoption and schema drift comparisons. */
17
+ export const ENGINE_TABLES = new Set([HISTORY_TABLE, CHANGES_TABLE, CHANGES_STATE_TABLE,
18
+ JOBS_TABLE, JOB_CHECKPOINTS_TABLE, ...Object.values(REPLICATION_TABLES)]);
package/src/index.js CHANGED
@@ -66,7 +66,7 @@ export {
66
66
  applyMandatoryPredicate, applyRowBound,
67
67
  } from './profile.js';
68
68
  export { translatePatch } from './patch-sql.js';
69
- export { normalizeEntities, explainMapping, relationTables } from './model.js';
69
+ export { normalizeEntities, explainMapping, compileEntityModel, relationTables } from './model.js';
70
70
  export { planEntity, planJoinTable } from './ddl.js';
71
71
  export { entityCore } from './entity.js';
72
72
  export { entityEmitModel } from './emit-model.js';
@@ -103,3 +103,6 @@ export { REPLICATION_VERSION, REPLICATION_DEFAULTS, normalizeFrontier,
103
103
 
104
104
  export { planInvariants } from './ddl.js';
105
105
  export { planPhysicalMigration } from './migrate.js';
106
+ export { sql, planRelational, relational } from './dialects/sqlite-relational.js';
107
+ export { defineTable, planTable } from './dialects/sqlite-schema.js';
108
+ export { planTableMigration, applyTableMigration, withForeignKeysSuspended } from './table-migration.js';
package/src/introspect.js CHANGED
@@ -30,8 +30,7 @@
30
30
  import { chain } from './driver.js';
31
31
  import { DbCompileError } from './errors.js';
32
32
  import { KEY_COLUMN, DOC_COLUMN } from './ddl.js';
33
- import { MODEL_VERSION } from './store.js';
34
- import { ENGINE_TABLES } from './migrate.js';
33
+ import { MODEL_VERSION, ENGINE_TABLES } from './engine-metadata.js';
35
34
  import { registeredName, expressionMembers } from './expression.js';
36
35
 
37
36
  /**
package/src/jobs.js CHANGED
@@ -1,4 +1,6 @@
1
1
  //@ts-check
2
+ import { JOBS_TABLE, JOB_CHECKPOINTS_TABLE } from './engine-metadata.js';
3
+ export { JOBS_TABLE, JOB_CHECKPOINTS_TABLE };
2
4
  /**
3
5
  * @file The durable job queue (JOBS-FORMAT): enqueue, the
4
6
  * single-statement guarded claim (§3 — one statement is one
@@ -44,8 +46,8 @@ import { DbCompileError, DbRuntimeError, wrapDriverError } from './errors.js';
44
46
  import { createCursor, rowClassOf, PAGE_LIMIT_DEFAULT } from './cursor.js';
45
47
  import { refuseCancelled } from './cancellation.js';
46
48
 
47
- export const JOBS_TABLE = '_jaren_jobs';
48
- export const JOB_CHECKPOINTS_TABLE = '_jaren_job_checkpoints';
49
+
50
+
49
51
 
50
52
  /** §4 defaults, all overridable per worker. */
51
53
  export const JOB_DEFAULTS = Object.freeze({
package/src/migrate.js CHANGED
@@ -34,9 +34,8 @@ import { chain, toPromise } from './driver.js';
34
34
  import { normalizeModel } from './store.js';
35
35
  import { planQuery } from './plan.js';
36
36
  import { createQueryEngine, createQueryState } from './query.js';
37
- import { CHANGES_TABLE, CHANGES_STATE_TABLE } from './capture.js';
38
- import { JOBS_TABLE, JOB_CHECKPOINTS_TABLE } from './jobs.js';
39
- import { REPLICATION_TABLES } from './replication-tables.js';
37
+ import { HISTORY_TABLE, ENGINE_TABLES } from './engine-metadata.js';
38
+ export { HISTORY_TABLE, ENGINE_TABLES };
40
39
  import { planCollection, verifyShape, planEntity, planJoinTable } from './ddl.js';
41
40
  import { normalizeEntities, explainMapping } from './model.js';
42
41
  import { derivedValue, memberAt, registerDeriveFunctions } from './derive.js';
@@ -74,17 +73,6 @@ function mappingFor(connection, expressions = undefined) {
74
73
  };
75
74
  }
76
75
 
77
- /** The history table name (outside the model's identifier namespace
78
- * conventions on purpose — a collection cannot collide with it). */
79
- export const HISTORY_TABLE = '_jaren_migrations';
80
- /** The tables the engine owns beside a model's: never a shape-drift finding. */
81
- /** The tables this package owns. A model never declared one, so one
82
- * found in a database is the engine's own bookkeeping rather than
83
- * anybody's drift — the drift check skips them and the introspector
84
- * does not derive them. */
85
- export const ENGINE_TABLES = new Set([HISTORY_TABLE, CHANGES_TABLE, CHANGES_STATE_TABLE,
86
- JOBS_TABLE, JOB_CHECKPOINTS_TABLE, ...Object.values(REPLICATION_TABLES)]);
87
-
88
76
  /**
89
77
  * The signature-grade identity of a model SHAPE.
90
78
  * @param {any} model - A jaren-model document
@@ -198,8 +186,16 @@ export function planMigration(fromModel, toModel, options = undefined) {
198
186
  const dialect = options?.dialect ?? null;
199
187
  if (dialect === null || typeof dialect !== 'object')
200
188
  throw new TypeError('planMigration needs { dialect } (the store dialect renders the DDL)');
201
- if ([fromModel, toModel].some((m) => Object.values(m.entities ?? {}).some((e) => e.physical !== undefined)))
202
- throw refuse('JD0021', 'column layouts require planPhysicalMigration with explicit preservation dispositions');
189
+ if ([fromModel, toModel].some((m) => Object.values(m.entities ?? {}).some((e) => e.physical !== undefined))) {
190
+ normalizeEntities(fromModel);
191
+ normalizeEntities(toModel);
192
+ if (canonicalizeJson(fromModel) !== canonicalizeJson(toModel))
193
+ throw refuse('JD0021', 'changed column layouts require planTableMigration on the open connection or planPhysicalMigration with explicit preservation dispositions');
194
+ return { migration: { $migration: MIGRATION_VERSION,
195
+ id: options?.id ?? `to-${shapeHash(toModel).slice(0, 8)}`,
196
+ from: shapeHash(fromModel), to: shapeHash(toModel), steps: [] },
197
+ report: { renamed: [], added: [], removed: [], schemaChanged: [], drafts: [], destructive: false } };
198
+ }
203
199
  const mapping = { derived: options?.derived ?? 'virtual', rtree: options?.rtree !== false,
204
200
  // a model that declares an index EXPRESSION resolves its functions
205
201
  // here too: a plan is DDL, and DDL over a function this planner was
@@ -0,0 +1,4 @@
1
+ //@ts-check
2
+ /** Model metadata and read-only schema inspection without opening a store. */
3
+ export { normalizeEntities, explainMapping, compileEntityModel, relationTables } from './model.js';
4
+ export { readSchema, introspectModel, INTROSPECT_CODES } from './introspect.js';
package/src/model.js CHANGED
@@ -557,7 +557,19 @@ export function relationTables(entities) {
557
557
  * @returns {any}
558
558
  */
559
559
  export function explainMapping(model) {
560
+ return mappingOf(model, normalizeEntities(model));
561
+ }
562
+
563
+ /** Normalize and map once for lightweight engines sharing model metadata.
564
+ * No process-global cache retains the model or any connection.
565
+ * @param {any} model @returns {{entities:Map<string,any>,mapping:any}} */
566
+ export function compileEntityModel(model) {
560
567
  const entities = normalizeEntities(model);
568
+ return { entities, mapping: mappingOf(model, entities) };
569
+ }
570
+
571
+ /** @param {any} model @param {Map<string,any>} entities @returns {any} */
572
+ function mappingOf(model, entities) {
561
573
  /** @type {any} */
562
574
  const mapping = { entities: {}, joinTables: {} };
563
575
 
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';