Files
parking_solution/packages/db/scripts/reset-db.mjs
T
julian ba5b4b1f4e fix(reset-db): app_logs + tariff_drafts were uncategorized — add a drift guard
Both tables belonged to NO reset category and silently survived every
reset, --all included (the hand-maintained table list lagged the schema
twice). app_logs gets a new --diagnostics category; tariff_drafts joins
--config. A drift guard now compares the category union against
sqlite_master before doing anything and refuses on any uncategorized
table, so the next new table forces a deliberate one-line decision instead
of escaping by omission. Verified on a scratch DB: guard refuses a planted
table (exit 1), --all lists both new tables, --diagnostics wipes app_logs.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-10 08:29:52 +02:00

183 lines
7.5 KiB
JavaScript

#!/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",
],
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");
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));