import { useCallback, useEffect, useRef, useState } from 'react'; import { ArrowUpFromLine, Check, CircleCheck, CircleDashed, Database, Loader2, Plus } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { readableError } from './errors'; import { Block } from './Block'; import { Console } from './Console'; import { BackupsRow } from './BackupsRow'; import { useFlash } from './useFlash'; /** * Where the fofos site's own data lives — two databases, named here. * * ── ONE button ── * * It shipped with two — Save, then Create — on the reasoning that naming and creating are different * decisions. They are not, from the outside: you type two names because you want two databases. Worse, * Create keyed off the SAVED names, so on a fresh screen with both fields filled it did not appear at * all, and the hint explaining why was behind the same condition. Two filled fields, one Save button, * and no way to tell what came next. * * So one button that records the names and creates whatever is not there, labelled with whichever of * those it is actually about to do. Recording the name you just used to make a database is not a side * effect worth a second click. * * ── It reports what is THERE, against what is TYPED ── * * The tick beside each field asks `pg_database` whether a database of that name exists, and it asks * about the text in the box rather than the text that was last saved. That is the question somebody * 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 }; type Status = { production: Named; development: Named; available: string[] }; export const DatabaseBlock = () => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [status, setStatus] = useState(null); const [production, setProduction] = useState(''); const [development, setDevelopment] = useState(''); /* WHICH action is running, not merely that one is. A single boolean put both rows into a spinner while only the pressed one was doing anything — the other looked like it had been started too, and then finished without having done a thing. */ const [busy, setBusy] = useState<'production' | 'development' | 'both' | 'apply' | null>(null); const [error, setError] = useState(null); const [flashing, flash] = useFlash(); const load = useCallback(async () => { try { const current = await clientRef.current.get('/fofos-admin/_config/databases'); setStatus(current); // Only seed the fields from the server when they are untouched, so a reload landing mid-typing // cannot replace what somebody is in the middle of writing. setProduction((typed) => typed || (current.production.name ?? '')); setDevelopment((typed) => typed || (current.development.name ?? '')); } catch { setStatus(null); } }, []); useEffect(() => { void load(); }, [load]); const [output, setOutput] = useState(null); const [failed, setFailed] = useState(false); /** * 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 pushOne = async (target: 'production' | 'development'): Promise => { const result = await clientRef.current.post<{ ok: boolean; output: string; status: Status }>( `/fofos-admin/_config/databases/${target}/push-schema`, {}, ); setStatus(result.status); return result.output; }; const push = async (target: 'production' | 'development' | 'both') => { setBusy(target); setError(null); setFailed(false); setOutput(null); try { const transcript = target === 'both' ? // Production first, then development. Sequential rather than parallel: they run the same // drizzle against one Postgres, and two at once buys nothing but an interleaved transcript. [await pushOne('production'), await pushOne('development')].join('\n\n') : await pushOne(target); setOutput(transcript); } catch (failure) { setError(readableError(failure)); setFailed(true); } finally { setBusy(null); /* Re-read regardless. The push route returns the new status, but only on the path where it succeeded — a failure answers 400 and throws, and a push that failed halfway can still have changed the count. This is the difference between the panel being right and the panel being right when nothing went wrong. */ void load(); } }; /** Records the names and creates whichever is missing. The server does both, in that order. */ const apply = async () => { setBusy('apply'); setError(null); try { const result = await clientRef.current.post<{ created: string[]; status: Status }>( '/fofos-admin/_config/databases/create', { production, development }, ); setStatus(result.status); flash(); } catch (failure) { setError(readableError(failure)); } finally { setBusy(null); } }; const field = (label: string, hint: string, value: string, onChange: (next: string) => void) => { // Against the typed text and the real list from `pg_database`. Nothing to say about an empty box. const typed = value.trim(); const exists = typed ? (status?.available.includes(typed) ?? false) : null; const Icon = exists ? CircleCheck : CircleDashed; return (
{hint}
onChange(event.target.value)} placeholder={label === 'Production' ? 'fofos_website' : 'fofos_website_dev'} className="w-full font-mono text-xs" autoComplete="off" spellCheck={false} /> {exists === null ? null : ( <> {exists ? 'exists' : 'not yet'} )}
); }; const dirty = production.trim() !== (status?.production.name ?? '') || development.trim() !== (status?.development.name ?? ''); // Typed, non-empty, and not on the instance. What the one button is about to make. const missing = [production.trim(), development.trim()].filter( (name) => name && !(status?.available.includes(name) ?? false), ); 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) => { return (

{label}

{state.name}

{state.tables === null ? '' : state.tables > 0 ? `${state.tables} tables` : 'empty'}
{/* Spins only when THIS row is the one running, or when both are. */}
); }; return ( Saved ) : undefined } > {!status ? null : settled ? ( <> {row('Production', 'production', status.production)} {row('Development', 'development', status.development)} {error &&

{error}

} {/* Below the two databases, because it is about both of them rather than either. */} {/* The common case is both. Doing them one at a time is still there for when it is not. */}
) : ( <> {field('Production', 'what the live site reads', production, setProduction)} {field('Development', 'what you work against', development, setDevelopment)} {error &&

{error}

}
{/* Nothing missing and nothing changed means there is genuinely nothing to press. */}
)}
); };