@jarenjs/db 0.87.0 → 0.89.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,104 @@
1
+ //@ts-check
2
+ /** Acquisition and complete physical acceptance for migration entry points. */
3
+ import { isThenable } from '@jarenjs/core/function';
4
+ import { chain, toPromise } from './driver.js';
5
+ import { DbCompileError } from './errors.js';
6
+ import { readSchema } from './introspect.js';
7
+ import { ENGINE_TABLES } from './engine-metadata.js';
8
+ import { comparableDeclaredSql } from './schema-sql.js';
9
+
10
+ /** Keep synchronous borrowed work synchronous; close only acquired resources.
11
+ * Cleanup failures retain the original failure as well as the close failure.
12
+ * @param {any} target @param {(connection:any)=>any} run @returns {any} */
13
+ export function withMigrationConnection(target, run) {
14
+ const borrowed = target?.connection !== undefined;
15
+ if (!target || typeof target !== 'object' || (borrowed
16
+ ? target.driver !== undefined || target.path !== undefined || target.busyTimeout !== undefined
17
+ || typeof target.connection?.prepare !== 'function' || typeof target.connection?.transaction !== 'function'
18
+ : typeof target.driver?.open !== 'function'))
19
+ throw new TypeError('migration target needs { driver, path? } or { connection }');
20
+ if (borrowed) return run(target.connection);
21
+ return toPromise(chain(target.driver.open(target.path ?? ':memory:', { timeout: target.busyTimeout ?? 5000 }), (connection) => {
22
+ const finish = (value) => chain(connection.close(), () => value);
23
+ const fail = (error) => {
24
+ const both = (closeError) => { throw new AggregateError([error, closeError], 'migration failed and its connection could not close', { cause: error }); };
25
+ let closed;
26
+ try { closed = connection.close(); }
27
+ catch (closeError) { return both(closeError); }
28
+ if (isThenable(closed)) return closed.then(() => { throw error; }, both);
29
+ throw error;
30
+ };
31
+ let result;
32
+ try { result = run(connection); }
33
+ catch (error) { return fail(error); }
34
+ return isThenable(result) ? result.then(finish, fail) : finish(result);
35
+ }));
36
+ }
37
+
38
+ /** SQLite supplies the main database's canonical filename; private in-memory
39
+ * databases have no filename and cannot be identified by an empty string.
40
+ * @param {any} connection @returns {any} value-or-promise of string or null */
41
+ export function sqliteDatabasePath(connection) {
42
+ return chain(connection.prepare(connection.dialect.introspect.pragma('database_list')), (statement) =>
43
+ chain(statement.all([]), (rows) => rows.find((row) => row.name === 'main')?.file || null));
44
+ }
45
+
46
+ /** Reject an alias before any shadow callback or replay statement can write.
47
+ * Native bindings identify files by device/inode; injected SQLite drivers may
48
+ * supply the same hook, with the engine's canonical filename as the fallback.
49
+ * @param {any} primary @param {any} shadow @param {any} driver @returns {any} */
50
+ export function verifyShadowOwnership(primary, shadow, driver) {
51
+ if (primary.dialect.name !== 'sqlite' || shadow.dialect.name !== 'sqlite') return null;
52
+ const identify = typeof driver.databaseIdentity === 'function'
53
+ ? (connection) => driver.databaseIdentity(connection) : sqliteDatabasePath;
54
+ return chain(identify(primary), (source) => chain(identify(shadow), (target) => {
55
+ if (source !== null && source !== undefined && source === target)
56
+ throw new DbCompileError('JD0021', 'shadow replay needs a different database from the primary');
57
+ }));
58
+ }
59
+
60
+ /** Validate a complete owned-program inventory. Omitted tables are derived from
61
+ * object owners; explicit absent table names express reviewed drops.
62
+ * @param {any} target @returns {{objects:any[],tables:string[]}} */
63
+ export function physicalTargetOf(target) {
64
+ const fail = () => { throw new DbCompileError('JD0021', 'physicalTarget requires complete objects and optional owned tables'); };
65
+ if (!target || typeof target !== 'object' || !Array.isArray(target.objects)
66
+ || Object.keys(target).some((k) => !['objects', 'tables'].includes(k))) fail();
67
+ const names = new Set();
68
+ const objects = target.objects.map((object) => {
69
+ if (!object || !['table', 'view', 'index', 'trigger'].includes(object.type)
70
+ || typeof object.name !== 'string' || !object.name || typeof object.owner !== 'string' || !object.owner
71
+ || typeof object.sql !== 'string' || ENGINE_TABLES.has(object.name) || ENGINE_TABLES.has(object.owner)
72
+ || names.has(`${object.type}:${object.name}`)) fail();
73
+ names.add(`${object.type}:${object.name}`);
74
+ return { type: object.type, name: object.name, owner: object.owner,
75
+ sql: comparableDeclaredSql(object.sql) };
76
+ });
77
+ const tables = target.tables ?? [...new Set(objects.map((o) => o.owner))];
78
+ if (!Array.isArray(tables) || new Set(tables).size !== tables.length
79
+ || tables.some((t) => typeof t !== 'string' || !t || ENGINE_TABLES.has(t))
80
+ || objects.some((o) => !tables.includes(o.owner))) fail();
81
+ if (objects.some((o) => ['index', 'trigger'].includes(o.type)
82
+ && !objects.some((t) => ['table', 'view'].includes(t.type) && t.name === o.owner))) fail();
83
+ return { objects, tables };
84
+ }
85
+
86
+ /** One acceptance owner for apply, repeated startup and status. Exact quoted
87
+ * programs and physical column order remain significant; unrelated tables stay
88
+ * outside the reviewed ownership set.
89
+ * @param {any} connection @param {any} target @returns {any} */
90
+ export function comparePhysicalTarget(connection, target) {
91
+ if (connection.dialect.name !== 'sqlite') throw new DbCompileError('JD0021', 'complete physical target verification is qualified for SQLite');
92
+ const wanted = physicalTargetOf(target);
93
+ const selection = [...new Set([...wanted.tables, ...wanted.objects.map((o) => o.name)])];
94
+ return chain(readSchema(connection, { tables: selection }), (schema) => {
95
+ const actual = new Map(schema.objects.map((o) => [`${o.type}:${o.name}`, o]));
96
+ for (const object of wanted.objects) {
97
+ const key = `${object.type}:${object.name}`, have = actual.get(key);
98
+ if (!have) return `missing ${key}`;
99
+ if (object.owner !== have.owner || object.sql !== comparableDeclaredSql(have.sql)) return `different ${key}`;
100
+ actual.delete(key);
101
+ }
102
+ return actual.size ? `unexpected ${actual.keys().next().value}` : null;
103
+ });
104
+ }
@@ -0,0 +1,207 @@
1
+ //@ts-check
2
+ /** Bounded physical migration reads and key-preserving column assignments. */
3
+ import { canonicalizeJson } from '@jarenjs/json/canonical';
4
+ import { createBoundedCache } from '@jarenjs/core/cache';
5
+ import { setObjectMember } from '@jarenjs/core/object';
6
+ import { chain, isThenable } from './driver.js';
7
+ import { DbCompileError } from './errors.js';
8
+ import { readSchema } from './introspect.js';
9
+ import { columnCodec, physicalSelection, verifyPhysical } from './physical.js';
10
+ import { entityCore } from './entity.js';
11
+ import { sqliteTableMigration } from './dialects/sqlite.js';
12
+
13
+ const member = (value, name) => Object.hasOwn(value, name) ? value[name] : undefined;
14
+ const same = (a, b) => a === undefined || b === undefined
15
+ ? a === b : canonicalizeJson(a) === canonicalizeJson(b);
16
+
17
+ /** Prove text cursors survived the binding without replacement characters. */
18
+ function textKeyReadPlan(connection, mapping, keys) {
19
+ const columns = keys.filter((column) => column.codec === 'text');
20
+ if (columns.length === 0) return null;
21
+ const names = new Set(mapping.columns.map((column) => column.physical.toLowerCase()));
22
+ const aliases = columns.map((_, index) => {
23
+ let name = `__jaren_key_bytes_${index}`;
24
+ while (names.has(name.toLowerCase())) name += '_';
25
+ names.add(name.toLowerCase());
26
+ return name;
27
+ });
28
+ const q = connection.dialect.quoteIdentifier;
29
+ const projection = columns.map((column, index) =>
30
+ `${sqliteTableMigration.binaryCast(q(column.physical))} AS ${q(aliases[index])}`).join(', ');
31
+ return chain(connection.prepare(connection.dialect.introspect.pragma('encoding')), (statement) => chain(statement.get([]), (row) => {
32
+ // Raw SQLite text bytes use the database encoding, including UTF-16 files.
33
+ // A fatal decoder refuses malformed text; preserving BOM makes equality
34
+ // with the binding's public key exact, without changing its byte identity.
35
+ const decoder = new TextDecoder(row.encoding, { fatal: true, ignoreBOM: true });
36
+ // Some native bindings read a leading BOM correctly but strip it when
37
+ // binding the cursor back. Probe once; those keys must refuse before paging.
38
+ const probe = `SELECT ${sqliteTableMigration.binaryCast(connection.dialect.parameterRef(1, 'text'))} AS ${q('bytes')}`;
39
+ return chain(connection.prepare(probe), (statement) => chain(statement.get(['\uFEFFx']), (bound) => {
40
+ const keepsLeadingBom = decoder.decode(bound.bytes) === '\uFEFFx';
41
+ const read = (rows) => {
42
+ for (const row of rows) for (let index = 0; index < columns.length; index++) {
43
+ let decoded;
44
+ try { decoded = decoder.decode(row[aliases[index]]); }
45
+ catch { /* The common refusal below also covers replacement decoding. */ }
46
+ if (decoded === undefined || decoded !== row[columns[index].physical]
47
+ || !keepsLeadingBom && decoded.startsWith('\uFEFF'))
48
+ throw new DbCompileError('JD0021', `physical '${mapping.table}' key '${columns[index].name}' cannot round-trip through its SQLite text encoding`);
49
+ }
50
+ return rows.map((row) => {
51
+ const clean = { ...row };
52
+ for (const alias of aliases) delete clean[alias];
53
+ return clean;
54
+ });
55
+ };
56
+ return { projection, read };
57
+ }));
58
+ }));
59
+ }
60
+
61
+ /** Validate the physical read before preparing a statement over mapped columns. */
62
+ function physicalReader(connection, mapping, batchSize, check) {
63
+ if (connection.dialect.name !== 'sqlite' || mapping.document !== false
64
+ || !mapping.keys?.length || !['table', 'view'].includes(mapping.kind))
65
+ throw new DbCompileError('JD0021', 'a physical migration read needs a declared SQLite table or view and mapped keys');
66
+ if (!Number.isSafeInteger(batchSize) || batchSize < 1)
67
+ throw new TypeError('physical migration batchSize must be a positive safe integer');
68
+ check?.();
69
+ return chain(readSchema(connection), (schema) => chain(verifyPhysical(connection, mapping, schema), () => {
70
+ const dialect = connection.dialect, q = dialect.quoteIdentifier;
71
+ const keys = mapping.keys.map((name) => mapping.columns.find((column) => column.name === name));
72
+ if (keys.some((column) => !column || !['text', 'integer', 'bigint'].includes(column.codec)))
73
+ throw new DbCompileError('JD0021', 'physical migration keys need text, integer or bigint codecs');
74
+ return chain(textKeyReadPlan(connection, mapping, keys), (textKeys) => {
75
+ // One explicit ordering governs both the cursor and comparisons. A table's
76
+ // actual PRIMARY KEY proves uniqueness; a view has no such proof and uses
77
+ // bounded offset pages. Big integers remain strings only at the binding
78
+ // boundary; comparison reads the INTEGER column with its SQLite affinity.
79
+ const ordered = keys.map((column) => column.codec === 'text'
80
+ ? dialect.codepoint(q(column.physical)) : q(column.physical));
81
+ const keyValues = (row) => keys.map((column) => {
82
+ const codec = columnCodec(column);
83
+ return codec.encode(codec.decode(row[column.physical]));
84
+ });
85
+ const table = q(mapping.table), projection = physicalSelection(mapping, dialect);
86
+ const select = `SELECT ${projection}${textKeys === null ? '' : `, ${textKeys.projection}`} FROM ${table}`;
87
+ const ordering = ` ORDER BY ${ordered.join(', ')}`;
88
+ const bounded = dialect.limitClause(batchSize, undefined);
89
+ const keyset = mapping.kind === 'table';
90
+ const seek = ` WHERE (${ordered.join(', ')}) > (${keys.map((_, i) => dialect.parameterRef(i + 1, 'key')).join(', ')})`;
91
+ const count = () => chain(connection.prepare(`SELECT COUNT(*) AS ${q('n')} FROM ${table}`), (s) => chain(s.get([]), (row) => Number(row.n)));
92
+ const walk = (handle) => chain(connection.prepare(select + ordering + bounded), (first) =>
93
+ chain(keyset ? connection.prepare(select + seek + ordering + bounded) : null, (following) => {
94
+ let after = null, offset = 0;
95
+ const done = Symbol('physical-migration-done');
96
+ const consume = (rows) => {
97
+ if (rows.length === 0) return done;
98
+ if (textKeys !== null) rows = textKeys.read(rows);
99
+ // Capture the continuation before a handler can mutate its batch.
100
+ if (keyset) after = keyValues(rows[rows.length - 1]);
101
+ offset += rows.length;
102
+ return handle(rows);
103
+ };
104
+ const advance = () => {
105
+ for (;;) {
106
+ check?.();
107
+ const page = keyset
108
+ ? (after === null ? first.all([]) : following.all(after))
109
+ : offset === 0 ? first.all([]) : chain(connection.prepare(select + ordering + dialect.limitClause(batchSize, offset)), (s) => s.all([]));
110
+ if (isThenable(page)) return page.then((rows) => {
111
+ const result = consume(rows);
112
+ return result === done ? null : chain(result, advance);
113
+ });
114
+ const result = consume(page);
115
+ if (result === done) return null;
116
+ if (isThenable(result)) return result.then(advance);
117
+ }
118
+ };
119
+ return advance();
120
+ }));
121
+ return { walk, count, keys, ordered, table };
122
+ });
123
+ }));
124
+ }
125
+
126
+ /** Read mapped rows without synthetic aliases that could overwrite stored names.
127
+ * @param {any} connection @param {any} mapping @param {number} batchSize
128
+ * @param {(rows: any[]) => any} handle @param {(() => void)} [check] @returns {any} */
129
+ export function walkPhysicalRows(connection, mapping, batchSize, handle, check) {
130
+ return chain(physicalReader(connection, mapping, batchSize, check), (reader) => reader.walk(handle));
131
+ }
132
+
133
+ /** Transform column-only entities without application defaults/version stamps.
134
+ * The migration's surrounding savepoint owns every assignment and invariant.
135
+ * @param {any} connection @param {{entity: any, mapping: any}} target
136
+ * @param {any} operation @param {{batchSize: number, runtime?: any, check?: Function,
137
+ * onProgress?: Function, migration: string, collection: string}} options @returns {any} */
138
+ export function transformPhysicalRows(connection, target, operation, options) {
139
+ const { entity, mapping } = target;
140
+ const core = entityCore(connection, entity, mapping, null, options.runtime);
141
+ core.plan.writable();
142
+ const columns = core.plan.scalarColumns;
143
+ const known = new Set(columns.map((column) => column.name));
144
+ const keyNames = new Set(core.plan.keys);
145
+ const statements = createBoundedCache(64);
146
+ return chain(physicalReader(connection, mapping, options.batchSize, options.check), (reader) =>
147
+ chain(reader.count(), (initialCount) => {
148
+ let visited = 0, transformed = 0;
149
+ const apply = (row) => {
150
+ const before = core.plan.merge(row);
151
+ const identity = Object.fromEntries(core.plan.keys.map((name) => [name, before[name]]));
152
+ const output = operation.apply(structuredClone(before), canonicalizeJson(identity));
153
+ for (const name of Object.keys(output)) if (!known.has(name))
154
+ operation.fail(`a physical transform cannot store unknown or relation member '${name}'`);
155
+ const values = [];
156
+ const candidate = {};
157
+ for (const column of columns) {
158
+ const name = column.name;
159
+ const supplied = Object.hasOwn(output, name);
160
+ const prior = member(before, name), raw = supplied ? output[name] : undefined;
161
+ const value = (column.generated || column.databaseDefault) && !supplied
162
+ ? prior : column.codecPlan.normalize(raw);
163
+ if (column.generated && !same(value, prior)) operation.fail(`generated column '${name}' is database-owned`);
164
+ if (keyNames.has(name) && !same(value, prior)) operation.fail(`a physical transform cannot change key '${name}'`);
165
+ if (value !== undefined) setObjectMember(candidate, name, value);
166
+ if (!column.generated && !keyNames.has(name) && !same(value, prior))
167
+ values.push({ name, value: core.plan.encodeColumn(name, raw) });
168
+ }
169
+ if (values.length === 0) return null;
170
+ const dialect = connection.dialect, q = dialect.quoteIdentifier;
171
+ const assignments = values.map((value, i) => `${q(core.plan.physicalName(value.name))} = ${dialect.parameterRef(i + 1, 'value')}`);
172
+ const where = reader.ordered.map((column, i) => `${column} = ${dialect.parameterRef(values.length + i + 1, 'key')}`).join(' AND ');
173
+ const sql = `UPDATE ${reader.table} SET ${assignments.join(', ')} WHERE ${where}`;
174
+ const params = [...values.map((value) => value.value), ...core.plan.keys.map((name) => core.plan.encodeColumn(name, before[name]))];
175
+ return chain(statements.getOrCreate(sql, (text) => connection.prepare(text)), (statement) => chain(statement.run(params), (result) => {
176
+ if (Number(result.changes) !== 1) operation.fail('the physical transform did not update exactly its addressed row');
177
+ return chain(core.get(core.plan.keys.length === 1 ? before[core.plan.keys[0]] : identity), (stored) => {
178
+ if (!stored || core.plan.keys.some((name) => !same(stored[name], before[name])))
179
+ operation.fail('the physical transform changed or lost its addressed key');
180
+ if (values.some(({ name }) => !same(member(stored, name), member(candidate, name))))
181
+ operation.fail('the physical transform did not store its requested column values');
182
+ core.plan.checkMutation('update', before, stored);
183
+ transformed++;
184
+ return null;
185
+ });
186
+ }));
187
+ };
188
+ return chain(reader.walk((rows) => {
189
+ visited += rows.length;
190
+ if (visited > initialCount) operation.fail('a physical transform changed source row membership');
191
+ let index = 0;
192
+ const next = () => {
193
+ while (index < rows.length) {
194
+ const result = apply(rows[index++]);
195
+ if (isThenable(result)) return result.then(next);
196
+ }
197
+ options.onProgress?.({ migration: options.migration, collection: options.collection, transformed });
198
+ return null;
199
+ };
200
+ return next();
201
+ }), () => chain(reader.count(), (finalCount) => {
202
+ if (finalCount !== initialCount || visited !== initialCount)
203
+ operation.fail('a physical transform changed source row membership');
204
+ return transformed;
205
+ }));
206
+ }));
207
+ }
@@ -0,0 +1,184 @@
1
+ //@ts-check
2
+ /** Conservative SQLite declaration identity. Quoted bytes are never formatting. */
3
+
4
+ /** @typedef {{ kind: string, text: string }} SqlToken */
5
+ /** @typedef {{ columnOrder?: 'preserve' | 'ignore' }} DeclaredSqlOptions */
6
+
7
+ /** @param {string} reason @returns {never} */
8
+ function invalid(reason) {
9
+ throw new TypeError(`SQL declaration cannot be compared: ${reason}`);
10
+ }
11
+
12
+ /** SQLite whitespace excludes Unicode identifier characters and vertical tab. */
13
+ const whitespace = (/** @type {number} */ code) => code === 32 || code === 9
14
+ || code === 10 || code === 12 || code === 13;
15
+ const wordStart = (/** @type {number} */ code) => code >= 128 || code === 95
16
+ || code >= 65 && code <= 90 || code >= 97 && code <= 122;
17
+ const wordPart = (/** @type {number} */ code) => wordStart(code) || code === 36
18
+ || code >= 48 && code <= 57;
19
+
20
+ /** Lex before discarding comments or whitespace; retain each quoted token raw.
21
+ * @param {string} sql @returns {SqlToken[]} */
22
+ function tokensOf(sql) {
23
+ if (typeof sql !== 'string') invalid('text is required');
24
+ /** @type {SqlToken[]} */
25
+ const tokens = [];
26
+ let depth = 0;
27
+ for (let i = 0; i < sql.length;) {
28
+ const start = i, c = sql[i];
29
+ if (whitespace(sql.charCodeAt(i))) { i++; continue; }
30
+ if (sql.startsWith('--', i)) {
31
+ const end = sql.indexOf('\n', i + 2);
32
+ i = end < 0 ? sql.length : end + 1;
33
+ continue;
34
+ }
35
+ if (sql.startsWith('/*', i)) {
36
+ const end = sql.indexOf('*/', i + 2);
37
+ if (end < 0) invalid('unterminated comment');
38
+ i = end + 2;
39
+ continue;
40
+ }
41
+ const blob = (c === 'x' || c === 'X') && sql[i + 1] === "'";
42
+ if (blob) i++;
43
+ const quote = sql[i];
44
+ if (blob || quote === "'" || quote === '"' || quote === '`' || quote === '[') {
45
+ const close = quote === '[' ? ']' : quote;
46
+ let closed = false;
47
+ for (i++; i < sql.length; i++) {
48
+ if (sql.charCodeAt(i) === 0) invalid('NUL in quoted text');
49
+ if (sql[i] !== close) continue;
50
+ if (quote !== '[' && sql[i + 1] === close) { i++; continue; }
51
+ i++; closed = true; break;
52
+ }
53
+ if (!closed) invalid('unterminated quoted token');
54
+ const text = sql.slice(start, i);
55
+ if (blob && !/^[xX]'(?:[0-9a-fA-F]{2})*'$/.test(text)) invalid('invalid blob literal');
56
+ tokens.push({ kind: blob ? 'blob' : quote === "'" ? 'string' : 'identifier', text });
57
+ continue;
58
+ }
59
+ if (wordStart(sql.charCodeAt(i))) {
60
+ while (++i < sql.length && wordPart(sql.charCodeAt(i))) { /* whole identifier */ }
61
+ tokens.push({ kind: 'word', text: sql.slice(start, i) });
62
+ continue;
63
+ }
64
+ const number = /^(?:0[xX][0-9a-fA-F](?:_?[0-9a-fA-F])*|(?:[0-9](?:_?[0-9])*(?:\.(?:[0-9](?:_?[0-9])*)?)?|\.[0-9](?:_?[0-9])*)(?:[eE][+-]?[0-9](?:_?[0-9])*)?)/.exec(sql.slice(i));
65
+ if (number !== null) {
66
+ tokens.push({ kind: 'number', text: number[0] });
67
+ i += number[0].length;
68
+ if (wordPart(sql.charCodeAt(i)) || sql[i] === '.') invalid('invalid numeric token');
69
+ continue;
70
+ }
71
+ const operator = /^(?:->>|->|\|\||<<|>>|<=|>=|==|!=|<>|[(),.;+*/%~&|=<>-])/.exec(sql.slice(i));
72
+ if (operator === null) invalid(`unsupported token at ${i}`);
73
+ const text = operator[0];
74
+ if (text === '(') depth++;
75
+ else if (text === ')' && --depth < 0) invalid('unbalanced parentheses');
76
+ tokens.push({ kind: 'symbol', text });
77
+ i += text.length;
78
+ }
79
+ if (depth !== 0) invalid('unbalanced parentheses');
80
+ if (tokens.length === 0) invalid('empty declaration');
81
+ return tokens;
82
+ }
83
+
84
+ /** @param {SqlToken | undefined} token @param {string} word */
85
+ const isWord = (token, word) => token?.kind === 'word' && token.text.toUpperCase() === word;
86
+
87
+ /** Remove only the CREATE prefix clause SQLite omits from its catalog.
88
+ * @param {SqlToken[]} tokens @returns {SqlToken[]} */
89
+ function withoutExistenceClause(tokens) {
90
+ if (!isWord(tokens[0], 'CREATE')) return tokens;
91
+ let at = 1;
92
+ if (isWord(tokens[at], 'TEMP') || isWord(tokens[at], 'TEMPORARY')) at++;
93
+ if (isWord(tokens[at], 'UNIQUE') || isWord(tokens[at], 'VIRTUAL')) at++;
94
+ if (!['TABLE', 'INDEX', 'TRIGGER', 'VIEW'].some((word) => isWord(tokens[at], word))) return tokens;
95
+ at++;
96
+ return isWord(tokens[at], 'IF') && isWord(tokens[at + 1], 'NOT') && isWord(tokens[at + 2], 'EXISTS')
97
+ ? [...tokens.slice(0, at), ...tokens.slice(at + 3)] : tokens;
98
+ }
99
+
100
+ /** Stable whitespace between tokens; punctuation cannot join words/operators.
101
+ * @param {SqlToken[]} tokens @returns {string} */
102
+ function render(tokens) {
103
+ let out = '';
104
+ for (let i = 0; i < tokens.length; i++) {
105
+ const token = tokens[i], previous = tokens[i - 1];
106
+ const tight = token.kind === 'symbol' && ['(', ')', ','].includes(token.text)
107
+ || previous?.kind === 'symbol' && ['(', ')', ','].includes(previous.text);
108
+ out += (i === 0 || tight ? '' : ' ') + token.text;
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** Reorder only ordinary named columns with no order-sensitive inline clauses.
114
+ * Table constraints and every token inside a definition retain their order.
115
+ * Unrecognized table shapes keep strict order rather than guessing equivalence.
116
+ * @param {SqlToken[]} tokens @returns {SqlToken[]} */
117
+ function namedColumnOrder(tokens) {
118
+ if (!isWord(tokens[0], 'CREATE')) return tokens;
119
+ let at = 1;
120
+ if (isWord(tokens[at], 'TEMP') || isWord(tokens[at], 'TEMPORARY')) at++;
121
+ if (!isWord(tokens[at++], 'TABLE')) return tokens;
122
+ const name = (/** @type {SqlToken | undefined} */ token) => token?.kind === 'word' || token?.kind === 'identifier';
123
+ if (!name(tokens[at++])) return tokens;
124
+ if (tokens[at]?.text === '.') {
125
+ at++;
126
+ if (!name(tokens[at++])) return tokens;
127
+ }
128
+ if (tokens[at]?.text !== '(') return tokens;
129
+ const open = at++;
130
+ /** @type {SqlToken[][]} */
131
+ const items = [];
132
+ let start = at, depth = 0, close = -1;
133
+ for (; at < tokens.length; at++) {
134
+ const token = tokens[at];
135
+ if (token.kind !== 'symbol') continue;
136
+ if (token.text === '(') depth++;
137
+ else if (token.text === ')') {
138
+ if (depth === 0) { items.push(tokens.slice(start, at)); close = at; break; }
139
+ depth--;
140
+ }
141
+ else if (token.text === ',' && depth === 0) { items.push(tokens.slice(start, at)); start = at + 1; }
142
+ }
143
+ if (close < 0 || items.some((item) => item.length === 0)) return tokens;
144
+ const constraint = (/** @type {SqlToken[]} */ item) => ['CONSTRAINT', 'PRIMARY', 'UNIQUE', 'CHECK', 'FOREIGN']
145
+ .some((word) => isWord(item[0], word));
146
+ const firstConstraint = items.findIndex(constraint);
147
+ const split = firstConstraint < 0 ? items.length : firstConstraint;
148
+ const columns = items.slice(0, split), constraints = items.slice(split);
149
+ if (constraints.some((item) => !constraint(item)) || columns.some((item) => !name(item[0]))) return tokens;
150
+ // Default evaluation and check/conflict/cascade order can change values,
151
+ // errors or surviving rows even when every access names its columns.
152
+ if (columns.some((item) => item.slice(1).some((token) =>
153
+ ['DEFAULT', 'CHECK', 'UNIQUE', 'REFERENCES', 'COLLATE', 'CONFLICT'].some((word) => isWord(token, word))))) return tokens;
154
+ columns.sort((a, b) => {
155
+ const left = render(a), right = render(b);
156
+ return left < right ? -1 : left > right ? 1 : 0;
157
+ });
158
+ const reordered = [...columns, ...constraints];
159
+ return [...tokens.slice(0, open + 1), ...reordered.flatMap((item, i) =>
160
+ i === 0 ? item : [{ kind: 'symbol', text: ',' }, ...item]), ...tokens.slice(close)];
161
+ }
162
+
163
+ /** A conservative declaration identity, preserving physical column order by default.
164
+ * `ignore` is for managed tables whose consumers address columns by name. Index,
165
+ * trigger and table-constraint order is always preserved; order-sensitive inline
166
+ * constraints and unfamiliar table structures retain strict column order too.
167
+ * Quoted bytes are exact. Comments and ordinary token whitespace are formatting.
168
+ * @param {string} sql
169
+ * @param {DeclaredSqlOptions} [options]
170
+ * @returns {string}
171
+ * @throws {TypeError} for malformed/unsupported lexical input or policy
172
+ */
173
+ export function comparableDeclaredSql(sql, options = {}) {
174
+ const columnOrder = options.columnOrder ?? 'preserve';
175
+ if (!['preserve', 'ignore'].includes(columnOrder)) invalid('unknown columnOrder policy');
176
+ const tokens = withoutExistenceClause(tokensOf(sql));
177
+ return render(columnOrder === 'ignore' ? namedColumnOrder(tokens) : tokens);
178
+ }
179
+
180
+ /** Normalize formatting only, through the declaration comparison owner.
181
+ * @param {string} sql @returns {string} */
182
+ export function normalizeDeclaredSql(sql) {
183
+ return comparableDeclaredSql(sql);
184
+ }
@@ -7,10 +7,13 @@ import { defineTable, planTable, schemaChangeSql } from './dialects/sqlite-schem
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';
10
+ import { ENGINE_TABLES } from './engine-metadata.js';
11
+ import { withForeignKeySettings } from './foreign-key-scope.js';
10
12
 
11
13
  const refuse = (message) => { throw new DbCompileError('JD0021', message); };
12
14
  const fingerprint = (v) => canonicalizeJson(v);
13
- const schema = (connection) => connection.prepare(sqliteTableMigration.schema()).all([]).map((v) => ({ ...v }));
15
+ const schema = (connection) => connection.prepare(sqliteTableMigration.schema()).all([])
16
+ .filter((v) => !ENGINE_TABLES.has(v.name) && !ENGINE_TABLES.has(v.tbl_name)).map((v) => ({ ...v }));
14
17
  const createdSql = (text) => text.replace(/^CREATE (TABLE|(?:UNIQUE )?INDEX|TRIGGER) IF NOT EXISTS /i, 'CREATE $1 ');
15
18
  const owned = (objects, table) => objects.filter((o) => o.tbl_name === table).map((o) => [o.type, o.name, o.sql]).sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0));
16
19
  const sync = (connection) => {
@@ -47,7 +50,7 @@ export function applySchemaChange(connection, plan) {
47
50
  refuse('source schema or connection settings changed after planning');
48
51
  connection.exec(plan.sql);
49
52
  return { changed: fingerprint(before) === fingerprint(schema(connection)) ? 0 : 1 };
50
- }, { mode: 'immediate' });
53
+ }, undefined, 'immediate');
51
54
  }
52
55
 
53
56
  /** Inspect a live schema and generate a table plan without changing it.
@@ -104,7 +107,15 @@ export function planTableMigration(connection, definition, options) {
104
107
  // Preserve hidden rowids too, including text/composite-key rowid tables.
105
108
  const tableInfo = existing ? connection.prepare(sqliteTableMigration.tableList()).all([]).find((t) => t.schema === 'main' && t.name === target.name) : null;
106
109
  if (rebuild && !!tableInfo.wr !== !!target.withoutRowid) refuse('rebuild cannot change rowid ownership');
107
- if (rebuild && !tableInfo.wr && !(oldKey.length === 1 && oldColumns.find((c) => c.name === oldKey[0]).type.toUpperCase() === 'INTEGER')) {
110
+ // INTEGER PRIMARY KEY DESC has a separate primary-key index and a hidden
111
+ // rowid. The declared type alone cannot establish rowid ownership.
112
+ const sourceAlias = rebuild && !tableInfo.wr && oldKey.length === 1
113
+ && oldColumns.find((c) => c.name === oldKey[0]).type.toUpperCase() === 'INTEGER'
114
+ && !connection.prepare(dialect.introspect.indexes(target.name)).all([]).some((index) => index.origin === 'pk');
115
+ const targetAlias = !target.withoutRowid && target.primaryKey?.length === 1
116
+ && target.columns.find((c) => c.name.toLowerCase() === target.primaryKey[0].toLowerCase()).type === 'INTEGER';
117
+ if (rebuild && sourceAlias !== !!targetAlias) refuse('rebuild cannot change rowid ownership');
118
+ if (rebuild && !tableInfo.wr && !sourceAlias) {
108
119
  const rowid = ['rowid', '_rowid_', 'oid'].find((name) => !oldColumns.some((c) => c.name.toLowerCase() === name)
109
120
  && !target.columns.some((c) => c.name.toLowerCase() === name));
110
121
  if (!rowid) refuse('the source shadows every rowid alias');
@@ -157,7 +168,7 @@ export function applyTableMigration(connection, plan) {
157
168
  if (connection.prepare(dialect.pragma.foreignKeyCheck()).get([])) refuse('migration violates foreign-key references');
158
169
  if (fingerprint(owned(schema(connection), plan.table)) !== fingerprint(owned(plan.after, plan.table))) refuse('migrated schema differs from the reviewed target');
159
170
  return { changed: plan.statements.length + plan.finish.length };
160
- }, { mode: 'immediate' });
171
+ }, undefined, 'immediate');
161
172
  return plan.rebuild ? withForeignKeysSuspended(connection, run) : run();
162
173
  }
163
174
 
@@ -168,13 +179,7 @@ export function withForeignKeysSuspended(connection, fn) {
168
179
  sync(connection);
169
180
  if (typeof fn !== 'function' || Object.prototype.toString.call(fn) === '[object AsyncFunction]')
170
181
  refuse('a physical migration scope requires a synchronous callback');
171
- const foreignKeys = connection.prepare(dialect.introspect.pragma('foreign_keys')).get([]).foreign_keys;
172
- const legacy = connection.prepare(dialect.introspect.pragma('legacy_alter_table')).get([]).legacy_alter_table;
173
- try {
174
- connection.exec(dialect.pragma.foreignKeys(false));
175
- if (connection.prepare(dialect.introspect.pragma('foreign_keys')).get([]).foreign_keys !== 0) refuse('foreign_keys cannot change inside a transaction; establish the outer migration scope first');
176
- connection.exec(dialect.pragma.set('legacy_alter_table', 'ON'));
177
- return connection.transaction(() => {
182
+ return withForeignKeySettings(connection, () => connection.transaction(() => {
178
183
  const result = fn();
179
184
  if (result != null && typeof result.then === 'function') {
180
185
  Promise.resolve(result).catch(() => {});
@@ -182,10 +187,5 @@ export function withForeignKeysSuspended(connection, fn) {
182
187
  }
183
188
  if (connection.prepare(dialect.pragma.foreignKeyCheck()).get([])) refuse('migration violates foreign-key references');
184
189
  return result;
185
- }, { mode: 'immediate' });
186
- }
187
- finally {
188
- connection.exec(dialect.pragma.set('legacy_alter_table', legacy ? 'ON' : 'OFF'));
189
- connection.exec(dialect.pragma.foreignKeys(!!foreignKeys));
190
- }
190
+ }, undefined, 'immediate'));
191
191
  }