Files
pad/internal/models
xarmian 11f67b0a98 fix: timeline can answer has_more=true with zero entries, and the client cannot page past it (BUG-2765) (#1202)
* fix(server): timeline returns the cursor for its next page (BUG-2765)

The timeline over-fetches 3x per source and drops rows that cannot render
(read/searched actions, empty-metadata updates, activities a version or a
comment already stands for, collapsed autosave bursts), so a page can carry
fewer entries than the rows it consumed — or none, while has_more is true.

The client derived its cursor from the last RENDERED entry, which fails in two
ways. With no entries it cannot form a cursor at all, so the first page is a
dead end. With a fully-dropped window LATER in the history it re-sends the same
cursor forever: nothing is appended, the oldest entry does not move, and paging
is wedged at that position permanently. The filing named the first; the second
is the one that bites an ordinary item, since a run of read activities anywhere
in its history is enough.

Both are the same root cause — the response says WHETHER to continue and not
WHERE — so the server now returns next_before / next_before_id whenever
has_more is true:

- page truncated: the last entry KEPT, because the ones cut off must be
  re-fetched. Unchanged from what the client derived.
- window exhausted: the NEWEST tail among the sources that filled their window.
  A short source has nothing older to come back for and must not drag the
  cursor forward; resuming at the oldest tail instead would step over a newer
  source's unexamined rows, and repeats are absorbed by the client's dedup
  while gaps are not recoverable.

Progress is guaranteed because every candidate is a row this page fetched and
the store's cursor predicate is strict.

Tests: an all-dropped window returns a cursor that reaches the history behind
it; paging across a dropped MIDDLE stretch terminates and yields each
renderable entry exactly once; and a control leg pins that an untruncated page
still resumes at its last rendered entry — without it, "always resume from the
oldest row touched" passes while silently skipping what truncation cut. The
two-full-source selection rule is pinned as a unit test, because a full source
whose rows RENDER puts 3x limit entries on the page and takes the truncation
branch instead, so the handler cannot cheaply reach it.

Mutation matrix, each independently detected: cursor from rendered entries;
oldest tail instead of newest; the full-window flag ignored; no cursor at all.

Client half follows in the next commit.

Refs: BUG-2765

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* fix(web): page the timeline with the server's cursor (BUG-2765)

Client half. The component derived its next-page cursor from the last RENDERED
entry, so a page the server had emptied by dropping rows either gave it nothing
to page from (the first page, where loadMore returned early on
entries.length === 0) or gave it the SAME cursor it already held (a later page,
where nothing was appended and the oldest entry did not move). The second case
is a permanent wedge with a live button and a running spinner.

It now pages with next_before / next_before_id when the server sends them, and
falls back to the last entry otherwise — which is exactly the old behaviour,
including its wedge, and is there only for a server that predates the field.

One press walks at most MAX_EMPTY_HOPS pages while every row keeps dropping.
That bound is UX, not correctness, and its comment says so: a single hop is
already correct now that the cursor advances; the loop exists so a user
crossing a long run of read activity sees entries appear rather than a spinner
and nothing, and it is small so a pathological item cannot turn one click into
an unbounded request fan.

Tests assert the cursors the component ASKS FOR, not only what it displays — a
component that shows the right thing by re-fetching page one forever is the
bug. Mutation matrix: ignoring the server cursor fails three of four legs (the
fallback leg survives, correctly, since that is the path it pins); a single hop
fails the advance leg; an unbounded hop count fails the bound leg.

vitest 109 files / 1862 tests pass; vite build clean; svelte-check 0 errors
(6 warnings, all pre-existing and in other files).

Refs: BUG-2765

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* fix: cursor must clear BOTH bounds, and a later page must merge not append (codex round 1)

Two findings, both real, both consequences of the cursor itself.

P1 — truncation and an exhausted window are INDEPENDENT bounds, and the second
is not implied by the first. A source whose rows all drop contributes nothing
to the page, so the truncation cursor can sit older than that source's tail,
and every unexamined row between the two falls in a gap neither page fetches.
The cursor is now the NEWEST candidate across both reasons. Its regression puts
one renderable activity in exactly that gap: two comments forcing truncation at
limit=1, three read rows filling the activity window above them, and the row at
risk in between. Run against the previous commit it comes back 0 times — the
row is not late, it is gone.

P2 — a later page can legitimately carry entries NEWER than the oldest one
already shown, because the cursor deliberately re-covers ground when one
source's window ran out before another's. Concatenating printed those below
older entries. The client merges by (created_at desc, id desc) now, comparing
INSTANTS rather than strings: precision is not uniform — the store writes whole
seconds but a structured note can carry a sub-second timestamp — and
lexicographically "…:05.123Z" sorts before "…:05Z".

Mutation checks: truncation ignoring the exhausted candidate fails the new gap
test; concatenating instead of merging fails the new order test and nothing
else.

internal/server suite green; timeline vitest 9 files / 83 tests green.

Refs: BUG-2765

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* fix(web): stop the hop loop when the cursor does not advance (codex round 2)

Against a server that predates next_before, the fallback re-derives the same
last entry on every hop, so a single Load More click fired five identical
requests where the pre-fix component fired one. The client cannot give an old
server a cursor it does not have — that wedge is the old behaviour and stays —
but amplifying it was new, and mine.

A cursor that did not move cannot make progress, so the loop stops on it. Its
test asserts the request COUNT, which is the only thing that distinguishes this
from the behaviour it replaces: the entries rendered are identical either way.

Refs: BUG-2765

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* fix(web): order the SSE-refresh merge too, and share one comparator (codex round 3)

The refresh prepended its genuinely-new entries on the assumption that a fresh
first page is always newer than everything on screen. Normally it is — but a
structured note or decision carries a hand-written created_at and can arrive
backdated, and the assumption was never stated, only relied on. Both merge
points now go through one byNewestFirst, which is the server's own ordering and
compares instants rather than strings.

Two of codex's three round-3 findings are not folded in:

- The refresh not adopting the response's has_more / cursor is DECLINED, not
  missed. The refresh re-fetches the NEWEST window, which says nothing about
  where the reader's paging frontier is; the stored cursor stays valid because
  the refresh consumes no older rows, and adopting the fresh page's has_more
  after the reader has paged deeper would point the cursor back at history they
  already hold. One wasted request, absorbed by the no-advance stop, in
  exchange for a correctness claim I cannot make.
- firstPageIds treating any entry missing from a refreshed first page as
  deleted is real, pre-existing, and a semantics call about what counts as a
  deletion rather than a patch: filed as BUG-2773.

Refs: BUG-2765, BUG-2773

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* test(server): derive the expected entry set instead of naming two ids (codex round 4)

The test claimed every renderable entry came back exactly once and checked the
two ids it had seeded. The item's own `created` activity could have vanished or
repeated underneath that claim — the same partial-verification shape as
asserting one direction and writing the symmetric conclusion.

The expectation now comes from the store: every activity on the item minus the
kinds buildTimeline drops unconditionally, compared in both directions, so a
fixture that grows a row cannot fall outside what the test says it covers.
Still fails with the cursor withheld.

Refs: BUG-2765

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* docs: state the timeline cursor contract where consumers read it (codex round 5)

The TypeScript type documented next_before/next_before_id, but the handler's
own doc comment — what a REST consumer reads — still described before + limit
only, and the API client method said nothing. A consumer following either could
still derive a cursor from its last visible entry, which is precisely the
invalid contract this change exists to replace.

Both now state the pair, that it must be forwarded rather than re-derived, why
(dropped rows make the last entry a different position, sometimes no position),
and that the id is the tie-break among entries sharing a second — send both or
neither.

Refs: BUG-2765

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* fix(web): clear paging state when the timeline reloads (codex round 6)

The previous item's entries stay on screen while the new one's page 1 is in
flight — the list renders on `!loading || entries.length > 0` — so Load More is
clickable during a switch, and its cursor was the OLD item's position aimed at
the NEW item. Pre-existing in shape (the pre-fix code derived the same stale
position from the same stale entries), but now it is one line to close, and the
dedicated cursor variable is mine.

loadTimeline replaces entries with page 1 when it resolves, so clearing the
cursor and has_more before the await throws away no state that would have
survived; it just stops offering paging for a position that is no longer known.

Test holds the switch's fetch unresolved and asserts no request goes out in
that window, with the button's absence as the observable. Fails with the two
lines removed.

Refs: BUG-2765

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-25 16:11:47 -04:00
..
2026-03-26 01:52:36 +00:00