refactor(server): extract pagination cursor codec

This commit is contained in:
Mohamed Boudra
2026-05-01 22:30:12 +07:00
parent c3d2129472
commit 6eab2a89e9
3 changed files with 302 additions and 163 deletions
@@ -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>[] = [
{ 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>[] = [
{ 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>[] = [
{ 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"),
);
});
});
@@ -0,0 +1,111 @@
export interface CursorSortEntry<K extends string> {
key: K;
direction: "asc" | "desc";
}
export type CursorSortValue = string | number | null;
export interface DecodedCursor<K extends string> {
sort: CursorSortEntry<K>[];
values: Record<string, CursorSortValue>;
id: string;
}
export class CursorError extends Error {
constructor(message: string) {
super(message);
this.name = "CursorError";
}
}
export function encodeCursor<E, K extends string>(
entry: E,
sort: readonly CursorSortEntry<K>[],
getId: (entry: E) => string,
getValue: (entry: E, key: K) => CursorSortValue,
): string {
const values: Record<string, CursorSortValue> = {};
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<K extends string>(
cursor: string,
sort: readonly CursorSortEntry<K>[],
validKeys: readonly K[],
label: string,
): DecodedCursor<K> {
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<string, CursorSortValue>,
id: payload.id,
};
}
interface RawCursorSortEntry {
key?: unknown;
direction?: unknown;
}
function parseCursorSort<K extends string>(
raw: unknown[],
validKeys: readonly K[],
label: string,
): CursorSortEntry<K>[] {
const result: CursorSortEntry<K>[] = [];
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;
}
+32 -163
View File
@@ -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<string, string | number | null> = {};
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<string, string | number | null>,
id: payload.id,
};
}
private compareAgentWithCursor(
@@ -6561,68 +6472,26 @@ export class Session {
entry: FetchWorkspacesResponseEntry,
sort: FetchWorkspacesRequestSort[],
): string {
const values: Record<string, string | number | null> = {};
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<string, string | number | null>,
id: String(payload.id),
};
}
private compareWorkspaceWithCursor(