/** * One-time backfill — rebuild `price_list.search_text` for every * existing row using the comprehensive builder in * `src/lib/price-list-search.ts`, AND fix the `supplier` column on * legacy rows where it stores a PB id instead of the resolved name. * * Why this exists alongside the search_text migration: * The search_text migration only ADDS the column + index. It * does not rewrite rows — adding the column left the existing * 46 rows with `search_text = ""` and `supplier = "q6hpwbxsicyoiuu"`. * * This script: * 1. Resolves the supplier name from raw_price_list → * suppliers expansion (so `supplier` becomes the human * name, not the PB id). * 2. Rebuilds `search_text` from EVERY text column on the * row PLUS the full `specifications` JSON PLUS the * tokenised number columns PLUS the resolved supplier name. * * Together with `getPriceLists` (which now ORs against every * text column AND the search_text blob), typing anything — * "jeisa", "12mm", "118", "slugger", "turbo steel", "each" — * matches. * * Idempotent: re-runs only PATCH a row if the rebuilt value differs * from what's currently stored. Safe to run on every deploy. * * Run with: * npm run pb:backfill:price-list-search-text */ import PocketBase from "pocketbase"; import { buildPriceListSearchText, type SearchTextSource, } from "../../src/lib/price-list-search"; const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090"; const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL; const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD; if (!ADMIN_EMAIL || !ADMIN_PASSWORD) { throw new Error("PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD are required."); } type PriceListRow = SearchTextSource & { id: string; supplier?: string; source_file?: string; }; type RawPriceListRow = { id: string; supplier_name?: string | null; expand?: { supplier_name?: { id: string; suppliername?: string; }; }; }; /** * Look up the supplier name for a price_list row by following * `source_file` → `raw_price_list` → `expand.supplier_name`. * Returns the resolved name (e.g. "JEISA - WESTSCOTT GROUP") or * null if the chain is broken (manually-created row with no * source_file, or the raw_price_list was deleted). */ async function resolveSupplierName( pb: PocketBase, sourceFile: string | undefined, fallback: string | undefined, ): Promise { if (!sourceFile) return fallback ?? null; try { const raw = await pb .collection("raw_price_list") .getOne(sourceFile, { expand: "supplier_name" }); const name = raw.expand?.supplier_name?.suppliername; if (name) return name; } catch { // raw_price_list row is gone — fall back to whatever the // price_list row stored (could be an old id or a real name). } return fallback ?? null; } /** * PB ids are 15 lowercase alphanumeric characters (a-z, 0-9). * Used to decide whether a legacy `supplier` value is actually * an id that the row expects the API to expand. */ function looksLikePbId(value: string | undefined): boolean { return !!value && /^[a-z0-9]{15}$/.test(value); } async function main() { const pb = new PocketBase(PB_URL); await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL!, ADMIN_PASSWORD!); let page = 1; const perPage = 200; let scanned = 0; let patchedSearch = 0; let patchedSupplier = 0; let unchanged = 0; const sampleRebuilds: Array<{ id: string; before: string; after: string }> = []; // One pagination loop over every price_list row. while (true) { const result = await pb.collection("price_list").getList(page, perPage, { sort: "-updated", }); if (result.items.length === 0) break; for (const row of result.items) { scanned++; // 1. Resolve supplier name from raw_price_list. const resolvedSupplier = await resolveSupplierName( pb, row.source_file, looksLikePbId(row.supplier) ? "" : row.supplier, ); // 2. Build the new search_text. const newSearchText = buildPriceListSearchText(row, resolvedSupplier); // 3. Decide what to PATCH. const patch: Record = {}; if ((row as unknown as Record).search_text !== newSearchText) { patch.search_text = newSearchText; } if ( resolvedSupplier && resolvedSupplier !== row.supplier ) { patch.supplier = resolvedSupplier; } if (Object.keys(patch).length === 0) { unchanged++; } else { if (patch.search_text !== undefined) patchedSearch++; if (patch.supplier !== undefined) patchedSupplier++; if (sampleRebuilds.length < 3 && patch.search_text !== undefined) { sampleRebuilds.push({ id: row.id, before: String((row as unknown as Record).search_text ?? ""), after: newSearchText, }); } await pb.collection("price_list").update(row.id, patch); } } if (result.items.length < perPage) break; page++; } console.log(`[backfill] scanned ${scanned} rows across ${page} page(s).`); console.log(`[backfill] patched search_text: ${patchedSearch}, supplier: ${patchedSupplier}, unchanged: ${unchanged}`); if (sampleRebuilds.length > 0) { console.log("[backfill] sample rebuilds:"); for (const s of sampleRebuilds) { console.log(` ${s.id}:`); console.log(` before: ${s.before.slice(0, 120)}${s.before.length > 120 ? "…" : ""}`); console.log(` after: ${s.after.slice(0, 240)}${s.after.length > 240 ? "…" : ""}`); } } console.log("[backfill] done."); } main().catch((err) => { console.error(err); process.exit(1); });