@porulle/plugin-channel-connector 0.10.8 → 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,8 +1,53 @@
1
1
  import { createHash } from "node:crypto";
2
- import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr, createSystemActor, } from "@porulle/core";
3
- import { and, eq, inArray } from "@porulle/core/drizzle";
4
- import { customerAddresses, customers, inventoryLevels, orderLineItems, orders, sellableEntities } from "@porulle/core/schema";
5
- import { channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
2
+ import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, } from "@porulle/core";
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";
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
+ }
6
51
  const exportTransitions = {
7
52
  pending: ["exported", "abandoned"],
8
53
  exported: ["confirmed", "failed", "abandoned"],
@@ -13,9 +58,218 @@ const exportTransitions = {
13
58
  export function canExportTransition(from, to) {
14
59
  return exportTransitions[from].includes(to);
15
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
+ }
16
72
  function hash(value) {
17
73
  return createHash("sha256").update(JSON.stringify(value)).digest("hex");
18
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
+ }
162
+ function mergeMetadata(existing, remote) {
163
+ return { ...(existing ?? {}), ...remote };
164
+ }
165
+ const attributeFields = ["title", "subtitle", "description", "richDescription", "seoTitle", "seoDescription"];
166
+ const pushImageRoles = ["primary", "gallery", "thumbnail", "video", "document"];
167
+ function customFieldValue(field) {
168
+ switch (field.fieldType) {
169
+ case "text":
170
+ case "relation":
171
+ case "select":
172
+ return field.textValue;
173
+ case "number":
174
+ return field.numberValue;
175
+ case "boolean":
176
+ return field.booleanValue;
177
+ case "date":
178
+ return field.dateValue;
179
+ case "json":
180
+ return field.jsonValue;
181
+ default:
182
+ return null;
183
+ }
184
+ }
185
+ function pushCatalogIntent(fieldPath, target) {
186
+ if (fieldPath.startsWith("customFields.") && target === "attribute")
187
+ return "filterable";
188
+ if (fieldPath.startsWith("customFields.") || fieldPath.startsWith("entity.metadata."))
189
+ return "tag";
190
+ return "display";
191
+ }
192
+ function pushCatalogField(fieldPath, value, mapping) {
193
+ const segments = fieldPath.split(".");
194
+ const locale = fieldPath.startsWith("attributes.")
195
+ ? segments[1]
196
+ : fieldPath.startsWith("customFields.")
197
+ ? segments[2]
198
+ : undefined;
199
+ return {
200
+ fieldPath,
201
+ intent: pushCatalogIntent(fieldPath, mapping.target),
202
+ value,
203
+ ...(locale !== undefined ? { locale } : {}),
204
+ remoteKey: mapping.remoteKey,
205
+ target: mapping.target,
206
+ };
207
+ }
208
+ function pushCatalogImageRole(value) {
209
+ return pushImageRoles.find((role) => role === value);
210
+ }
211
+ function importedFieldPaths(item) {
212
+ const paths = new Set(["entity.slug"]);
213
+ if (item.status !== undefined)
214
+ paths.add("entity.status");
215
+ for (const key of Object.keys(item.metadata ?? {})) {
216
+ const path = `entity.metadata.${key}`;
217
+ if (isValidFieldPath(path))
218
+ paths.add(path);
219
+ }
220
+ const attributes = item.attributes?.length
221
+ ? item.attributes
222
+ : [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
223
+ for (const attribute of attributes) {
224
+ for (const field of attributeFields) {
225
+ if (attribute[field] !== undefined)
226
+ paths.add(`attributes.${attribute.locale}.${field}`);
227
+ }
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
+ }
239
+ for (const image of item.images ?? [])
240
+ paths.add(`media.${image.role}`);
241
+ if (item.options?.length)
242
+ paths.add("options");
243
+ if (item.variants.some((variant) => variant.sku !== undefined))
244
+ paths.add("variants.sku");
245
+ if (item.variants.some((variant) => variant.barcode !== undefined))
246
+ paths.add("variants.barcode");
247
+ for (const currency of item.variants.flatMap((variant) => variant.prices ?? []).map((price) => price.currency)) {
248
+ const path = `prices.${currency}`;
249
+ if (isValidFieldPath(path))
250
+ paths.add(path);
251
+ }
252
+ return [...paths];
253
+ }
254
+ function summarizeValue(value) {
255
+ const serialized = JSON.stringify(value);
256
+ if (serialized === undefined)
257
+ return String(value);
258
+ return serialized.length > 256 ? `${serialized.slice(0, 253)}...` : serialized;
259
+ }
260
+ function uniqueSkipped(skipped) {
261
+ const seen = new Set();
262
+ return skipped.filter((entry) => {
263
+ const key = `${entry.entityId}:${entry.fieldPath}`;
264
+ if (seen.has(key))
265
+ return false;
266
+ seen.add(key);
267
+ return true;
268
+ });
269
+ }
270
+ function ownerAllows(owners, path) {
271
+ return owners.get(path) !== "platform";
272
+ }
19
273
  function stockFailure(line, reason) {
20
274
  return `Cannot checkout line "${line.title ?? line.entityId}": ${reason}.`;
21
275
  }
@@ -42,6 +296,8 @@ function redactStore(store) {
42
296
  credentials: "[REDACTED]",
43
297
  storeDomain: store.storeDomain,
44
298
  status: store.status,
299
+ catalogWriteEnabled: store.catalogWriteEnabled,
300
+ catalogFieldMapping: store.catalogFieldMapping,
45
301
  catalogCursor: store.catalogCursor,
46
302
  inventoryCursor: store.inventoryCursor,
47
303
  lastSyncAt: store.lastSyncAt,
@@ -79,6 +335,614 @@ export class ChannelConnectorService {
79
335
  get catalog() {
80
336
  return this.services.catalog;
81
337
  }
338
+ get media() {
339
+ return this.services.media;
340
+ }
341
+ get pricing() {
342
+ return this.services.pricing;
343
+ }
344
+ filterOwnedFields(item, owners) {
345
+ return this.filterOwnedFieldsAtPaths(item, owners, importedFieldPaths(item));
346
+ }
347
+ filterOwnedFieldsAtPaths(item, owners, fieldPaths) {
348
+ const populated = new Set(fieldPaths);
349
+ const skipped = fieldPaths.filter((path) => owners.get(path) === "platform");
350
+ const blocked = new Set(skipped);
351
+ const attributes = (item.attributes?.length
352
+ ? item.attributes
353
+ : [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }])
354
+ .flatMap((attribute) => {
355
+ return [{
356
+ locale: attribute.locale,
357
+ title: attribute.title,
358
+ ...Object.fromEntries(attributeFields.slice(1)
359
+ .filter((field) => attribute[field] !== undefined
360
+ && populated.has(`attributes.${attribute.locale}.${field}`)
361
+ && !blocked.has(`attributes.${attribute.locale}.${field}`))
362
+ .map((field) => [field, attribute[field]])),
363
+ }];
364
+ });
365
+ const writable = {
366
+ ...item,
367
+ attributes,
368
+ metadata: Object.fromEntries(Object.entries(item.metadata ?? {}).filter(([key]) => populated.has(`entity.metadata.${key}`) && !blocked.has(`entity.metadata.${key}`))),
369
+ ...(item.images !== undefined ? { images: item.images.filter((image) => populated.has(`media.${image.role}`) && !blocked.has(`media.${image.role}`)) } : {}),
370
+ ...(item.options !== undefined ? { options: blocked.has("options") ? [] : item.options } : {}),
371
+ variants: item.variants.map((variant) => ({
372
+ externalId: variant.externalId,
373
+ ...(variant.sku !== undefined && populated.has("variants.sku") && !blocked.has("variants.sku") ? { sku: variant.sku } : {}),
374
+ ...(variant.barcode !== undefined && populated.has("variants.barcode") && !blocked.has("variants.barcode") ? { barcode: variant.barcode } : {}),
375
+ ...(variant.metadata !== undefined ? { metadata: variant.metadata } : {}),
376
+ ...(variant.optionValues !== undefined && populated.has("options") && !blocked.has("options") ? { optionValues: variant.optionValues } : {}),
377
+ ...(variant.prices !== undefined
378
+ ? { prices: variant.prices.filter((price) => populated.has(`prices.${price.currency}`) && !blocked.has(`prices.${price.currency}`)) }
379
+ : {}),
380
+ })),
381
+ };
382
+ return { writable, skipped, conflicts: [] };
383
+ }
384
+ filterConflictingFields(item, conflicts) {
385
+ if (conflicts.length === 0)
386
+ return { writable: item, conflicts: [] };
387
+ const blocked = new Set(conflicts);
388
+ const attributes = (item.attributes ?? []).flatMap((attribute) => {
389
+ return [{
390
+ locale: attribute.locale,
391
+ title: attribute.title,
392
+ ...Object.fromEntries(attributeFields.slice(1)
393
+ .filter((field) => attribute[field] !== undefined && !blocked.has(`attributes.${attribute.locale}.${field}`))
394
+ .map((field) => [field, attribute[field]])),
395
+ }];
396
+ });
397
+ return {
398
+ writable: {
399
+ ...item,
400
+ attributes,
401
+ metadata: Object.fromEntries(Object.entries(item.metadata ?? {}).filter(([key]) => !blocked.has(`entity.metadata.${key}`))),
402
+ ...(item.images !== undefined ? { images: item.images.filter((image) => !blocked.has(`media.${image.role}`)) } : {}),
403
+ ...(item.options !== undefined ? { options: blocked.has("options") ? [] : item.options } : {}),
404
+ variants: item.variants.map((variant) => ({
405
+ externalId: variant.externalId,
406
+ ...(variant.sku !== undefined && !blocked.has("variants.sku") ? { sku: variant.sku } : {}),
407
+ ...(variant.barcode !== undefined && !blocked.has("variants.barcode") ? { barcode: variant.barcode } : {}),
408
+ ...(variant.metadata !== undefined ? { metadata: variant.metadata } : {}),
409
+ ...(variant.optionValues !== undefined && !blocked.has("options") ? { optionValues: variant.optionValues } : {}),
410
+ ...(variant.prices !== undefined
411
+ ? { prices: variant.prices.filter((price) => !blocked.has(`prices.${price.currency}`)) }
412
+ : {}),
413
+ })),
414
+ },
415
+ conflicts,
416
+ };
417
+ }
418
+ remoteFieldValue(item, path) {
419
+ const [root, segment, field] = path.split(".");
420
+ if (root === "entity" && segment === "slug")
421
+ return item.slug;
422
+ if (root === "entity" && segment === "status")
423
+ return item.status;
424
+ if (root === "entity" && segment === "metadata")
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
+ }
433
+ if (root === "attributes" && segment && field) {
434
+ const attributes = item.attributes?.length
435
+ ? item.attributes
436
+ : [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
437
+ const attribute = attributes.find((row) => row.locale === segment);
438
+ return attribute?.[field];
439
+ }
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
+ }
445
+ if (path === "options")
446
+ return item.options;
447
+ if (path === "variants.sku")
448
+ return item.variants.map((variant) => variant.sku);
449
+ if (path === "variants.barcode")
450
+ return item.variants.map((variant) => variant.barcode);
451
+ if (root === "prices" && segment)
452
+ return item.variants.flatMap((variant) => variant.prices ?? []).filter((price) => price.currency === segment);
453
+ return undefined;
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
+ }
464
+ async localFieldValue(entityId, entity, path) {
465
+ const [root, segment, field] = path.split(".");
466
+ if (root === "entity" && segment === "slug")
467
+ return entity.slug;
468
+ if (root === "entity" && segment === "status")
469
+ return entity.status;
470
+ if (root === "entity" && segment === "metadata")
471
+ return entity.metadata?.[field ?? ""];
472
+ if (root === "attributes" && segment && field) {
473
+ const [attribute] = await this.db.select().from(sellableAttributes).where(and(eq(sellableAttributes.entityId, entityId), eq(sellableAttributes.locale, segment)));
474
+ const values = attribute
475
+ ? {
476
+ title: attribute.title,
477
+ subtitle: attribute.subtitle,
478
+ description: attribute.description,
479
+ richDescription: attribute.richDescription,
480
+ seoTitle: attribute.seoTitle,
481
+ seoDescription: attribute.seoDescription,
482
+ }
483
+ : {};
484
+ return values[field];
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
+ }
490
+ if (root === "media" && segment) {
491
+ const links = await this.db.select({ id: entityMedia.mediaAssetId }).from(entityMedia).where(and(eq(entityMedia.entityId, entityId), eq(entityMedia.role, segment)));
492
+ return links.map((link) => link.id);
493
+ }
494
+ if (path === "options") {
495
+ const types = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entityId));
496
+ const values = await Promise.all(types.map(async (type) => ({
497
+ name: type.name,
498
+ values: await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, type.id)),
499
+ })));
500
+ return values;
501
+ }
502
+ if (path === "variants.sku" || path === "variants.barcode") {
503
+ const rows = await this.db.select().from(variants).where(eq(variants.entityId, entityId));
504
+ return rows.map((variant) => path === "variants.sku" ? variant.sku : variant.barcode);
505
+ }
506
+ if (root === "prices" && segment) {
507
+ const rows = await this.db.select().from(prices).where(and(eq(prices.entityId, entityId), eq(prices.currency, segment)));
508
+ return rows.map((price) => ({ amount: price.amount, compareAtAmount: price.compareAtAmount }));
509
+ }
510
+ return undefined;
511
+ }
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) {
517
+ if (!mapping || mapping.syncHash === remoteHash)
518
+ return { paths: [], conflicts: [] };
519
+ const revisions = await this.catalog.repository.findRevisionMarkers(entityId, mapping.lastSyncedAt);
520
+ const localChanged = revisions.some((revision) => revision.reason !== "import");
521
+ const paths = fieldPaths.filter((path) => owners.get(path) === "shared");
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) {
611
+ const attributes = item.attributes ?? [{
612
+ locale: "en",
613
+ title: item.title,
614
+ ...(item.description !== undefined ? { description: item.description } : {}),
615
+ }];
616
+ const existing = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entityId));
617
+ let created = 0;
618
+ let changed = false;
619
+ for (const attribute of attributes) {
620
+ const current = existing.find((row) => row.locale === attribute.locale);
621
+ const titlePath = `attributes.${attribute.locale}.title`;
622
+ if (!current && blockedPaths.has(titlePath))
623
+ continue;
624
+ const title = blockedPaths.has(titlePath) ? current?.title : attribute.title;
625
+ if (title === undefined)
626
+ continue;
627
+ const writeAttribute = {
628
+ title,
629
+ ...(attribute.subtitle !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.subtitle`) ? { subtitle: attribute.subtitle } : current?.subtitle != null ? { subtitle: current.subtitle } : {}),
630
+ ...(attribute.description !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.description`) ? { description: attribute.description } : current?.description != null ? { description: current.description } : {}),
631
+ ...(attribute.richDescription !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.richDescription`) ? { richDescription: attribute.richDescription } : current?.richDescription != null ? { richDescription: current.richDescription } : {}),
632
+ ...(attribute.seoTitle !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.seoTitle`) ? { seoTitle: attribute.seoTitle } : current?.seoTitle != null ? { seoTitle: current.seoTitle } : {}),
633
+ ...(attribute.seoDescription !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.seoDescription`) ? { seoDescription: attribute.seoDescription } : current?.seoDescription != null ? { seoDescription: current.seoDescription } : {}),
634
+ };
635
+ if (!current) {
636
+ created += 1;
637
+ changed = true;
638
+ }
639
+ else if (attributeFields.some((field) => (current[field] == null ? null : current[field]) !== (writeAttribute[field] == null ? null : writeAttribute[field]))) {
640
+ changed = true;
641
+ }
642
+ const result = await this.catalog.setAttributes(entityId, attribute.locale, writeAttribute, actor, catalogCtx);
643
+ if (!result.ok)
644
+ return PluginErr(result.error.message);
645
+ }
646
+ return Ok({ created, changed });
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
+ }
659
+ async upsertOptionAxes(entityId, item, actor) {
660
+ const optionValueIds = new Map();
661
+ const existingTypes = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entityId));
662
+ let changed = false;
663
+ for (const [typeIndex, sourceType] of (item.options ?? []).entries()) {
664
+ let optionType = existingTypes.find((row) => row.name === sourceType.name);
665
+ if (!optionType) {
666
+ const created = await this.catalog.createOptionType({ entityId, name: sourceType.name, values: [] }, actor);
667
+ if (!created.ok)
668
+ return PluginErr(created.error.message);
669
+ const [createdType] = await this.db.select().from(optionTypes).where(eq(optionTypes.id, created.value.id));
670
+ if (!createdType)
671
+ return PluginErr(`Option type "${sourceType.name}" was not persisted.`);
672
+ optionType = createdType;
673
+ existingTypes.push(optionType);
674
+ changed = true;
675
+ }
676
+ await this.db.update(optionTypes).set({
677
+ displayName: sourceType.displayName,
678
+ sortOrder: sourceType.sortOrder ?? typeIndex,
679
+ }).where(eq(optionTypes.id, optionType.id));
680
+ const existingValues = await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, optionType.id));
681
+ const valueIds = new Map();
682
+ for (const [valueIndex, sourceValue] of sourceType.values.entries()) {
683
+ let optionValue = existingValues.find((row) => row.value === sourceValue.value);
684
+ if (!optionValue) {
685
+ const created = await this.catalog.createOptionValue({ optionTypeId: optionType.id, value: sourceValue.value }, actor);
686
+ if (!created.ok)
687
+ return PluginErr(created.error.message);
688
+ const [createdValue] = await this.db.select().from(optionValues).where(eq(optionValues.id, created.value.id));
689
+ if (!createdValue)
690
+ return PluginErr(`Option value "${sourceValue.value}" was not persisted.`);
691
+ optionValue = createdValue;
692
+ existingValues.push(optionValue);
693
+ changed = true;
694
+ }
695
+ await this.db.update(optionValues).set({
696
+ displayValue: sourceValue.displayValue,
697
+ sortOrder: sourceValue.sortOrder ?? valueIndex,
698
+ }).where(eq(optionValues.id, optionValue.id));
699
+ valueIds.set(sourceValue.value, optionValue.id);
700
+ }
701
+ optionValueIds.set(sourceType.name, valueIds);
702
+ }
703
+ return Ok({ value: optionValueIds, changed });
704
+ }
705
+ async upsertVariants(orgId, storeId, entityId, item, optionValueIds, actor, warnings, applyOptionValues, fullItem) {
706
+ const variantIds = new Map();
707
+ let repaired = 0;
708
+ let changed = false;
709
+ const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), eq(channelEntityMap.entityId, entityId)));
710
+ for (const sourceVariant of item.variants) {
711
+ const fullSourceVariant = fullItem.variants.find((variant) => variant.externalId === sourceVariant.externalId) ?? sourceVariant;
712
+ let mapping = mappings.find((row) => row.externalId === sourceVariant.externalId);
713
+ let variantId = mapping?.variantId;
714
+ const createdVariant = !variantId;
715
+ if (!variantId) {
716
+ const options = {};
717
+ for (const [name, value] of Object.entries(sourceVariant.optionValues ?? {})) {
718
+ const optionValueId = optionValueIds.get(name)?.get(value);
719
+ if (!optionValueId) {
720
+ warnings.push(`Skipped unmapped option "${name}=${value}" on variant "${sourceVariant.externalId}".`);
721
+ continue;
722
+ }
723
+ options[name] = value;
724
+ }
725
+ const created = await this.catalog.createVariant({
726
+ entityId,
727
+ options,
728
+ ...(sourceVariant.sku !== undefined ? { sku: sourceVariant.sku } : {}),
729
+ ...(sourceVariant.barcode !== undefined ? { barcode: sourceVariant.barcode } : {}),
730
+ }, actor);
731
+ if (!created.ok)
732
+ return PluginErr(created.error.message);
733
+ variantId = created.value.id;
734
+ const [createdMapping] = await this.db.insert(channelEntityMap).values({
735
+ organizationId: orgId,
736
+ storeId,
737
+ kind: "variant",
738
+ externalId: sourceVariant.externalId,
739
+ entityId,
740
+ variantId,
741
+ syncHash: hash(fullSourceVariant),
742
+ }).returning();
743
+ mapping = createdMapping;
744
+ if (mapping)
745
+ mappings.push(mapping);
746
+ }
747
+ if (!variantId) {
748
+ warnings.push(`Skipped variant "${sourceVariant.externalId}": no local variant mapping exists.`);
749
+ continue;
750
+ }
751
+ variantIds.set(sourceVariant.externalId, variantId);
752
+ if (applyOptionValues) {
753
+ const desiredOptionValueIds = Object.entries(sourceVariant.optionValues ?? {})
754
+ .map(([name, value]) => optionValueIds.get(name)?.get(value))
755
+ .filter((optionValueId) => optionValueId !== undefined);
756
+ const currentOptionValues = await this.db.select().from(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
757
+ const currentIds = currentOptionValues.map((row) => row.optionValueId).sort();
758
+ const desiredIds = [...new Set(desiredOptionValueIds)].sort();
759
+ if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
760
+ await this.db.delete(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
761
+ if (desiredIds.length > 0) {
762
+ await this.db.insert(variantOptionValues).values(desiredIds.map((optionValueId) => ({ variantId, optionValueId }))).onConflictDoNothing();
763
+ repaired += 1;
764
+ }
765
+ changed = true;
766
+ }
767
+ if (createdVariant && desiredIds.length > 0) {
768
+ repaired += 1;
769
+ changed = true;
770
+ }
771
+ }
772
+ for (const price of sourceVariant.prices ?? []) {
773
+ const priced = await this.pricing.setBasePrice({
774
+ entityId,
775
+ variantId,
776
+ currency: price.currency,
777
+ amount: price.amount,
778
+ compareAtAmount: price.compareAtAmount ?? null,
779
+ }, actor);
780
+ if (!priced.ok)
781
+ return PluginErr(priced.error.message);
782
+ }
783
+ if (mapping) {
784
+ await this.db.update(channelEntityMap).set({
785
+ syncHash: hash(fullSourceVariant),
786
+ }).where(eq(channelEntityMap.id, mapping.id));
787
+ }
788
+ }
789
+ return Ok({ value: variantIds, repaired, changed });
790
+ }
791
+ async applyTaxonomy(orgId, entityId, item, actor, warnings) {
792
+ const categoryRows = await this.db.select().from(categories).where(eq(categories.organizationId, orgId));
793
+ for (const slug of new Set(item.categories ?? [])) {
794
+ let category = categoryRows.find((row) => row.slug === slug);
795
+ if (category?.status === "archived") {
796
+ warnings.push(`Skipped archived category "${slug}".`);
797
+ continue;
798
+ }
799
+ if (!category) {
800
+ const created = await this.catalog.createCategory({ slug }, actor);
801
+ if (!created.ok)
802
+ return PluginErr(created.error.message);
803
+ const [createdCategory] = await this.db.select().from(categories).where(eq(categories.id, created.value.id));
804
+ if (!createdCategory)
805
+ return PluginErr(`Category "${slug}" was not persisted.`);
806
+ category = createdCategory;
807
+ categoryRows.push(category);
808
+ }
809
+ const linked = await this.catalog.addToCategory(entityId, category.id, actor);
810
+ if (!linked.ok)
811
+ return PluginErr(linked.error.message);
812
+ }
813
+ const brandRows = await this.db.select().from(brands).where(eq(brands.organizationId, orgId));
814
+ if (item.brand) {
815
+ let brand = brandRows.find((row) => row.slug === item.brand);
816
+ if (!brand) {
817
+ const created = await this.catalog.createBrand({ slug: item.brand, displayName: item.brand }, actor);
818
+ if (!created.ok)
819
+ return PluginErr(created.error.message);
820
+ const [createdBrand] = await this.db.select().from(brands).where(eq(brands.id, created.value.id));
821
+ if (!createdBrand)
822
+ return PluginErr(`Brand "${item.brand}" was not persisted.`);
823
+ brand = createdBrand;
824
+ brandRows.push(brand);
825
+ }
826
+ const linked = await this.catalog.addToBrand(entityId, brand.id, actor);
827
+ if (!linked.ok)
828
+ return PluginErr(linked.error.message);
829
+ }
830
+ const tagRows = await this.db.select().from(tags).where(eq(tags.organizationId, orgId));
831
+ for (const slug of new Set(item.tags ?? [])) {
832
+ let tag = tagRows.find((row) => row.slug === slug);
833
+ if (!tag) {
834
+ const [createdTag] = await this.db.insert(tags).values({ organizationId: orgId, slug, displayName: slug }).onConflictDoNothing().returning();
835
+ tag = createdTag ?? (await this.db.select().from(tags).where(and(eq(tags.organizationId, orgId), eq(tags.slug, slug))))[0];
836
+ if (!tag)
837
+ return PluginErr(`Tag "${slug}" was not persisted.`);
838
+ tagRows.push(tag);
839
+ }
840
+ await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing();
841
+ }
842
+ return Ok(undefined);
843
+ }
844
+ async applyMedia(orgId, entityId, item, variantIds, actor, warnings, owners) {
845
+ const assets = await this.db.select().from(mediaAssets).where(eq(mediaAssets.organizationId, orgId));
846
+ const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entityId));
847
+ let imported = 0;
848
+ let changed = false;
849
+ const skipped = [];
850
+ for (const image of item.images ?? []) {
851
+ const urlHash = hash(image.url);
852
+ const asset = assets.find((row) => {
853
+ const metadata = row.metadata ?? {};
854
+ return (image.externalId != null && metadata.channelImageExternalId === image.externalId)
855
+ || metadata.channelImageUrlHash === urlHash;
856
+ });
857
+ let mediaAssetId = asset?.id;
858
+ if (!mediaAssetId) {
859
+ let response;
860
+ try {
861
+ response = await fetch(image.url);
862
+ }
863
+ catch (error) {
864
+ warnings.push(`Skipped image "${image.externalId ?? image.url}": ${error instanceof Error ? error.message : "download failed"}.`);
865
+ continue;
866
+ }
867
+ if (!response.ok) {
868
+ warnings.push(`Skipped image "${image.externalId ?? image.url}": download returned ${response.status}.`);
869
+ continue;
870
+ }
871
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0] ?? "image/jpeg";
872
+ const extension = contentType.split("/", 2)[1] ?? "jpg";
873
+ const uploaded = await this.media.upload({
874
+ filename: `${image.externalId ?? urlHash}.${extension}`,
875
+ contentType,
876
+ data: await response.arrayBuffer(),
877
+ ...(image.alt !== undefined ? { alt: image.alt } : {}),
878
+ metadata: {
879
+ channelImageUrlHash: urlHash,
880
+ ...(image.externalId !== undefined ? { channelImageExternalId: image.externalId } : {}),
881
+ },
882
+ origin: "imported",
883
+ }, actor);
884
+ if (!uploaded.ok) {
885
+ warnings.push(`Skipped image "${image.externalId ?? image.url}": ${uploaded.error.code === "STORAGE_NOT_SUPPORTED" ? "storage adapter is not configured" : uploaded.error.message}.`);
886
+ continue;
887
+ }
888
+ mediaAssetId = uploaded.value.id;
889
+ imported += 1;
890
+ changed = true;
891
+ const [createdAsset] = await this.db.select().from(mediaAssets).where(eq(mediaAssets.id, mediaAssetId));
892
+ if (createdAsset)
893
+ assets.push(createdAsset);
894
+ }
895
+ if (!mediaAssetId)
896
+ continue;
897
+ const targets = image.variantExternalIds?.length
898
+ ? image.variantExternalIds.map((externalId) => ({ externalId, variantId: variantIds.get(externalId) }))
899
+ : [{ externalId: undefined, variantId: undefined }];
900
+ for (const target of targets) {
901
+ if (image.variantExternalIds?.length && !target.variantId) {
902
+ warnings.push(`Skipped image "${image.externalId ?? image.url}" for unmapped variant "${target.externalId}".`);
903
+ continue;
904
+ }
905
+ const existingLink = links.find((link) => link.mediaAssetId === mediaAssetId
906
+ && (target.variantId === undefined ? link.variantId === null : link.variantId === target.variantId));
907
+ if (existingLink) {
908
+ if (existingLink.role !== image.role) {
909
+ const currentRolePath = `media.${existingLink.role}`;
910
+ const incomingRolePath = `media.${image.role}`;
911
+ for (const path of [currentRolePath, incomingRolePath]) {
912
+ if (owners.get(path) === "platform" && !skipped.includes(path))
913
+ skipped.push(path);
914
+ }
915
+ if (skipped.includes(currentRolePath) || skipped.includes(incomingRolePath))
916
+ continue;
917
+ }
918
+ if (existingLink.role !== image.role || existingLink.sortOrder !== (image.sortOrder ?? 0)) {
919
+ await this.db.update(entityMedia).set({ role: image.role, sortOrder: image.sortOrder ?? 0 }).where(and(eq(entityMedia.entityId, entityId), eq(entityMedia.mediaAssetId, mediaAssetId), target.variantId === undefined ? isNull(entityMedia.variantId) : eq(entityMedia.variantId, target.variantId)));
920
+ changed = true;
921
+ }
922
+ continue;
923
+ }
924
+ const attached = await this.media.attachToEntity({
925
+ entityId,
926
+ mediaAssetId,
927
+ role: image.role,
928
+ sortOrder: image.sortOrder ?? 0,
929
+ ...(target.variantId !== undefined ? { variantId: target.variantId } : {}),
930
+ }, actor);
931
+ if (!attached.ok)
932
+ return PluginErr(attached.error.message);
933
+ changed = true;
934
+ links.push({
935
+ entityId,
936
+ mediaAssetId,
937
+ role: image.role,
938
+ sortOrder: image.sortOrder ?? 0,
939
+ variantId: target.variantId ?? null,
940
+ createdAt: new Date(),
941
+ });
942
+ }
943
+ }
944
+ return Ok({ imported, changed, skipped });
945
+ }
82
946
  async getStoreRecord(orgId, id) {
83
947
  const rows = await this.db
84
948
  .select()
@@ -102,20 +966,458 @@ export class ChannelConnectorService {
102
966
  .where(eq(connectedStores.storeDomain, shopDomain));
103
967
  return rows;
104
968
  }
969
+ resolveCatalogFieldMapping(store, filterableCustomFields, warnings = []) {
970
+ return mergeCatalogFieldMapping(store.provider, store.catalogFieldMapping, filterableCustomFields, warnings);
971
+ }
972
+ async buildCatalogPushItems(orgId, storeId, entityIds, options = {}) {
973
+ const store = await this.getStoreRecord(orgId, storeId);
974
+ if (!store || store.status !== "connected")
975
+ return PluginErr("Connected store not found.", "NOT_FOUND");
976
+ if (!store.catalogWriteEnabled)
977
+ return PluginErr("Catalog writes are disabled for this store.", "CATALOG_WRITE_DISABLED");
978
+ if (entityIds.length === 0)
979
+ return Ok({ items: [], skipped: [], warnings: [] });
980
+ const entities = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.id, entityIds)));
981
+ const entityById = new Map(entities.map((entity) => [entity.id, entity]));
982
+ const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), inArray(channelEntityMap.entityId, entityIds)));
983
+ const mappingByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping]));
984
+ const items = [];
985
+ const skipped = [];
986
+ const warnings = [];
987
+ const revisionEntityIds = [];
988
+ for (const entityId of entityIds) {
989
+ const entity = entityById.get(entityId);
990
+ if (!entity)
991
+ return PluginErr("Catalog entity not found.", "NOT_FOUND");
992
+ if (entity.status !== "active") {
993
+ skipped.push({ entityId, fieldPath: "entity.status", reason: "entity_not_active" });
994
+ continue;
995
+ }
996
+ const entityMapping = mappingByEntity.get(entity.id);
997
+ if (!entityMapping) {
998
+ skipped.push({ entityId, fieldPath: "entity", reason: "unmapped_entity" });
999
+ continue;
1000
+ }
1001
+ const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
1002
+ const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
1003
+ const customFields = await this.db.select().from(sellableCustomFields).where(and(eq(sellableCustomFields.entityId, entity.id), eq(sellableCustomFields.status, "approved")));
1004
+ const customFieldNames = [...new Set(customFields.map((field) => field.fieldName))];
1005
+ const definitions = customFieldNames.length > 0
1006
+ ? await this.db.select({ name: entityFieldDefinitions.name, filterable: entityFieldDefinitions.filterable }).from(entityFieldDefinitions).where(and(eq(entityFieldDefinitions.organizationId, orgId), eq(entityFieldDefinitions.entityType, entity.type), inArray(entityFieldDefinitions.name, customFieldNames)))
1007
+ : [];
1008
+ const filterableCustomFields = Object.fromEntries(definitions.map((definition) => [
1009
+ `customFields.${definition.name}.en`,
1010
+ definition.filterable,
1011
+ ]));
1012
+ for (const field of customFields) {
1013
+ filterableCustomFields[`customFields.${field.fieldName}.${field.locale}`] = definitions.find((definition) => definition.name === field.fieldName)?.filterable ?? false;
1014
+ }
1015
+ const fieldMapping = this.resolveCatalogFieldMapping(store, filterableCustomFields, warnings);
1016
+ const heldPaths = new Set(entityMapping.heldFieldPaths ?? []);
1017
+ const forcedPushPaths = new Set([
1018
+ ...(entityMapping.forcedPushFieldPaths ?? []),
1019
+ ...(options.forceFieldPaths?.[entity.id] ?? []),
1020
+ ]);
1021
+ const fields = [];
1022
+ const appendField = (fieldPath, value) => {
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)
1042
+ return;
1043
+ if (heldPaths.has(fieldPath)) {
1044
+ skipped.push({
1045
+ entityId,
1046
+ fieldPath,
1047
+ reason: "held",
1048
+ value,
1049
+ owner,
1050
+ ...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
1051
+ });
1052
+ return;
1053
+ }
1054
+ if (!mapping) {
1055
+ skipped.push({ entityId, fieldPath, reason: "no_mapping", value, owner });
1056
+ return;
1057
+ }
1058
+ fields.push(pushCatalogField(fieldPath, value, mapping));
1059
+ };
1060
+ for (const attribute of attributes) {
1061
+ for (const field of attributeFields) {
1062
+ appendField(`attributes.${attribute.locale}.${field}`, attribute[field]);
1063
+ }
1064
+ }
1065
+ for (const [key, value] of Object.entries(entity.metadata ?? {})) {
1066
+ const fieldPath = `entity.metadata.${key}`;
1067
+ if (isValidFieldPath(fieldPath))
1068
+ appendField(fieldPath, value);
1069
+ }
1070
+ for (const customField of customFields) {
1071
+ const fieldPath = `customFields.${customField.fieldName}.${customField.locale}`;
1072
+ if (isValidFieldPath(fieldPath))
1073
+ appendField(fieldPath, customFieldValue(customField));
1074
+ }
1075
+ const media = await this.media.listEntityMedia(entity.id, { orgId });
1076
+ if (!media.ok)
1077
+ return PluginErr(media.error.message);
1078
+ const images = [];
1079
+ for (const attached of media.value) {
1080
+ const role = pushCatalogImageRole(attached.role);
1081
+ if (!role)
1082
+ continue;
1083
+ const fieldPath = `media.${role}`;
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)
1102
+ continue;
1103
+ if (heldPaths.has(fieldPath)) {
1104
+ skipped.push({
1105
+ entityId,
1106
+ fieldPath,
1107
+ reason: "held",
1108
+ value: imageValue,
1109
+ owner,
1110
+ ...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
1111
+ });
1112
+ continue;
1113
+ }
1114
+ if (!mapping) {
1115
+ skipped.push({ entityId, fieldPath, reason: "no_mapping", value: imageValue, owner });
1116
+ continue;
1117
+ }
1118
+ images.push({
1119
+ fieldPath,
1120
+ target: mapping.target,
1121
+ remoteKey: mapping.remoteKey,
1122
+ url: attached.url,
1123
+ role,
1124
+ sortOrder: attached.sortOrder,
1125
+ ...(attached.alt !== null ? { alt: attached.alt } : {}),
1126
+ });
1127
+ }
1128
+ fields.sort((left, right) => left.fieldPath.localeCompare(right.fieldPath));
1129
+ const item = {
1130
+ externalId: entityMapping.externalId,
1131
+ fields,
1132
+ ...(images.length > 0 ? { images } : {}),
1133
+ };
1134
+ items.push(item);
1135
+ if (options.recordRevision === true)
1136
+ revisionEntityIds.push(entity.id);
1137
+ }
1138
+ if (options.recordRevision === true && revisionEntityIds.length > 0) {
1139
+ const actor = createSystemActor(orgId);
1140
+ try {
1141
+ await this.transact(async (tx) => {
1142
+ const txContext = createTxContext(tx, { actor });
1143
+ for (const entityId of revisionEntityIds) {
1144
+ const revision = await this.catalog.recordEntityRevision(entityId, actor, "push", txContext);
1145
+ if (!revision.ok)
1146
+ throw new Error(revision.error.message);
1147
+ }
1148
+ });
1149
+ }
1150
+ catch (error) {
1151
+ return PluginErr(error instanceof Error ? error.message : "Failed to record catalog push revisions.");
1152
+ }
1153
+ }
1154
+ return Ok({ items, skipped, warnings: [...new Set(warnings)] });
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
+ }
1351
+ async getCatalogWriteSettings(orgId, storeId) {
1352
+ const store = await this.getStoreRecord(orgId, storeId);
1353
+ if (!store)
1354
+ return PluginErr("Connected store not found.", "NOT_FOUND");
1355
+ const warnings = [];
1356
+ return Ok({
1357
+ enabled: store.catalogWriteEnabled === true,
1358
+ overrides: store.catalogFieldMapping,
1359
+ merged: this.resolveCatalogFieldMapping(store, undefined, warnings),
1360
+ ...(warnings.length > 0 ? { warnings } : {}),
1361
+ });
1362
+ }
1363
+ async updateCatalogWriteEnabled(orgId, storeId, enabled) {
1364
+ const rows = await this.db
1365
+ .update(connectedStores)
1366
+ .set({ catalogWriteEnabled: enabled, updatedAt: new Date() })
1367
+ .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)))
1368
+ .returning();
1369
+ if (!rows[0])
1370
+ return PluginErr("Connected store not found.", "NOT_FOUND");
1371
+ return this.getCatalogWriteSettings(orgId, storeId);
1372
+ }
1373
+ async updateCatalogFieldMapping(orgId, storeId, mapping) {
1374
+ const store = await this.getStoreRecord(orgId, storeId);
1375
+ if (!store)
1376
+ return PluginErr("Connected store not found.", "NOT_FOUND");
1377
+ let normalized;
1378
+ try {
1379
+ normalized = normalizeCatalogFieldMapping(mapping, store.provider);
1380
+ }
1381
+ catch (error) {
1382
+ return PluginErr(error instanceof Error ? error.message : "Catalog mapping is invalid.", "INVALID_MAPPING");
1383
+ }
1384
+ await this.db
1385
+ .update(connectedStores)
1386
+ .set({ catalogFieldMapping: normalized, updatedAt: new Date() })
1387
+ .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
1388
+ return this.getCatalogWriteSettings(orgId, storeId);
1389
+ }
105
1390
  async connectStore(orgId, input) {
106
1391
  if (!this.connectors.has(input.provider)) {
107
1392
  return PluginErr(`No connector registered for provider "${input.provider}".`, "NOT_FOUND");
108
1393
  }
109
- const rows = await this.db
110
- .insert(connectedStores)
111
- .values({
112
- organizationId: orgId,
113
- provider: input.provider,
114
- credentials: input.credentials,
115
- storeDomain: input.storeDomain,
116
- webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
117
- })
118
- .returning();
1394
+ const existingRows = await this.db
1395
+ .select()
1396
+ .from(connectedStores)
1397
+ .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.provider, input.provider), eq(connectedStores.storeDomain, input.storeDomain)));
1398
+ const reconnect = existingRows.find((row) => row.status !== "connected");
1399
+ const rows = reconnect
1400
+ ? await this.db
1401
+ .update(connectedStores)
1402
+ .set({
1403
+ credentials: input.credentials,
1404
+ status: "connected",
1405
+ catalogWriteEnabled: false,
1406
+ webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
1407
+ updatedAt: new Date(),
1408
+ })
1409
+ .where(eq(connectedStores.id, reconnect.id))
1410
+ .returning()
1411
+ : await this.db
1412
+ .insert(connectedStores)
1413
+ .values({
1414
+ organizationId: orgId,
1415
+ provider: input.provider,
1416
+ credentials: input.credentials,
1417
+ storeDomain: input.storeDomain,
1418
+ webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
1419
+ })
1420
+ .returning();
119
1421
  const connector = this.connectors.get(input.provider);
120
1422
  const store = rows[0];
121
1423
  if (connector.registerWebhooks) {
@@ -258,78 +1560,468 @@ export class ChannelConnectorService {
258
1560
  .update(connectedStores)
259
1561
  .set({ catalogCursor: null, lastSyncAt: new Date(), updatedAt: new Date() })
260
1562
  .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
261
- return Ok({ imported: result.value.imported, cursor: null });
1563
+ return Ok({
1564
+ imported: result.value.imported,
1565
+ cursor: null,
1566
+ ...(result.value.skipped.length > 0 ? { skipped: uniqueSkipped(result.value.skipped) } : {}),
1567
+ ...(result.value.conflicts.length > 0 ? { conflicts: result.value.conflicts } : {}),
1568
+ ...(result.value.warnings.length > 0 ? { warnings: result.value.warnings } : {}),
1569
+ });
1570
+ }
1571
+ async promoteLegacyAttributes(orgId, storeId, actor, dryRun) {
1572
+ const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity")));
1573
+ let created = 0;
1574
+ for (const entityId of new Set(mappings.map((mapping) => mapping.entityId))) {
1575
+ const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entityId)));
1576
+ if (!entity)
1577
+ continue;
1578
+ const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
1579
+ if (attributes.length > 0)
1580
+ continue;
1581
+ const metadata = entity.metadata ?? {};
1582
+ if (typeof metadata.title !== "string")
1583
+ continue;
1584
+ const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
1585
+ if (owners.get("attributes.en.title") === "platform")
1586
+ continue;
1587
+ if (dryRun) {
1588
+ created += 1;
1589
+ continue;
1590
+ }
1591
+ const promoted = await this.catalog.setAttributes(entity.id, "en", {
1592
+ title: metadata.title,
1593
+ ...(typeof metadata.description === "string" ? { description: metadata.description } : {}),
1594
+ }, actor, CHANNEL_CONVERGENCE_CTX);
1595
+ if (!promoted.ok)
1596
+ return PluginErr(promoted.error.message);
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")));
1598
+ if (!confirmed || confirmed.title !== metadata.title || (typeof metadata.description === "string" && confirmed.description !== metadata.description)) {
1599
+ return PluginErr(`Legacy attributes for entity "${entity.id}" were not persisted.`);
1600
+ }
1601
+ const nextMetadata = { ...metadata };
1602
+ delete nextMetadata.title;
1603
+ if (typeof metadata.description === "string")
1604
+ delete nextMetadata.description;
1605
+ await this.db.update(sellableEntities).set({ metadata: nextMetadata, updatedAt: new Date() }).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entity.id)));
1606
+ created += 1;
1607
+ }
1608
+ return Ok(created);
1609
+ }
1610
+ async saveBackfillState(orgId, storeId, state) {
1611
+ const [store] = await this.db.select({ breakerState: connectedStores.breakerState }).from(connectedStores).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
1612
+ await this.db.update(connectedStores).set({
1613
+ breakerState: { ...(store?.breakerState ?? {}), catalogBackfill: state },
1614
+ updatedAt: new Date(),
1615
+ }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
1616
+ }
1617
+ async backfillCatalog(orgId, storeId, actor, options = {}) {
1618
+ const store = await this.getStoreRecord(orgId, storeId);
1619
+ if (!store || store.status !== "connected")
1620
+ return PluginErr("Connected store not found.", "NOT_FOUND");
1621
+ const connector = this.connectors.get(store.provider);
1622
+ if (!connector)
1623
+ return PluginErr(`No connector registered for provider "${store.provider}".`);
1624
+ const dryRun = options.dryRun === true;
1625
+ const saved = store.breakerState.catalogBackfill;
1626
+ const savedState = saved && typeof saved === "object" ? saved : undefined;
1627
+ // Undefined resume derives from persisted state, so a retried job or a
1628
+ // re-triggered run continues an unfinished backfill instead of restarting.
1629
+ const resume = options.resume ?? (savedState !== undefined && !savedState.completedAt);
1630
+ if (resume && savedState?.completedAt && savedState.cursor === null) {
1631
+ return Ok({
1632
+ ...savedState.report,
1633
+ cursor: null,
1634
+ complete: true,
1635
+ ...(savedState.skipped?.length ? { skipped: savedState.skipped } : {}),
1636
+ ...(savedState.conflicts?.length ? { conflicts: savedState.conflicts } : {}),
1637
+ ...(savedState.warnings?.length ? { warnings: savedState.warnings } : {}),
1638
+ });
1639
+ }
1640
+ const report = resume && savedState ? { ...savedState.report } : {
1641
+ entitiesTouched: 0,
1642
+ attributesCreated: 0,
1643
+ mediaImported: 0,
1644
+ variantsGivenOptionValues: 0,
1645
+ };
1646
+ const skipped = resume && savedState?.skipped ? [...savedState.skipped] : [];
1647
+ const conflicts = resume && savedState?.conflicts ? [...savedState.conflicts] : [];
1648
+ const warnings = resume && savedState?.warnings ? [...savedState.warnings] : [];
1649
+ const promoted = await this.promoteLegacyAttributes(orgId, storeId, actor, dryRun);
1650
+ if (!promoted.ok)
1651
+ return promoted;
1652
+ report.attributesCreated += promoted.value;
1653
+ let cursor = resume && savedState?.cursor ? savedState.cursor : undefined;
1654
+ let pages = 0;
1655
+ if (!dryRun) {
1656
+ await this.saveBackfillState(orgId, storeId, {
1657
+ cursor: cursor ?? null,
1658
+ report,
1659
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
1660
+ ...(conflicts.length > 0 ? { conflicts } : {}),
1661
+ ...(warnings.length > 0 ? { warnings } : {}),
1662
+ });
1663
+ }
1664
+ do {
1665
+ const page = await connector.importCatalog(store, cursor);
1666
+ if (!page.ok)
1667
+ return PluginErr(page.error.message);
1668
+ const converged = await this.convergeCatalogItems(orgId, storeId, page.value.items, actor, true, dryRun);
1669
+ if (!converged.ok)
1670
+ return converged;
1671
+ report.entitiesTouched += converged.value.entitiesTouched;
1672
+ report.attributesCreated += converged.value.attributesCreated;
1673
+ report.mediaImported += converged.value.mediaImported;
1674
+ report.variantsGivenOptionValues += converged.value.variantsGivenOptionValues;
1675
+ skipped.push(...converged.value.skipped);
1676
+ conflicts.push(...converged.value.conflicts);
1677
+ warnings.push(...converged.value.warnings);
1678
+ cursor = page.value.nextCursor ?? undefined;
1679
+ pages += 1;
1680
+ // The final state is written once with completedAt below; a cursor-null
1681
+ // checkpoint without it would read as a fresh start after a crash.
1682
+ if (!dryRun && cursor) {
1683
+ await this.saveBackfillState(orgId, storeId, {
1684
+ cursor,
1685
+ report,
1686
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
1687
+ ...(conflicts.length > 0 ? { conflicts } : {}),
1688
+ ...(warnings.length > 0 ? { warnings } : {}),
1689
+ });
1690
+ }
1691
+ if (options.maxPages !== undefined && pages >= options.maxPages && cursor) {
1692
+ return Ok({
1693
+ ...report,
1694
+ cursor,
1695
+ complete: false,
1696
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
1697
+ ...(conflicts.length > 0 ? { conflicts } : {}),
1698
+ ...(warnings.length > 0 ? { warnings } : {}),
1699
+ });
1700
+ }
1701
+ } while (cursor);
1702
+ if (!dryRun) {
1703
+ await this.saveBackfillState(orgId, storeId, {
1704
+ cursor: null,
1705
+ report,
1706
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
1707
+ ...(conflicts.length > 0 ? { conflicts } : {}),
1708
+ ...(warnings.length > 0 ? { warnings } : {}),
1709
+ completedAt: new Date().toISOString(),
1710
+ });
1711
+ }
1712
+ return Ok({
1713
+ ...report,
1714
+ cursor: null,
1715
+ complete: true,
1716
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
1717
+ ...(conflicts.length > 0 ? { conflicts } : {}),
1718
+ ...(warnings.length > 0 ? { warnings } : {}),
1719
+ });
262
1720
  }
263
- async convergeCatalogItems(orgId, storeId, items, actor) {
1721
+ async estimateCatalogItems(orgId, storeId, items) {
1722
+ const stats = {
1723
+ imported: 0,
1724
+ converged: 0,
1725
+ entitiesTouched: 0,
1726
+ attributesCreated: 0,
1727
+ mediaImported: 0,
1728
+ variantsGivenOptionValues: 0,
1729
+ skipped: [],
1730
+ conflicts: [],
1731
+ warnings: [],
1732
+ };
1733
+ const assets = await this.db.select().from(mediaAssets).where(eq(mediaAssets.organizationId, orgId));
1734
+ for (const item of items) {
1735
+ const [entityMapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, item.externalId)));
1736
+ if (!entityMapping) {
1737
+ stats.imported += 1;
1738
+ stats.entitiesTouched += 1;
1739
+ stats.attributesCreated += item.attributes?.length || 1;
1740
+ stats.variantsGivenOptionValues += item.variants.filter((variant) => Object.keys(variant.optionValues ?? {}).some((name) => item.options?.some((option) => option.name === name))).length;
1741
+ stats.mediaImported += item.images?.length ?? 0;
1742
+ continue;
1743
+ }
1744
+ const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entityMapping.entityId)));
1745
+ if (!entity)
1746
+ continue;
1747
+ const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
1748
+ stats.skipped.push(...importedFieldPaths(item)
1749
+ .filter((path) => owners.get(path) === "platform")
1750
+ .map((fieldPath) => ({ entityId: entity.id, fieldPath })));
1751
+ let touched = false;
1752
+ const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
1753
+ const locales = new Set(attributes.map((attribute) => attribute.locale));
1754
+ const metadata = entity.metadata ?? {};
1755
+ if (attributes.length === 0 && typeof metadata.title === "string") {
1756
+ locales.add("en");
1757
+ touched = true;
1758
+ }
1759
+ const sourceAttributes = item.attributes?.length
1760
+ ? item.attributes
1761
+ : [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
1762
+ for (const attribute of sourceAttributes) {
1763
+ if (!locales.has(attribute.locale)) {
1764
+ stats.attributesCreated += 1;
1765
+ locales.add(attribute.locale);
1766
+ touched = true;
1767
+ }
1768
+ }
1769
+ const remoteMetadata = mergeMetadata(entity.metadata, item.metadata ?? {});
1770
+ const remoteStatus = item.status ?? (entity.status === "archived" ? "active" : undefined);
1771
+ const entityChanged = entity.slug !== item.slug
1772
+ || hash(remoteMetadata) !== hash(entity.metadata ?? {})
1773
+ || (remoteStatus !== undefined && remoteStatus !== entity.status);
1774
+ if (entityChanged) {
1775
+ stats.converged += 1;
1776
+ touched = true;
1777
+ }
1778
+ const optionValueIds = new Map();
1779
+ const existingTypes = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entity.id));
1780
+ for (const sourceType of item.options ?? []) {
1781
+ const existingType = existingTypes.find((optionType) => optionType.name === sourceType.name);
1782
+ if (!existingType) {
1783
+ touched = true;
1784
+ optionValueIds.set(sourceType.name, new Map(sourceType.values.map((value) => [value.value, `new:${sourceType.name}:${value.value}`])));
1785
+ continue;
1786
+ }
1787
+ const existingValues = await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, existingType.id));
1788
+ const valueIds = new Map();
1789
+ for (const sourceValue of sourceType.values) {
1790
+ const existingValue = existingValues.find((value) => value.value === sourceValue.value);
1791
+ if (!existingValue)
1792
+ touched = true;
1793
+ valueIds.set(sourceValue.value, existingValue?.id ?? `new:${sourceType.name}:${sourceValue.value}`);
1794
+ }
1795
+ optionValueIds.set(sourceType.name, valueIds);
1796
+ }
1797
+ const variantMappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), eq(channelEntityMap.entityId, entity.id)));
1798
+ const variantIds = new Map();
1799
+ for (const sourceVariant of item.variants) {
1800
+ const mapping = variantMappings.find((row) => row.externalId === sourceVariant.externalId);
1801
+ const variantId = mapping?.variantId ?? `new:${sourceVariant.externalId}`;
1802
+ variantIds.set(sourceVariant.externalId, variantId);
1803
+ const desiredIds = [...new Set(Object.entries(sourceVariant.optionValues ?? {})
1804
+ .map(([name, value]) => optionValueIds.get(name)?.get(value))
1805
+ .filter((optionValueId) => optionValueId !== undefined))].sort();
1806
+ if (!mapping?.variantId) {
1807
+ if (desiredIds.length > 0)
1808
+ stats.variantsGivenOptionValues += 1;
1809
+ touched = true;
1810
+ continue;
1811
+ }
1812
+ const current = await this.db.select().from(variantOptionValues).where(eq(variantOptionValues.variantId, mapping.variantId));
1813
+ const currentIds = current.map((row) => row.optionValueId).sort();
1814
+ if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
1815
+ if (desiredIds.length > 0)
1816
+ stats.variantsGivenOptionValues += 1;
1817
+ touched = true;
1818
+ }
1819
+ }
1820
+ const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entity.id));
1821
+ for (const image of item.images ?? []) {
1822
+ const urlHash = hash(image.url);
1823
+ const asset = assets.find((row) => {
1824
+ const assetMetadata = row.metadata ?? {};
1825
+ return (image.externalId != null && assetMetadata.channelImageExternalId === image.externalId)
1826
+ || assetMetadata.channelImageUrlHash === urlHash;
1827
+ });
1828
+ const mediaAssetId = asset?.id ?? `new:${urlHash}`;
1829
+ if (!asset)
1830
+ stats.mediaImported += 1;
1831
+ const targets = image.variantExternalIds?.length
1832
+ ? image.variantExternalIds.map((externalId) => ({ externalId, variantId: variantIds.get(externalId) }))
1833
+ : [{ externalId: undefined, variantId: undefined }];
1834
+ for (const target of targets) {
1835
+ if (image.variantExternalIds?.length && !target.variantId) {
1836
+ stats.warnings.push(`Skipped image "${image.externalId ?? image.url}" for unmapped variant "${target.externalId}".`);
1837
+ continue;
1838
+ }
1839
+ const existingLink = links.find((link) => link.mediaAssetId === mediaAssetId && (target.variantId === undefined ? link.variantId === null : link.variantId === target.variantId));
1840
+ if (!existingLink)
1841
+ touched = true;
1842
+ }
1843
+ }
1844
+ if (touched)
1845
+ stats.entitiesTouched += 1;
1846
+ }
1847
+ return Ok(stats);
1848
+ }
1849
+ async convergeCatalogItems(orgId, storeId, items, actor, force = false, dryRun = false) {
1850
+ if (dryRun)
1851
+ return this.estimateCatalogItems(orgId, storeId, items);
264
1852
  let imported = 0;
265
1853
  let converged = 0;
1854
+ let entitiesTouched = 0;
1855
+ let attributesCreated = 0;
1856
+ let mediaImported = 0;
1857
+ let variantsGivenOptionValues = 0;
1858
+ const skipped = [];
1859
+ const conflicts = [];
1860
+ const warnings = [];
266
1861
  for (const item of items) {
1862
+ const remoteHash = hash(item);
267
1863
  const existing = await this.db
268
1864
  .select()
269
1865
  .from(channelEntityMap)
270
1866
  .where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, item.externalId)));
271
1867
  const entityMapping = existing.find((entry) => entry.kind === "entity");
1868
+ let entityId;
1869
+ let isNew = false;
1870
+ let entityTouched = false;
1871
+ let existingEntity;
272
1872
  if (entityMapping) {
273
1873
  const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entityMapping.entityId)));
274
- if (entityMapping.syncHash !== hash(item) || entity?.status === "archived") {
275
- const updated = await this.catalog.update(entityMapping.entityId, {
276
- slug: item.slug,
277
- metadata: {
278
- ...(item.metadata ?? {}),
279
- title: item.title,
280
- ...(item.description !== undefined ? { description: item.description } : {}),
281
- },
282
- ...(entity?.status === "archived" ? { status: "active", isVisible: true } : {}),
283
- }, actor);
284
- if (!updated.ok)
285
- return PluginErr(updated.error.message);
286
- await this.db.update(channelEntityMap).set({ syncHash: hash(item), lastSyncedAt: new Date() }).where(eq(channelEntityMap.id, entityMapping.id));
287
- converged += 1;
1874
+ if (!entity) {
1875
+ warnings.push(`Skipped "${item.externalId}": mapped entity ${entityMapping.entityId} no longer exists.`);
1876
+ continue;
288
1877
  }
289
- continue;
1878
+ entityId = entityMapping.entityId;
1879
+ existingEntity = entity;
290
1880
  }
291
- const entity = await this.catalog.create({
292
- type: "product",
293
- slug: item.slug,
294
- sourceStoreId: storeId,
295
- metadata: {
296
- ...(item.metadata ?? {}),
297
- title: item.title,
298
- ...(item.description !== undefined ? { description: item.description } : {}),
299
- },
300
- }, actor);
301
- if (!entity.ok)
302
- return PluginErr(entity.error.message);
303
- await this.db.insert(channelEntityMap).values({
304
- organizationId: orgId,
305
- storeId,
306
- kind: "entity",
307
- externalId: item.externalId,
308
- entityId: entity.value.id,
309
- syncHash: hash(item),
310
- });
311
- for (const sourceVariant of item.variants) {
312
- const variant = await this.catalog.createVariant({
313
- entityId: entity.value.id,
314
- options: {},
315
- ...(sourceVariant.sku !== undefined ? { sku: sourceVariant.sku } : {}),
316
- ...(sourceVariant.barcode !== undefined ? { barcode: sourceVariant.barcode } : {}),
1881
+ else {
1882
+ const status = item.status;
1883
+ const entity = await this.catalog.create({
1884
+ type: "product",
1885
+ slug: item.slug,
1886
+ sourceStoreId: storeId,
1887
+ metadata: mergeMetadata(undefined, item.metadata ?? {}),
1888
+ ...(status !== undefined ? { status, isVisible: status === "active" } : {}),
317
1889
  }, actor);
318
- if (!variant.ok)
319
- return PluginErr(variant.error.message);
1890
+ if (!entity.ok)
1891
+ return PluginErr(entity.error.message);
1892
+ entityId = entity.value.id;
1893
+ isNew = true;
1894
+ imported += 1;
1895
+ entityTouched = true;
1896
+ }
1897
+ const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
1898
+ const seedPaths = importedFieldPaths(item).filter((path) => !ownershipBeforeSeed.has(path));
1899
+ const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
1900
+ if (!seeded.ok)
1901
+ return PluginErr(seeded.error.message);
1902
+ for (const path of seedPaths)
1903
+ ownershipBeforeSeed.set(path, "store");
1904
+ const owners = ownershipBeforeSeed;
1905
+ const outboundEcho = entityMapping ? this.isOutboundEcho(entityMapping, item) : false;
1906
+ const remoteChanged = entityMapping === undefined || entityMapping.syncHash !== remoteHash;
1907
+ // An unchanged remote item writes nothing and advances no baseline:
1908
+ // converging a stale replay would revert local edits to shared and
1909
+ // unowned fields that the store never actually changed.
1910
+ if (!force && !remoteChanged && existingEntity && existingEntity.status !== "archived") {
1911
+ continue;
1912
+ }
1913
+ const shared = existingEntity
1914
+ ? await this.detectSharedConflicts(entityId, storeId, existingEntity, entityMapping, item, owners, importedFieldPaths(item), remoteHash, outboundEcho ? { certifiedPaths: new Set(entityMapping?.outboundFieldPaths ?? []) } : undefined)
1915
+ : { paths: [], conflicts: [] };
1916
+ const persistedConflicts = await this.persistCatalogConflicts(orgId, shared.conflicts, requireUserId(actor));
1917
+ if (!persistedConflicts.ok)
1918
+ return persistedConflicts;
1919
+ const owned = this.filterOwnedFields(item, owners);
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));
1925
+ const held = this.filterConflictingFields(owned.writable, heldSharedPaths);
1926
+ const writable = held.writable;
1927
+ const blockedPaths = new Set([...owned.skipped, ...heldSharedPaths]);
1928
+ skipped.push(...owned.skipped.map((fieldPath) => ({ entityId, fieldPath })));
1929
+ conflicts.push(...shared.conflicts.map(({ platformValue: _platformValue, storeValue: _storeValue, ...conflict }) => conflict));
1930
+ for (const conflict of shared.conflicts) {
1931
+ warnings.push(`Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
1932
+ }
1933
+ if (existingEntity && entityMapping) {
1934
+ const remoteMetadata = mergeMetadata(existingEntity.metadata, writable.metadata ?? {});
1935
+ const remoteStatus = ownerAllows(owners, "entity.status") && !blockedPaths.has("entity.status")
1936
+ ? writable.status ?? (existingEntity.status === "archived" ? "active" : undefined)
1937
+ : undefined;
1938
+ const updateInput = {};
1939
+ if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== writable.slug) {
1940
+ updateInput.slug = writable.slug;
1941
+ }
1942
+ if (hash(remoteMetadata) !== hash(existingEntity.metadata ?? {}))
1943
+ updateInput.metadata = remoteMetadata;
1944
+ if (remoteStatus !== undefined && !blockedPaths.has("entity.status") && remoteStatus !== existingEntity.status) {
1945
+ updateInput.status = remoteStatus;
1946
+ updateInput.isVisible = remoteStatus === "active";
1947
+ }
1948
+ const shouldUpdate = force
1949
+ ? Object.keys(updateInput).length > 0
1950
+ : remoteChanged || existingEntity.status === "archived";
1951
+ if (shouldUpdate) {
1952
+ converged += 1;
1953
+ if (Object.keys(updateInput).length > 0) {
1954
+ const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
1955
+ if (!updated.ok)
1956
+ return PluginErr(updated.error.message);
1957
+ entityTouched = true;
1958
+ }
1959
+ }
1960
+ }
1961
+ const optionAxes = await this.upsertOptionAxes(entityId, writable, actor);
1962
+ if (!optionAxes.ok)
1963
+ return optionAxes;
1964
+ const attributes = await this.setCatalogAttributesIfWritable(entityId, writable, actor, blockedPaths, CHANNEL_CONVERGENCE_CTX);
1965
+ if (!attributes.ok)
1966
+ return attributes;
1967
+ const variantIds = await this.upsertVariants(orgId, storeId, entityId, writable, optionAxes.value.value, actor, warnings, !heldSharedPaths.includes("options") && owners.get("options") !== "platform", item);
1968
+ if (!variantIds.ok)
1969
+ return variantIds;
1970
+ const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
1971
+ if (!taxonomy.ok)
1972
+ return taxonomy;
1973
+ const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
1974
+ if (!media.ok)
1975
+ return media;
1976
+ attributesCreated += attributes.value.created;
1977
+ mediaImported += media.value.imported;
1978
+ variantsGivenOptionValues += variantIds.value.repaired;
1979
+ skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
1980
+ entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
1981
+ if (entityTouched)
1982
+ entitiesTouched += 1;
1983
+ if (entityTouched) {
1984
+ const revision = await this.catalog.recordEntityRevision(entityId, actor, "import");
1985
+ if (!revision.ok)
1986
+ return PluginErr(revision.error.message);
1987
+ }
1988
+ const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
1989
+ const latestRevisionAt = revisionMarkers.at(-1)?.createdAt;
1990
+ const lastSyncedAt = latestRevisionAt ?? entityMapping?.lastSyncedAt ?? new Date();
1991
+ if (isNew) {
320
1992
  await this.db.insert(channelEntityMap).values({
321
1993
  organizationId: orgId,
322
1994
  storeId,
323
- kind: "variant",
324
- externalId: sourceVariant.externalId,
325
- entityId: entity.value.id,
326
- variantId: variant.value.id,
327
- syncHash: hash(sourceVariant),
1995
+ kind: "entity",
1996
+ externalId: item.externalId,
1997
+ entityId,
1998
+ syncHash: remoteHash,
1999
+ lastSyncedAt,
2000
+ heldFieldPaths: heldSharedPaths,
2001
+ forcedPushFieldPaths: survivingForcedPaths,
328
2002
  });
329
2003
  }
