@monlite/fts 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,6 +58,23 @@ import { reindex } from "@monlite/fts";
58
58
  reindex(db, "posts", ["title", "body"]); // rebuild a collection's index
59
59
  ```
60
60
 
61
+ ## Dynamic index — `createSearchIndex(db)`
62
+
63
+ The `fts()` plugin attaches `collection.search()` to a document collection with a **static
64
+ spec**. For a **programmatic** index over collections created **at runtime** (RAG, per-tenant),
65
+ use `createSearchIndex(db)`:
66
+
67
+ ```ts
68
+ import { createSearchIndex } from "@monlite/fts";
69
+ const idx = createSearchIndex(db);
70
+ idx.ensureCollection("docs", { fields: ["title", "body"], filterFields: ["docId"] });
71
+ idx.upsert("docs", [{ id: "c1", fields: { title, body }, filters: { docId: "d1" } }]);
72
+ idx.search("docs", "hello world", { where: { docId: "d1" } }); // scoped to one case/tenant
73
+ ```
74
+
75
+ Each collection is its own FTS5 table; `filterFields` are UNINDEXED so a `where` scopes the
76
+ MATCH. Synchronous.
77
+
61
78
  ## How it works
62
79
 
63
80
  For each configured collection, the plugin creates an FTS5 virtual table
package/dist/index.cjs CHANGED
@@ -127,8 +127,104 @@ function fts(spec) {
127
127
  }
128
128
  };
129
129
  }
130
+ var FTS_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
131
+ function ftsIdent(name) {
132
+ if (!FTS_IDENT.test(name))
133
+ throw new Error(`@monlite/fts: unsafe collection/field name "${name}"`);
134
+ return name;
135
+ }
136
+ function createSearchIndex(db) {
137
+ const configs = /* @__PURE__ */ new Map();
138
+ const create = (name, fields, filterFields) => {
139
+ const n = ftsIdent(name);
140
+ const fcols = fields.map(ftsIdent).join(", ");
141
+ const ucols = filterFields.map((f) => `, ${ftsIdent(f)} UNINDEXED`).join("");
142
+ db.sqlite.exec(
143
+ `CREATE VIRTUAL TABLE IF NOT EXISTS "${n}" USING fts5(doc_id UNINDEXED, ${fcols}${ucols})`
144
+ );
145
+ configs.set(name, { fields, filterFields });
146
+ return configs.get(name);
147
+ };
148
+ const known = (name) => {
149
+ if (configs.has(name)) return configs.get(name);
150
+ const row = db.sqlite.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`).get(name);
151
+ if (row) {
152
+ configs.set(name, { fields: [], filterFields: [] });
153
+ return configs.get(name);
154
+ }
155
+ return void 0;
156
+ };
157
+ return {
158
+ ensureCollection(name, { fields, filterFields = [] }) {
159
+ if (!configs.has(name)) create(name, fields, filterFields);
160
+ },
161
+ upsert(name, points) {
162
+ if (!points?.length) return;
163
+ const cfg = configs.get(name);
164
+ if (!cfg)
165
+ throw new Error(
166
+ `@monlite/fts: ensureCollection("${name}") before upsert`
167
+ );
168
+ const cols = [...cfg.fields, ...cfg.filterFields];
169
+ const colList = cols.map((c) => `, ${c}`).join("");
170
+ const ph = cols.map(() => ", ?").join("");
171
+ const del = db.sqlite.prepare(`DELETE FROM "${name}" WHERE doc_id = ?`);
172
+ const ins = db.sqlite.prepare(
173
+ `INSERT INTO "${name}"(doc_id${colList}) VALUES (?${ph})`
174
+ );
175
+ db.sqlite.exec("BEGIN");
176
+ try {
177
+ for (const p of points) {
178
+ del.run(p.id);
179
+ const vals = [
180
+ ...cfg.fields.map((f) => p.fields?.[f] ?? ""),
181
+ ...cfg.filterFields.map((f) => p.filters?.[f] ?? "")
182
+ ];
183
+ ins.run(p.id, ...vals);
184
+ }
185
+ db.sqlite.exec("COMMIT");
186
+ } catch (err) {
187
+ try {
188
+ db.sqlite.exec("ROLLBACK");
189
+ } catch {
190
+ }
191
+ throw err;
192
+ }
193
+ },
194
+ search(name, query, opts = {}) {
195
+ const cfg = known(name);
196
+ if (!cfg) return [];
197
+ const limit = opts.limit ?? 50;
198
+ const where = Object.entries(opts.where ?? {}).filter(
199
+ ([, v]) => v != null
200
+ );
201
+ const clause = where.map(([k]) => ` AND ${ftsIdent(k)} = ?`).join("");
202
+ const sql = `SELECT doc_id, rank FROM "${name}" WHERE "${name}" MATCH ?${clause} ORDER BY rank LIMIT ?`;
203
+ let rows;
204
+ try {
205
+ rows = db.sqlite.prepare(sql).all(query, ...where.map(([, v]) => v), limit);
206
+ } catch {
207
+ return [];
208
+ }
209
+ return rows.map((r) => ({ id: r.doc_id, score: -r.rank }));
210
+ },
211
+ delete(name, { id, where }) {
212
+ const cfg = known(name);
213
+ if (!cfg) return;
214
+ if (id != null) {
215
+ db.sqlite.prepare(`DELETE FROM "${name}" WHERE doc_id = ?`).run(id);
216
+ return;
217
+ }
218
+ const pairs = Object.entries(where ?? {}).filter(([, v]) => v != null);
219
+ if (!pairs.length) return;
220
+ const clause = pairs.map(([k]) => `${ftsIdent(k)} = ?`).join(" AND ");
221
+ db.sqlite.prepare(`DELETE FROM "${name}" WHERE ${clause}`).run(...pairs.map(([, v]) => v));
222
+ }
223
+ };
224
+ }
130
225
 
131
226
  exports.catchUp = catchUp;
227
+ exports.createSearchIndex = createSearchIndex;
132
228
  exports.fts = fts;
133
229
  exports.reindex = reindex;
