docs(db): runbook section merging, data model and spec notes (KAT-05, 2b)

Refs #2

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-03 20:49:42 +01:00
parent daeae999dd
commit 2c2546ab47
4 changed files with 19 additions and 3 deletions

View file

@ -342,7 +342,7 @@ Drei Ansichten (Views) kommen dazu: `public_profiles` zeigt Mitspielern Name, Av
Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle `postgres` (Klärungen 25/44, Ablauf: `docs/katalog-pflege.md`). Spec: `docs/specs/2-katalog-daten.md`. Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle `postgres` (Klärungen 25/44, Ablauf: `docs/katalog-pflege.md`). Spec: `docs/specs/2-katalog-daten.md`.
- **Ausblenden:** `games.hidden_at` gesetzt = ausgeblendet. Die View `catalog_games` enthält nur Katalogspiele (`visibility = 'catalog'`) ohne `hidden_at` und ohne `merged_into_id`. **Suche und Entdecken lesen nur diese View.** Ein ausgeblendetes Spiel bleibt per ID lesbar (Sammlungen, Partien und Detailseite verweisen darauf). - **Ausblenden:** `games.hidden_at` gesetzt = ausgeblendet. Die View `catalog_games` enthält nur Katalogspiele (`visibility = 'catalog'`) ohne `hidden_at` und ohne `merged_into_id`. **Suche und Entdecken lesen nur diese View.** Ein ausgeblendetes Spiel bleibt per ID lesbar (Sammlungen, Partien und Detailseite verweisen darauf).
- **Zusammenführen:** `games.merged_into_id` zeigt auf das Zielspiel (Spalte seit 2a; die Funktion `admin_merge_games` kommt mit PR 2b). Ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). - **Zusammenführen** (Migration `catalog_merge`, PR 2b): `games.merged_into_id` zeigt auf das Zielspiel; ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). Gesetzt wird die Spalte nur von der Funktion `public.admin_merge_games(p_source, p_target) returns jsonb` (Ablauf und Ergebnis-JSON: `docs/katalog-pflege.md`, Abschnitt 5). Sie ist `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`) und ist für `anon`, `authenticated` und `service_role` nicht ausführbar; einen API-Aufruf gibt es nicht. Beide Spiele müssen Katalogspiele sein, das Ziel sichtbar. Die Funktion hängt über die interne Funktion `public.repoint_game_references(p_from, p_to)` Sammlung, Favoriten, Partien (mit Edition) und Wertungsbögen um, kopiert die Varianten der Quelle ans Ziel (gleichnamige Zielvarianten werden abgebildet: `lower(btrim(name))`, bei mehreren die älteste, dann die kleinste `id`) und löscht danach die alten Varianten. Bei Doppelbesitz bleibt der Zieleintrag, leere Felder füllt die Quelle, die Liste folgt `owned` vor `wishlist` vor `shopping`, der Quell-Eintrag wird gelöscht; ein Favorit der Quelle entfällt, wenn der Nutzer das Ziel schon als Favorit hat. `bgg_id` und `catalog_source_id`/`source_*` gehen aufs Ziel über, wenn es keine hat; sonst werden sie an der Quelle trotzdem geleert und der Konflikt mit den alten Werten im Ergebnis gemeldet. Die Quelle wird nie gelöscht, sondern ausgeblendet und verwiesen (`hidden_at`, `merged_into_id`); Ketten werden geglättet. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) bleiben am Quell-Eintrag. Ein pgTAP-Wächter (`07`) listet alle Fremdschlüssel auf `games` und lässt neue Verweise erst nach einer Entscheidung zu.
- **Manuell gepflegte Felder:** Der Trigger `games_mark_manual_fields` trägt jedes Feld eines Katalogspiels, das ein Mensch setzt oder ändert, in `manual_fields` ein (beim Anlegen alle gesetzten Felder). Der Trigger `game_links_mark_manual` ergänzt `genres`, `mechanics`, `categories` und `publishers`, wenn Zuordnungen angelegt, geändert oder gelöscht werden. Ausgenommen ist nur ein späterer Import: Rolle `catalog_importer` **und** Einstellung `shuffle.catalog_import = 'on'` (der Import setzt sie per `set local`; das ist Konvention, die Rolle ist die Sperre). Private Spiele werden nicht gekennzeichnet. Eine bewusst entfernte Markierung entfernt der Trigger nicht wieder. - **Manuell gepflegte Felder:** Der Trigger `games_mark_manual_fields` trägt jedes Feld eines Katalogspiels, das ein Mensch setzt oder ändert, in `manual_fields` ein (beim Anlegen alle gesetzten Felder). Der Trigger `game_links_mark_manual` ergänzt `genres`, `mechanics`, `categories` und `publishers`, wenn Zuordnungen angelegt, geändert oder gelöscht werden. Ausgenommen ist nur ein späterer Import: Rolle `catalog_importer` **und** Einstellung `shuffle.catalog_import = 'on'` (der Import setzt sie per `set local`; das ist Konvention, die Rolle ist die Sperre). Private Spiele werden nicht gekennzeichnet. Eine bewusst entfernte Markierung entfernt der Trigger nicht wieder.
- **Anzeige-Erlaubnis:** `catalog_games.description` und `image_path` sind leer, wenn die Quelle (`catalog_sources`) Texte bzw. Bilder nicht erlaubt und das Feld nicht in `manual_fields` steht. Das ist nur die zweite Sicherung: Daten ohne Erlaubnis dürfen gar nicht erst gespeichert werden (Pflicht für den Import). - **Anzeige-Erlaubnis:** `catalog_games.description` und `image_path` sind leer, wenn die Quelle (`catalog_sources`) Texte bzw. Bilder nicht erlaubt und das Feld nicht in `manual_fields` steht. Das ist nur die zweite Sicherung: Daten ohne Erlaubnis dürfen gar nicht erst gespeichert werden (Pflicht für den Import).
- **Spaltenrechte auf `games`:** Nutzer dürfen nur `owner_id` (nur beim Anlegen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` und `visibility` schreiben, nicht `id`, `hidden_at`, `merged_into_id`, `manual_fields`, `catalog_source_id` und `source_*`. - **Spaltenrechte auf `games`:** Nutzer dürfen nur `owner_id` (nur beim Anlegen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` und `visibility` schreiben, nicht `id`, `hidden_at`, `merged_into_id`, `manual_fields`, `catalog_source_id` und `source_*`.

View file

@ -703,3 +703,18 @@ Umgesetzt: B1, B2, B3a, B3, B4 und die beiden CI-Schritte aus D1 (Deno-Tests und
- **Ergänzung Testdaten:** `supabase/catalog/testdata/` (fiktives Vokabular und drei Testspiele) erzeugt `supabase/tests/support/*.inc`, die `06_catalog_foundation.test.sql` per `\ir` einbindet; so laufen Idempotenz und `checks.sql` im pgTAP-Test mit den echten erzeugten Skripten. - **Ergänzung Testdaten:** `supabase/catalog/testdata/` (fiktives Vokabular und drei Testspiele) erzeugt `supabase/tests/support/*.inc`, die `06_catalog_foundation.test.sql` per `\ir` einbindet; so laufen Idempotenz und `checks.sql` im pgTAP-Test mit den echten erzeugten Skripten.
- **`game_links_mark_manual`:** prüft zuerst per `select`, ob das Spiel ein Katalogspiel ist, und schreibt nur dann `manual_fields`. Ohne diese Prüfung scheiterte jede Zuordnung zu einem privaten Spiel mit 42501, weil Nutzer kein Schreibrecht auf `manual_fields` haben. - **`game_links_mark_manual`:** prüft zuerst per `select`, ob das Spiel ein Katalogspiel ist, und schreibt nur dann `manual_fields`. Ohne diese Prüfung scheiterte jede Zuordnung zu einem privaten Spiel mit 42501, weil Nutzer kein Schreibrecht auf `manual_fields` haben.
- **Werkzeug:** Deno wird für das Skript gebraucht (lokal 2.9.8, in CI gepinnt über `DENO_VERSION`). Keine externen Importe. - **Werkzeug:** Deno wird für das Skript gebraucht (lokal 2.9.8, in CI gepinnt über `DENO_VERSION`). Keine externen Importe.
### 2026-11-03 – Umsetzung PR 2b (backend-dev)
Umgesetzt: B5 (Migration `20261103193928_catalog_merge.sql`), B6 (pgTAP `07_catalog_merge.test.sql`, 89 Tests), B7 (Runbook Abschnitt 5). Abweichungen und Ergänzungen, jeweils zur Prüfung im PR:
- **Branch:** `feature/2b-katalog-zusammenfuehren` statt `feature/2-katalog-pflege` (Vorgabe des Auftrags).
- **Keine neue Tabelle:** `00_schema.test.sql` bleibt unverändert (20 Tabellen).
- **Ergebnis-JSON (festgelegt):** `collection_items` (ohne Kollision umgehängt), `collection_conflicts`, `favorite_games`, `favorite_conflicts`, `plays`, `score_sheets`, `variants_copied`, `variants_mapped`, `variants_cleared`; `admin_merge_games` ergänzt `source_id`, `target_id`, `bgg_id_transferred`, `source_transferred`, `chain_flattened` und bei Konflikt `bgg_id_conflict` / `source_conflict`. **`variants_cleared` zählt Verweise** (Sammlungseinträge und Partien, deren Edition geleert wurde), nicht Varianten; so erkennt der Admin und später #13, ob bei einem Nutzer eine Edition verloren ging. Beim Zusammenführen zweier Katalogspiele ist er immer 0. `source_conflict` enthält zusätzlich `source_synced_at` (alle vier Werte der Gruppe bleiben erhalten).
- **Zusätzliche Prüfung:** `admin_merge_games(null, …)` / `(…, null)` meldet `merge_game_not_found`. `admin_merge_games` sperrt beide Zeilen in fester Reihenfolge (`for update`), damit parallele Aufrufe nicht verklemmen.
- **Variantenzuordnung** ist ein jsonb-Wert in der Funktion, keine temporäre Tabelle (mehrere Aufrufe in einer Transaktion bleiben möglich). Der Namensabgleich läuft nur gegen die Zielvarianten, die vor dem Aufruf existierten; jede Quellvariante wird einzeln kopiert, auch wenn zwei Quellvarianten gleich heißen (wörtliche Auslegung von 3.3).
- **Kollisionsregel Spec-F1** füllt nur `note`, `acquired_on` und `variant_id`; die Felder aus SAM-02/SAM-04 gibt es noch nicht (Hinweis 9.5 bleibt). Eine leere Notiz (`null` oder leerer Text) am Ziel gilt als leer.
- **`repoint_game_references` prüft nicht, ob `p_from` existiert** (nur die beiden Fehler aus 3.3); `admin_merge_games` prüft es. Der Fall „privater Eintrag auf Katalogspiel“ ist in 2b nur direkt gegen `repoint_game_references` getestet; `admin_resolve_report` kommt mit 2c.
- **Schritt 4 aus 3.3** (`game_reports.target_game_id` umhängen) entfällt bis 2c, wie vorgesehen.
- **Hinweis für 2c (B9):** Sobald `game_reports` zwei Fremdschlüssel auf `games` bekommt, wird der Wächter in `07` rot. Die erwartete Liste in `07` muss dort um `game_reports.target_game_id` (behandelt) und `game_reports.provisional_game_id` (bewusst ausgelassen) erweitert werden, nicht nur der Wächter in `08`.
- **Runbook:** Alle SQL-Bausteine aus Abschnitt 5 laufen wörtlich in `07` (einmalige Prüfung per Skript; keine CI-Prüfung). Abschnitt 5 enthält die Duplikatsuche, eine Vorschau (Verweise, Doppelbesitz), den Aufruf, die Nachprüfung, die Regeln, die Schlüssel des Ergebnis-JSON und die Fehlertexte.

View file

@ -32,6 +32,7 @@ supabase test db # alle pgTAP-Tests ausführen
## Katalog (KAT-05) ## Katalog (KAT-05)
- Pflege in R1 nur über Studio bzw. `psql` (Rolle `postgres`), Ablauf und Regeln: [`docs/katalog-pflege.md`](../docs/katalog-pflege.md). Spec: `docs/specs/2-katalog-daten.md`. - Pflege in R1 nur über Studio bzw. `psql` (Rolle `postgres`), Ablauf und Regeln: [`docs/katalog-pflege.md`](../docs/katalog-pflege.md). Spec: `docs/specs/2-katalog-daten.md`.
- Suche und Entdecken lesen nur die View `catalog_games` (ohne ausgeblendete und zusammengeführte Spiele). Geänderte Fakten trägt ein Trigger in `games.manual_fields` ein. - Suche und Entdecken lesen nur die View `catalog_games` (ohne ausgeblendete und zusammengeführte Spiele). Geänderte Fakten trägt ein Trigger in `games.manual_fields` ein.
- Zusammenführen von Duplikaten nur über die Funktion `public.admin_merge_games(quelle, ziel)` (nur Datenbankrolle `postgres`/`supabase_admin`, nicht über die API aufrufbar); Ablauf in `docs/katalog-pflege.md`, Abschnitt 5. Jede neue Tabelle mit Verweis auf `games` muss dort behandelt werden (Wächter in `tests/database/07_catalog_merge.test.sql`).
- `db reset` spielt `seed.sql`, `catalog/vocabulary.sql` und `catalog/startbestand.sql` ein (`[db.seed] sql_paths` in `config.toml`). Die SQL-Dateien in `catalog/` sind **erzeugt**; Quellen sind die CSV-Dateien. Neu erzeugen und testen (Deno, `brew install deno`): - `db reset` spielt `seed.sql`, `catalog/vocabulary.sql` und `catalog/startbestand.sql` ein (`[db.seed] sql_paths` in `config.toml`). Die SQL-Dateien in `catalog/` sind **erzeugt**; Quellen sind die CSV-Dateien. Neu erzeugen und testen (Deno, `brew install deno`):
```bash ```bash
deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts

View file

@ -202,9 +202,9 @@ select set_eq($$select owner_id, liste_quelle, liste_ziel from rb_dbl$$,
-- Runbook 5, Schritt 3: Zusammenführen -- Runbook 5, Schritt 3: Zusammenführen
create temp table rb_merge as create temp table rb_merge as
select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003') as ergebnis; select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003');
select is((select ergebnis from rb_merge), select is((select admin_merge_games from rb_merge),
jsonb_build_object( jsonb_build_object(
'source_id', '7a1b2c3d-0000-4000-8000-000000000002', 'target_id', '7a1b2c3d-0000-4000-8000-000000000003', 'source_id', '7a1b2c3d-0000-4000-8000-000000000002', 'target_id', '7a1b2c3d-0000-4000-8000-000000000003',
'collection_items', 1, 'collection_conflicts', 3, 'favorite_games', 1, 'favorite_conflicts', 1, 'collection_items', 1, 'collection_conflicts', 3, 'favorite_games', 1, 'favorite_conflicts', 1,