-
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 (