geocrop-platform./apps/nextgen/scripts/backup/africa-alert-backup.js

176 lines
6.3 KiB
JavaScript

#!/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: <repo root>/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/<this>.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 <repo>/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)}`);
});