Files
Reactive-Resume/apps/server/src/startup/schema-check.ts
T

72 lines
3.2 KiB
TypeScript

import { is } from "drizzle-orm";
import { getTableConfig, PgTable } from "drizzle-orm/pg-core";
import * as schema from "@reactive-resume/db/schema";
interface SchemaQueryable {
query(text: string, values?: unknown[]): Promise<{ rows: { table_name: string; column_name: string }[] }>;
}
export function collectExpectedColumns() {
const expected: { tableName: string; columnName: string }[] = [];
for (const value of Object.values(schema)) {
if (!is(value, PgTable)) continue;
const config = getTableConfig(value);
for (const column of config.columns) expected.push({ tableName: config.name, columnName: column.name });
}
return expected;
}
// The migration ledger (drizzle.__drizzle_migrations) only records that a migration ran; it
// cannot detect objects that were dropped or lost outside the migrator (a partial restore,
// a manual DROP TABLE, or a recreated "public" schema while the "drizzle" schema survives).
// Comparing the live catalog with the declared schema turns that silent drift into a startup
// failure instead of runtime "relation does not exist" (42P01) errors. The comparison covers
// tables and columns only — indexes, constraints, and enums are intentionally out of scope.
export async function verifyMigratedSchema(queryable: SchemaQueryable): Promise<void> {
const expected = collectExpectedColumns();
if (expected.length === 0) return;
// $1 and $2 are index-aligned: $1[i] is the name of the table expected to contain $2[i].
// Names are qualified as "public.<table>" so the lookup does not follow the connection's
// search_path — migrations always create these tables in the public schema.
const result = await queryable.query(
`select e.table_name, e.column_name
from unnest($1::text[], $2::text[]) as e(table_name, column_name)
where to_regclass('public.' || e.table_name) is null
or not exists (
select 1 from pg_catalog.pg_attribute a
where a.attrelid = to_regclass('public.' || e.table_name)
and a.attname = e.column_name
and a.attnum > 0 and not a.attisdropped
)
order by e.table_name, e.column_name`,
[expected.map((e) => e.tableName), expected.map((e) => e.columnName)],
);
if (result.rows.length === 0) return;
const expectedPerTable = new Map<string, number>();
for (const e of expected) expectedPerTable.set(e.tableName, (expectedPerTable.get(e.tableName) ?? 0) + 1);
const missingByTable = new Map<string, Set<string>>();
for (const row of result.rows) {
const columns = missingByTable.get(row.table_name) ?? new Set<string>();
columns.add(row.column_name);
missingByTable.set(row.table_name, columns);
}
const missing = [...missingByTable.entries()].map(([table, columns]) =>
columns.size === expectedPerTable.get(table)
? `table "${table}"`
: `column(s) ${[...columns].map((column) => `"${table}"."${column}"`).join(", ")}`,
);
throw new Error(
`Database schema does not match the migration ledger: ${missing.join(", ")} ` +
"missing even though all migrations are marked as applied. This usually means the database was " +
"restored from a backup that did not include these objects, or they were dropped outside of " +
"migrations. Restore a consistent backup or recreate the missing objects, then restart the server.",
);
}