Zum Inhalt springen
Deutsch

libSQL / Turso

Das libSQL-Backend liefert alle drei Persistence-Komponenten gegen eine Turso-Datenbank oder ein selbst betriebenes sqld, über den @libsql/client-Treiber:

  • LibSqlJournal — das Event-Journal für PersistentActors.
  • LibSqlSnapshotStore — Snapshots, um die Recovery zu begrenzen.
  • LibSqlDurableStateStore — Key-Value-Durable-State für DurableStateActors. Das ist der erste Durable-State-Store der SQLite-Familie — das lokale SQLite-Backend liefert nur Journal + Snapshot.

Zwei Dinge unterscheiden es von den anderen Backends:

  • Kein natives Binding. Der Treiber spricht HTTP und WebSocket, läuft damit auf jeder unterstützten Runtime und auch in Edge-Sandboxes, die native Addons überhaupt nicht laden können.
  • Schema-kompatibel mit dem lokalen SQLite-Backend. Tabellen und Statements sind identisch: Du kannst gegen eine lokale Datei entwickeln und nach Turso deployen — oder eine Turso-Datenbank herunterziehen und lokal öffnen — ohne Migration.

Wie Postgres und Cassandra ist es über Cluster-Nodes geteilt: jeder Node kann jede persistenceId lesen und schreiben.

@libsql/client ist eine optionale Peer-Dependency — installiere sie neben actor-ts:

Terminal-Fenster
bun add @libsql/client

Das Framework importiert sie lazy, erst wenn ein libSQL-Store zum ersten Mal genutzt wird — bis dahin bleibt sie aus deinem Bundle heraus.

Registriere Journal + Snapshot-Store an der PersistenceExtension und erhalte einen einsatzbereiten Durable-State-Store. Setz die Verbindung einmal am Composite, dann erben sie alle drei Komponenten:

import {
ActorSystem,
ActorSystemOptions,
LibSqlDurableStateStoreOptions,
LibSqlJournalOptions,
LibSqlSnapshotStoreOptions,
PersistenceExtensionId,
RegisterLibSqlPluginsOptions,
registerLibSqlPlugins,
} from 'actor-ts';
const systemOptions = ActorSystemOptions.create()
// Die libSQL-Plugins als aktives Journal + Snapshot-Store auswählen.
.withConfig({
'actor-ts': {
persistence: {
journal: { plugin: 'actor-ts.persistence.journal.libsql' },
'snapshot-store': { plugin: 'actor-ts.persistence.snapshot-store.libsql' },
},
},
});
const system = ActorSystem.create('my-app', systemOptions);
const ext = system.extension(PersistenceExtensionId);
const libSqlSnapshotStoreOptions = LibSqlSnapshotStoreOptions.create()
.withKeepN(3);
const registerOptions = RegisterLibSqlPluginsOptions.create()
.withUrl('libsql://my-database.turso.io')
.withAuthToken(process.env.TURSO_AUTH_TOKEN!)
.withJournal(LibSqlJournalOptions.create() /* .withEventsTable(...).withTagsTable(...) */)
.withSnapshotStore(libSqlSnapshotStoreOptions)
.withDurableStateStore(LibSqlDurableStateStoreOptions.create() /* .withTable(...) */);
const { durableStateStore } = registerLibSqlPlugins(ext, registerOptions);

registerLibSqlPlugins registriert Journal + Snapshot-Store über die Extension (ausgewählt durch die Plugin-IDs in der Config oben) und gibt den Durable-State-Store zurück. PersistenceExtension hat keine Durable-State-Registry — übergib durableStateStore direkt an die Settings deines DurableStateActor (dasselbe Muster wie beim Postgres- und beim Object-Storage-Plugin).

Ein libSQL-Client ist selbst schon ein Connection-Pool; einen client zu übergeben ist daher die effiziente Variante, wenn alle drei Komponenten dieselbe Datenbank adressieren:

import { createClient } from '@libsql/client/web';
const client = createClient({
url: 'libsql://my-database.turso.io',
authToken: process.env.TURSO_AUTH_TOKEN,
});
const registerLibSqlPluginsOptions = RegisterLibSqlPluginsOptions.create()
.withClient(client);
registerLibSqlPlugins(ext, registerLibSqlPluginsOptions);

Ein geteilter Client gehört dem Caller: kein Store schließt ihn, du schließt ihn also selbst beim Shutdown. Übergib stattdessen url / authToken, dann baut jeder Store seinen eigenen Client und schließt ihn bei close().

const libSqlJournalOptions = LibSqlJournalOptions.create()
.withUrl('http://127.0.0.1:8080');

