import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts"; import type { ProblemDetails } from "../../contracts/studio/contract.ts"; import { stableStringify } from "../stable-stringify.ts"; export type CursorPayload = { binding: string; lastValue: string; lastId: string }; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; function invalid(detail: string) { const problem: ProblemDetails = { type: "https://techlog.local/problems/request-validation-failed", title: "Request validation failed", status: 422, detail, code: "REQUEST_VALIDATION_FAILED", retryable: false, // wire와 같은 자리: `details`가 `ValidationErrorDetails` 모양이다 (Task 3 // fix round 1 — 예전엔 `fieldErrors`가 최상위 필드였다). details: { fieldErrors: [{ path: "/cursor", message: detail }] }, }; return new StudioGatewayError(problem); } export function cursorBinding(value: unknown) { return stableStringify(value); } export function encodeCursor(payload: CursorPayload): string { const bytes = new TextEncoder().encode(stableStringify(payload)); let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); } export function decodeCursor(cursor: string, binding: string): CursorPayload { try { const base64 = cursor.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(cursor.length / 4) * 4, "="); const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)); const parsed = JSON.parse(new TextDecoder().decode(bytes)) as Partial; if (parsed.binding !== binding || typeof parsed.lastValue !== "string" || typeof parsed.lastId !== "string" || !UUID.test(parsed.lastId)) { throw invalid("Cursor does not match the normalized filters and sort."); } return parsed as CursorPayload; } catch (error) { if (error instanceof StudioGatewayError) throw error; throw invalid("Cursor is malformed."); } }