Files
fofos-admin/api/databases.ts
T
pastilhasandClaude Opus 5 af31b57cdf the databases say what is in them, and the block folds away when it is done
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>
2026-08-29 13:32:42 +01:00

365 lines
16 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;
/**
* Tables in `public`. Null when there is no database to count them in.
*
* This is what "has the schema been pushed" means here, and it is deliberately a COUNT rather than a
* comparison against the site's expected tables. Reproducing that list would be a second copy of the
* site's schema living in the plugin, going stale the first time the site adds a table — and the
* question being asked is only "is this empty, or has something been put in it".
*/
tables: number | null;
/**
* Rows across every table, and the newest timestamp anything carries.
*
* A table count says the schema is there. It says nothing about whether the site has any CONTENT,
* which is the question somebody actually has when looking at two databases with identical
* structure — one of these is the live site and one is a copy, and they look the same until you
* count what is in them.
*/
rows: number | null;
/** The most recent `created_at` anywhere, or null when no table has one. */
lastWrite: string | 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);
const describe = async (name: string | null): Promise<DatabaseName> => {
if (!name) return { name: null, exists: null, tables: null, rows: null, lastWrite: null };
if (!available.includes(name)) return { name, exists: false, tables: null, rows: null, lastWrite: null };
return { name, exists: true, ...(await describeContents(name)) };
};
return {
production: await describe(production),
development: await describe(development),
available,
};
} finally {
await sql.end({ timeout: 5 });
}
}
/**
* What is actually in a database: tables, rows, and when anything was last written.
*
* One connection for all three, because it asks INSIDE the database and opening three would be three
* connections to answer one question.
*
* Rows are counted with `query_to_xml` over `information_schema`, which is the standard way to get an
* exact count per table without knowing the table names in advance. `reltuples` from the planner would
* be free, and it is an ESTIMATE — it reads as zero on a table nobody has analysed, which is exactly
* the freshly restored database this is meant to reassure somebody about.
*
* `lastWrite` looks only at columns called `created_at`. Most tables here have one and the ones that do
* not are settings rows; a schema-agnostic answer is not available and a wrong one would be worse than
* none, so this returns null rather than guessing when nothing qualifies.
*/
async function describeContents(name: string): Promise<{ tables: number | null; rows: number | null; lastWrite: string | null }> {
const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL;
if (!raw) return { tables: null, rows: null, lastWrite: null };
const url = new URL(raw);
url.pathname = `/${name}`;
const sql = postgres(url.toString(), { max: 1 });
try {
const [counts] = await sql<{ tables: string; rows: string }[]>`
select count(*)::text as tables, coalesce(sum(n), 0)::text as rows from (
select (xpath('/row/c/text()',
query_to_xml(format('select count(*) as c from %I.%I', table_schema, table_name), false, true, '')
))[1]::text::bigint as n
from information_schema.tables
where table_schema = 'public' and table_type = 'BASE TABLE'
) t
`;
const [latest] = await sql<{ at: string | null }[]>`
select max(at)::text as at from (
select (xpath('/row/c/text()',
query_to_xml(format('select max(created_at) as c from %I.%I', table_schema, table_name), false, true, '')
))[1]::text::timestamptz as at
from information_schema.columns
where table_schema = 'public' and column_name = 'created_at'
) t
`;
return {
tables: Number(counts?.tables ?? 0),
rows: Number(counts?.rows ?? 0),
lastWrite: latest?.at ?? null,
};
} catch {
// Exists but will not answer. Null throughout, because "0 rows" about a database that could not be
// read would say the opposite of what is true.
return { tables: null, rows: null, lastWrite: null };
} finally {
await sql.end({ timeout: 5 });
}
}
/** Tables alone, for the places that only need to know whether a schema has been pushed. */
async function countTables(name: string): Promise<number | null> {
const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL;
if (!raw) return null;
const url = new URL(raw);
url.pathname = `/${name}`;
const sql = postgres(url.toString(), { max: 1 });
try {
const rows = await sql<{ count: string }[]>`
select count(*)::text as count from information_schema.tables where table_schema = 'public'
`;
return Number(rows[0]?.count ?? 0);
} catch {
// A database that exists but will not answer is not the same as an empty one, and saying "0
// tables" about it would read as "ready for a schema push".
return null;
} finally {
await sql.end({ timeout: 5 });
}
}
/**
* Run the SITE's own `db:push` against one of the two databases.
*
* ── The site pushes its own schema ──
*
* Not drizzle-kit invoked from here with a config this plugin assembles. The schema, the drizzle
* version and the push command all belong to the website repository, and a plugin that reimplemented
* any of them would be a second definition of the site's database going stale on its own schedule.
* This supplies the connection and runs their script.
*
* ── The connection goes in the ENVIRONMENT, and that works because of how they read it ──
*
* `src/env-file.ts` consults `.env` files first and `process.env` last, so an ambient value is used
* only when no file in the checkout sets that key. That is the right precedence for them and it means
* this cannot quietly override a connection somebody wrote down.
*
* ── Their guard still applies, and it should ──
*
* `env.ts` refuses to connect unless the database NAME matches what `FOFOS_ENV` expects. If the names
* chosen here disagree, this fails with their message rather than ours, which is the correct outcome:
* the site is the authority on which database it may talk to, and a plugin that worked around that
* would be defeating the one check standing between development and live data.
*/
export async function pushSchema(
target: 'production' | 'development',
websiteDir: string,
): Promise<{ ok: boolean; output: string }> {
const status = await readDatabases();
const name = target === 'production' ? status.production.name : status.development.name;
if (!name) throw new Error(`No ${target} database is named.`);
if (!status.available.includes(name)) throw new Error(`"${name}" does not exist yet.`);
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 = `/${name}`;
const proc = Bun.spawn(['bun', 'run', 'db:push'], {
cwd: websiteDir,
env: {
...process.env,
FOFOS_ENV: target,
...(target === 'production' ? { POSTGRES_URL: url.toString() } : { POSTGRES_URL_DEV: url.toString() }),
/*
BOTH expected names, whichever target this is.
Their guard checks that the two environments expect different databases, so it needs both to do
that at all — and passing only the one being pushed to would leave the other falling back to the
name the repository shipped with, which is a comparison against something nobody configured.
These are the names stored here, which makes this plugin the authority on what the site's
databases are called. That is the point of the change: the site stopped hardcoding them.
*/
...(status.production.name ? { FOFOS_DB_PRODUCTION: status.production.name } : {}),
...(status.development.name ? { FOFOS_DB_DEVELOPMENT: status.development.name } : {}),
},
stdout: 'pipe',
stderr: 'pipe',
stdin: 'ignore',
});
const timer = setTimeout(() => proc.kill(), 5 * 60 * 1000);
try {
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { ok: code === 0, output: `$ bun run db:push (FOFOS_ENV=${target})\n${stdout}${stderr}`.trim() };
} finally {
clearTimeout(timer);
}
}
/**
* 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() };
}