@lobomfz/db 0.3.9 → 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/README.md CHANGED
@@ -5,7 +5,7 @@ SQLite database with Arktype schemas and typed Kysely client for Bun.
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- bun add @lobomfz/db arktype kysely
8
+ bun add @lobomfz/db
9
9
  ```
10
10
 
11
11
  ## Usage
@@ -65,12 +65,12 @@ type("string").configure({ unique: true }); // UNIQUE
65
65
  type("number.integer").configure({ references: "users.id", onDelete: "cascade" }); // FK
66
66
  ```
67
67
 
68
- JSON columns are validated against the schema on write by default. To also validate on read, or to disable write validation:
68
+ JSON columns are validated against the schema on write by default. To also validate on read:
69
69
 
70
70
  ```typescript
71
71
  new Database({
72
72
  // ...
73
- validation: { onRead: true }, // default: { onRead: false, onWrite: true }
73
+ validation: { onRead: true }, // default: { onRead: false }
74
74
  });
75
75
  ```
76
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobomfz/db",
3
- "version": "0.3.9",
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,18 +5,32 @@ 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";
11
13
  import { Introspector } from "./migration/introspect.js";
12
- import { ResultHydrationPlugin, type ColumnCoercion, type ColumnsMap } from "./plugin.js";
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,35 +80,97 @@ 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>();
69
91
 
70
- private columns: ColumnsMap = new Map();
71
- private tableColumns = new Map<string, Set<string>>();
92
+ readonly infer: TablesFromSchemas<T, F, V> = undefined as any;
72
93
 
73
- readonly infer: TablesFromSchemas<T> = undefined as any;
94
+ readonly kysely: Kysely<TablesFromSchemas<T, F, V>>;
74
95
 
75
- readonly kysely: Kysely<TablesFromSchemas<T>>;
96
+ readonly fts: FtsApi<F>;
97
+
98
+ readonly vectorIndex: VectorIndexApi<V>;
99
+
100
+ constructor(private options: DatabaseOptions<T, F, V>) {
101
+ const tableSchemas = new Map<string, Type>(Object.entries(options.schema.tables));
102
+ const writeSchemas = new Map<string, Type>(
103
+ Object.entries(options.schema.tables).map(([table, schema]) => [
104
+ table,
105
+ this.createWriteSchema(schema),
106
+ ]),
107
+ );
76
108
 
77
- constructor(private options: DatabaseOptions<T>) {
78
109
  this.sqlite = new BunDatabase(options.path);
79
110
 
80
111
  this.applyPragmas();
112
+ this.assertVectorIndexesHaveExtension();
113
+ this.loadConfiguredExtensions();
81
114
 
82
115
  this.migrate();
83
116
 
84
117
  const validation = {
85
118
  onRead: options.validation?.onRead ?? false,
86
- onWrite: options.validation?.onWrite ?? true,
87
119
  };
88
120
 
89
- this.kysely = new Kysely<TablesFromSchemas<T>>({
121
+ this.kysely = new Kysely<TablesFromSchemas<T, F, V>>({
90
122
  dialect: new BunSqliteDialect({ database: this.sqlite }),
91
123
  plugins: [
92
- new WriteValidationPlugin(this.columns, validation),
93
- new ResultHydrationPlugin(this.columns, this.tableColumns, validation),
124
+ new WriteValidationPlugin(writeSchemas),
125
+ new ResultHydrationPlugin(tableSchemas, validation),
94
126
  ],
95
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
+ );
96
174
  }
97
175
 
98
176
  private applyPragmas() {
@@ -123,7 +201,19 @@ export class Database<T extends SchemaRecord> {
123
201
 
124
202
  const branchMeta = v.branches.find((b) => b.meta && Object.keys(b.meta).length > 0)?.meta;
125
203
  const meta = { ...branchMeta, ...v.meta };
126
- 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
+ }
127
217
 
128
218
  if (v.proto === Date || concrete.some((b) => b.proto === Date)) {
129
219
  return { key, kind, nullable, isDate: true, generated, defaultValue };
@@ -177,7 +267,7 @@ export class Database<T extends SchemaRecord> {
177
267
  return "TEXT";
178
268
  }
179
269
 
180
- if (prop.isBlob) {
270
+ if (prop.isBlob || prop.isVector) {
181
271
  return "BLOB";
182
272
  }
183
273
 
@@ -210,7 +300,7 @@ export class Database<T extends SchemaRecord> {
210
300
 
211
301
  private defaultClause(prop: Prop) {
212
302
  if (prop.generated === "now") {
213
- return "DEFAULT (unixepoch())";
303
+ return "DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER))";
214
304
  }
215
305
 
216
306
  if (prop.defaultValue === undefined || prop.generated === "autoincrement") {
@@ -293,30 +383,16 @@ export class Database<T extends SchemaRecord> {
293
383
  return structureProps.map((p) => this.normalizeProp(p, schema));
294
384
  }
295
385
 
296
- private registerColumns(tableName: string, props: Prop[]) {
297
- this.tableColumns.set(tableName, new Set(props.map((p) => p.key)));
298
-
299
- const colMap = new Map<string, ColumnCoercion>();
300
-
301
- for (const prop of props) {
302
- if (prop.isBoolean) {
303
- colMap.set(prop.key, "boolean");
304
- continue;
305
- }
306
-
307
- if (prop.isDate) {
308
- colMap.set(prop.key, "date");
309
- continue;
310
- }
386
+ private createWriteSchema(schema: Type) {
387
+ const autoIncrementColumns = this.parseSchemaProps(schema)
388
+ .filter((prop) => prop.generated === "autoincrement")
389
+ .map((prop) => prop.key);
311
390
 
312
- if (prop.isJson && prop.jsonSchema) {
313
- colMap.set(prop.key, { type: "json", schema: prop.jsonSchema });
314
- }
391
+ if (autoIncrementColumns.length === 0) {
392
+ return schema;
315
393
  }
316
394
 
317
- if (colMap.size > 0) {
318
- this.columns.set(tableName, colMap);
319
- }
395
+ return (schema as any).omit(...autoIncrementColumns) as Type;
320
396
  }
321
397
 
322
398
  private generateCreateTableSQL(tableName: string, props: Prop[]) {
@@ -338,13 +414,13 @@ export class Database<T extends SchemaRecord> {
338
414
 
339
415
  private migrate() {
340
416
  const desiredTables: DesiredTable[] = [];
417
+ const tableInfos = new Map<string, FtsTableInfo>();
418
+ const vectorAnchors = new Map<string, VectorAnchorInfo>();
341
419
  const schemaIndexes = this.options.schema.indexes;
342
420
 
343
421
  for (const [name, schema] of Object.entries(this.options.schema.tables)) {
344
422
  const props = this.parseSchemaProps(schema);
345
423
 
346
- this.registerColumns(name, props);
347
-
348
424
  const columns = props.map((prop) => {
349
425
  const isNotNull = this.columnConstraint(prop) === "NOT NULL";
350
426
  const defaultClause = this.defaultClause(prop);
@@ -377,12 +453,129 @@ export class Database<T extends SchemaRecord> {
377
453
  columns,
378
454
  indexes,
379
455
  });
456
+
457
+ tableInfos.set(name, this.buildTableInfo(props));
458
+ vectorAnchors.set(name, this.buildVectorAnchorInfo(props));
380
459
  }
381
460
 
382
- const existing = new Introspector(this.sqlite).introspect();
383
- 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();
384
495
 
385
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
+ };
386
579
  }
387
580
 
388
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
+ }