a tested migration script for the capabilities→permissions rename
Other machines have to make the same database change, and pasted SQL is the
wrong way to ship it. `scripts/rename-capabilities-to-permissions.ts` renames
the table, its column, both indexes and its three CHECK constraints in one
transaction, and refuses to guess:
neither table nothing to do; db:push will create it
already renamed no-op, prints the grant count
BOTH tables present stops and says a human must decide
old table only migrates, counts before and after, prints every grant
Testing it found a bug that reading it had not. The existence checks were
inside the transaction but ran on the pool rather than on `tx`, so they could
not see the uncommitted rename: `columnExists('role_permissions','capability')`
answered false because that table did not exist yet on that connection, and the
COLUMN rename was silently skipped. The result was a `role_permissions` table
with a `capability` column — half migrated, and only failing on the next query.
That was found by building a scratch database in edge-pertento's exact shape
and running the script against it, rather than by review. Every check now
happens before the transaction, against the old names.
Verified end to end on that scratch: 6 grants migrated intact, a second run
correctly no-ops, and `db:push` afterwards leaves role_permissions and its rows
alone while creating the rest of the schema. Indexes come out as
role_permissions_pkey and uq_role_permissions_role_permission.
Also swept the last all-caps survivors the case-sensitive passes missed:
CORE_CAPABILITIES → CORE_PERMISSIONS, and a react-query key still spelling
['ROLE_CAPABILITIES']. The only CAPABILITIES left in src/ is the wallet's, which
is Lightning and stays.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
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<typeof sql>): Promise<Record<string, unknown>[]> => {
|
||||
const result = (await db.execute(statement)) as unknown as { rows?: Record<string, unknown>[] };
|
||||
return result.rows ?? (result as unknown as Record<string, unknown>[]);
|
||||
};
|
||||
|
||||
const tableExists = async (name: string): Promise<boolean> =>
|
||||
((await q(sql`select to_regclass(${`public.${name}`}) as t`))[0]?.t ?? null) !== null;
|
||||
|
||||
const columnExists = async (table: string, column: string): Promise<boolean> =>
|
||||
(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<boolean> =>
|
||||
((await q(sql`select to_regclass(${`public.${name}`}) as t`))[0]?.t ?? null) !== null;
|
||||
|
||||
const constraintExists = async (table: string, name: string): Promise<boolean> =>
|
||||
(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);
|
||||
Reference in New Issue
Block a user