From a012e5afda57632315a03859ceca06cbe7ad3ecb Mon Sep 17 00:00:00 2001 From: ScriptSorcerer Date: Wed, 23 Sep 2026 23:35:07 +0200 Subject: [PATCH] =?UTF-8?q?Datenbank-Schema=20hinzugef=C3=BCgt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Datenbank-Schema.-.md | 411 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 Datenbank-Schema.-.md diff --git a/Datenbank-Schema.-.md b/Datenbank-Schema.-.md new file mode 100644 index 0000000..6f20dd9 --- /dev/null +++ b/Datenbank-Schema.-.md @@ -0,0 +1,411 @@ +# Datenbank-Schema + +← Zurück zur [Übersicht](Datenbank) · Funktionen: [Datenbank-Repositories](Datenbank-Repositories) + +Diese Seite beschreibt alle Tabellen und wie sie zusammenhängen. + +| Bereich | Tabellen | Angelegt in | +|---|---|---| +| Benutzer | `users`, `user_settings` | `schema.ts` | +| Chats | `chat_sessions`, `chat_messages`, `chat_message_versions`, `chat_memories`, `persona_chat_state` | `schema.ts` | +| Personas | `personas`, `persona_versions`, `prompt_templates` | `schema.ts` | +| Marktplatz | `persona_market`, `persona_market_favorites`, `persona_market_ratings`, `persona_market_reports` | `schema.ts` | +| Anhänge | `attachments`, `message_attachments`, `attachment_vision_analyses`, `upload_deletions` | `attachments.ts` | + +--- + +## Beziehungsdiagramm + +**Durchgezogene Linie** = echter Fremdschlüssel (SQLite prüft ihn). +**Gestrichelte Linie** = nur logische Beziehung (Spalte verweist auf eine andere Tabelle, +wird aber nicht von der Datenbank geprüft, sondern vom Code). + +```mermaid +erDiagram + users ||--o| user_settings : "hat" + users ||--o{ chat_memories : "besitzt" + users ||--o{ persona_chat_state : "besitzt" + users ||--o{ attachments : "lädt hoch" + users ||..o{ chat_sessions : "besitzt" + users ||..o{ personas : "besitzt" + users ||..o{ prompt_templates : "besitzt" + users ||..o{ persona_market : "veröffentlicht" + users ||..o{ persona_market_favorites : "favorisiert" + users ||..o{ persona_market_ratings : "bewertet" + users ||..o{ persona_market_reports : "meldet" + + chat_sessions ||--o{ chat_messages : "enthält" + chat_sessions ||--o{ chat_memories : "hat" + chat_sessions ||--o| persona_chat_state : "hat" + chat_sessions |o--o{ chat_sessions : "Abzweigung von" + chat_messages |o--o{ chat_sessions : "Abzweigpunkt" + chat_messages |o..o{ chat_sessions : "aktives Blatt" + chat_messages |o--o{ chat_messages : "Parent" + chat_messages ||--o{ chat_message_versions : "Versionen" + chat_messages ||--o{ message_attachments : "hat" + + attachments ||--o{ message_attachments : "verwendet in" + attachments ||--o{ attachment_vision_analyses : "Analysen" + attachments ||..o| upload_deletions : "Trigger" + + personas |o--o{ chat_sessions : "Persona im Chat" + personas |o--o{ chat_memories : "bezieht sich auf" + personas |o--o{ persona_chat_state : "bezieht sich auf" + personas |o--o{ user_settings : "aktive Persona" + personas ||--o{ persona_versions : "Versionen" + personas |o--o{ prompt_templates : "Vorlagen" + personas |o..o{ persona_market : "veröffentlicht als" + persona_market |o..o{ personas : "gesammelt als" + persona_market ||--o{ persona_market_favorites : "hat" + persona_market ||--o{ persona_market_ratings : "hat" + persona_market ||--o{ persona_market_reports : "hat" +``` + +--- + +## Lösch- und Umbenennungsverhalten + +Aufräumen passiert an **zwei Stellen**: teils automatisch durch Fremdschlüssel in der +Datenbank, teils von Hand in `repositories.js`. + +### Automatisch durch die Datenbank (ON DELETE) + +| Wird gelöscht | Folge | +|---|---| +| `chat_sessions` | Löscht alle `chat_messages`, `chat_memories` und `persona_chat_state` des Chats. Abzweigungen dieses Chats behalten ihren Inhalt, `source_chat_id` wird `NULL` | +| `chat_messages` | Löscht `chat_message_versions` und `message_attachments`. Kind-Nachrichten bleiben, ihr `parent_message_id` wird `NULL` | +| `personas` | Löscht `persona_versions` und `prompt_templates` der Persona. In Chats, Memories, Chat-State und Settings wird der Verweis `NULL` | +| `persona_market` | Löscht Favoriten, Bewertungen und Meldungen | +| `attachments` | Löscht `message_attachments` und `attachment_vision_analyses`, trägt die Datei in `upload_deletions` ein (Trigger) | +| `users` | Löscht `user_settings`, `chat_memories`, `persona_chat_state`, `attachments` | + +### Von Hand im Code + +| Aktion | Funktion | Was passiert | +|---|---|---| +| Benutzer löschen | `deleteUserTransaction` | Löscht nacheinander Marktplatz-Favoriten, -Bewertungen, -Meldungen, veröffentlichte Personas, Vorlagen, Persona-Versionen, Memories, Chat-State, Chats, Settings, Personas und zuletzt den Benutzer. Anhänge werden danach automatisch per CASCADE entfernt | +| Benutzer umbenennen | `renameUserTransaction` | Ändert den Namen in `users` und in allen Tabellen mit Benutzerbezug. `attachments` wird per `ON UPDATE CASCADE` automatisch angepasst | +| Persona löschen | `clearPersonaReferences` + `deletePersona` | Entfernt die Persona aus allen Chats und löscht ihren Marktplatz-Eintrag, danach die Persona selbst | + +Wird eine **neue Tabelle mit Benutzerbezug** angelegt, muss sie in beiden Transaktionen ergänzt werden. + +--- + +## Tabellen + +### Benutzer + +#### `users` + +| Spalte | Typ | Hinweis | +|---|---|---| +| `username` | TEXT | **PK** | +| `password` | TEXT | Passwort-Hash, nie Klartext | +| `session_version` | INTEGER | Default `0`. Wird erhöht, um alle Sessions des Benutzers ungültig zu machen (z. B. nach Passwortänderung) | + +#### `user_settings` (1:1 zu `users`) + +| Spalte | Typ | Hinweis | +|---|---|---| +| `username` | TEXT | **PK**, FK → `users` (CASCADE) | +| `active_persona_id` | INTEGER | FK → `personas` (SET NULL) | +| `active_user_persona_id` | INTEGER | FK → `personas` (SET NULL), die aktuell gewählte User-Persona | + +### Chats + +#### `chat_sessions` + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | INTEGER | **PK** | +| `username` | TEXT | Besitzer (logisch → `users`) | +| `title` | TEXT | Gespeicherter Titel, siehe Hinweis unten | +| `assistant_persona_id` | INTEGER | FK → `personas` (SET NULL) | +| `user_persona_id` | INTEGER | FK → `personas` (SET NULL) | +| `folder_name` | TEXT | Ordner in der Seitenleiste | +| `is_pinned` | INTEGER | Boolean | +| `archived_at` | DATETIME | `NULL` = nicht archiviert | +| `scenario_prompt`, `scenario_summary` | TEXT | Rollenspiel-Szenario und Kurzfassung | +| `context_summary` | TEXT | Automatische Zusammenfassung älterer Nachrichten | +| `context_summary_message_id` | INTEGER | Bis zu dieser Nachricht reicht die Zusammenfassung (logisch → `chat_messages`) | +| `active_leaf_message_id` | INTEGER | Letzte Nachricht des sichtbaren Zweigs (logisch → `chat_messages`), siehe [Nachrichtenbaum](#nachrichtenbaum) | +| `source_chat_id` | INTEGER | FK → `chat_sessions` (SET NULL). Gesetzt, wenn dieser Chat eine Abzweigung eines anderen ist | +| `branched_from_message_id` | INTEGER | FK → `chat_messages` (SET NULL). Nachricht, an der abgezweigt wurde | +| `preferred_model`, `temperature`, `context_length`, `response_length`, `system_instruction` | | Modell-Einstellungen pro Chat | +| `created_at`, `updated_at` | DATETIME | `updated_at` bestimmt die Sortierung in der Liste | + +**Angezeigter Titel:** Hat ein Chat eine Assistant-Persona und ist keine Abzweigung, wird der +**Name der Persona** als Titel angezeigt. Der gespeicherte Titel steht dann in `stored_title`. +Diese Logik steckt in den SELECT-Abfragen in `repositories.js`. + +**Hauptchat:** Pro Kombination aus Assistant- und User-Persona gibt es einen Hauptchat +(`source_chat_id IS NULL`), den `getChatByParticipants` findet. + +#### `chat_messages` + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | INTEGER | **PK** | +| `chat_id` | INTEGER | FK → `chat_sessions` (CASCADE) | +| `role` | TEXT | `user` oder `bot` | +| `content` | TEXT | Aktuell angezeigter Text | +| `thinking` | TEXT | Reasoning-Ausgabe des Modells | +| `sources` | TEXT | Websuche-Quellen als JSON | +| `model_name` | TEXT | Modell, das die Antwort erzeugt hat | +| `retry_variants` | TEXT | JSON-Array aller Antwortvarianten (Kopie aus `chat_message_versions`) | +| `retry_active_index` | INTEGER | Welche Variante angezeigt wird | +| `retry_retries_used` | INTEGER | Anzahl genutzter Wiederholungen | +| `retry_prompt_message_id` | INTEGER | Zugehörige User-Nachricht (logisch → `chat_messages`) | +| `delivery_status` | TEXT | `complete` (Default), `failed`, sowie ein Zwischenstatus während der Generierung (wird im Chat-Service gesetzt) | +| `error_message` | TEXT | Fehlertext bei `failed` | +| `parent_message_id` | INTEGER | FK → `chat_messages` (SET NULL), Vorgänger im Baum | +| `created_at` | DATETIME | | + +#### `chat_message_versions` + +Alle Fassungen einer Nachricht (Regenerieren, Bearbeiten). + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | INTEGER | **PK** | +| `message_id` | INTEGER | FK → `chat_messages` (CASCADE), indiziert | +| `content`, `thinking`, `sources`, `model_name` | TEXT | Inhalt dieser Fassung | +| `created_at` | DATETIME | | + +#### `chat_memories` + +Fakten, die sich das Modell in einem Chat merkt. + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | INTEGER | **PK** | +| `username` | TEXT | FK → `users` (CASCADE) | +| `chat_id` | INTEGER | FK → `chat_sessions` (CASCADE) | +| `assistant_persona_id`, `user_persona_id` | INTEGER | FK → `personas` (SET NULL) | +| `fact` | TEXT | Der Fakt | +| `created_at`, `updated_at` | DATETIME | | + +#### `persona_chat_state` (1:1 zu `chat_sessions`) + +Rollenspiel-Zustand eines Chats. `chat_id` ist `UNIQUE`, Änderungen laufen per Upsert. + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | INTEGER | **PK** | +| `username` | TEXT | FK → `users` (CASCADE) | +| `chat_id` | INTEGER | FK → `chat_sessions` (CASCADE), **UNIQUE** | +| `assistant_persona_id`, `user_persona_id` | INTEGER | FK → `personas` (SET NULL) | +| `relationship_notes`, `tone`, `current_location`, `goals`, `unresolved_threads`, `boundaries` | TEXT | Zustand der Unterhaltung | +| `created_at`, `updated_at` | DATETIME | | + +### Personas + +#### `personas` + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | INTEGER | **PK** | +| `username` | TEXT | Besitzer (logisch → `users`) | +| `name` | TEXT | | +| `pronouns`, `appearance`, `background`, `details`, `example_dialogues` | TEXT | Beschreibung | +| `custom_fields` | TEXT | Eigene Felder als JSON | +| `persona_type` | TEXT | `assistant` (Default) oder `user` | +| `source_market_id` | INTEGER | Gesetzt, wenn aus dem Marktplatz gesammelt (logisch → `persona_market`) | +| `source_creator_username` | TEXT | Ursprünglicher Ersteller | +| `created_at`, `updated_at` | DATETIME | | + +#### `persona_versions` + +Bei jeder Änderung einer Persona wird der **vorherige** Stand als Version gespeichert. + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | INTEGER | **PK** | +| `persona_id` | INTEGER | FK → `personas` (CASCADE) | +| `username` | TEXT | | +| `version_number` | INTEGER | Fortlaufend ab 1 | +| `snapshot_json` | TEXT | Stand der Persona als JSON (erzeugt von `buildPersonaSnapshot`) | +| `created_at` | DATETIME | | + +#### `prompt_templates` + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | INTEGER | **PK** | +| `username` | TEXT | Besitzer. **`NULL` = System-Vorlage**, sichtbar für alle, bearbeitbar nur als Benutzer `system` | +| `persona_id` | INTEGER | FK → `personas` (CASCADE). `NULL` = für alle Personas | +| `name`, `description`, `category` | TEXT | | +| `prompt_text` | TEXT | Der Prompt | +| `starter_text` | TEXT | Optionaler Einstiegstext | +| `is_shared` | INTEGER | Boolean. Geteilte Vorlagen sehen alle Benutzer | +| `created_at`, `updated_at` | DATETIME | | + +### Marktplatz + +#### `persona_market` + +Eine **Kopie** der Persona zum Zeitpunkt der Veröffentlichung. Änderungen am Original +wirken erst nach erneutem Veröffentlichen. + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | INTEGER | **PK** | +| `persona_id` | INTEGER | Original-Persona (logisch → `personas`) | +| `creator_username` | TEXT | Ersteller | +| `name`, `pronouns`, `appearance`, `background`, `details`, `example_dialogues` | TEXT | Kopie der Persona | +| `persona_type` | TEXT | `assistant` oder `user` | +| `tags` | TEXT | JSON-Array | +| `usage_count` | INTEGER | Wie oft gesammelt | +| `favorite_count` | INTEGER | Zwischengespeicherte Anzahl, wird nach jeder Änderung neu gezählt | +| `rating_total`, `rating_count` | INTEGER | Summe und Anzahl der Bewertungen. Durchschnitt wird in Abfragen als `rating_average` berechnet | +| `moderation_status` | TEXT | Default `approved`. `rejected` wird öffentlich ausgeblendet | +| `moderation_notes` | TEXT | | +| `soft_deleted_at` | DATETIME | `NULL` = sichtbar | +| `created_at`, `updated_at` | DATETIME | | + +**UNIQUE** (`persona_id`, `creator_username`): Jede Persona kann nur einmal veröffentlicht werden, +erneutes Veröffentlichen aktualisiert den Eintrag. + +#### `persona_market_favorites` · `persona_market_ratings` + +| Spalte | Typ | Hinweis | +|---|---|---| +| `market_id` | INTEGER | **PK**, FK → `persona_market` (CASCADE) | +| `username` | TEXT | **PK** | +| `rating` | INTEGER | nur in `_ratings` | +| `created_at` (`updated_at`) | DATETIME | | + +Zusammengesetzter Primärschlüssel: ein Favorit bzw. eine Bewertung pro Benutzer und Eintrag. + +#### `persona_market_reports` + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | INTEGER | **PK** | +| `market_id` | INTEGER | FK → `persona_market` (CASCADE) | +| `reporter_username` | TEXT | | +| `reason`, `details` | TEXT | | +| `status` | TEXT | Default `open`. Offene Meldungen werden zuerst gelistet | +| `created_at`, `updated_at` | DATETIME | | + +### Anhänge + +#### `attachments` + +Metadaten hochgeladener Bilder. Die Datei selbst liegt im Upload-Verzeichnis. + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | TEXT | **PK** | +| `username` | TEXT | FK → `users` (CASCADE, auch ON UPDATE) | +| `original_name` | TEXT | Ursprünglicher Dateiname | +| `storage_key` | TEXT | **UNIQUE**, Dateiname im Speicher, **nie ans Frontend geben** | +| `mime_type` | TEXT | | +| `size_bytes`, `width`, `height` | INTEGER | | +| `status` | TEXT | `temporary` (Default) oder `attached`, per `CHECK` erzwungen | +| `created_at` | TEXT | | +| `vision_model`, `vision_analysis`, `vision_analyzed_at` | TEXT | Ältere Einzelanalyse, siehe Auffälligkeiten | + +#### `message_attachments` (n:m Nachricht ↔ Anhang) + +| Spalte | Typ | Hinweis | +|---|---|---| +| `message_id` | INTEGER | **PK**, FK → `chat_messages` (CASCADE) | +| `attachment_id` | TEXT | **PK**, FK → `attachments` (CASCADE), indiziert | +| `position` | INTEGER | Reihenfolge der Bilder | + +#### `attachment_vision_analyses` + +Zwischenspeicher für Bildanalysen, damit dasselbe Bild nicht mehrfach analysiert wird. + +| Spalte | Typ | Hinweis | +|---|---|---| +| `attachment_id` | TEXT | **PK**, FK → `attachments` (CASCADE) | +| `vision_model` | TEXT | **PK** | +| `prompt_version` | INTEGER | **PK** | +| `question` | TEXT | **PK** | +| `analysis` | TEXT | Ergebnis | +| `analyzed_at` | TEXT | | + +#### `upload_deletions` + +Warteschlange für Dateien, die die App vom Speicher löschen muss. Einzige Spalte: `storage_key` (**PK**). + +### Lebenszyklus eines Anhangs + +```mermaid +flowchart TD + A["Bild hochgeladen"] --> B["attachments
status = temporary"] + B -->|"Nachricht gesendet"| C["status = attached
+ Zeile in message_attachments"] + C -->|"Nachricht oder Chat gelöscht"| D["message_attachments-Zeile gelöscht"] + D -->|"Trigger release_message_attachment
(keine weiteren Verweise)"| E["attachments-Zeile gelöscht"] + B -->|"temporärer Upload verworfen"| E + E -->|"Trigger queue_attachment_file_delete"| F["storage_key in upload_deletions"] + F --> G["App löscht die Datei vom Speicher"] +``` + +--- + +## Nachrichtenbaum + +Nachrichten eines Chats sind keine flache Liste, sondern ein **Baum**. Jede Nachricht +verweist über `parent_message_id` auf ihren Vorgänger. So können aus einer Stelle mehrere +Verläufe entstehen, z. B. durch Bearbeiten einer Frage. + +```mermaid +flowchart TD + M1["1 · user: Hallo"] --> M2["2 · bot: Hi!"] + M2 --> M3["3 · user: Erzähl einen Witz"] + M3 --> M4["4 · bot: Witz"] + M2 --> M5["5 · user: Erzähl ein Rätsel
(bearbeitete Frage)"] + M5 --> M6["6 · bot: Rätsel"] +``` + +- **Aktiver Zweig:** `chat_sessions.active_leaf_message_id` zeigt auf das sichtbare Ende, + z. B. `6`. Die App läuft von dort über die Parents zurück zur Wurzel: `6 → 5 → 2 → 1`. + Das machen die rekursiven Abfragen (`WITH RECURSIVE branch ...`) in `repositories.js`. +- **Geschwister:** Nachrichten mit gleichem Parent und gleicher Rolle (hier `3` und `5`) + sind Alternativen, zwischen denen der Benutzer wechseln kann. +- **Fehlgeschlagene Antworten** (`delivery_status = 'failed'`) bleiben im Baum, werden aber + beim Aufbau des Modellkontexts übersprungen. + +### Drei Arten von „Alternativen“ + +| Mechanismus | Wo gespeichert | Wofür | +|---|---|---| +| Versionen einer Nachricht | `chat_message_versions` (vollständig) und `retry_variants` (Kopie der Inhalte für die Anzeige) | Regenerieren einer Antwort, Bearbeiten. `saveMessageVersion` hält beide synchron | +| Zweige im Baum | `parent_message_id` + `active_leaf_message_id` | Verschiedene Gesprächsverläufe im selben Chat | +| Chat-Abzweigung | neuer Chat mit `source_chat_id` + `branched_from_message_id` | Eigener Chat, der an einer bestimmten Nachricht eines anderen Chats beginnt | + +--- + +## Migrationen + +`runMigrations` bringt ältere Datenbanken auf den aktuellen Stand. Die Funktion läuft +**bei jedem Start**, ändert aber nur, was noch fehlt (idempotent). + +| Schritt | Was passiert | Wann aktiv | +|---|---|---| +| `chat_message_versions` anlegen | Tabelle + Index | Immer, auch bei neuen DBs (existiert nur hier) | +| `ensureColumn(...)` | Liest Spalten per `PRAGMA table_info`, führt `ALTER TABLE ... ADD COLUMN` nur bei fehlender Spalte aus | Nur bei alten DBs. **Ausnahme:** `chat_messages.sources` und `thinking` existieren nur hier, werden also auch bei neuen DBs angelegt | +| `UPDATE ... WHERE ... IS NULL` | Setzt Default-Werte in alten Zeilen | Läuft jedes Mal (liest die ganze Tabelle) | +| Nachrichtenbaum aufbauen | Verknüpft jede Nachricht mit ihrer Vorgängerin (`LAG`), setzt das aktive Blatt | Einmalig, wenn `parent_message_id` neu angelegt wurde | +| Alte Tabelle `chats` übernehmen | Pro Benutzer ein Chat „Imported chat“ mit allen alten Nachrichten, in einer Transaktion | Einmalig, wenn `chats` existiert und `chat_messages` leer ist | +| `chats` löschen | `DROP TABLE chats` | Wenn `dropLegacyChats` (Config `DROP_LEGACY_CHATS`, Default `true`) | + +Geplante Verbesserung: versionierte Migrationen mit `PRAGMA user_version`, damit jede +Migration genau einmal läuft (siehe TODO in `schema.ts`). + +--- + +## Bekannte Auffälligkeiten + +| Punkt | Details | +|---|---| +| Fehlende Fremdschlüssel auf `users` | `chat_sessions`, `personas`, `prompt_templates`, `persona_versions` und alle Marktplatz-Tabellen verweisen nur logisch auf `users`. Das Aufräumen übernimmt `deleteUserTransaction`. SQLite kann Fremdschlüssel nicht per `ALTER TABLE` nachrüsten, dafür muss die Tabelle neu erstellt werden | +| Umbenennen trotz Fremdschlüssel (zu prüfen) | `renameUserTransaction` ändert zuerst `users.username`. `user_settings`, `chat_memories` und `persona_chat_state` haben einen Fremdschlüssel auf `users` **ohne** `ON UPDATE CASCADE`. Hat der Benutzer dort Einträge, schlägt das Umbenennen vermutlich mit `FOREIGN KEY constraint failed` fehl. Mit einem Test prüfen | +| `sources`, `thinking`, `chat_message_versions` | Nur in `runMigrations` definiert, nicht in `initializeSchema` | +| `user_settings.active_persona_id` | Wird gelesen, aber in `repositories.js` nirgends gesetzt | +| Zwei Orte für Bildanalysen | `attachments.vision_*` und `attachment_vision_analyses`. Prüfen, ob die Spalten noch verwendet werden | +| Uneinheitliche Zeitstempel | `DATETIME` in `schema.ts`, `TEXT` in `attachments.ts`. In SQLite gleichwertig, aber inkonsistent | +| Bewertungen ohne Wertebereich | `persona_market_ratings.rating` hat keinen `CHECK`, der Bereich wird nur im Code geprüft | \ No newline at end of file