@porulle/plugin-channel-connector 0.10.8 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/service.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
- import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr, createSystemActor, } from "@porulle/core";
3
- import { and, eq, inArray } from "@porulle/core/drizzle";
4
- import { customerAddresses, customers, inventoryLevels, orderLineItems, orders, sellableEntities } from "@porulle/core/schema";
2
+ import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, } from "@porulle/core";
3
+ import { isValidFieldPath } from "@porulle/core";
4
+ import { and, eq, inArray, isNull } from "@porulle/core/drizzle";
5
+ import { brands, categories, customerAddresses, customers, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
5
6
  import { channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
7
+ import { mergeCatalogFieldMapping, normalizeCatalogFieldMapping, selectCatalogFieldMapping, } from "./catalog-field-mapping.js";
6
8
  const exportTransitions = {
7
9
  pending: ["exported", "abandoned"],
8
10
  exported: ["confirmed", "failed", "abandoned"],
@@ -16,6 +18,106 @@ export function canExportTransition(from, to) {
16
18
  function hash(value) {
17
19
  return createHash("sha256").update(JSON.stringify(value)).digest("hex");
18
20
  }
21
+ function mergeMetadata(existing, remote) {
22
+ return { ...(existing ?? {}), ...remote };
23
+ }
24
+ const attributeFields = ["title", "subtitle", "description", "richDescription", "seoTitle", "seoDescription"];
25
+ const pushImageRoles = ["primary", "gallery", "thumbnail", "video", "document"];
26
+ function customFieldValue(field) {
27
+ switch (field.fieldType) {
28
+ case "text":
29
+ case "relation":
30
+ case "select":
31
+ return field.textValue;
32
+ case "number":
33
+ return field.numberValue;
34
+ case "boolean":
35
+ return field.booleanValue;
36
+ case "date":
37
+ return field.dateValue;
38
+ case "json":
39
+ return field.jsonValue;
40
+ default:
41
+ return null;
42
+ }
43
+ }
44
+ function pushCatalogIntent(fieldPath, target) {
45
+ if (fieldPath.startsWith("customFields.") && target === "attribute")
46
+ return "filterable";
47
+ if (fieldPath.startsWith("customFields.") || fieldPath.startsWith("entity.metadata."))
48
+ return "tag";
49
+ return "display";
50
+ }
51
+ function pushCatalogField(fieldPath, value, mapping) {
52
+ const segments = fieldPath.split(".");
53
+ const locale = fieldPath.startsWith("attributes.")
54
+ ? segments[1]
55
+ : fieldPath.startsWith("customFields.")
56
+ ? segments[2]
57
+ : undefined;
58
+ return {
59
+ fieldPath,
60
+ intent: pushCatalogIntent(fieldPath, mapping.target),
61
+ value,
62
+ ...(locale !== undefined ? { locale } : {}),
63
+ remoteKey: mapping.remoteKey,
64
+ };
65
+ }
66
+ function pushCatalogImageRole(value) {
67
+ return pushImageRoles.find((role) => role === value);
68
+ }
69
+ function importedFieldPaths(item) {
70
+ const paths = new Set(["entity.slug"]);
71
+ if (item.status !== undefined)
72
+ paths.add("entity.status");
73
+ for (const key of Object.keys(item.metadata ?? {})) {
74
+ const path = `entity.metadata.${key}`;
75
+ if (isValidFieldPath(path))
76
+ paths.add(path);
77
+ }
78
+ const attributes = item.attributes?.length
79
+ ? item.attributes
80
+ : [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
81
+ for (const attribute of attributes) {
82
+ for (const field of attributeFields) {
83
+ if (attribute[field] !== undefined)
84
+ paths.add(`attributes.${attribute.locale}.${field}`);
85
+ }
86
+ }
87
+ for (const image of item.images ?? [])
88
+ paths.add(`media.${image.role}`);
89
+ if (item.options?.length)
90
+ paths.add("options");
91
+ if (item.variants.some((variant) => variant.sku !== undefined))
92
+ paths.add("variants.sku");
93
+ if (item.variants.some((variant) => variant.barcode !== undefined))
94
+ paths.add("variants.barcode");
95
+ for (const currency of item.variants.flatMap((variant) => variant.prices ?? []).map((price) => price.currency)) {
96
+ const path = `prices.${currency}`;
97
+ if (isValidFieldPath(path))
98
+ paths.add(path);
99
+ }
100
+ return [...paths];
101
+ }
102
+ function summarizeValue(value) {
103
+ const serialized = JSON.stringify(value);
104
+ if (serialized === undefined)
105
+ return String(value);
106
+ return serialized.length > 256 ? `${serialized.slice(0, 253)}...` : serialized;
107
+ }
108
+ function uniqueSkipped(skipped) {
109
+ const seen = new Set();
110
+ return skipped.filter((entry) => {
111
+ const key = `${entry.entityId}:${entry.fieldPath}`;
112
+ if (seen.has(key))
113
+ return false;
114
+ seen.add(key);
115
+ return true;
116
+ });
117
+ }
118
+ function ownerAllows(owners, path) {
119
+ return owners.get(path) !== "platform";
120
+ }
19
121
  function stockFailure(line, reason) {
20
122
  return `Cannot checkout line "${line.title ?? line.entityId}": ${reason}.`;
21
123
  }
@@ -42,6 +144,8 @@ function redactStore(store) {
42
144
  credentials: "[REDACTED]",
43
145
  storeDomain: store.storeDomain,
44
146
  status: store.status,
147
+ catalogWriteEnabled: store.catalogWriteEnabled,
148
+ catalogFieldMapping: store.catalogFieldMapping,
45
149
  catalogCursor: store.catalogCursor,
46
150
  inventoryCursor: store.inventoryCursor,
47
151
  lastSyncAt: store.lastSyncAt,
@@ -79,6 +183,499 @@ export class ChannelConnectorService {
79
183
  get catalog() {
80
184
  return this.services.catalog;
81
185
  }
186
+ get media() {
187
+ return this.services.media;
188
+ }
189
+ get pricing() {
190
+ return this.services.pricing;
191
+ }
192
+ filterOwnedFields(item, owners) {
193
+ return this.filterOwnedFieldsAtPaths(item, owners, importedFieldPaths(item));
194
+ }
195
+ filterOwnedFieldsAtPaths(item, owners, fieldPaths) {
196
+ const populated = new Set(fieldPaths);
197
+ const skipped = fieldPaths.filter((path) => owners.get(path) === "platform");
198
+ const blocked = new Set(skipped);
199
+ const attributes = (item.attributes?.length
200
+ ? item.attributes
201
+ : [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }])
202
+ .flatMap((attribute) => {
203
+ return [{
204
+ locale: attribute.locale,
205
+ title: attribute.title,
206
+ ...Object.fromEntries(attributeFields.slice(1)
207
+ .filter((field) => attribute[field] !== undefined
208
+ && populated.has(`attributes.${attribute.locale}.${field}`)
209
+ && !blocked.has(`attributes.${attribute.locale}.${field}`))
210
+ .map((field) => [field, attribute[field]])),
211
+ }];
212
+ });
213
+ const writable = {
214
+ ...item,
215
+ attributes,
216
+ metadata: Object.fromEntries(Object.entries(item.metadata ?? {}).filter(([key]) => populated.has(`entity.metadata.${key}`) && !blocked.has(`entity.metadata.${key}`))),
217
+ ...(item.images !== undefined ? { images: item.images.filter((image) => populated.has(`media.${image.role}`) && !blocked.has(`media.${image.role}`)) } : {}),
218
+ ...(item.options !== undefined ? { options: blocked.has("options") ? [] : item.options } : {}),
219
+ variants: item.variants.map((variant) => ({
220
+ externalId: variant.externalId,
221
+ ...(variant.sku !== undefined && populated.has("variants.sku") && !blocked.has("variants.sku") ? { sku: variant.sku } : {}),
222
+ ...(variant.barcode !== undefined && populated.has("variants.barcode") && !blocked.has("variants.barcode") ? { barcode: variant.barcode } : {}),
223
+ ...(variant.metadata !== undefined ? { metadata: variant.metadata } : {}),
224
+ ...(variant.optionValues !== undefined && populated.has("options") && !blocked.has("options") ? { optionValues: variant.optionValues } : {}),
225
+ ...(variant.prices !== undefined
226
+ ? { prices: variant.prices.filter((price) => populated.has(`prices.${price.currency}`) && !blocked.has(`prices.${price.currency}`)) }
227
+ : {}),
228
+ })),
229
+ };
230
+ return { writable, skipped, conflicts: [] };
231
+ }
232
+ filterConflictingFields(item, conflicts) {
233
+ if (conflicts.length === 0)
234
+ return { writable: item, conflicts: [] };
235
+ const blocked = new Set(conflicts);
236
+ const attributes = (item.attributes ?? []).flatMap((attribute) => {
237
+ return [{
238
+ locale: attribute.locale,
239
+ title: attribute.title,
240
+ ...Object.fromEntries(attributeFields.slice(1)
241
+ .filter((field) => attribute[field] !== undefined && !blocked.has(`attributes.${attribute.locale}.${field}`))
242
+ .map((field) => [field, attribute[field]])),
243
+ }];
244
+ });
245
+ return {
246
+ writable: {
247
+ ...item,
248
+ attributes,
249
+ metadata: Object.fromEntries(Object.entries(item.metadata ?? {}).filter(([key]) => !blocked.has(`entity.metadata.${key}`))),
250
+ ...(item.images !== undefined ? { images: item.images.filter((image) => !blocked.has(`media.${image.role}`)) } : {}),
251
+ ...(item.options !== undefined ? { options: blocked.has("options") ? [] : item.options } : {}),
252
+ variants: item.variants.map((variant) => ({
253
+ externalId: variant.externalId,
254
+ ...(variant.sku !== undefined && !blocked.has("variants.sku") ? { sku: variant.sku } : {}),
255
+ ...(variant.barcode !== undefined && !blocked.has("variants.barcode") ? { barcode: variant.barcode } : {}),
256
+ ...(variant.metadata !== undefined ? { metadata: variant.metadata } : {}),
257
+ ...(variant.optionValues !== undefined && !blocked.has("options") ? { optionValues: variant.optionValues } : {}),
258
+ ...(variant.prices !== undefined
259
+ ? { prices: variant.prices.filter((price) => !blocked.has(`prices.${price.currency}`)) }
260
+ : {}),
261
+ })),
262
+ },
263
+ conflicts,
264
+ };
265
+ }
266
+ remoteFieldValue(item, path) {
267
+ const [root, segment, field] = path.split(".");
268
+ if (root === "entity" && segment === "slug")
269
+ return item.slug;
270
+ if (root === "entity" && segment === "status")
271
+ return item.status;
272
+ if (root === "entity" && segment === "metadata")
273
+ return item.metadata?.[field ?? ""];
274
+ if (root === "attributes" && segment && field) {
275
+ const attributes = item.attributes?.length
276
+ ? item.attributes
277
+ : [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
278
+ const attribute = attributes.find((row) => row.locale === segment);
279
+ return attribute?.[field];
280
+ }
281
+ if (root === "media" && segment)
282
+ return (item.images ?? []).filter((image) => image.role === segment).map((image) => image.externalId ?? image.url);
283
+ if (path === "options")
284
+ return item.options;
285
+ if (path === "variants.sku")
286
+ return item.variants.map((variant) => variant.sku);
287
+ if (path === "variants.barcode")
288
+ return item.variants.map((variant) => variant.barcode);
289
+ if (root === "prices" && segment)
290
+ return item.variants.flatMap((variant) => variant.prices ?? []).filter((price) => price.currency === segment);
291
+ return undefined;
292
+ }
293
+ async localFieldValue(entityId, entity, path) {
294
+ const [root, segment, field] = path.split(".");
295
+ if (root === "entity" && segment === "slug")
296
+ return entity.slug;
297
+ if (root === "entity" && segment === "status")
298
+ return entity.status;
299
+ if (root === "entity" && segment === "metadata")
300
+ return entity.metadata?.[field ?? ""];
301
+ if (root === "attributes" && segment && field) {
302
+ const [attribute] = await this.db.select().from(sellableAttributes).where(and(eq(sellableAttributes.entityId, entityId), eq(sellableAttributes.locale, segment)));
303
+ const values = attribute
304
+ ? {
305
+ title: attribute.title,
306
+ subtitle: attribute.subtitle,
307
+ description: attribute.description,
308
+ richDescription: attribute.richDescription,
309
+ seoTitle: attribute.seoTitle,
310
+ seoDescription: attribute.seoDescription,
311
+ }
312
+ : {};
313
+ return values[field];
314
+ }
315
+ if (root === "media" && segment) {
316
+ const links = await this.db.select({ id: entityMedia.mediaAssetId }).from(entityMedia).where(and(eq(entityMedia.entityId, entityId), eq(entityMedia.role, segment)));
317
+ return links.map((link) => link.id);
318
+ }
319
+ if (path === "options") {
320
+ const types = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entityId));
321
+ const values = await Promise.all(types.map(async (type) => ({
322
+ name: type.name,
323
+ values: await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, type.id)),
324
+ })));
325
+ return values;
326
+ }
327
+ if (path === "variants.sku" || path === "variants.barcode") {
328
+ const rows = await this.db.select().from(variants).where(eq(variants.entityId, entityId));
329
+ return rows.map((variant) => path === "variants.sku" ? variant.sku : variant.barcode);
330
+ }
331
+ if (root === "prices" && segment) {
332
+ const rows = await this.db.select().from(prices).where(and(eq(prices.entityId, entityId), eq(prices.currency, segment)));
333
+ return rows.map((price) => ({ amount: price.amount, compareAtAmount: price.compareAtAmount }));
334
+ }
335
+ return undefined;
336
+ }
337
+ async detectSharedConflicts(entityId, storeId, entity, mapping, item, owners, fieldPaths = importedFieldPaths(item), remoteHash = hash(item)) {
338
+ if (!mapping || mapping.syncHash === remoteHash)
339
+ return { paths: [], conflicts: [] };
340
+ const revisions = await this.catalog.repository.findRevisionMarkers(entityId, mapping.lastSyncedAt);
341
+ const localChanged = revisions.some((revision) => revision.reason !== "import");
342
+ if (!localChanged)
343
+ return { paths: [], conflicts: [] };
344
+ const paths = fieldPaths.filter((path) => owners.get(path) === "shared");
345
+ const conflicts = await Promise.all(paths.map(async (fieldPath) => ({
346
+ entityId,
347
+ storeId,
348
+ fieldPath,
349
+ localValueSummary: summarizeValue(await this.localFieldValue(entityId, entity, fieldPath)),
350
+ remoteValueSummary: summarizeValue(this.remoteFieldValue(item, fieldPath)),
351
+ })));
352
+ return { paths, conflicts };
353
+ }
354
+ async setCatalogAttributes(entityId, item, actor, blockedPaths = new Set()) {
355
+ const attributes = item.attributes ?? [{
356
+ locale: "en",
357
+ title: item.title,
358
+ ...(item.description !== undefined ? { description: item.description } : {}),
359
+ }];
360
+ const existing = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entityId));
361
+ let created = 0;
362
+ let changed = false;
363
+ for (const attribute of attributes) {
364
+ const current = existing.find((row) => row.locale === attribute.locale);
365
+ const titlePath = `attributes.${attribute.locale}.title`;
366
+ if (!current && blockedPaths.has(titlePath))
367
+ continue;
368
+ const title = blockedPaths.has(titlePath) ? current?.title : attribute.title;
369
+ if (title === undefined)
370
+ continue;
371
+ const writeAttribute = {
372
+ title,
373
+ ...(attribute.subtitle !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.subtitle`) ? { subtitle: attribute.subtitle } : current?.subtitle != null ? { subtitle: current.subtitle } : {}),
374
+ ...(attribute.description !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.description`) ? { description: attribute.description } : current?.description != null ? { description: current.description } : {}),
375
+ ...(attribute.richDescription !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.richDescription`) ? { richDescription: attribute.richDescription } : current?.richDescription != null ? { richDescription: current.richDescription } : {}),
376
+ ...(attribute.seoTitle !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.seoTitle`) ? { seoTitle: attribute.seoTitle } : current?.seoTitle != null ? { seoTitle: current.seoTitle } : {}),
377
+ ...(attribute.seoDescription !== undefined && !blockedPaths.has(`attributes.${attribute.locale}.seoDescription`) ? { seoDescription: attribute.seoDescription } : current?.seoDescription != null ? { seoDescription: current.seoDescription } : {}),
378
+ };
379
+ if (!current) {
380
+ created += 1;
381
+ changed = true;
382
+ }
383
+ else if (attributeFields.some((field) => (current[field] == null ? null : current[field]) !== (writeAttribute[field] == null ? null : writeAttribute[field]))) {
384
+ changed = true;
385
+ }
386
+ const result = await this.catalog.setAttributes(entityId, attribute.locale, writeAttribute, actor);
387
+ if (!result.ok)
388
+ return PluginErr(result.error.message);
389
+ }
390
+ return Ok({ created, changed });
391
+ }
392
+ async upsertOptionAxes(entityId, item, actor) {
393
+ const optionValueIds = new Map();
394
+ const existingTypes = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entityId));
395
+ let changed = false;
396
+ for (const [typeIndex, sourceType] of (item.options ?? []).entries()) {
397
+ let optionType = existingTypes.find((row) => row.name === sourceType.name);
398
+ if (!optionType) {
399
+ const created = await this.catalog.createOptionType({ entityId, name: sourceType.name, values: [] }, actor);
400
+ if (!created.ok)
401
+ return PluginErr(created.error.message);
402
+ const [createdType] = await this.db.select().from(optionTypes).where(eq(optionTypes.id, created.value.id));
403
+ if (!createdType)
404
+ return PluginErr(`Option type "${sourceType.name}" was not persisted.`);
405
+ optionType = createdType;
406
+ existingTypes.push(optionType);
407
+ changed = true;
408
+ }
409
+ await this.db.update(optionTypes).set({
410
+ displayName: sourceType.displayName,
411
+ sortOrder: sourceType.sortOrder ?? typeIndex,
412
+ }).where(eq(optionTypes.id, optionType.id));
413
+ const existingValues = await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, optionType.id));
414
+ const valueIds = new Map();
415
+ for (const [valueIndex, sourceValue] of sourceType.values.entries()) {
416
+ let optionValue = existingValues.find((row) => row.value === sourceValue.value);
417
+ if (!optionValue) {
418
+ const created = await this.catalog.createOptionValue({ optionTypeId: optionType.id, value: sourceValue.value }, actor);
419
+ if (!created.ok)
420
+ return PluginErr(created.error.message);
421
+ const [createdValue] = await this.db.select().from(optionValues).where(eq(optionValues.id, created.value.id));
422
+ if (!createdValue)
423
+ return PluginErr(`Option value "${sourceValue.value}" was not persisted.`);
424
+ optionValue = createdValue;
425
+ existingValues.push(optionValue);
426
+ changed = true;
427
+ }
428
+ await this.db.update(optionValues).set({
429
+ displayValue: sourceValue.displayValue,
430
+ sortOrder: sourceValue.sortOrder ?? valueIndex,
431
+ }).where(eq(optionValues.id, optionValue.id));
432
+ valueIds.set(sourceValue.value, optionValue.id);
433
+ }
434
+ optionValueIds.set(sourceType.name, valueIds);
435
+ }
436
+ return Ok({ value: optionValueIds, changed });
437
+ }
438
+ async upsertVariants(orgId, storeId, entityId, item, optionValueIds, actor, warnings, applyOptionValues, fullItem) {
439
+ const variantIds = new Map();
440
+ let repaired = 0;
441
+ let changed = false;
442
+ const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), eq(channelEntityMap.entityId, entityId)));
443
+ for (const sourceVariant of item.variants) {
444
+ const fullSourceVariant = fullItem.variants.find((variant) => variant.externalId === sourceVariant.externalId) ?? sourceVariant;
445
+ let mapping = mappings.find((row) => row.externalId === sourceVariant.externalId);
446
+ let variantId = mapping?.variantId;
447
+ const createdVariant = !variantId;
448
+ if (!variantId) {
449
+ const options = {};
450
+ for (const [name, value] of Object.entries(sourceVariant.optionValues ?? {})) {
451
+ const optionValueId = optionValueIds.get(name)?.get(value);
452
+ if (!optionValueId) {
453
+ warnings.push(`Skipped unmapped option "${name}=${value}" on variant "${sourceVariant.externalId}".`);
454
+ continue;
455
+ }
456
+ options[name] = value;
457
+ }
458
+ const created = await this.catalog.createVariant({
459
+ entityId,
460
+ options,
461
+ ...(sourceVariant.sku !== undefined ? { sku: sourceVariant.sku } : {}),
462
+ ...(sourceVariant.barcode !== undefined ? { barcode: sourceVariant.barcode } : {}),
463
+ }, actor);
464
+ if (!created.ok)
465
+ return PluginErr(created.error.message);
466
+ variantId = created.value.id;
467
+ const [createdMapping] = await this.db.insert(channelEntityMap).values({
468
+ organizationId: orgId,
469
+ storeId,
470
+ kind: "variant",
471
+ externalId: sourceVariant.externalId,
472
+ entityId,
473
+ variantId,
474
+ syncHash: hash(fullSourceVariant),
475
+ }).returning();
476
+ mapping = createdMapping;
477
+ if (mapping)
478
+ mappings.push(mapping);
479
+ }
480
+ if (!variantId) {
481
+ warnings.push(`Skipped variant "${sourceVariant.externalId}": no local variant mapping exists.`);
482
+ continue;
483
+ }
484
+ variantIds.set(sourceVariant.externalId, variantId);
485
+ if (applyOptionValues) {
486
+ const desiredOptionValueIds = Object.entries(sourceVariant.optionValues ?? {})
487
+ .map(([name, value]) => optionValueIds.get(name)?.get(value))
488
+ .filter((optionValueId) => optionValueId !== undefined);
489
+ const currentOptionValues = await this.db.select().from(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
490
+ const currentIds = currentOptionValues.map((row) => row.optionValueId).sort();
491
+ const desiredIds = [...new Set(desiredOptionValueIds)].sort();
492
+ if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
493
+ await this.db.delete(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
494
+ if (desiredIds.length > 0) {
495
+ await this.db.insert(variantOptionValues).values(desiredIds.map((optionValueId) => ({ variantId, optionValueId }))).onConflictDoNothing();
496
+ repaired += 1;
497
+ }
498
+ changed = true;
499
+ }
500
+ if (createdVariant && desiredIds.length > 0) {
501
+ repaired += 1;
502
+ changed = true;
503
+ }
504
+ }
505
+ for (const price of sourceVariant.prices ?? []) {
506
+ const priced = await this.pricing.setBasePrice({
507
+ entityId,
508
+ variantId,
509
+ currency: price.currency,
510
+ amount: price.amount,
511
+ compareAtAmount: price.compareAtAmount ?? null,
512
+ }, actor);
513
+ if (!priced.ok)
514
+ return PluginErr(priced.error.message);
515
+ }
516
+ if (mapping) {
517
+ await this.db.update(channelEntityMap).set({
518
+ syncHash: hash(fullSourceVariant),
519
+ }).where(eq(channelEntityMap.id, mapping.id));
520
+ }
521
+ }
522
+ return Ok({ value: variantIds, repaired, changed });
523
+ }
524
+ async applyTaxonomy(orgId, entityId, item, actor, warnings) {
525
+ const categoryRows = await this.db.select().from(categories).where(eq(categories.organizationId, orgId));
526
+ for (const slug of new Set(item.categories ?? [])) {
527
+ let category = categoryRows.find((row) => row.slug === slug);
528
+ if (category?.status === "archived") {
529
+ warnings.push(`Skipped archived category "${slug}".`);
530
+ continue;
531
+ }
532
+ if (!category) {
533
+ const created = await this.catalog.createCategory({ slug }, actor);
534
+ if (!created.ok)
535
+ return PluginErr(created.error.message);
536
+ const [createdCategory] = await this.db.select().from(categories).where(eq(categories.id, created.value.id));
537
+ if (!createdCategory)
538
+ return PluginErr(`Category "${slug}" was not persisted.`);
539
+ category = createdCategory;
540
+ categoryRows.push(category);
541
+ }
542
+ const linked = await this.catalog.addToCategory(entityId, category.id, actor);
543
+ if (!linked.ok)
544
+ return PluginErr(linked.error.message);
545
+ }
546
+ const brandRows = await this.db.select().from(brands).where(eq(brands.organizationId, orgId));
547
+ if (item.brand) {
548
+ let brand = brandRows.find((row) => row.slug === item.brand);
549
+ if (!brand) {
550
+ const created = await this.catalog.createBrand({ slug: item.brand, displayName: item.brand }, actor);
551
+ if (!created.ok)
552
+ return PluginErr(created.error.message);
553
+ const [createdBrand] = await this.db.select().from(brands).where(eq(brands.id, created.value.id));
554
+ if (!createdBrand)
555
+ return PluginErr(`Brand "${item.brand}" was not persisted.`);
556
+ brand = createdBrand;
557
+ brandRows.push(brand);
558
+ }
559
+ const linked = await this.catalog.addToBrand(entityId, brand.id, actor);
560
+ if (!linked.ok)
561
+ return PluginErr(linked.error.message);
562
+ }
563
+ const tagRows = await this.db.select().from(tags).where(eq(tags.organizationId, orgId));
564
+ for (const slug of new Set(item.tags ?? [])) {
565
+ let tag = tagRows.find((row) => row.slug === slug);
566
+ if (!tag) {
567
+ const [createdTag] = await this.db.insert(tags).values({ organizationId: orgId, slug, displayName: slug }).onConflictDoNothing().returning();
568
+ tag = createdTag ?? (await this.db.select().from(tags).where(and(eq(tags.organizationId, orgId), eq(tags.slug, slug))))[0];
569
+ if (!tag)
570
+ return PluginErr(`Tag "${slug}" was not persisted.`);
571
+ tagRows.push(tag);
572
+ }
573
+ await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing();
574
+ }
575
+ return Ok(undefined);
576
+ }
577
+ async applyMedia(orgId, entityId, item, variantIds, actor, warnings, owners) {
578
+ const assets = await this.db.select().from(mediaAssets).where(eq(mediaAssets.organizationId, orgId));
579
+ const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entityId));
580
+ let imported = 0;
581
+ let changed = false;
582
+ const skipped = [];
583
+ for (const image of item.images ?? []) {
584
+ const urlHash = hash(image.url);
585
+ const asset = assets.find((row) => {
586
+ const metadata = row.metadata ?? {};
587
+ return (image.externalId != null && metadata.channelImageExternalId === image.externalId)
588
+ || metadata.channelImageUrlHash === urlHash;
589
+ });
590
+ let mediaAssetId = asset?.id;
591
+ if (!mediaAssetId) {
592
+ let response;
593
+ try {
594
+ response = await fetch(image.url);
595
+ }
596
+ catch (error) {
597
+ warnings.push(`Skipped image "${image.externalId ?? image.url}": ${error instanceof Error ? error.message : "download failed"}.`);
598
+ continue;
599
+ }
600
+ if (!response.ok) {
601
+ warnings.push(`Skipped image "${image.externalId ?? image.url}": download returned ${response.status}.`);
602
+ continue;
603
+ }
604
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0] ?? "image/jpeg";
605
+ const extension = contentType.split("/", 2)[1] ?? "jpg";
606
+ const uploaded = await this.media.upload({
607
+ filename: `${image.externalId ?? urlHash}.${extension}`,
608
+ contentType,
609
+ data: await response.arrayBuffer(),
610
+ ...(image.alt !== undefined ? { alt: image.alt } : {}),
611
+ metadata: {
612
+ channelImageUrlHash: urlHash,
613
+ ...(image.externalId !== undefined ? { channelImageExternalId: image.externalId } : {}),
614
+ },
615
+ origin: "imported",
616
+ }, actor);
617
+ if (!uploaded.ok) {
618
+ warnings.push(`Skipped image "${image.externalId ?? image.url}": ${uploaded.error.code === "STORAGE_NOT_SUPPORTED" ? "storage adapter is not configured" : uploaded.error.message}.`);
619
+ continue;
620
+ }
621
+ mediaAssetId = uploaded.value.id;
622
+ imported += 1;
623
+ changed = true;
624
+ const [createdAsset] = await this.db.select().from(mediaAssets).where(eq(mediaAssets.id, mediaAssetId));
625
+ if (createdAsset)
626
+ assets.push(createdAsset);
627
+ }
628
+ if (!mediaAssetId)
629
+ continue;
630
+ const targets = image.variantExternalIds?.length
631
+ ? image.variantExternalIds.map((externalId) => ({ externalId, variantId: variantIds.get(externalId) }))
632
+ : [{ externalId: undefined, variantId: undefined }];
633
+ for (const target of targets) {
634
+ if (image.variantExternalIds?.length && !target.variantId) {
635
+ warnings.push(`Skipped image "${image.externalId ?? image.url}" for unmapped variant "${target.externalId}".`);
636
+ continue;
637
+ }
638
+ const existingLink = links.find((link) => link.mediaAssetId === mediaAssetId
639
+ && (target.variantId === undefined ? link.variantId === null : link.variantId === target.variantId));
640
+ if (existingLink) {
641
+ if (existingLink.role !== image.role) {
642
+ const currentRolePath = `media.${existingLink.role}`;
643
+ const incomingRolePath = `media.${image.role}`;
644
+ for (const path of [currentRolePath, incomingRolePath]) {
645
+ if (owners.get(path) === "platform" && !skipped.includes(path))
646
+ skipped.push(path);
647
+ }
648
+ if (skipped.includes(currentRolePath) || skipped.includes(incomingRolePath))
649
+ continue;
650
+ }
651
+ if (existingLink.role !== image.role || existingLink.sortOrder !== (image.sortOrder ?? 0)) {
652
+ await this.db.update(entityMedia).set({ role: image.role, sortOrder: image.sortOrder ?? 0 }).where(and(eq(entityMedia.entityId, entityId), eq(entityMedia.mediaAssetId, mediaAssetId), target.variantId === undefined ? isNull(entityMedia.variantId) : eq(entityMedia.variantId, target.variantId)));
653
+ changed = true;
654
+ }
655
+ continue;
656
+ }
657
+ const attached = await this.media.attachToEntity({
658
+ entityId,
659
+ mediaAssetId,
660
+ role: image.role,
661
+ sortOrder: image.sortOrder ?? 0,
662
+ ...(target.variantId !== undefined ? { variantId: target.variantId } : {}),
663
+ }, actor);
664
+ if (!attached.ok)
665
+ return PluginErr(attached.error.message);
666
+ changed = true;
667
+ links.push({
668
+ entityId,
669
+ mediaAssetId,
670
+ role: image.role,
671
+ sortOrder: image.sortOrder ?? 0,
672
+ variantId: target.variantId ?? null,
673
+ createdAt: new Date(),
674
+ });
675
+ }
676
+ }
677
+ return Ok({ imported, changed, skipped });
678
+ }
82
679
  async getStoreRecord(orgId, id) {
83
680
  const rows = await this.db
84
681
  .select()
@@ -102,20 +699,208 @@ export class ChannelConnectorService {
102
699
  .where(eq(connectedStores.storeDomain, shopDomain));
103
700
  return rows;
104
701
  }
702
+ resolveCatalogFieldMapping(store, filterableCustomFields, warnings = []) {
703
+ return mergeCatalogFieldMapping(store.provider, store.catalogFieldMapping, filterableCustomFields, warnings);
704
+ }
705
+ async buildCatalogPushItems(orgId, storeId, entityIds, options = {}) {
706
+ const store = await this.getStoreRecord(orgId, storeId);
707
+ if (!store || store.status !== "connected")
708
+ return PluginErr("Connected store not found.", "NOT_FOUND");
709
+ if (!store.catalogWriteEnabled)
710
+ return PluginErr("Catalog writes are disabled for this store.", "CATALOG_WRITE_DISABLED");
711
+ if (entityIds.length === 0)
712
+ return Ok({ items: [], skipped: [], warnings: [] });
713
+ const entities = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.id, entityIds)));
714
+ const entityById = new Map(entities.map((entity) => [entity.id, entity]));
715
+ const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), inArray(channelEntityMap.entityId, entityIds)));
716
+ const mappingByEntity = new Map(mappings.map((mapping) => [mapping.entityId, mapping]));
717
+ const items = [];
718
+ const skipped = [];
719
+ const warnings = [];
720
+ const revisionEntityIds = [];
721
+ for (const entityId of entityIds) {
722
+ const entity = entityById.get(entityId);
723
+ if (!entity)
724
+ return PluginErr("Catalog entity not found.", "NOT_FOUND");
725
+ if (entity.status !== "active") {
726
+ skipped.push({ entityId, fieldPath: "entity.status", reason: "entity_not_active" });
727
+ continue;
728
+ }
729
+ const entityMapping = mappingByEntity.get(entity.id);
730
+ if (!entityMapping) {
731
+ skipped.push({ entityId, fieldPath: "entity", reason: "unmapped_entity" });
732
+ continue;
733
+ }
734
+ const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
735
+ const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
736
+ const customFields = await this.db.select().from(sellableCustomFields).where(and(eq(sellableCustomFields.entityId, entity.id), eq(sellableCustomFields.status, "approved")));
737
+ const customFieldNames = [...new Set(customFields.map((field) => field.fieldName))];
738
+ const definitions = customFieldNames.length > 0
739
+ ? await this.db.select({ name: entityFieldDefinitions.name, filterable: entityFieldDefinitions.filterable }).from(entityFieldDefinitions).where(and(eq(entityFieldDefinitions.organizationId, orgId), eq(entityFieldDefinitions.entityType, entity.type), inArray(entityFieldDefinitions.name, customFieldNames)))
740
+ : [];
741
+ const filterableCustomFields = Object.fromEntries(definitions.map((definition) => [
742
+ `customFields.${definition.name}.en`,
743
+ definition.filterable,
744
+ ]));
745
+ for (const field of customFields) {
746
+ filterableCustomFields[`customFields.${field.fieldName}.${field.locale}`] = definitions.find((definition) => definition.name === field.fieldName)?.filterable ?? false;
747
+ }
748
+ const fieldMapping = this.resolveCatalogFieldMapping(store, filterableCustomFields, warnings);
749
+ const heldPaths = new Set(entityMapping.heldFieldPaths ?? []);
750
+ const fields = [];
751
+ const appendField = (fieldPath, value) => {
752
+ if (value === undefined || owners.get(fieldPath) !== "platform")
753
+ return;
754
+ if (heldPaths.has(fieldPath)) {
755
+ skipped.push({ entityId, fieldPath, reason: "held" });
756
+ return;
757
+ }
758
+ const mapping = selectCatalogFieldMapping(fieldMapping, fieldPath);
759
+ if (!mapping) {
760
+ skipped.push({ entityId, fieldPath, reason: "no_mapping" });
761
+ return;
762
+ }
763
+ fields.push(pushCatalogField(fieldPath, value, mapping));
764
+ };
765
+ for (const attribute of attributes) {
766
+ for (const field of attributeFields) {
767
+ appendField(`attributes.${attribute.locale}.${field}`, attribute[field]);
768
+ }
769
+ }
770
+ for (const [key, value] of Object.entries(entity.metadata ?? {})) {
771
+ const fieldPath = `entity.metadata.${key}`;
772
+ if (isValidFieldPath(fieldPath))
773
+ appendField(fieldPath, value);
774
+ }
775
+ for (const customField of customFields) {
776
+ const fieldPath = `customFields.${customField.fieldName}.${customField.locale}`;
777
+ if (isValidFieldPath(fieldPath))
778
+ appendField(fieldPath, customFieldValue(customField));
779
+ }
780
+ const media = await this.media.listEntityMedia(entity.id, { orgId });
781
+ if (!media.ok)
782
+ return PluginErr(media.error.message);
783
+ const images = [];
784
+ for (const attached of media.value) {
785
+ const role = pushCatalogImageRole(attached.role);
786
+ if (!role)
787
+ continue;
788
+ const fieldPath = `media.${role}`;
789
+ if (owners.get(fieldPath) !== "platform")
790
+ continue;
791
+ if (heldPaths.has(fieldPath)) {
792
+ skipped.push({ entityId, fieldPath, reason: "held" });
793
+ continue;
794
+ }
795
+ if (!selectCatalogFieldMapping(fieldMapping, fieldPath)) {
796
+ skipped.push({ entityId, fieldPath, reason: "no_mapping" });
797
+ continue;
798
+ }
799
+ images.push({
800
+ url: attached.url,
801
+ role,
802
+ sortOrder: attached.sortOrder,
803
+ ...(attached.alt !== null ? { alt: attached.alt } : {}),
804
+ });
805
+ }
806
+ fields.sort((left, right) => left.fieldPath.localeCompare(right.fieldPath));
807
+ const item = {
808
+ externalId: entityMapping.externalId,
809
+ fields,
810
+ ...(images.length > 0 ? { images } : {}),
811
+ };
812
+ items.push(item);
813
+ if (options.recordRevision === true)
814
+ revisionEntityIds.push(entity.id);
815
+ }
816
+ if (options.recordRevision === true && revisionEntityIds.length > 0) {
817
+ const actor = createSystemActor(orgId);
818
+ try {
819
+ await this.transact(async (tx) => {
820
+ const txContext = createTxContext(tx, { actor });
821
+ for (const entityId of revisionEntityIds) {
822
+ const revision = await this.catalog.recordEntityRevision(entityId, actor, "push", txContext);
823
+ if (!revision.ok)
824
+ throw new Error(revision.error.message);
825
+ }
826
+ });
827
+ }
828
+ catch (error) {
829
+ return PluginErr(error instanceof Error ? error.message : "Failed to record catalog push revisions.");
830
+ }
831
+ }
832
+ return Ok({ items, skipped, warnings: [...new Set(warnings)] });
833
+ }
834
+ async getCatalogWriteSettings(orgId, storeId) {
835
+ const store = await this.getStoreRecord(orgId, storeId);
836
+ if (!store)
837
+ return PluginErr("Connected store not found.", "NOT_FOUND");
838
+ const warnings = [];
839
+ return Ok({
840
+ enabled: store.catalogWriteEnabled === true,
841
+ overrides: store.catalogFieldMapping,
842
+ merged: this.resolveCatalogFieldMapping(store, undefined, warnings),
843
+ ...(warnings.length > 0 ? { warnings } : {}),
844
+ });
845
+ }
846
+ async updateCatalogWriteEnabled(orgId, storeId, enabled) {
847
+ const rows = await this.db
848
+ .update(connectedStores)
849
+ .set({ catalogWriteEnabled: enabled, updatedAt: new Date() })
850
+ .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)))
851
+ .returning();
852
+ if (!rows[0])
853
+ return PluginErr("Connected store not found.", "NOT_FOUND");
854
+ return this.getCatalogWriteSettings(orgId, storeId);
855
+ }
856
+ async updateCatalogFieldMapping(orgId, storeId, mapping) {
857
+ const store = await this.getStoreRecord(orgId, storeId);
858
+ if (!store)
859
+ return PluginErr("Connected store not found.", "NOT_FOUND");
860
+ let normalized;
861
+ try {
862
+ normalized = normalizeCatalogFieldMapping(mapping, store.provider);
863
+ }
864
+ catch (error) {
865
+ return PluginErr(error instanceof Error ? error.message : "Catalog mapping is invalid.", "INVALID_MAPPING");
866
+ }
867
+ await this.db
868
+ .update(connectedStores)
869
+ .set({ catalogFieldMapping: normalized, updatedAt: new Date() })
870
+ .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
871
+ return this.getCatalogWriteSettings(orgId, storeId);
872
+ }
105
873
  async connectStore(orgId, input) {
106
874
  if (!this.connectors.has(input.provider)) {
107
875
  return PluginErr(`No connector registered for provider "${input.provider}".`, "NOT_FOUND");
108
876
  }
109
- const rows = await this.db
110
- .insert(connectedStores)
111
- .values({
112
- organizationId: orgId,
113
- provider: input.provider,
114
- credentials: input.credentials,
115
- storeDomain: input.storeDomain,
116
- webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
117
- })
118
- .returning();
877
+ const existingRows = await this.db
878
+ .select()
879
+ .from(connectedStores)
880
+ .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.provider, input.provider), eq(connectedStores.storeDomain, input.storeDomain)));
881
+ const reconnect = existingRows.find((row) => row.status !== "connected");
882
+ const rows = reconnect
883
+ ? await this.db
884
+ .update(connectedStores)
885
+ .set({
886
+ credentials: input.credentials,
887
+ status: "connected",
888
+ catalogWriteEnabled: false,
889
+ webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
890
+ updatedAt: new Date(),
891
+ })
892
+ .where(eq(connectedStores.id, reconnect.id))
893
+ .returning()
894
+ : await this.db
895
+ .insert(connectedStores)
896
+ .values({
897
+ organizationId: orgId,
898
+ provider: input.provider,
899
+ credentials: input.credentials,
900
+ storeDomain: input.storeDomain,
901
+ webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
902
+ })
903
+ .returning();
119
904
  const connector = this.connectors.get(input.provider);
