-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Expand file tree
/
Copy pathquery.ts
More file actions
572 lines (509 loc) · 17.7 KB
/
Copy pathquery.ts
File metadata and controls
572 lines (509 loc) · 17.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
/**
* Search Query Functions
*
* Programmatic API for searching content using FTS5.
*/
import type { Kysely } from "kysely";
import { sql } from "kysely";
import { encodeCursor, decodeCursor, InvalidCursorError } from "../database/repositories/types.js";
import type { Database } from "../database/types.js";
import { validateIdentifier } from "../database/validate.js";
import { resolveConfiguredLocale } from "../i18n/config.js";
import { getDb } from "../loader.js";
import { FTSManager } from "./fts-manager.js";
import type {
SearchOptions,
CollectionSearchOptions,
SearchResult,
SearchResponse,
SuggestOptions,
Suggestion,
SearchStats,
} from "./types.js";
/**
* Marker stored in the `id` slot of a search pagination cursor.
*
* Search results are merged from per-collection FTS queries and re-sorted by
* score, so there is no single stable keyset column to encode (the way
* `getEmDashCollection` encodes `(orderValue, id)`). We page over the merged,
* score-sorted set by offset instead, carried as the cursor's `orderValue`.
* Reusing `encodeCursor`/`decodeCursor` keeps the opaque base64-JSON shape and
* the `InvalidCursorError` handling consistent with the rest of the API.
*/
const SEARCH_CURSOR_MARKER = "search";
/** Encode the next-page offset into an opaque search cursor. */
function encodeSearchCursor(offset: number): string {
return encodeCursor(String(offset), SEARCH_CURSOR_MARKER);
}
/**
* Decode a search cursor back to its offset. Throws `InvalidCursorError` for a
* malformed cursor or one that doesn't carry a non-negative integer offset, so
* a client pagination bug surfaces immediately rather than silently restarting
* from the first page.
*/
/** Upper bound on a decoded search offset, to cap the per-collection row fetch a forged cursor can trigger. */
const MAX_SEARCH_OFFSET = 10_000;
function decodeSearchOffset(cursor: string): number {
const { orderValue, id } = decodeCursor(cursor);
if (id !== SEARCH_CURSOR_MARKER) {
throw new InvalidCursorError(cursor);
}
const offset = Number(orderValue);
if (!Number.isInteger(offset) || offset < 0 || offset > MAX_SEARCH_OFFSET) {
throw new InvalidCursorError(cursor);
}
return offset;
}
/** Pattern to split on whitespace for query term extraction */
const WHITESPACE_SPLIT_PATTERN = /\s+/;
const FTS_OPERATORS_PATTERN = /\b(AND|OR|NOT|NEAR)\b/i;
const DOUBLE_QUOTE_PATTERN = /"/g;
/**
* Detect FTS5 query syntax errors. Match specifically on the SQLite FTS5
* error fingerprints rather than a broad "fts5" / "syntax error" filter
* (which would also swallow internal table-corruption errors). The two
* fingerprints we care about are:
*
* - "fts5: syntax error near …" — unbalanced quotes, stray operators,
* other malformed user input
* - "unknown special query: …" — bare special tokens like `^*` that
* parse but don't resolve to a real FTS5 directive
*/
function isFts5SyntaxError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const message = error.message.toLowerCase();
return message.includes("fts5: syntax error") || message.includes("unknown special query");
}
/**
* Search across multiple collections
*
* Public API that auto-injects the database.
*
* @param query - Search query (FTS5 syntax supported)
* @param options - Search options
* @returns Search results with pagination
*
* @example
* ```typescript
* import { search } from "emdash";
*
* const results = await search("hello world", {
* collections: ["posts", "pages"],
* limit: 20
* });
* ```
*/
export async function search(query: string, options: SearchOptions = {}): Promise<SearchResponse> {
const db = await getDb();
return searchWithDb(db, query, options);
}
/**
* Search across multiple collections (with explicit db)
*
* @internal Use `search()` in templates. This variant is for admin routes
* that already have a database handle.
*
* @param db - Kysely database instance
* @param query - Search query (FTS5 syntax supported)
* @param options - Search options
* @returns Search results with pagination
*/
export async function searchWithDb(
db: Kysely<Database>,
query: string,
options: SearchOptions = {},
): Promise<SearchResponse> {
const ftsManager = new FTSManager(db);
const limit = options.limit ?? 20;
const status = options.status ?? "published";
const offset = options.cursor ? decodeSearchOffset(options.cursor) : 0;
// Get searchable collections
let collections = options.collections;
if (!collections || collections.length === 0) {
collections = await getSearchableCollections(db);
}
if (collections.length === 0) {
return { items: [] };
}
// To rank the merged window [offset, offset + limit) correctly, each
// collection must contribute its own top (offset + limit) rows — in the
// worst case the entire window comes from one collection. The extra +1
// row detects whether a further page exists.
const perCollectionLimit = offset + limit + 1;
// Search each collection and merge results
const allResults: SearchResult[] = [];
const titleColumns = await ftsManager.getCollectionsWithTitleColumn(collections);
for (const collection of collections) {
const config = await ftsManager.getSearchConfig(collection);
if (!config?.enabled) {
continue;
}
const collectionResults = await searchSingleCollection(
db,
collection,
query,
{
status,
locale: options.locale,
limit: perCollectionLimit,
},
config.weights,
titleColumns.has(collection),
);
allResults.push(...collectionResults);
}
// Sort by score descending
allResults.sort((a, b) => b.score - a.score);
// Page the merged set by offset. A nextCursor is issued only when at least
// one result exists past this page.
const items = allResults.slice(offset, offset + limit);
const hasMore = allResults.length > offset + limit;
const nextCursor = hasMore ? encodeSearchCursor(offset + limit) : undefined;
return { items, nextCursor };
}
/**
* Search within a single collection
*
* @param db - Kysely database instance
* @param collection - Collection slug
* @param query - Search query (FTS5 syntax supported)
* @param options - Search options
* @returns Search results with pagination
*
* @example
* ```typescript
* const results = await searchCollection(db, "posts", "hello world", {
* limit: 10
* });
* ```
*/
export async function searchCollection(
db: Kysely<Database>,
collection: string,
query: string,
options: CollectionSearchOptions = {},
): Promise<SearchResponse> {
const ftsManager = new FTSManager(db);
const config = await ftsManager.getSearchConfig(collection);
if (!config?.enabled) {
return { items: [] };
}
const limit = options.limit ?? 20;
const offset = options.cursor ? decodeSearchOffset(options.cursor) : 0;
// Over-fetch the [offset, offset + limit) window plus one row to detect a
// further page, then slice. Keeps the FTS SQL (LIMIT-only) unchanged and
// the cursor contract identical to the cross-collection path.
const fetched = await searchSingleCollection(
db,
collection,
query,
{ status: options.status, locale: options.locale, limit: offset + limit + 1 },
config.weights,
);
const items = fetched.slice(offset, offset + limit);
const hasMore = fetched.length > offset + limit;
const nextCursor = hasMore ? encodeSearchCursor(offset + limit) : undefined;
return { items, nextCursor };
}
/**
* Internal function to search a single collection
*/
async function searchSingleCollection(
db: Kysely<Database>,
collection: string,
query: string,
options: CollectionSearchOptions,
weights?: Record<string, number>,
hasTitle?: boolean,
): Promise<SearchResult[]> {
// Validate before any raw SQL interpolation
validateIdentifier(collection, "collection slug");
const ftsManager = new FTSManager(db);
const ftsTable = ftsManager.getFtsTableName(collection);
const contentTable = ftsManager.getContentTableName(collection);
const limit = options.limit ?? 20;
const status = options.status ?? "published";
const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;
// Check if FTS table exists
if (!(await ftsManager.ftsTableExists(collection))) {
return [];
}
// Escape the query for FTS5
const escapedQuery = escapeQuery(query);
if (!escapedQuery) {
return [];
}
// Get searchable fields for snippet generation
const searchableFields = await ftsManager.getSearchableFields(collection);
// `title` is an optional user-defined field, not a system column. Only
// select it when the collection actually has one; otherwise the query
// errors with "no such column: c.title" (#1178). Multi-collection callers
// precompute this in bulk and pass it in; single-collection callers fall
// back to the per-collection check.
const collectionHasTitle = hasTitle ?? (await ftsManager.hasTitleColumn(collection));
const titleExpr = collectionHasTitle ? sql`c.title` : sql`NULL`;
// Build weight string for bm25 if weights provided
// Format: bm25(table, weight1, weight2, ...)
// First two weights are for 'id' and 'locale' columns (UNINDEXED, so 0)
let bm25Args = "";
if (weights && searchableFields.length > 0) {
const weightValues = ["0", "0"]; // id column, locale column
for (const field of searchableFields) {
weightValues.push(String(weights[field] ?? 1));
}
bm25Args = weightValues.join(", ");
}
// Build and execute the search query
// Using raw SQL because Kysely doesn't have FTS5 support
const bm25Expr = bm25Args ? `bm25("${ftsTable}", ${bm25Args})` : `bm25("${ftsTable}")`;
// Snippet column index is 2 (after id=0, locale=1, first searchable field=2)
let results;
try {
results = await sql<{
id: string;
slug: string | null;
locale: string;
title: string | null;
snippet: string | null;
score: number;
}>`
SELECT
c.id,
c.slug,
c.locale,
${titleExpr} as title,
-- Column -1 lets FTS5 pick the column the query actually matched.
-- Hard-coding 2 (the first searchable field) meant a match in any
-- other field returned that first field's text, unhighlighted.
snippet("${sql.raw(ftsTable)}", -1, '<mark>', '</mark>', '...', 32) as snippet,
${sql.raw(bm25Expr)} as score
FROM "${sql.raw(ftsTable)}" f
JOIN "${sql.raw(contentTable)}" c ON f.id = c.id
WHERE "${sql.raw(ftsTable)}" MATCH ${escapedQuery}
AND c.status = ${status}
AND c.deleted_at IS NULL
${locale ? sql`AND c.locale = ${locale}` : sql``}
ORDER BY score
LIMIT ${limit}
`.execute(db);
} catch (error) {
// FTS5 returns syntax errors for queries with unbalanced quotes,
// stray operators, or other malformed input. Treat these as
// "no matches" so the user gets an empty result rather than an
// internals-leaking error. Other errors (table missing, IO) still
// propagate. Intentionally not logged: any anonymous client can
// trigger this path, and the underlying error message embeds the
// raw query, so logging would be both noisy and a log-injection
// vector.
if (isFts5SyntaxError(error)) {
return [];
}
throw error;
}
return results.rows.map((row) => ({
collection,
id: row.id,
slug: row.slug,
locale: row.locale,
title: row.title ?? undefined,
// SQLite's snippet() can still return NULL — for a row whose matched
// column holds no text, for instance. Skip sanitization in that case
// so we don't throw on `null.replace`. The SearchResult.snippet field is
// already optional, so omitting it is the documented contract.
snippet: row.snippet === null ? undefined : sanitizeSnippet(row.snippet),
score: Math.abs(row.score), // bm25 returns negative scores
}));
}
// Module-scope regexes so the engine doesn't recompile per call —
// snippet sanitization runs on every search result.
const SNIPPET_AMP_RE = /&/g;
const SNIPPET_LT_RE = /</g;
const SNIPPET_GT_RE = />/g;
const SNIPPET_QUOT_RE = /"/g;
const SNIPPET_APOS_RE = /'/g;
/**
* Make an FTS5 snippet safe to render with `set:html` / `innerHTML`.
*
* SQLite's `snippet()` function splices literal `<mark>` and `</mark>`
* markers around matched terms but does not escape the surrounding
* source text. Posts that legitimately contain `<`, `>`, `&`, `"` or
* `'` would render as broken markup, and a `<script>` literal in a
* title (or any other indexed field) would execute when displayed.
*
* The fix: HTML-escape the whole string, which turns the markers into
* `<mark>` / `</mark>`. Then restore those two patterns to
* their original tag form. The result is "the indexed text with all
* HTML metacharacters escaped, plus a small set of literal `<mark>`
* highlight tags around matched terms" — which matches the API's
* documented contract.
*/
function sanitizeSnippet(snippet: string): string {
return snippet
.replace(SNIPPET_AMP_RE, "&")
.replace(SNIPPET_LT_RE, "<")
.replace(SNIPPET_GT_RE, ">")
.replace(SNIPPET_QUOT_RE, """)
.replace(SNIPPET_APOS_RE, "'")
.replaceAll("<mark>", "<mark>")
.replaceAll("</mark>", "</mark>");
}
/**
* Get search suggestions for autocomplete
*
* @param db - Kysely database instance
* @param query - Partial search query
* @param options - Suggestion options
* @returns Array of suggestions
*/
export async function getSuggestions(
db: Kysely<Database>,
query: string,
options: SuggestOptions = {},
): Promise<Suggestion[]> {
const limit = options.limit ?? 5;
const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;
// Get searchable collections
let collections = options.collections;
if (!collections || collections.length === 0) {
collections = await getSearchableCollections(db);
}
if (collections.length === 0) {
return [];
}
const suggestions: Suggestion[] = [];
const ftsManager = new FTSManager(db);
const titleColumns = await ftsManager.getCollectionsWithTitleColumn(collections);
for (const collection of collections) {
const config = await ftsManager.getSearchConfig(collection);
if (!config?.enabled) {
continue;
}
// Suggestions are title-based (Suggestion.title is required and the
// query filters on `c.title IS NOT NULL`). Collections without a
// `title` field can't produce one, and selecting `c.title` would
// error, so skip them. See #1178.
if (!titleColumns.has(collection)) {
continue;
}
// Validate before raw SQL interpolation
validateIdentifier(collection, "collection slug");
const ftsTable = ftsManager.getFtsTableName(collection);
const contentTable = ftsManager.getContentTableName(collection);
// Use prefix search for autocomplete. `escapeQuery` already appends `*`
// to each term for prefix matching, so we must not append another one.
const prefixQuery = escapeQuery(query);
if (!prefixQuery) {
continue;
}
let results;
try {
results = await sql<{
id: string;
slug: string | null;
title: string;
}>`
SELECT
c.id,
c.slug,
c.title
FROM "${sql.raw(ftsTable)}" f
JOIN "${sql.raw(contentTable)}" c ON f.id = c.id
WHERE "${sql.raw(ftsTable)}" MATCH ${prefixQuery}
AND c.status = 'published'
AND c.deleted_at IS NULL
AND c.title IS NOT NULL
${locale ? sql`AND c.locale = ${locale}` : sql``}
ORDER BY bm25("${sql.raw(ftsTable)}")
LIMIT ${limit}
`.execute(db);
} catch (error) {
// Same swallow as searchSingleCollection: malformed prefix
// queries should yield no suggestions, not surface DB errors.
// Intentionally not logged (anonymous-triggerable, echoes
// user input -- see searchSingleCollection for rationale).
if (isFts5SyntaxError(error)) {
continue;
}
throw error;
}
for (const row of results.rows) {
suggestions.push({
collection,
id: row.id,
slug: row.slug,
title: row.title,
});
}
}
return suggestions.slice(0, limit);
}
/**
* Get search statistics for all collections
*/
export async function getSearchStats(db: Kysely<Database>): Promise<SearchStats> {
const ftsManager = new FTSManager(db);
const collections = await getSearchableCollections(db);
const stats: SearchStats = { collections: {} };
for (const collection of collections) {
const collectionStats = await ftsManager.getIndexStats(collection);
if (collectionStats) {
stats.collections[collection] = collectionStats;
}
}
return stats;
}
/**
* Get list of collections with search enabled
*/
async function getSearchableCollections(db: Kysely<Database>): Promise<string[]> {
const results = await db
.selectFrom("_emdash_collections")
.select(["slug", "search_config"])
.execute();
return results
.filter((r) => {
if (!r.search_config) return false;
try {
const config = JSON.parse(r.search_config);
return config.enabled === true;
} catch {
return false;
}
})
.map((r) => r.slug);
}
/**
* Escape a query string for FTS5
*
* Handles special characters and prevents injection.
*/
function escapeQuery(query: string): string {
if (!query || typeof query !== "string") {
return "";
}
// Trim whitespace
query = query.trim();
if (query.length === 0) {
return "";
}
// If already a quoted phrase, escape only interior quotes and preserve phrase syntax
if (query.startsWith('"') && query.endsWith('"') && query.length >= 2) {
const inner = query.slice(1, -1);
return `"${inner.replace(DOUBLE_QUOTE_PATTERN, '""')}"`;
}
// Escape any existing quotes
const escaped = query.replace(DOUBLE_QUOTE_PATTERN, '""');
// If the query contains FTS5 operators (AND, OR, NOT, NEAR),
// pass through with quotes escaped but operators preserved
if (FTS_OPERATORS_PATTERN.test(query)) {
return escaped;
}
// For simple queries, wrap each word to handle special chars
const terms = escaped.split(WHITESPACE_SPLIT_PATTERN).filter((t) => t.length > 0);
if (terms.length === 0) {
return "";
}
// Join with implicit AND, add prefix matching (*) to all terms
// This allows "hel wor" to match "hello world"
return terms.map((t) => `"${t}"*`).join(" ");
}