From 6eab2a89e9913b80694192d2b9301d79a9b090b0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 1 May 2026 22:30:12 +0700 Subject: [PATCH] refactor(server): extract pagination cursor codec --- .../src/server/pagination/cursor.test.ts | 159 ++++++++++++++ .../server/src/server/pagination/cursor.ts | 111 ++++++++++ packages/server/src/server/session.ts | 195 +++--------------- 3 files changed, 302 insertions(+), 163 deletions(-) create mode 100644 packages/server/src/server/pagination/cursor.test.ts create mode 100644 packages/server/src/server/pagination/cursor.ts diff --git a/packages/server/src/server/pagination/cursor.test.ts b/packages/server/src/server/pagination/cursor.test.ts new file mode 100644 index 000000000..13ab8cf0f --- /dev/null +++ b/packages/server/src/server/pagination/cursor.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; +import { CursorError, type CursorSortEntry, decodeCursor, encodeCursor } from "./cursor.js"; + +const KEYS = ["created_at", "title", "updated_at"] as const; +type Key = (typeof KEYS)[number]; + +interface Entry { + id: string; + created_at: number; + title: string; + updated_at: number; +} + +const sample: Entry = { + id: "agent-1", + created_at: 100, + title: "first", + updated_at: 200, +}; + +const sort: CursorSortEntry[] = [ + { key: "updated_at", direction: "desc" }, + { key: "title", direction: "asc" }, +]; + +function encodeSample(entry: Entry = sample, sortSpec = sort): string { + return encodeCursor( + entry, + sortSpec, + (e) => e.id, + (e, key) => e[key], + ); +} + +describe("cursor-codec", () => { + it("roundtrips through encode/decode", () => { + const token = encodeSample(); + const decoded = decodeCursor(token, sort, KEYS, "things"); + + expect(decoded.id).toBe("agent-1"); + expect(decoded.sort).toEqual(sort); + expect(decoded.values).toEqual({ updated_at: 200, title: "first" }); + }); + + it("only includes values for sort keys, not all entry fields", () => { + const token = encodeSample(); + const decoded = decodeCursor(token, sort, KEYS, "things"); + + expect(decoded.values).not.toHaveProperty("created_at"); + }); + + it("preserves null sort values", () => { + const token = encodeCursor( + sample, + [{ key: "title", direction: "asc" }] as const, + (e) => e.id, + () => null, + ); + const decoded = decodeCursor(token, [{ key: "title", direction: "asc" }], KEYS, "things"); + + expect(decoded.values.title).toBeNull(); + }); + + it("rejects invalid base64url", () => { + expect(() => decodeCursor("@@@not-base64@@@", sort, KEYS, "things")).toThrow( + new CursorError("Invalid things cursor"), + ); + }); + + it("rejects valid base64url that is not JSON", () => { + const token = Buffer.from("not json", "utf8").toString("base64url"); + expect(() => decodeCursor(token, sort, KEYS, "things")).toThrow(CursorError); + }); + + it("rejects JSON that is not an object", () => { + const token = Buffer.from(JSON.stringify(["array"]), "utf8").toString("base64url"); + expect(() => decodeCursor(token, sort, KEYS, "things")).toThrow(CursorError); + }); + + it("rejects payload missing id", () => { + const token = Buffer.from( + JSON.stringify({ sort, values: { updated_at: 1, title: "x" } }), + "utf8", + ).toString("base64url"); + expect(() => decodeCursor(token, sort, KEYS, "things")).toThrow(CursorError); + }); + + it("rejects payload with non-string id", () => { + const token = Buffer.from( + JSON.stringify({ sort, values: { updated_at: 1, title: "x" }, id: 42 }), + "utf8", + ).toString("base64url"); + expect(() => decodeCursor(token, sort, KEYS, "things")).toThrow(CursorError); + }); + + it("rejects payload missing values", () => { + const token = Buffer.from(JSON.stringify({ sort, id: "x" }), "utf8").toString("base64url"); + expect(() => decodeCursor(token, sort, KEYS, "things")).toThrow(CursorError); + }); + + it("rejects an unknown sort key", () => { + const token = Buffer.from( + JSON.stringify({ + sort: [{ key: "made_up", direction: "asc" }], + values: {}, + id: "x", + }), + "utf8", + ).toString("base64url"); + expect(() => decodeCursor(token, sort, KEYS, "things")).toThrow(CursorError); + }); + + it("rejects an invalid direction", () => { + const token = Buffer.from( + JSON.stringify({ + sort: [{ key: "title", direction: "sideways" }], + values: {}, + id: "x", + }), + "utf8", + ).toString("base64url"); + expect(() => decodeCursor(token, sort, KEYS, "things")).toThrow(CursorError); + }); + + it("rejects a cursor whose sort length does not match", () => { + const token = encodeSample(sample, [{ key: "title", direction: "asc" }]); + expect(() => decodeCursor(token, sort, KEYS, "things")).toThrow( + new CursorError("things cursor does not match current sort"), + ); + }); + + it("rejects a cursor whose sort key order does not match", () => { + const reversed: CursorSortEntry[] = [ + { key: "title", direction: "asc" }, + { key: "updated_at", direction: "desc" }, + ]; + const token = encodeSample(sample, reversed); + expect(() => decodeCursor(token, sort, KEYS, "things")).toThrow( + new CursorError("things cursor does not match current sort"), + ); + }); + + it("rejects a cursor whose sort direction does not match", () => { + const flipped: CursorSortEntry[] = [ + { key: "updated_at", direction: "asc" }, + { key: "title", direction: "asc" }, + ]; + const token = encodeSample(sample, flipped); + expect(() => decodeCursor(token, sort, KEYS, "things")).toThrow( + new CursorError("things cursor does not match current sort"), + ); + }); + + it("uses the label in error messages", () => { + expect(() => decodeCursor("@@@", sort, KEYS, "fetch_widgets")).toThrow( + new CursorError("Invalid fetch_widgets cursor"), + ); + }); +}); diff --git a/packages/server/src/server/pagination/cursor.ts b/packages/server/src/server/pagination/cursor.ts new file mode 100644 index 000000000..21d33bfd4 --- /dev/null +++ b/packages/server/src/server/pagination/cursor.ts @@ -0,0 +1,111 @@ +export interface CursorSortEntry { + key: K; + direction: "asc" | "desc"; +} + +export type CursorSortValue = string | number | null; + +export interface DecodedCursor { + sort: CursorSortEntry[]; + values: Record; + id: string; +} + +export class CursorError extends Error { + constructor(message: string) { + super(message); + this.name = "CursorError"; + } +} + +export function encodeCursor( + entry: E, + sort: readonly CursorSortEntry[], + getId: (entry: E) => string, + getValue: (entry: E, key: K) => CursorSortValue, +): string { + const values: Record = {}; + for (const spec of sort) { + values[spec.key] = getValue(entry, spec.key); + } + return Buffer.from(JSON.stringify({ sort, values, id: getId(entry) }), "utf8").toString( + "base64url", + ); +} + +export function decodeCursor( + cursor: string, + sort: readonly CursorSortEntry[], + validKeys: readonly K[], + label: string, +): DecodedCursor { + const invalid = new CursorError(`Invalid ${label} cursor`); + + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); + } catch { + throw invalid; + } + + if (!parsed || typeof parsed !== "object") { + throw invalid; + } + + const payload = parsed as { sort?: unknown; values?: unknown; id?: unknown }; + + if (!Array.isArray(payload.sort) || typeof payload.id !== "string") { + throw invalid; + } + if (!payload.values || typeof payload.values !== "object") { + throw invalid; + } + + const cursorSort = parseCursorSort(payload.sort, validKeys, label); + + if ( + cursorSort.length !== sort.length || + cursorSort.some( + (entry, index) => + entry.key !== sort[index]?.key || entry.direction !== sort[index]?.direction, + ) + ) { + throw new CursorError(`${label} cursor does not match current sort`); + } + + return { + sort: cursorSort, + values: payload.values as Record, + id: payload.id, + }; +} + +interface RawCursorSortEntry { + key?: unknown; + direction?: unknown; +} + +function parseCursorSort( + raw: unknown[], + validKeys: readonly K[], + label: string, +): CursorSortEntry[] { + const result: CursorSortEntry[] = []; + for (const item of raw) { + if (!item || typeof item !== "object") { + throw new CursorError(`Invalid ${label} cursor`); + } + const { key, direction } = item as RawCursorSortEntry; + if (typeof key !== "string" || typeof direction !== "string") { + throw new CursorError(`Invalid ${label} cursor`); + } + if ( + !(validKeys as readonly string[]).includes(key) || + (direction !== "asc" && direction !== "desc") + ) { + throw new CursorError(`Invalid ${label} cursor`); + } + result.push({ key: key as K, direction }); + } + return result; +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index e59944e4a..39874b0a6 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -51,6 +51,7 @@ import { decodeTerminalResizePayload, type TerminalStreamFrame, } from "../shared/terminal-stream-protocol.js"; +import { CursorError, decodeCursor, encodeCursor } from "./pagination/cursor.js"; import { TTSManager } from "./agent/tts-manager.js"; import { STTManager } from "./agent/stt-manager.js"; import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js"; @@ -431,61 +432,13 @@ function getFirstUserMessageText(timeline: readonly AgentTimelineItem[]): string return null; } -function parseFetchWorkspacesCursorSort(raw: unknown[]): FetchWorkspacesRequestSort[] { - const cursorSort: FetchWorkspacesRequestSort[] = []; - for (const item of raw) { - if ( - !item || - typeof item !== "object" || - typeof (item as { key?: unknown }).key !== "string" || - typeof (item as { direction?: unknown }).direction !== "string" - ) { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor"); - } - - const key = (item as { key: string }).key; - const direction = (item as { direction: string }).direction; - if ( - (key !== "status_priority" && - key !== "activity_at" && - key !== "name" && - key !== "project_id") || - (direction !== "asc" && direction !== "desc") - ) { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor"); - } - cursorSort.push({ key, direction }); - } - return cursorSort; -} - -function parseFetchAgentsCursorSort(raw: unknown[]): FetchAgentsRequestSort[] { - const cursorSort: FetchAgentsRequestSort[] = []; - for (const item of raw) { - if ( - !item || - typeof item !== "object" || - typeof (item as { key?: unknown }).key !== "string" || - typeof (item as { direction?: unknown }).direction !== "string" - ) { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor"); - } - - const key = (item as { key: string }).key; - const direction = (item as { direction: string }).direction; - if ( - (key !== "status_priority" && - key !== "created_at" && - key !== "updated_at" && - key !== "title") || - (direction !== "asc" && direction !== "desc") - ) { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor"); - } - cursorSort.push({ key, direction }); - } - return cursorSort; -} +const FETCH_AGENTS_SORT_KEYS = ["status_priority", "created_at", "updated_at", "title"] as const; +const FETCH_WORKSPACES_SORT_KEYS = [ + "status_priority", + "activity_at", + "name", + "project_id", +] as const; export function resolveWaitForFinishError(options: { status: "permission" | "error" | "idle"; @@ -5982,68 +5935,26 @@ export class Session { entry: FetchAgentsResponseEntry, sort: FetchAgentsRequestSort[], ): string { - const values: Record = {}; - for (const spec of sort) { - values[spec.key] = this.getFetchAgentsSortValue(entry, spec.key); - } - return Buffer.from( - JSON.stringify({ - sort, - values, - id: entry.agent.id, - }), - "utf8", - ).toString("base64url"); + return encodeCursor( + entry, + sort, + (e) => e.agent.id, + (e, key) => this.getFetchAgentsSortValue(e, key), + ); } private decodeFetchAgentsCursor( cursor: string, sort: FetchAgentsRequestSort[], ): FetchAgentsCursor { - let parsed: unknown; try { - parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); - } catch { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor"); + return decodeCursor(cursor, sort, FETCH_AGENTS_SORT_KEYS, "fetch_agents"); + } catch (error) { + if (error instanceof CursorError) { + throw new SessionRequestError("invalid_cursor", error.message); + } + throw error; } - - if (!parsed || typeof parsed !== "object") { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor"); - } - - const payload = parsed as { - sort?: unknown; - values?: unknown; - id?: unknown; - }; - - if (!Array.isArray(payload.sort) || typeof payload.id !== "string") { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor"); - } - if (!payload.values || typeof payload.values !== "object") { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor"); - } - - const cursorSort = parseFetchAgentsCursorSort(payload.sort); - - if ( - cursorSort.length !== sort.length || - cursorSort.some( - (entry, index) => - entry.key !== sort[index]?.key || entry.direction !== sort[index]?.direction, - ) - ) { - throw new SessionRequestError( - "invalid_cursor", - "fetch_agents cursor does not match current sort", - ); - } - - return { - sort: cursorSort, - values: payload.values as Record, - id: payload.id, - }; } private compareAgentWithCursor( @@ -6561,68 +6472,26 @@ export class Session { entry: FetchWorkspacesResponseEntry, sort: FetchWorkspacesRequestSort[], ): string { - const values: Record = {}; - for (const spec of sort) { - values[spec.key] = this.getFetchWorkspacesSortValue(entry, spec.key); - } - return Buffer.from( - JSON.stringify({ - sort, - values, - id: entry.id, - }), - "utf8", - ).toString("base64url"); + return encodeCursor( + entry, + sort, + (e) => e.id, + (e, key) => this.getFetchWorkspacesSortValue(e, key), + ); } private decodeFetchWorkspacesCursor( cursor: string, sort: FetchWorkspacesRequestSort[], ): FetchWorkspacesCursor { - let parsed: unknown; try { - parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); - } catch { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor"); + return decodeCursor(cursor, sort, FETCH_WORKSPACES_SORT_KEYS, "fetch_workspaces"); + } catch (error) { + if (error instanceof CursorError) { + throw new SessionRequestError("invalid_cursor", error.message); + } + throw error; } - - if (!parsed || typeof parsed !== "object") { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor"); - } - - const payload = parsed as { - sort?: unknown; - values?: unknown; - id?: unknown; - }; - - if (!Array.isArray(payload.sort) || typeof payload.id !== "string") { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor"); - } - if (!payload.values || typeof payload.values !== "object") { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor"); - } - - const cursorSort = parseFetchWorkspacesCursorSort(payload.sort); - - if ( - cursorSort.length !== sort.length || - cursorSort.some( - (entry, index) => - entry.key !== sort[index]?.key || entry.direction !== sort[index]?.direction, - ) - ) { - throw new SessionRequestError( - "invalid_cursor", - "fetch_workspaces cursor does not match current sort", - ); - } - - return { - sort: cursorSort, - values: payload.values as Record, - id: String(payload.id), - }; } private compareWorkspaceWithCursor(