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,
|
decodeTerminalResizePayload,
|
||||||
type TerminalStreamFrame,
|
type TerminalStreamFrame,
|
||||||
} from "../shared/terminal-stream-protocol.js";
|
} from "../shared/terminal-stream-protocol.js";
|
||||||
|
import { CursorError, decodeCursor, encodeCursor } from "./pagination/cursor.js";
|
||||||
import { TTSManager } from "./agent/tts-manager.js";
|
import { TTSManager } from "./agent/tts-manager.js";
|
||||||
import { STTManager } from "./agent/stt-manager.js";
|
import { STTManager } from "./agent/stt-manager.js";
|
||||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
||||||
@@ -431,61 +432,13 @@ function getFirstUserMessageText(timeline: readonly AgentTimelineItem[]): string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseFetchWorkspacesCursorSort(raw: unknown[]): FetchWorkspacesRequestSort[] {
|
const FETCH_AGENTS_SORT_KEYS = ["status_priority", "created_at", "updated_at", "title"] as const;
|
||||||
const cursorSort: FetchWorkspacesRequestSort[] = [];
|
const FETCH_WORKSPACES_SORT_KEYS = [
|
||||||
for (const item of raw) {
|
"status_priority",
|
||||||
if (
|
"activity_at",
|
||||||
!item ||
|
"name",
|
||||||
typeof item !== "object" ||
|
"project_id",
|
||||||
typeof (item as { key?: unknown }).key !== "string" ||
|
] as const;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveWaitForFinishError(options: {
|
export function resolveWaitForFinishError(options: {
|
||||||
status: "permission" | "error" | "idle";
|
status: "permission" | "error" | "idle";
|
||||||
@@ -5982,68 +5935,26 @@ export class Session {
|
|||||||
entry: FetchAgentsResponseEntry,
|
entry: FetchAgentsResponseEntry,
|
||||||
sort: FetchAgentsRequestSort[],
|
sort: FetchAgentsRequestSort[],
|
||||||
): string {
|
): string {
|
||||||
const values: Record<string, string | number | null> = {};
|
return encodeCursor(
|
||||||
for (const spec of sort) {
|
entry,
|
||||||
values[spec.key] = this.getFetchAgentsSortValue(entry, spec.key);
|
sort,
|
||||||
}
|
(e) => e.agent.id,
|
||||||
return Buffer.from(
|
(e, key) => this.getFetchAgentsSortValue(e, key),
|
||||||
JSON.stringify({
|
);
|
||||||
sort,
|
|
||||||
values,
|
|
||||||
id: entry.agent.id,
|
|
||||||
}),
|
|
||||||
"utf8",
|
|
||||||
).toString("base64url");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private decodeFetchAgentsCursor(
|
private decodeFetchAgentsCursor(
|
||||||
cursor: string,
|
cursor: string,
|
||||||
sort: FetchAgentsRequestSort[],
|
sort: FetchAgentsRequestSort[],
|
||||||
): FetchAgentsCursor {
|
): FetchAgentsCursor {
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
return decodeCursor(cursor, sort, FETCH_AGENTS_SORT_KEYS, "fetch_agents");
|
||||||
} catch {
|
} catch (error) {
|
||||||
throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor");
|
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(
|
private compareAgentWithCursor(
|
||||||
@@ -6561,68 +6472,26 @@ export class Session {
|
|||||||
entry: FetchWorkspacesResponseEntry,
|
entry: FetchWorkspacesResponseEntry,
|
||||||
sort: FetchWorkspacesRequestSort[],
|
sort: FetchWorkspacesRequestSort[],
|
||||||
): string {
|
): string {
|
||||||
const values: Record<string, string | number | null> = {};
|
return encodeCursor(
|
||||||
for (const spec of sort) {
|
entry,
|
||||||
values[spec.key] = this.getFetchWorkspacesSortValue(entry, spec.key);
|
sort,
|
||||||
}
|
(e) => e.id,
|
||||||
return Buffer.from(
|
(e, key) => this.getFetchWorkspacesSortValue(e, key),
|
||||||
JSON.stringify({
|
);
|
||||||
sort,
|
|
||||||
values,
|
|
||||||
id: entry.id,
|
|
||||||
}),
|
|
||||||
"utf8",
|
|
||||||
).toString("base64url");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private decodeFetchWorkspacesCursor(
|
private decodeFetchWorkspacesCursor(
|
||||||
cursor: string,
|
cursor: string,
|
||||||
sort: FetchWorkspacesRequestSort[],
|
sort: FetchWorkspacesRequestSort[],
|
||||||
): FetchWorkspacesCursor {
|
): FetchWorkspacesCursor {
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
return decodeCursor(cursor, sort, FETCH_WORKSPACES_SORT_KEYS, "fetch_workspaces");
|
||||||
} catch {
|
} catch (error) {
|
||||||
throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor");
|
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(
|
private compareWorkspaceWithCursor(
|
||||||
|
|||||||
Reference in New Issue
Block a user