@porulle/adapter-shopify 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/adapter-shopify",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -10,15 +10,15 @@
10
10
  }
11
11
  },
12
12
  "dependencies": {
13
- "@porulle/core": "0.11.0"
13
+ "@porulle/core": "0.13.0"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@types/node": "^24.5.2",
17
17
  "eslint": "^9.39.1",
18
18
  "typescript": "5.9.2",
19
19
  "vitest": "^3.2.4",
20
- "@porulle/eslint-config": "0.1.0",
21
- "@porulle/typescript-config": "0.1.0"
20
+ "@porulle/typescript-config": "0.1.0",
21
+ "@porulle/eslint-config": "0.1.0"
22
22
  },
23
23
  "publishConfig": {
24
24
  "access": "public"
package/src/index.ts CHANGED
@@ -6,11 +6,26 @@ import type {
6
6
  ChannelConnector,
7
7
  ChannelConnectorError,
8
8
  ChannelInventoryLevel,
9
+ ChannelPushCatalogItem,
10
+ ChannelPushCatalogResult,
9
11
  ChannelStore,
10
12
  ChannelOrderSlice,
11
13
  ChannelOrderStatus,
12
14
  Result,
13
15
  } from "@porulle/core";
16
+ import {
17
+ pushCatalog as executePushCatalog,
18
+ } from "./push-catalog.js";
19
+
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";
14
29
 
15
30
  export interface ShopifyConnectorOptions {
16
31
  fetchImpl?: typeof fetch;
@@ -27,6 +42,7 @@ export const REQUIRED_SCOPES = [
27
42
  "read_orders",
28
43
  "write_orders",
29
44
  "read_fulfillments",
45
+ "write_products",
30
46
  ] as const;
31
47
 
32
48
  type ShopifyProduct = {
@@ -126,6 +142,16 @@ function apiBase(store: ChannelStore, version: string): string {
126
142
  return `https://${store.storeDomain.replace(/^https?:\/\//, "").replace(/\/$/, "")}/admin/api/${version}`;
127
143
  }
128
144
 
145
+ function shopifyOAuthStartUrl(appUrl: string, storeDomain: string): string | undefined {
146
+ try {
147
+ const url = new URL("/api/channels/oauth/shopify/start", appUrl);
148
+ url.searchParams.set("shop", storeDomain);
149
+ return url.toString();
150
+ } catch {
151
+ return undefined;
152
+ }
153
+ }
154
+
129
155
  async function request<T>(fetchImpl: typeof fetch, url: string, accessToken: string, init?: RequestInit): Promise<Result<{ data: T; response: Response }>> {
130
156
  try {
131
157
  const response = await fetchImpl(url, {
@@ -183,13 +209,29 @@ function oauthError(code: string, message: string): Result<never, ChannelConnect
183
209
  return Err({ code, message, retriable: false });
184
210
  }
185
211
 
212
+ export function shopifyReauthorizeUrl(
213
+ options: ShopifyConnectorOptions,
214
+ params: {
215
+ storeDomain: string;
216
+ state: string;
217
+ redirectUri: string;
218
+ callbackUri: string;
219
+ scopes?: string[];
220
+ },
221
+ ): Result<string, ChannelConnectorError> {
222
+ return shopifyConnector(options).buildAuthUrl!({
223
+ ...params,
224
+ scopes: params.scopes ?? [],
225
+ });
226
+ }
227
+
186
228
  export function shopifyConnector(options: ShopifyConnectorOptions = {}): ChannelConnector {
187
229
  const fetchImpl = options.fetchImpl ?? fetch;
188
230
  const version = options.apiVersion ?? "2024-10";
189
231
  const currencyCache = new Map<string, Promise<string | undefined>>();
190
232
  return defineChannelConnector({
191
233
  providerId: "shopify",
192
- capabilities: { importCatalog: true, importInventory: true, pushOrder: true, receiveWebhooks: true },
234
+ capabilities: { importCatalog: true, importInventory: true, pushOrder: true, pushCatalog: true, receiveWebhooks: true },
193
235
  buildAuthUrl(params) {
194
236
  if (!options.clientId || !options.clientSecret || !options.appUrl) {
195
237
  return oauthError("SHOPIFY_OAUTH_NOT_CONFIGURED", "Shopify OAuth requires clientId, clientSecret, and appUrl.");
@@ -230,9 +272,12 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
230
272
  body: JSON.stringify({ client_id: options.clientId, client_secret: options.clientSecret, code }),
231
273
  });
232
274
  if (!response.ok) return oauthError("SHOPIFY_TOKEN_EXCHANGE_FAILED", `Shopify token exchange failed (${response.status}).`);
233
- const body = await response.json() as { access_token?: unknown };
275
+ const body = await response.json() as { access_token?: unknown; scope?: unknown };
234
276
  if (typeof body.access_token !== "string" || !body.access_token) return oauthError("SHOPIFY_TOKEN_INVALID", "Shopify token exchange did not return an access token.");
235
- return Ok({ credentials: { accessToken: body.access_token }, storeDomain: shopDomain });
277
+ const grantedScopes = typeof body.scope === "string"
278
+ ? body.scope.split(",").map((scope) => scope.trim()).filter(Boolean)
279
+ : [];
280
+ return Ok({ credentials: { accessToken: body.access_token, grantedScopes }, storeDomain: shopDomain });
236
281
  } catch (error) {
237
282
  return Err({ code: "SHOPIFY_TOKEN_EXCHANGE_FAILED", message: error instanceof Error ? error.message : "Shopify token exchange failed.", retriable: true });
238
283
  }
@@ -313,6 +358,17 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
313
358
  if (!result.ok) return result;
314
359
  return Ok(result.value.data.inventory_levels.map((level) => ({ externalId: String(level.inventory_item_id), available: level.available ?? 0 })));
315
360
  },
361
+ async pushCatalog(store: ChannelStore, items: ChannelPushCatalogItem[], opts?: { dryRun?: boolean }): Promise<Result<ChannelPushCatalogResult, ChannelConnectorError>> {
362
+ const oauthStartUrl = options.appUrl ? shopifyOAuthStartUrl(options.appUrl, store.storeDomain) : undefined;
363
+ return executePushCatalog({
364
+ fetchImpl,
365
+ apiBase: (target) => apiBase(target, version),
366
+ credentials,
367
+ }, store, items, {
368
+ ...(opts?.dryRun === true ? { dryRun: true } : {}),
369
+ ...(oauthStartUrl ? { reauthorizeUrl: oauthStartUrl } : {}),
370
+ });
371
+ },
316
372
  async pushOrder(store, slice: ChannelOrderSlice) {
317
373
  const token = credentials(store);
318
374
  if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required.", retriable: false });