@tallyui/connector-vendure 1.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/dist/index.js CHANGED
@@ -1,6 +1,141 @@
1
+ // src/index.ts
2
+ import { combinePullAdapters, createReconcileFeed } from "@tallyui/core";
3
+
4
+ // src/auth.ts
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
66
+ var LOGIN = `mutation Login($email: String!, $password: String!) {
67
+ login(username: $email, password: $password) {
68
+ __typename
69
+ ... on CurrentUser { id }
70
+ ... on ErrorResult { errorCode message }
71
+ }
72
+ }`;
73
+ var vendureSignIn = async (baseUrl, { email, password }, init = {}) => {
74
+ const doFetch = init.fetch ?? fetch;
75
+ let res;
76
+ try {
77
+ res = await doFetch(`${baseUrl}/admin-api`, {
78
+ method: "POST",
79
+ headers: { "Content-Type": "application/json" },
80
+ signal: init.signal,
81
+ body: JSON.stringify({ query: LOGIN, variables: { email, password } })
82
+ });
83
+ } catch (error) {
84
+ if (error?.name === "AbortError" || init.signal?.aborted) throw error;
85
+ throw new SignInError("failed", `Could not reach Vendure at ${baseUrl}: ${error instanceof Error ? error.message : String(error)}`);
86
+ }
87
+ let body;
88
+ try {
89
+ body = await res.json();
90
+ } catch {
91
+ throw new SignInError("server_error", `Vendure sent an unreadable response (HTTP ${res.status})`, res.status);
92
+ }
93
+ if (!res.ok) throw new SignInError("server_error", body?.errors?.[0]?.message ?? `Vendure sign-in failed (HTTP ${res.status})`, res.status);
94
+ const login = body?.data?.login;
95
+ if (body?.errors?.length || !login) {
96
+ throw new SignInError("server_error", body?.errors?.[0]?.message ?? `Vendure sign-in failed (HTTP ${res.status})`, res.status);
97
+ }
98
+ if (login.errorCode === "INVALID_CREDENTIALS_ERROR") throw new SignInError("invalid_credentials", login.message ?? "Invalid email or password");
99
+ if (login.errorCode === "NATIVE_AUTH_STRATEGY_ERROR") throw new SignInError("unsupported", login.message ?? "Native email and password sign-in is disabled on this Vendure server");
100
+ if (login.__typename !== "CurrentUser") throw new SignInError("server_error", login.message ?? `Vendure sign-in failed (${login.__typename})`, res.status);
101
+ const token = res.headers.get("vendure-auth-token");
102
+ if (!token) {
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.");
104
+ }
105
+ const capabilities = await readVendureCapabilities(baseUrl, { Authorization: `Bearer ${token}` }, { fetch: doFetch, signal: init.signal }).catch(() => void 0);
106
+ return { token, expiresAt: void 0, capabilities };
107
+ };
108
+ var warnedAuthToken = false;
109
+ var vendureAuth = {
110
+ type: "Vendure Admin API",
111
+ fields: [
112
+ { key: "url", label: "Backend URL", type: "url", placeholder: "https://my-vendure-server.com", required: true },
113
+ { key: "email", label: "Email", type: "text", required: true },
114
+ { key: "password", label: "Password", type: "password", required: true },
115
+ { key: "channel_token", label: "Channel token (optional)", type: "text" }
116
+ ],
117
+ // An API key (Vendure 3.6+) wins over a signed-in session token; channel_token selects a non-default channel.
118
+ // auth_token is the deprecated name for token, kept so existing apps keep working.
119
+ getHeaders: (credentials) => {
120
+ if (credentials.token === void 0 && credentials.auth_token !== void 0 && !warnedAuthToken) {
121
+ warnedAuthToken = true;
122
+ console.warn("@tallyui/connector-vendure: the 'auth_token' credential is deprecated; store the credential as 'token' instead.");
123
+ }
124
+ const token = credentials.token ?? credentials.auth_token;
125
+ return {
126
+ ...credentials.api_key ? { "vendure-api-key": credentials.api_key } : token ? { Authorization: `Bearer ${token}` } : {},
127
+ ...credentials.channel_token ? { "vendure-token": credentials.channel_token } : {}
128
+ };
129
+ },
130
+ signIn: vendureSignIn
131
+ };
132
+
1
133
  // src/schemas/products.ts
2
134
  var vendureProductSchema = {
3
- version: 0,
135
+ // 1: declares variant trackInventory/outOfStockThreshold/useGlobalOutOfStockThreshold/enabled
136
+ // (backlog 28). 2: declares variant taxCategory { id } (#288). A bump drops and resyncs
137
+ // (ConnectorSchemas, ADR-060 amendment 9).
138
+ version: 2,
4
139
  primaryKey: "id",
5
140
  type: "object",
6
141
  properties: {
@@ -68,7 +203,21 @@ var vendureProductSchema = {
68
203
  currencyCode: { type: "string" },
69
204
  stockLevel: { type: "string" },
70
205
  stockOnHand: { type: "number" },
206
+ stockLevels: {
207
+ type: "array",
208
+ items: {
209
+ type: "object",
210
+ properties: {
211
+ stockLocationId: { type: "string" },
212
+ stockOnHand: { type: "number" },
213
+ stockAllocated: { type: "number" }
214
+ }
215
+ }
216
+ },
71
217
  trackInventory: { type: "string" },
218
+ outOfStockThreshold: { type: "number" },
219
+ useGlobalOutOfStockThreshold: { type: "boolean" },
220
+ enabled: { type: "boolean" },
72
221
  featuredAsset: {
73
222
  type: ["object", "null"],
74
223
  properties: {
@@ -87,7 +236,8 @@ var vendureProductSchema = {
87
236
  }
88
237
  }
89
238
  },
90
- customFields: { type: "object" }
239
+ customFields: { type: "object" },
240
+ taxCategory: { type: ["object", "null"], properties: { id: { type: "string" } } }
91
241
  }
92
242
  }
93
243
  },
