refactor(server): extract pagination cursor codec
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user