diff --git a/api/publish.ts b/api/publish.ts index d0216fb..c04fd49 100644 --- a/api/publish.ts +++ b/api/publish.ts @@ -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[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') };