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

Merged opened this

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

Merged

mason/forge:mason/9104-get-api-v1-feed-s-first-page-carries-the → main 2 commits fork

  • Merged. mason/9104-get-api-v1-feed-s-first-page-carries-the was merged into main as b22240a.

Review

Sign in to review this change.
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";
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`",
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",
88 auth: true,
99 query: { limit: limitTo(FEED_LIMIT_MAX), cursor: "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)" },
src/feed/feed_tag.ts +4
@@ -31,6 +31,8 @@ export function feedTag(
3131 readonly cursor: string | null;
3232 readonly limit: number | null;
3333 readonly now?: number;
34 /** The viewer's last visit to `/`, which the first page's digest is cut from. */
35 readonly visit?: number | undefined;
3436 },
3537 ): string | undefined {
3638 const stamp = writeStamp(db);
@@ -44,6 +46,8 @@ export function feedTag(
4446 input.limit ?? "",
4547 stamp,
4648 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 ?? "") : "",
4751 ];
4852 return `W/"${Bun.hash(JSON.stringify(parts)).toString(36)}"`;
4953 }
src/http/feed.ts +9 −1
@@ -24,6 +24,8 @@ import { CURSOR_NOT_ISSUED, KIND_HAS_NO_CHIP } from "../actions/feed_words.ts";
2424 import { getUserByName, getUsersByIds } from "../core/users.ts";
2525 import { drainQueue } from "../core/shares.ts";
2626 import { feedTag, hasQueuedShares } from "../feed/feed_tag.ts";
27 import { lastVisit } from "../core/home_visit.ts";
28 import { feedDigest } from "../web/studio/since.ts";
2729 import { holds } from "../web/feeds.ts";
2830 import { share, RECIPIENTS_MAX } from "../feed/share.ts";
2931 import { canRead, getRepo } from "../core/repos.ts";
@@ -86,6 +88,7 @@ export function feedRoutes(router: Router<Handler>): void {
8688 cursor: rawCursor,
8789 limit: limit ?? null,
8890 now,
91 visit: rawCursor === null ? lastVisit(ctx.app.db, viewer.id) : undefined,
8992 });
9093 const headers: Record<string, string> = {
9194 "cache-control": "private, no-store",
@@ -105,8 +108,13 @@ export function feedRoutes(router: Router<Handler>): void {
105108 ...(cursor === undefined ? {} : { cursor }),
106109 }),
107110 );
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;
108116 return timePhase(ctx.request, "render", () =>
109 json(page, 200, headers),
117 json(cursor === undefined ? { ...page, digest } : page, 200, headers),
110118 );
111119 });
112120
src/http/mcp/tools/feed.ts +2 −3
@@ -1,7 +1,6 @@
11 import { FEED_LIMIT_DEFAULT, FEED_LIMIT_MAX, parseCursor, readFeed } from "../../../feed/feed.ts";
22 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";
54 import { good, bad, type Tool, CHIP_ARGS, chipArgs, str } from "../shared.ts";
65
76 export const tool: Tool = {
@@ -34,6 +33,6 @@ export const tool: Tool = {
3433 ...(chips === undefined ? {} : { chips }),
3534 ...(cursor === undefined ? {} : { cursor }),
3635 });
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()) } : {}) });
3837 },
3938 };
src/web/studio/since.ts +6
@@ -6,6 +6,7 @@
66 */
77
88 import type { Database } from "bun:sqlite";
9 import { lastVisit as visitOf } from "../../core/home_visit.ts";
910 import { canRead, getRepoById, type Repo } from "../../core/repos.ts";
1011 import type { User } from "../../core/users.ts";
1112 import { digest, headingOf, windowOf, type DigestEvent, type DigestLine } from "../../feed/digest.ts";
@@ -104,6 +105,11 @@ export function sinceDigest(
104105 return lines.length === 0 ? undefined : { heading: headingOf(window), lines };
105106 }
106107
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
107113 export function sinceYouWere(db: Database, viewer: User | undefined, lastVisit: number | undefined, now: number): Html {
108114 const d = sinceDigest(db, viewer, lastVisit, now);
109115 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs