The Fold
DraftHouse had a local file-based parser for deriving a review summary from channel history. Claudony wants a digest panel from the work channel. The pattern is always the same: read the messages, fold to state, render. I wanted one abstraction instead of two diverging local implementations.
ChannelProjection<S> is a pure left-fold: identity() returns a fresh empty
state, apply(S, MessageView) folds one message and returns the next state.
The service reads the channel’s message history and folds it. Simple enough.
The design review earned its keep
Three review rounds substantially changed what shipped.
The first change was removing channelType(). I’d kept it as a reserved hook for
a future CDI registry that selects projections by channel name — a defensive move
that costs nothing. The review disagreed: three abstract methods means no
@FunctionalInterface; the String return type is wrong if the registry later needs
a Pattern or Predicate; no default means every implementation must supply a no-op
String. Removing it now is zero cost. Adding it back later via a @ChannelBound
qualifier annotation doesn’t break existing implementations. Gone.
The second change was ProjectionRenderer<S>. I’d put it in api/spi/ as a named
@FunctionalInterface for “turns state into a string.” The review pointed out it
isn’t an SPI if the service never calls it — it’s documentation stored as code.
Function<S, String> from the JDK already does this job, and it’s named, typed,
and composable. Gone.
The third change was the most structural. The original API returned raw <S>. That’s
clean but breaks incremental re-projection: after a full fold, you have state but no
cursor. To resume from where you left off, you’d need a separate findLastMessage()
call — which races with new arrivals. The review flagged it. We wrapped the return in
ProjectionResult<S>(S state, Long lastMessageId) where lastMessageId is null for
an empty channel. Pass it back to the incremental overload and it folds only messages
since the cursor. No race.
Six bugs total surfaced across the reviews, including:
- Silent wrong results if
previous.isEmpty()butprevious.state()was non-null — the incremental overload now enforcesprojection.identity()whenever the cursor is null scope.descending(true)produces a right-fold masquerading as a left-fold and silently corrupts state-machine projections — rejected at the validation gate
The DTO constraint
ChannelProjection must live in api/spi/ so consumers depend only on the lightweight
api module. That means the fold function can’t take Message — it’s a JPA entity in
runtime/. We introduced MessageView, a pure-Java record in api/message/ that maps
the consumer-relevant fields. The entity’s messageType field becomes type on the DTO
— consistent with DispatchResult.type, worth the rename.
QhorusEntityMapper gains toMessageView(Message) alongside the existing
toChannelDetail() and toTimelineEntry() methods. Same pattern.
The reactive gap
The reactive service uses ReactiveMessageStore.stream(MessageQuery) returning
Multi<Message>. The fold runs via Mutiny’s collect().in() with a private mutable
accumulator class — collect().in() takes BiConsumer (void mutation), not BiFunction
(returns new state), so the pure fold function can’t be used as the accumulator directly.
PanacheQuery.stream() returning Multi<T> doesn’t exist in Quarkus 3.32 Hibernate
Reactive Panache. We documented the gap honestly: stream() on ReactiveJpaMessageStore
wraps scan().toMulti() — materialises the full list but gives the right shape. When
Hibernate Reactive exposes cursor streaming, only the store implementation changes.
What shipped
Both issues — #230 (base SPI) and #231 (incremental cursor) — went in one branch.
The fold logic itself is trivially testable without any framework: construct a
ChannelProjection<VoteState>, call identity(), fold MessageView records by hand,
assert on counts. The full @QuarkusTest integration suite uses messageStore.put()
directly rather than MessageService.dispatch() — no ledger overhead, projection
behaviour only.
DraftHouse (#31) and Claudony can now drop their local implementations and implement the SPI. Per-channel InboundNormaliser (#216) is next.