SQLite-Journal
SqliteJournal speichert Events in einer SQLite-Datenbank — eine
einzelne Datei auf der Disk, durable, kein separater Server. Es
ist der richtige Default für Single-Node-Produktion: auf Bun
ist der Treiber eingebaut (bun:sqlite, keine Installation), auf
Node ist es eine einzelne Peer-Dep (better-sqlite3). Überlebt
Neustarts, schnell genug für die meisten Workloads.
import { SqliteJournal, SqliteJournalOptions, SqliteSnapshotStore, SqliteSnapshotStoreOptions, PersistenceExtensionId, ActorSystem,} from 'actor-ts';
const system = ActorSystem.create('my-app');const sqliteJournalOptions = SqliteJournalOptions.create() .withPath('/var/lib/my-app/events.db') .withWal(true);const sqliteSnapshotStoreOptions = SqliteSnapshotStoreOptions.create().withPath('/var/lib/my-app/snapshots.db');const persistence = system.extension(PersistenceExtensionId);persistence.setJournal(new SqliteJournal(sqliteJournalOptions));persistence.setSnapshotStore(new SqliteSnapshotStore(sqliteSnapshotStoreOptions));Eine einzelne Datei pro System; das Actor-System schreibt durch SQLite in den OS-Page-Cache, der beim Commit auf Disk flusht.
Konfiguration
Abschnitt betitelt „Konfiguration“type SqliteJournalOptions = { path?: string; // Dateipfad, oder ":memory:" für ephemer eventsTable?: string; // Default "events" wal?: boolean; // WAL-Modus aktivieren (empfohlen) busyTimeoutMs?: number; // Lock-Wartebudget, Default 1000; 0 = sofort scheitern driver?: SqliteDriver; // expliziter Treiber-Override};const sqliteJournalOptions = SqliteJournalOptions.create().withPath('/var/lib/my-app/events.db');new SqliteJournal(sqliteJournalOptions)Die Datenbank-Datei. Absolute Pfade sind am besten — relative
Pfade werden ab process.cwd() aufgelöst, was dich überraschen
kann. Die Datei wird erstellt, wenn sie nicht existiert;
bestehende Dateien werden wiederverwendet (Events appenden in
place).
Für Tests verwende ':memory:' — eine SQLite-gestützte
In-Memory-DB, die sich genau wie die Datei-Version verhält, aber
mit dem Prozess verschwindet:
const sqliteJournalOptions = SqliteJournalOptions.create().withPath(':memory:');new SqliteJournal(sqliteJournalOptions)eventsTable
Abschnitt betitelt „eventsTable“Default 'events'. Überschreibe das, wenn du willst, dass mehrere
Systeme sich eine DB-Datei teilen (z. B. Dev-Rig):
const sqliteJournalOptions = SqliteJournalOptions.create() .withPath('shared.db') .withEventsTable('orders_events');new SqliteJournal(sqliteJournalOptions)Das Framework erstellt die Tabelle automatisch bei der ersten Verwendung, mit dem Schema:
CREATE TABLE events ( persistence_id TEXT NOT NULL, sequence_nr INTEGER NOT NULL, payload TEXT NOT NULL, tags TEXT, -- legacy CSV-Tags (Rückwärtskompatibilität) timestamp INTEGER NOT NULL, PRIMARY KEY (persistence_id, sequence_nr));
CREATE INDEX idx_events_pid ON events (persistence_id);
CREATE TABLE events_tags ( persistence_id TEXT NOT NULL, sequence_nr INTEGER NOT NULL, tag TEXT NOT NULL, timestamp INTEGER NOT NULL, PRIMARY KEY (tag, timestamp, persistence_id, sequence_nr));
CREATE INDEX idx_events_tags_pid_seq ON events_tags (persistence_id, sequence_nr);Das Dual-Table-Design (events + events_tags) lässt Tag-Queries einen Index treffen, statt CSV zu scannen.
const sqliteJournalOptions = SqliteJournalOptions.create() .withPath('...') .withWal(true);new SqliteJournal(sqliteJournalOptions)Aktiviert den Write-Ahead-Logging-Modus. Empfohlen für Produktion. WAL gibt dir:
- Bessere Concurrency — Reader blockieren den Writer nicht.
- Schnellere Commits — WAL-Writes sind sequenziell, dann wird der Checkpoint gebatched.
- Sicherere Crashes — Recovery ist einfacher als im Rollback-Journal-Modus.
Der Default ist aus, um den Defaults von SQLite zu entsprechen; aktiviere es explizit, wenn du in Produktion gehst.
busyTimeoutMs
Abschnitt betitelt „busyTimeoutMs“const sqliteJournalOptions = SqliteJournalOptions.create() .withPath('...') .withBusyTimeoutMs(1000);new SqliteJournal(sqliteJournalOptions)Wie lange ein Write auf das Datenbank-Lock wartet, bevor er mit
SQLITE_BUSY aufgibt. Default 1000 ms. 0 schaltet das Warten
ab — ein Write unter Contention scheitert beim ersten Versuch.
Diesen Wert setzt das Framework bewusst selbst, weil die Treiber sich
nicht einig sind. Ihre eingebauten Defaults sind 0 ms bei bun:sqlite
und node:sqlite, aber 5000 ms bei better-sqlite3 — ohne expliziten
Wert würde dasselbe Journal auf Bun und Deno sofort scheitern und auf
Node fünf Sekunden blockieren. Das Framework prägt jeder Verbindung,
die es öffnet, seinen eigenen Wert auf; genau das lässt die drei
Runtimes gleich verhalten.
Wähle einen eigenen Wert mit einer Sache im Kopf: die SQLite-Treiber
sind synchron, das Warten ist also keine Leerlaufzeit — es
blockiert die Event-Loop, und solange es dauert, läuft nichts anderes
im Prozess, Cluster-Heartbeats eingeschlossen. Deshalb liegt der
Default bei 1000 ms statt bei den 5000 ms von better-sqlite3: der
Cluster-Failure-Detector erklärt einen Node nach 2000 ms für
unerreichbar, ein Fünf-Sekunden-Stall wäre also lang genug, damit die
eigenen Peers einen Node aufgeben. Erhöhe ihn nur, wenn du weißt,
dass deine Contention kurzlebig ist und dein Node nicht im Cluster
steht.
Ein negativer Wert wird abgelehnt — SQLite liest ihn als „ewig weiterversuchen“, was auf einem synchronen Treiber ein unbegrenztes Einfrieren bedeutet.
Das Framework erkennt den richtigen Treiber basierend auf der
Runtime automatisch, sodass du driver normalerweise ungesetzt
lässt:
- Bun →
bun:sqlite(eingebaut). - Node →
better-sqlite3, wenn installiert, sonst das eingebautenode:sqlite. - Deno →
node:sqlite(eingebaut, Deno >= 2.2).
Die Auto-Detection deckt jede unterstützte Runtime ab, und jede von
ihnen hat einen Treiber, der keine Installation braucht. Die
konkreten Treiber-Klassen sind nicht Teil der öffentlichen API — es
gibt keinen Import-Pfad, um selbst einen zu konstruieren — daher ist
der driver-Slot eine interne Naht, die die Tests des Frameworks
verwenden, nichts, was du in Anwendungscode verdrahtest.
Peer-Dependency auf Node — optional
Abschnitt betitelt „Peer-Dependency auf Node — optional“npm install better-sqlite3Auf Node ist better-sqlite3 eine optionale Peer-Dependency:
installiert bevorzugt das Framework es, ohne es übernimmt das
eingebaute node:sqlite. Es lohnt sich, wenn du den zusätzlichen
Durchsatz willst oder dein Deployment es schon einsetzt; andernfalls
ist der Zero-Dependency-Pfad ein nativer Build weniger im Image.
Bun und Deno brauchen nichts: bun:sqlite und node:sqlite sind
eingebaut.
Für eine entfernte SQLite-Datenbank siehe libSQL / Turso — gleiches Schema, über HTTP erreichbar.
Durable State
Abschnitt betitelt „Durable State“Neben Journal und Snapshot-Store gibt es für SQLite einen
DurableStateStore — das Modell „nur den aktuellen Wert halten”, ohne
Event-Log und ohne Replay:
import { SqliteDurableStateStore, SqliteDurableStateStoreOptions } from 'actor-ts';
const stateOptions = SqliteDurableStateStoreOptions.create() .withPath('./state.db');const store = new SqliteDurableStateStore(stateOptions);Um ein Datenbank-Handle über Journal, Snapshot-Store und Durable-State-Store zu teilen, öffne es selbst und übergib es — der Store überlässt das Schließen dann dir:
import { getSqliteDriver } from 'actor-ts';
const driver = await getSqliteDriver();const database = driver.open('./app.db');
const sharedOptions = SqliteDurableStateStoreOptions.create() .withDatabase(database);Ein Handle, das du selbst geöffnet hast, lässt der Store unangetastet
— busyTimeoutMs eingeschlossen: der Store wendet es nur auf
Verbindungen an, die er selbst öffnet, denn das Pragma gilt pro
Verbindung, und ein geteiltes Handle nachzujustieren würde in jeden
anderen Store darauf hineingreifen. Setze es auf einem eigenen Handle
selbst:
database.exec('PRAGMA busy_timeout = 1000;');Das Schema ist das des SQLite-Dialekts und damit identisch zu libSQL / Turso und Cloudflare D1 — eine Datenbank kann also ohne Migration zwischen einer lokalen Datei und einem der beiden wandern.
Ein Unterschied in die andere Richtung ist erwähnenswert: weil hier eine
lokale Datei statt HTTP im Spiel ist, sind die Transaktionen ein echtes
BEGIN IMMEDIATE … COMMIT. Die HTTP-basierten SQLite-Backends können
nur einen atomaren Batch anbieten — genau deshalb legt SqlPool die
Isolation als adapterdefiniert fest. Dieses Backend liefert mehr, als
der Vertrag verlangt.
Eine entfernte URL wird bei der Konstruktion abgelehnt:
withPath('libsql://…') wirft und verweist auf
LibSqlDurableStateStore — der lokale Treiber kann sie nicht öffnen,
und stillschweigend eine Datei mit diesem Namen anzulegen wäre das
verwirrende Ergebnis.
Die Auto-Detection importiert better-sqlite3 lazy — nur wenn das
Framework tatsächlich eine SQLite-Datenbank öffnen muss. Wenn du
auf Bun bist und nie SQLite verwendest, spielt der fehlende Peer
keine Rolle.
Wie es performt
Abschnitt betitelt „Wie es performt“Grobe Zahlen (NVMe-Disk, Default-Settings):
- Append-Durchsatz — 10 000-50 000 Events/sec für kleine Events. WAL-Modus hilft signifikant.
- Read-Durchsatz — 100 000+ Events/sec für Recovery (sequenzieller Scan).
- Gleichzeitige Reader — viele parallele Reader blockieren im WAL-Modus keine Writer.
Für eine Single-Node-App mit ein paar Tausend Actors, die Events pro Sekunde emittieren, ist SQLite reichlich schnell. Für Zehntausende von Events pro Sekunde dauerhaft, überlege:
- SQLite-Pragmas tunen (
synchronous = NORMAL,journal_mode = WAL, größerer Cache). - Über mehrere Journals sharden.
- Zu Cassandra für Multi-Node-Verteilung wechseln.
Recovery-Flow
Abschnitt betitelt „Recovery-Flow“Wenn ein PersistentActor startet:
- Den neuesten Snapshot aus dem Snapshot-Store laden (falls vorhanden).
SELECT payload FROM events WHERE persistence_id = ? AND sequence_nr >= ?ausführen, um Events nach dem Snapshot zu streamen.- Jedes Event über
onEventanwenden.
Für ein 100 000-Event-Journal ohne Snapshot liest die Recovery alle 100 000 Zeilen. Sequenzieller Scan mit einem Prepared Statement — Sub-Sekunde auf moderner Hardware, aber richte Snapshots ein für jeden Actor, der Events akkumuliert.
Siehe Snapshots.
Backup + Restore
Abschnitt betitelt „Backup + Restore“Da das Journal eine einzelne SQLite-Datei ist:
# Backup (mit WAL — verwende SQLites Online-Backup):sqlite3 events.db ".backup events-$(date +%F).db"
# Oder die App stoppen + cp:systemctl stop my-appcp events.db events.db.baksystemctl start my-appOnline-Backup ist bevorzugt — keine Downtime, konsistenter Snapshot. Das Standard-SQLite-Tooling gilt.
Stolperfallen
Abschnitt betitelt „Stolperfallen“Wenn SQLite nicht reicht
Abschnitt betitelt „Wenn SQLite nicht reicht“Drei Signale, dass du dem Single-File-SQLite entwachsen bist:
- Multi-Node — du brauchst Actors auf N Nodes, die sich denselben Event-Stream teilen. SQLite pro Node funktioniert nicht; wechsle zu Cassandra.
- Anhaltend 100K+ Events/sec — SQLite kann das mit Tuning handhaben, aber du bist an den Rändern; spaltenorientierte / verteilte Engines sind dafür entworfen.
- Große Events (> 1 MB pro Stück) — SQLite speichert jedes
Event als JSON-
TEXT-Payload; die Read-Performance degradiert. Überlege Event-Compaction (Pointer zu externem Storage speichern) oder ein Journal, das für große Payloads entworfen ist.
Wie geht’s weiter
Abschnitt betitelt „Wie geht’s weiter“- Persistenz im Überblick — das größere Bild.
- In-Memory-Journal — für Tests und Dev.
- Cassandra-Journal — für Multi-Node-Produktion.
- Snapshots — um den Recovery-Scan zu begrenzen.
- Snapshot Stores — SQLite — der zugehörige Snapshot-Store.
- Migrations-Rezepte — Schema-Evolution auf einem lange laufenden Journal.
