#!/usr/bin/env node /** * africa-alert-backup.js * ---------------------- * Cross-platform Node fallback for `africa-alert-backup.sh`. * * Why this exists: * The shell script in this directory uses the `sqlite3` CLI's `.backup` * command, which is reliable on Linux but is not installed by default on * Windows. This Node script uses `better-sqlite3`'s `.backup(path)` API * — the same primitive under the hood, available wherever the project's * server dependencies are installed. * * Usage: * # From the repo root (where node_modules/server is the project's server): * DB_PATH=server/data/school.db \ * BACKUP_DIR=./backups \ * node scripts/backup/africa-alert-backup.js * * Environment variables (all optional, all honoured by the shell script too): * DB_PATH Live SQLite database file. Default: server/data/school.db * relative to the repo root (two levels up from this file). * BACKUP_DIR Directory to write the timestamped .db snapshot. * Default: ./backups relative to the repo root. * LOG_DIR Directory for the human-readable log file. * Default: /logs. * RETAIN_DAILY Number of most-recent daily snapshots to keep. Default: 30. * * Exit codes (matched to the shell script for parity): * 0 backup succeeded * 1 configuration error (missing tool, unreadable DB, etc.) * 2 backup itself failed * 3 retention sweep failed (the snapshot itself is still on disk) * * This script is safe to run while the server is up: SQLite's online * backup API flushes the WAL before copying and never produces a torn * snapshot, even if writers are active. */ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); // Resolve repo root (scripts/backup/.js -> two levels up). const SCRIPT_DIR = __dirname; const PROJECT_ROOT = path.resolve(SCRIPT_DIR, '..', '..'); const DB_PATH = process.env.DB_PATH || path.join(PROJECT_ROOT, 'server', 'data', 'school.db'); const BACKUP_DIR = process.env.BACKUP_DIR || path.join(PROJECT_ROOT, 'backups'); const LOG_DIR = process.env.LOG_DIR || path.join(PROJECT_ROOT, 'logs'); const RETAIN = parseInt(process.env.RETAIN_DAILY || '30', 10); const TIMESTAMP = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-'); const BACKUP_FILE = path.join(BACKUP_DIR, `school-${TIMESTAMP}.db`); const LOG_FILE = path.join(LOG_DIR, 'backup.log'); function log(line) { const stamped = `[${new Date().toISOString()}] [backup-js] ${line}\n`; fs.mkdirSync(LOG_DIR, { recursive: true }); fs.appendFileSync(LOG_FILE, stamped); process.stderr.write(stamped); } function fail(code, msg) { log(`ERROR: ${msg}`); process.exit(code); } async function main() { if (!fs.existsSync(DB_PATH)) { fail(1, `DB_PATH does not exist or is not a regular file: ${DB_PATH}`); } try { fs.accessSync(DB_PATH, fs.constants.R_OK); } catch { fail(1, `DB_PATH is not readable: ${DB_PATH}`); } fs.mkdirSync(BACKUP_DIR, { recursive: true }); log(`starting backup of ${DB_PATH} -> ${BACKUP_FILE}`); let Database; try { // Resolve better-sqlite3 against this script's dir first, then fall // back to /server/node_modules. The first path makes the script // self-contained; the second lets us share the server's installed // native bindings when invoked from outside server/. const Module = require('module'); const candidates = [ path.join(SCRIPT_DIR, 'node_modules', 'better-sqlite3'), path.join(PROJECT_ROOT, 'node_modules', 'better-sqlite3'), path.join(PROJECT_ROOT, 'server', 'node_modules', 'better-sqlite3'), ]; let resolved = null; for (const c of candidates) { try { resolved = require.resolve(c); break; } catch {} } if (!resolved) throw new Error('not found in: ' + candidates.join(', ')); Database = require(resolved); } catch (e) { fail(1, `better-sqlite3 not available: ${e.message}. Run 'npm install' inside server/ first.`); } let db; try { db = new Database(DB_PATH, { readonly: true, fileMustExist: true }); } catch (e) { fail(2, `could not open ${DB_PATH}: ${e.message}`); } try { // SQLite online backup API: flushes the WAL, then copies page-by-page. // No torn snapshots even if writers are active. await db.backup(BACKUP_FILE); } catch (e) { try { db.close(); } catch {} fail(2, `sqlite backup failed: ${e.message}`); } db.close(); if (!fs.existsSync(BACKUP_FILE) || fs.statSync(BACKUP_FILE).size === 0) { fail(2, `backup file was not created or is empty: ${BACKUP_FILE}`); } // Copy WAL/SHM sidecars if present, so the snapshot is self-contained. for (const ext of ['wal', 'shm']) { const side = `${DB_PATH}-${ext}`; if (fs.existsSync(side)) { fs.copyFileSync(side, `${BACKUP_FILE}-${ext}`); log(`copied sidecar ${side} -> ${BACKUP_FILE}-${ext}`); } } // sha256 for off-host transfer verification. try { const buf = fs.readFileSync(BACKUP_FILE); const hash = crypto.createHash('sha256').update(buf).digest('hex'); fs.writeFileSync(`${BACKUP_FILE}.sha256`, `${hash} ${path.basename(BACKUP_FILE)}\n`); log(`wrote sha256 checksum: ${hash}`); } catch (e) { log(`WARN: could not write sha256: ${e.message}`); } // Retention sweep: keep newest RETAIN snapshots. let deleted = 0; try { const files = fs.readdirSync(BACKUP_DIR) .filter(f => f.startsWith('school-') && f.endsWith('.db')) .map(f => ({ f, mtime: fs.statSync(path.join(BACKUP_DIR, f)).mtimeMs })) .sort((a, b) => b.mtime - a.mtime); const stale = files.slice(RETAIN); for (const entry of stale) { for (const ext of ['', '-wal', '-shm', '.sha256']) { const victim = path.join(BACKUP_DIR, entry.f + ext); if (fs.existsSync(victim)) { try { fs.unlinkSync(victim); } catch {} } } deleted++; log(`retention: deleted old backup ${entry.f}`); } } catch (e) { log(`ERROR: retention sweep failed: ${e.message}`); process.exit(3); } const size = fs.statSync(BACKUP_FILE).size; log(`done. file=${BACKUP_FILE} size=${size} bytes retention_deleted=${deleted}`); process.exit(0); } main().catch((e) => { fail(2, `unhandled error: ${e && e.message ? e.message : String(e)}`); });