@porulle/adapter-shopify 0.55.0 → 0.57.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
@@ -118,6 +118,35 @@ function shopifyOAuthStartUrl(appUrl, storeDomain) {
118
118
  return undefined;
119
119
  }
120
120
  }
121
+ /**
122
+ * The `rel="next"` target of a REST response's `Link` header, or null on the last page.
123
+ *
124
+ * RFC 8288 permits the relation as a quoted string OR a bare token, and this reads both. It read
125
+ * only `rel="next"` once, and the asymmetry is the argument rather than the likelihood: a reader
126
+ * that accepts only the quoted form and meets `rel=next` does not throw — it finds no next link,
127
+ * ends the walk, and reports a SUCCESSFUL walk of a partial list. Silent truncation. Accepting both
128
+ * cannot make a malformed header parse as a valid one, so the permissive direction has no cost.
129
+ */
130
+ function nextPageUrl(response) {
131
+ const link = response.headers.get("link") ?? "";
132
+ return link.match(/<([^>]+)>;\s*rel=(?:"next"|next)(?:\s*(?:,|$))/)?.[1] ?? null;
133
+ }
134
+ /** The `{ variant: { id, inventory_quantity } }` a `variants/{id}.json` answers, read without a cast. */
135
+ function variantInventoryOf(body) {
136
+ if (typeof body !== "object" || body === null || !("variant" in body))
137
+ return undefined;
138
+ const variant = body.variant;
139
+ if (typeof variant !== "object" || variant === null || !("id" in variant))
140
+ return undefined;
141
+ const id = variant.id;
142
+ if (typeof id !== "number" && typeof id !== "string")
143
+ return undefined;
144
+ const quantity = "inventory_quantity" in variant ? variant.inventory_quantity : null;
145
+ return { id, inventory_quantity: typeof quantity === "number" ? quantity : null };
146
+ }
147
+ /** Up to this many variant ids are read one `variants/{id}.json` each (the order-time check);
148
+ * more are answered from one walk of the catalogue. */
149
+ const PER_VARIANT_INVENTORY_READS = 25;
121
150
  async function request(fetchImpl, url, accessToken, init) {
122
151
  try {
123
152
  const response = await fetchImpl(url, {
@@ -259,15 +288,7 @@ export function shopifyConnector(options = {}) {
259
288
  const result = await request(fetchImpl, url, token);
260
289
  if (!result.ok)
261
290
  return result;
262
- const link = result.value.response.headers.get("link") ?? "";
263
- // RFC 8288 permits the relation as a quoted string OR a bare token, and this reads both.
264
- //
265
- // It read only `rel="next"` before, and the asymmetry is the argument rather than the
266
- // likelihood: a reader that accepts only the quoted form and meets `rel=next` does not throw
267
- // — it finds no next link, ends the walk, and reports a SUCCESSFUL import of a partial
268
- // catalogue. Silent truncation. Accepting both cannot make a malformed header parse as a
269
- // valid one, so the permissive direction has no matching cost.
270
- const next = link.match(/<([^>]+)>;\s*rel=(?:"next"|next)(?:\s*(?:,|$))/)?.[1] ?? null;
291
+ const next = nextPageUrl(result.value.response);
271
292
  return Ok({
272
293
  items: result.value.data.products.map((product) => {
273
294
  const options = product.options?.map((option, index) => ({
@@ -321,17 +342,73 @@ export function shopifyConnector(options = {}) {
321
342
  nextCursor: next,
322
343
  });
323
344
  },
345
+ /**
346
+ * Stock per VARIANT, keyed by the variant id every connector call site matches on.
347
+ *
348
+ * Read from the variant's `inventory_quantity`, never from `inventory_levels.json`: that
349
+ * endpoint requires `inventory_item_ids` or `location_ids`, takes at most 50 ids, and keys
350
+ * levels by INVENTORY ITEM id, which is not the variant id — so it answered a real store
351
+ * nothing the connector could match, and the sim (whose mock was laxer) one 250-row page.
352
+ * https://shopify.dev/docs/api/admin-rest/latest/resources/inventorylevel
353
+ *
354
+ * `inventory_quantity` sums ALL locations, so a store with a non-selling location overstates
355
+ * sellable stock; per-location stock would need InventoryLevel by location. Negative stock
356
+ * (oversold) reads as 0, as it is stored.
357
+ *
358
+ * A few ids (the order-time check) cost one `variants/{id}.json` each; a variant Shopify no
359
+ * longer has is omitted, which the caller treats as unconfirmed. More ids, or none (a full
360
+ * sync), are answered from one walk of `products.json`, every page: ceil(products / 250).
361
+ */
324
362
  async fetchInventory(store, ids) {
325
363
  const token = credentials(store);
326
364
  if (!token)
327
365
  return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
328
- const params = new URLSearchParams({ limit: "250" });
329
- if (ids?.length)
330
- params.set("inventory_item_ids", ids.join(","));
331
- const result = await request(fetchImpl, `${apiBase(store, version, options.baseUrl)}/inventory_levels.json?${params}`, token);
332
- if (!result.ok)
333
- return result;
334
- return Ok(result.value.data.inventory_levels.map((level) => ({ externalId: String(level.inventory_item_id), available: level.available ?? 0 })));
366
+ const base = apiBase(store, version, options.baseUrl);
367
+ const level = (variant) => ({ externalId: String(variant.id), available: Math.max(0, variant.inventory_quantity ?? 0) });
368
+ if (ids !== undefined && ids.length <= PER_VARIANT_INVENTORY_READS) {
369
+ const levels = [];
370
+ for (const id of ids) {
371
+ const url = `${base}/variants/${encodeURIComponent(id)}.json`;
372
+ let response;
373
+ try {
374
+ response = await fetchImpl(url, { headers: { accept: "application/json", "x-shopify-access-token": token } });
375
+ }
376
+ catch (error) {
377
+ return Err({ code: "SHOPIFY_API_FAILED", message: error instanceof Error ? error.message : "Shopify API request failed.", retriable: true });
378
+ }
379
+ // Gone upstream: omitted, so the caller refuses the line as unconfirmed rather than failing
380
+ // every other line of the order with it.
381
+ if (response.status === 404)
382
+ continue;
383
+ if (!response.ok)
384
+ return Err({ code: "SHOPIFY_API_FAILED", message: `Shopify API request failed (${response.status}) for ${url}.`, retriable: response.status >= 500 });
385
+ const variant = variantInventoryOf(await response.json());
386
+ if (variant === undefined)
387
+ return Err({ code: "SHOPIFY_API_FAILED", message: `Shopify answered no variant for ${url}.` });
388
+ levels.push(level(variant));
389
+ }
390
+ return Ok(levels);
391
+ }
392
+ const wanted = ids === undefined ? undefined : new Set(ids);
393
+ const levels = [];
394
+ let url = `${base}/products.json?limit=250&fields=id,variants`;
395
+ const seen = new Set();
396
+ while (url !== null) {
397
+ if (seen.has(url))
398
+ return Err({ code: "SHOPIFY_API_FAILED", message: "Shopify pagination repeated a page." });
399
+ seen.add(url);
400
+ const result = await request(fetchImpl, url, token);
401
+ if (!result.ok)
402
+ return result;
403
+ for (const product of result.value.data.products) {
404
+ for (const variant of product.variants ?? []) {
405
+ if (wanted === undefined || wanted.has(String(variant.id)))
406
+ levels.push(level(variant));
407
+ }
408
+ }
409
+ url = nextPageUrl(result.value.response);
410
+ }
411
+ return Ok(levels);
335
412
  },
336
413
  async pushCatalog(store, items, opts) {
337
414
  const oauthStartUrl = options.appUrl ? shopifyOAuthStartUrl(options.appUrl, store.storeDomain) : undefined;