EctoSediment

Ecto adapter for Sediment, the SQLite-compatible database for Elixir on the Turso engine (in-process, written in Rust), with optional S3-backed durability. Uses sediment as the driver. Both are experimental: expect rough edges, and keep backups.

Naming

Why not ecto_turso? See the Naming section in Sediment's README: we love Turso and don't want to ride on their name, and this project is experimental, so its integration mistakes are ours, not Turso's. Turso Cloud doesn't work with it right now.

Not affiliated with Turso. Sediment is an independent, community-maintained Elixir library built on the open-source Turso database engine (turso_core, MIT). "Turso" is a trademark of its respective owner and is used here only to describe compatibility. This project is not endorsed by, sponsored by, or otherwise connected with Turso or its company.

Provided under the MIT License, without warranty of any kind. The underlying engine is pre-1.0; keep independent backups of any data you care about.

ecto_sediment is a port of ecto_sqlite3: the options, generated SQL, types and migrations are the same, so switching an application over is usually a one-line change of the adapter module. Where Turso behaves differently the difference is listed below, and Turso-only features (vectors, concurrent transactions, S3-backed databases, encryption) are exposed as extensions.

Guides: Getting started, S3-backed repos (configuration, releases, deploys with the single-writer lease), Migrating from ecto_sqlite3, Multi-tenant apps (one database per tenant).

Contents: Installation · Usage · Migrating from ecto_sqlite3 · Turso extensions (vectors, full-text search, concurrent transactions, encryption, S3-backed databases) · Oban · Type Extensions · ecto_sqlite3 parity · Differences from ecto_sqlite3 · Benchmarks · Running Tests

Installation

defp deps do
[
{:ecto_sediment, "~> 0.1"}
]
end

ecto_sediment brings in sediment, the driver, and needs Elixir 1.18+ (OTP 27+). The driver's NIF is downloaded precompiled for Linux (glibc and musl, x86_64 and aarch64), macOS (Apple silicon and Intel) and Windows (x86_64), so no Rust toolchain is needed (on Linux, glibc 2.28 or later, or musl). On other targets, or to build from source anyway, set SEDIMENT_BUILD=1 and install Rust 1.91 or later; a git or path dependency on sediment always builds from source. See sediment's README.

With Igniter

In a project that uses Igniter, the installer adds the dependency, creates MyApp.Repo, adds it to the supervision tree and configures it the way Phoenix configures ecto_sqlite3 repos (a local file in dev and test with the SQL sandbox, DATABASE_PATH in config/runtime.exs for prod):

mix igniter.install ecto_sediment
mix igniter.install ecto_sediment --s3 # prod repo backed by S3 (env vars), integer primary keys

If ecto_sediment is already a dependency, run mix ecto_sediment.install (options --repo MyApp.OtherRepo, --s3).

Usage

Define your repo similar to this.

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

Configure your repository similar to the following. See Ecto.Adapters.Sediment for all available options.

config :my_app,
ecto_repos: [MyApp.Repo]
config :my_app, MyApp.Repo,
database: "path/to/my/database.db"

Migrating from ecto_sqlite3

Swap the dependency and the adapter module, move the config :ecto_sqlite3 settings to config :ecto_sediment, and rename the type extension behaviours; existing SQLite database files open directly. The steps and the repo options that behave differently are in the migration guide.

Turso extensions

Vectors

Turso has native vector support. Ecto.Adapters.Sediment.Vector (32-bit floats) and Ecto.Adapters.Sediment.Vector64 (64-bit floats) are Ecto types that store lists of numbers as Turso vector blobs, and Ecto.Adapters.Sediment.Vector.Query has macros for Turso's distance functions.

# migration
create table(:documents) do
add :content, :string
add :embedding, :vector32, size: 3 # F32_BLOB(3); :vector64 -> F64_BLOB(n)
end
# schema
schema "documents" do
field :content, :string
field :embedding, Ecto.Adapters.Sediment.Vector
end
# query
import Ecto.Adapters.Sediment.Vector.Query
Repo.all(
from d in Document,
order_by: vector_distance_cos(d.embedding, ^[0.1, 0.2, 0.3]),
limit: 5
)

