10 Development
yuris edited this page 2026-08-22 17:21:29 +07:00

Development

Everything on this page needs a clone of the repository. dev/ is stripped from the release archive, so none of it exists in a downloaded copy of the library.

Layout

The repo is two things at once, and the split is worth knowing before you read it. Only the first group reaches a consumer.

The library is what a Defold project importing this repo receives:

  • stdb/, the SDK, and the only directory Defold exposes
    • protocol_v2.lua, the message codec for v2.bsatn.spacetimedb (pure)
    • bsatn.lua, the BSATN reader and writer (pure)
    • cache.lua, the row cache keyed by primary key (pure)
    • schema.lua, the table descriptors, meaning column order and pk
    • tables.lua, the per-table callbacks and reads behind db.tables (pure)
    • connection.lua, request correlation, callbacks, reconnect replay
    • auth.lua, stdb.auth.exchange(), provider credential to SpacetimeDB token
    • init.lua, stdb.connect() binding to extension-websocket
  • game.project, the library manifest. Its [library] include_dirs = stdb is the reason require("stdb.init") resolves in your project, and the file cannot live anywhere but the repo root. You do not inherit the dependencies line in it, because Defold does not resolve dependencies transitively, which is why Quickstart lists two dependency URLs.
  • LICENSE, MIT, and it ships in the archive too

Everything else lives under dev/ and is excluded from the consumer archive via .gitattributes. Measured with git archive, a consumer receives 16 entries, namely LICENSE, README.md, game.project and stdb/. The zip the forge serves enumerates one more, because it prepends a spacetimedb-defold/ directory entry of its own. Neither this page nor .gitattributes quotes a byte figure, because the archive ships README.md, so its size moves with almost every commit.

  • dev/example/ is a real Defold app that exists only to test stdb/init.lua. That module calls _G.websocket, sys.save and timer.delay, so a plain Lua test cannot execute it at all; the only way to cover it is to build an engine binary and run it against a live server. dev/example/main/smoke.script is that client, and it runs both native and wasm. Defold allows only one project root, so the harness cannot own its own game.project; it layers dev/example/harness.project over the root one with bob --settings, which keeps its bootstrap collection and input bindings out of the file consumers download.
  • dev/tools/ holds dev-server.sh (server + publish), conformance.sh (native engine test), html5-conformance.sh (the same client as a wasm bundle in a real browser) and gen-schema.sh.
  • dev/server/ holds the chat module used for conformance and fixture capture.
  • dev/test/ holds the plain-lua5.1 suites, including fixture replay. The runner discovers 13 test_*.lua files, plus two helpers it does not run directly. conn_fixtures.lua holds the shared frame builders and connection factory for test_connection.lua and test_connection_handles.lua, one suite split in two because Lua 5.1 allows 200 locals per function and a single chunk could no longer hold them. crosscheck_v1.lua is the old v1 JSON decoder, moved out of stdb/ and kept as a test-time cross-check. BSATN is opaque, so a wrong type belief returns a plausible number instead of raising, and test_protocol_v2.lua decodes the same captured frame both ways and asserts the two agree. It is not part of the SDK and does not ship.
  • dev/nix/ holds the pinned Defold and SpacetimeDB binaries.

Four things cannot move into dev/, each because a tool demands the root. Those are game.project (Defold), flake.nix and flake.lock (Nix), .forgejo/ (Forgejo Actions), and the dotfiles.

Dev

nix develop              # or direnv allow
lua dev/test/runner.lua      # unit + fixture suites
nix flake check          # same, hermetic

# Engine-level: builds headless with bob, runs against a live server.
# The only coverage for stdb/init.lua's websocket binding.
nix develop .#conformance --command bash dev/tools/conformance.sh

# Same client as a wasm bundle in headless Chromium, the only coverage for the
# HTML5 transport. Needs the debug variant, because release strips `print`.
nix develop .#conformance --command bash dev/tools/html5-conformance.sh

# How long the frame that applies a FIRST subscription takes, and how it splits
# between BSATN decoding and building the rows. Either tier.
STDB_BULK_SWEEP=100,1000,5000,10000,25000,50000 STDB_BULK_RUNS=3 \
  STDB_TIMEOUT=900 nix develop .#conformance --command bash dev/tools/conformance.sh

Both conformance tiers also run in CI, as two steps of one job (native, then HTML5), plus a third that builds dev/example-chat/ headless and runs it against the same server. There is one wire, so there is no protocol matrix and nothing to set. A run is a run of the shipped default. Triggers are any push to main or pull request touching stdb/, dev/example/, the conformance and dev-server scripts, dev/server/, the schema fixture, gen-schema.sh, the flake, game.project or the workflow itself. There is no schedule, because everything the run depends on is pinned through flake.lock, so a version only moves when a commit moves it. workflow_dispatch still exists, and now takes no input.

