@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/src/service.ts CHANGED
@@ -4,42 +4,179 @@ import {
4
4
  CommerceValidationError,
5
5
  Ok,
6
6
  PluginErr,
7
+ createTxContext,
7
8
  createSystemActor,
8
9
  } from "@porulle/core";
9
10
  import type {
10
11
  Actor,
11
12
  ChannelCatalogItem,
12
13
  ChannelConnector,
13
- ChannelInventoryLevel,
14
14
  ChannelOrderSlice,
15
+ ChannelPushCatalogField,
16
+ ChannelPushCatalogImage,
17
+ ChannelPushCatalogIntent,
18
+ ChannelPushCatalogItem,
19
+ ChannelPushCatalogItemOutcome,
20
+ ChannelPushCatalogResult,
15
21
  ChannelStore,
16
22
  PluginDb,
17
23
  PluginResult,
18
24
  PluginTxFn,
25
+ CatalogWriteContext,
26
+ TxContext,
19
27
  } from "@porulle/core";
28
+ import { isValidFieldPath, requireUserId } from "@porulle/core";
29
+ import type { FieldOwner, FieldPath } from "@porulle/core";
20
30
  import type { JobsAdapter } from "@porulle/core";
21
- import { and, eq, inArray } from "@porulle/core/drizzle";
22
- import { customerAddresses, customers, inventoryLevels, orderLineItems, orders, sellableEntities } from "@porulle/core/schema";
31
+ import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
32
+ import { and, desc, eq, inArray, isNull, lte } from "@porulle/core/drizzle";
23
33
  import {
34
+ brands,
35
+ categories,
36
+ customerAddresses,
37
+ customers,
38
+ entityMedia,
39
+ entityTags,
40
+ inventoryLevels,
41
+ mediaAssets,
42
+ optionTypes,
43
+ optionValues,
44
+ orderLineItems,
45
+ orders,
46
+ prices,
47
+ sellableAttributes,
48
+ sellableCustomFields,
49
+ sellableEntities,
50
+ sellableEntityRevisions,
51
+ entityFieldDefinitions,
52
+ tags,
53
+ variants,
54
+ variantOptionValues,
55
+ } from "@porulle/core/schema";
56
+ import type { SellableEntityRevisionSnapshot } from "@porulle/core/schema";
57
+ import {
58
+ channelCatalogPushEvents,
59
+ channelCatalogPushes,
60
+ channelCatalogConflicts,
61
+ channelCatalogConflictEvents,
24
62
  channelEntityMap,
25
63
  channelExportEvents,
26
64
  channelOrderExports,
27
65
  connectedStores,
28
66
  channelRefundEvents,
29
67
  channelRefundRequests,
68
+ type ChannelCatalogPush,
69
+ type ChannelCatalogConflict,
30
70
  type ChannelOrderExport,
31
71
  type ChannelRefundRequest,
32
72
  type ConnectedStore,
33
73
  } from "./schema.js";
74
+ import {
75
+ mergeCatalogFieldMapping,
76
+ normalizeCatalogFieldMapping,
77
+ selectCatalogFieldMapping,
78
+ type CatalogFieldMapping,
79
+ type CatalogFieldMappingInput,
80
+ type CatalogFieldTarget,
81
+ } from "./catalog-field-mapping.js";
34
82
 
35
83
  export type ExportState = ChannelOrderExport["state"];
84
+ export type CatalogPushState = ChannelCatalogPush["state"];
85
+ export type CatalogConflictState = ChannelCatalogConflict["state"];
86
+
87
+ export const CATALOG_PUSH_BATCH_SIZES: Record<string, number> = {
88
+ mock: 100,
89
+ shopify: 50,
90
+ woocommerce: 100,
91
+ };
92
+
93
+ const DEFAULT_CATALOG_PUSH_BATCH_SIZE = 50;
94
+ const CATALOG_PUSH_BREAKER_RETRY_MS = 60_000;
95
+ export const CATALOG_PUSH_MAX_ATTEMPTS = 8;
96
+ const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
97
+ const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
98
+
99
+ export function catalogPushRetryDelayMs(attempts: number): number {
100
+ const exponent = Math.max(0, attempts - 1);
101
+ return Math.min(CATALOG_PUSH_RETRY_BASE_MS * (2 ** exponent), CATALOG_PUSH_RETRY_MAX_MS);
102
+ }
103
+
104
+ export interface CatalogPushJobResult extends Record<string, unknown> {
105
+ noop?: boolean;
106
+ rescheduled?: boolean;
107
+ complete?: boolean;
108
+ cursor?: string;
109
+ pushed?: number;
110
+ failed?: number;
111
+ }
112
+
113
+ export function catalogPushConcurrencyKey(input: Record<string, unknown>): string {
114
+ const storeId = String(input.storeId);
115
+ const entityIds = input.entityIds;
116
+ if (Array.isArray(entityIds) && entityIds.length === 1) {
117
+ return `push:${String(entityIds[0])}:${storeId}`;
118
+ }
119
+ return `push-catalog:${storeId}`;
120
+ }
121
+
122
+ export function isCatalogPushBreakerOpen(breakerState: Record<string, unknown>): boolean {
123
+ if (breakerState.open === true) {
124
+ const openUntil = breakerState.openUntil;
125
+ if (typeof openUntil === "string" && new Date(openUntil) <= new Date()) return false;
126
+ return true;
127
+ }
128
+ const catalogPush = breakerState.catalogPush;
129
+ if (!catalogPush || typeof catalogPush !== "object") return false;
130
+ const state = catalogPush as { open?: boolean; openUntil?: string };
131
+ if (state.open !== true) return false;
132
+ if (typeof state.openUntil === "string" && new Date(state.openUntil) <= new Date()) return false;
133
+ return true;
134
+ }
135
+
136
+ function catalogPushBatchSize(provider: string): number {
137
+ return CATALOG_PUSH_BATCH_SIZES[provider] ?? DEFAULT_CATALOG_PUSH_BATCH_SIZE;
138
+ }
36
139
 
37
140
  export interface ReconcileReport extends Record<string, unknown> {
38
141
  imported: number;
39
142
  converged: number;
40
143
  archived: number;
41
144
  inventoryUpdated: number;
145
+ openConflicts: number;
42
146
  driftAlert: boolean;
147
+ skipped?: CatalogFieldSkip[];
148
+ conflicts?: CatalogFieldConflict[];
149
+ warnings?: string[];
150
+ }
151
+
152
+ export interface CatalogFieldConflict {
153
+ entityId: string;
154
+ storeId: string;
155
+ fieldPath: FieldPath;
156
+ localValueSummary: string;
157
+ remoteValueSummary: string;
158
+ }
159
+
160
+ interface DetectedCatalogFieldConflict extends CatalogFieldConflict {
161
+ platformValue: unknown;
162
+ storeValue: unknown;
163
+ }
164
+
165
+ export interface CatalogFieldSkip {
166
+ entityId: string;
167
+ fieldPath: FieldPath;
168
+ }
169
+
170
+ export type CatalogPushSkipReason = "no_mapping" | "held" | "store_owned" | "entity_not_active" | "unmapped_entity";
171
+
172
+ export interface CatalogPushFieldSkip {
173
+ entityId: string;
174
+ fieldPath: FieldPath;
175
+ reason: CatalogPushSkipReason;
176
+ value?: unknown;
177
+ owner?: FieldOwner;
178
+ target?: CatalogFieldTarget;
179
+ remoteKey?: string;
43
180
  }
44
181
 
45
182
  export type PublicConnectedStore = Omit<ConnectedStore, "credentials" | "webhookSecret"> & {
@@ -47,6 +184,13 @@ export type PublicConnectedStore = Omit<ConnectedStore, "credentials" | "webhook
47
184
  webhookSecret: "[REDACTED]";
48
185
  };
49
186
 
