diff --git a/README.md b/README.md index 8ffbf324..1861aa62 100644 --- a/README.md +++ b/README.md @@ -294,10 +294,10 @@ When you fetch a collection of resources, you get paginated results. You can req const skuList = await skus.list({ pageNumber: 3, pageSize: 5 }) // Get the total number of SKUs in the collection - const skuCount = skus.meta.recordCount + const skuCount = skuList.meta.recordCount // Get the total number of pages - const pageCount = skus.meta.pageCount + const pageCount = skuList.meta.pageCount ``` > PS: the default page number is **1**, the default page size is **10**, and the maximum page size allowed is **25**. @@ -305,6 +305,31 @@ When you fetch a collection of resources, you get paginated results. You can req ℹ️ Check our API reference for more information on how [pagination](https://docs.commercelayer.io/developers/pagination) works. +
+How to fetch a cursor-paginated collection (e.g. event stores) +
+ +A few resources — such as [event stores](https://docs.commercelayer.io/core-api-reference/event_stores) — use **cursor-based** pagination instead of page numbers. Navigate with `pageAfter` (and `pageBefore`), and read the cursor for the next page from `meta.cursor`, which is present only on cursor-paginated responses: + +```javascript + const skuId = 'xYZkjABcde' + + // Event stores are fetched as a relationship of a resource + let page = await skus.event_stores(skuId, { pageSize: 10 }) + const events = [...page] + + // Follow the cursor until there are no more pages + while (page.meta.cursor?.next) { + page = await skus.event_stores(skuId, { pageAfter: page.meta.cursor.next.after }) + events.push(...page) + } +``` + +> PS: the default page size is **10** and the maximum is **25**. On cursor-paginated responses the offset fields (`meta.pageCount`/`meta.recordCount`) are `NaN` and `hasNextPage()` returns `false`, so check `meta.cursor.next` instead. The `pageAfter`/`pageBefore` params are always accepted at the type level; offset-paginated resources simply ignore them. + +ℹ️ See the [event stores pagination](https://docs.commercelayer.io/core-api-reference/event_stores#pagination) reference for details. +
+
How to iterate through a collection of SKUs
diff --git a/specs/cursor-pagination.spec.ts b/specs/cursor-pagination.spec.ts new file mode 100644 index 00000000..e7bbff80 --- /dev/null +++ b/specs/cursor-pagination.spec.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, test } from 'vitest' +import type { Fetch } from '../src/fetch' +import { CommerceLayer, skus } from '../src/single-client' + +const config = { organization: 'test-org', accessToken: 'fake-token' } as const + +// Minimal fake fetch: captures the requested URL and returns a crafted +// JSON:API document so both the request query string and the response-meta +// parsing can be asserted without hitting the network. +const fakeFetch = (body: unknown, capture?: (url: URL) => void): Fetch => + ((url: URL) => { + capture?.(url) + return Promise.resolve({ + ok: true, + status: 200, + body: {}, + json: () => Promise.resolve(body), + } as unknown as Response) + }) as Fetch + +const cursorBody = (nextUrl: string) => ({ + data: [{ id: '1234567891234-0', type: 'event_stores', attributes: { resource_type: 'skus', event: 'update' } }], + meta: {}, + links: { next: nextUrl }, +}) + +// Single-page cursor response: empty meta, no `links` (nothing before/after). +const cursorSinglePageBody = () => ({ + data: [{ id: '1234567891234-0', type: 'event_stores', attributes: { resource_type: 'skus', event: 'update' } }], + meta: {}, +}) + +const offsetBody = () => ({ + data: [{ id: 'SKU1', type: 'skus', attributes: { code: 'TSHIRT' } }], + meta: { record_count: 42, page_count: 5, page_number: 2, page_size: 10 }, +}) + +beforeEach(() => { + CommerceLayer(config) +}) + +describe('cursor pagination — request', () => { + test('emits page[after]/page[before] and suppresses the implicit page[number]=1', async () => { + let captured: URL | undefined + await skus + .list( + { pageAfter: 'CUR_AFTER', pageBefore: 'CUR_BEFORE', pageSize: 10 }, + { + fetch: fakeFetch(offsetBody(), (u) => { + captured = u + }), + }, + ) + .catch(() => {}) + + expect(captured?.searchParams.get('page[after]')).toBe('CUR_AFTER') + expect(captured?.searchParams.get('page[before]')).toBe('CUR_BEFORE') + expect(captured?.searchParams.get('page[size]')).toBe('10') + expect(captured?.searchParams.has('page[number]')).toBe(false) + }) + + test('offset list() still injects page[number]=1 when absent', async () => { + let captured: URL | undefined + await skus + .list( + {}, + { + fetch: fakeFetch(offsetBody(), (u) => { + captured = u + }), + }, + ) + .catch(() => {}) + expect(captured?.searchParams.get('page[number]')).toBe('1') + expect(captured?.searchParams.has('page[after]')).toBe(false) + }) +}) + +describe('cursor pagination — response meta', () => { + test('cursor response parses links.next into meta.cursor', async () => { + const next = + 'https://test-org.commercelayer.io/api/skus/xYZkjABcde/event_stores?page[after]=CURSOR123&page[size]=10' + const list = await skus.event_stores('xYZkjABcde', { pageSize: 10 }, { fetch: fakeFetch(cursorBody(next)) }) + + // cursor pagination is detected by the presence of meta.cursor — no narrowing + expect(list.meta.cursor).toBeDefined() + expect(list.meta.cursor?.next?.after).toBe('CURSOR123') + expect(list.meta.cursor?.next?.before).toBeUndefined() + expect(list.meta.cursor?.prev).toBeUndefined() + expect(list.meta.recordsPerPage).toBe(10) + // the offset interface still resolves; values are NaN/false in cursor mode + expect(list.hasNextPage()).toBe(false) + expect(list.hasPrevPage()).toBe(false) + expect(Number.isNaN(list.meta.recordCount)).toBe(true) + expect(Number.isNaN(list.pageCount)).toBe(true) + expect(Number.isNaN(list.recordCount)).toBe(true) + }) + + test('single-page cursor response (no links) still exposes meta.cursor', async () => { + const list = await skus.event_stores('xYZkjABcde', { pageSize: 25 }, { fetch: fakeFetch(cursorSinglePageBody()) }) + + expect(list.meta.cursor).toBeDefined() + expect(list.meta.cursor?.next).toBeUndefined() + expect(list.meta.cursor?.prev).toBeUndefined() + expect(list.hasNextPage()).toBe(false) + }) + + test('offset response builds offset meta and working accessors', async () => { + const list = await skus.list({ pageNumber: 2, pageSize: 10 }, { fetch: fakeFetch(offsetBody()) }) + + expect(list.meta.cursor).toBeUndefined() + // the historical meta.* interface resolves directly, no narrowing + expect(list.meta.pageCount).toBe(5) + expect(list.meta.recordCount).toBe(42) + expect(list.meta.currentPage).toBe(2) + expect(list.pageCount).toBe(5) + expect(list.recordCount).toBe(42) + expect(list.hasNextPage()).toBe(true) + expect(list.hasPrevPage()).toBe(true) + }) +}) diff --git a/src/index.ts b/src/index.ts index 0473ee62..1b3da192 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,7 @@ export type { QueryFields, QueryFilter, QueryInclude, + QueryPageCursor, QueryPageNumber, QueryPageSize, QueryParams, diff --git a/src/query.ts b/src/query.ts index 4c40799a..c82dae7a 100644 --- a/src/query.ts +++ b/src/query.ts @@ -44,18 +44,34 @@ export type QueryFilter = Record +export type QueryPageCursor = string export interface QueryParamsList extends QueryParamsRetrieve { sort?: QuerySort filters?: QueryFilter pageNumber?: QueryPageNumber pageSize?: QueryPageSize + /** + * Cursor for cursor-based pagination — returns the records after the given cursor. + * Only cursor-paginated resources (e.g. `event_stores`) honor it; offset-paginated + * resources ignore it. Emitted as the `page[after]` query parameter. + */ + pageAfter?: QueryPageCursor + /** + * Cursor for cursor-based pagination — returns the records before the given cursor. + * Only cursor-paginated resources honor it; offset-paginated resources ignore it. + * Emitted as the `page[before]` query parameter. + */ + pageBefore?: QueryPageCursor } export type QueryParams = QueryParamsRetrieve | QueryParamsList const isParamsList = (params: any): params is QueryParamsList => { - return params && (params.filters || params.pageNumber || params.pageSize || params.sort) + return ( + params && + (params.filters || params.pageNumber || params.pageSize || params.sort || params.pageAfter || params.pageBefore) + ) } type QueryStringParams = Record @@ -91,6 +107,9 @@ const generateQueryStringParams = ( // Page if (params.pageNumber) qp['page[number]'] = String(params.pageNumber) if (params.pageSize) qp['page[size]'] = String(params.pageSize) + // Cursor pagination — offset-paginated resources ignore these + if (params.pageAfter) qp['page[after]'] = String(params.pageAfter) + if (params.pageBefore) qp['page[before]'] = String(params.pageBefore) // Filters if (params.filters) { Object.entries(params.filters).forEach(([p, v]) => { diff --git a/src/resource.ts b/src/resource.ts index 7d6c5665..c4cf5d75 100644 --- a/src/resource.ts +++ b/src/resource.ts @@ -40,11 +40,22 @@ interface ResourceUpdate extends ResourceBase { readonly id: string } +type PageCursor = { readonly before?: string; readonly after?: string } + +// Flat, non-discriminated (mirrors the poc-js-sdk shape). The offset fields are +// always present so the historical `meta.*` interface keeps resolving as +// `number` — they're `NaN` on cursor-paginated responses. `cursor` is present +// only on cursor-paginated responses (e.g. `event_stores`), parsed from the +// response `links`; its presence is how you tell the two pagination styles apart. type ListMeta = { readonly pageCount: number readonly recordCount: number readonly currentPage: number readonly recordsPerPage: number + readonly cursor?: { + readonly prev?: PageCursor + readonly next?: PageCursor + } } class ListResponse extends Array { @@ -92,6 +103,57 @@ class ListResponse extends Array { } } +type ResponseLinks = { next?: string; prev?: string } | undefined + +const parseCursorLink = (url?: string): PageCursor | undefined => { + if (!url) return undefined + let params: URLSearchParams + try { + params = new URL(url).searchParams + } catch { + return undefined + } + const after = params.get('page[after]') ?? undefined + const before = params.get('page[before]') ?? undefined + return after != null || before != null ? { before, after } : undefined +} + +// Builds the list meta. The pagination style is decided by the presence of +// `meta.page_count`: offset collections always return it (even for a single +// page), whereas cursor collections (e.g. `event_stores`) never do — they carry +// `page[after]`/`page[before]` cursors in `links` instead, and only when further +// pages exist. So a single-page cursor response (no `links`) still gets a +// `cursor` (with no prev/next), which is how callers detect cursor pagination. +const buildListMeta = ( + res: DocWithData, + links: ResponseLinks, + params?: QueryParamsList, +): ListMeta => { + const recordsPerPage = params?.pageSize || config.default.pageSize + + if (res.meta?.page_count == null) { + return { + // Offset fields aren't applicable to cursor pagination; kept as NaN so the + // shared `meta.*` interface still resolves (see ListMeta). + pageCount: NaN, + recordCount: NaN, + currentPage: NaN, + recordsPerPage, + cursor: { + prev: parseCursorLink(links?.prev), + next: parseCursorLink(links?.next), + }, + } + } + + return { + pageCount: Number(res.meta?.page_count), + recordCount: Number(res.meta?.record_count), + currentPage: params?.pageNumber || config.default.pageNumber, + recordsPerPage, + } +} + export type { ListMeta, ListResponse, @@ -221,20 +283,16 @@ class ResourceAdapter { const queryParams = generateQueryStringParams(params, resource) if (options?.params) Object.assign(queryParams, options?.params) - // Load balancer performance optimization - if (!queryParams['page[number]']) queryParams['page[number]'] = '1' + // Load balancer performance optimization — skipped for cursor pagination, + // which must not be mixed with an implicit page[number]. + const usesCursor = queryParams['page[after]'] != null || queryParams['page[before]'] != null + if (!usesCursor && !queryParams['page[number]']) queryParams['page[number]'] = '1' const res = await this.#client.request('GET', `${resource.type}`, undefined, { ...options, params: queryParams }) + const links: ResponseLinks = res.links const r = denormalize(res as DocWithData) as R[] - const meta: ListMeta = { - pageCount: Number(res.meta?.page_count), - recordCount: Number(res.meta?.record_count), - currentPage: params?.pageNumber || config.default.pageNumber, - recordsPerPage: params?.pageSize || config.default.pageSize, - } - - return new ListResponse(meta, r) + return new ListResponse(buildListMeta(res as DocWithData, links, params), r) } async create( @@ -291,17 +349,12 @@ class ResourceAdapter { if (options?.params) Object.assign(queryParams, options?.params) const res = await this.#client.request('GET', path, undefined, { ...options, params: queryParams }) + const links: ResponseLinks = res.links const r = denormalize(res as DocWithData) if (Array.isArray(r)) { const p = params as QueryParamsList - const meta: ListMeta = { - pageCount: Number(res.meta?.page_count), - recordCount: Number(res.meta?.record_count), - currentPage: p?.pageNumber || config.default.pageNumber, - recordsPerPage: p?.pageSize || config.default.pageSize, - } - return new ListResponse(meta, r) + return new ListResponse(buildListMeta(res as DocWithData, links, p), r) } else return r } } diff --git a/src/single-client.ts b/src/single-client.ts index d832c69e..21b04ce1 100644 --- a/src/single-client.ts +++ b/src/single-client.ts @@ -60,6 +60,7 @@ export type { QueryFields, QueryFilter, QueryInclude, + QueryPageCursor, QueryPageNumber, QueryPageSize, QueryParams,