cursor.js 2.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. import { badRequest } from "@/lib/api/errors";
  2. /**
  3. * Cursor format (opaque for clients):
  4. * - base64url(JSON.stringify(payload))
  5. *
  6. * We keep the cursor extensible so we can switch provider internals later
  7. * (e.g. async-search context_id) without changing the public API.
  8. *
  9. * Current v1 payload shape:
  10. * {
  11. * v: 1,
  12. * mode: "sync",
  13. * offset: number,
  14. * contextId?: string
  15. * }
  16. */
  17. function isPlainObject(value) {
  18. return Boolean(value && typeof value === "object" && !Array.isArray(value));
  19. }
  20. function toBase64Url(jsonString) {
  21. // Node.js supports "base64url" encoding out of the box.
  22. return Buffer.from(String(jsonString), "utf8").toString("base64url");
  23. }
  24. function fromBase64Url(b64) {
  25. return Buffer.from(String(b64), "base64url").toString("utf8");
  26. }
  27. /**
  28. * Encode a cursor payload into an opaque string.
  29. *
  30. * @param {{ v?: number, mode?: string, offset: number, contextId?: string }} payload
  31. * @returns {string}
  32. */
  33. export function encodeCursor(payload) {
  34. if (!isPlainObject(payload)) {
  35. throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor payload");
  36. }
  37. const v = payload.v ?? 1;
  38. const mode = payload.mode ?? "sync";
  39. const offset = payload.offset;
  40. if (!Number.isInteger(offset) || offset < 0) {
  41. throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor payload", {
  42. offset,
  43. });
  44. }
  45. const normalized = {
  46. v,
  47. mode: String(mode),
  48. offset,
  49. };
  50. if (payload.contextId) normalized.contextId = String(payload.contextId);
  51. return toBase64Url(JSON.stringify(normalized));
  52. }
  53. /**
  54. * Decode an opaque cursor string.
  55. *
  56. * @param {string|null|undefined} cursor
  57. * @returns {{ v: number, mode: string, offset: number, contextId: string|null }}
  58. */
  59. export function decodeCursor(cursor) {
  60. if (!cursor) {
  61. return { v: 1, mode: "sync", offset: 0, contextId: null };
  62. }
  63. if (typeof cursor !== "string" || !cursor.trim()) {
  64. throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor");
  65. }
  66. let raw;
  67. try {
  68. raw = fromBase64Url(cursor.trim());
  69. } catch (err) {
  70. throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor");
  71. }
  72. let parsed;
  73. try {
  74. parsed = JSON.parse(raw);
  75. } catch (err) {
  76. throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor");
  77. }
  78. if (!isPlainObject(parsed)) {
  79. throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor");
  80. }
  81. const v = Number(parsed.v ?? 1);
  82. const mode = String(parsed.mode ?? "sync");
  83. const offset = Number(parsed.offset);
  84. if (!Number.isInteger(v) || v !== 1) {
  85. throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor");
  86. }
  87. if (!Number.isInteger(offset) || offset < 0) {
  88. throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor");
  89. }
  90. const contextId =
  91. typeof parsed.contextId === "string" && parsed.contextId.trim()
  92. ? parsed.contextId.trim()
  93. : null;
  94. return { v, mode, offset, contextId };
  95. }