Ecto.Adapters.Hrana (EctoHrana v0.1.0)

Copy Markdown View Source

Ecto adapter for libSQL servers (Turso, Bunny Database, sqld), over the Hrana protocol on HTTP, in pure Elixir: no NIF, nothing to compile.

libSQL speaks SQLite's dialect, so the SQL generation, the migrations, the loaders and dumpers, and the constraint errors are ecto_sqlite3's (Ecto.Adapters.SQLite3): queries behave as they do on a local SQLite file. Only the driver, EctoHrana.Connection, is different.

Configuration

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

config :my_app, MyApp.Repo,
  uri: System.fetch_env!("LIBSQL_URL"),
  auth_token: System.fetch_env!("LIBSQL_AUTH_TOKEN"),
  pool_size: 10

Options, besides the usual repo and pool options (:pool_size, :queue_target...):

  • :uri - the server's URL (required). libsql:// URLs, as given by Turso and Bunny, are reached over HTTPS; https:// and http:// are used as they are (http:// for a local sqld);
  • :auth_token - the database's token, sent as a bearer token. Not needed by a local sqld started without authentication;
  • :timeout - how long to wait for each response, in milliseconds (default 15_000);
  • :req_options - options merged into the Req requests, e.g. to tune the HTTP connection pool or to plug a fake server in tests.

Turso

config :my_app, MyApp.Repo,
  uri: "libsql://my-db-my-org.turso.io",
  auth_token: System.fetch_env!("TURSO_AUTH_TOKEN")

Bunny Database

bunny db create --token --save-env gives the URL and a token:

config :my_app, MyApp.Repo,
  uri: System.fetch_env!("BUNNY_DATABASE_URL"),
  auth_token: System.fetch_env!("BUNNY_DATABASE_AUTH_TOKEN")

A local server

sqld, libSQL's server, runs without authentication:

docker run -p 127.0.0.1:8080:8080 ghcr.io/tursodatabase/libsql-server:latest

config :my_app, MyApp.Repo,
  uri: "http://127.0.0.1:8080"

Types

Values are stored as ecto_sqlite3 stores them, and its application environment applies, e.g. to store UUIDs as BLOBs:

config :ecto_sqlite3, uuid_type: :binary, binary_id_type: :binary

See Ecto.Adapters.SQLite3 for these options (:uuid_type, :binary_id_type, :map_type, :array_type, :datetime_type...). Binaries are sent as BLOBs, so they keep their NUL bytes.

Transactions

Outside a transaction, each statement is one HTTP request, on a stream that the request closes. A transaction keeps a stream open, from its BEGIN to its COMMIT or ROLLBACK: the statements in between are run on it. Its :mode option (:deferred, the default, :immediate or :exclusive) chooses the BEGIN.

As with any Ecto adapter, a transaction nested in another one is part of it: rolling the inner one back rolls the whole transaction back. Savepoints are used by statements run with mode: :savepoint and by Ecto.Adapters.SQL.Sandbox, which works as with other adapters.

The server closes a stream left idle for some time (seconds, depending on the server): keep transactions short.

Migrations

Migrations run as with ecto_sqlite3, each in a transaction. The database is the server's: mix ecto.create does nothing (it is reported as already up) and mix ecto.drop fails.

Performance

Each statement is an HTTP round trip to the server: against a remote server, the network latency dominates. Keep the application close to the database, avoid N+1 queries, and prefer fewer statements returning more rows. Results are decoded from JSON, which is fast even for many rows.

Use with Ash

ash_sqlite accepts this adapter, but it checks the repos' adapter against the ones it knows and warns at compile time ("Expected a repo using Ecto.Adapters.SQLite3 or Ecto.Adapters.LibSql"). The warning is harmless.

Limitations

  • streams are emulated: Repo.stream/2 fetches all the rows at once;
  • no embedded replicas (a local copy synchronised with the server), which need libSQL's native library;
  • the SQL generation comes from ecto_sqlite3's internal modules, so the ecto_sqlite3 versions this library works with are pinned.

As with ecto_sqlite3, Ecto.Adapters.SQL.query_many/4 is not supported: run the statements one by one.