@porulle/plugin-channel-connector 0.10.8 → 0.11.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/service.ts CHANGED
@@ -4,22 +4,50 @@ import {
4
4
  CommerceValidationError,
5
5
  Ok,
6
6
  PluginErr,
7
+ createTxContext,
7
8
  createSystemActor,
8
9
  } from "@porulle/core";
9
10
  import type {
10
11
  Actor,
11
12
  ChannelCatalogItem,
12
13
  ChannelConnector,
13
- ChannelInventoryLevel,
14
14
  ChannelOrderSlice,
15
+ ChannelPushCatalogField,
16
+ ChannelPushCatalogImage,
17
+ ChannelPushCatalogIntent,
18
+ ChannelPushCatalogItem,
15
19
  ChannelStore,
16
20
  PluginDb,
17
21
  PluginResult,
18
22
  PluginTxFn,
23
+ TxContext,
19
24
  } from "@porulle/core";
25
+ import { isValidFieldPath } from "@porulle/core";
26
+ import type { FieldOwner, FieldPath } from "@porulle/core";
20
27
  import type { JobsAdapter } from "@porulle/core";
21
- import { and, eq, inArray } from "@porulle/core/drizzle";
22
- import { customerAddresses, customers, inventoryLevels, orderLineItems, orders, sellableEntities } from "@porulle/core/schema";
28
+ import { and, eq, inArray, isNull } from "@porulle/core/drizzle";
29
+ import {
30
+ brands,
31
+ categories,
32
+ customerAddresses,
33
+ customers,
34
+ entityMedia,
35
+ entityTags,
36
+ inventoryLevels,
37
+ mediaAssets,
38
+ optionTypes,
39
+ optionValues,
40
+ orderLineItems,
41
+ orders,
42
+ prices,
43
+ sellableAttributes,
44
+ sellableCustomFields,
45
+ sellableEntities,
46
+ entityFieldDefinitions,
47
+ tags,
48
+ variants,
49
+ variantOptionValues,
50
+ } from "@porulle/core/schema";
23
51
  import {
24
52
  channelEntityMap,
25
53
  channelExportEvents,
@@ -31,6 +59,13 @@ import {
31
59
  type ChannelRefundRequest,
32
60
  type ConnectedStore,
33
61
  } from "./schema.js";
62
+ import {
63
+ mergeCatalogFieldMapping,
64
+ normalizeCatalogFieldMapping,
65
+ selectCatalogFieldMapping,
66
+ type CatalogFieldMapping,
67
+ type CatalogFieldMappingInput,
68
+ } from "./catalog-field-mapping.js";
34
69
 
35
70
  export type ExportState = ChannelOrderExport["state"];
36
71
 
@@ -40,6 +75,30 @@ export interface ReconcileReport extends Record<string, unknown> {
40
75
  archived: number;
41
76
  inventoryUpdated: number;
42
77
  driftAlert: boolean;
78
+ skipped?: CatalogFieldSkip[];
79
+ conflicts?: CatalogFieldConflict[];
80
+ warnings?: string[];
81
+ }
82
+
83
+ export interface CatalogFieldConflict {
84
+ entityId: string;
85
+ storeId: string;
86
+ fieldPath: FieldPath;
87
+ localValueSummary: string;
88
+ remoteValueSummary: string;
89
+ }
90
+
91
+ export interface CatalogFieldSkip {
92
+ entityId: string;
93
+ fieldPath: FieldPath;
94
+ }
95
+
96
+ export type CatalogPushSkipReason = "no_mapping" | "held" | "entity_not_active" | "unmapped_entity";
97
+
98
+ export interface CatalogPushFieldSkip {
99
+ entityId: string;
100
+ fieldPath: FieldPath;
101
+ reason: CatalogPushSkipReason;
43
102
  }
44
103
 
45
104
  export type PublicConnectedStore = Omit<ConnectedStore, "credentials" | "webhookSecret"> & {
@@ -47,6 +106,13 @@ export type PublicConnectedStore = Omit<ConnectedStore, "credentials" | "webhook
47
106
  webhookSecret: "[REDACTED]";
48
107
  };
49
108
 
109
+ export interface CatalogWriteSettings {
110
+ enabled: boolean;
111
+ overrides: CatalogFieldMapping;
112
+ merged: CatalogFieldMapping;
113
+ warnings?: string[];
114
+ }
115
+
50
116
  export interface ChannelComplianceData {
51
117
  customer: { id?: string; email?: string };
52
118
  exports: Array<{
@@ -75,7 +141,62 @@ export interface ChannelStockLine {
75
141
  quantity: number;
76
142
  }
77
143
 
144
+ interface BackfillCounts {
145
+ entitiesTouched: number;
146
+ attributesCreated: number;
147
+ mediaImported: number;
148
+ variantsGivenOptionValues: number;
149
+ }
150
+
151
+ export interface BackfillCatalogReport extends BackfillCounts, Record<string, unknown> {
152
+ cursor: string | null;
153
+ complete: boolean;
154
+ skipped?: CatalogFieldSkip[];
155
+ conflicts?: CatalogFieldConflict[];
156
+ warnings?: string[];
157
+ }
158
+
159
+ interface CatalogConvergenceStats {
160
+ imported: number;
161
+ converged: number;
162
+ entitiesTouched: number;
163
+ attributesCreated: number;
164
+ mediaImported: number;
165
+ variantsGivenOptionValues: number;
166
+ skipped: CatalogFieldSkip[];
167
+ conflicts: CatalogFieldConflict[];
168
+ warnings: string[];
169
+ }
170
+
171
+ export interface BackfillCatalogOptions {
172
+ dryRun?: boolean;
173
+ resume?: boolean;
174
+ maxPages?: number;
175
+ }
176
+
177
+ export interface BuildCatalogPushItemsOptions {
178
+ recordRevision?: boolean;
179
+ }
180
+
181
+ export interface BuildCatalogPushItemsResult {
182
+ items: ChannelPushCatalogItem[];
183
+ skipped: CatalogPushFieldSkip[];
184
+ warnings: string[];
185
+ }
186
+
187
+ interface BackfillState {
188
+ cursor: string | null;
189
+ report: BackfillCounts;
190
+ skipped?: CatalogFieldSkip[];
191
+ conflicts?: CatalogFieldConflict[];
192
+ warnings?: string[];
193
+ completedAt?: string;
194
+ }
195
+
78
196
  interface CatalogService {
197
+ repository: {
198
+ findRevisionMarkers(entityId: string, since?: Date): Promise<Array<{ createdAt: Date; reason: string }>>;
199
+ };
79
200
  update(
80
201
  id: string,
81
202
  input: { slug?: string; status?: string; metadata?: Record<string, unknown>; isVisible?: boolean },
@@ -88,6 +209,8 @@ interface CatalogService {
88
209
  slug: string;
89
210
  sourceStoreId: string;
90
211
  metadata: Record<string, unknown>;
212
+ status?: string;
213
+ isVisible?: boolean;
91
214
  },
92
215
  actor: Actor,
93
216
  ): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
@@ -95,6 +218,100 @@ interface CatalogService {
95
218
  input: { entityId: string; options: Record<string, string>; sku?: string; barcode?: string },
96
219
  actor: Actor,
97
220
  ): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
221
+ setAttributes(
222
+ entityId: string,
223
+ locale: string,
224
+ attrs: {
225
+ title: string;
226
+ subtitle?: string;
227
+ description?: string;
228
+ richDescription?: unknown;
229
+ seoTitle?: string;
230
+ seoDescription?: string;
231
+ },
232
+ actor: Actor,
233
+ ): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
234
+ recordEntityRevision(
235
+ entityId: string,
236
+ actor: Actor,
237
+ reason: "import" | "push",
238
+ ctx?: TxContext<PluginDb>,
239
+ ): Promise<{ ok: true; value: unknown } | { ok: false; error: { message: string } }>;
240
+ resolveFieldOwners(entityId: string, storeId: string): Promise<Map<FieldPath, FieldOwner>>;
241
+ seedImportedFieldOwnership(entityId: string, storeId: string, fieldPaths: FieldPath[]): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
242
+ createOptionType(
243
+ input: { entityId: string; name: string; values?: string[] },
244
+ actor: Actor,
245
+ ): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
246
+ createOptionValue(
247
+ input: { optionTypeId: string; value: string },
248
+ actor: Actor,
249
+ ): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
250
+ createCategory(
251
+ input: { slug: string },
252
+ actor: Actor,
253
+ ): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
254
+ addToCategory(
255
+ entityId: string,
256
+ categoryId: string,
257
+ actor: Actor,
258
+ ): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
259
+ createBrand(
260
+ input: { slug: string; displayName: string },
261
+ actor: Actor,
262
+ ): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
263
+ addToBrand(
264
+ entityId: string,
265
+ brandId: string,
266
+ actor: Actor,
267
+ ): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
268
+ }
269
+
270
+ type ServiceResult<T> =
271
+ | { ok: true; value: T }
272
+ | { ok: false; error: { message: string; code?: string } };
273
+
274
+ interface MediaService {
275
+ upload(
276
+ input: {
277
+ filename: string;
278
+ contentType: string;
279
+ data: ArrayBuffer;
280
+ alt?: string;
281
+ metadata?: Record<string, unknown>;
282
+ origin?: "merchant" | "generated" | "imported";
283
+ },
284
+ actor: Actor,
285
+ ): Promise<ServiceResult<{ id: string; url: string }>>;
286
+ attachToEntity(
287
+ input: {
288
+ entityId: string;
289
+ mediaAssetId: string;
290
+ role: "primary" | "gallery" | "thumbnail" | "video" | "document";
291
+ variantId?: string;
292
+ sortOrder?: number;
293
+ },
294
+ actor: Actor,
295
+ ): Promise<ServiceResult<undefined>>;
296
+ listEntityMedia(
297
+ entityId: string,
298
+ opts?: { variantId?: string; orgId?: string },
299
+ ): Promise<ServiceResult<Array<{
300
+ mediaAssetId: string;
301
+ role: string;
302
+ sortOrder: number;
303
+ variantId: string | null;
304
+ url: string;
305
+ alt: string | null;
306
+ contentType: string;
307
+ }>>>;
308
+ }
309
+
310
+ interface PricingService {
311
+ setBasePrice(
312
+ input: { entityId: string; variantId?: string; currency: string; amount: number; compareAtAmount?: number | null },
313
+ actor: Actor,
314
+ ): Promise<ServiceResult<unknown>>;
98
315
  }
99
316
 
100
317
  const exportTransitions: Record<ExportState, readonly ExportState[]> = {
@@ -113,6 +330,114 @@ function hash(value: unknown): string {
113
330
  return createHash("sha256").update(JSON.stringify(value)).digest("hex");
114
331
  }
115
332
 
333
+ function mergeMetadata(
334
+ existing: Record<string, unknown> | null | undefined,
335
+ remote: Record<string, unknown>,
336
+ ): Record<string, unknown> {
337
+ return { ...(existing ?? {}), ...remote };
338
+ }
339
+
340
+ const attributeFields = ["title", "subtitle", "description", "richDescription", "seoTitle", "seoDescription"] as const;
341
+ const pushImageRoles = ["primary", "gallery", "thumbnail", "video", "document"] as const;
342
+
343
+ function customFieldValue(field: typeof sellableCustomFields.$inferSelect): unknown {
344
+ switch (field.fieldType) {
345
+ case "text":
346
+ case "relation":
347
+ case "select":
348
+ return field.textValue;
349
+ case "number":
350
+ return field.numberValue;
351
+ case "boolean":
352
+ return field.booleanValue;
353
+ case "date":
354
+ return field.dateValue;
355
+ case "json":
356
+ return field.jsonValue;
357
+ default:
358
+ return null;
359
+ }
360
+ }
361
+
362
+ function pushCatalogIntent(
363
+ fieldPath: string,
364
+ target: "native" | "attribute" | "meta",
365
+ ): ChannelPushCatalogIntent {
366
+ if (fieldPath.startsWith("customFields.") && target === "attribute") return "filterable";
367
+ if (fieldPath.startsWith("customFields.") || fieldPath.startsWith("entity.metadata.")) return "tag";
368
+ return "display";
369
+ }
370
+
371
+ function pushCatalogField(
372
+ fieldPath: FieldPath,
373
+ value: unknown,
374
+ mapping: { target: "native" | "attribute" | "meta"; remoteKey: string },
375
+ ): ChannelPushCatalogField {
376
+ const segments = fieldPath.split(".");
377
+ const locale = fieldPath.startsWith("attributes.")
378
+ ? segments[1]
379
+ : fieldPath.startsWith("customFields.")
380
+ ? segments[2]
381
+ : undefined;
382
+ return {
383
+ fieldPath,
384
+ intent: pushCatalogIntent(fieldPath, mapping.target),
385
+ value,
386
+ ...(locale !== undefined ? { locale } : {}),
387
+ remoteKey: mapping.remoteKey,
388
+ };
389
+ }
390
+
391
+ function pushCatalogImageRole(value: string): ChannelPushCatalogImage["role"] | undefined {
392
+ return pushImageRoles.find((role) => role === value);
393
+ }
394
+
395
+ function importedFieldPaths(item: ChannelCatalogItem): FieldPath[] {
396
+ const paths = new Set<FieldPath>(["entity.slug"]);
397
+ if (item.status !== undefined) paths.add("entity.status");
398
+ for (const key of Object.keys(item.metadata ?? {})) {
399
+ const path = `entity.metadata.${key}`;
400
+ if (isValidFieldPath(path)) paths.add(path);
401
+ }
402
+ const attributes = item.attributes?.length
403
+ ? item.attributes
404
+ : [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
405
+ for (const attribute of attributes) {
406
+ for (const field of attributeFields) {
407
+ if (attribute[field] !== undefined) paths.add(`attributes.${attribute.locale}.${field}`);
408
+ }
409
+ }
410
+ for (const image of item.images ?? []) paths.add(`media.${image.role}`);
411
+ if (item.options?.length) paths.add("options");
412
+ if (item.variants.some((variant) => variant.sku !== undefined)) paths.add("variants.sku");
413
+ if (item.variants.some((variant) => variant.barcode !== undefined)) paths.add("variants.barcode");
414
+ for (const currency of item.variants.flatMap((variant) => variant.prices ?? []).map((price) => price.currency)) {
415
+ const path = `prices.${currency}`;
416
+ if (isValidFieldPath(path)) paths.add(path);
417
+ }
418
+ return [...paths];
419
+ }
420
+
421
+ function summarizeValue(value: unknown): string {
422
+ const serialized = JSON.stringify(value);
423
+ if (serialized === undefined) return String(value);
424
+ return serialized.length > 256 ? `${serialized.slice(0, 253)}...` : serialized;
425
+ }
426
+
427
+ function uniqueSkipped(skipped: CatalogFieldSkip[]): CatalogFieldSkip[] {
428
+ const seen = new Set<string>();
429
+ return skipped.filter((entry) => {
430
+ const key = `${entry.entityId}:${entry.fieldPath}`;
431
+ if (seen.has(key)) return false;
432
+ seen.add(key);
433
+ return true;
434
+ });
435
+ }
436
+
437
+ function ownerAllows(owners: Map<FieldPath, FieldOwner>, path: FieldPath): boolean {
438
+ return owners.get(path) !== "platform";
439
+ }
440
+
116
441
  function stockFailure(line: ChannelStockLine, reason: string): string {
117
442
  return `Cannot checkout line "${line.title ?? line.entityId}": ${reason}.`;
118
443
  }
@@ -139,6 +464,8 @@ function redactStore(store: ConnectedStore): PublicConnectedStore {
139
464
  credentials: "[REDACTED]",
140
465
  storeDomain: store.storeDomain,
141
466
  status: store.status,
467
+ catalogWriteEnabled: store.catalogWriteEnabled,
468
+ catalogFieldMapping: store.catalogFieldMapping,
142
469
  catalogCursor: store.catalogCursor,
143
470
  inventoryCursor: store.inventoryCursor,
144
471
  lastSyncAt: store.lastSyncAt,
@@ -182,6 +509,557 @@ export class ChannelConnectorService {
182
509
  return this.services.catalog as CatalogService;
183
510
  }
184
511
 
512
+ private get media(): MediaService {
513
+ return this.services.media as MediaService;
514
+ }
515
+
516
+ private get pricing(): PricingService {
517
+ return this.services.pricing as PricingService;
518
+ }
519
+
520
+ private filterOwnedFields(
521
+ item: ChannelCatalogItem,
522
+ owners: Map<FieldPath, FieldOwner>,
523
+ ): { writable: ChannelCatalogItem; skipped: FieldPath[]; conflicts: FieldPath[] } {
524
+ return this.filterOwnedFieldsAtPaths(item, owners, importedFieldPaths(item));
525
+ }
526
+
527
+ private filterOwnedFieldsAtPaths(
528
+ item: ChannelCatalogItem,
529
+ owners: Map<FieldPath, FieldOwner>,
530
+ fieldPaths: FieldPath[],
531
+ ): { writable: ChannelCatalogItem; skipped: FieldPath[]; conflicts: FieldPath[] } {
532
+ const populated = new Set(fieldPaths);
533
+ const skipped = fieldPaths.filter((path) => owners.get(path) === "platform");
534
+ const blocked = new Set(skipped);
535
+ const attributes = (item.attributes?.length
536
+ ? item.attributes
537
+ : [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }])
538
+ .flatMap((attribute) => {
539
+ return [{
540
+ locale: attribute.locale,
541
+ title: attribute.title,
542
+ ...Object.fromEntries(attributeFields.slice(1)
543
+ .filter((field) => attribute[field] !== undefined
544
+ && populated.has(`attributes.${attribute.locale}.${field}`)
545
+ && !blocked.has(`attributes.${attribute.locale}.${field}`))
546
+ .map((field) => [field, attribute[field]])),
547
+ }];
548
+ });
549
+ const writable: ChannelCatalogItem = {
550
+ ...item,
551
+ attributes,
552
+ metadata: Object.fromEntries(Object.entries(item.metadata ?? {}).filter(([key]) => populated.has(`entity.metadata.${key}`) && !blocked.has(`entity.metadata.${key}`))),
553
+ ...(item.images !== undefined ? { images: item.images.filter((image) => populated.has(`media.${image.role}`) && !blocked.has(`media.${image.role}`)) } : {}),
554
+ ...(item.options !== undefined ? { options: blocked.has("options") ? [] : item.options } : {}),
555
+ variants: item.variants.map((variant) => ({
556
+ externalId: variant.externalId,
557
+ ...(variant.sku !== undefined && populated.has("variants.sku") && !blocked.has("variants.sku") ? { sku: variant.sku } : {}),
558
+ ...(variant.barcode !== undefined && populated.has("variants.barcode") && !blocked.has("variants.barcode") ? { barcode: variant.barcode } : {}),
559
+ ...(variant.metadata !== undefined ? { metadata: variant.metadata } : {}),
560
+ ...(variant.optionValues !== undefined && populated.has("options") && !blocked.has("options") ? { optionValues: variant.optionValues } : {}),
561
+ ...(variant.prices !== undefined
562
+ ? { prices: variant.prices.filter((price) => populated.has(`prices.${price.currency}`) && !blocked.has(`prices.${price.currency}`)) }
563
+ : {}),
564
+ })),
565
+ };
566
+ return { writable, skipped, conflicts: [] };
567
+ }
568
+
569
+ private filterConflictingFields(
570
+ item: ChannelCatalogItem,
571
+ conflicts: FieldPath[],
572
+ ): { writable: ChannelCatalogItem; conflicts: FieldPath[] } {
573
+ if (conflicts.length === 0) return { writable: item, conflicts: [] };
574
+ const blocked = new Set(conflicts);
575
+ const attributes = (item.attributes ?? []).flatMap((attribute) => {
576
+ return [{
577
+ locale: attribute.locale,
578
+ title: attribute.title,
579
+ ...Object.fromEntries(attributeFields.slice(1)
580
+ .filter((field) => attribute[field] !== undefined && !blocked.has(`attributes.${attribute.locale}.${field}`))
581
+ .map((field) => [field, attribute[field]])),
582
+ }];
583
+ });
584
+ return {
585
+ writable: {
586
+ ...item,
587
+ attributes,
588
+ metadata: Object.fromEntries(Object.entries(item.metadata ?? {}).filter(([key]) => !blocked.has(`entity.metadata.${key}`))),
589
+ ...(item.images !== undefined ? { images: item.images.filter((image) => !blocked.has(`media.${image.role}`)) } : {}),
590
+ ...(item.options !== undefined ? { options: blocked.has("options") ? [] : item.options } : {}),
591
+ variants: item.variants.map((variant) => ({
592
+ externalId: variant.externalId,
593
+ ...(variant.sku !== undefined && !blocked.has("variants.sku") ? { sku: variant.sku } : {}),
594
+ ...(variant.barcode !== undefined && !blocked.has("variants.barcode") ? { barcode: variant.barcode } : {}),
595
+ ...(variant.metadata !== undefined ? { metadata: variant.metadata } : {}),
596
+ ...(variant.optionValues !== undefined && !blocked.has("options") ? { optionValues: variant.optionValues } : {}),
597
+ ...(variant.prices !== undefined
598
+ ? { prices: variant.prices.filter((price) => !blocked.has(`prices.${price.currency}`)) }
599
+ : {}),
600
+ })),
601
+ },
602
+ conflicts,
603
+ };
604
+ }
605
+
606
+ private remoteFieldValue(item: ChannelCatalogItem, path: FieldPath): unknown {
607
+ const [root, segment, field] = path.split(".");
608
+ if (root === "entity" && segment === "slug") return item.slug;
609
+ if (root === "entity" && segment === "status") return item.status;
610
+ if (root === "entity" && segment === "metadata") return item.metadata?.[field ?? ""];
611
+ if (root === "attributes" && segment && field) {
612
+ const attributes = item.attributes?.length
613
+ ? item.attributes
614
+ : [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
615
+ const attribute = attributes.find((row) => row.locale === segment);
616
+ return attribute?.[field as keyof typeof attribute];
617
+ }
618
+ if (root === "media" && segment) return (item.images ?? []).filter((image) => image.role === segment).map((image) => image.externalId ?? image.url);
619
+ if (path === "options") return item.options;
620
+ if (path === "variants.sku") return item.variants.map((variant) => variant.sku);
621
+ if (path === "variants.barcode") return item.variants.map((variant) => variant.barcode);
622
+ if (root === "prices" && segment) return item.variants.flatMap((variant) => variant.prices ?? []).filter((price) => price.currency === segment);
623
+ return undefined;
624
+ }
625
+
626
+ private async localFieldValue(
627
+ entityId: string,
628
+ entity: typeof sellableEntities.$inferSelect,
629
+ path: FieldPath,
630
+ ): Promise<unknown> {
631
+ const [root, segment, field] = path.split(".");
632
+ if (root === "entity" && segment === "slug") return entity.slug;
633
+ if (root === "entity" && segment === "status") return entity.status;
634
+ if (root === "entity" && segment === "metadata") return entity.metadata?.[field ?? ""];
635
+ if (root === "attributes" && segment && field) {
636
+ const [attribute] = await this.db.select().from(sellableAttributes).where(and(
637
+ eq(sellableAttributes.entityId, entityId),
638
+ eq(sellableAttributes.locale, segment),
639
+ ));
640
+ const values: Record<string, unknown> = attribute
641
+ ? {
642
+ title: attribute.title,
643
+ subtitle: attribute.subtitle,
644
+ description: attribute.description,
645
+ richDescription: attribute.richDescription,
646
+ seoTitle: attribute.seoTitle,
647
+ seoDescription: attribute.seoDescription,
648
+ }
649
+ : {};
650
+ return values[field];
651
+ }
652
+ if (root === "media" && segment) {
653
+ const links = await this.db.select({ id: entityMedia.mediaAssetId }).from(entityMedia).where(and(
654
+ eq(entityMedia.entityId, entityId),
655
+ eq(entityMedia.role, segment as "primary" | "gallery" | "thumbnail" | "video" | "document"),
656
+ ));
657
+ return links.map((link) => link.id);
658
+ }
659
+ if (path === "options") {
660
+ const types = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entityId));
661
+ const values = await Promise.all(types.map(async (type) => ({
662
+ name: type.name,
663
+ values: await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, type.id)),
664
+ })));
665
+ return values;
666
+ }
667
+ if (path === "variants.sku" || path === "variants.barcode") {
668
+ const rows = await this.db.select().from(variants).where(eq(variants.entityId, entityId));
669
+ return rows.map((variant) => path === "variants.sku" ? variant.sku : variant.barcode);
670
+ }
671
+ if (root === "prices" && segment) {
672
+ const rows = await this.db.select().from(prices).where(and(
673
+ eq(prices.entityId, entityId),
674
+ eq(prices.currency, segment),
675
+ ));
676
+ return rows.map((price) => ({ amount: price.amount, compareAtAmount: price.compareAtAmount }));
677
+ }
678
+ return undefined;
679
+ }
680
+
681
+ private async detectSharedConflicts(
682
+ entityId: string,
683
+ storeId: string,
684
+ entity: typeof sellableEntities.$inferSelect,
685
+ mapping: typeof channelEntityMap.$inferSelect | undefined,
686
+ item: ChannelCatalogItem,
687
+ owners: Map<FieldPath, FieldOwner>,
688
+ fieldPaths: FieldPath[] = importedFieldPaths(item),
689
+ remoteHash = hash(item),
690
+ ): Promise<{ paths: FieldPath[]; conflicts: CatalogFieldConflict[] }> {
691
+ if (!mapping || mapping.syncHash === remoteHash) return { paths: [], conflicts: [] };
692
+ const revisions = await this.catalog.repository.findRevisionMarkers(entityId, mapping.lastSyncedAt);
693
+ const localChanged = revisions.some((revision) => revision.reason !== "import");
694
+ if (!localChanged) return { paths: [], conflicts: [] };
695
+ const paths = fieldPaths.filter((path) => owners.get(path) === "shared");
696
+ const conflicts = await Promise.all(paths.map(async (fieldPath) => ({
697
+ entityId,
698
+ storeId,
699
+ fieldPath,
700
+ localValueSummary: summarizeValue(await this.localFieldValue(entityId, entity, fieldPath)),
701
+ remoteValueSummary: summarizeValue(this.remoteFieldValue(item, fieldPath)),
702
+ })));
703
+ return { paths, conflicts };
704
+ }
705
+
706
+ private async setCatalogAttributes(
707
+ entityId: string,
708
+ item: ChannelCatalogItem,
709
+ actor: Actor,
710
+ blockedPaths: ReadonlySet<FieldPath> = new Set<FieldPath>(),
711
+ ): Promise<PluginResult<{ created: number; changed: boolean }>> {
712
+ const attributes = item.attributes ?? [{
713
+ locale: "en",
714
+ title: item.title,
715
+ ...(item.description !== undefined ? { description: item.description } : {}),
716
+ }];
717
+ const existing = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entityId));
718
+ let created = 0;
719
+ let changed = false;
720
+ for (const attribute of attributes) {
721
+ const current = existing.find((row) => row.locale === attribute.locale);
722
+ const titlePath = `attributes.${attribute.locale}.title` as FieldPath;
723
+ if (!current && blockedPaths.has(titlePath)) continue;
724
+ const title = blockedPaths.has(titlePath) ? current?.title : attribute.title;
725
+ if (title === undefined) continue;
726
+ const writeAttribute = {
727
+ title,
728
+ ...(attribute.subtitle !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.subtitle`) ? { subtitle: attribute.subtitle } : current?.subtitle != null ? { subtitle: current.subtitle } : {}),
729
+ ...(attribute.description !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.description`) ? { description: attribute.description } : current?.description != null ? { description: current.description } : {}),
730
+ ...(attribute.richDescription !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.richDescription`) ? { richDescription: attribute.richDescription } : current?.richDescription != null ? { richDescription: current.richDescription } : {}),
731
+ ...(attribute.seoTitle !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.seoTitle`) ? { seoTitle: attribute.seoTitle } : current?.seoTitle != null ? { seoTitle: current.seoTitle } : {}),
732
+ ...(attribute.seoDescription !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.seoDescription`) ? { seoDescription: attribute.seoDescription } : current?.seoDescription != null ? { seoDescription: current.seoDescription } : {}),
733
+ };
734
+ if (!current) {
735
+ created += 1;
736
+ changed = true;
737
+ } else if (attributeFields.some((field) => (current[field] == null ? null : current[field]) !== (writeAttribute[field] == null ? null : writeAttribute[field]))) {
738
+ changed = true;
739
+ }
740
+ const result = await this.catalog.setAttributes(entityId, attribute.locale, writeAttribute, actor);
741
+ if (!result.ok) return PluginErr(result.error.message);
742
+ }
743
+ return Ok({ created, changed });
744
+ }
745
+
746
+ private async upsertOptionAxes(
747
+ entityId: string,
748
+ item: ChannelCatalogItem,
749
+ actor: Actor,
750
+ ): Promise<PluginResult<{ value: Map<string, Map<string, string>>; changed: boolean }>> {
751
+ const optionValueIds = new Map<string, Map<string, string>>();
752
+ const existingTypes = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entityId));
753
+ let changed = false;
754
+ for (const [typeIndex, sourceType] of (item.options ?? []).entries()) {
755
+ let optionType = existingTypes.find((row) => row.name === sourceType.name);
756
+ if (!optionType) {
757
+ const created = await this.catalog.createOptionType({ entityId, name: sourceType.name, values: [] }, actor);
758
+ if (!created.ok) return PluginErr(created.error.message);
759
+ const [createdType] = await this.db.select().from(optionTypes).where(eq(optionTypes.id, created.value.id));
760
+ if (!createdType) return PluginErr(`Option type "${sourceType.name}" was not persisted.`);
761
+ optionType = createdType;
762
+ existingTypes.push(optionType);
763
+ changed = true;
764
+ }
765
+ await this.db.update(optionTypes).set({
766
+ displayName: sourceType.displayName,
767
+ sortOrder: sourceType.sortOrder ?? typeIndex,
768
+ }).where(eq(optionTypes.id, optionType.id));
769
+
770
+ const existingValues = await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, optionType.id));
771
+ const valueIds = new Map<string, string>();
772
+ for (const [valueIndex, sourceValue] of sourceType.values.entries()) {
773
+ let optionValue = existingValues.find((row) => row.value === sourceValue.value);
774
+ if (!optionValue) {
775
+ const created = await this.catalog.createOptionValue({ optionTypeId: optionType.id, value: sourceValue.value }, actor);
776
+ if (!created.ok) return PluginErr(created.error.message);
777
+ const [createdValue] = await this.db.select().from(optionValues).where(eq(optionValues.id, created.value.id));
778
+ if (!createdValue) return PluginErr(`Option value "${sourceValue.value}" was not persisted.`);
779
+ optionValue = createdValue;
780
+ existingValues.push(optionValue);
781
+ changed = true;
782
+ }
783
+ await this.db.update(optionValues).set({
784
+ displayValue: sourceValue.displayValue,
785
+ sortOrder: sourceValue.sortOrder ?? valueIndex,
786
+ }).where(eq(optionValues.id, optionValue.id));
787
+ valueIds.set(sourceValue.value, optionValue.id);
788
+ }
789
+ optionValueIds.set(sourceType.name, valueIds);
790
+ }
791
+ return Ok({ value: optionValueIds, changed });
792
+ }
793
+
794
+ private async upsertVariants(
795
+ orgId: string,
796
+ storeId: string,
797
+ entityId: string,
798
+ item: ChannelCatalogItem,
799
+ optionValueIds: Map<string, Map<string, string>>,
800
+ actor: Actor,
801
+ warnings: string[],
802
+ applyOptionValues: boolean,
803
+ fullItem: ChannelCatalogItem,
804
+ ): Promise<PluginResult<{ value: Map<string, string>; repaired: number; changed: boolean }>> {
805
+ const variantIds = new Map<string, string>();
806
+ let repaired = 0;
807
+ let changed = false;
808
+ const mappings = await this.db.select().from(channelEntityMap).where(and(
809
+ eq(channelEntityMap.organizationId, orgId),
810
+ eq(channelEntityMap.storeId, storeId),
811
+ eq(channelEntityMap.kind, "variant"),
812
+ eq(channelEntityMap.entityId, entityId),
813
+ ));
814
+ for (const sourceVariant of item.variants) {
815
+ const fullSourceVariant = fullItem.variants.find((variant) => variant.externalId === sourceVariant.externalId) ?? sourceVariant;
816
+ let mapping = mappings.find((row) => row.externalId === sourceVariant.externalId);
817
+ let variantId = mapping?.variantId;
818
+ const createdVariant = !variantId;
819
+ if (!variantId) {
820
+ const options: Record<string, string> = {};
821
+ for (const [name, value] of Object.entries(sourceVariant.optionValues ?? {})) {
822
+ const optionValueId = optionValueIds.get(name)?.get(value);
823
+ if (!optionValueId) {
824
+ warnings.push(`Skipped unmapped option "${name}=${value}" on variant "${sourceVariant.externalId}".`);
825
+ continue;
826
+ }
827
+ options[name] = value;
828
+ }
829
+ const created = await this.catalog.createVariant({
830
+ entityId,
831
+ options,
832
+ ...(sourceVariant.sku !== undefined ? { sku: sourceVariant.sku } : {}),
833
+ ...(sourceVariant.barcode !== undefined ? { barcode: sourceVariant.barcode } : {}),
834
+ }, actor);
835
+ if (!created.ok) return PluginErr(created.error.message);
836
+ variantId = created.value.id;
837
+ const [createdMapping] = await this.db.insert(channelEntityMap).values({
838
+ organizationId: orgId,
839
+ storeId,
840
+ kind: "variant",
841
+ externalId: sourceVariant.externalId,
842
+ entityId,
843
+ variantId,
844
+ syncHash: hash(fullSourceVariant),
845
+ }).returning();
846
+ mapping = createdMapping;
847
+ if (mapping) mappings.push(mapping);
848
+ }
849
+ if (!variantId) {
850
+ warnings.push(`Skipped variant "${sourceVariant.externalId}": no local variant mapping exists.`);
851
+ continue;
852
+ }
853
+ variantIds.set(sourceVariant.externalId, variantId);
854
+ if (applyOptionValues) {
855
+ const desiredOptionValueIds = Object.entries(sourceVariant.optionValues ?? {})
856
+ .map(([name, value]) => optionValueIds.get(name)?.get(value))
857
+ .filter((optionValueId): optionValueId is string => optionValueId !== undefined);
858
+ const currentOptionValues = await this.db.select().from(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
859
+ const currentIds = currentOptionValues.map((row) => row.optionValueId).sort();
860
+ const desiredIds = [...new Set(desiredOptionValueIds)].sort();
861
+ if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
862
+ await this.db.delete(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
863
+ if (desiredIds.length > 0) {
864
+ await this.db.insert(variantOptionValues).values(desiredIds.map((optionValueId) => ({ variantId, optionValueId }))).onConflictDoNothing();
865
+ repaired += 1;
866
+ }
867
+ changed = true;
868
+ }
869
+ if (createdVariant && desiredIds.length > 0) {
870
+ repaired += 1;
871
+ changed = true;
872
+ }
873
+ }
874
+ for (const price of sourceVariant.prices ?? []) {
875
+ const priced = await this.pricing.setBasePrice({
876
+ entityId,
877
+ variantId,
878
+ currency: price.currency,
879
+ amount: price.amount,
880
+ compareAtAmount: price.compareAtAmount ?? null,
881
+ }, actor);
882
+ if (!priced.ok) return PluginErr(priced.error.message);
883
+ }
884
+ if (mapping) {
885
+ await this.db.update(channelEntityMap).set({
886
+ syncHash: hash(fullSourceVariant),
887
+ }).where(eq(channelEntityMap.id, mapping.id));
888
+ }
889
+ }
890
+ return Ok({ value: variantIds, repaired, changed });
891
+ }
892
+
893
+ private async applyTaxonomy(
894
+ orgId: string,
895
+ entityId: string,
896
+ item: ChannelCatalogItem,
897
+ actor: Actor,
898
+ warnings: string[],
899
+ ): Promise<PluginResult<void>> {
900
+ const categoryRows = await this.db.select().from(categories).where(eq(categories.organizationId, orgId));
901
+ for (const slug of new Set(item.categories ?? [])) {
902
+ let category = categoryRows.find((row) => row.slug === slug);
903
+ if (category?.status === "archived") {
904
+ warnings.push(`Skipped archived category "${slug}".`);
905
+ continue;
906
+ }
907
+ if (!category) {
908
+ const created = await this.catalog.createCategory({ slug }, actor);
909
+ if (!created.ok) return PluginErr(created.error.message);
910
+ const [createdCategory] = await this.db.select().from(categories).where(eq(categories.id, created.value.id));
911
+ if (!createdCategory) return PluginErr(`Category "${slug}" was not persisted.`);
912
+ category = createdCategory;
913
+ categoryRows.push(category);
914
+ }
915
+ const linked = await this.catalog.addToCategory(entityId, category.id, actor);
916
+ if (!linked.ok) return PluginErr(linked.error.message);
917
+ }
918
+
919
+ const brandRows = await this.db.select().from(brands).where(eq(brands.organizationId, orgId));
920
+ if (item.brand) {
921
+ let brand = brandRows.find((row) => row.slug === item.brand);
922
+ if (!brand) {
923
+ const created = await this.catalog.createBrand({ slug: item.brand, displayName: item.brand }, actor);
924
+ if (!created.ok) return PluginErr(created.error.message);
925
+ const [createdBrand] = await this.db.select().from(brands).where(eq(brands.id, created.value.id));
926
+ if (!createdBrand) return PluginErr(`Brand "${item.brand}" was not persisted.`);
927
+ brand = createdBrand;
928
+ brandRows.push(brand);
929
+ }
930
+ const linked = await this.catalog.addToBrand(entityId, brand.id, actor);
931
+ if (!linked.ok) return PluginErr(linked.error.message);
932
+ }
933
+
934
+ const tagRows = await this.db.select().from(tags).where(eq(tags.organizationId, orgId));
935
+ for (const slug of new Set(item.tags ?? [])) {
936
+ let tag = tagRows.find((row) => row.slug === slug);
937
+ if (!tag) {
938
+ const [createdTag] = await this.db.insert(tags).values({ organizationId: orgId, slug, displayName: slug }).onConflictDoNothing().returning();
939
+ tag = createdTag ?? (await this.db.select().from(tags).where(and(
940
+ eq(tags.organizationId, orgId),
941
+ eq(tags.slug, slug),
942
+ )))[0];
943
+ if (!tag) return PluginErr(`Tag "${slug}" was not persisted.`);
944
+ tagRows.push(tag);
945
+ }
946
+ await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing();
947
+ }
948
+ return Ok(undefined);
949
+ }
950
+
951
+ private async applyMedia(
952
+ orgId: string,
953
+ entityId: string,
954
+ item: ChannelCatalogItem,
955
+ variantIds: Map<string, string>,
956
+ actor: Actor,
957
+ warnings: string[],
958
+ owners: Map<FieldPath, FieldOwner>,
959
+ ): Promise<PluginResult<{ imported: number; changed: boolean; skipped: FieldPath[] }>> {
960
+ const assets = await this.db.select().from(mediaAssets).where(eq(mediaAssets.organizationId, orgId));
961
+ const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entityId));
962
+ let imported = 0;
963
+ let changed = false;
964
+ const skipped: FieldPath[] = [];
965
+ for (const image of item.images ?? []) {
966
+ const urlHash = hash(image.url);
967
+ const asset = assets.find((row) => {
968
+ const metadata = row.metadata ?? {};
969
+ return (image.externalId != null && metadata.channelImageExternalId === image.externalId)
970
+ || metadata.channelImageUrlHash === urlHash;
971
+ });
972
+ let mediaAssetId = asset?.id;
973
+ if (!mediaAssetId) {
974
+ let response: Response;
975
+ try {
976
+ response = await fetch(image.url);
977
+ } catch (error) {
978
+ warnings.push(`Skipped image "${image.externalId ?? image.url}": ${error instanceof Error ? error.message : "download failed"}.`);
979
+ continue;
980
+ }
981
+ if (!response.ok) {
982
+ warnings.push(`Skipped image "${image.externalId ?? image.url}": download returned ${response.status}.`);
983
+ continue;
984
+ }
985
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0] ?? "image/jpeg";
986
+ const extension = contentType.split("/", 2)[1] ?? "jpg";
987
+ const uploaded = await this.media.upload({
988
+ filename: `${image.externalId ?? urlHash}.${extension}`,
989
+ contentType,
990
+ data: await response.arrayBuffer(),
991
+ ...(image.alt !== undefined ? { alt: image.alt } : {}),
992
+ metadata: {
993
+ channelImageUrlHash: urlHash,
994
+ ...(image.externalId !== undefined ? { channelImageExternalId: image.externalId } : {}),
995
+ },
996
+ origin: "imported",
997
+ }, actor);
998
+ if (!uploaded.ok) {
999
+ warnings.push(`Skipped image "${image.externalId ?? image.url}": ${uploaded.error.code === "STORAGE_NOT_SUPPORTED" ? "storage adapter is not configured" : uploaded.error.message}.`);
1000
+ continue;
1001
+ }
1002
+ mediaAssetId = uploaded.value.id;
1003
+ imported += 1;
1004
+ changed = true;
1005
+ const [createdAsset] = await this.db.select().from(mediaAssets).where(eq(mediaAssets.id, mediaAssetId));
1006
+ if (createdAsset) assets.push(createdAsset);
1007
+ }
1008
+ if (!mediaAssetId) continue;
1009
+
1010
+ const targets = image.variantExternalIds?.length
1011
+ ? image.variantExternalIds.map((externalId) => ({ externalId, variantId: variantIds.get(externalId) }))
1012
+ : [{ externalId: undefined, variantId: undefined }];
1013
+ for (const target of targets) {
1014
+ if (image.variantExternalIds?.length && !target.variantId) {
1015
+ warnings.push(`Skipped image "${image.externalId ?? image.url}" for unmapped variant "${target.externalId}".`);
1016
+ continue;
1017
+ }
1018
+ const existingLink = links.find((link) =>
1019
+ link.mediaAssetId === mediaAssetId
1020
+ && (target.variantId === undefined ? link.variantId === null : link.variantId === target.variantId),
1021
+ );
1022
+ if (existingLink) {
1023
+ if (existingLink.role !== image.role) {
1024
+ const currentRolePath = `media.${existingLink.role}` as FieldPath;
1025
+ const incomingRolePath = `media.${image.role}` as FieldPath;
1026
+ for (const path of [currentRolePath, incomingRolePath]) {
1027
+ if (owners.get(path) === "platform" && !skipped.includes(path)) skipped.push(path);
1028
+ }
1029
+ if (skipped.includes(currentRolePath) || skipped.includes(incomingRolePath)) continue;
1030
+ }
1031
+ if (existingLink.role !== image.role || existingLink.sortOrder !== (image.sortOrder ?? 0)) {
1032
+ await this.db.update(entityMedia).set({ role: image.role, sortOrder: image.sortOrder ?? 0 }).where(and(
1033
+ eq(entityMedia.entityId, entityId),
1034
+ eq(entityMedia.mediaAssetId, mediaAssetId),
1035
+ target.variantId === undefined ? isNull(entityMedia.variantId) : eq(entityMedia.variantId, target.variantId),
1036
+ ));
1037
+ changed = true;
1038
+ }
1039
+ continue;
1040
+ }
1041
+ const attached = await this.media.attachToEntity({
1042
+ entityId,
1043
+ mediaAssetId,
1044
+ role: image.role,
1045
+ sortOrder: image.sortOrder ?? 0,
1046
+ ...(target.variantId !== undefined ? { variantId: target.variantId } : {}),
1047
+ }, actor);
1048
+ if (!attached.ok) return PluginErr(attached.error.message);
1049
+ changed = true;
1050
+ links.push({
1051
+ entityId,
1052
+ mediaAssetId,
1053
+ role: image.role,
1054
+ sortOrder: image.sortOrder ?? 0,
1055
+ variantId: target.variantId ?? null,
1056
+ createdAt: new Date(),
1057
+ });
1058
+ }
1059
+ }
1060
+ return Ok({ imported, changed, skipped });
1061
+ }
1062
+
185
1063
  private async getStoreRecord(orgId: string, id: string): Promise<ConnectedStore | undefined> {
186
1064
  const rows = await this.db
187
1065
  .select()
@@ -208,6 +1086,203 @@ export class ChannelConnectorService {
208
1086
  return rows as ConnectedStore[];
209
1087
  }
210
1088
 
1089
+ resolveCatalogFieldMapping(
1090
+ store: Pick<ConnectedStore, "provider" | "catalogFieldMapping">,
1091
+ filterableCustomFields?: ReadonlySet<string> | Readonly<Record<string, boolean>>,
1092
+ warnings: string[] = [],
1093
+ ): CatalogFieldMapping {
1094
+ return mergeCatalogFieldMapping(store.provider, store.catalogFieldMapping, filterableCustomFields, warnings);
1095
+ }
1096
+
1097
+ async buildCatalogPushItems(
1098
+ orgId: string,
1099
+ storeId: string,
1100
+ entityIds: string[],
1101
+ options: BuildCatalogPushItemsOptions = {},
1102
+ ): Promise<PluginResult<BuildCatalogPushItemsResult>> {
1103
+ const store = await this.getStoreRecord(orgId, storeId);
1104
+ if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
1105
+ if (!store.catalogWriteEnabled) return PluginErr("Catalog writes are disabled for this store.", "CATALOG_WRITE_DISABLED");
1106
+ if (entityIds.length === 0) return Ok({ items: [], skipped: [], warnings: [] });
1107
+
1108
+ const entities = await this.db.select().from(sellableEntities).where(and(
1109
+ eq(sellableEntities.organizationId, orgId),
1110
+ inArray(sellableEntities.id, entityIds),
1111
+ ));
1112
+ const entityById = new Map(entities.map((entity) => [entity.id, entity]));
1113
+ const mappings = await this.db.select().from(channelEntityMap).where(and(
1114
+ eq(channelEntityMap.organizationId, orgId),
1115
+ eq(channelEntityMap.storeId, storeId),
1116
+ eq(channelEntityMap.kind, "entity"),
1117
+ inArray(channelEntityMap.entityId, entityIds),
1118
+ ));
1119
+ const mappingByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping]));
1120
+ const items: ChannelPushCatalogItem[] = [];
1121
+ const skipped: CatalogPushFieldSkip[] = [];
1122
+ const warnings: string[] = [];
1123
+ const revisionEntityIds: string[] = [];
1124
+
1125
+ for (const entityId of entityIds) {
1126
+ const entity = entityById.get(entityId);
1127
+ if (!entity) return PluginErr("Catalog entity not found.", "NOT_FOUND");
1128
+ if (entity.status !== "active") {
1129
+ skipped.push({ entityId, fieldPath: "entity.status", reason: "entity_not_active" });
1130
+ continue;
1131
+ }
1132
+ const entityMapping = mappingByEntity.get(entity.id);
1133
+ if (!entityMapping) {
1134
+ skipped.push({ entityId, fieldPath: "entity", reason: "unmapped_entity" });
1135
+ continue;
1136
+ }
1137
+ const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
1138
+ const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
1139
+ const customFields = await this.db.select().from(sellableCustomFields).where(and(
1140
+ eq(sellableCustomFields.entityId, entity.id),
1141
+ eq(sellableCustomFields.status, "approved"),
1142
+ ));
1143
+ const customFieldNames = [...new Set(customFields.map((field) => field.fieldName))];
1144
+ const definitions = customFieldNames.length > 0
1145
+ ? await this.db.select({ name: entityFieldDefinitions.name, filterable: entityFieldDefinitions.filterable }).from(entityFieldDefinitions).where(and(
1146
+ eq(entityFieldDefinitions.organizationId, orgId),
1147
+ eq(entityFieldDefinitions.entityType, entity.type),
1148
+ inArray(entityFieldDefinitions.name, customFieldNames),
1149
+ ))
1150
+ : [];
1151
+ const filterableCustomFields = Object.fromEntries(definitions.map((definition) => [
1152
+ `customFields.${definition.name}.en`,
1153
+ definition.filterable,
1154
+ ]));
1155
+ for (const field of customFields) {
1156
+ filterableCustomFields[`customFields.${field.fieldName}.${field.locale}`] = definitions.find(
1157
+ (definition) => definition.name === field.fieldName,
1158
+ )?.filterable ?? false;
1159
+ }
1160
+ const fieldMapping = this.resolveCatalogFieldMapping(store, filterableCustomFields, warnings);
1161
+ const heldPaths = new Set(entityMapping.heldFieldPaths ?? []);
1162
+ const fields: ChannelPushCatalogField[] = [];
1163
+ const appendField = (fieldPath: FieldPath, value: unknown) => {
1164
+ if (value === undefined || owners.get(fieldPath) !== "platform") return;
1165
+ if (heldPaths.has(fieldPath)) {
1166
+ skipped.push({ entityId, fieldPath, reason: "held" });
1167
+ return;
1168
+ }
1169
+ const mapping = selectCatalogFieldMapping(fieldMapping, fieldPath);
1170
+ if (!mapping) {
1171
+ skipped.push({ entityId, fieldPath, reason: "no_mapping" });
1172
+ return;
1173
+ }
1174
+ fields.push(pushCatalogField(fieldPath, value, mapping));
1175
+ };
1176
+
1177
+ for (const attribute of attributes) {
1178
+ for (const field of attributeFields) {
1179
+ appendField(`attributes.${attribute.locale}.${field}`, attribute[field]);
1180
+ }
1181
+ }
1182
+ for (const [key, value] of Object.entries(entity.metadata ?? {})) {
1183
+ const fieldPath = `entity.metadata.${key}`;
1184
+ if (isValidFieldPath(fieldPath)) appendField(fieldPath, value);
1185
+ }
1186
+ for (const customField of customFields) {
1187
+ const fieldPath = `customFields.${customField.fieldName}.${customField.locale}`;
1188
+ if (isValidFieldPath(fieldPath)) appendField(fieldPath, customFieldValue(customField));
1189
+ }
1190
+
1191
+ const media = await this.media.listEntityMedia(entity.id, { orgId });
1192
+ if (!media.ok) return PluginErr(media.error.message);
1193
+ const images: ChannelPushCatalogImage[] = [];
1194
+ for (const attached of media.value) {
1195
+ const role = pushCatalogImageRole(attached.role);
1196
+ if (!role) continue;
1197
+ const fieldPath = `media.${role}` as FieldPath;
1198
+ if (owners.get(fieldPath) !== "platform") continue;
1199
+ if (heldPaths.has(fieldPath)) {
1200
+ skipped.push({ entityId, fieldPath, reason: "held" });
1201
+ continue;
1202
+ }
1203
+ if (!selectCatalogFieldMapping(fieldMapping, fieldPath)) {
1204
+ skipped.push({ entityId, fieldPath, reason: "no_mapping" });
1205
+ continue;
1206
+ }
1207
+ images.push({
1208
+ url: attached.url,
1209
+ role,
1210
+ sortOrder: attached.sortOrder,
1211
+ ...(attached.alt !== null ? { alt: attached.alt } : {}),
1212
+ });
1213
+ }
1214
+ fields.sort((left, right) => left.fieldPath.localeCompare(right.fieldPath));
1215
+ const item: ChannelPushCatalogItem = {
1216
+ externalId: entityMapping.externalId,
1217
+ fields,
1218
+ ...(images.length > 0 ? { images } : {}),
1219
+ };
1220
+ items.push(item);
1221
+ if (options.recordRevision === true) revisionEntityIds.push(entity.id);
1222
+ }
1223
+ if (options.recordRevision === true && revisionEntityIds.length > 0) {
1224
+ const actor = createSystemActor(orgId);
1225
+ try {
1226
+ await this.transact(async (tx) => {
1227
+ const txContext = createTxContext(tx, { actor });
1228
+ for (const entityId of revisionEntityIds) {
1229
+ const revision = await this.catalog.recordEntityRevision(entityId, actor, "push", txContext);
1230
+ if (!revision.ok) throw new Error(revision.error.message);
1231
+ }
1232
+ });
1233
+ } catch (error) {
1234
+ return PluginErr(error instanceof Error ? error.message : "Failed to record catalog push revisions.");
1235
+ }
1236
+ }
1237
+ return Ok({ items, skipped, warnings: [...new Set(warnings)] });
1238
+ }
1239
+
1240
+ async getCatalogWriteSettings(orgId: string, storeId: string): Promise<PluginResult<CatalogWriteSettings>> {
1241
+ const store = await this.getStoreRecord(orgId, storeId);
1242
+ if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
1243
+ const warnings: string[] = [];
1244
+ return Ok({
1245
+ enabled: store.catalogWriteEnabled === true,
1246
+ overrides: store.catalogFieldMapping,
1247
+ merged: this.resolveCatalogFieldMapping(store, undefined, warnings),
1248
+ ...(warnings.length > 0 ? { warnings } : {}),
1249
+ });
1250
+ }
1251
+
1252
+ async updateCatalogWriteEnabled(
1253
+ orgId: string,
1254
+ storeId: string,
1255
+ enabled: boolean,
1256
+ ): Promise<PluginResult<CatalogWriteSettings>> {
1257
+ const rows = await this.db
1258
+ .update(connectedStores)
1259
+ .set({ catalogWriteEnabled: enabled, updatedAt: new Date() })
1260
+ .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)))
1261
+ .returning();
1262
+ if (!rows[0]) return PluginErr("Connected store not found.", "NOT_FOUND");
1263
+ return this.getCatalogWriteSettings(orgId, storeId);
1264
+ }
1265
+
1266
+ async updateCatalogFieldMapping(
1267
+ orgId: string,
1268
+ storeId: string,
1269
+ mapping: unknown,
1270
+ ): Promise<PluginResult<CatalogWriteSettings>> {
1271
+ const store = await this.getStoreRecord(orgId, storeId);
1272
+ if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
1273
+ let normalized: CatalogFieldMapping;
1274
+ try {
1275
+ normalized = normalizeCatalogFieldMapping(mapping as CatalogFieldMappingInput, store.provider);
1276
+ } catch (error) {
1277
+ return PluginErr(error instanceof Error ? error.message : "Catalog mapping is invalid.", "INVALID_MAPPING");
1278
+ }
1279
+ await this.db
1280
+ .update(connectedStores)
1281
+ .set({ catalogFieldMapping: normalized, updatedAt: new Date() })
1282
+ .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
1283
+ return this.getCatalogWriteSettings(orgId, storeId);
1284
+ }
1285
+
211
1286
  async connectStore(
212
1287
  orgId: string,
213
1288
  input: {
@@ -220,16 +1295,37 @@ export class ChannelConnectorService {
220
1295
  if (!this.connectors.has(input.provider)) {
221
1296
  return PluginErr(`No connector registered for provider "${input.provider}".`, "NOT_FOUND");
222
1297
  }
223
- const rows = await this.db
224
- .insert(connectedStores)
225
- .values({
226
- organizationId: orgId,
227
- provider: input.provider,
228
- credentials: input.credentials,
229
- storeDomain: input.storeDomain,
230
- webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
231
- })
232
- .returning();
1298
+ const existingRows = await this.db
1299
+ .select()
1300
+ .from(connectedStores)
1301
+ .where(and(
1302
+ eq(connectedStores.organizationId, orgId),
1303
+ eq(connectedStores.provider, input.provider),
1304
+ eq(connectedStores.storeDomain, input.storeDomain),
1305
+ ));
1306
+ const reconnect = existingRows.find((row) => row.status !== "connected");
1307
+ const rows = reconnect
1308
+ ? await this.db
1309
+ .update(connectedStores)
1310
+ .set({
1311
+ credentials: input.credentials,
1312
+ status: "connected",
1313
+ catalogWriteEnabled: false,
1314
+ webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
1315
+ updatedAt: new Date(),
1316
+ })
1317
+ .where(eq(connectedStores.id, reconnect.id))
1318
+ .returning()
1319
+ : await this.db
1320
+ .insert(connectedStores)
1321
+ .values({
1322
+ organizationId: orgId,
1323
+ provider: input.provider,
1324
+ credentials: input.credentials,
1325
+ storeDomain: input.storeDomain,
1326
+ webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
1327
+ })
1328
+ .returning();
233
1329
  const connector = this.connectors.get(input.provider)!;
234
1330
  const store = rows[0] as ConnectedStore;
235
1331
  if (connector.registerWebhooks) {
@@ -372,7 +1468,7 @@ export class ChannelConnectorService {
372
1468
  orgId: string,
373
1469
  storeId: string,
374
1470
  actor: Actor,
375
- ): Promise<PluginResult<{ imported: number; cursor: string | null }>> {
1471
+ ): Promise<PluginResult<{ imported: number; cursor: string | null; skipped?: CatalogFieldSkip[]; conflicts?: CatalogFieldConflict[]; warnings?: string[] }>> {
376
1472
  const store = await this.getStoreRecord(orgId, storeId);
377
1473
  if (!store || store.status !== "connected") {
378
1474
  return PluginErr("Connected store not found.", "NOT_FOUND");
@@ -396,7 +1492,323 @@ export class ChannelConnectorService {
396
1492
  .update(connectedStores)
397
1493
  .set({ catalogCursor: null, lastSyncAt: new Date(), updatedAt: new Date() })
398
1494
  .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
399
- return Ok({ imported: result.value.imported, cursor: null });
1495
+ return Ok({
1496
+ imported: result.value.imported,
1497
+ cursor: null,
1498
+ ...(result.value.skipped.length > 0 ? { skipped: uniqueSkipped(result.value.skipped) } : {}),
1499
+ ...(result.value.conflicts.length > 0 ? { conflicts: result.value.conflicts } : {}),
1500
+ ...(result.value.warnings.length > 0 ? { warnings: result.value.warnings } : {}),
1501
+ });
1502
+ }
1503
+
1504
+ private async promoteLegacyAttributes(
1505
+ orgId: string,
1506
+ storeId: string,
1507
+ actor: Actor,
1508
+ dryRun: boolean,
1509
+ ): Promise<PluginResult<number>> {
1510
+ const mappings = await this.db.select().from(channelEntityMap).where(and(
1511
+ eq(channelEntityMap.organizationId, orgId),
1512
+ eq(channelEntityMap.storeId, storeId),
1513
+ eq(channelEntityMap.kind, "entity"),
1514
+ ));
1515
+ let created = 0;
1516
+ for (const entityId of new Set(mappings.map((mapping) => mapping.entityId))) {
1517
+ const [entity] = await this.db.select().from(sellableEntities).where(and(
1518
+ eq(sellableEntities.organizationId, orgId),
1519
+ eq(sellableEntities.id, entityId),
1520
+ ));
1521
+ if (!entity) continue;
1522
+ const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
1523
+ if (attributes.length > 0) continue;
1524
+ const metadata = entity.metadata ?? {};
1525
+ if (typeof metadata.title !== "string") continue;
1526
+ const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
1527
+ if (owners.get("attributes.en.title") === "platform") continue;
1528
+ if (dryRun) {
1529
+ created += 1;
1530
+ continue;
1531
+ }
1532
+ const promoted = await this.catalog.setAttributes(entity.id, "en", {
1533
+ title: metadata.title,
1534
+ ...(typeof metadata.description === "string" ? { description: metadata.description } : {}),
1535
+ }, actor);
1536
+ if (!promoted.ok) return PluginErr(promoted.error.message);
1537
+ const [confirmed] = await this.db.select({ id: sellableAttributes.id, title: sellableAttributes.title, description: sellableAttributes.description }).from(sellableAttributes).where(and(
1538
+ eq(sellableAttributes.entityId, entity.id),
1539
+ eq(sellableAttributes.locale, "en"),
1540
+ ));
1541
+ if (!confirmed || confirmed.title !== metadata.title || (typeof metadata.description === "string" && confirmed.description !== metadata.description)) {
1542
+ return PluginErr(`Legacy attributes for entity "${entity.id}" were not persisted.`);
1543
+ }
1544
+ const nextMetadata = { ...metadata };
1545
+ delete nextMetadata.title;
1546
+ if (typeof metadata.description === "string") delete nextMetadata.description;
1547
+ await this.db.update(sellableEntities).set({ metadata: nextMetadata, updatedAt: new Date() }).where(and(
1548
+ eq(sellableEntities.organizationId, orgId),
1549
+ eq(sellableEntities.id, entity.id),
1550
+ ));
1551
+ created += 1;
1552
+ }
1553
+ return Ok(created);
1554
+ }
1555
+
1556
+ private async saveBackfillState(orgId: string, storeId: string, state: BackfillState): Promise<void> {
1557
+ const [store] = await this.db.select({ breakerState: connectedStores.breakerState }).from(connectedStores).where(and(
1558
+ eq(connectedStores.organizationId, orgId),
1559
+ eq(connectedStores.id, storeId),
1560
+ ));
1561
+ await this.db.update(connectedStores).set({
1562
+ breakerState: { ...(store?.breakerState ?? {}), catalogBackfill: state },
1563
+ updatedAt: new Date(),
1564
+ }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
1565
+ }
1566
+
1567
+ async backfillCatalog(
1568
+ orgId: string,
1569
+ storeId: string,
1570
+ actor: Actor,
1571
+ options: BackfillCatalogOptions = {},
1572
+ ): Promise<PluginResult<BackfillCatalogReport>> {
1573
+ const store = await this.getStoreRecord(orgId, storeId);
1574
+ if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
1575
+ const connector = this.connectors.get(store.provider);
1576
+ if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`);
1577
+ const dryRun = options.dryRun === true;
1578
+ const saved = store.breakerState.catalogBackfill;
1579
+ const savedState = saved && typeof saved === "object" ? saved as unknown as BackfillState : undefined;
1580
+ // Undefined resume derives from persisted state, so a retried job or a
1581
+ // re-triggered run continues an unfinished backfill instead of restarting.
1582
+ const resume = options.resume ?? (savedState !== undefined && !savedState.completedAt);
1583
+ if (resume && savedState?.completedAt && savedState.cursor === null) {
1584
+ return Ok({
1585
+ ...savedState.report,
1586
+ cursor: null,
1587
+ complete: true,
1588
+ ...(savedState.skipped?.length ? { skipped: savedState.skipped } : {}),
1589
+ ...(savedState.conflicts?.length ? { conflicts: savedState.conflicts } : {}),
1590
+ ...(savedState.warnings?.length ? { warnings: savedState.warnings } : {}),
1591
+ });
1592
+ }
1593
+ const report = resume && savedState ? { ...savedState.report } : {
1594
+ entitiesTouched: 0,
1595
+ attributesCreated: 0,
1596
+ mediaImported: 0,
1597
+ variantsGivenOptionValues: 0,
1598
+ };
1599
+ const skipped = resume && savedState?.skipped ? [...savedState.skipped] : [];
1600
+ const conflicts = resume && savedState?.conflicts ? [...savedState.conflicts] : [];
1601
+ const warnings = resume && savedState?.warnings ? [...savedState.warnings] : [];
1602
+ const promoted = await this.promoteLegacyAttributes(orgId, storeId, actor, dryRun);
1603
+ if (!promoted.ok) return promoted;
1604
+ report.attributesCreated += promoted.value;
1605
+ let cursor = resume && savedState?.cursor ? savedState.cursor : undefined;
1606
+ let pages = 0;
1607
+ if (!dryRun) {
1608
+ await this.saveBackfillState(orgId, storeId, {
1609
+ cursor: cursor ?? null,
1610
+ report,
1611
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
1612
+ ...(conflicts.length > 0 ? { conflicts } : {}),
1613
+ ...(warnings.length > 0 ? { warnings } : {}),
1614
+ });
1615
+ }
1616
+ do {
1617
+ const page = await connector.importCatalog(store as ChannelStore, cursor);
1618
+ if (!page.ok) return PluginErr(page.error.message);
1619
+ const converged = await this.convergeCatalogItems(orgId, storeId, page.value.items, actor, true, dryRun);
1620
+ if (!converged.ok) return converged;
1621
+ report.entitiesTouched += converged.value.entitiesTouched;
1622
+ report.attributesCreated += converged.value.attributesCreated;
1623
+ report.mediaImported += converged.value.mediaImported;
1624
+ report.variantsGivenOptionValues += converged.value.variantsGivenOptionValues;
1625
+ skipped.push(...converged.value.skipped);
1626
+ conflicts.push(...converged.value.conflicts);
1627
+ warnings.push(...converged.value.warnings);
1628
+ cursor = page.value.nextCursor ?? undefined;
1629
+ pages += 1;
1630
+ // The final state is written once with completedAt below; a cursor-null
1631
+ // checkpoint without it would read as a fresh start after a crash.
1632
+ if (!dryRun && cursor) {
1633
+ await this.saveBackfillState(orgId, storeId, {
1634
+ cursor,
1635
+ report,
1636
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
1637
+ ...(conflicts.length > 0 ? { conflicts } : {}),
1638
+ ...(warnings.length > 0 ? { warnings } : {}),
1639
+ });
1640
+ }
1641
+ if (options.maxPages !== undefined && pages >= options.maxPages && cursor) {
1642
+ return Ok({
1643
+ ...report,
1644
+ cursor,
1645
+ complete: false,
1646
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
1647
+ ...(conflicts.length > 0 ? { conflicts } : {}),
1648
+ ...(warnings.length > 0 ? { warnings } : {}),
1649
+ });
1650
+ }
1651
+ } while (cursor);
1652
+ if (!dryRun) {
1653
+ await this.saveBackfillState(orgId, storeId, {
1654
+ cursor: null,
1655
+ report,
1656
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
1657
+ ...(conflicts.length > 0 ? { conflicts } : {}),
1658
+ ...(warnings.length > 0 ? { warnings } : {}),
1659
+ completedAt: new Date().toISOString(),
1660
+ });
1661
+ }
1662
+ return Ok({
1663
+ ...report,
1664
+ cursor: null,
1665
+ complete: true,
1666
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
1667
+ ...(conflicts.length > 0 ? { conflicts } : {}),
1668
+ ...(warnings.length > 0 ? { warnings } : {}),
1669
+ });
1670
+ }
1671
+
1672
+ private async estimateCatalogItems(
1673
+ orgId: string,
1674
+ storeId: string,
1675
+ items: ChannelCatalogItem[],
1676
+ ): Promise<PluginResult<CatalogConvergenceStats>> {
1677
+ const stats: CatalogConvergenceStats = {
1678
+ imported: 0,
1679
+ converged: 0,
1680
+ entitiesTouched: 0,
1681
+ attributesCreated: 0,
1682
+ mediaImported: 0,
1683
+ variantsGivenOptionValues: 0,
1684
+ skipped: [],
1685
+ conflicts: [],
1686
+ warnings: [],
1687
+ };
1688
+ const assets = await this.db.select().from(mediaAssets).where(eq(mediaAssets.organizationId, orgId));
1689
+ for (const item of items) {
1690
+ const [entityMapping] = await this.db.select().from(channelEntityMap).where(and(
1691
+ eq(channelEntityMap.organizationId, orgId),
1692
+ eq(channelEntityMap.storeId, storeId),
1693
+ eq(channelEntityMap.kind, "entity"),
1694
+ eq(channelEntityMap.externalId, item.externalId),
1695
+ ));
1696
+ if (!entityMapping) {
1697
+ stats.imported += 1;
1698
+ stats.entitiesTouched += 1;
1699
+ stats.attributesCreated += item.attributes?.length || 1;
1700
+ stats.variantsGivenOptionValues += item.variants.filter((variant) => Object.keys(variant.optionValues ?? {}).some((name) => item.options?.some((option) => option.name === name))).length;
1701
+ stats.mediaImported += item.images?.length ?? 0;
1702
+ continue;
1703
+ }
1704
+ const [entity] = await this.db.select().from(sellableEntities).where(and(
1705
+ eq(sellableEntities.organizationId, orgId),
1706
+ eq(sellableEntities.id, entityMapping.entityId),
1707
+ ));
1708
+ if (!entity) continue;
1709
+ const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
1710
+ stats.skipped.push(...importedFieldPaths(item)
1711
+ .filter((path) => owners.get(path) === "platform")
1712
+ .map((fieldPath) => ({ entityId: entity.id, fieldPath })));
1713
+ let touched = false;
1714
+ const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
1715
+ const locales = new Set(attributes.map((attribute) => attribute.locale));
1716
+ const metadata = entity.metadata ?? {};
1717
+ if (attributes.length === 0 && typeof metadata.title === "string") {
1718
+ locales.add("en");
1719
+ touched = true;
1720
+ }
1721
+ const sourceAttributes = item.attributes?.length
1722
+ ? item.attributes
1723
+ : [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
1724
+ for (const attribute of sourceAttributes) {
1725
+ if (!locales.has(attribute.locale)) {
1726
+ stats.attributesCreated += 1;
1727
+ locales.add(attribute.locale);
1728
+ touched = true;
1729
+ }
1730
+ }
1731
+ const remoteMetadata = mergeMetadata(entity.metadata, item.metadata ?? {});
1732
+ const remoteStatus = item.status ?? (entity.status === "archived" ? "active" : undefined);
1733
+ const entityChanged = entity.slug !== item.slug
1734
+ || hash(remoteMetadata) !== hash(entity.metadata ?? {})
1735
+ || (remoteStatus !== undefined && remoteStatus !== entity.status);
1736
+ if (entityChanged) {
1737
+ stats.converged += 1;
1738
+ touched = true;
1739
+ }
1740
+
1741
+ const optionValueIds = new Map<string, Map<string, string>>();
1742
+ const existingTypes = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entity.id));
1743
+ for (const sourceType of item.options ?? []) {
1744
+ const existingType = existingTypes.find((optionType) => optionType.name === sourceType.name);
1745
+ if (!existingType) {
1746
+ touched = true;
1747
+ optionValueIds.set(sourceType.name, new Map(sourceType.values.map((value) => [value.value, `new:${sourceType.name}:${value.value}`])));
1748
+ continue;
1749
+ }
1750
+ const existingValues = await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, existingType.id));
1751
+ const valueIds = new Map<string, string>();
1752
+ for (const sourceValue of sourceType.values) {
1753
+ const existingValue = existingValues.find((value) => value.value === sourceValue.value);
1754
+ if (!existingValue) touched = true;
1755
+ valueIds.set(sourceValue.value, existingValue?.id ?? `new:${sourceType.name}:${sourceValue.value}`);
1756
+ }
1757
+ optionValueIds.set(sourceType.name, valueIds);
1758
+ }
1759
+
1760
+ const variantMappings = await this.db.select().from(channelEntityMap).where(and(
1761
+ eq(channelEntityMap.organizationId, orgId),
1762
+ eq(channelEntityMap.storeId, storeId),
1763
+ eq(channelEntityMap.kind, "variant"),
1764
+ eq(channelEntityMap.entityId, entity.id),
1765
+ ));
1766
+ const variantIds = new Map<string, string>();
1767
+ for (const sourceVariant of item.variants) {
1768
+ const mapping = variantMappings.find((row) => row.externalId === sourceVariant.externalId);
1769
+ const variantId = mapping?.variantId ?? `new:${sourceVariant.externalId}`;
1770
+ variantIds.set(sourceVariant.externalId, variantId);
1771
+ const desiredIds = [...new Set(Object.entries(sourceVariant.optionValues ?? {})
1772
+ .map(([name, value]) => optionValueIds.get(name)?.get(value))
1773
+ .filter((optionValueId): optionValueId is string => optionValueId !== undefined))].sort();
1774
+ if (!mapping?.variantId) {
1775
+ if (desiredIds.length > 0) stats.variantsGivenOptionValues += 1;
1776
+ touched = true;
1777
+ continue;
1778
+ }
1779
+ const current = await this.db.select().from(variantOptionValues).where(eq(variantOptionValues.variantId, mapping.variantId));
1780
+ const currentIds = current.map((row) => row.optionValueId).sort();
1781
+ if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
1782
+ if (desiredIds.length > 0) stats.variantsGivenOptionValues += 1;
1783
+ touched = true;
1784
+ }
1785
+ }
1786
+
1787
+ const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entity.id));
1788
+ for (const image of item.images ?? []) {
1789
+ const urlHash = hash(image.url);
1790
+ const asset = assets.find((row) => {
1791
+ const assetMetadata = row.metadata ?? {};
1792
+ return (image.externalId != null && assetMetadata.channelImageExternalId === image.externalId)
1793
+ || assetMetadata.channelImageUrlHash === urlHash;
1794
+ });
1795
+ const mediaAssetId = asset?.id ?? `new:${urlHash}`;
1796
+ if (!asset) stats.mediaImported += 1;
1797
+ const targets = image.variantExternalIds?.length
1798
+ ? image.variantExternalIds.map((externalId) => ({ externalId, variantId: variantIds.get(externalId) }))
1799
+ : [{ externalId: undefined, variantId: undefined }];
1800
+ for (const target of targets) {
1801
+ if (image.variantExternalIds?.length && !target.variantId) {
1802
+ stats.warnings.push(`Skipped image "${image.externalId ?? image.url}" for unmapped variant "${target.externalId}".`);
1803
+ continue;
1804
+ }
1805
+ const existingLink = links.find((link) => link.mediaAssetId === mediaAssetId && (target.variantId === undefined ? link.variantId === null : link.variantId === target.variantId));
1806
+ if (!existingLink) touched = true;
1807
+ }
1808
+ }
1809
+ if (touched) stats.entitiesTouched += 1;
1810
+ }
1811
+ return Ok(stats);
400
1812
  }
