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,