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".
|
* question being asked is only "is this empty, or has something been put in it".
|
||||||
*/
|
*/
|
||||||
tables: number | null;
|
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 = {
|
export type DatabasesStatus = {
|
||||||
@@ -111,9 +122,9 @@ export async function readDatabases(): Promise<DatabasesStatus> {
|
|||||||
const available = rows.map((entry) => entry.datname);
|
const available = rows.map((entry) => entry.datname);
|
||||||
|
|
||||||
const describe = async (name: string | null): Promise<DatabaseName> => {
|
const describe = async (name: string | null): Promise<DatabaseName> => {
|
||||||
if (!name) return { name: null, exists: null, tables: null };
|
if (!name) return { name: null, exists: null, tables: null, rows: null, lastWrite: null };
|
||||||
if (!available.includes(name)) return { name, exists: false, tables: null };
|
if (!available.includes(name)) return { name, exists: false, tables: null, rows: null, lastWrite: null };
|
||||||
return { name, exists: true, tables: await countTables(name) };
|
return { name, exists: true, ...(await describeContents(name)) };
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
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> {
|
async function countTables(name: string): Promise<number | null> {
|
||||||
const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL;
|
const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL;
|
||||||
if (!raw) return null;
|
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)
|
? await reconcile(user.email).then((state) => state.missing.length + state.stale.length)
|
||||||
: null;
|
: 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({
|
return ctx.json({
|
||||||
rootSet: settings.isSet,
|
rootSet: settings.isSet,
|
||||||
websiteReady: website.isRepo,
|
websiteReady: website.isRepo,
|
||||||
mediaReady: media.complete && outstanding === 0,
|
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
@@ -67,7 +67,15 @@ const size = (bytes: number) =>
|
|||||||
|
|
||||||
const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : null);
|
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 client = useClient();
|
||||||
const clientRef = useRef(client);
|
const clientRef = useRef(client);
|
||||||
clientRef.current = client;
|
clientRef.current = client;
|
||||||
@@ -110,6 +118,18 @@ export const BackupsRow = ({ prodName, devName }: { prodName: string; devName: s
|
|||||||
void load();
|
void load();
|
||||||
}, [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 () => {
|
const save = async () => {
|
||||||
setBusy('save');
|
setBusy('save');
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|||||||
+80
-4
@@ -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.
|
* 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[] };
|
type Status = { production: Named; development: Named; available: string[] };
|
||||||
|
|
||||||
export const DatabaseBlock = () => {
|
export const DatabaseBlock = () => {
|
||||||
@@ -69,6 +79,29 @@ export const DatabaseBlock = () => {
|
|||||||
const [output, setOutput] = useState<string | null>(null);
|
const [output, setOutput] = useState<string | null>(null);
|
||||||
const [failed, setFailed] = useState(false);
|
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.
|
* 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>
|
<p className="truncate font-mono text-xs">{state.name}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground/70">
|
{/* Tables say the schema is there; rows say the site has content. Two databases with identical
|
||||||
{state.tables === null ? '' : state.tables > 0 ? `${state.tables} tables` : 'empty'}
|
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>
|
</span>
|
||||||
|
|
||||||
<div className="flex shrink-0 gap-2">
|
<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 (
|
return (
|
||||||
<Block
|
<Block
|
||||||
icon={Database}
|
icon={Database}
|
||||||
title="Database"
|
title="Database"
|
||||||
hint="Where the fofos site's own data lives — on Officer's own Postgres."
|
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={
|
status={
|
||||||
flashing ? (
|
flashing ? (
|
||||||
<span className="flex items-center gap-1 text-xs text-success">
|
<span className="flex items-center gap-1 text-xs text-success">
|
||||||
<Check className="size-3.5" />
|
<Check className="size-3.5" />
|
||||||
Saved
|
Saved
|
||||||
</span>
|
</span>
|
||||||
|
) : settledNow ? (
|
||||||
|
<CircleCheck className="size-6 text-success" />
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -241,7 +317,7 @@ export const DatabaseBlock = () => {
|
|||||||
<Console output={output} failed={failed} />
|
<Console output={output} failed={failed} />
|
||||||
|
|
||||||
{/* Below the two databases, because it is about both of them rather than either. */}
|
{/* 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. */}
|
{/* 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">
|
<div className="flex justify-end border-t border-border pt-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user