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:
+104
-2
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user