Packages
Elixir Ecto adapter for LibSQL and Turso databases. Supports local SQLite files, remote Turso cloud databases, and embedded replicas with sync. Built with Rust NIFs for reliability and fault tolerance. See the Changelog for details!
Current section
Files
Jump to
Current section
Files
lib/ecto_libsql.ex
defmodule EctoLibSql do
@moduledoc """
DBConnection implementation for LibSQL and Turso databases.
This module provides the core database connection functionality for LibSQL/Turso,
implementing the `DBConnection` behaviour. It supports three connection modes:
- **Local**: SQLite files on the local filesystem
- **Remote**: Direct connections to Turso cloud databases
- **Remote Replica**: Local SQLite files that sync with remote Turso databases
## Features
- Connection management via Rust NIFs for high performance
- Full transaction support with multiple isolation levels
- Query execution in both transactional and non-transactional contexts
- Cursor support for streaming large result sets
- Prepared statement caching
- Batch operations (transactional and non-transactional)
- Vector similarity search
- Database encryption
## Connection Options
- `:database` - Path to local SQLite database file
- `:uri` - Remote LibSQL server URI (e.g., `"libsql://your-db.turso.io"`)
- `:auth_token` - Authentication token for remote connections
- `:sync` - Enable automatic sync for embedded replicas (boolean)
- `:encryption_key` - Encryption key for local database (min 32 characters)
## Examples
# 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 + remote sync)
{:ok, conn} = DBConnection.start_link(EctoLibSql,
database: "local.db",
uri: "libsql://your-db.turso.io",
auth_token: "your-token",
sync: true
)
"""
use DBConnection
@impl true
@doc """
Opens a connection to LibSQL using the native Rust layer.
Returns `{:ok, state}` on success or `{:error, reason}` on failure. Automatically using remote replica if the opts provided database, uri, and auth token.
"""
def connect(opts) do
case EctoLibSql.Native.connect(opts, EctoLibSql.State.detect_mode(opts)) do
conn_id when is_binary(conn_id) ->
{:ok,
%EctoLibSql.State{
conn_id: conn_id,
mode: EctoLibSql.State.detect_mode(opts),
sync: EctoLibSql.State.detect_sync(opts)
}}
{:error, _} = err ->
err
other ->
{:error, {:unexpected_response, other}}
end
end
@impl true
@doc """
Pings the current connection to ensure it is still alive.
"""
def ping(%EctoLibSql.State{conn_id: conn_id} = state) do
case EctoLibSql.Native.ping(conn_id) do
true -> {:ok, state}
_ -> {:disconnect, :ping_failed, state}
end
end
@impl true
@doc """
Disconnects from the database by closing the underlying native connection.
Removes the connection from the Rust connection registry and cleans up any resources.
"""
def disconnect(_opts, %EctoLibSql.State{conn_id: conn_id, trx_id: _trx_id} = state) do
# return :ok on success
EctoLibSql.Native.close_conn(conn_id, :conn_id, state)
end
@impl true
@doc """
Executes an SQL query, delegating to transactional or non-transactional logic
depending on the connection state.
"""
def handle_execute(
query,
args,
_opts,
%EctoLibSql.State{conn_id: _conn_id, trx_id: trx_id, mode: _mode} = state
) do
query_struct =
case query do
%EctoLibSql.Query{} -> query
query when is_binary(query) -> %EctoLibSql.Query{statement: query}
end
if trx_id do
EctoLibSql.Native.execute_with_trx(state, query_struct, args)
else
EctoLibSql.Native.execute_non_trx(query_struct, state, args)
end
end
@impl true
@doc """
Begins a new database transaction.
The transaction behaviour (deferred/immediate/exclusive) can be controlled
via options passed to the Native module.
"""
def handle_begin(_opts, state) do
case EctoLibSql.Native.begin(state) do
{:ok, new_state} -> {:ok, :begin, new_state}
{:error, reason} -> {:error, reason, state}
end
end
@impl true
def handle_commit(_opts, %EctoLibSql.State{trx_id: nil} = state) do
{:error, %RuntimeError{message: "no active transaction"}, state}
end
@impl true
@doc """
Commits the current transaction.
The state must contain a valid transaction ID. For embedded replicas with
auto-sync enabled, this will also trigger a sync to the remote database.
"""
def handle_commit(_opts, state) do
case EctoLibSql.Native.commit(
%EctoLibSql.State{conn_id: conn_id, trx_id: _trx_id, mode: mode} = state
) do
{:ok, _} ->
{:ok, %EctoLibSql.Result{}, %EctoLibSql.State{conn_id: conn_id, mode: mode}}
{:error, reason} ->
{:disconnect, reason, state}
end
end
@impl true
@doc """
Rolls back the current transaction.
Discards all changes made within the transaction and returns the connection
to autocommit mode.
"""
def handle_rollback(_opts, %EctoLibSql.State{conn_id: conn_id, trx_id: _trx_id} = state) do
case EctoLibSql.Native.rollback(state) do
{:ok, _} ->
{:ok, %EctoLibSql.Result{}, %EctoLibSql.State{conn_id: conn_id, trx_id: nil}}
{:error, reason} ->
{:disconnect, reason, state}
end
end
@impl true
@doc """
Closes the query. Currently a no-op as queries are stateless.
"""
def handle_close(_query, _opts, state) do
{:ok, %EctoLibSql.Result{}, state}
end
@impl true
@doc """
Checks the current transaction status.
"""
def handle_status(_opts, %EctoLibSql.State{conn_id: _conn_id, trx_id: trx_id} = state) do
case EctoLibSql.Native.handle_status_transaction(trx_id) do
:ok -> {:transaction, state}
{:error, message} -> {:disconnect, message, state}
end
end
@impl true
@doc """
Prepares a query for execution. Returns the query unchanged as preparation
is handled during execution.
"""
def handle_prepare(%EctoLibSql.Query{} = query, _opts, state) do
{:ok, query, state}
end
@impl true
@doc """
Checks out a connection from the pool by verifying it's still alive.
"""
def checkout(%EctoLibSql.State{conn_id: conn_id} = state) do
case EctoLibSql.Native.ping(conn_id) do
true -> {:ok, state}
{:error, reason} -> {:disconnect, reason, state}
end
end
@impl true
@doc """
Fetches the next batch of rows from a cursor.
Cursors are used for streaming large result sets without loading everything
into memory at once. Automatically deallocates the cursor when no more rows
are available.
"""
def handle_fetch(%EctoLibSql.Query{} = _query, cursor, opts, %EctoLibSql.State{} = state) do
max_rows = Keyword.get(opts, :max_rows, 500)
case EctoLibSql.Native.fetch_cursor(cursor.ref, max_rows) do
{columns, rows, _count} when is_list(rows) ->
result = %EctoLibSql.Result{
command: :select,
columns: columns,
rows: rows,
num_rows: length(rows)
}
if length(rows) == 0 do
# No more rows, deallocate cursor
:ok = EctoLibSql.Native.close(cursor.ref, :cursor_id)
{:deallocated, result, state}
else
{:cont, result, state}
end
{:error, reason} ->
{:error, reason, state}
end
end
@impl true
@doc """
Deallocates a cursor, freeing its resources.
"""
def handle_deallocate(_query, cursor, _opts, state) do
case EctoLibSql.Native.close(cursor.ref, :cursor_id) do
:ok ->
{:ok, %EctoLibSql.Result{}, state}
{:error, _reason} ->
# Cursor might already be deallocated, that's ok
{:ok, %EctoLibSql.Result{}, state}
end
end
@impl true
@doc """
Declares a cursor for streaming query results.
Cursors allow you to iterate through large result sets in chunks, which is
more memory-efficient than loading all rows at once.
"""
def handle_declare(
%EctoLibSql.Query{statement: statement} = query,
params,
_opts,
%EctoLibSql.State{conn_id: conn_id} = state
) do
case EctoLibSql.Native.declare_cursor(conn_id, statement, params) do
cursor_id when is_binary(cursor_id) ->
cursor = %{ref: cursor_id}
{:ok, query, cursor, state}
{:error, reason} ->
{:error, reason, state}
end
end
end