Zum Inhalt springen

11. Persistenz

Zu Zen-Modus wechseln

Die Render-Farm speichert ihre Jobs bisher in einer JSON-Datei; das war ein ehrlicher Zwischenstand, und für kleine Werkzeuge ist es sogar eine dauerhaft vertretbare Lösung. Aber die Datei hat strukturelle Grenzen, und wer sie kennt, weiß, wann eine Datenbank fällig ist. Dieses Kapitel zieht die Datenzugriffsschicht auf SQLite um, ohne dass Service oder Oberfläche eine Zeile ändern (der versprochene Beweis des Architekturkapitels), und behandelt den Datenaustausch zwischen Anwendungen über strukturierte Formate, wie ihn der Lehrplan verlangt.

Das SQL-Handwerk selbst (SELECT, INSERT, Joins, Schema-Entwurf) ist Stoff des Gegenstands INSY und wird hier vorausgesetzt; dieses Kapitel behandelt die Anbindung: Wie kommt eine Datenbank in ein TypeScript-Projekt, und wo wohnt sie in der Architektur?

Drei Grenzen der JSON-Datei, jede ein realer Fehlermodus:

  1. Gleichzeitige Zugriffe. Zwei Anfragen treffen fast zeitgleich ein; beide lesen die Datei, beide schreiben sie zurück, und die zweite überschreibt die Änderung der ersten. Ein klassisches Lost Update, dieselbe Fehlerfamilie wie die Race Conditions aus dem Nebenläufigkeitskapitel, nur auf der Datenebene.
  2. Suchen und Filtern. “Alle fehlgeschlagenen Jobs der letzten Woche” heißt bei der Datei: alles laden, alles durchgehen. Bei tausend Einträgen unelegant, bei einer Million unbenutzbar. Datenbanken beantworten solche Fragen über Indizes, ohne alles zu lesen.
  3. Teilweise Schreibvorgänge. Stürzt der Prozess mitten im Schreiben ab, hinterlässt er eine halbe, kaputte Datei; alle Daten sind verloren. Datenbanken garantieren, dass Änderungen ganz oder gar nicht passieren.

SQLite ist die Datenbank für genau diese Lücke: eine vollwertige SQL-Datenbank ohne eigenen Server, gespeichert in einer einzigen Datei im Projekt. Kein Dienst, keine Zugangsdaten, keine Installation für Mitschüler, die das Repository klonen; für Schulprojekte und für erstaunlich viel Produktivsoftware ist SQLite die richtige Wahl. Als Treiber verwenden wir better-sqlite3, dessen API angenehm direkt ist:

Terminal-Fenster
npm install better-sqlite3
npm install --save-dev @types/better-sqlite3

Das Repository, dritte Implementierung des bekannten Interfaces:

