@tallyui/connector-vendure 2.0.0 → 3.0.0-next.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/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`, defined in `@tallyui/core` and re-exported by this connector. `error.status` is `401` (`code: 'unauthorized'`, sign in again) or `403` (`code: 'forbidden'`, signed in but not allowed: the pull retries on the store schedule; don't sign out); a signed-out Vendure session is always `401`.
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
  *
@@ -71,7 +85,7 @@ declare const vendureStockReconcile: StockReconcileAdapter;
71
85
  /**
72
86
  * Reads the active channel's currency and tax-inclusivity (ADR-049), and the
73
87
  * default tax zone's enabled, non-customer-group rates keyed by tax category
74
- * id, in integer ppm rounded once. Read-only (ADR-048). Ignores `choice`:
88
+ * id, in integer ppm rounded once, with their rate names for grouping. Read-only (ADR-048). Ignores `choice`:
75
89
  * Vendure's channel is already chosen by the `vendure-token` header sent
76
90
  * with every request, so there is no `pricingContext` either.
77
91
  */
@@ -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}`, 401);
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`, 401);
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 void 0;
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.");
@@ -730,7 +824,7 @@ var CHANNEL_AND_CATEGORIES_QUERY = `
730
824
  var TAX_RATES_QUERY = `
731
825
  query StoreSettingsTaxRates($zoneId: String!) {
732
826
  taxRates(options: { take: 1000, filter: { zoneId: { eq: $zoneId } } }) {
733
- items { enabled value category { id } customerGroup { id } }
827
+ items { name enabled value category { id } customerGroup { id } }
734
828
  }
735
829
  }
736
830
  `;
@@ -752,19 +846,28 @@ var vendureStoreSettings = async (context, _choice) => {
752
846
  rateItems = ratesBody.data?.taxRates?.items ?? [];
753
847
  }
754
848
  const byCategory = /* @__PURE__ */ new Map();
849
+ const namesByCategory = /* @__PURE__ */ new Map();
755
850
  for (const rate of rateItems) {
756
851
  if (!rate.enabled || rate.customerGroup || !rate.category) continue;
757
852
  byCategory.set(rate.category.id, Math.round(rate.value * 1e4));
853
+ if (typeof rate.name === "string" && rate.name) namesByCategory.set(rate.category.id, rate.name);
854
+ else namesByCategory.delete(rate.category.id);
758
855
  }
759
856
  const defaultCategory = categories.find((c) => c.isDefault) ?? categories[0];
857
+ const taxRateCodes = {};
858
+ const defaultName = defaultCategory && namesByCategory.get(defaultCategory.id);
859
+ if (defaultName) taxRateCodes.default = defaultName;
860
+ for (const [categoryId, name] of namesByCategory) taxRateCodes[categoryId] = name;
760
861
  const taxRatesPpm = {
761
862
  default: defaultCategory ? byCategory.get(defaultCategory.id) ?? 0 : 0
762
863
  };
763
864
  for (const [categoryId, ppm] of byCategory) taxRatesPpm[categoryId] = ppm;
865
+ for (const category of categories) taxRatesPpm[category.id] ??= 0;
764
866
  return {
765
867
  currency: channel.defaultCurrencyCode,
766
868
  pricesIncludeTax: channel.pricesIncludeTax,
767
- taxRatesPpm
869
+ taxRatesPpm,
870
+ ...Object.keys(taxRateCodes).length > 0 && { taxRateCodes }
768
871
  };
769
872
  };
770
873
 
@@ -780,6 +883,8 @@ async function vendureGlobalStockSettings(context) {
780
883
  }
781
884
 
782
885
  // src/index.ts
886
+ import { ConnectorUnauthorizedError as ConnectorUnauthorizedError5 } from "@tallyui/core";
887
+ var VENDURE_IDS_PER_REQUEST = 1e3;
783
888
  var createVendureConnector = (options = {}) => {
784
889
  const idFeed = createReconcileFeed({ fetchByIds: createFetchByIds(options.barcodeField) });
785
890
  return {
@@ -814,14 +919,18 @@ var createVendureConnector = (options = {}) => {
814
919
  },
815
920
  reconcile: {
816
921
  stock: vendureStockReconcile,
817
- ids: { fetchPages, variantIds, enqueue: idFeed.enqueue },
818
- prices: { fetchPages: fetchPages2, fingerprint, enqueue: idFeed.enqueue }
922
+ ids: { fetchPages, variantIds, enqueue: idFeed.enqueue, refetchBatchSize: VENDURE_IDS_PER_REQUEST },
923
+ prices: { fetchPages: fetchPages2, fingerprint, enqueue: idFeed.enqueue, refetchBatchSize: VENDURE_IDS_PER_REQUEST }
819
924
  },
820
- storeSettings: vendureStoreSettings
925
+ storeSettings: vendureStoreSettings,
926
+ // Re-reads a restored session's capabilities; with an API key and no sign-in, the only reader.
927
+ capabilities: (context) => readVendureCapabilities(context.baseUrl, context.headers, { signal: context.signal })
821
928
  };
822
929
  };
823
930
  var vendureConnector = createVendureConnector();
