import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { PLATFORM_DIR } from '../data-path'; import type { DiscoveredPlugin } from './manifest'; // Creating a plugin's tables. // // ── The trap this is shaped around ── // // `drizzle-kit push` DROPS anything absent from the schema it is given. So a generated barrel that // tracked what is INSTALLED would delete a plugin's tables the moment it was uninstalled — turning "stop // running this" into "delete my data", which is the one thing the whole install model refuses to do. // // So the barrel tracks the DIRECTORY, not the install row: // // directory present, not installed in the barrel. tables exist, unused. costs nothing. // installed in the barrel. tables exist and are used. // uninstalled STILL in the barrel. tables and every row survive. // directory deleted out of the barrel — the code is gone, and a push may drop them. // // Which makes reinstall a restore rather than a fresh start, and makes the destructive case require // deliberately deleting a plugin's source. `bun db:push` by hand is then safe at any moment, which it // would not be if this followed the install table. const GENERATED = join(PLATFORM_DIR, 'src/databases/officer_db/src/plugin-schemas.gen.ts'); const HEADER = `// GENERATED by servers/plugins/schema.ts. Do not edit, and do not commit. // // One re-export per plugin DIRECTORY that has a db/schema.ts — not per installed plugin, deliberately. // drizzle-kit push drops what it cannot see, so following the install table would delete a plugin's // tables on uninstall. Following the directory means data survives uninstall and only a deleted plugin // can lose it. `; /** Import specifier from the generated file to a plugin's schema, POSIX-style. */ const specifier = (target: string): string => { const rel = relative(dirname(GENERATED), target).split('\\').join('/'); return (rel.startsWith('.') ? rel : `./${rel}`).replace(/\.ts$/, ''); }; /** Write the barrel. Always written, even empty — `schema.ts` imports it unconditionally. */ export function generatePluginSchemas(plugins: DiscoveredPlugin[]): void { const withSchema = plugins.filter((p) => p.schema); const lines = withSchema.map((p) => `export * from '${specifier(p.schema!)}'; // ${p.appName}`); const body = `${HEADER}\n${lines.join('\n')}\n`; const previous = existsSync(GENERATED) ? readFileSync(GENERATED, 'utf-8') : ''; if (previous !== body) writeFileSync(GENERATED, body); } export type PushResult = { ok: boolean; ms: number; output: string }; /** * Apply the schema to Postgres. * * Shells out to the same `bun db:push` a human runs, rather than driving drizzle-kit in-process: it is * one definition of "apply the schema" instead of two that can disagree, and an owner can reproduce * exactly what an install did with a command they already know. * * `--force` because there is nothing to prompt about by construction — the barrel only ever gains entries * unless a plugin's source is deleted — and a prompt with no terminal attached would hang the install * forever rather than fail it. */ export async function pushSchema(): Promise { const started = Date.now(); const proc = Bun.spawn(['bun', 'run', 'db:push', '--force'], { cwd: PLATFORM_DIR, stdout: 'pipe', stderr: 'pipe', }); const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); const code = await proc.exited; // drizzle writes its progress to stderr, so a non-empty stderr is not a failure — only the exit code is. const output = `${out}${err}`.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '').trim(); return { ok: code === 0, ms: Date.now() - started, output: output.slice(-600) }; }