@@ -98,55 +248,109 @@ var vendureProductSchema = {
98
248
  };
99
249
 
100
250
  // src/traits/product.ts
101
- var vendureProductTraits = {
102
- getId: (doc) => String(doc.id),
103
- getName: (doc) => doc.name ?? "",
104
- getSku: (doc) => doc.variants?.[0]?.sku || void 0,
105
- getPrice: (doc) => {
106
- const amount = doc.variants?.[0]?.priceWithTax;
107
- if (amount == null) return void 0;
108
- return (amount / 100).toFixed(2);
109
- },
110
- getRegularPrice: (doc) => {
111
- const amount = doc.variants?.[0]?.priceWithTax;
112
- if (amount == null) return void 0;
113
- return (amount / 100).toFixed(2);
114
- },
115
- getSalePrice: () => {
116
- return void 0;
117
- },
118
- isOnSale: () => {
119
- return false;
120
- },
121
- getImageUrl: (doc) => doc.featuredAsset?.preview || doc.assets?.[0]?.preview || void 0,
122
- getImageUrls: (doc) => (doc.assets ?? []).map((a) => a.preview).filter(Boolean),
123
- getDescription: (doc) => doc.description || void 0,
124
- getStockStatus: (doc) => {
125
- const variant = doc.variants?.[0];
126
- if (!variant) return "unknown";
127
- const level = variant.stockLevel;
128
- if (level === "IN_STOCK" || level === "LOW_STOCK") return "instock";
129
- if (level === "OUT_OF_STOCK") return "outofstock";
130
- if (variant.stockOnHand != null) {
131
- return variant.stockOnHand > 0 ? "instock" : "outofstock";
132
- }
133
- return "unknown";
134
- },
135
- getStockQuantity: (doc) => {
136
- return doc.variants?.[0]?.stockOnHand ?? null;
137
- },
138
- hasVariants: (doc) => (doc.variants?.length ?? 0) > 1,
139
- getType: (doc) => {
140
- if ((doc.variants?.length ?? 0) > 1) return "variable";
141
- return "simple";
142
- },
143
- getBarcode: (doc) => {
144
- return doc.variants?.[0]?.customFields?.barcode || void 0;
145
- },
146
- getCategoryNames: (doc) => (doc.collections ?? []).map((c) => c.name).filter(Boolean)
147
- };
251
+ function productStock(variants2) {
252
+ if (!variants2.length) return { status: "unknown" };
253
+ const status = variants2.some((stock) => stock.status === "in_stock") ? "in_stock" : variants2.some((stock) => stock.status === "backorder") ? "backorder" : variants2.every((stock) => stock.status === "out_of_stock") ? "out_of_stock" : "unknown";
254
+ const quantity = variants2.every((stock) => stock.quantity != null) ? variants2.reduce((sum, stock) => sum + stock.quantity, 0) : void 0;
255
+ return quantity === void 0 ? { status } : { status, quantity };
256
+ }
257
+ function variantStock(variant, stockLocationId, globalTrackInventory, globalOutOfStockThreshold) {
258
+ if (!variant) return { status: "unknown" };
259
+ const tracked = variant.trackInventory === "TRUE" || variant.trackInventory !== "FALSE" && globalTrackInventory;
260
+ if (!tracked) return { status: "in_stock" };
261
+ const threshold = variant.useGlobalOutOfStockThreshold !== false ? globalOutOfStockThreshold : variant.outOfStockThreshold ?? 0;
262
+ let onHandLessAllocated;
263
+ if (variant.stockLevels != null) {
264
+ onHandLessAllocated = variant.stockLevels.filter((level) => stockLocationId == null || String(level.stockLocationId) === stockLocationId).reduce((sum, level) => sum + level.stockOnHand - level.stockAllocated, 0);
265
+ } else if (variant.stockOnHand != null) {
266
+ onHandLessAllocated = variant.stockOnHand;
267
+ }
268
+ if (onHandLessAllocated != null) {
269
+ const saleable = onHandLessAllocated - threshold;
270
+ const status = saleable <= 0 ? "out_of_stock" : onHandLessAllocated > 0 ? "in_stock" : "backorder";
271
+ return { status, quantity: Math.max(saleable, 0) };
272
+ }
273
+ if (variant.stockLevel === "IN_STOCK" || variant.stockLevel === "LOW_STOCK") return { status: "in_stock" };
274
+ if (variant.stockLevel === "OUT_OF_STOCK") return { status: "out_of_stock" };
275
+ return { status: "unknown" };
276
+ }
277
+ function liveVariants(doc) {
278
+ return (doc.variants ?? []).filter((variant) => variant.enabled !== false);
279
+ }
280
+ function createVendureProductTraits(barcodeField, stockLocationId, pricesIncludeTax = false, globalTrackInventory = true, globalOutOfStockThreshold = 0) {
281
+ const priceField = pricesIncludeTax ? "priceWithTax" : "price";
282
+ const traits = {
283
+ getId: (doc) => String(doc.id),
284
+ getName: (doc) => doc.name ?? "",
285
+ getSku: (doc) => doc.variants?.[0]?.sku || void 0,
286
+ getPrices: (doc, context) => {
287
+ const variant = liveVariants(doc)[0];
288
+ const amount = variant?.[priceField];
289
+ if (amount == null) return [];
290
+ const currency = String(variant.currencyCode ?? context?.currency ?? "XXX").toUpperCase();
291
+ return [{ amount, currency, kind: "base" }];
292
+ },
293
+ getVariants: (doc, context) => liveVariants(doc).map((variant) => ({
294
+ id: String(variant.id),
295
+ title: variant.name || void 0,
296
+ sku: variant.sku || void 0,
297
+ barcode: barcodeField ? variant.customFields?.[barcodeField] || void 0 : void 0,
298
+ prices: traits.getPrices({ variants: [variant] }, context),
299
+ stock: traits.getStock({ variants: [variant] })
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
+ },
307
+ getStock: (doc) => productStock(liveVariants(doc).map((variant) => variantStock(variant, stockLocationId, globalTrackInventory, globalOutOfStockThreshold))),
308
+ getPrice: (doc) => {
309
+ const amount = liveVariants(doc)[0]?.[priceField];
310
+ if (amount == null) return void 0;
311
+ return (amount / 100).toFixed(2);
312
+ },
313
+ getRegularPrice: (doc) => {
314
+ const amount = liveVariants(doc)[0]?.[priceField];
315
+ if (amount == null) return void 0;
316
+ return (amount / 100).toFixed(2);
317
+ },
318
+ getSalePrice: () => {
319
+ return void 0;
320
+ },
321
+ isOnSale: () => {
322
+ return false;
323
+ },
324
+ getImageUrl: (doc) => doc.featuredAsset?.preview || doc.assets?.[0]?.preview || void 0,
325
+ getImageUrls: (doc) => (doc.assets ?? []).map((a) => a.preview).filter(Boolean),
326
+ getDescription: (doc) => doc.description || void 0,
327
+ // Aggregated across every variant, like getStock (backlog 28), not variant 0.
328
+ getStockStatus: (doc) => {
329
+ const status = traits.getStock(doc).status;
330
+ if (status === "in_stock" || status === "backorder") return "instock";
331
+ if (status === "out_of_stock") return "outofstock";
332
+ return "unknown";
333
+ },
334
+ getStockQuantity: (doc) => traits.getStock(doc).quantity ?? null,
335
+ hasVariants: (doc) => (doc.variants?.length ?? 0) > 1,
336
+ // A product with no variants at all keeps the product-level-only answer.
337
+ isSellable: (doc) => doc.enabled !== false && ((doc.variants?.length ?? 0) === 0 || liveVariants(doc).length > 0),
338
+ getVariantCount: (doc) => doc.variants ? liveVariants(doc).length : 1,
339
+ getType: (doc) => {
340
+ if ((doc.variants?.length ?? 0) > 1) return "variable";
341
+ return "simple";
342
+ },
343
+ getBarcode: (doc) => {
344
+ return barcodeField ? liveVariants(doc)[0]?.customFields?.[barcodeField] || void 0 : void 0;
345
+ },
346
+ getCategoryNames: (doc) => (doc.collections ?? []).map((c) => c.name).filter(Boolean)
347
+ };
348
+ return traits;
349
+ }
350
+ var vendureProductTraits = createVendureProductTraits();
148
351
 
149
352
  // src/sync/products.ts
353
+ import { ConnectorUnauthorizedError as ConnectorUnauthorizedError3 } from "@tallyui/core";
150
354
  var PRODUCT_LIST_QUERY = `
151
355
  query GetProducts($options: ProductListOptions) {
152
356
  products(options: $options) {
@@ -158,7 +362,7 @@ var PRODUCT_LIST_QUERY = `
158
362
  }
159
363
  }
