@silicajs/search 0.1.0 → 0.3.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
@@ -1,7 +1,7 @@
1
1
  # @silicajs/search
2
2
 
3
- Server-side FlexSearch helpers for Silica.
3
+ Server-side SQLite full-text search helpers for Silica.
4
4
 
5
- The build step creates a serialized `Document` index and records bundle. The generated Next.js `/api/search` route lazy-loads that artifact into a process-level singleton and returns ranked results with excerpts.
5
+ The build step adds an FTS5 `search_index` table to `.silica/vault.db`. The generated Next.js `/api/search` route queries that shared vault database and returns ranked results with excerpts.
6
6
 
7
7
  Includes a benchmark helper for cold/warm search latency checks.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Document } from 'flexsearch';
1
+ import Database from 'better-sqlite3';
2
2
 
3
3
  type SearchRecord = {
4
4
  id: string;
@@ -8,30 +8,23 @@ type SearchRecord = {
8
8
  description?: string;
9
9
  tags: string[];
10
10
  };
11
- type StoredSearchRecord = Omit<SearchRecord, "content"> & {
12
- excerpt: string;
13
- };
14
- type SerializedSearchIndex = {
11
+ type SearchDatabaseMetadata = {
15
12
  version: 1;
16
- config: SearchIndexConfig;
17
- records: StoredSearchRecord[];
18
- exported: Record<string, string>;
13
+ databasePath: string;
14
+ recordCount: number;
19
15
  builtAt: string;
20
16
  };
21
- type SearchIndexConfig = {
22
- tokenize: "forward" | "full" | "reverse" | "strict";
23
- document: {
24
- id: "id";
25
- index: ["title", "content", "tags"];
26
- store: ["slug", "title", "description", "tags"];
27
- };
17
+ type SearchHighlightPart = {
18
+ text: string;
19
+ highlighted: boolean;
28
20
  };
29
21
  type SearchResult = {
30
22
  slug: string;
31
23
  title: string;
24
+ titleParts: SearchHighlightPart[];
32
25
  description?: string;
33
26
  tags: string[];
34
- excerpt: string;
27
+ excerptParts: SearchHighlightPart[];
35
28
  score: number;
36
29
  };
37
30
  type SearchQueryOptions = {
@@ -49,23 +42,22 @@ type SearchBenchmarkResult = {
49
42
  warmRuns: number;
50
43
  resultCount: number;
51
44
  };
52
- declare function benchmarkSearchIndex(artifact: SerializedSearchIndex, { query, warmRuns, ...queryOptions }: SearchBenchmarkOptions): Promise<SearchBenchmarkResult>;
45
+ declare function benchmarkSearchIndex(databasePath: string, { query, warmRuns, ...queryOptions }: SearchBenchmarkOptions): Promise<SearchBenchmarkResult>;
53
46
 
54
- declare const DEFAULT_SEARCH_CONFIG: SearchIndexConfig;
55
- declare function createSearchDocument(config?: SearchIndexConfig): Document;
56
- declare function buildSearchIndex(records: SearchRecord[]): Promise<SerializedSearchIndex>;
47
+ declare const SEARCH_DATABASE_FILENAME = "search.db";
48
+ declare function buildSearchDatabase(records: SearchRecord[], databasePath: string): Promise<SearchDatabaseMetadata>;
49
+ declare function buildSearchTables(db: Database.Database, records: SearchRecord[]): string;
57
50
 
58
51
  declare function normalizeSearchText(value: string): string;
59
52
  declare function makeExcerpt(content: string, query: string, maxLength?: number): string;
60
53
 
61
54
  type LoadedSearchIndex = {
62
- document: ReturnType<typeof createSearchDocument>;
63
- artifact: SerializedSearchIndex;
64
- recordsById: Map<string, StoredSearchRecord>;
55
+ databasePath: string;
56
+ db: Database.Database;
57
+ close: () => void;
65
58
  };
66
- declare function hydrateSearchIndex(artifact: SerializedSearchIndex): Promise<LoadedSearchIndex>;
67
- declare function loadSearchIndex(artifactPath: string): Promise<LoadedSearchIndex>;
59
+ declare function loadSearchIndex(databasePath: string): Promise<LoadedSearchIndex>;
68
60
 
69
61
  declare function querySearchIndex(loaded: LoadedSearchIndex, query: string, options?: SearchQueryOptions): SearchResult[];
70
62
 
71
- export { DEFAULT_SEARCH_CONFIG, type LoadedSearchIndex, type SearchBenchmarkOptions, type SearchBenchmarkResult, type SearchIndexConfig, type SearchQueryOptions, type SearchRecord, type SearchResult, type SerializedSearchIndex, benchmarkSearchIndex, buildSearchIndex, createSearchDocument, hydrateSearchIndex, loadSearchIndex, makeExcerpt, normalizeSearchText, querySearchIndex };
63
+ export { type LoadedSearchIndex, SEARCH_DATABASE_FILENAME, type SearchBenchmarkOptions, type SearchBenchmarkResult, type SearchDatabaseMetadata, type SearchQueryOptions, type SearchRecord, type SearchResult, benchmarkSearchIndex, buildSearchDatabase, buildSearchTables, loadSearchIndex, makeExcerpt, normalizeSearchText, querySearchIndex };
package/dist/index.js CHANGED
@@ -2,10 +2,28 @@
2
2
  import { performance } from "perf_hooks";
3
3
 
4
4
  // src/load.ts
5
- import { readFile } from "fs/promises";
6
-
7
- // src/build.ts
8
- import { Document } from "flexsearch";
5
+ import Database from "better-sqlite3";
6
+ var globalCache = globalThis;
7
+ async function loadSearchIndex(databasePath) {
8
+ const cache = globalCache.__silicaSearchIndexes ??= /* @__PURE__ */ new Map();
9
+ const cached = cache.get(databasePath);
10
+ if (cached) return cached;
11
+ const db = new Database(databasePath, {
12
+ fileMustExist: true,
13
+ readonly: true
14
+ });
15
+ db.pragma("query_only = ON");
16
+ const loaded = {
17
+ databasePath,
18
+ db,
19
+ close: () => {
20
+ db.close();
21
+ cache.delete(databasePath);
22
+ }
23
+ };
24
+ cache.set(databasePath, loaded);
25
+ return loaded;
26
+ }
9
27
 
10
28
  // src/excerpt.ts
11
29
  var WORD_BOUNDARY = /\s+/g;
@@ -25,125 +43,128 @@ function makeExcerpt(content, query, maxLength = 180) {
25
43
  return `${start > 0 ? "\u2026" : ""}${excerpt}${end < normalized.length ? "\u2026" : ""}`;
26
44
  }
27
45
 
28
- // src/build.ts
29
- var DEFAULT_SEARCH_CONFIG = {
30
- tokenize: "forward",
31
- document: {
32
- id: "id",
33
- index: ["title", "content", "tags"],
34
- store: ["slug", "title", "description", "tags"]
35
- }
36
- };
37
- function createSearchDocument(config = DEFAULT_SEARCH_CONFIG) {
38
- return new Document(
39
- config
40
- );
41
- }
42
- async function buildSearchIndex(records) {
43
- const index = createSearchDocument();
44
- for (const record of records) {
45
- index.add({
46
- ...record,
47
- tags: record.tags.join(" ")
48
- });
49
- }
50
- const exported = {};
51
- await index.export((key, data) => {
52
- if (typeof data === "string") exported[key] = data;
53
- });
54
- return {
55
- version: 1,
56
- config: DEFAULT_SEARCH_CONFIG,
57
- records: records.map(({ content, ...record }) => ({
58
- ...record,
59
- excerpt: makeExcerpt(content, record.description ?? record.title)
60
- })),
61
- exported,
62
- builtAt: (/* @__PURE__ */ new Date()).toISOString()
63
- };
64
- }
65
-
66
- // src/load.ts
67
- var globalCache = globalThis;
68
- async function hydrateSearchIndex(artifact) {
69
- const document = createSearchDocument(artifact.config);
70
- for (const [key, data] of Object.entries(artifact.exported)) {
71
- document.import(key, data);
72
- }
73
- return {
74
- document,
75
- artifact,
76
- recordsById: new Map(artifact.records.map((record) => [record.id, record]))
77
- };
78
- }
79
- async function loadSearchIndex(artifactPath) {
80
- const cache = globalCache.__silicaSearchIndexes ??= /* @__PURE__ */ new Map();
81
- const cached = cache.get(artifactPath);
82
- if (cached) return cached;
83
- const raw = await readFile(artifactPath, "utf8");
84
- const artifact = JSON.parse(raw);
85
- const loaded = await hydrateSearchIndex(artifact);
86
- cache.set(artifactPath, loaded);
87
- return loaded;
88
- }
89
-
90
46
  // src/query.ts
47
+ var HIGHLIGHT_START = "\uE000";
48
+ var HIGHLIGHT_END = "\uE001";
91
49
  function querySearchIndex(loaded, query, options = {}) {
92
50
  const normalized = normalizeSearchText(query);
93
51
  const limit = options.limit ?? 10;
94
- const tagFilter = (options.tags ?? []).map(normalizeTag).filter(Boolean);
52
+ const tagFilter = [
53
+ ...new Set((options.tags ?? []).map(normalizeTag).filter(Boolean))
54
+ ];
95
55
  if (!normalized && tagFilter.length === 0) return [];
96
- if (!normalized) {
97
- return [...loaded.recordsById.values()].filter((record) => matchesTagFilter(record, tagFilter)).map((record) => toResult(record, 1)).sort((a, b) => a.title.localeCompare(b.title)).slice(0, limit);
56
+ if (!normalized) return queryByTags(loaded, tagFilter, limit);
57
+ const ftsQuery = toFtsQuery(normalized);
58
+ if (!ftsQuery) {
59
+ return tagFilter.length > 0 ? queryByTags(loaded, tagFilter, limit) : [];
98
60
  }
99
- const rawResults = loaded.document.search(normalized, {
100
- limit: Math.max(limit * 4, 20),
101
- enrich: false
102
- });
103
- const scoreById = /* @__PURE__ */ new Map();
104
- for (const fieldResult of rawResults) {
105
- const fieldWeight = fieldResult.field === "title" ? 5 : fieldResult.field === "tags" ? 3 : 1;
106
- for (const id of fieldResult.result) {
107
- const key = String(id);
108
- scoreById.set(key, (scoreById.get(key) ?? 0) + fieldWeight);
109
- }
110
- }
111
- return [...scoreById.entries()].map(([id, score]) => {
112
- const record = loaded.recordsById.get(id);
113
- if (!record) return void 0;
114
- if (!matchesTagFilter(record, tagFilter)) return void 0;
115
- return toResult(record, score);
116
- }).filter((result) => Boolean(result)).sort((a, b) => b.score - a.score || a.title.localeCompare(b.title)).slice(0, limit);
61
+ const tagClause = makeTagClause(tagFilter);
62
+ const rows = loaded.db.prepare(
63
+ `
64
+ SELECT
65
+ n.slug,
66
+ n.title,
67
+ highlight(search_index, 0, char(57344), char(57345)) AS highlighted_title,
68
+ n.description,
69
+ n.tags_json,
70
+ snippet(search_index, -1, char(57344), char(57345), '\u2026', 24) AS highlighted_excerpt,
71
+ bm25(search_index, 8.0, 1.0, 3.0) AS score
72
+ FROM search_index
73
+ JOIN notes n ON n.rowid = search_index.rowid
74
+ WHERE search_index MATCH ?
75
+ ${tagClause.sql}
76
+ ORDER BY score ASC, n.title COLLATE NOCASE ASC
77
+ LIMIT ?
78
+ `
79
+ ).all(ftsQuery, ...tagClause.params, limit);
80
+ return rows.map(toResult);
117
81
  }
118
82
  function normalizeTag(tag) {
119
83
  return tag.trim().replace(/^#/, "").toLowerCase();
120
84
  }
121
- function tagMatches(candidate, query) {
122
- const tag = normalizeTag(candidate);
123
- const normalizedQuery = normalizeTag(query);
124
- if (!tag || !normalizedQuery) return false;
125
- return tag === normalizedQuery || tag.startsWith(`${normalizedQuery}/`);
85
+ function queryByTags(loaded, tags, limit) {
86
+ const tagClause = makeTagClause(tags);
87
+ if (!tagClause.sql) return [];
88
+ const rows = loaded.db.prepare(
89
+ `
90
+ SELECT
91
+ n.slug,
92
+ n.title,
93
+ NULL AS highlighted_title,
94
+ n.description,
95
+ n.tags_json,
96
+ n.search_excerpt AS excerpt,
97
+ 0 AS score
98
+ FROM notes n
99
+ WHERE 1 = 1
100
+ ${tagClause.sql}
101
+ ORDER BY n.title COLLATE NOCASE ASC
102
+ LIMIT ?
103
+ `
104
+ ).all(...tagClause.params, limit);
105
+ return rows.map(toResult);
126
106
  }
127
- function matchesTagFilter(record, tagFilter) {
128
- return tagFilter.length === 0 || record.tags.some(
129
- (tag) => tagFilter.some((filterTag) => tagMatches(tag, filterTag))
130
- );
107
+ function makeTagClause(tags) {
108
+ if (tags.length === 0) return { sql: "", params: [] };
109
+ return {
110
+ sql: `
111
+ AND EXISTS (
112
+ SELECT 1
113
+ FROM note_tags nt
114
+ WHERE nt.slug = n.slug
115
+ AND nt.tag IN (${tags.map(() => "?").join(", ")})
116
+ )
117
+ `,
118
+ params: tags
119
+ };
131
120
  }
132
- function toResult(record, score) {
121
+ function toResult(row) {
122
+ const excerpt = "highlighted_excerpt" in row ? row.highlighted_excerpt ?? "" : row.excerpt;
133
123
  return {
134
- slug: record.slug,
135
- title: record.title,
136
- description: record.description,
137
- tags: record.tags,
138
- excerpt: record.excerpt,
139
- score
124
+ slug: row.slug,
125
+ title: row.title,
126
+ titleParts: toHighlightParts(row.highlighted_title ?? row.title),
127
+ description: row.description ?? void 0,
128
+ tags: parseTags(row.tags_json),
129
+ excerptParts: toHighlightParts(excerpt),
130
+ score: -row.score
140
131
  };
141
132
  }
133
+ function parseTags(value) {
134
+ return JSON.parse(value);
135
+ }
136
+ function toFtsQuery(query) {
137
+ const terms = query.match(/[\p{L}\p{N}_]+/gu) ?? [];
138
+ const normalizedTerms = terms.map((term) => term.toLocaleLowerCase()).filter(Boolean);
139
+ if (normalizedTerms.length === 0) return;
140
+ return normalizedTerms.map((term) => term.length >= 3 ? `${term}*` : term).join(" ");
141
+ }
142
+ function toHighlightParts(value) {
143
+ const normalized = normalizeSearchText(value);
144
+ if (!normalized) return [];
145
+ const parts = [];
146
+ let highlighted = false;
147
+ for (const part of normalized.split(
148
+ new RegExp(`(${HIGHLIGHT_START}|${HIGHLIGHT_END})`)
149
+ )) {
150
+ if (!part) continue;
151
+ if (part === HIGHLIGHT_START) {
152
+ highlighted = true;
153
+ continue;
154
+ }
155
+ if (part === HIGHLIGHT_END) {
156
+ highlighted = false;
157
+ continue;
158
+ }
159
+ parts.push({ text: part, highlighted });
160
+ }
161
+ return parts;
162
+ }
142
163
 
143
164
  // src/benchmark.ts
144
- async function benchmarkSearchIndex(artifact, { query, warmRuns = 10, ...queryOptions }) {
165
+ async function benchmarkSearchIndex(databasePath, { query, warmRuns = 10, ...queryOptions }) {
145
166
  const coldStart = performance.now();
146
- const loaded = await hydrateSearchIndex(artifact);
167
+ const loaded = await loadSearchIndex(databasePath);
147
168
  const coldResults = querySearchIndex(loaded, query, queryOptions);
148
169
  const coldMs = performance.now() - coldStart;
149
170
  const runs = Math.max(1, warmRuns);
@@ -152,22 +173,206 @@ async function benchmarkSearchIndex(artifact, { query, warmRuns = 10, ...queryOp
152
173
  querySearchIndex(loaded, query, queryOptions);
153
174
  }
154
175
  const warmMs = (performance.now() - warmStart) / runs;
155
- return {
156
- coldMs: round(coldMs),
157
- warmMs: round(warmMs),
158
- warmRuns: runs,
159
- resultCount: coldResults.length
160
- };
176
+ try {
177
+ return {
178
+ coldMs: round(coldMs),
179
+ warmMs: round(warmMs),
180
+ warmRuns: runs,
181
+ resultCount: coldResults.length
182
+ };
183
+ } finally {
184
+ loaded.close();
185
+ }
161
186
  }
162
187
  function round(value) {
163
188
  return Math.round(value * 100) / 100;
164
189
  }
190
+
191
+ // src/build.ts
192
+ import fs from "fs/promises";
193
+ import path from "path";
194
+ import Database2 from "better-sqlite3";
195
+ var SEARCH_DATABASE_FILENAME = "search.db";
196
+ async function buildSearchDatabase(records, databasePath) {
197
+ await fs.mkdir(path.dirname(databasePath), { recursive: true });
198
+ await removeDatabaseFiles(databasePath);
199
+ const db = new Database2(databasePath);
200
+ try {
201
+ db.pragma("journal_mode = DELETE");
202
+ db.pragma("synchronous = OFF");
203
+ createStandaloneNotesSchema(db);
204
+ const builtAt = buildSearchTables(db, records);
205
+ db.exec("VACUUM");
206
+ return {
207
+ version: 1,
208
+ databasePath,
209
+ recordCount: records.length,
210
+ builtAt
211
+ };
212
+ } finally {
213
+ db.close();
214
+ }
215
+ }
216
+ function buildSearchTables(db, records) {
217
+ db.exec(`
218
+ DROP TABLE IF EXISTS search_index;
219
+ CREATE VIRTUAL TABLE search_index USING fts5(
220
+ title,
221
+ content,
222
+ tags,
223
+ tokenize='porter unicode61',
224
+ prefix='3'
225
+ );
226
+ `);
227
+ const builtAt = (/* @__PURE__ */ new Date()).toISOString();
228
+ const upsertMetadata = db.prepare(`
229
+ INSERT INTO vault_metadata (key, value) VALUES (?, ?)
230
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
231
+ `);
232
+ const insertNote = db.prepare(`
233
+ INSERT INTO notes (
234
+ slug,
235
+ file,
236
+ relative_file,
237
+ title,
238
+ menu_label,
239
+ description,
240
+ generated_description,
241
+ frontmatter_json,
242
+ tags_json,
243
+ search_excerpt,
244
+ listed,
245
+ content_hash,
246
+ render_hash,
247
+ prerender
248
+ )
249
+ VALUES (
250
+ @slug,
251
+ @file,
252
+ @relativeFile,
253
+ @title,
254
+ @menuLabel,
255
+ @description,
256
+ NULL,
257
+ '{}',
258
+ @tagsJson,
259
+ @excerpt,
260
+ 1,
261
+ @contentHash,
262
+ @renderHash,
263
+ 1
264
+ )
265
+ ON CONFLICT(slug) DO UPDATE SET
266
+ title = excluded.title,
267
+ menu_label = excluded.menu_label,
268
+ description = excluded.description,
269
+ tags_json = excluded.tags_json,
270
+ search_excerpt = excluded.search_excerpt
271
+ `);
272
+ const selectNoteRowid = db.prepare(
273
+ "SELECT rowid AS rowid FROM notes WHERE slug = ?"
274
+ );
275
+ const insertSearch = db.prepare(`
276
+ INSERT INTO search_index (rowid, title, content, tags)
277
+ VALUES (?, ?, ?, ?)
278
+ `);
279
+ const insertTag = db.prepare(`
280
+ INSERT OR IGNORE INTO note_tags (slug, tag)
281
+ VALUES (?, ?)
282
+ `);
283
+ const insertRecords = db.transaction((items) => {
284
+ for (const record of items) {
285
+ const tags = record.tags.map(normalizeTag2).filter(Boolean);
286
+ const excerpt = makeExcerpt(
287
+ record.content,
288
+ record.description ?? record.title
289
+ );
290
+ insertNote.run({
291
+ slug: record.slug,
292
+ file: "",
293
+ relativeFile: "",
294
+ title: record.title,
295
+ menuLabel: record.title,
296
+ description: record.description,
297
+ tagsJson: JSON.stringify(record.tags),
298
+ excerpt,
299
+ contentHash: record.id,
300
+ renderHash: record.id
301
+ });
302
+ const row = selectNoteRowid.get(record.slug);
303
+ insertSearch.run(row.rowid, record.title, record.content, tags.join(" "));
304
+ for (const tag of tags) {
305
+ for (const hierarchyTag of tagHierarchy(tag)) {
306
+ insertTag.run(record.slug, hierarchyTag);
307
+ }
308
+ }
309
+ }
310
+ upsertMetadata.run("searchVersion", "1");
311
+ upsertMetadata.run("searchBuiltAt", builtAt);
312
+ upsertMetadata.run("searchRecordCount", String(items.length));
313
+ });
314
+ insertRecords(records);
315
+ db.prepare("INSERT INTO search_index(search_index) VALUES(?)").run(
316
+ "optimize"
317
+ );
318
+ return builtAt;
319
+ }
320
+ async function removeDatabaseFiles(databasePath) {
321
+ await Promise.all(
322
+ ["", "-journal", "-shm", "-wal"].map(
323
+ (suffix) => fs.rm(`${databasePath}${suffix}`, { force: true })
324
+ )
325
+ );
326
+ }
327
+ function normalizeTag2(tag) {
328
+ return tag.trim().replace(/^#/, "").toLowerCase();
329
+ }
330
+ function tagHierarchy(tag) {
331
+ const segments = tag.split("/").filter(Boolean);
332
+ return segments.map((_, index) => segments.slice(0, index + 1).join("/"));
333
+ }
334
+ function createStandaloneNotesSchema(db) {
335
+ db.exec(`
336
+ CREATE TABLE vault_metadata (
337
+ key TEXT PRIMARY KEY,
338
+ value TEXT NOT NULL
339
+ );
340
+
341
+ CREATE TABLE notes (
342
+ slug TEXT PRIMARY KEY,
343
+ file TEXT NOT NULL,
344
+ relative_file TEXT NOT NULL,
345
+ title TEXT NOT NULL,
346
+ menu_label TEXT NOT NULL,
347
+ description TEXT,
348
+ generated_description TEXT,
349
+ frontmatter_json TEXT NOT NULL,
350
+ tags_json TEXT NOT NULL,
351
+ search_excerpt TEXT NOT NULL DEFAULT '',
352
+ created TEXT,
353
+ modified TEXT,
354
+ sort_key TEXT,
355
+ listed INTEGER NOT NULL,
356
+ content_hash TEXT NOT NULL,
357
+ render_hash TEXT NOT NULL,
358
+ prerender INTEGER NOT NULL
359
+ );
360
+
361
+ CREATE TABLE note_tags (
362
+ slug TEXT NOT NULL,
363
+ tag TEXT NOT NULL,
364
+ PRIMARY KEY (slug, tag),
365
+ FOREIGN KEY (slug) REFERENCES notes(slug) ON DELETE CASCADE
366
+ );
367
+
368
+ CREATE INDEX note_tags_tag_idx ON note_tags(tag, slug);
369
+ `);
370
+ }
165
371
  export {
166
- DEFAULT_SEARCH_CONFIG,
372
+ SEARCH_DATABASE_FILENAME,
167
373
  benchmarkSearchIndex,
168
- buildSearchIndex,
169
- createSearchDocument,
170
- hydrateSearchIndex,
374
+ buildSearchDatabase,
375
+ buildSearchTables,
171
376
  loadSearchIndex,
172
377
  makeExcerpt,
173
378
  normalizeSearchText,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/benchmark.ts","../src/load.ts","../src/build.ts","../src/excerpt.ts","../src/query.ts"],"sourcesContent":["import { performance } from \"node:perf_hooks\";\nimport { hydrateSearchIndex } from \"./load.js\";\nimport { querySearchIndex } from \"./query.js\";\nimport type { SearchQueryOptions, SerializedSearchIndex } from \"./types.js\";\n\nexport type SearchBenchmarkOptions = SearchQueryOptions & {\n query: string;\n warmRuns?: number;\n};\n\nexport type SearchBenchmarkResult = {\n coldMs: number;\n warmMs: number;\n warmRuns: number;\n resultCount: number;\n};\n\nexport async function benchmarkSearchIndex(\n artifact: SerializedSearchIndex,\n { query, warmRuns = 10, ...queryOptions }: SearchBenchmarkOptions,\n): Promise<SearchBenchmarkResult> {\n const coldStart = performance.now();\n const loaded = await hydrateSearchIndex(artifact);\n const coldResults = querySearchIndex(loaded, query, queryOptions);\n const coldMs = performance.now() - coldStart;\n\n const runs = Math.max(1, warmRuns);\n const warmStart = performance.now();\n for (let index = 0; index < runs; index += 1) {\n querySearchIndex(loaded, query, queryOptions);\n }\n const warmMs = (performance.now() - warmStart) / runs;\n\n return {\n coldMs: round(coldMs),\n warmMs: round(warmMs),\n warmRuns: runs,\n resultCount: coldResults.length,\n };\n}\n\nfunction round(value: number): number {\n return Math.round(value * 100) / 100;\n}\n","import { readFile } from \"node:fs/promises\";\nimport { createSearchDocument } from \"./build.js\";\nimport type { SerializedSearchIndex, StoredSearchRecord } from \"./types.js\";\n\nexport type LoadedSearchIndex = {\n document: ReturnType<typeof createSearchDocument>;\n artifact: SerializedSearchIndex;\n recordsById: Map<string, StoredSearchRecord>;\n};\n\nconst globalCache = globalThis as typeof globalThis & {\n __silicaSearchIndexes?: Map<string, LoadedSearchIndex>;\n};\n\nexport async function hydrateSearchIndex(\n artifact: SerializedSearchIndex,\n): Promise<LoadedSearchIndex> {\n const document = createSearchDocument(artifact.config);\n\n for (const [key, data] of Object.entries(artifact.exported)) {\n document.import(key, data);\n }\n\n return {\n document,\n artifact,\n recordsById: new Map(artifact.records.map((record) => [record.id, record])),\n };\n}\n\nexport async function loadSearchIndex(\n artifactPath: string,\n): Promise<LoadedSearchIndex> {\n const cache = (globalCache.__silicaSearchIndexes ??= new Map());\n const cached = cache.get(artifactPath);\n if (cached) return cached;\n\n const raw = await readFile(artifactPath, \"utf8\");\n const artifact = JSON.parse(raw) as SerializedSearchIndex;\n const loaded = await hydrateSearchIndex(artifact);\n cache.set(artifactPath, loaded);\n return loaded;\n}\n","import { Document } from \"flexsearch\";\nimport type {\n SearchIndexConfig,\n SearchRecord,\n SerializedSearchIndex,\n} from \"./types.js\";\nimport { makeExcerpt } from \"./excerpt.js\";\n\nexport const DEFAULT_SEARCH_CONFIG: SearchIndexConfig = {\n tokenize: \"forward\",\n document: {\n id: \"id\",\n index: [\"title\", \"content\", \"tags\"],\n store: [\"slug\", \"title\", \"description\", \"tags\"],\n },\n};\n\nexport function createSearchDocument(\n config: SearchIndexConfig = DEFAULT_SEARCH_CONFIG,\n): Document {\n return new Document(\n config as unknown as ConstructorParameters<typeof Document>[0],\n );\n}\n\nexport async function buildSearchIndex(\n records: SearchRecord[],\n): Promise<SerializedSearchIndex> {\n const index = createSearchDocument();\n\n for (const record of records) {\n index.add({\n ...record,\n tags: record.tags.join(\" \"),\n });\n }\n\n const exported: Record<string, string> = {};\n await index.export((key, data) => {\n if (typeof data === \"string\") exported[key] = data;\n });\n\n return {\n version: 1,\n config: DEFAULT_SEARCH_CONFIG,\n records: records.map(({ content, ...record }) => ({\n ...record,\n excerpt: makeExcerpt(content, record.description ?? record.title),\n })),\n exported,\n builtAt: new Date().toISOString(),\n };\n}\n","const WORD_BOUNDARY = /\\s+/g;\n\nexport function normalizeSearchText(value: string): string {\n return value.replace(/\\s+/g, \" \").trim();\n}\n\nexport function makeExcerpt(content: string, query: string, maxLength = 180): string {\n const normalized = normalizeSearchText(content);\n if (normalized.length <= maxLength) return normalized;\n\n const firstTerm = query\n .toLowerCase()\n .split(WORD_BOUNDARY)\n .find((term) => term.length > 1);\n\n const matchIndex = firstTerm ? normalized.toLowerCase().indexOf(firstTerm) : -1;\n const center = matchIndex >= 0 ? matchIndex : 0;\n const half = Math.floor(maxLength / 2);\n const start = Math.max(0, center - half);\n const end = Math.min(normalized.length, start + maxLength);\n const excerpt = normalized.slice(start, end).trim();\n\n return `${start > 0 ? \"…\" : \"\"}${excerpt}${end < normalized.length ? \"…\" : \"\"}`;\n}\n","import { normalizeSearchText } from \"./excerpt.js\";\nimport type { LoadedSearchIndex } from \"./load.js\";\nimport type {\n SearchQueryOptions,\n SearchResult,\n StoredSearchRecord,\n} from \"./types.js\";\n\ntype FlexDocumentResult = {\n field?: string;\n result: Array<string | number>;\n};\n\nexport function querySearchIndex(\n loaded: LoadedSearchIndex,\n query: string,\n options: SearchQueryOptions = {},\n): SearchResult[] {\n const normalized = normalizeSearchText(query);\n const limit = options.limit ?? 10;\n const tagFilter = (options.tags ?? []).map(normalizeTag).filter(Boolean);\n if (!normalized && tagFilter.length === 0) return [];\n\n if (!normalized) {\n return [...loaded.recordsById.values()]\n .filter((record) => matchesTagFilter(record, tagFilter))\n .map((record) => toResult(record, 1))\n .sort((a, b) => a.title.localeCompare(b.title))\n .slice(0, limit);\n }\n\n const rawResults = loaded.document.search(normalized, {\n limit: Math.max(limit * 4, 20),\n enrich: false,\n }) as FlexDocumentResult[];\n\n const scoreById = new Map<string, number>();\n for (const fieldResult of rawResults) {\n const fieldWeight =\n fieldResult.field === \"title\" ? 5 : fieldResult.field === \"tags\" ? 3 : 1;\n for (const id of fieldResult.result) {\n const key = String(id);\n scoreById.set(key, (scoreById.get(key) ?? 0) + fieldWeight);\n }\n }\n\n return [...scoreById.entries()]\n .map(([id, score]) => {\n const record = loaded.recordsById.get(id);\n if (!record) return undefined;\n if (!matchesTagFilter(record, tagFilter)) return undefined;\n return toResult(record, score);\n })\n .filter((result): result is SearchResult => Boolean(result))\n .sort((a, b) => b.score - a.score || a.title.localeCompare(b.title))\n .slice(0, limit);\n}\n\nfunction normalizeTag(tag: string): string {\n return tag.trim().replace(/^#/, \"\").toLowerCase();\n}\n\nfunction tagMatches(candidate: string, query: string): boolean {\n const tag = normalizeTag(candidate);\n const normalizedQuery = normalizeTag(query);\n if (!tag || !normalizedQuery) return false;\n return tag === normalizedQuery || tag.startsWith(`${normalizedQuery}/`);\n}\n\nfunction matchesTagFilter(\n record: StoredSearchRecord,\n tagFilter: string[],\n): boolean {\n return (\n tagFilter.length === 0 ||\n record.tags.some((tag) =>\n tagFilter.some((filterTag) => tagMatches(tag, filterTag)),\n )\n );\n}\n\nfunction toResult(record: StoredSearchRecord, score: number): SearchResult {\n return {\n slug: record.slug,\n title: record.title,\n description: record.description,\n tags: record.tags,\n excerpt: record.excerpt,\n score,\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/benchmark.ts","../src/load.ts","../src/excerpt.ts","../src/query.ts","../src/build.ts"],"sourcesContent":["import { performance } from \"node:perf_hooks\";\nimport { loadSearchIndex } from \"./load.js\";\nimport { querySearchIndex } from \"./query.js\";\nimport type { SearchQueryOptions } from \"./types.js\";\n\nexport type SearchBenchmarkOptions = SearchQueryOptions & {\n query: string;\n warmRuns?: number;\n};\n\nexport type SearchBenchmarkResult = {\n coldMs: number;\n warmMs: number;\n warmRuns: number;\n resultCount: number;\n};\n\nexport async function benchmarkSearchIndex(\n databasePath: string,\n { query, warmRuns = 10, ...queryOptions }: SearchBenchmarkOptions,\n): Promise<SearchBenchmarkResult> {\n const coldStart = performance.now();\n const loaded = await loadSearchIndex(databasePath);\n const coldResults = querySearchIndex(loaded, query, queryOptions);\n const coldMs = performance.now() - coldStart;\n\n const runs = Math.max(1, warmRuns);\n const warmStart = performance.now();\n for (let index = 0; index < runs; index += 1) {\n querySearchIndex(loaded, query, queryOptions);\n }\n const warmMs = (performance.now() - warmStart) / runs;\n\n try {\n return {\n coldMs: round(coldMs),\n warmMs: round(warmMs),\n warmRuns: runs,\n resultCount: coldResults.length,\n };\n } finally {\n loaded.close();\n }\n}\n\nfunction round(value: number): number {\n return Math.round(value * 100) / 100;\n}\n","import Database from \"better-sqlite3\";\n\nexport type LoadedSearchIndex = {\n databasePath: string;\n db: Database.Database;\n close: () => void;\n};\n\nconst globalCache = globalThis as typeof globalThis & {\n __silicaSearchIndexes?: Map<string, LoadedSearchIndex>;\n};\n\nexport async function loadSearchIndex(\n databasePath: string,\n): Promise<LoadedSearchIndex> {\n const cache = (globalCache.__silicaSearchIndexes ??= new Map());\n const cached = cache.get(databasePath);\n if (cached) return cached;\n\n const db = new Database(databasePath, {\n fileMustExist: true,\n readonly: true,\n });\n db.pragma(\"query_only = ON\");\n\n const loaded: LoadedSearchIndex = {\n databasePath,\n db,\n close: () => {\n db.close();\n cache.delete(databasePath);\n },\n };\n cache.set(databasePath, loaded);\n return loaded;\n}\n","const WORD_BOUNDARY = /\\s+/g;\n\nexport function normalizeSearchText(value: string): string {\n return value.replace(/\\s+/g, \" \").trim();\n}\n\nexport function makeExcerpt(\n content: string,\n query: string,\n maxLength = 180,\n): string {\n const normalized = normalizeSearchText(content);\n if (normalized.length <= maxLength) return normalized;\n\n const firstTerm = query\n .toLowerCase()\n .split(WORD_BOUNDARY)\n .find((term) => term.length > 1);\n\n const matchIndex = firstTerm\n ? normalized.toLowerCase().indexOf(firstTerm)\n : -1;\n const center = matchIndex >= 0 ? matchIndex : 0;\n const half = Math.floor(maxLength / 2);\n const start = Math.max(0, center - half);\n const end = Math.min(normalized.length, start + maxLength);\n const excerpt = normalized.slice(start, end).trim();\n\n return `${start > 0 ? \"…\" : \"\"}${excerpt}${end < normalized.length ? \"…\" : \"\"}`;\n}\n","import { normalizeSearchText } from \"./excerpt.js\";\nimport type { LoadedSearchIndex } from \"./load.js\";\nimport type {\n SearchHighlightPart,\n SearchQueryOptions,\n SearchResult,\n} from \"./types.js\";\n\nconst HIGHLIGHT_START = \"\\uE000\";\nconst HIGHLIGHT_END = \"\\uE001\";\n\ntype SearchRow = {\n slug: string;\n title: string;\n highlighted_title: string | null;\n description: string | null;\n tags_json: string;\n highlighted_excerpt: string | null;\n score: number;\n};\n\ntype TagOnlyRow = Omit<\n SearchRow,\n \"highlighted_title\" | \"highlighted_excerpt\" | \"score\"\n> & {\n highlighted_title: null;\n excerpt: string;\n score: 0;\n};\n\nexport function querySearchIndex(\n loaded: LoadedSearchIndex,\n query: string,\n options: SearchQueryOptions = {},\n): SearchResult[] {\n const normalized = normalizeSearchText(query);\n const limit = options.limit ?? 10;\n const tagFilter = [\n ...new Set((options.tags ?? []).map(normalizeTag).filter(Boolean)),\n ];\n if (!normalized && tagFilter.length === 0) return [];\n\n if (!normalized) return queryByTags(loaded, tagFilter, limit);\n\n const ftsQuery = toFtsQuery(normalized);\n if (!ftsQuery) {\n return tagFilter.length > 0 ? queryByTags(loaded, tagFilter, limit) : [];\n }\n\n const tagClause = makeTagClause(tagFilter);\n const rows = loaded.db\n .prepare(\n `\n SELECT\n n.slug,\n n.title,\n highlight(search_index, 0, char(57344), char(57345)) AS highlighted_title,\n n.description,\n n.tags_json,\n snippet(search_index, -1, char(57344), char(57345), '…', 24) AS highlighted_excerpt,\n bm25(search_index, 8.0, 1.0, 3.0) AS score\n FROM search_index\n JOIN notes n ON n.rowid = search_index.rowid\n WHERE search_index MATCH ?\n ${tagClause.sql}\n ORDER BY score ASC, n.title COLLATE NOCASE ASC\n LIMIT ?\n `,\n )\n .all(ftsQuery, ...tagClause.params, limit) as SearchRow[];\n\n return rows.map(toResult);\n}\n\nfunction normalizeTag(tag: string): string {\n return tag.trim().replace(/^#/, \"\").toLowerCase();\n}\n\nfunction queryByTags(\n loaded: LoadedSearchIndex,\n tags: string[],\n limit: number,\n): SearchResult[] {\n const tagClause = makeTagClause(tags);\n if (!tagClause.sql) return [];\n\n const rows = loaded.db\n .prepare(\n `\n SELECT\n n.slug,\n n.title,\n NULL AS highlighted_title,\n n.description,\n n.tags_json,\n n.search_excerpt AS excerpt,\n 0 AS score\n FROM notes n\n WHERE 1 = 1\n ${tagClause.sql}\n ORDER BY n.title COLLATE NOCASE ASC\n LIMIT ?\n `,\n )\n .all(...tagClause.params, limit) as TagOnlyRow[];\n\n return rows.map(toResult);\n}\n\nfunction makeTagClause(tags: string[]): { sql: string; params: string[] } {\n if (tags.length === 0) return { sql: \"\", params: [] };\n return {\n sql: `\n AND EXISTS (\n SELECT 1\n FROM note_tags nt\n WHERE nt.slug = n.slug\n AND nt.tag IN (${tags.map(() => \"?\").join(\", \")})\n )\n `,\n params: tags,\n };\n}\n\nfunction toResult(row: SearchRow | TagOnlyRow): SearchResult {\n const excerpt =\n \"highlighted_excerpt\" in row\n ? (row.highlighted_excerpt ?? \"\")\n : row.excerpt;\n return {\n slug: row.slug,\n title: row.title,\n titleParts: toHighlightParts(row.highlighted_title ?? row.title),\n description: row.description ?? undefined,\n tags: parseTags(row.tags_json),\n excerptParts: toHighlightParts(excerpt),\n score: -row.score,\n };\n}\n\nfunction parseTags(value: string): string[] {\n return JSON.parse(value) as string[];\n}\n\nfunction toFtsQuery(query: string): string | undefined {\n const terms = query.match(/[\\p{L}\\p{N}_]+/gu) ?? [];\n const normalizedTerms = terms\n .map((term) => term.toLocaleLowerCase())\n .filter(Boolean);\n if (normalizedTerms.length === 0) return;\n\n return normalizedTerms\n .map((term) => (term.length >= 3 ? `${term}*` : term))\n .join(\" \");\n}\n\nfunction toHighlightParts(value: string): SearchHighlightPart[] {\n const normalized = normalizeSearchText(value);\n if (!normalized) return [];\n\n const parts: SearchHighlightPart[] = [];\n let highlighted = false;\n for (const part of normalized.split(\n new RegExp(`(${HIGHLIGHT_START}|${HIGHLIGHT_END})`),\n )) {\n if (!part) continue;\n if (part === HIGHLIGHT_START) {\n highlighted = true;\n continue;\n }\n if (part === HIGHLIGHT_END) {\n highlighted = false;\n continue;\n }\n parts.push({ text: part, highlighted });\n }\n return parts;\n}\n","import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport Database from \"better-sqlite3\";\nimport { makeExcerpt } from \"./excerpt.js\";\nimport type { SearchDatabaseMetadata, SearchRecord } from \"./types.js\";\n\nexport const SEARCH_DATABASE_FILENAME = \"search.db\";\n\nexport async function buildSearchDatabase(\n records: SearchRecord[],\n databasePath: string,\n): Promise<SearchDatabaseMetadata> {\n await fs.mkdir(path.dirname(databasePath), { recursive: true });\n await removeDatabaseFiles(databasePath);\n\n const db = new Database(databasePath);\n try {\n db.pragma(\"journal_mode = DELETE\");\n db.pragma(\"synchronous = OFF\");\n createStandaloneNotesSchema(db);\n const builtAt = buildSearchTables(db, records);\n db.exec(\"VACUUM\");\n\n return {\n version: 1,\n databasePath,\n recordCount: records.length,\n builtAt,\n };\n } finally {\n db.close();\n }\n}\n\nexport function buildSearchTables(\n db: Database.Database,\n records: SearchRecord[],\n): string {\n db.exec(`\n DROP TABLE IF EXISTS search_index;\n CREATE VIRTUAL TABLE search_index USING fts5(\n title,\n content,\n tags,\n tokenize='porter unicode61',\n prefix='3'\n );\n `);\n\n const builtAt = new Date().toISOString();\n const upsertMetadata = db.prepare(`\n INSERT INTO vault_metadata (key, value) VALUES (?, ?)\n ON CONFLICT(key) DO UPDATE SET value = excluded.value\n `);\n const insertNote = db.prepare(`\n INSERT INTO notes (\n slug,\n file,\n relative_file,\n title,\n menu_label,\n description,\n generated_description,\n frontmatter_json,\n tags_json,\n search_excerpt,\n listed,\n content_hash,\n render_hash,\n prerender\n )\n VALUES (\n @slug,\n @file,\n @relativeFile,\n @title,\n @menuLabel,\n @description,\n NULL,\n '{}',\n @tagsJson,\n @excerpt,\n 1,\n @contentHash,\n @renderHash,\n 1\n )\n ON CONFLICT(slug) DO UPDATE SET\n title = excluded.title,\n menu_label = excluded.menu_label,\n description = excluded.description,\n tags_json = excluded.tags_json,\n search_excerpt = excluded.search_excerpt\n `);\n const selectNoteRowid = db.prepare(\n \"SELECT rowid AS rowid FROM notes WHERE slug = ?\",\n );\n const insertSearch = db.prepare(`\n INSERT INTO search_index (rowid, title, content, tags)\n VALUES (?, ?, ?, ?)\n `);\n const insertTag = db.prepare(`\n INSERT OR IGNORE INTO note_tags (slug, tag)\n VALUES (?, ?)\n `);\n\n const insertRecords = db.transaction((items: SearchRecord[]) => {\n for (const record of items) {\n const tags = record.tags.map(normalizeTag).filter(Boolean);\n const excerpt = makeExcerpt(\n record.content,\n record.description ?? record.title,\n );\n insertNote.run({\n slug: record.slug,\n file: \"\",\n relativeFile: \"\",\n title: record.title,\n menuLabel: record.title,\n description: record.description,\n tagsJson: JSON.stringify(record.tags),\n excerpt,\n contentHash: record.id,\n renderHash: record.id,\n });\n const row = selectNoteRowid.get(record.slug) as { rowid: number };\n insertSearch.run(row.rowid, record.title, record.content, tags.join(\" \"));\n for (const tag of tags) {\n for (const hierarchyTag of tagHierarchy(tag)) {\n insertTag.run(record.slug, hierarchyTag);\n }\n }\n }\n\n upsertMetadata.run(\"searchVersion\", \"1\");\n upsertMetadata.run(\"searchBuiltAt\", builtAt);\n upsertMetadata.run(\"searchRecordCount\", String(items.length));\n });\n insertRecords(records);\n\n db.prepare(\"INSERT INTO search_index(search_index) VALUES(?)\").run(\n \"optimize\",\n );\n return builtAt;\n}\n\nasync function removeDatabaseFiles(databasePath: string): Promise<void> {\n await Promise.all(\n [\"\", \"-journal\", \"-shm\", \"-wal\"].map((suffix) =>\n fs.rm(`${databasePath}${suffix}`, { force: true }),\n ),\n );\n}\n\nfunction normalizeTag(tag: string): string {\n return tag.trim().replace(/^#/, \"\").toLowerCase();\n}\n\nfunction tagHierarchy(tag: string): string[] {\n const segments = tag.split(\"/\").filter(Boolean);\n return segments.map((_, index) => segments.slice(0, index + 1).join(\"/\"));\n}\n\nfunction createStandaloneNotesSchema(db: Database.Database): void {\n db.exec(`\n CREATE TABLE vault_metadata (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n );\n\n CREATE TABLE notes (\n slug TEXT PRIMARY KEY,\n file TEXT NOT NULL,\n relative_file TEXT NOT NULL,\n title TEXT NOT NULL,\n menu_label TEXT NOT NULL,\n description TEXT,\n generated_description TEXT,\n frontmatter_json TEXT NOT NULL,\n tags_json TEXT NOT NULL,\n search_excerpt TEXT NOT NULL DEFAULT '',\n created TEXT,\n modified TEXT,\n sort_key TEXT,\n listed INTEGER NOT NULL,\n content_hash TEXT NOT NULL,\n render_hash TEXT NOT NULL,\n prerender INTEGER NOT NULL\n );\n\n CREATE TABLE note_tags (\n slug TEXT NOT NULL,\n tag TEXT NOT NULL,\n PRIMARY KEY (slug, tag),\n FOREIGN KEY (slug) REFERENCES notes(slug) ON DELETE CASCADE\n );\n\n CREATE INDEX note_tags_tag_idx ON note_tags(tag, slug);\n `);\n}\n"],"mappings":"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","names":["Database","Database","normalizeTag"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@silicajs/search",
3
- "version": "0.1.0",
4
- "description": "Server-side FlexSearch index builder and query helpers for Silica.",
3
+ "version": "0.3.0",
4
+ "description": "Server-side SQLite full-text search builder and query helpers for Silica.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "publishConfig": {
@@ -23,6 +23,18 @@
23
23
  "lint": "tsc --noEmit"
24
24
  },
25
25
  "dependencies": {
26
- "flexsearch": "^0.8.212"
26
+ "better-sqlite3": "^12.10.0"
27
+ },
28
+ "homepage": "https://github.com/agdevhq/silica/tree/main/packages/search#readme",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/agdevhq/silica.git",
32
+ "directory": "packages/search"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/agdevhq/silica/issues"
36
+ },
37
+ "devDependencies": {
38
+ "@types/better-sqlite3": "^7.6.13"
27
39
  }
28
40
  }