Rebasely Beta: data may be purged

examples/storefront Public

TypeScript · 87 lines · 3.8 KiB · at 54fcfaa

// 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;}