Files
SRCmail/lib/mail-index/store.ts
T
Bernd Rodler a622e3755b fix: Phase 1 critical+high fixes (17/18 items)
CRITICAL fixes:
- C1: Error swallowing - throw TransportError on network failure in getEmails/searchEmails
- C2: Recurrence expansion ID delimiter changed from ':' to '::occurrence::'
- C3: Cross-account calendar event UID dedup after multi-account aggregation
- C4: Admin session token revocation via JTI blacklist on logout
- C6: FTS5 schema-drop - add warning log for automatic reindex trigger
- C7: Settings lock - gate updateSetting() with isSettingLocked() check
- C8: Offline push pause - add offline event handler that closes push transports

HIGH fixes:
- H1: Push handler - add ContactCard and FileNode branches
- H2: WS fallback - await state snapshot before reconcileAfterWebSocketFallback
- H3: Auth rate limiting - add checkUserAuthRateLimit to session and token routes
- H4: OAuth logs - strip access_token from error log context
- H7: Template XSS - apply DOMPurify to HTML template body on import
- H8: Secure cookie - derive from x-forwarded-proto, not NODE_ENV
- H9: bcrypt fix - remove bcrypt prefixes from isHashed() so scrypt-only
- H13: calendarTasksEnabled - apply admin gate at runtime in calendar page
- H14: Task mutations - add try/catch error handling to update/delete/toggle
- H18: autoSelectReplyIdentity default changed from false to true

Deferred: P1.3 (C5 auth localStorage encryption) - requires custom Zustand persist adapter.
2026-08-07 12:17:41 +02:00

601 lines
24 KiB
TypeScript