330
- imported += 1;
2004
+ else if (entityMapping) {
2005
+ await this.db.update(channelEntityMap).set({
2006
+ syncHash: remoteHash,
2007
+ lastSyncedAt,
2008
+ heldFieldPaths: heldSharedPaths,
2009
+ forcedPushFieldPaths: survivingForcedPaths,
2010
+ }).where(eq(channelEntityMap.id, entityMapping.id));
2011
+ }
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")));
331
2013
  }
332
- return Ok({ imported, converged });
2014
+ return Ok({
2015
+ imported,
2016
+ converged,
2017
+ entitiesTouched,
2018
+ attributesCreated,
2019
+ mediaImported,
2020
+ variantsGivenOptionValues,
2021
+ skipped,
2022
+ conflicts,
2023
+ warnings,
2024
+ });
333
2025
  }
334
2026
  async reconcile(orgId, storeId, actor) {
335
2027
  const store = await this.getStoreRecord(orgId, storeId);
@@ -354,11 +2046,17 @@ export class ChannelConnectorService {
354
2046
  return converged;
355
2047
  const present = new Set(items.map((item) => item.externalId));
356
2048
  let archived = 0;
2049
+ const skipped = [...converged.value.skipped];
357
2050
  for (const mapping of entityMappings) {
358
2051
  if (present.has(mapping.externalId))
359
2052
  continue;
360
2053
  const [entity] = await this.db.select({ status: sellableEntities.status }).from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, mapping.entityId)));
