@jarenjs/db 0.84.3 → 0.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,314 @@
1
+ //@ts-check
2
+ /** Explicit SQLite expressions and statements on an existing connection.
3
+ * Values retain SQLite's NULL, numeric, collation and byte semantics. */
4
+ import { DbCompileError } from '../errors.js';
5
+ import { sqliteDialect } from './sqlite.js';
6
+ import { chain } from '../driver.js';
7
+ import { createSyncCursor, rowClassOf } from '../cursor.js';
8
+
9
+ /** @typedef {{ sql: string, params: any[], access: 'read'|'write' }} RelationalPlan */
10
+ const fail = (message) => { throw new DbCompileError('JD0038', message); };
11
+ const own = (o, key) => Object.hasOwn(o, key);
12
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
13
+ const check = (value, members, label) => {
14
+ if (!object(value)) fail(`${label} must be an object`);
15
+ for (const key of Object.keys(value)) if (!members.includes(key)) fail(`unknown ${label} member '${key}'`);
16
+ };
17
+ /** Quote one identifier; a dot is part of the name, never SQL syntax.
18
+ * @param {string} name @returns {string} */
19
+ export function relationalIdentifier(name) {
20
+ if (typeof name !== 'string' || !name || name.includes('\0')) fail('a SQL identifier must be nonempty and contain no NUL');
21
+ return sqliteDialect.quoteIdentifier(name);
22
+ }
23
+ const q = relationalIdentifier;
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']);
26
+ const types = new Set(['INTEGER', 'REAL', 'TEXT', 'BLOB', 'NUMERIC']);
27
+ const collations = new Set(['BINARY', 'NOCASE', 'RTRIM']);
28
+ const node = (kind, spec) => ({ $sql: kind, ...spec });
29
+
30
+ /** Typed structural authoring for SQLite's own expression semantics. */
31
+ export const sql = Object.freeze({
32
+ /** @param {string} name @param {string} [table] */
33
+ column: (name, table) => node('column', { name, ...(table === undefined ? {} : { table }) }),
34
+ /** @param {string|number|bigint|Uint8Array|null} value */
35
+ value: (value) => node('value', { value }),
36
+ /** @param {string} name */
37
+ param: (name) => node('param', { name }),
38
+ /** @param {'='|'<>'|'<'|'<='|'>'|'>='|'IS'|'IS NOT'|'+'|'-'|'*'|'/'|'%'|'||'|'AND'|'OR'|'LIKE'|'NOT LIKE'|'GLOB'} op @param {any} left @param {any} right */
39
+ binary: (op, left, right) => node('binary', { op, left, right }),
40
+ /** @param {any} value */
41
+ not: (value) => node('not', { value }),
42
+ /** @param {any} value @param {any[]|object} values @param {boolean} [negate] */
43
+ in: (value, values, negate = false) => node('in', { value, values, negate }),
44
+ /** @param {string} name @param {any[]} args @param {{distinct?:boolean}} [options] */
45
+ call: (name, args, options = {}) => node('call', { name, args, ...options }),
46
+ /** @param {any} value @param {'INTEGER'|'REAL'|'TEXT'|'BLOB'|'NUMERIC'} type */
47
+ cast: (value, type) => node('cast', { value, type }),
48
+ /** @param {any} value @param {'BINARY'|'NOCASE'|'RTRIM'} collation */
49
+ collate: (value, collation) => node('collate', { value, collation }),
50
+ /** @param {{when:any,then:any}[]} branches @param {any} [otherwise] */
51
+ case: (branches, otherwise = null) => node('case', { branches, otherwise }),
52
+ /** @param {any} query */
53
+ scalar: (query) => node('scalar', { query }),
54
+ /** @param {any} query */
55
+ exists: (query) => node('exists', { query }),
56
+ });
57
+
58
+ /** Compile expressions once, sharing parameter ordering with nested statements.
59
+ * Inline mode is for schema expressions, where SQLite disallows placeholders.
60
+ * @param {{externals?:Record<string,any>, inline?:boolean}} [options] */
61
+ export function relationalEmitter(options = {}) {
62
+ const params = [];
63
+ const literal = (value) => {
64
+ if (!(value === null || typeof value === 'string' || typeof value === 'bigint'
65
+ || (typeof value === 'number' && Number.isFinite(value)) || value instanceof Uint8Array))
66
+ return fail('SQL values are null, strings, finite numbers, integers or bytes');
67
+ if (!options.inline) { params.push(value); return '?'; }
68
+ if (value === null) return 'NULL';
69
+ if (typeof value === 'string') {
70
+ if (value.includes('\0')) fail('NUL text defaults require a data operation');
71
+ return sqliteDialect.stringLiteral(value);
72
+ }
73
+ if (value instanceof Uint8Array) return fail('binary schema defaults are not supported');
74
+ return String(value);
75
+ };
76
+ const expr = (value, depth = 0) => {
77
+ if (depth > 64) return fail('SQL expression nesting exceeds 64');
78
+ if (!object(value) || value instanceof Uint8Array) return literal(value);
79
+ const next = (child) => expr(child, depth + 1);
80
+ switch (value.$sql) {
81
+ case 'column':
82
+ check(value, ['$sql', 'name', 'table'], 'column');
83
+ return (value.table === undefined ? '' : `${q(value.table)}.`) + q(value.name);
84
+ case 'value': check(value, ['$sql', 'value'], 'value'); return literal(value.value);
85
+ case 'param':
86
+ check(value, ['$sql', 'name'], 'parameter');
87
+ if (options.inline || !own(options.externals ?? {}, value.name)) return fail(`missing or unavailable SQL parameter '${value.name}'`);
88
+ return literal(options.externals[value.name]);
89
+ case 'binary':
90
+ check(value, ['$sql', 'op', 'left', 'right'], 'binary expression');
91
+ if (!binary.has(value.op)) return fail('unsupported SQL operator');
92
+ return `(${next(value.left)} ${value.op} ${next(value.right)})`;
93
+ case 'not': check(value, ['$sql', 'value'], 'not'); return `(NOT ${next(value.value)})`;
94
+ case 'in': {
95
+ check(value, ['$sql', 'value', 'values', 'negate'], 'in');
96
+ if (value.negate !== undefined && typeof value.negate !== 'boolean') fail('IN negate must be boolean');
97
+ const left = next(value.value);
98
+ const right = Array.isArray(value.values) ? value.values.map(next).join(', ') : select(value.values, depth + 1);
99
+ return `(${left} ${value.negate ? 'NOT IN' : 'IN'} (${right}))`;
100
+ }
101
+ case 'call': {
102
+ check(value, ['$sql', 'name', 'args', 'distinct'], 'function');
103
+ if (!functions.has(value.name) || !Array.isArray(value.args)) return fail('unsupported SQL function');
104
+ if (value.distinct !== undefined && typeof value.distinct !== 'boolean') fail('DISTINCT must be boolean');
105
+ if (value.distinct && value.args.length !== 1) fail('DISTINCT functions require one argument');
106
+ if (!value.args.length && value.name !== 'count') fail('SQL function requires arguments');
107
+ return `${value.name.toUpperCase()}(${value.distinct ? 'DISTINCT ' : ''}${value.args.length ? value.args.map(next).join(', ') : '*'})`;
108
+ }
109
+ case 'cast':
110
+ check(value, ['$sql', 'value', 'type'], 'cast');
111
+ if (!types.has(value.type)) return fail('unsupported SQLite cast type');
112
+ return `CAST(${next(value.value)} AS ${value.type})`;
113
+ case 'collate':
114
+ check(value, ['$sql', 'value', 'collation'], 'collation');
115
+ if (!collations.has(value.collation)) return fail('unsupported SQLite collation');
116
+ return `(${next(value.value)} COLLATE ${value.collation})`;
117
+ case 'case':
118
+ check(value, ['$sql', 'branches', 'otherwise'], 'case');
119
+ if (!Array.isArray(value.branches) || !value.branches.length) return fail('CASE requires branches');
120
+ return `(CASE ${value.branches.map((b) => {
121
+ check(b, ['when', 'then'], 'case branch');
122
+ return `WHEN ${next(b.when)} THEN ${next(b.then)}`;
123
+ }).join(' ')} ELSE ${next(value.otherwise ?? null)} END)`;
124
+ case 'scalar': case 'exists':
125
+ check(value, ['$sql', 'query'], 'subquery');
126
+ return `${value.$sql === 'exists' ? 'EXISTS ' : ''}(${select(value.query, depth + 1)})`;
127
+ default: return fail('SQL expressions require a supported $sql node');
128
+ }
129
+ };
130
+ const order = (term, depth) => {
131
+ check(term, ['by', 'direction', 'nulls'], 'order');
132
+ if (term.direction !== undefined && !['asc', 'desc'].includes(term.direction)) fail('order direction is asc or desc');
133
+ if (term.nulls !== undefined && !['first', 'last'].includes(term.nulls)) fail('null order is first or last');
134
+ return expr(term.by, depth) + (term.direction ? ` ${term.direction.toUpperCase()}` : '')
135
+ + (term.nulls ? ` NULLS ${term.nulls.toUpperCase()}` : '');
136
+ };
137
+ const source = (s, depth) => {
138
+ if (typeof s === 'string') return q(s);
139
+ check(s, ['table', 'query', 'as'], 'source');
140
+ if (own(s, 'table') === own(s, 'query')) return fail('a source names one table or subquery');
141
+ return (own(s, 'table') ? q(s.table) : `(${select(s.query, depth + 1)})`)
142
+ + (s.as === undefined ? '' : ` AS ${q(s.as)}`);
143
+ };
144
+ const projection = (columns, depth) => {
145
+ if (columns === undefined || columns === '*') return '*';
146
+ if (!object(columns) || !Object.keys(columns).length) return fail('columns must be a nonempty projection or *');
147
+ return Object.entries(columns).map(([name, value]) => `${expr(value, depth)} AS ${q(name)}`).join(', ');
148
+ };
149
+ const window = (value, name) => {
150
+ if (!Number.isSafeInteger(value) || value < 0) fail(`${name} must be a nonnegative safe integer`);
151
+ return value;
152
+ };
153
+ const select = (spec, depth = 0) => {
154
+ if (depth > 64) return fail('SQL query nesting exceeds 64');
155
+ check(spec, ['from', 'columns', 'where', 'joins', 'groupBy', 'having', 'orderBy', 'limit', 'offset', 'distinct', 'union', 'all'], 'select');
156
+ let out;
157
+ if (spec.union !== undefined) {
158
+ if (!Array.isArray(spec.union) || spec.union.length < 2) return fail('UNION requires at least two queries');
159
+ if (Object.keys(spec).some((key) => !['union', 'all', 'orderBy', 'limit', 'offset'].includes(key))) fail('UNION cannot also declare a selection');
160
+ if (spec.all !== undefined && typeof spec.all !== 'boolean') fail('UNION all must be boolean');
161
+ out = spec.union.map((part) => `SELECT * FROM (${select(part, depth + 1)})`).join(spec.all ? ' UNION ALL ' : ' UNION ');
162
+ }
163
+ else {
164
+ if (spec.all !== undefined) fail('all requires UNION');
165
+ if (spec.distinct !== undefined && typeof spec.distinct !== 'boolean') fail('distinct must be boolean');
166
+ out = `SELECT ${spec.distinct ? 'DISTINCT ' : ''}${projection(spec.columns, depth)}`;
167
+ if (spec.from !== undefined) out += ` FROM ${source(spec.from, depth)}`;
168
+ if (spec.joins !== undefined) {
169
+ if (spec.from === undefined || !Array.isArray(spec.joins)) fail('joins require a FROM source and a list');
170
+ for (const join of spec.joins) {
171
+ check(join, ['source', 'type', 'on'], 'join');
172
+ const type = join.type ?? 'inner';
173
+ if (!['inner', 'left', 'cross'].includes(type)) fail('unsupported join type');
174
+ if (type === 'cross' ? own(join, 'on') : !own(join, 'on')) fail('inner/left joins require ON; cross joins have no ON');
175
+ out += ` ${type.toUpperCase()} JOIN ${source(join.source, depth)}`;
176
+ if (type !== 'cross') out += ` ON ${expr(join.on, depth)}`;
177
+ }
178
+ }
179
+ if (own(spec, 'where')) out += ` WHERE ${expr(spec.where, depth)}`;
180
+ if (spec.groupBy !== undefined) {
181
+ if (!Array.isArray(spec.groupBy) || !spec.groupBy.length) fail('groupBy must be nonempty');
182
+ out += ` GROUP BY ${spec.groupBy.map((v) => expr(v, depth)).join(', ')}`;
183
+ }
184
+ if (own(spec, 'having')) out += ` HAVING ${expr(spec.having, depth)}`;
185
+ }
186
+ if (spec.orderBy !== undefined) {
187
+ if (!Array.isArray(spec.orderBy) || !spec.orderBy.length) fail('orderBy must be nonempty');
188
+ out += ` ORDER BY ${spec.orderBy.map((term) => order(term, depth)).join(', ')}`;
189
+ }
190
+ if (spec.limit !== undefined) out += ` LIMIT ${window(spec.limit, 'limit')}`;
191
+ if (spec.offset !== undefined) out += `${spec.limit === undefined ? ' LIMIT -1' : ''} OFFSET ${window(spec.offset, 'offset')}`;
192
+ return out;
193
+ };
194
+ const mutation = (spec) => {
195
+ check(spec, ['op', 'table', 'set', 'where', 'values', 'source', 'columns', 'conflict', 'ignore', 'returning', 'reporting'], 'mutation');
196
+ const table = q(spec.table);
197
+ let out;
198
+ if (spec.op === 'update' || spec.op === 'delete') {
199
+ if (!own(spec, 'where')) fail('update/delete require an explicit where (use 1 for all rows)');
200
+ for (const key of ['values', 'source', 'columns', 'conflict', 'ignore']) if (own(spec, key)) fail(`${key} requires insert`);
201
+ if (spec.op === 'delete' && (own(spec, 'set') || own(spec, 'reporting'))) fail('delete has no assignments or reporting policy');
202
+ if (spec.reporting !== undefined && !['matched', 'changed'].includes(spec.reporting)) fail('reporting is matched or changed');
203
+ if (spec.op === 'update') {
204
+ if (!object(spec.set) || !Object.keys(spec.set).length) fail('update requires assignments');
205
+ out = `UPDATE ${table} SET ${Object.entries(spec.set).map(([name, v]) => `${q(name)} = ${expr(v)}`).join(', ')}`;
206
+ }
207
+ else out = `DELETE FROM ${table}`;
208
+ out += ` WHERE ${expr(spec.where)}`;
209
+ if (spec.reporting === 'changed') out += ` AND (${Object.entries(spec.set).map(([name, v]) =>
210
+ `${q(name)} COLLATE BINARY IS NOT ${expr(v)}`).join(' OR ')})`;
211
+ }
212
+ else if (spec.op === 'insert') {
213
+ for (const key of ['set', 'where', 'reporting']) if (own(spec, key)) fail(`${key} is not an insert member`);
214
+ if (spec.ignore !== undefined && typeof spec.ignore !== 'boolean') fail('ignore must be boolean');
215
+ if (own(spec, 'values') === own(spec, 'source')) fail('insert requires values or a source query');
216
+ let names;
217
+ let input;
218
+ if (own(spec, 'source')) {
219
+ names = spec.columns;
220
+ if (!Array.isArray(names) || !names.length || new Set(names).size !== names.length) fail('insert-select requires distinct target columns');
221
+ input = `SELECT * FROM (${select(spec.source)}) WHERE 1`;
222
+ }
223
+ else {
224
+ if (own(spec, 'columns')) fail('literal inserts take column names from values');
225
+ if (!object(spec.values)) fail('insert values must be an object');
226
+ names = Object.keys(spec.values);
227
+ input = names.length ? `VALUES (${names.map((name) => expr(spec.values[name])).join(', ')})` : 'DEFAULT VALUES';
228
+ }
229
+ out = `INSERT${spec.ignore ? ' OR IGNORE' : ''} INTO ${table}`
230
+ + (names.length ? ` (${names.map(q).join(', ')})` : '') + ` ${input}`;
231
+ if (spec.conflict !== undefined) {
232
+ if (!names.length) fail('DEFAULT VALUES cannot carry an upsert clause');
233
+ const conflict = spec.conflict;
234
+ check(conflict, ['target', 'where', 'action', 'set', 'updateWhere'], 'conflict');
235
+ if (!['nothing', 'update'].includes(conflict.action)) fail('conflict action is nothing or update');
236
+ out += ' ON CONFLICT';
237
+ if (conflict.target !== undefined) {
238
+ if (!Array.isArray(conflict.target) || !conflict.target.length) fail('conflict target must be nonempty');
239
+ // Conflict predicates and index expressions must be literal
240
+ // schema expressions, otherwise SQLite cannot match the index.
241
+ const schema = relationalEmitter({ inline: true });
242
+ out += ` (${conflict.target.map((term) => typeof term === 'string' ? q(term) : schema.expr(term)).join(', ')})`;
243
+ if (own(conflict, 'where')) out += ` WHERE ${schema.expr(conflict.where)}`;
244
+ }
245
+ else if (own(conflict, 'where')) fail('a conflict predicate requires a target');
246
+ if (conflict.action === 'nothing') {
247
+ if (own(conflict, 'set') || own(conflict, 'updateWhere')) fail('DO NOTHING has no update assignments');
248
+ out += ' DO NOTHING';
249
+ }
250
+ else {
251
+ if (!object(conflict.set) || !Object.keys(conflict.set).length) fail('conflict update requires assignments');
252
+ out += ` DO UPDATE SET ${Object.entries(conflict.set).map(([name, v]) => `${q(name)} = ${expr(v)}`).join(', ')}`;
253
+ if (own(conflict, 'updateWhere')) out += ` WHERE ${expr(conflict.updateWhere)}`;
254
+ }
255
+ }
256
+ }
257
+ else return fail('mutation op is insert, update or delete');
258
+ if (spec.returning !== undefined) out += ` RETURNING ${projection(spec.returning, 0)}`;
259
+ return out;
260
+ };
261
+ return { expr, select, mutation, order, params };
262
+ }
263
+
264
+ /** Produce reviewable, bound SQLite SQL from structural data.
265
+ * @param {any} document @param {{externals?:Record<string,any>}} [options]
266
+ * @returns {RelationalPlan} */
267
+ export function planRelational(document, options) {
268
+ const emitter = relationalEmitter(options);
269
+ const writing = object(document) && own(document, 'op');
270
+ return { sql: writing ? emitter.mutation(document) : emitter.select(document),
271
+ params: emitter.params, access: writing ? 'write' : 'read' };
272
+ }
273
+
274
+ /** Native SQLite operations without a model store. Raw text and Uint8Array
275
+ * values travel directly through the driver, including synchronous cursors.
276
+ * @param {any} connection */
277
+ export function relational(connection) {
278
+ if (connection.dialect.name !== 'sqlite' || !connection.synchronous) fail('relational operations require a synchronous SQLite connection');
279
+ const read = (document, options) => {
280
+ if (connection.mustQueue) fail('synchronous reads cannot enter another transaction');
281
+ const plan = planRelational(document, options);
282
+ if (plan.access !== 'read') fail('a read method requires a select document');
283
+ return plan;
284
+ };
285
+ return Object.freeze({
286
+ plan: planRelational,
287
+ all(document, options = undefined) {
288
+ const plan = read(document, options);
289
+ return chain(connection.prepare(plan.sql, { readOnly: true }), (s) => s.all(plan.params));
290
+ },
291
+ get(document, options = undefined) {
292
+ const plan = read(document, options);
293
+ return chain(connection.prepare(plan.sql, { readOnly: true }), (s) => s.get(plan.params));
294
+ },
295
+ iterate(document, options = undefined) {
296
+ const plan = read(document, options);
297
+ return createSyncCursor({ ...rowClassOf(connection),
298
+ open: () => {
299
+ if (connection.mustQueue) fail('synchronous iteration cannot enter another transaction');
300
+ return connection.prepare(plan.sql, { readOnly: true, ephemeral: true }).iterate(plan.params);
301
+ },
302
+ items: (row) => [row],
303
+ });
304
+ },
305
+ execute(document, options = undefined) {
306
+ if (connection.mustQueue) fail('synchronous mutation cannot wait for another transaction');
307
+ const plan = planRelational(document, options);
308
+ if (plan.access !== 'write') fail('execute requires a mutation document');
309
+ return connection.transaction(() => chain(connection.prepare(plan.sql), (s) =>
310
+ document.returning !== undefined ? chain(s.all(plan.params), (rows) => ({ affected: rows.length, rows }))
311
+ : chain(s.run(plan.params), (result) => ({ affected: Number(result.changes), lastInsertRowid: result.lastInsertRowid }))));
312
+ },
313
+ });
314
+ }
@@ -0,0 +1,142 @@
1
+ //@ts-check
2
+ /** Column-first SQLite schema programs. No SQL strings are accepted as expressions. */
3
+ import { DbCompileError } from '../errors.js';
4
+ import { relationalEmitter, relationalIdentifier as q } from './sqlite-relational.js';
5
+
6
+ const fail = (message) => { throw new DbCompileError('JD0005', message); };
7
+ const check = (v, keys, label) => {
8
+ if (!v || typeof v !== 'object' || Array.isArray(v)) fail(`${label} must be an object`);
9
+ for (const key of Object.keys(v)) if (!keys.includes(key)) fail(`unknown ${label} member '${key}'`);
10
+ };
11
+ const list = (values, label) => {
12
+ if (!Array.isArray(values) || !values.length || new Set(values).size !== values.length) fail(`${label} must be a nonempty distinct list`);
13
+ return values.map(q).join(', ');
14
+ };
15
+ const action = (value) => {
16
+ if (!['cascade', 'restrict', 'no action', 'set null', 'set default'].includes(value)) fail('unsupported foreign-key action');
17
+ return value.toUpperCase();
18
+ };
19
+
20
+ /** Validate a structural table definition and retain its explicit column order.
21
+ * @param {any} definition @returns {any} */
22
+ export function defineTable(definition) {
23
+ planTable(definition);
24
+ return structuredClone(definition);
25
+ }
26
+
27
+ /** Render table, indexes and triggers as independently reviewable statements.
28
+ * @param {any} definition @returns {{table:string,createSql:string[],expected:any}} */
29
+ export function planTable(definition) {
30
+ check(definition, ['name', 'columns', 'primaryKey', 'constraints', 'indexes', 'triggers', 'strict', 'withoutRowid'], 'table');
31
+ const table = q(definition.name);
32
+ if (!Array.isArray(definition.columns) || !definition.columns.length) fail('table columns must be nonempty');
33
+ for (const key of ['strict', 'withoutRowid']) if (definition[key] !== undefined && typeof definition[key] !== 'boolean') fail(`${key} must be boolean`);
34
+ for (const key of ['constraints', 'indexes', 'triggers']) if (definition[key] !== undefined && !Array.isArray(definition[key])) fail(`${key} must be a list`);
35
+ const emitter = relationalEmitter({ inline: true });
36
+ const names = new Set();
37
+ let inlineKey = false;
38
+ 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);
41
+ if (names.has(column.name.toLowerCase())) fail('physical column names must be distinct');
42
+ 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;
67
+ });
68
+ const members = (values) => {
69
+ const text = list(values, 'constraint columns');
70
+ if (values.some((name) => !names.has(name.toLowerCase()))) fail('constraint names an undeclared column');
71
+ return text;
72
+ };
73
+ if (definition.primaryKey !== undefined && !inlineKey) columns.push(`PRIMARY KEY (${members(definition.primaryKey)})`);
74
+ if (definition.withoutRowid && !definition.primaryKey?.length) fail('WITHOUT ROWID requires a primary key');
75
+ for (const constraint of definition.constraints ?? []) {
76
+ check(constraint, ['kind', 'name', 'columns', 'expression', 'table', 'references', 'onDelete', 'onUpdate', 'deferred'], 'constraint');
77
+ const prefix = constraint.name === undefined ? '' : `CONSTRAINT ${q(constraint.name)} `;
78
+ if (constraint.kind === 'unique') columns.push(`${prefix}UNIQUE (${members(constraint.columns)})`);
79
+ else if (constraint.kind === 'check') columns.push(`${prefix}CHECK (${emitter.expr(constraint.expression)})`);
80
+ else if (constraint.kind === 'foreignKey') {
81
+ 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' : ''));
87
+ }
88
+ else fail('constraint kind is unique, check or foreignKey');
89
+ const keys = constraint.kind === 'unique' ? ['kind', 'name', 'columns']
90
+ : constraint.kind === 'check' ? ['kind', 'name', 'expression']
91
+ : ['kind', 'name', 'columns', 'table', 'references', 'onDelete', 'onUpdate', 'deferred'];
92
+ check(constraint, keys, `${constraint.kind} constraint`);
93
+ }
94
+ const createSql = [`CREATE TABLE IF NOT EXISTS ${table} (${columns.join(', ')})`
95
+ + (definition.withoutRowid ? ' WITHOUT ROWID' : '')
96
+ + (definition.strict ? `${definition.withoutRowid ? ',' : ''} STRICT` : '')];
97
+ const objects = new Set([definition.name.toLowerCase()]);
98
+ const objectName = (name) => {
99
+ const out = q(name);
100
+ if (objects.has(name.toLowerCase())) fail('schema object names must be distinct');
101
+ objects.add(name.toLowerCase()); return out;
102
+ };
103
+ for (const index of definition.indexes ?? []) {
104
+ check(index, ['name', 'terms', 'unique', 'where'], 'index');
105
+ if (!Array.isArray(index.terms) || !index.terms.length) fail('index terms must be nonempty');
106
+ if (index.unique !== undefined && typeof index.unique !== 'boolean') fail('index unique must be boolean');
107
+ if (index.terms.some((term) => term?.nulls !== undefined)) fail('SQLite index terms cannot declare NULLS FIRST/LAST');
108
+ createSql.push(`CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX IF NOT EXISTS ${objectName(index.name)} ON ${table} `
109
+ + `(${index.terms.map((term) => emitter.order(term, 0)).join(', ')})`
110
+ + (index.where === undefined ? '' : ` WHERE ${emitter.expr(index.where)}`));
111
+ }
112
+ for (const trigger of definition.triggers ?? []) {
113
+ check(trigger, ['name', 'timing', 'event', 'of', 'when', 'steps'], 'trigger');
114
+ if (!['before', 'after'].includes(trigger.timing) || !['insert', 'update', 'delete'].includes(trigger.event)) fail('unsupported trigger timing or event');
115
+ if (trigger.of !== undefined && trigger.event !== 'update') fail('UPDATE OF requires an update trigger');
116
+ if (!Array.isArray(trigger.steps) || !trigger.steps.length) fail('trigger steps must be nonempty');
117
+ const steps = trigger.steps.map((step) => {
118
+ if (!step || typeof step !== 'object' || Array.isArray(step)) fail('a trigger step must be a mutation or raise object');
119
+ if (Object.hasOwn(step, 'raise')) {
120
+ check(step, ['raise'], 'raise step');
121
+ check(step.raise, ['action', 'message'], 'raise');
122
+ if (!['abort', 'fail', 'rollback', 'ignore'].includes(step.raise.action)) fail('unsupported trigger RAISE action');
123
+ if (step.raise.action === 'ignore') {
124
+ if (step.raise.message !== undefined) fail('RAISE IGNORE has no message');
125
+ return 'SELECT RAISE(IGNORE)';
126
+ }
127
+ if (typeof step.raise.message !== 'string') fail('RAISE requires a string message');
128
+ return `SELECT RAISE(${step.raise.action.toUpperCase()}, ${emitter.expr(step.raise.message)})`;
129
+ }
130
+ if (step.returning !== undefined) fail('triggers cannot use RETURNING');
131
+ return emitter.mutation(step);
132
+ });
133
+ createSql.push(`CREATE TRIGGER IF NOT EXISTS ${objectName(trigger.name)} ${trigger.timing.toUpperCase()} ${trigger.event.toUpperCase()}`
134
+ + (trigger.of === undefined ? '' : ` OF ${members(trigger.of)}`) + ` ON ${table}`
135
+ + (trigger.when === undefined ? '' : ` WHEN ${emitter.expr(trigger.when)}`)
136
+ + ` BEGIN ${steps.join('; ')}; END`);
137
+ }
138
+ return { table: definition.name, createSql, expected: {
139
+ columns: definition.columns.map((c) => ({ name: c.name, type: c.type, generated: c.generated !== undefined })),
140
+ indexes: (definition.indexes ?? []).map((i) => ({ name: i.name, unique: i.unique === true, terms: i.terms })),
141
+ } };
142
+ }
@@ -494,3 +494,20 @@ export const sqliteDialect = createDialect({
494
494
  },
495
495
  readChecks: sqliteChecks,
496
496
  });
