feat(entity,pekko): carry the journal coordinate into view processors #45

Merged
mph merged 3 commits from feature/projection-event-coordinate into release/0.1.x 2026-08-28 15:43:01 +00:00
Owner

Why

A projection handler receives the event's journal position — persistenceId and sequenceNr — and
throws it away before calling the view processor:

processor.process(transform(envelope.event))   // envelope.persistenceId / .sequenceNr discarded

A read model therefore has no stable, unique identity to key its records on. Downstream
(MEDECA-447) that shows up as two Mongo timeline collections that insert a duplicate document on
every projection replay, because the only identity available to them — userId|epochSecond — is
neither stable nor unique per event. Measured in a dev database: 597 journal events, 5933 documents.

The journal coordinate is the right identity: assigned at write time, unchanged by replay, unique
per event, and unaffected by event-schema changes. It was already in scope at the handler; it just
had nowhere to go.

What

  • EventCoordinate(persistenceId, sequenceNr) and CoordinatedViewProcessor — both in
    entity/shared, so cross-compiled view processors can implement them.
  • PekkoProjectionSupport.runSingleCoordinated plus CoordinatedProjectionHandler, which reads the
    coordinate off the envelope.
  • runSingle re-expressed over runSingleCoordinated via CoordinatedViewProcessor.lift, so there
    is one projection-wiring implementation instead of two near-identical bodies.

Additive by construction

No existing public signature changes and no existing call site needs editing.
ViewProcessor, ProjectionHandler and runSingle keep their exact shapes — ViewProcessor.scala
and ProjectionHandler's body are byte-for-byte identical to v0.1.22. No deprecations, no compat
shims. Existing consumers are unaffected whether or not they ever adopt the new entry point.

Base branch

Targets release/0.1.x, cut from v0.1.22 — not main. main has moved to 0.2.2-SNAPSHOT,
including a Moment rewrite (opaque type Moment = Instant in core became
opaque type Moment = Long in a new time module). The consuming project pins 0.1.22, and
absorbing the 0.2.x migration is a much larger piece of work than the bug this unblocks. This
lands as 0.1.23 off the maintenance line; a forward-port to main follows separately.

Tests

entity and pekko-persistence had no test sources; both now have a test module, wired to the
existing BaseTests pattern.

  • CoordinatedViewProcessorSpec — lift forwards the event and discards the coordinate, against a
    Ref-backed stand-in rather than a mock.
  • CoordinatedProjectionHandlerSpec — coordinateOf maps an envelope to its journal position:
    events sharing a persistenceId are told apart by sequenceNr, events sharing a sequenceNr by
    persistenceId, a replayed position yields an equal coordinate, and the payload does not affect
    identity. That last pair is exactly the replay-idempotence and data-loss behaviour downstream
    depends on.

Verified: ./mill __.test 2218/2218, ./mill __.checkFormat clean, entity.js.compile passes
(the shared placement really is cross-compiled). The tests were mutation-checked — pointing
coordinateOf at envelope.timestamp instead of sequenceNr fails 2 of them; reverted.

🤖 Generated with Claude Code

## Why A projection handler receives the event's journal position — `persistenceId` and `sequenceNr` — and throws it away before calling the view processor: ```scala processor.process(transform(envelope.event)) // envelope.persistenceId / .sequenceNr discarded ``` A read model therefore has no stable, unique identity to key its records on. Downstream (MEDECA-447) that shows up as two Mongo timeline collections that insert a duplicate document on every projection replay, because the only identity available to them — `userId|epochSecond` — is neither stable nor unique per event. Measured in a dev database: 597 journal events, 5933 documents. The journal coordinate is the right identity: assigned at write time, unchanged by replay, unique per event, and unaffected by event-schema changes. It was already in scope at the handler; it just had nowhere to go. ## What - `EventCoordinate(persistenceId, sequenceNr)` and `CoordinatedViewProcessor` — both in `entity/shared`, so cross-compiled view processors can implement them. - `PekkoProjectionSupport.runSingleCoordinated` plus `CoordinatedProjectionHandler`, which reads the coordinate off the envelope. - `runSingle` re-expressed over `runSingleCoordinated` via `CoordinatedViewProcessor.lift`, so there is one projection-wiring implementation instead of two near-identical bodies. ## Additive by construction No existing public signature changes and no existing call site needs editing. `ViewProcessor`, `ProjectionHandler` and `runSingle` keep their exact shapes — `ViewProcessor.scala` and `ProjectionHandler`'s body are byte-for-byte identical to `v0.1.22`. No deprecations, no compat shims. Existing consumers are unaffected whether or not they ever adopt the new entry point. ## Base branch Targets `release/0.1.x`, cut from `v0.1.22` — **not** `main`. `main` has moved to `0.2.2-SNAPSHOT`, including a `Moment` rewrite (`opaque type Moment = Instant` in `core` became `opaque type Moment = Long` in a new `time` module). The consuming project pins `0.1.22`, and absorbing the `0.2.x` migration is a much larger piece of work than the bug this unblocks. This lands as `0.1.23` off the maintenance line; a forward-port to `main` follows separately. ## Tests `entity` and `pekko-persistence` had no test sources; both now have a test module, wired to the existing `BaseTests` pattern. - `CoordinatedViewProcessorSpec` — `lift` forwards the event and discards the coordinate, against a `Ref`-backed stand-in rather than a mock. - `CoordinatedProjectionHandlerSpec` — `coordinateOf` maps an envelope to its journal position: events sharing a `persistenceId` are told apart by `sequenceNr`, events sharing a `sequenceNr` by `persistenceId`, a replayed position yields an equal coordinate, and the payload does not affect identity. That last pair is exactly the replay-idempotence and data-loss behaviour downstream depends on. Verified: `./mill __.test` 2218/2218, `./mill __.checkFormat` clean, `entity.js.compile` passes (the shared placement really is cross-compiled). The tests were mutation-checked — pointing `coordinateOf` at `envelope.timestamp` instead of `sequenceNr` fails 2 of them; reverted. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
A projection handler receives the event's journal position — persistenceId and
sequenceNr — and discards it before calling the view processor, which sees only
the event. A read model therefore has no stable, unique identity to key its
records on, and a replay of an already-processed event inserts a duplicate
instead of overwriting.

Add EventCoordinate and CoordinatedViewProcessor, and a runSingleCoordinated
entry point that passes the coordinate through. Both new types live in
entity/shared so cross-compiled view processors can implement them.

The change is additive: ViewProcessor, ProjectionHandler and runSingle keep
their exact shapes, and runSingle is re-expressed over runSingleCoordinated
through a lifting adapter, so there is one projection-wiring implementation
rather than two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ci: validate pull requests onto maintenance release branches
All checks were successful
CI / Check Formatting (pull_request) Successful in 1m3s
CI / Compile (pull_request) Successful in 2m12s
CI / Check Linting (pull_request) Successful in 2m11s
CI / Run Tests (pull_request) Successful in 1m57s
3ffbb636e8
A pull request onto a release/* branch ran no checks at all, because the
workflow listened for main only. The 0.1.x line carries releases, so it needs
the same compile, format, lint and test gate main gets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
refactor(pekko,entity): one projection-handler implementation, tested on both platforms
All checks were successful
CI / Check Formatting (pull_request) Successful in 1m16s
CI / Compile (pull_request) Successful in 2m33s
CI / Check Linting (pull_request) Successful in 2m39s
CI / Run Tests (pull_request) Successful in 2m34s
72ba96e4a2
Code review found four gaps in the journal-coordinate change.

ProjectionHandler carried its own copy of the DBIO/Unsafe bridging that
CoordinatedProjectionHandler already performs, so the two would drift; it now
delegates, keeping its public shape while leaving one implementation.

The handler's own process method had no test — only the pure coordinateOf helper
did — so a break in the wiring that composes them would compile and ship
silently. A stub Slick profile is enough to exercise it: the effect runs to a
Future before the returned DBIO is executed, so no database is opened.

The entity module ran its shared spec on the JVM only, though the types live in
shared precisely so cross-compiled view processors can implement them. The JS
variant now runs it too.

Formatting: checkFormat covers no test submodule, so both specs had drifted from
the house style.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mph merged commit 3e9edd2b87 into release/0.1.x 2026-08-28 15:43:01 +00:00
Sign in to join this conversation.
No description provided.