@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.
- package/README.md +55 -4
- package/package.json +1 -1
- package/src/database.ts +93 -385
- package/src/index.ts +3 -0
- package/src/migration/data-loss-error.ts +29 -0
- package/src/migration/diff.ts +79 -28
- package/src/migration/drift-error.ts +9 -0
- package/src/migration/execute.ts +36 -26
- package/src/migration/guided.ts +136 -0
- package/src/migration/introspect.ts +11 -6
- package/src/migration/registry.ts +43 -0
- package/src/migration/types.ts +42 -0
- package/src/schema-compiler.ts +362 -0
- package/src/types.ts +18 -2
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ bun add @lobomfz/db
|
|
|
13
13
|
```typescript
|
|
14
14
|
import { Database, generated, type } from "@lobomfz/db";
|
|
15
15
|
|
|
16
|
-
const db =
|
|
16
|
+
const db = await Database.open({
|
|
17
17
|
path: "data.db",
|
|
18
18
|
schema: {
|
|
19
19
|
tables: {
|
|
@@ -68,7 +68,7 @@ type("number.integer").configure({ references: "users.id", onDelete: "cascade" }
|
|
|
68
68
|
JSON columns are validated against the schema on write by default. To also validate on read:
|
|
69
69
|
|
|
70
70
|
```typescript
|
|
71
|
-
|
|
71
|
+
await Database.open({
|
|
72
72
|
// ...
|
|
73
73
|
validation: { onRead: true }, // default: { onRead: false }
|
|
74
74
|
});
|
|
@@ -76,7 +76,7 @@ new Database({
|
|
|
76
76
|
|
|
77
77
|
## Migrations
|
|
78
78
|
|
|
79
|
-
Schema changes are applied automatically on startup.
|
|
79
|
+
Schema changes are applied automatically on startup. `Database.open(...)` is async because migrations run before it resolves: 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
80
|
|
|
81
81
|
### What's supported
|
|
82
82
|
|
|
@@ -102,7 +102,58 @@ Table rebuilds follow SQLite's [recommended procedure](https://www.sqlite.org/la
|
|
|
102
102
|
- Adding a NOT NULL column without DEFAULT to a table **with data** throws an error
|
|
103
103
|
- Changing a nullable column to NOT NULL without DEFAULT throws if any row has NULL in that column
|
|
104
104
|
- Nullable-to-NOT-NULL with DEFAULT uses `COALESCE` to fill existing NULLs
|
|
105
|
-
-
|
|
105
|
+
- Without a guided migration, column renames are treated as drop + add (data in the old column is not preserved)
|
|
106
|
+
|
|
107
|
+
### Refusing destructive changes
|
|
108
|
+
|
|
109
|
+
By default the diff applies whatever brings the database in sync, including drops. Set `failOnDataLoss` to turn dropping a table, dropping a column, or a data-discarding type change into a thrown `DataLossError` instead — the database is left untouched.
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
await Database.open({
|
|
113
|
+
// ...
|
|
114
|
+
failOnDataLoss: true,
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Configuring a `migration` (see below) routes through the guided path instead, where the drops are intrinsic to the restructure. The gate does not apply there; the drops are logged via `console.warn` so they are still visible.
|
|
119
|
+
|
|
120
|
+
### Guided data migrations
|
|
121
|
+
|
|
122
|
+
The automatic diff can rename or restructure a table only by dropping the old shape. When you need to preserve and transform the data — rename a column, split one column into two, convert a type — pass a `migration` with a stable `id` and a `run` function. It receives the same typed Kysely client and runs inside the migration transaction.
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
await Database.open({
|
|
126
|
+
path: "data.db",
|
|
127
|
+
schema: {
|
|
128
|
+
tables: {
|
|
129
|
+
users: type({ id: generated("autoincrement"), name: "string", email: "string" }),
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
migration: {
|
|
133
|
+
id: "rename-user-fields",
|
|
134
|
+
run: async ({ db }) => {
|
|
135
|
+
const rows = await db
|
|
136
|
+
.selectFrom("users")
|
|
137
|
+
.select(["id", sql<string>`full_name`.as("full_name")])
|
|
138
|
+
.execute();
|
|
139
|
+
|
|
140
|
+
for (const row of rows) {
|
|
141
|
+
await db.updateTable("users").set({ name: row.full_name }).where("id", "=", row.id).execute();
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The migration runs as expand then contract, all in one transaction:
|
|
149
|
+
|
|
150
|
+
1. New tables and new columns are added (new columns nullable, so the backfill can write them).
|
|
151
|
+
2. Your `run` backfills and transforms the data, reading old columns with raw `sql` and writing the new ones through the typed client.
|
|
152
|
+
3. The schema is reconciled to the target: leftover columns and tables are dropped (logged via `console.warn`) and `NOT NULL` is tightened.
|
|
153
|
+
|
|
154
|
+
`run` already executes inside the migration transaction, so do not open your own with `db.transaction(...)`. Your reads and writes are already atomic with the rest of the migration; if `run` throws, the whole transaction rolls back and the database is left exactly as it was. Referential integrity is checked with `PRAGMA foreign_key_check` before the commit, even when `foreign_keys` is disabled.
|
|
155
|
+
|
|
156
|
+
Each `id` runs at most once. The applied set lives in a `__migrations` table; a fresh database records the `id` as a baseline without running it (the schema is created directly). Editing the body of an already-applied migration throws `MigrationDriftError` — migrations are immutable once applied, so give new logic a fresh `id`.
|
|
106
157
|
|
|
107
158
|
## License
|
|
108
159
|
|
package/package.json
CHANGED
package/src/database.ts
CHANGED
|
@@ -4,20 +4,23 @@ import type { Type } from "arktype";
|
|
|
4
4
|
import { Kysely } from "kysely";
|
|
5
5
|
|
|
6
6
|
import { BunSqliteDialect } from "./dialect/dialect.js";
|
|
7
|
-
import type { DbFieldMeta } from "./env.js";
|
|
8
7
|
import { resolveFtsPlan, type FtsTableInfo, type ResolvedFtsPlan } from "./fts/resolve.js";
|
|
9
8
|
import { generateRebuildSelect } from "./fts/sql.js";
|
|
10
|
-
import
|
|
11
|
-
import { Differ
|
|
9
|
+
import { DataLossError } from "./migration/data-loss-error.js";
|
|
10
|
+
import { Differ } from "./migration/diff.js";
|
|
12
11
|
import { Executor } from "./migration/execute.js";
|
|
12
|
+
import { GuidedMigrationRunner } from "./migration/guided.js";
|
|
13
13
|
import { Introspector } from "./migration/introspect.js";
|
|
14
|
+
import { MigrationRegistry } from "./migration/registry.js";
|
|
15
|
+
import type { DesiredSchema, DesiredTable, ExistingSchema } from "./migration/types.js";
|
|
14
16
|
import { ResultHydrationPlugin } from "./plugin.js";
|
|
17
|
+
import { SchemaCompiler } from "./schema-compiler.js";
|
|
15
18
|
import type {
|
|
19
|
+
DataMigration,
|
|
16
20
|
DatabaseOptions,
|
|
17
21
|
DatabasePragmas,
|
|
18
22
|
FtsApi,
|
|
19
23
|
FtsConfig,
|
|
20
|
-
IndexDefinition,
|
|
21
24
|
SchemaRecord,
|
|
22
25
|
TablesFromSchemas,
|
|
23
26
|
VectorIndexApi,
|
|
@@ -33,49 +36,6 @@ import {
|
|
|
33
36
|
import { generateRebuildVectorSql } from "./vector-index/sql.js";
|
|
34
37
|
import { WriteValidationPlugin } from "./write-validation-plugin.js";
|
|
35
38
|
|
|
36
|
-
type ArkBranch = {
|
|
37
|
-
domain?: string;
|
|
38
|
-
proto?: unknown;
|
|
39
|
-
unit?: unknown;
|
|
40
|
-
structure?: unknown;
|
|
41
|
-
inner?: { divisor?: unknown };
|
|
42
|
-
meta?: DbFieldMeta;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
type StructureProp = {
|
|
46
|
-
key: string;
|
|
47
|
-
required: boolean;
|
|
48
|
-
value: Type & {
|
|
49
|
-
branches: ArkBranch[];
|
|
50
|
-
proto?: unknown;
|
|
51
|
-
meta: DbFieldMeta;
|
|
52
|
-
};
|
|
53
|
-
inner: { default?: unknown };
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
type Prop = {
|
|
57
|
-
key: string;
|
|
58
|
-
kind: "required" | "optional";
|
|
59
|
-
domain?: string;
|
|
60
|
-
nullable?: boolean;
|
|
61
|
-
isBoolean?: boolean;
|
|
62
|
-
isInteger?: boolean;
|
|
63
|
-
isDate?: boolean;
|
|
64
|
-
isBlob?: boolean;
|
|
65
|
-
isJson?: boolean;
|
|
66
|
-
isVector?: boolean;
|
|
67
|
-
vectorDim?: number;
|
|
68
|
-
jsonSchema?: Type;
|
|
69
|
-
meta?: DbFieldMeta;
|
|
70
|
-
generated?: GeneratedPreset;
|
|
71
|
-
defaultValue?: unknown;
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
const typeMap: Record<string, string> = {
|
|
75
|
-
string: "TEXT",
|
|
76
|
-
number: "REAL",
|
|
77
|
-
};
|
|
78
|
-
|
|
79
39
|
const defaultPragmas: DatabasePragmas = {
|
|
80
40
|
foreign_keys: true,
|
|
81
41
|
};
|
|
@@ -86,6 +46,7 @@ export class Database<
|
|
|
86
46
|
V extends VectorIndexConfig<T> = VectorIndexConfig<T>,
|
|
87
47
|
> {
|
|
88
48
|
private sqlite: BunDatabase;
|
|
49
|
+
private compiler = new SchemaCompiler();
|
|
89
50
|
private ftsPlans = new Map<string, ResolvedFtsPlan>();
|
|
90
51
|
private vectorIndexPlans = new Map<string, ResolvedVectorIndex>();
|
|
91
52
|
|
|
@@ -97,12 +58,24 @@ export class Database<
|
|
|
97
58
|
|
|
98
59
|
readonly vectorIndex: VectorIndexApi<V>;
|
|
99
60
|
|
|
100
|
-
|
|
61
|
+
static async open<
|
|
62
|
+
T extends SchemaRecord,
|
|
63
|
+
F extends FtsConfig<T> = FtsConfig<T>,
|
|
64
|
+
V extends VectorIndexConfig<T> = VectorIndexConfig<T>,
|
|
65
|
+
>(options: DatabaseOptions<T, F, V>) {
|
|
66
|
+
const db = new Database(options);
|
|
67
|
+
|
|
68
|
+
await db.runMigrations();
|
|
69
|
+
|
|
70
|
+
return db;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private constructor(private options: DatabaseOptions<T, F, V>) {
|
|
101
74
|
const tableSchemas = new Map<string, Type>(Object.entries(options.schema.tables));
|
|
102
75
|
const writeSchemas = new Map<string, Type>(
|
|
103
76
|
Object.entries(options.schema.tables).map(([table, schema]) => [
|
|
104
77
|
table,
|
|
105
|
-
this.createWriteSchema(schema),
|
|
78
|
+
this.compiler.createWriteSchema(schema),
|
|
106
79
|
]),
|
|
107
80
|
);
|
|
108
81
|
|
|
@@ -112,8 +85,6 @@ export class Database<
|
|
|
112
85
|
this.assertVectorIndexesHaveExtension();
|
|
113
86
|
this.loadConfiguredExtensions();
|
|
114
87
|
|
|
115
|
-
this.migrate();
|
|
116
|
-
|
|
117
88
|
const validation = {
|
|
118
89
|
onRead: options.validation?.onRead ?? false,
|
|
119
90
|
};
|
|
@@ -191,271 +162,18 @@ export class Database<
|
|
|
191
162
|
}
|
|
192
163
|
}
|
|
193
164
|
|
|
194
|
-
private
|
|
195
|
-
const { key, value: v, inner } = structureProp;
|
|
196
|
-
const kind: Prop["kind"] = structureProp.required ? "required" : "optional";
|
|
197
|
-
const defaultValue = inner.default;
|
|
198
|
-
|
|
199
|
-
const concrete = v.branches.filter((b) => b.unit !== null && b.domain !== "undefined");
|
|
200
|
-
const nullable = concrete.length < v.branches.length;
|
|
201
|
-
|
|
202
|
-
const branchMeta = v.branches.find((b) => b.meta && Object.keys(b.meta).length > 0)?.meta;
|
|
203
|
-
const meta = { ...branchMeta, ...v.meta };
|
|
204
|
-
const generated = meta._generated === "vector" ? undefined : meta._generated;
|
|
205
|
-
|
|
206
|
-
if (meta._generated === "vector") {
|
|
207
|
-
return {
|
|
208
|
-
key,
|
|
209
|
-
kind,
|
|
210
|
-
nullable,
|
|
211
|
-
isVector: true,
|
|
212
|
-
vectorDim: meta._vectorDim,
|
|
213
|
-
meta,
|
|
214
|
-
defaultValue,
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (v.proto === Date || concrete.some((b) => b.proto === Date)) {
|
|
219
|
-
return { key, kind, nullable, isDate: true, generated, defaultValue };
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
if (v.proto === Uint8Array || concrete.some((b) => b.proto === Uint8Array)) {
|
|
223
|
-
return {
|
|
224
|
-
key,
|
|
225
|
-
kind,
|
|
226
|
-
nullable,
|
|
227
|
-
isBlob: true,
|
|
228
|
-
meta,
|
|
229
|
-
generated,
|
|
230
|
-
defaultValue,
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (concrete.length > 0 && concrete.every((b) => b.domain === "boolean")) {
|
|
235
|
-
return { key, kind, nullable, isBoolean: true, generated, defaultValue };
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (concrete.some((b) => !!b.structure)) {
|
|
239
|
-
return {
|
|
240
|
-
key,
|
|
241
|
-
kind,
|
|
242
|
-
nullable,
|
|
243
|
-
isJson: true,
|
|
244
|
-
jsonSchema: (parentSchema as any).get(key) as Type,
|
|
245
|
-
meta,
|
|
246
|
-
generated,
|
|
247
|
-
defaultValue,
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const branch = concrete[0];
|
|
252
|
-
|
|
253
|
-
return {
|
|
254
|
-
key,
|
|
255
|
-
kind,
|
|
256
|
-
nullable,
|
|
257
|
-
domain: branch?.domain,
|
|
258
|
-
isInteger: !!branch?.inner?.divisor,
|
|
259
|
-
meta,
|
|
260
|
-
generated,
|
|
261
|
-
defaultValue,
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
private sqlType(prop: Prop) {
|
|
266
|
-
if (prop.isJson) {
|
|
267
|
-
return "TEXT";
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
if (prop.isBlob || prop.isVector) {
|
|
271
|
-
return "BLOB";
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
if (prop.isDate || prop.isBoolean || prop.isInteger) {
|
|
275
|
-
return "INTEGER";
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
if (prop.domain) {
|
|
279
|
-
return typeMap[prop.domain] ?? "TEXT";
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
return "TEXT";
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
private columnConstraint(prop: Prop) {
|
|
286
|
-
if (prop.generated === "autoincrement") {
|
|
287
|
-
return "PRIMARY KEY AUTOINCREMENT";
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
if (prop.meta?.primaryKey) {
|
|
291
|
-
return "PRIMARY KEY";
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
if (prop.kind === "required" && !prop.nullable) {
|
|
295
|
-
return "NOT NULL";
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
return null;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
private defaultClause(prop: Prop) {
|
|
302
|
-
if (prop.generated === "now") {
|
|
303
|
-
return "DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER))";
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
if (prop.defaultValue === undefined || prop.generated === "autoincrement") {
|
|
307
|
-
return null;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
if (prop.defaultValue === null) {
|
|
311
|
-
return "DEFAULT NULL";
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
if (typeof prop.defaultValue === "string") {
|
|
315
|
-
return `DEFAULT '${prop.defaultValue}'`;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
if (typeof prop.defaultValue === "number" || typeof prop.defaultValue === "boolean") {
|
|
319
|
-
return `DEFAULT ${prop.defaultValue}`;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
throw new Error(
|
|
323
|
-
`Unsupported default value type: ${typeof prop.defaultValue} ${JSON.stringify(prop)}`,
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
private columnDef(prop: Prop) {
|
|
328
|
-
return [
|
|
329
|
-
`"${prop.key}"`,
|
|
330
|
-
this.sqlType(prop),
|
|
331
|
-
this.columnConstraint(prop),
|
|
332
|
-
prop.meta?.unique ? "UNIQUE" : null,
|
|
333
|
-
this.defaultClause(prop),
|
|
334
|
-
]
|
|
335
|
-
.filter(Boolean)
|
|
336
|
-
.join(" ");
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
private foreignKey(prop: Prop) {
|
|
340
|
-
const ref = prop.meta?.references;
|
|
341
|
-
|
|
342
|
-
if (!ref) {
|
|
343
|
-
return null;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
const [table, column] = ref.split(".");
|
|
347
|
-
|
|
348
|
-
let fk = `FOREIGN KEY ("${prop.key}") REFERENCES "${table}"("${column}")`;
|
|
349
|
-
|
|
350
|
-
const onDelete = prop.meta?.onDelete;
|
|
351
|
-
|
|
352
|
-
if (onDelete) {
|
|
353
|
-
fk += ` ON DELETE ${onDelete.toUpperCase()}`;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
return fk;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
private addColumnDef(prop: Prop) {
|
|
360
|
-
let def = this.columnDef(prop);
|
|
361
|
-
|
|
362
|
-
const ref = prop.meta?.references;
|
|
363
|
-
|
|
364
|
-
if (ref) {
|
|
365
|
-
const [table, column] = ref.split(".");
|
|
366
|
-
def += ` REFERENCES "${table}"("${column}")`;
|
|
367
|
-
|
|
368
|
-
if (prop.meta?.onDelete) {
|
|
369
|
-
def += ` ON DELETE ${prop.meta.onDelete.toUpperCase()}`;
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
return def;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
private parseSchemaProps(schema: Type) {
|
|
377
|
-
const structureProps = (schema as any).structure?.props as StructureProp[] | undefined;
|
|
378
|
-
|
|
379
|
-
if (!structureProps) {
|
|
380
|
-
return [];
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
return structureProps.map((p) => this.normalizeProp(p, schema));
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
private createWriteSchema(schema: Type) {
|
|
387
|
-
const autoIncrementColumns = this.parseSchemaProps(schema)
|
|
388
|
-
.filter((prop) => prop.generated === "autoincrement")
|
|
389
|
-
.map((prop) => prop.key);
|
|
390
|
-
|
|
391
|
-
if (autoIncrementColumns.length === 0) {
|
|
392
|
-
return schema;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
return (schema as any).omit(...autoIncrementColumns) as Type;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
private generateCreateTableSQL(tableName: string, props: Prop[]) {
|
|
399
|
-
const columns: string[] = [];
|
|
400
|
-
const fks: string[] = [];
|
|
401
|
-
|
|
402
|
-
for (const prop of props) {
|
|
403
|
-
columns.push(this.columnDef(prop));
|
|
404
|
-
|
|
405
|
-
const fk = this.foreignKey(prop);
|
|
406
|
-
|
|
407
|
-
if (fk) {
|
|
408
|
-
fks.push(fk);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
return `CREATE TABLE IF NOT EXISTS "${tableName}" (${columns.concat(fks).join(", ")})`;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
private migrate() {
|
|
165
|
+
private buildDesiredSchema(): DesiredSchema {
|
|
416
166
|
const desiredTables: DesiredTable[] = [];
|
|
417
167
|
const tableInfos = new Map<string, FtsTableInfo>();
|
|
418
168
|
const vectorAnchors = new Map<string, VectorAnchorInfo>();
|
|
419
169
|
const schemaIndexes = this.options.schema.indexes;
|
|
420
170
|
|
|
421
171
|
for (const [name, schema] of Object.entries(this.options.schema.tables)) {
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
const hasLiteralDefault = prop.generated !== "now" && defaultClause !== null;
|
|
428
|
-
|
|
429
|
-
return {
|
|
430
|
-
name: prop.key,
|
|
431
|
-
addable: !isNotNull || hasLiteralDefault,
|
|
432
|
-
columnDef: this.addColumnDef(prop),
|
|
433
|
-
type: this.sqlType(prop),
|
|
434
|
-
notnull: isNotNull,
|
|
435
|
-
defaultValue: defaultClause
|
|
436
|
-
? defaultClause.replace("DEFAULT ", "").replace(/^\((.+)\)$/, "$1")
|
|
437
|
-
: null,
|
|
438
|
-
unique: !!prop.meta?.unique,
|
|
439
|
-
references: prop.meta?.references ?? null,
|
|
440
|
-
onDelete: prop.meta?.onDelete?.toUpperCase() ?? null,
|
|
441
|
-
};
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
const indexes = (schemaIndexes?.[name] ?? []).map((indexDef) => ({
|
|
445
|
-
name: this.generateIndexName(name, indexDef.columns, indexDef.unique ?? false),
|
|
446
|
-
columns: indexDef.columns,
|
|
447
|
-
sql: this.generateCreateIndexSQL(name, indexDef),
|
|
448
|
-
}));
|
|
449
|
-
|
|
450
|
-
desiredTables.push({
|
|
451
|
-
name,
|
|
452
|
-
sql: this.generateCreateTableSQL(name, props),
|
|
453
|
-
columns,
|
|
454
|
-
indexes,
|
|
455
|
-
});
|
|
456
|
-
|
|
457
|
-
tableInfos.set(name, this.buildTableInfo(props));
|
|
458
|
-
vectorAnchors.set(name, this.buildVectorAnchorInfo(props));
|
|
172
|
+
const compiled = this.compiler.compileTable(name, schema, schemaIndexes?.[name] ?? []);
|
|
173
|
+
|
|
174
|
+
desiredTables.push(compiled.table);
|
|
175
|
+
tableInfos.set(name, compiled.tableInfo);
|
|
176
|
+
vectorAnchors.set(name, compiled.vectorAnchor);
|
|
459
177
|
}
|
|
460
178
|
|
|
461
179
|
const desiredFts: ResolvedFtsPlan[] = [];
|
|
@@ -479,21 +197,27 @@ export class Database<
|
|
|
479
197
|
}
|
|
480
198
|
}
|
|
481
199
|
|
|
200
|
+
return { desiredTables, desiredFts, desiredVectorIndexes };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async runMigrations() {
|
|
204
|
+
const desired = this.buildDesiredSchema();
|
|
205
|
+
|
|
482
206
|
const introspector = new Introspector(this.sqlite);
|
|
483
|
-
const existing = introspector.introspect();
|
|
484
|
-
const existingFts = introspector.introspectFts();
|
|
485
207
|
const existingVectorIndexes = introspector.introspectVectorIndexes();
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
existing,
|
|
490
|
-
desiredFts,
|
|
491
|
-
existingFts,
|
|
492
|
-
desiredVectorIndexes,
|
|
208
|
+
const existingState: ExistingSchema = {
|
|
209
|
+
existing: introspector.introspect(),
|
|
210
|
+
existingFts: introspector.introspectFts(),
|
|
493
211
|
existingVectorIndexes,
|
|
494
|
-
|
|
212
|
+
};
|
|
495
213
|
|
|
496
|
-
|
|
214
|
+
const migration = this.options.migration;
|
|
215
|
+
|
|
216
|
+
if (migration) {
|
|
217
|
+
await this.applyWithMigration(desired, existingState, migration);
|
|
218
|
+
} else {
|
|
219
|
+
this.applyDiff(desired, existingState);
|
|
220
|
+
}
|
|
497
221
|
|
|
498
222
|
for (const name of existingVectorIndexes.keys()) {
|
|
499
223
|
if (!this.vectorIndexPlans.has(name)) {
|
|
@@ -502,6 +226,54 @@ export class Database<
|
|
|
502
226
|
}
|
|
503
227
|
}
|
|
504
228
|
|
|
229
|
+
private applyDiff(desired: DesiredSchema, existingState: ExistingSchema) {
|
|
230
|
+
const differ = new Differ(desired, existingState);
|
|
231
|
+
|
|
232
|
+
const ops = differ.diff();
|
|
233
|
+
|
|
234
|
+
if (this.options.failOnDataLoss && differ.destructive.length > 0) {
|
|
235
|
+
throw new DataLossError(differ.destructive);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
new Executor(this.sqlite, ops).execute();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private async applyWithMigration(
|
|
242
|
+
desired: DesiredSchema,
|
|
243
|
+
existingState: ExistingSchema,
|
|
244
|
+
migration: DataMigration<T, F, V>,
|
|
245
|
+
) {
|
|
246
|
+
const registry = new MigrationRegistry(this.sqlite);
|
|
247
|
+
registry.ensureTable();
|
|
248
|
+
|
|
249
|
+
const checksum = MigrationRegistry.checksum(migration.run);
|
|
250
|
+
|
|
251
|
+
registry.assertNoDrift(migration.id, checksum);
|
|
252
|
+
|
|
253
|
+
if (registry.appliedChecksum(migration.id) !== null) {
|
|
254
|
+
this.applyDiff(desired, existingState);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const fresh = !desired.desiredTables.some((table) => existingState.existing.has(table.name));
|
|
259
|
+
|
|
260
|
+
if (fresh) {
|
|
261
|
+
this.applyDiff(desired, existingState);
|
|
262
|
+
registry.record(migration.id, checksum);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
await new GuidedMigrationRunner({
|
|
267
|
+
sqlite: this.sqlite,
|
|
268
|
+
kysely: this.kysely,
|
|
269
|
+
desired,
|
|
270
|
+
existing: existingState.existing,
|
|
271
|
+
migration,
|
|
272
|
+
checksum,
|
|
273
|
+
registry,
|
|
274
|
+
}).run();
|
|
275
|
+
}
|
|
276
|
+
|
|
505
277
|
private rebuildFts(name: string) {
|
|
506
278
|
const plan = this.ftsPlans.get(name);
|
|
507
279
|
|
|
@@ -528,70 +300,6 @@ export class Database<
|
|
|
528
300
|
})();
|
|
529
301
|
}
|
|
530
302
|
|
|
531
|
-
private buildTableInfo(props: Prop[]): FtsTableInfo {
|
|
532
|
-
const pkProp = props.find(
|
|
533
|
-
(p) => p.generated === "autoincrement" || p.meta?.primaryKey === true,
|
|
534
|
-
);
|
|
535
|
-
|
|
536
|
-
return {
|
|
537
|
-
columns: new Set(props.map((p) => p.key)),
|
|
538
|
-
pkColumn: pkProp?.key ?? null,
|
|
539
|
-
pkIsInteger: pkProp ? this.sqlType(pkProp) === "INTEGER" : false,
|
|
540
|
-
foreignKeys: props.flatMap((p) => {
|
|
541
|
-
const ref = p.meta?.references;
|
|
542
|
-
|
|
543
|
-
if (!ref) {
|
|
544
|
-
return [];
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
const [referencedTable, referencedColumn] = ref.split(".");
|
|
548
|
-
|
|
549
|
-
if (!referencedTable || !referencedColumn) {
|
|
550
|
-
throw new Error(
|
|
551
|
-
`Invalid references format "${ref}" for column "${p.key}": expected "table.column"`,
|
|
552
|
-
);
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
return [{ column: p.key, referencedTable, referencedColumn }];
|
|
556
|
-
}),
|
|
557
|
-
};
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
private buildVectorAnchorInfo(props: Prop[]): VectorAnchorInfo {
|
|
561
|
-
const pkProp = props.find(
|
|
562
|
-
(p) => p.generated === "autoincrement" || p.meta?.primaryKey === true,
|
|
563
|
-
);
|
|
564
|
-
|
|
565
|
-
const vectorColumns = new Map<string, number>();
|
|
566
|
-
|
|
567
|
-
for (const p of props) {
|
|
568
|
-
if (p.isVector && typeof p.vectorDim === "number") {
|
|
569
|
-
vectorColumns.set(p.key, p.vectorDim);
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
return {
|
|
574
|
-
columns: new Set(props.map((p) => p.key)),
|
|
575
|
-
pkColumn: pkProp?.key ?? null,
|
|
576
|
-
pkIsInteger: pkProp ? this.sqlType(pkProp) === "INTEGER" : false,
|
|
577
|
-
vectorColumns,
|
|
578
|
-
};
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
private generateIndexName(tableName: string, columns: string[], unique: boolean) {
|
|
582
|
-
const prefix = unique ? "ux" : "ix";
|
|
583
|
-
|
|
584
|
-
return `${prefix}_${tableName}_${columns.join("_")}`;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
private generateCreateIndexSQL(tableName: string, indexDef: IndexDefinition) {
|
|
588
|
-
const indexName = this.generateIndexName(tableName, indexDef.columns, indexDef.unique ?? false);
|
|
589
|
-
const unique = indexDef.unique ? "UNIQUE " : "";
|
|
590
|
-
const columns = indexDef.columns.map((c) => `"${c}"`).join(", ");
|
|
591
|
-
|
|
592
|
-
return `CREATE ${unique}INDEX "${indexName}" ON "${tableName}" (${columns})`;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
303
|
reset(table?: keyof T & string): void {
|
|
596
304
|
const tables = table ? [table] : Object.keys(this.options.schema.tables);
|
|
597
305
|
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,8 @@ export { vector } from "./vector.js";
|
|
|
4
4
|
export { nearest, type NearestOptions } from "./vector-index/nearest.js";
|
|
5
5
|
export { ExtensionLoadError } from "./vector-index/load.js";
|
|
6
6
|
export { JsonParseError } from "./errors.js";
|
|
7
|
+
export { DataLossError } from "./migration/data-loss-error.js";
|
|
8
|
+
export { MigrationDriftError } from "./migration/drift-error.js";
|
|
7
9
|
export { jsonArrayFrom, jsonObjectFrom } from "./json.js";
|
|
8
10
|
export {
|
|
9
11
|
sql,
|
|
@@ -18,6 +20,7 @@ export { configure } from "arktype/config";
|
|
|
18
20
|
export { ValidationError } from "./validation-error.js";
|
|
19
21
|
export type { DbFieldMeta } from "./env.js";
|
|
20
22
|
export type {
|
|
23
|
+
DataMigration,
|
|
21
24
|
DatabaseOptions,
|
|
22
25
|
DatabaseExtension,
|
|
23
26
|
SchemaRecord,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { DestructiveChange } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export class DataLossError extends Error {
|
|
4
|
+
constructor(readonly changes: DestructiveChange[]) {
|
|
5
|
+
super(
|
|
6
|
+
`Refusing to apply a migration that loses data (failOnDataLoss is on):\n${changes
|
|
7
|
+
.map(formatChange)
|
|
8
|
+
.map((line) => ` - ${line}`)
|
|
9
|
+
.join("\n")}`,
|
|
10
|
+
);
|
|
11
|
+
this.name = "DataLossError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function formatChange(change: DestructiveChange) {
|
|
16
|
+
const columns = (change.columns ?? []).map((c) => `"${c}"`).join(", ");
|
|
17
|
+
|
|
18
|
+
switch (change.kind) {
|
|
19
|
+
case "DropTable": {
|
|
20
|
+
return `drop table "${change.table}"`;
|
|
21
|
+
}
|
|
22
|
+
case "DropColumn": {
|
|
23
|
+
return `drop column ${columns} from "${change.table}"`;
|
|
24
|
+
}
|
|
25
|
+
case "TypeChange": {
|
|
26
|
+
return `change type of column ${columns} in "${change.table}" (discards data)`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|