Files
accounted/lib/api/v1/pagination.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

121 lines
4.8 KiB
TypeScript

/**
* Cursor-based pagination for v1 list endpoints.
*
* Cursors are opaque base64-JSON tokens encoding the keyset position. The
* default key is `(created_at, id)`, which is stable across concurrent writes:
* a row inserted after a cursor was minted appears in a later page, never
* mid-page. Endpoints that need a different sort key supply their own
* encoder/decoder pair.
*
* Limits are clamped to [1, 100]; default 50.
*
* Cursors are NOT signed or encrypted: they reveal only sort-key values that
* the user could already see from a previous page. Treat them as ephemeral
* pagination hints, not security tokens.
*/
import { UUID_RE as UUID } from '@/lib/invariants/uuid'
export const DEFAULT_LIMIT = 50
export const MAX_LIMIT = 100
export interface PaginationParams {
limit: number
cursor: string | null
}
/**
* Parse `?cursor=...&limit=...` from a URL. Returns a normalized
* { limit, cursor } pair with limit clamped to [1, MAX_LIMIT].
*
* Invalid `limit` (non-numeric, negative) falls back to DEFAULT_LIMIT rather
* than throwing: callers can always re-validate via Zod if strictness is
* needed.
*/
export function parsePaginationParams(url: URL): PaginationParams {
const rawLimit = url.searchParams.get('limit')
const cursor = url.searchParams.get('cursor')
let limit = DEFAULT_LIMIT
if (rawLimit !== null) {
const parsed = Number.parseInt(rawLimit, 10)
if (Number.isFinite(parsed) && parsed > 0) {
limit = Math.min(parsed, MAX_LIMIT)
}
}
return { limit, cursor: cursor && cursor.length > 0 ? cursor : null }
}
// ─────────────────────────────────────────────────────────────────
// Default (created_at, id) keyset cursor
// ─────────────────────────────────────────────────────────────────
export interface DefaultCursor {
/** ISO 8601 timestamp of the boundary row's created_at. */
ts: string
/** UUID of the boundary row. Disambiguates rows with identical timestamps. */
id: string
}
/**
* Encode a (created_at, id) boundary into an opaque cursor string.
* Returns null when the input is null/undefined so callers can write
* `next_cursor: encodeDefaultCursor(lastRow)` without a conditional.
*/
export function encodeDefaultCursor(row: { created_at: string; id: string } | null | undefined): string | null {
if (!row) return null
const payload: DefaultCursor = { ts: row.created_at, id: row.id }
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
}
// Strict format guards: defence-in-depth against tampered cursors that
// could otherwise inject untyped strings into a query's `.gt(field, value)`.
// PostgREST would likely reject these, but validating here keeps the failure
// mode predictable (stale cursor → "start over") rather than 400-ing.
const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/
/**
* Decode a cursor produced by encodeDefaultCursor. Returns null when the
* input is missing or malformed: callers should treat null as "start from
* the beginning" rather than 400-ing on a stale cursor.
*
* `ts` must parse as an ISO 8601 timestamp and `id` must be a UUID; anything
* else is treated as a stale/corrupt cursor and discarded.
*/
export function decodeDefaultCursor(cursor: string | null | undefined): DefaultCursor | null {
if (!cursor) return null
try {
const json = Buffer.from(cursor, 'base64url').toString('utf8')
const parsed = JSON.parse(json) as Partial<DefaultCursor>
if (typeof parsed.ts !== 'string' || typeof parsed.id !== 'string') return null
if (!ISO_TIMESTAMP.test(parsed.ts)) return null
if (!UUID.test(parsed.id)) return null
return { ts: parsed.ts, id: parsed.id }
} catch {
return null
}
}
/**
* Convenience: given a result page and the requested limit, return the
* cursor for the *next* page (or null when this was the final page).
*
* Convention: the caller fetches `limit + 1` rows, passes the full slice in,
* and we return either the cursor of the LAST ROW OF THE TRIMMED PAGE
* (rows[limit - 1]) or null when the page wasn't full. The caller should then
* trim the slice to `limit` before returning it to the user.
*
* Contract: the cursor marks the last row already returned; every v1 route's
* keyset predicate is strictly greater/less than the cursor, so encoding
* rows[limit] (the first row of the NEXT page) would skip that row at every
* page boundary.
*/
export function nextCursorFromPage<T extends { created_at: string; id: string }>(
rows: T[],
limit: number,
): string | null {
if (rows.length <= limit) return null
return encodeDefaultCursor(rows[limit - 1])
}