@lobomfz/db 0.4.0 → 0.5.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/src/database.ts CHANGED
@@ -4,387 +4,300 @@ 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
- import type { GeneratedPreset } from "./generated.js";
9
- import { Differ, type DesiredTable } from "./migration/diff.js";
7
+ import { resolveFtsPlan, type FtsTableInfo, type ResolvedFtsPlan } from "./fts/resolve.js";
8
+ import { generateRebuildSelect } from "./fts/sql.js";
9
+ import { DataLossError } from "./migration/data-loss-error.js";
10
+ import { Differ } from "./migration/diff.js";
10
11
  import { Executor } from "./migration/execute.js";
12
+ import { GuidedMigrationRunner } from "./migration/guided.js";
11
13
  import { Introspector } from "./migration/introspect.js";
14
+ import { MigrationRegistry } from "./migration/registry.js";
15
+ import type { DesiredSchema, DesiredTable, ExistingSchema } from "./migration/types.js";
12
16
  import { ResultHydrationPlugin } from "./plugin.js";
17
+ import { SchemaCompiler } from "./schema-compiler.js";
13
18
  import type {
19
+ DataMigration,
14
20
  DatabaseOptions,
15
- IndexDefinition,
21
+ DatabasePragmas,
22
+ FtsApi,
23
+ FtsConfig,
16
24
  SchemaRecord,
17
25
  TablesFromSchemas,
18
- DatabasePragmas,
26
+ VectorIndexApi,
27
+ VectorIndexConfig,
19
28
  } from "./types.js";
29
+ import { loadExtensions } from "./vector-index/load.js";
30
+ import { registerVectorIndex, unregisterVectorIndex } from "./vector-index/nearest.js";
31
+ import {
32
+ resolveVectorIndex,
33
+ type ResolvedVectorIndex,
34
+ type VectorAnchorInfo,
35
+ } from "./vector-index/resolve.js";
36
+ import { generateRebuildVectorSql } from "./vector-index/sql.js";
20
37
  import { WriteValidationPlugin } from "./write-validation-plugin.js";
21
38
 
