Skip to content

refactor(contracts): extract the broker stores' shared persistence, not their model (#497) - #518

Merged
pofallon merged 1 commit into
mainfrom
refactor/497-json-record-store
Sep 22, 2026
Merged

pofallon merged 1 commit into
mainfrom
refactor/497-json-record-store

Conversation

@pofallon

Copy link
Copy Markdown
Contributor

Refs #497 — deliberately not closing it. See "What this leaves open" below.

Why this isn't what #497 asked for

#497 sets the trigger for generalising the broker-state model at a third store. There are two, and since I filed it they have diverged further (#502 added retention pruning, #509/#510 made a second service depend on the restore store's locking). They are genuinely different models:

BackupBrokerStateStore RestoreBrokerStateStore
shape one record per host keyed by request id
why a capture has one open operation at a time several restores can be open at once
update() bare read-modify-write serialising lock + retention pruning
also has transition (compare-and-set), get, pendingFor, retainWhileWaiting

Forcing a shared base on two data points would be guessing at the boundary, so this doesn't.

What it does instead

read() and write() are character-for-character identical between the two, apart from a log string and the type. That is extracted into a composed JsonRecordStore<T> — composition, not inheritance, so each store keeps its own update(), locking, expiry and queries, and a third store can take the mechanism without inheriting a shape it may not want.

The two policies are why this is worth centralising, and both are choices about what must not happen:

  • A read fails open to {}. A missing file is the first-run case; an unreadable one must not wedge the broker. The record is bookkeeping, and refusing to proceed without it would turn a corrupt file into an outage. Each caller's expiry rule then decides what empty means — isPrepareExpired treats an unparseable timestamp as expired precisely so failing open here cannot leave a dump on disk forever.
  • A write warns rather than throws. Losing the record costs bookkeeping, never the correctness of the operation a provider is mid-way through announcing.

Those were previously asserted only indirectly, via two stores that happened to implement them the same way. They now have direct tests — including the corrupt-file and failing-storage paths, which the indirect coverage never reached.

Honest accounting

This removes ~40 lines of duplicated logic and adds a 75-line module, most of it the explanation above. Raw line count goes up. The gain is that the two policies have one home and one test, not that there is less code. If that trade isn't worth it to you, this is a clean revert — the model generalisation in #497 is unaffected either way.

What this leaves open

#497 stays open for the model question, with its trigger intact: a third brokered store is still the signal to look at whether the single-record and keyed-by-id shapes want a common abstraction. This change makes that cheaper to do (the mechanism is already shared) without pre-judging it.

Verification

Server 1392 → 1397 (+5 direct tests for the extracted policies). Both stores' own suites unchanged and green (restore-broker.test.ts 22/22). Full gate green: typecheck ×2, lint, test (1397 server / 377 web / 286 CLI), build.

Also removes an orphaned private logger field the extraction left in the restore store — unused private fields don't trip the linter, so it would have sat there.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh

…ot their model (#497)

#497 set the trigger for generalising the brokered-contract state-store *model*
at a third store. There are two, and they are genuinely different models:
`BackupBrokerStateStore` holds one record per host because a capture has one
open operation at a time, while `RestoreBrokerStateStore` is keyed by request id
with a serialising lock and retention pruning. Two data points is not enough to
know where that boundary belongs, so this does not touch it.

What IS identical between them, character for character apart from a log string,
is `read()` and `write()`. That is what this extracts, into a composed
`JsonRecordStore<T>` — not a base class. Each store keeps its own `update()`,
locking, expiry and query methods entirely to itself, so nothing about the model
is prejudged and a third store gets the mechanism for free without inheriting a
shape it may not want.

The two policies are the reason this is worth having in one place, and both are
choices about what must NOT happen:

  - a read fails open to `{}`, because a missing file is the first-run case and
    an unreadable one must not wedge the broker — the record is bookkeeping, and
    refusing to proceed without it would turn a corrupt file into an outage.
    Each caller's own expiry rule then decides what empty means;
    `isPrepareExpired` reads an unparseable timestamp as expired precisely so
    that failing open here cannot leave a dump on disk forever;
  - a write warns rather than throws, because losing the record costs
    bookkeeping and never the correctness of the operation a provider is
    mid-way through announcing.

Those were previously asserted only indirectly, through the two stores that
happened to implement them identically. They now have direct tests, including
the corrupt-file and failing-storage paths that the indirect coverage never
reached.

Honest accounting: this removes about 40 lines of duplicated logic and adds a
75-line module, most of it the explanation above, so raw line count goes UP. The
gain is that the two policies have one home and one test, not that there is less
code.

Also removes an orphaned `private logger` field the extraction left behind in
the restore store — unused private fields do not trip the linter.

Refs #497

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh
@pofallon
pofallon merged commit 0d99789 into main Sep 22, 2026
3 checks passed
@pofallon
pofallon deleted the refactor/497-json-record-store branch September 22, 2026 03:38
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.

1 participant