import { useCallback, useEffect, useRef, useState } from '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'; import { readableError } from './errors'; import { Block } from './Block'; 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(''); const [busy, setBusy] = useState(false); 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); /** * 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); 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(false); } }; 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) => { // "Schema pushed" is simply: something is in it. See the note on `tables`. const pushed = (state.tables ?? 0) > 0; return (

{label}

{state.name}

{state.tables === null ? '' : pushed ? `${state.tables} tables` : 'empty'}
{/* 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. */}
); }; return ( Saved ) : undefined } > {!status ? null : settled ? ( <> {row('Production', 'production', status.production)} {row('Development', 'development', status.development)} {error &&

{error}

} {output && (
              {output}
            
)} ) : ( <> {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. */}
)}
); };