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
+104 -2
View File
@@ -36,6 +36,15 @@ export type DatabaseName = {
name: string | null;
/** Null when no name is set — "does it exist" has no answer for a database nobody has named. */
exists: boolean | null;
/**
* Tables in `public`. Null when there is no database to count them in.
*
* This is what "has the schema been pushed" means here, and it is deliberately a COUNT rather than a
* comparison against the site's expected tables. Reproducing that list would be a second copy of the
* site's schema living in the plugin, going stale the first time the site adds a table — and the
* question being asked is only "is this empty, or has something been put in it".
*/
tables: number | null;
};
export type DatabasesStatus = {
@@ -100,9 +109,16 @@ export async function readDatabases(): Promise<DatabasesStatus> {
select datname from pg_database where not datistemplate order by datname
`;
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) };
};
return {
production: { name: production, exists: production ? available.includes(production) : null },
development: { name: development, exists: development ? available.includes(development) : null },
production: await describe(production),
development: await describe(development),
available,
};
} finally {
@@ -110,6 +126,92 @@ export async function readDatabases(): Promise<DatabasesStatus> {
}
}
/** Tables in a database's `public` schema. Its own connection, since this asks INSIDE the database. */
async function countTables(name: string): Promise<number | null> {
const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL;
if (!raw) return null;
const url = new URL(raw);
url.pathname = `/${name}`;
const sql = postgres(url.toString(), { max: 1 });
try {
const rows = await sql<{ count: string }[]>`
select count(*)::text as count from information_schema.tables where table_schema = 'public'
`;
return Number(rows[0]?.count ?? 0);
} catch {
// A database that exists but will not answer is not the same as an empty one, and saying "0
// tables" about it would read as "ready for a schema push".
return null;
} finally {
await sql.end({ timeout: 5 });
}
}
/**
* Run the SITE's own `db:push` against one of the two databases.
*
* ── The site pushes its own schema ──
*
* Not drizzle-kit invoked from here with a config this plugin assembles. The schema, the drizzle
* version and the push command all belong to the website repository, and a plugin that reimplemented
* 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, and that works because of how they read it ──
*
* `src/env-file.ts` consults `.env` files first and `process.env` last, so an ambient value is used
* only when no file in the checkout sets that key. That is the right precedence for them and it means
* this cannot quietly override a connection somebody wrote down.
*
* ── Their guard still applies, and it should ──
*
* `env.ts` refuses to connect unless the database NAME matches what `FOFOS_ENV` expects. If the names
* chosen here disagree, this fails with their message rather than ours, which is the correct outcome:
* the site is the authority on which database it may talk to, and a plugin that worked around that
* would be defeating the one check standing between development and live data.
*/
export async function pushSchema(
target: 'production' | 'development',
websiteDir: string,
): Promise<{ ok: boolean; output: string }> {
const status = await readDatabases();
const name = target === 'production' ? status.production.name : status.development.name;
if (!name) throw new Error(`No ${target} database is named.`);
if (!status.available.includes(name)) throw new Error(`"${name}" does not exist yet.`);
const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL;
if (!raw) throw new Error('The platform has no Postgres connection configured.');
const url = new URL(raw);
url.pathname = `/${name}`;
const proc = Bun.spawn(['bun', 'run', 'db:push'], {
cwd: websiteDir,
env: {
...process.env,
FOFOS_ENV: target,
// Both, because which one their guard reads depends on FOFOS_ENV and setting only one would
// work for a single target by accident.
...(target === 'production' ? { POSTGRES_URL: url.toString() } : { POSTGRES_URL_DEV: url.toString() }),
},
stdout: 'pipe',
stderr: 'pipe',
stdin: 'ignore',
});
const timer = setTimeout(() => proc.kill(), 5 * 60 * 1000);
try {
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { ok: code === 0, output: `$ bun run db:push (FOFOS_ENV=${target})\n${stdout}${stderr}`.trim() };
} finally {
clearTimeout(timer);
}
}
/**
* Store the two names. Saving does NOT create anything — see the note in the panel.
*
+17 -1
View File
@@ -8,7 +8,8 @@ import { readMediaStatus, createMediaDir } from './media';
import { readSync, startSync } from './sync';
import { readRuns } from './ledger';
import { reconcile } from './derive';
import { createDatabases, readDatabases } from './databases';
import { createDatabases, pushSchema, readDatabases } from './databases';
import { join } from 'node:path';
import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client';
/**
@@ -293,6 +294,21 @@ router.get('/_config/media/sync', async (ctx) => {
*/
router.get('/_config/databases', async (ctx) => ctx.json(await readDatabases()));
router.post('/_config/databases/:target/push-schema', async (ctx) => {
const user = ctx.get('user');
const target = ctx.req.param('target');
if (target !== 'production' && target !== 'development') {
return ctx.json({ error: 'Target must be production or development.' }, 400);
}
try {
const settings = await readSettings(user.email);
const result = await pushSchema(target, join(settings.rootPath, 'website'));
return ctx.json({ ...result, status: await readDatabases() }, result.ok ? 200 : 400);
} catch (error) {
return ctx.json({ error: (error as Error).message }, 400);
}
});
router.post('/_config/databases/create', async (ctx) => {
// The names come with the request, so what gets created is what is on screen rather than what was
// stored at some earlier point. They are saved first, by the same validation as the PUT above.
+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)}