Both tiers fail the job. The HTML5 tier also posts its verdict to the issue that tracks its flakiness, because it fails about one run in five and then passes on the same commit, and this Forgejo serves no job logs over the API, so that comment is the only machine-readable trace a run leaves. It gates anyway, because HTML5 is the only place the wasm build of the binary wire runs at all. Roadmap carries the suspects and what would retire the reporting step.

smoke.script's PHASE6 is that measurement. It defaults to one 10000-row point so an ordinary conformance run stays quick; STDB_BULK_SWEEP sweeps row counts and STDB_BULK_RUNS repeats each. There is no typed/untyped knob, because a bulk row cannot be decoded without types at all, so the typed decoder is the only one there is. Both tiers honour both variables. On HTML5 they travel in the page's query string, because a browser tab has no environment. The seven-column bulk table exists for this and nothing else. Row width drives every part of the cost, so measuring a one-column table would understate it.

Every number in the tables below, here and under Spreading a large apply across frames, was measured on the JSON wire before it was removed. They stay because they are the only measured bound on what a codec swap could buy, and because they are what justified making it. They do not describe the shipped codec. PHASE6 now bills its decode bucket as bsatn decode, and the big-int quoting bucket has nothing left to bill, because the scan the JSON wire needed to carry a u128 exactly has no BSATN counterpart. Run the sweep for current figures.

What it measured, on an AMD Ryzen 7 8845HS, at 10000 rows, medians over ten samples per point (STDB_BULK_RUNS=5, and the harness runs two engine processes per invocation, printing a median per process, so these are the two pooled by hand). The native column is a paired run. main and the guarded branch were measured back to back in one session, because run-to-run spread is wide enough that unpaired numbers a day apart cannot be subtracted.

native headless wasm, headless Chromium
json.decode 12.2ms 23.5ms
big-int quoting, the rescue scan 0.0ms 0ms
row tables, pk indexing, event list 3.9ms 19.5ms
outside the apply: engine, socket delivery, rAF 0.5ms 47ms
unbilled: the guard, and the harness's own wrapper ~3.3ms n/a
the whole frame 20.9ms 92.5ms

Each bucket is its own median, so they need not add to the frame median exactly.

Three buckets rather than two, because json.decode was not the only thing a binary codec deletes. quote_big_ints (now dev/test/crosscheck_v1.lua) rescued u128 digits out of the raw frame text, a value JSON cannot carry and BSATN can, and it used to run unguarded on every row, which cost about what the parse did, 12.0ms of a 30.0ms frame. It is now gated on a necessary condition (a key it can rescue spells out to "__connection_id__ or "__uuid__, so "__c or "__u must appear literally) and the bucket reads 0.0ms. The row stays rather than being deleted because the bucket is measured rather than assumed absent. A table that really does carry an identity column puts time back into it.

The trigger is the envelope's shape as much as the table's. A real InitialSubscription (what the legacy db:subscribe returned) always carried an unescaped "__time_duration_micros__" at frame level, and a TransactionUpdate added "__connection_id__" and "__identity__" there. A guard keyed on the bare "__ run would hit on all of those and leave the whole-frame scan in place no matter what the table's columns were; keying on the rescuable initials is what short-circuits them, since "__t is not one. PHASE6 measures through subscribe_query, whose SubscribeMultiApplied envelope carries no wrapper key at all, so read the 0.0ms as the per-row scan being gone on every path, and the per-frame scan as gone on both subscription envelopes. A TransactionUpdate did still pay it, and should have. Its __connection_id__ is a value the scan exists to rescue, so that hit was the pass doing its job rather than a missed saving.

The unbilled row exists because that saving is not free and the harness cannot bill it. build is a residual (frame - pre - post - decode - quote), so the guard's own find lands there, and so does the wrapper the harness puts around string.find to count it. Measured separately at 10000 rows, the guard as shipped is 2.2ms, and 3.2ms through that wrapper, against 6.2ms for the gsub it skips, on a row of this table's shape. So construction is quoted at main's 3.9ms, which is the honest figure. The guard added no construction code, and reading the branch's 8.0ms residual as "building rows got slower" would be wrong by about half.

What that leaves is a frame that is mostly parse and construction. Natively json.decode is ~58% and building the rows ~19%, so a codec swap bounds at roughly 2.4x rather than the 6 to 8x the unguarded scan used to imply. These are bounds rather than predictions, since a binary decoder still parses bytes.

