tables: a typed accessor per table #45
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!45
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/27-table-api"
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 #27.
Row changes arrived as one firehose: every consumer wrote the same loop over
evt.counts.events, filtering by table name and branching on event type. Every table in the schema now has an accessor, namespaced underdb.tables:It is also where the generator's output stops being merely emitted:
t.defcarriescolumns,types,pk,uniqueandindexes,findreads the unique constraints, andon_updateexists because of the pk.Design decisions
stdb/tables.lua. connection.lua is 750 lines of request correlation, handles and message dispatch, and a facade shares none of it: what it needs is the schema and the cache, both passed in, so tables.lua is pure and unit-testable without an engine. connection.lua gains one require, oneinstallcall and a four-lineemit_rows.on_eventis untouched and still fires first. The per-table callbacks run after it for every frame, so nothing an existing consumer already saw can be reordered by a callback registered behind it.Subscription:off_appliedalready spells it:off_insert(fn), removing one registration, the first match. A game object registers ininit(self)and the closure holdsself; iffinal(self)cannot undo that, the first row after the object is deleted calls into a corpse. A handle returned byon_insertwould have been a second idiom for one concept, a second object for that game object to store, and it would have coston_insertits return value — which chains instead.iter/count/findsees a complete transaction, never half of one.firecopies its own: registering or detaching from inside a callback is ordinary — the fire-once idiom is exactly that — and mutating the list under the loop would either skip a sibling or index past the end. Attaching and detaching therefore take effect from the next row. One buffer refilled per row rather than one table per row, so a 500-row transaction does not allocate 500 of them.on_updateraises on a pk-less table. Such a table can never report an update — its row change arrives as a delete plus an insert, and cache.lua deliberately does not pair those — so the registration could never fire.require_pkreads the samedef.pkthat cache.lua pairs updates off, so the raise cannot disagree with what dispatch can actually deliver: it is derived, not heuristic.off_updateraises too, so afinal()mirroring itsinit()fails in the same place.finddelegates entirely tocache.find(#44). No index maintenance is duplicated here. Passing the pk is allowed and delegates tocache.get, so generated code can loop the oneuniquelist with no pk special case; a column under no unique constraint raises rather than scanning.Why
db.tables.<name>and not a flatdb.<name>Table names belong to whoever wrote the server module, and the connection object already owns
state,send,close,subscribe,identity, and whatever it grows next. A flat accessor merges those two namespaces, and the clash is reachable rather than theoretical: 2.8.2 accepts a table calledstateorclose, publishes it, and serves its rows throughSELECT * FROM state(verified against a live server on a scratch module).Every flat answer is worse:
init()asattempt to call method 'on_insert' (a nil value), withdb.statehanding back the cache.Under a namespace the construction is total. A table genuinely called
tablesisdb.tables.tables, nothing can collide, there is no list and nothing is refused; the one reserved name isdb.tablesitself, which is ours to spend. It also matches the peer SDKs — Godot'sclient.db.<table>and the officialctx.db.<table>both namespace, and theirclient/ctxis ourdb.Index with brackets for a name that is not a valid Lua identifier:
db.tables["my-table"]. Dot access does not reach those —db.tables.2fastis a syntax error, anddb.tables.my-tablecompiles, asdb.tables.my - table, then fails at runtime with an arithmetic error. The README says so in the table-accessor section.Since the last review
Rebased onto
6e37098, and the #44 seam is deleted.Table:findis nowreturn cache.find(...)and nothing else. This matters more than a tidy-up: pre-rebase,findfell back to a pk-only answer, so on the real generated schemaprofile— no pk, uniqueslug— resolved nothing,profile:find("slug", …)raised, and exactly onefindcall in the whole schema worked. That was the README:711 mismatch. Measured on the fixture schema, before and after:profile:find("slug", "hero")profile:find("slug", "nope")profile:find("realm", "eu")stdb.cache: table "profile" has no unique column "realm"message:find("id", 1)A
findtest on a non-pk unique column. Every existingfindtest usedmessage.id, where pk and unique coincide, so a pk-onlyfindpassed them all.lognow declaresunique = { "line" }— no pk, one unique column, the shape the generator really produces forprofile— and the suite asserts both a hit and a nil miss on it.db.tablesin the module's own docstrings. The re-cut renamed every callsite but missed tables.lua's docstrings, which are the API reference: the canonical Defoldinit/finalblock, theiterexample and thefindexample all still showed the flat form, and each raisedattempt to index field '<name>' (a nil value)if copied — the exact outcome the module header cites as the reason for the namespace. Also one stale comment in smoke.script.The
findexample no longer promises a lookup that cannot work. It wasdb.player:find("identity", me), which is precisely the unique-identity lookup #44's README documents as broken by #35 — a special-type column decodes to a fresh wrapper table per row, so neither the identity string nor a re-wrapped copy matches, and it returns a silent nil. The example is nowfind("slug", …), and the docstring carries the #35 caveat explicitly.err()'s level was wrong on every path that reaches it. It hardcoded level 3, documented as "blames the line that called the facade method".return attach(self, …)is a tail call, and Lua 5.1 discards the frame level 3 counts on, soon_insert/on_delete/on_updateraised with no file:line at all — the likeliest real mistake, a typo'dself.on_rowthat is nil, pointed nowhere — while the pk guard blamedtables.luaitself. Now level 4, correct for all five, since deleting the seam lefterrreachable only from a helper (attach,require_pk) that a facade method called. (off_insert/off_deletetake any argument and raise nothing;detachscans for a match and afinal()undoing a registration it never made is not an error.)on_insert(nil)on_delete(nil)on_update(nil)log:on_update(fn)stdb/tables.lua:163log:off_update(fn)stdb/tables.lua:171Three dispatch contracts that were real but undocumented, each measured before being written down:
on_eventalready ran with the whole batch. A per-table consumer can therefore disagree with anon_eventconsumer about one transaction, and nothing records which rows were dropped.db:close()from inside a callback does not stop the batch: dispatch is a plain loop over already-applied events, so the remaining rows are still delivered.logfrom amessagecallback and it receiveslog's rows of that same transaction — rows cached before it existed.The bracket form is in the README, and
require_pknow records the one residual it has: the guard is only as true as the generated schema, and a stale one inverts it into the silence it exists to prevent. That is #40's "regenerate after any module change" with a sharper consequence, inherited rather than introduced here.Verification
6 passed, 0 failed; stylua clean; native conformance PASS first try, both runs, on the rebased tree, with the API exercised end to end:The smoke client registers all three callbacks, calls
findby pk inside the update callback and asserts it returns the very row the callback was handed, thenoff_inserts from inside that callback — phase 3 proves the detach took by counting the rows that the reconnect replay and the phase-3 query set would otherwise have delivered. Regeneratingchat_schema.luaoff the live server reproduced the committed fixture byte for byte apart from the port in its header comment, so the fixture is current.Falsifiability by mutation — each piece broken in place, suite re-run, then reverted:
emit_rowsno longer dispatchesone call per row, in order, from a subscription: expected insert:1,insert:2, gotstdb/tables.lua:266: attempt to call field '?' (a nil value)detachstops removinga detached callback stops being delivered to: expected 1, got 2require_pkmade a no-opon_update on a pk-less table: expected error, call succeededon_eventon_event runs before the per-table callbacks: expected event,table, got table,eventfindanswers only the pk (the deleted seam, restored)stdb.tables: find("line") needs #44installskips a name the connection already ownstest_tables.lua:333: attempt to index field '?' (a nil value)The last two are the new coverage: the sixth is the regression the seam deletion closes, and the seventh guards the namespace's totality, which replaced the reserved list.
Reviewer attention
findis fully index-backed now, with one inherited limit. A uniqueidentitycolumn still does not resolve —cache.findkeys on the decoded value, and a special type decodes to a fresh table per row (#35). Documented in the docstring and the README row, not worked around here.cacheloses itsfile:linethrough the facade.cache.find/iter/countraise at level 2, which lands on the tail call inTable:findand prints no prefix. The message still names the table and the column, so it is self-locating by content; fixing it properly means a level argument on thecacheAPI, which is #44's surface and already merged. Flagging, not fixing.on_event. A reconnect still empties the cache with no delete callbacks; that is stated in the module header and the README rather than worked around, sinceSubscription:on_appliedre-firing is the existing answer.e5f19afd0d962c715a40Review — automated reviewer, posted for the record
Review — PR #45
feat/27-table-api@962c715Verdict: request changes. The re-cut design is right and I would freeze it. But the two things the author was asked to do were not done, the PR body still describes the rejected design, and the re-cut missed every example inside
stdb/tables.lua's own docstrings. All four are mechanical; I verified each fix is safe.Blocking
B1. The branch is not rebased and the #44 seam is still there
So
Table:findtakes theif not cache.findfallback. Against the real generated schema (dev/test/fixtures/chat_schema.lua), that means:findon PR headmessageid{id}idHIT;sender,textraiseprofile{slug}slugThis is the README mismatch the previous reviewer found and died before describing. README:711 promises:
profile.slugcarries a unique constraint and raises.profilehas no pk, so the seam's premise — "the primary key is the one column whose answer is identical either way" — resolves nothing at all for it. On this branch exactly onefindcall in the whole generated schema works:message:find("id", …).Verified fixable: rebase is clean, 6/6 green before and after. Deleting the 19-line seam (
tables.lua:206-224) leavesreturn cache.find(self.conn.state, self.name, column, value)— suite 6/6, stylua clean, andprofile.slug→ HIT, non-unique columns raise withcache.find's own message (stdb.cache: table "profile" has no unique column "realm").findis then fully index-backed.Test gap that hid this: no test calls
findon a non-pk unique column. Every find test usesmessage.id, where pk and unique coincide, so the seam's blind spot was invisible.profile:find("slug", …)is one line and would have failed on this branch.B2.
stdb/tables.luadocstrings still use the rejected flat form — 5 linesThe re-cut renamed every callsite (smoke.script, README, init.lua) but missed the module's own docstrings, which are the API reference:
init/finalexample —db.message:on_insert(self.on_row)/off_insertattempt to index field 'message' (a nil value)for row in db.message:iter() doattempt to index field 'message' (a nil value)db.player:find("identity", me)attempt to index field 'player' (a nil value)That is the exact error the module header (lines 20-22) cites as the bad outcome the namespace exists to prevent. :143/:146 is the block a Defold user copies wholesale.
Also
dev/example/main/smoke.script:341: comment still reads "seen throughdb.<table>".(
tables.lua:273"the shorterdb.<name>" and theclient.db.<table>/ctx.db.<table>peer citations are correct — leave them.)B3.
tables.lua:199'sfindexample contradicts #44's READMEThe only worked
findexample in the module isdb.player:find("identity", me)— precisely the unique-identity lookup main's README (from #44, lines 776-782) documents as not working because of #35. Proven on the rebased tree with aplayertable keyed by a unique identity column:Per the docstring's own contract nil means "a genuine miss", so this is exactly the silence the raise elsewhere in the same file exists to eliminate. Use
find("slug", …)— the lookup the real schema actually supports — or carry #44's caveat.Answering the focus question directly: #45's README neither duplicates nor contradicts #44's #35 note; the README is fine. The contradiction is in the module docstring only.
B4. The PR body describes the rejected design
The body still contains the whole
## The name collisionsection, "A reserved name raises atconnectanyway", "No opt-out", "The reserved list is tested against a real connection object",db.message:code blocks, and a Verification mutation row:a table named "state" is refused: expected error, call succeededThat test no longer exists —
grep 'is refused' dev/test/returns nothing. The Reviewer-attention section still says "cache.findis not merged yet" and "The no-opt-out collision raise is the contentious call". The commit message is correct ("under db.tables"); the body is stale and its Verification table is now false.Non-blocking bug
N1.
err()'s hardcoded level 3 is correct for exactly one of its three call pathstables.lua:62-64claims "Level 3: blames the line that called the facade method, not the method." Measured:on_insert,on_delete,off_insert,off_deleteon_update,off_update(viarequire_pk)tables.lua:163/:171✗errdirectfindCause:
return attach(self, self._insert, fn)is a tail call, so Lua 5.1 discards the frame level 3 counts on. Minimal repro confirms it — breaking the tail call restores the prefix. The likeliest real mistake (a typo'dself.on_rowthat is nil) therefore raises with zero indication of where. Fix: giveerra level argument (4 fromattach/detach/require_pk, 3 fromfind), or routefindthrough a helper too.Focus-by-focus
1. The namespaced surface — right call, structurally total
db.tables[name]works for every name I could construct:my-table,2fast,with space,kebab-case-name,end,nil,true,function— all reachable with full facade methods. Dot access behaves as it must:db.tables.2fastis a syntax error;db.tables.my-tablecompiles (Lua parses itdb.tables.my - table) and fails at runtime with an arithmetic error. Loud either way — but the README's per-table section never shows the bracket form, and a hyphenated table name is not exotic. One line worth adding.No reserved list, no
__indexclosure, no "reserved"/"collision" word left in connection.lua. Connection surface vsorigin/main:Every other raw field and method is identical.
db.stateis the cache with a table namedstate/close/send/identity/tables/pending/on_event/subscribedeclared;db:close()still resolves;db.tables.tablesresolves.The stated reason holds: a reserved list would make connection.lua's private field names frozen public API, and adding a field would then be a breaking change. Namespacing removes the list entirely. Both peer SDKs namespace. Yes — freeze this.
2. Dispatch and reentrancy — sound, three undocumented frozen contracts
The snapshot design is correct. Per-row refill, one call-local buffer, nesting safe. The nested-dispatch probe (callback feeds another frame from inside row 1) gives
A1 A99 B99 B1 A2 B2 A3 B3— the nested batch runs to completion inside A1 and the outer B still fires for every outer row. No corruption.A raising callback truncates delivery silently. Row 2's callback throws:
The cache is complete and
on_eventalready ran with the whole batch. So anon_eventconsumer and a per-table consumer disagree about the same transaction, and nothing records which rows were dropped. No-pcall is the right default and matcheson_event; the header says "the callbacks behind the one that threw do not run" — but does not say the later tables are lost too. Under-documented.db:close()mid-dispatch does not stop dispatch. Closed after row 1; rows 2 and 3 still delivered. Undocumented. Defensible (the batch completes) but a game that closes on a row keeps receiving rows. One sentence.Registering on a different table takes effect within the same batch. A callback registered on
logfrommessage's row 1 received bothlogrows of that same transaction — rows already in the cache before the callback existed. Literally consistent with "takes effect from the next row", but the README's framing reads as "not this batch". One clause.Sibling detach behaves as the snapshot implies and is already covered by the README: B still runs for the current row then never again (A=3, B=1); on a single row, a sibling detached before its turn still runs (X=1, Y=1). Correct — freeze.
3.
on_eventbyte-identical — confirmedCanonical dump of every
on_eventcall and every argument over 6 frames: InitialSubscription across two tables; a transaction mixing one pk-paired update + insert + two deletes across two tables; SubscribeMultiApplied; UnsubscribeMultiApplied; single-query SubscribeApplied; single-query UnsubscribeApplied. Census: 9 inserts, 4 deletes, 1 update, 3 subscribed, 2 unsubscribed, 1 transaction — so it is not passing vacuously.Three streams —
origin/main, PR head with no callbacks, PR head with callbacks on both tables (all three kinds, one reading the cache, one registering another mid-dispatch) — all md5d85a601b9ec0d5a4052d94e50bc11ff5. Byte-identical. The compatibility claim holds.4.
on_updatepk-less raise — correct, and safe to freezeOn the real schema:
message(pk=id) accepts;profile(pk=nil) raises on bothon_updateandoff_update.The regeneration hazard is smaller than it looks, and this is the reason:
require_pkreadst.def.pk, andcache.lua:154pairs updates off the samedef.pk. So the raise cannot disagree with what dispatch can actually deliver — if the schema says no pk, updates genuinely never arrive and the registration genuinely could never fire. It is derived, not heuristic. That makes it acceptable to freeze.Residual worth one doc line: under a stale generated schema the guard inverts into the silence it exists to prevent — schema says no pk while the server has one →
on_updateraises though updates would flow; schema says pk while the server dropped it → the cache pairs on a now-absent column. That is #40's "regenerate after any module change" with a sharper consequence, inherited from #34/#44 rather than introduced here.A table losing its pk server-side turns a game object's
init()into a boot crash. That is the intended loud failure; keep it.5. README
findvs the real schemaSee B1 and B3. Real constraints:
messagepk=idunique={id};profilepk=none unique={slug}plus a composite unique index(realm+handle)thatfindcannot serve — the README's "single-column" wording covers that correctly, no change needed there.6. Cost — dispatch is not the problem
tables.dispatchin isolation, 20k rows, wall clock + deterministic VM instruction count:The "no per-row buffer work when nobody is listening" claim is verified — and the instruction count proves it rather than suggesting it. 261,000 is identical for inserts, updates and mixed at zero callbacks, so execution never reaches the
e.type == "update"branch: it exits atif n > 0every row. 13.05 instr/row with no listeners vs 36.1 with one. Facade lookup +LIST[e.type]+#listcosts ~5 instr/row over the no-facade floor.For scale:
handle_frameof the same 20k-row batch end to end is 180.2 ms, so dispatch with no listeners is 0.48% of it. #30's frame budget is a decode /apply_database_updateproblem; this PR adds nothing meaningful to it. Flagging, not asking for a fix.7. Conformance and the guard — both confirmed
Rebased tree, own server (
STDB_PORT=3800,STDB_STATE_DIR=/tmp/stdb-45review): PASS both runs, first try. No #39 flake.Fixture check:
dev-server.shregeneratedchat_schema.luaand only the port in the header comment differed — the fixture is current, not stale.The end-to-end guard is real (this is the part nobody had confirmed).
tables.dispatch→ no-op:And the subtler deregistration assertion is also real —
detach→ no-op:Did the re-cut shrink the diff and change nothing else?
Recovered the pre-re-cut head
e5f19af(same base6efcfdd) from the previous reviewer's clone, so this is a direct comparison.stdb/tables.luastdb/connection.luaREADME.mddev/test/test_tables.luastdb/init.luadev/example/main/smoke.scriptNet shrink, as promised. The code-only delta is exactly what was claimed and nothing more:
M.reserved(30 entries) deleted; the collisionerror(...)deleted;c._tables→c.tables. Every othertables.luadelta line is a comment.connection.lua: the__index = function(_, k) return M[k] or facades[k] endclosure and the secondsetmetatabledeleted, replaced by onec.tables = tables.install(c). This also drops a redundant double metatable set and restores__indexto a table — one fewer closure call per method miss.db.tables.X"; the reserved-list drift loop (for k in pairs(probe) ... tables.reserved[k]) removed, correctly — there is no list left to drift. Addeddb.tables.tables resolves,db.state is the cache even with a table named state,and the facade reads that same cache,db:close() still resolves.smoke.scriptdelta is a puredb.X→db.tables.Xrename (6 lines).The README (+8) and test (+12) growth is the namespace argument and the new totality cases — justified, not scope creep. Nothing unrelated moved.
Summary
The design is right: namespaced accessors, snapshot-per-row dispatch,
on_eventuntouched and provably byte-identical,on_updategated on the samedef.pkthe cache pairs on. The re-cut did what it said. Blocking work is B1 (rebase + delete the 19-line seam), B2 (5 flat-form docstring lines + 1 smoke comment), B3 (the identityfindexample), B4 (rewrite the body) — all mechanical, and I verified the rebase is clean, the seam deletion is safe, and the suite plus conformance stay green through both. Fold in N1 and the three focus-2 doc gaps while the file is open, and addprofile:find("slug", …)as the test that would have caught B1.Verdict: REQUEST CHANGES — design is right and I would freeze it; 4 blocking items, all mechanical, all verified fixable
962c715a40462d96bfe3462d96bfe353afd276eb