@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,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
+ }
package/src/fts/sql.ts ADDED
@@ -0,0 +1,148 @@
1
+ import type { ResolvedFtsPlan } from "./resolve.js";
2
+ import { tokenizeOption } from "./tokenizer.js";
3
+
4
+ export const FTS_TRIGGER_PREFIX = "__fts_sync_";
5
+
6
+ export type FtsTriggerEvent = "AI" | "AU" | "AD";
7
+
8
+ export function ftsTriggerName(fts: string, sourceTable: string, event: FtsTriggerEvent) {
9
+ return `${FTS_TRIGGER_PREFIX}${fts}_${sourceTable}_${event}`;
10
+ }
11
+
12
+ export type GeneratedTrigger = { name: string; sql: string };
13
+
14
+ export function generateCreateFtsSql(plan: ResolvedFtsPlan) {
15
+ const cols = plan.fields.map((f) => `"${f.name}"`).join(", ");
16
+ const tokenize = tokenizeOption(plan.tokenizer);
17
+
18
+ return `CREATE VIRTUAL TABLE "${plan.name}" USING fts5(${cols}, ${tokenize})`;
19
+ }
20
+
21
+ function insertValueFor(field: ResolvedFtsPlan["fields"][number], anchor: string) {
22
+ if (field.sourceTable === anchor) {
23
+ return `COALESCE(NEW."${field.sourceColumn}", '')`;
24
+ }
25
+
26
+ return `''`;
27
+ }
28
+
29
+ function anchorTriggers(plan: ResolvedFtsPlan): GeneratedTrigger[] {
30
+ const fts = plan.name;
31
+ const anchor = plan.anchor;
32
+ const pk = plan.anchorPkColumn;
33
+
34
+ const allCols = plan.fields.map((f) => `"${f.name}"`).join(", ");
35
+ const insertValues = plan.fields.map((f) => insertValueFor(f, anchor)).join(", ");
36
+
37
+ const anchorFields = plan.fields.filter((f) => f.sourceTable === anchor);
38
+
39
+ const aiName = ftsTriggerName(fts, anchor, "AI");
40
+ const adName = ftsTriggerName(fts, anchor, "AD");
41
+
42
+ const triggers: GeneratedTrigger[] = [
43
+ {
44
+ name: aiName,
45
+ sql:
46
+ `CREATE TRIGGER "${aiName}" AFTER INSERT ON "${anchor}" BEGIN ` +
47
+ `INSERT INTO "${fts}"(rowid, ${allCols}) VALUES (NEW."${pk}", ${insertValues}); ` +
48
+ `END`,
49
+ },
50
+ {
51
+ name: adName,
52
+ sql:
53
+ `CREATE TRIGGER "${adName}" AFTER DELETE ON "${anchor}" BEGIN ` +
54
+ `DELETE FROM "${fts}" WHERE rowid = OLD."${pk}"; ` +
55
+ `END`,
56
+ },
57
+ ];
58
+
59
+ if (anchorFields.length > 0) {
60
+ const trackedCols = anchorFields.map((f) => `"${f.sourceColumn}"`).join(", ");
61
+ const setClause = anchorFields
62
+ .map((f) => `"${f.name}" = COALESCE(NEW."${f.sourceColumn}", '')`)
63
+ .join(", ");
64
+ const auName = ftsTriggerName(fts, anchor, "AU");
65
+
66
+ triggers.push({
67
+ name: auName,
68
+ sql:
69
+ `CREATE TRIGGER "${auName}" AFTER UPDATE OF ${trackedCols} ON "${anchor}" BEGIN ` +
70
+ `UPDATE "${fts}" SET ${setClause} WHERE rowid = NEW."${pk}"; ` +
71
+ `END`,
72
+ });
73
+ }
74
+
75
+ return triggers;
76
+ }
77
+
78
+ function sourceTriggers(plan: ResolvedFtsPlan): GeneratedTrigger[] {
79
+ const triggers: GeneratedTrigger[] = [];
80
+ const fts = plan.name;
81
+
82
+ for (const source of plan.sources) {
83
+ const fields = plan.fields.filter((f) => f.sourceTable === source.table);
84
+
85
+ if (fields.length === 0) {
86
+ continue;
87
+ }
88
+
89
+ const trackedCols = fields.map((f) => `"${f.sourceColumn}"`).join(", ");
90
+ const setNew = fields
91
+ .map((f) => `"${f.name}" = COALESCE(NEW."${f.sourceColumn}", '')`)
92
+ .join(", ");
93
+ const clearSet = fields.map((f) => `"${f.name}" = ''`).join(", ");
94
+
95
+ const aiName = ftsTriggerName(fts, source.table, "AI");
96
+ const auName = ftsTriggerName(fts, source.table, "AU");
97
+ const adName = ftsTriggerName(fts, source.table, "AD");
98
+
99
+ triggers.push({
100
+ name: aiName,
101
+ sql:
102
+ `CREATE TRIGGER "${aiName}" AFTER INSERT ON "${source.table}" BEGIN ` +
103
+ `UPDATE "${fts}" SET ${setNew} WHERE rowid = NEW."${source.fkColumn}"; ` +
104
+ `END`,
105
+ });
106
+
107
+ triggers.push({
108
+ name: auName,
109
+ sql:
110
+ `CREATE TRIGGER "${auName}" AFTER UPDATE OF ${trackedCols} ON "${source.table}" BEGIN ` +
111
+ `UPDATE "${fts}" SET ${setNew} WHERE rowid = NEW."${source.fkColumn}"; ` +
112
+ `END`,
113
+ });
114
+
115
+ triggers.push({
116
+ name: adName,
117
+ sql:
118
+ `CREATE TRIGGER "${adName}" AFTER DELETE ON "${source.table}" BEGIN ` +
119
+ `UPDATE "${fts}" SET ${clearSet} WHERE rowid = OLD."${source.fkColumn}"; ` +
120
+ `END`,
121
+ });
122
+ }
123
+
124
+ return triggers;
125
+ }
126
+
127
+ export function generateFtsTriggers(plan: ResolvedFtsPlan) {
128
+ return [...anchorTriggers(plan), ...sourceTriggers(plan)];
129
+ }
130
+
131
+ export function generateRebuildSelect(plan: ResolvedFtsPlan) {
132
+ const cols = plan.fields.map((f) => `"${f.name}"`).join(", ");
133
+ const selectExprs = plan.fields
134
+ .map((f) => `COALESCE("${f.sourceTable}"."${f.sourceColumn}", '')`)
135
+ .join(", ");
136
+ const joins = plan.sources
137
+ .map(
138
+ (s) =>
139
+ `LEFT JOIN "${s.table}" ON "${s.table}"."${s.fkColumn}" = "${plan.anchor}"."${plan.anchorPkColumn}"`,
140
+ )
141
+ .join(" ");
142
+
143
+ const from = joins
144
+ ? `FROM "${plan.anchor}" ${joins}`
145
+ : `FROM "${plan.anchor}"`;
146
+
147
+ return `INSERT INTO "${plan.name}"(rowid, ${cols}) SELECT "${plan.anchor}"."${plan.anchorPkColumn}", ${selectExprs} ${from}`;
148
+ }
@@ -0,0 +1,27 @@
1
+ import type { FtsTokenizer } from "../types.js";
2
+
3
+ export const DEFAULT_TOKENIZER: FtsTokenizer = { type: "unicode61", removeDiacritics: 2 };
4
+
5
+ export function tokenizeClause(tokenizer: FtsTokenizer) {
6
+ if (tokenizer.type === "trigram") {
7
+ return "trigram";
8
+ }
9
+
10
+ const parts: string[] = ["unicode61"];
11
+
12
+ if (tokenizer.removeDiacritics !== undefined) {
13
+ parts.push(`remove_diacritics ${tokenizer.removeDiacritics}`);
14
+ }
15
+
16
+ if (tokenizer.categories) {
17
+ parts.push(`categories '${tokenizer.categories}'`);
18
+ }
19
+
20
+ return parts.join(" ");
21
+ }
22
+
23
+ export function tokenizeOption(tokenizer: FtsTokenizer | undefined) {
24
+ const clause = tokenizeClause(tokenizer ?? DEFAULT_TOKENIZER);
25
+
26
+ return `tokenize = '${clause.replaceAll("'", "''")}'`;
27
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  export { Database } from "./database.js";
2
2
  export { generated, type GeneratedPreset } from "./generated.js";
3
+ export { vector } from "./vector.js";
4
+ export { nearest, type NearestOptions } from "./vector-index/nearest.js";
5
+ export { ExtensionLoadError } from "./vector-index/load.js";
3
6
  export { JsonParseError } from "./errors.js";
4
- export { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
7
+ export { DataLossError } from "./migration/data-loss-error.js";
8
+ export { MigrationDriftError } from "./migration/drift-error.js";
9
+ export { jsonArrayFrom, jsonObjectFrom } from "./json.js";
5
10
  export {
6
11
  sql,
7
12
  type Selectable,
@@ -15,7 +20,9 @@ export { configure } from "arktype/config";
15
20
  export { ValidationError } from "./validation-error.js";
16
21
  export type { DbFieldMeta } from "./env.js";
17
22
  export type {
23
+ DataMigration,
18
24
  DatabaseOptions,
25
+ DatabaseExtension,
19
26
  SchemaRecord,
20
27
  TablesFromSchemas,
21
28
  InferTableType,
@@ -25,4 +32,14 @@ export type {
25
32
  DatabaseSchema,
26
33
  JsonValidation,
27
34
  SqliteMasterRow,
35
+ FtsTokenizer,
36
+ FtsConfig,
37
+ FtsEntry,
38
+ FtsFieldRef,
39
+ FtsRowType,
40
+ FtsApi,
41
+ VectorIndexApi,
42
+ VectorIndexConfig,
43
+ VectorIndexEntry,
44
+ VectorMetric,
28
45
  } from "./types.js";
package/src/json.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { RawBuilder, Simplify } from "kysely";
2
+ import {
3
+ jsonArrayFrom as baseJsonArrayFrom,
4
+ jsonObjectFrom as baseJsonObjectFrom,
5
+ } from "kysely/helpers/sqlite";
6
+
7
+ export const jsonArrayFrom: <O>(
8
+ expr: Parameters<typeof baseJsonArrayFrom<O>>[0],
9
+ ) => RawBuilder<Simplify<O>[]> = baseJsonArrayFrom as never;
10
+
11
+ export const jsonObjectFrom: <O>(
12
+ expr: Parameters<typeof baseJsonObjectFrom<O>>[0],
13
+ ) => RawBuilder<Simplify<O> | null> = baseJsonObjectFrom as never;
@@ -0,0 +1,29 @@
1
+ import type { DestructiveChange } from "./types.js";
2
+
3
+ export class DataLossError extends Error {
4
+ constructor(readonly changes: DestructiveChange[]) {
5
+ super(
6
+ `Refusing to apply a migration that loses data (failOnDataLoss is on):\n${changes
7
+ .map(formatChange)
8
+ .map((line) => ` - ${line}`)
9
+ .join("\n")}`,
10
+ );
11
+ this.name = "DataLossError";
12
+ }
13
+ }
14
+
15
+ function formatChange(change: DestructiveChange) {
16
+ const columns = (change.columns ?? []).map((c) => `"${c}"`).join(", ");
17
+
18
+ switch (change.kind) {
19
+ case "DropTable": {
20
+ return `drop table "${change.table}"`;
21
+ }
22
+ case "DropColumn": {
23
+ return `drop column ${columns} from "${change.table}"`;
24
+ }
25
+ case "TypeChange": {
26
+ return `change type of column ${columns} in "${change.table}" (discards data)`;
27
+ }
28
+ }
29
+ }