134
230
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["import type {\n Collection,\n Doc,\n Monlite,\n MonlitePlugin,\n WhereInput,\n WithId,\n} from \"@monlite/core\";\n\n/** Map of collection name → searchable field paths (dot-notation allowed). */\nexport type FtsSpec = Record<string, string[]>;\n\nexport interface SearchOptions<T = Doc> {\n /** Max results (default 50). */\n limit?: number;\n /** Additionally constrain matches with a normal monlite where clause. */\n where?: WhereInput<T>;\n}\n\nexport type SearchResult<T = Doc> = WithId<T> & { _score: number };\n\n// Make `collection.search()` typed wherever @monlite/fts is imported.\ndeclare module \"@monlite/core\" {\n interface Collection<T> {\n search(query: string, opts?: SearchOptions<T>): Promise<SearchResult<T>[]>;\n /** Pick up documents written by another process; returns counts. */\n catchUp(): { indexed: number; removed: number };\n }\n}\n\nconst ftsTable = (coll: string) => `${coll}_fts`;\nconst col = (i: number) => `f${i}`;\nconst STATE = \"_monlite_fts_state\";\n\nfunction ensureState(db: Monlite): void {\n db.sqlite.exec(\n `CREATE TABLE IF NOT EXISTS ${STATE} (coll TEXT PRIMARY KEY, high_water INTEGER NOT NULL)`,\n );\n}\nfunction getHighWater(db: Monlite, coll: string): number {\n const row = db.sqlite\n .prepare(`SELECT high_water FROM ${STATE} WHERE coll = ?`)\n .get(coll) as { high_water: number } | undefined;\n return row?.high_water ?? 0;\n}\nfunction setHighWater(db: Monlite, coll: string, value: number): void {\n db.sqlite\n .prepare(\n `INSERT INTO ${STATE}(coll, high_water) VALUES (?, ?) ON CONFLICT(coll) DO UPDATE SET high_water = excluded.high_water`,\n )\n .run(coll, value);\n}\n\n/**\n * Incrementally index documents written by another process (and drop entries for\n * cross-process deletes), so a separate searcher process becomes fresh without a\n * full {@link reindex}. Returns how many docs were (re)indexed and removed.\n */\nexport function catchUp(\n db: Monlite,\n coll: string,\n fields: string[],\n): { indexed: number; removed: number } {\n const sqlite = db.sqlite;\n ensureState(db);\n const hw = getHighWater(db, coll);\n const docs = sqlite\n .prepare(`SELECT _id, updated_at FROM \"${coll}\" WHERE updated_at >= ?`)\n .all(hw) as Array<{ _id: string; updated_at: number }>;\n let max = hw;\n for (const d of docs) {\n indexDoc(db, coll, fields, d._id);\n if (d.updated_at > max) max = d.updated_at;\n }\n // Remove index rows whose document was deleted (possibly by another process).\n const orphans = sqlite\n .prepare(\n `SELECT doc_id FROM \"${ftsTable(coll)}\" WHERE doc_id NOT IN (SELECT _id FROM \"${coll}\")`,\n )\n .all() as Array<{ doc_id: string }>;\n const del = sqlite.prepare(\n `DELETE FROM \"${ftsTable(coll)}\" WHERE doc_id = ?`,\n );\n for (const o of orphans) del.run(o.doc_id);\n setHighWater(db, coll, max);\n return { indexed: docs.length, removed: orphans.length };\n}\n\n/** Extract searchable text for a field path from a document. */\nfunction extractText(doc: Record<string, any>, path: string): string {\n let cur: any = doc;\n for (const seg of path.split(\".\")) {\n if (cur == null) return \"\";\n cur = cur[seg];\n }\n if (cur == null) return \"\";\n if (typeof cur === \"string\") return cur;\n if (Array.isArray(cur))\n return cur\n .filter((x) => x != null)\n .map(String)\n .join(\" \");\n if (typeof cur === \"number\" || typeof cur === \"boolean\") return String(cur);\n return \"\";\n}\n\nfunction indexDoc(\n db: Monlite,\n coll: string,\n fields: string[],\n id: string,\n): void {\n const sqlite = db.sqlite;\n sqlite.prepare(`DELETE FROM \"${ftsTable(coll)}\" WHERE doc_id = ?`).run(id);\n const doc = db.collection(coll).getRaw(id);\n if (!doc) return; // deleted\n const cols = fields.map((_, i) => `\"${col(i)}\"`).join(\", \");\n const placeholders = fields.map(() => \"?\").join(\", \");\n const values = fields.map((f) => extractText(doc, f));\n sqlite\n .prepare(\n `INSERT INTO \"${ftsTable(coll)}\"(doc_id, ${cols}) VALUES (?, ${placeholders})`,\n )\n .run(id, ...values);\n}\n\n/** Rebuild a collection's FTS index from scratch. */\nexport function reindex(db: Monlite, coll: string, fields: string[]): void {\n const sqlite = db.sqlite;\n sqlite.exec(`DELETE FROM \"${ftsTable(coll)}\"`);\n for (const doc of db.collection(coll).findManyCore({})) {\n indexDoc(db, coll, fields, (doc as WithId<Doc>)._id);\n }\n}\n\nfunction search<T = Doc>(\n db: Monlite,\n coll: Collection<T>,\n spec: FtsSpec,\n query: string,\n opts?: SearchOptions<T>,\n): Promise<SearchResult<T>[]> {\n const fields = spec[coll.name];\n if (!fields) {\n throw new Error(\n `Collection \"${coll.name}\" is not configured for full-text search`,\n );\n }\n const limit = opts?.limit ?? 50;\n const rows = db.sqlite\n .prepare(\n `SELECT doc_id, rank FROM \"${ftsTable(coll.name)}\" ` +\n `WHERE \"${ftsTable(coll.name)}\" MATCH ? ORDER BY rank LIMIT ?`,\n )\n .all(query, limit) as Array<{ doc_id: string; rank: number }>;\n\n let allowed: Set<string> | null = null;\n if (opts?.where) {\n const ids = rows.map((r) => r.doc_id);\n const idIn = { _id: { in: ids } } as WhereInput<T>;\n const matching = coll.findManyCore({\n where: { AND: [opts.where, idIn] } as WhereInput<T>,\n });\n allowed = new Set(matching.map((d) => d._id));\n }\n\n const out: SearchResult<T>[] = [];\n for (const r of rows) {\n if (allowed && !allowed.has(r.doc_id)) continue;\n const doc = coll.getRaw(r.doc_id);\n if (doc) out.push({ ...doc, _score: -r.rank } as SearchResult<T>);\n }\n return Promise.resolve(out);\n}\n\n/**\n * Full-text search plugin (SQLite FTS5). Pass it to `createDb({ plugins: [...] })`\n * with a map of collection → searchable fields. Adds `collection.search()`, keeps\n * the index in sync on every write, and backfills existing documents on open.\n *\n * ```ts\n * const db = createDb(\"./app.db\", { plugins: [fts({ posts: [\"title\", \"body\"] })] });\n * await db.collection(\"posts\").search(\"hello world\");\n * ```\n */\nexport function fts(spec: FtsSpec): MonlitePlugin {\n let database: Monlite;\n return {\n name: \"fts\",\n init(db) {\n database = db;\n ensureState(db);\n for (const [coll, fields] of Object.entries(spec)) {\n const cols = fields.map((_, i) => `\"${col(i)}\"`).join(\", \");\n db.sqlite.exec(\n `CREATE VIRTUAL TABLE IF NOT EXISTS \"${ftsTable(coll)}\" ` +\n `USING fts5(doc_id UNINDEXED, ${cols})`,\n );\n // Backfill when the index is empty (e.g. enabling FTS on an existing db).\n const count = db.sqlite\n .prepare(`SELECT count(*) AS n FROM \"${ftsTable(coll)}\"`)\n .get() as { n: number };\n if (count.n === 0) reindex(db, coll, fields);\n // Pick up anything other processes wrote since we last indexed.\n catchUp(db, coll, fields);\n }\n },\n afterWrite(db, { collection, ids }) {\n const fields = spec[collection];\n if (!fields) return;\n for (const id of ids) indexDoc(db, collection, fields, id);\n setHighWater(db, collection, Date.now()); // our index is current to now\n },\n collectionMethods: {\n search: (collection, query: string, opts?: SearchOptions) =>\n search(database, collection, spec, query, opts),\n catchUp: (collection) =>\n catchUp(database, collection.name, spec[collection.name] ?? []),\n },\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["import type {\n Collection,\n Doc,\n Monlite,\n MonlitePlugin,\n WhereInput,\n WithId,\n} from \"@monlite/core\";\n\n/** Map of collection name → searchable field paths (dot-notation allowed). */\nexport type FtsSpec = Record<string, string[]>;\n\nexport interface SearchOptions<T = Doc> {\n /** Max results (default 50). */\n limit?: number;\n /** Additionally constrain matches with a normal monlite where clause. */\n where?: WhereInput<T>;\n}\n\nexport type SearchResult<T = Doc> = WithId<T> & { _score: number };\n\n// Make `collection.search()` typed wherever @monlite/fts is imported.\ndeclare module \"@monlite/core\" {\n interface Collection<T> {\n search(query: string, opts?: SearchOptions<T>): Promise<SearchResult<T>[]>;\n /** Pick up documents written by another process; returns counts. */\n catchUp(): { indexed: number; removed: number };\n }\n}\n\nconst ftsTable = (coll: string) => `${coll}_fts`;\nconst col = (i: number) => `f${i}`;\nconst STATE = \"_monlite_fts_state\";\n\nfunction ensureState(db: Monlite): void {\n db.sqlite.exec(\n `CREATE TABLE IF NOT EXISTS ${STATE} (coll TEXT PRIMARY KEY, high_water INTEGER NOT NULL)`,\n );\n}\nfunction getHighWater(db: Monlite, coll: string): number {\n const row = db.sqlite\n .prepare(`SELECT high_water FROM ${STATE} WHERE coll = ?`)\n .get(coll) as { high_water: number } | undefined;\n return row?.high_water ?? 0;\n}\nfunction setHighWater(db: Monlite, coll: string, value: number): void {\n db.sqlite\n .prepare(\n `INSERT INTO ${STATE}(coll, high_water) VALUES (?, ?) ON CONFLICT(coll) DO UPDATE SET high_water = excluded.high_water`,\n )\n .run(coll, value);\n}\n\n/**\n * Incrementally index documents written by another process (and drop entries for\n * cross-process deletes), so a separate searcher process becomes fresh without a\n * full {@link reindex}. Returns how many docs were (re)indexed and removed.\n */\nexport function catchUp(\n db: Monlite,\n coll: string,\n fields: string[],\n): { indexed: number; removed: number } {\n const sqlite = db.sqlite;\n ensureState(db);\n const hw = getHighWater(db, coll);\n const docs = sqlite\n .prepare(`SELECT _id, updated_at FROM \"${coll}\" WHERE updated_at >= ?`)\n .all(hw) as Array<{ _id: string; updated_at: number }>;\n let max = hw;\n for (const d of docs) {\n indexDoc(db, coll, fields, d._id);\n if (d.updated_at > max) max = d.updated_at;\n }\n // Remove index rows whose document was deleted (possibly by another process).\n const orphans = sqlite\n .prepare(\n `SELECT doc_id FROM \"${ftsTable(coll)}\" WHERE doc_id NOT IN (SELECT _id FROM \"${coll}\")`,\n )\n .all() as Array<{ doc_id: string }>;\n const del = sqlite.prepare(\n `DELETE FROM \"${ftsTable(coll)}\" WHERE doc_id = ?`,\n );\n for (const o of orphans) del.run(o.doc_id);\n setHighWater(db, coll, max);\n return { indexed: docs.length, removed: orphans.length };\n}\n\n/** Extract searchable text for a field path from a document. */\nfunction extractText(doc: Record<string, any>, path: string): string {\n let cur: any = doc;\n for (const seg of path.split(\".\")) {\n if (cur == null) return \"\";\n cur = cur[seg];\n }\n if (cur == null) return \"\";\n if (typeof cur === \"string\") return cur;\n if (Array.isArray(cur))\n return cur\n .filter((x) => x != null)\n .map(String)\n .join(\" \");\n if (typeof cur === \"number\" || typeof cur === \"boolean\") return String(cur);\n return \"\";\n}\n\nfunction indexDoc(\n db: Monlite,\n coll: string,\n fields: string[],\n id: string,\n): void {\n const sqlite = db.sqlite;\n sqlite.prepare(`DELETE FROM \"${ftsTable(coll)}\" WHERE doc_id = ?`).run(id);\n const doc = db.collection(coll).getRaw(id);\n if (!doc) return; // deleted\n const cols = fields.map((_, i) => `\"${col(i)}\"`).join(\", \");\n const placeholders = fields.map(() => \"?\").join(\", \");\n const values = fields.map((f) => extractText(doc, f));\n sqlite\n .prepare(\n `INSERT INTO \"${ftsTable(coll)}\"(doc_id, ${cols}) VALUES (?, ${placeholders})`,\n )\n .run(id, ...values);\n}\n\n/** Rebuild a collection's FTS index from scratch. */\nexport function reindex(db: Monlite, coll: string, fields: string[]): void {\n const sqlite = db.sqlite;\n sqlite.exec(`DELETE FROM \"${ftsTable(coll)}\"`);\n for (const doc of db.collection(coll).findManyCore({})) {\n indexDoc(db, coll, fields, (doc as WithId<Doc>)._id);\n }\n}\n\nfunction search<T = Doc>(\n db: Monlite,\n coll: Collection<T>,\n spec: FtsSpec,\n query: string,\n opts?: SearchOptions<T>,\n): Promise<SearchResult<T>[]> {\n const fields = spec[coll.name];\n if (!fields) {\n throw new Error(\n `Collection \"${coll.name}\" is not configured for full-text search`,\n );\n }\n const limit = opts?.limit ?? 50;\n const rows = db.sqlite\n .prepare(\n `SELECT doc_id, rank FROM \"${ftsTable(coll.name)}\" ` +\n `WHERE \"${ftsTable(coll.name)}\" MATCH ? ORDER BY rank LIMIT ?`,\n )\n .all(query, limit) as Array<{ doc_id: string; rank: number }>;\n\n let allowed: Set<string> | null = null;\n if (opts?.where) {\n const ids = rows.map((r) => r.doc_id);\n const idIn = { _id: { in: ids } } as WhereInput<T>;\n const matching = coll.findManyCore({\n where: { AND: [opts.where, idIn] } as WhereInput<T>,\n });\n allowed = new Set(matching.map((d) => d._id));\n }\n\n const out: SearchResult<T>[] = [];\n for (const r of rows) {\n if (allowed && !allowed.has(r.doc_id)) continue;\n const doc = coll.getRaw(r.doc_id);\n if (doc) out.push({ ...doc, _score: -r.rank } as SearchResult<T>);\n }\n return Promise.resolve(out);\n}\n\n/**\n * Full-text search plugin (SQLite FTS5). Pass it to `createDb({ plugins: [...] })`\n * with a map of collection → searchable fields. Adds `collection.search()`, keeps\n * the index in sync on every write, and backfills existing documents on open.\n *\n * ```ts\n * const db = createDb(\"./app.db\", { plugins: [fts({ posts: [\"title\", \"body\"] })] });\n * await db.collection(\"posts\").search(\"hello world\");\n * ```\n */\nexport function fts(spec: FtsSpec): MonlitePlugin {\n let database: Monlite;\n return {\n name: \"fts\",\n init(db) {\n database = db;\n ensureState(db);\n for (const [coll, fields] of Object.entries(spec)) {\n const cols = fields.map((_, i) => `\"${col(i)}\"`).join(\", \");\n db.sqlite.exec(\n `CREATE VIRTUAL TABLE IF NOT EXISTS \"${ftsTable(coll)}\" ` +\n `USING fts5(doc_id UNINDEXED, ${cols})`,\n );\n // Backfill when the index is empty (e.g. enabling FTS on an existing db).\n const count = db.sqlite\n .prepare(`SELECT count(*) AS n FROM \"${ftsTable(coll)}\"`)\n .get() as { n: number };\n if (count.n === 0) reindex(db, coll, fields);\n // Pick up anything other processes wrote since we last indexed.\n catchUp(db, coll, fields);\n }\n },\n afterWrite(db, { collection, ids }) {\n const fields = spec[collection];\n if (!fields) return;\n for (const id of ids) indexDoc(db, collection, fields, id);\n setHighWater(db, collection, Date.now()); // our index is current to now\n },\n collectionMethods: {\n search: (collection, query: string, opts?: SearchOptions) =>\n search(database, collection, spec, query, opts),\n catchUp: (collection) =>\n catchUp(database, collection.name, spec[collection.name] ?? []),\n },\n };\n}\n\n// ────────────────────────────────────────────────────────────────────────────\n// Dynamic search index (programmatic, not document-bound)\n//\n// The `fts()` plugin attaches `collection.search()` to a DOCUMENT collection with\n// a STATIC spec. When you instead need a programmatic full-text index over\n// collections created at RUNTIME — RAG corpora, per-tenant indexes — use\n// `createSearchIndex(db)`. Each collection is its own FTS5 table; `fields` are\n// indexed for search and `filterFields` are stored UNINDEXED so a `where` scopes\n// the MATCH (e.g. keyword search within one case/tenant). Synchronous.\n// ────────────────────────────────────────────────────────────────────────────\n\nconst FTS_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;\nfunction ftsIdent(name: string): string {\n if (!FTS_IDENT.test(name))\n throw new Error(`@monlite/fts: unsafe collection/field name \"${name}\"`);\n return name;\n}\n\nexport interface SearchIndexOptions {\n /** Text fields indexed for full-text search. */\n fields: string[];\n /** Fields stored UNINDEXED, for exact `where` filtering (scoped search). Default `[]`. */\n filterFields?: string[];\n}\n\nexport interface SearchIndexPoint {\n id: string;\n /** Indexed text, keyed by field name (the configured `fields`). */\n fields: Record<string, string>;\n /** Filter values, keyed by field name (the configured `filterFields`). */\n filters?: Record<string, string>;\n}\n\nexport interface SearchIndexHit {\n id: string;\n /** Relevance (higher = better; derived from BM25 rank). */\n score: number;\n}\n\nexport interface SearchIndex {\n ensureCollection(name: string, opts: SearchIndexOptions): void;\n upsert(name: string, points: SearchIndexPoint[]): void;\n search(\n name: string,\n query: string,\n opts?: { limit?: number; where?: Record<string, string> },\n ): SearchIndexHit[];\n delete(\n name: string,\n opts: { id?: string; where?: Record<string, string> },\n ): void;\n}\n\n/**\n * A programmatic, dynamic full-text index over `@monlite/core` (SQLite FTS5) —\n * collections created at runtime, with optional scoped filtering.\n *\n * ```ts\n * const idx = createSearchIndex(db);\n * idx.ensureCollection(\"docs\", { fields: [\"title\", \"body\"], filterFields: [\"docId\"] });\n * idx.upsert(\"docs\", [{ id: \"c1\", fields: { title, body }, filters: { docId: \"d1\" } }]);\n * idx.search(\"docs\", \"hello world\", { where: { docId: \"d1\" }, limit: 10 }); // scoped\n * ```\n */\nexport function createSearchIndex(db: Monlite): SearchIndex {\n const configs = new Map<\n string,\n { fields: string[]; filterFields: string[] }\n >();\n\n const create = (name: string, fields: string[], filterFields: string[]) => {\n const n = ftsIdent(name);\n const fcols = fields.map(ftsIdent).join(\", \");\n const ucols = filterFields\n .map((f) => `, ${ftsIdent(f)} UNINDEXED`)\n .join(\"\");\n db.sqlite.exec(\n `CREATE VIRTUAL TABLE IF NOT EXISTS \"${n}\" USING fts5(doc_id UNINDEXED, ${fcols}${ucols})`,\n );\n configs.set(name, { fields, filterFields });\n return configs.get(name)!;\n };\n\n const known = (name: string) => {\n if (configs.has(name)) return configs.get(name)!;\n const row = db.sqlite\n .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`)\n .get(name);\n if (row) {\n configs.set(name, { fields: [], filterFields: [] });\n return configs.get(name)!;\n }\n return undefined;\n };\n\n return {\n ensureCollection(name, { fields, filterFields = [] }) {\n if (!configs.has(name)) create(name, fields, filterFields);\n },\n\n upsert(name, points) {\n if (!points?.length) return;\n const cfg = configs.get(name);\n if (!cfg)\n throw new Error(\n `@monlite/fts: ensureCollection(\"${name}\") before upsert`,\n );\n const cols = [...cfg.fields, ...cfg.filterFields];\n const colList = cols.map((c) => `, ${c}`).join(\"\");\n const ph = cols.map(() => \", ?\").join(\"\");\n const del = db.sqlite.prepare(`DELETE FROM \"${name}\" WHERE doc_id = ?`);\n const ins = db.sqlite.prepare(\n `INSERT INTO \"${name}\"(doc_id${colList}) VALUES (?${ph})`,\n );\n db.sqlite.exec(\"BEGIN\");\n try {\n for (const p of points) {\n del.run(p.id);\n const vals = [\n ...cfg.fields.map((f) => p.fields?.[f] ?? \"\"),\n ...cfg.filterFields.map((f) => p.filters?.[f] ?? \"\"),\n ];\n ins.run(p.id, ...vals);\n }\n db.sqlite.exec(\"COMMIT\");\n } catch (err) {\n try {\n db.sqlite.exec(\"ROLLBACK\");\n } catch {\n /* ignore */\n }\n throw err;\n }\n },\n\n search(name, query, opts = {}) {\n const cfg = known(name);\n if (!cfg) return [];\n const limit = opts.limit ?? 50;\n const where = Object.entries(opts.where ?? {}).filter(\n ([, v]) => v != null,\n );\n const clause = where.map(([k]) => ` AND ${ftsIdent(k)} = ?`).join(\"\");\n const sql =\n `SELECT doc_id, rank FROM \"${name}\" ` +\n `WHERE \"${name}\" MATCH ?${clause} ORDER BY rank LIMIT ?`;\n let rows: Array<{ doc_id: string; rank: number }>;\n try {\n rows = db.sqlite\n .prepare(sql)\n .all(query, ...where.map(([, v]) => v), limit) as never;\n } catch {\n return [];\n }\n return rows.map((r) => ({ id: r.doc_id, score: -r.rank }));\n },\n\n delete(name, { id, where }) {\n const cfg = known(name);\n if (!cfg) return;\n if (id != null) {\n db.sqlite.prepare(`DELETE FROM \"${name}\" WHERE doc_id = ?`).run(id);\n return;\n }\n const pairs = Object.entries(where ?? {}).filter(([, v]) => v != null);\n if (!pairs.length) return;\n const clause = pairs.map(([k]) => `${ftsIdent(k)} = ?`).join(\" AND \");\n db.sqlite\n .prepare(`DELETE FROM \"${name}\" WHERE ${clause}`)\n .run(...pairs.map(([, v]) => v));\n },\n };\n}\n"]}
package/dist/index.d.cts CHANGED
@@ -43,5 +43,47 @@ declare function reindex(db: Monlite, coll: string, fields: string[]): void;
43
43
  * ```
44
44
  */
45
45
  declare function fts(spec: FtsSpec): MonlitePlugin;
46
+ interface SearchIndexOptions {
47
+ /** Text fields indexed for full-text search. */
48
+ fields: string[];
49
+ /** Fields stored UNINDEXED, for exact `where` filtering (scoped search). Default `[]`. */
50
+ filterFields?: string[];
51
+ }
52
+ interface SearchIndexPoint {
53
+ id: string;
54
+ /** Indexed text, keyed by field name (the configured `fields`). */
55
+ fields: Record<string, string>;
56
+ /** Filter values, keyed by field name (the configured `filterFields`). */
57
+ filters?: Record<string, string>;
58
+ }
59
+ interface SearchIndexHit {
60
+ id: string;
61
+ /** Relevance (higher = better; derived from BM25 rank). */
62
+ score: number;
63
+ }
64
+ interface SearchIndex {
65
+ ensureCollection(name: string, opts: SearchIndexOptions): void;
66
+ upsert(name: string, points: SearchIndexPoint[]): void;
67
+ search(name: string, query: string, opts?: {
68
+ limit?: number;
69
+ where?: Record<string, string>;
70
+ }): SearchIndexHit[];
71
+ delete(name: string, opts: {
72
+ id?: string;
73
+ where?: Record<string, string>;
74
+ }): void;
75
+ }
76
+ /**
77
+ * A programmatic, dynamic full-text index over `@monlite/core` (SQLite FTS5) —
78
+ * collections created at runtime, with optional scoped filtering.
79
+ *
80
+ * ```ts
81
+ * const idx = createSearchIndex(db);
82
+ * idx.ensureCollection("docs", { fields: ["title", "body"], filterFields: ["docId"] });
83
+ * idx.upsert("docs", [{ id: "c1", fields: { title, body }, filters: { docId: "d1" } }]);
84
+ * idx.search("docs", "hello world", { where: { docId: "d1" }, limit: 10 }); // scoped
85
+ * ```
86
+ */
87
+ declare function createSearchIndex(db: Monlite): SearchIndex;
46
88
 
47
- export { type FtsSpec, type SearchOptions, type SearchResult, catchUp, fts, reindex };
89
+ export { type FtsSpec, type SearchIndex, type SearchIndexHit, type SearchIndexOptions, type SearchIndexPoint, type SearchOptions, type SearchResult, catchUp, createSearchIndex, fts, reindex };
package/dist/index.d.ts CHANGED
@@ -43,5 +43,47 @@ declare function reindex(db: Monlite, coll: string, fields: string[]): void;
43
43
  * ```
