@lobomfz/db 0.2.1 → 0.3.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.
- package/README.md +64 -36
- package/package.json +1 -1
- package/src/database.ts +60 -26
- package/src/generated.ts +3 -1
- package/src/migration/diff.ts +194 -0
- package/src/migration/execute.ts +113 -0
- package/src/migration/introspect.ts +140 -0
- package/src/migration/types.ts +74 -0
package/README.md
CHANGED
|
@@ -14,35 +14,35 @@ bun add @lobomfz/db arktype kysely
|
|
|
14
14
|
import { Database, generated, type } from "@lobomfz/db";
|
|
15
15
|
|
|
16
16
|
const db = new Database({
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
17
|
+
path: "data.db",
|
|
18
|
+
schema: {
|
|
19
|
+
tables: {
|
|
20
|
+
users: type({
|
|
21
|
+
id: generated("autoincrement"),
|
|
22
|
+
name: "string",
|
|
23
|
+
email: type("string").configure({ unique: true }),
|
|
24
|
+
"bio?": "string", // optional → nullable in SQLite
|
|
25
|
+
active: type("boolean").default(true),
|
|
26
|
+
created_at: generated("now"), // defaults to current time
|
|
27
|
+
}),
|
|
28
|
+
posts: type({
|
|
29
|
+
id: generated("autoincrement"),
|
|
30
|
+
user_id: type("number.integer").configure({ references: "users.id", onDelete: "cascade" }),
|
|
31
|
+
title: "string",
|
|
32
|
+
published_at: "Date", // native Date support
|
|
33
|
+
tags: "string[]", // JSON columns just work
|
|
34
|
+
metadata: type({ source: "string", "priority?": "number" }), // validated on write by default
|
|
35
|
+
status: type.enumerated("draft", "published").default("draft"),
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
indexes: {
|
|
39
|
+
posts: [{ columns: ["user_id", "status"] }, { columns: ["title"], unique: true }],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
pragmas: {
|
|
43
|
+
journal_mode: "wal",
|
|
44
|
+
synchronous: "normal",
|
|
45
|
+
},
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
// Fully typed Kysely client — generated/default fields are optional on insert
|
|
@@ -58,10 +58,10 @@ Booleans, dates, objects, arrays — everything round-trips as the type you decl
|
|
|
58
58
|
## API
|
|
59
59
|
|
|
60
60
|
```typescript
|
|
61
|
-
generated("autoincrement");
|
|
62
|
-
generated("now");
|
|
63
|
-
type("string").default("pending");
|
|
64
|
-
type("string").configure({ unique: true });
|
|
61
|
+
generated("autoincrement"); // auto-incrementing primary key
|
|
62
|
+
generated("now"); // defaults to current timestamp, returned as Date
|
|
63
|
+
type("string").default("pending"); // SQL DEFAULT
|
|
64
|
+
type("string").configure({ unique: true }); // UNIQUE
|
|
65
65
|
type("number.integer").configure({ references: "users.id", onDelete: "cascade" }); // FK
|
|
66
66
|
```
|
|
67
67
|
|
|
@@ -69,12 +69,40 @@ JSON columns are validated against the schema on write by default. To also valid
|
|
|
69
69
|
|
|
70
70
|
```typescript
|
|
71
71
|
new Database({
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
// ...
|
|
73
|
+
validation: { onRead: true }, // default: { onRead: false, onWrite: true }
|
|
74
74
|
});
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
## Migrations
|
|
78
|
+
|
|
79
|
+
Schema changes are applied automatically on startup. Every time `new Database(...)` runs, the library compares your Arktype schema against the actual SQLite database and applies the minimum set of operations to bring them in sync. No migration files, no version tracking — the database itself is the source of truth.
|
|
80
|
+
|
|
81
|
+
### What's supported
|
|
82
|
+
|
|
83
|
+
| Change | Strategy |
|
|
84
|
+
|---|---|
|
|
85
|
+
| New table | `CREATE TABLE` |
|
|
86
|
+
| Removed table | `DROP TABLE` |
|
|
87
|
+
| New nullable column | `ALTER TABLE ADD COLUMN` |
|
|
88
|
+
| New NOT NULL column with DEFAULT | `ALTER TABLE ADD COLUMN` |
|
|
89
|
+
| Removed column | Table rebuild |
|
|
90
|
+
| Type change | Table rebuild |
|
|
91
|
+
| Nullability change | Table rebuild |
|
|
92
|
+
| DEFAULT change | Table rebuild |
|
|
93
|
+
| UNIQUE added/removed | Table rebuild |
|
|
94
|
+
| FK added/removed/changed | Table rebuild |
|
|
95
|
+
| Index added | `CREATE INDEX` |
|
|
96
|
+
| Index removed | `DROP INDEX` |
|
|
97
|
+
|
|
98
|
+
Table rebuilds follow SQLite's [recommended procedure](https://www.sqlite.org/lang_altertable.html#otheralter): create a new table with the target schema, copy data from the old table, drop the old table, rename the new one. Foreign keys are disabled during rebuilds and validated via `PRAGMA foreign_key_check` before committing.
|
|
99
|
+
|
|
100
|
+
### Safety rules
|
|
101
|
+
|
|
102
|
+
- Adding a NOT NULL column without DEFAULT to a table **with data** throws an error
|
|
103
|
+
- Changing a nullable column to NOT NULL without DEFAULT throws if any row has NULL in that column
|
|
104
|
+
- Nullable-to-NOT-NULL with DEFAULT uses `COALESCE` to fill existing NULLs
|
|
105
|
+
- Column renames are treated as drop + add (data in the old column is not preserved)
|
|
78
106
|
|
|
79
107
|
## License
|
|
80
108
|
|
package/package.json
CHANGED
package/src/database.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Database as BunDatabase } from "bun:sqlite";
|
|
2
|
-
import { Kysely } from "kysely";
|
|
2
|
+
import { Kysely, ParseJSONResultsPlugin } from "kysely";
|
|
3
3
|
import { BunSqliteDialect } from "./dialect/dialect";
|
|
4
4
|
import type { Type } from "arktype";
|
|
5
5
|
import type { GeneratedPreset } from "./generated";
|
|
@@ -12,6 +12,9 @@ import type {
|
|
|
12
12
|
TablesFromSchemas,
|
|
13
13
|
DatabasePragmas,
|
|
14
14
|
} from "./types";
|
|
15
|
+
import { Introspector } from "./migration/introspect";
|
|
16
|
+
import { Differ, type DesiredTable } from "./migration/diff";
|
|
17
|
+
import { Executor } from "./migration/execute";
|
|
15
18
|
|
|
16
19
|
type ArkBranch = {
|
|
17
20
|
domain?: string;
|
|
@@ -70,7 +73,7 @@ export class Database<T extends SchemaRecord> {
|
|
|
70
73
|
|
|
71
74
|
this.applyPragmas();
|
|
72
75
|
|
|
73
|
-
this.
|
|
76
|
+
this.migrate();
|
|
74
77
|
|
|
75
78
|
const validation = {
|
|
76
79
|
onRead: options.validation?.onRead ?? false,
|
|
@@ -79,7 +82,7 @@ export class Database<T extends SchemaRecord> {
|
|
|
79
82
|
|
|
80
83
|
this.kysely = new Kysely<TablesFromSchemas<T>>({
|
|
81
84
|
dialect: new BunSqliteDialect({ database: this.sqlite }),
|
|
82
|
-
plugins: [new DeserializePlugin(this.columns, validation)],
|
|
85
|
+
plugins: [new DeserializePlugin(this.columns, validation), new ParseJSONResultsPlugin()],
|
|
83
86
|
});
|
|
84
87
|
}
|
|
85
88
|
|
|
@@ -233,6 +236,23 @@ export class Database<T extends SchemaRecord> {
|
|
|
233
236
|
return fk;
|
|
234
237
|
}
|
|
235
238
|
|
|
239
|
+
private addColumnDef(prop: Prop) {
|
|
240
|
+
let def = this.columnDef(prop);
|
|
241
|
+
|
|
242
|
+
const ref = prop.meta?.references;
|
|
243
|
+
|
|
244
|
+
if (ref) {
|
|
245
|
+
const [table, column] = ref.split(".");
|
|
246
|
+
def += ` REFERENCES "${table}"("${column}")`;
|
|
247
|
+
|
|
248
|
+
if (prop.meta?.onDelete) {
|
|
249
|
+
def += ` ON DELETE ${prop.meta.onDelete.toUpperCase()}`;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return def;
|
|
254
|
+
}
|
|
255
|
+
|
|
236
256
|
private parseSchemaProps(schema: Type) {
|
|
237
257
|
const structureProps = (schema as any).structure?.props as StructureProp[] | undefined;
|
|
238
258
|
|
|
@@ -284,15 +304,48 @@ export class Database<T extends SchemaRecord> {
|
|
|
284
304
|
return `CREATE TABLE IF NOT EXISTS "${tableName}" (${columns.concat(fks).join(", ")})`;
|
|
285
305
|
}
|
|
286
306
|
|
|
287
|
-
private
|
|
307
|
+
private migrate() {
|
|
308
|
+
const desiredTables: DesiredTable[] = [];
|
|
309
|
+
const schemaIndexes = this.options.schema.indexes;
|
|
310
|
+
|
|
288
311
|
for (const [name, schema] of Object.entries(this.options.schema.tables)) {
|
|
289
312
|
const props = this.parseSchemaProps(schema);
|
|
290
313
|
|
|
291
314
|
this.registerColumns(name, props);
|
|
292
|
-
|
|
315
|
+
|
|
316
|
+
const columns = props.map((prop) => {
|
|
317
|
+
const isNotNull = this.columnConstraint(prop) === "NOT NULL";
|
|
318
|
+
const defaultClause = this.defaultClause(prop);
|
|
319
|
+
const hasLiteralDefault = prop.generated !== "now" && defaultClause !== null;
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
name: prop.key,
|
|
323
|
+
addable: !isNotNull || hasLiteralDefault,
|
|
324
|
+
columnDef: this.addColumnDef(prop),
|
|
325
|
+
type: this.sqlType(prop),
|
|
326
|
+
notnull: isNotNull,
|
|
327
|
+
defaultValue: defaultClause
|
|
328
|
+
? defaultClause.replace("DEFAULT ", "").replace(/^\((.+)\)$/, "$1")
|
|
329
|
+
: null,
|
|
330
|
+
unique: !!prop.meta?.unique,
|
|
331
|
+
references: prop.meta?.references ?? null,
|
|
332
|
+
onDelete: prop.meta?.onDelete?.toUpperCase() ?? null,
|
|
333
|
+
};
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const indexes = (schemaIndexes?.[name] ?? []).map((indexDef) => ({
|
|
337
|
+
name: this.generateIndexName(name, indexDef.columns, indexDef.unique ?? false),
|
|
338
|
+
columns: indexDef.columns,
|
|
339
|
+
sql: this.generateCreateIndexSQL(name, indexDef),
|
|
340
|
+
}));
|
|
341
|
+
|
|
342
|
+
desiredTables.push({ name, sql: this.generateCreateTableSQL(name, props), columns, indexes });
|
|
293
343
|
}
|
|
294
344
|
|
|
295
|
-
this.
|
|
345
|
+
const existing = new Introspector(this.sqlite).introspect();
|
|
346
|
+
const ops = new Differ(desiredTables, existing).diff();
|
|
347
|
+
|
|
348
|
+
new Executor(this.sqlite, ops).execute();
|
|
296
349
|
}
|
|
297
350
|
|
|
298
351
|
private generateIndexName(tableName: string, columns: string[], unique: boolean) {
|
|
@@ -306,25 +359,7 @@ export class Database<T extends SchemaRecord> {
|
|
|
306
359
|
const unique = indexDef.unique ? "UNIQUE " : "";
|
|
307
360
|
const columns = indexDef.columns.map((c) => `"${c}"`).join(", ");
|
|
308
361
|
|
|
309
|
-
return `CREATE ${unique}INDEX
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
private createIndexes() {
|
|
313
|
-
const indexes = this.options.schema.indexes;
|
|
314
|
-
|
|
315
|
-
if (!indexes) {
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
for (const [tableName, tableIndexes] of Object.entries(indexes)) {
|
|
320
|
-
if (!tableIndexes) {
|
|
321
|
-
continue;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
for (const indexDef of tableIndexes) {
|
|
325
|
-
this.sqlite.run(this.generateCreateIndexSQL(tableName, indexDef));
|
|
326
|
-
}
|
|
327
|
-
}
|
|
362
|
+
return `CREATE ${unique}INDEX "${indexName}" ON "${tableName}" (${columns})`;
|
|
328
363
|
}
|
|
329
364
|
|
|
330
365
|
reset(table?: keyof T & string): void {
|
|
@@ -334,5 +369,4 @@ export class Database<T extends SchemaRecord> {
|
|
|
334
369
|
this.sqlite.run(`DELETE FROM "${t}"`);
|
|
335
370
|
}
|
|
336
371
|
}
|
|
337
|
-
|
|
338
372
|
}
|
package/src/generated.ts
CHANGED
|
@@ -13,6 +13,8 @@ const generatedTypes = {
|
|
|
13
13
|
.default(() => new Date(0)),
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
export function generated<P extends GeneratedPreset>(
|
|
16
|
+
export function generated<P extends GeneratedPreset>(
|
|
17
|
+
preset: P,
|
|
18
|
+
): ReturnType<(typeof generatedTypes)[P]> {
|
|
17
19
|
return generatedTypes[preset]() as ReturnType<(typeof generatedTypes)[P]>;
|
|
18
20
|
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { ColumnSchema, IntrospectedTable, ColumnCopy, MigrationOp } from "./types";
|
|
2
|
+
|
|
3
|
+
export interface DesiredColumn extends ColumnSchema {
|
|
4
|
+
addable: boolean;
|
|
5
|
+
columnDef: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type DesiredIndex = {
|
|
9
|
+
name: string;
|
|
10
|
+
columns: string[];
|
|
11
|
+
sql: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type DesiredTable = {
|
|
15
|
+
name: string;
|
|
16
|
+
sql: string;
|
|
17
|
+
columns: DesiredColumn[];
|
|
18
|
+
indexes?: DesiredIndex[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export class Differ {
|
|
22
|
+
private ops: MigrationOp[] = [];
|
|
23
|
+
private desiredNames: Set<string>;
|
|
24
|
+
private rebuiltTables = new Set<string>();
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
private desired: DesiredTable[],
|
|
28
|
+
private existing: Map<string, IntrospectedTable>,
|
|
29
|
+
) {
|
|
30
|
+
this.desiredNames = new Set(desired.map((t) => t.name));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
diff() {
|
|
34
|
+
this.diffTables();
|
|
35
|
+
this.dropOrphans();
|
|
36
|
+
this.diffIndexes();
|
|
37
|
+
return this.ops;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private diffTables() {
|
|
41
|
+
for (const table of this.desired) {
|
|
42
|
+
const existingTable = this.existing.get(table.name);
|
|
43
|
+
|
|
44
|
+
if (!existingTable) {
|
|
45
|
+
this.ops.push({ type: "CreateTable", table: table.name, sql: table.sql });
|
|
46
|
+
this.rebuiltTables.add(table.name);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.diffColumns(table, existingTable);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private diffColumns(table: DesiredTable, existingTable: IntrospectedTable) {
|
|
55
|
+
const desiredNames = new Set(table.columns.map((c) => c.name));
|
|
56
|
+
const hasRemovedColumns = [...existingTable.columns.keys()].some(
|
|
57
|
+
(name) => !desiredNames.has(name),
|
|
58
|
+
);
|
|
59
|
+
const hasChangedColumns = table.columns.some((col) => {
|
|
60
|
+
const existing = existingTable.columns.get(col.name);
|
|
61
|
+
|
|
62
|
+
if (!existing) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return this.columnChanged(col, existing);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (hasRemovedColumns || hasChangedColumns) {
|
|
70
|
+
this.buildRebuild(table, existingTable);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this.buildAddColumns(table, existingTable);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private buildRebuild(table: DesiredTable, existingTable: IntrospectedTable) {
|
|
78
|
+
const columnCopies: ColumnCopy[] = [];
|
|
79
|
+
|
|
80
|
+
for (const col of table.columns) {
|
|
81
|
+
const existing = existingTable.columns.get(col.name);
|
|
82
|
+
|
|
83
|
+
if (!existing) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (col.type !== existing.type) {
|
|
88
|
+
if (col.notnull && col.defaultValue === null && existingTable.hasData) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Cannot change type of NOT NULL column "${col.name}" without DEFAULT in table "${table.name}" with existing data`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!existing.notnull && col.notnull && col.defaultValue === null && existing.hasNulls) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Cannot make column "${col.name}" NOT NULL without DEFAULT in table "${table.name}" with existing data`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!existing.notnull && col.notnull && col.defaultValue !== null) {
|
|
104
|
+
columnCopies.push({ name: col.name, expr: `COALESCE("${col.name}", ${col.defaultValue})` });
|
|
105
|
+
} else {
|
|
106
|
+
columnCopies.push({ name: col.name, expr: `"${col.name}"` });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.ops.push({ type: "RebuildTable", table: table.name, createSql: table.sql, columnCopies });
|
|
111
|
+
this.rebuiltTables.add(table.name);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private buildAddColumns(table: DesiredTable, existingTable: IntrospectedTable) {
|
|
115
|
+
const newColumns = table.columns.filter((c) => !existingTable.columns.has(c.name));
|
|
116
|
+
|
|
117
|
+
if (newColumns.length === 0) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const nonAddable = newColumns.filter((c) => !c.addable);
|
|
122
|
+
|
|
123
|
+
if (nonAddable.length > 0) {
|
|
124
|
+
if (existingTable.hasData) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Cannot add NOT NULL column "${nonAddable[0]!.name}" without DEFAULT to table "${table.name}" with existing data`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
this.ops.push({ type: "DropTable", table: table.name });
|
|
131
|
+
this.ops.push({ type: "CreateTable", table: table.name, sql: table.sql });
|
|
132
|
+
this.rebuiltTables.add(table.name);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const col of newColumns) {
|
|
137
|
+
this.ops.push({ type: "AddColumn", table: table.name, columnDef: col.columnDef });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private columnChanged(desired: ColumnSchema, existing: ColumnSchema) {
|
|
142
|
+
return (
|
|
143
|
+
desired.type !== existing.type ||
|
|
144
|
+
desired.notnull !== existing.notnull ||
|
|
145
|
+
desired.defaultValue !== existing.defaultValue ||
|
|
146
|
+
desired.unique !== existing.unique ||
|
|
147
|
+
desired.references !== existing.references ||
|
|
148
|
+
desired.onDelete !== existing.onDelete
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private dropOrphans() {
|
|
153
|
+
for (const [name] of this.existing) {
|
|
154
|
+
if (!this.desiredNames.has(name)) {
|
|
155
|
+
this.ops.push({ type: "DropTable", table: name });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private diffIndexes() {
|
|
161
|
+
for (const table of this.desired) {
|
|
162
|
+
const tableIndexes = table.indexes ?? [];
|
|
163
|
+
|
|
164
|
+
if (this.rebuiltTables.has(table.name)) {
|
|
165
|
+
for (const idx of tableIndexes) {
|
|
166
|
+
this.ops.push({ type: "CreateIndex", table: table.name, columns: idx.columns, sql: idx.sql });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const existingTable = this.existing.get(table.name);
|
|
173
|
+
|
|
174
|
+
if (!existingTable) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const existingNames = new Set(existingTable.indexes.map((i) => i.name));
|
|
179
|
+
const desiredNames = new Set(tableIndexes.map((i) => i.name));
|
|
180
|
+
|
|
181
|
+
for (const idx of tableIndexes) {
|
|
182
|
+
if (!existingNames.has(idx.name)) {
|
|
183
|
+
this.ops.push({ type: "CreateIndex", table: table.name, columns: idx.columns, sql: idx.sql });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const idx of existingTable.indexes) {
|
|
188
|
+
if (!desiredNames.has(idx.name)) {
|
|
189
|
+
this.ops.push({ type: "DropIndex", index: idx.name });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import type { MigrationOp, RebuildTableOp } from "./types";
|
|
3
|
+
|
|
4
|
+
export class Executor {
|
|
5
|
+
constructor(
|
|
6
|
+
private db: Database,
|
|
7
|
+
private ops: MigrationOp[],
|
|
8
|
+
) {}
|
|
9
|
+
|
|
10
|
+
execute() {
|
|
11
|
+
if (this.ops.length === 0) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const hasRebuild = this.ops.some((op) => op.type === "RebuildTable");
|
|
16
|
+
|
|
17
|
+
let restoreFk = false;
|
|
18
|
+
|
|
19
|
+
if (hasRebuild) {
|
|
20
|
+
const { foreign_keys } = this.db.prepare("PRAGMA foreign_keys").get() as {
|
|
21
|
+
foreign_keys: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
if (foreign_keys === 1) {
|
|
25
|
+
this.db.run("PRAGMA foreign_keys = OFF");
|
|
26
|
+
restoreFk = true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
this.db.transaction(() => {
|
|
32
|
+
for (const op of this.ops) {
|
|
33
|
+
try {
|
|
34
|
+
this.executeOp(op);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
throw this.parseError(e, op);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (restoreFk) {
|
|
41
|
+
const violations = this.db.prepare("PRAGMA foreign_key_check").all();
|
|
42
|
+
|
|
43
|
+
if (violations.length > 0) {
|
|
44
|
+
throw new Error("Foreign key check failed after rebuild");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
})();
|
|
48
|
+
} finally {
|
|
49
|
+
if (restoreFk) {
|
|
50
|
+
this.db.run("PRAGMA foreign_keys = ON");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private executeOp(op: MigrationOp) {
|
|
56
|
+
switch (op.type) {
|
|
57
|
+
case "CreateTable": {
|
|
58
|
+
return this.db.run(op.sql);
|
|
59
|
+
}
|
|
60
|
+
case "DropTable": {
|
|
61
|
+
return this.db.run(`DROP TABLE "${op.table}"`);
|
|
62
|
+
}
|
|
63
|
+
case "AddColumn": {
|
|
64
|
+
return this.db.run(`ALTER TABLE "${op.table}" ADD COLUMN ${op.columnDef}`);
|
|
65
|
+
}
|
|
66
|
+
case "RebuildTable": {
|
|
67
|
+
return this.rebuildTable(op);
|
|
68
|
+
}
|
|
69
|
+
case "CreateIndex": {
|
|
70
|
+
return this.db.run(op.sql);
|
|
71
|
+
}
|
|
72
|
+
case "DropIndex": {
|
|
73
|
+
return this.db.run(`DROP INDEX "${op.index}"`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private parseError(e: unknown, op: MigrationOp) {
|
|
79
|
+
if (
|
|
80
|
+
op.type === "CreateIndex" &&
|
|
81
|
+
e instanceof Error &&
|
|
82
|
+
e.message.includes("UNIQUE constraint failed")
|
|
83
|
+
) {
|
|
84
|
+
const cols = op.columns.map((c) => `"${c}"`).join(", ");
|
|
85
|
+
|
|
86
|
+
return new Error(
|
|
87
|
+
`Cannot create unique index on table "${op.table}" (${cols}): duplicate values exist`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return e;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private rebuildTable(op: RebuildTableOp) {
|
|
95
|
+
const tempName = `__new_${op.table}`;
|
|
96
|
+
const tempSql = op.createSql.replace(
|
|
97
|
+
`CREATE TABLE IF NOT EXISTS "${op.table}"`,
|
|
98
|
+
`CREATE TABLE "${tempName}"`,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
this.db.run(tempSql);
|
|
102
|
+
|
|
103
|
+
if (op.columnCopies.length > 0) {
|
|
104
|
+
const destCols = op.columnCopies.map((c) => `"${c.name}"`).join(", ");
|
|
105
|
+
const srcExprs = op.columnCopies.map((c) => c.expr).join(", ");
|
|
106
|
+
|
|
107
|
+
this.db.run(`INSERT INTO "${tempName}" (${destCols}) SELECT ${srcExprs} FROM "${op.table}"`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.db.run(`DROP TABLE "${op.table}"`);
|
|
111
|
+
this.db.run(`ALTER TABLE "${tempName}" RENAME TO "${op.table}"`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import type { IntrospectedColumn, IntrospectedIndex, IntrospectedTable } from "./types";
|
|
3
|
+
|
|
4
|
+
type TableListRow = {
|
|
5
|
+
name: string;
|
|
6
|
+
type: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type TableXInfoRow = {
|
|
10
|
+
name: string;
|
|
11
|
+
type: string;
|
|
12
|
+
notnull: number;
|
|
13
|
+
dflt_value: string | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type IndexListRow = {
|
|
17
|
+
name: string;
|
|
18
|
+
unique: number;
|
|
19
|
+
origin: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type IndexInfoRow = {
|
|
23
|
+
name: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type ForeignKeyListRow = {
|
|
27
|
+
from: string;
|
|
28
|
+
table: string;
|
|
29
|
+
to: string;
|
|
30
|
+
on_delete: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export class Introspector {
|
|
34
|
+
constructor(private db: Database) {}
|
|
35
|
+
|
|
36
|
+
introspect() {
|
|
37
|
+
const tables = new Map<string, IntrospectedTable>();
|
|
38
|
+
const tableRows = this.db.prepare("PRAGMA table_list").all() as TableListRow[];
|
|
39
|
+
|
|
40
|
+
for (const row of tableRows) {
|
|
41
|
+
if (row.type !== "table" || row.name.startsWith("sqlite_")) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const indexRows = this.db.prepare(`PRAGMA index_list("${row.name}")`).all() as IndexListRow[];
|
|
46
|
+
const uniqueCols = this.uniqueColumns(indexRows);
|
|
47
|
+
const fkMap = this.foreignKeys(row.name);
|
|
48
|
+
const columns = this.columns(row.name, uniqueCols, fkMap);
|
|
49
|
+
const indexes = this.indexes(indexRows);
|
|
50
|
+
const hasData = this.db.prepare(`SELECT 1 FROM "${row.name}" LIMIT 1`).get() !== null;
|
|
51
|
+
|
|
52
|
+
tables.set(row.name, { columns, indexes, hasData });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return tables;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private uniqueColumns(indexRows: IndexListRow[]) {
|
|
59
|
+
const unique = new Set<string>();
|
|
60
|
+
|
|
61
|
+
for (const idx of indexRows) {
|
|
62
|
+
if (idx.unique !== 1 || idx.origin !== "u") {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const idxCols = this.db.prepare(`PRAGMA index_info("${idx.name}")`).all() as IndexInfoRow[];
|
|
67
|
+
|
|
68
|
+
if (idxCols.length === 1) {
|
|
69
|
+
unique.add(idxCols[0]!.name);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return unique;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private indexes(indexRows: IndexListRow[]) {
|
|
77
|
+
const indexes: IntrospectedIndex[] = [];
|
|
78
|
+
|
|
79
|
+
for (const idx of indexRows) {
|
|
80
|
+
if (idx.origin !== "c") {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const idxCols = this.db.prepare(`PRAGMA index_info("${idx.name}")`).all() as IndexInfoRow[];
|
|
85
|
+
|
|
86
|
+
indexes.push({
|
|
87
|
+
name: idx.name,
|
|
88
|
+
columns: idxCols.map((c) => c.name),
|
|
89
|
+
unique: idx.unique === 1,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return indexes;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private foreignKeys(table: string) {
|
|
97
|
+
const fkRows = this.db
|
|
98
|
+
.prepare(`PRAGMA foreign_key_list("${table}")`)
|
|
99
|
+
.all() as ForeignKeyListRow[];
|
|
100
|
+
const fkMap = new Map<string, { references: string; onDelete: string | null }>();
|
|
101
|
+
|
|
102
|
+
for (const fk of fkRows) {
|
|
103
|
+
fkMap.set(fk.from, {
|
|
104
|
+
references: `${fk.table}.${fk.to}`,
|
|
105
|
+
onDelete: fk.on_delete === "NO ACTION" ? null : fk.on_delete,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return fkMap;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private columns(
|
|
113
|
+
table: string,
|
|
114
|
+
uniqueCols: Set<string>,
|
|
115
|
+
fkMap: Map<string, { references: string; onDelete: string | null }>,
|
|
116
|
+
) {
|
|
117
|
+
const colRows = this.db.prepare(`PRAGMA table_xinfo("${table}")`).all() as TableXInfoRow[];
|
|
118
|
+
const columns = new Map<string, IntrospectedColumn>();
|
|
119
|
+
|
|
120
|
+
for (const col of colRows) {
|
|
121
|
+
const fk = fkMap.get(col.name);
|
|
122
|
+
const isNotnull = col.notnull === 1;
|
|
123
|
+
|
|
124
|
+
columns.set(col.name, {
|
|
125
|
+
name: col.name,
|
|
126
|
+
type: col.type,
|
|
127
|
+
notnull: isNotnull,
|
|
128
|
+
defaultValue: col.dflt_value,
|
|
129
|
+
unique: uniqueCols.has(col.name),
|
|
130
|
+
references: fk?.references ?? null,
|
|
131
|
+
onDelete: fk?.onDelete ?? null,
|
|
132
|
+
hasNulls:
|
|
133
|
+
!isNotnull &&
|
|
134
|
+
this.db.prepare(`SELECT 1 FROM "${table}" WHERE "${col.name}" IS NULL LIMIT 1`).get() !== null,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return columns;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type CreateTableOp = {
|
|
2
|
+
type: "CreateTable";
|
|
3
|
+
table: string;
|
|
4
|
+
sql: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type DropTableOp = {
|
|
8
|
+
type: "DropTable";
|
|
9
|
+
table: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type AddColumnOp = {
|
|
13
|
+
type: "AddColumn";
|
|
14
|
+
table: string;
|
|
15
|
+
columnDef: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ColumnCopy = {
|
|
19
|
+
name: string;
|
|
20
|
+
expr: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type RebuildTableOp = {
|
|
24
|
+
type: "RebuildTable";
|
|
25
|
+
table: string;
|
|
26
|
+
createSql: string;
|
|
27
|
+
columnCopies: ColumnCopy[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type CreateIndexOp = {
|
|
31
|
+
type: "CreateIndex";
|
|
32
|
+
table: string;
|
|
33
|
+
columns: string[];
|
|
34
|
+
sql: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type DropIndexOp = {
|
|
38
|
+
type: "DropIndex";
|
|
39
|
+
index: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type MigrationOp =
|
|
43
|
+
| CreateTableOp
|
|
44
|
+
| DropTableOp
|
|
45
|
+
| AddColumnOp
|
|
46
|
+
| RebuildTableOp
|
|
47
|
+
| CreateIndexOp
|
|
48
|
+
| DropIndexOp;
|
|
49
|
+
|
|
50
|
+
export type ColumnSchema = {
|
|
51
|
+
name: string;
|
|
52
|
+
type: string;
|
|
53
|
+
notnull: boolean;
|
|
54
|
+
defaultValue: string | null;
|
|
55
|
+
unique: boolean;
|
|
56
|
+
references: string | null;
|
|
57
|
+
onDelete: string | null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export interface IntrospectedColumn extends ColumnSchema {
|
|
61
|
+
hasNulls: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type IntrospectedIndex = {
|
|
65
|
+
name: string;
|
|
66
|
+
columns: string[];
|
|
67
|
+
unique: boolean;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type IntrospectedTable = {
|
|
71
|
+
columns: Map<string, IntrospectedColumn>;
|
|
72
|
+
indexes: IntrospectedIndex[];
|
|
73
|
+
hasData: boolean;
|
|
74
|
+
};
|