Files
platform/src/servers/plugins/tables.ts
T
pastilhasandClaude Opus 5 3c03e61c5e delete a plugin and everything it owns, and stop trusting exit codes
Two halves of the same problem: a plugin installed onto a fresh machine, reported
success at every step, mounted, started its sidecar, registered — and then failed
its first query with 42P01, because `offscale_servers` had never been created.
Nothing said so, and there was no way back to a clean machine to try again.

── The install now asks Postgres ──

`pushSchema` only checked the exit code, and `db:push` is a compound command:
generate the barrel, cd, run drizzle-kit. It can exit 0 having created nothing.
So the install now reads the plugin's schema for the tables it declares and
verifies they exist, failing the install with a message naming what is missing
instead of leaving a stack trace to appear in a sidecar log minutes later.

Tables that are NOT prefixed with the app name are reported as a warning rather
than a failure — they still get created, but `purge` will not remove them and a
reinstall would find them already there.

── `purge`, next to an uninstall that deliberately keeps everything ──

Uninstall is careful on purpose: every table and row survives so a reinstall puts
you back where you were. That is what makes it safe to try, and it left no way to
get to a CLEAN machine. A second install finds the directory (so the catalogue
hides the plugin) and the tables (so push has nothing to do, and a broken schema
stays broken).

So a different verb, a different word, its own confirmation. It reads the table
list from the schema BEFORE deleting the source, uninstalls (stopping the sidecar
— nothing may be running against tables about to vanish), drops, deletes the
directory, then regenerates the barrel unconditionally, since a barrel naming a
directory that no longer exists is what stopped a server booting yesterday.

The table list comes from the plugin's own schema, not from `like '<app>_%'` —
that query eventually drops somebody else's table, because the prefix is a
convention nothing validates and `music` is a prefix of `music-videos`. The
prefix is used as a GUARD instead: a schema module could export `users`, and
anything outside the plugin's own namespace is refused rather than dropped.

── Verified as a full loop on this machine ──

purge offscale → table dropped, directory deleted, barrel regenerated → the
marketplace offers it again → install → cloned, ssh checked, "schema: applied in
2072ms (1 table verified)", dock pinned, mounted, sidecar started.

I left offscale INSTALLED here rather than restoring the half-state it was in
(no install row, table present, sidecar running).

tsgo clean, frontend builds, 873 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:46:16 +00:00

66 lines
3.2 KiB
TypeScript

import { getTableName, is, sql } from 'drizzle-orm';
import { PgTable } from 'drizzle-orm/pg-core';
import { db } from 'officerdb/db';
import type { DiscoveredPlugin } from './manifest';
// Which tables a plugin owns, and whether they are actually there.
//
// ── Why this is asked of the SCHEMA rather than of Postgres ──
//
// The obvious implementation is `drop table where name like '<appName>_%'`. It is also the one that
// eventually drops somebody else's table: the `<appName>_` prefix is a CONVENTION and nothing validates
// it, so a plugin called `music` and a plugin called `music-videos` share a prefix, and a table named by
// hand can match a plugin that never created it.
//
// So the list comes from the plugin's own `db/schema.ts` — the same file `db:push` reads, which makes
// "what this plugin created" and "what this deletes" the same question with the same answer.
//
// The prefix is then used as a GUARD rather than as the query. A schema module is arbitrary TypeScript
// and could export `users`; refusing anything not prefixed means the worst a wrong or hostile plugin can
// do here is fail to clean up after itself.
/** A plugin's tables, by name, as its schema declares them. Empty for a plugin with no `db/`. */
export async function declaredTables(plugin: DiscoveredPlugin): Promise<{ tables: string[]; refused: string[] }> {
if (!plugin.schema) return { tables: [], refused: [] };
const module = (await import(plugin.schema)) as Record<string, unknown>;
const prefix = `${plugin.appName.replace(/-/g, '_')}_`;
const tables: string[] = [];
const refused: string[] = [];
for (const value of Object.values(module)) {
if (!is(value, PgTable)) continue;
const name = getTableName(value);
// Also accept the literal appName as a table, for a plugin whose single table IS its name.
if (name === plugin.appName || name.startsWith(prefix) || name.startsWith(`${plugin.appName}_`)) tables.push(name);
else refused.push(name);
}
return { tables, refused };
}
/** Which of these exist in Postgres right now. */
export async function existingTables(names: string[]): Promise<string[]> {
if (names.length === 0) return [];
const rows = await db.execute<{ table_name: string }>(
sql`select table_name from information_schema.tables where table_schema = 'public' and table_name in ${sql.raw(
`(${names.map((n) => `'${n.replace(/'/g, "''")}'`).join(', ')})`,
)}`,
);
// drizzle's postgres-js driver returns the rows array directly; the pg driver wraps them in `.rows`.
const list = Array.isArray(rows) ? rows : ((rows as { rows?: { table_name: string }[] }).rows ?? []);
return list.map((r) => r.table_name);
}
/**
* Drop them. `cascade`, because a plugin's tables reference each other and dropping them in dependency
* order is a problem this does not need to solve — everything being dropped belongs to the same plugin.
*
* Names come from `declaredTables`, which has already refused anything outside the plugin's prefix, and
* are quoted here as identifiers. Nothing from a URL or a request body reaches this.
*/
export async function dropTables(names: string[]): Promise<void> {
for (const name of names) {
await db.execute(sql.raw(`drop table if exists "${name.replace(/"/g, '""')}" cascade`));
}
}