160
364
  `;
161
- var PRODUCT_DETAIL_QUERY = `
365
+ var PRODUCT_DETAIL_QUERY = (barcodeField) => `
162
366
  query GetProduct($id: ID!) {
163
367
  product(id: $id) {
164
368
  id
@@ -179,17 +383,16 @@ var PRODUCT_DETAIL_QUERY = `
179
383
  price
180
384
  priceWithTax
181
385
  currencyCode
182
- stockLevel
183
- stockOnHand
386
+ stockLevels { stockLocationId stockOnHand stockAllocated }
184
387
  trackInventory
185
388
  featuredAsset { id preview }
186
389
  options { id name code }
187
- customFields
390
+ ${barcodeField ? `customFields { ${barcodeField} }` : ""}
188
391
  }
189
392
  }
190
393
  }
191
394
  `;
192
- var vendureProductSync = {
395
+ var createVendureProductSync = (barcodeField) => ({
193
396
  fetchAllIds: async (context) => {
194
397
  const entries = [];
195
398
  const take = 100;
@@ -215,7 +418,7 @@ var vendureProductSync = {
215
418
  fetchByIds: async (ids, context) => {
216
419
  const products = [];
217
420
  for (const id of ids) {
218
- const res = await gql(context, PRODUCT_DETAIL_QUERY, { id });
421
+ const res = await gql(context, PRODUCT_DETAIL_QUERY(barcodeField), { id });
219
422
  if (res.data?.product) {
220
423
  products.push(res.data.product);
221
424
  }
@@ -241,7 +444,7 @@ var vendureProductSync = {
241
444
  if (!data) break;
242
445
  totalItems = data.totalItems;
243
446
  for (const item of data.items ?? []) {
244
- const detail = await gql(context, PRODUCT_DETAIL_QUERY, { id: item.id });
447
+ const detail = await gql(context, PRODUCT_DETAIL_QUERY(barcodeField), { id: item.id });
245
448
  if (detail.data?.product) {
246
449
  products.push(detail.data.product);
247
450
  }
@@ -250,23 +453,29 @@ var vendureProductSync = {
250
453
  }
251
454
  return products;
252
455
  }
253
- };
254
- async function gql(context, query, variables) {
456
+ });
457
+ var vendureProductSync = createVendureProductSync();
458
+ async function gql(context, query2, variables) {
255
459
  const res = await fetch(`${context.baseUrl}/admin-api`, {
256
460
  method: "POST",
257
461
  headers: {
258
462
  "Content-Type": "application/json",
259
463
  ...context.headers
260
464
  },
261
- body: JSON.stringify({ query, variables }),
465
+ body: JSON.stringify({ query: query2, variables }),
262
466
  signal: context.signal
263
467
  });
468
+ if (res.status === 401 || res.status === 403) throw new ConnectorUnauthorizedError3(`Vendure API error: ${res.status}`, res.status);
264
469
  if (!res.ok) throw new Error(`Vendure API error: ${res.status}`);
265
- 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;
266
474
  }
267
475
 
268
476
  // src/replication/products.ts
269
- var PRODUCT_LIST_QUERY2 = `
477
+ import { compareIds, ConnectorUnauthorizedError as ConnectorUnauthorizedError4 } from "@tallyui/core";
478
+ var PRODUCT_LIST_QUERY2 = (barcodeField) => `
270
479
  query GetProducts($options: ProductListOptions) {
271
480
  products(options: $options) {
272
481
  items {
@@ -288,136 +497,443 @@ var PRODUCT_LIST_QUERY2 = `
288
497
  price
