@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 +3 -3
- package/package.json +8 -5
- package/src/database.ts +235 -42
- package/src/dialect/serialize.ts +1 -1
- package/src/env.ts +2 -1
- package/src/fts/resolve.ts +208 -0
- package/src/fts/sql.ts +148 -0
- package/src/fts/tokenizer.ts +27 -0
- package/src/generated.ts +1 -1
- package/src/index.ts +16 -2
- package/src/json.ts +13 -0
- package/src/migration/diff.ts +194 -1
- package/src/migration/execute.ts +42 -0
- package/src/migration/introspect.ts +133 -1
- package/src/migration/types.ts +68 -1
- package/src/plugin.ts +269 -9
- package/src/types.ts +111 -13
- package/src/validation-error.ts +8 -4
- package/src/vector-index/load.ts +67 -0
- package/src/vector-index/nearest.ts +68 -0
- package/src/vector-index/resolve.ts +78 -0
- package/src/vector-index/sql.ts +76 -0
- package/src/vector.ts +13 -0
- package/src/write-validation-plugin.ts +419 -52
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
|
|
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
|
|
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
|
|
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
|
+
"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.
|
|
36
|
-
"@typescript/native-preview": "^7.0.0-dev.
|
|
37
|
-
"oxfmt": "^0.
|
|
38
|
-
"oxlint": "^1.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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<
|
|
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
|
-
|
|
71
|
-
private tableColumns = new Map<string, Set<string>>();
|
|
92
|
+
readonly infer: TablesFromSchemas<T, F, V> = undefined as any;
|
|
72
93
|
|
|
73
|
-
readonly
|
|
94
|
+
readonly kysely: Kysely<TablesFromSchemas<T, F, V>>;
|
|
74
95
|
|
|
75
|
-
readonly
|
|
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(
|
|
93
|
-
new ResultHydrationPlugin(
|
|
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
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
}
|
|
391
|
+
if (autoIncrementColumns.length === 0) {
|
|
392
|
+
return schema;
|
|
315
393
|
}
|
|
316
394
|
|
|
317
|
-
|
|
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
|
|
383
|
-
|
|
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) {
|
package/src/dialect/serialize.ts
CHANGED
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
|
+
}
|