-
v2.0.0
Stablereleased this
2026-08-21 11:20:05 +00:00 | 23 commits to main since this releasev2.0.0 defaults to the v2 BSATN wire. The v1 JSON wire is still fully supported and
its behaviour is frozen, but it is now something a caller asks for by name. This
release also closes the four items v1.1.0 listed as known and not addressed: #63,
#65, #67 and #69.Breaking changes
connect()negotiatesv2.bsatn.spacetimedbwhen you say nothingstdb/init.luaresolvesopts.protocol or "v2". Pin the old wire with
connect({ protocol = "v1" }):stdb/protocol.luahas an empty diff against
v1.1.0, so v1 is frozen rather than merely retained, and conformance is now a
matrix over both wires on native and HTML5 instead of v1 alone.The flip is what a v2 subscription buys: readback is 46% of v1's row-payload
bytes and client frames are 31%, and 10000 rows apply in 10.5ms against v1's
20.0ms (native, median of 3). The big-int quoting bucket is structurally absent -
that scan exists only because JSON cannot carry au128exactly.Calls that raise on v2
Only the first is caught at
connect; the others raise at the callsite that
needs the missing piece, so a v1-era callsite finds out rather than drifting.light = trueraises. The v2 schema has no light mode, and dropping the
request silently would hand the full stream to a caller who asked for a
fraction of it. v1-only.- Calling a reducer or procedure needs
reducers = gen.reducersand
procedures = gen.procedurespassed toconnect, because BSATN carries
neither argument names nor widths. Without them the raise lands at the call.
Pre-encoded BSATN string arguments pass through as before. - The legacy
db:subscriberaises at the subscribe callsite. Use
subscribe_query- v2'sSubscribeis the query-set form.
Row decoding
- A table with no generated
typesno longer decodes. It is skipped and
reported through anundescribedevent, not raised. - An unknown type tag raises instead of passing the value through. BSATN carries
no widths, so a tag the decoder does not know means it can no longer find the
next column. - One-off query rows arrive as encoded byte strings.
- Event-table rows arrive decoded as an event and are never cached - the one
behaviour v1 has no counterpart for. Identityrenders as the byte-reverse of the wire, because
crates/lib/src/identity.rsstores it little-endian whileto_hexencodes the
big-endian array.ReducerResult.timestampis microseconds, not the
nanosecondsv2.rs:378documents; both were settled by hex-dumping real
frames rather than by reading upstream comments.
Callback payloads
msgtables are the v2 messages. Atransactioncarries noreducer_call
and nocaller_identity.on_out_of_energynever fires. The v2 outcome has noOutOfEnergyvariant
and there is no substitute signal, so exhaustion arrives as ordinary
InternalErrortext.- Reducer
ret_valueandErrpayloads are raw BSATN, with a printable summary
handed to the callback. The(err, msg)callback shape is frozen, which is
whyret_valueis not decoded for you.
Where the two wires genuinely differ elsewhere, v1's behaviour wins:
SubscriptionErrordrops no rows, because only the server knows which of them
another live query set still matches, and a transaction still emits oneupdates
entry per query set per table, because the cache's hold counts depend on that.confirmed_readsresolves to the server's default, which differs per wireconnectsends noconfirmedparameter unless asked, exactly as the TS, C#,
Rust and Unreal SDKs do, so a defaulted caller gets the server's own default -
and upstream resolves that tofalseon v1 buttrueon v2
(subscribe.rs:103-111). A defaulted caller carried from v1 to v2 therefore has
updates held until the transaction offset is durable: slightly higher latency for
stronger consistency. Passconfirmed_reads = falsefor v1-style immediate
delivery, ortrueto ask for it outright. The tri-state resolution is now
pinned by test so the default cannot be "helpfully" written out as
confirmed=false.The value-surface guarantee now excludes
f32Rows decode to the same Lua values on either wire for every type tag except
f32. That is the whole of the promise now. The README and four wiki locations
previously claimed it held for every type, which was untrue.A stored
0.1reads back as0.10000000149011612on v2 and
0.10000000000000001on v1. v1 sends decimal text andjson.decodereturns the
double nearest0.1; v2 carries the four bytes the server stored, so it widens
to that single's exact value. v2 is the more faithful side - it hands back what
the server actually holds, where v1 reconstructs a double from a decimal
rendering of a single. Only values that do not fit 24 mantissa bits diverge,
which is why nothing caught it: everyf32fixture in the repo was exactly
representable,-1.5and0included.No codec changed here - the defect was undocumented, untested silence. It is now
pinned two ways: the type-tag census asserts anf32argument comes back
narrowed, and a cross-protocol check decodes one decimal literal through both
codecs and asserts they differ for0.1while agreeing for-1.5. Each side is
pinned to its own exact double, so the assertion cannot be repaired by editing
one expected value.Features
frame_budget, spreading a large apply across engine framesconnect({ frame_budget = 0.008 })spends at most that long per engine frame
decoding rows and resumes on the next, so a large first subscription costs
several ordinary frames instead of one hitch. It is opt-in andnilby default:
slicing is visible in timing - callbacks that fired inside the receive frame now
land one or more frames later - and the 1.0 surface is frozen, so a
timing-visible change cannot be the default. Unset, the code path is what it was:
theEVENT_MESSAGEbranch callsconn:handle_frameinline and
cache.slice_hookstays nil.stdb/cache.luagains one module-level hook,cache.slice_hook, called every
256 decoded rows insidedecode_rows' two loops and nowhere else. That is the
only place in an apply where nothing has been written yet - the cache decodes a
whole batch before touching the store, so a malformed row cannot half-apply -
which is why a yield there cannot leave a half-populated table. Store mutation
and event emission stay atomic, so "callbacks only ever see a complete batch"
falls out rather than needing bookkeeping.stdb/init.luaholds one FIFO queue
of raw frame strings and at most one live coroutine, pumped by
timer.delay(0, true, ...). One coroutine at a time is what keeps ordering
intact: aTransactionUpdatearriving mid-InitialSubscriptionis appended
behind it and applied strictly after. The deadline is per pump invocation and
shared across every frame drained in it, so N queued frames cannot spend N
budgets in one engine frame. A dropped socket clears queue and coroutine - the
cache is cleared and every subscription replayed on reconnect anyway, and
abandoning is safe precisely because nothing was written.close()cancels the
pump and clears the hook. Aframe_budgetof 0, negative, non-numeric, or with
no clock available raises atconnect.What it does not bound: two parts of an apply are O(rows) and run whole. On the
v1 JSON wire, where this was measured, those aredecode_server's single
json.decodeof the entire frame text - 12.8-23.3ms at 25000 rows, which makes
it the worst frame in the apply - and the deliberately atomic store mutation,
7.3-13.7ms. At an 8ms budget, native, 25000 rows: the row-decoding frames sit at
8.5ms against an unsliced 55.5ms, and the whole apply spans 53.2ms against
55.5ms, so this is redistribution rather than overhead. The honest headline is
that the worst frame drops from ~55ms to ~20ms, not that it drops to the budget.
On wasm the mechanism works and the arithmetic is the tier's: a browser frame has
a ~17ms rAF floor, so an 8ms budget is already below the frame quantum - at 10000
rows the decode frames land at 16-17ms and the first frame at ~75ms. On v2 the
message decode leaves rows in aBsatnRowListstill encoded, so the cache
decodes them and slicing covers them unchanged.The reconnect spread seeds from the persisted identity
v1.1.0 added
connection.backoff_spread(identity, attempt)- a deterministic
multiplier in [0.5, 1.0) keyed on the identity, so a server restart does not
bring the whole player base back in one instant - and then fed it
conn.identity, which is nil until a socket has succeeded. The one case the
spread exists for, a fleet relaunching into an outage, therefore got no spread at
all and retried on the flat 1/2/4/8/16/30 ladder in lockstep.connectnow falls back topersisted.identity, whichconnection.lua's
IdentityTokenhandler was already writing and nothing ever read back:
backoff_spread(conn.identity or persisted_identity, attempt).That saved identity is not server-confirmed at this point - nothing has connected
yet - and using it anyway is deliberate. The spread asks for a value that differs
between clients, not for a correct one, so the failure mode of a wrong identity
is a wrong-but-deterministic slot, which is strictly better than every client in
the fleet sharing one ladder. Theorordering hands over to the wire identity
the momentIdentityTokenlands, and nothing else in the SDK reads the persisted
value. A genuinely fresh install, orstorage = false, has nothing persisted and
keeps the exact ladder: one new client is not a herd.Bug fixes
dev-server.shoverwrote another port's pidfile - point two ports at one
explicitSTDB_STATE_DIRand the secondstart_servercorrectly failed
SpacetimeDB 2.8.2's exclusive database lock, but still wrote$PIDFILEwith
its own doomed child's pid.stopon the first port then read a dead pid,
reportedno server running, and left a live server that nothing could find
again.start_servernow reads/proc/$pid/cmdlinefor the pid already in the
pidfile and aborts if it belongs to a live server on a different port, ahead
of both keypair generation and the spawn, so a refused invocation writes
nothing at all into the contested state dir - and fails in 2.08s instead of
waiting out a 31.59s ping timeout. Same trailing-space match as
server_pid()'s existing check, so port692does not match a server on
6921, and the same degradation: without/procthe guard is inert. A merely
stale pidfile still proceeds and hits the database lock, which is legible and
unchanged.- The big-int rescue scan ran unguarded -
protocol.quote_big_intsran its
patterngsubat both callsites with no precondition: once per frame in
decode_server, once per row indecode_row. On a table with no 128-bit
column every one of those scans rewrote nothing and still cost about what the
parse did - 40% of the applying frame. It now has the same shape as its sibling
quote_wide_ints: a plainfindfor the byte run"__, which every key the
pass can rescue contains, so absent that run the pattern cannot match. A"__
inside a row's double-encoded string is a false positive that runs the scan for
nothing, which is exactly the old behaviour; there is no false negative, the
only direction that could corrupt au128. Native median at 10000 rows with a
hand-written schema: frame 29.3ms to 20.2ms, quoting share 40% to 0%. This
applies to the v1 JSON wire only, and it collected in advance the part a BSATN
decoder would have deleted - which is why the documented codec-swap bound fell
from 6-8x to ~3x native.
What's Changed
- stdb: default to the v2 BSATN wire, and ship it as 2.0.0 in #94
- test: bring the v2 smoke to parity with v1's in #92
- fix: kill chromium's process group before removing its profile in #93
- ci: run conformance on both wires from the automatic triggers in #91
- test: pin the confirmed_reads resolution to peer-SDK parity in #90
- Narrow the value-surface guarantee to exclude f32 in #88
- test: a census of the type-tag grammar in #87
- readme: two wire formats, one value surface in #86
- conformance: run the smoke over v2, and capture the wire in #85
- stdb: connect({ protocol = "v2" }), opt-in in #84
- protocol_v2: the v2 message schema over BSATN in #83
- bsatn: a pure-Lua BSATN primitive codec in #82
- ci: retarget the HTML5 self-report and close out #16 in #80
- ci: run conformance on changes that affect it, not on a weekly clock in #79
- ci: report the HTML5 conformance verdict to #16 in #78
- docs: move documentation into the wiki, shrink README to a repo description in #75
- feat: spread a large apply across engine frames behind frame_budget in #76
- fix: refuse to start over another port's server in a shared state dir in #73
- feat: seed the reconnect spread from the persisted identity in #72
- perf: guard the big-int rescue scan on a necessary condition in #71
Full Changelog: v1.1.0...v2.0.0
Writing release-note headUse as a Defold library
Add to
game.projectdependencies:https://git.nokopia.com/yuris/spacetimedb-defold/archive/v2.0.0.zip https://github.com/defold/extension-websocket/archive/refs/tags/4.2.3.zipextension-websocket is required: Defold does not resolve dependencies
transitively, so without itconnectfails at runtime.Targeting HTML5? 4.2.3 drops the subprotocol there, so it needs the
patch inpatches/AND the remote dependency above removed - keeping
both puts twowebsocketextensions in one build and the link fails.
The README has the four commands.Private repo? Append
?token=<access-token>to the URL, the same
shape Defold documents for GitLab. Basic auth in the URL
(https://user:token@...) is not a substitute: the archive endpoint
ignores URL credentials - a real account with a wrong password still
gets200on a public repo - while?token=is validated and a bad
one gets a plain401.Downloads
-
Source code (ZIP)
3 downloads
-
Source code (TAR.GZ)
1 download