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 protocol.ex
Raw

lib/spacetimedb/protocol.ex

defmodule SpacetimeDB.Protocol do
@moduledoc """
Encodes and decodes SpacetimeDB WebSocket messages using the
`v1.json.spacetimedb` text subprotocol.
## Wire format
Both client→server and server→client messages are tagged JSON objects:
# client → server
{"CallReducer": {"reducer": "...", "args": "...", "request_id": N, "flags": 0}}
{"Subscribe": {"query_strings": [...], "request_id": N}}
{"SubscribeSingle": {"query": "...", "request_id": N, "query_id": N}}
{"SubscribeMulti": {"query_strings": [...], "request_id": N, "query_id": N}}
{"Unsubscribe": {"request_id": N, "query_id": N}}
{"OneOffQuery": {"message_id": [...], "query_string": "..."}}
# server → client
{"IdentityToken": {...}}
{"InitialSubscription": {...}}
{"SubscribeApplied": {...}}
{"UnsubscribeApplied": {...}}
{"SubscriptionError": {...}}
{"TransactionUpdate": {...}}
{"TransactionUpdateLight": {...}}
{"OneOffQueryResponse": {...}}
"""
alias SpacetimeDB.Types
@subprotocol "v1.json.spacetimedb"
@doc "The WebSocket subprotocol header value."
def subprotocol, do: @subprotocol
# ---------------------------------------------------------------------------
# Encoding (client → server)
# ---------------------------------------------------------------------------
@doc """
Subscribe to multiple queries at once (legacy multi-query subscription).
The server responds with `InitialSubscription`.
"""
def encode_subscribe(query_strings, request_id) when is_list(query_strings) do
Jason.encode!(%{"Subscribe" => %{"query_strings" => query_strings, "request_id" => request_id}})
end
@doc """
Subscribe to a single query. The server responds with `SubscribeApplied`.
Use `query_id` to identify this subscription in future `Unsubscribe` messages.
"""
def encode_subscribe_single(query, request_id, query_id) do
Jason.encode!(%{
"SubscribeSingle" => %{"query" => query, "request_id" => request_id, "query_id" => query_id}
})
end
@doc """
Subscribe to multiple queries under a single `query_id`.
The server responds with `SubscribeMultiApplied`.
"""
def encode_subscribe_multi(query_strings, request_id, query_id) when is_list(query_strings) do
Jason.encode!(%{
"SubscribeMulti" => %{
"query_strings" => query_strings,
"request_id" => request_id,
"query_id" => query_id
}
})
end
@doc "Remove a subscription previously created with `SubscribeSingle` or `SubscribeMulti`."
def encode_unsubscribe(request_id, query_id) do
Jason.encode!(%{"Unsubscribe" => %{"request_id" => request_id, "query_id" => query_id}})
end
@doc """
Call a reducer.
`args` must be JSON-serialisable (list of reducer arguments in order).
In the JSON wire format, args are sent as a JSON-encoded string.
"""
def encode_call_reducer(reducer, args, request_id, flags \\ 0) do
Jason.encode!(%{
"CallReducer" => %{
"reducer" => reducer,
"args" => Jason.encode!(args),
"request_id" => request_id,
"flags" => flags
}
})
end
@doc """
Execute a one-off query without establishing a subscription.
`message_id` is a client-generated 16-byte binary used to correlate the response.
"""
def encode_one_off_query(query_string, message_id) when is_binary(message_id) do
Jason.encode!(%{
"OneOffQuery" => %{
"message_id" => :binary.bin_to_list(message_id),
"query_string" => query_string
}
})
end
# ---------------------------------------------------------------------------
# Decoding (server → client)
# ---------------------------------------------------------------------------
@doc "Decode a JSON text frame from the server into a typed struct."
@spec decode(binary()) :: {:ok, term()} | {:error, term()}
def decode(json) when is_binary(json) do
case Jason.decode(json) do
{:ok, map} -> {:ok, decode_map(map)}
{:error, _} = err -> err
end
end
defp decode_map(%{"IdentityToken" => d}) do
%Types.IdentityToken{
identity: d["identity"],
token: d["token"],
connection_id: d["connectionId"] || d["connection_id"]
}
end
defp decode_map(%{"InitialSubscription" => d}) do
%Types.InitialSubscription{
request_id: d["request_id"],
tables: decode_table_updates(d["database_update"]["tables"] || []),
execution_time_micros: d["total_host_execution_time_micros"] || 0
}
end
defp decode_map(%{"SubscribeApplied" => d}) do
%Types.SubscribeApplied{
request_id: d["request_id"],
query_id: get_query_id(d),
tables: decode_rows_in_tables(d["rows"] || d["tables"] || [])
}
end
defp decode_map(%{"SubscribeMultiApplied" => d}) do
%Types.SubscribeApplied{
request_id: d["request_id"],
query_id: get_query_id(d),
tables: decode_rows_in_tables(d["rows"] || d["tables"] || [])
}
end
defp decode_map(%{"UnsubscribeApplied" => d}) do
%Types.UnsubscribeApplied{
request_id: d["request_id"],
query_id: get_query_id(d),
tables: decode_rows_in_tables(d["rows"] || d["tables"] || [])
}
end
defp decode_map(%{"SubscriptionError" => d}) do
%Types.SubscriptionError{
request_id: d["request_id"],
query_id: get_query_id(d),
error: d["error"]
}
end
defp decode_map(%{"TransactionUpdate" => d}) do
{status, tables} = decode_update_status(d["status"], d)
%Types.TransactionUpdate{
status: status,
tables: tables,
timestamp: decode_timestamp(d["timestamp"]),
caller_identity: d["callerIdentity"] || d["caller_identity"],
caller_connection_id: d["callerConnectionId"] || d["caller_connection_id"],
reducer_call: decode_reducer_call(d["reducerCall"] || d["reducer_call"]),
energy_consumed: d["energyConsumed"] || d["energy_quanta_used"] || 0
}
end
defp decode_map(%{"TransactionUpdateLight" => d}) do
%Types.TransactionUpdate{
status: :committed,
tables: decode_table_updates(d["tables"] || [])
}
end
defp decode_map(%{"OneOffQueryResponse" => d}) do
%Types.OneOffQueryResponse{
message_id: decode_message_id(d["messageId"] || d["message_id"]),
error: d["error"],
tables: decode_rows_in_tables(d["tables"] || []),
execution_time_micros: d["total_host_execution_time_micros"] || 0
}
end
defp decode_map(other), do: {:unknown, other}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
defp decode_update_status(%{"Committed" => committed}, _d) do
tables =
committed["tables"] ||
get_in(committed, ["database_update", "tables"]) ||
[]
{:committed, decode_table_updates(tables)}
end
defp decode_update_status(%{"Failed" => reason}, _d), do: {{:failed, reason}, []}
defp decode_update_status(%{"OutOfEnergy" => _}, _d), do: {:out_of_energy, []}
defp decode_update_status(nil, d), do: {:committed, decode_table_updates(d["tables"] || [])}
defp decode_update_status(_, _d), do: {:unknown, []}
defp decode_table_updates(tables) when is_list(tables) do
Enum.map(tables, fn t ->
%Types.TableUpdate{
table_id: t["tableId"] || t["table_id"],
table_name: t["tableName"] || t["table_name"],
inserts: decode_rows(t["updates"]["inserts"] || t["inserts"] || []),
deletes: decode_rows(t["updates"]["deletes"] || t["deletes"] || [])
}
end)
end
defp decode_table_updates(_), do: []
# SubscribeApplied / OneOffQueryResponse carry rows differently
defp decode_rows_in_tables(rows) when is_list(rows) do
Enum.map(rows, fn
%{"tableName" => name, "tableRows" => updates} ->
%Types.TableUpdate{
table_name: name,
inserts: decode_rows(updates["inserts"] || []),
deletes: decode_rows(updates["deletes"] || [])
}
%{"table_name" => name} = t ->
%Types.TableUpdate{
table_name: name,
inserts: decode_rows(t["inserts"] || []),
deletes: decode_rows(t["deletes"] || [])
}
other ->
%Types.TableUpdate{inserts: decode_rows(other["inserts"] || []), deletes: []}
end)
end
defp decode_rows_in_tables(_), do: []
# Rows in JSON format are JSON-encoded strings of each row value
defp decode_rows(rows) when is_list(rows) do
Enum.map(rows, fn
row when is_binary(row) ->
case Jason.decode(row) do
{:ok, decoded} -> decoded
_ -> row
end
row ->
row
end)
end
defp decode_rows(_), do: []
defp decode_reducer_call(nil), do: nil
defp decode_reducer_call(rc) do
%Types.ReducerCallInfo{
reducer_name: rc["reducerName"] || rc["reducer_name"],
request_id: rc["requestId"] || rc["request_id"],
status: decode_call_status(rc["status"])
}
end
defp decode_call_status(%{"Committed" => _}), do: :committed
defp decode_call_status(%{"Failed" => msg}), do: {:failed, msg}
defp decode_call_status(%{"OutOfEnergy" => _}), do: :out_of_energy
defp decode_call_status(_), do: nil
defp decode_timestamp(nil), do: nil
defp decode_timestamp(us) when is_integer(us), do: %Types.Timestamp{microseconds_since_epoch: us}
defp decode_timestamp(%{"microseconds" => us}),
do: %Types.Timestamp{microseconds_since_epoch: us}
defp decode_timestamp(%{"__time_duration_micros__" => us}),
do: %Types.Timestamp{microseconds_since_epoch: us}
defp decode_message_id(nil), do: <<>>
defp decode_message_id(list) when is_list(list), do: :binary.list_to_bin(list)
defp decode_message_id(b64) when is_binary(b64), do: Base.decode64!(b64)
defp get_query_id(%{"queryId" => %{"id" => id}}), do: id
defp get_query_id(%{"queryId" => id}) when is_integer(id), do: id
defp get_query_id(%{"query_id" => %{"id" => id}}), do: id
defp get_query_id(%{"query_id" => id}) when is_integer(id), do: id
defp get_query_id(_), do: nil
end