| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115 |
- import { badRequest } from "@/lib/api/errors";
- /**
- * Cursor format (opaque for clients):
- * - base64url(JSON.stringify(payload))
- *
- * We keep the cursor extensible so we can switch provider internals later
- * (e.g. async-search context_id) without changing the public API.
- *
- * Current v1 payload shape:
- * {
- * v: 1,
- * mode: "sync",
- * offset: number,
- * contextId?: string
- * }
- */
- function isPlainObject(value) {
- return Boolean(value && typeof value === "object" && !Array.isArray(value));
- }
- function toBase64Url(jsonString) {
- // Node.js supports "base64url" encoding out of the box.
- return Buffer.from(String(jsonString), "utf8").toString("base64url");
- }
- function fromBase64Url(b64) {
- return Buffer.from(String(b64), "base64url").toString("utf8");
- }
- /**
- * Encode a cursor payload into an opaque string.
- *
- * @param {{ v?: number, mode?: string, offset: number, contextId?: string }} payload
- * @returns {string}
- */
- export function encodeCursor(payload) {
- if (!isPlainObject(payload)) {
- throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor payload");
- }
- const v = payload.v ?? 1;
- const mode = payload.mode ?? "sync";
- const offset = payload.offset;
- if (!Number.isInteger(offset) || offset < 0) {
- throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor payload", {
- offset,
- });
- }
- const normalized = {
- v,
- mode: String(mode),
- offset,
- };
- if (payload.contextId) normalized.contextId = String(payload.contextId);
- return toBase64Url(JSON.stringify(normalized));
- }
- /**
- * Decode an opaque cursor string.
- *
- * @param {string|null|undefined} cursor
- * @returns {{ v: number, mode: string, offset: number, contextId: string|null }}
- */
- export function decodeCursor(cursor) {
- if (!cursor) {
- return { v: 1, mode: "sync", offset: 0, contextId: null };
- }
- if (typeof cursor !== "string" || !cursor.trim()) {
- throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor");
- }
- let raw;
- try {
- raw = fromBase64Url(cursor.trim());
- } catch (err) {
- throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor");
- }
- let parsed;
- try {
- parsed = JSON.parse(raw);
- } catch (err) {
- throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor");
- }
- if (!isPlainObject(parsed)) {
- throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor");
- }
- const v = Number(parsed.v ?? 1);
- const mode = String(parsed.mode ?? "sync");
- const offset = Number(parsed.offset);
- if (!Number.isInteger(v) || v !== 1) {
- throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor");
- }
- if (!Number.isInteger(offset) || offset < 0) {
- throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor");
- }
- const contextId =
- typeof parsed.contextId === "string" && parsed.contextId.trim()
- ? parsed.contextId.trim()
- : null;
- return { v, mode, offset, contextId };
- }
|