361
2054
  if (entity?.status !== "archived") {
2055
+ const owners = await this.catalog.resolveFieldOwners(mapping.entityId, storeId);
2056
+ if (owners.get("entity.status") === "platform") {
2057
+ skipped.push({ entityId: mapping.entityId, fieldPath: "entity.status" });
2058
+ continue;
2059
+ }
362
2060
  const result = await this.catalog.archive(mapping.entityId, actor);
363
2061
  if (!result.ok)
364
2062
  return PluginErr(result.error.message);
@@ -389,12 +2087,17 @@ export class ChannelConnectorService {
389
2087
  inventoryUpdated += 1;
390
2088
  }
391
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")));
392
2091
  const report = {
393
2092
  imported: converged.value.imported,
394
2093
  converged: converged.value.converged,
395
2094
  archived,
396
2095
  inventoryUpdated,
2096
+ openConflicts: openConflictRows.length,
397
2097
  driftAlert: converged.value.imported + converged.value.converged + archived > threshold,
2098
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
2099
+ ...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
2100
+ ...(converged.value.warnings.length > 0 ? { warnings: converged.value.warnings } : {}),
398
2101
  };
399
2102
  await this.db.update(connectedStores).set({
400
2103
  lastReconcileAt: new Date(),
@@ -411,6 +2114,134 @@ export class ChannelConnectorService {
411
2114
  const report = store.lastReconcileReport;
412
2115
  return Ok({ lastReconcileAt: store.lastReconcileAt, report, driftAlert: report?.driftAlert ?? false });
413
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
+ }
414
2245
  async syncInventory(orgId, storeId, actor) {
415
2246
  const store = await this.getStoreRecord(orgId, storeId);
416
2247
  if (!store || store.status !== "connected")
@@ -447,19 +2278,34 @@ export class ChannelConnectorService {
447
2278
  return PluginErr("Connected store not found.", "NOT_FOUND");
448
2279
  const actor = createSystemActor(orgId);
449
2280
  const data = event.data;
2281
+ let skipped = [];
2282
+ let conflicts = [];
2283
+ let warnings = [];
450
2284
  if (event.type === "products/update") {
451
2285
  const productId = String(data.id ?? data.product_id ?? "");
452
2286
  const mapping = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, productId)));
453
- if (mapping[0])
454
- await this.convergeCatalogItem(orgId, storeId, mapping[0].entityId, data, actor);
2287
+ if (mapping[0]) {
2288
+ const converged = await this.convergeCatalogItem(orgId, storeId, mapping[0].entityId, data, actor);
2289
+ if (!converged.ok)
2290
+ return converged;
2291
+ skipped = converged.value.skipped;
2292
+ conflicts = converged.value.conflicts;
2293
+ warnings = converged.value.warnings;
2294
+ }
455
2295
  }
456
2296
  else if (event.type === "products/delete") {
457
2297
  const productId = String(data.id ?? data.product_id ?? "");
458
2298
  const mapping = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, productId)));
