ecto_libsql is an (unofficial) Elixir Ecto database adapter for LibSQL and Turso, built with Rust NIFs. It supports local SQLite files, remote replica with synchronisation, and remote only Turso databases.
Add ecto_libsql to your dependencies in mix.exs:
def deps do
[
{:ecto_libsql, "~> 0.4.0"}
]
end# Configure your repo
config :my_app, MyApp.Repo,
adapter: Ecto.Adapters.LibSql,
database: "my_app.db"
# Define your repo
defmodule MyApp.Repo do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.LibSql
end
# Use Ecto as normal
defmodule MyApp.User do
use Ecto.Schema
schema "users" do
field :name, :string
field :email, :string
timestamps()
end
end
# CRUD operations
{:ok, user} = MyApp.Repo.insert(%MyApp.User{name: "Alice", email: "alice@example.com"})
users = MyApp.Repo.all(MyApp.User)For lower-level control, you can use the DBConnection interface directly:
# Local database
{:ok, conn} = DBConnection.start_link(EctoLibSql, database: "local.db")
# Remote Turso database
{:ok, conn} = DBConnection.start_link(EctoLibSql,
uri: "libsql://your-db.turso.io",
auth_token: "your-token"
)
# Embedded replica (local database synced with remote)
{:ok, conn} = DBConnection.start_link(EctoLibSql,
database: "local.db",
uri: "libsql://your-db.turso.io",
auth_token: "your-token",
sync: true
)Connection Modes
- Local SQLite files
- Remote LibSQL/Turso servers
- Embedded replicas with automatic or manual synchronisation
Core Functionality
- Parameterised queries with safe parameter binding
- Prepared statements
- Transactions with multiple isolation levels (deferred, immediate, exclusive)
- Batch operations (transactional and non-transactional)
- Metadata access (last insert ID, row counts, etc.)
Advanced Features
- Vector similarity search
- Database encryption (AES-256-CBC for local and embedded replica databases)
- WebSocket and HTTP protocols
- Cursor-based streaming for large result sets (via DBConnection interface)
Note: Ecto Repo.stream() is not yet implemented. For streaming large datasets, use the DBConnection cursor interface directly (see examples in AGENT.md).
# Setup
defmodule MyApp.Repo do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.LibSql
end
defmodule MyApp.User do
use Ecto.Schema
schema "users" do
field :name, :string
field :email, :string
field :age, :integer
timestamps()
end
end
# Create
{:ok, user} = MyApp.Repo.insert(%MyApp.User{
name: "Alice",
email: "alice@example.com",
age: 30
})
# Read
user = MyApp.Repo.get(MyApp.User, 1)
users = MyApp.Repo.all(MyApp.User)
# Update
user
|> Ecto.Changeset.change(age: 31)
|> MyApp.Repo.update()
# Delete
MyApp.Repo.delete(user)import Ecto.Query
# Filter and order
adults = MyApp.User
|> where([u], u.age >= 18)
|> order_by([u], desc: u.inserted_at)
|> MyApp.Repo.all()
# Aggregations
count = MyApp.User
|> where([u], u.age >= 18)
|> MyApp.Repo.aggregate(:count)
avg_age = MyApp.Repo.aggregate(MyApp.User, :avg, :age)MyApp.Repo.transaction(fn ->
{:ok, user1} = MyApp.Repo.insert(%MyApp.User{name: "Bob", email: "bob@example.com"})
{:ok, user2} = MyApp.Repo.insert(%MyApp.User{name: "Carol", email: "carol@example.com"})
%{user1: user1, user2: user2}
end)For lower-level control, use the DBConnection interface:
{:ok, conn} = DBConnection.start_link(EctoLibSql, database: "test.db")
# Create table
DBConnection.execute(conn, %EctoLibSql.Query{
statement: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"
}, [])
# Insert with parameters
DBConnection.execute(conn, %EctoLibSql.Query{
statement: "INSERT INTO users (name) VALUES (?)"
}, ["Alice"])
# Query data
{:ok, _query, result} = DBConnection.execute(conn, %EctoLibSql.Query{
statement: "SELECT * FROM users WHERE name = ?"
}, ["Alice"])
IO.inspect(result.rows) # [[1, "Alice"]]DBConnection.transaction(conn, fn conn ->
DBConnection.execute(conn, %EctoLibSql.Query{
statement: "INSERT INTO users (name) VALUES (?)"
}, ["Bob"])
DBConnection.execute(conn, %EctoLibSql.Query{
statement: "INSERT INTO users (name) VALUES (?)"
}, ["Carol"])
end)# Prepare once, execute many times
{:ok, state} = EctoLibSql.connect(database: "test.db")
{:ok, stmt_id} = EctoLibSql.Native.prepare(state,
"SELECT * FROM users WHERE id = ?")
{:ok, result1} = EctoLibSql.Native.query_stmt(state, stmt_id, [1])
{:ok, result2} = EctoLibSql.Native.query_stmt(state, stmt_id, [2])
:ok = EctoLibSql.Native.close_stmt(stmt_id){:ok, state} = EctoLibSql.connect(database: "test.db")
# Execute multiple statements together
statements = [
{"INSERT INTO users (name) VALUES (?)", ["Dave"]},
{"INSERT INTO users (name) VALUES (?)", ["Eve"]},
{"UPDATE users SET name = ? WHERE id = ?", ["David", 1]}
]
# Non-transactional (each statement independent)
{:ok, results} = EctoLibSql.Native.batch(state, statements)
# Transactional (all-or-nothing)
{:ok, results} = EctoLibSql.Native.batch_transactional(state, statements){:ok, state} = EctoLibSql.connect(database: "vectors.db")
# Create table with vector column (3 dimensions, f32 precision)
vector_type = EctoLibSql.Native.vector_type(3, :f32)
EctoLibSql.handle_execute(
"CREATE TABLE items (id INTEGER, embedding #{vector_type})",
[], [], state
)
# Insert vector
vec = EctoLibSql.Native.vector([1.0, 2.0, 3.0])
EctoLibSql.handle_execute(
"INSERT INTO items VALUES (?, vector(?))",
[1, vec], [], state
)
# Find similar vectors (cosine distance)
query_vector = [1.5, 2.1, 2.9]
distance_fn = EctoLibSql.Native.vector_distance_cos("embedding", query_vector)
{:ok, _query, results, _} = EctoLibSql.handle_execute(
"SELECT id FROM items ORDER BY #{distance_fn} LIMIT 10",
[], [], state
)# Encrypted local database
{:ok, conn} = DBConnection.start_link(EctoLibSql,
database: "encrypted.db",
encryption_key: "your-secret-key-must-be-at-least-32-characters"
)
# Encrypted embedded replica
{:ok, conn} = DBConnection.start_link(EctoLibSql,
database: "encrypted.db",
uri: "libsql://your-db.turso.io",
auth_token: "your-token",
encryption_key: "your-secret-key-must-be-at-least-32-characters",
sync: true
)When using embedded replica mode (sync: true), the library automatically handles synchronisation between your local database and Turso cloud. However, you can also trigger manual sync when needed.
# Automatic sync is enabled with sync: true
{:ok, state} = EctoLibSql.connect(
database: "local.db",
uri: "libsql://your-db.turso.io",
auth_token: "your-token",
sync: true # Automatic sync enabled
)
# Writes and reads work normally - sync happens automatically
EctoLibSql.handle_execute("INSERT INTO users (name) VALUES (?)", ["Alice"], [], state)
EctoLibSql.handle_execute("SELECT * FROM users", [], [], state)How automatic sync works:
- Initial sync happens when you first connect
- Changes are synced automatically in the background
- You don't need to call
sync/1in most applications
For specific use cases, you can manually trigger synchronisation:
# Force immediate sync after critical operation
EctoLibSql.handle_execute("INSERT INTO orders (total) VALUES (?)", [1000.00], [], state)
{:ok, _} = EctoLibSql.Native.sync(state) # Ensure synced to cloud immediately
# Before shutdown - ensure all changes are persisted
{:ok, _} = EctoLibSql.Native.sync(state)
:ok = EctoLibSql.disconnect([], state)
# Coordinate between multiple replicas
{:ok, _} = EctoLibSql.Native.sync(replica1) # Push local changes
{:ok, _} = EctoLibSql.Native.sync(replica2) # Pull those changes on another replicaWhen to use manual sync:
- Critical operations: Immediately after writes that must be durable
- Before shutdown: Ensuring all local changes reach the cloud
- Coordinating replicas: When multiple replicas need consistent data immediately
- After batch operations: Following bulk inserts/updates
When you DON'T need manual sync:
- Normal application reads/writes (automatic sync handles this)
- Most CRUD operations (background sync is sufficient)
- Development and testing (automatic sync is fine)
You can disable automatic sync and rely entirely on manual control:
# Disable automatic sync
{:ok, state} = EctoLibSql.connect(
database: "local.db",
uri: "libsql://your-db.turso.io",
auth_token: "your-token",
sync: false # Manual sync only
)
# Make local changes (not synced yet)
EctoLibSql.handle_execute("INSERT INTO users (name) VALUES (?)", ["Alice"], [], state)
# Manually synchronise when ready
{:ok, _} = EctoLibSql.Native.sync(state)This is useful for offline-first applications or when you want explicit control over when data syncs.
| Option | Type | Description |
|---|---|---|
database |
string | Path to local SQLite database file |
uri |
string | Remote LibSQL server URI (e.g., libsql://... or wss://...) |
auth_token |
string | Authentication token for remote connections |
sync |
boolean | Enable automatic synchronisation for embedded replicas |
encryption_key |
string | Encryption key (32+ characters) for local database |
The adapter automatically detects the connection mode based on the options provided:
Only database specified - stores data in a local SQLite file:
config :my_app, MyApp.Repo,
adapter: Ecto.Adapters.LibSql,
database: "my_app.db"uri and auth_token specified - connects directly to Turso cloud:
config :my_app, MyApp.Repo,
adapter: Ecto.Adapters.LibSql,
uri: "libsql://your-database.turso.io",
auth_token: System.get_env("TURSO_AUTH_TOKEN")All of database, uri, auth_token, and sync specified - local file with cloud synchronisation:
config :my_app, MyApp.Repo,
adapter: Ecto.Adapters.LibSql,
database: "replica.db",
uri: "libsql://your-database.turso.io",
auth_token: System.get_env("TURSO_AUTH_TOKEN"),
sync: trueThis mode provides microsecond read latency (local file) with automatic cloud backup. Synchronisation happens automatically in the background - see the Embedded Replica Synchronisation section for details on sync behaviour and manual sync control.
Control transaction locking behaviour:
# Deferred (default) - locks acquired on first write
{:ok, state} = EctoLibSql.Native.begin(state, behavior: :deferred)
# Immediate - acquire write lock immediately
{:ok, state} = EctoLibSql.Native.begin(state, behavior: :immediate)
# Read-only - read lock only
{:ok, state} = EctoLibSql.Native.begin(state, behavior: :read_only)# Get last inserted row ID
rowid = EctoLibSql.Native.get_last_insert_rowid(state)
# Get number of rows changed by last statement
changes = EctoLibSql.Native.get_changes(state)
# Get total rows changed since connection opened
total = EctoLibSql.Native.get_total_changes(state)
# Check if in autocommit mode (not in transaction)
autocommit? = EctoLibSql.Native.get_is_autocommit(state)- API Documentation: https://hexdocs.pm/ecto_libsql
- Changelog: CHANGELOG.md
- Migration Guide: ECTO_MIGRATION_GUIDE.md
Apache 2.0
This library is a fork of libsqlex by danawanb, extended from a DBConnection adapter to a full Ecto adapter with additional features including vector similarity search, database encryption, batch operations, prepared statements, and comprehensive documentation.