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>
This commit is contained in:
2026-08-29 21:55:02 +01:00
co-authored by Claude Opus 5
parent 472fd9cb88
commit 1d9a621bd2
+38 -19
View File
@@ -1,4 +1,4 @@
import { mkdir, rm, stat } from 'node:fs/promises';
import { mkdir, rm, stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { db } from 'officerdb/db';
import { fofosAdminSettings } from '../db/schema';
@@ -148,35 +148,54 @@ export async function publish(secrets: Parameters<typeof buildSite>[0]): Promise
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 {
/*
── 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);
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 production content is now EMPTY.\nRestore it with:\n psql ${production} -f ${safety}\n\n${loaded.output}`,
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 });
}
// ── 5. Render it ──
// ── 4. Render it ──
const built = await buildSite(secrets);
lines.push(built.output);
if (!built.ok) return { ok: false, output: lines.join('\n') };