a plugin install creates its own tables and cannot touch anything else
The logs from the last commit showed what was actually wrong, and it was worse
than a failed push.
── What the install was doing ──
`bun db:push` hands drizzle-kit the whole schema and the whole database and asks
it to make them match. On a machine holding tables the current schema does not
declare — a plugin that is not on disk, anything left from before an extraction —
it sees a new table appearing while others are due to be dropped, and asks:
Is offscale_servers table created or renamed from another table?
❯ + offscale_servers create table
~ headscale_servers › offscale_servers rename table
~ music_favorites › offscale_servers rename table
That 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 killed.
Without a terminal the push applies NOTHING and exits 0, which is exactly how an
install reported success on a machine where its table was never created.
And `--force` would have been the wrong fix. Force means "run the destructive
statements without asking", and the destructive statements there are `drop table`
for every plugin whose directory is absent. Installing one plugin could delete
another's data.
── `tablesFilter` was tried, and is not enough ──
Scoping the push to the plugin's tables stops it dropping other tables and it
still diffs sequences. Measured on a scratch database: installing 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, into an empty directory,
emits exactly the statements that build that schema from nothing. It diffs against
the migration journal — empty — not against the database, so it cannot emit a DROP
of anything, of any object type. There is no destructive statement to guard.
Applying is idempotent by exception: an object that exists raises 42P07/42710 and
is skipped, which is the normal case for a reinstall, since uninstall keeps data
on purpose.
`[open]` This creates; it does not migrate. A plugin version that adds a column
will not get it. Plugin upgrades need real migrations — a separate feature. The
old push appeared to handle that case, at the price of being able to drop half the
database.
── Verified ──
Fresh install: 4 statements applied, table verified, 761ms. Reinstall over
surviving tables: 0 applied, 4 already present. Core sequences before and after:
18. Orphan tables on a scratch database shaped like the failing server
(headscale_servers, music_favorites): untouched.
The reinstall case failed on the first attempt — drizzle wraps driver errors, so
the SQLSTATE saying "duplicate table" is on `cause`, and checking only the top
level made every second install fail on its own CREATE TABLE. Found by testing the
second install rather than stopping at the first.
tsgo clean, frontend builds, 873 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<PushResult> {
|
||||
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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user