v1.0.0 release checklist #24

Closed
opened 2026-08-19 15:20:43 +00:00 by yuris · 6 comments
Owner

Tracking issue for cutting v1.0.0. Current release is v0.1.0 (game.project:3, tag v0.1.0).

Living here rather than in a ROADMAP.md on 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:

  • Automatic reconnect with backoff and subscription replay. The Godot SDK has manual reconnect_db() only. Ours is proven against a real server restart in CI, on both native and HTML5.
  • An automated test suite. 5 unit suites plus two conformance tiers against a live server. The Godot SDK's strategy is manual integration scenes.
  • HTML5 that actually works, including the upstream extension bug nobody else has hit yet (#15) and identity persistence proven across a reload (#17).
  • One-off queries, which the official Rust SDK does not implement at all (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:

  1. No known silent-wrong-answer bugs. The bar this project has held: #8's dropped header, #17's silent new identity, #21's phantom delete+insert, #26's opaque enum arrays.
  2. The documented surface is stable. After 1.0, connect options, 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.
  3. Every documented claim is proven by a test — and every claim about the wire is proven by a captured fixture, not by reading server source. This repo has been burned three times by the wire disagreeing with the docs.

Phase 1 — Foundation

The generator is the bottleneck for most of what follows, so it goes first.

  • #25 — codegen: emit column types, indexes and reducer signatures. The schema JSON already carries algebraic_type next to every column name; gen-schema.sh reads the element and keeps only .name.some. Unblocks #21, #23, #26, #27, #28.
  • #26 — decode every AlgebraicType, with a fixture module exercising all of them. Enums currently arrive as positional [tag, value] arrays with no variant name; Option has two encodings; i256 is a hex string; u128 overflows a Lua double. Undetected because the test module has three scalar columns.
  • #32 — correct the column-order doctrine. Captured rows are named objects, not positional arrays. No bug in the code; the comments justifying the generator are wrong, and they are load-bearing comments.

Phase 2 — Correctness

  • #21 — synthesize update events for pk tables. A one-column change reaches the consumer as delete + insert, which in Defold means despawn + respawn: lost tweens, factory.create every tick of movement. Both peer SDKs synthesize this; the Godot algorithm (intra-batch pk diff) ports directly.
  • #23 — special-type units. Timestamp is 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.
  • #35 — identity-keyed tables in the cache (filed during the work, PR #48). Cached rows now route through the typed decoder, so an Identity pk is the server's 0x hex string and is a usable key. Proven end to end by conformance PHASE5 on both tiers.
  • #42 — a view no longer silently stores empty rows (filed during the work, PR #49). An undescribed relation is skipped and reported rather than stored as {}; views are now emitted by the generator, and conformance re-derives the fixture from the published module so a wrong column order cannot ship.
  • #41 — procedure and one-off callbacks settle when the socket dies (filed during the work, PR #47). Also fixed a misdelivery: two concurrent one-off queries handed the first response to the second caller.

Phase 3 — Ergonomics

This is the phase that turns a transport into an SDK.

  • #27 — typed per-table API: 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.
  • #28 — unique-index lookups from the schema. "Which row is me?" is a lookup by a unique identity column, on every connect and reconnect, currently O(n).
  • #29 — handles for subscriptions and reducer calls. Subscription state is unobservable after an error; the three distinct reducer outcomes (including OutOfEnergy) are already separated internally and then flattened to one string.
  • #22 — light and confirmed_reads connect options. Two query params; TransactionUpdateLight is already handled. Light mode is a real parse-cost saving on the exact platform this targets.

Phase 4 — Gates at tag time

  • Unit suites green (nix develop .#ci --command lua dev/test/runner.lua)
  • Native conformance green against the pinned server — verified on 6efcfdd
  • HTML5 conformance green, both loads, identity preserved — verified on 6efcfdd
  • Fixtures re-captured against the pinned server, including the all-types fixture from #26, with tests passing on the fresh capture rather than a stale one — session.jsonl 29 frames, independently re-captured and confirmed faithful
  • API surface freeze review: one pass over the README's API tables asking of each entry "would I rename this in six months?" Cheapest moment to rename is before 1.0; most expensive is after
  • README's documented behaviours all still true — particularly the captured subscription and procedure quirks, which are assertions about a live server, not about this code
  • Bump version in game.project
  • Update the install URL in the README off v0.1.0 — a stale URL in the install instructions is the most visible possible mistake
  • Annotated tag v1.0.0; release.yml turns the annotation into the release notes, so the annotation is the changelog
  • Verify the consumer archive still contains only README.md, game.project, stdb/, patches/ via git archive
  • Fetch the published /archive/v1.0.0.zip through Project ▸ Fetch Libraries in the Defold editor, once, by hand

Explicitly deferred past 1.0

Deferred Why, and the trigger to revisit
#9 — BSATN / v2 protocol The big one, and genuinely tempting now: the Godot SDK proves a full codec is ~1.3k lines in a scripting language. Deferred because v1-over-JSON is correct, JSON is never compressed on this protocol so no decoder is needed, and v1 actually carries richer non-caller transaction metadata than v2/v3. It also has an unresolved prerequisite: whether Defold's Lua ships 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.
#19 — backoff jitter Correct and tested; the stampede it guards needs a player base this SDK does not have. Honest cost is an RNG seam plus math.randomseed, without which every client picks the same "random" spread and reproduces the bug.
#20 — IDBFS flush window Needs a tab closed inside a ~1s window on first connect; costs one anonymous identity. Documented.
Coroutine / await API Godot gets await free from engine signals. The Lua equivalent is a coroutine wrapper over #29's handles — a separate opinion, and better designed once handles exist.
Framework-style codegen parity Typed query builders and generated context types assume static types and a build step. Emitting names, order, types and constraints (#25) is the useful 90%.
Editor integration The Godot SDK's codegen lives in an editor dock. Defold has no editor plugin API, so the CLI generator stays the right home.
Worker-thread parsing Impossible in Defold Lua. #30's time-slicing is the substitute.
Compression Structurally impossible on 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.
Optimistic / local updates, offline queueing, latency compensation Neither peer SDK has these; no evidence yet of what shape they should take here.

Parity notes worth keeping

Three things that are easy to get backwards:

  • The pk-indexed cache is at parity, not behind. cache.get is a real key lookup; the official TypeScript SDK's index find is currently a linear scan with a source TODO saying so.
  • Non-caller transaction metadata is richer on v1. caller_identity, reducer_call, energy_quanta_used and total_host_execution_duration ride on the v1 TransactionUpdate; v2/v3 carry rows only, so official SDKs no longer see them. Already reachable here via evt.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.
  • Brotli is unsupported by the Godot SDK too, which is a useful precedent if BSATN ever lands here: punting on Brotli and requesting gzip or none is an accepted position, not a shortcut.
Tracking issue for cutting **v1.0.0**. Current release is `v0.1.0` (`game.project:3`, tag `v0.1.0`). Living here rather than in a `ROADMAP.md` on 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](https://github.com/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: - **Automatic reconnect with backoff and subscription replay.** The Godot SDK has manual `reconnect_db()` only. Ours is proven against a real server restart in CI, on both native and HTML5. - **An automated test suite.** 5 unit suites plus two conformance tiers against a live server. The Godot SDK's strategy is manual integration scenes. - **HTML5 that actually works**, including the upstream extension bug nobody else has hit yet (#15) and identity persistence proven across a reload (#17). - **One-off queries**, which the official Rust SDK does not implement at all (`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: 1. **No known silent-wrong-answer bugs.** The bar this project has held: #8's dropped header, #17's silent new identity, #21's phantom delete+insert, #26's opaque enum arrays. 2. **The documented surface is stable.** After 1.0, `connect` options, 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. 3. **Every documented claim is proven by a test** — and every claim about the wire is proven by a *captured fixture*, not by reading server source. This repo has been burned three times by the wire disagreeing with the docs. --- ## Phase 1 — Foundation The generator is the bottleneck for most of what follows, so it goes first. - [x] **#25 — codegen: emit column types, indexes and reducer signatures.** The schema JSON already carries `algebraic_type` next to every column name; `gen-schema.sh` reads the element and keeps only `.name.some`. Unblocks #21, #23, #26, #27, #28. - [x] **#26 — decode every AlgebraicType, with a fixture module exercising all of them.** Enums currently arrive as positional `[tag, value]` arrays with no variant name; `Option` has two encodings; `i256` is a hex string; `u128` overflows a Lua double. Undetected because the test module has three scalar columns. - [x] **#32 — correct the column-order doctrine.** Captured rows are named objects, not positional arrays. No bug in the code; the comments justifying the generator are wrong, and they are load-bearing comments. ## Phase 2 — Correctness - [x] **#21 — synthesize `update` events for pk tables.** A one-column change reaches the consumer as delete + insert, which in Defold means despawn + respawn: lost tweens, `factory.create` every tick of movement. Both peer SDKs synthesize this; the Godot algorithm (intra-batch pk diff) ports directly. - [x] **#23 — special-type units.** `Timestamp` is *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. - [x] **#35 — identity-keyed tables in the cache** (filed during the work, PR #48). Cached rows now route through the typed decoder, so an `Identity` pk is the server's `0x` hex string and is a usable key. Proven end to end by conformance PHASE5 on both tiers. - [x] **#42 — a view no longer silently stores empty rows** (filed during the work, PR #49). An undescribed relation is skipped and reported rather than stored as `{}`; views are now emitted by the generator, and conformance re-derives the fixture from the published module so a wrong column order cannot ship. - [x] **#41 — procedure and one-off callbacks settle when the socket dies** (filed during the work, PR #47). Also fixed a misdelivery: two concurrent one-off queries handed the first response to the second caller. ## Phase 3 — Ergonomics This is the phase that turns a transport into an SDK. - [x] **#27 — typed per-table API**: `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. - [x] **#28 — unique-index lookups from the schema.** "Which row is me?" is a lookup by a unique `identity` column, on every connect and reconnect, currently O(n). - [x] **#29 — handles for subscriptions and reducer calls.** Subscription state is unobservable after an error; the three distinct reducer outcomes (including `OutOfEnergy`) are already separated internally and then flattened to one string. - [x] **#22 — `light` and `confirmed_reads` connect options.** Two query params; `TransactionUpdateLight` is already handled. Light mode is a real parse-cost saving on the exact platform this targets. ## Phase 4 — Gates at tag time - [x] Unit suites green (`nix develop .#ci --command lua dev/test/runner.lua`) - [x] Native conformance green against the pinned server — verified on `6efcfdd` - [x] HTML5 conformance green, both loads, identity preserved — verified on `6efcfdd` - [x] Fixtures re-captured against the pinned server, including the all-types fixture from #26, with tests passing on the fresh capture rather than a stale one — `session.jsonl` 29 frames, independently re-captured and confirmed faithful - [x] API surface freeze review: one pass over the README's API tables asking of each entry "would I rename this in six months?" Cheapest moment to rename is before 1.0; most expensive is after - [x] README's documented behaviours all still true — particularly the captured subscription and procedure quirks, which are assertions about a live server, not about this code - [x] Bump `version` in `game.project` - [x] Update the install URL in the README off `v0.1.0` — a stale URL in the install instructions is the most visible possible mistake - [x] Annotated tag `v1.0.0`; `release.yml` turns the annotation into the release notes, so the annotation *is* the changelog - [x] Verify the consumer archive still contains only `README.md`, `game.project`, `stdb/`, `patches/` via `git archive` - [x] Fetch the published `/archive/v1.0.0.zip` through *Project ▸ Fetch Libraries* in the Defold editor, once, by hand --- ## Explicitly deferred past 1.0 | Deferred | Why, and the trigger to revisit | |---|---| | **#9 — BSATN / v2 protocol** | The big one, and genuinely tempting now: the Godot SDK proves a full codec is ~1.3k lines in a scripting language. Deferred because v1-over-JSON is *correct*, JSON is never compressed on this protocol so no decoder is needed, and v1 actually carries **richer** non-caller transaction metadata than v2/v3. It also has an unresolved prerequisite: whether Defold's Lua ships `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. | | **#19 — backoff jitter** | Correct and tested; the stampede it guards needs a player base this SDK does not have. Honest cost is an RNG seam *plus* `math.randomseed`, without which every client picks the same "random" spread and reproduces the bug. | | **#20 — IDBFS flush window** | Needs a tab closed inside a ~1s window on first connect; costs one anonymous identity. Documented. | | **Coroutine / await API** | Godot gets `await` free from engine signals. The Lua equivalent is a coroutine wrapper over #29's handles — a separate opinion, and better designed once handles exist. | | **Framework-style codegen parity** | Typed query builders and generated context types assume static types and a build step. Emitting names, order, types and constraints (#25) is the useful 90%. | | **Editor integration** | The Godot SDK's codegen lives in an editor dock. Defold has no editor plugin API, so the CLI generator stays the right home. | | **Worker-thread parsing** | Impossible in Defold Lua. #30's time-slicing is the substitute. | | **Compression** | Structurally impossible on `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. | | **Optimistic / local updates, offline queueing, latency compensation** | Neither peer SDK has these; no evidence yet of what shape they should take here. | ## Parity notes worth keeping Three things that are easy to get backwards: - **The pk-indexed cache is at parity, not behind.** `cache.get` is a real key lookup; the official TypeScript SDK's index `find` is currently a linear scan with a source TODO saying so. - **Non-caller transaction metadata is richer on v1.** `caller_identity`, `reducer_call`, `energy_quanta_used` and `total_host_execution_duration` ride on the v1 `TransactionUpdate`; v2/v3 carry rows only, so official SDKs no longer see them. Already reachable here via `evt.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. - **Brotli is unsupported by the Godot SDK too**, which is a useful precedent if BSATN ever lands here: punting on Brotli and requesting gzip or none is an accepted position, not a shortcut.
Author
Owner

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

PR Issue What actually landed
#33 #22 light / confirmed_reads. Review made subscribe_url private (it took an opts table and ignored opts.token — on HTML5 that silently connects as a new anonymous identity), added boolean validation (confirmed_reads = "false" requested confirmed=true), and proved the light-mode claim on the wire instead of from source.
#34 #21 update events for pk tables. Held until the premise was observed: a pk change really does arrive as one updates entry. Captured twice, independently, on separate servers.
#36 #25 Codegen keeps column types, unique constraints, indexes, reducer/procedure signatures, and a typespace. Review caught the walker tagging user-defined types as built-ins — silently wrong, not unknown.
#40 #32 Column-order doctrine corrected — including that the issue's own premise was half wrong: TransactionUpdate rows really are positional; the cited counter-example was an InitialSubscription row.
#37 #23 Special-type accessors. Two blockers: the helpers returned nil on TransactionUpdate rows (the path a game lives in), and connection_id padded a rounded double to 32 hex digits — 18 of them fabricated. Now exact, recovered from the raw frame text before json.decode.

Both conformance tiers PASS on merged main (6efcfdd), so all five integrate.

In flight

  • #46 (#26) — typed decoder for every AlgebraicType. Found five wire facts the repo had wrong or did not know, including that vec_u8 is hex text, not a JSON array.
  • #44 (#28) — unique-column lookups. Blocker: reindex erased index entries it did not own, permanently losing rows. Proven with the repo's own committed fixture (frame 24 has two updates entries) and a property check failing 40/40 seeds.
  • #45 (#27) — per-table API. Being re-cut: db.<table> → db.tables.<name>. See below.
  • #42, #35 — blockers found during the work, not yet scheduled.

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, send and identity as 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 makes connection.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 uses client.db.<table> and the official SDKs ctx.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 through decode_row and never the typed path. Recorded on #35 with the specific cheap fix.

## 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 | PR | Issue | What actually landed | |---|---|---| | #33 | #22 | `light` / `confirmed_reads`. Review made `subscribe_url` private (it took an opts table and *ignored* `opts.token` — on HTML5 that silently connects as a new anonymous identity), added boolean validation (`confirmed_reads = "false"` requested `confirmed=true`), and proved the light-mode claim on the wire instead of from source. | | #34 | #21 | `update` events for pk tables. Held until the premise was **observed**: a pk change really does arrive as one `updates` entry. Captured twice, independently, on separate servers. | | #36 | #25 | Codegen keeps column types, unique constraints, indexes, reducer/procedure signatures, and a `typespace`. Review caught the walker tagging *user-defined* types as built-ins — silently wrong, not `unknown`. | | #40 | #32 | Column-order doctrine corrected — including that the **issue's own premise was half wrong**: `TransactionUpdate` rows really are positional; the cited counter-example was an `InitialSubscription` row. | | #37 | #23 | Special-type accessors. Two blockers: the helpers returned `nil` on `TransactionUpdate` rows (the path a game lives in), and `connection_id` padded a rounded double to 32 hex digits — **18 of them fabricated**. Now exact, recovered from the raw frame text before `json.decode`. | Both conformance tiers PASS on merged `main` (`6efcfdd`), so all five integrate. ### In flight - **#46 (#26)** — typed decoder for every AlgebraicType. Found five wire facts the repo had wrong or did not know, including that **`vec_u8` is hex text, not a JSON array**. - **#44 (#28)** — unique-column lookups. Blocker: `reindex` erased index entries it did not own, permanently losing rows. Proven with the repo's **own committed fixture** (frame 24 has two `updates` entries) and a property check failing 40/40 seeds. - **#45 (#27)** — per-table API. Being re-cut: `db.<table>` → `db.tables.<name>`. See below. - **#42, #35** — blockers found during the work, not yet scheduled. ### 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`, `send` and `identity` as 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 makes `connection.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 uses `client.db.<table>` and the official SDKs `ctx.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 through `decode_row` and never the typed path. Recorded on #35 with the specific cheap fix.
Author
Owner

Phases 1–3 complete — all nine feature issues merged, both conformance tiers green

main is d646507. 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.

PR Issue Landed
#33 #22 light / confirmed_reads connect options
#34 #21 update events synthesized for pk tables
#36 #25 Codegen keeps column types, constraints, indexes, typespace
#38 #29 Handles for subscriptions and reducer calls
#40 #32 Column-order doctrine corrected
#37 #23 Special-type accessors, exact ConnectionId
#44 #28 Per-unique-column cache indexes, cache.find
#45 #27 Typed per-table API under db.tables.<name>
#46 #26 Typed decoder for every AlgebraicType

Verification on d646507

6 passed, 0 failed          (test_cache, test_connection, test_fixtures, test_init, test_protocol, test_tables)
stylua --check stdb dev/test — clean
native:  PASS (identity 0xc200d7a1…dfb0 persisted across a process restart)
         "per-table API: 2 insert(s), 1 update, 2 delete(s)"
HTML5:   subprotocol=v1.json.spacetimedb → 101
         PASS (identity 0xc200d7a1…dfb0 survived a reload)

No 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:

  • #37 — connection_id padded a rounded double to 32 hex digits, 18 of them fabricated; and the special-type accessors returned nil on TransactionUpdate rows, the path a game actually lives in.
  • #36 — the type walker tagged user-defined types as built-ins: silently wrong, not unknown. A module enum named Perhaps with Some/None variants became opt_unknown on a plain publish.
  • #44 — reindex erased index entries it did not own, permanently losing rows. Proven with the repo's own committed fixture (frame 24 carries two updates entries) and a property check that failed 40/40 seeds; 0/200 after the fix.
  • #46 — the 128-bit rescue sentinel was forgeable: \u0001 plus 18 digits written to a string column round-tripped verbatim from a live server and then decoded as the number 1e+18. Also %.0f fabricated 39-digit values on the un-rescued path, nine digits wrong for a ConnectionId.
  • #45 — the accessor design was re-cut mid-review. The implementer argued against their own design and won: with a raise on name collision, adding a name to the reserved list is itself a breaking change, which would make connection.lua's private field namespace part of the frozen public API forever. The server accepts state, close, send and identity as table names, so the raise rejected legal modules.

Two issues had their own premises corrected by the work: #32 cited an InitialSubscription row as evidence against a claim about TransactionUpdate (the original claim was right), and #27 claimed db.<table> matched the peer SDKs when both actually namespace.

Protocol facts this repo did not have before

All captured, not reasoned: vec_u8 is hex text, not a JSON array. i256/u256 are minimal-width sign-prefixed hex, so zero is "0x0". An Option column is an ordinal sum [0,v]/[1,{}] — the {"some":v} form exists only in schema JSON and message envelopes. ScheduleAt variants are capitalised while module enum variants are lowercased. Nested products are named in a readback and positional inside a positional row. OneOffQueryResponse rows 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, so find(player, "identity", me) returns nil today. #26 has now landed, which makes #35 cheap — decode_typed already performs the conversion; the cache just needs the typespace plumbed 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-error after 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.

## Phases 1–3 complete — all nine feature issues merged, both conformance tiers green `main` is `d646507`. 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. | PR | Issue | Landed | |---|---|---| | #33 | #22 | `light` / `confirmed_reads` connect options | | #34 | #21 | `update` events synthesized for pk tables | | #36 | #25 | Codegen keeps column types, constraints, indexes, `typespace` | | #38 | #29 | Handles for subscriptions and reducer calls | | #40 | #32 | Column-order doctrine corrected | | #37 | #23 | Special-type accessors, exact `ConnectionId` | | #44 | #28 | Per-unique-column cache indexes, `cache.find` | | #45 | #27 | Typed per-table API under `db.tables.<name>` | | #46 | #26 | Typed decoder for every AlgebraicType | ### Verification on `d646507` ``` 6 passed, 0 failed (test_cache, test_connection, test_fixtures, test_init, test_protocol, test_tables) stylua --check stdb dev/test — clean native: PASS (identity 0xc200d7a1…dfb0 persisted across a process restart) "per-table API: 2 insert(s), 1 update, 2 delete(s)" HTML5: subprotocol=v1.json.spacetimedb → 101 PASS (identity 0xc200d7a1…dfb0 survived a reload) ``` No 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: - **#37** — `connection_id` padded a rounded double to 32 hex digits, **18 of them fabricated**; and the special-type accessors returned `nil` on `TransactionUpdate` rows, the path a game actually lives in. - **#36** — the type walker tagged *user-defined* types as built-ins: silently wrong, not `unknown`. A module enum named `Perhaps` with `Some`/`None` variants became `opt_unknown` on a plain publish. - **#44** — `reindex` erased index entries it did not own, permanently losing rows. Proven with the repo's **own committed fixture** (frame 24 carries two `updates` entries) and a property check that failed 40/40 seeds; 0/200 after the fix. - **#46** — the 128-bit rescue sentinel was **forgeable**: `\u0001` plus 18 digits written to a string column round-tripped verbatim from a live server and then decoded as the number `1e+18`. Also `%.0f` fabricated 39-digit values on the un-rescued path, nine digits wrong for a `ConnectionId`. - **#45** — the accessor design was re-cut mid-review. The implementer argued against their own design and won: with a raise on name collision, *adding a name to the reserved list is itself a breaking change*, which would make `connection.lua`'s private field namespace part of the frozen public API forever. The server accepts `state`, `close`, `send` and `identity` as table names, so the raise rejected legal modules. Two issues had their own premises corrected by the work: **#32** cited an `InitialSubscription` row as evidence against a claim about `TransactionUpdate` (the original claim was right), and **#27** claimed `db.<table>` matched the peer SDKs when both actually namespace. ### Protocol facts this repo did not have before All captured, not reasoned: `vec_u8` is **hex text**, not a JSON array. `i256`/`u256` are minimal-width sign-prefixed hex, so zero is `"0x0"`. An `Option` *column* is an ordinal sum `[0,v]`/`[1,{}]` — the `{"some":v}` form exists only in schema JSON and message envelopes. `ScheduleAt` variants are capitalised while module enum variants are lowercased. Nested products are named in a readback and positional inside a positional row. `OneOffQueryResponse` rows 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, so `find(player, "identity", me)` returns nil today. #26 has now landed, which makes #35 cheap — `decode_typed` already performs the conversion; the cache just needs the `typespace` plumbed 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-error` after 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.
Author
Owner

Gate 1 is clear — all three blockers found during the work are now fixed

main is b336e94. Twelve PRs merged in this push. Every issue in Phases 1–3, plus the three blockers the work itself uncovered, is closed.

PR Issue Landed
#47 #41 Handle-free requests settle when the socket dies — on close and with reconnect = false, not only on reconnect
#48 #35 Cached rows route through the typed decoder, so an Identity primary key works
#49 #42 An undescribed relation is skipped and reported instead of stored as {}; views emitted by the generator

Verification on b336e94

6 passed, 0 failed          (cache, connection, fixtures, init, protocol, tables)
stylua --check stdb dev/test — clean
native:  chat_schema.lua matches what the tool derives from the published module
         PASS PHASE5 identity-keyed table: one row throughout, one update event
         PASS (identity 0xc200d7a1…dfb0 persisted across a process restart)
HTML5:   subprotocol=v1.json.spacetimedb → 101
         PASS PHASE5 identity-keyed table
         PASS (identity 0xc200d7a1…dfb0 survived a reload)

No 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_query keyed 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_send is only called when a socket is replaced, so db:close() and reconnect = false never swept at all, and #41's own motivating scenario still reproduced verbatim. Now M.settle_pending(c, replay) covers all three paths, each settled callback is pcall'd so one raising callback cannot abandon the rest, and an in-flight subscribe_query reaches a terminal state instead of sitting at sent forever.

#35 was the frozen-surface decision three earlier PRs each declined to make. Cached rows now decode through decode_typed when the table's schema carries types, so an Identity column is the server's 0x hex string — a usable key — while a hand-written schema without types is 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 — find on a non-pk unique Identity column now resolves through the real index.

#42 was worse than filed. Beyond storing empty rows, a fieldless {} row reaching the pk-less remove made 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 told err = nil while every row was dropped, so the skip is now reported on the subscription handle as sub.undescribed.

Two process improvements landed along the way

  • Conformance now re-derives chat_schema.lua from the published module and diffs it. A reviewer mutated the view's typespace $ref so 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.
  • The fixture-clobbering behaviour in dev-server.sh is 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:

  • #30 — frame budget. Now has real numbers to decide on: ×1.22 for a scalar table, ×2.1–2.3 for a realistic mixed table, with per-row cost measured at both cache and decode level.
  • #43 — a row leaving one of several overlapping queries is dropped. Pre-existing, and the only known correctness gap left. Refcounting is cache-internal, not public surface, so it can land after the freeze without a major bump — which is what makes deferring it defensible.
  • #15 (upstream PR, needs a GitHub account), #16 (drop continue-on-error after a few green weekly runs), #31 (auth, optional), the API surface freeze review, and the release mechanics.
## Gate 1 is clear — all three blockers found during the work are now fixed `main` is `b336e94`. Twelve PRs merged in this push. Every issue in Phases 1–3, plus the three blockers the work itself uncovered, is closed. | PR | Issue | Landed | |---|---|---| | #47 | #41 | Handle-free requests settle when the socket dies — on **close** and with `reconnect = false`, not only on reconnect | | #48 | #35 | Cached rows route through the typed decoder, so an `Identity` primary key works | | #49 | #42 | An undescribed relation is skipped and reported instead of stored as `{}`; views emitted by the generator | ### Verification on `b336e94` ``` 6 passed, 0 failed (cache, connection, fixtures, init, protocol, tables) stylua --check stdb dev/test — clean native: chat_schema.lua matches what the tool derives from the published module PASS PHASE5 identity-keyed table: one row throughout, one update event PASS (identity 0xc200d7a1…dfb0 persisted across a process restart) HTML5: subprotocol=v1.json.spacetimedb → 101 PASS PHASE5 identity-keyed table PASS (identity 0xc200d7a1…dfb0 survived a reload) ``` No 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_query` keyed 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_send` is only called when a socket is *replaced*, so `db:close()` and `reconnect = false` never swept at all, and #41's own motivating scenario still reproduced verbatim. Now `M.settle_pending(c, replay)` covers all three paths, each settled callback is `pcall`'d so one raising callback cannot abandon the rest, and an in-flight `subscribe_query` reaches a terminal state instead of sitting at `sent` forever. **#35 was the frozen-surface decision three earlier PRs each declined to make.** Cached rows now decode through `decode_typed` when the table's schema carries `types`, so an `Identity` column is the server's `0x` hex string — a usable key — while a hand-written schema without `types` is 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 — `find` on a non-pk unique `Identity` column now resolves through the real index. **#42 was worse than filed.** Beyond storing empty rows, a fieldless `{}` row reaching the pk-less `remove` made 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 told `err = nil` while every row was dropped, so the skip is now reported on the subscription handle as `sub.undescribed`. ### Two process improvements landed along the way - **Conformance now re-derives `chat_schema.lua` from the published module and diffs it.** A reviewer mutated the view's typespace `$ref` so 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. - **The fixture-clobbering behaviour in `dev-server.sh` is 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: - **#30 — frame budget.** Now has real numbers to decide on: ×1.22 for a scalar table, ×2.1–2.3 for a realistic mixed table, with per-row cost measured at both cache and decode level. - **#43 — a row leaving one of several overlapping queries is dropped.** Pre-existing, and the only known correctness gap left. Refcounting is cache-internal, not public surface, so it can land after the freeze without a major bump — which is what makes deferring it defensible. - **#15** (upstream PR, needs a GitHub account), **#16** (drop `continue-on-error` after a few green weekly runs), **#31** (auth, optional), the API surface freeze review, and the release mechanics.
Author
Owner

Gate 1 is closed — every known correctness bug is fixed

main is 13073c6. Twenty PRs landed in this push. No open PRs, no correctness issues open, no fixture drift.

PR Issue Landed
#50 #39 restart gates on the database serving, not the port answering
#51 #43 Cache counts how many queries hold a row

Verified on 13073c6

unit                6 passed, 0 failed
stylua              clean
native conformance  PASS both loads, PHASE1-PHASE5, identity persisted across a process restart
HTML5 conformance   PASS both loads, PHASE5, identity survived a reload
fixture drift       none

#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_id cannot be threaded in. The v1 JSON wire carries no per-entry query identifier — QueryUpdate { deletes, inserts }, TableUpdate.updates a bare list, upstream at v2.8.2. Upstream says so itself about UnsubscribeApplied.rows: "To remove this in the future, we would need to send query_ids with rows in transaction updates." Only v2 BSATN carries query_sets, and the official SDKs still only count, discarding the id. So apply_database_update keeps its frozen signature.
  • The bare delete is entry 0, not entry 1. Captured live.

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 main failed every seed by step 11–60. They also cross-swapped smoke.script and cache.lua both 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 Subscribe issued twice inflated the hold count, so a later real deletion left a phantom row with no event. M.subscribe now emits subscription_replaced — following #42's undescribed precedent rather than inventing a mechanism.

The wire shape the whole design rests on is now committed. session.jsonl frame 48, appended without disturbing a byte of the first 42 frames: e0 -[[1,"probe43","alpha"]] +[] (bare delete) beside e1 -[…"alpha"]] +[…"beta"]] (pair). Nothing in the repo captured that shape before; the design rested on a transient probe.

Cost, measured, against #30: 20k-row InitialSubscription 113.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:

ping 200          <- the old probe returned here
+  1.4 ms  replica lock
+  4.4 ms  transaction history replayed
+  5.6 ms  host launched
+ 28.4 ms  a trivial SELECT finally returns 200

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 subscribed payload 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 — stop leaked it and restart refused with a false message. Reproduced before and after.

What is left

Nothing on correctness. The release gate is verification and mechanics:

  • API surface freeze review, README behavioural-truth re-verification, consumer-archive check — in progress
  • Version bump, install-URL update, annotated tag, and the one manual step (fetching the published archive through Project ▸ Fetch Libraries)
  • Deferred by choice: #30 (frame budget — a 20k-row initial subscription is ~114 ms, already far over budget; #51 did not change its shape), #15 (needs a GitHub account), #16 (continue-on-error stays 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).
## Gate 1 is closed — every known correctness bug is fixed `main` is `13073c6`. Twenty PRs landed in this push. **No open PRs, no correctness issues open, no fixture drift.** | PR | Issue | Landed | |---|---|---| | #50 | #39 | `restart` gates on the database serving, not the port answering | | #51 | #43 | Cache counts how many queries hold a row | ### Verified on `13073c6` ``` unit 6 passed, 0 failed stylua clean native conformance PASS both loads, PHASE1-PHASE5, identity persisted across a process restart HTML5 conformance PASS both loads, PHASE5, identity survived a reload fixture drift none ``` ### #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_id` cannot be threaded in.** The v1 JSON wire carries no per-entry query identifier — `QueryUpdate { deletes, inserts }`, `TableUpdate.updates` a bare list, upstream at v2.8.2. Upstream says so itself about `UnsubscribeApplied.rows`: *"To remove this in the future, we would need to send query_ids with rows in transaction updates."* Only v2 BSATN carries `query_sets`, and the official SDKs still only **count**, discarding the id. So `apply_database_update` keeps its frozen signature. - **The bare delete is entry 0**, not entry 1. Captured live. 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 `main` failed every seed by step 11–60. They also cross-swapped `smoke.script` and `cache.lua` both 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 `Subscribe` issued twice inflated the hold count, so a later real deletion left a phantom row with no event. `M.subscribe` now emits `subscription_replaced` — following #42's `undescribed` precedent rather than inventing a mechanism. **The wire shape the whole design rests on is now committed.** `session.jsonl` frame 48, appended without disturbing a byte of the first 42 frames: `e0 -[[1,"probe43","alpha"]] +[]` (bare delete) beside `e1 -[…"alpha"]] +[…"beta"]]` (pair). Nothing in the repo captured that shape before; the design rested on a transient probe. Cost, measured, against #30: 20k-row `InitialSubscription` 113.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: ``` ping 200 <- the old probe returned here + 1.4 ms replica lock + 4.4 ms transaction history replayed + 5.6 ms host launched + 28.4 ms a trivial SELECT finally returns 200 ``` 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 `subscribed` payload 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 — `stop` leaked it and `restart` refused with a false message. Reproduced before and after. ### What is left Nothing on correctness. The release gate is verification and mechanics: - API surface freeze review, README behavioural-truth re-verification, consumer-archive check — in progress - Version bump, install-URL update, annotated tag, and the one manual step (fetching the published archive through *Project ▸ Fetch Libraries*) - Deferred by choice: **#30** (frame budget — a 20k-row initial subscription is ~114 ms, already far over budget; #51 did not change its shape), **#15** (needs a GitHub account), **#16** (`continue-on-error` stays 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).
Author
Owner

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

unit                6 passed, 0 failed
stylua              clean
native conformance  PASS both loads, PHASE1-PHASE5, identity persisted across a process restart
HTML5 conformance   PASS both loads, PHASE5, identity survived a reload
fixture drift       none
open PRs            none

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 six stdb/ 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.
  • The release body is 6,893 B — byte-for-byte what a local dry run of release.yml predicted against the real annotation.
  • The published README's own install instructions were extracted from the published zip and run verbatim in a clean directory with no repo access. Exit 0, patch applied with no .rej, and CreateConnectionEmscripten(conn) now sits at line 514 behind #if defined(__EMSCRIPTEN__), after m_Protocol is 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-body alongside -fsS in #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:

  • Consumer archive — the HTML5 install instructions did not work: they unzipped Defold's dependency cache by filename, and Defold hash-names those entries, so the command exited 9. The repo's own harness already knew this and looked inside each zip for 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.
  • API surface freeze — eight renames, including one that was a silent bug: schedule_at_to_seconds returned lowercase where the decoder produced capitalised, so a consumer comparing against the documented value got false forever with nothing raised.
  • README behavioural truth — 14 false claims, measured against a live 2.8.2. The most consequential: light = true is a no-op on 2.8.2. What decides whether you get a full TransactionUpdate or 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, since patches/ did not exist at 0.1.0.

Left open, deliberately

  • #30 frame budget — a 20k-row initial subscription is ~114 ms. #51 added ~9 ms and did not change the shape of the problem.
  • #15 upstream the extension-websocket fix — needs a GitHub account. The patch and its evidence are paste-ready.
  • #16 drop continue-on-error from 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.
  • #31 auth token exchange — optional for 1.0.
  • #52 the one unexplained conformance flake, with everything ruled out recorded so the next occurrence starts from something.
  • #54 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.zip through 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.

## 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 ``` unit 6 passed, 0 failed stylua clean native conformance PASS both loads, PHASE1-PHASE5, identity persisted across a process restart HTML5 conformance PASS both loads, PHASE5, identity survived a reload fixture drift none open PRs none ``` ### 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 six `stdb/` 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. - The release body is 6,893 B — byte-for-byte what a local dry run of `release.yml` predicted against the real annotation. - **The published README's own install instructions were extracted from the published zip and run verbatim** in a clean directory with no repo access. Exit 0, patch applied with no `.rej`, and `CreateConnectionEmscripten(conn)` now sits at line 514 behind `#if defined(__EMSCRIPTEN__)`, after `m_Protocol` is 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-body` alongside `-fsS` in #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: - **Consumer archive** — the HTML5 install instructions **did not work**: they unzipped Defold's dependency cache by filename, and Defold hash-names those entries, so the command exited 9. The repo's own harness already knew this and looked *inside* each zip for `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. - **API surface freeze** — eight renames, including one that was a **silent bug**: `schedule_at_to_seconds` returned lowercase where the decoder produced capitalised, so a consumer comparing against the documented value got `false` forever with nothing raised. - **README behavioural truth** — 14 false claims, measured against a live 2.8.2. The most consequential: `light = true` is **a no-op on 2.8.2**. What decides whether you get a full `TransactionUpdate` or 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, since `patches/` did not exist at 0.1.0. ### Left open, deliberately - **#30** frame budget — a 20k-row initial subscription is ~114 ms. #51 added ~9 ms and did not change the shape of the problem. - **#15** upstream the extension-websocket fix — needs a GitHub account. The patch and its evidence are paste-ready. - **#16** drop `continue-on-error` from 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. - **#31** auth token exchange — optional for 1.0. - **#52** the one unexplained conformance flake, with everything ruled out recorded so the next occurrence starts from something. - **#54** `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.zip` through **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.
Author
Owner

Closing: v1.0.0 is released and the last gate item is confirmed

/archive/v1.0.0.zip fetches 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:

why it is not a 1.0 blocker
#30 frame budget A 20k-row InitialSubscription is ~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.
#15 upstream the subprotocol fix Needs a GitHub account. The patch, its root-cause analysis and the headless-Chromium evidence are paste-ready in patches/. 1.0 never required it merged — only that consumers can apply it, which they now can via a direct raw URL.
#16 drop continue-on-error Stays until a CI run has actually passed in the job container. #50 is explicitly not an argument for removing it: the window it closed is ~48 ms against a 1 s reconnect backoff.
#31 auth token exchange Optional for 1.0 and stated as such from the start — a game can do this itself.

What 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

## Closing: v1.0.0 is released and the last gate item is confirmed `/archive/v1.0.0.zip` fetches 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: | | why it is not a 1.0 blocker | |---|---| | **#30** frame budget | A 20k-row `InitialSubscription` is ~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. | | **#15** upstream the subprotocol fix | Needs a GitHub account. The patch, its root-cause analysis and the headless-Chromium evidence are paste-ready in `patches/`. 1.0 never required it merged — only that consumers can apply it, which they now can via a direct raw URL. | | **#16** drop `continue-on-error` | Stays until a CI run has actually passed in the job container. #50 is explicitly **not** an argument for removing it: the window it closed is ~48 ms against a 1 s reconnect backoff. | | **#31** auth token exchange | Optional for 1.0 and stated as such from the start — a game can do this itself. | ### What 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
yuris closed this issue 2026-08-20 13:06:40 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yuris/spacetimedb-defold#24
No description provided.