From af31b57cdf3f8f6ec45322f60ba93f08b85a5847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 13:32:42 +0100 Subject: [PATCH] the databases say what is in them, and the block folds away when it is done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two databases with identical structure look identical until you count what is in them — one is the live site and one is a copy. So a row now reads "18 tables · 155 rows" with the time anything was last written, rather than only reporting that a schema exists. Counted with query_to_xml over information_schema, which is exact. reltuples from the planner would be free and is an ESTIMATE: it reads as zero on a table nobody has analysed, which is precisely the freshly restored database this is meant to reassure somebody about. That is not a hypothetical — it is why pg_stat_user_tables reported an empty database earlier today that had 155 rows in it. And the block collapses with a check once everything is in order, on the same terms as Root location: both databases existing with a schema, and backups configured. Not backups having RUN — a site set up an hour ago has taken none, and calling that incomplete is telling somebody to fix what is not broken. Seeded from /summary so the first paint is already right, then kept live by what the block and BackupsRow know. The `null` sentinel in `collapsed` is left alone while unsettled, which is the bug that stopped Root location folding at all. BackupsRow reports its bit from an EFFECT, not during render: calling the parent's setter mid-render is an update to a different component, which React refuses outright. Co-Authored-By: Claude Opus 5 --- api/databases.ts | 76 ++++++++++++++++++++++++++++++++++++--- api/router.ts | 17 +++++++++ web/BackupsRow.tsx | 22 +++++++++++- web/DatabaseBlock.tsx | 84 ++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 190 insertions(+), 9 deletions(-) diff --git a/api/databases.ts b/api/databases.ts index fa8e9eb..5b5f5d5 100644 --- a/api/databases.ts +++ b/api/databases.ts @@ -45,6 +45,17 @@ export type DatabaseName = { * question being asked is only "is this empty, or has something been put in it". */ tables: number | null; + /** + * Rows across every table, and the newest timestamp anything carries. + * + * A table count says the schema is there. It says nothing about whether the site has any CONTENT, + * which is the question somebody actually has when looking at two databases with identical + * structure — one of these is the live site and one is a copy, and they look the same until you + * count what is in them. + */ + rows: number | null; + /** The most recent `created_at` anywhere, or null when no table has one. */ + lastWrite: string | null; }; export type DatabasesStatus = { @@ -111,9 +122,9 @@ export async function readDatabases(): Promise { const available = rows.map((entry) => entry.datname); const describe = async (name: string | null): Promise => { - if (!name) return { name: null, exists: null, tables: null }; - if (!available.includes(name)) return { name, exists: false, tables: null }; - return { name, exists: true, tables: await countTables(name) }; + if (!name) return { name: null, exists: null, tables: null, rows: null, lastWrite: null }; + if (!available.includes(name)) return { name, exists: false, tables: null, rows: null, lastWrite: null }; + return { name, exists: true, ...(await describeContents(name)) }; }; return { @@ -126,7 +137,64 @@ export async function readDatabases(): Promise { } } -/** Tables in a database's `public` schema. Its own connection, since this asks INSIDE the database. */ +/** + * What is actually in a database: tables, rows, and when anything was last written. + * + * One connection for all three, because it asks INSIDE the database and opening three would be three + * connections to answer one question. + * + * Rows are counted with `query_to_xml` over `information_schema`, which is the standard way to get an + * exact count per table without knowing the table names in advance. `reltuples` from the planner would + * be free, and it is an ESTIMATE — it reads as zero on a table nobody has analysed, which is exactly + * the freshly restored database this is meant to reassure somebody about. + * + * `lastWrite` looks only at columns called `created_at`. Most tables here have one and the ones that do + * not are settings rows; a schema-agnostic answer is not available and a wrong one would be worse than + * none, so this returns null rather than guessing when nothing qualifies. + */ +async function describeContents(name: string): Promise<{ tables: number | null; rows: number | null; lastWrite: string | null }> { + const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL; + if (!raw) return { tables: null, rows: null, lastWrite: null }; + const url = new URL(raw); + url.pathname = `/${name}`; + + const sql = postgres(url.toString(), { max: 1 }); + try { + const [counts] = await sql<{ tables: string; rows: string }[]>` + select count(*)::text as tables, coalesce(sum(n), 0)::text as rows from ( + select (xpath('/row/c/text()', + query_to_xml(format('select count(*) as c from %I.%I', table_schema, table_name), false, true, '') + ))[1]::text::bigint as n + from information_schema.tables + where table_schema = 'public' and table_type = 'BASE TABLE' + ) t + `; + + const [latest] = await sql<{ at: string | null }[]>` + select max(at)::text as at from ( + select (xpath('/row/c/text()', + query_to_xml(format('select max(created_at) as c from %I.%I', table_schema, table_name), false, true, '') + ))[1]::text::timestamptz as at + from information_schema.columns + where table_schema = 'public' and column_name = 'created_at' + ) t + `; + + return { + tables: Number(counts?.tables ?? 0), + rows: Number(counts?.rows ?? 0), + lastWrite: latest?.at ?? null, + }; + } catch { + // Exists but will not answer. Null throughout, because "0 rows" about a database that could not be + // read would say the opposite of what is true. + return { tables: null, rows: null, lastWrite: null }; + } finally { + await sql.end({ timeout: 5 }); + } +} + +/** Tables alone, for the places that only need to know whether a schema has been pushed. */ async function countTables(name: string): Promise { const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL; if (!raw) return null; diff --git a/api/router.ts b/api/router.ts index 696f6c7..5375782 100644 --- a/api/router.ts +++ b/api/router.ts @@ -417,10 +417,27 @@ router.get('/_config/summary', async (ctx) => { ? await reconcile(user.email).then((state) => state.missing.length + state.stale.length) : null; + /* + The Database block folds away on the same terms as Root location, so its answer comes from here too + — for the same reason: a block that cannot know it is finished until its children report must paint + open and then shut, on every load. + + "Finished" is both databases existing WITH a schema in them, and backups configured. Not backups + having run: a site that has just been set up has taken none, and telling somebody their setup is + incomplete because nothing has happened yet is telling them to fix something that is not broken. + */ + const databases = await readDatabases(); + const backups = await readBackups(); + return ctx.json({ rootSet: settings.isSet, websiteReady: website.isRepo, mediaReady: media.complete && outstanding === 0, + databasesReady: + Boolean(databases.production.exists && databases.development.exists) && + (databases.production.tables ?? 0) > 0 && + (databases.development.tables ?? 0) > 0 && + Boolean(backups.cron && backups.sshHost && backups.sshPath), }); }); diff --git a/web/BackupsRow.tsx b/web/BackupsRow.tsx index 12b265f..dd60c41 100644 --- a/web/BackupsRow.tsx +++ b/web/BackupsRow.tsx @@ -67,7 +67,15 @@ const size = (bytes: number) => const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : null); -export const BackupsRow = ({ prodName, devName }: { prodName: string; devName: string }) => { +export const BackupsRow = ({ + prodName, + devName, + onSettled, +}: { + prodName: string; + devName: string; + onSettled?: (settled: boolean) => void; +}) => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; @@ -110,6 +118,18 @@ export const BackupsRow = ({ prodName, devName }: { prodName: string; devName: s void load(); }, [load]); + /* + One bit upward, so the block above can decide whether the whole section is finished. + + In an effect and not during render. Calling the parent's setter while this component renders is an + update to a DIFFERENT component mid-render, which React refuses outright — and the version of this + that did it would have thrown on the first paint. + */ + const settledUp = Boolean(settings?.cron && settings?.sshHost && settings?.sshPath); + useEffect(() => { + onSettled?.(settledUp); + }, [settledUp, onSettled]); + const save = async () => { setBusy('save'); setError(null); diff --git a/web/DatabaseBlock.tsx b/web/DatabaseBlock.tsx index d40b1db..1d9f116 100644 --- a/web/DatabaseBlock.tsx +++ b/web/DatabaseBlock.tsx @@ -31,7 +31,17 @@ import { useFlash } from './useFlash'; * typing a name actually has, and answering it about a different name would be worse than silence. */ -type Named = { name: string | null; exists: boolean | null; tables: number | null }; +/** Short, because it sits in a corner of a row: `29 Aug, 13:08`. */ +const shortWhen = (iso: string) => + new Date(iso).toLocaleString(undefined, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); + +type Named = { + name: string | null; + exists: boolean | null; + tables: number | null; + rows: number | null; + lastWrite: string | null; +}; type Status = { production: Named; development: Named; available: string[] }; export const DatabaseBlock = () => { @@ -69,6 +79,29 @@ export const DatabaseBlock = () => { const [output, setOutput] = useState(null); const [failed, setFailed] = useState(false); + /* + Finished, and therefore worth folding away — the same treatment as Root location, for the same + reason: everything here is in order most of the time, and a block that is three paragraphs of + "fine" pushes whatever is not fine off the screen. + + Seeded from `/summary` so the first paint is already right, then kept live by what this block and + `BackupsRow` actually know. See RootLocationBlock for why both sources are needed. + */ + const [summaryReady, setSummaryReady] = useState(null); + const [backupsReady, setBackupsReady] = useState(false); + const [collapsed, setCollapsed] = useState(null); + + useEffect(() => { + void (async () => { + try { + const summary = await clientRef.current.get<{ databasesReady: boolean }>('/fofos-admin/_config/summary'); + setSummaryReady(summary.databasesReady); + } catch { + setSummaryReady(false); + } + })(); + }, []); + /** * Push the site's schema into one of the two. * @@ -193,8 +226,21 @@ export const DatabaseBlock = () => {

{state.name}

- - {state.tables === null ? '' : state.tables > 0 ? `${state.tables} tables` : 'empty'} + {/* Tables say the schema is there; rows say the site has content. Two databases with identical + structure look the same until you count what is in them. */} + + {state.tables === null ? ( + '' + ) : state.tables === 0 ? ( + 'empty' + ) : ( + <> + {state.tables} tables · {state.rows ?? 0} rows + {state.lastWrite ? ( + last write {shortWhen(state.lastWrite)} + ) : null} + + )}
@@ -218,17 +264,47 @@ export const DatabaseBlock = () => { ); }; + /* + The summary answers on arrival; this block and BackupsRow answer from then on. Taking the summary + while nothing local has resolved, and the live value once it has, is what makes the first paint + correct without freezing the block on a snapshot. + */ + const live = Boolean( + status?.production.exists && + status?.development.exists && + (status.production.tables ?? 0) > 0 && + (status.development.tables ?? 0) > 0 && + backupsReady, + ); + const settledNow = status ? live : summaryReady === true; + + useEffect(() => { + setCollapsed((chosen) => { + // Leave `null` alone while unsettled — spending the sentinel before anything has resolved is what + // stopped Root location from ever folding itself away. See the note there. + if (!settledNow) return chosen === null ? null : false; + return chosen === null ? true : chosen; + }); + }, [settledNow]); + return ( Saved + ) : settledNow ? ( + ) : undefined } > @@ -241,7 +317,7 @@ export const DatabaseBlock = () => { {/* Below the two databases, because it is about both of them rather than either. */} - + {/* The common case is both. Doing them one at a time is still there for when it is not. */}