@tallyui/connector-vendure 1.0.0 → 2.0.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,79 @@
1
+ // src/index.ts
2
+ import { combinePullAdapters, createReconcileFeed } from "@tallyui/core";
3
+
4
+ // src/auth.ts
5
+ import { SignInError } from "@tallyui/core";
6
+ var LOGIN = `mutation Login($email: String!, $password: String!) {
7
+ login(username: $email, password: $password) {
8
+ __typename
9
+ ... on CurrentUser { id }
10
+ ... on ErrorResult { errorCode message }
11
+ }
12
+ }`;
13
+ var vendureSignIn = async (baseUrl, { email, password }, init = {}) => {
14
+ const doFetch = init.fetch ?? fetch;
15
+ let res;
16
+ try {
17
+ res = await doFetch(`${baseUrl}/admin-api`, {
18
+ method: "POST",
19
+ headers: { "Content-Type": "application/json" },
20
+ signal: init.signal,
21
+ body: JSON.stringify({ query: LOGIN, variables: { email, password } })
22
+ });
23
+ } catch (error) {
24
+ if (error?.name === "AbortError" || init.signal?.aborted) throw error;
25
+ throw new SignInError("failed", `Could not reach Vendure at ${baseUrl}: ${error instanceof Error ? error.message : String(error)}`);
26
+ }
27
+ let body;
28
+ try {
29
+ body = await res.json();
30
+ } catch {
31
+ throw new SignInError("server_error", `Vendure sent an unreadable response (HTTP ${res.status})`, res.status);
32
+ }
33
+ if (!res.ok) throw new SignInError("server_error", body?.errors?.[0]?.message ?? `Vendure sign-in failed (HTTP ${res.status})`, res.status);
34
+ const login = body?.data?.login;
35
+ if (body?.errors?.length || !login) {
36
+ throw new SignInError("server_error", body?.errors?.[0]?.message ?? `Vendure sign-in failed (HTTP ${res.status})`, res.status);
37
+ }
38
+ if (login.errorCode === "INVALID_CREDENTIALS_ERROR") throw new SignInError("invalid_credentials", login.message ?? "Invalid email or password");
39
+ 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");
40
+ if (login.__typename !== "CurrentUser") throw new SignInError("server_error", login.message ?? `Vendure sign-in failed (${login.__typename})`, res.status);
41
+ const token = res.headers.get("vendure-auth-token");
42
+ if (!token) {
43
+ 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
+ }
45
+ return { token, expiresAt: void 0 };
46
+ };
47
+ var warnedAuthToken = false;
48
+ var vendureAuth = {
49
+ type: "Vendure Admin API",
50
+ fields: [
51
+ { key: "url", label: "Backend URL", type: "url", placeholder: "https://my-vendure-server.com", required: true },
52
+ { key: "email", label: "Email", type: "text", required: true },
53
+ { key: "password", label: "Password", type: "password", required: true },
54
+ { key: "channel_token", label: "Channel token (optional)", type: "text" }
55
+ ],
56
+ // An API key (Vendure 3.6+) wins over a signed-in session token; channel_token selects a non-default channel.
57
+ // auth_token is the deprecated name for token, kept so existing apps keep working.
58
+ getHeaders: (credentials) => {
59
+ if (credentials.token === void 0 && credentials.auth_token !== void 0 && !warnedAuthToken) {
60
+ warnedAuthToken = true;
61
+ console.warn("@tallyui/connector-vendure: the 'auth_token' credential is deprecated; store the credential as 'token' instead.");
62
+ }
63
+ const token = credentials.token ?? credentials.auth_token;
64
+ return {
65
+ ...credentials.api_key ? { "vendure-api-key": credentials.api_key } : token ? { Authorization: `Bearer ${token}` } : {},
66
+ ...credentials.channel_token ? { "vendure-token": credentials.channel_token } : {}
67
+ };
68
+ },
69
+ signIn: vendureSignIn
70
+ };
71
+
1
72
  // src/schemas/products.ts
