@lobomfz/db 0.4.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobomfz/db",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Bun SQLite database with Arktype schemas and typed Kysely client",
5
5
  "keywords": [
6
6
  "arktype",
@@ -32,15 +32,18 @@
32
32
  },
33
33
  "dependencies": {},
34
34
  "devDependencies": {
35
- "@types/bun": "^1.3.11",
36
- "@typescript/native-preview": "^7.0.0-dev.20260324.1",
37
- "oxfmt": "^0.42.0",
38
- "oxlint": "^1.57.0"
35
+ "@types/bun": "^1.3.12",
36
+ "@typescript/native-preview": "^7.0.0-dev.20260417.1",
37
+ "oxfmt": "^0.45.0",
38
+ "oxlint": "^1.60.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@ark/schema": ">=0.56.0",
42
42
  "@ark/util": ">=0.56.0",
43
43
  "arktype": "^2.1.29",
44
44
  "kysely": "^0.28.14"
45
+ },
46
+ "optionalDependencies": {
47
+ "sqlite-vec": "^0.1.9"
45
48
  }
46
49
  }
package/src/database.ts CHANGED
@@ -5,6 +5,8 @@ import { Kysely } from "kysely";
5
5
 
6
6
  import { BunSqliteDialect } from "./dialect/dialect.js";
7
7
  import type { DbFieldMeta } from "./env.js";
8
+ import { resolveFtsPlan, type FtsTableInfo, type ResolvedFtsPlan } from "./fts/resolve.js";
9
+ import { generateRebuildSelect } from "./fts/sql.js";
8
10
  import type { GeneratedPreset } from "./generated.js";
9
11
  import { Differ, type DesiredTable } from "./migration/diff.js";
10
12
  import { Executor } from "./migration/execute.js";
@@ -12,11 +14,23 @@ import { Introspector } from "./migration/introspect.js";
12
14
  import { ResultHydrationPlugin } from "./plugin.js";
13
15
  import type {
14
16
  DatabaseOptions,
17
+ DatabasePragmas,
18
+ FtsApi,
19
+ FtsConfig,
15
20
  IndexDefinition,
16
21
  SchemaRecord,
17
22
  TablesFromSchemas,
18
- DatabasePragmas,
23
+ VectorIndexApi,
24
+ VectorIndexConfig,
19
25
  } from "./types.js";
26
+ import { loadExtensions } from "./vector-index/load.js";
27
+ import { registerVectorIndex, unregisterVectorIndex } from "./vector-index/nearest.js";
28
+ import {
29
+ resolveVectorIndex,
30
+ type ResolvedVectorIndex,
31
+ type VectorAnchorInfo,
32
+ } from "./vector-index/resolve.js";
33
+ import { generateRebuildVectorSql } from "./vector-index/sql.js";
20
34
  import { WriteValidationPlugin } from "./write-validation-plugin.js";
21
35
 
