Files
fofos-admin/api/publish.ts
T
pastilhasandClaude Opus 5 15042ccc76 a gallery is a carousel
It always rendered as one — data-carousel, a snap track, two arrows, driven by
carousel.client.js. The entity was the last thing still calling it something
else, and that collided with the site's own Gallery section: two nav rows
reading Gallery and Galleries, meaning a list of objects and a list of
photograph sets.

GalleriesPanel, GalleryPicker and GalleryItems become Carousels*, the endpoint
becomes /fofos-admin/carousels, the block kind becomes carousel and
subject-gallery becomes subject-carousel. The publish allowlist follows the
renamed tables.

What did NOT move: GALLERY_CONFIG at /fofos-admin/gallery, subjects.ts's
base '/gallery', and home_gallery_media. Those are the Gallery section and the
legacy rows, and both are correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 18:01:15 +01:00

233 lines
9.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 = [
/*
The page builder's own tables, and the reason they are FIRST.
Parents before children: a section belongs to a page and a component to a section. `carousels` sits
below them because a gallery block names a set by id — carried in JSON rather than as a foreign
key, so nothing enforces the order, which is exactly why it is written down.
They were missing until 2026-08-31, and the symptom was the whole point of this file: publish
copied every table it knew about, the build rendered production faithfully, and the home page came
out as the empty-page placeholder because production had the seeded layout and no content. Nothing
failed. The list is explicit so that a table added later is CONSIDERED — this is what it looks like
when one is not.
*/
'pages',
'sections',
'components',
// The site's colours, faces and default gutter. A singleton, and production had no row at all —
// which reads as "the defaults" and is indistinguishable from a setting nobody has changed yet.
'site_layout',
'home_content',
'home_gallery_media',
'about_content',
'carousels',
'carousel_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') };
}