跳转到内容
简体中文

libSQL / Turso

此内容尚不支持你的语言。

The libSQL backend provides all three persistence components against a Turso database or a self-hosted sqld, via the @libsql/client driver:

  • LibSqlJournal — the event journal for PersistentActors.
  • LibSqlSnapshotStore — snapshots to bound recovery.
  • LibSqlDurableStateStore — key-value durable state for DurableStateActors. This is the first durable-state store in the SQLite family — the local SQLite backend ships journal + snapshot only.

Two things make it different from the other backends:

  • No native binding. The driver speaks HTTP and WebSocket, so it runs on every runtime the framework supports and inside edge sandboxes that cannot load native addons at all.
  • Schema-compatible with the local SQLite backend. The tables and statements are the same, so you can develop against a local file and deploy to Turso — or pull a Turso database down and open it locally — without a migration.

Like Postgres and Cassandra, it is shared across cluster nodes: any node can read or write any persistenceId.

@libsql/client is an optional peer dependency — install it alongside actor-ts:

Terminal window
bun add @libsql/client

The framework lazy-imports it only when a libSQL store is first used, so it stays out of your bundle until you opt in.

Register the journal + snapshot store against the PersistenceExtension and receive a ready-to-use durable-state store. Set the connection once on the composite and all three components inherit it:

import {
ActorSystem,
ActorSystemOptions,
LibSqlDurableStateStoreOptions,
LibSqlJournalOptions,
LibSqlSnapshotStoreOptions,
PersistenceExtensionId,
RegisterLibSqlPluginsOptions,
registerLibSqlPlugins,
} from 'actor-ts';
const systemOptions = ActorSystemOptions.create()
// Select the libSQL plugins as the active journal + snapshot store.
.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 registers the journal + snapshot store via the extension (selected by the config plugin IDs above) and returns the durable-state store. PersistenceExtension has no durable-state registry — hand durableStateStore to your DurableStateActor settings directly (same pattern as the Postgres and object-storage plugins).

A libSQL client is a connection pool in its own right, so passing one client is the efficient shape when all three components target the same database:

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);

A shared client is caller-owned: no store closes it, so close it yourself during shutdown. Pass url / authToken instead and each store builds its own client and closes it on close().

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

A local sqld typically needs no auth token; omit withAuthToken.

  • You want SQLite semantics without a native build step. No better-sqlite3 compile, no platform binaries in your image.
  • You run on Deno, or on an edge runtime. The only SQLite-flavoured backend that works there, since it never loads a native addon.
  • You already use Turso, or want a managed SQLite with replicas near your users.
  • You want durable state in the SQLite family. The only SQLite-family durable-state store.

For a single-node, local database, prefer SqliteJournal: it talks to the file directly, keeps prepared statements alive across calls, and pays no network round-trip per statement.

type LibSqlConnection = {
url?: string; // libsql:// | http(s):// | ws(s)://
authToken?: string; // Turso token; omit for a local sqld
client?: LibSqlClientLike; // pre-built / shared 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; // keep newest N per pid; default 3, <=0 keeps all
autoCreateTables?: boolean;
}
interface LibSqlDurableStateStoreOptions extends LibSqlConnection {
table?: string; // default 'durable_state'
autoCreateTables?: boolean;
}

Options are validated when the store is constructed, so a bad URL or an empty auth token fails at wiring time rather than on the first append. Table names come from config (not user input) and are checked against a safe-identifier pattern; everything else — persistenceIds, tags, payloads — is passed as bind parameters (?), never string-concatenated.

With autoCreateTables (the default), the backend runs CREATE TABLE IF NOT EXISTS on first use. These are the same tables the local SQLite backend creates:

CREATE TABLE events (
persistence_id TEXT NOT NULL,
sequence_nr INTEGER NOT NULL,
payload TEXT NOT NULL, -- JSON
tags TEXT, -- CSV (also mirrored into events_tags)
timestamp INTEGER NOT NULL,
PRIMARY KEY (persistence_id, sequence_nr)
);
CREATE TABLE events_tags ( -- indexed tag lookups for 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 ( -- compaction high-water mark
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
);

Pre-provision these and set autoCreateTables: false if the database role cannot run DDL.

  • Journal append reads the current head and inserts inside one interactive transaction; the caller’s expectedSeq is checked against that head. A racing writer that slips through trips the primary key, and the resulting SQLITE_CONSTRAINT error is translated to JournalConcurrencyError as a backstop — so the guarantee holds even when the transport cannot give strict isolation.
  • Durable-state CAS uses the revision column: a create (expectedRevision === 0) is INSERT … ON CONFLICT DO NOTHING, and an update is UPDATE … WHERE revision = expected. Zero rows affected ⇒ DurableStateConcurrencyError, with the current revision read back for the caller.
  • SQLite journal — the local sibling, same schema, no network.
  • PostgreSQL — relational, with transactional isolation and no per-statement round-trip.
  • MariaDB — the MySQL-family sibling.
  • Durable state — the state-oriented alternative to event sourcing.
  • Snapshots — bound the recovery scan.