@lobomfz/db 0.4.1 → 0.5.1

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.
@@ -14,6 +14,10 @@ import {
14
14
  import type { FtsTokenizer } from "../types.js";
15
15
  import type {
16
16
  ColumnSchema,
17
+ DesiredSchema,
18
+ DesiredTable,
19
+ DestructiveChange,
20
+ ExistingSchema,
17
21
  IntrospectedFts,
18
22
  IntrospectedTable,
19
23
  IntrospectedVectorIndex,
@@ -21,38 +25,33 @@ import type {
21
25
  MigrationOp,
22
26
  } from "./types.js";
23
27
 
24
- export interface DesiredColumn extends ColumnSchema {
25
- addable: boolean;
26
- columnDef: string;
27
- }
28
-
29
- export type DesiredIndex = {
30
- name: string;
31
- columns: string[];
32
- sql: string;
33
- };
34
-
35
- export type DesiredTable = {
36
- name: string;
37
- sql: string;
38
- columns: DesiredColumn[];
39
- indexes?: DesiredIndex[];
40
- };
41
-
42
28
  export class Differ {
43
29
  private ops: MigrationOp[] = [];
44
30
  private desiredNames: Set<string>;
45
31
  private rebuiltTables = new Set<string>();
46
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
+
47
42
  constructor(
48
- private desired: DesiredTable[],
49
- private existing: Map<string, IntrospectedTable>,
50
- private desiredFts: ResolvedFtsPlan[] = [],
51
- private existingFts: Map<string, IntrospectedFts> = new Map(),
52
- private desiredVectorIndexes: ResolvedVectorIndex[] = [],
53
- private existingVectorIndexes: Map<string, IntrospectedVectorIndex> = new Map(),
43
+ desired: DesiredSchema,
44
+ existing: ExistingSchema,
45
+ private options: { guided?: boolean } = {},
54
46
  ) {
55
- this.desiredNames = new Set(desired.map((t) => t.name));
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));
56
55
  }
57
56
 
58
57
  diff() {
@@ -106,6 +105,16 @@ export class Differ {
106
105
  }
107
106
 
108
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[] = [];
109
118
  const columnCopies: ColumnCopy[] = [];
110
119
 
111
120
  for (const col of table.columns) {
@@ -122,6 +131,7 @@ export class Differ {
122
131
  );
123
132
  }
124
133
 
134
+ typeChanged.push(col.name);
125
135
  continue;
126
136
  }
127
137
 
@@ -141,6 +151,41 @@ export class Differ {
141
151
  }
142
152
  }
