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/adapters/libsql.ex
defmodule Ecto.Adapters.LibSql do
@moduledoc """
Ecto adapter for LibSQL and Turso databases.
This adapter provides full Ecto support for LibSQL databases, including
local SQLite files, remote Turso cloud databases, and embedded replicas
that sync between local and remote.
## Connection Modes
The adapter automatically detects the connection mode based on configuration:
- **Local**: Only `:database` specified - uses local SQLite file
- **Remote**: `:uri` and `:auth_token` specified - connects directly to Turso
- **Remote Replica**: All of `:database`, `:uri`, `:auth_token`, and `:sync` specified -
maintains local copy with automatic sync to remote
## Configuration Examples
### Local Database
config :my_app, MyApp.Repo,
adapter: Ecto.Adapters.LibSql,
database: "my_app.db"
### Remote Turso Database
config :my_app, MyApp.Repo,
adapter: Ecto.Adapters.LibSql,
uri: "libsql://your-database.turso.io",
auth_token: "your-auth-token"
### Embedded Replica (Local + Remote Sync)
config :my_app, MyApp.Repo,
adapter: Ecto.Adapters.LibSql,
database: "replica.db",
uri: "libsql://your-database.turso.io",
auth_token: "your-auth-token",
sync: true
### With Encryption
config :my_app, MyApp.Repo,
adapter: Ecto.Adapters.LibSql,
database: "encrypted.db",
encryption_key: "your-secret-key-must-be-at-least-32-characters"
## Configuration 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, default: `true` when in replica mode)
- `:encryption_key` - Encryption key for local database (minimum 32 characters)
## Features
- Full Ecto query support (schemas, changesets, associations, etc.)
- Migration support with DDL transactions
- SQLite-compatible data types with Ecto type conversions
- Constraint violation detection and error handling
- Storage management (`mix ecto.create`, `mix ecto.drop`, etc.)
- Structure dump/load support
## Limitations
- No advisory locking for migrations (SQLite uses database-level locking)
- `Repo.stream/2` is not yet implemented (use DBConnection cursor interface instead)
- Some advanced PostgreSQL/MySQL features may not be available
- Vector search requires LibSQL-specific syntax
"""
use Ecto.Adapters.SQL,
driver: :ecto_libsql,
migration_lock: nil
@behaviour Ecto.Adapter.Storage
@behaviour Ecto.Adapter.Structure
## Adapter Configuration
@impl Ecto.Adapter
defmacro __before_compile__(_env), do: :ok
@doc false
def connection, do: Ecto.Adapters.LibSql.Connection
@impl Ecto.Adapter.Schema
def autogenerate(:id), do: nil
def autogenerate(:binary_id), do: Ecto.UUID.generate()
def autogenerate(:embed_id), do: Ecto.UUID.generate()
## Storage API
@impl Ecto.Adapter.Storage
def storage_up(opts) do
# For remote-only mode (no local database), storage is managed by Turso
if Keyword.has_key?(opts, :uri) && !Keyword.has_key?(opts, :database) do
{:error, :already_up}
else
database = Keyword.fetch!(opts, :database)
# For local or replica mode, create the database file
case File.exists?(database) do
true ->
{:error, :already_up}
false ->
# Connect to create the database
case EctoLibSql.connect(opts) do
{:ok, state} ->
EctoLibSql.disconnect([], state)
:ok
{:error, reason} ->
{:error, reason}
end
end
end
end
@impl Ecto.Adapter.Storage
def storage_down(opts) do
database = Keyword.get(opts, :database)
# For remote-only mode, can't drop remote storage
if is_nil(database) do
{:error, :not_supported}
else
case File.rm(database) do
:ok -> :ok
{:error, :enoent} -> {:error, :already_down}
{:error, reason} -> {:error, reason}
end
end
end
@impl Ecto.Adapter.Storage
def storage_status(opts) do
database = Keyword.get(opts, :database)
# For remote-only mode
if is_nil(database) do
:up
else
if File.exists?(database), do: :up, else: :down
end
end
## Structure API
@impl Ecto.Adapter.Structure
def structure_dump(default, config) do
path = config[:dump_path] || Path.join(default, "structure.sql")
database = Keyword.fetch!(config, :database)
File.mkdir_p!(Path.dirname(path))
case System.cmd("sqlite3", [database, ".schema"]) do
{output, 0} ->
File.write!(path, output)
{:ok, path}
{output, _} ->
{:error, output}
end
end
@impl Ecto.Adapter.Structure
def structure_load(default, config) do
path = config[:dump_path] || Path.join(default, "structure.sql")
_database = Keyword.fetch!(config, :database)
case File.read(path) do
{:ok, sql} ->
# Connect and execute the schema
{:ok, state} = EctoLibSql.connect(config)
{:ok, _result, _state} = EctoLibSql.handle_execute(sql, [], [], state)
EctoLibSql.disconnect([], state)
{:ok, path}
{:error, reason} ->
{:error, reason}
end
end
@impl Ecto.Adapter.Structure
def dump_cmd(_args, _opts, config) do
database = Keyword.fetch!(config, :database)
path = config[:dump_path] || "structure.sql"
{:ok, ["sqlite3", database, ".schema"], [path]}
end
## Migration API
@impl Ecto.Adapter.Migration
def supports_ddl_transaction?, do: true
@impl Ecto.Adapter.Migration
def lock_for_migrations(_meta, _opts, fun) do
# SQLite uses database-level locking, so we just execute the function
fun.()
end
## Connection Helpers
@doc false
def loaders(:boolean, type), do: [&bool_decode/1, type]
def loaders(:binary_id, type), do: [type]
def loaders(:utc_datetime, type), do: [&datetime_decode/1, type]
def loaders(:naive_datetime, type), do: [&datetime_decode/1, type]
def loaders(:date, type), do: [&date_decode/1, type]
def loaders(:time, type), do: [&time_decode/1, type]
def loaders(:decimal, type), do: [&decimal_decode/1, type]
def loaders(_primitive, type), do: [type]
defp bool_decode(0), do: {:ok, false}
defp bool_decode(1), do: {:ok, true}
defp bool_decode(x), do: {:ok, x}
defp datetime_decode(value) when is_binary(value) do
case NaiveDateTime.from_iso8601(value) do
{:ok, datetime} -> {:ok, datetime}
{:error, _} -> :error
end
end
defp datetime_decode(value), do: {:ok, value}
defp date_decode(value) when is_binary(value) do
case Date.from_iso8601(value) do
{:ok, date} -> {:ok, date}
{:error, _} -> :error
end
end
defp date_decode(value), do: {:ok, value}
defp time_decode(value) when is_binary(value) do
case Time.from_iso8601(value) do
{:ok, time} -> {:ok, time}
{:error, _} -> :error
end
end
defp time_decode(value), do: {:ok, value}
defp decimal_decode(value) when is_binary(value) do
case Decimal.parse(value) do
{decimal, ""} -> {:ok, decimal}
_ -> :error
end
end
defp decimal_decode(value) when is_integer(value) do
{:ok, Decimal.new(value)}
end
defp decimal_decode(value) when is_float(value) do
{:ok, Decimal.from_float(value)}
end
defp decimal_decode(value), do: {:ok, value}
@doc false
def dumpers(:binary, type), do: [type]
def dumpers(:binary_id, type), do: [type]
def dumpers(:boolean, type), do: [type, &bool_encode/1]
def dumpers(:utc_datetime, type), do: [type, &datetime_encode/1]
def dumpers(:naive_datetime, type), do: [type, &datetime_encode/1]
def dumpers(:date, type), do: [type, &date_encode/1]
def dumpers(:time, type), do: [type, &time_encode/1]
def dumpers(:decimal, type), do: [type, &decimal_encode/1]
def dumpers(_primitive, type), do: [type]
defp bool_encode(false), do: {:ok, 0}
defp bool_encode(true), do: {:ok, 1}
defp datetime_encode(%NaiveDateTime{} = datetime) do
{:ok, NaiveDateTime.to_iso8601(datetime)}
end
defp date_encode(%Date{} = date) do
{:ok, Date.to_iso8601(date)}
end
defp time_encode(%Time{} = time) do
{:ok, Time.to_iso8601(time)}
end
defp decimal_encode(%Decimal{} = decimal) do
{:ok, Decimal.to_string(decimal)}
end
end