@porulle/adapter-shopify 0.38.0 → 0.40.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.38.0",
3
+ "version": "0.40.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.38.0"
13
+ "@porulle/core": "0.40.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
@@ -34,6 +34,25 @@ export interface ShopifyConnectorOptions {
34
34
  clientSecret?: string;
35
35
  appUrl?: string;
36
36
  scopes?: string[];
37
+ /**
38
+ * Origin override for every Shopify call — the Admin API and both OAuth endpoints.
39
+ *
40
+ * ABSENT IN PRODUCTION, where it resolves to `https://{store.storeDomain}` and the request is
41
+ * byte-identical to the one this adapter has always made. Present, it points the same code at a
42
+ * local stand-in built from Shopify's documentation, so that a missing app credential stops
43
+ * blocking development.
44
+ *
45
+ * It is an ORIGIN and not a base URL because Shopify's host is per-store: the shop still rides
46
+ * inside the path the adapter appends. A caller pointing at a mock passes
47
+ * `http://127.0.0.1:<port>/shopify`, the adapter appends `/admin/api/{version}/products.json`,
48
+ * and the stand-in serves Shopify's own address table unprefixed.
49
+ *
50
+ * There is deliberately NO `mock` flag and no URL rewriting inside `fetchImpl`. The shipped path
51
+ * must be the tested path; a branch inside the adapter means the code exercised by a test is not
52
+ * the code that runs, and reaching a stand-in by rewriting URLs inside an injected fetch is the
53
+ * same failure wearing a hook.
54
+ */
55
+ baseUrl?: string;
37
56
  }
38
57
 
39
58
  export const REQUIRED_SCOPES = [
@@ -75,6 +94,9 @@ type ShopifyProduct = {
75
94
  option1?: string | null;
76
95
  option2?: string | null;
77
96
  option3?: string | null;
97
+ grams?: number | null;
98
+ weight?: number | null;
99
+ weight_unit?: string | null;
78
100
  }>;
79
101
  };
80
102
 
@@ -125,6 +147,46 @@ function pricesForVariant(
125
147
  }];
126
148
  }
127
149
 
150
+ /**
151
+ * Shopify carries a variant's weight twice: `grams`, which it normalises itself,
152
+ * and `weight` with a `weight_unit`. Prefer `grams`; fall back to converting
153
+ * `weight` only when `grams` is absent or zero.
154
+ *
155
+ * Returns `undefined` — never `0` — when no weight is known, so the caller omits
156
+ * the key entirely. A written `0` is indistinguishable from a genuinely
157
+ * weightless item and would defeat a downstream default-parcel substitution.
158
+ *
159
+ * An unrecognised `weight_unit` is refused rather than assumed to be grams:
160
+ * reading "lbs" (a spelling Shopify does not use, but a proxy might) as grams
161
+ * under-prices a parcel by a factor of 453.
162
+ */
163
+ function weightGramsForVariant(
164
+ variant: NonNullable<ShopifyProduct["variants"]>[number],
165
+ ): number | undefined {
166
+ const { grams, weight, weight_unit: weightUnit } = variant;
167
+
168
+ if (typeof grams === "number" && Number.isFinite(grams) && grams > 0) {
169
+ return Math.round(grams);
170
+ }
171
+
172
+ if (typeof weight === "number" && Number.isFinite(weight) && weight > 0) {
173
+ switch (weightUnit ?? "g") {
174
+ case "g":
175
+ return Math.round(weight);
176
+ case "kg":
177
+ return Math.round(weight * 1000);
178
+ case "oz":
179
+ return Math.round(weight * 28.349523125);
180
+ case "lb":
181
+ return Math.round(weight * 453.59237);
182
+ default:
183
+ return undefined;
184
+ }
185
+ }
186
+
187
+ return undefined;
188
+ }
189
+
128
190
  function catalogStatus(value: string | null | undefined): "draft" | "active" | "archived" | undefined {
129
191
  return value === "draft" || value === "active" || value === "archived" ? value : undefined;
130
192
  }