2
73
  var vendureProductSchema = {
3
- version: 0,
74
+ // 1: declares variant trackInventory/outOfStockThreshold/useGlobalOutOfStockThreshold/enabled
75
+ // (backlog 28). A bump drops and resyncs (ConnectorSchemas, ADR-060 amendment 9).
76
+ version: 1,
4
77
  primaryKey: "id",
5
78
  type: "object",
6
79
  properties: {
@@ -68,7 +141,21 @@ var vendureProductSchema = {
68
141
  currencyCode: { type: "string" },
69
142
  stockLevel: { type: "string" },
70
143
  stockOnHand: { type: "number" },
144
+ stockLevels: {
145
+ type: "array",
146
+ items: {
147
+ type: "object",
148
+ properties: {
149
+ stockLocationId: { type: "string" },
150
+ stockOnHand: { type: "number" },
151
+ stockAllocated: { type: "number" }
152
+ }
153
+ }
154
+ },
71
155
  trackInventory: { type: "string" },
156
+ outOfStockThreshold: { type: "number" },
157
+ useGlobalOutOfStockThreshold: { type: "boolean" },
158
+ enabled: { type: "boolean" },
72
159
  featuredAsset: {
73
160
  type: ["object", "null"],
74
161
  properties: {
@@ -98,53 +185,100 @@ var vendureProductSchema = {
98
185
  };
99
186
 
100
187
  // 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
- };
188
+ function productStock(variants2) {
189
+ if (!variants2.length) return { status: "unknown" };
190
+ 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";
191
+ const quantity = variants2.every((stock) => stock.quantity != null) ? variants2.reduce((sum, stock) => sum + stock.quantity, 0) : void 0;
192
+ return quantity === void 0 ? { status } : { status, quantity };
193
+ }
194
+ function variantStock(variant, stockLocationId, globalTrackInventory, globalOutOfStockThreshold) {
195
+ if (!variant) return { status: "unknown" };
196
+ const tracked = variant.trackInventory === "TRUE" || variant.trackInventory !== "FALSE" && globalTrackInventory;
197
+ if (!tracked) return { status: "in_stock" };
198
+ const threshold = variant.useGlobalOutOfStockThreshold !== false ? globalOutOfStockThreshold : variant.outOfStockThreshold ?? 0;
199
+ let onHandLessAllocated;
200
+ if (variant.stockLevels != null) {
201
+ onHandLessAllocated = variant.stockLevels.filter((level) => stockLocationId == null || String(level.stockLocationId) === stockLocationId).reduce((sum, level) => sum + level.stockOnHand - level.stockAllocated, 0);
202
+ } else if (variant.stockOnHand != null) {
203
+ onHandLessAllocated = variant.stockOnHand;
204
+ }
205
+ if (onHandLessAllocated != null) {
206
+ const saleable = onHandLessAllocated - threshold;
207
+ const status = saleable <= 0 ? "out_of_stock" : onHandLessAllocated > 0 ? "in_stock" : "backorder";
208
+ return { status, quantity: Math.max(saleable, 0) };
209
+ }
210
+ if (variant.stockLevel === "IN_STOCK" || variant.stockLevel === "LOW_STOCK") return { status: "in_stock" };
211
+ if (variant.stockLevel === "OUT_OF_STOCK") return { status: "out_of_stock" };
212
+ return { status: "unknown" };
213
+ }
214
+ function liveVariants(doc) {
215
+ return (doc.variants ?? []).filter((variant) => variant.enabled !== false);
216
+ }
217
+ function createVendureProductTraits(barcodeField, stockLocationId, pricesIncludeTax = false, globalTrackInventory = true, globalOutOfStockThreshold = 0) {
218
+ const priceField = pricesIncludeTax ? "priceWithTax" : "price";
219
+ const traits = {
220
+ getId: (doc) => String(doc.id),
221
+ getName: (doc) => doc.name ?? "",
222
+ getSku: (doc) => doc.variants?.[0]?.sku || void 0,
223
+ getPrices: (doc, context) => {
224
+ const variant = liveVariants(doc)[0];
225
+ const amount = variant?.[priceField];
226
+ if (amount == null) return [];
227
+ const currency = String(variant.currencyCode ?? context?.currency ?? "XXX").toUpperCase();
228
+ return [{ amount, currency, kind: "base" }];
229
+ },
230
+ getVariants: (doc, context) => liveVariants(doc).map((variant) => ({
231
+ id: String(variant.id),
232
+ title: variant.name || void 0,
233
+ sku: variant.sku || void 0,
234
+ barcode: barcodeField ? variant.customFields?.[barcodeField] || void 0 : void 0,
235
+ prices: traits.getPrices({ variants: [variant] }, context),
236
+ stock: traits.getStock({ variants: [variant] })
237
+ })),
238
+ getStock: (doc) => productStock(liveVariants(doc).map((variant) => variantStock(variant, stockLocationId, globalTrackInventory, globalOutOfStockThreshold))),
239
+ getPrice: (doc) => {
240
+ const amount = liveVariants(doc)[0]?.[priceField];
241
+ if (amount == null) return void 0;
242
+ return (amount / 100).toFixed(2);
243
+ },
244
+ getRegularPrice: (doc) => {
245
+ const amount = liveVariants(doc)[0]?.[priceField];
246
+ if (amount == null) return void 0;
247
+ return (amount / 100).toFixed(2);
248
+ },
249
+ getSalePrice: () => {
250
+ return void 0;
251
+ },
252
+ isOnSale: () => {
253
+ return false;
254
+ },
255
+ getImageUrl: (doc) => doc.featuredAsset?.preview || doc.assets?.[0]?.preview || void 0,
256
+ getImageUrls: (doc) => (doc.assets ?? []).map((a) => a.preview).filter(Boolean),
257
+ getDescription: (doc) => doc.description || void 0,
258
+ // Aggregated across every variant, like getStock (backlog 28), not variant 0.
259
+ getStockStatus: (doc) => {
260
+ const status = traits.getStock(doc).status;
261
+ if (status === "in_stock" || status === "backorder") return "instock";
262
+ if (status === "out_of_stock") return "outofstock";
263
+ return "unknown";
264
+ },
265
+ getStockQuantity: (doc) => traits.getStock(doc).quantity ?? null,
266
+ hasVariants: (doc) => (doc.variants?.length ?? 0) > 1,
267
+ // A product with no variants at all keeps the product-level-only answer.
268
+ isSellable: (doc) => doc.enabled !== false && ((doc.variants?.length ?? 0) === 0 || liveVariants(doc).length > 0),
269
+ getVariantCount: (doc) => doc.variants ? liveVariants(doc).length : 1,
270
+ getType: (doc) => {
271
+ if ((doc.variants?.length ?? 0) > 1) return "variable";
272
+ return "simple";
273
+ },
274
+ getBarcode: (doc) => {
275
+ return barcodeField ? doc.variants?.[0]?.customFields?.[barcodeField] || void 0 : void 0;
276
+ },
277
+ getCategoryNames: (doc) => (doc.collections ?? []).map((c) => c.name).filter(Boolean)
278
+ };
279
+ return traits;
280
+ }
281
+ var vendureProductTraits = createVendureProductTraits();
148
282
 
149
283
  // src/sync/products.ts
150
284
  var PRODUCT_LIST_QUERY = `
@@ -158,7 +292,7 @@ var PRODUCT_LIST_QUERY = `
158
292
  }
159
293
  }
160
294
  `;
161
- var PRODUCT_DETAIL_QUERY = `
295
+ var PRODUCT_DETAIL_QUERY = (barcodeField) => `
162
296
  query GetProduct($id: ID!) {
163
297
  product(id: $id) {
164
298
  id
@@ -179,17 +313,16 @@ var PRODUCT_DETAIL_QUERY = `
179
313
  price
180
314
  priceWithTax
181
315
  currencyCode
182
- stockLevel
183
- stockOnHand
316
+ stockLevels { stockLocationId stockOnHand stockAllocated }
184
317
  trackInventory
185
318
  featuredAsset { id preview }
186
319
  options { id name code }
187
- customFields
320
+ ${barcodeField ? `customFields { ${barcodeField} }` : ""}
188
321
  }
189
322
  }
