@jarenjs/db 0.86.0 → 0.87.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/ARCHITECTURE.md CHANGED
@@ -1186,7 +1186,9 @@ both hosts, including transaction failures.
1186
1186
 
1187
1187
  `dialects/sqlite-relational.js` owns the explicit SQLite expression and statement
1188
1188
  compiler; `dialects/sqlite-schema.js` reuses it for ordered tables, indexes and
1189
- triggers. `table-migration.js` owns reviewed source/target guards, native copying
1189
+ triggers, sharing its column/reference renderer with native ADD COLUMN.
1190
+ `table-migration.js` owns reviewed source/settings guards for additive/object
1191
+ changes and source/target guards for native copying
1190
1192
  and preservation checks, with catalog/pragma spellings in the SQLite dialect.
1191
1193
  The supplied driver owns transactions and cursors. These programs are explicitly
1192
1194
  SQLite-semantic and never enter the JSON residual evaluator. See
@@ -1196,6 +1198,8 @@ SQLite-semantic and never enter the JSON residual evaluator. See
1196
1198
  does not import runtime owners. `/query`, `/model` and `/entity` expose those
1197
1199
  mechanisms without the root store import. `compileEntityModel` performs one model
1198
1200
  normalization for both entities and mapping; openStore reuses that result.
1201
+ `mutation.js` keeps bounded statement reuse keyed by emitted SQL; bindings,
1202
+ projections and limits are call-local, so cached plans do not retain old payloads.
1199
1203
 
1200
1204
  `drivers/worker-client.js` and `drivers/sqlite-endpoint.js` own the shared bounded
1201
1205
  RPC contract. Thread and process entries supply their transports. The process
package/README.md CHANGED
@@ -1441,12 +1441,17 @@ Native column reads and bounded mutation documents are specified in [NATIVE-PLAN
1441
1441
 
1442
1442
  `@jarenjs/db/search` composes the resident ranker with bounded authoritative entity reads and optional atomic snapshot storage. See [persisted search](docs/SEARCH.md).
1443
1443
 
1444
- Column-first table definitions, guarded same-connection rebuilds, exact matched writes,
1444
+ Column-first table definitions, native additive/object DDL, guarded same-connection rebuilds, exact matched writes,
1445
1445
  partial conflicts, raw-text JSON queries and byte-valued operations use
1446
1446
  [`@jarenjs/db/relational`](docs/SQLITE-RELATIONAL.md). Both host entries export
1447
1447
  `snapshotDatabase(connection, newPath)` for a disk-backed committed-WAL copy.
1448
1448
  Existing-connection consumers can import `/query`, `/model` and `/entity`;
1449
1449
  `compileEntityModel` shares one normalization between queries and mapping.
1450
+ `sql.call('json_type', ...)` preserves native missing/null/type distinctions.
1451
+ `planSchemaChange`/`applySchemaChange` guard a reviewed ADD COLUMN, DROP INDEX,
1452
+ RENAME TABLE or DROP TABLE against source drift. Identity-changing policies stay
1453
+ explicit; ordinary rebuilds retain key and row preservation. Mutation statements
1454
+ are reused by SQL without retaining previous payload-bearing documents.
1450
1455
 
1451
1456
  `@jarenjs/db/node-process` adds supervised native execution with finite owner
1452
1457
  admission, caller deadlines, generation fencing and separate process-exit and
@@ -2425,3 +2425,8 @@ physical entity retains adoption-only behavior. Public plans create complete
2425
2425
  ordered declarations; an incomplete generated/default definition or view remains
2426
2426
  adoption metadata. SQLite expressions preserve a separate, explicit semantic
2427
2427
  contract for raw text, bytes, nulls, collations and floating totals.
2428
+ `planSchemaChange`/`applySchemaChange` expose explicit native ADD COLUMN, DROP
2429
+ INDEX, RENAME TABLE and DROP TABLE with source/settings guards. They do not infer
2430
+ identity remapping or row deletion; that policy stays in an explicitly reviewed
2431
+ transaction. Entity mutation statement reuse is keyed by emitted SQL, while
2432
+ bindings, output projections and resource limits belong to each execution.
@@ -65,9 +65,11 @@ row limits bound fetched results; byte limits bound each decoded result item.
65
65
 
66
66
  ## Mutations
67
67
 
68
- The asynchronous entity set exposes `mutate(document)`. It compiles and caches a
69
- closed document into one parameterized SQLite data statement, inside the same
70
- guarded transaction used by the entity writer. It supports adopted writable
68
+ The asynchronous entity set exposes `mutate(document)`. It compiles each closed
69
+ document into one parameterized SQLite data statement and reuses statements by
70
+ their complete SQL in a bounded cache. Bindings, projections and output limits
71
+ remain local to each call; earlier payload-bearing documents are not retained.
72
+ Execution uses the same guarded transaction as the entity writer. It supports adopted writable
71
73
  column layouts. Hybrid entities, PostgreSQL physical layouts, arbitrary SQL,
72
74
  store-enforced before/after invariants and unsupported expression shapes refuse
73
75
  with `JD0038`. Database constraints and invariant triggers retain enforcement.
@@ -49,9 +49,16 @@ Selections support table and subquery sources, inner/left/cross joins, correlate
49
49
  `sql.scalar`/`sql.exists`, CASE, IN/NOT IN, DISTINCT, grouped and distinct
50
50
  aggregates, HAVING, UNION/UNION ALL and ordered windows. The declaration file
51
51
  lists the closed operators and functions, including trim/coalesce, LIKE, JSON
52
- extraction, casts and SQLite date functions. Neither arbitrary function names
52
+ extraction/type inspection, casts and SQLite date functions. Neither arbitrary function names
53
53
  nor a raw-expression escape hatch is accepted.
54
54
 
55
+ `sql.call('json_type', [document, path])` distinguishes a missing path (SQL NULL)
56
+ from JSON null (text `'null'`) and reports SQLite's native scalar/container type
57
+ names. Omitting the path inspects the whole document. SQL-null input stays null;
58
+ malformed JSON raises SQLite's error. It composes with CASE and synchronous
59
+ cursors without decoding or rewriting the original column. See
60
+ [SQLite JSON type inspection](https://www.sqlite.org/json1.html#the_json_type_function).
61
+
55
62
  ## Exact writes and bytes
56
63
 
57
64
  ```js
@@ -114,7 +121,9 @@ applyTableMigration(connection, migration);
114
121
  Columns retain declaration order and exact INTEGER/REAL/TEXT/BLOB/NUMERIC/ANY
115
122
  types. Definitions support ordered primary keys, rowid or AUTOINCREMENT identity,
116
123
  nullability, database defaults, generated columns, STRICT/WITHOUT ROWID, named
117
- UNIQUE/CHECK/foreign-key constraints and delete/update actions. Index terms
124
+ UNIQUE/CHECK/foreign-key constraints and delete/update actions. A column can also
125
+ declare `references: { table, columns: [name], onDelete, onUpdate, deferred }`.
126
+ Index terms
118
127
  support expressions, direction and collation, with an optional partial predicate.
119
128
  Triggers support BEFORE/AFTER, INSERT/UPDATE/DELETE, UPDATE OF, OLD/NEW conditions,
120
129
  mutation steps and RAISE. Schema expressions use the same structural emitter,
@@ -132,8 +141,9 @@ physical tables use the live-schema `planTableMigration` API.
132
141
  ## Guarded upgrades
133
142
 
134
143
  Planning inspects the existing schema without modifying it. A differing existing
135
- table requires `allowRebuild: true`; additions currently use the same guarded
136
- rebuild. Every removed column needs `dropColumns`; removing an existing explicit
144
+ table requires `allowRebuild: true` when using `planTableMigration`; use the narrow
145
+ schema operations below to append a column without rebuilding. Every removed
146
+ column needs `dropColumns`; removing an existing explicit
137
147
  index/trigger requires `dropObjects`. Unmentioned indexes and triggers survive.
138
148
  An optional `copy` maps writable non-key target columns to structural expressions;
139
149
  new columns otherwise use their defaults. The plan retains the source schema and
@@ -157,6 +167,90 @@ refuses before DDL. Node/Bun regressions cover populated history and references,
157
167
  failed copies, repeat reopening, nested rollback, and process death after DROP
158
168
  with WAL recovery. These tests do not establish power-loss durability.
159
169
 
170
+ ## Additive and object operations
171
+
172
+ `planSchemaChange(connection, operation)` returns a reviewable
173
+ `{ version, operation, sql, source, settings, checksum }`. It reads the main schema
174
+ and relevant connection settings without executing DDL. `applySchemaChange`
175
+ checks that source and settings again under an IMMEDIATE transaction before
176
+ executing the single statement. It returns `{ changed }`, the number of schema
177
+ operations that changed the catalog, rather than the number of affected rows.
178
+ Both methods require the same available synchronous SQLite ownership as rebuilds.
179
+
180
+ ```js
181
+ import { planSchemaChange, applySchemaChange, sql } from '@jarenjs/db/relational';
182
+ const addition = planSchemaChange(connection, {
183
+ op: 'addColumn', table: 'entries', column: {
184
+ name: 'revision', type: 'INTEGER', nullable: false, default: 1,
185
+ check: sql.binary('>=', sql.column('revision'), 1),
186
+ },
187
+ });
188
+ // Review addition.sql and addition.source before execution.
189
+ applySchemaChange(connection, addition);
190
+ applySchemaChange(connection, planSchemaChange(connection, {
191
+ op: 'dropIndex', name: 'obsolete_index', ifExists: true,
192
+ }));
193
+ ```
194
+
195
+ The closed operations are `addColumn` (`table`, `column`), `dropIndex` (`name`,
196
+ optional `ifExists`), `renameTable` (`table`, `to`) and `dropTable` (`table`,
197
+ optional `ifExists`). Identifiers address **main** explicitly; a temporary table
198
+ with the same spelling cannot redirect an operation. A dot inside a name is a
199
+ literal character. Attached database operations are not part of this surface.
200
+ The column definition and expression renderer are shared with `planTable`.
201
+
202
+ ADD COLUMN appends a declaration and preserves existing rowids, values, storage
203
+ classes, column order, indexes and triggers. It does not derive a complete table
204
+ definition, normalize unknown constraints or copy the table. Literal defaults
205
+ are supported; identity columns, STORED generated columns and expression defaults
206
+ refuse. Ordinary NOT NULL additions require a non-null default. REFERENCES
207
+ additions require a NULL default (or no default), and a single referenced column.
208
+ SQLite checks existing rows for a new CHECK or generated NOT NULL constraint;
209
+ those checks can scan the table even though the operation does not copy it.
210
+ See [SQLite ADD COLUMN restrictions](https://www.sqlite.org/lang_altertable.html#alter_table_add_column).
211
+
212
+ Drop operations fail on absence unless `ifExists: true` is supplied. A missing
213
+ object then returns `changed: 0`. DROP INDEX affects the named index, not its
214
+ table or triggers. DROP TABLE removes the table and its owned indexes/triggers;
215
+ SQLite's active foreign-key actions still apply. RENAME follows SQLite's current
216
+ dependency rewriting behavior and the reviewed `legacy_alter_table` setting.
217
+ See [DROP TABLE](https://www.sqlite.org/lang_droptable.html) and
218
+ [RENAME TABLE](https://www.sqlite.org/lang_altertable.html#alter_table_rename).
219
+
220
+ These are **single-source plans**, not durable migration receipts. Replan after
221
+ any schema change. Reapplying a successful ADD/RENAME plan refuses as stale;
222
+ it does not infer completion from a same-named object. An ordered migration must
223
+ check its trusted schema/version receipt before planning the next operation and
224
+ record completion in the same transaction. Unknown members or unsupported column
225
+ declarations refuse with `JD0005`; modified/stale plans refuse with `JD0021`.
226
+ Native object, data-constraint and dependency errors are left to SQLite. A
227
+ checksum detects accidental plan edits; it does not authorize untrusted plans.
228
+
229
+ ## Explicit identity-changing upgrades
230
+
231
+ The ordinary rebuild planner continues to refuse key reassignment or row loss.
232
+ A reviewed upgrade can use the primitive operations under
233
+ `withForeignKeysSuspended`, with its own explicit data policy:
234
+
235
+ 1. Check the durable migration receipt or the exact supported legacy schema.
236
+ 2. Create a distinct replacement table with `planTable`.
237
+ 3. Use relational insert-select with a scoped correlated selection, deterministic
238
+ identity choice and explicit exclusion predicate. Assert selected, inserted
239
+ and excluded counts, and any business-specific preservation requirements.
240
+ 4. Plan/apply `dropTable` for the original and then `renameTable` for the
241
+ replacement. Create the required indexes/triggers and validate dependents.
242
+ 5. Record completion inside the same transaction so another opening does no work.
243
+
244
+ Create the replacement before dropping the original; renaming the original first
245
+ can redirect dependent references. Plan each primitive inside the FK scope, after
246
+ the preceding schema operation. The helper checks references before commit and
247
+ restores connection settings. The caller must review incoming key references,
248
+ views, triggers, immutable neighbors and each row disposition. Conflicts fail and
249
+ roll back; there is no inferred permission to discard rows. The installed native
250
+ [qualification fixture](../../../test/db/fixtures/schema-upgrade.mjs) demonstrates
251
+ scoped minimum-ID selection, explicit exclusions, rollback, repeated reopening
252
+ and recovery after actual process death at DROP.
253
+
160
254
  ## Read-only inspection and disk snapshots
161
255
 
162
256
  `readSchema` from `@jarenjs/db/model` inventories tables without adopting them,
@@ -188,3 +282,15 @@ calls. No global strong model cache is introduced. Keep connection/query caches
188
282
  bounded and reuse compiled metadata. Smaller import graphs alone do not prove
189
283
  the complete application meets its RSS budget; measure application memory with
190
284
  representative workloads.
285
+
286
+ Entity mutation engines retain prepared statements by their complete emitted SQL
287
+ in the bounded core LRU cache. Bound values, output projections and row/byte limits
288
+ belong to each execution; changing a payload does not retain another document and
289
+ another copy of the same statement. Distinct SQL stays isolated. This reduces
290
+ retained payload memory, at the cost of rebinding/compiling an identical mutation
291
+ document on each call. The synthetic retention probe at
292
+ `test/db/fixtures/mutation-memory.mjs` reports both varying-payload and identical
293
+ mutation timings, heap and RSS on Node (`--expose-gc`) and Bun. Such samples do not
294
+ replace a complete application's resource gate. Metadata remains caller-owned;
295
+ keep one compiled mapping per used model, one query state per connection, and
296
+ release facade/cache references on close. Driver cursors remain ephemeral.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/db",
3
3
  "private": false,
4
- "version": "0.86.0",
4
+ "version": "0.87.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./types/index.d.ts",
@@ -108,9 +108,9 @@
108
108
  "prepack": "npm run build:types"
109
109
  },
110
110
  "dependencies": {
111
- "@jarenjs/core": "^0.86.0",
112
- "@jarenjs/json": "^0.86.0",
113
- "@jarenjs/validate": "^0.86.0"
111
+ "@jarenjs/core": "^0.87.0",
112
+ "@jarenjs/json": "^0.87.0",
113
+ "@jarenjs/validate": "^0.87.0"
114
114
  },
115
115
  "bin": {
116
116
  "jaren-db": "./src/cli.js"
@@ -22,7 +22,7 @@ export function relationalIdentifier(name) {
22
22
  }
23
23
  const q = relationalIdentifier;
24
24
  const binary = new Set(['=', '<>', '<', '<=', '>', '>=', 'IS', 'IS NOT', '+', '-', '*', '/', '%', '||', 'AND', 'OR', 'LIKE', 'NOT LIKE', 'GLOB']);
25
- const functions = new Set(['coalesce', 'nullif', 'trim', 'ltrim', 'rtrim', 'lower', 'upper', 'length', 'abs', 'round', 'typeof', 'json_extract', 'json_valid', 'count', 'sum', 'total', 'avg', 'min', 'max', 'date', 'time', 'datetime', 'julianday', 'unixepoch', 'strftime']);
25
+ const functions = new Set(['coalesce', 'nullif', 'trim', 'ltrim', 'rtrim', 'lower', 'upper', 'length', 'abs', 'round', 'typeof', 'json_extract', 'json_valid', 'json_type', 'count', 'sum', 'total', 'avg', 'min', 'max', 'date', 'time', 'datetime', 'julianday', 'unixepoch', 'strftime']);
26
26
  const types = new Set(['INTEGER', 'REAL', 'TEXT', 'BLOB', 'NUMERIC']);
27
27
  const collations = new Set(['BINARY', 'NOCASE', 'RTRIM']);
28
28
  const node = (kind, spec) => ({ $sql: kind, ...spec });
@@ -104,6 +104,7 @@ export function relationalEmitter(options = {}) {
104
104
  if (value.distinct !== undefined && typeof value.distinct !== 'boolean') fail('DISTINCT must be boolean');
105
105
  if (value.distinct && value.args.length !== 1) fail('DISTINCT functions require one argument');
106
106
  if (!value.args.length && value.name !== 'count') fail('SQL function requires arguments');
107
+ if (value.name === 'json_type' && value.args.length > 2) fail('json_type requires one or two arguments');
107
108
  return `${value.name.toUpperCase()}(${value.distinct ? 'DISTINCT ' : ''}${value.args.length ? value.args.map(next).join(', ') : '*'})`;
108
109
  }
109
110
  case 'cast':
@@ -34,37 +34,13 @@ export function planTable(definition) {
34
34
  for (const key of ['constraints', 'indexes', 'triggers']) if (definition[key] !== undefined && !Array.isArray(definition[key])) fail(`${key} must be a list`);
35
35
  const emitter = relationalEmitter({ inline: true });
36
36
  const names = new Set();
37
- let inlineKey = false;
38
37
  const columns = definition.columns.map((column) => {
39
- check(column, ['name', 'type', 'nullable', 'default', 'collation', 'identity', 'check', 'generated', 'stored'], 'column definition');
40
- const name = q(column.name);
38
+ const text = columnSql(column, definition, emitter);
41
39
  if (names.has(column.name.toLowerCase())) fail('physical column names must be distinct');
42
40
  names.add(column.name.toLowerCase());
43
- if (!['INTEGER', 'REAL', 'TEXT', 'BLOB', 'NUMERIC', 'ANY'].includes(column.type)) fail('unsupported SQLite column type');
44
- if (definition.strict && column.type === 'NUMERIC') fail('STRICT tables do not support NUMERIC');
45
- if (column.nullable !== undefined && typeof column.nullable !== 'boolean') fail('nullable must be boolean');
46
- if (column.stored !== undefined && (typeof column.stored !== 'boolean' || column.generated === undefined)) fail('stored requires a generated expression');
47
- let out = `${name} ${column.type}`;
48
- if (column.identity !== undefined) {
49
- if (!['rowid', 'autoincrement'].includes(column.identity) || column.type !== 'INTEGER'
50
- || definition.withoutRowid || definition.primaryKey?.length !== 1
51
- || definition.primaryKey[0] !== column.name || column.generated !== undefined) fail('identity requires a single INTEGER rowid primary key');
52
- inlineKey = true;
53
- out += ` PRIMARY KEY${column.identity === 'autoincrement' ? ' AUTOINCREMENT' : ''}`;
54
- }
55
- if (column.nullable === false) out += ' NOT NULL';
56
- if (column.collation !== undefined) {
57
- if (!['BINARY', 'NOCASE', 'RTRIM'].includes(column.collation)) fail('unsupported column collation');
58
- out += ` COLLATE ${column.collation}`;
59
- }
60
- if (Object.hasOwn(column, 'default')) out += ` DEFAULT (${emitter.expr(column.default)})`;
61
- if (column.check !== undefined) out += ` CHECK (${emitter.expr(column.check)})`;
62
- if (column.generated !== undefined) {
63
- if (Object.hasOwn(column, 'default')) fail('a generated column cannot have a default');
64
- out += ` GENERATED ALWAYS AS (${emitter.expr(column.generated)}) ${column.stored ? 'STORED' : 'VIRTUAL'}`;
65
- }
66
- return out;
41
+ return text;
67
42
  });
43
+ const inlineKey = definition.columns.some((column) => column.identity !== undefined);
68
44
  const members = (values) => {
69
45
  const text = list(values, 'constraint columns');
70
46
  if (values.some((name) => !names.has(name.toLowerCase()))) fail('constraint names an undeclared column');
@@ -79,11 +55,12 @@ export function planTable(definition) {
79
55
  else if (constraint.kind === 'check') columns.push(`${prefix}CHECK (${emitter.expr(constraint.expression)})`);
80
56
  else if (constraint.kind === 'foreignKey') {
81
57
  if (constraint.columns?.length !== constraint.references?.length) fail('foreign-key columns must have equal arity');
82
- if (constraint.deferred !== undefined && typeof constraint.deferred !== 'boolean') fail('deferred must be boolean');
83
- columns.push(`${prefix}FOREIGN KEY (${members(constraint.columns)}) REFERENCES ${q(constraint.table)} (${list(constraint.references, 'references')})`
84
- + (constraint.onDelete === undefined ? '' : ` ON DELETE ${action(constraint.onDelete)}`)
85
- + (constraint.onUpdate === undefined ? '' : ` ON UPDATE ${action(constraint.onUpdate)}`)
86
- + (constraint.deferred ? ' DEFERRABLE INITIALLY DEFERRED' : ''));
58
+ columns.push(`${prefix}FOREIGN KEY (${members(constraint.columns)}) ${referenceSql({
59
+ table: constraint.table, columns: constraint.references,
60
+ ...(constraint.onDelete === undefined ? {} : { onDelete: constraint.onDelete }),
61
+ ...(constraint.onUpdate === undefined ? {} : { onUpdate: constraint.onUpdate }),
62
+ ...(constraint.deferred === undefined ? {} : { deferred: constraint.deferred }),
63
+ })}`);
87
64
  }
88
65
  else fail('constraint kind is unique, check or foreignKey');
89
66
  const keys = constraint.kind === 'unique' ? ['kind', 'name', 'columns']
@@ -140,3 +117,76 @@ export function planTable(definition) {
140
117
  indexes: (definition.indexes ?? []).map((i) => ({ name: i.name, unique: i.unique === true, terms: i.terms })),
141
118
  } };
142
119
  }
120
+
121
+ /** One REFERENCES clause shared by table constraints and column declarations. */
122
+ function referenceSql(reference) {
123
+ check(reference, ['table', 'columns', 'onDelete', 'onUpdate', 'deferred'], 'reference');
124
+ if (reference.deferred !== undefined && typeof reference.deferred !== 'boolean') fail('deferred must be boolean');
125
+ return `REFERENCES ${q(reference.table)} (${list(reference.columns, 'references')})`
126
+ + (reference.onDelete === undefined ? '' : ` ON DELETE ${action(reference.onDelete)}`)
127
+ + (reference.onUpdate === undefined ? '' : ` ON UPDATE ${action(reference.onUpdate)}`)
128
+ + (reference.deferred ? ' DEFERRABLE INITIALLY DEFERRED' : '');
129
+ }
130
+
131
+ /** Render one typed column without reconstructing any surrounding schema. */
132
+ function columnSql(column, context, emitter) {
133
+ check(column, ['name', 'type', 'nullable', 'default', 'collation', 'identity', 'check', 'generated', 'stored', 'references'], 'column definition');
134
+ const name = q(column.name);
135
+ if (!['INTEGER', 'REAL', 'TEXT', 'BLOB', 'NUMERIC', 'ANY'].includes(column.type)) fail('unsupported SQLite column type');
136
+ if (context.strict && column.type === 'NUMERIC') fail('STRICT tables do not support NUMERIC');
137
+ if (column.nullable !== undefined && typeof column.nullable !== 'boolean') fail('nullable must be boolean');
138
+ if (column.stored !== undefined && (typeof column.stored !== 'boolean' || column.generated === undefined)) fail('stored requires a generated expression');
139
+ const hasDefault = Object.hasOwn(column, 'default');
140
+ if (context.additive) {
141
+ if (column.identity !== undefined || column.stored === true) fail('ADD COLUMN cannot add an identity or STORED column');
142
+ const value = column.default?.$sql === 'value' ? column.default.value : column.default;
143
+ if (hasDefault && !(value === null || typeof value === 'string' || typeof value === 'bigint'
144
+ || (typeof value === 'number' && Number.isFinite(value)))) fail('ADD COLUMN requires a literal default');
145
+ if (column.generated === undefined && column.nullable === false && (!hasDefault || value === null)) fail('ADD COLUMN NOT NULL requires a non-null default');
146
+ if (column.references !== undefined && hasDefault && value !== null) fail('ADD COLUMN REFERENCES requires a NULL default');
147
+ }
148
+ let out = `${name} ${column.type}`;
149
+ if (column.identity !== undefined) {
150
+ if (!['rowid', 'autoincrement'].includes(column.identity) || column.type !== 'INTEGER'
151
+ || context.withoutRowid || context.primaryKey?.length !== 1
152
+ || context.primaryKey[0] !== column.name || column.generated !== undefined) fail('identity requires a single INTEGER rowid primary key');
153
+ out += ` PRIMARY KEY${column.identity === 'autoincrement' ? ' AUTOINCREMENT' : ''}`;
154
+ }
155
+ if (column.nullable === false) out += ' NOT NULL';
156
+ if (column.collation !== undefined) {
157
+ if (!['BINARY', 'NOCASE', 'RTRIM'].includes(column.collation)) fail('unsupported column collation');
158
+ out += ` COLLATE ${column.collation}`;
159
+ }
160
+ if (hasDefault) out += context.additive ? ` DEFAULT ${emitter.expr(column.default)}` : ` DEFAULT (${emitter.expr(column.default)})`;
161
+ if (column.check !== undefined) out += ` CHECK (${emitter.expr(column.check)})`;
162
+ if (column.generated !== undefined) {
163
+ if (hasDefault) fail('a generated column cannot have a default');
164
+ out += ` GENERATED ALWAYS AS (${emitter.expr(column.generated)}) ${column.stored ? 'STORED' : 'VIRTUAL'}`;
165
+ }
166
+ if (column.references !== undefined) {
167
+ if (column.references?.columns?.length !== 1) fail('a column reference requires one referenced column');
168
+ out += ` ${referenceSql(column.references)}`;
169
+ }
170
+ return out;
171
+ }
172
+
173
+ /** Render one explicit main-schema operation; validation never executes SQL.
174
+ * @param {any} operation @returns {string} */
175
+ export function schemaChangeSql(operation) {
176
+ check(operation, ['op', 'table', 'column', 'name', 'to', 'ifExists'], 'schema change');
177
+ switch (operation.op) {
178
+ case 'addColumn':
179
+ check(operation, ['op', 'table', 'column'], 'addColumn');
180
+ return `ALTER TABLE "main".${q(operation.table)} ADD COLUMN ${columnSql(operation.column, { additive: true }, relationalEmitter({ inline: true }))}`;
181
+ case 'renameTable':
182
+ check(operation, ['op', 'table', 'to'], 'renameTable');
183
+ return `ALTER TABLE "main".${q(operation.table)} RENAME TO ${q(operation.to)}`;
184
+ case 'dropIndex': case 'dropTable': {
185
+ const index = operation.op === 'dropIndex';
186
+ check(operation, ['op', index ? 'name' : 'table', 'ifExists'], operation.op);
187
+ if (operation.ifExists !== undefined && typeof operation.ifExists !== 'boolean') fail('ifExists must be boolean');
188
+ return `DROP ${index ? 'INDEX' : 'TABLE'}${operation.ifExists ? ' IF EXISTS' : ''} "main".${q(index ? operation.name : operation.table)}`;
189
+ }
190
+ default: return fail('schema change is addColumn, dropIndex, renameTable or dropTable');
191
+ }
192
+ }
package/src/index.js CHANGED
@@ -105,4 +105,4 @@ export { planInvariants } from './ddl.js';
105
105
  export { planPhysicalMigration } from './migrate.js';
106
106
  export { sql, planRelational, relational } from './dialects/sqlite-relational.js';
107
107
  export { defineTable, planTable } from './dialects/sqlite-schema.js';
108
- export { planTableMigration, applyTableMigration, withForeignKeysSuspended } from './table-migration.js';
108
+ export { planTableMigration, applyTableMigration, withForeignKeysSuspended, planSchemaChange, applySchemaChange } from './table-migration.js';
package/src/mutation.js CHANGED
@@ -1,7 +1,7 @@
1
1
  //@ts-check
2
2
  /** Bounded column mutation documents, lowered through the entity's writer plan. */
3
3
  import { analyzeQuery } from '@jarenjs/json/query';
4
- import { canonicalizeJson } from '@jarenjs/json/canonical';
4
+ import { createBoundedCache } from '@jarenjs/core/cache';
5
5
  import { chain, attempt } from './driver.js';
6
6
  import { DbCompileError, DbRuntimeError, wrapDriverError } from './errors.js';
7
7
  import { entityShape, planEntityPredicate } from './plan.js';
@@ -10,12 +10,13 @@ import { physicalSelection } from './physical.js';
10
10
  import { utf8Length } from './cursor.js';
11
11
  import { relationalEmitter } from './dialects/sqlite-relational.js';
12
12
 
13
- /** Compile once per document, execute within the existing guarded transaction.
13
+ /** Bind each document, reuse its SQL statement within the guarded transaction.
14
14
  * @param {any} connection @param {any} entity @param {any} mapping @param {any} core */
15
15
  export function createEntityMutation(connection, entity, mapping, core) {
16
16
  const dialect = connection.dialect;
17
17
  const q = dialect.quoteIdentifier;
18
- const plans = new Map();
18
+ // Cache executable structure, never a value-bearing document or its bindings.
19
+ const statements = createBoundedCache(64);
19
20
  const fail = (reason) => { throw new DbCompileError('JD0038', reason, entity.docPath); };
20
21
  const column = (name) => {
21
22
  const c = mapping.columns.find((entry) => entry.name === name);
@@ -56,15 +57,17 @@ export function createEntityMutation(connection, entity, mapping, core) {
56
57
  const param = (value) => { params.push(value); return dialect.parameterRef(params.length, 'v'); };
57
58
  const table = q(mapping.table);
58
59
  const sqlExpression = (expression, inline = false) => {
59
- const mapped = (value) => {
60
- if (Array.isArray(value)) return value.map(mapped);
60
+ const mapped = (value, depth = 0) => {
61
+ if (depth > 64) fail('SQL expression nesting exceeds 64');
62
+ const next = (child) => mapped(child, depth + 1);
63
+ if (Array.isArray(value)) return value.map(next);
61
64
  if (value === null || typeof value !== 'object') return value;
62
65
  if (value.$sql === 'value') return value;
63
66
  if (value.$sql === 'column') {
64
67
  if (value.table !== undefined && value.table !== 'it') fail('mutation column expressions refer to the current entity');
65
68
  return { $sql: 'column', name: column(value.name).physical };
66
69
  }
67
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, mapped(item)]));
70
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, next(item)]));
68
71
  };
69
72
  const emitter = relationalEmitter({ inline });
70
73
  const result = emitter.expr(mapped(expression));
@@ -187,19 +190,13 @@ export function createEntityMutation(connection, entity, mapping, core) {
187
190
  }
188
191
  }
189
192
  sql = prefix + sql + ` RETURNING ${physicalSelection(mapping, dialect)}`;
190
- return { sql, params, returning, maxRows, maxBytes, statement: null };
193
+ return { sql, params, returning, maxRows, maxBytes };
191
194
  };
192
195
  return (document) => {
193
- const key = canonicalizeJson(document);
194
- let plan = plans.get(key);
195
- if (!plan) {
196
- plan = compile(document);
197
- if (plans.size >= 64) plans.delete(plans.keys().next().value);
198
- plans.set(key, plan);
199
- }
196
+ const plan = compile(document);
200
197
  return connection.transaction(() => {
201
- plan.statement ??= connection.prepare(plan.sql);
202
- return chain(plan.statement, (statement) => chain(attempt(() => statement.all(plan.params),
198
+ const prepared = statements.getOrCreate(plan.sql, (text) => connection.prepare(text));
199
+ return chain(prepared, (statement) => chain(attempt(() => statement.all(plan.params),
203
200
  (error) => String(error?.message).includes('jaren-mutation-row-bound')
204
201
  ? new DbRuntimeError('JD2007', 'insert-select exceeded its source row bound', { cause: error })
205
202
  : wrapDriverError(error, { collection: entity.name, docPath: entity.docPath })), (rows) => {
@@ -2,5 +2,5 @@
2
2
  /** Lightweight column-first SQLite authoring and execution. */
3
3
  export { sql, planRelational, relational } from './dialects/sqlite-relational.js';
4
4
  export { defineTable, planTable } from './dialects/sqlite-schema.js';
5
- export { planTableMigration, applyTableMigration, withForeignKeysSuspended } from './table-migration.js';
5
+ export { planTableMigration, applyTableMigration, withForeignKeysSuspended, planSchemaChange, applySchemaChange } from './table-migration.js';
6
6
  export { sqliteDialect } from './dialects/sqlite.js';
@@ -3,7 +3,7 @@
3
3
  import { hashContent } from '@jarenjs/core/string';
4
4
  import { canonicalizeJson } from '@jarenjs/json/canonical';
5
5
  import { DbCompileError } from './errors.js';
6
- import { defineTable, planTable } from './dialects/sqlite-schema.js';
6
+ import { defineTable, planTable, schemaChangeSql } from './dialects/sqlite-schema.js';
7
7
  import { relationalEmitter, relationalIdentifier as q, sql } from './dialects/sqlite-relational.js';
8
8
  import { sqliteDialect as dialect, sqliteTableMigration } from './dialects/sqlite.js';
9
9
  import { sqlTokens } from './dialects/check-read.js';
@@ -17,6 +17,39 @@ const sync = (connection) => {
17
17
  if (connection.dialect.name !== 'sqlite' || !connection.synchronous || connection.mustQueue) refuse('table migration requires an available synchronous SQLite connection');
18
18
  };
19
19
 
20
+ /** Connection settings that govern the meaning of an additive/drop/rename plan. */
21
+ function schemaSettings(connection) {
22
+ return ['foreign_keys', 'legacy_alter_table', 'schema_version'].map((name) =>
23
+ connection.prepare(dialect.introspect.pragma(name)).get([])[name]);
24
+ }
25
+
26
+ /** Review one native main-schema change without changing the database.
27
+ * Plans describe one source snapshot; callers own durable migration receipts.
28
+ * @param {any} connection @param {any} operation */
29
+ export function planSchemaChange(connection, operation) {
30
+ sync(connection);
31
+ const text = schemaChangeSql(operation);
32
+ const body = { version: 1, operation: structuredClone(operation), sql: text,
33
+ source: schema(connection), settings: schemaSettings(connection) };
34
+ return { ...body, checksum: fingerprint({ ...body, operation: text }) };
35
+ }
36
+
37
+ /** Apply a reviewed native change atomically, refusing schema/settings drift.
38
+ * Replan after any schema change; only explicit drop ifExists handles absence.
39
+ * @param {any} connection @param {ReturnType<typeof planSchemaChange>} plan */
40
+ export function applySchemaChange(connection, plan) {
41
+ sync(connection);
42
+ const { checksum, ...body } = plan;
43
+ if (body.version !== 1 || checksum !== fingerprint({ ...body, operation: plan.sql }) || plan.sql !== schemaChangeSql(plan.operation)) refuse('schema change checksum or statement differs');
44
+ return connection.transaction(() => {
45
+ const before = schema(connection);
46
+ if (fingerprint(before) !== fingerprint(plan.source) || fingerprint(schemaSettings(connection)) !== fingerprint(plan.settings))
47
+ refuse('source schema or connection settings changed after planning');
48
+ connection.exec(plan.sql);
49
+ return { changed: fingerprint(before) === fingerprint(schema(connection)) ? 0 : 1 };
50
+ }, { mode: 'immediate' });
51
+ }
52
+
20
53
  /** Inspect a live schema and generate a table plan without changing it.
21
54
  * Rebuilds require explicit opt-in. Unlisted indexes and triggers are preserved.
22
55
  * @param {any} connection @param {any} definition
package/types/index.d.ts CHANGED
@@ -2045,4 +2045,4 @@ export declare function planPhysicalMigration(connection: unknown, fromModel: un
2045
2045
  options: { id: string; steps: readonly unknown[]; dispositions: Readonly<Record<string, 'preserve' | 'replace' | 'drop'>>;
2046
2046
  assertions?: readonly { sql: string; params?: readonly unknown[]; expected: readonly unknown[] }[] }): unknown;
2047
2047
 
2048
- export { sql, relational, planRelational, defineTable, planTable, planTableMigration, applyTableMigration, withForeignKeysSuspended } from './relational.js';
2048
+ export { sql, relational, planRelational, defineTable, planTable, planTableMigration, applyTableMigration, withForeignKeysSuspended, planSchemaChange, applySchemaChange } from './relational.js';
@@ -4,7 +4,7 @@ export type SqlInput = SqlValue | SqlExpression;
4
4
  export type SqlOperator = '=' | '<>' | '<' | '<=' | '>' | '>=' | 'IS' | 'IS NOT'
5
5
  | '+' | '-' | '*' | '/' | '%' | '||' | 'AND' | 'OR' | 'LIKE' | 'NOT LIKE' | 'GLOB';
6
6
  export type SqlFunction = 'coalesce' | 'nullif' | 'trim' | 'ltrim' | 'rtrim' | 'lower' | 'upper'
7
- | 'length' | 'abs' | 'round' | 'typeof' | 'json_extract' | 'json_valid'
7
+ | 'length' | 'abs' | 'round' | 'typeof' | 'json_extract' | 'json_valid' | 'json_type'
8
8
  | 'count' | 'sum' | 'total' | 'avg' | 'min' | 'max'
9
9
  | 'date' | 'time' | 'datetime' | 'julianday' | 'unixepoch' | 'strftime';
10
10
  export type SqlType = 'INTEGER' | 'REAL' | 'TEXT' | 'BLOB' | 'NUMERIC';
@@ -78,8 +78,13 @@ export interface TableColumn {
78
78
  readonly default?: SqlInput; readonly collation?: SqlCollation;
79
79
  readonly identity?: 'rowid' | 'autoincrement'; readonly check?: SqlInput;
80
80
  readonly generated?: SqlInput; readonly stored?: boolean;
81
+ readonly references?: ColumnReference;
81
82
  }
82
83
  export type ForeignKeyAction = 'cascade' | 'restrict' | 'no action' | 'set null' | 'set default';
84
+ export interface ColumnReference {
85
+ readonly table: string; readonly columns: readonly [string];
86
+ readonly onDelete?: ForeignKeyAction; readonly onUpdate?: ForeignKeyAction; readonly deferred?: boolean;
87
+ }
83
88
  export type TableConstraint = { readonly name?: string } & (
84
89
  { readonly kind: 'unique'; readonly columns: readonly string[] }
85
90
  | { readonly kind: 'check'; readonly expression: SqlInput }
@@ -111,4 +116,17 @@ export interface TableMigrationPlan {
111
116
  export declare function planTableMigration(connection: unknown, definition: TableDefinition, options: TableMigrationOptions): TableMigrationPlan;
112
117
  export declare function applyTableMigration(connection: unknown, plan: TableMigrationPlan): { changed: number };
113
118
  export declare function withForeignKeysSuspended<T>(connection: unknown, fn: () => T): T;
119
+ export type SchemaChange =
120
+ | { readonly op: 'addColumn'; readonly table: string; readonly column: TableColumn }
121
+ | { readonly op: 'dropIndex'; readonly name: string; readonly ifExists?: boolean }
122
+ | { readonly op: 'renameTable'; readonly table: string; readonly to: string }
123
+ | { readonly op: 'dropTable'; readonly table: string; readonly ifExists?: boolean };
124
+ export interface SchemaChangePlan {
125
+ readonly version: 1; readonly operation: SchemaChange; readonly sql: string;
126
+ readonly source: readonly unknown[]; readonly settings: readonly number[]; readonly checksum: string;
127
+ }
128
+ /** Main-schema snapshot; does not execute SQL or infer replay/disposition policy. */
129
+ export declare function planSchemaChange(connection: unknown, operation: SchemaChange): SchemaChangePlan;
130
+ /** Refuses stale source/settings under an immediate transaction. */
131
+ export declare function applySchemaChange(connection: unknown, plan: SchemaChangePlan): { changed: number };
114
132
  export { sqliteDialect } from './index.js';