api: eight renames the 1.0 freeze makes permanent #55
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!55
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/api-freeze"
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?
The API surface freeze review from #24's release gate, actioned. Eight renames, because the tag makes every name permanent and this is the last cheap moment. No behaviour changes except one silent-failure bug that the naming inconsistency was hiding.
A freeze review enumerated every public name — every module, function, parameter, event
typestring, table field and handle method — and counted every call site. Eight were worth the churn; one was rejected.The eight
1. Event
type = "error"→"socket_error"(+2/-1)It was the only emitted event type absent from the README's event table — 16 rows for 17 types. An undocumented string literal in a frozen surface is the worst possible artifact: undiscoverable and unchangeable. It was also the most generic name on the surface given to the most specific case (one transport's error callback), sitting beside
evt.error,sub.state == "error"andcall.state == "error"— four unrelated meanings of one token. Every sibling socket event is a participle.The table is now 17/17, enumerated by hand, including the three dynamically-built sites (
connection.lua:764,:899,:934) that a literal grep cannot see.2.
db.state→db.cache(+122/-114)The connection's cache was
db.state, while the two handles a caller holds at the same moment spell their lifecyclesub.state/call.state:This is the exact defect this release already fixed once —
connection.lua:264says "Namedstateand notoutcometo matchSubscription.state: one concept deserves one name." The same rule condemneddb.state.tables.lua:22already confessed it in prose.cache.lua's own first parameter was renamed too; leaving it would have kept the word in play. Verified zero cache-ish.statefield accesses survive — every remaining one is sub/call lifecycle or a fixture row column literally namedstate. Test-localstate/*statevariables intest_cache.luaandtest_fixtures.luaare left alone: they are not surface, and ~30 lines of test churn at a freeze buys nothing.3.
undescribed[].rows→dropped(+22/-27)A count named as a plural noun.
README:811-816spent six lines apologising for it, and the apology was the proof: "The.rowon an insert or delete event is the row itself; these are different fields."e.row(a row) andevt.rows(a number) differed by one character and by kind.Moved in all three places at once — the event,
counts.undescribed[i],sub.undescribed[i]. Both apologies deleted, including a five-line paragraph inconnection.luathat the rename makes obsolete.4.
schedule_at_to_secondsreturns"Interval"/"Time"(+22/-19)This one was a bug, not a naming preference.
decode_valueyielded{tag="Interval"}while the helper returned"interval", and module-declared sums are server-lowercased ({tag="busy"}). So a consumer learned "tags are lowercase", wrotetag == "interval", and gotfalseforever with nothing raised.Fixed the helper, not the tag:
Interval/Timeare the server's own declared variant names (gen-schema.sh:210matches them off the live schema endpoint), so the decoder was right and the lowercasing was this SDK's invention.protocol.lua:275-277's apology comment is deleted.The reproduction is now a permanent guard:
schedule_at_to_seconds(decode_value({0,{1500000}}, "schedule_at", {}))must return exactlysa.tag. That assertion fails on the old code and passes now — it is the proof the silent path is closed.5.
stdb.url(host, database)→(base_url, database)(+4/-4)The parameter must be a scheme-qualified origin, and the README believed the name: its Returns column claimed
ws://was prepended. It is not —stdb.url("127.0.0.1:3000", "chat")returns a scheme-less string and the connect fails. The name had already caused a documentation bug. Zero call sites, since it is positional.6.
protocol.decode_typed(encoded, spec, …)→(encoded, def, …)(+16/-15)A schema entry had three names on the frozen surface:
def,defs,spec. The singular/plural pair wins 2:1;specwas the outlier, published in the README twelve lines fromt.def.protocol.lua:1115marks it FROZEN (v1.0), so this was the last chance. Zero call sites. Two user-facing error messages now say "schema def".7.
protocol.duration_to_seconds→time_duration_to_seconds(+11/-11)The type is
TimeDuration, the tag string is"time_duration", the wrapper is__time_duration_micros__, and five sibling helpers are named after their types exactly. This was the one abbreviation.8.
subscription_error→subscription_failed(+4/-4)Three
_failedevents, one_error. Among failure events it was one_erroragainst three_failed, sitting besidesub.errorandsub.state == "error"— the same collision item 1 exists to reduce. (procedure_resultis also an exact wire mirror and survives, so "no other event mirrors a wire name" would have been false; the failure-family argument is the one that holds.)evt.type == "subscription_failed"now diverges fromsub.state == "error"for the same occurrence, deliberately: it matchesreducer_failedvscall.state == "error". Wire tag untouched.BREAKING for 0.1 consumers
schedule_at_to_secondsnow returns"Interval"/"Time"where 0.1 returned"interval"/"time". Compare against the tag (kind == v.tag) rather than a literal, or update the literal.This repo has no CHANGELOG —
release.ymlpublishes the tag annotation as the release body — so this line and the tag annotation are the only notice that cohort gets. Item 4 is a bug fix and a silent breaking change, and saying only the first half would be the same failure mode this PR exists to remove, pointed the other way.The direction is still right. Lowercasing the decoder instead would break the larger cohort: cached-row
.tagreaders, the common path since #35, plus reducer parameters and procedure returns. Independently corroborated —dev/server/index.jsdeclarest.enum("Status", ["Idle", "Busy"])and the published schema saysidle/busy, so 2.8.2 lowercases module enums but not the SDK-builtinScheduleAt. The inconsistency is the server's;decode_value's one rule — tag is the declared variant name — is right, and the helper was the outlier.Rejected
evt.counts→evt.changes. Defensible as-is, and it appears in the README's opening example and in every tutorial written against 0.1. Not touched anywhere.Also not done: renaming
sub.conn/t.connto_conn. The_privacy convention genuinely is inconsistent there (subcarries_applied/_errorbeside a bareconn), but that is a ninth rename nobody accepted. Documented instead.Three freeze decisions
stdb.connection's re-export — all 12 functions are API. Document, do not narrow.stdb/init.luadrives exactly six of them (new,set_send,handle_frame,resubscribe_all,settle_pending,backoff_delay), which makesinit.luathe reference transport and those six precisely the seam anyone replacing it needs — LuaSocket, another engine, a test harness.settle_pendingandbackoff_delaylooked like plumbing, butinit.luacalls both directly, so a replacement transport cannot work without them. New README section documents the six with signatures verified against the source; the other six are thedb:request methods in module form.db.connection_idwas populated, public and undocumented. Now in the Connection-methods table, including the part the field name does not say: it is a fresh ConnectionId per socket, so it changes across a reconnect whiledb.identitydoes not.sub.queries,sub.conn,t.connwere all populated, public and undocumented — frozen whether documented or not. All three documented:sub.queriesis genuine API (it is what a reconnect replays, andsubscription_replacedalready publishes the same strings), and the two.connare named as back-references a consumer does not need because it already holdsdb.Counts, corrected
The freeze review's estimates were close but not exact, and where they disagreed the actual count won.
db.statewas 55 field lines, not ~48 — the review missedREADME:288-289and twotest_tableslines and misplaced one README reference.undescribed[].rowswas higher too, because the local accumulator and an obsolete comment came with it. Items 7 and 8 matched exactly.Verification
6 passed, 0 failedon the committed tree; stylua clean. Native conformance PASS both runs through PHASE5 with identity persisted across a process restart. HTML5 PASS both loads through PHASE5 with identity surviving a reload. No fixture drift —chat_schema.luamatches whatgen-schema.shderives from the freshly published module.Straggler grep is clean: zero hits for
subscription_error,[^_]duration_to_seconds,\bspec\b,variant("interval",variant("time",M.url(host,type = "error". There are four wire-rowssites instdb/(connection.lua:725,:733,protocol.lua:58,:60), all correctly untouched. They belong toSubscribeApplied/UnsubscribeApplied, notOneOffQueryResponse— an earlier draft of this body misattributed them.The tag freezes every public name for good, so each of these is now-or-never. None is cosmetic; each fixes a name that misleads, hides, or silently fails. 1. evt.type "error" -> "socket_error". It was the ONLY emitted event type missing from the README table - 16 rows for 17 types - and an undocumented string literal is the worst freeze artifact there is: undiscoverable and unchangeable. It also spent the most generic name on the surface on the most specific case (one transport's error callback), while `evt.error`, `sub.state == "error"` and `call.state == "error"` all mean something else. Its siblings from the same callback are participles - connected, disconnected, reconnecting, reconnected - and now so is this. The missing README row is added, so the table is 17/17. 2. db.state -> db.cache. The connection's cache was `state` while the two handles a caller holds at the same moment spell their lifecycle `sub.state` and `call.state`: one token, two concepts, in one function body. This release already fixed this exact defect once - connection.lua's "Named `state` and not `outcome` to match `Subscription.state`: one concept deserves one name" - and tables.lua already confessed the other half in prose. `db.cache` also reads right at the callsite: `stdb.cache.get(db.cache, "player", db.identity)`. cache.lua's own first parameter moves with it, because leaving that half-done keeps the word in play; the README's stdb.cache signatures now spell it `db.cache` too, which is what a consumer actually types. 3. undescribed[].rows -> dropped. A count named as a plural noun, one character from `e.row`, which is a row. The README spent five lines apologising for it ("it reads as a plural because it is one of `counts`") and connection.lua another five; `dropped` says what the caller wants to know - how many did I lose - and cannot be mistaken for a payload. Both apologies delete. Moved in all three places at once: the event, counts.undescribed[i], sub.undescribed[i]. 4. schedule_at_to_seconds returns "Interval"/"Time", not lowercase. This was a silent-failure bug, not a naming preference. `decode_value` yields { tag = "Interval" } while the helper returned "interval", and a module-declared sum really does decode to the server-lowercased variant ({ tag = "busy" }) - so a consumer learns "tags are lowercase", writes `when.tag == "interval"`, and gets false forever with nothing raised. The decoder is right: Interval/Time are the server's declared variant names (gen-schema.sh matches them off the live schema endpoint), so the helper's lowercasing was the SDK's own invention. Fixed there, apology comment gone, and a guard asserts kind == decode_value(...).tag so it cannot come back. 5. stdb.url(host, database) -> (base_url, database). The parameter must be a scheme-qualified origin; the name said host, and the README believed the name - its Returns column claimed `ws://` was prepended. It is not, and `stdb.url("127.0.0.1:3000", "chat")` yields a URL that cannot connect. The one entry here whose name had already produced a documentation bug. Zero call sites: positional. 6. protocol.decode_typed(encoded, spec, ...) -> (encoded, def, ...). A schema entry had three names on the frozen surface: `def` (t.def), `defs` (schema.new(defs)) and `spec`. The singular/plural pair wins 2:1 and `t.def` is published twelve README lines from `spec`. protocol.lua marks this function FROZEN (v1.0), so there is no later. Zero call sites: positional. 7. protocol.duration_to_seconds -> time_duration_to_seconds. The type is TimeDuration, the tag string is "time_duration", the wrapper is __time_duration_micros__, and five sibling helpers are named after their types exactly. This was the one abbreviation, and the only one of the six a reader could take for something generic. 8. subscription_error -> subscription_failed. Three `_failed` events, one `_error`, and nothing about a rejected query is more error-than-failure than a rejected reducer. It does mirror the wire's SubscriptionError, but no other event type mirrors a wire name (`transaction`, not TransactionUpdate), so that is not a convention this surface keeps. Deliberately NOT renamed: evt.counts -> evt.changes. Defensible as "the counts object, which also carries the events", and it is in the README's opening example and every tutorial written against 0.1. Freeze scope, decided rather than left implicit: * stdb.connection is re-exported and the README called it only "the pure core", which froze 12 undocumented functions. All 12 are genuinely API: stdb/init.lua drives exactly six of them (new, set_send, handle_frame, resubscribe_all, settle_pending, backoff_delay), which makes it the reference transport and those six the seam anyone replacing it needs. They get a `stdb.connection` reference section; the other six are the `db:` request methods in module form, which the section now says out loud. * db.connection_id was populated and public but absent from the connection table. Documented, including that it changes per socket while the identity does not. * sub.queries, sub.conn and t.conn were populated, public and undocumented. Documented - the two back-references named as such rather than renamed, since a `_conn` rename is not on this list. Suite 6/6, stylua clean, native and HTML5 conformance both pass, no fixture drift.Review — automated reviewer, posted for the record
Verdict: READY TO MERGE AND TAG after two one-line fixes (both since applied in
5385125). No blockers. All eight renames are correct — none is a worse name, none is churn. Every claim I could execute, I executed.One real survivor, and it ships
stdb/init.lua:24still read "a table named `state` or `close` cannot collide with the connection's own". After the rename the connection owns no field namedstate, so half the sentence names an impossible collision and points at something gone. Every sibling copy was updated (tables.lua:12,connection.lua:41,README:311) — a grep fordb.statecould not reach this one because it is the bare word in prose.git archive --worktree-attributesis README + game.project + patches/ + stdb/, so this is in the first 30 lines every consumer downloads.Undisclosed breaking change
Item 4 is a bug fix and a silent breaking change. A consumer using only the helper and comparing against the README-documented
"interval"worked on 0.1 and now getsfalseforever with nothing raised — the exact failure mode this PR exists to remove, pointed the other way. There is no CHANGELOG (release.ymlpublishes the tag annotation as the release body), so the PR body is the changelog.The direction is still right, and better-evidenced than the PR stated:
chat_schema.lua:86tagsall_types.schedasschedule_at, andgen-schema.shreaches that tag by exactly one route — sorted variant names["Interval","Time"]read verbatim off the live schema endpoint. If 2.8.2 published lowercase there the tag would beunknownand the drift diff would fail; it passed. Meanwhiledev/server/index.js:20declarest.enum('Status',['Idle','Busy'])and the published schema saysidle/busy. So the server lowercases module enums but not the SDK-builtin ScheduleAt — the inconsistency is the server's,decode_value's rule is right, and the helper was the outlier. Lowercasing the decoder instead would break the larger cohort.Verified by execution, not taken on trust
origin/main. Same expression both trees: old →sa.tag=Interval,kind=interval, equal FALSE; new → TRUE.tables.installalso write facades flat onto the connection):FAIL test_tables.lua "db.cache is the cache even with a table named cache": expected table, got nil. Identical strength to the pre-rename form; not defanged.cacheverified for the first time. I published a scratch module declaring seven tables named after connection fields —cache,close,state,tables,send,identity,pending— and 2.8.2 accepted all seven and served a row from each. No fixture drift.connection.lua:764,:899,:934).db.connection_idgenuinely changes per socket against a live server whiledb.identitydoes not, and the six documented seam functions behave as documented.Should-fix in the audit trail
The Verification paragraph had three inaccuracies, all corrected in the body: the surviving wire-
rowssites belong toSubscribeApplied/UnsubscribeApplied, notOneOffQueryResponse; there are four, not two; and "zero cache-ish.statesurvives" holds only for the dotted field form — test-localstate/*statevariables remain and are correctly left alone.Item 8's stated argument was also false:
procedure_resultis an exact snake_case mirror of its wire tag and survives this PR, so there were two mirrors. The rename is still right on the argument that holds — among failure events it was 1_erroragainst 3_failed.The freeze criterion was applied and then stopped
"Populated, public and undocumented is frozen whether documented or not" was applied to four fields. I enumerated every object a consumer holds:
Subscription,Calland the table accessor are complete. The connection is not — nine more remain,reconnect_pendingsharpest of all sincestdb.connectinvents it atinit.lua:236/241, it is on no opts table, and nothing mentions it anywhere. Fixed by naming them as outside the freeze rather than freezing nine internals as API.On the rejections
evt.counts→evt.changes: agree with rejecting.changesis no more accurate (undescribedis not a change), and the real complaint — two numbers and two lists under one noun — is only fixed by flattening, which is a behaviour change, not a rename.sub.conn/t.conn→_conn: agree, but for a better reason than the PR gives. "A ninth rename nobody accepted" is process. The substantive reason is that_connwould narrow the frozen surface after the fact — declaring a currently-public field private — which is exactly whatREADME:312-314condemns.Sanity
6 passed, 0 failed; stylua clean. Native conformance PASS both runs through PHASE5, run 2 tokenless with identity persisted across a process restart; no fixture drift. HTML5 PASS both loads through PHASE5, identity survived a reload. (First HTML5 attempt failed onnet::ERR_UNSAFE_PORT— my port choice, not the diff.)