459
2299
  if (mapping[0]) {
460
- const archived = await this.catalog.archive(mapping[0].entityId, actor);
461
- if (!archived.ok)
462
- return PluginErr(archived.error.message);
2300
+ const owners = await this.catalog.resolveFieldOwners(mapping[0].entityId, storeId);
2301
+ if (owners.get("entity.status") === "platform") {
2302
+ skipped.push({ entityId: mapping[0].entityId, fieldPath: "entity.status" });
2303
+ }
2304
+ else {
2305
+ const archived = await this.catalog.archive(mapping[0].entityId, actor);
2306
+ if (!archived.ok)
2307
+ return PluginErr(archived.error.message);
2308
+ }
463
2309
  }
464
2310
  }
465
2311
  else if (event.type === "inventory_levels/update") {
@@ -513,6 +2359,15 @@ export class ChannelConnectorService {
513
2359
  return disconnected;
514
2360
  return Ok({ processed: true });
515
2361
  }
2362
+ if (skipped.length > 0 || conflicts.length > 0 || warnings.length > 0) {
2363
+ const report = {
2364
+ ...(store.lastReconcileReport ?? {}),
2365
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
2366
+ ...(conflicts.length > 0 ? { conflicts } : {}),
2367
+ ...(warnings.length > 0 ? { warnings } : {}),
2368
+ };
2369
+ await this.db.update(connectedStores).set({ lastReconcileReport: report, updatedAt: new Date() }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
2370
+ }
516
2371
  return Ok({ processed: true });
517
2372
  }
518
2373
  complianceEmail(data) {
@@ -571,6 +2426,122 @@ export class ChannelConnectorService {
571
2426
  }
572
2427
  async convergeCatalogItem(orgId, storeId, entityId, data, actor) {
573
2428
  const product = data.product && typeof data.product === "object" ? data.product : data;
2429
+ const remoteMetadata = product.metadata && typeof product.metadata === "object" && !Array.isArray(product.metadata)
2430
+ ? product.metadata
2431
+ : {};
2432
+ const [mapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.entityId, entityId)));
2433
+ const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entityId)));
2434
+ if (!mapping || !entity)
2435
+ return Ok({ skipped: [], conflicts: [], warnings: [] });
2436
+ const [currentAttribute] = await this.db.select().from(sellableAttributes).where(and(eq(sellableAttributes.entityId, entityId), eq(sellableAttributes.locale, "en")));
2437
+ const title = typeof product.title === "string" ? product.title : currentAttribute?.title ?? entity.slug;
2438
+ const description = product.description !== undefined
2439
+ ? String(product.description)
2440
+ : currentAttribute?.description ?? undefined;
2441
+ const status = typeof product.status === "string" && ["draft", "active", "archived", "discontinued"].includes(product.status)
2442
+ ? product.status
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
+ : [];
2457
+ const remoteItem = {
2458
+ externalId: mapping.externalId,
2459
+ slug: typeof product.slug === "string" ? product.slug : entity.slug,
2460
+ title,
2461
+ ...(description !== undefined ? { description } : {}),
2462
+ ...(status !== undefined ? { status } : {}),
2463
+ attributes: [{ locale: "en", title, ...(description !== undefined ? { description } : {}) }],
2464
+ ...(Object.keys(remoteMetadata).length > 0 ? { metadata: remoteMetadata } : {}),
2465
+ ...(customFields !== undefined ? { customFields } : {}),
2466
+ ...(images.length > 0 ? { images } : {}),
2467
+ variants: [],
2468
+ };
2469
+ const fieldPaths = [];
2470
+ if (typeof product.slug === "string")
2471
+ fieldPaths.push("entity.slug");
2472
+ if (status !== undefined)
2473
+ fieldPaths.push("entity.status");
2474
+ for (const key of Object.keys(remoteMetadata)) {
2475
+ const path = `entity.metadata.${key}`;
2476
+ if (isValidFieldPath(path))
2477
+ fieldPaths.push(path);
2478
+ }
2479
+ if (typeof product.title === "string")
2480
+ fieldPaths.push("attributes.en.title");
2481
+ if (product.description !== undefined)
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}`);
2494
+ const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
2495
+ const seedPaths = fieldPaths.filter((path) => !ownershipBeforeSeed.has(path));
2496
+ const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
2497
+ if (!seeded.ok)
2498
+ return PluginErr(seeded.error.message);
2499
+ for (const path of seedPaths)
2500
+ ownershipBeforeSeed.set(path, "store");
2501
+ const owners = ownershipBeforeSeed;
2502
+ const remoteHash = hash(product);
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;
2508
+ const owned = this.filterOwnedFieldsAtPaths(remoteItem, owners, fieldPaths);
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));
2514
+ const held = this.filterConflictingFields(owned.writable, heldPaths);
2515
+ const blockedPaths = new Set([
2516
+ ...owned.skipped,
2517
+ ...heldPaths,
2518
+ ...(!fieldPaths.includes("attributes.en.title") ? ["attributes.en.title"] : []),
2519
+ ]);
2520
+ const skipped = owned.skipped.map((fieldPath) => ({ entityId, fieldPath }));
2521
+ const conflicts = shared.conflicts.map(({ platformValue: _platformValue, storeValue: _storeValue, ...conflict }) => conflict);
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}).`);
2523
+ const writable = held.writable;
2524
+ const updateInput = {};
2525
+ if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && entity.slug !== writable.slug) {
2526
+ updateInput.slug = writable.slug;
2527
+ }
2528
+ if (Object.keys(writable.metadata ?? {}).length > 0) {
2529
+ const remoteEntityMetadata = mergeMetadata(entity.metadata, writable.metadata ?? {});
2530
+ if (hash(remoteEntityMetadata) !== hash(entity.metadata ?? {}))
2531
+ updateInput.metadata = remoteEntityMetadata;
2532
+ }
2533
+ if (fieldPaths.includes("entity.status") && ownerAllows(owners, "entity.status") && !blockedPaths.has("entity.status") && typeof writable.status === "string" && writable.status !== entity.status) {
2534
+ updateInput.status = writable.status;
2535
+ updateInput.isVisible = writable.status === "active";
2536
+ }
2537
+ if (Object.keys(updateInput).length > 0) {
2538
+ const updated = await this.catalog.update(entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
2539
+ if (!updated.ok)
2540
+ return PluginErr(updated.error.message);
2541
+ }
2542
+ const attributes = await this.setCatalogAttributesIfWritable(entityId, writable, actor, blockedPaths, CHANNEL_CONVERGENCE_CTX);
2543
+ if (!attributes.ok)
2544
+ return attributes;
574
2545
  const levels = Array.isArray(product.variants) ? product.variants : [];
575
2546
  for (const variant of levels) {
576
2547
  const externalId = String(variant.id ?? variant.variation_id ?? "");
@@ -578,6 +2549,15 @@ export class ChannelConnectorService {
578
2549
  if (externalId && available !== undefined)
579
2550
  await this.setMappedInventory(orgId, storeId, externalId, Number(available), actor);
580
2551
  }
2552
+ const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
2553
+ const lastSyncedAt = revisionMarkers.at(-1)?.createdAt ?? mapping.lastSyncedAt;
2554
+ await this.db.update(channelEntityMap).set({
2555
+ syncHash: remoteHash,
2556
+ lastSyncedAt,
2557
+ heldFieldPaths: heldPaths,
2558
+ forcedPushFieldPaths: survivingForcedPaths,
2559
+ }).where(eq(channelEntityMap.id, mapping.id));
2560
+ return Ok({ skipped, conflicts, warnings });
581
2561
  }
582
2562
  async createRefundRequest(orgId, store, data, actor) {
583
2563
  const remoteRefundId = String(data.id ?? data.refund_id ?? "");
@@ -613,9 +2593,9 @@ export class ChannelConnectorService {
613
2593
  const max = this.options.refundAutoMax ?? order.amountCaptured ?? order.grandTotal;
614
2594
  const ageOk = Date.now() - store.createdAt.getTime() >= (this.options.newStoreDays ?? 7) * 86_400_000;
615
2595
  const auto = clean && amount > 0 && ageOk && amount <= max;
616
- 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();
617
2597
  const request = rows[0];
618
- 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) });
619
2599
  if (auto) {
620
2600
  const result = await this.executeRefund(request, refundLines, actor);
621
2601
  if (!result.ok)
@@ -629,7 +2609,7 @@ export class ChannelConnectorService {
629
2609
  if (!result.ok)
630
2610
  return PluginErr(result.error?.message ?? "Refund execution failed.");
631
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();
632
- 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) });
633
2613
  return Ok(updated);
