Shuffle/supabase/catalog/build_startbestand.ts
cschlaefke 9fa91eb684
All checks were successful
CI / flutter (pull_request) Successful in 1m19s
CI / web-build (pull_request) Successful in 57s
CI / supabase (pull_request) Successful in 11s
CI / supabase-tests (pull_request) Successful in 38s
CI / e2e (pull_request) Successful in 2m59s
fix(db): single filter set, filter tests with query, perf guards, evidence docs (KAT-01, 3a review)
Filters are evaluated once into an id set used by both search branches.
Adds tests for age/genre/mechanic filters with a search term, rank 2 vs 3
and null p_limit. approxNormalize uses NFKD. Perf script checks confirm_local
value and unix socket and re-analyzes after rollback. Adds
docs/specs/3-katalog-suche-umsetzung.md (measurements, log check incl. GET
finding), release checklist section 2a and the CSV-only note for alt titles.

Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 10:39:02 +01:00

766 lines
33 KiB
TypeScript

// Startbestand-Werkzeug (KAT-05, Spec docs/specs/2-katalog-daten.md, Abschnitt 4.3).
//
// Liest die von Hand gepflegten Dateien
// supabase/catalog/vocabulary.csv (Genres und Mechaniken, de/en)
// supabase/catalog/startbestand.csv (Spiele; Format siehe docs/katalog-pflege.md)
// prüft sie (SB2, SB4-SB7) und erzeugt daraus
// supabase/catalog/vocabulary.sql, startbestand.sql, startbestand-keys.csv, checks.sql
// supabase/tests/support/{vocabulary,startbestand,checks}_fixture.inc (nur für pgTAP, aus catalog/testdata/,
// siehe 06_catalog_foundation.test.sql)
// Die erzeugten Dateien nie von Hand bearbeiten. Keine Abhängigkeiten außer der Deno-Standardumgebung.
//
// Aufruf (im Repo-Root):
// deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts
//
// Das Skript erfindet keine Daten. Es erzeugt nie Cover-, Quellen- oder BGG-Angaben (SB6).
export const NAMESPACE = "6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4";
export const MIN_VISIBLE = 180;
export const GENERATED_HEADER =
"-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten.\n";
// ---------------------------------------------------------------------------------------------
// Fehler
// ---------------------------------------------------------------------------------------------
export class BuildError extends Error {
constructor(public readonly problems: string[]) {
super(problems.join("\n"));
this.name = "BuildError";
}
}
// ---------------------------------------------------------------------------------------------
// UUID v5 (RFC 4122), gleiches Ergebnis wie uuid_generate_v5() in PostgreSQL
// ---------------------------------------------------------------------------------------------
function uuidToBytes(uuid: string): Uint8Array {
const hex = uuid.replaceAll("-", "");
const out = new Uint8Array(16);
for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return out;
}
export async function uuidV5(name: string, namespace: string = NAMESPACE): Promise<string> {
const nameBytes = new TextEncoder().encode(name);
const data = new Uint8Array(16 + nameBytes.length);
data.set(uuidToBytes(namespace), 0);
data.set(nameBytes, 16);
const hash = new Uint8Array(await crypto.subtle.digest("SHA-1", data)).slice(0, 16);
hash[6] = (hash[6] & 0x0f) | 0x50;
hash[8] = (hash[8] & 0x3f) | 0x80;
const hex = Array.from(hash, (b) => b.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
// ---------------------------------------------------------------------------------------------
// CSV (RFC 4180: Anführungszeichen, "" als Escape, Zeilenumbrüche in Feldern, optionales BOM)
// ---------------------------------------------------------------------------------------------
export interface CsvRow {
line: number;
cells: Record<string, string>;
}
export interface Csv {
header: string[];
rows: CsvRow[];
}
export function parseCsv(text: string, file: string): Csv {
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
const records: { line: number; fields: string[] }[] = [];
let fields: string[] = [];
let field = "";
let inQuotes = false;
let line = 1;
let recordLine = 1;
let touched = false; // Satz hat Inhalt (auch ein leeres Feld nach Komma)
const problems: string[] = [];
const endField = () => {
fields.push(field);
field = "";
};
const endRecord = () => {
endField();
if (touched || fields.length > 1 || fields[0] !== "") {
records.push({ line: recordLine, fields });
}
fields = [];
touched = false;
};
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inQuotes) {
if (c === '"') {
if (text[i + 1] === '"') {
field += '"';
i++;
} else inQuotes = false;
} else {
if (c === "\n") line++;
field += c;
}
continue;
}
if (c === '"' && field === "") {
inQuotes = true;
touched = true;
} else if (c === ",") {
touched = true;
endField();
} else if (c === "\r") {
// wird mit dem folgenden \n behandelt; alleinstehendes \r zählt als Zeilenende
if (text[i + 1] !== "\n") {
endRecord();
line++;
recordLine = line;
}
} else if (c === "\n") {
endRecord();
line++;
recordLine = line;
} else {
field += c;
touched = true;
}
}
if (inQuotes) problems.push(`${file}:${recordLine}: Anführungszeichen nicht geschlossen`);
endRecord();
if (problems.length) throw new BuildError(problems);
if (records.length === 0) throw new BuildError([`${file}: Kopfzeile fehlt`]);
const header = records[0].fields.map((h) => h.trim());
const rows: CsvRow[] = [];
for (const r of records.slice(1)) {
if (r.fields.length !== header.length) {
problems.push(
`${file}:${r.line}: ${r.fields.length} Spalten statt ${header.length} (Komma im Text? Feld in Anführungszeichen setzen)`,
);
continue;
}
const cells: Record<string, string> = {};
header.forEach((h, idx) => (cells[h] = r.fields[idx]));
rows.push({ line: r.line, cells });
}
if (problems.length) throw new BuildError(problems);
return { header, rows };
}
function checkHeader(csv: Csv, file: string, required: string[], optional: string[]): string[] {
const problems: string[] = [];
const allowed = new Set([...required, ...optional]);
for (const col of required) {
if (!csv.header.includes(col)) problems.push(`${file}:1: Spalte "${col}" fehlt`);
}
for (const col of csv.header) {
if (!allowed.has(col)) {
// SB6: Es gibt bewusst keine Spalte für Cover, Quelle oder BGG-Kennung.
problems.push(`${file}:1: unbekannte Spalte "${col}" (erlaubt: ${[...allowed].join(", ")})`);
}
}
const seen = new Set<string>();
for (const col of csv.header) {
if (seen.has(col)) problems.push(`${file}:1: Spalte "${col}" doppelt`);
seen.add(col);
}
return problems;
}
// ---------------------------------------------------------------------------------------------
// Vokabular
// ---------------------------------------------------------------------------------------------
export type VocabKind = "genre" | "mechanic";
export interface VocabEntry {
kind: VocabKind;
vocab_key: string;
name_de: string;
name_en: string;
id: string;
}
export async function parseVocabulary(text: string, file = "vocabulary.csv"): Promise<VocabEntry[]> {
const csv = parseCsv(text, file);
const problems = checkHeader(csv, file, ["kind", "vocab_key", "name_de", "name_en"], []);
if (problems.length) throw new BuildError(problems);
const entries: VocabEntry[] = [];
const seenKey = new Set<string>();
const seenDe = new Set<string>();
const seenEn = new Set<string>();
for (const { line, cells } of csv.rows) {
const where = `${file}:${line}`;
const kind = cells.kind.trim();
const vocab_key = cells.vocab_key.trim();
const name_de = cells.name_de.trim();
const name_en = cells.name_en.trim();
if (kind !== "genre" && kind !== "mechanic") {
problems.push(`${where}: kind muss "genre" oder "mechanic" sein, ist "${kind}"`);
continue;
}
if (!/^[a-z0-9-]{2,40}$/.test(vocab_key)) {
problems.push(`${where}: vocab_key "${vocab_key}" ungültig (a-z, 0-9, Bindestrich, 2-40 Zeichen)`);
}
for (const [label, v] of [["name_de", name_de], ["name_en", name_en]] as const) {
if (v.length < 1 || v.length > 100) problems.push(`${where}: ${label} muss 1-100 Zeichen haben`);
}
const k = `${kind}:${vocab_key}`;
if (seenKey.has(k)) problems.push(`${where}: vocab_key "${vocab_key}" doppelt (${kind})`);
seenKey.add(k);
const de = `${kind}:${name_de.toLowerCase()}`;
if (seenDe.has(de)) problems.push(`${where}: name_de "${name_de}" doppelt (${kind})`);
seenDe.add(de);
const en = `${kind}:${name_en.toLowerCase()}`;
if (seenEn.has(en)) problems.push(`${where}: name_en "${name_en}" doppelt (${kind})`);
seenEn.add(en);
entries.push({ kind, vocab_key, name_de, name_en, id: await uuidV5(`${kind}:${vocab_key}`) });
}
if (problems.length) throw new BuildError(problems);
return entries;
}
// ---------------------------------------------------------------------------------------------
// Schlüsselliste (append-only)
// ---------------------------------------------------------------------------------------------
export function parseKeys(text: string, file = "startbestand-keys.csv"): Map<string, string> {
const csv = parseCsv(text, file);
const problems = checkHeader(csv, file, ["key", "uuid"], []);
if (problems.length) throw new BuildError(problems);
const keys = new Map<string, string>();
for (const { line, cells } of csv.rows) {
if (keys.has(cells.key)) problems.push(`${file}:${line}: key "${cells.key}" doppelt`);
keys.set(cells.key, cells.uuid);
}
if (problems.length) throw new BuildError(problems);
return keys;
}
export function renderKeys(keys: Map<string, string>): string {
const lines = [...keys.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([k, u]) => `${k},${u}`);
return ["key,uuid", ...lines].join("\n") + "\n";
}
// ---------------------------------------------------------------------------------------------
// Startbestand
// ---------------------------------------------------------------------------------------------
export interface Game {
key: string;
id: string;
title: string;
publishers: string[];
year: number;
min_players: number;
max_players: number;
min_minutes: number;
max_minutes: number;
min_age: number;
genres: string[]; // name_de
mechanics: string[]; // name_de
description: string;
hidden: boolean;
alt_titles: AltTitle[];
}
export interface AltTitle {
locale: "de" | "en";
title: string;
}
const REQUIRED_COLUMNS = [
"key", "title", "publishers", "year", "min_players", "max_players", "min_minutes", "max_minutes",
"min_age", "genres", "mechanics", "description", "description_locale",
];
const OPTIONAL_COLUMNS = ["hidden", "facts_source", "alt_titles", "base_game_key"];
const SMALLINT_MAX = 32767;
function splitList(value: string): string[] {
return value.split(";").map((s) => s.trim()).filter((s) => s !== "");
}
// Annäherung an public.search_normalize (Marken- und Schutzzeichen entfernen, Akzente/Umlaute entfernen,
// Kleinschreibung, alles außer a-z0-9 zu einem Leerzeichen). Maßgeblich bleibt die Datenbank
// (Unique-Constraint game_alt_titles_game_locale_title_key); hier nur für frühe Fehlermeldungen.
const FOLD: Record<string, string> = { "ß": "ss", "æ": "ae", "œ": "oe", "ø": "o", "đ": "d", "ł": "l", "ð": "d", "þ": "th" };
export function approxNormalize(s: string): string {
return s
.replace(/[®©™℗℠]/g, "")
.toLowerCase()
.replace(/[ßæœøđłðþ]/g, (c) => FOLD[c])
.normalize("NFKD")
.replace(/\p{M}+/gu, "")
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
// Spalte alt_titles: "de:Titel;en:Title" (Spec #3, 4.3). Sprache und Titel am ersten ":" getrennt.
// ";" ist in Titeln nicht erlaubt (kein Escaping); ein Titel mit ";" fällt als Eintrag ohne passende Sprache auf.
export function parseAltTitles(raw: string, mainTitle: string, fail: (msg: string) => void): AltTitle[] {
const out: AltTitle[] = [];
const seen = new Set<string>();
const mainNorm = approxNormalize(mainTitle);
const semicolonHint = '(";" ist in alternativen Titeln nicht erlaubt, Titel dieser Art im Studio pflegen)';
for (const entry of splitList(raw)) {
const colon = entry.indexOf(":");
if (colon < 0) {
fail(`alt_titles: Eintrag "${entry.slice(0, 40)}" hat kein ":" (Format de:Titel;en:Title) ${semicolonHint}`);
continue;
}
const locale = entry.slice(0, colon).trim();
const title = entry.slice(colon + 1).trim();
if (locale !== "de" && locale !== "en") {
fail(`alt_titles: Sprache "${locale.slice(0, 20)}" in "${entry.slice(0, 40)}" muss "de" oder "en" sein ${semicolonHint}`);
continue;
}
if (title === "") {
fail(`alt_titles: leerer Titel bei Sprache "${locale}"`);
continue;
}
if (title.length > 200) {
fail(`alt_titles: Titel "${title.slice(0, 30)}…" länger als 200 Zeichen`);
continue;
}
const norm = approxNormalize(title);
if (norm === "") {
fail(`alt_titles: Titel "${title}" besteht nur aus Sonderzeichen`);
continue;
}
if (norm === mainNorm) {
fail(`alt_titles: "${title}" ist gleich dem Haupttitel (ohne Beachtung von Schreibweise und Sonderzeichen)`);
continue;
}
const k = `${locale}:${norm}`;
if (seen.has(k)) {
fail(`alt_titles: Titel "${title}" doppelt (Sprache ${locale})`);
continue;
}
seen.add(k);
out.push({ locale, title });
}
return out;
}
export async function parseStartbestand(
text: string,
vocabulary: VocabEntry[],
previousKeys: Map<string, string>,
file = "startbestand.csv",
): Promise<Game[]> {
const csv = parseCsv(text, file);
const problems = checkHeader(csv, file, REQUIRED_COLUMNS, OPTIONAL_COLUMNS);
if (problems.length) throw new BuildError(problems);
const genreNames = new Set(vocabulary.filter((v) => v.kind === "genre").map((v) => v.name_de));
const mechanicNames = new Set(vocabulary.filter((v) => v.kind === "mechanic").map((v) => v.name_de));
const games: Game[] = [];
const seenKeys = new Set<string>();
const allKeys = new Set(csv.rows.map((r) => r.cells.key.trim()));
for (const { line, cells } of csv.rows) {
const where = `${file}:${line}`;
const key = cells.key.trim();
const before = problems.length;
const fail = (msg: string) => problems.push(`${where}: ${msg}`);
if (!/^[a-z0-9-]{2,60}$/.test(key)) fail(`key "${key}" ungültig (a-z, 0-9, Bindestrich, 2-60 Zeichen)`);
if (seenKeys.has(key)) fail(`key "${key}" doppelt`);
seenKeys.add(key);
const title = cells.title.trim();
if (title.length < 1 || title.length > 200) fail("title muss 1-200 Zeichen haben");
const publishers = splitList(cells.publishers);
if (publishers.length === 0) fail("mindestens ein Verlag nötig (publishers)");
const seenPub = new Set<string>();
for (const p of publishers) {
if (p.length > 200) fail(`Verlag "${p.slice(0, 30)}…" länger als 200 Zeichen`);
if (seenPub.has(p.toLowerCase())) fail(`Verlag "${p}" doppelt`);
seenPub.add(p.toLowerCase());
}
const num = (col: string, min: number, max: number): number => {
const raw = cells[col].trim();
if (raw === "") {
fail(`${col} fehlt (Pflicht-Fakt, SB2)`);
return NaN;
}
if (!/^\d+$/.test(raw)) {
fail(`${col} "${raw}" ist keine ganze Zahl`);
return NaN;
}
const n = Number(raw);
if (n < min || n > max) {
fail(`${col} ${n} außerhalb des erlaubten Bereichs ${min}-${max}`);
return NaN;
}
return n;
};
const year = num("year", 1800, 2100);
const min_players = num("min_players", 1, SMALLINT_MAX);
const max_players = num("max_players", 1, SMALLINT_MAX);
const min_minutes = num("min_minutes", 0, SMALLINT_MAX);
const max_minutes = num("max_minutes", 0, SMALLINT_MAX);
const min_age = num("min_age", 0, SMALLINT_MAX);
if (max_players < min_players) fail(`max_players (${max_players}) kleiner als min_players (${min_players})`);
if (max_minutes < min_minutes) fail(`max_minutes (${max_minutes}) kleiner als min_minutes (${min_minutes})`);
const resolveList = (col: "genres" | "mechanics", known: Set<string>, label: string): string[] => {
const names = splitList(cells[col]);
if (names.length === 0) fail(`mindestens ${label} nötig (${col}, SB2)`);
const seen = new Set<string>();
for (const n of names) {
if (!known.has(n)) fail(`unbekannt in ${col}: "${n}" (nur Begriffe aus vocabulary.csv, name_de, SB5)`);
if (seen.has(n)) fail(`${col}: "${n}" doppelt`);
seen.add(n);
}
return names;
};
const genres = resolveList("genres", genreNames, "ein Genre");
const mechanics = resolveList("mechanics", mechanicNames, "eine Mechanik");
const description = cells.description.trim();
if (description === "") fail("Kurztext fehlt (description, SB4)");
if (description.length > 5000) fail("description länger als 5000 Zeichen");
const locale = cells.description_locale.trim();
if (locale !== "de") fail(`description_locale muss "de" sein, ist "${locale}" (SB4)`);
const hiddenRaw = (cells.hidden ?? "").trim().toLowerCase();
if (hiddenRaw !== "" && hiddenRaw !== "ja") fail(`hidden muss leer oder "ja" sein, ist "${cells.hidden}"`);
const base = (cells.base_game_key ?? "").trim();
if (base !== "") {
if (base === key) fail("base_game_key verweist auf das Spiel selbst");
else if (!allKeys.has(base)) fail(`base_game_key "${base}" hat keinen passenden key (SB7)`);
}
const alt_titles = parseAltTitles(cells.alt_titles ?? "", title, fail);
if (problems.length === before) {
games.push({
key, id: await uuidV5(`game:${key}`), title, publishers, year, min_players, max_players,
min_minutes, max_minutes, min_age, genres, mechanics, description, hidden: hiddenRaw === "ja",
alt_titles,
});
}
}
// Schlüssel sind unveränderlich (W4): Ein früher vorhandener key darf nicht fehlen.
for (const [key, uuid] of previousKeys) {
if (!allKeys.has(key)) {
problems.push(
`${file}: Schlüssel entfernt oder umbenannt: "${key}" (Spiel nie löschen: Spalte hidden = ja setzen)`,
);
}
const expected = await uuidV5(`game:${key}`);
if (uuid !== expected) {
problems.push(`startbestand-keys.csv: uuid zu "${key}" ist ${uuid}, erwartet ${expected} (Namespace geändert?)`);
}
}
if (problems.length) throw new BuildError(problems);
return games;
}
// ---------------------------------------------------------------------------------------------
// SQL-Erzeugung
// ---------------------------------------------------------------------------------------------
export const q = (s: string): string => `'${s.replaceAll("'", "''")}'`;
const jsonLit = (v: unknown): string => `${q(JSON.stringify(v))}::jsonb`;
// Ein Eintrag pro Zeile, damit die erzeugten Dateien lesbar bleiben.
const jsonLines = (v: unknown[]): string =>
v.length === 0 ? "'[]'::jsonb" : `${q("[\n" + v.map((x) => " " + JSON.stringify(x)).join(",\n") + "\n]")}::jsonb`;
const num = (n: number): string => String(n);
export function generateVocabularySql(vocabulary: VocabEntry[]): string {
const out: string[] = [GENERATED_HEADER];
out.push(
"-- Vokabular (Genres, Mechaniken). Idempotent. Ein im Studio angelegter Begriff mit gleichem Namen",
"-- gewinnt (on conflict do nothing fängt ID- und Namenskonflikte); startbestand.sql löst über name_de auf.",
"",
);
for (const [kind, table] of [["genre", "genres"], ["mechanic", "mechanics"]] as const) {
const rows = vocabulary.filter((v) => v.kind === kind);
if (rows.length === 0) {
out.push(`-- keine ${table} im Vokabular`, "");
continue;
}
out.push(`insert into public.${table} (id, name_de, name_en) values`);
out.push(rows.map((v) => ` (${q(v.id)}, ${q(v.name_de)}, ${q(v.name_en)})`).join(",\n"));
out.push("on conflict do nothing;", "");
}
return out.join("\n");
}
export function generateStartbestandSql(games: Game[]): string {
const out: string[] = [GENERATED_HEADER];
out.push(
"-- Startbestand (KAT-05). Idempotent bis zum Go-live; danach eingefroren (docs/katalog-pflege.md).",
"-- Reihenfolge: Migrationen, vocabulary.sql, dann diese Datei.",
"",
);
if (games.length === 0) {
out.push("-- startbestand.csv enthält keine Spiele.", "");
return out.join("\n");
}
// Wächter: Fehlt ein Begriff (Vokabular nicht eingespielt, im Studio umbenannt), bricht die Datei
// ab, statt still Zuordnungen zu löschen.
const genreNames = [...new Set(games.flatMap((g) => g.genres))].sort();
const mechanicNames = [...new Set(games.flatMap((g) => g.mechanics))].sort();
out.push(
"do $guard$",
"declare",
" missing text;",
"begin",
` select string_agg(n, ', ') into missing from jsonb_array_elements_text(${jsonLit(genreNames)}) as t(n)`,
" where not exists (select 1 from public.genres g where g.name_de = t.n);",
" if missing is not null then",
" raise exception 'startbestand: Genre fehlt in der Datenbank (vocabulary.sql eingespielt?): %', missing;",
" end if;",
` select string_agg(n, ', ') into missing from jsonb_array_elements_text(${jsonLit(mechanicNames)}) as t(n)`,
" where not exists (select 1 from public.mechanics m where m.name_de = t.n);",
" if missing is not null then",
" raise exception 'startbestand: Mechanik fehlt in der Datenbank (vocabulary.sql eingespielt?): %', missing;",
" end if;",
"end",
"$guard$;",
"",
);
// 1. Verlage (ohne abgeleitete ID, Auflösung über lower(name))
const publisherNames = new Map<string, string>(); // lower -> erste Schreibweise
for (const g of games) for (const p of g.publishers) if (!publisherNames.has(p.toLowerCase())) publisherNames.set(p.toLowerCase(), p);
out.push("-- Verlage");
out.push("insert into public.publishers (name) values");
out.push([...publisherNames.values()].sort().map((p) => ` (${q(p)})`).join(",\n"));
out.push("on conflict ((lower(name))) do nothing;", "");
// 2. Spiele
const cols = "title, description, description_locale, year, min_players, max_players, min_minutes, max_minutes, min_age";
out.push("-- Spiele (unveränderte Zeilen werden nicht angefasst)");
out.push(`insert into public.games (id, ${cols}, visibility) values`);
out.push(
games.map((g) =>
` (${q(g.id)}, ${q(g.title)}, ${q(g.description)}, 'de', ${num(g.year)}, ${num(g.min_players)}, ${num(g.max_players)}, ` +
`${num(g.min_minutes)}, ${num(g.max_minutes)}, ${num(g.min_age)}, 'catalog')`
).join(",\n"),
);
const colList = cols.split(", ");
out.push("on conflict (id) do update set");
out.push(colList.map((c) => ` ${c} = excluded.${c}`).join(",\n"));
out.push(`where (${colList.map((c) => `games.${c}`).join(", ")})`);
out.push(` is distinct from (${colList.map((c) => `excluded.${c}`).join(", ")});`, "");
// 3. Entfernen = ausblenden (nur bei hidden = ja; sonst bleibt hidden_at unberührt)
const hidden = games.filter((g) => g.hidden);
if (hidden.length) {
out.push("-- hidden = ja: ausblenden, nichts löschen");
out.push(`update public.games set hidden_at = now()`);
out.push(`where hidden_at is null and id = any(array[${hidden.map((g) => q(g.id)).join(", ")}]::uuid[]);`, "");
}
// 4. Zuordnungen als Abgleich
const ids = games.map((g) => q(g.id)).join(", ");
const wantRows = (pick: (g: Game) => string[]) =>
games.flatMap((g) => pick(g).map((n) => ` (${q(g.id)}::uuid, ${q(n)})`)).join(",\n");
const reconcile = (
label: string,
linkTable: string,
fkColumn: string,
termTable: string,
termMatch: string,
pick: (g: Game) => string[],
) => {
out.push(`-- Zuordnung ${label}: fehlende anlegen, nicht mehr gewünschte löschen`);
out.push(`with want(game_id, term) as (values`);
out.push(wantRows(pick));
out.push(`), resolved as (`);
out.push(` select w.game_id, t.id as ${fkColumn} from want w join public.${termTable} t on ${termMatch}`);
out.push(`), removed as (`);
out.push(` delete from public.${linkTable} l`);
out.push(` where l.game_id = any(array[${ids}]::uuid[])`);
out.push(` and not exists (select 1 from resolved r where r.game_id = l.game_id and r.${fkColumn} = l.${fkColumn})`);
out.push(`)`);
out.push(`insert into public.${linkTable} (game_id, ${fkColumn})`);
out.push(`select game_id, ${fkColumn} from resolved`);
out.push(`on conflict (game_id, ${fkColumn}) do nothing;`, "");
};
reconcile("Genre", "game_genres", "genre_id", "genres", "t.name_de = w.term", (g) => g.genres);
reconcile("Mechanik", "game_mechanics", "mechanic_id", "mechanics", "t.name_de = w.term", (g) => g.mechanics);
reconcile("Verlag", "game_publishers", "publisher_id", "publishers", "lower(t.name) = lower(w.term)", (g) => g.publishers);
// 5. Alternative Titel (Spec #3, 4.3): Abgleich je Spiel über die normalisierte Form. Unveränderte
// Zeilen bleiben unberührt (updated_at und manual_fields stabil); Spiele ohne alt_titles verlieren
// früher gesetzte Titel. Keine abgeleitete ID, Konfliktziel ist die Unique-Constraint.
const altRows = games.flatMap((g) => g.alt_titles.map((a) => ` (${q(g.id)}::uuid, ${q(a.locale)}, ${q(a.title)})`));
out.push("-- Alternative Titel: fehlende anlegen, geänderte Schreibweise aktualisieren, nicht mehr gewünschte löschen");
if (altRows.length === 0) {
out.push(`delete from public.game_alt_titles a where a.game_id = any(array[${ids}]::uuid[]);`, "");
} else {
out.push(`with want(game_id, locale, title) as (values`);
out.push(altRows.join(",\n"));
out.push(`), removed as (`);
out.push(` delete from public.game_alt_titles a`);
out.push(` where a.game_id = any(array[${ids}]::uuid[])`);
out.push(` and not exists (select 1 from want w where w.game_id = a.game_id and w.locale = a.locale`);
out.push(` and public.search_normalize(w.title) = a.title_search)`);
out.push(`)`);
out.push(`insert into public.game_alt_titles (game_id, locale, title)`);
out.push(`select game_id, locale, title from want`);
out.push(`on conflict (game_id, locale, title_search) do update set title = excluded.title`);
out.push(`where public.game_alt_titles.title is distinct from excluded.title;`, "");
}
return out.join("\n");
}
// ---------------------------------------------------------------------------------------------
// Prüfabfrage checks.sql (und die Fixture-Variante für pgTAP)
// ---------------------------------------------------------------------------------------------
export function generateChecksQuery(startbestandIds: string[], vocabulary: VocabEntry[]): string {
const sbIds = [...startbestandIds].sort();
const voc = vocabulary.map((v) => ({ kind: v.kind, id: v.id, name_de: v.name_de }));
return [
`with sb(id) as (`,
` select t.value::uuid from jsonb_array_elements_text(${jsonLines(sbIds)}) as t(value)`,
`), voc(kind, id, name_de) as (`,
` select x.kind, x.id, x.name_de from jsonb_to_recordset(${jsonLines(voc)}) as x(kind text, id uuid, name_de text)`,
`)`,
`select rule, beschreibung, soll, ist, case when ok is null then 'Information' when ok then 'ok' else 'Verstoß' end as status`,
`from (`,
` select 1 as ord, 'SB0' as rule, 'Startbestandsspiele in der Datenbank vorhanden' as beschreibung,`,
` 'alle ' || (select count(*) from sb) as soll, n as ist, null::boolean as ok`,
` from (select count(*) as n from public.games g join sb on sb.id = g.id) x`,
` union all`,
` select 2, 'SB1', 'Sichtbare Katalogspiele aus dem Startbestand (Abnahme: mindestens ${MIN_VISIBLE})',`,
` '>= ${MIN_VISIBLE}', n, n >= ${MIN_VISIBLE}`,
` from (select count(*) as n from public.catalog_games c join sb on sb.id = c.id) x`,
` union all`,
` select 3, 'SB2', 'Spiele ohne Pflicht-Fakt (Jahr, Spielerzahl, Dauer, Alter, Verlag, Genre, Mechanik)', '0', n, n = 0`,
` from (select count(*) as n from public.games g join sb on sb.id = g.id`,
` where g.year is null or g.min_players is null or g.max_players is null`,
` or g.min_minutes is null or g.max_minutes is null or g.min_age is null`,
` or not exists (select 1 from public.game_publishers x where x.game_id = g.id)`,
` or not exists (select 1 from public.game_genres x where x.game_id = g.id)`,
` or not exists (select 1 from public.game_mechanics x where x.game_id = g.id)) x`,
` union all`,
` select 4, 'SB4', 'Spiele ohne deutschen Kurztext', '0', n, n = 0`,
` from (select count(*) as n from public.games g join sb on sb.id = g.id`,
` where g.description is null or g.description_locale is distinct from 'de') x`,
` union all`,
` select 5, 'SB6', 'Spiele mit Cover, Quelle oder BGG-Kennung', '0', n, n = 0`,
` from (select count(*) as n from public.games g join sb on sb.id = g.id`,
` where g.image_path is not null or g.catalog_source_id is not null or g.bgg_id is not null) x`,
` union all`,
` select 6, 'Info', 'Begriffe mit abweichender ID (im Studio angelegt, bitte prüfen)', '-', n, null::boolean`,
` from (select (select count(*) from public.genres t join voc v on v.kind = 'genre' and v.name_de = t.name_de where t.id <> v.id)`,
` + (select count(*) from public.mechanics t join voc v on v.kind = 'mechanic' and v.name_de = t.name_de where t.id <> v.id) as n) x`,
` union all`,
` select 7, 'Info-Alt', 'Startbestandsspiele mit alternativen Titeln', '-', n, null::boolean`,
` from (select count(distinct a.game_id) as n from public.game_alt_titles a join sb on sb.id = a.game_id) x`,
`) r`,
`order by ord`,
].join("\n");
}
export function generateChecksSql(startbestandIds: string[], vocabulary: VocabEntry[]): string {
return [
GENERATED_HEADER.trimEnd(),
"-- Prüfabfrage zur Abnahme (KAT-05 Krit. 7 und 10). Im Studio-SQL-Editor oder per psql ausführen.",
"-- Beschränkt sich auf die Spiele aus startbestand-keys.csv; Spiele, die im Studio angelegt wurden,",
"-- und das Beispielspiel aus seed.sql zählen nicht mit. Abnahme: kein Verstoß, SB1 mindestens " + MIN_VISIBLE + ".",
"-- Lokal und in CI ist SB1 nur eine Information, solange noch eingepflegt wird.",
generateChecksQuery(startbestandIds, vocabulary) + ";",
"",
].join("\n");
}
// Ausschließlich für pgTAP: legt die Prüfabfrage als temporäre View an (psql: \ir). Die IDs stammen
// aus den Testspielen in catalog/testdata/, damit die Prüfabfrage selbst getestet werden kann.
export function generateChecksFixtureInclude(startbestandIds: string[], vocabulary: VocabEntry[]): string {
return [
"-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten.",
"-- Include für pgTAP (\\ir), keine Test-Datei. Dieselbe Abfrage wie checks.sql, aber mit den IDs der",
"-- Testspiele aus supabase/catalog/testdata/.",
"create temp view checks_result as",
generateChecksQuery(startbestandIds, vocabulary) + ";",
"",
].join("\n");
}
// ---------------------------------------------------------------------------------------------
// Gesamtlauf
// ---------------------------------------------------------------------------------------------
export interface BuildInput {
vocabularyCsv: string;
startbestandCsv: string;
previousKeysCsv: string;
}
export interface BuildOutput {
"vocabulary.sql": string;
"startbestand.sql": string;
"startbestand-keys.csv": string;
"checks.sql": string;
checksFixtureInclude: string;
}
export async function build(input: BuildInput): Promise<BuildOutput> {
const vocabulary = await parseVocabulary(input.vocabularyCsv);
const previous = parseKeys(input.previousKeysCsv);
const games = await parseStartbestand(input.startbestandCsv, vocabulary, previous);
const keys = new Map(previous);
for (const g of games) keys.set(g.key, g.id);
const ids = [...keys.values()];
return {
"vocabulary.sql": generateVocabularySql(vocabulary),
"startbestand.sql": generateStartbestandSql(games),
"startbestand-keys.csv": renderKeys(keys),
"checks.sql": generateChecksSql(ids, vocabulary),
checksFixtureInclude: generateChecksFixtureInclude(ids, vocabulary),
};
}
async function main() {
const dir = new URL(".", import.meta.url);
const read = (name: string) => Deno.readTextFile(new URL(name, dir));
let previousKeysCsv = "key,uuid\n";
try {
previousKeysCsv = await read("startbestand-keys.csv");
} catch (e) {
if (!(e instanceof Deno.errors.NotFound)) throw e;
}
const real = await build({
vocabularyCsv: await read("vocabulary.csv"),
startbestandCsv: await read("startbestand.csv"),
previousKeysCsv,
});
// Testdaten (offensichtlich fiktiv) für pgTAP: eigenes Vokabular, eigene Spiele, keine Schlüsselliste.
const fixture = await build({
vocabularyCsv: await read("testdata/vocabulary.csv"),
startbestandCsv: await read("testdata/startbestand.csv"),
previousKeysCsv: "key,uuid\n",
});
for (const name of ["vocabulary.sql", "startbestand.sql", "startbestand-keys.csv", "checks.sql"] as const) {
await Deno.writeTextFile(new URL(name, dir), real[name]);
}
const support = new URL("../tests/support/", dir);
await Deno.mkdir(support, { recursive: true });
await Deno.writeTextFile(new URL("vocabulary_fixture.inc", support), fixture["vocabulary.sql"]);
await Deno.writeTextFile(new URL("startbestand_fixture.inc", support), fixture["startbestand.sql"]);
await Deno.writeTextFile(new URL("checks_fixture.inc", support), fixture.checksFixtureInclude);
console.log(
"Erzeugt: catalog/{vocabulary.sql,startbestand.sql,startbestand-keys.csv,checks.sql}, " +
"tests/support/{vocabulary,startbestand,checks}_fixture.inc",
);
}
if (import.meta.main) {
try {
await main();
} catch (e) {
if (e instanceof BuildError) {
console.error("Prüfung fehlgeschlagen:");
for (const p of e.problems) console.error(" " + p);
Deno.exit(1);
}
throw e;
}
}