2. Subscribe to tables
Once the connection greeting has arrived
(InitialConnection
/ SDK onConnect), send SQL subscriptions. Small filtered
sets can go in one shot. For BitCraft-scale full dumps, subscribe
one query set at a time and wait for Applied before
the next.
What SQL is safe
Subscriptions are not arbitrary analytics SQL. Stick to shapes SpacetimeDB accepts for live queries:
| Use | Example |
|---|---|
| Full table | SELECT * FROM skill_desc |
Equality / simple WHERE |
SELECT * FROM player_username_state WHERE username = 'Maplesugar' |
| PK / numeric filter | SELECT * FROM experience_state WHERE entity_id = 1297036692699996362 |
| Inequality used by relay-cache | SELECT * FROM location_state WHERE dimension != 1 |
Prefer projecting only what you need when a table is huge
(location_state, inventories, footprints).
Etiquette: do not SELECT * FROM location_state
(or other multi-hundred-MB tables) from every client laptop — filter,
use the HTTP cache, or run heavy
mirrors on a server. Fancy joins, aggregates, ORDER BY, or
LIMIT are either unsupported or a bad fit for subscriptions.
OneOffQuery is rejected by the relay.
One-shot snapshot (no live stream)
Need a dump, not a long-lived cache? That is the usual substitute for
OneOffQuery:
- Connect and wait for the greeting (
InitialConnection/IdentityToken). - Subscribe with the smallest SQL that answers the question (filters help).
- Wait for Applied /
InitialSubscription. - Read rows from the Applied payload or SDK client cache.
- Close the WebSocket — you are done.
The Python v1.json examples on this page and
Players do exactly that (connect → subscribe →
print → exit). Keep the socket open only if you need live
TransactionUpdates afterward.
One table (or a few filtered rows)
// npm i spacetimedb
// spacetime generate --lang typescript -o src/module_bindings …
import { DbConnection } from "./module_bindings";
const conn = DbConnection.builder()
.withUri("wss://relay.bitcraftsync.app:3014")
.withDatabaseName("relay-mirror-bc14")
.onConnect((conn, identity, token) => {
console.log("connected", identity.toHexString(), "token_len", token.length);
conn
.subscriptionBuilder()
.onApplied((ctx) => {
// Generated accessor name follows the table; SQL string also works:
for (const row of ctx.db.playerUsernameState.iter()) {
console.log(row.entityId.toString(), row.username);
}
})
.onError((_ctx, err) => console.error("subscribe error", err))
.subscribe(["SELECT * FROM player_username_state WHERE username = 'Maplesugar'"]);
})
.onConnectError((_ctx, err) => console.error("connect error", err))
.build();
#!/usr/bin/env python3
"""Runnable without a BSATN codec: v1.json text frames.
pip install websockets
"""
import asyncio
import json
import websockets
HOST = "wss://relay.bitcraftsync.app:3014"
DB = "relay-mirror-bc14"
URL = f"{HOST}/v1/database/{DB}/subscribe?compression=None"
SUBPROTOCOL = "v1.json.spacetimedb"
async def recv_json(ws, timeout=120):
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=timeout)
if isinstance(msg, (bytes, bytearray)):
continue # ignore pings framed as binary if any
return json.loads(msg)
async def main():
async with websockets.connect(
URL, subprotocols=[SUBPROTOCOL], max_size=None
) as ws:
greet = await recv_json(ws)
assert "IdentityToken" in greet, greet # v1 name for InitialConnection
print("IdentityToken ok")
# v1 Subscribe is set-replace: one message can list several queries.
await ws.send(json.dumps({
"Subscribe": {
"request_id": 1,
"query_strings": [
"SELECT * FROM player_username_state WHERE username = 'Maplesugar'",
],
}
}))
applied = await recv_json(ws)
assert "InitialSubscription" in applied, applied
tables = applied["InitialSubscription"]["database_update"]["tables"]
for table in tables:
print(f"table={table['table_name']} rows={table['num_rows']}")
for upd in table["updates"]:
for raw in upd.get("inserts", []):
# Each insert is a JSON *string* of the row object
row = json.loads(raw) if isinstance(raw, str) else raw
print(" ", row)
# → {'entity_id': 1297036692699996362, 'username': 'Maplesugar'}
asyncio.run(main())
// Inside on_connect, after spacetime generate --lang rust
ctx.subscription_builder()
.on_applied(|ctx| {
for row in ctx.db.player_username_state().iter().take(5) {
println!("entity_id={} username={}", row.entity_id, row.username);
}
})
.on_error(|_ctx, err| eprintln!("subscribe error: {err}"))
.subscribe(["SELECT * FROM player_username_state WHERE username = 'Maplesugar'"]);
Many tables — sequential Applied (runnable)
Rule: do not start the next subscribe until the previous query set has applied. TypeScript and Rust SDKs support additive subscriptions natively. Python below uses v1.json with one Subscribe that lists several small/filtered queries (set-replace once) — enough to join a single player without BSATN. For huge full-table dumps from Python, use the HTTP cache or a TS/Rust client.
import { DbConnection } from "./module_bindings";
const QUERIES = [
"SELECT * FROM skill_desc",
"SELECT * FROM player_username_state WHERE username = 'Maplesugar'",
"SELECT * FROM player_state WHERE entity_id = 1297036692699996362",
"SELECT * FROM experience_state WHERE entity_id = 1297036692699996362",
];
function subscribeSequential(conn: DbConnection, i = 0) {
if (i >= QUERIES.length) {
console.log("all sequential subscriptions applied");
// read joined columns from conn.db … (see players.html)
return;
}
const sql = QUERIES[i];
console.log("subscribing", sql);
conn
.subscriptionBuilder()
.onApplied(() => {
console.log("SubscribeApplied", i, sql);
subscribeSequential(conn, i + 1);
})
.onError((_ctx, err) => console.error("subscribe error", err))
.subscribe(sql);
}
DbConnection.builder()
.withUri("wss://relay.bitcraftsync.app:3014")
.withDatabaseName("relay-mirror-bc14")
.onConnect((conn) => subscribeSequential(conn))
.onConnectError((_ctx, err) => console.error(err))
.build();
use module_bindings::DbConnection;
use spacetimedb_sdk::DbContext;
use std::sync::Arc;
const QUERIES: &[&str] = &[
"SELECT * FROM skill_desc",
"SELECT * FROM player_username_state WHERE username = 'Maplesugar'",
"SELECT * FROM player_state WHERE entity_id = 1297036692699996362",
"SELECT * FROM experience_state WHERE entity_id = 1297036692699996362",
];
fn subscribe_at(ctx: &impl DbContext, i: usize) {
if i >= QUERIES.len() {
println!("all sequential subscriptions applied");
return;
}
let sql = QUERIES[i].to_string();
println!("subscribing {sql}");
ctx.subscription_builder()
.on_applied(move |ctx| {
println!("SubscribeApplied {i}");
subscribe_at(&ctx, i + 1);
})
.on_error(|_ctx, err| eprintln!("subscribe error: {err}"))
.subscribe([sql]);
}
fn main() {
let _conn = DbConnection::builder()
.with_uri("wss://relay.bitcraftsync.app:3014")
.with_database_name("relay-mirror-bc14")
.on_connect(|ctx, _id, _token| subscribe_at(&ctx, 0))
.build()
.expect("connect");
// keep process alive / tick the SDK runtime as your app requires
}
#!/usr/bin/env python3
"""One v1 Subscribe with several *filtered* queries — parses real columns.
For multi-GB full-table dumps, use TypeScript/Rust sequential instead.
"""
import asyncio, json, websockets
URL = (
"wss://relay.bitcraftsync.app:3014"
"/v1/database/relay-mirror-bc14/subscribe?compression=None"
)
ENTITY = 1297036692699996362 # Maplesugar on region 14
QUERIES = [
"SELECT * FROM skill_desc",
"SELECT * FROM player_username_state WHERE username = 'Maplesugar'",
f"SELECT * FROM player_state WHERE entity_id = {ENTITY}",
f"SELECT * FROM experience_state WHERE entity_id = {ENTITY}",
]
async def recv_json(ws, timeout=180):
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=timeout)
if isinstance(msg, str):
return json.loads(msg)
async def main():
async with websockets.connect(
URL, subprotocols=["v1.json.spacetimedb"], max_size=None
) as ws:
assert "IdentityToken" in await recv_json(ws)
await ws.send(json.dumps({
"Subscribe": {"request_id": 1, "query_strings": QUERIES}
}))
applied = await recv_json(ws)
isub = applied["InitialSubscription"]
skill_names = {}
username = None
stacks = []
for table in isub["database_update"]["tables"]:
name = table["table_name"]
for upd in table["updates"]:
for raw in upd.get("inserts", []):
row = json.loads(raw) if isinstance(raw, str) else raw
if name == "skill_desc":
skill_names[row["id"]] = row["name"]
elif name == "player_username_state":
username = row["username"]
print(f"username={username} entity_id={row['entity_id']}")
elif name == "player_state":
print(
f" signed_in={row.get('signed_in')} "
f"sign_in_timestamp={row.get('sign_in_timestamp')}"
)
elif name == "experience_state":
stacks = row.get("experience_stacks") or []
for skill_id, xp in sorted(stacks, key=lambda p: -p[1])[:5]:
print(f" {skill_names.get(skill_id, '?'):<20} skill_id={skill_id} xp={xp}")
asyncio.run(main())
Wire-level notes (v2 additive)
| Field | Role |
|---|---|
request_id |
Client-chosen; echoed on SubscribeApplied. |
query_set_id |
New id appends an additive set; reuse replaces that set. |
query_strings |
SQL list for this set. |
v1 Subscribe |
Set-replace (replaces previous queries). Use filters or
small tables; for additive v1 use SubscribeMulti
(SDK) or prefer v2. |