22
- type ArkBranch = {
23
- domain?: string;
24
- proto?: unknown;
25
- unit?: unknown;
26
- structure?: unknown;
27
- inner?: { divisor?: unknown };
28
- meta?: DbFieldMeta & { _generated?: GeneratedPreset };
39
+ const defaultPragmas: DatabasePragmas = {
40
+ foreign_keys: true,
29
41
  };
30
42
 
31
- type StructureProp = {
32
- key: string;
33
- required: boolean;
34
- value: Type & {
35
- branches: ArkBranch[];
36
- proto?: unknown;
37
- meta: DbFieldMeta & { _generated?: GeneratedPreset };
38
- };
39
- inner: { default?: unknown };
40
- };
43
+ export class Database<
44
+ T extends SchemaRecord,
45
+ F extends FtsConfig<T> = FtsConfig<T>,
46
+ V extends VectorIndexConfig<T> = VectorIndexConfig<T>,
47
+ > {
48
+ private sqlite: BunDatabase;
49
+ private compiler = new SchemaCompiler();
50
+ private ftsPlans = new Map<string, ResolvedFtsPlan>();
51
+ private vectorIndexPlans = new Map<string, ResolvedVectorIndex>();
41
52
 
42
- type Prop = {
43
- key: string;
44
- kind: "required" | "optional";
45
- domain?: string;
46
- nullable?: boolean;
47
- isBoolean?: boolean;
48
- isInteger?: boolean;
49
- isDate?: boolean;
50
- isBlob?: boolean;
51
- isJson?: boolean;
52
- jsonSchema?: Type;
53
- meta?: DbFieldMeta;
54
- generated?: GeneratedPreset;
55
- defaultValue?: unknown;
56
- };
53
+ readonly infer: TablesFromSchemas<T, F, V> = undefined as any;
57
54
 
58
- const typeMap: Record<string, string> = {
59
- string: "TEXT",
60
- number: "REAL",
61
- };
55
+ readonly kysely: Kysely<TablesFromSchemas<T, F, V>>;
62
56
 
63
- const defaultPragmas: DatabasePragmas = {
64
- foreign_keys: true,
65
- };
57
+ readonly fts: FtsApi<F>;
66
58
 
67
- export class Database<T extends SchemaRecord> {
68
- private sqlite: BunDatabase;
59
+ readonly vectorIndex: VectorIndexApi<V>;
60
+
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);
69
67
 
70
- readonly infer: TablesFromSchemas<T> = undefined as any;
68
+ await db.runMigrations();
71
69
 
72
- readonly kysely: Kysely<TablesFromSchemas<T>>;
70
+ return db;
71
+ }
73
72
 
74
- constructor(private options: DatabaseOptions<T>) {
73
+ private constructor(private options: DatabaseOptions<T, F, V>) {
75
74
  const tableSchemas = new Map<string, Type>(Object.entries(options.schema.tables));
76
75
  const writeSchemas = new Map<string, Type>(
77
76
  Object.entries(options.schema.tables).map(([table, schema]) => [
78
77
  table,
79
- this.createWriteSchema(schema),
78
+ this.compiler.createWriteSchema(schema),
80
79
  ]),
81
80
  );
82
81
 
83
82
  this.sqlite = new BunDatabase(options.path);
84
83
 
85
84
  this.applyPragmas();
86
-
87
- this.migrate();
85
+ this.assertVectorIndexesHaveExtension();
86
+ this.loadConfiguredExtensions();
88
87
 
89
88
  const validation = {
90
89
  onRead: options.validation?.onRead ?? false,
91
90
  };
92
91
 
93
- this.kysely = new Kysely<TablesFromSchemas<T>>({
92
+ this.kysely = new Kysely<TablesFromSchemas<T, F, V>>({
94
93
  dialect: new BunSqliteDialect({ database: this.sqlite }),
95
94
  plugins: [
96
95
  new WriteValidationPlugin(writeSchemas),
97
96
  new ResultHydrationPlugin(tableSchemas, validation),
98
97
  ],
99
98
  });
100
- }
101
-
102
- private applyPragmas() {
103
- const pragmas = { ...defaultPragmas, ...this.options.pragmas };
104
-
105
- if (pragmas.journal_mode) {
106
- this.sqlite.run(`PRAGMA journal_mode = ${pragmas.journal_mode.toUpperCase()}`);
107
- }
108
-
109
- if (pragmas.synchronous) {
110
- this.sqlite.run(`PRAGMA synchronous = ${pragmas.synchronous.toUpperCase()}`);
111
- }
112
99
 
113
- this.sqlite.run(`PRAGMA foreign_keys = ${pragmas.foreign_keys ? "ON" : "OFF"}`);
100
+ this.fts = {
101
+ rebuild: (name: string) => this.rebuildFts(name),
102
+ } as FtsApi<F>;
114
103
 
115
- if (pragmas.busy_timeout_ms !== undefined) {
116
- this.sqlite.run(`PRAGMA busy_timeout = ${pragmas.busy_timeout_ms}`);
117
- }
104
+ this.vectorIndex = {
105
+ rebuild: (name: string) => this.rebuildVectorIndex(name),
106
+ } as VectorIndexApi<V>;
118
107
  }
119
108
 
120
- private normalizeProp(structureProp: StructureProp, parentSchema: Type) {
121
- const { key, value: v, inner } = structureProp;
122
- const kind: Prop["kind"] = structureProp.required ? "required" : "optional";
123
- const defaultValue = inner.default;
109
+ private loadConfiguredExtensions() {
110
+ const extensions = this.options.extensions ?? [];
124
111
 
125
- const concrete = v.branches.filter((b) => b.unit !== null && b.domain !== "undefined");
126
- const nullable = concrete.length < v.branches.length;
112
+ loadExtensions(this.sqlite, extensions);
127
113
 
128
- const branchMeta = v.branches.find((b) => b.meta && Object.keys(b.meta).length > 0)?.meta;
129
- const meta = { ...branchMeta, ...v.meta };
130
- const generated = meta._generated;
131
-
132
- if (v.proto === Date || concrete.some((b) => b.proto === Date)) {
133
- return { key, kind, nullable, isDate: true, generated, defaultValue };
134
- }
114
+ const hasSqliteVec = extensions.some((e) => e.kind === "sqlite-vec");
135
115
 
136
- if (v.proto === Uint8Array || concrete.some((b) => b.proto === Uint8Array)) {
137
- return {
138
- key,
139
- kind,
140
- nullable,
141
- isBlob: true,
142
- meta,
143
- generated,
144
- defaultValue,
145
- };
116
+ if (hasSqliteVec) {
117
+ return;
146
118
  }
147
119
 
148
- if (concrete.length > 0 && concrete.every((b) => b.domain === "boolean")) {
149
- return { key, kind, nullable, isBoolean: true, generated, defaultValue };
150
- }
120
+ const probe = new Introspector(this.sqlite);
151
121
 
152
- if (concrete.some((b) => !!b.structure)) {
153
- return {
154
- key,
155
- kind,
156
- nullable,
157
- isJson: true,
158
- jsonSchema: (parentSchema as any).get(key) as Type,
159
- meta,
160
- generated,
161
- defaultValue,
162
- };
122
+ if (probe.hasVec0Tables()) {
123
+ loadExtensions(this.sqlite, [{ kind: "sqlite-vec" }]);
163
124
  }
164
-
165
- const branch = concrete[0];
166
-
167
- return {
168
- key,
169
- kind,
170
- nullable,
171
- domain: branch?.domain,
172
- isInteger: !!branch?.inner?.divisor,
173
- meta,
174
- generated,
175
- defaultValue,
176
- };
177
125
  }
178
126
 
179
- private sqlType(prop: Prop) {
180
- if (prop.isJson) {
181
- return "TEXT";
182
- }
127
+ private assertVectorIndexesHaveExtension() {
128
+ const vectorIndexes = this.options.schema.vectorIndexes;
183
129
 
184
- if (prop.isBlob) {
185
- return "BLOB";
130
+ if (!vectorIndexes || Object.keys(vectorIndexes).length === 0) {
131
+ return;
186
132
  }
187
133
 
188
- if (prop.isDate || prop.isBoolean || prop.isInteger) {
189
- return "INTEGER";
190
- }
134
+ const hasSqliteVec = (this.options.extensions ?? []).some((e) => e.kind === "sqlite-vec");
191
135
 
192
- if (prop.domain) {
193
- return typeMap[prop.domain] ?? "TEXT";
136
+ if (hasSqliteVec) {
137
+ return;
194
138
  }
195
139
 
196
- return "TEXT";
197
- }
140
+ const [first] = Object.keys(vectorIndexes);
198
141
 
199
- private columnConstraint(prop: Prop) {
200
- if (prop.generated === "autoincrement") {
201
- return "PRIMARY KEY AUTOINCREMENT";
202
- }
203
-
204
- if (prop.meta?.primaryKey) {
205
- return "PRIMARY KEY";
206
- }
207
-
208
- if (prop.kind === "required" && !prop.nullable) {
209
- return "NOT NULL";
210
- }
211
-
212
- return null;
142
+ throw new Error(
143
+ `vectorIndex "${first}" requires the sqlite-vec extension. Add { kind: "sqlite-vec" } to DatabaseOptions.extensions.`,
144
+ );
213
145
  }
214
146
 
215
- private defaultClause(prop: Prop) {
216
- if (prop.generated === "now") {
217
- return "DEFAULT (unixepoch())";
218
- }
147
+ private applyPragmas() {
148
+ const pragmas = { ...defaultPragmas, ...this.options.pragmas };
219
149
 
220
- if (prop.defaultValue === undefined || prop.generated === "autoincrement") {
221
- return null;
150
+ if (pragmas.journal_mode) {
151
+ this.sqlite.run(`PRAGMA journal_mode = ${pragmas.journal_mode.toUpperCase()}`);
222
152
  }
223
153
 
224
- if (prop.defaultValue === null) {
225
- return "DEFAULT NULL";
154
+ if (pragmas.synchronous) {
155
+ this.sqlite.run(`PRAGMA synchronous = ${pragmas.synchronous.toUpperCase()}`);
226
156
  }
227
157
 
228
- if (typeof prop.defaultValue === "string") {
229
- return `DEFAULT '${prop.defaultValue}'`;
230
- }
158
+ this.sqlite.run(`PRAGMA foreign_keys = ${pragmas.foreign_keys ? "ON" : "OFF"}`);
231
159
 
232
- if (typeof prop.defaultValue === "number" || typeof prop.defaultValue === "boolean") {
233
- return `DEFAULT ${prop.defaultValue}`;
160
+ if (pragmas.busy_timeout_ms !== undefined) {
161
+ this.sqlite.run(`PRAGMA busy_timeout = ${pragmas.busy_timeout_ms}`);
234
162
  }
235
-
236
- throw new Error(
237
- `Unsupported default value type: ${typeof prop.defaultValue} ${JSON.stringify(prop)}`,
238
- );
239
163
  }
240
164
 
241
- private columnDef(prop: Prop) {
242
- return [
243
- `"${prop.key}"`,
244
- this.sqlType(prop),
245
- this.columnConstraint(prop),
246
- prop.meta?.unique ? "UNIQUE" : null,
247
- this.defaultClause(prop),
248
- ]
249
- .filter(Boolean)
250
- .join(" ");
251
- }
165
+ private buildDesiredSchema(): DesiredSchema {
166
+ const desiredTables: DesiredTable[] = [];
167
+ const tableInfos = new Map<string, FtsTableInfo>();
168
+ const vectorAnchors = new Map<string, VectorAnchorInfo>();
169
+ const schemaIndexes = this.options.schema.indexes;
252
170
 
253
- private foreignKey(prop: Prop) {
254
- const ref = prop.meta?.references;
171
+ for (const [name, schema] of Object.entries(this.options.schema.tables)) {
172
+ const compiled = this.compiler.compileTable(name, schema, schemaIndexes?.[name] ?? []);
255
173
 
256
- if (!ref) {
257
- return null;
174
+ desiredTables.push(compiled.table);
175
+ tableInfos.set(name, compiled.tableInfo);
176
+ vectorAnchors.set(name, compiled.vectorAnchor);
258
177
  }
259
178
 
260
- const [table, column] = ref.split(".");
179
+ const desiredFts: ResolvedFtsPlan[] = [];
261
180
 
262
- let fk = `FOREIGN KEY ("${prop.key}") REFERENCES "${table}"("${column}")`;
181
+ if (this.options.schema.fts) {
182
+ for (const [name, entry] of Object.entries(this.options.schema.fts)) {
183
+ const plan = resolveFtsPlan(name, entry, tableInfos);
184
+ desiredFts.push(plan);
185
+ this.ftsPlans.set(name, plan);
186
+ }
187
+ }
263
188
 
264
- const onDelete = prop.meta?.onDelete;
189
+ const desiredVectorIndexes: ResolvedVectorIndex[] = [];
265
190
 
266
- if (onDelete) {
267
- fk += ` ON DELETE ${onDelete.toUpperCase()}`;
191
+ if (this.options.schema.vectorIndexes) {
192
+ for (const [name, entry] of Object.entries(this.options.schema.vectorIndexes)) {
193
+ const plan = resolveVectorIndex(name, entry as any, vectorAnchors);
194
+ desiredVectorIndexes.push(plan);
195
+ this.vectorIndexPlans.set(name, plan);
196
+ registerVectorIndex(plan);
197
+ }
268
198
  }
269
199
 
270
- return fk;
200
+ return { desiredTables, desiredFts, desiredVectorIndexes };
271
201
  }
272
202
 
273
- private addColumnDef(prop: Prop) {
274
- let def = this.columnDef(prop);
203
+ private async runMigrations() {
204
+ const desired = this.buildDesiredSchema();
205
+
206
+ const introspector = new Introspector(this.sqlite);
207
+ const existingVectorIndexes = introspector.introspectVectorIndexes();
208
+ const existingState: ExistingSchema = {
209
+ existing: introspector.introspect(),
210
+ existingFts: introspector.introspectFts(),
211
+ existingVectorIndexes,
212
+ };
275
213
 
276
- const ref = prop.meta?.references;
214
+ const migration = this.options.migration;
277
215
 
278
- if (ref) {
279
- const [table, column] = ref.split(".");
280
- def += ` REFERENCES "${table}"("${column}")`;
216
+ if (migration) {
217
+ await this.applyWithMigration(desired, existingState, migration);
218
+ } else {
219
+ this.applyDiff(desired, existingState);
220
+ }
281
221
 
282
- if (prop.meta?.onDelete) {
283
- def += ` ON DELETE ${prop.meta.onDelete.toUpperCase()}`;
222
+ for (const name of existingVectorIndexes.keys()) {
223
+ if (!this.vectorIndexPlans.has(name)) {
224
+ unregisterVectorIndex(name);
284
225
  }
285
226
  }
286
-
287
- return def;
288
227
  }
289
228
 
290
- private parseSchemaProps(schema: Type) {
291
- const structureProps = (schema as any).structure?.props as StructureProp[] | undefined;
229
+ private applyDiff(desired: DesiredSchema, existingState: ExistingSchema) {
230
+ const differ = new Differ(desired, existingState);
292
231
 
293
- if (!structureProps) {
294
- return [];
232
+ const ops = differ.diff();
233
+
234
+ if (this.options.failOnDataLoss && differ.destructive.length > 0) {
235
+ throw new DataLossError(differ.destructive);
295
236
  }
296
237
 
297
- return structureProps.map((p) => this.normalizeProp(p, schema));
238
+ new Executor(this.sqlite, ops).execute();
298
239
  }
299
240
 
300
- private createWriteSchema(schema: Type) {
301
- const autoIncrementColumns = this.parseSchemaProps(schema)
302
- .filter((prop) => prop.generated === "autoincrement")
303
- .map((prop) => prop.key);
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();
304
248
 
305
- if (autoIncrementColumns.length === 0) {
306
- return schema;
307
- }
308
-
309
- return (schema as any).omit(...autoIncrementColumns) as Type;
310
- }
249
+ const checksum = MigrationRegistry.checksum(migration.run);
311
250
 
312
- private generateCreateTableSQL(tableName: string, props: Prop[]) {
313
- const columns: string[] = [];
314
- const fks: string[] = [];
251
+ registry.assertNoDrift(migration.id, checksum);
315
252
 
316
- for (const prop of props) {
317
- columns.push(this.columnDef(prop));
253
+ if (registry.appliedChecksum(migration.id) !== null) {
254
+ this.applyDiff(desired, existingState);
255
+ return;
256
+ }
318
257
 
319
- const fk = this.foreignKey(prop);
258
+ const fresh = !desired.desiredTables.some((table) => existingState.existing.has(table.name));
320
259
 
321
- if (fk) {
322
- fks.push(fk);
323
- }
260
+ if (fresh) {
261
+ this.applyDiff(desired, existingState);
262
+ registry.record(migration.id, checksum);
263
+ return;
324
264
  }
325
265
 
326
- return `CREATE TABLE IF NOT EXISTS "${tableName}" (${columns.concat(fks).join(", ")})`;
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();
327
275
  }
328
276
 
329
- private migrate() {
330
- const desiredTables: DesiredTable[] = [];
331
- const schemaIndexes = this.options.schema.indexes;
277
+ private rebuildFts(name: string) {
278
+ const plan = this.ftsPlans.get(name);
332
279
 
333
- for (const [name, schema] of Object.entries(this.options.schema.tables)) {
334
- const props = this.parseSchemaProps(schema);
335
-
336
- const columns = props.map((prop) => {
337
- const isNotNull = this.columnConstraint(prop) === "NOT NULL";
338
- const defaultClause = this.defaultClause(prop);
339
- const hasLiteralDefault = prop.generated !== "now" && defaultClause !== null;
340
-
341
- return {
342
- name: prop.key,
343
- addable: !isNotNull || hasLiteralDefault,
344
- columnDef: this.addColumnDef(prop),
345
- type: this.sqlType(prop),
346
- notnull: isNotNull,
347
- defaultValue: defaultClause
348
- ? defaultClause.replace("DEFAULT ", "").replace(/^\((.+)\)$/, "$1")
349
- : null,
350
- unique: !!prop.meta?.unique,
351
- references: prop.meta?.references ?? null,
352
- onDelete: prop.meta?.onDelete?.toUpperCase() ?? null,
353
- };
354
- });
355
-
356
- const indexes = (schemaIndexes?.[name] ?? []).map((indexDef) => ({
357
- name: this.generateIndexName(name, indexDef.columns, indexDef.unique ?? false),
358
- columns: indexDef.columns,
359
- sql: this.generateCreateIndexSQL(name, indexDef),
360
- }));
361
-
362
- desiredTables.push({
363
- name,
364
- sql: this.generateCreateTableSQL(name, props),
365
- columns,
366
- indexes,
367
- });
280
+ if (!plan) {
281
+ throw new Error(`FTS "${name}" is not declared in the schema`);
368
282
  }
369
283
 
370
- const existing = new Introspector(this.sqlite).introspect();
371
- const ops = new Differ(desiredTables, existing).diff();
372
-
373
- new Executor(this.sqlite, ops).execute();
284
+ this.sqlite.transaction(() => {
285
+ this.sqlite.run(`DELETE FROM "${name}"`);
286
+ this.sqlite.run(generateRebuildSelect(plan));
287
+ })();
374
288
  }
375
289
 
376
- private generateIndexName(tableName: string, columns: string[], unique: boolean) {
377
- const prefix = unique ? "ux" : "ix";
290
+ private rebuildVectorIndex(name: string) {
291
+ const plan = this.vectorIndexPlans.get(name);
378
292
 
379
- return `${prefix}_${tableName}_${columns.join("_")}`;
380
- }
381
-
382
- private generateCreateIndexSQL(tableName: string, indexDef: IndexDefinition) {
383
- const indexName = this.generateIndexName(tableName, indexDef.columns, indexDef.unique ?? false);
384
- const unique = indexDef.unique ? "UNIQUE " : "";
385
- const columns = indexDef.columns.map((c) => `"${c}"`).join(", ");
293
+ if (!plan) {
294
+ throw new Error(`vector index "${name}" is not declared in the schema`);
295
+ }
386
296
 
387
- return `CREATE ${unique}INDEX "${indexName}" ON "${tableName}" (${columns})`;
297
+ this.sqlite.transaction(() => {
298
+ this.sqlite.run(`DELETE FROM "${name}"`);
299
+ this.sqlite.run(generateRebuildVectorSql(plan));
300
+ })();
388
301
  }
389
302
 
390
303
  reset(table?: keyof T & string): void {
@@ -1,6 +1,6 @@
1
1
  export function serializeParam(p: unknown): unknown {
2
2
  if (p instanceof Date) {
3
- return Math.floor(p.getTime() / 1000);
3
+ return p.getTime();
4
4
  }
5
5
 
6
6
  if (typeof p === "object" && p !== null && !ArrayBuffer.isView(p)) {
package/src/env.ts CHANGED
@@ -3,7 +3,8 @@ export type DbFieldMeta = {
3
3
  unique?: boolean;
4
4
  references?: `${string}.${string}`;
5
5
  onDelete?: "cascade" | "set null" | "restrict";
6
- _generated?: "autoincrement" | "now";
6
+ _generated?: "autoincrement" | "now" | "vector";
7
+ _vectorDim?: number;
7
8
  };
8
9
 
9
10
  declare global {