@silicajs/search 0.1.0 → 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
@@ -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 creates a `search.db` SQLite database with an FTS5 index over vault content. The generated Next.js `/api/search` route lazy-loads that database into a process-level singleton 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,21 @@ 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>;
57
49
 
58
50
  declare function normalizeSearchText(value: string): string;
59
51
  declare function makeExcerpt(content: string, query: string, maxLength?: number): string;
60
52
 
61
53
  type LoadedSearchIndex = {
62
- document: ReturnType<typeof createSearchDocument>;
63
- artifact: SerializedSearchIndex;
64
- recordsById: Map<string, StoredSearchRecord>;
54
+ databasePath: string;
55
+ db: Database.Database;
56
+ close: () => void;
65
57
  };
66
- declare function hydrateSearchIndex(artifact: SerializedSearchIndex): Promise<LoadedSearchIndex>;
67
- declare function loadSearchIndex(artifactPath: string): Promise<LoadedSearchIndex>;
58
+ declare function loadSearchIndex(databasePath: string): Promise<LoadedSearchIndex>;
68
59
 
69
60
  declare function querySearchIndex(loaded: LoadedSearchIndex, query: string, options?: SearchQueryOptions): SearchResult[];
70
61
 
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 };
62
+ export { type LoadedSearchIndex, SEARCH_DATABASE_FILENAME, type SearchBenchmarkOptions, type SearchBenchmarkResult, type SearchDatabaseMetadata, type SearchQueryOptions, type SearchRecord, type SearchResult, benchmarkSearchIndex, buildSearchDatabase, 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,129 @@ 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);
98
- }
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
- }
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) : [];
110
60
  }
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
+ d.slug,
66
+ d.title,
67
+ highlight(search_index, 0, char(57344), char(57345)) AS highlighted_title,
68
+ d.description,
69
+ d.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 documents d ON d.rowid = search_index.rowid
74
+ WHERE search_index MATCH ?
75
+ ${tagClause.sql}
76
+ ORDER BY score ASC, d.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
+ d.slug,
92
+ d.title,
93
+ NULL AS highlighted_title,
94
+ d.description,
95
+ d.tags_json,
96
+ d.excerpt,
97
+ 0 AS score
98
+ FROM documents d
99
+ WHERE 1 = 1
100
+ ${tagClause.sql}
101
+ ORDER BY d.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 document_tags dt
114
+ WHERE dt.document_rowid = d.rowid
115
+ AND dt.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
+ const parsed = JSON.parse(value);
135
+ return Array.isArray(parsed) ? parsed.filter((tag) => typeof tag === "string") : [];
136
+ }
137
+ function toFtsQuery(query) {
138
+ const terms = query.match(/[\p{L}\p{N}_]+/gu) ?? [];
139
+ const normalizedTerms = terms.map((term) => term.toLocaleLowerCase()).filter(Boolean);
140
+ if (normalizedTerms.length === 0) return;
141
+ return normalizedTerms.map((term) => term.length >= 3 ? `${term}*` : term).join(" ");
142
+ }
143
+ function toHighlightParts(value) {
144
+ const normalized = normalizeSearchText(value);
145
+ if (!normalized) return [];
146
+ const parts = [];
147
+ let highlighted = false;
148
+ for (const part of normalized.split(
149
+ new RegExp(`(${HIGHLIGHT_START}|${HIGHLIGHT_END})`)
150
+ )) {
151
+ if (!part) continue;
152
+ if (part === HIGHLIGHT_START) {
153
+ highlighted = true;
154
+ continue;
155
+ }
156
+ if (part === HIGHLIGHT_END) {
157
+ highlighted = false;
158
+ continue;
159
+ }
160
+ parts.push({ text: part, highlighted });
161
+ }
162
+ return parts;
163
+ }
142
164
 
143
165
  // src/benchmark.ts
