fix(timeline): a like was stored, then read back as zero everywhere - #811
Merged
Conversation
The write path was repaired in #806 and #809 and the like still did not appear. It was in timeline_likes, timeline_event_stats.like_count read 1, and the post page rendered an empty, unpressed heart. Nothing on the read side had ever populated these fields — in three separate places, all wrong in the same direction: eventQueries hardcoded `likesCount: 0, userLiked: false` under a comment saying the UI enriched them later. Nothing did. userFeeds called enrichEventsForDisplay and then OVERWROTE its output with `event.like_count` / `event.user_liked` — columns timeline_events has never had — so each value was replaced by `undefined || 0` on its way to the screen. transformEnrichedEventToDisplay, used by the followed-users feed and by search, set no reaction fields at all. It is synchronous and the enriched view carries no reaction columns, so it could not have. Three paths, one direction, which is why the counter never moved no matter which surface you looked at. Reaction state is now resolved by one function. enrichEventsForDisplay calls it for every path that goes through enrichment; the three that cannot — they build rows from an RPC or from the view — call attachReactionState after mapping. Making this each caller's responsibility is precisely what produced three callers that all got it wrong. Counts come from timeline_event_stats, which the reaction RPCs maintain and which is the only place they live. "Did I react" cannot come from there — it is per-reader — so it reads the membership tables. Batched: three queries per page regardless of post count, alongside the existing profile/project batching. Per-post lookups would be 60 round-trips for a 20-post feed. A failure degrades to zeros rather than failing the feed. A post without its counters is a small loss; a blank timeline is a total one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5
The post page never passed onDelete, so PostCard called a callback nobody had supplied. The delete itself succeeded — verified in production: is_deleted true, deleted_at set — and the page went on rendering the post until a reload replaced it with "This post doesn't exist". Deleting a reply had the same gap. The main post now navigates to the timeline, with replace rather than push so Back cannot return to a page with nothing to show. A deleted reply is removed from the thread at whatever depth it sat, and its parent's reply count follows it down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5
github-actions Bot
pushed a commit
that referenced
this pull request
Aug 28, 2026
* perf(timeline): opening a thread cost a round-trip per reply getReplies recursed: one query AND one enrichEventsForDisplay per node. Enrichment is several requests — profiles, projects, the reader's id, the three reaction tables — so a thread cost roughly six round-trips per reply. I introduced the reader's-id and reaction queries in #811 without noticing they sat inside a per-node recursion. Measured in production by opening one three-reply post: eight /auth/v1/user calls alone. Two changes, both structural: - getReplies fetches the tree a LEVEL at a time (one query per depth, regardless of width) and enriches the whole flat list ONCE, then assembles parent -> children from a map. - getCurrentUserId caches the in-flight PROMISE, so concurrent callers collapse onto one /auth/v1/user instead of racing to make several. A failure is never cached - caching "nobody is signed in" would outlive the blip and render the timeline as signed-out. The tests assert the COST, not just the tree. The recursive version built a perfectly correct tree, which is exactly why nothing caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5 * test(timeline): make the reply-tree mock honour the parent filter The first version of this mock returned rows by a level counter, ignoring what was asked for. That made "one query per level" and "one query per node" indistinguishable: a mutation swapping .in(parents) for .eq(parents[0]) stayed green, so the cost assertion was decorative. The mock now filters on parent_event_id, and a sibling-branch case pins that both branches are fetched in one query AND that neither loses its children. Both mutations are red against it and green after restore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5 --------- Co-authored-by: Georgy Butaev <41178744+g-but@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.
The write path was repaired in #806 and #809 and the like still did not appear. It was in
timeline_likes,timeline_event_stats.like_countread 1, and the post page rendered an empty, unpressed heart.Nothing on the read side had ever populated these fields — in three separate places, all wrong in the same direction:
eventQuerieslikesCount: 0, userLiked: falseunder a comment saying the UI enriched them later. Nothing did.userFeedsenrichEventsForDisplay, then overwrote its output withevent.like_count/event.user_liked— columnstimeline_eventshas never had — so each value becameundefined || 0.transformEnrichedEventToDisplayThat is why the counter never moved no matter which surface you looked at.
Reaction state is now resolved by one function.
enrichEventsForDisplaycalls it for every path that goes through enrichment; the three that cannot — they build rows from an RPC or from the view — callattachReactionStateafter mapping. Making this each caller's responsibility is precisely what produced three callers that all got it wrong.Counts come from
timeline_event_stats, the only place they live. "Did I react" is per-reader, so it reads the membership tables. Batched — three queries per page regardless of post count; per-post lookups would be 60 round-trips for a 20-post feed. A failure degrades to zeros rather than failing the feed.Also: deleting the post you are looking at left it on screen
Found while verifying #806's delete fix in production. The delete succeeded —
is_deletedtrue,deleted_atset — and the page went on rendering the post until a reload replaced it with "This post doesn't exist". The post page never passedonDelete, soPostCardcalled a callback nobody had supplied. Deleting a reply had the same gap.The main post now navigates to the timeline (
replace, so Back cannot return to a dead page). A deleted reply is removed from the thread at whatever depth it sat, and its parent's reply count follows it down.🤖 Generated with Claude Code
https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5