Available macros: vector_distance_cos/2, vector_distance_l2/2, vector_distance_dot/2, vector_distance_jaccard/2 and vector_extract/1. Pinned lists are converted to vector32 blobs automatically.

Turso's full-text search (Tantivy-based, not SQLite FTS5) is an experimental feature, enabled with experimental: [:index_method] in the repo config:

# migration
create index(:articles, [:title, :body], using: :fts,
options: "weights = 'title=2.0,body=1.0'")
# queries
import Ecto.Adapters.Sediment.FTS.Query
from a in Article,
where: fts_match([a.title, a.body], ^term),
select: {a.id, fts_highlight(a.title, "<b>", "</b>", ^term)}
# ranked results, best first
Ecto.Adapters.Sediment.FTS.search(Repo, Article, [:title, :body], term, limit: 10)
#=> [{%Article{}, 1.37}, ...]

Turso only computes fts_score through the index when it shares its query expression with fts_match, which an Ecto query with a pinned term can't express (each ^term is a separate parameter), so use Ecto.Adapters.Sediment.FTS.search/5 for ranking. See Ecto.Adapters.Sediment.FTS.

Concurrent transactions

In MVCC journal mode Turso supports BEGIN CONCURRENT, where write transactions only conflict when they touch the same rows:

config :my_app, MyApp.Repo,
database: "path/to/my/database.db",
journal_mode: :mvcc,
default_transaction_mode: :concurrent # or Repo.transaction(fun, mode: :concurrent)

