README.md

# SpacetimeDB — Elixir Client

An Elixir client for [SpacetimeDB](https://spacetimedb.com) using the
`v1.json.spacetimedb` WebSocket subprotocol.

## Features

- Full WebSocket lifecycle management via [Mint.WebSocket](https://github.com/elixir-mint/mint_websocket)
- All client→server messages: `Subscribe`, `SubscribeSingle`, `SubscribeMulti`,
  `Unsubscribe`, `CallReducer`, `OneOffQuery`
- All server→client messages decoded into typed structs
- Automatic reconnection with exponential backoff
- Handler behaviour for clean separation of connection and business logic
- Works as a supervised child in OTP applications

## Installation

```elixir
def deps do
  [
    {:spacetimedb_ex, "~> 0.1"}
  ]
end
```

## Quick start

```elixir
{:ok, conn} = SpacetimeDB.start_link(
  host: "localhost",
  database: "my_module",
  handler: %{
    on_identity_token: fn token, _ ->
      IO.puts("Connected as #{token.identity}")
    end,
    on_transaction_update: fn update, _ ->
      Enum.each(update.tables, fn t ->
        IO.puts("#{t.table_name}: +#{length(t.inserts)} -#{length(t.deletes)}")
      end)
    end
  }
)

# Subscribe to a SQL query
SpacetimeDB.subscribe(conn, ["SELECT * FROM Player"])

# Call a reducer
{:ok, request_id} = SpacetimeDB.call_reducer(conn, "CreatePlayer", ["Alice"])

# One-off query (no subscription)
{:ok, msg_id} = SpacetimeDB.one_off_query(conn, "SELECT * FROM Player WHERE name = 'Alice'")
```

## Handler behaviour

For production use, implement `SpacetimeDB.Handler` in a module:

```elixir
defmodule MyApp.SpacetimeHandler do
  @behaviour SpacetimeDB.Handler

  @impl true
  def on_identity_token(%SpacetimeDB.Types.IdentityToken{} = token, _arg) do
    MyApp.Auth.store_token(token.token)
  end

  @impl true
  def on_initial_subscription(%SpacetimeDB.Types.InitialSubscription{} = sub, _arg) do
    Enum.each(sub.tables, &MyApp.Cache.seed_table/1)
  end

  @impl true
  def on_transaction_update(%SpacetimeDB.Types.TransactionUpdate{} = update, _arg) do
    Enum.each(update.tables, fn t ->
      MyApp.Cache.apply_diff(t.table_name, t.inserts, t.deletes)
    end)
  end

  @impl true
  def on_disconnect(reason, _arg) do
    MyApp.Metrics.record_disconnect(reason)
  end
end
```

Then connect with:

```elixir
{:ok, conn} = SpacetimeDB.start_link(
  host: "prod.example.com",
  port: 443,
  tls: true,
  database: "game-prod",
  token: System.get_env("SPACETIMEDB_TOKEN"),
  handler: MyApp.SpacetimeHandler
)
```

## Supervised usage

Add to your application's supervision tree:

```elixir
defmodule MyApp.Application do
  use Application

  @impl true
  def start(_type, _args) do
    children = [
      {SpacetimeDB,
       host: "localhost",
       database: "my_module",
       handler: MyApp.SpacetimeHandler,
       name: MyApp.SpacetimeDB}
    ]

    Supervisor.start_link(children, strategy: :one_for_one)
  end
end
```

## Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `:host` | `String.t()` | required | SpacetimeDB host |
| `:port` | `non_neg_integer()` | `3000` | Port |
| `:tls` | `boolean()` | `false` | Use TLS (`wss://`) |
| `:database` | `String.t()` | required | Database name or identity hex |
| `:token` | `String.t() \| nil` | `nil` | Auth token (persisted on reconnect) |
| `:handler` | `module \| {module, term} \| map` | required | Callback module, `{module, arg}` tuple, or map of anonymous functions |
| `:reconnect` | `boolean()` | `true` | Auto-reconnect on disconnect |
| `:reconnect_delay_ms` | `non_neg_integer()` | `500` | Initial reconnect backoff delay |
| `:max_reconnect_delay_ms` | `non_neg_integer()` | `30_000` | Maximum reconnect backoff delay |
| `:name` | `GenServer.name()` | — | Registered process name |

## Types

All decoded server messages are plain Elixir structs:

| Struct | Description |
|--------|-------------|
| `SpacetimeDB.Types.IdentityToken` | First message — identity + auth token |
| `SpacetimeDB.Types.InitialSubscription` | Initial rows for a `Subscribe` call |
| `SpacetimeDB.Types.SubscribeApplied` | Confirmation for `SubscribeSingle` / `SubscribeMulti` |
| `SpacetimeDB.Types.UnsubscribeApplied` | Confirmation for `Unsubscribe` |
| `SpacetimeDB.Types.SubscriptionError` | Server rejected a subscription |
| `SpacetimeDB.Types.TransactionUpdate` | Row changes from a committed reducer call |
| `SpacetimeDB.Types.OneOffQueryResponse` | Result of a one-off query |
| `SpacetimeDB.Types.TableUpdate` | Per-table diff (`inserts`, `deletes`) |
| `SpacetimeDB.Types.ReducerCallInfo` | Metadata in a `TransactionUpdate` |
| `SpacetimeDB.Types.Timestamp` | Microseconds since Unix epoch |

## Architecture

```
SpacetimeDB (public API)
    │
    └── SpacetimeDB.Connection (GenServer)
            │  WebSocket frames via Mint.WebSocket
            │
            ├── SpacetimeDB.Protocol  encode/decode JSON
            ├── SpacetimeDB.Types     typed structs
            └── SpacetimeDB.Handler   callback behaviour
```

The `Connection` GenServer owns a single `Mint.HTTP` connection upgraded to
WebSocket. Received frames are decoded by `SpacetimeDB.Protocol.decode/1` and dispatched to
the handler callbacks. On disconnect the process waits `reconnect_delay_ms`
(doubling each attempt, capped at `max_reconnect_delay_ms`) before reconnecting.

## Protocol

SpacetimeDB WebSocket URL format:

```
ws[s]://{host}:{port}/database/{name_or_identity}/subscribe
```

Subprotocol: `v1.json.spacetimedb` (text frames, JSON-encoded messages)

The library currently implements the JSON protocol only. BSATN binary protocol
(`v1.bsatn.spacetimedb`) support is planned for a future release.

## License

MIT