187
+ export interface CatalogWriteSettings {
188
+ enabled: boolean;
189
+ overrides: CatalogFieldMapping;
190
+ merged: CatalogFieldMapping;
191
+ warnings?: string[];
192
+ }
193
+
50
194
  export interface ChannelComplianceData {
51
195
  customer: { id?: string; email?: string };
52
196
  exports: Array<{
@@ -75,11 +219,118 @@ export interface ChannelStockLine {
75
219
  quantity: number;
76
220
  }
77
221
 
222
+ interface BackfillCounts {
223
+ entitiesTouched: number;
224
+ attributesCreated: number;
225
+ mediaImported: number;
226
+ variantsGivenOptionValues: number;
227
+ }
228
+
229
+ export interface BackfillCatalogReport extends BackfillCounts, Record<string, unknown> {
230
+ cursor: string | null;
231
+ complete: boolean;
232
+ skipped?: CatalogFieldSkip[];
233
+ conflicts?: CatalogFieldConflict[];
234
+ warnings?: string[];
235
+ }
236
+
237
+ interface CatalogConvergenceStats {
238
+ imported: number;
239
+ converged: number;
240
+ entitiesTouched: number;
241
+ attributesCreated: number;
242
+ mediaImported: number;
243
+ variantsGivenOptionValues: number;
244
+ skipped: CatalogFieldSkip[];
245
+ conflicts: CatalogFieldConflict[];
246
+ warnings: string[];
247
+ }
248
+
249
+ export interface BackfillCatalogOptions {
250
+ dryRun?: boolean;
251
+ resume?: boolean;
252
+ maxPages?: number;
253
+ }
254
+
255
+ export interface BuildCatalogPushItemsOptions {
256
+ recordRevision?: boolean;
257
+ forceFieldPaths?: Record<string, FieldPath[]>;
258
+ }
259
+
260
+ export interface CatalogPushAssemblyField extends ChannelPushCatalogField {
261
+ target: CatalogFieldTarget;
262
+ }
263
+
264
+ export interface CatalogPushAssemblyImage extends ChannelPushCatalogImage {
265
+ fieldPath: FieldPath;
266
+ target: CatalogFieldTarget;
267
+ remoteKey: string;
268
+ }
269
+
270
+ export interface CatalogPushAssemblyItem extends Omit<ChannelPushCatalogItem, "fields" | "images"> {
271
+ fields: CatalogPushAssemblyField[];
272
+ images?: CatalogPushAssemblyImage[];
273
+ }
274
+
275
+ export interface BuildCatalogPushItemsResult {
276
+ items: CatalogPushAssemblyItem[];
277
+ skipped: CatalogPushFieldSkip[];
278
+ warnings: string[];
279
+ }
280
+
281
+ export interface PushCatalogToStoreResult extends ChannelPushCatalogResult {
282
+ skipped: CatalogPushFieldSkip[];
283
+ warnings: string[];
284
+ }
285
+
286
+ export interface CatalogPushPreviewUnavailable {
287
+ status: "unavailable";
288
+ }
289
+
290
+ export type CatalogPushPreviewBefore = unknown | CatalogPushPreviewUnavailable;
291
+ export type CatalogPushPreviewBeforeStatus = "value" | "missing" | "unavailable";
292
+
293
+ export interface CatalogPushPreviewDiff {
294
+ fieldPath: FieldPath;
295
+ target: CatalogFieldTarget | null;
296
+ remoteKey: string | null;
297
+ before: CatalogPushPreviewBefore;
298
+ beforeStatus: CatalogPushPreviewBeforeStatus;
299
+ after: unknown;
300
+ owner: FieldOwner;
301
+ willWrite: boolean;
302
+ reason?: CatalogPushSkipReason;
303
+ }
304
+
305
+ export interface CatalogPushPreviewItem {
306
+ externalId: string;
307
+ diffs: CatalogPushPreviewDiff[];
308
+ }
309
+
310
+ export interface CatalogPushPreviewResult {
311
+ items: CatalogPushPreviewItem[];
312
+ skipped: CatalogPushFieldSkip[];
313
+ warnings: string[];
314
+ }
315
+
316
+ interface BackfillState {
317
+ cursor: string | null;
318
+ report: BackfillCounts;
319
+ skipped?: CatalogFieldSkip[];
320
+ conflicts?: CatalogFieldConflict[];
321
+ warnings?: string[];
322
+ completedAt?: string;
323
+ }
324
+
78
325
  interface CatalogService {
326
+ repository: {
327
+ findRevisionMarkers(entityId: string, since?: Date): Promise<Array<{ createdAt: Date; reason: string }>>;
328
+ };
79
329
  update(
80
330
  id: string,
81
- input: { slug?: string; status?: string; metadata?: Record<string, unknown>; isVisible?: boolean },
331
+ input: { slug?: string; status?: string; metadata?: Record<string, unknown>; isVisible?: boolean; customFields?: Record<string, unknown | null> },
82
332
  actor: Actor,
333
+ ctx?: CatalogWriteContext,
83
334
  ): Promise<{ ok: true; value: unknown } | { ok: false; error: { message: string } }>;
84
335
  archive(id: string, actor: Actor): Promise<{ ok: true; value: unknown } | { ok: false; error: { message: string } }>;
85
336
  create(
@@ -88,6 +339,8 @@ interface CatalogService {
88
339
  slug: string;
89
340
  sourceStoreId: string;
90
341
  metadata: Record<string, unknown>;
342
+ status?: string;
343
+ isVisible?: boolean;
91
344
  },
92
345
  actor: Actor,
93
346
  ): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
@@ -95,6 +348,108 @@ interface CatalogService {
95
348
  input: { entityId: string; options: Record<string, string>; sku?: string; barcode?: string },
96
349
  actor: Actor,
97
350
  ): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
351
+ setAttributes(
352
+ entityId: string,
353
+ locale: string,
354
+ attrs: {
355
+ title: string;
356
+ subtitle?: string;
357
+ description?: string;
358
+ richDescription?: unknown;
359
+ seoTitle?: string;
360
+ seoDescription?: string;
361
+ },
362
+ actor: Actor,
363
+ ctx?: CatalogWriteContext,
364
+ ): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
365
+ recordEntityRevision(
366
+ entityId: string,
367
+ actor: Actor,
368
+ reason: "import" | "push",
369
+ ctx?: TxContext<PluginDb>,
370
+ ): Promise<{ ok: true; value: unknown } | { ok: false; error: { message: string } }>;
371
+ resolveFieldOwners(entityId: string, storeId: string): Promise<Map<FieldPath, FieldOwner>>;
372
+ setFieldOwner(
373
+ entityId: string,
374
+ fieldPath: FieldPath,
375
+ storeId: string | null,
376
+ owner: FieldOwner,
377
+ actor: Actor | null,
378
+ ): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
379
+ seedImportedFieldOwnership(entityId: string, storeId: string, fieldPaths: FieldPath[]): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
380
+ createOptionType(
381
+ input: { entityId: string; name: string; values?: string[] },
382
+ actor: Actor,
383
+ ): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
384
+ createOptionValue(
385
+ input: { optionTypeId: string; value: string },
386
+ actor: Actor,
387
+ ): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
388
+ createCategory(
389
+ input: { slug: string },
390
+ actor: Actor,
391
+ ): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
392
+ addToCategory(
393
+ entityId: string,
394
+ categoryId: string,
395
+ actor: Actor,
396
+ ): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
397
+ createBrand(
398
+ input: { slug: string; displayName: string },
399
+ actor: Actor,
400
+ ): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
401
+ addToBrand(
402
+ entityId: string,
403
+ brandId: string,
404
+ actor: Actor,
405
+ ): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
406
+ }
407
+
408
+ type ServiceResult<T> =
409
+ | { ok: true; value: T }
410
+ | { ok: false; error: { message: string; code?: string } };
411
+
412
+ interface MediaService {
413
+ upload(
414
+ input: {
415
+ filename: string;
416
+ contentType: string;
417
+ data: ArrayBuffer;
418
+ alt?: string;
419
+ metadata?: Record<string, unknown>;
420
+ origin?: "merchant" | "generated" | "imported";
421
+ },
422
+ actor: Actor,
423
+ ): Promise<ServiceResult<{ id: string; url: string }>>;
424
+ attachToEntity(
425
+ input: {
426
+ entityId: string;
427
+ mediaAssetId: string;
428
+ role: "primary" | "gallery" | "thumbnail" | "video" | "document";
429
+ variantId?: string;
430
+ sortOrder?: number;
431
+ },
432
+ actor: Actor,
433
+ ): Promise<ServiceResult<undefined>>;
434
+ listEntityMedia(
435
+ entityId: string,
436
+ opts?: { variantId?: string; orgId?: string },
437
+ ): Promise<ServiceResult<Array<{
438
+ mediaAssetId: string;
439
+ role: string;
440
+ sortOrder: number;
441
+ variantId: string | null;
442
+ url: string;
443
+ alt: string | null;
444
+ contentType: string;
445
+ }>>>;
446
+ }
447
+
448
+ interface PricingService {
449
+ setBasePrice(
450
+ input: { entityId: string; variantId?: string; currency: string; amount: number; compareAtAmount?: number | null },
451
+ actor: Actor,
452
+ ): Promise<ServiceResult<unknown>>;
98
453
  }
99
454
 
100
455
  const exportTransitions: Record<ExportState, readonly ExportState[]> = {
@@ -109,10 +464,244 @@ export function canExportTransition(from: ExportState, to: ExportState): boolean
109
464
  return exportTransitions[from].includes(to);
110
465
  }
111
466
 
467
+ // Catalog pushes recur; confirmed/failed rows re-arm through exported, and rows with nothing to push resolve directly.
468
+ const catalogPushTransitions: Record<CatalogPushState, readonly CatalogPushState[]> = {
469
+ pending: ["exported", "confirmed", "abandoned"],
470
+ exported: ["confirmed", "failed", "abandoned"],
471
+ confirmed: ["exported", "abandoned"],
472
+ failed: ["exported", "confirmed", "abandoned"],
473
+ abandoned: [],
474
+ };
475
+
476
+ export function canCatalogPushTransition(from: CatalogPushState, to: CatalogPushState): boolean {
477
+ return catalogPushTransitions[from].includes(to);
478
+ }
479
+
112
480
  function hash(value: unknown): string {
113
481
  return createHash("sha256").update(JSON.stringify(value)).digest("hex");
114
482
  }
115
483
 
484
+ export const CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS = 15 * 60 * 1000;
485
+
486
+ function normalizeCanonicalValue(value: unknown): unknown {
487
+ if (typeof value === "string") return value.replace(/\r\n?/g, "\n").replace(/\s+/g, " ").trim();
488
+ if (Array.isArray(value)) return value.map(normalizeCanonicalValue).sort((left, right) => (JSON.stringify(left) ?? "").localeCompare(JSON.stringify(right) ?? ""));
489
+ if (value && typeof value === "object") {
490
+ return Object.fromEntries(Object.entries(value)
491
+ .sort(([left], [right]) => left.localeCompare(right))
492
+ .map(([key, nested]) => [key, normalizeCanonicalValue(nested)]));
493
+ }
494
+ return value;
495
+ }
496
+
497
+ function normalizedValuesEqual(left: unknown, right: unknown): boolean {
498
+ return JSON.stringify(normalizeCanonicalValue(left)) === JSON.stringify(normalizeCanonicalValue(right));
499
+ }
500
+
501
+ function snapshotCustomFieldValue(field: Record<string, unknown>): unknown {
502
+ if (field.textValue !== null && field.textValue !== undefined) return field.textValue;
503
+ if (field.numberValue !== null && field.numberValue !== undefined) return field.numberValue;
504
+ if (field.booleanValue !== null && field.booleanValue !== undefined) return field.booleanValue;
505
+ if (field.dateValue !== null && field.dateValue !== undefined) return field.dateValue;
506
+ return field.jsonValue;
507
+ }
508
+
509
+ function snapshotFieldValue(
510
+ snapshot: SellableEntityRevisionSnapshot,
511
+ path: FieldPath,
512
+ ): { found: boolean; value: unknown } {
513
+ const [root, segment, field] = path.split(".");
514
+ if (root === "entity" && segment === "slug") return { found: true, value: snapshot.entity.slug };
515
+ if (root === "entity" && segment === "status") return { found: true, value: snapshot.entity.status };
516
+ if (root === "entity" && segment === "metadata") {
517
+ const metadata = snapshot.entity.metadata;
518
+ return {
519
+ found: true,
520
+ value: metadata && typeof metadata === "object" ? (metadata as Record<string, unknown>)[field ?? ""] : undefined,
521
+ };
522
+ }
523
+ if (root === "attributes" && segment && field) {
524
+ const attribute = snapshot.attributes.find((row) => row.locale === segment);
525
+ return { found: true, value: attribute?.[field] };
526
+ }
527
+ if (root === "customFields" && segment && field) {
528
+ const customField = snapshot.customFields.find((row) => row.fieldName === segment && row.locale === field && row.status === "approved");
529
+ return { found: true, value: customField ? snapshotCustomFieldValue(customField) : undefined };
530
+ }
531
+ if (root === "media" && segment) {
532
+ return {
533
+ found: true,
534
+ value: snapshot.media.filter((row) => row.role === segment).map((row) => row.mediaAssetId),
535
+ };
536
+ }
537
+ return { found: false, value: undefined };
538
+ }
539
+
540
+ interface CanonicalOutboundField {
541
+ fieldPath: string;
542
+ value: unknown;
543
+ }
544
+
545
+ function canonicalHash(
546
+ externalId: string,
547
+ fieldPaths: FieldPath[],
548
+ valueAtPath: (fieldPath: FieldPath) => unknown,
549
+ ): string {
550
+ const fields: CanonicalOutboundField[] = fieldPaths.flatMap((fieldPath) => {
551
+ const value = valueAtPath(fieldPath);
552
+ return value === undefined ? [] : [{ fieldPath, value: normalizeCanonicalValue(value) }];
553
+ });
554
+ return hash({
555
+ externalId,
556
+ fields: fields.sort((left, right) => left.fieldPath.localeCompare(right.fieldPath)),
557
+ });
558
+ }
559
+
560
+ function outboundFieldPaths(item: ChannelPushCatalogItem): FieldPath[] {
561
+ const paths = new Set<FieldPath>(item.fields.flatMap((field) => isValidFieldPath(field.fieldPath) ? [field.fieldPath] : []));
562
+ for (const image of item.images ?? []) paths.add(`media.${image.role}`);
563
+ return [...paths].sort();
564
+ }
565
+
566
+ function pushFieldValue(item: ChannelPushCatalogItem, fieldPath: FieldPath): unknown {
567
+ if (fieldPath.startsWith("media.")) {
568
+ const role = fieldPath.slice("media.".length);
569
+ return (item.images ?? [])
570
+ .filter((image) => image.role === role)
571
+ .map((image) => ({ url: image.url, role: image.role }));
572
+ }
573
+ return item.fields.find((field) => field.fieldPath === fieldPath)?.value;
574
+ }
575
+
576
+ function canonicalOutboundHash(externalId: string, item: ChannelPushCatalogItem, fieldPaths: FieldPath[]): string {
577
+ return canonicalHash(externalId, fieldPaths, (fieldPath) => pushFieldValue(item, fieldPath));
578
+ }
579
+
580
+ function canonicalInboundHash(
581
+ externalId: string,
582
+ fieldPaths: FieldPath[],
583
+ remoteFieldValue: (path: FieldPath) => unknown,
584
+ ): string {
585
+ return canonicalHash(externalId, fieldPaths, remoteFieldValue);
586
+ }
587
+
588
+ function mergeMetadata(
589
+ existing: Record<string, unknown> | null | undefined,
590
+ remote: Record<string, unknown>,
591
+ ): Record<string, unknown> {
592
+ return { ...(existing ?? {}), ...remote };
593
+ }
594
+
595
+ const attributeFields = ["title", "subtitle", "description", "richDescription", "seoTitle", "seoDescription"] as const;
596
+ const pushImageRoles = ["primary", "gallery", "thumbnail", "video", "document"] as const;
597
+
598
+ function customFieldValue(field: typeof sellableCustomFields.$inferSelect): unknown {
599
+ switch (field.fieldType) {
600
+ case "text":
601
+ case "relation":
602
+ case "select":
603
+ return field.textValue;
604
+ case "number":
605
+ return field.numberValue;
606
+ case "boolean":
607
+ return field.booleanValue;
608
+ case "date":
609
+ return field.dateValue;
610
+ case "json":
611
+ return field.jsonValue;
612
+ default:
613
+ return null;
614
+ }
615
+ }
616
+
617
+ function pushCatalogIntent(
618
+ fieldPath: string,
619
+ target: "native" | "attribute" | "meta",
620
+ ): ChannelPushCatalogIntent {
621
+ if (fieldPath.startsWith("customFields.") && target === "attribute") return "filterable";
622
+ if (fieldPath.startsWith("customFields.") || fieldPath.startsWith("entity.metadata.")) return "tag";
623
+ return "display";
624
+ }
625
+
626
+ function pushCatalogField(
627
+ fieldPath: FieldPath,
628
+ value: unknown,
629
+ mapping: { target: "native" | "attribute" | "meta"; remoteKey: string },
630
+ ): CatalogPushAssemblyField {
631
+ const segments = fieldPath.split(".");
632
+ const locale = fieldPath.startsWith("attributes.")
633
+ ? segments[1]
634
+ : fieldPath.startsWith("customFields.")
635
+ ? segments[2]
636
+ : undefined;
637
+ return {
638
+ fieldPath,
639
+ intent: pushCatalogIntent(fieldPath, mapping.target),
640
+ value,
641
+ ...(locale !== undefined ? { locale } : {}),
642
+ remoteKey: mapping.remoteKey,
643
+ target: mapping.target,
644
+ };
645
+ }
646
+
647
+ function pushCatalogImageRole(value: string): ChannelPushCatalogImage["role"] | undefined {
648
+ return pushImageRoles.find((role) => role === value);
649
+ }
650
+
651
+ function importedFieldPaths(item: ChannelCatalogItem): FieldPath[] {
652
+ const paths = new Set<FieldPath>(["entity.slug"]);
653
+ if (item.status !== undefined) paths.add("entity.status");
654
+ for (const key of Object.keys(item.metadata ?? {})) {
655
+ const path = `entity.metadata.${key}`;
656
+ if (isValidFieldPath(path)) paths.add(path);
657
+ }
658
+ const attributes = item.attributes?.length
659
+ ? item.attributes
660
+ : [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
661
+ for (const attribute of attributes) {
662
+ for (const field of attributeFields) {
663
+ if (attribute[field] !== undefined) paths.add(`attributes.${attribute.locale}.${field}`);
664
+ }
665
+ }
666
+ const customFields = (item as ChannelCatalogItem & { customFields?: Record<string, Record<string, unknown>> }).customFields;
667
+ for (const [name, locales] of Object.entries(customFields ?? {})) {
668
+ if (!locales || typeof locales !== "object" || Array.isArray(locales)) continue;
669
+ for (const locale of Object.keys(locales)) {
670
+ const path = `customFields.${name}.${locale}`;
671
+ if (isValidFieldPath(path)) paths.add(path);
672
+ }
673
+ }
674
+ for (const image of item.images ?? []) paths.add(`media.${image.role}`);
675
+ if (item.options?.length) paths.add("options");
676
+ if (item.variants.some((variant) => variant.sku !== undefined)) paths.add("variants.sku");
677
+ if (item.variants.some((variant) => variant.barcode !== undefined)) paths.add("variants.barcode");
678
+ for (const currency of item.variants.flatMap((variant) => variant.prices ?? []).map((price) => price.currency)) {
679
+ const path = `prices.${currency}`;
680
+ if (isValidFieldPath(path)) paths.add(path);
681
+ }
682
+ return [...paths];
683
+ }
684
+
685
+ function summarizeValue(value: unknown): string {
686
+ const serialized = JSON.stringify(value);
687
+ if (serialized === undefined) return String(value);
688
+ return serialized.length > 256 ? `${serialized.slice(0, 253)}...` : serialized;
689
+ }
690
+
691
+ function uniqueSkipped(skipped: CatalogFieldSkip[]): CatalogFieldSkip[] {
692
+ const seen = new Set<string>();
693
+ return skipped.filter((entry) => {
694
+ const key = `${entry.entityId}:${entry.fieldPath}`;
695
+ if (seen.has(key)) return false;
696
+ seen.add(key);
697
+ return true;
698
+ });
699
+ }
700
+
701
+ function ownerAllows(owners: Map<FieldPath, FieldOwner>, path: FieldPath): boolean {
702
+ return owners.get(path) !== "platform";
703
+ }
704
+
116
705
  function stockFailure(line: ChannelStockLine, reason: string): string {
117
706
  return `Cannot checkout line "${line.title ?? line.entityId}": ${reason}.`;
118
707
  }
@@ -139,6 +728,8 @@ function redactStore(store: ConnectedStore): PublicConnectedStore {
139
728
  credentials: "[REDACTED]",
140
729
  storeDomain: store.storeDomain,
141
730
  status: store.status,
731
+ catalogWriteEnabled: store.catalogWriteEnabled,
732
+ catalogFieldMapping: store.catalogFieldMapping,
142
733
  catalogCursor: store.catalogCursor,
143
734
  inventoryCursor: store.inventoryCursor,
144
735
  lastSyncAt: store.lastSyncAt,
@@ -182,109 +773,1317 @@ export class ChannelConnectorService {
182
773
  return this.services.catalog as CatalogService;
183
774
  }
184
775
 
185
- private async getStoreRecord(orgId: string, id: string): Promise<ConnectedStore | undefined> {
186
- const rows = await this.db
187
- .select()
188
- .from(connectedStores)
189
- .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, id)));
190
- return rows[0] as ConnectedStore | undefined;
776
+ private get media(): MediaService {
777
+ return this.services.media as MediaService;
191
778
  }
192
779
 
193
- async getStoreByDomain(shopDomain: string): Promise<ConnectedStore | undefined> {
194
- const rows = await this.db
195
- .select()
196
- .from(connectedStores)
197
- .where(eq(connectedStores.storeDomain, shopDomain));
198
- return rows[0] as ConnectedStore | undefined;
780
+ private get pricing(): PricingService {
781
+ return this.services.pricing as PricingService;
199
782
  }
200
783
 
201
- // A shop_domain can map to more than one connected store (reconnect, or the same
202
- // shop under two orgs). Compliance webhooks must fan out to all of them.
203
- async getStoresByDomain(shopDomain: string): Promise<ConnectedStore[]> {
204
- const rows = await this.db
205
- .select()
206
- .from(connectedStores)
207
- .where(eq(connectedStores.storeDomain, shopDomain));
208
- return rows as ConnectedStore[];
784
+ private filterOwnedFields(
785
+ item: ChannelCatalogItem,
786
+ owners: Map<FieldPath, FieldOwner>,
787
+ ): { writable: ChannelCatalogItem; skipped: FieldPath[]; conflicts: FieldPath[] } {
788
+ return this.filterOwnedFieldsAtPaths(item, owners, importedFieldPaths(item));
209
789
  }
210
790
 
211
- async connectStore(
212
- orgId: string,
213
- input: {
214
- provider: string;
215
- credentials: Record<string, unknown>;
216
- storeDomain: string;
217
- webhookSecret?: string;
218
- },
219
- ): Promise<PluginResult<PublicConnectedStore>> {
220
- if (!this.connectors.has(input.provider)) {
221
- return PluginErr(`No connector registered for provider "${input.provider}".`, "NOT_FOUND");
222
- }
223
- const rows = await this.db
224
- .insert(connectedStores)
225
- .values({
226
- organizationId: orgId,
227
- provider: input.provider,
228
- credentials: input.credentials,
229
- storeDomain: input.storeDomain,
230
- webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
231
- })
232
- .returning();
233
- const connector = this.connectors.get(input.provider)!;
234
- const store = rows[0] as ConnectedStore;
235
- if (connector.registerWebhooks) {
236
- const registration = await connector.registerWebhooks(store as ChannelStore, [
237
- "products/update",
238
- "products/delete",
239
- "inventory_levels/update",
240
- "orders/fulfilled",
241
- "orders/cancelled",
242
- "refunds/create",
243
- "app/uninstalled",
244
- ], `/api/channels/webhooks/${store.id}`);
245
- if (!registration.ok) {
246
- await this.db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
247
- return PluginErr(registration.error.message, "CONNECTOR_REGISTRATION_FAILED");
791
+ private filterOwnedFieldsAtPaths(
792
+ item: ChannelCatalogItem,
793
+ owners: Map<FieldPath, FieldOwner>,
794
+ fieldPaths: FieldPath[],
795
+ ): { writable: ChannelCatalogItem; skipped: FieldPath[]; conflicts: FieldPath[] } {
796
+ const populated = new Set(fieldPaths);
797
+ const skipped = fieldPaths.filter((path) => owners.get(path) === "platform");
798
+ const blocked = new Set(skipped);
799
+ const attributes = (item.attributes?.length
800
+ ? item.attributes
801
+ : [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }])
802
+ .flatMap((attribute) => {
803
+ return [{
804
+ locale: attribute.locale,
805
+ title: attribute.title,
806
+ ...Object.fromEntries(attributeFields.slice(1)
807
+ .filter((field) => attribute[field] !== undefined
808
+ && populated.has(`attributes.${attribute.locale}.${field}`)
809
+ && !blocked.has(`attributes.${attribute.locale}.${field}`))
810
+ .map((field) => [field, attribute[field]])),
811
+ }];
812
+ });
813
+ const writable: ChannelCatalogItem = {
814
+ ...item,
815
+ attributes,
816
+ metadata: Object.fromEntries(Object.entries(item.metadata ?? {}).filter(([key]) => populated.has(`entity.metadata.${key}`) && !blocked.has(`entity.metadata.${key}`))),
817
+ ...(item.images !== undefined ? { images: item.images.filter((image) => populated.has(`media.${image.role}`) && !blocked.has(`media.${image.role}`)) } : {}),
818
+ ...(item.options !== undefined ? { options: blocked.has("options") ? [] : item.options } : {}),
819
+ variants: item.variants.map((variant) => ({
820
+ externalId: variant.externalId,
821
+ ...(variant.sku !== undefined && populated.has("variants.sku") && !blocked.has("variants.sku") ? { sku: variant.sku } : {}),
822
+ ...(variant.barcode !== undefined && populated.has("variants.barcode") && !blocked.has("variants.barcode") ? { barcode: variant.barcode } : {}),
823
+ ...(variant.metadata !== undefined ? { metadata: variant.metadata } : {}),
824
+ ...(variant.optionValues !== undefined && populated.has("options") && !blocked.has("options") ? { optionValues: variant.optionValues } : {}),
825
+ ...(variant.prices !== undefined
826
+ ? { prices: variant.prices.filter((price) => populated.has(`prices.${price.currency}`) && !blocked.has(`prices.${price.currency}`)) }
827
+ : {}),
828
+ })),
829
+ };
830
+ return { writable, skipped, conflicts: [] };
831
+ }
832
+
833
+ private filterConflictingFields(
834
+ item: ChannelCatalogItem,
835
+ conflicts: FieldPath[],
836
+ ): { writable: ChannelCatalogItem; conflicts: FieldPath[] } {
837
+ if (conflicts.length === 0) return { writable: item, conflicts: [] };
838
+ const blocked = new Set(conflicts);
839
+ const attributes = (item.attributes ?? []).flatMap((attribute) => {
840
+ return [{
841
+ locale: attribute.locale,
842
+ title: attribute.title,
843
+ ...Object.fromEntries(attributeFields.slice(1)
844
+ .filter((field) => attribute[field] !== undefined && !blocked.has(`attributes.${attribute.locale}.${field}`))
845
+ .map((field) => [field, attribute[field]])),
846
+ }];
847
+ });
848
+ return {
849
+ writable: {
850
+ ...item,
851
+ attributes,
852
+ metadata: Object.fromEntries(Object.entries(item.metadata ?? {}).filter(([key]) => !blocked.has(`entity.metadata.${key}`))),
853
+ ...(item.images !== undefined ? { images: item.images.filter((image) => !blocked.has(`media.${image.role}`)) } : {}),
854
+ ...(item.options !== undefined ? { options: blocked.has("options") ? [] : item.options } : {}),
855
+ variants: item.variants.map((variant) => ({
856
+ externalId: variant.externalId,
857
+ ...(variant.sku !== undefined && !blocked.has("variants.sku") ? { sku: variant.sku } : {}),
858
+ ...(variant.barcode !== undefined && !blocked.has("variants.barcode") ? { barcode: variant.barcode } : {}),
859
+ ...(variant.metadata !== undefined ? { metadata: variant.metadata } : {}),
860
+ ...(variant.optionValues !== undefined && !blocked.has("options") ? { optionValues: variant.optionValues } : {}),
861
+ ...(variant.prices !== undefined
862
+ ? { prices: variant.prices.filter((price) => !blocked.has(`prices.${price.currency}`)) }
863
+ : {}),
864
+ })),
865
+ },
866
+ conflicts,
867
+ };
868
+ }
869
+
870
+ private remoteFieldValue(item: ChannelCatalogItem, path: FieldPath): unknown {
871
+ const [root, segment, field] = path.split(".");
872
+ if (root === "entity" && segment === "slug") return item.slug;
873
+ if (root === "entity" && segment === "status") return item.status;
874
+ if (root === "entity" && segment === "metadata") return item.metadata?.[field ?? ""];
875
+ if (root === "customFields" && segment && field) {
876
+ const customFields = (item as ChannelCatalogItem & { customFields?: Record<string, unknown> }).customFields;
877
+ const customField = customFields?.[segment];
878
+ if (customField && typeof customField === "object" && !Array.isArray(customField)) {
879
+ return (customField as Record<string, unknown>)[field];
248
880
  }
249
881
  }
250
- const jobs = this.optionsJobs;
251
- if (jobs) {
252
- await jobs.enqueue("channel/import-catalog", { orgId, storeId: (rows[0] as ConnectedStore).id }, {
253
- organizationId: orgId,
254
- concurrencyKey: (rows[0] as ConnectedStore).id,
255
- supersedes: true,
256
- });
882
+ if (root === "attributes" && segment && field) {
883
+ const attributes = item.attributes?.length
884
+ ? item.attributes
885
+ : [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
886
+ const attribute = attributes.find((row) => row.locale === segment);
887
+ return attribute?.[field as keyof typeof attribute];
257
888
  }
258
- return Ok(redactStore(store));
889
+ if (root === "media" && segment) {
890
+ return (item.images ?? [])
891
+ .filter((image) => image.role === segment)
892
+ .map((image) => ({ url: image.url, role: image.role }));
893
+ }
894
+ if (path === "options") return item.options;
895
+ if (path === "variants.sku") return item.variants.map((variant) => variant.sku);
896
+ if (path === "variants.barcode") return item.variants.map((variant) => variant.barcode);
897
+ if (root === "prices" && segment) return item.variants.flatMap((variant) => variant.prices ?? []).filter((price) => price.currency === segment);
898
+ return undefined;
259
899
  }
260
900
 
261
- private get optionsJobs(): JobsAdapter | undefined {
262
- return this.jobs;
901
+ private isOutboundEcho(
902
+ mapping: typeof channelEntityMap.$inferSelect,
903
+ item: ChannelCatalogItem,
904
+ ): boolean {
905
+ if (!mapping.outboundHash || !mapping.outboundPushedAt || mapping.outboundFieldPaths.length === 0) return false;
906
+ const age = Date.now() - mapping.outboundPushedAt.getTime();
907
+ if (age < 0 || age > CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS) return false;
908
+ const inboundHash = canonicalInboundHash(mapping.externalId, mapping.outboundFieldPaths, (fieldPath) => this.remoteFieldValue(item, fieldPath));
909
+ return inboundHash === mapping.outboundHash;
263
910
  }
264
911
 
265
- async disconnectStore(orgId: string, id: string): Promise<PluginResult<PublicConnectedStore>> {
266
- return this.disconnectStoreSystem(orgId, id);
912
+ private async localFieldValue(
913
+ entityId: string,
914
+ entity: typeof sellableEntities.$inferSelect,
915
+ path: FieldPath,
916
+ ): Promise<unknown> {
917
+ const [root, segment, field] = path.split(".");
918
+ if (root === "entity" && segment === "slug") return entity.slug;
919
+ if (root === "entity" && segment === "status") return entity.status;
920
+ if (root === "entity" && segment === "metadata") return entity.metadata?.[field ?? ""];
921
+ if (root === "attributes" && segment && field) {
922
+ const [attribute] = await this.db.select().from(sellableAttributes).where(and(
923
+ eq(sellableAttributes.entityId, entityId),
924
+ eq(sellableAttributes.locale, segment),
925
+ ));
926
+ const values: Record<string, unknown> = attribute
927
+ ? {
928
+ title: attribute.title,
929
+ subtitle: attribute.subtitle,
930
+ description: attribute.description,
931
+ richDescription: attribute.richDescription,
932
+ seoTitle: attribute.seoTitle,
933
+ seoDescription: attribute.seoDescription,
934
+ }
935
+ : {};
936
+ return values[field];
937
+ }
938
+ if (root === "customFields" && segment && field) {
939
+ const [customField] = await this.db.select().from(sellableCustomFields).where(and(
940
+ eq(sellableCustomFields.entityId, entityId),
941
+ eq(sellableCustomFields.fieldName, segment),
942
+ eq(sellableCustomFields.locale, field),
943
+ eq(sellableCustomFields.status, "approved"),
944
+ ));
945
+ return customField ? customFieldValue(customField) : undefined;
946
+ }
947
+ if (root === "media" && segment) {
948
+ const links = await this.db.select({ id: entityMedia.mediaAssetId }).from(entityMedia).where(and(
949
+ eq(entityMedia.entityId, entityId),
950
+ eq(entityMedia.role, segment as "primary" | "gallery" | "thumbnail" | "video" | "document"),
951
+ ));
952
+ return links.map((link) => link.id);
953
+ }
954
+ if (path === "options") {
955
+ const types = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entityId));
956
+ const values = await Promise.all(types.map(async (type) => ({
957
+ name: type.name,
958
+ values: await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, type.id)),
959
+ })));
960
+ return values;
961
+ }
962
+ if (path === "variants.sku" || path === "variants.barcode") {
963
+ const rows = await this.db.select().from(variants).where(eq(variants.entityId, entityId));
964
+ return rows.map((variant) => path === "variants.sku" ? variant.sku : variant.barcode);
965
+ }
966
+ if (root === "prices" && segment) {
967
+ const rows = await this.db.select().from(prices).where(and(
968
+ eq(prices.entityId, entityId),
969
+ eq(prices.currency, segment),
970
+ ));
971
+ return rows.map((price) => ({ amount: price.amount, compareAtAmount: price.compareAtAmount }));
972
+ }
973
+ return undefined;
267
974
  }
268
975
 
269
- async disconnectStoreSystem(orgId: string, id: string, redactDomain = false): Promise<PluginResult<PublicConnectedStore>> {
270
- const rows = await this.db
271
- .update(connectedStores)
272
- .set({
273
- status: "disconnected",
274
- credentials: {},
275
- webhookSecret: null,
276
- ...(redactDomain ? { storeDomain: "[REDACTED]" } : {}),
277
- updatedAt: new Date(),
278
- })
279
- .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, id)))
280
- .returning();
281
- const store = rows[0] as ConnectedStore | undefined;
282
- if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
283
- return Ok(redactStore(store));
976
+ private async lastSyncedSnapshot(
977
+ entityId: string,
978
+ lastSyncedAt: Date,
979
+ ): Promise<SellableEntityRevisionSnapshot | undefined> {
980
+ const [revision] = await this.db.select({ snapshot: sellableEntityRevisions.snapshot }).from(sellableEntityRevisions).where(and(
981
+ eq(sellableEntityRevisions.entityId, entityId),
982
+ lte(sellableEntityRevisions.createdAt, lastSyncedAt),
983
+ )).orderBy(desc(sellableEntityRevisions.createdAt)).limit(1);
984
+ return revision?.snapshot;
284
985
  }
285
986
 
286
- async getStore(orgId: string, id: string): Promise<PluginResult<PublicConnectedStore>> {
287
- const store = await this.getStoreRecord(orgId, id);
987
+ private async detectSharedConflicts(
988
+ entityId: string,
989
+ storeId: string,
990
+ entity: typeof sellableEntities.$inferSelect,
991
+ mapping: typeof channelEntityMap.$inferSelect | undefined,
992
+ item: ChannelCatalogItem,
993
+ owners: Map<FieldPath, FieldOwner>,
994
+ fieldPaths: FieldPath[] = importedFieldPaths(item),
995
+ remoteHash = hash(item),
996
+ echo?: { certifiedPaths: ReadonlySet<FieldPath> },
997
+ ): Promise<{ paths: FieldPath[]; conflicts: DetectedCatalogFieldConflict[] }> {
998
+ if (!mapping || mapping.syncHash === remoteHash) return { paths: [], conflicts: [] };
999
+ const revisions = await this.catalog.repository.findRevisionMarkers(entityId, mapping.lastSyncedAt);
1000
+ const localChanged = revisions.some((revision) => revision.reason !== "import");
1001
+ const paths = fieldPaths.filter((path) => owners.get(path) === "shared");
1002
+ const openRows = await this.db.select({
1003
+ fieldPath: channelCatalogConflicts.fieldPath,
1004
+ storeValue: channelCatalogConflicts.storeValue,
1005
+ }).from(channelCatalogConflicts).where(and(
1006
+ eq(channelCatalogConflicts.storeId, storeId),
1007
+ eq(channelCatalogConflicts.entityId, entityId),
1008
+ eq(channelCatalogConflicts.state, "open"),
1009
+ ));
1010
+ if (!localChanged && openRows.length === 0) return { paths: [], conflicts: [] };
1011
+ const openByPath = new Map(openRows.map((row) => [row.fieldPath as FieldPath, row.storeValue]));
1012
+ const baseline = await this.lastSyncedSnapshot(entityId, mapping.lastSyncedAt);
1013
+ const changed: FieldPath[] = [];
1014
+ for (const path of paths) {
1015
+ const localValue = await this.localFieldValue(entityId, entity, path);
1016
+ const remoteValue = this.remoteFieldValue(item, path);
1017
+ let diverged = false;
1018
+ if (echo) {
1019
+ // The outbound hash certifies only the pushed paths; a shared path
1020
+ // outside that set carrying a genuinely different remote value is a
1021
+ // real store edit even inside an echo payload.
1022
+ diverged = !echo.certifiedPaths.has(path) && !normalizedValuesEqual(remoteValue, localValue);
1023
+ } else if (openByPath.has(path)) {
1024
+ diverged = !normalizedValuesEqual(remoteValue, openByPath.get(path));
1025
+ } else if (baseline) {
1026
+ const baselineValue = snapshotFieldValue(baseline, path);
1027
+ diverged = baselineValue.found
1028
+ && !normalizedValuesEqual(localValue, baselineValue.value)
1029
+ && !normalizedValuesEqual(remoteValue, baselineValue.value)
1030
+ && !normalizedValuesEqual(localValue, remoteValue);
1031
+ } else {
1032
+ diverged = !normalizedValuesEqual(remoteValue, localValue);
1033
+ }
1034
+ if (diverged) changed.push(path);
1035
+ }
1036
+ const conflicts = await Promise.all(changed.map(async (fieldPath) => {
1037
+ const platformValue = await this.localFieldValue(entityId, entity, fieldPath);
1038
+ const storeValue = this.remoteFieldValue(item, fieldPath);
1039
+ return {
1040
+ entityId,
1041
+ storeId,
1042
+ fieldPath,
1043
+ platformValue: platformValue === undefined ? null : platformValue,
1044
+ storeValue: storeValue === undefined ? null : storeValue,
1045
+ localValueSummary: summarizeValue(platformValue),
1046
+ remoteValueSummary: summarizeValue(storeValue),
1047
+ };
1048
+ }));
1049
+ return { paths: changed, conflicts };
1050
+ }
1051
+
1052
+ private async persistCatalogConflicts(
1053
+ orgId: string,
1054
+ conflicts: DetectedCatalogFieldConflict[],
1055
+ changedBy: string,
1056
+ ): Promise<PluginResult<void>> {
1057
+ for (const conflict of conflicts) {
1058
+ const [inserted] = await this.db.insert(channelCatalogConflicts).values({
1059
+ organizationId: orgId,
1060
+ storeId: conflict.storeId,
1061
+ entityId: conflict.entityId,
1062
+ fieldPath: conflict.fieldPath,
1063
+ platformValue: conflict.platformValue,
1064
+ storeValue: conflict.storeValue,
1065
+ }).onConflictDoNothing().returning();
1066
+ if (!inserted) {
1067
+ const [existing] = await this.db.select({
1068
+ id: channelCatalogConflicts.id,
1069
+ storeValue: channelCatalogConflicts.storeValue,
1070
+ platformValue: channelCatalogConflicts.platformValue,
1071
+ }).from(channelCatalogConflicts).where(and(
1072
+ eq(channelCatalogConflicts.organizationId, orgId),
1073
+ eq(channelCatalogConflicts.storeId, conflict.storeId),
1074
+ eq(channelCatalogConflicts.entityId, conflict.entityId),
1075
+ eq(channelCatalogConflicts.fieldPath, conflict.fieldPath),
1076
+ eq(channelCatalogConflicts.state, "open"),
1077
+ ));
1078
+ const storeMoved = existing !== undefined && !normalizedValuesEqual(existing.storeValue, conflict.storeValue);
1079
+ const platformMoved = existing !== undefined && !normalizedValuesEqual(existing.platformValue, conflict.platformValue);
1080
+ if (existing && (storeMoved || platformMoved)) {
1081
+ await this.db.update(channelCatalogConflicts).set({
1082
+ storeValue: conflict.storeValue,
1083
+ platformValue: conflict.platformValue,
1084
+ updatedAt: new Date(),
1085
+ }).where(eq(channelCatalogConflicts.id, existing.id));
1086
+ }
1087
+ continue;
1088
+ }
1089
+ await this.db.insert(channelCatalogConflictEvents).values({
1090
+ organizationId: orgId,
1091
+ conflictId: inserted.id,
1092
+ fromState: null,
1093
+ toState: "open",
1094
+ reason: "Shared catalog field changed on both sides.",
1095
+ changedBy,
1096
+ });
1097
+ }
1098
+ return Ok(undefined);
1099
+ }
1100
+
1101
+ private async setCatalogAttributes(
1102
+ entityId: string,
1103
+ item: ChannelCatalogItem,
1104
+ actor: Actor,
1105
+ blockedPaths: ReadonlySet<FieldPath> = new Set<FieldPath>(),
1106
+ catalogCtx?: CatalogWriteContext,
1107
+ ): Promise<PluginResult<{ created: number; changed: boolean }>> {
1108
+ const attributes = item.attributes ?? [{
1109
+ locale: "en",
1110
+ title: item.title,
1111
+ ...(item.description !== undefined ? { description: item.description } : {}),
1112
+ }];
1113
+ const existing = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entityId));
1114
+ let created = 0;
1115
+ let changed = false;
1116
+ for (const attribute of attributes) {
1117
+ const current = existing.find((row) => row.locale === attribute.locale);
1118
+ const titlePath = `attributes.${attribute.locale}.title` as FieldPath;
1119
+ if (!current && blockedPaths.has(titlePath)) continue;
1120
+ const title = blockedPaths.has(titlePath) ? current?.title : attribute.title;
1121
+ if (title === undefined) continue;
1122
+ const writeAttribute = {
1123
+ title,
1124
+ ...(attribute.subtitle !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.subtitle`) ? { subtitle: attribute.subtitle } : current?.subtitle != null ? { subtitle: current.subtitle } : {}),
1125
+ ...(attribute.description !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.description`) ? { description: attribute.description } : current?.description != null ? { description: current.description } : {}),
1126
+ ...(attribute.richDescription !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.richDescription`) ? { richDescription: attribute.richDescription } : current?.richDescription != null ? { richDescription: current.richDescription } : {}),
1127
+ ...(attribute.seoTitle !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.seoTitle`) ? { seoTitle: attribute.seoTitle } : current?.seoTitle != null ? { seoTitle: current.seoTitle } : {}),
1128
+ ...(attribute.seoDescription !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.seoDescription`) ? { seoDescription: attribute.seoDescription } : current?.seoDescription != null ? { seoDescription: current.seoDescription } : {}),
1129
+ };
1130
+ if (!current) {
1131
+ created += 1;
1132
+ changed = true;
1133
+ } else if (attributeFields.some((field) => (current[field] == null ? null : current[field]) !== (writeAttribute[field] == null ? null : writeAttribute[field]))) {
1134
+ changed = true;
1135
+ }
1136
+ const result = await this.catalog.setAttributes(entityId, attribute.locale, writeAttribute, actor, catalogCtx);
1137
+ if (!result.ok) return PluginErr(result.error.message);
1138
+ }
1139
+ return Ok({ created, changed });
1140
+ }
1141
+
1142
+ private async setCatalogAttributesIfWritable(
1143
+ entityId: string,
1144
+ item: ChannelCatalogItem,
1145
+ actor: Actor,
1146
+ blockedPaths: ReadonlySet<FieldPath>,
1147
+ catalogCtx?: CatalogWriteContext,
1148
+ ): Promise<PluginResult<{ created: number; changed: boolean }>> {
1149
+ const attributes = item.attributes ?? [{
1150
+ locale: "en",
1151
+ title: item.title,
1152
+ ...(item.description !== undefined ? { description: item.description } : {}),
1153
+ }];
1154
+ const writable = attributes.some((attribute) => attributeFields.some((field) => (
1155
+ attribute[field] !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.${field}`)
1156
+ )));
1157
+ if (!writable) return Ok({ created: 0, changed: false });
1158
+ return this.setCatalogAttributes(entityId, item, actor, blockedPaths, catalogCtx);
1159
+ }
1160
+
1161
+ private async upsertOptionAxes(
1162
+ entityId: string,
1163
+ item: ChannelCatalogItem,
1164
+ actor: Actor,
1165
+ ): Promise<PluginResult<{ value: Map<string, Map<string, string>>; changed: boolean }>> {
1166
+ const optionValueIds = new Map<string, Map<string, string>>();
1167
+ const existingTypes = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entityId));
1168
+ let changed = false;
1169
+ for (const [typeIndex, sourceType] of (item.options ?? []).entries()) {
1170
+ let optionType = existingTypes.find((row) => row.name === sourceType.name);
1171
+ if (!optionType) {
1172
+ const created = await this.catalog.createOptionType({ entityId, name: sourceType.name, values: [] }, actor);
1173
+ if (!created.ok) return PluginErr(created.error.message);
1174
+ const [createdType] = await this.db.select().from(optionTypes).where(eq(optionTypes.id, created.value.id));
1175
+ if (!createdType) return PluginErr(`Option type "${sourceType.name}" was not persisted.`);
1176
+ optionType = createdType;
1177
+ existingTypes.push(optionType);
1178
+ changed = true;
1179
+ }
1180
+ await this.db.update(optionTypes).set({
1181
+ displayName: sourceType.displayName,
1182
+ sortOrder: sourceType.sortOrder ?? typeIndex,
1183
+ }).where(eq(optionTypes.id, optionType.id));
1184
+
1185
+ const existingValues = await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, optionType.id));
1186
+ const valueIds = new Map<string, string>();
1187
+ for (const [valueIndex, sourceValue] of sourceType.values.entries()) {
1188
+ let optionValue = existingValues.find((row) => row.value === sourceValue.value);
1189
+ if (!optionValue) {
1190
+ const created = await this.catalog.createOptionValue({ optionTypeId: optionType.id, value: sourceValue.value }, actor);
1191
+ if (!created.ok) return PluginErr(created.error.message);
1192
+ const [createdValue] = await this.db.select().from(optionValues).where(eq(optionValues.id, created.value.id));
1193
+ if (!createdValue) return PluginErr(`Option value "${sourceValue.value}" was not persisted.`);
1194
+ optionValue = createdValue;
1195
+ existingValues.push(optionValue);
1196
+ changed = true;
1197
+ }
1198
+ await this.db.update(optionValues).set({
1199
+ displayValue: sourceValue.displayValue,
1200
+ sortOrder: sourceValue.sortOrder ?? valueIndex,
1201
+ }).where(eq(optionValues.id, optionValue.id));
1202
+ valueIds.set(sourceValue.value, optionValue.id);
1203
+ }
1204
+ optionValueIds.set(sourceType.name, valueIds);
1205
+ }
1206
+ return Ok({ value: optionValueIds, changed });
1207
+ }
1208
+
1209
+ private async upsertVariants(
1210
+ orgId: string,
1211
+ storeId: string,
1212
+ entityId: string,
1213
+ item: ChannelCatalogItem,
1214
+ optionValueIds: Map<string, Map<string, string>>,
1215
+ actor: Actor,
1216
+ warnings: string[],
1217
+ applyOptionValues: boolean,
1218
+ fullItem: ChannelCatalogItem,
1219
+ ): Promise<PluginResult<{ value: Map<string, string>; repaired: number; changed: boolean }>> {
1220
+ const variantIds = new Map<string, string>();
1221
+ let repaired = 0;
1222
+ let changed = false;
1223
+ const mappings = await this.db.select().from(channelEntityMap).where(and(
1224
+ eq(channelEntityMap.organizationId, orgId),
1225
+ eq(channelEntityMap.storeId, storeId),
1226
+ eq(channelEntityMap.kind, "variant"),
1227
+ eq(channelEntityMap.entityId, entityId),
1228
+ ));
1229
+ for (const sourceVariant of item.variants) {
1230
+ const fullSourceVariant = fullItem.variants.find((variant) => variant.externalId === sourceVariant.externalId) ?? sourceVariant;
1231
+ let mapping = mappings.find((row) => row.externalId === sourceVariant.externalId);
1232
+ let variantId = mapping?.variantId;
1233
+ const createdVariant = !variantId;
1234
+ if (!variantId) {
1235
+ const options: Record<string, string> = {};
1236
+ for (const [name, value] of Object.entries(sourceVariant.optionValues ?? {})) {
1237
+ const optionValueId = optionValueIds.get(name)?.get(value);
1238
+ if (!optionValueId) {
1239
+ warnings.push(`Skipped unmapped option "${name}=${value}" on variant "${sourceVariant.externalId}".`);
1240
+ continue;
1241
+ }
1242
+ options[name] = value;
1243
+ }
1244
+ const created = await this.catalog.createVariant({
1245
+ entityId,
1246
+ options,
1247
+ ...(sourceVariant.sku !== undefined ? { sku: sourceVariant.sku } : {}),
1248
+ ...(sourceVariant.barcode !== undefined ? { barcode: sourceVariant.barcode } : {}),
1249
+ }, actor);
1250
+ if (!created.ok) return PluginErr(created.error.message);
1251
+ variantId = created.value.id;
1252
+ const [createdMapping] = await this.db.insert(channelEntityMap).values({
1253
+ organizationId: orgId,
1254
+ storeId,
1255
+ kind: "variant",
1256
+ externalId: sourceVariant.externalId,
1257
+ entityId,
1258
+ variantId,
1259
+ syncHash: hash(fullSourceVariant),
1260
+ }).returning();
1261
+ mapping = createdMapping;
1262
+ if (mapping) mappings.push(mapping);
1263
+ }
1264
+ if (!variantId) {
1265
+ warnings.push(`Skipped variant "${sourceVariant.externalId}": no local variant mapping exists.`);
1266
+ continue;
1267
+ }
1268
+ variantIds.set(sourceVariant.externalId, variantId);
1269
+ if (applyOptionValues) {
1270
+ const desiredOptionValueIds = Object.entries(sourceVariant.optionValues ?? {})
1271
+ .map(([name, value]) => optionValueIds.get(name)?.get(value))
1272
+ .filter((optionValueId): optionValueId is string => optionValueId !== undefined);
1273
+ const currentOptionValues = await this.db.select().from(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
1274
+ const currentIds = currentOptionValues.map((row) => row.optionValueId).sort();
1275
+ const desiredIds = [...new Set(desiredOptionValueIds)].sort();
1276
+ if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
1277
+ await this.db.delete(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
1278
+ if (desiredIds.length > 0) {
1279
+ await this.db.insert(variantOptionValues).values(desiredIds.map((optionValueId) => ({ variantId, optionValueId }))).onConflictDoNothing();
1280
+ repaired += 1;
1281
+ }
1282
+ changed = true;
1283
+ }
1284
+ if (createdVariant && desiredIds.length > 0) {
1285
+ repaired += 1;
1286
+ changed = true;
1287
+ }
1288
+ }
1289
+ for (const price of sourceVariant.prices ?? []) {
1290
+ const priced = await this.pricing.setBasePrice({
1291
+ entityId,
1292
+ variantId,
1293
+ currency: price.currency,
1294
+ amount: price.amount,
1295
+ compareAtAmount: price.compareAtAmount ?? null,
1296
+ }, actor);
1297
+ if (!priced.ok) return PluginErr(priced.error.message);
1298
+ }
1299
+ if (mapping) {
1300
+ await this.db.update(channelEntityMap).set({
1301
+ syncHash: hash(fullSourceVariant),
1302
+ }).where(eq(channelEntityMap.id, mapping.id));
1303
+ }
1304
+ }
1305
+ return Ok({ value: variantIds, repaired, changed });
1306
+ }
1307
+
1308
+ private async applyTaxonomy(
1309
+ orgId: string,
1310
+ entityId: string,
1311
+ item: ChannelCatalogItem,
1312
+ actor: Actor,
1313
+ warnings: string[],
1314
+ ): Promise<PluginResult<void>> {
1315
+ const categoryRows = await this.db.select().from(categories).where(eq(categories.organizationId, orgId));
1316
+ for (const slug of new Set(item.categories ?? [])) {
1317
+ let category = categoryRows.find((row) => row.slug === slug);
1318
+ if (category?.status === "archived") {
1319
+ warnings.push(`Skipped archived category "${slug}".`);
1320
+ continue;
1321
+ }
1322
+ if (!category) {
1323
+ const created = await this.catalog.createCategory({ slug }, actor);
1324
+ if (!created.ok) return PluginErr(created.error.message);
1325
+ const [createdCategory] = await this.db.select().from(categories).where(eq(categories.id, created.value.id));
1326
+ if (!createdCategory) return PluginErr(`Category "${slug}" was not persisted.`);
1327
+ category = createdCategory;
1328
+ categoryRows.push(category);
1329
+ }
1330
+ const linked = await this.catalog.addToCategory(entityId, category.id, actor);
1331
+ if (!linked.ok) return PluginErr(linked.error.message);
1332
+ }
1333
+
1334
+ const brandRows = await this.db.select().from(brands).where(eq(brands.organizationId, orgId));
1335
+ if (item.brand) {
1336
+ let brand = brandRows.find((row) => row.slug === item.brand);
1337
+ if (!brand) {
1338
+ const created = await this.catalog.createBrand({ slug: item.brand, displayName: item.brand }, actor);
1339
+ if (!created.ok) return PluginErr(created.error.message);
1340
+ const [createdBrand] = await this.db.select().from(brands).where(eq(brands.id, created.value.id));
1341
+ if (!createdBrand) return PluginErr(`Brand "${item.brand}" was not persisted.`);
1342
+ brand = createdBrand;
1343
+ brandRows.push(brand);
1344
+ }
1345
+ const linked = await this.catalog.addToBrand(entityId, brand.id, actor);
1346
+ if (!linked.ok) return PluginErr(linked.error.message);
1347
+ }
1348
+
1349
+ const tagRows = await this.db.select().from(tags).where(eq(tags.organizationId, orgId));
1350
+ for (const slug of new Set(item.tags ?? [])) {
1351
+ let tag = tagRows.find((row) => row.slug === slug);
1352
+ if (!tag) {
1353
+ const [createdTag] = await this.db.insert(tags).values({ organizationId: orgId, slug, displayName: slug }).onConflictDoNothing().returning();
1354
+ tag = createdTag ?? (await this.db.select().from(tags).where(and(
1355
+ eq(tags.organizationId, orgId),
1356
+ eq(tags.slug, slug),
1357
+ )))[0];
1358
+ if (!tag) return PluginErr(`Tag "${slug}" was not persisted.`);
1359
+ tagRows.push(tag);
1360
+ }
1361
+ await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing();
1362
+ }
1363
+ return Ok(undefined);
1364
+ }
1365
+
1366
+ private async applyMedia(
1367
+ orgId: string,
1368
+ entityId: string,
1369
+ item: ChannelCatalogItem,
1370
+ variantIds: Map<string, string>,
1371
+ actor: Actor,
1372
+ warnings: string[],
1373
+ owners: Map<FieldPath, FieldOwner>,
1374
+ ): Promise<PluginResult<{ imported: number; changed: boolean; skipped: FieldPath[] }>> {
1375
+ const assets = await this.db.select().from(mediaAssets).where(eq(mediaAssets.organizationId, orgId));
1376
+ const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entityId));
1377
+ let imported = 0;
1378
+ let changed = false;
1379
+ const skipped: FieldPath[] = [];
1380
+ for (const image of item.images ?? []) {
1381
+ const urlHash = hash(image.url);
1382
+ const asset = assets.find((row) => {
1383
+ const metadata = row.metadata ?? {};
1384
+ return (image.externalId != null && metadata.channelImageExternalId === image.externalId)
1385
+ || metadata.channelImageUrlHash === urlHash;
1386
+ });
1387
+ let mediaAssetId = asset?.id;
1388
+ if (!mediaAssetId) {
1389
+ let response: Response;
1390
+ try {
1391
+ response = await fetch(image.url);
1392
+ } catch (error) {
1393
+ warnings.push(`Skipped image "${image.externalId ?? image.url}": ${error instanceof Error ? error.message : "download failed"}.`);
1394
+ continue;
1395
+ }
1396
+ if (!response.ok) {
1397
+ warnings.push(`Skipped image "${image.externalId ?? image.url}": download returned ${response.status}.`);
1398
+ continue;
1399
+ }
1400
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0] ?? "image/jpeg";
1401
+ const extension = contentType.split("/", 2)[1] ?? "jpg";
1402
+ const uploaded = await this.media.upload({
1403
+ filename: `${image.externalId ?? urlHash}.${extension}`,
1404
+ contentType,
1405
+ data: await response.arrayBuffer(),
1406
+ ...(image.alt !== undefined ? { alt: image.alt } : {}),
1407
+ metadata: {
1408
+ channelImageUrlHash: urlHash,
1409
+ ...(image.externalId !== undefined ? { channelImageExternalId: image.externalId } : {}),
1410
+ },
1411
+ origin: "imported",
1412
+ }, actor);
1413
+ if (!uploaded.ok) {
1414
+ warnings.push(`Skipped image "${image.externalId ?? image.url}": ${uploaded.error.code === "STORAGE_NOT_SUPPORTED" ? "storage adapter is not configured" : uploaded.error.message}.`);
1415
+ continue;
1416
+ }
1417
+ mediaAssetId = uploaded.value.id;
1418
+ imported += 1;
1419
+ changed = true;
1420
+ const [createdAsset] = await this.db.select().from(mediaAssets).where(eq(mediaAssets.id, mediaAssetId));
1421
+ if (createdAsset) assets.push(createdAsset);
1422
+ }
1423
+ if (!mediaAssetId) continue;
1424
+
1425
+ const targets = image.variantExternalIds?.length
1426
+ ? image.variantExternalIds.map((externalId) => ({ externalId, variantId: variantIds.get(externalId) }))
1427
+ : [{ externalId: undefined, variantId: undefined }];
1428
+ for (const target of targets) {
1429
+ if (image.variantExternalIds?.length && !target.variantId) {
1430
+ warnings.push(`Skipped image "${image.externalId ?? image.url}" for unmapped variant "${target.externalId}".`);
1431
+ continue;
1432
+ }
1433
+ const existingLink = links.find((link) =>
1434
+ link.mediaAssetId === mediaAssetId
1435
+ && (target.variantId === undefined ? link.variantId === null : link.variantId === target.variantId),
1436
+ );
1437
+ if (existingLink) {
1438
+ if (existingLink.role !== image.role) {
1439
+ const currentRolePath = `media.${existingLink.role}` as FieldPath;
1440
+ const incomingRolePath = `media.${image.role}` as FieldPath;
1441
+ for (const path of [currentRolePath, incomingRolePath]) {
1442
+ if (owners.get(path) === "platform" && !skipped.includes(path)) skipped.push(path);
1443
+ }
1444
+ if (skipped.includes(currentRolePath) || skipped.includes(incomingRolePath)) continue;
1445
+ }
1446
+ if (existingLink.role !== image.role || existingLink.sortOrder !== (image.sortOrder ?? 0)) {
1447
+ await this.db.update(entityMedia).set({ role: image.role, sortOrder: image.sortOrder ?? 0 }).where(and(
1448
+ eq(entityMedia.entityId, entityId),
1449
+ eq(entityMedia.mediaAssetId, mediaAssetId),
1450
+ target.variantId === undefined ? isNull(entityMedia.variantId) : eq(entityMedia.variantId, target.variantId),
1451
+ ));
1452
+ changed = true;
1453
+ }
1454
+ continue;
1455
+ }
1456
+ const attached = await this.media.attachToEntity({
1457
+ entityId,
1458
+ mediaAssetId,
1459
+ role: image.role,
1460
+ sortOrder: image.sortOrder ?? 0,
1461
+ ...(target.variantId !== undefined ? { variantId: target.variantId } : {}),
1462
+ }, actor);
1463
+ if (!attached.ok) return PluginErr(attached.error.message);
1464
+ changed = true;
1465
+ links.push({
1466
+ entityId,
1467
+ mediaAssetId,
1468
+ role: image.role,
1469
+ sortOrder: image.sortOrder ?? 0,
1470
+ variantId: target.variantId ?? null,
1471
+ createdAt: new Date(),
1472
+ });
1473
+ }
1474
+ }
1475
+ return Ok({ imported, changed, skipped });
1476
+ }
1477
+
1478
+ private async getStoreRecord(orgId: string, id: string): Promise<ConnectedStore | undefined> {
1479
+ const rows = await this.db
1480
+ .select()
1481
+ .from(connectedStores)
1482
+ .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, id)));
1483
+ return rows[0] as ConnectedStore | undefined;
1484
+ }
1485
+
1486
+ async getStoreByDomain(shopDomain: string): Promise<ConnectedStore | undefined> {
1487
+ const rows = await this.db
1488
+ .select()
1489
+ .from(connectedStores)
1490
+ .where(eq(connectedStores.storeDomain, shopDomain));
1491
+ return rows[0] as ConnectedStore | undefined;
1492
+ }
1493
+
1494
+ // A shop_domain can map to more than one connected store (reconnect, or the same
1495
+ // shop under two orgs). Compliance webhooks must fan out to all of them.
1496
+ async getStoresByDomain(shopDomain: string): Promise<ConnectedStore[]> {
1497
+ const rows = await this.db
1498
+ .select()
1499
+ .from(connectedStores)
1500
+ .where(eq(connectedStores.storeDomain, shopDomain));
1501
+ return rows as ConnectedStore[];
1502
+ }
1503
+
1504
+ resolveCatalogFieldMapping(
1505
+ store: Pick<ConnectedStore, "provider" | "catalogFieldMapping">,
1506
+ filterableCustomFields?: ReadonlySet<string> | Readonly<Record<string, boolean>>,
1507
+ warnings: string[] = [],
1508
+ ): CatalogFieldMapping {
1509
+ return mergeCatalogFieldMapping(store.provider, store.catalogFieldMapping, filterableCustomFields, warnings);
1510
+ }
1511
+
1512
+ async buildCatalogPushItems(
1513
+ orgId: string,
1514
+ storeId: string,
1515
+ entityIds: string[],
1516
+ options: BuildCatalogPushItemsOptions = {},
1517
+ ): Promise<PluginResult<BuildCatalogPushItemsResult>> {
1518
+ const store = await this.getStoreRecord(orgId, storeId);
1519
+ if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
1520
+ if (!store.catalogWriteEnabled) return PluginErr("Catalog writes are disabled for this store.", "CATALOG_WRITE_DISABLED");
1521
+ if (entityIds.length === 0) return Ok({ items: [], skipped: [], warnings: [] });
1522
+
1523
+ const entities = await this.db.select().from(sellableEntities).where(and(
1524
+ eq(sellableEntities.organizationId, orgId),
1525
+ inArray(sellableEntities.id, entityIds),
1526
+ ));
1527
+ const entityById = new Map(entities.map((entity) => [entity.id, entity]));
1528
+ const mappings = await this.db.select().from(channelEntityMap).where(and(
1529
+ eq(channelEntityMap.organizationId, orgId),
1530
+ eq(channelEntityMap.storeId, storeId),
1531
+ eq(channelEntityMap.kind, "entity"),
1532
+ inArray(channelEntityMap.entityId, entityIds),
1533
+ ));
1534
+ const mappingByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping]));
1535
+ const items: CatalogPushAssemblyItem[] = [];
1536
+ const skipped: CatalogPushFieldSkip[] = [];
1537
+ const warnings: string[] = [];
1538
+ const revisionEntityIds: string[] = [];
1539
+
1540
+ for (const entityId of entityIds) {
1541
+ const entity = entityById.get(entityId);
1542
+ if (!entity) return PluginErr("Catalog entity not found.", "NOT_FOUND");
1543
+ if (entity.status !== "active") {
1544
+ skipped.push({ entityId, fieldPath: "entity.status", reason: "entity_not_active" });
1545
+ continue;
1546
+ }
1547
+ const entityMapping = mappingByEntity.get(entity.id);
1548
+ if (!entityMapping) {
1549
+ skipped.push({ entityId, fieldPath: "entity", reason: "unmapped_entity" });
1550
+ continue;
1551
+ }
1552
+ const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
1553
+ const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
1554
+ const customFields = await this.db.select().from(sellableCustomFields).where(and(
1555
+ eq(sellableCustomFields.entityId, entity.id),
1556
+ eq(sellableCustomFields.status, "approved"),
1557
+ ));
1558
+ const customFieldNames = [...new Set(customFields.map((field) => field.fieldName))];
1559
+ const definitions = customFieldNames.length > 0
1560
+ ? await this.db.select({ name: entityFieldDefinitions.name, filterable: entityFieldDefinitions.filterable }).from(entityFieldDefinitions).where(and(
1561
+ eq(entityFieldDefinitions.organizationId, orgId),
1562
+ eq(entityFieldDefinitions.entityType, entity.type),
1563
+ inArray(entityFieldDefinitions.name, customFieldNames),
1564
+ ))
1565
+ : [];
1566
+ const filterableCustomFields = Object.fromEntries(definitions.map((definition) => [
1567
+ `customFields.${definition.name}.en`,
1568
+ definition.filterable,
1569
+ ]));
1570
+ for (const field of customFields) {
1571
+ filterableCustomFields[`customFields.${field.fieldName}.${field.locale}`] = definitions.find(
1572
+ (definition) => definition.name === field.fieldName,
1573
+ )?.filterable ?? false;
1574
+ }
1575
+ const fieldMapping = this.resolveCatalogFieldMapping(store, filterableCustomFields, warnings);
1576
+ const heldPaths = new Set(entityMapping.heldFieldPaths ?? []);
1577
+ const forcedPushPaths = new Set([
1578
+ ...(entityMapping.forcedPushFieldPaths ?? []),
1579
+ ...(options.forceFieldPaths?.[entity.id] ?? []),
1580
+ ]);
1581
+ const fields: CatalogPushAssemblyField[] = [];
1582
+ const appendField = (fieldPath: FieldPath, value: unknown) => {
1583
+ if (value === undefined) return;
1584
+ const owner = owners.get(fieldPath);
1585
+ const mapping = selectCatalogFieldMapping(fieldMapping, fieldPath);
1586
+ if (owner === "store") {
1587
+ skipped.push({
1588
+ entityId,
1589
+ fieldPath,
1590
+ reason: "store_owned",
1591
+ value,
1592
+ owner,
1593
+ ...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
1594
+ });
1595
+ return;
1596
+ }
1597
+ if (owner === undefined) return;
1598
+ const forced = forcedPushPaths.has(fieldPath);
1599
+ if (owner !== "platform" && !forced) return;
1600
+ if (heldPaths.has(fieldPath)) {
1601
+ skipped.push({
1602
+ entityId,
1603
+ fieldPath,
1604
+ reason: "held",
1605
+ value,
1606
+ owner,
1607
+ ...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
1608
+ });
1609
+ return;
1610
+ }
1611
+ if (!mapping) {
1612
+ skipped.push({ entityId, fieldPath, reason: "no_mapping", value, owner });
1613
+ return;
1614
+ }
1615
+ fields.push(pushCatalogField(fieldPath, value, mapping));
1616
+ };
1617
+
1618
+ for (const attribute of attributes) {
1619
+ for (const field of attributeFields) {
1620
+ appendField(`attributes.${attribute.locale}.${field}`, attribute[field]);
1621
+ }
1622
+ }
1623
+ for (const [key, value] of Object.entries(entity.metadata ?? {})) {
1624
+ const fieldPath = `entity.metadata.${key}`;
1625
+ if (isValidFieldPath(fieldPath)) appendField(fieldPath, value);
1626
+ }
1627
+ for (const customField of customFields) {
1628
+ const fieldPath = `customFields.${customField.fieldName}.${customField.locale}`;
1629
+ if (isValidFieldPath(fieldPath)) appendField(fieldPath, customFieldValue(customField));
1630
+ }
1631
+
1632
+ const media = await this.media.listEntityMedia(entity.id, { orgId });
1633
+ if (!media.ok) return PluginErr(media.error.message);
1634
+ const images: CatalogPushAssemblyImage[] = [];
1635
+ for (const attached of media.value) {
1636
+ const role = pushCatalogImageRole(attached.role);
1637
+ if (!role) continue;
1638
+ const fieldPath = `media.${role}` as FieldPath;
1639
+ const owner = owners.get(fieldPath);
1640
+ const mapping = selectCatalogFieldMapping(fieldMapping, fieldPath);
1641
+ const imageValue = [{ url: attached.url, role }];
1642
+ if (owner === "store") {
1643
+ skipped.push({
1644
+ entityId,
1645
+ fieldPath,
1646
+ reason: "store_owned",
1647
+ value: imageValue,
1648
+ owner,
1649
+ ...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
1650
+ });
1651
+ continue;
1652
+ }
1653
+ if (owner === undefined) continue;
1654
+ const forced = forcedPushPaths.has(fieldPath);
1655
+ if (owner !== "platform" && !forced) continue;
1656
+ if (heldPaths.has(fieldPath)) {
1657
+ skipped.push({
1658
+ entityId,
1659
+ fieldPath,
1660
+ reason: "held",
1661
+ value: imageValue,
1662
+ owner,
1663
+ ...(mapping ? { target: mapping.target, remoteKey: mapping.remoteKey } : {}),
1664
+ });
1665
+ continue;
1666
+ }
1667
+ if (!mapping) {
1668
+ skipped.push({ entityId, fieldPath, reason: "no_mapping", value: imageValue, owner });
1669
+ continue;
1670
+ }
1671
+ images.push({
1672
+ fieldPath,
1673
+ target: mapping.target,
1674
+ remoteKey: mapping.remoteKey,
1675
+ url: attached.url,
1676
+ role,
1677
+ sortOrder: attached.sortOrder,
1678
+ ...(attached.alt !== null ? { alt: attached.alt } : {}),
1679
+ });
1680
+ }
1681
+ fields.sort((left, right) => left.fieldPath.localeCompare(right.fieldPath));
1682
+ const item: CatalogPushAssemblyItem = {
1683
+ externalId: entityMapping.externalId,
1684
+ fields,
1685
+ ...(images.length > 0 ? { images } : {}),
1686
+ };
1687
+ items.push(item);
1688
+ if (options.recordRevision === true) revisionEntityIds.push(entity.id);
1689
+ }
1690
+ if (options.recordRevision === true && revisionEntityIds.length > 0) {
1691
+ const actor = createSystemActor(orgId);
1692
+ try {
1693
+ await this.transact(async (tx) => {
1694
+ const txContext = createTxContext(tx, { actor });
1695
+ for (const entityId of revisionEntityIds) {
1696
+ const revision = await this.catalog.recordEntityRevision(entityId, actor, "push", txContext);
1697
+ if (!revision.ok) throw new Error(revision.error.message);
1698
+ }
1699
+ });
1700
+ } catch (error) {
1701
+ return PluginErr(error instanceof Error ? error.message : "Failed to record catalog push revisions.");
1702
+ }
1703
+ }
1704
+ return Ok({ items, skipped, warnings: [...new Set(warnings)] });
1705
+ }
1706
+
1707
+ async recordOutboundPush(
1708
+ orgId: string,
1709
+ storeId: string,
1710
+ outcomes: ChannelPushCatalogItemOutcome[],
1711
+ items: ChannelPushCatalogItem[],
1712
+ phase: "write-ahead" | "settle" = "settle",
1713
+ ): Promise<PluginResult<void>> {
1714
+ const outcomeByExternalId = new Map(outcomes.map((outcome) => [outcome.externalId, outcome]));
1715
+ const now = new Date();
1716
+ for (const item of items) {
1717
+ const outcome = outcomeByExternalId.get(item.externalId);
1718
+ const mapping = await this.db.select({
1719
+ id: channelEntityMap.id,
1720
+ externalId: channelEntityMap.externalId,
1721
+ forcedPushFieldPaths: channelEntityMap.forcedPushFieldPaths,
1722
+ }).from(channelEntityMap).where(and(
1723
+ eq(channelEntityMap.organizationId, orgId),
1724
+ eq(channelEntityMap.storeId, storeId),
1725
+ eq(channelEntityMap.kind, "entity"),
1726
+ eq(channelEntityMap.externalId, item.externalId),
1727
+ ));
1728
+ if (!mapping[0]) continue;
1729
+ if (outcome?.ok === true) {
1730
+ const fieldPaths = outboundFieldPaths(item);
1731
+ await this.db.update(channelEntityMap).set({
1732
+ outboundHash: canonicalOutboundHash(mapping[0].externalId, item, fieldPaths),
1733
+ outboundPushedAt: now,
1734
+ outboundFieldPaths: fieldPaths,
1735
+ // A force is an operator's conflict resolution. The write-ahead runs
1736
+ // before the connector is called and its outcomes are optimistic, so
1737
+ // consuming the force there would discard the resolution on a failed
1738
+ // push and the retry would silently omit the field.
1739
+ ...(phase === "settle"
1740
+ ? { forcedPushFieldPaths: (mapping[0].forcedPushFieldPaths ?? []).filter((path) => !fieldPaths.includes(path)) }
1741
+ : {}),
1742
+ }).where(eq(channelEntityMap.id, mapping[0].id));
1743
+ } else {
1744
+ await this.db.update(channelEntityMap).set({
1745
+ outboundHash: null,
1746
+ outboundPushedAt: null,
1747
+ outboundFieldPaths: [],
1748
+ syncHash: "",
1749
+ }).where(eq(channelEntityMap.id, mapping[0].id));
1750
+ }
1751
+ }
1752
+ return Ok(undefined);
1753
+ }
1754
+
1755
+ async pushCatalogToStore(
1756
+ orgId: string,
1757
+ storeId: string,
1758
+ entityIds: string[],
1759
+ ): Promise<PluginResult<PushCatalogToStoreResult>> {
1760
+ const assembled = await this.buildCatalogPushItems(orgId, storeId, entityIds);
1761
+ if (!assembled.ok) return assembled;
1762
+ const store = await this.getStoreRecord(orgId, storeId);
1763
+ if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
1764
+ const connector = this.connectors.get(store.provider);
1765
+ if (!connector?.pushCatalog) return PluginErr(`Catalog push is not supported by provider "${store.provider}".`);
1766
+ if (assembled.value.items.length === 0) return Ok({
1767
+ outcomes: [],
1768
+ skipped: assembled.value.skipped,
1769
+ warnings: assembled.value.warnings,
1770
+ });
1771
+
1772
+ const writeAhead = await this.recordOutboundPush(
1773
+ orgId,
1774
+ storeId,
1775
+ assembled.value.items.map((item) => ({ externalId: item.externalId, ok: true })),
1776
+ assembled.value.items,
1777
+ "write-ahead",
1778
+ );
1779
+ if (!writeAhead.ok) return writeAhead;
1780
+
1781
+ let result: Awaited<ReturnType<NonNullable<typeof connector.pushCatalog>>>;
1782
+ try {
1783
+ result = await connector.pushCatalog(store as ChannelStore, assembled.value.items);
1784
+ } catch (error) {
1785
+ const connectorError = {
1786
+ code: "CATALOG_PUSH_THROWN",
1787
+ message: error instanceof Error ? error.message : "Catalog push failed.",
1788
+ };
1789
+ const cleared = await this.recordOutboundPush(
1790
+ orgId,
1791
+ storeId,
1792
+ assembled.value.items.map((item) => ({ externalId: item.externalId, ok: false, error: connectorError })),
1793
+ assembled.value.items,
1794
+ );
1795
+ if (!cleared.ok) return cleared;
1796
+ return PluginErr(connectorError.message, connectorError.code);
1797
+ }
1798
+ if (!result.ok) {
1799
+ const cleared = await this.recordOutboundPush(
1800
+ orgId,
1801
+ storeId,
1802
+ assembled.value.items.map((item) => ({ externalId: item.externalId, ok: false, error: result.error })),
1803
+ assembled.value.items,
1804
+ );
1805
+ if (!cleared.ok) return cleared;
1806
+ return PluginErr(result.error.message, result.error.code);
1807
+ }
1808
+
1809
+ const recorded = await this.recordOutboundPush(orgId, storeId, result.value.outcomes, assembled.value.items);
1810
+ if (!recorded.ok) return recorded;
1811
+ const successfulEntityIds = assembled.value.items
1812
+ .filter((item) => result.value.outcomes.some((outcome) => outcome.externalId === item.externalId && outcome.ok))
1813
+ .map((item) => item.externalId);
1814
+ if (successfulEntityIds.length > 0) {
1815
+ const mappings = await this.db.select({ entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(
1816
+ eq(channelEntityMap.organizationId, orgId),
1817
+ eq(channelEntityMap.storeId, storeId),
1818
+ eq(channelEntityMap.kind, "entity"),
1819
+ inArray(channelEntityMap.externalId, successfulEntityIds),
1820
+ ));
1821
+ const actor = createSystemActor(orgId);
1822
+ try {
1823
+ await this.transact(async (tx) => {
1824
+ const txContext = createTxContext(tx, { actor });
1825
+ for (const entityId of [...new Set(mappings.map((mapping) => mapping.entityId))]) {
1826
+ const revision = await this.catalog.recordEntityRevision(entityId, actor, "push", txContext);
1827
+ if (!revision.ok) throw new Error(revision.error.message);
1828
+ }
1829
+ });
1830
+ } catch (error) {
1831
+ return PluginErr(error instanceof Error ? error.message : "Failed to record catalog push revisions.");
1832
+ }
1833
+ }
1834
+ return Ok({
1835
+ ...result.value,
1836
+ skipped: assembled.value.skipped,
1837
+ warnings: assembled.value.warnings,
1838
+ });
1839
+ }
1840
+
1841
+ async previewCatalogPush(
1842
+ orgId: string,
1843
+ storeId: string,
1844
+ entityIds?: string[],
1845
+ ): Promise<PluginResult<CatalogPushPreviewResult>> {
1846
+ const assembledEntityIds = await this.resolveCatalogPushEntityIds(orgId, storeId, entityIds);
1847
+ const assembled = await this.buildCatalogPushItems(orgId, storeId, assembledEntityIds);
1848
+ if (!assembled.ok) return assembled;
1849
+ const store = await this.getStoreRecord(orgId, storeId);
1850
+ if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
1851
+ const connector = this.connectors.get(store.provider);
1852
+ if (!connector?.pushCatalog) return PluginErr(`Catalog push is not supported by provider "${store.provider}".`);
1853
+ if (assembled.value.items.length === 0) {
1854
+ return Ok({ items: [], skipped: assembled.value.skipped, warnings: assembled.value.warnings });
1855
+ }
1856
+
1857
+ let result: Awaited<ReturnType<NonNullable<typeof connector.pushCatalog>>>;
1858
+ try {
1859
+ result = await connector.pushCatalog(store as ChannelStore, assembled.value.items, { dryRun: true });
1860
+ } catch (error) {
1861
+ return PluginErr(
1862
+ error instanceof Error ? error.message : "Catalog push preview failed.",
1863
+ "CATALOG_PREVIEW_THROWN",
1864
+ );
1865
+ }
1866
+ if (!result.ok) return PluginErr(result.error.message, result.error.code);
1867
+ const failed = result.value.outcomes.find((outcome) => !outcome.ok);
1868
+ if (failed) return PluginErr(
1869
+ failed.error?.message ?? `Catalog push preview failed for item "${failed.externalId}".`,
1870
+ failed.error?.code ?? "CATALOG_PREVIEW_FAILED",
1871
+ );
1872
+
1873
+ const mappings = assembledEntityIds.length === 0
1874
+ ? []
1875
+ : await this.db.select({ entityId: channelEntityMap.entityId, externalId: channelEntityMap.externalId }).from(channelEntityMap).where(and(
1876
+ eq(channelEntityMap.organizationId, orgId),
1877
+ eq(channelEntityMap.storeId, storeId),
1878
+ eq(channelEntityMap.kind, "entity"),
1879
+ inArray(channelEntityMap.entityId, assembledEntityIds),
1880
+ ));
1881
+ const externalByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping.externalId]));
1882
+ const skippedByExternal = new Map<string, CatalogPushFieldSkip[]>();
1883
+ for (const skipped of assembled.value.skipped) {
1884
+ const externalId = externalByEntity.get(skipped.entityId);
1885
+ if (!externalId) continue;
1886
+ const existing = skippedByExternal.get(externalId) ?? [];
1887
+ existing.push(skipped);
1888
+ skippedByExternal.set(externalId, existing);
1889
+ }
1890
+ const outcomeByExternalId = new Map(result.value.outcomes.map((outcome) => [outcome.externalId, outcome]));
1891
+ const beforeFor = (externalId: string, fieldPath: FieldPath): {
1892
+ before: CatalogPushPreviewBefore;
1893
+ beforeStatus: CatalogPushPreviewBeforeStatus;
1894
+ } => {
1895
+ const previousFields = outcomeByExternalId.get(externalId)?.previousFields;
1896
+ if (previousFields === undefined) {
1897
+ return { before: { status: "unavailable" }, beforeStatus: "unavailable" };
1898
+ }
1899
+ const previous = previousFields.find((field) => field.fieldPath === fieldPath);
1900
+ if (!previous) return { before: null, beforeStatus: "missing" };
1901
+ return { before: previous.value, beforeStatus: "value" };
1902
+ };
1903
+
1904
+ const items = assembled.value.items.map((item) => {
1905
+ const diffs: CatalogPushPreviewDiff[] = item.fields.map((field) => ({
1906
+ fieldPath: field.fieldPath,
1907
+ target: field.target,
1908
+ remoteKey: field.remoteKey ?? null,
1909
+ ...beforeFor(item.externalId, field.fieldPath),
1910
+ after: field.value,
1911
+ owner: "platform",
1912
+ willWrite: true,
1913
+ }));
1914
+ for (const image of item.images ?? []) {
1915
+ diffs.push({
1916
+ fieldPath: image.fieldPath,
1917
+ target: image.target,
1918
+ remoteKey: image.remoteKey,
1919
+ ...beforeFor(item.externalId, image.fieldPath),
1920
+ after: pushFieldValue(item, image.fieldPath),
1921
+ owner: "platform",
1922
+ willWrite: true,
1923
+ });
1924
+ }
1925
+ for (const skipped of skippedByExternal.get(item.externalId) ?? []) {
1926
+ if (skipped.value === undefined || skipped.owner === undefined) continue;
1927
+ diffs.push({
1928
+ fieldPath: skipped.fieldPath,
1929
+ target: skipped.target ?? null,
1930
+ remoteKey: skipped.remoteKey ?? null,
1931
+ ...beforeFor(item.externalId, skipped.fieldPath),
1932
+ after: skipped.value,
1933
+ owner: skipped.owner,
1934
+ willWrite: false,
1935
+ reason: skipped.reason,
1936
+ });
1937
+ }
1938
+ return { externalId: item.externalId, diffs };
1939
+ });
1940
+ return Ok({ items, skipped: assembled.value.skipped, warnings: assembled.value.warnings });
1941
+ }
1942
+
1943
+ async getCatalogWriteSettings(orgId: string, storeId: string): Promise<PluginResult<CatalogWriteSettings>> {
1944
+ const store = await this.getStoreRecord(orgId, storeId);
1945
+ if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
1946
+ const warnings: string[] = [];
1947
+ return Ok({
1948
+ enabled: store.catalogWriteEnabled === true,
1949
+ overrides: store.catalogFieldMapping,
1950
+ merged: this.resolveCatalogFieldMapping(store, undefined, warnings),
1951
+ ...(warnings.length > 0 ? { warnings } : {}),
1952
+ });
1953
+ }
1954
+
1955
+ async updateCatalogWriteEnabled(
1956
+ orgId: string,
1957
+ storeId: string,
1958
+ enabled: boolean,
1959
+ ): Promise<PluginResult<CatalogWriteSettings>> {
1960
+ const rows = await this.db
1961
+ .update(connectedStores)
1962
+ .set({ catalogWriteEnabled: enabled, updatedAt: new Date() })
1963
+ .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)))
1964
+ .returning();
1965
+ if (!rows[0]) return PluginErr("Connected store not found.", "NOT_FOUND");
1966
+ return this.getCatalogWriteSettings(orgId, storeId);
1967
+ }
1968
+
1969
+ async updateCatalogFieldMapping(
1970
+ orgId: string,
1971
+ storeId: string,
1972
+ mapping: unknown,
1973
+ ): Promise<PluginResult<CatalogWriteSettings>> {
1974
+ const store = await this.getStoreRecord(orgId, storeId);
1975
+ if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
1976
+ let normalized: CatalogFieldMapping;
1977
+ try {
1978
+ normalized = normalizeCatalogFieldMapping(mapping as CatalogFieldMappingInput, store.provider);
1979
+ } catch (error) {
1980
+ return PluginErr(error instanceof Error ? error.message : "Catalog mapping is invalid.", "INVALID_MAPPING");
1981
+ }
1982
+ await this.db
1983
+ .update(connectedStores)
1984
+ .set({ catalogFieldMapping: normalized, updatedAt: new Date() })
1985
+ .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
1986
+ return this.getCatalogWriteSettings(orgId, storeId);
1987
+ }
1988
+
1989
+ async connectStore(
1990
+ orgId: string,
1991
+ input: {
1992
+ provider: string;
1993
+ credentials: Record<string, unknown>;
1994
+ storeDomain: string;
1995
+ webhookSecret?: string;
1996
+ },
1997
+ ): Promise<PluginResult<PublicConnectedStore>> {
1998
+ if (!this.connectors.has(input.provider)) {
1999
+ return PluginErr(`No connector registered for provider "${input.provider}".`, "NOT_FOUND");
2000
+ }
2001
+ const existingRows = await this.db
2002
+ .select()
2003
+ .from(connectedStores)
2004
+ .where(and(
2005
+ eq(connectedStores.organizationId, orgId),
2006
+ eq(connectedStores.provider, input.provider),
2007
+ eq(connectedStores.storeDomain, input.storeDomain),
2008
+ ));
2009
+ const reconnect = existingRows.find((row) => row.status !== "connected");
2010
+ const rows = reconnect
2011
+ ? await this.db
2012
+ .update(connectedStores)
2013
+ .set({
2014
+ credentials: input.credentials,
2015
+ status: "connected",
2016
+ catalogWriteEnabled: false,
2017
+ webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
2018
+ updatedAt: new Date(),
2019
+ })
2020
+ .where(eq(connectedStores.id, reconnect.id))
2021
+ .returning()
2022
+ : await this.db
2023
+ .insert(connectedStores)
2024
+ .values({
2025
+ organizationId: orgId,
2026
+ provider: input.provider,
2027
+ credentials: input.credentials,
2028
+ storeDomain: input.storeDomain,
2029
+ webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
2030
+ })
2031
+ .returning();
2032
+ const connector = this.connectors.get(input.provider)!;
2033
+ const store = rows[0] as ConnectedStore;
2034
+ if (connector.registerWebhooks) {
2035
+ const registration = await connector.registerWebhooks(store as ChannelStore, [
2036
+ "products/update",
2037
+ "products/delete",
2038
+ "inventory_levels/update",
2039
+ "orders/fulfilled",
2040
+ "orders/cancelled",
2041
+ "refunds/create",
2042
+ "app/uninstalled",
2043
+ ], `/api/channels/webhooks/${store.id}`);
2044
+ if (!registration.ok) {
2045
+ await this.db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
2046
+ return PluginErr(registration.error.message, "CONNECTOR_REGISTRATION_FAILED");
2047
+ }
2048
+ }
2049
+ const jobs = this.optionsJobs;
2050
+ if (jobs) {
2051
+ await jobs.enqueue("channel/import-catalog", { orgId, storeId: (rows[0] as ConnectedStore).id }, {
2052
+ organizationId: orgId,
2053
+ concurrencyKey: (rows[0] as ConnectedStore).id,
2054
+ supersedes: true,
2055
+ });
2056
+ }
2057
+ return Ok(redactStore(store));
2058
+ }
2059
+
2060
+ private get optionsJobs(): JobsAdapter | undefined {
2061
+ return this.jobs;
2062
+ }
2063
+
2064
+ async disconnectStore(orgId: string, id: string): Promise<PluginResult<PublicConnectedStore>> {
2065
+ return this.disconnectStoreSystem(orgId, id);
2066
+ }
2067
+
2068
+ async disconnectStoreSystem(orgId: string, id: string, redactDomain = false): Promise<PluginResult<PublicConnectedStore>> {
2069
+ const rows = await this.db
2070
+ .update(connectedStores)
2071
+ .set({
2072
+ status: "disconnected",
2073
+ credentials: {},
2074
+ webhookSecret: null,
2075
+ ...(redactDomain ? { storeDomain: "[REDACTED]" } : {}),
2076
+ updatedAt: new Date(),
2077
+ })
2078
+ .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, id)))
2079
+ .returning();
2080
+ const store = rows[0] as ConnectedStore | undefined;
2081
+ if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
2082
+ return Ok(redactStore(store));
2083
+ }
2084
+
2085
+ async getStore(orgId: string, id: string): Promise<PluginResult<PublicConnectedStore>> {
2086
+ const store = await this.getStoreRecord(orgId, id);
288
2087
  if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
289
2088
  return Ok(redactStore(store));
290
2089
  }
