@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.
- package/ARCHITECTURE.md +29 -12
- package/README.md +12 -4
- package/docs/MIGRATION-FORMAT.md +259 -70
- package/docs/MODEL-FORMAT.md +38 -6
- package/docs/SQLITE-RELATIONAL.md +31 -2
- package/package.json +4 -4
- package/schemas/jaren-migration.draft-07.schema.json +169 -5
- package/schemas/jaren-migration.schema.json +164 -0
- package/src/ddl.js +8 -86
- package/src/dialects/sqlite.js +1 -0
- package/src/document-steps.js +23 -5
- package/src/drivers/bun.js +13 -3
- package/src/drivers/file-identity.js +14 -0
- package/src/drivers/node.js +2 -0
- package/src/foreign-key-scope.js +50 -0
- package/src/migrate.js +291 -497
- package/src/migration-target.js +104 -0
- package/src/physical-transform.js +207 -0
- package/src/schema-sql.js +184 -0
- package/src/table-migration.js +17 -17
- package/types/index.d.ts +113 -13
|
@@ -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
|
+
}
|
package/src/table-migration.js
CHANGED
|
@@ -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([])
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|