examples/storefront Public
// Catalog search: a full text index of product names and descriptions in its own D1 database// (SEARCH, search/migrations/). The storefront database stays the source of truth. The index// holds only the text search matches on, and every hit is read back from the catalog, so a// result always has the current price and stock and never includes an inactive product.// See .rebasely/kb/concepts/search.md.import { activeProducts, type Product } from "./catalog.ts";import { HttpError } from "./http.ts";import type { ImageUrls } from "./images.ts";export const MAX_QUERY = 100;const MAX_TERMS = 8;const REBUILD_PAGE = 50;// Name matches count ten times as much as description matches. The first weight is for// product_id, which is not indexed.const RANK = "bm25(products_fts, 0.0, 10.0, 1.0)";type Indexed = { id: string; name: string; description: string; active: boolean };// Turns what a shopper typed into an FTS5 query. Each word becomes a quoted string, so FTS5// syntax in the input (AND, NEAR, column filters, a stray quote) is just text, and the last word// matches as a prefix, so results keep up while someone is still typing. Null when nothing// searchable is left.export function toMatch(q: string): string | null { const words = q.normalize("NFKC").match(/[\p{L}\p{N}]+/gu)?.slice(0, MAX_TERMS) ?? []; if (!words.length) return null; return words.map((w, i) => (i === words.length - 1 ? `"${w}"*` : `"${w}"`)).join(" ");}export function parseQuery(value: string | null): string { const q = (value ?? "").trim(); if (!q || q.length > MAX_QUERY) throw new HttpError(400, "bad_query", `q is the text to search for, 1 to ${MAX_QUERY} characters.`); return q;}// The products matching `q`, best match first.export async function searchProducts(db: D1Database, search: D1Database, images: ImageUrls, q: string, limit: number): Promise<Product[]> { const match = toMatch(q); if (match === null) return []; const { results } = await search .prepare(`SELECT product_id FROM products_fts WHERE products_fts MATCH ? ORDER BY ${RANK} LIMIT ?`) .bind(match, limit) .all<{ product_id: string }>(); return activeProducts( db, results.map((r) => r.product_id), images, );}// Brings one product's row in the index in line with the catalog: replaced when the product is// active, removed when it is not. Called after every write to the product.export async function indexProduct(search: D1Database, p: Indexed): Promise<void> { const remove = search.prepare("DELETE FROM products_fts WHERE product_id = ?").bind(p.id); if (!p.active) { await remove.run(); return; } await search.batch([ remove, search.prepare("INSERT INTO products_fts (product_id, name, description) VALUES (?, ?, ?)").bind(p.id, p.name, p.description), ]);}// Empties the index and fills it again from every active product in the catalog, a page at a// time. For a new search database, and for repairing one that has drifted. While it runs,// searches can miss products that have not been added back yet.export async function rebuildIndex(db: D1Database, search: D1Database): Promise<number> { await search.prepare("DELETE FROM products_fts").run(); let after = ""; let indexed = 0; for (;;) { const { results } = await db .prepare("SELECT id, name, description FROM products WHERE active = 1 AND id > ? ORDER BY id LIMIT ?") .bind(after, REBUILD_PAGE) .all<{ id: string; name: string; description: string }>(); if (!results.length) break; await search.batch( results.map((r) => search.prepare("INSERT INTO products_fts (product_id, name, description) VALUES (?, ?, ?)").bind(r.id, r.name, r.description)), ); indexed += results.length; after = results.at(-1)!.id; if (results.length < REBUILD_PAGE) break; } return indexed;}