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>
233 lines
9.9 KiB
TypeScript
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') };
|
|
}
|