@@ -365,38 +2164,354 @@ export class ChannelConnectorService {
365
2164
  throw new CommerceValidationError(stockFailure(line, `only ${available ?? 0} available for ${line.quantity} requested`));
366
2165
  }
367
2166
  }
368
- }));
369
- }
2167
+ }));
2168
+ }
2169
+
2170
+ async importCatalog(
2171
+ orgId: string,
2172
+ storeId: string,
2173
+ actor: Actor,
2174
+ ): Promise<PluginResult<{ imported: number; cursor: string | null; skipped?: CatalogFieldSkip[]; conflicts?: CatalogFieldConflict[]; warnings?: string[] }>> {
2175
+ const store = await this.getStoreRecord(orgId, storeId);
2176
+ if (!store || store.status !== "connected") {
2177
+ return PluginErr("Connected store not found.", "NOT_FOUND");
2178
+ }
2179
+ const connector = this.connectors.get(store.provider);
2180
+ if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`);
2181
+
2182
+ const items: ChannelCatalogItem[] = [];
2183
+ let cursor: string | undefined = store.catalogCursor ?? undefined;
2184
+ do {
2185
+ const page = await connector.importCatalog(store as ChannelStore, cursor);
2186
+ if (!page.ok) return PluginErr(page.error.message);
2187
+ items.push(...page.value.items);
2188
+ cursor = page.value.nextCursor ?? undefined;
2189
+ } while (cursor);
2190
+
2191
+ const result = await this.convergeCatalogItems(orgId, storeId, items, actor);
2192
+ if (!result.ok) return result;
2193
+
2194
+ await this.db
2195
+ .update(connectedStores)
2196
+ .set({ catalogCursor: null, lastSyncAt: new Date(), updatedAt: new Date() })
2197
+ .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
2198
+ return Ok({
2199
+ imported: result.value.imported,
2200
+ cursor: null,
2201
+ ...(result.value.skipped.length > 0 ? { skipped: uniqueSkipped(result.value.skipped) } : {}),
2202
+ ...(result.value.conflicts.length > 0 ? { conflicts: result.value.conflicts } : {}),
2203
+ ...(result.value.warnings.length > 0 ? { warnings: result.value.warnings } : {}),
2204
+ });
2205
+ }
2206
+
2207
+ private async promoteLegacyAttributes(
2208
+ orgId: string,
2209
+ storeId: string,
2210
+ actor: Actor,
2211
+ dryRun: boolean,
2212
+ ): Promise<PluginResult<number>> {
2213
+ const mappings = await this.db.select().from(channelEntityMap).where(and(
2214
+ eq(channelEntityMap.organizationId, orgId),
2215
+ eq(channelEntityMap.storeId, storeId),
2216
+ eq(channelEntityMap.kind, "entity"),
2217
+ ));
2218
+ let created = 0;
2219
+ for (const entityId of new Set(mappings.map((mapping) => mapping.entityId))) {
2220
+ const [entity] = await this.db.select().from(sellableEntities).where(and(
2221
+ eq(sellableEntities.organizationId, orgId),
2222
+ eq(sellableEntities.id, entityId),
2223
+ ));
2224
+ if (!entity) continue;
2225
+ const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
2226
+ if (attributes.length > 0) continue;
2227
+ const metadata = entity.metadata ?? {};
2228
+ if (typeof metadata.title !== "string") continue;
2229
+ const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
2230
+ if (owners.get("attributes.en.title") === "platform") continue;
2231
+ if (dryRun) {
2232
+ created += 1;
2233
+ continue;
2234
+ }
2235
+ const promoted = await this.catalog.setAttributes(entity.id, "en", {
2236
+ title: metadata.title,
2237
+ ...(typeof metadata.description === "string" ? { description: metadata.description } : {}),
2238
+ }, actor, CHANNEL_CONVERGENCE_CTX);
2239
+ if (!promoted.ok) return PluginErr(promoted.error.message);
2240
+ const [confirmed] = await this.db.select({ id: sellableAttributes.id, title: sellableAttributes.title, description: sellableAttributes.description }).from(sellableAttributes).where(and(
2241
+ eq(sellableAttributes.entityId, entity.id),
2242
+ eq(sellableAttributes.locale, "en"),
2243
+ ));
2244
+ if (!confirmed || confirmed.title !== metadata.title || (typeof metadata.description === "string" && confirmed.description !== metadata.description)) {
2245
+ return PluginErr(`Legacy attributes for entity "${entity.id}" were not persisted.`);
2246
+ }
2247
+ const nextMetadata = { ...metadata };
2248
+ delete nextMetadata.title;
2249
+ if (typeof metadata.description === "string") delete nextMetadata.description;
2250
+ await this.db.update(sellableEntities).set({ metadata: nextMetadata, updatedAt: new Date() }).where(and(
2251
+ eq(sellableEntities.organizationId, orgId),
2252
+ eq(sellableEntities.id, entity.id),
2253
+ ));
2254
+ created += 1;
2255
+ }
2256
+ return Ok(created);
2257
+ }
2258
+
2259
+ private async saveBackfillState(orgId: string, storeId: string, state: BackfillState): Promise<void> {
2260
+ const [store] = await this.db.select({ breakerState: connectedStores.breakerState }).from(connectedStores).where(and(
2261
+ eq(connectedStores.organizationId, orgId),
2262
+ eq(connectedStores.id, storeId),
2263
+ ));
2264
+ await this.db.update(connectedStores).set({
2265
+ breakerState: { ...(store?.breakerState ?? {}), catalogBackfill: state },
2266
+ updatedAt: new Date(),
2267
+ }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
2268
+ }
2269
+
2270
+ async backfillCatalog(
2271
+ orgId: string,
2272
+ storeId: string,
2273
+ actor: Actor,
2274
+ options: BackfillCatalogOptions = {},
2275
+ ): Promise<PluginResult<BackfillCatalogReport>> {
2276
+ const store = await this.getStoreRecord(orgId, storeId);
2277
+ if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
2278
+ const connector = this.connectors.get(store.provider);
2279
+ if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`);
2280
+ const dryRun = options.dryRun === true;
2281
+ const saved = store.breakerState.catalogBackfill;
2282
+ const savedState = saved && typeof saved === "object" ? saved as unknown as BackfillState : undefined;
2283
+ // Undefined resume derives from persisted state, so a retried job or a
2284
+ // re-triggered run continues an unfinished backfill instead of restarting.
2285
+ const resume = options.resume ?? (savedState !== undefined && !savedState.completedAt);
2286
+ if (resume && savedState?.completedAt && savedState.cursor === null) {
2287
+ return Ok({
2288
+ ...savedState.report,
2289
+ cursor: null,
2290
+ complete: true,
2291
+ ...(savedState.skipped?.length ? { skipped: savedState.skipped } : {}),
2292
+ ...(savedState.conflicts?.length ? { conflicts: savedState.conflicts } : {}),
2293
+ ...(savedState.warnings?.length ? { warnings: savedState.warnings } : {}),
2294
+ });
2295
+ }
2296
+ const report = resume && savedState ? { ...savedState.report } : {
2297
+ entitiesTouched: 0,
2298
+ attributesCreated: 0,
2299
+ mediaImported: 0,
2300
+ variantsGivenOptionValues: 0,
2301
+ };
2302
+ const skipped = resume && savedState?.skipped ? [...savedState.skipped] : [];
2303
+ const conflicts = resume && savedState?.conflicts ? [...savedState.conflicts] : [];
2304
+ const warnings = resume && savedState?.warnings ? [...savedState.warnings] : [];
2305
+ const promoted = await this.promoteLegacyAttributes(orgId, storeId, actor, dryRun);
2306
+ if (!promoted.ok) return promoted;
2307
+ report.attributesCreated += promoted.value;
2308
+ let cursor = resume && savedState?.cursor ? savedState.cursor : undefined;
2309
+ let pages = 0;
2310
+ if (!dryRun) {
2311
+ await this.saveBackfillState(orgId, storeId, {
2312
+ cursor: cursor ?? null,
2313
+ report,
2314
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
2315
+ ...(conflicts.length > 0 ? { conflicts } : {}),
2316
+ ...(warnings.length > 0 ? { warnings } : {}),
2317
+ });
2318
+ }
2319
+ do {
2320
+ const page = await connector.importCatalog(store as ChannelStore, cursor);
2321
+ if (!page.ok) return PluginErr(page.error.message);
2322
+ const converged = await this.convergeCatalogItems(orgId, storeId, page.value.items, actor, true, dryRun);
2323
+ if (!converged.ok) return converged;
2324
+ report.entitiesTouched += converged.value.entitiesTouched;
2325
+ report.attributesCreated += converged.value.attributesCreated;
2326
+ report.mediaImported += converged.value.mediaImported;
2327
+ report.variantsGivenOptionValues += converged.value.variantsGivenOptionValues;
2328
+ skipped.push(...converged.value.skipped);
2329
+ conflicts.push(...converged.value.conflicts);
2330
+ warnings.push(...converged.value.warnings);
2331
+ cursor = page.value.nextCursor ?? undefined;
2332
+ pages += 1;
2333
+ // The final state is written once with completedAt below; a cursor-null
2334
+ // checkpoint without it would read as a fresh start after a crash.
2335
+ if (!dryRun && cursor) {
2336
+ await this.saveBackfillState(orgId, storeId, {
2337
+ cursor,
2338
+ report,
2339
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
2340
+ ...(conflicts.length > 0 ? { conflicts } : {}),
2341
+ ...(warnings.length > 0 ? { warnings } : {}),
2342
+ });
2343
+ }
2344
+ if (options.maxPages !== undefined && pages >= options.maxPages && cursor) {
2345
+ return Ok({
2346
+ ...report,
2347
+ cursor,
2348
+ complete: false,
2349
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
2350
+ ...(conflicts.length > 0 ? { conflicts } : {}),
2351
+ ...(warnings.length > 0 ? { warnings } : {}),
2352
+ });
2353
+ }
2354
+ } while (cursor);
2355
+ if (!dryRun) {
2356
+ await this.saveBackfillState(orgId, storeId, {
2357
+ cursor: null,
2358
+ report,
2359
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
2360
+ ...(conflicts.length > 0 ? { conflicts } : {}),
2361
+ ...(warnings.length > 0 ? { warnings } : {}),
2362
+ completedAt: new Date().toISOString(),
2363
+ });
2364
+ }
2365
+ return Ok({
2366
+ ...report,
2367
+ cursor: null,
2368
+ complete: true,
2369
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
2370
+ ...(conflicts.length > 0 ? { conflicts } : {}),
2371
+ ...(warnings.length > 0 ? { warnings } : {}),
2372
+ });
2373
+ }
2374
+
2375
+ private async estimateCatalogItems(
2376
+ orgId: string,
2377
+ storeId: string,
2378
+ items: ChannelCatalogItem[],
2379
+ ): Promise<PluginResult<CatalogConvergenceStats>> {
2380
+ const stats: CatalogConvergenceStats = {
2381
+ imported: 0,
2382
+ converged: 0,
2383
+ entitiesTouched: 0,
2384
+ attributesCreated: 0,
2385
+ mediaImported: 0,
2386
+ variantsGivenOptionValues: 0,
2387
+ skipped: [],
2388
+ conflicts: [],
2389
+ warnings: [],
2390
+ };
2391
+ const assets = await this.db.select().from(mediaAssets).where(eq(mediaAssets.organizationId, orgId));
2392
+ for (const item of items) {
2393
+ const [entityMapping] = await this.db.select().from(channelEntityMap).where(and(
2394
+ eq(channelEntityMap.organizationId, orgId),
2395
+ eq(channelEntityMap.storeId, storeId),
2396
+ eq(channelEntityMap.kind, "entity"),
2397
+ eq(channelEntityMap.externalId, item.externalId),
2398
+ ));
2399
+ if (!entityMapping) {
2400
+ stats.imported += 1;
2401
+ stats.entitiesTouched += 1;
2402
+ stats.attributesCreated += item.attributes?.length || 1;
2403
+ stats.variantsGivenOptionValues += item.variants.filter((variant) => Object.keys(variant.optionValues ?? {}).some((name) => item.options?.some((option) => option.name === name))).length;
2404
+ stats.mediaImported += item.images?.length ?? 0;
2405
+ continue;
2406
+ }
2407
+ const [entity] = await this.db.select().from(sellableEntities).where(and(
2408
+ eq(sellableEntities.organizationId, orgId),
2409
+ eq(sellableEntities.id, entityMapping.entityId),
2410
+ ));
2411
+ if (!entity) continue;
2412
+ const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
2413
+ stats.skipped.push(...importedFieldPaths(item)
2414
+ .filter((path) => owners.get(path) === "platform")
2415
+ .map((fieldPath) => ({ entityId: entity.id, fieldPath })));
2416
+ let touched = false;
2417
+ const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
2418
+ const locales = new Set(attributes.map((attribute) => attribute.locale));
2419
+ const metadata = entity.metadata ?? {};
2420
+ if (attributes.length === 0 && typeof metadata.title === "string") {
2421
+ locales.add("en");
2422
+ touched = true;
2423
+ }
2424
+ const sourceAttributes = item.attributes?.length
2425
+ ? item.attributes
2426
+ : [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
2427
+ for (const attribute of sourceAttributes) {
2428
+ if (!locales.has(attribute.locale)) {
2429
+ stats.attributesCreated += 1;
2430
+ locales.add(attribute.locale);
2431
+ touched = true;
2432
+ }
2433
+ }
2434
+ const remoteMetadata = mergeMetadata(entity.metadata, item.metadata ?? {});
2435
+ const remoteStatus = item.status ?? (entity.status === "archived" ? "active" : undefined);
2436
+ const entityChanged = entity.slug !== item.slug
2437
+ || hash(remoteMetadata) !== hash(entity.metadata ?? {})
2438
+ || (remoteStatus !== undefined && remoteStatus !== entity.status);
2439
+ if (entityChanged) {
2440
+ stats.converged += 1;
2441
+ touched = true;
2442
+ }
2443
+
2444
+ const optionValueIds = new Map<string, Map<string, string>>();
2445
+ const existingTypes = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entity.id));
2446
+ for (const sourceType of item.options ?? []) {
2447
+ const existingType = existingTypes.find((optionType) => optionType.name === sourceType.name);
2448
+ if (!existingType) {
2449
+ touched = true;
2450
+ optionValueIds.set(sourceType.name, new Map(sourceType.values.map((value) => [value.value, `new:${sourceType.name}:${value.value}`])));
2451
+ continue;
2452
+ }
2453
+ const existingValues = await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, existingType.id));
2454
+ const valueIds = new Map<string, string>();
2455
+ for (const sourceValue of sourceType.values) {
2456
+ const existingValue = existingValues.find((value) => value.value === sourceValue.value);
2457
+ if (!existingValue) touched = true;
2458
+ valueIds.set(sourceValue.value, existingValue?.id ?? `new:${sourceType.name}:${sourceValue.value}`);
2459
+ }
2460
+ optionValueIds.set(sourceType.name, valueIds);
2461
+ }
2462
+
2463
+ const variantMappings = await this.db.select().from(channelEntityMap).where(and(
2464
+ eq(channelEntityMap.organizationId, orgId),
2465
+ eq(channelEntityMap.storeId, storeId),
2466
+ eq(channelEntityMap.kind, "variant"),
2467
+ eq(channelEntityMap.entityId, entity.id),
2468
+ ));
2469
+ const variantIds = new Map<string, string>();
2470
+ for (const sourceVariant of item.variants) {
2471
+ const mapping = variantMappings.find((row) => row.externalId === sourceVariant.externalId);
2472
+ const variantId = mapping?.variantId ?? `new:${sourceVariant.externalId}`;
2473
+ variantIds.set(sourceVariant.externalId, variantId);
2474
+ const desiredIds = [...new Set(Object.entries(sourceVariant.optionValues ?? {})
2475
+ .map(([name, value]) => optionValueIds.get(name)?.get(value))
2476
+ .filter((optionValueId): optionValueId is string => optionValueId !== undefined))].sort();
2477
+ if (!mapping?.variantId) {
2478
+ if (desiredIds.length > 0) stats.variantsGivenOptionValues += 1;
2479
+ touched = true;
2480
+ continue;
2481
+ }
2482
+ const current = await this.db.select().from(variantOptionValues).where(eq(variantOptionValues.variantId, mapping.variantId));
2483
+ const currentIds = current.map((row) => row.optionValueId).sort();
2484
+ if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
2485
+ if (desiredIds.length > 0) stats.variantsGivenOptionValues += 1;
2486
+ touched = true;
2487
+ }
2488
+ }
370
2489
 
371
- async importCatalog(
372
- orgId: string,
373
- storeId: string,
374
- actor: Actor,
375
- ): Promise<PluginResult<{ imported: number; cursor: string | null }>> {
376
- const store = await this.getStoreRecord(orgId, storeId);
377
- if (!store || store.status !== "connected") {
378
- return PluginErr("Connected store not found.", "NOT_FOUND");
2490
+ const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entity.id));
2491
+ for (const image of item.images ?? []) {
2492
+ const urlHash = hash(image.url);
2493
+ const asset = assets.find((row) => {
2494
+ const assetMetadata = row.metadata ?? {};
2495
+ return (image.externalId != null && assetMetadata.channelImageExternalId === image.externalId)
2496
+ || assetMetadata.channelImageUrlHash === urlHash;
2497
+ });
2498
+ const mediaAssetId = asset?.id ?? `new:${urlHash}`;
2499
+ if (!asset) stats.mediaImported += 1;
2500
+ const targets = image.variantExternalIds?.length
2501
+ ? image.variantExternalIds.map((externalId) => ({ externalId, variantId: variantIds.get(externalId) }))
2502
+ : [{ externalId: undefined, variantId: undefined }];
2503
+ for (const target of targets) {
2504
+ if (image.variantExternalIds?.length && !target.variantId) {
2505
+ stats.warnings.push(`Skipped image "${image.externalId ?? image.url}" for unmapped variant "${target.externalId}".`);
2506
+ continue;
2507
+ }
2508
+ const existingLink = links.find((link) => link.mediaAssetId === mediaAssetId && (target.variantId === undefined ? link.variantId === null : link.variantId === target.variantId));
2509
+ if (!existingLink) touched = true;
2510
+ }
2511
+ }
2512
+ if (touched) stats.entitiesTouched += 1;
379
2513
  }
