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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10392-the-feed-pages-by-cursor-not-by-page-num → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/10392-the-feed-pages-by-cursor-not-by-page-num was merged into main as 864a93d.

Review

Sign in to review this change.
13 files changed +530 −57
docs/features/feed-cursor.md added +5
@@ -0,0 +1,5 @@
1 # The feed pages by cursor
2
3 `GET /api/v1/feed?before=now` (also MCP `feed` with `before`, and `/feed?before=now` as a page) reads the log newest first: `{ items, next_cursor }`; pass `next_cursor` back as `before`. The cursor is an event id (a ULID: its time and its identity), resolved to the log's write order, so posts arriving between two requests land above the cursor and nothing repeats or skips. A cursor that is not an event id is a 422; a valid one only names where to start, and visibility is still the log query's.
4
5 The ranked feed (`cursor=`) is unchanged; its page ends where its 300-event window does, and its last line links to "Everything, newest first". The timeline leaves out shares, asks and other standing items, and does not fold bursts. Code: `src/feed/timeline.ts`; test: `test/feed-cursor.test.ts`.
src/contract/openapi/routes/getApiV1Feed.ts +2 −2
@@ -4,8 +4,8 @@ import { FEED_LIMIT_MAX } from "../../../feed/feed.ts";
44 export const route: Route = {
55 method: "get",
66 path: "/api/v1/feed",
7 summary: "The caller's own feed, newest first (ro scope; never cached); answers { items, next cursor } to pass back as `cursor`; without a `cursor` it also carries `digest`, home's \"Since you were here\" card, `{ heading, lines: [{ repo, says, href }] }` or null (a page with a cursor has no `digest` key); reading never counts as a visit; the ETag moves with the digest",
7 summary: "The caller's own feed, newest first (ro scope; never cached); answers { items, next cursor } to pass back as `cursor`; `before=now` (then each `next_cursor` as `before`) reads the log newest first instead, one event id as cursor, a page costing the same at any depth, no repeat or skip when posts arrive; without a `cursor` it also carries `digest`, home's \"Since you were here\" card, `{ heading, lines: [{ repo, says, href }] }` or null (a page with a cursor has no `digest` key); reading never counts as a visit; the ETag moves with the digest",
88 auth: true,
9 query: { limit: limitTo(FEED_LIMIT_MAX), cursor: "string" },
9 query: { limit: limitTo(FEED_LIMIT_MAX), cursor: "string", before: "string" },
1010 refusals: { invalid_limit: "limit is not a whole number from 1 to 50 (422)", invalid_cursor: "the cursor was not issued here (422)" },
1111 };
src/core/events.ts +4
@@ -165,6 +165,10 @@ export function listEventsVisibleTo(
165165 params.push(options.afterId);
166166 where += ` AND ${afterEvent(params.length)}`;
167167 }
168 if (options.beforeId !== undefined) {
169 params.push(options.beforeId);
170 where += ` AND ${beforeEvent(params.length)}`;
171 }
168172 params.push(options.limit ?? 100);
169173 return db
170174 .query<EventRow, (string | number)[]>(
src/feed/feed.ts +26 −16
@@ -144,6 +144,31 @@ type FeedOptions = {
144144 phase?: (name: string, ms: number) => void;
145145 };
146146
147 /** A page with what it settled into, where it stands on the path, and who likes it. */
148 export function decorate(
149 db: Database,
150 viewer: User,
151 page: readonly FeedItem[],
152 phase?: (name: string, ms: number) => void,
153 ): FeedItem[] {
154 const started = performance.now();
155 const settled = settledOf(db, page);
156 phase?.("settled", performance.now() - started);
157 const steps = stepsOf(db, page);
158 const likes = likesOf(db, viewer.id, page.map((i) => i.id));
159 return page.map((i) => {
160 const state = settled.get(i.id);
161 const step = steps.get(i.id);
162 const liked = likes.get(i.id)!;
163 return {
164 ...i,
165 ...(state === undefined ? {} : { settled: state }),
166 ...(step === undefined ? {} : { step }),
167 ...(liked.likes === 0 ? {} : liked),
168 };
169 });
170 }
171
147172 /** The page, with the quiet runs it left out and where most of them are. */
148173 export function readFeedPage(
149174 db: Database,
@@ -203,22 +228,7 @@ export function readFeedPage(
203228 : ranked.filter((i) => i.score < cursor.score);
204229 }
205230 const page = ranked.slice(0, limit);
206 const started = performance.now();
207 const settled = settledOf(db, page);
208 options.phase?.("settled", performance.now() - started);
209 const steps = stepsOf(db, page);
210 const likes = likesOf(db, viewer.id, page.map((i) => i.id));
211 const items = page.map((i) => {
212 const state = settled.get(i.id);
213 const step = steps.get(i.id);
214 const liked = likes.get(i.id)!;
215 return {
216 ...i,
217 ...(state === undefined ? {} : { settled: state }),
218 ...(step === undefined ? {} : { step }),
219 ...(liked.likes === 0 ? {} : liked),
220 };
221 });
231 const items = decorate(db, viewer, page, options.phase);
222232 const last = items[items.length - 1];
223233 return {
224234 items,
src/feed/items.ts +35 −19
@@ -334,29 +334,18 @@ function hotCandidates(writable: readonly Repo[]): Candidate[] {
334334 return out;
335335 }
336336
337 export function candidatesFor(
337 /**
338 * What events say, as candidates: one read of their subjects, one per event.
339 * `repos` holds each event's repository; an event whose repository is not in
340 * it is left out. Shared by the ranked feed and the timeline (`readTimeline`).
341 */
342 export function eventCandidates(
338343 db: Database,
339344 viewer: User,
340345 now: number,
341 chips?: Chips,
342 /** False when the caller shows asks itself (the home pins them): they are not read twice. */
343 withAsks = true,
346 events: readonly ForgeEvent[],
347 repos: ReadonlyMap<string, Repo>,
344348 ): Candidate[] {
345 // A quiet run is counted at the end of the first page, not posted.
346 const { heard, quiet } = heardBy(db, viewer, now);
347 const events = [...heard.filter((e) => !quiet.has(e.id)), ...teamWaitsHeard(db, viewer, heard)];
348 const writable = listReposWritableTo(db, viewer, { limit: 200 });
349 const asks = withAsks ? askLinesFor(db, viewer, chips) : [];
350 const decided = listDecidedIn(
351 db,
352 writable.map((r) => r.id),
353 );
354 const repos = getReposByIds(db, [
355 ...events.map((e) => e.repoId!),
356 ...asks.map((a) => a.repoId),
357 ]);
358 for (const r of writable) repos.set(r.id, r);
359
360349 const names = new Map<string, string | null>();
361350 const nameOf = (id: string | null): string | null => {
362351 if (id === null) return null;
@@ -415,6 +404,33 @@ export function candidatesFor(
415404 ...(fold === undefined ? {} : { what: `${c.what} · ${foldedWords(fold, now)}` }),
416405 });
417406 }
407 return out;
408 }
409
410 export function candidatesFor(
411 db: Database,
412 viewer: User,
413 now: number,
414 chips?: Chips,
415 /** False when the caller shows asks itself (the home pins them): they are not read twice. */
416 withAsks = true,
417 ): Candidate[] {
418 // A quiet run is counted at the end of the first page, not posted.
419 const { heard, quiet } = heardBy(db, viewer, now);
420 const events = [...heard.filter((e) => !quiet.has(e.id)), ...teamWaitsHeard(db, viewer, heard)];
421 const writable = listReposWritableTo(db, viewer, { limit: 200 });
422 const asks = withAsks ? askLinesFor(db, viewer, chips) : [];
423 const decided = listDecidedIn(
424 db,
425 writable.map((r) => r.id),
426 );
427 const repos = getReposByIds(db, [
428 ...events.map((e) => e.repoId!),
429 ...asks.map((a) => a.repoId),
430 ]);
431 for (const r of writable) repos.set(r.id, r);
432
433 const out: Candidate[] = eventCandidates(db, viewer, now, events, repos);
418434 for (const a of asks) {
419435 const repo = repos.get(a.repoId);
420436 if (!repo) continue;
src/feed/timeline.ts added +109
@@ -0,0 +1,109 @@
1 /**
2 * The timeline: what the viewer may see, newest first, paged by `before`.
3 *
4 * The ranked feed (`feed.ts`) is recomputed over a window of recent events
5 * and cannot go deeper. This reads events straight from the log, in the order
6 * they were written, so a page costs the page — the 50th as much as the
7 * first — and a post that arrives between two requests lands above the
8 * cursor, never repeating or skipping one below it. The cursor is an event's
9 * id: a ULID, so it is its time and its identity in one (`kernel/id.ts`); the
10 * log resolves it to its place in write order (`core/events.ts`), which is
11 * total, so two posts in one millisecond cannot tie.
12 *
13 * Visibility is the log query's (`listEventsVisibleTo`) and the audience
14 * filter's, never the cursor's: a cursor only names where to start, so a
15 * forged one reads nothing the caller could not read. What it leaves out of
16 * the ranked feed: shares, asks and other standing items (they are not in the
17 * log), and the folding of bursts (a fold would straddle a page).
18 */
19
20 import type { Database } from "bun:sqlite";
21 import { chipsOf, type Chips } from "../core/feed_chips.ts";
22 import { audienceFilter } from "../core/event_audience.ts";
23 import { listEventsVisibleTo, type ForgeEvent } from "../core/events.ts";
24 import { dismissedItems, mutedRepos } from "../core/interactions.ts";
25 import { getReposByIds } from "../core/repos.ts";
26 import type { User } from "../core/users.ts";
27 import { chipHides } from "./chips.ts";
28 import { decorate, FEED_LIMIT_DEFAULT, FEED_LIMIT_MAX } from "./feed.ts";
29 import { eventCandidates } from "./items.ts";
30 import { ago, type FeedItem } from "./rank.ts";
31
32 /** `before=now` starts at the newest post. */
33 export const TIMELINE_START = "now";
34
35 const CURSOR = /^[0-9a-hjkmnp-tv-z]{26}$/;
36
37 /** A `before` as this forge issued it: an event id, or `now`. Anything else is undefined. */
38 export function parseBefore(raw: string): { id: string | undefined } | undefined {
39 if (raw === TIMELINE_START) return { id: undefined };
40 return CURSOR.test(raw) ? { id: raw } : undefined;
41 }
42
43 /** Events read per round trip, and rounds at most: a filter that hides most of the log ends the page short, with a cursor to go on. */
44 const BATCH = 100;
45 const ROUNDS = 5;
46
47 export function readTimeline(
48 db: Database,
49 viewer: User,
50 options: {
51 before?: string | undefined;
52 limit?: number;
53 now?: number;
54 chips?: Chips;
55 phase?: (name: string, ms: number) => void;
56 } = {},
57 ): { items: FeedItem[]; next_cursor: string | null } {
58 const now = options.now ?? Date.now();
59 const limit = Math.min(options.limit ?? FEED_LIMIT_DEFAULT, FEED_LIMIT_MAX);
60 const chips = options.chips ?? chipsOf(db, viewer.id);
61 const muted = mutedRepos(db, viewer.id);
62 const dismissed = dismissedItems(db, viewer.id);
63 const picked: { item: FeedItem; event: string }[] = [];
64 let before = options.before;
65 let more = false; // the log has events past the last one read
66 for (let round = 0; round < ROUNDS && picked.length <= limit; round++) {
67 const batch: ForgeEvent[] = listEventsVisibleTo(db, viewer, {
68 limit: BATCH,
69 ...(before === undefined ? {} : { beforeId: before }),
70 });
71 if (batch.length === 0) {
72 more = false;
73 break;
74 }
75 before = batch[batch.length - 1]!.id;
76 more = batch.length === BATCH;
77 const heard = batch.filter(audienceFilter(db, viewer, batch));
78 const repos = getReposByIds(db, heard.map((e) => e.repoId!));
79 for (const c of eventCandidates(db, viewer, now, heard, repos)) {
80 if (chipHides(chips, c) || dismissed.has(c.id) || muted.has(c.repoId)) continue;
81 picked.push({
82 event: c.id.slice("event:".length),
83 item: {
84 id: c.id,
85 kind: c.kind,
86 app: c.app,
87 actor: c.actor,
88 at: c.at,
89 link: c.link,
90 what: c.said ?? c.what,
91 reason: c.said ?? ago(Date.parse(c.at), now),
92 score: 0,
93 },
94 });
95 }
96 }
97 // Candidates come out in the batch's order (newest first); the page is the first `limit`.
98 const page = picked.slice(0, limit);
99 const last = page[page.length - 1];
100 const items = decorate(db, viewer, page.map((p) => p.item), options.phase);
101 // More to read: a further post already found, or events left unread (the cursor is then where the read stopped).
102 const next =
103 picked.length > limit && last !== undefined
104 ? last.event
105 : more && before !== undefined
106 ? before
107 : null;
108 return { items, next_cursor: next };
109 }
src/http/feed.ts +20 −1
@@ -47,6 +47,7 @@ import type { FleetError } from "../actions/fleet.ts";
4747 import { notePhase, takeSessionTime, timePhase } from "../core/route_timing.ts";
4848 import type { Handler } from "./context.ts";
4949 import type { Router } from "./router.ts";
50 import { parseBefore, readTimeline } from "../feed/timeline.ts";
5051 import { HIDDEN_WORDS } from "../core/arg_words.ts";
5152 import {
5253 apiError,
@@ -78,6 +79,11 @@ export function feedRoutes(router: Router<Handler>): void {
7879 const cursor = rawCursor === null ? undefined : parseCursor(rawCursor);
7980 if (rawCursor !== null && cursor === undefined)
8081 return apiError("invalid_cursor", 422, CURSOR_NOT_ISSUED);
82 // `before=` is the timeline: newest first, paged by an event id (`feed/timeline.ts`).
83 const rawBefore = q.get("before");
84 const before = rawBefore === null ? undefined : parseBefore(rawBefore);
85 if (rawBefore !== null && (before === undefined || rawCursor !== null))
86 return apiError("invalid_cursor", 422, CURSOR_NOT_ISSUED);
8187 // A 304 only when nothing the viewer would read has changed (`feedTag`).
8288 // The queue drains first, as `readFeed` would; shares still waiting
8389 // depend on the clock, so they always get a page.
@@ -85,7 +91,7 @@ export function feedRoutes(router: Router<Handler>): void {
8591 drainQueue(ctx.app.db, viewer.id, now);
8692 const tag = feedTag(ctx.app.db, viewer, {
8793 chips: chipsOf(ctx.app.db, viewer.id),
88 cursor: rawCursor,
94 cursor: rawBefore === null ? rawCursor : `before:${rawBefore}`,
8995 limit: limit ?? null,
9096 now,
9197 visit: rawCursor === null ? lastVisit(ctx.app.db, viewer.id) : undefined,
@@ -101,6 +107,19 @@ export function feedRoutes(router: Router<Handler>): void {
101107 !hasQueuedShares(ctx.app.db, viewer.id)
102108 )
103109 return new Response(null, { status: 304, headers });
110 if (before !== undefined)
111 return json(
112 timePhase(ctx.request, "feed", () =>
113 readTimeline(ctx.app.db, viewer, {
114 chips: chipsOf(ctx.app.db, viewer.id),
115 phase: (name, ms) => notePhase(ctx.request, name, ms),
116 ...(limit === undefined ? {} : { limit }),
117 ...(before.id === undefined ? {} : { before: before.id }),
118 }),
119 ),
120 200,
121 headers,
122 );
104123 const page = timePhase(ctx.request, "feed", () =>
105124 readFeed(ctx.app.db, viewer, {
106125 phase: (name, ms) => notePhase(ctx.request, name, ms),
src/http/mcp/tools/feed.ts +15 −1
@@ -1,11 +1,12 @@
11 import { FEED_LIMIT_DEFAULT, FEED_LIMIT_MAX, parseCursor, readFeed } from "../../../feed/feed.ts";
2 import { parseBefore, readTimeline } from "../../../feed/timeline.ts";
23 import { CURSOR_NOT_ISSUED } from "../../../actions/feed_words.ts";
34 import { feedDigest } from "../../../web/studio/since.ts";
45 import { good, bad, type Tool, CHIP_ARGS, chipArgs, str } from "../shared.ts";
56
67 export const tool: Tool = {
78 name: "feed",
8 doc: "`{ limit?, cursor?, hide_kinds?, hide_people? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed`; `digest` (first page only) is home's \"Since you were here\" card, `{ heading, lines: [{ repo, says, href }] }` or null; `quietRuns` counts agent runs of the last 24 h that pushed, opened or said nothing, which the timeline leaves out; an item with `lines: [{ text, link }]` and `more` is a folded burst (3+ issues one author opened in one repository within 2 minutes: `planned N issues in owner/name`, linking to its map) — `lines` are the first three, `more` the rest",
9 doc: "`{ limit?, cursor?, before?, hide_kinds?, hide_people? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed`; `before` (a `next_cursor`, or `now`) reads the log newest first instead of ranked, as constant-cost pages with no repeat or skip; `digest` (first page only) is home's \"Since you were here\" card, `{ heading, lines: [{ repo, says, href }] }` or null; `quietRuns` counts agent runs of the last 24 h that pushed, opened or said nothing, which the timeline leaves out; an item with `lines: [{ text, link }]` and `more` is a folded burst (3+ issues one author opened in one repository within 2 minutes: `planned N issues in owner/name`, linking to its map) — `lines` are the first three, `more` the rest",
910 description:
1011 "Your ranked home timeline; `cursor` pages; saved filter applies.",
1112 inputSchema: {
@@ -13,6 +14,7 @@ export const tool: Tool = {
1314 properties: {
1415 limit: { type: "integer", minimum: 1, maximum: FEED_LIMIT_MAX },
1516 cursor: { type: "string" },
17 before: { type: "string" },
1618 ...CHIP_ARGS,
1719 },
1820 },
@@ -28,6 +30,18 @@ export const tool: Tool = {
2830 const cursor = given === undefined ? undefined : parseCursor(str(given));
2931 if (given !== undefined && cursor === undefined)
3032 return bad("invalid", CURSOR_NOT_ISSUED);
33 const rawBefore = args["before"];
34 if (rawBefore !== undefined) {
35 const before = parseBefore(str(rawBefore));
36 if (before === undefined || given !== undefined) return bad("invalid", CURSOR_NOT_ISSUED);
37 return good(
38 readTimeline(ctx.app.db, user, {
39 limit,
40 ...(chips === undefined ? {} : { chips }),
41 ...(before.id === undefined ? {} : { before: before.id }),
42 }),
43 );
44 }
3145 const page = readFeed(ctx.app.db, user, {
3246 limit,
3347 ...(chips === undefined ? {} : { chips }),
src/tui/screens/feed.ts +23 −2
@@ -7,12 +7,13 @@
77 * posts to the same people as the page's Reply (`feed/reply.ts`). `l` likes
88 * the post under the cursor and a second press takes it back: the page's
99 * Like, behind the same `readInteraction` check (`feed/likes.ts`). Twenty
10 * posts at a time; `n` reads the next page from the cursor the last returned.
10 * posts at a time; `t` switches to every post, newest first (`readTimeline`); `n` reads the next page from the cursor the last returned.
1111 */
1212
1313 import { addCommentThrough } from "../../actions/issues.ts";
1414 import { getIssue } from "../../core/issues.ts";
1515 import { parseCursor, readFeed } from "../../feed/feed.ts";
16 import { parseBefore, readTimeline, TIMELINE_START } from "../../feed/timeline.ts";
1617 import type { FeedItem } from "../../feed/rank.ts";
1718 import { readInteraction } from "../../feed/interact.ts";
1819 import { toggleLike } from "../../feed/likes.ts";
@@ -40,9 +41,22 @@ export function feedScreen(f: Session, ctx: Ctx): Screen {
4041 const composer = new Composer();
4142 const items: FeedItem[] = [];
4243 let next: string | null = null;
44 // `t`: the log newest first, deeper than the ranked page reaches (the page's `?before=now`).
45 let timeline = false;
4346
4447 const read = (cursor?: string): void => {
4548 if (f.user === undefined) return;
49 if (timeline) {
50 const from = parseBefore(cursor ?? TIMELINE_START);
51 const page = readTimeline(f.db, f.user, {
52 limit: PAGE,
53 ...(from?.id === undefined ? {} : { before: from.id }),
54 });
55 items.push(...page.items);
56 next = page.next_cursor;
57 ctx.invalidate();
58 return;
59 }
4660 const parsed = cursor === undefined ? undefined : parseCursor(cursor);
4761 const page = readFeed(f.db, f.user, { limit: PAGE, ...(parsed ? { cursor: parsed } : {}) });
4862 items.push(...page.items);
@@ -97,6 +111,7 @@ export function feedScreen(f: Session, ctx: Ctx): Screen {
97111 ...(selected() === undefined ? [] : [binding(["r"], "reply")]),
98112 ...(items.length === 0 ? [] : [binding(["l"], "like")]),
99113 ...(next === null ? [] : [binding(["n"], "next page")]),
114 binding(["t"], timeline ? "ranked" : "newest first"),
100115 binding(["esc", "q"], "back"),
101116 ];
102117 },
@@ -129,7 +144,13 @@ export function feedScreen(f: Session, ctx: Ctx): Screen {
129144 const at = selected();
130145 if (at !== undefined) composer.start(`Reply on ${at.thread.owner}/${at.thread.name} #${at.thread.number}`);
131146 } else if (e.key === "l") like();
132 else if (e.key === "n") {
147 else if (e.key === "t") {
148 timeline = !timeline;
149 items.length = 0;
150 state.index = 0;
151 state.offset = 0;
152 read();
153 } else if (e.key === "n") {
133154 if (next !== null) read(next);
134155 } else state.key(e, items.length);
135156 return true;
src/web/studio/feed.ts +38 −16
@@ -17,6 +17,7 @@ import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.t
1717 import { placementsOf, type Placement } from "../../core/placements.ts";
1818 import { recentThoughts, type Thought } from "../../core/thoughts.ts";
1919 import { parseCursor, readFeedPage } from "../../feed/feed.ts";
20 import { parseBefore, readTimeline, TIMELINE_START } from "../../feed/timeline.ts";
2021 import type { FeedItem } from "../../feed/rank.ts";
2122 import { replyPath, replyRepo, threadOf } from "../../feed/reply.ts";
2223 import { getAppByRepo } from "../../core/apps.ts";
@@ -338,15 +339,29 @@ function readRegion(user: User, ctx: Ctx) {
338339 // ask is never a second time a post below it; the feed API keeps them.
339340 // `?cursor=` is a page the line below the last post pointed at; one not issued here is the first page.
340341 const cursor = parseCursor(ctx.url.searchParams.get("cursor") ?? "");
341 const page = readFeedPage(ctx.app.db, user, {
342 limit: FEED_FIRST_PAGE,
343 withoutAsks: true,
344 phase: (name, ms) => notePhase(ctx.request, name, ms),
345 ...(cursor === undefined ? {} : { cursor }),
346 });
342 // `?before=` is the timeline, newest first, deeper than the ranked page reaches (`feed/timeline.ts`).
343 const before = parseBefore(ctx.url.searchParams.get("before") ?? "");
344 const page =
345 before === undefined
346 ? readFeedPage(ctx.app.db, user, {
347 limit: FEED_FIRST_PAGE,
348 withoutAsks: true,
349 phase: (name, ms) => notePhase(ctx.request, name, ms),
350 ...(cursor === undefined ? {} : { cursor }),
351 })
352 : {
353 ...readTimeline(ctx.app.db, user, {
354 limit: FEED_FIRST_PAGE,
355 phase: (name, ms) => notePhase(ctx.request, name, ms),
356 ...(before.id === undefined ? {} : { before: before.id }),
357 }),
358 quiet: { count: 0, repoId: null },
359 };
360 const timeline = before !== undefined;
361 const asked = ctx.url.searchParams.get("before") ?? "";
347362 const items = page.items;
348363 // The day the page before ended on, so a continuing page draws no divider twice (`day_divider.ts`).
349 const day = cursor === undefined ? "" : (ctx.url.searchParams.get("day") ?? "");
364 const day = cursor === undefined && !timeline ? "" : (ctx.url.searchParams.get("day") ?? "");
350365 const lastDayOnPage = Number.isFinite(Date.parse(day)) ? new Date(day).toISOString() : undefined;
351366 const thoughts = recentThoughts(ctx.app.db, user.id, THOUGHTS_SHOWN, new Date(Date.now() - THOUGHT_AGE_MS).toISOString());
352367 const placed = placementsOf(ctx.app.db, thoughts.map((t) => t.id));
@@ -358,7 +373,7 @@ function readRegion(user: User, ctx: Ctx) {
358373 const quiet = page.quiet;
359374 const quietRepo = quiet.repoId === null ? undefined : getRepoById(ctx.app.db, quiet.repoId);
360375 const quietLink = quietRepo === undefined ? "/explore" : `/${quietRepo.ownerName}/${quietRepo.name}/runs?kind=agent`;
361 return { items, next: page.next_cursor, cursor, lastDayOnPage, thoughts, placed, marks, pictures: picturesFor(ctx, items), quiet: quiet.count, quietLink };
376 return { items, next: page.next_cursor, cursor, timeline, asked, lastDayOnPage, thoughts, placed, marks, pictures: picturesFor(ctx, items), quiet: quiet.count, quietLink };
362377 }
363378
364379 /** The region: cards, or one line saying there is nothing to do. */
@@ -370,23 +385,29 @@ export function feedRegion(user: User, ctx: Ctx): Html {
370385 * The line under the last post: how far back the page reaches and the way
371386 * to older ones, or that there is nothing older.
372387 */
373 export function caughtUp(items: readonly FeedItem[], next: string | null, cursor?: unknown): Html | null {
388 export function caughtUp(items: readonly FeedItem[], next: string | null, cursor?: unknown, timeline = false): Html | null {
374389 // Ranked by score, not time: the last card is not always the oldest.
375390 const oldest = items.reduce<string | undefined>((min, item) => (min === undefined || item.at < min ? item.at : min), undefined);
376 if (oldest === undefined) return null;
377 const newest = cursor === undefined ? html`` : html`<a href="/feed">Newest</a>`;
391 // A page the filters emptied can still have older posts behind it: keep the way on.
outdated

Returns null on an empty page, but a timeline page can be empty with next set (filters hid all 500 events read). Then there is no Older link and a no-script reader is stuck. Only return null when next is also null.

392 if (oldest === undefined && next === null) return null;
393 const newest = cursor === undefined && !timeline ? html`` : html`<a href="/feed">Newest</a>`;
394 // The ranked page ends where its window does; the log goes on, newest first.
outdated

data-timeline is read by nothing; remove it or give it a use. Also say 'Everything, newest first' in plain words per docs/WRITING.md, once.

395 const deeper = timeline ? html`` : html`<a href="/feed?before=${TIMELINE_START}">Everything, newest first</a>`;
396 const older = (n: string) => timeline
397 ? html`<a href="/feed?before=${encodeURIComponent(n)}">Older posts</a>`
398 : html`<a href="/feed?cursor=${encodeURIComponent(n)}&day=${encodeURIComponent(items[items.length - 1]?.at ?? "")}">Older posts</a>`;
378399 return next === null
379 ? html`<p class="bar muted small" data-caught-up="end"><span>That's everything</span><span class="spacer"></span>${newest}</p>`
400 ? html`<p class="bar muted small" data-caught-up="end"><span>${timeline ? html`That's every post` : html`That's everything`}</span><span class="spacer"></span>${newest}${deeper}</p>`
380401 : html`<p class="bar muted small" data-caught-up="more">
381 <span>You're caught up to ${time(oldest)}</span>
402 <span>${oldest === undefined ? "Nothing to show on this page" : html`You're caught up to ${time(oldest)}`}</span>
382403 <span class="spacer"></span>
383404 ${newest}
384 <a href="/feed?cursor=${encodeURIComponent(next)}&day=${encodeURIComponent(items[items.length - 1]!.at)}">Older posts</a>
405 ${older(next)}
385406 </p>`;
386407 }
387408
388409 function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>): Html {
389 const { items, next, cursor, lastDayOnPage, thoughts, placed, marks, pictures, quiet, quietLink } = read;
410 const { items, next, cursor, timeline, asked, lastDayOnPage, thoughts, placed, marks, pictures, quiet, quietLink } = read;
390411 const now = new Date();
391412 // One divider per change of day between neighbours, in the order the feed already has.
392413 const posts: Html[] = [];
@@ -405,6 +426,7 @@ function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>):
405426 before = item.at;
406427 }
407428 const liveQuery = [
429 timeline ? `before=${encodeURIComponent(asked)}` : "",
408430 cursor === undefined ? "" : `cursor=${encodeURIComponent(`${cursor.score}|${cursor.id}`)}`,
409431 cursor === undefined || lastDayOnPage === undefined ? "" : `day=${encodeURIComponent(lastDayOnPage)}`,
410432 ].filter(Boolean).join("&");
@@ -427,7 +449,7 @@ function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>):
427449 ${quiet === 0
428450 ? ""
429451 : html`<p class="small muted"><a href="${quietLink}">${quiet} quiet ${quiet === 1 ? "run" : "runs"} in the last 24 h</a></p>`}
430 ${caughtUp(items, next, cursor)}
452 ${caughtUp(items, next, cursor, timeline)}
431453 </section>`;
432454 }
433455
src/web/studio/home_last.ts +3
@@ -16,6 +16,7 @@ import { notePhase } from "../../core/route_timing.ts";
1616 import { forgetAll, recall, remember } from "../../core/region_memory.ts";
1717 import { writeStamp } from "../../core/write_clock.ts";
1818 import { parseCursor } from "../../feed/feed.ts";
19 import { parseBefore } from "../../feed/timeline.ts";
1920 import { escapeHtml, raw, type Html } from "../html.ts";
2021 import { HOME_MAX_AGE_MS } from "./home_cache.ts";
2122
@@ -37,6 +38,8 @@ export function homeVariant(url: URL): string {
3738 const day = url.searchParams.get("day") ?? "";
3839 if (/^\d{4}-\d{2}-\d{2}(T[\d:.]{1,16}Z?)?$/.test(day)) q.set("day", day);
3940 }
41 const before = url.searchParams.get("before") ?? "";
42 if (before.length <= 26 && parseBefore(before) !== undefined) q.set("before", before);
4043 const text = q.toString();
4144 return text === "" ? "" : `?${text}`;
4245 }
test/feed-cursor.test.ts added +238
@@ -0,0 +1,238 @@
1 /**
2 * The timeline pages by `before=<cursor>` (`feed/timeline.ts`): a page costs
3 * the page whatever its depth, an arrival between two requests neither
4 * repeats nor skips a post, and a cursor only names a place to start.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { createSession } from "../src/core/sessions.ts";
13 import { createToken } from "../src/core/tokens.ts";
14 import type { Repo } from "../src/core/repos.ts";
15 import { muteRepo } from "../src/core/interactions.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { parseBefore, readTimeline } from "../src/feed/timeline.ts";
19 import { promote } from "./helpers/members.ts";
20
21 describe("the feed pages by cursor", () => {
22 let dataDir: string;
23 let app: App;
24 let servers: Servers;
25 let alice: User;
26 let bob: User;
27 let aliceToken: string;
28 let bobToken: string;
29 let bobCookie: string;
30 let open: Repo;
31 let secret: Repo;
32 const post = (repo: Repo, n: number) =>
33 app.bus.append({ kind: "issue.opened", repoId: repo.id, actorId: alice.id, payload: { number: n } });
34
35 beforeAll(async () => {
36 dataDir = await mkdtemp(join(tmpdir(), "forge-feed-cursor-"));
37 app = createApp({ dataDir, port: 0 });
38 servers = await serve(app);
39 const mk = async (username: string, first = false) => {
40 const r = await createUser(app.db, {
41 origin: "seed",
42 trusted: true,
43 username,
44 allowReserved: true,
45 email: `${username}@forge.local`,
46 password: "a-good-passphrase",
47 adminIfFirst: first,
48 });
49 if (!r.ok) throw new Error(r.error);
50 promote(app.db, r.value);
51 return r.value;
52 };
53 await mk("root", true);
54 alice = await mk("alice");
55 bob = await mk("bob");
56 aliceToken = createToken(app.db, alice.id, "t", "rw").token;
57 bobToken = createToken(app.db, bob.id, "t", "rw").token;
58 bobCookie = `forge_session=${createSession(app.db, bob.id).token}`;
59 const o = await createRepo(app, alice, { name: "open", visibility: "public" });
60 const s = await createRepo(app, alice, { name: "secret", visibility: "private" });
61 if (!o.ok || !s.ok) throw new Error("repos");
62 open = o.value;
63 secret = s.value;
64 });
65
66 afterAll(async () => {
67 servers.stop();
68 app.close();
69 await rm(dataDir, { recursive: true, force: true });
70 });
71
72 const api = (token: string, path: string) =>
73 fetch(`${servers.url}${path}`, { headers: { authorization: `Bearer ${token}` } });
74 const numbers = (items: { link: string }[]) => items.map((i) => Number(i.link.split("/").pop()));
75
76 test("an arrival between page one and page two repeats nothing and skips nothing", async () => {
77 // Equal timestamps on purpose: a burst in one millisecond is ordered by the log, not the clock.
78 for (let n = 1; n <= 30; n++) post(open, n);
79 const one = (await (await api(aliceToken, "/api/v1/feed?before=now&limit=10")).json()) as {
80 items: { link: string }[];
81 next_cursor: string | null;
82 };
83 expect(numbers(one.items)).toEqual([30, 29, 28, 27, 26, 25, 24, 23, 22, 21]);
84 for (let n = 31; n <= 35; n++) post(open, n);
85 const two = (await (await api(aliceToken, `/api/v1/feed?before=${one.next_cursor}&limit=10`)).json()) as {
86 items: { link: string }[];
87 next_cursor: string | null;
88 };
89 expect(numbers(two.items)).toEqual([20, 19, 18, 17, 16, 15, 14, 13, 12, 11]);
90 const rest = readTimeline(app.db, alice, { before: two.next_cursor!, limit: 50 });
91 expect(numbers(rest.items)).toEqual([10, 9, 8, 7, 6, 5, 4, 3, 2, 1]);
92 expect(rest.next_cursor).toBeNull();
93 });
94
95 test("a malformed or tampered cursor is a 422, and a borrowed one reads nothing hidden", async () => {
96 for (const bad of ["abc", "'; DROP TABLE event;--", "x".repeat(200), "Z".repeat(26), "1|event:a"]) {
97 const res = await api(bobToken, `/api/v1/feed?before=${encodeURIComponent(bad)}`);
98 expect(res.status).toBe(422);
99 }
100 expect((await api(bobToken, "/api/v1/feed?before=now&cursor=1|event:a")).status).toBe(422);
101 // Alice's private event id, in Bob's hands: it names a place, and Bob still reads only what he may.
102 const hidden = post(secret, 999);
103 post(open, 1000);
104 const res = await api(bobToken, `/api/v1/feed?before=${hidden.id}&limit=50`);
105 expect(res.status).toBe(200);
106 const body = (await res.json()) as { items: { app: string }[] };
107 expect(body.items.every((i) => i.app === "alice/open")).toBe(true);
108 const all = readTimeline(app.db, bob, { limit: 50 });
109 expect(all.items.some((i) => i.app === "alice/secret")).toBe(false);
110 expect(parseBefore("now")).toEqual({ id: undefined });
111 expect(parseBefore(hidden.id)).toEqual({ id: hidden.id });
112 });
113
114 test("the 50th page reads the same rows as the first, from the event index", () => {
115 app.db.transaction(() => {
116 for (let n = 0; n < 5000; n++) post(open, 2000 + n);
117 })();
118 // The work, not the clock: every statement a deep page runs, with its plan.
119 const sqls: string[] = [];
120 const original = app.db.query.bind(app.db);
121 (app.db as unknown as { query: unknown }).query = (sql: string) => {
122 sqls.push(sql);
123 return original(sql);
124 };
125 let first = { items: [] as { id: string }[], next_cursor: null as string | null };
126 let deep = first;
127 try {
128 let before: string | undefined;
129 for (let p = 0; p < 50; p++) {
130 if (p === 49) sqls.length = 0;
131 const page = readTimeline(app.db, alice, { limit: 20, ...(before === undefined ? {} : { before }) });
132 if (p === 0) first = page;
133 deep = page;
134 before = page.next_cursor ?? undefined;
outdated

first*5+50 ms passes for nearly any implementation at this size, so it does not prove constant cost, and wall-clock asserts flake. Assert rows read or the query plan instead.

135 }
136 } finally {
137 (app.db as unknown as { query: unknown }).query = original;
138 }
139 expect(first.items.length).toBe(20);
140 expect(deep.items.length).toBe(20);
141 expect(deep.next_cursor).not.toBeNull();
142 const reads = sqls.filter((sql) => /FROM event JOIN repo/.test(sql));
143 expect(reads.length).toBe(1); // one batch of 100 serves a 20-post page, however deep
144 for (const sql of reads) {
145 const n = Math.max(...[...sql.matchAll(/\?(\d+)/g)].map((m) => Number(m[1])));
146 const plan = original(`EXPLAIN QUERY PLAN ${sql}`)
147 .all(...Array.from({ length: n }, () => "x"))
148 .map((r) => (r as { detail: string }).detail)
149 .join("\n");
150 expect(plan).not.toContain("TEMP B-TREE");
151 expect(plan).not.toMatch(/SCAN event(?! USING)/);
152 }
153 const seen = new Set<string>();
154 let before: string | undefined;
155 for (let p = 0; p < 50; p++) {
156 const page = readTimeline(app.db, alice, { limit: 20, ...(before === undefined ? {} : { before }) });
157 for (const i of page.items) {
158 expect(seen.has(i.id)).toBe(false);
159 seen.add(i.id);
160 }
161 before = page.next_cursor ?? undefined;
162 }
163 expect(seen.size).toBe(1000);
164 });
165
166 test("the older link works without script", async () => {
167 const res = await fetch(`${servers.url}/feed?before=now`, { headers: { cookie: bobCookie } });
168 expect(res.status).toBe(200);
169 const text = await res.text();
170 const href = /href="(\/feed\?before=[0-9a-z]{26})">Older posts/.exec(text);
171 expect(href).not.toBeNull();
172 const next = await fetch(`${servers.url}${href![1]}`, { headers: { cookie: bobCookie } });
173 expect(next.status).toBe(200);
174 expect(await next.text()).toContain('data-item="event:');
175 expect((await fetch(`${servers.url}/feed?before=garbage`, { headers: { cookie: bobCookie } })).status).toBe(200);
176 });
177
178 const mcp = async (token: string, args: object) => {
179 const res = await fetch(`${servers.url}/mcp`, {
180 method: "POST",
181 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
182 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "feed", arguments: args } }),
183 });
184 const out = (await res.json()) as { result: { isError?: boolean; content: { text: string }[] } };
185 return { error: out.result.isError === true, text: out.result.content[0]!.text };
186 };
187
188 test("MCP feed takes before, pages by it, and refuses before with cursor or garbage", async () => {
189 const one = await mcp(aliceToken, { before: "now", limit: 5 });
190 expect(one.error).toBe(false);
191 const page = JSON.parse(one.text) as { items: { id: string }[]; next_cursor: string };
192 expect(page.items).toHaveLength(5);
193 const two = JSON.parse((await mcp(aliceToken, { before: page.next_cursor, limit: 5 })).text) as { items: { id: string }[] };
194 expect(two.items).toHaveLength(5);
195 expect(two.items.some((i) => page.items.some((j) => j.id === i.id))).toBe(false);
196 expect((await mcp(aliceToken, { before: "now", cursor: "1|event:a" })).error).toBe(true);
197 expect((await mcp(aliceToken, { before: "garbage" })).error).toBe(true);
198 });
199
200 test("a timeline page answers 304 to its own ETag and a different one to another cursor", async () => {
201 const url = "/api/v1/feed?before=now&limit=5";
202 const res = await api(aliceToken, url);
203 const tag = res.headers.get("etag");
204 expect(tag).not.toBeNull();
205 const again = await fetch(`${servers.url}${url}`, { headers: { authorization: `Bearer ${aliceToken}`, "if-none-match": tag! } });
206 expect(again.status).toBe(304);
207 const { next_cursor } = (await res.json()) as { next_cursor: string };
208 const other = await api(aliceToken, `/api/v1/feed?before=${next_cursor}&limit=5`);
209 expect(other.headers.get("etag")).not.toBe(tag);
210 const stale = await fetch(`${servers.url}/api/v1/feed?before=${next_cursor}&limit=5`, { headers: { authorization: `Bearer ${aliceToken}`, "if-none-match": tag! } });
211 expect(stale.status).toBe(200);
212 });
213
214 test("a page whose filters hide a whole batch is short, carries a cursor, and the page still links on", async () => {
215 const r = await createUser(app.db, {
216 origin: "seed", trusted: true, username: "carol", allowReserved: true,
217 email: "[email protected]", password: "a-good-passphrase", adminIfFirst: false,
218 });
219 if (!r.ok) throw new Error(r.error);
220 promote(app.db, r.value);
221 const carol = r.value;
222 const noisy = await createRepo(app, alice, { name: "noisy", visibility: "public" });
223 if (!noisy.ok) throw new Error("repo");
224 app.db.transaction(() => {
225 for (let n = 0; n < 600; n++) post(noisy.value, 9000 + n);
226 })();
227 muteRepo(app.db, carol.id, noisy.value.id);
228 const page = readTimeline(app.db, carol, { limit: 5 });
229 expect(page.items).toEqual([]);
230 expect(page.next_cursor).not.toBeNull();
231 const cookie = `forge_session=${createSession(app.db, carol.id).token}`;
232 const html = await (await fetch(`${servers.url}/feed?before=now`, { headers: { cookie } })).text();
233 expect(html).toContain(`/feed?before=${page.next_cursor}">Older posts`);
234 // Following it reaches the posts the mute left.
235 const on = await (await fetch(`${servers.url}/feed?before=${page.next_cursor}`, { headers: { cookie } })).text();
236 expect(on).toContain('data-item="event:');
237 });
238 });
test/tui-feed-reply.test.ts +12
@@ -173,3 +173,15 @@ test("l on a post the person cannot read likes nothing", async () => {
173173 expect(forge.db.query("SELECT 1 FROM feed_like WHERE repo_id = ?1").get(secret)).toBeNull();
174174 t.stop();
175175 });
176
177 test("t switches the feed to every post, newest first, and back", async () => {
178 const t = boot("alice");
179 await t.open();
180 expect(t.text()).toContain("newest first");
181 await t.press("t");
182 expect(t.text()).toContain("ranked");
183 expect(t.text()).toContain("Hidden question");
184 await t.press("t");
185 expect(t.text()).toContain("newest first");
186 t.stop();
187 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs