@jarenjs/db 0.85.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,3 +1198,12 @@ 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.
1203
+
1204
+ `drivers/worker-client.js` and `drivers/sqlite-endpoint.js` own the shared bounded
1205
+ RPC contract. Thread and process entries supply their transports. The process
1206
+ driver retains owner credits until OS exit, fences lost generations, and combines
1207
+ observed native transaction state with pending statement metadata to distinguish
1208
+ rollback from an unknown commit. Supervision never serializes callbacks or replays
1209
+ transactions; Store ownership remains in the existing driver/Store gates.
package/README.md CHANGED
@@ -888,7 +888,8 @@ oracle run on both. What differs is declared rather than discovered:
888
888
  | `ALTER TABLE` | additive only | full, so a rebuild is never needed |
889
889
 
890
890
  Neither backend has a **statement timeout** (`capabilities.statementTimeout`
891
- is `false` on both) or **row estimates**. There is no replication.
891
+ is `false` on both) or **row estimates**. Portable replication uses SQLite
892
+ change capture; PostgreSQL capture and replication remain unqualified.
892
893
  Cross-process concurrency on SQLite is its own story — WAL plus a busy
893
894
  timeout, both set and visible on `store.capabilities`; on PostgreSQL it
894
895
  is the server's, and a serialization failure or a deadlock arrives as
@@ -1211,16 +1212,18 @@ same report rows — with the one difference the PostgreSQL mapping
1211
1212
  states: `numeric` carries both JSON number types, so an `integer`
1212
1213
  member reads back as `number`.
1213
1214
 
1214
- ## Sync-readiness — what exists and what does not
1215
+ ## Replication and external-write boundaries
1215
1216
 
1216
- The change stream is an ordered log of RFC 6902 patches with a
1217
- monotonic sequence, and SQLite's own changeset/conflict primitives are
1218
- available which is what a replication protocol would be *built
1219
- from*. **No replication is shipped.** There is no conflict resolution,
1220
- no site identity, no causal ordering across writers, and no capture of
1221
- writes made by another connection (the coarse `dataVersion()` signal
1222
- is the honest mitigation, not a pretend fine-grained one). Building
1223
- replication on these primitives is a roadmap item, not a hint.
1217
+ The portable replication engine supplies replica identities, causal frontiers,
1218
+ bounded logical envelopes, durable replay receipts, conflict evidence and snapshot
1219
+ resets. Data and acknowledgements commit together; hosts supply transport and any
1220
+ conflict resolver. The default preserves both contenders and rejects the write.
1221
+ See [REPLICATION-FORMAT](docs/REPLICATION-FORMAT.md) for the current contract.
1222
+
1223
+ Capture observes participating Store writes on supported SQLite hosts. Arbitrary
1224
+ writes from another connection are not fine-grained capture events; `dataVersion()`
1225
+ provides coarse invalidation. Adopted triggers, unsupported layouts and PostgreSQL
1226
+ retain the explicit capture and replication qualification limits.
1224
1227
 
1225
1228
  ## Operating a store — configuration, maintenance, backup, cancellation, the queue
1226
1229
 
@@ -1311,10 +1314,11 @@ its side-effect-free status read are in
1311
1314
 
1312
1315
  ## What this is not — every non-claim in one place
1313
1316
 
1314
- - **SQLite only.** One backend (3.45+); the dialect seam is tested
1315
- against a double but no second dialect ships. No server.
1316
- - **No replication or sync engine** (see above). No cross-connection
1317
- change capture another connection's writes are invisible locally.
1317
+ - **SQLite and PostgreSQL have different capabilities.** Both backends ship;
1318
+ PostgreSQL does not provide the SQLite capture, replication, job queue or pragma
1319
+ maintenance capabilities. See the dialect table and normative host contracts.
1320
+ - **Replication transport is supplied by the host.** Portable logical replication
1321
+ ships; arbitrary external writes are not captured as local row events.
1318
1322
  - **No statement timeout** on SQLite (the drivers expose no interrupt;
1319
1323
  the capability slot is honestly `false`), no row estimates.
1320
1324
  - **Not safe for mutually hostile tenants** without the profile's
@@ -1359,6 +1363,7 @@ Every subpath a consumer can import, derived from the manifest by
1359
1363
  <!--fact:exports.db-->