Wasm reads differently, and the reason is not the CPU. A browser frame cannot be shorter than a requestAnimationFrame interval, 17ms here, which is 77% of the 22.0ms frame a 1000-row subscription produces, and taking delivery of the websocket frame costs ~30ms more at 10000 rows. Those two are ~51% of the frame and no decoder touches either, so a codec swap bounds at only ~1.3x on this tier even though it still removes most of the apply. Quote the wasm cost as a floor plus a rate rather than as a rate alone, about 17ms + 7.5µs/row at 10000 rows, with the per-row part itself climbing from ~4.5µs/row at 1000 rows because socket delivery is inside it. Collapsing that to one µs/row figure is what makes a 500-row subscription look like 4ms when it is nearer 20.

Native is close to linear from about 5000 to 10000 rows (1.72µs/row at 5000 against 2.0 at 10000) and then drifts to ~2.4µs/row by 45000. Do not read the tail as a clean curve. Run-to-run spread within one process is usually 15-25%, with single points reaching 35%, and the two engine processes one invocation runs disagreed by up to 18% on the same point. Past about 25000 rows one number stops describing the behaviour, and the crossings below are worth no more than one significant figure.

So the frame crosses 16.7ms at roughly 8500 rows native (8000 → 15.8ms, 8500 → 16.9, 9000 → 17.7) and 100ms at roughly 42000, and 100ms at roughly 10700 rows in wasm, each swept across its crossing rather than extrapolated from a distant point. A generated schema decodes through decode_typed, which the guard does not help, since it never called quote_big_ints per row in the first place, so the hand-written path has closed the gap and then overtaken it. Typed now costs about 50% more, 29.3ms against 20.9ms at 10000 rows, and crosses 16.7ms at roughly 5800 rows. Nearly half of a typed frame is construction rather than parsing, which is where that difference lives. Re-run before quoting any of it. This is one machine, and the sweep is there so nobody has to take it on faith.

Spreading a large apply across frames

The frame above is one frame. connect({ frame_budget = 0.008 }) spends at most that many seconds per engine frame on decoding rows and resumes on the next one, so a first subscription costs several ordinary frames instead of one long hitch. It is opt-in and nil by default, because slicing is visible in timing. Callbacks that used to fire inside the receive frame now land one or more frames later. It is additive to the frozen 1.0 surface. Leave it unset and no hook is called and no behaviour changes, at a per-row cost (one modulo in the decode loop) that does not show above run-to-run variance.

local db = stdb.connect({
  url = ...,
  frame_budget = 0.008,   -- seconds; nil (default) applies each frame in one go
})

