@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/adapter-shopify",
3
- "version": "0.55.0",
3
+ "version": "0.57.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -10,15 +10,15 @@
10
10
  }
11
11
  },
12
12
  "dependencies": {
13
- "@porulle/core": "0.55.0"
13
+ "@porulle/core": "0.57.0"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@types/node": "^24.5.2",
17
17
  "eslint": "^9.39.1",
18
18
  "typescript": "5.9.2",
19
19
  "vitest": "^3.2.4",
20
- "@porulle/typescript-config": "0.1.0",
21
- "@porulle/eslint-config": "0.1.0"
20
+ "@porulle/eslint-config": "0.1.0",
21
+ "@porulle/typescript-config": "0.1.0"
22
22
  },
23
23
  "publishConfig": {
24
24
  "access": "public"
package/src/index.ts CHANGED
@@ -97,6 +97,8 @@ type ShopifyProduct = {
97
97
  grams?: number | null;
98
98
  weight?: number | null;
99
99
  weight_unit?: string | null;
100
+ /** Read-only aggregate available across ALL the shop's locations (Shopify's ProductVariant). */
101
+ inventory_quantity?: number | null;
100
102
  }>;
101
103
  };
102
104
 
@@ -220,6 +222,35 @@ function shopifyOAuthStartUrl(appUrl: string, storeDomain: string): string | und
220
222
  }
221
223
  }
222
224
 
