the databases say what is in them, and the block folds away when it is done

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 13:32:42 +01:00
co-authored by Claude Opus 5
parent fb234ede5d
commit af31b57cdf
4 changed files with 190 additions and 9 deletions
+72 -4
View File
@@ -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<DatabasesStatus> {
const available = rows.map((entry) => entry.datname);
const describe = async (name: string | null): Promise<DatabaseName> => {
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<DatabasesStatus> {
}
}
/** 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<number | null> {
const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL;
if (!raw) return null;
+17
View File
@@ -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),
});
});
+21 -1
View File
@@ -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);
+80 -4
View File
@@ -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<string | null>(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<boolean | null>(null);
const [backupsReady, setBackupsReady] = useState(false);
const [collapsed, setCollapsed] = useState<boolean | null>(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 = () => {
<p className="truncate font-mono text-xs">{state.name}</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground/70">
{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. */}
<span className="shrink-0 text-right text-xs text-muted-foreground/70">
{state.tables === null ? (
''
) : state.tables === 0 ? (
'empty'
) : (
<>
{state.tables} tables · {state.rows ?? 0} rows
{state.lastWrite ? (
<span className="block text-muted-foreground/50">last write {shortWhen(state.lastWrite)}</span>
) : null}
</>
)}
</span>
<div className="flex shrink-0 gap-2">
@@ -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 (
<Block
icon={Database}
title="Database"
hint="Where the fofos site's own data lives — on Officer's own Postgres."
// Only offered once there is nothing to do. A block you can shut on unfinished setup is a way to
// hide the thing you came here to fix.
collapsible={settledNow}
collapsed={settledNow && collapsed !== false}
onCollapsedChange={setCollapsed}
status={
flashing ? (
<span className="flex items-center gap-1 text-xs text-success">
<Check className="size-3.5" />
Saved
</span>
) : settledNow ? (
<CircleCheck className="size-6 text-success" />
) : undefined
}
>
@@ -241,7 +317,7 @@ export const DatabaseBlock = () => {
<Console output={output} failed={failed} />
{/* Below the two databases, because it is about both of them rather than either. */}
<BackupsRow prodName={status.production.name ?? 'production'} devName={status.development.name ?? 'development'} />
<BackupsRow onSettled={setBackupsReady} prodName={status.production.name ?? 'production'} devName={status.development.name ?? 'development'} />
{/* The common case is both. Doing them one at a time is still there for when it is not. */}
<div className="flex justify-end border-t border-border pt-3">