@lobomfz/db 0.4.0 → 0.5.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/README.md +55 -4
- package/package.json +8 -5
- package/src/database.ts +184 -271
- package/src/dialect/serialize.ts +1 -1
- package/src/env.ts +2 -1
- package/src/fts/resolve.ts +208 -0
- package/src/fts/sql.ts +148 -0
- package/src/fts/tokenizer.ts +27 -0
- package/src/index.ts +18 -1
- package/src/json.ts +13 -0
- package/src/migration/data-loss-error.ts +29 -0
- package/src/migration/diff.ts +269 -25
- package/src/migration/drift-error.ts +9 -0
- package/src/migration/execute.ts +78 -26
- package/src/migration/guided.ts +136 -0
- package/src/migration/introspect.ts +138 -1
- package/src/migration/registry.ts +43 -0
- package/src/migration/types.ts +110 -1
- package/src/plugin.ts +206 -3
- package/src/schema-compiler.ts +362 -0
- package/src/types.ts +110 -12
- package/src/vector-index/load.ts +67 -0
- package/src/vector-index/nearest.ts +68 -0
- package/src/vector-index/resolve.ts +78 -0
- package/src/vector-index/sql.ts +76 -0
- package/src/vector.ts +13 -0
- package/src/write-validation-plugin.ts +92 -0
package/src/migration/diff.ts
CHANGED
|
@@ -1,39 +1,65 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
1
|
+
import type { ResolvedFtsPlan } from "../fts/resolve.js";
|
|
2
|
+
import {
|
|
3
|
+
generateCreateFtsSql,
|
|
4
|
+
generateFtsTriggers,
|
|
5
|
+
generateRebuildSelect,
|
|
6
|
+
} from "../fts/sql.js";
|
|
7
|
+
import { tokenizeClause } from "../fts/tokenizer.js";
|
|
8
|
+
import type { ResolvedVectorIndex } from "../vector-index/resolve.js";
|
|
9
|
+
import {
|
|
10
|
+
generateCreateVecSql,
|
|
11
|
+
generateRebuildVectorSql,
|
|
12
|
+
generateVectorTriggers,
|
|
13
|
+
} from "../vector-index/sql.js";
|
|
14
|
+
import type { FtsTokenizer } from "../types.js";
|
|
15
|
+
import type {
|
|
16
|
+
ColumnSchema,
|
|
17
|
+
DesiredSchema,
|
|
18
|
+
DesiredTable,
|
|
19
|
+
DestructiveChange,
|
|
20
|
+
ExistingSchema,
|
|
21
|
+
IntrospectedFts,
|
|
22
|
+
IntrospectedTable,
|
|
23
|
+
IntrospectedVectorIndex,
|
|
24
|
+
ColumnCopy,
|
|
25
|
+
MigrationOp,
|
|
26
|
+
} from "./types.js";
|
|
20
27
|
|
|
21
28
|
export class Differ {
|
|
22
29
|
private ops: MigrationOp[] = [];
|
|
23
30
|
private desiredNames: Set<string>;
|
|
24
31
|
private rebuiltTables = new Set<string>();
|
|
25
32
|
|
|
33
|
+
private desired: DesiredTable[];
|
|
34
|
+
private existing: Map<string, IntrospectedTable>;
|
|
35
|
+
private desiredFts: ResolvedFtsPlan[];
|
|
36
|
+
private existingFts: Map<string, IntrospectedFts>;
|
|
37
|
+
private desiredVectorIndexes: ResolvedVectorIndex[];
|
|
38
|
+
private existingVectorIndexes: Map<string, IntrospectedVectorIndex>;
|
|
39
|
+
|
|
40
|
+
readonly destructive: DestructiveChange[] = [];
|
|
41
|
+
|
|
26
42
|
constructor(
|
|
27
|
-
|
|
28
|
-
|
|
43
|
+
desired: DesiredSchema,
|
|
44
|
+
existing: ExistingSchema,
|
|
45
|
+
private options: { guided?: boolean } = {},
|
|
29
46
|
) {
|
|
30
|
-
this.
|
|
47
|
+
this.desired = desired.desiredTables;
|
|
48
|
+
this.existing = existing.existing;
|
|
49
|
+
this.desiredFts = desired.desiredFts ?? [];
|
|
50
|
+
this.existingFts = existing.existingFts ?? new Map();
|
|
51
|
+
this.desiredVectorIndexes = desired.desiredVectorIndexes ?? [];
|
|
52
|
+
this.existingVectorIndexes = existing.existingVectorIndexes ?? new Map();
|
|
53
|
+
|
|
54
|
+
this.desiredNames = new Set(this.desired.map((t) => t.name));
|
|
31
55
|
}
|
|
32
56
|
|
|
33
57
|
diff() {
|
|
34
58
|
this.diffTables();
|
|
35
59
|
this.dropOrphans();
|
|
36
60
|
this.diffIndexes();
|
|
61
|
+
this.diffFts();
|
|
62
|
+
this.diffVectorIndexes();
|
|
37
63
|
return this.ops;
|
|
38
64
|
}
|
|
39
65
|
|
|
@@ -79,6 +105,16 @@ export class Differ {
|
|
|
79
105
|
}
|
|
80
106
|
|
|
81
107
|
private buildRebuild(table: DesiredTable, existingTable: IntrospectedTable) {
|
|
108
|
+
if (this.options.guided) {
|
|
109
|
+
this.buildGuidedRebuild(table, existingTable);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const desiredNames = new Set(table.columns.map((c) => c.name));
|
|
114
|
+
const droppedColumns = [...existingTable.columns.keys()].filter(
|
|
115
|
+
(name) => !desiredNames.has(name),
|
|
116
|
+
);
|
|
117
|
+
const typeChanged: string[] = [];
|
|
82
118
|
const columnCopies: ColumnCopy[] = [];
|
|
83
119
|
|
|
84
120
|
for (const col of table.columns) {
|
|
@@ -95,6 +131,7 @@ export class Differ {
|
|
|
95
131
|
);
|
|
96
132
|
}
|
|
97
133
|
|
|
134
|
+
typeChanged.push(col.name);
|
|
98
135
|
continue;
|
|
99
136
|
}
|
|
100
137
|
|
|
@@ -114,6 +151,41 @@ export class Differ {
|
|
|
114
151
|
}
|
|
115
152
|
}
|
|
116
153
|
|
|
154
|
+
if (existingTable.hasData && droppedColumns.length > 0) {
|
|
155
|
+
this.destructive.push({ table: table.name, kind: "DropColumn", columns: droppedColumns });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (existingTable.hasData && typeChanged.length > 0) {
|
|
159
|
+
this.destructive.push({ table: table.name, kind: "TypeChange", columns: typeChanged });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
this.ops.push({
|
|
163
|
+
type: "RebuildTable",
|
|
164
|
+
table: table.name,
|
|
165
|
+
createSql: table.sql,
|
|
166
|
+
columnCopies,
|
|
167
|
+
});
|
|
168
|
+
this.rebuiltTables.add(table.name);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private buildGuidedRebuild(table: DesiredTable, existingTable: IntrospectedTable) {
|
|
172
|
+
const columnCopies: ColumnCopy[] = [];
|
|
173
|
+
|
|
174
|
+
for (const col of table.columns) {
|
|
175
|
+
if (!existingTable.columns.has(col.name)) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (col.notnull && col.defaultValue !== null) {
|
|
180
|
+
columnCopies.push({
|
|
181
|
+
name: col.name,
|
|
182
|
+
expr: `COALESCE("${col.name}", ${col.defaultValue})`,
|
|
183
|
+
});
|
|
184
|
+
} else {
|
|
185
|
+
columnCopies.push({ name: col.name, expr: `"${col.name}"` });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
117
189
|
this.ops.push({
|
|
118
190
|
type: "RebuildTable",
|
|
119
191
|
table: table.name,
|
|
@@ -166,9 +238,15 @@ export class Differ {
|
|
|
166
238
|
}
|
|
167
239
|
|
|
168
240
|
private dropOrphans() {
|
|
169
|
-
for (const [name] of this.existing) {
|
|
170
|
-
if (
|
|
171
|
-
|
|
241
|
+
for (const [name, table] of this.existing) {
|
|
242
|
+
if (this.desiredNames.has(name)) {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
this.ops.push({ type: "DropTable", table: name });
|
|
247
|
+
|
|
248
|
+
if (!this.options.guided && table.hasData) {
|
|
249
|
+
this.destructive.push({ table: name, kind: "DropTable" });
|
|
172
250
|
}
|
|
173
251
|
}
|
|
174
252
|
}
|
|
@@ -217,4 +295,170 @@ export class Differ {
|
|
|
217
295
|
}
|
|
218
296
|
}
|
|
219
297
|
}
|
|
298
|
+
|
|
299
|
+
private diffFts() {
|
|
300
|
+
const desiredNames = new Set(this.desiredFts.map((p) => p.name));
|
|
301
|
+
|
|
302
|
+
for (const plan of this.desiredFts) {
|
|
303
|
+
const existing = this.existingFts.get(plan.name);
|
|
304
|
+
const triggers = generateFtsTriggers(plan);
|
|
305
|
+
const createSql = generateCreateFtsSql(plan);
|
|
306
|
+
const rebuildSql = generateRebuildSelect(plan);
|
|
307
|
+
|
|
308
|
+
if (!existing) {
|
|
309
|
+
this.ops.push({
|
|
310
|
+
type: "CreateFts",
|
|
311
|
+
name: plan.name,
|
|
312
|
+
createSql,
|
|
313
|
+
triggers,
|
|
314
|
+
});
|
|
315
|
+
this.ops.push({
|
|
316
|
+
type: "RebuildFts",
|
|
317
|
+
name: plan.name,
|
|
318
|
+
rebuildSql,
|
|
319
|
+
});
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const desiredCols = plan.fields.map((f) => f.name);
|
|
324
|
+
const colsMatch =
|
|
325
|
+
desiredCols.length === existing.columns.length &&
|
|
326
|
+
desiredCols.every((c, i) => existing.columns[i] === c);
|
|
327
|
+
const triggersMatch =
|
|
328
|
+
existing.triggers.size === triggers.length &&
|
|
329
|
+
triggers.every(
|
|
330
|
+
(t) => normalizeSql(existing.triggers.get(t.name) ?? "") === normalizeSql(t.sql),
|
|
331
|
+
);
|
|
332
|
+
const tokenizerMatch = ftsTokenizerMatches(existing.sql, plan.tokenizer);
|
|
333
|
+
|
|
334
|
+
if (colsMatch && triggersMatch && tokenizerMatch) {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
this.ops.push({
|
|
339
|
+
type: "DropFts",
|
|
340
|
+
name: plan.name,
|
|
341
|
+
triggerNames: [...existing.triggers.keys()],
|
|
342
|
+
});
|
|
343
|
+
this.ops.push({
|
|
344
|
+
type: "CreateFts",
|
|
345
|
+
name: plan.name,
|
|
346
|
+
createSql,
|
|
347
|
+
triggers,
|
|
348
|
+
});
|
|
349
|
+
this.ops.push({
|
|
350
|
+
type: "RebuildFts",
|
|
351
|
+
name: plan.name,
|
|
352
|
+
rebuildSql,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
for (const [name, existing] of this.existingFts) {
|
|
357
|
+
if (desiredNames.has(name)) {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
this.ops.push({
|
|
362
|
+
type: "DropFts",
|
|
363
|
+
name,
|
|
364
|
+
triggerNames: [...existing.triggers.keys()],
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private diffVectorIndexes() {
|
|
370
|
+
const desiredNames = new Set(this.desiredVectorIndexes.map((p) => p.name));
|
|
371
|
+
|
|
372
|
+
for (const plan of this.desiredVectorIndexes) {
|
|
373
|
+
const existing = this.existingVectorIndexes.get(plan.name);
|
|
374
|
+
const triggers = generateVectorTriggers(plan);
|
|
375
|
+
const createSql = generateCreateVecSql(plan);
|
|
376
|
+
const rebuildSql = generateRebuildVectorSql(plan);
|
|
377
|
+
|
|
378
|
+
if (!existing) {
|
|
379
|
+
this.ops.push({
|
|
380
|
+
type: "CreateVectorIndex",
|
|
381
|
+
name: plan.name,
|
|
382
|
+
createSql,
|
|
383
|
+
triggers,
|
|
384
|
+
});
|
|
385
|
+
this.ops.push({
|
|
386
|
+
type: "RebuildVectorIndex",
|
|
387
|
+
name: plan.name,
|
|
388
|
+
rebuildSql,
|
|
389
|
+
});
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const triggersMatch =
|
|
394
|
+
existing.triggers.size === triggers.length &&
|
|
395
|
+
triggers.every(
|
|
396
|
+
(t) => normalizeSql(existing.triggers.get(t.name) ?? "") === normalizeSql(t.sql),
|
|
397
|
+
);
|
|
398
|
+
const shapeMatch = vectorShapeMatches(existing.sql, plan);
|
|
399
|
+
|
|
400
|
+
if (triggersMatch && shapeMatch) {
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
this.ops.push({
|
|
405
|
+
type: "DropVectorIndex",
|
|
406
|
+
name: plan.name,
|
|
407
|
+
triggerNames: [...existing.triggers.keys()],
|
|
408
|
+
});
|
|
409
|
+
this.ops.push({
|
|
410
|
+
type: "CreateVectorIndex",
|
|
411
|
+
name: plan.name,
|
|
412
|
+
createSql,
|
|
413
|
+
triggers,
|
|
414
|
+
});
|
|
415
|
+
this.ops.push({
|
|
416
|
+
type: "RebuildVectorIndex",
|
|
417
|
+
name: plan.name,
|
|
418
|
+
rebuildSql,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
for (const [name, existing] of this.existingVectorIndexes) {
|
|
423
|
+
if (desiredNames.has(name)) {
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
this.ops.push({
|
|
428
|
+
type: "DropVectorIndex",
|
|
429
|
+
name,
|
|
430
|
+
triggerNames: [...existing.triggers.keys()],
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function ftsTokenizerMatches(existingSql: string, desired: FtsTokenizer) {
|
|
437
|
+
const match = existingSql.match(/tokenize\s*=\s*'((?:''|[^'])*)'/i);
|
|
438
|
+
|
|
439
|
+
if (!match) {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return match[1]!.replaceAll("''", "'") === tokenizeClause(desired);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function vectorShapeMatches(existingSql: string, desired: ResolvedVectorIndex) {
|
|
447
|
+
const dimMatch = existingSql.match(/float\s*\[\s*(\d+)\s*\]/i);
|
|
448
|
+
const metricMatch = existingSql.match(/distance_metric\s*=\s*([a-zA-Z0-9_]+)/i);
|
|
449
|
+
const colMatch = existingSql.match(/"([^"]+)"\s+float\s*\[/i);
|
|
450
|
+
|
|
451
|
+
if (!dimMatch || !metricMatch || !colMatch) {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return (
|
|
456
|
+
Number.parseInt(dimMatch[1]!, 10) === desired.dim &&
|
|
457
|
+
metricMatch[1]!.toLowerCase() === desired.metric &&
|
|
458
|
+
colMatch[1] === desired.column
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function normalizeSql(sql: string): string {
|
|
463
|
+
return sql.replaceAll(/\s+/g, " ").trim();
|
|
220
464
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export class MigrationDriftError extends Error {
|
|
2
|
+
constructor(readonly id: string) {
|
|
3
|
+
super(
|
|
4
|
+
`Migration "${id}" was already applied with a different body. ` +
|
|
5
|
+
`Migrations are immutable once applied: revert the change, or give the new logic a fresh id.`,
|
|
6
|
+
);
|
|
7
|
+
this.name = "MigrationDriftError";
|
|
8
|
+
}
|
|
9
|
+
}
|
package/src/migration/execute.ts
CHANGED
|
@@ -14,36 +14,14 @@ export class Executor {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
const hasRebuild = this.ops.some((op) => op.type === "RebuildTable");
|
|
17
|
-
|
|
18
|
-
let restoreFk = false;
|
|
19
|
-
|
|
20
|
-
if (hasRebuild) {
|
|
21
|
-
const { foreign_keys } = this.db.prepare("PRAGMA foreign_keys").get() as {
|
|
22
|
-
foreign_keys: number;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
if (foreign_keys === 1) {
|
|
26
|
-
this.db.run("PRAGMA foreign_keys = OFF");
|
|
27
|
-
restoreFk = true;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
17
|
+
const restoreFk = hasRebuild && Executor.disableForeignKeys(this.db);
|
|
30
18
|
|
|
31
19
|
try {
|
|
32
20
|
this.db.transaction(() => {
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
this.executeOp(op);
|
|
36
|
-
} catch (e) {
|
|
37
|
-
throw this.parseError(e, op);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
if (restoreFk) {
|
|
42
|
-
const violations = this.db.prepare("PRAGMA foreign_key_check").all();
|
|
21
|
+
this.applyWithin();
|
|
43
22
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
23
|
+
if (hasRebuild) {
|
|
24
|
+
Executor.assertForeignKeysIntact(this.db);
|
|
47
25
|
}
|
|
48
26
|
})();
|
|
49
27
|
} finally {
|
|
@@ -53,6 +31,38 @@ export class Executor {
|
|
|
53
31
|
}
|
|
54
32
|
}
|
|
55
33
|
|
|
34
|
+
applyWithin() {
|
|
35
|
+
for (const op of this.ops) {
|
|
36
|
+
try {
|
|
37
|
+
this.executeOp(op);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
throw this.parseError(e, op);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static disableForeignKeys(db: Database): boolean {
|
|
45
|
+
const { foreign_keys } = db.prepare("PRAGMA foreign_keys").get() as {
|
|
46
|
+
foreign_keys: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
if (foreign_keys !== 1) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
db.run("PRAGMA foreign_keys = OFF");
|
|
54
|
+
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static assertForeignKeysIntact(db: Database) {
|
|
59
|
+
const violations = db.prepare("PRAGMA foreign_key_check").all();
|
|
60
|
+
|
|
61
|
+
if (violations.length > 0) {
|
|
62
|
+
throw new Error("Foreign key check failed after rebuild");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
56
66
|
private executeOp(op: MigrationOp) {
|
|
57
67
|
switch (op.type) {
|
|
58
68
|
case "CreateTable": {
|
|
@@ -73,6 +83,48 @@ export class Executor {
|
|
|
73
83
|
case "DropIndex": {
|
|
74
84
|
return this.db.run(`DROP INDEX "${op.index}"`);
|
|
75
85
|
}
|
|
86
|
+
case "CreateFts": {
|
|
87
|
+
this.db.run(op.createSql);
|
|
88
|
+
|
|
89
|
+
for (const trigger of op.triggers) {
|
|
90
|
+
this.db.run(trigger.sql);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
case "DropFts": {
|
|
96
|
+
for (const name of op.triggerNames) {
|
|
97
|
+
this.db.run(`DROP TRIGGER IF EXISTS "${name}"`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return this.db.run(`DROP TABLE IF EXISTS "${op.name}"`);
|
|
101
|
+
}
|
|
102
|
+
case "RebuildFts": {
|
|
103
|
+
this.db.run(`DELETE FROM "${op.name}"`);
|
|
104
|
+
|
|
105
|
+
return this.db.run(op.rebuildSql);
|
|
106
|
+
}
|
|
107
|
+
case "CreateVectorIndex": {
|
|
108
|
+
this.db.run(op.createSql);
|
|
109
|
+
|
|
110
|
+
for (const trigger of op.triggers) {
|
|
111
|
+
this.db.run(trigger.sql);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
case "DropVectorIndex": {
|
|
117
|
+
for (const name of op.triggerNames) {
|
|
118
|
+
this.db.run(`DROP TRIGGER IF EXISTS "${name}"`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return this.db.run(`DROP TABLE IF EXISTS "${op.name}"`);
|
|
122
|
+
}
|
|
123
|
+
case "RebuildVectorIndex": {
|
|
124
|
+
this.db.run(`DELETE FROM "${op.name}"`);
|
|
125
|
+
|
|
126
|
+
return this.db.run(op.rebuildSql);
|
|
127
|
+
}
|
|
76
128
|
}
|
|
77
129
|
}
|
|
78
130
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
import type { Kysely } from "kysely";
|
|
4
|
+
|
|
5
|
+
import type { DataMigration } from "../types.js";
|
|
6
|
+
import { Differ } from "./diff.js";
|
|
7
|
+
import { Executor } from "./execute.js";
|
|
8
|
+
import { Introspector } from "./introspect.js";
|
|
9
|
+
import type { MigrationRegistry } from "./registry.js";
|
|
10
|
+
import type {
|
|
11
|
+
DesiredSchema,
|
|
12
|
+
ExistingSchema,
|
|
13
|
+
IntrospectedTable,
|
|
14
|
+
MigrationOp,
|
|
15
|
+
} from "./types.js";
|
|
16
|
+
|
|
17
|
+
export type GuidedMigrationDeps = {
|
|
18
|
+
sqlite: Database;
|
|
19
|
+
kysely: Kysely<any>;
|
|
20
|
+
desired: DesiredSchema;
|
|
21
|
+
existing: Map<string, IntrospectedTable>;
|
|
22
|
+
migration: DataMigration<any, any, any>;
|
|
23
|
+
checksum: string;
|
|
24
|
+
registry: MigrationRegistry;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export class GuidedMigrationRunner {
|
|
28
|
+
constructor(private deps: GuidedMigrationDeps) {}
|
|
29
|
+
|
|
30
|
+
async run() {
|
|
31
|
+
const expandOps = this.expandOps();
|
|
32
|
+
|
|
33
|
+
const restoreFk = Executor.disableForeignKeys(this.deps.sqlite);
|
|
34
|
+
|
|
35
|
+
this.deps.sqlite.run("BEGIN");
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
new Executor(this.deps.sqlite, expandOps).applyWithin();
|
|
39
|
+
|
|
40
|
+
await this.deps.migration.run({ db: this.deps.kysely });
|
|
41
|
+
|
|
42
|
+
const { ops, existing } = this.contractPlan();
|
|
43
|
+
|
|
44
|
+
this.logDrops(ops, existing);
|
|
45
|
+
|
|
46
|
+
new Executor(this.deps.sqlite, ops).applyWithin();
|
|
47
|
+
|
|
48
|
+
this.deps.registry.record(this.deps.migration.id, this.deps.checksum);
|
|
49
|
+
|
|
50
|
+
Executor.assertForeignKeysIntact(this.deps.sqlite);
|
|
51
|
+
|
|
52
|
+
this.deps.sqlite.run("COMMIT");
|
|
53
|
+
} catch (e) {
|
|
54
|
+
this.deps.sqlite.run("ROLLBACK");
|
|
55
|
+
throw e;
|
|
56
|
+
} finally {
|
|
57
|
+
if (restoreFk) {
|
|
58
|
+
this.deps.sqlite.run("PRAGMA foreign_keys = ON");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private expandOps() {
|
|
64
|
+
const creates: MigrationOp[] = [];
|
|
65
|
+
const adds: MigrationOp[] = [];
|
|
66
|
+
|
|
67
|
+
for (const table of this.deps.desired.desiredTables) {
|
|
68
|
+
const existing = this.deps.existing.get(table.name);
|
|
69
|
+
|
|
70
|
+
if (!existing) {
|
|
71
|
+
creates.push({ type: "CreateTable", table: table.name, sql: table.sql });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const col of table.columns) {
|
|
76
|
+
if (!existing.columns.has(col.name)) {
|
|
77
|
+
adds.push({ type: "AddColumn", table: table.name, columnDef: col.nullableAddDef });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return [...creates, ...adds];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private contractPlan() {
|
|
86
|
+
const introspector = new Introspector(this.deps.sqlite);
|
|
87
|
+
const existing = introspector.introspect();
|
|
88
|
+
|
|
89
|
+
const existingState: ExistingSchema = {
|
|
90
|
+
existing,
|
|
91
|
+
existingFts: introspector.introspectFts(),
|
|
92
|
+
existingVectorIndexes: introspector.introspectVectorIndexes(),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const ops = new Differ(this.deps.desired, existingState, { guided: true }).diff();
|
|
96
|
+
|
|
97
|
+
return { ops, existing };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private logDrops(ops: MigrationOp[], existing: Map<string, IntrospectedTable>) {
|
|
101
|
+
const drops: string[] = [];
|
|
102
|
+
|
|
103
|
+
for (const op of ops) {
|
|
104
|
+
if (op.type === "DropTable") {
|
|
105
|
+
drops.push(`table "${op.table}"`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (op.type !== "RebuildTable") {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const before = existing.get(op.table);
|
|
114
|
+
|
|
115
|
+
if (!before) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const kept = new Set(op.columnCopies.map((c) => c.name));
|
|
120
|
+
|
|
121
|
+
for (const name of before.columns.keys()) {
|
|
122
|
+
if (!kept.has(name)) {
|
|
123
|
+
drops.push(`column "${name}" from "${op.table}"`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (drops.length === 0) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.warn(
|
|
133
|
+
`Migration "${this.deps.migration.id}" will drop:\n${drops.map((d) => ` - ${d}`).join("\n")}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|