@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.
@@ -0,0 +1,78 @@
1
+ import type { VectorIndexEntry, VectorMetric } from "../types.js";
2
+
3
+ export type VectorAnchorInfo = {
4
+ columns: Set<string>;
5
+ pkColumn: string | null;
6
+ pkIsInteger: boolean;
7
+ vectorColumns: Map<string, number>;
8
+ };
9
+
10
+ export type ResolvedVectorIndex = {
11
+ name: string;
12
+ anchor: string;
13
+ anchorPkColumn: string;
14
+ column: string;
15
+ dim: number;
16
+ metric: VectorMetric;
17
+ };
18
+
19
+ const DEFAULT_METRIC: VectorMetric = "cosine";
20
+
21
+ const VALID_METRICS = new Set<VectorMetric>(["cosine", "l2", "l1"]);
22
+
23
+ export function resolveVectorIndex(
24
+ name: string,
25
+ entry: VectorIndexEntry<any>,
26
+ anchors: Map<string, VectorAnchorInfo>,
27
+ ) {
28
+ const anchor = anchors.get(entry.anchor);
29
+
30
+ if (!anchor) {
31
+ throw new Error(
32
+ `VectorIndex "${name}": anchor table "${entry.anchor}" is not declared in schema`,
33
+ );
34
+ }
35
+
36
+ if (!anchor.pkColumn) {
37
+ throw new Error(
38
+ `VectorIndex "${name}": anchor table "${entry.anchor}" has no primary key; anchor PK must be an integer`,
39
+ );
40
+ }
41
+
42
+ if (!anchor.pkIsInteger) {
43
+ throw new Error(
44
+ `VectorIndex "${name}": anchor table "${entry.anchor}" primary key "${anchor.pkColumn}" must be an integer`,
45
+ );
46
+ }
47
+
48
+ if (!anchor.columns.has(entry.column)) {
49
+ throw new Error(
50
+ `VectorIndex "${name}": column "${entry.column}" does not exist on anchor "${entry.anchor}"`,
51
+ );
52
+ }
53
+
54
+ const dim = anchor.vectorColumns.get(entry.column);
55
+
56
+ if (dim === undefined) {
57
+ throw new Error(
58
+ `VectorIndex "${name}": column "${entry.anchor}.${entry.column}" is not a vector column`,
59
+ );
60
+ }
61
+
62
+ const metric = entry.metric ?? DEFAULT_METRIC;
63
+
64
+ if (!VALID_METRICS.has(metric)) {
65
+ throw new Error(
66
+ `VectorIndex "${name}": unknown metric "${metric}", expected one of cosine, l2, l1`,
67
+ );
68
+ }
69
+
70
+ return {
71
+ name,
72
+ anchor: entry.anchor,
73
+ anchorPkColumn: anchor.pkColumn,
74
+ column: entry.column,
75
+ dim,
76
+ metric,
77
+ };
78
+ }
@@ -0,0 +1,76 @@
1
+ import type { ResolvedVectorIndex } from "./resolve.js";
2
+
3
+ export const VEC_TRIGGER_PREFIX = "__vec_sync_";
4
+
5
+ export type VecTriggerEvent = "AI" | "AU" | "AD";
6
+
7
+ export function vecTriggerName(index: string, anchor: string, event: VecTriggerEvent) {
8
+ return `${VEC_TRIGGER_PREFIX}${index}_${anchor}_${event}`;
9
+ }
10
+
11
+ export type GeneratedTrigger = { name: string; sql: string };
12
+
13
+ const VALID_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
14
+
15
+ export function generateCreateVecSql(index: ResolvedVectorIndex) {
16
+ if (!VALID_IDENTIFIER.test(index.column)) {
17
+ throw new Error(
18
+ `VectorIndex "${index.name}": column "${index.column}" must be a simple identifier (vec0 does not accept quoted column names)`,
19
+ );
20
+ }
21
+
22
+ return (
23
+ `CREATE VIRTUAL TABLE "${index.name}" USING vec0(` +
24
+ `rowid INTEGER PRIMARY KEY, ` +
25
+ `${index.column} float[${index.dim}] distance_metric=${index.metric}` +
26
+ `)`
27
+ );
28
+ }
29
+
30
+ export function generateVectorTriggers(index: ResolvedVectorIndex): GeneratedTrigger[] {
31
+ const anchor = index.anchor;
32
+ const pk = index.anchorPkColumn;
33
+ const col = index.column;
34
+ const vec = index.name;
35
+
36
+ const aiName = vecTriggerName(index.name, anchor, "AI");
37
+ const auName = vecTriggerName(index.name, anchor, "AU");
38
+ const adName = vecTriggerName(index.name, anchor, "AD");
39
+
40
+ return [
41
+ {
42
+ name: aiName,
43
+ sql:
44
+ `CREATE TRIGGER "${aiName}" AFTER INSERT ON "${anchor}" ` +
45
+ `WHEN NEW."${col}" IS NOT NULL BEGIN ` +
46
+ `INSERT INTO "${vec}"(rowid, "${col}") VALUES (NEW."${pk}", NEW."${col}"); ` +
47
+ `END`,
48
+ },
49
+ {
50
+ name: auName,
51
+ sql:
52
+ `CREATE TRIGGER "${auName}" AFTER UPDATE OF "${col}" ON "${anchor}" BEGIN ` +
53
+ `DELETE FROM "${vec}" WHERE rowid = OLD."${pk}" AND NEW."${col}" IS NULL; ` +
54
+ `INSERT INTO "${vec}"(rowid, "${col}") ` +
55
+ `SELECT NEW."${pk}", NEW."${col}" WHERE OLD."${col}" IS NULL AND NEW."${col}" IS NOT NULL; ` +
56
+ `UPDATE "${vec}" SET "${col}" = NEW."${col}" ` +
57
+ `WHERE rowid = NEW."${pk}" AND OLD."${col}" IS NOT NULL AND NEW."${col}" IS NOT NULL; ` +
58
+ `END`,
59
+ },
60
+ {
61
+ name: adName,
62
+ sql:
63
+ `CREATE TRIGGER "${adName}" AFTER DELETE ON "${anchor}" BEGIN ` +
64
+ `DELETE FROM "${vec}" WHERE rowid = OLD."${pk}"; ` +
65
+ `END`,
66
+ },
67
+ ];
68
+ }
69
+
70
+ export function generateRebuildVectorSql(index: ResolvedVectorIndex) {
71
+ return (
72
+ `INSERT INTO "${index.name}"(rowid, "${index.column}") ` +
73
+ `SELECT "${index.anchorPkColumn}", "${index.column}" FROM "${index.anchor}" ` +
74
+ `WHERE "${index.column}" IS NOT NULL`
75
+ );
76
+ }
package/src/vector.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { type } from "arktype";
2
+
3
+ export function vector(dim: number) {
4
+ if (!Number.isInteger(dim) || dim <= 0) {
5
+ throw new Error(`vector(dim) requires a positive integer dim, got ${dim}`);
6
+ }
7
+
8
+ return type
9
+ .instanceOf(Float32Array)
10
+ .or(type("number[]"))
11
+ .pipe((v) => (v instanceof Float32Array ? v : Float32Array.from(v)))
12
+ .configure({ _generated: "vector", _vectorDim: dim });
13
+ }
@@ -30,6 +30,7 @@ type TableWriteSchema = {
30
30
  columnSet: Set<string>;
31
31
  optionalNonNullColumns: Set<string>;
32
32
  insertNullColumns: Set<string>;
33
+ vectorColumns: Map<string, number>;
33
34
  };
34
35
 
35
36
  type InsertRow = {
@@ -57,6 +58,16 @@ export class WriteValidationPlugin implements KyselyPlugin {
57
58
  const structureProps = (schema as any).structure?.props as StructureProp[] | undefined;
58
59
  const columns = structureProps?.map((prop) => prop.key) ?? [];
59
60
 
61
+ const vectorColumns = new Map<string, number>();
62
+
63
+ for (const prop of structureProps ?? []) {
64
+ const meta = this.extractFieldMeta(prop);
65
+
66
+ if (meta?._generated === "vector" && typeof meta._vectorDim === "number") {
67
+ vectorColumns.set(prop.key, meta._vectorDim);
68
+ }
69
+ }
70
+
60
71
  this.schemas.set(table, {
61
72
  schema,
62
73
  columns,
@@ -71,14 +82,91 @@ export class WriteValidationPlugin implements KyselyPlugin {
71
82
  ?.filter((prop) => this.acceptsNull(prop.value) && prop.inner.default === undefined)
72
83
  .map((prop) => prop.key) ?? [],
73
84
  ),
85
+ vectorColumns,
74
86
  });
75
87
  }
76
88
  }
77
89
 
90
+ private validateAndCoerceVector(
91
+ table: string,
92
+ column: string,
93
+ dim: number,
94
+ value: unknown,
95
+ ): Uint8Array {
96
+ const vec =
97
+ value instanceof Float32Array
98
+ ? value
99
+ : Array.isArray(value)
100
+ ? Float32Array.from(value as number[])
101
+ : null;
102
+
103
+ if (!vec) {
104
+ throw new ValidationError(
105
+ table,
106
+ `expected Float32Array or number[] for vector column, got ${typeof value}`,
107
+ column,
108
+ );
109
+ }
110
+
111
+ if (vec.length !== dim) {
112
+ throw new ValidationError(
113
+ table,
114
+ `vector dim mismatch: expected ${dim}, received ${vec.length}`,
115
+ column,
116
+ );
117
+ }
118
+
119
+ for (let i = 0; i < vec.length; i++) {
120
+ if (!Number.isFinite(vec[i]!)) {
121
+ throw new ValidationError(
122
+ table,
123
+ `non-finite vector element at index ${i}: ${vec[i]}`,
124
+ column,
125
+ );
126
+ }
127
+ }
128
+
129
+ return new Uint8Array(vec.buffer, vec.byteOffset, vec.byteLength);
130
+ }
131
+
132
+ private coerceVectorsInRow(
133
+ table: string,
134
+ tableSchema: TableWriteSchema,
135
+ values: Record<string, unknown>,
136
+ ) {
137
+ for (const [column, dim] of tableSchema.vectorColumns) {
138
+ if (!Object.prototype.hasOwnProperty.call(values, column)) {
139
+ continue;
140
+ }
141
+
142
+ const value = values[column];
143
+
144
+ if (value === null || value === undefined) {
145
+ continue;
146
+ }
147
+
148
+ values[column] = this.validateAndCoerceVector(table, column, dim, value);
149
+ }
150
+ }
151
+
78
152
  private acceptsNull(field: StructureProp["value"]) {
79
153
  return field.branches.some((branch) => branch.unit === null || branch.domain === "null");
80
154
  }
