@propeller-commerce/propeller-sdk-v2 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,70 @@
3
3
  All notable changes to `@propeller-commerce/propeller-sdk-v2` are documented here.
4
4
 
5
5
  ---
6
+ ## [0.18.1] - 2026-11-03
7
+
8
+ A host can now hand the client its own `fetch()`, and a query no longer fails
9
+ the page when the connection to the API drops once.
10
+
11
+ ### Added
12
+
13
+ - **`fetch` on `GraphQLClientConfig`.** Supply the `fetch()` the client sends
14
+ requests with; it defaults to the global `fetch`. Pass a request-scoped fetch
15
+ during SSR (Nitro's `event.fetch`, in Nuxt `useRequestEvent()!.fetch`) so a
16
+ relative proxy `endpoint` such as `/api/graphql` is served in-process instead
17
+ of over the network, which fails with `fetch failed` wherever a server can't
18
+ reach its own public hostname. A request-scoped fetch belongs to one request,
19
+ so create the client per request when you pass one. The type is exported as
20
+ `FetchImplementation`. Contributed by Mark Wienk (TRIMM) in
21
+ [#1](https://github.com/propeller-commerce/propeller-sdk-v2/pull/1). (PWP-1091)
22
+
23
+ ### Fixed
24
+
25
+ - **A query is sent once more when the connection drops.** `GraphQL request
26
+ failed: fetch failed` used to reach the page straight away, so a
27
+ server-rendered page answered 500 where a reload worked. A query now gets one
28
+ more attempt, within the same `timeout`, before the error is thrown.
29
+ Mutations are never resent, since the server may have applied one before the
30
+ connection dropped; timeouts, HTTP error statuses and GraphQL errors are not
31
+ retried. (PWP-1062)
32
+
33
+ ---
34
+ ## [0.18.0] - 2026-09-24
35
+
36
+ `product` and `cluster` can now be scoped to a contact and a company, so a
37
+ storefront can ask the assortment question the API was always able to answer.
38
+
39
+ ### Added
40
+
41
+ - **`userId` and `companyId` on the `product` and `cluster` queries.** Both
42
+ arguments have always existed on the schema — the generated operations simply
43
+ did not declare or forward them, so there was no way to reach them through the
44
+ SDK and every call resolved against the bearer token's own identity.
45
+
46
+ This is what left a direct link to a product outside the company's POSITIVE
47
+ orderlist opening normally while listings and search correctly hid it: the
48
+ listing queries scope by identity, the single-entity ones could not. With the
49
+ arguments in place, `product(productId:, userId:, companyId:)` answers
50
+ `PRODUCT_NOT_FOUND` for a product outside the contact's assortment, which is
51
+ the correct answer and what a host needs in order to 404 the page. (PWP-1015)
52
+
53
+ Both are optional and default to undefined, so every existing call is
54
+ unchanged.
55
+
56
+ ### Notes for consumers
57
+
58
+ - The API rejects a `companyId` the contact does not belong to ("Unauthorized
59
+ use of companyId"), so a company restored from storage must be validated
60
+ against the contact's companies before it is sent.
61
+ - Three adjacent gaps were found while checking this and are deliberately NOT
62
+ in this release, because each is a behaviour change rather than an additive
63
+ argument: `product`/`cluster` also accept `hidden`, `clusterConfig` forwards
64
+ only `clusterId` (so it is not identity-scoped either), and
65
+ `orderGetPDF`/`quoteGetPDF` accept a `revisionNumber`. Say the word and they
66
+ can go in one follow-up rather than three.
67
+
68
+ ---
69
+
6
70
 
7
71
  ## [0.17.0] - 2026-09-23
8
72
 
package/README.md CHANGED
@@ -87,6 +87,7 @@ See [MIGRATION-0.10.0.md](./MIGRATION-0.10.0.md) for the full upgrade guide.
87
87
  | `getAccessToken` | `() => string \| undefined \| Promise<string \| undefined>` | reads `localStorage['access_token']` in browser | Use this for SSR (`getServerSession`, HTTP-only cookies) or in-memory token stores. |
88
88
  | `defaultLanguage` | `string` | — | ISO 639-1 tag (e.g. `'NL'`). Service methods that take an optional top-level `language` and don't receive one fall back to this value before sending the request. An explicit `language` on the call always wins. |
89
89
  | `throwOnPartialErrors` | `boolean` | `false` | On a partial response (`data` *and* `errors`), services return the data and debug-log the errors. Set `true` to throw `GraphQLOperationError` instead. `client.execute()` never throws regardless. |
90
+ | `fetch` | `(url: string, init: RequestInit) => Promise<Response>` | global `fetch` | Request transport. Pass a request-scoped fetch during SSR (e.g. Nitro's `event.fetch`) so a relative proxy `endpoint` is handled in-process instead of over the network. A request-scoped fetch belongs to one request, so create the client per request when you pass one. |
90
91
 
91
92
  ### Proxy contract
92
93
 
@@ -66,6 +66,13 @@ export interface GraphQLResponse<T = any> {
66
66
  * May return a string, undefined (no token), or a Promise of either.
67
67
  */
68
68
  export type AccessTokenProvider = () => string | undefined | Promise<string | undefined>;
69
+ /**
70
+ * The subset of `fetch()` the client calls: a URL string and a `RequestInit`,
71
+ * resolving to a standard `Response`. Narrower than `typeof fetch` so
72
+ * host-runtime implementations (undici, Nitro's `event.fetch`, test doubles)
73
+ * fit without matching every overload.
74
+ */
75
+ export type FetchImplementation = (url: string, init: RequestInit) => Promise<Response>;
69
76
  /**
70
77
  * Configuration for the GraphQL client.
71
78
  */
@@ -118,6 +125,18 @@ export interface GraphQLClientConfig {
118
125
  * compatible). The low-level `client.execute()` never throws regardless.
119
126
  */
120
127
  throwOnPartialErrors?: boolean;
128
+ /**
129
+ * `fetch()` implementation used for every request. Defaults to the global
130
+ * `fetch`, resolved per request (so later polyfills and spies apply).
131
+ *
132
+ * Supply a request-scoped fetch during SSR to keep the call in-process:
133
+ * Nitro's `event.fetch` (Nuxt: `useRequestEvent()!.fetch`) routes a
134
+ * relative `endpoint` like `/api/graphql` straight to the app's own proxy
135
+ * handler. With the global `fetch`, SSR needs an absolute URL and makes a
136
+ * real network round trip to its own public hostname — which fails with
137
+ * `fetch failed` wherever the server can't reach its own ingress.
138
+ */
139
+ fetch?: FetchImplementation;
121
140
  }
122
141
  /**
123
142
  * A GraphQL client for the Propeller eCommerce Platform.
@@ -195,6 +214,9 @@ export declare class GraphQLClient {
195
214
  * who want to inspect partial responses can do so. The higher-level
196
215
  * `query()` / `mutate()` helpers and the `runOperation` helper used by
197
216
  * services throw `GraphQLOperationError` when `errors` is non-empty.
217
+ *
218
+ * A query whose connection drops (`fetch failed`) is sent once more, within
219
+ * the same `timeout`, before the error is thrown. Mutations are never resent.
198
220
  */
199
221
  execute<T = any>(operation: GraphQLOperation): Promise<GraphQLResponse<T>>;
200
222
  /**
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
- // Note: This client uses the built-in fetch API available in Node.js 18+.
3
- // For older runtimes, install a fetch polyfill.
2
+ // Note: This client uses the built-in fetch API available in Node.js 18+
3
+ // unless `config.fetch` supplies another implementation. For older runtimes,
4
+ // install a fetch polyfill.
4
5
  Object.defineProperty(exports, "__esModule", { value: true });
5
6
  exports.GraphQLClient = void 0;
6
7
  exports.createClient = createClient;
@@ -15,6 +16,10 @@ const DEFAULT_ORDER_EDITOR_MUTATIONS = [
15
16
  'triggerQuoteSendRequest',
16
17
  'triggerOrderSendConfirm',
17
18
  ];
19
+ // The `mutation` keyword at the start of any line. Generated documents put
20
+ // their fragments first, so "the document starts with `mutation`" would miss
21
+ // most mutations — and a missed mutation is one that gets sent twice.
22
+ const MUTATION_DOCUMENT = /^\s*mutation\b/m;
18
23
  function defaultAccessTokenProvider() {
19
24
  if (typeof window !== 'undefined' && window.localStorage) {
20
25
  return window.localStorage.getItem(DEFAULT_TOKEN_STORAGE_KEY) || undefined;
@@ -183,6 +188,9 @@ class GraphQLClient {
183
188
  * who want to inspect partial responses can do so. The higher-level
184
189
  * `query()` / `mutate()` helpers and the `runOperation` helper used by
185
190
  * services throw `GraphQLOperationError` when `errors` is non-empty.
191
+ *
192
+ * A query whose connection drops (`fetch failed`) is sent once more, within
193
+ * the same `timeout`, before the error is thrown. Mutations are never resent.
186
194
  */
187
195
  async execute(operation) {
188
196
  const { query, variables = {}, operationName, fetchOptions } = operation;
@@ -200,7 +208,8 @@ class GraphQLClient {
200
208
  const controller = new AbortController();
201
209
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
202
210
  try {
203
- const response = await fetch(this.getEndpoint(), {
211
+ const fetchImpl = this.config.fetch ?? fetch;
212
+ const send = () => fetchImpl(this.getEndpoint(), {
204
213
  method: 'POST',
205
214
  headers,
206
215
  body,
@@ -214,6 +223,18 @@ class GraphQLClient {
214
223
  next: fetchOptions?.next,
215
224
  signal: controller.signal,
216
225
  });
226
+ // A dropped connection rejects fetch() with a TypeError (the fetch spec's
227
+ // network error). A timeout rejects with an AbortError and an HTTP error
228
+ // status resolves, so neither is retried. Matched by name, not
229
+ // `instanceof`, so a TypeError from another realm (Jest, a vm sandbox)
230
+ // still counts. A mutation is never sent twice: the server may have
231
+ // applied it before the connection dropped. The retry shares the timeout.
232
+ const response = await send().catch((error) => {
233
+ if (error?.name !== 'TypeError' || MUTATION_DOCUMENT.test(query))
234
+ throw error;
235
+ this.debugLog(`Connection dropped (${error.message}), retrying ${actualOperationName ?? 'query'} once`);
236
+ return send();
237
+ });
217
238
  clearTimeout(timeoutId);
218
239
  if (!response.ok) {
219
240
  const bodyText = await response.text().catch(() => '');
@@ -7,7 +7,7 @@
7
7
  * compatibility with code written against 0.9.x.
8
8
  */
9
9
  export { GraphQLClient, createClient, createGraphQLClient, initializeClient, getClient, } from './GraphQLClient';
10
- export type { GraphQLOperation, GraphQLResponse, GraphQLClientConfig, GraphQLFetchOptions, AccessTokenProvider, } from './GraphQLClient';
10
+ export type { GraphQLOperation, GraphQLResponse, GraphQLClientConfig, GraphQLFetchOptions, AccessTokenProvider, FetchImplementation, } from './GraphQLClient';
11
11
  export { GraphQLOperationError } from './GraphQLOperationError';
12
12
  export type { GraphQLErrorEntry } from './GraphQLOperationError';
13
13
  /**
@@ -1798,6 +1798,8 @@ export interface ClusterVariables {
1798
1798
  language?: string;
1799
1799
  applyOrderlists?: boolean;
1800
1800
  orderlistIds?: number[];
1801
+ userId?: number;
1802
+ companyId?: number;
1801
1803
  priceCalculateProductInput?: PriceCalculateProductInput;
1802
1804
  userBulkPriceProductInput?: UserBulkPriceProductInput;
1803
1805
  attributeResultSearchInput?: AttributeResultSearchInput;
@@ -3820,6 +3822,8 @@ export interface ProductVariables {
3820
3822
  language?: string;
3821
3823
  applyOrderlists?: boolean;
3822
3824
  orderlistIds?: number[];
3825
+ userId?: number;
3826
+ companyId?: number;
3823
3827
  priceCalculateProductInput?: PriceCalculateProductInput;
3824
3828
  userBulkPriceProductInput?: UserBulkPriceProductInput;
3825
3829
  attributeResultSearchInput?: AttributeResultSearchInput;
@@ -842,13 +842,15 @@ fragment ClusterConfigSettingFields on ClusterConfigSetting {
842
842
  priority
843
843
  }
844
844
 
845
- query cluster(\$clusterId: Int, \$slug: String, \$language: String, \$applyOrderlists: Boolean, \$orderlistIds: [Int!], \$priceCalculateProductInput: PriceCalculateProductInput, \$userBulkPriceProductInput: UserBulkPriceProductInput, \$attributeResultSearchInput: AttributeResultSearchInput, \$imageSearchFilters: MediaImageProductSearchInput, \$mediaVideoSearchInput: MediaVideoProductSearchInput, \$mediaDocumentSearchInput: MediaDocumentProductSearchInput, \$imageVariantFilters: TransformationsInput!) {
845
+ query cluster(\$clusterId: Int, \$slug: String, \$language: String, \$applyOrderlists: Boolean, \$orderlistIds: [Int!], \$userId: Int, \$companyId: Int, \$priceCalculateProductInput: PriceCalculateProductInput, \$userBulkPriceProductInput: UserBulkPriceProductInput, \$attributeResultSearchInput: AttributeResultSearchInput, \$imageSearchFilters: MediaImageProductSearchInput, \$mediaVideoSearchInput: MediaVideoProductSearchInput, \$mediaDocumentSearchInput: MediaDocumentProductSearchInput, \$imageVariantFilters: TransformationsInput!) {
846
846
  cluster(
847
847
  clusterId: \$clusterId
848
848
  slug: \$slug
849
849
  language: \$language
850
850
  applyOrderlists: \$applyOrderlists
851
851
  orderlistIds: \$orderlistIds
852
+ userId: \$userId
853
+ companyId: \$companyId
852
854
  ) {
853
855
  ...ClusterFields
854
856
  }
@@ -712,7 +712,7 @@ fragment SurchargeFields on Surcharge {
712
712
  validTo
713
713
  }
714
714
 
715
- query product(\$productId: Int, \$slug: String, \$sku: String, \$language: String, \$applyOrderlists: Boolean, \$orderlistIds: [Int!], \$priceCalculateProductInput: PriceCalculateProductInput, \$userBulkPriceProductInput: UserBulkPriceProductInput, \$attributeResultSearchInput: AttributeResultSearchInput, \$imageSearchFilters: MediaImageProductSearchInput, \$mediaVideoSearchInput: MediaVideoProductSearchInput, \$mediaDocumentSearchInput: MediaDocumentProductSearchInput, \$imageVariantFilters: TransformationsInput!) {
715
+ query product(\$productId: Int, \$slug: String, \$sku: String, \$language: String, \$applyOrderlists: Boolean, \$orderlistIds: [Int!], \$userId: Int, \$companyId: Int, \$priceCalculateProductInput: PriceCalculateProductInput, \$userBulkPriceProductInput: UserBulkPriceProductInput, \$attributeResultSearchInput: AttributeResultSearchInput, \$imageSearchFilters: MediaImageProductSearchInput, \$mediaVideoSearchInput: MediaVideoProductSearchInput, \$mediaDocumentSearchInput: MediaDocumentProductSearchInput, \$imageVariantFilters: TransformationsInput!) {
716
716
  product(
717
717
  productId: \$productId
718
718
  slug: \$slug
@@ -720,6 +720,8 @@ query product(\$productId: Int, \$slug: String, \$sku: String, \$language: Strin
720
720
  language: \$language
721
721
  applyOrderlists: \$applyOrderlists
722
722
  orderlistIds: \$orderlistIds
723
+ userId: \$userId
724
+ companyId: \$companyId
723
725
  ) {
724
726
  ...ProductFields
725
727
  }
@@ -1,5 +1,6 @@
1
- // Note: This client uses the built-in fetch API available in Node.js 18+.
2
- // For older runtimes, install a fetch polyfill.
1
+ // Note: This client uses the built-in fetch API available in Node.js 18+
2
+ // unless `config.fetch` supplies another implementation. For older runtimes,
3
+ // install a fetch polyfill.
3
4
  import { GraphQLOperationError } from './GraphQLOperationError.js';
4
5
  const DEFAULT_TOKEN_STORAGE_KEY = 'access_token';
5
6
  const DEFAULT_ORDER_EDITOR_MUTATIONS = [
@@ -8,6 +9,10 @@ const DEFAULT_ORDER_EDITOR_MUTATIONS = [
8
9
  'triggerQuoteSendRequest',
9
10
  'triggerOrderSendConfirm',
10
11
  ];
12
+ // The `mutation` keyword at the start of any line. Generated documents put
13
+ // their fragments first, so "the document starts with `mutation`" would miss
14
+ // most mutations — and a missed mutation is one that gets sent twice.
15
+ const MUTATION_DOCUMENT = /^\s*mutation\b/m;
11
16
  function defaultAccessTokenProvider() {
12
17
  if (typeof window !== 'undefined' && window.localStorage) {
13
18
  return window.localStorage.getItem(DEFAULT_TOKEN_STORAGE_KEY) || undefined;
@@ -176,6 +181,9 @@ export class GraphQLClient {
176
181
  * who want to inspect partial responses can do so. The higher-level
177
182
  * `query()` / `mutate()` helpers and the `runOperation` helper used by
178
183
  * services throw `GraphQLOperationError` when `errors` is non-empty.
184
+ *
185
+ * A query whose connection drops (`fetch failed`) is sent once more, within
186
+ * the same `timeout`, before the error is thrown. Mutations are never resent.
179
187
  */
180
188
  async execute(operation) {
181
189
  const { query, variables = {}, operationName, fetchOptions } = operation;
@@ -193,7 +201,8 @@ export class GraphQLClient {
193
201
  const controller = new AbortController();
194
202
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
195
203
  try {
196
- const response = await fetch(this.getEndpoint(), {
204
+ const fetchImpl = this.config.fetch ?? fetch;
205
+ const send = () => fetchImpl(this.getEndpoint(), {
197
206
  method: 'POST',
198
207
  headers,
199
208
  body,
@@ -207,6 +216,18 @@ export class GraphQLClient {
207
216
  next: fetchOptions?.next,
208
217
  signal: controller.signal,
209
218
  });
219
+ // A dropped connection rejects fetch() with a TypeError (the fetch spec's
220
+ // network error). A timeout rejects with an AbortError and an HTTP error
221
+ // status resolves, so neither is retried. Matched by name, not
222
+ // `instanceof`, so a TypeError from another realm (Jest, a vm sandbox)
223
+ // still counts. A mutation is never sent twice: the server may have
224
+ // applied it before the connection dropped. The retry shares the timeout.
225
+ const response = await send().catch((error) => {
226
+ if (error?.name !== 'TypeError' || MUTATION_DOCUMENT.test(query))
227
+ throw error;
228
+ this.debugLog(`Connection dropped (${error.message}), retrying ${actualOperationName ?? 'query'} once`);
229
+ return send();
230
+ });
210
231
  clearTimeout(timeoutId);
211
232
  if (!response.ok) {
212
233
  const bodyText = await response.text().catch(() => '');
@@ -839,13 +839,15 @@ fragment ClusterConfigSettingFields on ClusterConfigSetting {
839
839
  priority
840
840
  }
841
841
 
842
- query cluster(\$clusterId: Int, \$slug: String, \$language: String, \$applyOrderlists: Boolean, \$orderlistIds: [Int!], \$priceCalculateProductInput: PriceCalculateProductInput, \$userBulkPriceProductInput: UserBulkPriceProductInput, \$attributeResultSearchInput: AttributeResultSearchInput, \$imageSearchFilters: MediaImageProductSearchInput, \$mediaVideoSearchInput: MediaVideoProductSearchInput, \$mediaDocumentSearchInput: MediaDocumentProductSearchInput, \$imageVariantFilters: TransformationsInput!) {
842
+ query cluster(\$clusterId: Int, \$slug: String, \$language: String, \$applyOrderlists: Boolean, \$orderlistIds: [Int!], \$userId: Int, \$companyId: Int, \$priceCalculateProductInput: PriceCalculateProductInput, \$userBulkPriceProductInput: UserBulkPriceProductInput, \$attributeResultSearchInput: AttributeResultSearchInput, \$imageSearchFilters: MediaImageProductSearchInput, \$mediaVideoSearchInput: MediaVideoProductSearchInput, \$mediaDocumentSearchInput: MediaDocumentProductSearchInput, \$imageVariantFilters: TransformationsInput!) {
843
843
  cluster(
844
844
  clusterId: \$clusterId
845
845
  slug: \$slug
846
846
  language: \$language
847
847
  applyOrderlists: \$applyOrderlists
848
848
  orderlistIds: \$orderlistIds
849
+ userId: \$userId
850
+ companyId: \$companyId
849
851
  ) {
850
852
  ...ClusterFields
851
853
  }
@@ -709,7 +709,7 @@ fragment SurchargeFields on Surcharge {
709
709
  validTo
710
710
  }
711
711
 
712
- query product(\$productId: Int, \$slug: String, \$sku: String, \$language: String, \$applyOrderlists: Boolean, \$orderlistIds: [Int!], \$priceCalculateProductInput: PriceCalculateProductInput, \$userBulkPriceProductInput: UserBulkPriceProductInput, \$attributeResultSearchInput: AttributeResultSearchInput, \$imageSearchFilters: MediaImageProductSearchInput, \$mediaVideoSearchInput: MediaVideoProductSearchInput, \$mediaDocumentSearchInput: MediaDocumentProductSearchInput, \$imageVariantFilters: TransformationsInput!) {
712
+ query product(\$productId: Int, \$slug: String, \$sku: String, \$language: String, \$applyOrderlists: Boolean, \$orderlistIds: [Int!], \$userId: Int, \$companyId: Int, \$priceCalculateProductInput: PriceCalculateProductInput, \$userBulkPriceProductInput: UserBulkPriceProductInput, \$attributeResultSearchInput: AttributeResultSearchInput, \$imageSearchFilters: MediaImageProductSearchInput, \$mediaVideoSearchInput: MediaVideoProductSearchInput, \$mediaDocumentSearchInput: MediaDocumentProductSearchInput, \$imageVariantFilters: TransformationsInput!) {
713
713
  product(
714
714
  productId: \$productId
715
715
  slug: \$slug
@@ -717,6 +717,8 @@ query product(\$productId: Int, \$slug: String, \$sku: String, \$language: Strin
717
717
  language: \$language
718
718
  applyOrderlists: \$applyOrderlists
719
719
  orderlistIds: \$orderlistIds
720
+ userId: \$userId
721
+ companyId: \$companyId
720
722
  ) {
721
723
  ...ProductFields
722
724
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@propeller-commerce/propeller-sdk-v2",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
4
4
  "description": "TypeScript GraphQL client for Propeller eCommerce V2 Platform - works with React, Vue, Angular and all JavaScript frameworks",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",