Files
fofos-admin/api/publish.ts
T
pastilhasandClaude Opus 5 1d9a621bd2 publish clears and loads in one transaction
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 come out parents-first and it loads either way, but the previous shape
made that a load-bearing accident: truncate in one psql call, load in the next, so the day the
ordering changes the live site is left EMPTY until somebody notices and restores by hand.

Both statements now run as one transaction — TRUNCATE is transactional in Postgres — with foreign-key
checks off for the session. A failed load rolls the truncate back with it.

Verified against a scratch copy of production rather than reasoned about: a `create database ...
template fofos_website`, a deliberately failing load, and all nine rows still there afterwards. The
same copy confirmed what the real run will do — gallery and its nine items promoted, hero pointing at
the new file, and `users` and `contact_submissions` untouched at 2 and 8 rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 21:55:02 +01:00

214 lines
8.9 KiB
TypeScript

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<string, string> => {
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<string, string>) => {
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<typeof buildSite>[0]): Promise<PublishResult> {
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') };
}