81
155
 
156
+ private extractFieldMeta(prop: StructureProp) {
157
+ const rootMeta = (prop.value as any).meta as
158
+ | { _generated?: string; _vectorDim?: number }
159
+ | undefined;
160
+ const branchMeta = prop.value.branches.find(
161
+ (b) => (b as any).meta && Object.keys((b as any).meta).length > 0,
162
+ );
163
+ const fromBranch = (branchMeta as any)?.meta as
164
+ | { _generated?: string; _vectorDim?: number }
165
+ | undefined;
166
+
167
+ return { ...fromBranch, ...rootMeta };
168
+ }
169
+
82
170
  private getTableFromNode(node: RootOperationNode) {
83
171
  switch (node.kind) {
84
172
  case "InsertQueryNode":
@@ -219,6 +307,8 @@ export class WriteValidationPlugin implements KyselyPlugin {
219
307
  this.prepareInsertValue(tableSchema, schemaLiteralValues),
220
308
  );
221
309
 
310
+ this.coerceVectorsInRow(table, tableSchema, morphedSchemaValues);
311
+
222
312
  return {
223
313
  values: { ...nonSchemaLiteralValues, ...morphedSchemaValues },
224
314
  passthrough: row.passthrough,
@@ -364,6 +454,8 @@ export class WriteValidationPlugin implements KyselyPlugin {
364
454
  this.stripNullOptionalFields(tableSchema, literalValues),
365
455
  );
366
456
 
457
+ this.coerceVectorsInRow(table, tableSchema, morphed);
458
+
367
459
  return updates.flatMap((update) => {
368
460
  if (!ColumnNode.is(update.column) || !ValueNode.is(update.value)) {
369
461
  return [update];