Turso

CIHex.pm

An Elixir library that wraps the turso Rust crate (v0.5) via Rustler NIFs, exposed through a DBConnection pool.

It supports local file databases (and ":memory:"), Turso Cloud sync via embedded replicas, and turso's built-in vector search and full-text search SQL features, with correct ResourceArc lifetime management and a working connection pool. Ecto support is optional via Ecto.Adapters.Turso.

Native binaries

Version 0.5 builds the NIF from source while its renamed Turso.* API is prepared for a new set of precompiled artifacts. A working Rust toolchain (cargo) matching the BEAM architecture is therefore required.

Future releases may provide precompiled binaries for:

OSArchitectures
Linuxaarch64, x86_64
macOSaarch64, x86_64
FreeBSDx86_64
Windowsx86_64

Releases also publish static NIF archives for static BEAM builds:

TargetArchive
Linux amd64 muslex_turso-vVERSION-static-x86_64-unknown-linux-musl.tar.gz
Linux arm64 muslex_turso-vVERSION-static-aarch64-unknown-linux-musl.tar.gz

Each archive contains libex_turso.a, which exports ex_turso_nif_init for OTP's ex_turso static NIF library name. Use it when configuring OTP:

./configure --enable-static-nifs=/path/to/libex_turso.a:ex_turso

To build the archive from source instead:

TARGET=x86_64-unknown-linux-musl # or aarch64-unknown-linux-musl
rustup target add "$TARGET"
cargo build --manifest-path native/ex_turso/Cargo.toml \
--target "$TARGET" \
--release \
--locked
nm -g --defined-only "native/ex_turso/target/$TARGET/release/libex_turso.a" \
| grep ' ex_turso_nif_init$'

Use cross build instead of cargo build when building the arm64 musl archive from a non-arm64 host.

Installation

def deps do
[
{:ex_turso, "~> 3.0"}
]
end

Usage

Start a pool under your supervision tree:

children = [
{Turso, database: "my_app.db", name: MyApp.DB}
]
Supervisor.start_link(children, strategy: :one_for_one)

Then query and execute against the registered name:

{:ok, _} = Turso.execute(MyApp.DB, "CREATE TABLE users (id INTEGER, name TEXT)")
{:ok, _} = Turso.execute(MyApp.DB, "INSERT INTO users VALUES (?, ?)", [1, "Alice"])
{:ok, %Turso.Result{rows: [%{"name" => "Alice"}]}} =
Turso.query(MyApp.DB, "SELECT name FROM users WHERE id = ?", [1])

Transactions go through DBConnection:

DBConnection.transaction(MyApp.DB, fn conn ->
{:ok, _} = Turso.execute(conn, "UPDATE users SET name = ? WHERE id = ?", ["Bob", 1])
end)

Use database: ":memory:" for an in-memory database (one per pool connection).

Always pass values as bound parameters (?) rather than interpolating them into the SQL string — statements are logged when a query errors.

Ecto

Turso includes an optional SQL adapter for Ecto. Add ecto_sql in the application that uses Ecto:

def deps do
[
{:ex_turso, "~> 3.0"},
{:ecto_sql, "~> 3.14"}
]
end

Define your repo with Ecto.Adapters.Turso:

defmodule MyApp.Repo do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.Turso
end

Configure it with the same database options used by Turso:

config :my_app, MyApp.Repo,
database: "my_app.db",
pool_size: 5

The adapter supports regular Ecto.Repo schema/query operations and ecto_sql migrations using Turso's SQLite-compatible SQL dialect. Streaming and multi-result queries are not supported by the current native connection.

Rebuilding a table for unsupported column changes

Turso does not safely support every ALTER COLUMN operation. For changes that require replacing a table, use Ecto.Adapters.Turso.Migration.rebuild_table!/3 from explicit up/0 and down/0 migrations. The helper requires the complete target table definition and an explicit identifier-only copy mapping.

