13 Protocol Notes
yuris edited this page 2026-08-22 17:21:29 +07:00

Protocol Notes

Nothing on this page is needed to ship, because the SDK already implements all of it. Read it when a frame surprises you, or when you are reading raw messages yourself. To get something running instead, go to Quickstart.

This page is the wire archive. It records what was captured, what the bytes are, what the encodings cost, and which upstream code decides a behaviour. The contract here was not read off the documentation. It was captured against a live clockworklabs/spacetime:v2.8.2 server and is pinned by committed fixtures, and two conformance tiers run the same client against a real server, one as a headless engine build and one as a wasm bundle in headless Chromium. That is why the sections below can say what a frame contains rather than what it ought to.

Protocol reality check

The wire contract here is not guesswork. It was captured from a live clockworklabs/spacetime:v2.8.2 server (dev/test/fixtures/session.jsonl). The findings below shaped the implementation, and they are easy to get wrong from the docs alone.

Findings 1 to 7, this section and the Defold traps after it, describe the v1.json.spacetimedb wire, which the SDK no longer speaks. 4.0.0 deleted it. They stay because they are still true of the server, because the committed fixtures were captured over that wire, and because the decoder that reads them survives in the test tree as dev/test/crosscheck_v1.lua, a second opinion on the BSATN reader rather than a transport. BSATN is opaque, so a wrong belief about a column's type returns a plausible number rather than raising; the differential in dev/test/test_protocol_v2.lua decodes the same captured frame both ways and asserts the two Lua values are equal. The wire the SDK actually negotiates has its own section below, v2 over BSATN, captured the same way.

  1. The JSON subprotocol speaks the v1 message schema rather than v2. v1.json.spacetimedb maps to WsVersion::V1 in the server's routes/subscribe.rs. The v2 schema (InitialConnection, QuerySetUpdate, ReducerResult) is reachable only over v2.bsatn.spacetimedb. So the first frame is IdentityToken, not InitialConnection. Reaching the v2 schema meant writing a BSATN codec, which is now the only wire the SDK runs on.
  2. Rows and reducer args are double-encoded. Under JsonFormat, Single = ByteString and List = Vec<ByteString>, so every row and argument tuple is a JSON string containing JSON.
  3. Row shape depends on the message and on whether the relation is a table or a view, never on the table alone. Every subscription readback (InitialSubscription, SubscribeApplied, SubscribeMultiApplied and both unsubscribe forms), plus OneOffQueryResponse, sends table rows as named objects ({"id":3,"sender":"yuris"}). TransactionUpdate sends them as positional arrays ([3,"yuris",...]), inserts and deletes alike. A view projects positionally in all of those, subscription readbacks included, and in its own declared order rather than the underlying table's; see Views. Decoding a positional row needs the column order, which is why schema.lua requires columns. This was captured off a live 2.8.2 server rather than inferred. The split runs one level down too. A positional row is an untyped ProductValue, so field names are dropped recursively and a Timestamp column arrives as the bare array [micros] rather than the named wrapper a named row carries.
  4. Reducers are addressed by canonical snake_case name. A JS module exporting sendMessage is called over the wire as send_message (spacetime describe --json <db> shows the mapping under ExplicitNames).

Scalars are wrapped ({"__identity__":"0x…"}, {"__timestamp_micros_since_unix_epoch__":n}); the v1 decoder's unwrap strips them. Sums in rows are positional, always [tag, payload] with exactly two elements, so an Option column is [0, v] for Some, and for None it is [1, {}] in a named readback or [1, []] in a positional row. The named {"some":v} / {"none":[]} form shows up only in message envelopes and schema JSON, because a row layout carries variant tags but not variant names.

Defold-specific traps

