@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 +7 -0
- package/dist/index.d.ts +24 -4
- package/dist/index.js +112 -11
- package/package.json +5 -5
- package/src/auth.ts +6 -1
- package/src/capabilities.ts +44 -0
- package/src/index.ts +21 -5
- package/src/replication/products.ts +29 -3
- package/src/schemas/products.ts +4 -2
- package/src/session-probe.ts +39 -0
- package/src/store-settings.ts +3 -0
- package/src/sync/products.ts +7 -1
- package/src/traits/product.ts +9 -1
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 {
|
|
112
|
+
* import { createVendureConnector } from '@tallyui/connector-vendure';
|
|
98
113
|
* import { ConnectorProvider } from '@tallyui/core';
|
|
99
114
|
*
|
|
100
|
-
*
|
|
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
|
-
|
|
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
|
|
76
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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": "
|
|
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": "
|
|
29
|
+
"@tallyui/core": "^3.0.0-next.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@tallyui/database": "
|
|
33
|
-
"@tallyui/pos": "
|
|
34
|
-
"rxdb": "
|
|
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
|
-
|
|
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 {
|
|
27
|
+
* import { createVendureConnector } from '@tallyui/connector-vendure';
|
|
23
28
|
* import { ConnectorProvider } from '@tallyui/core';
|
|
24
29
|
*
|
|
25
|
-
*
|
|
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
|
-
|
|
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.');
|
package/src/schemas/products.ts
CHANGED
|
@@ -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
|
|
17
|
-
|
|
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
|
+
}
|
package/src/store-settings.ts
CHANGED
|
@@ -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,
|
package/src/sync/products.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/traits/product.ts
CHANGED
|
@@ -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
|
|
182
|
+
return barcodeField ? liveVariants(doc)[0]?.customFields?.[barcodeField] || undefined : undefined;
|
|
175
183
|
},
|
|
176
184
|
|
|
177
185
|
getCategoryNames: (doc) =>
|