-
v4.0.2
Stablereleased this
2026-08-22 13:58:54 +00:00 | 1 commits to main since this releaseA one-fix patch release: a connection the consumer closed no longer handles frames. No API
changed, so upgrading is the dependency URL.Bug fixes
- A closed connection still handled frames -
db:close()stopped the sends but not the
receives, and handling one frame wrote_stateback to"connected", re-persisted the
identity and token to the save slot, re-emittedconnectedtoon_eventandon_connect, and
refilled the row cache. A UI that tore down on"closed"could be told it was connected again,
to a socket nobody asked to keep. Reachable on HTML5, and only there in a real program:
extension-websocket 4.2.4'sCloseConnectiongates its whole body onSTATE_CONNECTED, and
the unconditional transition toSTATE_DISCONNECTEDis compiled only underHAVE_WSLAY, so a
close()during the handshake is a genuine no-op on that target - the browser still fires
onopenand the server's frames keep coming for as long as the page lives. Native was never
affected on this path, sinceCloseConnectiondoes transition there.connection.dispatchnow
refuses any frame on a closed connection
(#115). - Post-close frames retained by the
frame_budgetqueue - thedispatchguard alone sits
downstream of the budgeted queue, andclose()cancels the pump without restarting it, so
every post-close frame was retained for the life of the page - roughly 4 MB per 200,000 frames.
TheEVENT_MESSAGEbranch instdb/init.luanow drops earlier still, before the queue, the
coroutine and the decode. db:state()is terminal at"closed"- with frames dropped at both layers, nothing moves
the state off"closed", which is the propertydb:state()'s own documentation already
claimed and is now documented as terminal. Teardown code on"closed"no longer has to defend
against a laterconnected, and the two send-path guards the pre-open hold added in v4.0.1
collapse to that single sticky test.
What's Changed
Full Changelog: v4.0.1...v4.0.2
Use as a Defold library
Add to
game.projectdependencies:https://git.nokopia.com/yuris/spacetimedb-defold/archive/v4.0.2.zip https://github.com/defold/extension-websocket/archive/refs/tags/4.2.4.zipextension-websocket is required: Defold does not resolve dependencies
transitively, so without itconnectfails at runtime.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)
2 downloads
-
Source code (TAR.GZ)
1 download
- A closed connection still handled frames -
-
v4.0.1
Stablereleased this
2026-08-22 11:41:54 +00:00 | 3 commits to main since this releaseA single-fix patch release: a call issued on the line after
stdb.connectno
longer raises. No API, option, or protocol-visible behaviour changed. The one
observable difference is that a too-early call's error now arrives on the handle
or callback it already returns instead of raising at the callsite; the outcome
still lands.Bug fixes
-
Frames issued before the socket opens are held
(#113) -
stdb.connectreturns before the websocket handshake completes, so a call on
the next line was too early: it reached extension-websocket's ownLuaSend
guard (websocket.cpp:562), which raises for any state that is not
STATE_CONNECTED, and the caller sawConnection isn't connected- an error
from a native module they never wrote, naming neither this SDK nor readiness.
Every other mistake here explains itself: a missing websocket dependency, a
URL with no scheme, a stale generated module all produce messages that name
the actual problem. Those frames are now held in a per-socket outbox and
flushed, in order, whenEVENT_CONNECTEDarrives. The flush is guarded
because HTML5 delivers that event twice for one socket - once from the
emscriptenonopenhandler, once from the shared update loop. Reproduced
in-engine against a live 2.x server before and after: the old raise also
killed the socket, so the run reconnected and then timed out. Four assertions
indev/test/test_init.luapin the hold, the flush, the order, and the
double-EVENT_CONNECTEDguard.The hold covers that one window and no more. After a drop, the gap until the
replacement socket exists still raises, deliberately:set_sendinstalls a
new holder only whenopen()runs, which is after the reconnect backoff, and
holding that gap would strand the caller whenever no socket ever replaces this
one (reconnect = false, or afterclose()) - worse than a raise. A socket
that dies without ever opening still settles every held call through the same
"connection lost"sweep as any other drop, becausestdb/connection.lua
parks each request inpendingbefore callingsend. TheREADINESSnote in
stdb/init.luanow states that boundary, and says to gate on the
"disconnected"and"socket_error"events rather thandb:state(), which
still reads its pre-drop value inside the very handler being told about the
drop.
Known in this release, filed as
#115: a closed
connection still decodes frames, which resurrects its state, re-persists
credentials and re-emits"connected". Not reachable from a native build's
close path. Fixed in v4.0.2.What's Changed
Full Changelog: v4.0.0...v4.0.1
Use as a Defold library
Add to
game.projectdependencies:https://git.nokopia.com/yuris/spacetimedb-defold/archive/v4.0.1.zip https://github.com/defold/extension-websocket/archive/refs/tags/4.2.4.zipextension-websocket is required: Defold does not resolve dependencies
transitively, so without itconnectfails at runtime.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)
2 downloads
-
Source code (TAR.GZ)
1 download
-
-
v4.0.0
Stablereleased this
2026-08-22 10:32:04 +00:00 | 5 commits to main since this releaseOne wire, and a surface that is harder to get wrong.
Requires SpacetimeDB 2.x, Defold 1.12.4 or newer, and extension-websocket 4.2.4. The quickstart
moved out of the README and into the wiki, where it walks an unpublished module through to a
connected game that replicates player positions between clients - every command in it executed
against a live 2.8.2 server.Breaking changes
The v1 JSON wire is removed
SpacetimeDB 2.x serves the v1 message schema on
v1.json.spacetimedband the v2 schema only on
v2.bsatn.spacetimedb, and no official client keeps v1 selectable: the Rust, C# and Unreal SDKs
all hardcode v2. This SDK was the only client carrying both, and it paid for it with a second
implementation of every send path, a parallel handler table, and a 1215-line JSON codec shipped to
every consumer insideinclude_dirs = stdb. The v1 wire is deleted, not deprecated -stdb/loses
1710 lines, a fifth of itself.A caller who pinned
protocol = "v1"has five things to change:- Delete
opts.protocol,opts.light,Builder:with_protocolandSUBPROTOCOL_V2. There is one
subprotocol,v2.bsatn.spacetimedb, andstdb.SUBPROTOCOLholds it.lightwas v1-only and
already raised on v2. - Delete
opts.jsononconnectandconnection.new- it only ever fed the JSON codec, and its
process-global codec guard goes with it, a hazard that existed only becausestdb/protocol.lua
held one codec per process.stdb.auth.exchangeandstdb.schema.fetchstill take ajson,
being HTTP. - Pass a generated
schema. Nothing on the v2 BSATN wire names a column, so a generated module is
not an upgrade here but the requirement: a table with notypesis skipped and reported rather
than decoded, and the reducer argument types are what let a call encode itself. - Replace
db:subscribe, the legacy whole-set form, withsubscribe_queryor
subscription_builder. The v2ClientMessagehas no legacySubscribe- itsSubscribeis the
query-set form - sodb:subscribehad no frame to send and has raised on the default wire since
2.0.0.c.subscriptionsand thesubscription_replacedevent go with it;unsubscribeis
untouched. - Drop
Call:on_out_of_energy.ReducerOutcomehas noOutOfEnergyvariant, so it registered a
callback that could never fire, which reads as working. Exhaustion arrives asInternalError
text and reacheson_error.
one_off_keyis gone too: v1 correlated a one-off query on an echoed hexmessage_id, v2
correlates onrequest_id.extension-websocket 4.2.4 is required
Move the dependency in your own
game.projectfrom 4.2.3 to 4.2.4. That release fixes
defold/extension-websocket#67, the bug
where theprotocoloption never reached the HTML5 handshake, which was the only reasonpatches/
existed here.patches/is deleted, along with the three manual steps a browser build used to
need - an HTML5 build now takes the plain remote dependency and nothing else. A consumer who was
running the patched local copy must remove it: twowebsocketextensions in one build fail to
link.Verified rather than assumed.
dev/tools/html5-conformance.shbuilds against the unpatched
dependency in a real Chromium and passes, and the handshake assertion is what proves the upstream
fix landed:[handshake] -> subprotocol=v2.bsatn.spacetimedb [handshake] <- 101 Switching Protocolsdb:pingreports(err, seconds)It used to report
cb(seconds)on success andcb(nil, err)on failure, inverted against every
other async call in the SDK.-- before db:ping(function(seconds, err) end) -- after db:ping(function(err, seconds) end)db.tables.<name>raises on a name the schema does not declaredb.tables.mesage:on_insert(fn)used to fail one line later with "attempt to index a nil value",
naming neither the typo nor the alternatives. It now names both, the same courtesydb.reducers
already did. A connection given noschematherefore raises on every table name, and says which
option is missing. A caller who useddb.tables[name] == nilto test whether a table exists needs
the schema or apcallinstead.A row with bytes left over raises
decode_typed_bsatnchecks for leftover bytes and errors instead of decoding short. Regenerate the
generated module whenever the server's schema changes and this never fires.decode_rowsruns in a
pass that writes nothing, so a raise cannot leave half-applied cache state.schema = genreplaces three correlated optionstypespace,reducersandproceduresare deleted as separate arguments.schema = gentakes
the whole generated module andconnectreads all four sections off it. The four separate options
still work and win if both forms are given.-- before schema = stdb.schema.new(gen.tables), typespace = gen.typespace, reducers = gen.reducers, procedures = gen.procedures, -- after schema = gen,The point is not the line count: the four sections must all come from the same generation, and
dropping justreducersused to decode tables fine and fail only at the first reducer call,
wire-side, which is the one failure that does not point at the option that caused it.jsonis no longer required either. It defaults to the engine's own global instdb.schema.fetch
andstdb.auth.exchange, and is validated when passed; passing it is only needed off engine, in
tests.-- before stdb.schema.fetch({ base_url = url, database = name, json = json }, cb) -- after stdb.schema.fetch({ base_url = url, database = name }, cb)Features
stdb.builder(opts)seeds the raw optionsThe fluent builder no longer stops at what its setters cover.
stdb.builder(opts)seeds
on_event,frame_budget,reconnect,storage,confirmed_readsand anything elseconnect
accepts, andbuild()copies the seed verbatim, so a builder user is never forced back to raw
connectfor one option and never loses the three lifecycle callbacks by going there. The seed is
copied, sowith_tokenandwith_schemawrite into the builder's own table rather than the
caller's.stdb.queryis re-exported from the facadeThe typed query builder is reachable as
stdb.query, like every sibling module on the facade.
subscribe_queryalready accepted what it builds, so the only caller who knew it existed was one
who ranrequire("stdb.query")by hand.Bug fixes
db:pingraised the round trip time as an error - the inverted callback meant
stdb.await(db.ping, db)threw the latency. Both existing tests passed under the bug because
they only assertederr == nil.- A schema behind the server dropped a column silently and forever - nothing checked for bytes
left over after decoding a row, so a column appended on the server vanished from every row, and
one inserted in the middle misaligned the reader: two same-width scalars produced a plausible
wrong number rather than an error. stdb.awaitparked a coroutine forever when the socket dropped mid reducer call -
call_reducersettles itsCallhandle (ANSWERED_ELSEWHERE) instead of firing the callback,
andawaitdiscarded the handle. The same happened to an awaited subscribe across a reconnect
replay. It wires the handle too now.stdb.await_eventunhooked a second waiter when the first resolved - restoringdb.on_event
on resolve dropped a later waiter's wrapper out of the chain, so two coroutines waiting on
different events left one of them stranded. Only reproduces once a second coroutine exists.stdb.await_eventhung when the event had already fired - it waited only for a future
firing, so "wait until connected, then resubscribe" parked forever after a reconnect had already
landed, which is the natural use and the exact case that broke.- The editor command generated a schema from the wrong database -
databaseunder
[spacetimedb]ingame.projectis required now, and named in the error when it is absent. It
used to default to this repository's own dev fixture, so a consumer who never declared the
category got a schema from someone else's module, successfully and silently, and that stale
module then misdecoded every row.base_urlstill defaults tohttp://127.0.0.1:3000. stdb.urlaccepted a base URL with no scheme - a missingws://orwss://used to fail
inside the engine's websocket, naming neither this function nor the missing scheme. It is
rejected at the callsite.- The documented
pcallshape aroundawaitcould not work - Lua 5.1 cannot yield across
pcalland the HTML5 target is plain Lua 5.1, so an awaiting coroutine cannot be wrapped in a
single outerpcall; catch failures around the awaited step instead. The old documentation
claimed otherwise.
What's Changed
- deps: take extension-websocket 4.2.4, and drop the HTML5 patch in #112
- stdb: fix five silent failures, and delete three required arguments in #111
- stdb: drop the v1 JSON wire, and ship it as 4.0.0 in #110
- flake: drop forgejo-mcp and the omp-forgejo wrapper in #109
Full Changelog: v3.1.0...v4.0.0
Use as a Defold library
Add to
game.projectdependencies:https://git.nokopia.com/yuris/spacetimedb-defold/archive/v4.0.0.zip https://github.com/defold/extension-websocket/archive/refs/tags/4.2.4.zipextension-websocket is required: Defold does not resolve dependencies
transitively, so without itconnectfails at runtime.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)
2 downloads
-
Source code (TAR.GZ)
1 download
- Delete
-
v3.1.0
Stablereleased this
2026-08-22 05:12:38 +00:00 | 9 commits to main since this releaseGzip frames are decoded, and
connectasks the server for them. Additive: nothing
a caller writes changes, and the only new option,inflate, defaults to the
ambient builtin.Features
Gzip frames on the v2 BSATN wire
The server compresses any message over 1KiB and its default scheme is brotli,
which is whyconnecthad always pinned?compression=None- an omitted
parameter is a wire no Defold build can read. It now appends
?compression=Gzipwhen a gunzip is in reach and?compression=Nonewhen it is
not, so a session that cannot inflate still gets a readable wire instead of a
broken one; the parameter is never omitted. On the v1 JSON wire it changes
nothing at all - measured identical acrossNone,BrotliandGzip- so v1
passes no gunzip.Decoding lives in
decode_server(data, inflate)instdb/protocol_v2.lua. The
compression byte replaces the message's leading byte rather than prefixing the
stream, so the gzip magic sits at byte 2 and the plaintext is the
ServerMessagefrom its sum tag. The gunzip arrives as an argument instead of a
reach for thezlibglobal, which keeps the module free of engine APIs;
stdb/init.luaresolves the ambientzlib.inflatethe same way the builder
resolvesjson, andopts.inflatesubstitutes for it. Brotli raises by name
(brotli frame; Defold has no brotli decoder) rather than behind a generic
"compressed frame" message: the engine ships zlib and no brotli, so it is a wire
this client cannot read, not a feature nobody got round to.zlib.inflateonly learned to sniff the1f 8bwrapper and strip it in Defold
1.12.4 (engine/dlib/src/dlib/zlib.cpp); 1.12.3 has no gzip path at all, so
1.12.4 is the floor this release documents. Bandwidth, measured against 2.8.2 on
the v2 wire: a 33 KiB subscription readback arrives in 14,526 B of 33,338 B, a
479 KiB one in 203,745 B of 478,921 B. Below the 1KiB threshold nothing changes,
so the ordinary per-transaction update is untouched - this earns its keep on the
initial subscription and nowhere else.What's Changed
- docs: point the install URLs at v3.1.0 in #108
- decode gzip frames, and ask the server for them in #107
- patches: carry upstream PR 68 instead of our narrower fix in #106
- README: state, not usage, in the features table; drop the 2.x migration guide in #105
Full Changelog: v3.0.0...v3.1.0
Use as a Defold library
Add to
game.projectdependencies:https://git.nokopia.com/yuris/spacetimedb-defold/archive/v3.1.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
-
v3.0.0
Stablereleased this
2026-08-22 03:05:23 +00:00 | 13 commits to main since this releaseNear feature parity with the official SpacetimeDB 2.x clients, typed query builder
included, and a schema generator a consumer project can actually reach. The major is
for one break - row callbacks take a context first - and everything else here is added
surface, built from thespacetimedbnpm package 2.8.2's own source so that every name
below mirrors something real.Breaking changes
Row callbacks take a context first
db.tables.message:on_insert(function(row) end) -- 2.0.0 db.tables.message:on_insert(function(ctx, row) end) -- 3.0.0 db.tables.message:on_update(function(old, new) end) -- 2.0.0 db.tables.message:on_update(function(ctx, old, new) end) -- 3.0.0The fix is mechanical and the same everywhere: add a leading
ctxto every
on_insert,on_updateandon_deletehandler. Clean cutover - no shim, no arity
sniffing - and the conformance harness, every test, the README, the wiki and the new
example are migrated in the same branch.ctxis one object per connection carrying
db,reducers,procedures,event,is_active(),disconnect()and
subscription_builder(), withctx.eventset to the tagged cause of the batch
immediately before the callbacks that batch fires.Nothing else in the 2.0.0 surface changed shape.
on_event,
Subscription:on_applied/on_error,Call:on_ok/on_error/on_out_of_energyand
everydb:method keep their signatures; the other ctx-first callbacks named below
belong to API that is new in this release, so there is nothing to migrate there.Features
Typed query builder
stdb/query.lua.tables.message:where(function(r) return r.sender:eq("yuris") end)
compiles toSELECT * FROM "message" WHERE "message"."sender" = 'yuris'. Operators
eq ne lt lte gt gte, combinatorsand_ or_ not_, andleft_semijoin/right_semijoin
under the official SDKs' own names, so a TypeScript callsite ports across.not_works
despite the grammar having noNOT, by inverting the comparisons and applying De Morgan.This is a string templater with the schema checking it, not a query planner, because the
official builder is one too: it compiles to SQL in the client (lib/query.ts'stoSql,
consumed bysubscription_builder_impl.ts:122-126), so a query always leaves the client
as text. What the schema buys is callsite errors - an unknown column, a wrong value type,
a u64 above 2^53 passed as a number, a non-hex value for anIdentitycolumn, an
unindexed join column each raise naming what was wrong, instead of the server rejecting
a string.The boundary is the subscription grammar itself:
SELECT * FROM t [JOIN t2 ON col = col] [WHERE pred], ops= != < <= > >=, no projection, no ordering, no limit, at most two
tables, both join columns indexed. Six generated queries were POSTed to a live server and
accepted, including a join andsender = ''' OR 1=1 --', where the doubled quote returns
200with no rows rather than executing.Reachable schema generation
Since 2.0.0 made the v2 BSATN wire the default, a table with no generated
typescannot
be decoded at all - it is skipped and reported asundescribed. The generator the docs
told a consumer to run wasdev/tools/gen-schema.sh: bash,curlandjq. But
.gitattributesexport-ignores/dev, so the library archive a consumer actually gets
holds 13 entries and none of them is that script. The one artefact the default wire
cannot work without was the one artefact that was never delivered.stdb/typetags.luais the jq walker ofgen-schema.sh:171-459ported to Lua, and two
paths sit on top of it.stdb.schema.fetch(opts, cb)reads
<base>/v1/database/<db>/schema?version=9through thehttp_seamand calls back a ready
gentable - the development path, no external tool at all.stdb.schema.to_lua(gen)
serialises the same table to a loadable module carrying the same-- GENERATEDheader
the shell script writes - the release path, a file to commit and pin. Both go through
typetags.build, so the fetched schema and the pinned one cannot describe a table
differently. The seam grewget(url, cb)beside its existingpost, same
(status, body)callback and the samestatus == 0meaning nothing answered.stdb/spacetimedb.editor_scriptadds Project → SpacetimeDB: Regenerate schema. It
lives understdb/becauseinclude_dirs = stdbis the only directory Defold exposes to
a consumer project, reads its defaults fromspacetimedb.base_urland
spacetimedb.databasein the consumer'sgame.project, and writes/my_schema.lua. It
carries its own ~60-line JSON decoder because the editor's Lua 5.2 runtime has no JSON
module, and requiringjqin the editor would reinstate the dependency this work exists
to remove; that decoder is editor-only and never reaches a game bundle. Shipping the
shell script and shelling out to it is not possible - a dependency's files are zip
entries, and onlyext.manifestandplugins/bin/${platform}are ever extracted.
gen-schema.shkeeps its existing job as the contributor drift check at
conformance.sh:61-76.Contexts, connection builder and accessors
stdb.builder():with_url(...):with_database(...):with_schema(gen):build()is the
official connection-builder chain over the sameconnectit has always been sugar for,
withwith_token/with_protocolandon_connect/on_connect_error/on_disconnect
takingctxfirst.db.reducers.<name>(args, cb)anddb.procedures.<name>(args, cb)
are generated from the schema and raise on a typo with the real names listed, rather than
reaching the server as an unknown reducer.db:subscription_builder()chains
on_applied/on_errorintosubscribe(queries)orsubscribe_to_all_tables()and
returns a real handle:is_active,is_ended,unsubscribe,unsubscribe_then.
db.dbis the official name for the table map, and
remove_on_insert/remove_on_update/remove_on_deletealiasoff_*- one detach under
either spelling, so neither name breaks.Connection state and latency
db:state()returnsconnecting/connected/reconnecting/closed, assigned only at
the five points the SDK already learns of a transition, so it is a plain accessor rather
than a probe.db:latency()is an exponential moving average at weight 1/8 - TCP's own
SRTT smoothing - over correlated request/response pairs the SDK already exchanges: no new
traffic, no heartbeat.db:ping(cb)forces one fresh measurement with a one-off query
againstst_tablefiltered to match nothing. Enough to build a "reconnecting..." banner,
which nothing before this could.Coroutine mode
stdb.await(fn, ...)andstdb.await_event(db, type)instdb/await.luaput a
coroutine mode beside the callbacks, for sequential flows that should read as sequential.
Opt-in through an explicit wrapper on thenakama-defoldprecedent rather than
auto-detecting a coroutine, a truthyerrraises inside the coroutine instead of being
returned for the caller to forget, and astateguard is what stops aresumeon a
coroutine that never yielded.An openable example
dev/example-chat/is a real Defold project -game.project, a GUI, an input binding -
rather than the conformance harness, plus a headless entry point that proves the wiring:
it sends a message and printsEXAMPLE: PASSonly once that row comes back through
on_insert. The v2 leg of the conformance workflow builds it headless and greps for that
line, so the first code a consumer copies is code something runs..script_api: answered, not builtThe Defold manual scopes
.script_apito native extensions, and separately says the
editor completes Lua modules a scriptrequires. A pure-Lua library needs neither, so
there is nothing to generate. Recorded as a decision on the wiki roadmap instead of
staying an open item.Bug fixes
- The README quickstart could not be followed on the default wire - it omitted
reducers/procedures, so the reducer call it told you to make failed withno schema entry for reducer send_message. Both quickstarts then called that reducer immediately
afterconnect, where there is no socket yet andstdb.connect's pre-socket seam
raisessend before the socket was created. Found by building the example against the
documented path; the sends now sit in the connected path, where the conformance harness
always had them. - A second connection could re-point the first one's JSON codec -
stdb.protocolis
pure and holds its codec in a module local, soprotocol.initre-pointed every
connection already running, not just the one being opened. It surfaced as a wrong
number rather than an error: the two-socket conformance phase handed its observer an
uninstrumented codec and the measurement on the other socket reported10000 row(s) cost 0 json.decode call(s).connection.newnow asserts that a second connection
passes the samejsonimplementation.protocol.initstays permissive - wrapping or
instrumenting the codec on purpose is a documented act, andtest_protocol.luadoes
exactly that; two connections disagreeing by accident is the bug.
What's Changed
- test: prove the light-mode claim on two sockets, and stop a codec swap in silence in #104
- ci: run the example, and stop commenting on a closed issue in #103
- feat: near parity with the official SDKs, typed query builder included in #102
- test: pin what a mutation audit could break in silence on the default wire in #101
- docs: title the README "SpacetimeDB Defold SDK" in #100
- docs: rewrite the README as an SDK introduction in #99
- fix: build conformance offline when the deps are already cached in #98
- feat: reachable schema generation, and a README that describes the SDK in #97
- docs: say how to decode a one-off row, and why we do not do it for you in #96
- docs: make the facade's own example run on the default wire in #95
Full Changelog: v2.0.0...v3.0.0
Writing rewritten proseUse as a Defold library
Add to
game.projectdependencies:https://git.nokopia.com/yuris/spacetimedb-defold/archive/v3.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)
4 downloads
-
Source code (TAR.GZ)
1 download
- The README quickstart could not be followed on the default wire - it omitted
-
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
-
v1.1.0
Stablereleased this
2026-08-20 16:30:02 +00:00 | 43 commits to main since this releasev1.1.0 adds a provider-credential exchange and a deterministic per-client reconnect spread.
Nothing a v1.0.0 caller wrote stops working: the renames in
#68 all land on surfaces that first
appear in this release, and the two changes to frozen surfaces -sys_storage's load filter and
connect's identity save - cannot be observed by a released consumer, which only ever reads
persisted.tokenback. Applying a first subscription was also measured, if you are budgeting one:
it is a single frame's work, which natively exceeds the 60fps budget at roughly 6,100 rows and
100ms at roughly 31,000, and on HTML5 reaches 100ms at roughly 9,800, where an idle wasm frame
already costs 17ms torequestAnimationFrame- each threshold swept across its crossing rather
than extrapolated, with time-slicing tracked as #67 and an unguardedprotocol.quote_big_ints
costing 40-44% of the native frame as #69.Features
stdb.auth.exchange(opts, cb)Trades a provider credential - a Steam session ticket, a Google or Apple ID token, your own
session cookie - for a SpacetimeDB token you hand toconnectas itstokenoption.
exchange({url, provider, credential, json}, cb)POSTsjson.encode({ token = credential })as
application/jsonand calls back(err, token)exactly once. The option iscredentialwhile
the body field istoken: the body shape belongs to your backend, andtokenmeans a
SpacetimeDB token everywhere else in this SDK, including this function's own result. The
credential travels in the body and never in anAuthorizationheader - the exchange target is
not the resource server, and a header invites a proxy to forward it as one. The tests assert
there is no other header and no other field.timeoutis 10s per attempt rather than total, armed after the request and skipped if the
request already answered, since a seam is free to call back synchronously.retriesis 3 further
attempts withbackoff_base0.5s doubling - four lines of its own arithmetic rather than
connection.backoff_delay, so one knob does not move both the exchange and the socket reconnect.
A 5xx or a transport failure retries; a 4xx never does, because a rejected credential will not be
accepted on the second ask and retrying would cost 3.5s before the game learns the player is not
signed in. Neither the credential nor the token reaches an error string or a log line:auth.lua
contains noprint, no response body is interpolated into a message - a body may itself carry a
token - and errors name a status and a count. A redaction test sweeps every failure and success
path and asserts none of the strings the SDK can produce contains either secret, plus that the
sweep collected more than 8 composed strings, so it cannot pass vacuously.storagedefaults tonil, so nothing is cached - deliberately unlikeconnect, which defaults
tosys_storage(). An identity token is the SDK's own to persist; a provider-exchanged token is
the game's credential, so the caller opts in. Opting in stores
{ auth = { [provider] = token } }, merged on save so a second provider cannot evict the first,
keyed on the caller-suppliedproviderstring. There is no JWT parsing anywhere, so no base64url
decoder ships and no unverified claim can be mistaken for an authorization decision. A hit
short-circuits and makes no request. The cached token is not validated locally - verifying one
needs a round trip, andconnectalready handles a rejected token by reconnecting - so a stale
entry surfaces as a failure atconnect, and clearing the cache is the caller's move.The boundary: SpacetimeDB 2.8.2 has no exchange endpoint.
/v1/identity/token,/v1/token,
/v1/authand/v1/identity/exchangeall answer 404, onlyPOST /v1/identityexists and it
mints an anonymous identity, andspacetimedb-standalone startexposes a single
--jwt-pub-key-path/--jwt-priv-key-pathkeypair with no issuer list or JWKS URL, so it cannot
be pointed at a third-party OIDC issuer either.opts.urlis therefore your own backend, which
verifies the credential and obtains the identity. It is a configurable URL either way, so a
future server version that grows an endpoint needs no code change here.stdb.http_seam()The SDK's first HTTP, defined in
stdb/auth.luaand re-exported for symmetry with
stdb.sys_storage, which it mirrors: a factory returning a table of closures, so tests
substitute a table and skip the engine.post(url, headers, body, cb)calls back
(status, body), wherestatus == 0means nothing answered.There is deliberately no separate
errargument. Status 0 is Defold's own transport-failure
signal, verified against engine source rather than assumed:
engine/dlib/src/dlib/http/http_service.cpp:637-648sends the real status only onRESULT_OKor
RESULT_NOT_200_OKand a literal 0 otherwise,script_http.cpp:158passes it through, and
Defold's own engine test asserts it -test_http.lua.raw:79-87expects 0 for an unresolvable
host while a real 404 asserts 404. The HTML5/XHR path reports 0 too, so one detection covers both
targets. Defold's publishedhttpdocs do not document the convention. Two traps are recorded in
the seam's comment:res.responseis""and notnilon a transport failure, andres.error
is not a transport indicator - it is set only for a failed download-to-disc write.connection.backoff_spread(identity, attempt)A deterministic multiplier in
[0.5, 1.0)derived from the identity and the attempt, so a server
restart does not bring the whole player base back in one instant.
connection.backoff_delay(attempt, base, cap, spread)gains a trailing optional fourth parameter
applied after the cap, so a capped delay is spread too - otherwise every client in a long outage
converges on exactly 30s, which is the herd again at the worst moment. Anilspread is an exact
no-op, which is why v1.0.0's seven exact-value assertions still pass untouched:
backoff_delay(99, 1, 30, 0.5) == 15,backoff_delay(6, 1, 30) == 30.Deliberately not
math.random. A library callingmath.randomseedclobbers the consuming game's
own RNG sequence and any deterministic simulation built on it, and an unseededmath.randomhands
every client the same spread, which is the stampede this exists to prevent. Every client already
holds a unique 64-hex-char identity by the time it can be in a reconnect herd, so the spread
needs no RNG, no global state and no seeding, and stays exactly testable. The arithmetic is
integral in Lua 5.1: the largest intermediate is4294967296 * 31 + 255=133143986431,
against2^53= 9.0072e15. The spread redistributes per attempt rather than holding one fixed
offset - for a single identity, attempts 1..10 give 0.7405, 0.537, 0.6855, 0.834, 0.6305, 0.779,
0.9275, 0.724, 0.8725, 0.521. Conformance logs it on the wire:reconnecting attempt 1 in 0.744s,
where before the change it was a flat1s.The boundary:
backoff_spreadreturnsnilbefore an identity exists, because a first connect is
a cold start and not a herd. A fleet relaunching into an outage therefore still retries in
lockstep, tracked as #65.Bug fixes
sys_storage's load filtered on a.tokenfield - the seam was written forconnect's
{identity, token}record and silently made itself useless for any other shape, so the storage
configuration documented forauth.exchangenever cached and evicted on every save.loadnow
treats an empty table as "nothing stored", which is what Defold'ssys.loadreturns for a
missing file. No released consumer can observe the widening:connectreads only
persisted.token, where an empty table behaved asnilalready.connect's identity save rewrote the storage slot whole - theIdentityTokenhandler
calledsave({identity = ..., token = ...})wholesale, so every connect dropped anauthcache
sharing the slot, which nesting the auth record under its own key fixed in one direction only.
The handler now loads, sets the two fields and saves. A slot was always a caller-chosen
sys_storage(name), so pointing two things at one is a supported arrangement in both
directions.- The README's HTML5 persistence caveat named the wrong mechanism - it was written from
observed behaviour; it is now read out of Defold engine source at tag 1.13.1, with the loader
patch confirmed present at 1.4.8, 1.6.0, 1.9.5, 1.10.4, 1.11.2, 1.12.4 and branchdev.
sys.save's__EMSCRIPTEN__branch (engine/script/src/script_sys.cpp:202-218) is
fopen/fwrite/fcloseand does not sync, butdmloader.jsmounts the save root as IDBFS and
patchesFS.close, so every save starts an asynchronousFS.syncfs(false, cb), coalesced and
retried up to 3 times. Asyncfsis mount-wide, so a single failed flush is written by the next
one that succeeds; what has no recovery is three consecutive failures, after which the loader's
guard is left stuck and persistence is dead for the lifetime of the page. Where IndexedDB is
unavailable - some private-browsing modes - the mount is skipped,Module.persistentStorageis
set to false, andsys.savewrites the in-memory layer only with no error raised. The SDK adds
no flush of its own, becausehtml5.run("FS.syncfs(false, function(){})")starts the same
fire-and-forget operation the patchedFS.closealready started; the one ordering that
genuinely waits is reloading from inside the callback, and that is what the caveat now gives. - The dev-server shared one state directory across ports -
dev/tools/dev-server.shderived
PIDFILE,DATA,KEYS,LOGFILEandHOMEfrom a fixed/tmp/spacetimedb-defold, so a
second port reported the first port's server as its own andcurlthen failed to connect. The
default is now keyed on$PORT-/tmp/spacetimedb-defold-3000for the defaultSTDB_PORT-
andserver_pid()verifies the recorded pid is our server for this port by reading
/proc/$pid/cmdlinefor--listen-addr 127.0.0.1:$PORT, where the trailing space is
load-bearing: it stops port 1234 matching a server on 12345. Reading argv needs no external
binary, has no pipeline forset -o pipefailto reach, and stays true during the window before
the process has bound, which keepsstopable to kill a server that is still starting; a host
without a readable/procdegrades to the previouskill -0. An explicitSTDB_STATE_DIR
still overrides, and two ports pointed at one explicit directory can still leak a server,
tracked as #63.
What's Changed
- release: v1.1.0 in #70
- api: settle the v1.1.0 surface before it freezes in #68
- Measure the frame that applies a first subscription (#30) in #66
- auth: exchange a provider credential for a SpacetimeDB token (#31) in #64
- docs: state the real HTML5 save mechanism, from engine source (#20) in #61
- feat(connection): spread the reconnect backoff per client, deterministically (#19) in #62
- dev: isolate dev-server state per port and verify the pid holds it (#54) in #60
Full Changelog: v1.0.0...v1.1.0
Use as a Defold library
Add to
game.projectdependencies:https://git.nokopia.com/yuris/spacetimedb-defold/archive/v1.1.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)
2 downloads
-
Source code (TAR.GZ)
1 download
-
v1.0.0
Stablereleased this
2026-08-20 11:43:15 +00:00 | 50 commits to main since this releaseThe 1.0 API freeze: every public name in
stdb/- six pure-Lua modules speakingv1.json.spacetimedbover Defold's own websocket extension, no native extension and no build step - is now permanent. Twenty pull requests since v0.1.0, verified against a live SpacetimeDB 2.8.2 inside a real Defold engine on both the native and HTML5 targets through five conformance phases, withdev/test/fixtures/session.jsonlholding 50 captured frames across four sockets that the suite replays and a generated schema re-derived from the live server on every run and diffed. The release-gate checklist, including what was deliberately deferred, is issue #24.Breaking changes
The tag makes every name permanent, so #55 spent eight renames at the last cheap moment. Six are pure spelling, two change what a call returns. Below that, one value representation changed in #37.
Event
type = "error"→"socket_error"The websocket transport's error event. It was the only emitted event type absent from the README's event table - 16 rows for 17 types - and
"error"already carried three other meanings beside it:evt.error,sub.state == "error"andcall.state == "error". The table is 17/17 now, including the three sitesconnection.luabuilds dynamically (:764,:899,:934) that a literal grep cannot see.db.state→db.cacheThe connection's row cache.
sub.stateandcall.stateare lifecycle strings, on objects a caller holds at the same moment, so one word named two concepts:if sub.state == "applied" then stdb.cache.get(db.state, ...) end. 55 field lines moved, andcache.lua's own first parameter with them, since leaving it would have kept the word in play.undescribedevent:rows→droppedIt is a count, not a payload, and
rowssat one character fromrowon an insert event, which is a row. Renamed in all three places at once: the event,counts.undescribed[i]andsub.undescribed[i].schedule_at_to_secondsreturns"Interval"/"Time"A behaviour change, not a spelling: v0.1.0 returned
"interval"/"time".decode_valuealways produced the capitalised form - those are the server's own declared variant names for the SDK-builtinScheduleAt- so a consumer who compared against the documented lowercase gotfalseforever with nothing raised. Compare against the tag,kind == v.tag, rather than a literal.The helper was fixed rather than the decoder, because 2.8.2 lowercases module enums but not this one:
dev/server/index.jsdeclarest.enum("Status", ["Idle", "Busy"])and the published schema saysidle/busy. Lowercasing the decoder would have broken the larger cohort - every cached-row.tagreader, plus reducer parameters and procedure returns.stdb.url(host, database)→stdb.url(base_url, database)Positional, so no call site changes. The name was wrong and the README believed it, claiming
ws://was prepended: nothing prepends a scheme, sostdb.url("127.0.0.1:3000", "chat")returns a scheme-less string and the connect fails. Pass a scheme-qualified origin.protocol.decode_typed(encoded, spec, ...)→(encoded, def, ...)Positional, so no call site changes. A schema entry had three names on the frozen surface -
def,defs,spec- andspecwas the outlier, published in the README twelve lines fromt.def. Two user-facing error messages now say "schema def".protocol.duration_to_seconds→protocol.time_duration_to_secondsThe type is
TimeDuration, the tag string is"time_duration", the wrapper key is__time_duration_micros__, and the five sibling helpers are named after their types exactly. This was the one abbreviation.Event
type = "subscription_error"→"subscription_failed"One
_erroragainst three_failedamong the failure events, besidesub.errorandsub.state == "error". The wire tag is untouched.evt.type == "subscription_failed"now diverges fromsub.state == "error"for the same occurrence, deliberately, matching howreducer_failedalready diverges fromcall.state == "error".db.connection_idis canonical hexSeparate from the renames, in #37. The server sends
ConnectionIdas a bare 128-bit JSON number, sojson.decoderounded it and only 14 of 32 hex digits survived - padding the rest produced a string of exactly the canonical width with a fabricated tail, and two ids differing by 1 on the wire rendered identically.db.connection_idis now0xplus 32 exact digits, converted from the raw decimal text, so it reads likedb.identity. It is still a fresh ConnectionId per socket, so it changes across a reconnect whiledb.identitydoes not.Features
Typed table accessors
Row changes used to arrive as one firehose: every consumer wrote the same loop over
evt.counts.events, filtering by table name and branching on event type. #45 gives every table in the schema an accessor underdb.tables:db.tables.message:on_insert(function(row) end) db.tables.message:on_update(function(old, new) end) -- primary-key tables only db.tables.message:on_delete(function(row) end) db.tables.message:count() db.tables.message:iter() db.tables.message:find("id", 7) db.tables["my-table"] -- a name that is not a Lua identifierstdb/tables.luais a new module over the schema and the cache, both passed in, so it is unit-testable without an engine;connection.luagains onerequire, oneinstallcall and a four-lineemit_rows.on_eventis untouched and still fires first, per-table callbacks run after it, and callbacks fire per row only once the whole batch is in the cache, so a callback readingcount/iter/findsees a complete transaction and never half of one. Deregistration isoff_insert(fn)/off_update(fn)/off_delete(fn), spelled the waySubscription:off_appliedalready is, because a game object registers ininit(self)and the closure holdsself- iffinal(self)cannot undo that, the first row after the object is deleted calls into a corpse.The namespace is load-bearing rather than decorative: 2.8.2 accepts a table called
state,closeorcache, publishes it and serves its rows, so a flatdb.<name>would collide with the connection's own fields, and any reserved list of those names would itself be frozen API - adding a field toconnection.luawould then break anyone whose module has a table by that name.on_updateraises on a pk-less table, reading the samedef.pkthatcache.luapairs updates off, because such a table's row change arrives as a delete plus an insert that the cache deliberately does not pair, so the registration could never fire.finddelegates entirely tocache.find.Handles from subscriptions and reducers
subscribe_queryreturned a barequery_idandcall_reducera barerequest_id, each with one(err, ...)callback. #38 returns a handle from both, and both old forms keep working -unsubscribeaccepts a handle or a raw id:local sub = db:subscribe_query({ "SELECT * FROM message" }) sub:on_applied(fn) sub:on_error(fn) sub:is_active() sub:is_ended() sub:unsubscribe(cb) sub.query_id local call = db:call_reducer("send_message", { "yuris", "hello" }) call:on_ok(fn) call:on_error(fn) call:on_out_of_energy(fn) call.request_idWhat each buys: a subscription that errored was silently dropped, discoverable only by having caught the callback at the time; and
OutOfEnergyis the account's problem while a failed reducer is the module's, whichhandlers.TransactionUpdatealready separated internally and then flattened into one error string. A late attach fires immediately from the retained result, andon_appliedis a list rather than a one-shot, so it fires again on every reconnect replay. A reconnect walks an applied handle back tosent, sinceresubscribe_alldrops the cache, and the flag is set beforec.sendso a transport that raises mid-replay cannot leave a handle claiming to be active. A query set ends when the drop is issued, not whenUnsubscribeMultiAppliedlands, because it is already out ofquery_setsand therefore unreplayable.Boundaries: there are no timeouts, so a pending call lives until the connection drops. The legacy
subscribestill cannot be undone and still replaces the whole set - calling it twice while one is live inflates the row hold counts, which #51 made loud by emitting asubscription_replacedevent rather than doing it silently. Usesubscribe_queryfor anything you may change.Unique-column lookups maintained from the schema
cache.getlooked up by primary key only; any other unique column needed a fullcache.iterscan. Now that the generator emitsunique, #44 maintains a lookup per unique column:cache.find(state, "player", "identity", my_identity)state.indexes[table][column][value] = rowis built once from the schema atcache.new, rebuilt bydrop_all, and maintained inside the privatestore/remove, which are only ever reached fromapply_database_update- no separate pass. The pk column is deliberately not mirrored, becausestate.tables[name]already is that index, andfindon the pk delegates togetinstead, so a caller routes every single-column unique lookup through one function with no pk special case.findon a non-unique column raises rather than returning nil or scanning: a nil would be indistinguishable from a genuine miss, and a scan would be a silent O(n) on the main thread, so hit, miss and unusable stay three distinguishable outcomes.schema.newnow rejects auniquename that is not a column, for the same reason it already rejected such a pk.The motivating shape is "which row is me?" on a
playertable keyed by auto-incrementidwith a uniqueidentitycolumn - a lookup that runs on every connect and every reconnect. It only pays off because #48 also made anIdentitycolumn decode to a string instead of a fresh table per row.Full AlgebraicType decoding
The SDK handed row values through untouched, which is correct for scalars and strings and wrong or unusable for everything else. #46 adds
protocol.decode_typed(encoded, def, typespace)andprotocol.decode_value(v, tag, typespace), written against a new 31-columnall_typestable captured in both encodings - subscription readback andTransactionUpdate- intosession.jsonl. Since #48 the cache routes a table's rows throughdecode_typedwhenever its schema entry carriestypes, and throughdecode_rowwhen it does not, so a hand-written schema is unchanged byte for byte.Four wire facts the repo had wrong or did not know, captured rather than reasoned:
vec_u8is hex text ("0001feff", and""when empty), not a JSON array;i256/u256are hex text of minimal width with a-0xsign prefix, so zero is"0x0"; anOptioncolumn is an ordinal-keyed sum[0,v]/[1,{}], never the{"some":v}formprotocol.lua's header claimed; andScheduleAt's variant names are capitalised because they come from the library, while module enum variants are lowercased by the server.i128/u128decode to exact decimal digit strings, always strings so the type never depends on magnitude, with exactness coming from quoting 16-digit-and-longer integer literals in the raw row text beforejson.decodeever runs.i256/u256keep the server's hex verbatim.i64/u64are unchanged - still a Lua double, still rounding past 2^53 - because retyping them would retypemessage.id, au64pk the cache keys on. A 128-bit value that never met that rescue, such as a procedure return throughdecode_payload, decodes tonilpast 2^53 rather than printing exact-looking digits for bits that are already gone.Boundary: Lua 5.1 makes both misuses of a 128-bit string silent.
row.serial + 1coerces to a rounded double and raises nothing, and<is lexicographic, sotable.sorton a 128-bit column orders10000000000000000before9007199254740993. Compare with==, which is exact because the representation is canonical, and leave 128-bit arithmetic and ordering to the server.A primary-key row change is one
updateeventA one-column change on a primary-key table reached the consumer as
delete+insert. The cache state was always right, since deletes apply before inserts and a pk change lands as an upsert, but the event stream reported the row leaving and a different row arriving - in Defold that maps to despawn plus respawn: lost tweens, and ago.delete/factory.createpair every tick of movement instead of ago.set_position. #34 makesapply_database_updatedecode each batch up front, pair deletes with inserts by primary key within oneupdatesentry, suppress the paired delete and emit:{ type = "update", table = name, row = new_row, old = old_row }The premise was observed, not inferred: an
edit_messagereducer driven over a realv1.json.spacetimedbsocket packs both halves of the change into one entry (tables.length == 1,updates.length == 1), captured twice independently on separate servers.Pairing is by key, never by field similarity, and scoped to one
updatesentry, because only there is the insert guaranteed to be applied last. A changed pk value therefore declines to pair and still reportsdelete+insert, pinned by a test whose two rows are identical apart from the key; tables with nopkbehave exactly as before. The batch being decoded before any mutation also means a malformed row now leaves the store untouched, where the old code applied the delete and then raised.Light mode and confirmed reads on
connect#33 exposes two connect options the protocol supports and this SDK could not ask for:
light(defaultfalse) becomes?light=true, andconfirmed_reads(defaultnil) becomes?confirmed=true|false. Both reach the URL on native, where auth is a header, and on HTML5, where the token travels in the URL.confirmed_readsis tri-state on purpose - absent,trueandfalseare three distinct requests - because V1 resolves absent andconfirmed=falseidentically while later protocol versions default absent totrue, so a boolean option defaulting tofalsewould have frozen V1's meaning into the API. It must be a boolean or nil: the earlieropts.confirmed_reads and "true" or "false"collapsed every truthy value, so a string"false"arriving from a config file orjson.decoderequestedconfirmed=true, an exact inversion.What light mode does was then measured against a live server rather than read from source.
light = trueis accepted and validated by 2.8.2 -light=notaboolmakes it refuse every connection - and is a no-op there: what decides whether you receive a fullTransactionUpdateor aTransactionUpdateLightis whether you made the call, not whether you asked for light mode. Your own transaction always arrives full, carryingreducer_call, which matters concretely because the SDK correlates reducer callbacks onreducer_call.request_idandhandlers.TransactionUpdateLightnever touchesc.pending, so a caller's own update arriving light would mean everycall_reducercallback silently never fires.A
TransactionUpdateLightdoes carry a top-levelrequest_id, and it is the originating client's: a probe sent4242from the other socket while the light socket's own last id was7, and4242is what the light frame carried. Correlating on it matches another player's call.Bug fixes
- Overlapping subscriptions no longer lose rows - with two live subscriptions matching a row by distinct queries, a change that took the row out of one of them evicted it from the cache entirely, silently, while the server still held it and the other query still matched. #51 counts how many queries hold each primary key (
state.refs), removes a row only at the last hold, and suppresses the delete event exactly when the row survives. The v1 JSON wire carries no per-entry query identifier -QueryUpdateis{ deletes, inserts }andTableUpdate.updatesis a bare list - so the fix is a hold count rather than an attribution, and both wire orders (bare delete first, pair first) are asserted because nothing on the wire promises one. Cost: +9.4 ms on a 20k-rowInitialSubscriptionthat already takes about 114 ms in one frame on Defold's main thread, +1.9% on the steady-state transaction path, and one integer per primary key, about 26 B/key at 20k rows. - Identity-keyed tables work -
decode_rownever converted special-type columns, so anIdentitycolumn decoded to a fresh wrapper table per row. A table key only ever matches itself, so the canonical SpacetimeDB game pattern was broken three ways at once:cache.get/cache.findcould never resolve a row,store/removetreated every arrival as a new key so the row set grew without bound, and #34's pairing never fired. #48 routes rows through the typed decoder, so anIdentitycolumn becomes the server's0xhex string and is usable as a primary key. It is never cheaper than the untyped path: ×1.22 per row formessage(u64, string, string) and ×2.1 forprofile(identity, timestamp, option, vec, sum, nested product), which is the realistic worst case to budget against. - Procedure and one-off-query callbacks no longer vanish when the socket dies - they carry no handle, so their
c.pendingentries were dropped when the socket was replaced and the callback was never invoked; a consumer who disabled a button until a procedure returned left it disabled forever, with nothing reported anywhere. #47 addsM.settle_pending(c, replay)and calls it on all three paths a socket can go away on - replaced byset_send, retired byconn.close, and the terminal branch ofschedule_reconnect- withsubscribeexempt only under a replay that will re-issue it. Each entry settles underpcalland the first error is re-raised once the sweep completes, so one raising callback no longer costs every later caller its answer: 4 of 4 reached, against 1 of 4 before. - Two concurrent one-off queries no longer deliver each other's rows -
one_off_querykeyedc.pendingonc.last_one_off, "the query sent last", so with two in flight the first response went to the second caller's callback, the second response reached nobody, and one entry leaked for the life of the connection. #47 keys on the echoedmessage_id, verified on the wire as lowercasehex::encodeof exactly the bytes sent. AnOneOffQueryResponsematching no request is now emitted withmatched = falseinstead of consuming a whole frame silently. - A relation the schema cannot describe is skipped, not stored as empty rows -
table_defreturned{}, sodecode_rowmapped zero columns and an empty row was stored and counted as an insert, with no error, reachable by an ordinarySELECT * FROM my_view. Those fieldless rows were readable too:count()reported them,iter()handed out a table where every access is nil, and because a pk-less delete scans for an equal row andpairs({})never falsifies a match, a delete could evict the one row the relation did hold. #49 skips the relation whole and reports it incounts.undescribed, as anundescribedevent and onsub.undescribed. It is not a raise: a raise aborts the frame'stablesloop, so it would drop the rows of relations the consumer did describe, and which ones depends on the server's table order.gen-schema.shnow also emits views, in the view's own column order, withview = true. - The HTML5 install instructions did not work -
README.md:95was the only consumer-facing instruction for the only HTML5 path, andunzip -q .internal/lib/*websocket*.zip -d /tmp/wsexits 9 withNo zipfiles found, because Defold hash-names its dependency-cache entries andwebsocketappears nowhere in them. #53 downloads extension-websocket 4.2.3 from upstream and patches it in place, verified by extracting the instructions from the shipped README and running them as a script. The patch's own trailer told you to maintain a fork, contradicting the README and the issue that exists to avoid one; it now describes the vendoring flow, and the patch has a direct raw URL, since[library] include_dirs = stdbmeanspatches/never appears in a consumer's project tree.game.projectis a library manifest atversion = 1.0.0rather than this repo's harness project - it pointedmain_collectionandgame_bindingat/devpaths thatexport-ignorestrips from the archive - with the harness keys moved todev/example/harness.projectand layered bybob --settings..gitignoreno longer ships to consumers.
What's Changed
- release: -fsS and --fail-with-body are mutually exclusive in #58
- release: stop shipping the basic-auth claim #56 removed as false in #57
- docs: correct every behavioural claim measurement contradicted in #56
- api: eight renames the 1.0 freeze makes permanent in #55
- archive: fix the broken HTML5 install path and ship a consumer-only game.project in #53
- cache: count how many queries hold a row, so an overlapping one survives in #51
- dev-server: gate restart on the database serving, not the port answering in #50
- cache: skip a relation the schema cannot describe, and emit views in #49
- cache: route rows through the typed decoder so an Identity key works in #48
- connection: settle handle-free requests when the socket dies in #47
- protocol: decode every AlgebraicType from the schema tags in #46
- tables: a typed accessor per table in #45
- cache: unique-column lookups maintained from the schema in #44
- protocol: convert special types explicitly in #37
- docs: state the real reason column order is load-bearing in #40
- cache: report a pk row change as one update event in #34
- subscriptions, reducers: return handles instead of bare ids in #38
- codegen: keep the module's type information in #36
- connect: expose light mode and confirmed reads in #33
- html5: verify identity survives a reload in #17
- ci: run HTML5 conformance as a step in the conformance job in
bfd2b14 - repo: separate the library from its development infrastructure in
201dbc8 - chore: untrack secretspec.toml in
5242674 - ci: drop the bespoke reporting now that the job is trustworthy in
2581e8f - ci: stop linking a log the run did not upload in
640ce03 - conformance: resolve dependencies before building in
a9ba07e - ci: make the conformance self-report actually readable in
915961d - ci: check out with git so the nix image needs no node in
a2cdbb2 - docs: HTML5 traps, an API reference, and the docker-free workflow in
fae6e56 - ci: route conformance to the nix runner and publish its logs in
522d400 - html5: authenticate with a query token, and prove it in a browser in
0e8745e - server: replace docker with pinned SpacetimeDB release binaries in
050e71e
Full Changelog: v0.1.0...v1.0.0
Use as a Defold library
Add to
game.projectdependencies:https://git.nokopia.com/yuris/spacetimedb-defold/archive/v1.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)
5 downloads
-
Source code (TAR.GZ)
1 download
- Overlapping subscriptions no longer lose rows - with two live subscriptions matching a row by distinct queries, a change that took the row out of one of them evicted it from the cache entirely, silently, while the server still held it and the other query still matched. #51 counts how many queries hold each primary key (
-
v0.1.0
Stablereleased this
2026-08-19 10:04:01 +00:00 | 82 commits to main since this releaseFirst tagged release of the SpacetimeDB 2.x client SDK for Defold, in Lua. Every capability below
was exercised against a liveclockworklabs/spacetime:v2.8.2server from inside a real Defold
engine, and the wire contract it depends on is captured rather than inferred:test/fixtures/session.jsonl
holds 21 real frames andtest/test_fixtures.luareplays them, so a server bump shows up as a
fixture diff instead of a runtime surprise.Features
Protocol rewritten against captured 2.8.2 wire traffic
The first implementation was written from the docs and was wrong in four ways, each found by
publishing a JS chat module to a live 2.8.2 server and capturing the session.v1.json.spacetimedb
carries the v1 message schema, not v2 - the v2 schema (InitialConnection,QuerySetUpdate,
ReducerResult) exists only on the v2 BSATN wire, so the first frame on the v1 JSON wire is
IdentityToken. UnderJsonFormatrows and reducer arguments are double-encoded (ByteString:
JSON strings containing JSON). Row shape depends on the message:InitialSubscriptionsends named
objects,TransactionUpdatesends positional arrays, which is whystdb/schema.luarequires
columns- the declared order is what names the fields. And reducers are addressed by their
canonical snake_case name, so a JSsendMessageissend_messageon the wire.stdb/protocol.lua,stdb/cache.luaandstdb/connection.luawere rewritten against those
findings.protocol.unwrapnormalises wrapped scalars ({"__identity__":"0x…"},
{"__timestamp_micros_since_unix_epoch__":n}) and options ({"some":v}/{"none":[]}).
Reaching the v2 BSATN wire needs a BSATN codec and is not part of this release.Reconnect with backoff, cache drop and subscription replay
stdb.connectnow owns a reconnect state machine rather than leavingresubscribe_all()
unreachable. On socket close it retries with exponential backoff (backoff_baseand
backoff_capare options,reconnect = falseopts out), reuses the server-issued token so
identity survives, and on the new socket'sIdentityTokenclears the cache and replays every
live subscription. Two pure helpers carry the logic and are unit tested:connection.backoff_delay
(doubling with a clamp) andconnection.set_send, which swaps the transport and drops requests
owed by the dead socket so their callbacks can never fire late.The engine test proves it end to end: the harness runs
docker restartunder a live client, and
the run only passes if the client reconnects, replays its subscription and reads the pre-restart
row back out of the cache.Identity and token persistence across restarts
SpacetimeDB issues an identity and token on first connect.
stdb/connection.luagained an
injectable{ load, save }storage seam, written through onIdentityToken;stdb/init.lua
defaults it tostdb.sys_storage()oversys.save/sys.loadand prefers, in order, the live
token, an explicitopts.token, then the persisted one.storage = falseopts out.Verified in-engine, not asserted: conformance runs the headless client twice, the second time with
no token in the environment, and fails unless both runs report the same identity.Droppable subscription query sets
The legacy
Subscribemessage replaces the entire subscription set and cannot be undone one query
at a time.db:subscribe_query(queries)returns a query id overSubscribeMultiand
db:unsubscribe(qid)drops it overUnsubscribeMulti.Three server behaviours are pinned as fixtures. An unsubscribe reply carries the now-unmatched rows
as deletes, so the cache path that fills on subscribe drains on unsubscribe. The server computes
the real delta: dropping one of two overlapping subscriptions deletes nothing because the other
still matches, so a caller needs no query-to-row bookkeeping. An overlapping subscribe returns
tables:[], contradicting upstream's doc comment, so a UI that repopulates from a subscribe
payload renders empty - read the cache instead. The single-query forms nest rows as
.rows.table_rows(a bareTableUpdate) while the multi forms use.update(aDatabaseUpdate
with atablesarray); decoding one as the other silently applies nothing, so both are normalised
in one place. Reconnect replays query sets under their originalquery_idso caller-held handles
stay valid, and a set the server rejected is dropped from the replay list.Procedure calls with the return value decoded
db:call_procedurecompletes the path to the existingProcedureResulthandler and hands the
callback a decoded value rather than the raw message. Procedure returns are double-encoded by the
sameByteStringrule as reducer arguments -status.Returnedis"2"for au642 and
'"echo: from lua"'for a string - soprotocol.decode_payloadunwraps it. Captured from three
test procedures (u64return, string return with args, and one that throws).Two limits worth knowing. A thrown error and an unknown procedure name produce the same status,
InternalError, differing only in message, so there is no distinct "no such procedure" variant to
branch on;request_idcorrelates directly and is notOption-wrapped. And the HTTP schema
endpoint has noprocedureskey at all, sotools/gen-schema.shcannot emit procedure names even
thoughspacetime describe --jsonlists them.Schema generation from a live server
Column order is load-bearing, and hand-maintaining it is silent: reorder a column in the module and
every client mis-maps fields with no error anywhere.tools/gen-schema.shreads
/v1/database/<db>/schemaand emits a Lua module with columns in declaration order, the primary
key, and canonical reducer names with params. It is wired in rather than optional -
tools/dev-server.shregeneratestest/fixtures/chat_schema.luaon every publish and
test/test_fixtures.luaconsumes the generated file instead of a hand-typed copy, so a column
reorder fails the suite withexpected id,sender,text, got id,text,sender.Headless Defold conformance test
The
extension-websocketbinding instdb/init.luahas no unit coverage and cannot get any. The
repo is therefore also a Defold library project (game.projectwithinclude_dirs=stdb) whose
bootstrap collection runsmain/smoke.script, a headless client, against a live server:
tools/dev-server.shbrings the server up and publishes the module,tools/conformance.shbuilds
headless withboband assertsSMOKE: PASSacross four phases (round trip, restart survival,
query set drop, procedure round trip).It caught two engine-only bugs on its first run.
extension-websocketdefaults to binary frames,
so the server tried to BSATN-decode the JSON and dropped the connection with
unknown tag 0x7b for sum type ClientMessage; frames are now sent with
{ type = websocket.DATA_TYPE_TEXT }. Andrequire("websocket")fails at build time because
native extensions register globals, so the binding uses_G.websocket. Running Defold's generic
Linux binaries on NixOS needs help:defold-patchrewrites the interpreter and rpath, with a
buildFHSEnvwrapper as fallback, because the FHS env needs user namespaces that unprivileged job
containers usually deny. On the project's own Forgejo runner the conformance job stays out of the
blocking gates - it lives inconformance.ymlonworkflow_dispatchplus a weekly schedule and
self-reports to issue #7 - while
stylua, the Lua suites andnix flake checkgate every push.Consumable as a Defold library dependency
Forgejo serves
/archive/<tag>.zipfor any tag, so the dependency URL works from the tag alone,
and the release workflow publishes notes from the tag annotation read over the API (curlonly, no
toolchain). Verified by building a separate consumer project with no local copy ofstdb/,
resolving the release URL withboband running the bundle in a real engine.Two facts that project came back with, both now in the README. Defold does not resolve dependencies
transitively, so a consumer that lists only this SDK compiles and then fails atconnectwith
extension-websocket missing: add it to game.project dependencies. And Forgejo refuses basic auth
for archive downloads, which is allbobspeaks: measured,https://user:token@host/...returns
404 and?token=returns 200, so a private repo needs the token as a query parameter.What's Changed
- release: read the tag annotation from the API, publish a reachable URL in
8768170 - release: ship as a Defold library dependency, verified by consuming it in
394c228 - procedures: add call_procedure and decode the returned value in
a20f026 - subscriptions: add droppable query sets (subscribe_query / unsubscribe) in
3528c2b - schema: generate table descriptors from the live server in
ad3779c - identity: persist the server-issued token across app restarts in
7c0fac9 - reconnect: wire the state machine into the websocket binding in
4c0682a - ci: split conformance into a manual workflow in
c870e63 - conformance: patchelf the engine binary instead of bubblewrap in
3c14fdb - ci: stop gating conformance on a step output in
a3043a7 - ci: attach server to the job's netns when running containerised in
b30a36a - ci: grant issues:write and capture runner docker topology in
8989aa8 - ci: probe a reachable server address instead of assuming localhost in
9e8988d - ci: conformance job non-blocking, self-reports to issue #7 in
3be6593 - engine: headless conformance test — SDK verified inside real Defold in
2b32580 - protocol: rewrite against captured 2.8.2 wire traffic (v1 schema) in
ee90548 - ci: lean ci devShell, provision json.lua in shellHook in
0a6602c - ci: unit tests + stylua + flake check on push and PR in
57a3012 - spacetimedb-defold: SpacetimeDB 2.x client SDK for Defold — scaffold in
d0583b2
Full Changelog: v0.1.0
Use as a Defold library
Add to
game.projectdependencies:https://git.nokopia.com/yuris/spacetimedb-defold/archive/v0.1.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.Private repo? Forgejo rejects basic auth for archive downloads; append
?token=<access-token>to the URL instead.Downloads
-
Source code (ZIP)
16 downloads
-
Source code (TAR.GZ)
1 download
- release: read the tag annotation from the API, publish a reachable URL in