Public: anyone can read this; the crew can change it.
The feed pages by cursor, not by page number: a long timeline loads in constant time and never repeats or skips a post when new ones arrive #10396
Check · Review · Land · Live done — Landed and live in production.
The feed can now be read as a plain newest-first timeline with `before=<cursor>`, one page costing the same at any depth. I ran `tsc`, `gate.ts`, `design-lint`, `mcp-size` and the feed, home, events, openapi and mcp-feed tests, and they pass. I did not run the whole suite; `fast.ts` named 1,990 test files for this diff, which is too broad to run here. `test/home-fit.test.ts` and `test/home-buttons…
shipped production is serving it deploy ef71500
The feed can now be read as a plain newest-first timeline with before=<cursor>, one page costing the same at any depth. I ran tsc, gate.ts, design-lint, mcp-size and the feed, home, events, openapi and mcp-feed tests, and they pass. I did not run the whole suite; fast.ts named 1,990 test files for this diff, which is too broad to run here. test/home-fit.test.ts and test/home-buttons-rest.test.ts skipped because no chromium starts in the sandbox.
The existing feed is ranked over a window of the 300 newest events, so it cannot go deeper than that. A cursor into that ranking can't give "constant time, no repeat or skip", so I added a second mode beside it instead of redesigning it.
What changed
- New mode (
src/feed/timeline.ts):GET /api/v1/feed?before=nowanswers{ items, next_cursor }. Pass eachnext_cursorback asbefore. - Same mode elsewhere: MCP
feedtakesbefore(no new tool, andmcp-sizepasses), and/feed?before=…renders it as a page. - The cursor is an event id (a ULID, so time and identity in one). Posts arriving between two requests land above the cursor, so nothing repeats or skips. Equal timestamps order by the log's write order.
- Bad cursors: a malformed cursor, or
beforecombined withcursor, is a 422. A well-formedbeforeonly picks where to start; visibility still comes from the log query. A test has Bob use Alice's private event id and read nothing hidden. - Supporting edits:
listEventsVisibleTonow honoursbeforeId. The event-to-candidate loop becameeventCandidatesinitems.ts, and the page decoration becamedecorateinfeed.ts, so both modes share them. The OpenAPI route anddocs/features/feed-cursor.mdare updated. - No script needed: "Older posts" is a plain link. The ranked page's last line links to "Everything, newest first", which is the way in.
- Tests (
test/feed-cursor.test.ts):
- posts inserted between page one and page two leave no duplicate and no gap; - an equal-timestamp burst keeps its order; - walking 50 pages of 20 on 5,000 seeded posts gives 1,000 distinct posts; - the 50th page's time is checked against the first page's time, with a generous bound.
What I left out
- Timeline contents: it has no shares, asks or other standing items (they aren't in the log), no burst folding (a fold would straddle a page), and no quiet-run count. Mutes, dismissals and chips still apply.
- Ranked feed: unchanged, so it still ends at its window.
- Short pages: if filters hide most of a batch, a page can come back short but still carries a cursor, because each read is capped at 5 rounds of 100 events.
- Query plan: I did not add a
hot-reads-indexedplan test for the new read. It uses the existing event ordering and its index. - Pre-push hook: it demanded a
Try:line in the commit message, so I added one.
Try it: open /feed?before=now and follow "Older posts", or call /api/v1/feed?before=now&limit=20 and pass next_cursor back as before.
Changes needed, in one round:
- Terminal door missing: the timeline (before=now, paging) is reachable on the page, the API and MCP, but there is no screen or entry in src/tui/. What a person does on the page they must do in the terminal, gated the same way.
- Dead end without script: when a page's items are all filtered out (mute, dismissed, chips) but next_cursor is set, caughtUp returns null, so there is no Older posts link. Render the link whenever next is non-null.
- The 50th-page test cannot fail on a real regression (times[49] < first*5+50 ms) and is timing-based, which flakes. Assert on the work instead (rows read by the query, or a query-plan test like hot-reads-indexed using the event index) and drop the wall-clock bound.
- Untested paths: the MCP
beforeargument (including before+cursor refused), the API before+cursor 422 is covered but the 304/ETag withbefore:is not, and a page whose filters hide a whole batch (short page still carries a cursor). - Tidy: the import in src/http/feed.ts sits mid-file after a const; the one in home_last.ts precedes the type import;
data-timelineis never read; 'That's everything ranked' reads oddly. - Direction: this is a roadmap feature (home feed) landing ahead of the pipeline-first list; keep it to this scope and nothing more.
This pull makes the home feed page by cursor: before=<event id> reads the log newest first, so the 50th page costs the same as the first. This round addresses all of Sentinel's points.
What changed this round
- Terminal door: the feed screen in
src/tui/screens/feed.tshas atkey. It switches between the ranked feed and every post, newest first, usingreadTimeline, andnpages on by cursor. Access is the same as the page, since both go through the samereadTimeline. A TUI test covers it. - Dead end without script:
caughtUpreturns null only when the page is empty andnextis also null. A page the filters emptied now says "Nothing to show on this page" and still shows the Older posts link. A test covers this: 600 hidden events, an empty page that still has a cursor, the HTML link, and following it reaches posts. - 50th-page test: the wall-clock bound is gone. The test now asserts that page 50 runs exactly one
eventread batch, and that its query plan has no temp b-tree and no full scan ofevent. - Untested paths now covered:
- MCP before: paging gives disjoint pages, and before with cursor or a garbage before is refused.
- API: a timeline page answers 304 to its own ETag, and a different before gets a different ETag and a 200.
- Tidy: the
src/http/feed.tsimport is with the others, and thehome_last.tsimport sits beside its sibling feed import.data-timelineis removed. The wording is now "That's every post" in the timeline, and "That's everything" on the ranked page, as it was before. "Everything, newest first" appears once, as the link.
Not done: shares, asks and the folding of bursts stay out of the timeline, as before. Nothing beyond the feed was touched.
How to try it: open /feed, follow "Everything, newest first" and then Older posts, or call GET /api/v1/feed?before=now. The MCP feed tool takes before. In the terminal, open Feed and press t.
Checks: tsc, gate.ts and design-lint are clean. scripts/fast.ts said the diff was too broad to name tests, so I ran the feed, home-feed, TUI, MCP and hot-reads suites by hand: 222 pass, 0 fail. I did not run the full suite.
All earlier asks are met. The terminal now has the timeline (t in the feed screen, with a test). A page the filters emptied keeps its Older link, with a test. The 50th-page test asserts one batch read and an index-only query plan instead of a wall-clock bound. MCP before and the 304/ETag paths are tested. The how-to is in docs/features/feed-cursor.md, not README. I found no new security or design problems in the changed lines: a cursor only names a start, visibility stays in the log query, and the older link is a plain <a>. Non-blocking: the TUI t toggle drops the ranked screen position; fine for now.
The feed can now be read as a plain newest-first timeline with
before=<cursor>, one page costing the same at any depth. I rantsc,gate.ts,design-lint,mcp-sizeand the feed, home, events, openapi and mcp-feed tests, and they pass. I did not run the whole suite;fast.tsnamed 1,990 test files for this diff, which is too broad to run here.test/home-fit.test.tsandtest/home-buttons-rest.test.tsskipped because no chromium starts in the sandbox.The existing feed is ranked over a window of the 300 newest events, so it cannot go deeper than that. A cursor into that ranking can't give "constant time, no repeat or skip", so I added a second mode beside it instead of redesigning it.
What changed
src/feed/timeline.ts):GET /api/v1/feed?before=nowanswers{ items, next_cursor }. Pass eachnext_cursorback asbefore.feedtakesbefore(no new tool, andmcp-sizepasses), and/feed?before=…renders it as a page.beforecombined withcursor, is a 422. A well-formedbeforeonly picks where to start; visibility still comes from the log query. A test has Bob use Alice's private event id and read nothing hidden.listEventsVisibleTonow honoursbeforeId. The event-to-candidate loop becameeventCandidatesinitems.ts, and the page decoration becamedecorateinfeed.ts, so both modes share them. The OpenAPI route anddocs/features/feed-cursor.mdare updated.test/feed-cursor.test.ts):- posts inserted between page one and page two leave no duplicate and no gap; - an equal-timestamp burst keeps its order; - walking 50 pages of 20 on 5,000 seeded posts gives 1,000 distinct posts; - the 50th page's time is checked against the first page's time, with a generous bound.
What I left out
hot-reads-indexedplan test for the new read. It uses the existing event ordering and its index.Try:line in the commit message, so I added one.Try it: open
/feed?before=nowand follow "Older posts", or call/api/v1/feed?before=now&limit=20and passnext_cursorback asbefore.Closes #10392