protocol: convert special types explicitly #37
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!37
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/23-special-types"
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 #23.
Six explicit conversion accessors in
stdb/protocol.lua, besideunwrapanddecode_payloadwhere the rest of the wire-encoding knowledge lives:protocol.timestamp_to_seconds(v)os.date/os.timeprotocol.duration_to_seconds(v)protocol.schedule_at_to_seconds(v)"interval"or"time", plus the converted valueprotocol.connection_id_to_hex(v)0x+ 32 lowercase hex digits, exactprotocol.identity_to_hex(v)0x…hex stringprotocol.uuid_to_string(v)01020304-0506-0708-090a-0b0c0d0e0f10Every name states its output unit, because the unit is the point of #23.
The bug this closes
Timestampis microseconds. Every other timestamp a Defold developer touches is seconds (socket.gettime,os.time), soos.date("%H:%M", row.created_at)does not error — it produces a date about 56 million years out (os.date("!%Y-%m-%d", 1787158263465067)→56634760-11-18). Same trap forTimeDuration.Two shapes, not one
A special-type column is a one-field product, and it arrives in two shapes depending on the row that carries it:
A named row serializes from the row layout, so the field name survives (
serialize_named_product). A positional row serializes an untypedProductValue—TransactionUpdate'sinserts: Arc<[ProductValue]>— andAlgebraicValue::Productforwards to the seq product serializer recursively, so the wrapper name is gone at every depth. Both observed live on 2.8.2, same table, same column.Accepting only the named form meant
timestamp_to_seconds(row.created_at)worked on rows fromInitialSubscriptionand returnednilon rows fromTransactionUpdate— theon_eventpath a game spends its life in, and exactly the path #23 exists to fix. Per #40's capture it is also every view row, subscription readbacks included.The fix is one guard in
scalar, the choke point all six accessors route through, and the arity check is load-bearing rather than defensive dressing:serialize_variant's positional branch isserialize_tuple(2), so a sum is always exactly[tag, payload]and can never be mistaken for a one-field column. Without#v == 1,Option<Timestamp>None[1,{}]reads the variant tag as microseconds and returns1e-06, andSome[0,[n]]returns0— silent wrong numbers, the exact class this PR exists to prevent. Tested both ways: the positional column resolves, every Option shape givesnil.connection_id_to_hexis exact — all 32 digitsThe server sends
ConnectionIdas a bare 128-bit JSON number, sojson.decoderounds it to the nearest double and only 14 of 32 hex digits survive. Captured live from 2.8.2:Padding produced a string of precisely the canonical width with a fabricated tail — the shape you paste into
spacetime, key a table on, or write to a save file. Two ids differing by 1 on the wire rendered identically. And the retained digits are not reliably right either: rounding is to nearest, so the committed fixture's id renders as…bef508under the old path where the truth is…bef5077b.This needs no BSATN codec and no big-integer JSON decoder, which is what makes it fixable rather than a documented limit: the digits are in the raw frame text.
decode_serveranddecode_rowquote u128 fields beforejson.decodeever runs, and the accessor converts the exact decimal by repeated division by 16 over a digit array (every intermediate stays under 160, so it is exact in a double at any width).Verified against four ConnectionIds captured live this round plus the committed fixture, each cross-checked against Node
BigInt:1359361285996313722332892319907697131960x66445897597a38fb947e3b5c1d06002c1671705335074208968735588865721991498040x7dc3dfa6391cdb0016558ed835ae80ec1645340920793690198451963191577646410200x7bc81d0d1444b98bc7a4ae86911e50fc104143716647716457400417217618755155470x07d5bc2a8a5cb45acd01357ed52e589b304429789785738994598068895799762753346(fixture)0xe5070b78bef5077ba9abfbd79e381742That is byte-for-byte the server's own output:
to_hexishex::encode(as_be_byte_array())=u128::to_be_bytes, i.e.%032xof the decimal, so there is no byte-swap subtlety.What it will not do is guess.
nilfor a number already past 2^53 (its low bits are gone and no downstream arithmetic returns them),nilpast 2^128 rather than silently widening to 33 digits, andnilfor a u128 column on a positional row, which carries no field name for the rescue to match. The old string branch is gone with it: a ConnectionId is never hex on this protocol (connection_id.rs:13— bare decimal, always), and returning"hello"unchanged from a function contracted to return 32 hex digits was the failure mode most likely to be mistaken for success.Breaking change, taken deliberately before the freeze:
connection.lua'sc.connection_idis now canonical hex rather than a rounded number. Exact, and the same shapec.identityalready has, so the two credentials read alike.Decisions worth a reviewer's opinion
nilon bad input, never raise — with the real justification. The earlier "anOptioncolumn legitimately decodes tonil" was false: sums in rows are positional, so anOptioncolumn is the table[0, v]or[1, {}]. The actual reason is the splitprotocol.luaalready keeps: frame-level functions (decode_server,decode_row,encode_client) raise, because a malformed frame is a protocol violation that should kill the connection; value-level functions (unwrap,decode_payload) are total. These six are value-level, so they matchunwrap, which they sit beside. Callers check fornil. AnOption<Timestamp>column needs its own unwrap step first — whichunwrapcannot yet do for the positional sum form.schedule_at→schedule_at_to_seconds,connection_id→connection_id_to_hex. Once frozen, consistency beats any individual name.ConnectionId/Uuidtake exact decimal digits, or a number within 2^53 — not hex, which never occurs.Identitytakes a hex string, because that is its wire form. Nothing passes through unvalidated.identity_to_hexadded so the family is complete, and sounwrapstays a generic wrapper-stripper rather than doubling as the Identity accessor. It also closes the two-shapes gap for Identity, whereunwrapreturned a bare table on a positional row.unwrapitself is deliberately not extended: it is type-blind and cannot know whether a bare number is micros or a ConnectionId.Uuidincluded rather than deferred. #23 names it,gen-schema.shalready tags__uuid__columns, and it is the same u128 with the same precision decision being made once, here. Output is the canonical hyphenated form, matchingUuid'sDisplay.schedule_at_to_secondshandles both sum encodings. Ordinals follow variant declaration order —0= Interval,1= Time, read offcrates/lib/src/scheduler.rs:59-63. The positional form is what rows carry; the named{ Interval = … }form is unreachable on this protocol today and is kept, with a comment saying so, for schema JSON and message envelopes.variant()returns a singlenil, not("interval", nil). "Could not interpret that" is then uniform across the family, soif kind then use(secs) endis always safe. The two-shapes fix removed the only shape that triggered the alternative in practice.decode_row. That needs #25's column types and changes the type of row field values, which is frozen at 1.0 — tracked in #35. Explicit accessors only.Precision
A current-era microsecond value (~1.787e15) is exactly representable — a double holds integers to 2^53 ≈ 9.007e15. Only the
/1e6is inexact, and whole-second resolution survives. The ceiling is 2^53 micros = 2255-06-05, computed and asserted in a test so the doc comment cannot rot.string.format("%x", n)returns"0"for a 128-bit value in Lua 5.1, which is why the hex is hand-rolled. Note the new implementation never runs that arithmetic on an out-of-range double at all — it divides a decimal digit array, so themath.fmodnegative-remainder hazard and the%xtrap are structurally gone rather than guarded. (A previous revision of this description claimed a test pinned the%xresult directly. It did not — the pin was indirect, via the expected hex string. Now moot.)Discoverability
#23's point was that a helper nobody finds does not prevent the bug. The table is now reachable from where a reader actually stands:
### stdb.cache, the section on reading rows, points down to it;## Usageexample says so inline, where a timestamp column would go.The table itself is corrected: it previously claimed the decoded row value is the named wrapper, which is true only for named rows.
Verification
5 passed, 0 failed; stylua clean; native conformance PASS on the first run, both phases including identity persistence across a process restart.Both blockers reproduced before the change and re-checked after:
timestamp_to_secondson aTransactionUpdatecolumnnil1787158263.4651Option<Timestamp>None1e-06nilOption<Timestamp>Some0nil0x66445897597a380000000000000000000x66445897597a38fb947e3b5c1d06002c…06002c/…06002dconnection_id("hello")"hello"nilconnection_id(2^128)nilFalsifiability by nine independent sabotages, each restored afterwards:
#v == 1arity guardOption<Timestamp> None is not a Timestamp: expected nil, got 1e-06positional Timestamp column: expected 1787157191.2252, got nildecode_serverconnection_id survives json.decode as digit text: expected string, got numberdecode_rowConnectionId column on a named row is exact: … got nilhex string is not a wire form: expected nil, got 0x0000…2^128 does not fit 32 digits: … got 0x1000…(33 digits)string.format("%x")on the whole valuelow digits are real, not padding: expected true, got falseordinal 0 is Interval: expected interval, got time/1e6→/1e3timestamp wrapper to seconds: expected 1787157191.2252, got 1787157191225.2The fixture assertion is deliberately not a pinned hex literal: the server mints a fresh ConnectionId per socket, so a literal goes stale on every recapture for no behavioural reason. It asserts instead that the digits reach Lua as text, that the capture is genuinely past 2^53, and that the hex does not end in the 14 zeros the padding path produced — which is what the
%xsabotage above trips. The exact-value proofs live intest_protocol.luaon frozen captures no recapture can invalidate.Deliberately not done
No wrapper objects. No seconds→micros encode direction — nothing in the SDK sends a
Timestampyet, so the encode shape would be a guess.unwrapstill cannot strip a positionalOption; that is pre-existing and belongs with #26's schema-driven decoder.Timestamp and TimeDuration are microseconds on the wire; every clock a Defold game already touches is seconds. `os.date("%H:%M", row.created_at)` on a raw Timestamp does not error, it prints a date ~50,000 years out. That is the silent-wrong-answer class this SDK has been careful about elsewhere (#8's dropped header, #17's silent new identity), and unwrap made it worse by removing the wrapper name that hinted at the unit. Four pure helpers beside unwrap, where the rest of the wire-encoding knowledge lives: timestamp_to_seconds, duration_to_seconds, schedule_at and connection_id. Each takes the wrapper table or an already-unwrapped bare value, so running unwrap first is not punished, and each returns nil rather than raising for nil or a wrong shape: an Option column legitimately decodes to nil, raising would force a pcall at every call site, and a nil errors loudly at the point of misuse instead of producing a plausible wrong number. schedule_at accepts both JSON sum encodings. The named { Interval = v } / { Time = v } form is what a hand-written fixture looks like; the wire uses the positional ordinal form [0, v] / [1, v], and ordinals follow variant declaration order, so 0 is Interval and 1 is Time. connection_id needs hand-rolled hex. The server sends ConnectionId as a bare 128-bit JSON *number*, so it is already rounded to a double before the SDK sees it, and string.format("%x", n) on a value that large returns "0" outright — silently, which is the whole problem. The manual digits are the exact hex of the parsed double: high bits real, low bits zero. A test pins that, so the "%x" trap cannot be reintroduced as a simplification. No automatic per-column coercion in decode_row. That needs column types the generated schema does not carry (#25) and would change decoded values under existing consumers, which is breaking; tracked in #35. The 2^53 exact-integer ceiling is documented in both protocol.lua and the README, with a test asserting the date it falls on (2255-06-05) so the comment cannot rot. Current-era values are held exactly; only the division to seconds is inexact, and whole-second resolution survives it.Review — automated reviewer, posted for the record
Review — PR #37
feat/23-special-typesAll numeric claims checked by running them in the pinned Lua 5.1 from
nix develop .#ci. Wire-shape claims checked against the pinned SpacetimeDB v2.8.2 source (/tmp/librarian-stdb,git describe=v2.8.2) and against the live server at127.0.0.1:3000. Baseline:5 passed, 0 failed, stylua clean.Verdict: request changes. The arithmetic in this PR is correct — I could not break it. What is wrong is one shape assumption and one honesty problem, and both get frozen at 1.0.
BLOCKER 1 —
timestamp_to_secondsreturnsnilfor exactly the bug #23 exists to fixstdb/protocol.lua:141-146(scalar)A special-type column arrives in two shapes depending on which frame carried the row, and the helpers accept only one of them.
InitialSubscription,Subscribe*Applied) serialize rows from aRowRef, which carries the row layout → typed →serialize_named_product→{"__timestamp_micros_since_unix_epoch__": n}.crates/table/src/table.rs:2034-2041→crates/sats/src/ser/impls.rs:226-228TransactionUpdateserializes rows frominserts: Arc<[ProductValue]>— an untyped value. Field names are dropped, andAlgebraicValue::Product(prod) => prod.serialize(ser)means they are dropped recursively, so the one-fieldTimestampproduct becomes the array[micros].crates/core/src/host/module_host.rs:127-128,159-160→crates/sats/src/ser/impls.rs:129-131,152-156Both shapes observed live on 2.8.2, same table, same column:
Run end to end through the SDK (
decode_row+ the new helper):So
protocol.timestamp_to_seconds(row.created_at)works on rows from the initial subscription and returnsnilon rows from live updates — theon_insert/on_eventpath a game actually spends its life in. That is the worst available failure mode for a helper whose whole purpose is a silent-wrong-answer bug: the helper silently declines to work on half the inputs, in a way unit tests built from hand-written wrappers cannot see.This is not an unknown-unknown for this codebase.
protocol.lua:262-264already says decode_row "Handles both encodings the server uses: positional arrays (TransactionUpdate) and named objects (InitialSubscription)." The dual encoding is known at the row level and simply was not carried down to the column value level.Fix — one guard in
scalar, the shared choke point all four helpers route through:The
#v == 1guard is load-bearing, not defensive dressing. I tried the naivex = v[1]first and it produces silent wrong numbers on Option columns, which is the exact class #23 is about:Guarded version, verified:
Applied to a scratch clone:
5 passed, 0 failed, stylua clean. Fully backwards compatible.The README table needs the same correction — it currently states the decoded row value is
{ __timestamp_micros_since_unix_epoch__ = n }, which is true only for subscription rows.BLOCKER 2 —
connection_idpads lossy data into a precise-looking identifier, and the lossiness is avoidablestdb/protocol.lua:220-234First, the author's own claims are all true. I verified each:
string.format("%x", 36062078055823991097399387891464995893)→"0". Confirmed. (%don it gives-9223372036854775808, also garbage.) The hand-rolled loop is genuinely necessary.0x1b214db8a0d796 * 2^72 == <parsed value>→ true.0x1b214db8a0d796is exactly 53 significant bits, as it must be.BigInt(...).toString(16): 0 mismatches.math.fmod/math.floorby 16 on a double are exact, as claimed.math.fmod(-17, 16)is indeed-1in this Lua, so the negative-remainder hazard is real — and it is correctly closed by thev < 0guard on line 228.-1,-0.5,-inf, and NaN all returnnil. The guard is load-bearing and there is a test for it. Good.The problem is not the arithmetic. It is that the arithmetic is applied to already-corrupted input and the result is then padded to 32 digits, which is precisely the canonical width of an exact ConnectionId.
Captured live from 2.8.2 this session:
14 of 32 digits are real. Same result on the fixture value (
0x1b214db8a0d79617a81f1948ffa10435true,0x1b214db8a0d796000000000000000000emitted). The true hex is%032xof the u128 —to_hex=hex::encode(as_be_byte_array())=u128::to_be_bytes(crates/lib/src/connection_id.rs:104-119), so there is no byte-swap subtlety; the emitted string is directly comparable to the server's, and it is 56% right.So: not honest. The value is shaped exactly like the thing you paste into
spacetime, key a table on, write to a save file, or send to a support channel.0x…000000000000000000does not read as "unknown low bits"; it reads as a ConnectionId that happens to end in zeros. Even the server's own abbreviated form (to_abbreviated_hex, first 16 hex chars) would be wrong in its last two digits.The docstring's mitigations don't hold up either:
…d796emits…d797.json.decodeon…302976and…302977.crates/lib/src/connection_id.rs:13-16: "In JSON, aConnectionIdis represented as a BARE DECIMAL number. This requires some care when deserializing", linking the JS BigInt question).decode_serverholds the raw frame text, so the digits are right there beforejson.decodeever sees them. One gsub plus ~14 lines of Lua 5.1 recovers the value exactly:Verified: recovers
0x66445897597a38fb947e3b5c1d06002cfrom the live decimal,0x1b214db8a0d79617a81f1948ffa10435from the fixture, and agrees with NodeBigInton every value tried.42→0x…002a,0→ all zeros.Recommendation, in preference order:
decode_serverand makeconnection_idconvert the decimal string exactly. This is the only option that makes a frozen function namedconnection_idreturn an actual ConnectionId. Note it changesconnection.lua:224(c.connection_id = protocol.unwrap(...)) from a lossy double to an exact decimal string, andtest_connection.lua:51,221,229assert on the number. Pre-1.0 is the moment to take that break; after the freeze you are stuck shipping a wrong identifier.niland document that exact ConnectionIds need BSATN. A short honest string is safer than a long false one.Either way, the current combination — precise width, fabricated content, caveat buried in a docstring — is the one option that will silently produce a wrong ConnectionId in someone's save file.
SHOULD-FIX 3 — the string branch is dead for wire data and actively wrong for decimal strings
stdb/protocol.lua:222-224A ConnectionId never arrives as a hex string on this protocol.
crates/lib/src/connection_id.rs:13is explicit: in JSON it is a bare decimal number, always — in theIdentityTokenenvelope and in row columns alike (both go through the sameSerializeimpl; only named-vs-positional differs). Soconnection_id("0xc200"), the tested case, is unreachable.Worse, the branch that exists returns non-hex from a function whose contract is
"0x" + 32 hex digits:A decimal string is the one string form that does occur — it is what you get from the gsub in Blocker 2, or from anyone who read the fixture. Passing it through unchanged is the failure mode most likely to be mistaken for success.
Make the string branch the exact path (Blocker 2 fix), or validate it (
^0x%x+$, correct length) and returnnilotherwise. Do not freeze a pass-through.SHOULD-FIX 4 — the
nil-not-raise decision is right, but its stated justification is falseOn the question as posed:
protocol.luais not of two minds. The split is clean and the new helpers land on the correct side of it. Frame-level functions raise because a malformed frame is a protocol violation that should kill the connection:decode_server(242-256),decode_row(269-278),encode_client(199-215), all viaerr()at65-67. Value-level functions are total and never raise:unwrap(88-111) returns its input unchanged for anything it doesn't recognise, anddecode_payloadreturnsnil, err. The four new helpers are value-level, so returningnilmatchesunwrap, the function they sit next to and are documented beside. Keep it.The justification is what's wrong, and it is frozen into the README (
441-444) and the code comment (135-139):It does not. Sums in rows are always positional
[tag, payload]— even on the named row path, becauseserialize_row_from_pageworks from the row layout, which carries variant tags but not variant names, soserialize_variantgetsname: Noneand falls to the tuple branch (crates/sats/src/ser/serde.rs:104-124). Observed live and in the SQL endpoint:So an Option
Nonecolumn decodes to the table[1,{}], never tonil:Two consequences. First,
unwrap'ssome/nonebranches (104-109) are dead for row columns — pre-existing, out of scope, but it is the same root cause as Blocker 1 and the header comment at19("Options encode as{"some": v}/{"none": []}") is wrong for rows. Second,nilinputs do occur, just for different reasons:decode_rowon a positional row shorter thancolumns(286-288), a missing key in a named row,decode_payload(nil).Frozen contract to state, in these words: the four helpers are total value-level accessors — they return
nilfor any input they cannot interpret and never raise, matchingunwrap; frame-level functions raise. Callers check fornil. Then delete the Option rationale and replace it with the real one (total accessors alongsideunwrap), and note that anOption<Timestamp>column needs its own unwrap step first, whichunwrapcannot currently do for the positional sum form.SHOULD-FIX 5 — the frozen family is inconsistent four ways
On the specific questions asked:
Ordinal mapping is correct. Verified against the pinned 2.8.2 source, not the PR's assertion:
crates/lib/src/scheduler.rs:59-63builds the type as[(SCHEDULE_AT_INTERVAL_TAG, time_duration()), (SCHEDULE_AT_TIME_TAG, timestamp())], tags"Interval"/"Time"fromcrates/sats/src/sum_type.rs:9-11. 0 = Interval, 1 = Time. Correct.Both encodings are genuinely real, which vindicates handling both — though not for the stated reason. Named sums appear in message envelopes and metadata (
{"Committed":…},{"Product":…},{"some":"id"}); positional[tag, payload]appears in row values. SinceScheduleAtonly ever occurs as a table column, the named{Interval=…}branch is unreachable on this protocol today. Harmless, and it would fire if the SDK ever reads schema JSON. Worth a comment saying so rather than implying the named form is what rows carry.Edge cases behave. All verified:
[2,…]→nil;{}→nil;{Interval="soon"}→nil;{[1]=0}with no payload →nil;{[1]="0",…}→nil. A named check never misfires on a positional payload — a positional{0, {…}}has no top-level.Interval, and{[1]=1,[2]={Interval=…}}correctly yieldsnil. Both keys present returnsinterval(first-declared wins); unreachable on the wire, worth one line of doc.What I would change before freezing:
timestamp_to_seconds/duration_to_secondsannounce their units;schedule_at/connection_idannounce nothing. The unit is the point of #23, so make all four say it:timestamp_to_seconds,duration_to_seconds,schedule_at_to_seconds,connection_id_to_hex. Consistency matters more than any single name once frozen, andtimestamp_to_secondsis the right instinct — do not shorten it totimestamp.connection_idreturns it. Pick one rule.Identityis the odd sibling. The README table routesIdentity→protocol.unwrap(v)andConnectionId→protocol.connection_id(v). The server treats them as siblings (both special types, both rendered hex byto_hex). Addprotocol.identity(v)so the family is complete andunwrapstays a generic wrapper-stripper rather than doubling as the Identity accessor. This also fixes the Blocker-1 gap for Identity, whereunwrapreturns a bare table on TransactionUpdate rows.unwrapinstead.unwrapis type-blind by design — it cannot know whether a bare number is micros or a ConnectionId, and the positional row shape makes that strictly worse. A parallel family is the right call; it just needs to be a complete, consistently named one.Uuidis missing and unmentioned. #23 names it explicitly ("Timestamp, TimeDuration, ConnectionId, ScheduleAt or Uuid"). It exists in 2.8.2 asUuid { __uuid__: u128 }(crates/sats/src/uuid.rs:26-27,AlgebraicType::uuid()) — the same u128 shape as ConnectionId, so the same >2^53 precision problem, and a canonical hyphenated text form. Either adduuidnow while the precision decision is being made once, or say in the PR why it is out of scope. Silently dropping one of five named types is the kind of gap a freeze makes permanent.Numeric claims I verified and found correct
Nothing here needs changing; recording it so it isn't re-litigated.
timestamp_to_seconds(1787157191225200)→ the right UTC instant2026-08-19T16:33:11UTC, and1787157191225200is exactly representable2255-06-052255-06-05 23:47:34UTC/1e6__timestamp_micros_since_unix_epoch__asI64, so pre-1970 is legitimate.-1e15µs →1938-04-24;duration_to_seconds(-1455)→-0.001455.math.fmodnegative-remainder hazardmath.fmod(-17,16) == -1), and correctly closed by thev < 0guard before the hex loop ever runsstring.format("%x")returns"0"BigInt, 0 mismatches0x1b214db8a0d796 * 2^72== parsed valueNits
~50,000 years outis wrong by three orders of magnitude —protocol.lua:118,README.md:430, PR body, inherited from #23. Measured:os.date("!%Y-%m-%d", 1787158263465067)→56634760-11-18, about 56.6 million years. It does not error, as claimed. Amusingly, ~50,000 years is the error you'd get if the value were milliseconds — right number, wrong unit, in the document whose entire subject is unit confusion. Say~56 million years.protocol.lua:233.string.rep("0", 32 - #digits)with#digits > 32returns""in Lua 5.1, so output silently grows:connection_id(2^128)→0x1000…000, 33 digits. Reachable, barely — a u128 within 2^75 of the top rounds up to 2^128 (p ≈ 2^-53). Returnnilwhen#digits > 32.variant()throws away a tag it knows —protocol.lua:172-177. When the payload fails to convert,schedule_atreturns a singlenil, discarding the variant it had already identified with certainty. Returning"interval", nilwould let a caller distinguish "not a ScheduleAt" from "an Interval I could not convert". Blocker 1's fix removes the common trigger; still worth deciding before the return shape freezes.%xresult so the shortcut cannot be reintroduced." No test asserts onstring.format("%x", …)directly; the pin is indirect, via the expected hex string. It does work (the sabotage table shows it), but the claim as written describes a test that isn't there.Does it prevent the bug it exists for?
Not yet, on both counts.
Mechanically, no — Blocker 1. On the live-update path
timestamp_to_seconds(row.created_at)returnsnil. That is better than a date 56 million years out (anilintoos.dateraises at the call site, which is the failure mode the PR argues for), but a helper that works on subscription rows and not on update rows will read as flaky rather than as a units lesson.Discoverability: placed correctly, signposted nowhere. #23 asked for the table "in the API reference next to the cache section." It is at
README.md:426, inside### stdb.schema and stdb.protocol(413), one subsection after### stdb.cache(399). Letter satisfied. But the two places a reader actually stands when they touch a timestamp column both stay silent:### stdb.cache(399-411) is the section on reading rows. It documentscache.get/cache.iterand says nothing about units, with no pointer 15 lines down.## Usageexample (167-187) printse.row.senderande.row.text— both strings, no trap. Someone who addscreated_atgets no signal at all.### Two Defold-specific traps(33-45) is the natural home for a trap of exactly this class — "found by running the real thing, silently wrong rather than loud" — and this isn't in it.The table itself is good: correct units, both ceilings explained, the
os.datetrap named. It just needs to be reachable from where the reader is. Cheapest fix, and I'd call it required given #23 says outright that "a helper nobody knows about does not prevent theos.datebug":### stdb.cache: row values keep wire units —Timestamp/TimeDurationare microseconds; see Special types and their units.### Two Defold-specific traps(it becomes three), linking down to the table.Does it close #23?
No, not in substance. Against #23's acceptance list:
MICROS, theos.dateassertion, and the round-trip back to microsScheduleAtvariants testedintbuild" worry as the 2^53 double ceiling — Lua 5.1's number type is a double on Defold's targets regardless of pointer width, so #23's premise was itself mistaken. The literal "on a 32-bit Lua build" cannot be tested here; the 2^53 assertion is the correct substitute.protocol.connection_id(v)→ hex string "matchingunwrap's treatment of identity"unwrap's Identity treatment is exact where this is notos.date(row.created_at)being silently wrongTransactionUpdatepath, where the helper returnsnilUuid, named in #23With Blocker 1 fixed (a nine-line guard, already validated: 5 passed, stylua clean), Blocker 2 resolved either way, and the cross-references added, this closes #23 properly. The shape of the change is right — pure functions in
protocol.lua, no automatic coercion indecode_row, explicit units in the names, all correctly argued. It is the wire-shape assumption and the ConnectionId honesty that need to be settled before the surface freezes.No edits, comments, or pushes were made to the PR. Verified in a throwaway clone at
/tmp/review-37plus a scratch copy at/tmp/fixcheckfor the proposed fix. On the live server I only read: HTTPGET/SQL SELECTand short-lived WebSocket subscriptions. No databases created or modified.Verdict: Request changes. Two blockers. The arithmetic is right; the shape assumptions and the ConnectionId honesty are not. Does not close #23 in substance.
27234934a25a460435b45a460435b4ac32a1ae3fac32a1ae3ffb1be0b206