@jarenjs/db 0.86.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 +34 -13
- package/README.md +18 -5
- package/docs/MIGRATION-FORMAT.md +259 -70
- package/docs/MODEL-FORMAT.md +43 -6
- package/docs/NATIVE-PLANS.md +5 -3
- package/docs/SQLITE-RELATIONAL.md +141 -6
- 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-relational.js +2 -1
- package/src/dialects/sqlite-schema.js +82 -32
- 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/index.js +1 -1
- package/src/migrate.js +291 -497
- package/src/migration-target.js +104 -0
- package/src/mutation.js +13 -16
- package/src/physical-transform.js +207 -0
- package/src/relational-api.js +1 -1
- package/src/schema-sql.js +184 -0
- package/src/table-migration.js +50 -17
- package/types/index.d.ts +114 -14
- package/types/relational.d.ts +19 -1
package/src/ddl.js
CHANGED
|
@@ -21,6 +21,10 @@ import { chain } from './driver.js';
|
|
|
21
21
|
import { BBOX_COMPONENTS, BBOX_INDEX_ORDER, derivedMappingFor } from './derive.js';
|
|
22
22
|
import { expressionSql, expressionStem, expressionFunctions } from './expression.js';
|
|
23
23
|
import { planTable } from './dialects/sqlite-schema.js';
|
|
24
|
+
import { comparableDeclaredSql } from './schema-sql.js';
|
|
25
|
+
export { normalizeDeclaredSql, comparableDeclaredSql } from './schema-sql.js';
|
|
26
|
+
|
|
27
|
+
const MANAGED_SQL_OPTIONS = Object.freeze({ columnOrder: /** @type {const} */ ('ignore') });
|
|
24
28
|
|
|
25
29
|
/** The fixed physical column names of the 0.1 mapping. */
|
|
26
30
|
export const KEY_COLUMN = 'key';
|
|
@@ -620,88 +624,6 @@ export function planCollection(name, collection, dialect, options = undefined) {
|
|
|
620
624
|
};
|
|
621
625
|
}
|
|
622
626
|
|
|
623
|
-
/**
|
|
624
|
-
* Normalize a stored `CREATE` statement for comparison: collapse runs of
|
|
625
|
-
* whitespace, drop whitespace around punctuation, and strip the
|
|
626
|
-
* `IF NOT EXISTS` SQLite does not keep. What survives is every token that
|
|
627
|
-
* carries meaning, so two statements compare equal exactly when they
|
|
628
|
-
* declare the same physical object.
|
|
629
|
-
* @param {string} sql
|
|
630
|
-
* @returns {string}
|
|
631
|
-
*/
|
|
632
|
-
export function normalizeDeclaredSql(sql) {
|
|
633
|
-
return String(sql)
|
|
634
|
-
.replace(/\s+/g, ' ')
|
|
635
|
-
.replace(/\s*([(),])\s*/g, '$1')
|
|
636
|
-
.replace(/\bIF NOT EXISTS\s+/i, '')
|
|
637
|
-
.trim();
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
/**
|
|
641
|
-
* Split a comma-separated list at TOP-LEVEL commas only, so a
|
|
642
|
-
* `CHECK(x IN (1,2))` or a multi-column constraint stays one item.
|
|
643
|
-
* @param {string} body
|
|
644
|
-
* @returns {string[]}
|
|
645
|
-
*/
|
|
646
|
-
function splitTopLevel(body) {
|
|
647
|
-
/** @type {string[]} */
|
|
648
|
-
const parts = [];
|
|
649
|
-
let depth = 0;
|
|
650
|
-
let quote = '';
|
|
651
|
-
let start = 0;
|
|
652
|
-
for (let i = 0; i < body.length; i++) {
|
|
653
|
-
const c = body[i];
|
|
654
|
-
if (quote !== '') {
|
|
655
|
-
if (c === quote) quote = '';
|
|
656
|
-
continue;
|
|
657
|
-
}
|
|
658
|
-
if (c === '"' || c === "'") quote = c;
|
|
659
|
-
else if (c === '(') depth++;
|
|
660
|
-
else if (c === ')') depth--;
|
|
661
|
-
else if (c === ',' && depth === 0) {
|
|
662
|
-
parts.push(body.slice(start, i));
|
|
663
|
-
start = i + 1;
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
parts.push(body.slice(start));
|
|
667
|
-
return parts.map((part) => part.trim()).filter((part) => part !== '');
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
/**
|
|
671
|
-
* A comparable form of one `CREATE` statement.
|
|
672
|
-
*
|
|
673
|
-
* For a TABLE the column definitions compare as a SET, because
|
|
674
|
-
* `ALTER TABLE … ADD COLUMN` can only append — so a migrated table and a
|
|
675
|
-
* freshly built one legitimately differ in column order, and this store
|
|
676
|
-
* never reads a column positionally. Everything else is exact: each
|
|
677
|
-
* column's full definition (type, `PRIMARY KEY`, `NOT NULL`, `DEFAULT`,
|
|
678
|
-
* `CHECK`, `GENERATED … AS`, `REFERENCES … ON DELETE …`), the table
|
|
679
|
-
* constraints, and the trailing table options (`STRICT`,
|
|
680
|
-
* `WITHOUT ROWID`).
|
|
681
|
-
*
|
|
682
|
-
* For an INDEX the text compares whole, because an index IS its order —
|
|
683
|
-
* `(a,b)` and `(b,a)` serve different lookups — as are its partial
|
|
684
|
-
* predicate and each term's collation and direction.
|
|
685
|
-
* @param {string} sql
|
|
686
|
-
* @returns {string}
|
|
687
|
-
*/
|
|
688
|
-
export function comparableDeclaredSql(sql) {
|
|
689
|
-
const normalized = normalizeDeclaredSql(sql);
|
|
690
|
-
const open = normalized.indexOf('(');
|
|
691
|
-
const close = normalized.lastIndexOf(')');
|
|
692
|
-
if (!/^CREATE\s+TABLE\b/i.test(normalized) || open < 0 || close < open)
|
|
693
|
-
return normalized;
|
|
694
|
-
const head = normalized.slice(0, open);
|
|
695
|
-
const options = normalized.slice(close + 1).trim();
|
|
696
|
-
const items = splitTopLevel(normalized.slice(open + 1, close));
|
|
697
|
-
// a column definition opens with the quoted column name; anything else
|
|
698
|
-
// (PRIMARY KEY(...), UNIQUE(...), CHECK(...), FOREIGN KEY(...)) is a
|
|
699
|
-
// table constraint, and those are unordered too
|
|
700
|
-
const columns = items.filter((item) => item.startsWith('"')).sort();
|
|
701
|
-
const constraints = items.filter((item) => !item.startsWith('"')).sort();
|
|
702
|
-
return `${head}(${[...columns, ...constraints].join(',')})${options}`;
|
|
703
|
-
}
|
|
704
|
-
|
|
705
627
|
/**
|
|
706
628
|
* The declared-SQL half of verification: compare every schema object the
|
|
707
629
|
* table owns against the statements the plan would have created.
|
|
@@ -738,7 +660,7 @@ function verifyDeclaredSql(connection, plan, disagree) {
|
|
|
738
660
|
// comparison for free.
|
|
739
661
|
const owned = new Set(plan.virtualTables?.map((virtual) => virtual.name) ?? []);
|
|
740
662
|
for (const sql of plan.createSql) {
|
|
741
|
-
const comparable = comparableDeclaredSql(sql);
|
|
663
|
+
const comparable = comparableDeclaredSql(sql, MANAGED_SQL_OPTIONS);
|
|
742
664
|
// the object's name is the first quoted identifier in the statement
|
|
743
665
|
const name = /"((?:[^"]|"")*)"/.exec(comparable)?.[1]?.replace(/""/g, '"');
|
|
744
666
|
if (name === undefined || owned.has(name)) continue;
|
|
@@ -756,8 +678,8 @@ function verifyDeclaredSql(connection, plan, disagree) {
|
|
|
756
678
|
disagree(`the model declares the virtual table '${virtual.name}', `
|
|
757
679
|
+ 'which the database does not have');
|
|
758
680
|
}
|
|
759
|
-
const have = comparableDeclaredSql(row.sql);
|
|
760
|
-
const wanted = comparableDeclaredSql(virtual.createSql);
|
|
681
|
+
const have = comparableDeclaredSql(row.sql, MANAGED_SQL_OPTIONS);
|
|
682
|
+
const wanted = comparableDeclaredSql(virtual.createSql, MANAGED_SQL_OPTIONS);
|
|
761
683
|
if (have !== wanted) {
|
|
762
684
|
disagree(`'${virtual.name}' is declared as\n ${have}\nand the model declares\n ${wanted}`);
|
|
763
685
|
}
|
|
@@ -768,7 +690,7 @@ function verifyDeclaredSql(connection, plan, disagree) {
|
|
|
768
690
|
(statement) => chain(statement.all([]), (rows) => {
|
|
769
691
|
/** @type {Map<string, string>} */
|
|
770
692
|
const actual = new Map();
|
|
771
|
-
for (const row of rows) actual.set(String(row.name), comparableDeclaredSql(row.sql));
|
|
693
|
+
for (const row of rows) actual.set(String(row.name), comparableDeclaredSql(row.sql, MANAGED_SQL_OPTIONS));
|
|
772
694
|
for (const [name, wanted] of planned) {
|
|
773
695
|
const have = actual.get(name);
|
|
774
696
|
if (have === undefined)
|
|
@@ -22,7 +22,7 @@ export function relationalIdentifier(name) {
|
|
|
22
22
|
}
|
|
23
23
|
const q = relationalIdentifier;
|
|
24
24
|
const binary = new Set(['=', '<>', '<', '<=', '>', '>=', 'IS', 'IS NOT', '+', '-', '*', '/', '%', '||', 'AND', 'OR', 'LIKE', 'NOT LIKE', 'GLOB']);
|
|
25
|
-
const functions = new Set(['coalesce', 'nullif', 'trim', 'ltrim', 'rtrim', 'lower', 'upper', 'length', 'abs', 'round', 'typeof', 'json_extract', 'json_valid', 'count', 'sum', 'total', 'avg', 'min', 'max', 'date', 'time', 'datetime', 'julianday', 'unixepoch', 'strftime']);
|
|
25
|
+
const functions = new Set(['coalesce', 'nullif', 'trim', 'ltrim', 'rtrim', 'lower', 'upper', 'length', 'abs', 'round', 'typeof', 'json_extract', 'json_valid', 'json_type', 'count', 'sum', 'total', 'avg', 'min', 'max', 'date', 'time', 'datetime', 'julianday', 'unixepoch', 'strftime']);
|
|
26
26
|
const types = new Set(['INTEGER', 'REAL', 'TEXT', 'BLOB', 'NUMERIC']);
|
|
27
27
|
const collations = new Set(['BINARY', 'NOCASE', 'RTRIM']);
|
|
28
28
|
const node = (kind, spec) => ({ $sql: kind, ...spec });
|
|
@@ -104,6 +104,7 @@ export function relationalEmitter(options = {}) {
|
|
|
104
104
|
if (value.distinct !== undefined && typeof value.distinct !== 'boolean') fail('DISTINCT must be boolean');
|
|
105
105
|
if (value.distinct && value.args.length !== 1) fail('DISTINCT functions require one argument');
|
|
106
106
|
if (!value.args.length && value.name !== 'count') fail('SQL function requires arguments');
|
|
107
|
+
if (value.name === 'json_type' && value.args.length > 2) fail('json_type requires one or two arguments');
|
|
107
108
|
return `${value.name.toUpperCase()}(${value.distinct ? 'DISTINCT ' : ''}${value.args.length ? value.args.map(next).join(', ') : '*'})`;
|
|
108
109
|
}
|
|
109
110
|
case 'cast':
|
|
@@ -34,37 +34,13 @@ export function planTable(definition) {
|
|
|
34
34
|
for (const key of ['constraints', 'indexes', 'triggers']) if (definition[key] !== undefined && !Array.isArray(definition[key])) fail(`${key} must be a list`);
|
|
35
35
|
const emitter = relationalEmitter({ inline: true });
|
|
36
36
|
const names = new Set();
|
|
37
|
-
let inlineKey = false;
|
|
38
37
|
const columns = definition.columns.map((column) => {
|
|
39
|
-
|
|
40
|
-
const name = q(column.name);
|
|
38
|
+
const text = columnSql(column, definition, emitter);
|
|
41
39
|
if (names.has(column.name.toLowerCase())) fail('physical column names must be distinct');
|
|
42
40
|
names.add(column.name.toLowerCase());
|
|
43
|
-
|
|
44
|
-
if (definition.strict && column.type === 'NUMERIC') fail('STRICT tables do not support NUMERIC');
|
|
45
|
-
if (column.nullable !== undefined && typeof column.nullable !== 'boolean') fail('nullable must be boolean');
|
|
46
|
-
if (column.stored !== undefined && (typeof column.stored !== 'boolean' || column.generated === undefined)) fail('stored requires a generated expression');
|
|
47
|
-
let out = `${name} ${column.type}`;
|
|
48
|
-
if (column.identity !== undefined) {
|
|
49
|
-
if (!['rowid', 'autoincrement'].includes(column.identity) || column.type !== 'INTEGER'
|
|
50
|
-
|| definition.withoutRowid || definition.primaryKey?.length !== 1
|
|
51
|
-
|| definition.primaryKey[0] !== column.name || column.generated !== undefined) fail('identity requires a single INTEGER rowid primary key');
|
|
52
|
-
inlineKey = true;
|
|
53
|
-
out += ` PRIMARY KEY${column.identity === 'autoincrement' ? ' AUTOINCREMENT' : ''}`;
|
|
54
|
-
}
|
|
55
|
-
if (column.nullable === false) out += ' NOT NULL';
|
|
56
|
-
if (column.collation !== undefined) {
|
|
57
|
-
if (!['BINARY', 'NOCASE', 'RTRIM'].includes(column.collation)) fail('unsupported column collation');
|
|
58
|
-
out += ` COLLATE ${column.collation}`;
|
|
59
|
-
}
|
|
60
|
-
if (Object.hasOwn(column, 'default')) out += ` DEFAULT (${emitter.expr(column.default)})`;
|
|
61
|
-
if (column.check !== undefined) out += ` CHECK (${emitter.expr(column.check)})`;
|
|
62
|
-
if (column.generated !== undefined) {
|
|
63
|
-
if (Object.hasOwn(column, 'default')) fail('a generated column cannot have a default');
|
|
64
|
-
out += ` GENERATED ALWAYS AS (${emitter.expr(column.generated)}) ${column.stored ? 'STORED' : 'VIRTUAL'}`;
|
|
65
|
-
}
|
|
66
|
-
return out;
|
|
41
|
+
return text;
|
|
67
42
|
});
|
|
43
|
+
const inlineKey = definition.columns.some((column) => column.identity !== undefined);
|
|
68
44
|
const members = (values) => {
|
|
69
45
|
const text = list(values, 'constraint columns');
|
|
70
46
|
if (values.some((name) => !names.has(name.toLowerCase()))) fail('constraint names an undeclared column');
|
|
@@ -79,11 +55,12 @@ export function planTable(definition) {
|
|
|
79
55
|
else if (constraint.kind === 'check') columns.push(`${prefix}CHECK (${emitter.expr(constraint.expression)})`);
|
|
80
56
|
else if (constraint.kind === 'foreignKey') {
|
|
81
57
|
if (constraint.columns?.length !== constraint.references?.length) fail('foreign-key columns must have equal arity');
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
58
|
+
columns.push(`${prefix}FOREIGN KEY (${members(constraint.columns)}) ${referenceSql({
|
|
59
|
+
table: constraint.table, columns: constraint.references,
|
|
60
|
+
...(constraint.onDelete === undefined ? {} : { onDelete: constraint.onDelete }),
|
|
61
|
+
...(constraint.onUpdate === undefined ? {} : { onUpdate: constraint.onUpdate }),
|
|
62
|
+
...(constraint.deferred === undefined ? {} : { deferred: constraint.deferred }),
|
|
63
|
+
})}`);
|
|
87
64
|
}
|
|
88
65
|
else fail('constraint kind is unique, check or foreignKey');
|
|
89
66
|
const keys = constraint.kind === 'unique' ? ['kind', 'name', 'columns']
|
|
@@ -140,3 +117,76 @@ export function planTable(definition) {
|
|
|
140
117
|
indexes: (definition.indexes ?? []).map((i) => ({ name: i.name, unique: i.unique === true, terms: i.terms })),
|
|
141
118
|
} };
|
|
142
119
|
}
|
|
120
|
+
|
|
121
|
+
/** One REFERENCES clause shared by table constraints and column declarations. */
|
|
122
|
+
function referenceSql(reference) {
|
|
123
|
+
check(reference, ['table', 'columns', 'onDelete', 'onUpdate', 'deferred'], 'reference');
|
|
124
|
+
if (reference.deferred !== undefined && typeof reference.deferred !== 'boolean') fail('deferred must be boolean');
|
|
125
|
+
return `REFERENCES ${q(reference.table)} (${list(reference.columns, 'references')})`
|
|
126
|
+
+ (reference.onDelete === undefined ? '' : ` ON DELETE ${action(reference.onDelete)}`)
|
|
127
|
+
+ (reference.onUpdate === undefined ? '' : ` ON UPDATE ${action(reference.onUpdate)}`)
|
|
128
|
+
+ (reference.deferred ? ' DEFERRABLE INITIALLY DEFERRED' : '');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Render one typed column without reconstructing any surrounding schema. */
|
|
132
|
+
function columnSql(column, context, emitter) {
|
|
133
|
+
check(column, ['name', 'type', 'nullable', 'default', 'collation', 'identity', 'check', 'generated', 'stored', 'references'], 'column definition');
|
|
134
|
+
const name = q(column.name);
|
|
135
|
+
if (!['INTEGER', 'REAL', 'TEXT', 'BLOB', 'NUMERIC', 'ANY'].includes(column.type)) fail('unsupported SQLite column type');
|
|
136
|
+
if (context.strict && column.type === 'NUMERIC') fail('STRICT tables do not support NUMERIC');
|
|
137
|
+
if (column.nullable !== undefined && typeof column.nullable !== 'boolean') fail('nullable must be boolean');
|
|
138
|
+
if (column.stored !== undefined && (typeof column.stored !== 'boolean' || column.generated === undefined)) fail('stored requires a generated expression');
|
|
139
|
+
const hasDefault = Object.hasOwn(column, 'default');
|
|
140
|
+
if (context.additive) {
|
|
141
|
+
if (column.identity !== undefined || column.stored === true) fail('ADD COLUMN cannot add an identity or STORED column');
|
|
142
|
+
const value = column.default?.$sql === 'value' ? column.default.value : column.default;
|
|
143
|
+
if (hasDefault && !(value === null || typeof value === 'string' || typeof value === 'bigint'
|
|
144
|
+
|| (typeof value === 'number' && Number.isFinite(value)))) fail('ADD COLUMN requires a literal default');
|
|
145
|
+
if (column.generated === undefined && column.nullable === false && (!hasDefault || value === null)) fail('ADD COLUMN NOT NULL requires a non-null default');
|
|
146
|
+
if (column.references !== undefined && hasDefault && value !== null) fail('ADD COLUMN REFERENCES requires a NULL default');
|
|
147
|
+
}
|
|
148
|
+
let out = `${name} ${column.type}`;
|
|
149
|
+
if (column.identity !== undefined) {
|
|
150
|
+
if (!['rowid', 'autoincrement'].includes(column.identity) || column.type !== 'INTEGER'
|
|
151
|
+
|| context.withoutRowid || context.primaryKey?.length !== 1
|
|
152
|
+
|| context.primaryKey[0] !== column.name || column.generated !== undefined) fail('identity requires a single INTEGER rowid primary key');
|
|
153
|
+
out += ` PRIMARY KEY${column.identity === 'autoincrement' ? ' AUTOINCREMENT' : ''}`;
|
|
154
|
+
}
|
|
155
|
+
if (column.nullable === false) out += ' NOT NULL';
|
|
156
|
+
if (column.collation !== undefined) {
|
|
157
|
+
if (!['BINARY', 'NOCASE', 'RTRIM'].includes(column.collation)) fail('unsupported column collation');
|
|
158
|
+
out += ` COLLATE ${column.collation}`;
|
|
159
|
+
}
|
|
160
|
+
if (hasDefault) out += context.additive ? ` DEFAULT ${emitter.expr(column.default)}` : ` DEFAULT (${emitter.expr(column.default)})`;
|
|
161
|
+
if (column.check !== undefined) out += ` CHECK (${emitter.expr(column.check)})`;
|
|
162
|
+
if (column.generated !== undefined) {
|
|
163
|
+
if (hasDefault) fail('a generated column cannot have a default');
|
|
164
|
+
out += ` GENERATED ALWAYS AS (${emitter.expr(column.generated)}) ${column.stored ? 'STORED' : 'VIRTUAL'}`;
|
|
165
|
+
}
|
|
166
|
+
if (column.references !== undefined) {
|
|
167
|
+
if (column.references?.columns?.length !== 1) fail('a column reference requires one referenced column');
|
|
168
|
+
out += ` ${referenceSql(column.references)}`;
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Render one explicit main-schema operation; validation never executes SQL.
|
|
174
|
+
* @param {any} operation @returns {string} */
|
|
175
|
+
export function schemaChangeSql(operation) {
|
|
176
|
+
check(operation, ['op', 'table', 'column', 'name', 'to', 'ifExists'], 'schema change');
|
|
177
|
+
switch (operation.op) {
|
|
178
|
+
case 'addColumn':
|
|
179
|
+
check(operation, ['op', 'table', 'column'], 'addColumn');
|
|
180
|
+
return `ALTER TABLE "main".${q(operation.table)} ADD COLUMN ${columnSql(operation.column, { additive: true }, relationalEmitter({ inline: true }))}`;
|
|
181
|
+
case 'renameTable':
|
|
182
|
+
check(operation, ['op', 'table', 'to'], 'renameTable');
|
|
183
|
+
return `ALTER TABLE "main".${q(operation.table)} RENAME TO ${q(operation.to)}`;
|
|
184
|
+
case 'dropIndex': case 'dropTable': {
|
|
185
|
+
const index = operation.op === 'dropIndex';
|
|
186
|
+
check(operation, ['op', index ? 'name' : 'table', 'ifExists'], operation.op);
|
|
187
|
+
if (operation.ifExists !== undefined && typeof operation.ifExists !== 'boolean') fail('ifExists must be boolean');
|
|
188
|
+
return `DROP ${index ? 'INDEX' : 'TABLE'}${operation.ifExists ? ' IF EXISTS' : ''} "main".${q(index ? operation.name : operation.table)}`;
|
|
189
|
+
}
|
|
190
|
+
default: return fail('schema change is addColumn, dropIndex, renameTable or dropTable');
|
|
191
|
+
}
|
|
192
|
+
}
|
package/src/dialects/sqlite.js
CHANGED
|
@@ -497,6 +497,7 @@ export const sqliteDialect = createDialect({
|
|
|
497
497
|
|
|
498
498
|
/** SQLite schema inspection and identity preservation statements. */
|
|
499
499
|
export const sqliteTableMigration = Object.freeze({
|
|
500
|
+
binaryCast: (sql) => `CAST(${sql} AS BLOB)`,
|
|
500
501
|
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
502
|
tableList: () => 'PRAGMA table_list',
|
|
502
503
|
sequenceExists: () => "SELECT 1 AS present FROM sqlite_schema WHERE name='sqlite_sequence'",
|
package/src/document-steps.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { analyzeQuery, createQueryAccumulator } from '@jarenjs/json/query';
|
|
20
|
+
import { setObjectMember } from '@jarenjs/core/object';
|
|
20
21
|
import { DbCompileError, DbRuntimeError } from './errors.js';
|
|
21
22
|
import { utf8Length } from './cursor.js';
|
|
22
23
|
|
|
@@ -31,13 +32,13 @@ export const DOCUMENT_STEP_KINDS = new Set(['jslt', 'query']);
|
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* The step kinds that act on a PHYSICAL database — rendered DDL, a
|
|
34
|
-
* data statement spelled as SQL, the table-rebuild procedure, and the
|
|
35
|
+
* data statement spelled as SQL, guarded table plans, the table-rebuild procedure, and the
|
|
35
36
|
* backfill that recomputes stored derived COLUMNS. A host without
|
|
36
37
|
* tables cannot honour any of them, and silently skipping one would
|
|
37
38
|
* leave a migration half-applied, so it refuses instead.
|
|
38
39
|
* @type {ReadonlySet<string>}
|
|
39
40
|
*/
|
|
40
|
-
export const PHYSICAL_STEP_KINDS = new Set(['ddl', 'sql', 'rebuild', 'derive']);
|
|
41
|
+
export const PHYSICAL_STEP_KINDS = new Set(['ddl', 'sql', 'rebuild', 'derive', 'table']);
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
44
|
* The refusal a failing step raises, spelled the one way — a reader
|
|
@@ -185,8 +186,12 @@ export function compileDocumentStep(step, index, context) {
|
|
|
185
186
|
// a body that leaves it out keeps it, a body that rewrites it
|
|
186
187
|
// is refused, as a collection's key is
|
|
187
188
|
for (const key of keys) {
|
|
188
|
-
|
|
189
|
-
|
|
189
|
+
const present = Object.hasOwn(document, key);
|
|
190
|
+
const previous = present ? document[key] : undefined;
|
|
191
|
+
if (!Object.hasOwn(next, key) || next[key] === undefined) {
|
|
192
|
+
if (present) setObjectMember(next, key, previous);
|
|
193
|
+
}
|
|
194
|
+
else if (next[key] !== previous) {
|
|
190
195
|
fail(`the transform changed the key member '${key}' of row ${identity} — `
|
|
191
196
|
+ `key changes are not supported in ${MIGRATION_VERSION}`);
|
|
192
197
|
}
|
|
@@ -271,7 +276,7 @@ export function compileDocumentStep(step, index, context) {
|
|
|
271
276
|
};
|
|
272
277
|
}
|
|
273
278
|
|
|
274
|
-
const STEP_KINDS = new Set([
|
|
279
|
+
const STEP_KINDS = new Set([...DOCUMENT_STEP_KINDS, ...PHYSICAL_STEP_KINDS]);
|
|
275
280
|
|
|
276
281
|
/**
|
|
277
282
|
* Structural validation of one migration document, including the
|
|
@@ -304,6 +309,19 @@ export function checkMigrationDocument(migration) {
|
|
|
304
309
|
throw new DbCompileError('JD0023',
|
|
305
310
|
`migration '${migration.id}' step ${i} is a sql step without sql text`);
|
|
306
311
|
}
|
|
312
|
+
if (step.kind === 'table' && (!step.plan || typeof step.plan !== 'object' || Array.isArray(step.plan) || step.plan.version !== 1
|
|
313
|
+
|| ['id', 'table', 'checksum', 'temporary'].some((key) => typeof step.plan[key] !== 'string' || step.plan[key].length === 0)
|
|
314
|
+
|| typeof step.plan.rebuild !== 'boolean'
|
|
315
|
+
|| ['source', 'after'].some((key) => !Array.isArray(step.plan[key])
|
|
316
|
+
|| step.plan[key].some((value) => !value || typeof value !== 'object' || Array.isArray(value)))
|
|
317
|
+
|| ['unchanged', 'statements', 'finish'].some((key) => !Array.isArray(step.plan[key])
|
|
318
|
+
|| step.plan[key].some((value) => typeof value !== 'string' || value.length === 0)))) {
|
|
319
|
+
throw new DbCompileError('JD0023', `migration '${migration.id}' step ${i} is a table step without its reviewed plan`);
|
|
320
|
+
}
|
|
321
|
+
if (['jslt', 'query'].includes(step.kind) && step.model !== undefined
|
|
322
|
+
&& (!step.model || typeof step.model !== 'object' || step.model.$model !== '0.1')) {
|
|
323
|
+
throw new DbCompileError('JD0023', `migration '${migration.id}' step ${i} has an invalid current model`);
|
|
324
|
+
}
|
|
307
325
|
if (step.kind === 'derive'
|
|
308
326
|
&& (typeof step.collection !== 'string' || !Array.isArray(step.columns)
|
|
309
327
|
|| step.columns.length === 0)) {
|
package/src/drivers/bun.js
CHANGED
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
|
|
21
21
|
import { lazyOpen, openConnection } from '../driver.js';
|
|
22
22
|
import { sqliteDialect } from '../dialects/sqlite.js';
|
|
23
|
-
import { PRAGMA_NAMES } from '../pragmas.js';
|
|
23
|
+
import { PRAGMAS, PRAGMA_NAMES } from '../pragmas.js';
|
|
24
24
|
import { writeSqliteSnapshot } from './snapshot.js';
|
|
25
|
+
import { nativeDatabaseIdentity } from './file-identity.js';
|
|
25
26
|
export { snapshotDatabase } from './snapshot.js';
|
|
26
27
|
|
|
27
28
|
/**
|
|
@@ -128,11 +129,19 @@ export function adaptBunDatabase(db, options) {
|
|
|
128
129
|
* substitute module.
|
|
129
130
|
* @param {any} mod - The `bun:sqlite` module (or a substitute)
|
|
130
131
|
* @param {string} path
|
|
131
|
-
* @param {{ readOnly?: boolean, queueTimeout?: number }} [options]
|
|
132
|
+
* @param {{ timeout?: number, readOnly?: boolean, queueTimeout?: number }} [options]
|
|
132
133
|
* @returns {any}
|
|
133
134
|
*/
|
|
134
135
|
export function fromBunModule(mod, path, options) {
|
|
136
|
+
const timeout = options?.timeout === undefined ? undefined
|
|
137
|
+
: PRAGMAS.busyTimeout.normalize(options.timeout, 'timeout');
|
|
135
138
|
const db = options?.readOnly === true ? new mod.Database(path, { readonly: true }) : new mod.Database(path);
|
|
139
|
+
// Bun has no constructor timeout option. Configure newly owned handles
|
|
140
|
+
// before probing; adapting a caller-owned handle preserves its settings.
|
|
141
|
+
if (timeout !== undefined) {
|
|
142
|
+
try { db.run(sqliteDialect.pragma.set('busy_timeout', timeout)); }
|
|
143
|
+
catch (error) { db.close(); throw error; }
|
|
144
|
+
}
|
|
136
145
|
const backup = {
|
|
137
146
|
snapshot: true,
|
|
138
147
|
copy: (target, copyOptions) => writeSqliteSnapshot(target, () => {
|
|
@@ -154,9 +163,10 @@ export function bunDriver() {
|
|
|
154
163
|
return Object.freeze({
|
|
155
164
|
name: 'bun-sqlite',
|
|
156
165
|
dialect: sqliteDialect,
|
|
166
|
+
databaseIdentity: nativeDatabaseIdentity,
|
|
157
167
|
/**
|
|
158
168
|
* @param {string} path
|
|
159
|
-
* @param {{ readOnly?: boolean, queueTimeout?: number }} [options]
|
|
169
|
+
* @param {{ timeout?: number, readOnly?: boolean, queueTimeout?: number }} [options]
|
|
160
170
|
* @returns {Promise<any>}
|
|
161
171
|
*/
|
|
162
172
|
open: (path, options) => lazyOpen('bun:sqlite',
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Native filesystem identity stays below the Node/Bun entry points. */
|
|
3
|
+
import { chain } from '../driver.js';
|
|
4
|
+
import { sqliteDatabasePath } from '../migration-target.js';
|
|
5
|
+
|
|
6
|
+
/** Device/inode identity also catches hardlinks that SQLite filenames cannot.
|
|
7
|
+
* The filesystem is imported only when identifying an opened file; two private
|
|
8
|
+
* in-memory databases remain independent.
|
|
9
|
+
* @param {any} connection @returns {any} value-or-promise of string or null */
|
|
10
|
+
export function nativeDatabaseIdentity(connection) {
|
|
11
|
+
return chain(sqliteDatabasePath(connection), (path) => path === null ? null
|
|
12
|
+
: import('node:fs/promises').then((fs) => fs.stat(path, { bigint: true }))
|
|
13
|
+
.then((stat) => `${stat.dev}:${stat.ino}`));
|
|
14
|
+
}
|
package/src/drivers/node.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { lazyOpen, openConnection } from '../driver.js';
|
|
10
10
|
import { sqliteDialect } from '../dialects/sqlite.js';
|
|
11
11
|
import { PRAGMA_NAMES } from '../pragmas.js';
|
|
12
|
+
import { nativeDatabaseIdentity } from './file-identity.js';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Adapt an already-constructed `node:sqlite` `DatabaseSync` (or any
|
|
@@ -109,6 +110,7 @@ export function nodeDriver() {
|
|
|
109
110
|
return Object.freeze({
|
|
110
111
|
name: 'node-sqlite',
|
|
111
112
|
dialect: sqliteDialect,
|
|
113
|
+
databaseIdentity: nativeDatabaseIdentity,
|
|
112
114
|
/**
|
|
113
115
|
* @param {string} path
|
|
114
116
|
* @param {{ timeout?: number, readOnly?: boolean,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** SQLite's connection settings belong outside the transaction they govern. */
|
|
3
|
+
import { isThenable } from '@jarenjs/core/function';
|
|
4
|
+
import { chain } from './driver.js';
|
|
5
|
+
import { DbCompileError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
/** Suspend enforcement and legacy rewrites for a caller-owned transaction,
|
|
8
|
+
* restoring the exact original settings after success or failure. The caller
|
|
9
|
+
* still must run foreign_key_check before committing. Value-or-promise, so both
|
|
10
|
+
* synchronous table helpers and asynchronous migration drivers share one bracket.
|
|
11
|
+
* @param {any} connection @param {()=>any} fn @returns {any} */
|
|
12
|
+
export function withForeignKeySettings(connection, fn) {
|
|
13
|
+
const dialect = connection.dialect;
|
|
14
|
+
const read = (name) => chain(connection.prepare(dialect.introspect.pragma(name)), (s) => chain(s.get([]), (row) => row[name]));
|
|
15
|
+
return chain(read('foreign_keys'), (foreignKeys) => chain(read('legacy_alter_table'), (legacy) => {
|
|
16
|
+
const restore = () => {
|
|
17
|
+
const errors = [];
|
|
18
|
+
const attempt = (sql) => {
|
|
19
|
+
try {
|
|
20
|
+
const result = connection.exec(sql);
|
|
21
|
+
return isThenable(result) ? result.then(() => undefined, (error) => { errors.push(error); }) : undefined;
|
|
22
|
+
}
|
|
23
|
+
catch (error) { errors.push(error); return undefined; }
|
|
24
|
+
};
|
|
25
|
+
// These settings are independent: a failed legacy restoration must
|
|
26
|
+
// not prevent the attempt to re-enable foreign-key enforcement.
|
|
27
|
+
return chain(attempt(dialect.pragma.set('legacy_alter_table', legacy ? 'ON' : 'OFF')),
|
|
28
|
+
() => chain(attempt(dialect.pragma.foreignKeys(!!foreignKeys)), () => errors));
|
|
29
|
+
};
|
|
30
|
+
const finish = (value) => chain(restore(), (errors) => {
|
|
31
|
+
if (errors.length === 1) throw errors[0];
|
|
32
|
+
if (errors.length > 1) throw new AggregateError(errors, 'migration connection settings could not be restored');
|
|
33
|
+
return value;
|
|
34
|
+
});
|
|
35
|
+
const fail = (error) => chain(restore(), (errors) => {
|
|
36
|
+
if (errors.length > 0) throw new AggregateError([error, ...errors],
|
|
37
|
+
'migration failed and its connection settings could not be restored', { cause: error });
|
|
38
|
+
throw error;
|
|
39
|
+
});
|
|
40
|
+
let result;
|
|
41
|
+
try {
|
|
42
|
+
result = chain(connection.exec(dialect.pragma.foreignKeys(false)), () => chain(read('foreign_keys'), (actual) => {
|
|
43
|
+
if (actual !== 0) throw new DbCompileError('JD0021', 'foreign_keys cannot change inside a transaction; establish the outer migration scope first');
|
|
44
|
+
return chain(connection.exec(dialect.pragma.set('legacy_alter_table', 'ON')), fn);
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
catch (error) { return fail(error); }
|
|
48
|
+
return isThenable(result) ? result.then(finish, fail) : finish(result);
|
|
49
|
+
}));
|
|
50
|
+
}
|
package/src/index.js
CHANGED
|
@@ -105,4 +105,4 @@ export { planInvariants } from './ddl.js';
|
|
|
105
105
|
export { planPhysicalMigration } from './migrate.js';
|
|
106
106
|
export { sql, planRelational, relational } from './dialects/sqlite-relational.js';
|
|
107
107
|
export { defineTable, planTable } from './dialects/sqlite-schema.js';
|
|
108
|
-
export { planTableMigration, applyTableMigration, withForeignKeysSuspended } from './table-migration.js';
|
|
108
|
+
export { planTableMigration, applyTableMigration, withForeignKeysSuspended, planSchemaChange, applySchemaChange } from './table-migration.js';
|