176 lines
6.3 KiB
JavaScript
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)}`);
|
|
}); |