144
- async function benchmarkSearchIndex(artifact, { query, warmRuns = 10, ...queryOptions }) {
166
+ async function benchmarkSearchIndex(databasePath, { query, warmRuns = 10, ...queryOptions }) {
145
167
  const coldStart = performance.now();
146
- const loaded = await hydrateSearchIndex(artifact);
168
+ const loaded = await loadSearchIndex(databasePath);
147
169
  const coldResults = querySearchIndex(loaded, query, queryOptions);
148
170
  const coldMs = performance.now() - coldStart;
149
171
  const runs = Math.max(1, warmRuns);
@@ -152,22 +174,141 @@ async function benchmarkSearchIndex(artifact, { query, warmRuns = 10, ...queryOp
152
174
  querySearchIndex(loaded, query, queryOptions);
153
175
  }
154
176
  const warmMs = (performance.now() - warmStart) / runs;
155
- return {
156
- coldMs: round(coldMs),
157
- warmMs: round(warmMs),
158
- warmRuns: runs,
159
- resultCount: coldResults.length
160
- };
177
+ try {
178
+ return {
179
+ coldMs: round(coldMs),
180
+ warmMs: round(warmMs),
181
+ warmRuns: runs,
182
+ resultCount: coldResults.length
183
+ };
184
+ } finally {
185
+ loaded.close();
186
+ }
161
187
  }
162
188
  function round(value) {
163
189
  return Math.round(value * 100) / 100;
164
190
  }
191
+
192
+ // src/build.ts
193
+ import fs from "fs/promises";
194
+ import path from "path";
195
+ import Database2 from "better-sqlite3";
196
+ var SEARCH_DATABASE_FILENAME = "search.db";
197
+ async function buildSearchDatabase(records, databasePath) {
198
+ await fs.mkdir(path.dirname(databasePath), { recursive: true });
199
+ await removeDatabaseFiles(databasePath);
200
+ const db = new Database2(databasePath);
201
+ try {
202
+ db.pragma("journal_mode = DELETE");
203
+ db.pragma("synchronous = OFF");
204
+ db.exec(`
205
+ CREATE TABLE metadata (
206
+ key TEXT PRIMARY KEY,
207
+ value TEXT NOT NULL
208
+ );
209
+
210
+ CREATE TABLE documents (
211
+ rowid INTEGER PRIMARY KEY,
212
+ id TEXT NOT NULL UNIQUE,
213
+ slug TEXT NOT NULL,
214
+ title TEXT NOT NULL,
215
+ description TEXT,
216
+ tags_json TEXT NOT NULL,
217
+ excerpt TEXT NOT NULL
218
+ );
219
+
220
+ CREATE TABLE document_tags (
221
+ document_rowid INTEGER NOT NULL,
222
+ tag TEXT NOT NULL,
223
+ PRIMARY KEY (document_rowid, tag),
224
+ FOREIGN KEY (document_rowid) REFERENCES documents(rowid) ON DELETE CASCADE
225
+ );
226
+
227
+ CREATE INDEX document_tags_tag_idx ON document_tags(tag);
228
+
229
+ CREATE VIRTUAL TABLE search_index USING fts5(
230
+ title,
231
+ content,
232
+ tags,
233
+ tokenize='porter unicode61',
234
+ prefix='3'
235
+ );
236
+ `);
237
+ const builtAt = (/* @__PURE__ */ new Date()).toISOString();
238
+ const insertMetadata = db.prepare(
239
+ "INSERT INTO metadata (key, value) VALUES (?, ?)"
240
+ );
241
+ const insertDocument = db.prepare(`
242
+ INSERT INTO documents (id, slug, title, description, tags_json, excerpt)
243
+ VALUES (@id, @slug, @title, @description, @tagsJson, @excerpt)
244
+ `);
245
+ const insertSearch = db.prepare(`
246
+ INSERT INTO search_index (rowid, title, content, tags)
247
+ VALUES (?, ?, ?, ?)
248
+ `);
249
+ const insertTag = db.prepare(`
250
+ INSERT OR IGNORE INTO document_tags (document_rowid, tag)
251
+ VALUES (?, ?)
252
+ `);
253
+ const insertRecords = db.transaction((items) => {
254
+ for (const record of items) {
255
+ const tags = record.tags.map(normalizeTag2).filter(Boolean);
256
+ const result = insertDocument.run({
257
+ id: record.id,
258
+ slug: record.slug,
259
+ title: record.title,
260
+ description: record.description,
261
+ tagsJson: JSON.stringify(record.tags),
262
+ excerpt: makeExcerpt(
263
+ record.content,
264
+ record.description ?? record.title
265
+ )
266
+ });
267
+ const rowid = Number(result.lastInsertRowid);
268
+ insertSearch.run(rowid, record.title, record.content, tags.join(" "));
269
+ for (const tag of tags) {
270
+ for (const hierarchyTag of tagHierarchy(tag)) {
271
+ insertTag.run(rowid, hierarchyTag);
272
+ }
273
+ }
274
+ }
275
+ insertMetadata.run("version", "1");
276
+ insertMetadata.run("builtAt", builtAt);
277
+ insertMetadata.run("recordCount", String(items.length));
278
+ });
279
+ insertRecords(records);
280
+ db.prepare("INSERT INTO search_index(search_index) VALUES(?)").run(
281
+ "optimize"
282
+ );
283
+ db.exec("VACUUM");
284
+ return {
285
+ version: 1,
286
+ databasePath,
287
+ recordCount: records.length,
288
+ builtAt
289
+ };
290
+ } finally {
291
+ db.close();
292
+ }
293
+ }
294
+ async function removeDatabaseFiles(databasePath) {
295
+ await Promise.all(
296
+ ["", "-journal", "-shm", "-wal"].map(
297
+ (suffix) => fs.rm(`${databasePath}${suffix}`, { force: true })
298
+ )
299
+ );
300
+ }
301
+ function normalizeTag2(tag) {
302
+ return tag.trim().replace(/^#/, "").toLowerCase();
303
+ }
304
+ function tagHierarchy(tag) {
305
+ const segments = tag.split("/").filter(Boolean);
306
+ return segments.map((_, index) => segments.slice(0, index + 1).join("/"));
307
+ }
165
308
  export {
166
- DEFAULT_SEARCH_CONFIG,
309
+ SEARCH_DATABASE_FILENAME,
167
310
  benchmarkSearchIndex,
168
- buildSearchIndex,
169
- createSearchDocument,
170
- hydrateSearchIndex,
311
+ buildSearchDatabase,
171
312
  loadSearchIndex,
172
313
  makeExcerpt,
173
314
  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":";AAAA,SAAS,mBAAmB;;;ACA5B,SAAS,gBAAgB;;;ACAzB,SAAS,gBAAgB;;;ACAzB,IAAM,gBAAgB;AAEf,SAAS,oBAAoB,OAAuB;AACzD,SAAO,MAAM,QAAQ,QAAQ,GAAG,EAAE,KAAK;AACzC;AAEO,SAAS,YAAY,SAAiB,OAAe,YAAY,KAAa;AACnF,QAAM,aAAa,oBAAoB,OAAO;AAC9C,MAAI,WAAW,UAAU,UAAW,QAAO;AAE3C,QAAM,YAAY,MACf,YAAY,EACZ,MAAM,aAAa,EACnB,KAAK,CAAC,SAAS,KAAK,SAAS,CAAC;AAEjC,QAAM,aAAa,YAAY,WAAW,YAAY,EAAE,QAAQ,SAAS,IAAI;AAC7E,QAAM,SAAS,cAAc,IAAI,aAAa;AAC9C,QAAM,OAAO,KAAK,MAAM,YAAY,CAAC;AACrC,QAAM,QAAQ,KAAK,IAAI,GAAG,SAAS,IAAI;AACvC,QAAM,MAAM,KAAK,IAAI,WAAW,QAAQ,QAAQ,SAAS;AACzD,QAAM,UAAU,WAAW,MAAM,OAAO,GAAG,EAAE,KAAK;AAElD,SAAO,GAAG,QAAQ,IAAI,WAAM,EAAE,GAAG,OAAO,GAAG,MAAM,WAAW,SAAS,WAAM,EAAE;AAC/E;;;ADfO,IAAM,wBAA2C;AAAA,EACtD,UAAU;AAAA,EACV,UAAU;AAAA,IACR,IAAI;AAAA,IACJ,OAAO,CAAC,SAAS,WAAW,MAAM;AAAA,IAClC,OAAO,CAAC,QAAQ,SAAS,eAAe,MAAM;AAAA,EAChD;AACF;AAEO,SAAS,qBACd,SAA4B,uBAClB;AACV,SAAO,IAAI;AAAA,IACT;AAAA,EACF;AACF;AAEA,eAAsB,iBACpB,SACgC;AAChC,QAAM,QAAQ,qBAAqB;AAEnC,aAAW,UAAU,SAAS;AAC5B,UAAM,IAAI;AAAA,MACR,GAAG;AAAA,MACH,MAAM,OAAO,KAAK,KAAK,GAAG;AAAA,IAC5B,CAAC;AAAA,EACH;AAEA,QAAM,WAAmC,CAAC;AAC1C,QAAM,MAAM,OAAO,CAAC,KAAK,SAAS;AAChC,QAAI,OAAO,SAAS,SAAU,UAAS,GAAG,IAAI;AAAA,EAChD,CAAC;AAED,SAAO;AAAA,IACL,SAAS;AAAA,IACT,QAAQ;AAAA,IACR,SAAS,QAAQ,IAAI,CAAC,EAAE,SAAS,GAAG,OAAO,OAAO;AAAA,MAChD,GAAG;AAAA,MACH,SAAS,YAAY,SAAS,OAAO,eAAe,OAAO,KAAK;AAAA,IAClE,EAAE;AAAA,IACF;AAAA,IACA,UAAS,oBAAI,KAAK,GAAE,YAAY;AAAA,EAClC;AACF;;;AD1CA,IAAM,cAAc;AAIpB,eAAsB,mBACpB,UAC4B;AAC5B,QAAM,WAAW,qBAAqB,SAAS,MAAM;AAErD,aAAW,CAAC,KAAK,IAAI,KAAK,OAAO,QAAQ,SAAS,QAAQ,GAAG;AAC3D,aAAS,OAAO,KAAK,IAAI;AAAA,EAC3B;AAEA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,aAAa,IAAI,IAAI,SAAS,QAAQ,IAAI,CAAC,WAAW,CAAC,OAAO,IAAI,MAAM,CAAC,CAAC;AAAA,EAC5E;AACF;AAEA,eAAsB,gBACpB,cAC4B;AAC5B,QAAM,QAAS,YAAY,0BAA0B,oBAAI,IAAI;AAC7D,QAAM,SAAS,MAAM,IAAI,YAAY;AACrC,MAAI,OAAQ,QAAO;AAEnB,QAAM,MAAM,MAAM,SAAS,cAAc,MAAM;AAC/C,QAAM,WAAW,KAAK,MAAM,GAAG;AAC/B,QAAM,SAAS,MAAM,mBAAmB,QAAQ;AAChD,QAAM,IAAI,cAAc,MAAM;AAC9B,SAAO;AACT;;;AG7BO,SAAS,iBACd,QACA,OACA,UAA8B,CAAC,GACf;AAChB,QAAM,aAAa,oBAAoB,KAAK;AAC5C,QAAM,QAAQ,QAAQ,SAAS;AAC/B,QAAM,aAAa,QAAQ,QAAQ,CAAC,GAAG,IAAI,YAAY,EAAE,OAAO,OAAO;AACvE,MAAI,CAAC,cAAc,UAAU,WAAW,EAAG,QAAO,CAAC;AAEnD,MAAI,CAAC,YAAY;AACf,WAAO,CAAC,GAAG,OAAO,YAAY,OAAO,CAAC,EACnC,OAAO,CAAC,WAAW,iBAAiB,QAAQ,SAAS,CAAC,EACtD,IAAI,CAAC,WAAW,SAAS,QAAQ,CAAC,CAAC,EACnC,KAAK,CAAC,GAAG,MAAM,EAAE,MAAM,cAAc,EAAE,KAAK,CAAC,EAC7C,MAAM,GAAG,KAAK;AAAA,EACnB;AAEA,QAAM,aAAa,OAAO,SAAS,OAAO,YAAY;AAAA,IACpD,OAAO,KAAK,IAAI,QAAQ,GAAG,EAAE;AAAA,IAC7B,QAAQ;AAAA,EACV,CAAC;AAED,QAAM,YAAY,oBAAI,IAAoB;AAC1C,aAAW,eAAe,YAAY;AACpC,UAAM,cACJ,YAAY,UAAU,UAAU,IAAI,YAAY,UAAU,SAAS,IAAI;AACzE,eAAW,MAAM,YAAY,QAAQ;AACnC,YAAM,MAAM,OAAO,EAAE;AACrB,gBAAU,IAAI,MAAM,UAAU,IAAI,GAAG,KAAK,KAAK,WAAW;AAAA,IAC5D;AAAA,EACF;AAEA,SAAO,CAAC,GAAG,UAAU,QAAQ,CAAC,EAC3B,IAAI,CAAC,CAAC,IAAI,KAAK,MAAM;AACpB,UAAM,SAAS,OAAO,YAAY,IAAI,EAAE;AACxC,QAAI,CAAC,OAAQ,QAAO;AACpB,QAAI,CAAC,iBAAiB,QAAQ,SAAS,EAAG,QAAO;AACjD,WAAO,SAAS,QAAQ,KAAK;AAAA,EAC/B,CAAC,EACA,OAAO,CAAC,WAAmC,QAAQ,MAAM,CAAC,EAC1D,KAAK,CAAC,GAAG,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,cAAc,EAAE,KAAK,CAAC,EAClE,MAAM,GAAG,KAAK;AACnB;AAEA,SAAS,aAAa,KAAqB;AACzC,SAAO,IAAI,KAAK,EAAE,QAAQ,MAAM,EAAE,EAAE,YAAY;AAClD;AAEA,SAAS,WAAW,WAAmB,OAAwB;AAC7D,QAAM,MAAM,aAAa,SAAS;AAClC,QAAM,kBAAkB,aAAa,KAAK;AAC1C,MAAI,CAAC,OAAO,CAAC,gBAAiB,QAAO;AACrC,SAAO,QAAQ,mBAAmB,IAAI,WAAW,GAAG,eAAe,GAAG;AACxE;AAEA,SAAS,iBACP,QACA,WACS;AACT,SACE,UAAU,WAAW,KACrB,OAAO,KAAK;AAAA,IAAK,CAAC,QAChB,UAAU,KAAK,CAAC,cAAc,WAAW,KAAK,SAAS,CAAC;AAAA,EAC1D;AAEJ;AAEA,SAAS,SAAS,QAA4B,OAA6B;AACzE,SAAO;AAAA,IACL,MAAM,OAAO;AAAA,IACb,OAAO,OAAO;AAAA,IACd,aAAa,OAAO;AAAA,IACpB,MAAM,OAAO;AAAA,IACb,SAAS,OAAO;AAAA,IAChB;AAAA,EACF;AACF;;;AJzEA,eAAsB,qBACpB,UACA,EAAE,OAAO,WAAW,IAAI,GAAG,aAAa,GACR;AAChC,QAAM,YAAY,YAAY,IAAI;AAClC,QAAM,SAAS,MAAM,mBAAmB,QAAQ;AAChD,QAAM,cAAc,iBAAiB,QAAQ,OAAO,YAAY;AAChE,QAAM,SAAS,YAAY,IAAI,IAAI;AAEnC,QAAM,OAAO,KAAK,IAAI,GAAG,QAAQ;AACjC,QAAM,YAAY,YAAY,IAAI;AAClC,WAAS,QAAQ,GAAG,QAAQ,MAAM,SAAS,GAAG;AAC5C,qBAAiB,QAAQ,OAAO,YAAY;AAAA,EAC9C;AACA,QAAM,UAAU,YAAY,IAAI,IAAI,aAAa;AAEjD,SAAO;AAAA,IACL,QAAQ,MAAM,MAAM;AAAA,IACpB,QAAQ,MAAM,MAAM;AAAA,IACpB,UAAU;AAAA,IACV,aAAa,YAAY;AAAA,EAC3B;AACF;AAEA,SAAS,MAAM,OAAuB;AACpC,SAAO,KAAK,MAAM,QAAQ,GAAG,IAAI;AACnC;","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 d.slug,\n d.title,\n highlight(search_index, 0, char(57344), char(57345)) AS highlighted_title,\n d.description,\n d.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 documents d ON d.rowid = search_index.rowid\n WHERE search_index MATCH ?\n ${tagClause.sql}\n ORDER BY score ASC, d.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 d.slug,\n d.title,\n NULL AS highlighted_title,\n d.description,\n d.tags_json,\n d.excerpt,\n 0 AS score\n FROM documents d\n WHERE 1 = 1\n ${tagClause.sql}\n ORDER BY d.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 document_tags dt\n WHERE dt.document_rowid = d.rowid\n AND dt.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 const parsed = JSON.parse(value) as unknown;\n return Array.isArray(parsed)\n ? parsed.filter((tag): tag is string => typeof tag === \"string\")\n : [];\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 db.exec(`\n CREATE TABLE metadata (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n );\n\n CREATE TABLE documents (\n rowid INTEGER PRIMARY KEY,\n id TEXT NOT NULL UNIQUE,\n slug TEXT NOT NULL,\n title TEXT NOT NULL,\n description TEXT,\n tags_json TEXT NOT NULL,\n excerpt TEXT NOT NULL\n );\n\n CREATE TABLE document_tags (\n document_rowid INTEGER NOT NULL,\n tag TEXT NOT NULL,\n PRIMARY KEY (document_rowid, tag),\n FOREIGN KEY (document_rowid) REFERENCES documents(rowid) ON DELETE CASCADE\n );\n\n CREATE INDEX document_tags_tag_idx ON document_tags(tag);\n\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 insertMetadata = db.prepare(\n \"INSERT INTO metadata (key, value) VALUES (?, ?)\",\n );\n const insertDocument = db.prepare(`\n INSERT INTO documents (id, slug, title, description, tags_json, excerpt)\n VALUES (@id, @slug, @title, @description, @tagsJson, @excerpt)\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 document_tags (document_rowid, 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 result = insertDocument.run({\n id: record.id,\n slug: record.slug,\n title: record.title,\n description: record.description,\n tagsJson: JSON.stringify(record.tags),\n excerpt: makeExcerpt(\n record.content,\n record.description ?? record.title,\n ),\n });\n const rowid = Number(result.lastInsertRowid);\n insertSearch.run(rowid, record.title, record.content, tags.join(\" \"));\n for (const tag of tags) {\n for (const hierarchyTag of tagHierarchy(tag)) {\n insertTag.run(rowid, hierarchyTag);\n }\n }\n }\n\n insertMetadata.run(\"version\", \"1\");\n insertMetadata.run(\"builtAt\", builtAt);\n insertMetadata.run(\"recordCount\", String(items.length));\n });\n insertRecords(records);\n\n db.prepare(\"INSERT INTO search_index(search_index) VALUES(?)\").run(\n \"optimize\",\n );\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\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"],"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.2.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
  }