import { mkdir, rm, stat, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { db } from 'officerdb/db'; import { fofosAdminSettings } from '../db/schema'; import { readDatabases } from './databases'; import { backupAfterPublish, defaultBackupDir } from './backups'; import { buildSite } from './static-site'; /** * Publish: copy the site's content from development to production, then build. * * ── Why this exists ── * * The admin edits development and the build renders production, so nothing written in the panel could * reach the live site. Build was only half of publishing. Without this the first symptom is a * successful build of yesterday's content, which reads as a broken build and sends somebody looking at * the renderer. * * Copy and build are ONE action rather than two buttons in an order nobody is told. A copy without a * build changes nothing anybody can see, and a build without a copy is the bug above. * * ── What is copied, and what is deliberately not ── * * Every table the site owns EXCEPT two: * * `users` — accounts and password hashes. Production's logins are not development's, * and overwriting them would lock somebody out of the live site in order to * publish a photograph. * `contact_submissions` — the legacy inbox. Messages live in SQLite now and this table is only what * predates that. Development's copy is empty, so copying it over production * would destroy real messages to publish nothing — the most expensive thing * this file could possibly do. * * The list is explicit rather than "every table except". A table added later should be considered * rather than swept along, and the failure mode of the other choice — production-only data silently * overwritten by an empty development table — is discovered far too late to fix. */ /** The site's content. Parents before children, so the restore satisfies its own foreign keys. */ const CONTENT_TABLES = [ 'home_content', 'home_gallery_media', 'about_content', 'galleries', 'gallery_items', 'collections', 'collection_items', 'collection_item_media', 'gallery_pieces', 'gallery_piece_media', 'drops', 'drop_media', 'collabs', 'collab_media', 'service_nodes', 'partners', 'social_links', 'contact_details', ] as const; /** Long enough for a slow dump of a few megabytes, short enough that a wedged psql is not forever. */ const TIMEOUT_MS = 5 * 60 * 1000; /** * Connection details as ENVIRONMENT, not as a URL argument. * * `libpq` reads these, so the password never appears in `ps` or in anything that logs a command line. * The database name is the one part passed as an argument, because it is the part that is not secret * and the part that changes between the two calls. */ const connection = (): Record => { 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); return { PGHOST: url.hostname, PGPORT: url.port || '5432', PGUSER: decodeURIComponent(url.username), PGPASSWORD: decodeURIComponent(url.password), }; }; const run = async (argv: string[], env: Record) => { const proc = Bun.spawn(argv, { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', env: { ...process.env, ...env }, }); const timer = setTimeout(() => proc.kill(), TIMEOUT_MS); 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: `${stdout}${stderr}`.trim() }; } finally { clearTimeout(timer); } }; export type PublishResult = { ok: boolean; output: string }; export async function publish(secrets: Parameters[0]): Promise { const row = (await db.select().from(fofosAdminSettings).limit(1))[0] ?? null; const status = await readDatabases(); const production = status.production.name; const development = status.development.name; if (!production || !status.production.exists) throw new Error('The production database does not exist.'); if (!development || !status.development.exists) throw new Error('The development database does not exist.'); const env = connection(); const dir = row?.backupDir ?? defaultBackupDir(); await mkdir(dir, { recursive: true }); const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, '').replace('T', '-'); const lines: string[] = []; /* ── 1. A way back, before anything is destroyed ── This truncates and replaces. A dump taken immediately before is the difference between a mistake that costs a minute and one that costs everything the client has written, and the whole database is a few megabytes. If it cannot be taken, nothing else happens. */ const safety = join(dir, `${production}-before-publish-${stamp}.sql`); const dumped = await run(['pg_dump', '-f', safety, production], env); if (!dumped.ok) { return { ok: false, output: `Refusing to publish: production could not be backed up first.\n${dumped.output}` }; } lines.push(`backed up production → ${safety}`); /* ── 2. Read development BEFORE clearing production ── Ordered this way on purpose. Dumping second would mean a failure to read development leaves production already empty — the site down, for a reason that had nothing to do with production. Now the worst a bad dump can do is stop before touching anything. */ const staged = join(dir, `.publish-${stamp}.sql`); const dumpArgs = CONTENT_TABLES.flatMap((table) => ['--table', table]); const copy = await run(['pg_dump', '--data-only', ...dumpArgs, '-f', staged, development], env); if (!copy.ok) { await rm(staged, { force: true }); return { ok: false, output: `${lines.join('\n')}\nCould not read development: ${copy.output}` }; } const size = (await stat(staged).catch(() => null))?.size ?? 0; lines.push(`read ${CONTENT_TABLES.length} tables from ${development} (${size} bytes)`); /* ── 3. Clear and load, as ONE transaction ── Written as a wrapper around the dump rather than as two psql calls, and that shape is the whole point of this step: `-1` puts the truncate and the load in one transaction, and TRUNCATE is transactional in Postgres. So a load that fails takes the truncate down with it and production keeps the content it had. Two separate calls — which is what this was first — leave the live site EMPTY when the second one fails, for however long it takes somebody to notice and restore. Verified against a scratch copy of production: a deliberately failing load left all nine rows in place. `session_replication_role = replica` turns off foreign-key checks for the session. `pg_dump` warns that `service_nodes` carries a circular foreign key and that a --data-only dump of it may not restore; today the rows happen to come out parents-first and it loads either way, but "happens to" is not a property to depend on for the live site. It needs superuser, and if it is refused the transaction rolls back like any other failure — production intact, the reason in the output. `RESTART IDENTITY`, so the sequence values the dump then sets are not merely ahead of the rows it inserts. A copy whose sequences still start at 1 does not fail here; it fails on the client's next save, hours later, somewhere else entirely. */ const wrapper = join(dir, `.publish-${stamp}.psql`); await writeFile( wrapper, [ 'SET session_replication_role = replica;', `TRUNCATE ${CONTENT_TABLES.join(', ')} RESTART IDENTITY CASCADE;`, `\\i ${staged}`, '', ].join('\n'), ); try { const loaded = await run(['psql', '-1', '-q', '-v', 'ON_ERROR_STOP=1', '-f', wrapper, production], env); if (!loaded.ok) { return { ok: false, output: `${lines.join('\n')}\nThe copy failed and was rolled back — production still holds the content it had.\n\n${loaded.output}`, }; } lines.push('copied development content into production'); } finally { await rm(staged, { force: true }); await rm(wrapper, { force: true }); } // ── 4. Render it ── const built = await buildSite(secrets); lines.push(built.output); if (!built.ok) return { ok: false, output: lines.join('\n') }; /* The configured backup, if "on publish" is what it is set to. `backupAfterPublish` decides that for itself and swallows its own failures, so this calls it unconditionally: a backup that could not be taken must not turn into a publish that appears to have failed, when the thing the person asked for actually worked. */ await backupAfterPublish(); return { ok: true, output: lines.join('\n') }; }