• v4.0.2 685d0fab01

    v4.0.2
    All checks were successful
    ci / ci (push) Successful in 20s
    release / release (push) Successful in 2s
    conformance / conformance (push) Successful in 2m13s
    Stable

    yuris released this 2026-08-22 13:58:54 +00:00 | 1 commits to main since this release

    A 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 _state back to "connected", re-persisted the
      identity and token to the save slot, re-emitted connected to on_event and on_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's CloseConnection gates its whole body on STATE_CONNECTED, and
      the unconditional transition to STATE_DISCONNECTED is compiled only under HAVE_WSLAY, so a
      close() during the handshake is a genuine no-op on that target - the browser still fires
      onopen and the server's frames keep coming for as long as the page lives. Native was never
      affected on this path, since CloseConnection does transition there. connection.dispatch now
      refuses any frame on a closed connection
      (#115).
    • Post-close frames retained by the frame_budget queue - the dispatch guard alone sits
      downstream of the budgeted queue, and close() 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.
      The EVENT_MESSAGE branch in stdb/init.lua now 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 property db:state()'s own documentation already
      claimed and is now documented as terminal. Teardown code on "closed" no longer has to defend
      against a later connected, and the two send-path guards the pre-open hold added in v4.0.1
      collapse to that single sticky test.

    What's Changed

    • chore(release): v4.0.2 in #117
    • fix(stdb): drop frames on a closed connection in #116

    Full Changelog: v4.0.1...v4.0.2

    Use as a Defold library

    Add to game.project dependencies:

    https://git.nokopia.com/yuris/spacetimedb-defold/archive/v4.0.2.zip
    https://github.com/defold/extension-websocket/archive/refs/tags/4.2.4.zip
    

    extension-websocket is required: Defold does not resolve dependencies
    transitively, so without it connect fails 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
    gets 200 on a public repo - while ?token= is validated and a bad
    one gets a plain 401.

    Downloads
  • v4.0.1 8cdf78dcd5

    v4.0.1
    All checks were successful
    ci / ci (push) Successful in 1m29s
    release / release (push) Successful in 2s
    conformance / conformance (push) Successful in 2m37s
    Stable

    yuris released this 2026-08-22 11:41:54 +00:00 | 3 commits to main since this release

    A single-fix patch release: a call issued on the line after stdb.connect no
    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.connect returns before the websocket handshake completes, so a call on
      the next line was too early: it reached extension-websocket's own LuaSend
      guard (websocket.cpp:562), which raises for any state that is not
      STATE_CONNECTED, and the caller saw Connection 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, when EVENT_CONNECTED arrives. The flush is guarded
      because HTML5 delivers that event twice for one socket - once from the
      emscripten onopen handler, 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
      in dev/test/test_init.lua pin the hold, the flush, the order, and the
      double-EVENT_CONNECTED guard.

      The hold covers that one window and no more. After a drop, the gap until the
      replacement socket exists still raises, deliberately: set_send installs a
      new holder only when open() 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 after close()) - 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, because stdb/connection.lua
      parks each request in pending before calling send. The READINESS note in
      stdb/init.lua now states that boundary, and says to gate on the
      "disconnected" and "socket_error" events rather than db: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

    • release: v4.0.1 in #114
    • stdb: hold frames issued before the socket opens in #113

    Full Changelog: v4.0.0...v4.0.1

    Use as a Defold library

    Add to game.project dependencies:

    https://git.nokopia.com/yuris/spacetimedb-defold/archive/v4.0.1.zip
    https://github.com/defold/extension-websocket/archive/refs/tags/4.2.4.zip
    

    extension-websocket is required: Defold does not resolve dependencies
    transitively, so without it connect fails 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
    gets 200 on a public repo - while ?token= is validated and a bad
    one gets a plain 401.

    Downloads
  • v4.0.0 31efbf54c1

    v4.0.0
    All checks were successful
    ci / ci (push) Successful in 1m27s
    conformance / conformance (push) Successful in 2m23s
    release / release (push) Successful in 2s
    Stable

    yuris released this 2026-08-22 10:32:04 +00:00 | 5 commits to main since this release

    One 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.spacetimedb and 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 inside include_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_protocol and SUBPROTOCOL_V2. There is one
      subprotocol, v2.bsatn.spacetimedb, and stdb.SUBPROTOCOL holds it. light was v1-only and
      already raised on v2.
    • Delete opts.json on connect and connection.new - it only ever fed the JSON codec, and its
      process-global codec guard goes with it, a hazard that existed only because stdb/protocol.lua
      held one codec per process. stdb.auth.exchange and stdb.schema.fetch still take a json,
      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 no types is 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, with subscribe_query or
      subscription_builder. The v2 ClientMessage has no legacy Subscribe - its Subscribe is the
      query-set form - so db:subscribe had no frame to send and has raised on the default wire since
      2.0.0. c.subscriptions and the subscription_replaced event go with it; unsubscribe is
      untouched.
    • Drop Call:on_out_of_energy. ReducerOutcome has no OutOfEnergy variant, so it registered a
      callback that could never fire, which reads as working. Exhaustion arrives as InternalError
      text and reaches on_error.

    one_off_key is gone too: v1 correlated a one-off query on an echoed hex message_id, v2
    correlates on request_id.

    extension-websocket 4.2.4 is required

    Move the dependency in your own game.project from 4.2.3 to 4.2.4. That release fixes
    defold/extension-websocket#67, the bug
    where the protocol option never reached the HTML5 handshake, which was the only reason patches/
    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: two websocket extensions in one build fail to
    link.

    Verified rather than assumed. dev/tools/html5-conformance.sh builds 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 Protocols
    

    db:ping reports (err, seconds)

    It used to report cb(seconds) on success and cb(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 declare

    db.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 courtesy db.reducers
    already did. A connection given no schema therefore raises on every table name, and says which
    option is missing. A caller who used db.tables[name] == nil to test whether a table exists needs
    the schema or a pcall instead.

    A row with bytes left over raises

    decode_typed_bsatn checks for leftover bytes and errors instead of decoding short. Regenerate the
    generated module whenever the server's schema changes and this never fires. decode_rows runs in a
    pass that writes nothing, so a raise cannot leave half-applied cache state.

    schema = gen replaces three correlated options

    typespace, reducers and procedures are deleted as separate arguments. schema = gen takes
    the whole generated module and connect reads 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 just reducers used 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.

    json is no longer required either. It defaults to the engine's own global in stdb.schema.fetch
    and stdb.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 options

    The fluent builder no longer stops at what its setters cover. stdb.builder(opts) seeds
    on_event, frame_budget, reconnect, storage, confirmed_reads and anything else connect
    accepts, and build() copies the seed verbatim, so a builder user is never forced back to raw
    connect for one option and never loses the three lifecycle callbacks by going there. The seed is
    copied, so with_token and with_schema write into the builder's own table rather than the
    caller's.

    stdb.query is re-exported from the facade

    The typed query builder is reachable as stdb.query, like every sibling module on the facade.
    subscribe_query already accepted what it builds, so the only caller who knew it existed was one
    who ran require("stdb.query") by hand.

    Bug fixes

    • db:ping raised 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 asserted err == 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.await parked a coroutine forever when the socket dropped mid reducer call -
      call_reducer settles its Call handle (ANSWERED_ELSEWHERE) instead of firing the callback,
      and await discarded the handle. The same happened to an awaited subscribe across a reconnect
      replay. It wires the handle too now.
    • stdb.await_event unhooked a second waiter when the first resolved - restoring db.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_event hung 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 - database under
      [spacetimedb] in game.project is 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_url still defaults to http://127.0.0.1:3000.
    • stdb.url accepted a base URL with no scheme - a missing ws:// or wss:// used to fail
      inside the engine's websocket, naming neither this function nor the missing scheme. It is
      rejected at the callsite.
    • The documented pcall shape around await could not work - Lua 5.1 cannot yield across
      pcall and the HTML5 target is plain Lua 5.1, so an awaiting coroutine cannot be wrapped in a
      single outer pcall; 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.project dependencies:

    https://git.nokopia.com/yuris/spacetimedb-defold/archive/v4.0.0.zip
    https://github.com/defold/extension-websocket/archive/refs/tags/4.2.4.zip
    

    extension-websocket is required: Defold does not resolve dependencies
    transitively, so without it connect fails 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
    gets 200 on a public repo - while ?token= is validated and a bad
    one gets a plain 401.

    Downloads
  • v3.1.0 8421da7557

    v3.1.0
    All checks were successful
    ci / ci (push) Successful in 21s
    release / release (push) Successful in 2s
    Stable

    yuris released this 2026-08-22 05:12:38 +00:00 | 9 commits to main since this release

    Gzip frames are decoded, and connect asks 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 why connect had always pinned ?compression=None - an omitted
    parameter is a wire no Defold build can read. It now appends
    ?compression=Gzip when a gunzip is in reach and ?compression=None when 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 across None, Brotli and Gzip - so v1
    passes no gunzip.

    Decoding lives in decode_server(data, inflate) in stdb/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
    ServerMessage from its sum tag. The gunzip arrives as an argument instead of a
    reach for the zlib global, which keeps the module free of engine APIs;
    stdb/init.lua resolves the ambient zlib.inflate the same way the builder
    resolves json, and opts.inflate substitutes 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.inflate only learned to sniff the 1f 8b wrapper 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.project dependencies:

    https://git.nokopia.com/yuris/spacetimedb-defold/archive/v3.1.0.zip
    https://github.com/defold/extension-websocket/archive/refs/tags/4.2.3.zip
    

    extension-websocket is required: Defold does not resolve dependencies
    transitively, so without it connect fails at runtime.

    Targeting HTML5? 4.2.3 drops the subprotocol there, so it needs the
    patch in patches/ AND the remote dependency above removed - keeping
    both puts two websocket extensions 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
    gets 200 on a public repo - while ?token= is validated and a bad
    one gets a plain 401.

    Downloads
  • v3.0.0 cdf230b0aa

    v3.0.0
    All checks were successful
    ci / ci (push) Successful in 1m28s
    release / release (push) Successful in 1s
    conformance / conformance (v1) (push) Successful in 1m53s
    conformance / conformance (v2) (push) Successful in 2m19s
    Stable

    yuris released this 2026-08-22 03:05:23 +00:00 | 13 commits to main since this release

    Near 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 the spacetimedb npm 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.0
    

    The fix is mechanical and the same everywhere: add a leading ctx to every
    on_insert, on_update and on_delete handler. 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. ctx is one object per connection carrying
    db, reducers, procedures, event, is_active(), disconnect() and
    subscription_builder(), with ctx.event set 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_energy and
    every db: 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 to SELECT * FROM "message" WHERE "message"."sender" = 'yuris'. Operators
    eq ne lt lte gt gte, combinators and_ or_ not_, and left_semijoin/right_semijoin
    under the official SDKs' own names, so a TypeScript callsite ports across. not_ works
    despite the grammar having no NOT, 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's toSql,
    consumed by subscription_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 an Identity column, 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 and sender = ''' OR 1=1 --', where the doubled quote returns
    200 with no rows rather than executing.

    Reachable schema generation

    Since 2.0.0 made the v2 BSATN wire the default, a table with no generated types cannot
    be decoded at all - it is skipped and reported as undescribed. The generator the docs
    told a consumer to run was dev/tools/gen-schema.sh: bash, curl and jq. But
    .gitattributes export-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.lua is the jq walker of gen-schema.sh:171-459 ported to Lua, and two
    paths sit on top of it. stdb.schema.fetch(opts, cb) reads
    <base>/v1/database/<db>/schema?version=9 through the http_seam and calls back a ready
    gen table - the development path, no external tool at all. stdb.schema.to_lua(gen)
    serialises the same table to a loadable module carrying the same -- GENERATED header
    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 grew get(url, cb) beside its existing post, same
    (status, body) callback and the same status == 0 meaning nothing answered.

    stdb/spacetimedb.editor_script adds Project → SpacetimeDB: Regenerate schema. It
    lives under stdb/ because include_dirs = stdb is the only directory Defold exposes to
    a consumer project, reads its defaults from spacetimedb.base_url and
    spacetimedb.database in the consumer's game.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 requiring jq in 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 only ext.manifest and plugins/bin/${platform} are ever extracted.
    gen-schema.sh keeps 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 same connect it has always been sugar for,
    with with_token/with_protocol and on_connect/on_connect_error/on_disconnect
    taking ctx first. db.reducers.<name>(args, cb) and db.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_error into subscribe(queries) or subscribe_to_all_tables() and
    returns a real handle: is_active, is_ended, unsubscribe, unsubscribe_then.
    db.db is the official name for the table map, and
    remove_on_insert/remove_on_update/remove_on_delete alias off_* - one detach under
    either spelling, so neither name breaks.

    Connection state and latency

    db:state() returns connecting/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
    against st_table filtered to match nothing. Enough to build a "reconnecting..." banner,
    which nothing before this could.

    Coroutine mode

    stdb.await(fn, ...) and stdb.await_event(db, type) in stdb/await.lua put a
    coroutine mode beside the callbacks, for sequential flows that should read as sequential.
    Opt-in through an explicit wrapper on the nakama-defold precedent rather than
    auto-detecting a coroutine, a truthy err raises inside the coroutine instead of being
    returned for the caller to forget, and a state guard is what stops a resume on 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 prints EXAMPLE: PASS only 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 built

    The Defold manual scopes .script_api to native extensions, and separately says the
    editor completes Lua modules a script requires. 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 with no schema entry for reducer send_message. Both quickstarts then called that reducer immediately
      after connect, where there is no socket yet and stdb.connect's pre-socket seam
      raises send 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.protocol is
      pure and holds its codec in a module local, so protocol.init re-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 reported 10000 row(s) cost 0 json.decode call(s). connection.new now asserts that a second connection
      passes the same json implementation. protocol.init stays permissive - wrapping or
      instrumenting the codec on purpose is a documented act, and test_protocol.lua does
      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 prose

    Use as a Defold library

    Add to game.project dependencies:

    https://git.nokopia.com/yuris/spacetimedb-defold/archive/v3.0.0.zip
    https://github.com/defold/extension-websocket/archive/refs/tags/4.2.3.zip
    

    extension-websocket is required: Defold does not resolve dependencies
    transitively, so without it connect fails at runtime.

    Targeting HTML5? 4.2.3 drops the subprotocol there, so it needs the
    patch in patches/ AND the remote dependency above removed - keeping
    both puts two websocket extensions 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
    gets 200 on a public repo - while ?token= is validated and a bad
    one gets a plain 401.

    Downloads
  • v2.0.0 11f8817dd7

    v2.0.0
    All checks were successful
    ci / ci (push) Successful in 1m32s
    release / release (push) Successful in 2s
    conformance / conformance (v1) (push) Successful in 2m50s
    conformance / conformance (v2) (push) Successful in 2m23s
    Stable

    yuris released this 2026-08-21 11:20:05 +00:00 | 23 commits to main since this release

    v2.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() negotiates v2.bsatn.spacetimedb when you say nothing

    stdb/init.lua resolves opts.protocol or "v2". Pin the old wire with
    connect({ protocol = "v1" }): stdb/protocol.lua has 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 a u128 exactly.

    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 = true raises. 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.reducers and
      procedures = gen.procedures passed to connect, 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:subscribe raises at the subscribe callsite. Use
      subscribe_query - v2's Subscribe is the query-set form.

    Row decoding

    • A table with no generated types no longer decodes. It is skipped and
      reported through an undescribed event, 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.
    • Identity renders as the byte-reverse of the wire, because
      crates/lib/src/identity.rs stores it little-endian while to_hex encodes the
      big-endian array. ReducerResult.timestamp is microseconds, not the
      nanoseconds v2.rs:378 documents; both were settled by hex-dumping real
      frames rather than by reading upstream comments.

    Callback payloads

    • msg tables are the v2 messages. A transaction carries no reducer_call
      and no caller_identity.
    • on_out_of_energy never fires. The v2 outcome has no OutOfEnergy variant
      and there is no substitute signal, so exhaustion arrives as ordinary
      InternalError text.
    • Reducer ret_value and Err payloads are raw BSATN, with a printable summary
      handed to the callback. The (err, msg) callback shape is frozen, which is
      why ret_value is not decoded for you.

    Where the two wires genuinely differ elsewhere, v1's behaviour wins:
    SubscriptionError drops no rows, because only the server knows which of them
    another live query set still matches, and a transaction still emits one updates
    entry per query set per table, because the cache's hold counts depend on that.

    confirmed_reads resolves to the server's default, which differs per wire

    connect sends no confirmed parameter 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 to false on v1 but true on 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. Pass confirmed_reads = false for v1-style immediate
    delivery, or true to 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 f32

    Rows 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.1 reads back as 0.10000000149011612 on v2 and
    0.10000000000000001 on v1. v1 sends decimal text and json.decode returns the
    double nearest 0.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: every f32 fixture in the repo was exactly
    representable, -1.5 and 0 included.

    No codec changed here - the defect was undocumented, untested silence. It is now
    pinned two ways: the type-tag census asserts an f32 argument comes back
    narrowed, and a cross-protocol check decodes one decimal literal through both
    codecs and asserts they differ for 0.1 while 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 frames

    connect({ 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 and nil by 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:
    the EVENT_MESSAGE branch calls conn:handle_frame inline and
    cache.slice_hook stays nil.

    stdb/cache.lua gains one module-level hook, cache.slice_hook, called every
    256 decoded rows inside decode_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.lua holds 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: a TransactionUpdate arriving mid-InitialSubscription is 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. A frame_budget of 0, negative, non-numeric, or with
    no clock available raises at connect.

    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 are decode_server's single
    json.decode of 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 a BsatnRowList still 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.

    connect now falls back to persisted.identity, which connection.lua's
    IdentityToken handler 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. The or ordering hands over to the wire identity
    the moment IdentityToken lands, and nothing else in the SDK reads the persisted
    value. A genuinely fresh install, or storage = false, has nothing persisted and
    keeps the exact ladder: one new client is not a herd.

    Bug fixes

    • dev-server.sh overwrote another port's pidfile - point two ports at one
      explicit STDB_STATE_DIR and the second start_server correctly failed
      SpacetimeDB 2.8.2's exclusive database lock, but still wrote $PIDFILE with
      its own doomed child's pid. stop on the first port then read a dead pid,
      reported no server running, and left a live server that nothing could find
      again. start_server now reads /proc/$pid/cmdline for 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 port 692 does not match a server on
      6921, and the same degradation: without /proc the 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_ints ran its
      pattern gsub at both callsites with no precondition: once per frame in
      decode_server, once per row in decode_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 plain find for 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 a u128. 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 head

    Use as a Defold library

    Add to game.project dependencies:

    https://git.nokopia.com/yuris/spacetimedb-defold/archive/v2.0.0.zip
    https://github.com/defold/extension-websocket/archive/refs/tags/4.2.3.zip
    

    extension-websocket is required: Defold does not resolve dependencies
    transitively, so without it connect fails at runtime.

    Targeting HTML5? 4.2.3 drops the subprotocol there, so it needs the
    patch in patches/ AND the remote dependency above removed - keeping
    both puts two websocket extensions 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
    gets 200 on a public repo - while ?token= is validated and a bad
    one gets a plain 401.

    Downloads
  • v1.1.0 a60640662a

    v1.1.0
    All checks were successful
    release / release (push) Successful in 2s
    ci / ci (push) Successful in 1m33s
    Stable

    yuris released this 2026-08-20 16:30:02 +00:00 | 43 commits to main since this release

    v1.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.token back. 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 to requestAnimationFrame - each threshold swept across its crossing rather
    than extrapolated, with time-slicing tracked as #67 and an unguarded protocol.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 to connect as its token option.
    exchange({url, provider, credential, json}, cb) POSTs json.encode({ token = credential }) as
    application/json and calls back (err, token) exactly once. The option is credential while
    the body field is token: the body shape belongs to your backend, and token means a
    SpacetimeDB token everywhere else in this SDK, including this function's own result. The
    credential travels in the body and never in an Authorization header - 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.

    timeout is 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. retries is 3 further
    attempts with backoff_base 0.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 no print, 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.

    storage defaults to nil, so nothing is cached - deliberately unlike connect, which defaults
    to sys_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-supplied provider string. 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, and connect already handles a rejected token by reconnecting - so a stale
    entry surfaces as a failure at connect, 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/auth and /v1/identity/exchange all answer 404, only POST /v1/identity exists and it
    mints an anonymous identity, and spacetimedb-standalone start exposes a single
    --jwt-pub-key-path/--jwt-priv-key-path keypair with no issuer list or JWKS URL, so it cannot
    be pointed at a third-party OIDC issuer either. opts.url is 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.lua and 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), where status == 0 means nothing answered.

    There is deliberately no separate err argument. 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-648 sends the real status only on RESULT_OK or
    RESULT_NOT_200_OK and a literal 0 otherwise, script_http.cpp:158 passes it through, and
    Defold's own engine test asserts it - test_http.lua.raw:79-87 expects 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 published http docs do not document the convention. Two traps are recorded in
    the seam's comment: res.response is "" and not nil on a transport failure, and res.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. A nil spread 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 calling math.randomseed clobbers the consuming game's
    own RNG sequence and any deterministic simulation built on it, and an unseeded math.random hands
    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 is 4294967296 * 31 + 255 = 133143986431,
    against 2^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 flat 1s.

    The boundary: backoff_spread returns nil before 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 .token field - the seam was written for connect's
      {identity, token} record and silently made itself useless for any other shape, so the storage
      configuration documented for auth.exchange never cached and evicted on every save. load now
      treats an empty table as "nothing stored", which is what Defold's sys.load returns for a
      missing file. No released consumer can observe the widening: connect reads only
      persisted.token, where an empty table behaved as nil already.
    • connect's identity save rewrote the storage slot whole - the IdentityToken handler
      called save({identity = ..., token = ...}) wholesale, so every connect dropped an auth cache
      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 branch dev.
      sys.save's __EMSCRIPTEN__ branch (engine/script/src/script_sys.cpp:202-218) is
      fopen/fwrite/fclose and does not sync, but dmloader.js mounts the save root as IDBFS and
      patches FS.close, so every save starts an asynchronous FS.syncfs(false, cb), coalesced and
      retried up to 3 times. A syncfs is 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.persistentStorage is
      set to false, and sys.save writes the in-memory layer only with no error raised. The SDK adds
      no flush of its own, because html5.run("FS.syncfs(false, function(){})") starts the same
      fire-and-forget operation the patched FS.close already 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.sh derived
      PIDFILE, DATA, KEYS, LOGFILE and HOME from a fixed /tmp/spacetimedb-defold, so a
      second port reported the first port's server as its own and curl then failed to connect. The
      default is now keyed on $PORT - /tmp/spacetimedb-defold-3000 for the default STDB_PORT -
      and server_pid() verifies the recorded pid is our server for this port by reading
      /proc/$pid/cmdline for --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 for set -o pipefail to reach, and stays true during the window before
      the process has bound, which keeps stop able to kill a server that is still starting; a host
      without a readable /proc degrades to the previous kill -0. An explicit STDB_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.project dependencies:

    https://git.nokopia.com/yuris/spacetimedb-defold/archive/v1.1.0.zip
    https://github.com/defold/extension-websocket/archive/refs/tags/4.2.3.zip
    

    extension-websocket is required: Defold does not resolve dependencies
    transitively, so without it connect fails at runtime.

    Targeting HTML5? 4.2.3 drops the subprotocol there, so it needs the
    patch in patches/ AND the remote dependency above removed - keeping
    both puts two websocket extensions 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
    gets 200 on a public repo - while ?token= is validated and a bad
    one gets a plain 401.

    Downloads
  • v1.0.0 a2e39ff246

    v1.0.0
    All checks were successful
    release / release (push) Successful in 2s
    ci / ci (push) Successful in 1m28s
    Stable

    yuris released this 2026-08-20 11:43:15 +00:00 | 50 commits to main since this release

    The 1.0 API freeze: every public name in stdb/ - six pure-Lua modules speaking v1.json.spacetimedb over 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, with dev/test/fixtures/session.jsonl holding 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" and call.state == "error". The table is 17/17 now, including the three sites connection.lua builds dynamically (:764, :899, :934) that a literal grep cannot see.

    db.state → db.cache

    The connection's row cache. sub.state and call.state are 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, and cache.lua's own first parameter with them, since leaving it would have kept the word in play.

    undescribed event: rows → dropped

    It is a count, not a payload, and rows sat one character from row on an insert event, which is a row. Renamed in all three places at once: the event, counts.undescribed[i] and sub.undescribed[i].

    schedule_at_to_seconds returns "Interval" / "Time"

    A behaviour change, not a spelling: v0.1.0 returned "interval" / "time". decode_value always produced the capitalised form - those are the server's own declared variant names for the SDK-builtin ScheduleAt - so a consumer who compared against the documented lowercase got false forever 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.js declares t.enum("Status", ["Idle", "Busy"]) and the published schema says idle/busy. Lowercasing the decoder would have broken the larger cohort - every cached-row .tag reader, 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, so stdb.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 - and spec was the outlier, published in the README twelve lines from t.def. Two user-facing error messages now say "schema def".

    protocol.duration_to_seconds → protocol.time_duration_to_seconds

    The 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 _error against three _failed among the failure events, beside sub.error and sub.state == "error". The wire tag is untouched. evt.type == "subscription_failed" now diverges from sub.state == "error" for the same occurrence, deliberately, matching how reducer_failed already diverges from call.state == "error".

    db.connection_id is canonical hex

    Separate from the renames, in #37. The server sends ConnectionId as a bare 128-bit JSON number, so json.decode rounded 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_id is now 0x plus 32 exact digits, converted from the raw decimal text, so it reads like db.identity. It is still a fresh ConnectionId per socket, so it changes across a reconnect while db.identity does 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 under db.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 identifier
    

    stdb/tables.lua is a new module over the schema and the cache, both passed in, so it is unit-testable without an engine; connection.lua gains one require, one install call and a four-line emit_rows. on_event is 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 reading count/iter/find sees a complete transaction and never half of one. Deregistration is off_insert(fn) / off_update(fn) / off_delete(fn), spelled the way Subscription:off_applied already is, because a game object registers in init(self) and the closure holds self - if final(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, close or cache, publishes it and serves its rows, so a flat db.<name> would collide with the connection's own fields, and any reserved list of those names would itself be frozen API - adding a field to connection.lua would then break anyone whose module has a table by that name. on_update raises on a pk-less table, reading the same def.pk that cache.lua pairs 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. find delegates entirely to cache.find.

    Handles from subscriptions and reducers

    subscribe_query returned a bare query_id and call_reducer a bare request_id, each with one (err, ...) callback. #38 returns a handle from both, and both old forms keep working - unsubscribe accepts 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_id
    

    What each buys: a subscription that errored was silently dropped, discoverable only by having caught the callback at the time; and OutOfEnergy is the account's problem while a failed reducer is the module's, which handlers.TransactionUpdate already separated internally and then flattened into one error string. A late attach fires immediately from the retained result, and on_applied is a list rather than a one-shot, so it fires again on every reconnect replay. A reconnect walks an applied handle back to sent, since resubscribe_all drops the cache, and the flag is set before c.send so a transport that raises mid-replay cannot leave a handle claiming to be active. A query set ends when the drop is issued, not when UnsubscribeMultiApplied lands, because it is already out of query_sets and therefore unreplayable.

    Boundaries: there are no timeouts, so a pending call lives until the connection drops. The legacy subscribe still 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 a subscription_replaced event rather than doing it silently. Use subscribe_query for anything you may change.

    Unique-column lookups maintained from the schema

    cache.get looked up by primary key only; any other unique column needed a full cache.iter scan. Now that the generator emits unique, #44 maintains a lookup per unique column:

    cache.find(state, "player", "identity", my_identity)
    

    state.indexes[table][column][value] = row is built once from the schema at cache.new, rebuilt by drop_all, and maintained inside the private store / remove, which are only ever reached from apply_database_update - no separate pass. The pk column is deliberately not mirrored, because state.tables[name] already is that index, and find on the pk delegates to get instead, so a caller routes every single-column unique lookup through one function with no pk special case. find on 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.new now rejects a unique name 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 player table keyed by auto-increment id with a unique identity column - a lookup that runs on every connect and every reconnect. It only pays off because #48 also made an Identity column 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) and protocol.decode_value(v, tag, typespace), written against a new 31-column all_types table captured in both encodings - subscription readback and TransactionUpdate - into session.jsonl. Since #48 the cache routes a table's rows through decode_typed whenever its schema entry carries types, and through decode_row when 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_u8 is hex text ("0001feff", and "" when empty), not a JSON array; i256/u256 are hex text of minimal width with a -0x sign prefix, so zero is "0x0"; an Option column is an ordinal-keyed sum [0,v] / [1,{}], never the {"some":v} form protocol.lua's header claimed; and ScheduleAt's variant names are capitalised because they come from the library, while module enum variants are lowercased by the server.

    i128/u128 decode 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 before json.decode ever runs. i256/u256 keep the server's hex verbatim. i64/u64 are unchanged - still a Lua double, still rounding past 2^53 - because retyping them would retype message.id, a u64 pk the cache keys on. A 128-bit value that never met that rescue, such as a procedure return through decode_payload, decodes to nil past 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 + 1 coerces to a rounded double and raises nothing, and < is lexicographic, so table.sort on a 128-bit column orders 10000000000000000 before 9007199254740993. 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 update event

    A 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 a go.delete/factory.create pair every tick of movement instead of a go.set_position. #34 makes apply_database_update decode each batch up front, pair deletes with inserts by primary key within one updates entry, suppress the paired delete and emit:

    { type = "update", table = name, row = new_row, old = old_row }
    

    The premise was observed, not inferred: an edit_message reducer driven over a real v1.json.spacetimedb socket 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 updates entry, because only there is the insert guaranteed to be applied last. A changed pk value therefore declines to pair and still reports delete + insert, pinned by a test whose two rows are identical apart from the key; tables with no pk behave 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 (default false) becomes ?light=true, and confirmed_reads (default nil) 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_reads is tri-state on purpose - absent, true and false are three distinct requests - because V1 resolves absent and confirmed=false identically while later protocol versions default absent to true, so a boolean option defaulting to false would have frozen V1's meaning into the API. It must be a boolean or nil: the earlier opts.confirmed_reads and "true" or "false" collapsed every truthy value, so a string "false" arriving from a config file or json.decode requested confirmed=true, an exact inversion.

    What light mode does was then measured against a live server rather than read from source. light = true is accepted and validated by 2.8.2 - light=notabool makes it refuse every connection - and is a no-op there: what decides whether you receive a full TransactionUpdate or a TransactionUpdateLight is whether you made the call, not whether you asked for light mode. Your own transaction always arrives full, carrying reducer_call, which matters concretely because the SDK correlates reducer callbacks on reducer_call.request_id and handlers.TransactionUpdateLight never touches c.pending, so a caller's own update arriving light would mean every call_reducer callback silently never fires.

    A TransactionUpdateLight does carry a top-level request_id, and it is the originating client's: a probe sent 4242 from the other socket while the light socket's own last id was 7, and 4242 is 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 - QueryUpdate is { deletes, inserts } and TableUpdate.updates is 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-row InitialSubscription that 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_row never converted special-type columns, so an Identity column 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.find could never resolve a row, store/remove treated 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 an Identity column becomes the server's 0x hex string and is usable as a primary key. It is never cheaper than the untyped path: ×1.22 per row for message (u64, string, string) and ×2.1 for profile (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.pending entries 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 adds M.settle_pending(c, replay) and calls it on all three paths a socket can go away on - replaced by set_send, retired by conn.close, and the terminal branch of schedule_reconnect - with subscribe exempt only under a replay that will re-issue it. Each entry settles under pcall and 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_query keyed c.pending on c.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 echoed message_id, verified on the wire as lowercase hex::encode of exactly the bytes sent. An OneOffQueryResponse matching no request is now emitted with matched = false instead of consuming a whole frame silently.
    • A relation the schema cannot describe is skipped, not stored as empty rows - table_def returned {}, so decode_row mapped zero columns and an empty row was stored and counted as an insert, with no error, reachable by an ordinary SELECT * 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 and pairs({}) never falsifies a match, a delete could evict the one row the relation did hold. #49 skips the relation whole and reports it in counts.undescribed, as an undescribed event and on sub.undescribed. It is not a raise: a raise aborts the frame's tables loop, so it would drop the rows of relations the consumer did describe, and which ones depends on the server's table order. gen-schema.sh now also emits views, in the view's own column order, with view = true.
    • The HTML5 install instructions did not work - README.md:95 was the only consumer-facing instruction for the only HTML5 path, and unzip -q .internal/lib/*websocket*.zip -d /tmp/ws exits 9 with No zipfiles found, because Defold hash-names its dependency-cache entries and websocket appears 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 = stdb means patches/ never appears in a consumer's project tree. game.project is a library manifest at version = 1.0.0 rather than this repo's harness project - it pointed main_collection and game_binding at /dev paths that export-ignore strips from the archive - with the harness keys moved to dev/example/harness.project and layered by bob --settings. .gitignore no 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.project dependencies:

    https://git.nokopia.com/yuris/spacetimedb-defold/archive/v1.0.0.zip
    https://github.com/defold/extension-websocket/archive/refs/tags/4.2.3.zip
    

    extension-websocket is required: Defold does not resolve dependencies
    transitively, so without it connect fails at runtime.

    Targeting HTML5? 4.2.3 drops the subprotocol there, so it needs the
    patch in patches/ AND the remote dependency above removed - keeping
    both puts two websocket extensions 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
    gets 200 on a public repo - while ?token= is validated and a bad
    one gets a plain 401.

    Downloads
  • v0.1.0 8768170cd9

    v0.1.0
    All checks were successful
    release / release (push) Successful in 1s
    ci / ci (push) Successful in 1m24s
    Stable

    yuris released this 2026-08-19 10:04:01 +00:00 | 82 commits to main since this release

    First tagged release of the SpacetimeDB 2.x client SDK for Defold, in Lua. Every capability below
    was exercised against a live clockworklabs/spacetime:v2.8.2 server 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 and test/test_fixtures.lua replays 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. Under JsonFormat rows and reducer arguments are double-encoded (ByteString:
    JSON strings containing JSON). Row shape depends on the message: InitialSubscription sends named
    objects, TransactionUpdate sends positional arrays, which is why stdb/schema.lua requires
    columns - the declared order is what names the fields. And reducers are addressed by their
    canonical snake_case name, so a JS sendMessage is send_message on the wire.

    stdb/protocol.lua, stdb/cache.lua and stdb/connection.lua were rewritten against those
    findings. protocol.unwrap normalises 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.connect now owns a reconnect state machine rather than leaving resubscribe_all()
    unreachable. On socket close it retries with exponential backoff (backoff_base and
    backoff_cap are options, reconnect = false opts out), reuses the server-issued token so
    identity survives, and on the new socket's IdentityToken clears the cache and replays every
    live subscription. Two pure helpers carry the logic and are unit tested: connection.backoff_delay
    (doubling with a clamp) and connection.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 restart under 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.lua gained an
    injectable { load, save } storage seam, written through on IdentityToken; stdb/init.lua
    defaults it to stdb.sys_storage() over sys.save/sys.load and prefers, in order, the live
    token, an explicit opts.token, then the persisted one. storage = false opts 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 Subscribe message replaces the entire subscription set and cannot be undone one query
    at a time. db:subscribe_query(queries) returns a query id over SubscribeMulti and
    db:unsubscribe(qid) drops it over UnsubscribeMulti.

    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 bare TableUpdate) while the multi forms use .update (a DatabaseUpdate
    with a tables array); decoding one as the other silently applies nothing, so both are normalised
    in one place. Reconnect replays query sets under their original query_id so 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_procedure completes the path to the existing ProcedureResult handler and hands the
    callback a decoded value rather than the raw message. Procedure returns are double-encoded by the
    same ByteString rule as reducer arguments - status.Returned is "2" for a u64 2 and
    '"echo: from lua"' for a string - so protocol.decode_payload unwraps it. Captured from three
    test procedures (u64 return, 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_id correlates directly and is not Option-wrapped. And the HTTP schema
    endpoint has no procedures key at all, so tools/gen-schema.sh cannot emit procedure names even
    though spacetime describe --json lists 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.sh reads
    /v1/database/<db>/schema and 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.sh regenerates test/fixtures/chat_schema.lua on every publish and
    test/test_fixtures.lua consumes the generated file instead of a hand-typed copy, so a column
    reorder fails the suite with expected id,sender,text, got id,text,sender.

    Headless Defold conformance test

    The extension-websocket binding in stdb/init.lua has no unit coverage and cannot get any. The
    repo is therefore also a Defold library project (game.project with include_dirs=stdb) whose
    bootstrap collection runs main/smoke.script, a headless client, against a live server:
    tools/dev-server.sh brings the server up and publishes the module, tools/conformance.sh builds
    headless with bob and asserts SMOKE: PASS across four phases (round trip, restart survival,
    query set drop, procedure round trip).

    It caught two engine-only bugs on its first run. extension-websocket defaults 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 }. And require("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-patch rewrites the interpreter and rpath, with a
    buildFHSEnv wrapper 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 in conformance.yml on workflow_dispatch plus a weekly schedule and
    self-reports to issue #7 - while
    stylua, the Lua suites and nix flake check gate every push.

    Consumable as a Defold library dependency

    Forgejo serves /archive/<tag>.zip for 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 (curl only, no
    toolchain). Verified by building a separate consumer project with no local copy of stdb/,
    resolving the release URL with bob and 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 at connect with
    extension-websocket missing: add it to game.project dependencies. And Forgejo refuses basic auth
    for archive downloads, which is all bob speaks: 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.project dependencies:

    https://git.nokopia.com/yuris/spacetimedb-defold/archive/v0.1.0.zip
    https://github.com/defold/extension-websocket/archive/refs/tags/4.2.3.zip
    

    extension-websocket is required: Defold does not resolve dependencies
    transitively, so without it connect fails at runtime.

    Private repo? Forgejo rejects basic auth for archive downloads; append
    ?token=<access-token> to the URL instead.

    Downloads