#!/usr/bin/env node // Guards lib/supabase/types.ts against drifting away from the migrations. // // That file is maintained by hand (its own header explains why: no DB // connection string is available to run `supabase gen types`), 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, "supabase", "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); } for (const m of sql.matchAll(/alter table (?:if exists )?(\w+)\s+add column (?:if not exists )?(\w+)/gi)) { tables.get(m[1])?.add(m[2].toLowerCase()); } for (const m of sql.matchAll(/alter table (?:if exists )?(\w+)\s+drop column (?:if exists )?(\w+)/gi)) { tables.get(m[1])?.delete(m[2].toLowerCase()); } for (const m of sql.matchAll(/alter table (?:if exists )?(\w+)\s+rename column (\w+) to (\w+)/gi)) { const t = tables.get(m[1]); if (t?.delete(m[2].toLowerCase())) t.add(m[3].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", "supabase", "types.ts"), "utf8"); const start = source.indexOf("Tables: {"); if (start === -1) throw new Error("Tables block not found in lib/supabase/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 supabase/migrations und lib/supabase/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.`);