Public: anyone can read this; the crew can change it.
GET /api/v1/explore answers `{ items, next_cursor }`, and MCP `explore` takes the cursor #6417
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6413-get-api-v1-explore-answers-items-next-cu → main 3 commits fork 3 threads · 3 unresolved
6 files
changed +274
−40
docs/MCP.md +1 −1
| @@ -68,7 +68,7 @@ for a tunnel and a forge that sleeps. | ||
| 68 | 68 | | `egress_grants` | `{ repo, op?, destination? }`: the private `host:port` destinations an app's forge.json asks for and which are granted; `op` grant or revoke by a site admin (never an agent), and every op needs an admin-scope token; the same as the settings page and `/api/v1/repos/:owner/:name/egress-grants` | yes | |
| 69 | 69 | | `email_verify` | no input: send the caller a new verification email, with the API's rate limit and errors; the answer never carries the link | yes | |
| 70 | 70 | | `events` | `{ repo, limit?, cursor? }`: the repository's events, newest first, as you may see them (a webhook's URL and an app's variable names are the admins'): `{ items, next_cursor }`, `limit` 1 to 200 (default 50), `cursor` from `next_cursor` | | |
| 71 | | `explore` | `{ query? }`: the apps and people you can see, ranked by forks, ships, use and freshness, with their numbers; `kind:` `sort:` and words in one line | | | |
| 71 | | `explore` | `{ query?, cursor? }`: the apps and people you can see, ranked, with their numbers; `kind:` `sort:` and words in one line; page on with `cursor` | | | |
| 72 | 72 | | `family` | the forge that sent this one and, for a site admin, those it sent, with state and version | | |
| 73 | 73 | | `feed` | `{ limit?, cursor? }`: 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 | | |
| 74 | 74 | | `feed_chips` | `{ kind?, value?, hidden? }`: the feed chips you switched off, which `feed` and `asks` leave out; with `kind` (`kind` or `person`), `value` and `hidden` it switches one off or on | yes | |
src/actions/explore.ts +107 −19
| @@ -189,6 +189,81 @@ export async function explore( | ||
| 189 | 189 | query: ExploreQuery, |
| 190 | 190 | now?: number, |
| 191 | 191 | ): Promise<ExploreRow[]> { |
| 192 | return (await everything(app, viewer, query, now)).slice(0, ROWS); | |
| 193 | } | |
| 194 | ||
| 195 | export interface ExplorePage { | |
| 196 | readonly items: readonly ExploreRow[]; | |
| 197 | /** Pass back as `cursor` for the page after; null on the last. */ | |
| 198 | readonly nextCursor: string | null; | |
| 199 | } | |
| 200 | ||
| 201 | /** | |
| 202 | * One page after `cursor` (a `next_cursor` of the page before), or null when | |
| 203 | * the cursor is not one. The cursor holds the last row's sort value, kind and | |
| 204 | * name, so the next page starts after that place in the order even when the | |
| 205 | * row itself has since gone. | |
| 206 | */ | |
| 207 | export async function explorePage( | |
| 208 | app: App, | |
| 209 | viewer: User | undefined, | |
| 210 | query: ExploreQuery, | |
| 211 | cursor: string | null, | |
| 212 | visible?: (row: ExploreRow) => boolean, | |
| 213 | now?: number, | |
| 214 | ): Promise<ExplorePage | null> { | |
| 215 | const after = cursor === null ? undefined : readCursor(cursor, query); | |
| 216 | if (after === null) return null; | |
| 217 | // The caller's narrowing comes first, so a page and its cursor only ever | |
| 218 | // hold rows the caller may see. | |
| 219 | const all = await everything(app, viewer, query, now); | |
| 220 | const rows = visible === undefined ? all : all.filter(visible); | |
| 221 | const rest = after === undefined ? rows : rows.filter((r) => compare(r, after, query) > 0); | |
| 222 | const items = rest.slice(0, ROWS); | |
| 223 | return { | |
| 224 | items, | |
| 225 | nextCursor: rest.length > ROWS ? writeCursor(items[items.length - 1]!, query) : null, | |
| 226 | }; | |
| 227 | } | |
| 228 | ||
| 229 | interface Place { | |
| 230 | readonly key: number | string; | |
| 231 | readonly kind: ExploreKind; | |
| 232 | readonly name: string; | |
| 233 | } | |
| 234 | ||
| 235 | const CURSOR_MAX = 400; | |
outdated
This only tells |
||
| 236 | ||
| 237 | function writeCursor(row: ExploreRow, query: ExploreQuery): string { | |
| 238 | const place: [ExploreSort, number | string, ExploreKind, string] = [query.sort, sortKey(row, query.sort), row.kind, row.name]; | |
| 239 | return Buffer.from(JSON.stringify(place)).toString("base64url"); | |
| 240 | } | |
| 241 | ||
| 242 | /** The place a cursor names, undefined never; null when it is not one for this query. */ | |
| 243 | function readCursor(cursor: string, query: ExploreQuery): Place | null { | |
| 244 | if (cursor.length > CURSOR_MAX || !/^[A-Za-z0-9_-]+$/.test(cursor)) return null; | |
| 245 | let value: unknown; | |
| 246 | try { | |
| 247 | value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); | |
| 248 | } catch { | |
| 249 | return null; | |
| 250 | } | |
| 251 | if (!Array.isArray(value) || value.length !== 4) return null; | |
| 252 | const [sort, key, kind, name] = value as unknown[]; | |
| 253 | if (sort !== query.sort) return null; | |
| 254 | const wanted = query.sort === "name" ? "string" : "number"; | |
| 255 | if (typeof key !== wanted || (typeof key === "number" && !Number.isFinite(key))) return null; | |
| 256 | if (kind !== "app" && kind !== "person" && kind !== "forge") return null; | |
| 257 | if (typeof name !== "string" || name.length > 200) return null; | |
| 258 | return { key: key as number | string, kind, name }; | |
| 259 | } | |
| 260 | ||
| 261 | async function everything( | |
| 262 | app: App, | |
| 263 | viewer: User | undefined, | |
| 264 | query: ExploreQuery, | |
| 265 | now?: number, | |
| 266 | ): Promise<readonly ExploreRow[]> { | |
| 192 | 267 | const forgeRows = await forges(app, viewer, query); |
| 193 | 268 | const stamp = now === undefined ? writeStamp(app.db) : undefined; |
| 194 | 269 | if (stamp === undefined) return place(rank(app, viewer, query, now ?? Date.now()), forgeRows, query); |
| @@ -349,6 +424,33 @@ function rank( | ||
| 349 | 424 | ); |
| 350 | 425 | } |
| 351 | 426 | |
| 427 | function sortKey(r: ExploreRow, sort: ExploreSort): number | string { | |
| 428 | switch (sort) { | |
| 429 | case "score": return r.score; | |
| 430 | case "forks": return r.numbers.forks; | |
| 431 | case "ships": return r.numbers.ships; | |
| 432 | case "use": return r.numbers.awakeHours; | |
| 433 | case "fresh": return r.shippedAt === null ? 0 : Date.parse(r.shippedAt) || 0; | |
| 434 | case "name": return r.name.toLowerCase(); | |
| 435 | } | |
| 436 | } | |
| 437 | ||
| 438 | /** | |
| 439 | * The total order: the sort value, then kind and name, so no two rows tie and | |
| 440 | * a cursor names exactly one place in it. | |
| 441 | */ | |
| 442 | function compare(a: Place | ExploreRow, b: Place | ExploreRow, query: ExploreQuery): number { | |
| 443 | const x = "key" in a ? a.key : sortKey(a, query.sort); | |
| 444 | const y = "key" in b ? b.key : sortKey(b, query.sort); | |
| 445 | const by = | |
| 446 | query.sort === "name" | |
| 447 | ? String(x) < String(y) ? -1 : String(x) > String(y) ? 1 : 0 | |
| 448 | : (y as number) - (x as number); | |
| 449 | if (by !== 0) return by; | |
| 450 | if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1; | |
| 451 | return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; | |
| 452 | } | |
| 453 | ||
| 352 | 454 | /** Whether the row's `shippedAt` (its last merged pull) is inside the window. */ |
| 353 | 455 | function movedWithin(row: ExploreRow, query: ExploreQuery, now: number): boolean { |
| 354 | 456 | if (query.updated === "all") return true; |
| @@ -359,33 +461,19 @@ function movedWithin(row: ExploreRow, query: ExploreQuery, now: number): boolean | ||
| 359 | 461 | |
| 360 | 462 | /** Rows sorted as the query says, in place. */ |
| 361 | 463 | function order(rows: ExploreRow[], query: ExploreQuery): ExploreRow[] { |
| 362 | const by: Record<ExploreSort, (r: ExploreRow) => number | string> = { | |
| 363 | score: (r) => r.score, | |
| 364 | forks: (r) => r.numbers.forks, | |
| 365 | ships: (r) => r.numbers.ships, | |
| 366 | use: (r) => r.numbers.awakeHours, | |
| 367 | fresh: (r) => (r.shippedAt === null ? 0 : Date.parse(r.shippedAt)), | |
| 368 | name: (r) => r.name.toLowerCase(), | |
| 369 | }; | |
| 370 | const key = by[query.sort]; | |
| 371 | return rows.sort((a, b) => { | |
| 372 | const x = key(a); | |
| 373 | const y = key(b); | |
| 374 | if (query.sort === "name") return String(x).localeCompare(String(y)); | |
| 375 | return (y as number) - (x as number) || a.name.localeCompare(b.name); | |
| 376 | }); | |
| 464 | return rows.sort((a, b) => compare(a, b, query)); | |
| 377 | 465 | } |
| 378 | 466 | |
| 379 | 467 | /** |
| 380 | 468 | * The answer: the ranked rows (kept, so never changed here) with the forge |
| 381 | * rows among them, cut to a page. Only the forge rows lack a write clock, | |
| 469 | * rows among them, whole (the callers cut a page). Only the forge rows lack a write clock, | |
| 382 | 470 | * so they are added after the kept read, to a copy. |
| 383 | 471 | */ |
| 384 | function place(ranked: readonly ExploreRow[], forgeRows: ExploreRow[], query: ExploreQuery): ExploreRow[] { | |
| 472 | function place(ranked: readonly ExploreRow[], forgeRows: ExploreRow[], query: ExploreQuery): readonly ExploreRow[] { | |
| 385 | 473 | const text = query.text.toLowerCase(); |
| 386 | 474 | const own = forgeRows.filter( |
| 387 | 475 | (row) => text === "" || `${row.name} ${row.description}`.toLowerCase().includes(text), |
| 388 | 476 | ); |
| 389 | if (own.length === 0) return ranked.slice(0, ROWS); | |
| 390 | return order([...ranked, ...own], query).slice(0, ROWS); | |
| 477 | if (own.length === 0) return ranked; | |
| 478 | return order([...ranked, ...own], query); | |
| 391 | 479 | } |
src/contract/openapi/routes/getApiV1Explore.ts +1 −1
| @@ -1,3 +1,3 @@ | ||
| 1 | 1 | import type { Route } from "../shared.ts"; |
| 2 | 2 | |
| 3 | export const route: Route = { method: "get", path: "/api/v1/explore", summary: "Explore apps and repositories; `q` takes words and `kind:`/`owner:` filters; answers { query, items }", auth: false, query: { q: "string" } }; | |
| 3 | export const route: Route = { method: "get", path: "/api/v1/explore", summary: "Explore apps and repositories; `q` takes words and `kind:`/`owner:` filters; answers { query, items, next_cursor } (60 rows a page, next_cursor null on the last); cursor from next_cursor; a bad cursor is a 400", auth: false, query: { q: "string", cursor: "string" } }; |
src/http/api.ts +16 −13
| @@ -450,7 +450,8 @@ import { | ||
| 450 | 450 | type AppMembersError, |
| 451 | 451 | } from "../actions/app_members.ts"; |
| 452 | 452 | import { |
| 453 | explore, | |
| 453 | explorePage, | |
| 454 | type ExploreRow, | |
| 454 | 455 | exploreLine, |
| 455 | 456 | parseExploreQuery, |
| 456 | 457 | } from "../actions/explore.ts"; |
| @@ -5385,19 +5386,21 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 5385 | 5386 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 5386 | 5387 | const query = parseExploreQuery(ctx.url.searchParams.get("q") ?? ""); |
| 5387 | 5388 | const mine = runRepos(ctx.app, ctx.auth); |
| 5388 | const all = await timePhaseAsync(ctx.request, "explore", () => | |
| 5389 | explore(ctx.app, ctx.auth.user, query), | |
| 5390 | ); | |
| 5391 | // A run token is shown only the rows of its own repositories. | |
| 5392 | const items = | |
| 5389 | // A run token is shown only the rows of its own repositories; the cut | |
| 5390 | // and the cursor are made from those rows alone. | |
| 5391 | const visible = | |
| 5393 | 5392 | mine === undefined |
| 5394 | ? all | |
| 5395 | : all.filter( | |
| 5396 | (row) => | |
| 5397 | row.repo !== undefined && | |
| 5398 | mine.some((r) => r.ownerName.toLowerCase() === row.repo!.owner.toLowerCase() && r.name.toLowerCase() === row.repo!.name.toLowerCase()), | |
| 5399 | ); | |
| 5400 | return timePhase(ctx.request, "render", () => json({ query: exploreLine(query), items })); | |
| 5393 | ? undefined | |
outdated
High: |
||
| 5394 | : (row: ExploreRow) => | |
| 5395 | row.repo !== undefined && | |
| 5396 | mine.some((r) => r.ownerName.toLowerCase() === row.repo!.owner.toLowerCase() && r.name.toLowerCase() === row.repo!.name.toLowerCase()); | |
| 5397 | const page = await timePhaseAsync(ctx.request, "explore", () => | |
| 5398 | explorePage(ctx.app, ctx.auth.user, query, ctx.url.searchParams.get("cursor"), visible), | |
| 5399 | ); | |
| 5400 | if (page === null) | |
| 5401 | return apiError("invalid_cursor", 400, "cursor is the next_cursor of a page before"); | |
| 5402 | const items = page.items; | |
| 5403 | return timePhase(ctx.request, "render", () => json({ query: exploreLine(query), items, next_cursor: page.nextCursor })); | |
| 5401 | 5404 | }); |
| 5402 | 5405 | |
| 5403 | 5406 | // Make it yours (#606): a fork with its app, planned unless dryRun is |
src/http/mcp/tools/explore.ts +12 −5
| @@ -1,11 +1,11 @@ | ||
| 1 | import { explore, exploreLine, parseExploreQuery } from "../../../actions/explore.ts"; | |
| 2 | import { good, type Tool, str } from "../shared.ts"; | |
| 1 | import { explorePage, exploreLine, parseExploreQuery } from "../../../actions/explore.ts"; | |
| 2 | import { bad, good, type Tool, str } from "../shared.ts"; | |
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "explore", |
| 6 | doc: "`{ query? }`: the apps and people you can see, ranked by forks, ships, use and freshness, with their numbers; `kind:` `sort:` and words in one line", | |
| 6 | doc: "`{ query?, cursor? }`: the apps and people you can see, ranked, with their numbers; `kind:` `sort:` and words in one line; page on with `cursor`", | |
| 7 | 7 | description: |
| 8 | "The apps, people (and for a site admin, spawned forges) you can see, ranked.", | |
| 8 | "The apps, people (and, for an admin, forges) you can see, ranked.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
| @@ -14,14 +14,21 @@ export const tool: Tool = { | ||
| 14 | 14 | maxLength: 200, |
| 15 | 15 | description: "Words; kind: sort: updated:7d", |
| 16 | 16 | }, |
| 17 | cursor: { | |
| 18 | type: "string", | |
| 19 | }, | |
| 17 | 20 | }, |
| 18 | 21 | }, |
| 19 | 22 | writes: false, |
| 20 | 23 | async run(ctx, user, args) { |
| 21 | 24 | const query = parseExploreQuery(str(args["query"])); |
| 25 | const cursor = str(args["cursor"]); | |
| 26 | const page = await explorePage(ctx.app, user, query, cursor === "" ? null : cursor); | |
| 27 | if (page === null) return bad("invalid_cursor", "cursor is not the next_cursor of a page before"); | |
| 22 | 28 | return good({ |
| 23 | 29 | query: exploreLine(query), |
| 24 | items: await explore(ctx.app, user, query), | |
| 30 | items: page.items, | |
| 31 | next_cursor: page.nextCursor, | |
| 25 | 32 | }); |
| 26 | 33 | }, |
| 27 | 34 | }; |
test/explore.test.ts +137 −1
| @@ -9,7 +9,7 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | import { exploreLine, parseExploreQuery } from "../src/actions/explore.ts"; | |
| 12 | import { explorePage, exploreLine, parseExploreQuery } from "../src/actions/explore.ts"; | |
| 13 | 13 | import { suspendAccount } from "../src/actions/accounts.ts"; |
| 14 | 14 | import { createRepo } from "../src/actions/repos.ts"; |
| 15 | 15 | import { createApp as insertApp } from "../src/core/apps.ts"; |
| @@ -450,3 +450,139 @@ describe("explore on a forge with nothing public", () => { | ||
| 450 | 450 | expect(out).toContain("Nothing matches <script>x</script>"); |
| 451 | 451 | }); |
| 452 | 452 | }); |
| 453 | ||
| 454 | describe("explore past the first page", () => { | |
| 455 | let dataDir: string; | |
| 456 | let app: App; | |
| 457 | let servers: Servers; | |
| 458 | let token: string; | |
| 459 | const COUNT = 70; | |
| 460 | ||
| 461 | const get = async (q: string, cursor?: string) => | |
| 462 | fetch( | |
| 463 | `${servers.url}/api/v1/explore?q=${encodeURIComponent(q)}${cursor === undefined ? "" : `&cursor=${encodeURIComponent(cursor)}`}`, | |
| 464 | ); | |
| 465 | ||
| 466 | beforeAll(async () => { | |
| 467 | dataDir = await mkdtemp(join(tmpdir(), "forge-explore-pages-")); | |
| 468 | app = createApp({ dataDir, port: 0 }); | |
| 469 | servers = await serve(app); | |
| 470 | const made = await createUser(app.db, { | |
| 471 | origin: "seed", | |
| 472 | username: "paged", | |
| 473 | email: "[email protected]", | |
| 474 | password: "a-good-passphrase", | |
| 475 | adminIfFirst: true, | |
| 476 | }); | |
| 477 | if (!made.ok) throw new Error(made.error); | |
| 478 | proveAll(app); | |
| 479 | promote(app.db, made.value); | |
| 480 | token = createToken(app.db, made.value.id, "ro", "ro").token; | |
| 481 | for (let i = 0; i < COUNT; i++) { | |
| 482 | const name = `r${String(i).padStart(3, "0")}`; | |
| 483 | const repo = await createRepo(app, made.value, { name, visibility: "public", description: name }); | |
| 484 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 485 | const record = insertApp(app.db, { repoId: repo.value.id, slug: `paged-${name}`, access: "public" }); | |
| 486 | if (!record.ok) throw new Error(record.error); | |
| 487 | } | |
| 488 | }, 60_000); | |
| 489 | ||
| 490 | afterAll(async () => { | |
| 491 | servers.stop(); | |
| 492 | app.close(); | |
| 493 | await rm(dataDir, { recursive: true, force: true }); | |
| 494 | }); | |
| 495 | ||
| 496 | for (const sort of ["score", "name"]) { | |
| 497 | test(`following next_cursor reaches every app once, in rank order (sort:${sort})`, async () => { | |
| 498 | const q = `kind:app sort:${sort}`; | |
| 499 | const first = (await (await get(q)).json()) as any; | |
| 500 | expect(first.items.length).toBe(60); | |
| 501 | expect(typeof first.next_cursor).toBe("string"); | |
| 502 | const seen: string[] = first.items.map((r: any) => r.name); | |
| 503 | let cursor: string | null = first.next_cursor; | |
| 504 | let pages = 1; | |
| 505 | while (cursor !== null) { | |
| 506 | const page = (await (await get(q, cursor)).json()) as any; | |
| 507 | seen.push(...page.items.map((r: any) => r.name)); | |
| 508 | cursor = page.next_cursor; | |
| 509 | pages++; | |
| 510 | } | |
| 511 | expect(pages).toBe(2); | |
| 512 | expect(seen.length).toBe(COUNT); | |
| 513 | expect(new Set(seen).size).toBe(COUNT); | |
| 514 | expect(seen).toEqual([...seen].sort()); | |
| 515 | }); | |
| 516 | } | |
| 517 | ||
| 518 | test("the last page and a short list answer next_cursor null", async () => { | |
| 519 | const short = (await (await get("r001")).json()) as any; | |
| 520 | expect(short.next_cursor).toBeNull(); | |
| 521 | }); | |
| 522 | ||
| 523 | test("a malformed cursor, or one from another sort, is refused", async () => { | |
| 524 | for (const bad of ["!!", "x".repeat(500), Buffer.from("[1,2]").toString("base64url")]) { | |
| 525 | const res = await get("kind:app", bad); | |
| 526 | expect(res.status).toBe(400); | |
| 527 | expect(((await res.json()) as any).error).toBe("invalid_cursor"); | |
| 528 | } | |
| 529 | const first = (await (await get("kind:app")).json()) as any; | |
| 530 | expect((await get("kind:app sort:name", first.next_cursor)).status).toBe(400); | |
| 531 | }); | |
| 532 | ||
| 533 | test("MCP explore takes the cursor and answers next_cursor", async () => { | |
| 534 | const call = async (args: object) => { | |
| 535 | const res = await fetch(`${servers.url}/mcp`, { | |
| 536 | method: "POST", | |
| 537 | headers: { | |
| 538 | "content-type": "application/json", | |
| 539 | accept: "application/json, text/event-stream", | |
| 540 | authorization: `Bearer ${token}`, | |
| 541 | }, | |
| 542 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "explore", arguments: args } }), | |
| 543 | }); | |
| 544 | return (await res.json()) as any; | |
| 545 | }; | |
| 546 | const first = JSON.parse((await call({ query: "kind:app" })).result.content[0].text); | |
| 547 | expect(first.items.length).toBe(60); | |
| 548 | const second = JSON.parse((await call({ query: "kind:app", cursor: first.next_cursor })).result.content[0].text); | |
| 549 | expect(second.items.length).toBe(COUNT - 60); | |
| 550 | expect(second.next_cursor).toBeNull(); | |
| 551 | const refused = await call({ query: "kind:app", cursor: "!!" }); | |
| 552 | expect(JSON.stringify(refused)).toContain("invalid_cursor"); | |
| 553 | }); | |
| 554 | ||
| 555 | test("a cursor made under one sort is refused under every other", async () => { | |
| 556 | const sorts = ["score", "forks", "ships", "use", "fresh", "name"]; | |
| 557 | for (const made of sorts) { | |
| 558 | const first = (await (await get(`kind:app sort:${made}`)).json()) as any; | |
| 559 | for (const used of sorts) { | |
| 560 | const res = await get(`kind:app sort:${used}`, first.next_cursor); | |
| 561 | expect(res.status).toBe(made === used ? 200 : 400); | |
| 562 | } | |
| 563 | } | |
| 564 | }); | |
| 565 | ||
| 566 | test("a narrowed caller's pages and cursors hold only rows it may see", async () => { | |
| 567 | const viewer = undefined; | |
| 568 | const query = parseExploreQuery("kind:app sort:name"); | |
| 569 | // Hides the row each unnarrowed page would end on, and every tenth after. | |
| 570 | const visible = (row: { name: string }) => !/[09]$/.test(row.name) || row.name.endsWith("r000"); | |
| 571 | const seen: string[] = []; | |
| 572 | let cursor: string | null = null; | |
| 573 | do { | |
| 574 | const page: Awaited<ReturnType<typeof explorePage>> = await explorePage(app, viewer, query, cursor, visible); | |
| 575 | if (page === null) throw new Error("refused"); | |
| 576 | seen.push(...page.items.map((r) => r.name)); | |
| 577 | if (page.nextCursor !== null) { | |
| 578 | const [, , , name] = JSON.parse(Buffer.from(page.nextCursor, "base64url").toString()); | |
| 579 | expect(visible({ name })).toBe(true); | |
| 580 | expect(page.items.length).toBe(60); | |
| 581 | } | |
| 582 | cursor = page.nextCursor; | |
| 583 | } while (cursor !== null); | |
| 584 | expect(seen.every((n) => visible({ name: n }))).toBe(true); | |
| 585 | expect(new Set(seen).size).toBe(seen.length); | |
| 586 | expect(seen.length).toBe(COUNT - 14 + 1); | |
| 587 | }); | |
| 588 | }); |
new above
offline
The cursor comes from the last row of the page, so any filter a caller applies afterwards (run tokens in api.ts) is out of sync with it. Take a visibility predicate here and apply it to
rowsfirst.