120
905
  const store = rows[0];
121
906
  if (connector.registerWebhooks) {
@@ -258,78 +1043,458 @@ export class ChannelConnectorService {
258
1043
  .update(connectedStores)
259
1044
  .set({ catalogCursor: null, lastSyncAt: new Date(), updatedAt: new Date() })
260
1045
  .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
261
- return Ok({ imported: result.value.imported, cursor: null });
1046
+ return Ok({
1047
+ imported: result.value.imported,
1048
+ cursor: null,
1049
+ ...(result.value.skipped.length > 0 ? { skipped: uniqueSkipped(result.value.skipped) } : {}),
1050
+ ...(result.value.conflicts.length > 0 ? { conflicts: result.value.conflicts } : {}),
1051
+ ...(result.value.warnings.length > 0 ? { warnings: result.value.warnings } : {}),
1052
+ });
262
1053
  }
263
- async convergeCatalogItems(orgId, storeId, items, actor) {
1054
+ async promoteLegacyAttributes(orgId, storeId, actor, dryRun) {
1055
+ const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity")));
1056
+ let created = 0;
1057
+ for (const entityId of new Set(mappings.map((mapping) => mapping.entityId))) {
1058
+ const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entityId)));
1059
+ if (!entity)
1060
+ continue;
1061
+ const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
1062
+ if (attributes.length > 0)
1063
+ continue;
1064
+ const metadata = entity.metadata ?? {};
1065
+ if (typeof metadata.title !== "string")
1066
+ continue;
1067
+ const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
1068
+ if (owners.get("attributes.en.title") === "platform")
1069
+ continue;
1070
+ if (dryRun) {
1071
+ created += 1;
1072
+ continue;
1073
+ }
1074
+ const promoted = await this.catalog.setAttributes(entity.id, "en", {
1075
+ title: metadata.title,
1076
+ ...(typeof metadata.description === "string" ? { description: metadata.description } : {}),
1077
+ }, actor);
1078
+ if (!promoted.ok)
1079
+ return PluginErr(promoted.error.message);
1080
+ const [confirmed] = await this.db.select({ id: sellableAttributes.id, title: sellableAttributes.title, description: sellableAttributes.description }).from(sellableAttributes).where(and(eq(sellableAttributes.entityId, entity.id), eq(sellableAttributes.locale, "en")));
1081
+ if (!confirmed || confirmed.title !== metadata.title || (typeof metadata.description === "string" && confirmed.description !== metadata.description)) {
1082
+ return PluginErr(`Legacy attributes for entity "${entity.id}" were not persisted.`);
1083
+ }
1084
+ const nextMetadata = { ...metadata };
1085
+ delete nextMetadata.title;
1086
+ if (typeof metadata.description === "string")
1087
+ delete nextMetadata.description;
1088
+ await this.db.update(sellableEntities).set({ metadata: nextMetadata, updatedAt: new Date() }).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entity.id)));
1089
+ created += 1;
1090
+ }
1091
+ return Ok(created);
1092
+ }
1093
+ async saveBackfillState(orgId, storeId, state) {
1094
+ const [store] = await this.db.select({ breakerState: connectedStores.breakerState }).from(connectedStores).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
1095
+ await this.db.update(connectedStores).set({
1096
+ breakerState: { ...(store?.breakerState ?? {}), catalogBackfill: state },
1097
+ updatedAt: new Date(),
1098
+ }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
1099
+ }
1100
+ async backfillCatalog(orgId, storeId, actor, options = {}) {
1101
+ const store = await this.getStoreRecord(orgId, storeId);
1102
+ if (!store || store.status !== "connected")
1103
+ return PluginErr("Connected store not found.", "NOT_FOUND");
1104
+ const connector = this.connectors.get(store.provider);
1105
+ if (!connector)
1106
+ return PluginErr(`No connector registered for provider "${store.provider}".`);
1107
+ const dryRun = options.dryRun === true;
1108
+ const saved = store.breakerState.catalogBackfill;
1109
+ const savedState = saved && typeof saved === "object" ? saved : undefined;
1110
+ // Undefined resume derives from persisted state, so a retried job or a
1111
+ // re-triggered run continues an unfinished backfill instead of restarting.
1112
+ const resume = options.resume ?? (savedState !== undefined && !savedState.completedAt);
1113
+ if (resume && savedState?.completedAt && savedState.cursor === null) {
1114
+ return Ok({
1115
+ ...savedState.report,
1116
+ cursor: null,
1117
+ complete: true,
1118
+ ...(savedState.skipped?.length ? { skipped: savedState.skipped } : {}),
1119
+ ...(savedState.conflicts?.length ? { conflicts: savedState.conflicts } : {}),
1120
+ ...(savedState.warnings?.length ? { warnings: savedState.warnings } : {}),
1121
+ });
1122
+ }
1123
+ const report = resume && savedState ? { ...savedState.report } : {
1124
+ entitiesTouched: 0,
1125
+ attributesCreated: 0,
1126
+ mediaImported: 0,
1127
+ variantsGivenOptionValues: 0,
1128
+ };
1129
+ const skipped = resume && savedState?.skipped ? [...savedState.skipped] : [];
1130
+ const conflicts = resume && savedState?.conflicts ? [...savedState.conflicts] : [];
1131
+ const warnings = resume && savedState?.warnings ? [...savedState.warnings] : [];
1132
+ const promoted = await this.promoteLegacyAttributes(orgId, storeId, actor, dryRun);
1133
+ if (!promoted.ok)
1134
+ return promoted;
1135
+ report.attributesCreated += promoted.value;
1136
+ let cursor = resume && savedState?.cursor ? savedState.cursor : undefined;
1137
+ let pages = 0;
1138
+ if (!dryRun) {
1139
+ await this.saveBackfillState(orgId, storeId, {
1140
+ cursor: cursor ?? null,
1141
+ report,
1142
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
1143
+ ...(conflicts.length > 0 ? { conflicts } : {}),
1144
+ ...(warnings.length > 0 ? { warnings } : {}),
1145
+ });
1146
+ }
1147
+ do {
1148
+ const page = await connector.importCatalog(store, cursor);
1149
+ if (!page.ok)
1150
+ return PluginErr(page.error.message);
1151
+ const converged = await this.convergeCatalogItems(orgId, storeId, page.value.items, actor, true, dryRun);
1152
+ if (!converged.ok)
1153
+ return converged;
1154
+ report.entitiesTouched += converged.value.entitiesTouched;
1155
+ report.attributesCreated += converged.value.attributesCreated;
1156
+ report.mediaImported += converged.value.mediaImported;
1157
+ report.variantsGivenOptionValues += converged.value.variantsGivenOptionValues;
1158
+ skipped.push(...converged.value.skipped);
1159
+ conflicts.push(...converged.value.conflicts);
1160
+ warnings.push(...converged.value.warnings);
1161
+ cursor = page.value.nextCursor ?? undefined;
1162
+ pages += 1;
1163
+ // The final state is written once with completedAt below; a cursor-null
1164
+ // checkpoint without it would read as a fresh start after a crash.
1165
+ if (!dryRun && cursor) {
1166
+ await this.saveBackfillState(orgId, storeId, {
1167
+ cursor,
1168
+ report,
1169
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
1170
+ ...(conflicts.length > 0 ? { conflicts } : {}),
1171
+ ...(warnings.length > 0 ? { warnings } : {}),
1172
+ });
1173
+ }
1174
+ if (options.maxPages !== undefined && pages >= options.maxPages && cursor) {
1175
+ return Ok({
1176
+ ...report,
1177
+ cursor,
1178
+ complete: false,
1179
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
1180
+ ...(conflicts.length > 0 ? { conflicts } : {}),
1181
+ ...(warnings.length > 0 ? { warnings } : {}),
1182
+ });
1183
+ }
1184
+ } while (cursor);
1185
+ if (!dryRun) {
1186
+ await this.saveBackfillState(orgId, storeId, {
1187
+ cursor: null,
1188
+ report,
1189
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
1190
+ ...(conflicts.length > 0 ? { conflicts } : {}),
1191
+ ...(warnings.length > 0 ? { warnings } : {}),
1192
+ completedAt: new Date().toISOString(),
1193
+ });
1194
+ }
1195
+ return Ok({
1196
+ ...report,
1197
+ cursor: null,
1198
+ complete: true,
1199
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
1200
+ ...(conflicts.length > 0 ? { conflicts } : {}),
1201
+ ...(warnings.length > 0 ? { warnings } : {}),
1202
+ });
1203
+ }
1204
+ async estimateCatalogItems(orgId, storeId, items) {
1205
+ const stats = {
1206
+ imported: 0,
1207
+ converged: 0,
1208
+ entitiesTouched: 0,
1209
+ attributesCreated: 0,
1210
+ mediaImported: 0,
1211
+ variantsGivenOptionValues: 0,
1212
+ skipped: [],
1213
+ conflicts: [],
1214
+ warnings: [],
1215
+ };
1216
+ const assets = await this.db.select().from(mediaAssets).where(eq(mediaAssets.organizationId, orgId));
1217
+ for (const item of items) {
1218
+ const [entityMapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, item.externalId)));
1219
+ if (!entityMapping) {
1220
+ stats.imported += 1;
1221
+ stats.entitiesTouched += 1;
1222
+ stats.attributesCreated += item.attributes?.length || 1;
1223
+ stats.variantsGivenOptionValues += item.variants.filter((variant) => Object.keys(variant.optionValues ?? {}).some((name) => item.options?.some((option) => option.name === name))).length;
1224
+ stats.mediaImported += item.images?.length ?? 0;
1225
+ continue;
1226
+ }
1227
+ const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entityMapping.entityId)));
1228
+ if (!entity)
1229
+ continue;
1230
+ const owners = await this.catalog.resolveFieldOwners(entity.id, storeId);
1231
+ stats.skipped.push(...importedFieldPaths(item)
1232
+ .filter((path) => owners.get(path) === "platform")
1233
+ .map((fieldPath) => ({ entityId: entity.id, fieldPath })));
1234
+ let touched = false;
1235
+ const attributes = await this.db.select().from(sellableAttributes).where(eq(sellableAttributes.entityId, entity.id));
1236
+ const locales = new Set(attributes.map((attribute) => attribute.locale));
1237
+ const metadata = entity.metadata ?? {};
1238
+ if (attributes.length === 0 && typeof metadata.title === "string") {
1239
+ locales.add("en");
1240
+ touched = true;
1241
+ }
1242
+ const sourceAttributes = item.attributes?.length
1243
+ ? item.attributes
1244
+ : [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
1245
+ for (const attribute of sourceAttributes) {
1246
+ if (!locales.has(attribute.locale)) {
1247
+ stats.attributesCreated += 1;
1248
+ locales.add(attribute.locale);
1249
+ touched = true;
1250
+ }
1251
+ }
1252
+ const remoteMetadata = mergeMetadata(entity.metadata, item.metadata ?? {});
1253
+ const remoteStatus = item.status ?? (entity.status === "archived" ? "active" : undefined);
1254
+ const entityChanged = entity.slug !== item.slug
1255
+ || hash(remoteMetadata) !== hash(entity.metadata ?? {})
1256
+ || (remoteStatus !== undefined && remoteStatus !== entity.status);
1257
+ if (entityChanged) {
1258
+ stats.converged += 1;
1259
+ touched = true;
1260
+ }
1261
+ const optionValueIds = new Map();
1262
+ const existingTypes = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entity.id));
1263
+ for (const sourceType of item.options ?? []) {
1264
+ const existingType = existingTypes.find((optionType) => optionType.name === sourceType.name);
1265
+ if (!existingType) {
1266
+ touched = true;
1267
+ optionValueIds.set(sourceType.name, new Map(sourceType.values.map((value) => [value.value, `new:${sourceType.name}:${value.value}`])));
1268
+ continue;
1269
+ }
1270
+ const existingValues = await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, existingType.id));
1271
+ const valueIds = new Map();
1272
+ for (const sourceValue of sourceType.values) {
1273
+ const existingValue = existingValues.find((value) => value.value === sourceValue.value);
1274
+ if (!existingValue)
1275
+ touched = true;
1276
+ valueIds.set(sourceValue.value, existingValue?.id ?? `new:${sourceType.name}:${sourceValue.value}`);
1277
+ }
1278
+ optionValueIds.set(sourceType.name, valueIds);
1279
+ }
1280
+ const variantMappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), eq(channelEntityMap.entityId, entity.id)));
1281
+ const variantIds = new Map();
1282
+ for (const sourceVariant of item.variants) {
1283
+ const mapping = variantMappings.find((row) => row.externalId === sourceVariant.externalId);
1284
+ const variantId = mapping?.variantId ?? `new:${sourceVariant.externalId}`;
1285
+ variantIds.set(sourceVariant.externalId, variantId);
1286
+ const desiredIds = [...new Set(Object.entries(sourceVariant.optionValues ?? {})
1287
+ .map(([name, value]) => optionValueIds.get(name)?.get(value))
1288
+ .filter((optionValueId) => optionValueId !== undefined))].sort();
1289
+ if (!mapping?.variantId) {
1290
+ if (desiredIds.length > 0)
1291
+ stats.variantsGivenOptionValues += 1;
1292
+ touched = true;
1293
+ continue;
1294
+ }
1295
+ const current = await this.db.select().from(variantOptionValues).where(eq(variantOptionValues.variantId, mapping.variantId));
1296
+ const currentIds = current.map((row) => row.optionValueId).sort();
1297
+ if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
1298
+ if (desiredIds.length > 0)
1299
+ stats.variantsGivenOptionValues += 1;
1300
+ touched = true;
1301
+ }
1302
+ }
1303
+ const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entity.id));
1304
+ for (const image of item.images ?? []) {
1305
+ const urlHash = hash(image.url);
1306
+ const asset = assets.find((row) => {
1307
+ const assetMetadata = row.metadata ?? {};
1308
+ return (image.externalId != null && assetMetadata.channelImageExternalId === image.externalId)
1309
+ || assetMetadata.channelImageUrlHash === urlHash;
1310
+ });
1311
+ const mediaAssetId = asset?.id ?? `new:${urlHash}`;
1312
+ if (!asset)
1313
+ stats.mediaImported += 1;
1314
+ const targets = image.variantExternalIds?.length
1315
+ ? image.variantExternalIds.map((externalId) => ({ externalId, variantId: variantIds.get(externalId) }))
1316
+ : [{ externalId: undefined, variantId: undefined }];
1317
+ for (const target of targets) {
1318
+ if (image.variantExternalIds?.length && !target.variantId) {
1319
+ stats.warnings.push(`Skipped image "${image.externalId ?? image.url}" for unmapped variant "${target.externalId}".`);
1320
+ continue;
1321
+ }
1322
+ const existingLink = links.find((link) => link.mediaAssetId === mediaAssetId && (target.variantId === undefined ? link.variantId === null : link.variantId === target.variantId));
1323
+ if (!existingLink)
1324
+ touched = true;
1325
+ }
1326
+ }
1327
+ if (touched)
1328
+ stats.entitiesTouched += 1;
1329
+ }
1330
+ return Ok(stats);
1331
+ }
1332
+ async convergeCatalogItems(orgId, storeId, items, actor, force = false, dryRun = false) {
1333
+ if (dryRun)
1334
+ return this.estimateCatalogItems(orgId, storeId, items);
264
1335
  let imported = 0;
265
1336
  let converged = 0;
1337
+ let entitiesTouched = 0;
1338
+ let attributesCreated = 0;
1339
+ let mediaImported = 0;
1340
+ let variantsGivenOptionValues = 0;
1341
+ const skipped = [];
1342
+ const conflicts = [];
1343
+ const warnings = [];
266
1344
  for (const item of items) {
1345
+ const remoteHash = hash(item);
267
1346
  const existing = await this.db
268
1347
  .select()
269
1348
  .from(channelEntityMap)
270
1349
  .where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, item.externalId)));