190
323
  }
191
324
  `;
192
- var vendureProductSync = {
325
+ var createVendureProductSync = (barcodeField) => ({
193
326
  fetchAllIds: async (context) => {
194
327
  const entries = [];
195
328
  const take = 100;
@@ -215,7 +348,7 @@ var vendureProductSync = {
215
348
  fetchByIds: async (ids, context) => {
216
349
  const products = [];
217
350
  for (const id of ids) {
218
- const res = await gql(context, PRODUCT_DETAIL_QUERY, { id });
351
+ const res = await gql(context, PRODUCT_DETAIL_QUERY(barcodeField), { id });
219
352
  if (res.data?.product) {
220
353
  products.push(res.data.product);
221
354
  }
@@ -241,7 +374,7 @@ var vendureProductSync = {
241
374
  if (!data) break;
242
375
  totalItems = data.totalItems;
243
376
  for (const item of data.items ?? []) {
244
- const detail = await gql(context, PRODUCT_DETAIL_QUERY, { id: item.id });
377
+ const detail = await gql(context, PRODUCT_DETAIL_QUERY(barcodeField), { id: item.id });
245
378
  if (detail.data?.product) {
246
379
  products.push(detail.data.product);
247
380
  }
@@ -250,15 +383,16 @@ var vendureProductSync = {
250
383
  }
251
384
  return products;
252
385
  }
253
- };
254
- async function gql(context, query, variables) {
386
+ });
387
+ var vendureProductSync = createVendureProductSync();
388
+ async function gql(context, query2, variables) {
255
389
  const res = await fetch(`${context.baseUrl}/admin-api`, {
256
390
  method: "POST",
257
391
  headers: {
258
392
  "Content-Type": "application/json",
259
393
  ...context.headers
260
394
  },
261
- body: JSON.stringify({ query, variables }),
395
+ body: JSON.stringify({ query: query2, variables }),
262
396
  signal: context.signal
263
397
  });
264
398
  if (!res.ok) throw new Error(`Vendure API error: ${res.status}`);
@@ -266,7 +400,8 @@ async function gql(context, query, variables) {
266
400
  }
267
401
 
268
402
  // src/replication/products.ts
269
- var PRODUCT_LIST_QUERY2 = `
403
+ import { compareIds } from "@tallyui/core";
404
+ var PRODUCT_LIST_QUERY2 = (barcodeField) => `
270
405
  query GetProducts($options: ProductListOptions) {
271
406
  products(options: $options) {
272
407
  items {
@@ -288,136 +423,416 @@ var PRODUCT_LIST_QUERY2 = `
288
423
  price
