bc-api-client 0.2.2 → 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,1424 @@
1
+ import * as jose from "jose";
2
+ import ky, { isHTTPError, isKyError, isTimeoutError } from "ky";
3
+ import pLimit from "p-limit";
4
+ //#region src/lib/common.ts
5
+ /** Maximum allowed concurrency value. */
6
+ const MAX_CONCURRENCY = 1e3;
7
+ /** Maximum allowed URL length before chunking is required. */
8
+ const MAX_URL_LENGTH = 2048;
9
+ /** Regex to strip leading slashes from API paths. */
10
+ const LEADING_SLASHES = /^\/+/;
11
+ /**
12
+ * Random positive jitter within 0-500 ms in increments of 100
13
+ * @param {number} delay
14
+ */
15
+ const rateLimitJitter = (delay) => delay + Math.floor(Math.random() * 6) * 100;
16
+ /**
17
+ * HTTP header names used by the BigCommerce API.
18
+ */
19
+ const HEADERS = {
20
+ AUTH_TOKEN: "X-Auth-Token",
21
+ ACCEPT: "Accept",
22
+ CONTENT_TYPE: "Content-Type",
23
+ RATE_LIMIT_LEFT: "x-rate-limit-requests-left",
24
+ RATE_LIMIT_RESET: "x-rate-limit-time-reset-ms",
25
+ RATE_LIMIT_QUOTA: "x-rate-limit-requests-quota",
26
+ RATE_LIMIT_WINDOW: "x-rate-limit-time-window-ms"
27
+ };
28
+ /**
29
+ * Default configuration for the underlying ky HTTP client.
30
+ */
31
+ const BASE_KY_CONFIG = {
32
+ prefixUrl: "https://api.bigcommerce.com",
33
+ throwHttpErrors: true,
34
+ timeout: 12e4,
35
+ retry: {
36
+ limit: 3,
37
+ methods: ["GET", "DELETE"],
38
+ statusCodes: [
39
+ 429,
40
+ 500,
41
+ 502,
42
+ 503,
43
+ 504
44
+ ],
45
+ afterStatusCodes: [],
46
+ jitter: true,
47
+ maxRetryAfter: 12e4
48
+ },
49
+ headers: {
50
+ [HEADERS.ACCEPT]: "application/json",
51
+ [HEADERS.CONTENT_TYPE]: "application/json"
52
+ }
53
+ };
54
+ //#endregion
55
+ //#region src/lib/errors.ts
56
+ /**
57
+ * Abstract base class for all library errors. Carries a typed `context` object with
58
+ * structured diagnostic data and a machine-readable `code` string.
59
+ *
60
+ * Use `instanceof` checks against specific subclasses rather than this base class.
61
+ */
62
+ var BaseError = class extends Error {
63
+ constructor(message, context, options) {
64
+ super(message, options);
65
+ this.context = context;
66
+ this.name = this.constructor.name;
67
+ }
68
+ /** @internal */
69
+ toJSON() {
70
+ return {
71
+ name: this.name,
72
+ code: this.code,
73
+ message: this.message,
74
+ context: this.context,
75
+ cause: this.cause
76
+ };
77
+ }
78
+ };
79
+ /** Catch-all for unexpected client-side errors not covered by a more specific subclass. */
80
+ var BCClientError = class extends BaseError {
81
+ code = "BC_CLIENT_ERROR";
82
+ constructor(message, context, cause) {
83
+ super(message, context ?? {}, { cause });
84
+ }
85
+ };
86
+ /** Thrown by the {@link BigCommerceClient} constructor when credentials or config are invalid. */
87
+ var BCCredentialsError = class extends BaseError {
88
+ code = "BC_CLIENT_CREDENTIALS_ERROR";
89
+ constructor(errors) {
90
+ super("Failed to initialize BigCommerceClient", { errors });
91
+ }
92
+ };
93
+ /** Thrown before a request is sent when the constructed URL exceeds 2048 characters. */
94
+ var BCUrlTooLongError = class extends BaseError {
95
+ code = "BC_URL_TOO_LONG";
96
+ constructor(url, max) {
97
+ super(`Url length (${url.length}) exceeds max allowed length of ${max}`, {
98
+ url,
99
+ max,
100
+ len: url.length
101
+ });
102
+ }
103
+ };
104
+ /**
105
+ * Thrown during retry when a 429 response is received but the expected
106
+ * `X-Rate-Limit-*` headers are absent, making it impossible to determine the backoff delay.
107
+ */
108
+ var BCRateLimitNoHeadersError = class extends BaseError {
109
+ code = "BC_RATE_LIMIT_NO_HEADERS";
110
+ constructor(request, attempts) {
111
+ super("Rate limit reached but the X-Rate-Limit-* headers were not returned. Unable to retry", {
112
+ url: request.url,
113
+ method: request.method,
114
+ attempts
115
+ });
116
+ }
117
+ };
118
+ /**
119
+ * Thrown during retry when a 429 response specifies a reset window that exceeds
120
+ * `config.retry.maxRetryAfter`, preventing an unbounded wait.
121
+ */
122
+ var BCRateLimitDelayTooLongError = class extends BaseError {
123
+ code = "BC_RATE_LIMIT_DELAY_TOO_LONG";
124
+ constructor(request, attempts, maxDelay, delay) {
125
+ super("Rate limit reached, and the rate limit reset window is too high.", {
126
+ url: request.url,
127
+ method: request.method,
128
+ attempts,
129
+ maxDelay,
130
+ delay
131
+ });
132
+ }
133
+ };
134
+ /**
135
+ * Abstract base for all StandardSchema validation errors. Carries the raw `data` that failed
136
+ * validation and the schema `error` result. Use specific subclasses for `instanceof` checks.
137
+ */
138
+ var BCSchemaValidationError = class extends BaseError {
139
+ constructor(message, method, path, data, error) {
140
+ super(message, {
141
+ method,
142
+ path,
143
+ data,
144
+ error
145
+ });
146
+ }
147
+ };
148
+ /** Thrown when `options.querySchema` validation fails before a request is sent. */
149
+ var BCQueryValidationError = class extends BCSchemaValidationError {
150
+ code = "BC_QUERY_VALIDATION_FAILED";
151
+ };
152
+ /** Thrown when `options.bodySchema` validation fails before a request is sent. */
153
+ var BCRequestBodyValidationError = class extends BCSchemaValidationError {
154
+ code = "BC_REQUEST_BODY_VALIDATION_FAILED";
155
+ };
156
+ /** Thrown when `options.responseSchema` validation fails after a response is received. */
157
+ var BCResponseValidationError = class extends BCSchemaValidationError {
158
+ code = "BC_RESPONSE_VALIDATION_FAILED";
159
+ };
160
+ /** Thrown or yielded when `options.itemSchema` validation fails for an item in a page response. */
161
+ var BCPaginatedItemValidationError = class extends BCSchemaValidationError {
162
+ code = "BC_PAGINATED_ITEM_VALIDATION_FAILED";
163
+ };
164
+ /**
165
+ * Thrown when the BigCommerce API returns a non-2xx HTTP response.
166
+ * `context.status` and `context.responseBody` are the most useful fields for debugging.
167
+ */
168
+ var BCApiError = class extends BaseError {
169
+ code = "BC_API_ERROR";
170
+ constructor(err, requestBody, responseBody) {
171
+ const { request, response } = err;
172
+ super("BigCommerce API request failed", {
173
+ method: request.method,
174
+ url: request.url,
175
+ status: response.status,
176
+ statusMessage: response.statusText,
177
+ headers: Object.fromEntries(response.headers),
178
+ requestBody,
179
+ responseBody
180
+ });
181
+ }
182
+ };
183
+ /** Thrown when a request exceeds the configured timeout (default 120 s). */
184
+ var BCTimeoutError = class extends BaseError {
185
+ code = "BC_TIMEOUT_ERROR";
186
+ constructor(err) {
187
+ super("BigCommerce API request timed out", {
188
+ method: err.request.method,
189
+ url: err.request.url
190
+ });
191
+ }
192
+ };
193
+ /**
194
+ * Thrown when the response body cannot be read or parsed as JSON.
195
+ * `context.rawBody` contains the raw text that failed to parse (empty string if the body was empty).
196
+ */
197
+ var BCResponseParseError = class extends BaseError {
198
+ code = "BC_RESPONSE_PARSE_ERROR";
199
+ constructor(method, path, status, cause, query, rawBody) {
200
+ super("Failed to parse BigCommerce API response", {
201
+ status,
202
+ method,
203
+ path,
204
+ query,
205
+ rawBody
206
+ }, { cause });
207
+ }
208
+ };
209
+ /**
210
+ * Thrown when a pagination option (`limit`, `page`, or `count`) is not a positive number.
211
+ * `context.option` names the offending field; `context.value` is the value that was passed.
212
+ */
213
+ var BCPaginatedOptionError = class extends BaseError {
214
+ code = "BC_PAGINATED_OPTION_ERROR";
215
+ constructor(path, value, option) {
216
+ super("The pagination option must be a positive number", {
217
+ path,
218
+ option,
219
+ value
220
+ });
221
+ }
222
+ };
223
+ /**
224
+ * Thrown or yielded when a paginated response is missing required v3 envelope fields
225
+ * (`data`, `meta.pagination`, etc.). Usually means the path is not a v3 collection endpoint.
226
+ */
227
+ var BCPaginatedResponseError = class extends BaseError {
228
+ code = "BC_PAGINATED_RESPONSE_ERROR";
229
+ constructor(path, data, reason) {
230
+ super("Paginated response structure is invalid", {
231
+ path,
232
+ data,
233
+ reason
234
+ });
235
+ }
236
+ };
237
+ /** Thrown by {@link BigCommerceAuth} constructor when `config.redirectUri` is not a valid URL. */
238
+ var BCAuthInvalidRedirectUriError = class extends BaseError {
239
+ code = "BC_AUTH_INVALID_REDIRECT_URI";
240
+ constructor(redirectUri, cause) {
241
+ super("Invalid redirect URI", { redirectUri }, { cause });
242
+ }
243
+ };
244
+ /** Thrown by {@link BigCommerceAuth.requestToken} when a required OAuth callback param is absent. */
245
+ var BCAuthMissingParamError = class extends BaseError {
246
+ code = "BC_AUTH_MISSING_PARAM";
247
+ constructor(param) {
248
+ super(`Missing required auth callback parameter: ${param}`, { param });
249
+ }
250
+ };
251
+ /**
252
+ * Thrown by {@link BigCommerceAuth.requestToken} when the scopes granted by BigCommerce
253
+ * do not include all scopes listed in `config.scopes`.
254
+ * `context.missing` lists the scopes that were expected but not granted.
255
+ */
256
+ var BCAuthScopeMismatchError = class extends BaseError {
257
+ code = "BC_AUTH_SCOPE_MISMATCH";
258
+ constructor(granted, expected, missing) {
259
+ super("Granted scopes do not match expected scopes", {
260
+ granted,
261
+ expected,
262
+ missing
263
+ });
264
+ }
265
+ };
266
+ /** Thrown by {@link BigCommerceAuth.verify} when the JWT signature, audience, issuer, or subject is invalid. */
267
+ var BCAuthInvalidJwtError = class extends BaseError {
268
+ code = "BC_AUTH_INVALID_JWT";
269
+ constructor(storeHash, cause) {
270
+ super("Invalid JWT payload", { storeHash }, { cause });
271
+ }
272
+ };
273
+ //#endregion
274
+ //#region src/lib/logger.ts
275
+ /** @internal */
276
+ const LOG_LEVELS = [
277
+ "debug",
278
+ "info",
279
+ "warn",
280
+ "error"
281
+ ];
282
+ /**
283
+ * Adapts an AWS Lambda Powertools logger to the {@link Logger} interface expected by
284
+ * {@link BigCommerceClient} and {@link BigCommerceAuth}.
285
+ *
286
+ * Powertools loggers use `(message, ...data)` argument order whereas this library uses
287
+ * `(data, message)`. This adapter swaps the arguments.
288
+ *
289
+ * @param logger - An AWS Lambda Powertools (or any {@link PowertoolsLikeLogger}-compatible) logger.
290
+ * @returns A {@link Logger} wrapper suitable for `config.logger`.
291
+ */
292
+ const fromAwsPowertoolsLogger = (logger) => ({
293
+ debug: (data, message) => logger.debug(message ?? "", data),
294
+ info: (data, message) => logger.info(message ?? "", data),
295
+ warn: (data, message) => logger.warn(message ?? "", data),
296
+ error: (data, message) => logger.error(message ?? "", data)
297
+ });
298
+ /**
299
+ * Console-based {@link Logger} that filters messages below a minimum level.
300
+ *
301
+ * Used automatically when `config.logger` is `true`, `undefined`, or a {@link LogLevel} string.
302
+ * Can also be instantiated directly for custom log level control.
303
+ *
304
+ * @example
305
+ * ```ts
306
+ * new BigCommerceClient({ ..., logger: new FallbackLogger('debug') });
307
+ * ```
308
+ */
309
+ var FallbackLogger = class {
310
+ /**
311
+ * @param level - Minimum level to output. Messages below this level are silently dropped.
312
+ */
313
+ constructor(level) {
314
+ this.level = level;
315
+ }
316
+ debug(data, message) {
317
+ this.log("debug", data, message);
318
+ }
319
+ info(data, message) {
320
+ this.log("info", data, message);
321
+ }
322
+ warn(data, message) {
323
+ this.log("warn", data, message);
324
+ }
325
+ error(data, message) {
326
+ this.log("error", data, message);
327
+ }
328
+ log(level, data, message) {
329
+ if (LOG_LEVELS.indexOf(level) < LOG_LEVELS.indexOf(this.level)) return;
330
+ const fn = console[level];
331
+ message !== void 0 ? fn(message, data) : fn(data);
332
+ }
333
+ };
334
+ /**
335
+ * @internal
336
+ */
337
+ const initLogger = (logger) => {
338
+ if (logger === false) return;
339
+ if (logger === void 0 || logger === true) return new FallbackLogger("info");
340
+ if (typeof logger === "string") if (LOG_LEVELS.includes(logger)) return new FallbackLogger(logger);
341
+ else {
342
+ const logger = new FallbackLogger("info");
343
+ logger.warn({ level: logger }, "Unknown log level passed, using info");
344
+ return logger;
345
+ }
346
+ return logger;
347
+ };
348
+ //#endregion
349
+ //#region src/auth.ts
350
+ const GRANT_TYPE = "authorization_code";
351
+ const TOKEN_ENDPOINT = "https://login.bigcommerce.com/oauth2/token";
352
+ const ISSUER = "bc";
353
+ /**
354
+ * Handles authentication with BigCommerce OAuth
355
+ */
356
+ var BigCommerceAuth = class {
357
+ logger;
358
+ client;
359
+ /**
360
+ * Creates a new BigCommerceAuth instance for handling OAuth authentication
361
+ * @param config - Configuration options for BigCommerce authentication
362
+ * @param config.clientId - The OAuth client ID from BigCommerce
363
+ * @param config.secret - The OAuth client secret from BigCommerce
364
+ * @param config.redirectUri - The redirect URI registered with BigCommerce
365
+ * @param config.scopes - Optional array of scopes to validate during auth callback
366
+ * @param config.logger - Optional logger instance for debugging and error tracking
367
+ * @throws {BCAuthInvalidRedirectUriError} If the redirect URI is invalid
368
+ */
369
+ constructor(config) {
370
+ this.config = config;
371
+ try {
372
+ new URL(this.config.redirectUri);
373
+ } catch (error) {
374
+ throw new BCAuthInvalidRedirectUriError(this.config.redirectUri, error);
375
+ }
376
+ this.logger = initLogger(config.logger);
377
+ const { prefixUrl: _, ...authKyConfig } = BASE_KY_CONFIG;
378
+ this.client = ky.create({
379
+ ...authKyConfig,
380
+ retry: {
381
+ ...authKyConfig.retry,
382
+ methods: ["POST"]
383
+ }
384
+ });
385
+ }
386
+ /**
387
+ * Exchanges an OAuth authorization code for an access token.
388
+ *
389
+ * @param data - The auth callback payload: a raw query string, `URLSearchParams`, or a
390
+ * pre-parsed object with `code`, `scope`, and `context`.
391
+ * @returns The token response including `access_token`, `user`, and `context`.
392
+ * @throws {@link BCAuthMissingParamError} if `code`, `scope`, or `context` are absent.
393
+ * @throws {@link BCAuthScopeMismatchError} if the granted scopes don't include all `config.scopes`.
394
+ * @throws {@link BCApiError} on HTTP error responses from the token endpoint.
395
+ * @throws {@link BCTimeoutError} if the token request times out.
396
+ * @throws {@link BCClientError} on any other error.
397
+ */
398
+ async requestToken(data) {
399
+ const query = typeof data === "string" || data instanceof URLSearchParams ? this.parseQueryString(data) : data;
400
+ this.validateScopes(query.scope);
401
+ const tokenRequest = {
402
+ client_id: this.config.clientId,
403
+ client_secret: this.config.secret,
404
+ ...query,
405
+ grant_type: GRANT_TYPE,
406
+ redirect_uri: this.config.redirectUri
407
+ };
408
+ this.logger?.debug({
409
+ clientId: this.config.clientId,
410
+ context: query.context,
411
+ scopes: query.scope
412
+ }, "Requesting OAuth token");
413
+ let res;
414
+ try {
415
+ res = await this.client(TOKEN_ENDPOINT, {
416
+ method: "POST",
417
+ json: tokenRequest
418
+ });
419
+ } catch (error) {
420
+ if (isHTTPError(error)) {
421
+ const err = new BCApiError(error, await error.request.text().catch(() => ""), await error.response.text().catch(() => ""));
422
+ this.logger?.error(err.context, "Failed to request token");
423
+ throw err;
424
+ }
425
+ if (isTimeoutError(error)) {
426
+ const err = new BCTimeoutError(error);
427
+ this.logger?.error(err.context, "Token request timed out");
428
+ throw err;
429
+ }
430
+ throw new BCClientError("Failed to request token", {}, error);
431
+ }
432
+ return res.json();
433
+ }
434
+ /**
435
+ * Verifies a JWT payload from BigCommerce
436
+ * @param jwtPayload - The JWT string to verify
437
+ * @param storeHash - The store hash for the BigCommerce store
438
+ * @returns Promise resolving to the verified JWT claims
439
+ * @throws {BCAuthInvalidJwtError} If the JWT is invalid
440
+ */
441
+ async verify(jwtPayload, storeHash) {
442
+ try {
443
+ const secret = new TextEncoder().encode(this.config.secret);
444
+ const { payload } = await jose.jwtVerify(jwtPayload, secret, {
445
+ audience: this.config.clientId,
446
+ issuer: ISSUER,
447
+ subject: `stores/${storeHash}`
448
+ });
449
+ this.logger?.debug({
450
+ userId: payload.user?.id,
451
+ storeHash: payload.sub.split("/")[1]
452
+ }, "JWT verified successfully");
453
+ return payload;
454
+ } catch (error) {
455
+ const err = new BCAuthInvalidJwtError(storeHash, error);
456
+ this.logger?.error(err.context, "JWT verification failed");
457
+ throw err;
458
+ }
459
+ }
460
+ /**
461
+ * Parses and validates a query string from BigCommerce auth callback
462
+ * @param queryString - The query string to parse
463
+ * @returns The parsed auth query parameters
464
+ * @throws {BCAuthMissingParamError} If required parameters are missing
465
+ */
466
+ parseQueryString(queryString) {
467
+ const params = typeof queryString === "string" ? new URLSearchParams(queryString) : queryString;
468
+ const code = params.get("code");
469
+ const scope = params.get("scope");
470
+ const context = params.get("context");
471
+ if (!code) throw new BCAuthMissingParamError("code");
472
+ if (!scope) throw new BCAuthMissingParamError("scope");
473
+ else if (this.config.scopes?.length) this.validateScopes(scope);
474
+ if (!context) throw new BCAuthMissingParamError("context");
475
+ return {
476
+ code,
477
+ scope,
478
+ context
479
+ };
480
+ }
481
+ /**
482
+ * Validates that the granted scopes match the expected scopes
483
+ * @param scopes - Space-separated list of granted scopes
484
+ * @throws {BCAuthScopeMismatchError} If the scopes don't match the expected scopes
485
+ */
486
+ validateScopes(scopes) {
487
+ if (!this.config.scopes) return;
488
+ const granted = scopes.split(" ");
489
+ const expected = this.config.scopes;
490
+ const missing = expected.filter((scope) => !granted.includes(scope));
491
+ if (missing.length) throw new BCAuthScopeMismatchError(granted, expected, missing);
492
+ }
493
+ };
494
+ //#endregion
495
+ //#region src/lib/util.ts
496
+ const parseIntHeader = (headers, key) => {
497
+ const value = Number.parseInt(headers.get(key) ?? "", 10);
498
+ return Number.isNaN(value) ? void 0 : value;
499
+ };
500
+ const extractRateLimitHeaders = (headers) => {
501
+ const resetIn = parseIntHeader(headers, HEADERS.RATE_LIMIT_RESET);
502
+ if (resetIn === void 0) return;
503
+ return {
504
+ resetIn,
505
+ requestsLeft: parseIntHeader(headers, HEADERS.RATE_LIMIT_LEFT),
506
+ quota: parseIntHeader(headers, HEADERS.RATE_LIMIT_QUOTA),
507
+ window: parseIntHeader(headers, HEADERS.RATE_LIMIT_WINDOW)
508
+ };
509
+ };
510
+ const chunkStrLength = (items, options = {}) => {
511
+ const { maxLength = 2048, chunkLength = 250, offset = 0, separatorSize = 1 } = options;
512
+ const chunks = [];
513
+ let currentStrLength = offset;
514
+ let currentChunk = [];
515
+ for (const item of items) {
516
+ const itemLength = encodeURIComponent(item).length;
517
+ const totalItemLength = itemLength + (currentChunk.length > 0 ? separatorSize : 0);
518
+ const wouldExceedLength = currentStrLength + totalItemLength > maxLength;
519
+ const wouldExceedCount = currentChunk.length >= chunkLength;
520
+ if ((wouldExceedLength || wouldExceedCount) && currentChunk.length > 0) {
521
+ chunks.push(currentChunk);
522
+ currentChunk = [];
523
+ currentStrLength = offset;
524
+ }
525
+ if (itemLength + offset > maxLength) throw new Error(`Item too large: ${itemLength} exceeds maxLength ${maxLength}`);
526
+ currentChunk.push(item);
527
+ currentStrLength += itemLength + (currentChunk.length > 1 ? separatorSize : 0);
528
+ }
529
+ if (currentChunk.length > 0) chunks.push(currentChunk);
530
+ return chunks;
531
+ };
532
+ var AsyncChannel = class {
533
+ queue = [];
534
+ notify = null;
535
+ done = false;
536
+ push(item) {
537
+ this.queue.push(item);
538
+ this.notify?.();
539
+ this.notify = null;
540
+ }
541
+ close() {
542
+ this.done = true;
543
+ this.notify?.();
544
+ this.notify = null;
545
+ }
546
+ async *[Symbol.asyncIterator]() {
547
+ while (!this.done || this.queue.length > 0) {
548
+ if (this.queue.length === 0) await new Promise((r) => {
549
+ if (this.queue.length > 0) return r();
550
+ this.notify = r;
551
+ });
552
+ while (this.queue.length > 0) {
553
+ const item = this.queue.shift();
554
+ if (item !== void 0) yield item;
555
+ }
556
+ }
557
+ }
558
+ };
559
+ //#endregion
560
+ //#region src/lib/hooks.ts
561
+ const validateUrlLength = (request) => {
562
+ if (request.url.length > 2048) throw new BCUrlTooLongError(request.url, MAX_URL_LENGTH);
563
+ };
564
+ const bcRateLimitRetry = (logger) => async ({ request, options, error, retryCount }) => {
565
+ if (isHTTPError(error) && error.response.status === 429) {
566
+ const retryMeta = extractRateLimitHeaders(error.response.headers);
567
+ if (!retryMeta) throw new BCRateLimitNoHeadersError(request, retryCount);
568
+ if (options.retry.maxRetryAfter && retryMeta.resetIn > options.retry.maxRetryAfter) throw new BCRateLimitDelayTooLongError(request, retryCount, options.retry.maxRetryAfter, retryMeta.resetIn);
569
+ const delay = typeof options.retry.jitter === "function" ? options.retry.jitter(retryMeta.resetIn) : rateLimitJitter(retryMeta.resetIn);
570
+ logger?.warn({
571
+ attempt: retryCount,
572
+ url: request.url,
573
+ method: request.method,
574
+ retryMeta
575
+ }, `Rate limit reached, retrying in ${delay} (with jitter)`);
576
+ await new Promise((resolve) => setTimeout(resolve, delay));
577
+ } else logger?.warn({
578
+ url: request.url,
579
+ method: request.method,
580
+ attempt: retryCount
581
+ }, "Retrying request");
582
+ };
583
+ //#endregion
584
+ //#region src/lib/request.ts
585
+ /**
586
+ * Converts a Query object to URLSearchParams.
587
+ * Array values are joined with commas (e.g., `id:in=1,2,3`).
588
+ */
589
+ const toUrlSearchParams = (query) => {
590
+ if (!query) return;
591
+ const params = new URLSearchParams();
592
+ for (const [key, value] of Object.entries(query)) if (Array.isArray(value)) params.append(key, value.map(String).join(","));
593
+ else params.append(key, String(value));
594
+ return params;
595
+ };
596
+ /**
597
+ * Helpers for building typed request descriptors to pass to
598
+ * {@link BigCommerceClient.batchSafe} or {@link BigCommerceClient.batchStream}.
599
+ *
600
+ * @example
601
+ * ```ts
602
+ * const results = await client.batchSafe([
603
+ * req.get('catalog/products/1'),
604
+ * req.post('catalog/products', { body: { name: 'Widget' } }),
605
+ * ]);
606
+ * ```
607
+ */
608
+ const req = {
609
+ get: (path, options) => ({
610
+ method: "GET",
611
+ path,
612
+ ...options
613
+ }),
614
+ post: (path, options) => ({
615
+ method: "POST",
616
+ path,
617
+ ...options
618
+ }),
619
+ put: (path, options) => ({
620
+ method: "PUT",
621
+ path,
622
+ ...options
623
+ }),
624
+ delete: (path, options) => ({
625
+ method: "DELETE",
626
+ path,
627
+ ...options
628
+ })
629
+ };
630
+ //#endregion
631
+ //#region src/lib/result.ts
632
+ /**
633
+ * Creates a successful {@link Result}. Check `result.ok` or `result.err` before accessing `data`.
634
+ * @param data - The success value.
635
+ */
636
+ const Ok = (data) => ({
637
+ ok: true,
638
+ data,
639
+ err: void 0
640
+ });
641
+ /**
642
+ * Creates a failed {@link Result}. Check `result.ok` or `result.err` before accessing `err`.
643
+ * @param err - The error value.
644
+ */
645
+ const Err = (err) => ({
646
+ ok: false,
647
+ data: void 0,
648
+ err
649
+ });
650
+ //#endregion
651
+ //#region src/client.ts
652
+ var BigCommerceClient = class {
653
+ logger;
654
+ client;
655
+ storeHash;
656
+ /**
657
+ * Creates a new BigCommerceClient.
658
+ *
659
+ * @param config - Client configuration. Ky options (e.g. `prefixUrl`, `timeout`, `retry`,
660
+ * `hooks`) are forwarded to the underlying ky instance.
661
+ * @param config.storeHash - BigCommerce store hash. Must be a non-empty string.
662
+ * @param config.accessToken - BigCommerce API access token. Must be a non-empty string.
663
+ * @param config.logger - A {@link Logger} instance, a log level string
664
+ * (`'debug' | 'info' | 'warn' | 'error'`), `true` to enable console logging at `'info'`
665
+ * level, or `false` to disable logging entirely. Omitting also defaults to `'info'` level.
666
+ * @param config.concurrency - Default max concurrent requests for batch/stream operations.
667
+ * Must be between 1 and 1000. Pass `false` to disable concurrency (sequential execution).
668
+ * Defaults to 10.
669
+ * @param config.rateLimitBackoff - Concurrency cap applied when a 429 response is received.
670
+ * Defaults to 1.
671
+ * @param config.backoff - Divisor (or `(concurrency, status) => number` function) applied to
672
+ * concurrency on non-429 error responses. Defaults to 2.
673
+ * @param config.backoffRecover - Amount (or `(concurrency) => number` function) added to
674
+ * concurrency per successful response while below the configured max. Defaults to 1.
675
+ *
676
+ * @throws {@link BCCredentialsError} if `storeHash` or `accessToken` are missing or if
677
+ * `concurrency` is out of range.
678
+ * @throws {@link BCClientError} if `prefixUrl` is not a valid URL.
679
+ */
680
+ constructor(config) {
681
+ this.config = config;
682
+ this.validateConfig();
683
+ const { storeHash, accessToken, logger, concurrency: _, ...kyOptions } = config;
684
+ this.logger = initLogger(logger);
685
+ this.storeHash = storeHash;
686
+ this.client = ky.create({
687
+ ...BASE_KY_CONFIG,
688
+ ...kyOptions,
689
+ headers: {
690
+ ...BASE_KY_CONFIG.headers,
691
+ ...kyOptions.headers ?? {},
692
+ [HEADERS.AUTH_TOKEN]: accessToken
693
+ },
694
+ hooks: {
695
+ beforeRequest: [...kyOptions.hooks?.beforeRequest ?? [], validateUrlLength],
696
+ beforeRetry: [
697
+ ({ error }) => {
698
+ if (error instanceof BaseError) throw error;
699
+ },
700
+ bcRateLimitRetry(this.logger),
701
+ ...kyOptions.hooks?.beforeRetry ?? []
702
+ ],
703
+ beforeError: [...kyOptions.hooks?.beforeError ?? []],
704
+ afterResponse: [...kyOptions.hooks?.afterResponse ?? []]
705
+ }
706
+ });
707
+ }
708
+ /**
709
+ * Sends a GET request to the given path.
710
+ *
711
+ * @param path - API path relative to the store's versioned base URL (e.g. `catalog/products`).
712
+ * @param options - Ky options are forwarded to the underlying request.
713
+ * @param options.version - API version segment inserted into the URL. Defaults to `'v3'`.
714
+ * @param options.query - Query parameters to append to the URL.
715
+ * @param options.querySchema - StandardSchemaV1 schema to validate `query` before the request
716
+ * is sent. Requires `query` to be provided.
717
+ * @param options.responseSchema - StandardSchemaV1 schema to validate the parsed response body.
718
+ *
719
+ * @returns Parsed and optionally validated response body.
720
+ *
721
+ * @throws {@link BCApiError} on HTTP error responses.
722
+ * @throws {@link BCTimeoutError} if the request times out.
723
+ * @throws {@link BCResponseParseError} if the response body cannot be parsed.
724
+ * @throws {@link BCUrlTooLongError} if the constructed URL exceeds 2048 characters.
725
+ * @throws {@link BCRateLimitNoHeadersError} if a 429 is received without rate-limit headers.
726
+ * @throws {@link BCRateLimitDelayTooLongError} if the rate-limit reset window exceeds
727
+ * `config.retry.maxRetryAfter`.
728
+ * @throws {@link BCQueryValidationError} if `querySchema` validation fails.
729
+ * @throws {@link BCResponseValidationError} if `responseSchema` validation fails.
730
+ * @throws {@link BCClientError} on any other ky or unknown error.
731
+ */
732
+ async get(path, options) {
733
+ return this.request(path, {
734
+ ...options,
735
+ method: "GET"
736
+ });
737
+ }
738
+ /**
739
+ * Sends a POST request to the given path.
740
+ *
741
+ * @param path - API path relative to the store's versioned base URL.
742
+ * @param options - Ky options are forwarded to the underlying request.
743
+ * @param options.version - API version segment inserted into the URL. Defaults to `'v3'`.
744
+ * @param options.body - Request body, serialized as JSON.
745
+ * @param options.bodySchema - StandardSchemaV1 schema to validate `body` before the request
746
+ * is sent. Requires `body` to be provided.
747
+ * @param options.query - Query parameters to append to the URL.
748
+ * @param options.querySchema - StandardSchemaV1 schema to validate `query` before the request
749
+ * is sent. Requires `query` to be provided.
750
+ * @param options.responseSchema - StandardSchemaV1 schema to validate the parsed response body.
751
+ *
752
+ * @returns Parsed and optionally validated response body.
753
+ *
754
+ * @throws {@link BCApiError} on HTTP error responses.
755
+ * @throws {@link BCTimeoutError} if the request times out.
756
+ * @throws {@link BCResponseParseError} if the response body cannot be parsed.
757
+ * @throws {@link BCUrlTooLongError} if the constructed URL exceeds 2048 characters.
758
+ * @throws {@link BCRateLimitNoHeadersError} if a 429 is received without rate-limit headers.
759
+ * @throws {@link BCRateLimitDelayTooLongError} if the rate-limit reset window exceeds
760
+ * `config.retry.maxRetryAfter`.
761
+ * @throws {@link BCQueryValidationError} if `querySchema` validation fails.
762
+ * @throws {@link BCRequestBodyValidationError} if `bodySchema` validation fails.
763
+ * @throws {@link BCResponseValidationError} if `responseSchema` validation fails.
764
+ * @throws {@link BCClientError} on any other ky or unknown error.
765
+ */
766
+ async post(path, options) {
767
+ return this.request(path, {
768
+ ...options,
769
+ method: "POST"
770
+ });
771
+ }
772
+ /**
773
+ * Sends a PUT request to the given path.
774
+ *
775
+ * @param path - API path relative to the store's versioned base URL.
776
+ * @param options - Ky options are forwarded to the underlying request.
777
+ * @param options.version - API version segment inserted into the URL. Defaults to `'v3'`.
778
+ * @param options.body - Request body, serialized as JSON.
779
+ * @param options.bodySchema - StandardSchemaV1 schema to validate `body` before the request
780
+ * is sent. Requires `body` to be provided.
781
+ * @param options.query - Query parameters to append to the URL.
782
+ * @param options.querySchema - StandardSchemaV1 schema to validate `query` before the request
783
+ * is sent. Requires `query` to be provided.
784
+ * @param options.responseSchema - StandardSchemaV1 schema to validate the parsed response body.
785
+ *
786
+ * @returns Parsed and optionally validated response body.
787
+ *
788
+ * @throws {@link BCApiError} on HTTP error responses.
789
+ * @throws {@link BCTimeoutError} if the request times out.
790
+ * @throws {@link BCResponseParseError} if the response body cannot be parsed.
791
+ * @throws {@link BCUrlTooLongError} if the constructed URL exceeds 2048 characters.
792
+ * @throws {@link BCRateLimitNoHeadersError} if a 429 is received without rate-limit headers.
793
+ * @throws {@link BCRateLimitDelayTooLongError} if the rate-limit reset window exceeds
794
+ * `config.retry.maxRetryAfter`.
795
+ * @throws {@link BCQueryValidationError} if `querySchema` validation fails.
796
+ * @throws {@link BCRequestBodyValidationError} if `bodySchema` validation fails.
797
+ * @throws {@link BCResponseValidationError} if `responseSchema` validation fails.
798
+ * @throws {@link BCClientError} on any other ky or unknown error.
799
+ */
800
+ async put(path, options) {
801
+ return this.request(path, {
802
+ ...options,
803
+ method: "PUT"
804
+ });
805
+ }
806
+ /**
807
+ * Sends a DELETE request to the given path.
808
+ *
809
+ * Silently suppresses 404 responses (resource already gone) and empty response bodies.
810
+ *
811
+ * @param path - API path relative to the store's versioned base URL.
812
+ * @param options - Ky options are forwarded to the underlying request.
813
+ * @param options.version - API version segment inserted into the URL. Defaults to `'v3'`.
814
+ * @param options.query - Query parameters to append to the URL.
815
+ * @param options.querySchema - StandardSchemaV1 schema to validate `query` before the request
816
+ * is sent. Requires `query` to be provided.
817
+ *
818
+ * @throws {@link BCApiError} on non-404 HTTP error responses.
819
+ * @throws {@link BCTimeoutError} if the request times out.
820
+ * @throws {@link BCResponseParseError} if the response body is non-empty and cannot be parsed.
821
+ * @throws {@link BCUrlTooLongError} if the constructed URL exceeds 2048 characters.
822
+ * @throws {@link BCRateLimitNoHeadersError} if a 429 is received without rate-limit headers.
823
+ * @throws {@link BCRateLimitDelayTooLongError} if the rate-limit reset window exceeds
824
+ * `config.retry.maxRetryAfter`.
825
+ * @throws {@link BCQueryValidationError} if `querySchema` validation fails.
826
+ * @throws {@link BCClientError} on any other ky or unknown error.
827
+ */
828
+ async delete(path, options) {
829
+ try {
830
+ await this.request(path, {
831
+ ...options,
832
+ method: "DELETE"
833
+ });
834
+ } catch (err) {
835
+ if (err instanceof BCResponseParseError && err.context.rawBody === "") return;
836
+ if (err instanceof BCApiError && err.context.status === 404) {
837
+ this.logger?.warn({ err }, "Attempted to delete the resource that is already gone");
838
+ return;
839
+ }
840
+ throw err;
841
+ }
842
+ }
843
+ /**
844
+ * Fetches items from a v3 paginated endpoint by splitting `values` across multiple requests
845
+ * using the given `key` query param, chunking to stay within URL length limits.
846
+ *
847
+ * Collects all results into an array. Use {@link queryStream} to process items lazily.
848
+ *
849
+ * @param path - API path relative to the store's versioned base URL.
850
+ * @param options - Query options including `key`, `values`, pagination params, and concurrency
851
+ * controls.
852
+ * @param options.key - Query parameter name used for value filtering (e.g. `'id:in'`).
853
+ * @param options.values - Values to filter by. Automatically chunked across multiple requests
854
+ * to keep each URL under 2048 characters.
855
+ * @param options.query - Additional query parameters. `query.limit` controls page size
856
+ * (default 250, must be > 0). If `options.key` is present in `query` it will be ignored.
857
+ * @param options.querySchema - StandardSchemaV1 schema to validate `query`. Requires `query`
858
+ * to be provided.
859
+ * @param options.itemSchema - StandardSchemaV1 schema to validate each returned item.
860
+ * @param options.concurrency - Max concurrent chunk requests. Must be 1–1000. `false` for
861
+ * sequential. Defaults to `config.concurrency`, or 10 if not set on the client.
862
+ * @param options.rateLimitBackoff - Concurrency cap on 429 responses. Defaults to
863
+ * `config.rateLimitBackoff`, or 1 if not set on the client.
864
+ * @param options.backoff - Divisor (or function) applied to concurrency on error responses.
865
+ * Defaults to `config.backoff`, or 2 if not set on the client.
866
+ * @param options.backoffRecover - Amount (or function) added to concurrency per successful
867
+ * response. Defaults to `config.backoffRecover`, or 1 if not set on the client.
868
+ *
869
+ * @returns All matching items across all chunked requests.
870
+ * @throws {@link BCPaginatedOptionError} if `query.limit` is not a positive number.
871
+ * @throws {@link BCQueryValidationError} if `querySchema` validation fails.
872
+ * @throws {@link BCApiError} on HTTP error responses.
873
+ * @throws {@link BCTimeoutError} if a request times out.
874
+ * @throws {@link BCResponseParseError} if a response body cannot be parsed.
875
+ * @throws {@link BCUrlTooLongError} if a constructed URL exceeds 2048 characters.
876
+ * @throws {@link BCRateLimitNoHeadersError} if a 429 is received without rate-limit headers.
877
+ * @throws {@link BCRateLimitDelayTooLongError} if the rate-limit reset window exceeds
878
+ * `config.retry.maxRetryAfter`.
879
+ * @throws {@link BCPaginatedResponseError} if a page response has an unexpected shape.
880
+ * @throws {@link BCPaginatedItemValidationError} if `itemSchema` validation fails for an item.
881
+ * @throws {@link BCClientError} on any other ky or unknown error.
882
+ */
883
+ async query(path, options) {
884
+ const results = [];
885
+ for await (const { data, err } of this.queryStream(path, options)) if (err) throw err;
886
+ else results.push(data);
887
+ return results;
888
+ }
889
+ /**
890
+ * Streaming variant of {@link query}. Yields each item individually as results arrive,
891
+ * splitting `values` into URL-length-safe chunks across concurrent requests.
892
+ *
893
+ * Each yielded value is a {@link Result} — check `err` before using `data`.
894
+ *
895
+ * @param path - API path relative to the store's versioned base URL.
896
+ * @param options - Query options including `key`, `values`, pagination params, and concurrency
897
+ * controls.
898
+ * @param options.key - Query parameter name used for value filtering (e.g. `'id:in'`).
899
+ * @param options.values - Values to filter by. Automatically chunked across multiple requests
900
+ * to keep each URL under 2048 characters.
901
+ * @param options.query - Additional query parameters. `query.limit` controls page size
902
+ * (default 250, must be > 0). If `options.key` is present in `query` it will be ignored.
903
+ * @param options.querySchema - StandardSchemaV1 schema to validate `query`. Requires `query`
904
+ * to be provided.
905
+ * @param options.itemSchema - StandardSchemaV1 schema to validate each returned item.
906
+ * @param options.concurrency - Max concurrent chunk requests. Must be 1–1000. `false` for
907
+ * sequential. Defaults to `config.concurrency`, or 10 if not set on the client.
908
+ * @param options.rateLimitBackoff - Concurrency cap on 429 responses. Defaults to
909
+ * `config.rateLimitBackoff`, or 1 if not set on the client.
910
+ * @param options.backoff - Divisor (or function) applied to concurrency on error responses.
911
+ * Defaults to `config.backoff`, or 2 if not set on the client.
912
+ * @param options.backoffRecover - Amount (or function) added to concurrency per successful
913
+ * response. Defaults to `config.backoffRecover`, or 1 if not set on the client.
914
+ * @throws {@link BCPaginatedOptionError} if `query.limit` is not a positive number.
915
+ * @throws {@link BCQueryValidationError} if `querySchema` validation fails.
916
+ */
917
+ async *queryStream(path, options) {
918
+ const { key, values, query, querySchema, itemSchema, ...requestOptions } = options;
919
+ const limit = this.validatePaginationOption(path, "limit", query?.limit ?? 250);
920
+ const newQuery = {
921
+ ...await this.validate(query, querySchema, BCQueryValidationError, "GET", path, "Invalid query parameters"),
922
+ limit
923
+ };
924
+ if (key in newQuery) {
925
+ this.logger?.warn({ key }, "The provided key is already in the query params, this param will be ignored");
926
+ delete newQuery[key];
927
+ }
928
+ const fullUrl = `${this.config.prefixUrl ?? requestOptions.prefixUrl ?? BASE_KY_CONFIG.prefixUrl}/${this.makePath("v3", path)}?${toUrlSearchParams(newQuery)}`;
929
+ const keyOverhead = key.length + 2;
930
+ const requests = chunkStrLength(values.map(String), {
931
+ chunkLength: limit,
932
+ maxLength: MAX_URL_LENGTH,
933
+ offset: fullUrl.length + keyOverhead,
934
+ separatorSize: 3
935
+ }).map((chunk) => req.get(path, {
936
+ ...requestOptions,
937
+ query: {
938
+ ...newQuery,
939
+ page: 1,
940
+ [key]: chunk
941
+ }
942
+ }));
943
+ for await (const { err, data } of this.batchStream(requests, options)) {
944
+ if (err) {
945
+ yield Err(err);
946
+ continue;
947
+ }
948
+ try {
949
+ const { data: items } = this.assertPaginatedResponse(path, data);
950
+ for (const item of items) yield this.validatePaginatedItem(path, item, itemSchema);
951
+ } catch (err) {
952
+ if (err instanceof BaseError) yield Err(err);
953
+ else yield Err(new BCClientError("Unknown error occurred processing page", {}, { cause: err }));
954
+ }
955
+ }
956
+ }
957
+ /**
958
+ * Fetches all pages from a v3 paginated endpoint and collects items into an array.
959
+ *
960
+ * Use {@link stream} to process items lazily without buffering the full result set.
961
+ *
962
+ * @param path - API path relative to the store's versioned base URL.
963
+ * @param options - Ky options are forwarded to page requests.
964
+ * @param options.query - Query parameters. `query.limit` controls page size (default 250,
965
+ * must be > 0). `query.page` sets the starting page (default 1, must be > 0).
966
+ * @param options.querySchema - StandardSchemaV1 schema to validate `query`. Requires `query`
967
+ * to be provided.
968
+ * @param options.itemSchema - StandardSchemaV1 schema to validate each returned item.
969
+ * @param options.concurrency - Max concurrent page requests for pages after the first.
970
+ * Must be 1–1000. `false` for sequential. Defaults to `config.concurrency`, or 10 if not
971
+ * set on the client.
972
+ * @param options.rateLimitBackoff - Concurrency cap on 429 responses. Defaults to
973
+ * `config.rateLimitBackoff`, or 1 if not set on the client.
974
+ * @param options.backoff - Divisor (or function) applied to concurrency on error responses.
975
+ * Defaults to `config.backoff`, or 2 if not set on the client.
976
+ * @param options.backoffRecover - Amount (or function) added to concurrency per successful
977
+ * response. Defaults to `config.backoffRecover`, or 1 if not set on the client.
978
+ * @returns All items across all pages.
979
+ *
980
+ * @throws {@link BCPaginatedOptionError} if `query.limit` or `query.page` is not a positive number.
981
+ * @throws {@link BCQueryValidationError} if `querySchema` validation fails.
982
+ * @throws {@link BCApiError} on HTTP error responses.
983
+ * @throws {@link BCTimeoutError} if a request times out.
984
+ * @throws {@link BCResponseParseError} if a response body cannot be parsed.
985
+ * @throws {@link BCUrlTooLongError} if a constructed URL exceeds 2048 characters.
986
+ * @throws {@link BCRateLimitNoHeadersError} if a 429 is received without rate-limit headers.
987
+ * @throws {@link BCRateLimitDelayTooLongError} if the rate-limit reset window exceeds
988
+ * `config.retry.maxRetryAfter`.
989
+ * @throws {@link BCPaginatedResponseError} if a page response has an unexpected shape.
990
+ * @throws {@link BCPaginatedItemValidationError} if `itemSchema` validation fails for an item.
991
+ * @throws {@link BCClientError} on any other ky or unknown error.
992
+ */
993
+ async collect(path, options) {
994
+ const items = [];
995
+ for await (const { data, err } of this.stream(path, options)) if (err) throw err;
996
+ else items.push(data);
997
+ return items;
998
+ }
999
+ /**
1000
+ * Fetches all pages from a v2 flat-array endpoint and collects items into an array.
1001
+ *
1002
+ * Pagination is discovered dynamically — pages are fetched in batches until an empty page,
1003
+ * a 404, or a 204 response is received. No prior knowledge of total count is required.
1004
+ *
1005
+ * Use {@link streamBlind} to process items lazily without buffering the full result set.
1006
+ *
1007
+ * @param path - API path relative to the store's versioned base URL (always requests v2).
1008
+ * @param options - Ky options are forwarded to page requests.
1009
+ * @param options.query - Query parameters. `query.limit` controls page size (default 250,
1010
+ * must be > 0). `query.page` sets the starting page (default 1, must be > 0).
1011
+ * @param options.querySchema - StandardSchemaV1 schema to validate `query`. Requires `query`
1012
+ * to be provided.
1013
+ * @param options.itemSchema - StandardSchemaV1 schema to validate each returned item.
1014
+ * @param options.maxPages - Maximum number of pages to fetch before stopping (default 500,
1015
+ * must be > 0). A warning is logged if this limit is reached.
1016
+ * @param options.concurrency - Max concurrent page requests per batch. Must be 1–1000.
1017
+ * `false` for sequential. Defaults to `config.concurrency`, or 10 if not set on the client.
1018
+ * @param options.rateLimitBackoff - Concurrency cap on 429 responses. Defaults to
1019
+ * `config.rateLimitBackoff`, or 1 if not set on the client.
1020
+ * @param options.backoff - Divisor (or function) applied to concurrency on error responses.
1021
+ * Defaults to `config.backoff`, or 2 if not set on the client.
1022
+ * @param options.backoffRecover - Amount (or function) added to concurrency per successful
1023
+ * response. Defaults to `config.backoffRecover`, or 1 if not set on the client.
1024
+ * @returns All items across all pages.
1025
+ *
1026
+ * @throws {@link BCPaginatedOptionError} if `query.limit`, `query.page`, or `maxPages` is not a positive number.
1027
+ * @throws {@link BCQueryValidationError} if `querySchema` validation fails.
1028
+ * @throws {@link BCPaginatedItemValidationError} if `itemSchema` validation fails for an item.
1029
+ * @throws {@link BCClientError} if a page returns a non-array response, or on any other error.
1030
+ * @throws {@link BCApiError} on non-terminating HTTP error responses.
1031
+ * @throws {@link BCTimeoutError} if a request times out.
1032
+ * @throws {@link BCResponseParseError} if a response body cannot be parsed.
1033
+ */
1034
+ async collectBlind(path, options) {
1035
+ const results = [];
1036
+ for await (const { err, data } of this.streamBlind(path, options)) if (err) throw err;
1037
+ else results.push(data);
1038
+ return results;
1039
+ }
1040
+ /**
1041
+ * Lazily streams items from a v2 flat-array endpoint, page by page.
1042
+ *
1043
+ * Pagination is discovered dynamically — pages are fetched in concurrent batches until an
1044
+ * empty page, a 404, or a 204 response is received. No prior knowledge of total count is
1045
+ * required. Each item is yielded as a {@link Result}: `Ok(item)` on success or
1046
+ * `Err(error)` for item-level validation failures and non-terminating page errors.
1047
+ *
1048
+ * Use {@link collectBlind} to buffer all results into an array (throws on any error).
1049
+ *
1050
+ * @param path - API path relative to the store's versioned base URL (always requests v2).
1051
+ * @param options - Ky options are forwarded to page requests.
1052
+ * @param options.query - Query parameters. `query.limit` controls page size (default 250,
1053
+ * must be > 0). `query.page` sets the starting page (default 1, must be > 0).
1054
+ * @param options.querySchema - StandardSchemaV1 schema to validate `query`. Requires `query`
1055
+ * to be provided.
1056
+ * @param options.itemSchema - StandardSchemaV1 schema to validate each returned item.
1057
+ * @param options.maxPages - Maximum number of pages to fetch before stopping (default 500,
1058
+ * must be > 0). A warning is logged if this limit is reached.
1059
+ * @param options.concurrency - Max concurrent page requests per batch. Must be 1–1000.
1060
+ * `false` for sequential. Defaults to `config.concurrency`, or 10 if not set on the client.
1061
+ * @param options.rateLimitBackoff - Concurrency cap on 429 responses. Defaults to
1062
+ * `config.rateLimitBackoff`, or 1 if not set on the client.
1063
+ * @param options.backoff - Divisor (or function) applied to concurrency on error responses.
1064
+ * Defaults to `config.backoff`, or 2 if not set on the client.
1065
+ * @param options.backoffRecover - Amount (or function) added to concurrency per successful
1066
+ * response. Defaults to `config.backoffRecover`, or 1 if not set on the client.
1067
+ *
1068
+ * @throws {@link BCPaginatedOptionError} if `query.limit`, `query.page`, or `maxPages` is not a positive number.
1069
+ * @throws {@link BCQueryValidationError} if `querySchema` validation fails.
1070
+ *
1071
+ * @yields `Ok(item)` for each successfully fetched and validated item.
1072
+ * @yields `Err(BCPaginatedItemValidationError)` when `itemSchema` rejects an item.
1073
+ * @yields `Err(BCClientError)` when a page returns a non-array response.
1074
+ * @yields `Err(error)` for non-terminating page errors (e.g. non-404/204 HTTP errors).
1075
+ */
1076
+ async *streamBlind(path, options) {
1077
+ const { query: rawQuery, querySchema, itemSchema, maxPages: rawMaxPages, concurrency: rawConcurrency, rateLimitBackoff, backoff, backoffRecover, ...requestOptions } = options ?? {};
1078
+ const concurrencyOptions = {
1079
+ concurrency: rawConcurrency,
1080
+ rateLimitBackoff,
1081
+ backoff,
1082
+ backoffRecover
1083
+ };
1084
+ const concurrency = this.validateConcurrency(this.config.concurrency ?? rawConcurrency ?? 10);
1085
+ const query = await this.validate(rawQuery, querySchema, BCQueryValidationError, "GET", path);
1086
+ const page = this.validatePaginationOption(path, "page", query?.page ?? 1);
1087
+ const limit = this.validatePaginationOption(path, "limit", query?.limit ?? 250);
1088
+ const maxPages = this.validatePaginationOption(path, "maxPages", rawMaxPages ?? 500);
1089
+ let done = false;
1090
+ let currentPage = page;
1091
+ do {
1092
+ if (currentPage > maxPages) {
1093
+ this.logger?.warn({ currentPage }, "Blind pagination reached maxPages before the end of the data");
1094
+ break;
1095
+ }
1096
+ const batchSize = concurrency || 1;
1097
+ const pageRequests = Array.from({ length: batchSize }, (_, i) => currentPage + i).map((page) => req.get(path, {
1098
+ ...requestOptions,
1099
+ version: "v2",
1100
+ query: {
1101
+ ...query,
1102
+ page,
1103
+ limit
1104
+ }
1105
+ }));
1106
+ currentPage += batchSize;
1107
+ const pages = await this.batchSafe(pageRequests, concurrencyOptions);
1108
+ for (const { err, data } of pages) if (err) {
1109
+ done = err instanceof BCApiError && err.context.status === 404 || err instanceof BCResponseParseError && err.context.rawBody === "" && err.context.status === 204;
1110
+ if (!done) yield Err(err);
1111
+ } else if (Array.isArray(data)) {
1112
+ if (data.length === 0) {
1113
+ done = true;
1114
+ break;
1115
+ }
1116
+ for (const item of data) yield this.validatePaginatedItem(path, item, itemSchema);
1117
+ } else yield Err(new BCClientError("Received non array response from blind pagination page endpoint", {
1118
+ data,
1119
+ path
1120
+ }));
1121
+ } while (!done);
1122
+ }
1123
+ /**
1124
+ * Executes multiple requests concurrently and returns all results as {@link Result} values,
1125
+ * never throwing. Errors from individual requests are captured as `Err` results.
1126
+ *
1127
+ * Use {@link batchStream} to process results as they arrive rather than waiting for all.
1128
+ *
1129
+ * @param requests - Array of request descriptors built with the {@link req} helpers.
1130
+ * @param options.concurrency - Max concurrent requests. Must be 1–1000. `false` for
1131
+ * sequential. Defaults to `config.concurrency`, or 10 if not set on the client.
1132
+ * @param options.rateLimitBackoff - Concurrency cap on 429 responses. Defaults to
1133
+ * `config.rateLimitBackoff`, or 1 if not set on the client.
1134
+ * @param options.backoff - Divisor (or function) applied to concurrency on error responses.
1135
+ * Defaults to `config.backoff`, or 2 if not set on the client.
1136
+ * @param options.backoffRecover - Amount (or function) added to concurrency per successful
1137
+ * response. Defaults to `config.backoffRecover`, or 1 if not set on the client.
1138
+ *
1139
+ * @returns Results in the order requests complete (not necessarily input order).
1140
+ */
1141
+ async batchSafe(requests, options) {
1142
+ const results = [];
1143
+ for await (const res of this.batchStream(requests, options)) results.push(res);
1144
+ return results;
1145
+ }
1146
+ /**
1147
+ * Streams all items from a v3 paginated endpoint, fetching the first page sequentially
1148
+ * and remaining pages concurrently via {@link batchStream}.
1149
+ *
1150
+ * Each yielded value is a {@link Result} — check `err` before using `data`. Use
1151
+ * {@link collect} to gather all items into an array.
1152
+ *
1153
+ * @param path - API path relative to the store's versioned base URL.
1154
+ * @param options - Ky options are forwarded to page requests.
1155
+ * @param options.query - Query parameters. `query.limit` controls page size (default 250,
1156
+ * must be > 0). `query.page` sets the starting page (default 1, must be > 0). If the API
1157
+ * enforces a different limit, the actual `per_page` from the first response is used for
1158
+ * subsequent pages.
1159
+ * @param options.querySchema - StandardSchemaV1 schema to validate `query`. Requires `query`
1160
+ * to be provided.
1161
+ * @param options.itemSchema - StandardSchemaV1 schema to validate each returned item.
1162
+ * @param options.concurrency - Max concurrent page requests for pages after the first.
1163
+ * Must be 1–1000. `false` for sequential. Defaults to `config.concurrency`, or 10 if not
1164
+ * set on the client.
1165
+ * @param options.rateLimitBackoff - Concurrency cap on 429 responses. Defaults to
1166
+ * `config.rateLimitBackoff`, or 1 if not set on the client.
1167
+ * @param options.backoff - Divisor (or function) applied to concurrency on error responses.
1168
+ * Defaults to `config.backoff`, or 2 if not set on the client.
1169
+ * @param options.backoffRecover - Amount (or function) added to concurrency per successful
1170
+ * response. Defaults to `config.backoffRecover`, or 1 if not set on the client.
1171
+ * @throws {@link BCPaginatedOptionError} if `query.limit` or `query.page` is not a positive number.
1172
+ * @throws {@link BCQueryValidationError} if `querySchema` validation fails.
1173
+ */
1174
+ async *stream(path, options) {
1175
+ const { query, querySchema, itemSchema, ...requestOptions } = options ?? {};
1176
+ let limit = this.validatePaginationOption(path, "limit", query?.limit ?? 250);
1177
+ const page = this.validatePaginationOption(path, "page", query?.page ?? 1);
1178
+ const validatedQuery = await this.validate(query, querySchema, BCQueryValidationError, "GET", path, "Invalid query parameters");
1179
+ let firstPageMeta;
1180
+ try {
1181
+ const firstPage = await this.get(path, {
1182
+ ...requestOptions,
1183
+ query: {
1184
+ ...validatedQuery,
1185
+ page,
1186
+ limit
1187
+ }
1188
+ });
1189
+ const { data, meta } = this.assertPaginatedResponse(path, firstPage);
1190
+ firstPageMeta = meta;
1191
+ for (const item of data) yield this.validatePaginatedItem(path, item, itemSchema);
1192
+ } catch (err) {
1193
+ if (err instanceof BaseError) yield Err(err);
1194
+ else yield Err(new BCClientError("Unknown error occurred fetching first page", {}, { cause: err }));
1195
+ return;
1196
+ }
1197
+ const { total_pages, per_page } = firstPageMeta.pagination;
1198
+ if (limit !== per_page) {
1199
+ this.logger?.warn({
1200
+ limit,
1201
+ actual: per_page
1202
+ }, "API enforces alternate limit on this endpoint");
1203
+ limit = per_page;
1204
+ }
1205
+ const requests = Array.from({ length: total_pages - page }, (_, i) => i + page + 1).map((page) => ({
1206
+ method: "GET",
1207
+ path,
1208
+ ...requestOptions,
1209
+ query: {
1210
+ ...validatedQuery,
1211
+ limit,
1212
+ page
1213
+ }
1214
+ }));
1215
+ for await (const pageRes of requests.length > 0 ? this.batchStream(requests, options) : []) {
1216
+ const { data: page, err } = pageRes;
1217
+ if (err) {
1218
+ yield Err(err);
1219
+ continue;
1220
+ }
1221
+ try {
1222
+ const { data } = this.assertPaginatedResponse(path, page);
1223
+ for (const item of data) yield this.validatePaginatedItem(path, item, itemSchema);
1224
+ } catch (err) {
1225
+ if (err instanceof BaseError) yield Err(err);
1226
+ else yield Err(new BCClientError("Unknown error occured processing page", {}, { cause: err }));
1227
+ }
1228
+ }
1229
+ }
1230
+ /**
1231
+ * Executes multiple requests with configurable concurrency, yielding each result as a
1232
+ * {@link Result} as it completes. Errors from individual requests are yielded as `Err`
1233
+ * results rather than thrown.
1234
+ *
1235
+ * Automatically adjusts concurrency up/down in response to rate-limit and error responses.
1236
+ * Use {@link batchSafe} to collect all results into an array.
1237
+ *
1238
+ * **Caution:** the generator is making requests concurrently. As a consequence if a
1239
+ * request is mutating the remote (POST, DELETE) and `for await` loop is exited early,
1240
+ * the in-flight request may or may not commit the mutation, and the results of
1241
+ * these request WILL NOT be yielded. If you do intent to break the loop early and want to
1242
+ * get all the results, set `concurrency: false` to trade concurrency for deterministic behavior.
1243
+ *
1244
+ * @param requests - Array of request descriptors built with the {@link req} helpers.
1245
+ * @param options.concurrency - Max concurrent requests. Must be 1–1000. `false` for
1246
+ * sequential. Defaults to `config.concurrency`, or 10 if not set on the client.
1247
+ * @param options.rateLimitBackoff - Concurrency cap on 429 responses. Defaults to
1248
+ * `config.rateLimitBackoff`, or 1 if not set on the client.
1249
+ * @param options.backoff - Divisor (or function) applied to concurrency on error responses.
1250
+ * Defaults to `config.backoff`, or 2 if not set on the client.
1251
+ * @param options.backoffRecover - Amount (or function) added to concurrency per successful
1252
+ * response. Defaults to `config.backoffRecover`, or 1 if not set on the client.
1253
+ */
1254
+ async *batchStream(requests, options) {
1255
+ const resolved = this.resolveStreamOptions(options);
1256
+ if (resolved.concurrency) {
1257
+ const limit = pLimit({
1258
+ concurrency: resolved.concurrency,
1259
+ rejectOnClear: true
1260
+ });
1261
+ const client = this.makeStreamClient(limit, resolved);
1262
+ const channel = new AsyncChannel();
1263
+ try {
1264
+ Promise.all(requests.map((req) => limit(() => this.request(req.path, req, client).then((val) => channel.push(Ok(val)), (err) => channel.push(Err(err)))))).catch((err) => this.logger?.warn({ err }, "In-flight concurrent requests aborted")).finally(() => channel.close());
1265
+ for await (const item of channel) yield item;
1266
+ } finally {
1267
+ limit.clearQueue();
1268
+ }
1269
+ } else for (const request of requests) try {
1270
+ yield Ok(await this.request(request.path, request));
1271
+ } catch (err) {
1272
+ if (err instanceof BaseError) yield Err(err);
1273
+ else yield Err(new BCClientError("Unknown error in batchStream", {}, { cause: err }));
1274
+ }
1275
+ }
1276
+ async validatePaginatedItem(path, item, schema) {
1277
+ if (!schema) return Ok(item);
1278
+ const result = await schema["~standard"].validate(item);
1279
+ if (result.issues) return Err(new BCPaginatedItemValidationError("Page item validation failed", "GET", path, item, result));
1280
+ else return Ok(result.value);
1281
+ }
1282
+ assertPaginatedResponse(path, res) {
1283
+ if (typeof res !== "object" || res === null) throw new BCPaginatedResponseError(path, res, "Response is invalid");
1284
+ if (!("data" in res) || !Array.isArray(res.data)) throw new BCPaginatedResponseError(path, res, "response.data must be an array, ensure this endpoint returns a v3 collection");
1285
+ if (!("meta" in res) || typeof res.meta !== "object" || res.meta === null || !("pagination" in res.meta)) throw new BCPaginatedResponseError(path, res, "response.meta is invalid unable to paginate");
1286
+ const pagination = res.meta.pagination;
1287
+ if (typeof pagination !== "object" || pagination === null) throw new BCPaginatedResponseError(path, res, "response.meta.pagination is invalid unable to paginate");
1288
+ const requiredFields = [["per_page", (v) => typeof v === "number" && v > 0], ["total_pages", (v) => typeof v === "number" && v >= 0]];
1289
+ for (const [field, isValid] of requiredFields) if (!(field in pagination) || !isValid(pagination[field])) throw new BCPaginatedResponseError(path, res, `response.meta.pagination.${field} is missing or invalid`);
1290
+ const { links } = pagination;
1291
+ if (typeof links !== "object" || links === null) throw new BCPaginatedResponseError(path, res, "response.meta.pagination.links is missing or invalid");
1292
+ const isNullableString = (v) => v === null || typeof v === "string";
1293
+ if (!("current" in links) || typeof links.current !== "string") throw new BCPaginatedResponseError(path, res, "response.meta.pagination.links.current is missing or invalid");
1294
+ if ("next" in links && !isNullableString(links.next)) throw new BCPaginatedResponseError(path, res, "response.meta.pagination.links.next is invalid");
1295
+ if ("previous" in links && !isNullableString(links.previous)) throw new BCPaginatedResponseError(path, res, "response.meta.pagination.links.previous is invalid");
1296
+ return res;
1297
+ }
1298
+ validatePaginationOption(path, key, value) {
1299
+ if (typeof value !== "number" || value <= 0) throw new BCPaginatedOptionError(path, value, key);
1300
+ return value;
1301
+ }
1302
+ resolveStreamOptions(options) {
1303
+ return {
1304
+ concurrency: options?.concurrency ?? this.config.concurrency ?? 10,
1305
+ rateLimitBackoff: options?.rateLimitBackoff ?? this.config.rateLimitBackoff ?? 1,
1306
+ backoff: options?.backoff ?? this.config.backoff ?? 2,
1307
+ backoffRecover: options?.backoffRecover ?? this.config.backoffRecover ?? 1
1308
+ };
1309
+ }
1310
+ makeStreamClient(limit, options) {
1311
+ const { concurrency, rateLimitBackoff, backoff, backoffRecover } = options;
1312
+ if (concurrency === false) return this.client;
1313
+ return this.client.extend({ hooks: {
1314
+ beforeRetry: [({ error }) => {
1315
+ if (!isHTTPError(error)) return;
1316
+ const previousConcurrency = limit.concurrency;
1317
+ if (error.response.status === 429) {
1318
+ limit.concurrency = rateLimitBackoff;
1319
+ this.logger?.warn({
1320
+ previousConcurrency,
1321
+ newConcurrency: limit.concurrency
1322
+ }, "Rate limit reached, limiting concurrency");
1323
+ } else {
1324
+ const rate = typeof backoff === "function" ? backoff(limit.concurrency, error.response.status) : backoff;
1325
+ limit.concurrency = Math.ceil(limit.concurrency / rate);
1326
+ this.logger?.warn({
1327
+ previousConcurrency,
1328
+ newConcurrency: limit.concurrency
1329
+ }, "Intermittent errors, limiting concurrency to compensate");
1330
+ }
1331
+ }],
1332
+ afterResponse: [(_request, _options, response) => {
1333
+ if (response.ok && limit.concurrency < concurrency) {
1334
+ const recover = typeof backoffRecover === "function" ? backoffRecover(limit.concurrency) : backoffRecover;
1335
+ limit.concurrency = Math.min(concurrency, limit.concurrency + recover);
1336
+ }
1337
+ }]
1338
+ } });
1339
+ }
1340
+ async request(_path, options, client) {
1341
+ const { version, query, body, bodySchema, querySchema, responseSchema, ...kyOptions } = options;
1342
+ const path = this.makePath(options.version ?? "v3", _path);
1343
+ const validQuery = await this.validate(query, querySchema, BCQueryValidationError, options.method, path, "Invalid query parameters");
1344
+ const validBody = await this.validate(body, bodySchema, BCRequestBodyValidationError, options.method, path, `Invalid ${options.method} request body`);
1345
+ let response;
1346
+ try {
1347
+ response = await (client ?? this.client)(path, {
1348
+ ...kyOptions,
1349
+ method: options.method,
1350
+ searchParams: toUrlSearchParams(validQuery),
1351
+ json: validBody
1352
+ });
1353
+ } catch (err) {
1354
+ if (err instanceof BaseError) throw err;
1355
+ if (isHTTPError(err)) {
1356
+ const error = new BCApiError(err, await err.request.text().catch(() => ""), await err.response.text().catch(() => ""));
1357
+ this.logger?.error(error.context, "Request failed");
1358
+ throw error;
1359
+ }
1360
+ if (isTimeoutError(err)) {
1361
+ const error = new BCTimeoutError(err);
1362
+ this.logger?.error(error.context, "Request timed out");
1363
+ throw error;
1364
+ }
1365
+ if (isKyError(err)) throw new BCClientError("Client error", void 0, err);
1366
+ throw new BCClientError("Unknown error", void 0, err);
1367
+ }
1368
+ let text;
1369
+ try {
1370
+ text = await response.text();
1371
+ } catch (err) {
1372
+ throw new BCResponseParseError(options.method, path, response.status, err, query, "");
1373
+ }
1374
+ let res;
1375
+ try {
1376
+ res = JSON.parse(text);
1377
+ } catch (err) {
1378
+ throw new BCResponseParseError(options.method, path, response.status, err, query, text);
1379
+ }
1380
+ this.logger?.debug({
1381
+ method: options.method,
1382
+ url: response.url,
1383
+ status: response.status
1384
+ }, "Successful request");
1385
+ return this.validate(res, responseSchema, BCResponseValidationError, options.method, path, "Invalid API response");
1386
+ }
1387
+ async validate(data, schema, ErrorClass, method, path, message) {
1388
+ if (!schema) return data;
1389
+ const result = await schema["~standard"].validate(data);
1390
+ if (result.issues) throw new ErrorClass(message ?? "Validation failed", method, path, data, result);
1391
+ return result.value;
1392
+ }
1393
+ makePath(version, route) {
1394
+ return `stores/${this.storeHash}/${version}/${route.replace(LEADING_SLASHES, "")}`;
1395
+ }
1396
+ validateConfig() {
1397
+ const { accessToken, storeHash } = this.config;
1398
+ const errors = [];
1399
+ if (typeof storeHash !== "string" || storeHash.length <= 0) errors.push("storeHash is empty");
1400
+ if (typeof accessToken !== "string" || accessToken.length <= 0) errors.push("accessToken is empty");
1401
+ if (this.config.prefixUrl) try {
1402
+ new URL(this.config.prefixUrl);
1403
+ } catch (err) {
1404
+ throw new BCClientError("Invalid prefixUrl", void 0, err);
1405
+ }
1406
+ try {
1407
+ this.validateConcurrency(this.config.concurrency);
1408
+ } catch (err) {
1409
+ if (err instanceof BCClientError) errors.push(err.message);
1410
+ else throw err;
1411
+ }
1412
+ if (errors.length > 0) throw new BCCredentialsError(errors);
1413
+ }
1414
+ validateConcurrency(concurrency) {
1415
+ if (concurrency === void 0) return;
1416
+ if (concurrency === false) return concurrency;
1417
+ if (concurrency <= 0 || concurrency > 1e3) throw new BCClientError(`Invalid concurrency: allowed range (1:${MAX_CONCURRENCY})`, void 0);
1418
+ return concurrency;
1419
+ }
1420
+ };
1421
+ //#endregion
1422
+ export { BCApiError, BCAuthInvalidJwtError, BCAuthInvalidRedirectUriError, BCAuthMissingParamError, BCAuthScopeMismatchError, BCClientError, BCCredentialsError, BCPaginatedItemValidationError, BCPaginatedOptionError, BCPaginatedResponseError, BCQueryValidationError, BCRateLimitDelayTooLongError, BCRateLimitNoHeadersError, BCRequestBodyValidationError, BCResponseParseError, BCResponseValidationError, BCSchemaValidationError, BCTimeoutError, BCUrlTooLongError, BaseError, BigCommerceAuth, BigCommerceClient, Err, FallbackLogger, Ok, fromAwsPowertoolsLogger, req };
1423
+
1424
+ //# sourceMappingURL=index.mjs.map