@typicalday/firegraph 0.15.0 → 0.16.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.
Files changed (57) hide show
  1. package/README.md +39 -17
  2. package/dist/{backend-CvImIwTY.d.cts → backend-CE3pM9-T.d.ts} +32 -2
  3. package/dist/{backend-BpYLdwCW.d.cts → backend-DNzv8KSR.d.cts} +33 -19
  4. package/dist/{backend-BpYLdwCW.d.ts → backend-DNzv8KSR.d.ts} +33 -19
  5. package/dist/{backend-YH5HtawN.d.ts → backend-EjFfw9yO.d.cts} +32 -2
  6. package/dist/backend.cjs.map +1 -1
  7. package/dist/backend.d.cts +2 -2
  8. package/dist/backend.d.ts +2 -2
  9. package/dist/backend.js +1 -1
  10. package/dist/{chunk-FODIMIWY.js → chunk-5JBNLH5W.js} +17 -6
  11. package/dist/chunk-5JBNLH5W.js.map +1 -0
  12. package/dist/{chunk-5HIRYV2S.js → chunk-6IO74NKD.js} +12 -10
  13. package/dist/{chunk-5HIRYV2S.js.map → chunk-6IO74NKD.js.map} +1 -1
  14. package/dist/{chunk-ULRDQ6HZ.js → chunk-NZVSLWNY.js} +6 -1
  15. package/dist/chunk-NZVSLWNY.js.map +1 -0
  16. package/dist/{chunk-N5HFDWQX.js → chunk-PWIO46RT.js} +1 -1
  17. package/dist/{chunk-N5HFDWQX.js.map → chunk-PWIO46RT.js.map} +1 -1
  18. package/dist/{client-B5o39X79.d.ts → client-CNAwJayO.d.ts} +1 -1
  19. package/dist/{client-BGHwxwPg.d.cts → client-CaXH5D5C.d.cts} +1 -1
  20. package/dist/cloudflare/index.cjs +11 -9
  21. package/dist/cloudflare/index.cjs.map +1 -1
  22. package/dist/cloudflare/index.d.cts +3 -3
  23. package/dist/cloudflare/index.d.ts +3 -3
  24. package/dist/cloudflare/index.js +3 -3
  25. package/dist/codegen/index.d.cts +1 -1
  26. package/dist/codegen/index.d.ts +1 -1
  27. package/dist/firestore-enterprise/index.cjs +11 -9
  28. package/dist/firestore-enterprise/index.cjs.map +1 -1
  29. package/dist/firestore-enterprise/index.d.cts +3 -3
  30. package/dist/firestore-enterprise/index.d.ts +3 -3
  31. package/dist/firestore-enterprise/index.js +2 -2
  32. package/dist/firestore-standard/index.cjs +11 -9
  33. package/dist/firestore-standard/index.cjs.map +1 -1
  34. package/dist/firestore-standard/index.d.cts +3 -3
  35. package/dist/firestore-standard/index.d.ts +3 -3
  36. package/dist/firestore-standard/index.js +2 -2
  37. package/dist/index.cjs +11 -9
  38. package/dist/index.cjs.map +1 -1
  39. package/dist/index.d.cts +4 -4
  40. package/dist/index.d.ts +4 -4
  41. package/dist/index.js +1 -1
  42. package/dist/{registry-tKTb5Kx1.d.ts → registry-By1i-zge.d.ts} +1 -1
  43. package/dist/{registry-BGh7Jqpb.d.cts → registry-CNToyEra.d.cts} +1 -1
  44. package/dist/sqlite/index.cjs +24 -12
  45. package/dist/sqlite/index.cjs.map +1 -1
  46. package/dist/sqlite/index.d.cts +4 -4
  47. package/dist/sqlite/index.d.ts +4 -4
  48. package/dist/sqlite/index.js +4 -4
  49. package/dist/sqlite/local.cjs +484 -47
  50. package/dist/sqlite/local.cjs.map +1 -1
  51. package/dist/sqlite/local.d.cts +31 -5
  52. package/dist/sqlite/local.d.ts +31 -5
  53. package/dist/sqlite/local.js +439 -4
  54. package/dist/sqlite/local.js.map +1 -1
  55. package/package.json +1 -1
  56. package/dist/chunk-FODIMIWY.js.map +0 -1
  57. package/dist/chunk-ULRDQ6HZ.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  import { Database } from 'better-sqlite3';
