Working log for implementing gobts. Claude Code reads this at the start of each session and updates it before ending work. The human may edit this file too — Claude should respect external edits and merge them in.
Rules for Claude:
- Read this file first, every session.
- Before ending a session (quota, fatigue, handoff), update "Current state" and "Next session should start with".
- When a phase completes, move it from "In progress" to "Done" and check the acceptance boxes.
- Don't skip phases. Phase N depends on Phase N-1 being solid.
- If you discover work that doesn't fit a phase, add it under "Discovered work" rather than rearranging phases.
Phase: All phases complete ✅ — plus documentation-snippet integration (2026-08-20). Last session: 2026-08-20 (Session 4) — cross-port snippet data harmonization: stream topic merge, canonical Point fixtures, two new topics. See Discovered work #6. Branch: main
Next session: Two decoder/API issues surfaced while writing the doc examples — see "Discovered work". Otherwise: performance (see bench/results/baseline.md) or v1 release prep.
See PRD §Implementation Plan → Phase 0.
-
bun init --typescriptin repo root; replace template files. -
package.json: namegobts,"type": "module", correctexportsmap (root plus subpaths./codecs/time,./codecs/uuid,./codecs). No runtimedependencies. -
tsconfig.json:"strict": true,"noUncheckedIndexedAccess": true,"exactOptionalPropertyTypes": true,"moduleResolution": "bundler","target": "ES2022","lib": ["ES2022", "DOM"]. - Dev deps:
@types/bun,fast-check,mitata,typescript. -
testdata/,go_verify/main.go,generate_testdata.goalready present (pre-copied from pygob). - Source skeleton in place: all
src/files andsrc/codecs/files exist as placeholders. -
bun testruns (scaffold test passes). -
bunx tsc --noEmitpasses.
Acceptance: Fresh clone can install, type-check, and run bun test on an empty suite without errors. ✅
See PRD §Implementation Plan → Phase 1.
-
GobWriterinsrc/codec.ts: all required methods implemented. -
GobReaderinsrc/codec.ts: mirror; throwsEndOfStreamErrorat EOF. -
Complexclass insrc/types.tswithZEROstatic andequals(other). -
errors.ts: all four error classes implemented. - Geometric growth buffer in
GobWriter. - Float byte-reversal: discovered the correct approach — on little-endian systems, _f64bytes[0..7] interpreted as big-endian uint gives Go's ReverseBytes64 output. Tested against 3 Go fixtures.
-
TextEncoder/TextDecodercached per instance. -
tests/codec.test.ts: 46 tests covering all boundary values, EOS errors, fixture validation. - Out-of-range bigint throws
GobEncodeError.
Acceptance: 46/46 tests pass; float byte-reversal validated against Go-generated scalar_float/*.gob fixtures. ✅
See PRD §Implementation Plan → Phase 2.
- Bootstrap type ID constants in
src/wire.ts. - Wire-type interfaces all implemented.
-
decodeWireType()with correct delta dispatch for fields 0–6. - Empty
CommonType.Namecollection case handled (delta=2); dedicated test passes. -
tests/wire.test.ts: 14 tests covering all variants + Go fixture integration.
Acceptance: 14/14 tests pass. ✅
See PRD §Implementation Plan → Phase 3.
- Message framing with bounded sub-reader per message.
- Type registry with all bootstrap IDs pre-populated.
- All 8 bootstrap scalar types with 0x00 singleton wrapper.
- Struct decoding: delta arithmetic, zero-value pre-population.
- Collections: slice, array, map.
- Interface decoding: inline type-def loop + deferred concrete value message.
- GobObject for unregistered structs; registered factory override.
- GobEncoded for marshaler types without a codec.
- EndOfStreamError + tryDecode; feed(); [Symbol.iterator].
- All testdata/*.gob fixtures decode correctly.
Acceptance: 62/62 decoder tests pass. ✅
Key implementation notes:
- Interface inline type defs end on EOF (not on raw_id > 0) in the current struct payload.
- Interface concrete value has inner uint N byte-count wrapper.
- Struct fields truncate due to EOF are treated as terminated (same as pygob).
See PRD §Implementation Plan → Phase 4.
- Three type registries in
GobEncoder:schemaRegistry(name → id),collectionRegistry(signature → id),interfaceRegistry(name → schema). - Type ID allocator starts at 65.
- Message emission using a scratch
GobWriterfor payload → length prefix → outer writer. - Struct payload encoding with correct delta arithmetic and zero-value omission.
- Field value encoding for all primitive and composite types.
- Interface field encoding with deferred-message pattern.
- Track
topLevelSchemasandinlineSchemasseparately — interface concrete types get ONLY an inline type def, never a top-level one. -
CommonTypeempty-name shortcut for collection wire types (delta=2). -
bytes()returns accumulated buffer and resets it; type-def state is preserved across calls untilreset(). -
tests/encoder.test.ts— encoder output is byte-identical to Go for scalars; structurally identical for non-scalars. -
tests/goVerify.test.ts— TS output decodes cleanly in Go.
Acceptance: Every round-trip test passes AND every go_verify test passes. ✅ (168/168 tests, 15/15 go_verify)
See PRD §Implementation Plan → Phase 5.
-
encode<T>(value, options?)/decode<T>(bytes, options?)convenience functions insrc/index.ts. -
Schemaclass withnew Schema(name, fields)constructor; implementsGobFieldTypefor nested-struct use. -
GobFieldTypediscriminated union withreadonly kindbrand on each variant. - All primitive constants:
GOB_BOOL,GOB_INT,GOB_UINT,GOB_FLOAT,GOB_BYTES,GOB_STRING,GOB_COMPLEX,GOB_INTERFACE,GOB_DURATION. - Composite factories:
SliceOf,MapOf,ArrayOf,Marshaler,SemanticType. -
GobObjectwithtype,schema,fields,get,has,keys,values,entries,[Symbol.iterator]. -
GobEncodedclass. -
InferSchema<S>type helper insrc/infer.ts— type-level only, no runtime output. -
tests/types.test.ts— 31 tests covering all new APIs. - Compile-time type assertions for
InferSchema<S>on canonical shapes (Point, Person, Tags). -
EncodeOptions/DecodeOptionsnamed interfaces exported fromsrc/index.ts.
Acceptance: All APIs work as specified. InferSchema<S> produces correct compile-time types. ✅ (199/199 tests, tsc clean)
See PRD §Implementation Plan → Phase 6.
-
TimeCodecinsrc/codecs/time.tswithkind: 'gob'— 15-byte format, BigInt for int64 safety. -
UuidCodecinsrc/codecs/uuid.tswithkind: 'binary', canonical hyphenated lowercase string. -
DEFAULT_CODECSinsrc/codecs/index.ts:{ Time: TimeCodec, UUID: UuidCodec }. -
EncodeOptions.marshalerType+marshalerKindadded for top-level marshaler encoding. -
tests/codecs.test.ts: 25 tests covering all codec cases including pre-epoch, sub-ms precision, all-zeros UUID, wrong byte count. -
go_verifytests: scalar_time (TS Date → Go time.Time), scalar_uuid (TS string → Go uuid.UUID).
Acceptance: Go-generated time.Time and UUID values decode to correct Date/string; TS→Go round-trips pass go_verify. ✅ (226/226 tests, tsc clean)
See PRD §Testing Strategy → Layer 4 and §Benchmarks.
-
tests/property.test.tswithfast-checkarbitraries: int64, bool, float, string, bytes, Complex, Point, Person, []int, []string, map[string]int, type-def idempotency. 13 tests, 1000 runs each. -
bench/index.bench.tswith all scenarios: scalars, Point struct, Person struct, []int (1000), struct slice (1000), round-trip, warm-encoder. - Baseline results in
bench/results/baseline.md. - Root-cause analysis documented — 2× target not met (expected for TS vs V8 built-in JSON).
Acceptance: Property tests green. ✅ (239/239 tests). 2× target exceeded across all scenarios — root-cause analysis in bench/results/baseline.md: V8 built-in vs TypeScript object allocation overhead, BigInt arithmetic, Map lookups. Identical structural causes to gobdotnet and pygob.
(none — all phases complete)
- Phase 0 — Scaffolding (2026-04-18)
- Phase 1 — Codec Layer (2026-04-18)
- Phase 2 — Wire Types (2026-04-18)
- Phase 3 — Decoder (2026-04-18)
- Phase 4 — Encoder (2026-04-18)
- Phase 5 — Public API + Type Inference (2026-04-18)
- Phase 6 — Codecs (2026-04-18)
- Phase 7 — Property Tests & Benchmarks (2026-04-18)
SemanticField<T> declared encode as a readonly property, which TypeScript
checks contravariantly. That made SemanticField<Status> unassignable to
SemanticField<unknown>, so the README's own SemanticType example did not
compile once placed in a Schema. Changed to method syntax (bivariant) in
src/types.ts. Type-level only — no runtime or wire change.
_decodeInterface read the package-qualified concrete type name off the wire
and discarded it, so the factory lookup in _decodeStruct saw only the struct's
own wire name. dec.register('main.Point', …) — exactly what README.md
documented — silently did nothing.
Evidence. An interface value carries two distinct names. The Go-generated
tests/testdata/interface_value.gob shows both: 0a "main.Point" in the
interface header, then 05 "Point" as the inline type definition's
CommonType.Name. Go's own decoder keys on the qualified one
(encoding/gob/decode.go:700, "name not registered for interface"), and
gob.Register builds it from the full import path — so it is main.Point
only in package main, otherwise github.com/you/pkg.Point.
Sister-port precedent. Both ports key their decode-side registry on the
unqualified name and document the asymmetry (qualified for encode, unqualified
for decode): gobdotnet README shows dec.Register<Point>("Point") alongside
enc.Register("main.Point", …); pygob's decoder register() is effectively
write-only and documented as not required, because decoding is self-describing.
Resolution — accept both, qualified first. _decodeStruct now takes an
optional qualifiedName, supplied only on the interface path, and prefers it
over structT.common.name. This makes the documented qualified form work,
disambiguates same-named types from different packages, and keeps the
unqualified name — the portable key the sister ports use, and the one top-level
structs already matched — as the fallback. GobObject.type is unchanged
(unqualified), so the testdata sidecars still match.
Regression tests in tests/decoder.test.ts → "interface decoding" cover all
four cases: qualified fires, unqualified fires, qualified wins when both are
registered, and an unrelated qualified name does not fire.
README.md's "Registering concrete types" section was rewritten — it was the
actual source of the wrong instruction — and docs/04-go-interop.md gained a
"Two names, one value" section. The interface-values snippet still shows the
GobObject path, which is the variant that stays honest across all four
languages.
Lines 470 and 485: expect(...).toEqual(...) resolves to the (expected: undefined) overload under the current bun-types. Predates this session;
bun test passes. Worth pinning or reworking so bunx tsc --noEmit is clean.
gobts was written believing itself the first mover — at the time no sibling repo
had any snippet:start marker and content/manifest.json listed every source
with "topics": []. That premise turned out to be wrong: all four repos landed
ids independently. See section 5 — the ids were reconciled on 2026-08-20 with
gobts's vocabulary winning the ties, so the 16 ids below did become the contract,
but by agreement rather than by precedence.
Topics and their host files (see CLAUDE.md → "Documentation snippets"; tables updated 2026-08-20 by the harmonization pass, section 6):
| Topic | File |
|---|---|
define-schema, schema-type-inference, semantic-type |
examples/schemas.test.ts |
encode-struct, decode-struct, nested-struct, zero-fields-omitted, dynamic-field-access |
examples/structs.test.ts |
encode-scalars, encode-slice, encode-map |
examples/collections.test.ts |
stream-multiple-values, end-of-stream |
examples/streaming.test.ts |
interface-values |
examples/interfaces.test.ts |
time-values, uuid-values, custom-marshaler |
examples/codecs.test.ts |
Shared fixture data every port must mirror:
| Topic | Go shape | Value |
|---|---|---|
define-schema, encode-struct, decode-struct, dynamic-field-access |
type Point struct { X, Y int } |
{X: 3, Y: 4} |
zero-fields-omitted |
type Point struct { X, Y int } |
full {3, 4} vs partial {3, 0}; compare byte lengths, decode restores the zero |
nested-struct |
type Line struct { From, To Point } |
From{1,2}, To{3,4} |
schema-type-inference |
type Person struct { Name string; Age int } |
{"Ada", 36} |
stream-multiple-values, end-of-stream |
type Point struct { X, Y int } |
one encoder: {3, 4} then {5, 6} |
encode-scalars |
int (+ a short idiomatic scalar tour) |
anchor value 42 |
encode-slice |
[]int |
[1, 2, 3] |
encode-map |
map[string]int |
{"one": 1, "two": 2} |
interface-values |
type Box struct { Value any } |
main.Point{3, 4} |
semantic-type |
type Status string |
"active" |
time-values |
time.Time |
2009-11-10T23:00:00Z |
uuid-values |
uuid.UUID |
6ba7b810-9dad-11d1-80b4-00c04fd430c8 |
custom-marshaler |
type Celsius float64 (BinaryMarshaler, 8 big-endian bytes) |
21.5 |
schema-type-inference and dynamic-field-access are TypeScript-specific and
will render as single-tab blocks unless the other ports add equivalents.
gobspect, pygob, and gobdotnet each landed their own topic ids believing
themselves the first mover, so four vocabularies existed at once. Reconciled in
a gobdotnet session on 2026-08-20, with the maintainer breaking ties toward
gobts — the opposite direction from what this section originally proposed.
gobts markers and :::examples references were left unchanged. Do not
"fix" them back.
Renames applied elsewhere:
| repo | from | to |
|---|---|---|
| gobspect | encode-nested-struct |
nested-struct |
| gobspect | encode-interface |
interface-values |
| gobspect | encode-time |
time-values |
| pygob | interface-value |
interface-values |
| pygob | decode-stream-until-eof |
end-of-stream |
| gobdotnet | encode-scalar, stream-multiple, stream-eof, decode-interface, decode-time, decode-uuid, custom-codec, semantic-types |
encode-scalars, stream-multiple-values, end-of-stream, interface-values, time-values, uuid-values, custom-marshaler, semantic-type |
go test ./... green in gobspect and pytest tests/test_examples.py green in
pygob after their renames.
custom-marshaler vs gobspect's gobencoder-type: not the same concept, so
they were not merged. Ours registers a codec with the library; gobspect's shows
a Go type implementing GobEncoder/GobDecode, and stays a gobspect-only topic.
Still open at the time this section was written — both items were resolved on the gobts side by the harmonization pass, section 6:
stream-encode+stream-decodevsstream-multiple-values. gobspect, pygob, and gobdotnet each use one combined topic where we use two. Left as-is rather than renamed into a half-match; merging means rewriting our example bodies and the prose indocs/02-encoding-decoding.md. (Merged in section 6.)- Fixture data still diverges on the shared multi-tab topics. gobspect uses
Dog/Petforinterface-valuesand2024-03-14T15:09:26Zfortime-values; ours usesmain.Point{3,4}and2009-11-10T23:00:00Z; gobdotnet's are pinned to itstestdata/*.gobfixtures. Same-id variants are supposed to show the same data, so a tabbed block currently shows four variants doing the same thing to different values. (Canonical data agreed cross-port; gobts aligned in section 6.)
Not done here: the codepuke side. content/manifest.json still pins gobts at
commit bc04ab1 with "topics": [] and "docs": []. The maintainer advances
sources.json and runs go run ./cmd/sync from that repo after this lands —
sync reads via git, so uncommitted changes here are invisible to it.
Follow-up to section 5: the canonical fixture data was agreed across all four
ports (running struct Point{X: 3, Y: 4}, bigint on our side), and gobts was
brought onto it. The tables in section 4 were updated in place and are current.
Changes:
- Stream topics merged.
stream-encode+stream-decodereplaced by the single combinedstream-multiple-values(the id the other three ports already use): oneGobEncoderencodesPoint{3n,4n}thenPoint{5n,6n}, then aGobDecoderiterates both. The topic's point is that the type definition is sent once. The one-line comment thatbytes()drains the buffer but keeps type state survives; the Person/Ada/Grace stream fixtures are gone fromexamples/streaming.test.ts. end-of-streamswitched from the Person stream to the same canonicalPoint{3n,4n},{5n,6n}stream. The tryDecode/hasMore vs decode-throws teaching is unchanged.define-schematrimmed to the hand-declared Point schema only; the Person schema left the marked region so all four tabs show the same thing.- New topic
encode-scalarsinexamples/collections.test.ts: anchor value42, plus a short idiomatic tour (string/bool/float one-shots). - New topic
zero-fields-omittedinexamples/structs.test.ts: fullPoint{3n,4n}vs partialPoint{3n,0n}, byte-length comparison shows the zero field absent on the wire, decode restores it. - Docs:
docs/02-encoding-decoding.mdstreaming prose reworked around the single:::examples stream-multiple-valuesblock and a new Scalars section referencesencode-scalars;docs/04-go-interop.mdgained a "Zero values on the wire" section referencingzero-fields-omitted(replacing the redundant wire-format-notes bullet).
Deliberately unchanged, verified against the canon: encode-struct,
decode-struct, nested-struct, encode-slice, encode-map,
interface-values, time-values, uuid-values, custom-marshaler,
semantic-type, and schema-type-inference (now shared with pygob; the
Person/Ada/36 content is the canon). dynamic-field-access stays a gobts-only
topic — merging it into decode-struct was considered and rejected because it
shows keys/values/iteration the other ports do not.
Topic count is now 17: 16 − 2 stream ids + 1 merged + 2 new.
- Project start: Targeting Bun 1.1+ as the primary runtime and
bun testas the primary test runner (matches the maintainer's stated preference for new TypeScript projects). Node 20+ and modern browsers are supported as a strict consequence of the zero-runtime-dependency rule — nothing in the code base uses Bun-specific APIs. - Project start:
bigintis the default representation for gobintanduint. Rejected "number if safe, bigint otherwise" — nondeterministic decoded types are a worse DX thanNumber(x)when narrowing. - Project start: No decorators. Rejected stage-3 decorators (
@gobStruct) because they require specifictsconfig.jsonsettings and complicate consumer builds.Schema+InferSchema<S>is idiomatic, decorator-free, and works in any TS config. - Project start: ESM only — rejected dual ESM/CJS publish to avoid doubling the build and test surface. CJS consumers use dynamic
import(). - Project start:
Datefortime.Time(with documented millisecond precision and offset loss).Temporalis forward-looking but not yet baseline; aTemporalTimeCodeccan ship later as an additive change. - Project start:
string(canonical hyphenated lowercase) foruuid.UUID, notUint8Array. Matchescrypto.randomUUID()output and is JSON-friendly.
- Worked on: (phase, component, specific task)
- Completed: (files created / modified, tests added, bugs fixed)
- Partial / blocked: (anything unfinished or blocked, and why)
- Next session: (what to do first in the next session)
- Worked on: codepuke documentation integration — snippet markers and
docs/pages. - Completed:
- New
examples/directory with 6 doc-shaped test files defining 16 snippet topics. Regions wrap clean bodies; imports and assertions stay outside the markers. Picked up bybun testautomatically (nobunfig.tomlhere). - New
docs/with 6 numbered pages (00-overview…05-limitations), condensed from README.md, using:::examples <topic>in place of inline ts fences. All 16 topics referenced; README.md left untouched. tsconfig.json: addedexamples/**/*toincludesobunx tsc --noEmitactually covers the examples.tsconfig.build.jsonstill builds onlysrc/**/*, so examples never ship todist/.CLAUDE.md: new "Documentation snippets" section plus layout entries.src/types.ts:SemanticField<T>variance fix (Discovered work #1).
- New
- Verified:
bun test305 pass / 0 fail across 15 files;bunx tsc --noEmitclean apart from the two pre-existingtests/encoder.test.tserrors; 16snippet:start/ 16snippet:end, all marker lines conforming; defined topic set == referenced topic set; nothing in../codepuketouched. - Partial / blocked: Discovered work #2 (interface factory registration) left open — it is a decoder behaviour question, not a docs one.
- Next session: decide #2, then decide whether README.md should point at the published docs pages rather than duplicating them.
- Worked on: Discovered work #2 — interface-value factory registration.
- Completed: decoder fix (
src/decoder.ts), 4 regression tests, README "Which name to register" rewrite,docs/04-go-interop.mdupdate. - Verified:
bun test309 pass / 0 fail;go_verify17 pass (Go on PATH);bunx tsc --noEmitclean apart from the two pre-existingtests/encoder.test.tserrors (Discovered work #3). - New, needs a decision:
../codepuke/content/manifest.jsonchanged under us.gobspecthas since landed 19 snippet topics, so the "no ids to reuse" finding that drove this session's id choices is now stale. Four of our 16 ids match gobspect exactly (encode-struct,decode-struct,encode-slice,encode-map); several are near-misses that must be renamed to gobspect's spelling, and its fixture data differs from ours. See "Discovered work" #5.
- Worked on: cross-port snippet data harmonization (Discovered work #5 → #6).
- Completed: merged
stream-encode/stream-decodeintostream-multiple-valueson the canonical Point stream;end-of-streammoved to the same stream;define-schematrimmed to Point only; new topicsencode-scalarsandzero-fields-omitted; docs pages 02 and 04 updated; sections 4/5/6 of this file updated. Working tree left dirty on purpose — the maintainer commits. - Verified:
bun test310 pass / 0 fail; 17snippet:start/ 17snippet:end; no duplicate topic ids; defined topic set equals the docs-referenced set; nostream-encode/stream-decodeid remains inexamples/ordocs/(the old ids survive only as history in this file). - Next session: nothing new — codepuke-side sync (
sources.json+cmd/sync) still pending, as recorded in section 5.