271
1350
  const entityMapping = existing.find((entry) => entry.kind === "entity");
1351
+ let entityId;
1352
+ let isNew = false;
1353
+ let entityTouched = false;
1354
+ let existingEntity;
272
1355
  if (entityMapping) {
273
1356
  const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entityMapping.entityId)));
274
- if (entityMapping.syncHash !== hash(item) || entity?.status === "archived") {
275
- const updated = await this.catalog.update(entityMapping.entityId, {
276
- slug: item.slug,
277
- metadata: {
278
- ...(item.metadata ?? {}),
279
- title: item.title,
280
- ...(item.description !== undefined ? { description: item.description } : {}),
281
- },
282
- ...(entity?.status === "archived" ? { status: "active", isVisible: true } : {}),
283
- }, actor);
284
- if (!updated.ok)
285
- return PluginErr(updated.error.message);
286
- await this.db.update(channelEntityMap).set({ syncHash: hash(item), lastSyncedAt: new Date() }).where(eq(channelEntityMap.id, entityMapping.id));
287
- converged += 1;
1357
+ if (!entity) {
1358
+ warnings.push(`Skipped "${item.externalId}": mapped entity ${entityMapping.entityId} no longer exists.`);
1359
+ continue;
288
1360
  }
289
- continue;
1361
+ entityId = entityMapping.entityId;
1362
+ existingEntity = entity;
290
1363
  }
