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:
2026-08-29 12:54:54 +01:00
co-authored by Claude Opus 5
parent bda59bdf03
commit 1b398121b7
5 changed files with 552 additions and 18 deletions
+6 -18
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowUpFromLine, Check, CircleCheck, CircleDashed, Database, DatabaseBackup, Loader2, Plus } from 'lucide-react';
import { ArrowUpFromLine, Check, CircleCheck, CircleDashed, Database, Loader2, Plus } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { readableError } from './errors';
import { Block } from './Block';
import { Console } from './Console';
import { BackupsRow } from './BackupsRow';
import { useFlash } from './useFlash';
/**
@@ -185,9 +186,6 @@ export const DatabaseBlock = () => {
);
const row = (label: string, target: 'production' | 'development', state: Named) => {
// "Schema pushed" is simply: something is in it. See the note on `tables`.
const pushed = (state.tables ?? 0) > 0;
return (
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
@@ -196,7 +194,7 @@ export const DatabaseBlock = () => {
</div>
<span className="shrink-0 text-xs text-muted-foreground/70">
{state.tables === null ? '' : pushed ? `${state.tables} tables` : 'empty'}
{state.tables === null ? '' : state.tables > 0 ? `${state.tables} tables` : 'empty'}
</span>
<div className="flex shrink-0 gap-2">
@@ -215,19 +213,6 @@ export const DatabaseBlock = () => {
Push schema
</Button>
{/* Nothing to restore INTO until there are tables to restore into — a data-only load against
an empty database fails on every insert, and that failure would read as a bad backup.
Still disabled beyond that: what a backup IS here has not been decided, and a button that
guesses at the source of a restore is not a thing to put near a client's data. */}
<Button
variant="outline"
size="sm"
disabled
title={pushed ? 'Not built yet — no backup source chosen' : 'Push the schema first'}
>
<DatabaseBackup className="size-3.5" />
Restore from backup
</Button>
</div>
</div>
);
@@ -255,6 +240,9 @@ export const DatabaseBlock = () => {
{error && <p className="text-xs text-destructive">{error}</p>}
<Console output={output} failed={failed} />
{/* Below the two databases, because it is about both of them rather than either. */}
<BackupsRow />
{/* The common case is both. Doing them one at a time is still there for when it is not. */}
<div className="flex justify-end border-t border-border pt-3">
<Button size="sm" onClick={() => void push('both')} disabled={busy !== null}>