@porulle/adapter-shopify 0.56.0 → 0.57.1
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 +93 -16
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/index.ts +91 -14
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
|
|
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
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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;
|