d92b8d1e6a
A site is sometimes run live to train operators/admins; afterwards the demo data must go without an obvious self-serve button (the operator must not be able to wipe history). Adds packages/db/scripts/reset-db.mjs, exposed as `pnpm db:reset` (dev) and run via `docker exec ... node node_modules/@parking/db/scripts/reset-db.mjs` on the booth (no pnpm there). Category flags (combinable): --financial (ledger + telemetry + snapshots + subscription instances + blocklist; keeps users/devices/config/tariffs/plans), --config, --users, --all. Shifts/cash/payments live as event types inside the hash-chained ledger_events, so --financial truncates the whole signed ledger back to empty (re-seed starts a new chain under the SAME EVENT_SIGNING_KEY — keys untouched). Two safety gates: RESET_ALLOWED=1 env (a real booth never sets it) + typed DB-filename confirmation (--yes skips for CI). Single transaction + VACUUM. Verified on throwaway dev-DB copies: both gates refuse correctly; each flag wipes/keeps the right tables; the real dev DB is never touched. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
142 lines
5.7 KiB
JavaScript
142 lines
5.7 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, subscription_plans.
|
|
// --users users, roles, role_permissions, auth sessions. (After this or --all,
|
|
// re-seed an admin: apps/server/scripts/seed-admin.mjs.)
|
|
//
|
|
// 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_versions", "tariffs", "subscription_plans"],
|
|
users: ["sessions", "role_permissions", "users", "roles"],
|
|
};
|
|
|
|
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, and/or --users");
|
|
}
|
|
|
|
// 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).`,
|
|
);
|
|
}
|
|
|
|
// 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.");
|
|
}
|
|
|
|
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.
|
|
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));
|