connect: expose light mode and confirmed reads #33
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!33
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/22-light-confirmed"
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?
Closes #22. Review actioned; body rewritten after review — several original claims were superseded.
Two connect options that
v1.json.spacetimedbsupports and this SDK could not ask for:light(defaultfalse) →?light=true. The server sendsTransactionUpdateLightfor transactions this client did not itself call. The caller always still receives a full update — now proven against a live server, not read from source.confirmed_reads(defaultnil) →?confirmed=true|false. Tri-state: absent, true and false are three distinct requests. Justified as forward compatibility — V1 resolves absent andconfirmed=falseidentically, but later protocol versions default absent totrue, so a boolean option defaulting to false would have frozen V1's meaning into the API.Both options reach the URL on both platforms — native (header auth) and HTML5 (token in URL).
The refactor
with_tokenassumed it was the only query parameter. There is now one privatewith_param(url, key, value)making the?vs&decision, withwith_tokena single line over it — same signature, byte-identical output on every non-erroring input (verified across 10 inputs including an existing query string, an empty token, and a URL ending in?).What review changed
subscribe_urlis private, not public API. Its third argument is notopts.tokenbut the platform decisionconnectalready made, so a public version invitesstdb.subscribe_url(opts.url, opts)→ token silently dropped → on HTML5, a successful connection under a brand-new anonymous identity. That is the #8/#17 failure mode exactly. The reviewer's own reductio settled it: dropping the third argument makes it unusable byconnect, which proves it is internal.connectwith stubbed_G.websocket/_G.timer/is_html5, instead of testing a public seam. This closed a real coverage hole: moving the URL composition back inside theif tokenblock would have silently broken native light mode with a fully green suite. Verified by doing exactly that — one test goes red.opts.confirmed_reads and "true" or "false"collapsed every truthy value, soconfirmed_reads = "false"— a string, as it arrives from a config file orjson.decode— requestedconfirmed=true. An exact inversion. Now asserts boolean-or-nil, with tests pinning the rejection.TransactionUpdateLighthad zero coverage. Before this PR a v1 client could not reach that handler; this PR is what makes it reachable. Now unit-tested.smoke.scriptconnects withlight = true, and phase 1 asserts the caller's own update arrives as a fullTransactionUpdatecarryingreducer_calland that the reducer callback already fired. This matters concretely: the SDK correlates callbacks onreducer_call.request_id, andhandlers.TransactionUpdateLightnever touchesc.pending— so if a caller's own update ever arrived light, everycall_reducercallback would silently never fire andc.pendingwould leak for the life of the connection. A baseline conformance run was taken before the change, so the assertion is not shipping unexercised.One review claim was corrected rather than accepted:
TransactionUpdateLightdoes carryrequest_idon the wire (protocol.lua:40lists it REQUIRED). The real defect is one level over — the SDK correlates onreducer_call.request_id, which the light variant has no equivalent of. The consequence is unchanged; the docs are written against the accurate mechanism.Verification
5 passed, 0 failed;stylua --checkcleanPASS (identity 0xc200d7a1…dfb0 persisted across a process restart)if tokenblock goes red with exactly the predicted native light-mode failure; (2) sendinglight=notaboolmakes the live server refuse every connection, proving the parameter is genuinely parsed server-side rather than ignored — so a green run means somethingchat_schema.luaregenerated byte-identical during both conformance runs: no schema drift against the pinned serverStill deferred, deliberately
Proving the server actually sends
TransactionUpdateLightfor transactions this client did not call. That needs a second connection in the harness — genuinely new infrastructure. #22 explicitly offered this deferral; the single-client half is now covered, and the README names what remains unproven rather than implying full coverage.confirmed_readsis not added to the conformance run: the README makes no server-behaviour claim about it, and setting it changes durability timing in a latency-sensitive harness. Its URL composition is unit-tested on both platforms, which is all #22 asked for.Review — automated reviewer, posted for the record
Review — PR #33
feat/22-light-confirmedRan everything:
5 passed, 0 failedandstylua --check stdb dev/testexit 0 both reproduce. Suite runs under Lua 5.1.5; the diff uses only..,~=,and/orandstring.find(…, 1, true)— no 5.2+ constructs. The diff touchesstdb/init.lua(the one shipped file allowed engine globals),README.mdanddev/test/only, so the pure-core no-Defold-globals rule is untouched.Headline: the bug you were most worried about is not there — I verified it by running
connect— but the PR ships one new frozen public function that should not be frozen, and its two most load-bearing claims have no test that could detect them breaking.1.
M.subscribe_urlas public API — blocker (for a PR on the #24 freeze path)Make it
local. Definitively. Three independent reasons, the third decisive:It is public in code and absent from the documented surface.
README.md:337-345lists every public facade function — including both existing testing seams,with_tokenandis_html5. The PR does not addsubscribe_urlto that table. #24's contract is "the documented surface is stable", so this lands in the worst quadrant: reachable by consumers, not covered by the freeze promise.It duplicates
with_token.subscribe_url(u, {}, t) == with_token(u, t)— the PR asserts exactly that atdev/test/test_init.lua:70-74. Freezing both means freezing two overlapping URL builders forever, one of which is a strict superset of the other.The signature is a consumer footgun.
M.subscribe_url(url, opts, token)(init.lua:65) takes a connect-opts table but ignoresopts.token, while connect-opts has atokenfield (README.md:355). A consumer holding their opts table writes the obvious thing:On HTML5 that yields a connection that succeeds as a brand new anonymous identity — the precise silent-wrong-answer failure of #8 and #17, which
init.lua:149-153has a five-line comment warning about. And the reason the third argument exists at all is that the platform decision lives inconnect(init.lua:147-160): the token must not enter the URL on native. A function whose third parameter only makes sense to its single in-tree caller is an internal seam, not an API.The
with_token/is_html5precedent looks like it cuts the other way, but it does not: both are self-contained concepts and documented.subscribe_urlis neither.The fix, and why it is strictly better than the current tests
Make it
local function subscribe_url(url, opts, token)besidewith_param; keepM.with_tokenpublic and documented as-is. Then test throughconnectwith a stubbedwebsocketglobal. I confirmed this works in this runner —runner.lua:50sandboxes each test file, but_G.websocket = {…}writes the real global thatinit.lua:113reads, andis_html5is already documented atinit.lua:79as "wrapped as a function so tests can stub it" (a seam nothing currently uses). Roughly 15 lines:Restoring matters:
package.loadedpersists across test files in this runner.That test removes the new public API and closes the finding-6 gap below, which no
subscribe_urlunit test can reach. Two problems, one smaller diff.If you keep it public anyway: it must be added to the
README.md:337-345table, and theopts.tokenfootgun must go — but dropping the third argument makes it unusable byconnect, which is the proof it belongs private.2.
with_tokenbackwards compatibility — verified byte-identicalI reimplemented the pre-PR body and diffed both against ten inputs:
…?token=tok…?token=tok?compression=None)…&token=tok…&token=tok?token=?token=a.b-c_d?token=a.b-c_da b&c=d?e#f/g+h%i??&token=tok?&token=tok#frag#frag?token=tok42?token=42?token=42The PR's "same signature, same output" holds for every non-erroring input. The one difference is the error string, because the concat moved into
with_paramand the local is namedvalue:Nit, no action. Both raise; nothing
pcalls this; the message was never a contract. Worth knowing only because the PR says byte-identical and this is the single place it is not.Uncovered by tests, unchanged by the PR: a URL already ending in
?produces?&token=(empty first pair). Pre-existing in both implementations; not this PR's problem.3. Tri-state
confirmed_reads— correct conditional, but the value mapping has the bug one level down (should-fix)The conditional you were worried about is right.
init.lua:69isif opts.confirmed_reads ~= nil then, notif opts.confirmed_reads then. Measured:falseis sent, not swallowed. Upstream confirms the tri-state is real:SubscribeQueryParams.confirmedisOption<bool>(crates/client-api/src/routes/subscribe.rs:100).But
init.lua:70maps the value withopts.confirmed_reads and "true" or "false", so every non-falsetruthy value collapses to"true". Measured:A value arriving from a config file, a
json.decode, or a Defold.iniis a string.confirmed_reads = "false"requestingconfirmed=trueis the same silent-inversion class the PR body congratulates itself on avoiding fornil, just moved from the conditional into the value. On a frozen public API this is an input-validation gap at a trust boundary.Fix, one line each:
Test-honesty note on the
nilcasetest_init.lua:52-56assertssubscribe_url(base, { confirmed_reads = nil }). Lua's table constructor drops nil fields — I counted the fields, it is 0 — so that table is{}and the assertion is byte-identical totest_init.lua:34. It documents intent, which is fine, but the PR body's "eachconfirmed_readsstate includingnilproducing no parameter at all" over-claims it as distinct coverage. The realnilcoverage is line 34.Similarly
test_init.lua:70-74(subscribe_url(base, {}, "tok") == with_token(base, "tok")) is self-referential: both sides route throughwith_param, so a change to the separator logic moves both and the test still passes. What actually pins today's bytes is the pair of literal-string assertions attest_init.lua:17-27, unmodified by this PR — so the PR's claim is true, just via a different test than it credits. Cheap improvement: assert against the literalbase .. "?token=tok".4. Parameter escaping — absence is correct, not latent (nit: write the reasoning down)
No encoding is right here, and I checked the server side rather than assuming:
light/confirmedvalues are the hardcoded literals"true"/"false". Nothing to encode.Query<SubscribeQueryParams>(subscribe.rs:120-127), i.e.serde_urlencoded, so values are percent-decoded server-side.A-Za-z0-9-_) plus.separators. None of those are percent-significant, and base64url never emits+or/. Decoding is a no-op for exactly the inputs this parameter carries. Your instinct is right.Latent only for a non-JWT
tokencontaining&,=,%,+or a space — which would corrupt the query string. That is pre-existing inwith_token, not a regression, and encoding it now would changewith_token's output for precisely the inputs that are already broken.Recommendation: do not encode. Add one comment line to
with_param(init.lua:44) stating that values must already be URL-safe — literals and JWT base64url — so the next person adding a user-supplied parameter through this now-shared helper does not inherit a silent bug. The helper's own docstring currently advertises that everything goes through it, which makes the unstated precondition more dangerous than it was when only the token used it.5. Ordering claim — accurate and genuinely pinned
init.lua:66-74is three sequentialifstatements, straight-line, no table iteration — had it been apairsloop over a params table, order would have been non-deterministic and unassertable. It is not.Pinned by two tests that would fail on a swap:
test_init.lua:58-62(?light=true&confirmed=false) and:64-68(?light=true&confirmed=true&token=tok). Claim verified.6. Both platform paths — no bug; verified by running
connect(but zero test coverage)This is the one you flagged as most likely broken. It is correct, and the reason is
init.lua:161:local url = M.subscribe_url(opts.url, opts, url_token)sits outside theif tokenblock, so the URL is composed unconditionally.url_tokenstaysnilon native, so the token stays out of the URL andparams.headersis still set at:158.I stubbed
_G.websocketand captured whatwebsocket.connectactually receives:…subscribe?light=true&confirmed=false…subscribe?light=true&confirmed=falseAuthorization: Bearer JWT.a-b_c…subscribe?light=true&confirmed=false&token=JWT.a-b_c…subscribeAuthorization: Bearer JWT.a-b_c…subscribe?token=JWT.a-b_c…subscribeBoth transports get
light/confirmed; native still authenticates by header with no token in the URL; HTML5 appends the token last; and the default rows are byte-identical to pre-PR output. Also worth noting the URL is recomputed insideopen()rather than hoisted, so a reconnect does not accumulate duplicate parameters — the other classic shape of this bug is absent too.The gap is coverage, not correctness. If someone moved
:161back inside theif tokenblock, every test in this PR would still pass, and native builds would silently lose light mode — on the platform the README's bandwidth argument is aimed at. The pure-seam tests structurally cannot see this, because the platform branch lives inconnect. This is what turns finding 1 from a taste argument into a concrete one: the stubbed-connecttest both deletes the new public API and covers this.7. The README claim — accurate, verified against upstream, and untested where it matters most (should-fix)
I checked the source rather than trusting the PR body.
crates/core/src/subscription/module_subscription_manager.rs:The caller branch sends
TransactionUpdateMessage { event: Some(event.clone()), … }; the light gateclient.config.tx_update_full.then(|| event.clone())appears only in the non-caller loop. So "Your own reducer calls still come back as full updates" is correct, as is?light=true→tx_update_full: !light(subscribe.rs:206).Why that sentence is load-bearing rather than cosmetic:
handlers.TransactionUpdateLight(stdb/connection.lua:324-327) applies the cache and emitstransaction, but a light message carries norequest_id, so unlikehandlers.TransactionUpdate(:303-311) it never clearsc.pending[request_id]. If a caller's own update ever arrived light, everydb:call_reducercallback would silently never fire andc.pendingwould leak entries for the life of the connection. The README sentence is the only thing standing between this PR and that failure — and #24 says explicitly: "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."The deferral is broader than #22 authorised. #22 offers deferring "did the server honour light mode", which needs a second connection to cause a transaction this client did not call. The bolded README claim needs one client: connect with
light = true, call a reducer yourself, assert the update arrives as fullTransactionUpdatewithreducer_callpresent and the reducer callback fires. That fits the existing single-client harness with no new infrastructure. The PR's "Deliberately not done" section is admirably explicit about the half it names, and silently sweeps this half in with it.Cheaper second gap, independent of any server. Nothing in
dev/mentionsTransactionUpdateLight— zero grep hits acrossdev/, zero indev/test/fixtures/session.jsonl. Before this PR that handler was unreachable for a v1 client; this PR is what makes it reachable. The PR body's "connection.lua already handles that message, so this adds no new message handling — only the ability to ask" is technically true and rhetorically misleading: it adds the ability to reach an entirely untested handler. Eight lines intest_connection.luausing the existingnew_conn()+conn:handle_frame(json.encode{ TransactionUpdateLight = { update = … } })pattern, asserting cache counts and the emittedtransactionevent, closes it.Two smaller README accuracy points
README.md:365-367— "nilandfalseare different requests to the server". At the wire level yes; behaviourally on the protocol this SDK speaks, no.resolve_confirmed_reads_default(subscribe.rs:103-111) mapsWsVersion::V1 + None→false, identical toSome(false); upstream's own test pins it (subscribe.rs:2838-2844). The tri-state is still the right design — V2/V3 mapNone→DEFAULT_CONFIRMED_READS, which istrue— so the honest justification is forward compatibility:nilmeans something different on the next protocol version, and a boolean default would have baked in today's meaning. One sentence, nit.Informational, no action. Upstream also computes
hide_reducer_info_for_non_callers = matches!(module_def_version, RawModuleDefVersion::V10)and sendsevent: Noneto non-callers regardless oflight. For V10 modules part of light mode's saving is already unconditional, so the README's bandwidth pitch may overstate the marginal delta on some modules. Doesn't affect the change.Claim audit
with_tokensame signature, same outputtest_init.lua:17-27untouched, literal expected stringsnilomits the parameter~= nil, not truthinessconfirmed_readsstate includingnil"nilassertion is byte-identical to the{}one; Lua drops nil fields:70-74, which compares new code to new code5 passed, 0 failed; stylua cleanDoes it close #22?
Substantially yes, with two acceptance gaps.
Met: light alone (
:36), confirmed alone (:38-42), both (:58-62), both plus token proving?/&composition (:64-68), default byte-identical (via:17-27). Both options genuinely reach the URL on both platforms. #22's request to "say so explicitly rather than leaving it ambiguous" about the conformance deferral is honoured with a dedicated section.Not met:
connect. #22 asks that the options be threaded onto the URL; nothing tests that either platform branch does so. Revertinginit.lua:161into theif tokenblock would break native light mode with a fully green suite.handlers.TransactionUpdateLightalso has no unit test at all.Summary
Blocker (1): make
M.subscribe_urllocal; test throughconnectwith a stubbedwebsocketglobal (~15 lines, verified working), which also closes gap 6. Do not freeze this signature — it takes an opts table and ignoresopts.token.Should-fix (3): assert boolean-or-nil on
light/confirmed_reads("false"currently requeststrue); add the single-client conformance assertion for the bolded README claim, or soften the README; add aTransactionUpdateLightunit test totest_connection.lua.Nits (4): note the unstated URL-safe precondition in
with_param; reword thenilvsfalsejustification as forward compatibility; strengthentest_init.lua:70-74to a literal string; theconfirmed_reads = nilassertion is documentation, not coverage — worth labelling as such.The engineering is sound and the refactor is the right one —
with_paramis exactly the correct shape and the compatibility is genuinely byte-identical. What needs work is the public surface it exposes on the way to a freeze, and the distance between what the README asserts and what any test can detect.