1360
1364
  | Import | Kind | Declarations |
1361
1365
  |---|---|---|
1366
+ | `@jarenjs/db/node-process` | JavaScript | declared |
1362
1367
  | `@jarenjs/db/search` | JavaScript | declared |
1363
1368
  | `@jarenjs/db` | JavaScript | declared |
1364
1369
  | `@jarenjs/db/node` | JavaScript | declared |
@@ -1436,9 +1441,19 @@ Native column reads and bounded mutation documents are specified in [NATIVE-PLAN
1436
1441
 
1437
1442
  `@jarenjs/db/search` composes the resident ranker with bounded authoritative entity reads and optional atomic snapshot storage. See [persisted search](docs/SEARCH.md).
1438
1443
 
1439
- 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,
1440
1445
  partial conflicts, raw-text JSON queries and byte-valued operations use
1441
1446
  [`@jarenjs/db/relational`](docs/SQLITE-RELATIONAL.md). Both host entries export
1442
1447
  `snapshotDatabase(connection, newPath)` for a disk-backed committed-WAL copy.
1443
1448
  Existing-connection consumers can import `/query`, `/model` and `/entity`;
1444
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.
1455
+
1456
+ `@jarenjs/db/node-process` adds supervised native execution with finite owner
1457
+ admission, caller deadlines, generation fencing and separate process-exit and
1458
+ transaction-fate observations. See [execution hosts](docs/HOSTS.md#supervised-node-processes)
1459
+ for Store composition, receipt reconciliation and operating-system limits.
package/docs/HOSTS.md CHANGED
@@ -77,6 +77,132 @@ run on the caller. Synchronous Store methods and live queries are unavailable on
77
77
  these asynchronous connections. Bun can import both subpaths; opening a Node
78
78
  SQLite worker there reports the named unavailable-binding failure (`JD0003`).
79
79
 
80
+ ## Supervised Node processes
81
+
82
+ `nodeProcessDriver` from `@jarenjs/db/node-process` implements the same asynchronous
83
+ Driver and Connection contracts using one Node child process per connection. It
84
+ shares the worker protocol, cursor credits, transaction scopes and error handling
85
+ with the thread driver. SQLite and its native calls execute in the child; callbacks
86
+ and decoded query residuals still execute in the parent. No function is serialized.
87
+ Node.js 24 or newer is required. Bun can import the entry but `open` refuses with
88
+ `JD0003`. The native-call and process-lifecycle fixtures qualify Node 24.20.0 on
89
+ Linux; other supported operating systems require their own timing qualification.
90
+
91
+ ```js
92
+ import { nodeProcessDriver } from '@jarenjs/db/node-process';
93
+
94
+ const driver = nodeProcessDriver({ maxOwners: 2, timeoutMs: 250 });
95
+ const owner = await driver.open('application.sqlite', { timeout: 50, queueTimeout: 100 });
96
+ try {
97
+ const result = await owner.supervise(async (connection) => {
98
+ return connection.transaction(async (scope) => {
99
+ const statement = await scope.prepare('SELECT value FROM settings WHERE key = ?');
100
+ return statement.get(['theme']);
101
+ });
102
+ }, { signal: requestSignal, timeoutMs: 250 });
103
+ useResult(result);
104
+ } catch (error) {
105
+ if (error.code === 'JD2097' || error.code === 'JD2090') {
106
+ const observation = owner.settlement();
107
+ // This snapshot can still say quarantined / safeToReplace:false.
108
+ recordOwnerObservation(observation);
109
+ const exited = await owner.settled();
110
+ // Before repeating an uncertain write, inspect its durable receipt.
111
+ reconcileBeforeRetry(exited);
112
+ } else throw error;
113
+ } finally {
114
+ // Preserve the original operation outcome if closing a fenced generation fails.
115
+ await owner.close().catch(recordCleanupFailure);
116
+ }
117
+ ```
118
+
119
+ `supervise(body,{signal,timeoutMs})` admits one supervised callback per connection.
120
+ An overlapping callback refuses with `JD2091`; an already-aborted call runs no
121
+ callback and leaves the owner healthy. A deadline or abort rejects with `JD2097`,
122
+ fences all pending calls and signals process termination. Later operations on the
123
+ old generation fail with `JD2090`. A normal callback failure propagates unchanged
124
+ and does not terminate a healthy owner. `cancel(reason)` explicitly fences the
125
+ whole connection and returns its cancellation error. It affects every call sharing
126
+ that owner, so independent request lifetimes should use separate owners.
127
+
128
+ This is response cancellation and owner termination, not a SQLite statement
129
+ interrupt. `capabilities.process` and `ownerTermination` are true, while
130
+ `capabilities.cancellation.midStatement` remains false. The supervision timer cannot
131
+ preempt synchronous parent JavaScript or bound operating-system scheduling. Keep
132
+ callbacks/residuals bounded and await all admitted work. Native fixtures use a
133
+ short deadline and independently enforce a one-second caller-response ceiling;
134
+ that tested ceiling is not a universal scheduling guarantee.
135
+
136
+ `settlement()` reports the generation, PID, health, transaction fate and
137
+ `safeToReplace`. `settled()` resolves only after the child's OS exit event. An
138
+ owner awaiting termination is quarantined and still consumes its credit. If the
139
+ OS cannot settle a process promptly, the response can finish while the owner
140
+ remains quarantined; neither capacity nor writer ownership is recycled. `restart()`
141
+ waits for confirmed exit and returns a new generation; old Store handles remain
142
+ invalid. No statement, transaction or callback is automatically replayed.
143
+
144
+ The transaction observation is conservative:
145
+
146
+ - A live, acknowledged native transaction is `active`.
147
+ - An acknowledged single-statement completion is `committed`, or `rolled-back`
148
+ after rollback. A multi-statement program with no open transaction remains
149
+ `unknown`: an acknowledgement alone cannot distinguish its internal boundaries.
150
+ - Confirmed process death rolls back an observed open transaction only when no
151
+ pending operation could have committed it.
152
+ - A lost commit reply, multi-statement program or uncertain autocommit mutation
153
+ is `unknown`. Native transaction-state support is observed, never inferred from
154
+ a close promise. Reopen, verify integrity and reconcile a durable receipt before
155
+ retrying a write with an unknown outcome.
156
+
157
+ Autocommit mutation cursors remain `unknown` until their final native frame is
158
+ acknowledged. Creating an iterator or receiving an intermediate `RETURNING` row
159
+ does not acknowledge completion; early cursor return still requires reconciliation.
160
+
161
+ Driver `metrics()` reports capacity, owners, healthy owners and quarantine. A
162
+ file path already owned by the same driver cannot be opened again until exit;
163
+ this reservation uses its resolved path spelling, not filesystem inode identity.
164
+ Different driver instances, aliases and external processes remain the host's
165
+ coordination responsibility. Use one supervisor per ownership domain and retain
166
+ finite SQLite busy and transaction-queue timeouts. The host's service manager must
167
+ also own its process group: an uncatchable parent-process death is not a promise
168
+ that JavaScript can reap every descendant. Child crashes and restart are covered
169
+ by the file/receipt fixtures; power loss and service-manager policy are separate.
170
+
171
+ The ordinary Store composition uses `openStore(model,{driver,path})`. To supervise
172
+ Store calls, retain the connection in a small driver wrapper's `open` method, then
173
+ call `owner.supervise(() => store.transaction(body), options)`. This uses the same
174
+ Store and transaction APIs. After owner loss, close the invalid Store, await owner
175
+ exit and explicitly reopen/reconcile. A failed-generation `close()` may reject;
176
+ its rejection never substitutes for the separate exit observation.
177
+
178
+ All options below are positive safe integers. Row/byte/statement/cursor limits
179
+ retain the thread worker meanings. `maxOwners` includes startup and quarantine;
180
+ `timeoutMs` is the default supervised response deadline. `maxRequestBytes` bounds
181
+ each outgoing request before IPC; parameters must be SQLite scalars or byte arrays.
182
+ The process close deadline
183
+ and startup deadline can reject while termination is still pending. The reserved
184
+ owner credit remains until exit. Direct calls without `supervise`
185
+ retain the ordinary row, queue and step cancellation boundaries.
186
+
187
+ <!--fact:db.execution-options-->
188
+
189
+ | Option | Thread worker | Process owner |
190
+ |---|---:|---:|
191
+ | `windowRows` | 64 | 64 |
192
+ | `windowBytes` | 1048576 | 1048576 |
193
+ | `maxPending` | 64 | 64 |
194
+ | `maxStatements` | 1024 | 1024 |
195
+ | `maxCursors` | 64 | 64 |
196
+ | `allMaxRows` | 100000 | 100000 |
197
+ | `allMaxBytes` | 16777216 | 16777216 |
198
+ | `closeTimeoutMs` | 5000 | 1000 |
199
+ | `startupTimeoutMs` | 10000 | 10000 |
200
+ | `maxOwners` | — | 4 |
201
+ | `timeoutMs` | — | 250 |
202
+ | `maxRequestBytes` | — | 1048576 |
203
+
204
+ <!--/fact-->
205
+
80
206
  ## WAL pool policy
81
207
 
82
208
  A file pool opens exactly one writer and `readers` read-only workers, verifies WAL,
@@ -283,9 +409,10 @@ sync twin. Physical adoption on PostgreSQL refuses pending a separate mapping
283
409
  and codec qualification. Unknown application-trigger effects do not qualify
284
410
  capture or replication; those combinations refuse before an adoption claim.
285
411
 
286
- Node backups use the built-in online snapshot. Bun uses `Database.serialize()`
287
- under the store gate, writes and flushes a sibling temporary, then uses the shared
288
- atomic publisher. Both include committed WAL. Bun's snapshot holds the whole
289
- image in memory and cancellation takes effect between phases. Process-kill tests
412
+ Node backups use the built-in online snapshot. Bun uses disk-backed `VACUUM INTO`
413
+ under the store gate, flushes a sibling temporary, then uses the shared atomic
414
+ publisher. Both include committed WAL. Bun allocates no whole-database JavaScript
415
+ image; native SQLite caches and temporary storage govern working memory, and
416
+ cancellation takes effect between phases. Process-kill tests
290
417
  cover rebuild copy, table drop, commit and backup publication on both hosts;
291
418
  these tests do not establish power-loss durability or native executable packaging.
@@ -1101,6 +1101,7 @@ error.
1101
1101
  | `JD2094` | invalid or uncommitted durable snapshot; reopen the last committed version |
1102
1102
  | `JD2095` | trusted SQL or synchronous callback authority refused |
1103
1103
  | `JD2096` | persistence invariant rejected the mutation; constraint class |
1104
+ | `JD2097` | supervised native operation cancelled or past its response deadline; owner exit and transaction fate are reported separately |
1104
1105
  | `JD0060` | a replication envelope or snapshot is invalid |
1105
1106
  | `JD2100` | a replica sequence or causal dependency has a gap |
1106
1107
  | `JD2101` | an envelope identity names different content or an unknown local origin |
@@ -2424,3 +2425,8 @@ physical entity retains adoption-only behavior. Public plans create complete
2424
2425
  ordered declarations; an incomplete generated/default definition or view remains
2425
2426
  adoption metadata. SQLite expressions preserve a separate, explicit semantic
2426
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,12 +1,16 @@
1
1
  {
2
2
  "name": "@jarenjs/db",
3
3
  "private": false,
4
- "version": "0.85.0",
4
+ "version": "0.87.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./types/index.d.ts",
8
8
  "sideEffects": false,
9
9
  "exports": {
10
+ "./node-process": {
11
+ "types": "./types/node-process.d.ts",
12
+ "default": "./src/drivers/node-process.js"
13
+ },
10
14
  "./search": {
11
15
  "types": "./types/search.d.ts",
12
16
  "default": "./src/search.js"
@@ -104,9 +108,9 @@
104
108
  "prepack": "npm run build:types"
105
109
  },
106
110
  "dependencies": {
107
- "@jarenjs/core": "^0.85.0",
108
- "@jarenjs/json": "^0.85.0",
109
- "@jarenjs/validate": "^0.85.0"
111
+ "@jarenjs/core": "^0.87.0",
112
+ "@jarenjs/json": "^0.87.0",
113
+ "@jarenjs/validate": "^0.87.0"
110
114
  },
111
115
  "bin": {
112
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
+ }