@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 +5 -1
- package/README.md +6 -1
- package/docs/MODEL-FORMAT.md +5 -0
- package/docs/NATIVE-PLANS.md +5 -3
- package/docs/SQLITE-RELATIONAL.md +110 -4
- package/package.json +4 -4
- package/src/dialects/sqlite-relational.js +2 -1
- package/src/dialects/sqlite-schema.js +82 -32
- package/src/index.js +1 -1
- package/src/mutation.js +13 -16
- package/src/relational-api.js +1 -1
- package/src/table-migration.js +34 -1
- package/types/index.d.ts +1 -1
- package/types/relational.d.ts +19 -1
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
|
|
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
|
package/docs/MODEL-FORMAT.md
CHANGED
|
@@ -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.
|
package/docs/NATIVE-PLANS.md
CHANGED
|
@@ -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
|
|
69
|
-
|
|
70
|
-
|
|
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.
|
|
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
|
|
136
|
-
|
|
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.
|
|
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.
|
|
112
|
-
"@jarenjs/json": "^0.
|
|
113
|
-
"@jarenjs/validate": "^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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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
|
|
193
|
+
return { sql, params, returning, maxRows, maxBytes };
|
|
191
194
|
};
|
|
192
195
|
return (document) => {
|
|
193
|
-
const
|
|
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.
|
|
202
|
-
return chain(
|
|
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) => {
|
package/src/relational-api.js
CHANGED
|
@@ -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';
|
package/src/table-migration.js
CHANGED
|
@@ -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';
|
package/types/relational.d.ts
CHANGED
|
@@ -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';
|