From 0971764fcac5381bbab8becad706399e0a28d676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 12:14:39 +0100 Subject: [PATCH] once both databases are there, the form becomes two rows of actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/databases.ts | 106 +++++++++++++++++++++++++++++++++++++++++- api/router.ts | 18 ++++++- web/DatabaseBlock.tsx | 85 +++++++++++++++++++++++++++++++-- 3 files changed, 203 insertions(+), 6 deletions(-) diff --git a/api/databases.ts b/api/databases.ts index 0cedab0..4ac41be 100644 --- a/api/databases.ts +++ b/api/databases.ts @@ -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 { 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 => { + 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 { } } +/** Tables in a database's `public` schema. Its own connection, since this asks INSIDE the database. */ +async function countTables(name: string): Promise { + 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. * diff --git a/api/router.ts b/api/router.ts index d02e21a..a8cd0c6 100644 --- a/api/router.ts +++ b/api/router.ts @@ -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. diff --git a/web/DatabaseBlock.tsx b/web/DatabaseBlock.tsx index f74e882..8f2ee62 100644 --- a/web/DatabaseBlock.tsx +++ b/web/DatabaseBlock.tsx @@ -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(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 ( +
+
+

{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 ( { ) : undefined } > - {!status ? null : ( + {!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)}