import { db } from 'officerdb/db'; import { sql } from 'drizzle-orm'; // 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. // // ── 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 q = async (statement: ReturnType): Promise[]> => { const result = (await db.execute(statement)) as unknown as { rows?: Record[] }; return result.rows ?? (result as unknown as Record[]); }; const tableExists = async (name: string): Promise => ((await q(sql`select to_regclass(${`public.${name}`}) as t`))[0]?.t ?? null) !== null; const columnExists = async (table: string, column: string): Promise => (await q(sql`select 1 from information_schema.columns where table_name = ${table} and column_name = ${column}`)) .length > 0; const relationExists = async (name: string): Promise => ((await q(sql`select to_regclass(${`public.${name}`}) as t`))[0]?.t ?? null) !== null; const constraintExists = async (table: string, name: string): Promise => (await q(sql`select 1 from pg_constraint where conname = ${name} and conrelid = to_regclass(${`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(sql`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(sql`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.transaction(async (tx) => { await tx.execute(sql`ALTER TABLE role_capabilities RENAME TO role_permissions`); if (hasOldColumn) await tx.execute(sql`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.execute( sql`ALTER INDEX uq_role_capabilities_role_capability RENAME TO uq_role_permissions_role_permission`, ); } if (hasOldPkey) await tx.execute(sql`ALTER INDEX role_capabilities_pkey RENAME TO role_permissions_pkey`); for (const suffix of oldChecks) { await tx.execute( sql.raw( `ALTER TABLE role_permissions RENAME CONSTRAINT ck_role_capabilities_${suffix} TO ck_role_permissions_${suffix}`, ), ); } }); const after = Number((await q(sql`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(sql`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(); process.exit(process.exitCode ?? 0);