From 247436667e803717c18c16762949b1b0c0ca6c7e Mon Sep 17 00:00:00 2001 From: javorosas Date: Wed, 9 Sep 2026 13:12:32 +0200 Subject: [PATCH 01/10] fix: serialize nested and array query params with bracket notation new URLSearchParams() coerces object values to "[object Object]", so list and search calls that pass a date range object ({ gte, lt }) sent date=[object Object] and every date-filtered request failed with a 400. Flatten params into [key, value] pairs before encoding: plain objects expand to bracket keys (date[gte]=...), arrays to repeated empty-bracket keys (status[]=a&status[]=b), matching the v2 API contract and the curl examples in the docs. null/undefined values and empty collections are skipped; Date values are sent as ISO 8601 strings. Bump to 4.22.0 and add regression tests. --- CHANGELOG.md | 6 ++ package.json | 2 +- src/wrapper.ts | 47 ++++++++++- test/node/query-params.node.test.ts | 110 ++++++++++++++++++++++++++ test/node/runtime-compat.node.test.ts | 4 +- 5 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 test/node/query-params.node.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d1043fc..6baffc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [4.22.0] 2026-09-09 + +### Fixed + +- Serialize nested query params with the bracket notation the API expects. List/search calls passing an object value (for example `date: { gte, lt }` on `invoices.list`, `receipts.list`, `customers.list`, etc.) used to send `date=[object Object]` and fail; they now send `date[gte]=...&date[lt]=...`. Array values now expand to repeated keys (`status[]=a&status[]=b`) instead of being comma-joined, matching the API contract. + ## [4.21.0] 2026-09-04 ### Added diff --git a/package.json b/package.json index 958a804..7219b04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "facturapi", - "version": "4.21.0", + "version": "4.22.0", "description": "SDK oficial de Facturapi para Node.js y navegadores. Integra facturación electrónica en México (CFDI) de forma simple y obtén una perspectiva fiscal completa de tu operación, con búsquedas indexadas, envío de documentos y trazabilidad.", "main": "dist/index.cjs.js", "module": "dist/index.es.js", diff --git a/src/wrapper.ts b/src/wrapper.ts index 7a6ec75..f629508 100644 --- a/src/wrapper.ts +++ b/src/wrapper.ts @@ -83,6 +83,51 @@ const responseHeadersToObject = (headers: Headers): Record => { return result; }; +/** + * Flattens a params object into `[key, value]` pairs suitable for + * `URLSearchParams`, expanding nested objects and arrays into the bracket + * notation the API expects (`date[gte]=...`, `status[]=...`). `null` and + * `undefined` values and empty collections are skipped, mirroring how query + * params were serialized before the Fetch API migration. + */ +const buildQueryString = (params: Record): string => { + const pairs: Array<[string, string]> = []; + const append = (value: unknown, key: string) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + if (value.length === 0) { + return; + } + for (const item of value) { + append(item, `${key}[]`); + } + return; + } + if (typeof value === 'object') { + if (value instanceof Date) { + pairs.push([key, value.toISOString()]); + return; + } + const entries = Object.entries(value); + if (entries.length === 0) { + return; + } + for (const [subKey, subValue] of entries) { + append(subValue, `${key}[${subKey}]`); + } + return; + } + pairs.push([key, String(value)]); + }; + for (const [key, value] of Object.entries(params)) { + append(value, key); + } + return new URLSearchParams(pairs).toString(); +}; + + const stringFrom = (value: unknown): string | undefined => typeof value === 'string' ? value : undefined; @@ -218,7 +263,7 @@ export const createWrapper = ( ) { const { params, body, formData, ...restOptions } = options || {}; const queryString = params - ? '?' + new URLSearchParams(params).toString() + ? '?' + buildQueryString(params) : ''; const requestHeaders = new Headers(defaultHeaders); if (!formData) { diff --git a/test/node/query-params.node.test.ts b/test/node/query-params.node.test.ts new file mode 100644 index 0000000..ef4f4e7 --- /dev/null +++ b/test/node/query-params.node.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import Facturapi from '../../src' + +const originalFetch = globalThis.fetch + +function createClient() { + const client = new Facturapi('sk_test_123') + client.BASE_URL = 'https://api.test.local/v2' + return client +} + +afterEach(() => { + globalThis.fetch = originalFetch + vi.restoreAllMocks() +}) + +describe('query param serialization', () => { + it('serializes a nested date range object with bracket notation', async () => { + const client = createClient() + + globalThis.fetch = vi.fn(async (url) => { + expect(url).toBe( + 'https://api.test.local/v2/invoices?limit=100&q=laboratorio+ramos&date%5Bgte%5D=2026-01-01&date%5Blt%5D=2026-02-01', + ) + return new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) as typeof fetch + + await client.invoices.list({ + limit: 100, + q: 'laboratorio ramos', + date: { gte: '2026-01-01', lt: '2026-02-01' }, + }) + }) + + it('serializes arrays with repeated empty-bracket keys', async () => { + const client = createClient() + + globalThis.fetch = vi.fn(async (url) => { + expect(url).toBe( + 'https://api.test.local/v2/invoices?status%5B%5D=valid&status%5B%5D=canceled', + ) + return new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) as typeof fetch + + await client.invoices.list({ status: ['valid', 'canceled'] }) + }) + + it('skips null, undefined, and empty collections', async () => { + const client = createClient() + + globalThis.fetch = vi.fn(async (url) => { + expect(url).toBe('https://api.test.local/v2/invoices?page=2') + return new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) as typeof fetch + + await client.invoices.list({ + page: 2, + q: null, + date: undefined, + status: [], + empty: {}, + }) + }) + + it('serializes Date values as ISO 8601 strings', async () => { + const client = createClient() + + globalThis.fetch = vi.fn(async (url) => { + expect(url).toBe( + 'https://api.test.local/v2/invoices?date%5Bgte%5D=2026-01-01T00%3A00%3A00.000Z', + ) + return new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) as typeof fetch + + await client.invoices.list({ + date: { gte: new Date('2026-01-01T00:00:00.000Z') }, + }) + }) + + it('keeps flat params encoded exactly as before', async () => { + const client = createClient() + + globalThis.fetch = vi.fn(async (url) => { + expect(url).toBe( + 'https://api.test.local/v2/organizations/domain-check?domain=empresa-demo', + ) + return new Response(JSON.stringify({ available: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) as typeof fetch + + await client.organizations.checkDomainIsAvailable({ + domain: 'empresa-demo', + }) + }) +}) diff --git a/test/node/runtime-compat.node.test.ts b/test/node/runtime-compat.node.test.ts index 0e5f97f..ab4a655 100644 --- a/test/node/runtime-compat.node.test.ts +++ b/test/node/runtime-compat.node.test.ts @@ -509,12 +509,12 @@ describe('runtime compatibility (node)', () => { ).rejects.toThrow(/Unsupported file input type/) }) - it('serializes query params consistently with URLSearchParams semantics', async () => { + it('serializes flat params with URLSearchParams encoding and arrays with bracket keys', async () => { const client = createClient() globalThis.fetch = vi.fn(async (url) => { expect(url).toBe( - 'https://api.test.local/v2/invoices?search=a+b&page=2&active=true&empty=&tags=x%2Cy', + 'https://api.test.local/v2/invoices?search=a+b&page=2&active=true&empty=&tags%5B%5D=x&tags%5B%5D=y', ) return new Response( JSON.stringify({ From a0fe49d086e0b68d0b947dca5217dac18fed88db Mon Sep 17 00:00:00 2001 From: javorosas Date: Wed, 9 Sep 2026 14:40:46 +0200 Subject: [PATCH 02/10] feat(types): type the pagination/capping envelope on search responses SearchResult now mirrors what the v2 API returns: page/total_pages/ total_results are optional (later cursor pages omit totals) and new optional totals_are_capped, next_cursor, and previous_cursor support cursor pagination and capped totals. --- CHANGELOG.md | 1 + src/types/common.ts | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6baffc6..a3315db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed - Serialize nested query params with the bracket notation the API expects. List/search calls passing an object value (for example `date: { gte, lt }` on `invoices.list`, `receipts.list`, `customers.list`, etc.) used to send `date=[object Object]` and fail; they now send `date[gte]=...&date[lt]=...`. Array values now expand to repeated keys (`status[]=a&status[]=b`) instead of being comma-joined, matching the API contract. +- Type search responses with the pagination envelope the API returns: `page`, `total_pages`, and `total_results` are optional (omitted on later cursor pages), and new optional `totals_are_capped`, `next_cursor`, and `previous_cursor` fields support cursor pagination and capped totals. ## [4.21.0] 2026-09-04 diff --git a/src/types/common.ts b/src/types/common.ts index 867bab9..cac8eec 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -13,9 +13,18 @@ export interface Address { } export interface SearchResult { - page: number; - total_pages: number; - total_results: number; + /** Page number (page mode only). */ + page?: number; + /** Total pages derived from the (possibly capped) total (page mode only). */ + total_pages?: number; + /** Total matching results; capped (approximate) when totals_are_capped is true. */ + total_results?: number; + /** True when total_results is capped at the maximum search count. */ + totals_are_capped?: boolean; + /** Cursor to fetch the next page of a cursor search (cursor mode only). */ + next_cursor?: string | null; + /** Cursor to fetch the previous page of a cursor search (cursor mode only). */ + previous_cursor?: string | null; data: T[]; } From 87926878708df2c0f4bfa9eddef3d58fa1938784 Mon Sep 17 00:00:00 2001 From: javorosas Date: Wed, 9 Sep 2026 15:48:54 +0200 Subject: [PATCH 03/10] docs(changelog): move cursor/capped envelope typing to Added The new SearchResult pagination fields are an additive feature (minor), so they belong under Added; the nested query serialization fix stays under Fixed. --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3315db..e7c9cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [4.22.0] 2026-09-09 +### Added + +- Type search responses with the pagination envelope the API returns: `page`, `total_pages`, and `total_results` are optional (omitted on later cursor pages), and new optional `totals_are_capped`, `next_cursor`, and `previous_cursor` fields support cursor pagination and capped totals. + ### Fixed - Serialize nested query params with the bracket notation the API expects. List/search calls passing an object value (for example `date: { gte, lt }` on `invoices.list`, `receipts.list`, `customers.list`, etc.) used to send `date=[object Object]` and fail; they now send `date[gte]=...&date[lt]=...`. Array values now expand to repeated keys (`status[]=a&status[]=b`) instead of being comma-joined, matching the API contract. -- Type search responses with the pagination envelope the API returns: `page`, `total_pages`, and `total_results` are optional (omitted on later cursor pages), and new optional `totals_are_capped`, `next_cursor`, and `previous_cursor` fields support cursor pagination and capped totals. ## [4.21.0] 2026-09-04 From f7d0b8553152cacca01f43cba41885d281cf8d5b Mon Sep 17 00:00:00 2001 From: javorosas Date: Wed, 9 Sep 2026 22:01:02 +0200 Subject: [PATCH 04/10] fix(wrapper): plain-object-only bracket expansion, no stray '?' delimiter - Restrict nested bracket expansion to plain records (Object.prototype or null prototype); Date keeps ISO conversion; other object values (URL, RegExp, custom instances) keep their previous String(value) encoding instead of being silently dropped or recursed. - buildQueryString returns an empty string when every value is omitted, and the request builder only appends '?' when there is something to serialize, so all-omitted params produce the bare URL. - Regression tests for both cases. --- src/wrapper.ts | 31 ++++++++++++++++++----------- test/node/query-params.node.test.ts | 28 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/wrapper.ts b/src/wrapper.ts index f629508..d21cd73 100644 --- a/src/wrapper.ts +++ b/src/wrapper.ts @@ -83,12 +83,18 @@ const responseHeadersToObject = (headers: Headers): Record => { return result; }; +const isPlainRecord = (value: object): boolean => { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + /** * Flattens a params object into `[key, value]` pairs suitable for - * `URLSearchParams`, expanding nested objects and arrays into the bracket + * `URLSearchParams`, expanding plain objects and arrays into the bracket * notation the API expects (`date[gte]=...`, `status[]=...`). `null` and * `undefined` values and empty collections are skipped, mirroring how query - * params were serialized before the Fetch API migration. + * params were serialized before the Fetch API migration. Other object values + * (`URL`, `RegExp`, custom instances) keep their previous string conversion. */ const buildQueryString = (params: Record): string => { const pairs: Array<[string, string]> = []; @@ -110,21 +116,23 @@ const buildQueryString = (params: Record): string => { pairs.push([key, value.toISOString()]); return; } - const entries = Object.entries(value); - if (entries.length === 0) { + if (isPlainRecord(value)) { + const entries = Object.entries(value); + if (entries.length === 0) { + return; + } + for (const [subKey, subValue] of entries) { + append(subValue, `${key}[${subKey}]`); + } return; } - for (const [subKey, subValue] of entries) { - append(subValue, `${key}[${subKey}]`); - } - return; } pairs.push([key, String(value)]); }; for (const [key, value] of Object.entries(params)) { append(value, key); } - return new URLSearchParams(pairs).toString(); + return pairs.length ? new URLSearchParams(pairs).toString() : ''; }; @@ -262,9 +270,8 @@ export const createWrapper = ( }, ) { const { params, body, formData, ...restOptions } = options || {}; - const queryString = params - ? '?' + buildQueryString(params) - : ''; + const serializedQuery = params ? buildQueryString(params) : ''; + const queryString = serializedQuery ? `?${serializedQuery}` : ''; const requestHeaders = new Headers(defaultHeaders); if (!formData) { requestHeaders.set('Content-Type', 'application/json'); diff --git a/test/node/query-params.node.test.ts b/test/node/query-params.node.test.ts index ef4f4e7..694c5fa 100644 --- a/test/node/query-params.node.test.ts +++ b/test/node/query-params.node.test.ts @@ -107,4 +107,32 @@ describe('query param serialization', () => { domain: 'empresa-demo', }) }) + + it('does not append a query delimiter when every value is omitted', async () => { + const client = createClient() + + globalThis.fetch = vi.fn(async (url) => { + expect(url).toBe('https://api.test.local/v2/invoices') + return new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) as typeof fetch + + await client.invoices.list({ status: [], q: null, date: undefined }) + }) + + it('keeps string conversion for non-plain object values', async () => { + const client = createClient() + + globalThis.fetch = vi.fn(async (url) => { + expect(new URLSearchParams(url.split('?')[1]).get('q')).toBe('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/walmart/i') + return new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) as typeof fetch + + await client.invoices.list({ q: /walmart/i as unknown as string }) + }) }) From 6b1ad0056bdc5c1fac0202d8f8b2d6a2704c6254 Mon Sep 17 00:00:00 2001 From: javorosas Date: Thu, 10 Sep 2026 02:27:38 +0200 Subject: [PATCH 05/10] fix(types): keep page/total signatures required; cursor metadata stays additive Making SearchResult.page/total_pages/total_results optional would be a source-breaking change for strict TypeScript consumers under a minor release. Restore the required signatures (previous contract unchanged) and keep the cursor/capped metadata as new optional fields; document that the API only reports totals on page-mode responses and the first request of a cursor search, so cursor consumers should rely on next_cursor. --- CHANGELOG.md | 2 +- src/types/common.ts | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7c9cee..b94c8e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added -- Type search responses with the pagination envelope the API returns: `page`, `total_pages`, and `total_results` are optional (omitted on later cursor pages), and new optional `totals_are_capped`, `next_cursor`, and `previous_cursor` fields support cursor pagination and capped totals. +- Add the pagination envelope fields to search responses without changing the existing type contract: new optional `totals_are_capped`, `next_cursor`, and `previous_cursor` support cursor pagination and capped totals. (`page`/`total_pages`/`total_results` keep their existing signatures; the API only reports totals on page-mode responses and on the first request of a cursor search, so consumers draining cursors should rely on `next_cursor`.) ### Fixed diff --git a/src/types/common.ts b/src/types/common.ts index cac8eec..6273d5b 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -13,12 +13,16 @@ export interface Address { } export interface SearchResult { - /** Page number (page mode only). */ - page?: number; - /** Total pages derived from the (possibly capped) total (page mode only). */ - total_pages?: number; - /** Total matching results; capped (approximate) when totals_are_capped is true. */ - total_results?: number; + /** + * Page number. Present in page mode and on the first page of a cursor + * search; later cursor pages may omit it at runtime (the API only reports + * totals on the first request of a cursor sequence). + */ + page: number; + /** Total pages (same presence caveats as `page`). */ + total_pages: number; + /** Total matching results (same presence caveats as `page`). */ + total_results: number; /** True when total_results is capped at the maximum search count. */ totals_are_capped?: boolean; /** Cursor to fetch the next page of a cursor search (cursor mode only). */ From 23dc8cb92a1e965d093e0ff86ffdbc968d81f97d Mon Sep 17 00:00:00 2001 From: javorosas Date: Thu, 10 Sep 2026 02:31:42 +0200 Subject: [PATCH 06/10] feat(types): make SearchResult totals optional; release 5.0.0 (major) The API reports totals only in page mode and on the first request of a cursor search; later cursor pages omit page/total_pages/total_results. Model that truthfully as optional fields (source-breaking for strict consumers that dereferenced them) and release as a major, with the new optional totals_are_capped/next_cursor/previous_cursor under Added. --- CHANGELOG.md | 10 ++++++++-- package.json | 2 +- src/types/common.ts | 16 ++++++---------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b94c8e2..9c870fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## [4.22.0] 2026-09-09 +## [5.0.0] 2026-09-09 ### Added -- Add the pagination envelope fields to search responses without changing the existing type contract: new optional `totals_are_capped`, `next_cursor`, and `previous_cursor` support cursor pagination and capped totals. (`page`/`total_pages`/`total_results` keep their existing signatures; the API only reports totals on page-mode responses and on the first request of a cursor search, so consumers draining cursors should rely on `next_cursor`.) +### Breaking + +- `SearchResult.page`, `total_pages`, and `total_results` are now optional: the API reports totals only in page mode and on the first request of a cursor search, so later cursor pages omit them. Strict TypeScript consumers that dereferenced these fields must handle their absence (or use `next_cursor`). + +### Added + +- Optional `totals_are_capped`, `next_cursor`, and `previous_cursor` on search responses for cursor pagination and capped totals. ### Fixed diff --git a/package.json b/package.json index 7219b04..2e1a23d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "facturapi", - "version": "4.22.0", + "version": "5.0.0", "description": "SDK oficial de Facturapi para Node.js y navegadores. Integra facturación electrónica en México (CFDI) de forma simple y obtén una perspectiva fiscal completa de tu operación, con búsquedas indexadas, envío de documentos y trazabilidad.", "main": "dist/index.cjs.js", "module": "dist/index.es.js", diff --git a/src/types/common.ts b/src/types/common.ts index 6273d5b..396d9e4 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -13,16 +13,12 @@ export interface Address { } export interface SearchResult { - /** - * Page number. Present in page mode and on the first page of a cursor - * search; later cursor pages may omit it at runtime (the API only reports - * totals on the first request of a cursor sequence). - */ - page: number; - /** Total pages (same presence caveats as `page`). */ - total_pages: number; - /** Total matching results (same presence caveats as `page`). */ - total_results: number; + /** Page number. Absent on cursor pages after the first request. */ + page?: number; + /** Total pages. Absent on cursor pages after the first request. */ + total_pages?: number; + /** Total matching results (capped/approximate when totals_are_capped). Absent on cursor pages after the first request. */ + total_results?: number; /** True when total_results is capped at the maximum search count. */ totals_are_capped?: boolean; /** Cursor to fetch the next page of a cursor search (cursor mode only). */ From d89dc372cbeee744794a7ef52823137cd3707972 Mon Sep 17 00:00:00 2001 From: javorosas Date: Thu, 10 Sep 2026 03:45:55 +0200 Subject: [PATCH 07/10] feat(types): type cursor searches separately; keep page contract (no major) Instead of making SearchResult.page/total_pages/total_results optional (source breaking), add CursorSearchResult/CursorSearchParams and cursor-aware list() overloads: page mode keeps the existing signatures, and cursor searches get truthful optional totals plus next/previous cursors. Release stays 4.22.0 (minor, additive). --- CHANGELOG.md | 11 +++-------- package.json | 2 +- src/resources/customers.ts | 8 +++++++- src/resources/invoices.ts | 8 +++++++- src/resources/products.ts | 13 +++++++++++-- src/resources/receipts.ts | 8 +++++++- src/resources/retentions.ts | 8 +++++++- src/types/common.ts | 32 ++++++++++++++++++++++---------- 8 files changed, 65 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c870fd..aaaf1d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,17 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## [5.0.0] 2026-09-09 +## [4.22.0] 2026-09-09 ### Added -### Breaking - -- `SearchResult.page`, `total_pages`, and `total_results` are now optional: the API reports totals only in page mode and on the first request of a cursor search, so later cursor pages omit them. Strict TypeScript consumers that dereferenced these fields must handle their absence (or use `next_cursor`). - -### Added - -- Optional `totals_are_capped`, `next_cursor`, and `previous_cursor` on search responses for cursor pagination and capped totals. +- `CursorSearchResult` and `CursorSearchParams` for cursor searches: totals are only reported on the first request of a cursor sequence, so cursor responses type `total_results` as optional and expose `previous_cursor`/`next_cursor`. Page-mode `SearchResult` keeps its existing contract and its new optional `totals_are_capped`. +- Cursor-aware overloads on `list()` for invoices, receipts, customers, products, and retentions: passing `pagination: 'cursor'` (or `after`/`before`) types the result as `CursorSearchResult`. ### Fixed diff --git a/package.json b/package.json index 2e1a23d..7219b04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "facturapi", - "version": "5.0.0", + "version": "4.22.0", "description": "SDK oficial de Facturapi para Node.js y navegadores. Integra facturación electrónica en México (CFDI) de forma simple y obtén una perspectiva fiscal completa de tu operación, con búsquedas indexadas, envío de documentos y trazabilidad.", "main": "dist/index.cjs.js", "module": "dist/index.es.js", diff --git a/src/resources/customers.ts b/src/resources/customers.ts index dff307d..c391477 100644 --- a/src/resources/customers.ts +++ b/src/resources/customers.ts @@ -1,5 +1,7 @@ import { Customer, + CursorSearchParams, + CursorSearchResult, GenericResponse, SearchResult, TaxInfoValidation, @@ -30,7 +32,11 @@ export default class Customers { * @param params Search parameters * @returns List of customers */ - list(params: Record): Promise> { + list(params: CursorSearchParams): Promise>; + list(params?: Record | null): Promise>; + list( + params?: Record | null, + ): Promise | CursorSearchResult> { if (!params) params = {}; return this.client.get('/customers', { params: params }); } diff --git a/src/resources/invoices.ts b/src/resources/invoices.ts index fd3674e..b6d300b 100644 --- a/src/resources/invoices.ts +++ b/src/resources/invoices.ts @@ -10,6 +10,8 @@ import { SearchResult, SendEmailBody, ZipRequest, + CursorSearchParams, + CursorSearchResult, } from '../types'; import { WrapperClient } from '../wrapper'; @@ -38,7 +40,11 @@ export default class Invoices { * @param params - Search parameters * @returns Search results object. The object contains a `data` property with the list of invoices. */ - list(params?: Record | null): Promise> { + list(params: CursorSearchParams): Promise>; + list(params?: Record | null): Promise>; + list( + params?: Record | null, + ): Promise | CursorSearchResult> { if (!params) params = {}; return this.client.get('/invoices', { params }); } diff --git a/src/resources/products.ts b/src/resources/products.ts index 839995b..42ebde9 100644 --- a/src/resources/products.ts +++ b/src/resources/products.ts @@ -1,4 +1,9 @@ -import { Product, SearchResult } from '../types'; +import { + CursorSearchParams, + CursorSearchResult, + Product, + SearchResult +} from '../types'; import { WrapperClient } from '../wrapper'; export default class Products { @@ -21,7 +26,11 @@ export default class Products { * @param params - Search parameters * @returns Search results object. The object contains a `data` property with the list of products. */ - list(params?: Record | null): Promise> { + list(params: CursorSearchParams): Promise>; + list(params?: Record | null): Promise>; + list( + params?: Record | null, + ): Promise | CursorSearchResult> { return this.client.get('/products', { params: params }); } diff --git a/src/resources/receipts.ts b/src/resources/receipts.ts index c116b9f..e38547e 100644 --- a/src/resources/receipts.ts +++ b/src/resources/receipts.ts @@ -5,6 +5,8 @@ import { ReceiptsToInvoiceInput, Receipt, SearchResult, + CursorSearchResult, + CursorSearchParams, SendEmailBody, PreviewReceiptsToInvoicePdfInput, } from '../types' @@ -30,7 +32,11 @@ export default class Receipts { * @param params Search parameters * @returns Search results object. The object contains a `data` property with the list of receipts. */ - list(params?: Record | null): Promise> { + list(params: CursorSearchParams): Promise>; + list(params?: Record | null): Promise>; + list( + params?: Record | null, + ): Promise | CursorSearchResult> { if (!params) params = {} return this.client.get('/receipts', { params }) } diff --git a/src/resources/retentions.ts b/src/resources/retentions.ts index 92f5cb1..fb399f4 100644 --- a/src/resources/retentions.ts +++ b/src/resources/retentions.ts @@ -3,6 +3,8 @@ import { GenericResponse, Retention, SearchResult, + CursorSearchResult, + CursorSearchParams, SendEmailBody, } from '../types' import { WrapperClient } from '../wrapper' @@ -27,7 +29,11 @@ export default class Retentions { * @param params - Search parameters * @returns */ - list(params?: Record | null): Promise> { + list(params: CursorSearchParams): Promise>; + list(params?: Record | null): Promise>; + list( + params?: Record | null, + ): Promise | CursorSearchResult> { if (!params) params = {} return this.client.get('/retentions', { params }) } diff --git a/src/types/common.ts b/src/types/common.ts index 396d9e4..2d4c033 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -13,21 +13,33 @@ export interface Address { } export interface SearchResult { - /** Page number. Absent on cursor pages after the first request. */ - page?: number; - /** Total pages. Absent on cursor pages after the first request. */ - total_pages?: number; - /** Total matching results (capped/approximate when totals_are_capped). Absent on cursor pages after the first request. */ - total_results?: number; + /** Page number (page mode). */ + page: number; + /** Total pages derived from the (possibly capped) total (page mode). */ + total_pages: number; + /** Total matching results; capped (approximate) when totals_are_capped is true (page mode). */ + total_results: number; /** True when total_results is capped at the maximum search count. */ totals_are_capped?: boolean; - /** Cursor to fetch the next page of a cursor search (cursor mode only). */ - next_cursor?: string | null; - /** Cursor to fetch the previous page of a cursor search (cursor mode only). */ - previous_cursor?: string | null; data: T[]; } +/** + * Response of a cursor search. Totals are only reported on the first request + * of a cursor sequence, so they are optional here; navigate with the cursors. + */ +export interface CursorSearchResult { + total_results?: number; + totals_are_capped?: boolean; + previous_cursor: string | null; + next_cursor: string | null; + data: T[]; +} + +/** Params that select cursor pagination (page mode is the default). */ +export type CursorSearchParams = ({ pagination: 'cursor' } | { after: string } | { before: string }) & + Record; + export interface InvoiceItemPart { quantity: number; product_key: string; From 5373a6075407640a0bf5b92c4fbd3f0f05fe1f95 Mon Sep 17 00:00:00 2001 From: javorosas Date: Thu, 10 Sep 2026 12:28:53 +0200 Subject: [PATCH 08/10] feat(types): add PageSearchParams alongside CursorSearchParams Symmetric param types for list(): PageSearchParams (page mode, the default) and CursorSearchParams (cursor mode), each an object with known fields intersected with Record. A page-mode overload returns SearchResult (unchanged) and cursor params keep returning CursorSearchResult. Purely additive: existing call sites, including loose Record params, keep compiling. Type tests cover both modes, the loose-params fallback, and that page totals stay required. --- src/resources/customers.ts | 2 ++ src/resources/invoices.ts | 2 ++ src/resources/products.ts | 2 ++ src/resources/receipts.ts | 2 ++ src/resources/retentions.ts | 2 ++ src/types/common.ts | 4 ++++ test-d/runtime-types.test-d.ts | 21 +++++++++++++++++++++ 7 files changed, 35 insertions(+) diff --git a/src/resources/customers.ts b/src/resources/customers.ts index c391477..5bfa1e7 100644 --- a/src/resources/customers.ts +++ b/src/resources/customers.ts @@ -1,6 +1,7 @@ import { Customer, CursorSearchParams, + PageSearchParams, CursorSearchResult, GenericResponse, SearchResult, @@ -33,6 +34,7 @@ export default class Customers { * @returns List of customers */ list(params: CursorSearchParams): Promise>; + list(params: PageSearchParams): Promise>; list(params?: Record | null): Promise>; list( params?: Record | null, diff --git a/src/resources/invoices.ts b/src/resources/invoices.ts index b6d300b..b5d0d29 100644 --- a/src/resources/invoices.ts +++ b/src/resources/invoices.ts @@ -11,6 +11,7 @@ import { SendEmailBody, ZipRequest, CursorSearchParams, + PageSearchParams, CursorSearchResult, } from '../types'; import { WrapperClient } from '../wrapper'; @@ -41,6 +42,7 @@ export default class Invoices { * @returns Search results object. The object contains a `data` property with the list of invoices. */ list(params: CursorSearchParams): Promise>; + list(params: PageSearchParams): Promise>; list(params?: Record | null): Promise>; list( params?: Record | null, diff --git a/src/resources/products.ts b/src/resources/products.ts index 42ebde9..f7deb5f 100644 --- a/src/resources/products.ts +++ b/src/resources/products.ts @@ -1,5 +1,6 @@ import { CursorSearchParams, + PageSearchParams, CursorSearchResult, Product, SearchResult @@ -27,6 +28,7 @@ export default class Products { * @returns Search results object. The object contains a `data` property with the list of products. */ list(params: CursorSearchParams): Promise>; + list(params: PageSearchParams): Promise>; list(params?: Record | null): Promise>; list( params?: Record | null, diff --git a/src/resources/receipts.ts b/src/resources/receipts.ts index e38547e..7a643be 100644 --- a/src/resources/receipts.ts +++ b/src/resources/receipts.ts @@ -7,6 +7,7 @@ import { SearchResult, CursorSearchResult, CursorSearchParams, + PageSearchParams, SendEmailBody, PreviewReceiptsToInvoicePdfInput, } from '../types' @@ -33,6 +34,7 @@ export default class Receipts { * @returns Search results object. The object contains a `data` property with the list of receipts. */ list(params: CursorSearchParams): Promise>; + list(params: PageSearchParams): Promise>; list(params?: Record | null): Promise>; list( params?: Record | null, diff --git a/src/resources/retentions.ts b/src/resources/retentions.ts index fb399f4..205193e 100644 --- a/src/resources/retentions.ts +++ b/src/resources/retentions.ts @@ -5,6 +5,7 @@ import { SearchResult, CursorSearchResult, CursorSearchParams, + PageSearchParams, SendEmailBody, } from '../types' import { WrapperClient } from '../wrapper' @@ -30,6 +31,7 @@ export default class Retentions { * @returns */ list(params: CursorSearchParams): Promise>; + list(params: PageSearchParams): Promise>; list(params?: Record | null): Promise>; list( params?: Record | null, diff --git a/src/types/common.ts b/src/types/common.ts index 2d4c033..2c7910b 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -36,6 +36,10 @@ export interface CursorSearchResult { data: T[]; } +/** Params that select page pagination (the default). */ +export type PageSearchParams = ({ pagination?: 'page' } | { page: number }) & + Record; + /** Params that select cursor pagination (page mode is the default). */ export type CursorSearchParams = ({ pagination: 'cursor' } | { after: string } | { before: string }) & Record; diff --git a/test-d/runtime-types.test-d.ts b/test-d/runtime-types.test-d.ts index bb10f90..ee8ef9f 100644 --- a/test-d/runtime-types.test-d.ts +++ b/test-d/runtime-types.test-d.ts @@ -1,7 +1,10 @@ import { expectAssignable, expectType, expectError } from 'tsd'; import Facturapi, { BinaryDownload, + CursorSearchResult, + PageSearchParams, FacturapiError, + Invoice, InvoiceItem, InvoiceType, IssuingType, @@ -70,3 +73,21 @@ expectType(apiError.path); expectType(apiError.location); expectType(apiError.logId); expectType>(apiError.headers); + +// Pagination params keep the page-mode contract; cursor params select the cursor result. +expectAssignable({ page: 2 }); +expectType>>( + client.invoices.list({ page: 2, limit: 50 }), +); +expectType>>( + client.invoices.list({ pagination: 'cursor', limit: 50 }), +); +expectType>>( + client.invoices.list({ after: 'cursor-token' }), +); +const looseParams: Record = { page: 2 }; +expectType>>(client.invoices.list(looseParams)); +expectType>>(client.invoices.list()); +// Page-mode fields stay required, so existing dereferences keep compiling. +const pagePromise = client.invoices.list({ page: 1 }); +expectType>(pagePromise.then((result) => result.total_results)); From ec0973b5d23973b0396641554ec8578a84b988ae Mon Sep 17 00:00:00 2001 From: javorosas Date: Thu, 10 Sep 2026 17:51:49 +0200 Subject: [PATCH 09/10] fix(wrapper): send array query params as repeated keys --- CHANGELOG.md | 2 +- src/wrapper.ts | 14 ++++++++------ test/node/query-params.node.test.ts | 4 ++-- test/node/runtime-compat.node.test.ts | 4 ++-- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aaaf1d1..492e6a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed -- Serialize nested query params with the bracket notation the API expects. List/search calls passing an object value (for example `date: { gte, lt }` on `invoices.list`, `receipts.list`, `customers.list`, etc.) used to send `date=[object Object]` and fail; they now send `date[gte]=...&date[lt]=...`. Array values now expand to repeated keys (`status[]=a&status[]=b`) instead of being comma-joined, matching the API contract. +- Serialize nested query params with the bracket notation the API expects. List/search calls passing an object value (for example `date: { gte, lt }` on `invoices.list`, `receipts.list`, `customers.list`, etc.) used to send `date=[object Object]` and fail; they now send `date[gte]=...&date[lt]=...`. Array values now expand to repeated keys (`status=a&status=b`) instead of being comma-joined or sent as `status[]=a`, matching the API contract and the other official SDKs. ## [4.21.0] 2026-09-04 diff --git a/src/wrapper.ts b/src/wrapper.ts index d21cd73..6dc4070 100644 --- a/src/wrapper.ts +++ b/src/wrapper.ts @@ -90,11 +90,13 @@ const isPlainRecord = (value: object): boolean => { /** * Flattens a params object into `[key, value]` pairs suitable for - * `URLSearchParams`, expanding plain objects and arrays into the bracket - * notation the API expects (`date[gte]=...`, `status[]=...`). `null` and - * `undefined` values and empty collections are skipped, mirroring how query - * params were serialized before the Fetch API migration. Other object values - * (`URL`, `RegExp`, custom instances) keep their previous string conversion. + * `URLSearchParams`. Plain objects expand to the bracket notation the API + * documents (`date[gte]=...`, the `deepObject` style) and arrays expand to + * repeated keys (`status=a&status=b`, the OpenAPI default `form` + `explode`), + * so every official SDK sends the same encoding. `null` and `undefined` values + * and empty collections are skipped, mirroring how query params were + * serialized before the Fetch API migration. Other object values (`URL`, + * `RegExp`, custom instances) keep their previous string conversion. */ const buildQueryString = (params: Record): string => { const pairs: Array<[string, string]> = []; @@ -107,7 +109,7 @@ const buildQueryString = (params: Record): string => { return; } for (const item of value) { - append(item, `${key}[]`); + append(item, key); } return; } diff --git a/test/node/query-params.node.test.ts b/test/node/query-params.node.test.ts index 694c5fa..a9bcd66 100644 --- a/test/node/query-params.node.test.ts +++ b/test/node/query-params.node.test.ts @@ -36,12 +36,12 @@ describe('query param serialization', () => { }) }) - it('serializes arrays with repeated empty-bracket keys', async () => { + it('serializes arrays as repeated keys', async () => { const client = createClient() globalThis.fetch = vi.fn(async (url) => { expect(url).toBe( - 'https://api.test.local/v2/invoices?status%5B%5D=valid&status%5B%5D=canceled', + 'https://api.test.local/v2/invoices?status=valid&status=canceled', ) return new Response(JSON.stringify({ data: [] }), { status: 200, diff --git a/test/node/runtime-compat.node.test.ts b/test/node/runtime-compat.node.test.ts index ab4a655..19dee8e 100644 --- a/test/node/runtime-compat.node.test.ts +++ b/test/node/runtime-compat.node.test.ts @@ -509,12 +509,12 @@ describe('runtime compatibility (node)', () => { ).rejects.toThrow(/Unsupported file input type/) }) - it('serializes flat params with URLSearchParams encoding and arrays with bracket keys', async () => { + it('serializes flat params with URLSearchParams encoding and arrays with repeated keys', async () => { const client = createClient() globalThis.fetch = vi.fn(async (url) => { expect(url).toBe( - 'https://api.test.local/v2/invoices?search=a+b&page=2&active=true&empty=&tags%5B%5D=x&tags%5B%5D=y', + 'https://api.test.local/v2/invoices?search=a+b&page=2&active=true&empty=&tags=x&tags=y', ) return new Response( JSON.stringify({ From 6ba7609b5d61652363d0f2babc88a4bc25e4e63f Mon Sep 17 00:00:00 2001 From: javorosas Date: Thu, 10 Sep 2026 18:24:29 +0200 Subject: [PATCH 10/10] feat(types)!: make the search envelope optional and drop CursorSearchResult --- CHANGELOG.md | 10 +++++++--- package.json | 2 +- src/resources/customers.ts | 10 +--------- src/resources/invoices.ts | 10 +--------- src/resources/products.ts | 10 +--------- src/resources/receipts.ts | 10 +--------- src/resources/retentions.ts | 10 +--------- src/types/common.ts | 31 +++++++++++++------------------ test-d/runtime-types.test-d.ts | 19 ++++++++++++------- 9 files changed, 38 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 492e6a9..377fab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## [4.22.0] 2026-09-09 +## [5.0.0] 2026-09-10 + +### Breaking + +- `SearchResult` now describes the whole search envelope, so `page`, `total_pages`, and `total_results` are optional. Cursor pages only report the totals on the first request of the sequence, and a search with no matches reports `page: 0`, so the fields can be absent and dereferencing them requires narrowing. +- `CursorSearchResult` was removed: cursor responses are `SearchResult` too, with `previous_cursor` and `next_cursor` as optional fields. The `list()` overloads that selected the result type are gone with it. ### Added -- `CursorSearchResult` and `CursorSearchParams` for cursor searches: totals are only reported on the first request of a cursor sequence, so cursor responses type `total_results` as optional and expose `previous_cursor`/`next_cursor`. Page-mode `SearchResult` keeps its existing contract and its new optional `totals_are_capped`. -- Cursor-aware overloads on `list()` for invoices, receipts, customers, products, and retentions: passing `pagination: 'cursor'` (or `after`/`before`) types the result as `CursorSearchResult`. +- `CursorSearchParams` and `PageSearchParams` to type search params, and `totals_are_capped`, `previous_cursor`, and `next_cursor` on `SearchResult`. ### Fixed diff --git a/package.json b/package.json index 7219b04..2e1a23d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "facturapi", - "version": "4.22.0", + "version": "5.0.0", "description": "SDK oficial de Facturapi para Node.js y navegadores. Integra facturación electrónica en México (CFDI) de forma simple y obtén una perspectiva fiscal completa de tu operación, con búsquedas indexadas, envío de documentos y trazabilidad.", "main": "dist/index.cjs.js", "module": "dist/index.es.js", diff --git a/src/resources/customers.ts b/src/resources/customers.ts index 5bfa1e7..c714584 100644 --- a/src/resources/customers.ts +++ b/src/resources/customers.ts @@ -1,8 +1,5 @@ import { Customer, - CursorSearchParams, - PageSearchParams, - CursorSearchResult, GenericResponse, SearchResult, TaxInfoValidation, @@ -33,12 +30,7 @@ export default class Customers { * @param params Search parameters * @returns List of customers */ - list(params: CursorSearchParams): Promise>; - list(params: PageSearchParams): Promise>; - list(params?: Record | null): Promise>; - list( - params?: Record | null, - ): Promise | CursorSearchResult> { + list(params?: Record | null): Promise> { if (!params) params = {}; return this.client.get('/customers', { params: params }); } diff --git a/src/resources/invoices.ts b/src/resources/invoices.ts index b5d0d29..fd3674e 100644 --- a/src/resources/invoices.ts +++ b/src/resources/invoices.ts @@ -10,9 +10,6 @@ import { SearchResult, SendEmailBody, ZipRequest, - CursorSearchParams, - PageSearchParams, - CursorSearchResult, } from '../types'; import { WrapperClient } from '../wrapper'; @@ -41,12 +38,7 @@ export default class Invoices { * @param params - Search parameters * @returns Search results object. The object contains a `data` property with the list of invoices. */ - list(params: CursorSearchParams): Promise>; - list(params: PageSearchParams): Promise>; - list(params?: Record | null): Promise>; - list( - params?: Record | null, - ): Promise | CursorSearchResult> { + list(params?: Record | null): Promise> { if (!params) params = {}; return this.client.get('/invoices', { params }); } diff --git a/src/resources/products.ts b/src/resources/products.ts index f7deb5f..e20afa5 100644 --- a/src/resources/products.ts +++ b/src/resources/products.ts @@ -1,7 +1,4 @@ import { - CursorSearchParams, - PageSearchParams, - CursorSearchResult, Product, SearchResult } from '../types'; @@ -27,12 +24,7 @@ export default class Products { * @param params - Search parameters * @returns Search results object. The object contains a `data` property with the list of products. */ - list(params: CursorSearchParams): Promise>; - list(params: PageSearchParams): Promise>; - list(params?: Record | null): Promise>; - list( - params?: Record | null, - ): Promise | CursorSearchResult> { + list(params?: Record | null): Promise> { return this.client.get('/products', { params: params }); } diff --git a/src/resources/receipts.ts b/src/resources/receipts.ts index 7a643be..c116b9f 100644 --- a/src/resources/receipts.ts +++ b/src/resources/receipts.ts @@ -5,9 +5,6 @@ import { ReceiptsToInvoiceInput, Receipt, SearchResult, - CursorSearchResult, - CursorSearchParams, - PageSearchParams, SendEmailBody, PreviewReceiptsToInvoicePdfInput, } from '../types' @@ -33,12 +30,7 @@ export default class Receipts { * @param params Search parameters * @returns Search results object. The object contains a `data` property with the list of receipts. */ - list(params: CursorSearchParams): Promise>; - list(params: PageSearchParams): Promise>; - list(params?: Record | null): Promise>; - list( - params?: Record | null, - ): Promise | CursorSearchResult> { + list(params?: Record | null): Promise> { if (!params) params = {} return this.client.get('/receipts', { params }) } diff --git a/src/resources/retentions.ts b/src/resources/retentions.ts index 205193e..92f5cb1 100644 --- a/src/resources/retentions.ts +++ b/src/resources/retentions.ts @@ -3,9 +3,6 @@ import { GenericResponse, Retention, SearchResult, - CursorSearchResult, - CursorSearchParams, - PageSearchParams, SendEmailBody, } from '../types' import { WrapperClient } from '../wrapper' @@ -30,12 +27,7 @@ export default class Retentions { * @param params - Search parameters * @returns */ - list(params: CursorSearchParams): Promise>; - list(params: PageSearchParams): Promise>; - list(params?: Record | null): Promise>; - list( - params?: Record | null, - ): Promise | CursorSearchResult> { + list(params?: Record | null): Promise> { if (!params) params = {} return this.client.get('/retentions', { params }) } diff --git a/src/types/common.ts b/src/types/common.ts index 2c7910b..3748a68 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -13,26 +13,21 @@ export interface Address { } export interface SearchResult { - /** Page number (page mode). */ - page: number; - /** Total pages derived from the (possibly capped) total (page mode). */ - total_pages: number; - /** Total matching results; capped (approximate) when totals_are_capped is true (page mode). */ - total_results: number; - /** True when total_results is capped at the maximum search count. */ - totals_are_capped?: boolean; - data: T[]; -} - -/** - * Response of a cursor search. Totals are only reported on the first request - * of a cursor sequence, so they are optional here; navigate with the cursors. - */ -export interface CursorSearchResult { + /** Page number. Absent in cursor searches and when the search has no matches. */ + page?: number; + /** Total pages derived from the (possibly capped) total. Absent in cursor searches. */ + total_pages?: number; + /** + * Total matching results. Capped (approximate) when `totals_are_capped` is + * true, and only reported on the first request of a cursor sequence. + */ total_results?: number; + /** True when total_results is capped at the maximum search count. */ totals_are_capped?: boolean; - previous_cursor: string | null; - next_cursor: string | null; + /** Cursor to the previous slice (cursor searches only). */ + previous_cursor?: string | null; + /** Cursor to the next slice (cursor searches only). */ + next_cursor?: string | null; data: T[]; } diff --git a/test-d/runtime-types.test-d.ts b/test-d/runtime-types.test-d.ts index ee8ef9f..0ff48aa 100644 --- a/test-d/runtime-types.test-d.ts +++ b/test-d/runtime-types.test-d.ts @@ -1,7 +1,7 @@ import { expectAssignable, expectType, expectError } from 'tsd'; import Facturapi, { BinaryDownload, - CursorSearchResult, + CursorSearchParams, PageSearchParams, FacturapiError, Invoice, @@ -74,20 +74,25 @@ expectType(apiError.location); expectType(apiError.logId); expectType>(apiError.headers); -// Pagination params keep the page-mode contract; cursor params select the cursor result. +// Pagination params document the two modes; both return the same envelope. expectAssignable({ page: 2 }); +expectAssignable({ pagination: 'cursor', limit: 50 }); expectType>>( client.invoices.list({ page: 2, limit: 50 }), ); -expectType>>( +expectType>>( client.invoices.list({ pagination: 'cursor', limit: 50 }), ); -expectType>>( +expectType>>( client.invoices.list({ after: 'cursor-token' }), ); const looseParams: Record = { page: 2 }; expectType>>(client.invoices.list(looseParams)); expectType>>(client.invoices.list()); -// Page-mode fields stay required, so existing dereferences keep compiling. -const pagePromise = client.invoices.list({ page: 1 }); -expectType>(pagePromise.then((result) => result.total_results)); +// Totals and cursors are optional because cursor pages omit them. +expectType>( + client.invoices.list({ page: 1 }).then((result) => result.total_results), +); +expectType>( + client.invoices.list({ after: 'token' }).then((result) => result.next_cursor), +);