b742230d94
FOUNDATION-FIRST re-order, phase 1 (tooling + safety). BACKUP (highest-severity open item) — INSTALLED, not re-proven. src/backupScheduler.js runs scripts/backup-db.sh nightly from inside the API container, armed at boot in server.js. The container already has SUPABASE_DB_URL, pg_dump and the Supabase route, so deploy == installed: no host crontab, no Coolify click. Arming is deliberately opt-OUT (armed whenever SUPABASE_DB_URL exists; BACKUP_CRON=0 kills it) because the S62 design was opt-in and nobody ever opted in — the DB went unbacked every night for weeks. A failed run pages high-priority ntfy; silence is the danger with backups. Durability is the one part still needing a human: the container FS is ephemeral, so a dump dies on redeploy unless BACKUP_REMOTE (off-box rsync) or BACKUP_DIR (persistent volume) is set. The scheduler detects that and pages a WARNING at boot rather than letting an undurable backup read as "backed up". Runbook rewritten to lead with the code path. MANUAL REGRADE TRIGGER — scripts/run-snapshot.js, runnable via docker exec with no VYNDR_INTERNAL_KEY and no new HTTP surface. Runs the SAME snapshotService.runSnapshot the cron runs (including the team-stats refresh that powers opp_rank_stat), supports `all` and `--settle`, and prints the grade/confidence distribution plus p_win/ev_pct presence — which is the thing you actually want when verifying a grading change. ACCESS BLOCKER, logged honestly in specs/model-train.md: there is no VYNDR_INTERNAL_KEY in the local .env and SSH to the box times out from WSL2, so I can neither curl the internal endpoints (which already exist from S45) nor docker exec. The trigger is built and correct but only Kev can run it until a key or SSH access exists. This is the highest-leverage unblock for phases 2 and 3, which both need on-demand regrade+settle to verify anything. Also logged the standing cautions: CLV ledger stays private until backtest-proven; "self-improving model" is unsupported marketing until the loop closes; the engine is MLB/WNBA-calibrated and NFL/NBA/soccer need their own calibration before the hub grades them (scaling gate). Suite 277/3300 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
100 lines
3.5 KiB
JavaScript
100 lines
3.5 KiB
JavaScript
/**
|
|
* Session 64 — nightly backup scheduler.
|
|
*
|
|
* The S62 backup script was mechanism-verified but never installed, so the
|
|
* database went unbacked every night. These lock the arming semantics that make
|
|
* "deploy == installed" true, and the honesty rules around durability.
|
|
*/
|
|
|
|
const sched = require('../../src/backupScheduler');
|
|
|
|
describe('arming (opt-OUT, because opt-in is what failed)', () => {
|
|
test('armed whenever SUPABASE_DB_URL is present', () => {
|
|
expect(sched.shouldArm({ SUPABASE_DB_URL: 'postgres://x' }).armed).toBe(true);
|
|
});
|
|
|
|
test('BACKUP_CRON=0 is the kill switch', () => {
|
|
const r = sched.shouldArm({ SUPABASE_DB_URL: 'postgres://x', BACKUP_CRON: '0' });
|
|
expect(r.armed).toBe(false);
|
|
expect(r.reason).toMatch(/kill switch/);
|
|
});
|
|
|
|
test('no SUPABASE_DB_URL → disarmed with a reason (nothing to dump)', () => {
|
|
const r = sched.shouldArm({});
|
|
expect(r.armed).toBe(false);
|
|
expect(r.reason).toMatch(/SUPABASE_DB_URL/);
|
|
});
|
|
|
|
test('does NOT require an opt-in env var to arm', () => {
|
|
// The whole S62 failure: BACKUP_CRON=1 was never set by anyone.
|
|
expect(sched.shouldArm({ SUPABASE_DB_URL: 'postgres://x' }).armed).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('durability honesty', () => {
|
|
test('warns when neither a volume nor an off-box target is configured', () => {
|
|
expect(sched.durabilityWarning({})).toMatch(/ephemeral/);
|
|
});
|
|
|
|
test('no warning once BACKUP_REMOTE is set', () => {
|
|
expect(sched.durabilityWarning({ BACKUP_REMOTE: 'u1@box:vyndr/' })).toBeNull();
|
|
});
|
|
|
|
test('no warning once BACKUP_DIR is set (persistent volume)', () => {
|
|
expect(sched.durabilityWarning({ BACKUP_DIR: '/var/backups/vyndr' })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('scheduling', () => {
|
|
const env = { SUPABASE_DB_URL: 'postgres://x', BACKUP_REMOTE: 'u1@box:v/' };
|
|
|
|
test('runs at the configured minute, and only once per day', async () => {
|
|
const runs = [];
|
|
const at = (h, m, day = 19) => new Date(Date.UTC(2026, 6, day, h, m));
|
|
let clock = at(3, 10);
|
|
const s = sched.startBackupScheduler({
|
|
env,
|
|
now: () => clock,
|
|
notify: async () => {},
|
|
runBackup: async () => { runs.push(clock.toISOString()); return { ok: true, code: 0, ms: 1 }; },
|
|
});
|
|
await s.tick();
|
|
await s.tick(); // same minute again — must not re-run
|
|
expect(runs).toHaveLength(1);
|
|
|
|
clock = at(3, 10, 20); // next day
|
|
await s.tick();
|
|
expect(runs).toHaveLength(2);
|
|
});
|
|
|
|
test('does nothing outside the scheduled minute', async () => {
|
|
const runs = [];
|
|
const s = sched.startBackupScheduler({
|
|
env,
|
|
now: () => new Date(Date.UTC(2026, 6, 19, 14, 0)),
|
|
notify: async () => {},
|
|
runBackup: async () => { runs.push(1); return { ok: true, code: 0, ms: 1 }; },
|
|
});
|
|
await s.tick();
|
|
expect(runs).toHaveLength(0);
|
|
});
|
|
|
|
test('a failed backup pages high priority — silence is the danger', async () => {
|
|
const alerts = [];
|
|
const s = sched.startBackupScheduler({
|
|
env,
|
|
now: () => new Date(Date.UTC(2026, 6, 19, 3, 10)),
|
|
notify: async (msg, opts) => { alerts.push({ msg, opts }); },
|
|
runBackup: async () => ({ ok: false, code: 2, ms: 5 }),
|
|
});
|
|
await s.tick();
|
|
expect(alerts).toHaveLength(1);
|
|
expect(alerts[0].opts.priority).toBe('high');
|
|
expect(alerts[0].msg).toMatch(/FAILED/);
|
|
});
|
|
|
|
test('disarmed scheduler returns null and schedules nothing', () => {
|
|
expect(sched.startBackupScheduler({ env: {}, notify: async () => {} })).toBeNull();
|
|
});
|
|
});
|