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 diff --git a/components/js-api-client/README.md b/components/js-api-client/README.md index 8f0fcc68..0924a4ea 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. @@ -378,7 +397,36 @@ 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 +444,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 +463,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/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 65715f32..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": [ @@ -26,20 +26,20 @@ }, "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", - "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/catalogue/create-catalogue-fetcher.ts b/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts index 2d049afa..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); @@ -64,7 +83,7 @@ function onFolder(onFolder?: OF, c?: CatalogueFetcherGrapqhqlOnFol const children = () => { if (c?.onChildren) { return { - chidlren: { + children: { ...c.onChildren, }, }; 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 abca70e8..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 @@ -87,7 +87,7 @@ function byPaths(client: ClientInterface, options?: ProductHydraterOptions): Pro }, {} as any); const query = { - ...{ ...productListQuery }, + ...productListQuery, ...(extraQuery !== undefined ? extraQuery : {}), }; @@ -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-api-caller.ts b/components/js-api-client/src/core/client/create-api-caller.ts index 4e864f70..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 @@ -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; @@ -27,13 +27,33 @@ export class JSApiClientCallError extends Error { variables: VariablesType; }) { super(message); + this.name = 'JSApiClientCallError'; this.code = code; this.statusText = statusText; this.errors = errors; this.query = query; this.variables = variables; } + + /** Alias for `code` — follows the Node.js convention of numeric `statusCode`. */ + get statusCode(): number { + 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, @@ -49,7 +69,7 @@ export const createApiCaller = ( variables, options?.extraHeaders ? { - headers: options.extraHeaders, + headers: normalizeHeaders(options.extraHeaders), } : undefined, options, @@ -68,10 +88,13 @@ export const authenticationHeaders = (config: ClientConfiguration): Record( config: ClientConfiguration, query: string, variables?: VariablesType, - init?: RequestInit | any | undefined, + init?: GrabOptions, 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 timeout = options?.timeout; + const signal = timeout ? AbortSignal.timeout(timeout) : undefined; - 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 || {}, - }); + const response = await grab(path, { + ...initRest, + method: 'POST', + headers, + body, + signal, + }); + + 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 durMatch = serverTiming?.match(/dur=([\d.]+)/); + const duration = durMatch ? durMatch[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; }; 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..e0e2e948 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; @@ -29,7 +30,11 @@ export type ClientConfiguration = { export type CreateClientOptions = { useHttp2?: boolean; profiling?: ProfilingOptions; - extraHeaders?: RequestInit['headers']; + 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). */ + http2IdleTimeout?: number; shopApiToken?: { doNotFetch?: boolean; scopes?: string[]; @@ -47,10 +52,29 @@ 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({ useHttp2: options?.useHttp2, + http2IdleTimeout: options?.http2IdleTimeout, }); // let's rewrite the configuration based on the need of the endpoint @@ -100,5 +124,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/client/create-grabber.ts b/components/js-api-client/src/core/client/create-grabber.ts index 81490987..9102417e 100644 --- a/components/js-api-client/src/core/client/create-grabber.ts +++ b/components/js-api-client/src/core/client/create-grabber.ts @@ -10,20 +10,31 @@ export type GrabResponse = { json: () => Promise; text: () => Promise; }; +export type GrabOptions = { + method?: string; + headers?: Record; + body?: string; + signal?: AbortSignal; +}; export type Grab = { - grab: (url: string, options?: RequestInit | any | undefined) => Promise; + grab: (url: string, options?: GrabOptions) => Promise; close: () => void; }; type Options = { useHttp2?: boolean; + http2IdleTimeout?: number; }; 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 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) { - return fetch(url, grabOptions); + const { signal, ...fetchOptions } = grabOptions || {}; + return fetch(url, { ...fetchOptions, signal }); } const closeAndDeleteClient = (origin: string) => { const clientObj = clients.get(origin); @@ -35,7 +46,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(() => { @@ -44,7 +58,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', () => { @@ -53,21 +67,50 @@ 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) => { + 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); 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?.signal) { + const signal = grabOptions.signal; + if (signal.aborted) { + req.close(); + safeReject(signal.reason); + return; + } + const onAbort = () => { + req.close(); + safeReject(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); } req.setEncoding('utf8'); @@ -97,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/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/create-mass-call-client.ts b/components/js-api-client/src/core/create-mass-call-client.ts index 22d0af65..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 @@ -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; @@ -59,13 +61,36 @@ const createFibonacciSleeper = (): Sleeper => { }; }; +let hasWarnedDeprecation = false; + /** - * 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. + * + * @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. * - * @param client ClientInterface - * @param options Object - * @returns MassClientInterface + * ```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. + * @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, @@ -74,19 +99,32 @@ 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; }, ): 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; @@ -97,16 +135,16 @@ 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, @@ -128,20 +166,18 @@ export function createMassCallClient( return buildStandardPromise(promise); } - // otherwise we wrap it - return new Promise(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) => { @@ -152,7 +188,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; @@ -206,32 +242,16 @@ export function createMassCallClient( nextPimApi: client.nextPimApi, config: client.config, close: client.close, - 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; - }, - }, + [Symbol.dispose]: client[Symbol.dispose], + 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'], }; } 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-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..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,10 +12,27 @@ 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 ( + const create = async ( intentCustomer: CreateCustomerInput, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise> => { const input = CreateCustomerInputSchema.parse(intentCustomer); const mutation = { @@ -43,9 +60,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 +92,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 +131,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 +157,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..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?: OO, onOrderItem?: OOI, onCustomer?: OC) => { +const buildBaseQuery = ( + onOrder?: OrderExtra, + onOrderItem?: OrderItemExtra, + onCustomer?: CustomerExtra, +) => { const priceQuery = { gross: true, net: true, @@ -62,25 +66,39 @@ type PageInfo = { endCursor: string; }; -type EnhanceQuery = { - onOrder?: OO; - onOrderItem?: OOI; - onCustomer?: OC; +type EnhanceQuery = { + onOrder?: OrderExtra; + onOrderItem?: OrderItemExtra; + onCustomer?: CustomerExtra; }; +/** + * Creates an order fetcher for retrieving orders from 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 `byId` and `byCustomerIdentifier` methods for fetching orders. + * + * @example + * ```ts + * const orderFetcher = createOrderFetcher(client); + * const order = await orderFetcher.byId('order-id-123'); + * const { orders, pageInfo } = await orderFetcher.byCustomerIdentifier('customer@example.com'); + * ``` + */ export function createOrderFetcher(apiClient: ClientInterface) { const fetchPaginatedByCustomerIdentifier = async < OnOrder = unknown, 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 +160,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..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 @@ -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', @@ -28,6 +28,23 @@ const baseQuery = (enhancements?: { onCustomer?: OC; onOrder?: OO }) => ], }); +/** + * 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); @@ -63,9 +80,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 +118,21 @@ 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 < + OnOrder = unknown, + OnCustomer = unknown, + CustomerExtra = unknown, + OrderExtra = unknown, + >( { id, pipelineId, stageId }: PutInPipelineStageArgs, - enhancements?: PutInPipelineStageEnhancedQuery, + enhancements?: PutInPipelineStageEnhancedQuery, ): Promise> => { const mutation = { updateOrderPipelineStage: { @@ -133,16 +155,21 @@ 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 < + OnOrder = unknown, + OnCustomer = unknown, + CustomerExtra = unknown, + OrderExtra = unknown, + >( { id, pipelineId }: RemoveFromPipelineArgs, - enhancements?: RemoveFromPipelineEnhancedQuery, + enhancements?: RemoveFromPipelineEnhancedQuery, ): Promise> => { const mutation = { deleteOrderPipeline: { @@ -160,10 +187,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 +200,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..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?: OSC, onCustomer?: OC) => { +const buildBaseQuery = ( + onSubscriptionContract?: SubscriptionContractExtra, + onCustomer?: CustomerExtra, +) => { const phaseQuery = { period: true, unit: true, @@ -108,15 +111,20 @@ 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 < + OnSubscriptionContract = unknown, + OnCustomer = unknown, + SubscriptionContractExtra = unknown, + CustomerExtra = unknown, + >( id: string, - enhancements?: EnhanceQuery, + enhancements?: EnhanceQuery, ): Promise | null> => { const query = { subscriptionContract: { @@ -147,12 +155,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..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 @@ -25,7 +25,9 @@ type WithIdentifiersAndStatus = R & { } & (R extends { status: infer S } ? S : {}); }; -const baseQuery = }>(onSubscriptionContract?: OSC) => ({ +const baseQuery = }>( + onSubscriptionContract?: SubscriptionContractExtra, +) => ({ __on: [ { __typeName: 'SubscriptionContractAggregate', @@ -45,142 +47,131 @@ const baseQuery = }>(onSubscript ], }); +/** + * Creates a subscription contract manager for creating, updating, and managing subscription lifecycle 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`, `cancel`, `pause`, `resume`, `renew`, `createTemplateBasedOnVariant`, and `createTemplateBasedOnVariantIdentity`. + * + * @example + * ```ts + * const subscriptionManager = createSubscriptionContractManager(client); + * const contract = await subscriptionManager.create({ + * customerIdentifier: 'customer@example.com', + * subscriptionPlan: { identifier: 'monthly', periodId: 'period-1' }, + * item: { sku: 'SKU-001', name: 'My Subscription', quantity: 1 }, + * recurring: { price: 9.99, currency: 'USD', period: 1, unit: 'month' }, + * }); + * ``` + */ export const createSubscriptionContractManager = (apiClient: ClientInterface) => { - const create = async } = {}>( - intentSubscriptionContract: CreateSubscriptionContractInput, - onSubscriptionContract?: OSC, + const lifecycleAction = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( + 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 } = {}>( + const update = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( intentSubscriptionContract: UpdateSubscriptionContractInput, - onSubscriptionContract?: OSC, + 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 } = {}>( + const cancel = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( id: UpdateSubscriptionContractInput['id'], deactivate = false, - onSubscriptionContract?: OSC, + 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 } = {}>( + const pause = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( id: UpdateSubscriptionContractInput['id'], - onSubscriptionContract?: OSC, + 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 } = {}>( + const resume = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( id: UpdateSubscriptionContractInput['id'], - onSubscriptionContract?: OSC, + 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 } = {}>( + const renew = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( id: UpdateSubscriptionContractInput['id'], - onSubscriptionContract?: OSC, + 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, + ); }; /** 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..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 @@ -13,166 +13,86 @@ 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 = { - cart: { - __args: { - id, - }, - id: true, - ...onCart, - }, - }; - const response = await apiClient.shopCartApi<{ cart: WithId }>(jsonToGraphQLQuery({ query })); - return response.cart; - }; - - 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 cartQuery = async ( + name: string, + args: Record, + onCart?: CartExtra, + ) => { + const query = { [name]: { __args: args, id: true, ...onCart } }; + const response = await apiClient.shopCartApi>>(jsonToGraphQLQuery({ query })); + return response[name]; }; - const removeItem = async ( - id: string, - { sku, quantity }: { sku: string; quantity: number }, - onCart?: OC, + const cartMutation = async ( + name: string, + args: Record, + onCart?: CartExtra, ) => { - const mutation = { - removeCartItem: { - __args: { - id, - sku, - quantity, - }, - id: true, - ...onCart, - }, - }; - const response = await apiClient.shopCartApi<{ - removeCartItem: WithId; - }>(jsonToGraphQLQuery({ mutation })); - return response.removeCartItem; + 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?: CartExtra) => { + const input = CartInputSchema.parse(intent); + return cartMutation('hydrate', { input: transformCartInput(input) }, onCart); + }, + 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); + }, + removeItem: ( + id: string, + { sku, quantity }: { sku: string; quantity: number }, + 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, + ); + }, }; }; 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 : {}), -// }; -// }; 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..698557e6 --- /dev/null +++ b/components/js-api-client/tests/unit/create-api-caller.test.ts @@ -0,0 +1,275 @@ +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 { mockGrabResponse, defaultConfig } from './helpers.js'; + +const mockGrab = (response: GrabResponse): Grab['grab'] => { + return vi.fn().mockResolvedValue(response); +}; + +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('returns empty headers when no auth is configured', () => { + const config: ClientConfiguration = { tenantIdentifier: 'test' }; + expect(authenticationHeaders(config)).toEqual({}); + }); +}); + +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..1bb97708 --- /dev/null +++ b/components/js-api-client/tests/unit/create-grabber.test.ts @@ -0,0 +1,84 @@ +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..acad1082 --- /dev/null +++ b/components/js-api-client/tests/unit/create-mass-call-client.test.ts @@ -0,0 +1,230 @@ +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); + }); +}); 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..9fb36c0a --- /dev/null +++ b/components/js-api-client/tests/unit/error-handling.test.ts @@ -0,0 +1,414 @@ +import { describe, test, expect, vi } from 'vitest'; +import { post, JSApiClientCallError } from '../../src/core/client/create-api-caller.js'; +import { mockGrabResponse, defaultConfig } from './helpers.js'; + +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(); + }); +}); 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 0c1b91d2..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"], + "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: {}