634
2614
  }
635
2615
  async listRefundRequests(orgId) {
@@ -728,7 +2708,7 @@ export class ChannelConnectorService {
728
2708
  if (created.value.state === "confirmed")
729
2709
  return created;
730
2710
  if (created.value.state !== "exported") {
731
- 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.");
732
2712
  if (!exported.ok)
733
2713
  return exported;
734
2714
  }
@@ -738,7 +2718,7 @@ export class ChannelConnectorService {
738
2718
  .where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.id, created.value.id)));
739
2719
  const pushed = await connector.pushOrder(store, slice);
740
2720
  if (!pushed.ok) {
741
- 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");
742
2722
  }
743
2723
  await this.db
744
2724
  .update(channelOrderExports)
@@ -750,13 +2730,13 @@ export class ChannelConnectorService {
750
2730
  .where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.id, created.value.id)));
751
2731
  const remoteStatus = await connector.fetchOrderStatus(store, pushed.value.remoteOrderId);
752
2732
  if (!remoteStatus.ok) {
753
- 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");
754
2734
  }
755
2735
  if (remoteStatus.value.status === "confirmed") {
756
- 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.");
757
2737
  }
758
2738
  if (remoteStatus.value.status === "failed" || remoteStatus.value.status === "cancelled") {
759
- 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}.`);
760
2740
  }
761
2741
  const refreshed = await this.getExport(orgId, created.value.id);
762
2742
  return refreshed;
@@ -845,4 +2825,347 @@ export class ChannelConnectorService {
845
2825
  abandonExport(orgId, exportId, changedBy, reason) {
846
2826
  return this.transitionExport(orgId, exportId, "abandoned", changedBy, reason);
847
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
+ }
848
3171
  }