@porulle/adapter-shopify 0.56.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 +93 -16
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/index.ts +91 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/adapter-shopify",
|
|
3
|
-
"version": "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.
|
|
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/
|
|
21
|
-
"@porulle/
|
|
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
|
|
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
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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;
|