Turso (beta)

The Rust rewrite of SQLite (@tursodatabase/database) with MVCC concurrent writes via BEGIN CONCURRENT — OR remote Turso Cloud (libsql://) with embedded-replica sync via @libsql/client. Routed by URL scheme. SQLite-compatible SQL. Single-node local, beta. No generated columns, no FTS; numeric ids drop AUTOINCREMENT; no Alpine/musl or Intel-mac prebuilts on the local engine.

Turso is the Rust rewrite of SQLite (@tursodatabase/database, formerly Limbo). It speaks SQLite's SQL dialect, file format, and a better-sqlite3-shaped driver — so the framework reuses the entire SQLite-family store and SQL compiler. The ONE thing it adds over the sqlite dialect is the reason to pick it: MVCC concurrent writes via BEGIN CONCURRENT. Where sqlite serializes every write through a single connection, turso runs a connection POOL where multiple transactions commit concurrently; a write-write conflict is detected and the framework retries it transparently.

The turso dialect has two live backends, routed by the connection URL scheme — not two versions of the same thing, but a real config choice:

  • Local Rust engine (file: / :memory:) — the embedded @tursodatabase/database engine described above: pooled, single-process, MVCC concurrent writes. beta (0.x).
  • Remote Turso Cloud (libsql:// / https:// / wss://) — a connection to a hosted Turso Cloud database over the official @libsql/client, with an auth token. Also supports an embedded replica: a local file: copy that periodically syncs from a remote primary (local-latency reads, writes forwarded to the primary).

Treat the local engine as "SQLite for a single node that needs real write concurrency", not a production-hardened engine; treat the remote/replica backend as "your app runs against managed Turso Cloud". For multi-instance scale-out with sub-second local reactivity use postgres / mysql / mariadb / mssql; for a rock-stable single-process embed use sqlite.

Configuration

The URL scheme picks the backend — local engine vs remote Turso Cloud vs embedded replica:

DB_DIALECT=turso

# ── Local Rust engine ─────────────────────────────────────────
DB_URL=file:./db.turso        # relative path (resolved against cwd)
DB_URL=file:/abs/path/db.turso
DB_URL=:memory:               # ephemeral, pool forced to size 1 (see below)
DB_MAX_CONNECTIONS=8          # pool size = the MVCC write-concurrency knob. Default 4.

# ── Remote Turso Cloud ────────────────────────────────────────
DB_URL=libsql://your-db.turso.io   # or https:// / wss://
DB_AUTH_TOKEN=<token>              # from `turso db tokens create <db>` — never logged/baked

# ── Embedded replica (local file that syncs from a remote primary) ─
DB_URL=file:./replica.db           # the LOCAL replica file
DB_SYNC_URL=libsql://your-db.turso.io   # the remote primary it syncs FROM
DB_AUTH_TOKEN=<token>
DB_SYNC_INTERVAL=30                # optional: pull-sync every N seconds (else sync on boot only)

The scheme routes the connection: file: / :memory: → the local Rust engine; libsql:// / https:// / wss://remote Turso Cloud (needs DB_AUTH_TOKEN); a file: URL paired with DB_SYNC_URL → an embedded replica. So the same file: scheme means "local engine" without a sync URL and "libsql embedded replica" with one. A remote URL without an auth token, or a DB_SYNC_URL without one, fails loud at boot with an actionable message. The token comes from DB_AUTH_TOKEN (alias TURSO_AUTH_TOKEN) — it is never logged or baked into the build.

Remote Turso Cloud + embedded replicas

The remote backend connects through the official @libsql/client:

  • Remote Turso Cloud (libsql:// / https:// / wss://): every statement runs against your hosted Turso database; ctx.store.transactional(...) opens an interactive libsql transaction('write'). Turso Cloud serialises writes server-side (there's no client-side BEGIN CONCURRENT here — that's the local engine's mechanism), and a transient busy/conflict is retried by the same transaction-retry the local engine uses.
  • Embedded replica (file: + DB_SYNC_URL): the client keeps a local copy of the database that reads with local latency and syncs from the remote primary. The framework does an initial client.sync() at boot so the first reads see a warm replica; if DB_SYNC_INTERVAL is set, the driver keeps pulling on that cadence. Writes are forwarded to the primary. Optional DB_READ_YOUR_WRITES (default on) makes a local read after a write wait until the replica has caught up; DB_ENCRYPTION_KEY encrypts the local replica file at rest.

Everything above this seam is identical to the local engine — the same reused SQLite-family DataStore, the same schema/query/mutation code. Only the transport differs.

The local Rust engine and the remote/replica backend are a deliberate config choice, not a versioned split. Pick file:/:memory: for an embedded single-process DB with MVCC write concurrency; pick libsql:// (± an embedded replica) to run against managed Turso Cloud.

MVCC is mandatory — journal_mode=experimental_mvcc

BEGIN CONCURRENT is only accepted when MVCC is enabled, so every pooled connection runs PRAGMA journal_mode=experimental_mvcc at open. This is not optional for the dialect — it's what the whole thing is for. The store's DML transactions run as BEGIN CONCURRENT, so every ctx.store.transactional(...) (i.e. every mutation) opens a concurrent transaction. DDL keeps plain BEGIN (Turso rejects DDL inside a concurrent transaction), so the @effect/sql client defaults to BEGIN and the store upgrades its own transactions per-fiber — migrations (including the workflow engine's) still work.

Concurrent writes + automatic retry

Two mutations that touch the same row run on two different pooled connections, each with its own MVCC snapshot. The loser of the commit race gets a Write-write conflict; the framework's transaction retry (exponential backoff, a few attempts) replays the whole transaction body on a fresh snapshot that now sees the winner's commit. Your handler code is unchanged — write a normal transactional mutation and the concurrency + retry happen underneath.

Size the pool (DB_MAX_CONNECTIONS) to the concurrent-mutation count you expect. :memory: is per-connection (each connection is a private database), so the pool is forced to size 1 there — :memory: is for tests, not for exercising concurrency.

Driver: @tursodatabase/database (native NAPI)

Async driver (connect()prepare()run/get/all), wrapped in Effect. The framework's decodeRowsFromSchema post-processor handles the same SQLite read quirks as the sqlite dialect:

  • boolean() columns: 0/1 → false/true
  • json() columns: TEXT → object (JSON.parse)
  • timestamp() / date() columns: ISO string → Date

On write, the same coercion converts Date → ISO string and boolean → 0/1 before binding.

Prebuilt binaries ship for linux-x64-gnu, linux-arm64-gnu, win32-x64-msvc, darwin-arm64 only — there is NO Alpine/musl or Intel-mac binary. A Node Docker image on Alpine will fail to install the driver; use a glibc base image (node:22-bookworm-slim, etc.). This applies to the local engine only — the remote / embedded-replica backend goes through @libsql/client (pure JS + its own bindings) and is not bound by the local engine's prebuilt matrix.

No cluster — runnerStorage: 'memory'

Like sqlite, Turso is single-node here. The workflow engine boots with runnerStorage: 'memory': workflows run durably within ONE process (crash + restart resumes from the journal), multi-replica scale-out is not possible, and DB_REPLICA_URLS is a no-op with a warning. (The pool gives concurrent writes on one node — it does NOT give horizontal scale-out.)

CDC — in-process EventEmitter

Same as sqlite: an in-process Node EventEmitter fans insert/update/delete events to the dispatcher. Sub-millisecond, single-process. No LISTEN/NOTIFY, no triggers.

Not supported on Turso (beta gaps)

These are SQLite features the sqlite dialect has but Turso's MVCC mode does not. The framework fails LOUD at migrate rather than emit DDL the engine rejects:

  • Generated columns (.generatedAs(...)). Turso MVCC rejects STORED/VIRTUAL generated columns — migrate throws a clear error. Drop the column or use sqlite / postgres.
  • Full-text search (.fullTextIndex(...)). MVCC has no virtual tables (FTS5) — migrate throws. Use sqlite / postgres for FTS.
  • AUTOINCREMENT. Numeric ids (id({ scheme: 'numeric' })) emit plain INTEGER PRIMARY KEY (still auto-allocating, just without the no-reuse-of-deleted-ids guarantee). The default TypeID/ULID schemes are unaffected.

Everything else — RETURNING, ON CONFLICT upserts, json_object/json_group_array eager-loads, PRAGMA introspection, partial + expression indexes, CHECK constraints, namespace ATTACH (multi-tenant physical isolation) — works identically to sqlite.

DDL runs in autocommit

Turso rejects DDL inside a BEGIN CONCURRENT transaction ("DDL statements require an exclusive transaction"). So voltro migrate / auto-migrate runs each DDL statement in autocommit on turso (sequential, idempotent CREATE … IF NOT EXISTS). A partial failure simply recovers on the next boot; DML transactions still use BEGIN CONCURRENT.

Identifier quoting

"name" — double quotes. Same as postgres + sqlite.

File vs :memory:

  • :memory: — ephemeral, per-connection, pool clamped to 1. Tests + smoke fixtures.
  • file:./db.turso — durable. Pool of N connections share the one file; MVCC coordinates concurrent writers.

Where it lives

  • voltro/packages/sql-turso/src/sqlLayer.ts — the URL-scheme ROUTER: connectionFromConfig parses a ConnectionConfig (+ token/sync env) into a local (Rust engine) or remote (libsql) connection and builds the matching layer
  • voltro/packages/sql-turso/src/sqlClient.ts — the LOCAL pooled @effect/sql client: async connection, per-connection prepare cache, Pool acquirers, beginTransaction: 'BEGIN' (DDL-safe default; the store upgrades its own DML transactions to BEGIN CONCURRENT per-fiber via ConcurrentTransaction), MVCC pragma, :memory: size-1 clamp
  • voltro/packages/sql-turso/src/libsqlClient.ts — the REMOTE @effect/sql client over @libsql/client: autocommit client.execute, an interactive client.transaction('write') for transactional() (with BEGIN as a no-op and COMMIT/ROLLBACK mapped onto the tx object), and the embedded-replica sync (syncUrl + initial client.sync()). The auth token is config/env-sourced and never logged
  • voltro/packages/sql-turso/src/retry.tsisTursoRetryableFailure (matches the "Write-write conflict" / busy MESSAGE; the local engine tags every error with a generic code, so the retry identity is the message)
  • voltro/packages/sql-turso/src/index.tstursoDialect (id: 'turso'); reuses makeSqliteStore from @voltro/sql-sqlite with the routed client + retry predicate
  • voltro/packages/sql-sqlite/src/store.ts — the shared SQLite-family store (driver + retry predicate + span name are injected)
  • voltro/packages/database/src/migrate.ts — turso DDL branch (numeric-id without AUTOINCREMENT; generated-column + FTS guards; autocommit DDL)