Files
fofos-admin/db/schema.ts
T
pastilhasandClaude Opus 5 1b398121b7 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>
2026-08-29 12:54:54 +01:00

134 lines
6.5 KiB
TypeScript

import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core';
/**
* The connection to the fofos server. One row, and the ONLY thing this plugin stores.
*
* ── Why there is nothing else here ──
*
* Every temptation to add a table to this file should be resisted, and it is worth writing down why
* rather than relying on taste. fofos owns its own Postgres and its own media store; this plugin
* reaches them over HTTP. If the client's collections and photographs lived here instead, they would
* live under Officer's plugin installer — which drops a plugin's tables when the "delete its data"
* checkbox is ticked.
*
* So the strongest thing that can be said about this plugin is a consequence of this file being one
* table long: **no operation on it can destroy the client's work.** Deleting it removes a URL and a
* token, and the site is untouched.
*
* A cache would be the thin end of it. If a panel ever needs one, it belongs in memory or in the
* browser, not in a table that outlives the request.
*
* ── Why not core `service_connections` ──
*
* It fits the shape — `url`, an encrypted `secret` — but its `ServiceName` is a closed TypeScript
* union, so using it means editing the platform to add a name, and a plugin should install into a
* platform it did not have to change. The same reasoning `plugins/proxy-manager/db/schema.ts` gives,
* and worth revisiting on the day that union opens.
*/
export const fofosAdminConnection = pgTable('fofos_admin_connection', {
id: serial('id').primaryKey(),
/** Where the fofos server is — the origin its API is served from. Normalised without a trailing slash. */
url: text('url').notNull(),
/**
* fofos's `FOFOS_API_TOKEN`, encrypted with `encryptSecret('fofos-admin', …)` — its own purpose
* key, so a leak of that one opens this column and nothing else.
*
* **Never returned across the API boundary**, at any level, to anybody, including the owner. A
* settings form asking "is one set" gets a boolean.
*
* There is exactly one of these rather than one per user, and that is not laziness: fofos has a
* single token and no notion of accounts. WHO is editing is a question Officer answers with its own
* accounts and permissions, in front of this.
*/
token: text('token').notNull(),
/** From the last successful probe — which sections that server says it serves. Diagnostic only. */
sections: text('sections').array(),
lastProbeAt: timestamp('last_probe_at', { withTimezone: true }),
/**
* `integer` rather than `boolean` for the same reason proxy-manager uses one: three states, not
* two. Null means never probed, which reads differently from probed-and-failed.
*/
lastProbeOk: integer('last_probe_ok'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export type FofosAdminConnection = typeof fofosAdminConnection.$inferSelect;
/**
* Administration's own settings. One row, and nothing in it belongs to the client.
*
* ── Why a second table and not a column on the one above ──
*
* `fofos_admin_connection.url` and `.token` are both `notNull`, so a root path could not be saved
* without also having an HTTP connection — and that connection is being replaced, not extended. When
* it goes, its table is a clean `drop` rather than surgery on columns that outlived it.
*
* The rule at the top of this file still holds and this does not bend it: what is forbidden here is
* the CLIENT'S WORK — collections, photographs, copy. This row holds a path and, later, how to read
* fofos's own database. Deleting it loses a setting somebody can retype, never a thing they made.
*/
export const fofosAdminSettings = pgTable('fofos_admin_settings', {
id: serial('id').primaryKey(),
/**
* Where the fofos site lives on this machine. ABSOLUTE, always.
*
* Never `~/fofos`. A tilde means "whoever is asking", and the people asking will not all be the same
* person — this plugin is grantable, and a member reading this setting must see the one real
* directory the site is in rather than a path that resolves to their own home and is not there.
*
* Null means never set, and the default is computed at read time rather than written here. See
* `api/settings.ts` for why that is not the same as writing it once at install.
*/
rootPath: text('root_path'),
/**
* The two Postgres databases the site uses, by NAME.
*
* Names and not URLs, deliberately. They live on the same instance Officer is already connected to —
* that is the whole reason this is a name — so a URL here would be a second copy of a host, a port
* and a credential that already exist in the platform's own configuration, able to drift from it and
* able to point somewhere nobody intended.
*
* Null means not chosen yet. There is no default: a database name is not something to guess at, and
* a wrong guess here is a site reading somebody else's tables.
*/
dbProduction: text('db_production'),
dbDevelopment: text('db_development'),
/**
* Backups: when, and where they land.
*
* A cron expression, because a backup wants to happen at an HOUR — "every day at three" — and an
* interval measured from whenever somebody last pressed save drifts across the clock until it is
* running at two in the afternoon. The panel offers the handful of schedules anybody wants, and the
* expression is validated on the way in so a malformed one cannot be stored and then fail at a time
* nobody is watching.
*
* Null in either means backups are not set up. Both are needed, so one without the other is not a
* half-configured state to interpret — it is off.
*/
backupCron: text('backup_cron'),
backupDir: text('backup_dir'),
/**
* When the last one finished, which is what makes the schedule survive a restart.
*
* The timer does not measure elapsed time from when the process started; it asks whether a backup is
* DUE, against this. A daily backup on a machine that restarts every few hours would otherwise never
* fire once — the interval would be reset before it ever elapsed, and nothing would look wrong.
*/
lastBackupAt: timestamp('last_backup_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export type FofosAdminSettings = typeof fofosAdminSettings.$inferSelect;