@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 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 = new Database({
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
- new Database({
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. 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.
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
- - Column renames are treated as drop + add (data in the old column is not preserved)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobomfz/db",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Bun SQLite database with Arktype schemas and typed Kysely client",
5
5
  "keywords": [
6
6
  "arktype",
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 type { GeneratedPreset } from "./generated.js";
11
- import { Differ, type DesiredTable } from "./migration/diff.js";
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
- constructor(private options: DatabaseOptions<T, F, V>) {
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 normalizeProp(structureProp: StructureProp, parentSchema: Type) {
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 props = this.parseSchemaProps(schema);
423
-
424
- const columns = props.map((prop) => {
425
- const isNotNull = this.columnConstraint(prop) === "NOT NULL";
426
- const defaultClause = this.defaultClause(prop);
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
- const ops = new Differ(
488
- desiredTables,
489
- existing,
490
- desiredFts,
491
- existingFts,
492
- desiredVectorIndexes,
208
+ const existingState: ExistingSchema = {
209
+ existing: introspector.introspect(),
210
+ existingFts: introspector.introspectFts(),
493
211
  existingVectorIndexes,
494
- ).diff();
212
+ };
495
213
 
496
- new Executor(this.sqlite, ops).execute();
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
+ }