• 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