#!/usr/bin/env node // DESTRUCTIVE training/demo reset of the SQLite DB at DATABASE_URL. Deletes rows from // whole CATEGORIES of tables so a site can be re-used to TRAIN operators/admins without // leaving demo data behind. This is intentionally a CLI script (no UI button) so it // cannot be triggered casually — and it is double-gated so it never runs on a real booth. // // ⚠ This TRUNCATES the append-only, hash-chained, SIGNED ledger (`ledger_events`). // That is the anti-fraud record. A partial delete would break the chain, so a // financial reset wipes the whole ledger back to empty (re-seeding starts a NEW // chain under the same EVENT_SIGNING_KEY — the key is NOT touched here). Only ever // do this on a TRAINING/DEMO box. See wiki/concepts/append-only-event-chain.md. // // Flags (combinable; at least one required): // --all every category below (a blank-slate box) // --financial transactional history: ledger (entry/exit/payment/void/shift/cash/ // anomaly), device telemetry, snapshots, subscription INSTANCES + // 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 + 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. // 2. type the DB filename — interactive confirmation (skip with --yes ONLY in CI). // // Usage: // RESET_ALLOWED=1 DATABASE_URL=apps/server/parking.sqlite \ // node packages/db/scripts/reset-db.mjs --financial import { createInterface } from "node:readline"; import { basename, resolve } from "node:path"; import { existsSync } from "node:fs"; import Database from "better-sqlite3"; // --- Category → tables (child tables BEFORE parents; we also disable FKs for the txn). --- const CATEGORIES = { financial: [ "ledger_events", "device_events", "snapshots", "subscription_plates", "subscription_credentials", "subscriptions", "blocklist", // Car Wash (venue module): orders are money history (settled against ledger events); // the review outbox is a delivery queue of crops + choices — both go with the ledger. "carwash_review_outbox", "carwash_orders", ], config: [ "site_config", "devices", "setup_state", "tariff_drafts", "tariff_versions", "tariffs", "subscription_plans", // Merchant validation programs (bar/lavazh) + their user bindings (child first). // A --users reset without --config may orphan a binding row; harmless — a binding // whose user is gone grants nothing. "validation_program_users", "validation_programs", // Car Wash master data: prices reference categories + services (child first); the // module's site-level config (pay-at, vision threshold) is config like site_config. "carwash_prices", "carwash_categories", "carwash_services", "carwash_config", ], // role_jobs = which jobs a role follows (child of roles). users: ["sessions", "role_permissions", "role_jobs", "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"); const cats = wantAll ? Object.keys(CATEGORIES) : Object.keys(CATEGORIES).filter((c) => flags.has(c)); return { cats, autoYes: flags.has("yes"), wantAll }; } function die(msg) { console.error(`[reset] ${msg}`); process.exit(1); } async function confirm(promptText, expected) { const rl = createInterface({ input: process.stdin, output: process.stdout }); const answer = await new Promise((res) => rl.question(promptText, res)); rl.close(); return answer.trim() === expected; } async function main() { const url = process.env.DATABASE_URL; if (!url) die("DATABASE_URL is required"); const dbPath = resolve(url); if (!existsSync(dbPath)) die(`no database at ${dbPath}`); const { cats, autoYes, wantAll } = parseArgs(process.argv.slice(2)); if (cats.length === 0) { die("nothing to do — pass --all, --financial, --config, --users, and/or --diagnostics"); } // GATE 1: env opt-in. A production booth never sets this. if (process.env.RESET_ALLOWED !== "1") { die( `refusing to reset ${dbPath}\n` + ` set RESET_ALLOWED=1 to enable (real booths never set this).`, ); } // 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); console.error(`\n⚠ DESTRUCTIVE RESET`); console.error(` db : ${dbPath}`); console.error(` categories: ${cats.join(", ")}${wantAll ? " (= everything)" : ""}`); console.error(` tables : ${tables.join(", ")}`); if (cats.includes("financial")) { console.error(` NOTE: this TRUNCATES the signed append-only ledger. Training/demo only.`); } console.error(""); // GATE 2: typed confirmation of the DB filename (skippable only with --yes, for CI). if (!autoYes) { const fname = basename(dbPath); const ok = await confirm(`Type the db filename to confirm (${fname}): `, fname); if (!ok) die("confirmation did not match — aborted, nothing changed."); } try { // FKs OFF for the wipe so we can delete in any order without ordering hazards; // a single transaction makes it all-or-nothing. sqlite.pragma("foreign_keys = OFF"); const wipe = sqlite.transaction(() => { const counts = {}; for (const t of tables) { const before = sqlite.prepare(`SELECT COUNT(*) AS n FROM "${t}"`).get().n; sqlite.prepare(`DELETE FROM "${t}"`).run(); counts[t] = before; } return counts; }); const counts = wipe(); sqlite.pragma("foreign_keys = ON"); // Reclaim space + reset the WAL so the file shrinks (demo boxes get re-used a lot). sqlite.exec("VACUUM"); console.error(`[reset] done. Rows deleted:`); for (const t of tables) console.error(` ${String(counts[t]).padStart(7)} ${t}`); if (cats.includes("users") || wantAll) { console.error( `\n[reset] users were cleared — re-seed an admin:\n` + ` ADMIN_USER=admin ADMIN_PASS='…' node apps/server/scripts/seed-admin.mjs`, ); } } finally { sqlite.close(); } } main().catch((e) => die(e.message));