824
931
  export {
932
+ ConnectorUnauthorizedError5 as ConnectorUnauthorizedError,
933
+ VendureTimezoneConfigError,
825
934
  createVendureConnector,
826
935
  createVendureProductReplication,
827
936
  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.1",
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.1"
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.1",
33
+ "@tallyui/pos": "3.0.0-next.1",
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,45 @@
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 404 means an old plugin and gives the default; a network failure,
11
+ * non-2xx other than 404/401/403, non-JSON 2xx or malformed `taxRounding` is unknown and gives
12
+ * `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
+ // The probe confirms sign-out; the route's 403 also covers missing permissions.
32
+ if (out === true) throw new ConnectorUnauthorizedError(`Vendure refused ${CAPABILITIES_PATH}: the session is signed out`, 401);
33
+ if (out === false) throw new Error(`Vendure refused ${CAPABILITIES_PATH} although the session is signed in: the CreateOrder permission is missing (HTTP ${res.status})`);
34
+ return undefined;
35
+ }
36
+ if (res.status === 404) return { orderCreate: 1 };
37
+ if (!res.ok) return undefined;
38
+ let body: unknown;
39
+ try {
40
+ body = await res.json();
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ return parseInfoCapabilities(body, (reason) => console.warn(`@tallyui/connector-vendure: ${reason}`));
45
+ }
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 as 401 | 403);
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,40 @@
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
+ // The probe confirmed a signed-out session even though Vendure answered FORBIDDEN.
36
+ if (out === true) return new ConnectorUnauthorizedError(`Vendure GraphQL error: ${forbidden[0].message}`, 401);
37
+ const what = forbidden.map(({ message, path }) => (path?.length ? `${path.join('.')}: ${message}` : message)).join('; ');
38
+ 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}`);
39
+ return new Error(`Vendure GraphQL error: FORBIDDEN: ${what} (the session check failed, so this may be transient)`);
40
+ }
@@ -13,14 +13,14 @@ const CHANNEL_AND_CATEGORIES_QUERY = `
13
13
  const TAX_RATES_QUERY = `
14
14
  query StoreSettingsTaxRates($zoneId: String!) {
15
15
  taxRates(options: { take: 1000, filter: { zoneId: { eq: $zoneId } } }) {
16
- items { enabled value category { id } customerGroup { id } }
16
+ items { name enabled value category { id } customerGroup { id } }
17
17
  }
18
18
  }
19
19
  `;
20
20
 
21
21
  type ActiveChannel = { defaultCurrencyCode: string; pricesIncludeTax: boolean; defaultTaxZone: { id: string } | null };
22
22
  type TaxCategory = { id: string; isDefault: boolean };
23
- type TaxRateItem = { enabled: boolean; value: number; category: { id: string } | null; customerGroup: { id: string } | null };
23
+ type TaxRateItem = { name?: string; enabled: boolean; value: number; category: { id: string } | null; customerGroup: { id: string } | null };
24
24
 
25
25
  /** `gql` throws a plain `Error` for a non-OK response or a GraphQL `errors` body; both become `StoreSettingsError('failed')` here. */
26
26
  async function query(context: SyncContext, source: string, variables?: Record<string, unknown>): Promise<any> {
@@ -34,7 +34,7 @@ async function query(context: SyncContext, source: string, variables?: Record<st
34
34
  /**
35
35
  * Reads the active channel's currency and tax-inclusivity (ADR-049), and the
36
36
  * default tax zone's enabled, non-customer-group rates keyed by tax category
37
- * id, in integer ppm rounded once. Read-only (ADR-048). Ignores `choice`:
37
+ * id, in integer ppm rounded once, with their rate names for grouping. Read-only (ADR-048). Ignores `choice`:
38
38
  * Vendure's channel is already chosen by the `vendure-token` header sent
39
39
  * with every request, so there is no `pricingContext` either.
40
40
  */
@@ -51,10 +51,13 @@ export const vendureStoreSettings = async (context: SyncContext, _choice?: Store
51
51
  }
52
52
 
53
53
  const byCategory = new Map<string, number>();
54
+ const namesByCategory = new Map<string, string>();
54
55
  for (const rate of rateItems) {
55
56
  if (!rate.enabled || rate.customerGroup || !rate.category) continue;
56
57
  // Rounded once, here, at the connector's edge (a backend decimal percentage -> integer ppm).
57
58
  byCategory.set(rate.category.id, Math.round(rate.value * 10_000));
59
+ if (typeof rate.name === 'string' && rate.name) namesByCategory.set(rate.category.id, rate.name);
60
+ else namesByCategory.delete(rate.category.id);
58
61
  }
59
62
 
60
63
  // Vendure's own fallback for a variant created without a category
@@ -65,14 +68,22 @@ export const vendureStoreSettings = async (context: SyncContext, _choice?: Store
65
68
  // at all), `default` is 0 — that is what Vendure itself charges in that
66
69
  // case, never a silent guess.
67
70
  const defaultCategory = categories.find((c) => c.isDefault) ?? categories[0];
71
+ const taxRateCodes: Record<string, string> = {};
72
+ const defaultName = defaultCategory && namesByCategory.get(defaultCategory.id);
73
+ if (defaultName) taxRateCodes.default = defaultName;
74
+ for (const [categoryId, name] of namesByCategory) taxRateCodes[categoryId] = name;
68
75
  const taxRatesPpm: { default: number; [taxClass: string]: number } = {
69
76
  default: defaultCategory ? byCategory.get(defaultCategory.id) ?? 0 : 0,
70
77
  };
71
78
  for (const [categoryId, ppm] of byCategory) taxRatesPpm[categoryId] = ppm;
79
+ // A category with no enabled rate in the zone is charged 0 by Vendure, as for `default` above: an explicit 0, so the
80
+ // till never taxes it at the default rate (#288).
81
+ for (const category of categories) taxRatesPpm[category.id] ??= 0;
72
82
 
73
83
  return {
74
84
  currency: channel.defaultCurrencyCode,
75
85
  pricesIncludeTax: channel.pricesIncludeTax,
76
86
  taxRatesPpm,
87
+ ...(Object.keys(taxRateCodes).length > 0 && { taxRateCodes }),
77
88
  };
78
89
  };
@@ -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 as 401 | 403);
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) =>