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; }; 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 => { 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 { 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 => { if (!name) return { name: null, exists: null, tables: null }; if (!available.includes(name)) return { name, exists: false, tables: null }; return { name, exists: true, tables: await countTables(name) }; }; return { production: await describe(production), development: await describe(development), available, }; } finally { await sql.end({ timeout: 5 }); } } /** Tables in a database's `public` schema. Its own connection, since this asks INSIDE the database. */ async function countTables(name: string): Promise { 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, // Both, because which one their guard reads depends on FOFOS_ENV and setting only one would // work for a single target by accident. ...(target === 'production' ? { POSTGRES_URL: url.toString() } : { POSTGRES_URL_DEV: url.toString() }), }, 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 { 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() }; }