@typicalday/firegraph 0.16.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.
- package/dist/sqlite/local.cjs +11 -1
- package/dist/sqlite/local.cjs.map +1 -1
- package/dist/sqlite/local.js +11 -1
- package/dist/sqlite/local.js.map +1 -1
- package/package.json +1 -1
package/dist/sqlite/local.js
CHANGED
|
@@ -148,6 +148,16 @@ function compileFullTextSearch(table, params) {
|
|
|
148
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
149
|
return { sql, params: sqlParams };
|
|
150
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
|
+
}
|
|
151
161
|
var DISTANCE_MEASURES = /* @__PURE__ */ new Set(["EUCLIDEAN", "COSINE", "DOT_PRODUCT"]);
|
|
152
162
|
function toNumberArray(qv) {
|
|
153
163
|
if (Array.isArray(qv)) return qv;
|
|
@@ -467,7 +477,7 @@ function wrapLocalSearchBackend(inner, executor, rootTable) {
|
|
|
467
477
|
rows = await runWithSchema(() => executor.all(stmt.sql, stmt.params));
|
|
468
478
|
} catch (err) {
|
|
469
479
|
const message = err instanceof Error ? err.message : String(err);
|
|
470
|
-
if (
|
|
480
|
+
if (isFts5QueryError(message)) {
|
|
471
481
|
throw new FiregraphError(
|
|
472
482
|
`fullTextSearch(): invalid FTS5 query syntax \u2014 ${message}`,
|
|
473
483
|
"INVALID_QUERY"
|
package/dist/sqlite/local.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/internal/sqlite-search.ts","../../src/sqlite/local.ts"],"sourcesContent":["/**\n * Search compilation for the local SQLite backend (`firegraph/sqlite-local`).\n *\n * Two capabilities are compiled here:\n *\n * - **`search.fullText`** — an FTS5 index table per graph table, kept in\n * sync by pure-SQL triggers. Text is extracted from the `data` JSON via\n * `json_tree(...) WHERE type = 'text'`, so the triggers work from ANY\n * connection or process touching the file — no user-defined function\n * required on the write path. Queries rank with `bm25()` (lower =\n * better, so `ORDER BY bm25 ASC` is relevance-descending).\n *\n * - **`search.vector`** — brute-force k-NN via a deterministic scalar UDF\n * (`firegraph_vector_distance`) registered on the better-sqlite3\n * connection by `createLocalSqliteBackend`. There is no ANN index; the\n * engine evaluates the distance per candidate row, which is the right\n * trade-off for the local-file use case (thousands to low millions of\n * rows, zero infrastructure). UDFs are connection-local: vector search\n * only works through a connection that registered the function.\n *\n * ## FTS row keying\n *\n * The FTS5 table's `rowid` is keyed through a dedicated mapping table\n * (`<t>_fts_map`, `INTEGER PRIMARY KEY AUTOINCREMENT` → `doc_id`) rather\n * than the graph table's own rowid. The graph table has a TEXT primary key,\n * so its raw rowids are NOT stable — `VACUUM` may renumber them, silently\n * detaching every FTS entry. AUTOINCREMENT ids survive VACUUM. Storing\n * `doc_id` UNINDEXED inside the FTS table was also rejected: FTS5 can't\n * index UNINDEXED columns, making the per-write delete a full scan.\n *\n * Validation parity: error messages and codes mirror the Firestore helpers\n * (`firestore-vector.ts` / `firestore-fulltext.ts`) so a caller migrating\n * between backends sees the same failures. This module must stay free of\n * `@google-cloud/firestore` imports — it is bundled into the\n * `firegraph/sqlite-local` entry.\n */\n\nimport { FiregraphError } from '../errors.js';\nimport type { FindNearestParams, FullTextSearchParams, QueryFilter } from '../types.js';\nimport { validateJsonPathKey } from './sqlite-data-ops.js';\nimport { quoteIdent } from './sqlite-schema.js';\nimport type { CompiledStatement } from './sqlite-sql.js';\nimport { compileFilterConditions } from './sqlite-sql.js';\n\n/** Name of the connection-local vector-distance UDF. */\nexport const VECTOR_DISTANCE_UDF = 'firegraph_vector_distance';\n\n/** Column alias carrying the computed distance through the vector query. */\nexport const DISTANCE_ALIAS = '__fg_distance';\n\nconst BACKEND_ERR_LABEL = 'SQLite backend';\n\n/**\n * Built-in envelope fields that must NOT be passed as search field paths.\n * Mirrors the Firestore helpers' rejection list.\n */\nconst ENVELOPE_FIELDS: ReadonlySet<string> = new Set([\n 'aType',\n 'aUid',\n 'axbType',\n 'bType',\n 'bUid',\n 'createdAt',\n 'updatedAt',\n 'v',\n]);\n\n/** FTS5 index table for a graph table. */\nexport function ftsTableName(table: string): string {\n return `${table}_fts`;\n}\n\n/** Stable-rowid mapping table for a graph table's FTS index. */\nexport function ftsMapTableName(table: string): string {\n return `${table}_fts_map`;\n}\n\n/**\n * SQL fragment extracting every string value in a `data` JSON payload as\n * one space-joined text blob. Pure SQL (`json_tree`), so it is evaluatable\n * inside triggers from any connection.\n */\nfunction textExtractionExpr(dataRef: string): string {\n return (\n `(SELECT coalesce(group_concat(\"value\", ' '), '') ` +\n `FROM json_tree(coalesce(${dataRef}, '{}')) WHERE \"type\" = 'text')`\n );\n}\n\n/**\n * DDL installing the FTS5 infrastructure for one graph table: the mapping\n * table, the FTS5 virtual table, and three sync triggers. All statements\n * are `IF NOT EXISTS` — safe to re-run on every bootstrap.\n *\n * The AFTER INSERT trigger also fires for the INSERT arm of the backend's\n * upsert (`INSERT … ON CONFLICT DO UPDATE`); the conflict arm fires AFTER\n * UPDATE. Both re-derive the indexed text from `new.\"data\"`, and both\n * start with a defensive delete of any stale FTS row so replayed writes\n * never double-index.\n */\nexport function buildFtsDDL(table: string): string[] {\n const t = quoteIdent(table);\n const fts = quoteIdent(ftsTableName(table));\n const map = quoteIdent(ftsMapTableName(table));\n const mappedId = `(SELECT \"id\" FROM ${map} WHERE \"doc_id\" = new.\"doc_id\")`;\n // The map insert must be conflict-free rather than `INSERT OR IGNORE`:\n // when the outer statement is the backend's upsert (`INSERT … ON CONFLICT\n // DO UPDATE`), SQLite replaces conflict handling inside trigger programs\n // with the outer statement's algorithm, turning the IGNORE into an abort.\n const reindexBody =\n ` INSERT INTO ${map} (\"doc_id\") SELECT new.\"doc_id\" ` +\n `WHERE NOT EXISTS (SELECT 1 FROM ${map} WHERE \"doc_id\" = new.\"doc_id\");\\n` +\n ` DELETE FROM ${fts} WHERE rowid = ${mappedId};\\n` +\n ` INSERT INTO ${fts} (rowid, \"text\") VALUES (${mappedId}, ${textExtractionExpr('new.\"data\"')});\\n`;\n return [\n `CREATE TABLE IF NOT EXISTS ${map} (\n \"id\" INTEGER PRIMARY KEY AUTOINCREMENT,\n \"doc_id\" TEXT NOT NULL UNIQUE\n )`,\n `CREATE VIRTUAL TABLE IF NOT EXISTS ${fts} USING fts5(\"text\")`,\n `CREATE TRIGGER IF NOT EXISTS ${quoteIdent(`${table}_fts_ai`)} AFTER INSERT ON ${t} BEGIN\\n${reindexBody}END`,\n `CREATE TRIGGER IF NOT EXISTS ${quoteIdent(`${table}_fts_au`)} AFTER UPDATE ON ${t} BEGIN\\n${reindexBody}END`,\n `CREATE TRIGGER IF NOT EXISTS ${quoteIdent(`${table}_fts_ad`)} AFTER DELETE ON ${t} BEGIN\n DELETE FROM ${fts} WHERE rowid = (SELECT \"id\" FROM ${map} WHERE \"doc_id\" = old.\"doc_id\");\n DELETE FROM ${map} WHERE \"doc_id\" = old.\"doc_id\";\nEND`,\n ];\n}\n\n/**\n * Idempotent reconciliation statements run at every schema bootstrap,\n * after `buildFtsDDL`:\n *\n * 1–2. Purge FTS/map rows whose `doc_id` no longer exists in the graph\n * table. Covers the recreate-after-cascade path: a parent cascade\n * DROPs the graph table (taking the triggers with it) but leaves\n * the FTS artifacts; without the purge, a recreated subgraph would\n * surface ghost matches and hit UNIQUE violations on the map.\n * 3–4. Backfill map/FTS rows for graph rows that predate the FTS\n * infrastructure (e.g. a database written by an older firegraph).\n */\nexport function buildFtsSyncStatements(table: string): string[] {\n const t = quoteIdent(table);\n const fts = quoteIdent(ftsTableName(table));\n const map = quoteIdent(ftsMapTableName(table));\n return [\n `DELETE FROM ${fts} WHERE rowid IN (\n SELECT m.\"id\" FROM ${map} m LEFT JOIN ${t} t ON t.\"doc_id\" = m.\"doc_id\"\n WHERE t.\"doc_id\" IS NULL\n )`,\n `DELETE FROM ${map} WHERE \"doc_id\" NOT IN (SELECT \"doc_id\" FROM ${t})`,\n `INSERT OR IGNORE INTO ${map} (\"doc_id\") SELECT \"doc_id\" FROM ${t}`,\n `INSERT INTO ${fts} (rowid, \"text\")\n SELECT m.\"id\", ${textExtractionExpr('t.\"data\"')}\n FROM ${t} t JOIN ${map} m ON m.\"doc_id\" = t.\"doc_id\"\n WHERE m.\"id\" NOT IN (SELECT rowid FROM ${fts})`,\n ];\n}\n\n/**\n * Full `extraTableDDL` payload for `firegraph/sqlite-local`: FTS\n * infrastructure plus the reconciliation pass.\n */\nexport function buildLocalSearchDDL(table: string): string[] {\n return [...buildFtsDDL(table), ...buildFtsSyncStatements(table)];\n}\n\n/**\n * Normalise a caller-supplied vector / distance-result field path. Bare\n * names rewrite to `data.<name>`; `'data'` and `'data.*'` pass through;\n * envelope fields are rejected. Same contract and message shape as\n * `normalizeVectorFieldPath` in `firestore-vector.ts`.\n */\nexport function normalizeVectorFieldPath(label: string, field: string): string {\n if (ENVELOPE_FIELDS.has(field)) {\n throw new FiregraphError(\n `findNearest(): ${label} '${field}' is a built-in envelope field — ` +\n `vectors must live under \\`data.*\\`. Use a path like 'data.${field}' ` +\n `if you really meant a nested data field.`,\n 'INVALID_QUERY',\n );\n }\n if (field === 'data' || field.startsWith('data.')) return field;\n return `data.${field}`;\n}\n\n/**\n * Normalise a caller-supplied FTS field path. Same contract as\n * `normalizeFullTextFieldPath` in `firestore-fulltext.ts`.\n */\nexport function normalizeFullTextFieldPath(field: string): string {\n if (ENVELOPE_FIELDS.has(field)) {\n throw new FiregraphError(\n `fullTextSearch(): field '${field}' is a built-in envelope field — ` +\n `text-indexed fields must live under \\`data.*\\`. Use a path like ` +\n `'data.${field}' if you really meant a nested data field.`,\n 'INVALID_QUERY',\n );\n }\n if (field === 'data' || field.startsWith('data.')) return field;\n return `data.${field}`;\n}\n\n/**\n * Identifying filters (`aType` / `axbType` / `bType`) plus optional `where`.\n * Bare `where` field names rewrite to `data.<name>` — the same convention\n * `buildEdgeQueryPlan` applies for `findEdges({ where })`.\n */\nfunction buildSearchFilters(params: {\n aType?: string;\n axbType?: string;\n bType?: string;\n where?: QueryFilter[];\n}): QueryFilter[] {\n const filters: QueryFilter[] = [];\n if (params.aType) filters.push({ field: 'aType', op: '==', value: params.aType });\n if (params.axbType) filters.push({ field: 'axbType', op: '==', value: params.axbType });\n if (params.bType) filters.push({ field: 'bType', op: '==', value: params.bType });\n for (const clause of params.where ?? []) {\n const field =\n ENVELOPE_FIELDS.has(clause.field) || clause.field.startsWith('data.')\n ? clause.field\n : `data.${clause.field}`;\n filters.push({ field, op: clause.op, value: clause.value });\n }\n return filters;\n}\n\n/**\n * Compile a `fullTextSearch()` call into one SELECT over the FTS5 index.\n *\n * Validation parity with `runFirestoreFullTextSearch`: non-empty string\n * query, positive integer limit, and a non-empty `fields` list is rejected\n * with `INVALID_QUERY` (\"not yet supported\") — FTS5 column filters could\n * support per-field search later, but the single-blob index built today\n * has one `text` column, so the option is reserved rather than silently\n * mis-honoured.\n *\n * Results order by `bm25()` ascending (best match first), with `doc_id`\n * as a deterministic tie-break.\n */\nexport function compileFullTextSearch(\n table: string,\n params: FullTextSearchParams,\n): CompiledStatement {\n if (typeof params.query !== 'string' || params.query.length === 0) {\n throw new FiregraphError(\n 'fullTextSearch(): query must be a non-empty string.',\n 'INVALID_QUERY',\n );\n }\n if (!Number.isInteger(params.limit) || params.limit <= 0) {\n throw new FiregraphError(\n `fullTextSearch(): limit must be a positive integer (got ${params.limit}).`,\n 'INVALID_QUERY',\n );\n }\n const normalizedFields = params.fields?.map((f) => normalizeFullTextFieldPath(f));\n if (normalizedFields !== undefined && normalizedFields.length > 0) {\n throw new FiregraphError(\n 'fullTextSearch(): the `fields` option is not yet supported — ' +\n 'the local SQLite FTS index stores one combined text column per record. ' +\n 'Omit `fields` to search all string values.',\n 'INVALID_QUERY',\n );\n }\n\n const t = quoteIdent(table);\n const fts = quoteIdent(ftsTableName(table));\n const map = quoteIdent(ftsMapTableName(table));\n\n const sqlParams: unknown[] = [params.query];\n const conditions: string[] = [`${fts} MATCH ?`];\n conditions.push(...compileFilterConditions(buildSearchFilters(params), sqlParams));\n sqlParams.push(params.limit);\n\n const sql =\n `SELECT ${t}.* FROM ${fts} ` +\n `JOIN ${map} ON ${map}.\"id\" = ${fts}.rowid ` +\n `JOIN ${t} ON ${t}.\"doc_id\" = ${map}.\"doc_id\" ` +\n `WHERE ${conditions.join(' AND ')} ` +\n `ORDER BY bm25(${fts}) ASC, ${t}.\"doc_id\" ASC LIMIT ?`;\n return { sql, params: sqlParams };\n}\n\nconst DISTANCE_MEASURES: ReadonlySet<string> = new Set(['EUCLIDEAN', 'COSINE', 'DOT_PRODUCT']);\n\nexport interface CompiledVectorQuery {\n stmt: CompiledStatement;\n /**\n * `data`-relative path segments to write the computed distance into on\n * each result record, or `null` when `distanceResultField` was not set.\n */\n distancePath: string[] | null;\n}\n\n/** Resolve a `queryVector` argument to a plain `number[]`. */\nfunction toNumberArray(qv: number[] | { toArray(): number[] }): number[] {\n if (Array.isArray(qv)) return qv;\n if (typeof (qv as { toArray?: unknown }).toArray === 'function') {\n return (qv as { toArray(): number[] }).toArray();\n }\n throw new FiregraphError(\n 'findNearest(): queryVector must be a number[] or a Firestore VectorValue.',\n 'INVALID_QUERY',\n );\n}\n\n/**\n * Compile a `findNearest()` call into one SELECT that scores every\n * candidate row via the `firegraph_vector_distance` UDF.\n *\n * Shape (subquery because SQLite forbids referencing a SELECT alias in\n * the same level's WHERE):\n *\n * SELECT * FROM (\n * SELECT *, firegraph_vector_distance(json_extract(\"data\", '$.<path>'), ?, ?) AS \"__fg_distance\"\n * FROM \"<t>\" [WHERE <identifiers + where>]\n * ) WHERE \"__fg_distance\" IS NOT NULL [AND \"__fg_distance\" <=|>= ?]\n * ORDER BY \"__fg_distance\" ASC|DESC, \"doc_id\" ASC LIMIT ?\n *\n * `NULL` distances (missing field, non-array value, dimension mismatch)\n * drop out of the result, mirroring Firestore's behaviour of silently\n * skipping non-conforming documents. Threshold and ordering semantics\n * follow the `FindNearestParams.distanceThreshold` contract: `<=` /\n * ascending for EUCLIDEAN and COSINE, `>=` / descending for DOT_PRODUCT.\n *\n * Validation parity with `runFirestoreFindNearest`: envelope-field\n * rejection on both field params, non-empty query vector, positive\n * integer limit ≤ 1000.\n */\nexport function compileFindNearest(table: string, params: FindNearestParams): CompiledVectorQuery {\n const vec = toNumberArray(params.queryVector);\n if (vec.length === 0) {\n throw new FiregraphError(\n 'findNearest(): queryVector is empty — at least one dimension is required.',\n 'INVALID_QUERY',\n );\n }\n if (!Number.isInteger(params.limit) || params.limit <= 0 || params.limit > 1000) {\n throw new FiregraphError(\n `findNearest(): limit must be a positive integer ≤ 1000 (got ${params.limit}).`,\n 'INVALID_QUERY',\n );\n }\n if (!DISTANCE_MEASURES.has(params.distanceMeasure)) {\n throw new FiregraphError(\n `findNearest(): unknown distanceMeasure '${String(params.distanceMeasure)}' — ` +\n `expected EUCLIDEAN, COSINE, or DOT_PRODUCT.`,\n 'INVALID_QUERY',\n );\n }\n\n const vectorField = normalizeVectorFieldPath('vectorField', params.vectorField);\n let vectorExpr: string;\n if (vectorField === 'data') {\n vectorExpr = '\"data\"';\n } else {\n const suffix = vectorField.slice('data.'.length);\n for (const part of suffix.split('.')) {\n validateJsonPathKey(part, BACKEND_ERR_LABEL);\n }\n vectorExpr = `json_extract(\"data\", '$.${suffix}')`;\n }\n\n let distancePath: string[] | null = null;\n if (params.distanceResultField !== undefined) {\n const normalized = normalizeVectorFieldPath('distanceResultField', params.distanceResultField);\n if (normalized === 'data') {\n throw new FiregraphError(\n `findNearest(): distanceResultField 'data' would replace the entire data ` +\n `payload — use a nested path like 'data.distance'.`,\n 'INVALID_QUERY',\n );\n }\n distancePath = normalized.slice('data.'.length).split('.');\n for (const part of distancePath) {\n validateJsonPathKey(part, BACKEND_ERR_LABEL);\n }\n }\n\n // Bound-parameter order tracks placeholder order in the statement text:\n // the two UDF arguments in the SELECT list come first, then the inner\n // WHERE filters, then threshold and limit.\n const sqlParams: unknown[] = [JSON.stringify(vec), params.distanceMeasure];\n const conditions = compileFilterConditions(buildSearchFilters(params), sqlParams);\n const innerWhere = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '';\n const dist = quoteIdent(DISTANCE_ALIAS);\n const descending = params.distanceMeasure === 'DOT_PRODUCT';\n\n let sql =\n `SELECT * FROM (` +\n `SELECT *, ${VECTOR_DISTANCE_UDF}(${vectorExpr}, ?, ?) AS ${dist} ` +\n `FROM ${quoteIdent(table)}${innerWhere}` +\n `) WHERE ${dist} IS NOT NULL`;\n if (params.distanceThreshold !== undefined) {\n sql += ` AND ${dist} ${descending ? '>=' : '<='} ?`;\n sqlParams.push(params.distanceThreshold);\n }\n sql += ` ORDER BY ${dist} ${descending ? 'DESC' : 'ASC'}, \"doc_id\" ASC LIMIT ?`;\n sqlParams.push(params.limit);\n\n return { stmt: { sql, params: sqlParams }, distancePath };\n}\n\n// One-entry memo for the parsed query vector: the UDF runs once per\n// candidate row with the identical query-vector JSON, so re-parsing it\n// every call would dominate the scan cost.\nlet memoQueryJson: string | null = null;\nlet memoQueryVec: number[] | null = null;\n\n/**\n * Scalar UDF body for `firegraph_vector_distance(storedJson, queryJson,\n * measure)`. Returns the distance as a REAL, or `null` when the stored\n * value is missing, not a JSON array, dimension-mismatched, or contains\n * non-finite/non-numeric entries — NULL rows are filtered out by the\n * query, mirroring Firestore's silent skip of non-conforming documents.\n *\n * COSINE returns `1 − cos(a, b)` (Firestore's distance convention) and\n * `null` when either vector has zero norm (cosine undefined).\n *\n * Exported for direct unit testing and registered on the connection by\n * `createLocalSqliteBackend` with `deterministic: true`.\n */\nexport function computeVectorDistance(\n storedJson: unknown,\n queryJson: unknown,\n measure: unknown,\n): number | null {\n if (\n typeof storedJson !== 'string' ||\n typeof queryJson !== 'string' ||\n typeof measure !== 'string'\n ) {\n return null;\n }\n let query: number[];\n if (memoQueryJson === queryJson && memoQueryVec !== null) {\n query = memoQueryVec;\n } else {\n let parsed: unknown;\n try {\n parsed = JSON.parse(queryJson);\n } catch {\n return null;\n }\n if (!Array.isArray(parsed)) return null;\n query = parsed as number[];\n memoQueryJson = queryJson;\n memoQueryVec = query;\n }\n\n let stored: unknown;\n try {\n stored = JSON.parse(storedJson);\n } catch {\n return null;\n }\n if (!Array.isArray(stored) || stored.length !== query.length) return null;\n\n let dot = 0;\n let sumSq = 0;\n let normStored = 0;\n let normQuery = 0;\n for (let i = 0; i < query.length; i++) {\n const a = stored[i];\n const b = query[i];\n if (typeof a !== 'number' || !Number.isFinite(a)) return null;\n if (typeof b !== 'number' || !Number.isFinite(b)) return null;\n dot += a * b;\n const diff = a - b;\n sumSq += diff * diff;\n normStored += a * a;\n normQuery += b * b;\n }\n\n let result: number;\n switch (measure) {\n case 'EUCLIDEAN':\n result = Math.sqrt(sumSq);\n break;\n case 'COSINE': {\n const denom = Math.sqrt(normStored) * Math.sqrt(normQuery);\n if (denom === 0) return null;\n result = 1 - dot / denom;\n break;\n }\n case 'DOT_PRODUCT':\n result = dot;\n break;\n default:\n return null;\n }\n return Number.isFinite(result) ? result : null;\n}\n\n/**\n * Set a nested value inside a record's `data` payload, creating\n * intermediate objects along the way (replacing non-object intermediates,\n * matching Firestore's `distanceResultField` write semantics).\n */\nexport function setDataPath(\n data: Record<string, unknown>,\n path: ReadonlyArray<string>,\n value: unknown,\n): void {\n let cursor = data;\n for (let i = 0; i < path.length - 1; i++) {\n const key = path[i];\n const next = cursor[key];\n if (typeof next !== 'object' || next === null || Array.isArray(next)) {\n const created: Record<string, unknown> = {};\n cursor[key] = created;\n cursor = created;\n } else {\n cursor = next as Record<string, unknown>;\n }\n }\n cursor[path[path.length - 1]] = value;\n}\n\n/**\n * Identify orphaned FTS artifacts (`<t>_fts` / `<t>_fts_map`) whose base\n * graph table no longer exists — left behind when a parent cascade DROPs a\n * descendant subgraph table (triggers die with the table; the FTS\n * artifacts do not).\n *\n * Safety against false positives: only names under the subgraph prefix\n * (`<rootTable>_g_`) are considered, a candidate must NOT itself be a\n * registered graph table (`catalogTables` — covers a real graph whose\n * mangled scope happens to end in `_fts`), and its base table must be\n * absent from `allTables`. FTS5 shadow tables (`<t>_fts_data`,\n * `<t>_fts_idx`, …) never match the suffix patterns and are dropped\n * implicitly with their parent virtual table.\n */\nexport function findOrphanedFtsTables(\n allTables: ReadonlyArray<string>,\n catalogTables: ReadonlyArray<string>,\n rootTable: string,\n): string[] {\n const names = new Set(allTables);\n const liveGraphTables = new Set(catalogTables);\n const subgraphPrefix = `${rootTable}_g_`;\n const orphans: string[] = [];\n for (const name of names) {\n let base: string | null = null;\n if (name.endsWith('_fts_map')) base = name.slice(0, -'_fts_map'.length);\n else if (name.endsWith('_fts')) base = name.slice(0, -'_fts'.length);\n if (base === null || !base.startsWith(subgraphPrefix)) continue;\n if (liveGraphTables.has(name)) continue;\n if (names.has(base)) continue;\n orphans.push(name);\n }\n return orphans.sort();\n}\n","/**\n * Local SQLite backend over `better-sqlite3`.\n *\n * This entry point is published as `firegraph/sqlite-local` and is the only\n * module in the library that references `better-sqlite3` — keep it out of\n * `firegraph/sqlite` so that D1 / workerd bundles never see the native\n * dependency. `better-sqlite3` is loaded via dynamic `import()` at factory\n * call time, so merely importing this module stays side-effect free.\n *\n * The factory accepts either a database file path (`':memory:'` works) or an\n * already-open `better-sqlite3` Database. Path-opened databases get\n * `journal_mode = WAL` and a `busy_timeout` applied; caller-provided\n * databases are used as-is (only `busy_timeout` is set) since the caller\n * owns their pragma configuration.\n *\n * ## Search capabilities\n *\n * On top of the shared SQLite capability set, the local backend declares\n * `search.fullText` and `search.vector` (see `src/internal/sqlite-search.ts`\n * for the mechanics):\n *\n * - **Full-text search** is backed by one FTS5 table per graph table,\n * kept in sync by pure-SQL triggers installed with the table's DDL.\n * Because the triggers live in the database file, writes from ANY\n * process or connection stay indexed. The trade-off is a per-write\n * overhead (text extraction via `json_tree` + an FTS index update) on\n * every insert/update/delete.\n * - **Vector search** is a brute-force scan scored by a deterministic\n * scalar UDF registered on this connection. UDFs are connection-local:\n * `findNearest` only works through a backend created by this factory\n * (other connections to the same file can read/write normally — only\n * vector *search* needs the UDF).\n */\n\nimport type { Database as BetterSqliteDb, default as BetterSqliteDatabase } from 'better-sqlite3';\n\nimport { FiregraphError } from '../errors.js';\nimport type { StorageBackend } from '../internal/backend.js';\nimport { createCapabilities } from '../internal/backend.js';\nimport type { SqliteExecutor, SqliteTxExecutor } from '../internal/sqlite-executor.js';\nimport { quoteIdent, validateTableName } from '../internal/sqlite-schema.js';\nimport {\n buildLocalSearchDDL,\n compileFindNearest,\n compileFullTextSearch,\n computeVectorDistance,\n DISTANCE_ALIAS,\n findOrphanedFtsTables,\n ftsMapTableName,\n ftsTableName,\n setDataPath,\n VECTOR_DISTANCE_UDF,\n} from '../internal/sqlite-search.js';\nimport { rowToRecord } from '../internal/sqlite-sql.js';\nimport type { FindNearestParams, FullTextSearchParams, StoredGraphRecord } from '../types.js';\nimport type { SqliteBackendOptions, SqliteCapability, SqliteStorageBackend } from './backend.js';\nimport { createSqliteBackend } from './backend.js';\nimport { catalogTableName } from './catalog.js';\n\n/**\n * Capability union for the local better-sqlite3 backend: everything the\n * shared SQLite edition declares, plus native FTS5 full-text search and\n * brute-force vector search. `search.geo` stays out — there is no geo\n * index in stock SQLite, and a UDF-scored scan without a haversine\n * contract pinned by Firestore parity tests would be guesswork.\n */\nexport type LocalSqliteCapability = SqliteCapability | 'search.fullText' | 'search.vector';\n\nexport interface LocalSqliteBackendOptions extends SqliteBackendOptions {\n /** Root graph table name. Defaults to `'firegraph'`. */\n tableName?: string;\n /**\n * `PRAGMA busy_timeout` in milliseconds — how long a connection waits on a\n * lock held by another process before erroring. Defaults to 5000.\n */\n busyTimeoutMs?: number;\n /**\n * Extra pragmas applied after the defaults, e.g.\n * `{ synchronous: 'NORMAL', cache_size: -64000 }`. Applied in object\n * order via `PRAGMA <key> = <value>`.\n */\n pragmas?: Record<string, string | number>;\n /**\n * When opening by path: throw if the file does not already exist instead\n * of creating it. Defaults to false.\n */\n fileMustExist?: boolean;\n}\n\nexport interface LocalSqliteBackend {\n /** The graph storage backend — pass to `createGraphClient`. */\n backend: StorageBackend<LocalSqliteCapability>;\n /** The underlying better-sqlite3 database, for raw access. */\n db: BetterSqliteDb;\n /**\n * Close the database. No-op when the factory was given an already-open\n * Database (the caller owns its lifecycle).\n */\n close(): void;\n}\n\n/**\n * Build a transaction-capable `SqliteExecutor` over a better-sqlite3\n * Database. Interactive transactions use manual `BEGIN IMMEDIATE` /\n * `COMMIT` / `ROLLBACK` because `db.transaction()` requires a synchronous\n * callback while `SqliteExecutor.transaction` callbacks are async.\n *\n * Exported for callers that want to wire `createSqliteBackend` directly\n * (e.g. to share one executor across several root tables).\n */\nexport function createBetterSqliteExecutor(db: BetterSqliteDb): SqliteExecutor {\n return {\n async all(sql: string, params: unknown[]): Promise<Record<string, unknown>[]> {\n return db.prepare(sql).all(...params) as Record<string, unknown>[];\n },\n async run(sql: string, params: unknown[]): Promise<void> {\n db.prepare(sql).run(...params);\n },\n async batch(statements): Promise<void> {\n const tx = db.transaction((stmts: typeof statements) => {\n for (const s of stmts) {\n db.prepare(s.sql).run(...s.params);\n }\n });\n tx(statements);\n },\n async transaction<T>(fn: (tx: SqliteTxExecutor) => Promise<T>): Promise<T> {\n db.exec('BEGIN IMMEDIATE');\n try {\n const result = await fn({\n async all(sql: string, params: unknown[]) {\n return db.prepare(sql).all(...params) as Record<string, unknown>[];\n },\n async run(sql: string, params: unknown[]) {\n db.prepare(sql).run(...params);\n },\n });\n db.exec('COMMIT');\n return result;\n } catch (err) {\n db.exec('ROLLBACK');\n throw err;\n }\n },\n };\n}\n\nfunction isDatabase(value: unknown): value is BetterSqliteDb {\n return (\n typeof value === 'object' &&\n value !== null &&\n typeof (value as { prepare?: unknown }).prepare === 'function' &&\n typeof (value as { exec?: unknown }).exec === 'function'\n );\n}\n\nconst PRAGMA_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;\n// Pragma values are identifiers (WAL, NORMAL) or integers — never compound\n// expressions, so anything else is rejected rather than interpolated.\nconst PRAGMA_VALUE_PATTERN = /^-?[A-Za-z0-9_]+$/;\n\nfunction applyPragmas(db: BetterSqliteDb, pragmas: Record<string, string | number>): void {\n for (const [key, value] of Object.entries(pragmas)) {\n if (!PRAGMA_KEY_PATTERN.test(key)) {\n throw new FiregraphError(`Invalid pragma name: ${JSON.stringify(key)}`, 'INVALID_ARGUMENT');\n }\n if (\n !PRAGMA_VALUE_PATTERN.test(String(value)) ||\n (typeof value === 'number' && !Number.isFinite(value))\n ) {\n throw new FiregraphError(\n `Invalid pragma value for ${key}: ${JSON.stringify(value)}`,\n 'INVALID_ARGUMENT',\n );\n }\n db.pragma(`${key} = ${value}`);\n }\n}\n\n/**\n * Register the vector-distance UDF on a connection. Idempotent across\n * multiple factory calls over the same caller-provided Database —\n * better-sqlite3 raises on duplicate registration, which we swallow since\n * re-registering the identical pure function changes nothing.\n */\nfunction registerVectorUdf(db: BetterSqliteDb): void {\n try {\n db.function(VECTOR_DISTANCE_UDF, { deterministic: true }, (stored, query, measure) =>\n computeVectorDistance(stored, query, measure),\n );\n } catch {\n // Already registered on this connection.\n }\n}\n\n/**\n * After a cascade DROPs descendant graph tables, their FTS artifacts\n * (`<t>_fts`, `<t>_fts_map`) survive — triggers die with the base table\n * but separate tables do not. Sweep and drop any artifact whose base\n * graph table is gone. Stale rows in a *recreated* subgraph are handled\n * independently by the bootstrap reconciliation pass\n * (`buildFtsSyncStatements`); this sweep is what reclaims the space for\n * graphs that never come back.\n */\nasync function sweepOrphanedFtsArtifacts(\n executor: SqliteExecutor,\n rootTable: string,\n): Promise<void> {\n const tableRows = await executor.all(\n `SELECT \"name\" FROM sqlite_master WHERE \"type\" = 'table'`,\n [],\n );\n const allTables = tableRows.map((r) => String(r.name));\n const catalogRows = await executor.all(\n `SELECT \"table_name\" FROM ${quoteIdent(catalogTableName(rootTable))}`,\n [],\n );\n const catalogTables = catalogRows.map((r) => String(r.table_name));\n for (const name of findOrphanedFtsTables(allTables, catalogTables, rootTable)) {\n validateTableName(name);\n await executor.run(`DROP TABLE IF EXISTS ${quoteIdent(name)}`, []);\n }\n}\n\n/**\n * Wrap the shared SQLite backend with the two search capabilities. Every\n * core method delegates to the inner backend unchanged; `subgraph()`\n * re-wraps so children search too, and `removeNodeCascade` follows the\n * inner cascade with the orphaned-FTS sweep.\n */\nfunction wrapLocalSearchBackend(\n inner: SqliteStorageBackend,\n executor: SqliteExecutor,\n rootTable: string,\n): StorageBackend<LocalSqliteCapability> {\n const caps = new Set<LocalSqliteCapability>([\n ...(inner.capabilities.values() as IterableIterator<SqliteCapability>),\n 'search.fullText',\n 'search.vector',\n ]);\n\n // Same self-heal contract as SqliteBackendImpl.withSchema: a stale handle\n // whose table — or whose FTS artifacts, which bootstrap alongside it — was\n // dropped by a parent cascade recreates the empty graph and retries once.\n // The missing table name is matched exactly (not by prefix) so an unrelated\n // table that merely shares the prefix never triggers a re-bootstrap.\n const healableTables = new Set([\n inner.collectionPath,\n ftsTableName(inner.collectionPath),\n ftsMapTableName(inner.collectionPath),\n ]);\n const runWithSchema = async <T>(op: () => Promise<T>): Promise<T> => {\n await inner.ensureReady();\n try {\n return await op();\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n const missing = /no such table: (\\S+)/.exec(message)?.[1];\n if (missing === undefined || !healableTables.has(missing)) throw err;\n await inner.ensureReady(true);\n return op();\n }\n };\n\n const wrapper: StorageBackend<LocalSqliteCapability> = {\n capabilities: createCapabilities(caps),\n collectionPath: inner.collectionPath,\n scopePath: inner.scopePath,\n\n getDoc: (docId) => inner.getDoc(docId),\n query: (filters, options) => inner.query(filters, options),\n setDoc: (docId, record, mode) => inner.setDoc(docId, record, mode),\n updateDoc: (docId, update) => inner.updateDoc(docId, update),\n deleteDoc: (docId) => inner.deleteDoc(docId),\n runTransaction: (fn) => inner.runTransaction(fn),\n createBatch: () => inner.createBatch(),\n\n subgraph: (parentNodeUid, name) =>\n wrapLocalSearchBackend(inner.subgraph(parentNodeUid, name), executor, rootTable),\n\n removeNodeCascade: async (uid, reader, options) => {\n const result = await inner.removeNodeCascade(uid, reader, options);\n if (result.errors.length === 0) {\n await sweepOrphanedFtsArtifacts(executor, rootTable);\n }\n return result;\n },\n bulkRemoveEdges: (params, reader, options) => inner.bulkRemoveEdges(params, reader, options),\n\n aggregate: (spec, filters) => inner.aggregate!(spec, filters),\n bulkDelete: (filters, options) => inner.bulkDelete!(filters, options),\n bulkUpdate: (filters, patch, options) => inner.bulkUpdate!(filters, patch, options),\n expand: (params) => inner.expand!(params),\n findEdgesProjected: (select, filters, options) =>\n inner.findEdgesProjected!(select, filters, options),\n\n // `findEdgesGlobal` stays absent, same as the inner backend — each graph\n // is its own table; there is no cross-table index.\n\n async findNearest(params: FindNearestParams): Promise<StoredGraphRecord[]> {\n const { stmt, distancePath } = compileFindNearest(inner.collectionPath, params);\n const rows = await runWithSchema(() => executor.all(stmt.sql, stmt.params));\n return rows.map((row) => {\n const record = rowToRecord(row);\n if (distancePath) {\n const distance = row[DISTANCE_ALIAS];\n setDataPath(\n record.data as Record<string, unknown>,\n distancePath,\n typeof distance === 'number' ? distance : Number(distance),\n );\n }\n return record;\n });\n },\n\n async fullTextSearch(params: FullTextSearchParams): Promise<StoredGraphRecord[]> {\n const stmt = compileFullTextSearch(inner.collectionPath, params);\n let rows: Record<string, unknown>[];\n try {\n rows = await runWithSchema(() => executor.all(stmt.sql, stmt.params));\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n // FTS5 reports malformed MATCH expressions at query time; surface\n // them as INVALID_QUERY rather than a raw driver error.\n if (message.includes('fts5') || message.includes('unknown special query')) {\n throw new FiregraphError(\n `fullTextSearch(): invalid FTS5 query syntax — ${message}`,\n 'INVALID_QUERY',\n );\n }\n throw err;\n }\n return rows.map(rowToRecord);\n },\n };\n return wrapper;\n}\n\n/**\n * Open (or wrap) a local SQLite database and return a graph storage backend\n * over it.\n *\n * ```typescript\n * import { createLocalSqliteBackend } from 'firegraph/sqlite-local';\n * import { createGraphClient } from 'firegraph/sqlite';\n *\n * const { backend, close } = await createLocalSqliteBackend('./graph.db');\n * const client = createGraphClient(backend);\n * // ... use the client — including fullTextSearch() and findNearest() ...\n * close();\n * ```\n *\n * Requires `better-sqlite3` to be installed (declared as an optional peer\n * dependency). The factory is async because the driver is loaded via\n * dynamic `import()`.\n */\nexport async function createLocalSqliteBackend(\n pathOrDb: string | BetterSqliteDb,\n options: LocalSqliteBackendOptions = {},\n): Promise<LocalSqliteBackend> {\n const {\n tableName = 'firegraph',\n busyTimeoutMs = 5000,\n pragmas,\n fileMustExist,\n ...backendOptions\n } = options;\n\n let db: BetterSqliteDb;\n let ownsDb: boolean;\n if (typeof pathOrDb === 'string') {\n let Database: typeof BetterSqliteDatabase;\n try {\n Database = (await import('better-sqlite3')).default;\n } catch (err) {\n throw new FiregraphError(\n `createLocalSqliteBackend requires the optional peer dependency 'better-sqlite3' — install it to use the local SQLite backend (${\n err instanceof Error ? err.message : String(err)\n })`,\n 'MISSING_DEPENDENCY',\n );\n }\n db = new Database(pathOrDb, fileMustExist ? { fileMustExist: true } : {});\n ownsDb = true;\n // WAL lets concurrent readers coexist with a writer — the right default\n // for a long-lived local graph file. On ':memory:' databases SQLite\n // reports 'memory' and ignores the request, which is fine.\n db.pragma('journal_mode = WAL');\n } else if (isDatabase(pathOrDb)) {\n db = pathOrDb;\n ownsDb = false;\n } else {\n throw new FiregraphError(\n 'createLocalSqliteBackend expects a file path or an open better-sqlite3 Database',\n 'INVALID_ARGUMENT',\n );\n }\n\n db.pragma(`busy_timeout = ${Math.max(0, Math.floor(busyTimeoutMs))}`);\n if (pragmas) {\n applyPragmas(db, pragmas);\n }\n registerVectorUdf(db);\n\n // Compose the FTS DDL into the lazy bootstrap so every graph table —\n // root, lazily created subgraphs, and self-heal recreations — gets its\n // FTS infrastructure the moment the table exists.\n const userExtraDDL = backendOptions.extraTableDDL;\n const optionsWithSearch: SqliteBackendOptions = {\n ...backendOptions,\n extraTableDDL: (table) => [\n ...(userExtraDDL ? userExtraDDL(table) : []),\n ...buildLocalSearchDDL(table),\n ],\n };\n\n const executor = createBetterSqliteExecutor(db);\n const inner = createSqliteBackend(executor, tableName, optionsWithSearch);\n const backend = wrapLocalSearchBackend(inner, executor, tableName);\n let closed = false;\n return {\n backend,\n db,\n close(): void {\n if (closed || !ownsDb) return;\n closed = true;\n db.close();\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/internal/sqlite-search.ts","../../src/sqlite/local.ts"],"sourcesContent":["/**\n * Search compilation for the local SQLite backend (`firegraph/sqlite-local`).\n *\n * Two capabilities are compiled here:\n *\n * - **`search.fullText`** — an FTS5 index table per graph table, kept in\n * sync by pure-SQL triggers. Text is extracted from the `data` JSON via\n * `json_tree(...) WHERE type = 'text'`, so the triggers work from ANY\n * connection or process touching the file — no user-defined function\n * required on the write path. Queries rank with `bm25()` (lower =\n * better, so `ORDER BY bm25 ASC` is relevance-descending).\n *\n * - **`search.vector`** — brute-force k-NN via a deterministic scalar UDF\n * (`firegraph_vector_distance`) registered on the better-sqlite3\n * connection by `createLocalSqliteBackend`. There is no ANN index; the\n * engine evaluates the distance per candidate row, which is the right\n * trade-off for the local-file use case (thousands to low millions of\n * rows, zero infrastructure). UDFs are connection-local: vector search\n * only works through a connection that registered the function.\n *\n * ## FTS row keying\n *\n * The FTS5 table's `rowid` is keyed through a dedicated mapping table\n * (`<t>_fts_map`, `INTEGER PRIMARY KEY AUTOINCREMENT` → `doc_id`) rather\n * than the graph table's own rowid. The graph table has a TEXT primary key,\n * so its raw rowids are NOT stable — `VACUUM` may renumber them, silently\n * detaching every FTS entry. AUTOINCREMENT ids survive VACUUM. Storing\n * `doc_id` UNINDEXED inside the FTS table was also rejected: FTS5 can't\n * index UNINDEXED columns, making the per-write delete a full scan.\n *\n * Validation parity: error messages and codes mirror the Firestore helpers\n * (`firestore-vector.ts` / `firestore-fulltext.ts`) so a caller migrating\n * between backends sees the same failures. This module must stay free of\n * `@google-cloud/firestore` imports — it is bundled into the\n * `firegraph/sqlite-local` entry.\n */\n\nimport { FiregraphError } from '../errors.js';\nimport type { FindNearestParams, FullTextSearchParams, QueryFilter } from '../types.js';\nimport { validateJsonPathKey } from './sqlite-data-ops.js';\nimport { quoteIdent } from './sqlite-schema.js';\nimport type { CompiledStatement } from './sqlite-sql.js';\nimport { compileFilterConditions } from './sqlite-sql.js';\n\n/** Name of the connection-local vector-distance UDF. */\nexport const VECTOR_DISTANCE_UDF = 'firegraph_vector_distance';\n\n/** Column alias carrying the computed distance through the vector query. */\nexport const DISTANCE_ALIAS = '__fg_distance';\n\nconst BACKEND_ERR_LABEL = 'SQLite backend';\n\n/**\n * Built-in envelope fields that must NOT be passed as search field paths.\n * Mirrors the Firestore helpers' rejection list.\n */\nconst ENVELOPE_FIELDS: ReadonlySet<string> = new Set([\n 'aType',\n 'aUid',\n 'axbType',\n 'bType',\n 'bUid',\n 'createdAt',\n 'updatedAt',\n 'v',\n]);\n\n/** FTS5 index table for a graph table. */\nexport function ftsTableName(table: string): string {\n return `${table}_fts`;\n}\n\n/** Stable-rowid mapping table for a graph table's FTS index. */\nexport function ftsMapTableName(table: string): string {\n return `${table}_fts_map`;\n}\n\n/**\n * SQL fragment extracting every string value in a `data` JSON payload as\n * one space-joined text blob. Pure SQL (`json_tree`), so it is evaluatable\n * inside triggers from any connection.\n */\nfunction textExtractionExpr(dataRef: string): string {\n return (\n `(SELECT coalesce(group_concat(\"value\", ' '), '') ` +\n `FROM json_tree(coalesce(${dataRef}, '{}')) WHERE \"type\" = 'text')`\n );\n}\n\n/**\n * DDL installing the FTS5 infrastructure for one graph table: the mapping\n * table, the FTS5 virtual table, and three sync triggers. All statements\n * are `IF NOT EXISTS` — safe to re-run on every bootstrap.\n *\n * The AFTER INSERT trigger also fires for the INSERT arm of the backend's\n * upsert (`INSERT … ON CONFLICT DO UPDATE`); the conflict arm fires AFTER\n * UPDATE. Both re-derive the indexed text from `new.\"data\"`, and both\n * start with a defensive delete of any stale FTS row so replayed writes\n * never double-index.\n */\nexport function buildFtsDDL(table: string): string[] {\n const t = quoteIdent(table);\n const fts = quoteIdent(ftsTableName(table));\n const map = quoteIdent(ftsMapTableName(table));\n const mappedId = `(SELECT \"id\" FROM ${map} WHERE \"doc_id\" = new.\"doc_id\")`;\n // The map insert must be conflict-free rather than `INSERT OR IGNORE`:\n // when the outer statement is the backend's upsert (`INSERT … ON CONFLICT\n // DO UPDATE`), SQLite replaces conflict handling inside trigger programs\n // with the outer statement's algorithm, turning the IGNORE into an abort.\n const reindexBody =\n ` INSERT INTO ${map} (\"doc_id\") SELECT new.\"doc_id\" ` +\n `WHERE NOT EXISTS (SELECT 1 FROM ${map} WHERE \"doc_id\" = new.\"doc_id\");\\n` +\n ` DELETE FROM ${fts} WHERE rowid = ${mappedId};\\n` +\n ` INSERT INTO ${fts} (rowid, \"text\") VALUES (${mappedId}, ${textExtractionExpr('new.\"data\"')});\\n`;\n return [\n `CREATE TABLE IF NOT EXISTS ${map} (\n \"id\" INTEGER PRIMARY KEY AUTOINCREMENT,\n \"doc_id\" TEXT NOT NULL UNIQUE\n )`,\n `CREATE VIRTUAL TABLE IF NOT EXISTS ${fts} USING fts5(\"text\")`,\n `CREATE TRIGGER IF NOT EXISTS ${quoteIdent(`${table}_fts_ai`)} AFTER INSERT ON ${t} BEGIN\\n${reindexBody}END`,\n `CREATE TRIGGER IF NOT EXISTS ${quoteIdent(`${table}_fts_au`)} AFTER UPDATE ON ${t} BEGIN\\n${reindexBody}END`,\n `CREATE TRIGGER IF NOT EXISTS ${quoteIdent(`${table}_fts_ad`)} AFTER DELETE ON ${t} BEGIN\n DELETE FROM ${fts} WHERE rowid = (SELECT \"id\" FROM ${map} WHERE \"doc_id\" = old.\"doc_id\");\n DELETE FROM ${map} WHERE \"doc_id\" = old.\"doc_id\";\nEND`,\n ];\n}\n\n/**\n * Idempotent reconciliation statements run at every schema bootstrap,\n * after `buildFtsDDL`:\n *\n * 1–2. Purge FTS/map rows whose `doc_id` no longer exists in the graph\n * table. Covers the recreate-after-cascade path: a parent cascade\n * DROPs the graph table (taking the triggers with it) but leaves\n * the FTS artifacts; without the purge, a recreated subgraph would\n * surface ghost matches and hit UNIQUE violations on the map.\n * 3–4. Backfill map/FTS rows for graph rows that predate the FTS\n * infrastructure (e.g. a database written by an older firegraph).\n */\nexport function buildFtsSyncStatements(table: string): string[] {\n const t = quoteIdent(table);\n const fts = quoteIdent(ftsTableName(table));\n const map = quoteIdent(ftsMapTableName(table));\n return [\n `DELETE FROM ${fts} WHERE rowid IN (\n SELECT m.\"id\" FROM ${map} m LEFT JOIN ${t} t ON t.\"doc_id\" = m.\"doc_id\"\n WHERE t.\"doc_id\" IS NULL\n )`,\n `DELETE FROM ${map} WHERE \"doc_id\" NOT IN (SELECT \"doc_id\" FROM ${t})`,\n `INSERT OR IGNORE INTO ${map} (\"doc_id\") SELECT \"doc_id\" FROM ${t}`,\n `INSERT INTO ${fts} (rowid, \"text\")\n SELECT m.\"id\", ${textExtractionExpr('t.\"data\"')}\n FROM ${t} t JOIN ${map} m ON m.\"doc_id\" = t.\"doc_id\"\n WHERE m.\"id\" NOT IN (SELECT rowid FROM ${fts})`,\n ];\n}\n\n/**\n * Full `extraTableDDL` payload for `firegraph/sqlite-local`: FTS\n * infrastructure plus the reconciliation pass.\n */\nexport function buildLocalSearchDDL(table: string): string[] {\n return [...buildFtsDDL(table), ...buildFtsSyncStatements(table)];\n}\n\n/**\n * Normalise a caller-supplied vector / distance-result field path. Bare\n * names rewrite to `data.<name>`; `'data'` and `'data.*'` pass through;\n * envelope fields are rejected. Same contract and message shape as\n * `normalizeVectorFieldPath` in `firestore-vector.ts`.\n */\nexport function normalizeVectorFieldPath(label: string, field: string): string {\n if (ENVELOPE_FIELDS.has(field)) {\n throw new FiregraphError(\n `findNearest(): ${label} '${field}' is a built-in envelope field — ` +\n `vectors must live under \\`data.*\\`. Use a path like 'data.${field}' ` +\n `if you really meant a nested data field.`,\n 'INVALID_QUERY',\n );\n }\n if (field === 'data' || field.startsWith('data.')) return field;\n return `data.${field}`;\n}\n\n/**\n * Normalise a caller-supplied FTS field path. Same contract as\n * `normalizeFullTextFieldPath` in `firestore-fulltext.ts`.\n */\nexport function normalizeFullTextFieldPath(field: string): string {\n if (ENVELOPE_FIELDS.has(field)) {\n throw new FiregraphError(\n `fullTextSearch(): field '${field}' is a built-in envelope field — ` +\n `text-indexed fields must live under \\`data.*\\`. Use a path like ` +\n `'data.${field}' if you really meant a nested data field.`,\n 'INVALID_QUERY',\n );\n }\n if (field === 'data' || field.startsWith('data.')) return field;\n return `data.${field}`;\n}\n\n/**\n * Identifying filters (`aType` / `axbType` / `bType`) plus optional `where`.\n * Bare `where` field names rewrite to `data.<name>` — the same convention\n * `buildEdgeQueryPlan` applies for `findEdges({ where })`.\n */\nfunction buildSearchFilters(params: {\n aType?: string;\n axbType?: string;\n bType?: string;\n where?: QueryFilter[];\n}): QueryFilter[] {\n const filters: QueryFilter[] = [];\n if (params.aType) filters.push({ field: 'aType', op: '==', value: params.aType });\n if (params.axbType) filters.push({ field: 'axbType', op: '==', value: params.axbType });\n if (params.bType) filters.push({ field: 'bType', op: '==', value: params.bType });\n for (const clause of params.where ?? []) {\n const field =\n ENVELOPE_FIELDS.has(clause.field) || clause.field.startsWith('data.')\n ? clause.field\n : `data.${clause.field}`;\n filters.push({ field, op: clause.op, value: clause.value });\n }\n return filters;\n}\n\n/**\n * Compile a `fullTextSearch()` call into one SELECT over the FTS5 index.\n *\n * Validation parity with `runFirestoreFullTextSearch`: non-empty string\n * query, positive integer limit, and a non-empty `fields` list is rejected\n * with `INVALID_QUERY` (\"not yet supported\") — FTS5 column filters could\n * support per-field search later, but the single-blob index built today\n * has one `text` column, so the option is reserved rather than silently\n * mis-honoured.\n *\n * Results order by `bm25()` ascending (best match first), with `doc_id`\n * as a deterministic tie-break.\n */\nexport function compileFullTextSearch(\n table: string,\n params: FullTextSearchParams,\n): CompiledStatement {\n if (typeof params.query !== 'string' || params.query.length === 0) {\n throw new FiregraphError(\n 'fullTextSearch(): query must be a non-empty string.',\n 'INVALID_QUERY',\n );\n }\n if (!Number.isInteger(params.limit) || params.limit <= 0) {\n throw new FiregraphError(\n `fullTextSearch(): limit must be a positive integer (got ${params.limit}).`,\n 'INVALID_QUERY',\n );\n }\n const normalizedFields = params.fields?.map((f) => normalizeFullTextFieldPath(f));\n if (normalizedFields !== undefined && normalizedFields.length > 0) {\n throw new FiregraphError(\n 'fullTextSearch(): the `fields` option is not yet supported — ' +\n 'the local SQLite FTS index stores one combined text column per record. ' +\n 'Omit `fields` to search all string values.',\n 'INVALID_QUERY',\n );\n }\n\n const t = quoteIdent(table);\n const fts = quoteIdent(ftsTableName(table));\n const map = quoteIdent(ftsMapTableName(table));\n\n const sqlParams: unknown[] = [params.query];\n const conditions: string[] = [`${fts} MATCH ?`];\n conditions.push(...compileFilterConditions(buildSearchFilters(params), sqlParams));\n sqlParams.push(params.limit);\n\n const sql =\n `SELECT ${t}.* FROM ${fts} ` +\n `JOIN ${map} ON ${map}.\"id\" = ${fts}.rowid ` +\n `JOIN ${t} ON ${t}.\"doc_id\" = ${map}.\"doc_id\" ` +\n `WHERE ${conditions.join(' AND ')} ` +\n `ORDER BY bm25(${fts}) ASC, ${t}.\"doc_id\" ASC LIMIT ?`;\n return { sql, params: sqlParams };\n}\n\n/**\n * Substrings that identify a malformed-FTS5-query failure raised by the\n * FTS5 MATCH parser at *query* time.\n *\n * FTS5 reports a bad MATCH expression as a generic `SQLITE_ERROR` (the same\n * code used for ordinary SQL logic errors), not as a distinct error code, so\n * a raw better-sqlite3 `SqliteError` would otherwise escape `fullTextSearch()`\n * instead of the documented `INVALID_QUERY` (the Firestore-parity contract).\n * Matching the parser's specific complaints lets us translate query-syntax\n * failures while leaving genuine storage failures (disk I/O, corruption, lock\n * contention, a non-healable `no such table`) — which carry different messages\n * / codes — to propagate unchanged.\n *\n * Observed shapes (`code === 'SQLITE_ERROR'` for all):\n * - `\"unclosed phrase (((` → `\"unterminated string\"` (unclosed quote)\n * - `AND AND` → `\"fts5: syntax error near ...\"` (grammar error)\n * - `* leading` → `\"unknown special query: ...\"` (bad directive)\n * - `col: bar` → `\"no such column: col\"` (column filter)\n *\n * `no such column` is safe to treat as a query error here: every column the\n * compiled statement references is a fixed, real column, so the only runtime\n * source of that message is an FTS5 `col:term` filter inside the user's MATCH\n * expression — it can never originate from a genuine missing column. A\n * `no such table` miss is distinct (\"table\", not \"column\") and is handled\n * upstream by the self-heal retry, so it never reaches this matcher's scope.\n */\nconst FTS5_QUERY_ERROR_SIGNATURES: readonly string[] = [\n 'fts5: syntax error',\n 'unterminated string',\n 'unknown special query',\n 'no such column',\n];\n\n/**\n * True when `message` is the FTS5 MATCH parser rejecting a malformed query\n * string — the failures `fullTextSearch()` must surface as `INVALID_QUERY`\n * rather than as a raw driver error. See `FTS5_QUERY_ERROR_SIGNATURES`.\n */\nexport function isFts5QueryError(message: string): boolean {\n const lower = message.toLowerCase();\n return FTS5_QUERY_ERROR_SIGNATURES.some((sig) => lower.includes(sig));\n}\n\nconst DISTANCE_MEASURES: ReadonlySet<string> = new Set(['EUCLIDEAN', 'COSINE', 'DOT_PRODUCT']);\n\nexport interface CompiledVectorQuery {\n stmt: CompiledStatement;\n /**\n * `data`-relative path segments to write the computed distance into on\n * each result record, or `null` when `distanceResultField` was not set.\n */\n distancePath: string[] | null;\n}\n\n/** Resolve a `queryVector` argument to a plain `number[]`. */\nfunction toNumberArray(qv: number[] | { toArray(): number[] }): number[] {\n if (Array.isArray(qv)) return qv;\n if (typeof (qv as { toArray?: unknown }).toArray === 'function') {\n return (qv as { toArray(): number[] }).toArray();\n }\n throw new FiregraphError(\n 'findNearest(): queryVector must be a number[] or a Firestore VectorValue.',\n 'INVALID_QUERY',\n );\n}\n\n/**\n * Compile a `findNearest()` call into one SELECT that scores every\n * candidate row via the `firegraph_vector_distance` UDF.\n *\n * Shape (subquery because SQLite forbids referencing a SELECT alias in\n * the same level's WHERE):\n *\n * SELECT * FROM (\n * SELECT *, firegraph_vector_distance(json_extract(\"data\", '$.<path>'), ?, ?) AS \"__fg_distance\"\n * FROM \"<t>\" [WHERE <identifiers + where>]\n * ) WHERE \"__fg_distance\" IS NOT NULL [AND \"__fg_distance\" <=|>= ?]\n * ORDER BY \"__fg_distance\" ASC|DESC, \"doc_id\" ASC LIMIT ?\n *\n * `NULL` distances (missing field, non-array value, dimension mismatch)\n * drop out of the result, mirroring Firestore's behaviour of silently\n * skipping non-conforming documents. Threshold and ordering semantics\n * follow the `FindNearestParams.distanceThreshold` contract: `<=` /\n * ascending for EUCLIDEAN and COSINE, `>=` / descending for DOT_PRODUCT.\n *\n * Validation parity with `runFirestoreFindNearest`: envelope-field\n * rejection on both field params, non-empty query vector, positive\n * integer limit ≤ 1000.\n */\nexport function compileFindNearest(table: string, params: FindNearestParams): CompiledVectorQuery {\n const vec = toNumberArray(params.queryVector);\n if (vec.length === 0) {\n throw new FiregraphError(\n 'findNearest(): queryVector is empty — at least one dimension is required.',\n 'INVALID_QUERY',\n );\n }\n if (!Number.isInteger(params.limit) || params.limit <= 0 || params.limit > 1000) {\n throw new FiregraphError(\n `findNearest(): limit must be a positive integer ≤ 1000 (got ${params.limit}).`,\n 'INVALID_QUERY',\n );\n }\n if (!DISTANCE_MEASURES.has(params.distanceMeasure)) {\n throw new FiregraphError(\n `findNearest(): unknown distanceMeasure '${String(params.distanceMeasure)}' — ` +\n `expected EUCLIDEAN, COSINE, or DOT_PRODUCT.`,\n 'INVALID_QUERY',\n );\n }\n\n const vectorField = normalizeVectorFieldPath('vectorField', params.vectorField);\n let vectorExpr: string;\n if (vectorField === 'data') {\n vectorExpr = '\"data\"';\n } else {\n const suffix = vectorField.slice('data.'.length);\n for (const part of suffix.split('.')) {\n validateJsonPathKey(part, BACKEND_ERR_LABEL);\n }\n vectorExpr = `json_extract(\"data\", '$.${suffix}')`;\n }\n\n let distancePath: string[] | null = null;\n if (params.distanceResultField !== undefined) {\n const normalized = normalizeVectorFieldPath('distanceResultField', params.distanceResultField);\n if (normalized === 'data') {\n throw new FiregraphError(\n `findNearest(): distanceResultField 'data' would replace the entire data ` +\n `payload — use a nested path like 'data.distance'.`,\n 'INVALID_QUERY',\n );\n }\n distancePath = normalized.slice('data.'.length).split('.');\n for (const part of distancePath) {\n validateJsonPathKey(part, BACKEND_ERR_LABEL);\n }\n }\n\n // Bound-parameter order tracks placeholder order in the statement text:\n // the two UDF arguments in the SELECT list come first, then the inner\n // WHERE filters, then threshold and limit.\n const sqlParams: unknown[] = [JSON.stringify(vec), params.distanceMeasure];\n const conditions = compileFilterConditions(buildSearchFilters(params), sqlParams);\n const innerWhere = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '';\n const dist = quoteIdent(DISTANCE_ALIAS);\n const descending = params.distanceMeasure === 'DOT_PRODUCT';\n\n let sql =\n `SELECT * FROM (` +\n `SELECT *, ${VECTOR_DISTANCE_UDF}(${vectorExpr}, ?, ?) AS ${dist} ` +\n `FROM ${quoteIdent(table)}${innerWhere}` +\n `) WHERE ${dist} IS NOT NULL`;\n if (params.distanceThreshold !== undefined) {\n sql += ` AND ${dist} ${descending ? '>=' : '<='} ?`;\n sqlParams.push(params.distanceThreshold);\n }\n sql += ` ORDER BY ${dist} ${descending ? 'DESC' : 'ASC'}, \"doc_id\" ASC LIMIT ?`;\n sqlParams.push(params.limit);\n\n return { stmt: { sql, params: sqlParams }, distancePath };\n}\n\n// One-entry memo for the parsed query vector: the UDF runs once per\n// candidate row with the identical query-vector JSON, so re-parsing it\n// every call would dominate the scan cost.\nlet memoQueryJson: string | null = null;\nlet memoQueryVec: number[] | null = null;\n\n/**\n * Scalar UDF body for `firegraph_vector_distance(storedJson, queryJson,\n * measure)`. Returns the distance as a REAL, or `null` when the stored\n * value is missing, not a JSON array, dimension-mismatched, or contains\n * non-finite/non-numeric entries — NULL rows are filtered out by the\n * query, mirroring Firestore's silent skip of non-conforming documents.\n *\n * COSINE returns `1 − cos(a, b)` (Firestore's distance convention) and\n * `null` when either vector has zero norm (cosine undefined).\n *\n * Exported for direct unit testing and registered on the connection by\n * `createLocalSqliteBackend` with `deterministic: true`.\n */\nexport function computeVectorDistance(\n storedJson: unknown,\n queryJson: unknown,\n measure: unknown,\n): number | null {\n if (\n typeof storedJson !== 'string' ||\n typeof queryJson !== 'string' ||\n typeof measure !== 'string'\n ) {\n return null;\n }\n let query: number[];\n if (memoQueryJson === queryJson && memoQueryVec !== null) {\n query = memoQueryVec;\n } else {\n let parsed: unknown;\n try {\n parsed = JSON.parse(queryJson);\n } catch {\n return null;\n }\n if (!Array.isArray(parsed)) return null;\n query = parsed as number[];\n memoQueryJson = queryJson;\n memoQueryVec = query;\n }\n\n let stored: unknown;\n try {\n stored = JSON.parse(storedJson);\n } catch {\n return null;\n }\n if (!Array.isArray(stored) || stored.length !== query.length) return null;\n\n let dot = 0;\n let sumSq = 0;\n let normStored = 0;\n let normQuery = 0;\n for (let i = 0; i < query.length; i++) {\n const a = stored[i];\n const b = query[i];\n if (typeof a !== 'number' || !Number.isFinite(a)) return null;\n if (typeof b !== 'number' || !Number.isFinite(b)) return null;\n dot += a * b;\n const diff = a - b;\n sumSq += diff * diff;\n normStored += a * a;\n normQuery += b * b;\n }\n\n let result: number;\n switch (measure) {\n case 'EUCLIDEAN':\n result = Math.sqrt(sumSq);\n break;\n case 'COSINE': {\n const denom = Math.sqrt(normStored) * Math.sqrt(normQuery);\n if (denom === 0) return null;\n result = 1 - dot / denom;\n break;\n }\n case 'DOT_PRODUCT':\n result = dot;\n break;\n default:\n return null;\n }\n return Number.isFinite(result) ? result : null;\n}\n\n/**\n * Set a nested value inside a record's `data` payload, creating\n * intermediate objects along the way (replacing non-object intermediates,\n * matching Firestore's `distanceResultField` write semantics).\n */\nexport function setDataPath(\n data: Record<string, unknown>,\n path: ReadonlyArray<string>,\n value: unknown,\n): void {\n let cursor = data;\n for (let i = 0; i < path.length - 1; i++) {\n const key = path[i];\n const next = cursor[key];\n if (typeof next !== 'object' || next === null || Array.isArray(next)) {\n const created: Record<string, unknown> = {};\n cursor[key] = created;\n cursor = created;\n } else {\n cursor = next as Record<string, unknown>;\n }\n }\n cursor[path[path.length - 1]] = value;\n}\n\n/**\n * Identify orphaned FTS artifacts (`<t>_fts` / `<t>_fts_map`) whose base\n * graph table no longer exists — left behind when a parent cascade DROPs a\n * descendant subgraph table (triggers die with the table; the FTS\n * artifacts do not).\n *\n * Safety against false positives: only names under the subgraph prefix\n * (`<rootTable>_g_`) are considered, a candidate must NOT itself be a\n * registered graph table (`catalogTables` — covers a real graph whose\n * mangled scope happens to end in `_fts`), and its base table must be\n * absent from `allTables`. FTS5 shadow tables (`<t>_fts_data`,\n * `<t>_fts_idx`, …) never match the suffix patterns and are dropped\n * implicitly with their parent virtual table.\n */\nexport function findOrphanedFtsTables(\n allTables: ReadonlyArray<string>,\n catalogTables: ReadonlyArray<string>,\n rootTable: string,\n): string[] {\n const names = new Set(allTables);\n const liveGraphTables = new Set(catalogTables);\n const subgraphPrefix = `${rootTable}_g_`;\n const orphans: string[] = [];\n for (const name of names) {\n let base: string | null = null;\n if (name.endsWith('_fts_map')) base = name.slice(0, -'_fts_map'.length);\n else if (name.endsWith('_fts')) base = name.slice(0, -'_fts'.length);\n if (base === null || !base.startsWith(subgraphPrefix)) continue;\n if (liveGraphTables.has(name)) continue;\n if (names.has(base)) continue;\n orphans.push(name);\n }\n return orphans.sort();\n}\n","/**\n * Local SQLite backend over `better-sqlite3`.\n *\n * This entry point is published as `firegraph/sqlite-local` and is the only\n * module in the library that references `better-sqlite3` — keep it out of\n * `firegraph/sqlite` so that D1 / workerd bundles never see the native\n * dependency. `better-sqlite3` is loaded via dynamic `import()` at factory\n * call time, so merely importing this module stays side-effect free.\n *\n * The factory accepts either a database file path (`':memory:'` works) or an\n * already-open `better-sqlite3` Database. Path-opened databases get\n * `journal_mode = WAL` and a `busy_timeout` applied; caller-provided\n * databases are used as-is (only `busy_timeout` is set) since the caller\n * owns their pragma configuration.\n *\n * ## Search capabilities\n *\n * On top of the shared SQLite capability set, the local backend declares\n * `search.fullText` and `search.vector` (see `src/internal/sqlite-search.ts`\n * for the mechanics):\n *\n * - **Full-text search** is backed by one FTS5 table per graph table,\n * kept in sync by pure-SQL triggers installed with the table's DDL.\n * Because the triggers live in the database file, writes from ANY\n * process or connection stay indexed. The trade-off is a per-write\n * overhead (text extraction via `json_tree` + an FTS index update) on\n * every insert/update/delete.\n * - **Vector search** is a brute-force scan scored by a deterministic\n * scalar UDF registered on this connection. UDFs are connection-local:\n * `findNearest` only works through a backend created by this factory\n * (other connections to the same file can read/write normally — only\n * vector *search* needs the UDF).\n */\n\nimport type { Database as BetterSqliteDb, default as BetterSqliteDatabase } from 'better-sqlite3';\n\nimport { FiregraphError } from '../errors.js';\nimport type { StorageBackend } from '../internal/backend.js';\nimport { createCapabilities } from '../internal/backend.js';\nimport type { SqliteExecutor, SqliteTxExecutor } from '../internal/sqlite-executor.js';\nimport { quoteIdent, validateTableName } from '../internal/sqlite-schema.js';\nimport {\n buildLocalSearchDDL,\n compileFindNearest,\n compileFullTextSearch,\n computeVectorDistance,\n DISTANCE_ALIAS,\n findOrphanedFtsTables,\n ftsMapTableName,\n ftsTableName,\n isFts5QueryError,\n setDataPath,\n VECTOR_DISTANCE_UDF,\n} from '../internal/sqlite-search.js';\nimport { rowToRecord } from '../internal/sqlite-sql.js';\nimport type { FindNearestParams, FullTextSearchParams, StoredGraphRecord } from '../types.js';\nimport type { SqliteBackendOptions, SqliteCapability, SqliteStorageBackend } from './backend.js';\nimport { createSqliteBackend } from './backend.js';\nimport { catalogTableName } from './catalog.js';\n\n/**\n * Capability union for the local better-sqlite3 backend: everything the\n * shared SQLite edition declares, plus native FTS5 full-text search and\n * brute-force vector search. `search.geo` stays out — there is no geo\n * index in stock SQLite, and a UDF-scored scan without a haversine\n * contract pinned by Firestore parity tests would be guesswork.\n */\nexport type LocalSqliteCapability = SqliteCapability | 'search.fullText' | 'search.vector';\n\nexport interface LocalSqliteBackendOptions extends SqliteBackendOptions {\n /** Root graph table name. Defaults to `'firegraph'`. */\n tableName?: string;\n /**\n * `PRAGMA busy_timeout` in milliseconds — how long a connection waits on a\n * lock held by another process before erroring. Defaults to 5000.\n */\n busyTimeoutMs?: number;\n /**\n * Extra pragmas applied after the defaults, e.g.\n * `{ synchronous: 'NORMAL', cache_size: -64000 }`. Applied in object\n * order via `PRAGMA <key> = <value>`.\n */\n pragmas?: Record<string, string | number>;\n /**\n * When opening by path: throw if the file does not already exist instead\n * of creating it. Defaults to false.\n */\n fileMustExist?: boolean;\n}\n\nexport interface LocalSqliteBackend {\n /** The graph storage backend — pass to `createGraphClient`. */\n backend: StorageBackend<LocalSqliteCapability>;\n /** The underlying better-sqlite3 database, for raw access. */\n db: BetterSqliteDb;\n /**\n * Close the database. No-op when the factory was given an already-open\n * Database (the caller owns its lifecycle).\n */\n close(): void;\n}\n\n/**\n * Build a transaction-capable `SqliteExecutor` over a better-sqlite3\n * Database. Interactive transactions use manual `BEGIN IMMEDIATE` /\n * `COMMIT` / `ROLLBACK` because `db.transaction()` requires a synchronous\n * callback while `SqliteExecutor.transaction` callbacks are async.\n *\n * Exported for callers that want to wire `createSqliteBackend` directly\n * (e.g. to share one executor across several root tables).\n */\nexport function createBetterSqliteExecutor(db: BetterSqliteDb): SqliteExecutor {\n return {\n async all(sql: string, params: unknown[]): Promise<Record<string, unknown>[]> {\n return db.prepare(sql).all(...params) as Record<string, unknown>[];\n },\n async run(sql: string, params: unknown[]): Promise<void> {\n db.prepare(sql).run(...params);\n },\n async batch(statements): Promise<void> {\n const tx = db.transaction((stmts: typeof statements) => {\n for (const s of stmts) {\n db.prepare(s.sql).run(...s.params);\n }\n });\n tx(statements);\n },\n async transaction<T>(fn: (tx: SqliteTxExecutor) => Promise<T>): Promise<T> {\n db.exec('BEGIN IMMEDIATE');\n try {\n const result = await fn({\n async all(sql: string, params: unknown[]) {\n return db.prepare(sql).all(...params) as Record<string, unknown>[];\n },\n async run(sql: string, params: unknown[]) {\n db.prepare(sql).run(...params);\n },\n });\n db.exec('COMMIT');\n return result;\n } catch (err) {\n db.exec('ROLLBACK');\n throw err;\n }\n },\n };\n}\n\nfunction isDatabase(value: unknown): value is BetterSqliteDb {\n return (\n typeof value === 'object' &&\n value !== null &&\n typeof (value as { prepare?: unknown }).prepare === 'function' &&\n typeof (value as { exec?: unknown }).exec === 'function'\n );\n}\n\nconst PRAGMA_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;\n// Pragma values are identifiers (WAL, NORMAL) or integers — never compound\n// expressions, so anything else is rejected rather than interpolated.\nconst PRAGMA_VALUE_PATTERN = /^-?[A-Za-z0-9_]+$/;\n\nfunction applyPragmas(db: BetterSqliteDb, pragmas: Record<string, string | number>): void {\n for (const [key, value] of Object.entries(pragmas)) {\n if (!PRAGMA_KEY_PATTERN.test(key)) {\n throw new FiregraphError(`Invalid pragma name: ${JSON.stringify(key)}`, 'INVALID_ARGUMENT');\n }\n if (\n !PRAGMA_VALUE_PATTERN.test(String(value)) ||\n (typeof value === 'number' && !Number.isFinite(value))\n ) {\n throw new FiregraphError(\n `Invalid pragma value for ${key}: ${JSON.stringify(value)}`,\n 'INVALID_ARGUMENT',\n );\n }\n db.pragma(`${key} = ${value}`);\n }\n}\n\n/**\n * Register the vector-distance UDF on a connection. Idempotent across\n * multiple factory calls over the same caller-provided Database —\n * better-sqlite3 raises on duplicate registration, which we swallow since\n * re-registering the identical pure function changes nothing.\n */\nfunction registerVectorUdf(db: BetterSqliteDb): void {\n try {\n db.function(VECTOR_DISTANCE_UDF, { deterministic: true }, (stored, query, measure) =>\n computeVectorDistance(stored, query, measure),\n );\n } catch {\n // Already registered on this connection.\n }\n}\n\n/**\n * After a cascade DROPs descendant graph tables, their FTS artifacts\n * (`<t>_fts`, `<t>_fts_map`) survive — triggers die with the base table\n * but separate tables do not. Sweep and drop any artifact whose base\n * graph table is gone. Stale rows in a *recreated* subgraph are handled\n * independently by the bootstrap reconciliation pass\n * (`buildFtsSyncStatements`); this sweep is what reclaims the space for\n * graphs that never come back.\n */\nasync function sweepOrphanedFtsArtifacts(\n executor: SqliteExecutor,\n rootTable: string,\n): Promise<void> {\n const tableRows = await executor.all(\n `SELECT \"name\" FROM sqlite_master WHERE \"type\" = 'table'`,\n [],\n );\n const allTables = tableRows.map((r) => String(r.name));\n const catalogRows = await executor.all(\n `SELECT \"table_name\" FROM ${quoteIdent(catalogTableName(rootTable))}`,\n [],\n );\n const catalogTables = catalogRows.map((r) => String(r.table_name));\n for (const name of findOrphanedFtsTables(allTables, catalogTables, rootTable)) {\n validateTableName(name);\n await executor.run(`DROP TABLE IF EXISTS ${quoteIdent(name)}`, []);\n }\n}\n\n/**\n * Wrap the shared SQLite backend with the two search capabilities. Every\n * core method delegates to the inner backend unchanged; `subgraph()`\n * re-wraps so children search too, and `removeNodeCascade` follows the\n * inner cascade with the orphaned-FTS sweep.\n */\nfunction wrapLocalSearchBackend(\n inner: SqliteStorageBackend,\n executor: SqliteExecutor,\n rootTable: string,\n): StorageBackend<LocalSqliteCapability> {\n const caps = new Set<LocalSqliteCapability>([\n ...(inner.capabilities.values() as IterableIterator<SqliteCapability>),\n 'search.fullText',\n 'search.vector',\n ]);\n\n // Same self-heal contract as SqliteBackendImpl.withSchema: a stale handle\n // whose table — or whose FTS artifacts, which bootstrap alongside it — was\n // dropped by a parent cascade recreates the empty graph and retries once.\n // The missing table name is matched exactly (not by prefix) so an unrelated\n // table that merely shares the prefix never triggers a re-bootstrap.\n const healableTables = new Set([\n inner.collectionPath,\n ftsTableName(inner.collectionPath),\n ftsMapTableName(inner.collectionPath),\n ]);\n const runWithSchema = async <T>(op: () => Promise<T>): Promise<T> => {\n await inner.ensureReady();\n try {\n return await op();\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n const missing = /no such table: (\\S+)/.exec(message)?.[1];\n if (missing === undefined || !healableTables.has(missing)) throw err;\n await inner.ensureReady(true);\n return op();\n }\n };\n\n const wrapper: StorageBackend<LocalSqliteCapability> = {\n capabilities: createCapabilities(caps),\n collectionPath: inner.collectionPath,\n scopePath: inner.scopePath,\n\n getDoc: (docId) => inner.getDoc(docId),\n query: (filters, options) => inner.query(filters, options),\n setDoc: (docId, record, mode) => inner.setDoc(docId, record, mode),\n updateDoc: (docId, update) => inner.updateDoc(docId, update),\n deleteDoc: (docId) => inner.deleteDoc(docId),\n runTransaction: (fn) => inner.runTransaction(fn),\n createBatch: () => inner.createBatch(),\n\n subgraph: (parentNodeUid, name) =>\n wrapLocalSearchBackend(inner.subgraph(parentNodeUid, name), executor, rootTable),\n\n removeNodeCascade: async (uid, reader, options) => {\n const result = await inner.removeNodeCascade(uid, reader, options);\n if (result.errors.length === 0) {\n await sweepOrphanedFtsArtifacts(executor, rootTable);\n }\n return result;\n },\n bulkRemoveEdges: (params, reader, options) => inner.bulkRemoveEdges(params, reader, options),\n\n aggregate: (spec, filters) => inner.aggregate!(spec, filters),\n bulkDelete: (filters, options) => inner.bulkDelete!(filters, options),\n bulkUpdate: (filters, patch, options) => inner.bulkUpdate!(filters, patch, options),\n expand: (params) => inner.expand!(params),\n findEdgesProjected: (select, filters, options) =>\n inner.findEdgesProjected!(select, filters, options),\n\n // `findEdgesGlobal` stays absent, same as the inner backend — each graph\n // is its own table; there is no cross-table index.\n\n async findNearest(params: FindNearestParams): Promise<StoredGraphRecord[]> {\n const { stmt, distancePath } = compileFindNearest(inner.collectionPath, params);\n const rows = await runWithSchema(() => executor.all(stmt.sql, stmt.params));\n return rows.map((row) => {\n const record = rowToRecord(row);\n if (distancePath) {\n const distance = row[DISTANCE_ALIAS];\n setDataPath(\n record.data as Record<string, unknown>,\n distancePath,\n typeof distance === 'number' ? distance : Number(distance),\n );\n }\n return record;\n });\n },\n\n async fullTextSearch(params: FullTextSearchParams): Promise<StoredGraphRecord[]> {\n const stmt = compileFullTextSearch(inner.collectionPath, params);\n let rows: Record<string, unknown>[];\n try {\n rows = await runWithSchema(() => executor.all(stmt.sql, stmt.params));\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n // FTS5 reports a malformed MATCH expression at query time as a generic\n // SQLITE_ERROR (e.g. \"unterminated string\", \"fts5: syntax error\",\n // \"unknown special query\"); surface those as INVALID_QUERY to match the\n // documented Firestore-parity contract. Genuine storage errors (disk\n // I/O, corruption, a non-healable missing table) carry different\n // messages and propagate unchanged.\n if (isFts5QueryError(message)) {\n throw new FiregraphError(\n `fullTextSearch(): invalid FTS5 query syntax — ${message}`,\n 'INVALID_QUERY',\n );\n }\n throw err;\n }\n return rows.map(rowToRecord);\n },\n };\n return wrapper;\n}\n\n/**\n * Open (or wrap) a local SQLite database and return a graph storage backend\n * over it.\n *\n * ```typescript\n * import { createLocalSqliteBackend } from 'firegraph/sqlite-local';\n * import { createGraphClient } from 'firegraph/sqlite';\n *\n * const { backend, close } = await createLocalSqliteBackend('./graph.db');\n * const client = createGraphClient(backend);\n * // ... use the client — including fullTextSearch() and findNearest() ...\n * close();\n * ```\n *\n * Requires `better-sqlite3` to be installed (declared as an optional peer\n * dependency). The factory is async because the driver is loaded via\n * dynamic `import()`.\n */\nexport async function createLocalSqliteBackend(\n pathOrDb: string | BetterSqliteDb,\n options: LocalSqliteBackendOptions = {},\n): Promise<LocalSqliteBackend> {\n const {\n tableName = 'firegraph',\n busyTimeoutMs = 5000,\n pragmas,\n fileMustExist,\n ...backendOptions\n } = options;\n\n let db: BetterSqliteDb;\n let ownsDb: boolean;\n if (typeof pathOrDb === 'string') {\n let Database: typeof BetterSqliteDatabase;\n try {\n Database = (await import('better-sqlite3')).default;\n } catch (err) {\n throw new FiregraphError(\n `createLocalSqliteBackend requires the optional peer dependency 'better-sqlite3' — install it to use the local SQLite backend (${\n err instanceof Error ? err.message : String(err)\n })`,\n 'MISSING_DEPENDENCY',\n );\n }\n db = new Database(pathOrDb, fileMustExist ? { fileMustExist: true } : {});\n ownsDb = true;\n // WAL lets concurrent readers coexist with a writer — the right default\n // for a long-lived local graph file. On ':memory:' databases SQLite\n // reports 'memory' and ignores the request, which is fine.\n db.pragma('journal_mode = WAL');\n } else if (isDatabase(pathOrDb)) {\n db = pathOrDb;\n ownsDb = false;\n } else {\n throw new FiregraphError(\n 'createLocalSqliteBackend expects a file path or an open better-sqlite3 Database',\n 'INVALID_ARGUMENT',\n );\n }\n\n db.pragma(`busy_timeout = ${Math.max(0, Math.floor(busyTimeoutMs))}`);\n if (pragmas) {\n applyPragmas(db, pragmas);\n }\n registerVectorUdf(db);\n\n // Compose the FTS DDL into the lazy bootstrap so every graph table —\n // root, lazily created subgraphs, and self-heal recreations — gets its\n // FTS infrastructure the moment the table exists.\n const userExtraDDL = backendOptions.extraTableDDL;\n const optionsWithSearch: SqliteBackendOptions = {\n ...backendOptions,\n extraTableDDL: (table) => [\n ...(userExtraDDL ? userExtraDDL(table) : []),\n ...buildLocalSearchDDL(table),\n ],\n };\n\n const executor = createBetterSqliteExecutor(db);\n const inner = createSqliteBackend(executor, tableName, optionsWithSearch);\n const backend = wrapLocalSearchBackend(inner, executor, tableName);\n let closed = false;\n return {\n backend,\n db,\n close(): void {\n if (closed || !ownsDb) return;\n closed = true;\n db.close();\n },\n };\n}\n"],"mappings":"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","names":[]}
|