backups: a schedule, a location, and a timer that survives a restart
Restore goes. What it would read from was never decided, and a disabled button with a tooltip explaining it is not built is worse than no button. In its place, backups. `cron` was already a dependency and used nowhere, so the schedule is a real cron expression — a backup wants to happen at an HOUR, and an interval counted from whenever somebody pressed save drifts across the clock until it runs mid-afternoon. Cron alone is not enough, and this is the part worth writing down: it does not run a job it was down for. A daily backup at three on a machine that restarts overnight is skipped silently — the schedule is still there, the next fire is still in the future, and a month later there is one file. So `lastBackupAt` is in the database, and both the startup and a fifteen-minute tick compare it against the schedule's own period, computed from two successive fires. A backup that should already have happened happens now. The job handle is module-level and stopped before a new one is made. Two saves leaving two jobs is a bug that surfaces as backups arriving in pairs, weeks later, with nothing connecting it to a settings form. Back up now exists because a scheduled job nobody has ever run by hand is a guess — it is how you learn the location is writable and pg_dump can reach the databases without waiting until three to find out it cannot. The list underneath is a readdir of the location rather than a record of what should be there: a schedule that has been failing quietly looks exactly like a working one until you count the files. pg_dump per database, not pg_dumpall — a cluster dump would also carry Officer's own, which is not this plugin's to copy around. And a failed run still records the timestamp, or one undumpable database would have the catch-up tick retrying every fifteen minutes for the rest of the week. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import { readSync, startSync } from './sync';
|
||||
import { readRuns } from './ledger';
|
||||
import { reconcile } from './derive';
|
||||
import { createDatabases, pushSchema, readDatabases } from './databases';
|
||||
import { readBackups, runBackup, saveBackups, schedule } from './backups';
|
||||
import { join } from 'node:path';
|
||||
import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client';
|
||||
|
||||
@@ -320,6 +321,32 @@ router.post('/_config/databases/create', async (ctx) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Backups — the schedule, the location, and one on demand.
|
||||
*
|
||||
* `POST /run` exists so somebody can prove the location and the credentials work without waiting until
|
||||
* three in the morning to find out they do not.
|
||||
*/
|
||||
router.get('/_config/backups', async (ctx) => ctx.json(await readBackups()));
|
||||
|
||||
router.put('/_config/backups', async (ctx) => {
|
||||
const { cron, dir } = (ctx.get('body') ?? {}) as { cron?: string; dir?: string };
|
||||
try {
|
||||
return ctx.json(await saveBackups(cron ?? '', dir ?? ''));
|
||||
} catch (error) {
|
||||
return ctx.json({ error: (error as Error).message }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/_config/backups/run', async (ctx) => {
|
||||
try {
|
||||
const result = await runBackup();
|
||||
return ctx.json({ ...result, settings: await readBackups() }, result.ok ? 200 : 400);
|
||||
} catch (error) {
|
||||
return ctx.json({ error: (error as Error).message }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/_config/summary', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const settings = await readSettings(user.email);
|
||||
@@ -418,3 +445,13 @@ router.all('/*', async (ctx) => {
|
||||
return unreachable(ctx, error);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
Start the backup timer when this router is imported, which is once, at boot.
|
||||
|
||||
Not awaited: the server must not wait on a database read to finish mounting a router, and a schedule
|
||||
that starts a moment late costs nothing. Failures are swallowed for the same reason the timer swallows
|
||||
them — a bad expression in one column must not stop the plugin from loading, because the panel is
|
||||
where somebody would go to fix it.
|
||||
*/
|
||||
void schedule().catch(() => {});
|
||||
|
||||
Reference in New Issue
Block a user