Files
fofos-admin/db/schema.ts
T
pastilhasandClaude Opus 5 2e56260a88 a Servers section: development and production, both under pm2
pm2 for BOTH, development included. A `bun dev` started in a terminal dies with
the terminal, and nobody can tell whether it is running without finding the
window it was started in. Under pm2 the two are the same kind of thing — a named
process with a status, an uptime and a log, startable from here and inspectable
from anywhere. The cost is that development stops printing to a console somebody
is watching, which is what `pm2 logs fofos-dev` is for; the process names are in
the panel so nobody has to guess them.

This plugin owns exactly two names, `fofos-dev` and `fofos-prod`, and every
command is built from that pair and from nothing a caller supplies. No request
can stop, restart or delete another process on the machine. That matters more
than usual here: Officer's own processes share this pm2 daemon, and a plugin
able to name any of them would be a way to take the host down through a web
form.

Start is really a replace — delete, then start. Not `restart`: it fails on a
process that has never existed, and on one that has it reuses the environment it
was started with, so a port or database changed here would be saved, displayed,
and not in effect. That is an hour of somebody's debugging.

The environment is assembled here because this plugin is what knows the ports
and the database names, and it is handed to pm2 through the invoking command's
own environment — pm2 has no flag for it; `--env` names a block in an ecosystem
file. The site reads .env files first and the environment last, so this fills
what is absent rather than overriding what somebody wrote down.

Ports have no default. It is the one setting where a plausible guess is worst:
it binds, the site appears to work, and it is quietly answering on a port
another service wanted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:43:48 +01:00

172 lines
8.1 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'),
/**
* Where dumps are written on THIS machine, before they are sent anywhere.
*
* Null means the default, computed at read time from `OFFICER_ROOT` — the same reasoning as
* `rootPath` above: a path written in at install freezes the layout of the machine it was installed
* on, and a restore elsewhere then points at a directory that is not there while looking chosen.
*/
backupDir: text('backup_dir'),
/**
* Where they are COPIED to. `user@host`, and an absolute path on it.
*
* A backup that only exists on the machine it came from is not a backup — it is a second copy on the
* same disk, lost to the same failure. So the local directory is staging and this is the destination.
*
* Authenticated by the owner's existing SSH key, never a password: there is nobody at a terminal when
* this runs, and a credential stored here would be one this plugin had to hold and could leak.
*/
backupSshHost: text('backup_ssh_host'),
backupSshPath: text('backup_ssh_path'),
/**
* How many dumps to keep, per database, in each place.
*
* Null means keep everything, which is a real choice and a bad default — a nightly dump of a small
* site fills a disk slowly enough that nobody notices until something else fails.
*/
backupKeep: integer('backup_keep'),
/**
* The ports the site's two servers listen on.
*
* Null means not chosen, and there is no default: a port is the one setting where a sensible-looking
* guess is worst — it binds, the site appears to work, and it is quietly answering on something
* another service wanted.
*/
devPort: integer('dev_port'),
prodPort: integer('prod_port'),
/**
* 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;