@@ -138,8 +200,14 @@ async function fetchShopCurrency(fetchImpl: typeof fetch, url: string, accessTok
138
200
  return result.ok ? normalizeCurrency(result.value.data.shop?.currency) : undefined;
139
201
  }
140
202
 
141
- function apiBase(store: ChannelStore, version: string): string {
142
- return `https://${store.storeDomain.replace(/^https?:\/\//, "").replace(/\/$/, "")}/admin/api/${version}`;
203
+ /** `baseUrl` when given, else the store's own origin. The single place that choice is made. */
204
+ function shopOrigin(storeDomain: string, baseUrl: string | undefined): string {
205
+ if (baseUrl !== undefined && baseUrl !== "") return baseUrl.replace(/\/$/, "");
206
+ return `https://${storeDomain.replace(/^https?:\/\//, "").replace(/\/$/, "")}`;
207
+ }
208
+
209
+ function apiBase(store: ChannelStore, version: string, baseUrl?: string): string {
210
+ return `${shopOrigin(store.storeDomain, baseUrl)}/admin/api/${version}`;
143
211
  }
144
212
 
145
213
  function shopifyOAuthStartUrl(appUrl: string, storeDomain: string): string | undefined {
@@ -239,7 +307,7 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
239
307
  const shopDomain = params.storeDomain.toLowerCase();
240
308
  if (!validShopDomain(shopDomain)) return oauthError("SHOPIFY_INVALID_STORE_DOMAIN", "Shopify storeDomain must be a *.myshopify.com domain.");
241
309
  const scopes = [...new Set([...REQUIRED_SCOPES, ...(options.scopes ?? []), ...params.scopes])];
242
- const url = new URL(`https://${shopDomain}/admin/oauth/authorize`);
310
+ const url = new URL(`${shopOrigin(shopDomain, options.baseUrl)}/admin/oauth/authorize`);
243
311
  url.searchParams.set("client_id", options.clientId);
244
312
  url.searchParams.set("scope", scopes.join(","));
245
313
  url.searchParams.set("redirect_uri", params.redirectUri);
@@ -266,7 +334,7 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
266
334
  const code = url.searchParams.get("code");
267
335
  if (!code) return oauthError("SHOPIFY_OAUTH_CODE_REQUIRED", "Shopify OAuth callback code is required.");
268
336
  try {
269
- const response = await fetchImpl(`https://${shopDomain}/admin/oauth/access_token`, {
337
+ const response = await fetchImpl(`${shopOrigin(shopDomain, options.baseUrl)}/admin/oauth/access_token`, {
270
338
  method: "POST",
271
339
  headers: { accept: "application/json", "content-type": "application/json" },
272
340
  body: JSON.stringify({ client_id: options.clientId, client_secret: options.clientSecret, code }),
@@ -285,19 +353,26 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
285
353
  async importCatalog(store, cursor): Promise<Result<ChannelCatalogPage>> {
286
354
  const token = credentials(store);
287
355
  if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
288
- const currencyUrl = `${apiBase(store, version)}/shop.json`;
289
- const currencyKey = apiBase(store, version);
356
+ const currencyUrl = `${apiBase(store, version, options.baseUrl)}/shop.json`;
357
+ const currencyKey = apiBase(store, version, options.baseUrl);
290
358
  let currencyPromise = currencyCache.get(currencyKey);
291
359
  if (!currencyPromise) {
292
360
  currencyPromise = fetchShopCurrency(fetchImpl, currencyUrl, token);
293
361
  currencyCache.set(currencyKey, currencyPromise);
294
362
  }
295
363
  const currency = await currencyPromise;
296
- const url = cursor ?? `${apiBase(store, version)}/products.json?limit=250`;
364
+ const url = cursor ?? `${apiBase(store, version, options.baseUrl)}/products.json?limit=250`;
297
365
  const result = await request<{ products: ShopifyProduct[] }>(fetchImpl, url, token);
298
366
  if (!result.ok) return result;
299
367
  const link = result.value.response.headers.get("link") ?? "";
300
- const next = link.match(/<([^>]+)>;\s*rel="next"/)?.[1] ?? null;
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;
301
376
  return Ok({
302
377
  items: result.value.data.products.map((product) => {
303
378
  const options = product.options?.map((option, index) => ({
@@ -313,12 +388,14 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
313
388
  return value != null && value !== "" ? [[option.name, value] as const] : [];
314
389
  }));
315
390
  const prices = pricesForVariant(variant, currency);
391
+ const weightGrams = weightGramsForVariant(variant);
316
392
  return {
317
393
  externalId: String(variant.id),
318
394
  ...(variant.sku ? { sku: variant.sku } : {}),
319
395
  ...(variant.barcode ? { barcode: variant.barcode } : {}),
320
396
  ...(Object.keys(optionValues).length > 0 ? { optionValues } : {}),
321
397
  ...(prices ? { prices } : {}),
398
+ ...(weightGrams !== undefined ? { metadata: { weightGrams } } : {}),
322
399
  };
323
400
  });
324
401
  const category = product.product_type ? slugify(product.product_type) : "";
@@ -354,7 +431,7 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
354
431
  if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
355
432
  const params = new URLSearchParams({ limit: "250" });
356
433
  if (ids?.length) params.set("inventory_item_ids", ids.join(","));
357
- const result = await request<{ inventory_levels: Array<{ inventory_item_id: number | string; available: number | null }> }>(fetchImpl, `${apiBase(store, version)}/inventory_levels.json?${params}`, token);
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);
358
435
  if (!result.ok) return result;
359
436
  return Ok(result.value.data.inventory_levels.map((level) => ({ externalId: String(level.inventory_item_id), available: level.available ?? 0 })));
360
437
  },
@@ -373,7 +450,7 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
373
450
  const token = credentials(store);
374
451
  if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required.", retriable: false });
375
452
  const [firstName, ...lastParts] = slice.customer.name.trim().split(/\s+/);
376
- const result = await request<{ order: { id: number | string; admin_graphql_api_id?: string } }>(fetchImpl, `${apiBase(store, version)}/orders.json`, token, {
453
+ const result = await request<{ order: { id: number | string; admin_graphql_api_id?: string } }>(fetchImpl, `${apiBase(store, version, options.baseUrl)}/orders.json`, token, {
377
454
  method: "POST",
378
455
  headers: { "content-type": "application/json", "idempotency-key": `porulle:${slice.orderId}` },
379
456
  body: JSON.stringify({ order: {
@@ -386,12 +463,12 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
386
463
  });
387
464
  if (!result.ok) return result;
388
465
  const id = String(result.value.data.order.id);
389
- return Ok({ remoteOrderId: id, remoteUrl: `${apiBase(store, version)}/orders/${id}.json` });
466
+ return Ok({ remoteOrderId: id, remoteUrl: `${apiBase(store, version, options.baseUrl)}/orders/${id}.json` });
390
467
  },
391
468
  async fetchOrderStatus(store, remoteId) {
392
469
  const token = credentials(store);
393
470
  if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required.", retriable: false });
394
- const result = await request<{ order: { financial_status?: string | null; fulfillment_status?: string | null; cancelled_at?: string | null } }>(fetchImpl, `${apiBase(store, version)}/orders/${encodeURIComponent(remoteId)}.json`, token);
471
+ const result = await request<{ order: { financial_status?: string | null; fulfillment_status?: string | null; cancelled_at?: string | null } }>(fetchImpl, `${apiBase(store, version, options.baseUrl)}/orders/${encodeURIComponent(remoteId)}.json`, token);
395
472
  return result.ok ? Ok(shopifyStatus(result.value.data.order)) : result;
396
473
  },
397
474
  async verifyWebhook(_store, request) {
@@ -437,7 +514,7 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
437
514
  const token = credentials(store);
438
515
  if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
439
516
  for (const topic of topics) {
440
- const result = await request<{ webhook: unknown }>(fetchImpl, `${apiBase(store, version)}/webhooks.json`, token, {
517
+ const result = await request<{ webhook: unknown }>(fetchImpl, `${apiBase(store, version, options.baseUrl)}/webhooks.json`, token, {
441
518
  method: "POST",
442
519
  headers: { "content-type": "application/json" },
443
520
  body: JSON.stringify({ webhook: { topic, address: callbackUrl, format: "json" } }),