The :create callback receives an already quoted temporary table identifier:

defmodule MyApp.Repo.Migrations.AllowNullableOwnerId do
use Ecto.Migration
@disable_ddl_transaction true
def up do
execute(fn -> rebuild(null: true) end)
end
def down do
execute(fn -> rebuild(null: false) end)
end
defp rebuild(opts) do
Ecto.Adapters.Turso.Migration.rebuild_table!(
repo(),
:github_import_runs,
create: fn temporary_table -> create_sql(temporary_table, opts) end,
copy: [:id, :source_owner_github_id, :source_repository_id],
recreate: [:indexes, :triggers]
)
end
defp create_sql(temporary_table, opts) do
owner_nullability = if opts[:null], do: "", else: " NOT NULL"
"""
CREATE TABLE #{temporary_table} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_owner_github_id INTEGER#{owner_nullability},
source_repository_id INTEGER,
CONSTRAINT github_import_runs_owner_check
CHECK (source_owner_github_id IS NOT NULL OR source_repository_id IS NOT NULL)
)
"""
end
end

The helper pins one connection, temporarily disables foreign-key enforcement, creates and copies into the replacement table inside a transaction, restores explicit indexes, triggers, and the AUTOINCREMENT sequence, runs foreign-key and integrity checks, and restores the connection's original foreign-key setting on every exit path. It rejects existing transactions, qualified table names, generated or hidden columns, and copy mappings that do not preserve the primary key. :recreate defaults to both indexes and triggers; an optional :validate callback receives the checked-out transaction connection for additional application checks. Built-in foreign-key and integrity checks cannot be disabled. Application-specific backfills must be performed separately.

Turso enables Turso's embedded full-text search index support for local databases. Use Turso's FTS index syntax:

{:ok, _} = Turso.execute(MyApp.DB, "CREATE TABLE docs (id INTEGER PRIMARY KEY, content TEXT)")
{:ok, _} = Turso.execute(MyApp.DB, "CREATE INDEX docs_fts ON docs USING fts (content)")
{:ok, %Turso.Result{rows: rows}} =
Turso.query(MyApp.DB, "SELECT id FROM docs WHERE (content) MATCH ?", ["search term"])

SQLite's FTS5 virtual table syntax, such as CREATE VIRTUAL TABLE docs_fts USING fts5(content), is not exposed by the embedded turso crate v0.5 API. Use CREATE INDEX ... USING fts with MATCH queries instead.

Turso Cloud sync

Pass :remote_url and :auth_token to open the local file as an embedded replica of a Turso Cloud database:

children = [
{Turso,
database: "replica.db",
remote_url: "libsql://my-db.turso.io",
auth_token: fn -> System.fetch_env!("TURSO_AUTH_TOKEN") end,
name: MyApp.DB}
]

auth_token accepts a string or a zero-arity function; prefer the function so the token does not appear in supervisor child specs and crash reports.

Trigger a bidirectional sync (pull then push) with:

:ok = Turso.sync(MyApp.DB)

Sync is rejected inside a transaction and on databases not configured with :remote_url/:auth_token.

Errors

Failures return {:error, %Turso.Error{message: message, code: code}}. The code classifies the failure: :busy (locked, retryable), :constraint, :invalid_param (unsupported bound parameter type), :misuse, :error, or :io/:corrupt — the last two mark the connection as broken, so the pool drops it and opens a fresh one.

Architecture

LayerModule / fileRole
Nativenative/ex_turso/src/lib.rsRustler NIFs over turso, driven by a global Tokio runtime
NIF declsTurso.NativeLoads the compiled NIF
PoolingTurso.ConnectionDBConnection behaviour implementation
QueryTurso.QueryStatement struct + DBConnection.Query protocol
EctoEcto.Adapters.TursoOptional ecto_sql adapter
Public APITursostart_link/1, child_spec/1, query/3, execute/3