f266c386f3
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
121 lines
4.8 KiB
TypeScript
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])
|
|
}
|