stdb: fix five silent failures, and delete three required arguments #111
No reviewers
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!111
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "dx-pass"
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?
A DX pass over the whole SDK surface. Four read-only audits (connect, the
read side, the call side, the first-five-minutes path) turned up five
failures that were silent, three required arguments that did not need to
exist, and a README that never explained where the schema came from.
Everything below was executed, not reasoned about. Details in the commit
message; this is the reviewer's map.
Five silent failures
db:pingcallback was invertedcb(seconds)on success, sostdb.await(db.ping, db)raised the latency as an error. Both existing tests passed under the bug because they only assertederr == nil.decode_typed_bsatnnever checked for leftover bytes. A column added at the end vanished forever; one added in the middle misaligned the reader and produced a plausible wrong number.awaithung on a dropped socketcall_reducersettles its Call instead of firing the callback (ANSWERED_ELSEWHERE), andawaitthrew the handle away. Same for subscribe across a reconnect replay.await_eventunhooked its siblingdb.on_eventon resolve dropped a later waiter's wrapper out of the chain. Only reproduces once a second coroutine exists.await_eventhung when already connectedThree arguments deleted
schema = genaccepts the whole generated module, so the four correlatedoptions become one.
jsondefaults to the engine global.with_schemacollapses to one assignment, and
stdb.builder(opts)reaches the optionsthe fluent setters could not.
The
jsonone needed checking rather than assuming. The comment ininit.lua about bob's static dependency scan is about
require, not aboutglobals, so nothing forced the argument.
Verification
Beyond
lua dev/test/runner.lua(13/13) and stylua:against it.
join_gamearrived throughon_insert,move_playercameback as a real
on_updatefrom 0,0 to 12.5,40, andfindon theidentity pk resolved. Scratch database deleted afterwards.
spacetime initas documented. Caught two errors in my own draftthat way: a missing
export default, without which the module does notpublish, and the wrong file path.
README's illustrative snippet. Matches field for field, including the
empty
typespace.send_message-style snake_casing from the live schemaendpoint rather than asserting it.
Reviewer notes
Three behaviour changes could break a caller who depended on the old
shape, and each has a test pinning the new one:
db:ping's callback is(err, seconds). Anyone reading the firstargument as a number now gets nil.
db.tables.<unknown>raises instead of returning nil. A schemalessconnection therefore raises on every table name.
Number 3 is the one worth arguing about. It turns a silent wrong answer
into a hard failure, which I think is right for a developer-time mistake,
and
decode_rowsruns in a pass that writes nothing so it cannot leavehalf-applied state. Say if you would rather it reported through
undescribedinstead.Not done
db.dbandctx.dbare still a third and second alias fordb.tables, andctx.db.tables.messageis a nil index. Deletingc.dbtouches contract §2, so I left it.A DX pass over the whole surface, driven by an audit of connect, the read side, the call side and the first-five-minutes path. FIXED, each of which failed silently before: * `db:ping` called back `(seconds)` on success and `(nil, err)` on failure, inverted against every other async entry point here, so `stdb.await(db.ping, db)` raised the latency as an error. Now `(err, seconds)`. The two existing tests passed under the bug because they only asserted `err == nil`; naming the parameters in order is what pins it. * `decode_typed_bsatn` read as many columns as the def named and never checked for leftover bytes, so a generated schema behind the server dropped a trailing column forever, and a column added in the MIDDLE misaligned the reader and produced a plausible wrong number. Both sibling decoders already made this check (protocol_v2.lua:445, :785). Safe to raise here: decode_rows runs in apply_database_update's PASS ONE, which writes nothing. * `stdb.await` discarded the handle `fn` returned, so a reducer awaited across a dropped socket parked its coroutine forever (the Call is settled instead of the callback firing, ANSWERED_ELSEWHERE), and a subscribe awaited across a reconnect replay did the same (ANSWERED_BY_REPLAY). It now wires the handle's on_error/on_applied too, settle-once. * `stdb.await_event` restored `db.on_event` unconditionally on resolve, which dropped a LATER waiter's wrapper out of the chain and hung it. It now detaches only while it is still the head. * `stdb.await_event(db, "connected")` waited for a FUTURE firing, so it parked forever on an already-connected db. That is the natural "wait until connected, then resubscribe" after a reconnect has landed. It now answers from `db:is_connected()`. DELETED, three arguments a caller had to type for no reason: * `schema = gen` takes the whole generated module and `connect` reads `typespace`/`reducers`/`procedures` off it. Four correlated lines become one, and dropping `reducers` no longer fails later, wire-side, at the first call. The four-field spelling is unchanged. * `json` defaults to the engine's own global in `schema.fetch` and `auth.exchange`. Verified the bob static-scan constraint does not force the argument: `json` is an ambient global, read the way `websocket` and `zlib` already are, not a module this SDK requires. * `Builder:with_schema` collapses to one assignment, and `stdb.builder(opts)` seeds the options the fluent setters cannot reach, which also makes init.lua's existing read of `_opts.on_event` live rather than dead. BETTER ERRORS, where the old one named neither the cause nor the fix: * `db.tables.mesage` raises and lists the real names, like `db.reducers` already did. Covers a typo, a `schema` never passed, and a table the generated module predates. * an empty reducer/procedure section names the option that was omitted instead of trailing "known: " with nothing after it. * `stdb.url` rejects a scheme-less host at the callsite rather than letting it fail inside the engine's websocket. * the editor command no longer defaults `database` to this repo's dev fixture, which generated a schema from the wrong module successfully and silently. * `stdb.query` is re-exported, so the typed query builder is reachable without reading stdb/*.lua to learn it exists. DOCS. The README quickstart is eight steps, and the two it was missing are the two a new reader needs first: writing and publishing a module, and generating the schema. Every command and snippet in it was executed against a live 2.8.2 server, including publishing the README's own module to a scratch database and driving it from the SDK. The example is a `player` table rather than a chat, since a game replicates positions and a moving row is what demonstrates that on_update needs a primary key. ROT. `evt.reason` in the example was never set by the SDK, so a disconnect printed nil. A dead `json` option on `connect`, a stale comment about a README bug since fixed, and three references to stdb/protocol.lua, deleted in 4.0.0, one of them stamped into every generated schema module. Tests: 13/13, stylua clean, headless example round trip PASS.Review — PR #111 (
dx-pass), correctnessVerdict: REQUEST CHANGES — two blocking items. The five claimed fixes are real
and four of them are pinned properly; the two blockers are a discriminator that
does not discriminate in the one case the PR says it handles, and a new failure
path that trades a silent hang for a silent death.
Environment:
lua dev/test/runner.lua→ 13 passed, 0 failed.stylua --check stdb dev/test→ clean. Runner is Lua 5.1.5.1. BLOCKING — the
schema = gendiscriminator misfires on a module with a table namedcolumnsstdb/init.lua:292:and the comment above it,
stdb/init.lua:287-289:"A section map never does" is false.
gen.tablesis keyed by table name, so aserver module with a table called
columnsmakesgen.tables.columnsa def —non-nil — and the whole generated module is then passed through as if it were a
schema map. Reproduced against the real
stdb.connect:For comparison, the case the comment was worried about is fine:
Why it matters: the four section names become the "tables",
typespaceis lost,every real relation is undecodable, and every subscribed row arrives as an
undescribedevent.columnsis not an exotic table name (information_schemahas one), and this is precisely the ambiguity the PR claims to have settled.
Smallest fix — discriminate on the sections, which
typetags.buildalways emitsall four of (
stdb/typetags.lua:520), instead of on a name insidetables:Verified against the same six cases: correct for all of them. The residual
ambiguity shrinks from "a module with one table named
columns" to "a module withfour tables named
tables,reducers,proceduresandtypespace". (A totalfix would be an explicit
gen = ...option;setmetatable-taggingschema.new's output does not work, sinceM.newreturns the caller's owngen.tablestable —stdb/schema.lua:100.)2. BLOCKING —
stdb.awaitno longer hangs on a dropped socket; it now fails silently insteadstdb/await.lua:88-92:coroutine.resume's return value is discarded, so theerror(err, 0)atstdb/await.lua:134— the entire point of wiring the handle — is swallowedwhenever the settle arrives asynchronously, which is every real dropped socket.
Reproduced end to end with a real
connection.new+call_reducer+settle_pending:Nothing raised, nothing logged,
settle_pendingreturned clean, the coroutine'sremaining statements never ran. The good news the PR claims is real (the
coroutine does resume rather than parking forever); the bad news is that "the
socket died" reaches no surface at all.
stdb/await.lua:50-52promises theopposite: "a caller who forgets to
pcallan await that raises finds outimmediately". It does not.
Note this is why the new test at
dev/test/test_init.lua:637-651passes: its stublate-fires synchronously inside registration, so
settletakes thestate == "RUNNING"branch and the raise happens on the caller's own stack. Theasynchronous path is never exercised for an error.
Smallest fix, in
settle:This makes the failure land where every other SDK raise from inside a frame
already lands (see
stdb/init.lua:373-383, which re-raises for exactly thisreason).
3. BLOCKING — README §8 documents a
pcallidiom that cannot work on the HTML5 targetREADME.md, step 8:On plain Lua 5.1 a
pcallcannot be yielded across. Demonstrated on the repo'sown interpreter (Lua 5.1.5) with a real
call_reducer:So
pcall(function() ... stdb.await(...) ... end)fails at the first await thatactually waits. It works under LuaJIT (yield-across-pcall is a LuaJIT extension),
which covers Defold's 64-bit native targets, but [INFERENCE] not the HTML5/Lua 5.1
target this README promises support for two sections earlier ("The SDK itself is
pure Lua and runs on both native and HTML5 targets").
Smallest fix: drop the sentence, or qualify it — "a failure raises inside the
coroutine; catch it with a
pcallinside the awaited step, not around the wholecoroutine.wrapbody, since Lua 5.1 cannot yield acrosspcall."The same claim exists at
stdb/await.lua:49-53(pre-existing, not part of thisdiff) and is wrong there for the same reason; worth fixing in the same pass.
4. NIT — the handle-error path is pinned only against a stub, and only in its inline branch
dev/test/test_init.lua:637-651and:659-671assertawait's wiring againsthand-built
{ on_error = ... }/{ on_applied = ... }tables. Nothing in thesuite awaits a real
conn:call_reducerand then drops the socket, so the actualclaim — "
ANSWERED_ELSEWHEREno longer parks the coroutine" — is not pinned:Call:on_errorcould stop firing fromsettle_one(
stdb/connection.lua:846-848) and these tests would still pass. I verified byhand that the real path works today, so this is coverage, not a defect.
The replay test (
:653-676) is the good one — it genuinely yields, parks, andproves the second fire is inert. The error test should do the same: build a
connection.new,stdb.await(conn.call_reducer, conn, ...)inside acoroutine.create, assert it issuspended, thenconnection.settle_pending(conn, false)and assert the outcome. (That test will also catch finding 2.)5. NIT —
awaitattaches to a Subscription permanentlystdb/await.lua:120-124registers anon_appliedclosure and never detaches it.Subscription:on_appliedregistration is permanent by contract(
stdb/connection.lua:339-343), so every awaitedsubscribe_queryleaves oneinert closure that is invoked on every reconnect replay for the life of the
subscription. Harmless (the
resultsguard short-circuits) but unbounded perawait. Fix: capture the closure and
handle:off_applied(fn)insidesettle.6. NIT —
M.builder(opts)aliases the caller's tablestdb/init.lua:616stores the caller'soptsas_opts, andwith_token(
:630) /with_schema(:638) then write into it.build()copies(
:667-670), but the seed the caller still holds has been mutated. One-line fix:shallow-copy in
M.builderrather than inbuild.7. NIT — three new guards ship with no test
stdb/init.lua:70-75—stdb.url's scheme and non-empty-database raises. Notest matches
needs a wsornon-empty name.stdb/spacetimedb.editor_script:273-281— the new "no database to generatefrom" raise.
dev/test/test_fixtures.lua:1507alreadyloadfiles the editorscript and calls
get_commands(), so this is cheap to pin.8. NIT — the duck-type comment should name the one
on_errorwith a different shapestdb/await.lua:110-112says a Call hason_errorand a Subscription has both.SubBuilder:on_error(stdb/connection.lua:630) is a third: same name, but itsfnis called(ctx, err), sosettle(err, msg)would read a ctx as the error.No awaitable function returns a
SubBuildertoday, so this is documentation, nota bug — but it is the one counterexample to the sentence as written.
9. NIT —
stdb/auth.lua:10still showsjson = json,in the header examplewhile
:98now says it is "only needed off-engine". Drop it from the example forconsistency with the change.
Checked, fine — do not redo
decode_typed_bsatn's new raise cannot half-apply the cache. Readcache.decode_rows(stdb/cache.lua:372) andapply_database_update(
:524-594) directly: PASS ONE buildsbatchesand writes nothing; every storewrite is in PASS TWO (
:598+), which never reaches a decoder. A raise from thedecoder leaves the store untouched for the whole frame. The comment's claim
holds.
frame_budget.stdb/init.lua:373-383re-raises
coroutine.resume's error with level 0 and keeps the queue. (Contrastfinding 2, where the same pattern does not re-raise.)
row_listpartitionsrows_dataexactly for both hints (
FixedSizeslices atstdb/protocol_v2.lua:521-534,RowOffsetsvalidated at:535+), so a well-matched def consumes a row exactly.Event-table rows are decoded against the table's own def
(
stdb/connection.lua:1181-1187) — same def, same rows. One-off query rows arenever decoded by the SDK, and the documented caller-side decode of a
projection against a full def now raises instead of silently swapping columns
(
stdb/connection.lua:777-783) — an improvement, and the intended one.resultsis setbefore any resume, so late-fire-during-registration, callback-then-handle,
handle-then-callback, and replay-after-settle all land on the early return. The
replay test at
dev/test/test_init.lua:653-676covers the double-fire casethrough a real yield.
one_off_query,call_procedure,unsubscribeandpingall return a plainnumeric
request_id(stdb/connection.lua:759,:791,:724);unsubscribereturns
nilfor an already-ended set. Onlycall_reducer(Call) andsubscribe_query(Subscription) return tables.SubHandlehas neither method.db.tables's new raising__indexbreaks nothing. Nopairs,nil-checkor
rawgetagainst the facade map anywhere instdb/ordev/.tables.dispatch(stdb/tables.lua:261) indexes it, but only with names takenfrom
cache.schema, which is the same map the facades are built from;subscribe_to_all_tables(stdb/connection.lua:666) iteratescache.schema,not the facades.
build_ctxaliases the map (:86,:160), it does not probeit.
jsondefaulting mixes no implementations._G.jsonis read in exactly twoplaces (
stdb/schema.lua:206,stdb/auth.lua:121); each uses it only for itsown response body,
opts.jsonremains the seam in both, andconnecttakes nojsonat all. The editor script never callsschema.fetch(it carries its owndecoder), so the editor VM's missing
jsonglobal is not reachable from the newdefault. Both new tests restore
_G.jsonbefore their assertions(
dev/test/test_auth.lua:551,dev/test/test_fixtures.lua:1466), and therunner hands each file its own
jsonin a custom env (dev/test/runner.lua:50),so unsetting
_G.jsondoes not disturb the test file's own reference.pinginversion is genuinely pinned, not just ported.dev/test/test_connection_handles.lua:1265-1267and:1626-1628assertassert_nil(ping_err)plustype(ping_seconds) == "number"pluslatency() == ping_seconds; re-invertingcbputs the number inping_errandfails. (The error branch,
cb(err)atstdb/connection.lua:1475, has no test —minor.)
stdb/connection.lua:59-69emits
pass reducers = gen.reducers to stdb.connect, which the new match atdev/test/test_connection_handles.lua:1677finds; the oldknown: $match wouldno longer match this string, so it was replaced rather than weakened. The
typo-path assertions (
no such reducer+ the real name listed) are untouched.test_tables.luaandtest_auth.luagot stronger, not weaker. Both replacedassert_nilwith a raise plus two content assertions on the message; thejsonentry dropped from the required-field loop is replaced by a positive
default-resolution test and a negative no-json-anywhere test.
chat_client.lua'sevt.codeis correct:stdb/init.lua:444is the onlyemitter of
disconnectedand it carriescodeand nothing else.stdb.query.tables(gen)(re-exported atstdb/init.lua:48, validated atstdb/query.lua:489),:where+ a builder passed tosubscribe_query,Table:count/:find/:iter/:on_insert/:on_update/:on_delete(
stdb/tables.lua:171-251),db.identity(stdb/connection.lua:958),stdb.url("ws://...", "arena")under the new scheme check,schema.fetchwithout
json,stdb.await(self.db.reducers.join_game, { ... })against theaccessor's
(args, callback)shape, and/my_schema.luamatchingOUTin the editor script. The hand-writtengenexample matches the realgenerated shape (
{ params, types }per reducer,stdb/typetags.lua:341;identity/f32/stringare all real scalar tags,stdb/protocol_v2.lua:171-190). The TypeScript module andspacetimeCLIinvocations were not verified — no server toolchain reachable from here.
Review actioned. All three blocking findings fixed, four of the six nits taken.
Blocking
1.
schema = gendiscriminator. Took the reviewer's fix: discriminate on the four sections (tables+reducers+procedures+typespaceall present), not ontables.columns. New test builds a module whose tables include one namedcolumnsand asserts the typespace survives and the real tables are still the tables. Verified it fails against the old discriminator (expected table, got nilon the typespace).2.
stdb.awaitswallowing the async error. Took the reviewer's fix insettle:local ok, e = coroutine.resume(co); if not ok then error(e, 0) end. Replaced nothing; added a test that genuinely parks, per finding 4: realconnection.new+stdb.await(conn.call_reducer, ...)insidecoroutine.create, assertsuspended, thenconnection.settle_pending(conn, false)and assert the raise reaches the caller, the message isconnection lost, and the coroutine isdead. Verified explicitly that this test FAILS against the pre-fix code (expected error, call succeeded) and passes after.3. README
pcallclaim. Fixed inREADME.mdstep 8 and instdb/await.lua's docstring. Both now say topcallaround the awaited step rather than the wholecoroutine.wrapbody, because Lua 5.1 cannot yield acrosspcalland the HTML5 target is plain Lua 5.1. No em dashes, no bold, no mid-sentence colons.Nits taken
on_applied.awaitnow captures the closure and detaches it once settled. Placed after the yield rather than insidesettle, becauseon_appliedcan late-fire during registration, before the local holding the closure has been assigned. Pinned by extending the existing replay stub with anoff_appliedthat records which function was detached.M.builderaliasing. The seed is shallow-copied inM.builder.build()keeps its own copy, which is what lets a builder be built twice.assert_errors forstdb.url's scheme and empty-database raises. One for the editor script's no-database raise, driven throughcommands[1].runwith a stubbededitorglobal, asserting on the message content.SubBuilder:on_erroras the one same-name-different-shape case and why it never reaches there.stdb/auth.lua:10.json = json,dropped from the header example.Nits skipped
settle.Verification
lua dev/test/runner.lua— 13 passed, 0 failedstylua stdb dev/test— clean, and the suite still passes after formattingEXAMPLE: PASS, including the cross-client light frameRe-review
Not warranted. Every fix is the reviewer's own suggested change, each pinned by a test whose failure against the pre-fix code was verified rather than assumed. The one judgement call worth naming is the
off_appliedplacement above, which is more conservative than doing it insidesettle. No new problems surfaced while fixing, and nothing changed behaviour beyond what the review asked for. Merging.