2
- import { S as StorageBackend } from '../backend-BpYLdwCW.cjs';
3
- import { a as SqliteCapability, S as SqliteBackendOptions, b as SqliteExecutor } from '../backend-CvImIwTY.cjs';
2
+ import { S as StorageBackend } from '../backend-DNzv8KSR.cjs';
3
+ import { a as SqliteCapability, S as SqliteBackendOptions, b as SqliteExecutor } from '../backend-EjFfw9yO.cjs';
4
4
  import '@google-cloud/firestore';
5
5
 
6
6
  /**
@@ -17,8 +17,34 @@ import '@google-cloud/firestore';
17
17
  * `journal_mode = WAL` and a `busy_timeout` applied; caller-provided
18
18
  * databases are used as-is (only `busy_timeout` is set) since the caller
19
19
  * owns their pragma configuration.
20
+ *
21
+ * ## Search capabilities
22
+ *
23
+ * On top of the shared SQLite capability set, the local backend declares
24
+ * `search.fullText` and `search.vector` (see `src/internal/sqlite-search.ts`
25
+ * for the mechanics):
26
+ *
27
+ * - **Full-text search** is backed by one FTS5 table per graph table,
28
+ * kept in sync by pure-SQL triggers installed with the table's DDL.
29
+ * Because the triggers live in the database file, writes from ANY
30
+ * process or connection stay indexed. The trade-off is a per-write
31
+ * overhead (text extraction via `json_tree` + an FTS index update) on
32
+ * every insert/update/delete.
33
+ * - **Vector search** is a brute-force scan scored by a deterministic
34
+ * scalar UDF registered on this connection. UDFs are connection-local:
35
+ * `findNearest` only works through a backend created by this factory
36
+ * (other connections to the same file can read/write normally — only
37
+ * vector *search* needs the UDF).
20
38
  */
21
39
 
