Datenbank-Schema hinzugefügt

2026-09-23 23:35:07 +02:00
parent d88ecfdac5
commit a012e5afda
+411
@@ -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<br/>status = temporary"]
B -->|"Nachricht gesendet"| C["status = attached<br/>+ Zeile in message_attachments"]
C -->|"Nachricht oder Chat gelöscht"| D["message_attachments-Zeile gelöscht"]
D -->|"Trigger release_message_attachment<br/>(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<br/>(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 |