v1.0.0 release checklist #24
Labels
No labels
area:defold
area:docs
area:infra
area:protocol
area:testing
kind:bug
kind:chore
phase:breadth
phase:core
phase:hardening
phase:slice
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
yuris/spacetimedb-defold#24
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Tracking issue for cutting v1.0.0. Current release is
v0.1.0(game.project:3, tagv0.1.0).Living here rather than in a
ROADMAP.mdon purpose: every item is a link to an issue, and a file duplicating that list is a second copy to forget to update.Where this actually stands
Measured against the official SDKs at v2.8.2 and against the peer community SDK for Godot (flametime/Godot-SpacetimeDB-SDK, ~1.3k lines of hand-written BSATN, full typed codegen, 290★), the honest summary is:
This is a correct transport and cache. It is not yet an SDK.
What that means concretely: a consumer gets a connection, a subscription, a row cache and an event firehose. They do not get typed bindings, per-table callbacks, working enum or timestamp columns, or an identity story beyond anonymous. The generator fetches the module's full type information and discards all of it (#25).
Where this project genuinely leads its peers, worth protecting through any refactor:
reconnect_db()only. Ours is proven against a real server restart in CI, on both native and HTML5.unreachable!) and the TypeScript SDK does not expose.Where it lags, and what 1.0 should fix: typed codegen, update events, per-table callbacks, unique-index lookups, non-scalar column decoding, and auth.
What 1.0 is meant to mean
A game can ship on it. Not "feature-complete against SDKs with a build step and six frontend integrations", but: a module using ordinary SpacetimeDB features — enums, timestamps, an identity column, a few thousand rows — works without the consumer discovering the gaps themselves. Plus:
connectoptions, connection methods, event types and cache functions do not change shape without a major bump. Consumers pin a tag, so a rename is a break.Phase 1 — Foundation
The generator is the bottleneck for most of what follows, so it goes first.
algebraic_typenext to every column name;gen-schema.shreads the element and keeps only.name.some. Unblocks #21, #23, #26, #27, #28.[tag, value]arrays with no variant name;Optionhas two encodings;i256is a hex string;u128overflows a Lua double. Undetected because the test module has three scalar columns.Phase 2 — Correctness
updateevents for pk tables. A one-column change reaches the consumer as delete + insert, which in Defold means despawn + respawn: lost tweens,factory.createevery tick of movement. Both peer SDKs synthesize this; the Godot algorithm (intra-batch pk diff) ports directly.Timestampis microseconds;os.date(row.created_at)is silently wrong by 1e6 and loses precision on 32-bit Lua. Downstream of #25, which is what identifies the special columns.Identitypk is the server's0xhex string and is a usable key. Proven end to end by conformance PHASE5 on both tiers.{}; views are now emitted by the generator, and conformance re-derives the fixture from the published module so a wrong column order cannot ship.Phase 3 — Ergonomics
This is the phase that turns a transport into an SDK.
db.message:on_insert/on_update/on_delete,count,iter,find. Reverses an earlier call of mine to skip this: once the schema carries types, the table facade is where that information becomes usable, not sugar over the event stream. Both peer SDKs converged there.identitycolumn, on every connect and reconnect, currently O(n).OutOfEnergy) are already separated internally and then flattened to one string.lightandconfirmed_readsconnect options. Two query params;TransactionUpdateLightis already handled. Light mode is a real parse-cost saving on the exact platform this targets.Phase 4 — Gates at tag time
nix develop .#ci --command lua dev/test/runner.lua)6efcfdd6efcfddsession.jsonl29 frames, independently re-captured and confirmed faithfulversioningame.projectv0.1.0— a stale URL in the install instructions is the most visible possible mistakev1.0.0;release.ymlturns the annotation into the release notes, so the annotation is the changelogREADME.md,game.project,stdb/,patches/viagit archive/archive/v1.0.0.zipthrough Project ▸ Fetch Libraries in the Defold editor, once, by handExplicitly deferred past 1.0
string.pack(Lua 5.1 does not; Defold backports selectively). Revisit when bandwidth or parse cost is measured as a real problem — #30's measurement is the natural trigger, and #25's types are the prerequisite either way.math.randomseed, without which every client picks the same "random" spread and reproduces the bug.awaitfree from engine signals. The Lua equivalent is a coroutine wrapper over #29's handles — a separate opinion, and better designed once handles exist.v1.json.spacetimedb— the server's JSON path has no compression code and allocates zero compressed buffer. Relevant only if BSATN happens, where frames carry a tag byte and compress above 1 KiB.Parity notes worth keeping
Three things that are easy to get backwards:
cache.getis a real key lookup; the official TypeScript SDK's indexfindis currently a linear scan with a source TODO saying so.caller_identity,reducer_call,energy_quanta_usedandtotal_host_execution_durationride on the v1TransactionUpdate; v2/v3 carry rows only, so official SDKs no longer see them. Already reachable here viaevt.message— worth a README line, since an advantage that looks like an omission gets "fixed" by accident. Caveat: modules on schema version V10 hide it from non-callers regardless of protocol.yuris referenced this issue2026-08-19 15:54:29 +00:00
Progress — five merged, four in flight, seven new issues found
Everything below went through branch → PR → independent review → actioned review → merge. Reviews were adversarial by design: re-capture the wire rather than trust the author, and mutate the implementation to prove the tests can fail.
Merged
light/confirmed_reads. Review madesubscribe_urlprivate (it took an opts table and ignoredopts.token— on HTML5 that silently connects as a new anonymous identity), added boolean validation (confirmed_reads = "false"requestedconfirmed=true), and proved the light-mode claim on the wire instead of from source.updateevents for pk tables. Held until the premise was observed: a pk change really does arrive as oneupdatesentry. Captured twice, independently, on separate servers.typespace. Review caught the walker tagging user-defined types as built-ins — silently wrong, notunknown.TransactionUpdaterows really are positional; the cited counter-example was anInitialSubscriptionrow.nilonTransactionUpdaterows (the path a game lives in), andconnection_idpadded a rounded double to 32 hex digits — 18 of them fabricated. Now exact, recovered from the raw frame text beforejson.decode.Both conformance tiers PASS on merged
main(6efcfdd), so all five integrate.In flight
vec_u8is hex text, not a JSON array.reindexerased index entries it did not own, permanently losing rows. Proven with the repo's own committed fixture (frame 24 has twoupdatesentries) and a property check failing 40/40 seeds.db.<table>→db.tables.<name>. See below.The freeze decision worth reading
#45 originally raised at connect if a table's name collided with a connection field. Testing killed it: SpacetimeDB accepts
state,close,sendandidentityas table names and serves rows from them, so the raise rejected a legal module. The decisive argument came from the implementer arguing against their own design — with the raise, adding a name to the reserved list is itself a breaking change, which makesconnection.lua's private field namespace part of the frozen public API forever.Also corrected: this issue claimed
db.<table>matched the peer SDKs. It does not — Godot usesclient.db.<table>and the official SDKsctx.db.<table>, both namespaced. That framing was mine and it was wrong.Seven new issues, all found by doing the work
Blockers: #35 (identity-keyed tables broken in the cache), #42 (subscribing to a view silently stores empty rows).
Real bugs: #41 (procedure/one-off callbacks silently lost on socket death), #43 (a row leaving one of several overlapping queries is dropped).
Recorded: #19 (backoff jitter), #39 (conformance readiness race, ~1 run in 8), #38 (superseded).
Sequencing now established: #26 → #35 → #28's headline lookup works. Merging #28 delivers nothing for
find(player, "identity", me)until #35 lands, because the cache decodes throughdecode_rowand never the typed path. Recorded on #35 with the specific cheap fix.Phases 1–3 complete — all nine feature issues merged, both conformance tiers green
mainisd646507. Nine PRs merged, each through branch → PR → independent review → actioned review → merge, with reviewers instructed to re-capture the wire rather than trust the author and to mutate the implementation to prove the tests can fail.light/confirmed_readsconnect optionsupdateevents synthesized for pk tablestypespaceConnectionIdcache.finddb.tables.<name>Verification on
d646507No fixture drift after either run. No open PRs, no stray worktrees, working tree clean.
What review caught that the implementations did not
Every one of these was found by an independent reviewer and fixed before merge:
connection_idpadded a rounded double to 32 hex digits, 18 of them fabricated; and the special-type accessors returnednilonTransactionUpdaterows, the path a game actually lives in.unknown. A module enum namedPerhapswithSome/Nonevariants becameopt_unknownon a plain publish.reindexerased index entries it did not own, permanently losing rows. Proven with the repo's own committed fixture (frame 24 carries twoupdatesentries) and a property check that failed 40/40 seeds; 0/200 after the fix.\u0001plus 18 digits written to a string column round-tripped verbatim from a live server and then decoded as the number1e+18. Also%.0ffabricated 39-digit values on the un-rescued path, nine digits wrong for aConnectionId.connection.lua's private field namespace part of the frozen public API forever. The server acceptsstate,close,sendandidentityas table names, so the raise rejected legal modules.Two issues had their own premises corrected by the work: #32 cited an
InitialSubscriptionrow as evidence against a claim aboutTransactionUpdate(the original claim was right), and #27 claimeddb.<table>matched the peer SDKs when both actually namespace.Protocol facts this repo did not have before
All captured, not reasoned:
vec_u8is hex text, not a JSON array.i256/u256are minimal-width sign-prefixed hex, so zero is"0x0". AnOptioncolumn is an ordinal sum[0,v]/[1,{}]— the{"some":v}form exists only in schema JSON and message envelopes.ScheduleAtvariants are capitalised while module enum variants are lowercased. Nested products are named in a readback and positional inside a positional row.OneOffQueryResponserows are named. A view is positional in every message, in the view's own column order.Seven issues filed from doing the work
1.0 blockers: #35 (identity-keyed tables broken in the cache), #42 (subscribing to a view silently stores empty rows).
Real bugs: #41 (procedure / one-off callbacks silently lost on socket death), #43 (a row leaving one of several overlapping queries is dropped).
Recorded: #19 (backoff jitter), #39 (conformance readiness race, ~1 run in 8).
Sequencing established and recorded on #35: #26 → #35 → #28's headline lookup works. The cache decodes through
decode_row, never the typed path, sofind(player, "identity", me)returns nil today. #26 has now landed, which makes #35 cheap —decode_typedalready performs the conversion; the cache just needs thetypespaceplumbed in.What remains for v1.0
Phase 4 and 5 only: #30 (frame budget — measure first), #15 (upstream PR, needs a GitHub account), #16 (drop
continue-on-errorafter a few green weekly runs), #31 (auth, optional), plus the release mechanics and the API surface freeze review. The two new blockers #35 and #42 should join Gate 1.Gate 1 is clear — all three blockers found during the work are now fixed
mainisb336e94. Twelve PRs merged in this push. Every issue in Phases 1–3, plus the three blockers the work itself uncovered, is closed.reconnect = false, not only on reconnectIdentityprimary key works{}; views emitted by the generatorVerification on
b336e94No fixture drift after either run. No open PRs, no stray worktrees or branches, working tree clean.
What each blocker turned out to be
#41 was two bugs. The reported one — a procedure or one-off query in flight when the socket dies never reaching its callback — plus one found by auditing
c.pending:one_off_querykeyed on "the query sent last", so with two in flight the first response went to the second caller's callback, wrong rows to the wrong consumer. Review then found the fix was incomplete in reach:set_sendis only called when a socket is replaced, sodb:close()andreconnect = falsenever swept at all, and #41's own motivating scenario still reproduced verbatim. NowM.settle_pending(c, replay)covers all three paths, each settled callback ispcall'd so one raising callback cannot abandon the rest, and an in-flightsubscribe_queryreaches a terminal state instead of sitting atsentforever.#35 was the frozen-surface decision three earlier PRs each declined to make. Cached rows now decode through
decode_typedwhen the table's schema carriestypes, so anIdentitycolumn is the server's0xhex string — a usable key — while a hand-written schema withouttypesis byte-identically unchanged. The cost claim in the original PR (player×0.76, cheaper) did not reproduce: independently measured at ×1.30–1.40, and a realistic game table with a timestamp plus a sum plus a nested product is ×2.1–2.3. That number is now recorded honestly against #30's frame-budget question rather than advertised as free. This also unblocked #28's headline case —findon a non-pk uniqueIdentitycolumn now resolves through the real index.#42 was worse than filed. Beyond storing empty rows, a fieldless
{}row reaching the pk-lessremovemade the field-wise scan match the first candidate unconditionally, so a delete of a row the relation never held evicted the one it did. The fix skips and reports rather than raising — verified by experiment, because a raise drops described relations' rows order-dependently (a ghost relation listed first → nothing applied; listed last → applied), which would make the loss nondeterministic since table order is the server's. Review then closed the discoverability hole: a view subscriber holding only the subscribe callback was being tolderr = nilwhile every row was dropped, so the skip is now reported on the subscription handle assub.undescribed.Two process improvements landed along the way
chat_schema.luafrom the published module and diffs it. A reviewer mutated the view's typespace$refso the generator emitted a view with the table's column order — the exact silent mis-mapping #42 exists to prevent — and the unit suite still passed 6/6, because nothing re-derived the fixture from the tool. It does now, and that mutation fails the run.dev-server.shis gone. A clobber can never detect drift: it destroys the copy it would have to compare against, and it was what wrote an ad-hoc port into the provenance line.What remains for v1.0
Phase 4 and 5 only, and none of it is a correctness blocker:
continue-on-errorafter a few green weekly runs), #31 (auth, optional), the API surface freeze review, and the release mechanics.Gate 1 is closed — every known correctness bug is fixed
mainis13073c6. Twenty PRs landed in this push. No open PRs, no correctness issues open, no fixture drift.restartgates on the database serving, not the port answeringVerified on
13073c6#43 — the cache dropped rows under overlapping subscriptions
A row matching two live queries was evicted entirely when it stopped matching one of them, though it still existed on the server and still matched the other. Silent. The cache now counts holds per primary key and removes a row only at the last one.
Two of the issue's own premises were wrong and are corrected in the PR:
query_idcannot be threaded in. The v1 JSON wire carries no per-entry query identifier —QueryUpdate { deletes, inserts },TableUpdate.updatesa bare list, upstream at v2.8.2. Upstream says so itself aboutUnsubscribeApplied.rows: "To remove this in the future, we would need to send query_ids with rows in transaction updates." Only v2 BSATN carriesquery_sets, and the official SDKs still only count, discarding the id. Soapply_database_updatekeeps its frozen signature.A README bullet claiming "drop one of two overlapping subscriptions and it deletes nothing — clients need no query-to-row bookkeeping of their own" was flatly wrong for distinct-text sets. Its removal is a correction, not a convenience.
The reviewer wrote a server simulator and ran 12 seeds × 800 steps — 9,612 verifications, all invariants holding, while
mainfailed every seed by step 11–60. They also cross-swappedsmoke.scriptandcache.luaboth ways to prove neither assertion was vacuous, and concluded the old PHASE3 assertion was asserting the bug.Review found one regression in the first cut, now fixed: a legacy
Subscribeissued twice inflated the hold count, so a later real deletion left a phantom row with no event.M.subscribenow emitssubscription_replaced— following #42'sundescribedprecedent rather than inventing a mechanism.The wire shape the whole design rests on is now committed.
session.jsonlframe 48, appended without disturbing a byte of the first 42 frames:e0 -[[1,"probe43","alpha"]] +[](bare delete) besidee1 -[…"alpha"]] +[…"beta"]](pair). Nothing in the repo captured that shape before; the design rested on a transient probe.Cost, measured, against #30: 20k-row
InitialSubscription113.8 → 123.2 ms (+8.3%); 2000 paired-update frames +1.9%; ~21–26 B per primary key. pk-less relations pay nothing.#39 — retitled and split, deliberately
restart's probe polled/v1/ping, which proves the port answers and nothing else. The measurement that justified the change:The reviewer then found the fact nobody had written down: the module database opens lazily, on the first request that names it. Control run — restart, poll ping only, issue no SQL for 3 s — and the entire server log for that window is one line. So asking is what forces the open, and the gate is a mechanism rather than a hope. The ~48 ms does not vanish; it moves off the reconnecting client's WebSocket handshake (47–51 ms → 2–3 ms) and into
restart.But the flake did not reproduce — 70 unpatched runs green, and three candidate mechanisms actively disproved. #39 is retitled to what was proven and closed; the unexplained symptom is now #52, which records what is ruled out so the next occurrence starts from something. Most importantly, #52 records that #39's stated mechanism was factually wrong: there is no phase-2 time budget, so the reported failure needs a
subscribedpayload that lacked the row, not a slow one.Review also found a latent bug in the same file: two racing invocations ended with the loser deleting the winner's pidfile, leaving a live server with no record —
stopleaked it andrestartrefused with a false message. Reproduced before and after.What is left
Nothing on correctness. The release gate is verification and mechanics:
continue-on-errorstays until a CI run actually passes — the window #50 closed is ~48 ms against a 1 s reconnect backoff, three orders of magnitude apart), #31 (auth, optional), #52 (harness only, one occurrence).v1.0.0 is published
Release: https://git.nokopia.com/yuris/spacetimedb-defold/releases/tag/v1.0.0
Tag
v1.0.0→a2e39ff. Twenty-four PRs landed across this push.Verified on the tagged commit
Verified on the published artifacts, not the working tree
/archive/v1.0.0.zip→ 200, 91,938 B, and its contents are exactly the intended set:README.md,game.project,patches/and the sixstdb/modules. Nothing else.raw/tag/v1.0.0/patches/…→ 200. That URL is referenced by the README inside the archive, so it could only be confirmed after the tag existed.release.ymlpredicted against the real annotation..rej, andCreateConnectionEmscripten(conn)now sits at line 514 behind#if defined(__EMSCRIPTEN__), afterm_Protocolis assigned at 509 — which is the entire fix.The release took two attempts, and the first failure was mine
The first tag push failed the release job outright:
curl: option --fail-with-body: is badly used here, exit 2. I had added--fail-with-bodyalongside-fsSin #57, and curl treats the two as mutually exclusive. Local curl only warns and proceeds, which is exactly why a dry run that rendered the body correctly did not catch it.Nothing was half-published, and that was not luck. #57's review had verified the job has exactly one mutating call, so a failure there leaves the tag with no release object and nothing to clean up. Deleting the tag and re-cutting at a commit containing the fix (#58) was the whole remediation.
What the release gate found
The four verification items were not formalities. Each found something real:
websocket/ext.manifest. The patch's trailer also told consumers to maintain a fork, contradicting the README and the issue that exists to avoid one.schedule_at_to_secondsreturned lowercase where the decoder produced capitalised, so a consumer comparing against the documented value gotfalseforever with nothing raised.light = trueis a no-op on 2.8.2. What decides whether you get a fullTransactionUpdateor a light one is whether you made the call. The README had also declared one of its own findings unproven when it was provable — and proving it is what exposed the no-op.release.yml— would have republished the false basic-auth claim #56 had just removed, and told HTML5 consumers to add a dependency the README tells them to remove. New for 1.0, sincepatches/did not exist at 0.1.0.Left open, deliberately
continue-on-errorfrom the HTML5 CI step — stays until a CI run has actually passed there. The window #50 closed is ~48 ms against a 1 s reconnect backoff, three orders of magnitude apart, so #50 is no argument for removing it.dev-server.sh's global pidfile — harness ergonomics, found while doing this work.One item needs a human
The last checklist box is fetching
/archive/v1.0.0.zipthrough Project ▸ Fetch Libraries in the Defold editor, by hand. Everything reachable without the GUI is verified — the archive resolves, contains the right files, and its own install instructions run clean — but the editor round trip cannot be automated from here.Closing: v1.0.0 is released and the last gate item is confirmed
/archive/v1.0.0.zipfetches cleanly through Project ▸ Fetch Libraries in the Defold editor — confirmed by hand, the one step that could not be automated from outside the GUI.That was the final release-gate item. 23 of 27 boxes ticked.
The four left unticked are pointers, not unfinished work
Each is a live issue in its own right, and each was deliberately scoped out of 1.0 rather than skipped:
InitialSubscriptionis ~114 ms. #51 added ~9 ms and did not change the shape of the problem. Needs chunking, which is a design change, not a fix.patches/. 1.0 never required it merged — only that consumers can apply it, which they now can via a direct raw URL.continue-on-errorWhat this issue actually delivered
Twenty-four PRs, each through branch → PR → independent review → actioner → merge. Every reviewer was instructed to re-capture the wire rather than trust the author, and that turned out to be the load-bearing decision: reviewers overturned the stated premise of five issues, three of them mine.
Nine correctness bugs fixed, eight names frozen deliberately rather than by accident, fourteen false README claims corrected against a live 2.8.2, and an install path that provably did not work replaced with one verified end to end from the published artifact.
Three issues were filed because of this work rather than in spite of it — #52 (the one unexplained flake, with everything ruled out recorded), #54 (
dev-server.sh's global pidfile) and the two blockers that became #41/#42 — which is the outcome I wanted from the review discipline.Release: https://git.nokopia.com/yuris/spacetimedb-defold/releases/tag/v1.0.0