diff --git a/Datenbank-Repositories.-.md b/Datenbank-Repositories.-.md new file mode 100644 index 0000000..a5b2798 --- /dev/null +++ b/Datenbank-Repositories.-.md @@ -0,0 +1,248 @@ +# Datenbank-Repositories + +← Zurück zur [Übersicht](Datenbank) · Tabellen: [Datenbank-Schema](Datenbank-Schema) + +`src/db/repositories.js` enthält **alle** Datenbankzugriffe der App. Der Rest der App +schreibt kein SQL, sondern ruft Funktionen aus dieser Datei auf. + +```js +import { createRepositories } from "./db/repositories.js"; + +const repositories = createRepositories(db, config); +const chats = repositories.listChats("jerome"); +``` + +--- + +## Aufbau der Datei + +| Teil | Inhalt | +|---|---| +| 1. Hilfsfunktionen | `parseJsonArray`, `buildPersonaSnapshot`, `MARKET_SORT_ORDERS`, `normalizeMarketSort`, `normalizeMarketPersonaType` | +| 2. `statements` | Alle vorbereiteten SQL-Abfragen (`db.prepare`), einmal beim Start erstellt | +| 3. Transaktionen | Zusammengehörige Befehle, die gemeinsam gelingen oder gemeinsam fehlschlagen | +| 4. Rückgabeobjekt | Die öffentliche API, meist dünne Hüllen um ein Statement | + +`createRepositories` ist eine **Factory-Funktion**: `statements` ist von außen nicht +erreichbar, nur das zurückgegebene Objekt. Tests können eine eigene In-Memory-Datenbank übergeben. + +Verwendete Config-Felder: `chatHistoryLimit` (Länge des Modellkontexts), `uploadMaxFiles` (Import). + +--- + +## Wichtige Hinweise + +**Zugriffsschutz über `username`.** Die meisten Abfragen enthalten `AND username = ?`. +Ein Benutzer bekommt dadurch nur seine eigenen Daten, auch wenn er eine fremde ID schickt. +Nachrichtenfunktionen (`listChatMessages(chatId)` usw.) prüfen den Benutzer **nicht**. +Vorher immer mit `getChat(chatId, username)` sicherstellen, dass der Chat dem Benutzer gehört. + +**Rückgabewerte.** Lesefunktionen geben ein Objekt, `undefined`/`null` oder ein Array zurück. +Schreibfunktionen geben meist das Ergebnis von `.run()` zurück: `{ changes, lastInsertRowid }`. +`changes === 0` bedeutet: nichts gefunden oder nicht berechtigt. + +**`this` in einigen Funktionen.** `upsertMarketPersona`, `rateMarketPersona` und `exportWorkspace` +benutzen `this`. Sie funktionieren nur als `repositories.funktion(...)`, **nicht** nach +Destructuring (`const { exportWorkspace } = repositories` bricht). + +**Parameterreihenfolge ist nicht einheitlich.** Meist steht `chatId` vorne, aber z. B. +`updatePassword(passwordHash, username)` oder `createChatMemory(username, chat, fact)`. +Im Zweifel in der Tabelle unten nachschlagen. + +**Payloads in camelCase, Zeilen in snake_case.** Eingaben wie `createPersona(..., payload)` +erwarten `exampleDialogues`, Rückgaben aus der DB enthalten `example_dialogues`. + +### Transaktionen + +| Transaktion | Wird verwendet von | +|---|---| +| `renameUserTransaction` | `renameUser` | +| `deleteUserTransaction` | `deleteUser` | +| `completeChatTurnTransaction` | `completeChatTurn` | +| `insertBranchChatMessageTransaction` | `insertBranchChatMessage` | +| `failChatTurnTransaction` | `failChatTurn` | +| `savePersonaVersion` | `updatePersona`, `restorePersonaVersion` | +| `clonePersonaTransaction` | `clonePersona` | +| `importWorkspaceTransaction` | `importWorkspace` | +| (inline) | `saveMessageVersion` | + +--- + +## Funktionsreferenz + +### Benutzer + +| Funktion | Beschreibung | +|---|---| +| `getUser(username)` | Benutzer mit Passwort-Hash und `session_version`, sonst `undefined` | +| `insertUser(username, passwordHash)` | Legt einen Benutzer an | +| `updatePassword(passwordHash, username)` | Setzt einen neuen Hash. **Hash zuerst!** | +| `incrementSessionVersion(username)` | Macht alle Sessions ungültig, gibt den aktualisierten Benutzer zurück | +| `renameUser(currentUsername, nextUsername)` | Ändert den Namen in allen Tabellen (Transaktion) | +| `deleteUser(username)` | Löscht den Benutzer und alle seine Daten (Transaktion) | + +### Chats + +| Funktion | Beschreibung | +|---|---| +| `listChats(username)` | Alle Chats, angepinnte zuerst, dann nach `updated_at` | +| `searchChats(username, query)` | Suche in Titel und Nachrichten (Groß-/Kleinschreibung egal). Enthält `matched_content`. Ein Chat kann **mehrfach** vorkommen (einmal pro Treffer) | +| `countChats(username)` | Anzahl Chats | +| `getChat(chatId, username)` | Ein Chat inkl. Persona-Namen oder `undefined` | +| `getChatByParticipants(username, assistantPersonaId, userPersonaId)` | Hauptchat (keine Abzweigung) für diese Persona-Kombination | +| `createChat(username, title, assistantPersonaId?, userPersonaId?, scenarioPrompt?, scenarioSummary?, organization?)` | Legt einen Chat an und gibt ihn zurück. `organization`: `{ folderName, isPinned, archivedAt }` | +| `updateChatTitle(chatId, username, title)` | | +| `updateChatScene(chatId, username, scenarioPrompt, scenarioSummary)` | | +| `updateChatContextSummary(chatId, username, summary, messageId)` | Speichert die Verlaufszusammenfassung | +| `updateChatOrganization(chatId, username, { folderName, isPinned, archivedAt })` | Ordner, Anpinnen, Archivieren | +| `updateChatGenerationSettings(chatId, username, settings)` | `settings`: `preferredModel`, `temperature`, `contextLength`, `responseLength`, `systemInstruction` | +| `updateChatPreferredModel(chatId, username, model)` | | +| `updateChatActiveLeaf(chatId, username, messageId)` | Wechselt den sichtbaren Zweig | +| `updateChatBranchSource(chatId, username, sourceChatId, messageId)` | Markiert einen Chat als Abzweigung | +| `touchChat(chatId, username)` | Setzt nur `updated_at` (Chat rutscht in der Liste nach oben) | +| `deleteChat(chatId, username)` | Löscht den Chat inkl. Nachrichten, Memories und Chat-State | + +### Memories und Chat-State + +| Funktion | Beschreibung | +|---|---| +| `listChatMemories(chatId, username)` | Neueste zuerst | +| `getChatMemory(chatId, memoryId, username)` | | +| `createChatMemory(username, chat, fact)` | `chat` = Chat-Objekt (für die Persona-IDs). Gibt den neuen Eintrag zurück | +| `updateChatMemory(chatId, memoryId, username, fact)` | | +| `deleteChatMemory(chatId, memoryId, username)` | | +| `getPersonaChatState(chatId, username)` | Rollenspiel-Zustand oder `null` | +| `updatePersonaChatState(username, chat, state)` | Upsert. `state` in snake_case (`relationship_notes`, …) | + +### Nachrichten lesen + +Hintergrund zu Zweigen: [Datenbank-Schema → Nachrichtenbaum](Datenbank-Schema#nachrichtenbaum) + +| Funktion | Beschreibung | +|---|---| +| `listChatMessages(chatId)` | Alle Nachrichten **aller** Zweige, nach ID | +| `listActiveBranchMessages(chatId)` | Nur der sichtbare Zweig, von der Wurzel bis zum aktiven Blatt | +| `listChatBranchThroughMessage(chatId, messageId)` | Zweig von der Wurzel bis `messageId`, nur `complete` | +| `getRecentChatMessages(chatId)` | Letzte `chatHistoryLimit` Nachrichten des aktiven Zweigs ohne `failed`, **neueste zuerst**, nur `id`, `role`, `content`. Grundlage für den Modellkontext | +| `getRecentChatMessagesUpToId(chatId, messageId)` | Wie oben, endet aber bei `messageId` (z. B. beim Regenerieren) | +| `listSiblingChatMessages(chatId, role, parentMessageId)` | Alternativen mit gleichem Parent und gleicher Rolle | +| `getLatestDescendantMessage(chatId, messageId)` | Neuester Nachfahre ohne `failed`, also das Blatt beim Zweigwechsel | +| `getChatMessage(chatId, messageId)` | Eine Nachricht | +| `getChatMessageByIndex(chatId, index)` | n-te Nachricht nach ID, über alle Zweige | +| `getLatestChatMessage(chatId)` | Nachricht mit der höchsten ID, zweigunabhängig | +| `getPreviousUserMessage(chatId, messageId)` | Letzte User-Nachricht mit kleinerer ID, zweigunabhängig | +| `listMessageVersions(messageId)` | Alle Einträge aus `chat_message_versions` | + +### Nachrichten schreiben + +`retryState` ist ein Objekt `{ retryVariants, retryActiveIndex, retryRetriesUsed, retryPromptMessageId }`. + +| Funktion | Beschreibung | +|---|---| +| `insertChatMessage(chatId, role, content, retryState?, modelName?, deliveryStatus?, errorMessage?, parentMessageId?)` | Fügt eine Nachricht ein, ändert **nicht** das aktive Blatt | +| `insertBranchChatMessage(...)` | Gleiche Parameter. Fügt ein **und** setzt das aktive Blatt (Transaktion) | +| `completeChatTurn(chatId, userMessageId, assistantContent, retryState, modelName?, sources?, thinking?)` | Speichert die Bot-Antwort als Kind der User-Nachricht, markiert die User-Nachricht als `complete`, speichert Quellen und Thinking, setzt das aktive Blatt (Transaktion) | +| `failChatTurn(chatId, userMessageId, errorMessage, parentMessageId?)` | Markiert die User-Nachricht als `failed` und setzt das aktive Blatt zurück auf den Parent (Transaktion) | +| `updateChatMessageDelivery(chatId, messageId, deliveryStatus, errorMessage?)` | | +| `updateChatMessage(chatId, messageId, content)` | Nur den Text ändern | +| `updateChatMessageRetryState(chatId, messageId, payload)` | `payload`: `content`, `retryVariants`, `retryActiveIndex`, `retryRetriesUsed`, `retryPromptMessageId` | +| `updateChatMessageThinking(chatId, messageId, thinking)` | | +| `updateChatMessageSources(chatId, messageId, sources)` | `sources` wird als JSON gespeichert | +| `saveMessageVersion(chatId, messageId, version?, selectedIndex?)` | Mit `version`: neue Fassung anhängen und anzeigen. Ohne: Fassung `selectedIndex` anzeigen. Überträgt die Fassung nach `chat_messages` und baut `retry_variants` neu auf. Wirft `"Message not found"` bzw. `"Version is out of range"` | +| `deleteChatMessage(chatId, messageId)` | Kind-Nachrichten bleiben erhalten, ihr Parent wird `NULL` | +| `clearChatMessages(chatId)` | Löscht alle Nachrichten des Chats | + +### Personas + +| Funktion | Beschreibung | +|---|---| +| `listPersonasByType(username, personaType)` | `personaType`: `assistant` oder `user` | +| `listAllPersonas(username)` | | +| `getPersona(personaId, username)` | | +| `createPersona(username, payload, personaType)` | Legt an, speichert Version 1, gibt die Persona zurück | +| `updatePersona(personaId, username, payload)` | Speichert den **alten** Stand als Version, dann Update | +| `listPersonaVersions(personaId, username)` | Neueste zuerst, mit geparstem `snapshot` | +| `restorePersonaVersion(personaId, versionId, username)` | Sichert den aktuellen Stand, stellt die Version wieder her. `null`, wenn nicht gefunden | +| `clonePersona(personaId, username)` | Kopie mit Namenszusatz „ Copy“ (Transaktion) | +| `deletePersona(personaId, username)` | Vorher `clearPersonaReferences` aufrufen | +| `clearPersonaReferences(username, personaId)` | Entfernt die Persona aus Chats und löscht ihren Marktplatz-Eintrag | +| `getActivePersonaIds(username)` | `{ active_persona_id, active_user_persona_id }` oder `{}` | +| `setActiveUserPersona(username, personaId)` | Upsert in `user_settings` | +| `getActiveUserPersona(username)` | | +| `getAssistantPersonaForChat(chatId, username)` | | +| `getUserPersonaForChat(chatId, username)` | | + +### Marktplatz + +| Funktion | Beschreibung | +|---|---| +| `listMarketPersonas({ admin, username, personaType, sort })` | Liste mit `is_favorite` (für `username`) und geparsten `tags`. Ohne `admin` werden gelöschte und abgelehnte Einträge ausgeblendet. `sort`: `best`, `newest`, `most_favorited`, `most_popular`, `top_rated`, `alphabetical` (Bindestriche erlaubt, unbekannt → `best`) | +| `getMarketPersona(marketId)` | Mit `rating_average` und geparsten `tags`, sonst `null` | +| `getMarketPersonaByPersonaId(personaId, username)` | Marktplatz-Eintrag zu einer eigenen Persona | +| `upsertMarketPersona(personaId, username, persona, tags?)` | Veröffentlichen oder aktualisieren. Setzt Moderation auf `approved` zurück und hebt Soft Delete auf | +| `deletePublishedPersona(personaId, username)` | Entfernt den Eintrag endgültig | +| `listPublishedPersonaIds(username)` | Array der veröffentlichten Persona-IDs | +| `collectMarketPersona(username, marketPersona)` | Kopiert einen Eintrag in die eigenen Personas (mit `source_market_id`), speichert Version 1 | +| `getPersonaBySourceMarket(username, marketId)` | Prüft, ob bereits gesammelt | +| `incrementMarketUsageCount(marketId)` | | +| `toggleMarketFavorite(marketId, username)` | Gibt den **neuen** Zustand zurück (`true` = jetzt Favorit) | +| `rateMarketPersona(marketId, username, rating)` | Upsert, zählt neu, gibt den Eintrag zurück | +| `reportMarketPersona(marketId, username, reason, details)` | | +| `listMarketReports()` | Offene Meldungen zuerst | +| `updateMarketReportStatus(reportId, status)` | | +| `updateMarketModeration(marketId, { status, notes?, softDelete? })` | Für Admins | + +### Prompt-Vorlagen + +| Funktion | Beschreibung | +|---|---| +| `listPromptTemplates(username)` | Eigene, geteilte und System-Vorlagen | +| `listPromptTemplatesForPersona(username, personaId?)` | Vorlagen für diese Persona plus allgemeine | +| `getPromptTemplate(templateId)` | **Ohne Benutzerprüfung** | +| `createPromptTemplate(username, payload)` | `payload` in camelCase (`promptText`, `starterText`, `isShared`, `personaId`) | +| `updatePromptTemplate(templateId, username, payload)` | Nur eigene. System-Vorlagen nur als Benutzer `system` | +| `deletePromptTemplate(templateId, username)` | Nur eigene | + +### Dashboard, Export und Import + +| Funktion | Beschreibung | +|---|---| +| `getDashboardSummary(username)` | Statistiken: Anzahl Chats, Nachrichten, Personas, veröffentlichte Personas, meistgenutzte Personas, durchschnittliche Chatlänge, Lieblingsmodell, letzte Aktivität | +| `exportWorkspace(username)` | Alle Chats (mit Nachrichten, Versionen, Anhang-Metadaten, Memories, Chat-State), Personas und Vorlagen als Objekt. Bilddateien sind **nicht** enthalten | +| `importWorkspace(username, workspace)` | Importiert in einer Transaktion. Gibt `{ personas, chats, templates }` (Anzahl) zurück | + +**Import-Details:** + +- Nachrichten-IDs werden neu vergeben. Die Zuordnung alt → neu läuft über `message.sourceId`, + damit Parents, Retry-Verweise und das aktive Blatt stimmen. +- Anhänge werden nur verknüpft, wenn das Bild noch existiert und dem Benutzer gehört. +- Importierte Chats und Memories verlieren ihre Persona-Verknüpfung (`NULL`). +- Der Export liefert Felder in snake_case (`id`, `parent_message_id`), der Import erwartet + teils camelCase (`sourceId`, `parentMessageId`). Die Umwandlung passiert vermutlich in + `workspaceImportService.js`. Bei Änderungen beide Seiten prüfen. + +--- + +## Hilfsfunktionen + +| Name | Aufgabe | +|---|---| +| `parseJsonArray(value)` | Parst JSON sicher. Gibt bei `null`, ungültigem JSON oder Nicht-Arrays `[]` zurück | +| `buildPersonaSnapshot(persona)` | Erzeugt das JSON für `persona_versions.snapshot_json` | +| `MARKET_SORT_ORDERS` | Erlaubte Sortierungen für den Marktplatz (Whitelist, wird direkt ins SQL eingesetzt) | +| `normalizeMarketSort(sort)` | Bereinigt die Sortierung, unbekannt → `best` | +| `normalizeMarketPersonaType(type)` | Nur `assistant` oder `user`, sonst `null` | + +--- + +## Bekannte Auffälligkeiten + +| Punkt | Details | +|---|---| +| Doppelter SELECT | `listChatSessions`, `getChatSession`, `getChatSessionByParticipants` und `searchChats` wiederholen denselben Spaltenblock | +| `db.prepare` außerhalb von `statements` | In `listMessageVersions`, `saveMessageVersion`, `exportWorkspace` und in den Import-Schleifen wird bei jedem Aufruf neu vorbereitet | +| Ungenutztes Statement | `listMarketPersonasAdmin` wird nirgends verwendet | +| Dashboard zählt teuer | `marketPersonas` lädt alle Marktplatz-Einträge, nur um sie zu zählen | +| Moderation beim erneuten Veröffentlichen | `upsertMarketPersona` setzt den Status auf `approved`. Eine abgelehnte Persona wird durch erneutes Veröffentlichen wieder sichtbar. Prüfen, ob das gewollt ist | + +Offene Aufgaben für Aufräumen und TypeScript-Migration stehen als TODO-Kommentar oben in `repositories.js`. \ No newline at end of file