Skip to content

ledger: a pure record parser under #readSegments (#117) - #131

Merged
shovon merged 1 commit into
mainfrom
worktree-issue-117
Oct 28, 2026
Merged

shovon merged 1 commit into
mainfrom
worktree-issue-117

Conversation

@shovon

@shovon shovon commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Closes #117.

What changed

The record format and its reading rules (packages/ledger/records.ts, new):

  • encode, decode, LogEvent and Hlc move here from ledger.ts, unchanged.
  • readRecords(buf, start, last, suspectAfter) is the per-segment loop that was inside #readSegments, as a pure fold over the segment's newline-ended lines. It returns:
    • accepted: the records taken, in order
    • last: the seq of the last one taken
    • end: just past the last good record, taken or a duplicate, where the next read resumes; undefined if there was none
    • suspect: the latest hlc.l among taken records dated past suspectAfter
    • stall: { at, why }, with at as an offset in the segment
  • It reads no file and no clock: suspectAfter is handed in.
  • records.ts is not exported from the package (exports is still ./ledger.ts). ledger.ts re-exports encode, LogEvent and Hlc, so the public surface doesn't change.

#readSegments (ledger.ts) keeps the directory listing, opening and reading each segment, and moving on to the next. For each segment it calls readRecords and applies the result to the cursor: last, suspect, and seg/offset when end is set. The cursor still moves into a new segment only once one of its records is accepted or skipped, as before.

multi-writer.md: one paragraph under "Reading foreign logs" says which rows of the table are readRecords, and that a rule changes in both places together.

For the reviewer

  • The format, and every rule in the spec's table, are unchanged. The loop moved as it was. The jump message, the offsets, and the "intact records follow it" and "torn tail" reasons are byte-for-byte the same.
  • Two small differences:
    • The too-far-ahead check uses the scan's own wall clock (at.wall, already taken once per scan) instead of calling Date.now() per record. The threshold can differ by however long the read took, and every record in one scan is now judged against the same time.
    • Accepted records are pushed in a loop, not push(...accepted). A full 16 MiB segment can hold more records than a call accepts as arguments.
  • Found while testing, not changed here: with a mutation that folds past a seq gap, the existing ledger.test.ts hangs instead of failing, and had to be stopped. Some test waits on an iterator for a state that never comes and isn't bounded by its timeout. Worth a look on its own.

Tests

  • records.test.ts (12 tests, new), over records built with encode:
    • one per row of the table: a clean run, nothing to read, duplicate deliveries (alone and mixed in), a seq that jumps (after a record, and as the first one), a bad record with intact records after it, a checksum mismatch, a torn tail (bad, unterminated, several bad lines), offsets counted from a non-zero start, and records dated too far ahead (taken, the latest reported, a duplicate not counted)
    • a valid run with multi-byte text, cut at every byte: taken up to the last whole record, never past the cut, and stalled at the start of the split record exactly when one is split
    • the same run read up to each cut and then on from end with the last it gave: every record taken once, ending past the run
    • a bad line at every position in the run: everything before it taken, nothing after, with the matching reason
  • Mutation checks: folding past a gap fails the jump test, and skipping a bad line fails 4 tests.
  • The existing ledger.test.ts and open-writer.test.ts (29 tests) pass unchanged.
  • End to end, in a scratch instance, on a copy of a ledger an earlier build wrote:
    • it folded the same pages, with all 7 records read and the writer ok
    • with half of an eighth record appended, the writer stalled at byte 2376 (where the record starts) with "torn tail" and the pages unchanged
    • with the rest appended, it healed by itself: 8 records read, back to ok
  • npm test (213 tests across all workspaces), and the app's npm run typecheck (which covers the ledger) and npm run lint pass.

🤖 Generated with Claude Code

#readSegments read the files and decided what their bytes meant in one
loop: a torn or unterminated tail, a bad record with intact records after
it, a seq that jumps, a duplicate delivery, and a record dated too far
ahead. Those rules are what stop a half-synced folder from losing notes,
and they could only be tested by writing real files into a temp folder.

records.ts now holds the record format (encode, decode, LogEvent and Hlc,
moved unchanged) and readRecords(buf, start, last, suspectAfter): a pure
fold over a segment's lines that returns the records taken, the last seq,
the offset just past the last good record, the latest record dated past
suspectAfter, and the stall, if any, with its offset and reason. It is not
exported from the package; ledger.ts re-exports encode, LogEvent and Hlc,
so the public surface is the same.

#readSegments keeps the listing, the reads and the move to the next
segment, and applies what readRecords says to the cursor. The records
taken are pushed in a loop rather than spread, since a full segment can
hold more of them than a call takes arguments. The skew check uses the
scan's own wall clock instead of calling Date.now() again per record.

The format and every rule in multi-writer.md are unchanged, and the spec
now says which rows of its table are readRecords. The ledger's existing
tests pass unchanged. The new ones cover each row, a valid run cut at
every byte (taken up to the last whole record, stalling at the cut, never
past it), reading on from a cut taking every record once, and a bad line
at every position.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@shovon
shovon merged commit 9218cc0 into main Oct 28, 2026
1 check passed
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.

ledger: a pure record parser under #readSegments

1 participant