Current section

Files

Jump to
spacetimedbex README.md
Raw

README.md

# Spacetimedbex

[![Hex.pm](https://img.shields.io/hexpm/v/spacetimedbex.svg)](https://hex.pm/packages/spacetimedbex)
[![Hex Docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/spacetimedbex)
[![Elixir](https://img.shields.io/badge/elixir-~%3E_1.19-blueviolet)](https://elixir-lang.org)
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)

SpacetimeDB client library for Elixir.

Connects to [SpacetimeDB](https://spacetimedb.com) 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

```elixir
# mix.exs
def deps do
  [
    {:spacetimedbex, "~> 0.2.0"}
  ]
end
```

[Documentation](https://hexdocs.pm/spacetimedbex) | [Hex](https://hex.pm/packages/spacetimedbex) | [GitHub](https://github.com/phiat/spacetimedbex)

## Quick Start

### High-Level Client (recommended)

Define a client module with callbacks:

```elixir
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:

```elixir
{: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.

```elixir
# 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:

```bash
mix spacetimedb.gen \
  --host localhost:3000 \
  --database my_db \
  --module MyApp.SpacetimeDB \
  --output lib
```

Produces:
- `MyApp.SpacetimeDB.Tables.TableName` — `defstruct` + `@type t` + `from_row/1`
- `MyApp.SpacetimeDB.Reducers` — typed functions with `@spec`
- `MyApp.SpacetimeDB.Client` — `use Spacetimedbex.Client` skeleton with config

### HTTP REST Client

For operations that don't need a persistent WebSocket (identity management, database admin, ad-hoc SQL):

```elixir
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:

```elixir
{: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: `u32` length prefix + raw data (UTF-8 validated)
- Arrays: `u32` count prefix + concatenated elements
- Products (structs): fields concatenated in order
- Sums (enums): `u8` variant 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

```bash
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