Public: anyone can read this; the crew can change it.
The feed's cursor and chip refusals live in one module, read by the feed routes and MCP #5675
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/5648-the-feed-s-cursor-and-chip-refusals-live → main 7 commits fork 3 threads · 3 unresolved
6 files
changed +53
−5
src/actions/feed_words.ts added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | /** | |
| 2 | * The feed's refusals in words (#5648): a cursor from elsewhere and a chip | |
| 3 | * of an unknown kind read the same from every door — the feed routes and | |
| 4 | * MCP — because they are said here, once. | |
| 5 | */ | |
| 6 | ||
| 7 | /** A feed cursor this forge did not hand out. */ | |
| 8 | export const CURSOR_NOT_ISSUED = "that cursor was not issued here"; | |
| 9 | ||
| 10 | /** A chip asked for a kind the feed has no chip for. */ | |
| 11 | export const KIND_HAS_NO_CHIP = "that kind has no chip"; |
src/http/feed.ts +3 −2
| @@ -20,6 +20,7 @@ import { | ||
| 20 | 20 | recordInteraction, |
| 21 | 21 | } from "../core/interactions.ts"; |
| 22 | 22 | import { chipsOf, isKindChip, setChip } from "../core/feed_chips.ts"; |
| 23 | import { CURSOR_NOT_ISSUED, KIND_HAS_NO_CHIP } from "../actions/feed_words.ts"; | |
| 23 | 24 | import { getUserByName, getUsersByIds } from "../core/users.ts"; |
| 24 | 25 | import { drainQueue } from "../core/shares.ts"; |
| 25 | 26 | import { feedTag, hasQueuedShares } from "../feed/feed_tag.ts"; |
| @@ -70,7 +71,7 @@ export function feedRoutes(router: Router<Handler>): void { | ||
| 70 | 71 | const rawCursor = q.get("cursor"); |
| 71 | 72 | const cursor = rawCursor === null ? undefined : parseCursor(rawCursor); |
| 72 | 73 | if (rawCursor !== null && cursor === undefined) |
| 73 | return apiError("invalid_cursor", 422, "that cursor was not issued here"); | |
| 74 | return apiError("invalid_cursor", 422, CURSOR_NOT_ISSUED); | |
| 74 | 75 | // A 304 only when nothing the viewer would read has changed (`feedTag`). |
| 75 | 76 | // The queue drains first, as `readFeed` would; shares still waiting |
| 76 | 77 | // depend on the clock, so they always get a page. |
| @@ -133,7 +134,7 @@ export function feedRoutes(router: Router<Handler>): void { | ||
| 133 | 134 | return apiError("invalid_hidden", 422, HIDDEN_WORDS); |
| 134 | 135 | if (input.kind === "kind") { |
| 135 | 136 | if (!isKindChip(value)) |
| 136 | return apiError("invalid_chip", 422, "that kind has no chip"); | |
| 137 | return apiError("invalid_chip", 422, KIND_HAS_NO_CHIP); | |
| 137 | 138 | setChip(ctx.app.db, ctx.auth.user!.id, { kind: "kind", value }, input.hidden); |
| 138 | 139 | } else if (input.kind === "person") { |
| 139 | 140 | const who = getUserByName(ctx.app.db, value); |
src/http/mcp/tools/events.ts +2 −1
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { EVENTS_LIMIT_MAX, eventsLimit, eventsPage, validEventsCursor } from "../../../core/events_page.ts"; |
| 2 | import { CURSOR_NOT_ISSUED } from "../../../actions/feed_words.ts"; | |
| 2 | 3 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 3 | 4 | |
| 4 | 5 | export const tool: Tool = { |
| @@ -22,7 +23,7 @@ export const tool: Tool = { | ||
| 22 | 23 | if (limit === undefined) return bad("invalid", `limit is 1 to ${EVENTS_LIMIT_MAX}`); |
| 23 | 24 | const given = args["cursor"]; |
| 24 | 25 | if (given !== undefined && (typeof given !== "string" || !validEventsCursor(given))) |
| 25 | return bad("invalid", "that cursor was not issued here"); | |
| 26 | return bad("invalid", CURSOR_NOT_ISSUED); | |
| 26 | 27 | return good(eventsPage(ctx.app.db, repo.id, user, limit, given)); |
| 27 | 28 | }, |
| 28 | 29 | }; |
src/http/mcp/tools/feed.ts +2 −1
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { FEED_LIMIT_DEFAULT, FEED_LIMIT_MAX, parseCursor, readFeed } from "../../../feed/feed.ts"; |
| 2 | import { CURSOR_NOT_ISSUED } from "../../../actions/feed_words.ts"; | |
| 2 | 3 | import { good, bad, type Tool, CHIP_ARGS, chipArgs, str } from "../shared.ts"; |
| 3 | 4 | |
| 4 | 5 | export const tool: Tool = { |
| @@ -25,7 +26,7 @@ export const tool: Tool = { | ||
| 25 | 26 | const given = args["cursor"]; |
| 26 | 27 | const cursor = given === undefined ? undefined : parseCursor(str(given)); |
| 27 | 28 | if (given !== undefined && cursor === undefined) |
| 28 | return bad("invalid", "that cursor was not issued here"); | |
| 29 | return bad("invalid", CURSOR_NOT_ISSUED); | |
| 29 | 30 | return good( |
| 30 | 31 | readFeed(ctx.app.db, user, { |
| 31 | 32 | limit, |
src/http/mcp/tools/feed_chips.ts +2 −1
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { getUserByName, getUsersByIds } from "../../../core/users.ts"; |
| 2 | 2 | import { chipsOf, isKindChip, setChip } from "../../../core/feed_chips.ts"; |
| 3 | import { KIND_HAS_NO_CHIP } from "../../../actions/feed_words.ts"; | |
| 3 | 4 | import { good, bad, type Tool, str } from "../shared.ts"; |
| 4 | 5 | import { HIDDEN_WORDS } from "../../../core/arg_words.ts"; |
| 5 | 6 | |
| @@ -24,7 +25,7 @@ export const tool: Tool = { | ||
| 24 | 25 | const hidden = args["hidden"]; |
| 25 | 26 | if (typeof hidden !== "boolean") return bad("invalid", HIDDEN_WORDS); |
| 26 | 27 | if (args["kind"] === "kind") { |
| 27 | if (!isKindChip(value)) return bad("invalid", "that kind has no chip"); | |
| 28 | if (!isKindChip(value)) return bad("invalid", KIND_HAS_NO_CHIP); | |
| 28 | 29 | setChip(ctx.app.db, user.id, { kind: "kind", value }, hidden); |
| 29 | 30 | } else if (args["kind"] === "person") { |
| 30 | 31 | const who = getUserByName(ctx.app.db, value); |
test/feed-words.test.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | /** | |
| 2 | * The feed's refusals are said from one place (#5648): src/actions/feed_words.ts | |
| 3 | * holds each sentence, and the feed routes and MCP import it rather than | |
| 4 | * typing a copy that could drift. | |
| 5 | */ | |
| 6 | ||
| 7 | import { expect, test } from "bun:test"; | |
| 8 | import { readFileSync } from "node:fs"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { CURSOR_NOT_ISSUED, KIND_HAS_NO_CHIP } from "../src/actions/feed_words.ts"; | |
| 11 | ||
| 12 | const ROOT = join(import.meta.dir, ".."); | |
| 13 | const READERS = [ | |
| 14 | "src/http/feed.ts", | |
| 15 | "src/http/mcp/tools/events.ts", | |
| 16 | "src/http/mcp/tools/feed.ts", | |
| 17 | "src/http/mcp/tools/feed_chips.ts", | |
| 18 | ]; | |
| 19 | ||
| 20 | test("the module says each sentence as it always read", () => { | |
| 21 | expect(CURSOR_NOT_ISSUED).toBe("that cursor was not issued here"); | |
| 22 | expect(KIND_HAS_NO_CHIP).toBe("that kind has no chip"); | |
| 23 | }); | |
| 24 | ||
| 25 | for (const file of READERS) { | |
| 26 | test(`${file} imports the sentences and types no copy`, () => { | |
| 27 | const text = readFileSync(join(ROOT, file), "utf8"); | |
| 28 | expect(text).toMatch(/from "(\.\.\/)+actions\/feed_words\.ts"/); | |
| 29 | for (const sentence of [CURSOR_NOT_ISSUED, KIND_HAS_NO_CHIP]) { | |
| 30 | expect(text).not.toContain(sentence); | |
| 31 | } | |
| 32 | }); | |
| 33 | } |
new above
offline
Non-blocking: the events cursor borrows the feed's sentence. That is fine while the wording is shared, but
accounts.tssays the same thing with its own copy. Either rename this constant to a general cursor sentence and import it there too, or give each door its own constant.