codegen: keep the module's type information #36

Merged
yuris merged 1 commit from feat/25-codegen-types into main 2026-08-19 17:02:30 +00:00
Owner

Closes #25. Foundation for #21, #23, #26, #27, #28 and eventually #9 — none of which can work without column types.

gen-schema.sh fetched the module's complete type information and kept only the column names. algebraic_type sat next to every name and went in the bin, along with unique constraints, indexes, and reducer parameter types.

What is emitted now

Per table: columns and pk as before, plus types (parallel array of tag strings), unique, and indexes. Per reducer: parameter types alongside params. Per procedure: parameters and returns.

Type tags are flat lowercase strings — cheap for a table-driven decoder, and the shape a BSATN codec would need later:

  • scalars bool, i8..i256, u8..u256, f32, f64, string
  • opt_<inner>, vec_<inner>
  • special types detected structurally, never by column name: identity, connection_id, timestamp, time_duration, uuid, schedule_at
  • sum_<Name> / product_<Name> for named compound types
  • unknown for anything unrecognised, never a guessed tag

The grammar was verified against a live server, not reasoned about

A throwaway probe module was published covering all 16 scalars, Option, Option inside an array, all five magic products, ScheduleAt, a named enum and a named struct. Generated output:

u64 string bool i8 u16 i128 u256 f32 f64 opt_string vec_u32 vec_opt_string
identity connection_id timestamp time_duration uuid schedule_at
sum_Status product_Point vec_u8 opt_identity

Zero unknown. The probe db was deleted afterwards. This also settled two shapes the Rust source left ambiguous: Array serializes transparently ({"Array":{"U32":[]}}) and Ref as a bare number ({"Ref":1}).

Two things worth reviewer attention

No compound primary key exists. Publishing two .primaryKey() columns is rejected by the server ("table profile has multiple primary key annotations"). So the new profile table uses a compound unique constraint plus the btree the server demands to back it. Consequence: pk is now emitted only when primary_key has exactly one column — verified with a synthetic payload that a 2-column primary_key emits no pk rather than silently naming the first. Compound uniqueness surfaces as unique = true on the backing index, since unique holds plain single-column names.

Procedures are now generated, contradicting a comment this repo has carried for a while. The old header claimed the schema endpoint does not report procedures. It does — they are under misc_exports[].Procedure. That claim is now corrected.

Verification

5 passed, 0 failed; stylua clean. 14 new assertions in test_fixtures.lua on type tags, profile's compound-index/unique/no-pk metadata, reducer param types including opt_string/vec_string, and procedure param + return tags. chat_schema.lua was regenerated from a real publish, not hand-written.

Falsifiability: restoring the pre-change fixture makes the suite fail, so the new assertions genuinely depend on the new metadata.

Deliberately not done

session.jsonl was not re-captured — frame-count and frame-tag assertions depend on it and there is no protocol change here. message is byte-identical, so both conformance harnesses and the smoke client are unaffected. Conformance not run. The column-order paragraph in the README is left alone — that is #32's job on another branch.

Closes #25. Foundation for #21, #23, #26, #27, #28 and eventually #9 — none of which can work without column types. `gen-schema.sh` fetched the module's complete type information and kept only the column *names*. `algebraic_type` sat next to every name and went in the bin, along with unique constraints, indexes, and reducer parameter types. ## What is emitted now Per table: `columns` and `pk` as before, plus `types` (parallel array of tag strings), `unique`, and `indexes`. Per reducer: parameter `types` alongside `params`. Per procedure: parameters **and** `returns`. Type tags are flat lowercase strings — cheap for a table-driven decoder, and the shape a BSATN codec would need later: - scalars `bool`, `i8`..`i256`, `u8`..`u256`, `f32`, `f64`, `string` - `opt_<inner>`, `vec_<inner>` - special types detected **structurally**, never by column name: `identity`, `connection_id`, `timestamp`, `time_duration`, `uuid`, `schedule_at` - `sum_<Name>` / `product_<Name>` for named compound types - `unknown` for anything unrecognised, never a guessed tag ## The grammar was verified against a live server, not reasoned about A throwaway `probe` module was published covering all 16 scalars, `Option`, `Option` inside an array, all five magic products, `ScheduleAt`, a named enum and a named struct. Generated output: ``` u64 string bool i8 u16 i128 u256 f32 f64 opt_string vec_u32 vec_opt_string identity connection_id timestamp time_duration uuid schedule_at sum_Status product_Point vec_u8 opt_identity ``` Zero `unknown`. The probe db was deleted afterwards. This also settled two shapes the Rust source left ambiguous: `Array` serializes transparently (`{"Array":{"U32":[]}}`) and `Ref` as a bare number (`{"Ref":1}`). ## Two things worth reviewer attention **No compound primary key exists.** Publishing two `.primaryKey()` columns is rejected by the server (*"table `profile` has multiple primary key annotations"*). So the new `profile` table uses a compound **unique** constraint plus the btree the server demands to back it. Consequence: `pk` is now emitted only when `primary_key` has exactly one column — verified with a synthetic payload that a 2-column `primary_key` emits no `pk` rather than silently naming the first. Compound uniqueness surfaces as `unique = true` on the backing index, since `unique` holds plain single-column names. **Procedures are now generated, contradicting a comment this repo has carried for a while.** The old header claimed the schema endpoint does not report procedures. It does — they are under `misc_exports[].Procedure`. That claim is now corrected. ## Verification `5 passed, 0 failed`; stylua clean. 14 new assertions in `test_fixtures.lua` on type tags, `profile`'s compound-index/unique/no-pk metadata, reducer param types including `opt_string`/`vec_string`, and procedure param + return tags. `chat_schema.lua` was regenerated from a real publish, not hand-written. Falsifiability: restoring the pre-change fixture makes the suite fail, so the new assertions genuinely depend on the new metadata. ## Deliberately not done `session.jsonl` was **not** re-captured — frame-count and frame-tag assertions depend on it and there is no protocol change here. `message` is byte-identical, so both conformance harnesses and the smoke client are unaffected. Conformance not run. The column-order paragraph in the README is left alone — that is #32's job on another branch.
codegen: keep the module's type information (#25)
All checks were successful
ci / ci (pull_request) Successful in 1m21s
78642a103d
gen-schema.sh fetched the whole module schema and threw away everything but
column names, the first primary-key column and reducer parameter names. The
type sat right beside every name it kept — {"name":{"some":"id"},
"algebraic_type":{"U64":[]}} — and special-type conversion, unique-index
lookups, typed facades and any future BSATN codec all need it. A wire codec is
impossible without column types, so this is the piece the rest of 1.0 is
waiting on.

