Spacetimedbex
SpacetimeDB client library for Elixir.
Connects to SpacetimeDB via the v2 BSATN binary WebSocket protocol, providing real-time subscriptions, reducer calls, a local ETS-backed client cache, an HTTP REST client, Phoenix PubSub integration, and code generation.
Features
| Module | Description |
|---|---|
Spacetimedbex.BSATN |
Binary codec — encoder, decoder, value encoder |
Spacetimedbex.Protocol |
v2 client/server message encoding and decoding |
Spacetimedbex.Connection |
WebSocket connection with auto-reconnect and backoff |
Spacetimedbex.Schema |
Schema fetcher and parser (tables, reducers, typespace) |
Spacetimedbex.ClientCache |
Standalone ETS-backed, ref-counted mirror of subscribed tables |
Spacetimedbex.Client |
High-level client with callbacks, auto-encoding, and a built-in cache |
Spacetimedbex.Types |
Identity, ConnectionId, Timestamp, TimeDuration and Uuid conversions |
Spacetimedbex.Http |
HTTP REST client for all v1 API endpoints |
Spacetimedbex.Phoenix |
Phoenix PubSub adapter for broadcasting events |
Spacetimedbex.Codegen |
Code generation from schema |
mix spacetimedb.gen |
Mix task to generate structs, reducers, and client |
Installation
# mix.exs
def deps do
[
{:spacetimedbex, "~> 0.2.0"}
]
end
Documentation | Hex | GitHub
Quick Start
High-Level Client (recommended)
Define a client module with callbacks:
defmodule MyApp.SpaceClient do
use Spacetimedbex.Client
def config do
%{
host: "localhost:3000",
database: "my_db",
subscriptions: ["SELECT * FROM users"]
}
end
def on_connect(_identity, _conn_id, token, state) do
{:ok, Map.put(state, :token, token)}
end
def on_insert("users", row, state) do
IO.puts("New user: #{inspect(row)}")
{:ok, state}
end
def on_update("users", old_row, new_row, state) do
IO.puts("Updated: #{inspect(old_row)} → #{inspect(new_row)}")
{:ok, state}
end
def on_delete("users", row, state) do
IO.puts("Removed: #{inspect(row)}")
{:ok, state}
end
end
For TLS (e.g. Maincloud), prefix the host with a scheme: host: "https://maincloud.spacetimedb.com"
uses https:// for HTTP and wss:// for the WebSocket. A bare host:port uses plain HTTP/WS.
Start it and interact:
{:ok, pid} = Spacetimedbex.Client.start_link(MyApp.SpaceClient, %{})
# Call a reducer (auto-encodes args via schema). The request id matches
# the request_id later passed to on_reducer_result/3.
{:ok, request_id} =
Spacetimedbex.Client.call_reducer(pid, "create_user", %{"name" => "Alice", "age" => 30})
# Query the local cache
Spacetimedbex.Client.get_all(pid, "users")
Spacetimedbex.Client.find(pid, "users", 1)
# One-off SQL query via WebSocket (result arrives in on_query_result/3)
{:ok, request_id} = Spacetimedbex.Client.query(pid, "SELECT * FROM users WHERE age > 25")
# Add and remove subscriptions at runtime
{:ok, query_set_id} = Spacetimedbex.Client.subscribe(pid, ["SELECT * FROM messages"])
:ok = Spacetimedbex.Client.unsubscribe(pid, query_set_id)
The :subscriptions from config/0 form query set 1. Every active query set is re-sent
after an automatic reconnect with the same id. Rows matched by several queries are
ref-counted in the cache, so row callbacks fire once per row entering or leaving it.
# List active query sets
Spacetimedbex.Client.subscriptions(pid)
#=> %{1 => ["SELECT * FROM users"], 2 => ["SELECT * FROM messages"]}
Client Callbacks
All callbacks are optional except config/0:
| Callback | When it fires |
|---|---|
on_connect(identity, conn_id, token, state) |
Connection (re)established; identity/conn_id are hex strings |
on_subscribe_applied(table, rows, state) |
Subscription data arrives |
on_subscription_error(query_set_id, error, state) |
Server rejected or dropped a query set |
on_insert(table, row, state) |
Row inserted (also fires for event-table rows, which are never cached) |
on_delete(table, row, state) |
Row deleted |
on_update(table, old_row, new_row, state) |
Row replaced (same PK deleted + inserted). If not implemented, on_delete + on_insert fire instead |
on_transaction(changes, state) |
Transaction's effective changes — return {:ok, state, :skip_row_callbacks} to suppress per-row callbacks |
on_reducer_result(request_id, result, state) |
Reducer completes |
on_unsubscribe_applied(query_set_id, rows, state) |
Unsubscribe completes |
on_query_result(request_id, result, state) |
One-off query result arrives |
on_procedure_result(request_id, status, state) |
Procedure completes (call_procedure_raw/3) |
on_disconnect(reason, state) |
Disconnected — the cache is cleared and repopulated when the auto-reconnect resubscribes |
Decoded values
Rows are maps with string keys. Options decode to {:some, value} or nil; sums (enums)
decode to {"Variant", payload} (unit variants have payload %{}). When encoding reducer
arguments, sums also accept {:Variant, payload} or a bare "Variant"/:Variant for unit
variants; integers are range-checked against their column type.
SpacetimeDB's special types decode to Elixir-friendly values (see Spacetimedbex.Types):
| SpacetimeDB | Elixir |
|---|---|
Identity |
64-char lowercase hex string — the same form the CLI and HTTP API use |
ConnectionId |
32-char lowercase hex string |
Timestamp |
DateTime (UTC, microsecond precision) |
TimeDuration |
Duration |
Uuid |
"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" |
Encoding accepts these forms as well as raw integers.
Code Generation
Generate typed structs, reducer functions, and a client skeleton from a live database:
mix spacetimedb.gen \
--host localhost:3000 \
--database my_db \
--module MyApp.SpacetimeDB \
--output lib
Produces:
MyApp.SpacetimeDB.Tables.TableName—defstruct+@type t+from_row/1MyApp.SpacetimeDB.Reducers— typed functions with@specMyApp.SpacetimeDB.Client—use Spacetimedbex.Clientskeleton with config
HTTP REST Client
For operations that don't need a persistent WebSocket (identity management, database admin, ad-hoc SQL):
alias Spacetimedbex.Http
# Identity
{:ok, %{"identity" => id, "token" => token}} = Http.create_identity("localhost:3000")
# SQL query
{:ok, results} = Http.sql("localhost:3000", "my_db", "SELECT * FROM users", token)
# Call a reducer over HTTP
:ok = Http.call_reducer("localhost:3000", "my_db", "create_user", ["Alice", 30], token)
# Database management
{:ok, _} = Http.publish_database("localhost:3000", "my_db", wasm_binary, token)
{:ok, info} = Http.get_database("localhost:3000", "my_db")
Low-Level Connection
For full control over the WebSocket connection:
{:ok, conn} = Spacetimedbex.Connection.start_link(
host: "localhost:3000",
database: "my_db",
handler: self()
)
# Messages arrive as {:spacetimedb, msg} tuples
receive do
{:spacetimedb, {:identity, identity, conn_id, token}} -> :connected
end
Spacetimedbex.Connection.subscribe(conn, ["SELECT * FROM users"])
Spacetimedbex.Connection.call_reducer(conn, "create_user", bsatn_args)
Architecture
BSATN Codec
Binary SpacetimeDB Algebraic Type Notation — a compact little-endian binary format:
- Integers:
u8..u256,i8..i256(little-endian) - Floats:
f32,f64(IEEE 754, little-endian) - Strings/Bytes:
u32length prefix + raw data (UTF-8 validated) - Arrays:
u32count prefix + concatenated elements - Products (structs): fields concatenated in order
- Sums (enums):
u8variant tag + payload
Protocol (v2)
Tested against SpacetimeDB 2.10.1. The v2 wire format is unchanged since 2.0.
Client sends: Subscribe, Unsubscribe, OneOffQuery, CallReducer, CallProcedure.
Server sends (with 1-byte compression envelope): InitialConnection, SubscribeApplied, UnsubscribeApplied, SubscriptionError, TransactionUpdate, OneOffQueryResult, ReducerResult, ProcedureResult.
OTP Design
Client (GenServer) — callbacks, request/query-set ids, owns the ETS row cache
└── Connection (WebSockex) — WebSocket with auto-reconnect
ClientCache (GenServer) — the same ref-counted cache, for use with a raw Connection
Schema — HTTP schema fetch + parse
Development
mix deps.get # Install dependencies
just test # Unit tests (no server needed)
just test-all # All tests (requires SpacetimeDB with the test_module published;
# set SPACETIMEDB_HOST to override localhost:3000)
just check # Compile (strict) + test + credo
just shell # iex -S mix
See justfile for all available commands.
License
MIT