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>
365 lines
16 KiB
TypeScript
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() };
|
|
}
|