lib/repository/sqlite-job-repository.ts
import Database from "better-sqlite3";
import type { JobRepository } from "./job-repository";
import type { Job } from "@/lib/types";
export class SqliteJobRepository implements JobRepository {
private db: Database.Database;
constructor(path: string) {
this.db = new Database(path);
this.db.exec(`
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
preset TEXT NOT NULL,
state TEXT NOT NULL,
progress REAL NOT NULL,
created_at TEXT NOT NULL
)
`);
}
async findAll(): Promise<Job[]> {
const rows = this.db.prepare("SELECT * FROM jobs ORDER BY id").all();
return (rows as JobRow[]).map(toJob);
}
async findById(id: number): Promise<Job | null> {
const row = this.db.prepare("SELECT * FROM jobs WHERE id = ?").get(id);
return row ? toJob(row as JobRow) : null;
}
async insert(job: Omit<Job, "id">): Promise<Job> {
const result = this.db.prepare(`
INSERT INTO jobs (filename, preset, state, progress, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(job.filename, job.preset, job.state, job.progress, job.createdAt);
return { ...job, id: Number(result.lastInsertRowid) };
}
async delete(id: number): Promise<boolean> {
return this.db.prepare("DELETE FROM jobs WHERE id = ?").run(id).changes > 0;
}
}
interface JobRow {
id: number; filename: string; preset: string;
state: Job["state"]; progress: number; created_at: string;
}
function toJob(row: JobRow): Job {
return {
id: row.id, filename: row.filename, preset: row.preset,
state: row.state, progress: row.progress, createdAt: row.created_at,
};
}

Vier Handwerkspunkte daran:

  • CREATE TABLE IF NOT EXISTS im Konstruktor macht das Repository selbstinitialisierend: Erster Start legt das Schema an, jeder weitere findet es vor.
  • Platzhalter (?) sind Pflicht. Werte werden nie in den SQL-Text hineinformatiert (`... WHERE filename = '${name}'` ist verboten): Erstens zerbricht es an einem Apostroph im Dateinamen, zweitens ist es das Einfallstor für SQL-Injection, bei der ein bösartiger Eingabewert zum SQL-Befehl wird. Prepared Statements mit Platzhaltern erledigen beides.
  • Die Zeile ist nicht das Objekt. Die Datenbank spricht snake_case und kennt kein createdAt; die kleine Abbildungsfunktion toJob übersetzt zwischen Tabellenzeile und Domänenobjekt. Diese Übersetzung gehört ins Repository und nirgendwohin sonst; oberhalb davon existiert nur Job.
  • Die Datenbankdatei (data/render-farm.db) gehört wie jede erzeugte Datei in die .gitignore; ins Repository gehört das Schema (der Code, der es anlegt), nicht der Datenbestand.

Der Umstieg selbst ist die bekannte eine Zeile in instances.ts, und alles oberhalb läuft unverändert; führen Sie den Beweis wirklich vor: Anwendung starten, Jobs anlegen, Dev-Server neu starten, die Jobs sind noch da.

Mit echter Persistenz werden die Randfälle verbindlich, und die Schichten sortieren, wer sie behandelt:

  • Nicht gefunden: Das Repository liefert ehrlich null; die Deutung (“404” oder “Fehler im Ablauf”) ist Sache von Service und Präsentation.
  • Duplikate: Fachliche Eindeutigkeit erzwingt die Datenbank per UNIQUE-Constraint; der Verstoß kommt als Treiber-Fehler an und wird im Repository in einen fachlichen Fehler übersetzt (Konfliktfall, an der API 409), nicht als roher SQL-Fehlertext durchgereicht.
  • Zusammengehörige Änderungen: Sollen mehrere Schreibvorgänge nur gemeinsam gelten (Job anlegen und Zähler erhöhen), fasst eine Transaktion sie zusammen: ganz oder gar nicht. better-sqlite3 bietet dafür db.transaction(...); das Konzept kennt jede Datenbank, die Tiefe gehört INSY. Für dieses Jahr genügt: erkennen, wann eine Transaktion nötig ist.

Der Lehrplan verlangt den Datenaustausch zwischen Anwendungen über strukturierte Formate; die Datenbank allein leistet das nicht, denn sie ist das private Innenleben einer Anwendung. Ausgetauscht wird über die Grenzformate, und beide sind vorhanden:

  • Die API ist der Live-Austausch: Ein fremdes Programm liest GET /api/jobs und bekommt den dokumentierten JSON-Vertrag, egal ob dahinter Datei oder SQLite liegt. Die Persistenzschicht ist durch die Architektur unsichtbar; genau deshalb durfte sie wechseln, ohne Konsumenten zu brechen.
  • Export und Import sind der Austausch als Datei: ein Endpunkt oder Kommando, das den Datenbestand als JSON-Dokument liefert (GET /api/jobs/export), und ein Import, der ein solches Dokument einliest, validiert und übernimmt. Das Format ist wieder ein Vertrag mit Versionsfeld und Beispieldokument; der Import behandelt die drei bekannten Fehlerklassen jeder Datengrenze (fehlt, syntaktisch kaputt, inhaltlich falsch).

Die Nagelprobe dazu ist Übung 19: Zwei Projekte tauschen ihre Exportdateien und importieren sie gegenseitig. Alles, was dabei knirscht (undokumentierte Felder, andere Datumsformate, abweichende Zustandsnamen), ist eine Vertragslücke, gefunden zum billigsten möglichen Zeitpunkt.

Lernergebnisse: Was Sie nach diesem Kapitel können sollten

Abschnitt betitelt „Lernergebnisse: Was Sie nach diesem Kapitel können sollten“

Nach Abschluss dieses Kapitels sollten Schülerinnen und Schüler in der Lage sein:

  • Erklären: die Grenzen der Datei-Persistenz (gleichzeitige Zugriffe, Suchen, Teilschreiben) und den Einsatzbereich von SQLite erklären.
  • Anwenden: SQLite mit better-sqlite3 in ein Next.js-Projekt einbinden, Schema selbstinitialisierend anlegen und ein Repository gegen das bestehende Interface implementieren.
  • Anwenden: ausschließlich Prepared Statements mit Platzhaltern verwenden und die SQL-Injection-Gefahr erklären.
  • Anwenden: zwischen Tabellenzeilen und Domänenobjekten im Repository übersetzen.
  • Erklären: Randfälle (nicht gefunden, Duplikate, zusammengehörige Änderungen) den zuständigen Schichten zuordnen und den Transaktionsgedanken erläutern.
  • Entwerfen: Export- und Importformate als Verträge definieren und Daten zwischen zwei Anwendungen austauschen.
  • Aufgabe 19 - Vom File zur Datenbank