#!/usr/bin/env node // scripts/check-pb-schema.mjs // // Pre-build sanity check: probes every PocketBase filter used by the driver // app against the live server and reports any reference to an unknown // field. Catches the exact "added a clause to a filter, forgot to add the // field to PB" class of bug that produced the July-2026 4×400 incident. // // Usage: // npm run check-pb-schema // // Env (optional — defaults match this project's dev PB + e2e creds): // NEXT_PUBLIC_POCKETBASE_URL=https://your-pb.example.com // PB_USER_EMAIL=... // PB_USER_PASSWORD=... // // Exit codes: // 0 all filters and identifiers parse against the live schema // 1 one or more filters reference unknown fields // 2 couldn't authenticate against PB (fix env / network first) import PocketBase from 'pocketbase'; // --- config --------------------------------------------------------- const PB = process.env.NEXT_PUBLIC_POCKETBASE_URL || 'https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io'; const EMAIL = process.env.PB_USER_EMAIL || 'david.els1@outlook.com'; const PASSWORD = process.env.PB_USER_PASSWORD || '1234567890'; // One row per `filter:` string in `lib/pocketbase.ts`. Literal values are // placeholders — we don't want to MATCH data, we want the filter to PARSE. // PB validates identifier names at the parser level and 400s the moment // it hits one it doesn't know, regardless of the value. const FILTERS = [ // Assigned-tab filter with the yesterday+today window. The ISO // timestamps here are placeholders — PB only validates field names // and operators at parse time, so any well-formed ISO string works. // Keep in sync with getAssignedDayWindow in lib/pocketbase.ts. { collection: 'order_items', filter: 'drv_id = "placeholder" && item_status = "assigned" && assigned_at >= "2026-01-01T00:00:00.000Z" && assigned_at <= "2026-12-31T23:59:59.999Z"' }, { collection: 'order_items', filter: 'item_status = "collection_pool"' }, { collection: 'order_items', filter: 'problem_flag = true || item_status = "problem"' }, // Stock pickups live in their own collection — see // getMyAssignedStockItems in lib/pocketbase.ts. The driver app // was silently dropping these rows before this filter was added. { collection: 'stock_purchase_items', filter: 'drv_id = "placeholder" && status = "assigned_to_driver"' }, { collection: 'driver_locations', filter: 'relation = "placeholder" && timestamp >= "2026-01-01T00:00:00Z"' }, ]; // --- helpers (declared up here so they're in scope for the loop) ---- function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; } // PB operator tokens we want to skip when picking identifiers out of a // filter string. Anything not in here AND not in a quoted string AND // matching `/^[a-zA-Z_]…/` is treated as a candidate field name. const OPS = new Set(['&&', '||', '!=', '=', '<', '>', '<=', '>=']); function extractIdentifiers(filter) { const out = new Set(); // Strip quoted strings so their contents aren't picked up as idents. const stripped = filter.replace(/"[^"]*"/g, ''); for (const tok of stripped.split(/[\s()]+/)) { if (!tok) continue; if (OPS.has(tok)) continue; if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(tok)) continue; // Reduce `expand.path` to just `expand` for the bisect probe — // the relation FIELD is what PB validates, not the remote column. out.add(tok.split('.')[0]); } return [...out]; } // --- run ------------------------------------------------------------ const pb = new PocketBase(PB); pb.autoCancellation(false); console.log(`[check-pb-schema] target: ${PB}`); try { await pb.collection('users').authWithPassword(EMAIL, PASSWORD); } catch (e) { console.error(`[check-pb-schema] FATAL: cannot authenticate — ${e.message}`); console.error(` check NEXT_PUBLIC_POCKETBASE_URL / PB_USER_EMAIL / PB_USER_PASSWORD`); process.exit(2); } console.log(`[check-pb-schema] auth: OK (as ${pb.authStore.record?.id ?? EMAIL})\n`); let failed = 0; // Pass 1 — probe each filter at face value. Most filters are happy // here and the loop is fast. console.log('Pass 1 — probe every filter at face value:'); for (const { collection, filter } of FILTERS) { try { await pb.collection(collection).getList(1, 1, { filter }); console.log(` [OK] ${collection.padEnd(24)} ${truncate(filter, 70)}`); } catch (e) { failed++; console.log(` [FAIL] ${collection.padEnd(24)} ${truncate(filter, 70)}`); console.log(` status=${e.status ?? '??'} msg="${e.message}"`); // Pass 2 — bisect: probe each top-level identifier in the filter // as its own one-clause filter. Whatever 400s is an unknown field. // Unknown fields 400 regardless of the operator/value used. const suspects = extractIdentifiers(filter); for (const id of suspects) { try { await pb.collection(collection).getList(1, 1, { filter: `${id} = ""` }); console.log(` · ok : ${id}`); } catch (probeErr) { console.log(` ⚠ unknown : ${id} (PB: ${probeErr.message})`); } } } } console.log(''); if (failed > 0) { console.error(`[check-pb-schema] ${failed} filter(s) reference unknown fields.`); console.error(`[check-pb-schema] Either add the field to PB, or remove the clause from the filter.`); console.error(`[check-pb-schema] See lib/pocketbase.ts header for verified-live schema notes.`); process.exit(1); } console.log(`[check-pb-schema] all filters valid.`);