app/lib/search/search.server.ts
468 lines and 42 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| renderSnippet | 109 to 115 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| splitDocTags | 118 to 120 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| toHit | 123 to 138 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| visibilityClause | 145 to 148 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| buildFilters | 158 to 189 | 5 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:163> | 163 to 163 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:171> | 171 to 171 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:172> | 172 to 172 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| runIndex | 191 to 215 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| runBrowse | 234 to 248 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| whyMatched | 251 to 263 | 6 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:252> | 252 to 252 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:257> | 257 to 257 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:258> | 258 to 258 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| search | 285 to 364 | 17 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:334> | 334 to 334 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:337> | 337 to 348 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:343> | 343 to 343 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| browse | 367 to 391 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:377> | 377 to 379 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| explainFor | 394 to 424 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:404> | 404 to 422 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| at | 405 to 405 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| rank | 408 to 408 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| contribution | 409 to 410 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| buildFacets | 427 to 451 | 9 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:445> | 445 to 445 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:445> | 445 to 445 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:447> | 447 to 447 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:447> | 447 to 447 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:448> | 448 to 448 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:448> | 448 to 448 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:449> | 449 to 449 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:449> | 449 to 449 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| askCorpusRecords | 453 to 466 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| askExpectedUrls | 468 to 477 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:476> | 476 to 476 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| zeroState | 479 to 522 | 5 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:481> | 481 to 481 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:498> | 498 to 498 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:498> | 498 to 498 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:516> | 516 to 520 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
Imports
It imports app/kb/search-bases.mjs, app/lib/content/og-card-text.mjs, app/lib/search/query.mjs, app/lib/search/sort.mjs, app/lib/search/visibility.mjs. Imported by app/lib/search/ask.server.ts, app/routes/search.tsx.
Source
// Raw D1 rather than drizzle: bm25(), snippet() and MATCH have no drizzle surface. Nothing here
// awaits a third party, so the AI layer cannot slow or break classic search.
import {
RRF_K,
fuse,
hasFilters,
parseQuery,
toMatchExpression,
} from "./query.mjs";
import { applySort, parseSort, type SearchSort } from "./sort.mjs";
import { PUBLISHED_STATUS } from "./visibility.mjs";
import { clampWords } from "~/lib/content/og-card-text.mjs";
import { escapeXml } from "@drdustinedwards/site-helpers";
import { SEARCH_BASES, baseOfUrl, basePrefix } from "~/kb/search-bases.mjs";
type ParsedQuery = ReturnType<typeof parseQuery>;
/** Facet counts are computed over the fused set, so this bounds facet accuracy too. */
const CANDIDATE_LIMIT = 100;
const IDENTITY_WEIGHTS = { title: 10.0, tags: 4.0 };
/**
* snippet() does not escape, so it splices control-character markers that the corpus cannot contain;
* the snippet is escaped first and the markers become <mark> after. Built at runtime so no control
* byte is ever written into this file.
*/
const MARK_START = String.fromCharCode(1);
const MARK_END = String.fromCharCode(2);
const MARK_START_SQL = "char(1)";
const MARK_END_SQL = "char(2)";
/** "filter" is not an index match: it labels a browse-path record, where there was no text to match. */
export type MatchReason = "title" | "tag" | "body" | "filter";
export interface SearchHit {
uid: string;
url: string;
type: string;
title: string;
docTitle: string;
docUrl: string;
anchor: string | null;
tags: string[];
publishAt: number | null;
/** Escaped HTML with <mark> around matched terms. Safe to render. */
snippet: string;
why: MatchReason[];
score: number;
}
interface SearchFacets {
types: Array<{ value: string; count: number }>;
/** The knowledge bases the hits belong to (app/kb/search-bases.mjs), each with its words. */
bases: Array<{ value: string; label: string; count: number }>;
tags: Array<{ value: string; count: number }>;
years: Array<{ value: number; count: number }>;
}
/** No score field: bm25 across differently tokenized indexes is not comparable, so it never leaves SQL. */
interface SearchExplainRow {
uid: string;
title: string;
/** Records are section-grained, so a row's own title is often a bare heading. */
docTitle: string;
url: string;
identityRank: number | null;
proseRank: number | null;
identityContribution: number | null;
proseContribution: number | null;
score: number;
}
interface SearchExplain {
k: number;
layers: string[];
identityCount: number;
proseCount: number;
rows: SearchExplainRow[];
}
export interface SearchResult {
parsed: ParsedQuery;
hits: SearchHit[];
total: number;
page: number;
pageSize: number;
facets: SearchFacets;
/** True when a list hit CANDIDATE_LIMIT, so counts are a floor not a total. */
truncated: boolean;
tookMs: number;
explain?: SearchExplain;
}
interface RawRow {
uid: string;
url: string;
type: string;
title: string;
doc_title: string;
doc_url: string;
anchor: string | null;
doc_tags: string;
publish_at: number | null;
snippet: string;
}
function renderSnippet(raw: string): string {
return escapeXml(raw)
.split(MARK_START)
.join("<mark>")
.split(MARK_END)
.join("</mark>");
}
/** The pipe-delimited `doc_tags` column as a list. */
function splitDocTags(docTags: string): string[] {
return docTags.split("|").filter(Boolean);
}
/** One index row as a result; the caller decides the snippet, the reasons and the rank. */
function toHit(row: RawRow, snippet: string, why: MatchReason[], score: number): SearchHit {
return {
uid: row.uid,
url: row.url,
type: row.type,
title: row.title,
docTitle: row.doc_title,
docUrl: row.doc_url,
anchor: row.anchor,
tags: splitDocTags(row.doc_tags),
publishAt: row.publish_at,
snippet,
why,
score,
};
}
/**
* The SQL twin of publiclyVisible() in app/db/client.ts: they cannot be one function, so
* test/visibility-invariants.test.mjs asserts they admit the same rows.
* @public read by that test
*/
export function visibilityClause(alias = "d"): string {
const q = alias ? `${alias}.` : "";
return `${q}status = '${PUBLISHED_STATUS}' AND (${q}publish_at IS NULL OR ${q}publish_at <= ?)`;
}
/** Named, not a bare "": an empty literal between SQL literals misleads the raw-SQL scans. DO NOT INLINE. */
const NO_ALIAS = "";
interface FilterSql {
clause: string;
params: unknown[];
}
function buildFilters(parsed: ParsedQuery, nowSeconds: number): FilterSql {
const clauses: string[] = [visibilityClause()];
const params: unknown[] = [nowSeconds];
if (parsed.types.length > 0) {
clauses.push(`d.type IN (${parsed.types.map(() => "?").join(", ")})`);
params.push(...parsed.types);
}
// A record is in one knowledge base, read from its address, so base:a base:b widens to either. An id no base has
// matches nothing, as an unknown type does.
if (parsed.bases.length > 0) {
const prefixes = parsed.bases.map(basePrefix);
clauses.push(`(${prefixes.map(() => "d.doc_url LIKE ?").join(" OR ")})`);
params.push(...prefixes.map((prefix) => (prefix === null ? "\u0000" : `${prefix}%`)));
}
// Every tag must be present, so tag:a tag:b narrows rather than widens.
for (const tag of parsed.tags) {
clauses.push(`instr(d.doc_tags, ?) > 0`);
params.push(`|${tag}|`);
}
if (parsed.year !== null) {
const start = Math.floor(Date.UTC(parsed.year, 0, 1) / 1000);
const end = Math.floor(Date.UTC(parsed.year + 1, 0, 1) / 1000);
clauses.push(`d.publish_at >= ? AND d.publish_at < ?`);
params.push(start, end);
}
return { clause: clauses.join(" AND "), params };
}
async function runIndex(
db: D1Database,
table: "search_identity" | "search_prose",
snippetColumn: number,
order: string,
match: string,
filters: FilterSql,
): Promise<RawRow[]> {
const sql = `
SELECT d.uid, d.url, d.type, d.title, d.doc_title, d.doc_url, d.anchor,
d.doc_tags, d.publish_at,
snippet(${table}, ${snippetColumn}, ${MARK_START_SQL}, ${MARK_END_SQL}, '…', 14) AS snippet
FROM ${table}
JOIN search_docs d ON d.id = ${table}.rowid
WHERE ${table} MATCH ?
AND ${filters.clause}
ORDER BY ${order}
LIMIT ${CANDIDATE_LIMIT}
`;
const result = await db
.prepare(sql)
.bind(match, ...filters.params)
.all<RawRow>();
return result.results ?? [];
}
/**
* The posts' listing order for the search index (app/lib/post-order.mjs, app/db/posts.ts newestFirstOrder): publish
* instant, then a series kept together by the newest id among its posts of that instant, then part, then the
* later row. The index has its own row ids, so a post reads its series, part and id from the posts table through
* its address; a page has none of those and falls back to its index id.
*/
const NEWEST_JOIN = `LEFT JOIN posts p ON d.type = 'post' AND p.kind = 'post' AND d.url = '/writing/' || p.slug`;
const NEWEST_ORDER = `d.publish_at DESC,
CASE WHEN p.series IS NULL THEN COALESCE(p.id, d.id)
ELSE COALESCE((SELECT MAX(g.id) FROM posts g WHERE g.kind = 'post' AND g.status = '${PUBLISHED_STATUS}' AND g.publish_at = p.publish_at AND g.series = p.series), p.id) END DESC,
COALESCE(p.part, 0) DESC,
COALESCE(p.id, d.id) DESC`;
/**
* Document records only: returning each section separately would present the corpus as a multiple of
* its size. Date-ordered, since with no relevance signal recency is the only defensible order.
*/
async function runBrowse(db: D1Database, filters: FilterSql): Promise<RawRow[]> {
const sql = `
SELECT d.uid, d.url, d.type, d.title, d.doc_title, d.doc_url, d.anchor,
d.doc_tags, d.publish_at,
substr(d.body, 1, 240) AS snippet
FROM search_docs d
${NEWEST_JOIN}
WHERE d.anchor IS NULL
AND ${filters.clause}
ORDER BY ${NEWEST_ORDER}
LIMIT ${CANDIDATE_LIMIT}
`;
const result = await db.prepare(sql).bind(...filters.params).all<RawRow>();
return result.results ?? [];
}
/** Computed in app code: fts5 reports that a row matched, not which column carried it. */
function whyMatched(row: RawRow, parsed: ParsedQuery, inProse: boolean): MatchReason[] {
const needles = [...parsed.terms, ...parsed.phrases].map((t) => t.toLowerCase());
const why: MatchReason[] = [];
const title = row.title.toLowerCase();
const tags = row.doc_tags.toLowerCase();
if (needles.some((n) => title.includes(n))) why.push("title");
if (needles.some((n) => tags.includes(n))) why.push("tag");
if (inProse) why.push("body");
// An identity-index match can be stemmed or diacritic-folded, which substring testing does not see.
if (why.length === 0) why.push(inProse ? "body" : "title");
return why;
}
interface SearchOptions {
q: string;
type?: string | null;
/** A knowledge base's id (app/kb/search-bases.mjs). */
base?: string | null;
tag?: string | null;
year?: string | null;
page?: number;
pageSize?: number;
prefix?: boolean;
now?: Date;
/** Read off what fuse() recorded, so the playground demo and real search cannot disagree. */
explain?: boolean;
/**
* Re-sorts the FUSED list, never the SQL: sorting in SQL would change the candidate set per sort, so
* pages would disagree. The browse path ignores it.
*/
sort?: SearchSort;
}
export async function search(env: Env, options: SearchOptions): Promise<SearchResult> {
const pageSize = options.pageSize ?? 10;
const page = Math.max(1, options.page ?? 1);
const sort: SearchSort = options.sort ?? "relevance";
const now = options.now ?? new Date();
const nowSeconds = Math.floor(now.getTime() / 1000);
// ?tag=d1 and a typed tag:d1 merge into one parse, so facet links can be plain hrefs.
const parsed = parseQuery(options.q ?? "");
if (options.type) parsed.types.push(options.type.toLowerCase());
if (options.base) parsed.bases.push(options.base.toLowerCase());
if (options.tag) parsed.tags.push(options.tag.toLowerCase());
if (options.year && /^\d{4}$/.test(options.year)) parsed.year = Number(options.year);
const empty: SearchResult = {
parsed,
hits: [],
total: 0,
page,
pageSize,
facets: { types: [], bases: [], tags: [], years: [] },
truncated: false,
tookMs: 0,
};
const match = toMatchExpression(parsed, options.prefix ?? false);
if (!match && !hasFilters(parsed)) return empty;
const filters = buildFilters(parsed, nowSeconds);
if (!match) return browse(env, parsed, filters, page, pageSize);
const started = Date.now();
const [identityRows, proseRows] = await Promise.all([
runIndex(
env.DB,
"search_identity",
0,
`bm25(search_identity, ${IDENTITY_WEIGHTS.title}, ${IDENTITY_WEIGHTS.tags}) ASC`,
match,
filters,
),
runIndex(env.DB, "search_prose", 0, `bm25(search_prose) ASC`, match, filters),
]);
const tookMs = Date.now() - started;
const truncated =
identityRows.length >= CANDIDATE_LIMIT || proseRows.length >= CANDIDATE_LIMIT;
const proseUids = new Set(proseRows.map((r) => r.uid));
const fused = fuse<RawRow & { uid: string }>([identityRows, proseRows]);
const fusedHits: SearchHit[] = fused.map(({ item, score }) =>
toHit(
item,
// The prose snippet has sentences; the identity one is just the title, already shown.
renderSnippet(
(proseUids.has(item.uid)
? proseRows.find((r) => r.uid === item.uid)?.snippet
: item.snippet) ?? item.snippet,
),
whyMatched(item, parsed, proseUids.has(item.uid)),
score,
),
);
const hits = applySort(fusedHits, sort);
return {
parsed,
hits: hits.slice((page - 1) * pageSize, page * pageSize),
total: hits.length,
page,
pageSize,
facets: buildFacets(hits),
truncated,
tookMs,
...(options.explain ? { explain: explainFor(fused, identityRows, proseRows) } : {}),
};
}
/** No-match browse: the filters alone, newest first, with no relevance to fuse. */
async function browse(
env: Env,
parsed: ParsedQuery,
filters: FilterSql,
page: number,
pageSize: number,
): Promise<SearchResult> {
const started = Date.now();
const rows = await runBrowse(env.DB, filters);
const browseTook = Date.now() - started;
const browseHits: SearchHit[] = rows.map((item, index) =>
// Rank is the date order this came back in, not a relevance score.
toHit(item, renderSnippet(clampWords(item.snippet, 200)), ["filter"], rows.length - index),
);
return {
parsed,
hits: browseHits.slice((page - 1) * pageSize, page * pageSize),
total: browseHits.length,
page,
pageSize,
facets: buildFacets(browseHits),
truncated: rows.length >= CANDIDATE_LIMIT,
tookMs: browseTook,
};
}
/** Each fused row's rank and RRF contribution in each layer, for `?explain`. */
function explainFor(
fused: ReturnType<typeof fuse<RawRow>>,
identityRows: RawRow[],
proseRows: RawRow[],
): SearchExplain {
return {
k: RRF_K,
layers: ["search_identity", "search_prose"],
identityCount: identityRows.length,
proseCount: proseRows.length,
rows: fused.map(({ item, score, sources, ranks, contributions }) => {
const at = (list: number) => sources.indexOf(list);
const i = at(0);
const p = at(1);
const rank = (n: number) => (n === -1 ? null : (ranks[n] ?? null));
const contribution = (n: number) =>
n === -1 ? null : (contributions[n] ?? null);
return {
uid: item.uid,
title: item.title,
docTitle: item.doc_title,
url: item.url,
identityRank: rank(i),
proseRank: rank(p),
identityContribution: contribution(i),
proseContribution: contribution(p),
score,
};
}),
};
}
/** Over the whole match set, not the page. If truncated, the caller must present these as a floor. */
function buildFacets(hits: SearchHit[]): SearchFacets {
const types = new Map<string, number>();
const bases = new Map<string, number>();
const tags = new Map<string, number>();
const years = new Map<number, number>();
for (const hit of hits) {
types.set(hit.type, (types.get(hit.type) ?? 0) + 1);
const base = baseOfUrl(hit.docUrl);
if (base) bases.set(base, (bases.get(base) ?? 0) + 1);
for (const tag of hit.tags) tags.set(tag, (tags.get(tag) ?? 0) + 1);
if (hit.publishAt !== null) {
const year = new Date(hit.publishAt * 1000).getUTCFullYear();
years.set(year, (years.get(year) ?? 0) + 1);
}
}
return {
types: [...types].map(([value, count]) => ({ value, count })).sort((a, b) => b.count - a.count),
// In the bases' own order, not by count, so the list reads the same from one search to the next.
bases: SEARCH_BASES.filter((b) => bases.has(b.id)).map((b) => ({ value: b.id, label: b.label, count: bases.get(b.id) ?? 0 })),
tags: [...tags].map(([value, count]) => ({ value, count })).sort((a, b) => b.count - a.count),
years: [...years].map(([value, count]) => ({ value, count })).sort((a, b) => b.value - a.value),
};
}
export async function askCorpusRecords(
env: Env,
now = new Date(),
): Promise<Array<{ url: string; title: string; body: string }>> {
const nowSeconds = Math.floor(now.getTime() / 1000);
const rows = await env.DB.prepare(
// CONCATENATED, not interpolated, for the reason stated at NO_ALIAS.
`SELECT url, title, body FROM search_docs WHERE type = 'post' AND ` +
visibilityClause(NO_ALIAS),
)
.bind(nowSeconds)
.all<{ url: string; title: string; body: string }>();
return rows.results ?? [];
}
export async function askExpectedUrls(env: Env, now = new Date()): Promise<string[]> {
const nowSeconds = Math.floor(now.getTime() / 1000);
const rows = await env.DB.prepare(
// Concatenated, not interpolated (see NO_ALIAS). Posts only: pages are never uploaded to Ask.
`SELECT url FROM search_docs WHERE type = 'post' AND ` + visibilityClause(NO_ALIAS),
)
.bind(nowSeconds)
.all<{ url: string }>();
return (rows.results ?? []).map((r) => r.url);
}
export async function zeroState(env: Env, parsed: ParsedQuery, now = new Date()) {
const nowSeconds = Math.floor(now.getTime() / 1000);
const needles = [...parsed.terms, ...parsed.phrases].map((t) => t.toLowerCase());
const tagRows = await env.DB.prepare(
// Concatenated, not interpolated: a ${...} truncates the literal the raw-SQL scans can see.
`SELECT DISTINCT doc_tags FROM search_docs WHERE doc_tags <> '' AND ` +
visibilityClause(NO_ALIAS),
)
.bind(nowSeconds)
.all<{ doc_tags: string }>();
const allTags = new Set<string>();
for (const row of tagRows.results ?? []) {
for (const tag of splitDocTags(row.doc_tags)) allTags.add(tag);
}
// Prefix or substring, not edit distance: on a handful of tags, fuzzy matching surfaces confident nonsense.
const nearest = [...allTags]
.filter((tag) => needles.some((n) => n.length >= 2 && (tag.includes(n) || n.includes(tag))))
.sort()
.slice(0, 6);
const recent = await env.DB.prepare(
`SELECT d.uid, d.url, d.title, d.publish_at FROM search_docs d ` +
NEWEST_JOIN +
` WHERE d.anchor IS NULL AND d.type = 'post' AND ` +
visibilityClause() +
` ORDER BY ` +
NEWEST_ORDER +
` LIMIT 5`,
)
.bind(nowSeconds)
.all<{ uid: string; url: string; title: string; publish_at: number | null }>();
return {
nearestTags: nearest,
recentPosts: (recent.results ?? []).map((r) => ({
url: r.url,
title: r.title,
publishAt: r.publish_at,
})),
};
}
export { parseSort };