380
- const connector = this.connectors.get(store.provider);
381
- if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`);
382
-
383
- const items: ChannelCatalogItem[] = [];
384
- let cursor: string | undefined = store.catalogCursor ?? undefined;
385
- do {
386
- const page = await connector.importCatalog(store as ChannelStore, cursor);
387
- if (!page.ok) return PluginErr(page.error.message);
388
- items.push(...page.value.items);
389
- cursor = page.value.nextCursor ?? undefined;
390
- } while (cursor);
391
-
392
- const result = await this.convergeCatalogItems(orgId, storeId, items, actor);
393
- if (!result.ok) return result;
394
-
395
- await this.db
396
- .update(connectedStores)
397
- .set({ catalogCursor: null, lastSyncAt: new Date(), updatedAt: new Date() })
398
- .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
399
- return Ok({ imported: result.value.imported, cursor: null });
2514
+ return Ok(stats);
400
2515
  }
401
2516
 
402
2517
  private async convergeCatalogItems(
@@ -404,10 +2519,21 @@ export class ChannelConnectorService {
404
2519
  storeId: string,
405
2520
  items: ChannelCatalogItem[],
406
2521
  actor: Actor,
407
- ): Promise<PluginResult<{ imported: number; converged: number }>> {
2522
+ force = false,
2523
+ dryRun = false,
2524
+ ): Promise<PluginResult<CatalogConvergenceStats>> {
2525
+ if (dryRun) return this.estimateCatalogItems(orgId, storeId, items);
408
2526
  let imported = 0;
409
2527
  let converged = 0;
2528
+ let entitiesTouched = 0;
2529
+ let attributesCreated = 0;
2530
+ let mediaImported = 0;
2531
+ let variantsGivenOptionValues = 0;
2532
+ const skipped: CatalogFieldSkip[] = [];
2533
+ const conflicts: CatalogFieldConflict[] = [];
2534
+ const warnings: string[] = [];
410
2535
  for (const item of items) {
2536
+ const remoteHash = hash(item);
411
2537
  const existing = await this.db
412
2538
  .select()
413
2539
  .from(channelEntityMap)
@@ -418,76 +2544,183 @@ export class ChannelConnectorService {
418
2544
  eq(channelEntityMap.externalId, item.externalId),
419
2545
  ));
420
2546
  const entityMapping = existing.find((entry) => entry.kind === "entity");
2547
+ let entityId: string;
2548
+ let isNew = false;
2549
+ let entityTouched = false;
2550
+ let existingEntity: typeof sellableEntities.$inferSelect | undefined;
421
2551
  if (entityMapping) {
422
2552
  const [entity] = await this.db.select().from(sellableEntities).where(and(
423
2553
  eq(sellableEntities.organizationId, orgId),
424
2554
  eq(sellableEntities.id, entityMapping.entityId),
425
2555
  ));
426
- if (entityMapping.syncHash !== hash(item) || entity?.status === "archived") {
427
- const updated = await this.catalog.update(entityMapping.entityId, {
428
- slug: item.slug,
429
- metadata: {
430
- ...(item.metadata ?? {}),
431
- title: item.title,
432
- ...(item.description !== undefined ? { description: item.description } : {}),
433
- },
434
- ...(entity?.status === "archived" ? { status: "active", isVisible: true } : {}),
435
- }, actor);
436
- if (!updated.ok) return PluginErr(updated.error.message);
437
- await this.db.update(channelEntityMap).set({ syncHash: hash(item), lastSyncedAt: new Date() }).where(eq(channelEntityMap.id, entityMapping.id));
438
- converged += 1;
2556
+ if (!entity) {
2557
+ warnings.push(`Skipped "${item.externalId}": mapped entity ${entityMapping.entityId} no longer exists.`);
2558
+ continue;
439
2559
  }
440
- continue;
441
- }
442
-
443
- const entity = await this.catalog.create(
444
- {
2560
+ entityId = entityMapping.entityId;
2561
+ existingEntity = entity;
2562
+ } else {
2563
+ const status = item.status;
2564
+ const entity = await this.catalog.create({
445
2565
  type: "product",
446
2566
  slug: item.slug,
447
2567
  sourceStoreId: storeId,
448
- metadata: {
449
- ...(item.metadata ?? {}),
450
- title: item.title,
451
- ...(item.description !== undefined ? { description: item.description } : {}),
452
- },
453
- },
454
- actor,
2568
+ metadata: mergeMetadata(undefined, item.metadata ?? {}),
2569
+ ...(status !== undefined ? { status, isVisible: status === "active" } : {}),
2570
+ }, actor);
2571
+ if (!entity.ok) return PluginErr(entity.error.message);
2572
+ entityId = entity.value.id;
2573
+ isNew = true;
2574
+ imported += 1;
2575
+ entityTouched = true;
2576
+ }
2577
+
2578
+ const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
2579
+ const seedPaths = importedFieldPaths(item).filter((path) => !ownershipBeforeSeed.has(path));
2580
+ const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
2581
+ if (!seeded.ok) return PluginErr(seeded.error.message);
2582
+ for (const path of seedPaths) ownershipBeforeSeed.set(path, "store");
2583
+ const owners = ownershipBeforeSeed;
2584
+ const outboundEcho = entityMapping ? this.isOutboundEcho(entityMapping, item) : false;
2585
+ const remoteChanged = entityMapping === undefined || entityMapping.syncHash !== remoteHash;
2586
+ // An unchanged remote item writes nothing and advances no baseline:
2587
+ // converging a stale replay would revert local edits to shared and
2588
+ // unowned fields that the store never actually changed.
2589
+ if (!force && !remoteChanged && existingEntity && existingEntity.status !== "archived") {
2590
+ continue;
2591
+ }
2592
+ const shared = existingEntity
2593
+ ? await this.detectSharedConflicts(
2594
+ entityId, storeId, existingEntity, entityMapping, item, owners,
2595
+ importedFieldPaths(item), remoteHash,
2596
+ outboundEcho ? { certifiedPaths: new Set(entityMapping?.outboundFieldPaths ?? []) } : undefined,
2597
+ )
2598
+ : { paths: [], conflicts: [] };
2599
+ const persistedConflicts = await this.persistCatalogConflicts(orgId, shared.conflicts, requireUserId(actor));
2600
+ if (!persistedConflicts.ok) return persistedConflicts;
2601
+ const owned = this.filterOwnedFields(item, owners);
2602
+ const heldSharedPaths = [...new Set([...(entityMapping?.heldFieldPaths ?? []), ...shared.paths])];
2603
+ // A newly held path revokes any force left from an earlier resolution of
2604
+ // that same path: the force was the operator's answer to a question that
2605
+ // has since been asked again, and it must not pre-empt the new one.
2606
+ const survivingForcedPaths = (entityMapping?.forcedPushFieldPaths ?? []).filter(
2607
+ (path) => !heldSharedPaths.includes(path),
455
2608
  );
456
- if (!entity.ok) return PluginErr(entity.error.message);
2609
+ const held = this.filterConflictingFields(owned.writable, heldSharedPaths);
2610
+ const writable = held.writable;
2611
+ const blockedPaths = new Set<FieldPath>([...owned.skipped, ...heldSharedPaths]);
2612
+ skipped.push(...owned.skipped.map((fieldPath) => ({ entityId, fieldPath })));
2613
+ conflicts.push(...shared.conflicts.map(({ platformValue: _platformValue, storeValue: _storeValue, ...conflict }) => conflict));
2614
+ for (const conflict of shared.conflicts) {
2615
+ warnings.push(`Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
2616
+ }
457
2617
 
