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
parent
583b663aa6
commit
62eb937012
@ -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…
Reference in New Issue