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.
scout/forge:scout/10392-the-feed-pages-by-cursor-not-by-page-num → main 2 commits fork 4 threads · 4 unresolved
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"; | ||
| 4 | 4 | export const route: Route = { |
| 5 | 5 | method: "get", |
| 6 | 6 | 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", | |
| 8 | 8 | auth: true, |
| 9 | query: { limit: limitTo(FEED_LIMIT_MAX), cursor: "string" }, | |
| 9 | query: { limit: limitTo(FEED_LIMIT_MAX), cursor: "string", before: "string" }, | |
| 10 | 10 | refusals: { invalid_limit: "limit is not a whole number from 1 to 50 (422)", invalid_cursor: "the cursor was not issued here (422)" }, |
| 11 | 11 | }; |
src/core/events.ts +4
| @@ -165,6 +165,10 @@ export function listEventsVisibleTo( | ||
| 165 | 165 | params.push(options.afterId); |
| 166 | 166 | where += ` AND ${afterEvent(params.length)}`; |
| 167 | 167 | } |
| 168 | if (options.beforeId !== undefined) { | |
| 169 | params.push(options.beforeId); | |
| 170 | where += ` AND ${beforeEvent(params.length)}`; | |
| 171 | } | |
| 168 | 172 | params.push(options.limit ?? 100); |
| 169 | 173 | return db |
| 170 | 174 | .query<EventRow, (string | number)[]>( |
src/feed/feed.ts +26 −16
| @@ -144,6 +144,31 @@ type FeedOptions = { | ||
| 144 | 144 | phase?: (name: string, ms: number) => void; |
| 145 | 145 | }; |
| 146 | 146 | |
| 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 | ||
| 147 | 172 | /** The page, with the quiet runs it left out and where most of them are. */ |
| 148 | 173 | export function readFeedPage( |
| 149 | 174 | db: Database, |
| @@ -203,22 +228,7 @@ export function readFeedPage( | ||
| 203 | 228 | : ranked.filter((i) => i.score < cursor.score); |
| 204 | 229 | } |
| 205 | 230 | 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); | |
| 222 | 232 | const last = items[items.length - 1]; |
| 223 | 233 | return { |
| 224 | 234 | items, |
src/feed/items.ts +35 −19
| @@ -334,29 +334,18 @@ function hotCandidates(writable: readonly Repo[]): Candidate[] { | ||
| 334 | 334 | return out; |
| 335 | 335 | } |
| 336 | 336 | |
| 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( | |
| 338 | 343 | db: Database, |
| 339 | 344 | viewer: User, |
| 340 | 345 | 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>, | |
| 344 | 348 | ): 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 | ||
| 360 | 349 | const names = new Map<string, string | null>(); |
| 361 | 350 | const nameOf = (id: string | null): string | null => { |
| 362 | 351 | if (id === null) return null; |
| @@ -415,6 +404,33 @@ export function candidatesFor( | ||
| 415 | 404 | ...(fold === undefined ? {} : { what: `${c.what} · ${foldedWords(fold, now)}` }), |
| 416 | 405 | }); |
| 417 | 406 | } |
| 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); | |
| 418 | 434 | for (const a of asks) { |
| 419 | 435 | const repo = repos.get(a.repoId); |
| 420 | 436 | 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"; | ||
| 47 | 47 | import { notePhase, takeSessionTime, timePhase } from "../core/route_timing.ts"; |
| 48 | 48 | import type { Handler } from "./context.ts"; |
| 49 | 49 | import type { Router } from "./router.ts"; |
| 50 | import { parseBefore, readTimeline } from "../feed/timeline.ts"; | |
| 50 | 51 | import { HIDDEN_WORDS } from "../core/arg_words.ts"; |
| 51 | 52 | import { |
| 52 | 53 | apiError, |
| @@ -78,6 +79,11 @@ export function feedRoutes(router: Router<Handler>): void { | ||
| 78 | 79 | const cursor = rawCursor === null ? undefined : parseCursor(rawCursor); |
| 79 | 80 | if (rawCursor !== null && cursor === undefined) |
| 80 | 81 | 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); | |
| 81 | 87 | // A 304 only when nothing the viewer would read has changed (`feedTag`). |
| 82 | 88 | // The queue drains first, as `readFeed` would; shares still waiting |
| 83 | 89 | // depend on the clock, so they always get a page. |
| @@ -85,7 +91,7 @@ export function feedRoutes(router: Router<Handler>): void { | ||
| 85 | 91 | drainQueue(ctx.app.db, viewer.id, now); |
| 86 | 92 | const tag = feedTag(ctx.app.db, viewer, { |
| 87 | 93 | chips: chipsOf(ctx.app.db, viewer.id), |
| 88 | cursor: rawCursor, | |
| 94 | cursor: rawBefore === null ? rawCursor : `before:${rawBefore}`, | |
| 89 | 95 | limit: limit ?? null, |
| 90 | 96 | now, |
| 91 | 97 | visit: rawCursor === null ? lastVisit(ctx.app.db, viewer.id) : undefined, |
| @@ -101,6 +107,19 @@ export function feedRoutes(router: Router<Handler>): void { | ||
| 101 | 107 | !hasQueuedShares(ctx.app.db, viewer.id) |
| 102 | 108 | ) |
| 103 | 109 | 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 | ); | |
| 104 | 123 | const page = timePhase(ctx.request, "feed", () => |
| 105 | 124 | readFeed(ctx.app.db, viewer, { |
| 106 | 125 | phase: (name, ms) => notePhase(ctx.request, name, ms), |
src/http/mcp/tools/feed.ts +15 −1
| @@ -1,11 +1,12 @@ | ||
| 1 | 1 | import { FEED_LIMIT_DEFAULT, FEED_LIMIT_MAX, parseCursor, readFeed } from "../../../feed/feed.ts"; |
| 2 | import { parseBefore, readTimeline } from "../../../feed/timeline.ts"; | |
| 2 | 3 | import { CURSOR_NOT_ISSUED } from "../../../actions/feed_words.ts"; |
| 3 | 4 | import { feedDigest } from "../../../web/studio/since.ts"; |
| 4 | 5 | import { good, bad, type Tool, CHIP_ARGS, chipArgs, str } from "../shared.ts"; |
| 5 | 6 | |
| 6 | 7 | export const tool: Tool = { |
| 7 | 8 | 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", | |
| 9 | 10 | description: |
| 10 | 11 | "Your ranked home timeline; `cursor` pages; saved filter applies.", |
| 11 | 12 | inputSchema: { |
| @@ -13,6 +14,7 @@ export const tool: Tool = { | ||
| 13 | 14 | properties: { |
| 14 | 15 | limit: { type: "integer", minimum: 1, maximum: FEED_LIMIT_MAX }, |
| 15 | 16 | cursor: { type: "string" }, |
| 17 | before: { type: "string" }, | |
| 16 | 18 | ...CHIP_ARGS, |
| 17 | 19 | }, |
| 18 | 20 | }, |
| @@ -28,6 +30,18 @@ export const tool: Tool = { | ||
| 28 | 30 | const cursor = given === undefined ? undefined : parseCursor(str(given)); |
| 29 | 31 | if (given !== undefined && cursor === undefined) |
| 30 | 32 | 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 | } | |
| 31 | 45 | const page = readFeed(ctx.app.db, user, { |
| 32 | 46 | limit, |
| 33 | 47 | ...(chips === undefined ? {} : { chips }), |
src/tui/screens/feed.ts +23 −2
| @@ -7,12 +7,13 @@ | ||
| 7 | 7 | * posts to the same people as the page's Reply (`feed/reply.ts`). `l` likes |
| 8 | 8 | * the post under the cursor and a second press takes it back: the page's |
| 9 | 9 | * 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. | |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { addCommentThrough } from "../../actions/issues.ts"; |
| 14 | 14 | import { getIssue } from "../../core/issues.ts"; |
| 15 | 15 | import { parseCursor, readFeed } from "../../feed/feed.ts"; |
| 16 | import { parseBefore, readTimeline, TIMELINE_START } from "../../feed/timeline.ts"; | |
| 16 | 17 | import type { FeedItem } from "../../feed/rank.ts"; |
| 17 | 18 | import { readInteraction } from "../../feed/interact.ts"; |
| 18 | 19 | import { toggleLike } from "../../feed/likes.ts"; |
| @@ -40,9 +41,22 @@ export function feedScreen(f: Session, ctx: Ctx): Screen { | ||
| 40 | 41 | const composer = new Composer(); |
| 41 | 42 | const items: FeedItem[] = []; |
| 42 | 43 | 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; | |
| 43 | 46 | |
| 44 | 47 | const read = (cursor?: string): void => { |
| 45 | 48 | 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 | } | |
| 46 | 60 | const parsed = cursor === undefined ? undefined : parseCursor(cursor); |
| 47 | 61 | const page = readFeed(f.db, f.user, { limit: PAGE, ...(parsed ? { cursor: parsed } : {}) }); |
| 48 | 62 | items.push(...page.items); |
| @@ -97,6 +111,7 @@ export function feedScreen(f: Session, ctx: Ctx): Screen { | ||
| 97 | 111 | ...(selected() === undefined ? [] : [binding(["r"], "reply")]), |
| 98 | 112 | ...(items.length === 0 ? [] : [binding(["l"], "like")]), |
| 99 | 113 | ...(next === null ? [] : [binding(["n"], "next page")]), |
| 114 | binding(["t"], timeline ? "ranked" : "newest first"), | |
| 100 | 115 | binding(["esc", "q"], "back"), |
| 101 | 116 | ]; |
| 102 | 117 | }, |
| @@ -129,7 +144,13 @@ export function feedScreen(f: Session, ctx: Ctx): Screen { | ||
| 129 | 144 | const at = selected(); |
| 130 | 145 | if (at !== undefined) composer.start(`Reply on ${at.thread.owner}/${at.thread.name} #${at.thread.number}`); |
| 131 | 146 | } 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") { | |
| 133 | 154 | if (next !== null) read(next); |
| 134 | 155 | } else state.key(e, items.length); |
| 135 | 156 | return true; |
src/web/studio/feed.ts +38 −16
| @@ -17,6 +17,7 @@ import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.t | ||
| 17 | 17 | import { placementsOf, type Placement } from "../../core/placements.ts"; |
| 18 | 18 | import { recentThoughts, type Thought } from "../../core/thoughts.ts"; |
| 19 | 19 | import { parseCursor, readFeedPage } from "../../feed/feed.ts"; |
| 20 | import { parseBefore, readTimeline, TIMELINE_START } from "../../feed/timeline.ts"; | |
| 20 | 21 | import type { FeedItem } from "../../feed/rank.ts"; |
| 21 | 22 | import { replyPath, replyRepo, threadOf } from "../../feed/reply.ts"; |
| 22 | 23 | import { getAppByRepo } from "../../core/apps.ts"; |
| @@ -338,15 +339,29 @@ function readRegion(user: User, ctx: Ctx) { | ||
| 338 | 339 | // ask is never a second time a post below it; the feed API keeps them. |
| 339 | 340 | // `?cursor=` is a page the line below the last post pointed at; one not issued here is the first page. |
| 340 | 341 | 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") ?? ""; | |
| 347 | 362 | const items = page.items; |
| 348 | 363 | // 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") ?? ""); | |
| 350 | 365 | const lastDayOnPage = Number.isFinite(Date.parse(day)) ? new Date(day).toISOString() : undefined; |
| 351 | 366 | const thoughts = recentThoughts(ctx.app.db, user.id, THOUGHTS_SHOWN, new Date(Date.now() - THOUGHT_AGE_MS).toISOString()); |
| 352 | 367 | const placed = placementsOf(ctx.app.db, thoughts.map((t) => t.id)); |
| @@ -358,7 +373,7 @@ function readRegion(user: User, ctx: Ctx) { | ||
| 358 | 373 | const quiet = page.quiet; |
| 359 | 374 | const quietRepo = quiet.repoId === null ? undefined : getRepoById(ctx.app.db, quiet.repoId); |
| 360 | 375 | 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 }; | |
| 362 | 377 | } |
| 363 | 378 | |
| 364 | 379 | /** The region: cards, or one line saying there is nothing to do. */ |
| @@ -370,23 +385,29 @@ export function feedRegion(user: User, ctx: Ctx): Html { | ||
| 370 | 385 | * The line under the last post: how far back the page reaches and the way |
| 371 | 386 | * to older ones, or that there is nothing older. |
| 372 | 387 | */ |
| 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 { | |
| 374 | 389 | // Ranked by score, not time: the last card is not always the oldest. |
| 375 | 390 | 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. | |
| 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
|
||
| 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>`; | |
| 378 | 399 | 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>` | |
| 380 | 401 | : 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> | |
| 382 | 403 | <span class="spacer"></span> |
| 383 | 404 | ${newest} |
| 384 | <a href="/feed?cursor=${encodeURIComponent(next)}&day=${encodeURIComponent(items[items.length - 1]!.at)}">Older posts</a> | |
| 405 | ${older(next)} | |
| 385 | 406 | </p>`; |
| 386 | 407 | } |
| 387 | 408 | |
| 388 | 409 | 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; | |
| 390 | 411 | const now = new Date(); |
| 391 | 412 | // One divider per change of day between neighbours, in the order the feed already has. |
| 392 | 413 | const posts: Html[] = []; |
| @@ -405,6 +426,7 @@ function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>): | ||
| 405 | 426 | before = item.at; |
| 406 | 427 | } |
| 407 | 428 | const liveQuery = [ |
| 429 | timeline ? `before=${encodeURIComponent(asked)}` : "", | |
| 408 | 430 | cursor === undefined ? "" : `cursor=${encodeURIComponent(`${cursor.score}|${cursor.id}`)}`, |
| 409 | 431 | cursor === undefined || lastDayOnPage === undefined ? "" : `day=${encodeURIComponent(lastDayOnPage)}`, |
| 410 | 432 | ].filter(Boolean).join("&"); |
| @@ -427,7 +449,7 @@ function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>): | ||
| 427 | 449 | ${quiet === 0 |
| 428 | 450 | ? "" |
| 429 | 451 | : 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)} | |
| 431 | 453 | </section>`; |
| 432 | 454 | } |
| 433 | 455 |
src/web/studio/home_last.ts +3
| @@ -16,6 +16,7 @@ import { notePhase } from "../../core/route_timing.ts"; | ||
| 16 | 16 | import { forgetAll, recall, remember } from "../../core/region_memory.ts"; |
| 17 | 17 | import { writeStamp } from "../../core/write_clock.ts"; |
| 18 | 18 | import { parseCursor } from "../../feed/feed.ts"; |
| 19 | import { parseBefore } from "../../feed/timeline.ts"; | |
| 19 | 20 | import { escapeHtml, raw, type Html } from "../html.ts"; |
| 20 | 21 | import { HOME_MAX_AGE_MS } from "./home_cache.ts"; |
| 21 | 22 | |
| @@ -37,6 +38,8 @@ export function homeVariant(url: URL): string { | ||
| 37 | 38 | const day = url.searchParams.get("day") ?? ""; |
| 38 | 39 | if (/^\d{4}-\d{2}-\d{2}(T[\d:.]{1,16}Z?)?$/.test(day)) q.set("day", day); |
| 39 | 40 | } |
| 41 | const before = url.searchParams.get("before") ?? ""; | |
| 42 | if (before.length <= 26 && parseBefore(before) !== undefined) q.set("before", before); | |
| 40 | 43 | const text = q.toString(); |
| 41 | 44 | return text === "" ? "" : `?${text}`; |
| 42 | 45 | } |
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 () => { | ||
| 173 | 173 | expect(forge.db.query("SELECT 1 FROM feed_like WHERE repo_id = ?1").get(secret)).toBeNull(); |
| 174 | 174 | t.stop(); |
| 175 | 175 | }); |
| 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 | }); |
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.