Files
Julian Knutsen 4e7cc88180 feat(journalops): the mutation journal becomes the third facade namespace (#5552)
* feat(journalops): the mutation journal becomes the third facade namespace

The durable mutation journal's read side is now a role. `journalops` is a
leaf package beside `issueops` and `memoryops` holding Row, Page,
TruncatedError/TruncatedCode and `Journal`, whose doc comment is the
specification: since-exclusive, seq-ascending, limit 0 uncapped with the
wire's cap named as the handler's, a head that comes with its rows and never
decreases, a caught-up checkpoint, and a typed truncation naming a resumable
window.

NOTHING CHANGES BEHAVIOR. The four names in `internal/storage` become
ALIASES of the leaf — `type EventsJournalCursor = journalops.Journal` and its
three siblings — so every implementation, every errors.As site and every
caller compiles unchanged. The alias direction is what avoids the import
cycle: the leaf imports context and fmt and cannot name internal/storage, so
the canon goes down and the alias goes up. Outside storage.go and the
conformance tier, the only non-test edits in this change are COMMENTS.

The role brings the apparatus a role owes:

- backend/conformance/journal_contract.go, six cases over a fixture carrying
  the role, the activation switch, a prune, and a mutation kit that is
  exhaustive against the engine's closed op vocabulary.
- Wirings on all three legs (dolt, embeddeddolt, uow), so #5499's
  entrypoint-scoped lock passes with every leg running every case.
- journalops added to the conformance census's facadePackages. That is the
  #5459 demand side, and the role that needed it: `Journal` is handed out by
  NO accessor — a front door type-asserts for it — so only a source parse can
  see it. issueops.Importer is the precedent.

EventsJournalAccessor and EventsJournalConfigurer stay in internal/storage.
Retention and activation are operator surface, and a server documented to
publish the journal and never retain it must not hold a delete.

Net deletion on the bespoke side: TestEventsJournalPageReportsTheHead is
subsumed by the contract and is gone. TestEventsJournalRestartAndRetentionBoundary
stays, because it observes ReadEventsInTx — the accessor's second read body,
deliberately not on the role — and a mutation of one is invisible to the other.

Mutation-measured, per case, on the embedded leg: an inclusive checkpoint, a
head derived from the page, an untyped truncation, a head taken from MAX(seq)
of surviving rows, and a dropped engine-only op each redden exactly the arm
named for them, and a fixture whose activation hook is a no-op reddens all
six. Reading the head BEFORE the rows leaves all six green — that promise is
declared unpinnable in the contract header rather than faked.

Refs ga-fvya1, ga-2yaqp.4.

* docs(journalops): fix three comment-precision findings from review

All comments, plus one test-only compile-time pin block.

1. journal_facade_test.go told a misassembled causal story: it used the
   CURSOR de-aliasing as its example and then attributed the breakage to
   errors.As. Those are two different failures. Measured, both:
   de-aliasing the interface builds the WHOLE TREE clean and breaks nothing
   behavioral — what it costs is the claim that one role has one name — while
   the error's dangerous shape is not the bare defined type (which loses
   Error() and fails to build) but the COPY-PASTE TWIN, which compiles in
   silence and stops crossing errors.As for everything holding the leaf's
   spelling. Rewritten to say that straight.

2. The seq commit-order unpinnable declaration cited "the dolt package's own
   journal tests" for the server-mode half. Those are single-threaded. The
   actual pin is internal/storage/domain/db/journal_seq_test.go —
   TestEventsJournal_CommitOrderedGaplessSeq drives two OVERLAPPING
   transactions on independent connections, and
   TestEventsJournal_ConcurrentWritersGaplessNoDup a racing batch. Cited
   correctly, and the single-threaded ones named as NOT the pin.

3. EveryMutationKindLandsARow's runs-last ordering is load-bearing but held
   by convention: a reorder compiles, passes, and silently gives up the only
   coverage that a journal keeps recording after a full prune. It was stated
   in the dispatch table and the three leg wirings; it is now stated at the
   case itself, where somebody editing a leg file is actually looking.

And the compile-time half of the alias pin, which fires before any test runs:
a pointer conversion per name. The pointer form is not decoration — for the
three structs a plain assignment would do, but for the INTERFACE it would be
blind, since interface-to-interface assignment goes by method set and a
de-aliased cursor still accepts a journalops.Journal. Watched fail: the
de-aliased interface, which nothing else in the tree catches.

---------

Co-authored-by: Review Sim <ci@beads.test>
2026-08-10 10:58:05 -07:00

64 lines
3.1 KiB
Go

package journalops
import "fmt"
// TruncatedCode is the stable machine-readable code a consumer matches on when
// its checkpoint has fallen below the retained journal window.
//
// It is the wire spelling of *TruncatedError, for the front doors that cannot
// hand back a Go type: the HTTP problem document carries it, and a client that
// speaks JSON dispatches on this string exactly as an in-process caller
// dispatches on errors.As. Both name one condition, which is why the string
// lives beside the error rather than in a handler.
const TruncatedCode = "events_journal_truncated"
// TruncatedError reports that a sequential read cannot resume from the caller's
// checkpoint because the records it needs next were pruned.
//
// Without it, `WHERE seq > since` cannot distinguish "nothing new" from "your
// prefix is gone": a consumer resuming past a prune would either see an empty
// success and stall forever, or silently skip to the current floor and lose
// every record in between. Both are silent data loss, so the read fails loudly
// instead and hands back the window it can actually serve.
//
// Floor is the lowest seq still retained, or Head+1 when the journal holds no
// rows at all. Head is the highest seq the counter has ever assigned; it never
// decreases under a prune, so Floor > Head means "fully pruned, caught up to
// Head". A consumer that receives this must decide explicitly — resume from
// Floor-1 and accept the gap, or rebuild from scratch — and the engine does not
// decide for it.
//
// It is returned as a POINTER, which is what errors.As matches against, and
// implementations return it rather than wrapping it in a message of their own
// making. A caller reads the FIELDS; the string below is for humans.
type TruncatedError struct {
// Since is the checkpoint the reported window begins after, which is the
// caller's own checkpoint in every case except one.
//
// When the rows the read can serve start above the caller's checkpoint —
// the ordinary "your prefix was pruned" case — Since IS that checkpoint and
// Floor is the first row still retained.
//
// When the prefix is intact but the retained window has an interior hole
// (a restored or hand-edited table; bd's own prune cannot produce one),
// Since is instead the last seq the engine could serve contiguously from
// the caller's checkpoint, and Floor is where the next intact island
// starts. A batch with BOTH shapes reports the prefix one; the interior
// hole is reported on the next read, once the caller has resumed past the
// first. Every gap is surfaced, one resume at a time.
//
// Since therefore never reports a value BELOW what the caller presented, so
// echoing it back can never make a consumer re-read records it already has.
Since int64
// Floor is the lowest retained seq (Head+1 when nothing is retained).
Floor int64
// Head is the highest seq ever assigned.
Head int64
}
func (e *TruncatedError) Error() string {
return fmt.Sprintf(
"events journal truncated: checkpoint %d is below the retained window [%d..%d]; records %d..%d were pruned",
e.Since, e.Floor, e.Head, e.Since+1, e.Floor-1)
}