fix(timeline): likes, dislikes, replies, deleting and quoting were all dead - #806
Merged
Conversation
…l dead
Reported as separate complaints; nearly all of them were one cause.
Nineteen plpgsql functions write to a column, table or type that does not
exist. plpgsql only plans a statement when it RUNS, so each raised 42703
the moment a user triggered it and at no other time. Every one looked
healthy: defined, listed by \df, routed by PostgREST, called happily by
the app. Nine of them are the timeline.
like/unlike_timeline_event, dislike/undislike_timeline_event
end with `UPDATE timeline_events SET like_count = ...` under a
comment reading "if column exists". timeline_events has no count
columns and never has; the counts live in timeline_event_stats,
which the line above already updates correctly. Since a function
body is one transaction, the raise rolled back the INSERT three
lines earlier — so the like was written, then unwritten. The UI
applied its optimistic update, saw success:false and rolled back,
which is why the heart filled for ~150ms and emptied again.
add_timeline_comment, delete_timeline_comment
same line, same column, so replies never saved either.
soft_delete_timeline_event
checked `SELECT user_id FROM profiles WHERE role = 'admin'`.
profiles has neither column. And even fixed it would not have
worked: it set only deleted_at while every read path filters
is_deleted. Production confirms nothing ever took this path — 102
events have both flags, zero have deleted_at alone. Both are set
now, the reason goes in the deletion_reason COLUMN that exists,
and the phantom admin branch is gone rather than repaired: it
could never have run, so nothing real is lost, and a moderator
capability should be built deliberately against a table that
exists.
create_quote_reply
failed three separate ways, each hidden behind the last: assigned
text into a jsonb column, incremented a quote_count that does not
exist, named four columns notifications does not have, used a
`type` its CHECK constraint forbids, and never set the NOT NULL
subject_type — so it could not have succeeded on any input. It
also never wrote `description`, which is the field PostContent
renders, so a quote reply would have shown as an empty post.
Verified by clicking Like on orangecat.ch/timeline and reading the
response: HTTP 400, 42703. Each function was then rehearsed against
production inside a transaction and rolled back — like returns 1 and
lands in timeline_event_stats, delete returns true and the row is
genuinely hidden, a quote reply creates with a non-empty body and
notifies its author.
THE GATE, because nineteen is not a run of bad luck. plpgsql_check reads
every function body against the live schema and reports exactly this
class. Exposed as count_broken_plpgsql_functions() over PostgREST, in
the same shape as count_email_derived_usernames, and ratcheted in
check-data-invariants.mjs at the eleven that remain. Its limits are
stated where it is defined: it is static, so a NOT NULL left unset or a
CHECK violation still only fails at runtime — both of which
create_quote_reply also did.
Nothing else could have caught these. Unit tests mock the database;
check-rpc-exists proves a function is DEFINED, which all nineteen were;
migration replay proves the SQL applies, and creating a function never
validates its body.
Ctrl+Enter was a different bug with the same shape — it worked, and
looked like it did not. The post was created; the composer kept the
text, because the effect syncing content into the contentEditable bails
whenever the editor is the active element, which it always is right
after a keystroke. So the obvious response was to press again, and the
server rejected that as a duplicate. Clicking the button never showed it
because a click moves focus to the button first. A reset to empty is now
exempt from that guard: there is nothing to clobber.
UI, following X rather than inventing:
The action row was five near-identical JSX blocks that had already
drifted — one had -ml-2, some had titles, the reply count was computed
by an inline three-way IIFE. It is one config-driven component now.
Counts render into a fixed-width tabular slot instead of collapsing to
'' at zero, so liking something no longer reflows the row and slides
every later icon under the cursor that just clicked it.
Edit post: Save moves from the header to the bottom right, beside the
character count that decides whether it is allowed, instead of
diagonally opposite it.
Repost: the quoted post now sits directly under what you are writing,
with the buttons last. The action row used to separate the composer
from the thing it refers to. The textarea loses its border — the
dialog is already the container.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5
This was referenced Aug 28, 2026
github-actions Bot
pushed a commit
that referenced
this pull request
Aug 28, 2026
…811) * fix(timeline): a like was stored, then read back as zero everywhere 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 * fix(timeline): deleting the post you are looking at left it on screen 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 --------- 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.
Reported as separate complaints. Nearly all of them are one cause.
Nineteen functions reference something that does not exist
plpgsql only plans a statement when it runs, so each raised
42703the moment a user triggered it and at no other time. Every one looked healthy — defined, listed by\df, routed by PostgREST, called happily by the app. Nine are the timeline:like_/unlike_timeline_eventdislike_/undislike_timeline_eventadd_/delete_timeline_commentsoft_delete_timeline_eventcreate_quote_replyThe reaction functions end with
UPDATE timeline_events SET like_count = …under a comment reading "if column exists". It doesn't, and never has — counts live intimeline_event_stats, which the line above already updates correctly. A function body is one transaction, so the raise rolled back the INSERT three lines earlier: the like was written, then unwritten. The UI applied its optimistic update, sawsuccess:falseand rolled back — which is why the heart fills for ~150ms and empties.soft_delete_timeline_eventcheckedprofiles.user_id/role; neither column exists. And even fixed it would not have worked — it set onlydeleted_atwhile every read path filtersis_deleted. Production agrees nothing ever took this path: 102 events have both flags, zero havedeleted_atalone.create_quote_replyfailed three ways, each hidden behind the last, and never wrotedescription— the fieldPostContentrenders — so a quote reply would have shown as an empty post.Found by clicking Like in production and reading the response: HTTP 400, 42703. Proven by rehearsing each against production inside a transaction and rolling back: like returns 1 and lands in stats, delete returns true and the row is genuinely hidden, a quote reply creates with a body and notifies its author.
The gate
Nineteen is not bad luck.
plpgsql_checkreads every body against the live schema; exposed ascount_broken_plpgsql_functions()and ratcheted at the 11 that remain. Its limits are stated where it's defined: static only, so a NOT NULL left unset or a CHECK violation still fails at runtime — both of whichcreate_quote_replyalso did.Nothing else could have caught these: unit tests mock the DB,
check-rpc-existsproves a function is defined (all nineteen were), and migration replay never validates a body.Ctrl+Enter
Same shape — it worked and looked like it didn't. The post was created; the composer kept the text, because the sync effect bails whenever the editor is the active element, which it always is right after a keystroke. So you press again and the server rejects it as a duplicate. Clicking the button never showed this, because a click moves focus to the button first. A reset to empty is now exempt: there is nothing to clobber. Pinned by a test, proven by mutation.
UI, following X
-ml-2, some had titles, the reply count was an inline three-way IIFE) → one config-driven component. Counts render into a fixed-width tabular slot instead of collapsing to''at zero, so liking no longer reflows the row and slides every later icon under the cursor that just clicked.🤖 Generated with Claude Code
https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5