fix(backup): persist last-success/error status; wall-clock-based schedule

BackupService tracked last-success/last-error as plain in-process fields
and scheduled the daily backup via setInterval measured from process
start — so any server restart (deploy/crash/OOM/reboot, routine under
`restart: always`) silently reset the admin UI to "last successful
backup: Never" and drifted the actual cadence, independent of whether
backups were writing correctly to disk (they were — a real field
incident at park-buzi showed 7 valid rotating backups on disk with the
status stuck on "Never").

Persist last-success/error to new site_config columns (migration 0025)
and add BackupService.isDue(), computed from the persisted timestamp
instead of process uptime; server.ts now polls every 15 min and lets
isDue() gate the actual run. No API/UI contract change.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
2026-08-30 18:11:23 +02:00
parent 3a176c5cc8
commit 2910672b5a
7 changed files with 317 additions and 28 deletions
@@ -0,0 +1,11 @@
-- Last-success/last-error for the encrypted DB backup were previously tracked only as
-- in-process fields on BackupService (never written to the DB) — so every server restart
-- (deploy/crash/OOM/host reboot, all routine under `restart: always`) silently reset the admin
-- UI's "last successful backup" to "Never", even with valid, correctly-rotating backups already
-- on disk (2026-08-30 field incident, park-buzi). Four additive, nullable columns; null = no
-- run recorded yet (or, for the error pair, no failure since the last success). See
-- wiki/concepts/backup-recovery.md.
ALTER TABLE `site_config` ADD `backup_last_success_at` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `backup_last_result_json` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `backup_last_error_at` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `backup_last_error` text;
+7
View File
@@ -176,6 +176,13 @@
"when": 1783948800000,
"tag": "0024_validation_programs",
"breakpoints": true
},
{
"idx": 25,
"version": "6",
"when": 1788078414270,
"tag": "0025_backup_last_status",
"breakpoints": true
}
]
}
+14
View File
@@ -288,6 +288,20 @@ export const siteConfig = sqliteTable("site_config", {
backupKeepLast: integer("backup_keep_last"),
/** Beyond keepLast, keep one backup per day for this many days. null ⇒ code default (30). */
backupKeepDailyDays: integer("backup_keep_daily_days"),
/** ISO timestamp of the last backup that actually completed successfully. Persisted here
* (not just in-process memory) so the admin UI's "last successful backup" survives a
* server restart — before this column existed, a restart silently reset that status to
* "Never" even with valid backups already on disk. null = no successful run recorded yet.
* See wiki/concepts/backup-recovery.md. */
backupLastSuccessAt: text("backup_last_success_at"),
/** JSON-encoded { path, bytes, prunedFiles } of the last successful run, for the same
* restart-durability reason as backupLastSuccessAt. null = none recorded yet. */
backupLastResultJson: text("backup_last_result_json"),
/** ISO timestamp of the last FAILED scheduled/manual backup attempt, persisted for the same
* reason. null = no failure recorded (or none since the last success). */
backupLastErrorAt: text("backup_last_error_at"),
/** Error message of the last failed attempt. Cleared (set null) on the next success. */
backupLastError: text("backup_last_error"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),