Ein lokales sqld braucht üblicherweise kein Auth-Token; lass withAuthToken dann weg.

  • Du willst SQLite-Semantik ohne nativen Build-Schritt. Kein better-sqlite3-Compile, keine Plattform-Binaries im Image.
  • Du läufst auf Deno oder in einer Edge-Runtime. Das einzige SQLite-Backend, das dort funktioniert, weil es nie ein natives Addon lädt.
  • Du nutzt Turso schon oder willst ein Managed-SQLite mit Replicas in der Nähe deiner Nutzer.
  • Du willst Durable-State in der SQLite-Familie. Der einzige Durable-State-Store dieser Familie.

Für eine lokale Single-Node-Datenbank ist SqliteJournal die bessere Wahl: es spricht direkt mit der Datei, hält Prepared Statements über Aufrufe hinweg und zahlt keinen Netzwerk-Round-Trip pro Statement.

type LibSqlConnection = {
url?: string; // libsql:// | http(s):// | ws(s)://
authToken?: string; // Turso-Token; bei lokalem sqld weglassen
client?: LibSqlClientLike; // vorgebauter / geteilter Client
};
interface LibSqlJournalOptions extends LibSqlConnection {
eventsTable?: string; // Default 'events'
tagsTable?: string; // Default '<eventsTable>_tags'
autoCreateTables?: boolean; // Default true
}
interface LibSqlSnapshotStoreOptions extends LibSqlConnection {
snapshotsTable?: string; // Default 'snapshots'
keepN?: number; // neueste N pro pid; Default 3, <=0 behält alle
autoCreateTables?: boolean;
}
interface LibSqlDurableStateStoreOptions extends LibSqlConnection {
table?: string; // Default 'durable_state'
autoCreateTables?: boolean;
}

Die Optionen werden bei der Konstruktion des Stores validiert — eine falsche URL oder ein leeres Auth-Token schlägt beim Verdrahten fehl, nicht erst beim ersten Append. Tabellennamen kommen aus der Config (nicht von Nutzereingaben) und werden gegen ein Safe-Identifier-Pattern geprüft; alles andere — persistenceIds, Tags, Payloads — geht als Bind-Parameter (?) durch und wird nie per String-Konkatenation zusammengesetzt.

Mit autoCreateTables (dem Default) führt das Backend beim ersten Zugriff CREATE TABLE IF NOT EXISTS aus. Das sind dieselben Tabellen, die auch das lokale SQLite-Backend anlegt:

CREATE TABLE events (
persistence_id TEXT NOT NULL,
sequence_nr INTEGER NOT NULL,
payload TEXT NOT NULL, -- JSON
tags TEXT, -- CSV (zusätzlich in events_tags gespiegelt)
timestamp INTEGER NOT NULL,
PRIMARY KEY (persistence_id, sequence_nr)
);
CREATE TABLE events_tags ( -- indizierte Tag-Lookups für Projections
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 TABLE events_meta ( -- High-Water-Mark der Kompaktierung
persistence_id TEXT PRIMARY KEY,
deleted_to INTEGER NOT NULL
);
CREATE TABLE snapshots (
persistence_id TEXT NOT NULL,
sequence_nr INTEGER NOT NULL,
payload TEXT NOT NULL,
timestamp INTEGER NOT NULL,
PRIMARY KEY (persistence_id, sequence_nr)
);
CREATE TABLE durable_state (
persistence_id TEXT PRIMARY KEY,
revision INTEGER NOT NULL,
payload TEXT NOT NULL,
timestamp INTEGER NOT NULL
);

Leg die Tabellen vorab an und setze autoCreateTables: false, wenn die Datenbankrolle kein DDL ausführen darf.

  • Journal-Append liest den aktuellen Head und fügt innerhalb einer interaktiven Transaktion ein; das expectedSeq des Callers wird gegen diesen Head geprüft. Ein konkurrierender Writer, der sich dazwischenschiebt, verletzt den Primary Key, und der resultierende SQLITE_CONSTRAINT-Fehler wird als Backstop in einen JournalConcurrencyError übersetzt — die Garantie hält also selbst dann, wenn der Transport keine strikte Isolation liefern kann.
  • Durable-State-CAS läuft über die revision-Spalte: ein Create (expectedRevision === 0) ist INSERT … ON CONFLICT DO NOTHING, ein Update UPDATE … WHERE revision = expected. Null betroffene Zeilen ⇒ DurableStateConcurrencyError, mit der aktuellen Revision für den Caller zurückgelesen.
  • SQLite-Journal — das lokale Geschwister, gleiches Schema, ohne Netzwerk.
  • PostgreSQL — relational, mit transaktionaler Isolation und ohne Round-Trip pro Statement.
  • MariaDB — das Geschwister aus der MySQL-Familie.
  • Durable State — die State-orientierte Alternative zum Event-Sourcing.
  • Snapshots — begrenzt den Recovery-Scan.