UNPKG

bc-api-client

Version:

A client for the BigCommerce management API and app authentication

1,229 lines 64.4 kB
import * as jose from "jose"; import ky, { isHTTPError, isKyError, isTimeoutError } from "ky"; import pLimit from "p-limit"; //#region src/lib/common.ts /** Maximum allowed concurrency value. */ const MAX_CONCURRENCY = 1e3; /** Maximum allowed URL length before chunking is required. */ const MAX_URL_LENGTH = 2048; /** Regex to strip leading slashes from API paths. */ const LEADING_SLASHES = /^\/+/; /** * Random positive jitter within 0-500 ms in increments of 100 * @param {number} delay */ const rateLimitJitter = (delay) => delay + Math.floor(Math.random() * 6) * 100; /** * HTTP header names used by the BigCommerce API. */ const HEADERS = { AUTH_TOKEN: "X-Auth-Token", ACCEPT: "Accept", CONTENT_TYPE: "Content-Type", RATE_LIMIT_LEFT: "x-rate-limit-requests-left", RATE_LIMIT_RESET: "x-rate-limit-time-reset-ms", RATE_LIMIT_QUOTA: "x-rate-limit-requests-quota", RATE_LIMIT_WINDOW: "x-rate-limit-time-window-ms" }; /** * Default configuration for the underlying ky HTTP client. */ const BASE_KY_CONFIG = { prefix: "https://api.bigcommerce.com", throwHttpErrors: true, timeout: 12e4, retry: { limit: 3, methods: ["get", "delete"], statusCodes: [ 429, 500, 502, 503, 504 ], afterStatusCodes: [], jitter: true, maxRetryAfter: 12e4 }, headers: { [HEADERS.ACCEPT]: "application/json", [HEADERS.CONTENT_TYPE]: "application/json" } }; //#endregion //#region src/lib/errors.ts /** * Abstract base class for all library errors. Carries a typed `context` object with * structured diagnostic data and a machine-readable `code` string. * * Use `instanceof` checks against specific subclasses rather than this base class. */ var BaseError = class extends Error { context; constructor(message, context, options) { super(message, options); this.context = context; this.name = this.constructor.name; } /** @internal */ toJSON() { return { name: this.name, code: this.code, message: this.message, context: this.context, cause: this.cause }; } }; /** Catch-all for unexpected client-side errors not covered by a more specific subclass. */ var BCClientError = class extends BaseError { code = "BC_CLIENT_ERROR"; constructor(message, context, cause) { super(message, context ?? {}, { cause }); } }; /** Thrown by the {@link BigCommerceClient} constructor when credentials or config are invalid. */ var BCCredentialsError = class extends BaseError { code = "BC_CLIENT_CREDENTIALS_ERROR"; constructor(errors) { super("Failed to initialize BigCommerceClient", { errors }); } }; /** Thrown before a request is sent when the constructed URL exceeds 2048 characters. */ var BCUrlTooLongError = class extends BaseError { code = "BC_URL_TOO_LONG"; constructor(url, max) { super(`Url length (${url.length}) exceeds max allowed length of ${max}`, { url, max, len: url.length }); } }; /** * Thrown during retry when a 429 response is received but the expected * `X-Rate-Limit-*` headers are absent, making it impossible to determine the backoff delay. */ var BCRateLimitNoHeadersError = class extends BaseError { code = "BC_RATE_LIMIT_NO_HEADERS"; constructor(request, attempts) { super("Rate limit reached but the X-Rate-Limit-* headers were not returned. Unable to retry", { url: request.url, method: request.method, attempts }); } }; /** * Thrown during retry when a 429 response specifies a reset window that exceeds * `config.retry.maxRetryAfter`, preventing an unbounded wait. */ var BCRateLimitDelayTooLongError = class extends BaseError { code = "BC_RATE_LIMIT_DELAY_TOO_LONG"; constructor(request, attempts, maxDelay, delay) { super("Rate limit reached, and the rate limit reset window is too high.", { url: request.url, method: request.method, attempts, maxDelay, delay }); } }; /** * Abstract base for all StandardSchema validation errors. Carries the raw `data` that failed * validation and the schema `error` result. Use specific subclasses for `instanceof` checks. */ var BCSchemaValidationError = class extends BaseError { constructor(message, method, path, data, error) { super(message, { method, path, data, error }); } }; /** Thrown when `options.querySchema` validation fails before a request is sent. */ var BCQueryValidationError = class extends BCSchemaValidationError { code = "BC_QUERY_VALIDATION_FAILED"; }; /** Thrown when `options.bodySchema` validation fails before a request is sent. */ var BCRequestBodyValidationError = class extends BCSchemaValidationError { code = "BC_REQUEST_BODY_VALIDATION_FAILED"; }; /** Thrown when `options.responseSchema` validation fails after a response is received. */ var BCResponseValidationError = class extends BCSchemaValidationError { code = "BC_RESPONSE_VALIDATION_FAILED"; }; /** Thrown or yielded when `options.itemSchema` validation fails for an item in a page response. */ var BCPaginatedItemValidationError = class extends BCSchemaValidationError { code = "BC_PAGINATED_ITEM_VALIDATION_FAILED"; }; /** * Thrown when the BigCommerce API returns a non-2xx HTTP response. * `context.status` and `context.responseBody` are the most useful fields for debugging. */ var BCApiError = class extends BaseError { code = "BC_API_ERROR"; constructor(err, requestBody) { const { request, response, data } = err; super("BigCommerce API request failed", { method: request.method, url: request.url, status: response.status, statusMessage: response.statusText, headers: Object.fromEntries(response.headers), requestBody, responseBody: data }); } }; /** Thrown when a request exceeds the configured timeout (default 120 s). */ var BCTimeoutError = class extends BaseError { code = "BC_TIMEOUT_ERROR"; constructor(err) { super("BigCommerce API request timed out", { method: err.request.method, url: err.request.url }); } }; /** * Thrown when the response body cannot be read or parsed as JSON. * `context.rawBody` contains the raw text that failed to parse (empty string if the body was empty). */ var BCResponseParseError = class extends BaseError { code = "BC_RESPONSE_PARSE_ERROR"; constructor(method, path, status, cause, query, rawBody) { super("Failed to parse BigCommerce API response", { status, method, path, query, rawBody }, { cause }); } }; /** * Thrown when a pagination option (`limit`, `page`, or `count`) is not a positive number. * `context.option` names the offending field; `context.value` is the value that was passed. */ var BCPaginatedOptionError = class extends BaseError { code = "BC_PAGINATED_OPTION_ERROR"; constructor(path, value, option) { super("The pagination option must be a positive number", { path, option, value }); } }; /** * Thrown or yielded when a paginated response is missing required v3 envelope fields * (`data`, `meta.pagination`, etc.). Usually means the path is not a v3 collection endpoint. */ var BCPaginatedResponseError = class extends BaseError { code = "BC_PAGINATED_RESPONSE_ERROR"; constructor(path, data, reason) { super("Paginated response structure is invalid", { path, data, reason }); } }; /** Thrown by {@link BigCommerceAuth} constructor when `config.redirectUri` is not a valid URL. */ var BCAuthInvalidRedirectUriError = class extends BaseError { code = "BC_AUTH_INVALID_REDIRECT_URI"; constructor(redirectUri, cause) { super("Invalid redirect URI", { redirectUri }, { cause }); } }; /** Thrown by {@link BigCommerceAuth.requestToken} when a required OAuth callback param is absent. */ var BCAuthMissingParamError = class extends BaseError { code = "BC_AUTH_MISSING_PARAM"; constructor(param) { super(`Missing required auth callback parameter: ${param}`, { param }); } }; /** * Thrown by {@link BigCommerceAuth.requestToken} when the scopes granted by BigCommerce * do not include all scopes listed in `config.scopes`. * `context.missing` lists the scopes that were expected but not granted. */ var BCAuthScopeMismatchError = class extends BaseError { code = "BC_AUTH_SCOPE_MISMATCH"; constructor(granted, expected, missing) { super("Granted scopes do not match expected scopes", { granted, expected, missing }); } }; /** Thrown by {@link BigCommerceAuth.verify} when the JWT signature, audience, issuer, or subject is invalid. */ var BCAuthInvalidJwtError = class extends BaseError { code = "BC_AUTH_INVALID_JWT"; constructor(storeHash, cause) { super("Invalid JWT payload", { storeHash }, { cause }); } }; //#endregion //#region src/lib/logger.ts /** @internal */ const LOG_LEVELS = [ "debug", "info", "warn", "error" ]; /** * Adapts an AWS Lambda Powertools logger to the {@link Logger} interface expected by * {@link BigCommerceClient} and {@link BigCommerceAuth}. * * Powertools loggers use `(message, ...data)` argument order whereas this library uses * `(data, message)`. This adapter swaps the arguments. * * @param logger - An AWS Lambda Powertools (or any {@link PowertoolsLikeLogger}-compatible) logger. * @returns A {@link Logger} wrapper suitable for `config.logger`. */ const fromAwsPowertoolsLogger = (logger) => ({ debug: (data, message) => logger.debug(message ?? "", data), info: (data, message) => logger.info(message ?? "", data), warn: (data, message) => logger.warn(message ?? "", data), error: (data, message) => logger.error(message ?? "", data) }); /** * Console-based {@link Logger} that filters messages below a minimum level. * * Used automatically when `config.logger` is `true`, `undefined`, or a {@link LogLevel} string. * Can also be instantiated directly for custom log level control. * * @example * ```ts * new BigCommerceClient({ ..., logger: new FallbackLogger('debug') }); * ``` */ var FallbackLogger = class { level; /** * @param level - Minimum level to output. Messages below this level are silently dropped. */ constructor(level) { this.level = level; } debug(data, message) { this.log("debug", data, message); } info(data, message) { this.log("info", data, message); } warn(data, message) { this.log("warn", data, message); } error(data, message) { this.log("error", data, message); } log(level, data, message) { if (LOG_LEVELS.indexOf(level) < LOG_LEVELS.indexOf(this.level)) return; const fn = console[level]; message !== void 0 ? fn(message, data) : fn(data); } }; /** * @internal */ const initLogger = (logger) => { if (logger === false) return; if (logger === void 0 || logger === true) return new FallbackLogger("info"); if (typeof logger === "string") if (LOG_LEVELS.includes(logger)) return new FallbackLogger(logger); else { const logger = new FallbackLogger("info"); logger.warn({ level: logger }, "Unknown log level passed, using info"); return logger; } return logger; }; //#endregion //#region src/auth.ts const GRANT_TYPE = "authorization_code"; const TOKEN_ENDPOINT = "https://login.bigcommerce.com/oauth2/token"; const ISSUER = "bc"; /** * Handles authentication with BigCommerce OAuth */ var BigCommerceAuth = class { config; logger; client; /** * Creates a new BigCommerceAuth instance for handling OAuth authentication * @param config - Configuration options for BigCommerce authentication * @param config.clientId - The OAuth client ID from BigCommerce * @param config.secret - The OAuth client secret from BigCommerce * @param config.redirectUri - The redirect URI registered with BigCommerce * @param config.scopes - Optional array of scopes to validate during auth callback * @param config.logger - Optional logger instance for debugging and error tracking * @throws {BCAuthInvalidRedirectUriError} If the redirect URI is invalid */ constructor(config) { this.config = config; try { new URL(this.config.redirectUri); } catch (error) { throw new BCAuthInvalidRedirectUriError(this.config.redirectUri, error); } this.logger = initLogger(config.logger); const { prefix: _, ...authKyConfig } = BASE_KY_CONFIG; this.client = ky.create({ ...authKyConfig, retry: { ...authKyConfig.retry, methods: ["post"] } }); } /** * Exchanges an OAuth authorization code for an access token. * * @param data - The auth callback payload: a raw query string, `URLSearchParams`, or a * pre-parsed object with `code`, `scope`, and `context`. * @returns The token response including `access_token`, `user`, and `context`. * @throws {@link BCAuthMissingParamError} if `code`, `scope`, or `context` are absent. * @throws {@link BCAuthScopeMismatchError} if the granted scopes don't include all `config.scopes`. * @throws {@link BCApiError} on HTTP error responses from the token endpoint. * @throws {@link BCTimeoutError} if the token request times out. * @throws {@link BCClientError} on any other error. */ async requestToken(data) { const query = typeof data === "string" || data instanceof URLSearchParams ? this.parseQueryString(data) : data; this.validateScopes(query.scope); const tokenRequest = { client_id: this.config.clientId, client_secret: this.config.secret, ...query, grant_type: GRANT_TYPE, redirect_uri: this.config.redirectUri }; this.logger?.debug({ clientId: this.config.clientId, context: query.context, scopes: query.scope }, "Requesting OAuth token"); let res; try { res = await this.client(TOKEN_ENDPOINT, { method: "POST", json: tokenRequest }); } catch (error) { if (isHTTPError(error)) { const err = new BCApiError(error, await error.request.text().catch(() => "")); this.logger?.error(err.context, "Failed to request token"); throw err; } if (isTimeoutError(error)) { const err = new BCTimeoutError(error); this.logger?.error(err.context, "Token request timed out"); throw err; } throw new BCClientError("Failed to request token", {}, error); } return res.json(); } /** * Verifies a JWT payload from BigCommerce * @param jwtPayload - The JWT string to verify * @param storeHash - The store hash for the BigCommerce store * @returns Promise resolving to the verified JWT claims * @throws {BCAuthInvalidJwtError} If the JWT is invalid */ async verify(jwtPayload, storeHash) { try { const secret = new TextEncoder().encode(this.config.secret); const { payload } = await jose.jwtVerify(jwtPayload, secret, { audience: this.config.clientId, issuer: ISSUER, subject: `stores/${storeHash}` }); this.logger?.debug({ userId: payload.user?.id, storeHash: payload.sub.split("/")[1] }, "JWT verified successfully"); return payload; } catch (error) { const err = new BCAuthInvalidJwtError(storeHash, error); this.logger?.error(err.context, "JWT verification failed"); throw err; } } /** * Parses and validates a query string from BigCommerce auth callback * @param queryString - The query string to parse * @returns The parsed auth query parameters * @throws {BCAuthMissingParamError} If required parameters are missing */ parseQueryString(queryString) { const params = typeof queryString === "string" ? new URLSearchParams(queryString) : queryString; const code = params.get("code"); const scope = params.get("scope"); const context = params.get("context"); if (!code) throw new BCAuthMissingParamError("code"); if (!scope) throw new BCAuthMissingParamError("scope"); else if (this.config.scopes?.length) this.validateScopes(scope); if (!context) throw new BCAuthMissingParamError("context"); return { code, scope, context }; } /** * Validates that the granted scopes match the expected scopes * @param scopes - Space-separated list of granted scopes * @throws {BCAuthScopeMismatchError} If the scopes don't match the expected scopes */ validateScopes(scopes) { if (!this.config.scopes) return; const granted = scopes.split(" "); const expected = this.config.scopes; const missing = expected.filter((scope) => !granted.includes(scope)); if (missing.length) throw new BCAuthScopeMismatchError(granted, expected, missing); } }; //#endregion //#region src/lib/util.ts const parseIntHeader = (headers, key) => { const value = Number.parseInt(headers.get(key) ?? "", 10); return Number.isNaN(value) ? void 0 : value; }; const extractRateLimitHeaders = (headers) => { const resetIn = parseIntHeader(headers, HEADERS.RATE_LIMIT_RESET); if (resetIn === void 0) return; return { resetIn, requestsLeft: parseIntHeader(headers, HEADERS.RATE_LIMIT_LEFT), quota: parseIntHeader(headers, HEADERS.RATE_LIMIT_QUOTA), window: parseIntHeader(headers, HEADERS.RATE_LIMIT_WINDOW) }; }; const chunkStrLength = (items, options = {}) => { const { maxLength = 2048, chunkLength = 250, offset = 0, separatorSize = 1 } = options; const chunks = []; let currentStrLength = offset; let currentChunk = []; for (const item of items) { const itemLength = encodeURIComponent(item).length; const totalItemLength = itemLength + (currentChunk.length > 0 ? separatorSize : 0); const wouldExceedLength = currentStrLength + totalItemLength > maxLength; const wouldExceedCount = currentChunk.length >= chunkLength; if ((wouldExceedLength || wouldExceedCount) && currentChunk.length > 0) { chunks.push(currentChunk); currentChunk = []; currentStrLength = offset; } if (itemLength + offset > maxLength) throw new Error(`Item too large: ${itemLength} exceeds maxLength ${maxLength}`); currentChunk.push(item); currentStrLength += itemLength + (currentChunk.length > 1 ? separatorSize : 0); } if (currentChunk.length > 0) chunks.push(currentChunk); return chunks; }; var AsyncChannel = class { queue = []; notify = null; done = false; push(item) { this.queue.push(item); this.notify?.(); this.notify = null; } close() { this.done = true; this.notify?.(); this.notify = null; } async *[Symbol.asyncIterator]() { while (!this.done || this.queue.length > 0) { if (this.queue.length === 0) await new Promise((r) => { if (this.queue.length > 0) return r(); this.notify = r; }); while (this.queue.length > 0) { const item = this.queue.shift(); if (item !== void 0) yield item; } } } }; //#endregion //#region src/lib/hooks.ts const validateUrlLength = ({ request }) => { if (request.url.length > 2048) throw new BCUrlTooLongError(request.url, MAX_URL_LENGTH); }; const bcRateLimitRetry = (logger) => async ({ request, options, error, retryCount }) => { if (isHTTPError(error) && error.response.status === 429) { const retryMeta = extractRateLimitHeaders(error.response.headers); if (!retryMeta) throw new BCRateLimitNoHeadersError(request, retryCount); if (options.retry.maxRetryAfter && retryMeta.resetIn > options.retry.maxRetryAfter) throw new BCRateLimitDelayTooLongError(request, retryCount, options.retry.maxRetryAfter, retryMeta.resetIn); const delay = typeof options.retry.jitter === "function" ? options.retry.jitter(retryMeta.resetIn) : rateLimitJitter(retryMeta.resetIn); logger?.warn({ attempt: retryCount, url: request.url, method: request.method, retryMeta }, `Rate limit reached, retrying in ${delay} (with jitter)`); await new Promise((resolve) => setTimeout(resolve, delay)); } else logger?.warn({ url: request.url, method: request.method, attempt: retryCount }, "Retrying request"); }; //#endregion //#region src/lib/request.ts /** * Converts a Query object to URLSearchParams. * Array values are joined with commas (e.g., `id:in=1,2,3`). */ const toUrlSearchParams = (query) => { if (!query) return; const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) if (Array.isArray(value)) params.append(key, value.map(String).join(",")); else params.append(key, String(value)); return params; }; /** * Helpers for building typed request descriptors to pass to * {@link BigCommerceClient.batchSafe} or {@link BigCommerceClient.batchStream}. * * @example * ```ts * const results = await client.batchSafe([ * req.get('catalog/products/1'), * req.post('catalog/products', { body: { name: 'Widget' } }), * ]); * ``` */ const req = { /** * Builds a GET request descriptor. * @param path - API path relative to the store's versioned base URL. * @param options - Optional query params, schemas, and ky options. */ get: (path, options) => ({ method: "GET", path, ...options }), /** * Builds a POST request descriptor. * @param path - API path relative to the store's versioned base URL. * @param options - Optional body, query params, schemas, and ky options. */ post: (path, options) => ({ method: "POST", path, ...options }), /** * Builds a PUT request descriptor. * @param path - API path relative to the store's versioned base URL. * @param options - Optional body, query params, schemas, and ky options. */ put: (path, options) => ({ method: "PUT", path, ...options }), /** * Builds a DELETE request descriptor. * @param path - API path relative to the store's versioned base URL. * @param options - Optional query params and ky options. */ delete: (path, options) => ({ method: "DELETE", path, ...options }) }; //#endregion //#region src/lib/result.ts /** * Creates a successful {@link Result}. Check `result.ok` or `result.err` before accessing `data`. * @param data - The success value. */ const Ok = (data) => ({ ok: true, data, err: void 0 }); /** * Creates a failed {@link Result}. Check `result.ok` or `result.err` before accessing `err`. * @param err - The error value. */ const Err = (err) => ({ ok: false, data: void 0, err }); //#endregion //#region src/client.ts var BigCommerceClient = class { config; logger; client; storeHash; /** * Creates a new BigCommerceClient. * * @param config - Client configuration. Ky options (e.g. `prefix`, `timeout`, `retry`, * `hooks`) are forwarded to the underlying ky instance. * @param config.storeHash - BigCommerce store hash. Must be a non-empty string. * @param config.accessToken - BigCommerce API access token. Must be a non-empty string. * @param config.logger - A {@link Logger} instance, a log level string * (`'debug' | 'info' | 'warn' | 'error'`), `true` to enable console logging at `'info'` * level, or `false` to disable logging entirely. Omitting also defaults to `'info'` level. * @param config.concurrency - Default max concurrent requests for batch/stream operations. * Must be between 1 and 1000. Pass `false` to disable concurrency (sequential execution). * Defaults to 10. * @param config.rateLimitBackoff - Concurrency cap applied when a 429 response is received. * Defaults to 1. * @param config.backoff - Divisor (or `(concurrency, status) => number` function) applied to * concurrency on non-429 error responses. Defaults to 2. * @param config.backoffRecover - Amount (or `(concurrency) => number` function) added to * concurrency per successful response while below the configured max. Defaults to 1. * * @throws {@link BCCredentialsError} if `storeHash` or `accessToken` are missing. * @throws {@link BCClientError} if `prefix` is not a valid URL or `concurrency` is out of range. */ constructor(config) { this.config = config; this.validateConfig(); const { storeHash, accessToken, logger, concurrency: _, ...kyOptions } = config; this.logger = initLogger(logger); this.storeHash = storeHash; this.client = ky.create({ ...BASE_KY_CONFIG, ...kyOptions, headers: { ...BASE_KY_CONFIG.headers, ...kyOptions.headers ?? {}, [HEADERS.AUTH_TOKEN]: accessToken }, hooks: { beforeRequest: [...kyOptions.hooks?.beforeRequest ?? [], validateUrlLength], beforeRetry: [ ({ error }) => { if (error instanceof BaseError) throw error; }, bcRateLimitRetry(this.logger), ...kyOptions.hooks?.beforeRetry ?? [] ], beforeError: [...kyOptions.hooks?.beforeError ?? []], afterResponse: [...kyOptions.hooks?.afterResponse ?? []] } }); } /** * Sends a GET request to the given path. * * @param path - API path relative to the store's versioned base URL (e.g. `catalog/products`). * @param options - Ky options are forwarded to the underlying request. * @param options.version - API version segment inserted into the URL. Defaults to `'v3'`. * @param options.query - Query parameters to append to the URL. * @param options.querySchema - StandardSchemaV1 schema to validate `query` before the request * is sent. Requires `query` to be provided. * @param options.responseSchema - StandardSchemaV1 schema to validate the parsed response body. * * @returns Parsed and optionally validated response body. * * @throws {@link BCApiError} on HTTP error responses. * @throws {@link BCTimeoutError} if the request times out. * @throws {@link BCResponseParseError} if the response body cannot be parsed. * @throws {@link BCUrlTooLongError} if the constructed URL exceeds 2048 characters. * @throws {@link BCRateLimitNoHeadersError} if a 429 is received without rate-limit headers. * @throws {@link BCRateLimitDelayTooLongError} if the rate-limit reset window exceeds * `config.retry.maxRetryAfter`. * @throws {@link BCQueryValidationError} if `querySchema` validation fails. * @throws {@link BCResponseValidationError} if `responseSchema` validation fails. * @throws {@link BCClientError} on any other ky or unknown error. */ async get(path, options) { return this.request(path, { ...options, method: "GET" }); } /** * Sends a POST request to the given path. * * @param path - API path relative to the store's versioned base URL. * @param options - Ky options are forwarded to the underlying request. * @param options.version - API version segment inserted into the URL. Defaults to `'v3'`. * @param options.body - Request body, serialized as JSON. * @param options.bodySchema - StandardSchemaV1 schema to validate `body` before the request * is sent. Requires `body` to be provided. * @param options.query - Query parameters to append to the URL. * @param options.querySchema - StandardSchemaV1 schema to validate `query` before the request * is sent. Requires `query` to be provided. * @param options.responseSchema - StandardSchemaV1 schema to validate the parsed response body. * * @returns Parsed and optionally validated response body. * * @throws {@link BCApiError} on HTTP error responses. * @throws {@link BCTimeoutError} if the request times out. * @throws {@link BCResponseParseError} if the response body cannot be parsed. * @throws {@link BCUrlTooLongError} if the constructed URL exceeds 2048 characters. * @throws {@link BCRateLimitNoHeadersError} if a 429 is received without rate-limit headers. * @throws {@link BCRateLimitDelayTooLongError} if the rate-limit reset window exceeds * `config.retry.maxRetryAfter`. * @throws {@link BCQueryValidationError} if `querySchema` validation fails. * @throws {@link BCRequestBodyValidationError} if `bodySchema` validation fails. * @throws {@link BCResponseValidationError} if `responseSchema` validation fails. * @throws {@link BCClientError} on any other ky or unknown error. */ async post(path, options) { return this.request(path, { ...options, method: "POST" }); } /** * Sends a PUT request to the given path. * * @param path - API path relative to the store's versioned base URL. * @param options - Ky options are forwarded to the underlying request. * @param options.version - API version segment inserted into the URL. Defaults to `'v3'`. * @param options.body - Request body, serialized as JSON. * @param options.bodySchema - StandardSchemaV1 schema to validate `body` before the request * is sent. Requires `body` to be provided. * @param options.query - Query parameters to append to the URL. * @param options.querySchema - StandardSchemaV1 schema to validate `query` before the request * is sent. Requires `query` to be provided. * @param options.responseSchema - StandardSchemaV1 schema to validate the parsed response body. * * @returns Parsed and optionally validated response body. * * @throws {@link BCApiError} on HTTP error responses. * @throws {@link BCTimeoutError} if the request times out. * @throws {@link BCResponseParseError} if the response body cannot be parsed. * @throws {@link BCUrlTooLongError} if the constructed URL exceeds 2048 characters. * @throws {@link BCRateLimitNoHeadersError} if a 429 is received without rate-limit headers. * @throws {@link BCRateLimitDelayTooLongError} if the rate-limit reset window exceeds * `config.retry.maxRetryAfter`. * @throws {@link BCQueryValidationError} if `querySchema` validation fails. * @throws {@link BCRequestBodyValidationError} if `bodySchema` validation fails. * @throws {@link BCResponseValidationError} if `responseSchema` validation fails. * @throws {@link BCClientError} on any other ky or unknown error. */ async put(path, options) { return this.request(path, { ...options, method: "PUT" }); } /** * Sends a DELETE request to the given path. * * Silently suppresses 404 responses (resource already gone) and empty response bodies. * * @param path - API path relative to the store's versioned base URL. * @param options - Ky options are forwarded to the underlying request. * @param options.version - API version segment inserted into the URL. Defaults to `'v3'`. * @param options.query - Query parameters to append to the URL. * @param options.querySchema - StandardSchemaV1 schema to validate `query` before the request * is sent. Requires `query` to be provided. * * @throws {@link BCApiError} on non-404 HTTP error responses. * @throws {@link BCTimeoutError} if the request times out. * @throws {@link BCResponseParseError} if the response body is non-empty and cannot be parsed. * @throws {@link BCUrlTooLongError} if the constructed URL exceeds 2048 characters. * @throws {@link BCRateLimitNoHeadersError} if a 429 is received without rate-limit headers. * @throws {@link BCRateLimitDelayTooLongError} if the rate-limit reset window exceeds * `config.retry.maxRetryAfter`. * @throws {@link BCQueryValidationError} if `querySchema` validation fails. * @throws {@link BCClientError} on any other ky or unknown error. */ async delete(path, options) { try { await this.request(path, { ...options, method: "DELETE" }); } catch (err) { if (err instanceof BCResponseParseError && err.context.rawBody === "") return; if (err instanceof BCApiError && err.context.status === 404) { this.logger?.warn({ err }, "Attempted to delete the resource that is already gone"); return; } throw err; } } /** * Fetches items from a v3 paginated endpoint by splitting `values` across multiple requests * using the given `key` query param, chunking to stay within URL length limits. * * Collects all results into an array. Use {@link queryStream} to process items lazily. * * **Sorting and concurrency:** when `concurrency > 1`, chunks complete out of order so * sorted output is not preserved across the full result set. Pass `concurrency: false` * if sort order matters. * * @param path - API path relative to the store's versioned base URL. * @param options - Query options including `key`, `values`, pagination params, and concurrency * controls. * @param options.key - Query parameter name used for value filtering (e.g. `'id:in'`). * @param options.values - Values to filter by. Automatically chunked across multiple requests * to keep each URL under 2048 characters. * @param options.query - Additional query parameters. `query.limit` controls page size * (default 250, must be > 0). If `options.key` is present in `query` it will be ignored. * @param options.querySchema - StandardSchemaV1 schema to validate `query`. Requires `query` * to be provided. * @param options.itemSchema - StandardSchemaV1 schema to validate each returned item. * @param options.concurrency - Max concurrent chunk requests. Must be 1–1000. `false` for * sequential. Defaults to `config.concurrency`, or 10 if not set on the client. * @param options.rateLimitBackoff - Concurrency cap on 429 responses. Defaults to * `config.rateLimitBackoff`, or 1 if not set on the client. * @param options.backoff - Divisor (or function) applied to concurrency on error responses. * Defaults to `config.backoff`, or 2 if not set on the client. * @param options.backoffRecover - Amount (or function) added to concurrency per successful * response. Defaults to `config.backoffRecover`, or 1 if not set on the client. * * @returns All matching items across all chunked requests. * @throws {@link BCPaginatedOptionError} if `query.limit` is not a positive number. * @throws {@link BCQueryValidationError} if `querySchema` validation fails. * @throws {@link BCApiError} on HTTP error responses. * @throws {@link BCTimeoutError} if a request times out. * @throws {@link BCResponseParseError} if a response body cannot be parsed. * @throws {@link BCUrlTooLongError} if a constructed URL exceeds 2048 characters. * @throws {@link BCRateLimitNoHeadersError} if a 429 is received without rate-limit headers. * @throws {@link BCRateLimitDelayTooLongError} if the rate-limit reset window exceeds * `config.retry.maxRetryAfter`. * @throws {@link BCPaginatedResponseError} if a page response has an unexpected shape. * @throws {@link BCPaginatedItemValidationError} if `itemSchema` validation fails for an item. * @throws {@link BCClientError} on any other ky or unknown error. */ async query(path, options) { const results = []; for await (const { data, err } of this.queryStream(path, options)) if (err) throw err; else results.push(data); return results; } /** * Streaming variant of {@link query}. Yields each item individually as results arrive, * splitting `values` into URL-length-safe chunks across concurrent requests. * * Each yielded value is a {@link Result} — check `err` before using `data`. * * **Sorting and concurrency:** when `concurrency > 1`, chunks complete out of order so * sorted output is not preserved across the full result set. Pass `concurrency: false` * if sort order matters. * * @param path - API path relative to the store's versioned base URL. * @param options - Query options including `key`, `values`, pagination params, and concurrency * controls. * @param options.key - Query parameter name used for value filtering (e.g. `'id:in'`). * @param options.values - Values to filter by. Automatically chunked across multiple requests * to keep each URL under 2048 characters. * @param options.query - Additional query parameters. `query.limit` controls page size * (default 250, must be > 0). If `options.key` is present in `query` it will be ignored. * @param options.querySchema - StandardSchemaV1 schema to validate `query`. Requires `query` * to be provided. * @param options.itemSchema - StandardSchemaV1 schema to validate each returned item. * @param options.concurrency - Max concurrent chunk requests. Must be 1–1000. `false` for * sequential. Defaults to `config.concurrency`, or 10 if not set on the client. * @param options.rateLimitBackoff - Concurrency cap on 429 responses. Defaults to * `config.rateLimitBackoff`, or 1 if not set on the client. * @param options.backoff - Divisor (or function) applied to concurrency on error responses. * Defaults to `config.backoff`, or 2 if not set on the client. * @param options.backoffRecover - Amount (or function) added to concurrency per successful * response. Defaults to `config.backoffRecover`, or 1 if not set on the client. * @throws {@link BCPaginatedOptionError} if `query.limit` is not a positive number. * @throws {@link BCQueryValidationError} if `querySchema` validation fails. */ async *queryStream(path, options) { const { key, values, query, querySchema, itemSchema, concurrency, rateLimitBackoff, backoff, backoffRecover, ...requestOptions } = options; const limit = this.validatePaginationOption(path, "limit", query?.limit ?? 250); const newQuery = { ...await this.validate(query, querySchema, BCQueryValidationError, "GET", path, "Invalid query parameters"), limit }; if (key in newQuery) { this.logger?.warn({ key }, "The provided key is already in the query params, this param will be ignored"); delete newQuery[key]; } const fullUrl = `${this.config.prefix ?? requestOptions.prefix ?? BASE_KY_CONFIG.prefix}/${this.makePath("v3", path)}?${toUrlSearchParams({ ...newQuery, page: 1 })}`; const keyOverhead = encodeURIComponent(key).length + 2; const requests = chunkStrLength(values.map(String), { chunkLength: limit, maxLength: MAX_URL_LENGTH, offset: fullUrl.length + keyOverhead, separatorSize: 3 }).map((chunk) => req.get(path, { ...requestOptions, query: { ...newQuery, page: 1, [key]: chunk } })); for await (const { err, data } of this.batchStream(requests, { concurrency, rateLimitBackoff, backoff, backoffRecover })) { if (err) { yield Err(err); continue; } try { const { data: items } = this.assertPaginatedResponse(path, data); for (const item of items) yield this.validatePaginatedItem(path, item, itemSchema); } catch (err) { if (err instanceof BaseError) yield Err(err); else yield Err(new BCClientError("Unknown error occurred processing page", {}, { cause: err })); } } } /** * Fetches all pages from a v3 paginated endpoint and collects items into an array. * * Use {@link stream} to process items lazily without buffering the full result set. * * **Sorting and concurrency:** the first page is fetched sequentially; remaining pages are * fetched concurrently and may complete out of order. When `concurrency > 1`, sort order * is not preserved across pages. Pass `concurrency: false` if sort order matters. * * @param path - API path relative to the store's versioned base URL. * @param options - Ky options are forwarded to page requests. * @param options.query - Query parameters. `query.limit` controls page size (default 250, * must be > 0). `query.page` sets the starting page (default 1, must be > 0). * @param options.querySchema - StandardSchemaV1 schema to validate `query`. Requires `query` * to be provided. * @param options.itemSchema - StandardSchemaV1 schema to validate each returned item. * @param options.concurrency - Max concurrent page requests for pages after the first. * Must be 1–1000. `false` for sequential. Defaults to `config.concurrency`, or 10 if not * set on the client. * @param options.rateLimitBackoff - Concurrency cap on 429 responses. Defaults to * `config.rateLimitBackoff`, or 1 if not set on the client. * @param options.backoff - Divisor (or function) applied to concurrency on error responses. * Defaults to `config.backoff`, or 2 if not set on the client. * @param options.backoffRecover - Amount (or function) added to concurrency per successful * response. Defaults to `config.backoffRecover`, or 1 if not set on the client. * @returns All items across all pages. * * @throws {@link BCPaginatedOptionError} if `query.limit` or `query.page` is not a positive number. * @throws {@link BCQueryValidationError} if `querySchema` validation fails. * @throws {@link BCApiError} on HTTP error responses. * @throws {@link BCTimeoutError} if a request times out. * @throws {@link BCResponseParseError} if a response body cannot be parsed. * @throws {@link BCUrlTooLongError} if a constructed URL exceeds 2048 characters. * @throws {@link BCRateLimitNoHeadersError} if a 429 is received without rate-limit headers. * @throws {@link BCRateLimitDelayTooLongError} if the rate-limit reset window exceeds * `config.retry.maxRetryAfter`. * @throws {@link BCPaginatedResponseError} if a page response has an unexpected shape. * @throws {@link BCPaginatedItemValidationError} if `itemSchema` validation fails for an item. * @throws {@link BCClientError} on any other ky or unknown error. */ async collect(path, options) { const items = []; for await (const { data, err } of this.stream(path, options)) if (err) throw err; else items.push(data); return items; } /** * Fetches all pages from a v2 flat-array endpoint and collects items into an array. * * Pagination is discovered dynamically — pages are fetched in batches until an empty page, * a 404, or a 204 response is received. No prior knowledge of total count is required. * * Use {@link streamBlind} to process items lazily without buffering the full result set. * * **Sorting and concurrency:** pages within each batch are fetched concurrently and may * complete out of order. When `concurrency > 1`, sort order is not preserved across pages. * Pass `concurrency: false` if sort order matters. * * @param path - API path relative to the store's versioned base URL (always requests v2). * @param options - Ky options are forwarded to page requests. * @param options.query - Query parameters. `query.limit` controls page size (default 250, * must be > 0). `query.page` sets the starting page (default 1, must be > 0). * @param options.querySchema - StandardSchemaV1 schema to validate `query`. Requires `query` * to be provided. * @param options.itemSchema - StandardSchemaV1 schema to validate each returned item. * @param options.maxPages - Maximum number of pages to fetch before stopping (default 500, * must be > 0). A warning is logged if this limit is reached. * @param options.concurrency - Max concurrent page requests per batch. Must be 1–1000. * `false` for sequential. Defaults to `config.concurrency`, or 10 if not set on the client. * @param options.rateLimitBackoff - Concurrency cap on 429 responses. Defaults to * `config.rateLimitBackoff`, or 1 if not set on the client. * @param options.backoff - Divisor (or function) applied to concurrency on error responses. * Defaults to `config.backoff`, or 2 if not set on the client. * @param options.backoffRecover - Amount (or function) added to concurrency per successful * response. Defaults to `config.backoffRecover`, or 1 if not set on the client. * @returns All items across all pages. * * @throws {@link BCPaginatedOptionError} if `query.limit`, `query.page`, or `maxPages` is not a positive number. * @throws {@link BCQueryValidationError} if `querySchema` validation fails. * @throws {@link BCPaginatedItemValidationError} if `itemSchema` validation fails for an item. * @throws {@link BCClientError} if a page returns a non-array response, or on any other error. * @throws {@link BCApiError} on non-terminating HTTP error responses. * @throws {@link BCTimeoutError} if a request times out. * @throws {@link BCResponseParseError} if a response body cannot be parsed. */ async collectBlind(path, options) { const results = []; for await (const { err, data } of this.streamBlind(path, options)) if (err) throw err; else results.push(data); return results; } /** * Lazily streams items from a v2 flat-array endpoint, page by page. * * Pagination is discovered dynamically — pages are fetched in concurrent batches until an * empty page, a 404, or a 204 response is received. No prior knowledge of total count is * required. Each item is yielded as a {@link PageResult}: `Ok(item)` on success or * `Err(error)` for item-level validation failures and non-terminating page errors. * Use `page` to correlate the item back to its source page. * * Use {@link collectBlind} to buffer all results into an array (throws on any error). * * **Sorting and concurrency:** pages within each batch are fetched concurrently and may * complete out of order. When `concurrency > 1`, sort order is not preserved across pages. * Pass `concurrency: false` if sort order matters. * * @param path - API path relative to the store's versioned base URL (always requests v2). * @param options - Ky options are forwarded to page requests. * @param options.query - Query parameters. `query.limit` controls page size (default 250, * must be > 0). `query.page` sets the starting page (default 1, must be > 0). * @param options.querySchema - StandardSchemaV1 schema to validate `query`. Requires `query` * to be provided. * @param options.itemSchema - StandardSchemaV1 schema to validate each returned item. * @param options.maxPages - Maximum number of pages to fetch before stopping (default 500, * must be > 0). A warning is logged if this limit is reached. * @param options.concurrency - Max concurrent page requests per batch. Must be 1–1000. * `false` for sequential. Defaults to `config.concurrency`, or 10 if not set on the client. * @param options.rateLimitBackoff - Concurrency cap on 429 responses. Defaults to * `config.rateLimitBackoff`, or 1 if not set on the client. * @param options.backoff - Divisor (or function) applied to concurrency on error responses. * Defaults to `config.backoff`, or 2 if not set on the client. * @param options.backoffRecover - Amount (or function) added to concurrency per successful * response. Defaults to `config.backoffRecover`, or 1 if not set on the client. * * @throws {@link BCPaginatedOptionError} if `query.limit`, `query.page`, or `maxPages` is not a positive number. * @throws {@link BCQueryValidationError} if `querySchema` validation fails. * * @yields `Ok(item)` for each successfully fetched and validated item. * @yields `Err(BCPaginatedItemValidationError)` when `itemSchema` rejects an item. * @yields `Err(BCClientError)` when a page returns a non-array response. * @yields `Err(error)` for non-terminating page errors (e.g. non-404/204 HTTP errors). */ async *streamBlind(path, options) { const { query: rawQuery, querySchema, itemSchema, maxPages: rawMaxPages, concurrency: rawConcurrency, rateLimitBackoff, backoff, backoffRecover, pLimit: rawPLimit, ...requestOptions } = options ?? {}; const concurrency = this.validateConcurrency(rawConcurrency ?? this.config.concurrency ?? 10); const limiter = rawPLimit ?? (concurrency ? pLimit({ concurrency, rejectOnClear: true }) : void 0); const concurrencyOptions = { concurrency: rawConcurrency, rateLimitBackoff, backoff, backoffRecover, pLimit: limiter }; const query = await this.validate(rawQuery, querySchema, BCQueryValidationError, "GET", path); const page = this.validatePaginationOption(path, "page", query?.page ?? 1); const limit = this.validatePaginationOption(path, "limit", query?.limit ?? 250); const maxPages = this.validatePaginationOption(path, "maxPages", rawMaxPages ?? 500); let done = false; let currentPage = page; do { if (currentPage > maxPages) { this.logger?.warn({ currentPage }, "Blind pagination reached maxPages before the end of the data"); break; } const batchSize = (limiter?.concurrency ?? concurrency) || 1; const batchStartPage = currentPage; const pageRequests = Array.from({ length: batchSize }, (_, i) => currentPage + i).map((page) => req.get(path, { ...requestOptions, version: "v2", query: { ...query, page, limit } })); currentPage += batchSize; const pages = await this.batchSafe(pageRequests, concurrencyOptions); for (const { err, data, index } of pages) { const itemPage = batchStartPage + index; if (err) { done = err instanceof BCApiError && err.context.status === 404 || err instanceof BCResponseParseError && err.context.rawBody === "" && err.context.status === 204; if (!done) yield { ...Err(err), page: itemPage }; } else if (Array.isArray(data)) { if (data.length === 0) { done = true; break; } for (const item of data) yield { ...await this.validatePaginatedItem(path, item, itemSchema), page: itemPage }; } else yield { ...Err(new BCClientError("Received non array response from blind pagination page endpoint", { data, path })), page: itemPage }; } } while (!done); } /** * Executes multiple requests concurrently and returns all results as {@link BatchResult} * values, never throwing. Errors from individual requests are captured as `Err` results. * * Results arrive in completion order, not input order. Use the `index` field on each * {@link BatchResult} to correlate a result back to its position in the `requests` array. * * Use {@link batchStream} to process results as they arrive rather than waiting for all. * * @param requests - Array of request descriptors built with the {@link req} helpers. * @param options.concurrency - Max concurrent requests. Must be 1–1000. `false` for * sequential. Defaults to `config.concurrency`, or 10 if not set on the client. * @param options.rateLimitBackoff - Concurrency cap on 429 responses. Defaults to * `config.rateLimitBackoff`, or 1 if not set on the client. * @param options.backoff - Divisor (or function) applied to concurrency on error responses. * Defaults to `config.backoff`, or 2 if not set on the client. * @param options.backoffRecover - Amount (or function) added to concurrency per successful * response. Defaults to `config.backoffRecover`, or 1 if not set on the client. * * @returns {@link BatchResult} array in the order requests completed (not input order). */ async batchSafe(requests, options) { const results = []; for await (const res of this.batchStream(requests, options)) results.push(res); return results; } /** * Streams all items from a v3 paginated endpoint, fetching the first page sequentially * and remaining pages concurrently via {@link batchStream}. * * Each yielded value is a {@link PageResult} — check `err` before using `data`, and use * `page` to correlate the item back to its source page. Use * {@link collect} to gather all items into an array. * * **Sorting and concurrency:** the first page is fetched sequentially; remaining pages are * fetched concurrently and may complete out of order. When `concurrency > 1`, sort order * is not prese