Files
platform/scripts/rename-capabilities-to-permissions.ts
T
pastilhas abb8fe4320 the migration script stops needing the app it migrates
It died on its first real use, on a production server, before running a single
statement:

  error: Cannot find module './plugin-schemas.gen' from officer_db/src/schema.ts

It imported `officerdb/db`, which imports `schema.ts`, which imports the
gitignored `plugin-schemas.gen.ts`. That file does not exist on a fresh clone —
which is exactly the state every machine this script is FOR is in. It had been
tested against a scratch database on a machine where the barrel happened to
exist, so the one condition that mattered was the one condition never tested.

A migration issues ALTER statements. It has no business needing the
application's schema barrel, its table objects or its query layer. It now opens
its own `postgres` connection and imports none of them.

Tested the way it failed: barrel moved out of the tree, script run against a
scratch database built in the old shape. Renames the table, the column, both
indexes and all three CHECK constraints, keeps the rows, and the idempotent
path still no-ops. Verified by reading pg_indexes and pg_constraint afterwards
rather than trusting the exit code.

Deployed to edge-pertento today with two real users. Nine grants migrated
intact, old table gone, permissions page confirmed in the browser. The only
casualty was a minute lost to this bug, because the database had not been
touched when it failed — the import blew up before the first query, which is
the one place a crash costs nothing.

`bun db:push` was already immune: it runs scripts/gen-plugin-schemas.ts first.
That fix existed because the same trap was found earlier today in the setup
path. It was not applied here because I did not think of this script as
something that runs on a fresh clone, which is precisely what it is.
2026-08-15 17:03:05 +00:00

138 lines
7.4 KiB
TypeScript

