@porulle/adapter-shopify 0.65.0 → 0.66.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/src/index.ts CHANGED
@@ -1,257 +1,116 @@
1
- import { defineChannelConnector, Err, Ok, toMinorUnits } from "@porulle/core";
2
1
  import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { defineChannelConnector, Err, Ok } from "@porulle/core";
3
3
  import type {
4
- ChannelCatalogPage,
5
- ChannelCatalogPrice,
6
4
  ChannelConnector,
7
5
  ChannelConnectorError,
8
6
  ChannelInventoryLevel,
9
- ChannelPushCatalogItem,
10
- ChannelPushCatalogResult,
11
- ChannelStore,
12
7
  ChannelOrderSlice,
13
8
  ChannelOrderStatus,
9
+ ChannelStore,
10
+ ChannelStoreProfile,
14
11
  Result,
15
12
  } from "@porulle/core";
13
+ import { z } from "zod";
14
+ import { readCatalogItems, readCatalogPage } from "./catalog.js";
15
+ import { shopifyGid, shopifyGraphql } from "./graphql.js";
16
+ import type { ShopifyGraphqlTarget } from "./graphql.js";
16
17
  import {
17
- pushCatalog as executePushCatalog,
18
- } from "./push-catalog.js";
18
+ REQUIRED_SCOPES,
19
+ buildAuthorizeUrl,
20
+ exchangeCallback,
21
+ normalizeShopDomain,
22
+ parseShopifyCredentials,
23
+ refreshIfExpiring,
24
+ validShopDomain,
25
+ } from "./oauth.js";
19
26
 
20
- export {
21
- PORULLE_METAFIELD_NAMESPACE,
22
- PUSH_CATALOG_SCOPE,
23
- SHOPIFY_NATIVE_PRODUCT_FIELDS,
24
- SHOPIFY_NATIVE_VARIANT_FIELDS,
25
- shopifyGrantedScopes,
26
- shopifyPushCatalogEnabled,
27
- shopifyWriteProductsScopeMissingError,
28
- } from "./push-catalog.js";
27
+ export { SHOPIFY_API_VERSION } from "./graphql.js";
28
+ export { CATALOG_ITEMS_QUERY, CATALOG_PAGE_QUERY, VARIANTS_PAGE_QUERY } from "./catalog.js";
29
+ export { REQUIRED_SCOPES, normalizeShopDomain, parseShopifyCredentials } from "./oauth.js";
30
+ export type { ShopifyCredentials } from "./oauth.js";
29
31
 
30
32
  export interface ShopifyConnectorOptions {
33
+ clientId: string;
34
+ clientSecret: string;
31
35
  fetchImpl?: typeof fetch;
32
- apiVersion?: string;
33
- clientId?: string;
34
- clientSecret?: string;
35
- appUrl?: string;
36
- scopes?: string[];
37
36
  /**
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.
37
+ * Where a shop's Admin API lives. Production omits it: `https://{shop}`. A test points it at a
38
+ * stand-in that serves every shop under its own path — `(shop) => \`${mock}/shopify/${shop}\`` —
39
+ * so the shop's identity still rides in the request exactly as it does in production, and the
40
+ * code exercised by a test is the code that runs. There is no mock flag inside this adapter.
54
41
  */
55
- baseUrl?: string;
42
+ shopOrigin?: (shopDomain: string) => string;
56
43
  }
57
44
 
58
- export const REQUIRED_SCOPES = [
59
- "read_products",
60
- "read_inventory",
61
- "read_orders",
62
- "write_orders",
63
- "read_fulfillments",
64
- "write_products",
65
- ] as const;
45
+ export const INVENTORY_QUERY = `query PorulleInventoryPage($after: String) {
46
+ productVariants(first: 250, after: $after, sortKey: ID) { pageInfo { hasNextPage endCursor } nodes { legacyResourceId inventoryQuantity } }
47
+ }`;
66
48
 
67
- type ShopifyProduct = {
68
- id: number | string;
69
- title: string;
70
- handle?: string;
71
- body_html?: string;
72
- vendor?: string | null;
73
- product_type?: string | null;
74
- tags?: string | null;
75
- status?: string | null;
76
- images?: Array<{
77
- id: number | string;
78
- src: string;
79
- alt?: string | null;
80
- position?: number | null;
81
- variant_ids?: Array<number | string> | null;
82
- }>;
83
- options?: Array<{
84
- name: string;
85
- position?: number | null;
86
- values?: string[];
87
- }>;
88
- variants?: Array<{
89
- id: number | string;
90
- sku?: string | null;
91
- barcode?: string | null;
92
- price?: string | null;
93
- compare_at_price?: string | null;
94
- option1?: string | null;
95
- option2?: string | null;
96
- option3?: string | null;
97
- grams?: number | null;
98
- weight?: number | null;
99
- weight_unit?: string | null;
100
- /** Read-only aggregate available across ALL the shop's locations (Shopify's ProductVariant). */
101
- inventory_quantity?: number | null;
102
- /** Shopify's inventory item: what `inventory_levels/update` webhooks name, never the variant id. */
103
- inventory_item_id?: number | string | null;
104
- }>;
105
- };
49
+ export const VARIANT_INVENTORY_QUERY = `query PorulleVariantInventory($ids: [ID!]!) {
50
+ nodes(ids: $ids) { ... on ProductVariant { legacyResourceId inventoryQuantity } }
51
+ }`;
106
52
 
107
- function normalizeCurrency(value: unknown): string | undefined {
108
- if (typeof value !== "string" || value.trim() === "") return undefined;
109
- return value.trim().toUpperCase();
110
- }
53
+ export const STORE_PROFILE_QUERY = `query PorulleStoreProfile {
54
+ shop { name currencyCode myshopifyDomain primaryDomain { host } }
55
+ }`;
111
56
 
