@monlite/fts 0.4.0 β†’ 0.5.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/README.md CHANGED
@@ -1,11 +1,11 @@
1
- # πŸŒ™ @monlite/fts
1
+ # @monlite/fts
2
2
 
3
- > Full-text search for [`@monlite/core`](https://www.npmjs.com/package/@monlite/core),
4
- > powered by SQLite's built-in **FTS5**. Adds `collection.search()`.
3
+ Full-text search for [`@monlite/core`](https://www.npmjs.com/package/@monlite/core), powered by
4
+ SQLite's built-in **FTS5**. Adds `collection.search()`.
5
5
 
6
- A monlite plugin β€” opt in by passing it to `createDb`, point it at the fields you
7
- want searchable, and it maintains an FTS5 index automatically (on every write,
8
- including changes applied by `@monlite/sync`).
6
+ A monlite plugin β€” pass it to `createDb`, point it at the fields you want indexed, and it
7
+ maintains an FTS5 index automatically on every write, including changes applied by
8
+ `@monlite/sync`.
9
9
 
10
10
  ```ts
11
11
  import { createDb } from "@monlite/core";
@@ -20,7 +20,7 @@ await db.collection("posts").create({
20
20
  });
21
21
 
22
22
  const results = await db.collection("posts").search("quick");
23
- // [ { _id, title, body, _score, … } ] β€” ranked, full documents
23
+ // [ { _id, title, body, _score, … } ] β€” ranked, full documents returned
24
24
  ```
25
25
 
26
26
  ## Install
@@ -29,29 +29,35 @@ const results = await db.collection("posts").search("quick");
29
29
  npm install @monlite/core @monlite/fts
30
30
  ```
31
31
 
32
- No native dependency β€” FTS5 ships inside SQLite, so this works on both monlite
33
- backends (`better-sqlite3` and the built-in `node:sqlite`).
32
+ No native dependency β€” FTS5 is built into SQLite, so this works on both monlite backends
33
+ (`better-sqlite3` and the built-in `node:sqlite`).
34
34
 
35
35
  ## API
36
36
 
37
+ ### Plugin
38
+
37
39
  ```ts
38
40
  fts(spec: Record<string, string[]>): MonlitePlugin
39
41
  ```
40
42
 
41
- `spec` maps a collection name to the field paths to index (dot-notation for
42
- nested fields, e.g. `"profile.bio"`).
43
+ `spec` maps a collection name to the field paths to index. Dot-notation is supported for nested
44
+ fields (e.g. `"profile.bio"`).
45
+
46
+ ### `search`
43
47
 
44
48
  ```ts
45
- collection.search(query, {
49
+ collection.search(query: string, {
46
50
  limit?: number, // default 50
47
- where?: WhereInput<T>, // also constrain with a normal monlite filter
51
+ where?: WhereInput<T>, // combine with a normal monlite filter
48
52
  }): Promise<Array<WithId<T> & { _score: number }>>
49
53
  ```
50
54
 
51
- - `query` uses FTS5 [MATCH syntax](https://www.sqlite.org/fts5.html#full_text_query_syntax)
52
- (bare terms are AND-ed; `"a phrase"`; `term*` prefix; `a OR b`).
55
+ - `query` uses FTS5 [MATCH syntax](https://www.sqlite.org/fts5.html#full_text_query_syntax):
56
+ bare terms are AND-ed, `"a phrase"`, `term*` prefix, `a OR b`.
53
57
  - Results are ordered by relevance; `_score` is higher = better.
54
- - `where` is applied after matching, so you can combine search with structured filters.
58
+ - `where` is applied after matching, so you can combine FTS with structured filters.
59
+
60
+ ### Reindex
55
61
 
56
62
  ```ts
57
63
  import { reindex } from "@monlite/fts";
@@ -60,43 +66,41 @@ reindex(db, "posts", ["title", "body"]); // rebuild a collection's index
60
66
 
61
67
  ## Dynamic index β€” `createSearchIndex(db)`
62
68
 
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)`:
69
+ The `fts()` plugin attaches `collection.search()` with a static spec. For a programmatic index
70
+ over collections created at runtime β€” RAG, per-tenant search β€” use `createSearchIndex(db)`:
66
71
 
67
72
  ```ts
68
73
  import { createSearchIndex } from "@monlite/fts";
74
+
69
75
  const idx = createSearchIndex(db);
70
76
  idx.ensureCollection("docs", { fields: ["title", "body"], filterFields: ["docId"] });
71
77
  idx.upsert("docs", [{ id: "c1", fields: { title, body }, filters: { docId: "d1" } }]);
72
78
  idx.search("docs", "hello world", { where: { docId: "d1" } }); // scoped to one case/tenant
73
79
  ```
74
80
 
75
- Each collection is its own FTS5 table; `filterFields` are UNINDEXED so a `where` scopes the
76
- MATCH. Synchronous.
81
+ Each collection is its own FTS5 table; `filterFields` are UNINDEXED columns so a `where` scopes
82
+ the MATCH without affecting ranking. Synchronous.
77
83
 
78
84
  ## How it works
79
85
 
80
- For each configured collection, the plugin creates an FTS5 virtual table
81
- (`<collection>_fts`) keyed by the document `_id`. It indexes on `init` (backfilling
82
- existing documents when the index is empty) and keeps it current via the plugin
83
- `afterWrite` hook. Search runs `MATCH` against that table, then returns the live
84
- documents from the collection in rank order.
86
+ For each configured collection, the plugin creates an FTS5 virtual table (`<collection>_fts`)
87
+ keyed by the document `_id`. It backfills existing documents on `init` (when the index is empty)
88
+ and keeps it current via the plugin `afterWrite` hook. Search runs `MATCH` against that table
89
+ and returns the live documents in rank order.
85
90
 
86
91
  ## Multi-process freshness
87
92
 
88
- The `afterWrite` hook only sees writes made through *its own* connection. If a
89
- **separate process** writes documents (e.g. an ingest worker), call
90
- `collection.catchUp()` in the searching process to incrementally index what
91
- changed (and reconcile cross-process deletes) β€” no full reindex:
93
+ The `afterWrite` hook only sees writes made through its own connection. If a separate process
94
+ writes documents (e.g. an ingest worker), call `collection.catchUp()` in the searching process
95
+ to incrementally index what changed and reconcile cross-process deletes β€” no full reindex:
92
96
 
93
97
  ```ts
94
98
  db.collection("posts").catchUp(); // β†’ { indexed, removed }; call periodically
95
99
  await db.collection("posts").search("hello");
96
100
  ```
97
101
 
98
- It tracks an `updated_at` high-water-mark, so each call only does the new work.
102
+ `catchUp` tracks an `updated_at` high-water-mark, so each call only processes new work.
99
103
 
100
104
  ## License
101
105
 
102
- MIT πŸŒ™
106
+ MIT
package/dist/index.cjs CHANGED
@@ -4,10 +4,14 @@
4
4
  var ftsTable = (coll) => `${coll}_fts`;
5
5
  var col = (i) => `f${i}`;
6
6
  var STATE = "_monlite_fts_state";
7
+ var IDMAP = "_monlite_fts_ids";
7
8
  function ensureState(db) {
8
9
  db.sqlite.exec(
9
10
  `CREATE TABLE IF NOT EXISTS ${STATE} (coll TEXT PRIMARY KEY, high_water INTEGER NOT NULL)`
10
11
  );
12
+ db.sqlite.exec(
13
+ `CREATE TABLE IF NOT EXISTS ${IDMAP} (coll TEXT NOT NULL, doc_id TEXT NOT NULL, rid INTEGER NOT NULL, PRIMARY KEY (coll, doc_id))`
14
+ );
11
15
  }
12
16
  function getHighWater(db, coll) {
13
17
  const row = db.sqlite.prepare(`SELECT high_water FROM ${STATE} WHERE coll = ?`).get(coll);
@@ -29,12 +33,14 @@ function catchUp(db, coll, fields) {
29
33
  if (d.updated_at > max) max = d.updated_at;
30
34
  }
31
35
  const orphans = sqlite.prepare(
32
- `SELECT doc_id FROM "${ftsTable(coll)}" WHERE doc_id NOT IN (SELECT _id FROM "${coll}")`
36
+ `SELECT rowid AS rid, doc_id FROM "${ftsTable(coll)}" WHERE doc_id NOT IN (SELECT _id FROM "${coll}")`
33
37
  ).all();
34
- const del = sqlite.prepare(
35
- `DELETE FROM "${ftsTable(coll)}" WHERE doc_id = ?`
36
- );
37
- for (const o of orphans) del.run(o.doc_id);
38
+ const del = sqlite.prepare(`DELETE FROM "${ftsTable(coll)}" WHERE rowid = ?`);
39
+ const delMap = sqlite.prepare(`DELETE FROM ${IDMAP} WHERE coll = ? AND doc_id = ?`);
40
+ for (const o of orphans) {
41
+ del.run(o.rid);
42
+ delMap.run(coll, o.doc_id);
43
+ }
38
44
  setHighWater(db, coll, max);
39
45
  return { indexed: docs.length, removed: orphans.length };
40
46
  }
@@ -53,19 +59,27 @@ function extractText(doc, path) {
53
59
  }
54
60
  function indexDoc(db, coll, fields, id) {
55
61
  const sqlite = db.sqlite;
56
- sqlite.prepare(`DELETE FROM "${ftsTable(coll)}" WHERE doc_id = ?`).run(id);
62
+ const prev = sqlite.prepare(`SELECT rid FROM ${IDMAP} WHERE coll = ? AND doc_id = ?`).get(coll, id);
63
+ if (prev) sqlite.prepare(`DELETE FROM "${ftsTable(coll)}" WHERE rowid = ?`).run(prev.rid);
57
64
  const doc = db.collection(coll).getRaw(id);
58
- if (!doc) return;
65
+ if (!doc) {
66
+ if (prev) sqlite.prepare(`DELETE FROM ${IDMAP} WHERE coll = ? AND doc_id = ?`).run(coll, id);
67
+ return;
68
+ }
59
69
  const cols = fields.map((_, i) => `"${col(i)}"`).join(", ");
60
70
  const placeholders = fields.map(() => "?").join(", ");
61
71
  const values = fields.map((f) => extractText(doc, f));
62
- sqlite.prepare(
72
+ const res = sqlite.prepare(
63
73
  `INSERT INTO "${ftsTable(coll)}"(doc_id, ${cols}) VALUES (?, ${placeholders})`
64
74
  ).run(id, ...values);
75
+ sqlite.prepare(
76
+ `INSERT INTO ${IDMAP}(coll, doc_id, rid) VALUES (?, ?, ?) ON CONFLICT(coll, doc_id) DO UPDATE SET rid = excluded.rid`
77
+ ).run(coll, id, Number(res.lastInsertRowid));
65
78
  }
66
79
  function reindex(db, coll, fields) {
67
80
  const sqlite = db.sqlite;
68
81
  sqlite.exec(`DELETE FROM "${ftsTable(coll)}"`);
82
+ sqlite.prepare(`DELETE FROM ${IDMAP} WHERE coll = ?`).run(coll);
69
83
  for (const doc of db.collection(coll).findManyCore({})) {
70
84
  indexDoc(db, coll, fields, doc._id);
71
85
  }
@@ -78,9 +92,10 @@ function search(db, coll, spec, query, opts) {
78
92
  );
79
93
  }
80
94
  const limit = opts?.limit ?? 50;
95
+ const fetch = opts?.where ? Math.max(opts.candidates ?? limit * 10, 200) : limit;
81
96
  const rows = db.sqlite.prepare(
82
97
  `SELECT doc_id, rank FROM "${ftsTable(coll.name)}" WHERE "${ftsTable(coll.name)}" MATCH ? ORDER BY rank LIMIT ?`
83
- ).all(query, limit);
98
+ ).all(query, fetch);
84
99
  let allowed = null;
85
100
  if (opts?.where) {
86
101
  const ids = rows.map((r) => r.doc_id);
@@ -95,6 +110,7 @@ function search(db, coll, spec, query, opts) {
95
110
  if (allowed && !allowed.has(r.doc_id)) continue;
96
111
  const doc = coll.getRaw(r.doc_id);
97
112
  if (doc) out.push({ ...doc, _score: -r.rank });
113
+ if (out.length >= limit) break;
98
114
  }
99
115
  return Promise.resolve(out);
100
116
  }
@@ -110,6 +126,9 @@ function fts(spec) {
110
126
  db.sqlite.exec(
111
127
  `CREATE VIRTUAL TABLE IF NOT EXISTS "${ftsTable(coll)}" USING fts5(doc_id UNINDEXED, ${cols})`
112
128
  );
129
+ db.sqlite.prepare(
130
+ `INSERT OR IGNORE INTO ${IDMAP}(coll, doc_id, rid) SELECT ?, doc_id, rowid FROM "${ftsTable(coll)}"`
131
+ ).run(coll);
113
132
  const count = db.sqlite.prepare(`SELECT count(*) AS n FROM "${ftsTable(coll)}"`).get();
114
133
  if (count.n === 0) reindex(db, coll, fields);
115
134
  catchUp(db, coll, fields);
@@ -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\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"]}
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 * When `where` is set, how many ranked matches to pull before filtering (then\n * trimmed to `limit`). Larger = better recall for selective filters.\n * Default `max(limit * 10, 200)`.\n */\n candidates?: number;\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// doc_id β†’ fts rowid map, so the per-doc re-index can DELETE by rowid (O(log n))\n// instead of scanning the fts table on the UNINDEXED doc_id column (O(n), which\n// made bulk ingestion O(nΒ²)).\nconst IDMAP = \"_monlite_fts_ids\";\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 db.sqlite.exec(\n `CREATE TABLE IF NOT EXISTS ${IDMAP} (coll TEXT NOT NULL, doc_id TEXT NOT NULL, rid INTEGER NOT NULL, PRIMARY KEY (coll, doc_id))`,\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 rowid AS rid, doc_id FROM \"${ftsTable(coll)}\" WHERE doc_id NOT IN (SELECT _id FROM \"${coll}\")`,\n )\n .all() as Array<{ rid: number; doc_id: string }>;\n const del = sqlite.prepare(`DELETE FROM \"${ftsTable(coll)}\" WHERE rowid = ?`);\n const delMap = sqlite.prepare(`DELETE FROM ${IDMAP} WHERE coll = ? AND doc_id = ?`);\n for (const o of orphans) { del.run(o.rid); delMap.run(coll, 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 const prev = sqlite\n .prepare(`SELECT rid FROM ${IDMAP} WHERE coll = ? AND doc_id = ?`)\n .get(coll, id) as { rid: number } | undefined;\n if (prev) sqlite.prepare(`DELETE FROM \"${ftsTable(coll)}\" WHERE rowid = ?`).run(prev.rid);\n const doc = db.collection(coll).getRaw(id);\n if (!doc) {\n if (prev) sqlite.prepare(`DELETE FROM ${IDMAP} WHERE coll = ? AND doc_id = ?`).run(coll, id);\n return; // deleted\n }\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 const res = sqlite\n .prepare(\n `INSERT INTO \"${ftsTable(coll)}\"(doc_id, ${cols}) VALUES (?, ${placeholders})`,\n )\n .run(id, ...values);\n sqlite\n .prepare(\n `INSERT INTO ${IDMAP}(coll, doc_id, rid) VALUES (?, ?, ?) ON CONFLICT(coll, doc_id) DO UPDATE SET rid = excluded.rid`,\n )\n .run(coll, id, Number(res.lastInsertRowid));\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 sqlite.prepare(`DELETE FROM ${IDMAP} WHERE coll = ?`).run(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 // With a where filter, over-fetch ranked matches then filter + trim to limit,\n // so a selective filter doesn't drop results that exist further down the rank.\n const fetch = opts?.where ? Math.max(opts.candidates ?? limit * 10, 200) : limit;\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, fetch) 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 if (out.length >= limit) break;\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 // Migration: backfill the doc_idβ†’rowid map for any existing fts rows\n // (databases written before the map existed), so re-index deletes hit\n // the right row instead of leaving duplicates.\n db.sqlite\n .prepare(\n `INSERT OR IGNORE INTO ${IDMAP}(coll, doc_id, rid) SELECT ?, doc_id, rowid FROM \"${ftsTable(coll)}\"`,\n )\n .run(coll);\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
@@ -7,6 +7,12 @@ interface SearchOptions<T = Doc> {
7
7
  limit?: number;
8
8
  /** Additionally constrain matches with a normal monlite where clause. */
9
9
  where?: WhereInput<T>;
10
+ /**
11
+ * When `where` is set, how many ranked matches to pull before filtering (then
12
+ * trimmed to `limit`). Larger = better recall for selective filters.
13
+ * Default `max(limit * 10, 200)`.
14
+ */
15
+ candidates?: number;
10
16
  }
11
17
  type SearchResult<T = Doc> = WithId<T> & {
12
18
  _score: number;
package/dist/index.d.ts CHANGED
@@ -7,6 +7,12 @@ interface SearchOptions<T = Doc> {
7
7
  limit?: number;
8
8
  /** Additionally constrain matches with a normal monlite where clause. */
9
9
  where?: WhereInput<T>;
10
+ /**
11
+ * When `where` is set, how many ranked matches to pull before filtering (then
12
+ * trimmed to `limit`). Larger = better recall for selective filters.
13
+ * Default `max(limit * 10, 200)`.
14
+ */
15
+ candidates?: number;
10
16
  }
11
17
  type SearchResult<T = Doc> = WithId<T> & {
12
18
  _score: number;
package/dist/index.js CHANGED
@@ -2,10 +2,14 @@
2
2
  var ftsTable = (coll) => `${coll}_fts`;
3
3
  var col = (i) => `f${i}`;
4
4
  var STATE = "_monlite_fts_state";
5
+ var IDMAP = "_monlite_fts_ids";
5
6
  function ensureState(db) {
6
7
  db.sqlite.exec(
7
8
  `CREATE TABLE IF NOT EXISTS ${STATE} (coll TEXT PRIMARY KEY, high_water INTEGER NOT NULL)`
8
9
  );
10
+ db.sqlite.exec(
11
+ `CREATE TABLE IF NOT EXISTS ${IDMAP} (coll TEXT NOT NULL, doc_id TEXT NOT NULL, rid INTEGER NOT NULL, PRIMARY KEY (coll, doc_id))`
12
+ );
9
13
  }
10
14
  function getHighWater(db, coll) {
11
15
  const row = db.sqlite.prepare(`SELECT high_water FROM ${STATE} WHERE coll = ?`).get(coll);
@@ -27,12 +31,14 @@ function catchUp(db, coll, fields) {
27
31
  if (d.updated_at > max) max = d.updated_at;
28
32
  }
29
33
  const orphans = sqlite.prepare(
30
- `SELECT doc_id FROM "${ftsTable(coll)}" WHERE doc_id NOT IN (SELECT _id FROM "${coll}")`
34
+ `SELECT rowid AS rid, doc_id FROM "${ftsTable(coll)}" WHERE doc_id NOT IN (SELECT _id FROM "${coll}")`
31
35
  ).all();
32
- const del = sqlite.prepare(
33
- `DELETE FROM "${ftsTable(coll)}" WHERE doc_id = ?`
34
- );
35
- for (const o of orphans) del.run(o.doc_id);
36
+ const del = sqlite.prepare(`DELETE FROM "${ftsTable(coll)}" WHERE rowid = ?`);
37
+ const delMap = sqlite.prepare(`DELETE FROM ${IDMAP} WHERE coll = ? AND doc_id = ?`);
38
+ for (const o of orphans) {
39
+ del.run(o.rid);
40
+ delMap.run(coll, o.doc_id);
41
+ }
36
42
  setHighWater(db, coll, max);
37
43
  return { indexed: docs.length, removed: orphans.length };
38
44
  }
@@ -51,19 +57,27 @@ function extractText(doc, path) {
51
57
  }
52
58
  function indexDoc(db, coll, fields, id) {
53
59
  const sqlite = db.sqlite;
54
- sqlite.prepare(`DELETE FROM "${ftsTable(coll)}" WHERE doc_id = ?`).run(id);
60
+ const prev = sqlite.prepare(`SELECT rid FROM ${IDMAP} WHERE coll = ? AND doc_id = ?`).get(coll, id);
61
+ if (prev) sqlite.prepare(`DELETE FROM "${ftsTable(coll)}" WHERE rowid = ?`).run(prev.rid);
55
62
  const doc = db.collection(coll).getRaw(id);
56
- if (!doc) return;
63
+ if (!doc) {
64
+ if (prev) sqlite.prepare(`DELETE FROM ${IDMAP} WHERE coll = ? AND doc_id = ?`).run(coll, id);
65
+ return;
66
+ }
57
67
  const cols = fields.map((_, i) => `"${col(i)}"`).join(", ");
58
68
  const placeholders = fields.map(() => "?").join(", ");
59
69
  const values = fields.map((f) => extractText(doc, f));
60
- sqlite.prepare(
70
+ const res = sqlite.prepare(
61
71
  `INSERT INTO "${ftsTable(coll)}"(doc_id, ${cols}) VALUES (?, ${placeholders})`
62
72
  ).run(id, ...values);
73
+ sqlite.prepare(
74
+ `INSERT INTO ${IDMAP}(coll, doc_id, rid) VALUES (?, ?, ?) ON CONFLICT(coll, doc_id) DO UPDATE SET rid = excluded.rid`
75
+ ).run(coll, id, Number(res.lastInsertRowid));
63
76
  }
64
77
  function reindex(db, coll, fields) {
65
78
  const sqlite = db.sqlite;
66
79
  sqlite.exec(`DELETE FROM "${ftsTable(coll)}"`);
80
+ sqlite.prepare(`DELETE FROM ${IDMAP} WHERE coll = ?`).run(coll);
67
81
  for (const doc of db.collection(coll).findManyCore({})) {
68
82
  indexDoc(db, coll, fields, doc._id);
69
83
  }
@@ -76,9 +90,10 @@ function search(db, coll, spec, query, opts) {
76
90
  );
77
91
  }
78
92
  const limit = opts?.limit ?? 50;
93
+ const fetch = opts?.where ? Math.max(opts.candidates ?? limit * 10, 200) : limit;
79
94
  const rows = db.sqlite.prepare(
80
95
  `SELECT doc_id, rank FROM "${ftsTable(coll.name)}" WHERE "${ftsTable(coll.name)}" MATCH ? ORDER BY rank LIMIT ?`
81
- ).all(query, limit);
96
+ ).all(query, fetch);
82
97
  let allowed = null;
83
98
  if (opts?.where) {
84
99
  const ids = rows.map((r) => r.doc_id);
@@ -93,6 +108,7 @@ function search(db, coll, spec, query, opts) {
93
108
  if (allowed && !allowed.has(r.doc_id)) continue;
94
109
  const doc = coll.getRaw(r.doc_id);
95
110
  if (doc) out.push({ ...doc, _score: -r.rank });
111
+ if (out.length >= limit) break;
96
112
  }
97
113
  return Promise.resolve(out);
98
114
  }
@@ -108,6 +124,9 @@ function fts(spec) {
108
124
  db.sqlite.exec(
109
125
  `CREATE VIRTUAL TABLE IF NOT EXISTS "${ftsTable(coll)}" USING fts5(doc_id UNINDEXED, ${cols})`
110
126
  );
127
+ db.sqlite.prepare(
128
+ `INSERT OR IGNORE INTO ${IDMAP}(coll, doc_id, rid) SELECT ?, doc_id, rowid FROM "${ftsTable(coll)}"`
129
+ ).run(coll);
111
130
  const count = db.sqlite.prepare(`SELECT count(*) AS n FROM "${ftsTable(coll)}"`).get();
112
131
  if (count.n === 0) reindex(db, coll, fields);
113
132
  catchUp(db, coll, fields);
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\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"]}
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 * When `where` is set, how many ranked matches to pull before filtering (then\n * trimmed to `limit`). Larger = better recall for selective filters.\n * Default `max(limit * 10, 200)`.\n */\n candidates?: number;\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// doc_id β†’ fts rowid map, so the per-doc re-index can DELETE by rowid (O(log n))\n// instead of scanning the fts table on the UNINDEXED doc_id column (O(n), which\n// made bulk ingestion O(nΒ²)).\nconst IDMAP = \"_monlite_fts_ids\";\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 db.sqlite.exec(\n `CREATE TABLE IF NOT EXISTS ${IDMAP} (coll TEXT NOT NULL, doc_id TEXT NOT NULL, rid INTEGER NOT NULL, PRIMARY KEY (coll, doc_id))`,\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 rowid AS rid, doc_id FROM \"${ftsTable(coll)}\" WHERE doc_id NOT IN (SELECT _id FROM \"${coll}\")`,\n )\n .all() as Array<{ rid: number; doc_id: string }>;\n const del = sqlite.prepare(`DELETE FROM \"${ftsTable(coll)}\" WHERE rowid = ?`);\n const delMap = sqlite.prepare(`DELETE FROM ${IDMAP} WHERE coll = ? AND doc_id = ?`);\n for (const o of orphans) { del.run(o.rid); delMap.run(coll, 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 const prev = sqlite\n .prepare(`SELECT rid FROM ${IDMAP} WHERE coll = ? AND doc_id = ?`)\n .get(coll, id) as { rid: number } | undefined;\n if (prev) sqlite.prepare(`DELETE FROM \"${ftsTable(coll)}\" WHERE rowid = ?`).run(prev.rid);\n const doc = db.collection(coll).getRaw(id);\n if (!doc) {\n if (prev) sqlite.prepare(`DELETE FROM ${IDMAP} WHERE coll = ? AND doc_id = ?`).run(coll, id);\n return; // deleted\n }\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 const res = sqlite\n .prepare(\n `INSERT INTO \"${ftsTable(coll)}\"(doc_id, ${cols}) VALUES (?, ${placeholders})`,\n )\n .run(id, ...values);\n sqlite\n .prepare(\n `INSERT INTO ${IDMAP}(coll, doc_id, rid) VALUES (?, ?, ?) ON CONFLICT(coll, doc_id) DO UPDATE SET rid = excluded.rid`,\n )\n .run(coll, id, Number(res.lastInsertRowid));\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 sqlite.prepare(`DELETE FROM ${IDMAP} WHERE coll = ?`).run(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 // With a where filter, over-fetch ranked matches then filter + trim to limit,\n // so a selective filter doesn't drop results that exist further down the rank.\n const fetch = opts?.where ? Math.max(opts.candidates ?? limit * 10, 200) : limit;\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, fetch) 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 if (out.length >= limit) break;\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 // Migration: backfill the doc_idβ†’rowid map for any existing fts rows\n // (databases written before the map existed), so re-index deletes hit\n // the right row instead of leaving duplicates.\n db.sqlite\n .prepare(\n `INSERT OR IGNORE INTO ${IDMAP}(coll, doc_id, rid) SELECT ?, doc_id, rowid FROM \"${ftsTable(coll)}\"`,\n )\n .run(coll);\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.4.0",
3
+ "version": "0.5.1",
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.6.1"
51
+ "@monlite/core": "^2.6.3"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/node": "^22.10.0",