/* eslint-disable */ /** * Migration — add `sku_normalised` (text, indexed) to `price_list`. * * The catalogue SKU is normally uppercased and dash-separated * (e.g. `JEITD-06`, `TS-DRL-12-55`). When a buyer's email / PO * references the same product they often write it differently — * lowercase x, no dashes, different ordering. The Check-pricing * feature's `findPriceListBySku()` matcher needs to find "this is * the same product" across those variants. * * `sku_normalised` is the uppercased, non-alphanumeric-stripped * form of `sku`. `JEITD-06` becomes `JEITD06`. `TS-DRL-12-55` * becomes `TSDRL1255`. The matcher uppercases + strips the buyer's * input the same way, then a direct string equality gets * canonical-form hits regardless of how the buyer typed it. * * Existing rows are backfilled once. * * Idempotent — re-runs add the column / index / backfill only if * missing. * * Run with: * npx tsx scripts/migrations/2026-06-30-add-price-list-sku-normalised.ts */ import PocketBase from "pocketbase"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; function loadEnvFile(name: string) { try { const raw = readFileSync(resolve(process.cwd(), name), "utf8"); for (const line of raw.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i); if (m && !process.env[m[1]]) { process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim(); } } } catch { /* fine */ } } loadEnvFile(".env"); loadEnvFile(".env.local"); /** Canonicalise a SKU: uppercase, strip everything that's not A-Z / 0-9. * `JEITD-06` → `JEITD06`. Empty string → empty string. */ function normaliseSku(raw: string | null | undefined): string { if (!raw) return ""; return raw.toUpperCase().replace(/[^A-Z0-9]/g, ""); } async function main() { const url = process.env.NEXT_PUBLIC_POCKETBASE_URL; const email = process.env.PB_ADMIN_EMAIL; const password = process.env.PB_ADMIN_PASSWORD; if (!url || !email || !password) { throw new Error( "Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (e.g. sourced from .env.local).", ); } const pb = new PocketBase(url); await pb.collection("_superusers").authWithPassword(email, password); console.log(`[price-list-sku-normalised] Authenticated against ${url}`); const priceList = await pb.collections.getOne("price_list"); // 1. Add the column if missing. if (!priceList.fields.some((f) => f.name === "sku_normalised")) { await pb.collections.update(priceList.id, { fields: [ ...priceList.fields, { name: "sku_normalised", type: "text", required: false, presentable: false, options: { max: 100, pattern: "" }, }, ], }); console.log("[price-list-sku-normalised] Added price_list.sku_normalised."); } else { console.log("[price-list-sku-normalised] price_list.sku_normalised already present — skipping."); } // 2. Add the index if missing. const fresh = await pb.collections.getOne("price_list"); const hasIdx = (fresh.indexes ?? []).some( (i) => typeof i === "string" && i.includes("idx_pl_sku_normalised"), ); if (!hasIdx) { await pb.collections.update(fresh.id, { indexes: [ ...(fresh.indexes ?? []), "CREATE INDEX `idx_pl_sku_normalised` ON `price_list` (`sku_normalised`)", ], }); console.log("[price-list-sku-normalised] Added idx_pl_sku_normalised."); } else { console.log("[price-list-sku-normalised] idx_pl_sku_normalised already present — skipping."); } // 3. Backfill — paginate, compute sku_normalised per row, PATCH if // it differs from the stored value. Cheap with 46 rows; on 10k // rows the index makes this O(rows). let page = 1; const perPage = 200; let scanned = 0; let patched = 0; while (true) { const batch = await pb.collection("price_list").getList(page, perPage, { fields: "id,sku,sku_normalised", }); if (batch.items.length === 0) break; for (const row of batch.items) { scanned++; const expected = normaliseSku(row.sku as string | null); if ((row as unknown as { sku_normalised?: string }).sku_normalised !== expected) { await pb.collection("price_list").update(row.id, { sku_normalised: expected }); patched++; } } if (batch.items.length < perPage) break; page++; } console.log(`[price-list-sku-normalised] Backfilled ${patched} / ${scanned} rows.`); console.log("[price-list-sku-normalised] Migration complete."); } main().catch((err) => { console.error("[price-list-sku-normalised] Migration failed:", err); process.exit(1); });