• 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