@tallyui/connector-vendure 2.0.0 → 3.0.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # TallyUI Vendure connector
2
+
3
+ ## Errors
4
+
5
+ Requests with expired or rejected credentials reject with `ConnectorUnauthorizedError` (`code: 'unauthorized'`), defined in `@tallyui/core` and re-exported by this connector. The app should sign in again.
6
+
7
+ Sign-in itself rejects with `SignInError`.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ConnectorAuth, ProductTraits, CollectionSync, ReplicationAdapter, StockReconcileAdapter, SyncContext, StoreSettingsChoice, StoreSettings, TallyConnector } from '@tallyui/core';
2
+ export { ConnectorUnauthorizedError } from '@tallyui/core';
2
3
  import { RxJsonSchema } from 'rxdb';
3
4
 
4
5
  declare const vendureSignIn: NonNullable<ConnectorAuth['signIn']>;
@@ -28,6 +29,19 @@ type VendureProductCheckpoint = {
28
29
  passHighWater?: string;
29
30
  passTotal?: number;
30
31
  };
32
+ /** The server's updatedAt filters miss changes because it does not run in UTC (probeUpdatedAtSkew). */
33
+ declare class VendureTimezoneConfigError extends Error {
34
+ name: string;
35
+ readonly code: "store_misconfigured";
36
+ /** Only the store owner can fix it on the server, so the pull waits the store delay (`errorKind`). */
37
+ readonly fixedBy: "store";
38
+ /**
39
+ * The store-side remedy, for `SyncStatus`'s detail. The message's other remedy,
40
+ * `updatedAtSkewMs`, is a connector option on the till, so it stays in the message only.
41
+ */
42
+ readonly fix = "run the Vendure server with its time zone set to UTC";
43
+ constructor();
44
+ }
31
45
  /**
32
46
  * Replication adapter for Vendure products.
33
47
  *
@@ -88,16 +102,18 @@ declare function vendureGlobalStockSettings(context: SyncContext): Promise<{
88
102
  }>;
89
103
 
90
104
  /**
91
- * Vendure connector for Tally UI.
105
+ * Vendure connector for Tally UI. Build one per store session, anew on each sign-in or store change:
106
+ * each instance owns its reconcile feed, whose queued work must never reach another store's database (#307).
92
107
  *
93
108
  * Connects to Vendure backends via the Admin GraphQL API.
94
109
  * Products are stored in RxDB using a schema that mirrors the Vendure API shape.
95
110
  *
96
111
  * ```ts
97
- * import { vendureConnector } from '@tallyui/connector-vendure';
112
+ * import { createVendureConnector } from '@tallyui/connector-vendure';
98
113
  * import { ConnectorProvider } from '@tallyui/core';
99
114
  *
100
- * <ConnectorProvider connector={vendureConnector}>
115
+ * const connector = useMemo(() => createVendureConnector({ pricesIncludeTax }), [backendUrl, pricesIncludeTax]);
116
+ * <ConnectorProvider connector={connector}>
101
117
  * <App />
102
118
  * </ConnectorProvider>
103
119
  * ```
@@ -116,6 +132,10 @@ declare const createVendureConnector: (options?: {
116
132
  globalTrackInventory?: boolean;
117
133
  globalOutOfStockThreshold?: number;
118
134
  }) => TallyConnector;
135
+ /**
136
+ * @deprecated One instance for the whole app: a store switch can leak queued reconcile work across stores.
137
+ * Use createVendureConnector() per store session. Removed in 4.0.
138
+ */
119
139
  declare const vendureConnector: TallyConnector;
120
140
 
121
- export { createVendureConnector, createVendureProductReplication, createVendureVariantFeedReplication, vendureAuth, vendureConnector, vendureGlobalStockSettings, vendureProductReplication, vendureProductSchema, vendureProductSync, vendureProductTraits, vendureSignIn, vendureStockReconcile, vendureStoreSettings };
141
+ export { VendureTimezoneConfigError, createVendureConnector, createVendureProductReplication, createVendureVariantFeedReplication, vendureAuth, vendureConnector, vendureGlobalStockSettings, vendureProductReplication, vendureProductSchema, vendureProductSync, vendureProductTraits, vendureSignIn, vendureStockReconcile, vendureStoreSettings };
package/dist/index.js CHANGED
@@ -3,6 +3,66 @@ import { combinePullAdapters, createReconcileFeed } from "@tallyui/core";
3
3
 
4
4
  // src/auth.ts
5
5
  import { SignInError } from "@tallyui/core";