22
36
  type ArkBranch = {
@@ -25,7 +39,7 @@ type ArkBranch = {
25
39
  unit?: unknown;
26
40
  structure?: unknown;
27
41
  inner?: { divisor?: unknown };
28
- meta?: DbFieldMeta & { _generated?: GeneratedPreset };
42
+ meta?: DbFieldMeta;
29
43
  };
30
44
 
31
45
  type StructureProp = {
@@ -34,7 +48,7 @@ type StructureProp = {
34
48
  value: Type & {
35
49
  branches: ArkBranch[];
36
50
  proto?: unknown;
37
- meta: DbFieldMeta & { _generated?: GeneratedPreset };
51
+ meta: DbFieldMeta;
38
52
  };
39
53
  inner: { default?: unknown };
40
54
  };
@@ -49,6 +63,8 @@ type Prop = {
49
63
  isDate?: boolean;
50
64
  isBlob?: boolean;
51
65
  isJson?: boolean;
66
+ isVector?: boolean;
67
+ vectorDim?: number;
52
68
  jsonSchema?: Type;
53
69
  meta?: DbFieldMeta;
54
70
  generated?: GeneratedPreset;
@@ -64,14 +80,24 @@ const defaultPragmas: DatabasePragmas = {
64
80
  foreign_keys: true,
65
81
  };
66
82
 
67
- export class Database<T extends SchemaRecord> {
83
+ export class Database<
84
+ T extends SchemaRecord,
85
+ F extends FtsConfig<T> = FtsConfig<T>,
86
+ V extends VectorIndexConfig<T> = VectorIndexConfig<T>,
87
+ > {
68
88
  private sqlite: BunDatabase;
89
+ private ftsPlans = new Map<string, ResolvedFtsPlan>();
90
+ private vectorIndexPlans = new Map<string, ResolvedVectorIndex>();
91
+
92
+ readonly infer: TablesFromSchemas<T, F, V> = undefined as any;
93
+
94
+ readonly kysely: Kysely<TablesFromSchemas<T, F, V>>;
69
95
 
70
- readonly infer: TablesFromSchemas<T> = undefined as any;
96
+ readonly fts: FtsApi<F>;
71
97
 
72
- readonly kysely: Kysely<TablesFromSchemas<T>>;
98
+ readonly vectorIndex: VectorIndexApi<V>;
73
99
 
74
- constructor(private options: DatabaseOptions<T>) {
100
+ constructor(private options: DatabaseOptions<T, F, V>) {
75
101
  const tableSchemas = new Map<string, Type>(Object.entries(options.schema.tables));
76
102
  const writeSchemas = new Map<string, Type>(
77
103
  Object.entries(options.schema.tables).map(([table, schema]) => [
@@ -83,6 +109,8 @@ export class Database<T extends SchemaRecord> {
83
109
  this.sqlite = new BunDatabase(options.path);
84
110
 
85
111
  this.applyPragmas();
112
+ this.assertVectorIndexesHaveExtension();
113
+ this.loadConfiguredExtensions();
86
114
 
87
115
  this.migrate();
88
116
 
@@ -90,13 +118,59 @@ export class Database<T extends SchemaRecord> {
90
118
  onRead: options.validation?.onRead ?? false,
91
119
  };
92
120
 
93
- this.kysely = new Kysely<TablesFromSchemas<T>>({
121
+ this.kysely = new Kysely<TablesFromSchemas<T, F, V>>({
94
122
  dialect: new BunSqliteDialect({ database: this.sqlite }),
95
123
  plugins: [
96
124
  new WriteValidationPlugin(writeSchemas),
97
125
  new ResultHydrationPlugin(tableSchemas, validation),
98
126
  ],
99
127
  });
128
+
129
+ this.fts = {
130
+ rebuild: (name: string) => this.rebuildFts(name),
131
+ } as FtsApi<F>;
132
+
133
+ this.vectorIndex = {
134
+ rebuild: (name: string) => this.rebuildVectorIndex(name),
135
+ } as VectorIndexApi<V>;
136
+ }
137
+
138
+ private loadConfiguredExtensions() {
139
+ const extensions = this.options.extensions ?? [];
140
+
141
+ loadExtensions(this.sqlite, extensions);
142
+
143
+ const hasSqliteVec = extensions.some((e) => e.kind === "sqlite-vec");
144
+
145
+ if (hasSqliteVec) {
146
+ return;
147
+ }
148
+
149
+ const probe = new Introspector(this.sqlite);
150
+
151
+ if (probe.hasVec0Tables()) {
152
+ loadExtensions(this.sqlite, [{ kind: "sqlite-vec" }]);
153
+ }
154
+ }
155
+
156
+ private assertVectorIndexesHaveExtension() {
157
+ const vectorIndexes = this.options.schema.vectorIndexes;
158
+
159
+ if (!vectorIndexes || Object.keys(vectorIndexes).length === 0) {
160
+ return;
161
+ }
162
+
163
+ const hasSqliteVec = (this.options.extensions ?? []).some((e) => e.kind === "sqlite-vec");
164
+
165
+ if (hasSqliteVec) {
166
+ return;
167
+ }
168
+
169
+ const [first] = Object.keys(vectorIndexes);
170
+
171
+ throw new Error(
172
+ `vectorIndex "${first}" requires the sqlite-vec extension. Add { kind: "sqlite-vec" } to DatabaseOptions.extensions.`,
173
+ );
100
174
  }
101
175
 
102
176
  private applyPragmas() {
@@ -127,7 +201,19 @@ export class Database<T extends SchemaRecord> {
127
201
 
128
202
  const branchMeta = v.branches.find((b) => b.meta && Object.keys(b.meta).length > 0)?.meta;
129
203
  const meta = { ...branchMeta, ...v.meta };
130
- const generated = meta._generated;
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
+ }
131
217
 
132
218
  if (v.proto === Date || concrete.some((b) => b.proto === Date)) {
133
219
  return { key, kind, nullable, isDate: true, generated, defaultValue };
@@ -181,7 +267,7 @@ export class Database<T extends SchemaRecord> {
181
267
  return "TEXT";
182
268
  }
183
269
 
184
- if (prop.isBlob) {
270
+ if (prop.isBlob || prop.isVector) {
185
271
  return "BLOB";
186
272
  }
187
273
 
@@ -214,7 +300,7 @@ export class Database<T extends SchemaRecord> {
214
300
 
215
301
  private defaultClause(prop: Prop) {
216
302
  if (prop.generated === "now") {
217
- return "DEFAULT (unixepoch())";
303
+ return "DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER))";
218
304
  }
219
305
 
220
306
  if (prop.defaultValue === undefined || prop.generated === "autoincrement") {
@@ -328,6 +414,8 @@ export class Database<T extends SchemaRecord> {
328
414
 
329
415
  private migrate() {
330
416
  const desiredTables: DesiredTable[] = [];
417
+ const tableInfos = new Map<string, FtsTableInfo>();
418
+ const vectorAnchors = new Map<string, VectorAnchorInfo>();
331
419
  const schemaIndexes = this.options.schema.indexes;
332
420
 
333
421
  for (const [name, schema] of Object.entries(this.options.schema.tables)) {
@@ -365,12 +453,129 @@ export class Database<T extends SchemaRecord> {
365
453
  columns,
366
454
  indexes,
367
455
  });
456
+
457
+ tableInfos.set(name, this.buildTableInfo(props));
458
+ vectorAnchors.set(name, this.buildVectorAnchorInfo(props));
368
459
  }
369
460
 
370
- const existing = new Introspector(this.sqlite).introspect();
371
- const ops = new Differ(desiredTables, existing).diff();
461
+ const desiredFts: ResolvedFtsPlan[] = [];
462
+
463
+ if (this.options.schema.fts) {
464
+ for (const [name, entry] of Object.entries(this.options.schema.fts)) {
465
+ const plan = resolveFtsPlan(name, entry, tableInfos);
466
+ desiredFts.push(plan);
467
+ this.ftsPlans.set(name, plan);
468
+ }
469
+ }
470
+
471
+ const desiredVectorIndexes: ResolvedVectorIndex[] = [];
472
+
473
+ if (this.options.schema.vectorIndexes) {
474
+ for (const [name, entry] of Object.entries(this.options.schema.vectorIndexes)) {
475
+ const plan = resolveVectorIndex(name, entry as any, vectorAnchors);
476
+ desiredVectorIndexes.push(plan);
477
+ this.vectorIndexPlans.set(name, plan);
478
+ registerVectorIndex(plan);
479
+ }
480
+ }
481
+
482
+ const introspector = new Introspector(this.sqlite);
483
+ const existing = introspector.introspect();
484
+ const existingFts = introspector.introspectFts();
485
+ const existingVectorIndexes = introspector.introspectVectorIndexes();
486
+
487
+ const ops = new Differ(
488
+ desiredTables,
489
+ existing,
490
+ desiredFts,
491
+ existingFts,
492
+ desiredVectorIndexes,
493
+ existingVectorIndexes,
494
+ ).diff();
372
495
 
373
496
  new Executor(this.sqlite, ops).execute();
497
+
498
+ for (const name of existingVectorIndexes.keys()) {
499
+ if (!this.vectorIndexPlans.has(name)) {
500
+ unregisterVectorIndex(name);
501
+ }
502
+ }
503
+ }
504
+
505
+ private rebuildFts(name: string) {
506
+ const plan = this.ftsPlans.get(name);
507
+
508
+ if (!plan) {
509
+ throw new Error(`FTS "${name}" is not declared in the schema`);
510
+ }
511
+
512
+ this.sqlite.transaction(() => {
513
+ this.sqlite.run(`DELETE FROM "${name}"`);
514
+ this.sqlite.run(generateRebuildSelect(plan));
515
+ })();
516
+ }
517
+
518
+ private rebuildVectorIndex(name: string) {
519
+ const plan = this.vectorIndexPlans.get(name);
520
+
521
+ if (!plan) {
522
+ throw new Error(`vector index "${name}" is not declared in the schema`);
523
+ }
524
+
525
+ this.sqlite.transaction(() => {
526
+ this.sqlite.run(`DELETE FROM "${name}"`);
527
+ this.sqlite.run(generateRebuildVectorSql(plan));
528
+ })();
529
+ }
530
+
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
+ };
374
579
  }
375
580
 
376
581
  private generateIndexName(tableName: string, columns: string[], unique: boolean) {
@@ -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 {
@@ -0,0 +1,208 @@
1
+ import type { FtsEntry, FtsFieldRef, FtsTokenizer } from "../types.js";
2
+ import { DEFAULT_TOKENIZER } from "./tokenizer.js";
3
+
4
+ export type FtsTableInfo = {
5
+ columns: Set<string>;
6
+ pkColumn: string | null;
7
+ pkIsInteger: boolean;
8
+ foreignKeys: Array<{
9
+ column: string;
10
+ referencedTable: string;
11
+ referencedColumn: string;
12
+ }>;
13
+ };
14
+
15
+ export type ResolvedFtsField = {
16
+ name: string;
17
+ sourceTable: string;
18
+ sourceColumn: string;
19
+ };
20
+
21
+ export type ResolvedFtsSource = {
22
+ table: string;
23
+ fkColumn: string;
24
+ };
25
+
26
+ export type ResolvedFtsPlan = {
27
+ name: string;
28
+ anchor: string;
29
+ anchorPkColumn: string;
30
+ fields: ResolvedFtsField[];
31
+ sources: ResolvedFtsSource[];
32
+ tokenizer: FtsTokenizer;
33
+ };
34
+
35
+ function parseFieldRef(name: string, fieldName: string, ref: FtsFieldRef) {
36
+ const source = typeof ref === "string" ? ref : ref.source;
37
+ const [sourceTable, sourceColumn, ...rest] = source.split(".");
38
+
39
+ if (!sourceTable || !sourceColumn || rest.length > 0) {
40
+ throw new Error(
41
+ `FTS "${name}": field "${fieldName}" has invalid reference "${source}", expected "table.column"`,
42
+ );
43
+ }
44
+
45
+ return {
46
+ sourceTable,
47
+ sourceColumn,
48
+ via: typeof ref === "string" ? undefined : ref.via,
49
+ };
50
+ }
51
+
52
+ function parseVia(name: string, fieldName: string, via: string) {
53
+ const [table, column, ...rest] = via.split(".");
54
+
55
+ if (!table || !column || rest.length > 0) {
56
+ throw new Error(
57
+ `FTS "${name}": field "${fieldName}" has invalid via "${via}", expected "table.column"`,
58
+ );
59
+ }
60
+
61
+ return { table, column };
62
+ }
63
+
64
+ function resolveCrossTableFk(
65
+ name: string,
66
+ fieldName: string,
67
+ sourceTable: string,
68
+ sourceInfo: FtsTableInfo,
69
+ anchor: string,
70
+ anchorPk: string,
71
+ via: string | undefined,
72
+ ) {
73
+ if (via) {
74
+ const parsed = parseVia(name, fieldName, via);
75
+
76
+ if (parsed.table !== sourceTable) {
77
+ throw new Error(
78
+ `FTS "${name}": field "${fieldName}" via "${via}" must reference a column on source table "${sourceTable}"`,
79
+ );
80
+ }
81
+
82
+ if (!sourceInfo.columns.has(parsed.column)) {
83
+ throw new Error(
84
+ `FTS "${name}": field "${fieldName}" via "${via}" column does not exist`,
85
+ );
86
+ }
87
+
88
+ const fk = sourceInfo.foreignKeys.find((f) => f.column === parsed.column);
89
+
90
+ if (!fk || fk.referencedTable !== anchor || fk.referencedColumn !== anchorPk) {
91
+ throw new Error(
92
+ `FTS "${name}": field "${fieldName}" via "${via}" does not reference "${anchor}.${anchorPk}"`,
93
+ );
94
+ }
95
+
96
+ return parsed.column;
97
+ }
98
+
99
+ const matches = sourceInfo.foreignKeys.filter(
100
+ (f) => f.referencedTable === anchor && f.referencedColumn === anchorPk,
101
+ );
102
+
103
+ if (matches.length === 0) {
104
+ throw new Error(
105
+ `FTS "${name}": field "${fieldName}" source table "${sourceTable}" has no foreign key to anchor "${anchor}.${anchorPk}"; add \`via\` to disambiguate`,
106
+ );
107
+ }
108
+
109
+ if (matches.length > 1) {
110
+ const cols = matches.map((m) => m.column).join(", ");
111
+
112
+ throw new Error(
113
+ `FTS "${name}": field "${fieldName}" source table "${sourceTable}" has multiple foreign keys to anchor "${anchor}.${anchorPk}" (${cols}); add \`via\` to disambiguate`,
114
+ );
115
+ }
116
+
117
+ return matches[0]!.column;
118
+ }
119
+
120
+ export function resolveFtsPlan(
121
+ name: string,
122
+ entry: FtsEntry<any>,
123
+ tables: Map<string, FtsTableInfo>,
124
+ ) {
125
+ const anchor = tables.get(entry.anchor);
126
+
127
+ if (!anchor) {
128
+ throw new Error(
129
+ `FTS "${name}": anchor table "${entry.anchor}" is not declared in schema`,
130
+ );
131
+ }
132
+
133
+ if (!anchor.pkColumn) {
134
+ throw new Error(
135
+ `FTS "${name}": anchor table "${entry.anchor}" has no primary key; anchor PK must be an integer`,
136
+ );
137
+ }
138
+
139
+ if (!anchor.pkIsInteger) {
140
+ throw new Error(
141
+ `FTS "${name}": anchor table "${entry.anchor}" primary key "${anchor.pkColumn}" must be an integer`,
142
+ );
143
+ }
144
+
145
+ const fields: ResolvedFtsField[] = [];
146
+ const sources = new Map<string, ResolvedFtsSource>();
147
+ const fieldEntries = Object.entries(entry.fields);
148
+
149
+ if (fieldEntries.length === 0) {
150
+ throw new Error(`FTS "${name}": must declare at least one field`);
151
+ }
152
+
153
+ for (const [fieldName, ref] of fieldEntries) {
154
+ const parsed = parseFieldRef(name, fieldName, ref);
155
+ const sourceTable = tables.get(parsed.sourceTable);
156
+
157
+ if (!sourceTable) {
158
+ throw new Error(
159
+ `FTS "${name}": field "${fieldName}" references unknown table "${parsed.sourceTable}"`,
160
+ );
161
+ }
162
+
163
+ if (!sourceTable.columns.has(parsed.sourceColumn)) {
164
+ throw new Error(
165
+ `FTS "${name}": field "${fieldName}" references unknown column "${parsed.sourceTable}.${parsed.sourceColumn}"`,
166
+ );
167
+ }
168
+
169
+ if (parsed.sourceTable !== entry.anchor) {
170
+ const fkColumn = resolveCrossTableFk(
171
+ name,
172
+ fieldName,
173
+ parsed.sourceTable,
174
+ sourceTable,
175
+ entry.anchor,
176
+ anchor.pkColumn,
177
+ parsed.via,
178
+ );
179
+
180
+ const existing = sources.get(parsed.sourceTable);
181
+
182
+ if (existing && existing.fkColumn !== fkColumn) {
183
+ throw new Error(
184
+ `FTS "${name}": field "${fieldName}" resolves to via "${parsed.sourceTable}.${fkColumn}" but source already bound to "${parsed.sourceTable}.${existing.fkColumn}"`,
185
+ );
186
+ }
187
+
188
+ if (!existing) {
189
+ sources.set(parsed.sourceTable, { table: parsed.sourceTable, fkColumn });
190
+ }
191
+ }
192
+
193
+ fields.push({
194
+ name: fieldName,
195
+ sourceTable: parsed.sourceTable,
196
+ sourceColumn: parsed.sourceColumn,
197
+ });
198
+ }
199
+
200
+ return {
201
+ name,
202
+ anchor: entry.anchor,
203
+ anchorPkColumn: anchor.pkColumn,
204
+ fields,
205
+ sources: [...sources.values()],
206
+ tokenizer: entry.tokenizer ?? DEFAULT_TOKENIZER,
207
+ };
208
+ }