44
44
  */
45
45
  declare function fts(spec: FtsSpec): MonlitePlugin;
46
+ interface SearchIndexOptions {
47
+ /** Text fields indexed for full-text search. */
48
+ fields: string[];
49
+ /** Fields stored UNINDEXED, for exact `where` filtering (scoped search). Default `[]`. */
50
+ filterFields?: string[];
51
+ }
52
+ interface SearchIndexPoint {
53
+ id: string;
54
+ /** Indexed text, keyed by field name (the configured `fields`). */
55
+ fields: Record<string, string>;
56
+ /** Filter values, keyed by field name (the configured `filterFields`). */
57
+ filters?: Record<string, string>;
58
+ }
59
+ interface SearchIndexHit {
60
+ id: string;
61
+ /** Relevance (higher = better; derived from BM25 rank). */
62
+ score: number;
63
+ }
64
+ interface SearchIndex {
65
+ ensureCollection(name: string, opts: SearchIndexOptions): void;
66
+ upsert(name: string, points: SearchIndexPoint[]): void;
67
+ search(name: string, query: string, opts?: {
68
+ limit?: number;
69
+ where?: Record<string, string>;
70
+ }): SearchIndexHit[];
71
+ delete(name: string, opts: {
72
+ id?: string;
73
+ where?: Record<string, string>;
74
+ }): void;
75
+ }
76
+ /**
77
+ * A programmatic, dynamic full-text index over `@monlite/core` (SQLite FTS5) —
78
+ * collections created at runtime, with optional scoped filtering.
79
+ *
80
+ * ```ts
81
+ * const idx = createSearchIndex(db);
82
+ * idx.ensureCollection("docs", { fields: ["title", "body"], filterFields: ["docId"] });
83
+ * idx.upsert("docs", [{ id: "c1", fields: { title, body }, filters: { docId: "d1" } }]);
84
+ * idx.search("docs", "hello world", { where: { docId: "d1" }, limit: 10 }); // scoped
85
+ * ```
86
+ */
87
+ declare function createSearchIndex(db: Monlite): SearchIndex;
46
88
 
