Seed legacy reactions in migration and serve them from the v2 read path (rebuild RR-2)#139
Merged
Conversation
…th (rebuild RR-2)
Completes the reactions read model: the legacy messages.reactions JSON now
survives cutover (9,441 rows from 5,455 reaction-bearing messages on the
operator's real store) and v2-primary serves reactions in the same JSON the
web UI already parses.
Seeding (migration transform):
- Parses the legacy per-message reactions JSON
([{"emoji","count","actors"}]) and seeds rows through RR-1's first-wins
SeedReaction contract: seeds lose to any live write and never touch
reaction_snapshot_fences (the first live Google snapshot establishes each
fence).
- Actor tokens normalize to EXACTLY the identity forms live ingest derives,
so a post-cutover frame lands on the seeded primary key instead of
minting a duplicate: WhatsApp canonical JIDs normalize to E164 the same
way the live decoder does; Signal actors split resolved-E164 vs raw-ACI,
matching live's resolve-or-raw capture behavior; self-reactions are
detected against per-platform own numbers derived from is_from_me rows
(guarding blank and non-E164 garbage senders) and seed as
key "self" / is_self=1 / label "me", byte-identical to the live worker's
convention. Identity-resolved rows carry the live label convention.
Signal self-reactions whose actor is an ACI are an accepted, documented
corner: the legacy store cannot resolve own-ACI, and delta platforms only
re-deliver on new activity for that (message, reactor).
- Convergence is pinned end-to-end per platform: migrate a fixture with
real actor forms, then deliver a live reaction frame through the actual
decoder and ingest worker, asserting exactly one row with live-shaped
fields.
Accounting (drop-with-count discipline):
- Row-grained reconciliation: plannable rows vs seeded rows, with explicit
counters for actorless-count collapse, actor-count surplus, in-message
duplicates, and reactions on dropped parent messages. Validation enforces
messages_with_reactions == seeded + malformed + unmappable +
dropped_with_parent exactly, and reactions row count == rows seeded.
- Malformed reaction JSON degrades per message without blocking migration.
Serving (v2read):
- Message DTOs populate Reactions from one batched ReactionsForMessages
call per page (no N+1), active rows only, serialized byte-compatibly with
the legacy read path including legacy insertion order (seeded rows keep
order via per-entry timestamp offsets; first-wins semantics are
repo-level, so the offsets cannot outrank live writes). Empty set serves
the legacy empty representation.
Real-store rehearsal (third run): 9,441/9,441 rows reconciled at ratio 1,
84 self-reactions seeded as self, 13 dropped-with-parent attributed
exactly, fences 0, integrity gates green, source untouched.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rebuild RR-2 — seed legacy reactions in migration + serve from v2read
Completes the reactions read model behind cutover. With RR-1 (#138) writing live reactions and this PR seeding + serving historical ones, the top behavioral gap on the cutover list is closed: 9,441 reaction rows from 5,455 reaction-bearing messages on the real store survive migration and render in v2-primary.
What's in it
messages.reactionsJSON ([{"emoji","count","actors"}]— ground-truthed against all three platform writers + the UI parser) seeds through RR-1's first-winsSeedReactioncontract. Seeds lose to any live write; no fence rows (first live Google snapshot establishes each fence).is_from_merows →self/is_self=1/"me", field-identical to the live worker. Without this, every seeded WhatsApp reaction (2,289 messages on the real store, incl. 84 self) would duplicate or go stale on the first post-cutover delta.messages_with_reactions == seeded + malformed + unmappable + dropped_with_parentexactly.ReactionsForMessagesper page (no N+1), active rows only, byte-compatible with the legacy read path including insertion order; conversation page, search, and message lists all covered.Review
Opus adversarial review: FIX-FIRST → all six findings fixed. The convergence discriminators now drive the actual platform decoder → ingest worker (not hand-built entries) for WhatsApp and Signal, migrate-then-live-apply, asserting exactly one row with live-shaped fields; each fails on the pre-fix key derivation (e.g. WA contact
24ea023f…→b8222756…, WA selff6d6c1ec…→self). Served-JSON byte-parity pins non-lexicographic insertion order and the documented collapse/surplus degradations.Real-store rehearsal (third run, this exact build)
self(was the proven duplicate/stuck-reaction bug — those rows previously seeded as ordinary identities keyed by Max's own JID).Verification
Independent full
GOWORK=off go test -race ./...outside the sandbox: 31/31 packages, exit 0 (three runs: post-build, post-fixes, post-discriminators).🤖 Generated with Claude Code