289
498
  priceWithTax
290
499
  currencyCode
291
- stockLevel
292
- stockOnHand
500
+ stockLevels { stockLocationId stockOnHand stockAllocated }
293
501
  trackInventory
502
+ outOfStockThreshold
503
+ useGlobalOutOfStockThreshold
504
+ enabled
294
505
  featuredAsset { id preview }
295
506
  options { id name code }
296
- customFields
507
+ taxCategory { id }
508
+ ${barcodeField ? `customFields { ${barcodeField} }` : ""}
297
509
  }
298
510
  }
299
511
  totalItems
300
512
  }
301
513
  }
302
514
  `;
303
- var UPDATE_PRODUCT_MUTATION = `
304
- mutation UpdateProduct($input: UpdateProductInput!) {
305
- updateProduct(input: $input) {
306
- id
307
- updatedAt
515
+ var PRODUCT_MARK_QUERY = `
516
+ query GetProductsMark($options: ProductListOptions) {
517
+ products(options: $options) {
518
+ items { id updatedAt }
519
+ totalItems
308
520
  }
309
521
  }
310
522
  `;
311
- async function gql2(context, query, variables) {
523
+ async function gql2(context, query2, variables) {
312
524
  const res = await fetch(`${context.baseUrl}/admin-api`, {
313
525
  method: "POST",
314
526
  headers: {
315
527
  "Content-Type": "application/json",
316
528
  ...context.headers
317
529
  },
318
- body: JSON.stringify({ query, variables }),
530
+ body: JSON.stringify({ query: query2, variables }),
319
531
  signal: context.signal
320
532
  });
321
- if (!res.ok) throw new Error(`Vendure API error: ${res.status}`);
322
- return res.json();
533
+ if (!res.ok) {
534
+ const body2 = await res.json().catch(() => void 0);
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);
537
+ throw new Error(`Vendure API error: ${res.status}${message ? `: ${message}` : ""}`);
538
+ }
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);
542
+ if (body.errors?.length) throw new Error(`Vendure GraphQL error: ${body.errors[0].message}`);
543
+ return body;
323
544
  }
324
- var vendureProductReplication = {
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
+ };
560
+ async function probeUpdatedAtSkew(passHighWater, updatedAtSkewMs, fetchTotal, remark) {
561
+ for (const overlap of [1, 0]) {
562
+ if (overlap === 0 && updatedAtSkewMs > 0) continue;
563
+ const after = (mark) => new Date(Date.parse(mark) + (overlap === 0 ? 1 : -1) - updatedAtSkewMs).toISOString();
564
+ let total = await fetchTotal(after(passHighWater));
565
+ if (overlap === 1 && total === 0 && remark) {
566
+ const remarked = await remark();
567
+ if (remarked) {
568
+ passHighWater = remarked;
569
+ total = await fetchTotal(after(passHighWater));
570
+ }
571
+ }
572
+ if (overlap === 1 && total === 0) {
573
+ if (remark) throw new VendureTimezoneConfigError();
574
+ throw new Error(TIMEZONE_MESSAGE);
575
+ }
576
+ if (overlap === 0 && total > 0) {
577
+ console.warn("Vendure updatedAt filters over-fetch because the server is not in UTC.");
578
+ }
579
+ }
580
+ return passHighWater;
581
+ }
582
+ function toProductDocument(p) {
583
+ const doc = { _deleted: false };
584
+ for (const field of Object.keys(vendureProductSchema.properties)) {
585
+ if (p[field] !== void 0) doc[field] = p[field];
586
+ }
587
+ if (Array.isArray(doc.variants)) {
588
+ doc.variants = [...doc.variants].sort((a, b) => compareIds(a.id, b.id));
589
+ }
590
+ return doc;
591
+ }
592
+ var createVendureProductReplication = (barcodeField, updatedAtSkewMs = 0) => ({
325
593
  pull: {
326
594
  async handler(lastCheckpoint, batchSize, context) {
595
+ if (batchSize > 1e3) throw new Error("Vendure Admin API take must not exceed 1000");
596
+ if (lastCheckpoint?.skip && lastCheckpoint.passHighWater === void 0 && lastCheckpoint.passTotal === void 0) {
597
+ lastCheckpoint = { skip: 0, updatedAt: lastCheckpoint.updatedAt ?? "" };
598
+ }
599
+ let passHighWater = lastCheckpoint?.passHighWater ?? lastCheckpoint?.updatedAt ?? "";
600
+ if (!lastCheckpoint?.skip) {
601
+ const fetchMark = async () => {
602
+ const head = await gql2(context, PRODUCT_MARK_QUERY, { options: { take: 1, sort: { updatedAt: "DESC" } } });
603
+ return head.data?.products?.items?.[0]?.updatedAt;
604
+ };
605
+ const mark = await fetchMark();
606
+ passHighWater = mark ?? lastCheckpoint?.updatedAt ?? "";
607
+ if (mark === void 0 || lastCheckpoint?.updatedAt && passHighWater === lastCheckpoint.updatedAt) {
608
+ return { documents: [], checkpoint: lastCheckpoint ?? { skip: 0, updatedAt: "" } };
609
+ }
610
+ passHighWater = await probeUpdatedAtSkew(passHighWater, updatedAtSkewMs, async (after) => {
611
+ const probe = await gql2(context, PRODUCT_MARK_QUERY, {
612
+ options: { take: 1, sort: { updatedAt: "DESC" }, filter: { updatedAt: { after } } }
613
+ });
614
+ return probe.data.products.totalItems;
615
+ }, fetchMark);
616
+ }
327
617
  const options = {
328
618
  take: batchSize,
329
- skip: lastCheckpoint?.skip ?? 0
619
+ skip: lastCheckpoint?.skip ?? 0,
620
+ sort: { id: "ASC" }
330
621
  };
331
622
  if (lastCheckpoint?.updatedAt) {
332
623
  options.filter = {
333
- updatedAt: { after: lastCheckpoint.updatedAt }
624
+ // Vendure after is strict; overlap by 1 ms to include timestamp ties.
625
+ updatedAt: { after: new Date(Date.parse(lastCheckpoint.updatedAt) - 1 - updatedAtSkewMs).toISOString() }
334
626
  };
335
627
  }
336
- const res = await gql2(context, PRODUCT_LIST_QUERY2, { options });
337
- if (res.errors?.length) {
338
- throw new Error(`Vendure GraphQL error: ${res.errors[0].message}`);
628
+ const res = await gql2(context, PRODUCT_LIST_QUERY2(barcodeField), { options });
629
+ let data = res.data?.products;
630
+ let passTotal = options.skip === 0 ? data.totalItems : lastCheckpoint?.passTotal ?? data.totalItems;
631
+ if (options.skip > 0 && data.totalItems < passTotal) {
632
+ options.skip = 0;
633
+ const restarted = await gql2(context, PRODUCT_LIST_QUERY2(barcodeField), { options });
634
+ data = restarted.data?.products;
635
+ passTotal = data.totalItems;
339
636
  }
340
- const data = res.data?.products;
341
637
  const products = data?.items ?? [];
342
- const documents = products.map((p) => ({ ...p, _deleted: false }));
343
- const nextSkip = products.length < batchSize ? 0 : (lastCheckpoint?.skip ?? 0) + products.length;
344
- const checkpoint = products.length > 0 ? {
345
- skip: nextSkip,
346
- updatedAt: products[products.length - 1].updatedAt
347
- } : lastCheckpoint ?? { skip: 0, updatedAt: "" };
638
+ if (!products.length && options.skip > 0) {
639
+ return createVendureProductReplication(barcodeField, updatedAtSkewMs).pull.handler(
640
+ { skip: 0, updatedAt: lastCheckpoint?.updatedAt ?? "" },
641
+ batchSize,
642
+ context
643
+ );
644
+ }
645
+ const documents = products.map(toProductDocument);
646
+ const checkpoint = options.skip + products.length >= data.totalItems ? { skip: 0, updatedAt: passHighWater, passHighWater: void 0, passTotal: void 0 } : {
647
+ skip: options.skip + products.length,
648
+ updatedAt: lastCheckpoint?.updatedAt ?? "",
649
+ passHighWater,
650
+ passTotal
651
+ };
348
652
  return { documents, checkpoint };
349
653
  }
350
- },
351
- push: {
352
- async handler(changeRows, context) {
353
- const conflicts = [];
354
- for (const row of changeRows) {
355
- const doc = row.newDocumentState;
356
- try {
357
- const res = await gql2(context, UPDATE_PRODUCT_MUTATION, {
358
- input: doc
654
+ }
655
+ });
656
+ var vendureProductReplication = createVendureProductReplication();
657
+
658
+ // src/replication/variant-feed.ts
659
+ var MAX_TAKE = 1e3;
660
+ var variants = (context, fields, options) => gql2(context, `
661
+ query GetVariants($options: ProductVariantListOptions) {
662
+ productVariants(options: $options) { items { ${fields} } totalItems }
663
+ }
664
+ `, { options }).then((res) => res.data.productVariants);
665
+ var newest = (context) => variants(context, "id updatedAt", { take: 1, sort: { updatedAt: "DESC" } });
666
+ var createVendureVariantFeedReplication = (barcodeField, updatedAtSkewMs = 0, variantPageSize = MAX_TAKE) => ({
667
+ pull: {
668
+ // The next call's unchanged-high-water check then ends at one read, unless
669
+ // a variant changed since. The skew probe runs when a pass starts from it.
670
+ async seedCheckpoint(context) {
671
+ const head = await newest(context);
672
+ return { skip: 0, updatedAt: head.items[0]?.updatedAt ?? "", passHighWater: void 0, passTotal: void 0 };
673
+ },
674
+ async handler(lastCheckpoint, batchSize, context) {
675
+ if (batchSize > MAX_TAKE || variantPageSize > MAX_TAKE) throw new Error("Vendure Admin API take must not exceed 1000");
676
+ let passHighWater = lastCheckpoint?.passHighWater ?? lastCheckpoint?.updatedAt ?? "";
677
+ let skip = lastCheckpoint?.skip ?? 0;
678
+ let passTotal = lastCheckpoint?.passTotal;
679
+ if (!skip) {
680
+ const head = await newest(context);
681
+ passHighWater = head.items[0]?.updatedAt ?? lastCheckpoint?.updatedAt ?? "";
682
+ if (!head.items.length || lastCheckpoint?.updatedAt && passHighWater === lastCheckpoint.updatedAt) {
683
+ return { documents: [], checkpoint: lastCheckpoint ?? { skip: 0, updatedAt: "" } };
684
+ }
685
+ await probeUpdatedAtSkew(passHighWater, updatedAtSkewMs, async (after) => {
686
+ const probe = await variants(context, "id updatedAt", { take: 1, sort: { updatedAt: "DESC" }, filter: { updatedAt: { after } } });
687
+ return probe.totalItems;
688
+ });
689
+ }
690
+ const filter = lastCheckpoint?.updatedAt ? { updatedAt: { after: new Date(Date.parse(lastCheckpoint.updatedAt) - 1 - updatedAtSkewMs).toISOString() } } : void 0;
691
+ const page = (from) => variants(context, "id productId", { take: variantPageSize, skip: from, sort: { id: "ASC" }, filter });
692
+ const seen = /* @__PURE__ */ new Set();
693
+ const documents = [];
694
+ let complete = false;
695
+ while (!complete && documents.length < batchSize) {
696
+ let data = await page(skip);
697
+ if (skip > 0 && (!data.items.length || data.totalItems < (passTotal ?? data.totalItems))) {
698
+ skip = 0;
699
+ data = await page(0);
700
+ }
701
+ if (skip === 0) passTotal = data.totalItems;
702
+ const parentIds = [...new Set(data.items.map((v) => String(v.productId)))].filter((id) => !seen.has(id));
703
+ parentIds.forEach((id) => seen.add(id));
704
+ if (parentIds.length) {
705
+ const res = await gql2(context, PRODUCT_LIST_QUERY2(barcodeField), {
706
+ options: { take: parentIds.length, filter: { id: { in: parentIds } } }
359
707
  });
360
- if (res.errors?.length) {
361
- if (row.assumedMasterState) {
362
- conflicts.push({ ...row.assumedMasterState, _deleted: false });
363
- }
364
- }
365
- } catch {
366
- if (row.assumedMasterState) {
367
- conflicts.push({ ...row.assumedMasterState, _deleted: false });
368
- }
708
+ documents.push(...(res.data?.products?.items ?? []).map(toProductDocument));
369
709
  }
710
+ skip += data.items.length;
711
+ complete = skip >= data.totalItems || !data.items.length;
370
712
  }
371
- return conflicts;
713
+ const checkpoint = complete ? { skip: 0, updatedAt: passHighWater, passHighWater: void 0, passTotal: void 0 } : { skip, updatedAt: lastCheckpoint?.updatedAt ?? "", passHighWater, passTotal };
714
+ const moved = ["skip", "updatedAt", "passHighWater", "passTotal"].some((key) => checkpoint[key] !== lastCheckpoint?.[key]);
715
+ if (!documents.length && moved) {
716
+ const res = await gql2(context, PRODUCT_LIST_QUERY2(barcodeField), { options: { take: 1 } });
717
+ documents.push(...(res.data?.products?.items ?? []).map(toProductDocument));
718
+ }
719
+ return { documents, checkpoint };
372
720
  }
373
721
  }
374
- };
722
+ });
375
723
 
376
- // src/index.ts
377
- var vendureConnector = {
378
- id: "vendure",
379
- name: "Vendure",
380
- description: "Connect to Vendure backends via the Admin GraphQL API",
381
- icon: void 0,
382
- auth: {
383
- type: "Vendure Admin API",
384
- fields: [
385
- {
386
- key: "url",
387
- label: "Backend URL",
388
- type: "url",
389
- placeholder: "https://my-vendure-server.com",
390
- required: true
391
- },
392
- {
393
- key: "auth_token",
394
- label: "Auth Token",
395
- type: "password",
396
- placeholder: "vendure-auth-token from login",
397
- required: true
398
- }
399
- ],
400
- getHeaders: (credentials) => ({
401
- Authorization: `Bearer ${credentials.auth_token}`
402
- })
403
- },
404
- schemas: {
405
- products: vendureProductSchema
406
- },
407
- traits: {
408
- product: vendureProductTraits
409
- },
410
- sync: {
411
- products: vendureProductSync
724
+ // src/reconcile/stock.ts
725
+ var PAGE_SIZE = 1e3;
726
+ var VARIANT_STOCK_QUERY = `
727
+ query VariantStock($options: ProductVariantListOptions) {
728
+ productVariants(options: $options) {
729
+ items { id stockLevels { stockLocationId stockOnHand stockAllocated } }
730
+ totalItems
731
+ }
732
+ }
733
+ `;
734
+ var levelsKey = (levels = []) => levels.map((l) => JSON.stringify([l.stockLocationId, l.stockOnHand, l.stockAllocated])).sort().join();
735
+ var vendureStockReconcile = {
736
+ async *fetchPages(context) {
737
+ for (let skip = 0, total = 1; skip < total; skip += PAGE_SIZE) {
738
+ const body = await gql2(context, VARIANT_STOCK_QUERY, { options: { take: PAGE_SIZE, skip, sort: { id: "ASC" } } });
739
+ const { items, totalItems } = body.data.productVariants;
740
+ total = totalItems;
741
+ yield new Map(items.map((v) => [v.id, v.stockLevels]));
742
+ }
412
743
  },
413
- replication: {
414
- products: vendureProductReplication
744
+ overlay(doc, stock) {
745
+ const variants2 = doc.variants ?? [];
746
+ let changed = false;
747
+ const next = variants2.map((v) => {
748
+ const levels = stock.get(v.id);
749
+ if (!levels || levelsKey(levels) === levelsKey(v.stockLevels)) return v;
750
+ changed = true;
751
+ return { ...v, stockLevels: levels };
752
+ });
753
+ return changed ? { variants: next } : void 0;
415
754
  }
416
755
  };
756
+
757
+ // src/reconcile/ids.ts
758
+ var PAGE_SIZE2 = 1e3;
759
+ var ID_LIST_QUERY = `
760
+ query GetProductIds($options: ProductListOptions) {
761
+ products(options: $options) { items { id variants { id } } totalItems }
762
+ }
763
+ `;
764
+ async function* fetchPages(context) {
765
+ for (let skip = 0, total = 1; skip < total; skip += PAGE_SIZE2) {
766
+ const res = await gql2(context, ID_LIST_QUERY, { options: { take: PAGE_SIZE2, skip, sort: { id: "ASC" } } });
767
+ const { items, totalItems } = res.data.products;
768
+ total = totalItems;
769
+ yield items.map((p) => ({ id: String(p.id), variantIds: (p.variants ?? []).map((v) => String(v.id)) }));
770
+ }
771
+ }
772
+ function variantIds(doc) {
773
+ return (doc.variants ?? []).map((v) => String(v.id));
774
+ }
775
+ function createFetchByIds(barcodeField) {
776
+ return async function fetchByIds(ids, context) {
777
+ if (!ids.length) return [];
778
+ const res = await gql2(context, PRODUCT_LIST_QUERY2(barcodeField), { options: { take: ids.length, filter: { id: { in: ids } } } });
779
+ return (res.data?.products?.items ?? []).map(toProductDocument);
780
+ };
781
+ }
782
+
783
+ // src/reconcile/prices.ts
784
+ var PAGE_SIZE3 = 1e3;
785
+ var VENDURE_PRICE_RECONCILE_INTERVAL_MS = 24 * 60 * 60 * 1e3;
786
+ var PRICE_LIST_QUERY = `
787
+ query GetVariantPrices($options: ProductVariantListOptions) {
788
+ productVariants(options: $options) { items { id productId price priceWithTax currencyCode } totalItems }
789
+ }
790
+ `;
791
+ var fingerprintOf = (entries) => [...entries].sort().join("|");
792
+ async function* fetchPages2(context) {
793
+ const byProduct = /* @__PURE__ */ new Map();
794
+ for (let skip = 0, total = 1; skip < total; skip += PAGE_SIZE3) {
795
+ const res = await gql2(context, PRICE_LIST_QUERY, { options: { take: PAGE_SIZE3, skip, sort: { id: "ASC" } } });
796
+ const { items, totalItems } = res.data.productVariants;
797
+ total = totalItems;
798
+ const touched = /* @__PURE__ */ new Set();
799
+ for (const variant of items) {
800
+ const productId = String(variant.productId);
801
+ touched.add(productId);
802
+ const entries = byProduct.get(productId) ?? [];
803
+ byProduct.set(productId, entries);
804
+ entries.push(`${variant.id}:${variant.currencyCode}:${variant.price}:${variant.priceWithTax}`);
805
+ }
806
+ yield new Map([...touched].map((id) => [id, fingerprintOf(byProduct.get(id) ?? [])]));
807
+ }
808
+ }
809
+ function fingerprint(doc) {
810
+ const entries = (doc.variants ?? []).map(
811
+ (v) => `${v.id}:${v.currencyCode}:${v.price}:${v.priceWithTax}`
812
+ );
813
+ return fingerprintOf(entries);
814
+ }
815
+
816
+ // src/store-settings.ts
817
+ import { StoreSettingsError } from "@tallyui/core";
818
+ var CHANNEL_AND_CATEGORIES_QUERY = `
819
+ query StoreSettingsChannel {
820
+ activeChannel { defaultCurrencyCode pricesIncludeTax defaultTaxZone { id } }
821
+ taxCategories { items { id isDefault } }
822
+ }
823
+ `;
824
+ var TAX_RATES_QUERY = `
825
+ query StoreSettingsTaxRates($zoneId: String!) {
826
+ taxRates(options: { take: 1000, filter: { zoneId: { eq: $zoneId } } }) {
827
+ items { enabled value category { id } customerGroup { id } }
828
+ }
829
+ }
830
+ `;
831
+ async function query(context, source, variables) {
832
+ try {
833
+ return await gql2(context, source, variables);
834
+ } catch (error) {
835
+ throw new StoreSettingsError("failed", error instanceof Error ? error.message : String(error));
836
+ }
837
+ }
838
+ var vendureStoreSettings = async (context, _choice) => {
839
+ const head = await query(context, CHANNEL_AND_CATEGORIES_QUERY);
840
+ const channel = head.data?.activeChannel;
841
+ if (!channel) throw new StoreSettingsError("failed", "Vendure returned no active channel");
842
+ const categories = head.data?.taxCategories?.items ?? [];
843
+ let rateItems = [];
844
+ if (channel.defaultTaxZone) {
845
+ const ratesBody = await query(context, TAX_RATES_QUERY, { zoneId: channel.defaultTaxZone.id });
846
+ rateItems = ratesBody.data?.taxRates?.items ?? [];
847
+ }
848
+ const byCategory = /* @__PURE__ */ new Map();
849
+ for (const rate of rateItems) {
850
+ if (!rate.enabled || rate.customerGroup || !rate.category) continue;
851
+ byCategory.set(rate.category.id, Math.round(rate.value * 1e4));
852
+ }
853
+ const defaultCategory = categories.find((c) => c.isDefault) ?? categories[0];
854
+ const taxRatesPpm = {
855
+ default: defaultCategory ? byCategory.get(defaultCategory.id) ?? 0 : 0
856
+ };
857
+ for (const [categoryId, ppm] of byCategory) taxRatesPpm[categoryId] = ppm;
858
+ for (const category of categories) taxRatesPpm[category.id] ??= 0;
859
+ return {
860
+ currency: channel.defaultCurrencyCode,
861
+ pricesIncludeTax: channel.pricesIncludeTax,
862
+ taxRatesPpm
863
+ };
864
+ };
865
+
866
+ // src/global-settings.ts
867
+ var GLOBAL_SETTINGS_QUERY = `
868
+ query GlobalStockSettings {
869
+ globalSettings { trackInventory outOfStockThreshold }
870
+ }
871
+ `;
872
+ async function vendureGlobalStockSettings(context) {
873
+ const res = await gql2(context, GLOBAL_SETTINGS_QUERY);
874
+ return res.data.globalSettings;
875
+ }
876
+
877
+ // src/index.ts
878
+ import { ConnectorUnauthorizedError as ConnectorUnauthorizedError5 } from "@tallyui/core";
879
+ var VENDURE_IDS_PER_REQUEST = 1e3;
880
+ var createVendureConnector = (options = {}) => {
881
+ const idFeed = createReconcileFeed({ fetchByIds: createFetchByIds(options.barcodeField) });
882
+ return {
883
+ id: "vendure",
884
+ name: "Vendure",
885
+ description: "Connect to Vendure backends via the Admin GraphQL API",
886
+ icon: void 0,
887
+ auth: vendureAuth,
888
+ schemas: {
889
+ products: vendureProductSchema
890
+ },
891
+ traits: {
892
+ product: createVendureProductTraits(
893
+ options.barcodeField,
894
+ options.stockLocationId,
895
+ options.pricesIncludeTax,
896
+ options.globalTrackInventory,
897
+ options.globalOutOfStockThreshold
898
+ )
899
+ },
900
+ sync: {
901
+ products: createVendureProductSync(options.barcodeField)
902
+ },
903
+ replication: {
904
+ // One replication per collection: the product, variant and id-reconcile feeds
905
+ // share it (ADR-060). reconcile is last so its fetch wins duplicates.
906
+ products: combinePullAdapters({
907
+ products: createVendureProductReplication(options.barcodeField, options.updatedAtSkewMs),
908
+ variants: createVendureVariantFeedReplication(options.barcodeField, options.updatedAtSkewMs),
909
+ reconcile: idFeed.adapter
910
+ }, { legacyKey: "products" })
911
+ },
912
+ reconcile: {
913
+ stock: vendureStockReconcile,
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 }
916
+ },
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 })
920
+ };
921
+ };
922
+ var vendureConnector = createVendureConnector();
417
923
  export {
924
+ ConnectorUnauthorizedError5 as ConnectorUnauthorizedError,
925
+ VendureTimezoneConfigError,
926
+ createVendureConnector,
927
+ createVendureProductReplication,
928
+ createVendureVariantFeedReplication,
929
+ vendureAuth,
418
930
  vendureConnector,
931
+ vendureGlobalStockSettings,
419
932
  vendureProductReplication,
420
933
  vendureProductSchema,
421
934
  vendureProductSync,
422
- vendureProductTraits
935
+ vendureProductTraits,
936
+ vendureSignIn,
937
+ vendureStockReconcile,
938
+ vendureStoreSettings
423
939
  };