Spacetimedbex

Hex.pm Hex Docs Elixir License: MIT

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

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:

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:

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