401
1813
 
402
1814
  private async convergeCatalogItems(
@@ -404,10 +1816,21 @@ export class ChannelConnectorService {
404
1816
  storeId: string,
405
1817
  items: ChannelCatalogItem[],
406
1818
  actor: Actor,
407
- ): Promise<PluginResult<{ imported: number; converged: number }>> {
1819
+ force = false,
1820
+ dryRun = false,
1821
+ ): Promise<PluginResult<CatalogConvergenceStats>> {
1822
+ if (dryRun) return this.estimateCatalogItems(orgId, storeId, items);
408
1823
  let imported = 0;
409
1824
  let converged = 0;
1825
+ let entitiesTouched = 0;
1826
+ let attributesCreated = 0;
1827
+ let mediaImported = 0;
1828
+ let variantsGivenOptionValues = 0;
1829
+ const skipped: CatalogFieldSkip[] = [];
1830
+ const conflicts: CatalogFieldConflict[] = [];
1831
+ const warnings: string[] = [];
410
1832
  for (const item of items) {
1833
+ const remoteHash = hash(item);
411
1834
  const existing = await this.db
412
1835
  .select()
413
1836
  .from(channelEntityMap)
@@ -418,76 +1841,168 @@ export class ChannelConnectorService {
418
1841
  eq(channelEntityMap.externalId, item.externalId),
419
1842
  ));
420
1843
  const entityMapping = existing.find((entry) => entry.kind === "entity");
1844
+ let entityId: string;
1845
+ let isNew = false;
1846
+ let entityTouched = false;
1847
+ let existingEntity: typeof sellableEntities.$inferSelect | undefined;
421
1848
  if (entityMapping) {
422
1849
  const [entity] = await this.db.select().from(sellableEntities).where(and(
423
1850
  eq(sellableEntities.organizationId, orgId),
424
1851
  eq(sellableEntities.id, entityMapping.entityId),
425
1852
  ));
426
- if (entityMapping.syncHash !== hash(item) || entity?.status === "archived") {
427
- const updated = await this.catalog.update(entityMapping.entityId, {
428
- slug: item.slug,
429
- metadata: {
430
- ...(item.metadata ?? {}),
431
- title: item.title,
432
- ...(item.description !== undefined ? { description: item.description } : {}),
433
- },
434
- ...(entity?.status === "archived" ? { status: "active", isVisible: true } : {}),
435
- }, actor);
436
- if (!updated.ok) return PluginErr(updated.error.message);
437
- await this.db.update(channelEntityMap).set({ syncHash: hash(item), lastSyncedAt: new Date() }).where(eq(channelEntityMap.id, entityMapping.id));
438
- converged += 1;
1853
+ if (!entity) {
1854
+ warnings.push(`Skipped "${item.externalId}": mapped entity ${entityMapping.entityId} no longer exists.`);
1855
+ continue;
439
1856
  }
440
- continue;
441
- }
442
-
443
- const entity = await this.catalog.create(
444
- {
1857
+ entityId = entityMapping.entityId;
1858
+ existingEntity = entity;
1859
+ } else {
1860
+ const status = item.status;
1861
+ const entity = await this.catalog.create({
445
1862
  type: "product",
446
1863
  slug: item.slug,
447
1864
  sourceStoreId: storeId,
448
- metadata: {
449
- ...(item.metadata ?? {}),
450
- title: item.title,
451
- ...(item.description !== undefined ? { description: item.description } : {}),
452
- },
453
- },
1865
+ metadata: mergeMetadata(undefined, item.metadata ?? {}),
1866
+ ...(status !== undefined ? { status, isVisible: status === "active" } : {}),
1867
+ }, actor);
1868
+ if (!entity.ok) return PluginErr(entity.error.message);
1869
+ entityId = entity.value.id;
1870
+ isNew = true;
1871
+ imported += 1;
1872
+ entityTouched = true;
1873
+ }
1874
+
1875
+ const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
1876
+ const seedPaths = importedFieldPaths(item).filter((path) => !ownershipBeforeSeed.has(path));
1877
+ const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
1878
+ if (!seeded.ok) return PluginErr(seeded.error.message);
1879
+ for (const path of seedPaths) ownershipBeforeSeed.set(path, "store");
1880
+ const owners = ownershipBeforeSeed;
1881
+ const remoteChanged = entityMapping === undefined || entityMapping.syncHash !== remoteHash;
1882
+ // An unchanged remote item writes nothing and advances no baseline:
1883
+ // converging a stale replay would revert local edits to shared and
1884
+ // unowned fields that the store never actually changed.
1885
+ if (!force && !remoteChanged && existingEntity && existingEntity.status !== "archived") {
1886
+ continue;
1887
+ }
1888
+ const shared = existingEntity
1889
+ ? await this.detectSharedConflicts(entityId, storeId, existingEntity, entityMapping, item, owners)
1890
+ : { paths: [], conflicts: [] };
1891
+ const owned = this.filterOwnedFields(item, owners);
1892
+ const heldSharedPaths = [...new Set([...(entityMapping?.heldFieldPaths ?? []), ...shared.paths])];
1893
+ const held = this.filterConflictingFields(owned.writable, heldSharedPaths);
1894
+ const writable = held.writable;
1895
+ const blockedPaths = new Set<FieldPath>([...owned.skipped, ...heldSharedPaths]);
1896
+ skipped.push(...owned.skipped.map((fieldPath) => ({ entityId, fieldPath })));
1897
+ conflicts.push(...shared.conflicts);
1898
+ for (const conflict of shared.conflicts) {
1899
+ warnings.push(`Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
1900
+ }
1901
+
1902
+ if (existingEntity && entityMapping) {
1903
+ const remoteMetadata = mergeMetadata(existingEntity.metadata, writable.metadata ?? {});
1904
+ const remoteStatus = ownerAllows(owners, "entity.status") && !blockedPaths.has("entity.status")
1905
+ ? writable.status ?? (existingEntity.status === "archived" ? "active" : undefined)
1906
+ : undefined;
1907
+ const updateInput: {
1908
+ slug?: string;
1909
+ metadata?: Record<string, unknown>;
1910
+ status?: string;
1911
+ isVisible?: boolean;
1912
+ } = {};
1913
+ if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== writable.slug) {
1914
+ updateInput.slug = writable.slug;
1915
+ }
1916
+ if (hash(remoteMetadata) !== hash(existingEntity.metadata ?? {})) updateInput.metadata = remoteMetadata;
1917
+ if (remoteStatus !== undefined && !blockedPaths.has("entity.status") && remoteStatus !== existingEntity.status) {
1918
+ updateInput.status = remoteStatus;
1919
+ updateInput.isVisible = remoteStatus === "active";
1920
+ }
1921
+ const shouldUpdate = force
1922
+ ? Object.keys(updateInput).length > 0
1923
+ : remoteChanged || existingEntity.status === "archived";
1924
+ if (shouldUpdate) {
1925
+ converged += 1;
1926
+ if (Object.keys(updateInput).length > 0) {
1927
+ const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor);
1928
+ if (!updated.ok) return PluginErr(updated.error.message);
1929
+ entityTouched = true;
1930
+ }
1931
+ }
1932
+ }
1933
+
1934
+ const optionAxes = await this.upsertOptionAxes(entityId, writable, actor);
1935
+ if (!optionAxes.ok) return optionAxes;
1936
+ const attributes = await this.setCatalogAttributes(entityId, writable, actor, blockedPaths);
1937
+ if (!attributes.ok) return attributes;
1938
+ const variantIds = await this.upsertVariants(
1939
+ orgId,
1940
+ storeId,
1941
+ entityId,
1942
+ writable,
1943
+ optionAxes.value.value,
454
1944
  actor,
1945
+ warnings,
1946
+ !heldSharedPaths.includes("options") && owners.get("options") !== "platform",
1947
+ item,
455
1948
  );
456
- if (!entity.ok) return PluginErr(entity.error.message);
1949
+ if (!variantIds.ok) return variantIds;
1950
+ const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
1951
+ if (!taxonomy.ok) return taxonomy;
1952
+ const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
1953
+ if (!media.ok) return media;
1954
+ attributesCreated += attributes.value.created;
1955
+ mediaImported += media.value.imported;
1956
+ variantsGivenOptionValues += variantIds.value.repaired;
1957
+ skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
1958
+ entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
1959
+ if (entityTouched) entitiesTouched += 1;
1960
+
1961
+ if (entityTouched) {
1962
+ const revision = await this.catalog.recordEntityRevision(entityId, actor, "import");
1963
+ if (!revision.ok) return PluginErr(revision.error.message);
1964
+ }
457
1965
 
458
- await this.db.insert(channelEntityMap).values({
459
- organizationId: orgId,
460
- storeId,
461
- kind: "entity",
462
- externalId: item.externalId,
463
- entityId: entity.value.id,
464
- syncHash: hash(item),
465
- });
1966
+ const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
1967
+ const latestRevisionAt = revisionMarkers.at(-1)?.createdAt;
1968
+ const lastSyncedAt = latestRevisionAt ?? entityMapping?.lastSyncedAt ?? new Date();
466
1969
 
467
- for (const sourceVariant of item.variants) {
468
- const variant = await this.catalog.createVariant(
469
- {
470
- entityId: entity.value.id,
471
- options: {},
472
- ...(sourceVariant.sku !== undefined ? { sku: sourceVariant.sku } : {}),
473
- ...(sourceVariant.barcode !== undefined ? { barcode: sourceVariant.barcode } : {}),
474
- },
475
- actor,
476
- );
477
- if (!variant.ok) return PluginErr(variant.error.message);
1970
+ if (isNew) {
478
1971
  await this.db.insert(channelEntityMap).values({
479
1972
  organizationId: orgId,
480
1973
  storeId,
481
- kind: "variant",
482
- externalId: sourceVariant.externalId,
483
- entityId: entity.value.id,
484
- variantId: variant.value.id,
485
- syncHash: hash(sourceVariant),
1974
+ kind: "entity",
1975
+ externalId: item.externalId,
1976
+ entityId,
1977
+ syncHash: remoteHash,
1978
+ lastSyncedAt,
1979
+ heldFieldPaths: heldSharedPaths,
486
1980
  });
1981
+ } else if (entityMapping) {
1982
+ await this.db.update(channelEntityMap).set({
1983
+ syncHash: remoteHash,
1984
+ lastSyncedAt,
1985
+ heldFieldPaths: heldSharedPaths,
1986
+ }).where(eq(channelEntityMap.id, entityMapping.id));
487
1987
  }
488
- imported += 1;
1988
+ await this.db.update(channelEntityMap).set({ lastSyncedAt }).where(and(
1989
+ eq(channelEntityMap.organizationId, orgId),
1990
+ eq(channelEntityMap.storeId, storeId),
1991
+ eq(channelEntityMap.entityId, entityId),
1992
+ eq(channelEntityMap.kind, "variant"),
1993
+ ));
489
1994
  }
490
- return Ok({ imported, converged });
1995
+ return Ok({
1996
+ imported,
1997
+ converged,
1998
+ entitiesTouched,
1999
+ attributesCreated,
2000
+ mediaImported,
2001
+ variantsGivenOptionValues,
2002
+ skipped,
2003
+ conflicts,
2004
+ warnings,
2005
+ });
491
2006
  }
492
2007
 
493
2008
  async reconcile(
@@ -515,6 +2030,7 @@ export class ChannelConnectorService {
515
2030
  if (!converged.ok) return converged;
516
2031
  const present = new Set(items.map((item) => item.externalId));
517
2032
  let archived = 0;
2033
+ const skipped = [...converged.value.skipped];
518
2034
  for (const mapping of entityMappings) {
519
2035
  if (present.has(mapping.externalId)) continue;
520
2036
  const [entity] = await this.db.select({ status: sellableEntities.status }).from(sellableEntities).where(and(
@@ -522,6 +2038,11 @@ export class ChannelConnectorService {
522
2038
  eq(sellableEntities.id, mapping.entityId),
523
2039
  ));
524
2040
  if (entity?.status !== "archived") {
2041
+ const owners = await this.catalog.resolveFieldOwners(mapping.entityId, storeId);
2042
+ if (owners.get("entity.status") === "platform") {
2043
+ skipped.push({ entityId: mapping.entityId, fieldPath: "entity.status" });
2044
+ continue;
2045
+ }
525
2046
  const result = await this.catalog.archive(mapping.entityId, actor);
526
2047
  if (!result.ok) return PluginErr(result.error.message);
527
2048
  archived += 1;
@@ -556,6 +2077,9 @@ export class ChannelConnectorService {
556
2077
  archived,
557
2078
  inventoryUpdated,
558
2079
  driftAlert: converged.value.imported + converged.value.converged + archived > threshold,
2080
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
2081
+ ...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
2082
+ ...(converged.value.warnings.length > 0 ? { warnings: converged.value.warnings } : {}),
559
2083
  };
560
2084
  await this.db.update(connectedStores).set({
561
2085
  lastReconcileAt: new Date(),
@@ -616,16 +2140,30 @@ export class ChannelConnectorService {
616
2140
  if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
617
2141
  const actor = createSystemActor(orgId);
618
2142
  const data = event.data as Record<string, unknown>;
2143
+ let skipped: CatalogFieldSkip[] = [];
2144
+ let conflicts: CatalogFieldConflict[] = [];
2145
+ let warnings: string[] = [];
619
2146
  if (event.type === "products/update") {
620
2147
  const productId = String(data.id ?? data.product_id ?? "");
621
2148
  const mapping = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, productId)));
622
- if (mapping[0]) await this.convergeCatalogItem(orgId, storeId, mapping[0].entityId, data, actor);
2149
+ if (mapping[0]) {
2150
+ const converged = await this.convergeCatalogItem(orgId, storeId, mapping[0].entityId, data, actor);
2151
+ if (!converged.ok) return converged;
2152
+ skipped = converged.value.skipped;
2153
+ conflicts = converged.value.conflicts;
2154
+ warnings = converged.value.warnings;
2155
+ }
623
2156
  } else if (event.type === "products/delete") {
624
2157
  const productId = String(data.id ?? data.product_id ?? "");
625
2158
  const mapping = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, productId)));
626
2159
  if (mapping[0]) {
627
- const archived = await this.catalog.archive(mapping[0].entityId, actor);
628
- if (!archived.ok) return PluginErr(archived.error.message);
2160
+ const owners = await this.catalog.resolveFieldOwners(mapping[0].entityId, storeId);
2161
+ if (owners.get("entity.status") === "platform") {
2162
+ skipped.push({ entityId: mapping[0].entityId, fieldPath: "entity.status" });
2163
+ } else {
2164
+ const archived = await this.catalog.archive(mapping[0].entityId, actor);
2165
+ if (!archived.ok) return PluginErr(archived.error.message);
2166
+ }
629
2167
  }
630
2168
  } else if (event.type === "inventory_levels/update") {
631
2169
  const externalId = String(data.inventory_item_id ?? data.variation_id ?? data.product_id ?? "");
@@ -664,6 +2202,18 @@ export class ChannelConnectorService {
664
2202
  if (!disconnected.ok) return disconnected;
665
2203
  return Ok({ processed: true });
666
2204
  }
2205
+ if (skipped.length > 0 || conflicts.length > 0 || warnings.length > 0) {
2206
+ const report = {
2207
+ ...(store.lastReconcileReport ?? {}),
2208
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
2209
+ ...(conflicts.length > 0 ? { conflicts } : {}),
2210
+ ...(warnings.length > 0 ? { warnings } : {}),
2211
+ };
2212
+ await this.db.update(connectedStores).set({ lastReconcileReport: report, updatedAt: new Date() }).where(and(
2213
+ eq(connectedStores.organizationId, orgId),
2214
+ eq(connectedStores.id, storeId),
2215
+ ));
2216
+ }
667
2217
  return Ok({ processed: true });
668
2218
  }
669
2219
 
@@ -735,14 +2285,115 @@ export class ChannelConnectorService {
735
2285
  await inventory.setAbsolute({ entityId: mapping.entityId, ...(mapping.variantId ? { variantId: mapping.variantId } : {}), quantity: Math.max(0, Math.floor(quantity)), reason: "Inventory webhook sync" }, actor);
736
2286
  }
737
2287
 
738
- private async convergeCatalogItem(orgId: string, storeId: string, entityId: string, data: Record<string, unknown>, actor: Actor): Promise<void> {
2288
+ private async convergeCatalogItem(
2289
+ orgId: string,
2290
+ storeId: string,
2291
+ entityId: string,
2292
+ data: Record<string, unknown>,
2293
+ actor: Actor,
2294
+ ): Promise<PluginResult<{ skipped: CatalogFieldSkip[]; conflicts: CatalogFieldConflict[]; warnings: string[] }>> {
739
2295
  const product = data.product && typeof data.product === "object" ? data.product as Record<string, unknown> : data;
2296
+ const remoteMetadata = product.metadata && typeof product.metadata === "object" && !Array.isArray(product.metadata)
2297
+ ? product.metadata as Record<string, unknown>
2298
+ : {};
2299
+ const [mapping] = await this.db.select().from(channelEntityMap).where(and(
2300
+ eq(channelEntityMap.organizationId, orgId),
2301
+ eq(channelEntityMap.storeId, storeId),
2302
+ eq(channelEntityMap.kind, "entity"),
2303
+ eq(channelEntityMap.entityId, entityId),
2304
+ ));
2305
+ const [entity] = await this.db.select().from(sellableEntities).where(and(
2306
+ eq(sellableEntities.organizationId, orgId),
2307
+ eq(sellableEntities.id, entityId),
2308
+ ));
2309
+ if (!mapping || !entity) return Ok({ skipped: [], conflicts: [], warnings: [] });
2310
+ const [currentAttribute] = await this.db.select().from(sellableAttributes).where(and(
2311
+ eq(sellableAttributes.entityId, entityId),
2312
+ eq(sellableAttributes.locale, "en"),
2313
+ ));
2314
+ const title = typeof product.title === "string" ? product.title : currentAttribute?.title ?? entity.slug;
2315
+ const description = product.description !== undefined
2316
+ ? String(product.description)
2317
+ : currentAttribute?.description ?? undefined;
2318
+ const status = typeof product.status === "string" && ["draft", "active", "archived", "discontinued"].includes(product.status)
2319
+ ? product.status as NonNullable<ChannelCatalogItem["status"]>
2320
+ : undefined;
2321
+ const remoteItem: ChannelCatalogItem = {
2322
+ externalId: mapping.externalId,
2323
+ slug: typeof product.slug === "string" ? product.slug : entity.slug,
2324
+ title,
2325
+ ...(description !== undefined ? { description } : {}),
2326
+ ...(status !== undefined ? { status } : {}),
2327
+ attributes: [{ locale: "en", title, ...(description !== undefined ? { description } : {}) }],
2328
+ ...(Object.keys(remoteMetadata).length > 0 ? { metadata: remoteMetadata } : {}),
2329
+ variants: [],
2330
+ };
2331
+ const fieldPaths: FieldPath[] = [];
2332
+ if (typeof product.slug === "string") fieldPaths.push("entity.slug");
2333
+ if (status !== undefined) fieldPaths.push("entity.status");
2334
+ for (const key of Object.keys(remoteMetadata)) {
2335
+ const path = `entity.metadata.${key}`;
2336
+ if (isValidFieldPath(path)) fieldPaths.push(path);
2337
+ }
2338
+ if (typeof product.title === "string") fieldPaths.push("attributes.en.title");
2339
+ if (product.description !== undefined) fieldPaths.push("attributes.en.description");
2340
+ const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
2341
+ const seedPaths = fieldPaths.filter((path) => !ownershipBeforeSeed.has(path));
2342
+ const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
2343
+ if (!seeded.ok) return PluginErr(seeded.error.message);
2344
+ for (const path of seedPaths) ownershipBeforeSeed.set(path, "store");
2345
+ const owners = ownershipBeforeSeed;
2346
+ const remoteHash = hash(product);
2347
+ const shared = await this.detectSharedConflicts(entityId, storeId, entity, mapping, remoteItem, owners, fieldPaths, remoteHash);
2348
+ const owned = this.filterOwnedFieldsAtPaths(remoteItem, owners, fieldPaths);
2349
+ const heldPaths = [...new Set([...(mapping.heldFieldPaths ?? []), ...shared.paths])];
2350
+ const held = this.filterConflictingFields(owned.writable, heldPaths);
2351
+ const blockedPaths = new Set<FieldPath>([
2352
+ ...owned.skipped,
2353
+ ...heldPaths,
2354
+ ...(!fieldPaths.includes("attributes.en.title") ? ["attributes.en.title" as FieldPath] : []),
2355
+ ]);
2356
+ const skipped = owned.skipped.map((fieldPath) => ({ entityId, fieldPath }));
2357
+ const conflicts = shared.conflicts;
2358
+ const warnings = conflicts.map((conflict) => `Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
2359
+ const writable = held.writable;
2360
+ const updateInput: {
2361
+ slug?: string;
2362
+ metadata?: Record<string, unknown>;
2363
+ status?: string;
2364
+ isVisible?: boolean;
2365
+ } = {};
2366
+ if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && entity.slug !== writable.slug) {
2367
+ updateInput.slug = writable.slug;
2368
+ }
2369
+ if (Object.keys(writable.metadata ?? {}).length > 0) {
2370
+ const remoteEntityMetadata = mergeMetadata(entity.metadata, writable.metadata ?? {});
2371
+ if (hash(remoteEntityMetadata) !== hash(entity.metadata ?? {})) updateInput.metadata = remoteEntityMetadata;
2372
+ }
2373
+ if (fieldPaths.includes("entity.status") && ownerAllows(owners, "entity.status") && !blockedPaths.has("entity.status") && typeof writable.status === "string" && writable.status !== entity.status) {
2374
+ updateInput.status = writable.status;
2375
+ updateInput.isVisible = writable.status === "active";
2376
+ }
2377
+ if (Object.keys(updateInput).length > 0) {
2378
+ const updated = await this.catalog.update(entityId, updateInput, actor);
2379
+ if (!updated.ok) return PluginErr(updated.error.message);
2380
+ }
2381
+ const attributes = await this.setCatalogAttributes(entityId, writable, actor, blockedPaths);
2382
+ if (!attributes.ok) return attributes;
740
2383
  const levels = Array.isArray(product.variants) ? product.variants as Array<Record<string, unknown>> : [];
741
2384
  for (const variant of levels) {
742
2385
  const externalId = String(variant.id ?? variant.variation_id ?? "");
743
2386
  const available = variant.inventory_quantity ?? variant.stock_quantity;
744
2387
  if (externalId && available !== undefined) await this.setMappedInventory(orgId, storeId, externalId, Number(available), actor);
745
2388
  }
2389
+ const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
2390
+ const lastSyncedAt = revisionMarkers.at(-1)?.createdAt ?? mapping.lastSyncedAt;
2391
+ await this.db.update(channelEntityMap).set({
2392
+ syncHash: remoteHash,
2393
+ lastSyncedAt,
2394
+ heldFieldPaths: heldPaths,
2395
+ }).where(eq(channelEntityMap.id, mapping.id));
2396
+ return Ok({ skipped, conflicts, warnings });
746
2397
  }
747
2398
 
748
2399
  private async createRefundRequest(orgId: string, store: ConnectedStore, data: Record<string, unknown>, actor: Actor): Promise<PluginResult<ChannelRefundRequest>> {