@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.
@@ -1,6 +1,15 @@
1
1
  import type { Database } from "bun:sqlite";
2
2
 
3
- import type { IntrospectedColumn, IntrospectedIndex, IntrospectedTable } from "./types.js";
3
+ import { FTS_TRIGGER_PREFIX } from "../fts/sql.js";
4
+ import { VEC_TRIGGER_PREFIX } from "../vector-index/sql.js";
5
+ import {
6
+ MIGRATIONS_TABLE,
7
+ type IntrospectedColumn,
8
+ type IntrospectedFts,
9
+ type IntrospectedIndex,
10
+ type IntrospectedTable,
11
+ type IntrospectedVectorIndex,
12
+ } from "./types.js";
4
13
 
5
14
  type TableListRow = {
6
15
  name: string;
@@ -12,6 +21,7 @@ type TableXInfoRow = {
12
21
  type: string;
13
22
  notnull: number;
14
23
  dflt_value: string | null;
24
+ hidden: number;
15
25
  };
16
26
 
17
27
  type IndexListRow = {
@@ -31,18 +41,34 @@ type ForeignKeyListRow = {
31
41
  on_delete: string;
32
42
  };
33
43
 
44
+ type MasterRow = {
45
+ name: string;
46
+ sql: string | null;
47
+ };
48
+
34
49
  export class Introspector {
35
50
  constructor(private db: Database) {}
36
51
 
37
52
  introspect() {
38
53
  const tables = new Map<string, IntrospectedTable>();
39
54
  const tableRows = this.db.prepare("PRAGMA table_list").all() as TableListRow[];
55
+ const virtualPrefixes = tableRows
56
+ .filter((r) => r.type === "virtual")
57
+ .map((r) => `${r.name}_`);
40
58
 
41
59
  for (const row of tableRows) {
42
60
  if (row.type !== "table" || row.name.startsWith("sqlite_")) {
43
61
  continue;
44
62
  }
45
63
 
64
+ if (row.name === MIGRATIONS_TABLE) {
65
+ continue;
66
+ }
67
+
68
+ if (virtualPrefixes.some((prefix) => row.name.startsWith(prefix))) {
69
+ continue;
70
+ }
71
+
46
72
  const indexRows = this.db.prepare(`PRAGMA index_list("${row.name}")`).all() as IndexListRow[];
47
73
  const uniqueCols = this.uniqueColumns(indexRows);
48
74
  const fkMap = this.foreignKeys(row.name);
@@ -56,6 +82,117 @@ export class Introspector {
56
82
  return tables;
57
83
  }
58
84
 
85
+ hasVec0Tables() {
86
+ const row = this.db
87
+ .prepare(
88
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND sql LIKE '%USING vec0%' LIMIT 1",
89
+ )
90
+ .get();
91
+
92
+ return row !== null;
93
+ }
94
+
95
+ introspectFts() {
96
+ const fts = new Map<string, IntrospectedFts>();
97
+ const tableRows = this.db.prepare("PRAGMA table_list").all() as TableListRow[];
98
+ const libraryTriggers = this.triggersByPrefix(FTS_TRIGGER_PREFIX);
99
+
100
+ for (const row of tableRows) {
101
+ if (row.type !== "virtual" || row.name.startsWith("sqlite_")) {
102
+ continue;
103
+ }
104
+
105
+ const master = this.db
106
+ .prepare(`SELECT name, sql FROM sqlite_master WHERE name = ? AND type = 'table'`)
107
+ .get(row.name) as MasterRow | null;
108
+
109
+ if (!master?.sql || !/USING\s+fts5/i.test(master.sql)) {
110
+ continue;
111
+ }
112
+
113
+ const colRows = this.db.prepare(`PRAGMA table_xinfo("${row.name}")`).all() as TableXInfoRow[];
114
+ const columns = colRows.filter((c) => c.hidden !== 1).map((c) => c.name);
115
+
116
+ fts.set(row.name, {
117
+ name: row.name,
118
+ columns,
119
+ sql: master.sql,
120
+ triggers: new Map(),
121
+ });
122
+ }
123
+
124
+ this.assignTriggersByPrefix(libraryTriggers, FTS_TRIGGER_PREFIX, fts);
125
+
126
+ return fts;
127
+ }
128
+
129
+ introspectVectorIndexes() {
130
+ const indexes = new Map<string, IntrospectedVectorIndex>();
131
+ const tableRows = this.db.prepare("PRAGMA table_list").all() as TableListRow[];
132
+ const libraryTriggers = this.triggersByPrefix(VEC_TRIGGER_PREFIX);
133
+
134
+ for (const row of tableRows) {
135
+ if (row.type !== "virtual" || row.name.startsWith("sqlite_")) {
136
+ continue;
137
+ }
138
+
139
+ const master = this.db
140
+ .prepare(`SELECT name, sql FROM sqlite_master WHERE name = ? AND type = 'table'`)
141
+ .get(row.name) as MasterRow | null;
142
+
143
+ if (!master?.sql || !/USING\s+vec0/i.test(master.sql)) {
144
+ continue;
145
+ }
146
+
147
+ indexes.set(row.name, {
148
+ name: row.name,
149
+ sql: master.sql,
150
+ triggers: new Map(),
151
+ });
152
+ }
153
+
154
+ this.assignTriggersByPrefix(libraryTriggers, VEC_TRIGGER_PREFIX, indexes);
155
+
156
+ return indexes;
157
+ }
158
+
159
+ private assignTriggersByPrefix(
160
+ triggers: Map<string, string>,
161
+ prefix: string,
162
+ target: Map<string, { triggers: Map<string, string> }>,
163
+ ) {
164
+ const sortedNames = [...target.keys()].sort((a, b) => b.length - a.length);
165
+
166
+ for (const [triggerName, triggerSql] of triggers) {
167
+ const withoutPrefix = triggerName.slice(prefix.length);
168
+
169
+ for (const name of sortedNames) {
170
+ if (withoutPrefix.startsWith(`${name}_`)) {
171
+ target.get(name)!.triggers.set(triggerName, triggerSql);
172
+ break;
173
+ }
174
+ }
175
+ }
176
+ }
177
+
178
+ private triggersByPrefix(prefix: string) {
179
+ const rows = this.db
180
+ .prepare(
181
+ `SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND name LIKE '${prefix}%'`,
182
+ )
183
+ .all() as MasterRow[];
184
+
185
+ const map = new Map<string, string>();
186
+
187
+ for (const row of rows) {
188
+ if (row.sql !== null) {
189
+ map.set(row.name, row.sql);
190
+ }
191
+ }
192
+
193
+ return map;
194
+ }
195
+
59
196
  private uniqueColumns(indexRows: IndexListRow[]) {
60
197
  const unique = new Set<string>();
61
198
 
@@ -0,0 +1,43 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ import { MigrationDriftError } from "./drift-error.js";
4
+ import { MIGRATIONS_TABLE } from "./types.js";
5
+
6
+ export class MigrationRegistry {
7
+ constructor(private db: Database) {}
8
+
9
+ ensureTable() {
10
+ this.db.run(
11
+ `CREATE TABLE IF NOT EXISTS "${MIGRATIONS_TABLE}" ` +
12
+ `("id" TEXT PRIMARY KEY, "checksum" TEXT NOT NULL, "applied_at" INTEGER NOT NULL)`,
13
+ );
14
+ }
15
+
16
+ appliedChecksum(id: string) {
17
+ const row = this.db
18
+ .prepare(`SELECT "checksum" FROM "${MIGRATIONS_TABLE}" WHERE "id" = ?`)
19
+ .get(id) as { checksum: string } | null;
20
+
21
+ return row?.checksum ?? null;
22
+ }
23
+
24
+ assertNoDrift(id: string, checksum: string) {
25
+ const applied = this.appliedChecksum(id);
26
+
27
+ if (applied !== null && applied !== checksum) {
28
+ throw new MigrationDriftError(id);
29
+ }
30
+ }
31
+
32
+ record(id: string, checksum: string) {
33
+ this.db
34
+ .prepare(
35
+ `INSERT INTO "${MIGRATIONS_TABLE}" ("id", "checksum", "applied_at") VALUES (?, ?, ?)`,
36
+ )
37
+ .run(id, checksum, Date.now());
38
+ }
39
+
40
+ static checksum(run: (...args: any[]) => unknown) {
41
+ return new Bun.CryptoHasher("sha256").update(run.toString()).digest("hex");
42
+ }
43
+ }
@@ -1,3 +1,6 @@
1
+ import type { ResolvedFtsPlan } from "../fts/resolve.js";
2
+ import type { ResolvedVectorIndex } from "../vector-index/resolve.js";
3
+
1
4
  export type CreateTableOp = {
2
5
  type: "CreateTable";
3
6
  table: string;
@@ -39,13 +42,75 @@ export type DropIndexOp = {
39
42
  index: string;
40
43
  };
41
44
 
45
+ export type FtsTriggerSpec = {
46
+ name: string;
47
+ sql: string;
48
+ };
49
+
50
+ export type CreateFtsOp = {
51
+ type: "CreateFts";
52
+ name: string;
53
+ createSql: string;
54
+ triggers: FtsTriggerSpec[];
55
+ };
56
+
57
+ export type DropFtsOp = {
58
+ type: "DropFts";
59
+ name: string;
60
+ triggerNames: string[];
61
+ };
62
+
63
+ export type RebuildFtsOp = {
64
+ type: "RebuildFts";
65
+ name: string;
66
+ rebuildSql: string;
67
+ };
68
+
69
+ export type VectorTriggerSpec = {
70
+ name: string;
71
+ sql: string;
72
+ };
73
+
74
+ export type CreateVectorIndexOp = {
75
+ type: "CreateVectorIndex";
76
+ name: string;
77
+ createSql: string;
78
+ triggers: VectorTriggerSpec[];
79
+ };
80
+
81
+ export type DropVectorIndexOp = {
82
+ type: "DropVectorIndex";
83
+ name: string;
84
+ triggerNames: string[];
85
+ };
86
+
87
+ export type RebuildVectorIndexOp = {
88
+ type: "RebuildVectorIndex";
89
+ name: string;
90
+ rebuildSql: string;
91
+ };
92
+
42
93
  export type MigrationOp =
43
94
  | CreateTableOp
44
95
  | DropTableOp
45
96
  | AddColumnOp
46
97
  | RebuildTableOp
47
98
  | CreateIndexOp
48
- | DropIndexOp;
99
+ | DropIndexOp
100
+ | CreateFtsOp
101
+ | DropFtsOp
102
+ | RebuildFtsOp
103
+ | CreateVectorIndexOp
104
+ | DropVectorIndexOp
105
+ | RebuildVectorIndexOp;
106
+
107
+ export const MIGRATIONS_TABLE = "__migrations";
108
+
109
+ export type DestructiveChange = {
110
+ table: string;
111
+ kind: "DropTable" | "DropColumn" | "TypeChange";
112
+ columns?: string[];
113
+ };
49
114
 
50
115
  export type ColumnSchema = {
51
116
  name: string;
@@ -72,3 +137,47 @@ export type IntrospectedTable = {
72
137
  indexes: IntrospectedIndex[];
73
138
  hasData: boolean;
74
139
  };
140
+
141
+ export type IntrospectedFts = {
142
+ name: string;
143
+ columns: string[];
144
+ sql: string;
145
+ triggers: Map<string, string>;
146
+ };
147
+
148
+ export type IntrospectedVectorIndex = {
149
+ name: string;
150
+ sql: string;
151
+ triggers: Map<string, string>;
152
+ };
153
+
154
+ export interface DesiredColumn extends ColumnSchema {
155
+ addable: boolean;
156
+ columnDef: string;
157
+ nullableAddDef: string;
158
+ }
159
+
160
+ export type DesiredIndex = {
161
+ name: string;
162
+ columns: string[];
163
+ sql: string;
164
+ };
165
+
166
+ export type DesiredTable = {
167
+ name: string;
168
+ sql: string;
169
+ columns: DesiredColumn[];
170
+ indexes?: DesiredIndex[];
171
+ };
172
+
173
+ export type DesiredSchema = {
174
+ desiredTables: DesiredTable[];
175
+ desiredFts?: ResolvedFtsPlan[];
176
+ desiredVectorIndexes?: ResolvedVectorIndex[];
177
+ };
178
+
179
+ export type ExistingSchema = {
180
+ existing: Map<string, IntrospectedTable>;
181
+ existingFts?: Map<string, IntrospectedFts>;
182
+ existingVectorIndexes?: Map<string, IntrospectedVectorIndex>;
183
+ };
package/src/plugin.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  type OperationNode,
6
6
  type QueryId,
7
7
  type RootOperationNode,
8
+ type SelectionNode as KyselySelectionNode,
8
9
  type UnknownRow,
9
10
  AggregateFunctionNode,
10
11
  AliasNode,
@@ -12,7 +13,9 @@ import {
12
13
  ColumnNode,
13
14
  IdentifierNode,
14
15
  ParensNode,
16
+ RawNode,
15
17
  ReferenceNode,
18
+ SelectionNode,
16
19
  SelectQueryNode,
17
20
  TableNode,
18
21
  } from "kysely";
@@ -21,7 +24,11 @@ import { JsonParseError } from "./errors.js";
21
24
  import type { StructureProp } from "./types.js";
22
25
  import { ValidationError } from "./validation-error.js";
23
26
 
24
- type ColumnCoercion = "boolean" | "date" | { type: "json"; schema: Type };
27
+ type ColumnCoercion =
28
+ | "boolean"
29
+ | "date"
30
+ | { type: "json"; schema: Type }
31
+ | { type: "vector"; dim: number };
25
32
 
26
33
  type ResolvedCoercion = { table: string; coercion: ColumnCoercion };
27
34
 
@@ -121,7 +128,27 @@ export class ResultHydrationPlugin implements KyselyPlugin {
121
128
  }
122
129
  }
123
130
 
131
+ private extractFieldMeta(prop: StructureProp) {
132
+ const rootMeta = (prop.value as any).meta as
133
+ | { _generated?: string; _vectorDim?: number }
134
+ | undefined;
135
+ const branchWithMeta = prop.value.branches.find(
136
+ (b) => (b as any).meta && Object.keys((b as any).meta).length > 0,
137
+ );
138
+ const fromBranch = (branchWithMeta as any)?.meta as
139
+ | { _generated?: string; _vectorDim?: number }
140
+ | undefined;
141
+
142
+ return { ...fromBranch, ...rootMeta };
143
+ }
144
+
124
145
  private getColumnCoercion(prop: StructureProp, parentSchema: Type) {
146
+ const meta = this.extractFieldMeta(prop);
147
+
148
+ if (meta._generated === "vector" && typeof meta._vectorDim === "number") {
149
+ return { type: "vector", dim: meta._vectorDim } satisfies ColumnCoercion;
150
+ }
151
+
125
152
  const concrete = prop.value.branches.filter(
126
153
  (branch) => branch.unit !== null && branch.domain !== "undefined",
127
154
  );
@@ -150,9 +177,151 @@ export class ResultHydrationPlugin implements KyselyPlugin {
150
177
  selectionPlans: this.getSelectionPlans(args.node),
151
178
  });
152
179
 
153
- return args.node;
180
+ return this.rewriteVectorsInJsonHelpers(args.node);
154
181
  };
155
182
 
183
+ private rewriteVectorsInJsonHelpers(node: RootOperationNode): RootOperationNode {
184
+ if (!SelectQueryNode.is(node)) {
185
+ return node;
186
+ }
187
+
188
+ return this.rewriteSelectQueryNode(node) as RootOperationNode;
189
+ }
190
+
191
+ private rewriteSelectQueryNode(node: SelectQueryNode): SelectQueryNode {
192
+ if (!node.selections) {
193
+ return node;
194
+ }
195
+
196
+ let changed = false;
197
+
198
+ const newSelections = node.selections.map((sel) => {
199
+ const rewritten = this.rewriteTopLevelSelection(sel);
200
+
201
+ if (rewritten !== sel) {
202
+ changed = true;
203
+ }
204
+
205
+ return rewritten;
206
+ });
207
+
208
+ if (!changed) {
209
+ return node;
210
+ }
211
+
212
+ return Object.freeze({ ...node, selections: Object.freeze(newSelections) });
213
+ }
214
+
215
+ private rewriteTopLevelSelection(sel: KyselySelectionNode): KyselySelectionNode {
216
+ const rewritten = this.rewriteNodeForJsonHelpers(sel.selection);
217
+
218
+ if (rewritten === sel.selection) {
219
+ return sel;
220
+ }
221
+
222
+ return SelectionNode.create(rewritten as any);
223
+ }
224
+
225
+ private rewriteNodeForJsonHelpers(node: OperationNode): OperationNode {
226
+ if (AliasNode.is(node)) {
227
+ const rewrittenInner = this.rewriteNodeForJsonHelpers(node.node);
228
+
229
+ if (rewrittenInner === node.node) {
230
+ return node;
231
+ }
232
+
233
+ return AliasNode.create(rewrittenInner, node.alias as any);
234
+ }
235
+
236
+ if (!this.isRawNode(node)) {
237
+ return node;
238
+ }
239
+
240
+ const helper = this.getJsonHelper(node);
241
+
242
+ if (!helper) {
243
+ return node;
244
+ }
245
+
246
+ return this.rewriteJsonHelper(node, helper.query);
247
+ }
248
+
249
+ private rewriteJsonHelper(rawNode: RawOperationNode, subquery: SelectQueryNode) {
250
+ const scope = this.getTableScope(subquery);
251
+
252
+ if (!subquery.selections) {
253
+ return rawNode;
254
+ }
255
+
256
+ let changed = false;
257
+
258
+ const newSubquerySelections = subquery.selections.map((sel) => {
259
+ const rewrittenNested = this.rewriteNodeForJsonHelpers(sel.selection);
260
+ const withNested =
261
+ rewrittenNested === sel.selection
262
+ ? sel
263
+ : SelectionNode.create(rewrittenNested as any);
264
+
265
+ if (withNested !== sel) {
266
+ changed = true;
267
+ }
268
+
269
+ const wrapped = this.wrapVectorSelectionWithHex(withNested, scope);
270
+
271
+ if (wrapped !== withNested) {
272
+ changed = true;
273
+ }
274
+
275
+ return wrapped;
276
+ });
277
+
278
+ if (!changed) {
279
+ return rawNode;
280
+ }
281
+
282
+ const newSubquery = Object.freeze({
283
+ ...subquery,
284
+ selections: Object.freeze(newSubquerySelections),
285
+ });
286
+ const newParameters = [...rawNode.parameters];
287
+ newParameters[1] = newSubquery;
288
+
289
+ return Object.freeze({
290
+ ...rawNode,
291
+ parameters: Object.freeze(newParameters),
292
+ });
293
+ }
294
+
295
+ private wrapVectorSelectionWithHex(sel: KyselySelectionNode, scope: Map<string, string>) {
296
+ const selectionNode = sel.selection;
297
+ const output = this.getSelectionOutputName(selectionNode);
298
+
299
+ if (!output) {
300
+ return sel;
301
+ }
302
+
303
+ const inner = AliasNode.is(selectionNode) ? selectionNode.node : selectionNode;
304
+
305
+ if (!ReferenceNode.is(inner) && !ColumnNode.is(inner)) {
306
+ return sel;
307
+ }
308
+
309
+ const resolved = this.resolveReferenceCoercion(inner, scope);
310
+
311
+ if (!resolved) {
312
+ return sel;
313
+ }
314
+
315
+ if (typeof resolved.coercion !== "object" || resolved.coercion.type !== "vector") {
316
+ return sel;
317
+ }
318
+
319
+ const hexNode = RawNode.create(["hex(", ")"], [inner]);
320
+ const aliased = AliasNode.create(hexNode, IdentifierNode.create(output));
321
+
322
+ return SelectionNode.create(aliased);
323
+ }
324
+
156
325
  private getTableFromNode(node: RootOperationNode) {
157
326
  switch (node.kind) {
158
327
  case "InsertQueryNode":
@@ -225,12 +394,16 @@ export class ResultHydrationPlugin implements KyselyPlugin {
225
394
 
226
395
  if (plan.coercion === "date") {
227
396
  if (typeof value === "number") {
228
- return new Date(value * 1000);
397
+ return new Date(value);
229
398
  }
230
399
 
231
400
  return value;
232
401
  }
233
402
 
403
+ if (plan.coercion.type === "vector") {
404
+ return this.hydrateVector(value);
405
+ }
406
+
234
407
  const parsed =
235
408
  typeof value === "string" ? this.parseJson(plan.table, plan.column, value) : value;
236
409
 
@@ -241,6 +414,36 @@ export class ResultHydrationPlugin implements KyselyPlugin {
241
414
  return parsed;
242
415
  }
243
416
 
417
+ private hydrateVector(value: unknown) {
418
+ if (value instanceof Float32Array) {
419
+ return value;
420
+ }
421
+
422
+ if (value instanceof Uint8Array) {
423
+ return new Float32Array(
424
+ value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength),
425
+ );
426
+ }
427
+
428
+ if (typeof value === "string") {
429
+ return this.hexToFloat32Array(value);
430
+ }
431
+
432
+ return value;
433
+ }
434
+
435
+ private hexToFloat32Array(hex: string) {
436
+ const byteLength = hex.length / 2;
437
+ const buffer = new ArrayBuffer(byteLength);
438
+ const bytes = new Uint8Array(buffer);
439
+
440
+ for (let i = 0; i < byteLength; i++) {
441
+ bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
442
+ }
443
+
444
+ return new Float32Array(buffer);
445
+ }
446
+
244
447
  private parseStructuredValue(table: string, column: string, value: unknown) {
245
448
  if (value === null || value === undefined) {
246
449
  return value;