@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.
@@ -1,702 +0,0 @@
1
- import { Err, Ok } from "@porulle/core";
2
- import type {
3
- ChannelConnectorError,
4
- ChannelPushCatalogField,
5
- ChannelPushCatalogImage,
6
- ChannelPushCatalogImageOutcome,
7
- ChannelPushCatalogItem,
8
- ChannelPushCatalogItemOutcome,
9
- ChannelPushCatalogPreviousField,
10
- ChannelPushCatalogResult,
11
- ChannelStore,
12
- Result,
13
- } from "@porulle/core";
14
-
15
- export const PUSH_CATALOG_SCOPE = "write_products";
16
- export const PORULLE_METAFIELD_NAMESPACE = "porulle";
17
-
18
- export const SHOPIFY_NATIVE_PRODUCT_FIELDS = new Set([
19
- "title",
20
- "body_html",
21
- "vendor",
22
- "product_type",
23
- "handle",
24
- "status",
25
- ]);
26
-
27
- export const SHOPIFY_NATIVE_VARIANT_FIELDS = new Set([
28
- "sku",
29
- "barcode",
30
- ]);
31
-
32
- type ShopifyMetafield = {
33
- id: number | string;
34
- namespace: string;
35
- key: string;
36
- value: string;
37
- type?: string;
38
- };
39
-
40
- type ShopifyProductSnapshot = {
41
- id: number | string;
42
- title?: string | null;
43
- body_html?: string | null;
44
- vendor?: string | null;
45
- product_type?: string | null;
46
- handle?: string | null;
47
- status?: string | null;
48
- tags?: string | null;
49
- updated_at?: string | null;
50
- variants?: ShopifyVariantSnapshot[];
51
- };
52
-
53
- type ShopifyVariantSnapshot = {
54
- id: number | string;
55
- sku?: string | null;
56
- barcode?: string | null;
57
- };
58
-
59
- type ShopifyProductImage = {
60
- id: number | string;
61
- src?: string | null;
62
- alt?: string | null;
63
- position?: number | null;
64
- variant_ids?: Array<number | string> | null;
65
- };
66
-
67
- const SHOPIFY_IMAGE_ROLES = new Set<ChannelPushCatalogImage["role"]>(["primary", "gallery", "thumbnail"]);
68
-
69
- interface PushRequestResult<T> {
70
- ok: true;
71
- data: T;
72
- response: Response;
73
- }
74
-
75
- interface PushRequestFailure {
76
- ok: false;
77
- error: ChannelConnectorError;
78
- status?: number;
79
- response?: Response;
80
- }
81
-
82
- type PushRequestResponse<T> = PushRequestResult<T> | PushRequestFailure;
83
-
84
- export interface PushCatalogDeps {
85
- fetchImpl: typeof fetch;
86
- apiBase: (store: ChannelStore) => string;
87
- credentials: (store: ChannelStore) => string | undefined;
88
- sleep?: (ms: number) => Promise<void>;
89
- }
90
-
91
- export function shopifyGrantedScopes(store: ChannelStore): string[] {
92
- const raw = store.credentials.grantedScopes;
93
- if (Array.isArray(raw)) {
94
- return raw.filter((scope): scope is string => typeof scope === "string" && scope.length > 0);
95
- }
96
- if (typeof raw === "string" && raw.length > 0) {
97
- return raw.split(",").map((scope) => scope.trim()).filter(Boolean);
98
- }
99
- return [];
100
- }
101
-
102
- export function shopifyPushCatalogEnabled(store: ChannelStore): boolean {
103
- return shopifyGrantedScopes(store).includes(PUSH_CATALOG_SCOPE);
104
- }
105
-
106
- export function shopifyWriteProductsScopeMissingError(reauthorizeUrl?: string): ChannelConnectorError {
107
- const suffix = reauthorizeUrl
108
- ? ` Re-authorize at ${reauthorizeUrl}.`
109
- : " Re-authorize the store through the Shopify OAuth start route.";
110
- return {
111
- code: "SHOPIFY_WRITE_PRODUCTS_SCOPE_MISSING",
112
- message: `Shopify store is missing the ${PUSH_CATALOG_SCOPE} scope.${suffix}`,
113
- retriable: false,
114
- };
115
- }
116
-
117
- function parseCallLimit(header: string | null): { current: number; max: number } | undefined {
118
- const match = header?.match(/^(\d+)\/(\d+)$/);
119
- if (!match) return undefined;
120
- return { current: Number(match[1]), max: Number(match[2]) };
121
- }
122
-
123
- function metafieldType(value: unknown): string {
124
- if (typeof value === "boolean") return "boolean";
125
- if (typeof value === "number") return Number.isInteger(value) ? "number_integer" : "number_decimal";
126
- if (typeof value === "object" && value !== null) return "json";
127
- return "single_line_text_field";
128
- }
129
-
130
- function metafieldValue(value: unknown): string {
131
- if (typeof value === "string") return value;
132
- if (typeof value === "number" || typeof value === "boolean") return String(value);
133
- return JSON.stringify(value);
134
- }
135
-
136
- function remoteKey(field: ChannelPushCatalogField): string | undefined {
137
- if (typeof field.remoteKey === "string" && field.remoteKey.length > 0) return field.remoteKey;
138
- return undefined;
139
- }
140
-
141
- function missingRemoteKeyError(field: ChannelPushCatalogField): ChannelConnectorError {
142
- return {
143
- code: "SHOPIFY_REMOTE_KEY_REQUIRED",
144
- message: `Shopify catalog field ${field.fieldPath} requires a remoteKey.`,
145
- retriable: false,
146
- };
147
- }
148
-
149
- function isNativeProductField(field: ChannelPushCatalogField): boolean {
150
- const key = remoteKey(field);
151
- return field.intent === "display" && key !== undefined && SHOPIFY_NATIVE_PRODUCT_FIELDS.has(key);
152
- }
153
-
154
- function isNativeVariantField(field: ChannelPushCatalogField): boolean {
155
- const key = remoteKey(field);
156
- return field.intent === "display" && key !== undefined && SHOPIFY_NATIVE_VARIANT_FIELDS.has(key);
157
- }
158
-
159
- function isMetafieldField(field: ChannelPushCatalogField): boolean {
160
- if (field.intent === "filterable") return true;
161
- if (field.intent === "tag") return false;
162
- return field.intent === "display" && remoteKey(field) !== undefined && !isNativeProductField(field) && !isNativeVariantField(field);
163
- }
164
-
165
- function parseTags(value: string | null | undefined): string[] {
166
- if (!value) return [];
167
- return value.split(",").map((tag) => tag.trim()).filter(Boolean);
168
- }
169
-
170
- function mergeTags(existing: string[], pushed: string[]): string {
171
- return [...new Set([...existing, ...pushed])].join(", ");
172
- }
173
-
174
- function collectPreviousFields(
175
- product: ShopifyProductSnapshot,
176
- metafields: ShopifyMetafield[],
177
- variantMetafields: Map<string, ShopifyMetafield[]>,
178
- item: ChannelPushCatalogItem,
179
- ): ChannelPushCatalogPreviousField[] {
180
- const previous: ChannelPushCatalogPreviousField[] = [];
181
- const metafieldByKey = new Map(metafields
182
- .filter((entry) => entry.namespace === PORULLE_METAFIELD_NAMESPACE)
183
- .map((entry) => [entry.key, entry]));
184
- for (const field of item.fields) {
185
- if (field.intent === "tag") {
186
- const tag = field.value == null ? "" : String(field.value);
187
- previous.push({
188
- fieldPath: field.fieldPath,
189
- value: tag.length > 0 && parseTags(product.tags).includes(tag) ? tag : null,
190
- });
191
- continue;
192
- }
193
- if (isNativeProductField(field)) {
194
- const key = remoteKey(field);
195
- if (key !== undefined) previous.push({ fieldPath: field.fieldPath, value: product[key as keyof ShopifyProductSnapshot] ?? null });
196
- continue;
197
- }
198
- if (isMetafieldField(field)) {
199
- const key = remoteKey(field);
200
- if (key !== undefined) {
201
- const existing = metafieldByKey.get(key);
202
- previous.push({ fieldPath: field.fieldPath, value: existing?.value ?? null });
203
- }
204
- }
205
- }
206
- for (const variant of item.variants ?? []) {
207
- for (const field of variant.fields) {
208
- if (isNativeVariantField(field)) {
209
- const key = remoteKey(field);
210
- const snapshot = product.variants?.find((entry) => String(entry.id) === variant.externalId);
211
- previous.push({ fieldPath: field.fieldPath, value: key !== undefined ? snapshot?.[key as keyof ShopifyVariantSnapshot] ?? null : null });
212
- } else if (isMetafieldField(field)) {
213
- const key = remoteKey(field);
214
- if (key !== undefined) {
215
- const existing = new Map((variantMetafields.get(variant.externalId) ?? [])
216
- .filter((entry) => entry.namespace === PORULLE_METAFIELD_NAMESPACE)
217
- .map((entry) => [entry.key, entry])).get(key);
218
- previous.push({ fieldPath: field.fieldPath, value: existing?.value ?? null });
219
- }
220
- }
221
- }
222
- }
223
- return previous;
224
- }
225
-
226
- async function pushRequest<T>(
227
- deps: PushCatalogDeps,
228
- url: string,
229
- token: string,
230
- init?: RequestInit,
231
- ): Promise<PushRequestResponse<T>> {
232
- const sleep = deps.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
233
- try {
234
- const response = await deps.fetchImpl(url, {
235
- ...init,
236
- headers: {
237
- accept: "application/json",
238
- ...(init?.headers ?? {}),
239
- "x-shopify-access-token": token,
240
- },
241
- });
242
- const limit = parseCallLimit(response.headers.get("x-shopify-shop-api-call-limit"));
243
- if (limit && limit.current >= Math.max(1, limit.max - 2)) {
244
- await sleep(500);
245
- }
246
- if (response.status === 429) {
247
- return {
248
- ok: false,
249
- status: 429,
250
- response,
251
- error: {
252
- code: "SHOPIFY_RATE_LIMITED",
253
- message: "Shopify API rate limit exceeded.",
254
- retriable: true,
255
- },
256
- };
257
- }
258
- const method = (init?.method ?? "GET").toUpperCase();
259
- if (response.status === 403 && method !== "GET") {
260
- return {
261
- ok: false,
262
- status: 403,
263
- response,
264
- error: shopifyWriteProductsScopeMissingError(),
265
- };
266
- }
267
- if (!response.ok) {
268
- return {
269
- ok: false,
270
- status: response.status,
271
- response,
272
- error: {
273
- code: "SHOPIFY_API_FAILED",
274
- message: `Shopify API request failed (${response.status}) for ${url}.`,
275
- retriable: response.status >= 500,
276
- },
277
- };
278
- }
279
- const text = await response.text();
280
- const data = text.length > 0 ? JSON.parse(text) as T : {} as T;
281
- return { ok: true, data, response };
282
- } catch (error) {
283
- return {
284
- ok: false,
285
- error: {
286
- code: "SHOPIFY_API_FAILED",
287
- message: error instanceof Error ? error.message : "Shopify API request failed.",
288
- retriable: true,
289
- },
290
- };
291
- }
292
- }
293
-
294
- async function loadProductSnapshot(
295
- deps: PushCatalogDeps,
296
- store: ChannelStore,
297
- token: string,
298
- externalId: string,
299
- variantIds: string[],
300
- withImages: boolean,
301
- ): Promise<Result<{
302
- product: ShopifyProductSnapshot;
303
- metafields: ShopifyMetafield[];
304
- variantMetafields: Map<string, ShopifyMetafield[]>;
305
- images: ShopifyProductImage[];
306
- }>> {
307
- const base = deps.apiBase(store);
308
- const productResult = await pushRequest<{ product: ShopifyProductSnapshot }>(
309
- deps,
310
- `${base}/products/${encodeURIComponent(externalId)}.json`,
311
- token,
312
- );
313
- if (!productResult.ok) return productResult;
314
- const metafieldsResult = await pushRequest<{ metafields: ShopifyMetafield[] }>(
315
- deps,
316
- `${base}/products/${encodeURIComponent(externalId)}/metafields.json?namespace=${encodeURIComponent(PORULLE_METAFIELD_NAMESPACE)}`,
317
- token,
318
- );
319
- if (!metafieldsResult.ok) return metafieldsResult;
320
- const variantMetafields = new Map<string, ShopifyMetafield[]>();
321
- for (const variantId of variantIds) {
322
- const variantResult = await pushRequest<{ metafields: ShopifyMetafield[] }>(
323
- deps,
324
- `${base}/variants/${encodeURIComponent(variantId)}/metafields.json?namespace=${encodeURIComponent(PORULLE_METAFIELD_NAMESPACE)}`,
325
- token,
326
- );
327
- if (!variantResult.ok) return variantResult;
328
- variantMetafields.set(variantId, variantResult.data.metafields ?? []);
329
- }
330
- let images: ShopifyProductImage[] = [];
331
- if (withImages) {
332
- const imagesResult = await pushRequest<{ images: ShopifyProductImage[] }>(
333
- deps,
334
- `${base}/products/${encodeURIComponent(externalId)}/images.json`,
335
- token,
336
- );
337
- if (!imagesResult.ok) return imagesResult;
338
- images = imagesResult.data.images ?? [];
339
- }
340
- return Ok({
341
- product: productResult.data.product,
342
- metafields: metafieldsResult.data.metafields ?? [],
343
- variantMetafields,
344
- images,
345
- });
346
- }
347
-
348
- function shopifyId(externalId: string): number | string {
349
- return /^\d+$/.test(externalId) ? Number(externalId) : externalId;
350
- }
351
-
352
- function imageFileName(url: string): string | undefined {
353
- try {
354
- const path = new URL(url).pathname;
355
- const name = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
356
- return name.length > 0 ? name : undefined;
357
- } catch {
358
- return undefined;
359
- }
360
- }
361
-
362
- function imageFileStem(name: string): string {
363
- const dot = name.lastIndexOf(".");
364
- return dot > 0 ? name.slice(0, dot) : name;
365
- }
366
-
367
- // Shopify keeps the uploaded file name in the CDN path (suffixing `_<hash>` on a
368
- // collision) but never echoes the original source URL, so a re-push without a
369
- // recorded image id can only recognise its own image by that file name.
370
- function matchExistingImage(
371
- image: ChannelPushCatalogImage,
372
- existing: ShopifyProductImage[],
373
- ): ShopifyProductImage | undefined {
374
- const externalId = image.externalId?.trim();
375
- if (externalId) return existing.find((candidate) => String(candidate.id) === externalId);
376
- const name = imageFileName(image.url);
377
- if (name === undefined) return undefined;
378
- const stem = imageFileStem(name);
379
- return existing.find((candidate) => {
380
- const candidateName = candidate.src ? imageFileName(candidate.src) : undefined;
381
- if (candidateName === undefined) return false;
382
- const candidateStem = imageFileStem(candidateName);
383
- return candidateName === name || candidateStem === stem || candidateStem.startsWith(`${stem}_`);
384
- });
385
- }
386
-
387
- function orderImages(images: ChannelPushCatalogImage[]): ChannelPushCatalogImage[] {
388
- const rank = (image: ChannelPushCatalogImage): number => (image.role === "primary" ? 0 : 1);
389
- return images
390
- .map((image, index) => ({ image, index }))
391
- .sort((left, right) =>
392
- rank(left.image) - rank(right.image)
393
- || (left.image.sortOrder ?? left.index) - (right.image.sortOrder ?? right.index)
394
- || left.index - right.index)
395
- .map((entry) => entry.image);
396
- }
397
-
398
- async function writeImages(
399
- deps: PushCatalogDeps,
400
- store: ChannelStore,
401
- token: string,
402
- externalId: string,
403
- images: ChannelPushCatalogImage[],
404
- existing: ShopifyProductImage[],
405
- ): Promise<{ outcomes: ChannelPushCatalogImageOutcome[]; error?: ChannelConnectorError }> {
406
- const outcomes: ChannelPushCatalogImageOutcome[] = [];
407
- const base = `${deps.apiBase(store)}/products/${encodeURIComponent(externalId)}/images`;
408
- const claimed = new Set<string>();
409
- for (const [index, image] of orderImages(images).entries()) {
410
- if (!SHOPIFY_IMAGE_ROLES.has(image.role)) {
411
- const error: ChannelConnectorError = {
412
- code: "SHOPIFY_IMAGE_ROLE_UNSUPPORTED",
413
- message: `Shopify product images cannot carry the "${image.role}" role.`,
414
- retriable: false,
415
- };
416
- outcomes.push({ url: image.url, role: image.role, ok: false, error });
417
- return { outcomes, error };
418
- }
419
- const match = matchExistingImage(image, existing.filter((candidate) => !claimed.has(String(candidate.id))));
420
- const payload: Record<string, unknown> = { position: index + 1 };
421
- if (image.alt !== undefined) payload.alt = image.alt;
422
- if (image.variantExternalIds && image.variantExternalIds.length > 0) {
423
- payload.variant_ids = image.variantExternalIds.map(shopifyId);
424
- }
425
- const result = match
426
- ? await pushRequest<{ image: ShopifyProductImage }>(deps, `${base}/${encodeURIComponent(String(match.id))}.json`, token, {
427
- method: "PUT",
428
- headers: { "content-type": "application/json" },
429
- body: JSON.stringify({ image: { id: match.id, ...payload } }),
430
- })
431
- : await pushRequest<{ image: ShopifyProductImage }>(deps, `${base}.json`, token, {
432
- method: "POST",
433
- headers: { "content-type": "application/json" },
434
- body: JSON.stringify({ image: { src: image.url, ...payload } }),
435
- });
436
- if (!result.ok) {
437
- outcomes.push({ url: image.url, role: image.role, ok: false, error: result.error });
438
- return { outcomes, error: result.error };
439
- }
440
- const written = result.data.image ?? match;
441
- if (written) claimed.add(String(written.id));
442
- outcomes.push({
443
- url: image.url,
444
- role: image.role,
445
- ok: true,
446
- ...(written ? { externalId: String(written.id) } : {}),
447
- });
448
- }
449
- return { outcomes };
450
- }
451
-
452
- async function writeNativeProduct(
453
- deps: PushCatalogDeps,
454
- store: ChannelStore,
455
- token: string,
456
- externalId: string,
457
- fields: ChannelPushCatalogField[],
458
- tagValues: string[],
459
- existingTags: string[],
460
- ): Promise<PushRequestResponse<{ product: ShopifyProductSnapshot }>> {
461
- const product: Record<string, unknown> = { id: externalId };
462
- for (const field of fields) {
463
- if (!isNativeProductField(field)) continue;
464
- const key = remoteKey(field);
465
- if (key !== undefined) product[key] = field.value;
466
- }
467
- if (tagValues.length > 0) {
468
- product.tags = mergeTags(existingTags, tagValues);
469
- }
470
- if (Object.keys(product).length <= 1) {
471
- return {
472
- ok: true,
473
- data: { product: { id: externalId } },
474
- response: new Response(null, { status: 200 }),
475
- };
476
- }
477
- return pushRequest<{ product: ShopifyProductSnapshot }>(
478
- deps,
479
- `${deps.apiBase(store)}/products/${encodeURIComponent(externalId)}.json`,
480
- token,
481
- {
482
- method: "PUT",
483
- headers: { "content-type": "application/json" },
484
- body: JSON.stringify({ product }),
485
- },
486
- );
487
- }
488
-
489
- async function writeVariantFields(
490
- deps: PushCatalogDeps,
491
- store: ChannelStore,
492
- token: string,
493
- variantExternalId: string,
494
- fields: ChannelPushCatalogField[],
495
- ): Promise<PushRequestResponse<{ variant: ShopifyVariantSnapshot }>> {
496
- const variant: Record<string, unknown> = { id: variantExternalId };
497
- for (const field of fields) {
498
- if (!isNativeVariantField(field)) continue;
499
- const key = remoteKey(field);
500
- if (key !== undefined) variant[key] = field.value;
501
- }
502
- if (Object.keys(variant).length <= 1) {
503
- return {
504
- ok: true,
505
- data: { variant: { id: variantExternalId } },
506
- response: new Response(null, { status: 200 }),
507
- };
508
- }
509
- return pushRequest<{ variant: ShopifyVariantSnapshot }>(
510
- deps,
511
- `${deps.apiBase(store)}/variants/${encodeURIComponent(variantExternalId)}.json`,
512
- token,
513
- {
514
- method: "PUT",
515
- headers: { "content-type": "application/json" },
516
- body: JSON.stringify({ variant }),
517
- },
518
- );
519
- }
520
-
521
- async function writeMetafield(
522
- deps: PushCatalogDeps,
523
- store: ChannelStore,
524
- token: string,
525
- externalId: string,
526
- field: ChannelPushCatalogField,
527
- existing: ShopifyMetafield | undefined,
528
- resource: "product" | "variant",
529
- ): Promise<PushRequestResponse<{ metafield: ShopifyMetafield }>> {
530
- const key = remoteKey(field);
531
- if (key === undefined) return { ok: false, error: missingRemoteKeyError(field) };
532
- const payload = {
533
- metafield: {
534
- namespace: PORULLE_METAFIELD_NAMESPACE,
535
- key,
536
- value: metafieldValue(field.value),
537
- type: metafieldType(field.value),
538
- },
539
- };
540
- if (existing) {
541
- return pushRequest<{ metafield: ShopifyMetafield }>(
542
- deps,
543
- `${deps.apiBase(store)}/metafields/${encodeURIComponent(String(existing.id))}.json`,
544
- token,
545
- {
546
- method: "PUT",
547
- headers: { "content-type": "application/json" },
548
- body: JSON.stringify(payload),
549
- },
550
- );
551
- }
552
- return pushRequest<{ metafield: ShopifyMetafield }>(
553
- deps,
554
- `${deps.apiBase(store)}/${resource}s/${encodeURIComponent(externalId)}/metafields.json`,
555
- token,
556
- {
557
- method: "POST",
558
- headers: { "content-type": "application/json" },
559
- body: JSON.stringify(payload),
560
- },
561
- );
562
- }
563
-
564
- async function pushCatalogItem(
565
- deps: PushCatalogDeps,
566
- store: ChannelStore,
567
- token: string,
568
- item: ChannelPushCatalogItem,
569
- dryRun: boolean,
570
- ): Promise<ChannelPushCatalogItemOutcome> {
571
- const missingField = [...item.fields, ...(item.variants ?? []).flatMap((variant) => variant.fields)]
572
- .find((field) => field.intent !== "tag" && remoteKey(field) === undefined);
573
- if (missingField) return { externalId: item.externalId, ok: false, error: missingRemoteKeyError(missingField) };
574
- const images = item.images ?? [];
575
- const variantIds = (item.variants ?? [])
576
- .filter((variant) => variant.fields.some((field) => isMetafieldField(field)))
577
- .map((variant) => variant.externalId);
578
- const snapshot = await loadProductSnapshot(deps, store, token, item.externalId, variantIds, images.length > 0);
579
- if (!snapshot.ok) {
580
- return { externalId: item.externalId, ok: false, error: snapshot.error };
581
- }
582
- const previousFields = collectPreviousFields(snapshot.value.product, snapshot.value.metafields, snapshot.value.variantMetafields, item);
583
- if (dryRun) {
584
- return {
585
- externalId: item.externalId,
586
- ok: true,
587
- ...(previousFields.length > 0 ? { previousFields } : {}),
588
- };
589
- }
590
-
591
- const metafieldByKey = new Map(snapshot.value.metafields
592
- .filter((entry) => entry.namespace === PORULLE_METAFIELD_NAMESPACE)
593
- .map((entry) => [entry.key, entry]));
594
- const productFields = item.fields.filter((field) => field.intent !== "tag");
595
- const tagValues = item.fields
596
- .filter((field) => field.intent === "tag")
597
- .flatMap((field) => {
598
- if (typeof field.value === "string") return [field.value];
599
- if (Array.isArray(field.value)) return field.value.filter((entry): entry is string => typeof entry === "string");
600
- return field.value == null ? [] : [String(field.value)];
601
- });
602
-
603
- const nativeResult = await writeNativeProduct(
604
- deps,
605
- store,
606
- token,
607
- item.externalId,
608
- productFields,
609
- tagValues,
610
- parseTags(snapshot.value.product.tags),
611
- );
612
- if (!nativeResult.ok) {
613
- return { externalId: item.externalId, ok: false, error: nativeResult.error };
614
- }
615
-
616
- for (const field of productFields) {
617
- if (!isMetafieldField(field)) continue;
618
- const key = remoteKey(field);
619
- const metafieldResult = await writeMetafield(
620
- deps,
621
- store,
622
- token,
623
- item.externalId,
624
- field,
625
- key !== undefined ? metafieldByKey.get(key) : undefined,
626
- "product",
627
- );
628
- if (!metafieldResult.ok) {
629
- return { externalId: item.externalId, ok: false, error: metafieldResult.error };
630
- }
631
- if (metafieldResult.data.metafield) {
632
- metafieldByKey.set(metafieldResult.data.metafield.key, metafieldResult.data.metafield);
633
- }
634
- }
635
-
636
- for (const variant of item.variants ?? []) {
637
- const variantMetafieldByKey = new Map((snapshot.value.variantMetafields.get(variant.externalId) ?? [])
638
- .filter((entry) => entry.namespace === PORULLE_METAFIELD_NAMESPACE)
639
- .map((entry) => [entry.key, entry]));
640
- for (const field of variant.fields) {
641
- if (!isMetafieldField(field)) continue;
642
- const key = remoteKey(field);
643
- const metafieldResult = await writeMetafield(
644
- deps,
645
- store,
646
- token,
647
- variant.externalId,
648
- field,
649
- key !== undefined ? variantMetafieldByKey.get(key) : undefined,
650
- "variant",
651
- );
652
- if (!metafieldResult.ok) {
653
- return { externalId: item.externalId, ok: false, error: metafieldResult.error };
654
- }
655
- if (metafieldResult.data.metafield) {
656
- variantMetafieldByKey.set(metafieldResult.data.metafield.key, metafieldResult.data.metafield);
657
- }
658
- }
659
- const variantResult = await writeVariantFields(deps, store, token, variant.externalId, variant.fields);
660
- if (!variantResult.ok) {
661
- return { externalId: item.externalId, ok: false, error: variantResult.error };
662
- }
663
- }
664
-
665
- let imageOutcomes: ChannelPushCatalogImageOutcome[] | undefined;
666
- if (images.length > 0) {
667
- const imageResult = await writeImages(deps, store, token, item.externalId, images, snapshot.value.images);
668
- imageOutcomes = imageResult.outcomes;
669
- if (imageResult.error) {
670
- return { externalId: item.externalId, ok: false, error: imageResult.error, images: imageOutcomes };
671
- }
672
- }
673
-
674
- return {
675
- externalId: item.externalId,
676
- ok: true,
677
- ...(nativeResult.data.product.updated_at ? { remoteUpdatedAt: nativeResult.data.product.updated_at } : {}),
678
- ...(previousFields.length > 0 ? { previousFields } : {}),
679
- ...(imageOutcomes ? { images: imageOutcomes } : {}),
680
- };
681
- }
682
-
683
- export async function pushCatalog(
684
- deps: PushCatalogDeps,
685
- store: ChannelStore,
686
- items: ChannelPushCatalogItem[],
687
- opts?: { dryRun?: boolean; reauthorizeUrl?: string },
688
- ): Promise<Result<ChannelPushCatalogResult, ChannelConnectorError>> {
689
- const token = deps.credentials(store);
690
- if (!token) {
691
- return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required.", retriable: false });
692
- }
693
- if (!shopifyPushCatalogEnabled(store)) {
694
- return Err(shopifyWriteProductsScopeMissingError(opts?.reauthorizeUrl));
695
- }
696
-
697
- const outcomes: ChannelPushCatalogItemOutcome[] = [];
698
- for (const item of items) {
699
- outcomes.push(await pushCatalogItem(deps, store, token, item, opts?.dryRun === true));
700
- }
701
- return Ok({ outcomes });
702
- }