291
- const entity = await this.catalog.create({
292
- type: "product",
293
- slug: item.slug,
294
- sourceStoreId: storeId,
295
- metadata: {
296
- ...(item.metadata ?? {}),
297
- title: item.title,
298
- ...(item.description !== undefined ? { description: item.description } : {}),
299
- },
300
- }, actor);
301
- if (!entity.ok)
302
- return PluginErr(entity.error.message);
303
- await this.db.insert(channelEntityMap).values({
304
- organizationId: orgId,
305
- storeId,
306
- kind: "entity",
307
- externalId: item.externalId,
308
- entityId: entity.value.id,
309
- syncHash: hash(item),
310
- });
311
- for (const sourceVariant of item.variants) {
312
- const variant = await this.catalog.createVariant({
313
- entityId: entity.value.id,
314
- options: {},
315
- ...(sourceVariant.sku !== undefined ? { sku: sourceVariant.sku } : {}),
316
- ...(sourceVariant.barcode !== undefined ? { barcode: sourceVariant.barcode } : {}),
1364
+ else {
1365
+ const status = item.status;
1366
+ const entity = await this.catalog.create({
1367
+ type: "product",
1368
+ slug: item.slug,
1369
+ sourceStoreId: storeId,
1370
+ metadata: mergeMetadata(undefined, item.metadata ?? {}),
1371
+ ...(status !== undefined ? { status, isVisible: status === "active" } : {}),
317
1372
  }, actor);
318
- if (!variant.ok)
319
- return PluginErr(variant.error.message);
1373
+ if (!entity.ok)
1374
+ return PluginErr(entity.error.message);
1375
+ entityId = entity.value.id;
1376
+ isNew = true;
1377
+ imported += 1;
1378
+ entityTouched = true;
1379
+ }
1380
+ const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
1381
+ const seedPaths = importedFieldPaths(item).filter((path) => !ownershipBeforeSeed.has(path));
1382
+ const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
1383
+ if (!seeded.ok)
1384
+ return PluginErr(seeded.error.message);
1385
+ for (const path of seedPaths)
1386
+ ownershipBeforeSeed.set(path, "store");
1387
+ const owners = ownershipBeforeSeed;
1388
+ const remoteChanged = entityMapping === undefined || entityMapping.syncHash !== remoteHash;
1389
+ // An unchanged remote item writes nothing and advances no baseline:
1390
+ // converging a stale replay would revert local edits to shared and
1391
+ // unowned fields that the store never actually changed.
1392
+ if (!force && !remoteChanged && existingEntity && existingEntity.status !== "archived") {
1393
+ continue;
1394
+ }
1395
+ const shared = existingEntity
1396
+ ? await this.detectSharedConflicts(entityId, storeId, existingEntity, entityMapping, item, owners)
1397
+ : { paths: [], conflicts: [] };
1398
+ const owned = this.filterOwnedFields(item, owners);
1399
+ const heldSharedPaths = [...new Set([...(entityMapping?.heldFieldPaths ?? []), ...shared.paths])];
1400
+ const held = this.filterConflictingFields(owned.writable, heldSharedPaths);
1401
+ const writable = held.writable;
1402
+ const blockedPaths = new Set([...owned.skipped, ...heldSharedPaths]);
1403
+ skipped.push(...owned.skipped.map((fieldPath) => ({ entityId, fieldPath })));
1404
+ conflicts.push(...shared.conflicts);
1405
+ for (const conflict of shared.conflicts) {
1406
+ warnings.push(`Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
1407
+ }
1408
+ if (existingEntity && entityMapping) {
1409
+ const remoteMetadata = mergeMetadata(existingEntity.metadata, writable.metadata ?? {});
1410
+ const remoteStatus = ownerAllows(owners, "entity.status") && !blockedPaths.has("entity.status")
1411
+ ? writable.status ?? (existingEntity.status === "archived" ? "active" : undefined)
1412
+ : undefined;
1413
+ const updateInput = {};
1414
+ if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== writable.slug) {
1415
+ updateInput.slug = writable.slug;
1416
+ }
1417
+ if (hash(remoteMetadata) !== hash(existingEntity.metadata ?? {}))
1418
+ updateInput.metadata = remoteMetadata;
1419
+ if (remoteStatus !== undefined && !blockedPaths.has("entity.status") && remoteStatus !== existingEntity.status) {
1420
+ updateInput.status = remoteStatus;
1421
+ updateInput.isVisible = remoteStatus === "active";
1422
+ }
1423
+ const shouldUpdate = force
1424
+ ? Object.keys(updateInput).length > 0
1425
+ : remoteChanged || existingEntity.status === "archived";
1426
+ if (shouldUpdate) {
1427
+ converged += 1;
1428
+ if (Object.keys(updateInput).length > 0) {
1429
+ const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor);
1430
+ if (!updated.ok)
1431
+ return PluginErr(updated.error.message);
1432
+ entityTouched = true;
1433
+ }
1434
+ }
1435
+ }
1436
+ const optionAxes = await this.upsertOptionAxes(entityId, writable, actor);
1437
+ if (!optionAxes.ok)
1438
+ return optionAxes;
1439
+ const attributes = await this.setCatalogAttributes(entityId, writable, actor, blockedPaths);
1440
+ if (!attributes.ok)
1441
+ return attributes;
1442
+ const variantIds = await this.upsertVariants(orgId, storeId, entityId, writable, optionAxes.value.value, actor, warnings, !heldSharedPaths.includes("options") && owners.get("options") !== "platform", item);
1443
+ if (!variantIds.ok)
1444
+ return variantIds;
1445
+ const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
1446
+ if (!taxonomy.ok)
1447
+ return taxonomy;
1448
+ const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
1449
+ if (!media.ok)
1450
+ return media;
1451
+ attributesCreated += attributes.value.created;
1452
+ mediaImported += media.value.imported;
1453
+ variantsGivenOptionValues += variantIds.value.repaired;
1454
+ skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
1455
+ entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
1456
+ if (entityTouched)
1457
+ entitiesTouched += 1;
1458
+ if (entityTouched) {
1459
+ const revision = await this.catalog.recordEntityRevision(entityId, actor, "import");
1460
+ if (!revision.ok)
1461
+ return PluginErr(revision.error.message);
1462
+ }
1463
+ const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
1464
+ const latestRevisionAt = revisionMarkers.at(-1)?.createdAt;
1465
+ const lastSyncedAt = latestRevisionAt ?? entityMapping?.lastSyncedAt ?? new Date();
1466
+ if (isNew) {
320
1467
  await this.db.insert(channelEntityMap).values({
321
1468
  organizationId: orgId,
322
1469
  storeId,
323
- kind: "variant",
324
- externalId: sourceVariant.externalId,
325
- entityId: entity.value.id,
326
- variantId: variant.value.id,
327
- syncHash: hash(sourceVariant),
1470
+ kind: "entity",
1471
+ externalId: item.externalId,
1472
+ entityId,
1473
+ syncHash: remoteHash,
1474
+ lastSyncedAt,
1475
+ heldFieldPaths: heldSharedPaths,
328
1476
  });
329
1477
  }
330
- imported += 1;
1478
+ else if (entityMapping) {
1479
+ await this.db.update(channelEntityMap).set({
1480
+ syncHash: remoteHash,
1481
+ lastSyncedAt,
1482
+ heldFieldPaths: heldSharedPaths,
1483
+ }).where(eq(channelEntityMap.id, entityMapping.id));
1484
+ }
1485
+ await this.db.update(channelEntityMap).set({ lastSyncedAt }).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.entityId, entityId), eq(channelEntityMap.kind, "variant")));
331
1486
  }
332
- return Ok({ imported, converged });
1487
+ return Ok({
1488
+ imported,
1489
+ converged,
1490
+ entitiesTouched,
1491
+ attributesCreated,
1492
+ mediaImported,
1493
+ variantsGivenOptionValues,
1494
+ skipped,
1495
+ conflicts,
1496
+ warnings,
1497
+ });
333
1498
  }
334
1499
  async reconcile(orgId, storeId, actor) {
335
1500
  const store = await this.getStoreRecord(orgId, storeId);
@@ -354,11 +1519,17 @@ export class ChannelConnectorService {
354
1519
  return converged;
355
1520
  const present = new Set(items.map((item) => item.externalId));
356
1521
  let archived = 0;
1522
+ const skipped = [...converged.value.skipped];
357
1523
  for (const mapping of entityMappings) {
358
1524
  if (present.has(mapping.externalId))
359
1525
  continue;
360
1526
  const [entity] = await this.db.select({ status: sellableEntities.status }).from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, mapping.entityId)));
361
1527
  if (entity?.status !== "archived") {
1528
+ const owners = await this.catalog.resolveFieldOwners(mapping.entityId, storeId);
1529
+ if (owners.get("entity.status") === "platform") {
1530
+ skipped.push({ entityId: mapping.entityId, fieldPath: "entity.status" });
1531
+ continue;
1532
+ }
362
1533
  const result = await this.catalog.archive(mapping.entityId, actor);
363
1534
  if (!result.ok)
364
1535
  return PluginErr(result.error.message);
@@ -395,6 +1566,9 @@ export class ChannelConnectorService {
395
1566
  archived,
396
1567
  inventoryUpdated,
397
1568
  driftAlert: converged.value.imported + converged.value.converged + archived > threshold,
1569
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
1570
+ ...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
1571
+ ...(converged.value.warnings.length > 0 ? { warnings: converged.value.warnings } : {}),
398
1572
  };
399
1573
  await this.db.update(connectedStores).set({
400
1574
  lastReconcileAt: new Date(),
@@ -447,19 +1621,34 @@ export class ChannelConnectorService {
447
1621
  return PluginErr("Connected store not found.", "NOT_FOUND");
448
1622
  const actor = createSystemActor(orgId);
449
1623
  const data = event.data;
1624
+ let skipped = [];
1625
+ let conflicts = [];
1626
+ let warnings = [];
450
1627
  if (event.type === "products/update") {
451
1628
  const productId = String(data.id ?? data.product_id ?? "");
452
1629
  const mapping = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, productId)));
453
- if (mapping[0])
454
- await this.convergeCatalogItem(orgId, storeId, mapping[0].entityId, data, actor);
1630
+ if (mapping[0]) {
1631
+ const converged = await this.convergeCatalogItem(orgId, storeId, mapping[0].entityId, data, actor);
1632
+ if (!converged.ok)
1633
+ return converged;
1634
+ skipped = converged.value.skipped;
1635
+ conflicts = converged.value.conflicts;
1636
+ warnings = converged.value.warnings;
1637
+ }
455
1638
  }
456
1639
  else if (event.type === "products/delete") {
457
1640
  const productId = String(data.id ?? data.product_id ?? "");
458
1641
  const mapping = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, productId)));
459
1642
  if (mapping[0]) {
460
- const archived = await this.catalog.archive(mapping[0].entityId, actor);
461
- if (!archived.ok)
462
- return PluginErr(archived.error.message);
1643
+ const owners = await this.catalog.resolveFieldOwners(mapping[0].entityId, storeId);
1644
+ if (owners.get("entity.status") === "platform") {
1645
+ skipped.push({ entityId: mapping[0].entityId, fieldPath: "entity.status" });
1646
+ }
1647
+ else {
1648
+ const archived = await this.catalog.archive(mapping[0].entityId, actor);
1649
+ if (!archived.ok)
1650
+ return PluginErr(archived.error.message);
1651
+ }
463
1652
  }
464
1653
  }
465
1654
  else if (event.type === "inventory_levels/update") {
@@ -513,6 +1702,15 @@ export class ChannelConnectorService {
513
1702
  return disconnected;
514
1703
  return Ok({ processed: true });
515
1704
  }
1705
+ if (skipped.length > 0 || conflicts.length > 0 || warnings.length > 0) {
1706
+ const report = {
1707
+ ...(store.lastReconcileReport ?? {}),
1708
+ ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
1709
+ ...(conflicts.length > 0 ? { conflicts } : {}),
1710
+ ...(warnings.length > 0 ? { warnings } : {}),
1711
+ };
1712
+ await this.db.update(connectedStores).set({ lastReconcileReport: report, updatedAt: new Date() }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
1713
+ }
516
1714
  return Ok({ processed: true });
517
1715
  }
518
1716
  complianceEmail(data) {
@@ -571,6 +1769,88 @@ export class ChannelConnectorService {
571
1769
  }
572
1770
  async convergeCatalogItem(orgId, storeId, entityId, data, actor) {
573
1771
  const product = data.product && typeof data.product === "object" ? data.product : data;
1772
+ const remoteMetadata = product.metadata && typeof product.metadata === "object" && !Array.isArray(product.metadata)
1773
+ ? product.metadata
1774
+ : {};
1775
+ const [mapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.entityId, entityId)));
1776
+ const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entityId)));
1777
+ if (!mapping || !entity)
1778
+ return Ok({ skipped: [], conflicts: [], warnings: [] });
1779
+ const [currentAttribute] = await this.db.select().from(sellableAttributes).where(and(eq(sellableAttributes.entityId, entityId), eq(sellableAttributes.locale, "en")));
1780
+ const title = typeof product.title === "string" ? product.title : currentAttribute?.title ?? entity.slug;
1781
+ const description = product.description !== undefined
1782
+ ? String(product.description)
1783
+ : currentAttribute?.description ?? undefined;
1784
+ const status = typeof product.status === "string" && ["draft", "active", "archived", "discontinued"].includes(product.status)
1785
+ ? product.status
1786
+ : undefined;
1787
+ const remoteItem = {
1788
+ externalId: mapping.externalId,
1789
+ slug: typeof product.slug === "string" ? product.slug : entity.slug,
1790
+ title,
1791
+ ...(description !== undefined ? { description } : {}),
1792
+ ...(status !== undefined ? { status } : {}),
1793
+ attributes: [{ locale: "en", title, ...(description !== undefined ? { description } : {}) }],
1794
+ ...(Object.keys(remoteMetadata).length > 0 ? { metadata: remoteMetadata } : {}),
1795
+ variants: [],
1796
+ };
1797
+ const fieldPaths = [];
1798
+ if (typeof product.slug === "string")
1799
+ fieldPaths.push("entity.slug");
1800
+ if (status !== undefined)
1801
+ fieldPaths.push("entity.status");
1802
+ for (const key of Object.keys(remoteMetadata)) {
1803
+ const path = `entity.metadata.${key}`;
1804
+ if (isValidFieldPath(path))
1805
+ fieldPaths.push(path);
1806
+ }
1807
+ if (typeof product.title === "string")
1808
+ fieldPaths.push("attributes.en.title");
1809
+ if (product.description !== undefined)
1810
+ fieldPaths.push("attributes.en.description");
1811
+ const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
1812
+ const seedPaths = fieldPaths.filter((path) => !ownershipBeforeSeed.has(path));
1813
+ const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
1814
+ if (!seeded.ok)
1815
+ return PluginErr(seeded.error.message);
1816
+ for (const path of seedPaths)
1817
+ ownershipBeforeSeed.set(path, "store");
1818
+ const owners = ownershipBeforeSeed;
1819
+ const remoteHash = hash(product);
1820
+ const shared = await this.detectSharedConflicts(entityId, storeId, entity, mapping, remoteItem, owners, fieldPaths, remoteHash);
1821
+ const owned = this.filterOwnedFieldsAtPaths(remoteItem, owners, fieldPaths);
1822
+ const heldPaths = [...new Set([...(mapping.heldFieldPaths ?? []), ...shared.paths])];
1823
+ const held = this.filterConflictingFields(owned.writable, heldPaths);
1824
+ const blockedPaths = new Set([
1825
+ ...owned.skipped,
1826
+ ...heldPaths,
1827
+ ...(!fieldPaths.includes("attributes.en.title") ? ["attributes.en.title"] : []),
1828
+ ]);
1829
+ const skipped = owned.skipped.map((fieldPath) => ({ entityId, fieldPath }));
1830
+ const conflicts = shared.conflicts;
1831
+ const warnings = conflicts.map((conflict) => `Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
1832
+ const writable = held.writable;
1833
+ const updateInput = {};
1834
+ if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && entity.slug !== writable.slug) {
1835
+ updateInput.slug = writable.slug;
1836
+ }
1837
+ if (Object.keys(writable.metadata ?? {}).length > 0) {
1838
+ const remoteEntityMetadata = mergeMetadata(entity.metadata, writable.metadata ?? {});
1839
+ if (hash(remoteEntityMetadata) !== hash(entity.metadata ?? {}))
1840
+ updateInput.metadata = remoteEntityMetadata;
1841
+ }
1842
+ if (fieldPaths.includes("entity.status") && ownerAllows(owners, "entity.status") && !blockedPaths.has("entity.status") && typeof writable.status === "string" && writable.status !== entity.status) {
1843
+ updateInput.status = writable.status;
1844
+ updateInput.isVisible = writable.status === "active";
1845
+ }
1846
+ if (Object.keys(updateInput).length > 0) {
1847
+ const updated = await this.catalog.update(entityId, updateInput, actor);
1848
+ if (!updated.ok)
1849
+ return PluginErr(updated.error.message);
1850
+ }
1851
+ const attributes = await this.setCatalogAttributes(entityId, writable, actor, blockedPaths);
1852
+ if (!attributes.ok)
1853
+ return attributes;
574
1854
  const levels = Array.isArray(product.variants) ? product.variants : [];
575
1855
  for (const variant of levels) {
576
1856
  const externalId = String(variant.id ?? variant.variation_id ?? "");
@@ -578,6 +1858,14 @@ export class ChannelConnectorService {
578
1858
  if (externalId && available !== undefined)
579
1859
  await this.setMappedInventory(orgId, storeId, externalId, Number(available), actor);
580
1860
  }
1861
+ const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
1862
+ const lastSyncedAt = revisionMarkers.at(-1)?.createdAt ?? mapping.lastSyncedAt;
1863
+ await this.db.update(channelEntityMap).set({
1864
+ syncHash: remoteHash,
1865
+ lastSyncedAt,
1866
+ heldFieldPaths: heldPaths,
1867
+ }).where(eq(channelEntityMap.id, mapping.id));
1868
+ return Ok({ skipped, conflicts, warnings });
581
1869
  }
582
1870
  async createRefundRequest(orgId, store, data, actor) {
583
1871
  const remoteRefundId = String(data.id ?? data.refund_id ?? "");