From 68a3232c86342ce4701cc3fc1a1688e8062e1f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:43:55 -0700 Subject: [PATCH 01/34] fix(catalogue): fix 'chidlren' typo in GraphQL query builder The onFolder helper in create-catalogue-fetcher.ts produced a field named 'chidlren' instead of 'children' in generated GraphQL queries, causing incorrect query results for folder children. --- .../src/core/catalogue/create-catalogue-fetcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts b/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts index 2d049afa..0a1ea1df 100644 --- a/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts +++ b/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts @@ -64,7 +64,7 @@ function onFolder(onFolder?: OF, c?: CatalogueFetcherGrapqhqlOnFol const children = () => { if (c?.onChildren) { return { - chidlren: { + children: { ...c.onChildren, }, }; From 3c25287e857c0da5e250842e5a95f7b7c9a5e84b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:45:29 -0700 Subject: [PATCH 02/34] fix(api-caller): remove dead try/catch block that only rethrows The outer try/catch in the `post` function was a no-op that added visual noise and misleadingly suggested error handling was happening. --- .../src/core/client/create-api-caller.ts | 162 +++++++++--------- 1 file changed, 79 insertions(+), 83 deletions(-) diff --git a/components/js-api-client/src/core/client/create-api-caller.ts b/components/js-api-client/src/core/client/create-api-caller.ts index 4e864f70..e4a25116 100644 --- a/components/js-api-client/src/core/client/create-api-caller.ts +++ b/components/js-api-client/src/core/client/create-api-caller.ts @@ -95,96 +95,92 @@ export const post = async ( init?: RequestInit | any | undefined, options?: CreateClientOptions, ): Promise => { - try { - const { headers: initHeaders, ...initRest } = init || {}; - const profiling = options?.profiling; + const { headers: initHeaders, ...initRest } = init || {}; + const profiling = options?.profiling; - const headers = { - 'Content-type': 'application/json; charset=UTF-8', - Accept: 'application/json', - ...authenticationHeaders(config), - ...initHeaders, - }; + const headers = { + 'Content-type': 'application/json; charset=UTF-8', + Accept: 'application/json', + ...authenticationHeaders(config), + ...initHeaders, + }; - const body = JSON.stringify({ query, variables }); - let start: number = 0; - if (profiling) { - start = Date.now(); - if (profiling.onRequest) { - profiling.onRequest(query, variables); - } + const body = JSON.stringify({ query, variables }); + let start: number = 0; + if (profiling) { + start = Date.now(); + if (profiling.onRequest) { + profiling.onRequest(query, variables); } + } - const response = await grab(path, { - ...initRest, - method: 'POST', - headers, - body, - }); + const response = await grab(path, { + ...initRest, + method: 'POST', + headers, + body, + }); - if (profiling) { - const ms = Date.now() - start; - let serverTiming = response.headers.get('server-timing') ?? undefined; - if (Array.isArray(serverTiming)) { - serverTiming = serverTiming[0]; - } - const duration = serverTiming?.split(';')[1]?.split('=')[1] ?? -1; - profiling.onRequestResolved( - { - resolutionTimeMs: ms, - serverTimeMs: Number(duration), - }, - query, - variables, - ); - } - if (response.ok && 204 === response.status) { - return {}; - } - if (!response.ok) { - const json = await response.json<{ - message: string; - errors: unknown; - }>(); - throw new JSApiClientCallError({ - code: response.status, - statusText: response.statusText, - message: json.message, - query, - variables: variables || {}, - errors: json.errors || {}, - }); + if (profiling) { + const ms = Date.now() - start; + let serverTiming = response.headers.get('server-timing') ?? undefined; + if (Array.isArray(serverTiming)) { + serverTiming = serverTiming[0]; } - // we still need to check for error as the API can return 200 with errors + const duration = serverTiming?.split(';')[1]?.split('=')[1] ?? -1; + profiling.onRequestResolved( + { + resolutionTimeMs: ms, + serverTimeMs: Number(duration), + }, + query, + variables, + ); + } + if (response.ok && 204 === response.status) { + return {}; + } + if (!response.ok) { const json = await response.json<{ - errors: { - message: string; - }[]; - data: T; + message: string; + errors: unknown; }>(); - if (json.errors) { - throw new JSApiClientCallError({ - code: 400, - statusText: 'Error was returned from the API', - message: json.errors[0].message, - query, - variables: variables || {}, - errors: json.errors || {}, - }); - } - // let's try to find `errorName` at the second level to handle Core Next errors more gracefully - const err = getCoreNextError(json.data); - if (err) { - throw new JSApiClientCallError({ - code: 400, - query, - variables: variables || {}, - statusText: 'Error was returned (wrapped) from the API. (most likely Core Next)', - message: `[${err.errorName}] ${err.message ?? 'An error occurred'}`, - }); - } - return json.data; - } catch (exception) { - throw exception; + throw new JSApiClientCallError({ + code: response.status, + statusText: response.statusText, + message: json.message, + query, + variables: variables || {}, + errors: json.errors || {}, + }); + } + // we still need to check for error as the API can return 200 with errors + const json = await response.json<{ + errors: { + message: string; + }[]; + data: T; + }>(); + if (json.errors) { + throw new JSApiClientCallError({ + code: 400, + statusText: 'Error was returned from the API', + message: json.errors[0].message, + query, + variables: variables || {}, + errors: json.errors || {}, + }); + } + // let's try to find `errorName` at the second level to handle Core Next errors more gracefully + const err = getCoreNextError(json.data); + if (err) { + throw new JSApiClientCallError({ + code: 400, + query, + variables: variables || {}, + statusText: 'Error was returned (wrapped) from the API. (most likely Core Next)', + message: `[${err.errorName}] ${err.message ?? 'An error occurred'}`, + }); } + return json.data; }; From d85bb52a248fec170c7edb8e92c258bab2b2fef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:46:17 -0700 Subject: [PATCH 03/34] chore: delete dead commented-out subscription.ts file The file contained 543 lines of entirely commented-out code, replaced by the modules in src/core/pim/subscriptions/. Git history preserves it if ever needed. --- .../js-api-client/src/core/subscription.ts | 543 ------------------ 1 file changed, 543 deletions(-) delete mode 100644 components/js-api-client/src/core/subscription.ts diff --git a/components/js-api-client/src/core/subscription.ts b/components/js-api-client/src/core/subscription.ts deleted file mode 100644 index e6b6589b..00000000 --- a/components/js-api-client/src/core/subscription.ts +++ /dev/null @@ -1,543 +0,0 @@ -// import { EnumType, jsonToGraphQLQuery } from 'json-to-graphql-query'; -// import { -// ProductPriceVariant, -// ProductVariant, -// ProductVariantSubscriptionPlan, -// ProductVariantSubscriptionPlanPeriod, -// ProductVariantSubscriptionMeteredVariable, -// ProductVariantSubscriptionPlanTier, -// ProductVariantSubscriptionPlanPricing, -// } from '../types/product.js'; -// import { -// createSubscriptionContractInputRequest, -// CreateSubscriptionContractInputRequest, -// SubscriptionContract, -// SubscriptionContractMeteredVariableReferenceInputRequest, -// SubscriptionContractMeteredVariableTierInputRequest, -// SubscriptionContractPhaseInput, -// updateSubscriptionContractInputRequest, -// UpdateSubscriptionContractInputRequest, -// } from '../types/subscription.js'; -// import { catalogueFetcherGraphqlBuilder, createCatalogueFetcher } from './catalogue/create-catalogue-fetcher.js'; -// import { ClientInterface } from './client/create-client.js'; - -// function convertDates(intent: CreateSubscriptionContractInputRequest | UpdateSubscriptionContractInputRequest) { -// if (!intent.status) { -// return { -// ...intent, -// }; -// } - -// let results: any = { -// ...intent, -// }; - -// if (intent.status.renewAt) { -// results = { -// ...results, -// status: { -// ...results.status, -// renewAt: intent.status.renewAt.toISOString(), -// }, -// }; -// } - -// if (intent.status.activeUntil) { -// results = { -// ...results, -// status: { -// ...results.status, -// activeUntil: intent.status.activeUntil.toISOString(), -// }, -// }; -// } -// return results; -// } - -// function convertEnums(intent: CreateSubscriptionContractInputRequest | UpdateSubscriptionContractInputRequest) { -// let results: any = { -// ...intent, -// }; - -// if (intent.initial && intent.initial.meteredVariables) { -// results = { -// ...results, -// initial: { -// ...intent.initial, -// meteredVariables: intent.initial.meteredVariables.map((variable: any) => { -// return { -// ...variable, -// tierType: typeof variable.tierType === 'string' ? variable.tierType : variable.tierType.value, -// }; -// }), -// }, -// }; -// } - -// if (intent.recurring && intent.recurring.meteredVariables) { -// results = { -// ...results, -// recurring: { -// ...intent.recurring, -// meteredVariables: intent.recurring.meteredVariables.map((variable: any) => { -// return { -// ...variable, -// tierType: typeof variable.tierType === 'string' ? variable.tierType : variable.tierType.value, -// }; -// }), -// }, -// }; -// } - -// return results; -// } - -// export function createSubscriptionContractManager(apiClient: ClientInterface) { -// const create = async ( -// intentSubsctiptionContract: CreateSubscriptionContractInputRequest, -// extraResultQuery?: any, -// ): Promise => { -// const intent = createSubscriptionContractInputRequest.parse(convertEnums(intentSubsctiptionContract)); -// const api = apiClient.pimApi; - -// const mutation = { -// mutation: { -// subscriptionContract: { -// create: { -// __args: { -// input: convertDates(intent), -// }, -// id: true, -// createdAt: true, -// ...(extraResultQuery !== undefined ? extraResultQuery : {}), -// }, -// }, -// }, -// }; -// const confirmation = await api(jsonToGraphQLQuery(mutation)); -// return confirmation.subscriptionContract.create; -// }; - -// const update = async ( -// id: string, -// intentSubsctiptionContract: UpdateSubscriptionContractInputRequest, -// extraResultQuery?: any, -// ): Promise => { -// const intent = updateSubscriptionContractInputRequest.parse(convertEnums(intentSubsctiptionContract)); -// const api = apiClient.pimApi; - -// const mutation = { -// mutation: { -// subscriptionContract: { -// update: { -// __args: { -// id, -// input: convertDates(intent), -// }, -// id: true, -// updatedAt: true, -// ...(extraResultQuery !== undefined ? extraResultQuery : {}), -// }, -// }, -// }, -// }; -// const confirmation = await api(jsonToGraphQLQuery(mutation)); -// return confirmation.subscriptionContract.update; -// }; - -// /** -// * This function assumes that the variant contains the subscriptions plans -// */ -// const createSubscriptionContractTemplateBasedOnVariant = async ( -// variant: ProductVariant, -// planIdentifier: string, -// periodId: string, -// priceVariantIdentifier: string, -// ) => { -// const matchingPlan: ProductVariantSubscriptionPlan | undefined = variant?.subscriptionPlans?.find( -// (plan: ProductVariantSubscriptionPlan) => plan.identifier === planIdentifier, -// ); -// const matchingPeriod: ProductVariantSubscriptionPlanPeriod | undefined = matchingPlan?.periods?.find( -// (period: ProductVariantSubscriptionPlanPeriod) => period.id === periodId, -// ); -// if (!matchingPlan || !matchingPeriod) { -// throw new Error( -// `Impossible to find the Subscription Plans for SKU ${variant.sku}, plan: ${planIdentifier}, period: ${periodId}`, -// ); -// } - -// const getPriceVariant = ( -// priceVariants: ProductPriceVariant[], -// identifier: string, -// ): ProductPriceVariant | undefined => { -// return priceVariants.find((priceVariant: ProductPriceVariant) => priceVariant.identifier === identifier); -// }; - -// const transformPeriod = (period: ProductVariantSubscriptionPlanPricing): SubscriptionContractPhaseInput => { -// return { -// currency: getPriceVariant(period.priceVariants || [], priceVariantIdentifier)?.currency || 'USD', -// price: getPriceVariant(period.priceVariants || [], priceVariantIdentifier)?.price || 0.0, -// meteredVariables: (period.meteredVariables || []).map( -// ( -// meteredVariable: ProductVariantSubscriptionMeteredVariable, -// ): SubscriptionContractMeteredVariableReferenceInputRequest => { -// return { -// id: meteredVariable.id, -// tierType: new EnumType(meteredVariable.tierType), -// tiers: meteredVariable.tiers.map( -// ( -// tier: ProductVariantSubscriptionPlanTier, -// ): SubscriptionContractMeteredVariableTierInputRequest => { -// return { -// threshold: tier.threshold, -// currency: -// getPriceVariant(tier.priceVariants || [], priceVariantIdentifier) -// ?.currency || 'USD', -// price: -// getPriceVariant(tier.priceVariants || [], priceVariantIdentifier)?.price || -// 0.0, -// }; -// }, -// ), -// }; -// }, -// ), -// }; -// }; -// const contract: Omit< -// CreateSubscriptionContractInputRequest, -// 'customerIdentifier' | 'payment' | 'addresses' | 'tenantId' | 'status' -// > = { -// item: { -// sku: variant.sku, -// name: variant.name || '', -// quantity: 1, -// imageUrl: variant.firstImage?.url || '', -// }, -// subscriptionPlan: { -// identifier: matchingPlan.identifier, -// periodId: matchingPeriod.id, -// }, -// initial: !matchingPeriod.initial ? undefined : transformPeriod(matchingPeriod.initial), -// recurring: !matchingPeriod.recurring ? undefined : transformPeriod(matchingPeriod.recurring), -// }; - -// return contract; -// }; - -// const createSubscriptionContractTemplateBasedOnVariantIdentity = async ( -// path: string, -// productVariantIdentifier: { sku?: string; id?: string }, -// planIdentifier: string, -// periodId: string, -// priceVariantIdentifier: string, -// language: string = 'en', -// ) => { -// if (!productVariantIdentifier.sku && !productVariantIdentifier.id) { -// throw new Error( -// `Impossible to find the Subscription Plans for Path ${path} with and empty Variant Identity`, -// ); -// } - -// // let's ask the catalog for the data we need to create the subscription contract template -// const fetcher = createCatalogueFetcher(apiClient); -// const builder = catalogueFetcherGraphqlBuilder; -// const data: any = await fetcher({ -// catalogue: { -// __args: { -// path, -// language, -// }, -// __on: [ -// builder.onProduct( -// {}, -// { -// onVariant: { -// id: true, -// name: true, -// sku: true, -// ...builder.onSubscriptionPlan(), -// }, -// }, -// ), -// ], -// }, -// }); - -// const matchingVariant: ProductVariant | undefined = data.catalogue?.variants?.find( -// (variant: ProductVariant) => { -// if (productVariantIdentifier.sku && variant.sku === productVariantIdentifier.sku) { -// return true; -// } -// if (productVariantIdentifier.id && variant.id === productVariantIdentifier.id) { -// return true; -// } -// return false; -// }, -// ); - -// if (!matchingVariant) { -// throw new Error( -// `Impossible to find the Subscription Plans for Path ${path} and Variant: (sku: ${productVariantIdentifier.sku} id: ${productVariantIdentifier.id}), plan: ${planIdentifier}, period: ${periodId} in lang: ${language}`, -// ); -// } - -// return createSubscriptionContractTemplateBasedOnVariant( -// matchingVariant, -// planIdentifier, -// periodId, -// priceVariantIdentifier, -// ); -// }; - -// const fetchById = async (id: string, onCustomer?: any, extraQuery?: any): Promise => { -// const query = { -// subscriptionContract: { -// get: { -// __args: { -// id, -// }, -// ...SubscriptionContractQuery(onCustomer, extraQuery), -// }, -// }, -// }; -// const data = await apiClient.pimApi(jsonToGraphQLQuery({ query })); -// return data.subscriptionContract.get; -// }; - -// const fetchByCustomerIdentifier = async ( -// customerIdentifier: string, -// extraQueryArgs?: any, -// onCustomer?: any, -// extraQuery?: any, -// ): Promise<{ -// pageInfo: { -// hasNextPage: boolean; -// hasPreviousPage: boolean; -// startCursor: string; -// endCursor: string; -// totalNodes: number; -// }; -// contracts: SubscriptionContract[]; -// }> => { -// const query = { -// subscriptionContract: { -// getMany: { -// __args: { -// customerIdentifier: customerIdentifier, -// tenantId: apiClient.config.tenantId, -// ...(extraQueryArgs !== undefined ? extraQueryArgs : {}), -// }, -// pageInfo: { -// hasPreviousPage: true, -// hasNextPage: true, -// startCursor: true, -// endCursor: true, -// totalNodes: true, -// }, -// edges: { -// cursor: true, -// node: SubscriptionContractQuery(onCustomer, extraQuery), -// }, -// }, -// }, -// }; -// const response = await apiClient.pimApi(jsonToGraphQLQuery({ query })); -// return { -// pageInfo: response.subscriptionContract.getMany.pageInfo, -// contracts: response.subscriptionContract.getMany?.edges?.map((edge: any) => edge.node) || [], -// }; -// }; - -// const getCurrentPhase = async (id: string): Promise<'initial' | 'recurring'> => { -// const query = { -// subscriptionContractEvent: { -// getMany: { -// __args: { -// subscriptionContractId: id, -// tenantId: apiClient.config.tenantId, -// sort: new EnumType('asc'), -// first: 1, -// eventTypes: new EnumType('renewed'), -// }, -// edges: { -// node: { -// id: true, -// }, -// }, -// }, -// }, -// }; -// const contractUsage = await apiClient.pimApi(jsonToGraphQLQuery({ query })); -// return contractUsage.subscriptionContractEvent.getMany.edges.length > 0 ? 'recurring' : 'initial'; -// }; - -// const getUsageForPeriod = async ( -// id: string, -// from: Date, -// to: Date, -// ): Promise< -// { -// meteredVariableId: string; -// quantity: number; -// }[] -// > => { -// const query = { -// subscriptionContract: { -// get: { -// __args: { -// id, -// }, -// id: true, -// usage: { -// __args: { -// start: from.toISOString(), -// end: to.toISOString(), -// }, -// meteredVariableId: true, -// quantity: true, -// }, -// }, -// }, -// }; -// const contractUsage = await apiClient.pimApi(jsonToGraphQLQuery({ query })); -// return contractUsage.subscriptionContract.get.usage; -// }; - -// return { -// create, -// update, -// fetchById, -// fetchByCustomerIdentifier, -// getCurrentPhase, -// getUsageForPeriod, -// createSubscriptionContractTemplateBasedOnVariantIdentity, -// createSubscriptionContractTemplateBasedOnVariant, -// }; -// } - -// const buildGenericSubscriptionContractQuery = (onCustomer?: any, extraQuery?: any) => { -// return { -// id: true, -// tenantId: true, -// subscriptionPlan: { -// name: true, -// identifier: true, -// meteredVariables: { -// id: true, -// identifier: true, -// name: true, -// unit: true, -// }, -// }, -// item: { -// name: true, -// sku: true, -// quantity: true, -// meta: { -// key: true, -// value: true, -// }, -// }, -// initial: { -// period: true, -// unit: true, -// price: true, -// currency: true, -// meteredVariables: { -// id: true, -// name: true, -// identifier: true, -// unit: true, -// tierType: true, -// tiers: { -// currency: true, -// threshold: true, -// price: true, -// }, -// }, -// }, -// recurring: { -// period: true, -// unit: true, -// price: true, -// currency: true, -// meteredVariables: { -// id: true, -// name: true, -// identifier: true, -// unit: true, -// tierType: true, -// tiers: { -// currency: true, -// threshold: true, -// price: true, -// }, -// }, -// }, -// status: { -// renewAt: true, -// activeUntil: true, -// price: true, -// currency: true, -// }, -// meta: { -// key: true, -// value: true, -// }, -// addresses: { -// type: true, -// lastName: true, -// firstName: true, -// email: true, -// middleName: true, -// street: true, -// street2: true, -// city: true, -// country: true, -// state: true, -// postalCode: true, -// phone: true, -// streetNumber: true, -// }, -// customerIdentifier: true, -// customer: { -// identifier: true, -// email: true, -// firstName: true, -// lastName: true, -// companyName: true, -// phone: true, -// taxNumber: true, -// meta: { -// key: true, -// value: true, -// }, -// externalReferences: { -// key: true, -// value: true, -// }, -// addresses: { -// type: true, -// lastName: true, -// firstName: true, -// email: true, -// middleName: true, -// street: true, -// street2: true, -// city: true, -// country: true, -// state: true, -// postalCode: true, -// phone: true, -// streetNumber: true, -// meta: { -// key: true, -// value: true, -// }, -// }, -// ...(onCustomer !== undefined ? onCustomer : {}), -// }, -// ...(extraQuery !== undefined ? extraQuery : {}), -// }; -// }; From 88d41d8e2c411c408309169464cb1509697204ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:47:11 -0700 Subject: [PATCH 04/34] fix(catalogue): simplify double spread to single spread in product hydrater The query object construction used `{ ...{ ...productListQuery } }` which is functionally identical to `{ ...productListQuery }`. Simplified to remove the unnecessary nesting. --- .../js-api-client/src/core/catalogue/create-product-hydrater.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/js-api-client/src/core/catalogue/create-product-hydrater.ts b/components/js-api-client/src/core/catalogue/create-product-hydrater.ts index abca70e8..a8e32938 100644 --- a/components/js-api-client/src/core/catalogue/create-product-hydrater.ts +++ b/components/js-api-client/src/core/catalogue/create-product-hydrater.ts @@ -87,7 +87,7 @@ function byPaths(client: ClientInterface, options?: ProductHydraterOptions): Pro }, {} as any); const query = { - ...{ ...productListQuery }, + ...productListQuery, ...(extraQuery !== undefined ? extraQuery : {}), }; From 146f8766b8babeeed0b703e1c9f2664522327456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:48:04 -0700 Subject: [PATCH 05/34] fix(mass-call): use object literal instead of array for results initialization The `results` variable was typed as `{ [key: string]: any }` but initialized as `[]`. While JS allows property assignment on arrays, this is misleading. Changed to `{}` to match the declared type and actual usage. --- components/js-api-client/src/core/create-mass-call-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/js-api-client/src/core/create-mass-call-client.ts b/components/js-api-client/src/core/create-mass-call-client.ts index 22d0af65..c2a3afa9 100644 --- a/components/js-api-client/src/core/create-mass-call-client.ts +++ b/components/js-api-client/src/core/create-mass-call-client.ts @@ -99,7 +99,7 @@ export function createMassCallClient( let batch = []; let results: { [key: string]: any; - } = []; + } = {}; do { let batchErrorCount = 0; const to = seek + increment; From be4265ce912ee8f6751125bae74aeb8bc94ac01a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:48:54 -0700 Subject: [PATCH 06/34] fix(package): correct module field to match actual ESM build output The module field pointed to ./dist/index.mjs which doesn't exist. The build outputs ./dist/index.js for ESM. This mismatch could cause import failures in bundlers (Webpack, Rollup) that use the module field. --- components/js-api-client/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/js-api-client/package.json b/components/js-api-client/package.json index 65715f32..0b21259d 100644 --- a/components/js-api-client/package.json +++ b/components/js-api-client/package.json @@ -26,7 +26,7 @@ }, "types": "./dist/index.d.ts", "main": "./dist/index.cjs", - "module": "./dist/index.mjs", + "module": "./dist/index.js", "devDependencies": { "@tsconfig/node22": "^22.0.2", "@types/node": "^24.2.0", From a903151f3e1043c61e70dc47c70db67ddaed5936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:49:39 -0700 Subject: [PATCH 07/34] fix(error): set name property on JSApiClientCallError for better debugging Stack traces and error.name now correctly show 'JSApiClientCallError' instead of generic 'Error', making it easier to identify API client errors in logs and error handlers. --- components/js-api-client/src/core/client/create-api-caller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/components/js-api-client/src/core/client/create-api-caller.ts b/components/js-api-client/src/core/client/create-api-caller.ts index e4a25116..cad1783e 100644 --- a/components/js-api-client/src/core/client/create-api-caller.ts +++ b/components/js-api-client/src/core/client/create-api-caller.ts @@ -27,6 +27,7 @@ export class JSApiClientCallError extends Error { variables: VariablesType; }) { super(message); + this.name = 'JSApiClientCallError'; this.code = code; this.statusText = statusText; this.errors = errors; From 46ec4d4fddf3dfaeb3864ca7f2a09643e287f872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:52:17 -0700 Subject: [PATCH 08/34] fix(types): replace 'any' with proper GrabOptions type in grabber and API caller The `RequestInit | any | undefined` union types collapsed to `any`, defeating TypeScript's type checking for all library consumers. Introduced a focused GrabOptions type covering method, headers, and body, and updated all signatures. --- .../src/core/client/create-api-caller.ts | 4 ++-- .../src/core/client/create-client.ts | 2 +- .../src/core/client/create-grabber.ts | 15 ++++++++++----- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/components/js-api-client/src/core/client/create-api-caller.ts b/components/js-api-client/src/core/client/create-api-caller.ts index cad1783e..b564379d 100644 --- a/components/js-api-client/src/core/client/create-api-caller.ts +++ b/components/js-api-client/src/core/client/create-api-caller.ts @@ -1,5 +1,5 @@ import { CreateClientOptions, ClientConfiguration } from './create-client.js'; -import { Grab } from './create-grabber.js'; +import { Grab, GrabOptions } from './create-grabber.js'; export type VariablesType = Record; export type ApiCaller = (query: string, variables?: VariablesType) => Promise; @@ -93,7 +93,7 @@ export const post = async ( config: ClientConfiguration, query: string, variables?: VariablesType, - init?: RequestInit | any | undefined, + init?: GrabOptions, options?: CreateClientOptions, ): Promise => { const { headers: initHeaders, ...initRest } = init || {}; diff --git a/components/js-api-client/src/core/client/create-client.ts b/components/js-api-client/src/core/client/create-client.ts index 18958b37..1adf28f9 100644 --- a/components/js-api-client/src/core/client/create-client.ts +++ b/components/js-api-client/src/core/client/create-client.ts @@ -29,7 +29,7 @@ export type ClientConfiguration = { export type CreateClientOptions = { useHttp2?: boolean; profiling?: ProfilingOptions; - extraHeaders?: RequestInit['headers']; + extraHeaders?: Record; shopApiToken?: { doNotFetch?: boolean; scopes?: string[]; diff --git a/components/js-api-client/src/core/client/create-grabber.ts b/components/js-api-client/src/core/client/create-grabber.ts index 81490987..63a22055 100644 --- a/components/js-api-client/src/core/client/create-grabber.ts +++ b/components/js-api-client/src/core/client/create-grabber.ts @@ -10,8 +10,13 @@ export type GrabResponse = { json: () => Promise; text: () => Promise; }; +export type GrabOptions = { + method?: string; + headers?: Record; + body?: string; +}; export type Grab = { - grab: (url: string, options?: RequestInit | any | undefined) => Promise; + grab: (url: string, options?: GrabOptions) => Promise; close: () => void; }; @@ -21,7 +26,7 @@ type Options = { export const createGrabber = (options?: Options): Grab => { const clients = new Map(); const IDLE_TIMEOUT = 300000; // 5 min idle timeout - const grab = async (url: string, grabOptions?: RequestInit | any): Promise => { + const grab = async (url: string, grabOptions?: GrabOptions): Promise => { if (options?.useHttp2 !== true) { return fetch(url, grabOptions); } @@ -62,12 +67,12 @@ export const createGrabber = (options?: Options): Grab => { const client = getClient(origin); resetIdleTimeout(origin); const headers = { - ':method': grabOptions.method || 'GET', + ':method': grabOptions?.method || 'GET', ':path': urlObj.pathname + urlObj.search, - ...grabOptions.headers, + ...grabOptions?.headers, }; const req = client.request(headers); - if (grabOptions.body) { + if (grabOptions?.body) { req.write(grabOptions.body); } req.setEncoding('utf8'); From 165b56fcd2156f8c4011436631a6878b70df1707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:53:13 -0700 Subject: [PATCH 09/34] fix(security): replace tracked .env with .env.example template Add .env to .gitignore and create .env.example with placeholder values to prevent accidental credential commits. --- components/js-api-client/.env.example | 12 ++++++++++++ components/js-api-client/.gitignore | 1 + 2 files changed, 13 insertions(+) create mode 100644 components/js-api-client/.env.example diff --git a/components/js-api-client/.env.example b/components/js-api-client/.env.example new file mode 100644 index 00000000..c180243a --- /dev/null +++ b/components/js-api-client/.env.example @@ -0,0 +1,12 @@ +# PROD +CRYSTALLIZE_TENANT_ID=your-tenant-id-here +CRYSTALLIZE_TENANT_IDENTIFIER=your-tenant-identifier-here +CRYSTALLIZE_ACCESS_TOKEN_ID=your-token-id-here +CRYSTALLIZE_ACCESS_TOKEN_SECRET=your-token-secret-here + +# # DEV +# CRYSTALLIZE_TENANT_ID=your-dev-tenant-id-here +# CRYSTALLIZE_TENANT_IDENTIFIER=your-dev-tenant-identifier-here +# CRYSTALLIZE_ACCESS_TOKEN_ID=your-dev-token-id-here +# CRYSTALLIZE_ACCESS_TOKEN_SECRET=your-dev-token-secret-here +# CRYSTALLIZE_ORIGIN=-dev.crystallize.digital diff --git a/components/js-api-client/.gitignore b/components/js-api-client/.gitignore index 70e72faa..b820f4d7 100644 --- a/components/js-api-client/.gitignore +++ b/components/js-api-client/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +.env yarn.lock package-lock.json From 7920727b2f29004db7a04d2567620e623a131904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:54:11 -0700 Subject: [PATCH 10/34] feat(auth): warn when no authentication credentials are configured Adds a console.warn when the fallback auth path is reached with empty accessTokenId and accessTokenSecret, helping developers catch missing auth configuration early instead of getting cryptic 401/403 errors. Uses a WeakSet to ensure the warning fires only once per config object. --- .../js-api-client/src/core/client/create-api-caller.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/components/js-api-client/src/core/client/create-api-caller.ts b/components/js-api-client/src/core/client/create-api-caller.ts index b564379d..7df1c471 100644 --- a/components/js-api-client/src/core/client/create-api-caller.ts +++ b/components/js-api-client/src/core/client/create-api-caller.ts @@ -58,6 +58,8 @@ export const createApiCaller = ( }; }; +const warnedConfigs = new WeakSet(); + export const authenticationHeaders = (config: ClientConfiguration): Record => { if (config.sessionId) { return { @@ -69,6 +71,13 @@ export const authenticationHeaders = (config: ClientConfiguration): Record Date: Thu, 12 Mar 2026 16:55:06 -0700 Subject: [PATCH 11/34] fix(profiling): use regex to parse Server-Timing header reliably Replace fragile split-based parsing with a regex that extracts the dur= value per the Server-Timing spec, avoiding garbage results from non-standard header formats. --- components/js-api-client/src/core/client/create-api-caller.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/js-api-client/src/core/client/create-api-caller.ts b/components/js-api-client/src/core/client/create-api-caller.ts index 7df1c471..710c5c13 100644 --- a/components/js-api-client/src/core/client/create-api-caller.ts +++ b/components/js-api-client/src/core/client/create-api-caller.ts @@ -137,7 +137,8 @@ export const post = async ( if (Array.isArray(serverTiming)) { serverTiming = serverTiming[0]; } - const duration = serverTiming?.split(';')[1]?.split('=')[1] ?? -1; + const durMatch = serverTiming?.match(/dur=([\d.]+)/); + const duration = durMatch ? durMatch[1] : -1; profiling.onRequestResolved( { resolutionTimeMs: ms, From 67192c6ab171f4b3ec40d9f4185cc7fd48063fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:58:21 -0700 Subject: [PATCH 12/34] refactor(types): rename cryptic generic parameters to descriptive names in fetchers/managers Replace abbreviations like OO, OOI, OC, OSC, OP with readable names (OrderExtra, OrderItemExtra, CustomerExtra, SubscriptionContractExtra, PaymentExtra) across order, customer, and subscription modules for better IDE tooltip readability. --- .../pim/customers/create-customer-fetcher.ts | 10 ++--- .../pim/customers/create-customer-manager.ts | 32 +++++++------- .../core/pim/orders/create-order-fetcher.ts | 26 ++++++------ .../core/pim/orders/create-order-manager.ts | 42 +++++++++---------- .../create-subscription-contract-fetcher.ts | 18 ++++---- .../create-subscription-contract-manager.ts | 26 ++++++------ 6 files changed, 77 insertions(+), 77 deletions(-) diff --git a/components/js-api-client/src/core/pim/customers/create-customer-fetcher.ts b/components/js-api-client/src/core/pim/customers/create-customer-fetcher.ts index 79794a43..879a2edd 100644 --- a/components/js-api-client/src/core/pim/customers/create-customer-fetcher.ts +++ b/components/js-api-client/src/core/pim/customers/create-customer-fetcher.ts @@ -4,7 +4,7 @@ import { Customer } from '@crystallize/schema/pim'; export type DefaultCustomerType = R & Required>; -const buildBaseQuery = (onCustomer?: OC) => { +const buildBaseQuery = (onCustomer?: CustomerExtra) => { return { identifier: true, email: true, @@ -15,9 +15,9 @@ const buildBaseQuery = (onCustomer?: OC) => { }; export const createCustomerFetcher = (apiClient: ClientInterface) => { - const fetchByIdentifier = async ( + const fetchByIdentifier = async ( identifier: string, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise | null> => { const query = { customer: { @@ -42,9 +42,9 @@ export const createCustomerFetcher = (apiClient: ClientInterface) => { ).customer; }; - const fetchByExternalReference = async ( + const fetchByExternalReference = async ( { key, value }: { key: string; value?: string }, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise | null> => { const query = { customer: { diff --git a/components/js-api-client/src/core/pim/customers/create-customer-manager.ts b/components/js-api-client/src/core/pim/customers/create-customer-manager.ts index 5ada9bfe..745619c7 100644 --- a/components/js-api-client/src/core/pim/customers/create-customer-manager.ts +++ b/components/js-api-client/src/core/pim/customers/create-customer-manager.ts @@ -13,9 +13,9 @@ import { createCustomerFetcher } from './create-customer-fetcher.js'; type WithIdentifier = R & { identifier: string }; export const createCustomerManager = (apiClient: ClientInterface) => { - const create = async ( + const create = async ( intentCustomer: CreateCustomerInput, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise> => { const input = CreateCustomerInputSchema.parse(intentCustomer); const mutation = { @@ -43,9 +43,9 @@ export const createCustomerManager = (apiClient: ClientInterface) => { return confirmation.createCustomer; }; - const update = async ( + const update = async ( intentCustomer: UpdateCustomerInput, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise> => { const { identifier, ...input } = UpdateCustomerInputSchema.parse(intentCustomer); const mutation = { @@ -75,30 +75,30 @@ export const createCustomerManager = (apiClient: ClientInterface) => { }; // this is overriding completely the previous meta (there is no merge method yes on the API) - const setMeta = async ( + const setMeta = async ( identifier: string, intentMeta: NonNullable, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise> => { const meta = UpdateCustomerInputSchema.shape.meta.parse(intentMeta); - return await update({ identifier, meta }, onCustomer); + return await update({ identifier, meta }, onCustomer); }; // this is overriding completely the previous references (there is no merge method yes on the API) - const setExternalReference = async ( + const setExternalReference = async ( identifier: string, intentReferences: NonNullable, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise> => { const references = UpdateCustomerInputSchema.shape.meta.parse(intentReferences); - return await update({ identifier, externalReferences: references }, onCustomer); + return await update({ identifier, externalReferences: references }, onCustomer); }; - const setMetaKey = async ( + const setMetaKey = async ( identifier: string, key: string, value: string, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise> => { const fetcher = createCustomerFetcher(apiClient); const customer = await fetcher.byIdentifier<{ meta: Customer['meta'] }>(identifier, { @@ -114,14 +114,14 @@ export const createCustomerManager = (apiClient: ClientInterface) => { const newMeta = existingMeta.filter((m) => m.key !== key).concat({ key, value }) as NonNullable< UpdateCustomerInput['meta'] >; - return await setMeta(identifier, newMeta, onCustomer); + return await setMeta(identifier, newMeta, onCustomer); }; - const setExternalReferenceKey = async ( + const setExternalReferenceKey = async ( identifier: string, key: string, value: string, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise> => { const fetcher = createCustomerFetcher(apiClient); const customer = await fetcher.byIdentifier<{ externalReferences: Customer['externalReferences'] }>( @@ -140,7 +140,7 @@ export const createCustomerManager = (apiClient: ClientInterface) => { const newReferences = existingReferences.filter((m) => m.key !== key).concat({ key, value }) as NonNullable< UpdateCustomerInput['externalReferences'] >; - return await setExternalReference(identifier, newReferences, onCustomer); + return await setExternalReference(identifier, newReferences, onCustomer); }; return { create, diff --git a/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts b/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts index bdc81c30..8a206c81 100644 --- a/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts +++ b/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts @@ -20,7 +20,7 @@ export type DefaultOrderType; } & OnOrder; -const buildBaseQuery = (onOrder?: OO, onOrderItem?: OOI, onCustomer?: OC) => { +const buildBaseQuery = (onOrder?: OrderExtra, onOrderItem?: OrderItemExtra, onCustomer?: CustomerExtra) => { const priceQuery = { gross: true, net: true, @@ -62,10 +62,10 @@ type PageInfo = { endCursor: string; }; -type EnhanceQuery = { - onOrder?: OO; - onOrderItem?: OOI; - onCustomer?: OC; +type EnhanceQuery = { + onOrder?: OrderExtra; + onOrderItem?: OrderItemExtra; + onCustomer?: CustomerExtra; }; export function createOrderFetcher(apiClient: ClientInterface) { @@ -74,13 +74,13 @@ export function createOrderFetcher(apiClient: ClientInterface) { OnOrderItem = unknown, OnCustomer = unknown, EA extends Record = Record, - OC = unknown, - OOI = unknown, - OO = unknown, + CustomerExtra = unknown, + OrderItemExtra = unknown, + OrderExtra = unknown, >( customerIdentifier: string, extraArgs?: EA & { filter?: Record & { customer?: Record } }, - enhancements?: EnhanceQuery, + enhancements?: EnhanceQuery, ): Promise<{ pageInfo: PageInfo; orders: Array>; @@ -142,12 +142,12 @@ export function createOrderFetcher(apiClient: ClientInterface) { OnOrder = unknown, OnOrderItem = unknown, OnCustomer = unknown, - OC = unknown, - OOI = unknown, - OO = unknown, + CustomerExtra = unknown, + OrderItemExtra = unknown, + OrderExtra = unknown, >( id: string, - enhancements?: EnhanceQuery, + enhancements?: EnhanceQuery, ): Promise | null> => { const query = { order: { diff --git a/components/js-api-client/src/core/pim/orders/create-order-manager.ts b/components/js-api-client/src/core/pim/orders/create-order-manager.ts index 65ea1dfb..b5ee6143 100644 --- a/components/js-api-client/src/core/pim/orders/create-order-manager.ts +++ b/components/js-api-client/src/core/pim/orders/create-order-manager.ts @@ -9,7 +9,7 @@ import { ClientInterface } from '../../client/create-client.js'; import { jsonToGraphQLQuery } from 'json-to-graphql-query'; import { transformOrderInput } from './helpers.js'; -const baseQuery = (enhancements?: { onCustomer?: OC; onOrder?: OO }) => ({ +const baseQuery = (enhancements?: { onCustomer?: CustomerExtra; onOrder?: OrderExtra }) => ({ __on: [ { __typeName: 'Order', @@ -63,9 +63,9 @@ export const createOrderManager = (apiClient: ClientInterface) => { }; // --- - const update = async ( + const update = async ( intentOrder: UpdateOrderInput, - onOrder?: OO, + onOrder?: OrderExtra, ): Promise> & OnOrder> => { const { id, ...input } = UpdateOrderInputSchema.parse(intentOrder); const mutation = { @@ -101,16 +101,16 @@ export const createOrderManager = (apiClient: ClientInterface) => { pipelineId: string; stageId: string; }; - type PutInPipelineStageEnhancedQuery = { - onCustomer?: OC; - onOrder?: OO; + type PutInPipelineStageEnhancedQuery = { + onCustomer?: CustomerExtra; + onOrder?: OrderExtra; }; type PutInPipelineStageDefaultOrderType = Required> & { customer: Required, 'identifier'>> & OnCustomer; } & OnOrder; - const putInPipelineStage = async ( + const putInPipelineStage = async ( { id, pipelineId, stageId }: PutInPipelineStageArgs, - enhancements?: PutInPipelineStageEnhancedQuery, + enhancements?: PutInPipelineStageEnhancedQuery, ): Promise> => { const mutation = { updateOrderPipelineStage: { @@ -133,16 +133,16 @@ export const createOrderManager = (apiClient: ClientInterface) => { id: string; pipelineId: string; }; - type RemoveFromPipelineEnhancedQuery = { - onCustomer?: OC; - onOrder?: OO; + type RemoveFromPipelineEnhancedQuery = { + onCustomer?: CustomerExtra; + onOrder?: OrderExtra; }; type RemoveFromPipelineDefaultOrderType = Required> & { customer: Required, 'identifier'>> & OnCustomer; } & OnOrder; - const removeFromPipeline = async ( + const removeFromPipeline = async ( { id, pipelineId }: RemoveFromPipelineArgs, - enhancements?: RemoveFromPipelineEnhancedQuery, + enhancements?: RemoveFromPipelineEnhancedQuery, ): Promise> => { const mutation = { deleteOrderPipeline: { @@ -160,10 +160,10 @@ export const createOrderManager = (apiClient: ClientInterface) => { }; // --- - type SetPaymentsEnhancedQuery = { - onCustomer?: OC; - onPayment?: OP; - onOrder?: OO; + type SetPaymentsEnhancedQuery = { + onCustomer?: CustomerExtra; + onPayment?: PaymentExtra; + onOrder?: OrderExtra; }; type SetPaymentsDefaultOrderType = Required> & { customer: Required, 'identifier'>> & OnCustomer; @@ -173,13 +173,13 @@ export const createOrderManager = (apiClient: ClientInterface) => { OnCustomer = unknown, OnPayment = unknown, OnOrder = unknown, - OC = unknown, - OP = unknown, - OO = unknown, + CustomerExtra = unknown, + PaymentExtra = unknown, + OrderExtra = unknown, >( id: string, payments: UpdateOrderInput['payment'], - enhancements?: SetPaymentsEnhancedQuery, + enhancements?: SetPaymentsEnhancedQuery, ): Promise> => { const paymentSchema = UpdateOrderInputSchema.shape.payment; const input = paymentSchema.parse(payments); diff --git a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-fetcher.ts b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-fetcher.ts index 8cef4889..3bc66e98 100644 --- a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-fetcher.ts +++ b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-fetcher.ts @@ -19,7 +19,7 @@ type DefaultSubscriptionContractType = Requi customer: Required, 'identifier'>> & OnCustomer; } & OnSubscriptionContract; -const buildBaseQuery = (onSubscriptionContract?: OSC, onCustomer?: OC) => { +const buildBaseQuery = (onSubscriptionContract?: SubscriptionContractExtra, onCustomer?: CustomerExtra) => { const phaseQuery = { period: true, unit: true, @@ -108,15 +108,15 @@ type PageInfo = { endCursor: string; }; -type EnhanceQuery = { - onSubscriptionContract?: OSC; - onCustomer?: OC; +type EnhanceQuery = { + onSubscriptionContract?: SubscriptionContractExtra; + onCustomer?: CustomerExtra; }; export const createSubscriptionContractFetcher = (apiClient: ClientInterface) => { - const fetchById = async ( + const fetchById = async ( id: string, - enhancements?: EnhanceQuery, + enhancements?: EnhanceQuery, ): Promise | null> => { const query = { subscriptionContract: { @@ -147,12 +147,12 @@ export const createSubscriptionContractFetcher = (apiClient: ClientInterface) => OnSubscriptionContract = unknown, OnCustomer = unknown, EA extends Record = Record, - OSC = unknown, - OC = unknown, + SubscriptionContractExtra = unknown, + CustomerExtra = unknown, >( customerIdentifier: string, extraArgs?: EA & { filter?: Record }, - enhancements?: EnhanceQuery, + enhancements?: EnhanceQuery, ): Promise<{ pageInfo: PageInfo; subscriptionContracts: Array>; diff --git a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts index aaa6b1aa..b4dc7d91 100644 --- a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts +++ b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts @@ -25,7 +25,7 @@ type WithIdentifiersAndStatus = R & { } & (R extends { status: infer S } ? S : {}); }; -const baseQuery = }>(onSubscriptionContract?: OSC) => ({ +const baseQuery = }>(onSubscriptionContract?: SubscriptionContractExtra) => ({ __on: [ { __typeName: 'SubscriptionContractAggregate', @@ -46,9 +46,9 @@ const baseQuery = }>(onSubscript }); export const createSubscriptionContractManager = (apiClient: ClientInterface) => { - const create = async } = {}>( + const create = async } = {}>( intentSubscriptionContract: CreateSubscriptionContractInput, - onSubscriptionContract?: OSC, + onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { const input = CreateSubscriptionContractInputSchema.parse(intentSubscriptionContract); const api = apiClient.nextPimApi; @@ -82,9 +82,9 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.createSubscriptionContract; }; - const update = async } = {}>( + const update = async } = {}>( intentSubscriptionContract: UpdateSubscriptionContractInput, - onSubscriptionContract?: OSC, + onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { const { id, ...input } = UpdateSubscriptionContractInputSchema.parse(intentSubscriptionContract); const api = apiClient.nextPimApi; @@ -103,10 +103,10 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.updateSubscriptionContract; }; - const cancel = async } = {}>( + const cancel = async } = {}>( id: UpdateSubscriptionContractInput['id'], deactivate = false, - onSubscriptionContract?: OSC, + onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { const api = apiClient.nextPimApi; const mutation = { @@ -126,9 +126,9 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.cancelSubscriptionContract; }; - const pause = async } = {}>( + const pause = async } = {}>( id: UpdateSubscriptionContractInput['id'], - onSubscriptionContract?: OSC, + onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { const api = apiClient.nextPimApi; const mutation = { @@ -145,9 +145,9 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.pauseSubscriptionContract; }; - const resume = async } = {}>( + const resume = async } = {}>( id: UpdateSubscriptionContractInput['id'], - onSubscriptionContract?: OSC, + onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { const api = apiClient.nextPimApi; const mutation = { @@ -164,9 +164,9 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.resumeSubscriptionContract; }; - const renew = async } = {}>( + const renew = async } = {}>( id: UpdateSubscriptionContractInput['id'], - onSubscriptionContract?: OSC, + onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { const api = apiClient.nextPimApi; const mutation = { From 763f4ff07f9cccf55bcc48cb39d991ce2c42215c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:59:59 -0700 Subject: [PATCH 13/34] feat(client): add Symbol.dispose support for automatic cleanup Enables `using client = createClient({...})` syntax (TypeScript 5.2+) so HTTP/2 connections are automatically closed when the scope exits. Added `esnext.disposable` to tsconfig lib and implemented Symbol.dispose on both ClientInterface and MassClientInterface. --- components/js-api-client/src/core/client/create-client.ts | 2 ++ components/js-api-client/src/core/create-mass-call-client.ts | 1 + components/js-api-client/tsconfig.json | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/components/js-api-client/src/core/client/create-client.ts b/components/js-api-client/src/core/client/create-client.ts index 1adf28f9..da9e71c2 100644 --- a/components/js-api-client/src/core/client/create-client.ts +++ b/components/js-api-client/src/core/client/create-client.ts @@ -13,6 +13,7 @@ export type ClientInterface = { shopCartApi: ApiCaller; config: Pick; close: () => void; + [Symbol.dispose]: () => void; }; export type ClientConfiguration = { tenantIdentifier: string; @@ -100,5 +101,6 @@ export const createClient = (configuration: ClientConfiguration, options?: Creat origin: configuration.origin, }, close: grabClose, + [Symbol.dispose]: grabClose, }; }; diff --git a/components/js-api-client/src/core/create-mass-call-client.ts b/components/js-api-client/src/core/create-mass-call-client.ts index c2a3afa9..b55fb8a2 100644 --- a/components/js-api-client/src/core/create-mass-call-client.ts +++ b/components/js-api-client/src/core/create-mass-call-client.ts @@ -206,6 +206,7 @@ export function createMassCallClient( nextPimApi: client.nextPimApi, config: client.config, close: client.close, + [Symbol.dispose]: client[Symbol.dispose], enqueue: { catalogueApi: (query: string, variables?: VariablesType): string => { const key = `catalogueApi-${counter++}`; diff --git a/components/js-api-client/tsconfig.json b/components/js-api-client/tsconfig.json index 0c1b91d2..baddaa4d 100644 --- a/components/js-api-client/tsconfig.json +++ b/components/js-api-client/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "./dist", "declaration": true, - "lib": ["es2021", "DOM"], + "lib": ["es2021", "DOM", "esnext.disposable"], "sourceMap": true }, "include": ["./src/**/*"] From 5ed77623cb6c777e346b251eae70eba22f30f5d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 17:01:25 -0700 Subject: [PATCH 14/34] feat(client): add request timeout support via AbortController Add optional `timeout` field (in milliseconds) to CreateClientOptions. When configured, requests that exceed the timeout are automatically aborted using AbortSignal.timeout(). Works for both fetch and HTTP/2 code paths. Default is no timeout (backward compatible). --- .../src/core/client/create-api-caller.ts | 4 ++++ .../src/core/client/create-client.ts | 2 ++ .../src/core/client/create-grabber.ts | 21 ++++++++++++++++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/components/js-api-client/src/core/client/create-api-caller.ts b/components/js-api-client/src/core/client/create-api-caller.ts index 710c5c13..98988ea9 100644 --- a/components/js-api-client/src/core/client/create-api-caller.ts +++ b/components/js-api-client/src/core/client/create-api-caller.ts @@ -124,11 +124,15 @@ export const post = async ( } } + const timeout = options?.timeout; + const signal = timeout ? AbortSignal.timeout(timeout) : undefined; + const response = await grab(path, { ...initRest, method: 'POST', headers, body, + signal, }); if (profiling) { diff --git a/components/js-api-client/src/core/client/create-client.ts b/components/js-api-client/src/core/client/create-client.ts index da9e71c2..c617b9b5 100644 --- a/components/js-api-client/src/core/client/create-client.ts +++ b/components/js-api-client/src/core/client/create-client.ts @@ -31,6 +31,8 @@ export type CreateClientOptions = { useHttp2?: boolean; profiling?: ProfilingOptions; extraHeaders?: Record; + /** Request timeout in milliseconds. When set, requests that take longer will be aborted. */ + timeout?: number; shopApiToken?: { doNotFetch?: boolean; scopes?: string[]; diff --git a/components/js-api-client/src/core/client/create-grabber.ts b/components/js-api-client/src/core/client/create-grabber.ts index 63a22055..7912be84 100644 --- a/components/js-api-client/src/core/client/create-grabber.ts +++ b/components/js-api-client/src/core/client/create-grabber.ts @@ -14,6 +14,7 @@ export type GrabOptions = { method?: string; headers?: Record; body?: string; + signal?: AbortSignal; }; export type Grab = { grab: (url: string, options?: GrabOptions) => Promise; @@ -28,7 +29,8 @@ export const createGrabber = (options?: Options): Grab => { const IDLE_TIMEOUT = 300000; // 5 min idle timeout const grab = async (url: string, grabOptions?: GrabOptions): Promise => { if (options?.useHttp2 !== true) { - return fetch(url, grabOptions); + const { signal, ...fetchOptions } = grabOptions || {}; + return fetch(url, { ...fetchOptions, signal }); } const closeAndDeleteClient = (origin: string) => { const clientObj = clients.get(origin); @@ -72,6 +74,23 @@ export const createGrabber = (options?: Options): Grab => { ...grabOptions?.headers, }; const req = client.request(headers); + + if (grabOptions?.signal) { + const signal = grabOptions.signal; + if (signal.aborted) { + req.close(); + reject(signal.reason); + return; + } + const onAbort = () => { + req.close(); + reject(signal.reason); + }; + signal.addEventListener('abort', onAbort, { once: true }); + req.on('end', () => signal.removeEventListener('abort', onAbort)); + req.on('error', () => signal.removeEventListener('abort', onAbort)); + } + if (grabOptions?.body) { req.write(grabOptions.body); } From 795dca8760e89efdbe33dc648029753c00133901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 17:03:01 -0700 Subject: [PATCH 15/34] refactor(types): replace any with proper types in mass call client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace Promise with Promise (Record) - Replace any in afterRequest callback with Record - Replace any for exception in onFailure with unknown - Type buildStandardPromise return as { key: string; result: unknown } | undefined - Fix typo: situaion → situation in changeIncrementFor parameter --- .../src/core/create-mass-call-client.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/components/js-api-client/src/core/create-mass-call-client.ts b/components/js-api-client/src/core/create-mass-call-client.ts index b55fb8a2..5e258564 100644 --- a/components/js-api-client/src/core/create-mass-call-client.ts +++ b/components/js-api-client/src/core/create-mass-call-client.ts @@ -14,12 +14,14 @@ export type MassCallClientBatch = { }; export type QueuedApiCaller = (query: string, variables?: VariablesType) => string; +export type MassCallResults = Record; + export type MassClientInterface = ClientInterface & { - execute: () => Promise; + execute: () => Promise; reset: () => void; hasFailed: () => boolean; failureCount: () => number; - retry: () => Promise; + retry: () => Promise; catalogueApi: ApiCaller; discoveryApi: ApiCaller; pimApi: ApiCaller; @@ -74,15 +76,15 @@ export function createMassCallClient( maxSpawn?: number; onBatchDone?: (batch: MassCallClientBatch) => Promise; beforeRequest?: (batch: MassCallClientBatch, promise: CrystallizePromise) => Promise; - afterRequest?: (batch: MassCallClientBatch, promise: CrystallizePromise, results: any) => Promise; + afterRequest?: (batch: MassCallClientBatch, promise: CrystallizePromise, results: Record) => Promise; onFailure?: ( batch: { from: number; to: number }, - exception: any, + exception: unknown, promise: CrystallizePromise, ) => Promise; sleeper?: Sleeper; changeIncrementFor?: ( - situaion: 'more-than-half-have-failed' | 'some-have-failed' | 'none-have-failed', + situation: 'more-than-half-have-failed' | 'some-have-failed' | 'none-have-failed', currentIncrement: number, ) => number; }, @@ -97,16 +99,14 @@ export function createMassCallClient( const execute = async () => { failedPromises = []; let batch = []; - let results: { - [key: string]: any; - } = {}; + let results: MassCallResults = {}; do { let batchErrorCount = 0; const to = seek + increment; batch = promises.slice(seek, to); const batchResults = await Promise.all( batch.map(async (promise: CrystallizePromise) => { - const buildStandardPromise = async (promise: CrystallizePromise): Promise => { + const buildStandardPromise = async (promise: CrystallizePromise): Promise<{ key: string; result: unknown } | undefined> => { try { return { key: promise.key, @@ -129,7 +129,7 @@ export function createMassCallClient( } // otherwise we wrap it - return new Promise(async (resolve) => { + return new Promise<{ key: string; result: unknown } | undefined>(async (resolve) => { let alteredPromise; if (options.beforeRequest) { alteredPromise = await options.beforeRequest({ from: seek, to: to }, promise); From 2e64734918df7d093180d20be23361da4a897fe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 17:04:02 -0700 Subject: [PATCH 16/34] refactor(mass-call): replace repetitive enqueue methods with generated approach The five identical enqueue methods (catalogueApi, discoveryApi, pimApi, nextPimApi, shopCartApi) differed only in their key prefix and caller reference. Replaced with Object.fromEntries to eliminate ~20 lines of boilerplate while preserving the public API and types. --- .../src/core/create-mass-call-client.ts | 37 +++++-------------- 1 file changed, 10 insertions(+), 27 deletions(-) diff --git a/components/js-api-client/src/core/create-mass-call-client.ts b/components/js-api-client/src/core/create-mass-call-client.ts index 5e258564..af60a030 100644 --- a/components/js-api-client/src/core/create-mass-call-client.ts +++ b/components/js-api-client/src/core/create-mass-call-client.ts @@ -207,32 +207,15 @@ export function createMassCallClient( config: client.config, close: client.close, [Symbol.dispose]: client[Symbol.dispose], - enqueue: { - catalogueApi: (query: string, variables?: VariablesType): string => { - const key = `catalogueApi-${counter++}`; - promises.push({ key, caller: client.catalogueApi, query, variables }); - return key; - }, - discoveryApi: (query: string, variables?: VariablesType): string => { - const key = `discoveryApi-${counter++}`; - promises.push({ key, caller: client.discoveryApi, query, variables }); - return key; - }, - pimApi: (query: string, variables?: VariablesType): string => { - const key = `pimApi-${counter++}`; - promises.push({ key, caller: client.pimApi, query, variables }); - return key; - }, - nextPimApi: (query: string, variables?: VariablesType): string => { - const key = `nextPimApi-${counter++}`; - promises.push({ key, caller: client.nextPimApi, query, variables }); - return key; - }, - shopCartApi: (query: string, variables?: VariablesType): string => { - const key = `shopCartApi-${counter++}`; - promises.push({ key, caller: client.shopCartApi, query, variables }); - return key; - }, - }, + enqueue: Object.fromEntries( + (['catalogueApi', 'discoveryApi', 'pimApi', 'nextPimApi', 'shopCartApi'] as const).map((apiName) => [ + apiName, + (query: string, variables?: VariablesType): string => { + const key = `${apiName}-${counter++}`; + promises.push({ key, caller: client[apiName], query, variables }); + return key; + }, + ]), + ) as MassClientInterface['enqueue'], }; } From bd8b7b7b5e63c64765066513b59f598b71da8c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 17:07:07 -0700 Subject: [PATCH 17/34] test: add unit tests with mocked HTTP for core modules Add 38 unit tests covering create-api-caller, create-grabber, and create-mass-call-client without requiring API credentials or network access. Tests cover: authentication headers, successful responses, HTTP errors, GraphQL errors, Core Next wrapped errors, 204 handling, profiling, mass call batching, retry logic, and adaptive concurrency. --- .../tests/unit/create-api-caller.test.ts | 277 ++++++++++++++++++ .../tests/unit/create-grabber.test.ts | 78 +++++ .../unit/create-mass-call-client.test.ts | 228 ++++++++++++++ 3 files changed, 583 insertions(+) create mode 100644 components/js-api-client/tests/unit/create-api-caller.test.ts create mode 100644 components/js-api-client/tests/unit/create-grabber.test.ts create mode 100644 components/js-api-client/tests/unit/create-mass-call-client.test.ts diff --git a/components/js-api-client/tests/unit/create-api-caller.test.ts b/components/js-api-client/tests/unit/create-api-caller.test.ts new file mode 100644 index 00000000..7efde203 --- /dev/null +++ b/components/js-api-client/tests/unit/create-api-caller.test.ts @@ -0,0 +1,277 @@ +import { describe, test, expect, vi } from 'vitest'; +import { createApiCaller, post, authenticationHeaders, JSApiClientCallError } from '../../src/core/client/create-api-caller.js'; +import type { Grab, GrabResponse } from '../../src/core/client/create-grabber.js'; +import type { ClientConfiguration } from '../../src/core/client/create-client.js'; + +const mockGrabResponse = (overrides: Partial & { jsonData?: unknown } = {}): GrabResponse => { + const { jsonData = { data: { test: true } }, ...rest } = overrides; + return { + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null }, + json: () => Promise.resolve(jsonData as any), + text: () => Promise.resolve(JSON.stringify(jsonData)), + ...rest, + }; +}; + +const mockGrab = (response: GrabResponse): Grab['grab'] => { + return vi.fn().mockResolvedValue(response); +}; + +const defaultConfig: ClientConfiguration = { + tenantIdentifier: 'test-tenant', + tenantId: 'test-id', + accessTokenId: 'token-id', + accessTokenSecret: 'token-secret', +}; + +describe('authenticationHeaders', () => { + test('returns session cookie when sessionId is set', () => { + const headers = authenticationHeaders({ ...defaultConfig, sessionId: 'sess123' }); + expect(headers).toEqual({ Cookie: 'connect.sid=sess123' }); + }); + + test('returns static auth token when set (and no sessionId)', () => { + const headers = authenticationHeaders({ ...defaultConfig, staticAuthToken: 'static-tok' }); + expect(headers).toEqual({ 'X-Crystallize-Static-Auth-Token': 'static-tok' }); + }); + + test('sessionId takes priority over staticAuthToken', () => { + const headers = authenticationHeaders({ + ...defaultConfig, + sessionId: 'sess123', + staticAuthToken: 'static-tok', + }); + expect(headers).toEqual({ Cookie: 'connect.sid=sess123' }); + }); + + test('returns access token headers when no session or static token', () => { + const headers = authenticationHeaders(defaultConfig); + expect(headers).toEqual({ + 'X-Crystallize-Access-Token-Id': 'token-id', + 'X-Crystallize-Access-Token-Secret': 'token-secret', + }); + }); + + test('warns when no auth is configured', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config: ClientConfiguration = { tenantIdentifier: 'test' }; + authenticationHeaders(config); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No authentication credentials configured')); + warnSpy.mockRestore(); + }); + + test('warns only once per config object', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config: ClientConfiguration = { tenantIdentifier: 'test-once' }; + authenticationHeaders(config); + authenticationHeaders(config); + authenticationHeaders(config); + expect(warnSpy).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); +}); + +describe('post', () => { + test('successful response returns data', async () => { + const grab = mockGrab(mockGrabResponse({ jsonData: { data: { items: [1, 2, 3] } } })); + const result = await post(grab, 'https://api.test.com', defaultConfig, '{ items }'); + expect(result).toEqual({ items: [1, 2, 3] }); + }); + + test('passes query and variables in request body', async () => { + const grab = mockGrab(mockGrabResponse()); + await post(grab, 'https://api.test.com', defaultConfig, '{ items }', { limit: 10 }); + expect(grab).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ query: '{ items }', variables: { limit: 10 } }), + })); + }); + + test('includes authentication headers', async () => { + const grab = mockGrab(mockGrabResponse()); + await post(grab, 'https://api.test.com', defaultConfig, '{ items }'); + expect(grab).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Crystallize-Access-Token-Id': 'token-id', + 'X-Crystallize-Access-Token-Secret': 'token-secret', + 'Content-type': 'application/json; charset=UTF-8', + }), + })); + }); + + test('204 No Content returns empty object', async () => { + const grab = mockGrab(mockGrabResponse({ ok: true, status: 204, statusText: 'No Content' })); + const result = await post(grab, 'https://api.test.com', defaultConfig, 'mutation { delete }'); + expect(result).toEqual({}); + }); + + test('throws JSApiClientCallError on HTTP error', async () => { + const grab = mockGrab(mockGrabResponse({ + ok: false, + status: 401, + statusText: 'Unauthorized', + jsonData: { message: 'Invalid credentials', errors: [{ field: 'token' }] }, + })); + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ items }'); + expect.unreachable('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(JSApiClientCallError); + const err = e as JSApiClientCallError; + expect(err.name).toBe('JSApiClientCallError'); + expect(err.code).toBe(401); + expect(err.statusText).toBe('Unauthorized'); + expect(err.query).toBe('{ items }'); + } + }); + + test('throws on GraphQL errors in 200 response', async () => { + const grab = mockGrab(mockGrabResponse({ + jsonData: { + errors: [{ message: 'Field "foo" not found' }], + data: null, + }, + })); + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ foo }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.code).toBe(400); + expect(err.message).toBe('Field "foo" not found'); + expect(err.statusText).toBe('Error was returned from the API'); + } + }); + + test('detects Core Next wrapped errors', async () => { + const grab = mockGrab(mockGrabResponse({ + jsonData: { + data: { + someOperation: { + errorName: 'ItemNotFound', + message: 'The item does not exist', + }, + }, + }, + })); + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ someOperation }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.code).toBe(400); + expect(err.message).toBe('[ItemNotFound] The item does not exist'); + expect(err.statusText).toContain('Core Next'); + } + }); + + test('Core Next error without message uses fallback', async () => { + const grab = mockGrab(mockGrabResponse({ + jsonData: { + data: { + op: { errorName: 'GenericError' }, + }, + }, + })); + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ op }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.message).toBe('[GenericError] An error occurred'); + } + }); + + test('includes extra headers from options', async () => { + const grab = mockGrab(mockGrabResponse()); + await post(grab, 'https://api.test.com', defaultConfig, '{ items }', undefined, { + headers: { 'X-Custom': 'value' }, + }); + expect(grab).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ + headers: expect.objectContaining({ 'X-Custom': 'value' }), + })); + }); + + test('error includes query and variables for debugging', async () => { + const grab = mockGrab(mockGrabResponse({ + ok: false, status: 500, statusText: 'Internal Server Error', + jsonData: { message: 'Server error', errors: [] }, + })); + const variables = { id: '123' }; + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ item(id: $id) }', variables); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.query).toBe('{ item(id: $id) }'); + expect(err.variables).toEqual({ id: '123' }); + } + }); +}); + +describe('createApiCaller', () => { + test('returns a callable function', () => { + const grab = mockGrab(mockGrabResponse()); + const caller = createApiCaller(grab, 'https://api.test.com', defaultConfig); + expect(typeof caller).toBe('function'); + }); + + test('caller delegates to post with correct URL', async () => { + const grab = mockGrab(mockGrabResponse({ jsonData: { data: { result: 42 } } })); + const caller = createApiCaller(grab, 'https://api.test.com/graphql', defaultConfig); + const result = await caller('{ result }'); + expect(result).toEqual({ result: 42 }); + expect(grab).toHaveBeenCalledWith('https://api.test.com/graphql', expect.anything()); + }); + + test('passes extra headers from options', async () => { + const grab = mockGrab(mockGrabResponse()); + const caller = createApiCaller(grab, 'https://api.test.com', defaultConfig, { + extraHeaders: { 'X-Tenant': 'test' }, + }); + await caller('{ items }'); + expect(grab).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ + headers: expect.objectContaining({ 'X-Tenant': 'test' }), + })); + }); +}); + +describe('profiling', () => { + test('calls onRequest and onRequestResolved', async () => { + const onRequest = vi.fn(); + const onRequestResolved = vi.fn(); + const grab = mockGrab(mockGrabResponse({ + headers: { get: (name: string) => name === 'server-timing' ? 'total;dur=42.5' : null }, + })); + const caller = createApiCaller(grab, 'https://api.test.com', defaultConfig, { + profiling: { onRequest, onRequestResolved }, + }); + await caller('{ items }', { limit: 5 }); + expect(onRequest).toHaveBeenCalledWith('{ items }', { limit: 5 }); + expect(onRequestResolved).toHaveBeenCalledWith( + expect.objectContaining({ + serverTimeMs: 42.5, + resolutionTimeMs: expect.any(Number), + }), + '{ items }', + { limit: 5 }, + ); + }); + + test('handles missing server-timing header', async () => { + const onRequestResolved = vi.fn(); + const grab = mockGrab(mockGrabResponse()); + const caller = createApiCaller(grab, 'https://api.test.com', defaultConfig, { + profiling: { onRequestResolved }, + }); + await caller('{ items }'); + expect(onRequestResolved).toHaveBeenCalledWith( + expect.objectContaining({ serverTimeMs: -1 }), + '{ items }', + undefined, + ); + }); +}); diff --git a/components/js-api-client/tests/unit/create-grabber.test.ts b/components/js-api-client/tests/unit/create-grabber.test.ts new file mode 100644 index 00000000..e6cc8789 --- /dev/null +++ b/components/js-api-client/tests/unit/create-grabber.test.ts @@ -0,0 +1,78 @@ +import { describe, test, expect, vi } from 'vitest'; +import { createGrabber } from '../../src/core/client/create-grabber.js'; + +describe('createGrabber (fetch mode)', () => { + test('returns grab and close functions', () => { + const grabber = createGrabber(); + expect(typeof grabber.grab).toBe('function'); + expect(typeof grabber.close).toBe('function'); + }); + + test('grab delegates to fetch with correct options', async () => { + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'content-type': 'application/json' }), + json: () => Promise.resolve({ data: 'test' }), + text: () => Promise.resolve('{"data":"test"}'), + }; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as Response); + + const { grab } = createGrabber(); + const response = await grab('https://api.test.com/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"query":"{ test }"}', + }); + + expect(fetchSpy).toHaveBeenCalledWith('https://api.test.com/graphql', expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"query":"{ test }"}', + })); + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + + const json = await response.json(); + expect(json).toEqual({ data: 'test' }); + + fetchSpy.mockRestore(); + }); + + test('passes signal to fetch for abort support', async () => { + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: () => Promise.resolve({}), + text: () => Promise.resolve('{}'), + }; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as Response); + const controller = new AbortController(); + + const { grab } = createGrabber(); + await grab('https://api.test.com', { signal: controller.signal }); + + expect(fetchSpy).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ + signal: controller.signal, + })); + + fetchSpy.mockRestore(); + }); + + test('close is callable without error', () => { + const { close } = createGrabber(); + expect(() => close()).not.toThrow(); + }); + + test('propagates fetch errors', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network failure')); + + const { grab } = createGrabber(); + await expect(grab('https://api.test.com')).rejects.toThrow('Network failure'); + + fetchSpy.mockRestore(); + }); +}); diff --git a/components/js-api-client/tests/unit/create-mass-call-client.test.ts b/components/js-api-client/tests/unit/create-mass-call-client.test.ts new file mode 100644 index 00000000..abcfef20 --- /dev/null +++ b/components/js-api-client/tests/unit/create-mass-call-client.test.ts @@ -0,0 +1,228 @@ +import { describe, test, expect, vi } from 'vitest'; +import { createMassCallClient } from '../../src/core/create-mass-call-client.js'; +import type { ClientInterface } from '../../src/core/client/create-client.js'; +import type { ApiCaller } from '../../src/core/client/create-api-caller.js'; + +const createMockCaller = (results?: Record): ApiCaller => { + let callCount = 0; + return vi.fn(async (query: string) => { + callCount++; + return results?.[query] ?? { success: true, call: callCount }; + }) as unknown as ApiCaller; +}; + +const createMockClient = (overrides?: Partial>): ClientInterface => { + return { + catalogueApi: overrides?.catalogueApi ?? createMockCaller(), + discoveryApi: overrides?.discoveryApi ?? createMockCaller(), + pimApi: overrides?.pimApi ?? createMockCaller(), + nextPimApi: overrides?.nextPimApi ?? createMockCaller(), + shopCartApi: overrides?.shopCartApi ?? createMockCaller(), + config: { tenantIdentifier: 'test', tenantId: 'test-id' }, + close: vi.fn(), + [Symbol.dispose]: vi.fn(), + }; +}; + +const noopSleeper = () => ({ + wait: () => Promise.resolve(), + reset: () => {}, +}); + +describe('createMassCallClient', () => { + test('enqueue returns a unique key', () => { + const client = createMockClient(); + const mass = createMassCallClient(client, {}); + const key1 = mass.enqueue.pimApi('{ query1 }'); + const key2 = mass.enqueue.pimApi('{ query2 }'); + expect(key1).not.toBe(key2); + expect(key1).toContain('pimApi'); + expect(key2).toContain('pimApi'); + }); + + test('execute runs all enqueued requests and returns results', async () => { + const pimCaller = createMockCaller(); + const client = createMockClient({ pimApi: pimCaller }); + const mass = createMassCallClient(client, { sleeper: noopSleeper() }); + + const key1 = mass.enqueue.pimApi('{ query1 }'); + const key2 = mass.enqueue.pimApi('{ query2 }'); + const results = await mass.execute(); + + expect(results[key1]).toBeDefined(); + expect(results[key2]).toBeDefined(); + expect(pimCaller).toHaveBeenCalledTimes(2); + }); + + test('execute with different API types', async () => { + const client = createMockClient(); + const mass = createMassCallClient(client, { sleeper: noopSleeper() }); + + const k1 = mass.enqueue.catalogueApi('{ catalogue }'); + const k2 = mass.enqueue.pimApi('{ pim }'); + const k3 = mass.enqueue.discoveryApi('{ discovery }'); + const results = await mass.execute(); + + expect(results[k1]).toBeDefined(); + expect(results[k2]).toBeDefined(); + expect(results[k3]).toBeDefined(); + }); + + test('reset clears queue and state', async () => { + const client = createMockClient(); + const mass = createMassCallClient(client, { sleeper: noopSleeper() }); + + mass.enqueue.pimApi('{ query1 }'); + mass.reset(); + + const results = await mass.execute(); + expect(Object.keys(results)).toHaveLength(0); + }); + + test('hasFailed and failureCount track failures', async () => { + const failingCaller = vi.fn().mockRejectedValue(new Error('fail')) as unknown as ApiCaller; + const client = createMockClient({ pimApi: failingCaller }); + const mass = createMassCallClient(client, { sleeper: noopSleeper() }); + + mass.enqueue.pimApi('{ fail1 }'); + mass.enqueue.pimApi('{ fail2 }'); + await mass.execute(); + + expect(mass.hasFailed()).toBe(true); + expect(mass.failureCount()).toBe(2); + }); + + test('retry re-executes failed requests', async () => { + let callCount = 0; + const sometimesFails = vi.fn(async () => { + callCount++; + if (callCount <= 2) throw new Error('temporary failure'); + return { recovered: true }; + }) as unknown as ApiCaller; + + const client = createMockClient({ pimApi: sometimesFails }); + const mass = createMassCallClient(client, { sleeper: noopSleeper() }); + + mass.enqueue.pimApi('{ q1 }'); + mass.enqueue.pimApi('{ q2 }'); + await mass.execute(); + + expect(mass.hasFailed()).toBe(true); + const retryResults = await mass.retry(); + expect(mass.hasFailed()).toBe(false); + expect(Object.values(retryResults).every((r: any) => r.recovered)).toBe(true); + }); + + test('onFailure callback controls retry queuing', async () => { + const failingCaller = vi.fn().mockRejectedValue(new Error('fail')) as unknown as ApiCaller; + const client = createMockClient({ pimApi: failingCaller }); + const onFailure = vi.fn().mockResolvedValue(false); // don't retry + + const mass = createMassCallClient(client, { onFailure, sleeper: noopSleeper() }); + mass.enqueue.pimApi('{ q1 }'); + await mass.execute(); + + expect(onFailure).toHaveBeenCalled(); + expect(mass.hasFailed()).toBe(false); // not queued for retry + }); + + test('batch size adapts: increases on success', async () => { + const caller = createMockCaller(); + const client = createMockClient({ pimApi: caller }); + const batches: Array<{ from: number; to: number }> = []; + const mass = createMassCallClient(client, { + initialSpawn: 1, + maxSpawn: 5, + sleeper: noopSleeper(), + onBatchDone: async (batch) => { batches.push(batch); }, + }); + + for (let i = 0; i < 6; i++) { + mass.enqueue.pimApi(`{ q${i} }`); + } + await mass.execute(); + + // First batch: 1 item, second: 2 items, third: 3 items = 6 total + expect(batches[0]).toEqual({ from: 0, to: 1 }); + expect(batches[1]).toEqual({ from: 1, to: 3 }); + expect(batches[2]).toEqual({ from: 3, to: 6 }); + }); + + test('batch size does not exceed maxSpawn', async () => { + const caller = createMockCaller(); + const client = createMockClient({ pimApi: caller }); + const mass = createMassCallClient(client, { + initialSpawn: 3, + maxSpawn: 3, + sleeper: noopSleeper(), + }); + + for (let i = 0; i < 9; i++) { + mass.enqueue.pimApi(`{ q${i} }`); + } + await mass.execute(); + // All batches should be size 3 + expect(caller).toHaveBeenCalledTimes(9); + }); + + test('batch size decreases on errors', async () => { + let callNum = 0; + const mixedCaller = vi.fn(async () => { + callNum++; + // First batch (3 items): 2 fail = more than half + if (callNum <= 2) throw new Error('fail'); + return { ok: true }; + }) as unknown as ApiCaller; + + const client = createMockClient({ pimApi: mixedCaller }); + const changeIncrementFor = vi.fn((situation: string, current: number) => { + if (situation === 'more-than-half-have-failed') return 1; + if (situation === 'some-have-failed') return current - 1; + return current + 1; + }); + + const mass = createMassCallClient(client, { + initialSpawn: 3, + maxSpawn: 5, + sleeper: noopSleeper(), + changeIncrementFor, + }); + + for (let i = 0; i < 5; i++) { + mass.enqueue.pimApi(`{ q${i} }`); + } + await mass.execute(); + + expect(changeIncrementFor).toHaveBeenCalled(); + }); + + test('beforeRequest and afterRequest hooks are called', async () => { + const caller = createMockCaller(); + const client = createMockClient({ pimApi: caller }); + const beforeRequest = vi.fn().mockResolvedValue(undefined); + const afterRequest = vi.fn().mockResolvedValue(undefined); + + const mass = createMassCallClient(client, { + beforeRequest, + afterRequest, + sleeper: noopSleeper(), + }); + + mass.enqueue.pimApi('{ q1 }'); + await mass.execute(); + + expect(beforeRequest).toHaveBeenCalledTimes(1); + expect(afterRequest).toHaveBeenCalledTimes(1); + }); + + test('passes through API callers from original client', () => { + const client = createMockClient(); + const mass = createMassCallClient(client, {}); + + expect(mass.catalogueApi).toBe(client.catalogueApi); + expect(mass.pimApi).toBe(client.pimApi); + expect(mass.discoveryApi).toBe(client.discoveryApi); + expect(mass.nextPimApi).toBe(client.nextPimApi); + expect(mass.shopCartApi).toBe(client.shopCartApi); + }); +}); From 4602983655200073370cec565395cf10c31901a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 17:08:56 -0700 Subject: [PATCH 18/34] test: add comprehensive error-path tests for API caller Cover HTTP error codes (400-503), GraphQL errors in 200 responses, Core Next wrapped errors, network failures, timeout scenarios, malformed JSON responses, 204 No Content, and JSApiClientCallError property validation. --- .../tests/unit/error-handling.test.ts | 454 ++++++++++++++++++ 1 file changed, 454 insertions(+) create mode 100644 components/js-api-client/tests/unit/error-handling.test.ts diff --git a/components/js-api-client/tests/unit/error-handling.test.ts b/components/js-api-client/tests/unit/error-handling.test.ts new file mode 100644 index 00000000..bf770a0e --- /dev/null +++ b/components/js-api-client/tests/unit/error-handling.test.ts @@ -0,0 +1,454 @@ +import { describe, test, expect, vi } from 'vitest'; +import { post, JSApiClientCallError } from '../../src/core/client/create-api-caller.js'; +import type { GrabResponse } from '../../src/core/client/create-grabber.js'; +import type { ClientConfiguration } from '../../src/core/client/create-client.js'; + +const mockGrabResponse = (overrides: Partial & { jsonData?: unknown } = {}): GrabResponse => { + const { jsonData = { data: {} }, ...rest } = overrides; + return { + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null }, + json: () => Promise.resolve(jsonData as any), + text: () => Promise.resolve(JSON.stringify(jsonData)), + ...rest, + }; +}; + +const defaultConfig: ClientConfiguration = { + tenantIdentifier: 'test-tenant', + tenantId: 'test-id', + accessTokenId: 'token-id', + accessTokenSecret: 'token-secret', +}; + +const query = '{ items { id name } }'; +const variables = { lang: 'en' }; + +describe('HTTP error codes', () => { + const errorCases = [ + { status: 400, statusText: 'Bad Request', message: 'Invalid query syntax' }, + { status: 401, statusText: 'Unauthorized', message: 'Invalid credentials' }, + { status: 403, statusText: 'Forbidden', message: 'Access denied' }, + { status: 404, statusText: 'Not Found', message: 'Endpoint not found' }, + { status: 429, statusText: 'Too Many Requests', message: 'Rate limit exceeded' }, + { status: 500, statusText: 'Internal Server Error', message: 'Server error' }, + { status: 502, statusText: 'Bad Gateway', message: 'Upstream failure' }, + { status: 503, statusText: 'Service Unavailable', message: 'Service down' }, + ]; + + test.each(errorCases)( + 'throws JSApiClientCallError for HTTP $status ($statusText)', + async ({ status, statusText, message }) => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + ok: false, + status, + statusText, + jsonData: { message, errors: [] }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, query, variables); + expect.unreachable('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(JSApiClientCallError); + const err = e as JSApiClientCallError; + expect(err.name).toBe('JSApiClientCallError'); + expect(err.code).toBe(status); + expect(err.statusText).toBe(statusText); + expect(err.message).toBe(message); + expect(err.query).toBe(query); + expect(err.variables).toEqual(variables); + } + }, + ); + + test('error includes errors array from response', async () => { + const errors = [{ field: 'token', message: 'expired' }, { field: 'scope', message: 'insufficient' }]; + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + ok: false, + status: 403, + statusText: 'Forbidden', + jsonData: { message: 'Forbidden', errors }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, query); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.errors).toEqual(errors); + } + }); + + test('error defaults variables to empty object when undefined', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + jsonData: { message: 'fail', errors: [] }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, query); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.variables).toEqual({}); + } + }); +}); + +describe('GraphQL errors in 200 response', () => { + test('throws on single GraphQL error', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + errors: [{ message: 'Cannot query field "foo" on type "Query"' }], + data: null, + }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ foo }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err).toBeInstanceOf(JSApiClientCallError); + expect(err.code).toBe(400); + expect(err.message).toBe('Cannot query field "foo" on type "Query"'); + expect(err.statusText).toBe('Error was returned from the API'); + expect(err.errors).toEqual([{ message: 'Cannot query field "foo" on type "Query"' }]); + } + }); + + test('uses first error message when multiple GraphQL errors', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + errors: [ + { message: 'First error' }, + { message: 'Second error' }, + { message: 'Third error' }, + ], + data: null, + }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ bad }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.message).toBe('First error'); + expect(err.errors).toHaveLength(3); + } + }); + + test('preserves query and variables in GraphQL error', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + errors: [{ message: 'Validation error' }], + data: null, + }, + }), + ); + + const vars = { id: 'abc', limit: 5 }; + try { + await post(grab, 'https://api.test.com', defaultConfig, 'query Q($id: ID!) { item(id: $id) }', vars); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.query).toBe('query Q($id: ID!) { item(id: $id) }'); + expect(err.variables).toEqual(vars); + } + }); +}); + +describe('Core Next wrapped errors', () => { + test('detects errorName at second level of data', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + data: { + createItem: { + errorName: 'ValidationError', + message: 'Name is required', + }, + }, + }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, 'mutation { createItem }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.code).toBe(400); + expect(err.message).toBe('[ValidationError] Name is required'); + expect(err.statusText).toContain('Core Next'); + } + }); + + test('uses fallback message when errorName has no message', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + data: { + deleteItem: { errorName: 'InternalError' }, + }, + }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, 'mutation { deleteItem }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.message).toBe('[InternalError] An error occurred'); + } + }); + + test('does not trigger on normal data without errorName', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + data: { + item: { id: '123', name: 'Test' }, + }, + }, + }), + ); + + const result = await post(grab, 'https://api.test.com', defaultConfig, '{ item }'); + expect(result).toEqual({ item: { id: '123', name: 'Test' } }); + }); + + test('does not trigger when errorName is not a string', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + data: { + item: { errorName: 42, message: 'not a real error' }, + }, + }, + }), + ); + + const result = await post(grab, 'https://api.test.com', defaultConfig, '{ item }'); + expect(result).toEqual({ item: { errorName: 42, message: 'not a real error' } }); + }); +}); + +describe('network failures', () => { + test('propagates network error from grab', async () => { + const grab = vi.fn().mockRejectedValue(new TypeError('fetch failed')); + + await expect( + post(grab, 'https://api.test.com', defaultConfig, query), + ).rejects.toThrow('fetch failed'); + }); + + test('propagates DNS resolution failure', async () => { + const grab = vi.fn().mockRejectedValue(new TypeError('getaddrinfo ENOTFOUND api.test.com')); + + await expect( + post(grab, 'https://api.test.com', defaultConfig, query), + ).rejects.toThrow('ENOTFOUND'); + }); + + test('propagates connection refused', async () => { + const grab = vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:443')); + + await expect( + post(grab, 'https://api.test.com', defaultConfig, query), + ).rejects.toThrow('ECONNREFUSED'); + }); + + test('propagates connection reset', async () => { + const grab = vi.fn().mockRejectedValue(new Error('read ECONNRESET')); + + await expect( + post(grab, 'https://api.test.com', defaultConfig, query), + ).rejects.toThrow('ECONNRESET'); + }); +}); + +describe('timeout scenarios', () => { + test('passes abort signal when timeout is configured', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ jsonData: { data: { ok: true } } }), + ); + + await post(grab, 'https://api.test.com', defaultConfig, query, undefined, undefined, { + timeout: 5000, + }); + + expect(grab).toHaveBeenCalledWith( + 'https://api.test.com', + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); + }); + + test('does not pass signal when no timeout configured', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ jsonData: { data: { ok: true } } }), + ); + + await post(grab, 'https://api.test.com', defaultConfig, query); + + const callArgs = grab.mock.calls[0][1]; + expect(callArgs.signal).toBeUndefined(); + }); + + test('propagates abort error on timeout', async () => { + const grab = vi.fn().mockRejectedValue(new DOMException('The operation was aborted', 'TimeoutError')); + + await expect( + post(grab, 'https://api.test.com', defaultConfig, query, undefined, undefined, { + timeout: 1, + }), + ).rejects.toThrow('The operation was aborted'); + }); +}); + +describe('malformed responses', () => { + test('propagates JSON parse error on HTTP error with invalid body', async () => { + const grab = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + headers: { get: () => null }, + json: () => Promise.reject(new SyntaxError('Unexpected token < in JSON')), + text: () => Promise.resolve('Server Error'), + }); + + await expect( + post(grab, 'https://api.test.com', defaultConfig, query), + ).rejects.toThrow(SyntaxError); + }); + + test('propagates JSON parse error on 200 with invalid body', async () => { + const grab = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null }, + json: () => Promise.reject(new SyntaxError('Unexpected end of JSON input')), + text: () => Promise.resolve(''), + }); + + await expect( + post(grab, 'https://api.test.com', defaultConfig, query), + ).rejects.toThrow(SyntaxError); + }); +}); + +describe('204 No Content', () => { + test('returns empty object', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ ok: true, status: 204, statusText: 'No Content' }), + ); + + const result = await post(grab, 'https://api.test.com', defaultConfig, 'mutation { delete }'); + expect(result).toEqual({}); + }); + + test('does not attempt to parse JSON body', async () => { + const jsonFn = vi.fn(); + const grab = vi.fn().mockResolvedValue({ + ok: true, + status: 204, + statusText: 'No Content', + headers: { get: () => null }, + json: jsonFn, + text: () => Promise.resolve(''), + }); + + await post(grab, 'https://api.test.com', defaultConfig, 'mutation { delete }'); + expect(jsonFn).not.toHaveBeenCalled(); + }); +}); + +describe('JSApiClientCallError properties', () => { + test('is an instance of Error', () => { + const err = new JSApiClientCallError({ + message: 'test', + code: 500, + statusText: 'Error', + query: '{ q }', + variables: {}, + }); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(JSApiClientCallError); + }); + + test('has correct name property', () => { + const err = new JSApiClientCallError({ + message: 'test', + code: 400, + statusText: 'Bad Request', + query: '{ q }', + variables: {}, + }); + expect(err.name).toBe('JSApiClientCallError'); + }); + + test('stores all constructor properties', () => { + const errors = [{ field: 'x' }]; + const err = new JSApiClientCallError({ + message: 'Something broke', + code: 422, + statusText: 'Unprocessable Entity', + query: 'mutation M { m }', + variables: { id: '1' }, + errors, + }); + expect(err.message).toBe('Something broke'); + expect(err.code).toBe(422); + expect(err.statusText).toBe('Unprocessable Entity'); + expect(err.query).toBe('mutation M { m }'); + expect(err.variables).toEqual({ id: '1' }); + expect(err.errors).toEqual(errors); + }); + + test('has a stack trace', () => { + const err = new JSApiClientCallError({ + message: 'test', + code: 500, + statusText: 'Error', + query: '', + variables: {}, + }); + expect(err.stack).toBeDefined(); + expect(err.stack).toContain('JSApiClientCallError'); + }); + + test('uses default values when provided', () => { + const err = new JSApiClientCallError({ + message: 'An error occurred while calling the API', + code: 500, + statusText: 'Internal Server Error', + query: '', + variables: {}, + }); + expect(err.message).toBe('An error occurred while calling the API'); + expect(err.code).toBe(500); + expect(err.errors).toBeUndefined(); + }); +}); From 0b8e1c594f130ef793c40cd9d98f0f6622f72fa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 17:09:51 -0700 Subject: [PATCH 19/34] ci: add GitHub Actions workflow for PR and main branch checks Runs build and unit tests across Node.js 20, 22, and 24 on every pull request and push to main, so broken code is caught before merge. --- .../js-api-client/.github/workflows/ci.yaml | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 components/js-api-client/.github/workflows/ci.yaml diff --git a/components/js-api-client/.github/workflows/ci.yaml b/components/js-api-client/.github/workflows/ci.yaml new file mode 100644 index 00000000..80b76017 --- /dev/null +++ b/components/js-api-client/.github/workflows/ci.yaml @@ -0,0 +1,37 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + build-and-test: + name: Build & Unit Tests (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22, 24] + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v5 + + - name: ⎔ Setup node + uses: actions/setup-node@v5 + with: + node-version: ${{ matrix.node-version }} + + - name: ⎔ Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.14.0 + + - name: 📥 Download deps + run: pnpm install + + - name: 🔨 Build + run: pnpm build + + - name: 🧪 Run unit tests + run: pnpm vitest run tests/unit/ From ee3ec82f1f58f76cc1c0b92f4aeb4f0b479a4169 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 17:12:51 -0700 Subject: [PATCH 20/34] docs: add JSDoc comments to all main exported factory functions Add comprehensive JSDoc with @param, @returns, and @example tags to 12 exported factory functions for better IDE discoverability. --- .../catalogue/create-catalogue-fetcher.ts | 19 +++++++++++++++++ .../catalogue/create-navigation-fetcher.ts | 14 +++++++++++++ .../core/catalogue/create-product-hydrater.ts | 15 +++++++++++++ .../src/core/client/create-client.ts | 18 ++++++++++++++++ .../src/core/create-mass-call-client.ts | 21 ++++++++++++++----- .../src/core/create-signature-verifier.ts | 17 +++++++++++++++ .../core/pim/create-binary-file-manager.ts | 14 +++++++++++++ .../pim/customers/create-customer-manager.ts | 17 +++++++++++++++ .../core/pim/orders/create-order-fetcher.ts | 14 +++++++++++++ .../core/pim/orders/create-order-manager.ts | 17 +++++++++++++++ .../create-subscription-contract-manager.ts | 18 ++++++++++++++++ .../src/core/shop/create-cart-manager.ts | 17 +++++++++++++++ 12 files changed, 196 insertions(+), 5 deletions(-) diff --git a/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts b/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts index 0a1ea1df..da500ed1 100644 --- a/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts +++ b/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts @@ -22,6 +22,25 @@ export type CatalogueFetcherGrapqhqlOnFolder = { onChildren?: OC; }; +/** + * Creates a catalogue fetcher that executes queries against the Crystallize Catalogue API using JSON-based query objects. + * Use this when you want to build catalogue queries programmatically instead of writing raw GraphQL strings. + * + * @param client - A Crystallize client instance created via `createClient`. + * @returns A function that accepts a JSON query object and optional variables, and returns the catalogue data. + * + * @example + * ```ts + * const fetcher = createCatalogueFetcher(client); + * const data = await fetcher({ + * catalogue: { + * __args: { path: '/my-product', language: 'en' }, + * name: true, + * path: true, + * }, + * }); + * ``` + */ export const createCatalogueFetcher = (client: ClientInterface) => { return (query: object, variables?: VariablesType): Promise => { return client.catalogueApi(jsonToGraphQLQuery({ query }), variables); diff --git a/components/js-api-client/src/core/catalogue/create-navigation-fetcher.ts b/components/js-api-client/src/core/catalogue/create-navigation-fetcher.ts index eb2b76a3..1018eb37 100644 --- a/components/js-api-client/src/core/catalogue/create-navigation-fetcher.ts +++ b/components/js-api-client/src/core/catalogue/create-navigation-fetcher.ts @@ -112,6 +112,20 @@ function buildNestedNavigationQuery( return jsonToGraphQLQuery({ query }); } +/** + * Creates a navigation fetcher that builds nested tree queries for folder-based or topic-based navigation. + * Use this to retrieve hierarchical navigation structures from the Crystallize catalogue. + * + * @param client - A Crystallize client instance created via `createClient`. + * @returns An object with `byFolders` and `byTopics` methods for fetching navigation trees at a given depth. + * + * @example + * ```ts + * const nav = createNavigationFetcher(client); + * const folderTree = await nav.byFolders('/', 'en', 3); + * const topicTree = await nav.byTopics('/', 'en', 2); + * ``` + */ export function createNavigationFetcher(client: ClientInterface): { byFolders: TreeFetcher; byTopics: TreeFetcher; diff --git a/components/js-api-client/src/core/catalogue/create-product-hydrater.ts b/components/js-api-client/src/core/catalogue/create-product-hydrater.ts index a8e32938..285e3c23 100644 --- a/components/js-api-client/src/core/catalogue/create-product-hydrater.ts +++ b/components/js-api-client/src/core/catalogue/create-product-hydrater.ts @@ -140,6 +140,21 @@ function bySkus(client: ClientInterface, options?: ProductHydraterOptions): Prod }; } +/** + * Creates a product hydrater that fetches full product data from the catalogue by paths or SKUs. + * Use this to enrich a list of product references with complete variant, pricing, and attribute data. + * + * @param client - A Crystallize client instance created via `createClient`. + * @param options - Optional settings for market identifiers, price lists, and price-for-everyone inclusion. + * @returns An object with `byPaths` and `bySkus` methods for hydrating products. + * + * @example + * ```ts + * const hydrater = createProductHydrater(client); + * const products = await hydrater.byPaths(['/shop/my-product'], 'en'); + * const productsBySkus = await hydrater.bySkus(['SKU-001', 'SKU-002'], 'en'); + * ``` + */ export function createProductHydrater(client: ClientInterface, options?: ProductHydraterOptions) { return { byPaths: byPaths(client, options), diff --git a/components/js-api-client/src/core/client/create-client.ts b/components/js-api-client/src/core/client/create-client.ts index c617b9b5..43bfddc9 100644 --- a/components/js-api-client/src/core/client/create-client.ts +++ b/components/js-api-client/src/core/client/create-client.ts @@ -50,6 +50,24 @@ export const apiHost = (configuration: ClientConfiguration) => { }; }; +/** + * Creates a Crystallize API client that provides access to catalogue, discovery, PIM, and shop cart APIs. + * Use this as the main entry point for all interactions with the Crystallize APIs. + * + * @param configuration - The tenant configuration including identifier and authentication credentials. + * @param options - Optional settings for HTTP/2, profiling, timeouts, and extra headers. + * @returns A client interface with pre-configured API callers for each Crystallize endpoint. + * + * @example + * ```ts + * const client = createClient({ + * tenantIdentifier: 'my-tenant', + * accessTokenId: 'my-token-id', + * accessTokenSecret: 'my-token-secret', + * }); + * const data = await client.catalogueApi(query); + * ``` + */ export const createClient = (configuration: ClientConfiguration, options?: CreateClientOptions): ClientInterface => { const identifier = configuration.tenantIdentifier; const { grab, close: grabClose } = createGrabber({ diff --git a/components/js-api-client/src/core/create-mass-call-client.ts b/components/js-api-client/src/core/create-mass-call-client.ts index af60a030..b9b7c463 100644 --- a/components/js-api-client/src/core/create-mass-call-client.ts +++ b/components/js-api-client/src/core/create-mass-call-client.ts @@ -62,12 +62,23 @@ const createFibonacciSleeper = (): Sleeper => { }; /** - * Note: MassCallClient is experimental and may not work as expected. - * Creates a mass call client based on an existing ClientInterface. + * Creates a mass call client that batches and throttles multiple API requests with automatic retry and concurrency control. + * Use this when you need to execute many API calls efficiently, such as bulk imports or migrations. Note: this feature is experimental. * - * @param client ClientInterface - * @param options Object - * @returns MassClientInterface + * @param client - A Crystallize client instance created via `createClient`. + * @param options - Configuration for concurrency, batching callbacks, failure handling, and sleep strategy. + * @returns A mass client interface that extends `ClientInterface` with `enqueue`, `execute`, `retry`, `reset`, `hasFailed`, and `failureCount` capabilities. + * + * @example + * ```ts + * const massClient = createMassCallClient(client, { initialSpawn: 2, maxSpawn: 5 }); + * massClient.enqueue.pimApi(`mutation { ... }`, { id: '1' }); + * massClient.enqueue.pimApi(`mutation { ... }`, { id: '2' }); + * const results = await massClient.execute(); + * if (massClient.hasFailed()) { + * const retryResults = await massClient.retry(); + * } + * ``` */ export function createMassCallClient( client: ClientInterface, diff --git a/components/js-api-client/src/core/create-signature-verifier.ts b/components/js-api-client/src/core/create-signature-verifier.ts index 10f0ca85..ac7fd73b 100644 --- a/components/js-api-client/src/core/create-signature-verifier.ts +++ b/components/js-api-client/src/core/create-signature-verifier.ts @@ -65,6 +65,23 @@ const buildGETSituationChallenge = (request: SimplifiedRequest) => { return null; }; +/** + * Creates a signature verifier for validating Crystallize webhook and app signatures. + * Use this to verify that incoming requests genuinely originate from Crystallize. + * + * @param params - An object containing a `sha256` hash function, a `jwtVerify` function, and the webhook `secret`. + * @returns An async function that takes a signature string and a simplified request, and resolves to the verified payload or throws on invalid signatures. + * + * @example + * ```ts + * const verifier = createSignatureVerifier({ + * sha256: async (data) => createHash('sha256').update(data).digest('hex'), + * jwtVerify: async (token, secret) => jwt.verify(token, secret), + * secret: process.env.CRYSTALLIZE_WEBHOOK_SECRET, + * }); + * const payload = await verifier(signatureHeader, { url, method, body }); + * ``` + */ export const createSignatureVerifier = ({ sha256, jwtVerify, secret }: CreateAsyncSignatureVerifierParams) => { return async (signature: string, request: SimplifiedRequest): Promise => { try { diff --git a/components/js-api-client/src/core/pim/create-binary-file-manager.ts b/components/js-api-client/src/core/pim/create-binary-file-manager.ts index 666248f5..c70fb7b3 100644 --- a/components/js-api-client/src/core/pim/create-binary-file-manager.ts +++ b/components/js-api-client/src/core/pim/create-binary-file-manager.ts @@ -34,6 +34,20 @@ const generatePresignedUploadRequest = `#graphql } }`; +/** + * Creates a binary file manager for uploading images, static files, and mass operation files to a Crystallize tenant. + * Requires PIM API credentials (accessTokenId/accessTokenSecret) in the client configuration. + * + * @param apiClient - A Crystallize client instance created via `createClient` with PIM credentials. + * @returns An object with methods to `uploadToTenant`, `uploadImage`, `uploadFile`, and `uploadMassOperationFile`. + * + * @example + * ```ts + * const fileManager = createBinaryFileManager(client); + * const imageKey = await fileManager.uploadImage('/path/to/image.png'); + * const fileKey = await fileManager.uploadFile('/path/to/document.pdf'); + * ``` + */ export const createBinaryFileManager = (apiClient: ClientInterface) => { // this function returns the key of the uploaded file const uploadToTenant = async ({ type = 'MEDIA', mimeType, filename, buffer }: BinaryHandler): Promise => { diff --git a/components/js-api-client/src/core/pim/customers/create-customer-manager.ts b/components/js-api-client/src/core/pim/customers/create-customer-manager.ts index 745619c7..b72ebd25 100644 --- a/components/js-api-client/src/core/pim/customers/create-customer-manager.ts +++ b/components/js-api-client/src/core/pim/customers/create-customer-manager.ts @@ -12,6 +12,23 @@ import { createCustomerFetcher } from './create-customer-fetcher.js'; type WithIdentifier = R & { identifier: string }; +/** + * Creates a customer manager for creating, updating, and managing customer records via the Crystallize PIM API. + * Requires PIM API credentials (accessTokenId/accessTokenSecret) in the client configuration. + * + * @param apiClient - A Crystallize client instance created via `createClient` with PIM credentials. + * @returns An object with methods to `create`, `update`, `setMeta`, `setMetaKey`, `setExternalReference`, and `setExternalReferenceKey`. + * + * @example + * ```ts + * const customerManager = createCustomerManager(client); + * const customer = await customerManager.create({ + * identifier: 'customer@example.com', + * firstName: 'Jane', + * lastName: 'Doe', + * }); + * ``` + */ export const createCustomerManager = (apiClient: ClientInterface) => { const create = async ( intentCustomer: CreateCustomerInput, diff --git a/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts b/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts index 8a206c81..281f5bf1 100644 --- a/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts +++ b/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts @@ -68,6 +68,20 @@ type EnhanceQuery(enhancements?: { onCustomer?: Cust ], }); +/** + * Creates an order manager for registering, updating, and managing orders via the Crystallize PIM API. + * Requires PIM API credentials (accessTokenId/accessTokenSecret) in the client configuration. + * + * @param apiClient - A Crystallize client instance created via `createClient` with PIM credentials. + * @returns An object with methods to `register`, `update`, `setPayments`, `putInPipelineStage`, and `removeFromPipeline`. + * + * @example + * ```ts + * const orderManager = createOrderManager(client); + * const { id, createdAt } = await orderManager.register({ + * customer: { identifier: 'customer@example.com' }, + * cart: [{ sku: 'SKU-001', name: 'My Product', quantity: 1 }], + * total: { gross: 100, net: 80, currency: 'USD' }, + * }); + * ``` + */ export const createOrderManager = (apiClient: ClientInterface) => { const register = async (intentOrder: RegisterOrderInput) => { const intent = RegisterOrderInputSchema.parse(intentOrder); diff --git a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts index b4dc7d91..923f5fad 100644 --- a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts +++ b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts @@ -45,6 +45,24 @@ const baseQuery = { const create = async } = {}>( intentSubscriptionContract: CreateSubscriptionContractInput, diff --git a/components/js-api-client/src/core/shop/create-cart-manager.ts b/components/js-api-client/src/core/shop/create-cart-manager.ts index bfabc40e..8729a501 100644 --- a/components/js-api-client/src/core/shop/create-cart-manager.ts +++ b/components/js-api-client/src/core/shop/create-cart-manager.ts @@ -13,6 +13,23 @@ import { transformCartCustomerInput, transformCartInput } from './helpers.js'; type WithId = R & { id: string }; +/** + * Creates a cart manager for hydrating, placing, and managing shopping carts via the Crystallize Shop Cart API. + * Requires a shop API token or appropriate credentials in the client configuration. + * + * @param apiClient - A Crystallize client instance created via `createClient`. + * @returns An object with methods to `hydrate`, `fetch`, `place`, `fulfill`, `abandon`, `addSkuItem`, `removeItem`, `setMeta`, and `setCustomer`. + * + * @example + * ```ts + * const cartManager = createCartManager(client); + * const cart = await cartManager.hydrate({ + * items: [{ sku: 'SKU-001', quantity: 2 }], + * locale: { displayName: 'English', language: 'en' }, + * }); + * const placed = await cartManager.place(cart.id); + * ``` + */ export const createCartManager = (apiClient: ClientInterface) => { const fetch = async (id: string, onCart?: OC) => { const query = { From 447af4eade6b1de5b3460ec343a5ac26cad82cdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 17:35:08 -0700 Subject: [PATCH 21/34] feat: review --- .../js-api-client/.claude/settings.local.json | 3 + components/js-api-client/.mcp.json | 12 ++ .../src/core/create-mass-call-client.ts | 10 +- .../core/pim/orders/create-order-fetcher.ts | 6 +- .../core/pim/orders/create-order-manager.ts | 14 +- .../create-subscription-contract-fetcher.ts | 12 +- .../create-subscription-contract-manager.ts | 34 ++++- .../tests/unit/create-api-caller.test.ts | 131 +++++++++++------- .../tests/unit/create-grabber.test.ts | 22 +-- .../unit/create-mass-call-client.test.ts | 4 +- .../tests/unit/error-handling.test.ts | 47 ++----- 11 files changed, 189 insertions(+), 106 deletions(-) create mode 100644 components/js-api-client/.claude/settings.local.json create mode 100644 components/js-api-client/.mcp.json diff --git a/components/js-api-client/.claude/settings.local.json b/components/js-api-client/.claude/settings.local.json new file mode 100644 index 00000000..47ae10ef --- /dev/null +++ b/components/js-api-client/.claude/settings.local.json @@ -0,0 +1,3 @@ +{ + "enabledMcpjsonServers": ["crystallize"] +} diff --git a/components/js-api-client/.mcp.json b/components/js-api-client/.mcp.json new file mode 100644 index 00000000..7828dbae --- /dev/null +++ b/components/js-api-client/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "crystallize": { + "type": "http", + "url": "https://mcp.crystallize.com/mcp", + "headers": { + "X-Crystallize-Access-Token-Id": "558f95141de1c4112f34", + "X-Crystallize-Access-Token-Secret": "8f56c78c874ce55b2629139b9061cefacaff7d17" + } + } + } +} diff --git a/components/js-api-client/src/core/create-mass-call-client.ts b/components/js-api-client/src/core/create-mass-call-client.ts index b9b7c463..e49f338d 100644 --- a/components/js-api-client/src/core/create-mass-call-client.ts +++ b/components/js-api-client/src/core/create-mass-call-client.ts @@ -87,7 +87,11 @@ export function createMassCallClient( maxSpawn?: number; onBatchDone?: (batch: MassCallClientBatch) => Promise; beforeRequest?: (batch: MassCallClientBatch, promise: CrystallizePromise) => Promise; - afterRequest?: (batch: MassCallClientBatch, promise: CrystallizePromise, results: Record) => Promise; + afterRequest?: ( + batch: MassCallClientBatch, + promise: CrystallizePromise, + results: Record, + ) => Promise; onFailure?: ( batch: { from: number; to: number }, exception: unknown, @@ -117,7 +121,9 @@ export function createMassCallClient( batch = promises.slice(seek, to); const batchResults = await Promise.all( batch.map(async (promise: CrystallizePromise) => { - const buildStandardPromise = async (promise: CrystallizePromise): Promise<{ key: string; result: unknown } | undefined> => { + const buildStandardPromise = async ( + promise: CrystallizePromise, + ): Promise<{ key: string; result: unknown } | undefined> => { try { return { key: promise.key, diff --git a/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts b/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts index 281f5bf1..52cad9df 100644 --- a/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts +++ b/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts @@ -20,7 +20,11 @@ export type DefaultOrderType; } & OnOrder; -const buildBaseQuery = (onOrder?: OrderExtra, onOrderItem?: OrderItemExtra, onCustomer?: CustomerExtra) => { +const buildBaseQuery = ( + onOrder?: OrderExtra, + onOrderItem?: OrderItemExtra, + onCustomer?: CustomerExtra, +) => { const priceQuery = { gross: true, net: true, diff --git a/components/js-api-client/src/core/pim/orders/create-order-manager.ts b/components/js-api-client/src/core/pim/orders/create-order-manager.ts index 4d774a4d..8d880d7d 100644 --- a/components/js-api-client/src/core/pim/orders/create-order-manager.ts +++ b/components/js-api-client/src/core/pim/orders/create-order-manager.ts @@ -125,7 +125,12 @@ export const createOrderManager = (apiClient: ClientInterface) => { type PutInPipelineStageDefaultOrderType = Required> & { customer: Required, 'identifier'>> & OnCustomer; } & OnOrder; - const putInPipelineStage = async ( + const putInPipelineStage = async < + OnOrder = unknown, + OnCustomer = unknown, + CustomerExtra = unknown, + OrderExtra = unknown, + >( { id, pipelineId, stageId }: PutInPipelineStageArgs, enhancements?: PutInPipelineStageEnhancedQuery, ): Promise> => { @@ -157,7 +162,12 @@ export const createOrderManager = (apiClient: ClientInterface) => { type RemoveFromPipelineDefaultOrderType = Required> & { customer: Required, 'identifier'>> & OnCustomer; } & OnOrder; - const removeFromPipeline = async ( + const removeFromPipeline = async < + OnOrder = unknown, + OnCustomer = unknown, + CustomerExtra = unknown, + OrderExtra = unknown, + >( { id, pipelineId }: RemoveFromPipelineArgs, enhancements?: RemoveFromPipelineEnhancedQuery, ): Promise> => { diff --git a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-fetcher.ts b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-fetcher.ts index 3bc66e98..195167e4 100644 --- a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-fetcher.ts +++ b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-fetcher.ts @@ -19,7 +19,10 @@ type DefaultSubscriptionContractType = Requi customer: Required, 'identifier'>> & OnCustomer; } & OnSubscriptionContract; -const buildBaseQuery = (onSubscriptionContract?: SubscriptionContractExtra, onCustomer?: CustomerExtra) => { +const buildBaseQuery = ( + onSubscriptionContract?: SubscriptionContractExtra, + onCustomer?: CustomerExtra, +) => { const phaseQuery = { period: true, unit: true, @@ -114,7 +117,12 @@ type EnhanceQuery }; export const createSubscriptionContractFetcher = (apiClient: ClientInterface) => { - const fetchById = async ( + const fetchById = async < + OnSubscriptionContract = unknown, + OnCustomer = unknown, + SubscriptionContractExtra = unknown, + CustomerExtra = unknown, + >( id: string, enhancements?: EnhanceQuery, ): Promise | null> => { diff --git a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts index 923f5fad..3ca2ad50 100644 --- a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts +++ b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts @@ -25,7 +25,9 @@ type WithIdentifiersAndStatus = R & { } & (R extends { status: infer S } ? S : {}); }; -const baseQuery = }>(onSubscriptionContract?: SubscriptionContractExtra) => ({ +const baseQuery = }>( + onSubscriptionContract?: SubscriptionContractExtra, +) => ({ __on: [ { __typeName: 'SubscriptionContractAggregate', @@ -64,7 +66,10 @@ const baseQuery = { - const create = async } = {}>( + const create = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( intentSubscriptionContract: CreateSubscriptionContractInput, onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { @@ -100,7 +105,10 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.createSubscriptionContract; }; - const update = async } = {}>( + const update = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( intentSubscriptionContract: UpdateSubscriptionContractInput, onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { @@ -121,7 +129,10 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.updateSubscriptionContract; }; - const cancel = async } = {}>( + const cancel = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( id: UpdateSubscriptionContractInput['id'], deactivate = false, onSubscriptionContract?: SubscriptionContractExtra, @@ -144,7 +155,10 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.cancelSubscriptionContract; }; - const pause = async } = {}>( + const pause = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( id: UpdateSubscriptionContractInput['id'], onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { @@ -163,7 +177,10 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.pauseSubscriptionContract; }; - const resume = async } = {}>( + const resume = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( id: UpdateSubscriptionContractInput['id'], onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { @@ -182,7 +199,10 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.resumeSubscriptionContract; }; - const renew = async } = {}>( + const renew = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( id: UpdateSubscriptionContractInput['id'], onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { diff --git a/components/js-api-client/tests/unit/create-api-caller.test.ts b/components/js-api-client/tests/unit/create-api-caller.test.ts index 7efde203..3d7b8dcf 100644 --- a/components/js-api-client/tests/unit/create-api-caller.test.ts +++ b/components/js-api-client/tests/unit/create-api-caller.test.ts @@ -1,5 +1,10 @@ import { describe, test, expect, vi } from 'vitest'; -import { createApiCaller, post, authenticationHeaders, JSApiClientCallError } from '../../src/core/client/create-api-caller.js'; +import { + createApiCaller, + post, + authenticationHeaders, + JSApiClientCallError, +} from '../../src/core/client/create-api-caller.js'; import type { Grab, GrabResponse } from '../../src/core/client/create-grabber.js'; import type { ClientConfiguration } from '../../src/core/client/create-client.js'; @@ -84,22 +89,28 @@ describe('post', () => { test('passes query and variables in request body', async () => { const grab = mockGrab(mockGrabResponse()); await post(grab, 'https://api.test.com', defaultConfig, '{ items }', { limit: 10 }); - expect(grab).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ query: '{ items }', variables: { limit: 10 } }), - })); + expect(grab).toHaveBeenCalledWith( + 'https://api.test.com', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ query: '{ items }', variables: { limit: 10 } }), + }), + ); }); test('includes authentication headers', async () => { const grab = mockGrab(mockGrabResponse()); await post(grab, 'https://api.test.com', defaultConfig, '{ items }'); - expect(grab).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ - headers: expect.objectContaining({ - 'X-Crystallize-Access-Token-Id': 'token-id', - 'X-Crystallize-Access-Token-Secret': 'token-secret', - 'Content-type': 'application/json; charset=UTF-8', + expect(grab).toHaveBeenCalledWith( + 'https://api.test.com', + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Crystallize-Access-Token-Id': 'token-id', + 'X-Crystallize-Access-Token-Secret': 'token-secret', + 'Content-type': 'application/json; charset=UTF-8', + }), }), - })); + ); }); test('204 No Content returns empty object', async () => { @@ -109,12 +120,14 @@ describe('post', () => { }); test('throws JSApiClientCallError on HTTP error', async () => { - const grab = mockGrab(mockGrabResponse({ - ok: false, - status: 401, - statusText: 'Unauthorized', - jsonData: { message: 'Invalid credentials', errors: [{ field: 'token' }] }, - })); + const grab = mockGrab( + mockGrabResponse({ + ok: false, + status: 401, + statusText: 'Unauthorized', + jsonData: { message: 'Invalid credentials', errors: [{ field: 'token' }] }, + }), + ); try { await post(grab, 'https://api.test.com', defaultConfig, '{ items }'); expect.unreachable('should have thrown'); @@ -129,12 +142,14 @@ describe('post', () => { }); test('throws on GraphQL errors in 200 response', async () => { - const grab = mockGrab(mockGrabResponse({ - jsonData: { - errors: [{ message: 'Field "foo" not found' }], - data: null, - }, - })); + const grab = mockGrab( + mockGrabResponse({ + jsonData: { + errors: [{ message: 'Field "foo" not found' }], + data: null, + }, + }), + ); try { await post(grab, 'https://api.test.com', defaultConfig, '{ foo }'); expect.unreachable('should have thrown'); @@ -147,16 +162,18 @@ describe('post', () => { }); test('detects Core Next wrapped errors', async () => { - const grab = mockGrab(mockGrabResponse({ - jsonData: { - data: { - someOperation: { - errorName: 'ItemNotFound', - message: 'The item does not exist', + const grab = mockGrab( + mockGrabResponse({ + jsonData: { + data: { + someOperation: { + errorName: 'ItemNotFound', + message: 'The item does not exist', + }, }, }, - }, - })); + }), + ); try { await post(grab, 'https://api.test.com', defaultConfig, '{ someOperation }'); expect.unreachable('should have thrown'); @@ -169,13 +186,15 @@ describe('post', () => { }); test('Core Next error without message uses fallback', async () => { - const grab = mockGrab(mockGrabResponse({ - jsonData: { - data: { - op: { errorName: 'GenericError' }, + const grab = mockGrab( + mockGrabResponse({ + jsonData: { + data: { + op: { errorName: 'GenericError' }, + }, }, - }, - })); + }), + ); try { await post(grab, 'https://api.test.com', defaultConfig, '{ op }'); expect.unreachable('should have thrown'); @@ -190,16 +209,23 @@ describe('post', () => { await post(grab, 'https://api.test.com', defaultConfig, '{ items }', undefined, { headers: { 'X-Custom': 'value' }, }); - expect(grab).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ - headers: expect.objectContaining({ 'X-Custom': 'value' }), - })); + expect(grab).toHaveBeenCalledWith( + 'https://api.test.com', + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Custom': 'value' }), + }), + ); }); test('error includes query and variables for debugging', async () => { - const grab = mockGrab(mockGrabResponse({ - ok: false, status: 500, statusText: 'Internal Server Error', - jsonData: { message: 'Server error', errors: [] }, - })); + const grab = mockGrab( + mockGrabResponse({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + jsonData: { message: 'Server error', errors: [] }, + }), + ); const variables = { id: '123' }; try { await post(grab, 'https://api.test.com', defaultConfig, '{ item(id: $id) }', variables); @@ -233,9 +259,12 @@ describe('createApiCaller', () => { extraHeaders: { 'X-Tenant': 'test' }, }); await caller('{ items }'); - expect(grab).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ - headers: expect.objectContaining({ 'X-Tenant': 'test' }), - })); + expect(grab).toHaveBeenCalledWith( + 'https://api.test.com', + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Tenant': 'test' }), + }), + ); }); }); @@ -243,9 +272,11 @@ describe('profiling', () => { test('calls onRequest and onRequestResolved', async () => { const onRequest = vi.fn(); const onRequestResolved = vi.fn(); - const grab = mockGrab(mockGrabResponse({ - headers: { get: (name: string) => name === 'server-timing' ? 'total;dur=42.5' : null }, - })); + const grab = mockGrab( + mockGrabResponse({ + headers: { get: (name: string) => (name === 'server-timing' ? 'total;dur=42.5' : null) }, + }), + ); const caller = createApiCaller(grab, 'https://api.test.com', defaultConfig, { profiling: { onRequest, onRequestResolved }, }); diff --git a/components/js-api-client/tests/unit/create-grabber.test.ts b/components/js-api-client/tests/unit/create-grabber.test.ts index e6cc8789..1bb97708 100644 --- a/components/js-api-client/tests/unit/create-grabber.test.ts +++ b/components/js-api-client/tests/unit/create-grabber.test.ts @@ -26,11 +26,14 @@ describe('createGrabber (fetch mode)', () => { body: '{"query":"{ test }"}', }); - expect(fetchSpy).toHaveBeenCalledWith('https://api.test.com/graphql', expect.objectContaining({ - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: '{"query":"{ test }"}', - })); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.test.com/graphql', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"query":"{ test }"}', + }), + ); expect(response.ok).toBe(true); expect(response.status).toBe(200); @@ -55,9 +58,12 @@ describe('createGrabber (fetch mode)', () => { const { grab } = createGrabber(); await grab('https://api.test.com', { signal: controller.signal }); - expect(fetchSpy).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ - signal: controller.signal, - })); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.test.com', + expect.objectContaining({ + signal: controller.signal, + }), + ); fetchSpy.mockRestore(); }); diff --git a/components/js-api-client/tests/unit/create-mass-call-client.test.ts b/components/js-api-client/tests/unit/create-mass-call-client.test.ts index abcfef20..acad1082 100644 --- a/components/js-api-client/tests/unit/create-mass-call-client.test.ts +++ b/components/js-api-client/tests/unit/create-mass-call-client.test.ts @@ -134,7 +134,9 @@ describe('createMassCallClient', () => { initialSpawn: 1, maxSpawn: 5, sleeper: noopSleeper(), - onBatchDone: async (batch) => { batches.push(batch); }, + onBatchDone: async (batch) => { + batches.push(batch); + }, }); for (let i = 0; i < 6; i++) { diff --git a/components/js-api-client/tests/unit/error-handling.test.ts b/components/js-api-client/tests/unit/error-handling.test.ts index bf770a0e..4cd0cec3 100644 --- a/components/js-api-client/tests/unit/error-handling.test.ts +++ b/components/js-api-client/tests/unit/error-handling.test.ts @@ -67,7 +67,10 @@ describe('HTTP error codes', () => { ); test('error includes errors array from response', async () => { - const errors = [{ field: 'token', message: 'expired' }, { field: 'scope', message: 'insufficient' }]; + const errors = [ + { field: 'token', message: 'expired' }, + { field: 'scope', message: 'insufficient' }, + ]; const grab = vi.fn().mockResolvedValue( mockGrabResponse({ ok: false, @@ -134,11 +137,7 @@ describe('GraphQL errors in 200 response', () => { const grab = vi.fn().mockResolvedValue( mockGrabResponse({ jsonData: { - errors: [ - { message: 'First error' }, - { message: 'Second error' }, - { message: 'Third error' }, - ], + errors: [{ message: 'First error' }, { message: 'Second error' }, { message: 'Third error' }], data: null, }, }), @@ -257,41 +256,31 @@ describe('network failures', () => { test('propagates network error from grab', async () => { const grab = vi.fn().mockRejectedValue(new TypeError('fetch failed')); - await expect( - post(grab, 'https://api.test.com', defaultConfig, query), - ).rejects.toThrow('fetch failed'); + await expect(post(grab, 'https://api.test.com', defaultConfig, query)).rejects.toThrow('fetch failed'); }); test('propagates DNS resolution failure', async () => { const grab = vi.fn().mockRejectedValue(new TypeError('getaddrinfo ENOTFOUND api.test.com')); - await expect( - post(grab, 'https://api.test.com', defaultConfig, query), - ).rejects.toThrow('ENOTFOUND'); + await expect(post(grab, 'https://api.test.com', defaultConfig, query)).rejects.toThrow('ENOTFOUND'); }); test('propagates connection refused', async () => { const grab = vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:443')); - await expect( - post(grab, 'https://api.test.com', defaultConfig, query), - ).rejects.toThrow('ECONNREFUSED'); + await expect(post(grab, 'https://api.test.com', defaultConfig, query)).rejects.toThrow('ECONNREFUSED'); }); test('propagates connection reset', async () => { const grab = vi.fn().mockRejectedValue(new Error('read ECONNRESET')); - await expect( - post(grab, 'https://api.test.com', defaultConfig, query), - ).rejects.toThrow('ECONNRESET'); + await expect(post(grab, 'https://api.test.com', defaultConfig, query)).rejects.toThrow('ECONNRESET'); }); }); describe('timeout scenarios', () => { test('passes abort signal when timeout is configured', async () => { - const grab = vi.fn().mockResolvedValue( - mockGrabResponse({ jsonData: { data: { ok: true } } }), - ); + const grab = vi.fn().mockResolvedValue(mockGrabResponse({ jsonData: { data: { ok: true } } })); await post(grab, 'https://api.test.com', defaultConfig, query, undefined, undefined, { timeout: 5000, @@ -306,9 +295,7 @@ describe('timeout scenarios', () => { }); test('does not pass signal when no timeout configured', async () => { - const grab = vi.fn().mockResolvedValue( - mockGrabResponse({ jsonData: { data: { ok: true } } }), - ); + const grab = vi.fn().mockResolvedValue(mockGrabResponse({ jsonData: { data: { ok: true } } })); await post(grab, 'https://api.test.com', defaultConfig, query); @@ -338,9 +325,7 @@ describe('malformed responses', () => { text: () => Promise.resolve('Server Error'), }); - await expect( - post(grab, 'https://api.test.com', defaultConfig, query), - ).rejects.toThrow(SyntaxError); + await expect(post(grab, 'https://api.test.com', defaultConfig, query)).rejects.toThrow(SyntaxError); }); test('propagates JSON parse error on 200 with invalid body', async () => { @@ -353,17 +338,13 @@ describe('malformed responses', () => { text: () => Promise.resolve(''), }); - await expect( - post(grab, 'https://api.test.com', defaultConfig, query), - ).rejects.toThrow(SyntaxError); + await expect(post(grab, 'https://api.test.com', defaultConfig, query)).rejects.toThrow(SyntaxError); }); }); describe('204 No Content', () => { test('returns empty object', async () => { - const grab = vi.fn().mockResolvedValue( - mockGrabResponse({ ok: true, status: 204, statusText: 'No Content' }), - ); + const grab = vi.fn().mockResolvedValue(mockGrabResponse({ ok: true, status: 204, statusText: 'No Content' })); const result = await post(grab, 'https://api.test.com', defaultConfig, 'mutation { delete }'); expect(result).toEqual({}); From 3341835aa702070fbc81da1ff3562d5753d697b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 3 Apr 2026 13:01:44 -0700 Subject: [PATCH 22/34] fix: await onBatchDone callback in mass call client The onBatchDone callback is typed as returning Promise but was not awaited, causing batch callbacks to overlap with subsequent batch execution. --- components/js-api-client/src/core/create-mass-call-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/js-api-client/src/core/create-mass-call-client.ts b/components/js-api-client/src/core/create-mass-call-client.ts index e49f338d..94916c8f 100644 --- a/components/js-api-client/src/core/create-mass-call-client.ts +++ b/components/js-api-client/src/core/create-mass-call-client.ts @@ -169,7 +169,7 @@ export function createMassCallClient( // fire that a batch is done if (options.onBatchDone) { - options.onBatchDone({ from: seek, to }); + await options.onBatchDone({ from: seek, to }); } // we move the seek pointer seek += batch.length; From 4d726a51c6d8aae879d0ca2ac8544d41d7a2de9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 3 Apr 2026 13:02:36 -0700 Subject: [PATCH 23/34] fix: remove Promise constructor antipattern in mass call client Replace `new Promise(async (resolve) => { ... })` with plain async/await flow. The old pattern swallowed errors from beforeRequest/afterRequest hooks, causing the promise to silently hang instead of rejecting. --- .../src/core/create-mass-call-client.ts | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/components/js-api-client/src/core/create-mass-call-client.ts b/components/js-api-client/src/core/create-mass-call-client.ts index 94916c8f..574e2af7 100644 --- a/components/js-api-client/src/core/create-mass-call-client.ts +++ b/components/js-api-client/src/core/create-mass-call-client.ts @@ -145,20 +145,18 @@ export function createMassCallClient( return buildStandardPromise(promise); } - // otherwise we wrap it - return new Promise<{ key: string; result: unknown } | undefined>(async (resolve) => { - let alteredPromise; - if (options.beforeRequest) { - alteredPromise = await options.beforeRequest({ from: seek, to: to }, promise); - } - const result = await buildStandardPromise(alteredPromise ?? promise); - if (options.afterRequest && result) { - await options.afterRequest({ from: seek, to: to }, promise, { - [result.key]: result.result, - }); - } - resolve(result); - }); + // otherwise we wrap it with before/after hooks + let alteredPromise; + if (options.beforeRequest) { + alteredPromise = await options.beforeRequest({ from: seek, to: to }, promise); + } + const result = await buildStandardPromise(alteredPromise ?? promise); + if (options.afterRequest && result) { + await options.afterRequest({ from: seek, to: to }, promise, { + [result.key]: result.result, + }); + } + return result; }), ); batchResults.forEach((result) => { From 56bfe48ed0ec486063d72657d433de898eb24315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 3 Apr 2026 13:04:06 -0700 Subject: [PATCH 24/34] fix: type HTTP/2 clients Map and add null guard in grabber The clients Map was untyped (new Map()), hiding potential null-access bugs. Added proper generic type parameters and a guard in resetIdleTimeout for when clientObj is undefined. --- .../js-api-client/src/core/client/create-grabber.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/components/js-api-client/src/core/client/create-grabber.ts b/components/js-api-client/src/core/client/create-grabber.ts index 7912be84..27cab8d7 100644 --- a/components/js-api-client/src/core/client/create-grabber.ts +++ b/components/js-api-client/src/core/client/create-grabber.ts @@ -25,7 +25,7 @@ type Options = { useHttp2?: boolean; }; export const createGrabber = (options?: Options): Grab => { - const clients = new Map(); + const clients = new Map | null }>(); const IDLE_TIMEOUT = 300000; // 5 min idle timeout const grab = async (url: string, grabOptions?: GrabOptions): Promise => { if (options?.useHttp2 !== true) { @@ -42,7 +42,10 @@ export const createGrabber = (options?: Options): Grab => { const resetIdleTimeout = (origin: string) => { const clientObj = clients.get(origin); - if (clientObj && clientObj.idleTimeout) { + if (!clientObj) { + return; + } + if (clientObj.idleTimeout) { clearTimeout(clientObj.idleTimeout); } clientObj.idleTimeout = setTimeout(() => { @@ -51,7 +54,7 @@ export const createGrabber = (options?: Options): Grab => { }; const getClient = (origin: string): ClientHttp2Session => { - if (!clients.has(origin) || clients.get(origin).client.closed) { + if (!clients.has(origin) || clients.get(origin)!.client.closed) { closeAndDeleteClient(origin); const client = connect(origin); client.on('error', () => { @@ -60,7 +63,7 @@ export const createGrabber = (options?: Options): Grab => { clients.set(origin, { client, idleTimeout: null }); resetIdleTimeout(origin); } - return clients.get(origin).client; + return clients.get(origin)!.client; }; return new Promise((resolve, reject) => { From 09296f693aca69c0f0cf54884450906f856fb1fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 3 Apr 2026 13:04:49 -0700 Subject: [PATCH 25/34] fix: stop sending empty auth headers when no credentials configured When no accessTokenId/accessTokenSecret are set, return an empty object instead of headers with empty string values. Crystallize APIs already handle missing headers correctly. --- .../src/core/client/create-api-caller.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/components/js-api-client/src/core/client/create-api-caller.ts b/components/js-api-client/src/core/client/create-api-caller.ts index 98988ea9..0f9313f9 100644 --- a/components/js-api-client/src/core/client/create-api-caller.ts +++ b/components/js-api-client/src/core/client/create-api-caller.ts @@ -71,12 +71,15 @@ export const authenticationHeaders = (config: ClientConfiguration): Record Date: Fri, 3 Apr 2026 13:06:08 -0700 Subject: [PATCH 26/34] refactor: extract shared lifecycleAction helper in subscription contract manager cancel, pause, resume, renew, create, and update all followed the same pattern: build a mutation object, call nextPimApi, unwrap the response. A private lifecycleAction helper now encapsulates this, eliminating ~80 lines of duplication while keeping the public API identical. --- .../create-subscription-contract-manager.ts | 147 ++++++------------ 1 file changed, 50 insertions(+), 97 deletions(-) diff --git a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts index 3ca2ad50..183092ce 100644 --- a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts +++ b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts @@ -66,43 +66,40 @@ const baseQuery = { - const create = async < + const lifecycleAction = async < OnSubscriptionContract, SubscriptionContractExtra extends { status?: Record } = {}, >( - intentSubscriptionContract: CreateSubscriptionContractInput, + mutationName: string, + args: Record, onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { - const input = CreateSubscriptionContractInputSchema.parse(intentSubscriptionContract); const api = apiClient.nextPimApi; const mutation = { - createSubscriptionContract: { - __args: { - input: transformSubscriptionContractInput(input), - }, - __on: [ - { - __typeName: 'SubscriptionContractAggregate', - id: true, - customerIdentifier: true, - status: { - state: true, - ...onSubscriptionContract?.status, - }, - ...(onSubscriptionContract || {}), - }, - { - __typeName: 'BasicError', - errorName: true, - message: true, - }, - ], + [mutationName]: { + __args: args, + ...baseQuery(onSubscriptionContract), }, }; - const confirmation = await api<{ - createSubscriptionContract: WithIdentifiersAndStatus; - }>(jsonToGraphQLQuery({ mutation })); - return confirmation.createSubscriptionContract; + const confirmation = await api>>( + jsonToGraphQLQuery({ mutation }), + ); + return confirmation[mutationName]; + }; + + const create = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( + intentSubscriptionContract: CreateSubscriptionContractInput, + onSubscriptionContract?: SubscriptionContractExtra, + ): Promise> => { + const input = CreateSubscriptionContractInputSchema.parse(intentSubscriptionContract); + return lifecycleAction( + 'createSubscriptionContract', + { input: transformSubscriptionContractInput(input) }, + onSubscriptionContract, + ); }; const update = async < @@ -113,20 +110,11 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { const { id, ...input } = UpdateSubscriptionContractInputSchema.parse(intentSubscriptionContract); - const api = apiClient.nextPimApi; - const mutation = { - updateSubscriptionContract: { - __args: { - subscriptionContractId: id, - input: transformSubscriptionContractInput(input), - }, - ...baseQuery(onSubscriptionContract), - }, - }; - const confirmation = await api<{ - updateSubscriptionContract: WithIdentifiersAndStatus; - }>(jsonToGraphQLQuery({ mutation })); - return confirmation.updateSubscriptionContract; + return lifecycleAction( + 'updateSubscriptionContract', + { subscriptionContractId: id, input: transformSubscriptionContractInput(input) }, + onSubscriptionContract, + ); }; const cancel = async < @@ -137,22 +125,11 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => deactivate = false, onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { - const api = apiClient.nextPimApi; - const mutation = { - cancelSubscriptionContract: { - __args: { - subscriptionContractId: id, - input: { - deactivate, - }, - }, - ...baseQuery(onSubscriptionContract), - }, - }; - const confirmation = await api<{ - cancelSubscriptionContract: WithIdentifiersAndStatus; - }>(jsonToGraphQLQuery({ mutation })); - return confirmation.cancelSubscriptionContract; + return lifecycleAction( + 'cancelSubscriptionContract', + { subscriptionContractId: id, input: { deactivate } }, + onSubscriptionContract, + ); }; const pause = async < @@ -162,19 +139,11 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => id: UpdateSubscriptionContractInput['id'], onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { - const api = apiClient.nextPimApi; - const mutation = { - pauseSubscriptionContract: { - __args: { - subscriptionContractId: id, - }, - ...baseQuery(onSubscriptionContract), - }, - }; - const confirmation = await api<{ - pauseSubscriptionContract: WithIdentifiersAndStatus; - }>(jsonToGraphQLQuery({ mutation })); - return confirmation.pauseSubscriptionContract; + return lifecycleAction( + 'pauseSubscriptionContract', + { subscriptionContractId: id }, + onSubscriptionContract, + ); }; const resume = async < @@ -184,19 +153,11 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => id: UpdateSubscriptionContractInput['id'], onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { - const api = apiClient.nextPimApi; - const mutation = { - resumeSubscriptionContract: { - __args: { - subscriptionContractId: id, - }, - ...baseQuery(onSubscriptionContract), - }, - }; - const confirmation = await api<{ - resumeSubscriptionContract: WithIdentifiersAndStatus; - }>(jsonToGraphQLQuery({ mutation })); - return confirmation.resumeSubscriptionContract; + return lifecycleAction( + 'resumeSubscriptionContract', + { subscriptionContractId: id }, + onSubscriptionContract, + ); }; const renew = async < @@ -206,19 +167,11 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => id: UpdateSubscriptionContractInput['id'], onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { - const api = apiClient.nextPimApi; - const mutation = { - renewSubscriptionContract: { - __args: { - subscriptionContractId: id, - }, - ...baseQuery(onSubscriptionContract), - }, - }; - const confirmation = await api<{ - renewSubscriptionContract: WithIdentifiersAndStatus; - }>(jsonToGraphQLQuery({ mutation })); - return confirmation.renewSubscriptionContract; + return lifecycleAction( + 'renewSubscriptionContract', + { subscriptionContractId: id }, + onSubscriptionContract, + ); }; /** From 5ecf7d5d8da563499d844fa82fe2b01ef4abca7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 3 Apr 2026 13:07:14 -0700 Subject: [PATCH 27/34] refactor: extract shared helpers in cart manager to eliminate duplication Cart manager methods (fetch, place, abandon, fulfill, addSkuItem, removeItem, setMeta, setCustomer, hydrate) all followed the same pattern of building a GraphQL query/mutation, calling shopCartApi, and unwrapping the response. Extracted cartQuery and cartMutation helpers to remove ~80 lines of structural duplication. Public API unchanged. --- .../src/core/shop/create-cart-manager.ts | 184 ++++-------------- 1 file changed, 34 insertions(+), 150 deletions(-) diff --git a/components/js-api-client/src/core/shop/create-cart-manager.ts b/components/js-api-client/src/core/shop/create-cart-manager.ts index 8729a501..398cd1f8 100644 --- a/components/js-api-client/src/core/shop/create-cart-manager.ts +++ b/components/js-api-client/src/core/shop/create-cart-manager.ts @@ -31,165 +31,49 @@ type WithId = R & { id: string }; * ``` */ export const createCartManager = (apiClient: ClientInterface) => { - const fetch = async (id: string, onCart?: OC) => { - const query = { - cart: { - __args: { - id, - }, - id: true, - ...onCart, - }, - }; - const response = await apiClient.shopCartApi<{ cart: WithId }>(jsonToGraphQLQuery({ query })); - return response.cart; + const cartQuery = async (name: string, args: Record, onCart?: OC) => { + const query = { [name]: { __args: args, id: true, ...onCart } }; + const response = await apiClient.shopCartApi>>(jsonToGraphQLQuery({ query })); + return response[name]; }; - const place = async (id: string, onCart?: OC) => { - const mutation = { - place: { - __args: { - id, - }, - id: true, - ...onCart, - }, - }; - const response = await apiClient.shopCartApi<{ place: WithId }>(jsonToGraphQLQuery({ mutation })); - return response.place; - }; - - const abandon = async (id: string, onCart?: OC) => { - const mutation = { - abandon: { - __args: { - id, - }, - id: true, - ...onCart, - }, - }; - const response = await apiClient.shopCartApi<{ abandon: WithId }>(jsonToGraphQLQuery({ mutation })); - return response.abandon; - }; - - const fulfill = async (id: string, orderId: string, onCart?: OC) => { - const mutation = { - fulfill: { - __args: { - id, - orderId, - }, - id: true, - ...onCart, - }, - }; - const response = await apiClient.shopCartApi<{ fulfill: WithId }>(jsonToGraphQLQuery({ mutation })); - return response.fulfill; - }; - - const addSkuItem = async (id: string, intent: CartSkuItemInput, onCart?: OC) => { - const input = CartSkuItemInputSchema.parse(intent); - const mutation = { - addSkuItem: { - __args: { - id, - input, - }, - id: true, - ...onCart, - }, - }; - const response = await apiClient.shopCartApi<{ addSkuItem: WithId }>(jsonToGraphQLQuery({ mutation })); - return response.addSkuItem; - }; - - const removeItem = async ( - id: string, - { sku, quantity }: { sku: string; quantity: number }, - onCart?: OC, - ) => { - const mutation = { - removeCartItem: { - __args: { - id, - sku, - quantity, - }, - id: true, - ...onCart, - }, - }; - const response = await apiClient.shopCartApi<{ - removeCartItem: WithId; - }>(jsonToGraphQLQuery({ mutation })); - return response.removeCartItem; + const cartMutation = async (name: string, args: Record, onCart?: OC) => { + const mutation = { [name]: { __args: args, id: true, ...onCart } }; + const response = await apiClient.shopCartApi>>( + jsonToGraphQLQuery({ mutation }), + ); + return response[name]; }; type MetaIntent = { meta: MetaInput; merge: boolean; }; - const setMeta = async (id: string, { meta, merge }: MetaIntent, onCart?: OC) => { - const mutation = { - setMeta: { - __args: { - id, - merge, - meta, - }, - id: true, - ...onCart, - }, - }; - const response = await apiClient.shopCartApi<{ - setMeta: WithId; - }>(jsonToGraphQLQuery({ mutation })); - return response.setMeta; - }; - - const setCustomer = async (id: string, customerIntent: CustomerInput, onCart?: OC) => { - const input = CustomerInputSchema.parse(customerIntent); - const mutation = { - setCustomer: { - __args: { - id, - input: transformCartCustomerInput(input), - }, - id: true, - ...onCart, - }, - }; - const response = await apiClient.shopCartApi<{ - setCustomer: WithId; - }>(jsonToGraphQLQuery({ mutation })); - return response.setCustomer; - }; - - const hydrate = async (intent: CartInput, onCart?: OC) => { - const input = CartInputSchema.parse(intent); - const mutation = { - hydrate: { - __args: { - input: transformCartInput(input), - }, - id: true, - ...onCart, - }, - }; - const response = await apiClient.shopCartApi<{ hydrate: WithId }>(jsonToGraphQLQuery({ mutation })); - return response.hydrate; - }; return { - hydrate, - place, - fetch, - fulfill, - abandon, - addSkuItem, - removeItem, - setMeta, - setCustomer, + hydrate: async (intent: CartInput, onCart?: OC) => { + const input = CartInputSchema.parse(intent); + return cartMutation('hydrate', { input: transformCartInput(input) }, onCart); + }, + place: (id: string, onCart?: OC) => cartMutation('place', { id }, onCart), + fetch: (id: string, onCart?: OC) => cartQuery('cart', { id }, onCart), + fulfill: (id: string, orderId: string, onCart?: OC) => + cartMutation('fulfill', { id, orderId }, onCart), + abandon: (id: string, onCart?: OC) => cartMutation('abandon', { id }, onCart), + addSkuItem: async (id: string, intent: CartSkuItemInput, onCart?: OC) => { + const input = CartSkuItemInputSchema.parse(intent); + return cartMutation('addSkuItem', { id, input }, onCart); + }, + removeItem: ( + id: string, + { sku, quantity }: { sku: string; quantity: number }, + onCart?: OC, + ) => cartMutation('removeCartItem', { id, sku, quantity }, onCart), + setMeta: (id: string, { meta, merge }: MetaIntent, onCart?: OC) => + cartMutation('setMeta', { id, merge, meta }, onCart), + setCustomer: async (id: string, customerIntent: CustomerInput, onCart?: OC) => { + const input = CustomerInputSchema.parse(customerIntent); + return cartMutation('setCustomer', { id, input: transformCartCustomerInput(input) }, onCart); + }, }; }; From 837dbd0b55c14369cb62c87cb41560168af7522b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 3 Apr 2026 13:08:59 -0700 Subject: [PATCH 28/34] deprecate: add deprecation notice to mass call client, recommend p-limit/p-queue The mass call client duplicates functionality that mature ecosystem packages provide better. Added @deprecated JSDoc tag, one-time console.warn on first use, and updated README with deprecation notice and p-limit usage example. The function remains fully functional. --- components/js-api-client/README.md | 34 ++++++++++++++++--- .../src/core/create-mass-call-client.ts | 23 ++++++++++++- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/components/js-api-client/README.md b/components/js-api-client/README.md index 8f0fcc68..a41a5275 100644 --- a/components/js-api-client/README.md +++ b/components/js-api-client/README.md @@ -378,7 +378,34 @@ const bulkKey = await files.uploadMassOperationFile('/absolute/path/to/import.zi [crystallizeobject]: crystallize_marketing|folder|625619f6615e162541535959 -## Mass Call Client +## Mass Call Client (Deprecated) + +> **Deprecated:** Use mature ecosystem packages like [`p-limit`](https://www.npmjs.com/package/p-limit) or [`p-queue`](https://www.npmjs.com/package/p-queue) instead. They provide better error handling, TypeScript support, and are actively maintained. + +### Recommended alternative using p-limit + +```typescript +import pLimit from 'p-limit'; +import { createClient } from '@crystallize/js-api-client'; + +const api = createClient({ tenantIdentifier: 'my-tenant', accessTokenId: '…', accessTokenSecret: '…' }); +const limit = pLimit(5); // max 5 concurrent requests + +const mutations = items.map((item) => + limit(() => api.pimApi( + `mutation UpdateItem($id: ID!, $name: String!) { product { update(id: $id, input: { name: $name }) { id } } }`, + { id: item.id, name: item.name }, + )), +); + +const results = await Promise.allSettled(mutations); +const failed = results.filter((r) => r.status === 'rejected'); +console.log(`Done: ${results.length - failed.length} succeeded, ${failed.length} failed`); +``` + +### Legacy usage + +The mass call client is still functional but will emit a deprecation warning on first use. Sometimes, when you have many calls to do, whether they are queries or mutations, you want to be able to manage them asynchronously. This is the purpose of the Mass Call Client. It will let you be asynchronous, managing the heavy lifting of lifecycle, retry, incremental increase or decrease of the pace, etc. @@ -396,8 +423,7 @@ These are the main features: - Optional lifecycle function *afterRequest* (sync) to execute after each request. You also get the result in there, if needed ```javascript -// import { createMassCallClient } from '@crystallize/js-api-client'; -const client = createMassCallClient(api, { initialSpawn: 1 }); // api created via createClient(...) +const client = createMassCallClient(api, { initialSpawn: 1 }); async function run() { for (let i = 1; i <= 54; i++) { @@ -416,5 +442,3 @@ async function run() { } run(); ``` - -Full example: https://github.com/CrystallizeAPI/libraries/blob/main/components/js-api-client/src/examples/dump-tenant.ts diff --git a/components/js-api-client/src/core/create-mass-call-client.ts b/components/js-api-client/src/core/create-mass-call-client.ts index 574e2af7..e5a051ca 100644 --- a/components/js-api-client/src/core/create-mass-call-client.ts +++ b/components/js-api-client/src/core/create-mass-call-client.ts @@ -61,9 +61,21 @@ const createFibonacciSleeper = (): Sleeper => { }; }; +let hasWarnedDeprecation = false; + /** * Creates a mass call client that batches and throttles multiple API requests with automatic retry and concurrency control. - * Use this when you need to execute many API calls efficiently, such as bulk imports or migrations. Note: this feature is experimental. + * + * @deprecated Use mature ecosystem packages like `p-limit` or `p-queue` instead for concurrency control. + * They provide better error handling, TypeScript support, and are actively maintained. + * + * ```ts + * import pLimit from 'p-limit'; + * const limit = pLimit(5); + * const results = await Promise.all( + * items.map((item) => limit(() => client.pimApi(mutation, { id: item.id }))), + * ); + * ``` * * @param client - A Crystallize client instance created via `createClient`. * @param options - Configuration for concurrency, batching callbacks, failure handling, and sleep strategy. @@ -104,6 +116,15 @@ export function createMassCallClient( ) => number; }, ): MassClientInterface { + if (!hasWarnedDeprecation) { + hasWarnedDeprecation = true; + console.warn( + '[@crystallize/js-api-client] createMassCallClient is deprecated. ' + + 'Use p-limit or p-queue for concurrency control instead. ' + + 'See https://www.npmjs.com/package/p-limit', + ); + } + let promises: CrystallizePromise[] = []; let failedPromises: CrystallizePromise[] = []; let seek = 0; From dd98cc1604a7246cea9e5deef52695efcc8b8842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 3 Apr 2026 13:09:56 -0700 Subject: [PATCH 29/34] feat: add statusCode getter alias on JSApiClientCallError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `get statusCode()` getter that returns `this.code`, following Node.js conventions where `code` is typically a string error code (ECONNREFUSED, etc.) and `statusCode` is the numeric HTTP status. Non-breaking — existing `.code` usage is unaffected. --- .../js-api-client/src/core/client/create-api-caller.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/components/js-api-client/src/core/client/create-api-caller.ts b/components/js-api-client/src/core/client/create-api-caller.ts index 0f9313f9..8ad49f7d 100644 --- a/components/js-api-client/src/core/client/create-api-caller.ts +++ b/components/js-api-client/src/core/client/create-api-caller.ts @@ -34,6 +34,11 @@ export class JSApiClientCallError extends Error { this.query = query; this.variables = variables; } + + /** Alias for `code` — follows the Node.js convention of numeric `statusCode`. */ + get statusCode(): number { + return this.code; + } } export const createApiCaller = ( grab: Grab['grab'], From aa338dcd3856ec5574dcb91e40bd71b1fe0c8983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 3 Apr 2026 13:10:50 -0700 Subject: [PATCH 30/34] feat: make HTTP/2 idle timeout configurable via http2IdleTimeout option Expose http2IdleTimeout on CreateClientOptions so users can tune the idle timeout for serverless (short) and long-running server (long) use cases. Defaults to 300000ms (5 minutes) when not set. --- components/js-api-client/src/core/client/create-client.ts | 3 +++ components/js-api-client/src/core/client/create-grabber.ts | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/components/js-api-client/src/core/client/create-client.ts b/components/js-api-client/src/core/client/create-client.ts index 43bfddc9..895ddf9d 100644 --- a/components/js-api-client/src/core/client/create-client.ts +++ b/components/js-api-client/src/core/client/create-client.ts @@ -33,6 +33,8 @@ export type CreateClientOptions = { extraHeaders?: Record; /** Request timeout in milliseconds. When set, requests that take longer will be aborted. */ timeout?: number; + /** HTTP/2 idle timeout in milliseconds. Defaults to 300000 (5 minutes). */ + http2IdleTimeout?: number; shopApiToken?: { doNotFetch?: boolean; scopes?: string[]; @@ -72,6 +74,7 @@ export const createClient = (configuration: ClientConfiguration, options?: Creat const identifier = configuration.tenantIdentifier; const { grab, close: grabClose } = createGrabber({ useHttp2: options?.useHttp2, + http2IdleTimeout: options?.http2IdleTimeout, }); // let's rewrite the configuration based on the need of the endpoint diff --git a/components/js-api-client/src/core/client/create-grabber.ts b/components/js-api-client/src/core/client/create-grabber.ts index 27cab8d7..05e40e9c 100644 --- a/components/js-api-client/src/core/client/create-grabber.ts +++ b/components/js-api-client/src/core/client/create-grabber.ts @@ -23,10 +23,11 @@ export type Grab = { type Options = { useHttp2?: boolean; + http2IdleTimeout?: number; }; export const createGrabber = (options?: Options): Grab => { const clients = new Map | null }>(); - const IDLE_TIMEOUT = 300000; // 5 min idle timeout + const IDLE_TIMEOUT = options?.http2IdleTimeout ?? 300000; // default 5 min idle timeout const grab = async (url: string, grabOptions?: GrabOptions): Promise => { if (options?.useHttp2 !== true) { const { signal, ...fetchOptions } = grabOptions || {}; From ff66fd2e0b988e50146ec51955d103d5a9eb83cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 3 Apr 2026 13:12:08 -0700 Subject: [PATCH 31/34] docs: update README with http2IdleTimeout, timeout options and statusCode alias Document the new timeout and http2IdleTimeout client options, and add an error handling section showing the statusCode getter on JSApiClientCallError. --- components/js-api-client/README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/components/js-api-client/README.md b/components/js-api-client/README.md index a41a5275..6d8c0517 100644 --- a/components/js-api-client/README.md +++ b/components/js-api-client/README.md @@ -63,6 +63,8 @@ api.close(); - `origin` custom host suffix (defaults to `.crystallize.com`) - options - `useHttp2` enable HTTP/2 transport + - `timeout` request timeout in milliseconds; requests that take longer will be aborted + - `http2IdleTimeout` HTTP/2 idle timeout in milliseconds (default `300000` — 5 minutes). Use a shorter value for serverless functions, a longer one for long-running servers - `profiling` callbacks - `extraHeaders` extra request headers for all calls - `shopApiToken` controls auto-fetch: `{ doNotFetch?: boolean; scopes?: string[]; expiresIn?: number }` @@ -89,6 +91,23 @@ Pass the relevant credentials to `createClient`: See the official docs for auth: https://crystallize.com/learn/developer-guides/api-overview/authentication +### Error handling + +API call errors throw a `JSApiClientCallError` with both `code` and `statusCode` properties for the HTTP status: + +```typescript +import { JSApiClientCallError } from '@crystallize/js-api-client'; + +try { + await api.pimApi(`query { … }`); +} catch (e) { + if (e instanceof JSApiClientCallError) { + console.error(`HTTP ${e.statusCode}:`, e.message); + // e.code also works (same value) + } +} +``` + ## Profiling requests Log queries, timings and server timing if available. From 95ad6c276e9c737f1c6a2cdfdec57cabde58c7d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 3 Apr 2026 13:32:26 -0700 Subject: [PATCH 32/34] clean --- components/js-api-client/.claude/settings.local.json | 3 --- components/js-api-client/.mcp.json | 12 ------------ 2 files changed, 15 deletions(-) delete mode 100644 components/js-api-client/.claude/settings.local.json delete mode 100644 components/js-api-client/.mcp.json diff --git a/components/js-api-client/.claude/settings.local.json b/components/js-api-client/.claude/settings.local.json deleted file mode 100644 index 47ae10ef..00000000 --- a/components/js-api-client/.claude/settings.local.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "enabledMcpjsonServers": ["crystallize"] -} diff --git a/components/js-api-client/.mcp.json b/components/js-api-client/.mcp.json deleted file mode 100644 index 7828dbae..00000000 --- a/components/js-api-client/.mcp.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "mcpServers": { - "crystallize": { - "type": "http", - "url": "https://mcp.crystallize.com/mcp", - "headers": { - "X-Crystallize-Access-Token-Id": "558f95141de1c4112f34", - "X-Crystallize-Access-Token-Secret": "8f56c78c874ce55b2629139b9061cefacaff7d17" - } - } - } -} From 78712b0994ddac539789824ac4ec3157f121bb08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 3 Apr 2026 13:52:53 -0700 Subject: [PATCH 33/34] review 1 --- .../js-api-client/.github/workflows/ci.yaml | 37 ------------------- components/js-api-client/README.md | 10 +++-- .../src/core/client/create-api-caller.ts | 21 +++-------- .../src/core/client/create-grabber.ts | 5 ++- .../src/core/client/shop-api-caller.ts | 2 +- .../src/core/shop/create-cart-manager.ts | 4 +- .../tests/unit/create-api-caller.test.ts | 17 +-------- 7 files changed, 20 insertions(+), 76 deletions(-) delete mode 100644 components/js-api-client/.github/workflows/ci.yaml diff --git a/components/js-api-client/.github/workflows/ci.yaml b/components/js-api-client/.github/workflows/ci.yaml deleted file mode 100644 index 80b76017..00000000 --- a/components/js-api-client/.github/workflows/ci.yaml +++ /dev/null @@ -1,37 +0,0 @@ -name: CI - -on: - pull_request: - branches: [main] - push: - branches: [main] - -jobs: - build-and-test: - name: Build & Unit Tests (Node ${{ matrix.node-version }}) - runs-on: ubuntu-latest - strategy: - matrix: - node-version: [20, 22, 24] - steps: - - name: ⬇️ Checkout repo - uses: actions/checkout@v5 - - - name: ⎔ Setup node - uses: actions/setup-node@v5 - with: - node-version: ${{ matrix.node-version }} - - - name: ⎔ Set up pnpm - uses: pnpm/action-setup@v4 - with: - version: 10.14.0 - - - name: 📥 Download deps - run: pnpm install - - - name: 🔨 Build - run: pnpm build - - - name: 🧪 Run unit tests - run: pnpm vitest run tests/unit/ diff --git a/components/js-api-client/README.md b/components/js-api-client/README.md index 6d8c0517..0924a4ea 100644 --- a/components/js-api-client/README.md +++ b/components/js-api-client/README.md @@ -411,10 +411,12 @@ const api = createClient({ tenantIdentifier: 'my-tenant', accessTokenId: '…', const limit = pLimit(5); // max 5 concurrent requests const mutations = items.map((item) => - limit(() => api.pimApi( - `mutation UpdateItem($id: ID!, $name: String!) { product { update(id: $id, input: { name: $name }) { id } } }`, - { id: item.id, name: item.name }, - )), + limit(() => + api.pimApi( + `mutation UpdateItem($id: ID!, $name: String!) { product { update(id: $id, input: { name: $name }) { id } } }`, + { id: item.id, name: item.name }, + ), + ), ); const results = await Promise.allSettled(mutations); diff --git a/components/js-api-client/src/core/client/create-api-caller.ts b/components/js-api-client/src/core/client/create-api-caller.ts index 8ad49f7d..06b3d063 100644 --- a/components/js-api-client/src/core/client/create-api-caller.ts +++ b/components/js-api-client/src/core/client/create-api-caller.ts @@ -63,8 +63,6 @@ export const createApiCaller = ( }; }; -const warnedConfigs = new WeakSet(); - export const authenticationHeaders = (config: ClientConfiguration): Record => { if (config.sessionId) { return { @@ -76,20 +74,13 @@ export const authenticationHeaders = (config: ClientConfiguration): Record { - const clients = new Map | null }>(); + const clients = new Map< + string, + { client: ClientHttp2Session; idleTimeout: ReturnType | null } + >(); const IDLE_TIMEOUT = options?.http2IdleTimeout ?? 300000; // default 5 min idle timeout const grab = async (url: string, grabOptions?: GrabOptions): Promise => { if (options?.useHttp2 !== true) { diff --git a/components/js-api-client/src/core/client/shop-api-caller.ts b/components/js-api-client/src/core/client/shop-api-caller.ts index 009274ad..c1f8841c 100644 --- a/components/js-api-client/src/core/client/shop-api-caller.ts +++ b/components/js-api-client/src/core/client/shop-api-caller.ts @@ -4,7 +4,7 @@ import { Grab } from './create-grabber.js'; const getExpirationAtFromToken = (token: string) => { const payload = token.split('.')[1]; - const decodedPayload = Buffer.from(payload, 'base64').toString('utf-8'); + const decodedPayload = atob(payload); const parsedPayload = JSON.parse(decodedPayload); return parsedPayload.exp * 1000; }; diff --git a/components/js-api-client/src/core/shop/create-cart-manager.ts b/components/js-api-client/src/core/shop/create-cart-manager.ts index 398cd1f8..8239af4e 100644 --- a/components/js-api-client/src/core/shop/create-cart-manager.ts +++ b/components/js-api-client/src/core/shop/create-cart-manager.ts @@ -39,9 +39,7 @@ export const createCartManager = (apiClient: ClientInterface) => { const cartMutation = async (name: string, args: Record, onCart?: OC) => { const mutation = { [name]: { __args: args, id: true, ...onCart } }; - const response = await apiClient.shopCartApi>>( - jsonToGraphQLQuery({ mutation }), - ); + const response = await apiClient.shopCartApi>>(jsonToGraphQLQuery({ mutation })); return response[name]; }; diff --git a/components/js-api-client/tests/unit/create-api-caller.test.ts b/components/js-api-client/tests/unit/create-api-caller.test.ts index 3d7b8dcf..2745b1e5 100644 --- a/components/js-api-client/tests/unit/create-api-caller.test.ts +++ b/components/js-api-client/tests/unit/create-api-caller.test.ts @@ -60,22 +60,9 @@ describe('authenticationHeaders', () => { }); }); - test('warns when no auth is configured', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + test('returns empty headers when no auth is configured', () => { const config: ClientConfiguration = { tenantIdentifier: 'test' }; - authenticationHeaders(config); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No authentication credentials configured')); - warnSpy.mockRestore(); - }); - - test('warns only once per config object', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const config: ClientConfiguration = { tenantIdentifier: 'test-once' }; - authenticationHeaders(config); - authenticationHeaders(config); - authenticationHeaders(config); - expect(warnSpy).toHaveBeenCalledTimes(1); - warnSpy.mockRestore(); + expect(authenticationHeaders(config)).toEqual({}); }); }); From 13eece57c5e0bbf8b1c3c922b9b47dc7571ed0c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 3 Apr 2026 14:22:34 -0700 Subject: [PATCH 34/34] review --- components/js-api-client/UPGRADE.md | 26 + components/js-api-client/package.json | 16 +- .../src/core/client/create-api-caller.ts | 16 +- .../src/core/client/create-client.ts | 2 +- .../src/core/client/create-grabber.ts | 20 +- .../src/core/shop/create-cart-manager.ts | 61 +- .../tests/unit/create-api-caller.test.ts | 22 +- .../tests/unit/error-handling.test.ts | 23 +- .../js-api-client/tests/unit/helpers.ts | 22 + components/js-api-client/tsconfig.json | 2 + pnpm-lock.yaml | 834 ++++++++++++++++-- 11 files changed, 909 insertions(+), 135 deletions(-) create mode 100644 components/js-api-client/tests/unit/helpers.ts diff --git a/components/js-api-client/UPGRADE.md b/components/js-api-client/UPGRADE.md index faccbec2..cf2f05d9 100644 --- a/components/js-api-client/UPGRADE.md +++ b/components/js-api-client/UPGRADE.md @@ -1,3 +1,29 @@ +# Upgrade Guide + +## From v5 + +### `extraHeaders` type widened + +The `extraHeaders` option on `createClient` now accepts `Record | Headers | [string, string][]` instead of only `Record`. This is **not a breaking change** — all existing code continues to work. If you were casting headers to `Record`, you can now pass `Headers` instances or tuple arrays directly. + +### HTTP/2 stability + +The HTTP/2 transport now guards against double-settlement of promises when abort signals fire after a request has already completed. No API changes — this is a reliability fix. + +### `JSApiClientCallError.statusCode` alias + +A read-only `statusCode` getter was added as an alias for `code`, following the Node.js convention. Both properties return the same numeric HTTP status. + +### `http2IdleTimeout` option + +You can now configure the HTTP/2 session idle timeout via `createClient(config, { http2IdleTimeout: 60000 })`. The default remains 300 000 ms (5 minutes). + +### `timeout` option + +A request-level timeout can be set via `createClient(config, { timeout: 10000 })`. When set, requests that exceed the timeout are aborted with an `AbortError`. + +--- + # Upgrade Guide to v5 This guide helps you migrate from v4 to v5 of `@crystallize/js-api-client`. diff --git a/components/js-api-client/package.json b/components/js-api-client/package.json index 0b21259d..505cf662 100644 --- a/components/js-api-client/package.json +++ b/components/js-api-client/package.json @@ -1,7 +1,7 @@ { "name": "@crystallize/js-api-client", "license": "MIT", - "version": "5.3.0", + "version": "6.0.0", "type": "module", "author": "Crystallize (https://crystallize.com)", "contributors": [ @@ -28,18 +28,18 @@ "main": "./dist/index.cjs", "module": "./dist/index.js", "devDependencies": { - "@tsconfig/node22": "^22.0.2", - "@types/node": "^24.2.0", - "dotenv": "^16.6.1", - "tsup": "^8.5.0", - "typescript": "^5.9.2", - "vitest": "^3.2.4" + "@tsconfig/node22": "^22.0.5", + "@types/node": "^25.5.2", + "dotenv": "^17.4.0", + "tsup": "^8.5.1", + "typescript": "^6.0.2", + "vitest": "^4.1.2" }, "dependencies": { "@crystallize/schema": "workspace:*", "json-to-graphql-query": "^2.3.0", "mime-lite": "^1.0.3", - "zod": "^4.1.12" + "zod": "^4.3.6" }, "browser": { "fs": false, diff --git a/components/js-api-client/src/core/client/create-api-caller.ts b/components/js-api-client/src/core/client/create-api-caller.ts index 06b3d063..cba9e912 100644 --- a/components/js-api-client/src/core/client/create-api-caller.ts +++ b/components/js-api-client/src/core/client/create-api-caller.ts @@ -40,6 +40,20 @@ export class JSApiClientCallError extends Error { return this.code; } } +const normalizeHeaders = (headers: Record | Headers | [string, string][]): Record => { + if (headers instanceof Headers) { + const result: Record = {}; + headers.forEach((value, key) => { + result[key] = value; + }); + return result; + } + if (Array.isArray(headers)) { + return Object.fromEntries(headers); + } + return headers; +}; + export const createApiCaller = ( grab: Grab['grab'], uri: string, @@ -55,7 +69,7 @@ export const createApiCaller = ( variables, options?.extraHeaders ? { - headers: options.extraHeaders, + headers: normalizeHeaders(options.extraHeaders), } : undefined, options, diff --git a/components/js-api-client/src/core/client/create-client.ts b/components/js-api-client/src/core/client/create-client.ts index 895ddf9d..e0e2e948 100644 --- a/components/js-api-client/src/core/client/create-client.ts +++ b/components/js-api-client/src/core/client/create-client.ts @@ -30,7 +30,7 @@ export type ClientConfiguration = { export type CreateClientOptions = { useHttp2?: boolean; profiling?: ProfilingOptions; - extraHeaders?: Record; + extraHeaders?: Record | Headers | [string, string][]; /** Request timeout in milliseconds. When set, requests that take longer will be aborted. */ timeout?: number; /** HTTP/2 idle timeout in milliseconds. Defaults to 300000 (5 minutes). */ diff --git a/components/js-api-client/src/core/client/create-grabber.ts b/components/js-api-client/src/core/client/create-grabber.ts index 12af4831..9102417e 100644 --- a/components/js-api-client/src/core/client/create-grabber.ts +++ b/components/js-api-client/src/core/client/create-grabber.ts @@ -71,6 +71,18 @@ export const createGrabber = (options?: Options): Grab => { }; return new Promise((resolve, reject) => { + let settled = false; + const safeResolve = (value: GrabResponse) => { + if (settled) return; + settled = true; + resolve(value); + }; + const safeReject = (reason: unknown) => { + if (settled) return; + settled = true; + reject(reason); + }; + const urlObj = new URL(url); const origin = urlObj.origin; const client = getClient(origin); @@ -86,12 +98,12 @@ export const createGrabber = (options?: Options): Grab => { const signal = grabOptions.signal; if (signal.aborted) { req.close(); - reject(signal.reason); + safeReject(signal.reason); return; } const onAbort = () => { req.close(); - reject(signal.reason); + safeReject(signal.reason); }; signal.addEventListener('abort', onAbort, { once: true }); req.on('end', () => signal.removeEventListener('abort', onAbort)); @@ -128,12 +140,12 @@ export const createGrabber = (options?: Options): Grab => { req.on('end', () => { resetIdleTimeout(origin); - resolve(response); + safeResolve(response); }); req.on('error', (err) => { resetIdleTimeout(origin); - reject(err); + safeReject(err); }); }); req.end(); diff --git a/components/js-api-client/src/core/shop/create-cart-manager.ts b/components/js-api-client/src/core/shop/create-cart-manager.ts index 8239af4e..18986fcd 100644 --- a/components/js-api-client/src/core/shop/create-cart-manager.ts +++ b/components/js-api-client/src/core/shop/create-cart-manager.ts @@ -31,15 +31,25 @@ type WithId = R & { id: string }; * ``` */ export const createCartManager = (apiClient: ClientInterface) => { - const cartQuery = async (name: string, args: Record, onCart?: OC) => { + const cartQuery = async ( + name: string, + args: Record, + onCart?: CartExtra, + ) => { const query = { [name]: { __args: args, id: true, ...onCart } }; - const response = await apiClient.shopCartApi>>(jsonToGraphQLQuery({ query })); + const response = await apiClient.shopCartApi>>(jsonToGraphQLQuery({ query })); return response[name]; }; - const cartMutation = async (name: string, args: Record, onCart?: OC) => { + const cartMutation = async ( + name: string, + args: Record, + onCart?: CartExtra, + ) => { const mutation = { [name]: { __args: args, id: true, ...onCart } }; - const response = await apiClient.shopCartApi>>(jsonToGraphQLQuery({ mutation })); + const response = await apiClient.shopCartApi>>( + jsonToGraphQLQuery({ mutation }), + ); return response[name]; }; @@ -49,29 +59,40 @@ export const createCartManager = (apiClient: ClientInterface) => { }; return { - hydrate: async (intent: CartInput, onCart?: OC) => { + hydrate: async (intent: CartInput, onCart?: CartExtra) => { const input = CartInputSchema.parse(intent); - return cartMutation('hydrate', { input: transformCartInput(input) }, onCart); + return cartMutation('hydrate', { input: transformCartInput(input) }, onCart); }, - place: (id: string, onCart?: OC) => cartMutation('place', { id }, onCart), - fetch: (id: string, onCart?: OC) => cartQuery('cart', { id }, onCart), - fulfill: (id: string, orderId: string, onCart?: OC) => - cartMutation('fulfill', { id, orderId }, onCart), - abandon: (id: string, onCart?: OC) => cartMutation('abandon', { id }, onCart), - addSkuItem: async (id: string, intent: CartSkuItemInput, onCart?: OC) => { + place: (id: string, onCart?: CartExtra) => + cartMutation('place', { id }, onCart), + fetch: (id: string, onCart?: CartExtra) => + cartQuery('cart', { id }, onCart), + fulfill: (id: string, orderId: string, onCart?: CartExtra) => + cartMutation('fulfill', { id, orderId }, onCart), + abandon: (id: string, onCart?: CartExtra) => + cartMutation('abandon', { id }, onCart), + addSkuItem: async (id: string, intent: CartSkuItemInput, onCart?: CartExtra) => { const input = CartSkuItemInputSchema.parse(intent); - return cartMutation('addSkuItem', { id, input }, onCart); + return cartMutation('addSkuItem', { id, input }, onCart); }, - removeItem: ( + removeItem: ( id: string, { sku, quantity }: { sku: string; quantity: number }, - onCart?: OC, - ) => cartMutation('removeCartItem', { id, sku, quantity }, onCart), - setMeta: (id: string, { meta, merge }: MetaIntent, onCart?: OC) => - cartMutation('setMeta', { id, merge, meta }, onCart), - setCustomer: async (id: string, customerIntent: CustomerInput, onCart?: OC) => { + onCart?: CartExtra, + ) => cartMutation('removeCartItem', { id, sku, quantity }, onCart), + setMeta: (id: string, { meta, merge }: MetaIntent, onCart?: CartExtra) => + cartMutation('setMeta', { id, merge, meta }, onCart), + setCustomer: async ( + id: string, + customerIntent: CustomerInput, + onCart?: CartExtra, + ) => { const input = CustomerInputSchema.parse(customerIntent); - return cartMutation('setCustomer', { id, input: transformCartCustomerInput(input) }, onCart); + return cartMutation( + 'setCustomer', + { id, input: transformCartCustomerInput(input) }, + onCart, + ); }, }; }; diff --git a/components/js-api-client/tests/unit/create-api-caller.test.ts b/components/js-api-client/tests/unit/create-api-caller.test.ts index 2745b1e5..698557e6 100644 --- a/components/js-api-client/tests/unit/create-api-caller.test.ts +++ b/components/js-api-client/tests/unit/create-api-caller.test.ts @@ -6,32 +6,12 @@ import { JSApiClientCallError, } from '../../src/core/client/create-api-caller.js'; import type { Grab, GrabResponse } from '../../src/core/client/create-grabber.js'; -import type { ClientConfiguration } from '../../src/core/client/create-client.js'; - -const mockGrabResponse = (overrides: Partial & { jsonData?: unknown } = {}): GrabResponse => { - const { jsonData = { data: { test: true } }, ...rest } = overrides; - return { - ok: true, - status: 200, - statusText: 'OK', - headers: { get: () => null }, - json: () => Promise.resolve(jsonData as any), - text: () => Promise.resolve(JSON.stringify(jsonData)), - ...rest, - }; -}; +import { mockGrabResponse, defaultConfig } from './helpers.js'; const mockGrab = (response: GrabResponse): Grab['grab'] => { return vi.fn().mockResolvedValue(response); }; -const defaultConfig: ClientConfiguration = { - tenantIdentifier: 'test-tenant', - tenantId: 'test-id', - accessTokenId: 'token-id', - accessTokenSecret: 'token-secret', -}; - describe('authenticationHeaders', () => { test('returns session cookie when sessionId is set', () => { const headers = authenticationHeaders({ ...defaultConfig, sessionId: 'sess123' }); diff --git a/components/js-api-client/tests/unit/error-handling.test.ts b/components/js-api-client/tests/unit/error-handling.test.ts index 4cd0cec3..9fb36c0a 100644 --- a/components/js-api-client/tests/unit/error-handling.test.ts +++ b/components/js-api-client/tests/unit/error-handling.test.ts @@ -1,27 +1,6 @@ import { describe, test, expect, vi } from 'vitest'; import { post, JSApiClientCallError } from '../../src/core/client/create-api-caller.js'; -import type { GrabResponse } from '../../src/core/client/create-grabber.js'; -import type { ClientConfiguration } from '../../src/core/client/create-client.js'; - -const mockGrabResponse = (overrides: Partial & { jsonData?: unknown } = {}): GrabResponse => { - const { jsonData = { data: {} }, ...rest } = overrides; - return { - ok: true, - status: 200, - statusText: 'OK', - headers: { get: () => null }, - json: () => Promise.resolve(jsonData as any), - text: () => Promise.resolve(JSON.stringify(jsonData)), - ...rest, - }; -}; - -const defaultConfig: ClientConfiguration = { - tenantIdentifier: 'test-tenant', - tenantId: 'test-id', - accessTokenId: 'token-id', - accessTokenSecret: 'token-secret', -}; +import { mockGrabResponse, defaultConfig } from './helpers.js'; const query = '{ items { id name } }'; const variables = { lang: 'en' }; diff --git a/components/js-api-client/tests/unit/helpers.ts b/components/js-api-client/tests/unit/helpers.ts new file mode 100644 index 00000000..f3fa0e99 --- /dev/null +++ b/components/js-api-client/tests/unit/helpers.ts @@ -0,0 +1,22 @@ +import type { GrabResponse } from '../../src/core/client/create-grabber.js'; +import type { ClientConfiguration } from '../../src/core/client/create-client.js'; + +export const mockGrabResponse = (overrides: Partial & { jsonData?: unknown } = {}): GrabResponse => { + const { jsonData = { data: { test: true } }, ...rest } = overrides; + return { + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null }, + json: () => Promise.resolve(jsonData as any), + text: () => Promise.resolve(JSON.stringify(jsonData)), + ...rest, + }; +}; + +export const defaultConfig: ClientConfiguration = { + tenantIdentifier: 'test-tenant', + tenantId: 'test-id', + accessTokenId: 'token-id', + accessTokenSecret: 'token-secret', +}; diff --git a/components/js-api-client/tsconfig.json b/components/js-api-client/tsconfig.json index baddaa4d..c2c35701 100644 --- a/components/js-api-client/tsconfig.json +++ b/components/js-api-client/tsconfig.json @@ -1,9 +1,11 @@ { "extends": "@tsconfig/node22/tsconfig.json", "compilerOptions": { + "ignoreDeprecations": "6.0", "outDir": "./dist", "declaration": true, "lib": ["es2021", "DOM", "esnext.disposable"], + "types": ["node"], "sourceMap": true }, "include": ["./src/**/*"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1373863..ab54bc05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,13 +18,13 @@ importers: dependencies: '@astrojs/react': specifier: ^4.4.0 - version: 4.4.0(@types/node@24.2.0)(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(jiti@2.5.1)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(sass-embedded@1.82.0) + version: 4.4.0(@types/node@25.5.2)(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(jiti@2.5.1)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(sass-embedded@1.82.0) '@astrojs/starlight': specifier: ^0.36.0 - version: 0.36.0(astro@5.14.1(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@5.9.2)) + version: 0.36.0(astro@5.14.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@6.0.2)) '@astrojs/starlight-tailwind': specifier: ^4.0.1 - version: 4.0.1(@astrojs/starlight@0.36.0(astro@5.14.1(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@5.9.2)))(tailwindcss@4.1.11) + version: 4.0.1(@astrojs/starlight@0.36.0(astro@5.14.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@6.0.2)))(tailwindcss@4.1.11) '@crystallize/js-api-client': specifier: workspace:* version: link:../../components/js-api-client @@ -33,7 +33,7 @@ importers: version: link:../../components/reactjs-components '@tailwindcss/vite': specifier: ^4.1.11 - version: 4.1.11(vite@6.3.6(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0)) + version: 4.1.11(vite@6.3.6(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0)) '@types/react': specifier: ^19.1.9 version: 19.1.9 @@ -42,7 +42,7 @@ importers: version: 19.1.7(@types/react@19.1.9) astro: specifier: ^5.14.1 - version: 5.14.1(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@5.9.2) + version: 5.14.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@6.0.2) react: specifier: ^19.1.1 version: 19.1.1 @@ -93,27 +93,27 @@ importers: specifier: ^1.0.3 version: 1.0.3 zod: - specifier: ^4.1.12 - version: 4.1.12 + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@tsconfig/node22': - specifier: ^22.0.2 - version: 22.0.2 + specifier: ^22.0.5 + version: 22.0.5 '@types/node': - specifier: ^24.2.0 - version: 24.2.0 + specifier: ^25.5.2 + version: 25.5.2 dotenv: - specifier: ^16.6.1 - version: 16.6.1 + specifier: ^17.4.0 + version: 17.4.0 tsup: - specifier: ^8.5.0 - version: 8.5.0(jiti@2.5.1)(postcss@8.5.6)(typescript@5.9.2) + specifier: ^8.5.1 + version: 8.5.1(jiti@2.5.1)(postcss@8.5.6)(typescript@6.0.2) typescript: - specifier: ^5.9.2 - version: 5.9.2 + specifier: ^6.0.2 + version: 6.0.2 vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0) + specifier: ^4.1.2 + version: 4.1.2(@types/node@25.5.2)(vite@7.1.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0)) components/reactjs-components: dependencies: @@ -457,6 +457,15 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.7': + resolution: + { + integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==, + } + engines: { node: '>=18' } + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.8': resolution: { @@ -466,6 +475,15 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.7': + resolution: + { + integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.8': resolution: { @@ -475,6 +493,15 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.7': + resolution: + { + integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==, + } + engines: { node: '>=18' } + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.8': resolution: { @@ -484,6 +511,15 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.7': + resolution: + { + integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.8': resolution: { @@ -493,6 +529,15 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.7': + resolution: + { + integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.8': resolution: { @@ -502,6 +547,15 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.7': + resolution: + { + integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.8': resolution: { @@ -511,6 +565,15 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': + resolution: + { + integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.8': resolution: { @@ -520,6 +583,15 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': + resolution: + { + integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.8': resolution: { @@ -529,6 +601,15 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.7': + resolution: + { + integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.8': resolution: { @@ -538,6 +619,15 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.7': + resolution: + { + integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==, + } + engines: { node: '>=18' } + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.8': resolution: { @@ -547,6 +637,15 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.7': + resolution: + { + integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==, + } + engines: { node: '>=18' } + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.8': resolution: { @@ -556,6 +655,15 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.7': + resolution: + { + integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==, + } + engines: { node: '>=18' } + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.8': resolution: { @@ -565,6 +673,15 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.7': + resolution: + { + integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==, + } + engines: { node: '>=18' } + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.8': resolution: { @@ -574,6 +691,15 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.7': + resolution: + { + integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==, + } + engines: { node: '>=18' } + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.8': resolution: { @@ -583,6 +709,15 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.7': + resolution: + { + integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==, + } + engines: { node: '>=18' } + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.8': resolution: { @@ -592,6 +727,15 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.7': + resolution: + { + integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==, + } + engines: { node: '>=18' } + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.8': resolution: { @@ -601,6 +745,15 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.7': + resolution: + { + integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.8': resolution: { @@ -610,6 +763,15 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.7': + resolution: + { + integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.8': resolution: { @@ -619,6 +781,15 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': + resolution: + { + integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.8': resolution: { @@ -628,6 +799,15 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.7': + resolution: + { + integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.8': resolution: { @@ -637,6 +817,15 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': + resolution: + { + integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.8': resolution: { @@ -646,6 +835,15 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.7': + resolution: + { + integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.8': resolution: { @@ -655,6 +853,15 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.7': + resolution: + { + integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.8': resolution: { @@ -664,6 +871,15 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.7': + resolution: + { + integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.8': resolution: { @@ -673,6 +889,15 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.7': + resolution: + { + integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==, + } + engines: { node: '>=18' } + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.8': resolution: { @@ -682,6 +907,15 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.7': + resolution: + { + integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [win32] + '@expressive-code/core@0.41.3': resolution: { @@ -1260,6 +1494,12 @@ packages: integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==, } + '@standard-schema/spec@1.1.0': + resolution: + { + integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==, + } + '@swc/helpers@0.5.17': resolution: { @@ -1413,6 +1653,12 @@ packages: integrity: sha512-Kmwj4u8sDRDrMYRoN9FDEcXD8UpBSaPQQ24Gz+Gamqfm7xxn+GBR7ge/Z7pK8OXNGyUzbSwJj+TH6B+DS/epyA==, } + '@tsconfig/node22@22.0.5': + resolution: + { + integrity: sha512-hLf2ld+sYN/BtOJjHUWOk568dvjFQkHnLNa6zce25GIH+vxKfvTgm3qpaH6ToF5tu/NN0IH66s+Bb5wElHrLcw==, + } + '@types/babel__core@7.20.5': resolution: { @@ -1533,6 +1779,12 @@ packages: integrity: sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==, } + '@types/node@25.5.2': + resolution: + { + integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==, + } + '@types/react-dom@19.1.7': resolution: { @@ -1586,6 +1838,12 @@ packages: integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==, } + '@vitest/expect@4.1.2': + resolution: + { + integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==, + } + '@vitest/mocker@3.2.4': resolution: { @@ -1600,36 +1858,80 @@ packages: vite: optional: true + '@vitest/mocker@4.1.2': + resolution: + { + integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==, + } + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@3.2.4': resolution: { integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==, } + '@vitest/pretty-format@4.1.2': + resolution: + { + integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==, + } + '@vitest/runner@3.2.4': resolution: { integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==, } + '@vitest/runner@4.1.2': + resolution: + { + integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==, + } + '@vitest/snapshot@3.2.4': resolution: { integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==, } + '@vitest/snapshot@4.1.2': + resolution: + { + integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==, + } + '@vitest/spy@3.2.4': resolution: { integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==, } + '@vitest/spy@4.1.2': + resolution: + { + integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==, + } + '@vitest/utils@3.2.4': resolution: { integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==, } + '@vitest/utils@4.1.2': + resolution: + { + integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==, + } + acorn-jsx@5.3.2: resolution: { @@ -1878,6 +2180,13 @@ packages: } engines: { node: '>=18' } + chai@6.2.2: + resolution: + { + integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==, + } + engines: { node: '>=18' } + chalk@5.5.0: resolution: { @@ -2208,6 +2517,13 @@ packages: } engines: { node: '>=12' } + dotenv@17.4.0: + resolution: + { + integrity: sha512-kCKF62fwtzwYm0IGBNjRUjtJgMfGapII+FslMHIjMR5KTnwEmBmWLDRSnc3XSNP8bNy34tekgQyDT0hr7pERRQ==, + } + engines: { node: '>=12' } + dset@3.1.4: resolution: { @@ -2265,6 +2581,12 @@ packages: integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==, } + es-module-lexer@2.0.0: + resolution: + { + integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==, + } + esast-util-from-estree@2.0.0: resolution: { @@ -2285,6 +2607,14 @@ packages: engines: { node: '>=18' } hasBin: true + esbuild@0.27.7: + resolution: + { + integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==, + } + engines: { node: '>=18' } + hasBin: true + escalade@3.2.0: resolution: { @@ -2360,6 +2690,13 @@ packages: } engines: { node: '>=12.0.0' } + expect-type@1.3.0: + resolution: + { + integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==, + } + engines: { node: '>=12.0.0' } + expressive-code@0.41.3: resolution: { @@ -2389,6 +2726,18 @@ packages: picomatch: optional: true + fdir@6.5.0: + resolution: + { + integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==, + } + engines: { node: '>=12.0.0' } + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fix-dts-default-cjs-exports@1.0.1: resolution: { @@ -2989,6 +3338,12 @@ packages: integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==, } + magic-string@0.30.21: + resolution: + { + integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==, + } + magicast@0.3.5: resolution: { @@ -3475,6 +3830,12 @@ packages: } engines: { node: '>=0.10.0' } + obug@2.1.1: + resolution: + { + integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==, + } + ofetch@1.4.1: resolution: { @@ -4271,6 +4632,12 @@ packages: integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==, } + std-env@4.0.0: + resolution: + { + integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==, + } + stream-replace-string@2.0.0: resolution: { @@ -4416,6 +4783,13 @@ packages: integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==, } + tinyexec@1.0.4: + resolution: + { + integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==, + } + engines: { node: '>=18' } + tinyglobby@0.2.14: resolution: { @@ -4423,6 +4797,13 @@ packages: } engines: { node: '>=12.0.0' } + tinyglobby@0.2.15: + resolution: + { + integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==, + } + engines: { node: '>=12.0.0' } + tinypool@1.1.1: resolution: { @@ -4437,6 +4818,13 @@ packages: } engines: { node: '>=14.0.0' } + tinyrainbow@3.1.0: + resolution: + { + integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==, + } + engines: { node: '>=14.0.0' } + tinyspy@4.0.3: resolution: { @@ -4522,6 +4910,28 @@ packages: typescript: optional: true + tsup@8.5.1: + resolution: + { + integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==, + } + engines: { node: '>=18' } + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + turbo-darwin-64@2.5.5: resolution: { @@ -4592,6 +5002,14 @@ packages: engines: { node: '>=14.17' } hasBin: true + typescript@6.0.2: + resolution: + { + integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==, + } + engines: { node: '>=14.17' } + hasBin: true + ufo@1.6.1: resolution: { @@ -4622,6 +5040,12 @@ packages: integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==, } + undici-types@7.18.2: + resolution: + { + integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==, + } + unicode-properties@1.4.1: resolution: { @@ -4952,6 +5376,44 @@ packages: jsdom: optional: true + vitest@4.1.2: + resolution: + { + integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==, + } + engines: { node: ^20.0.0 || ^22.0.0 || >=24.0.0 } + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.2 + '@vitest/browser-preview': 4.1.2 + '@vitest/browser-webdriverio': 4.1.2 + '@vitest/ui': 4.1.2 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + web-namespaces@2.0.1: resolution: { @@ -5103,12 +5565,6 @@ packages: integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, } - zod@4.1.12: - resolution: - { - integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==, - } - zod@4.3.6: resolution: { @@ -5185,12 +5641,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/mdx@4.3.3(astro@5.14.1(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@5.9.2))': + '@astrojs/mdx@4.3.3(astro@5.14.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@6.0.2))': dependencies: '@astrojs/markdown-remark': 6.3.5 '@mdx-js/mdx': 3.1.0(acorn@8.15.0) acorn: 8.15.0 - astro: 5.14.1(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@5.9.2) + astro: 5.14.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@6.0.2) es-module-lexer: 1.7.0 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -5208,15 +5664,15 @@ snapshots: dependencies: prismjs: 1.30.0 - '@astrojs/react@4.4.0(@types/node@24.2.0)(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(jiti@2.5.1)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(sass-embedded@1.82.0)': + '@astrojs/react@4.4.0(@types/node@25.5.2)(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(jiti@2.5.1)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(sass-embedded@1.82.0)': dependencies: '@types/react': 19.1.9 '@types/react-dom': 19.1.7(@types/react@19.1.9) - '@vitejs/plugin-react': 4.7.0(vite@6.3.6(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0)) + '@vitejs/plugin-react': 4.7.0(vite@6.3.6(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0)) react: 19.1.1 react-dom: 19.1.1(react@19.1.1) ultrahtml: 1.6.0 - vite: 6.3.6(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0) + vite: 6.3.6(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0) transitivePeerDependencies: - '@types/node' - jiti @@ -5237,22 +5693,22 @@ snapshots: stream-replace-string: 2.0.0 zod: 3.25.76 - '@astrojs/starlight-tailwind@4.0.1(@astrojs/starlight@0.36.0(astro@5.14.1(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@5.9.2)))(tailwindcss@4.1.11)': + '@astrojs/starlight-tailwind@4.0.1(@astrojs/starlight@0.36.0(astro@5.14.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@6.0.2)))(tailwindcss@4.1.11)': dependencies: - '@astrojs/starlight': 0.36.0(astro@5.14.1(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@5.9.2)) + '@astrojs/starlight': 0.36.0(astro@5.14.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@6.0.2)) tailwindcss: 4.1.11 - '@astrojs/starlight@0.36.0(astro@5.14.1(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@5.9.2))': + '@astrojs/starlight@0.36.0(astro@5.14.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@6.0.2))': dependencies: '@astrojs/markdown-remark': 6.3.5 - '@astrojs/mdx': 4.3.3(astro@5.14.1(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@5.9.2)) + '@astrojs/mdx': 4.3.3(astro@5.14.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@6.0.2)) '@astrojs/sitemap': 3.4.2 '@pagefind/default-ui': 1.3.0 '@types/hast': 3.0.4 '@types/js-yaml': 4.0.9 '@types/mdast': 4.0.4 - astro: 5.14.1(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@5.9.2) - astro-expressive-code: 0.41.3(astro@5.14.1(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@5.9.2)) + astro: 5.14.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@6.0.2) + astro-expressive-code: 0.41.3(astro@5.14.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@6.0.2)) bcp-47: 2.1.0 hast-util-from-html: 2.0.3 hast-util-select: 6.0.4 @@ -5422,81 +5878,159 @@ snapshots: '@esbuild/aix-ppc64@0.25.8': optional: true + '@esbuild/aix-ppc64@0.27.7': + optional: true + '@esbuild/android-arm64@0.25.8': optional: true + '@esbuild/android-arm64@0.27.7': + optional: true + '@esbuild/android-arm@0.25.8': optional: true + '@esbuild/android-arm@0.27.7': + optional: true + '@esbuild/android-x64@0.25.8': optional: true + '@esbuild/android-x64@0.27.7': + optional: true + '@esbuild/darwin-arm64@0.25.8': optional: true + '@esbuild/darwin-arm64@0.27.7': + optional: true + '@esbuild/darwin-x64@0.25.8': optional: true + '@esbuild/darwin-x64@0.27.7': + optional: true + '@esbuild/freebsd-arm64@0.25.8': optional: true + '@esbuild/freebsd-arm64@0.27.7': + optional: true + '@esbuild/freebsd-x64@0.25.8': optional: true + '@esbuild/freebsd-x64@0.27.7': + optional: true + '@esbuild/linux-arm64@0.25.8': optional: true + '@esbuild/linux-arm64@0.27.7': + optional: true + '@esbuild/linux-arm@0.25.8': optional: true + '@esbuild/linux-arm@0.27.7': + optional: true + '@esbuild/linux-ia32@0.25.8': optional: true + '@esbuild/linux-ia32@0.27.7': + optional: true + '@esbuild/linux-loong64@0.25.8': optional: true + '@esbuild/linux-loong64@0.27.7': + optional: true + '@esbuild/linux-mips64el@0.25.8': optional: true + '@esbuild/linux-mips64el@0.27.7': + optional: true + '@esbuild/linux-ppc64@0.25.8': optional: true + '@esbuild/linux-ppc64@0.27.7': + optional: true + '@esbuild/linux-riscv64@0.25.8': optional: true + '@esbuild/linux-riscv64@0.27.7': + optional: true + '@esbuild/linux-s390x@0.25.8': optional: true + '@esbuild/linux-s390x@0.27.7': + optional: true + '@esbuild/linux-x64@0.25.8': optional: true + '@esbuild/linux-x64@0.27.7': + optional: true + '@esbuild/netbsd-arm64@0.25.8': optional: true + '@esbuild/netbsd-arm64@0.27.7': + optional: true + '@esbuild/netbsd-x64@0.25.8': optional: true + '@esbuild/netbsd-x64@0.27.7': + optional: true + '@esbuild/openbsd-arm64@0.25.8': optional: true + '@esbuild/openbsd-arm64@0.27.7': + optional: true + '@esbuild/openbsd-x64@0.25.8': optional: true + '@esbuild/openbsd-x64@0.27.7': + optional: true + '@esbuild/openharmony-arm64@0.25.8': optional: true + '@esbuild/openharmony-arm64@0.27.7': + optional: true + '@esbuild/sunos-x64@0.25.8': optional: true + '@esbuild/sunos-x64@0.27.7': + optional: true + '@esbuild/win32-arm64@0.25.8': optional: true + '@esbuild/win32-arm64@0.27.7': + optional: true + '@esbuild/win32-ia32@0.25.8': optional: true + '@esbuild/win32-ia32@0.27.7': + optional: true + '@esbuild/win32-x64@0.25.8': optional: true + '@esbuild/win32-x64@0.27.7': + optional: true + '@expressive-code/core@0.41.3': dependencies: '@ctrl/tinycolor': 4.1.0 @@ -5823,6 +6357,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.17': dependencies: tslib: 2.8.1 @@ -5891,17 +6427,19 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.11 '@tailwindcss/oxide-win32-x64-msvc': 4.1.11 - '@tailwindcss/vite@4.1.11(vite@6.3.6(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0))': + '@tailwindcss/vite@4.1.11(vite@6.3.6(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0))': dependencies: '@tailwindcss/node': 4.1.11 '@tailwindcss/oxide': 4.1.11 tailwindcss: 4.1.11 - vite: 6.3.6(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0) + vite: 6.3.6(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0) '@tsconfig/node20@20.1.6': {} '@tsconfig/node22@22.0.2': {} + '@tsconfig/node22@22.0.5': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.0 @@ -5973,6 +6511,10 @@ snapshots: dependencies: undici-types: 7.10.0 + '@types/node@25.5.2': + dependencies: + undici-types: 7.18.2 + '@types/react-dom@19.1.7(@types/react@19.1.9)': dependencies: '@types/react': 19.1.9 @@ -5991,7 +6533,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@4.7.0(vite@6.3.6(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0))': + '@vitejs/plugin-react@4.7.0(vite@6.3.6(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0))': dependencies: '@babel/core': 7.28.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0) @@ -5999,7 +6541,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.3.6(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0) + vite: 6.3.6(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0) transitivePeerDependencies: - supports-color @@ -6011,6 +6553,15 @@ snapshots: chai: 5.2.1 tinyrainbow: 2.0.0 + '@vitest/expect@4.1.2': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.2 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + chai: 6.2.2 + tinyrainbow: 3.1.0 + '@vitest/mocker@3.2.4(vite@7.1.1(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0))': dependencies: '@vitest/spy': 3.2.4 @@ -6019,32 +6570,64 @@ snapshots: optionalDependencies: vite: 7.1.1(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0) + '@vitest/mocker@4.1.2(vite@7.1.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0))': + dependencies: + '@vitest/spy': 4.1.2 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.1.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0) + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.1.2': + dependencies: + tinyrainbow: 3.1.0 + '@vitest/runner@3.2.4': dependencies: '@vitest/utils': 3.2.4 pathe: 2.0.3 strip-literal: 3.0.0 + '@vitest/runner@4.1.2': + dependencies: + '@vitest/utils': 4.1.2 + pathe: 2.0.3 + '@vitest/snapshot@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 magic-string: 0.30.17 pathe: 2.0.3 + '@vitest/snapshot@4.1.2': + dependencies: + '@vitest/pretty-format': 4.1.2 + '@vitest/utils': 4.1.2 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.3 + '@vitest/spy@4.1.2': {} + '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 loupe: 3.2.0 tinyrainbow: 2.0.0 + '@vitest/utils@4.1.2': + dependencies: + '@vitest/pretty-format': 4.1.2 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -6084,12 +6667,12 @@ snapshots: astring@1.9.0: {} - astro-expressive-code@0.41.3(astro@5.14.1(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@5.9.2)): + astro-expressive-code@0.41.3(astro@5.14.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@6.0.2)): dependencies: - astro: 5.14.1(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@5.9.2) + astro: 5.14.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@6.0.2) rehype-expressive-code: 0.41.3 - astro@5.14.1(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@5.9.2): + astro@5.14.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(rollup@4.46.2)(sass-embedded@1.82.0)(typescript@6.0.2): dependencies: '@astrojs/compiler': 2.12.2 '@astrojs/internal-helpers': 0.7.3 @@ -6139,20 +6722,20 @@ snapshots: smol-toml: 1.4.2 tinyexec: 0.3.2 tinyglobby: 0.2.14 - tsconfck: 3.1.6(typescript@5.9.2) + tsconfck: 3.1.6(typescript@6.0.2) ultrahtml: 1.6.0 unifont: 0.5.2 unist-util-visit: 5.0.0 unstorage: 1.17.1 vfile: 6.0.3 - vite: 6.3.6(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0) - vitefu: 1.1.1(vite@6.3.6(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0)) + vite: 6.3.6(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0) + vitefu: 1.1.1(vite@6.3.6(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0)) xxhash-wasm: 1.1.0 yargs-parser: 21.1.1 yocto-spinner: 0.2.3 zod: 3.25.76 zod-to-json-schema: 3.24.6(zod@3.25.76) - zod-to-ts: 1.2.0(typescript@5.9.2)(zod@3.25.76) + zod-to-ts: 1.2.0(typescript@6.0.2)(zod@3.25.76) optionalDependencies: sharp: 0.34.3 transitivePeerDependencies: @@ -6247,6 +6830,11 @@ snapshots: esbuild: 0.25.8 load-tsconfig: 0.2.5 + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + cac@6.7.14: {} camelcase@8.0.0: {} @@ -6263,6 +6851,8 @@ snapshots: loupe: 3.2.0 pathval: 2.0.1 + chai@6.2.2: {} + chalk@5.5.0: {} character-entities-html4@2.1.0: {} @@ -6395,6 +6985,8 @@ snapshots: dotenv@16.6.1: {} + dotenv@17.4.0: {} + dset@3.1.4: {} eastasianwidth@0.2.0: {} @@ -6416,6 +7008,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.0.0: {} + esast-util-from-estree@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -6459,6 +7053,35 @@ snapshots: '@esbuild/win32-ia32': 0.25.8 '@esbuild/win32-x64': 0.25.8 + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + escalade@3.2.0: {} escape-string-regexp@5.0.0: {} @@ -6502,6 +7125,8 @@ snapshots: expect-type@1.2.2: {} + expect-type@1.3.0: {} + expressive-code@0.41.3: dependencies: '@expressive-code/core': 0.41.3 @@ -6517,9 +7142,13 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + fix-dts-default-cjs-exports@1.0.1: dependencies: - magic-string: 0.30.17 + magic-string: 0.30.19 mlly: 1.7.4 rollup: 4.46.2 @@ -6930,6 +7559,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: dependencies: '@babel/parser': 7.28.0 @@ -7462,6 +8095,8 @@ snapshots: object-assign@4.1.1: {} + obug@2.1.1: {} + ofetch@1.4.1: dependencies: destr: 2.0.5 @@ -8000,6 +8635,8 @@ snapshots: std-env@3.9.0: {} + std-env@4.0.0: {} + stream-replace-string@2.0.0: {} string-width@4.2.3: @@ -8095,15 +8732,24 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.0.4: {} + tinyglobby@0.2.14: dependencies: fdir: 6.4.6(picomatch@4.0.3) picomatch: 4.0.3 + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + tinypool@1.1.1: {} tinyrainbow@2.0.0: {} + tinyrainbow@3.1.0: {} + tinyspy@4.0.3: {} tr46@0.0.3: {} @@ -8120,9 +8766,9 @@ snapshots: ts-interface-checker@0.1.13: {} - tsconfck@3.1.6(typescript@5.9.2): + tsconfck@3.1.6(typescript@6.0.2): optionalDependencies: - typescript: 5.9.2 + typescript: 6.0.2 tslib@2.8.1: {} @@ -8154,6 +8800,34 @@ snapshots: - tsx - yaml + tsup@8.5.1(jiti@2.5.1)(postcss@8.5.6)(typescript@6.0.2): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.1 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.5.1)(postcss@8.5.6) + resolve-from: 5.0.0 + rollup: 4.46.2 + source-map: 0.7.6 + sucrase: 3.35.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.14 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.6 + typescript: 6.0.2 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + turbo-darwin-64@2.5.5: optional: true @@ -8185,6 +8859,8 @@ snapshots: typescript@5.9.2: {} + typescript@6.0.2: {} + ufo@1.6.1: {} ultrahtml@1.6.0: {} @@ -8195,6 +8871,8 @@ snapshots: undici-types@7.10.0: {} + undici-types@7.18.2: {} + unicode-properties@1.4.1: dependencies: base64-js: 1.5.1 @@ -8329,7 +9007,7 @@ snapshots: - tsx - yaml - vite@6.3.6(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0): + vite@6.3.6(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0): dependencies: esbuild: 0.25.8 fdir: 6.4.6(picomatch@4.0.3) @@ -8338,7 +9016,7 @@ snapshots: rollup: 4.46.2 tinyglobby: 0.2.14 optionalDependencies: - '@types/node': 24.2.0 + '@types/node': 25.5.2 fsevents: 2.3.3 jiti: 2.5.1 lightningcss: 1.30.1 @@ -8359,9 +9037,24 @@ snapshots: lightningcss: 1.30.1 sass-embedded: 1.82.0 - vitefu@1.1.1(vite@6.3.6(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0)): + vite@7.1.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0): + dependencies: + esbuild: 0.25.8 + fdir: 6.4.6(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.46.2 + tinyglobby: 0.2.14 + optionalDependencies: + '@types/node': 25.5.2 + fsevents: 2.3.3 + jiti: 2.5.1 + lightningcss: 1.30.1 + sass-embedded: 1.82.0 + + vitefu@1.1.1(vite@6.3.6(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0)): optionalDependencies: - vite: 6.3.6(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0) + vite: 6.3.6(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0) vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.2.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0): dependencies: @@ -8405,6 +9098,33 @@ snapshots: - tsx - yaml + vitest@4.1.2(@types/node@25.5.2)(vite@7.1.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0)): + dependencies: + '@vitest/expect': 4.1.2 + '@vitest/mocker': 4.1.2(vite@7.1.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0)) + '@vitest/pretty-format': 4.1.2 + '@vitest/runner': 4.1.2 + '@vitest/snapshot': 4.1.2 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.1.1(@types/node@25.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.82.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.5.2 + transitivePeerDependencies: + - msw + web-namespaces@2.0.1: {} webidl-conversions@3.0.1: {} @@ -8475,15 +9195,13 @@ snapshots: dependencies: zod: 3.25.76 - zod-to-ts@1.2.0(typescript@5.9.2)(zod@3.25.76): + zod-to-ts@1.2.0(typescript@6.0.2)(zod@3.25.76): dependencies: - typescript: 5.9.2 + typescript: 6.0.2 zod: 3.25.76 zod@3.25.76: {} - zod@4.1.12: {} - zod@4.3.6: {} zwitch@2.0.4: {}