497
+
498
+ /** SQLite schema inspection and identity preservation statements. */
499
+ export const sqliteTableMigration = Object.freeze({
500
+ schema: () => "SELECT type,name,tbl_name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND substr(name,1,7) <> 'sqlite_' ORDER BY type,name",
501
+ tableList: () => 'PRAGMA table_list',
502
+ sequenceExists: () => "SELECT 1 AS present FROM sqlite_schema WHERE name='sqlite_sequence'",
503
+ sequence: () => 'SELECT CAST(seq AS TEXT) AS seq FROM sqlite_sequence WHERE name=?',
504
+ raiseSequence: () => 'UPDATE sqlite_sequence SET seq=MAX(seq,CAST(? AS INTEGER)) WHERE name=?',
505
+ seedSequence: () => 'INSERT INTO sqlite_sequence(name,seq) SELECT ?,CAST(? AS INTEGER) WHERE NOT EXISTS(SELECT 1 FROM sqlite_sequence WHERE name=?)',
506
+ });
507
+
508
+ /** Infer the storage declaration for a physical codec.
509
+ * @param {string} codec @returns {string} */
510
+ export function sqlitePhysicalColumnType(codec) {
511
+ return ['integer', 'bigint', 'boolean', 'epoch-ms'].includes(codec)
512
+ ? 'INTEGER' : codec === 'number' ? 'REAL' : codec === 'blob-hex' ? 'BLOB' : 'TEXT';
513
+ }
@@ -21,6 +21,8 @@
21
21
  import { lazyOpen, openConnection } from '../driver.js';