225
+ /**
226
+ * The `rel="next"` target of a REST response's `Link` header, or null on the last page.
227
+ *
228
+ * RFC 8288 permits the relation as a quoted string OR a bare token, and this reads both. It read
229
+ * only `rel="next"` once, and the asymmetry is the argument rather than the likelihood: a reader
230
+ * that accepts only the quoted form and meets `rel=next` does not throw — it finds no next link,
231
+ * ends the walk, and reports a SUCCESSFUL walk of a partial list. Silent truncation. Accepting both
232
+ * cannot make a malformed header parse as a valid one, so the permissive direction has no cost.
233
+ */
234
+ function nextPageUrl(response: Response): string | null {
235
+ const link = response.headers.get("link") ?? "";
236
+ return link.match(/<([^>]+)>;\s*rel=(?:"next"|next)(?:\s*(?:,|$))/)?.[1] ?? null;
237
+ }
238
+
239
+ /** The `{ variant: { id, inventory_quantity } }` a `variants/{id}.json` answers, read without a cast. */
240
+ function variantInventoryOf(body: unknown): { id: number | string; inventory_quantity: number | null } | undefined {
241
+ if (typeof body !== "object" || body === null || !("variant" in body)) return undefined;
242
+ const variant: unknown = body.variant;
243
+ if (typeof variant !== "object" || variant === null || !("id" in variant)) return undefined;
244
+ const id: unknown = variant.id;
245
+ if (typeof id !== "number" && typeof id !== "string") return undefined;
246
+ const quantity: unknown = "inventory_quantity" in variant ? variant.inventory_quantity : null;
247
+ return { id, inventory_quantity: typeof quantity === "number" ? quantity : null };
248
+ }
249
+
250
+ /** Up to this many variant ids are read one `variants/{id}.json` each (the order-time check);
251
+ * more are answered from one walk of the catalogue. */
252
+ const PER_VARIANT_INVENTORY_READS = 25;
253
+
223
254
  async function request<T>(fetchImpl: typeof fetch, url: string, accessToken: string, init?: RequestInit): Promise<Result<{ data: T; response: Response }>> {
224
255
  try {
225
256
  const response = await fetchImpl(url, {
@@ -364,15 +395,7 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
364
395
  const url = cursor ?? `${apiBase(store, version, options.baseUrl)}/products.json?limit=250`;
365
396
  const result = await request<{ products: ShopifyProduct[] }>(fetchImpl, url, token);
366
397
  if (!result.ok) return result;
367
- const link = result.value.response.headers.get("link") ?? "";
368
- // RFC 8288 permits the relation as a quoted string OR a bare token, and this reads both.
369
- //
370
- // It read only `rel="next"` before, and the asymmetry is the argument rather than the
371
- // likelihood: a reader that accepts only the quoted form and meets `rel=next` does not throw
372
- // — it finds no next link, ends the walk, and reports a SUCCESSFUL import of a partial
373
- // catalogue. Silent truncation. Accepting both cannot make a malformed header parse as a
374
- // valid one, so the permissive direction has no matching cost.
375
- const next = link.match(/<([^>]+)>;\s*rel=(?:"next"|next)(?:\s*(?:,|$))/)?.[1] ?? null;
398
+ const next = nextPageUrl(result.value.response);
376
399
  return Ok({
377
400
  items: result.value.data.products.map((product) => {
378
401
  const options = product.options?.map((option, index) => ({
@@ -426,14 +449,68 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
426
449
  nextCursor: next,
427
450
  });
428
451
  },
452
+ /**
453
+ * Stock per VARIANT, keyed by the variant id every connector call site matches on.
454
+ *
455
+ * Read from the variant's `inventory_quantity`, never from `inventory_levels.json`: that
456
+ * endpoint requires `inventory_item_ids` or `location_ids`, takes at most 50 ids, and keys
457
+ * levels by INVENTORY ITEM id, which is not the variant id — so it answered a real store
458
+ * nothing the connector could match, and the sim (whose mock was laxer) one 250-row page.
459
+ * https://shopify.dev/docs/api/admin-rest/latest/resources/inventorylevel
460
+ *
461
+ * `inventory_quantity` sums ALL locations, so a store with a non-selling location overstates
462
+ * sellable stock; per-location stock would need InventoryLevel by location. Negative stock
463
+ * (oversold) reads as 0, as it is stored.
464
+ *
465
+ * A few ids (the order-time check) cost one `variants/{id}.json` each; a variant Shopify no
466
+ * longer has is omitted, which the caller treats as unconfirmed. More ids, or none (a full
467
+ * sync), are answered from one walk of `products.json`, every page: ceil(products / 250).
468
+ */
429
469
  async fetchInventory(store, ids): Promise<Result<ChannelInventoryLevel[]>> {
430
470
  const token = credentials(store);
431
471
  if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
432
- const params = new URLSearchParams({ limit: "250" });
433
- if (ids?.length) params.set("inventory_item_ids", ids.join(","));
434
- const result = await request<{ inventory_levels: Array<{ inventory_item_id: number | string; available: number | null }> }>(fetchImpl, `${apiBase(store, version, options.baseUrl)}/inventory_levels.json?${params}`, token);
435
- if (!result.ok) return result;
436
- return Ok(result.value.data.inventory_levels.map((level) => ({ externalId: String(level.inventory_item_id), available: level.available ?? 0 })));
472
+ const base = apiBase(store, version, options.baseUrl);
473
+ const level = (variant: { id: number | string; inventory_quantity?: number | null }): ChannelInventoryLevel =>
474
+ ({ externalId: String(variant.id), available: Math.max(0, variant.inventory_quantity ?? 0) });
475
+
476
+ if (ids !== undefined && ids.length <= PER_VARIANT_INVENTORY_READS) {
477
+ const levels: ChannelInventoryLevel[] = [];
478
+ for (const id of ids) {
479
+ const url = `${base}/variants/${encodeURIComponent(id)}.json`;
480
+ let response: Response;
481
+ try {
482
+ response = await fetchImpl(url, { headers: { accept: "application/json", "x-shopify-access-token": token } });
483
+ } catch (error) {
484
+ return Err({ code: "SHOPIFY_API_FAILED", message: error instanceof Error ? error.message : "Shopify API request failed.", retriable: true });
485
+ }
486
+ // Gone upstream: omitted, so the caller refuses the line as unconfirmed rather than failing
487
+ // every other line of the order with it.
488
+ if (response.status === 404) continue;
489
+ if (!response.ok) return Err({ code: "SHOPIFY_API_FAILED", message: `Shopify API request failed (${response.status}) for ${url}.`, retriable: response.status >= 500 });
490
+ const variant = variantInventoryOf(await response.json());
491
+ if (variant === undefined) return Err({ code: "SHOPIFY_API_FAILED", message: `Shopify answered no variant for ${url}.` });
492
+ levels.push(level(variant));
493
+ }
494
+ return Ok(levels);
495
+ }
496
+
497
+ const wanted = ids === undefined ? undefined : new Set(ids);
498
+ const levels: ChannelInventoryLevel[] = [];
499
+ let url: string | null = `${base}/products.json?limit=250&fields=id,variants`;
500
+ const seen = new Set<string>();
501
+ while (url !== null) {
502
+ if (seen.has(url)) return Err({ code: "SHOPIFY_API_FAILED", message: "Shopify pagination repeated a page." });
503
+ seen.add(url);
504
+ const result = await request<{ products: ShopifyProduct[] }>(fetchImpl, url, token);
505
+ if (!result.ok) return result;
506
+ for (const product of result.value.data.products) {
507
+ for (const variant of product.variants ?? []) {
508
+ if (wanted === undefined || wanted.has(String(variant.id))) levels.push(level(variant));
509
+ }
510
+ }
511
+ url = nextPageUrl(result.value.response);
512
+ }
513
+ return Ok(levels);
437
514
  },
438
515
  async pushCatalog(store: ChannelStore, items: ChannelPushCatalogItem[], opts?: { dryRun?: boolean }): Promise<Result<ChannelPushCatalogResult, ChannelConnectorError>> {
439
516
  const oauthStartUrl = options.appUrl ? shopifyOAuthStartUrl(options.appUrl, store.storeDomain) : undefined;