Skip to content

fix(timeline): likes, dislikes, replies, deleting and quoting were all dead - #806

Merged
github-actions[bot] merged 1 commit into
mainfrom
fix/timeline-overhaul
Aug 28, 2026
Merged

fix(timeline): likes, dislikes, replies, deleting and quoting were all dead#806
github-actions[bot] merged 1 commit into
mainfrom
fix/timeline-overhaul

Conversation

@catomean

Copy link
Copy Markdown
Collaborator

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 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 are the timeline:

Function Your symptom
like_/unlike_timeline_event likes don't work
dislike_/undislike_timeline_event dislikes don't work
add_/delete_timeline_comment replies
soft_delete_timeline_event deletion doesn't work
create_quote_reply reposts

The 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 in timeline_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, saw success:false and rolled back — which is why the heart fills for ~150ms and empties.

soft_delete_timeline_event checked profiles.user_id/role; neither column exists. And even fixed it would not have worked — it set only deleted_at while every read path filters is_deleted. Production agrees nothing ever took this path: 102 events have both flags, zero have deleted_at alone.

create_quote_reply failed three ways, each hidden behind the last, and never wrote description — the field PostContent renders — 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_check reads every body against the live schema; exposed as count_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 which create_quote_reply also did.

Nothing else could have caught these: unit tests mock the DB, check-rpc-exists proves 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

  • Action row: five near-identical JSX blocks that had already drifted (one had -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.
  • Edit post: Save moves to the bottom right, beside the character count that decides whether it's allowed.
  • Repost: the quoted post now sits directly under what you're writing, buttons last; the textarea loses its border, since the dialog is already the container.

🤖 Generated with Claude Code

https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5

…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
@github-actions
github-actions Bot merged commit c4df1ff into main Aug 28, 2026
8 checks passed
@github-actions
github-actions Bot deleted the fix/timeline-overhaul branch August 28, 2026 09:18
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant