Table of Contents
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 exposesprotocol_v2.lua, the message codec forv2.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 pktables.lua, the per-table callbacks and reads behinddb.tables(pure)connection.lua, request correlation, callbacks, reconnect replayauth.lua,stdb.auth.exchange(), provider credential to SpacetimeDB tokeninit.lua,stdb.connect()binding toextension-websocket
game.project, the library manifest. Its[library] include_dirs = stdbis the reasonrequire("stdb.init")resolves in your project, and the file cannot live anywhere but the repo root. You do not inherit thedependenciesline 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 teststdb/init.lua. That module calls_G.websocket,sys.saveandtimer.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.scriptis that client, and it runs both native and wasm. Defold allows only one project root, so the harness cannot own its owngame.project; it layersdev/example/harness.projectover the root one withbob --settings, which keeps its bootstrap collection and input bindings out of the file consumers download.dev/tools/holdsdev-server.sh(server + publish),conformance.sh(native engine test),html5-conformance.sh(the same client as a wasm bundle in a real browser) andgen-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 13test_*.luafiles, plus two helpers it does not run directly.conn_fixtures.luaholds the shared frame builders and connection factory fortest_connection.luaandtest_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.luais the old v1 JSON decoder, moved out ofstdb/and kept as a test-time cross-check. BSATN is opaque, so a wrong type belief returns a plausible number instead of raising, andtest_protocol_v2.luadecodes 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,findand every per-table callback see a whole frame or none of it, even a frame spanning several tables. (This is whyapply_database_updateis 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
TransactionUpdatearriving 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.gettimeis required (LuaSocket, present in both Defold tiers), or anopts.clockof your own.connectraises 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:
decode_server's single whole-frame parse, meaning the gunzip, then the walk of the BSATN envelope, and under the JSON wire ajson.decodeof 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.- 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.
spacetimedb-defold
Using the SDK
Working on the SDK