Skip to content

feat(go-core): M1 session and message store over the existing SQLite - #1133

Merged
ElioNeto merged 44 commits into
developfrom
feat/m1-go-session-store
Sep 10, 2026
Merged

feat(go-core): M1 session and message store over the existing SQLite#1133
ElioNeto merged 44 commits into
developfrom
feat/m1-go-session-store

Conversation

@ElioNeto

@ElioNeto ElioNeto commented Sep 9, 2026

Copy link
Copy Markdown
Owner

What

Milestone M1 of the plan to read and continue Claude Code and Codex sessions from teamcode: go-core becomes able to own session, message, part and todo persistence on the same opencode.db and drizzle schema the TypeScript side uses today, without changing TypeScript behaviour. Spec and plan are in docs/rewrite/ (also on #1132).

  • internal/store: opens the file with the TS pragmas and path rules (TEAMCODE_DB, channel, XDG), one serialized writer with BEGIN IMMEDIATE, busy_timeout, no WAL recovery on the Go side; schema check = migration floor (__drizzle_migrations) plus PRAGMA table_info column shape, degraded mode when behind.
  • internal/ident: TS id scheme (48-bit ms*4096+counter, descending sessions, ascending messages/parts/events), pinned byte-for-byte against an 800-id fixture generated by packages/teamcode/src/id/id.ts.
  • internal/sessiondb: sessions, messages, parts, todos, fork; write rules copied from the TS projectors (usage deltas, time_updated never bumped implicitly, null clears / absent keeps, clock guard inside the transaction, late writes swallowed); keyset pagination with the TS cursor.
  • internal/eventlog: per-session seq, replay ring, lag signalling, no silent drops; GET /v1/events SSE with Last-Event-ID.
  • cmd/server: /v1/session/* mirroring Session.Service with TS wire shapes; 400/404/503 mapping; legacy /session/* untouched.
  • Tests: Go unit tests over a DB built by replaying the 22 TS migrations; TS-vs-Go parity by table dump and a 1,000-write two-process concurrency test (packages/teamcode/test/gocore/, skipped without GO_CORE_BINARY); 50k-message read budget (437 ms of 5 s).
  • CI: new go-core job (gofmt, vet, golangci-lint, go test -race, build, parity); legacy go-core code formatted and lint-clean so the job passes.
  • Removes the unused JSON PersistentStore; updates ARCHITECTURE.md, PERFORMANCE_PLAN.md, CONTRACTS.md.

Review trail

Fifteen tasks, each implemented and reviewed by separate agents, then a whole-branch review and one fix wave (13 commits). Known follow-ups left open on purpose, none blocking M2:

  • v1.Close() in main.go runs after server.Shutdown, but main returns as soon as Serve returns ErrServerClosed, so the PASSIVE checkpoint at exit does not run (spec §3.1). WAL auto-checkpoints; the TS side still owns recovery.
  • Parity dump compares timestamps by presence and order, not by clock; it cannot regress-detect the time_updated fix.
  • eventlog.Drop does not disconnect SSE clients of a deleted session; a publish racing the delete can recreate the ring.
  • Cross-session upsert with a caller id publishes into the request session while the payload carries the stored owner (matches TS data-wise).
  • Schema column matching is case-sensitive; model dropped on empty-string id where TS keeps it; PATCH with a non-object body answers 500.

Verification

  • go build ./... && go vet ./... && go test -count=1 ./... green; golangci-lint run ./... 0 issues on LF content.
  • bun x tsc --noEmit clean; bun test test/gocore/ 2 pass with the built binary, 5/5 consecutive parity runs; test/session/ unchanged.
  • -race was not run locally (no C compiler on the dev box); this CI run is its first execution.

🤖 Generated with Claude Code

elio-neto and others added 30 commits September 9, 2026 16:27
Identical to the documents on docs/m1-go-session-store-spec, so the
implementation subagents can read their briefs from this checkout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…path rules

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ions in tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ainst a TS fixture

Adds internal/ident, mirroring packages/teamcode/src/id/id.ts byte for
byte: 6 big-endian bytes of ms*0x1000 + counter, bitwise NOT for
descending ids, 14 base62 characters. script/gen-id-fixture.ts drives
the real TS create/timestamp functions to produce
go-core/internal/ident/testdata/ids-from-ts.json, which the Go tests
check ordering and timestamp decoding against.

The fixture generator's base timestamp is a small millisecond value
(1_000_000_000) rather than a present-day one. The TS encoding packs
ms*0x1000 into 6 bytes (48 bits), so any ms value at or above 2^36
(~1972-03-06) overflows and timestamp() no longer round-trips to the
original ms — reproducible against the unmodified TS source with
Date.now(). Both sides still sort correctly for values sharing the
same 2^36 band, which covers all realistic near-term comparisons, but
an exact-equality fixture needs a base under that ceiling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The fixture generator restores base = Date.UTC(2026, 8, 9, 12, 0, 0) so
the truncation the TS scheme actually exhibits at real timestamps is
exercised, instead of being sidestepped with a base date small enough
to avoid the 48-bit overflow. Each ascending row now stores both the
raw ms passed to create() and the TS-computed decoded value
(timestamp(row.id), null for descending rows).

ident_test.go: TestTimestampMatchesTS compares Timestamp(row.ID)
against row.Decoded rather than row.Timestamp. Ordering assertions are
untouched, since they never call Timestamp() and only compare the raw
stored ms values, which is valid because every fixture id falls inside
one 2^36 ms wrap band. Adds TestTimestampTruncatesLikeTS, verified
against the real, unmodified TS source first, asserting
Timestamp(NewAt(...)) == ms & ((1<<36)-1).

TestGoIdsInterleaveWithTSIds also calls Timestamp() on fixture and
freshly generated ids and previously compared the result against the
untruncated mid.Timestamp+1; with real 2026 timestamps this comparison
mixes a truncated value with an untruncated one and fails on every
run. Fixed by comparing against (mid.Timestamp+1) truncated the same
way, which is what the ids actually decode to.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ixture

TestTimestampMatchesTS and the ordering tests only covered ascending
ids; nothing checked that a Go-generated descending id (used for
session ids) produces the same 12 hex time bytes as the TS reference,
and Timestamp() cannot decode descending ids at all, leaving the
session-id path unguarded.

Adds TestDescendingHexMatchesTS: for every fixture "ses" row (the
first NewAt call issued for that millisecond in the generator, TS
counter 1), calls NewAt(PrefixSession, true, row.Timestamp) and
compares the 12 hex chars after the prefix against the fixture id's.

Adds TestAscendingHexMatchesTS: for every fixture "msg" row (TS
counter 2, since "ses" was generated first for that millisecond),
calls NewAt(PrefixMessage, false, row.Timestamp) twice and compares
the second call's hex chars against the fixture id's, reproducing the
same counter progression TS used when generating the fixture.

Both tests iterate the fixture in generation order and rely on
consecutive rows always landing on a different millisecond so Go's
package-level counter resets the same way TS's did; neither is marked
parallel, and a shuffled, repeated (-shuffle=on -count=3) run confirms
there is no hidden ordering dependency on the other tests in the file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…set pagination

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…scoped by message

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…known replay positions

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ded mode

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ull patch mapping

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds a harness that spawns the go-core binary against a real SQLite
file, runs one scenario (session create/rename/fork, message and part
upsert/removal, todo replace, session delete) through both the TS
Session.Service/Todo.Service and the equivalent /v1/session HTTP
calls, and diffs normalized session/message/part/todo table dumps.
Skips instead of failing when GO_CORE_BINARY is unset so
`bun turbo test:ci --filter=teamcode` keeps working without Go.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The parity test overrides TEAMCODE_DB with a real file path so go-core
can open a database file the preload's in-memory default can't
provide. Without restoring it afterward, the override leaked into
every test file loaded later in the same Bun process. Save the
pre-override value and restore it in an afterAll registered inside the
describe block.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ites

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… SQLite store

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fix all gofmt and golangci-lint findings in pre-existing go-core files
(errcheck, staticcheck, unused) so the Task 13 CI job's Format and Lint
steps pass. Behaviour is unchanged: unchecked errors are logged or
discarded with `_ =` matching the existing convention in main.go,
unused identifiers are removed, and the staticcheck rewrite applies
De Morgan's law without altering the parsed result.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The rebase onto develop resolved a conflict in main.go by taking the
develop side of the file, which removed the registerV1Routes(mux) call
added with the /v1/session handlers. golangci-lint flagged the function
as unused; the call is back where the handlers commit placed it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
writeError built the body with a format string, so a message carrying a
double quote produced a payload the client could not parse.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A database migrated past the known floor could still miss a column the
store reads, which surfaced as a query error per request instead of the
degraded mode.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The insert copied time_created into time_updated, while the TypeScript
projector lets the column default to the insert clock. The parity dump
now ranks timestamps instead of dropping them, so the two runtimes are
compared on ordering.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Subscribing to an unknown session allocated a ring that nothing ever
freed, so a client polling arbitrary ids grew the log without limit.
Deleting a session now drops its ring and closes its subscribers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The filter normalized to forward slashes, which never matched the
backslash paths the TypeScript side stores on Windows, and a relative
directory was compared unresolved.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
elio-neto and others added 14 commits September 9, 2026 17:07
A wrong id prefix, a malformed cursor, a body that is not a JSON object,
a null on a non-nullable patch path and a create against a missing
project all surfaced as 500. The store now marks them as invalid input
and the route layer maps that to 400.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The database handles stayed open after the HTTP server stopped, so the
WAL checkpoint in Close never ran on exit. newV1State loses its error
return, which was always nil.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An upsert of an existing id echoed the request path instead of the row,
so a part re-sent under another message reported the wrong messageID
while the database kept the original.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A write for a deleted session answered 200 with an "ignored" body that
no client decodes, so the caller had to inspect the payload to learn
nothing was stored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The wire object kept a model shape that the TypeScript fromRow discards,
so M2 would decode a model the TypeScript side never returns.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rite

OpenWithBusyTimeout becomes exported so the server test can lower the
timeout through the openStore seam and observe 503 busy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The spec listed a fork body without directory and version, said nothing
about the archived filter M2 has to pass, and CONTRACTS.md did not
mention the /v1 surface at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… not by clock

Ranking every timestamp compared millisecond ties between two runtimes
that do not derive the values from the same input. The dump now compares
message time_created exactly, part time_created by row order, the
remaining time columns by presence plus time_updated >= time_created,
and keeps data.time as stored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Replace the runtime-assembled PRAGMA, WHERE, SET and IN fragments with
literal statements bound by parameters: a table-to-query map for the
schema probe, one all-flags list query, one statement per patchable
column and json_each for the part hydration.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Break patchColumns into scalar, json, nested and token helpers, move the
publisher and subscriber goroutine bodies of the event log concurrency
test into named helpers, name the step-finish usage probe structs and
drop a redundant binding in the v1 handler test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ture writer

Replace the assertion-less skip placeholders with describe.skipIf so the
shared-database override only runs when the suite runs, split the table
dump into row and timestamp helpers, assert lengths with toHaveLength,
and reject a fixture output path outside the repository root.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The instrumented read takes 21.5 s against a 5 s budget, so the budget
test now skips under -short or the race build tag and CI enforces it in
its own uninstrumented step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sonar reads any concatenation in query text as dynamic SQL, so the
shared column list is written out inside selectSessionByIDQuery and
listSessionsQuery and the sessionColumns constant is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Sep 9, 2026

Copy link
Copy Markdown

@ElioNeto
ElioNeto marked this pull request as ready for review September 9, 2026 21:25
@ElioNeto
ElioNeto merged commit a67f464 into develop Sep 10, 2026
5 checks passed
@ElioNeto
ElioNeto deleted the feat/m1-go-session-store branch September 10, 2026 11:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants