The schema/type drift check has been red in CI. It reported seven
discrepancies, and all seven were the checker's own fault.
The migrations put several clauses in one statement:
alter table employees
drop column if exists division_id,
drop column if exists team_id,
drop column if exists manager_id,
drop column if exists org_level,
drop column if exists is_lead;
The old pattern matched `alter table (\w+)\s+drop column (\w+)` as a single
regex, which finds exactly the first clause. So division_id was dropped from
the model and the other four stayed in it — the checker insisted four columns
existed that the OM cutover removed a year ago. The same cut the other way for
`add column`: kuendigungsschutz_bis, teilzeit_bis and aufenthaltstitel_bis are
each the second clause of their statement, so the checker never saw them and
called them typed-but-absent.
Now the statement is collected up to its terminating semicolon — with paren
depth tracked, so a semicolon inside a check constraint does not end it early
— and every clause inside is applied.
Verified by mutation, not by the green result alone: putting an invented
column into types.ts is caught, and reverting the parser to read only the
first clause brings back exactly those seven messages. That is the diagnosis
confirmed, not merely a passing run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
194 lines
6.9 KiB
JavaScript
194 lines
6.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// Guards lib/types.ts against drifting away from the migrations.
|
|
//
|
|
// That file is maintained by hand (its own header explains why: no DB
|
|
// Werkzeug erzeugt sie), so a new
|
|
// column reaches the database without the TypeScript side noticing — and
|
|
// `tsc` stays perfectly happy while the app reads a field that is typed but
|
|
// absent, or writes one that exists but is not typed.
|
|
//
|
|
// Reads the migrations rather than a live database on purpose: CI then needs
|
|
// no Postgres, and the migrations are the actual source of truth for what
|
|
// gets deployed.
|
|
|
|
import { readFileSync, readdirSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
const migrationsDir = join(root, "db", "migrations");
|
|
|
|
// Columns are compared, not their types: the hand-written file deliberately
|
|
// uses richer unions than the SQL (`EmploymentStatus` for a text column with
|
|
// a check constraint), and flagging those would be noise.
|
|
const NON_COLUMN_START =
|
|
/^(constraint|check|primary|unique|foreign|exclude|like|inherits|partition|)$/i;
|
|
|
|
function stripComments(sql) {
|
|
return sql.replace(/--[^\n]*/g, "");
|
|
}
|
|
|
|
/** Splits on top-level commas only, so `numeric(10,2)` stays in one piece. */
|
|
function splitTopLevel(body) {
|
|
const parts = [];
|
|
let depth = 0;
|
|
let current = "";
|
|
for (const ch of body) {
|
|
if (ch === "(") depth++;
|
|
if (ch === ")") depth--;
|
|
if (ch === "," && depth === 0) {
|
|
parts.push(current);
|
|
current = "";
|
|
continue;
|
|
}
|
|
current += ch;
|
|
}
|
|
if (current.trim()) parts.push(current);
|
|
return parts;
|
|
}
|
|
|
|
function parseMigrations() {
|
|
const tables = new Map();
|
|
const files = readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort();
|
|
|
|
for (const file of files) {
|
|
const sql = stripComments(readFileSync(join(migrationsDir, file), "utf8"));
|
|
|
|
for (const match of sql.matchAll(/create table (?:if not exists )?(\w+)\s*\(/gi)) {
|
|
const name = match[1];
|
|
// Walk from the opening paren to its match so nested parens in column
|
|
// definitions do not end the table early.
|
|
let depth = 0;
|
|
let end = match.index + match[0].length - 1;
|
|
for (let i = end; i < sql.length; i++) {
|
|
if (sql[i] === "(") depth++;
|
|
else if (sql[i] === ")") {
|
|
depth--;
|
|
if (depth === 0) {
|
|
end = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
const body = sql.slice(match.index + match[0].length, end);
|
|
const columns = new Set();
|
|
for (const part of splitTopLevel(body)) {
|
|
const first = part.trim().split(/\s+/)[0] ?? "";
|
|
if (!first || NON_COLUMN_START.test(first)) continue;
|
|
columns.add(first.toLowerCase());
|
|
}
|
|
tables.set(name, columns);
|
|
}
|
|
|
|
// ── ALTER TABLE, vollständig ────────────────────────────────────
|
|
//
|
|
// Eine Anweisung kann mehrere Klauseln tragen, durch Komma getrennt:
|
|
//
|
|
// alter table employees
|
|
// drop column if exists division_id,
|
|
// drop column if exists team_id,
|
|
// drop column if exists manager_id;
|
|
//
|
|
// Wer nur nach `alter table … drop column …` als **einem** Muster sucht,
|
|
// findet davon genau die erste. Die übrigen bleiben im Modell stehen, und der
|
|
// Abgleich meldet Spalten als vorhanden, die es seit einem Jahr nicht
|
|
// mehr gibt — genau die vier aus dem OM-Umbau. Umgekehrt fehlten drei
|
|
// später ergänzte Spalten, weil sie jeweils die zweite Klausel eines
|
|
// `add column` waren.
|
|
//
|
|
// Deshalb wird hier erst die ganze Anweisung bis zum Semikolon
|
|
// aufgesammelt und dann jede Klausel darin ausgewertet. Klammern zählen
|
|
// mit, damit ein Semikolon in einer Prüfbedingung die Anweisung nicht
|
|
// vorzeitig beendet.
|
|
for (const kopf of sql.matchAll(/alter table (?:if exists )?(\w+)/gi)) {
|
|
const tabelle = tables.get(kopf[1]);
|
|
if (!tabelle) continue;
|
|
|
|
let tiefe = 0;
|
|
let ende = kopf.index + kopf[0].length;
|
|
for (; ende < sql.length; ende++) {
|
|
const c = sql[ende];
|
|
if (c === "(") tiefe++;
|
|
else if (c === ")") tiefe--;
|
|
else if (c === ";" && tiefe === 0) break;
|
|
}
|
|
const anweisung = sql.slice(kopf.index + kopf[0].length, ende);
|
|
|
|
for (const m of anweisung.matchAll(/\badd column (?:if not exists )?(\w+)/gi)) {
|
|
tabelle.add(m[1].toLowerCase());
|
|
}
|
|
for (const m of anweisung.matchAll(/\bdrop column (?:if exists )?(\w+)/gi)) {
|
|
tabelle.delete(m[1].toLowerCase());
|
|
}
|
|
for (const m of anweisung.matchAll(/\brename column (\w+) to (\w+)/gi)) {
|
|
if (tabelle.delete(m[1].toLowerCase())) tabelle.add(m[2].toLowerCase());
|
|
}
|
|
}
|
|
|
|
for (const m of sql.matchAll(/drop table (?:if exists )?(\w+)/gi)) {
|
|
tables.delete(m[1]);
|
|
}
|
|
}
|
|
return tables;
|
|
}
|
|
|
|
function parseTypes() {
|
|
const source = readFileSync(join(root, "lib", "types.ts"), "utf8");
|
|
const start = source.indexOf("Tables: {");
|
|
if (start === -1) throw new Error("Tables block not found in lib/types.ts");
|
|
|
|
const tables = new Map();
|
|
// Each entry looks like `name: NoRelationships & { Row: { … } … }`; the Row
|
|
// block is the one that has to match the database.
|
|
const entry = /^ {6}(\w+): /gm;
|
|
for (const m of source.slice(start).matchAll(entry)) {
|
|
const rest = source.slice(start + m.index);
|
|
const rowAt = rest.indexOf("Row: {");
|
|
if (rowAt === -1) continue;
|
|
let depth = 0;
|
|
let end = rowAt + "Row: ".length;
|
|
for (let i = end; i < rest.length; i++) {
|
|
if (rest[i] === "{") depth++;
|
|
else if (rest[i] === "}") {
|
|
depth--;
|
|
if (depth === 0) {
|
|
end = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
const body = rest.slice(rowAt + "Row: {".length, end);
|
|
const columns = new Set();
|
|
for (const f of body.matchAll(/(\w+)\s*\??\s*:/g)) columns.add(f[1].toLowerCase());
|
|
tables.set(m[1], columns);
|
|
}
|
|
return tables;
|
|
}
|
|
|
|
const sqlTables = parseMigrations();
|
|
const tsTables = parseTypes();
|
|
const problems = [];
|
|
|
|
for (const [name, columns] of tsTables) {
|
|
const sqlColumns = sqlTables.get(name);
|
|
if (!sqlColumns) {
|
|
problems.push(`Tabelle "${name}" ist in types.ts typisiert, existiert aber in keiner Migration.`);
|
|
continue;
|
|
}
|
|
for (const c of columns) {
|
|
if (!sqlColumns.has(c)) problems.push(`${name}.${c}: in types.ts typisiert, in den Migrationen nicht vorhanden.`);
|
|
}
|
|
for (const c of sqlColumns) {
|
|
if (!columns.has(c)) problems.push(`${name}.${c}: in den Migrationen vorhanden, in types.ts nicht typisiert.`);
|
|
}
|
|
}
|
|
|
|
if (problems.length > 0) {
|
|
console.error("Schema-Drift zwischen db/migrations und lib/types.ts:\n");
|
|
for (const p of problems.sort()) console.error(" - " + p);
|
|
console.error(`\n${problems.length} Abweichung(en). types.ts entsprechend nachziehen.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`Kein Schema-Drift: ${tsTables.size} typisierte Tabellen stimmen mit den Migrationen überein.`);
|