once both databases are there, the form becomes two rows of actions

A text box invites an edit nobody is here to make. Named and created, the two
things somebody wants are per-database actions, so the inputs and the button go
and each database becomes a label, a table count, and what can be done to it.

Push schema runs the WEBSITE's own `db:push`. Not drizzle-kit driven from here
with a config this plugin assembles — the schema, the drizzle version and the
push command all belong to that repository, and reimplementing any of them would
be a second definition of the site's database going stale on its own schedule.
This supplies the connection and runs their script.

The connection goes in the environment, which works because src/env-file.ts
consults .env files first and process.env last. So an ambient value is used only
where no file in the checkout sets that key, and this cannot quietly override a
connection somebody wrote down.

Their name guard still applies and should: env.ts refuses to connect unless the
database name matches what FOFOS_ENV expects. A plugin that worked around that
would be defeating the one check standing between development and live data.

"Schema pushed" is a COUNT of tables in public, not a comparison against the
site's expected tables — that list would be a second copy of the site's schema,
stale the first time it gains a table, and the question being asked is only
whether anything is in there yet.

Restore is present and disabled, gated on the schema being pushed. It is not
wired: which backup it reads from is not decided, and guessing at the source of
a restore is not a thing to do with a client's data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 12:14:39 +01:00
co-authored by Claude Opus 5
parent 30c52e6449
commit 0971764fca
3 changed files with 203 additions and 6 deletions
+82 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Check, CircleCheck, CircleDashed, Database, Loader2, Plus } from 'lucide-react';
import { ArrowUpFromLine, Check, CircleCheck, CircleDashed, Database, DatabaseBackup, Loader2, Plus } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -29,7 +29,7 @@ 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 };
type Named = { name: string | null; exists: boolean | null; tables: number | null };
type Status = { production: Named; development: Named; available: string[] };
export const DatabaseBlock = () => {
@@ -61,6 +61,33 @@ export const DatabaseBlock = () => {
void load();
}, [load]);
const [output, setOutput] = useState<string | null>(null);
/**
* Push the site's schema into one of the two.
*
* Runs the WEBSITE's own `db:push` — the schema and the drizzle version belong to that repository,
* and a plugin that reimplemented either would be a second definition of the site's database going
* stale on its own schedule.
*/
const push = async (target: 'production' | 'development') => {
setBusy(true);
setError(null);
setOutput(null);
try {
const result = await clientRef.current.post<{ ok: boolean; output: string; status: Status }>(
`/fofos-admin/_config/databases/${target}/push-schema`,
{},
);
setStatus(result.status);
setOutput(result.output);
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
/** Records the names and creates whichever is missing. The server does both, in that order. */
const apply = async () => {
setBusy(true);
@@ -123,6 +150,47 @@ export const DatabaseBlock = () => {
const label = missing.length ? `Create ${missing.length === 1 ? missing[0] : 'databases'}` : 'Save names';
/*
Settled: both named, both there. The fields have nothing left to do — a text box invites an edit
nobody is here to make, and the two things somebody DOES want at this point are per-database
actions rather than a form.
*/
const settled = Boolean(
status?.production.exists && status?.development.exists && !dirty && production.trim() && development.trim(),
);
const row = (label: string, target: 'production' | 'development', state: Named) => {
// "Schema pushed" is simply: something is in it. See the note on `tables`.
const pushed = (state.tables ?? 0) > 0;
return (
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="truncate font-mono text-xs">{state.name}</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground/70">
{state.tables === null ? '' : pushed ? `${state.tables} tables` : 'empty'}
</span>
<div className="flex shrink-0 gap-2">
<Button variant="outline" size="sm" onClick={() => void push(target)} disabled={busy}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowUpFromLine className="size-3.5" />}
Push schema
</Button>
{/* Nothing to restore INTO until there are tables to restore into — a data-only load against
an empty database fails on every insert, and the failure would read as a bad backup. */}
<Button variant="outline" size="sm" disabled title={pushed ? undefined : 'Push the schema first'}>
<DatabaseBackup className="size-3.5" />
Restore from backup
</Button>
</div>
</div>
);
};
return (
<Block
icon={Database}
@@ -137,7 +205,18 @@ export const DatabaseBlock = () => {
) : undefined
}
>
{!status ? null : (
{!status ? null : settled ? (
<>
{row('Production', 'production', status.production)}
{row('Development', 'development', status.development)}
{error && <p className="text-xs text-destructive">{error}</p>}
{output && (
<pre className="max-h-48 overflow-auto rounded bg-black p-3 font-mono text-[11px] whitespace-pre-wrap text-white/90">
{output}
</pre>
)}
</>
) : (
<>
{field('Production', 'what the live site reads', production, setProduction)}
{field('Development', 'what you work against', development, setDevelopment)}