@monlite/fts 0.1.2 → 0.2.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
@@ -66,6 +66,20 @@ existing documents when the index is empty) and keeps it current via the plugin
66
66
  `afterWrite` hook. Search runs `MATCH` against that table, then returns the live
67
67
  documents from the collection in rank order.
68
68
 
69
+ ## Multi-process freshness
70
+
71
+ The `afterWrite` hook only sees writes made through *its own* connection. If a
72
+ **separate process** writes documents (e.g. an ingest worker), call
73
+ `collection.catchUp()` in the searching process to incrementally index what
74
+ changed (and reconcile cross-process deletes) — no full reindex:
75
+
76
+ ```ts
77
+ db.collection("posts").catchUp(); // → { indexed, removed }; call periodically
78
+ await db.collection("posts").search("hello");
79
+ ```
80
+
81
+ It tracks an `updated_at` high-water-mark, so each call only does the new work.
82
+
69
83
  ## License
70
84
 
71
85
  MIT 🌙
package/dist/index.cjs CHANGED
@@ -3,6 +3,41 @@
3
3
  // src/index.ts
4
4
  var ftsTable = (coll) => `${coll}_fts`;
5
5
  var col = (i) => `f${i}`;
6
+ var STATE = "_monlite_fts_state";
7
+ function ensureState(db) {
8
+ db.sqlite.exec(
9
+ `CREATE TABLE IF NOT EXISTS ${STATE} (coll TEXT PRIMARY KEY, high_water INTEGER NOT NULL)`
10
+ );
11
+ }
12
+ function getHighWater(db, coll) {
13
+ const row = db.sqlite.prepare(`SELECT high_water FROM ${STATE} WHERE coll = ?`).get(coll);
14
+ return row?.high_water ?? 0;
15
+ }
16
+ function setHighWater(db, coll, value) {
17
+ db.sqlite.prepare(
18
+ `INSERT INTO ${STATE}(coll, high_water) VALUES (?, ?) ON CONFLICT(coll) DO UPDATE SET high_water = excluded.high_water`
19
+ ).run(coll, value);
20
+ }
21
+ function catchUp(db, coll, fields) {
22
+ const sqlite = db.sqlite;
23
+ ensureState(db);
24
+ const hw = getHighWater(db, coll);
25
+ const docs = sqlite.prepare(`SELECT _id, updated_at FROM "${coll}" WHERE updated_at >= ?`).all(hw);
26
+ let max = hw;
27
+ for (const d of docs) {
28
+ indexDoc(db, coll, fields, d._id);
29
+ if (d.updated_at > max) max = d.updated_at;
30
+ }
31
+ const orphans = sqlite.prepare(
32
+ `SELECT doc_id FROM "${ftsTable(coll)}" WHERE doc_id NOT IN (SELECT _id FROM "${coll}")`
33
+ ).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
+ setHighWater(db, coll, max);
39
+ return { indexed: docs.length, removed: orphans.length };
40
+ }
6
41
  function extractText(doc, path) {
7
42
  let cur = doc;
8
43
  for (const seg of path.split(".")) {
@@ -69,6 +104,7 @@ function fts(spec) {
69
104
  name: "fts",
70
105
  init(db) {
71
106
  database = db;
107
+ ensureState(db);
72
108
  for (const [coll, fields] of Object.entries(spec)) {
73
109
  const cols = fields.map((_, i) => `"${col(i)}"`).join(", ");
74
110
  db.sqlite.exec(
@@ -76,19 +112,23 @@ function fts(spec) {
76
112
  );
77
113
  const count = db.sqlite.prepare(`SELECT count(*) AS n FROM "${ftsTable(coll)}"`).get();
78
114
  if (count.n === 0) reindex(db, coll, fields);
115
+ catchUp(db, coll, fields);
79
116
  }
80
117
  },
81
118
  afterWrite(db, { collection, ids }) {
82
119
  const fields = spec[collection];
83
120
  if (!fields) return;
84
121
  for (const id of ids) indexDoc(db, collection, fields, id);
122
+ setHighWater(db, collection, Date.now());
85
123
  },
86
124
  collectionMethods: {
87
- search: (collection, query, opts) => search(database, collection, spec, query, opts)
125
+ search: (collection, query, opts) => search(database, collection, spec, query, opts),
126
+ catchUp: (collection) => catchUp(database, collection.name, spec[collection.name] ?? [])
88
127
  }
89
128
  };
90
129
  }
91
130
 
131
+ exports.catchUp = catchUp;
92
132
  exports.fts = fts;
93
133
  exports.reindex = reindex;
94
134
  //# 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 }\n}\n\nconst ftsTable = (coll: string) => `${coll}_fts`;\nconst col = (i: number) => `f${i}`;\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 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 }\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 },\n collectionMethods: {\n search: (collection, query: string, opts?: SearchOptions) =>\n search(database, collection, spec, query, opts),\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"]}
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { Doc, WhereInput, WithId, MonlitePlugin, Monlite } from '@monlite/core';
1
+ import { Doc, WhereInput, WithId, Monlite, MonlitePlugin } from '@monlite/core';
2
2
 
3
3
  /** Map of collection name → searchable field paths (dot-notation allowed). */
4
4
  type FtsSpec = Record<string, string[]>;
@@ -14,8 +14,22 @@ type SearchResult<T = Doc> = WithId<T> & {
14
14
  declare module "@monlite/core" {
15
15
  interface Collection<T> {
16
16
  search(query: string, opts?: SearchOptions<T>): Promise<SearchResult<T>[]>;
17
+ /** Pick up documents written by another process; returns counts. */
18
+ catchUp(): {
19
+ indexed: number;
20
+ removed: number;
21
+ };
17
22
  }
18
23
  }
24
+ /**
25
+ * Incrementally index documents written by another process (and drop entries for
26
+ * cross-process deletes), so a separate searcher process becomes fresh without a
27
+ * full {@link reindex}. Returns how many docs were (re)indexed and removed.
28
+ */
29
+ declare function catchUp(db: Monlite, coll: string, fields: string[]): {
30
+ indexed: number;
31
+ removed: number;
32
+ };
19
33
  /** Rebuild a collection's FTS index from scratch. */
20
34
  declare function reindex(db: Monlite, coll: string, fields: string[]): void;
21
35
  /**
@@ -30,4 +44,4 @@ declare function reindex(db: Monlite, coll: string, fields: string[]): void;
30
44
  */
31
45
  declare function fts(spec: FtsSpec): MonlitePlugin;
32
46
 
33
- export { type FtsSpec, type SearchOptions, type SearchResult, fts, reindex };
47
+ export { type FtsSpec, type SearchOptions, type SearchResult, catchUp, fts, reindex };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Doc, WhereInput, WithId, MonlitePlugin, Monlite } from '@monlite/core';
1
+ import { Doc, WhereInput, WithId, Monlite, MonlitePlugin } from '@monlite/core';
2
2
 
3
3
  /** Map of collection name → searchable field paths (dot-notation allowed). */
4
4
  type FtsSpec = Record<string, string[]>;
@@ -14,8 +14,22 @@ type SearchResult<T = Doc> = WithId<T> & {
14
14
  declare module "@monlite/core" {
15
15
  interface Collection<T> {
16
16
  search(query: string, opts?: SearchOptions<T>): Promise<SearchResult<T>[]>;
17
+ /** Pick up documents written by another process; returns counts. */
18
+ catchUp(): {
19
+ indexed: number;
20
+ removed: number;
21
+ };
17
22
  }
18
23
  }
24
+ /**
25
+ * Incrementally index documents written by another process (and drop entries for
26
+ * cross-process deletes), so a separate searcher process becomes fresh without a
27
+ * full {@link reindex}. Returns how many docs were (re)indexed and removed.
28
+ */
29
+ declare function catchUp(db: Monlite, coll: string, fields: string[]): {
30
+ indexed: number;
31
+ removed: number;
32
+ };
19
33
  /** Rebuild a collection's FTS index from scratch. */
20
34
  declare function reindex(db: Monlite, coll: string, fields: string[]): void;
21
35
  /**
@@ -30,4 +44,4 @@ declare function reindex(db: Monlite, coll: string, fields: string[]): void;
30
44
  */
31
45
  declare function fts(spec: FtsSpec): MonlitePlugin;
32
46
 
33
- export { type FtsSpec, type SearchOptions, type SearchResult, fts, reindex };
47
+ export { type FtsSpec, type SearchOptions, type SearchResult, catchUp, fts, reindex };
package/dist/index.js CHANGED
@@ -1,6 +1,41 @@
1
1
  // src/index.ts
2
2
  var ftsTable = (coll) => `${coll}_fts`;
3
3
  var col = (i) => `f${i}`;
4
+ var STATE = "_monlite_fts_state";
5
+ function ensureState(db) {
6
+ db.sqlite.exec(
7
+ `CREATE TABLE IF NOT EXISTS ${STATE} (coll TEXT PRIMARY KEY, high_water INTEGER NOT NULL)`
8
+ );
9
+ }
10
+ function getHighWater(db, coll) {
11
+ const row = db.sqlite.prepare(`SELECT high_water FROM ${STATE} WHERE coll = ?`).get(coll);
12
+ return row?.high_water ?? 0;
13
+ }
14
+ function setHighWater(db, coll, value) {
15
+ db.sqlite.prepare(
16
+ `INSERT INTO ${STATE}(coll, high_water) VALUES (?, ?) ON CONFLICT(coll) DO UPDATE SET high_water = excluded.high_water`
17
+ ).run(coll, value);
18
+ }
19
+ function catchUp(db, coll, fields) {
20
+ const sqlite = db.sqlite;
21
+ ensureState(db);
22
+ const hw = getHighWater(db, coll);
23
+ const docs = sqlite.prepare(`SELECT _id, updated_at FROM "${coll}" WHERE updated_at >= ?`).all(hw);
24
+ let max = hw;
25
+ for (const d of docs) {
26
+ indexDoc(db, coll, fields, d._id);
27
+ if (d.updated_at > max) max = d.updated_at;
28
+ }
29
+ const orphans = sqlite.prepare(
30
+ `SELECT doc_id FROM "${ftsTable(coll)}" WHERE doc_id NOT IN (SELECT _id FROM "${coll}")`
31
+ ).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
+ setHighWater(db, coll, max);
37
+ return { indexed: docs.length, removed: orphans.length };
38
+ }
4
39
  function extractText(doc, path) {
5
40
  let cur = doc;
6
41
  for (const seg of path.split(".")) {
@@ -67,6 +102,7 @@ function fts(spec) {
67
102
  name: "fts",
68
103
  init(db) {
69
104
  database = db;
105
+ ensureState(db);
70
106
  for (const [coll, fields] of Object.entries(spec)) {
71
107
  const cols = fields.map((_, i) => `"${col(i)}"`).join(", ");
72
108
  db.sqlite.exec(
@@ -74,19 +110,22 @@ function fts(spec) {
74
110
  );
75
111
  const count = db.sqlite.prepare(`SELECT count(*) AS n FROM "${ftsTable(coll)}"`).get();
76
112
  if (count.n === 0) reindex(db, coll, fields);
113
+ catchUp(db, coll, fields);
77
114
  }
78
115
  },
79
116
  afterWrite(db, { collection, ids }) {
80
117
  const fields = spec[collection];
81
118
  if (!fields) return;
82
119
  for (const id of ids) indexDoc(db, collection, fields, id);
120
+ setHighWater(db, collection, Date.now());
83
121
  },
84
122
  collectionMethods: {
85
- search: (collection, query, opts) => search(database, collection, spec, query, opts)
123
+ search: (collection, query, opts) => search(database, collection, spec, query, opts),
124
+ catchUp: (collection) => catchUp(database, collection.name, spec[collection.name] ?? [])
86
125
  }
87
126
  };
88
127
  }
89
128
 
90
- export { fts, reindex };
129
+ export { catchUp, fts, reindex };
91
130
  //# sourceMappingURL=index.js.map
92
131
  //# 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 }\n}\n\nconst ftsTable = (coll: string) => `${coll}_fts`;\nconst col = (i: number) => `f${i}`;\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 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 }\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 },\n collectionMethods: {\n search: (collection, query: string, opts?: SearchOptions) =>\n search(database, collection, spec, query, opts),\n },\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";AA8BA,IAAM,QAAA,GAAW,CAAC,IAAA,KAAiB,CAAA,EAAG,IAAI,CAAA,IAAA,CAAA;AAC1C,IAAM,GAAA,GAAM,CAAC,CAAA,KAAc,CAAA,CAAA,EAAI,CAAC,CAAA,CAAA;AAChC,IAAM,KAAA,GAAQ,oBAAA;AAEd,SAAS,YAAY,EAAA,EAAmB;AACtC,EAAA,EAAA,CAAG,MAAA,CAAO,IAAA;AAAA,IACR,8BAA8B,KAAK,CAAA,qDAAA;AAAA,GACrC;AACF;AACA,SAAS,YAAA,CAAa,IAAa,IAAA,EAAsB;AACvD,EAAA,MAAM,GAAA,GAAM,GAAG,MAAA,CACZ,OAAA,CAAQ,0BAA0B,KAAK,CAAA,eAAA,CAAiB,CAAA,CACxD,GAAA,CAAI,IAAI,CAAA;AACX,EAAA,OAAO,KAAK,UAAA,IAAc,CAAA;AAC5B;AACA,SAAS,YAAA,CAAa,EAAA,EAAa,IAAA,EAAc,KAAA,EAAqB;AACpE,EAAA,EAAA,CAAG,MAAA,CACA,OAAA;AAAA,IACC,eAAe,KAAK,CAAA,iGAAA;AAAA,GACtB,CACC,GAAA,CAAI,IAAA,EAAM,KAAK,CAAA;AACpB;AAOO,SAAS,OAAA,CACd,EAAA,EACA,IAAA,EACA,MAAA,EACsC;AACtC,EAAA,MAAM,SAAS,EAAA,CAAG,MAAA;AAClB,EAAA,WAAA,CAAY,EAAE,CAAA;AACd,EAAA,MAAM,EAAA,GAAK,YAAA,CAAa,EAAA,EAAI,IAAI,CAAA;AAChC,EAAA,MAAM,IAAA,GAAO,OACV,OAAA,CAAQ,CAAA,6BAAA,EAAgC,IAAI,CAAA,uBAAA,CAAyB,CAAA,CACrE,IAAI,EAAE,CAAA;AACT,EAAA,IAAI,GAAA,GAAM,EAAA;AACV,EAAA,KAAA,MAAW,KAAK,IAAA,EAAM;AACpB,IAAA,QAAA,CAAS,EAAA,EAAI,IAAA,EAAM,MAAA,EAAQ,CAAA,CAAE,GAAG,CAAA;AAChC,IAAA,IAAI,CAAA,CAAE,UAAA,GAAa,GAAA,EAAK,GAAA,GAAM,CAAA,CAAE,UAAA;AAAA,EAClC;AAEA,EAAA,MAAM,UAAU,MAAA,CACb,OAAA;AAAA,IACC,CAAA,oBAAA,EAAuB,QAAA,CAAS,IAAI,CAAC,2CAA2C,IAAI,CAAA,EAAA;AAAA,IAErF,GAAA,EAAI;AACP,EAAA,MAAM,MAAM,MAAA,CAAO,OAAA;AAAA,IACjB,CAAA,aAAA,EAAgB,QAAA,CAAS,IAAI,CAAC,CAAA,kBAAA;AAAA,GAChC;AACA,EAAA,KAAA,MAAW,CAAA,IAAK,OAAA,EAAS,GAAA,CAAI,GAAA,CAAI,EAAE,MAAM,CAAA;AACzC,EAAA,YAAA,CAAa,EAAA,EAAI,MAAM,GAAG,CAAA;AAC1B,EAAA,OAAO,EAAE,OAAA,EAAS,IAAA,CAAK,MAAA,EAAQ,OAAA,EAAS,QAAQ,MAAA,EAAO;AACzD;AAGA,SAAS,WAAA,CAAY,KAA0B,IAAA,EAAsB;AACnE,EAAA,IAAI,GAAA,GAAW,GAAA;AACf,EAAA,KAAA,MAAW,GAAA,IAAO,IAAA,CAAK,KAAA,CAAM,GAAG,CAAA,EAAG;AACjC,IAAA,IAAI,GAAA,IAAO,MAAM,OAAO,EAAA;AACxB,IAAA,GAAA,GAAM,IAAI,GAAG,CAAA;AAAA,EACf;AACA,EAAA,IAAI,GAAA,IAAO,MAAM,OAAO,EAAA;AACxB,EAAA,IAAI,OAAO,GAAA,KAAQ,QAAA,EAAU,OAAO,GAAA;AACpC,EAAA,IAAI,KAAA,CAAM,QAAQ,GAAG,CAAA;AACnB,IAAA,OAAO,GAAA,CACJ,MAAA,CAAO,CAAC,CAAA,KAAM,CAAA,IAAK,IAAI,CAAA,CACvB,GAAA,CAAI,MAAM,CAAA,CACV,IAAA,CAAK,GAAG,CAAA;AACb,EAAA,IAAI,OAAO,QAAQ,QAAA,IAAY,OAAO,QAAQ,SAAA,EAAW,OAAO,OAAO,GAAG,CAAA;AAC1E,EAAA,OAAO,EAAA;AACT;AAEA,SAAS,QAAA,CACP,EAAA,EACA,IAAA,EACA,MAAA,EACA,EAAA,EACM;AACN,EAAA,MAAM,SAAS,EAAA,CAAG,MAAA;AAClB,EAAA,MAAA,CAAO,OAAA,CAAQ,gBAAgB,QAAA,CAAS,IAAI,CAAC,CAAA,kBAAA,CAAoB,CAAA,CAAE,IAAI,EAAE,CAAA;AACzE,EAAA,MAAM,MAAM,EAAA,CAAG,UAAA,CAAW,IAAI,CAAA,CAAE,OAAO,EAAE,CAAA;AACzC,EAAA,IAAI,CAAC,GAAA,EAAK;AACV,EAAA,MAAM,IAAA,GAAO,MAAA,CAAO,GAAA,CAAI,CAAC,CAAA,EAAG,CAAA,KAAM,CAAA,CAAA,EAAI,GAAA,CAAI,CAAC,CAAC,CAAA,CAAA,CAAG,CAAA,CAAE,KAAK,IAAI,CAAA;AAC1D,EAAA,MAAM,eAAe,MAAA,CAAO,GAAA,CAAI,MAAM,GAAG,CAAA,CAAE,KAAK,IAAI,CAAA;AACpD,EAAA,MAAM,MAAA,GAAS,OAAO,GAAA,CAAI,CAAC,MAAM,WAAA,CAAY,GAAA,EAAK,CAAC,CAAC,CAAA;AACpD,EAAA,MAAA,CACG,OAAA;AAAA,IACC,gBAAgB,QAAA,CAAS,IAAI,CAAC,CAAA,UAAA,EAAa,IAAI,gBAAgB,YAAY,CAAA,CAAA;AAAA,GAC7E,CACC,GAAA,CAAI,EAAA,EAAI,GAAG,MAAM,CAAA;AACtB;AAGO,SAAS,OAAA,CAAQ,EAAA,EAAa,IAAA,EAAc,MAAA,EAAwB;AACzE,EAAA,MAAM,SAAS,EAAA,CAAG,MAAA;AAClB,EAAA,MAAA,CAAO,IAAA,CAAK,CAAA,aAAA,EAAgB,QAAA,CAAS,IAAI,CAAC,CAAA,CAAA,CAAG,CAAA;AAC7C,EAAA,KAAA,MAAW,GAAA,IAAO,GAAG,UAAA,CAAW,IAAI,EAAE,YAAA,CAAa,EAAE,CAAA,EAAG;AACtD,IAAA,QAAA,CAAS,EAAA,EAAI,IAAA,EAAM,MAAA,EAAS,GAAA,CAAoB,GAAG,CAAA;AAAA,EACrD;AACF;AAEA,SAAS,MAAA,CACP,EAAA,EACA,IAAA,EACA,IAAA,EACA,OACA,IAAA,EAC4B;AAC5B,EAAA,MAAM,MAAA,GAAS,IAAA,CAAK,IAAA,CAAK,IAAI,CAAA;AAC7B,EAAA,IAAI,CAAC,MAAA,EAAQ;AACX,IAAA,MAAM,IAAI,KAAA;AAAA,MACR,CAAA,YAAA,EAAe,KAAK,IAAI,CAAA,wCAAA;AAAA,KAC1B;AAAA,EACF;AACA,EAAA,MAAM,KAAA,GAAQ,MAAM,KAAA,IAAS,EAAA;AAC7B,EAAA,MAAM,IAAA,GAAO,GAAG,MAAA,CACb,OAAA;AAAA,IACC,CAAA,0BAAA,EAA6B,SAAS,IAAA,CAAK,IAAI,CAAC,CAAA,SAAA,EACpC,QAAA,CAAS,IAAA,CAAK,IAAI,CAAC,CAAA,+BAAA;AAAA,GACjC,CACC,GAAA,CAAI,KAAA,EAAO,KAAK,CAAA;AAEnB,EAAA,IAAI,OAAA,GAA8B,IAAA;AAClC,EAAA,IAAI,MAAM,KAAA,EAAO;AACf,IAAA,MAAM,MAAM,IAAA,CAAK,GAAA,CAAI,CAAC,CAAA,KAAM,EAAE,MAAM,CAAA;AACpC,IAAA,MAAM,OAAO,EAAE,GAAA,EAAK,EAAE,EAAA,EAAI,KAAI,EAAE;AAChC,IAAA,MAAM,QAAA,GAAW,KAAK,YAAA,CAAa;AAAA,MACjC,OAAO,EAAE,GAAA,EAAK,CAAC,IAAA,CAAK,KAAA,EAAO,IAAI,CAAA;AAAE,KAClC,CAAA;AACD,IAAA,OAAA,GAAU,IAAI,IAAI,QAAA,CAAS,GAAA,CAAI,CAAC,CAAA,KAAM,CAAA,CAAE,GAAG,CAAC,CAAA;AAAA,EAC9C;AAEA,EAAA,MAAM,MAAyB,EAAC;AAChC,EAAA,KAAA,MAAW,KAAK,IAAA,EAAM;AACpB,IAAA,IAAI,WAAW,CAAC,OAAA,CAAQ,GAAA,CAAI,CAAA,CAAE,MAAM,CAAA,EAAG;AACvC,IAAA,MAAM,GAAA,GAAM,IAAA,CAAK,MAAA,CAAO,CAAA,CAAE,MAAM,CAAA;AAChC,IAAA,IAAI,GAAA,EAAK,GAAA,CAAI,IAAA,CAAK,EAAE,GAAG,KAAK,MAAA,EAAQ,CAAC,CAAA,CAAE,IAAA,EAAyB,CAAA;AAAA,EAClE;AACA,EAAA,OAAO,OAAA,CAAQ,QAAQ,GAAG,CAAA;AAC5B;AAYO,SAAS,IAAI,IAAA,EAA8B;AAChD,EAAA,IAAI,QAAA;AACJ,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,KAAA;AAAA,IACN,KAAK,EAAA,EAAI;AACP,MAAA,QAAA,GAAW,EAAA;AACX,MAAA,WAAA,CAAY,EAAE,CAAA;AACd,MAAA,KAAA,MAAW,CAAC,IAAA,EAAM,MAAM,KAAK,MAAA,CAAO,OAAA,CAAQ,IAAI,CAAA,EAAG;AACjD,QAAA,MAAM,IAAA,GAAO,MAAA,CAAO,GAAA,CAAI,CAAC,CAAA,EAAG,CAAA,KAAM,CAAA,CAAA,EAAI,GAAA,CAAI,CAAC,CAAC,CAAA,CAAA,CAAG,CAAA,CAAE,KAAK,IAAI,CAAA;AAC1D,QAAA,EAAA,CAAG,MAAA,CAAO,IAAA;AAAA,UACR,CAAA,oCAAA,EAAuC,QAAA,CAAS,IAAI,CAAC,kCACnB,IAAI,CAAA,CAAA;AAAA,SACxC;AAEA,QAAA,MAAM,KAAA,GAAQ,EAAA,CAAG,MAAA,CACd,OAAA,CAAQ,CAAA,2BAAA,EAA8B,SAAS,IAAI,CAAC,CAAA,CAAA,CAAG,CAAA,CACvD,GAAA,EAAI;AACP,QAAA,IAAI,MAAM,CAAA,KAAM,CAAA,EAAG,OAAA,CAAQ,EAAA,EAAI,MAAM,MAAM,CAAA;AAE3C,QAAA,OAAA,CAAQ,EAAA,EAAI,MAAM,MAAM,CAAA;AAAA,MAC1B;AAAA,IACF,CAAA;AAAA,IACA,UAAA,CAAW,EAAA,EAAI,EAAE,UAAA,EAAY,KAAI,EAAG;AAClC,MAAA,MAAM,MAAA,GAAS,KAAK,UAAU,CAAA;AAC9B,MAAA,IAAI,CAAC,MAAA,EAAQ;AACb,MAAA,KAAA,MAAW,MAAM,GAAA,EAAK,QAAA,CAAS,EAAA,EAAI,UAAA,EAAY,QAAQ,EAAE,CAAA;AACzD,MAAA,YAAA,CAAa,EAAA,EAAI,UAAA,EAAY,IAAA,CAAK,GAAA,EAAK,CAAA;AAAA,IACzC,CAAA;AAAA,IACA,iBAAA,EAAmB;AAAA,MACjB,MAAA,EAAQ,CAAC,UAAA,EAAY,KAAA,EAAe,IAAA,KAClC,OAAO,QAAA,EAAU,UAAA,EAAY,IAAA,EAAM,KAAA,EAAO,IAAI,CAAA;AAAA,MAChD,OAAA,EAAS,CAAC,UAAA,KACR,OAAA,CAAQ,QAAA,EAAU,UAAA,CAAW,IAAA,EAAM,IAAA,CAAK,UAAA,CAAW,IAAI,CAAA,IAAK,EAAE;AAAA;AAClE,GACF;AACF","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"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monlite/fts",
3
- "version": "0.1.2",
3
+ "version": "0.2.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": "^1.0.0"
51
+ "@monlite/core": "^2.4.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/node": "^22.10.0",