6
+
7
+ // src/capabilities.ts
8
+ import { ConnectorUnauthorizedError as ConnectorUnauthorizedError2, parseInfoCapabilities } from "@tallyui/core";
9
+
10
+ // src/session-probe.ts
11
+ import { ConnectorUnauthorizedError } from "@tallyui/core";
12
+ async function signedOut(context) {
13
+ try {
14
+ const res = await fetch(`${context.baseUrl}/admin-api`, {
15
+ method: "POST",
16
+ headers: { "Content-Type": "application/json", ...context.headers },
17
+ body: JSON.stringify({ query: "query { activeAdministrator { id } }" }),
18
+ signal: context.signal
19
+ });
20
+ if (!res.ok) return void 0;
21
+ const body = await res.json();
22
+ if (body.errors?.length) return void 0;
23
+ if (body.data?.activeAdministrator === null) return true;
24
+ return body.data?.activeAdministrator?.id != null ? false : void 0;
25
+ } catch (error) {
26
+ if (context.signal?.aborted) throw error;
27
+ return void 0;
28
+ }
29
+ }
30
+ async function forbiddenError(context, forbidden) {
31
+ const out = await signedOut(context);
32
+ if (out === true) return new ConnectorUnauthorizedError(`Vendure GraphQL error: ${forbidden[0].message}`);
33
+ const what = forbidden.map(({ message, path }) => path?.length ? `${path.join(".")}: ${message}` : message).join("; ");
34
+ if (out === false) return new Error(`Vendure GraphQL error: FORBIDDEN: the store refused this request although the session is signed in (a permission is missing): ${what}`);
35
+ return new Error(`Vendure GraphQL error: FORBIDDEN: ${what} (the session check failed, so this may be transient)`);
36
+ }
37
+
38
+ // src/capabilities.ts
39
+ var CAPABILITIES_PATH = "/tally/v1/info";
40
+ async function readVendureCapabilities(baseUrl, headers, init = {}) {
41
+ const doFetch = init.fetch ?? fetch;
42
+ let res;
43
+ try {
44
+ res = await doFetch(`${baseUrl}${CAPABILITIES_PATH}`, { method: "GET", headers, signal: init.signal });
45
+ } catch {
46
+ return void 0;
47
+ }
48
+ if (res.status === 401 || res.status === 403) {
49
+ const out = await signedOut({ connectorId: "vendure", baseUrl, headers, signal: init.signal });
50
+ if (out === true) throw new ConnectorUnauthorizedError2(`Vendure refused ${CAPABILITIES_PATH}: the session is signed out`, res.status);
51
+ if (out === false) throw new Error(`Vendure refused ${CAPABILITIES_PATH} although the session is signed in: the CreateOrder permission is missing (HTTP ${res.status})`);
52
+ return void 0;
53
+ }
54
+ if (res.status === 404) return { orderCreate: 1 };
55
+ if (!res.ok) return void 0;
56
+ let body;
57
+ try {
58
+ body = await res.json();
59
+ } catch {
60
+ return { orderCreate: 1 };
61
+ }
62
+ return parseInfoCapabilities(body, (reason) => console.warn(`@tallyui/connector-vendure: ${reason}`));
63
+ }
64
+
65
+ // src/auth.ts
6
66
  var LOGIN = `mutation Login($email: String!, $password: String!) {
7
67
  login(username: $email, password: $password) {
8
68
  __typename
@@ -42,7 +102,8 @@ var vendureSignIn = async (baseUrl, { email, password }, init = {}) => {
42
102
  if (!token) {
43
103
  throw new SignInError("unsupported", "Vendure signed in but sent no 'vendure-auth-token' header. Add 'bearer' to the server's authOptions.tokenMethod. This connector reads the default header name, so a server that renames authOptions.authTokenHeaderKey also gives this error.");
44
104
  }
45
- return { token, expiresAt: void 0 };
105
+ const capabilities = await readVendureCapabilities(baseUrl, { Authorization: `Bearer ${token}` }, { fetch: doFetch, signal: init.signal }).catch(() => void 0);
106
+ return { token, expiresAt: void 0, capabilities };
46
107
  };
47
108
  var warnedAuthToken = false;
48
109
  var vendureAuth = {
@@ -72,8 +133,9 @@ var vendureAuth = {
72
133
  // src/schemas/products.ts
73
134
  var vendureProductSchema = {
74
135
  // 1: declares variant trackInventory/outOfStockThreshold/useGlobalOutOfStockThreshold/enabled
75
- // (backlog 28). A bump drops and resyncs (ConnectorSchemas, ADR-060 amendment 9).
76
- version: 1,
136
+ // (backlog 28). 2: declares variant taxCategory { id } (#288). A bump drops and resyncs
137
+ // (ConnectorSchemas, ADR-060 amendment 9).
138
+ version: 2,
77
139
  primaryKey: "id",
78
140
  type: "object",
79
141
  properties: {
@@ -174,7 +236,8 @@ var vendureProductSchema = {
174
236
  }
175
237
  }
176
238
  },
177
- customFields: { type: "object" }
239
+ customFields: { type: "object" },
240
+ taxCategory: { type: ["object", "null"], properties: { id: { type: "string" } } }
178
241
  }
179
242
  }
180
243
  },
@@ -235,6 +298,12 @@ function createVendureProductTraits(barcodeField, stockLocationId, pricesInclude
235
298
  prices: traits.getPrices({ variants: [variant] }, context),
236
299
  stock: traits.getStock({ variants: [variant] })
237
300
  })),
301
+ // The variant's tax category id: store-settings.ts keys taxRatesPpm by the same id. No variant id: the default
302
+ // (first live) variant, as getPrices.
303
+ getTaxClass: (doc, variantId) => {
304
+ const variant = variantId === void 0 ? liveVariants(doc)[0] : (doc.variants ?? []).find((v) => String(v.id) === variantId);
305
+ return variant?.taxCategory?.id == null ? void 0 : String(variant.taxCategory.id);
306
+ },
238
307
  getStock: (doc) => productStock(liveVariants(doc).map((variant) => variantStock(variant, stockLocationId, globalTrackInventory, globalOutOfStockThreshold))),
239
308
  getPrice: (doc) => {
240
309
  const amount = liveVariants(doc)[0]?.[priceField];
@@ -272,7 +341,7 @@ function createVendureProductTraits(barcodeField, stockLocationId, pricesInclude
272
341
  return "simple";
273
342
  },
274
343
  getBarcode: (doc) => {
275
- return barcodeField ? doc.variants?.[0]?.customFields?.[barcodeField] || void 0 : void 0;
344
+ return barcodeField ? liveVariants(doc)[0]?.customFields?.[barcodeField] || void 0 : void 0;
276
345
  },
277
346
  getCategoryNames: (doc) => (doc.collections ?? []).map((c) => c.name).filter(Boolean)
278
347
  };
@@ -281,6 +350,7 @@ function createVendureProductTraits(barcodeField, stockLocationId, pricesInclude
281
350
  var vendureProductTraits = createVendureProductTraits();
282
351
 
283
352
  // src/sync/products.ts
353
+ import { ConnectorUnauthorizedError as ConnectorUnauthorizedError3 } from "@tallyui/core";
284
354
  var PRODUCT_LIST_QUERY = `
