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.
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
import { db } from 'officerdb/db';
|
import postgres from 'postgres';
|
||||||
import { sql } from 'drizzle-orm';
|
|
||||||
|
|
||||||
// One-time database migration for the 2026-08-15 rename: `role_capabilities` → `role_permissions`.
|
// 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
|
// 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.
|
// 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 ──
|
// ── 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
|
// 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
|
// `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.
|
// 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 url = process.env.POSTGRES_URL;
|
||||||
const result = (await db.execute(statement)) as unknown as { rows?: Record<string, unknown>[] };
|
if (!url) {
|
||||||
return result.rows ?? (result as unknown as Record<string, unknown>[]);
|
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> =>
|
const tableExists = async (name: string): Promise<boolean> =>
|
||||||
((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<boolean> =>
|
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}`))
|
(await q('select 1 from information_schema.columns where table_name = $1 and column_name = $2', [table, column]))
|
||||||
.length > 0;
|
.length > 0;
|
||||||
|
|
||||||
const relationExists = async (name: string): Promise<boolean> =>
|
const relationExists = async (name: string): Promise<boolean> =>
|
||||||
((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<boolean> =>
|
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}`})`))
|
(await q('select 1 from pg_constraint where conname = $1 and conrelid = to_regclass($2)', [name, `public.${table}`]))
|
||||||
.length > 0;
|
.length > 0;
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -57,7 +72,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
if (!hasOld && hasNew) {
|
if (!hasOld && hasNew) {
|
||||||
console.log(' Already migrated: role_permissions exists and role_capabilities does not. Nothing to do.');
|
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}`);
|
console.log(` Grants on this server: ${rows[0]?.n}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -69,7 +84,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Count before, so the transaction can be checked against something rather than trusted.
|
// 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…`);
|
console.log(` Found role_capabilities with ${before} grant(s). Renaming…`);
|
||||||
|
|
||||||
// EVERY existence check happens HERE, before the transaction, and against the OLD names.
|
// 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
|
// 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.
|
// `push --force` would resolve that difference by dropping it.
|
||||||
await db.transaction(async (tx) => {
|
await db.begin(async (tx) => {
|
||||||
await tx.execute(sql`ALTER TABLE role_capabilities RENAME TO role_permissions`);
|
await tx.unsafe('ALTER TABLE role_capabilities RENAME TO role_permissions');
|
||||||
if (hasOldColumn) await tx.execute(sql`ALTER TABLE role_permissions RENAME COLUMN capability TO permission`);
|
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
|
// 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.
|
// make every future push want to drop and recreate them.
|
||||||
if (hasOldUnique) {
|
if (hasOldUnique) {
|
||||||
await tx.execute(
|
await tx.unsafe('ALTER INDEX uq_role_capabilities_role_capability RENAME TO uq_role_permissions_role_permission');
|
||||||
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`);
|
if (hasOldPkey) await tx.unsafe('ALTER INDEX role_capabilities_pkey RENAME TO role_permissions_pkey');
|
||||||
for (const suffix of oldChecks) {
|
for (const suffix of oldChecks) {
|
||||||
await tx.execute(
|
// `suffix` comes from a hardcoded list three lines up, never from input.
|
||||||
sql.raw(
|
await tx.unsafe(
|
||||||
`ALTER TABLE role_permissions RENAME CONSTRAINT ck_role_capabilities_${suffix} TO ck_role_permissions_${suffix}`,
|
`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!'}`);
|
console.log(` Renamed. Grants after: ${after}${after === before ? ' — unchanged, as expected.' : ' — MISMATCH!'}`);
|
||||||
if (after !== before) process.exitCode = 1;
|
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})`);
|
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`.');
|
console.log('\n Next: `bun db:push` (expect "No changes detected"), then `pm2 restart all`.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await main();
|
await main();
|
||||||
|
await db.end();
|
||||||
process.exit(process.exitCode ?? 0);
|
process.exit(process.exitCode ?? 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user