@porulle/plugin-channel-connector 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/service.ts CHANGED
@@ -16,16 +16,20 @@ import type {
16
16
  ChannelPushCatalogImage,
17
17
  ChannelPushCatalogIntent,
18
18
  ChannelPushCatalogItem,
19
+ ChannelPushCatalogItemOutcome,
20
+ ChannelPushCatalogResult,
19
21
  ChannelStore,
20
22
  PluginDb,
21
23
  PluginResult,
22
24
  PluginTxFn,
25
+ CatalogWriteContext,
23
26
  TxContext,
24
27
  } from "@porulle/core";
25
- import { isValidFieldPath } from "@porulle/core";
28
+ import { isValidFieldPath, requireUserId } from "@porulle/core";
26
29
  import type { FieldOwner, FieldPath } from "@porulle/core";
27
30
  import type { JobsAdapter } from "@porulle/core";
28
- import { and, eq, inArray, isNull } from "@porulle/core/drizzle";
31
+ import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
32
+ import { and, desc, eq, inArray, isNull, lte } from "@porulle/core/drizzle";
29
33
  import {
30
34
  brands,
31
35
  categories,
@@ -43,18 +47,26 @@ import {
43
47
  sellableAttributes,
44
48
  sellableCustomFields,
45
49
  sellableEntities,
50
+ sellableEntityRevisions,
46
51
  entityFieldDefinitions,
47
52
  tags,
48
53
  variants,
49
54
  variantOptionValues,
50
55
  } from "@porulle/core/schema";
56
+ import type { SellableEntityRevisionSnapshot } from "@porulle/core/schema";
51
57
  import {
58
+ channelCatalogPushEvents,
59
+ channelCatalogPushes,
60
+ channelCatalogConflicts,
61
+ channelCatalogConflictEvents,
52
62
  channelEntityMap,
53
63
  channelExportEvents,
54
64
  channelOrderExports,
55
65
  connectedStores,
56
66
  channelRefundEvents,
57
67
  channelRefundRequests,
68
+ type ChannelCatalogPush,
69
+ type ChannelCatalogConflict,
58
70
  type ChannelOrderExport,
59
71
  type ChannelRefundRequest,
60
72
  type ConnectedStore,
@@ -65,15 +77,72 @@ import {
65
77
  selectCatalogFieldMapping,
66
78
  type CatalogFieldMapping,
67
79
  type CatalogFieldMappingInput,
80
+ type CatalogFieldTarget,
68
81
  } from "./catalog-field-mapping.js";
69
82
 
70
83
  export type ExportState = ChannelOrderExport["state"];
84
+ export type CatalogPushState = ChannelCatalogPush["state"];
85
+ export type CatalogConflictState = ChannelCatalogConflict["state"];
86
+
87
+ export const CATALOG_PUSH_BATCH_SIZES: Record<string, number> = {
88
+ mock: 100,
89
+ shopify: 50,
90
+ woocommerce: 100,
91
+ };
92
+
93
+ const DEFAULT_CATALOG_PUSH_BATCH_SIZE = 50;
94
+ const CATALOG_PUSH_BREAKER_RETRY_MS = 60_000;
95
+ export const CATALOG_PUSH_MAX_ATTEMPTS = 8;
96
+ const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
97
+ const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
98
+
99
+ export function catalogPushRetryDelayMs(attempts: number): number {
100
+ const exponent = Math.max(0, attempts - 1);
101
+ return Math.min(CATALOG_PUSH_RETRY_BASE_MS * (2 ** exponent), CATALOG_PUSH_RETRY_MAX_MS);
102
+ }
103
+
104
+ export interface CatalogPushJobResult extends Record<string, unknown> {
105
+ noop?: boolean;
106
+ rescheduled?: boolean;
107
+ complete?: boolean;
108
+ cursor?: string;
109
+ pushed?: number;
110
+ failed?: number;
111
+ }
112
+
113
+ export function catalogPushConcurrencyKey(input: Record<string, unknown>): string {
114
+ const storeId = String(input.storeId);
115
+ const entityIds = input.entityIds;
116
+ if (Array.isArray(entityIds) && entityIds.length === 1) {
117
+ return `push:${String(entityIds[0])}:${storeId}`;
118
+ }
119
+ return `push-catalog:${storeId}`;
120
+ }
121
+
122
+ export function isCatalogPushBreakerOpen(breakerState: Record<string, unknown>): boolean {
123
+ if (breakerState.open === true) {
124
+ const openUntil = breakerState.openUntil;
125
+ if (typeof openUntil === "string" && new Date(openUntil) <= new Date()) return false;
126
+ return true;
127
+ }
128
+ const catalogPush = breakerState.catalogPush;
129
+ if (!catalogPush || typeof catalogPush !== "object") return false;
130
+ const state = catalogPush as { open?: boolean; openUntil?: string };
131
+ if (state.open !== true) return false;
132
+ if (typeof state.openUntil === "string" && new Date(state.openUntil) <= new Date()) return false;
133
+ return true;
134
+ }
135
+
136
+ function catalogPushBatchSize(provider: string): number {
137
+ return CATALOG_PUSH_BATCH_SIZES[provider] ?? DEFAULT_CATALOG_PUSH_BATCH_SIZE;
138
+ }
71
139
 
72
140
  export interface ReconcileReport extends Record<string, unknown> {
73
141
  imported: number;
74
142
  converged: number;
75
143
  archived: number;
76
144
  inventoryUpdated: number;
145
+ openConflicts: number;
77
146
  driftAlert: boolean;
78
147
  skipped?: CatalogFieldSkip[];
79
148
  conflicts?: CatalogFieldConflict[];
@@ -88,17 +157,26 @@ export interface CatalogFieldConflict {
88
157
  remoteValueSummary: string;
89
158
  }
90
159
 
160
+ interface DetectedCatalogFieldConflict extends CatalogFieldConflict {
161
+ platformValue: unknown;
162
+ storeValue: unknown;
163
+ }
164
+
91
165
  export interface CatalogFieldSkip {
92
166
  entityId: string;
93
167
  fieldPath: FieldPath;
94
168
  }
95
169
 
96
- export type CatalogPushSkipReason = "no_mapping" | "held" | "entity_not_active" | "unmapped_entity";
170
+ export type CatalogPushSkipReason = "no_mapping" | "held" | "store_owned" | "entity_not_active" | "unmapped_entity";
97
171
 
98
172
  export interface CatalogPushFieldSkip {
99
173
  entityId: string;
100
174
  fieldPath: FieldPath;
101
175
  reason: CatalogPushSkipReason;
176
+ value?: unknown;
177
+ owner?: FieldOwner;
178
+ target?: CatalogFieldTarget;
179
+ remoteKey?: string;
102
180
  }
103
181
 
104
182
  export type PublicConnectedStore = Omit<ConnectedStore, "credentials" | "webhookSecret"> & {
@@ -176,10 +254,61 @@ export interface BackfillCatalogOptions {
176
254
 
177
255
  export interface BuildCatalogPushItemsOptions {
178
256
  recordRevision?: boolean;
257
+ forceFieldPaths?: Record<string, FieldPath[]>;
258
+ }
259
+
260
+ export interface CatalogPushAssemblyField extends ChannelPushCatalogField {
261
+ target: CatalogFieldTarget;
262
+ }
263
+
264
+ export interface CatalogPushAssemblyImage extends ChannelPushCatalogImage {
265
+ fieldPath: FieldPath;
266
+ target: CatalogFieldTarget;
267
+ remoteKey: string;
268
+ }
269
+
270
+ export interface CatalogPushAssemblyItem extends Omit<ChannelPushCatalogItem, "fields" | "images"> {
271
+ fields: CatalogPushAssemblyField[];
272
+ images?: CatalogPushAssemblyImage[];
179
273
  }
180
274
 
181
275
  export interface BuildCatalogPushItemsResult {
182
- items: ChannelPushCatalogItem[];
276
+ items: CatalogPushAssemblyItem[];
277
+ skipped: CatalogPushFieldSkip[];
278
+ warnings: string[];
279
+ }
280
+
281
+ export interface PushCatalogToStoreResult extends ChannelPushCatalogResult {
282
+ skipped: CatalogPushFieldSkip[];
283
+ warnings: string[];
284
+ }
285
+
286
+ export interface CatalogPushPreviewUnavailable {
287
+ status: "unavailable";
288
+ }
289
+
290
+ export type CatalogPushPreviewBefore = unknown | CatalogPushPreviewUnavailable;
291
+ export type CatalogPushPreviewBeforeStatus = "value" | "missing" | "unavailable";
292
+
293
+ export interface CatalogPushPreviewDiff {
294
+ fieldPath: FieldPath;
295
+ target: CatalogFieldTarget | null;
296
+ remoteKey: string | null;
297
+ before: CatalogPushPreviewBefore;
298
+ beforeStatus: CatalogPushPreviewBeforeStatus;
299
+ after: unknown;
300
+ owner: FieldOwner;
301
+ willWrite: boolean;
302
+ reason?: CatalogPushSkipReason;
303
+ }
304
+
305
+ export interface CatalogPushPreviewItem {
306
+ externalId: string;
307
+ diffs: CatalogPushPreviewDiff[];
308
+ }
309
+
310
+ export interface CatalogPushPreviewResult {
311
+ items: CatalogPushPreviewItem[];
183
312
  skipped: CatalogPushFieldSkip[];
184
313
  warnings: string[];
185
314
  }
@@ -199,8 +328,9 @@ interface CatalogService {
199
328
  };
200
329
  update(
201
330
  id: string,
202
- input: { slug?: string; status?: string; metadata?: Record<string, unknown>; isVisible?: boolean },
331
+ input: { slug?: string; status?: string; metadata?: Record<string, unknown>; isVisible?: boolean; customFields?: Record<string, unknown | null> },
203
332
  actor: Actor,
333
+ ctx?: CatalogWriteContext,
204
334
  ): Promise<{ ok: true; value: unknown } | { ok: false; error: { message: string } }>;
205
335
  archive(id: string, actor: Actor): Promise<{ ok: true; value: unknown } | { ok: false; error: { message: string } }>;
206
336
  create(
@@ -230,6 +360,7 @@ interface CatalogService {
230
360
  seoDescription?: string;
231
361
  },
232
362
  actor: Actor,
363
+ ctx?: CatalogWriteContext,
233
364
  ): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
234
365
  recordEntityRevision(
235
366
  entityId: string,
@@ -238,6 +369,13 @@ interface CatalogService {
238
369
  ctx?: TxContext<PluginDb>,
239
370
  ): Promise<{ ok: true; value: unknown } | { ok: false; error: { message: string } }>;
240
371
  resolveFieldOwners(entityId: string, storeId: string): Promise<Map<FieldPath, FieldOwner>>;
372
+ setFieldOwner(
373
+ entityId: string,
374
+ fieldPath: FieldPath,
375
+ storeId: string | null,
376
+ owner: FieldOwner,
377
+ actor: Actor | null,
378
+ ): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
241
379
  seedImportedFieldOwnership(entityId: string, storeId: string, fieldPaths: FieldPath[]): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
242
380
  createOptionType(
243
381
  input: { entityId: string; name: string; values?: string[] },
@@ -326,10 +464,127 @@ export function canExportTransition(from: ExportState, to: ExportState): boolean
326
464
  return exportTransitions[from].includes(to);
327
465
  }
328
466
 
467
+ // Catalog pushes recur; confirmed/failed rows re-arm through exported, and rows with nothing to push resolve directly.
468
+ const catalogPushTransitions: Record<CatalogPushState, readonly CatalogPushState[]> = {
469
+ pending: ["exported", "confirmed", "abandoned"],
470
+ exported: ["confirmed", "failed", "abandoned"],
471
+ confirmed: ["exported", "abandoned"],
472
+ failed: ["exported", "confirmed", "abandoned"],
473
+ abandoned: [],
474
+ };
475
+
476
+ export function canCatalogPushTransition(from: CatalogPushState, to: CatalogPushState): boolean {
477
+ return catalogPushTransitions[from].includes(to);
478
+ }
479
+
329
480
  function hash(value: unknown): string {
330
481
  return createHash("sha256").update(JSON.stringify(value)).digest("hex");
331
482
  }
332
483
 
484
+ export const CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS = 15 * 60 * 1000;
485
+
486
+ function normalizeCanonicalValue(value: unknown): unknown {
487
+ if (typeof value === "string") return value.replace(/\r\n?/g, "\n").replace(/\s+/g, " ").trim();
488
+ if (Array.isArray(value)) return value.map(normalizeCanonicalValue).sort((left, right) => (JSON.stringify(left) ?? "").localeCompare(JSON.stringify(right) ?? ""));
489
+ if (value && typeof value === "object") {
490
+ return Object.fromEntries(Object.entries(value)
491
+ .sort(([left], [right]) => left.localeCompare(right))
492
+ .map(([key, nested]) => [key, normalizeCanonicalValue(nested)]));
493
+ }
494
+ return value;
495
+ }
496
+
497
+ function normalizedValuesEqual(left: unknown, right: unknown): boolean {
498
+ return JSON.stringify(normalizeCanonicalValue(left)) === JSON.stringify(normalizeCanonicalValue(right));
499
+ }
500
+
501
+ function snapshotCustomFieldValue(field: Record<string, unknown>): unknown {
502
+ if (field.textValue !== null && field.textValue !== undefined) return field.textValue;
503
+ if (field.numberValue !== null && field.numberValue !== undefined) return field.numberValue;
504
+ if (field.booleanValue !== null && field.booleanValue !== undefined) return field.booleanValue;
505
+ if (field.dateValue !== null && field.dateValue !== undefined) return field.dateValue;
506
+ return field.jsonValue;
507
+ }
508
+
509
+ function snapshotFieldValue(
510
+ snapshot: SellableEntityRevisionSnapshot,
511
+ path: FieldPath,
512
+ ): { found: boolean; value: unknown } {
513
+ const [root, segment, field] = path.split(".");
514
+ if (root === "entity" && segment === "slug") return { found: true, value: snapshot.entity.slug };
515
+ if (root === "entity" && segment === "status") return { found: true, value: snapshot.entity.status };
516
+ if (root === "entity" && segment === "metadata") {
517
+ const metadata = snapshot.entity.metadata;
518
+ return {
519
+ found: true,
520
+ value: metadata && typeof metadata === "object" ? (metadata as Record<string, unknown>)[field ?? ""] : undefined,
521
+ };
522
+ }
523
+ if (root === "attributes" && segment && field) {
524
+ const attribute = snapshot.attributes.find((row) => row.locale === segment);
525
+ return { found: true, value: attribute?.[field] };
526
+ }
527
+ if (root === "customFields" && segment && field) {
528
+ const customField = snapshot.customFields.find((row) => row.fieldName === segment && row.locale === field && row.status === "approved");
529
+ return { found: true, value: customField ? snapshotCustomFieldValue(customField) : undefined };
530
+ }
531
+ if (root === "media" && segment) {
532
+ return {
533
+ found: true,
534
+ value: snapshot.media.filter((row) => row.role === segment).map((row) => row.mediaAssetId),
535
+ };
536
+ }
537
+ return { found: false, value: undefined };
538
+ }
539
+
540
+ interface CanonicalOutboundField {
541
+ fieldPath: string;
542
+ value: unknown;
543
+ }
544
+
545
+ function canonicalHash(
546
+ externalId: string,
547
+ fieldPaths: FieldPath[],
548
+ valueAtPath: (fieldPath: FieldPath) => unknown,
549
+ ): string {
550
+ const fields: CanonicalOutboundField[] = fieldPaths.flatMap((fieldPath) => {
551
+ const value = valueAtPath(fieldPath);
552
+ return value === undefined ? [] : [{ fieldPath, value: normalizeCanonicalValue(value) }];
553
+ });
554
+ return hash({
555
+ externalId,
556
+ fields: fields.sort((left, right) => left.fieldPath.localeCompare(right.fieldPath)),
557
+ });
558
+ }
559
+
560
+ function outboundFieldPaths(item: ChannelPushCatalogItem): FieldPath[] {
561
+ const paths = new Set<FieldPath>(item.fields.flatMap((field) => isValidFieldPath(field.fieldPath) ? [field.fieldPath] : []));
562
+ for (const image of item.images ?? []) paths.add(`media.${image.role}`);
563
+ return [...paths].sort();
564
+ }
565
+
566
+ function pushFieldValue(item: ChannelPushCatalogItem, fieldPath: FieldPath): unknown {
567
+ if (fieldPath.startsWith("media.")) {
568
+ const role = fieldPath.slice("media.".length);
569
+ return (item.images ?? [])
570
+ .filter((image) => image.role === role)
571
+ .map((image) => ({ url: image.url, role: image.role }));
572
+ }
573
+ return item.fields.find((field) => field.fieldPath === fieldPath)?.value;
574
+ }
575
+
576
+ function canonicalOutboundHash(externalId: string, item: ChannelPushCatalogItem, fieldPaths: FieldPath[]): string {
577
+ return canonicalHash(externalId, fieldPaths, (fieldPath) => pushFieldValue(item, fieldPath));
578
+ }
579
+
580
+ function canonicalInboundHash(
581
+ externalId: string,
582
+ fieldPaths: FieldPath[],
583
+ remoteFieldValue: (path: FieldPath) => unknown,
584
+ ): string {
585
+ return canonicalHash(externalId, fieldPaths, remoteFieldValue);
586
+ }
587
+
333
588
  function mergeMetadata(
334
589
  existing: Record<string, unknown> | null | undefined,
335
590
  remote: Record<string, unknown>,
@@ -372,7 +627,7 @@ function pushCatalogField(
372
627
  fieldPath: FieldPath,
373
628
  value: unknown,
374
629
  mapping: { target: "native" | "attribute" | "meta"; remoteKey: string },
375
- ): ChannelPushCatalogField {
630
+ ): CatalogPushAssemblyField {
376
631
  const segments = fieldPath.split(".");
377
632
  const locale = fieldPath.startsWith("attributes.")
378
633
  ? segments[1]
@@ -385,6 +640,7 @@ function pushCatalogField(
385
640
  value,
386
641
  ...(locale !== undefined ? { locale } : {}),
387
642
  remoteKey: mapping.remoteKey,
643
+ target: mapping.target,
388
644
  };
389
645
  }
390
646
 
@@ -407,6 +663,14 @@ function importedFieldPaths(item: ChannelCatalogItem): FieldPath[] {
407
663
  if (attribute[field] !== undefined) paths.add(`attributes.${attribute.locale}.${field}`);
408
664
  }
409
665
  }
666
+ const customFields = (item as ChannelCatalogItem & { customFields?: Record<string, Record<string, unknown>> }).customFields;
667
+ for (const [name, locales] of Object.entries(customFields ?? {})) {
668
+ if (!locales || typeof locales !== "object" || Array.isArray(locales)) continue;
669
+ for (const locale of Object.keys(locales)) {
670
+ const path = `customFields.${name}.${locale}`;
671
+ if (isValidFieldPath(path)) paths.add(path);
672
+ }
673
+ }
410
674
  for (const image of item.images ?? []) paths.add(`media.${image.role}`);
411
675
  if (item.options?.length) paths.add("options");
412
676
  if (item.variants.some((variant) => variant.sku !== undefined)) paths.add("variants.sku");
@@ -608,6 +872,13 @@ export class ChannelConnectorService {
608
872
  if (root === "entity" && segment === "slug") return item.slug;
609
873
  if (root === "entity" && segment === "status") return item.status;
610
874
  if (root === "entity" && segment === "metadata") return item.metadata?.[field ?? ""];
875
+ if (root === "customFields" && segment && field) {
876
+ const customFields = (item as ChannelCatalogItem & { customFields?: Record<string, unknown> }).customFields;
877
+ const customField = customFields?.[segment];
878
+ if (customField && typeof customField === "object" && !Array.isArray(customField)) {
879
+ return (customField as Record<string, unknown>)[field];
880
+ }
881
+ }
611
882
  if (root === "attributes" && segment && field) {
612
883
  const attributes = item.attributes?.length
613
884
  ? item.attributes
@@ -615,7 +886,11 @@ export class ChannelConnectorService {
615
886
  const attribute = attributes.find((row) => row.locale === segment);
616
887
  return attribute?.[field as keyof typeof attribute];
617
888
  }
618
- if (root === "media" && segment) return (item.images ?? []).filter((image) => image.role === segment).map((image) => image.externalId ?? image.url);
889
+ if (root === "media" && segment) {
890
+ return (item.images ?? [])
891
+ .filter((image) => image.role === segment)
892
+ .map((image) => ({ url: image.url, role: image.role }));
893
+ }
619
894
  if (path === "options") return item.options;
620
895
  if (path === "variants.sku") return item.variants.map((variant) => variant.sku);
621
896
  if (path === "variants.barcode") return item.variants.map((variant) => variant.barcode);
@@ -623,6 +898,17 @@ export class ChannelConnectorService {
623
898
  return undefined;
624
899
  }
625
900
 
901
+ private isOutboundEcho(
902
+ mapping: typeof channelEntityMap.$inferSelect,
903
+ item: ChannelCatalogItem,
904
+ ): boolean {
905
+ if (!mapping.outboundHash || !mapping.outboundPushedAt || mapping.outboundFieldPaths.length === 0) return false;
906
+ const age = Date.now() - mapping.outboundPushedAt.getTime();
907
+ if (age < 0 || age > CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS) return false;
908
+ const inboundHash = canonicalInboundHash(mapping.externalId, mapping.outboundFieldPaths, (fieldPath) => this.remoteFieldValue(item, fieldPath));
909
+ return inboundHash === mapping.outboundHash;
910
+ }
911
+
626
912
  private async localFieldValue(
627
913
  entityId: string,
628
914
  entity: typeof sellableEntities.$inferSelect,
@@ -649,6 +935,15 @@ export class ChannelConnectorService {
649
935
  : {};
650
936
  return values[field];
651
937
  }
938
+ if (root === "customFields" && segment && field) {
939
+ const [customField] = await this.db.select().from(sellableCustomFields).where(and(
940
+ eq(sellableCustomFields.entityId, entityId),
941
+ eq(sellableCustomFields.fieldName, segment),
942
+ eq(sellableCustomFields.locale, field),
943
+ eq(sellableCustomFields.status, "approved"),
944
+ ));
945
+ return customField ? customFieldValue(customField) : undefined;
946
+ }
652
947
  if (root === "media" && segment) {
653
948
  const links = await this.db.select({ id: entityMedia.mediaAssetId }).from(entityMedia).where(and(
654
949
  eq(entityMedia.entityId, entityId),
@@ -678,6 +973,17 @@ export class ChannelConnectorService {
678
973
  return undefined;
679
974
  }
680
975
 
976
+ private async lastSyncedSnapshot(
977
+ entityId: string,
978
+ lastSyncedAt: Date,
979
+ ): Promise<SellableEntityRevisionSnapshot | undefined> {
980
+ const [revision] = await this.db.select({ snapshot: sellableEntityRevisions.snapshot }).from(sellableEntityRevisions).where(and(
981
+ eq(sellableEntityRevisions.entityId, entityId),
982
+ lte(sellableEntityRevisions.createdAt, lastSyncedAt),
983
+ )).orderBy(desc(sellableEntityRevisions.createdAt)).limit(1);
984
+ return revision?.snapshot;
985
+ }
986
+
681
987
  private async detectSharedConflicts(
682
988
  entityId: string,
683
989
  storeId: string,
@@ -687,20 +993,109 @@ export class ChannelConnectorService {
687
993
  owners: Map<FieldPath, FieldOwner>,
688
994
  fieldPaths: FieldPath[] = importedFieldPaths(item),
689
995
  remoteHash = hash(item),
690
- ): Promise<{ paths: FieldPath[]; conflicts: CatalogFieldConflict[] }> {
996
+ echo?: { certifiedPaths: ReadonlySet<FieldPath> },
997
+ ): Promise<{ paths: FieldPath[]; conflicts: DetectedCatalogFieldConflict[] }> {
691
998
  if (!mapping || mapping.syncHash === remoteHash) return { paths: [], conflicts: [] };
692
999
  const revisions = await this.catalog.repository.findRevisionMarkers(entityId, mapping.lastSyncedAt);
693
1000
  const localChanged = revisions.some((revision) => revision.reason !== "import");
694
- if (!localChanged) return { paths: [], conflicts: [] };
695
1001
  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 };
1002
+ const openRows = await this.db.select({
1003
+ fieldPath: channelCatalogConflicts.fieldPath,
1004
+ storeValue: channelCatalogConflicts.storeValue,
1005
+ }).from(channelCatalogConflicts).where(and(
1006
+ eq(channelCatalogConflicts.storeId, storeId),
1007
+ eq(channelCatalogConflicts.entityId, entityId),
1008
+ eq(channelCatalogConflicts.state, "open"),
1009
+ ));
1010
+ if (!localChanged && openRows.length === 0) return { paths: [], conflicts: [] };
1011
+ const openByPath = new Map(openRows.map((row) => [row.fieldPath as FieldPath, row.storeValue]));
1012
+ const baseline = await this.lastSyncedSnapshot(entityId, mapping.lastSyncedAt);
1013
+ const changed: FieldPath[] = [];
1014
+ for (const path of paths) {
1015
+ const localValue = await this.localFieldValue(entityId, entity, path);
1016
+ const remoteValue = this.remoteFieldValue(item, path);
1017
+ let diverged = false;
1018
+ if (echo) {
1019
+ // The outbound hash certifies only the pushed paths; a shared path
1020
+ // outside that set carrying a genuinely different remote value is a
1021
+ // real store edit even inside an echo payload.
1022
+ diverged = !echo.certifiedPaths.has(path) && !normalizedValuesEqual(remoteValue, localValue);
1023
+ } else if (openByPath.has(path)) {
1024
+ diverged = !normalizedValuesEqual(remoteValue, openByPath.get(path));
1025
+ } else if (baseline) {
1026
+ const baselineValue = snapshotFieldValue(baseline, path);
1027
+ diverged = baselineValue.found
1028
+ && !normalizedValuesEqual(localValue, baselineValue.value)
1029
+ && !normalizedValuesEqual(remoteValue, baselineValue.value)
1030
+ && !normalizedValuesEqual(localValue, remoteValue);
1031
+ } else {
1032
+ diverged = !normalizedValuesEqual(remoteValue, localValue);
1033
+ }
1034
+ if (diverged) changed.push(path);
1035
+ }
1036
+ const conflicts = await Promise.all(changed.map(async (fieldPath) => {
1037
+ const platformValue = await this.localFieldValue(entityId, entity, fieldPath);
1038
+ const storeValue = this.remoteFieldValue(item, fieldPath);
1039
+ return {
1040
+ entityId,
1041
+ storeId,
1042
+ fieldPath,
1043
+ platformValue: platformValue === undefined ? null : platformValue,
1044
+ storeValue: storeValue === undefined ? null : storeValue,
1045
+ localValueSummary: summarizeValue(platformValue),
1046
+ remoteValueSummary: summarizeValue(storeValue),
1047
+ };
1048
+ }));
1049
+ return { paths: changed, conflicts };
1050
+ }
1051
+
1052
+ private async persistCatalogConflicts(
1053
+ orgId: string,
1054
+ conflicts: DetectedCatalogFieldConflict[],
1055
+ changedBy: string,
1056
+ ): Promise<PluginResult<void>> {
1057
+ for (const conflict of conflicts) {
1058
+ const [inserted] = await this.db.insert(channelCatalogConflicts).values({
1059
+ organizationId: orgId,
1060
+ storeId: conflict.storeId,
1061
+ entityId: conflict.entityId,
1062
+ fieldPath: conflict.fieldPath,
1063
+ platformValue: conflict.platformValue,
1064
+ storeValue: conflict.storeValue,
1065
+ }).onConflictDoNothing().returning();
1066
+ if (!inserted) {
1067
+ const [existing] = await this.db.select({
1068
+ id: channelCatalogConflicts.id,
1069
+ storeValue: channelCatalogConflicts.storeValue,
1070
+ platformValue: channelCatalogConflicts.platformValue,
1071
+ }).from(channelCatalogConflicts).where(and(
1072
+ eq(channelCatalogConflicts.organizationId, orgId),
1073
+ eq(channelCatalogConflicts.storeId, conflict.storeId),
1074
+ eq(channelCatalogConflicts.entityId, conflict.entityId),
1075
+ eq(channelCatalogConflicts.fieldPath, conflict.fieldPath),
1076
+ eq(channelCatalogConflicts.state, "open"),
1077
+ ));
1078
+ const storeMoved = existing !== undefined && !normalizedValuesEqual(existing.storeValue, conflict.storeValue);
1079
+ const platformMoved = existing !== undefined && !normalizedValuesEqual(existing.platformValue, conflict.platformValue);
1080
+ if (existing && (storeMoved || platformMoved)) {
1081
+ await this.db.update(channelCatalogConflicts).set({
1082
+ storeValue: conflict.storeValue,
1083
+ platformValue: conflict.platformValue,
1084
+ updatedAt: new Date(),
1085
+ }).where(eq(channelCatalogConflicts.id, existing.id));
1086
+ }
1087
+ continue;
1088
+ }
1089
+ await this.db.insert(channelCatalogConflictEvents).values({
1090
+ organizationId: orgId,
1091
+ conflictId: inserted.id,
1092
+ fromState: null,
1093
+ toState: "open",
1094
+ reason: "Shared catalog field changed on both sides.",
1095
+ changedBy,
1096
+ });
1097
+ }
1098
+ return Ok(undefined);
704
1099
  }
705
1100
 
706
1101
  private async setCatalogAttributes(
@@ -708,6 +1103,7 @@ export class ChannelConnectorService {
708
1103
  item: ChannelCatalogItem,
709
1104
  actor: Actor,
710
1105
  blockedPaths: ReadonlySet<FieldPath> = new Set<FieldPath>(),
1106
+ catalogCtx?: CatalogWriteContext,
711
1107
  ): Promise<PluginResult<{ created: number; changed: boolean }>> {
712
1108
  const attributes = item.attributes ?? [{
713
1109
  locale: "en",
@@ -737,12 +1133,31 @@ export class ChannelConnectorService {
737
1133
  } else if (attributeFields.some((field) => (current[field] == null ? null : current[field]) !== (writeAttribute[field] == null ? null : writeAttribute[field]))) {
738
1134
  changed = true;
739
1135
  }
740
- const result = await this.catalog.setAttributes(entityId, attribute.locale, writeAttribute, actor);
1136
+ const result = await this.catalog.setAttributes(entityId, attribute.locale, writeAttribute, actor, catalogCtx);
741
1137
  if (!result.ok) return PluginErr(result.error.message);
742
1138
  }
743
1139
  return Ok({ created, changed });
744
1140
  }
745
1141
 
1142
+ private async setCatalogAttributesIfWritable(
1143
+ entityId: string,
1144
+ item: ChannelCatalogItem,
1145
+ actor: Actor,
1146
+ blockedPaths: ReadonlySet<FieldPath>,
1147
+ catalogCtx?: CatalogWriteContext,
1148
+ ): Promise<PluginResult<{ created: number; changed: boolean }>> {
1149
+ const attributes = item.attributes ?? [{
1150
+ locale: "en",
1151
+ title: item.title,
1152
+ ...(item.description !== undefined ? { description: item.description } : {}),
1153
+ }];
1154
+ const writable = attributes.some((attribute) => attributeFields.some((field) => (
1155
+ attribute[field] !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.${field}`)
1156
+ )));
1157
+ if (!writable) return Ok({ created: 0, changed: false });
1158
+ return this.setCatalogAttributes(entityId, item, actor, blockedPaths, catalogCtx);
1159
+ }
1160
+
746
1161
  private async upsertOptionAxes(
747
1162
  entityId: string,
748
1163
  item: ChannelCatalogItem,
@@ -1117,7 +1532,7 @@ export class ChannelConnectorService {
1117
1532
  inArray(channelEntityMap.entityId, entityIds),
1118
1533
  ));
1119
1534
  const mappingByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping]));
1120
- const items: ChannelPushCatalogItem[] = [];
1535
+ const items: CatalogPushAssemblyItem[] = [];
1121
1536
  const skipped: CatalogPushFieldSkip[] = [];
1122
1537
  const warnings: string[] = [];
1123
1538
  const revisionEntityIds: string[] = [];
@@ -1159,16 +1574,42 @@ export class ChannelConnectorService {
1159
1574
  }
1160
1575
  const fieldMapping = this.resolveCatalogFieldMapping(store, filterableCustomFields, warnings);
1161
1576
  const heldPaths = new Set(entityMapping.heldFieldPaths ?? []);
1162
- const fields: ChannelPushCatalogField[] = [];
1577
+ const forcedPushPaths = new Set([
1578
+ ...(entityMapping.forcedPushFieldPaths ?? []),
1579
+ ...(options.forceFieldPaths?.[entity.id] ?? []),
1580
+ ]);
1581
+ const fields: CatalogPushAssemblyField[] = [];
1163
1582
  const appendField = (fieldPath: FieldPath, value: unknown) => {
1164
- if (value === undefined || owners.get(fieldPath) !== "platform") return;
1583
+ if (value === undefined) return;
1584
+ const owner = owners.get(fieldPath);
1585
+ const mapping = selectCatalogFieldMapping(fieldMapping, fieldPath);
1586
+ if (owner === "store") {
1587
+ skipped.push({
1588
+ entityId,
1589
+ fieldPath,
1590
+ reason: "store_owned",
1591
+ value,
1592
+ owner,
1593
+ ...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
1594
+ });
1595
+ return;
1596
+ }
1597
+ if (owner === undefined) return;
1598
+ const forced = forcedPushPaths.has(fieldPath);
1599
+ if (owner !== "platform" && !forced) return;
1165
1600
  if (heldPaths.has(fieldPath)) {
1166
- skipped.push({ entityId, fieldPath, reason: "held" });
1601
+ skipped.push({
1602
+ entityId,
1603
+ fieldPath,
1604
+ reason: "held",
1605
+ value,
1606
+ owner,
1607
+ ...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
1608
+ });
1167
1609
  return;
1168
1610
  }
1169
- const mapping = selectCatalogFieldMapping(fieldMapping, fieldPath);
1170
1611
  if (!mapping) {
1171
- skipped.push({ entityId, fieldPath, reason: "no_mapping" });
1612
+ skipped.push({ entityId, fieldPath, reason: "no_mapping", value, owner });
1172
1613
  return;
1173
1614
  }
1174
1615
  fields.push(pushCatalogField(fieldPath, value, mapping));
@@ -1190,21 +1631,47 @@ export class ChannelConnectorService {
1190
1631
 
1191
1632
  const media = await this.media.listEntityMedia(entity.id, { orgId });
1192
1633
  if (!media.ok) return PluginErr(media.error.message);
1193
- const images: ChannelPushCatalogImage[] = [];
1634
+ const images: CatalogPushAssemblyImage[] = [];
1194
1635
  for (const attached of media.value) {
1195
1636
  const role = pushCatalogImageRole(attached.role);
1196
1637
  if (!role) continue;
1197
1638
  const fieldPath = `media.${role}` as FieldPath;
1198
- if (owners.get(fieldPath) !== "platform") continue;
1639
+ const owner = owners.get(fieldPath);
1640
+ const mapping = selectCatalogFieldMapping(fieldMapping, fieldPath);
1641
+ const imageValue = [{ url: attached.url, role }];
1642
+ if (owner === "store") {
1643
+ skipped.push({
1644
+ entityId,
1645
+ fieldPath,
1646
+ reason: "store_owned",
1647
+ value: imageValue,
1648
+ owner,
1649
+ ...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
1650
+ });
1651
+ continue;
1652
+ }
1653
+ if (owner === undefined) continue;
1654
+ const forced = forcedPushPaths.has(fieldPath);
1655
+ if (owner !== "platform" && !forced) continue;
1199
1656
  if (heldPaths.has(fieldPath)) {
1200
- skipped.push({ entityId, fieldPath, reason: "held" });
1657
+ skipped.push({
1658
+ entityId,
1659
+ fieldPath,
1660
+ reason: "held",
1661
+ value: imageValue,
1662
+ owner,
1663
+ ...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
1664
+ });
1201
1665
  continue;
1202
1666
  }
1203
- if (!selectCatalogFieldMapping(fieldMapping, fieldPath)) {
1204
- skipped.push({ entityId, fieldPath, reason: "no_mapping" });
1667
+ if (!mapping) {
1668
+ skipped.push({ entityId, fieldPath, reason: "no_mapping", value: imageValue, owner });
1205
1669
  continue;
1206
1670
  }
1207
1671
  images.push({
1672
+ fieldPath,
1673
+ target: mapping.target,
1674
+ remoteKey: mapping.remoteKey,
1208
1675
  url: attached.url,
1209
1676
  role,
1210
1677
  sortOrder: attached.sortOrder,
@@ -1212,7 +1679,7 @@ export class ChannelConnectorService {
1212
1679
  });
1213
1680
  }
1214
1681
  fields.sort((left, right) => left.fieldPath.localeCompare(right.fieldPath));
1215
- const item: ChannelPushCatalogItem = {
1682
+ const item: CatalogPushAssemblyItem = {
1216
1683
  externalId: entityMapping.externalId,
1217
1684
  fields,
1218
1685
  ...(images.length > 0 ? { images } : {}),
@@ -1237,6 +1704,242 @@ export class ChannelConnectorService {
1237
1704
  return Ok({ items, skipped, warnings: [...new Set(warnings)] });
1238
1705
  }
1239
1706
 
1707
+ async recordOutboundPush(
1708
+ orgId: string,
1709
+ storeId: string,
1710
+ outcomes: ChannelPushCatalogItemOutcome[],
1711
+ items: ChannelPushCatalogItem[],
1712
+ phase: "write-ahead" | "settle" = "settle",
1713
+ ): Promise<PluginResult<void>> {
1714
+ const outcomeByExternalId = new Map(outcomes.map((outcome) => [outcome.externalId, outcome]));
1715
+ const now = new Date();
1716
+ for (const item of items) {
1717
+ const outcome = outcomeByExternalId.get(item.externalId);
1718
+ const mapping = await this.db.select({
1719
+ id: channelEntityMap.id,
1720
+ externalId: channelEntityMap.externalId,
1721
+ forcedPushFieldPaths: channelEntityMap.forcedPushFieldPaths,
1722
+ }).from(channelEntityMap).where(and(
1723
+ eq(channelEntityMap.organizationId, orgId),
1724
+ eq(channelEntityMap.storeId, storeId),
1725
+ eq(channelEntityMap.kind, "entity"),
1726
+ eq(channelEntityMap.externalId, item.externalId),
1727
+ ));
1728
+ if (!mapping[0]) continue;
1729
+ if (outcome?.ok === true) {
1730
+ const fieldPaths = outboundFieldPaths(item);
1731
+ await this.db.update(channelEntityMap).set({
1732
+ outboundHash: canonicalOutboundHash(mapping[0].externalId, item, fieldPaths),
1733
+ outboundPushedAt: now,
1734
+ outboundFieldPaths: fieldPaths,
1735
+ // A force is an operator's conflict resolution. The write-ahead runs
1736
+ // before the connector is called and its outcomes are optimistic, so
1737
+ // consuming the force there would discard the resolution on a failed
1738
+ // push and the retry would silently omit the field.
1739
+ ...(phase === "settle"
1740
+ ? { forcedPushFieldPaths: (mapping[0].forcedPushFieldPaths ?? []).filter((path) => !fieldPaths.includes(path)) }
1741
+ : {}),
1742
+ }).where(eq(channelEntityMap.id, mapping[0].id));
1743
+ } else {
1744
+ await this.db.update(channelEntityMap).set({
1745
+ outboundHash: null,
1746
+ outboundPushedAt: null,
1747
+ outboundFieldPaths: [],
1748
+ syncHash: "",
1749
+ }).where(eq(channelEntityMap.id, mapping[0].id));
1750
+ }
1751
+ }
1752
+ return Ok(undefined);
1753
+ }
1754
+
1755
+ async pushCatalogToStore(
1756
+ orgId: string,
1757
+ storeId: string,
1758
+ entityIds: string[],
1759
+ ): Promise<PluginResult<PushCatalogToStoreResult>> {
1760
+ const assembled = await this.buildCatalogPushItems(orgId, storeId, entityIds);
1761
+ if (!assembled.ok) return assembled;
1762
+ const store = await this.getStoreRecord(orgId, storeId);
1763
+ if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
1764
+ const connector = this.connectors.get(store.provider);
1765
+ if (!connector?.pushCatalog) return PluginErr(`Catalog push is not supported by provider "${store.provider}".`);
1766
+ if (assembled.value.items.length === 0) return Ok({
1767
+ outcomes: [],
1768
+ skipped: assembled.value.skipped,
1769
+ warnings: assembled.value.warnings,
1770
+ });
1771
+
1772
+ const writeAhead = await this.recordOutboundPush(
1773
+ orgId,
1774
+ storeId,
1775
+ assembled.value.items.map((item) => ({ externalId: item.externalId, ok: true })),
1776
+ assembled.value.items,
1777
+ "write-ahead",
1778
+ );
1779
+ if (!writeAhead.ok) return writeAhead;
1780
+
1781
+ let result: Awaited<ReturnType<NonNullable<typeof connector.pushCatalog>>>;
1782
+ try {
1783
+ result = await connector.pushCatalog(store as ChannelStore, assembled.value.items);
1784
+ } catch (error) {
1785
+ const connectorError = {
1786
+ code: "CATALOG_PUSH_THROWN",
1787
+ message: error instanceof Error ? error.message : "Catalog push failed.",
1788
+ };
1789
+ const cleared = await this.recordOutboundPush(
1790
+ orgId,
1791
+ storeId,
1792
+ assembled.value.items.map((item) => ({ externalId: item.externalId, ok: false, error: connectorError })),
1793
+ assembled.value.items,
1794
+ );
1795
+ if (!cleared.ok) return cleared;
1796
+ return PluginErr(connectorError.message, connectorError.code);
1797
+ }
1798
+ if (!result.ok) {
1799
+ const cleared = await this.recordOutboundPush(
1800
+ orgId,
1801
+ storeId,
1802
+ assembled.value.items.map((item) => ({ externalId: item.externalId, ok: false, error: result.error })),
1803
+ assembled.value.items,
1804
+ );
1805
+ if (!cleared.ok) return cleared;
1806
+ return PluginErr(result.error.message, result.error.code);
1807
+ }
1808
+
1809
+ const recorded = await this.recordOutboundPush(orgId, storeId, result.value.outcomes, assembled.value.items);
1810
+ if (!recorded.ok) return recorded;
1811
+ const successfulEntityIds = assembled.value.items
1812
+ .filter((item) => result.value.outcomes.some((outcome) => outcome.externalId === item.externalId && outcome.ok))
1813
+ .map((item) => item.externalId);
1814
+ if (successfulEntityIds.length > 0) {
1815
+ const mappings = await this.db.select({ entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(
1816
+ eq(channelEntityMap.organizationId, orgId),
1817
+ eq(channelEntityMap.storeId, storeId),
1818
+ eq(channelEntityMap.kind, "entity"),
1819
+ inArray(channelEntityMap.externalId, successfulEntityIds),
1820
+ ));
1821
+ const actor = createSystemActor(orgId);
1822
+ try {
1823
+ await this.transact(async (tx) => {
1824
+ const txContext = createTxContext(tx, { actor });
1825
+ for (const entityId of [...new Set(mappings.map((mapping) => mapping.entityId))]) {
1826
+ const revision = await this.catalog.recordEntityRevision(entityId, actor, "push", txContext);
1827
+ if (!revision.ok) throw new Error(revision.error.message);
1828
+ }
1829
+ });
1830
+ } catch (error) {
1831
+ return PluginErr(error instanceof Error ? error.message : "Failed to record catalog push revisions.");
1832
+ }
1833
+ }
1834
+ return Ok({
1835
+ ...result.value,
1836
+ skipped: assembled.value.skipped,
1837
+ warnings: assembled.value.warnings,
1838
+ });
1839
+ }
1840
+
1841
+ async previewCatalogPush(
1842
+ orgId: string,
1843
+ storeId: string,
1844
+ entityIds?: string[],
1845
+ ): Promise<PluginResult<CatalogPushPreviewResult>> {
1846
+ const assembledEntityIds = await this.resolveCatalogPushEntityIds(orgId, storeId, entityIds);
1847
+ const assembled = await this.buildCatalogPushItems(orgId, storeId, assembledEntityIds);
1848
+ if (!assembled.ok) return assembled;
1849
+ const store = await this.getStoreRecord(orgId, storeId);
1850
+ if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
1851
+ const connector = this.connectors.get(store.provider);
1852
+ if (!connector?.pushCatalog) return PluginErr(`Catalog push is not supported by provider "${store.provider}".`);
1853
+ if (assembled.value.items.length === 0) {
1854
+ return Ok({ items: [], skipped: assembled.value.skipped, warnings: assembled.value.warnings });
1855
+ }
1856
+
1857
+ let result: Awaited<ReturnType<NonNullable<typeof connector.pushCatalog>>>;
1858
+ try {
1859
+ result = await connector.pushCatalog(store as ChannelStore, assembled.value.items, { dryRun: true });
1860
+ } catch (error) {
1861
+ return PluginErr(
1862
+ error instanceof Error ? error.message : "Catalog push preview failed.",
1863
+ "CATALOG_PREVIEW_THROWN",
1864
+ );
1865
+ }
1866
+ if (!result.ok) return PluginErr(result.error.message, result.error.code);
1867
+ const failed = result.value.outcomes.find((outcome) => !outcome.ok);
1868
+ if (failed) return PluginErr(
1869
+ failed.error?.message ?? `Catalog push preview failed for item "${failed.externalId}".`,
1870
+ failed.error?.code ?? "CATALOG_PREVIEW_FAILED",
1871
+ );
1872
+
1873
+ const mappings = assembledEntityIds.length === 0
1874
+ ? []
1875
+ : await this.db.select({ entityId: channelEntityMap.entityId, externalId: channelEntityMap.externalId }).from(channelEntityMap).where(and(
1876
+ eq(channelEntityMap.organizationId, orgId),
1877
+ eq(channelEntityMap.storeId, storeId),
1878
+ eq(channelEntityMap.kind, "entity"),
1879
+ inArray(channelEntityMap.entityId, assembledEntityIds),
1880
+ ));
1881
+ const externalByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping.externalId]));
1882
+ const skippedByExternal = new Map<string, CatalogPushFieldSkip[]>();
1883
+ for (const skipped of assembled.value.skipped) {
1884
+ const externalId = externalByEntity.get(skipped.entityId);
1885
+ if (!externalId) continue;
1886
+ const existing = skippedByExternal.get(externalId) ?? [];
1887
+ existing.push(skipped);
1888
+ skippedByExternal.set(externalId, existing);
1889
+ }
1890
+ const outcomeByExternalId = new Map(result.value.outcomes.map((outcome) => [outcome.externalId, outcome]));
1891
+ const beforeFor = (externalId: string, fieldPath: FieldPath): {
1892
+ before: CatalogPushPreviewBefore;
1893
+ beforeStatus: CatalogPushPreviewBeforeStatus;
1894
+ } => {
1895
+ const previousFields = outcomeByExternalId.get(externalId)?.previousFields;
1896
+ if (previousFields === undefined) {
1897
+ return { before: { status: "unavailable" }, beforeStatus: "unavailable" };
1898
+ }
1899
+ const previous = previousFields.find((field) => field.fieldPath === fieldPath);
1900
+ if (!previous) return { before: null, beforeStatus: "missing" };
1901
+ return { before: previous.value, beforeStatus: "value" };
1902
+ };
1903
+
1904
+ const items = assembled.value.items.map((item) => {
1905
+ const diffs: CatalogPushPreviewDiff[] = item.fields.map((field) => ({
1906
+ fieldPath: field.fieldPath,
1907
+ target: field.target,
1908
+ remoteKey: field.remoteKey ?? null,
1909
+ ...beforeFor(item.externalId, field.fieldPath),
1910
+ after: field.value,
1911
+ owner: "platform",
1912
+ willWrite: true,
1913
+ }));
1914
+ for (const image of item.images ?? []) {
1915
+ diffs.push({
1916
+ fieldPath: image.fieldPath,
1917
+ target: image.target,
1918
+ remoteKey: image.remoteKey,
1919
+ ...beforeFor(item.externalId, image.fieldPath),
1920
+ after: pushFieldValue(item, image.fieldPath),
1921
+ owner: "platform",
1922
+ willWrite: true,
1923
+ });
1924
+ }
1925
+ for (const skipped of skippedByExternal.get(item.externalId) ?? []) {
1926
+ if (skipped.value === undefined || skipped.owner === undefined) continue;
1927
+ diffs.push({
1928
+ fieldPath: skipped.fieldPath,
1929
+ target: skipped.target ?? null,
1930
+ remoteKey: skipped.remoteKey ?? null,
1931
+ ...beforeFor(item.externalId, skipped.fieldPath),
1932
+ after: skipped.value,
1933
+ owner: skipped.owner,
1934
+ willWrite: false,
1935
+ reason: skipped.reason,
1936
+ });
1937
+ }
1938
+ return { externalId: item.externalId, diffs };
1939
+ });
1940
+ return Ok({ items, skipped: assembled.value.skipped, warnings: assembled.value.warnings });
1941
+ }
1942
+
1240
1943
  async getCatalogWriteSettings(orgId: string, storeId: string): Promise<PluginResult<CatalogWriteSettings>> {
1241
1944
  const store = await this.getStoreRecord(orgId, storeId);
1242
1945
  if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
@@ -1532,7 +2235,7 @@ export class ChannelConnectorService {
1532
2235
  const promoted = await this.catalog.setAttributes(entity.id, "en", {
1533
2236
  title: metadata.title,
1534
2237
  ...(typeof metadata.description === "string" ? { description: metadata.description } : {}),
1535
- }, actor);
2238
+ }, actor, CHANNEL_CONVERGENCE_CTX);
1536
2239
  if (!promoted.ok) return PluginErr(promoted.error.message);
1537
2240
  const [confirmed] = await this.db.select({ id: sellableAttributes.id, title: sellableAttributes.title, description: sellableAttributes.description }).from(sellableAttributes).where(and(
1538
2241
  eq(sellableAttributes.entityId, entity.id),
@@ -1878,6 +2581,7 @@ export class ChannelConnectorService {
1878
2581
  if (!seeded.ok) return PluginErr(seeded.error.message);
1879
2582
  for (const path of seedPaths) ownershipBeforeSeed.set(path, "store");
1880
2583
  const owners = ownershipBeforeSeed;
2584
+ const outboundEcho = entityMapping ? this.isOutboundEcho(entityMapping, item) : false;
1881
2585
  const remoteChanged = entityMapping === undefined || entityMapping.syncHash !== remoteHash;
1882
2586
  // An unchanged remote item writes nothing and advances no baseline:
1883
2587
  // converging a stale replay would revert local edits to shared and
@@ -1886,15 +2590,27 @@ export class ChannelConnectorService {
1886
2590
  continue;
1887
2591
  }
1888
2592
  const shared = existingEntity
1889
- ? await this.detectSharedConflicts(entityId, storeId, existingEntity, entityMapping, item, owners)
2593
+ ? await this.detectSharedConflicts(
2594
+ entityId, storeId, existingEntity, entityMapping, item, owners,
2595
+ importedFieldPaths(item), remoteHash,
2596
+ outboundEcho ? { certifiedPaths: new Set(entityMapping?.outboundFieldPaths ?? []) } : undefined,
2597
+ )
1890
2598
  : { paths: [], conflicts: [] };
2599
+ const persistedConflicts = await this.persistCatalogConflicts(orgId, shared.conflicts, requireUserId(actor));
2600
+ if (!persistedConflicts.ok) return persistedConflicts;
1891
2601
  const owned = this.filterOwnedFields(item, owners);
1892
2602
  const heldSharedPaths = [...new Set([...(entityMapping?.heldFieldPaths ?? []), ...shared.paths])];
2603
+ // A newly held path revokes any force left from an earlier resolution of
2604
+ // that same path: the force was the operator's answer to a question that
2605
+ // has since been asked again, and it must not pre-empt the new one.
2606
+ const survivingForcedPaths = (entityMapping?.forcedPushFieldPaths ?? []).filter(
2607
+ (path) => !heldSharedPaths.includes(path),
2608
+ );
1893
2609
  const held = this.filterConflictingFields(owned.writable, heldSharedPaths);
1894
2610
  const writable = held.writable;
1895
2611
  const blockedPaths = new Set<FieldPath>([...owned.skipped, ...heldSharedPaths]);
1896
2612
  skipped.push(...owned.skipped.map((fieldPath) => ({ entityId, fieldPath })));
1897
- conflicts.push(...shared.conflicts);
2613
+ conflicts.push(...shared.conflicts.map(({ platformValue: _platformValue, storeValue: _storeValue, ...conflict }) => conflict));
1898
2614
  for (const conflict of shared.conflicts) {
1899
2615
  warnings.push(`Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
1900
2616
  }
@@ -1924,7 +2640,7 @@ export class ChannelConnectorService {
1924
2640
  if (shouldUpdate) {
1925
2641
  converged += 1;
1926
2642
  if (Object.keys(updateInput).length > 0) {
1927
- const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor);
2643
+ const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
1928
2644
  if (!updated.ok) return PluginErr(updated.error.message);
1929
2645
  entityTouched = true;
1930
2646
  }
@@ -1933,7 +2649,7 @@ export class ChannelConnectorService {
1933
2649
 
1934
2650
  const optionAxes = await this.upsertOptionAxes(entityId, writable, actor);
1935
2651
  if (!optionAxes.ok) return optionAxes;
1936
- const attributes = await this.setCatalogAttributes(entityId, writable, actor, blockedPaths);
2652
+ const attributes = await this.setCatalogAttributesIfWritable(entityId, writable, actor, blockedPaths, CHANNEL_CONVERGENCE_CTX);
1937
2653
  if (!attributes.ok) return attributes;
1938
2654
  const variantIds = await this.upsertVariants(
1939
2655
  orgId,
@@ -1977,12 +2693,14 @@ export class ChannelConnectorService {
1977
2693
  syncHash: remoteHash,
1978
2694
  lastSyncedAt,
1979
2695
  heldFieldPaths: heldSharedPaths,
2696
+ forcedPushFieldPaths: survivingForcedPaths,
1980
2697
  });
1981
2698
  } else if (entityMapping) {
1982
2699
  await this.db.update(channelEntityMap).set({
1983
2700
  syncHash: remoteHash,
1984
2701
  lastSyncedAt,
1985
2702
  heldFieldPaths: heldSharedPaths,
2703
+ forcedPushFieldPaths: survivingForcedPaths,
1986
2704
  }).where(eq(channelEntityMap.id, entityMapping.id));
1987
2705
  }
1988
2706
  await this.db.update(channelEntityMap).set({ lastSyncedAt }).where(and(
@@ -2071,11 +2789,17 @@ export class ChannelConnectorService {
2071
2789
  inventoryUpdated += 1;
2072
2790
  }
2073
2791
  const threshold = this.options.driftAlertThreshold ?? 25;
2792
+ const openConflictRows = await this.db.select({ id: channelCatalogConflicts.id }).from(channelCatalogConflicts).where(and(
2793
+ eq(channelCatalogConflicts.organizationId, orgId),
2794
+ eq(channelCatalogConflicts.storeId, storeId),
2795
+ eq(channelCatalogConflicts.state, "open"),
2796
+ ));
2074
2797
  const report: ReconcileReport = {
2075
2798
  imported: converged.value.imported,
2076
2799
  converged: converged.value.converged,
2077
2800
  archived,
2078
2801
  inventoryUpdated,
2802
+ openConflicts: openConflictRows.length,
2079
2803
  driftAlert: converged.value.imported + converged.value.converged + archived > threshold,
2080
2804
  ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
2081
2805
  ...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
@@ -2097,6 +2821,152 @@ export class ChannelConnectorService {
2097
2821
  return Ok({ lastReconcileAt: store.lastReconcileAt, report, driftAlert: report?.driftAlert ?? false });
2098
2822
  }
2099
2823
 
2824
+ async listCatalogConflicts(
2825
+ orgId: string,
2826
+ storeId?: string,
2827
+ state: ChannelCatalogConflict["state"] = "open",
2828
+ ): Promise<PluginResult<ChannelCatalogConflict[]>> {
2829
+ const conditions = [eq(channelCatalogConflicts.organizationId, orgId), eq(channelCatalogConflicts.state, state)];
2830
+ if (storeId !== undefined) conditions.push(eq(channelCatalogConflicts.storeId, storeId));
2831
+ return Ok(await this.db.select().from(channelCatalogConflicts).where(and(...conditions)) as ChannelCatalogConflict[]);
2832
+ }
2833
+
2834
+ async resolveCatalogConflict(
2835
+ orgId: string,
2836
+ id: string,
2837
+ choose: "platform" | "store",
2838
+ actor: Pick<Actor, "userId">,
2839
+ ): Promise<PluginResult<ChannelCatalogConflict>> {
2840
+ const [conflict] = await this.db.select().from(channelCatalogConflicts).where(and(
2841
+ eq(channelCatalogConflicts.organizationId, orgId),
2842
+ eq(channelCatalogConflicts.id, id),
2843
+ eq(channelCatalogConflicts.state, "open"),
2844
+ ));
2845
+ if (!conflict) return PluginErr("Catalog conflict not found or already resolved.", "NOT_FOUND");
2846
+ if (choose === "platform" && !this.jobs) return PluginErr("Jobs are not configured.", "JOBS_UNAVAILABLE");
2847
+ const [mapping] = await this.db.select().from(channelEntityMap).where(and(
2848
+ eq(channelEntityMap.organizationId, orgId),
2849
+ eq(channelEntityMap.storeId, conflict.storeId),
2850
+ eq(channelEntityMap.kind, "entity"),
2851
+ eq(channelEntityMap.entityId, conflict.entityId),
2852
+ ));
2853
+ if (!mapping) return PluginErr("Catalog conflict mapping not found.", "NOT_FOUND");
2854
+ const systemActor = createSystemActor(orgId);
2855
+ let resolutionBaselineAt: Date | undefined;
2856
+ if (choose === "store") {
2857
+ const applied = await this.applyStoreConflictValue(orgId, conflict as ChannelCatalogConflict, systemActor);
2858
+ if (!applied.ok) return PluginErr(applied.error, applied.code);
2859
+ const revisions = await this.catalog.repository.findRevisionMarkers(conflict.entityId);
2860
+ resolutionBaselineAt = revisions.at(-1)?.createdAt;
2861
+ }
2862
+ const heldFieldPaths = (mapping.heldFieldPaths ?? []).filter((path) => path !== conflict.fieldPath);
2863
+ const forcedPushFieldPaths = choose === "platform"
2864
+ ? [...new Set([...(mapping.forcedPushFieldPaths ?? []), conflict.fieldPath as FieldPath])]
2865
+ : mapping.forcedPushFieldPaths ?? [];
2866
+ const [resolved] = await this.db.update(channelCatalogConflicts).set({
2867
+ state: "resolved",
2868
+ resolvedBy: requireUserId(actor),
2869
+ updatedAt: new Date(),
2870
+ }).where(and(
2871
+ eq(channelCatalogConflicts.organizationId, orgId),
2872
+ eq(channelCatalogConflicts.id, id),
2873
+ eq(channelCatalogConflicts.state, "open"),
2874
+ )).returning();
2875
+ if (!resolved) return PluginErr("Catalog conflict not found or already resolved.", "NOT_FOUND");
2876
+ await this.db.update(channelEntityMap).set({
2877
+ heldFieldPaths,
2878
+ forcedPushFieldPaths,
2879
+ ...(resolutionBaselineAt ? { lastSyncedAt: resolutionBaselineAt } : {}),
2880
+ }).where(and(
2881
+ eq(channelEntityMap.organizationId, orgId),
2882
+ eq(channelEntityMap.id, mapping.id),
2883
+ ));
2884
+ await this.db.insert(channelCatalogConflictEvents).values({
2885
+ organizationId: orgId,
2886
+ conflictId: conflict.id,
2887
+ fromState: "open",
2888
+ toState: "resolved",
2889
+ reason: `Operator chose the ${choose} value.`,
2890
+ changedBy: requireUserId(actor),
2891
+ });
2892
+ if (choose === "platform") {
2893
+ await this.jobs!.enqueue("channel/push-catalog", {
2894
+ organizationId: orgId,
2895
+ storeId: conflict.storeId,
2896
+ entityIds: [conflict.entityId],
2897
+ }, {
2898
+ organizationId: orgId,
2899
+ concurrencyKey: catalogPushConcurrencyKey({ storeId: conflict.storeId, entityIds: [conflict.entityId] }),
2900
+ supersedes: true,
2901
+ });
2902
+ }
2903
+ return Ok(resolved as ChannelCatalogConflict);
2904
+ }
2905
+
2906
+ private async applyStoreConflictValue(
2907
+ orgId: string,
2908
+ conflict: ChannelCatalogConflict,
2909
+ actor: Actor,
2910
+ ): Promise<PluginResult<void>> {
2911
+ const [root, segment, field] = conflict.fieldPath.split(".");
2912
+ if (root === "entity" && segment === "slug") {
2913
+ if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not a valid slug.", "INVALID_CONFLICT_VALUE");
2914
+ const updated = await this.catalog.update(conflict.entityId, { slug: conflict.storeValue }, actor, CHANNEL_CONVERGENCE_CTX);
2915
+ return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
2916
+ }
2917
+ if (root === "entity" && segment === "status") {
2918
+ if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not a valid status.", "INVALID_CONFLICT_VALUE");
2919
+ const status = ["draft", "active", "archived", "discontinued"].find((value) => value === conflict.storeValue);
2920
+ if (!status) return PluginErr("The stored catalog value is not a valid status.", "INVALID_CONFLICT_VALUE");
2921
+ const updated = await this.catalog.update(conflict.entityId, { status, isVisible: status === "active" }, actor, CHANNEL_CONVERGENCE_CTX);
2922
+ return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
2923
+ }
2924
+ if (root === "entity" && segment === "metadata" && field) {
2925
+ const [entity] = await this.db.select().from(sellableEntities).where(and(
2926
+ eq(sellableEntities.organizationId, orgId),
2927
+ eq(sellableEntities.id, conflict.entityId),
2928
+ ));
2929
+ if (!entity) return PluginErr("Catalog entity not found.", "NOT_FOUND");
2930
+ const updated = await this.catalog.update(conflict.entityId, {
2931
+ metadata: { ...(entity.metadata ?? {}), [field]: conflict.storeValue },
2932
+ }, actor, CHANNEL_CONVERGENCE_CTX);
2933
+ return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
2934
+ }
2935
+ if (root === "attributes" && segment && field && attributeFields.some((attributeField) => attributeField === field)) {
2936
+ const [attribute] = await this.db.select().from(sellableAttributes).where(and(
2937
+ eq(sellableAttributes.entityId, conflict.entityId),
2938
+ eq(sellableAttributes.locale, segment),
2939
+ ));
2940
+ const title = attribute?.title ?? "";
2941
+ const attrs: Parameters<CatalogService["setAttributes"]>[2] = { title };
2942
+ if (field === "title") {
2943
+ if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
2944
+ attrs.title = conflict.storeValue;
2945
+ } else if (field === "subtitle") {
2946
+ if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
2947
+ attrs.subtitle = conflict.storeValue;
2948
+ } else if (field === "description") {
2949
+ if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
2950
+ attrs.description = conflict.storeValue;
2951
+ } else if (field === "richDescription") {
2952
+ attrs.richDescription = conflict.storeValue;
2953
+ } else if (field === "seoTitle") {
2954
+ if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
2955
+ attrs.seoTitle = conflict.storeValue;
2956
+ } else if (field === "seoDescription") {
2957
+ if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
2958
+ attrs.seoDescription = conflict.storeValue;
2959
+ }
2960
+ const updated = await this.catalog.setAttributes(conflict.entityId, segment, attrs, actor, CHANNEL_CONVERGENCE_CTX);
2961
+ return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
2962
+ }
2963
+ if (root === "customFields" && segment && field === "en") {
2964
+ const updated = await this.catalog.update(conflict.entityId, { customFields: { [segment]: conflict.storeValue } }, actor, CHANNEL_CONVERGENCE_CTX);
2965
+ return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
2966
+ }
2967
+ return PluginErr(`Conflict field path "${conflict.fieldPath}" cannot be resolved to the store value.`, "UNSUPPORTED_CONFLICT_FIELD");
2968
+ }
2969
+
2100
2970
  async syncInventory(
2101
2971
  orgId: string,
2102
2972
  storeId: string,
@@ -2318,7 +3188,19 @@ export class ChannelConnectorService {
2318
3188
  const status = typeof product.status === "string" && ["draft", "active", "archived", "discontinued"].includes(product.status)
2319
3189
  ? product.status as NonNullable<ChannelCatalogItem["status"]>
2320
3190
  : undefined;
2321
- const remoteItem: ChannelCatalogItem = {
3191
+ const customFields = product.customFields && typeof product.customFields === "object" && !Array.isArray(product.customFields)
3192
+ ? product.customFields as Record<string, unknown>
3193
+ : undefined;
3194
+ const images = Array.isArray(product.images)
3195
+ ? product.images.flatMap((raw) => {
3196
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
3197
+ const image = raw as Record<string, unknown>;
3198
+ const role = typeof image.role === "string" ? pushCatalogImageRole(image.role) : undefined;
3199
+ const url = typeof image.url === "string" ? image.url : typeof image.src === "string" ? image.src : undefined;
3200
+ return role && url ? [{ role, url }] : [];
3201
+ })
3202
+ : [];
3203
+ const remoteItem = {
2322
3204
  externalId: mapping.externalId,
2323
3205
  slug: typeof product.slug === "string" ? product.slug : entity.slug,
2324
3206
  title,
@@ -2326,8 +3208,10 @@ export class ChannelConnectorService {
2326
3208
  ...(status !== undefined ? { status } : {}),
2327
3209
  attributes: [{ locale: "en", title, ...(description !== undefined ? { description } : {}) }],
2328
3210
  ...(Object.keys(remoteMetadata).length > 0 ? { metadata: remoteMetadata } : {}),
3211
+ ...(customFields !== undefined ? { customFields } : {}),
3212
+ ...(images.length > 0 ? { images } : {}),
2329
3213
  variants: [],
2330
- };
3214
+ } as ChannelCatalogItem & { customFields?: Record<string, unknown> };
2331
3215
  const fieldPaths: FieldPath[] = [];
2332
3216
  if (typeof product.slug === "string") fieldPaths.push("entity.slug");
2333
3217
  if (status !== undefined) fieldPaths.push("entity.status");
@@ -2337,6 +3221,14 @@ export class ChannelConnectorService {
2337
3221
  }
2338
3222
  if (typeof product.title === "string") fieldPaths.push("attributes.en.title");
2339
3223
  if (product.description !== undefined) fieldPaths.push("attributes.en.description");
3224
+ for (const [name, locales] of Object.entries(customFields ?? {})) {
3225
+ if (!locales || typeof locales !== "object" || Array.isArray(locales)) continue;
3226
+ for (const locale of Object.keys(locales as Record<string, unknown>)) {
3227
+ const path = `customFields.${name}.${locale}`;
3228
+ if (isValidFieldPath(path)) fieldPaths.push(path);
3229
+ }
3230
+ }
3231
+ for (const image of images) fieldPaths.push(`media.${image.role}`);
2340
3232
  const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
2341
3233
  const seedPaths = fieldPaths.filter((path) => !ownershipBeforeSeed.has(path));
2342
3234
  const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
@@ -2344,9 +3236,21 @@ export class ChannelConnectorService {
2344
3236
  for (const path of seedPaths) ownershipBeforeSeed.set(path, "store");
2345
3237
  const owners = ownershipBeforeSeed;
2346
3238
  const remoteHash = hash(product);
2347
- const shared = await this.detectSharedConflicts(entityId, storeId, entity, mapping, remoteItem, owners, fieldPaths, remoteHash);
3239
+ const outboundEcho = this.isOutboundEcho(mapping, remoteItem);
3240
+ const shared = await this.detectSharedConflicts(
3241
+ entityId, storeId, entity, mapping, remoteItem, owners, fieldPaths, remoteHash,
3242
+ outboundEcho ? { certifiedPaths: new Set(mapping.outboundFieldPaths ?? []) } : undefined,
3243
+ );
3244
+ const persistedConflicts = await this.persistCatalogConflicts(orgId, shared.conflicts, requireUserId(actor));
3245
+ if (!persistedConflicts.ok) return persistedConflicts;
2348
3246
  const owned = this.filterOwnedFieldsAtPaths(remoteItem, owners, fieldPaths);
2349
3247
  const heldPaths = [...new Set([...(mapping.heldFieldPaths ?? []), ...shared.paths])];
3248
+ // Same revocation as the reconcile path: a newly held path cancels any force
3249
+ // left from an earlier resolution, so a webhook-raised conflict cannot be
3250
+ // pre-empted by an operator's answer to a previous one.
3251
+ const survivingForcedPaths = (mapping.forcedPushFieldPaths ?? []).filter(
3252
+ (path) => !heldPaths.includes(path),
3253
+ );
2350
3254
  const held = this.filterConflictingFields(owned.writable, heldPaths);
2351
3255
  const blockedPaths = new Set<FieldPath>([
2352
3256
  ...owned.skipped,
@@ -2354,7 +3258,7 @@ export class ChannelConnectorService {
2354
3258
  ...(!fieldPaths.includes("attributes.en.title") ? ["attributes.en.title" as FieldPath] : []),
2355
3259
  ]);
2356
3260
  const skipped = owned.skipped.map((fieldPath) => ({ entityId, fieldPath }));
2357
- const conflicts = shared.conflicts;
3261
+ const conflicts = shared.conflicts.map(({ platformValue: _platformValue, storeValue: _storeValue, ...conflict }) => conflict);
2358
3262
  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
3263
  const writable = held.writable;
2360
3264
  const updateInput: {
@@ -2375,10 +3279,10 @@ export class ChannelConnectorService {
2375
3279
  updateInput.isVisible = writable.status === "active";
2376
3280
  }
2377
3281
  if (Object.keys(updateInput).length > 0) {
2378
- const updated = await this.catalog.update(entityId, updateInput, actor);
3282
+ const updated = await this.catalog.update(entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
2379
3283
  if (!updated.ok) return PluginErr(updated.error.message);
2380
3284
  }
2381
- const attributes = await this.setCatalogAttributes(entityId, writable, actor, blockedPaths);
3285
+ const attributes = await this.setCatalogAttributesIfWritable(entityId, writable, actor, blockedPaths, CHANNEL_CONVERGENCE_CTX);
2382
3286
  if (!attributes.ok) return attributes;
2383
3287
  const levels = Array.isArray(product.variants) ? product.variants as Array<Record<string, unknown>> : [];
2384
3288
  for (const variant of levels) {
@@ -2392,6 +3296,7 @@ export class ChannelConnectorService {
2392
3296
  syncHash: remoteHash,
2393
3297
  lastSyncedAt,
2394
3298
  heldFieldPaths: heldPaths,
3299
+ forcedPushFieldPaths: survivingForcedPaths,
2395
3300
  }).where(eq(channelEntityMap.id, mapping.id));
2396
3301
  return Ok({ skipped, conflicts, warnings });
2397
3302
  }
@@ -2425,9 +3330,9 @@ export class ChannelConnectorService {
2425
3330
  const max = this.options.refundAutoMax ?? order.amountCaptured ?? order.grandTotal;
2426
3331
  const ageOk = Date.now() - store.createdAt.getTime() >= (this.options.newStoreDays ?? 7) * 86_400_000;
2427
3332
  const auto = clean && amount > 0 && ageOk && amount <= max;
2428
- const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, state: auto ? "approved" : "requested", approvedBy: auto ? actor.userId : null }).returning();
3333
+ const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, state: auto ? "approved" : "requested", approvedBy: auto ? requireUserId(actor) : null }).returning();
2429
3334
  const request = rows[0] as ChannelRefundRequest;
2430
- await this.db.insert(channelRefundEvents).values({ organizationId: orgId, requestId: request.id, fromState: null, toState: request.state, reason: auto ? "Automatic guarded refund" : "Operator approval required", changedBy: actor.userId });
3335
+ await this.db.insert(channelRefundEvents).values({ organizationId: orgId, requestId: request.id, fromState: null, toState: request.state, reason: auto ? "Automatic guarded refund" : "Operator approval required", changedBy: requireUserId(actor) });
2431
3336
  if (auto) {
2432
3337
  const result = await this.executeRefund(request, refundLines, actor);
2433
3338
  if (!result.ok) return PluginErr(result.error);
@@ -2440,7 +3345,7 @@ export class ChannelConnectorService {
2440
3345
  const result = await ordersService.refundLines(request.orderId, { lines, reason: `Channel refund ${request.remoteRefundId}` }, actor);
2441
3346
  if (!result.ok) return PluginErr(result.error?.message ?? "Refund execution failed.");
2442
3347
  const [updated] = await this.db.update(channelRefundRequests).set({ state: "executed", updatedAt: new Date() }).where(and(eq(channelRefundRequests.organizationId, request.organizationId), eq(channelRefundRequests.id, request.id), eq(channelRefundRequests.state, "approved"))).returning();
2443
- await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: actor.userId });
3348
+ await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: requireUserId(actor) });
2444
3349
  return Ok(updated as ChannelRefundRequest);
2445
3350
  }
2446
3351
 
@@ -2574,7 +3479,7 @@ export class ChannelConnectorService {
2574
3479
  orgId,
2575
3480
  created.value.id,
2576
3481
  "exported",
2577
- actor.userId,
3482
+ requireUserId(actor),
2578
3483
  "Export attempt started.",
2579
3484
  );
2580
3485
  if (!exported.ok) return exported;
@@ -2594,7 +3499,7 @@ export class ChannelConnectorService {
2594
3499
  orgId,
2595
3500
  created.value.id,
2596
3501
  "failed",
2597
- actor.userId,
3502
+ requireUserId(actor),
2598
3503
  pushed.error.message,
2599
3504
  pushed.error.retriable === true ? "transient" : "definitive",
2600
3505
  );
@@ -2621,7 +3526,7 @@ export class ChannelConnectorService {
2621
3526
  orgId,
2622
3527
  created.value.id,
2623
3528
  "failed",
2624
- actor.userId,
3529
+ requireUserId(actor),
2625
3530
  remoteStatus.error.message,
2626
3531
  remoteStatus.error.retriable === true ? "transient" : "definitive",
2627
3532
  );
@@ -2631,7 +3536,7 @@ export class ChannelConnectorService {
2631
3536
  orgId,
2632
3537
  created.value.id,
2633
3538
  "confirmed",
2634
- actor.userId,
3539
+ requireUserId(actor),
2635
3540
  "Remote order confirmed.",
2636
3541
  );
2637
3542
  }
@@ -2640,7 +3545,7 @@ export class ChannelConnectorService {
2640
3545
  orgId,
2641
3546
  created.value.id,
2642
3547
  "failed",
2643
- actor.userId,
3548
+ requireUserId(actor),
2644
3549
  `Remote order status: ${remoteStatus.value.status}.`,
2645
3550
  );
2646
3551
  }
@@ -2746,4 +3651,467 @@ export class ChannelConnectorService {
2746
3651
  ): Promise<PluginResult<ChannelOrderExport>> {
2747
3652
  return this.transitionExport(orgId, exportId, "abandoned", changedBy, reason);
2748
3653
  }
3654
+
3655
+ async resolveCatalogPushEntityIds(
3656
+ orgId: string,
3657
+ storeId: string,
3658
+ entityIds?: string[],
3659
+ ): Promise<string[]> {
3660
+ if (entityIds !== undefined) return [...new Set(entityIds)].sort();
3661
+ const mappings = await this.db.select({ entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(
3662
+ eq(channelEntityMap.organizationId, orgId),
3663
+ eq(channelEntityMap.storeId, storeId),
3664
+ eq(channelEntityMap.kind, "entity"),
3665
+ ));
3666
+ return [...new Set(mappings.map((mapping) => mapping.entityId))].sort();
3667
+ }
3668
+
3669
+ async createCatalogPush(
3670
+ orgId: string,
3671
+ storeId: string,
3672
+ entityId: string,
3673
+ ): Promise<PluginResult<ChannelCatalogPush>> {
3674
+ const store = await this.getStoreRecord(orgId, storeId);
3675
+ if (!store || store.status !== "connected") {
3676
+ return PluginErr("Connected store not found.", "NOT_FOUND");
3677
+ }
3678
+ const rows = await this.db
3679
+ .insert(channelCatalogPushes)
3680
+ .values({ organizationId: orgId, storeId, entityId })
3681
+ .onConflictDoNothing({ target: [channelCatalogPushes.storeId, channelCatalogPushes.entityId] })
3682
+ .returning();
3683
+ if (rows[0]) return Ok(rows[0] as ChannelCatalogPush);
3684
+ const existing = await this.db
3685
+ .select()
3686
+ .from(channelCatalogPushes)
3687
+ .where(and(
3688
+ eq(channelCatalogPushes.organizationId, orgId),
3689
+ eq(channelCatalogPushes.storeId, storeId),
3690
+ eq(channelCatalogPushes.entityId, entityId),
3691
+ ));
3692
+ if (!existing[0]) return PluginErr("Failed to create channel catalog push.");
3693
+ return Ok(existing[0] as ChannelCatalogPush);
3694
+ }
3695
+
3696
+ async transitionCatalogPush(
3697
+ orgId: string,
3698
+ pushId: string,
3699
+ toState: CatalogPushState,
3700
+ changedBy: string,
3701
+ reason?: string,
3702
+ failureKind?: "definitive" | "transient",
3703
+ payloadSnapshot?: ChannelPushCatalogItem | null,
3704
+ ): Promise<PluginResult<ChannelCatalogPush>> {
3705
+ return this.transact(async (tx) => {
3706
+ const currentRows = await tx
3707
+ .select()
3708
+ .from(channelCatalogPushes)
3709
+ .where(and(
3710
+ eq(channelCatalogPushes.organizationId, orgId),
3711
+ eq(channelCatalogPushes.id, pushId),
3712
+ ));
3713
+ const current = currentRows[0] as ChannelCatalogPush | undefined;
3714
+ if (!current) return PluginErr("Channel catalog push not found.", "NOT_FOUND");
3715
+ if (!canCatalogPushTransition(current.state, toState)) {
3716
+ const error = new CommerceInvalidTransitionError(
3717
+ `Cannot transition channel catalog push from ${current.state} to ${toState}.`,
3718
+ );
3719
+ return PluginErr(error.message, error.code);
3720
+ }
3721
+
3722
+ const updatedRows = await tx
3723
+ .update(channelCatalogPushes)
3724
+ .set({
3725
+ state: toState,
3726
+ updatedAt: new Date(),
3727
+ ...(payloadSnapshot !== undefined ? { payloadSnapshot } : {}),
3728
+ ...(toState === "exported" ? { attempts: current.attempts + 1, lastError: null, failureKind: null } : {}),
3729
+ ...(toState === "failed" ? { lastError: reason ?? "Catalog push failed." } : {}),
3730
+ ...(toState === "failed" ? { failureKind: failureKind ?? "definitive" } : {}),
3731
+ ...(toState === "confirmed" ? { lastError: null, failureKind: null } : {}),
3732
+ })
3733
+ .where(and(
3734
+ eq(channelCatalogPushes.organizationId, orgId),
3735
+ eq(channelCatalogPushes.id, pushId),
3736
+ eq(channelCatalogPushes.state, current.state),
3737
+ ))
3738
+ .returning();
3739
+ const updated = updatedRows[0] as ChannelCatalogPush | undefined;
3740
+ if (!updated) return PluginErr("Channel catalog push changed concurrently.", "CONFLICT");
3741
+
3742
+ await tx.insert(channelCatalogPushEvents).values({
3743
+ organizationId: orgId,
3744
+ pushId,
3745
+ fromState: current.state,
3746
+ toState,
3747
+ reason: reason ?? null,
3748
+ changedBy,
3749
+ });
3750
+ return Ok(updated);
3751
+ });
3752
+ }
3753
+
3754
+ private async recordCatalogPushRevisions(
3755
+ orgId: string,
3756
+ entityIds: string[],
3757
+ actor: Actor,
3758
+ ): Promise<PluginResult<void>> {
3759
+ if (entityIds.length === 0) return Ok(undefined);
3760
+ try {
3761
+ await this.transact(async (tx) => {
3762
+ const txContext = createTxContext(tx, { actor });
3763
+ for (const entityId of [...new Set(entityIds)]) {
3764
+ const revision = await this.catalog.recordEntityRevision(entityId, actor, "push", txContext);
3765
+ if (!revision.ok) throw new Error(revision.error.message);
3766
+ }
3767
+ });
3768
+ } catch (error) {
3769
+ return PluginErr(error instanceof Error ? error.message : "Failed to record catalog push revisions.");
3770
+ }
3771
+ return Ok(undefined);
3772
+ }
3773
+
3774
+ async executeCatalogPushJob(
3775
+ orgId: string,
3776
+ storeId: string,
3777
+ options: { entityIds?: string[]; cursor?: string; forceFieldPaths?: Record<string, FieldPath[]> },
3778
+ actor: Actor,
3779
+ runtime: { jobs: JobsAdapter },
3780
+ ): Promise<PluginResult<CatalogPushJobResult>> {
3781
+ const store = await this.getStoreRecord(orgId, storeId);
3782
+ if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
3783
+ if (!store.catalogWriteEnabled) return Ok({ noop: true });
3784
+ const connector = this.connectors.get(store.provider);
3785
+ if (!connector?.pushCatalog) return Ok({ noop: true });
3786
+ if (isCatalogPushBreakerOpen(store.breakerState)) {
3787
+ await runtime.jobs.enqueue("channel/push-catalog", {
3788
+ organizationId: orgId,
3789
+ storeId,
3790
+ ...(options.entityIds ? { entityIds: options.entityIds } : {}),
3791
+ ...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
3792
+ ...(options.cursor ? { cursor: options.cursor } : {}),
3793
+ }, {
3794
+ organizationId: orgId,
3795
+ concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
3796
+ supersedes: false,
3797
+ delayMs: CATALOG_PUSH_BREAKER_RETRY_MS,
3798
+ });
3799
+ return Ok({ rescheduled: true });
3800
+ }
3801
+
3802
+ const allEntityIds = await this.resolveCatalogPushEntityIds(orgId, storeId, options.entityIds);
3803
+ const batchSize = catalogPushBatchSize(store.provider);
3804
+ const pageEntityIds = options.cursor
3805
+ ? allEntityIds.filter((entityId) => entityId > options.cursor!).slice(0, batchSize)
3806
+ : allEntityIds.slice(0, batchSize);
3807
+ if (pageEntityIds.length === 0) return Ok({ complete: true, pushed: 0, failed: 0 });
3808
+
3809
+ // Abandoned is terminal: a row that exhausted its attempts stays out of
3810
+ // every later sweep until an operator re-arms it.
3811
+ const abandonedRows = await this.db.select({ entityId: channelCatalogPushes.entityId }).from(channelCatalogPushes).where(and(
3812
+ eq(channelCatalogPushes.organizationId, orgId),
3813
+ eq(channelCatalogPushes.storeId, storeId),
3814
+ eq(channelCatalogPushes.state, "abandoned"),
3815
+ inArray(channelCatalogPushes.entityId, pageEntityIds),
3816
+ ));
3817
+ const abandonedEntityIds = new Set(abandonedRows.map((row) => row.entityId));
3818
+ const batchEntityIds = pageEntityIds.filter((entityId) => !abandonedEntityIds.has(entityId));
3819
+ if (batchEntityIds.length === 0) {
3820
+ const batchCursor = pageEntityIds[pageEntityIds.length - 1]!;
3821
+ const hasMore = allEntityIds.some((entityId) => entityId > batchCursor);
3822
+ if (!hasMore) return Ok({ complete: true, pushed: 0, failed: 0 });
3823
+ await runtime.jobs.enqueue("channel/push-catalog", {
3824
+ organizationId: orgId,
3825
+ storeId,
3826
+ ...(options.entityIds ? { entityIds: options.entityIds } : {}),
3827
+ ...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
3828
+ cursor: batchCursor,
3829
+ }, {
3830
+ organizationId: orgId,
3831
+ concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
3832
+ supersedes: false,
3833
+ });
3834
+ return Ok({ complete: false, cursor: batchCursor, pushed: 0, failed: 0 });
3835
+ }
3836
+
3837
+ const assembled = await this.buildCatalogPushItems(orgId, storeId, batchEntityIds, {
3838
+ ...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
3839
+ });
3840
+ if (!assembled.ok) return assembled;
3841
+
3842
+ const mappings = await this.db.select({
3843
+ entityId: channelEntityMap.entityId,
3844
+ externalId: channelEntityMap.externalId,
3845
+ }).from(channelEntityMap).where(and(
3846
+ eq(channelEntityMap.organizationId, orgId),
3847
+ eq(channelEntityMap.storeId, storeId),
3848
+ eq(channelEntityMap.kind, "entity"),
3849
+ inArray(channelEntityMap.entityId, batchEntityIds),
3850
+ ));
3851
+ const externalByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping.externalId]));
3852
+ const entityByExternal = new Map(mappings.map((mapping) => [mapping.externalId, mapping.entityId]));
3853
+ const itemByExternal = new Map(assembled.value.items.map((item) => [item.externalId, item]));
3854
+
3855
+ let pushed = 0;
3856
+ let failed = 0;
3857
+
3858
+ if (assembled.value.items.length === 0) {
3859
+ for (const entityId of batchEntityIds) {
3860
+ const created = await this.createCatalogPush(orgId, storeId, entityId);
3861
+ if (!created.ok) return created;
3862
+ if (created.value.state === "confirmed" || created.value.state === "abandoned") {
3863
+ if (created.value.state === "confirmed") pushed += 1;
3864
+ continue;
3865
+ }
3866
+ const confirmed = await this.transitionCatalogPush(
3867
+ orgId,
3868
+ created.value.id,
3869
+ "confirmed",
3870
+ requireUserId(actor),
3871
+ "No platform-owned fields to push.",
3872
+ undefined,
3873
+ null,
3874
+ );
3875
+ if (!confirmed.ok) return confirmed;
3876
+ pushed += 1;
3877
+ }
3878
+ } else {
3879
+ const pushIds = new Map<string, string>();
3880
+ const pushAttempts = new Map<string, number>();
3881
+ for (const entityId of batchEntityIds) {
3882
+ const externalId = externalByEntity.get(entityId);
3883
+ const item = externalId ? itemByExternal.get(externalId) : undefined;
3884
+ if (!item) continue;
3885
+ const created = await this.createCatalogPush(orgId, storeId, entityId);
3886
+ if (!created.ok) return created;
3887
+ pushIds.set(item.externalId, created.value.id);
3888
+ pushAttempts.set(item.externalId, created.value.attempts);
3889
+ if (created.value.state === "pending" || created.value.state === "confirmed" || created.value.state === "failed") {
3890
+ const exported = await this.transitionCatalogPush(
3891
+ orgId,
3892
+ created.value.id,
3893
+ "exported",
3894
+ requireUserId(actor),
3895
+ "Catalog push attempt started.",
3896
+ undefined,
3897
+ item,
3898
+ );
3899
+ if (!exported.ok) return exported;
3900
+ pushAttempts.set(item.externalId, exported.value.attempts);
3901
+ }
3902
+ }
3903
+
3904
+ const items = assembled.value.items;
3905
+ const writeAhead = await this.recordOutboundPush(
3906
+ orgId,
3907
+ storeId,
3908
+ items.map((item) => ({ externalId: item.externalId, ok: true })),
3909
+ items,
3910
+ "write-ahead",
3911
+ );
3912
+ if (!writeAhead.ok) return writeAhead;
3913
+
3914
+ let result: Awaited<ReturnType<NonNullable<typeof connector.pushCatalog>>>;
3915
+ try {
3916
+ result = await connector.pushCatalog(store as ChannelStore, items);
3917
+ } catch (error) {
3918
+ const connectorError = {
3919
+ code: "CATALOG_PUSH_THROWN",
3920
+ message: error instanceof Error ? error.message : "Catalog push failed.",
3921
+ };
3922
+ const cleared = await this.recordOutboundPush(
3923
+ orgId,
3924
+ storeId,
3925
+ items.map((item) => ({ externalId: item.externalId, ok: false, error: connectorError })),
3926
+ items,
3927
+ );
3928
+ if (!cleared.ok) return cleared;
3929
+ for (const item of items) {
3930
+ const pushId = pushIds.get(item.externalId);
3931
+ if (!pushId) continue;
3932
+ const attempts = pushAttempts.get(item.externalId) ?? 0;
3933
+ if (attempts >= CATALOG_PUSH_MAX_ATTEMPTS) {
3934
+ await this.transitionCatalogPush(
3935
+ orgId,
3936
+ pushId,
3937
+ "abandoned",
3938
+ requireUserId(actor),
3939
+ connectorError.message,
3940
+ );
3941
+ } else {
3942
+ await this.transitionCatalogPush(
3943
+ orgId,
3944
+ pushId,
3945
+ "failed",
3946
+ requireUserId(actor),
3947
+ connectorError.message,
3948
+ "transient",
3949
+ );
3950
+ }
3951
+ failed += 1;
3952
+ }
3953
+ const maxAttempts = Math.max(0, ...items.map((item) => pushAttempts.get(item.externalId) ?? 0));
3954
+ if (maxAttempts < CATALOG_PUSH_MAX_ATTEMPTS) {
3955
+ await runtime.jobs.enqueue("channel/push-catalog", {
3956
+ organizationId: orgId,
3957
+ storeId,
3958
+ ...(options.entityIds ? { entityIds: options.entityIds } : {}),
3959
+ ...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
3960
+ ...(options.cursor ? { cursor: options.cursor } : {}),
3961
+ }, {
3962
+ organizationId: orgId,
3963
+ concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
3964
+ supersedes: false,
3965
+ delayMs: catalogPushRetryDelayMs(maxAttempts),
3966
+ });
3967
+ }
3968
+ return Ok({ rescheduled: true, pushed, failed });
3969
+ }
3970
+
3971
+ if (!result.ok) {
3972
+ const cleared = await this.recordOutboundPush(
3973
+ orgId,
3974
+ storeId,
3975
+ items.map((item) => ({ externalId: item.externalId, ok: false, error: result.error })),
3976
+ items,
3977
+ );
3978
+ if (!cleared.ok) return cleared;
3979
+ for (const item of items) {
3980
+ const pushId = pushIds.get(item.externalId);
3981
+ if (!pushId) continue;
3982
+ const attempts = pushAttempts.get(item.externalId) ?? 0;
3983
+ const failureKind = result.error.retriable === true ? "transient" as const : "definitive" as const;
3984
+ if (failureKind === "transient" && attempts >= CATALOG_PUSH_MAX_ATTEMPTS) {
3985
+ await this.transitionCatalogPush(
3986
+ orgId,
3987
+ pushId,
3988
+ "abandoned",
3989
+ requireUserId(actor),
3990
+ result.error.message,
3991
+ );
3992
+ } else {
3993
+ await this.transitionCatalogPush(
3994
+ orgId,
3995
+ pushId,
3996
+ "failed",
3997
+ requireUserId(actor),
3998
+ result.error.message,
3999
+ failureKind,
4000
+ );
4001
+ }
4002
+ failed += 1;
4003
+ }
4004
+ if (result.error.retriable === true) {
4005
+ const maxAttempts = Math.max(0, ...items.map((item) => pushAttempts.get(item.externalId) ?? 0));
4006
+ if (maxAttempts < CATALOG_PUSH_MAX_ATTEMPTS) {
4007
+ await runtime.jobs.enqueue("channel/push-catalog", {
4008
+ organizationId: orgId,
4009
+ storeId,
4010
+ ...(options.entityIds ? { entityIds: options.entityIds } : {}),
4011
+ ...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
4012
+ ...(options.cursor ? { cursor: options.cursor } : {}),
4013
+ }, {
4014
+ organizationId: orgId,
4015
+ concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
4016
+ supersedes: false,
4017
+ delayMs: catalogPushRetryDelayMs(maxAttempts),
4018
+ });
4019
+ }
4020
+ return Ok({ rescheduled: true, pushed, failed });
4021
+ }
4022
+ const batchCursor = pageEntityIds[pageEntityIds.length - 1]!;
4023
+ const hasMore = allEntityIds.some((entityId) => entityId > batchCursor);
4024
+ return Ok({ complete: !hasMore, pushed, failed });
4025
+ }
4026
+
4027
+ const recorded = await this.recordOutboundPush(orgId, storeId, result.value.outcomes, items);
4028
+ if (!recorded.ok) return recorded;
4029
+
4030
+ const successfulEntityIds: string[] = [];
4031
+ for (const outcome of result.value.outcomes) {
4032
+ const pushId = pushIds.get(outcome.externalId);
4033
+ const entityId = entityByExternal.get(outcome.externalId);
4034
+ if (!pushId || !entityId) continue;
4035
+ const item = itemByExternal.get(outcome.externalId);
4036
+ if (outcome.ok) {
4037
+ const confirmed = await this.transitionCatalogPush(
4038
+ orgId,
4039
+ pushId,
4040
+ "confirmed",
4041
+ requireUserId(actor),
4042
+ "Remote catalog confirmed item.",
4043
+ undefined,
4044
+ item ?? null,
4045
+ );
4046
+ if (!confirmed.ok) return confirmed;
4047
+ successfulEntityIds.push(entityId);
4048
+ pushed += 1;
4049
+ continue;
4050
+ }
4051
+ const failureKind = outcome.error?.retriable === true ? "transient" : "definitive";
4052
+ const attempts = pushAttempts.get(outcome.externalId) ?? 0;
4053
+ if (failureKind === "transient" && attempts >= CATALOG_PUSH_MAX_ATTEMPTS) {
4054
+ const abandoned = await this.transitionCatalogPush(
4055
+ orgId,
4056
+ pushId,
4057
+ "abandoned",
4058
+ requireUserId(actor),
4059
+ outcome.error?.message ?? "Catalog push failed.",
4060
+ undefined,
4061
+ item ?? null,
4062
+ );
4063
+ if (!abandoned.ok) return abandoned;
4064
+ } else {
4065
+ const failedPush = await this.transitionCatalogPush(
4066
+ orgId,
4067
+ pushId,
4068
+ "failed",
4069
+ requireUserId(actor),
4070
+ outcome.error?.message ?? "Catalog push failed.",
4071
+ failureKind,
4072
+ item ?? null,
4073
+ );
4074
+ if (!failedPush.ok) return failedPush;
4075
+ if (failureKind === "transient") {
4076
+ await runtime.jobs.enqueue("channel/push-catalog", {
4077
+ organizationId: orgId,
4078
+ storeId,
4079
+ entityIds: [entityId],
4080
+ ...(options.forceFieldPaths?.[entityId]
4081
+ ? { forceFieldPaths: { [entityId]: options.forceFieldPaths[entityId] } }
4082
+ : {}),
4083
+ }, {
4084
+ organizationId: orgId,
4085
+ concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: [entityId] }),
4086
+ supersedes: true,
4087
+ delayMs: catalogPushRetryDelayMs(attempts),
4088
+ });
4089
+ }
4090
+ }
4091
+ failed += 1;
4092
+ }
4093
+
4094
+ const revisions = await this.recordCatalogPushRevisions(orgId, successfulEntityIds, actor);
4095
+ if (!revisions.ok) return revisions;
4096
+ }
4097
+
4098
+ const batchCursor = pageEntityIds[pageEntityIds.length - 1]!;
4099
+ const hasMore = allEntityIds.some((entityId) => entityId > batchCursor);
4100
+ if (hasMore) {
4101
+ await runtime.jobs.enqueue("channel/push-catalog", {
4102
+ organizationId: orgId,
4103
+ storeId,
4104
+ ...(options.entityIds ? { entityIds: options.entityIds } : {}),
4105
+ ...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
4106
+ cursor: batchCursor,
4107
+ }, {
4108
+ organizationId: orgId,
4109
+ concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
4110
+ supersedes: false,
4111
+ });
4112
+ return Ok({ complete: false, cursor: batchCursor, pushed, failed });
4113
+ }
4114
+
4115
+ return Ok({ complete: true, pushed, failed });
4116
+ }
2749
4117
  }