22
22
  import { sqliteDialect } from '../dialects/sqlite.js';
23
23
  import { PRAGMA_NAMES } from '../pragmas.js';
24
+ import { writeSqliteSnapshot } from './snapshot.js';
25
+ export { snapshotDatabase } from './snapshot.js';
24
26
 
25
27
  /**
26
28
  * Adapt an already-constructed `bun:sqlite` `Database` (or any object
@@ -37,6 +39,7 @@ export function adaptBunDatabase(db, options) {
37
39
  /** @type {Set<WeakRef<any>>} */
38
40
  const statements = new Set();
39
41
  const collected = new FinalizationRegistry((ref) => statements.delete(ref));
42
+ let changesStatement;
40
43
  const raw = {
41
44
  ...(options?.backup ? { backup: options.backup } : {}),
42
45
  /** @param {string} sql */
@@ -50,7 +53,13 @@ export function adaptBunDatabase(db, options) {
50
53
  collected.register(statement, ref, ref);
51
54
  }
52
55
  return {
53
- run: (params = []) => statement.run(...params),
56
+ run: (params = []) => {
57
+ const result = statement.run(...params);
58
+ // Bun's result can include trigger side effects. The driver
59
+ // contract counts the data statement's own affected rows.
60
+ changesStatement ??= db.prepare('SELECT changes() AS changes');
61
+ return { ...result, changes: changesStatement.get().changes };
62
+ },
54
63
  // the driver contract says a missing row reads UNDEFINED;
55
64
  // bun:sqlite answers null — normalize at the seam, or every
56
65
  // create-or-verify and absence check misfires
@@ -60,12 +69,21 @@ export function adaptBunDatabase(db, options) {
60
69
  // driver-level wrapper composes one over `all` when it does not,
61
70
  // and `iterate` stays absent here so that fallback is reached
62
71
  ...(typeof statement.iterate === 'function'
63
- ? { iterate: (params = []) => statement.iterate(...params) }
72
+ ? { *iterate(params = []) {
73
+ // Bun's iterator.return() does not reset the native statement.
74
+ // Give each cursor its own statement and finalize on every exit;
75
+ // cached queries and concurrent cursors remain independently usable.
76
+ const cursorStatement = db.prepare(sql);
77
+ try { yield* cursorStatement.iterate(...params); }
78
+ finally { cursorStatement.finalize?.(); }
79
+ } }
64
80
  : undefined),
65
81
  };
66
82
  },
67
83
  close: () => {
68
84
  const errors = [];
85
+ try { changesStatement?.finalize?.(); }
86
+ catch (error) { errors.push(error); }
69
87
  for (const ref of statements) {
70
88
  collected.unregister(ref);
71
89
  try {
@@ -115,20 +133,13 @@ export function adaptBunDatabase(db, options) {
115
133
  */
116
134
  export function fromBunModule(mod, path, options) {
117
135
  const db = options?.readOnly === true ? new mod.Database(path, { readonly: true }) : new mod.Database(path);
118
- const backup = typeof db.serialize !== 'function' ? undefined : {
136
+ const backup = {
119
137
  snapshot: true,
120
- copy: async (target, copyOptions) => {
121
- const bytes = db.serialize();
122
- const pageSize = ((bytes[16] << 8) | bytes[17]) || 65536;
123
- const pages = bytes.length / (pageSize === 1 ? 65536 : pageSize);
124
- copyOptions?.progress?.({ totalPages: pages, remainingPages: pages });
125
- const fs = await import('node:fs/promises');
126
- const file = await fs.open(target, 'wx');
127
- try { await file.writeFile(bytes); await file.sync(); }
128
- finally { await file.close(); }
129
- copyOptions?.progress?.({ totalPages: pages, remainingPages: 0 });
130
- return pages;
131
- },
138
+ copy: (target, copyOptions) => writeSqliteSnapshot(target, () => {
139
+ const statement = db.prepare('VACUUM INTO ?');
140
+ try { return statement.run(target); }
141
+ finally { statement.finalize?.(); }
142
+ }, copyOptions),
132
143
  rename: (from, to) => import('node:fs/promises').then((fs) => fs.rename(from, to)),
133
144
  remove: (target) => import('node:fs/promises').then((fs) => fs.rm(target, { force: true })),
134
145
  };
@@ -120,6 +120,8 @@ export function nodeDriver() {
120
120
  });
121
121
  }
122
122
 
123
+ export { snapshotDatabase } from './snapshot.js';
124
+
123
125
  export {
124
126
  readDocuments, readJsonDocuments, readJsonlDocuments, readCollectionBundle,
125
127
  openAtomicTarget, openStreamTarget, openNullTarget,
@@ -0,0 +1,49 @@
1
+ //@ts-check
2
+ /** Disk-backed SQLite snapshots shared by the Node and Bun bindings. */
3
+ import { DbCompileError } from '../errors.js';
4
+
5
+ /** Reserve a new destination and fill it through SQLite, never a JS image.
6
+ * @param {string} target @param {() => any} write
7
+ * @param {{progress?:Function}} [options] @returns {Promise<number>} */
8
+ export async function writeSqliteSnapshot(target, write, options) {
9
+ if (typeof target !== 'string' || !target || target.includes('\0')) throw new TypeError('snapshot target must be a nonempty path');
10
+ const fs = await import('node:fs/promises');
11
+ const file = await fs.open(target, 'wx');
12
+ let complete = false;
13
+ try {
14
+ await write();
15
+ await file.sync();
16
+ const header = new Uint8Array(100);
17
+ const reader = await fs.open(target, 'r');
18
+ try { await reader.read(header, 0, header.length, 0); }
19
+ finally { await reader.close(); }
20
+ const encoded = (header[16] << 8) | header[17];
21
+ const pageSize = encoded === 1 ? 65536 : encoded;
22
+ const size = (await file.stat()).size;
23
+ const pages = size / pageSize;
24
+ options?.progress?.({ totalPages: pages, remainingPages: 0 });
25
+ complete = true;
26
+ return pages;
27
+ }
28
+ finally {
29
+ await file.close();
30
+ if (!complete) await fs.rm(target, { force: true });
31
+ }
32
+ }
33
+
34
+ /** Create a consistent, bounded-memory snapshot on a new path, including
35
+ * committed WAL data. Does not open a model store or replace a target.
36
+ * SQLite's page caches bound working memory; no serialize/hex image is built.
37
+ * @param {any} connection @param {string} target
38
+ * @returns {Promise<{path:string,pages:number}>} */
39
+ export async function snapshotDatabase(connection, target) {
40
+ if (connection.dialect.name !== 'sqlite' || !connection.synchronous)
41
+ throw new DbCompileError('JD0038', 'snapshotDatabase requires a synchronous SQLite connection');
42
+ const write = () => connection.prepare('VACUUM INTO ?').run([target]);
43
+ // Acquire ownership before asynchronous filesystem work. Acquiring it
44
+ // afterwards would queue behind an outer transaction awaiting this call.
45
+ const snapshot = () => writeSqliteSnapshot(target, write);
46
+ const pages = await (typeof connection.exclusively === 'function'
47
+ ? connection.exclusively(snapshot, 'a database snapshot') : snapshot());
48
+ return { path: target, pages };
49
+ }