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),
});
});