@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 +2 -2
- package/dist/index.d.ts +17 -26
- package/dist/index.js +256 -115
- package/dist/index.js.map +1 -1
- package/package.json +15 -3
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @silicajs/search
|
|
2
2
|
|
|
3
|
-
Server-side
|
|
3
|
+
Server-side SQLite full-text search helpers for Silica.
|
|
4
4
|
|
|
5
|
-
The build step creates a
|
|
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
|
|
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
|
|
12
|
-
excerpt: string;
|
|
13
|
-
};
|
|
14
|
-
type SerializedSearchIndex = {
|
|
11
|
+
type SearchDatabaseMetadata = {
|
|
15
12
|
version: 1;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
exported: Record<string, string>;
|
|
13
|
+
databasePath: string;
|
|
14
|
+
recordCount: number;
|
|
19
15
|
builtAt: string;
|
|
20
16
|
};
|
|
21
|
-
type
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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(
|
|
45
|
+
declare function benchmarkSearchIndex(databasePath: string, { query, warmRuns, ...queryOptions }: SearchBenchmarkOptions): Promise<SearchBenchmarkResult>;
|
|
53
46
|
|
|
54
|
-
declare const
|
|
55
|
-
declare function
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
54
|
+
databasePath: string;
|
|
55
|
+
db: Database.Database;
|
|
56
|
+
close: () => void;
|
|
65
57
|
};
|
|
66
|
-
declare function
|
|
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 {
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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 =
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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(
|
|
121
|
+
function toResult(row) {
|
|
122
|
+
const excerpt = "highlighted_excerpt" in row ? row.highlighted_excerpt ?? "" : row.excerpt;
|
|
133
123
|
return {
|
|
134
|
-
slug:
|
|
135
|
-
title:
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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(
|
|
166
|
+
async function benchmarkSearchIndex(databasePath, { query, warmRuns = 10, ...queryOptions }) {
|
|
145
167
|
const coldStart = performance.now();
|
|
146
|
-
const loaded = await
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
309
|
+
SEARCH_DATABASE_FILENAME,
|
|
167
310
|
benchmarkSearchIndex,
|
|
168
|
-
|
|
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":"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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 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.
|
|
4
|
-
"description": "Server-side
|
|
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
|
-
"
|
|
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
|
}
|