mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

paginate.ts

Language TypeScript8.8 KB252 lines

packages/ninety-client/src/paginate.ts

  1. /**
  2. * Pagination and response-shape normalization.
  3. *
  4. * This file exists because Ninety's API is not internally consistent. Per a
  5. * third-party SDK's docs (verified live only where marked):
  6. *
  7. * Issues, KPIs -> { items: [], totalCount: n }
  8. * To-Dos, Teams -> bare JSON array
  9. * Rocks -> object keyed by teamId
  10. *
  11. * and pagination splits three ways:
  12. *
  13. * Rocks, Issues -> pageIndex (0-based), sortField/sortDirection
  14. * To-Dos -> page (1-based, verified), sort/order
  15. * Company To-Dos -> opaque cursor (verified, see fromCursor below)
  16. *
  17. * Everything above this layer speaks one dialect: 0-based `page`, and `Page<T>`.
  18. * Cursor endpoints cannot be expressed that way (you cannot seek to page 4 of a
  19. * cursor stream), so they get `CursorPage<T>` and `iterateCursor` instead of a
  20. * fake page number. Do not paper over the difference; a synthetic pageIndex on a
  21. * cursor endpoint is a lie that eventually gets indexed into.
  22. *
  23. * If a shape turns out wrong, this file changes and nothing else does. That is
  24. * the point of isolating it.
  25. */
  26. import { NinetySchemaError, type NinetyErrorContext } from './errors.js'
  27. /** The one paginated shape the rest of the codebase knows about. */
  28. export interface Page<T> {
  29. items: T[]
  30. /** null when Ninety does not report a total (bare-array endpoints). */
  31. total: number | null
  32. /** Always 0-based, regardless of what the endpoint wanted. */
  33. pageIndex: number
  34. pageSize: number
  35. hasMore: boolean
  36. }
  37. /**
  38. * Cursor-paginated shape. No page index, no total; those do not exist here.
  39. * `nextToken` is opaque: pass it back verbatim, never parse or construct one.
  40. */
  41. export interface CursorPage<T> {
  42. items: T[]
  43. /** null means the set is exhausted. Verified: a complete set returns null. */
  44. nextToken: string | null
  45. }
  46. /** The one request shape callers use. Translated per-resource on the way out. */
  47. export interface PageRequest {
  48. /** 0-based. Translated to 1-based for endpoints that need it. */
  49. page?: number
  50. pageSize?: number
  51. sortBy?: string
  52. sortDir?: 'asc' | 'desc'
  53. }
  54. // --- shape adapters ---------------------------------------------------------
  55. /** `{ items, totalCount }` -> Page. Issues, KPIs. */
  56. export function fromEnvelope<T>(
  57. raw: unknown,
  58. req: Required<Pick<PageRequest, 'page' | 'pageSize'>>,
  59. ctx: NinetyErrorContext,
  60. ): Page<T> {
  61. if (typeof raw !== 'object' || raw === null || !('items' in raw)) {
  62. throw new NinetySchemaError(ctx, `expected {items, totalCount}, got ${describe(raw)}`)
  63. }
  64. const items = (raw as { items: unknown }).items
  65. if (!Array.isArray(items)) {
  66. throw new NinetySchemaError(ctx, `expected .items to be an array, got ${describe(items)}`)
  67. }
  68. const totalRaw = (raw as { totalCount?: unknown }).totalCount
  69. const total = typeof totalRaw === 'number' ? totalRaw : null
  70. return {
  71. items: items as T[],
  72. total,
  73. pageIndex: req.page,
  74. pageSize: req.pageSize,
  75. hasMore:
  76. total === null ? items.length === req.pageSize : (req.page + 1) * req.pageSize < total,
  77. }
  78. }
  79. /**
  80. * Bare array -> Page. To-Dos, Teams.
  81. *
  82. * No total is reported, so `hasMore` is inferred from a full page. That means
  83. * one wasted request when the item count is an exact multiple of pageSize.
  84. * Correctness over cleverness; the alternative is silently truncating.
  85. */
  86. export function fromArray<T>(
  87. raw: unknown,
  88. req: Required<Pick<PageRequest, 'page' | 'pageSize'>>,
  89. ctx: NinetyErrorContext,
  90. ): Page<T> {
  91. if (!Array.isArray(raw)) {
  92. throw new NinetySchemaError(ctx, `expected a bare array, got ${describe(raw)}`)
  93. }
  94. return {
  95. items: raw as T[],
  96. total: null,
  97. pageIndex: req.page,
  98. pageSize: req.pageSize,
  99. hasMore: raw.length >= req.pageSize,
  100. }
  101. }
  102. /**
  103. * `{ [teamId]: T[] }` -> Page. Rocks.
  104. *
  105. * Flattens and stamps each item with the teamId it was filed under, since that
  106. * key is otherwise the only place the association exists.
  107. */
  108. export function fromTeamKeyed<T extends object>(
  109. raw: unknown,
  110. req: Required<Pick<PageRequest, 'page' | 'pageSize'>>,
  111. ctx: NinetyErrorContext,
  112. ): Page<T & { teamId: string }> {
  113. if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
  114. throw new NinetySchemaError(ctx, `expected an object keyed by teamId, got ${describe(raw)}`)
  115. }
  116. const items: (T & { teamId: string })[] = []
  117. for (const [teamId, group] of Object.entries(raw as Record<string, unknown>)) {
  118. if (!Array.isArray(group)) {
  119. throw new NinetySchemaError(ctx, `expected an array under teamId "${teamId}", got ${describe(group)}`)
  120. }
  121. for (const item of group) {
  122. items.push({ ...(item as T), teamId })
  123. }
  124. }
  125. return {
  126. items,
  127. total: null,
  128. pageIndex: req.page,
  129. pageSize: req.pageSize,
  130. hasMore: items.length >= req.pageSize,
  131. }
  132. }
  133. /**
  134. * `{ data: T[], pagination: { nextToken } }` -> CursorPage. Company To-Dos.
  135. *
  136. * Verified against GET /todos/company: top-level keys are exactly
  137. * `['data', 'pagination']`. A complete result set
  138. * returns `pagination.nextToken: null`, so null is "done", not "unknown".
  139. *
  140. * Note this is a different envelope from `{items, totalCount}`: `data` not
  141. * `items`, and no total is reported at all. Do not try to compute a percentage
  142. * complete from it; there is no denominator.
  143. */
  144. export function fromCursor<T>(raw: unknown, ctx: NinetyErrorContext): CursorPage<T> {
  145. if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
  146. throw new NinetySchemaError(ctx, `expected {data, pagination}, got ${describe(raw)}`)
  147. }
  148. const data = (raw as { data?: unknown }).data
  149. if (!Array.isArray(data)) {
  150. throw new NinetySchemaError(ctx, `expected .data to be an array, got ${describe(data)}`)
  151. }
  152. const pagination = (raw as { pagination?: unknown }).pagination
  153. let nextToken: string | null = null
  154. if (pagination !== undefined && pagination !== null) {
  155. if (typeof pagination !== 'object' || Array.isArray(pagination)) {
  156. throw new NinetySchemaError(ctx, `expected .pagination to be an object, got ${describe(pagination)}`)
  157. }
  158. const token = (pagination as { nextToken?: unknown }).nextToken
  159. if (typeof token === 'string' && token.length > 0) nextToken = token
  160. }
  161. return { items: data as T[], nextToken }
  162. }
  163. /**
  164. * Walks a cursor stream to exhaustion.
  165. *
  166. * The safety cap matters more here than on numbered pages: a server that keeps
  167. * echoing the same nextToken produces an infinite loop that looks like healthy
  168. * traffic. We also detect a repeated token explicitly, because that is the
  169. * failure mode most likely to actually happen.
  170. */
  171. export async function* iterateCursor<T>(
  172. fetchPage: (nextToken: string | undefined) => Promise<CursorPage<T>>,
  173. opts: IterateOptions = {},
  174. ): AsyncGenerator<T, void, undefined> {
  175. const maxPages = opts.maxPages ?? 500
  176. let token: string | undefined
  177. const seen = new Set<string>()
  178. for (let n = 0; n < maxPages; n++) {
  179. const page = await fetchPage(token)
  180. for (const item of page.items) yield item
  181. if (!page.nextToken) return
  182. if (seen.has(page.nextToken)) {
  183. throw new Error(
  184. `iterateCursor received the same nextToken twice ("${page.nextToken.slice(0, 12)}..."). ` +
  185. `Ninety is not advancing the cursor; stopping rather than looping forever.`,
  186. )
  187. }
  188. seen.add(page.nextToken)
  189. token = page.nextToken
  190. }
  191. throw new Error(
  192. `iterateCursor stopped at the ${maxPages}-page safety cap. Either raise maxPages or ` +
  193. `check that nextToken is being threaded back correctly.`,
  194. )
  195. }
  196. // --- iteration --------------------------------------------------------------
  197. export interface IterateOptions {
  198. /**
  199. * Hard stop. Without it a pagination bug on Ninety's side becomes an infinite
  200. * loop against a rate-limited API.
  201. */
  202. maxPages?: number
  203. }
  204. /**
  205. * Walks every page. Callers should almost always use this rather than paging by
  206. * hand, so the 1-based/0-based translation stays in one place.
  207. */
  208. export async function* iterateAll<T>(
  209. fetchPage: (req: Required<Pick<PageRequest, 'page' | 'pageSize'>>) => Promise<Page<T>>,
  210. req: Required<Pick<PageRequest, 'page' | 'pageSize'>>,
  211. opts: IterateOptions = {},
  212. ): AsyncGenerator<T, void, undefined> {
  213. const maxPages = opts.maxPages ?? 500
  214. let page = req.page
  215. for (let n = 0; n < maxPages; n++) {
  216. const result = await fetchPage({ page, pageSize: req.pageSize })
  217. for (const item of result.items) yield item
  218. if (!result.hasMore || result.items.length === 0) return
  219. page++
  220. }
  221. throw new Error(
  222. `iterateAll stopped at the ${maxPages}-page safety cap. Either raise maxPages or ` +
  223. `check that hasMore is being computed correctly for this endpoint.`,
  224. )
  225. }
  226. /** Convenience: drain an iterator into an array. */
  227. export async function collect<T>(it: AsyncGenerator<T, void, undefined>): Promise<T[]> {
  228. const out: T[] = []
  229. for await (const item of it) out.push(item)
  230. return out
  231. }
  232. function describe(v: unknown): string {
  233. if (v === null) return 'null'
  234. if (Array.isArray(v)) return `array(${v.length})`
  235. return typeof v
  236. }