Public: anyone can read this; the crew can change it.
GET /api/v1/feed's first page carries the "Since you were here" digest, as the MCP feed tool's does #9107
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9104-get-api-v1-feed-s-first-page-carries-the → main 2 commits fork
6 files
changed +100
−5
src/contract/openapi/routes/getApiV1Feed.ts +1 −1
| @@ -4,7 +4,7 @@ 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`", | |
| 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", | |
| 8 | 8 | auth: true, |
| 9 | 9 | query: { limit: limitTo(FEED_LIMIT_MAX), cursor: "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)" }, |
src/feed/feed_tag.ts +4
| @@ -31,6 +31,8 @@ export function feedTag( | ||
| 31 | 31 | readonly cursor: string | null; |
| 32 | 32 | readonly limit: number | null; |
| 33 | 33 | readonly now?: number; |
| 34 | /** The viewer's last visit to `/`, which the first page's digest is cut from. */ | |
| 35 | readonly visit?: number | undefined; | |
| 34 | 36 | }, |
| 35 | 37 | ): string | undefined { |
| 36 | 38 | const stamp = writeStamp(db); |
| @@ -44,6 +46,8 @@ export function feedTag( | ||
| 44 | 46 | input.limit ?? "", |
| 45 | 47 | stamp, |
| 46 | 48 | Math.floor(now / TAG_BUCKET_MS), |
| 49 | // Only the first page carries a digest, and the digest reads the last visit. | |
| 50 | input.cursor === null ? (input.visit ?? "") : "", | |
| 47 | 51 | ]; |
| 48 | 52 | return `W/"${Bun.hash(JSON.stringify(parts)).toString(36)}"`; |
| 49 | 53 | } |
src/http/feed.ts +9 −1
| @@ -24,6 +24,8 @@ import { CURSOR_NOT_ISSUED, KIND_HAS_NO_CHIP } from "../actions/feed_words.ts"; | ||
| 24 | 24 | import { getUserByName, getUsersByIds } from "../core/users.ts"; |
| 25 | 25 | import { drainQueue } from "../core/shares.ts"; |
| 26 | 26 | import { feedTag, hasQueuedShares } from "../feed/feed_tag.ts"; |
| 27 | import { lastVisit } from "../core/home_visit.ts"; | |
| 28 | import { feedDigest } from "../web/studio/since.ts"; | |
| 27 | 29 | import { holds } from "../web/feeds.ts"; |
| 28 | 30 | import { share, RECIPIENTS_MAX } from "../feed/share.ts"; |
| 29 | 31 | import { canRead, getRepo } from "../core/repos.ts"; |
| @@ -86,6 +88,7 @@ export function feedRoutes(router: Router<Handler>): void { | ||
| 86 | 88 | cursor: rawCursor, |
| 87 | 89 | limit: limit ?? null, |
| 88 | 90 | now, |
| 91 | visit: rawCursor === null ? lastVisit(ctx.app.db, viewer.id) : undefined, | |
| 89 | 92 | }); |
| 90 | 93 | const headers: Record<string, string> = { |
| 91 | 94 | "cache-control": "private, no-store", |
| @@ -105,8 +108,13 @@ export function feedRoutes(router: Router<Handler>): void { | ||
| 105 | 108 | ...(cursor === undefined ? {} : { cursor }), |
| 106 | 109 | }), |
| 107 | 110 | ); |
| 111 | // The first page carries home's "Since you were here" card, as MCP `feed` does. | |
| 112 | const digest = | |
| 113 | cursor === undefined | |
| 114 | ? timePhase(ctx.request, "digest", () => feedDigest(ctx.app.db, viewer, now)) | |
| 115 | : undefined; | |
| 108 | 116 | return timePhase(ctx.request, "render", () => |
| 109 | json(page, 200, headers), | |
| 117 | json(cursor === undefined ? { ...page, digest } : page, 200, headers), | |
| 110 | 118 | ); |
| 111 | 119 | }); |
| 112 | 120 |
src/http/mcp/tools/feed.ts +2 −3
| @@ -1,7 +1,6 @@ | ||
| 1 | 1 | import { FEED_LIMIT_DEFAULT, FEED_LIMIT_MAX, parseCursor, readFeed } from "../../../feed/feed.ts"; |
| 2 | 2 | import { CURSOR_NOT_ISSUED } from "../../../actions/feed_words.ts"; |
| 3 | import { lastVisit } from "../../../core/home_visit.ts"; | |
| 4 | import { sinceDigest } from "../../../web/studio/since.ts"; | |
| 3 | import { feedDigest } from "../../../web/studio/since.ts"; | |
| 5 | 4 | import { good, bad, type Tool, CHIP_ARGS, chipArgs, str } from "../shared.ts"; |
| 6 | 5 | |
| 7 | 6 | export const tool: Tool = { |
| @@ -34,6 +33,6 @@ export const tool: Tool = { | ||
| 34 | 33 | ...(chips === undefined ? {} : { chips }), |
| 35 | 34 | ...(cursor === undefined ? {} : { cursor }), |
| 36 | 35 | }); |
| 37 | return good({ ...page, ...(cursor === undefined ? { digest: sinceDigest(ctx.app.db, user, lastVisit(ctx.app.db, user.id), Date.now()) ?? null } : {}) }); | |
| 36 | return good({ ...page, ...(cursor === undefined ? { digest: feedDigest(ctx.app.db, user, Date.now()) } : {}) }); | |
| 38 | 37 | }, |
| 39 | 38 | }; |
src/web/studio/since.ts +6
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import type { Database } from "bun:sqlite"; |
| 9 | import { lastVisit as visitOf } from "../../core/home_visit.ts"; | |
| 9 | 10 | import { canRead, getRepoById, type Repo } from "../../core/repos.ts"; |
| 10 | 11 | import type { User } from "../../core/users.ts"; |
| 11 | 12 | import { digest, headingOf, windowOf, type DigestEvent, type DigestLine } from "../../feed/digest.ts"; |
| @@ -104,6 +105,11 @@ export function sinceDigest( | ||
| 104 | 105 | return lines.length === 0 ? undefined : { heading: headingOf(window), lines }; |
| 105 | 106 | } |
| 106 | 107 | |
| 108 | /** The digest a feed's first page carries (HTTP and MCP alike): read-only, a visit is never noted; `null` when there is nothing. */ | |
| 109 | export function feedDigest(db: Database, viewer: User, now: number): { heading: string; lines: DigestLine[] } | null { | |
| 110 | return sinceDigest(db, viewer, visitOf(db, viewer.id), now) ?? null; | |
| 111 | } | |
| 112 | ||
| 107 | 113 | export function sinceYouWere(db: Database, viewer: User | undefined, lastVisit: number | undefined, now: number): Html { |
| 108 | 114 | const d = sinceDigest(db, viewer, lastVisit, now); |
| 109 | 115 | if (d === undefined) return html``; |
test/feed-digest-http.test.ts added +78
| @@ -0,0 +1,78 @@ | ||
| 1 | /** GET /api/v1/feed's first page carries the "Since you were here" digest, as MCP `feed` does (#9104). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createToken } from "../src/core/tokens.ts"; | |
| 9 | import { createUser, type User } from "../src/core/users.ts"; | |
| 10 | import { lastVisit } from "../src/core/home_visit.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | import { promote } from "./helpers/members.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | let dir: string; | |
| 18 | let app: App; | |
| 19 | let servers: Servers; | |
| 20 | let alice: User; | |
| 21 | let token: string; | |
| 22 | ||
| 23 | beforeAll(async () => { | |
| 24 | dir = await mkdtemp(join(tmpdir(), "forge-feed-digest-http-")); | |
| 25 | app = createApp({ dataDir: dir, port: 0 }); | |
| 26 | servers = await serve(app); | |
| 27 | const r = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 28 | if (!r.ok) throw new Error(r.error); | |
| 29 | alice = r.value; | |
| 30 | promote(app.db, alice); | |
| 31 | token = createToken(app.db, alice.id, "digest", "ro").token; | |
| 32 | const g = await createRepo(app, alice, { name: "garden", visibility: "public" }); | |
| 33 | if (!g.ok) throw new Error("repo"); | |
| 34 | for (const v of ["1.2.3", "1.2.4"]) app.bus.append({ kind: "app.serving", repoId: g.value.id, payload: { app: "garden", env: "production", commitOid: "a".repeat(40), version: v } }); | |
| 35 | app.db.query("INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES ('d1', ?1, 1, 'pull', 't', ?2)").run(g.value.id, alice.id); | |
| 36 | app.db.query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES ('d1', 'main', 'h', 'a', 'b', ?1)").run(new Date(Date.now() - 600_000).toISOString()); | |
| 37 | }); | |
| 38 | ||
| 39 | afterAll(async () => { | |
| 40 | servers.stop?.(); | |
| 41 | app.close(); | |
| 42 | await rm(dir, { recursive: true, force: true }); | |
| 43 | }); | |
| 44 | ||
| 45 | const http = (query = "", etag?: string) => | |
| 46 | fetch(`${servers.url}/api/v1/feed${query}`, { headers: { authorization: `Bearer ${token}`, ...(etag ? { "if-none-match": etag } : {}) } }); | |
| 47 | ||
| 48 | const mcp = async (args: object): Promise<any> => { | |
| 49 | const res = await fetch(`${servers.url}/mcp`, { | |
| 50 | method: "POST", | |
| 51 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 52 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "feed", arguments: args } }), | |
| 53 | }); | |
| 54 | return JSON.parse(((await res.json()) as any).result.content[0].text); | |
| 55 | }; | |
| 56 | ||
| 57 | test("the first page carries the digest MCP carries; a page with a cursor carries none", async () => { | |
| 58 | const first = await http("?limit=1"); | |
| 59 | const page: any = await first.json(); | |
| 60 | expect(page.digest.lines.length).toBeGreaterThan(0); | |
| 61 | expect(first.headers.get("server-timing")).toContain("digest"); | |
| 62 | expect(page.digest).toEqual((await mcp({ limit: 1 })).digest); | |
| 63 | expect(page.next_cursor).toBeTruthy(); | |
| 64 | const next: any = await (await http(`?limit=1&cursor=${encodeURIComponent(page.next_cursor)}`)).json(); | |
| 65 | expect("digest" in next).toBe(false); | |
| 66 | expect(lastVisit(app.db, alice.id)).toBeUndefined(); | |
| 67 | }); | |
| 68 | ||
| 69 | test("the ETag moves with the last visit, so a 304 never hides a new digest", async () => { | |
| 70 | const res = await http(); | |
| 71 | await res.arrayBuffer(); | |
| 72 | const tag = res.headers.get("etag")!; | |
| 73 | expect((await http("", tag)).status).toBe(304); | |
| 74 | app.db.query("INSERT INTO home_visit (user_id, at) VALUES (?1, ?2) ON CONFLICT(user_id) DO UPDATE SET at = excluded.at").run(alice.id, Date.now() - 3_600_000); | |
| 75 | const after = await http("", tag); | |
| 76 | expect(after.status).toBe(200); | |
| 77 | expect(after.headers.get("etag")).not.toBe(tag); | |
| 78 | }); |
new above
offline