// The encrypted local search index: schema, open/close, upsert, search.
//
// One SQLite (SQLCipher) file per account. Rows are ALSO account-scoped
// internally - `(jmap_account_id, content_type, id)` - because a single login
// exposes the user's own JMAP account plus every delegated/shared account, and
// JMAP ids are unique only WITHIN an account (this codebase already works
// around that collision in `lib/jmap/client.ts:388`'s namespaceMailboxIds).
// One file per account keeps purge trivial; the composite key keeps
// delegated accounts from merging inside it.
//
// This is a SEARCH INDEX, not a mail replica. It is allowed to be stale, it is
// allowed to be incomplete, and it can be discarded and rebuilt at any time -
// which is why the schema-version mismatch path below simply drops everything
// rather than migrating.
import fs from 'node:fs';
import path from 'node:path';
import { loadSqlcipher, type SqlcipherConstructor, type SqlcipherDatabase } from './binding';
import { dbSiblings, indexDbPath } from './paths';
export const SCHEMA_VERSION = 1;
export type ContentType = 'mail' | 'calendar' | 'contact' | 'file';
export const CONTENT_TYPES: readonly ContentType[] = ['mail', 'calendar', 'contact', 'file'];
export function isContentType(v: unknown): v is ContentType {
return typeof v === 'string' && (CONTENT_TYPES as readonly string[]).includes(v);
}
/**
* One indexable thing, already flattened to text. Produced by the pure
* extractors in `extract.ts` so that every JMAP-shape decision is unit-testable
* without a database or a server.
*/
export interface IndexDoc {
jmapAccountId: string;
contentType: ContentType;
/** JMAP id. Unique only within (jmapAccountId, contentType). */
id: string;
/** Subject / event title / contact display name / filename. */
title: string;
/** Addresses and names: sender+recipients, attendees, contact emails/phones, owner. */
people: string;
/** The bulk searchable text. Plain text only - never HTML. */
body: string;
/** ISO 8601, or null when the type has no meaningful date. Drives recency ordering. */
occurredAt: string | null;
/** Small type-specific extras returned verbatim to the caller (never searched). */
metadata: Record<string, unknown>;
}
export interface SearchHit {
contentType: ContentType;
id: string;
jmapAccountId: string;
title: string;
people: string;
occurredAt: string | null;
metadata: Record<string, unknown>;
/** FTS5 bm25 score. Lower is a better match (bm25 returns negative values). */
score: number;
/** Highlighted excerpt from the body, for feeding an LLM as context. */
snippet: string;
}
const DDL = `
CREATE TABLE IF NOT EXISTS doc (
jmap_account_id TEXT NOT NULL,
content_type TEXT NOT NULL,
id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
people TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
occurred_at TEXT,
metadata_json TEXT NOT NULL DEFAULT '{}',
indexed_at INTEGER NOT NULL,
PRIMARY KEY (jmap_account_id, content_type, id)
);
CREATE INDEX IF NOT EXISTS doc_recent
ON doc(jmap_account_id, content_type, occurred_at DESC);
-- Standalone (not external-content) FTS5: the text is duplicated into this
-- table and kept in step manually on upsert. External content would avoid the
-- duplication but requires deleting the old FTS row using its OLD column
-- values, which an upsert does not have to hand - a well-known source of
-- silently-stale FTS rows. At this scale (a bounded recent window) the
-- duplication is the cheaper correctness trade.
CREATE VIRTUAL TABLE IF NOT EXISTS doc_fts USING fts5(
title, people, body,
tokenize='unicode61 remove_diacritics 2'
);
CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);
`;
export class MailIndexUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = 'MailIndexUnavailableError';
}
}
/**
* Assert that the file we just opened is REALLY encrypted.
*
* This is not defensive boilerplate, it guards the sharpest landmine found
* while designing this: on both `node:sqlite` and plain `better-sqlite3`,
* `PRAGMA key = ...` is **silently accepted and does nothing** - no error, a
* working database, and the mail sitting on disk in cleartext. Verified by
* writing a file and recovering a canary string from the raw bytes.
*
* The check is on the VALUE, not the row count: a non-cipher binding returns
* ZERO ROWS for `PRAGMA cipher_version`, so a naive `!== ''` comparison over a
* missing row passes vacuously. Require a non-empty string.
*/
function assertEncrypted(db: SqlcipherDatabase, dbPath: string): void {
const rows = db.pragma('cipher_version');
const value =
Array.isArray(rows) && rows.length > 0 && rows[0] && typeof rows[0] === 'object'
? (rows[0] as Record<string, unknown>).cipher_version
: undefined;
if (typeof value !== 'string' || value.trim().length === 0) {
db.close();
throw new MailIndexUnavailableError(
`Refusing to use ${path.basename(dbPath)}: the SQLite binding reports no SQLCipher ` +
`support (PRAGMA cipher_version returned ${JSON.stringify(rows)}), so the index ` +
`would be written in cleartext.`,
);
}
}
export interface OpenOptions {
storeDir: string;
accountId: string;
/** Raw 32-byte key. Used as SQLCipher's raw key (no KDF) via `PRAGMA key = "x'..'"`. */
key: Buffer;
}
/**
* Opens the connection, sets the SQLCipher key, verifies real encryption, and
* applies the fixed pragmas - the sequence both the first attempt and the
* wrong-key retry in `open()` need identically.
*
* On ANY failure the just-opened connection is closed before the error
* propagates. This matters beyond `assertEncrypted`'s own failure (which
* already closes): a bare `db.pragma(...)` throwing - SQLITE_BUSY, a full disk
* on the first WAL write, anything - must not leak the native handle either,
* which a `try` wrapped around only some of these calls previously missed.
*
* Exported so the cleanup guarantee can be unit-tested against a fake
* `SqlcipherDatabase` - a real double failure (wrong key, THEN a pragma
* failure on the freshly rebuilt file) is not practically reproducible
* against the real binding.
*/
export function openKeyed(
Database: SqlcipherConstructor,
dbPath: string,
key: Buffer,
): { db: SqlcipherDatabase; version: number | null } {
const db = new Database(dbPath);
try {
// The key pragma must be the FIRST statement on the connection. Hex form
// means SQLCipher uses these 32 bytes as the raw key with no KDF, which is
// right for a random key (a passphrase would want the KDF).
db.pragma(`key = "x'${key.toString('hex')}'"`);
assertEncrypted(db, dbPath);
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
// The offline replica (lib/offline-replica/**) is a SECOND connection to
// this same file, writing disjoint tables. WAL lets a writer and readers
// coexist, but two WRITERS get SQLITE_BUSY immediately without this - and
// both subsystems are driven by the same renderer push handler, so they
// genuinely do overlap.
db.pragma('busy_timeout = 8000');
return { db, version: readSchemaVersion(db) };
} catch (error) {
// Idempotent: assertEncrypted already closed on its own failure, so this
// is a harmless no-op in that case.
try { db.close(); } catch { /* already closed */ }
throw error;
}
}
export class MailIndex {
private constructor(
private readonly db: SqlcipherDatabase,
readonly dbPath: string,
) {}
/**
* Opens (creating if needed) the account's index. Throws
* MailIndexUnavailableError when the native binding is absent or the file is
* not actually encrypted; the caller turns the feature off rather than
* falling back to something unencrypted.
*/
static open({ storeDir, accountId, key }: OpenOptions): MailIndex {
const Database = loadSqlcipher();
if (!Database) {
throw new MailIndexUnavailableError(
'@signalapp/sqlcipher is not installed for this platform (it is an optional dependency).',
);
}
if (key.length !== 32) {
throw new MailIndexUnavailableError(`Index key must be 32 bytes, got ${key.length}.`);
}
const dbPath = indexDbPath(storeDir, accountId);
fs.mkdirSync(path.dirname(dbPath), { recursive: true, mode: 0o700 });
// A wrong key surfaces here rather than at open: SQLCipher only reads the
// header lazily. Treat it as "unreadable" and rebuild from scratch - the
// index is derived data, so there is nothing to recover and never anything
// to prompt the user for (the key was never a user secret).
let opened: { db: SqlcipherDatabase; version: number | null };
try {
opened = openKeyed(Database, dbPath, key);
} catch {
// `openKeyed` guarantees the failed connection above is already closed,
// so there is nothing to clean up here before retrying on a fresh file.
for (const f of dbSiblings(dbPath)) {
try { fs.rmSync(f, { force: true }); } catch { /* best effort */ }
}
opened = openKeyed(Database, dbPath, key);
opened.version = null; // fresh file - nothing to read
}
const db = opened.db;
let version = opened.version;
if (version !== null && version !== SCHEMA_VERSION) {
console.warn(
`[MailIndex] Schema version mismatch (stored=${version}, current=${SCHEMA_VERSION}). ` +
'Dropping all tables — a full reindex will run on the next push event or /api/offline/reindex call.',
);
db.exec('DROP TABLE IF EXISTS doc_fts; DROP TABLE IF EXISTS doc; DROP TABLE IF EXISTS meta;');
version = null;
}
if (version === null) {
db.exec(DDL);
db.prepare('INSERT OR REPLACE INTO meta (k, v) VALUES (?, ?)').run([
'schema_version',
String(SCHEMA_VERSION),
]);
}
return new MailIndex(db, dbPath);
}
close(): void {
try { this.db.close(); } catch { /* already closed */ }
}
/**
* Upserts documents and keeps the FTS rows in step. Returns the number of
* rows written. One transaction for the whole batch - a partially-applied
* batch is harmless (it is an index) but a transaction is faster.
*/
upsert(docs: readonly IndexDoc[]): number {
if (docs.length === 0) return 0;
const upsertDoc = this.db.prepare(`
INSERT INTO doc (jmap_account_id, content_type, id, title, people, body,
occurred_at, metadata_json, indexed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(jmap_account_id, content_type, id) DO UPDATE SET
title = excluded.title, people = excluded.people, body = excluded.body,
occurred_at = excluded.occurred_at, metadata_json = excluded.metadata_json,
indexed_at = excluded.indexed_at
RETURNING rowid
`);
const deleteFts = this.db.prepare('DELETE FROM doc_fts WHERE rowid = ?');
const insertFts = this.db.prepare(
'INSERT INTO doc_fts (rowid, title, people, body) VALUES (?, ?, ?, ?)',
);
const now = Date.now();
let written = 0;
this.db.exec('BEGIN');
try {
for (const d of docs) {
const row = upsertDoc.get([
d.jmapAccountId, d.contentType, d.id,
d.title, d.people, d.body,
d.occurredAt, JSON.stringify(d.metadata ?? {}), now,
]);
const rowid = row?.rowid;
if (typeof rowid !== 'number') continue;
// ON CONFLICT preserves the rowid, so delete-then-insert replaces the
// old FTS row rather than accumulating duplicates for one document.
deleteFts.run([rowid]);
insertFts.run([rowid, d.title, d.people, d.body]);
written++;
}
this.db.exec('COMMIT');
} catch (error) {
this.db.exec('ROLLBACK');
throw error;
}
return written;
}
/** Removes documents by id (a JMAP `destroyed` id, or a stale row). */
remove(jmapAccountId: string, contentType: ContentType, ids: readonly string[]): number {
if (ids.length === 0) return 0;
const findRow = this.db.prepare(
'SELECT rowid FROM doc WHERE jmap_account_id = ? AND content_type = ? AND id = ?',
);
const deleteFts = this.db.prepare('DELETE FROM doc_fts WHERE rowid = ?');
const deleteDoc = this.db.prepare(
'DELETE FROM doc WHERE jmap_account_id = ? AND content_type = ? AND id = ?',
);
let removed = 0;
this.db.exec('BEGIN');
try {
for (const id of ids) {
const row = findRow.get([jmapAccountId, contentType, id]);
if (typeof row?.rowid === 'number') deleteFts.run([row.rowid]);
removed += deleteDoc.run([jmapAccountId, contentType, id]).changes;
}
this.db.exec('COMMIT');
} catch (error) {
this.db.exec('ROLLBACK');
throw error;
}
return removed;
}
/**
* Full-text search - the retrieval surface an AI feature calls to gather
* context. `types` empty/omitted searches everything.
*/
search(opts: {
query: string;
types?: readonly ContentType[];
limit?: number;
snippetTokens?: number;
/** 'and' (default): every token required - a deliberate search-box query.
* 'any': stop words dropped, remaining tokens OR-joined, ranked by bm25 -
* a natural-language question (see toFtsMatchQueryAny's docstring). */
mode?: 'and' | 'any';
}): SearchHit[] {
const match = opts.mode === 'any' ? toFtsMatchQueryAny(opts.query) : toFtsMatchQuery(opts.query);
if (!match) return [];
const limit = Math.min(Math.max(opts.limit ?? 20, 1), 200);
const tokens = Math.min(Math.max(opts.snippetTokens ?? 24, 4), 64);
const types = opts.types && opts.types.length > 0 ? opts.types : null;
const typeFilter = types ? ` AND d.content_type IN (${types.map(() => '?').join(',')})` : '';
// bm25 weights: a hit in the title or in a name/address is a stronger
// signal than one in a long body, and for RAG the title is what makes a
// retrieved chunk recognisable.
const rows = this.db
.prepare(`
SELECT d.content_type, d.id, d.jmap_account_id, d.title, d.people,
d.occurred_at, d.metadata_json,
bm25(doc_fts, 8.0, 4.0, 1.0) AS score,
snippet(doc_fts, 2, '[', ']', '…', ${tokens}) AS snip
FROM doc_fts
JOIN doc d ON d.rowid = doc_fts.rowid
WHERE doc_fts MATCH ?${typeFilter}
ORDER BY score ASC, d.occurred_at DESC
LIMIT ?
`)
.all([match, ...(types ?? []), limit]);
return rows.map((r) => ({
contentType: String(r.content_type) as ContentType,
id: String(r.id),
jmapAccountId: String(r.jmap_account_id),
title: String(r.title ?? ''),
people: String(r.people ?? ''),
occurredAt: r.occurred_at === null || r.occurred_at === undefined ? null : String(r.occurred_at),
metadata: safeParseObject(r.metadata_json),
score: typeof r.score === 'number' ? r.score : 0,
snippet: String(r.snip ?? ''),
}));
}
/**
* Newest documents by date, ignoring keyword relevance entirely.
*
* The retrieval leg for RECENCY questions — "what was the last mail", "who
* wrote most recently", "everything from July". Full-text search cannot
* answer those even with a perfect index: bm25 ranks by term overlap and has
* no notion of "latest", so "the last mail" matches documents containing the
* word "last". Two real questions failed exactly that way before this
* existed. `doc(jmap_account_id, content_type, occurred_at DESC)` is already
* indexed, so this is an ordered range scan, not a table sweep.
*
* `since`/`until` are ISO strings, both optional — a month-name question
* becomes a bounded range, a bare "latest" becomes an unbounded top-N.
*/
recent(opts: {
types?: readonly ContentType[];
limit?: number;
since?: string;
until?: string;
snippetChars?: number;
}): SearchHit[] {
const limit = Math.min(Math.max(opts.limit ?? 10, 1), 200);
const snippetChars = Math.min(Math.max(opts.snippetChars ?? 400, 80), 2000);
const types = opts.types && opts.types.length > 0 ? opts.types : null;
const where: string[] = ['d.occurred_at IS NOT NULL'];
const params: Array<string | number> = [];
if (types) {
where.push(`d.content_type IN (${types.map(() => '?').join(',')})`);
params.push(...types);
}
if (opts.since) { where.push('d.occurred_at >= ?'); params.push(opts.since); }
if (opts.until) { where.push('d.occurred_at <= ?'); params.push(opts.until); }
const rows = this.db
.prepare(`
SELECT d.content_type, d.id, d.jmap_account_id, d.title, d.people,
d.occurred_at, d.metadata_json,
substr(f.body, 1, ${snippetChars}) AS snip
FROM doc d
LEFT JOIN doc_fts f ON f.rowid = d.rowid
WHERE ${where.join(' AND ')}
ORDER BY d.occurred_at DESC
LIMIT ?
`)
.all([...params, limit]);
return rows.map((r) => ({
contentType: String(r.content_type) as ContentType,
id: String(r.id),
jmapAccountId: String(r.jmap_account_id),
title: String(r.title ?? ''),
people: String(r.people ?? ''),
occurredAt: r.occurred_at === null || r.occurred_at === undefined ? null : String(r.occurred_at),
metadata: safeParseObject(r.metadata_json),
// No bm25 score here: these are ordered by time, not relevance, and
// faking a relevance number would let the fusion step rank them as if
// they had been scored.
score: 0,
snippet: String(r.snip ?? ''),
}));
}
/** Per-type counts and freshness, for the Settings UI and for debugging. */
stats(): Array<{ contentType: string; count: number; newest: string | null; indexedAt: number | null }> {
return this.db
.prepare(`
SELECT content_type, COUNT(*) AS n, MAX(occurred_at) AS newest, MAX(indexed_at) AS indexed
FROM doc GROUP BY content_type ORDER BY content_type
`)
.all()
.map((r) => ({
contentType: String(r.content_type),
count: Number(r.n ?? 0),
newest: r.newest === null || r.newest === undefined ? null : String(r.newest),
indexedAt: typeof r.indexed === 'number' ? r.indexed : null,
}));
}
/** Ids already present, so a catch-up pass can skip re-fetching bodies. */
existingIds(jmapAccountId: string, contentType: ContentType): Set<string> {
const rows = this.db
.prepare('SELECT id FROM doc WHERE jmap_account_id = ? AND content_type = ?')
.all([jmapAccountId, contentType]);
return new Set(rows.map((r) => String(r.id)));
}
/** Drops documents older than the retention floor for a type. */
pruneOlderThan(jmapAccountId: string, contentType: ContentType, isoFloor: string): number {
const rows = this.db
.prepare(`
SELECT rowid FROM doc
WHERE jmap_account_id = ? AND content_type = ?
AND occurred_at IS NOT NULL AND occurred_at < ?
`)
.all([jmapAccountId, contentType, isoFloor]);
if (rows.length === 0) return 0;
const deleteFts = this.db.prepare('DELETE FROM doc_fts WHERE rowid = ?');
const deleteDoc = this.db.prepare('DELETE FROM doc WHERE rowid = ?');
this.db.exec('BEGIN');
try {
for (const r of rows) {
deleteFts.run([r.rowid]);
deleteDoc.run([r.rowid]);
}
this.db.exec('COMMIT');
} catch (error) {
this.db.exec('ROLLBACK');
throw error;
}
return rows.length;
}
}
function readSchemaVersion(db: SqlcipherDatabase): number | null {
try {
const row = db.prepare("SELECT v FROM meta WHERE k = 'schema_version'").get();
if (!row || row.v === undefined) return null;
const n = Number(row.v);
return Number.isFinite(n) ? n : null;
} catch {
// `meta` doesn't exist yet - a fresh file.
return null;
}
}
function safeParseObject(v: unknown): Record<string, unknown> {
if (typeof v !== 'string') return {};
try {
const parsed = JSON.parse(v);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
/**
* Splits and safely quotes raw text into FTS5-safe tokens, shared by both
* query-builders below. Split on anything that isn't a word character or an
* intra-word mark - keeps unicode letters (so "Müller" and "東京" survive)
* via the u flag. Every token is quoted (making it a literal phrase) so a
* bare `"` or a stray `*`/`NEAR`/`:` in user input can never raise FTS5's own
* `fts5: syntax error` - that would turn a normal search into a 500.
*/
function quoteFtsTokens(raw: string): string[] {
return raw
.normalize('NFC')
.split(/[^\p{L}\p{N}_@.'-]+/u)
.map((t) => t.replace(/^['-]+|['-]+$/g, ''))
.filter((t) => t.length > 0)
.slice(0, 24)
.map((t, i, all) => {
const quoted = `"${t.replace(/"/g, '""')}"`;
// Prefix-match only the final token, and only if it's long enough to not
// match half the mailbox.
return i === all.length - 1 && t.length >= 3 ? `${quoted}*` : quoted;
});
}
/**
* Turns arbitrary user text into a safe FTS5 MATCH expression, every token
* required (AND-joined). Right for a deliberate, short search-box query,
* where requiring every word is what makes results precise as you type.
*
* Exported for unit testing - it is the one piece of this file with no
* database dependency and the most ways to be wrong.
*/
export function toFtsMatchQuery(raw: string): string | null {
if (typeof raw !== 'string') return null;
const tokens = quoteFtsTokens(raw);
if (tokens.length === 0) return null;
return tokens.join(' AND ');
}
// A minimal, well-known set of English function words that carry no
// retrieval signal - kept out of toFtsMatchQueryAny's OR expression so they
// don't drown out the bm25 ranking's actual signal (see below). Deliberately
// NOT applied inside quoteFtsTokens/toFtsMatchQuery: that function's own
// tests rely on "AND"/"OR"/"NOT" surviving verbatim as literal search terms
// (FTS5-keyword-injection safety) - a different concern from this one's job
// of turning a natural-language QUESTION into a good search.
const RETRIEVAL_STOP_WORDS = new Set([
'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'am',
'and', 'or', 'but', 'if', 'then', 'than', 'so', 'because',
'for', 'of', 'to', 'in', 'on', 'at', 'by', 'with', 'from', 'as', 'about', 'into', 'over', 'after', 'before',
'that', 'this', 'these', 'those', 'what', 'when', 'where', 'who', 'whom', 'which', 'why', 'how',
'do', 'does', 'did', 'doing', 'done',
'can', 'could', 'will', 'would', 'shall', 'should', 'may', 'might', 'must',
'i', 'you', 'he', 'she', 'it', 'we', 'they', 'my', 'your', 'his', 'her', 'its', 'our', 'their', 'me', 'him', 'us', 'them',
'not', 'no',
]);
/**
* Turns a natural-language QUESTION into a lenient FTS5 MATCH expression:
* stop words dropped, remaining tokens OR-joined so bm25 ranks by how many
* content words matched instead of requiring every one of them present.
*
* toFtsMatchQuery's strict AND is wrong for this shape of input: a real
* question like "When is check-in for the Villa sul Lago booking?" shares
* almost none of its own function words ("when"/"is"/"for"/"the") with the
* document that actually answers it, so ANDing every token together returns
* nothing - confirmed live: 0 hits for the full question, 2 correct hits for
* the same index once reduced to "Villa sul Lago check-in". The one real
* caller of `/api/offline/search?q=...` is exactly this AI-question shape
* (see that route's own header - no manual search-box UI hits it today), so
* this is the query builder that route now uses, not toFtsMatchQuery.
*/
export function toFtsMatchQueryAny(raw: string): string | null {
if (typeof raw !== 'string') return null;
const withoutStopWords = raw
.split(/\s+/)
.filter((w) => w.length > 0 && !RETRIEVAL_STOP_WORDS.has(w.toLowerCase().replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '')))
.join(' ');
// Every word was a stop word (e.g. "What is this?") - fall back to the
// original text rather than searching for literally nothing.
const tokens = quoteFtsTokens(withoutStopWords.length > 0 ? withoutStopWords : raw);
if (tokens.length === 0) return null;
return tokens.join(' OR ');
}