458
- await this.db.insert(channelEntityMap).values({
459
- organizationId: orgId,
2618
+ if (existingEntity && entityMapping) {
2619
+ const remoteMetadata = mergeMetadata(existingEntity.metadata, writable.metadata ?? {});
2620
+ const remoteStatus = ownerAllows(owners, "entity.status") && !blockedPaths.has("entity.status")
2621
+ ? writable.status ?? (existingEntity.status === "archived" ? "active" : undefined)
2622
+ : undefined;
2623
+ const updateInput: {
2624
+ slug?: string;
2625
+ metadata?: Record<string, unknown>;
2626
+ status?: string;
2627
+ isVisible?: boolean;
2628
+ } = {};
2629
+ if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== writable.slug) {
2630
+ updateInput.slug = writable.slug;
2631
+ }
2632
+ if (hash(remoteMetadata) !== hash(existingEntity.metadata ?? {})) updateInput.metadata = remoteMetadata;
2633
+ if (remoteStatus !== undefined && !blockedPaths.has("entity.status") && remoteStatus !== existingEntity.status) {
2634
+ updateInput.status = remoteStatus;
2635
+ updateInput.isVisible = remoteStatus === "active";
2636
+ }
2637
+ const shouldUpdate = force
2638
+ ? Object.keys(updateInput).length > 0
2639
+ : remoteChanged || existingEntity.status === "archived";
2640
+ if (shouldUpdate) {
2641
+ converged += 1;
2642
+ if (Object.keys(updateInput).length > 0) {
2643
+ const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
2644
+ if (!updated.ok) return PluginErr(updated.error.message);
2645
+ entityTouched = true;
2646
+ }
2647
+ }
2648
+ }
2649
+
2650
+ const optionAxes = await this.upsertOptionAxes(entityId, writable, actor);
2651
+ if (!optionAxes.ok) return optionAxes;
2652
+ const attributes = await this.setCatalogAttributesIfWritable(entityId, writable, actor, blockedPaths, CHANNEL_CONVERGENCE_CTX);
2653
+ if (!attributes.ok) return attributes;
2654
+ const variantIds = await this.upsertVariants(
2655
+ orgId,
460
2656
  storeId,
461
- kind: "entity",
462
- externalId: item.externalId,
463
- entityId: entity.value.id,
464
- syncHash: hash(item),
465
- });
2657
+ entityId,
2658
+ writable,
2659
+ optionAxes.value.value,
2660
+ actor,
2661
+ warnings,
2662
+ !heldSharedPaths.includes("options") && owners.get("options") !== "platform",
2663
+ item,
2664
+ );
2665
+ if (!variantIds.ok) return variantIds;
2666
+ const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
2667
+ if (!taxonomy.ok) return taxonomy;
2668
+ const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
2669
+ if (!media.ok) return media;
2670
+ attributesCreated += attributes.value.created;
2671
+ mediaImported += media.value.imported;
2672
+ variantsGivenOptionValues += variantIds.value.repaired;
2673
+ skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
2674
+ entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
2675
+ if (entityTouched) entitiesTouched += 1;
466
2676
 
467
- for (const sourceVariant of item.variants) {
468
- const variant = await this.catalog.createVariant(
469
- {
470
- entityId: entity.value.id,
471
- options: {},
472
- ...(sourceVariant.sku !== undefined ? { sku: sourceVariant.sku } : {}),
473
- ...(sourceVariant.barcode !== undefined ? { barcode: sourceVariant.barcode } : {}),
474
- },
475
- actor,
476
- );
477
- if (!variant.ok) return PluginErr(variant.error.message);
2677
+ if (entityTouched) {
2678
+ const revision = await this.catalog.recordEntityRevision(entityId, actor, "import");
2679
+ if (!revision.ok) return PluginErr(revision.error.message);
2680
+ }
2681
+
2682
+ const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
2683
+ const latestRevisionAt = revisionMarkers.at(-1)?.createdAt;
2684
+ const lastSyncedAt = latestRevisionAt ?? entityMapping?.lastSyncedAt ?? new Date();
2685
+
2686
+ if (isNew) {
478
2687
  await this.db.insert(channelEntityMap).values({
479
2688
  organizationId: orgId,
480
2689
  storeId,
481
- kind: "variant",
482
- externalId: sourceVariant.externalId,
483
- entityId: entity.value.id,
484
- variantId: variant.value.id,
485
- syncHash: hash(sourceVariant),
2690
+ kind: "entity",
2691
+ externalId: item.externalId,
2692
+ entityId,
2693
+ syncHash: remoteHash,
2694
+ lastSyncedAt,
2695
+ heldFieldPaths: heldSharedPaths,
2696
+ forcedPushFieldPaths: survivingForcedPaths,
486
2697
  });
2698
+ } else if (entityMapping) {
2699
+ await this.db.update(channelEntityMap).set({
2700
+ syncHash: remoteHash,
2701
+ lastSyncedAt,
2702
+ heldFieldPaths: heldSharedPaths,
2703
+ forcedPushFieldPaths: survivingForcedPaths,
2704
+ }).where(eq(channelEntityMap.id, entityMapping.id));
487
2705
  }
488
- imported += 1;
2706
+ await this.db.update(channelEntityMap).set({ lastSyncedAt }).where(and(
2707
+ eq(channelEntityMap.organizationId, orgId),
2708
+ eq(channelEntityMap.storeId, storeId),
2709
+ eq(channelEntityMap.entityId, entityId),
2710
+ eq(channelEntityMap.kind, "variant"),
2711
+ ));
489
2712
  }
490
- return Ok({ imported, converged });
2713
+ return Ok({
2714
+ imported,
2715
+ converged,
2716
+ entitiesTouched,
2717
+ attributesCreated,
2718
+ mediaImported,
2719
+ variantsGivenOptionValues,
2720
+ skipped,
2721
+ conflicts,
2722
+ warnings,
2723
+ });
491
2724
  }
492
2725
 
