Production and development, stored as NAMES rather than URLs. The site runs on the same Postgres Officer is already connected to — that is the whole reason a name is enough — and a URL here 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. Naming and creating are separate, and were worth two buttons. A database that already exists still needs its name recorded, and making Create the only way to save would leave nobody able to point the site at something already set up. The tick beside each field comes from pg_database, not from what was typed — the useful question is whether the thing exists, and that came apart from "somebody saved a name" the moment the two actions split. CREATE DATABASE cannot be parameterised, so the name goes into the statement as text and the identifier check is the entire security boundary. It is therefore strict rather than accommodating — lowercase, leading letter or underscore, nothing else — and it is re-run immediately before the statement is built, because the gap between storing a name and using it is exactly where a check that happened earlier stops being a check. Postgres folds unquoted identifiers to lowercase, so accepting `Fofos` would create `fofos` and then report a name nobody could find; refusing is kinder than silently changing it. Officer's own database and the Postgres templates are refused outright. Pointing the site at Officer's database would not read as a configuration mistake — it would read as the platform corrupting itself. Nothing here drops a database, and nothing should: `fofos_website` and `fofos_old` are one keystroke apart and one of them holds the client's site. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
167 lines
7.0 KiB
TypeScript
167 lines
7.0 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(): Promise<{ created: string[]; status: DatabasesStatus }> {
|
|
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() };
|
|
}
|