What it guarantees, and what it does not:

  • No table is ever half-populated, and neither is a frame. The cache decodes every row of a frame, across all its tables, before it writes any of them, and the slicing yields only inside that decode, never during the write or the callbacks. So iter, find and every per-table callback see a whole frame or none of it, even a frame spanning several tables. (This is why apply_database_update is two passes. With decode and store interleaved per table, a yield in the second table's decode left the first one populated and unannounced for as many frames as the rest of the decode took.)
  • Ordering is intact. There is one FIFO queue and one live coroutine. A TransactionUpdate arriving mid-subscription is applied strictly after it, never interleaved.
  • A dropped socket abandons the batch. Reconnect clears the cache and replays every subscription, so an in-flight decode from a dead socket is dropped rather than finished. Nothing was written, so nothing needs undoing, which holds because of the first bullet rather than independently of it.
  • socket.gettime is required (LuaSocket, present in both Defold tiers), or an opts.clock of your own. connect raises if neither exists rather than quietly ignoring the budget.

Measured at an 8ms budget, 25000 rows, native, medians over six samples.

unsliced sliced at 8ms
worst row-decoding frame 55.5ms 8.5ms
the frame that takes delivery and parses the envelope (same frame) 12.8 to 23.3ms
the frame that writes the store (same frame) 7.3 to 13.7ms
frames the apply spans 1 4
whole apply, wall clock 55.5ms 50.7ms

Two parts of an apply cannot be sliced, and they are what is left. Both are O(rows) and both run whole:

  1. decode_server's single whole-frame parse, meaning the gunzip, then the walk of the BSATN envelope, and under the JSON wire a json.decode of the entire frame text, all of which happens before any row loop exists to yield from. That is the 12.8 to 23.3ms frame above, and at 25000 rows it is the worst frame in the apply.
  2. The store mutation, which is atomic on purpose. That is the invariant in the first bullet.

So the honest claim is that the worst frame drops from ~55ms to ~20ms at 25000 rows, not that it drops to the budget. The budget bounds the row-decoding frames, which is the part that scales without limit; the other two shrink only with a different codec or a streaming parser. Setting a budget below the unsliceable pieces does not make them smaller.

On wasm the arithmetic is worse, and the reason is the tier rather than the mechanism. A browser frame has a ~17ms requestAnimationFrame floor, so a budget under that is already below the frame quantum, and taking delivery of the websocket frame is inside the first frame. Measured at 10000 rows, decode frames land at 16 to 17ms, which is the floor, while the first frame is ~75ms. Slicing still keeps the tail frames at the floor instead of adding to one 110ms frame, but do not expect a budget to bound the first one.

STDB_BULK_BUDGET=<seconds> re-runs PHASE6 with the budget set, on either tier, and asserts what this section claims. The row-decoding frames stay inside budget + one idle frame + 5ms, the apply really did span more than one frame, and the cache holds every row at the end.

STDB_BULK_SWEEP=10000,25000 STDB_BULK_RUNS=3 STDB_BULK_BUDGET=0.008 \
  nix develop .#conformance --command bash dev/tools/conformance.sh

One caveat applies if you open two budgeted connections in one process. The slicing hook is a single module-level slot on stdb.cache, set and cleared around each pump, so two connections' pumps interleave per engine frame rather than within one. Each still honours its own budget; they do not share one. Per-connection hooks were not built for a case nothing in the SDK creates.

Regenerating fixtures

Fixtures pin the protocol; re-capture them when bumping the server version. dev-server.sh already does the server-and-publish half, so capture takes two steps.

read -r BASE TOKEN < <(dev/tools/dev-server.sh | tail -2 | tr '\n' ' ')
cd dev/server && STDB_TOKEN="$TOKEN" node capture-fixtures.mjs
cd ../.. && lua dev/test/runner.lua

session.jsonl is four blocks from four sockets, the main sequence, then player, views and overlap, split at the Nth IdentityToken. The command above rewrites the whole file. A third argument captures one block and replaces that block and everything after it, so re-capturing an earlier one means re-capturing the later ones in order.

cd dev/server && STDB_TOKEN="$TOKEN" node capture-fixtures.mjs "$WS_URL" "$TOKEN" player
cd dev/server && STDB_TOKEN="$TOKEN" node capture-fixtures.mjs "$WS_URL" "$TOKEN" views
cd dev/server && STDB_TOKEN="$TOKEN" node capture-fixtures.mjs "$WS_URL" "$TOKEN" overlap

Prefer the per-block form. A full recapture moves every frame byte-wise, since the identity, the connection id, timestamps, table ids and sequence counters all change, so dozens of pinned assertions come back as a diff nobody can read, for no behavioural reason. The player block also has to be its own socket, because the identity in its IdentityToken is the primary key of every row in it.

Bumping the server version means editing version and hash in dev/nix/spacetime.nix. The pin lives there rather than in a docker tag.

The module is published as a bundled JS file (--js-path), which avoids the Rust/wasm toolchain entirely. spacetime:* imports stay external, because the host runtime provides them.

No docker

Both conformance tiers used to need a docker daemon, with one container for the server, another to run the spacetime CLI, and docker restart to force the reconnect phase. All three are gone. dev/nix/spacetime.nix packages the upstream 2.8.2 release binaries (autoPatchelf over two files), so the tests need nothing but nix. There is no daemon, no socket and no sibling containers, and 127.0.0.1 always means this machine.

That deleted the bridge-IP probing and network-namespace sharing dev-server.sh carried for containerised CI, and it made the suite about three times faster (31s → 10s for the native tier, measured on the last containerised revision; that configuration is deleted from the tree, so the 31s is not reproducible here).

Not nixpkgs' spacetimedb, for two reasons. It trails the pinned version (2.2.0 on nixos-26.05, 2.8.1 on unstable at the time of writing, against 2.8.2 here), and its licence is BSL 1.1, which nixpkgs marks unfree, so Hydra never builds it and it is structurally absent from cache.nixos.org. Using it would mean compiling SpacetimeDB and librusty_v8 on every cold runner to arrive at the wrong version.

The server keeps state under /tmp/spacetimedb-defold-$STDB_PORT, keyed on the port so two of them never share a pidfile, data directory or keypair. STDB_PORT defaults to 3000, so unless you set it the directory is /tmp/spacetimedb-defold-3000. The script deliberately leaves it running between runs, the way the container used to be. Override the whole path with STDB_STATE_DIR. dev/tools/dev-server.sh stop is the clean teardown.

One consequence is worth knowing. The server generates the JWT signing keys once and keeps them, so an identity token now survives a server restart. Under docker each fresh container signed with new keys, which is why a cached token used to come back as a mystery 401.