143
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
+
144
189
  this.ops.push({
145
190
  type: "RebuildTable",
146
191
  table: table.name,
@@ -193,9 +238,15 @@ export class Differ {
193
238
  }
194
239
 
195
240
  private dropOrphans() {
196
- for (const [name] of this.existing) {
197
- if (!this.desiredNames.has(name)) {
198
- this.ops.push({ type: "DropTable", table: name });
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" });
199
250
  }
200
251
  }
201
252
  }
@@ -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
+ }
@@ -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
- for (const op of this.ops) {
34
- try {
35
- this.executeOp(op);
36
- } catch (e) {
37
- throw this.parseError(e, op);
38
- }
39
- }
21
+ this.applyWithin();
40
22
 
41
- if (restoreFk) {
42
- const violations = this.db.prepare("PRAGMA foreign_key_check").all();
43
-
44
- if (violations.length > 0) {
45
- throw new Error("Foreign key check failed after rebuild");
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": {
@@ -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
+ }
@@ -2,12 +2,13 @@ import type { Database } from "bun:sqlite";
2
2
 
3
3
  import { FTS_TRIGGER_PREFIX } from "../fts/sql.js";
4
4
  import { VEC_TRIGGER_PREFIX } from "../vector-index/sql.js";
5
- import type {
6
- IntrospectedColumn,
7
- IntrospectedFts,
8
- IntrospectedIndex,
9
- IntrospectedTable,
10
- IntrospectedVectorIndex,
5
+ import {
6
+ MIGRATIONS_TABLE,
7
+ type IntrospectedColumn,
8
+ type IntrospectedFts,
9
+ type IntrospectedIndex,
10
+ type IntrospectedTable,
11
+ type IntrospectedVectorIndex,
11
12
  } from "./types.js";
12
13
 
13
14
  type TableListRow = {
@@ -60,6 +61,10 @@ export class Introspector {
60
61
  continue;
61
62
  }
62
63
 
64
+ if (row.name === MIGRATIONS_TABLE) {
65
+ continue;
66
+ }
67
+
63
68
  if (virtualPrefixes.some((prefix) => row.name.startsWith(prefix))) {
64
69
  continue;
65
70
  }
@@ -0,0 +1,43 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ import { MigrationDriftError } from "./drift-error.js";
4
+ import { MIGRATIONS_TABLE } from "./types.js";
5
+
6
+ export class MigrationRegistry {
7
+ constructor(private db: Database) {}
8
+
9
+ ensureTable() {
10
+ this.db.run(
11
+ `CREATE TABLE IF NOT EXISTS "${MIGRATIONS_TABLE}" ` +
12
+ `("id" TEXT PRIMARY KEY, "checksum" TEXT NOT NULL, "applied_at" INTEGER NOT NULL)`,
13
+ );
14
+ }
15
+
16
+ appliedChecksum(id: string) {
17
+ const row = this.db
18
+ .prepare(`SELECT "checksum" FROM "${MIGRATIONS_TABLE}" WHERE "id" = ?`)
19
+ .get(id) as { checksum: string } | null;
20
+
21
+ return row?.checksum ?? null;
22
+ }
23
+
24
+ assertNoDrift(id: string, checksum: string) {
25
+ const applied = this.appliedChecksum(id);
26
+
27
+ if (applied !== null && applied !== checksum) {
28
+ throw new MigrationDriftError(id);
29
+ }
30
+ }
31
+
32
+ record(id: string, checksum: string) {
33
+ this.db
34
+ .prepare(
35
+ `INSERT INTO "${MIGRATIONS_TABLE}" ("id", "checksum", "applied_at") VALUES (?, ?, ?)`,
36
+ )
37
+ .run(id, checksum, Date.now());
38
+ }
39
+
40
+ static checksum(run: (...args: any[]) => unknown) {
41
+ return new Bun.CryptoHasher("sha256").update(run.toString()).digest("hex");
42
+ }
43
+ }
@@ -1,3 +1,6 @@
1
+ import type { ResolvedFtsPlan } from "../fts/resolve.js";
2
+ import type { ResolvedVectorIndex } from "../vector-index/resolve.js";
3
+
1
4
  export type CreateTableOp = {
2
5
  type: "CreateTable";
3
6
  table: string;
@@ -101,6 +104,14 @@ export type MigrationOp =
101
104
  | DropVectorIndexOp
102
105
  | RebuildVectorIndexOp;
103
106
 
107
+ export const MIGRATIONS_TABLE = "__migrations";
108
+
109
+ export type DestructiveChange = {
110
+ table: string;
111
+ kind: "DropTable" | "DropColumn" | "TypeChange";
112
+ columns?: string[];
113
+ };
114
+
104
115
  export type ColumnSchema = {
105
116
  name: string;
106
117
  type: string;
@@ -139,3 +150,34 @@ export type IntrospectedVectorIndex = {
139
150
  sql: string;
140
151
  triggers: Map<string, string>;
141
152
  };
153
+
154
+ export interface DesiredColumn extends ColumnSchema {
155
+ addable: boolean;
156
+ columnDef: string;
157
+ nullableAddDef: string;
158
+ }
159
+
160
+ export type DesiredIndex = {
161
+ name: string;
162
+ columns: string[];
163
+ sql: string;
164
+ };
165
+
166
+ export type DesiredTable = {
167
+ name: string;
168
+ sql: string;
169
+ columns: DesiredColumn[];
170
+ indexes?: DesiredIndex[];
171
+ };
172
+
173
+ export type DesiredSchema = {
174
+ desiredTables: DesiredTable[];
175
+ desiredFts?: ResolvedFtsPlan[];
176
+ desiredVectorIndexes?: ResolvedVectorIndex[];
177
+ };
178
+
179
+ export type ExistingSchema = {
180
+ existing: Map<string, IntrospectedTable>;
181
+ existingFts?: Map<string, IntrospectedFts>;
182
+ existingVectorIndexes?: Map<string, IntrospectedVectorIndex>;
183
+ };