112
- function pricesForVariant(
113
- variant: NonNullable<ShopifyProduct["variants"]>[number],
114
- currency: string | undefined,
115
- ): ChannelCatalogPrice[] | undefined {
116
- if (!currency) return undefined;
117
- const amount = toMinorUnits(variant.price, currency);
118
- if (amount === undefined) return undefined;
119
- const compareAtAmount = toMinorUnits(variant.compare_at_price, currency);
120
- return [{
121
- currency,
122
- amount,
123
- ...(compareAtAmount !== undefined && compareAtAmount !== amount ? { compareAtAmount } : {}),
124
- }];
125
- }
57
+ export const ORDER_CREATE_MUTATION = `mutation PorulleOrderCreate($order: OrderCreateOrderInput!, $options: OrderCreateOptionsInput) {
58
+ orderCreate(order: $order, options: $options) { order { legacyResourceId } userErrors { field message } }
59
+ }`;
126
60
 
127
- /**
128
- * Shopify carries a variant's weight twice: `grams`, which it normalises itself,
129
- * and `weight` with a `weight_unit`. Prefer `grams`; fall back to converting
130
- * `weight` only when `grams` is absent or zero.
131
- *
132
- * Returns `undefined` — never `0` — when no weight is known, so the caller omits
133
- * the key entirely. A written `0` is indistinguishable from a genuinely
134
- * weightless item and would defeat a downstream default-parcel substitution.
135
- *
136
- * An unrecognised `weight_unit` is refused rather than assumed to be grams:
137
- * reading "lbs" (a spelling Shopify does not use, but a proxy might) as grams
138
- * under-prices a parcel by a factor of 453.
139
- */
140
- function weightGramsForVariant(
141
- variant: NonNullable<ShopifyProduct["variants"]>[number],
142
- ): number | undefined {
143
- const { grams, weight, weight_unit: weightUnit } = variant;
61
+ export const ORDER_BY_SOURCE_QUERY = `query PorulleOrderBySource($query: String!) {
62
+ orders(first: 1, query: $query) { nodes { legacyResourceId } }
63
+ }`;
144
64
 
145
- if (typeof grams === "number" && Number.isFinite(grams) && grams > 0) {
146
- return Math.round(grams);
147
- }
65
+ export const ORDER_STATUS_QUERY = `query PorulleOrderStatus($id: ID!) {
66
+ order(id: $id) { cancelledAt displayFinancialStatus displayFulfillmentStatus }
67
+ }`;
148
68
 
149
- if (typeof weight === "number" && Number.isFinite(weight) && weight > 0) {
150
- switch (weightUnit ?? "g") {
151
- case "g":
152
- return Math.round(weight);
153
- case "kg":
154
- return Math.round(weight * 1000);
155
- case "oz":
156
- return Math.round(weight * 28.349523125);
157
- case "lb":
158
- return Math.round(weight * 453.59237);
159
- default:
160
- return undefined;
161
- }
162
- }
69
+ const inventoryLevelSchema = z.object({ legacyResourceId: z.string(), inventoryQuantity: z.number().nullable() });
70
+ const inventoryPageSchema = z.object({
71
+ productVariants: z.object({ pageInfo: z.object({ hasNextPage: z.boolean(), endCursor: z.string().nullable() }), nodes: z.array(inventoryLevelSchema) }),
72
+ });
73
+ const variantInventorySchema = z.object({ nodes: z.array(z.union([inventoryLevelSchema, z.object({}).strict(), z.null()])) });
74
+ const storeProfileSchema = z.object({
75
+ shop: z.object({ name: z.string(), currencyCode: z.string(), myshopifyDomain: z.string(), primaryDomain: z.object({ host: z.string() }) }),
76
+ });
77
+ const orderCreateSchema = z.object({
78
+ orderCreate: z.object({
79
+ order: z.object({ legacyResourceId: z.string() }).nullable(),
80
+ userErrors: z.array(z.object({ field: z.array(z.string()).nullable(), message: z.string() })),
81
+ }),
82
+ });
83
+ const orderBySourceSchema = z.object({ orders: z.object({ nodes: z.array(z.object({ legacyResourceId: z.string() })) }) });
84
+ const orderStatusSchema = z.object({
85
+ order: z.object({ cancelledAt: z.string().nullable(), displayFinancialStatus: z.string().nullable(), displayFulfillmentStatus: z.string() }).nullable(),
86
+ });
163
87
 
164
- return undefined;
165
- }
88
+ const SOURCE_NAME = "porulle";
166
89
 
167
- function catalogStatus(value: string | null | undefined): "draft" | "active" | "archived" | undefined {
168
- return value === "draft" || value === "active" || value === "archived" ? value : undefined;
169
- }
90
+ type VariantLevel = z.infer<typeof inventoryLevelSchema>;
170
91
 
