@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/dist/catalog.d.ts +198 -0
- package/dist/catalog.js +226 -0
- package/dist/graphql.d.ts +31 -0
- package/dist/graphql.js +106 -0
- package/dist/index.d.ts +19 -33
- package/dist/index.js +230 -443
- package/dist/oauth.d.ts +66 -0
- package/dist/oauth.js +170 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +12 -5
- package/src/catalog.ts +245 -0
- package/src/graphql.ts +128 -0
- package/src/index.ts +232 -513
- package/src/oauth.ts +200 -0
- package/dist/push-catalog.d.ts +0 -18
- package/dist/push-catalog.js +0 -502
- package/src/push-catalog.ts +0 -702
package/package.json
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/adapter-shopify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
8
|
-
"
|
|
9
|
-
"types": "./
|
|
10
|
-
|
|
8
|
+
"@porulle/source": "./src/index.ts",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20.19.0"
|
|
11
16
|
},
|
|
12
17
|
"dependencies": {
|
|
13
|
-
"
|
|
18
|
+
"zod": "^4.1.11",
|
|
19
|
+
"@porulle/core": "0.66.0"
|
|
14
20
|
},
|
|
15
21
|
"devDependencies": {
|
|
16
22
|
"@types/node": "^24.5.2",
|
|
17
23
|
"eslint": "^9.39.1",
|
|
24
|
+
"graphql": "^16.11.0",
|
|
18
25
|
"typescript": "5.9.2",
|
|
19
26
|
"vitest": "^3.2.4",
|
|
20
27
|
"@porulle/eslint-config": "0.1.0",
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { Err, Ok, toMinorUnits } from "@porulle/core";
|
|
2
|
+
import type { ChannelCatalogItem, ChannelCatalogPrice, ChannelConnectorError, Result } from "@porulle/core";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { shopifyGid, shopifyGraphql } from "./graphql.js";
|
|
5
|
+
import type { ShopifyGraphqlTarget } from "./graphql.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Products per catalogue page. Measured against Shopify's own demo store through its public GraphQL
|
|
9
|
+
* proxy on 2026-10-03: 25 products with 25 variants and 10 media each requested 320 cost points
|
|
10
|
+
* against a 2,000-point bucket restoring 100/s. 50 keeps a page well under the 1,000-point
|
|
11
|
+
* single-query ceiling while halving the number of calls a large catalogue takes.
|
|
12
|
+
*/
|
|
13
|
+
export const CATALOG_PAGE_PRODUCTS = 50;
|
|
14
|
+
/** Variants read with the product; a product with more is completed by `VARIANTS_PAGE`. */
|
|
15
|
+
const VARIANTS_WITH_PRODUCT = 25;
|
|
16
|
+
/** Images read per product. Import selects a hero and a few gallery images from these. */
|
|
17
|
+
const MEDIA_PER_PRODUCT = 20;
|
|
18
|
+
|
|
19
|
+
const VARIANT_FIELDS = `
|
|
20
|
+
legacyResourceId sku price compareAtPrice inventoryQuantity
|
|
21
|
+
barcodes(first: 1) { nodes { value } }
|
|
22
|
+
selectedOptions { name value }
|
|
23
|
+
media(first: 1) { nodes { id } }
|
|
24
|
+
inventoryItem { legacyResourceId measurement { weight { unit value } } }`;
|
|
25
|
+
|
|
26
|
+
const PRODUCT_FIELDS = `
|
|
27
|
+
legacyResourceId title handle status descriptionHtml vendor productType tags onlineStoreUrl
|
|
28
|
+
category { name }
|
|
29
|
+
options { name position optionValues { name } }
|
|
30
|
+
media(first: ${MEDIA_PER_PRODUCT}) { nodes { id alt mediaContentType ... on MediaImage { image { url } } } }
|
|
31
|
+
variants(first: ${VARIANTS_WITH_PRODUCT}) { pageInfo { hasNextPage endCursor } nodes { ${VARIANT_FIELDS} } }`;
|
|
32
|
+
|
|
33
|
+
export const CATALOG_PAGE_QUERY = `query PorulleCatalogPage($first: Int!, $after: String) {
|
|
34
|
+
shop { currencyCode }
|
|
35
|
+
products(first: $first, after: $after, sortKey: ID) { pageInfo { hasNextPage endCursor } nodes { ${PRODUCT_FIELDS} } }
|
|
36
|
+
}`;
|
|
37
|
+
|
|
38
|
+
export const VARIANTS_PAGE_QUERY = `query PorulleProductVariants($id: ID!, $after: String) {
|
|
39
|
+
product(id: $id) { variants(first: 250, after: $after) { pageInfo { hasNextPage endCursor } nodes { ${VARIANT_FIELDS} } } }
|
|
40
|
+
}`;
|
|
41
|
+
|
|
42
|
+
export const CATALOG_ITEMS_QUERY = `query PorulleCatalogItems($ids: [ID!]!) {
|
|
43
|
+
shop { currencyCode }
|
|
44
|
+
nodes(ids: $ids) { ... on Product { ${PRODUCT_FIELDS} } }
|
|
45
|
+
}`;
|
|
46
|
+
|
|
47
|
+
const pageInfoSchema = z.object({ hasNextPage: z.boolean(), endCursor: z.string().nullable() });
|
|
48
|
+
|
|
49
|
+
const variantSchema = z.object({
|
|
50
|
+
legacyResourceId: z.string(),
|
|
51
|
+
sku: z.string().nullable(),
|
|
52
|
+
price: z.string(),
|
|
53
|
+
compareAtPrice: z.string().nullable(),
|
|
54
|
+
inventoryQuantity: z.number().nullable(),
|
|
55
|
+
barcodes: z.object({ nodes: z.array(z.object({ value: z.string() })) }),
|
|
56
|
+
selectedOptions: z.array(z.object({ name: z.string(), value: z.string() })),
|
|
57
|
+
media: z.object({ nodes: z.array(z.object({ id: z.string() })) }),
|
|
58
|
+
inventoryItem: z.object({
|
|
59
|
+
legacyResourceId: z.string(),
|
|
60
|
+
measurement: z.object({ weight: z.object({ unit: z.string(), value: z.number() }).nullable() }),
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
type ShopifyVariant = z.infer<typeof variantSchema>;
|
|
64
|
+
|
|
65
|
+
const variantConnectionSchema = z.object({ pageInfo: pageInfoSchema, nodes: z.array(variantSchema) });
|
|
66
|
+
|
|
67
|
+
const productSchema = z.object({
|
|
68
|
+
legacyResourceId: z.string(),
|
|
69
|
+
title: z.string(),
|
|
70
|
+
handle: z.string(),
|
|
71
|
+
status: z.string(),
|
|
72
|
+
descriptionHtml: z.string(),
|
|
73
|
+
vendor: z.string(),
|
|
74
|
+
productType: z.string(),
|
|
75
|
+
tags: z.array(z.string()),
|
|
76
|
+
onlineStoreUrl: z.string().nullable(),
|
|
77
|
+
category: z.object({ name: z.string() }).nullable(),
|
|
78
|
+
options: z.array(z.object({ name: z.string(), position: z.number(), optionValues: z.array(z.object({ name: z.string() })) })),
|
|
79
|
+
media: z.object({
|
|
80
|
+
nodes: z.array(z.object({
|
|
81
|
+
id: z.string(),
|
|
82
|
+
alt: z.string().nullable(),
|
|
83
|
+
mediaContentType: z.string(),
|
|
84
|
+
image: z.object({ url: z.string() }).nullable().optional(),
|
|
85
|
+
})),
|
|
86
|
+
}),
|
|
87
|
+
variants: variantConnectionSchema,
|
|
88
|
+
});
|
|
89
|
+
type ShopifyProduct = z.infer<typeof productSchema>;
|
|
90
|
+
|
|
91
|
+
const shopCurrencySchema = z.object({ currencyCode: z.string() });
|
|
92
|
+
|
|
93
|
+
export const catalogPageSchema = z.object({
|
|
94
|
+
shop: shopCurrencySchema,
|
|
95
|
+
products: z.object({ pageInfo: pageInfoSchema, nodes: z.array(productSchema) }),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const variantsPageSchema = z.object({ product: z.object({ variants: variantConnectionSchema }).nullable() });
|
|
99
|
+
|
|
100
|
+
const catalogItemsSchema = z.object({
|
|
101
|
+
shop: shopCurrencySchema,
|
|
102
|
+
// `nodes` answers null for an id that no longer exists, and `{}` for one that is not a Product.
|
|
103
|
+
nodes: z.array(z.union([productSchema, z.object({}).strict(), z.null()])),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const GRAMS_PER_UNIT: Record<string, number> = { GRAMS: 1, KILOGRAMS: 1000, OUNCES: 28.349523125, POUNDS: 453.59237 };
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Grams, or undefined — never 0 — when no weight is known, so the caller omits the key. A written
|
|
110
|
+
* 0 is indistinguishable from a weightless item and would defeat a default-parcel substitution. An
|
|
111
|
+
* unknown unit is refused rather than assumed to be grams.
|
|
112
|
+
*/
|
|
113
|
+
function weightGrams(variant: ShopifyVariant): number | undefined {
|
|
114
|
+
const weight = variant.inventoryItem.measurement.weight;
|
|
115
|
+
if (!weight || !Number.isFinite(weight.value) || weight.value <= 0) return undefined;
|
|
116
|
+
const factor = GRAMS_PER_UNIT[weight.unit];
|
|
117
|
+
return factor === undefined ? undefined : Math.round(weight.value * factor);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function prices(variant: ShopifyVariant, currency: string): ChannelCatalogPrice[] | undefined {
|
|
121
|
+
const amount = toMinorUnits(variant.price, currency);
|
|
122
|
+
if (amount === undefined) return undefined;
|
|
123
|
+
const compareAtAmount = variant.compareAtPrice === null ? undefined : toMinorUnits(variant.compareAtPrice, currency);
|
|
124
|
+
return [{ currency, amount, ...(compareAtAmount !== undefined && compareAtAmount !== amount ? { compareAtAmount } : {}) }];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* UNLISTED is live but hidden from the shop's own search and collections — the merchant chose not to
|
|
129
|
+
* surface it, so it is not surfaced here either. Any status this version does not know is a draft:
|
|
130
|
+
* the safe direction for a value that decides whether a product is shown.
|
|
131
|
+
*/
|
|
132
|
+
function catalogStatus(status: string): "active" | "draft" | "archived" {
|
|
133
|
+
if (status === "ACTIVE") return "active";
|
|
134
|
+
if (status === "ARCHIVED") return "archived";
|
|
135
|
+
return "draft";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function slugify(value: string): string {
|
|
139
|
+
return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function toCatalogItem(product: ShopifyProduct, variants: readonly ShopifyVariant[], currency: string): ChannelCatalogItem {
|
|
143
|
+
const images = product.media.nodes.flatMap((media) => (media.mediaContentType === "IMAGE" && media.image ? [{ id: media.id, url: media.image.url, alt: media.alt }] : []));
|
|
144
|
+
const category = product.productType ? slugify(product.productType) : product.category ? slugify(product.category.name) : "";
|
|
145
|
+
const storefrontUrl = product.onlineStoreUrl;
|
|
146
|
+
return {
|
|
147
|
+
externalId: product.legacyResourceId,
|
|
148
|
+
slug: product.handle,
|
|
149
|
+
title: product.title,
|
|
150
|
+
attributes: [{ locale: "en", title: product.title, ...(product.descriptionHtml ? { description: product.descriptionHtml } : {}) }],
|
|
151
|
+
variants: variants.map((variant) => {
|
|
152
|
+
const optionValues = Object.fromEntries(variant.selectedOptions.map((option) => [option.name, option.value] as const));
|
|
153
|
+
const variantPrices = prices(variant, currency);
|
|
154
|
+
const grams = weightGrams(variant);
|
|
155
|
+
const barcode = variant.barcodes.nodes[0]?.value;
|
|
156
|
+
return {
|
|
157
|
+
externalId: variant.legacyResourceId,
|
|
158
|
+
...(variant.sku ? { sku: variant.sku } : {}),
|
|
159
|
+
...(barcode ? { barcode } : {}),
|
|
160
|
+
...(Object.keys(optionValues).length > 0 ? { optionValues } : {}),
|
|
161
|
+
...(variantPrices ? { prices: variantPrices } : {}),
|
|
162
|
+
// The inventory item id lets a stock webhook, which names only the item, find this variant.
|
|
163
|
+
metadata: { inventoryItemId: variant.inventoryItem.legacyResourceId, ...(grams !== undefined ? { weightGrams: grams } : {}) },
|
|
164
|
+
};
|
|
165
|
+
}),
|
|
166
|
+
images: images.map((image, index) => ({
|
|
167
|
+
externalId: image.id,
|
|
168
|
+
url: image.url,
|
|
169
|
+
...(image.alt ? { alt: image.alt } : {}),
|
|
170
|
+
role: index === 0 ? "primary" as const : "gallery" as const,
|
|
171
|
+
sortOrder: index + 1,
|
|
172
|
+
variantExternalIds: variants.filter((variant) => variant.media.nodes.some((media) => media.id === image.id)).map((variant) => variant.legacyResourceId),
|
|
173
|
+
})),
|
|
174
|
+
options: product.options.map((option) => ({
|
|
175
|
+
name: option.name,
|
|
176
|
+
displayName: option.name,
|
|
177
|
+
sortOrder: option.position,
|
|
178
|
+
values: option.optionValues.map((value, index) => ({ value: value.name, displayValue: value.name, sortOrder: index })),
|
|
179
|
+
})),
|
|
180
|
+
tags: product.tags,
|
|
181
|
+
...(product.vendor ? { brand: product.vendor } : {}),
|
|
182
|
+
...(category ? { categories: [category] } : {}),
|
|
183
|
+
status: catalogStatus(product.status),
|
|
184
|
+
// Only Shopify's own answer. Null means the product is not on the Online Store channel; a URL
|
|
185
|
+
// assembled from the handle would be a guess that 404s on any shop with a custom route.
|
|
186
|
+
...(storefrontUrl ? { storefrontUrl } : {}),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Every variant of `product`: the ones read with it, then the rest a page at a time. */
|
|
191
|
+
async function allVariants(target: ShopifyGraphqlTarget, product: ShopifyProduct): Promise<Result<ShopifyVariant[], ChannelConnectorError>> {
|
|
192
|
+
const variants = [...product.variants.nodes];
|
|
193
|
+
let pageInfo = product.variants.pageInfo;
|
|
194
|
+
const seen = new Set<string>();
|
|
195
|
+
while (pageInfo.hasNextPage && pageInfo.endCursor) {
|
|
196
|
+
if (seen.has(pageInfo.endCursor)) return Err({ code: "SHOPIFY_PAGINATION_STUCK", message: `Shopify repeated a variant page for product ${product.legacyResourceId}.` });
|
|
197
|
+
seen.add(pageInfo.endCursor);
|
|
198
|
+
const page = await shopifyGraphql(target, VARIANTS_PAGE_QUERY, { id: shopifyGid("Product", product.legacyResourceId), after: pageInfo.endCursor }, variantsPageSchema);
|
|
199
|
+
if (!page.ok) return page;
|
|
200
|
+
if (!page.value.product) break;
|
|
201
|
+
variants.push(...page.value.product.variants.nodes);
|
|
202
|
+
pageInfo = page.value.product.variants.pageInfo;
|
|
203
|
+
}
|
|
204
|
+
return Ok(variants);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function toItems(target: ShopifyGraphqlTarget, products: readonly ShopifyProduct[], currency: string): Promise<Result<ChannelCatalogItem[], ChannelConnectorError>> {
|
|
208
|
+
const items: ChannelCatalogItem[] = [];
|
|
209
|
+
for (const product of products) {
|
|
210
|
+
const variants = await allVariants(target, product);
|
|
211
|
+
if (!variants.ok) return variants;
|
|
212
|
+
items.push(toCatalogItem(product, variants.value, currency));
|
|
213
|
+
}
|
|
214
|
+
return Ok(items);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function readCatalogPage(
|
|
218
|
+
target: ShopifyGraphqlTarget,
|
|
219
|
+
cursor: string | undefined,
|
|
220
|
+
): Promise<Result<{ items: ChannelCatalogItem[]; nextCursor: string | null }, ChannelConnectorError>> {
|
|
221
|
+
const page = await shopifyGraphql(target, CATALOG_PAGE_QUERY, { first: CATALOG_PAGE_PRODUCTS, after: cursor ?? null }, catalogPageSchema);
|
|
222
|
+
if (!page.ok) return page;
|
|
223
|
+
const { pageInfo, nodes } = page.value.products;
|
|
224
|
+
if (pageInfo.hasNextPage && (!pageInfo.endCursor || pageInfo.endCursor === cursor)) {
|
|
225
|
+
return Err({ code: "SHOPIFY_PAGINATION_STUCK", message: "Shopify answered a product page that does not advance." });
|
|
226
|
+
}
|
|
227
|
+
const items = await toItems(target, nodes, page.value.shop.currencyCode);
|
|
228
|
+
if (!items.ok) return items;
|
|
229
|
+
return Ok({ items: items.value, nextCursor: pageInfo.hasNextPage ? pageInfo.endCursor : null });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The current state of the named products. An id Shopify no longer has is simply absent. */
|
|
233
|
+
export async function readCatalogItems(target: ShopifyGraphqlTarget, externalIds: readonly string[]): Promise<Result<ChannelCatalogItem[], ChannelConnectorError>> {
|
|
234
|
+
const items: ChannelCatalogItem[] = [];
|
|
235
|
+
for (let offset = 0; offset < externalIds.length; offset += CATALOG_PAGE_PRODUCTS) {
|
|
236
|
+
const ids = externalIds.slice(offset, offset + CATALOG_PAGE_PRODUCTS).map((id) => shopifyGid("Product", id));
|
|
237
|
+
const page = await shopifyGraphql(target, CATALOG_ITEMS_QUERY, { ids }, catalogItemsSchema);
|
|
238
|
+
if (!page.ok) return page;
|
|
239
|
+
const products = page.value.nodes.filter((node): node is ShopifyProduct => node !== null && "legacyResourceId" in node);
|
|
240
|
+
const converted = await toItems(target, products, page.value.shop.currencyCode);
|
|
241
|
+
if (!converted.ok) return converted;
|
|
242
|
+
items.push(...converted.value);
|
|
243
|
+
}
|
|
244
|
+
return Ok(items);
|
|
245
|
+
}
|
package/src/graphql.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Err, Ok } from "@porulle/core";
|
|
2
|
+
import type { ChannelConnectorError, Result } from "@porulle/core";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The one way this adapter talks to a store: the GraphQL Admin API.
|
|
7
|
+
*
|
|
8
|
+
* Shopify made the REST Admin API legacy on 2024-10-01, deprecated its product and variant endpoints
|
|
9
|
+
* in 2024-04, and requires public apps to use GraphQL only — so there is no REST path left here to
|
|
10
|
+
* drift out of date. Every document this adapter sends is validated against Shopify's published
|
|
11
|
+
* schema for `SHOPIFY_API_VERSION` by `scripts/validate-shopify-documents.mjs`.
|
|
12
|
+
*/
|
|
13
|
+
export const SHOPIFY_API_VERSION = "2026-10";
|
|
14
|
+
|
|
15
|
+
/** How long one call may wait for the cost bucket to refill before reporting a retriable failure. */
|
|
16
|
+
const MAX_THROTTLE_WAIT_MS = 10_000;
|
|
17
|
+
const MAX_THROTTLE_RETRIES = 3;
|
|
18
|
+
|
|
19
|
+
export interface ShopifyGraphqlTarget {
|
|
20
|
+
fetchImpl: typeof fetch;
|
|
21
|
+
/** `https://{shop}` in production; the stand-in's per-shop origin under test. */
|
|
22
|
+
origin: string;
|
|
23
|
+
accessToken: string;
|
|
24
|
+
/** Overridable for tests only; production always waits on the real clock. */
|
|
25
|
+
sleep?: (ms: number) => Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const envelopeSchema = z.object({
|
|
29
|
+
data: z.unknown().optional(),
|
|
30
|
+
errors: z.array(z.object({
|
|
31
|
+
message: z.string().optional(),
|
|
32
|
+
extensions: z.object({ code: z.string().optional() }).partial().optional(),
|
|
33
|
+
})).optional(),
|
|
34
|
+
extensions: z.object({
|
|
35
|
+
cost: z.object({
|
|
36
|
+
requestedQueryCost: z.number().optional(),
|
|
37
|
+
throttleStatus: z.object({ currentlyAvailable: z.number(), restoreRate: z.number() }).optional(),
|
|
38
|
+
}).optional(),
|
|
39
|
+
}).optional(),
|
|
40
|
+
});
|
|
41
|
+
type Envelope = z.infer<typeof envelopeSchema>;
|
|
42
|
+
|
|
43
|
+
/** Milliseconds until the bucket holds the query's cost again, from Shopify's own report of it. */
|
|
44
|
+
function throttleWaitMs(envelope: Envelope): number {
|
|
45
|
+
const cost = envelope.extensions?.cost;
|
|
46
|
+
const status = cost?.throttleStatus;
|
|
47
|
+
if (!status || cost?.requestedQueryCost === undefined || status.restoreRate <= 0) return 1_000;
|
|
48
|
+
const deficit = Math.max(0, cost.requestedQueryCost - status.currentlyAvailable);
|
|
49
|
+
return Math.ceil((deficit / status.restoreRate) * 1_000);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const defaultSleep = (ms: number): Promise<void> => new Promise<void>((resolve) => { setTimeout(resolve, ms); });
|
|
53
|
+
|
|
54
|
+
async function readEnvelope(response: Response): Promise<Envelope | undefined> {
|
|
55
|
+
const body: unknown = await response.json().catch(() => undefined);
|
|
56
|
+
const parsed = envelopeSchema.safeParse(body);
|
|
57
|
+
return parsed.success ? parsed.data : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* POST one document and parse its `data` with `schema`.
|
|
62
|
+
*
|
|
63
|
+
* Throttling (`THROTTLED` in `errors`, or HTTP 429) waits for the bucket Shopify reports and
|
|
64
|
+
* retries. Anything else not-ok is an error: a GraphQL `errors` array is never read as an empty
|
|
65
|
+
* answer, because "the store has no products" and "the query failed" must not look alike. A `data`
|
|
66
|
+
* the schema rejects is an error too — that is Shopify's schema moving under a pinned version, and
|
|
67
|
+
* the right response is to stop rather than import a half-read product.
|
|
68
|
+
*/
|
|
69
|
+
export async function shopifyGraphql<T>(
|
|
70
|
+
target: ShopifyGraphqlTarget,
|
|
71
|
+
query: string,
|
|
72
|
+
variables: Record<string, unknown>,
|
|
73
|
+
schema: z.ZodType<T>,
|
|
74
|
+
): Promise<Result<T, ChannelConnectorError>> {
|
|
75
|
+
const sleep = target.sleep ?? defaultSleep;
|
|
76
|
+
const url = `${target.origin}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`;
|
|
77
|
+
let waited = 0;
|
|
78
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
79
|
+
let response: Response;
|
|
80
|
+
try {
|
|
81
|
+
// `manual`, never `error`: workerd implements only `follow` and `manual` and throws on `error`
|
|
82
|
+
// from the Request constructor. A 3xx is then simply not ok, which refuses the redirect.
|
|
83
|
+
response = await target.fetchImpl(url, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
redirect: "manual",
|
|
86
|
+
headers: { accept: "application/json", "content-type": "application/json", "x-shopify-access-token": target.accessToken },
|
|
87
|
+
body: JSON.stringify({ query, variables }),
|
|
88
|
+
});
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return Err({ code: "SHOPIFY_API_FAILED", message: error instanceof Error ? error.message : "Shopify API request failed.", retriable: true });
|
|
91
|
+
}
|
|
92
|
+
if (response.status === 401 || response.status === 403) {
|
|
93
|
+
return Err({ code: "SHOPIFY_UNAUTHORIZED", message: `Shopify refused the access token (${response.status}); the store must be reconnected.`, retriable: false });
|
|
94
|
+
}
|
|
95
|
+
if (response.status !== 429 && !response.ok) {
|
|
96
|
+
return Err({ code: "SHOPIFY_API_FAILED", message: `Shopify API request failed (${response.status}).`, retriable: response.status >= 500 });
|
|
97
|
+
}
|
|
98
|
+
const envelope = await readEnvelope(response);
|
|
99
|
+
if (envelope === undefined && response.status !== 429) {
|
|
100
|
+
return Err({ code: "SHOPIFY_API_FAILED", message: "Shopify answered a body that is not a GraphQL response.", retriable: true });
|
|
101
|
+
}
|
|
102
|
+
const errors = envelope?.errors ?? [];
|
|
103
|
+
if (response.status === 429 || errors.some((error) => error.extensions?.code === "THROTTLED")) {
|
|
104
|
+
const wait = envelope === undefined ? 1_000 : throttleWaitMs(envelope);
|
|
105
|
+
if (attempt >= MAX_THROTTLE_RETRIES || waited + wait > MAX_THROTTLE_WAIT_MS) {
|
|
106
|
+
return Err({ code: "SHOPIFY_THROTTLED", message: "Shopify's API cost bucket is exhausted; retry later.", retriable: true });
|
|
107
|
+
}
|
|
108
|
+
waited += wait;
|
|
109
|
+
await sleep(wait);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (errors.length > 0) {
|
|
113
|
+
const message = errors.map((error) => error.message ?? "unknown error").join("; ");
|
|
114
|
+
const denied = errors.some((error) => error.extensions?.code === "ACCESS_DENIED");
|
|
115
|
+
return Err({ code: denied ? "SHOPIFY_ACCESS_DENIED" : "SHOPIFY_GRAPHQL_ERROR", message: `Shopify GraphQL error: ${message}`, retriable: false });
|
|
116
|
+
}
|
|
117
|
+
const data = schema.safeParse(envelope?.data);
|
|
118
|
+
if (!data.success) {
|
|
119
|
+
return Err({ code: "SHOPIFY_RESPONSE_INVALID", message: `Shopify answered data this adapter cannot read: ${data.error.issues[0]?.message ?? "invalid shape"}.`, retriable: false });
|
|
120
|
+
}
|
|
121
|
+
return Ok(data.data);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `gid://shopify/<Type>/<id>` for a numeric id; the adapter keys everything by the numeric id. */
|
|
126
|
+
export function shopifyGid(type: "Product" | "ProductVariant", id: string): string {
|
|
127
|
+
return `gid://shopify/${type}/${id}`;
|
|
128
|
+
}
|