#!/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})`)