285
355
  query GetProducts($options: ProductListOptions) {
286
356
  products(options: $options) {
@@ -395,12 +465,16 @@ async function gql(context, query2, variables) {
395
465
  body: JSON.stringify({ query: query2, variables }),
396
466
  signal: context.signal
397
467
  });
468
+ if (res.status === 401 || res.status === 403) throw new ConnectorUnauthorizedError3(`Vendure API error: ${res.status}`, res.status);
398
469
  if (!res.ok) throw new Error(`Vendure API error: ${res.status}`);
399
- return res.json();
470
+ const body = await res.json();
471
+ const forbidden = body.errors?.filter((error) => error.extensions?.code === "FORBIDDEN") ?? [];
472
+ if (forbidden.length) throw await forbiddenError(context, forbidden);
473
+ return body;
400
474
  }
401
475
 
402
476
  // src/replication/products.ts
403
- import { compareIds } from "@tallyui/core";
477
+ import { compareIds, ConnectorUnauthorizedError as ConnectorUnauthorizedError4 } from "@tallyui/core";
404
478
  var PRODUCT_LIST_QUERY2 = (barcodeField) => `
405
479
  query GetProducts($options: ProductListOptions) {
406
480
  products(options: $options) {
@@ -430,6 +504,7 @@ var PRODUCT_LIST_QUERY2 = (barcodeField) => `
430
504
  enabled
431
505
  featuredAsset { id preview }
432
506
  options { id name code }
507
+ taxCategory { id }
433
508
  ${barcodeField ? `customFields { ${barcodeField} }` : ""}
434
509
  }
435
510
  }
@@ -458,12 +533,30 @@ async function gql2(context, query2, variables) {
458
533
  if (!res.ok) {
459
534
  const body2 = await res.json().catch(() => void 0);
460
535
  const message = body2?.errors?.[0]?.message;
536
+ if (res.status === 401 || res.status === 403) throw new ConnectorUnauthorizedError4(`Vendure API error: ${res.status}${message ? `: ${message}` : ""}`, res.status);
461
537
  throw new Error(`Vendure API error: ${res.status}${message ? `: ${message}` : ""}`);
462
538
  }
463
539
  const body = await res.json();
540
+ const forbidden = body.errors?.filter((error) => error.extensions?.code === "FORBIDDEN") ?? [];
541
+ if (forbidden.length) throw await forbiddenError(context, forbidden);
464
542
  if (body.errors?.length) throw new Error(`Vendure GraphQL error: ${body.errors[0].message}`);
465
543
  return body;
466
544
  }
