Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,17 +294,42 @@ 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**.

ℹ️ Check our API reference for more information on how [pagination](https://docs.commercelayer.io/developers/pagination) works.
</details>

<details>
<summary>How to fetch a cursor-paginated collection (e.g. event stores)</summary>
<br />

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.
</details>

<details>
<summary>How to iterate through a collection of SKUs</summary>
<br />
Expand Down
121 changes: 121 additions & 0 deletions specs/cursor-pagination.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export type {
QueryFields,
QueryFilter,
QueryInclude,
QueryPageCursor,
QueryPageNumber,
QueryPageSize,
QueryParams,
Expand Down
21 changes: 20 additions & 1 deletion src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,34 @@ export type QueryFilter = Record<string, string | number | boolean | object | Ar

export type QueryPageNumber = number
export type QueryPageSize = PositiveNumberRange<25>
export type QueryPageCursor = string

export interface QueryParamsList<R extends Resource = Resource> extends QueryParamsRetrieve<R> {
sort?: QuerySort<R>
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<R extends Resource = Resource> = QueryParamsRetrieve<R> | QueryParamsList<R>

const isParamsList = <R extends Resource>(params: any): params is QueryParamsList<R> => {
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<string, string>
Expand Down Expand Up @@ -91,6 +107,9 @@ const generateQueryStringParams = <R extends Resource>(
// 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]) => {
Expand Down
87 changes: 70 additions & 17 deletions src/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<R extends Resource = Resource> extends Array<R> {
Expand Down Expand Up @@ -92,6 +103,57 @@ class ListResponse<R extends Resource = Resource> extends Array<R> {
}
}

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 = <R extends Resource>(
res: DocWithData,
links: ResponseLinks,
params?: QueryParamsList<R>,
): 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,
Expand Down Expand Up @@ -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<R>(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<R>(res as DocWithData, links, params), r)
}

async create<C extends ResourceCreate, R extends Resource>(
Expand Down Expand Up @@ -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<R>(res as DocWithData)

if (Array.isArray(r)) {
const p = params as QueryParamsList<R>
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<R>(res as DocWithData, links, p), r)
} else return r
}
}
Expand Down
1 change: 1 addition & 0 deletions src/single-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export type {
QueryFields,
QueryFilter,
QueryInclude,
QueryPageCursor,
QueryPageNumber,
QueryPageSize,
QueryParams,
Expand Down
Loading