/** * Opaque keyset-pagination cursor: a positional tuple encoding the sort-key of * the last returned row (e.g. `[block, index]` for an append-only feed, or * `[balance, address]` for a balance ranking). Pages are anchored to this * position rather than a numeric offset, so rows arriving at the head of a feed * cannot shift items across page boundaries (the duplicate/skip hazard of offset * pagination over a live feed). * * The token is opaque on purpose: callers must treat it as a blob and pass back * the `nextCursor` from the previous page verbatim. Because cursor values are * inlined into upstream SQL, every field is validated on decode against its * declared `Field` kind and re-serialized into a safe SQL literal via `literal`, * so a malformed or forged cursor degrades to "start from the head" rather than * reaching the query. */ export type Cursor = (string | number)[] /** The kind of a cursor field, determining its validation and SQL literal form. */ export type Field = 'address' | 'id' | 'int' | 'timestamp' | 'uint' /** Encodes a positional tuple as an opaque, URL-safe token. */ export function encode(values: Cursor): string { return base64UrlEncode(JSON.stringify(values)) } /** * Splits a keyset query result (fetched with one extra row, `LIMIT n + 1`) into * a page. Returns the page `rows` (capped at `limit`), whether `hasMore` rows * follow, and the `nextCursor` anchoring the next page below the last row. * * `key` derives the next-page cursor tuple from the last row of the page, * returning `undefined` when the row's sort-key can't be extracted (then * `nextCursor` is `null`). Callers build their typed `data` from `rows`. */ export function paginate(options: { /** The raw query result, fetched with one extra row (`LIMIT limit + 1`). */ rows: readonly row[] /** The requested page size. */ limit: number /** Derives the next-page cursor tuple from the last row of the page. */ key: (row: row) => Cursor | undefined }): { hasMore: boolean; nextCursor: string | null; rows: row[] } { const { rows, limit, key } = options const pageRows = rows.slice(0, limit) const hasMore = rows.length > limit const last = pageRows.at(-1) const tuple = hasMore && last !== undefined ? key(last) : undefined return { hasMore, nextCursor: tuple ? encode(tuple) : null, rows: pageRows } } /** * Decodes a cursor token, validating arity and each field against `fields`. * Returns `undefined` for anything malformed/forged so a bad cursor falls back * to the head page instead of erroring (or reaching SQL). */ export function decode(token: string, fields: readonly Field[]): Cursor | undefined { try { const parsed = JSON.parse(base64UrlDecode(token)) as unknown if (!Array.isArray(parsed) || parsed.length !== fields.length) return undefined for (let i = 0; i < fields.length; i++) if (!isValid(parsed[i], fields[i]!)) return undefined return parsed as Cursor } catch { return undefined } } /** * Renders a (decode-validated) cursor value as a safe SQL literal: integers and * uint256 decimals are inlined bare; addresses are lowercased and quoted; * timestamps are quoted. Only ever call with values returned from `decode`. */ export function literal(value: string | number, field: Field): string { switch (field) { case 'int': case 'uint': return String(value) case 'address': return `'${String(value).toLowerCase()}'` case 'id': return `'${String(value)}'` case 'timestamp': return `'${String(value)}'` } } /** * Builds a strict lexicographic keyset predicate for the given ORDER BY columns * and direction. Columns carry pre-rendered `literal`s. * * When every column sorts the same way, this emits a row-value (tuple) * comparison, e.g. for `ORDER BY a DESC, b DESC` after `(va, vb)`: * `((a, b) < (va, vb))`. This single-predicate form is the only one the * indexer's signature-decoded queries accept (they reject the equivalent * `OR`-expansion), and standard Postgres / ClickHouse support it too. * * When directions are mixed (e.g. a ranking `ORDER BY balance DESC, addr ASC`), * a tuple comparison can't express it, so it falls back to the `OR` expansion: * `((a < va) OR (a = va AND b > vb))`. Mixed-direction keysets only run against * ClickHouse, which accepts the `OR` form. * * Multi-column keysets additionally AND a redundant non-strict bound on the * first column (`a <= va`): neither the tuple nor the `OR` form alone lets the * ClickHouse planner prune the primary-key range, so without the bare bound a * deep cursor page scans from the tip (see the Q1 notes in `activities.ts`). */ export function keyset( columns: readonly { name: string; literal: string; order: 'asc' | 'desc' }[], ): string { const first = columns[0]! const bound = columns.length > 1 ? `${first.name} ${first.order === 'asc' ? '>=' : '<='} ${first.literal} AND ` : '' const uniform = columns.every((column) => column.order === first.order) if (uniform) { const op = first.order === 'asc' ? '>' : '<' const names = columns.map((column) => column.name).join(', ') const literals = columns.map((column) => column.literal).join(', ') return `(${bound}(${names}) ${op} (${literals}))` } const clauses: string[] = [] for (let i = 0; i < columns.length; i++) { const parts = columns.slice(0, i).map((column) => `${column.name} = ${column.literal}`) const op = columns[i]!.order === 'asc' ? '>' : '<' parts.push(`${columns[i]!.name} ${op} ${columns[i]!.literal}`) clauses.push(`(${parts.join(' AND ')})`) } return `(${bound}(${clauses.join(' OR ')}))` } function isValid(value: unknown, field: Field): boolean { switch (field) { case 'int': return typeof value === 'number' && Number.isInteger(value) && value >= 0 case 'uint': return typeof value === 'string' && /^\d+$/.test(value) case 'address': return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value) case 'id': return typeof value === 'string' && /^[A-Za-z0-9:_-]+$/.test(value) case 'timestamp': return typeof value === 'string' && !Number.isNaN(Date.parse(value)) } } function base64UrlEncode(value: string): string { return btoa(value).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '') } function base64UrlDecode(value: string): string { return atob(value.replaceAll('-', '+').replaceAll('_', '/')) }