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:
+72
-4
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user