BSATN codec — the only route to the v2 message schema #9
Labels
No labels
area:defold
area:docs
area:infra
area:protocol
area:testing
kind:bug
kind:chore
phase:breadth
phase:core
phase:hardening
phase:slice
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
yuris/spacetimedb-defold#9
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Rescoped after the 2.8.2 protocol capture (see #3).
This was filed as a bandwidth optimisation. It is more than that: the
v1.json.spacetimedbsubprotocol carries the v1 message schema, and the v2 schema (InitialConnection,QuerySetUpdate,ReducerResult) is reachable only overv2.bsatn.spacetimedb/ v3 framing. The server'sroutes/subscribe.rsmaps TEXT_PROTOCOL toWsVersion::V1— there is no JSON transport for v2.So the real decision: stay on v1-over-JSON (works against 2.x servers, zero binary code, shipping today) or write BSATN to unlock v2 semantics.
Reasons that might force BSATN later:
query_set_id), giving cleaner unsubscribe and per-set error handlingReducerResultfromTransactionUpdate, making correlation less awkwardDecision made 2026-08-21: build it now; benchmark demoted from gate to deliverable.
The v2 semantics — event tables above all — are the reason, not the bytes. Waiting on a benchmark
was gating an unlock behind a measurement of the wrong axis, so the numbers now ship with the
codec instead of deciding whether it exists. v2 lands opt-in (
connect({ protocol = "v2" }));v1-over-JSON stays the default and its v1.0 value surface stays frozen. Flipping the default is a
separate later decision, after v2 conformance runs green for a while.
Implementation tracker — five phases, one PR each against
main. Wire facts read from thev2.8.2tag; each phase is gated on
lua dev/test/runner.lua, with the conformance suites as the Phase 4gate.
stdb/bsatn.lua: primitive codec. Reader over a Lua string with an offsetcursor, writer accumulating fragments into
table.concat. All little-endian: u8..u64 / i8..i64(64-bit as a double, exact ≤ 2^53), u128/i128 → exact decimal digit strings, u256/i256 →
minimal-width
0x…/-0x…hex, f32/f64 via IEEE-754 +math.ldexp,string/bytesas u32length + bytes. Pure arithmetic — no
require("bit"), because the unit-test host's Lua 5.1lacks the module. Tests: byte-exact vectors per primitive, both directions.
stdb/protocol_v2.lua: message schema + typed row decode.decode_serverforthe 8
ServerMessagevariants (compression byte first;BsatnRowListslices to an array ofstill-encoded row strings, both
FixedSizeandRowOffsets),encode_clientfor the 5ClientMessagevariants, anddecode_typed_bsatn— the binary twin ofprotocol.decode_typedover the same compositional tag grammar. Tests: hand-built frames per variant, plus a
differential test proving a BSATN
all_typesrow decodes value-for-value equal to the JSONone.
stdb/init.lua:SUBPROTOCOL_V2, theprotocolopt, thecompression=Noneparam, binary frames.stdb/connection.lua: ahandlers_v2table mappingthe v2 messages onto the existing event and cache surface, so
on_eventconsumers do notfork — plus the new
eventtype for event-table rows, which are emitted per row and nevercached.
stdb/cache.lua: one decoder seam indecode_rows, and a table without generatedtypesreported undecodable (nothing names columns on the binary wire).dev/tools/capture-v2.mjsdumps hex frames tosession_v2.jsonl; pin the open questions against it (ReducerResulttimestamp unit — microsor nanos, client frames accepted without a compression tag,
Option/Resulttag ordinals,identity byte order) and record each answer at the decode site. Then the full six-phase smoke
over v2, native and HTML5 — the latter also proves binary frames survive emscripten.
comparison from the two captures, published here and on the wiki. Then Protocol-Notes gains a
"v2 over BSATN" section, Usage-Guide documents the opt-in and the event-table event type,
Roadmap moves to done-pending-default-flip, and #77 gets its tick.
Deliberately out of scope: v3 (same message schema, multiple messages coalesced per frame — v2
sends exactly one, which keeps
handle_frameunchanged) and frame decompression (we always askfor
compression=None; a pure-Lua inflate stays unbuilt). CI keeps running v1 per-PR: a v2dispatch input lands in Phase 4, but adding it to the path filter doubles run time and waits until
v2 conformance is green and stable locally.
Results
All five phases shipped. Every number below was measured on one workstation — a Ryzen 7 8845HS
running headless Defold against a local
spacetime:v2.8.2— so read them as local figures. The~117ms/10k baseline this issue once quoted came off a different machine and is not comparable to
them.
Wire bytes — v2 is 46% of v1
Identical traffic over both wires, back to back against one server, from a purpose-built probe. The
two committed capture files drive different sequences and are not byte-comparable, so they
were not the source. The figure counted is the subscription readback frame.
Whole session at 10k, server → client: 2,085,999 B against 958,329 B (45.9%). The identical four
client messages: 309 B against 96 B (31.1%). "Roughly half the bytes" — the estimate this issue was
justified with — is confirmed, and turns out to have been slightly conservative.
Apply latency — v2 is faster at every row count; the absolutes are not pinned down
This section is a correction. It previously published 20.0ms against 10.5ms at 10k rows, a ratio
of 0.53, and a per-row 2.00µs against 1.05µs, with no statement of the conditions they were measured
under. Those figures were a median of 3 on a Ryzen 7 8845HS with the machine's load unrecorded. A
re-run of the same commit measured every native absolute 2-3x higher — v1 median 61.7ms at 10k
against 20.0ms, v2 44.6ms against 10.5ms, over 5 runs — with
/proc/loadavgat 11.06 on 16 coresfrom concurrent work, and the harness itself 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
published. That is a material difference, not a rounding one, so it is stated rather than smoothed
into a range that flatters the change. The honest claim is: v2 is consistently faster at every row
count on both tiers, and the size of the win is not pinned to two digits.
PHASE6 times the single frame that applies a first subscription. The original table is kept below as
one machine's snapshot under unknown load — not a regression baseline:
Both sweeps of each run are given because
conformance.shruns the smoke twice. Before reading aslower number here as a regression, check
loadavgand the harness spread warning — that exactambiguity is what forced this correction. A clean re-measure on a quiesced box is an open
follow-up below. The intended command, for whoever gets an idle machine:
The wire-byte tables above are unaffected. They are byte counts, not timings, and they stand
exactly as published: v2 is ~46% of v1 on row payloads and 31% on client frames.
Where the time goes, at 10,000 rows, native — same caveat on the absolutes, but the shape is the
load-independent part:
json.decode, 57%)Two claims in that split are structural — they follow from the encodings, not from the clock, and
both reproduce regardless of load.
The big-integer quoting bucket cannot exist on v2. That pass is v1 rewriting wide integer
literals as strings before
json.decodecan round them; it exists at all only because JSON cannotcarry a
u128exactly. A binary wire has no text to rescue. This is now enforced, not merelyobserved to be zero: the v2 path asserts zero quoting ATTEMPTS — a claim about
guard_calls/wide_callsthat fails if a per-rowstring.findscan ever returns on the binary wirewith its time billed nowhere. The three hit-based assertions beside it are
0 == 0on v2 and wouldhave passed unchanged, which is why the ATTEMPTS assertion had to be added.
Row construction falls with decoding, 6.8ms to 1.9ms: 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 — 68.0 pre,
21.0
json.decode, 23.0 build — against v2's 70.0ms — 17.0 pre, 45.0 BSATN decode, 8.0 build. Thestructural fact is the collapse of
pre, which is frame delivery: on v1 it is the single largestbucket at 61% of the frame, 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 load-independent; the millisecond totals carry the same caveat as the native table.
Per-sweep tables, the B/row columns and the methodology note are on the wiki:
Bytes and latency, v1 against v2.
The four open questions, pinned at capture
ReducerResult.timestampis MICROSECONDS. Upstreamv2.rs:378documents the field asnanoseconds and is wrong — the captured value is three orders of magnitude short of nanos, and
is exactly the unit v1's envelope uses. Row-level
Timestampcolumns are micros on bothprotocols, so nothing divides differently per protocol.
Identityis a little-endian32-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. Confirmedagainst the same identity over both protocols. Decode without the reversal and you get a plausible
hex string that matches nothing.
Optionis 0Some/ 1None;Resultis 0Ok/ 1Err. The same ordinals v1'spositional sums use, so
[0, v]/[1, {}]on JSON and tag0/1on BSATN say the same thing.not 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.
Each answer is recorded as a comment at the affected decode site.
Conformance and CI
All four suites pass end to end — the full six-phase smoke over v1 native, v2 native, v2 HTML5 and
v1 HTML5. The HTML5 pair is what proves binary frames survive emscripten; keeping the v1 pair is
what shows a second protocol cost the first nothing. Unit suite: 11 passed, 0 failed.
CI runs v2 by dispatch only — a
workflow_dispatchprotocol input that defaults to v1. Theautomatic push and pull-request path filter stays v1-only, because a second tier doubles run time for
a protocol nothing defaults to yet.
Divergences from v1, worth having written down
OutOfEnergyoutcome; it arrives asInternalErrortext.subscriberaises on v2 — usesubscribe_query.lightis v1-only; the v2 schema has no light mode.typesto be decodable at all: nothing on the binary wire names a column.ret_valuestays raw BSATN (see the follow-up below).Follow-ups, not part of this milestone
The three below were open when this milestone closed and remain open — none was picked up during
the review round. Two more were added by it.
for a while. Until then v1 stays what
connectnegotiates when you say nothing.filter is v1-only today for run time.
returnsfrom the schema generator, so aReducerResult'sret_valuecan bedecoded to a typed value instead of handed back as raw BSATN. Procedures already carry
returns; reducers do not, which is the only reason that value is untyped.event = truemarker for event tables from the schema generator. Verified againstdev/test/fixtures/chat_schema.luaas of this comment:dev/server/index.js:183declarespingwith{ name: 'ping', public: true, event: true }, and the generated fixture entry forping(lines 113-116) carries onlycolumnsandtypes— the marker is dropped. Views arenot: the generator does emit
view = trueformessage_flipandmessage_latest, so this isan omission in one code path rather than a missing capability. The SDK works without it because
an event table's transience is carried by the wire (
TableUpdateRows::EventTable) rather thanby the schema, so nothing is broken today — but a consumer reading the generated schema cannot
tell an event table from a pk-less persistent one.
Results section above with real ones. Both wires,
STDB_BULK_RUNS=5, the six-bucket sweep, with/proc/loadavgrecorded alongside the figures so the next reader can tell a regression from abusy box. The attempt during the review round was abandoned rather than published: the machine
was at loadavg 8.7-10.0 on 16 cores throughout (a Wine game and Steam, ~4 cores of it), which is
the same class of noise that produced the numbers this correction fixes. Publishing a second set
of load-contaminated figures would have re-created the problem, not solved it.
BSATN binary protocol supportto BSATN codec — the only route to the v2 message schemaAdding a source-verified constraint for the BSATN branch of this decision, and closing off one suspicion about the current JSON transport.
Investigated
clockworklabs/SpacetimeDBat tag v2.8.2.JSON is never compressed — the SDK needs no decoder today
The
compressionquery parameter defaults to Brotli when omitted (crates/client-api-messages/src/websocket/common.rs:36-45,#[default]on theBrotlivariant; parsed with#[serde(default)]atroutes/subscribe.rs:86-91). That looked like a latent hazard for a pure-Lua client that cannot inflate anything, and the SDK does not send?compression=None.It is inert. The deciding function is
serializeincrates/core/src/client/messages.rs:160-194, which branches on format:The JSON arm never reaches a compress call, and
SerializeBuffer::newallocates zero compressed capacity for text protocol (messages.rs:52-57:... || config.protocol == Protocol::Text { 0 }). The per-QueryUpdatehook agrees —JsonFormat::into_query_updatetakes the compression argument as_and returns the update untouched (subscription/websocket_building.rs:52-57), whileBsatnFormat's impl compresses.So: omitting
compressionis safe, sending?compression=Noneis unnecessary, and no Brotli/Gzip work is needed to stay on v1-over-JSON. One less reason to move.If BSATN happens, the framing gains a leading tag byte
This belongs on the checklist, because it is easy to miss and produces garbage rather than an error:
[1-byte tag][payload], tag0= none,1= brotli,2= gzip (common.rs:47-54, written bywrite_with_tag/compress_with_tagatmessages.rs:75-99).decide_compressionreturnsNoneat or belowCOMPRESS_THRESHOLD(websocket_building.rs:200-210).That threshold is the trap. A BSATN implementation tested on small fixtures sees tag
0every time and looks finished; it then breaks in production on the first row batch that crosses 1 KiB. So a BSATN codec must ship both decoders, or explicitly send?compression=Noneand rely on that — worth deciding rather than defaulting into.Two amendments to the checkbox list, if this ever goes ahead:
?compression=NoneAlso worth noting for the benchmark checkbox: the "JSON rows are roughly 2x the bytes" estimate compares uncompressed JSON against compressed BSATN. Since JSON is never compressed and BSATN is compressed above 1 KiB, the real gap on large row batches is wider than 2x — and on small frames, where both are uncompressed, narrower. A benchmark should measure both sides of that threshold rather than reporting one ratio.
Cost evidence for the BSATN decision, from the peer community SDK for Godot (flametime/Godot-SpacetimeDB-SDK). It made the opposite call —
v3.bsatn.spacetimedbfrom the start — so it is the closest thing to a measurement of what this checkbox costs in a scripting language.The codec is ~1.3k lines
core/bsatn_serializer.gd— 546 linescore/bsatn_deserializer.gd— 795 linescore/data_decompressor.gd— 35 linesBuilt on Godot's native
StreamPeerBufferfor little-endian byte primitives. So the honest estimate for a Lua port is "about that, plus whatever byte-packing costs without engine primitives" — which is the one real unknown below.Unresolved prerequisite:
string.packLua 5.1 has no
string.pack/string.unpack; those arrived in 5.3. Defold backports some later features selectively, and I have not verified whether it ships these. This matters a lot:StreamPeerBufferand the estimate above roughly holds.string.byte/string.chararithmetic in pure Lua, on the main thread, per field, per row. That is both more code and materially slower than the JSON path it is meant to beat — which would invert the entire rationale.Verify this before any other BSATN work. It is a five-line probe in a Defold build and it decides whether this issue is viable.
Details the Godot implementation had to handle
Useful as a checklist, since each one is a place a naive port breaks:
raw_bytes[0]— 0=none, 1=brotli, 2=gzip. It errors out on brotli and supports gzip via nativeStreamPeerGZIP. Defold has an existing zlib extension for gzip; punting on brotli is evidently an acceptable position for a community SDK, not a shortcut. Recall the threshold from the earlier note: compression only engages above 1024 bytes, so small-fixture testing will show tag 0 every time and look finished.BsatnRowListrow-size hints: tag 0 = FixedSize (u16 row_size + u32 data_len + packed rows), tag 1 = RowOffsets (u32 count + u64 offsets + u32 data_len). Two distinct parse paths, both required.opt_/vec_prefixes for nesting, and the deserializer recurses on those prefixes. This is a ready-made design for the Lua equivalent, and it is exactly the output #25 would need to emit.Sequencing
#25 (typed codegen) is a hard prerequisite: a wire codec cannot exist without column types, and #25 is on the 1.0 path anyway for reasons that have nothing to do with BSATN. So the useful order is #25 →
string.packprobe → benchmark → decide. The first checkbox on this issue (benchmark JSON vs BSATN) should not be attempted before the probe, since a negative probe result makes the benchmark moot.Also worth weighing against the move, from the earlier compression finding: v1-over-JSON receives richer non-caller transaction metadata than v2/v3 do. Going binary trades that away.
The JSON-vs-BSATN benchmark this issue's first checkbox asks for
Collected while measuring #30 (see #66), so it cost nothing extra. It argues
against doing this work.
json.decode's share of the frame that applies a first subscription, measuredover a sweep from 100 to 100,000 rows, 6 runs per point, against live pinned
2.8.2:
Full sweep, native, medians (rows → frame ms / decode ms / construction ms):
What that implies for a native decoder
Amdahl ceiling on replacing
json.decodewith a C++/native BSATN decoder,assuming the decoder were infinitely fast:
1/(1 - 0.45)= 1.8×1/(1 - 0.23)= 1.3×In #30's terms, 1.8× moves the native 100ms threshold from ~32,000 rows to
~58,000. It postpones the hitch; it does not remove it. And on the web tier —
where the frame budget is tightest, since an idle wasm frame already costs 18ms
to
requestAnimationFrame— a 1.3× ceiling is close to irrelevant.The remaining ~55% native / ~77% wasm is
protocol.quote_big_ints' gsub,building the row tables, pk indexing, event-list construction and
tables.dispatch. None of that is touched by swapping the wire codec.Conclusion
BSATN is not the fix for the frame-budget problem, which was the main practical
argument for it. #30's answer is time-slicing, and that works regardless of codec.
Staying closed for v1.1.0. Beyond the weak payoff, it re-opens protocol surface
one release after freezing the public API at v1.0.0, and the benchmark that was
this issue's own precondition now points the other way.
Worth noting the honest caveat: this measures decode of the JSON the server
already sends. It does not measure a hypothetical BSATN path end to end, so it
bounds the upper limit of the win rather than predicting the actual one. The
bound is what matters here, because the bound is already too small.
Partial retraction: the ceiling above is too low
Review of #66 found the measurement it rests on stops one function short, so the
1.8x native / 1.3x wasmfigure understates the case for BSATN. Flagging it nowrather than leaving a wrong number standing; corrected figures follow when the
re-measurement lands.
The instrumentation timed
json.decodeonly.protocol.quote_big_intsfell intothe bucket I then called untouchable — and it is not untouchable. Measured:
12.3ms of a 30.1ms frame at 10k rows, 75.3ms of 165.5ms at 50k — as much as
json.decodeitself, 41–45% of the frame, because it runs twice over the samebytes (once over the whole frame text, then once per row).
It exists only because JSON cannot carry u128 exactly.
stdb/protocol.lua:711-714documents it as a raw-text rescue. BSATN carries u128natively, so a decoder swap deletes that cost outright rather than reducing it.
Genuinely untouchable work — building row tables, pk indexing, event-list
construction, dispatch — is therefore around 11% native and 13% wasm, not the
55%/77% I claimed. That puts the real ceiling somewhere near 2.5–6x rather
than 1.8x/1.3x.
What that does and does not change:
because it works regardless of codec and because even a 6x speedup leaves a
10k-row apply costing several frames' worth of work in one frame.
closed it partly on "the main practical argument for BSATN does not survive the
benchmark". At 2.5–6x that argument does survive, and the honest position is
that BSATN is a real performance win whose cost is re-opening protocol surface
one release after freezing the API — a tradeoff, not a settled no.
Leaving #9 closed for v1.1.0 stands on the API-freeze timing alone, which is a
scheduling judgement rather than a claim about the numbers. I will post the
corrected three-bucket measurements here once #66's re-run is done, so the next
person deciding this has the real figures.
Apologies for the noise; better than leaving the wrong number as the record.
Corrected measurements, as promised
Re-measured with a five-bucket split that sums to the frame exactly
(
pre/json.decode/quote_big_ints/build/post), medians over 44samples at 10k rows, native, against live pinned 2.8.2. Full detail in #66.
WASM at 10k rows, 102ms total: rAF floor ~17ms (17%), websocket receipt/engine
~28ms (27%),
json.decode23ms (23%), quoting 20ms (20%), construction 14ms(14%), dispatch 1ms.
The corrected ceiling
What a codec swap deletes is
decodeplusquote—quote_big_intsexistsonly because JSON cannot carry a u128 exactly, and BSATN carries one natively.
What it cannot touch is
build,post, and (on web) the rAF floor and socketdelivery.
Native: untouchable is 11.3–11.7% net of measurement apparatus →
ceiling ≈8.5× (7.4× raw, per-sample range 5.1–9.5×).
WASM: untouchable is 58% of the frame, because the floor and socket receipt
are untouchable too → frame ceiling only ≈1.7×. Measured on the SDK's apply
alone, excluding engine overhead, it is 4.1×.
So my original 1.8×/1.3× understated native by roughly 4.5× and wasm by 1.3×.
That figure was simply Amdahl on
json.decode's share alone, which is the error.My interim estimate of 2.5–6× was also wrong: too low for native, too high for a
wasm frame.
These are ceilings if decode and quoting go to zero, not predictions. A BSATN
decoder still parses bytes. The number bounds what is available; it does not
promise it.
But the practical conclusion has moved somewhere else entirely
The re-measurement turned up something better than either answer, now filed as
#69:
quote_big_intshas no guard, while its siblingquote_wide_intshas one and short-circuits. On a table with no 128-bit column the hand-written
path pays a full
gsubover the whole frame and over every row to rewritenothing — 40–44% of the frame, and at 50k rows more than the JSON parser itself.
The evidence is already in the typed-path comparison: going typed halves the
quote bucket (12.2 → 6.4ms at 10k) purely because the guarded function replaces
the unguarded one.
So most of what BSATN would be credited with on this axis is available from one
necessary-condition guard, with no protocol surface re-opened and no API change.
That is the work to do first, and it makes the honest case for #9 weaker again —
not because BSATN is slow, but because the cheap fix takes the same ground.
Where that leaves this issue
Still closed for v1.1.0, and now on firmer ground than when I closed it:
decoder leaves a 10k-row apply spending several frames of work in one frame.
the real cost, and it is unchanged by any of these numbers.
If #69 lands and the remaining decode share still hurts at a row count real games
hit, that is the moment to reconsider — with the buckets already instrumented to
prove it.
Method note:
us/rowis not constant. Native is linear within 1% to 10,000 rows,then drifts +30–45% by 100,000 with sample spread reaching 31%, so a single
per-row figure stops describing the behaviour past ~25,000 rows.
All five phases are merged to
mainas of 2026-08-21:cccfd7astdb/bsatn.lua— pure-arithmetic BSATN codec91e3de1stdb/protocol_v2.lua— v2 message schema4dd965bconnect({ protocol = "v2" }), opt-in5da10db9f19af4Review round. Four reviews (two code, one harness, one security) ran against the stack before merge, then a second adversarial pass re-tested every finding with crafted bytes rather than accepting the fix commits. What they caught:
RowOffsetsgave quadratic memory amplification — a measured 232 KB frame producing 388 MB of Lua heap (model frame²/64, ~15 GB at 1 MB). Overlapping offsets made every row a near-copy of the buffer. Closed by validating the offsets as a non-descending in-bounds partition while the array is read, which also keeps an offset past 2^53 away fromstring.sub's undefined double→ptrdiff_t cast — that one returned the whole buffer on x86-64 while arm64 and wasm32 saturate the other way, so identical bytes would have decoded differently per platform. Re-proven closed: original PoC now rejected with a 2 KB heap delta, and 4000 randomised offset lists showed a worst decoded/rows_data ratio of exactly 1.000.Sec-WebSocket-Protocolat every connect including reconnects. Negative controls confirm both now reject a v1-masquerading-as-v2 run that the old gates passed.test_fixtures_v2.luanow replays every captured frame.State on
main: 11 unit suites green,stylua --check stdb dev/testclean, v1 and v2 native conformance both PASS post-merge (re-run after the final rebase),stdb/protocol.luabyte-identical to its pre-stack blob so the frozen v1 default is provably untouched.Follow-ups, each a separate decision rather than leftover work:
returnsfrom the generator, soret_valuecan be decoded instead of handed back as raw BSATN.event = truefor event tables from the generator. Verified missing:dev/server/index.jsdeclarespingwithevent: truebut the generated fixture carries onlycolumnsandtypes, whileview = trueis emitted for views — an omission in one path, not a missing capability. Nothing breaks today because event-table transience rides the wire (TableUpdateRows::EventTable), not the schema.One thing worth recording for whoever picks up the default flip:
Call:on_out_of_energycan never fire on v2. The v2ReducerOutcomehas noOutOfEnergyvariant, so energy exhaustion arrives asInternalErrortext. That is an upstream protocol change, not something the SDK can paper over, and it is a behavioural difference a default flip would impose on every consumer.Default-flip decision: not yet, and here is what changes that
A read-only assessment of the flip (v1 → v2) ran against
mainb057960. Nothing in the code blocks it. Two things outside the code do, and the assessment found eight divergences the previous list missed — two of which I would not have wanted to learn from a bug report.Corrected already, in #88
f32decodes to a different double on the two wires. v1 sends decimal text (json.decode→0.10000000000000001); v2 carries the stored single's four bytes (→0.10000000149011612). The repo claimed identical values in five places, including inside this issue's own flip-decision text, where it would have fed a wrong decision. Now pinned two ways and documented. v2 is the more faithful side.confirmed_readsresolves differently per protocol. An absent parameter resolvesfalseon v1 buttrueon v2 (subscribe.rs:103-111, asserted upstream at:2838-2844), so an opted-in v2 caller today already has updates held until the transaction offset is durable. Documented; no behaviour change.What blocks the flip
One item, and it is a decision rather than a defect:
confirmed_reads. The flip's whole premise is "no consumer code changes", yet it would silently change update latency for every consumer who never touched the option — in a game SDK. Two honest resolutions: pinconfirmed=falsewhen the caller said nothing (one line, preserves today's behaviour exactly), or accept the server default and make the latency change a headline changelog item. Either is fine; shipping without picking one is not.And one prerequisite that is not optional: v2 must be in the automatic CI triggers before it becomes the default, not alongside. Today
ci.ymlruns stylua + the Lua suite +nix flake checkon every PR, which covers v2's pure-Lua half well. The engine tier runsSTDB_PROTOCOL: v1and reaches v2 only byworkflow_dispatch. That engine tier is the only coverage forstdb/init.lua— subprotocol negotiation,DATA_TYPE_BINARYframe selection,compression=None, and the emscripten binary path. Flip without wiring it in and the shipped default has zero automatic engine coverage while the opt-out path keeps all of it: exactly inverted.Grades
confirmed_reads(undecided, silent).light = trueraising (the docs demonstrate that exact configuration, and the SDK's own smoke uses it on v1);reducers/proceduresbecoming mandatory to call anything (loud but currently unannounced, and no shim is possible — BSATN cannot encode without types).subscriberaising, the reducer/subscribe callbackmsgchanging shape, hand-written schemas and undecodable column tags degrading to skip-and-report, one-off rows becoming byte strings, rawret_value.Two capability losses worth stating plainly
on_out_of_energycannot fire on v2 and there is no substitute signal. The v2 outcome has four variants and no out-of-energy;energy_quanta_usedhas no v2 counterpart anywhere in the schema. A consumer showing "you're out of energy" cannot do that on v2 by any means the wire provides. Changelog line, not a heuristic on error text.TransactionUpdate. Deserves its own changelog paragraph.Sequence, each gate observable
confirmed_reads; assert the chosen URL intest_init.lua.Pin the— done in #88.f32divergencecontinue-on-errortoday).lightassertion.1.2.0as pre-announcement of the loud breaks.2.0.0, docs in the same commit.Versioning: this repo versions in
game.project(1.1.0), tagsv*, and publishes release notes from the tag annotation —release.ymlrefuses a tag without one.Development.mdstates an additive-only posture within 1.x. A flip changes observable behaviour for unchanged consumer code, so it is2.0.0and the upgrade note lives in the tag annotation.Follow-up 4 is dropped
event = truefrom the generator is dead weight, verified: nothing instdb/reads such a flag — the v2 event path keys off the wire variant (TableUpdateRows::EventTable), never the schema. The comparison toview = truedoes not hold, because that flag is read (stdb/tables.lua:146-149,stdb/schema.lua:32-45). The only thing it would buy is a betterrequire_pkdiagnostic. Not a generator work item, certainly not a flip prerequisite.Remaining follow-ups
confirmed_readsfor v2 (flip gate 1)2.0.0(after gates 1-5)returnsfrom the generator — additive, not a gate. Note the premise was wrong: v1 has no reducer return value at all, so v2 already gives strictly more. Worth pinning whether the schema endpoint even exposes a reducer return type before promising it.Emit— dropped, see above.event = truefor event tablesState on
mainb057960: 11 unit suites green, format gate clean, v1 and v2 native conformance both PASS,stdb/protocol.luastill byte-identical to its pre-stack blob.Default flipped in
v2.0.0—connect()now negotiatesv2.bsatn.spacetimedb, andconnect({ protocol = "v1" })is the JSON opt-out, CI-covered on both wires. Tracking issue: #89.