The first two were found by running the SDK inside a real engine and are not reachable from unit tests. The third is reachable, and still bit us, because it is silently wrong rather than loud.

  1. A v1 frame had to be sent as text. extension-websocket defaults to binary frames. Send JSON as binary and the server tries to BSATN-decode it, then drops the connection with unknown tag 0x7b for sum type ClientMessage (0x7b is {). websocket.send needs { type = websocket.DATA_TYPE_TEXT }. The SDK now sends binary unconditionally, since its frames are BSATN. The trap is recorded because the failure mode is a dropped connection rather than a bad value.
  2. websocket is a global rather than a module. require("websocket") fails at build time with "the Lua module '/websocket.lua' can't be found", because native extensions register globals. Use _G.websocket.
  3. Timestamp and TimeDuration columns are microseconds. Every clock a Defold game touches is seconds (os.time, socket.gettime), so os.date("%H:%M", row.created_at) is off by a factor of 1e6, and it does not error. It prints a date about 56 million years out. Divide by 1e6 first. There is no conversion helper in the SDK, since the stdb.protocol module that carried one went with the v1 wire in 4.0.0. See special types and their units. Lua 5.1 has no integer type either, which is where both ceilings in that section come from.

v2 over BSATN

Everything above describes v1.json.spacetimedb, which 4.0.0 removed. This section is v2.bsatn.spacetimedb, the only wire connect negotiates. The Usage Guide opens with what that means for a consumer. It was captured the same way the v1 notes were, against a live clockworklabs/spacetime:v2.8.2 server, and committed as dev/test/fixtures/session_v2.jsonl (33 frames, 15 client and 18 server, recorded by dev/tools/capture-v2.mjs).

Frame envelope

The two directions are not symmetric, and getting either wrong drops the connection rather than producing a bad value.

  • Server to client, a frame is one compression byte, then exactly one BSATN ServerMessage, or, when the byte is not 0, a gzip stream whose plaintext is that ServerMessage. The byte is 0 none, 1 brotli, 2 gzip, and it replaces the message's leading byte rather than prefixing the stream, so the gzip magic sits at byte 2. The SDK puts compression=Gzip in the connect URL and inflates with Defold's builtin zlib.inflate; brotli raises, because no Defold build can decode one. Both shapes arrive on the same socket, since the server only compresses above 1KiB, so small frames stay plain.
  • Client to server, a frame is the raw BSATN ClientMessage with no compression byte. The server begins decoding at the sum tag, so a leading byte becomes an unknown variant ordinal. The capture confirms that client frames sent with no tag are accepted.
  • Client frames are binary, websocket.DATA_TYPE_BINARY, the exact mirror of trap 5 above. unknown tag 0x7b is what JSON-over-binary produces on v1, and BSATN-over-text fails for the same reason in the other direction.

A v2 frame carries exactly one message. v3 (v3.bsatn.spacetimedb) is the same message schema with several coalesced per frame; that is the whole difference, and it is why frame handling here is unchanged.

Message map

Variant order is the wire ordinal. Fields are listed in encoding order, since a BSATN product carries neither names nor a count.

ordinal ClientMessage fields
0 Subscribe request_id u32, query_set_id u32, query_strings array<string>
1 Unsubscribe request_id u32, query_set_id u32, flags sum (0 Default, 1 SendDroppedRows)
2 OneOffQuery request_id u32, query_string string
3 CallReducer request_id u32, flags plain u8, reducer string, args bytes
4 CallProcedure request_id u32, flags plain u8, procedure string, args bytes

UnsubscribeFlags is a SATS sum and the two call flags are raw u8. Both are one byte of 0 in practice; the distinction only bites if a flag ever gains a payload, which is why the SDK writes them through different writer methods.

ordinal ServerMessage fields
0 InitialConnection identity u256, connection_id u128, token string
1 SubscribeApplied request_id u32, query_set_id u32, rows QueryRows
2 UnsubscribeApplied request_id u32, query_set_id u32, rows Option<QueryRows>
3 SubscriptionError request_id Option<u32>, query_set_id u32, error string
4 TransactionUpdate query_sets array<QuerySetUpdate>
5 OneOffQueryResult request_id u32, result Result<QueryRows, string>
6 ReducerResult request_id u32, timestamp i64, result ReducerOutcome
7 ProcedureResult status ProcedureStatus, timestamp i64, total_host_execution_duration i64, request_id u32

ReducerOutcome is a sum of 0 Ok { ret_value bytes, transaction_update }, 1 OkEmpty (unit), 2 Err(bytes) and 3 InternalError(string). ProcedureStatus is 0 Returned(bytes), 1 InternalError(string). Rows travel as a BsatnRowList, which is a size hint plus one rows_data blob. The hint is either 0 FixedSize(u16), rows being consecutive equal-width slices, or 1 RowOffsets(array<u64>), each offset a start.

ReducerOutcome::Ok is the separation that makes v2 worth having. The reducer's result and the transaction it caused arrive as one frame addressed to the caller, where v1 broadcast a whole reducer_call to every subscriber. The consequence is that an ordinal-4 TransactionUpdate is always another caller's commit, measured against the capture and never once your own, so a v2 client applies updates from two places rather than one.

What the capture settled

The sources left these ambiguous, or state them wrongly. Each is pinned at its decode site in stdb/protocol_v2.lua or stdb/connection.lua.

  1. ReducerResult.timestamp is microseconds. Upstream v2.rs:378 documents the field as nanoseconds and is wrong. The captured value is 1787288613878948, three orders of magnitude short of nanoseconds and exactly the unit v1's envelope uses. Row-level Timestamp columns are micros on both protocols, so nothing here divides differently per protocol.
  2. Identity hex text is the byte-reverse of the wire bytes. An Identity is a little-endian 32-byte number in BSATN, and the server renders hex from the big-endian array, so the 64 digits a v1 frame carries, and the JWT's own hex_identity, hold the wire bytes reversed. Both were compared against the same identity to confirm it. Decode without the reversal and you get a plausible hex string that matches nothing.
  3. Option ordinals are 0 Some and 1 None; Result is 0 Ok and 1 Err. These are the same ordinals v1's positional sums use, so [0, v] / [1, {}] on JSON and tag 0 / 1 on BSATN say the same thing.
  4. A client frame with no compression byte is accepted. The asymmetry in the envelope above is the protocol rather than something to work around. The server reads the sum tag first on the frames it receives and writes a compression byte on the frames it sends.

Hostile input

BSATN is self-describing only in the sense that a decoder knows what it expects next. There are no lengths to cross-check, no field names and no terminator, so a malformed frame is indistinguishable from a valid one until the numbers stop making sense. Three checks make that survivable:

  • The decoder validates RowOffsets as a partition of rows_data, non-descending with the last offset in bounds, while the offset array is being read and before a single row is sliced. Total decoded bytes then cannot exceed the frame. Unvalidated, an offset table is an amplifier rather than a nuisance, since 232 KB of offsets naming overlapping spans expands to 388 MB of Lua strings. The same check keeps an offset past 2^53 away from string.sub, whose double-to-ptrdiff_t cast is undefined and platform-divergent, so the same bytes would decode differently on x86-64 and on wasm32.
  • Array counts are bounded by the bytes remaining in the frame. A u32 element count is four bytes that can ask for four billion elements. Every element of a real array costs at least one byte, so the bound rejects nothing a server sends. The one shape it does reject is a vector of a zero-width element type, a unit or an empty product, which no generated schema produces.
  • A decoded frame must be fully consumed. Cursor exhaustion is the only structural self-check the format admits. If a message decodes but bytes are left over, the layout the decoder assumed was not the layout on the wire, and the values it produced are not to be trusted.

Bytes and latency, v1 against v2

Every figure here was measured on one workstation, a Ryzen 7 8845HS running headless Defold against a local spacetime:v2.8.2, so read them as local numbers. Older CI figures on this SDK came off a different machine and are not comparable to these.

Measured in wire bytes, a v2 subscription readback is 46% of v1's. The traffic is identical over both wires, back to back against one server, driven by a purpose-built probe rather than by the two committed captures, which drive different sequences and are not byte-comparable. Each data point runs clear_bulk, fill_bulk(N), subscribe "SELECT * FROM bulk" and unsubscribe. The bytes counted are the subscription readback, the frame PHASE6 times.

rows v1 bytes v2 bytes v2 as % of v1 v1 B/row v2 B/row
100 9,818 4,621 47.1% 98.2 46.2
1,000 100,406 46,921 46.7% 100.4 46.9
10,000 1,042,187 478,921 46.0% 104.2 47.9
50,000 5,360,120 2,438,921 45.5% 107.2 48.8

A v1 row costs more per row as the table grows and a v2 row barely moves, because JSON pays for the digits it prints. Across the whole 10k-row session, server → client is 2,085,999 B on v1 against 958,329 B on v2 (45.9%); the same four client messages are 309 B against 96 B (31.1%). "Roughly half the bytes" was the estimate this work was justified with, and it turns out to have been slightly conservative.

The committed captures give a second, weaker reading. The two sessions are not the same traffic, so this is a scale check and nothing more. session.jsonl is 50 server frames / 26,238 B (525 B mean, client half not captured); session_v2.jsonl is 16 server frames / 2,296 B (144 B mean) plus 13 client frames / 446 B.

On apply latency, v2 is faster at every row count, but the absolutes below are not a stable baseline. PHASE6 times the single frame that applies a first subscription. Read this whole subsection as one machine's snapshot rather than a regression baseline. The paragraph after the table explains why that distinction matters here.

rows v1 frame ms v2 frame ms
100 0.4, 0.4 0.4, 0.4
1,000 2.1, 2.3 1.3, 1.3
5,000 9.9, 10.4 6.3, 5.4
10,000 20.0, 20.1 10.5, 13.6
25,000 53.6, 54.3 35.5, 31.1
50,000 105.7, 113.7 86.3, 81.9

Both sweeps are given because conformance.sh runs the smoke twice and the large row counts are noisy. Read the pair rather than the better half of it.

The measurement conditions matter more than the digits. Those figures are a median of 3 on a Ryzen 7 8845HS, and nobody recorded the load on the box at the time. A later re-run of the same commit measured every native absolute 2-3x higher (v1 median 61.7ms at 10k rows against the 20.0ms above, v2 44.6ms against 10.5ms, over 5 runs) with /proc/loadavg at 11.06 on 16 cores from concurrent work, and the harness flagging up to 47% spread on two of the sweeps. The ratio survived in direction but not in value, 0.59-0.65 re-measured against the 0.53 the table above implies. That is a material difference rather than a rounding one, and it is the honest headline. v2 is consistently faster, and the size of the win is not pinned down to two digits.

A clean re-measure on a quiesced box is still owed, and Roadmap is where it is tracked. Until it happens, do not read a slower number off this table as a regression. Check loadavg and the harness spread warning first. Ambiguity of exactly this kind is what forced this correction.

What is not load-dependent is the wire-byte counts above. They are byte counts and they stand unchanged. So do the two structural claims below, both of which reproduce regardless of what else the machine is doing.

Where the time goes, at 10,000 rows, native, with the same caveat on the absolutes. The shape is the point.

bucket v1 v2
pre 0.5 0.3
decode 11.4 (json.decode) 8.4 (BSATN)
big-integer quoting 0.0 structurally absent
row construction 6.8 1.9
post 0.1 0.1
frame 20.0 ms 10.5 ms

Two things in that split are structural. They follow from the encodings rather than from the clock.

The big-integer quoting bucket cannot exist on v2. The quoting pass is v1 rewriting wide integer literals as strings before json.decode can round them (described under Row value types); it exists at all only because JSON cannot carry a u128 exactly. A binary wire has no text to rescue, so there is nothing to scan. This is now enforced rather than observed. The v2 path asserts zero quoting attempts, a claim about guard_calls/wide_calls that would fail if a per-row string.find scan ever came back on the binary wire with its time billed nowhere. The three hit-based assertions beside it are 0 == 0 on v2 and would have passed unchanged.

Row construction falls with decoding, 6.8ms to 1.9ms, because a BSATN row builds fewer intermediate Lua values on its way to the same table, so the win is not confined to the decode bucket.

HTML5 (the wasm bundle in Chromium), 10,000 rows, one sample each. v1 is 112.0ms total, made of 68.0 pre, 21.0 json.decode and 23.0 build, against v2's 70.0ms, made of 17.0 pre, 45.0 BSATN decode and 8.0 build. The structural fact here is the collapse of pre, which is frame delivery. It is the single largest bucket on v1 at 61% of the frame, and on v2 it is 24% and no longer dominant. That is the larger v1 payload showing up as delivery cost, and it dominates the browser tier in a way it never does natively. The collapse is the load-independent part; the millisecond totals carry the same caveat as the native table.

One methodology note, since a bucket split is only worth reading if both sides were billed the same way. PHASE6 bills both wires into those same five buckets and the decode call counts match exactly (one json.decode for the envelope plus one per row on v1, one protocol_v2.decode_server plus one decode_typed_bsatn per row on v2), so decode_calls == rows + 1 is asserted identically on both protocols. The columns are comparable rather than merely adjacent.

Where the confirmed_reads default comes from

connect({ confirmed_reads = nil }) sends no parameter, and what the server then does depends on which protocol negotiated. The rule a consumer needs is in The confirmed_reads tri-state; this is where it was read from, so a later server version can be checked against the same two places.

Upstream resolves the parameter in resolve_confirmed_reads_default (crates/client-api/src/routes/subscribe.rs:103-111). An explicit true or false is honoured as sent, and an absent parameter becomes false on v1 but DEFAULT_CONFIRMED_READS, which is true, on v2 and v3. The upstream test confirmed_reads_default_depends_on_ws_version (subscribe.rs:2838-2844) asserts both defaults and both overrides, which is why the SDK sends nothing unless asked rather than picking a default of its own.