Two databases with identical structure look identical until you count what is in
them — one is the live site and one is a copy. So a row now reads "18 tables ·
155 rows" with the time anything was last written, rather than only reporting
that a schema exists.
Counted with query_to_xml over information_schema, which is exact. reltuples
from the planner would be free and is an ESTIMATE: it reads as zero on a table
nobody has analysed, which is precisely the freshly restored database this is
meant to reassure somebody about. That is not a hypothetical — it is why
pg_stat_user_tables reported an empty database earlier today that had 155 rows
in it.
And the block collapses with a check once everything is in order, on the same
terms as Root location: both databases existing with a schema, and backups
configured. Not backups having RUN — a site set up an hour ago has taken none,
and calling that incomplete is telling somebody to fix what is not broken.
Seeded from /summary so the first paint is already right, then kept live by what
the block and BackupsRow know. The `null` sentinel in `collapsed` is left alone
while unsettled, which is the bug that stopped Root location folding at all.
BackupsRow reports its bit from an EFFECT, not during render: calling the
parent's setter mid-render is an update to a different component, which React
refuses outright.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A nightly backup of a site that only changes when somebody publishes spends most
of its life copying a database identical to yesterday's, and misses the one
moment worth capturing. So "On publish" joins the schedules, and goes first.
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 creates no cron job and no catch-up tick. 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 here would take a backup every fifteen minutes of a
site nobody had touched.
`backupAfterPublish()` does nothing unless this is the configured trigger, so
the publish path can call it unconditionally and never has to know what the
backup settings say. It swallows failures for the same reason the timer does: a
backup that could not be taken must not present as a publish that failed, when
the thing the person asked for worked.
Nothing calls it yet — there is no publish in this plugin, because the sections
that would do the publishing are not built.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pg_dump carries CREATE TABLE, so it cannot be layered onto a database that
already has the schema — it fails on every object. The only honest restore
empties the target first, and that is not a detail to bury behind a button. So
choosing a destination and confirming are two separate clicks, and the second
names what it is about to destroy.
`confirm` is required by the function, not merely by the panel, so no code path
can reach a restore by accident.
The file is matched against what listDumps actually found in the backup
directory rather than taken as given. There is no path to traverse out of and no
way to point psql at a file this module did not write: an unknown name is
refused before anything is opened, rather than sanitised and hoped about.
Emptied with DROP SCHEMA public CASCADE through psql, not by dropping the
DATABASE — that would need a connection to another one, fail while anything is
attached, and lose whatever was granted on it. Replacing the schema does the
same job to the part that matters.
psql rather than pg_restore, because these are plain-SQL dumps, which is what
pg_dump writes without -F c.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Requiring an absolute one was simply wrong. `scp file host:db-backups/fofos/`
lands under the remote account's home — that is what every ssh tool does, and
what anybody typing a destination expects, since the home directory is the one
place they are certain to be able to write.
What is still refused is unchanged and is the part that matters: the characters
a shell would act on. This value IS interpreted remotely, since `ssh host
mkdir -p <path>` runs through a shell, so they are rejected rather than escaped
— refusing is checkable by reading the function, escaping is checkable only by
being right about every shell.
The summary shows a relative destination as ~/… because that is where it lands.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things.
The local directory defaults to <officerdev root>/dockers/postgres/dumps,
derived from OFFICER_ROOT rather than written down — the same reasoning as
rootPath: a path fixed at install freezes the layout of the machine it was
installed on, and a restore elsewhere then points somewhere that is not there
while looking deliberately chosen.
The destination is SSH, and it is now what "set up" MEANS: a dump sitting on the
machine it came from is a second copy on one disk, lost to the same failure that
would make anybody want it. So local is staging and the copy off the box is the
backup, and a schedule with nowhere to send it does not count as configured.
Testable, in two steps reported separately, because they fail for different
reasons and the fixes differ: the connection is keys and networking, the
directory is permissions on the far side. It tests what is in the FORM rather
than what is stored — testing a saved destination while somebody looks at a
different one is worse than not testing.
Everything goes out with BatchMode=yes. Nobody is at a terminal when this runs,
so a key that has lost access must FAIL rather than block forever on a prompt
nothing will answer. It is also why no credential is stored: there is none to
store and none to leak.
The remote path is the one value here a shell interprets — `ssh host mkdir -p
<path>` runs through it — so shell characters are refused rather than escaped.
Refusing is checkable by reading it; escaping is checkable by being right about
every shell.
And rotation keeps the newest N per database at both ends. Per database, because
two sharing one budget means a busy one evicts the other's history. Sorted by
NAME rather than mtime: the timestamp is in the filename and is sortable, while
mtime stops meaning "when this was taken" the moment a file is copied.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Anchored to the clock — midnight, six, noon, six — rather than to whenever the
schedule was saved, which is the whole reason these are cron expressions.
Verified the catch-up check reads its period as six hours, since that is what
decides whether a run was missed while the machine was down.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>