Packages

Elixir client for SpacetimeDB — BSATN binary protocol, WebSocket subscriptions, reducer calls, live ETS table mirrors

Current section

Files

Jump to
spacetimedb_ex lib spacetimedb table.ex
Raw

lib/spacetimedb/table.ex

defmodule SpacetimeDB.Table do
@moduledoc """
An ETS-backed local mirror of a single SpacetimeDB table.
`SpacetimeDB.Table` is a GenServer that owns a named ETS table and keeps it
in sync with the server via `apply_update/2`. All query functions
(`all/1`, `get/2`, `filter/2`, `count/1`) read directly from ETS — they
never go through the GenServer process, so they are non-blocking and can be
called from any process at full ETS speed.
## Setup
children = [
{SpacetimeDB.Table,
name: :players,
schema: MyGame.Player,
table_name: "Player",
primary_key: :id},
{SpacetimeDB,
host: "localhost",
database: "game",
handler: MyGame.Handler}
]
## Wiring updates
Call `apply_update/2` from your handler callbacks. It accepts
`TransactionUpdate`, `InitialSubscription`, `SubscribeApplied`, or a bare
`TableUpdate` struct. Only rows whose `table_name` matches the `:table_name`
option are touched.
defmodule MyGame.Handler do
@behaviour SpacetimeDB.Handler
@impl true
def on_initial_subscription(sub, _),
do: SpacetimeDB.Table.apply_update(:players, sub)
@impl true
def on_transaction_update(update, _),
do: SpacetimeDB.Table.apply_update(:players, update)
end
## Querying
SpacetimeDB.Table.all(:players)
#=> [%MyGame.Player{id: 1, name: "Alice"}, ...]
SpacetimeDB.Table.get(:players, 1)
#=> {:ok, %MyGame.Player{id: 1, name: "Alice"}}
SpacetimeDB.Table.filter(:players, fn p -> p.health > 50 end)
#=> [%MyGame.Player{...}, ...]
SpacetimeDB.Table.count(:players)
#=> 42
## BSATN vs JSON rows
- **BSATN mode** (default): row inserts/deletes arrive as raw binaries and are
decoded via `schema.decode/1` (generated by `SpacetimeDB.BSATN.Schema`).
- **JSON mode**: rows arrive as string-keyed maps and are coerced to structs via
`String.to_existing_atom/1` on the keys.
## Options
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `:name` | `atom()` | yes | Registered name for the GenServer and ETS lookup |
| `:schema` | `module()` | yes | Module that implements `decode/1` and `encode/1` (via `SpacetimeDB.BSATN.Schema`) |
| `:table_name` | `String.t()` | yes | SpacetimeDB table name to track (must match `TableUpdate.table_name`) |
| `:primary_key` | `atom()` | yes | Field name used as the ETS key |
"""
use GenServer
require Logger
alias SpacetimeDB.Types
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@doc "Start and link the table mirror process."
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts) do
name = Keyword.fetch!(opts, :name)
GenServer.start_link(__MODULE__, opts, name: name)
end
@doc false
def child_spec(opts) do
name = Keyword.fetch!(opts, :name)
%{id: {__MODULE__, name}, start: {__MODULE__, :start_link, [opts]}, restart: :permanent}
end
# ---------------------------------------------------------------------------
# Query API (direct ETS reads — no GenServer call)
# ---------------------------------------------------------------------------
@doc "Return all rows as a list of structs."
@spec all(atom()) :: [struct()]
def all(name) do
name
|> ets_name()
|> :ets.tab2list()
|> Enum.map(fn {_k, v} -> v end)
end
@doc "Look up a single row by primary key. Returns `{:ok, row}` or `:error`."
@spec get(atom(), term()) :: {:ok, struct()} | :error
def get(name, key) do
tab = ets_name(name)
case :ets.lookup(tab, key) do
[{^key, row}] -> {:ok, row}
[] -> :error
end
end
@doc "Return all rows matching the predicate function."
@spec filter(atom(), (struct() -> boolean())) :: [struct()]
def filter(name, fun), do: Enum.filter(all(name), fun)
@doc "Return the number of rows currently in the table."
@spec count(atom()) :: non_neg_integer()
def count(name), do: :ets.info(ets_name(name), :size)
# ---------------------------------------------------------------------------
# Update API (direct ETS writes — no GenServer call)
# ---------------------------------------------------------------------------
@doc """
Apply a server message to this table mirror.
Accepts `TransactionUpdate`, `InitialSubscription`, `SubscribeApplied`, or a
bare `TableUpdate`. Only rows whose `table_name` matches the table configured
at `start_link` time are affected.
"""
@spec apply_update(atom(), term()) :: :ok
def apply_update(name, %Types.TransactionUpdate{tables: tables}),
do: apply_matching(name, tables)
def apply_update(name, %Types.InitialSubscription{tables: tables}),
do: apply_matching(name, tables)
def apply_update(name, %Types.SubscribeApplied{tables: tables}),
do: apply_matching(name, tables)
def apply_update(name, %Types.TableUpdate{} = tu),
do: do_apply(name, tu)
def apply_update(_name, _other), do: :ok
# ---------------------------------------------------------------------------
# GenServer — owns the ETS table
# ---------------------------------------------------------------------------
@impl GenServer
def init(opts) do
name = Keyword.fetch!(opts, :name)
schema = Keyword.fetch!(opts, :schema)
table_name = Keyword.fetch!(opts, :table_name)
primary_key = Keyword.fetch!(opts, :primary_key)
tab = :ets.new(ets_name(name), [:named_table, :public, :set, {:read_concurrency, true}])
# Store config in persistent_term so apply_update/query functions can read
# it without a GenServer call.
:persistent_term.put(config_key(name), %{
schema: schema,
table_name: table_name,
primary_key: primary_key
})
{:ok, %{name: name, tab: tab}}
end
@impl GenServer
def terminate(_reason, %{name: name, tab: tab}) do
:persistent_term.erase(config_key(name))
:ets.delete(tab)
:ok
end
# ---------------------------------------------------------------------------
# Private — update helpers
# ---------------------------------------------------------------------------
defp apply_matching(name, table_updates) do
%{table_name: watched} = config(name)
Enum.each(table_updates, fn tu ->
if tu.table_name == watched, do: do_apply(name, tu)
end)
end
defp do_apply(name, %Types.TableUpdate{inserts: inserts, deletes: deletes}) do
%{schema: schema, primary_key: pk} = config(name)
tab = ets_name(name)
for row <- inserts do
case decode_row(schema, row) do
{:ok, struct} -> :ets.insert(tab, {Map.fetch!(struct, pk), struct})
{:error, reason} -> Logger.warning("[SpacetimeDB.Table] insert decode failed: #{inspect(reason)}")
end
end
for row <- deletes do
case decode_row(schema, row) do
{:ok, struct} -> :ets.delete(tab, Map.fetch!(struct, pk))
{:error, reason} -> Logger.warning("[SpacetimeDB.Table] delete decode failed: #{inspect(reason)}")
end
end
:ok
end
# BSATN rows arrive as binaries
defp decode_row(schema, row) when is_binary(row) do
case schema.decode(row) do
{:ok, struct, _rest} -> {:ok, struct}
{:error, _} = err -> err
end
end
# JSON rows arrive as string-keyed maps
defp decode_row(schema, row) when is_map(row) do
attrs =
Map.new(row, fn {k, v} ->
{String.to_existing_atom(k), v}
end)
{:ok, struct!(schema, attrs)}
rescue
e -> {:error, Exception.message(e)}
end
defp decode_row(_schema, other), do: {:error, {:unexpected_row_type, other}}
# ---------------------------------------------------------------------------
# Private — naming helpers
# ---------------------------------------------------------------------------
defp ets_name(name), do: :"__stdb_table_#{name}"
defp config_key(name), do: {__MODULE__, name}
defp config(name), do: :persistent_term.get(config_key(name))
end