diff --git a/scripts/rename-capabilities-to-permissions.ts b/scripts/rename-capabilities-to-permissions.ts index 660843d4..8ccdec84 100644 --- a/scripts/rename-capabilities-to-permissions.ts +++ b/scripts/rename-capabilities-to-permissions.ts @@ -1,5 +1,4 @@ -import { db } from 'officerdb/db'; -import { sql } from 'drizzle-orm'; +import postgres from 'postgres'; // One-time database migration for the 2026-08-15 rename: `role_capabilities` → `role_permissions`. // @@ -13,6 +12,16 @@ import { sql } from 'drizzle-orm'; // 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 @@ -28,23 +37,29 @@ import { sql } from 'drizzle-orm'; // `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 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[]> => + db.unsafe(text, params as never[]) as unknown as Promise[]>; const tableExists = async (name: string): Promise => - ((await q(sql`select to_regclass(${`public.${name}`}) as t`))[0]?.t ?? null) !== null; + ((await q('select to_regclass($1) as t', [`public.${name}`]))[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}`)) + (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 => - ((await q(sql`select to_regclass(${`public.${name}`}) as t`))[0]?.t ?? null) !== null; + ((await q('select to_regclass($1) as t', [`public.${name}`]))[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}`})`)) + (await q('select 1 from pg_constraint where conname = $1 and conrelid = to_regclass($2)', [name, `public.${table}`])) .length > 0; async function main() { @@ -57,7 +72,7 @@ async function main() { } 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`); + const rows = await q('select count(*)::int as n from role_permissions'); console.log(` Grants on this server: ${rows[0]?.n}`); return; } @@ -69,7 +84,7 @@ async function main() { } // 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); + 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. @@ -90,35 +105,33 @@ async function main() { // 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`); + 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.execute( - sql`ALTER INDEX uq_role_capabilities_role_capability RENAME TO uq_role_permissions_role_permission`, - ); + await tx.unsafe('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`); + if (hasOldPkey) await tx.unsafe('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}`, - ), + // `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(sql`select count(*)::int as n from role_permissions`))[0]?.n ?? 0); + 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(sql`select role, permission, level from role_permissions order by role, permission`); + 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);