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