40
+ /**
41
+ * Capability union for the local better-sqlite3 backend: everything the
42
+ * shared SQLite edition declares, plus native FTS5 full-text search and
43
+ * brute-force vector search. `search.geo` stays out — there is no geo
44
+ * index in stock SQLite, and a UDF-scored scan without a haversine
45
+ * contract pinned by Firestore parity tests would be guesswork.
46
+ */
47
+ type LocalSqliteCapability = SqliteCapability | 'search.fullText' | 'search.vector';
22
48
  interface LocalSqliteBackendOptions extends SqliteBackendOptions {
23
49
  /** Root graph table name. Defaults to `'firegraph'`. */
24
50
  tableName?: string;
@@ -41,7 +67,7 @@ interface LocalSqliteBackendOptions extends SqliteBackendOptions {
41
67
  }
42
68
  interface LocalSqliteBackend {
43
69
  /** The graph storage backend — pass to `createGraphClient`. */
44
- backend: StorageBackend<SqliteCapability>;
70
+ backend: StorageBackend<LocalSqliteCapability>;
45
71
  /** The underlying better-sqlite3 database, for raw access. */
46
72
  db: Database;
47
73
  /**
@@ -70,7 +96,7 @@ declare function createBetterSqliteExecutor(db: Database): SqliteExecutor;
70
96
  *
71
97
  * const { backend, close } = await createLocalSqliteBackend('./graph.db');
72
98
  * const client = createGraphClient(backend);
73
- * // ... use the client ...
99
+ * // ... use the client — including fullTextSearch() and findNearest() ...
74
100
  * close();
75
101
  * ```
76
102
  *
@@ -80,4 +106,4 @@ declare function createBetterSqliteExecutor(db: Database): SqliteExecutor;
80
106
  */
81
107
  declare function createLocalSqliteBackend(pathOrDb: string | Database, options?: LocalSqliteBackendOptions): Promise<LocalSqliteBackend>;
82
108
 
83
- export { type LocalSqliteBackend, type LocalSqliteBackendOptions, createBetterSqliteExecutor, createLocalSqliteBackend };
109
+ export { type LocalSqliteBackend, type LocalSqliteBackendOptions, type LocalSqliteCapability, createBetterSqliteExecutor, createLocalSqliteBackend };
@@ -1,6 +1,6 @@
1
1
  import { Database } from 'better-sqlite3';
2
- import { S as StorageBackend } from '../backend-BpYLdwCW.js';
3
- import { a as SqliteCapability, S as SqliteBackendOptions, b as SqliteExecutor } from '../backend-YH5HtawN.js';
2
+ import { S as StorageBackend } from '../backend-DNzv8KSR.js';
3
+ import { a as SqliteCapability, S as SqliteBackendOptions, b as SqliteExecutor } from '../backend-CE3pM9-T.js';
4
4
  import '@google-cloud/firestore';
5
5
 
6
6
  /**
@@ -17,8 +17,34 @@ import '@google-cloud/firestore';
17
17
  * `journal_mode = WAL` and a `busy_timeout` applied; caller-provided
18
18
  * databases are used as-is (only `busy_timeout` is set) since the caller
19
19
  * owns their pragma configuration.
20
+ *
21
+ * ## Search capabilities
22
+ *
23
+ * On top of the shared SQLite capability set, the local backend declares
24
+ * `search.fullText` and `search.vector` (see `src/internal/sqlite-search.ts`
25
+ * for the mechanics):
26
+ *
27
+ * - **Full-text search** is backed by one FTS5 table per graph table,
28
+ * kept in sync by pure-SQL triggers installed with the table's DDL.
29
+ * Because the triggers live in the database file, writes from ANY
30
+ * process or connection stay indexed. The trade-off is a per-write
31
+ * overhead (text extraction via `json_tree` + an FTS index update) on
32
+ * every insert/update/delete.
33
+ * - **Vector search** is a brute-force scan scored by a deterministic
34
+ * scalar UDF registered on this connection. UDFs are connection-local:
35
+ * `findNearest` only works through a backend created by this factory
36
+ * (other connections to the same file can read/write normally — only
37
+ * vector *search* needs the UDF).
20
38
  */
21
39
 
40
+ /**
41
+ * Capability union for the local better-sqlite3 backend: everything the
42
+ * shared SQLite edition declares, plus native FTS5 full-text search and
43
+ * brute-force vector search. `search.geo` stays out — there is no geo
44
+ * index in stock SQLite, and a UDF-scored scan without a haversine
45
+ * contract pinned by Firestore parity tests would be guesswork.
46
+ */
47
+ type LocalSqliteCapability = SqliteCapability | 'search.fullText' | 'search.vector';
22
48
  interface LocalSqliteBackendOptions extends SqliteBackendOptions {
23
49
  /** Root graph table name. Defaults to `'firegraph'`. */
24
50
  tableName?: string;
@@ -41,7 +67,7 @@ interface LocalSqliteBackendOptions extends SqliteBackendOptions {
41
67
  }
42
68
  interface LocalSqliteBackend {
43
69
  /** The graph storage backend — pass to `createGraphClient`. */
44
- backend: StorageBackend<SqliteCapability>;
70
+ backend: StorageBackend<LocalSqliteCapability>;
45
71
  /** The underlying better-sqlite3 database, for raw access. */
46
72
  db: Database;
47
73
  /**
@@ -70,7 +96,7 @@ declare function createBetterSqliteExecutor(db: Database): SqliteExecutor;
70
96
  *
71
97
  * const { backend, close } = await createLocalSqliteBackend('./graph.db');
72
98
  * const client = createGraphClient(backend);
73
- * // ... use the client ...
99
+ * // ... use the client — including fullTextSearch() and findNearest() ...
74
100
  * close();
75
101
  * ```
76
102
  *
@@ -80,4 +106,4 @@ declare function createBetterSqliteExecutor(db: Database): SqliteExecutor;
80
106
  */
81
107
  declare function createLocalSqliteBackend(pathOrDb: string | Database, options?: LocalSqliteBackendOptions): Promise<LocalSqliteBackend>;
82
108
 
83
- export { type LocalSqliteBackend, type LocalSqliteBackendOptions, createBetterSqliteExecutor, createLocalSqliteBackend };
109
+ export { type LocalSqliteBackend, type LocalSqliteBackendOptions, type LocalSqliteCapability, createBetterSqliteExecutor, createLocalSqliteBackend };
@@ -1,15 +1,327 @@
1
1
  import {
2
+ catalogTableName,
2
3
  createSqliteBackend
3
- } from "../chunk-FODIMIWY.js";
4
- import "../chunk-ULRDQ6HZ.js";
4
+ } from "../chunk-5JBNLH5W.js";
5
+ import {
6
+ compileFilterConditions,
7
+ quoteIdent,
8
+ rowToRecord,
9
+ validateJsonPathKey,
10
+ validateTableName
11
+ } from "../chunk-NZVSLWNY.js";
5
12
  import "../chunk-2DHMNTV6.js";
6
- import "../chunk-N5HFDWQX.js";
13
+ import {
14
+ createCapabilities
15
+ } from "../chunk-PWIO46RT.js";
7
16
  import "../chunk-NGAJCALM.js";
8
17
  import {
9
18
  FiregraphError
10
19
  } from "../chunk-SIHE4UY4.js";
11
20
  import "../chunk-EQJUUVFG.js";
12
21
 
22
+ // src/internal/sqlite-search.ts
23
+ var VECTOR_DISTANCE_UDF = "firegraph_vector_distance";
24
+ var DISTANCE_ALIAS = "__fg_distance";
25
+ var BACKEND_ERR_LABEL = "SQLite backend";
26
+ var ENVELOPE_FIELDS = /* @__PURE__ */ new Set([
27
+ "aType",
28
+ "aUid",
29
+ "axbType",
30
+ "bType",
31
+ "bUid",
32
+ "createdAt",
33
+ "updatedAt",
34
+ "v"
35
+ ]);
36
+ function ftsTableName(table) {
37
+ return `${table}_fts`;
38
+ }
39
+ function ftsMapTableName(table) {
40
+ return `${table}_fts_map`;
41
+ }
42
+ function textExtractionExpr(dataRef) {
43
+ return `(SELECT coalesce(group_concat("value", ' '), '') FROM json_tree(coalesce(${dataRef}, '{}')) WHERE "type" = 'text')`;
44
+ }
45
+ function buildFtsDDL(table) {
46
+ const t = quoteIdent(table);
47
+ const fts = quoteIdent(ftsTableName(table));
48
+ const map = quoteIdent(ftsMapTableName(table));
49
+ const mappedId = `(SELECT "id" FROM ${map} WHERE "doc_id" = new."doc_id")`;
50
+ const reindexBody = ` INSERT INTO ${map} ("doc_id") SELECT new."doc_id" WHERE NOT EXISTS (SELECT 1 FROM ${map} WHERE "doc_id" = new."doc_id");
51
+ DELETE FROM ${fts} WHERE rowid = ${mappedId};
52
+ INSERT INTO ${fts} (rowid, "text") VALUES (${mappedId}, ${textExtractionExpr('new."data"')});
53
+ `;
54
+ return [
55
+ `CREATE TABLE IF NOT EXISTS ${map} (
56
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT,
57
+ "doc_id" TEXT NOT NULL UNIQUE
58
+ )`,
59
+ `CREATE VIRTUAL TABLE IF NOT EXISTS ${fts} USING fts5("text")`,
60
+ `CREATE TRIGGER IF NOT EXISTS ${quoteIdent(`${table}_fts_ai`)} AFTER INSERT ON ${t} BEGIN
61
+ ${reindexBody}END`,
62
+ `CREATE TRIGGER IF NOT EXISTS ${quoteIdent(`${table}_fts_au`)} AFTER UPDATE ON ${t} BEGIN
63
+ ${reindexBody}END`,
64
+ `CREATE TRIGGER IF NOT EXISTS ${quoteIdent(`${table}_fts_ad`)} AFTER DELETE ON ${t} BEGIN
65
+ DELETE FROM ${fts} WHERE rowid = (SELECT "id" FROM ${map} WHERE "doc_id" = old."doc_id");
66
+ DELETE FROM ${map} WHERE "doc_id" = old."doc_id";
67
+ END`
68
+ ];
69
+ }
70
+ function buildFtsSyncStatements(table) {
71
+ const t = quoteIdent(table);
72
+ const fts = quoteIdent(ftsTableName(table));
73
+ const map = quoteIdent(ftsMapTableName(table));
74
+ return [
75
+ `DELETE FROM ${fts} WHERE rowid IN (
76
+ SELECT m."id" FROM ${map} m LEFT JOIN ${t} t ON t."doc_id" = m."doc_id"
77
+ WHERE t."doc_id" IS NULL
78
+ )`,
79
+ `DELETE FROM ${map} WHERE "doc_id" NOT IN (SELECT "doc_id" FROM ${t})`,
80
+ `INSERT OR IGNORE INTO ${map} ("doc_id") SELECT "doc_id" FROM ${t}`,
81
+ `INSERT INTO ${fts} (rowid, "text")
82
+ SELECT m."id", ${textExtractionExpr('t."data"')}
83
+ FROM ${t} t JOIN ${map} m ON m."doc_id" = t."doc_id"
84
+ WHERE m."id" NOT IN (SELECT rowid FROM ${fts})`
85
+ ];
86
+ }
87
+ function buildLocalSearchDDL(table) {
88
+ return [...buildFtsDDL(table), ...buildFtsSyncStatements(table)];
89
+ }
90
+ function normalizeVectorFieldPath(label, field) {
91
+ if (ENVELOPE_FIELDS.has(field)) {
92
+ throw new FiregraphError(
93
+ `findNearest(): ${label} '${field}' is a built-in envelope field \u2014 vectors must live under \`data.*\`. Use a path like 'data.${field}' if you really meant a nested data field.`,
94
+ "INVALID_QUERY"
95
+ );
96
+ }
97
+ if (field === "data" || field.startsWith("data.")) return field;
98
+ return `data.${field}`;
99
+ }
100
+ function normalizeFullTextFieldPath(field) {
101
+ if (ENVELOPE_FIELDS.has(field)) {
102
+ throw new FiregraphError(
103
+ `fullTextSearch(): field '${field}' is a built-in envelope field \u2014 text-indexed fields must live under \`data.*\`. Use a path like 'data.${field}' if you really meant a nested data field.`,
104
+ "INVALID_QUERY"
105
+ );
106
+ }
107
+ if (field === "data" || field.startsWith("data.")) return field;
108
+ return `data.${field}`;
109
+ }
110
+ function buildSearchFilters(params) {
111
+ const filters = [];
112
+ if (params.aType) filters.push({ field: "aType", op: "==", value: params.aType });
113
+ if (params.axbType) filters.push({ field: "axbType", op: "==", value: params.axbType });
114
+ if (params.bType) filters.push({ field: "bType", op: "==", value: params.bType });
115
+ for (const clause of params.where ?? []) {
116
+ const field = ENVELOPE_FIELDS.has(clause.field) || clause.field.startsWith("data.") ? clause.field : `data.${clause.field}`;
117
+ filters.push({ field, op: clause.op, value: clause.value });
118
+ }
119
+ return filters;
120
+ }
121
+ function compileFullTextSearch(table, params) {
122
+ if (typeof params.query !== "string" || params.query.length === 0) {
123
+ throw new FiregraphError(
124
+ "fullTextSearch(): query must be a non-empty string.",
125
+ "INVALID_QUERY"
126
+ );
127
+ }
128
+ if (!Number.isInteger(params.limit) || params.limit <= 0) {
129
+ throw new FiregraphError(
130
+ `fullTextSearch(): limit must be a positive integer (got ${params.limit}).`,
131
+ "INVALID_QUERY"
132
+ );
133
+ }
134
+ const normalizedFields = params.fields?.map((f) => normalizeFullTextFieldPath(f));
135
+ if (normalizedFields !== void 0 && normalizedFields.length > 0) {
136
+ throw new FiregraphError(
137
+ "fullTextSearch(): the `fields` option is not yet supported \u2014 the local SQLite FTS index stores one combined text column per record. Omit `fields` to search all string values.",
138
+ "INVALID_QUERY"
139
+ );
140
+ }
141
+ const t = quoteIdent(table);
142
+ const fts = quoteIdent(ftsTableName(table));
143
+ const map = quoteIdent(ftsMapTableName(table));
144
+ const sqlParams = [params.query];
145
+ const conditions = [`${fts} MATCH ?`];
146
+ conditions.push(...compileFilterConditions(buildSearchFilters(params), sqlParams));
147
+ sqlParams.push(params.limit);
148
+ const sql = `SELECT ${t}.* FROM ${fts} JOIN ${map} ON ${map}."id" = ${fts}.rowid JOIN ${t} ON ${t}."doc_id" = ${map}."doc_id" WHERE ${conditions.join(" AND ")} ORDER BY bm25(${fts}) ASC, ${t}."doc_id" ASC LIMIT ?`;
149
+ return { sql, params: sqlParams };
150
+ }
151
+ var FTS5_QUERY_ERROR_SIGNATURES = [
152
+ "fts5: syntax error",
153
+ "unterminated string",
154
+ "unknown special query",
155
+ "no such column"
156
+ ];
157
+ function isFts5QueryError(message) {
158
+ const lower = message.toLowerCase();
159
+ return FTS5_QUERY_ERROR_SIGNATURES.some((sig) => lower.includes(sig));
160
+ }
161
+ var DISTANCE_MEASURES = /* @__PURE__ */ new Set(["EUCLIDEAN", "COSINE", "DOT_PRODUCT"]);
162
+ function toNumberArray(qv) {
163
+ if (Array.isArray(qv)) return qv;
164
+ if (typeof qv.toArray === "function") {
165
+ return qv.toArray();
166
+ }
167
+ throw new FiregraphError(
168
+ "findNearest(): queryVector must be a number[] or a Firestore VectorValue.",
169
+ "INVALID_QUERY"
170
+ );
171
+ }
172
+ function compileFindNearest(table, params) {
173
+ const vec = toNumberArray(params.queryVector);
174
+ if (vec.length === 0) {
175
+ throw new FiregraphError(
176
+ "findNearest(): queryVector is empty \u2014 at least one dimension is required.",
177
+ "INVALID_QUERY"
178
+ );
179
+ }
180
+ if (!Number.isInteger(params.limit) || params.limit <= 0 || params.limit > 1e3) {
181
+ throw new FiregraphError(
182
+ `findNearest(): limit must be a positive integer \u2264 1000 (got ${params.limit}).`,
183
+ "INVALID_QUERY"
184
+ );
185
+ }
186
+ if (!DISTANCE_MEASURES.has(params.distanceMeasure)) {
187
+ throw new FiregraphError(
188
+ `findNearest(): unknown distanceMeasure '${String(params.distanceMeasure)}' \u2014 expected EUCLIDEAN, COSINE, or DOT_PRODUCT.`,
189
+ "INVALID_QUERY"
190
+ );
191
+ }
192
+ const vectorField = normalizeVectorFieldPath("vectorField", params.vectorField);
193
+ let vectorExpr;
194
+ if (vectorField === "data") {
195
+ vectorExpr = '"data"';
196
+ } else {
197
+ const suffix = vectorField.slice("data.".length);
198
+ for (const part of suffix.split(".")) {
199
+ validateJsonPathKey(part, BACKEND_ERR_LABEL);
200
+ }
201
+ vectorExpr = `json_extract("data", '$.${suffix}')`;
202
+ }
203
+ let distancePath = null;
204
+ if (params.distanceResultField !== void 0) {
205
+ const normalized = normalizeVectorFieldPath("distanceResultField", params.distanceResultField);
206
+ if (normalized === "data") {
207
+ throw new FiregraphError(
208
+ `findNearest(): distanceResultField 'data' would replace the entire data payload \u2014 use a nested path like 'data.distance'.`,
209
+ "INVALID_QUERY"
210
+ );
211
+ }
212
+ distancePath = normalized.slice("data.".length).split(".");
213
+ for (const part of distancePath) {
214
+ validateJsonPathKey(part, BACKEND_ERR_LABEL);
215
+ }
216
+ }
217
+ const sqlParams = [JSON.stringify(vec), params.distanceMeasure];
218
+ const conditions = compileFilterConditions(buildSearchFilters(params), sqlParams);
219
+ const innerWhere = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
220
+ const dist = quoteIdent(DISTANCE_ALIAS);
221
+ const descending = params.distanceMeasure === "DOT_PRODUCT";
222
+ let sql = `SELECT * FROM (SELECT *, ${VECTOR_DISTANCE_UDF}(${vectorExpr}, ?, ?) AS ${dist} FROM ${quoteIdent(table)}${innerWhere}) WHERE ${dist} IS NOT NULL`;
223
+ if (params.distanceThreshold !== void 0) {
224
+ sql += ` AND ${dist} ${descending ? ">=" : "<="} ?`;
225
+ sqlParams.push(params.distanceThreshold);
226
+ }
227
+ sql += ` ORDER BY ${dist} ${descending ? "DESC" : "ASC"}, "doc_id" ASC LIMIT ?`;
228
+ sqlParams.push(params.limit);
229
+ return { stmt: { sql, params: sqlParams }, distancePath };
230
+ }
231
+ var memoQueryJson = null;
232
+ var memoQueryVec = null;
233
+ function computeVectorDistance(storedJson, queryJson, measure) {
234
+ if (typeof storedJson !== "string" || typeof queryJson !== "string" || typeof measure !== "string") {
235
+ return null;
236
+ }
237
+ let query;
238
+ if (memoQueryJson === queryJson && memoQueryVec !== null) {
239
+ query = memoQueryVec;
240
+ } else {
241
+ let parsed;
242
+ try {
243
+ parsed = JSON.parse(queryJson);
244
+ } catch {
245
+ return null;
246
+ }
247
+ if (!Array.isArray(parsed)) return null;
248
+ query = parsed;
249
+ memoQueryJson = queryJson;
250
+ memoQueryVec = query;
251
+ }
252
+ let stored;
253
+ try {
254
+ stored = JSON.parse(storedJson);
255
+ } catch {
256
+ return null;
257
+ }
258
+ if (!Array.isArray(stored) || stored.length !== query.length) return null;
259
+ let dot = 0;
260
+ let sumSq = 0;
261
+ let normStored = 0;
262
+ let normQuery = 0;
263
+ for (let i = 0; i < query.length; i++) {
264
+ const a = stored[i];
265
+ const b = query[i];
266
+ if (typeof a !== "number" || !Number.isFinite(a)) return null;
267
+ if (typeof b !== "number" || !Number.isFinite(b)) return null;
268
+ dot += a * b;
269
+ const diff = a - b;
270
+ sumSq += diff * diff;
271
+ normStored += a * a;
272
+ normQuery += b * b;
273
+ }
274
+ let result;
275
+ switch (measure) {
276
+ case "EUCLIDEAN":
277
+ result = Math.sqrt(sumSq);
278
+ break;
279
+ case "COSINE": {
280
+ const denom = Math.sqrt(normStored) * Math.sqrt(normQuery);
281
+ if (denom === 0) return null;
282
+ result = 1 - dot / denom;
283
+ break;
284
+ }
285
+ case "DOT_PRODUCT":
286
+ result = dot;
287
+ break;
288
+ default:
289
+ return null;
290
+ }
291
+ return Number.isFinite(result) ? result : null;
292
+ }
293
+ function setDataPath(data, path, value) {
294
+ let cursor = data;
295
+ for (let i = 0; i < path.length - 1; i++) {
296
+ const key = path[i];
297
+ const next = cursor[key];
298
+ if (typeof next !== "object" || next === null || Array.isArray(next)) {
299
+ const created = {};
300
+ cursor[key] = created;
301
+ cursor = created;
302
+ } else {
303
+ cursor = next;
304
+ }
305
+ }
306
+ cursor[path[path.length - 1]] = value;
307
+ }
308
+ function findOrphanedFtsTables(allTables, catalogTables, rootTable) {
309
+ const names = new Set(allTables);
310
+ const liveGraphTables = new Set(catalogTables);
311
+ const subgraphPrefix = `${rootTable}_g_`;
312
+ const orphans = [];
313
+ for (const name of names) {
314
+ let base = null;
315
+ if (name.endsWith("_fts_map")) base = name.slice(0, -"_fts_map".length);
316
+ else if (name.endsWith("_fts")) base = name.slice(0, -"_fts".length);
317
+ if (base === null || !base.startsWith(subgraphPrefix)) continue;
318
+ if (liveGraphTables.has(name)) continue;
319
+ if (names.has(base)) continue;
320
+ orphans.push(name);
321
+ }
322
+ return orphans.sort();
323
+ }
324
+
13
325
  // src/sqlite/local.ts
14
326
  function createBetterSqliteExecutor(db) {
15
327
  return {
@@ -66,6 +378,118 @@ function applyPragmas(db, pragmas) {
66
378
  db.pragma(`${key} = ${value}`);
67
379
  }
68
380
  }
381
+ function registerVectorUdf(db) {
382
+ try {
383
+ db.function(
384
+ VECTOR_DISTANCE_UDF,
385
+ { deterministic: true },
386
+ (stored, query, measure) => computeVectorDistance(stored, query, measure)
387
+ );
388
+ } catch {
389
+ }
390
+ }
391
+ async function sweepOrphanedFtsArtifacts(executor, rootTable) {
392
+ const tableRows = await executor.all(
393
+ `SELECT "name" FROM sqlite_master WHERE "type" = 'table'`,
394
+ []
395
+ );
396
+ const allTables = tableRows.map((r) => String(r.name));
397
+ const catalogRows = await executor.all(
398
+ `SELECT "table_name" FROM ${quoteIdent(catalogTableName(rootTable))}`,
399
+ []
400
+ );
401
+ const catalogTables = catalogRows.map((r) => String(r.table_name));
402
+ for (const name of findOrphanedFtsTables(allTables, catalogTables, rootTable)) {
403
+ validateTableName(name);
404
+ await executor.run(`DROP TABLE IF EXISTS ${quoteIdent(name)}`, []);
405
+ }
406
+ }
407
+ function wrapLocalSearchBackend(inner, executor, rootTable) {
408
+ const caps = /* @__PURE__ */ new Set([
409
+ ...inner.capabilities.values(),
410
+ "search.fullText",
411
+ "search.vector"
412
+ ]);
413
+ const healableTables = /* @__PURE__ */ new Set([
414
+ inner.collectionPath,
415
+ ftsTableName(inner.collectionPath),
416
+ ftsMapTableName(inner.collectionPath)
417
+ ]);
418
+ const runWithSchema = async (op) => {
419
+ await inner.ensureReady();
420
+ try {
421
+ return await op();
422
+ } catch (err) {
423
+ const message = err instanceof Error ? err.message : String(err);
424
+ const missing = /no such table: (\S+)/.exec(message)?.[1];
425
+ if (missing === void 0 || !healableTables.has(missing)) throw err;
426
+ await inner.ensureReady(true);
427
+ return op();
428
+ }
429
+ };
430
+ const wrapper = {
431
+ capabilities: createCapabilities(caps),
432
+ collectionPath: inner.collectionPath,
433
+ scopePath: inner.scopePath,
434
+ getDoc: (docId) => inner.getDoc(docId),
435
+ query: (filters, options) => inner.query(filters, options),
436
+ setDoc: (docId, record, mode) => inner.setDoc(docId, record, mode),
437
+ updateDoc: (docId, update) => inner.updateDoc(docId, update),
438
+ deleteDoc: (docId) => inner.deleteDoc(docId),
439
+ runTransaction: (fn) => inner.runTransaction(fn),
440
+ createBatch: () => inner.createBatch(),
441
+ subgraph: (parentNodeUid, name) => wrapLocalSearchBackend(inner.subgraph(parentNodeUid, name), executor, rootTable),
442
+ removeNodeCascade: async (uid, reader, options) => {
443
+ const result = await inner.removeNodeCascade(uid, reader, options);
444
+ if (result.errors.length === 0) {
445
+ await sweepOrphanedFtsArtifacts(executor, rootTable);
446
+ }
447
+ return result;
448
+ },
449
+ bulkRemoveEdges: (params, reader, options) => inner.bulkRemoveEdges(params, reader, options),
450
+ aggregate: (spec, filters) => inner.aggregate(spec, filters),
451
+ bulkDelete: (filters, options) => inner.bulkDelete(filters, options),
452
+ bulkUpdate: (filters, patch, options) => inner.bulkUpdate(filters, patch, options),
453
+ expand: (params) => inner.expand(params),
454
+ findEdgesProjected: (select, filters, options) => inner.findEdgesProjected(select, filters, options),
455
+ // `findEdgesGlobal` stays absent, same as the inner backend — each graph
456
+ // is its own table; there is no cross-table index.
457
+ async findNearest(params) {
458
+ const { stmt, distancePath } = compileFindNearest(inner.collectionPath, params);
459
+ const rows = await runWithSchema(() => executor.all(stmt.sql, stmt.params));
460
+ return rows.map((row) => {
461
+ const record = rowToRecord(row);
462
+ if (distancePath) {
463
+ const distance = row[DISTANCE_ALIAS];
464
+ setDataPath(
465
+ record.data,
466
+ distancePath,
467
+ typeof distance === "number" ? distance : Number(distance)
468
+ );
469
+ }
470
+ return record;
471
+ });
472
+ },
473
+ async fullTextSearch(params) {
474
+ const stmt = compileFullTextSearch(inner.collectionPath, params);
475
+ let rows;
476
+ try {
477
+ rows = await runWithSchema(() => executor.all(stmt.sql, stmt.params));
478
+ } catch (err) {
479
+ const message = err instanceof Error ? err.message : String(err);
480
+ if (isFts5QueryError(message)) {
481
+ throw new FiregraphError(
482
+ `fullTextSearch(): invalid FTS5 query syntax \u2014 ${message}`,
483
+ "INVALID_QUERY"
484
+ );
485
+ }
486
+ throw err;
487
+ }
488
+ return rows.map(rowToRecord);
489
+ }
490
+ };
491
+ return wrapper;
492
+ }
69
493
  async function createLocalSqliteBackend(pathOrDb, options = {}) {
70
494
  const {
71
495
  tableName = "firegraph",
@@ -102,7 +526,18 @@ async function createLocalSqliteBackend(pathOrDb, options = {}) {
102
526
  if (pragmas) {
103
527
  applyPragmas(db, pragmas);
104
528
  }
105
- const backend = createSqliteBackend(createBetterSqliteExecutor(db), tableName, backendOptions);
529
+ registerVectorUdf(db);
530
+ const userExtraDDL = backendOptions.extraTableDDL;
531
+ const optionsWithSearch = {
532
+ ...backendOptions,
533
+ extraTableDDL: (table) => [
534
+ ...userExtraDDL ? userExtraDDL(table) : [],
535
+ ...buildLocalSearchDDL(table)
536
+ ]
537
+ };
538
+ const executor = createBetterSqliteExecutor(db);
539
+ const inner = createSqliteBackend(executor, tableName, optionsWithSearch);
540
+ const backend = wrapLocalSearchBackend(inner, executor, tableName);
106
541
  let closed = false;
107
542
  return {
108
543
  backend,