diff --git a/src/servers/plugins/install.ts b/src/servers/plugins/install.ts index 8b248f41..8f1384ff 100644 --- a/src/servers/plugins/install.ts +++ b/src/servers/plugins/install.ts @@ -14,7 +14,7 @@ import { discoverPlugins } from './discover'; import { fetchPlugin } from './fetch'; import { publishAssets, unpublishAssets } from '../app-store/assets'; import { refreshPluginMounts, snapshotPlugins } from './mount'; -import { generatePluginSchemas, pushSchema } from './schema'; +import { generatePluginSchemas, pushPluginSchema } from './schema'; import { declaredTables, dropTables, existingTables } from './tables'; import type { OnLog } from './run-logged'; import { installDependencies, manualInstallHint, reportDependencies, stillMissing } from './os-deps'; @@ -228,21 +228,24 @@ export async function installPlugin( const { plugins } = await discoverPlugins(); generatePluginSchemas(plugins); - const pushed = await pushSchema(onLog); + // The table list first: it is both what the push is scoped to and what is verified afterwards. + const { tables, refused } = await declaredTables(plugin); + + const pushed = await pushPluginSchema(plugin, tables, onLog); if (!pushed.ok) { return { ok: false, appName, steps, error: `schema push failed: ${pushed.output}` }; } // Exit code 0 is not proof the tables exist. // - // `db:push` is a compound command — generate the barrel, cd, run drizzle-kit — and an install was - // reported as successful on a machine where `offscale_servers` was never created. The plugin then + // An install reported success on a machine where `offscale_servers` was never created: the plugin // mounted, its sidecar started, registered, and failed its first query with 42P01. Every step said - // OK and the plugin was unusable, which is the worst shape a failure can take. + // OK and the plugin was unusable, which is the worst shape a failure can take. The scoped push + // above removes the cause; this stays because "the command exited 0" and "the tables are there" + // are different claims, and only one of them is the one that matters. // // So the install asks Postgres. A missing table fails the install HERE, where the message can say // what happened, instead of appearing later as a stack trace in a sidecar log. - const { tables, refused } = await declaredTables(plugin); if (refused.length) { // Not fatal: the plugin still works, and the tables are still created by push. But they are // outside its prefix, so `purge` will not remove them and nobody will notice until a reinstall diff --git a/src/servers/plugins/schema.ts b/src/servers/plugins/schema.ts index de422cac..50187d98 100644 --- a/src/servers/plugins/schema.ts +++ b/src/servers/plugins/schema.ts @@ -1,5 +1,8 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, join, relative } from 'node:path'; +import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { basename, dirname, join, relative } from 'node:path'; +import { tmpdir } from 'node:os'; +import { sql } from 'drizzle-orm'; +import { db } from 'officerdb/db'; import { PLATFORM_DIR } from '../data-path'; import { runLogged, type OnLog } from './run-logged'; import type { DiscoveredPlugin } from './manifest'; @@ -51,6 +54,141 @@ export function generatePluginSchemas(plugins: DiscoveredPlugin[]): void { export type PushResult = { ok: boolean; ms: number; output: string }; +/** + * Create ONE plugin's tables, and nothing else. + * + * ── Why the global push cannot be used for this ── + * + * `bun db:push` hands drizzle-kit the whole schema and the whole database, and asks it to make them + * match. On a machine whose database contains tables the current schema does not declare — a plugin that + * is not on disk, anything left from before an extraction — that produces two failures at once: + * + * 1. **It asks a question nobody can answer.** Seeing a new table appear while others are due to be + * dropped, drizzle-kit prompts "Is offscale_servers created or renamed from another table?" and lists + * every candidate. The prompt needs a TTY: `--force` does not answer it, `< /dev/null` does not + * answer it, and piping newlines does not either — measured, it hangs until it is killed. Without a + * terminal the push applies NOTHING and still exits 0, which is exactly how an install reported + * success on a machine where its table was never created. + * + * 2. **`--force` would be dangerous if it did work.** Force means "apply the destructive statements + * without asking", and the destructive statements here are `drop table` for every plugin whose + * directory is absent. An install of one plugin could delete another's data. + * + * ── `tablesFilter` was tried first, and is not enough ── + * + * Scoping a push to the plugin's tables stops it dropping other TABLES, and it still diffs sequences. + * Measured: an install of offscale issued `drop sequence passkey_challenges_id_seq` — a core platform + * sequence — because the plugin's schema does not declare it. Postgres refused only because a column + * default depended on it. A sequence without dependents would have gone. + * + * ── So: generate the SQL, then apply it ── + * + * `drizzle-kit generate` against the plugin's schema alone, with an empty output directory, emits + * exactly the statements that build that schema from nothing: its CREATE TABLE, its indexes, its foreign + * keys. It diffs against the migration journal — which is empty — rather than against the database, so + * it cannot emit a DROP of anything, for any object type. There is no destructive statement to guard. + * + * Applying it is then idempotent by exception: an object that already exists raises 42P07 or 42710 and + * is skipped, which is the normal case for a reinstall, because uninstall deliberately keeps data. + * + * `[open]` This creates; it does not migrate. A plugin whose new version adds a column will not get it — + * the CREATE fails as "already exists" and the column is silently absent. Plugin upgrades need real + * migrations, and that is a separate feature; the old `push` appeared to handle it, at the price of + * being able to drop half the database. + */ +export async function pushPluginSchema(plugin: DiscoveredPlugin, tables: string[], onLog?: OnLog): Promise { + if (!plugin.schema) return { ok: true, ms: 0, output: 'no db/schema.ts — nothing to push' }; + const started = Date.now(); + + const dbDir = join(PLATFORM_DIR, 'src/databases/officer_db'); + const configPath = join(dbDir, `.plugin-gen.${plugin.appName}.config.ts`); + const outDir = join(tmpdir(), `officer-plugin-schema-${plugin.appName}-${process.pid}`); + + const config = `import { defineConfig } from 'drizzle-kit'; + +// GENERATED for one install of "${plugin.appName}". Deleted immediately afterwards; see plugins/schema.ts. +export default defineConfig({ + schema: ${JSON.stringify(plugin.schema)}, + out: ${JSON.stringify(outDir)}, + dialect: 'postgresql', +}); +`; + + const say = async (line: string) => { + try { + await onLog?.(line); + } catch { + /* nobody listening */ + } + }; + + try { + rmSync(outDir, { recursive: true, force: true }); + writeFileSync(configPath, config); + + // `generate`, never `push`. See the note above this function. + const generated = await runLogged(['bunx', 'drizzle-kit', 'generate', `--config=${basename(configPath)}`], { + cwd: dbDir, + onLog, + }); + if (!generated.ok) { + return { ok: false, ms: Date.now() - started, output: generated.output.slice(-600) }; + } + + const files = existsSync(outDir) + ? readdirSync(outDir) + .filter((f) => f.endsWith('.sql')) + .sort() + : []; + if (files.length === 0) { + return { ok: false, ms: Date.now() - started, output: 'drizzle-kit generated no SQL' }; + } + + let created = 0; + let existed = 0; + for (const file of files) { + const statements = readFileSync(join(outDir, file), 'utf-8') + .split('--> statement-breakpoint') + .map((chunk) => chunk.trim()) + .filter(Boolean); + + for (const statement of statements) { + try { + await db.execute(sql.raw(statement)); + created += 1; + await say(`applied: ${statement.split('\n')[0]!.slice(0, 100)}`); + } catch (err) { + // Already there. A reinstall over surviving tables is the NORMAL case — uninstall keeps data on + // purpose — so an object that exists is a no-op, not a failure. Anything else is real. + // + // The code is read from `cause` as well as from the error itself: drizzle wraps driver errors in + // a `DrizzleQueryError`, so the SQLSTATE that says "duplicate table" is one level down. Checking + // only the top level made every reinstall fail on its own CREATE TABLE — caught by testing the + // second install rather than the first. + const message = err instanceof Error ? err.message : String(err); + const code = (err as { code?: string }).code ?? (err as { cause?: { code?: string } }).cause?.code ?? ''; + if (code === '42P07' || code === '42710' || /already exists/i.test(message)) { + existed += 1; + continue; + } + await say(`FAILED: ${message}`); + return { ok: false, ms: Date.now() - started, output: `${message}\n\nin: ${statement.slice(0, 300)}` }; + } + } + } + + await say(`schema: ${created} statement(s) applied, ${existed} already present`); + return { + ok: true, + ms: Date.now() - started, + output: `${tables.join(', ') || 'no tables'}: ${created} applied, ${existed} already present`, + }; + } finally { + rmSync(configPath, { force: true }); + rmSync(outDir, { recursive: true, force: true }); + } +} + /** * Apply the schema to Postgres. *