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 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 3 days ago
parent 583b663aa6
commit 62eb937012

3
.gitignore vendored

@ -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/

@ -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" <dump>`.
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`.

@ -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",

@ -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-<ts>.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})`)
Loading…
Cancel
Save