289
424
  priceWithTax
290
425
  currencyCode
291
- stockLevel
292
- stockOnHand
426
+ stockLevels { stockLocationId stockOnHand stockAllocated }
293
427
  trackInventory
428
+ outOfStockThreshold
429
+ useGlobalOutOfStockThreshold
430
+ enabled
294
431
  featuredAsset { id preview }
295
432
  options { id name code }
296
- customFields
433
+ ${barcodeField ? `customFields { ${barcodeField} }` : ""}
297
434
  }
298
435
  }
299
436
  totalItems
300
437
  }
301
438
  }
302
439
  `;
303
- var UPDATE_PRODUCT_MUTATION = `
304
- mutation UpdateProduct($input: UpdateProductInput!) {
305
- updateProduct(input: $input) {
306
- id
307
- updatedAt
440
+ var PRODUCT_MARK_QUERY = `
441
+ query GetProductsMark($options: ProductListOptions) {
442
+ products(options: $options) {
443
+ items { id updatedAt }
444
+ totalItems
308
445
  }
309
446
  }
310
447
  `;
311
- async function gql2(context, query, variables) {
448
+ async function gql2(context, query2, variables) {
312
449
  const res = await fetch(`${context.baseUrl}/admin-api`, {
313
450
  method: "POST",
314
451
  headers: {
315
452
  "Content-Type": "application/json",
316
453
  ...context.headers
317
454
  },
318
- body: JSON.stringify({ query, variables }),
455
+ body: JSON.stringify({ query: query2, variables }),
319
456
  signal: context.signal
320
457
  });
321
- if (!res.ok) throw new Error(`Vendure API error: ${res.status}`);
322
- return res.json();
458
+ if (!res.ok) {
459
+ const body2 = await res.json().catch(() => void 0);
460
+ const message = body2?.errors?.[0]?.message;
461
+ throw new Error(`Vendure API error: ${res.status}${message ? `: ${message}` : ""}`);
462
+ }
463
+ const body = await res.json();
464
+ if (body.errors?.length) throw new Error(`Vendure GraphQL error: ${body.errors[0].message}`);
465
+ return body;
466
+ }
467
+ async function probeUpdatedAtSkew(passHighWater, updatedAtSkewMs, fetchTotal, remark) {
468
+ for (const overlap of [1, 0]) {
469
+ if (overlap === 0 && updatedAtSkewMs > 0) continue;
470
+ const after = (mark) => new Date(Date.parse(mark) + (overlap === 0 ? 1 : -1) - updatedAtSkewMs).toISOString();
471
+ let total = await fetchTotal(after(passHighWater));
472
+ if (overlap === 1 && total === 0 && remark) {
473
+ const remarked = await remark();
474
+ if (remarked) {
475
+ passHighWater = remarked;
476
+ total = await fetchTotal(after(passHighWater));
477
+ }
478
+ }
479
+ 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.");
481
+ }
482
+ if (overlap === 0 && total > 0) {
483
+ console.warn("Vendure updatedAt filters over-fetch because the server is not in UTC.");
484
+ }
485
+ }
486
+ return passHighWater;
487
+ }
488
+ function toProductDocument(p) {
489
+ const doc = { _deleted: false };
490
+ for (const field of Object.keys(vendureProductSchema.properties)) {
491
+ if (p[field] !== void 0) doc[field] = p[field];
492
+ }
493
+ if (Array.isArray(doc.variants)) {
494
+ doc.variants = [...doc.variants].sort((a, b) => compareIds(a.id, b.id));
495
+ }
496
+ return doc;
323
497
  }
324
- var vendureProductReplication = {
498
+ var createVendureProductReplication = (barcodeField, updatedAtSkewMs = 0) => ({
325
499
  pull: {
326
500
  async handler(lastCheckpoint, batchSize, context) {
501
+ if (batchSize > 1e3) throw new Error("Vendure Admin API take must not exceed 1000");
502
+ if (lastCheckpoint?.skip && lastCheckpoint.passHighWater === void 0 && lastCheckpoint.passTotal === void 0) {
503
+ lastCheckpoint = { skip: 0, updatedAt: lastCheckpoint.updatedAt ?? "" };
504
+ }
505
+ let passHighWater = lastCheckpoint?.passHighWater ?? lastCheckpoint?.updatedAt ?? "";
506
+ if (!lastCheckpoint?.skip) {
507
+ const fetchMark = async () => {
508
+ const head = await gql2(context, PRODUCT_MARK_QUERY, { options: { take: 1, sort: { updatedAt: "DESC" } } });
509
+ return head.data?.products?.items?.[0]?.updatedAt;
510
+ };
511
+ const mark = await fetchMark();
512
+ passHighWater = mark ?? lastCheckpoint?.updatedAt ?? "";
513
+ if (mark === void 0 || lastCheckpoint?.updatedAt && passHighWater === lastCheckpoint.updatedAt) {
514
+ return { documents: [], checkpoint: lastCheckpoint ?? { skip: 0, updatedAt: "" } };
515
+ }
516
+ passHighWater = await probeUpdatedAtSkew(passHighWater, updatedAtSkewMs, async (after) => {
517
+ const probe = await gql2(context, PRODUCT_MARK_QUERY, {
518
+ options: { take: 1, sort: { updatedAt: "DESC" }, filter: { updatedAt: { after } } }
519
+ });
520
+ return probe.data.products.totalItems;
521
+ }, fetchMark);
522
+ }
327
523
  const options = {
328
524
  take: batchSize,
329
- skip: lastCheckpoint?.skip ?? 0
525
+ skip: lastCheckpoint?.skip ?? 0,
526
+ sort: { id: "ASC" }
330
527
  };
331
528
  if (lastCheckpoint?.updatedAt) {
332
529
  options.filter = {
333
- updatedAt: { after: lastCheckpoint.updatedAt }
530
+ // Vendure after is strict; overlap by 1 ms to include timestamp ties.
531
+ updatedAt: { after: new Date(Date.parse(lastCheckpoint.updatedAt) - 1 - updatedAtSkewMs).toISOString() }
334
532
  };
335
533
  }
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}`);
534
+ const res = await gql2(context, PRODUCT_LIST_QUERY2(barcodeField), { options });
535
+ let data = res.data?.products;
536
+ let passTotal = options.skip === 0 ? data.totalItems : lastCheckpoint?.passTotal ?? data.totalItems;
537
+ if (options.skip > 0 && data.totalItems < passTotal) {
538
+ options.skip = 0;
539
+ const restarted = await gql2(context, PRODUCT_LIST_QUERY2(barcodeField), { options });
540
+ data = restarted.data?.products;
541
+ passTotal = data.totalItems;
339
542
  }
340
- const data = res.data?.products;
341
543
  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: "" };
544
+ if (!products.length && options.skip > 0) {
545
+ return createVendureProductReplication(barcodeField, updatedAtSkewMs).pull.handler(
546
+ { skip: 0, updatedAt: lastCheckpoint?.updatedAt ?? "" },
547
+ batchSize,
548
+ context
549
+ );
550
+ }
551
+ const documents = products.map(toProductDocument);
552
+ const checkpoint = options.skip + products.length >= data.totalItems ? { skip: 0, updatedAt: passHighWater, passHighWater: void 0, passTotal: void 0 } : {
553
+ skip: options.skip + products.length,
554
+ updatedAt: lastCheckpoint?.updatedAt ?? "",
555
+ passHighWater,
556
+ passTotal
557
+ };
348
558
  return { documents, checkpoint };
349
559
  }
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
560
+ }
561
+ });
562
+ var vendureProductReplication = createVendureProductReplication();
563
+
564
+ // src/replication/variant-feed.ts
565
+ var MAX_TAKE = 1e3;
566
+ var variants = (context, fields, options) => gql2(context, `
567
+ query GetVariants($options: ProductVariantListOptions) {
568
+ productVariants(options: $options) { items { ${fields} } totalItems }
569
+ }
570
+ `, { options }).then((res) => res.data.productVariants);
571
+ var newest = (context) => variants(context, "id updatedAt", { take: 1, sort: { updatedAt: "DESC" } });
572
+ var createVendureVariantFeedReplication = (barcodeField, updatedAtSkewMs = 0, variantPageSize = MAX_TAKE) => ({
573
+ pull: {
574
+ // The next call's unchanged-high-water check then ends at one read, unless
575
+ // a variant changed since. The skew probe runs when a pass starts from it.
576
+ async seedCheckpoint(context) {
577
+ const head = await newest(context);
578
+ return { skip: 0, updatedAt: head.items[0]?.updatedAt ?? "", passHighWater: void 0, passTotal: void 0 };
579
+ },
580
+ async handler(lastCheckpoint, batchSize, context) {
581
+ if (batchSize > MAX_TAKE || variantPageSize > MAX_TAKE) throw new Error("Vendure Admin API take must not exceed 1000");
582
+ let passHighWater = lastCheckpoint?.passHighWater ?? lastCheckpoint?.updatedAt ?? "";
583
+ let skip = lastCheckpoint?.skip ?? 0;
584
+ let passTotal = lastCheckpoint?.passTotal;
585
+ if (!skip) {
586
+ const head = await newest(context);
587
+ passHighWater = head.items[0]?.updatedAt ?? lastCheckpoint?.updatedAt ?? "";
588
+ if (!head.items.length || lastCheckpoint?.updatedAt && passHighWater === lastCheckpoint.updatedAt) {
589
+ return { documents: [], checkpoint: lastCheckpoint ?? { skip: 0, updatedAt: "" } };
590
+ }
591
+ await probeUpdatedAtSkew(passHighWater, updatedAtSkewMs, async (after) => {
592
+ const probe = await variants(context, "id updatedAt", { take: 1, sort: { updatedAt: "DESC" }, filter: { updatedAt: { after } } });
593
+ return probe.totalItems;
594
+ });
595
+ }
596
+ const filter = lastCheckpoint?.updatedAt ? { updatedAt: { after: new Date(Date.parse(lastCheckpoint.updatedAt) - 1 - updatedAtSkewMs).toISOString() } } : void 0;
597
+ const page = (from) => variants(context, "id productId", { take: variantPageSize, skip: from, sort: { id: "ASC" }, filter });
598
+ const seen = /* @__PURE__ */ new Set();
599
+ const documents = [];
600
+ let complete = false;
601
+ while (!complete && documents.length < batchSize) {
602
+ let data = await page(skip);
603
+ if (skip > 0 && (!data.items.length || data.totalItems < (passTotal ?? data.totalItems))) {
604
+ skip = 0;
605
+ data = await page(0);
606
+ }
607
+ if (skip === 0) passTotal = data.totalItems;
608
+ const parentIds = [...new Set(data.items.map((v) => String(v.productId)))].filter((id) => !seen.has(id));
609
+ parentIds.forEach((id) => seen.add(id));
610
+ if (parentIds.length) {
611
+ const res = await gql2(context, PRODUCT_LIST_QUERY2(barcodeField), {
612
+ options: { take: parentIds.length, filter: { id: { in: parentIds } } }
359
613
  });
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
- }
614
+ documents.push(...(res.data?.products?.items ?? []).map(toProductDocument));
369
615
  }
616
+ skip += data.items.length;
617
+ complete = skip >= data.totalItems || !data.items.length;
618
+ }
619
+ const checkpoint = complete ? { skip: 0, updatedAt: passHighWater, passHighWater: void 0, passTotal: void 0 } : { skip, updatedAt: lastCheckpoint?.updatedAt ?? "", passHighWater, passTotal };
620
+ const moved = ["skip", "updatedAt", "passHighWater", "passTotal"].some((key) => checkpoint[key] !== lastCheckpoint?.[key]);
621
+ if (!documents.length && moved) {
622
+ const res = await gql2(context, PRODUCT_LIST_QUERY2(barcodeField), { options: { take: 1 } });
623
+ documents.push(...(res.data?.products?.items ?? []).map(toProductDocument));
370
624
  }
371
- return conflicts;
625
+ return { documents, checkpoint };
372
626
  }
373
627
  }
374
- };
628
+ });
375
629
 
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
630
+ // src/reconcile/stock.ts
631
+ var PAGE_SIZE = 1e3;
632
+ var VARIANT_STOCK_QUERY = `
633
+ query VariantStock($options: ProductVariantListOptions) {
634
+ productVariants(options: $options) {
635
+ items { id stockLevels { stockLocationId stockOnHand stockAllocated } }
636
+ totalItems
637
+ }
638
+ }
639
+ `;
640
+ var levelsKey = (levels = []) => levels.map((l) => JSON.stringify([l.stockLocationId, l.stockOnHand, l.stockAllocated])).sort().join();
641
+ var vendureStockReconcile = {
642
+ async *fetchPages(context) {
643
+ for (let skip = 0, total = 1; skip < total; skip += PAGE_SIZE) {
644
+ const body = await gql2(context, VARIANT_STOCK_QUERY, { options: { take: PAGE_SIZE, skip, sort: { id: "ASC" } } });
645
+ const { items, totalItems } = body.data.productVariants;
646
+ total = totalItems;
647
+ yield new Map(items.map((v) => [v.id, v.stockLevels]));
648
+ }
412
649
  },
413
- replication: {
414
- products: vendureProductReplication
650
+ overlay(doc, stock) {
651
+ const variants2 = doc.variants ?? [];
652
+ let changed = false;
653
+ const next = variants2.map((v) => {
654
+ const levels = stock.get(v.id);
655
+ if (!levels || levelsKey(levels) === levelsKey(v.stockLevels)) return v;
656
+ changed = true;
657
+ return { ...v, stockLevels: levels };
658
+ });
659
+ return changed ? { variants: next } : void 0;
660
+ }
661
+ };
662
+
663
+ // src/reconcile/ids.ts
664
+ var PAGE_SIZE2 = 1e3;
665
+ var ID_LIST_QUERY = `
666
+ query GetProductIds($options: ProductListOptions) {
667
+ products(options: $options) { items { id variants { id } } totalItems }
668
+ }
669
+ `;
670
+ async function* fetchPages(context) {
671
+ for (let skip = 0, total = 1; skip < total; skip += PAGE_SIZE2) {
672
+ const res = await gql2(context, ID_LIST_QUERY, { options: { take: PAGE_SIZE2, skip, sort: { id: "ASC" } } });
673
+ const { items, totalItems } = res.data.products;
674
+ total = totalItems;
675
+ yield items.map((p) => ({ id: String(p.id), variantIds: (p.variants ?? []).map((v) => String(v.id)) }));
676
+ }
677
+ }
678
+ function variantIds(doc) {
679
+ return (doc.variants ?? []).map((v) => String(v.id));
680
+ }
681
+ function createFetchByIds(barcodeField) {
682
+ return async function fetchByIds(ids, context) {
683
+ if (!ids.length) return [];
684
+ const res = await gql2(context, PRODUCT_LIST_QUERY2(barcodeField), { options: { take: ids.length, filter: { id: { in: ids } } } });
685
+ return (res.data?.products?.items ?? []).map(toProductDocument);
686
+ };
687
+ }
688
+
689
+ // src/reconcile/prices.ts
690
+ var PAGE_SIZE3 = 1e3;
691
+ var VENDURE_PRICE_RECONCILE_INTERVAL_MS = 24 * 60 * 60 * 1e3;
692
+ var PRICE_LIST_QUERY = `
693
+ query GetVariantPrices($options: ProductVariantListOptions) {
694
+ productVariants(options: $options) { items { id productId price priceWithTax currencyCode } totalItems }
695
+ }
696
+ `;
697
+ var fingerprintOf = (entries) => [...entries].sort().join("|");
698
+ async function* fetchPages2(context) {
699
+ const byProduct = /* @__PURE__ */ new Map();
700
+ for (let skip = 0, total = 1; skip < total; skip += PAGE_SIZE3) {
701
+ const res = await gql2(context, PRICE_LIST_QUERY, { options: { take: PAGE_SIZE3, skip, sort: { id: "ASC" } } });
702
+ const { items, totalItems } = res.data.productVariants;
703
+ total = totalItems;
704
+ const touched = /* @__PURE__ */ new Set();
705
+ for (const variant of items) {
706
+ const productId = String(variant.productId);
707
+ touched.add(productId);
708
+ const entries = byProduct.get(productId) ?? [];
709
+ byProduct.set(productId, entries);
710
+ entries.push(`${variant.id}:${variant.currencyCode}:${variant.price}:${variant.priceWithTax}`);
711
+ }
712
+ yield new Map([...touched].map((id) => [id, fingerprintOf(byProduct.get(id) ?? [])]));
415
713
  }
714
+ }
715
+ function fingerprint(doc) {
716
+ const entries = (doc.variants ?? []).map(
717
+ (v) => `${v.id}:${v.currencyCode}:${v.price}:${v.priceWithTax}`
718
+ );
719
+ return fingerprintOf(entries);
720
+ }
721
+
722
+ // src/store-settings.ts
723
+ import { StoreSettingsError } from "@tallyui/core";
724
+ var CHANNEL_AND_CATEGORIES_QUERY = `
725
+ query StoreSettingsChannel {
726
+ activeChannel { defaultCurrencyCode pricesIncludeTax defaultTaxZone { id } }
727
+ taxCategories { items { id isDefault } }
728
+ }
729
+ `;
730
+ var TAX_RATES_QUERY = `
731
+ query StoreSettingsTaxRates($zoneId: String!) {
732
+ taxRates(options: { take: 1000, filter: { zoneId: { eq: $zoneId } } }) {
733
+ items { enabled value category { id } customerGroup { id } }
734
+ }
735
+ }
736
+ `;
737
+ async function query(context, source, variables) {
738
+ try {
739
+ return await gql2(context, source, variables);
740
+ } catch (error) {
741
+ throw new StoreSettingsError("failed", error instanceof Error ? error.message : String(error));
742
+ }
743
+ }
744
+ var vendureStoreSettings = async (context, _choice) => {
745
+ const head = await query(context, CHANNEL_AND_CATEGORIES_QUERY);
746
+ const channel = head.data?.activeChannel;
747
+ if (!channel) throw new StoreSettingsError("failed", "Vendure returned no active channel");
748
+ const categories = head.data?.taxCategories?.items ?? [];
749
+ let rateItems = [];
750
+ if (channel.defaultTaxZone) {
751
+ const ratesBody = await query(context, TAX_RATES_QUERY, { zoneId: channel.defaultTaxZone.id });
752
+ rateItems = ratesBody.data?.taxRates?.items ?? [];
753
+ }
754
+ const byCategory = /* @__PURE__ */ new Map();
755
+ for (const rate of rateItems) {
756
+ if (!rate.enabled || rate.customerGroup || !rate.category) continue;
757
+ byCategory.set(rate.category.id, Math.round(rate.value * 1e4));
758
+ }
759
+ const defaultCategory = categories.find((c) => c.isDefault) ?? categories[0];
760
+ const taxRatesPpm = {
761
+ default: defaultCategory ? byCategory.get(defaultCategory.id) ?? 0 : 0
762
+ };
763
+ for (const [categoryId, ppm] of byCategory) taxRatesPpm[categoryId] = ppm;
764
+ return {
765
+ currency: channel.defaultCurrencyCode,
766
+ pricesIncludeTax: channel.pricesIncludeTax,
767
+ taxRatesPpm
768
+ };
769
+ };
770
+
771
+ // src/global-settings.ts
772
+ var GLOBAL_SETTINGS_QUERY = `
773
+ query GlobalStockSettings {
774
+ globalSettings { trackInventory outOfStockThreshold }
775
+ }
776
+ `;
777
+ async function vendureGlobalStockSettings(context) {
778
+ const res = await gql2(context, GLOBAL_SETTINGS_QUERY);
779
+ return res.data.globalSettings;
780
+ }
781
+
782
+ // src/index.ts
783
+ var createVendureConnector = (options = {}) => {
784
+ const idFeed = createReconcileFeed({ fetchByIds: createFetchByIds(options.barcodeField) });
785
+ return {
786
+ id: "vendure",
787
+ name: "Vendure",
788
+ description: "Connect to Vendure backends via the Admin GraphQL API",
789
+ icon: void 0,
790
+ auth: vendureAuth,
791
+ schemas: {
792
+ products: vendureProductSchema
793
+ },
794
+ traits: {
795
+ product: createVendureProductTraits(
796
+ options.barcodeField,
797
+ options.stockLocationId,
798
+ options.pricesIncludeTax,
799
+ options.globalTrackInventory,
800
+ options.globalOutOfStockThreshold
801
+ )
802
+ },
803
+ sync: {
804
+ products: createVendureProductSync(options.barcodeField)
805
+ },
806
+ replication: {
807
+ // One replication per collection: the product, variant and id-reconcile feeds
808
+ // share it (ADR-060). reconcile is last so its fetch wins duplicates.
809
+ products: combinePullAdapters({
810
+ products: createVendureProductReplication(options.barcodeField, options.updatedAtSkewMs),
811
+ variants: createVendureVariantFeedReplication(options.barcodeField, options.updatedAtSkewMs),
812
+ reconcile: idFeed.adapter
813
+ }, { legacyKey: "products" })
814
+ },
815
+ reconcile: {
816
+ stock: vendureStockReconcile,
817
+ ids: { fetchPages, variantIds, enqueue: idFeed.enqueue },
818
+ prices: { fetchPages: fetchPages2, fingerprint, enqueue: idFeed.enqueue }
819
+ },
820
+ storeSettings: vendureStoreSettings
821
+ };
416
822
  };
823
+ var vendureConnector = createVendureConnector();
417
824
  export {
825
+ createVendureConnector,
826
+ createVendureProductReplication,
827
+ createVendureVariantFeedReplication,
828
+ vendureAuth,
418
829
  vendureConnector,
830
+ vendureGlobalStockSettings,
419
831
  vendureProductReplication,
420
832
  vendureProductSchema,
421
833
  vendureProductSync,
422
- vendureProductTraits
834
+ vendureProductTraits,
835
+ vendureSignIn,
836
+ vendureStockReconcile,
837
+ vendureStoreSettings
423
838
  };