Choose :mvcc when the database is created. An existing :wal database with AUTOINCREMENT tables (Ecto's default primary keys) can't be switched to it: turso_core 0.8.1 would then reuse ids and silently overwrite existing rows, so the repo refuses to connect ("refusing to switch to MVCC: ...") and leaves the database untouched. To move such a database to MVCC, copy its data into a new MVCC database.

The loser of a write-write conflict gets a Sediment.Error with the message "Write-write conflict" and is rolled back; a commit that can't get its turn fails with "Database busy", and a transaction that overlaps another connection's DDL (a migration) can fail with "Database schema changed". All three are safe to retry, since nothing of the transaction was written:

def transaction_with_retry(fun, attempts \\ 10) do
MyApp.Repo.transaction(fun)
rescue
error in Sediment.Error ->
if attempts > 1 and error.message =~ ~r/^(Write-write conflict|Database busy|Database schema changed)/ do
transaction_with_retry(fun, attempts - 1)
else
reraise error, __STACKTRACE__
end
end

Run the whole read-modify-write inside fun, so a retry reads fresh values.

Ecto runs each migration in a transaction. Turso doesn't allow DDL inside BEGIN CONCURRENT, so when the default mode is :concurrent and a transaction's first statement is DDL (as in a normal migration), sediment begins it as BEGIN IMMEDIATE instead. Migrations therefore work unchanged and stay atomic. The exception is a migration that writes data before its first DDL statement (e.g. repo().insert_all/2 at the top of up/0; remember that create/alter/execute are queued until the end of the migration). It fails with DDL statements require an exclusive transaction and is rolled back. Run such migrations with another mode:

Ecto.Migrator.with_repo(MyApp.Repo, &Ecto.Migrator.run(&1, :up, all: true),
default_transaction_mode: :immediate)

or put the DDL first and call flush() before the data changes. A transaction started explicitly with mode: :concurrent always uses BEGIN CONCURRENT.

Two more things to know in MVCC mode: prefer integer primary keys (migration_primary_key: [type: :integer], see "Differences" below), and avoid running migrations while concurrent writers are active: turso_core can panic in that situation. sediment contains the panic (the statement fails with "internal turso error: ..." and that pool connection is replaced), but the migration or write fails.

Encryption

config :my_app, MyApp.Repo,
database: "path/to/my/database.db",
encryption: [cipher: "aegis256", key: "<64 hex characters>"]

S3-backed databases

With :s3, an S3 bucket/prefix is the durable state of record and the local file is a working copy, restored from S3 when the repo starts. The S3 guide covers production configuration, releases, deploys with the single-writer lease, errors and restores.

config :my_app, MyApp.Repo,
database: "/var/lib/my_app/app.db",
encryption: [cipher: "aegis256", key: System.fetch_env!("DATABASE_ENCRYPTION_KEY")],
s3: [
bucket: "my-bucket",
prefix: "prod/app",
region: "eu-central-1",
access_key_id: System.get_env("AWS_ACCESS_KEY_ID"),
secret_access_key: System.get_env("AWS_SECRET_ACCESS_KEY")
# endpoint: "http://127.0.0.1:8333" for S3-compatible servers
]

A runnable walkthrough (write, kill -9, wipe the local copy, restore) is in examples/s3_demo (see its README).

Oban

Oban works on ecto_sediment with Oban.Engines.Lite, the engine for SQLite. It needs Oban 2.24.0 or later: Oban 2.23 fails to start with an adapter it doesn't know (it calls repo().config() at boot) whenever :testing isn't :disabled. Oban recognizes adapters by module name, so tell it which migrations to use:

config :my_app, MyApp.Repo,
database: "path/to/my/database.db",
migrator: Oban.Migrations.SQLite
config :my_app, Oban,
repo: MyApp.Repo,
engine: Oban.Engines.Lite,
queues: [default: 10]

and add a migration that calls Oban.Migration.up() / Oban.Migration.down() as usual (mix oban.install doesn't recognize the adapter; write the config and migration by hand).

test/ecto/integration/oban_test.exs runs job insertion, execution, retries with backoff, discarding after max_attempts, scheduled jobs, unique jobs, cancellation, the Pruner plugin and two Oban instances draining the same queue (every job runs exactly once) in WAL, MVCC with BEGIN CONCURRENT, and S3 mode. With an S3-backed repo every job state change is a commit; with the default async durability those don't wait for S3, so job throughput is about that of a local database. With durability: :sync each one waits for an S3 upload: expect tens of jobs per second per node (see the S3 guide).

Type Extensions

Type extensions allow custom data types to be stored and retrieved from a Turso database.

This is done by implementing a module with the Ecto.Adapters.Sediment.TypeExtension behaviour which maps types to encoder and decoder functions. Type extensions are activated by adding them, as a list of modules under the type_extensions key, to both the sediment configuration (which encodes query parameters) and the ecto_sediment configuration:

config :sediment,
type_extensions: [MyApp.TypeExtension]
config :ecto_sediment,
type_extensions: [MyApp.TypeExtension]

Extensions are asked in order and the first one that doesn't return nil wins. Loader functions are also called for NULL values, so they must accept nil.

ecto_sqlite3 parity

Every Ecto.Adapters.SQLite3 option and feature, and its status in Ecto.Adapters.Sediment. test/ecto/integration/options_test.exs sets every repo option.

Repo options

Option Status
:database (incl. :memory / ":memory:") Same
:pool_size Same (default 5)
:default_transaction_mode Same, plus :concurrent (BEGIN CONCURRENT, MVCC only)
:journal_mode :wal (default) as in ecto_sqlite3; :mvcc added (for new databases: an existing database with AUTOINCREMENT tables can't switch to it, see "Concurrent transactions"); :delete, :truncate, :persist, :memory, :off accepted but have no effect
:temp_store Same (default :memory)
:synchronous Same (default :normal)
:foreign_keys Same (default :on)
:cache_size Same (default -64000)
:cache_spill Same
:busy_timeout Same (default 2000; 15000 for S3 repos)
:auto_vacuum :none same; :full/:incremental need experimental: [:autovacuum]
:case_sensitive_like Accepted, no effect (LIKE is ASCII case-insensitive)
:locking_mode Accepted, not applied (Turso locks the file per process)
:secure_delete Accepted, no effect
:wal_auto_check_point Accepted, no effect
:load_extensions Not supported: the repo fails to connect (Turso can't load SQLite extensions)
:key (SQLCipher) Not supported: use :encryption (which also works for S3-backed repos)
:binary_id_type, :uuid_type, :map_type, :array_type, :datetime_type Same, under config :ecto_sediment (JSONB works for :binary map/array types)
:type_extensions, :json_library app config Same, under config :ecto_sediment
New: :mvcc_checkpoint_threshold Defaults to 256 KiB for MVCC repos without :s3 (turso_core's ~4 MB default slows MVCC commits down, see bench/RESULTS.md); nil restores Turso's default
New: :encryption, :s3, :experimental, :custom_pragmas Turso extensions, see above and Sediment.Connection.connect/1

Features

Feature Status
SQL generation (queries, update_all, delete_all, CTEs, windows, unions, values/2, JSON paths) Same SQL, same unit tests
Migrations (tables, indexes, column adds/renames, check constraints on columns) Same; ALTER COLUMN, ALTER TABLE ADD/DROP CONSTRAINT, table prefixes unsupported as in ecto_sqlite3. New: using: :fts indexes
Upserts (on_conflict, conflict_target) Same, except on_conflict: :replace_all including the primary key fails (Turso engine bug, see "Differences from ecto_sqlite3" below)
Constraint errors → changeset errors Same (unique, check; foreign keys without a name)
Transactions, savepoints, Repo.transact/2, Repo.stream/2 Same
Ecto.Adapters.SQL.Sandbox Same (no async tests, as in ecto_sqlite3), verified in WAL, MVCC and S3 modes
storage_up/1, storage_down/1, storage_status/1 Same; storage_down/1 also removes -tshm and .db-log; S3 semantics documented above
structure_dump/2, structure_load/2, dump_cmd/3 Same results without the sqlite3 executable; dump_cmd/3 supports SQL and .schema only
Type extensions (Ecto.Adapters.Sediment.TypeExtension) Same
DELETE with joins, row locks (lock:) Not supported, as in ecto_sqlite3
LIKE on BLOB columns Different: Turso matches them
Views Same; INSTEAD OF triggers on views are unsupported, materialized views need experimental: [:views]
ecto/ecto_sql integration suites Run in WAL, MVCC, MVCC + :concurrent, encrypted, S3, S3 + group commit and encrypted S3 modes; the same exclusions as ecto_sqlite3 minus six tests Turso passes, plus two for the replace_all engine bug

Differences from ecto_sqlite3

Benchmarks

bench/ compares ecto_sediment (WAL, MVCC, MVCC+S3) with ecto_sqlite3; see bench/RESULTS.md. Reads (point lookups and bulk loads) and multi-statement transactions are faster than ecto_sqlite3, single-row inserts in WAL mode somewhat slower; MVCC with integer primary keys is the fastest mode for writes, and MVCC benefits from a lower checkpoint threshold, which ecto_sediment therefore uses by default (mvcc_checkpoint_threshold: 262_144). S3 repos with the default async durability write at about the speed of local MVCC (0.13 ms per insert against a local SeaweedFS); durability: :sync adds an S3 round trip to every commit.

Running Tests

mix test # unit and adapter integration tests (S3 ones need SeaweedFS on :8333)
mix ci # everything CI runs: the ecto/ecto_sql suites in all modes, linters

CONTRIBUTING.md describes the setup (Sediment checked out next to this repository, SeaweedFS 4.48 for the S3 tests), the test tags and the other S3 servers.

Publishing to Hex

Releases are published by CI from a GitHub release; see RELEASING.md.

Acknowledgements

License

MIT, see LICENSE (which keeps ecto_sqlite3's copyright notice).