diff --git a/packages/db/scripts/reset-db.mjs b/packages/db/scripts/reset-db.mjs index 3acc449..e66d89d 100644 --- a/packages/db/scripts/reset-db.mjs +++ b/packages/db/scripts/reset-db.mjs @@ -17,9 +17,17 @@ // their credentials/plates, blocklist. KEEPS users, devices, config, // tariffs, subscription PLANS. // --config site_config, devices, setup_state (re-runs first-run setup), -// tariffs + tariff_versions, subscription_plans. +// tariffs + tariff_versions + tariff_drafts, subscription_plans. // --users users, roles, role_permissions, auth sessions. (After this or --all, // re-seed an admin: apps/server/scripts/seed-admin.mjs.) +// --diagnostics app_logs (the unsigned diagnostic store behind the /setup/logs +// viewer). Separate from --financial: logs are evidence about the BOX, +// not the traffic — wipe them only when handing over a blank slate. +// +// DRIFT GUARD: before doing anything, the script compares the union of the categories +// above against the tables actually present in the DB and REFUSES if any table is +// uncategorized — so a new table can't silently survive resets (app_logs and +// tariff_drafts did exactly that until 2026-07-08). // // Safety gates (BOTH required): // 1. env RESET_ALLOWED=1 — a real booth never sets this. @@ -44,10 +52,39 @@ const CATEGORIES = { "subscriptions", "blocklist", ], - config: ["site_config", "devices", "setup_state", "tariff_versions", "tariffs", "subscription_plans"], + config: [ + "site_config", + "devices", + "setup_state", + "tariff_drafts", + "tariff_versions", + "tariffs", + "subscription_plans", + ], users: ["sessions", "role_permissions", "users", "roles"], + diagnostics: ["app_logs"], }; +/** Every user table in the DB must belong to a category above (internal bookkeeping + * like sqlite_* and drizzle's __* migration table excepted). Dies listing offenders — + * the fix is a one-line addition to CATEGORIES, decided deliberately, not by omission. */ +function assertNoUncategorizedTables(sqlite) { + const known = new Set(Object.values(CATEGORIES).flat()); + const actual = sqlite + .prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`) + .all() + .map((r) => r.name) + .filter((n) => !n.startsWith("sqlite_") && !n.startsWith("__")); + const uncategorized = actual.filter((n) => !known.has(n)); + if (uncategorized.length > 0) { + die( + `schema drift — table(s) not covered by any reset category: ${uncategorized.join(", ")}\n` + + ` add them to CATEGORIES in packages/db/scripts/reset-db.mjs (this guard exists so\n` + + ` new tables can't silently survive resets).`, + ); + } +} + function parseArgs(argv) { const flags = new Set(argv.filter((a) => a.startsWith("--")).map((a) => a.slice(2))); const wantAll = flags.has("all"); @@ -75,7 +112,7 @@ async function main() { const { cats, autoYes, wantAll } = parseArgs(process.argv.slice(2)); if (cats.length === 0) { - die("nothing to do — pass --all, --financial, --config, and/or --users"); + die("nothing to do — pass --all, --financial, --config, --users, and/or --diagnostics"); } // GATE 1: env opt-in. A production booth never sets this. @@ -86,6 +123,11 @@ async function main() { ); } + // Open early: the drift guard must run BEFORE anything is printed or confirmed, so + // an uncategorized table aborts the whole run rather than surviving a "successful" reset. + const sqlite = new Database(dbPath); + assertNoUncategorizedTables(sqlite); + // Resolve the ordered, de-duplicated table list for the chosen categories. const tables = []; for (const c of cats) for (const t of CATEGORIES[c]) if (!tables.includes(t)) tables.push(t); @@ -106,7 +148,6 @@ async function main() { if (!ok) die("confirmation did not match — aborted, nothing changed."); } - const sqlite = new Database(dbPath); try { // FKs OFF for the wipe so we can delete in any order without ordering hazards; // a single transaction makes it all-or-nothing.