diff --git a/api/backups.ts b/api/backups.ts index 75dbbd3..c98148d 100644 --- a/api/backups.ts +++ b/api/backups.ts @@ -35,6 +35,18 @@ import { readDatabases } from './databases'; * also why no credential is stored here — there is none to store, and none to leak. */ +/** + * "Back up when something is published", rather than at a time. + * + * Stored in the same column as a cron expression because it answers the same question — WHEN — and a + * second column would mean every read had to decide which of the two was authoritative. + * + * It is the right answer for this site and probably for most: nothing changes until somebody publishes, + * so a nightly backup spends most of its life copying a database that is identical to yesterday's, and + * the one moment worth capturing is the one it does not cover. + */ +export const ON_PUBLISH = 'on-publish'; + /** How often to look for a run that was missed while this machine was down. */ const CATCHUP_TICK_MS = 15 * 60 * 1000; @@ -85,6 +97,7 @@ function periodMs(expression: string): number | null { export function checkCron(expression: string): string { const trimmed = expression.trim(); if (!trimmed) throw new Error('A schedule is required.'); + if (trimmed === ON_PUBLISH) return trimmed; if (!validateCronExpression(trimmed).valid) throw new Error(`"${trimmed}" is not a schedule cron understands.`); return trimmed; } @@ -153,7 +166,8 @@ export async function readBackups(): Promise { const dir = row?.backupDir ?? defaultBackupDir(); let nextRunAt: string | null = null; - if (cron) { + // Nothing to predict when the trigger is an event: the next run is whenever somebody publishes. + if (cron && cron !== ON_PUBLISH) { try { nextRunAt = new CronTime(cron).getNextDateFrom(new Date()).toISO(); } catch { @@ -426,7 +440,7 @@ let catchup: ReturnType | null = null; /** Is a scheduled run already overdue, because the machine was off when it should have happened? */ async function runIfMissed(): Promise { const row = await readRow(); - if (!row?.backupCron) return; + if (!row?.backupCron || row.backupCron === ON_PUBLISH) return; const period = periodMs(row.backupCron); if (!period) return; @@ -454,6 +468,15 @@ export async function schedule(): Promise { const row = await readRow(); if (!row?.backupCron) return; + /* + Nothing to schedule, and nothing to catch up on. + + A missed run is a run whose TIME passed while the machine was down. An event has no time, so there + is nothing to have missed — and a catch-up tick here would take a backup every fifteen minutes of a + site nobody had touched. + */ + if (row.backupCron === ON_PUBLISH) return; + try { job = new CronJob(row.backupCron, () => void runBackup().catch(() => {}), null, true); } catch { @@ -466,3 +489,17 @@ export async function schedule(): Promise { // And once now, because the restart after a missed run is the most likely moment to discover it. void runIfMissed(); } + +/** + * Take a backup because something was just published. + * + * Does nothing unless that is the configured trigger, so the publish path can call it unconditionally + * and never has to know what the backup settings say. Failures are swallowed for the same reason the + * timer swallows them: a backup that could not be taken must not turn into a publish that appears to + * have failed, when the thing the person asked for actually worked. + */ +export async function backupAfterPublish(): Promise { + const row = await readRow(); + if (row?.backupCron !== ON_PUBLISH) return; + await runBackup().catch(() => {}); +} diff --git a/web/BackupsRow.tsx b/web/BackupsRow.tsx index c5ed504..12b265f 100644 --- a/web/BackupsRow.tsx +++ b/web/BackupsRow.tsx @@ -42,8 +42,18 @@ type Settings = { recent: Backup[]; }; -/** Every schedule anybody has actually wanted, and their expressions. */ +/** The sentinel the API stores for "not on a clock". Kept in step with `ON_PUBLISH` in api/backups.ts. */ +const ON_PUBLISH = 'on-publish'; + +/** + * When to back up. + * + * "On publish" is first because it is the right answer for a site that only changes when somebody + * changes it: a nightly backup spends most of its life copying a database identical to yesterday's, + * and misses the one moment worth capturing. + */ const CHOICES = [ + { label: 'On publish', cron: ON_PUBLISH }, { label: 'Hourly', cron: '0 * * * *' }, // Midnight, 6am, noon and 6pm — anchored to the clock rather than to whenever this was saved. { label: 'Every 6 hours', cron: '0 */6 * * *' }, @@ -266,14 +276,21 @@ export const BackupsRow = ({ prodName, devName }: { prodName: string; devName: s ))} - {/* The expression stays visible and editable. The buttons write into it; they do not hide it. */} - setCron(event.target.value)} - className="w-full font-mono text-xs" - autoComplete="off" - spellCheck={false} - /> + {/* The expression stays visible and editable for a SCHEDULE. "On publish" is not one, so the + box would be a field with nothing to put in it. */} + {cron === ON_PUBLISH ? ( +

+ A backup is taken every time the site is published, and at no other time. +

+ ) : ( + setCron(event.target.value)} + className="w-full font-mono text-xs" + autoComplete="off" + spellCheck={false} + /> + )}