Each table now carries `types` (one tag per column, in column order), `unique`
and `indexes`; each reducer carries its parameter `types`. Procedures are
emitted too, with parameter types and the type of what they return: they were
never absent from this endpoint, they live under misc_exports[].Procedure
rather than a `procedures` key, which is what the old header comment mistook
for absence.

Tags are flat lowercase strings — `u64`, `opt_string`, `vec_u8`, `identity`,
`sum_Status` — not nested tables, because the consumer is a table-driven
decoder: one string lookup per column, and the same tags will drive BSATN.
Special types are detected STRUCTURALLY, by the magic field names sats puts
inside them (`__identity__`, `__timestamp_micros_since_unix_epoch__` and
friends), so a column *named* timestamp that holds a u64 is tagged u64 and a
column named x that holds a Timestamp is tagged timestamp. Nothing gets a
guessed tag; anything unrecognised comes out as `unknown`.

Verified against a live 2.8.2 server rather than reasoned about: a probe module
covering all sixteen scalars, Option, an Option inside an array, all five magic
products, ScheduleAt, a named enum and a named struct generates zero `unknown`.
It also settled two shapes the source alone left ambiguous — Array serializes
transparently ({"Array":{"U32":[]}}) and Ref as a bare number.

The new `profile` table gives the generated fixture metadata `message` cannot
carry. Its key is a compound UNIQUE constraint rather than a compound primary
key because the server rejects a module that annotates two ("table `x` has
multiple primary key annotations"), so a compound pk is unreachable from a
module today. The server also refuses a unique constraint no index supports,
which is why compound uniqueness survives as `unique = true` on the backing
index — the only place it can. For the same reason `pk` is emitted only for a
single-column primary key: should the server ever allow two, this emits no `pk`
rather than silently naming the first column.

`message` itself is untouched, byte for byte: the captured frames, the cache
tests and both conformance harnesses decode its rows positionally.

Reducers and procedures are emitted one field per line, like tables, because a
wide parameter list on one line exceeds the 120 columns stylua enforces and
this fixture is committed; an empty section collapses to `{}` for the same
reason.
Author
Owner

Review — automated reviewer, posted for the record

Review — PR #36 feat/25-codegen-types

Verdict: closes #25's acceptance criteria, but not mergeable as written. The design is right, the empirical rigour is real (the committed fixture genuinely reproduces byte-for-byte from a live publish — I checked twice), but the walker emits silently wrong tags in the direction that matters most, and three new header comments assert wire facts the live server contradicts. Everything below is reproduced against the 2.8.2 dev server; every throwaway db was deleted and chat was restored to ["message"].

What I ran: cloned the branch, 5 passed, 0 failed; republished dev/server/index.js to a throwaway db and regenerated — byte-identical to the committed fixture; ran the full dev-server.sh pipeline — fixture regenerated with zero diff; published 8 probe modules; drove a v1.json.spacetimedb socket to capture real row encodings; exercised the jq over 5 hostile synthetic payloads via file://.


Blockers

B1 — A user-defined type is silently tagged as a built-in

dev/tools/gen-schema.sh:119-134

The structural checks run before the typespace-name check, so any module-declared type whose shape happens to collide is tagged as the built-in and its name is destroyed. Published probe, generated output:

declaration emitted correct
t.object('FakeIdent', { __identity__: t.string() }) identity product_FakeIdent
t.object('FakeTs', { __timestamp_micros_since_unix_epoch__: t.string() }) timestamp product_FakeTs
t.enum('FakeOpt', ['some','none']) opt_unknown sum_FakeOpt
t.enum('FakeSched', ['Interval','Time']) schedule_at sum_FakeSched
t.array(FakeOpt) vec_opt_unknown vec_sum_FakeOpt

The some/none collision needs no exotic configuration: under the default CaseConversionPolicy, t.enum('Perhaps', ['Some','None']) canonicalises to some/none and I got opt_unknown on a plain publish. A decoder will then try to read an Option payload out of a Perhaps. This is exactly the failure mode you asked me to hunt for — wrong, not absent — and #23/#26 will trust it.

Why the fix is safe and one-shot: the real special types and real Option are never typespace entries. In the positive probe, .types contained only Containers, Noop, Point, Scalars, Specials, Status — Identity/Timestamp/Option all arrive inlined, so $name == "" for every genuine one. Every false positive arrives through a Ref to a named entry. So: check the name first.

        elif $k == "Sum" then
          ($ty.Sum.variants // []) as $vs
          | ([$vs[] | .name.some // ""] | sort) as $names
          | if $name != "" then "sum_" + $name          # <-- moved up
            elif $names == ["none", "some"] then "opt_" + (...)
            elif $names == ["Interval", "Time"] then "schedule_at"
            else "unknown" end
        elif $k == "Product" then
          ($ty.Product.elements // []) as $els
          | if $name != "" then "product_" + $name      # <-- moved up
            elif ($els | length) == 1 and ((magic)[$els[0].name.some // ""] != null)
            then (magic)[$els[0].name.some]
            else "unknown" end

Pair it with :117 — change the Ref-name fallback from ($r | tostring) to "", otherwise an unnamed Ref target would now produce product_3, a tag naming a typespace index (unstable across module edits) baked into a committed file.

Verified: with this patch the positive-grammar probe output is unchanged, all five false positives are fixed, and chat_schema.lua regenerates byte-identical from a real publish. Zero regression.

README.md:344-347 needs the converse caveat too — it currently only documents the true-negative direction.

B2 — sum_<Name> / product_<Name> are dead ends, and the header says they aren't

dev/tools/gen-schema.sh:53-60

"Under the JSON protocol a decoder needs nothing more: sum values arrive tagged with their variant name ({"Idle":[]})… A BSATN codec, which writes variants by ordinal, has to resolve the name…"

False. Captured off a live socket (InitialSubscription, v1.json.spacetimedb):

{"id":1,
 "pos":{"x":3,"y":4},
 "maybe":[0,{"x":1,"y":2}],
 "nothing":[1,{}],
 "state":[1,{}],
 "ident":{"__identity__":"0xc200d7a1…"},
 "ts":{"__timestamp_micros_since_unix_epoch__":1787157191225200},
 "tags":["a","b"]}

state is a named sum (Status{Idle,Busy}) and arrives as [1,{}] — ordinal, not name. Same for Option: [0,payload] / [1,{}], not {"some":…}. So the JSON protocol writes variants by ordinal too, and sum_Status alone cannot be decoded — you need Status's variant list in ordinal order. The generated module carries no type declarations at all, so #26 (full decoding) is blocked by precisely the gap this comment claims does not exist. A future product_Point is only decodable because nested products happen to arrive as named objects here; if one ever arrives positionally (which the same comment allows for), it is undecodable too.

Fix: emit the typespace now, while it is cheap. Additive, so strictly it could land later — but the comment is the blocker, because it will send #26 down a dead end.

	typespace = {
		["Status"] = { kind = "sum", variants = { "Idle", "Busy" } },
		["Point"] = { kind = "product", fields = { "x", "y" }, types = { "i32", "i32" } },
	},

Name it typespace, not types — types is already the per-table key.


Should-fix

S1 — A compound unique constraint silently disappears

dev/tools/gen-schema.sh:211

[$uniques[] | select(. == $ix.cols)] compares column-id lists for exact equality. But the server stores them differently: a unique constraint is canonically sorted, a btree index preserves declared order. Verified:

  • btree(['b','a']) → {"BTree":[1,0]}; btree(['c','b','a']) → {"BTree":[2,1,0]} (order kept — good, and the generator faithfully emits columns = { "b", "a" })
  • unique(['handle','realm']) → {"Unique":{"columns":[0,1]}} (sorted)

The server accepts unique(realm, handle) backed by btree(handle, realm) — I published it. Result: constraint [0,1], index [1,0], no match, unique = true is dropped. And since single-column-only unique (:74-75) means compound uniqueness surfaces nowhere else, the constraint vanishes from the generated schema entirely. #28 would see a plain non-unique index.

+ (if ([$uniques[] | select((. | sort) == ($ix.cols | sort))] | length) > 0 then ", unique = true" else "" end)

Verified: recovers unique = true on the reverse-order case, no change to chat.

S2 — The stylua limitation is understated ~10× and will break CI

dev/tools/gen-schema.sh:76-77

"A wide table emits a columns/types line past stylua's 120-column width"

The PR body says "a table with >120 columns of names". It is >120 characters. There is no .stylua.toml, so the default width is 120, and CI runs stylua --check stdb dev/test (.forgejo/workflows/ci.yml:43) — the fixture is in scope.

Current headroom: 12 characters. chat_schema.lua:24 is at 108. My 13-column probe table blew straight past it and stylua --check failed. So the next column added to profile, or one longer index name, makes CI fail on a file nobody hand-edited, with a format error unrelated to the change. "Run stylua over the output if you commit it" doesn't help, because dev-server.sh:~175 is what commits it and it doesn't — and stylua isn't even in the conformance devshell (flake.nix:192-212).

Fix: add pkgs.stylua to the conformance shell and pipe the regeneration through it:

if "$ROOT/dev/tools/gen-schema.sh" "$BASE" "$DB" \
  | stylua --stdin-filepath "${DB}_schema.lua" - > "$tmp"; then

Verified: stylua --stdin-filepath on the wide probe output produces a clean file; the {} collapse and -- GENERATED header survive. That also lets you delete the two { }-workaround comments (:165-166, :258-259) and the paramlines line-splitting rationale (:155-157).

S3 — t.result and t.unit produce unknown, and the "no anonymous types" claim is false

dev/tools/gen-schema.sh:53-55

"the schema's typespace names every compound type — an anonymous one cannot occur, both module SDKs refuse to register one"

Falsified by the positive probe. t.result(t.string(), t.u32()) → inline anonymous {"Sum":{"variants":[{"name":{"some":"ok"}…},{"name":{"some":"err"}…}]}} → unknown. t.unit() → inline {"Product":{"elements":[]}} → unknown. Both are first-class t builders, so a user hits unknown on an ordinary column.

unknown is at least honest, unlike B1. But unit is trivially exact (empty product), and ok/err is as structurally detectable as some/none. Add unit, and either result_<ok>_<err> or an explicit "Result is not tagged, by choice" note. Delete the false clause either way.

S4 — The unit-payload comment mixes two encodings and gets both wrong

dev/tools/gen-schema.sh:62-63

"Unit payloads serialize as an empty ARRAY ({"U64":[]}, {"none":[]}), never as null"

{"U64":[]} is AlgebraicType schema JSON — true and relevant. {"none":[]} is presented as a value encoding and is wrong twice: a none value is [1,{}] (ordinal + empty object). Since this file is where the repo records its protocol facts, and #21/#26 will read it, split the two encodings apart or drop the value half.


Design assessment (the shape being frozen)

Parallel types array — keep. Rows arrive positionally in the captured session, so types[i] aligned with columns[i] matches the decode loop exactly; keying by column name would add a lookup and lose the order that is the whole reason this generator exists.

Flat string tags — keep. A decoder is for i, tag in ipairs(def.types), and opt_/vec_ prefix stripping is two string ops. opt_vec_opt_vec_string round-tripped correctly through four levels. Not awkward.

unique as single-column names — keep, and it correctly includes the pk (message.unique = { "id" }), which is what #28 wants.

Compound uniqueness on indexes[].unique — the weak joint. It conflates "this index is unique" with "these columns are jointly unique", it is order-fragile (S1), and it makes #28 read two places. I would still keep it rather than putting nested lists in unique — but fix S1 and say in the header that compound uniqueness lives only on indexes.

pk omitted for a compound primary key — correct, and better-supported than the PR argues. stdb/schema.lua:12 already documents omitting pk, and stdb/cache.lua:58-65 already falls back to a field-wise scan (with its own ponytail: note about the O(n) cost). Emitting a column list would break schema.lua:26-35 and cache.lua:41, both of which treat pk as a scalar string. So this is the right answer for the right reason — the reason is just stated as "unreachable today" when it is in fact "already a supported first-class case".

Missing: the typespace (B2). That is the one thing I would add while it is cheap.


Robustness of the jq — it fails in the worse direction

You asked which failure this produces. Wrong tags, not malformed files. Every reachable defect (B1, S1, S3) is a silently-wrong or silently-missing value in a file that parses perfectly. Malformed output is essentially unreachable. Details:

input result verdict
unknown type kind {"Bogus":[]} unknown, parses graceful
out-of-range {"Ref":99} unknown, parses graceful
{} / null as algebraic_type unknown, parses graceful
unnamed column "unnamed", correct type, parses graceful (nit: two unnamed columns collide)
zero-column table, missing primary_key columns = {}, no pk, parses graceful
name containing " or \ correctly escaped — I misread luastr at first; it is right fine
name containing a newline malformed Lua (unfinished string near '"new') unreachable: not a legal identifier
Ref cycle (typespace[0] = Array(Ref 0)) jq recurses to OOM, core dump, 24s, exit 134 unreachable, see below

The Ref cycle is genuinely unreachable: the server itself panics first. Attempting to publish a recursive type killed the standalone process — panicked at crates/schema/src/schema.rs:1189: validated module should have all types resolve: RecursiveTypeRef(AlgebraicTypeRef(5)). Such a schema can never be served. And the blast radius is contained anyway: set -euo pipefail propagates the jq failure, and dev-server.sh's if … then mv … else rm … log warning leaves the committed fixture untouched. Nit at most. (I restarted the server and restored chat.)

Note the walker is safe against recursion by construction, not by accident: Product never recurses into its elements, so struct Node { kids: Vec<Node> } terminates at product_Node. Only Array, Ref, and an Option's some payload recurse, and only a typespace entry that is itself an Array/Ref can close a loop.


Claims audit — everything else checks out

claim result
procedures live under misc_exports[].Procedure, old comment wrong True. 3 entries, each {name, params:{elements}, return_type}.
Array transparent {"Array":{"U32":[]}}, Ref bare number {"Ref":1} True. Also types[].ty is a bare number, which is what makes :117's select(.ty == $r) work.
server rejects two .primaryKey() True, verbatim: table `twopk` has multiple primary key annotations.
2-column primary_key emits no pk, tested over file:// True, and the mechanism is sound. One correction: curl strips the query string for file://, so the payload must be named schema, not schema?version=9.
message byte-identical True. Diff vs origin/main is insertions only, zero deletion lines.
session.jsonl correctly not re-captured, frame tests pass True. Empty diff vs main; 5 passed, 0 failed.
profile/claim_profile cannot perturb conformance True, twice over. Every subscription is an explicit SELECT * FROM message (capture-fixtures.mjs:38, smoke.script:82,141) — no wildcard anywhere. And smoke.script:77 builds its own inline stdb.schema.new({…}), it never loads the generated fixture.
fixture regenerated from a real publish True. Republished the branch module to a throwaway db → byte-identical. Then ran the whole dev-server.sh → git diff on the fixture is empty.
falsifiability: old fixture fails the suite True. 4 passed, 1 failed. Nit: it fails with bad argument #1 to 'concat' (table expected, got nil) rather than a clean assertion; a test.assert_eq(profile.types ~= nil, true, …) guard first would read better.
14 new assertions True, counted.
stylua clean True today — and see S2.
special types detected structurally, never by column name True in the direction claimed. The reverse direction is B1.

Acceptance criteria (#25)

  • fixture regenerates with types, unique constraints and reducer param types — met, and reproducibly.
  • tests assert against the live-captured schema — met (expectations are hand-written literals, which is unavoidable; the data is live).
  • a table with a compound pk, a unique non-pk column, and an index — met in substance. Compound pk is impossible and the author proved it; the compound-unique substitution is the right call and is documented at dev/server/index.js:26-30. I would only ask that the substitution be reflected in #25 itself, since #27/#28 will read that issue.

So: #25 is closed. B1, S1 and S2 are the three I would not merge without.


Nits

  • gen-schema.sh:117 — Ref-name fallback ($r | tostring) bakes a typespace index into a tag. Use "". (Required alongside B1.)
  • gen-schema.sh:112 — keys_unsorted[0] silently picks one key from a multi-key AlgebraicType. Fine, but a comment would help.
  • Asymmetric emission: types = {} is always present for a parameterless reducer, but indexes/unique are omitted when empty. Consumers nil-check three fields and not two. Harmless, slightly surprising.
  • Two unnamed columns both emit "unnamed", colliding. Unreachable via either module SDK.
  • gen-schema.sh:7-9 still says rows arrive as positional arrays. True for message, but my capture shows named objects for a 7-column table — both forms occur, and test_fixtures.lua:157 already tests the named-object path. #32's territory, flagging only so it isn't forgotten.

Cleanup

Deleted all 9 throwaway databases (rev36pr, rev36probe{a,b,d,e,f,g,h}, rev36final). Restarted the server after the recursive-type panic and republished from the workspace's own module — live chat is back to ["message"]. No files edited, nothing pushed, no PR comment.


Verdict: Closes #25 on the letter of its acceptance criteria, but I would not merge it as-is: two of the type tags it emits are silently WRONG (not unknown), one metadata field silently vanishes, and three of the new header comments state protocol facts that the live server contradicts. All fixes are small and I verified them end to end against a real 2.8.2 publish.

## Review — automated reviewer, posted for the record # Review — PR #36 `feat/25-codegen-types` **Verdict: closes #25's acceptance criteria, but not mergeable as written.** The design is right, the empirical rigour is real (the committed fixture genuinely reproduces byte-for-byte from a live publish — I checked twice), but the walker emits *silently wrong* tags in the direction that matters most, and three new header comments assert wire facts the live server contradicts. Everything below is reproduced against the 2.8.2 dev server; every throwaway db was deleted and `chat` was restored to `["message"]`. What I ran: cloned the branch, `5 passed, 0 failed`; republished `dev/server/index.js` to a throwaway db and regenerated — **byte-identical to the committed fixture**; ran the full `dev-server.sh` pipeline — fixture regenerated with **zero diff**; published 8 probe modules; drove a `v1.json.spacetimedb` socket to capture real row encodings; exercised the jq over 5 hostile synthetic payloads via `file://`. --- ## Blockers ### B1 — A user-defined type is silently tagged as a built-in `dev/tools/gen-schema.sh:119-134` The structural checks run *before* the typespace-name check, so any module-declared type whose shape happens to collide is tagged as the built-in and its name is destroyed. Published probe, generated output: | declaration | emitted | correct | |---|---|---| | `t.object('FakeIdent', { __identity__: t.string() })` | `identity` | `product_FakeIdent` | | `t.object('FakeTs', { __timestamp_micros_since_unix_epoch__: t.string() })` | `timestamp` | `product_FakeTs` | | `t.enum('FakeOpt', ['some','none'])` | `opt_unknown` | `sum_FakeOpt` | | `t.enum('FakeSched', ['Interval','Time'])` | `schedule_at` | `sum_FakeSched` | | `t.array(FakeOpt)` | `vec_opt_unknown` | `vec_sum_FakeOpt` | The `some`/`none` collision needs **no exotic configuration**: under the *default* `CaseConversionPolicy`, `t.enum('Perhaps', ['Some','None'])` canonicalises to `some`/`none` and I got `opt_unknown` on a plain publish. A decoder will then try to read an Option payload out of a `Perhaps`. This is exactly the failure mode you asked me to hunt for — wrong, not absent — and #23/#26 will trust it. **Why the fix is safe and one-shot:** the real special types and real `Option` are *never* typespace entries. In the positive probe, `.types` contained only `Containers, Noop, Point, Scalars, Specials, Status` — `Identity`/`Timestamp`/`Option` all arrive **inlined**, so `$name == ""` for every genuine one. Every false positive arrives through a `Ref` to a **named** entry. So: check the name first. ```jq elif $k == "Sum" then ($ty.Sum.variants // []) as $vs | ([$vs[] | .name.some // ""] | sort) as $names | if $name != "" then "sum_" + $name # <-- moved up elif $names == ["none", "some"] then "opt_" + (...) elif $names == ["Interval", "Time"] then "schedule_at" else "unknown" end elif $k == "Product" then ($ty.Product.elements // []) as $els | if $name != "" then "product_" + $name # <-- moved up elif ($els | length) == 1 and ((magic)[$els[0].name.some // ""] != null) then (magic)[$els[0].name.some] else "unknown" end ``` Pair it with `:117` — change the Ref-name fallback from `($r | tostring)` to `""`, otherwise an unnamed Ref target would now produce `product_3`, a tag naming a typespace *index* (unstable across module edits) baked into a committed file. **Verified:** with this patch the positive-grammar probe output is *unchanged*, all five false positives are fixed, and `chat_schema.lua` regenerates byte-identical from a real publish. Zero regression. `README.md:344-347` needs the converse caveat too — it currently only documents the true-negative direction. ### B2 — `sum_<Name>` / `product_<Name>` are dead ends, and the header says they aren't `dev/tools/gen-schema.sh:53-60` > "Under the JSON protocol a decoder needs nothing more: sum values arrive tagged with their variant name (`{"Idle":[]}`)… A BSATN codec, which writes variants by ordinal, has to resolve the name…" **False.** Captured off a live socket (`InitialSubscription`, `v1.json.spacetimedb`): ```json {"id":1, "pos":{"x":3,"y":4}, "maybe":[0,{"x":1,"y":2}], "nothing":[1,{}], "state":[1,{}], "ident":{"__identity__":"0xc200d7a1…"}, "ts":{"__timestamp_micros_since_unix_epoch__":1787157191225200}, "tags":["a","b"]} ``` `state` is a named sum (`Status{Idle,Busy}`) and arrives as **`[1,{}]` — ordinal, not name**. Same for `Option`: `[0,payload]` / `[1,{}]`, not `{"some":…}`. So the JSON protocol writes variants by ordinal *too*, and `sum_Status` alone cannot be decoded — you need `Status`'s variant list in ordinal order. The generated module carries **no type declarations at all**, so #26 (full decoding) is blocked by precisely the gap this comment claims does not exist. A future `product_Point` is only decodable because nested products happen to arrive as named objects here; if one ever arrives positionally (which the same comment allows for), it is undecodable too. **Fix:** emit the typespace now, while it is cheap. Additive, so strictly it could land later — but the comment is the blocker, because it will send #26 down a dead end. ```lua typespace = { ["Status"] = { kind = "sum", variants = { "Idle", "Busy" } }, ["Point"] = { kind = "product", fields = { "x", "y" }, types = { "i32", "i32" } }, }, ``` Name it `typespace`, not `types` — `types` is already the per-table key. --- ## Should-fix ### S1 — A compound unique constraint silently disappears `dev/tools/gen-schema.sh:211` `[$uniques[] | select(. == $ix.cols)]` compares column-id lists for exact equality. But the server stores them differently: a **unique constraint** is canonically sorted, a **btree index** preserves declared order. Verified: - `btree(['b','a'])` → `{"BTree":[1,0]}`; `btree(['c','b','a'])` → `{"BTree":[2,1,0]}` (order kept — good, and the generator faithfully emits `columns = { "b", "a" }`) - `unique(['handle','realm'])` → `{"Unique":{"columns":[0,1]}}` (sorted) The server **accepts** `unique(realm, handle)` backed by `btree(handle, realm)` — I published it. Result: constraint `[0,1]`, index `[1,0]`, no match, **`unique = true` is dropped**. And since single-column-only `unique` (`:74-75`) means compound uniqueness surfaces *nowhere else*, the constraint vanishes from the generated schema entirely. #28 would see a plain non-unique index. ```jq + (if ([$uniques[] | select((. | sort) == ($ix.cols | sort))] | length) > 0 then ", unique = true" else "" end) ``` Verified: recovers `unique = true` on the reverse-order case, no change to `chat`. ### S2 — The stylua limitation is understated ~10× and will break CI `dev/tools/gen-schema.sh:76-77` > "A wide table emits a `columns`/`types` line past stylua's 120-column width" The PR body says "a table with >120 columns of names". It is >120 **characters**. There is no `.stylua.toml`, so the default width is 120, and CI runs `stylua --check stdb dev/test` (`.forgejo/workflows/ci.yml:43`) — the fixture is in scope. Current headroom: **12 characters.** `chat_schema.lua:24` is at 108. My 13-column probe table blew straight past it and `stylua --check` failed. So the next column added to `profile`, or one longer index name, makes CI fail on a file nobody hand-edited, with a format error unrelated to the change. "Run stylua over the output if you commit it" doesn't help, because `dev-server.sh:~175` is what commits it and it doesn't — and `stylua` isn't even in the `conformance` devshell (`flake.nix:192-212`). **Fix:** add `pkgs.stylua` to the conformance shell and pipe the regeneration through it: ```sh if "$ROOT/dev/tools/gen-schema.sh" "$BASE" "$DB" \ | stylua --stdin-filepath "${DB}_schema.lua" - > "$tmp"; then ``` Verified: `stylua --stdin-filepath` on the wide probe output produces a clean file; the `{}` collapse and `-- GENERATED` header survive. That also lets you delete the two `{ }`-workaround comments (`:165-166`, `:258-259`) and the `paramlines` line-splitting rationale (`:155-157`). ### S3 — `t.result` and `t.unit` produce `unknown`, and the "no anonymous types" claim is false `dev/tools/gen-schema.sh:53-55` > "the schema's typespace names every compound type — an anonymous one cannot occur, both module SDKs refuse to register one" Falsified by the positive probe. `t.result(t.string(), t.u32())` → inline anonymous `{"Sum":{"variants":[{"name":{"some":"ok"}…},{"name":{"some":"err"}…}]}}` → **`unknown`**. `t.unit()` → inline `{"Product":{"elements":[]}}` → **`unknown`**. Both are first-class `t` builders, so a user hits `unknown` on an ordinary column. `unknown` is at least honest, unlike B1. But `unit` is trivially exact (empty product), and `ok`/`err` is as structurally detectable as `some`/`none`. Add `unit`, and either `result_<ok>_<err>` or an explicit "Result is not tagged, by choice" note. Delete the false clause either way. ### S4 — The unit-payload comment mixes two encodings and gets both wrong `dev/tools/gen-schema.sh:62-63` > "Unit payloads serialize as an empty ARRAY (`{"U64":[]}`, `{"none":[]}`), never as null" `{"U64":[]}` is *AlgebraicType schema* JSON — true and relevant. `{"none":[]}` is presented as a *value* encoding and is wrong twice: a `none` value is `[1,{}]` (ordinal + empty object). Since this file is where the repo records its protocol facts, and #21/#26 will read it, split the two encodings apart or drop the value half. --- ## Design assessment (the shape being frozen) **Parallel `types` array — keep.** Rows arrive positionally in the captured session, so `types[i]` aligned with `columns[i]` matches the decode loop exactly; keying by column name would add a lookup and lose the order that is the whole reason this generator exists. **Flat string tags — keep.** A decoder is `for i, tag in ipairs(def.types)`, and `opt_`/`vec_` prefix stripping is two string ops. `opt_vec_opt_vec_string` round-tripped correctly through four levels. Not awkward. **`unique` as single-column names — keep**, and it correctly *includes* the pk (`message.unique = { "id" }`), which is what #28 wants. **Compound uniqueness on `indexes[].unique` — the weak joint.** It conflates "this index is unique" with "these columns are jointly unique", it is order-fragile (S1), and it makes #28 read two places. I would still keep it rather than putting nested lists in `unique` — but fix S1 and say in the header that compound uniqueness lives *only* on `indexes`. **`pk` omitted for a compound primary key — correct, and better-supported than the PR argues.** `stdb/schema.lua:12` already documents omitting `pk`, and `stdb/cache.lua:58-65` already falls back to a field-wise scan (with its own `ponytail:` note about the O(n) cost). Emitting a column *list* would break `schema.lua:26-35` and `cache.lua:41`, both of which treat `pk` as a scalar string. So this is the right answer for the right reason — the reason is just stated as "unreachable today" when it is in fact "already a supported first-class case". **Missing: the typespace (B2).** That is the one thing I would add while it is cheap. --- ## Robustness of the jq — it fails in the *worse* direction You asked which failure this produces. **Wrong tags, not malformed files.** Every reachable defect (B1, S1, S3) is a silently-wrong or silently-missing value in a file that parses perfectly. Malformed output is essentially unreachable. Details: | input | result | verdict | |---|---|---| | unknown type kind `{"Bogus":[]}` | `unknown`, parses | graceful | | out-of-range `{"Ref":99}` | `unknown`, parses | graceful | | `{}` / `null` as `algebraic_type` | `unknown`, parses | graceful | | unnamed column | `"unnamed"`, correct type, parses | graceful (nit: two unnamed columns collide) | | zero-column table, missing `primary_key` | `columns = {}`, no `pk`, parses | graceful | | name containing `"` or `\` | correctly escaped — I misread `luastr` at first; it is right | fine | | name containing a newline | **malformed Lua** (`unfinished string near '"new'`) | unreachable: not a legal identifier | | Ref cycle (`typespace[0] = Array(Ref 0)`) | jq recurses to OOM, **core dump**, 24s, exit 134 | unreachable, see below | The Ref cycle is genuinely unreachable: **the server itself panics first.** Attempting to publish a recursive type killed the standalone process — `panicked at crates/schema/src/schema.rs:1189: validated module should have all types resolve: RecursiveTypeRef(AlgebraicTypeRef(5))`. Such a schema can never be served. And the blast radius is contained anyway: `set -euo pipefail` propagates the jq failure, and `dev-server.sh`'s `if … then mv … else rm … log warning` leaves the committed fixture untouched. Nit at most. (I restarted the server and restored `chat`.) Note the walker is safe against recursion *by construction*, not by accident: `Product` never recurses into its elements, so `struct Node { kids: Vec<Node> }` terminates at `product_Node`. Only `Array`, `Ref`, and an Option's `some` payload recurse, and only a typespace entry that is *itself* an `Array`/`Ref` can close a loop. --- ## Claims audit — everything else checks out | claim | result | |---|---| | procedures live under `misc_exports[].Procedure`, old comment wrong | **True.** 3 entries, each `{name, params:{elements}, return_type}`. | | `Array` transparent `{"Array":{"U32":[]}}`, `Ref` bare number `{"Ref":1}` | **True.** Also `types[].ty` is a bare number, which is what makes `:117`'s `select(.ty == $r)` work. | | server rejects two `.primaryKey()` | **True**, verbatim: ``table `twopk` has multiple primary key annotations``. | | 2-column `primary_key` emits no `pk`, tested over `file://` | **True**, and the mechanism is sound. One correction: curl strips the query string for `file://`, so the payload must be named `schema`, not `schema?version=9`. | | `message` byte-identical | **True.** Diff vs `origin/main` is insertions only, zero deletion lines. | | `session.jsonl` correctly not re-captured, frame tests pass | **True.** Empty diff vs main; `5 passed, 0 failed`. | | `profile`/`claim_profile` cannot perturb conformance | **True**, twice over. Every subscription is an explicit `SELECT * FROM message` (`capture-fixtures.mjs:38`, `smoke.script:82,141`) — no wildcard anywhere. And `smoke.script:77` builds its own inline `stdb.schema.new({…})`, it never loads the generated fixture. | | fixture regenerated from a real publish | **True.** Republished the branch module to a throwaway db → byte-identical. Then ran the whole `dev-server.sh` → `git diff` on the fixture is empty. | | falsifiability: old fixture fails the suite | **True.** `4 passed, 1 failed`. Nit: it fails with `bad argument #1 to 'concat' (table expected, got nil)` rather than a clean assertion; a `test.assert_eq(profile.types ~= nil, true, …)` guard first would read better. | | 14 new assertions | **True**, counted. | | stylua clean | **True today** — and see S2. | | special types detected structurally, never by column name | **True** in the direction claimed. The reverse direction is B1. | ### Acceptance criteria (#25) - *fixture regenerates with types, unique constraints and reducer param types* — **met**, and reproducibly. - *tests assert against the live-captured schema* — **met** (expectations are hand-written literals, which is unavoidable; the *data* is live). - *a table with a compound pk, a unique non-pk column, and an index* — **met in substance.** Compound pk is impossible and the author proved it; the compound-unique substitution is the right call and is documented at `dev/server/index.js:26-30`. I would only ask that the substitution be reflected in #25 itself, since #27/#28 will read that issue. So: **#25 is closed.** B1, S1 and S2 are the three I would not merge without. --- ## Nits - `gen-schema.sh:117` — Ref-name fallback `($r | tostring)` bakes a typespace *index* into a tag. Use `""`. (Required alongside B1.) - `gen-schema.sh:112` — `keys_unsorted[0]` silently picks one key from a multi-key AlgebraicType. Fine, but a comment would help. - Asymmetric emission: `types = {}` is always present for a parameterless reducer, but `indexes`/`unique` are omitted when empty. Consumers nil-check three fields and not two. Harmless, slightly surprising. - Two unnamed columns both emit `"unnamed"`, colliding. Unreachable via either module SDK. - `gen-schema.sh:7-9` still says rows arrive as positional arrays. True for `message`, but my capture shows named objects for a 7-column table — both forms occur, and `test_fixtures.lua:157` already tests the named-object path. #32's territory, flagging only so it isn't forgotten. ## Cleanup Deleted all 9 throwaway databases (`rev36pr`, `rev36probe{a,b,d,e,f,g,h}`, `rev36final`). Restarted the server after the recursive-type panic and republished from the workspace's own module — live `chat` is back to `["message"]`. No files edited, nothing pushed, no PR comment. --- **Verdict:** Closes #25 on the letter of its acceptance criteria, but I would not merge it as-is: two of the type tags it emits are silently WRONG (not `unknown`), one metadata field silently vanishes, and three of the new header comments state protocol facts that the live server contradicts. All fixes are small and I verified them end to end against a real 2.8.2 publish.
yuris force-pushed feat/25-codegen-types from 78642a103d
All checks were successful
ci / ci (pull_request) Successful in 1m21s
to c1e44ae98b
All checks were successful
ci / ci (pull_request) Successful in 1m24s
2026-08-19 17:00:09 +00:00
Compare
yuris merged commit a1e716d27b into main 2026-08-19 17:02:30 +00:00
Sign in to join this conversation.
No description provided.