Datenbank
Die App speichert alle Daten in einer SQLite-Datenbank und greift über die Bibliothek
better-sqlite3 darauf zu.
| Seite | Inhalt |
|---|---|
| Datenbank (diese Seite) | Überblick, Dateien, Startablauf, Grundlagen, Notfall-Hilfe |
| Datenbank-Schema | Alle Tabellen, Beziehungen, Lösch-Verhalten, Nachrichtenbaum, Migrationen |
| Datenbank-Repositories | Alle Funktionen aus repositories.js zum Nachschlagen |
Siehe auch: TypeScript
Die Dateien auf einen Blick
| Datei | Sprache | Exportiert | Aufgabe |
|---|---|---|---|
src/db/index.ts |
TypeScript | createDatabase |
Öffnet die Datenbank und baut das Schema auf |
src/db/schema.ts |
TypeScript | initializeSchema, runMigrations, MigrationOptions |
Grundtabellen anlegen, ältere Datenbanken aktualisieren |
src/db/attachments.ts |
TypeScript | initializeAttachments, attachmentMetadata |
Tabellen und Trigger für Bild-Anhänge, Umwandlung für die API |
src/db/repositories.js |
JavaScript | createRepositories |
Alle Lese- und Schreibzugriffe der App (ca. 90 Funktionen) |
Wo die Datenbank in der App steht
Die App ist in Schichten aufgebaut. Jede Schicht spricht nur mit der darunter.
SQL steht im laufenden Betrieb ausschließlich in repositories.js.
flowchart TD
Browser["Browser"] --> Routes["routes/<br/>HTTP-Anfragen, Eingabeprüfung"]
Routes --> Services["services/<br/>Geschäftslogik"]
Services --> Repos["db/repositories.js<br/>SQL-Abfragen"]
Repos --> DB[("SQLite<br/>app.db")]
Start["App-Start"] -.-> Create["db/index.ts<br/>createDatabase()"]
Create -.-> DB
Startablauf
flowchart TD
A["createDatabase(config)"] --> B{"dbPath ist :memory: ?"}
B -- nein --> C["Ordner für die DB-Datei anlegen"]
B -- ja --> D
C --> D["new Database(dbPath)"]
D --> E["initializeSchema(db)<br/>PRAGMAs + 13 Grundtabellen"]
E --> F["runMigrations(db, config)<br/>fehlende Spalten, Altdaten"]
F --> G["initializeAttachments(db)<br/>Anhang-Tabellen + Trigger"]
G --> H["Verbindung zurückgeben"]
H --> I["createRepositories(db, config)"]
Die Reihenfolge ist wichtig:
runMigrationserwartet, dass die Grundtabellen bereits existieren.initializeAttachmentsverweist per Fremdschlüssel aufusersundchat_messages, diese müssen also vorher angelegt sein.
Die Dateien im Detail
src/db/index.ts
export function createDatabase(config: Config): Database.Database
Einstiegspunkt beim App-Start. Verwendet aus der Config:
| Config-Feld | Verwendung |
|---|---|
dbPath |
Pfad zur Datenbankdatei. ":memory:" = Datenbank nur im Arbeitsspeicher (für Tests, weg nach dem Schließen) |
dropLegacyChats |
Wird an runMigrations weitergereicht |
Der Ordner wird mit path.dirname(config.dbPath) angelegt, funktioniert also auch mit
einem eigenen DB_PATH außerhalb von data/.
runMigrations(db, config) bekommt die ganze Config, liest aber nur dropLegacyChats
heraus. TypeScript erlaubt das, weil Config alle Felder von MigrationOptions enthält
(strukturelle Typisierung).
src/db/schema.ts
| Funktion | Aufgabe |
|---|---|
initializeSchema(db) |
Setzt die PRAGMAs und legt alle Grundtabellen mit CREATE TABLE IF NOT EXISTS an. Bestehende Tabellen werden nicht verändert |
runMigrations(db, options) |
Ergänzt fehlende Spalten und Tabellen in älteren Datenbanken, übernimmt Altdaten. Details: Datenbank-Schema → Migrationen |
Enthaltene Typen:
ColumnInfo: eine Zeile ausPRAGMA table_info(...)(intern)MigrationOptions:{ dropLegacyChats?: boolean }(exportiert)
src/db/attachments.ts
| Funktion | Aufgabe |
|---|---|
initializeAttachments(db) |
Legt attachments, message_attachments, attachment_vision_analyses, upload_deletions, einen Index und zwei Trigger an |
attachmentMetadata(row) |
Wandelt eine DB-Zeile (snake_case) in das API-Format (camelCase) um. Gibt nur öffentliche Felder zurück, nie storage_key oder username |
// Eingabe: Zeile aus der Datenbank
interface AttachmentRow {
id: string; original_name: string; mime_type: string;
size_bytes: number; width: number; height: number;
}
// Ausgabe: an das Frontend
interface AttachmentMetadata {
id: string; type: "image"; originalName: string; mimeType: string;
sizeBytes: number; width: number; height: number;
}
AttachmentRow enthält nur die Spalten, die attachmentMetadata braucht. Eine vollständige
Zeile aus SELECT * passt trotzdem, weil TypeScript zusätzliche Felder erlaubt.
src/db/repositories.js
Die Datenzugriffsschicht (Repository-Pattern). createRepositories(db, config) gibt ein
Objekt mit allen Funktionen zurück, die der Rest der App benutzt, z. B.
repositories.listChats(username).
Vollständige Referenz: Datenbank-Repositories
Konventionen
| Thema | Konvention |
|---|---|
| Benutzer-Identität | username (TEXT) ist Primärschlüssel und wird in vielen Tabellen als Verweis gespeichert |
| Booleans | INTEGER mit 0 = false, 1 = true (z. B. is_pinned, is_shared) |
| JSON | Als TEXT gespeichert, im Code mit JSON.parse / JSON.stringify (z. B. tags, sources, retry_variants) |
| Zeitstempel | DEFAULT CURRENT_TIMESTAMP, in UTC |
| Namen | Datenbank: snake_case. JavaScript/API: meist camelCase |
| Soft Delete | Marktplatz-Einträge werden über soft_deleted_at ausgeblendet statt gelöscht |
PRAGMA-Einstellungen
PRAGMA ist ein SQLite-spezifischer Befehl, um Einstellungen der Datenbank selbst zu lesen oder zu setzen.
| PRAGMA | Wo | Bedeutung |
|---|---|---|
journal_mode = WAL |
initializeSchema |
Lesen bleibt während Schreibvorgängen möglich. Wird in der Datei gespeichert, es entstehen zusätzlich app.db-wal und app.db-shm |
foreign_keys = ON |
initializeSchema |
Ohne diese Einstellung ignoriert SQLite alle Fremdschlüssel und ON DELETE CASCADE. Gilt nur pro Verbindung |
table_info(tabelle) |
runMigrations |
Liefert die Spalten einer Tabelle, damit fehlende ergänzt werden können |
better-sqlite3 in Kürze
better-sqlite3 arbeitet synchron: Es gibt kein await, Abfragen blockieren kurz den Event-Loop.
| Methode | Rückgabe | Verwendung |
|---|---|---|
db.prepare(sql) |
Statement | Abfrage einmal vorbereiten, danach beliebig oft ausführen. ? sind Platzhalter (Schutz vor SQL-Injection) |
stmt.get(...params) |
Objekt oder undefined |
Eine Zeile lesen |
stmt.all(...params) |
Array | Alle Zeilen lesen |
stmt.run(...params) |
{ changes, lastInsertRowid } |
INSERT, UPDATE, DELETE |
db.exec(sql) |
– | Mehrere SQL-Befehle ohne Parameter ausführen (Schema) |
db.transaction(fn) |
Funktion | Alle Befehle in fn gelingen gemeinsam oder werden gemeinsam zurückgerollt |
db.pragma(sql) |
abhängig | PRAGMA ausführen |
In TypeScript liefern .get() und .all() standardmäßig unknown. Den Ergebnistyp gibt man per Generic an:
db.prepare<[string], { count: number }>("SELECT COUNT(*) AS count FROM personas WHERE username = ?")
// ^^^^^^^^ ^^^^^^^^^^^^^^^^^
// Parameter eine Ergebniszeile
Der Typ der Verbindung heißt Database.Database (nicht nur Database, das ist der Konstruktor).
Notfall-Hilfe
Wo liegt die Datenbank?
Standard: <Projektordner>/data/app.db. Änderbar über die Umgebungsvariable DB_PATH.
Im WAL-Modus gehören die Dateien app.db-wal und app.db-shm dazu.
Datenbank ansehen
Mit dem SQLite-Kommandozeilenprogramm:
sqlite3 data/app.db
.tables -- alle Tabellen
.schema chat_sessions -- Aufbau einer Tabelle
PRAGMA table_info(personas); -- Spalten einer Tabelle
PRAGMA foreign_key_check; -- verwaiste Verweise finden
SELECT * FROM users;
.quit
Alternativ grafisch: DB Browser for SQLite.
Backup
Vor jeder Änderung an Schema oder Migrationen ein Backup machen.
sqlite3 data/app.db ".backup data/app-backup.db"
Nicht einfach nur app.db kopieren, solange die App läuft: Im WAL-Modus können
Änderungen noch in app.db-wal stehen. .backup berücksichtigt das.
Typische Fehler
| Fehlermeldung | Ursache | Lösung |
|---|---|---|
SqliteError: no such column: xyz |
Spalte fehlt in einer älteren Datenbank | ensureColumn in runMigrations ergänzen |
SqliteError: no such table: xyz |
Tabelle wird nirgends angelegt oder Startreihenfolge falsch | initializeSchema prüfen, Reihenfolge in createDatabase prüfen |
SqliteError: FOREIGN KEY constraint failed |
Verweis auf eine Zeile, die nicht existiert (z. B. Chat-ID eines gelöschten Chats) | IDs prüfen, PRAGMA foreign_key_check; ausführen |
SqliteError: UNIQUE constraint failed |
Doppelter Eintrag (z. B. Benutzername existiert schon) | Vorher prüfen oder Fehler abfangen |
SqliteError: database is locked |
Ein anderer Prozess schreibt gerade (z. B. offenes DB-Programm) | Andere Programme schließen, lange Transaktionen vermeiden |
attempt to write a readonly database / unable to open database file |
Keine Schreibrechte auf Datei oder Ordner (oft Docker-Volume) | Rechte des data/-Ordners prüfen |
Checkliste: Neue Spalte hinzufügen
- Spalte im
CREATE TABLEininitializeSchemaergänzen (für neue Datenbanken). ensureColumn(...)inrunMigrationsergänzen (für bestehende Datenbanken).- SELECT-Abfragen in
repositories.jsergänzen. Beichat_sessionsan vier Stellen:listChatSessions,getChatSession,getChatSessionByParticipants,searchChats. - Falls die Spalte einen Benutzernamen enthält:
renameUserTransactionunddeleteUserTransactionergänzen. - Wiki-Seite Datenbank-Schema aktualisieren.
Checkliste: Neue Tabelle hinzufügen
CREATE TABLE IF NOT EXISTSininitializeSchema(bzw.initializeAttachmentsfür Anhänge).- Fremdschlüssel mit passendem
ON DELETEfestlegen. - Hat die Tabelle eine
username-Spalte ohne Fremdschlüssel aufusers: inrenameUserTransactionunddeleteUserTransactioneintragen. - Statements und Funktionen in
repositories.jsergänzen. - Wiki aktualisieren (Tabelle, Diagramm, Lösch-Verhalten).