493
2726
  async reconcile(
@@ -515,6 +2748,7 @@ export class ChannelConnectorService {
515
2748
  if (!converged.ok) return converged;
516
2749
  const present = new Set(items.map((item) => item.externalId));
517
2750
  let archived = 0;
2751
+ const skipped = [...converged.value.skipped];
518
2752
  for (const mapping of entityMappings) {
519
2753
  if (present.has(mapping.externalId)) continue;
520
2754
  const [entity] = await this.db.select({ status: sellableEntities.status }).from(sellableEntities).where(and(
@@ -522,6 +2756,11 @@ export class ChannelConnectorService {
522
2756
  eq(sellableEntities.id, mapping.entityId),
523
2757
  ));
524
2758
  if (entity?.status !== "archived") {
2759
+ const owners = await this.catalog.resolveFieldOwners(mapping.entityId, storeId);
2760
+ if (owners.get("entity.status") === "platform") {
2761
+ skipped.push({ entityId: mapping.entityId, fieldPath: "entity.status" });
2762
+ continue;
2763
+ }
525
2764
  const result = await this.catalog.archive(mapping.entityId, actor);
526
2765
  if (!result.ok) return PluginErr(result.error.message);
527
2766
  archived += 1;
@@ -550,12 +2789,21 @@ export class ChannelConnectorService {
550
2789
  inventoryUpdated += 1;
551
2790
  }
552
2791
  const threshold = this.options.driftAlertThreshold ?? 25;
2792
+ const openConflictRows = await this.db.select({ id: channelCatalogConflicts.id }).from(channelCatalogConflicts).where(and(
2793
+ eq(channelCatalogConflicts.organizationId, orgId),
2794
+ eq(channelCatalogConflicts.storeId, storeId),
2795
+ eq(channelCatalogConflicts.state, "open"),
2796
+ ));
553
2797
  const report: ReconcileReport = {
554
2798
  imported: converged.value.imported,
555
2799
  converged: converged.value.converged,
556
2800
  archived,
557
2801
  inventoryUpdated,
2802
+ openConflicts: openConflictRows.length,
558
2803
  driftAlert: converged.value.imported + converged.value.converged + archived > threshold,
2804
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
2805
+ ...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
2806
+ ...(converged.value.warnings.length > 0 ? { warnings: converged.value.warnings } : {}),
559
2807
  };
560
2808
  await this.db.update(connectedStores).set({
561
2809
  lastReconcileAt: new Date(),
@@ -573,6 +2821,152 @@ export class ChannelConnectorService {
573
2821
  return Ok({ lastReconcileAt: store.lastReconcileAt, report, driftAlert: report?.driftAlert ?? false });
574
2822
  }
575
2823
 
2824
+ async listCatalogConflicts(
2825
+ orgId: string,
2826
+ storeId?: string,
2827
+ state: ChannelCatalogConflict["state"] = "open",
2828
+ ): Promise<PluginResult<ChannelCatalogConflict[]>> {
2829
+ const conditions = [eq(channelCatalogConflicts.organizationId, orgId), eq(channelCatalogConflicts.state, state)];
2830
+ if (storeId !== undefined) conditions.push(eq(channelCatalogConflicts.storeId, storeId));
2831
+ return Ok(await this.db.select().from(channelCatalogConflicts).where(and(...conditions)) as ChannelCatalogConflict[]);
2832
+ }
2833
+
2834
+ async resolveCatalogConflict(
2835
+ orgId: string,
2836
+ id: string,
2837
+ choose: "platform" | "store",
2838
+ actor: Pick<Actor, "userId">,
2839
+ ): Promise<PluginResult<ChannelCatalogConflict>> {
2840
+ const [conflict] = await this.db.select().from(channelCatalogConflicts).where(and(
2841
+ eq(channelCatalogConflicts.organizationId, orgId),
2842
+ eq(channelCatalogConflicts.id, id),
2843
+ eq(channelCatalogConflicts.state, "open"),
2844
+ ));
2845
+ if (!conflict) return PluginErr("Catalog conflict not found or already resolved.", "NOT_FOUND");
2846
+ if (choose === "platform" && !this.jobs) return PluginErr("Jobs are not configured.", "JOBS_UNAVAILABLE");
2847
+ const [mapping] = await this.db.select().from(channelEntityMap).where(and(
2848
+ eq(channelEntityMap.organizationId, orgId),
2849
+ eq(channelEntityMap.storeId, conflict.storeId),
2850
+ eq(channelEntityMap.kind, "entity"),
2851
+ eq(channelEntityMap.entityId, conflict.entityId),
2852
+ ));
2853
+ if (!mapping) return PluginErr("Catalog conflict mapping not found.", "NOT_FOUND");
2854
+ const systemActor = createSystemActor(orgId);
2855
+ let resolutionBaselineAt: Date | undefined;
2856
+ if (choose === "store") {
2857
+ const applied = await this.applyStoreConflictValue(orgId, conflict as ChannelCatalogConflict, systemActor);
2858
+ if (!applied.ok) return PluginErr(applied.error, applied.code);
2859
+ const revisions = await this.catalog.repository.findRevisionMarkers(conflict.entityId);
2860
+ resolutionBaselineAt = revisions.at(-1)?.createdAt;
2861
+ }
2862
+ const heldFieldPaths = (mapping.heldFieldPaths ?? []).filter((path) => path !== conflict.fieldPath);
2863
+ const forcedPushFieldPaths = choose === "platform"
2864
+ ? [...new Set([...(mapping.forcedPushFieldPaths ?? []), conflict.fieldPath as FieldPath])]
2865
+ : mapping.forcedPushFieldPaths ?? [];
2866
+ const [resolved] = await this.db.update(channelCatalogConflicts).set({
2867
+ state: "resolved",
2868
+ resolvedBy: requireUserId(actor),
2869
+ updatedAt: new Date(),
2870
+ }).where(and(
2871
+ eq(channelCatalogConflicts.organizationId, orgId),
2872
+ eq(channelCatalogConflicts.id, id),
2873
+ eq(channelCatalogConflicts.state, "open"),
2874
+ )).returning();
2875
+ if (!resolved) return PluginErr("Catalog conflict not found or already resolved.", "NOT_FOUND");
2876
+ await this.db.update(channelEntityMap).set({
2877
+ heldFieldPaths,
2878
+ forcedPushFieldPaths,
2879
+ ...(resolutionBaselineAt ? { lastSyncedAt: resolutionBaselineAt } : {}),
2880
+ }).where(and(
2881
+ eq(channelEntityMap.organizationId, orgId),
2882
+ eq(channelEntityMap.id, mapping.id),
2883
+ ));
2884
+ await this.db.insert(channelCatalogConflictEvents).values({
2885
+ organizationId: orgId,
2886
+ conflictId: conflict.id,
2887
+ fromState: "open",
2888
+ toState: "resolved",
2889
+ reason: `Operator chose the ${choose} value.`,
2890
+ changedBy: requireUserId(actor),
2891
+ });
2892
+ if (choose === "platform") {
2893
+ await this.jobs!.enqueue("channel/push-catalog", {
2894
+ organizationId: orgId,
2895
+ storeId: conflict.storeId,
2896
+ entityIds: [conflict.entityId],
2897
+ }, {
2898
+ organizationId: orgId,
2899
+ concurrencyKey: catalogPushConcurrencyKey({ storeId: conflict.storeId, entityIds: [conflict.entityId] }),
2900
+ supersedes: true,
2901
+ });
2902
+ }
2903
+ return Ok(resolved as ChannelCatalogConflict);
2904
+ }
2905
+
2906
+ private async applyStoreConflictValue(
2907
+ orgId: string,
2908
+ conflict: ChannelCatalogConflict,
2909
+ actor: Actor,
2910
+ ): Promise<PluginResult<void>> {
2911
+ const [root, segment, field] = conflict.fieldPath.split(".");
2912
+ if (root === "entity" && segment === "slug") {
2913
+ if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not a valid slug.", "INVALID_CONFLICT_VALUE");
2914
+ const updated = await this.catalog.update(conflict.entityId, { slug: conflict.storeValue }, actor, CHANNEL_CONVERGENCE_CTX);
2915
+ return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
2916
+ }
2917
+ if (root === "entity" && segment === "status") {
2918
+ if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not a valid status.", "INVALID_CONFLICT_VALUE");
2919
+ const status = ["draft", "active", "archived", "discontinued"].find((value) => value === conflict.storeValue);
2920
+ if (!status) return PluginErr("The stored catalog value is not a valid status.", "INVALID_CONFLICT_VALUE");
2921
+ const updated = await this.catalog.update(conflict.entityId, { status, isVisible: status === "active" }, actor, CHANNEL_CONVERGENCE_CTX);
2922
+ return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
2923
+ }
2924
+ if (root === "entity" && segment === "metadata" && field) {
2925
+ const [entity] = await this.db.select().from(sellableEntities).where(and(
2926
+ eq(sellableEntities.organizationId, orgId),
2927
+ eq(sellableEntities.id, conflict.entityId),
2928
+ ));
2929
+ if (!entity) return PluginErr("Catalog entity not found.", "NOT_FOUND");
2930
+ const updated = await this.catalog.update(conflict.entityId, {
2931
+ metadata: { ...(entity.metadata ?? {}), [field]: conflict.storeValue },
2932
+ }, actor, CHANNEL_CONVERGENCE_CTX);
2933
+ return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
2934
+ }
2935
+ if (root === "attributes" && segment && field && attributeFields.some((attributeField) => attributeField === field)) {
2936
+ const [attribute] = await this.db.select().from(sellableAttributes).where(and(
2937
+ eq(sellableAttributes.entityId, conflict.entityId),
2938
+ eq(sellableAttributes.locale, segment),
2939
+ ));
2940
+ const title = attribute?.title ?? "";
2941
+ const attrs: Parameters<CatalogService["setAttributes"]>[2] = { title };
2942
+ if (field === "title") {
2943
+ if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
2944
+ attrs.title = conflict.storeValue;
2945
+ } else if (field === "subtitle") {
2946
+ if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
2947
+ attrs.subtitle = conflict.storeValue;
2948
+ } else if (field === "description") {
2949
+ if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
2950
+ attrs.description = conflict.storeValue;
2951
+ } else if (field === "richDescription") {
2952
+ attrs.richDescription = conflict.storeValue;
2953
+ } else if (field === "seoTitle") {
2954
+ if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
2955
+ attrs.seoTitle = conflict.storeValue;
2956
+ } else if (field === "seoDescription") {
2957
+ if (typeof conflict.storeValue !== "string") return PluginErr("The stored catalog value is not valid text.", "INVALID_CONFLICT_VALUE");
2958
+ attrs.seoDescription = conflict.storeValue;
2959
+ }
2960
+ const updated = await this.catalog.setAttributes(conflict.entityId, segment, attrs, actor, CHANNEL_CONVERGENCE_CTX);
2961
+ return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
2962
+ }
2963
+ if (root === "customFields" && segment && field === "en") {
2964
+ const updated = await this.catalog.update(conflict.entityId, { customFields: { [segment]: conflict.storeValue } }, actor, CHANNEL_CONVERGENCE_CTX);
2965
+ return updated.ok ? Ok(undefined) : PluginErr(updated.error.message);
2966
+ }
2967
+ return PluginErr(`Conflict field path "${conflict.fieldPath}" cannot be resolved to the store value.`, "UNSUPPORTED_CONFLICT_FIELD");
2968
+ }
2969
+
576
2970
  async syncInventory(
577
2971
  orgId: string,
578
2972
  storeId: string,
@@ -616,16 +3010,30 @@ export class ChannelConnectorService {
616
3010
  if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
617
3011
  const actor = createSystemActor(orgId);
618
3012
  const data = event.data as Record<string, unknown>;
3013
+ let skipped: CatalogFieldSkip[] = [];
3014
+ let conflicts: CatalogFieldConflict[] = [];
3015
+ let warnings: string[] = [];
619
3016
  if (event.type === "products/update") {
620
3017
  const productId = String(data.id ?? data.product_id ?? "");
621
3018
  const mapping = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, productId)));
622
- if (mapping[0]) await this.convergeCatalogItem(orgId, storeId, mapping[0].entityId, data, actor);
3019
+ if (mapping[0]) {
3020
+ const converged = await this.convergeCatalogItem(orgId, storeId, mapping[0].entityId, data, actor);
3021
+ if (!converged.ok) return converged;
3022
+ skipped = converged.value.skipped;
3023
+ conflicts = converged.value.conflicts;
3024
+ warnings = converged.value.warnings;
3025
+ }
623
3026
  } else if (event.type === "products/delete") {
624
3027
  const productId = String(data.id ?? data.product_id ?? "");
625
3028
  const mapping = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, productId)));
626
3029
  if (mapping[0]) {
627
- const archived = await this.catalog.archive(mapping[0].entityId, actor);
628
- if (!archived.ok) return PluginErr(archived.error.message);
3030
+ const owners = await this.catalog.resolveFieldOwners(mapping[0].entityId, storeId);
3031
+ if (owners.get("entity.status") === "platform") {
3032
+ skipped.push({ entityId: mapping[0].entityId, fieldPath: "entity.status" });
3033
+ } else {
3034
+ const archived = await this.catalog.archive(mapping[0].entityId, actor);
3035
+ if (!archived.ok) return PluginErr(archived.error.message);
3036
+ }
629
3037
  }
630
3038
  } else if (event.type === "inventory_levels/update") {
631
3039
  const externalId = String(data.inventory_item_id ?? data.variation_id ?? data.product_id ?? "");
@@ -664,6 +3072,18 @@ export class ChannelConnectorService {
664
3072
  if (!disconnected.ok) return disconnected;
665
3073
  return Ok({ processed: true });
666
3074
  }
3075
+ if (skipped.length > 0 || conflicts.length > 0 || warnings.length > 0) {
3076
+ const report = {
3077
+ ...(store.lastReconcileReport ?? {}),
3078
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
3079
+ ...(conflicts.length > 0 ? { conflicts } : {}),
3080
+ ...(warnings.length > 0 ? { warnings } : {}),
3081
+ };
3082
+ await this.db.update(connectedStores).set({ lastReconcileReport: report, updatedAt: new Date() }).where(and(
3083
+ eq(connectedStores.organizationId, orgId),
3084
+ eq(connectedStores.id, storeId),
3085
+ ));
3086
+ }
667
3087
  return Ok({ processed: true });
668
3088
  }
669
3089
 
@@ -735,14 +3155,150 @@ export class ChannelConnectorService {
735
3155
  await inventory.setAbsolute({ entityId: mapping.entityId, ...(mapping.variantId ? { variantId: mapping.variantId } : {}), quantity: Math.max(0, Math.floor(quantity)), reason: "Inventory webhook sync" }, actor);
736
3156
  }
737
3157
 
738
- private async convergeCatalogItem(orgId: string, storeId: string, entityId: string, data: Record<string, unknown>, actor: Actor): Promise<void> {
3158
+ private async convergeCatalogItem(
3159
+ orgId: string,
3160
+ storeId: string,
3161
+ entityId: string,
3162
+ data: Record<string, unknown>,
3163
+ actor: Actor,
3164
+ ): Promise<PluginResult<{ skipped: CatalogFieldSkip[]; conflicts: CatalogFieldConflict[]; warnings: string[] }>> {
739
3165
  const product = data.product && typeof data.product === "object" ? data.product as Record<string, unknown> : data;
3166
+ const remoteMetadata = product.metadata && typeof product.metadata === "object" && !Array.isArray(product.metadata)
3167
+ ? product.metadata as Record<string, unknown>
3168
+ : {};
3169
+ const [mapping] = await this.db.select().from(channelEntityMap).where(and(
3170
+ eq(channelEntityMap.organizationId, orgId),
3171
+ eq(channelEntityMap.storeId, storeId),
3172
+ eq(channelEntityMap.kind, "entity"),
3173
+ eq(channelEntityMap.entityId, entityId),
3174
+ ));
3175
+ const [entity] = await this.db.select().from(sellableEntities).where(and(
3176
+ eq(sellableEntities.organizationId, orgId),
3177
+ eq(sellableEntities.id, entityId),
3178
+ ));
3179
+ if (!mapping || !entity) return Ok({ skipped: [], conflicts: [], warnings: [] });
3180
+ const [currentAttribute] = await this.db.select().from(sellableAttributes).where(and(
3181
+ eq(sellableAttributes.entityId, entityId),
3182
+ eq(sellableAttributes.locale, "en"),
3183
+ ));
3184
+ const title = typeof product.title === "string" ? product.title : currentAttribute?.title ?? entity.slug;
3185
+ const description = product.description !== undefined
3186
+ ? String(product.description)
3187
+ : currentAttribute?.description ?? undefined;
3188
+ const status = typeof product.status === "string" && ["draft", "active", "archived", "discontinued"].includes(product.status)
3189
+ ? product.status as NonNullable<ChannelCatalogItem["status"]>
3190
+ : undefined;
3191
+ const customFields = product.customFields && typeof product.customFields === "object" && !Array.isArray(product.customFields)
3192
+ ? product.customFields as Record<string, unknown>
3193
+ : undefined;
3194
+ const images = Array.isArray(product.images)
3195
+ ? product.images.flatMap((raw) => {
3196
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
3197
+ const image = raw as Record<string, unknown>;
3198
+ const role = typeof image.role === "string" ? pushCatalogImageRole(image.role) : undefined;
3199
+ const url = typeof image.url === "string" ? image.url : typeof image.src === "string" ? image.src : undefined;
3200
+ return role && url ? [{ role, url }] : [];
3201
+ })
3202
+ : [];
3203
+ const remoteItem = {
3204
+ externalId: mapping.externalId,
3205
+ slug: typeof product.slug === "string" ? product.slug : entity.slug,
3206
+ title,
3207
+ ...(description !== undefined ? { description } : {}),
3208
+ ...(status !== undefined ? { status } : {}),
3209
+ attributes: [{ locale: "en", title, ...(description !== undefined ? { description } : {}) }],
3210
+ ...(Object.keys(remoteMetadata).length > 0 ? { metadata: remoteMetadata } : {}),
3211
+ ...(customFields !== undefined ? { customFields } : {}),
3212
+ ...(images.length > 0 ? { images } : {}),
3213
+ variants: [],
3214
+ } as ChannelCatalogItem & { customFields?: Record<string, unknown> };
3215
+ const fieldPaths: FieldPath[] = [];
3216
+ if (typeof product.slug === "string") fieldPaths.push("entity.slug");
3217
+ if (status !== undefined) fieldPaths.push("entity.status");
3218
+ for (const key of Object.keys(remoteMetadata)) {
3219
+ const path = `entity.metadata.${key}`;
3220
+ if (isValidFieldPath(path)) fieldPaths.push(path);
3221
+ }
3222
+ if (typeof product.title === "string") fieldPaths.push("attributes.en.title");
3223
+ if (product.description !== undefined) fieldPaths.push("attributes.en.description");
3224
+ for (const [name, locales] of Object.entries(customFields ?? {})) {
3225
+ if (!locales || typeof locales !== "object" || Array.isArray(locales)) continue;
3226
+ for (const locale of Object.keys(locales as Record<string, unknown>)) {
3227
+ const path = `customFields.${name}.${locale}`;
3228
+ if (isValidFieldPath(path)) fieldPaths.push(path);
3229
+ }
3230
+ }
3231
+ for (const image of images) fieldPaths.push(`media.${image.role}`);
3232
+ const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
3233
+ const seedPaths = fieldPaths.filter((path) => !ownershipBeforeSeed.has(path));
3234
+ const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
3235
+ if (!seeded.ok) return PluginErr(seeded.error.message);
3236
+ for (const path of seedPaths) ownershipBeforeSeed.set(path, "store");
3237
+ const owners = ownershipBeforeSeed;
3238
+ const remoteHash = hash(product);
3239
+ const outboundEcho = this.isOutboundEcho(mapping, remoteItem);
3240
+ const shared = await this.detectSharedConflicts(
3241
+ entityId, storeId, entity, mapping, remoteItem, owners, fieldPaths, remoteHash,
3242
+ outboundEcho ? { certifiedPaths: new Set(mapping.outboundFieldPaths ?? []) } : undefined,
3243
+ );
3244
+ const persistedConflicts = await this.persistCatalogConflicts(orgId, shared.conflicts, requireUserId(actor));
3245
+ if (!persistedConflicts.ok) return persistedConflicts;
3246
+ const owned = this.filterOwnedFieldsAtPaths(remoteItem, owners, fieldPaths);
3247
+ const heldPaths = [...new Set([...(mapping.heldFieldPaths ?? []), ...shared.paths])];
3248
+ // Same revocation as the reconcile path: a newly held path cancels any force
3249
+ // left from an earlier resolution, so a webhook-raised conflict cannot be
3250
+ // pre-empted by an operator's answer to a previous one.
3251
+ const survivingForcedPaths = (mapping.forcedPushFieldPaths ?? []).filter(
3252
+ (path) => !heldPaths.includes(path),
3253
+ );
3254
+ const held = this.filterConflictingFields(owned.writable, heldPaths);
3255
+ const blockedPaths = new Set<FieldPath>([
3256
+ ...owned.skipped,
3257
+ ...heldPaths,
3258
+ ...(!fieldPaths.includes("attributes.en.title") ? ["attributes.en.title" as FieldPath] : []),
3259
+ ]);
3260
+ const skipped = owned.skipped.map((fieldPath) => ({ entityId, fieldPath }));
3261
+ const conflicts = shared.conflicts.map(({ platformValue: _platformValue, storeValue: _storeValue, ...conflict }) => conflict);
3262
+ const warnings = conflicts.map((conflict) => `Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
3263
+ const writable = held.writable;
3264
+ const updateInput: {
3265
+ slug?: string;
3266
+ metadata?: Record<string, unknown>;
3267
+ status?: string;
3268
+ isVisible?: boolean;
3269
+ } = {};
3270
+ if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && entity.slug !== writable.slug) {
3271
+ updateInput.slug = writable.slug;
3272
+ }
3273
+ if (Object.keys(writable.metadata ?? {}).length > 0) {
3274
+ const remoteEntityMetadata = mergeMetadata(entity.metadata, writable.metadata ?? {});
3275
+ if (hash(remoteEntityMetadata) !== hash(entity.metadata ?? {})) updateInput.metadata = remoteEntityMetadata;
3276
+ }
3277
+ if (fieldPaths.includes("entity.status") && ownerAllows(owners, "entity.status") && !blockedPaths.has("entity.status") && typeof writable.status === "string" && writable.status !== entity.status) {
3278
+ updateInput.status = writable.status;
3279
+ updateInput.isVisible = writable.status === "active";
3280
+ }
3281
+ if (Object.keys(updateInput).length > 0) {
3282
+ const updated = await this.catalog.update(entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
3283
+ if (!updated.ok) return PluginErr(updated.error.message);
3284
+ }
3285
+ const attributes = await this.setCatalogAttributesIfWritable(entityId, writable, actor, blockedPaths, CHANNEL_CONVERGENCE_CTX);
3286
+ if (!attributes.ok) return attributes;
740
3287
  const levels = Array.isArray(product.variants) ? product.variants as Array<Record<string, unknown>> : [];
741
3288
  for (const variant of levels) {
742
3289
  const externalId = String(variant.id ?? variant.variation_id ?? "");
743
3290
  const available = variant.inventory_quantity ?? variant.stock_quantity;
744
3291
  if (externalId && available !== undefined) await this.setMappedInventory(orgId, storeId, externalId, Number(available), actor);
745
3292
  }
3293
+ const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
3294
+ const lastSyncedAt = revisionMarkers.at(-1)?.createdAt ?? mapping.lastSyncedAt;
3295
+ await this.db.update(channelEntityMap).set({
3296
+ syncHash: remoteHash,
3297
+ lastSyncedAt,
3298
+ heldFieldPaths: heldPaths,
3299
+ forcedPushFieldPaths: survivingForcedPaths,
3300
+ }).where(eq(channelEntityMap.id, mapping.id));
3301
+ return Ok({ skipped, conflicts, warnings });
746
3302
  }
747
3303
 
748
3304
  private async createRefundRequest(orgId: string, store: ConnectedStore, data: Record<string, unknown>, actor: Actor): Promise<PluginResult<ChannelRefundRequest>> {
@@ -774,9 +3330,9 @@ export class ChannelConnectorService {
774
3330
  const max = this.options.refundAutoMax ?? order.amountCaptured ?? order.grandTotal;
775
3331
  const ageOk = Date.now() - store.createdAt.getTime() >= (this.options.newStoreDays ?? 7) * 86_400_000;
776
3332
  const auto = clean && amount > 0 && ageOk && amount <= max;
777
- const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, state: auto ? "approved" : "requested", approvedBy: auto ? actor.userId : null }).returning();
3333
+ const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, state: auto ? "approved" : "requested", approvedBy: auto ? requireUserId(actor) : null }).returning();
778
3334
  const request = rows[0] as ChannelRefundRequest;
779
- await this.db.insert(channelRefundEvents).values({ organizationId: orgId, requestId: request.id, fromState: null, toState: request.state, reason: auto ? "Automatic guarded refund" : "Operator approval required", changedBy: actor.userId });
3335
+ await this.db.insert(channelRefundEvents).values({ organizationId: orgId, requestId: request.id, fromState: null, toState: request.state, reason: auto ? "Automatic guarded refund" : "Operator approval required", changedBy: requireUserId(actor) });
780
3336
  if (auto) {
781
3337
  const result = await this.executeRefund(request, refundLines, actor);
782
3338
  if (!result.ok) return PluginErr(result.error);
@@ -789,7 +3345,7 @@ export class ChannelConnectorService {
789
3345
  const result = await ordersService.refundLines(request.orderId, { lines, reason: `Channel refund ${request.remoteRefundId}` }, actor);
790
3346
  if (!result.ok) return PluginErr(result.error?.message ?? "Refund execution failed.");
791
3347
  const [updated] = await this.db.update(channelRefundRequests).set({ state: "executed", updatedAt: new Date() }).where(and(eq(channelRefundRequests.organizationId, request.organizationId), eq(channelRefundRequests.id, request.id), eq(channelRefundRequests.state, "approved"))).returning();
792
- await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: actor.userId });
3348
+ await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: requireUserId(actor) });
793
3349
  return Ok(updated as ChannelRefundRequest);
794
3350
  }
795
3351
 
@@ -923,7 +3479,7 @@ export class ChannelConnectorService {
923
3479
  orgId,
924
3480
  created.value.id,
925
3481
  "exported",
926
- actor.userId,
3482
+ requireUserId(actor),
927
3483
  "Export attempt started.",
928
3484
  );
929
3485
  if (!exported.ok) return exported;
@@ -943,7 +3499,7 @@ export class ChannelConnectorService {
943
3499
  orgId,
944
3500
  created.value.id,
945
3501
  "failed",
946
- actor.userId,
3502
+ requireUserId(actor),
947
3503
  pushed.error.message,
948
3504
  pushed.error.retriable === true ? "transient" : "definitive",
949
3505
  );
@@ -970,7 +3526,7 @@ export class ChannelConnectorService {
970
3526
  orgId,
971
3527
  created.value.id,
972
3528
  "failed",
973
- actor.userId,
3529
+ requireUserId(actor),
974
3530
  remoteStatus.error.message,
975
3531
  remoteStatus.error.retriable === true ? "transient" : "definitive",
976
3532
  );
@@ -980,7 +3536,7 @@ export class ChannelConnectorService {
980
3536
  orgId,
981
3537
  created.value.id,
982
3538
  "confirmed",
983
- actor.userId,
3539
+ requireUserId(actor),
984
3540
  "Remote order confirmed.",
985
3541
  );
986
3542
  }
@@ -989,7 +3545,7 @@ export class ChannelConnectorService {
989
3545
  orgId,
990
3546
  created.value.id,
991
3547
  "failed",
992
- actor.userId,
3548
+ requireUserId(actor),
993
3549
  `Remote order status: ${remoteStatus.value.status}.`,
994
3550
  );
995
3551
  }
@@ -1095,4 +3651,467 @@ export class ChannelConnectorService {
1095
3651
  ): Promise<PluginResult<ChannelOrderExport>> {
1096
3652
  return this.transitionExport(orgId, exportId, "abandoned", changedBy, reason);
1097
3653
  }
3654
+
3655
+ async resolveCatalogPushEntityIds(
3656
+ orgId: string,
3657
+ storeId: string,
3658
+ entityIds?: string[],
3659
+ ): Promise<string[]> {
3660
+ if (entityIds !== undefined) return [...new Set(entityIds)].sort();
3661
+ const mappings = await this.db.select({ entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(
3662
+ eq(channelEntityMap.organizationId, orgId),
3663
+ eq(channelEntityMap.storeId, storeId),
3664
+ eq(channelEntityMap.kind, "entity"),
3665
+ ));
3666
+ return [...new Set(mappings.map((mapping) => mapping.entityId))].sort();
3667
+ }
3668
+
3669
+ async createCatalogPush(
3670
+ orgId: string,
3671
+ storeId: string,
3672
+ entityId: string,
3673
+ ): Promise<PluginResult<ChannelCatalogPush>> {
3674
+ const store = await this.getStoreRecord(orgId, storeId);
3675
+ if (!store || store.status !== "connected") {
3676
+ return PluginErr("Connected store not found.", "NOT_FOUND");
3677
+ }
3678
+ const rows = await this.db
3679
+ .insert(channelCatalogPushes)
3680
+ .values({ organizationId: orgId, storeId, entityId })
3681
+ .onConflictDoNothing({ target: [channelCatalogPushes.storeId, channelCatalogPushes.entityId] })
3682
+ .returning();
3683
+ if (rows[0]) return Ok(rows[0] as ChannelCatalogPush);
3684
+ const existing = await this.db
3685
+ .select()
3686
+ .from(channelCatalogPushes)
3687
+ .where(and(
3688
+ eq(channelCatalogPushes.organizationId, orgId),
3689
+ eq(channelCatalogPushes.storeId, storeId),
3690
+ eq(channelCatalogPushes.entityId, entityId),
3691
+ ));
3692
+ if (!existing[0]) return PluginErr("Failed to create channel catalog push.");
3693
+ return Ok(existing[0] as ChannelCatalogPush);
3694
+ }
3695
+
3696
+ async transitionCatalogPush(
3697
+ orgId: string,
3698
+ pushId: string,
3699
+ toState: CatalogPushState,
3700
+ changedBy: string,
3701
+ reason?: string,
3702
+ failureKind?: "definitive" | "transient",
3703
+ payloadSnapshot?: ChannelPushCatalogItem | null,
3704
+ ): Promise<PluginResult<ChannelCatalogPush>> {
3705
+ return this.transact(async (tx) => {
3706
+ const currentRows = await tx
3707
+ .select()
3708
+ .from(channelCatalogPushes)
3709
+ .where(and(
3710
+ eq(channelCatalogPushes.organizationId, orgId),
3711
+ eq(channelCatalogPushes.id, pushId),
3712
+ ));
3713
+ const current = currentRows[0] as ChannelCatalogPush | undefined;
3714
+ if (!current) return PluginErr("Channel catalog push not found.", "NOT_FOUND");
3715
+ if (!canCatalogPushTransition(current.state, toState)) {
3716
+ const error = new CommerceInvalidTransitionError(
3717
+ `Cannot transition channel catalog push from ${current.state} to ${toState}.`,
3718
+ );
3719
+ return PluginErr(error.message, error.code);
3720
+ }
3721
+
3722
+ const updatedRows = await tx
3723
+ .update(channelCatalogPushes)
3724
+ .set({
3725
+ state: toState,
3726
+ updatedAt: new Date(),
3727
+ ...(payloadSnapshot !== undefined ? { payloadSnapshot } : {}),
3728
+ ...(toState === "exported" ? { attempts: current.attempts + 1, lastError: null, failureKind: null } : {}),
3729
+ ...(toState === "failed" ? { lastError: reason ?? "Catalog push failed." } : {}),
3730
+ ...(toState === "failed" ? { failureKind: failureKind ?? "definitive" } : {}),
3731
+ ...(toState === "confirmed" ? { lastError: null, failureKind: null } : {}),
3732
+ })
3733
+ .where(and(
3734
+ eq(channelCatalogPushes.organizationId, orgId),
3735
+ eq(channelCatalogPushes.id, pushId),
3736
+ eq(channelCatalogPushes.state, current.state),
3737
+ ))
3738
+ .returning();
3739
+ const updated = updatedRows[0] as ChannelCatalogPush | undefined;
3740
+ if (!updated) return PluginErr("Channel catalog push changed concurrently.", "CONFLICT");
3741
+
3742
+ await tx.insert(channelCatalogPushEvents).values({
3743
+ organizationId: orgId,
3744
+ pushId,
3745
+ fromState: current.state,
3746
+ toState,
3747
+ reason: reason ?? null,
3748
+ changedBy,
3749
+ });
3750
+ return Ok(updated);
3751
+ });
3752
+ }
3753
+
3754
+ private async recordCatalogPushRevisions(
3755
+ orgId: string,
3756
+ entityIds: string[],
3757
+ actor: Actor,
3758
+ ): Promise<PluginResult<void>> {
3759
+ if (entityIds.length === 0) return Ok(undefined);
3760
+ try {
3761
+ await this.transact(async (tx) => {
3762
+ const txContext = createTxContext(tx, { actor });
3763
+ for (const entityId of [...new Set(entityIds)]) {
3764
+ const revision = await this.catalog.recordEntityRevision(entityId, actor, "push", txContext);
3765
+ if (!revision.ok) throw new Error(revision.error.message);
3766
+ }
3767
+ });
3768
+ } catch (error) {
3769
+ return PluginErr(error instanceof Error ? error.message : "Failed to record catalog push revisions.");
3770
+ }
3771
+ return Ok(undefined);
3772
+ }
3773
+
3774
+ async executeCatalogPushJob(
3775
+ orgId: string,
3776
+ storeId: string,
3777
+ options: { entityIds?: string[]; cursor?: string; forceFieldPaths?: Record<string, FieldPath[]> },
3778
+ actor: Actor,
3779
+ runtime: { jobs: JobsAdapter },
3780
+ ): Promise<PluginResult<CatalogPushJobResult>> {
3781
+ const store = await this.getStoreRecord(orgId, storeId);
3782
+ if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
3783
+ if (!store.catalogWriteEnabled) return Ok({ noop: true });
3784
+ const connector = this.connectors.get(store.provider);
3785
+ if (!connector?.pushCatalog) return Ok({ noop: true });
3786
+ if (isCatalogPushBreakerOpen(store.breakerState)) {
3787
+ await runtime.jobs.enqueue("channel/push-catalog", {
3788
+ organizationId: orgId,
3789
+ storeId,
3790
+ ...(options.entityIds ? { entityIds: options.entityIds } : {}),
3791
+ ...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
3792
+ ...(options.cursor ? { cursor: options.cursor } : {}),
3793
+ }, {
3794
+ organizationId: orgId,
3795
+ concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
3796
+ supersedes: false,
3797
+ delayMs: CATALOG_PUSH_BREAKER_RETRY_MS,
3798
+ });
3799
+ return Ok({ rescheduled: true });
3800
+ }
3801
+
3802
+ const allEntityIds = await this.resolveCatalogPushEntityIds(orgId, storeId, options.entityIds);
3803
+ const batchSize = catalogPushBatchSize(store.provider);
3804
+ const pageEntityIds = options.cursor
3805
+ ? allEntityIds.filter((entityId) => entityId > options.cursor!).slice(0, batchSize)
3806
+ : allEntityIds.slice(0, batchSize);
3807
+ if (pageEntityIds.length === 0) return Ok({ complete: true, pushed: 0, failed: 0 });
3808
+
3809
+ // Abandoned is terminal: a row that exhausted its attempts stays out of
3810
+ // every later sweep until an operator re-arms it.
3811
+ const abandonedRows = await this.db.select({ entityId: channelCatalogPushes.entityId }).from(channelCatalogPushes).where(and(
3812
+ eq(channelCatalogPushes.organizationId, orgId),
3813
+ eq(channelCatalogPushes.storeId, storeId),
3814
+ eq(channelCatalogPushes.state, "abandoned"),
3815
+ inArray(channelCatalogPushes.entityId, pageEntityIds),
3816
+ ));
3817
+ const abandonedEntityIds = new Set(abandonedRows.map((row) => row.entityId));
3818
+ const batchEntityIds = pageEntityIds.filter((entityId) => !abandonedEntityIds.has(entityId));
3819
+ if (batchEntityIds.length === 0) {
3820
+ const batchCursor = pageEntityIds[pageEntityIds.length - 1]!;
3821
+ const hasMore = allEntityIds.some((entityId) => entityId > batchCursor);
3822
+ if (!hasMore) return Ok({ complete: true, pushed: 0, failed: 0 });
3823
+ await runtime.jobs.enqueue("channel/push-catalog", {
3824
+ organizationId: orgId,
3825
+ storeId,
3826
+ ...(options.entityIds ? { entityIds: options.entityIds } : {}),
3827
+ ...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
3828
+ cursor: batchCursor,
3829
+ }, {
3830
+ organizationId: orgId,
3831
+ concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
3832
+ supersedes: false,
3833
+ });
3834
+ return Ok({ complete: false, cursor: batchCursor, pushed: 0, failed: 0 });
3835
+ }
3836
+
3837
+ const assembled = await this.buildCatalogPushItems(orgId, storeId, batchEntityIds, {
3838
+ ...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
3839
+ });
3840
+ if (!assembled.ok) return assembled;
3841
+
3842
+ const mappings = await this.db.select({
3843
+ entityId: channelEntityMap.entityId,
3844
+ externalId: channelEntityMap.externalId,
3845
+ }).from(channelEntityMap).where(and(
3846
+ eq(channelEntityMap.organizationId, orgId),
3847
+ eq(channelEntityMap.storeId, storeId),
3848
+ eq(channelEntityMap.kind, "entity"),
3849
+ inArray(channelEntityMap.entityId, batchEntityIds),
3850
+ ));
3851
+ const externalByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping.externalId]));
3852
+ const entityByExternal = new Map(mappings.map((mapping) => [mapping.externalId, mapping.entityId]));
3853
+ const itemByExternal = new Map(assembled.value.items.map((item) => [item.externalId, item]));
3854
+
3855
+ let pushed = 0;
3856
+ let failed = 0;
3857
+
3858
+ if (assembled.value.items.length === 0) {
3859
+ for (const entityId of batchEntityIds) {
3860
+ const created = await this.createCatalogPush(orgId, storeId, entityId);
3861
+ if (!created.ok) return created;
3862
+ if (created.value.state === "confirmed" || created.value.state === "abandoned") {
3863
+ if (created.value.state === "confirmed") pushed += 1;
3864
+ continue;
3865
+ }
3866
+ const confirmed = await this.transitionCatalogPush(
3867
+ orgId,
3868
+ created.value.id,
3869
+ "confirmed",
3870
+ requireUserId(actor),
3871
+ "No platform-owned fields to push.",
3872
+ undefined,
3873
+ null,
3874
+ );
3875
+ if (!confirmed.ok) return confirmed;
3876
+ pushed += 1;
3877
+ }
3878
+ } else {
3879
+ const pushIds = new Map<string, string>();
3880
+ const pushAttempts = new Map<string, number>();
3881
+ for (const entityId of batchEntityIds) {
3882
+ const externalId = externalByEntity.get(entityId);
3883
+ const item = externalId ? itemByExternal.get(externalId) : undefined;
3884
+ if (!item) continue;
3885
+ const created = await this.createCatalogPush(orgId, storeId, entityId);
3886
+ if (!created.ok) return created;
3887
+ pushIds.set(item.externalId, created.value.id);
3888
+ pushAttempts.set(item.externalId, created.value.attempts);
3889
+ if (created.value.state === "pending" || created.value.state === "confirmed" || created.value.state === "failed") {
3890
+ const exported = await this.transitionCatalogPush(
3891
+ orgId,
3892
+ created.value.id,
3893
+ "exported",
3894
+ requireUserId(actor),
3895
+ "Catalog push attempt started.",
3896
+ undefined,
3897
+ item,
3898
+ );
3899
+ if (!exported.ok) return exported;
3900
+ pushAttempts.set(item.externalId, exported.value.attempts);
3901
+ }
3902
+ }
3903
+
3904
+ const items = assembled.value.items;
3905
+ const writeAhead = await this.recordOutboundPush(
3906
+ orgId,
3907
+ storeId,
3908
+ items.map((item) => ({ externalId: item.externalId, ok: true })),
3909
+ items,
3910
+ "write-ahead",
3911
+ );
3912
+ if (!writeAhead.ok) return writeAhead;
3913
+
3914
+ let result: Awaited<ReturnType<NonNullable<typeof connector.pushCatalog>>>;
3915
+ try {
3916
+ result = await connector.pushCatalog(store as ChannelStore, items);
3917
+ } catch (error) {
3918
+ const connectorError = {
3919
+ code: "CATALOG_PUSH_THROWN",
3920
+ message: error instanceof Error ? error.message : "Catalog push failed.",
3921
+ };
3922
+ const cleared = await this.recordOutboundPush(
3923
+ orgId,
3924
+ storeId,
3925
+ items.map((item) => ({ externalId: item.externalId, ok: false, error: connectorError })),
3926
+ items,
3927
+ );
3928
+ if (!cleared.ok) return cleared;
3929
+ for (const item of items) {
3930
+ const pushId = pushIds.get(item.externalId);
3931
+ if (!pushId) continue;
3932
+ const attempts = pushAttempts.get(item.externalId) ?? 0;
3933
+ if (attempts >= CATALOG_PUSH_MAX_ATTEMPTS) {
3934
+ await this.transitionCatalogPush(
3935
+ orgId,
3936
+ pushId,
3937
+ "abandoned",
3938
+ requireUserId(actor),
3939
+ connectorError.message,
3940
+ );
3941
+ } else {
3942
+ await this.transitionCatalogPush(
3943
+ orgId,
3944
+ pushId,
3945
+ "failed",
3946
+ requireUserId(actor),
3947
+ connectorError.message,
3948
+ "transient",
3949
+ );
3950
+ }
3951
+ failed += 1;
3952
+ }
3953
+ const maxAttempts = Math.max(0, ...items.map((item) => pushAttempts.get(item.externalId) ?? 0));
3954
+ if (maxAttempts < CATALOG_PUSH_MAX_ATTEMPTS) {
3955
+ await runtime.jobs.enqueue("channel/push-catalog", {
3956
+ organizationId: orgId,
3957
+ storeId,
3958
+ ...(options.entityIds ? { entityIds: options.entityIds } : {}),
3959
+ ...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
3960
+ ...(options.cursor ? { cursor: options.cursor } : {}),
3961
+ }, {
3962
+ organizationId: orgId,
3963
+ concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
3964
+ supersedes: false,
3965
+ delayMs: catalogPushRetryDelayMs(maxAttempts),
3966
+ });
3967
+ }
3968
+ return Ok({ rescheduled: true, pushed, failed });
3969
+ }
3970
+
3971
+ if (!result.ok) {
3972
+ const cleared = await this.recordOutboundPush(
3973
+ orgId,
3974
+ storeId,
3975
+ items.map((item) => ({ externalId: item.externalId, ok: false, error: result.error })),
3976
+ items,
3977
+ );
3978
+ if (!cleared.ok) return cleared;
3979
+ for (const item of items) {
3980
+ const pushId = pushIds.get(item.externalId);
3981
+ if (!pushId) continue;
3982
+ const attempts = pushAttempts.get(item.externalId) ?? 0;
3983
+ const failureKind = result.error.retriable === true ? "transient" as const : "definitive" as const;
3984
+ if (failureKind === "transient" && attempts >= CATALOG_PUSH_MAX_ATTEMPTS) {
3985
+ await this.transitionCatalogPush(
3986
+ orgId,
3987
+ pushId,
3988
+ "abandoned",
3989
+ requireUserId(actor),
3990
+ result.error.message,
3991
+ );
3992
+ } else {
3993
+ await this.transitionCatalogPush(
3994
+ orgId,
3995
+ pushId,
3996
+ "failed",
3997
+ requireUserId(actor),
3998
+ result.error.message,
3999
+ failureKind,
4000
+ );
4001
+ }
4002
+ failed += 1;
4003
+ }
4004
+ if (result.error.retriable === true) {
4005
+ const maxAttempts = Math.max(0, ...items.map((item) => pushAttempts.get(item.externalId) ?? 0));
4006
+ if (maxAttempts < CATALOG_PUSH_MAX_ATTEMPTS) {
4007
+ await runtime.jobs.enqueue("channel/push-catalog", {
4008
+ organizationId: orgId,
4009
+ storeId,
4010
+ ...(options.entityIds ? { entityIds: options.entityIds } : {}),
4011
+ ...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
4012
+ ...(options.cursor ? { cursor: options.cursor } : {}),
4013
+ }, {
4014
+ organizationId: orgId,
4015
+ concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
4016
+ supersedes: false,
4017
+ delayMs: catalogPushRetryDelayMs(maxAttempts),
4018
+ });
4019
+ }
4020
+ return Ok({ rescheduled: true, pushed, failed });
4021
+ }
4022
+ const batchCursor = pageEntityIds[pageEntityIds.length - 1]!;
4023
+ const hasMore = allEntityIds.some((entityId) => entityId > batchCursor);
4024
+ return Ok({ complete: !hasMore, pushed, failed });
4025
+ }
4026
+
4027
+ const recorded = await this.recordOutboundPush(orgId, storeId, result.value.outcomes, items);
4028
+ if (!recorded.ok) return recorded;
4029
+
4030
+ const successfulEntityIds: string[] = [];
4031
+ for (const outcome of result.value.outcomes) {
4032
+ const pushId = pushIds.get(outcome.externalId);
4033
+ const entityId = entityByExternal.get(outcome.externalId);
4034
+ if (!pushId || !entityId) continue;
4035
+ const item = itemByExternal.get(outcome.externalId);
4036
+ if (outcome.ok) {
4037
+ const confirmed = await this.transitionCatalogPush(
4038
+ orgId,
4039
+ pushId,
4040
+ "confirmed",
4041
+ requireUserId(actor),
4042
+ "Remote catalog confirmed item.",
4043
+ undefined,
4044
+ item ?? null,
4045
+ );
4046
+ if (!confirmed.ok) return confirmed;
4047
+ successfulEntityIds.push(entityId);
4048
+ pushed += 1;
4049
+ continue;
4050
+ }
4051
+ const failureKind = outcome.error?.retriable === true ? "transient" : "definitive";
4052
+ const attempts = pushAttempts.get(outcome.externalId) ?? 0;
4053
+ if (failureKind === "transient" && attempts >= CATALOG_PUSH_MAX_ATTEMPTS) {
4054
+ const abandoned = await this.transitionCatalogPush(
4055
+ orgId,
4056
+ pushId,
4057
+ "abandoned",
4058
+ requireUserId(actor),
4059
+ outcome.error?.message ?? "Catalog push failed.",
4060
+ undefined,
4061
+ item ?? null,
4062
+ );
4063
+ if (!abandoned.ok) return abandoned;
4064
+ } else {
4065
+ const failedPush = await this.transitionCatalogPush(
4066
+ orgId,
4067
+ pushId,
4068
+ "failed",
4069
+ requireUserId(actor),
4070
+ outcome.error?.message ?? "Catalog push failed.",
4071
+ failureKind,
4072
+ item ?? null,
4073
+ );
4074
+ if (!failedPush.ok) return failedPush;
4075
+ if (failureKind === "transient") {
4076
+ await runtime.jobs.enqueue("channel/push-catalog", {
4077
+ organizationId: orgId,
4078
+ storeId,
4079
+ entityIds: [entityId],
4080
+ ...(options.forceFieldPaths?.[entityId]
4081
+ ? { forceFieldPaths: { [entityId]: options.forceFieldPaths[entityId] } }
4082
+ : {}),
4083
+ }, {
4084
+ organizationId: orgId,
4085
+ concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: [entityId] }),
4086
+ supersedes: true,
4087
+ delayMs: catalogPushRetryDelayMs(attempts),
4088
+ });
4089
+ }
4090
+ }
4091
+ failed += 1;
4092
+ }
4093
+
4094
+ const revisions = await this.recordCatalogPushRevisions(orgId, successfulEntityIds, actor);
4095
+ if (!revisions.ok) return revisions;
4096
+ }
4097
+
4098
+ const batchCursor = pageEntityIds[pageEntityIds.length - 1]!;
4099
+ const hasMore = allEntityIds.some((entityId) => entityId > batchCursor);
4100
+ if (hasMore) {
4101
+ await runtime.jobs.enqueue("channel/push-catalog", {
4102
+ organizationId: orgId,
4103
+ storeId,
4104
+ ...(options.entityIds ? { entityIds: options.entityIds } : {}),
4105
+ ...(options.forceFieldPaths ? { forceFieldPaths: options.forceFieldPaths } : {}),
4106
+ cursor: batchCursor,
4107
+ }, {
4108
+ organizationId: orgId,
4109
+ concurrencyKey: catalogPushConcurrencyKey({ storeId, entityIds: options.entityIds }),
4110
+ supersedes: false,
4111
+ });
4112
+ return Ok({ complete: false, cursor: batchCursor, pushed, failed });
4113
+ }
4114
+
4115
+ return Ok({ complete: true, pushed, failed });
4116
+ }
1098
4117
  }