import postgres from 'postgres';
// One-time database migration for the 2026-08-15 rename: `role_capabilities` → `role_permissions`.
//
// ── Why this is a script and not `bun db:push` ──
//
// drizzle-kit does not understand renames. It sees a table gone and a table added, and with `--force` it
// resolves that by DROPPING and CREATING — which would delete every grant on the server and silently
// reduce every member to core-only access. There is no prompt to catch it, because `--force` exists to
// answer prompts.
//
// So the database is renamed by hand, first, and `db:push` afterwards should report "No changes
// detected" — which is the proof that the two now agree.
//
// ── Why a raw connection rather than `officerdb/db` ──
//
// It used `officerdb/db` and died on its first real use, on a production server: that module imports
// `schema.ts`, which imports the gitignored `plugin-schemas.gen.ts`, which does not exist on a fresh
// clone. MODULE_NOT_FOUND, before a single statement ran. It had been tested against a scratch database
// on a machine where the barrel happened to exist.
//
// A migration issues ALTER statements. It has no business needing the application's schema barrel, its
// table objects or its query layer — so it opens its own connection and takes none of them.
//
// ── Safe to run twice, and safe to run on a server that never had the old names ──
//
// Every step checks first. A machine already migrated prints "already done" and touches nothing; a fresh
// install that never had `role_capabilities` is not an error either. That matters because this will be
// run by hand, on more than one machine, possibly twice on the same one.
//
// Run it BEFORE restarting the platform on the new code. The old code cannot read `role_permissions` and
// the new code cannot read `role_capabilities`, so the window between them is the outage — keep it short:
//
// pm2 stop officer && bun run scripts/rename-capabilities-to-permissions.ts && bun db:push && pm2 restart all
//
// If it goes wrong: the OWNER is unaffected either way. `getEffectivePermissions` short-circuits on
// `role === 'Super Admin'` before it reads the table at all, so the account that can fix things can
// always sign in. Non-owners degrade to core-only until the rename completes.
const url = process.env.POSTGRES_URL;
if (!url) {
console.error(' POSTGRES_URL is not set. Run from the platform directory so Bun loads .env.');
process.exit(1);
}
const db = postgres(url);
/** Every read below. Parameterised where it takes a value; nothing here interpolates user input. */
const q = (text: string, params: unknown[] = []): Promise<Record<string, unknown>[]> =>
db.unsafe(text, params as never[]) as unknown as Promise<Record<string, unknown>[]>;
const tableExists = async (name: string): Promise<boolean> =>
((await q('select to_regclass($1) as t', [`public.${name}`]))[0]?.t ?? null) !== null;
const columnExists = async (table: string, column: string): Promise<boolean> =>
(await q('select 1 from information_schema.columns where table_name = $1 and column_name = $2', [table, column]))
.length > 0;
const relationExists = async (name: string): Promise<boolean> =>
((await q('select to_regclass($1) as t', [`public.${name}`]))[0]?.t ?? null) !== null;
const constraintExists = async (table: string, name: string): Promise<boolean> =>
(await q('select 1 from pg_constraint where conname = $1 and conrelid = to_regclass($2)', [name, `public.${table}`]))
.length > 0;
async function main() {
const hasOld = await tableExists('role_capabilities');
const hasNew = await tableExists('role_permissions');
if (!hasOld && !hasNew) {
console.log(' Neither table exists — nothing to migrate. `bun db:push` will create role_permissions.');
return;
}
if (!hasOld && hasNew) {
console.log(' Already migrated: role_permissions exists and role_capabilities does not. Nothing to do.');
const rows = await q('select count(*)::int as n from role_permissions');
console.log(` Grants on this server: ${rows[0]?.n}`);
return;
}
if (hasOld && hasNew) {
console.error(' BOTH tables exist. That is not a state this script can resolve safely — stopping.');
console.error(' Look at both by hand and decide which holds the real grants.');
process.exitCode = 1;
return;
}
// Count before, so the transaction can be checked against something rather than trusted.
const before = Number((await q('select count(*)::int as n from role_capabilities'))[0]?.n ?? 0);
console.log(` Found role_capabilities with ${before} grant(s). Renaming…`);
// EVERY existence check happens HERE, before the transaction, and against the OLD names.
//
// They used to be inside it, and that was a real bug caught by testing against a copy of a production
// database rather than by reading: the helpers run on the pool, not on `tx`, so inside the transaction
// they cannot see its uncommitted rename. `columnExists('role_permissions', 'capability')` answered
// false — the table did not exist yet as far as that connection was concerned — so the column rename
// was silently skipped and the migration produced a `role_permissions` table with a `capability`
// column. Half migrated, and the failure only surfaced on the next query.
const hasOldColumn = await columnExists('role_capabilities', 'capability');
const hasOldUnique = await relationExists('uq_role_capabilities_role_capability');
const hasOldPkey = await relationExists('role_capabilities_pkey');
const oldChecks: string[] = [];
for (const suffix of ['role', 'level', 'not_owner']) {
if (await constraintExists('role_capabilities', `ck_role_capabilities_${suffix}`)) oldChecks.push(suffix);
}
// One transaction. A partial rename leaves drizzle-kit seeing a table it half-recognises, and the next
// `push --force` would resolve that difference by dropping it.
await db.begin(async (tx) => {
await tx.unsafe('ALTER TABLE role_capabilities RENAME TO role_permissions');
if (hasOldColumn) await tx.unsafe('ALTER TABLE role_permissions RENAME COLUMN capability TO permission');
// Index and constraint names are renamed too. drizzle-kit diffs on the NAME, so leaving them would
// make every future push want to drop and recreate them.
if (hasOldUnique) {
await tx.unsafe('ALTER INDEX uq_role_capabilities_role_capability RENAME TO uq_role_permissions_role_permission');
}
if (hasOldPkey) await tx.unsafe('ALTER INDEX role_capabilities_pkey RENAME TO role_permissions_pkey');
for (const suffix of oldChecks) {
// `suffix` comes from a hardcoded list three lines up, never from input.
await tx.unsafe(
`ALTER TABLE role_permissions RENAME CONSTRAINT ck_role_capabilities_${suffix} TO ck_role_permissions_${suffix}`,
);
}
});
const after = Number((await q('select count(*)::int as n from role_permissions'))[0]?.n ?? 0);
console.log(` Renamed. Grants after: ${after}${after === before ? ' — unchanged, as expected.' : ' — MISMATCH!'}`);
if (after !== before) process.exitCode = 1;
const rows = await q('select role, permission, level from role_permissions order by role, permission');
for (const r of rows) console.log(` ${r.role}${r.permission} (${r.level})`);
console.log('\n Next: `bun db:push` (expect "No changes detected"), then `pm2 restart all`.');
}
await main();
await db.end();
process.exit(process.exitCode ?? 0);