A Hrana (libSQL) protocol server and client for Elixir — speak libSQL's wire protocol from any Plug app, backed by the SQLite engine of your choice, and drive other Hrana servers with a client built on the same codec.
Hrana is the wire protocol spoken by libSQL clients: the official libSQL SDKs,
django-libsql,
sqlalchemy-libsql, and
friends. Filo implements the server side, so any SQLite-backed Elixir app can
accept those clients over the network — with no changes on the client.
Filo is engine-agnostic: it owns the protocol (the value codec, statement and
batch decoding, cursors, and baton-pinned streams) and calls back into a
Filo.Executor you provide to actually run SQL. Bring exqlite, a libSQL/Turso
connection, or anything that speaks SQLite.
Status: early, built test-first.
Features
- Both transports libSQL clients use, from one Plug:
- HTTP — Hrana 1 (stateless
execute/batch), Hrana 2/3 pipelines, and Hrana 3 cursors, in JSON and Protobuf. - WebSocket — Hrana 2/3 (and
hrana3-protobuf) — the pathdjango-libsqluses.
- HTTP — Hrana 1 (stateless
- Ships a
Plugand aWebSockhandler, nothing else. Mount them in any Plug or Phoenix app; you bring the HTTP server (Bandit/Cowboy). - Stateful streams. A stream owns one connection for its life, so transactions and temp tables persist across requests. HTTP streams are pinned by signed batons; WebSocket streams by a client-allocated id.
- Engine-agnostic via a small
Filo.Executorbehaviour —open,execute,autocommit?,close(plus optionaldescribeandsequence). - An
:authorizeseam gating every stream open, for both transports. It sees the WebSockethelloJWT and the HTTPAuthorizationheader, so per-tenant auth works on the path where clients send no upgrade header. May return a host-opaque context that Filo threads intoExecutor.open/2. - A matching client (
Filo.Client) that reuses the server's own codec, so there is no second implementation of the wire format to drift. It transparently resumes a dropped stream with the same baton, so an idle connection recycled by a load balancer does not abandon a held transaction. - Minimal footprint. Only
plug,jason, and thewebsockbehaviour are runtime deps; the server and SQLite engine are the host's choice.
How it works
libSQL client ──Hrana──▶ Filo.Plug / Filo.Socket ──▶ Filo.Executor ──▶ your SQLite/libSQL
(SDK, django-libsql, …) protocol: streams, batons, your SQL
cursors, value codec
▲
same codec, other direction
│
Filo.Client ──Hrana──▶ any Hrana serverFilo decodes the wire, manages stream lifecycles and batons, and hands each
statement to your executor. Your executor only ever sees a Filo.Stmt going in and
returns a Filo.StmtResult or a Filo.Error.
Usage
Keep a Filo.Streams supervisor in your tree, implement a Filo.Executor, and run
Filo.Plug behind an HTTP server (Bandit gives you the WebSocket upgrade for free).
1. Implement an executor (here with exqlite):
defmodule MyApp.SqliteExecutor do
@behaviour Filo.Executor
@impl true
def open(db_name) do
case Exqlite.Sqlite3.open("#{db_name}.db") do
{:ok, conn} -> {:ok, conn}
{:error, reason} -> {:error, %Filo.Error{message: to_string(reason), code: "FILO_OPEN"}}
end
end
@impl true
def execute(conn, %Filo.Stmt{sql: sql, args: args}) do
# run sql/args on conn, then map rows -> %Filo.StmtResult{} (or -> %Filo.Error{})
end
@impl true
def autocommit?(_conn), do: true
@impl true
def close(conn), do: Exqlite.Sqlite3.close(conn)
end2. Add the streams registry and the listener to your supervision tree:
children = [
{Filo.Streams, name: MyApp.Streams},
{Bandit,
scheme: :http,
port: 8080,
plug:
{Filo.Plug,
[
executor: MyApp.SqliteExecutor,
streams: MyApp.Streams,
key: Filo.Baton.new_key(), # keep stable for the server's life
open_arg: fn conn -> conn.host end # e.g. pick a database from the request
]}}
]3. Point any libSQL client at it:
# django-libsql (over WebSocket)
DATABASES = {"default": {"ENGINE": "django_libsql.libsql", "NAME": "ws://localhost:8080"}}See the Filo.Plug (options) and Filo.Executor (callbacks) module docs for the
full reference.
Gating stream opens (:authorize)
Pass an :authorize callback to gate every stream open, on both transports. This is
a Filo seam rather than a plug because WebSocket clients send no upgrade header —
the credential arrives in the Hrana hello frame, which only Filo sees:
{Filo.Plug,
[
executor: MyApp.SqliteExecutor,
streams: MyApp.Streams,
key: Filo.Baton.new_key(),
open_arg: fn conn -> conn.host end,
authorize: fn token, open_arg ->
case MyApp.Auth.verify(token, open_arg) do
# a bare :ok admits the stream; {:ok, context} also threads a
# host-opaque value into Executor.open/2 for this connection
{:ok, claims} -> {:ok, claims}
:error -> {:error, %Filo.Error{message: "unauthorized", code: "UNAUTHORIZED"}}
end
end
]}open/2 is optional — an executor that only implements open/1 keeps working and
the context is dropped.
Driving a Hrana server (Filo.Client)
One client is one Hrana stream over one owned connection: the first request opens
it, and each response carries a baton threaded into the next, so a transaction is a
burst of execute/3 on a held connection.
{:ok, c} = Filo.Client.connect("http://localhost:8080", authority: "acme.example")
{:ok, _res, c} = Filo.Client.execute(c, "INSERT INTO t VALUES (?, ?)", [1, "x"])
{:ok, res, c} = Filo.Client.execute(c, "SELECT a, b FROM t WHERE a = ?", [1])
res.rows #=> [[1, "x"]]
:ok = Filo.Client.close(c)The struct is immutable — thread it through your own process and hold one per
concurrent stream (on the BEAM that is one cheap process each). If the connection
drops, the client reconnects and resumes the same stream with its baton rather
than abandoning a held transaction. HTTP transport is a Filo.Client.Transport
behaviour: the default Filo.Client.Transport.Mint needs the optional :mint dep,
and a host that prefers Finch/Req/:gun passes its own :transport.
Scope today is the Hrana 2/3 HTTP JSON pipeline — enough to drive a database end
to end. See the Filo.Client module docs for the rest.
Supported clients
django-libsql (WebSocket) and libsql-client / libsql-experimental / the
official libSQL SDKs (HTTP) all work end to end. Filo is exercised against real
clients in its integration suite.
Development
mix deps.get
mix test # unit + protocol tests
mix precommit # compile --warnings-as-errors, format check, unused-deps check, test
Integration tests drive real libSQL clients against Filo over a live server and
need external setup (a Python with libsql-client), so they are excluded by
default:
mix test --include integration
CI runs mix precommit on Elixir 1.20 across OTP 27, 28, and 29.
License
MIT