@lobomfz/db 0.2.1 → 0.3.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 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
- 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
- },
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"); // 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
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
- validation: { onRead: true }, // default: { onRead: false, onWrite: true }
72
+ // ...
73
+ validation: { onRead: true }, // default: { onRead: false, onWrite: true }
74
74
  });
75
75
  ```
76
76
 
77
- > **Note:** Migrations are not supported yet. Tables are created with `CREATE TABLE IF NOT EXISTS`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobomfz/db",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Bun SQLite database with Arktype schemas and typed Kysely client",
5
5
  "keywords": [
6
6
  "arktype",
package/src/database.ts CHANGED
@@ -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.createTables();
76
+ this.migrate();
74
77
 
75
78
  const validation = {
76
79
  onRead: options.validation?.onRead ?? false,
@@ -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,47 @@ 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 createTables() {
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
- this.sqlite.run(this.generateCreateTableSQL(name, props));
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
+ sql: this.generateCreateIndexSQL(name, indexDef),
339
+ }));
340
+
341
+ desiredTables.push({ name, sql: this.generateCreateTableSQL(name, props), columns, indexes });
293
342
  }
294
343
 
295
- this.createIndexes();
344
+ const existing = new Introspector(this.sqlite).introspect();
345
+ const ops = new Differ(desiredTables, existing).diff();
346
+
347
+ new Executor(this.sqlite, ops).execute();
296
348
  }
297
349
 
298
350
  private generateIndexName(tableName: string, columns: string[], unique: boolean) {
@@ -306,25 +358,7 @@ export class Database<T extends SchemaRecord> {
306
358
  const unique = indexDef.unique ? "UNIQUE " : "";
307
359
  const columns = indexDef.columns.map((c) => `"${c}"`).join(", ");
308
360
 
309
- return `CREATE ${unique}INDEX IF NOT EXISTS "${indexName}" ON "${tableName}" (${columns})`;
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
- }
361
+ return `CREATE ${unique}INDEX "${indexName}" ON "${tableName}" (${columns})`;
328
362
  }
329
363
 
330
364
  reset(table?: keyof T & string): void {
@@ -334,5 +368,4 @@ export class Database<T extends SchemaRecord> {
334
368
  this.sqlite.run(`DELETE FROM "${t}"`);
335
369
  }
336
370
  }
337
-
338
371
  }
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>(preset: P): ReturnType<(typeof generatedTypes)[P]> {
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,193 @@
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
+ sql: string;
11
+ };
12
+
13
+ export type DesiredTable = {
14
+ name: string;
15
+ sql: string;
16
+ columns: DesiredColumn[];
17
+ indexes?: DesiredIndex[];
18
+ };
19
+
20
+ export class Differ {
21
+ private ops: MigrationOp[] = [];
22
+ private desiredNames: Set<string>;
23
+ private rebuiltTables = new Set<string>();
24
+
25
+ constructor(
26
+ private desired: DesiredTable[],
27
+ private existing: Map<string, IntrospectedTable>,
28
+ ) {
29
+ this.desiredNames = new Set(desired.map((t) => t.name));
30
+ }
31
+
32
+ diff() {
33
+ this.diffTables();
34
+ this.dropOrphans();
35
+ this.diffIndexes();
36
+ return this.ops;
37
+ }
38
+
39
+ private diffTables() {
40
+ for (const table of this.desired) {
41
+ const existingTable = this.existing.get(table.name);
42
+
43
+ if (!existingTable) {
44
+ this.ops.push({ type: "CreateTable", table: table.name, sql: table.sql });
45
+ this.rebuiltTables.add(table.name);
46
+ continue;
47
+ }
48
+
49
+ this.diffColumns(table, existingTable);
50
+ }
51
+ }
52
+
53
+ private diffColumns(table: DesiredTable, existingTable: IntrospectedTable) {
54
+ const desiredNames = new Set(table.columns.map((c) => c.name));
55
+ const hasRemovedColumns = [...existingTable.columns.keys()].some(
56
+ (name) => !desiredNames.has(name),
57
+ );
58
+ const hasChangedColumns = table.columns.some((col) => {
59
+ const existing = existingTable.columns.get(col.name);
60
+
61
+ if (!existing) {
62
+ return false;
63
+ }
64
+
65
+ return this.columnChanged(col, existing);
66
+ });
67
+
68
+ if (hasRemovedColumns || hasChangedColumns) {
69
+ this.buildRebuild(table, existingTable);
70
+ return;
71
+ }
72
+
73
+ this.buildAddColumns(table, existingTable);
74
+ }
75
+
76
+ private buildRebuild(table: DesiredTable, existingTable: IntrospectedTable) {
77
+ const columnCopies: ColumnCopy[] = [];
78
+
79
+ for (const col of table.columns) {
80
+ const existing = existingTable.columns.get(col.name);
81
+
82
+ if (!existing) {
83
+ continue;
84
+ }
85
+
86
+ if (col.type !== existing.type) {
87
+ if (col.notnull && col.defaultValue === null && existingTable.hasData) {
88
+ throw new Error(
89
+ `Cannot change type of NOT NULL column "${col.name}" without DEFAULT in table "${table.name}" with existing data`,
90
+ );
91
+ }
92
+
93
+ continue;
94
+ }
95
+
96
+ if (!existing.notnull && col.notnull && col.defaultValue === null && existing.hasNulls) {
97
+ throw new Error(
98
+ `Cannot make column "${col.name}" NOT NULL without DEFAULT in table "${table.name}" with existing data`,
99
+ );
100
+ }
101
+
102
+ if (!existing.notnull && col.notnull && col.defaultValue !== null) {
103
+ columnCopies.push({ name: col.name, expr: `COALESCE("${col.name}", ${col.defaultValue})` });
104
+ } else {
105
+ columnCopies.push({ name: col.name, expr: `"${col.name}"` });
106
+ }
107
+ }
108
+
109
+ this.ops.push({ type: "RebuildTable", table: table.name, createSql: table.sql, columnCopies });
110
+ this.rebuiltTables.add(table.name);
111
+ }
112
+
113
+ private buildAddColumns(table: DesiredTable, existingTable: IntrospectedTable) {
114
+ const newColumns = table.columns.filter((c) => !existingTable.columns.has(c.name));
115
+
116
+ if (newColumns.length === 0) {
117
+ return;
118
+ }
119
+
120
+ const nonAddable = newColumns.filter((c) => !c.addable);
121
+
122
+ if (nonAddable.length > 0) {
123
+ if (existingTable.hasData) {
124
+ throw new Error(
125
+ `Cannot add NOT NULL column "${nonAddable[0]!.name}" without DEFAULT to table "${table.name}" with existing data`,
126
+ );
127
+ }
128
+
129
+ this.ops.push({ type: "DropTable", table: table.name });
130
+ this.ops.push({ type: "CreateTable", table: table.name, sql: table.sql });
131
+ this.rebuiltTables.add(table.name);
132
+ return;
133
+ }
134
+
135
+ for (const col of newColumns) {
136
+ this.ops.push({ type: "AddColumn", table: table.name, columnDef: col.columnDef });
137
+ }
138
+ }
139
+
140
+ private columnChanged(desired: ColumnSchema, existing: ColumnSchema) {
141
+ return (
142
+ desired.type !== existing.type ||
143
+ desired.notnull !== existing.notnull ||
144
+ desired.defaultValue !== existing.defaultValue ||
145
+ desired.unique !== existing.unique ||
146
+ desired.references !== existing.references ||
147
+ desired.onDelete !== existing.onDelete
148
+ );
149
+ }
150
+
151
+ private dropOrphans() {
152
+ for (const [name] of this.existing) {
153
+ if (!this.desiredNames.has(name)) {
154
+ this.ops.push({ type: "DropTable", table: name });
155
+ }
156
+ }
157
+ }
158
+
159
+ private diffIndexes() {
160
+ for (const table of this.desired) {
161
+ const tableIndexes = table.indexes ?? [];
162
+
163
+ if (this.rebuiltTables.has(table.name)) {
164
+ for (const idx of tableIndexes) {
165
+ this.ops.push({ type: "CreateIndex", sql: idx.sql });
166
+ }
167
+
168
+ continue;
169
+ }
170
+
171
+ const existingTable = this.existing.get(table.name);
172
+
173
+ if (!existingTable) {
174
+ continue;
175
+ }
176
+
177
+ const existingNames = new Set(existingTable.indexes.map((i) => i.name));
178
+ const desiredNames = new Set(tableIndexes.map((i) => i.name));
179
+
180
+ for (const idx of tableIndexes) {
181
+ if (!existingNames.has(idx.name)) {
182
+ this.ops.push({ type: "CreateIndex", sql: idx.sql });
183
+ }
184
+ }
185
+
186
+ for (const idx of existingTable.indexes) {
187
+ if (!desiredNames.has(idx.name)) {
188
+ this.ops.push({ type: "DropIndex", index: idx.name });
189
+ }
190
+ }
191
+ }
192
+ }
193
+ }
@@ -0,0 +1,93 @@
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
+ this.executeOp(op);
34
+ }
35
+
36
+ if (restoreFk) {
37
+ const violations = this.db.prepare("PRAGMA foreign_key_check").all();
38
+
39
+ if (violations.length > 0) {
40
+ throw new Error("Foreign key check failed after rebuild");
41
+ }
42
+ }
43
+ })();
44
+ } finally {
45
+ if (restoreFk) {
46
+ this.db.run("PRAGMA foreign_keys = ON");
47
+ }
48
+ }
49
+ }
50
+
51
+ private executeOp(op: MigrationOp) {
52
+ switch (op.type) {
53
+ case "CreateTable": {
54
+ return this.db.run(op.sql);
55
+ }
56
+ case "DropTable": {
57
+ return this.db.run(`DROP TABLE "${op.table}"`);
58
+ }
59
+ case "AddColumn": {
60
+ return this.db.run(`ALTER TABLE "${op.table}" ADD COLUMN ${op.columnDef}`);
61
+ }
62
+ case "RebuildTable": {
63
+ return this.rebuildTable(op);
64
+ }
65
+ case "CreateIndex": {
66
+ return this.db.run(op.sql);
67
+ }
68
+ case "DropIndex": {
69
+ return this.db.run(`DROP INDEX "${op.index}"`);
70
+ }
71
+ }
72
+ }
73
+
74
+ private rebuildTable(op: RebuildTableOp) {
75
+ const tempName = `__new_${op.table}`;
76
+ const tempSql = op.createSql.replace(
77
+ `CREATE TABLE IF NOT EXISTS "${op.table}"`,
78
+ `CREATE TABLE "${tempName}"`,
79
+ );
80
+
81
+ this.db.run(tempSql);
82
+
83
+ if (op.columnCopies.length > 0) {
84
+ const destCols = op.columnCopies.map((c) => `"${c.name}"`).join(", ");
85
+ const srcExprs = op.columnCopies.map((c) => c.expr).join(", ");
86
+
87
+ this.db.run(`INSERT INTO "${tempName}" (${destCols}) SELECT ${srcExprs} FROM "${op.table}"`);
88
+ }
89
+
90
+ this.db.run(`DROP TABLE "${op.table}"`);
91
+ this.db.run(`ALTER TABLE "${tempName}" RENAME TO "${op.table}"`);
92
+ }
93
+ }
@@ -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,72 @@
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
+ sql: string;
33
+ };
34
+
35
+ export type DropIndexOp = {
36
+ type: "DropIndex";
37
+ index: string;
38
+ };
39
+
40
+ export type MigrationOp =
41
+ | CreateTableOp
42
+ | DropTableOp
43
+ | AddColumnOp
44
+ | RebuildTableOp
45
+ | CreateIndexOp
46
+ | DropIndexOp;
47
+
48
+ export type ColumnSchema = {
49
+ name: string;
50
+ type: string;
51
+ notnull: boolean;
52
+ defaultValue: string | null;
53
+ unique: boolean;
54
+ references: string | null;
55
+ onDelete: string | null;
56
+ };
57
+
58
+ export interface IntrospectedColumn extends ColumnSchema {
59
+ hasNulls: boolean;
60
+ }
61
+
62
+ export type IntrospectedIndex = {
63
+ name: string;
64
+ columns: string[];
65
+ unique: boolean;
66
+ };
67
+
68
+ export type IntrospectedTable = {
69
+ columns: Map<string, IntrospectedColumn>;
70
+ indexes: IntrospectedIndex[];
71
+ hasData: boolean;
72
+ };