From 62eb93701222f5544c425a2d0d62703006ea670a Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sun, 19 Jul 2026 11:46:03 +0530 Subject: [PATCH] feat(d33): database backup script (pg_dump + rotation) scripts/backup.mjs (npm run backup): pg_dump the DB to a timestamped compressed pg_restore-able file under ./backups (gitignored), keep newest HQ_BACKUP_KEEP (14). Locates pg_dump on PATH or the Windows PostgreSQL install dir. Verified against live data: 304 KB dump, 32 tables, valid archive. Backups hold plaintext creds (D32) so backups/ is gitignored + documented as access-controlled. Pending: schedule + off-box copy + DB password out of server.ts. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 ++ docs/06-DECISIONS.md | 10 ++++++ package.json | 3 +- scripts/backup.mjs | 78 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 scripts/backup.mjs diff --git a/.gitignore b/.gitignore index 73af595..b5f55e6 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ ruvector.db # Real APEX data exports (contain plaintext credentials) - never commit /Apex/ + +# Database backups (D33) — contain plaintext credentials (D32); never commit +backups/ diff --git a/docs/06-DECISIONS.md b/docs/06-DECISIONS.md index 96719b7..0fa6187 100644 --- a/docs/06-DECISIONS.md +++ b/docs/06-DECISIONS.md @@ -568,3 +568,13 @@ per-poll telemetry (ok/error + time) lives in the unaudited `sms_balance_check` the scheduler runs one pass per calendar day (`maybeRefreshSmsDaily`, claims the day via a setting). **Requires `HQ_SECRET_KEY`** set to the import-time key to decrypt — without it the refresh no-ops with a clear message, it never stores anything unencrypted. +(Superseded by D31/D32: the key was removed; the pull now runs keyless.) + +## D33 — Database backups (ops, 2026-07-19) +`scripts/backup.mjs` (`npm run backup`) `pg_dump`s the database to a timestamped, compressed, +`pg_restore`-able file under `./backups` (gitignored), keeping the newest `HQ_BACKUP_KEEP` +(default 14). It finds `pg_dump` on PATH or in the Windows `C:\Program Files\PostgreSQL\*\bin` +install. Restore: `pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" `. +Priority rose with D32 — dumps now contain **plaintext credentials**, so the `backups/` dir +must stay access-controlled and off shared drives. Still pending in the ops slice: schedule +it (Task Scheduler / cron), off-box copy, and getting the DB password out of `server.ts`. diff --git a/package.json b/package.json index 323cf9c..99224ad 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "scripts": { "test": "vitest run", "test:watch": "vitest", - "typecheck": "tsc -p tsconfig.json && npm run typecheck --workspaces --if-present" + "typecheck": "tsc -p tsconfig.json && npm run typecheck --workspaces --if-present", + "backup": "node scripts/backup.mjs" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/scripts/backup.mjs b/scripts/backup.mjs new file mode 100644 index 0000000..0fb0081 --- /dev/null +++ b/scripts/backup.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * SiMS HQ database backup (ops slice). Dumps the Postgres database to a timestamped, + * compressed file under ./backups (or $HQ_BACKUP_DIR), then rotates to the newest N. + * + * DATABASE_URL=postgres://user:pass@host:5432/db node scripts/backup.mjs + * npm run backup (from apps/hq — passes DATABASE_URL through) + * + * Restore a dump with: + * pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" backups/hq-.dump + * + * SECURITY (D32): credentials are stored in the CLEAR, so these dumps contain plaintext + * logins. Keep the backups/ directory access-controlled and off any shared drive; it is + * gitignored so it can never be committed. + * + * Env: DATABASE_URL (required), HQ_BACKUP_DIR (default ./backups), HQ_BACKUP_KEEP (default 14). + */ +import { execFileSync } from 'node:child_process' +import { mkdirSync, readdirSync, statSync, unlinkSync, existsSync } from 'node:fs' +import { join } from 'node:path' + +const url = process.env.DATABASE_URL +if (!url || url.trim() === '') { + console.error('DATABASE_URL is not set. Run: DATABASE_URL=postgres://… node scripts/backup.mjs') + process.exit(1) +} + +/** Locate pg_dump: PATH first, then a standard Windows PostgreSQL install. */ +function findPgDump() { + try { + execFileSync(process.platform === 'win32' ? 'where' : 'which', ['pg_dump'], { stdio: 'ignore' }) + return 'pg_dump' + } catch { /* not on PATH */ } + if (process.platform === 'win32') { + const base = 'C:/Program Files/PostgreSQL' + try { + for (const v of readdirSync(base).sort().reverse()) { + const cand = join(base, v, 'bin', 'pg_dump.exe') + if (existsSync(cand)) return cand + } + } catch { /* no install dir */ } + } + return null +} + +const pgDump = findPgDump() +if (pgDump === null) { + console.error('pg_dump not found. Install the PostgreSQL client tools, or add pg_dump to PATH.') + process.exit(1) +} + +const OUT = process.env.HQ_BACKUP_DIR || join(process.cwd(), 'backups') +const KEEP = Math.max(1, Number(process.env.HQ_BACKUP_KEEP || 14)) +mkdirSync(OUT, { recursive: true }) + +const d = new Date() +const p2 = (n) => String(n).padStart(2, '0') +const ts = `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}` +const file = join(OUT, `hq-${ts}.dump`) + +// -Fc = custom, compressed, restorable with pg_restore. --no-owner keeps it portable. +try { + execFileSync(pgDump, ['-Fc', '--no-owner', '-f', file, url], { stdio: ['ignore', 'inherit', 'inherit'] }) +} catch (e) { + console.error(`pg_dump failed: ${e instanceof Error ? e.message : String(e)}`) + process.exit(1) +} + +const kb = (statSync(file).size / 1024).toFixed(1) +console.log(`✓ backup written: ${file} (${kb} KB)`) + +// Rotation: keep the newest KEEP hq-YYYYMMDD-HHMMSS.dump files. +const dumps = readdirSync(OUT).filter((f) => /^hq-\d{8}-\d{6}\.dump$/.test(f)).sort() +for (const f of dumps.slice(0, Math.max(0, dumps.length - KEEP))) { + unlinkSync(join(OUT, f)) + console.log(` rotated out: ${f}`) +} +console.log(`retained ${Math.min(dumps.length, KEEP)} backup(s) in ${OUT} (HQ_BACKUP_KEEP=${KEEP})`)