171
- function slugify(value: string): string {
172
- return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
92
+ /** `nodes` answers null for a deleted id and `{}` for an id of another type; neither is a level. */
93
+ function isVariantLevel(node: VariantLevel | Record<string, never> | null): node is VariantLevel {
94
+ return node !== null && typeof node.legacyResourceId === "string";
173
95
  }
174
96
 
175
- async function fetchShopCurrency(fetchImpl: typeof fetch, url: string, accessToken: string): Promise<string | undefined> {
176
- const result = await request<{ shop?: { currency?: string | null } }>(fetchImpl, url, accessToken);
177
- return result.ok ? normalizeCurrency(result.value.data.shop?.currency) : undefined;
97
+ /** Stock is never negative here: an oversold variant reads as none available. */
98
+ function level(node: z.infer<typeof inventoryLevelSchema>): ChannelInventoryLevel {
99
+ return { externalId: node.legacyResourceId, available: Math.max(0, node.inventoryQuantity ?? 0) };
178
100
  }
179
101
 
180
- /** `baseUrl` when given, else the store's own origin. The single place that choice is made. */
181
- function shopOrigin(storeDomain: string, baseUrl: string | undefined): string {
182
- if (baseUrl !== undefined && baseUrl !== "") return baseUrl.replace(/\/$/, "");
183
- return `https://${storeDomain.replace(/^https?:\/\//, "").replace(/\/$/, "")}`;
102
+ function money(minor: number, currency: string): { shopMoney: { amount: string; currencyCode: string } } {
103
+ return { shopMoney: { amount: (minor / 100).toFixed(2), currencyCode: currency } };
184
104
  }
185
105
 
186
- function apiBase(store: ChannelStore, version: string, baseUrl?: string): string {
187
- return `${shopOrigin(store.storeDomain, baseUrl)}/admin/api/${version}`;
188
- }
189
-
190
- function shopifyOAuthStartUrl(appUrl: string, storeDomain: string): string | undefined {
191
- try {
192
- const url = new URL("/api/channels/oauth/shopify/start", appUrl);
193
- url.searchParams.set("shop", storeDomain);
194
- return url.toString();
195
- } catch {
196
- return undefined;
197
- }
198
- }
199
-
200
- /**
201
- * The `rel="next"` target of a REST response's `Link` header, or null on the last page.
202
- *
203
- * RFC 8288 permits the relation as a quoted string OR a bare token, and this reads both. It read
204
- * only `rel="next"` once, and the asymmetry is the argument rather than the likelihood: a reader
205
- * that accepts only the quoted form and meets `rel=next` does not throw — it finds no next link,
206
- * ends the walk, and reports a SUCCESSFUL walk of a partial list. Silent truncation. Accepting both
207
- * cannot make a malformed header parse as a valid one, so the permissive direction has no cost.
208
- */
209
- function nextPageUrl(response: Response): string | null {
210
- const link = response.headers.get("link") ?? "";
211
- return link.match(/<([^>]+)>;\s*rel=(?:"next"|next)(?:\s*(?:,|$))/)?.[1] ?? null;
212
- }
213
-
214
- /** The `{ variant: { id, inventory_quantity } }` a `variants/{id}.json` answers, read without a cast. */
215
- function variantInventoryOf(body: unknown): { id: number | string; inventory_quantity: number | null } | undefined {
216
- if (typeof body !== "object" || body === null || !("variant" in body)) return undefined;
217
- const variant: unknown = body.variant;
218
- if (typeof variant !== "object" || variant === null || !("id" in variant)) return undefined;
219
- const id: unknown = variant.id;
220
- if (typeof id !== "number" && typeof id !== "string") return undefined;
221
- const quantity: unknown = "inventory_quantity" in variant ? variant.inventory_quantity : null;
222
- return { id, inventory_quantity: typeof quantity === "number" ? quantity : null };
223
- }
224
-
225
- /** Up to this many variant ids are read one `variants/{id}.json` each (the order-time check);
226
- * more are answered from one walk of the catalogue. */
227
- const PER_VARIANT_INVENTORY_READS = 25;
228
-
229
- async function request<T>(fetchImpl: typeof fetch, url: string, accessToken: string, init?: RequestInit): Promise<Result<{ data: T; response: Response }>> {
230
- try {
231
- const response = await fetchImpl(url, {
232
- ...init,
233
- headers: { accept: "application/json", ...(init?.headers ?? {}), "x-shopify-access-token": accessToken },
234
- });
235
- if (!response.ok) return Err({ code: "SHOPIFY_API_FAILED", message: `Shopify API request failed (${response.status}) for ${url}.`, retriable: response.status >= 500 });
236
- return Ok({ data: await response.json() as T, response });
237
- } catch (error) {
238
- return Err({ code: "SHOPIFY_API_FAILED", message: error instanceof Error ? error.message : "Shopify API request failed.", retriable: true });
239
- }
240
- }
241
-
242
- function shopifyStatus(order: { financial_status?: string | null; fulfillment_status?: string | null; cancelled_at?: string | null }): ChannelOrderStatus {
243
- if (order.cancelled_at) return { status: "cancelled" };
244
- if (order.fulfillment_status === "fulfilled") return { status: "fulfilled" };
245
- if (order.financial_status === "paid" || order.financial_status === "partially_paid") return { status: "confirmed" };
246
- if (order.financial_status === "refunded" || order.financial_status === "voided") return { status: "failed" };
106
+ function orderStatus(order: NonNullable<z.infer<typeof orderStatusSchema>["order"]>): ChannelOrderStatus {
107
+ if (order.cancelledAt) return { status: "cancelled" };
108
+ if (order.displayFulfillmentStatus === "FULFILLED") return { status: "fulfilled" };
109
+ if (order.displayFinancialStatus === "PAID" || order.displayFinancialStatus === "PARTIALLY_PAID") return { status: "confirmed" };
110
+ if (order.displayFinancialStatus === "REFUNDED" || order.displayFinancialStatus === "VOIDED") return { status: "failed" };
247
111
  return { status: "pending" };
248
112
  }
249
113
 
250
- function credentials(store: ChannelStore): string | undefined {
251
- const accessToken = store.credentials.accessToken;
252
- return typeof accessToken === "string" && accessToken.length > 0 ? accessToken : undefined;
253
- }
254
-
255
114
  function validBase64Hmac(secret: string, body: string, signature: string | null): boolean {
256
115
  if (!signature) return false;
257
116
  const expected = createHmac("sha256", secret).update(body).digest();
@@ -259,337 +118,197 @@ function validBase64Hmac(secret: string, body: string, signature: string | null)
259
118
  return actual.length === expected.length && timingSafeEqual(actual, expected);
260
119
  }
261
120
 
262
- function validShopDomain(value: string): boolean {
263
- return /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.myshopify\.com$/i.test(value);
264
- }
265
-
266
- function oauthHmacMessage(searchParams: URLSearchParams): string {
267
- return [...searchParams.entries()]
268
- .filter(([key]) => key !== "hmac")
269
- .sort(([left], [right]) => left.localeCompare(right))
270
- .map(([key, value]) => `${key}=${value}`)
271
- .join("&");
272
- }
273
-
274
- function validOAuthHmac(searchParams: URLSearchParams, secret: string): boolean {
275
- const provided = searchParams.get("hmac");
276
- if (!provided || !/^[a-f0-9]+$/i.test(provided)) return false;
277
- const expected = createHmac("sha256", secret).update(oauthHmacMessage(searchParams)).digest();
278
- const actual = Buffer.from(provided, "hex");
279
- return actual.length === expected.length && timingSafeEqual(actual, expected);
280
- }
281
-
282
- function oauthError(code: string, message: string): Result<never, ChannelConnectorError> {
283
- return Err({ code, message, retriable: false });
284
- }
121
+ const credentialsRequired: ChannelConnectorError = { code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "The store holds no Shopify access token; it must be reconnected.", retriable: false };
285
122
 
286
- export function shopifyReauthorizeUrl(
287
- options: ShopifyConnectorOptions,
288
- params: {
289
- storeDomain: string;
290
- state: string;
291
- redirectUri: string;
292
- callbackUri: string;
293
- scopes?: string[];
294
- },
295
- ): Result<string, ChannelConnectorError> {
296
- return shopifyConnector(options).buildAuthUrl!({
297
- ...params,
298
- scopes: params.scopes ?? [],
299
- });
300
- }
301
-
302
- export function shopifyConnector(options: ShopifyConnectorOptions = {}): ChannelConnector {
123
+ export function shopifyConnector(options: ShopifyConnectorOptions): ChannelConnector {
303
124
  const fetchImpl = options.fetchImpl ?? fetch;
304
- /** One `products.json` page of variant stock (`inventory_quantity`, all locations summed), keyed by variant id. */
305
- const inventoryPage = async (store: ChannelStore, cursor: string | null): Promise<Result<{ levels: ChannelInventoryLevel[]; nextCursor: string | null }>> => {
306
- const token = credentials(store);
307
- if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
308
- const url = cursor ?? `${apiBase(store, version, options.baseUrl)}/products.json?limit=250&fields=id,variants`;
309
- const result = await request<{ products: ShopifyProduct[] }>(fetchImpl, url, token);
310
- if (!result.ok) return result;
311
- const levels = result.value.data.products.flatMap((product) => (product.variants ?? [])
312
- .map((variant): ChannelInventoryLevel => ({ externalId: String(variant.id), available: Math.max(0, variant.inventory_quantity ?? 0) })));
313
- return Ok({ levels, nextCursor: nextPageUrl(result.value.response) });
125
+ const origin = (shopDomain: string): string => options.shopOrigin?.(shopDomain) ?? `https://${shopDomain}`;
126
+ const target = (store: ChannelStore): ShopifyGraphqlTarget | undefined => {
127
+ const credentials = parseShopifyCredentials(store.credentials);
128
+ return credentials ? { fetchImpl, origin: origin(store.storeDomain), accessToken: credentials.accessToken } : undefined;
314
129
  };
315
- const version = options.apiVersion ?? "2024-10";
316
- const currencyCache = new Map<string, Promise<string | undefined>>();
130
+
317
131
  return defineChannelConnector({
318
132
  providerId: "shopify",
319
- capabilities: { importCatalog: true, importInventory: true, pushOrder: true, pushCatalog: true, receiveWebhooks: true },
133
+ capabilities: { importCatalog: true, importInventory: true, pushOrder: true, receiveWebhooks: true },
134
+ normalizeStoreDomain: normalizeShopDomain,
320
135
  buildAuthUrl(params) {
321
- if (!options.clientId || !options.clientSecret || !options.appUrl) {
322
- return oauthError("SHOPIFY_OAUTH_NOT_CONFIGURED", "Shopify OAuth requires clientId, clientSecret, and appUrl.");
323
- }
324
- const shopDomain = params.storeDomain.toLowerCase();
325
- if (!validShopDomain(shopDomain)) return oauthError("SHOPIFY_INVALID_STORE_DOMAIN", "Shopify storeDomain must be a *.myshopify.com domain.");
326
- const scopes = [...new Set([...REQUIRED_SCOPES, ...(options.scopes ?? []), ...params.scopes])];
327
- const url = new URL(`${shopOrigin(shopDomain, options.baseUrl)}/admin/oauth/authorize`);
328
- url.searchParams.set("client_id", options.clientId);
329
- url.searchParams.set("scope", scopes.join(","));
330
- url.searchParams.set("redirect_uri", params.redirectUri);
331
- url.searchParams.set("state", params.state);
332
- return Ok(url.toString());
136
+ const shop = params.storeDomain.toLowerCase();
137
+ if (!validShopDomain(shop)) return Err({ code: "SHOPIFY_INVALID_STORE_DOMAIN", message: "A Shopify store is named by its *.myshopify.com domain.", retriable: false });
138
+ return Ok(buildAuthorizeUrl({ origin: origin(shop), clientId: options.clientId, scopes: REQUIRED_SCOPES, redirectUri: params.redirectUri, state: params.state }));
333
139
  },
334
140
  async completeAuth(request, ctx) {
335
- if (!options.clientId || !options.clientSecret || !options.appUrl) {
336
- return oauthError("SHOPIFY_OAUTH_NOT_CONFIGURED", "Shopify OAuth requires clientId, clientSecret, and appUrl.");
337
- }
338
- const url = new URL(request.url);
339
- const shopDomain = ctx.storeDomain.toLowerCase();
340
- const callbackShop = url.searchParams.get("shop")?.toLowerCase();
341
- if (!validShopDomain(shopDomain) || callbackShop !== shopDomain) {
342
- return oauthError("SHOPIFY_INVALID_STORE_DOMAIN", "Shopify storeDomain must be a *.myshopify.com domain.");
343
- }
344
- if (!validOAuthHmac(url.searchParams, options.clientSecret)) {
345
- return oauthError("SHOPIFY_INVALID_OAUTH_HMAC", "Shopify OAuth callback HMAC is invalid.");
346
- }
347
- const timestamp = Number(url.searchParams.get("timestamp"));
348
- if (!Number.isInteger(timestamp) || Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) {
349
- return oauthError("SHOPIFY_STALE_OAUTH_CALLBACK", "Shopify OAuth callback timestamp is stale.");
350
- }
351
- const code = url.searchParams.get("code");
352
- if (!code) return oauthError("SHOPIFY_OAUTH_CODE_REQUIRED", "Shopify OAuth callback code is required.");
353
- try {
354
- const response = await fetchImpl(`${shopOrigin(shopDomain, options.baseUrl)}/admin/oauth/access_token`, {
355
- method: "POST",
356
- headers: { accept: "application/json", "content-type": "application/json" },
357
- body: JSON.stringify({ client_id: options.clientId, client_secret: options.clientSecret, code }),
358
- });
359
- if (!response.ok) return oauthError("SHOPIFY_TOKEN_EXCHANGE_FAILED", `Shopify token exchange failed (${response.status}).`);
360
- const body = await response.json() as { access_token?: unknown; scope?: unknown };
361
- if (typeof body.access_token !== "string" || !body.access_token) return oauthError("SHOPIFY_TOKEN_INVALID", "Shopify token exchange did not return an access token.");
362
- const grantedScopes = typeof body.scope === "string"
363
- ? body.scope.split(",").map((scope) => scope.trim()).filter(Boolean)
364
- : [];
365
- return Ok({ credentials: { accessToken: body.access_token, grantedScopes }, storeDomain: shopDomain });
366
- } catch (error) {
367
- return Err({ code: "SHOPIFY_TOKEN_EXCHANGE_FAILED", message: error instanceof Error ? error.message : "Shopify token exchange failed.", retriable: true });
368
- }
369
- },
370
- async importCatalog(store, cursor): Promise<Result<ChannelCatalogPage>> {
371
- const token = credentials(store);
372
- if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
373
- const currencyUrl = `${apiBase(store, version, options.baseUrl)}/shop.json`;
374
- const currencyKey = apiBase(store, version, options.baseUrl);
375
- let currencyPromise = currencyCache.get(currencyKey);
376
- if (!currencyPromise) {
377
- currencyPromise = fetchShopCurrency(fetchImpl, currencyUrl, token);
378
- currencyCache.set(currencyKey, currencyPromise);
379
- }
380
- const currency = await currencyPromise;
381
- const url = cursor ?? `${apiBase(store, version, options.baseUrl)}/products.json?limit=250`;
382
- const result = await request<{ products: ShopifyProduct[] }>(fetchImpl, url, token);
383
- if (!result.ok) return result;
384
- const next = nextPageUrl(result.value.response);
385
- return Ok({
386
- items: result.value.data.products.map((product) => {
387
- const options = product.options?.map((option, index) => ({
388
- name: option.name,
389
- displayName: option.name,
390
- ...(option.position != null ? { sortOrder: option.position } : { sortOrder: index }),
391
- values: (option.values ?? []).map((value, valueIndex) => ({ value, displayValue: value, sortOrder: valueIndex })),
392
- }));
393
- const variants = (product.variants ?? []).map((variant) => {
394
- const selectors = [variant.option1, variant.option2, variant.option3];
395
- const optionValues = Object.fromEntries((product.options ?? []).slice(0, 3).flatMap((option, index) => {
396
- const value = selectors[index];
397
- return value != null && value !== "" ? [[option.name, value] as const] : [];
398
- }));
399
- const prices = pricesForVariant(variant, currency);
400
- const weightGrams = weightGramsForVariant(variant);
401
- return {
402
- externalId: String(variant.id),
403
- ...(variant.sku ? { sku: variant.sku } : {}),
404
- ...(variant.barcode ? { barcode: variant.barcode } : {}),
405
- ...(Object.keys(optionValues).length > 0 ? { optionValues } : {}),
406
- ...(prices ? { prices } : {}),
407
- // The inventory item id lets a stock webhook, which names only the item, find this variant.
408
- ...(weightGrams !== undefined || (variant.inventory_item_id !== undefined && variant.inventory_item_id !== null)
409
- ? { metadata: {
410
- ...(weightGrams !== undefined ? { weightGrams } : {}),
411
- ...(variant.inventory_item_id !== undefined && variant.inventory_item_id !== null ? { inventoryItemId: String(variant.inventory_item_id) } : {}),
412
- } }
413
- : {}),
414
- };
415
- });
416
- const category = product.product_type ? slugify(product.product_type) : "";
417
- const status = catalogStatus(product.status);
418
- return {
419
- externalId: String(product.id),
420
- slug: product.handle ?? String(product.id),
421
- title: product.title,
422
- attributes: [{ locale: "en", title: product.title, ...(product.body_html != null ? { description: product.body_html } : {}) }],
423
- variants,
424
- ...(product.images ? {
425
- images: product.images.map((image, index) => ({
426
- externalId: String(image.id),
427
- url: image.src,
428
- ...(image.alt != null ? { alt: image.alt } : {}),
429
- role: index === 0 ? "primary" as const : "gallery" as const,
430
- ...(image.position != null ? { sortOrder: image.position } : {}),
431
- ...(image.variant_ids != null ? { variantExternalIds: image.variant_ids.map(String) } : {}),
432
- })),
433
- } : {}),
434
- ...(options ? { options } : {}),
435
- ...(product.tags != null ? { tags: product.tags.split(",").map((tag) => tag.trim()).filter(Boolean) } : {}),
436
- ...(product.vendor ? { brand: product.vendor } : {}),
437
- ...(category ? { categories: [category] } : {}),
438
- ...(status ? { status } : {}),
439
- };
440
- }),
441
- nextCursor: next,
141
+ const shop = ctx.storeDomain.toLowerCase();
142
+ const credentials = await exchangeCallback({
143
+ fetchImpl,
144
+ origin: origin(shop),
145
+ clientId: options.clientId,
146
+ clientSecret: options.clientSecret,
147
+ callbackUrl: new URL(request.url),
148
+ expectedShop: shop,
149
+ requiredScopes: REQUIRED_SCOPES,
150
+ now: Date.now(),
442
151
  });
152
+ if (!credentials.ok) return credentials;
153
+ return Ok({ credentials: { ...credentials.value }, storeDomain: shop });
154
+ },
155
+ async liveCredentials(store) {
156
+ const credentials = parseShopifyCredentials(store.credentials);
157
+ if (!credentials) return Err(credentialsRequired);
158
+ const refreshed = await refreshIfExpiring({ fetchImpl, origin: origin(store.storeDomain), clientId: options.clientId, clientSecret: options.clientSecret, credentials, now: Date.now() });
159
+ if (!refreshed.ok) return refreshed;
160
+ return Ok(refreshed.value === null ? null : { ...refreshed.value });
161
+ },
162
+ async fetchStoreProfile(store): Promise<Result<ChannelStoreProfile, ChannelConnectorError>> {
163
+ const shop = target(store);
164
+ if (!shop) return Err(credentialsRequired);
165
+ const profile = await shopifyGraphql(shop, STORE_PROFILE_QUERY, {}, storeProfileSchema);
166
+ if (!profile.ok) return profile;
167
+ const { name, currencyCode, myshopifyDomain, primaryDomain } = profile.value.shop;
168
+ // Both hosts serve the shop's own storefront, and Shopify attests to both by answering this
169
+ // query for the token the merchant granted: no separate proof of ownership is needed.
170
+ return Ok({ name, currency: currencyCode, storefrontHosts: [...new Set([myshopifyDomain.toLowerCase(), primaryDomain.host.toLowerCase()])] });
171
+ },
172
+ async importCatalog(store, cursor) {
173
+ const shop = target(store);
174
+ if (!shop) return Err(credentialsRequired);
175
+ return readCatalogPage(shop, cursor);
176
+ },
177
+ async fetchCatalogItems(store, externalIds) {
178
+ const shop = target(store);
179
+ if (!shop) return Err(credentialsRequired);
180
+ return readCatalogItems(shop, externalIds);
443
181
  },
444
182
  /**
445
- * Stock per VARIANT, keyed by the variant id every connector call site matches on.
446
- *
447
- * Read from the variant's `inventory_quantity`, never from `inventory_levels.json`: that
448
- * endpoint requires `inventory_item_ids` or `location_ids`, takes at most 50 ids, and keys
449
- * levels by INVENTORY ITEM id, which is not the variant id — so it answered a real store
450
- * nothing the connector could match, and the sim (whose mock was laxer) one 250-row page.
451
- * https://shopify.dev/docs/api/admin-rest/latest/resources/inventorylevel
452
- *
453
- * `inventory_quantity` sums ALL locations, so a store with a non-selling location overstates
454
- * sellable stock; per-location stock would need InventoryLevel by location. Negative stock
455
- * (oversold) reads as 0, as it is stored.
456
- *
457
- * A few ids (the order-time check) cost one `variants/{id}.json` each; a variant Shopify no
458
- * longer has is omitted, which the caller treats as unconfirmed. More ids, or none (a full
459
- * sync), are answered from one walk of `products.json`, every page: ceil(products / 250).
183
+ * Stock per VARIANT (the id every connector call site matches on), from `inventoryQuantity`:
184
+ * available summed over every location, so a store with a non-selling location overstates
185
+ * sellable stock. A few ids (the order-time check) are one `nodes` read; a variant Shopify no
186
+ * longer has is omitted, which the caller treats as unconfirmed. More or none walk every page.
460
187
  */
461
- async fetchInventory(store, ids): Promise<Result<ChannelInventoryLevel[]>> {
462
- const token = credentials(store);
463
- if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
464
- const base = apiBase(store, version, options.baseUrl);
465
- const level = (variant: { id: number | string; inventory_quantity?: number | null }): ChannelInventoryLevel =>
466
- ({ externalId: String(variant.id), available: Math.max(0, variant.inventory_quantity ?? 0) });
467
-
468
- if (ids !== undefined && ids.length <= PER_VARIANT_INVENTORY_READS) {
469
- const levels: ChannelInventoryLevel[] = [];
470
- for (const id of ids) {
471
- const url = `${base}/variants/${encodeURIComponent(id)}.json`;
472
- let response: Response;
473
- try {
474
- response = await fetchImpl(url, { headers: { accept: "application/json", "x-shopify-access-token": token } });
475
- } catch (error) {
476
- return Err({ code: "SHOPIFY_API_FAILED", message: error instanceof Error ? error.message : "Shopify API request failed.", retriable: true });
477
- }
478
- // Gone upstream: omitted, so the caller refuses the line as unconfirmed rather than failing
479
- // every other line of the order with it.
480
- if (response.status === 404) continue;
481
- if (!response.ok) return Err({ code: "SHOPIFY_API_FAILED", message: `Shopify API request failed (${response.status}) for ${url}.`, retriable: response.status >= 500 });
482
- const variant = variantInventoryOf(await response.json());
483
- if (variant === undefined) return Err({ code: "SHOPIFY_API_FAILED", message: `Shopify answered no variant for ${url}.` });
484
- levels.push(level(variant));
485
- }
486
- return Ok(levels);
188
+ async fetchInventory(store, ids) {
189
+ const shop = target(store);
190
+ if (!shop) return Err(credentialsRequired);
191
+ if (ids !== undefined && ids.length <= 250) {
192
+ if (ids.length === 0) return Ok([]);
193
+ const read = await shopifyGraphql(shop, VARIANT_INVENTORY_QUERY, { ids: ids.map((id) => shopifyGid("ProductVariant", id)) }, variantInventorySchema);
194
+ if (!read.ok) return read;
195
+ return Ok(read.value.nodes.filter(isVariantLevel).map(level));
487
196
  }
488
-
489
197
  const wanted = ids === undefined ? undefined : new Set(ids);
490
198
  const levels: ChannelInventoryLevel[] = [];
491
199
  let cursor: string | null = null;
492
- const seen = new Set<string>();
493
200
  do {
494
- const page = await inventoryPage(store, cursor);
201
+ const page: Result<{ levels: ChannelInventoryLevel[]; nextCursor: string | null }, ChannelConnectorError> = await inventoryPage(shop, cursor);
495
202
  if (!page.ok) return page;
496
203
  levels.push(...page.value.levels.filter((entry) => wanted === undefined || wanted.has(entry.externalId)));
497
204
  cursor = page.value.nextCursor;
498
- if (cursor !== null && seen.has(cursor)) return Err({ code: "SHOPIFY_API_FAILED", message: "Shopify pagination repeated a page." });
499
- if (cursor !== null) seen.add(cursor);
500
205
  } while (cursor !== null);
501
206
  return Ok(levels);
502
207
  },
503
- /** One `products.json` page (250 products) of variant stock; the cursor is Shopify's next-page URL. */
504
- fetchInventoryPage: (store, cursor) => inventoryPage(store, cursor),
505
- async pushCatalog(store: ChannelStore, items: ChannelPushCatalogItem[], opts?: { dryRun?: boolean }): Promise<Result<ChannelPushCatalogResult, ChannelConnectorError>> {
506
- const oauthStartUrl = options.appUrl ? shopifyOAuthStartUrl(options.appUrl, store.storeDomain) : undefined;
507
- return executePushCatalog({
508
- fetchImpl,
509
- apiBase: (target) => apiBase(target, version),
510
- credentials,
511
- }, store, items, {
512
- ...(opts?.dryRun === true ? { dryRun: true } : {}),
513
- ...(oauthStartUrl ? { reauthorizeUrl: oauthStartUrl } : {}),
514
- });
208
+ async fetchInventoryPage(store, cursor) {
209
+ const shop = target(store);
210
+ if (!shop) return Err(credentialsRequired);
211
+ return inventoryPage(shop, cursor);
515
212
  },
213
+ /**
214
+ * Creates the paid order in the store. The platform's order id rides as `sourceIdentifier`, and
215
+ * an order already carrying it is answered instead of created again: a retry after a lost
216
+ * response must not put a second paid order in front of the merchant.
217
+ */
516
218
  async pushOrder(store, slice: ChannelOrderSlice) {
517
- const token = credentials(store);
518
- if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required.", retriable: false });
519
- const [firstName, ...lastParts] = slice.customer.name.trim().split(/\s+/);
520
- const result = await request<{ order: { id: number | string; admin_graphql_api_id?: string } }>(fetchImpl, `${apiBase(store, version, options.baseUrl)}/orders.json`, token, {
521
- method: "POST",
522
- headers: { "content-type": "application/json", "idempotency-key": `porulle:${slice.orderId}` },
523
- body: JSON.stringify({ order: {
524
- financial_status: "paid",
525
- line_items: slice.lines.map((line) => ({ variant_id: line.externalVariantId, quantity: line.quantity, price: line.unitPrice / 100 })),
526
- customer: { email: slice.customer.email, first_name: firstName ?? "", last_name: lastParts.join(" ") },
527
- shipping_address: slice.customer.shippingAddress,
528
- transactions: [{ kind: "sale", status: "success", amount: slice.grandTotal / 100 }],
529
- } }),
530
- });
531
- if (!result.ok) return result;
532
- const id = String(result.value.data.order.id);
533
- return Ok({ remoteOrderId: id, remoteUrl: `${apiBase(store, version, options.baseUrl)}/orders/${id}.json` });
219
+ const shop = target(store);
220
+ if (!shop) return Err(credentialsRequired);
221
+ const existing = await shopifyGraphql(shop, ORDER_BY_SOURCE_QUERY, { query: `source_identifier:${JSON.stringify(slice.orderId)}` }, orderBySourceSchema);
222
+ if (!existing.ok) return existing;
223
+ const already = existing.value.orders.nodes[0];
224
+ if (already) return Ok({ remoteOrderId: already.legacyResourceId });
225
+ const [firstName, ...rest] = slice.customer.name.trim().split(/\s+/);
226
+ const address = slice.customer.shippingAddress;
227
+ // Shopify takes a province as its CODE. A region the shopper typed as a name is kept for the
228
+ // courier on the second address line rather than sent where Shopify would reject it.
229
+ const provinceCode = address.region !== undefined && /^[A-Z0-9]{1,3}$/.test(address.region) ? address.region : undefined;
230
+ const regionText = address.region !== undefined && provinceCode === undefined ? address.region : undefined;
231
+ const address2 = [address.line2, regionText].filter((part) => part !== undefined && part !== "").join(", ");
232
+ const shippingAddress = {
233
+ firstName: address.firstName,
234
+ lastName: address.lastName,
235
+ address1: address.line1,
236
+ ...(address2 ? { address2 } : {}),
237
+ city: address.city,
238
+ ...(provinceCode ? { provinceCode } : {}),
239
+ ...(address.postalCode ? { zip: address.postalCode } : {}),
240
+ countryCode: address.countryCode,
241
+ ...(address.phone ? { phone: address.phone } : {}),
242
+ };
243
+ const created = await shopifyGraphql(shop, ORDER_CREATE_MUTATION, {
244
+ order: {
245
+ sourceName: SOURCE_NAME,
246
+ sourceIdentifier: slice.orderId,
247
+ currency: slice.currency,
248
+ email: slice.customer.email,
249
+ financialStatus: "PAID",
250
+ customer: { toUpsert: { email: slice.customer.email, firstName: firstName ?? "", lastName: rest.join(" ") } },
251
+ shippingAddress,
252
+ lineItems: slice.lines.map((line) => ({
253
+ variantId: shopifyGid("ProductVariant", line.externalVariantId),
254
+ quantity: line.quantity,
255
+ priceSet: money(line.unitPrice, slice.currency),
256
+ })),
257
+ transactions: [{ kind: "SALE", status: "SUCCESS", gateway: SOURCE_NAME, amountSet: money(slice.grandTotal, slice.currency) }],
258
+ },
259
+ options: { inventoryBehaviour: "DECREMENT_OBEYING_POLICY", sendReceipt: false, sendFulfillmentReceipt: false },
260
+ }, orderCreateSchema);
261
+ if (!created.ok) return created;
262
+ const { order, userErrors } = created.value.orderCreate;
263
+ if (userErrors.length > 0 || !order) {
264
+ return Err({ code: "SHOPIFY_ORDER_REJECTED", message: `Shopify refused the order: ${userErrors.map((error) => error.message).join("; ") || "no order returned"}.`, retriable: false });
265
+ }
266
+ return Ok({ remoteOrderId: order.legacyResourceId });
534
267
  },
535
268
  async fetchOrderStatus(store, remoteId) {
536
- const token = credentials(store);
537
- if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required.", retriable: false });
538
- 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);
539
- return result.ok ? Ok(shopifyStatus(result.value.data.order)) : result;
540
- },
541
- async verifyWebhook(_store, request) {
542
- const body = await request.text();
543
- // Shopify signs every webhook for an app with the app CLIENT SECRET — there is no
544
- // per-store/per-subscription secret (unlike WooCommerce). Verify against clientSecret.
545
- if (!options.clientSecret) {
546
- return Err({ code: "SHOPIFY_CLIENT_SECRET_MISSING", message: "Shopify clientSecret is required to verify webhooks." });
547
- }
548
- if (!validBase64Hmac(options.clientSecret, body, request.headers.get("x-shopify-hmac-sha256"))) {
549
- return Err({ code: "INVALID_WEBHOOK_SIGNATURE", message: "Invalid Shopify webhook signature." });
550
- }
551
- try {
552
- const data = JSON.parse(body) as unknown;
553
- const id = request.headers.get("x-shopify-event-id");
554
- const type = request.headers.get("x-shopify-topic");
555
- if (!id || !type) return Err({ code: "INVALID_WEBHOOK", message: "Shopify webhook headers are incomplete." });
556
- return Ok({ id, type, data });
557
- } catch {
558
- return Err({ code: "INVALID_WEBHOOK", message: "Shopify webhook body must be valid JSON." });
559
- }
269
+ const shop = target(store);
270
+ if (!shop) return Err(credentialsRequired);
271
+ const read = await shopifyGraphql(shop, ORDER_STATUS_QUERY, { id: `gid://shopify/Order/${remoteId}` }, orderStatusSchema);
272
+ if (!read.ok) return read;
273
+ if (!read.value.order) return Err({ code: "SHOPIFY_ORDER_NOT_FOUND", message: `Shopify has no order ${remoteId}.`, retriable: false });
274
+ return Ok(orderStatus(read.value.order));
560
275
  },
276
+ /**
277
+ * Every Shopify delivery — catalogue, stock, orders, uninstall and the mandatory compliance topics —
278
+ * arrives at ONE app-level address declared in `shopify.app.toml`, signed with the app's client
279
+ * secret (Shopify has no per-store webhook secret). The shop is named by a header, and a delivery
280
+ * is identified by `X-Shopify-Webhook-Id`: `X-Shopify-Event-Id` is shared by every delivery one
281
+ * merchant action produces, so deduplicating on it would drop a second topic as a "duplicate".
282
+ */
561
283
  async verifyAppWebhook(request) {
562
- if (!options.clientSecret) {
563
- return Err({ code: "SHOPIFY_CLIENT_SECRET_MISSING", message: "Shopify clientSecret is required to verify app webhooks.", retriable: false });
564
- }
565
284
  const body = await request.text();
566
285
  if (!validBase64Hmac(options.clientSecret, body, request.headers.get("x-shopify-hmac-sha256"))) {
567
- return Err({ code: "INVALID_APP_WEBHOOK_SIGNATURE", message: "Invalid Shopify app webhook signature.", retriable: false });
286
+ return Err({ code: "INVALID_APP_WEBHOOK_SIGNATURE", message: "Invalid Shopify webhook signature.", retriable: false });
568
287
  }
288
+ const topic = request.headers.get("x-shopify-topic");
289
+ const shopDomain = request.headers.get("x-shopify-shop-domain")?.toLowerCase();
290
+ const id = request.headers.get("x-shopify-webhook-id");
291
+ if (!topic || !shopDomain || !id) return Err({ code: "INVALID_APP_WEBHOOK", message: "Shopify webhook headers are incomplete.", retriable: false });
292
+ let data: unknown;
569
293
  try {
570
- const data = JSON.parse(body) as unknown;
571
- const payload = data as Record<string, unknown>;
572
- const topic = request.headers.get("x-shopify-topic");
573
- const shopDomain = typeof payload.shop_domain === "string" ? payload.shop_domain : "";
574
- if (!topic) return Err({ code: "INVALID_APP_WEBHOOK", message: "Shopify app webhook topic header is missing.", retriable: false });
575
- return Ok({ topic, shopDomain, data });
294
+ data = JSON.parse(body);
576
295
  } catch {
577
- return Err({ code: "INVALID_APP_WEBHOOK", message: "Shopify app webhook body must be valid JSON.", retriable: false });
296
+ return Err({ code: "INVALID_APP_WEBHOOK", message: "Shopify webhook body must be valid JSON.", retriable: false });
578
297
  }
298
+ return Ok({ id, topic, shopDomain, data });
579
299
  },
580
- async registerWebhooks(store: ChannelStore, topics: string[], callbackUrl: string) {
581
- const token = credentials(store);
582
- if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
583
- for (const topic of topics) {
584
- const result = await request<{ webhook: unknown }>(fetchImpl, `${apiBase(store, version, options.baseUrl)}/webhooks.json`, token, {
585
- method: "POST",
586
- headers: { "content-type": "application/json" },
587
- body: JSON.stringify({ webhook: { topic, address: callbackUrl, format: "json" } }),
588
- });
589
- if (!result.ok) return result;
590
- }
591
- return Ok({ registered: topics.length });
300
+ async refundExecute() {
301
+ return Err({ code: "NOT_IMPLEMENTED", message: "Refunds are issued by the platform, not executed in the Shopify store." });
592
302
  },
593
- async refundExecute() { return Err({ code: "NOT_IMPLEMENTED", message: "Shopify refund execution is not implemented in this slice." }); },
594
303
  });
304
+
305
+ async function inventoryPage(shop: ShopifyGraphqlTarget, cursor: string | null): Promise<Result<{ levels: ChannelInventoryLevel[]; nextCursor: string | null }, ChannelConnectorError>> {
306
+ const page = await shopifyGraphql(shop, INVENTORY_QUERY, { after: cursor }, inventoryPageSchema);
307
+ if (!page.ok) return page;
308
+ const { pageInfo, nodes } = page.value.productVariants;
309
+ if (pageInfo.hasNextPage && (!pageInfo.endCursor || pageInfo.endCursor === cursor)) {
310
+ return Err({ code: "SHOPIFY_PAGINATION_STUCK", message: "Shopify answered an inventory page that does not advance.", retriable: false });
311
+ }
312
+ return Ok({ levels: nodes.map(level), nextCursor: pageInfo.hasNextPage ? pageInfo.endCursor : null });
313
+ }
595
314
  }