Run the database in a container of our own
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m25s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m9s

Supabase was only ever the host: the application has talked to PostgreSQL
directly through pg/Kysely for a while. So the move is mostly about
supplying what the platform used to supply.

Proved before building anything. All 65 migrations replay onto an empty
database, and the result matches production exactly — 183 columns, 25
policies, 68 indexes, 84 constraints, identical sets, no diff. The only
function missing from the rebuild turned out to matter, see below.

What the platform supplied, deploy/db-init now does:

  - alpenwerk_app, explicitly NOBYPASSRLS. The whole access model is 21
    RLS policies; a role that bypasses them would leave everything
    working while showing too much, and nobody would notice.
  - pgcrypto and pg_trgm. uuid-ossp was available on Supabase but is
    used nowhere — no column default, no function calls uuid_generate_*.
  - anon, authenticated and service_role as NOLOGIN placeholders. No
    policy names them; they only carry grants the platform handed out,
    and a data dump referencing them would fail to restore without them.
  - A stub `auth` schema. The end state needs none of it — checked: no
    foreign key, no policy, no column default refers to it. The June
    2026 migrations do, and rewriting those would be falsifying history;
    they describe what was true then.

The gap the comparison found: rls_auto_enable() and the ensure_rls event
trigger existed only in the running database, created by hand, in no
migration. That is the net which forces RLS on every newly created
table — the reason a forgotten policy yields an empty table instead of
an open one. A rebuild from migrations would silently not have had it:
everything works, and the next new table is unprotected. Now a migration
(20260819100000), verified by creating a table on the rebuild and
confirming RLS came on by itself.

Data moves separately, via scripts/umzug-von-supabase.sh: schema from
the migrations, then pg_dump --data-only --disable-triggers for the rows.
Without --disable-triggers every foreign key trips over load order. RLS
does not interfere — none of the 19 tables uses FORCE ROW LEVEL
SECURITY, so the owner writes through. The dump is deliberately left on
disk afterwards.

psql and node come from two `tools`-profile services rather than being
installed on the host, so the server needs nothing but Docker. The db
service publishes no port at all — reachable only inside the compose
network.

SUPABASE_DB_URL is renamed MIGRATE_DATABASE_URL, since after this it
describes something else entirely; the old name still works so existing
.env files keep running. Both were exercised, as was the error when
neither is set.

The deploy workflow is set to manual-only. Its preconditions were never
met — no secrets, and whether the job container can reach the host's
Docker daemon is untested — and failing on every push teaches people to
ignore red runs. It also needs updating for the new database service
before it could work at all.

Not verified: none of this has run in an actual container. There is no
Docker daemon on this machine. What is verified is the part that
decides whether it can work — the schema, on a real empty database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 16:34:08 +02:00
parent d574d3c9d6
commit 77d9a95f7f
9 changed files with 494 additions and 29 deletions

View File

@@ -0,0 +1,56 @@
-- Eine Attrappe des Supabase-Schemas `auth` — nur für die Migrationshistorie.
--
-- ═══ Warum das hier steht, obwohl es niemand mehr braucht ═══
--
-- Der **Endstand** des Schemas ist frei von Supabase: kein Fremdschlüssel aus
-- `public` zeigt nach `auth`, keine Policy ruft `auth.uid()`, keine
-- Spaltenvorgabe nennt es. Geprüft, nicht vermutet.
--
-- Die **Historie** ist es nicht. Die erste Migration von Juni 2026 hängt
-- profiles an `auth.users`, und ein Dutzend Policies dieser Zeit fragen
-- `auth.role() = 'authenticated'`. Abgelöst wurde das erst im August
-- (20260805110000, 20260805140000). Wer die Migrationen von vorne abspielt —
-- die CI tut das bei jedem Lauf, und genau dafür ist sie da —, kommt ohne
-- diese Objekte nicht an der ersten Datei vorbei.
--
-- Die Alternative wäre, die alten Migrationen umzuschreiben. Das hiesse,
-- Geschichte zu fälschen: die Dateien beschreiben, was damals galt, und ein
-- nachträglich geänderter Stand von Juni ist keiner, der je gelaufen ist.
-- Eine Attrappe, die beim Abspielen trägt und danach leer stehenbleibt, ist
-- die ehrlichere Lösung.
--
-- Sie ist bewusst **so dumm wie möglich**: keine Rechte für die
-- Anwendungsrolle, keine Zeilen, keine Anmeldung. `auth.users` bleibt leer,
-- die Fremdschlüssel darauf verschwinden im Lauf der Migrationen von selbst.
create schema if not exists auth;
-- Nur das, worauf die frühen Fremdschlüssel zeigen. Ohne weitere Spalten:
-- geschrieben wurde in diese Tabelle nie, die Anwendung führt ihre Benutzer
-- in `public.app_users`.
create table if not exists auth.users (
id uuid primary key
);
-- Auf Supabase lasen diese beiden aus dem JWT der Anfrage. Hier gibt es keins
-- — und das ist richtig so: der Sitzungskontext dieser Anwendung steht in
-- `app.user_id` und wird von withUser() je Transaktion gesetzt (lib/db).
--
-- `app_current_user_id()` ruft `auth.uid()` noch auf, gefasst in ein
-- `exception when undefined_function`. Es liefe also auch ohne dieses Schema;
-- mit ihm liefert es null statt in den Ausnahmezweig zu laufen. Das Ergebnis
-- ist dasselbe.
create or replace function auth.uid() returns uuid language sql stable as $$
select nullif(current_setting('app.user_id', true), '')::uuid
$$;
create or replace function auth.role() returns text language sql stable as $$
select case when nullif(current_setting('app.user_id', true), '') is null
then 'anon' else 'authenticated' end
$$;
-- Niemand ausser dem Eigentümer braucht hier etwas. Die Anwendungsrolle
-- bekommt bewusst kein `usage` — fiele je wieder ein Bezug auf dieses Schema
-- in den laufenden Betrieb, soll er scheitern und nicht stillschweigend
-- funktionieren.
revoke all on schema auth from public;