import { mkdir, rm, stat } 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)`); try { /* ── 3. Clear, in one statement ── All of them together, because foreign keys between them make any single truncate fail on its own. `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 collides on the client's next save, hours later, somewhere else entirely. */ const cleared = await run( ['psql', '-v', 'ON_ERROR_STOP=1', '-c', `TRUNCATE ${CONTENT_TABLES.join(', ')} RESTART IDENTITY CASCADE;`, production], env, ); if (!cleared.ok) return { ok: false, output: `${lines.join('\n')}\nCould not clear production: ${cleared.output}` }; // ── 4. Load ── const loaded = await run(['psql', '-v', 'ON_ERROR_STOP=1', '-f', staged, production], env); if (!loaded.ok) { return { ok: false, output: `${lines.join('\n')}\nThe copy failed and production content is now EMPTY.\nRestore it with:\n psql ${production} -f ${safety}\n\n${loaded.output}`, }; } lines.push('copied development content into production'); } finally { await rm(staged, { force: true }); } // ── 5. 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') }; }