The database moved to a container of our own; the platform is gone. This takes out what was left of it — and, where the leftovers were load bearing, moves rather than deletes. Moved, not deleted: supabase/migrations/ -> db/migrations/ the schema's source of truth supabase/build-org.ts -> scripts/build-org.ts lib/supabase/types.ts -> lib/types.ts 52 import sites repointed The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`, and simply renaming the schema would have left the runner facing an empty table: it would have called all 67 migrations pending and replayed them against a database that is long since current. So the runner now creates `migrationen.schema_migrations` and, once, copies the old rows across — guarded so a second run does nothing and a fresh database skips it entirely. Only then does migration 20260907100000 drop the old schema. Deleted: the CLI config, the seed, the historical schema/function dumps (nothing read them), scripts/umzug-von-supabase.sh (the move is done), and both Supabase packages plus the CLI. Nothing in the application imported them — the build now succeeds with no environment variables at all, which is the proof. Integration tests: six of them signed in through Supabase Auth and asserted against the anon key and the service role. That model is gone, so the tests were not portable — they are deleted. session-context and employee-status-filter already ran on pg and are untouched; om-reporting is ported to a direct connection because it guards a real risk (the reporting line rule exists twice, once in SQL and once in TypeScript). CI: the integration job started a Supabase stack. It now runs a postgres service, applies deploy/db-init and every migration to an empty database — that was the valuable part, and it still holds — then checks that a second run is a no-op, which is what proves the bookkeeping works. Docs: security-review.md audited a service-role key, a cookie adapter and auth.users, none of which exist. Restating findings about removed components would suggest today's system had been reviewed; it has not. It now records what was removed and says a fresh review is due. data-model.md was already marked obsolete and described the pre-OM schema; azure-migration.md was a plan for a route not taken. Both deleted. Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the packages. Integration tests skip cleanly with no database. Migration SQL and the runner are reviewed but NOT executed: no Docker here, and the old instance no longer resolves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
103 lines
4.8 KiB
TypeScript
103 lines
4.8 KiB
TypeScript
import "server-only";
|
|
import { Pool, types } from "pg";
|
|
|
|
// ═══ Wie Werte aus der Datenbank ankommen ════════════════════════
|
|
//
|
|
// Der Wechsel des Zugriffswegs hat hier eine Falle hinterlassen, die kein
|
|
// Typprüfer und keiner der Tests fangen konnte.
|
|
//
|
|
// Die alte API-Schicht lieferte JSON: ein `date` kam als "2026-08-03" an,
|
|
// ein `numeric` als Zahl. Genau so steht es in lib/types.ts, und
|
|
// darauf baut die gesamte Anwendung — Sortierungen mit localeCompare,
|
|
// Vergleiche wie `entry_date <= stichtag`, das Ableiten des Status.
|
|
//
|
|
// Der `pg`-Treiber macht es anders herum: aus `date` wird ein Date-Objekt,
|
|
// aus `numeric` eine Zeichenkette. Die Deklarationen blieben dabei
|
|
// unverändert gültig — sie beschreiben ja nur, was der Code *glaubt*. Der
|
|
// Fehler zeigt sich erst zur Laufzeit, und im günstigen Fall als Absturz
|
|
// („a.date.localeCompare is not a function"). Im ungünstigen Fall gar
|
|
// nicht: ein Datumsvergleich zwischen Date und Zeichenkette wirft nicht, er
|
|
// liefert bloss das falsche Ergebnis.
|
|
//
|
|
// Deshalb wird der Treiber hier auf die Form zurückgestellt, die die Typen
|
|
// beschreiben. Das ist die kleinere und ehrlichere Änderung, als 49
|
|
// Abfragestellen umzuschreiben.
|
|
//
|
|
// Nebenbei löst es ein zweites Problem: `date` ist ein Kalendertag ohne
|
|
// Zeitzone. Als Date-Objekt bekäme er eine — Mitternacht in der Zone des
|
|
// Servers —, und ein Geburtsdatum verschöbe sich beim Formatieren um einen
|
|
// Tag. Dieselbe Klasse von Fehler wie im Seed.
|
|
types.setTypeParser(1082, (v) => v); // date → "YYYY-MM-DD", unverändert
|
|
types.setTypeParser(1184, (v) => new Date(v).toISOString()); // timestamptz → ISO-8601 mit Z
|
|
types.setTypeParser(1114, (v) => new Date(v + "Z").toISOString()); // timestamp ohne Zone
|
|
types.setTypeParser(1700, (v) => Number(v)); // numeric → Zahl
|
|
|
|
// Bewusst *nicht* umgestellt: int8 (bigint). Es kommt nur aus count() und
|
|
// wird überall mit Number() gelesen; als Zahl geparst verlöre es jenseits
|
|
// von 2^53 stillschweigend an Genauigkeit.
|
|
|
|
// Die einzige Stelle im Projekt, die `pg` importieren darf.
|
|
//
|
|
// Der Grund steht in lib/db/index.ts: eine Abfrage ausserhalb von withUser()
|
|
// läuft ohne Sitzungskontext und damit — je nachdem, was die vorherige
|
|
// Anfrage auf derselben gepoolten Verbindung hinterlassen hat — im Namen
|
|
// einer fremden Person. Deshalb wird der Pool nicht exportiert, sondern nur
|
|
// die Kysely-Instanz, die ihn benutzt, und eine ESLint-Regel verbietet den
|
|
// Import von `pg` und von dieser Datei überall sonst.
|
|
|
|
// Der Pool hängt am globalen Objekt, nicht nur am Modul.
|
|
//
|
|
// Im Entwicklungsbetrieb lädt Next.js geänderte Module neu. Ein modul-lokales
|
|
// `let` wäre danach leer, der alte Pool aber weiterhin am Leben — mit seinen
|
|
// Verbindungen. Nach ein paar Bearbeitungen ist das Kontingent des Anbieters
|
|
// aufgebraucht, und die Anwendung antwortet nur noch mit „max clients
|
|
// reached". Genau so ist sie hier stehengeblieben.
|
|
//
|
|
// In der Produktion gibt es kein Neuladen; dort ist die Zeile wirkungslos.
|
|
const globalForPool = globalThis as typeof globalThis & { __alpenwerkPool?: Pool };
|
|
|
|
let instance: Pool | undefined = globalForPool.__alpenwerkPool;
|
|
|
|
/**
|
|
* Der Verbindungspool — erst beim ersten Zugriff angelegt, nicht beim Import.
|
|
*
|
|
* Der Unterschied ist nicht kosmetisch: `next build` importiert jedes Route-
|
|
* Modul, um die Seitendaten einzusammeln. Entstünde der Pool dabei, bräuchte
|
|
* schon der Bau Zugangsdaten zur Datenbank — ein Container-Abbild liesse sich
|
|
* in einer Baustrecke ohne Produktionsgeheimnisse nicht mehr erzeugen.
|
|
*/
|
|
export function getPool(): Pool {
|
|
if (instance) return instance;
|
|
|
|
const connectionString = process.env.DATABASE_URL;
|
|
if (!connectionString) {
|
|
throw new Error(
|
|
"DATABASE_URL fehlt. Erwartet wird ein PostgreSQL-Verbindungsstring — " +
|
|
"die Anwendung spricht direkt mit der Datenbank, nicht über eine API-Schicht."
|
|
);
|
|
}
|
|
|
|
instance = new Pool({
|
|
connectionString,
|
|
// Der Standard sind 10; bei serverseitigem Rendering hängt an jeder
|
|
// Anfrage genau eine Transaktion, und mehr Verbindungen als die Datenbank
|
|
// zulässt bringen nur Wartezeit an einer anderen Stelle.
|
|
max: Number(process.env.DATABASE_POOL_MAX ?? 10),
|
|
// Eine Anfrage, die länger braucht, ist kaputt und soll das melden statt
|
|
// eine Verbindung zu belegen.
|
|
statement_timeout: 20_000,
|
|
idle_in_transaction_session_timeout: 20_000,
|
|
connectionTimeoutMillis: 10_000,
|
|
// Verwaltete Anbieter (Azure, RDS) verlangen TLS; im eigenen Netz nicht.
|
|
ssl: process.env.DATABASE_SSL === "false" ? undefined : { rejectUnauthorized: false },
|
|
});
|
|
|
|
// Ein Fehler auf einer Leerlaufverbindung beendet sonst den Prozess.
|
|
instance.on("error", (err) => {
|
|
console.error("Unerwarteter Fehler auf einer Leerlaufverbindung:", err);
|
|
});
|
|
|
|
globalForPool.__alpenwerkPool = instance;
|
|
return instance;
|
|
}
|