@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/dist/service.js CHANGED
@@ -1,10 +1,53 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, } from "@porulle/core";
3
- import { isValidFieldPath } from "@porulle/core";
4
- import { and, eq, inArray, isNull } from "@porulle/core/drizzle";
5
- import { brands, categories, customerAddresses, customers, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
6
- import { channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
3
+ import { isValidFieldPath, requireUserId } from "@porulle/core";
4
+ import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
5
+ import { and, desc, eq, inArray, isNull, lte } from "@porulle/core/drizzle";
6
+ import { brands, categories, customerAddresses, customers, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
7
+ import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
7
8
  import { mergeCatalogFieldMapping, normalizeCatalogFieldMapping, selectCatalogFieldMapping, } from "./catalog-field-mapping.js";
9
+ export const CATALOG_PUSH_BATCH_SIZES = {
10
+ mock: 100,
11
+ shopify: 50,
12
+ woocommerce: 100,
13
+ };
14
+ const DEFAULT_CATALOG_PUSH_BATCH_SIZE = 50;
15
+ const CATALOG_PUSH_BREAKER_RETRY_MS = 60_000;
16
+ export const CATALOG_PUSH_MAX_ATTEMPTS = 8;
17
+ const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
18
+ const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
19
+ export function catalogPushRetryDelayMs(attempts) {
20
+ const exponent = Math.max(0, attempts - 1);
21
+ return Math.min(CATALOG_PUSH_RETRY_BASE_MS * (2 ** exponent), CATALOG_PUSH_RETRY_MAX_MS);
22
+ }
23
+ export function catalogPushConcurrencyKey(input) {
24
+ const storeId = String(input.storeId);
25
+ const entityIds = input.entityIds;
26
+ if (Array.isArray(entityIds) && entityIds.length === 1) {
27
+ return `push:${String(entityIds[0])}:${storeId}`;
28
+ }
29
+ return `push-catalog:${storeId}`;
30
+ }
31
+ export function isCatalogPushBreakerOpen(breakerState) {
32
+ if (breakerState.open === true) {
33
+ const openUntil = breakerState.openUntil;
34
+ if (typeof openUntil === "string" && new Date(openUntil) <= new Date())
35
+ return false;
36
+ return true;
37
+ }
38
+ const catalogPush = breakerState.catalogPush;
39
+ if (!catalogPush || typeof catalogPush !== "object")
40
+ return false;
41
+ const state = catalogPush;
42
+ if (state.open !== true)
43
+ return false;
44
+ if (typeof state.openUntil === "string" && new Date(state.openUntil) <= new Date())
45
+ return false;
46
+ return true;
47
+ }
48
+ function catalogPushBatchSize(provider) {
49
+ return CATALOG_PUSH_BATCH_SIZES[provider] ?? DEFAULT_CATALOG_PUSH_BATCH_SIZE;
50
+ }
8
51
  const exportTransitions = {
9
52
  pending: ["exported", "abandoned"],
10
53
  exported: ["confirmed", "failed", "abandoned"],
@@ -15,9 +58,107 @@ const exportTransitions = {
15
58
  export function canExportTransition(from, to) {
16
59
  return exportTransitions[from].includes(to);
17
60
  }
61
+ // Catalog pushes recur; confirmed/failed rows re-arm through exported, and rows with nothing to push resolve directly.
62
+ const catalogPushTransitions = {
63
+ pending: ["exported", "confirmed", "abandoned"],
64
+ exported: ["confirmed", "failed", "abandoned"],
65
+ confirmed: ["exported", "abandoned"],
66
+ failed: ["exported", "confirmed", "abandoned"],
67
+ abandoned: [],
68
+ };
69
+ export function canCatalogPushTransition(from, to) {
70
+ return catalogPushTransitions[from].includes(to);
71
+ }
18
72
  function hash(value) {
19
73
  return createHash("sha256").update(JSON.stringify(value)).digest("hex");
20
74
  }
75
+ export const CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS = 15 * 60 * 1000;
76
+ function normalizeCanonicalValue(value) {
77
+ if (typeof value === "string")
78
+ return value.replace(/\r\n?/g, "\n").replace(/\s+/g, " ").trim();
79
+ if (Array.isArray(value))
80
+ return value.map(normalizeCanonicalValue).sort((left, right) => (JSON.stringify(left) ?? "").localeCompare(JSON.stringify(right) ?? ""));
81
+ if (value && typeof value === "object") {
82
+ return Object.fromEntries(Object.entries(value)
83
+ .sort(([left], [right]) => left.localeCompare(right))
84
+ .map(([key, nested]) => [key, normalizeCanonicalValue(nested)]));
85
+ }
86
+ return value;
87
+ }
88
+ function normalizedValuesEqual(left, right) {
89
+ return JSON.stringify(normalizeCanonicalValue(left)) === JSON.stringify(normalizeCanonicalValue(right));
90
+ }
91
+ function snapshotCustomFieldValue(field) {
92
+ if (field.textValue !== null && field.textValue !== undefined)
93
+ return field.textValue;
94
+ if (field.numberValue !== null && field.numberValue !== undefined)
95
+ return field.numberValue;
96
+ if (field.booleanValue !== null && field.booleanValue !== undefined)
97
+ return field.booleanValue;
98
+ if (field.dateValue !== null && field.dateValue !== undefined)
99
+ return field.dateValue;
100
+ return field.jsonValue;
101
+ }
102
+ function snapshotFieldValue(snapshot, path) {
103
+ const [root, segment, field] = path.split(".");
104
+ if (root === "entity" && segment === "slug")
105
+ return { found: true, value: snapshot.entity.slug };
106
+ if (root === "entity" && segment === "status")
107
+ return { found: true, value: snapshot.entity.status };
108
+ if (root === "entity" && segment === "metadata") {
109
+ const metadata = snapshot.entity.metadata;
110
+ return {
111
+ found: true,
112
+ value: metadata && typeof metadata === "object" ? metadata[field ?? ""] : undefined,
113
+ };
114
+ }
115
+ if (root === "attributes" && segment && field) {
116
+ const attribute = snapshot.attributes.find((row) => row.locale === segment);
117
+ return { found: true, value: attribute?.[field] };
118
+ }
119
+ if (root === "customFields" && segment && field) {
120
+ const customField = snapshot.customFields.find((row) => row.fieldName === segment && row.locale === field && row.status === "approved");
121
+ return { found: true, value: customField ? snapshotCustomFieldValue(customField) : undefined };
122
+ }
123
+ if (root === "media" && segment) {
124
+ return {
125
+ found: true,
126
+ value: snapshot.media.filter((row) => row.role === segment).map((row) => row.mediaAssetId),
127
+ };
128
+ }
129
+ return { found: false, value: undefined };
130
+ }
131
+ function canonicalHash(externalId, fieldPaths, valueAtPath) {
132
+ const fields = fieldPaths.flatMap((fieldPath) => {
133
+ const value = valueAtPath(fieldPath);
134
+ return value === undefined ? [] : [{ fieldPath, value: normalizeCanonicalValue(value) }];
135
+ });
136
+ return hash({
137
+ externalId,
138
+ fields: fields.sort((left, right) => left.fieldPath.localeCompare(right.fieldPath)),
139
+ });
140
+ }
141
+ function outboundFieldPaths(item) {
142
+ const paths = new Set(item.fields.flatMap((field) => isValidFieldPath(field.fieldPath) ? [field.fieldPath] : []));
143
+ for (const image of item.images ?? [])
144
+ paths.add(`media.${image.role}`);
145
+ return [...paths].sort();
146
+ }
147
+ function pushFieldValue(item, fieldPath) {
148
+ if (fieldPath.startsWith("media.")) {
149
+ const role = fieldPath.slice("media.".length);
150
+ return (item.images ?? [])
151
+ .filter((image) => image.role === role)
152
+ .map((image) => ({ url: image.url, role: image.role }));
153
+ }
154
+ return item.fields.find((field) => field.fieldPath === fieldPath)?.value;
155
+ }
156
+ function canonicalOutboundHash(externalId, item, fieldPaths) {
157
+ return canonicalHash(externalId, fieldPaths, (fieldPath) => pushFieldValue(item, fieldPath));
158
+ }
159
+ function canonicalInboundHash(externalId, fieldPaths, remoteFieldValue) {
160
+ return canonicalHash(externalId, fieldPaths, remoteFieldValue);
161
+ }
21
162
  function mergeMetadata(existing, remote) {
22
163
  return { ...(existing ?? {}), ...remote };
23
164
  }
@@ -61,6 +202,7 @@ function pushCatalogField(fieldPath, value, mapping) {
61
202
  value,
62
203
  ...(locale !== undefined ? { locale } : {}),
63
204
  remoteKey: mapping.remoteKey,
205
+ target: mapping.target,
64
206
  };
65
207
  }
66
208
  function pushCatalogImageRole(value) {
@@ -84,6 +226,16 @@ function importedFieldPaths(item) {
84
226
  paths.add(`attributes.${attribute.locale}.${field}`);
85
227
  }
86
228
  }
229
+ const customFields = item.customFields;
230
+ for (const [name, locales] of Object.entries(customFields ?? {})) {
231
+ if (!locales || typeof locales !== "object" || Array.isArray(locales))
232
+ continue;
233
+ for (const locale of Object.keys(locales)) {
234
+ const path = `customFields.${name}.${locale}`;
235
+ if (isValidFieldPath(path))
236
+ paths.add(path);
237
+ }
238
+ }
87
239
  for (const image of item.images ?? [])
88
240
  paths.add(`media.${image.role}`);
89
241
  if (item.options?.length)
@@ -271,6 +423,13 @@ export class ChannelConnectorService {
271
423
  return item.status;
272
424
  if (root === "entity" && segment === "metadata")
273
425
  return item.metadata?.[field ?? ""];
426
+ if (root === "customFields" && segment && field) {
427
+ const customFields = item.customFields;
428
+ const customField = customFields?.[segment];
429
+ if (customField && typeof customField === "object" && !Array.isArray(customField)) {
430
+ return customField[field];
431
+ }
432
+ }
274
433
  if (root === "attributes" && segment && field) {
275
434
  const attributes = item.attributes?.length
276
435
  ? item.attributes
@@ -278,8 +437,11 @@ export class ChannelConnectorService {
278
437
  const attribute = attributes.find((row) => row.locale === segment);
279
438
  return attribute?.[field];
280
439
  }
281
- if (root === "media" && segment)
282
- return (item.images ?? []).filter((image) => image.role === segment).map((image) => image.externalId ?? image.url);
440
+ if (root === "media" && segment) {
441
+ return (item.images ?? [])
442
+ .filter((image) => image.role === segment)
443
+ .map((image) => ({ url: image.url, role: image.role }));
444
+ }
283
445
  if (path === "options")
284
446
  return item.options;
285
447
  if (path === "variants.sku")
@@ -290,6 +452,15 @@ export class ChannelConnectorService {
290
452
  return item.variants.flatMap((variant) => variant.prices ?? []).filter((price) => price.currency === segment);
291
453
  return undefined;
292
454
  }
455
+ isOutboundEcho(mapping, item) {
456
+ if (!mapping.outboundHash || !mapping.outboundPushedAt || mapping.outboundFieldPaths.length === 0)
457
+ return false;
458
+ const age = Date.now() - mapping.outboundPushedAt.getTime();
459
+ if (age < 0 || age > CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS)
460
+ return false;
461
+ const inboundHash = canonicalInboundHash(mapping.externalId, mapping.outboundFieldPaths, (fieldPath) => this.remoteFieldValue(item, fieldPath));
462
+ return inboundHash === mapping.outboundHash;
463
+ }
293
464
  async localFieldValue(entityId, entity, path) {
294
465
  const [root, segment, field] = path.split(".");
295
466
  if (root === "entity" && segment === "slug")
@@ -312,6 +483,10 @@ export class ChannelConnectorService {
312
483
  : {};
313
484
  return values[field];
314
485
  }
486
+ if (root === "customFields" && segment && field) {
487
+ const [customField] = await this.db.select().from(sellableCustomFields).where(and(eq(sellableCustomFields.entityId, entityId), eq(sellableCustomFields.fieldName, segment), eq(sellableCustomFields.locale, field), eq(sellableCustomFields.status, "approved")));
488
+ return customField ? customFieldValue(customField) : undefined;
489
+ }
315
490
  if (root === "media" && segment) {
316
491
  const links = await this.db.select({ id: entityMedia.mediaAssetId }).from(entityMedia).where(and(eq(entityMedia.entityId, entityId), eq(entityMedia.role, segment)));
317
492
  return links.map((link) => link.id);
@@ -334,24 +509,105 @@ export class ChannelConnectorService {
334
509
  }
335
510
  return undefined;
336
511
  }
337
- async detectSharedConflicts(entityId, storeId, entity, mapping, item, owners, fieldPaths = importedFieldPaths(item), remoteHash = hash(item)) {
512
+ async lastSyncedSnapshot(entityId, lastSyncedAt) {
513
+ const [revision] = await this.db.select({ snapshot: sellableEntityRevisions.snapshot }).from(sellableEntityRevisions).where(and(eq(sellableEntityRevisions.entityId, entityId), lte(sellableEntityRevisions.createdAt, lastSyncedAt))).orderBy(desc(sellableEntityRevisions.createdAt)).limit(1);
514
+ return revision?.snapshot;
515
+ }
516
+ async detectSharedConflicts(entityId, storeId, entity, mapping, item, owners, fieldPaths = importedFieldPaths(item), remoteHash = hash(item), echo) {
338
517
  if (!mapping || mapping.syncHash === remoteHash)
339
518
  return { paths: [], conflicts: [] };
340
519
  const revisions = await this.catalog.repository.findRevisionMarkers(entityId, mapping.lastSyncedAt);
341
520
  const localChanged = revisions.some((revision) => revision.reason !== "import");
342
- if (!localChanged)
343
- return { paths: [], conflicts: [] };
344
521
  const paths = fieldPaths.filter((path) => owners.get(path) === "shared");
345
- const conflicts = await Promise.all(paths.map(async (fieldPath) => ({
346
- entityId,
347
- storeId,
348
- fieldPath,
349
- localValueSummary: summarizeValue(await this.localFieldValue(entityId, entity, fieldPath)),
350
- remoteValueSummary: summarizeValue(this.remoteFieldValue(item, fieldPath)),
351
- })));
352
- return { paths, conflicts };
353
- }
354
- async setCatalogAttributes(entityId, item, actor, blockedPaths = new Set()) {
522
+ const openRows = await this.db.select({
523
+ fieldPath: channelCatalogConflicts.fieldPath,
524
+ storeValue: channelCatalogConflicts.storeValue,
525
+ }).from(channelCatalogConflicts).where(and(eq(channelCatalogConflicts.storeId, storeId), eq(channelCatalogConflicts.entityId, entityId), eq(channelCatalogConflicts.state, "open")));
526
+ if (!localChanged && openRows.length === 0)
527
+ return { paths: [], conflicts: [] };
528
+ const openByPath = new Map(openRows.map((row) => [row.fieldPath, row.storeValue]));
529
+ const baseline = await this.lastSyncedSnapshot(entityId, mapping.lastSyncedAt);
530
+ const changed = [];
531
+ for (const path of paths) {
532
+ const localValue = await this.localFieldValue(entityId, entity, path);
533
+ const remoteValue = this.remoteFieldValue(item, path);
534
+ let diverged = false;
535
+ if (echo) {
536
+ // The outbound hash certifies only the pushed paths; a shared path
537
+ // outside that set carrying a genuinely different remote value is a
538
+ // real store edit even inside an echo payload.
539
+ diverged = !echo.certifiedPaths.has(path) && !normalizedValuesEqual(remoteValue, localValue);
540
+ }
541
+ else if (openByPath.has(path)) {
542
+ diverged = !normalizedValuesEqual(remoteValue, openByPath.get(path));
543
+ }
544
+ else if (baseline) {
545
+ const baselineValue = snapshotFieldValue(baseline, path);
546
+ diverged = baselineValue.found
547
+ && !normalizedValuesEqual(localValue, baselineValue.value)
548
+ && !normalizedValuesEqual(remoteValue, baselineValue.value)
549
+ && !normalizedValuesEqual(localValue, remoteValue);
550
+ }
551
+ else {
552
+ diverged = !normalizedValuesEqual(remoteValue, localValue);
553
+ }
554
+ if (diverged)
555
+ changed.push(path);
556
+ }
557
+ const conflicts = await Promise.all(changed.map(async (fieldPath) => {
558
+ const platformValue = await this.localFieldValue(entityId, entity, fieldPath);
559
+ const storeValue = this.remoteFieldValue(item, fieldPath);
560
+ return {
561
+ entityId,
562
+ storeId,
563
+ fieldPath,
564
+ platformValue: platformValue === undefined ? null : platformValue,
565
+ storeValue: storeValue === undefined ? null : storeValue,
566
+ localValueSummary: summarizeValue(platformValue),
567
+ remoteValueSummary: summarizeValue(storeValue),
568
+ };
569
+ }));
570
+ return { paths: changed, conflicts };
571
+ }
572
+ async persistCatalogConflicts(orgId, conflicts, changedBy) {
573
+ for (const conflict of conflicts) {
574
+ const [inserted] = await this.db.insert(channelCatalogConflicts).values({
575
+ organizationId: orgId,
576
+ storeId: conflict.storeId,
577
+ entityId: conflict.entityId,
578
+ fieldPath: conflict.fieldPath,
579
+ platformValue: conflict.platformValue,
580
+ storeValue: conflict.storeValue,
581
+ }).onConflictDoNothing().returning();
582
+ if (!inserted) {
583
+ const [existing] = await this.db.select({
584
+ id: channelCatalogConflicts.id,
585
+ storeValue: channelCatalogConflicts.storeValue,
586
+ platformValue: channelCatalogConflicts.platformValue,
587
+ }).from(channelCatalogConflicts).where(and(eq(channelCatalogConflicts.organizationId, orgId), eq(channelCatalogConflicts.storeId, conflict.storeId), eq(channelCatalogConflicts.entityId, conflict.entityId), eq(channelCatalogConflicts.fieldPath, conflict.fieldPath), eq(channelCatalogConflicts.state, "open")));
588
+ const storeMoved = existing !== undefined && !normalizedValuesEqual(existing.storeValue, conflict.storeValue);
589
+ const platformMoved = existing !== undefined && !normalizedValuesEqual(existing.platformValue, conflict.platformValue);
590
+ if (existing && (storeMoved || platformMoved)) {
591
+ await this.db.update(channelCatalogConflicts).set({
592
+ storeValue: conflict.storeValue,
593
+ platformValue: conflict.platformValue,
594
+ updatedAt: new Date(),
595
+ }).where(eq(channelCatalogConflicts.id, existing.id));
596
+ }
597
+ continue;
598
+ }
599
+ await this.db.insert(channelCatalogConflictEvents).values({
600
+ organizationId: orgId,
601
+ conflictId: inserted.id,
602
+ fromState: null,
603
+ toState: "open",
604
+ reason: "Shared catalog field changed on both sides.",
605
+ changedBy,
606
+ });
607
+ }
608
+ return Ok(undefined);
609
+ }
610
+ async setCatalogAttributes(entityId, item, actor, blockedPaths = new Set(), catalogCtx) {
355
611
  const attributes = item.attributes ?? [{
356
612
  locale: "en",
357
613
  title: item.title,
@@ -383,12 +639,23 @@ export class ChannelConnectorService {
383
639
  else if (attributeFields.some((field) => (current[field] == null ? null : current[field]) !== (writeAttribute[field] == null ? null : writeAttribute[field]))) {
384
640
  changed = true;
385
641
  }
386
- const result = await this.catalog.setAttributes(entityId, attribute.locale, writeAttribute, actor);
642
+ const result = await this.catalog.setAttributes(entityId, attribute.locale, writeAttribute, actor, catalogCtx);
387
643
  if (!result.ok)
388
644
  return PluginErr(result.error.message);
389
645
  }
390
646
  return Ok({ created, changed });
391
647
  }
648
+ async setCatalogAttributesIfWritable(entityId, item, actor, blockedPaths, catalogCtx) {
649
+ const attributes = item.attributes ?? [{
650
+ locale: "en",
651
+ title: item.title,
652
+ ...(item.description !== undefined ? { description: item.description } : {}),
653
+ }];
654
+ const writable = attributes.some((attribute) => attributeFields.some((field) => (attribute[field] !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.${field}`))));
655
+ if (!writable)
656
+ return Ok({ created: 0, changed: false });
657
+ return this.setCatalogAttributes(entityId, item, actor, blockedPaths, catalogCtx);
658
+ }
392
659
  async upsertOptionAxes(entityId, item, actor) {
393
660
  const optionValueIds = new Map();
394
661
  const existingTypes = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entityId));
@@ -747,17 +1014,45 @@ export class ChannelConnectorService {
747
1014
  }
748
1015
  const fieldMapping = this.resolveCatalogFieldMapping(store, filterableCustomFields, warnings);
749
1016
  const heldPaths = new Set(entityMapping.heldFieldPaths ?? []);
1017
+ const forcedPushPaths = new Set([
1018
+ ...(entityMapping.forcedPushFieldPaths ?? []),
1019
+ ...(options.forceFieldPaths?.[entity.id] ?? []),
1020
+ ]);
750
1021
  const fields = [];
751
1022
  const appendField = (fieldPath, value) => {
752
- if (value === undefined || owners.get(fieldPath) !== "platform")
1023
+ if (value === undefined)
1024
+ return;
1025
+ const owner = owners.get(fieldPath);
1026
+ const mapping = selectCatalogFieldMapping(fieldMapping, fieldPath);
1027
+ if (owner === "store") {
1028
+ skipped.push({
1029
+ entityId,
1030
+ fieldPath,
1031
+ reason: "store_owned",
1032
+ value,
1033
+ owner,
1034
+ ...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
1035
+ });
1036
+ return;
1037
+ }
1038
+ if (owner === undefined)
1039
+ return;
1040
+ const forced = forcedPushPaths.has(fieldPath);
1041
+ if (owner !== "platform" && !forced)
753
1042
  return;
754
1043
  if (heldPaths.has(fieldPath)) {
755
- skipped.push({ entityId, fieldPath, reason: "held" });
1044
+ skipped.push({
1045
+ entityId,
1046
+ fieldPath,
1047
+ reason: "held",
1048
+ value,
1049
+ owner,
1050
+ ...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
1051
+ });
756
1052
  return;
757
1053
  }
758
- const mapping = selectCatalogFieldMapping(fieldMapping, fieldPath);
759
1054
  if (!mapping) {
760
- skipped.push({ entityId, fieldPath, reason: "no_mapping" });
1055
+ skipped.push({ entityId, fieldPath, reason: "no_mapping", value, owner });
761
1056
  return;
762
1057
  }
763
1058
  fields.push(pushCatalogField(fieldPath, value, mapping));
@@ -786,17 +1081,44 @@ export class ChannelConnectorService {
786
1081
  if (!role)
787
1082
  continue;
788
1083
  const fieldPath = `media.${role}`;
789
- if (owners.get(fieldPath) !== "platform")
1084
+ const owner = owners.get(fieldPath);
1085
+ const mapping = selectCatalogFieldMapping(fieldMapping, fieldPath);
1086
+ const imageValue = [{ url: attached.url, role }];
1087
+ if (owner === "store") {
1088
+ skipped.push({
1089
+ entityId,
1090
+ fieldPath,
1091
+ reason: "store_owned",
1092
+ value: imageValue,
1093
+ owner,
1094
+ ...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
1095
+ });
1096
+ continue;
1097
+ }
1098
+ if (owner === undefined)
1099
+ continue;
1100
+ const forced = forcedPushPaths.has(fieldPath);
1101
+ if (owner !== "platform" && !forced)
790
1102
  continue;
791
1103
  if (heldPaths.has(fieldPath)) {
792
- skipped.push({ entityId, fieldPath, reason: "held" });
1104
+ skipped.push({
1105
+ entityId,
1106
+ fieldPath,
1107
+ reason: "held",
1108
+ value: imageValue,
1109
+ owner,
1110
+ ...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
1111
+ });
793
1112
  continue;
794
1113
  }
795
- if (!selectCatalogFieldMapping(fieldMapping, fieldPath)) {
796
- skipped.push({ entityId, fieldPath, reason: "no_mapping" });
1114
+ if (!mapping) {
1115
+ skipped.push({ entityId, fieldPath, reason: "no_mapping", value: imageValue, owner });
797
1116
  continue;
798
1117
  }
799
1118
  images.push({
1119
+ fieldPath,
1120
+ target: mapping.target,
1121
+ remoteKey: mapping.remoteKey,
800
1122
  url: attached.url,
801
1123
  role,
802
1124
  sortOrder: attached.sortOrder,
@@ -831,6 +1153,201 @@ export class ChannelConnectorService {
831
1153
  }
832
1154
  return Ok({ items, skipped, warnings: [...new Set(warnings)] });
833
1155
  }
1156
+ async recordOutboundPush(orgId, storeId, outcomes, items, phase = "settle") {
1157
+ const outcomeByExternalId = new Map(outcomes.map((outcome) => [outcome.externalId, outcome]));
1158
+ const now = new Date();
1159
+ for (const item of items) {
1160
+ const outcome = outcomeByExternalId.get(item.externalId);
1161
+ const mapping = await this.db.select({
1162
+ id: channelEntityMap.id,
1163
+ externalId: channelEntityMap.externalId,
1164
+ forcedPushFieldPaths: channelEntityMap.forcedPushFieldPaths,
1165
+ }).from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, item.externalId)));
1166
+ if (!mapping[0])
1167
+ continue;
1168
+ if (outcome?.ok === true) {
1169
+ const fieldPaths = outboundFieldPaths(item);
1170
+ await this.db.update(channelEntityMap).set({
1171
+ outboundHash: canonicalOutboundHash(mapping[0].externalId, item, fieldPaths),
1172
+ outboundPushedAt: now,
1173
+ outboundFieldPaths: fieldPaths,
1174
+ // A force is an operator's conflict resolution. The write-ahead runs
1175
+ // before the connector is called and its outcomes are optimistic, so
1176
+ // consuming the force there would discard the resolution on a failed
1177
+ // push and the retry would silently omit the field.
1178
+ ...(phase === "settle"
1179
+ ? { forcedPushFieldPaths: (mapping[0].forcedPushFieldPaths ?? []).filter((path) => !fieldPaths.includes(path)) }
1180
+ : {}),
1181
+ }).where(eq(channelEntityMap.id, mapping[0].id));
1182
+ }
1183
+ else {
1184
+ await this.db.update(channelEntityMap).set({
1185
+ outboundHash: null,
1186
+ outboundPushedAt: null,
1187
+ outboundFieldPaths: [],
1188
+ syncHash: "",
1189
+ }).where(eq(channelEntityMap.id, mapping[0].id));
1190
+ }
1191
+ }
1192
+ return Ok(undefined);
1193
+ }
1194
+ async pushCatalogToStore(orgId, storeId, entityIds) {
1195
+ const assembled = await this.buildCatalogPushItems(orgId, storeId, entityIds);
1196
+ if (!assembled.ok)
1197
+ return assembled;
1198
+ const store = await this.getStoreRecord(orgId, storeId);
1199
+ if (!store || store.status !== "connected")
1200
+ return PluginErr("Connected store not found.", "NOT_FOUND");
1201
+ const connector = this.connectors.get(store.provider);
1202
+ if (!connector?.pushCatalog)
1203
+ return PluginErr(`Catalog push is not supported by provider "${store.provider}".`);
1204
+ if (assembled.value.items.length === 0)
1205
+ return Ok({
1206
+ outcomes: [],
1207
+ skipped: assembled.value.skipped,
1208
+ warnings: assembled.value.warnings,
1209
+ });
1210
+ const writeAhead = await this.recordOutboundPush(orgId, storeId, assembled.value.items.map((item) => ({ externalId: item.externalId, ok: true })), assembled.value.items, "write-ahead");
1211
+ if (!writeAhead.ok)
1212
+ return writeAhead;
1213
+ let result;
1214
+ try {
1215
+ result = await connector.pushCatalog(store, assembled.value.items);
1216
+ }
1217
+ catch (error) {
1218
+ const connectorError = {
1219
+ code: "CATALOG_PUSH_THROWN",
1220
+ message: error instanceof Error ? error.message : "Catalog push failed.",
1221
+ };
1222
+ const cleared = await this.recordOutboundPush(orgId, storeId, assembled.value.items.map((item) => ({ externalId: item.externalId, ok: false, error: connectorError })), assembled.value.items);
1223
+ if (!cleared.ok)
1224
+ return cleared;
1225
+ return PluginErr(connectorError.message, connectorError.code);
1226
+ }
1227
+ if (!result.ok) {
1228
+ const cleared = await this.recordOutboundPush(orgId, storeId, assembled.value.items.map((item) => ({ externalId: item.externalId, ok: false, error: result.error })), assembled.value.items);
1229
+ if (!cleared.ok)
1230
+ return cleared;
1231
+ return PluginErr(result.error.message, result.error.code);
1232
+ }
1233
+ const recorded = await this.recordOutboundPush(orgId, storeId, result.value.outcomes, assembled.value.items);
1234
+ if (!recorded.ok)
1235
+ return recorded;
1236
+ const successfulEntityIds = assembled.value.items
1237
+ .filter((item) => result.value.outcomes.some((outcome) => outcome.externalId === item.externalId && outcome.ok))
1238
+ .map((item) => item.externalId);
1239
+ if (successfulEntityIds.length > 0) {
1240
+ const mappings = await this.db.select({ entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), inArray(channelEntityMap.externalId, successfulEntityIds)));
1241
+ const actor = createSystemActor(orgId);
1242
+ try {
1243
+ await this.transact(async (tx) => {
1244
+ const txContext = createTxContext(tx, { actor });
1245
+ for (const entityId of [...new Set(mappings.map((mapping) => mapping.entityId))]) {
1246
+ const revision = await this.catalog.recordEntityRevision(entityId, actor, "push", txContext);
1247
+ if (!revision.ok)
1248
+ throw new Error(revision.error.message);
1249
+ }
1250
+ });
1251
+ }
1252
+ catch (error) {
1253
+ return PluginErr(error instanceof Error ? error.message : "Failed to record catalog push revisions.");
1254
+ }
1255
+ }
1256
+ return Ok({
1257
+ ...result.value,
1258
+ skipped: assembled.value.skipped,
1259
+ warnings: assembled.value.warnings,
1260
+ });
1261
+ }
1262
+ async previewCatalogPush(orgId, storeId, entityIds) {
1263
+ const assembledEntityIds = await this.resolveCatalogPushEntityIds(orgId, storeId, entityIds);
1264
+ const assembled = await this.buildCatalogPushItems(orgId, storeId, assembledEntityIds);
1265
+ if (!assembled.ok)
1266
+ return assembled;
1267
+ const store = await this.getStoreRecord(orgId, storeId);
1268
+ if (!store || store.status !== "connected")
1269
+ return PluginErr("Connected store not found.", "NOT_FOUND");
1270
+ const connector = this.connectors.get(store.provider);
1271
+ if (!connector?.pushCatalog)
1272
+ return PluginErr(`Catalog push is not supported by provider "${store.provider}".`);
1273
+ if (assembled.value.items.length === 0) {
1274
+ return Ok({ items: [], skipped: assembled.value.skipped, warnings: assembled.value.warnings });
1275
+ }
1276
+ let result;
1277
+ try {
1278
+ result = await connector.pushCatalog(store, assembled.value.items, { dryRun: true });
1279
+ }
1280
+ catch (error) {
1281
+ return PluginErr(error instanceof Error ? error.message : "Catalog push preview failed.", "CATALOG_PREVIEW_THROWN");
1282
+ }
1283
+ if (!result.ok)
1284
+ return PluginErr(result.error.message, result.error.code);
1285
+ const failed = result.value.outcomes.find((outcome) => !outcome.ok);
1286
+ if (failed)
1287
+ return PluginErr(failed.error?.message ?? `Catalog push preview failed for item "${failed.externalId}".`, failed.error?.code ?? "CATALOG_PREVIEW_FAILED");
1288
+ const mappings = assembledEntityIds.length === 0
1289
+ ? []
1290
+ : await this.db.select({ entityId: channelEntityMap.entityId, externalId: channelEntityMap.externalId }).from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), inArray(channelEntityMap.entityId, assembledEntityIds)));
1291
+ const externalByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping.externalId]));
1292
+ const skippedByExternal = new Map();
1293
+ for (const skipped of assembled.value.skipped) {
1294
+ const externalId = externalByEntity.get(skipped.entityId);
1295
+ if (!externalId)
1296
+ continue;
1297
+ const existing = skippedByExternal.get(externalId) ?? [];
1298
+ existing.push(skipped);
1299
+ skippedByExternal.set(externalId, existing);
1300
+ }
1301
+ const outcomeByExternalId = new Map(result.value.outcomes.map((outcome) => [outcome.externalId, outcome]));
1302
+ const beforeFor = (externalId, fieldPath) => {
1303
+ const previousFields = outcomeByExternalId.get(externalId)?.previousFields;
1304
+ if (previousFields === undefined) {
1305
+ return { before: { status: "unavailable" }, beforeStatus: "unavailable" };
1306
+ }
1307
+ const previous = previousFields.find((field) => field.fieldPath === fieldPath);
1308
+ if (!previous)
1309
+ return { before: null, beforeStatus: "missing" };
1310
+ return { before: previous.value, beforeStatus: "value" };
1311
+ };
1312
+ const items = assembled.value.items.map((item) => {
1313
+ const diffs = item.fields.map((field) => ({
1314
+ fieldPath: field.fieldPath,
1315
+ target: field.target,
1316
+ remoteKey: field.remoteKey ?? null,
1317
+ ...beforeFor(item.externalId, field.fieldPath),
1318
+ after: field.value,
1319
+ owner: "platform",
1320
+ willWrite: true,
1321
+ }));
1322
+ for (const image of item.images ?? []) {
1323
+ diffs.push({
1324
+ fieldPath: image.fieldPath,
1325
+ target: image.target,
1326
+ remoteKey: image.remoteKey,
1327
+ ...beforeFor(item.externalId, image.fieldPath),
1328
+ after: pushFieldValue(item, image.fieldPath),
1329
+ owner: "platform",
1330
+ willWrite: true,
1331
+ });
1332
+ }
1333
+ for (const skipped of skippedByExternal.get(item.externalId) ?? []) {
1334
+ if (skipped.value === undefined || skipped.owner === undefined)
1335
+ continue;
1336
+ diffs.push({
1337
+ fieldPath: skipped.fieldPath,
1338
+ target: skipped.target ?? null,
1339
+ remoteKey: skipped.remoteKey ?? null,
1340
+ ...beforeFor(item.externalId, skipped.fieldPath),
1341
+ after: skipped.value,
1342
+ owner: skipped.owner,
1343
+ willWrite: false,
1344
+ reason: skipped.reason,
1345
+ });
1346
+ }
1347
+ return { externalId: item.externalId, diffs };
1348
+ });
1349
+ return Ok({ items, skipped: assembled.value.skipped, warnings: assembled.value.warnings });
1350
+ }
834
1351
  async getCatalogWriteSettings(orgId, storeId) {
835
1352
  const store = await this.getStoreRecord(orgId, storeId);
836
1353
  if (!store)
@@ -1074,7 +1591,7 @@ export class ChannelConnectorService {
1074
1591
  const promoted = await this.catalog.setAttributes(entity.id, "en", {
1075
1592
  title: metadata.title,
1076
1593
  ...(typeof metadata.description === "string" ? { description: metadata.description } : {}),
1077
- }, actor);
1594
+ }, actor, CHANNEL_CONVERGENCE_CTX);
1078
1595
  if (!promoted.ok)
1079
1596
  return PluginErr(promoted.error.message);
1080
1597
  const [confirmed] = await this.db.select({ id: sellableAttributes.id, title: sellableAttributes.title, description: sellableAttributes.description }).from(sellableAttributes).where(and(eq(sellableAttributes.entityId, entity.id), eq(sellableAttributes.locale, "en")));
@@ -1385,6 +1902,7 @@ export class ChannelConnectorService {
1385
1902
  for (const path of seedPaths)
1386
1903
  ownershipBeforeSeed.set(path, "store");
1387
1904
  const owners = ownershipBeforeSeed;
1905
+ const outboundEcho = entityMapping ? this.isOutboundEcho(entityMapping, item) : false;
1388
1906
  const remoteChanged = entityMapping === undefined || entityMapping.syncHash !== remoteHash;
1389
1907
  // An unchanged remote item writes nothing and advances no baseline:
1390
1908
  // converging a stale replay would revert local edits to shared and
@@ -1393,15 +1911,22 @@ export class ChannelConnectorService {
1393
1911
  continue;
1394
1912
  }
1395
1913
  const shared = existingEntity
1396
- ? await this.detectSharedConflicts(entityId, storeId, existingEntity, entityMapping, item, owners)
1914
+ ? await this.detectSharedConflicts(entityId, storeId, existingEntity, entityMapping, item, owners, importedFieldPaths(item), remoteHash, outboundEcho ? { certifiedPaths: new Set(entityMapping?.outboundFieldPaths ?? []) } : undefined)
1397
1915
  : { paths: [], conflicts: [] };
1916
+ const persistedConflicts = await this.persistCatalogConflicts(orgId, shared.conflicts, requireUserId(actor));
1917
+ if (!persistedConflicts.ok)
1918
+ return persistedConflicts;
1398
1919
  const owned = this.filterOwnedFields(item, owners);
1399
1920
  const heldSharedPaths = [...new Set([...(entityMapping?.heldFieldPaths ?? []), ...shared.paths])];
1921
+ // A newly held path revokes any force left from an earlier resolution of
1922
+ // that same path: the force was the operator's answer to a question that
1923
+ // has since been asked again, and it must not pre-empt the new one.
1924
+ const survivingForcedPaths = (entityMapping?.forcedPushFieldPaths ?? []).filter((path) => !heldSharedPaths.includes(path));
1400
1925
  const held = this.filterConflictingFields(owned.writable, heldSharedPaths);
1401
1926
  const writable = held.writable;
1402
1927
  const blockedPaths = new Set([...owned.skipped, ...heldSharedPaths]);
1403
1928
  skipped.push(...owned.skipped.map((fieldPath) => ({ entityId, fieldPath })));
1404
- conflicts.push(...shared.conflicts);
1929
+ conflicts.push(...shared.conflicts.map(({ platformValue: _platformValue, storeValue: _storeValue, ...conflict }) => conflict));
1405
1930
  for (const conflict of shared.conflicts) {
1406
1931
  warnings.push(`Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
1407
1932
  }
@@ -1426,7 +1951,7 @@ export class ChannelConnectorService {
1426
1951
  if (shouldUpdate) {
1427
1952
  converged += 1;
1428
1953
  if (Object.keys(updateInput).length > 0) {
1429
- const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor);
1954
+ const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
1430
1955
  if (!updated.ok)
1431
1956
  return PluginErr(updated.error.message);
1432
1957
  entityTouched = true;
@@ -1436,7 +1961,7 @@ export class ChannelConnectorService {
1436
1961
  const optionAxes = await this.upsertOptionAxes(entityId, writable, actor);
1437
1962
  if (!optionAxes.ok)
1438
1963
  return optionAxes;
1439
- const attributes = await this.setCatalogAttributes(entityId, writable, actor, blockedPaths);
1964
+ const attributes = await this.setCatalogAttributesIfWritable(entityId, writable, actor, blockedPaths, CHANNEL_CONVERGENCE_CTX);
1440
1965
  if (!attributes.ok)
1441
1966
  return attributes;
1442
1967
  const variantIds = await this.upsertVariants(orgId, storeId, entityId, writable, optionAxes.value.value, actor, warnings, !heldSharedPaths.includes("options") && owners.get("options") !== "platform", item);
@@ -1473,6 +1998,7 @@ export class ChannelConnectorService {
1473
1998
  syncHash: remoteHash,
1474
1999
  lastSyncedAt,
1475
2000
  heldFieldPaths: heldSharedPaths,
2001
+ forcedPushFieldPaths: survivingForcedPaths,
1476
2002
  });
1477
2003
  }
1478
2004
  else if (entityMapping) {
@@ -1480,6 +2006,7 @@ export class ChannelConnectorService {
1480
2006
  syncHash: remoteHash,
1481
2007
  lastSyncedAt,
1482
2008
  heldFieldPaths: heldSharedPaths,
2009
+ forcedPushFieldPaths: survivingForcedPaths,
1483
2010
  }).where(eq(channelEntityMap.id, entityMapping.id));
1484
2011
  }
1485
2012
  await this.db.update(channelEntityMap).set({ lastSyncedAt }).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.entityId, entityId), eq(channelEntityMap.kind, "variant")));
@@ -1560,11 +2087,13 @@ export class ChannelConnectorService {
1560
2087
  inventoryUpdated += 1;
1561
2088
  }
1562
2089
  const threshold = this.options.driftAlertThreshold ?? 25;
2090
+ const openConflictRows = await this.db.select({ id: channelCatalogConflicts.id }).from(channelCatalogConflicts).where(and(eq(channelCatalogConflicts.organizationId, orgId), eq(channelCatalogConflicts.storeId, storeId), eq(channelCatalogConflicts.state, "open")));
1563
2091
  const report = {
1564
2092
  imported: converged.value.imported,
1565
2093
  converged: converged.value.converged,
1566
2094
  archived,
1567
2095
  inventoryUpdated,
2096
+ openConflicts: openConflictRows.length,
1568
2097
  driftAlert: converged.value.imported + converged.value.converged + archived > threshold,
1569
2098
  ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
1570
2099
  ...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
@@ -1585,6 +2114,134 @@ export class ChannelConnectorService {
1585
2114
  const report = store.lastReconcileReport;
1586
2115
  return Ok({ lastReconcileAt: store.lastReconcileAt, report, driftAlert: report?.driftAlert ?? false });
1587
2116
  }
2117
+ async listCatalogConflicts(orgId, storeId, state = "open") {
2118
+ const conditions = [eq(channelCatalogConflicts.organizationId, orgId), eq(channelCatalogConflicts.state, state)];
2119
+ if (storeId !== undefined)
2120
+ conditions.push(eq(channelCatalogConflicts.storeId, storeId));
2121
+ return Ok(await this.db.select().from(channelCatalogConflicts).where(and(...conditions)));
2122
+ }
2123
+ async resolveCatalogConflict(orgId, id, choose, actor) {
2124
+ const [conflict] = await this.db.select().from(channelCatalogConflicts).where(and(eq(channelCatalogConflicts.organizationId, orgId), eq(channelCatalogConflicts.id, id), eq(channelCatalogConflicts.state, "open")));
2125
+ if (!conflict)
2126
+ return PluginErr("Catalog conflict not found or already resolved.", "NOT_FOUND");
2127
+ if (choose === "platform" && !this.jobs)
2128
+ return PluginErr("Jobs are not configured.", "JOBS_UNAVAILABLE");
2129
+ const [mapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, conflict.storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.entityId, conflict.entityId)));
2130
+ if (!mapping)
2131
+ return PluginErr("Catalog conflict mapping not found.", "NOT_FOUND");
2132
+ const systemActor = createSystemActor(orgId);
2133
+ let resolutionBaselineAt;
2134
+ if (choose === "store") {
2135
+ const applied = await this.applyStoreConflictValue(orgId, conflict, systemActor);
2136
+ if (!applied.ok)
2137
+ return PluginErr(applied.error, applied.code);
2138
+ const revisions = await this.catalog.repository.findRevisionMarkers(conflict.entityId);
2139
+ resolutionBaselineAt = revisions.at(-1)?.createdAt;
2140
+ }
2141
+ const heldFieldPaths = (mapping.heldFieldPaths ?? []).filter((path) => path !== conflict.fieldPath);
2142
+ const forcedPushFieldPaths = choose === "platform"
2143
+ ? [...new Set([...(mapping.forcedPushFieldPaths ?? []), conflict.fieldPath])]
2144
+ : mapping.forcedPushFieldPaths ?? [];
2145
+ const [resolved] = await this.db.update(channelCatalogConflicts).set({
2146
+ state: "resolved",
2147
+ resolvedBy: requireUserId(actor),
2148
+ updatedAt: new Date(),
2149
+ }).where(and(eq(channelCatalogConflicts.organizationId, orgId), eq(channelCatalogConflicts.id, id), eq(channelCatalogConflicts.state, "open"))).returning();
2150
+ if (!resolved)
2151
+ return PluginErr("Catalog conflict not found or already resolved.", "NOT_FOUND");
2152
+ await this.db.update(channelEntityMap).set({
2153
+ heldFieldPaths,
2154
+ forcedPushFieldPaths,
2155
+ ...(resolutionBaselineAt ? { lastSyncedAt: resolutionBaselineAt } : {}),
2156
+ }).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.id, mapping.id)));
2157
+ await this.db.insert(channelCatalogConflictEvents).values({
2158
+ organizationId: orgId,
2159
+ conflictId: conflict.id,
2160
+ fromState: "open",
2161
+ toState: "resolved",
2162
+ reason: `Operator chose the ${choose} value.`,
2163
+ changedBy: requireUserId(actor),
2164
+ });
2165
+ if (choose === "platform") {
2166
+ await this.jobs.enqueue("channel/push-catalog", {
2167
+ organizationId: orgId,
2168
+ storeId: conflict.storeId,
2169
+ entityIds: [conflict.entityId],
2170
+ }, {
2171
+ organizationId: orgId,
2172
+ concurrencyKey: catalogPushConcurrencyKey({ storeId: conflict.storeId, entityIds: [conflict.entityId] }),
2173
+ supersedes: true,
2174
+ });
2175
+ }
2176
+ return Ok(resolved);
2177
+ }
2178
+ async applyStoreConflictValue(orgId, conflict, actor) {
2179
+ const [root, segment, field] = conflict.fieldPath.split(".");
2180
+ if (root === "entity" && segment === "slug") {
2181
+ if (typeof conflict.storeValue !== "string")
2182
+ return PluginErr("The stored catalog value is not a valid slug.", "INVALID_CONFLICT_VALUE");
2183
+ const updated = await this.catalog.update(conflict.entityId, { slug: conflict.storeValue }, actor, CHANNEL_CONVERGENCE_CTX);
2184
+ return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
2185
+ }
2186
+ if (root === "entity" && segment === "status") {
2187
+ if (typeof conflict.storeValue !== "string")
2188
+ return PluginErr("The stored catalog value is not a valid status.", "INVALID_CONFLICT_VALUE");
2189
+ const status = ["draft", "active", "archived", "discontinued"].find((value) => value === conflict.storeValue);
2190
+ if (!status)
2191
+ return PluginErr("The stored catalog value is not a valid status.", "INVALID_CONFLICT_VALUE");
2192
+ const updated = await this.catalog.update(conflict.entityId, { status, isVisible: status === "active" }, actor, CHANNEL_CONVERGENCE_CTX);
2193
+ return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
2194
+ }
2195
+ if (root === "entity" && segment === "metadata" && field) {
2196
+ const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, conflict.entityId)));
2197
+ if (!entity)
2198
+ return PluginErr("Catalog entity not found.", "NOT_FOUND");
2199
+ const updated = await this.catalog.update(conflict.entityId, {
2200
+ metadata: { ...(entity.metadata ?? {}), [field]: conflict.storeValue },
2201
+ }, actor, CHANNEL_CONVERGENCE_CTX);
2202
+ return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
2203
+ }
2204
+ if (root === "attributes" && segment && field && attributeFields.some((attributeField) => attributeField === field)) {
2205
+ const [attribute] = await this.db.select().from(sellableAttributes).where(and(eq(sellableAttributes.entityId, conflict.entityId), eq(sellableAttributes.locale, segment)));
2206
+ const title = attribute?.title ?? "";
2207
+ const attrs = { title };
2208
+ if (field === "title") {
2209
+ if (typeof conflict.storeValue !== "string")
2210
+ return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
2211
+ attrs.title = conflict.storeValue;
2212
+ }
2213
+ else if (field === "subtitle") {
2214
+ if (typeof conflict.storeValue !== "string")
2215
+ return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
2216
+ attrs.subtitle = conflict.storeValue;
2217
+ }
2218
+ else if (field === "description") {
2219
+ if (typeof conflict.storeValue !== "string")
2220
+ return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
2221
+ attrs.description = conflict.storeValue;
2222
+ }
2223
+ else if (field === "richDescription") {
2224
+ attrs.richDescription = conflict.storeValue;
2225
+ }
2226
+ else if (field === "seoTitle") {
2227
+ if (typeof conflict.storeValue !== "string")
2228
+ return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
2229
+ attrs.seoTitle = conflict.storeValue;
2230
+ }
2231
+ else if (field === "seoDescription") {
2232
+ if (typeof conflict.storeValue !== "string")
2233
+ return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
2234
+ attrs.seoDescription = conflict.storeValue;
2235
+ }
2236
+ const updated = await this.catalog.setAttributes(conflict.entityId, segment, attrs, actor, CHANNEL_CONVERGENCE_CTX);
2237
+ return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
2238
+ }
2239
+ if (root === "customFields" && segment && field === "en") {
2240
+ const updated = await this.catalog.update(conflict.entityId, { customFields: { [segment]: conflict.storeValue } }, actor, CHANNEL_CONVERGENCE_CTX);
2241
+ return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
2242
+ }
2243
+ return PluginErr(`Conflict field path "${conflict.fieldPath}" cannot be resolved to the store value.`, "UNSUPPORTED_CONFLICT_FIELD");
2244
+ }
1588
2245
  async syncInventory(orgId, storeId, actor) {
1589
2246
  const store = await this.getStoreRecord(orgId, storeId);
1590
2247
  if (!store || store.status !== "connected")
@@ -1784,6 +2441,19 @@ export class ChannelConnectorService {
1784
2441
  const status = typeof product.status === "string" && ["draft", "active", "archived", "discontinued"].includes(product.status)
1785
2442
  ? product.status
1786
2443
  : undefined;
2444
+ const customFields = product.customFields && typeof product.customFields === "object" && !Array.isArray(product.customFields)
2445
+ ? product.customFields
2446
+ : undefined;
2447
+ const images = Array.isArray(product.images)
2448
+ ? product.images.flatMap((raw) => {
2449
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
2450
+ return [];
2451
+ const image = raw;
2452
+ const role = typeof image.role === "string" ? pushCatalogImageRole(image.role) : undefined;
2453
+ const url = typeof image.url === "string" ? image.url : typeof image.src === "string" ? image.src : undefined;
2454
+ return role && url ? [{ role, url }] : [];
2455
+ })
2456
+ : [];
1787
2457
  const remoteItem = {
1788
2458
  externalId: mapping.externalId,
1789
2459
  slug: typeof product.slug === "string" ? product.slug : entity.slug,
@@ -1792,6 +2462,8 @@ export class ChannelConnectorService {
1792
2462
  ...(status !== undefined ? { status } : {}),
1793
2463
  attributes: [{ locale: "en", title, ...(description !== undefined ? { description } : {}) }],
1794
2464
  ...(Object.keys(remoteMetadata).length > 0 ? { metadata: remoteMetadata } : {}),
2465
+ ...(customFields !== undefined ? { customFields } : {}),
2466
+ ...(images.length > 0 ? { images } : {}),
1795
2467
  variants: [],
1796
2468
  };
1797
2469
  const fieldPaths = [];
@@ -1808,6 +2480,17 @@ export class ChannelConnectorService {
1808
2480
  fieldPaths.push("attributes.en.title");
1809
2481
  if (product.description !== undefined)
1810
2482
  fieldPaths.push("attributes.en.description");
2483
+ for (const [name, locales] of Object.entries(customFields ?? {})) {
2484
+ if (!locales || typeof locales !== "object" || Array.isArray(locales))
2485
+ continue;
2486
+ for (const locale of Object.keys(locales)) {
2487
+ const path = `customFields.${name}.${locale}`;
2488
+ if (isValidFieldPath(path))
2489
+ fieldPaths.push(path);
2490
+ }
2491
+ }
2492
+ for (const image of images)
2493
+ fieldPaths.push(`media.${image.role}`);
1811
2494
  const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
1812
2495
  const seedPaths = fieldPaths.filter((path) => !ownershipBeforeSeed.has(path));
1813
2496
  const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
@@ -1817,9 +2500,17 @@ export class ChannelConnectorService {
1817
2500
  ownershipBeforeSeed.set(path, "store");
1818
2501
  const owners = ownershipBeforeSeed;
1819
2502
  const remoteHash = hash(product);
1820
- const shared = await this.detectSharedConflicts(entityId, storeId, entity, mapping, remoteItem, owners, fieldPaths, remoteHash);
2503
+ const outboundEcho = this.isOutboundEcho(mapping, remoteItem);
2504
+ const shared = await this.detectSharedConflicts(entityId, storeId, entity, mapping, remoteItem, owners, fieldPaths, remoteHash, outboundEcho ? { certifiedPaths: new Set(mapping.outboundFieldPaths ?? []) } : undefined);
2505
+ const persistedConflicts = await this.persistCatalogConflicts(orgId, shared.conflicts, requireUserId(actor));
2506
+ if (!persistedConflicts.ok)
2507
+ return persistedConflicts;
1821
2508
  const owned = this.filterOwnedFieldsAtPaths(remoteItem, owners, fieldPaths);
1822
2509
  const heldPaths = [...new Set([...(mapping.heldFieldPaths ?? []), ...shared.paths])];
2510
+ // Same revocation as the reconcile path: a newly held path cancels any force
2511
+ // left from an earlier resolution, so a webhook-raised conflict cannot be
2512
+ // pre-empted by an operator's answer to a previous one.
2513
+ const survivingForcedPaths = (mapping.forcedPushFieldPaths ?? []).filter((path) => !heldPaths.includes(path));
1823
2514
  const held = this.filterConflictingFields(owned.writable, heldPaths);
1824
2515
  const blockedPaths = new Set([
1825
2516
  ...owned.skipped,
@@ -1827,7 +2518,7 @@ export class ChannelConnectorService {
1827
2518
  ...(!fieldPaths.includes("attributes.en.title") ? ["attributes.en.title"] : []),
1828
2519
  ]);
1829
2520
  const skipped = owned.skipped.map((fieldPath) => ({ entityId, fieldPath }));
1830
- const conflicts = shared.conflicts;
2521
+ const conflicts = shared.conflicts.map(({ platformValue: _platformValue, storeValue: _storeValue, ...conflict }) => conflict);
1831
2522
  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}).`);
1832
2523
  const writable = held.writable;
1833
2524
  const updateInput = {};
@@ -1844,11 +2535,11 @@ export class ChannelConnectorService {
1844
2535
  updateInput.isVisible = writable.status === "active";
1845
2536
  }
1846
2537
  if (Object.keys(updateInput).length > 0) {
1847
- const updated = await this.catalog.update(entityId, updateInput, actor);
2538
+ const updated = await this.catalog.update(entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
1848
2539
  if (!updated.ok)
1849
2540
  return PluginErr(updated.error.message);
1850
2541
  }
1851
- const attributes = await this.setCatalogAttributes(entityId, writable, actor, blockedPaths);
2542
+ const attributes = await this.setCatalogAttributesIfWritable(entityId, writable, actor, blockedPaths, CHANNEL_CONVERGENCE_CTX);
1852
2543
  if (!attributes.ok)
1853
2544
  return attributes;
1854
2545
  const levels = Array.isArray(product.variants) ? product.variants : [];
@@ -1864,6 +2555,7 @@ export class ChannelConnectorService {
1864
2555
  syncHash: remoteHash,
1865
2556
  lastSyncedAt,
1866
2557
  heldFieldPaths: heldPaths,
2558
+ forcedPushFieldPaths: survivingForcedPaths,
1867
2559
  }).where(eq(channelEntityMap.id, mapping.id));
1868
2560
  return Ok({ skipped, conflicts, warnings });
1869
2561
  }
@@ -1901,9 +2593,9 @@ export class ChannelConnectorService {
1901
2593
  const max = this.options.refundAutoMax ?? order.amountCaptured ?? order.grandTotal;
1902
2594
  const ageOk = Date.now() - store.createdAt.getTime() >= (this.options.newStoreDays ?? 7) * 86_400_000;
1903
2595
  const auto = clean && amount > 0 && ageOk && amount <= max;
1904
- 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();
2596
+ 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();
1905
2597
  const request = rows[0];
1906
- 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 });
2598
+ 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) });
1907
2599
  if (auto) {
1908
2600
  const result = await this.executeRefund(request, refundLines, actor);
1909
2601
  if (!result.ok)
@@ -1917,7 +2609,7 @@ export class ChannelConnectorService {
1917
2609
  if (!result.ok)
1918
2610
  return PluginErr(result.error?.message ?? "Refund execution failed.");
1919
2611
  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();
1920
- await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: actor.userId });
2612
+ await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: requireUserId(actor) });
1921
2613
  return Ok(updated);
1922
2614
  }
1923
2615
  async listRefundRequests(orgId) {
@@ -2016,7 +2708,7 @@ export class ChannelConnectorService {
2016
2708
  if (created.value.state === "confirmed")
2017
2709
  return created;
2018
2710
  if (created.value.state !== "exported") {
2019
- const exported = await this.transitionExport(orgId, created.value.id, "exported", actor.userId, "Export attempt started.");
2711
+ const exported = await this.transitionExport(orgId, created.value.id, "exported", requireUserId(actor), "Export attempt started.");
2020
2712
  if (!exported.ok)
2021
2713
  return exported;
2022
2714
  }
@@ -2026,7 +2718,7 @@ export class ChannelConnectorService {
2026
2718
  .where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.id, created.value.id)));
2027
2719
  const pushed = await connector.pushOrder(store, slice);
2028
2720
  if (!pushed.ok) {
2029
- return this.transitionExport(orgId, created.value.id, "failed", actor.userId, pushed.error.message, pushed.error.retriable === true ? "transient" : "definitive");
2721
+ return this.transitionExport(orgId, created.value.id, "failed", requireUserId(actor), pushed.error.message, pushed.error.retriable === true ? "transient" : "definitive");
2030
2722
  }
2031
2723
  await this.db
2032
2724
  .update(channelOrderExports)
@@ -2038,13 +2730,13 @@ export class ChannelConnectorService {
2038
2730
  .where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.id, created.value.id)));
2039
2731
  const remoteStatus = await connector.fetchOrderStatus(store, pushed.value.remoteOrderId);
2040
2732
  if (!remoteStatus.ok) {
2041
- return this.transitionExport(orgId, created.value.id, "failed", actor.userId, remoteStatus.error.message, remoteStatus.error.retriable === true ? "transient" : "definitive");
2733
+ return this.transitionExport(orgId, created.value.id, "failed", requireUserId(actor), remoteStatus.error.message, remoteStatus.error.retriable === true ? "transient" : "definitive");
2042
2734
  }
2043
2735
  if (remoteStatus.value.status === "confirmed") {
2044
- return this.transitionExport(orgId, created.value.id, "confirmed", actor.userId, "Remote order confirmed.");
2736
+ return this.transitionExport(orgId, created.value.id, "confirmed", requireUserId(actor), "Remote order confirmed.");
2045
2737
  }
2046
2738
  if (remoteStatus.value.status === "failed" || remoteStatus.value.status === "cancelled") {
2047
- return this.transitionExport(orgId, created.value.id, "failed", actor.userId, `Remote order status: ${remoteStatus.value.status}.`);
2739
+ return this.transitionExport(orgId, created.value.id, "failed", requireUserId(actor), `Remote order status: ${remoteStatus.value.status}.`);
2048
2740
  }
2049
2741
  const refreshed = await this.getExport(orgId, created.value.id);
2050
2742
  return refreshed;
@@ -2133,4 +2825,347 @@ export class ChannelConnectorService {
2133
2825
  abandonExport(orgId, exportId, changedBy, reason) {
2134
2826
  return this.transitionExport(orgId, exportId, "abandoned", changedBy, reason);
2135
2827
  }
2828
+ async resolveCatalogPushEntityIds(orgId, storeId, entityIds) {
2829
+ if (entityIds !== undefined)
2830
+ return [...new Set(entityIds)].sort();
2831
+ const mappings = await this.db.select({ entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity")));
2832
+ return [...new Set(mappings.map((mapping) => mapping.entityId))].sort();
2833
+ }
2834
+ async createCatalogPush(orgId, storeId, entityId) {
2835
+ const store = await this.getStoreRecord(orgId, storeId);
2836
+ if (!store || store.status !== "connected") {
2837
+ return PluginErr("Connected store not found.", "NOT_FOUND");
2838
+ }
2839
+ const rows = await this.db
2840
+ .insert(channelCatalogPushes)
2841
+ .values({ organizationId: orgId, storeId, entityId })
2842
+ .onConflictDoNothing({ target: [channelCatalogPushes.storeId, channelCatalogPushes.entityId] })
2843
+ .returning();
2844
+ if (rows[0])
2845
+ return Ok(rows[0]);
2846
+ const existing = await this.db
2847
+ .select()
2848
+ .from(channelCatalogPushes)
2849
+ .where(and(eq(channelCatalogPushes.organizationId, orgId), eq(channelCatalogPushes.storeId, storeId), eq(channelCatalogPushes.entityId, entityId)));
2850
+ if (!existing[0])
2851
+ return PluginErr("Failed to create channel catalog push.");
2852
+ return Ok(existing[0]);
2853
+ }
2854
+ async transitionCatalogPush(orgId, pushId, toState, changedBy, reason, failureKind, payloadSnapshot) {
2855
+ return this.transact(async (tx) => {
2856
+ const currentRows = await tx
2857
+ .select()
2858
+ .from(channelCatalogPushes)
2859
+ .where(and(eq(channelCatalogPushes.organizationId, orgId), eq(channelCatalogPushes.id, pushId)));
2860
+ const current = currentRows[0];
2861
+ if (!current)
2862
+ return PluginErr("Channel catalog push not found.", "NOT_FOUND");
2863
+ if (!canCatalogPushTransition(current.state, toState)) {
2864
+ const error = new CommerceInvalidTransitionError(`Cannot transition channel catalog push from ${current.state} to ${toState}.`);
2865
+ return PluginErr(error.message, error.code);
2866
+ }
2867
+ const updatedRows = await tx
2868
+ .update(channelCatalogPushes)
2869
+ .set({
2870
+ state: toState,
2871
+ updatedAt: new Date(),
2872
+ ...(payloadSnapshot !== undefined ? { payloadSnapshot } : {}),
2873
+ ...(toState === "exported" ? { attempts: current.attempts + 1, lastError: null, failureKind: null } : {}),
2874
+ ...(toState === "failed" ? { lastError: reason ?? "Catalog push failed." } : {}),
2875
+ ...(toState === "failed" ? { failureKind: failureKind ?? "definitive" } : {}),
2876
+ ...(toState === "confirmed" ? { lastError: null, failureKind: null } : {}),
2877
+ })
2878
+ .where(and(eq(channelCatalogPushes.organizationId, orgId), eq(channelCatalogPushes.id, pushId), eq(channelCatalogPushes.state, current.state)))
2879
+ .returning();
2880
+ const updated = updatedRows[0];
2881
+ if (!updated)
2882
+ return PluginErr("Channel catalog push changed concurrently.", "CONFLICT");
2883
+ await tx.insert(channelCatalogPushEvents).values({
2884
+ organizationId: orgId,
2885
+ pushId,
2886
+ fromState: current.state,
2887
+ toState,
2888
+ reason: reason ?? null,
2889
+ changedBy,
2890
+ });
2891
+ return Ok(updated);
2892
+ });
2893
+ }
2894
+ async recordCatalogPushRevisions(orgId, entityIds, actor) {
2895
+ if (entityIds.length === 0)
2896
+ return Ok(undefined);
2897
+ try {
2898
+ await this.transact(async (tx) => {
2899
+ const txContext = createTxContext(tx, { actor });
2900
+ for (const entityId of [...new Set(entityIds)]) {
2901
+ const revision = await this.catalog.recordEntityRevision(entityId, actor, "push", txContext);
2902
+ if (!revision.ok)
2903
+ throw new Error(revision.error.message);
2904
+ }
2905
+ });
2906
+ }
2907
+ catch (error) {
2908
+ return PluginErr(error instanceof Error ? error.message : "Failed to record catalog push revisions.");
2909
+ }
2910
+ return Ok(undefined);
2911
+ }
2912
+ async executeCatalogPushJob(orgId, storeId, options, actor, runtime) {
2913
+ const store = await this.getStoreRecord(orgId, storeId);
2914
+ if (!store || store.status !== "connected")
2915
+ return PluginErr("Connected store not found.", "NOT_FOUND");
2916
+ if (!store.catalogWriteEnabled)
2917
+ return Ok({ noop: true });
2918
+ const connector = this.connectors.get(store.provider);
2919
+ if (!connector?.pushCatalog)
2920
+ return Ok({ noop: true });
2921
+ if (isCatalogPushBreakerOpen(store.breakerState)) {
2922
+ await runtime.jobs.enqueue("channel/push-catalog", {
2923
+ organizationId: orgId,
2924
+ storeId,
2925
+ ...(options.entityIds ? { entityIds: options.entityIds } : {}),
2926
+ ...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
2927
+ ...(options.cursor ? { cursor: options.cursor } : {}),
2928
+ }, {
2929
+ organizationId: orgId,
2930
+ concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
2931
+ supersedes: false,
2932
+ delayMs: CATALOG_PUSH_BREAKER_RETRY_MS,
2933
+ });
2934
+ return Ok({ rescheduled: true });
2935
+ }
2936
+ const allEntityIds = await this.resolveCatalogPushEntityIds(orgId, storeId, options.entityIds);
2937
+ const batchSize = catalogPushBatchSize(store.provider);
2938
+ const pageEntityIds = options.cursor
2939
+ ? allEntityIds.filter((entityId) => entityId > options.cursor).slice(0, batchSize)
2940
+ : allEntityIds.slice(0, batchSize);
2941
+ if (pageEntityIds.length === 0)
2942
+ return Ok({ complete: true, pushed: 0, failed: 0 });
2943
+ // Abandoned is terminal: a row that exhausted its attempts stays out of
2944
+ // every later sweep until an operator re-arms it.
2945
+ const abandonedRows = await this.db.select({ entityId: channelCatalogPushes.entityId }).from(channelCatalogPushes).where(and(eq(channelCatalogPushes.organizationId, orgId), eq(channelCatalogPushes.storeId, storeId), eq(channelCatalogPushes.state, "abandoned"), inArray(channelCatalogPushes.entityId, pageEntityIds)));
2946
+ const abandonedEntityIds = new Set(abandonedRows.map((row) => row.entityId));
2947
+ const batchEntityIds = pageEntityIds.filter((entityId) => !abandonedEntityIds.has(entityId));
2948
+ if (batchEntityIds.length === 0) {
2949
+ const batchCursor = pageEntityIds[pageEntityIds.length - 1];
2950
+ const hasMore = allEntityIds.some((entityId) => entityId > batchCursor);
2951
+ if (!hasMore)
2952
+ return Ok({ complete: true, pushed: 0, failed: 0 });
2953
+ await runtime.jobs.enqueue("channel/push-catalog", {
2954
+ organizationId: orgId,
2955
+ storeId,
2956
+ ...(options.entityIds ? { entityIds: options.entityIds } : {}),
2957
+ ...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
2958
+ cursor: batchCursor,
2959
+ }, {
2960
+ organizationId: orgId,
2961
+ concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
2962
+ supersedes: false,
2963
+ });
2964
+ return Ok({ complete: false, cursor: batchCursor, pushed: 0, failed: 0 });
2965
+ }
2966
+ const assembled = await this.buildCatalogPushItems(orgId, storeId, batchEntityIds, {
2967
+ ...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
2968
+ });
2969
+ if (!assembled.ok)
2970
+ return assembled;
2971
+ const mappings = await this.db.select({
2972
+ entityId: channelEntityMap.entityId,
2973
+ externalId: channelEntityMap.externalId,
2974
+ }).from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), inArray(channelEntityMap.entityId, batchEntityIds)));
2975
+ const externalByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping.externalId]));
2976
+ const entityByExternal = new Map(mappings.map((mapping) => [mapping.externalId, mapping.entityId]));
2977
+ const itemByExternal = new Map(assembled.value.items.map((item) => [item.externalId, item]));
2978
+ let pushed = 0;
2979
+ let failed = 0;
2980
+ if (assembled.value.items.length === 0) {
2981
+ for (const entityId of batchEntityIds) {
2982
+ const created = await this.createCatalogPush(orgId, storeId, entityId);
2983
+ if (!created.ok)
2984
+ return created;
2985
+ if (created.value.state === "confirmed" || created.value.state === "abandoned") {
2986
+ if (created.value.state === "confirmed")
2987
+ pushed += 1;
2988
+ continue;
2989
+ }
2990
+ const confirmed = await this.transitionCatalogPush(orgId, created.value.id, "confirmed", requireUserId(actor), "No platform-owned fields to push.", undefined, null);
2991
+ if (!confirmed.ok)
2992
+ return confirmed;
2993
+ pushed += 1;
2994
+ }
2995
+ }
2996
+ else {
2997
+ const pushIds = new Map();
2998
+ const pushAttempts = new Map();
2999
+ for (const entityId of batchEntityIds) {
3000
+ const externalId = externalByEntity.get(entityId);
3001
+ const item = externalId ? itemByExternal.get(externalId) : undefined;
3002
+ if (!item)
3003
+ continue;
3004
+ const created = await this.createCatalogPush(orgId, storeId, entityId);
3005
+ if (!created.ok)
3006
+ return created;
3007
+ pushIds.set(item.externalId, created.value.id);
3008
+ pushAttempts.set(item.externalId, created.value.attempts);
3009
+ if (created.value.state === "pending" || created.value.state === "confirmed" || created.value.state === "failed") {
3010
+ const exported = await this.transitionCatalogPush(orgId, created.value.id, "exported", requireUserId(actor), "Catalog push attempt started.", undefined, item);
3011
+ if (!exported.ok)
3012
+ return exported;
3013
+ pushAttempts.set(item.externalId, exported.value.attempts);
3014
+ }
3015
+ }
3016
+ const items = assembled.value.items;
3017
+ const writeAhead = await this.recordOutboundPush(orgId, storeId, items.map((item) => ({ externalId: item.externalId, ok: true })), items, "write-ahead");
3018
+ if (!writeAhead.ok)
3019
+ return writeAhead;
3020
+ let result;
3021
+ try {
3022
+ result = await connector.pushCatalog(store, items);
3023
+ }
3024
+ catch (error) {
3025
+ const connectorError = {
3026
+ code: "CATALOG_PUSH_THROWN",
3027
+ message: error instanceof Error ? error.message : "Catalog push failed.",
3028
+ };
3029
+ const cleared = await this.recordOutboundPush(orgId, storeId, items.map((item) => ({ externalId: item.externalId, ok: false, error: connectorError })), items);
3030
+ if (!cleared.ok)
3031
+ return cleared;
3032
+ for (const item of items) {
3033
+ const pushId = pushIds.get(item.externalId);
3034
+ if (!pushId)
3035
+ continue;
3036
+ const attempts = pushAttempts.get(item.externalId) ?? 0;
3037
+ if (attempts >= CATALOG_PUSH_MAX_ATTEMPTS) {
3038
+ await this.transitionCatalogPush(orgId, pushId, "abandoned", requireUserId(actor), connectorError.message);
3039
+ }
3040
+ else {
3041
+ await this.transitionCatalogPush(orgId, pushId, "failed", requireUserId(actor), connectorError.message, "transient");
3042
+ }
3043
+ failed += 1;
3044
+ }
3045
+ const maxAttempts = Math.max(0, ...items.map((item) => pushAttempts.get(item.externalId) ?? 0));
3046
+ if (maxAttempts < CATALOG_PUSH_MAX_ATTEMPTS) {
3047
+ await runtime.jobs.enqueue("channel/push-catalog", {
3048
+ organizationId: orgId,
3049
+ storeId,
3050
+ ...(options.entityIds ? { entityIds: options.entityIds } : {}),
3051
+ ...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
3052
+ ...(options.cursor ? { cursor: options.cursor } : {}),
3053
+ }, {
3054
+ organizationId: orgId,
3055
+ concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
3056
+ supersedes: false,
3057
+ delayMs: catalogPushRetryDelayMs(maxAttempts),
3058
+ });
3059
+ }
3060
+ return Ok({ rescheduled: true, pushed, failed });
3061
+ }
3062
+ if (!result.ok) {
3063
+ const cleared = await this.recordOutboundPush(orgId, storeId, items.map((item) => ({ externalId: item.externalId, ok: false, error: result.error })), items);
3064
+ if (!cleared.ok)
3065
+ return cleared;
3066
+ for (const item of items) {
3067
+ const pushId = pushIds.get(item.externalId);
3068
+ if (!pushId)
3069
+ continue;
3070
+ const attempts = pushAttempts.get(item.externalId) ?? 0;
3071
+ const failureKind = result.error.retriable === true ? "transient" : "definitive";
3072
+ if (failureKind === "transient" && attempts >= CATALOG_PUSH_MAX_ATTEMPTS) {
3073
+ await this.transitionCatalogPush(orgId, pushId, "abandoned", requireUserId(actor), result.error.message);
3074
+ }
3075
+ else {
3076
+ await this.transitionCatalogPush(orgId, pushId, "failed", requireUserId(actor), result.error.message, failureKind);
3077
+ }
3078
+ failed += 1;
3079
+ }
3080
+ if (result.error.retriable === true) {
3081
+ const maxAttempts = Math.max(0, ...items.map((item) => pushAttempts.get(item.externalId) ?? 0));
3082
+ if (maxAttempts < CATALOG_PUSH_MAX_ATTEMPTS) {
3083
+ await runtime.jobs.enqueue("channel/push-catalog", {
3084
+ organizationId: orgId,
3085
+ storeId,
3086
+ ...(options.entityIds ? { entityIds: options.entityIds } : {}),
3087
+ ...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
3088
+ ...(options.cursor ? { cursor: options.cursor } : {}),
3089
+ }, {
3090
+ organizationId: orgId,
3091
+ concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
3092
+ supersedes: false,
3093
+ delayMs: catalogPushRetryDelayMs(maxAttempts),
3094
+ });
3095
+ }
3096
+ return Ok({ rescheduled: true, pushed, failed });
3097
+ }
3098
+ const batchCursor = pageEntityIds[pageEntityIds.length - 1];
3099
+ const hasMore = allEntityIds.some((entityId) => entityId > batchCursor);
3100
+ return Ok({ complete: !hasMore, pushed, failed });
3101
+ }
3102
+ const recorded = await this.recordOutboundPush(orgId, storeId, result.value.outcomes, items);
3103
+ if (!recorded.ok)
3104
+ return recorded;
3105
+ const successfulEntityIds = [];
3106
+ for (const outcome of result.value.outcomes) {
3107
+ const pushId = pushIds.get(outcome.externalId);
3108
+ const entityId = entityByExternal.get(outcome.externalId);
3109
+ if (!pushId || !entityId)
3110
+ continue;
3111
+ const item = itemByExternal.get(outcome.externalId);
3112
+ if (outcome.ok) {
3113
+ const confirmed = await this.transitionCatalogPush(orgId, pushId, "confirmed", requireUserId(actor), "Remote catalog confirmed item.", undefined, item ?? null);
3114
+ if (!confirmed.ok)
3115
+ return confirmed;
3116
+ successfulEntityIds.push(entityId);
3117
+ pushed += 1;
3118
+ continue;
3119
+ }
3120
+ const failureKind = outcome.error?.retriable === true ? "transient" : "definitive";
3121
+ const attempts = pushAttempts.get(outcome.externalId) ?? 0;
3122
+ if (failureKind === "transient" && attempts >= CATALOG_PUSH_MAX_ATTEMPTS) {
3123
+ const abandoned = await this.transitionCatalogPush(orgId, pushId, "abandoned", requireUserId(actor), outcome.error?.message ?? "Catalog push failed.", undefined, item ?? null);
3124
+ if (!abandoned.ok)
3125
+ return abandoned;
3126
+ }
3127
+ else {
3128
+ const failedPush = await this.transitionCatalogPush(orgId, pushId, "failed", requireUserId(actor), outcome.error?.message ?? "Catalog push failed.", failureKind, item ?? null);
3129
+ if (!failedPush.ok)
3130
+ return failedPush;
3131
+ if (failureKind === "transient") {
3132
+ await runtime.jobs.enqueue("channel/push-catalog", {
3133
+ organizationId: orgId,
3134
+ storeId,
3135
+ entityIds: [entityId],
3136
+ ...(options.forceFieldPaths?.[entityId]
3137
+ ? { forceFieldPaths: { [entityId]: options.forceFieldPaths[entityId] } }
3138
+ : {}),
3139
+ }, {
3140
+ organizationId: orgId,
3141
+ concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: [entityId] }),
3142
+ supersedes: true,
3143
+ delayMs: catalogPushRetryDelayMs(attempts),
3144
+ });
3145
+ }
3146
+ }
3147
+ failed += 1;
3148
+ }
3149
+ const revisions = await this.recordCatalogPushRevisions(orgId, successfulEntityIds, actor);
3150
+ if (!revisions.ok)
3151
+ return revisions;
3152
+ }
3153
+ const batchCursor = pageEntityIds[pageEntityIds.length - 1];
3154
+ const hasMore = allEntityIds.some((entityId) => entityId > batchCursor);
3155
+ if (hasMore) {
3156
+ await runtime.jobs.enqueue("channel/push-catalog", {
3157
+ organizationId: orgId,
3158
+ storeId,
3159
+ ...(options.entityIds ? { entityIds: options.entityIds } : {}),
3160
+ ...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
3161
+ cursor: batchCursor,
3162
+ }, {
3163
+ organizationId: orgId,
3164
+ concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
3165
+ supersedes: false,
3166
+ });
3167
+ return Ok({ complete: false, cursor: batchCursor, pushed, failed });
3168
+ }
3169
+ return Ok({ complete: true, pushed, failed });
3170
+ }
2136
3171
  }