Files
fofos-admin/api/databases.ts
T
pastilhasandClaude Opus 5 30c52e6449 one button, and it appears when there is something to press
Two buttons was the wrong model, and it failed in the worst way: Create keyed
off the SAVED names, so on a fresh screen with both fields filled it did not
render at all — and the hint explaining why was behind the same condition. Two
filled fields, one Save, and nothing saying what came next.

Naming and creating are not separate decisions from the outside. You type two
names because you want two databases. So one button that records them and
creates whatever is missing, labelled with whichever of those it is about to do,
and disabled only when there is genuinely nothing to press.

The tick beside each field now asks pg_database about the text in the BOX rather
than the text last saved. That is the question somebody typing a name has, and
answering it about a different name was worse than silence.

The PUT that only stored names goes with the second button. Nothing called it,
and a second way to write the same two columns is a second thing that can drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 12:09:54 +01:00

185 lines
7.7 KiB
TypeScript

import postgres from 'postgres';
import { db } from 'officerdb/db';
import { eq } from 'drizzle-orm';
import { fofosAdminSettings } from '../db/schema';
/**
* The two Postgres databases the fofos site reads — production and development.
*
* ── Names, on the instance Officer is already using ──
*
* The site runs on the same box and the same Postgres as Officer, so what has to be stored is a NAME.
* Storing a URL would be a second copy of a host, a port and a credential that already exist in the
* platform's configuration, free to drift from it and free to point at another machine entirely.
*
* ── This module creates databases, which is worth saying out loud ──
*
* `CREATE DATABASE` cannot be parameterised — the name goes into the statement as text. So the name is
* validated to an identifier this module is willing to write, and anything else is refused before a
* statement is built. That check is the whole security boundary here, which is why it is strict rather
* than accommodating: lowercase, starts with a letter or underscore, and nothing else in it.
*
* Postgres folds an unquoted identifier to lowercase, so accepting `Fofos` would create `fofos` and
* then report a name nobody would find. Refusing it is kinder than silently changing it.
*
* ── It never drops, and never overwrites ──
*
* Create makes what is missing and leaves what is there. There is no route here that destroys a
* database, and there should not be: the difference between `fofos_website` and `fofos_old` is one
* keystroke, and one of those holds the client's site.
*/
/** Postgres's own rule, minus the parts nobody should be using in a name typed into a form. */
const IDENTIFIER = /^[a-z_][a-z0-9_]{0,62}$/;
export type DatabaseName = {
name: string | null;
/** Null when no name is set — "does it exist" has no answer for a database nobody has named. */
exists: boolean | null;
};
export type DatabasesStatus = {
production: DatabaseName;
development: DatabaseName;
/** Every database on the instance, so the form can say what is already there. */
available: string[];
};
/**
* The connection used to ask about and create databases.
*
* Against `postgres`, the maintenance database, because `CREATE DATABASE` cannot run from inside the
* database it is creating and cannot run in a transaction. A fresh client per call, closed after:
* these are rare administrative actions, and a pool held open for them would outlive every one.
*/
const maintenance = () => {
const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL;
if (!raw) throw new Error('The platform has no Postgres connection configured.');
const url = new URL(raw);
url.pathname = '/postgres';
return postgres(url.toString(), { max: 1 });
};
/**
* Names this module refuses, whatever anybody types.
*
* `postgres` and the templates are the instance's own machinery. Officer's database is the important
* one: pointing the site at it would have fofos reading and eventually writing Officer's tables, and
* the failure would not look like a configuration mistake — it would look like the platform corrupting
* itself.
*/
const reserved = (): Set<string> => {
const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL;
const own = raw ? new URL(raw).pathname.replace(/^\//, '') : '';
return new Set(['postgres', 'template0', 'template1', ...(own ? [own] : [])]);
};
/** Throws with a sentence a person can act on, rather than returning false. */
export function checkName(name: string, field: string): string {
const trimmed = name.trim();
if (!trimmed) throw new Error(`The ${field} database needs a name.`);
if (!IDENTIFIER.test(trimmed)) {
throw new Error(
`"${trimmed}" is not a database name this will create. Lowercase letters, digits and underscores, starting with a letter or underscore.`,
);
}
if (reserved().has(trimmed)) throw new Error(`"${trimmed}" belongs to Officer or to Postgres itself.`);
return trimmed;
}
const readRow = async () => (await db.select().from(fofosAdminSettings).limit(1))[0] ?? null;
export async function readDatabases(): Promise<DatabasesStatus> {
const row = await readRow();
const production = row?.dbProduction ?? null;
const development = row?.dbDevelopment ?? null;
const sql = maintenance();
try {
const rows = await sql<{ datname: string }[]>`
select datname from pg_database where not datistemplate order by datname
`;
const available = rows.map((entry) => entry.datname);
return {
production: { name: production, exists: production ? available.includes(production) : null },
development: { name: development, exists: development ? available.includes(development) : null },
available,
};
} finally {
await sql.end({ timeout: 5 });
}
}
/**
* Store the two names. Saving does NOT create anything — see the note in the panel.
*
* Validated here as well as at creation, so a name that could never be created cannot be stored and
* sit there looking configured.
*/
export async function saveDatabases(production: string, development: string): Promise<DatabasesStatus> {
const prod = checkName(production, 'production');
const dev = checkName(development, 'development');
if (prod === dev) throw new Error('Production and development must be different databases.');
const row = await readRow();
if (row) {
await db
.update(fofosAdminSettings)
.set({ dbProduction: prod, dbDevelopment: dev, updatedAt: new Date() })
.where(eq(fofosAdminSettings.id, row.id));
} else {
await db.insert(fofosAdminSettings).values({ dbProduction: prod, dbDevelopment: dev });
}
return readDatabases();
}
/**
* Create whichever of the two is missing.
*
* Idempotent by checking first rather than by catching "already exists": the second would also swallow
* a name that failed for a different reason, and reporting "created" about a database that is not
* there is the one outcome worth engineering against.
*/
export async function createDatabases(names?: {
production?: string;
development?: string;
}): Promise<{ created: string[]; status: DatabasesStatus }> {
/*
Names may arrive with the request, and then they are SAVED before anything is created.
Because otherwise Create acts on stored names while the person is looking at typed ones, and the
two can differ. Making them press Save first was the version of this that shipped, and it left a
screen with two filled fields, no Create button and nothing explaining why — the button keyed off
the saved names, which were still empty.
Recording the name you just used to create a database is not a side effect worth hiding behind a
second click.
*/
if (names?.production !== undefined || names?.development !== undefined) {
await saveDatabases(names.production ?? '', names.development ?? '');
}
const status = await readDatabases();
const wanted = [status.production.name, status.development.name].filter((name): name is string => Boolean(name));
if (!wanted.length) throw new Error('Name the databases before creating them.');
const missing = wanted.filter((name) => !status.available.includes(name));
if (!missing.length) return { created: [], status };
const sql = maintenance();
try {
for (const name of missing) {
// Re-checked immediately before the statement is built. The name came from our own row, but the
// gap between storing and creating is exactly where a check that happened "earlier" stops being
// a check — and this is the one line in the plugin that interpolates into SQL.
const safe = checkName(name, 'database');
await sql.unsafe(`CREATE DATABASE "${safe}"`);
}
} finally {
await sql.end({ timeout: 5 });
}
return { created: missing, status: await readDatabases() };
}