47
- export { type FtsSpec, type SearchOptions, type SearchResult, catchUp, fts, reindex };
89
+ export { type FtsSpec, type SearchIndex, type SearchIndexHit, type SearchIndexOptions, type SearchIndexPoint, type SearchOptions, type SearchResult, catchUp, createSearchIndex, fts, reindex };
package/dist/index.js CHANGED
@@ -125,7 +125,102 @@ function fts(spec) {
125
125
  }
126
126
  };
127
127
  }
128
+ var FTS_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
129
+ function ftsIdent(name) {
130
+ if (!FTS_IDENT.test(name))
131
+ throw new Error(`@monlite/fts: unsafe collection/field name "${name}"`);
132
+ return name;
133
+ }
134
+ function createSearchIndex(db) {
135
+ const configs = /* @__PURE__ */ new Map();
136
+ const create = (name, fields, filterFields) => {
137
+ const n = ftsIdent(name);
138
+ const fcols = fields.map(ftsIdent).join(", ");
139
+ const ucols = filterFields.map((f) => `, ${ftsIdent(f)} UNINDEXED`).join("");
140
+ db.sqlite.exec(
141
+ `CREATE VIRTUAL TABLE IF NOT EXISTS "${n}" USING fts5(doc_id UNINDEXED, ${fcols}${ucols})`
142
+ );
143
+ configs.set(name, { fields, filterFields });
144
+ return configs.get(name);
145
+ };
146
+ const known = (name) => {
147
+ if (configs.has(name)) return configs.get(name);
148
+ const row = db.sqlite.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`).get(name);
149
+ if (row) {
150
+ configs.set(name, { fields: [], filterFields: [] });
151
+ return configs.get(name);
152
+ }
153
+ return void 0;
154
+ };
155
+ return {
156
+ ensureCollection(name, { fields, filterFields = [] }) {
157
+ if (!configs.has(name)) create(name, fields, filterFields);
158
+ },
159
+ upsert(name, points) {
160
+ if (!points?.length) return;
161
+ const cfg = configs.get(name);
162
+ if (!cfg)
163
+ throw new Error(
164
+ `@monlite/fts: ensureCollection("${name}") before upsert`
165
+ );
166
+ const cols = [...cfg.fields, ...cfg.filterFields];
167
+ const colList = cols.map((c) => `, ${c}`).join("");
168
+ const ph = cols.map(() => ", ?").join("");
169
+ const del = db.sqlite.prepare(`DELETE FROM "${name}" WHERE doc_id = ?`);
170
+ const ins = db.sqlite.prepare(
171
+ `INSERT INTO "${name}"(doc_id${colList}) VALUES (?${ph})`
172
+ );
173
+ db.sqlite.exec("BEGIN");
174
+ try {
175
+ for (const p of points) {
176
+ del.run(p.id);
177
+ const vals = [
178
+ ...cfg.fields.map((f) => p.fields?.[f] ?? ""),
179
+ ...cfg.filterFields.map((f) => p.filters?.[f] ?? "")
180
+ ];
181
+ ins.run(p.id, ...vals);
182
+ }
183
+ db.sqlite.exec("COMMIT");
184
+ } catch (err) {
185
+ try {
186
+ db.sqlite.exec("ROLLBACK");
187
+ } catch {
188
+ }
189
+ throw err;
190
+ }
191
+ },
192
+ search(name, query, opts = {}) {
193
+ const cfg = known(name);
194
+ if (!cfg) return [];
195
+ const limit = opts.limit ?? 50;
196
+ const where = Object.entries(opts.where ?? {}).filter(
197
+ ([, v]) => v != null
198
+ );
199
+ const clause = where.map(([k]) => ` AND ${ftsIdent(k)} = ?`).join("");
200
+ const sql = `SELECT doc_id, rank FROM "${name}" WHERE "${name}" MATCH ?${clause} ORDER BY rank LIMIT ?`;
201
+ let rows;
202
+ try {
203
+ rows = db.sqlite.prepare(sql).all(query, ...where.map(([, v]) => v), limit);
204
+ } catch {
205
+ return [];
206
+ }
207
+ return rows.map((r) => ({ id: r.doc_id, score: -r.rank }));
208
+ },
209
+ delete(name, { id, where }) {
210
+ const cfg = known(name);
211
+ if (!cfg) return;
212
+ if (id != null) {
213
+ db.sqlite.prepare(`DELETE FROM "${name}" WHERE doc_id = ?`).run(id);
214
+ return;
215
+ }
216
+ const pairs = Object.entries(where ?? {}).filter(([, v]) => v != null);
217
+ if (!pairs.length) return;
218
+ const clause = pairs.map(([k]) => `${ftsIdent(k)} = ?`).join(" AND ");
219
+ db.sqlite.prepare(`DELETE FROM "${name}" WHERE ${clause}`).run(...pairs.map(([, v]) => v));
220
+ }
221
+ };
222
+ }
128
223
 
129
- export { catchUp, fts, reindex };
224
+ export { catchUp, createSearchIndex, fts, reindex };
130
225
  //# sourceMappingURL=index.js.map
131
226
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type {\n Collection,\n Doc,\n Monlite,\n MonlitePlugin,\n WhereInput,\n WithId,\n} from \"@monlite/core\";\n\n/** Map of collection name → searchable field paths (dot-notation allowed). */\nexport type FtsSpec = Record<string, string[]>;\n\nexport interface SearchOptions<T = Doc> {\n /** Max results (default 50). */\n limit?: number;\n /** Additionally constrain matches with a normal monlite where clause. */\n where?: WhereInput<T>;\n}\n\nexport type SearchResult<T = Doc> = WithId<T> & { _score: number };\n\n// Make `collection.search()` typed wherever @monlite/fts is imported.\ndeclare module \"@monlite/core\" {\n interface Collection<T> {\n search(query: string, opts?: SearchOptions<T>): Promise<SearchResult<T>[]>;\n /** Pick up documents written by another process; returns counts. */\n catchUp(): { indexed: number; removed: number };\n }\n}\n\nconst ftsTable = (coll: string) => `${coll}_fts`;\nconst col = (i: number) => `f${i}`;\nconst STATE = \"_monlite_fts_state\";\n\nfunction ensureState(db: Monlite): void {\n db.sqlite.exec(\n `CREATE TABLE IF NOT EXISTS ${STATE} (coll TEXT PRIMARY KEY, high_water INTEGER NOT NULL)`,\n );\n}\nfunction getHighWater(db: Monlite, coll: string): number {\n const row = db.sqlite\n .prepare(`SELECT high_water FROM ${STATE} WHERE coll = ?`)\n .get(coll) as { high_water: number } | undefined;\n return row?.high_water ?? 0;\n}\nfunction setHighWater(db: Monlite, coll: string, value: number): void {\n db.sqlite\n .prepare(\n `INSERT INTO ${STATE}(coll, high_water) VALUES (?, ?) ON CONFLICT(coll) DO UPDATE SET high_water = excluded.high_water`,\n )\n .run(coll, value);\n}\n\n/**\n * Incrementally index documents written by another process (and drop entries for\n * cross-process deletes), so a separate searcher process becomes fresh without a\n * full {@link reindex}. Returns how many docs were (re)indexed and removed.\n */\nexport function catchUp(\n db: Monlite,\n coll: string,\n fields: string[],\n): { indexed: number; removed: number } {\n const sqlite = db.sqlite;\n ensureState(db);\n const hw = getHighWater(db, coll);\n const docs = sqlite\n .prepare(`SELECT _id, updated_at FROM \"${coll}\" WHERE updated_at >= ?`)\n .all(hw) as Array<{ _id: string; updated_at: number }>;\n let max = hw;\n for (const d of docs) {\n indexDoc(db, coll, fields, d._id);\n if (d.updated_at > max) max = d.updated_at;\n }\n // Remove index rows whose document was deleted (possibly by another process).\n const orphans = sqlite\n .prepare(\n `SELECT doc_id FROM \"${ftsTable(coll)}\" WHERE doc_id NOT IN (SELECT _id FROM \"${coll}\")`,\n )\n .all() as Array<{ doc_id: string }>;\n const del = sqlite.prepare(\n `DELETE FROM \"${ftsTable(coll)}\" WHERE doc_id = ?`,\n );\n for (const o of orphans) del.run(o.doc_id);\n setHighWater(db, coll, max);\n return { indexed: docs.length, removed: orphans.length };\n}\n\n/** Extract searchable text for a field path from a document. */\nfunction extractText(doc: Record<string, any>, path: string): string {\n let cur: any = doc;\n for (const seg of path.split(\".\")) {\n if (cur == null) return \"\";\n cur = cur[seg];\n }\n if (cur == null) return \"\";\n if (typeof cur === \"string\") return cur;\n if (Array.isArray(cur))\n return cur\n .filter((x) => x != null)\n .map(String)\n .join(\" \");\n if (typeof cur === \"number\" || typeof cur === \"boolean\") return String(cur);\n return \"\";\n}\n\nfunction indexDoc(\n db: Monlite,\n coll: string,\n fields: string[],\n id: string,\n): void {\n const sqlite = db.sqlite;\n sqlite.prepare(`DELETE FROM \"${ftsTable(coll)}\" WHERE doc_id = ?`).run(id);\n const doc = db.collection(coll).getRaw(id);\n if (!doc) return; // deleted\n const cols = fields.map((_, i) => `\"${col(i)}\"`).join(\", \");\n const placeholders = fields.map(() => \"?\").join(\", \");\n const values = fields.map((f) => extractText(doc, f));\n sqlite\n .prepare(\n `INSERT INTO \"${ftsTable(coll)}\"(doc_id, ${cols}) VALUES (?, ${placeholders})`,\n )\n .run(id, ...values);\n}\n\n/** Rebuild a collection's FTS index from scratch. */\nexport function reindex(db: Monlite, coll: string, fields: string[]): void {\n const sqlite = db.sqlite;\n sqlite.exec(`DELETE FROM \"${ftsTable(coll)}\"`);\n for (const doc of db.collection(coll).findManyCore({})) {\n indexDoc(db, coll, fields, (doc as WithId<Doc>)._id);\n }\n}\n\nfunction search<T = Doc>(\n db: Monlite,\n coll: Collection<T>,\n spec: FtsSpec,\n query: string,\n opts?: SearchOptions<T>,\n): Promise<SearchResult<T>[]> {\n const fields = spec[coll.name];\n if (!fields) {\n throw new Error(\n `Collection \"${coll.name}\" is not configured for full-text search`,\n );\n }\n const limit = opts?.limit ?? 50;\n const rows = db.sqlite\n .prepare(\n `SELECT doc_id, rank FROM \"${ftsTable(coll.name)}\" ` +\n `WHERE \"${ftsTable(coll.name)}\" MATCH ? ORDER BY rank LIMIT ?`,\n )\n .all(query, limit) as Array<{ doc_id: string; rank: number }>;\n\n let allowed: Set<string> | null = null;\n if (opts?.where) {\n const ids = rows.map((r) => r.doc_id);\n const idIn = { _id: { in: ids } } as WhereInput<T>;\n const matching = coll.findManyCore({\n where: { AND: [opts.where, idIn] } as WhereInput<T>,\n });\n allowed = new Set(matching.map((d) => d._id));\n }\n\n const out: SearchResult<T>[] = [];\n for (const r of rows) {\n if (allowed && !allowed.has(r.doc_id)) continue;\n const doc = coll.getRaw(r.doc_id);\n if (doc) out.push({ ...doc, _score: -r.rank } as SearchResult<T>);\n }\n return Promise.resolve(out);\n}\n\n/**\n * Full-text search plugin (SQLite FTS5). Pass it to `createDb({ plugins: [...] })`\n * with a map of collection → searchable fields. Adds `collection.search()`, keeps\n * the index in sync on every write, and backfills existing documents on open.\n *\n * ```ts\n * const db = createDb(\"./app.db\", { plugins: [fts({ posts: [\"title\", \"body\"] })] });\n * await db.collection(\"posts\").search(\"hello world\");\n * ```\n */\nexport function fts(spec: FtsSpec): MonlitePlugin {\n let database: Monlite;\n return {\n name: \"fts\",\n init(db) {\n database = db;\n ensureState(db);\n for (const [coll, fields] of Object.entries(spec)) {\n const cols = fields.map((_, i) => `\"${col(i)}\"`).join(\", \");\n db.sqlite.exec(\n `CREATE VIRTUAL TABLE IF NOT EXISTS \"${ftsTable(coll)}\" ` +\n `USING fts5(doc_id UNINDEXED, ${cols})`,\n );\n // Backfill when the index is empty (e.g. enabling FTS on an existing db).\n const count = db.sqlite\n .prepare(`SELECT count(*) AS n FROM \"${ftsTable(coll)}\"`)\n .get() as { n: number };\n if (count.n === 0) reindex(db, coll, fields);\n // Pick up anything other processes wrote since we last indexed.\n catchUp(db, coll, fields);\n }\n },\n afterWrite(db, { collection, ids }) {\n const fields = spec[collection];\n if (!fields) return;\n for (const id of ids) indexDoc(db, collection, fields, id);\n setHighWater(db, collection, Date.now()); // our index is current to now\n },\n collectionMethods: {\n search: (collection, query: string, opts?: SearchOptions) =>\n search(database, collection, spec, query, opts),\n catchUp: (collection) =>\n catchUp(database, collection.name, spec[collection.name] ?? []),\n },\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type {\n Collection,\n Doc,\n Monlite,\n MonlitePlugin,\n WhereInput,\n WithId,\n} from \"@monlite/core\";\n\n/** Map of collection name → searchable field paths (dot-notation allowed). */\nexport type FtsSpec = Record<string, string[]>;\n\nexport interface SearchOptions<T = Doc> {\n /** Max results (default 50). */\n limit?: number;\n /** Additionally constrain matches with a normal monlite where clause. */\n where?: WhereInput<T>;\n}\n\nexport type SearchResult<T = Doc> = WithId<T> & { _score: number };\n\n// Make `collection.search()` typed wherever @monlite/fts is imported.\ndeclare module \"@monlite/core\" {\n interface Collection<T> {\n search(query: string, opts?: SearchOptions<T>): Promise<SearchResult<T>[]>;\n /** Pick up documents written by another process; returns counts. */\n catchUp(): { indexed: number; removed: number };\n }\n}\n\nconst ftsTable = (coll: string) => `${coll}_fts`;\nconst col = (i: number) => `f${i}`;\nconst STATE = \"_monlite_fts_state\";\n\nfunction ensureState(db: Monlite): void {\n db.sqlite.exec(\n `CREATE TABLE IF NOT EXISTS ${STATE} (coll TEXT PRIMARY KEY, high_water INTEGER NOT NULL)`,\n );\n}\nfunction getHighWater(db: Monlite, coll: string): number {\n const row = db.sqlite\n .prepare(`SELECT high_water FROM ${STATE} WHERE coll = ?`)\n .get(coll) as { high_water: number } | undefined;\n return row?.high_water ?? 0;\n}\nfunction setHighWater(db: Monlite, coll: string, value: number): void {\n db.sqlite\n .prepare(\n `INSERT INTO ${STATE}(coll, high_water) VALUES (?, ?) ON CONFLICT(coll) DO UPDATE SET high_water = excluded.high_water`,\n )\n .run(coll, value);\n}\n\n/**\n * Incrementally index documents written by another process (and drop entries for\n * cross-process deletes), so a separate searcher process becomes fresh without a\n * full {@link reindex}. Returns how many docs were (re)indexed and removed.\n */\nexport function catchUp(\n db: Monlite,\n coll: string,\n fields: string[],\n): { indexed: number; removed: number } {\n const sqlite = db.sqlite;\n ensureState(db);\n const hw = getHighWater(db, coll);\n const docs = sqlite\n .prepare(`SELECT _id, updated_at FROM \"${coll}\" WHERE updated_at >= ?`)\n .all(hw) as Array<{ _id: string; updated_at: number }>;\n let max = hw;\n for (const d of docs) {\n indexDoc(db, coll, fields, d._id);\n if (d.updated_at > max) max = d.updated_at;\n }\n // Remove index rows whose document was deleted (possibly by another process).\n const orphans = sqlite\n .prepare(\n `SELECT doc_id FROM \"${ftsTable(coll)}\" WHERE doc_id NOT IN (SELECT _id FROM \"${coll}\")`,\n )\n .all() as Array<{ doc_id: string }>;\n const del = sqlite.prepare(\n `DELETE FROM \"${ftsTable(coll)}\" WHERE doc_id = ?`,\n );\n for (const o of orphans) del.run(o.doc_id);\n setHighWater(db, coll, max);\n return { indexed: docs.length, removed: orphans.length };\n}\n\n/** Extract searchable text for a field path from a document. */\nfunction extractText(doc: Record<string, any>, path: string): string {\n let cur: any = doc;\n for (const seg of path.split(\".\")) {\n if (cur == null) return \"\";\n cur = cur[seg];\n }\n if (cur == null) return \"\";\n if (typeof cur === \"string\") return cur;\n if (Array.isArray(cur))\n return cur\n .filter((x) => x != null)\n .map(String)\n .join(\" \");\n if (typeof cur === \"number\" || typeof cur === \"boolean\") return String(cur);\n return \"\";\n}\n\nfunction indexDoc(\n db: Monlite,\n coll: string,\n fields: string[],\n id: string,\n): void {\n const sqlite = db.sqlite;\n sqlite.prepare(`DELETE FROM \"${ftsTable(coll)}\" WHERE doc_id = ?`).run(id);\n const doc = db.collection(coll).getRaw(id);\n if (!doc) return; // deleted\n const cols = fields.map((_, i) => `\"${col(i)}\"`).join(\", \");\n const placeholders = fields.map(() => \"?\").join(\", \");\n const values = fields.map((f) => extractText(doc, f));\n sqlite\n .prepare(\n `INSERT INTO \"${ftsTable(coll)}\"(doc_id, ${cols}) VALUES (?, ${placeholders})`,\n )\n .run(id, ...values);\n}\n\n/** Rebuild a collection's FTS index from scratch. */\nexport function reindex(db: Monlite, coll: string, fields: string[]): void {\n const sqlite = db.sqlite;\n sqlite.exec(`DELETE FROM \"${ftsTable(coll)}\"`);\n for (const doc of db.collection(coll).findManyCore({})) {\n indexDoc(db, coll, fields, (doc as WithId<Doc>)._id);\n }\n}\n\nfunction search<T = Doc>(\n db: Monlite,\n coll: Collection<T>,\n spec: FtsSpec,\n query: string,\n opts?: SearchOptions<T>,\n): Promise<SearchResult<T>[]> {\n const fields = spec[coll.name];\n if (!fields) {\n throw new Error(\n `Collection \"${coll.name}\" is not configured for full-text search`,\n );\n }\n const limit = opts?.limit ?? 50;\n const rows = db.sqlite\n .prepare(\n `SELECT doc_id, rank FROM \"${ftsTable(coll.name)}\" ` +\n `WHERE \"${ftsTable(coll.name)}\" MATCH ? ORDER BY rank LIMIT ?`,\n )\n .all(query, limit) as Array<{ doc_id: string; rank: number }>;\n\n let allowed: Set<string> | null = null;\n if (opts?.where) {\n const ids = rows.map((r) => r.doc_id);\n const idIn = { _id: { in: ids } } as WhereInput<T>;\n const matching = coll.findManyCore({\n where: { AND: [opts.where, idIn] } as WhereInput<T>,\n });\n allowed = new Set(matching.map((d) => d._id));\n }\n\n const out: SearchResult<T>[] = [];\n for (const r of rows) {\n if (allowed && !allowed.has(r.doc_id)) continue;\n const doc = coll.getRaw(r.doc_id);\n if (doc) out.push({ ...doc, _score: -r.rank } as SearchResult<T>);\n }\n return Promise.resolve(out);\n}\n\n/**\n * Full-text search plugin (SQLite FTS5). Pass it to `createDb({ plugins: [...] })`\n * with a map of collection → searchable fields. Adds `collection.search()`, keeps\n * the index in sync on every write, and backfills existing documents on open.\n *\n * ```ts\n * const db = createDb(\"./app.db\", { plugins: [fts({ posts: [\"title\", \"body\"] })] });\n * await db.collection(\"posts\").search(\"hello world\");\n * ```\n */\nexport function fts(spec: FtsSpec): MonlitePlugin {\n let database: Monlite;\n return {\n name: \"fts\",\n init(db) {\n database = db;\n ensureState(db);\n for (const [coll, fields] of Object.entries(spec)) {\n const cols = fields.map((_, i) => `\"${col(i)}\"`).join(\", \");\n db.sqlite.exec(\n `CREATE VIRTUAL TABLE IF NOT EXISTS \"${ftsTable(coll)}\" ` +\n `USING fts5(doc_id UNINDEXED, ${cols})`,\n );\n // Backfill when the index is empty (e.g. enabling FTS on an existing db).\n const count = db.sqlite\n .prepare(`SELECT count(*) AS n FROM \"${ftsTable(coll)}\"`)\n .get() as { n: number };\n if (count.n === 0) reindex(db, coll, fields);\n // Pick up anything other processes wrote since we last indexed.\n catchUp(db, coll, fields);\n }\n },\n afterWrite(db, { collection, ids }) {\n const fields = spec[collection];\n if (!fields) return;\n for (const id of ids) indexDoc(db, collection, fields, id);\n setHighWater(db, collection, Date.now()); // our index is current to now\n },\n collectionMethods: {\n search: (collection, query: string, opts?: SearchOptions) =>\n search(database, collection, spec, query, opts),\n catchUp: (collection) =>\n catchUp(database, collection.name, spec[collection.name] ?? []),\n },\n };\n}\n\n// ────────────────────────────────────────────────────────────────────────────\n// Dynamic search index (programmatic, not document-bound)\n//\n// The `fts()` plugin attaches `collection.search()` to a DOCUMENT collection with\n// a STATIC spec. When you instead need a programmatic full-text index over\n// collections created at RUNTIME — RAG corpora, per-tenant indexes — use\n// `createSearchIndex(db)`. Each collection is its own FTS5 table; `fields` are\n// indexed for search and `filterFields` are stored UNINDEXED so a `where` scopes\n// the MATCH (e.g. keyword search within one case/tenant). Synchronous.\n// ────────────────────────────────────────────────────────────────────────────\n\nconst FTS_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;\nfunction ftsIdent(name: string): string {\n if (!FTS_IDENT.test(name))\n throw new Error(`@monlite/fts: unsafe collection/field name \"${name}\"`);\n return name;\n}\n\nexport interface SearchIndexOptions {\n /** Text fields indexed for full-text search. */\n fields: string[];\n /** Fields stored UNINDEXED, for exact `where` filtering (scoped search). Default `[]`. */\n filterFields?: string[];\n}\n\nexport interface SearchIndexPoint {\n id: string;\n /** Indexed text, keyed by field name (the configured `fields`). */\n fields: Record<string, string>;\n /** Filter values, keyed by field name (the configured `filterFields`). */\n filters?: Record<string, string>;\n}\n\nexport interface SearchIndexHit {\n id: string;\n /** Relevance (higher = better; derived from BM25 rank). */\n score: number;\n}\n\nexport interface SearchIndex {\n ensureCollection(name: string, opts: SearchIndexOptions): void;\n upsert(name: string, points: SearchIndexPoint[]): void;\n search(\n name: string,\n query: string,\n opts?: { limit?: number; where?: Record<string, string> },\n ): SearchIndexHit[];\n delete(\n name: string,\n opts: { id?: string; where?: Record<string, string> },\n ): void;\n}\n\n/**\n * A programmatic, dynamic full-text index over `@monlite/core` (SQLite FTS5) —\n * collections created at runtime, with optional scoped filtering.\n *\n * ```ts\n * const idx = createSearchIndex(db);\n * idx.ensureCollection(\"docs\", { fields: [\"title\", \"body\"], filterFields: [\"docId\"] });\n * idx.upsert(\"docs\", [{ id: \"c1\", fields: { title, body }, filters: { docId: \"d1\" } }]);\n * idx.search(\"docs\", \"hello world\", { where: { docId: \"d1\" }, limit: 10 }); // scoped\n * ```\n */\nexport function createSearchIndex(db: Monlite): SearchIndex {\n const configs = new Map<\n string,\n { fields: string[]; filterFields: string[] }\n >();\n\n const create = (name: string, fields: string[], filterFields: string[]) => {\n const n = ftsIdent(name);\n const fcols = fields.map(ftsIdent).join(\", \");\n const ucols = filterFields\n .map((f) => `, ${ftsIdent(f)} UNINDEXED`)\n .join(\"\");\n db.sqlite.exec(\n `CREATE VIRTUAL TABLE IF NOT EXISTS \"${n}\" USING fts5(doc_id UNINDEXED, ${fcols}${ucols})`,\n );\n configs.set(name, { fields, filterFields });\n return configs.get(name)!;\n };\n\n const known = (name: string) => {\n if (configs.has(name)) return configs.get(name)!;\n const row = db.sqlite\n .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`)\n .get(name);\n if (row) {\n configs.set(name, { fields: [], filterFields: [] });\n return configs.get(name)!;\n }\n return undefined;\n };\n\n return {\n ensureCollection(name, { fields, filterFields = [] }) {\n if (!configs.has(name)) create(name, fields, filterFields);\n },\n\n upsert(name, points) {\n if (!points?.length) return;\n const cfg = configs.get(name);\n if (!cfg)\n throw new Error(\n `@monlite/fts: ensureCollection(\"${name}\") before upsert`,\n );\n const cols = [...cfg.fields, ...cfg.filterFields];\n const colList = cols.map((c) => `, ${c}`).join(\"\");\n const ph = cols.map(() => \", ?\").join(\"\");\n const del = db.sqlite.prepare(`DELETE FROM \"${name}\" WHERE doc_id = ?`);\n const ins = db.sqlite.prepare(\n `INSERT INTO \"${name}\"(doc_id${colList}) VALUES (?${ph})`,\n );\n db.sqlite.exec(\"BEGIN\");\n try {\n for (const p of points) {\n del.run(p.id);\n const vals = [\n ...cfg.fields.map((f) => p.fields?.[f] ?? \"\"),\n ...cfg.filterFields.map((f) => p.filters?.[f] ?? \"\"),\n ];\n ins.run(p.id, ...vals);\n }\n db.sqlite.exec(\"COMMIT\");\n } catch (err) {\n try {\n db.sqlite.exec(\"ROLLBACK\");\n } catch {\n /* ignore */\n }\n throw err;\n }\n },\n\n search(name, query, opts = {}) {\n const cfg = known(name);\n if (!cfg) return [];\n const limit = opts.limit ?? 50;\n const where = Object.entries(opts.where ?? {}).filter(\n ([, v]) => v != null,\n );\n const clause = where.map(([k]) => ` AND ${ftsIdent(k)} = ?`).join(\"\");\n const sql =\n `SELECT doc_id, rank FROM \"${name}\" ` +\n `WHERE \"${name}\" MATCH ?${clause} ORDER BY rank LIMIT ?`;\n let rows: Array<{ doc_id: string; rank: number }>;\n try {\n rows = db.sqlite\n .prepare(sql)\n .all(query, ...where.map(([, v]) => v), limit) as never;\n } catch {\n return [];\n }\n return rows.map((r) => ({ id: r.doc_id, score: -r.rank }));\n },\n\n delete(name, { id, where }) {\n const cfg = known(name);\n if (!cfg) return;\n if (id != null) {\n db.sqlite.prepare(`DELETE FROM \"${name}\" WHERE doc_id = ?`).run(id);\n return;\n }\n const pairs = Object.entries(where ?? {}).filter(([, v]) => v != null);\n if (!pairs.length) return;\n const clause = pairs.map(([k]) => `${ftsIdent(k)} = ?`).join(\" AND \");\n db.sqlite\n .prepare(`DELETE FROM \"${name}\" WHERE ${clause}`)\n .run(...pairs.map(([, v]) => v));\n },\n };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monlite/fts",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Full-text search for @monlite/core, powered by SQLite FTS5. Adds collection.search().",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -48,7 +48,7 @@
48
48
  "node": ">=18"
49
49
  },
50
50
  "dependencies": {
51
- "@monlite/core": "^2.4.0"
51
+ "@monlite/core": "^2.6.1"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/node": "^22.10.0",