545
+ var TIMEZONE_MESSAGE = "Vendure updatedAt filters miss changes: run Vendure with TZ=UTC or set updatedAtSkewMs to at least the magnitude of the server UTC offset in milliseconds.";
546
+ var VendureTimezoneConfigError = class extends Error {
547
+ name = "VendureTimezoneConfigError";
548
+ code = "store_misconfigured";
549
+ /** Only the store owner can fix it on the server, so the pull waits the store delay (`errorKind`). */
550
+ fixedBy = "store";
551
+ /**
552
+ * The store-side remedy, for `SyncStatus`'s detail. The message's other remedy,
553
+ * `updatedAtSkewMs`, is a connector option on the till, so it stays in the message only.
554
+ */
555
+ fix = "run the Vendure server with its time zone set to UTC";
556
+ constructor() {
557
+ super(TIMEZONE_MESSAGE);
558
+ }
559
+ };
467
560
  async function probeUpdatedAtSkew(passHighWater, updatedAtSkewMs, fetchTotal, remark) {
468
561
  for (const overlap of [1, 0]) {
469
562
  if (overlap === 0 && updatedAtSkewMs > 0) continue;
@@ -477,7 +570,8 @@ async function probeUpdatedAtSkew(passHighWater, updatedAtSkewMs, fetchTotal, re
477
570
  }
478
571
  }
479
572
  if (overlap === 1 && total === 0) {
480
- throw new Error("Vendure updatedAt filters miss changes: run Vendure with TZ=UTC or set updatedAtSkewMs to at least the magnitude of the server UTC offset in milliseconds.");
573
+ if (remark) throw new VendureTimezoneConfigError();
574
+ throw new Error(TIMEZONE_MESSAGE);
481
575
  }
482
576
  if (overlap === 0 && total > 0) {
483
577
  console.warn("Vendure updatedAt filters over-fetch because the server is not in UTC.");
@@ -761,6 +855,7 @@ var vendureStoreSettings = async (context, _choice) => {
761
855
  default: defaultCategory ? byCategory.get(defaultCategory.id) ?? 0 : 0
762
856
  };
763
857
  for (const [categoryId, ppm] of byCategory) taxRatesPpm[categoryId] = ppm;
858
+ for (const category of categories) taxRatesPpm[category.id] ??= 0;
764
859
  return {
765
860
  currency: channel.defaultCurrencyCode,
766
861
  pricesIncludeTax: channel.pricesIncludeTax,
@@ -780,6 +875,8 @@ async function vendureGlobalStockSettings(context) {
780
875
  }
781
876
 
782
877
  // src/index.ts
878
+ import { ConnectorUnauthorizedError as ConnectorUnauthorizedError5 } from "@tallyui/core";
879
+ var VENDURE_IDS_PER_REQUEST = 1e3;
783
880
  var createVendureConnector = (options = {}) => {
784
881
  const idFeed = createReconcileFeed({ fetchByIds: createFetchByIds(options.barcodeField) });
785
882
  return {
@@ -814,14 +911,18 @@ var createVendureConnector = (options = {}) => {
814
911
  },
815
912
  reconcile: {
816
913
  stock: vendureStockReconcile,
817
- ids: { fetchPages, variantIds, enqueue: idFeed.enqueue },
818
- prices: { fetchPages: fetchPages2, fingerprint, enqueue: idFeed.enqueue }
914
+ ids: { fetchPages, variantIds, enqueue: idFeed.enqueue, refetchBatchSize: VENDURE_IDS_PER_REQUEST },
915
+ prices: { fetchPages: fetchPages2, fingerprint, enqueue: idFeed.enqueue, refetchBatchSize: VENDURE_IDS_PER_REQUEST }
819
916
  },
820
- storeSettings: vendureStoreSettings
917
+ storeSettings: vendureStoreSettings,
918
+ // Re-reads a restored session's capabilities; with an API key and no sign-in, the only reader.
919
+ capabilities: (context) => readVendureCapabilities(context.baseUrl, context.headers, { signal: context.signal })
821
920
  };
822
921
  };
823
922
  var vendureConnector = createVendureConnector();
824
923
  export {
924
+ ConnectorUnauthorizedError5 as ConnectorUnauthorizedError,
925
+ VendureTimezoneConfigError,
825
926
  createVendureConnector,
826
927
  createVendureProductReplication,
827
928
  createVendureVariantFeedReplication,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/connector-vendure",
3
- "version": "2.0.0",
3
+ "version": "3.0.0-next.0",
4
4
  "type": "module",
5
5
  "description": "Vendure connector for Tally UI",
6
6
  "main": "./dist/index.js",
@@ -26,12 +26,12 @@
26
26
  "directory": "connectors/vendure"
27
27
  },
28
28
  "peerDependencies": {
29
- "@tallyui/core": "2.0.0"
29
+ "@tallyui/core": "^3.0.0-next.0"
30
30
  },
31
31
  "devDependencies": {
32
- "@tallyui/database": "2.0.0",
33
- "@tallyui/pos": "2.0.0",
34
- "rxdb": "16.21.1"
32
+ "@tallyui/database": "3.0.0-next.0",
33
+ "@tallyui/pos": "3.0.0-next.0",
34
+ "rxdb": "17.5.0"
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsup",
package/src/auth.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { SignInError, type ConnectorAuth } from '@tallyui/core';
2
2
 
3
+ import { readVendureCapabilities } from './capabilities';
4
+
3
5
  const LOGIN = `mutation Login($email: String!, $password: String!) {
4
6
  login(username: $email, password: $password) {
5
7
  __typename
@@ -43,7 +45,10 @@ export const vendureSignIn: NonNullable<ConnectorAuth['signIn']> = async (baseUr
43
45
  if (!token) {
44
46
  throw new SignInError('unsupported', "Vendure signed in but sent no 'vendure-auth-token' header. Add 'bearer' to the server's authOptions.tokenMethod. This connector reads the default header name, so a server that renames authOptions.authTokenHeaderKey also gives this error.");
45
47
  }
46
- return { token, expiresAt: undefined };
48
+ // A capabilities read that fails never fails the sign-in: the capabilities are then unknown.
49
+ const capabilities = await readVendureCapabilities(baseUrl, { Authorization: `Bearer ${token}` }, { fetch: doFetch, signal: init.signal })
50
+ .catch(() => undefined);
51
+ return { token, expiresAt: undefined, capabilities };
47
52
  };
48
53
 
49
54
  // Warns once per module lifetime when a caller still relies on the deprecated auth_token alias.
@@ -0,0 +1,44 @@
1
+ import { ConnectorUnauthorizedError, parseInfoCapabilities, type ServerCapabilities } from '@tallyui/core';
2
+
3
+ import { signedOut } from './session-probe';
4
+
5
+ /** The plugin's capability endpoint on Vendure's REST server (ADR-062), relative to the base URL. */
6
+ export const CAPABILITIES_PATH = '/tally/v1/info';
7
+
8
+ /**
9
+ * Reads the store's contract capabilities (ADR-062) and `taxRounding` (#287) with the
10
+ * connector's own headers. A 2xx gives `parseInfoCapabilities`; a 404 or a non-JSON body
11
+ * means no plugin and gives `{ orderCreate: 1 }`; a network failure, a 5xx or any other
12
+ * non-2xx is unknown and gives `undefined`. The route answers a dead session and a missing
13
+ * `CreateOrder` permission alike with 403, so a 401/403 asks who is signed in (#279): nobody
14
+ * throws `ConnectorUnauthorizedError`, an administrator throws a plain Error naming the
15
+ * permission, and a failed check is unknown.
16
+ */
17
+ export async function readVendureCapabilities(
18
+ baseUrl: string,
19
+ headers: Record<string, string>,
20
+ init: { fetch?: typeof fetch; signal?: AbortSignal } = {},
21
+ ): Promise<ServerCapabilities | undefined> {
22
+ const doFetch = init.fetch ?? fetch;
23
+ let res: Response;
24
+ try {
25
+ res = await doFetch(`${baseUrl}${CAPABILITIES_PATH}`, { method: 'GET', headers, signal: init.signal });
26
+ } catch {
27
+ return undefined;
28
+ }
29
+ if (res.status === 401 || res.status === 403) {
30
+ const out = await signedOut({ connectorId: 'vendure', baseUrl, headers, signal: init.signal });
31
+ if (out === true) throw new ConnectorUnauthorizedError(`Vendure refused ${CAPABILITIES_PATH}: the session is signed out`, res.status);
32
+ if (out === false) throw new Error(`Vendure refused ${CAPABILITIES_PATH} although the session is signed in: the CreateOrder permission is missing (HTTP ${res.status})`);
33
+ return undefined;
34
+ }
35
+ if (res.status === 404) return { orderCreate: 1 };
36
+ if (!res.ok) return undefined;
37
+ let body: unknown;
38
+ try {
39
+ body = await res.json();
40
+ } catch {
41
+ return { orderCreate: 1 };
42
+ }
43
+ return parseInfoCapabilities(body, (reason) => console.warn(`@tallyui/connector-vendure: ${reason}`));
44
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { combinePullAdapters, createReconcileFeed, type TallyConnector } from '@tallyui/core';
2
2
 
3
3
  import { vendureAuth } from './auth';
4
+ import { readVendureCapabilities } from './capabilities';
4
5
  import { vendureProductSchema } from './schemas/products';
5
6
  import { createVendureProductTraits } from './traits/product';
6
7
  import { createVendureProductSync } from './sync/products';
@@ -12,17 +13,22 @@ import { fetchPages as fetchPricePages, fingerprint as priceFingerprint } from '
12
13
  import { vendureStoreSettings } from './store-settings';
13
14
  import { vendureGlobalStockSettings } from './global-settings';
14
15
 
16
+ /** Ids per fetchByIds request: it sends the reconcile feed's whole chunk (at most 1,000 ids, ADR-060) in one query. */
17
+ const VENDURE_IDS_PER_REQUEST = 1000;
18
+
15
19
  /**
16
- * Vendure connector for Tally UI.
20
+ * Vendure connector for Tally UI. Build one per store session, anew on each sign-in or store change:
21
+ * each instance owns its reconcile feed, whose queued work must never reach another store's database (#307).
17
22
  *
18
23
  * Connects to Vendure backends via the Admin GraphQL API.
19
24
  * Products are stored in RxDB using a schema that mirrors the Vendure API shape.
20
25
  *
21
26
  * ```ts
22
- * import { vendureConnector } from '@tallyui/connector-vendure';
27
+ * import { createVendureConnector } from '@tallyui/connector-vendure';
23
28
  * import { ConnectorProvider } from '@tallyui/core';
24
29
  *
25
- * <ConnectorProvider connector={vendureConnector}>
30
+ * const connector = useMemo(() => createVendureConnector({ pricesIncludeTax }), [backendUrl, pricesIncludeTax]);
31
+ * <ConnectorProvider connector={connector}>
26
32
  * <App />
27
33
  * </ConnectorProvider>
28
34
  * ```
@@ -74,14 +80,21 @@ export const createVendureConnector = (options: {
74
80
 
75
81
  reconcile: {
76
82
  stock: vendureStockReconcile,
77
- ids: { fetchPages, variantIds, enqueue: idFeed.enqueue },
78
- prices: { fetchPages: fetchPricePages, fingerprint: priceFingerprint, enqueue: idFeed.enqueue },
83
+ ids: { fetchPages, variantIds, enqueue: idFeed.enqueue, refetchBatchSize: VENDURE_IDS_PER_REQUEST },
84
+ prices: { fetchPages: fetchPricePages, fingerprint: priceFingerprint, enqueue: idFeed.enqueue, refetchBatchSize: VENDURE_IDS_PER_REQUEST },
79
85
  },
80
86
 
81
87
  storeSettings: vendureStoreSettings,
88
+
89
+ // Re-reads a restored session's capabilities; with an API key and no sign-in, the only reader.
90
+ capabilities: (context) => readVendureCapabilities(context.baseUrl, context.headers, { signal: context.signal }),
82
91
  };
83
92
  };
84
93
 
94
+ /**
95
+ * @deprecated One instance for the whole app: a store switch can leak queued reconcile work across stores.
96
+ * Use createVendureConnector() per store session. Removed in 4.0.
97
+ */
85
98
  export const vendureConnector = createVendureConnector();
86
99
 
87
100
  // Re-export pieces for advanced usage
@@ -94,3 +107,6 @@ export { createVendureVariantFeedReplication } from './replication/variant-feed'
94
107
  export { vendureStockReconcile } from './reconcile/stock';
95
108
  export { vendureStoreSettings } from './store-settings';
96
109
  export { vendureGlobalStockSettings } from './global-settings';
110
+
111
+ export { ConnectorUnauthorizedError } from '@tallyui/core';
112
+ export { VendureTimezoneConfigError } from './replication/products';
@@ -1,6 +1,7 @@
1
- import { compareIds } from '@tallyui/core';
1
+ import { compareIds, ConnectorUnauthorizedError } from '@tallyui/core';
2
2
  import type { ReplicationAdapter, SyncContext } from '@tallyui/core';
3
3
  import { vendureProductSchema } from '../schemas/products';
4
+ import { forbiddenError } from '../session-probe';
4
5
 
5
6
  export type VendureProductCheckpoint = {
6
7
  skip: number;
@@ -39,6 +40,7 @@ export const PRODUCT_LIST_QUERY = (barcodeField?: string) => `
39
40
  enabled
40
41
  featuredAsset { id preview }
41
42
  options { id name code }
43
+ taxCategory { id }
42
44
  ${barcodeField ? `customFields { ${barcodeField} }` : ''}
43
45
  }
44
46
  }
@@ -65,7 +67,7 @@ export async function gql(
65
67
  query: string,
66
68
  variables?: Record<string, any>,
67
69
  ): Promise<any> {
68
- type GqlBody = { data?: any; errors?: Array<{ message?: string }> };
70
+ type GqlBody = { data?: any; errors?: Array<{ message?: string; path?: Array<string | number>; extensions?: { code?: string } }> };
69
71
  const res = await fetch(`${context.baseUrl}/admin-api`, {
70
72
  method: 'POST',
71
73
  headers: {
@@ -79,13 +81,35 @@ export async function gql(
79
81
  if (!res.ok) {
80
82
  const body = await res.json().catch(() => undefined) as GqlBody | undefined;
81
83
  const message = body?.errors?.[0]?.message;
84
+ if (res.status === 401 || res.status === 403) throw new ConnectorUnauthorizedError(`Vendure API error: ${res.status}${message ? `: ${message}` : ''}`, res.status);
82
85
  throw new Error(`Vendure API error: ${res.status}${message ? `: ${message}` : ''}`);
83
86
  }
84
87
  const body = await res.json() as GqlBody;
88
+ const forbidden = body.errors?.filter((error) => error.extensions?.code === 'FORBIDDEN') ?? [];
89
+ if (forbidden.length) throw await forbiddenError(context, forbidden);
85
90
  if (body.errors?.length) throw new Error(`Vendure GraphQL error: ${body.errors[0].message}`);
86
91
  return body;
87
92
  }
88
93
 
94
+ const TIMEZONE_MESSAGE = 'Vendure updatedAt filters miss changes: run Vendure with TZ=UTC or set updatedAtSkewMs to at least the magnitude of the server UTC offset in milliseconds.';
95
+
96
+ /** The server's updatedAt filters miss changes because it does not run in UTC (probeUpdatedAtSkew). */
97
+ export class VendureTimezoneConfigError extends Error {
98
+ name = 'VendureTimezoneConfigError';
99
+ readonly code = 'store_misconfigured' as const;
100
+ /** Only the store owner can fix it on the server, so the pull waits the store delay (`errorKind`). */
101
+ readonly fixedBy = 'store' as const;
102
+ /**
103
+ * The store-side remedy, for `SyncStatus`'s detail. The message's other remedy,
104
+ * `updatedAtSkewMs`, is a connector option on the till, so it stays in the message only.
105
+ */
106
+ readonly fix = 'run the Vendure server with its time zone set to UTC';
107
+
108
+ constructor() {
109
+ super(TIMEZONE_MESSAGE);
110
+ }
111
+ }
112
+
89
113
  /**
90
114
  * Probes around a pass's high-water mark for the server's updatedAt skew
91
115
  * (ADR-060). `fetchTotal(after)` runs the caller's own list query (products,
@@ -114,7 +138,9 @@ export async function probeUpdatedAtSkew(
114
138
  }
115
139
  }
116
140
  if (overlap === 1 && total === 0) {
117
- throw new Error('Vendure updatedAt filters miss changes: run Vendure with TZ=UTC or set updatedAtSkewMs to at least the magnitude of the server UTC offset in milliseconds.');
141
+ // Only a re-read rules out a mark deleted mid-probe; without one (the variant feed) the race stays a transient error.
142
+ if (remark) throw new VendureTimezoneConfigError();
143
+ throw new Error(TIMEZONE_MESSAGE);
118
144
  }
119
145
  if (overlap === 0 && total > 0) {
120
146
  console.warn('Vendure updatedAt filters over-fetch because the server is not in UTC.');
@@ -13,8 +13,9 @@ import type { RxJsonSchema } from 'rxdb';
13
13
  */
14
14
  export const vendureProductSchema: RxJsonSchema<any> = {
15
15
  // 1: declares variant trackInventory/outOfStockThreshold/useGlobalOutOfStockThreshold/enabled
16
- // (backlog 28). A bump drops and resyncs (ConnectorSchemas, ADR-060 amendment 9).
17
- version: 1,
16
+ // (backlog 28). 2: declares variant taxCategory { id } (#288). A bump drops and resyncs
17
+ // (ConnectorSchemas, ADR-060 amendment 9).
18
+ version: 2,
18
19
  primaryKey: 'id',
19
20
  type: 'object',
20
21
  properties: {
@@ -116,6 +117,7 @@ export const vendureProductSchema: RxJsonSchema<any> = {
116
117
  },
117
118
  },
118
119
  customFields: { type: 'object' },
120
+ taxCategory: { type: ['object', 'null'], properties: { id: { type: 'string' } } },
119
121
  },
120
122
  },
121
123
  },
@@ -0,0 +1,39 @@
1
+ import { ConnectorUnauthorizedError } from '@tallyui/core';
2
+ import type { SyncContext } from '@tallyui/core';
3
+
4
+ /**
5
+ * Vendure answers a dead session and a missing permission alike: HTTP 200 with GraphQL
6
+ * `FORBIDDEN` (#274). Asks who is signed in with the failed request's headers: `true` when
7
+ * nobody is, `false` when an administrator is, `undefined` when the check itself failed.
8
+ */
9
+ export async function signedOut(context: SyncContext): Promise<boolean | undefined> {
10
+ try {
11
+ const res = await fetch(`${context.baseUrl}/admin-api`, {
12
+ method: 'POST',
13
+ headers: { 'Content-Type': 'application/json', ...context.headers },
14
+ body: JSON.stringify({ query: 'query { activeAdministrator { id } }' }),
15
+ signal: context.signal,
16
+ });
17
+ if (!res.ok) return undefined;
18
+ const body = await res.json() as { data?: { activeAdministrator?: { id?: unknown } | null }; errors?: unknown[] };
19
+ if (body.errors?.length) return undefined;
20
+ if (body.data?.activeAdministrator === null) return true;
21
+ return body.data?.activeAdministrator?.id != null ? false : undefined;
22
+ } catch (error) {
23
+ if (context.signal?.aborted) throw error;
24
+ return undefined;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * The error for a `FORBIDDEN` answer: only a signed-out session signs the till out. The plain
30
+ * errors name everything refused, in the answer's order (each GraphQL `path`, e.g. `product.variants`),
31
+ * for the store owner's log. One probe answers for the whole response.
32
+ */
33
+ export async function forbiddenError(context: SyncContext, forbidden: Array<{ message?: string; path?: ReadonlyArray<string | number> }>): Promise<Error> {
34
+ const out = await signedOut(context);
35
+ if (out === true) return new ConnectorUnauthorizedError(`Vendure GraphQL error: ${forbidden[0].message}`);
36
+ const what = forbidden.map(({ message, path }) => (path?.length ? `${path.join('.')}: ${message}` : message)).join('; ');
37
+ if (out === false) return new Error(`Vendure GraphQL error: FORBIDDEN: the store refused this request although the session is signed in (a permission is missing): ${what}`);
38
+ return new Error(`Vendure GraphQL error: FORBIDDEN: ${what} (the session check failed, so this may be transient)`);
39
+ }
@@ -69,6 +69,9 @@ export const vendureStoreSettings = async (context: SyncContext, _choice?: Store
69
69
  default: defaultCategory ? byCategory.get(defaultCategory.id) ?? 0 : 0,
70
70
  };
71
71
  for (const [categoryId, ppm] of byCategory) taxRatesPpm[categoryId] = ppm;
72
+ // A category with no enabled rate in the zone is charged 0 by Vendure, as for `default` above: an explicit 0, so the
73
+ // till never taxes it at the default rate (#288).
74
+ for (const category of categories) taxRatesPpm[category.id] ??= 0;
72
75
 
73
76
  return {
74
77
  currency: channel.defaultCurrencyCode,
@@ -1,4 +1,6 @@
1
+ import { ConnectorUnauthorizedError } from '@tallyui/core';
1
2
  import type { CollectionSync, SyncContext } from '@tallyui/core';
3
+ import { forbiddenError } from '../session-probe';
2
4
 
3
5
  /**
4
6
  * GraphQL query fragments used by the Vendure sync.
@@ -150,6 +152,10 @@ async function gql(
150
152
  signal: context.signal,
151
153
  });
152
154
 
155
+ if (res.status === 401 || res.status === 403) throw new ConnectorUnauthorizedError(`Vendure API error: ${res.status}`, res.status);
153
156
  if (!res.ok) throw new Error(`Vendure API error: ${res.status}`);
154
- return res.json();
157
+ const body: { data?: any; errors?: Array<{ message?: string; path?: Array<string | number>; extensions?: { code?: string } }> } = await res.json();
158
+ const forbidden = body.errors?.filter((error) => error.extensions?.code === 'FORBIDDEN') ?? [];
159
+ if (forbidden.length) throw await forbiddenError(context, forbidden);
160
+ return body;
155
161
  }
@@ -110,6 +110,14 @@ const traits: ProductTraits = {
110
110
  stock: traits.getStock({ variants: [variant] }),
111
111
  })),
112
112
 
113
+ // The variant's tax category id: store-settings.ts keys taxRatesPpm by the same id. No variant id: the default
114
+ // (first live) variant, as getPrices.
115
+ getTaxClass: (doc, variantId) => {
116
+ const variant = variantId === undefined ? liveVariants(doc)[0]
117
+ : (doc.variants ?? []).find((v: any) => String(v.id) === variantId);
118
+ return variant?.taxCategory?.id == null ? undefined : String(variant.taxCategory.id);
119
+ },
120
+
113
121
  getStock: (doc) => productStock(liveVariants(doc).map((variant: any) =>
114
122
  variantStock(variant, stockLocationId, globalTrackInventory, globalOutOfStockThreshold))),
115
123
 
@@ -171,7 +179,7 @@ const traits: ProductTraits = {
171
179
 
172
180
  getBarcode: (doc) => {
173
181
  // Vendure has no native barcode — check custom fields
174
- return barcodeField ? doc.variants?.[0]?.customFields?.[barcodeField] || undefined : undefined;
182
+ return barcodeField ? liveVariants(doc)[0]?.customFields?.[barcodeField] || undefined : undefined;
175
183
  },
176
184
 
177
185
  getCategoryNames: (doc) =>