@porulle/plugin-channel-connector 0.48.1 → 0.50.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
@@ -125,6 +125,41 @@ export function canCatalogPushTransition(from, to) {
125
125
  function hash(value) {
126
126
  return createHash("sha256").update(JSON.stringify(value)).digest("hex");
127
127
  }
128
+ /**
129
+ * The suffix a store's product takes when its handle is already another store's slug: the first
130
+ * label of the store domain (`kelly-felder.myshopify.com` → `kelly-felder`), slugified.
131
+ */
132
+ export function storeSlugSuffix(storeDomain) {
133
+ const label = storeDomain.trim().toLowerCase().split(".")[0] ?? "";
134
+ return label.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
135
+ }
136
+ const SLUG_CONFLICT = /sellable_entities_org_slug_unique|Entity with slug .+ already exists|Slug ".+" already exists/;
137
+ /**
138
+ * Whether a create or update lost a slug to another writer — core's pre-check message, or the
139
+ * unique index itself, which a driver error can carry several `cause`s deep.
140
+ */
141
+ export function isSlugConflict(error) {
142
+ let cursor = error;
143
+ for (let depth = 0; depth < 5; depth += 1) {
144
+ if (typeof cursor === "string")
145
+ return SLUG_CONFLICT.test(cursor);
146
+ if (typeof cursor !== "object" || cursor === null)
147
+ return false;
148
+ if ("constraint" in cursor && cursor.constraint === "sellable_entities_org_slug_unique")
149
+ return true;
150
+ if ("message" in cursor && typeof cursor.message === "string" && SLUG_CONFLICT.test(cursor.message))
151
+ return true;
152
+ cursor = "cause" in cursor ? cursor.cause : undefined;
153
+ }
154
+ return false;
155
+ }
156
+ function errorMessage(error) {
157
+ if (typeof error === "string")
158
+ return error;
159
+ if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
160
+ return error.message;
161
+ return String(error);
162
+ }
128
163
  /** Mid-import map rows carry this until convergence finishes; must not equal any real remote hash. */
129
164
  const PENDING_ENTITY_MAP_SYNC_HASH = "";
130
165
  export const CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS = 15 * 60 * 1000;
@@ -364,6 +399,131 @@ function redactStore(store) {
364
399
  updatedAt: store.updatedAt,
365
400
  };
366
401
  }
402
+ /**
403
+ * A hero is streamed inside the page's own invocation, so it is bounded: a 30 MB TIFF a merchant
404
+ * uploaded by mistake must not buffer into a 128 MiB isolate. Anything larger is reported, not
405
+ * stored, and the product still lands — the index reads text first and media later.
406
+ */
407
+ export const HERO_IMAGE_BYTE_CAP = 1024 * 1024;
408
+ function imageOrder(a, b) {
409
+ return (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
410
+ }
411
+ /**
412
+ * Ruling 2026-09-22: import the hero plus the FIRST photo of each other variant, nothing more.
413
+ * A "blue long dress" query must be able to show the blue variant, and a fourth photo of the red
414
+ * one adds nothing the index can use. Variants are read off the images' own variant references,
415
+ * so a connector that lists images against variants it does not enumerate still gets one each.
416
+ */
417
+ export function selectImportImages(item) {
418
+ const images = [...(item.images ?? [])].sort(imageOrder);
419
+ const hero = images.find((image) => image.role === "primary") ?? images[0] ?? null;
420
+ if (!hero)
421
+ return { hero: null, perVariant: [] };
422
+ const covered = new Set(hero.variantExternalIds ?? []);
423
+ const usedUrls = new Set([hero.url]);
424
+ const perVariant = [];
425
+ const variantRefs = [...new Set(images.flatMap((image) => image.variantExternalIds ?? []))];
426
+ for (const ref of variantRefs) {
427
+ if (covered.has(ref))
428
+ continue;
429
+ const image = images.find((candidate) => candidate.variantExternalIds?.includes(ref) && !usedUrls.has(candidate.url));
430
+ for (const shown of image?.variantExternalIds ?? [])
431
+ covered.add(shown);
432
+ covered.add(ref);
433
+ if (!image)
434
+ continue;
435
+ usedUrls.add(image.url);
436
+ perVariant.push(image);
437
+ }
438
+ return { hero, perVariant };
439
+ }
440
+ /** Streams a response and refuses mid-stream past `cap`; a lying `content-length` cannot get around it. */
441
+ async function fetchBounded(url, cap) {
442
+ let response;
443
+ try {
444
+ response = await fetch(url);
445
+ }
446
+ catch (error) {
447
+ return { ok: false, reason: "download-failed", detail: error instanceof Error ? error.message : "download failed" };
448
+ }
449
+ if (!response.ok)
450
+ return { ok: false, reason: "download-failed", detail: `download returned ${response.status}` };
451
+ const declared = Number(response.headers.get("content-length"));
452
+ if (Number.isFinite(declared) && declared > cap)
453
+ return { ok: false, reason: "too-large", detail: `content-length ${declared} exceeds ${cap}` };
454
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim() || "image/jpeg";
455
+ const reader = response.body?.getReader();
456
+ if (!reader) {
457
+ const buffer = await response.arrayBuffer();
458
+ if (buffer.byteLength > cap)
459
+ return { ok: false, reason: "too-large", detail: `${buffer.byteLength} bytes exceeds ${cap}` };
460
+ return { ok: true, bytes: new Uint8Array(buffer), contentType };
461
+ }
462
+ const chunks = [];
463
+ let total = 0;
464
+ for (;;) {
465
+ const { done, value } = await reader.read();
466
+ if (done)
467
+ break;
468
+ total += value.byteLength;
469
+ if (total > cap) {
470
+ await reader.cancel();
471
+ return { ok: false, reason: "too-large", detail: `stream exceeded ${cap} bytes` };
472
+ }
473
+ chunks.push(value);
474
+ }
475
+ const bytes = new Uint8Array(total);
476
+ let offset = 0;
477
+ for (const chunk of chunks) {
478
+ bytes.set(chunk, offset);
479
+ offset += chunk.byteLength;
480
+ }
481
+ return { ok: true, bytes, contentType };
482
+ }
483
+ function toImportProduct(item) {
484
+ const attributes = item.attributes?.length
485
+ ? item.attributes
486
+ : [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
487
+ return {
488
+ ref: item.externalId,
489
+ slug: item.slug,
490
+ ...(item.status !== undefined ? { status: item.status, isVisible: item.status === "active" } : {}),
491
+ metadata: mergeMetadata(undefined, item.metadata ?? {}),
492
+ attributes: attributes.map((attribute) => ({
493
+ locale: attribute.locale,
494
+ title: attribute.title,
495
+ ...(attribute.subtitle !== undefined ? { subtitle: attribute.subtitle } : {}),
496
+ ...(attribute.description !== undefined ? { description: attribute.description } : {}),
497
+ ...(attribute.richDescription !== undefined ? { richDescription: attribute.richDescription } : {}),
498
+ ...(attribute.seoTitle !== undefined ? { seoTitle: attribute.seoTitle } : {}),
499
+ ...(attribute.seoDescription !== undefined ? { seoDescription: attribute.seoDescription } : {}),
500
+ })),
501
+ ...(item.options !== undefined ? {
502
+ options: item.options.map((option) => ({
503
+ name: option.name,
504
+ displayName: option.displayName,
505
+ ...(option.sortOrder !== undefined ? { sortOrder: option.sortOrder } : {}),
506
+ values: option.values.map((value) => ({
507
+ value: value.value,
508
+ displayValue: value.displayValue,
509
+ ...(value.sortOrder !== undefined ? { sortOrder: value.sortOrder } : {}),
510
+ })),
511
+ })),
512
+ } : {}),
513
+ variants: item.variants.map((variant) => ({
514
+ ref: variant.externalId,
515
+ ...(variant.sku !== undefined ? { sku: variant.sku } : {}),
516
+ ...(variant.barcode !== undefined ? { barcode: variant.barcode } : {}),
517
+ ...(variant.optionValues !== undefined ? { options: variant.optionValues } : {}),
518
+ ...(variant.prices !== undefined ? { prices: variant.prices } : {}),
519
+ ...(variant.metadata !== undefined ? { metadata: variant.metadata } : {}),
520
+ })),
521
+ ...(item.tags !== undefined ? { tags: item.tags } : {}),
522
+ ...(item.brand !== undefined ? { brand: item.brand } : {}),
523
+ ...(item.categories !== undefined ? { categories: item.categories } : {}),
524
+ ownedFieldPaths: importedFieldPaths(item),
525
+ };
526
+ }
367
527
  export class ChannelConnectorService {
368
528
  db;
369
529
  services;
@@ -713,7 +873,15 @@ export class ChannelConnectorService {
713
873
  }
714
874
  async upsertOptionAxes(entityId, item, actor) {
715
875
  const optionValueIds = new Map();
716
- const existingTypes = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entityId));
876
+ // PROJECTED, not `select()`. Three reasons, and the third is the one that saves statements:
877
+ // the row's other columns are never read; a projection types the locally-constructed row below
878
+ // without a cast; and carrying `displayName`/`sortOrder` is what lets the update be SKIPPED when
879
+ // they already hold. A re-import that changes nothing is the common case for a sync, and it used
880
+ // to issue one UPDATE per option type and one per option value regardless.
881
+ const existingTypes = await this.db
882
+ .select({ id: optionTypes.id, name: optionTypes.name, displayName: optionTypes.displayName, sortOrder: optionTypes.sortOrder })
883
+ .from(optionTypes)
884
+ .where(eq(optionTypes.entityId, entityId));
717
885
  let changed = false;
718
886
  for (const [typeIndex, sourceType] of (item.options ?? []).entries()) {
719
887
  let optionType = existingTypes.find((row) => row.name === sourceType.name);
@@ -721,18 +889,37 @@ export class ChannelConnectorService {
721
889
  const created = await this.catalog.createOptionType({ entityId, name: sourceType.name, values: [] }, actor);
722
890
  if (!created.ok)
723
891
  return PluginErr(created.error.message);
724
- const [createdType] = await this.db.select().from(optionTypes).where(eq(optionTypes.id, created.value.id));
725
- if (!createdType)
726
- return PluginErr(`Option type "${sourceType.name}" was not persisted.`);
727
- optionType = createdType;
892
+ // The created row is CONSTRUCTED rather than read back: `createOptionType` returns the id and
893
+ // the name is what we just sent.
894
+ //
895
+ // The two nulls are DELIBERATE PLACEHOLDERS, not a claim about the database. `createOptionType`
896
+ // actually persists `displayName: input.name, sortOrder: 0` (core entity-service.ts:595) and
897
+ // `display_name` is NOT NULL (core catalog/schema.ts:307) — so a fresh row never holds null.
898
+ // Constructing nulls here makes the comparison below unequal on any input, which is what forces
899
+ // the update that writes the caller's real `displayName`/`sortOrder` over those defaults.
900
+ optionType = { id: created.value.id, name: sourceType.name, displayName: null, sortOrder: null };
728
901
  existingTypes.push(optionType);
729
902
  changed = true;
730
903
  }
731
- await this.db.update(optionTypes).set({
732
- displayName: sourceType.displayName,
733
- sortOrder: sourceType.sortOrder ?? typeIndex,
734
- }).where(eq(optionTypes.id, optionType.id));
735
- const existingValues = await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, optionType.id));
904
+ const desiredTypeSort = sourceType.sortOrder ?? typeIndex;
905
+ // `?? null` NORMALISES, and it is load-bearing rather than tidy. The stored value is `null` or a
906
+ // string; a connector that omits `displayName` sends `undefined`, and `null !== undefined` is
907
+ // true — so without this every such connector issued one UPDATE per option type on every sync
908
+ // and the conditional bought it nothing. Latent on today's corpus, whose connector always sends
909
+ // both fields.
910
+ const desiredTypeDisplay = sourceType.displayName ?? null;
911
+ if (optionType.displayName !== desiredTypeDisplay || optionType.sortOrder !== desiredTypeSort) {
912
+ await this.db.update(optionTypes).set({
913
+ displayName: sourceType.displayName,
914
+ sortOrder: desiredTypeSort,
915
+ }).where(eq(optionTypes.id, optionType.id));
916
+ optionType.displayName = desiredTypeDisplay;
917
+ optionType.sortOrder = desiredTypeSort;
918
+ }
919
+ const existingValues = await this.db
920
+ .select({ id: optionValues.id, value: optionValues.value, displayValue: optionValues.displayValue, sortOrder: optionValues.sortOrder })
921
+ .from(optionValues)
922
+ .where(eq(optionValues.optionTypeId, optionType.id));
736
923
  const valueIds = new Map();
737
924
  for (const [valueIndex, sourceValue] of sourceType.values.entries()) {
738
925
  let optionValue = existingValues.find((row) => row.value === sourceValue.value);
@@ -740,17 +927,22 @@ export class ChannelConnectorService {
740
927
  const created = await this.catalog.createOptionValue({ optionTypeId: optionType.id, value: sourceValue.value }, actor);
741
928
  if (!created.ok)
742
929
  return PluginErr(created.error.message);
743
- const [createdValue] = await this.db.select().from(optionValues).where(eq(optionValues.id, created.value.id));
744
- if (!createdValue)
745
- return PluginErr(`Option value "${sourceValue.value}" was not persisted.`);
746
- optionValue = createdValue;
930
+ // Same placeholder reasoning as the option type above: core persists `displayValue: input.value,
931
+ // sortOrder: 0` (entity-service.ts:615), and the nulls force the update that overwrites them.
932
+ optionValue = { id: created.value.id, value: sourceValue.value, displayValue: null, sortOrder: null };
747
933
  existingValues.push(optionValue);
748
934
  changed = true;
749
935
  }
750
- await this.db.update(optionValues).set({
751
- displayValue: sourceValue.displayValue,
752
- sortOrder: sourceValue.sortOrder ?? valueIndex,
753
- }).where(eq(optionValues.id, optionValue.id));
936
+ const desiredValueSort = sourceValue.sortOrder ?? valueIndex;
937
+ const desiredValueDisplay = sourceValue.displayValue ?? null;
938
+ if (optionValue.displayValue !== desiredValueDisplay || optionValue.sortOrder !== desiredValueSort) {
939
+ await this.db.update(optionValues).set({
940
+ displayValue: sourceValue.displayValue,
941
+ sortOrder: desiredValueSort,
942
+ }).where(eq(optionValues.id, optionValue.id));
943
+ optionValue.displayValue = desiredValueDisplay;
944
+ optionValue.sortOrder = desiredValueSort;
945
+ }
754
946
  valueIds.set(sourceValue.value, optionValue.id);
755
947
  }
756
948
  optionValueIds.set(sourceType.name, valueIds);
@@ -762,10 +954,52 @@ export class ChannelConnectorService {
762
954
  let repaired = 0;
763
955
  let changed = false;
764
956
  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)));
957
+ // ONE read of the existing option-value rows for every already-mapped variant, instead of one
958
+ // per variant inside the loop. Measured on the deployed Worker at 17.0 calls per product, which
959
+ // is one per offer on a catalogue averaging 13 offers per product. A variant CREATED below is
960
+ // absent from this map and correctly reads as empty: its rows are written in this same pass.
961
+ const mappedVariantIds = mappings
962
+ .map((row) => row.variantId)
963
+ .filter((variantId) => variantId !== null);
964
+ const existingOptionValues = new Map();
965
+ if (mappedVariantIds.length > 0) {
966
+ const rows = await this.db
967
+ .select({ variantId: variantOptionValues.variantId, optionValueId: variantOptionValues.optionValueId })
968
+ .from(variantOptionValues)
969
+ .where(inArray(variantOptionValues.variantId, mappedVariantIds));
970
+ for (const row of rows) {
971
+ const list = existingOptionValues.get(row.variantId) ?? [];
972
+ list.push(row.optionValueId);
973
+ existingOptionValues.set(row.variantId, list);
974
+ }
975
+ }
765
976
  for (const sourceVariant of item.variants) {
766
977
  const fullSourceVariant = fullItem.variants.find((variant) => variant.externalId === sourceVariant.externalId) ?? sourceVariant;
767
978
  let mapping = mappings.find((row) => row.externalId === sourceVariant.externalId);
768
979
  let variantId = mapping?.variantId;
980
+ // ADOPT BEFORE CREATE. A variant-kind mapping row whose `variantId` is null has lost its link
981
+ // — the key survived, the target did not. Creating a replacement is what the loop used to do,
982
+ // and it cannot work: the orphaned variant still holds the sku, so `variants_native_org_sku_unique`
983
+ // refuses the insert and the item fails on this and every later sync. The row can never heal.
984
+ //
985
+ // `sku` is the store's own natural key for a variant, so re-resolving by it is what restores
986
+ // the link the null destroyed. Scoped to this entity because a sku is unique per organization
987
+ // and adopting another entity's variant would be worse than failing.
988
+ if (mapping && !variantId && sourceVariant.sku) {
989
+ const [adopted] = await this.db
990
+ .select({ id: variants.id })
991
+ .from(variants)
992
+ .where(and(eq(variants.entityId, entityId), eq(variants.sku, sourceVariant.sku)))
993
+ .limit(1);
994
+ if (adopted) {
995
+ variantId = adopted.id;
996
+ // Write the link back, or the row stays broken and every later sync pays this lookup again.
997
+ await this.db.update(channelEntityMap)
998
+ .set({ variantId })
999
+ .where(eq(channelEntityMap.id, mapping.id));
1000
+ mapping.variantId = variantId;
1001
+ }
1002
+ }
769
1003
  const createdVariant = !variantId;
770
1004
  if (!variantId) {
771
1005
  const options = {};
@@ -786,18 +1020,38 @@ export class ChannelConnectorService {
786
1020
  if (!created.ok)
787
1021
  return PluginErr(created.error.message);
788
1022
  variantId = created.value.id;
789
- const [createdMapping] = await this.db.insert(channelEntityMap).values({
790
- organizationId: orgId,
791
- storeId,
792
- kind: "variant",
793
- externalId: sourceVariant.externalId,
794
- entityId,
795
- variantId,
796
- syncHash: hash(fullSourceVariant),
797
- }).returning();
798
- mapping = createdMapping;
799
- if (mapping)
800
- mappings.push(mapping);
1023
+ if (mapping) {
1024
+ // REPAIR IN PLACE. `variantId` is nullable because this table also holds `kind: "entity"`
1025
+ // rows, which legitimately have none — but a VARIANT row with a null `variantId` is a
1026
+ // broken invariant, not a state to work around. The row already occupies
1027
+ // `channel_entity_map_store_kind_external_unique` on (store, kind, externalId), so the
1028
+ // insert below would raise a unique violation and fail the whole item. Updating the row
1029
+ // we already hold is the only shape that converges.
1030
+ await this.db.update(channelEntityMap).set({
1031
+ entityId,
1032
+ variantId,
1033
+ syncHash: hash(fullSourceVariant),
1034
+ }).where(eq(channelEntityMap.id, mapping.id));
1035
+ mapping.entityId = entityId;
1036
+ mapping.variantId = variantId;
1037
+ mapping.syncHash = hash(fullSourceVariant);
1038
+ }
1039
+ else {
1040
+ const [createdMapping] = await this.db.insert(channelEntityMap).values({
1041
+ organizationId: orgId,
1042
+ storeId,
1043
+ kind: "variant",
1044
+ externalId: sourceVariant.externalId,
1045
+ entityId,
1046
+ variantId,
1047
+ syncHash: hash(fullSourceVariant),
1048
+ }).returning();
1049
+ mapping = createdMapping;
1050
+ // Pushed so a payload repeating this externalId resolves the row it just created rather
1051
+ // than creating a second variant for it.
1052
+ if (mapping)
1053
+ mappings.push(mapping);
1054
+ }
801
1055
  }
802
1056
  if (!variantId) {
803
1057
  warnings.push(`Skipped variant "${sourceVariant.externalId}": no local variant mapping exists.`);
@@ -808,8 +1062,7 @@ export class ChannelConnectorService {
808
1062
  const desiredOptionValueIds = Object.entries(sourceVariant.optionValues ?? {})
809
1063
  .map(([name, value]) => optionValueIds.get(name)?.get(value))
810
1064
  .filter((optionValueId) => optionValueId !== undefined);
811
- const currentOptionValues = await this.db.select().from(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
812
- const currentIds = currentOptionValues.map((row) => row.optionValueId).sort();
1065
+ const currentIds = [...(existingOptionValues.get(variantId) ?? [])].sort();
813
1066
  const desiredIds = [...new Set(desiredOptionValueIds)].sort();
814
1067
  if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
815
1068
  await this.db.delete(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
@@ -817,6 +1070,9 @@ export class ChannelConnectorService {
817
1070
  await this.db.insert(variantOptionValues).values(desiredIds.map((optionValueId) => ({ variantId, optionValueId }))).onConflictDoNothing();
818
1071
  repaired += 1;
819
1072
  }
1073
+ // The map is the read model for this pass; a payload repeating an externalId must not see
1074
+ // the pre-fetched state after this write.
1075
+ existingOptionValues.set(variantId, [...desiredIds]);
820
1076
  changed = true;
821
1077
  }
822
1078
  if (createdVariant && desiredIds.length > 0) {
@@ -835,16 +1091,57 @@ export class ChannelConnectorService {
835
1091
  if (!priced.ok)
836
1092
  return PluginErr(priced.error.message);
837
1093
  }
838
- if (mapping) {
1094
+ // Was UNCONDITIONAL: one UPDATE per variant on every import, including a re-sync where the
1095
+ // variant is byte-identical. `mapping.syncHash` is already in hand from the select above, so
1096
+ // the comparison is free and the write disappears on an unchanged variant — which is the
1097
+ // ordinary case for a store that syncs repeatedly.
1098
+ const nextSyncHash = hash(fullSourceVariant);
1099
+ if (mapping && mapping.syncHash !== nextSyncHash) {
839
1100
  await this.db.update(channelEntityMap).set({
840
- syncHash: hash(fullSourceVariant),
1101
+ syncHash: nextSyncHash,
841
1102
  }).where(eq(channelEntityMap.id, mapping.id));
1103
+ mapping.syncHash = nextSyncHash;
842
1104
  }
843
1105
  }
844
1106
  return Ok({ value: variantIds, repaired, changed });
845
1107
  }
1108
+ /**
1109
+ * The organization's categories, brands and tags, read ONCE per converge run instead of once per
1110
+ * product.
1111
+ *
1112
+ * `applyTaxonomy` takes an `entityId` and is called unconditionally for every item, and each of
1113
+ * its three lookups was a whole-table read filtered by `organization_id`. Measured on the deployed
1114
+ * Worker: 1.0 call per product per class, three classes, every product — an organization's whole
1115
+ * category list re-read for each of twenty products in a batch that cannot have changed it.
1116
+ *
1117
+ * Rows created DURING the run are appended by the callers below, exactly as they were appended to
1118
+ * the per-product arrays before, so an item that introduces a category is still seen by the next
1119
+ * item. The cache is cleared at the top of `convergeCatalogItems`, so its lifetime is one converge
1120
+ * rather than the lifetime of the service.
1121
+ *
1122
+ * The staleness window widens from one product to one batch: a category created by ANOTHER process
1123
+ * mid-batch is not seen here. That was already true within a product — these lists were always a
1124
+ * snapshot — and the create paths below go through `this.catalog`, which refuses a duplicate slug
1125
+ * rather than writing one. So the failure mode is unchanged in kind and wider in window, which is
1126
+ * the trade this comment exists to state rather than hide.
1127
+ */
1128
+ taxonomyCache = null;
1129
+ async taxonomyFor(orgId) {
1130
+ // Keyed on orgId as well as presence: one service instance serving two organizations must not
1131
+ // hand the second one the first one's taxonomy.
1132
+ if (this.taxonomyCache?.orgId === orgId)
1133
+ return this.taxonomyCache;
1134
+ const [categoryRows, brandRows, tagRows] = await Promise.all([
1135
+ this.db.select().from(categories).where(eq(categories.organizationId, orgId)),
1136
+ this.db.select().from(brands).where(eq(brands.organizationId, orgId)),
1137
+ this.db.select().from(tags).where(eq(tags.organizationId, orgId)),
1138
+ ]);
1139
+ this.taxonomyCache = { orgId, categories: categoryRows, brands: brandRows, tags: tagRows };
1140
+ return this.taxonomyCache;
1141
+ }
846
1142
  async applyTaxonomy(orgId, entityId, item, actor, warnings) {
847
- const categoryRows = await this.db.select().from(categories).where(eq(categories.organizationId, orgId));
1143
+ const taxonomy = await this.taxonomyFor(orgId);
1144
+ const categoryRows = taxonomy.categories;
848
1145
  for (const slug of new Set(item.categories ?? [])) {
849
1146
  let category = categoryRows.find((row) => row.slug === slug);
850
1147
  if (category?.status === "archived") {
@@ -865,7 +1162,7 @@ export class ChannelConnectorService {
865
1162
  if (!linked.ok)
866
1163
  return PluginErr(linked.error.message);
867
1164
  }
868
- const brandRows = await this.db.select().from(brands).where(eq(brands.organizationId, orgId));
1165
+ const brandRows = taxonomy.brands;
869
1166
  if (item.brand) {
870
1167
  let brand = brandRows.find((row) => row.slug === item.brand);
871
1168
  if (!brand) {
@@ -882,7 +1179,7 @@ export class ChannelConnectorService {
882
1179
  if (!linked.ok)
883
1180
  return PluginErr(linked.error.message);
884
1181
  }
885
- const tagRows = await this.db.select().from(tags).where(eq(tags.organizationId, orgId));
1182
+ const tagRows = taxonomy.tags;
886
1183
  for (const slug of new Set(item.tags ?? [])) {
887
1184
  let tag = tagRows.find((row) => row.slug === slug);
888
1185
  if (!tag) {
@@ -1088,6 +1385,59 @@ export class ChannelConnectorService {
1088
1385
  .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, id)));
1089
1386
  return rows[0];
1090
1387
  }
1388
+ /**
1389
+ * The slug each wanted handle takes for THIS store. Slugs stay unique across the organization —
1390
+ * the storefront resolves `/:idOrSlug` org-wide — but one platform organization holds many
1391
+ * merchants, and two of them may sell the same handle.
1392
+ *
1393
+ * A handle's FAMILY for a store is, in order: the bare handle, `<handle>-<store suffix>`, and
1394
+ * `<handle>-<store suffix>-<store id prefix>`. The last is unique per store, so a third store with
1395
+ * the same domain label still gets a slug of its own. A new product takes the first member no
1396
+ * other store holds. An existing product whose slug is already in its handle's family KEEPS it
1397
+ * (see `slugToKeep`): a slug, once assigned, is a shared link and is never recomputed.
1398
+ */
1399
+ async resolveStoreSlugs(orgId, storeId, handles) {
1400
+ const wanted = [...new Set(handles)];
1401
+ const resolved = new Map();
1402
+ if (wanted.length === 0)
1403
+ return resolved;
1404
+ const suffix = await this.storeSlugSuffixFor(orgId, storeId);
1405
+ const idPart = storeId.replace(/[^a-z0-9]/gi, "").slice(0, 8).toLowerCase();
1406
+ const familyOf = (handle) => suffix
1407
+ ? [handle, `${handle}-${suffix}`, `${handle}-${suffix}-${idPart}`]
1408
+ : [handle, `${handle}-${idPart}`];
1409
+ const families = new Map(wanted.map((handle) => [handle, familyOf(handle)]));
1410
+ const owners = await this.db.select({ slug: sellableEntities.slug, sourceStoreId: sellableEntities.sourceStoreId })
1411
+ .from(sellableEntities)
1412
+ .where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.slug, [...families.values()].flat())));
1413
+ // Only ANOTHER STORE's product moves this one to a qualified slug. A product a person made in
1414
+ // Merchant Center (no source store) holding the handle stays a loud slug conflict, as before.
1415
+ const heldElsewhere = new Set(owners.filter((row) => row.sourceStoreId !== null && row.sourceStoreId !== storeId).map((row) => row.slug));
1416
+ for (const [handle, family] of families) {
1417
+ // Every member held by another store is only reachable through a hand-made slug; the last
1418
+ // member is then used anyway and the create fails loudly on the unique index.
1419
+ const slug = family.find((candidate) => !heldElsewhere.has(candidate)) ?? family[family.length - 1] ?? handle;
1420
+ resolved.set(handle, { slug, family });
1421
+ }
1422
+ return resolved;
1423
+ }
1424
+ // ponytail: cached for the service instance's lifetime (one task invocation), so a batched import
1425
+ // reads the store once. A store whose domain changes mid-invocation keeps the old suffix until the
1426
+ // next one — a new slug family only, never a rename of a slug already assigned.
1427
+ slugSuffixByStore = new Map();
1428
+ async storeSlugSuffixFor(orgId, storeId) {
1429
+ const cached = this.slugSuffixByStore.get(storeId);
1430
+ if (cached !== undefined)
1431
+ return cached;
1432
+ const suffix = storeSlugSuffix((await this.getStoreRecord(orgId, storeId))?.storeDomain ?? "");
1433
+ this.slugSuffixByStore.set(storeId, suffix);
1434
+ return suffix;
1435
+ }
1436
+ /** The slug an existing product converges to: its current one while that is still in the family
1437
+ * of the handle the store sends, so a slug is never recomputed out from under a shared link. */
1438
+ slugToKeep(currentSlug, resolved) {
1439
+ return resolved.family.includes(currentSlug) ? currentSlug : resolved.slug;
1440
+ }
1091
1441
  async getStoreByDomain(shopDomain) {
1092
1442
  const rows = await this.db
1093
1443
  .select()
@@ -1688,6 +2038,236 @@ export class ChannelConnectorService {
1688
2038
  }
1689
2039
  }));
1690
2040
  }
2041
+ /**
2042
+ * One page from the connector, nothing written. The host lands the page durably (R2 + its
2043
+ * ledger) and hands it to `convergeCatalogPage` from a queue consumer; the two halves are
2044
+ * separate so a consumer retry never re-fetches the merchant's API.
2045
+ */
2046
+ async fetchCatalogPage(orgId, storeId, cursor) {
2047
+ const store = await this.getStoreRecord(orgId, storeId);
2048
+ if (!store || store.status !== "connected")
2049
+ return PluginErr("Connected store not found.", "NOT_FOUND");
2050
+ const connector = this.connectors.get(store.provider);
2051
+ if (!connector)
2052
+ return PluginErr(`No connector registered for provider "${store.provider}".`);
2053
+ const page = await connector.importCatalog(store, cursor ?? undefined);
2054
+ if (!page.ok)
2055
+ return PluginErr(page.error.message);
2056
+ return Ok({ items: page.value.items, nextCursor: page.value.nextCursor ?? null });
2057
+ }
2058
+ /**
2059
+ * Converges a page: items this store has never mapped take the import fast path
2060
+ * (`catalog.importProducts`, one transaction, multi-row writes); items already mapped and
2061
+ * unchanged cost nothing; items mapped-but-changed, and orphans (an entity of this store with
2062
+ * the item's slug but no map row), take the editor path, which owns ownership and conflicts.
2063
+ *
2064
+ * Media: only each new item's hero is fetched here, streamed under `HERO_IMAGE_BYTE_CAP`, and
2065
+ * linked at entity level as `primary` plus to the variants it shows. The first photo of every
2066
+ * other variant comes back in `deferredMedia` for the host to land later.
2067
+ */
2068
+ async convergeCatalogPage(orgId, storeId, items, actor) {
2069
+ const failures = [];
2070
+ const warnings = [];
2071
+ const entityByExternalId = new Map();
2072
+ let unchanged = 0;
2073
+ let updated = 0;
2074
+ const externalIds = [...new Set(items.map((item) => item.externalId))];
2075
+ const mappings = externalIds.length === 0 ? [] : await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), inArray(channelEntityMap.externalId, externalIds)));
2076
+ const mappingByExternalId = new Map(mappings.map((row) => [row.externalId, row]));
2077
+ const slugFor = await this.resolveStoreSlugs(orgId, storeId, items.map((item) => item.slug));
2078
+ const slugs = [...new Set([...slugFor.values()].flatMap((resolved) => resolved.family))];
2079
+ const orphans = slugs.length === 0 ? [] : await this.db.select({ slug: sellableEntities.slug }).from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.sourceStoreId, storeId), inArray(sellableEntities.slug, slugs)));
2080
+ const orphanSlugs = new Set(orphans.map((row) => row.slug));
2081
+ const isOrphan = (handle) => (slugFor.get(handle)?.family ?? [handle]).some((slug) => orphanSlugs.has(slug));
2082
+ const fresh = [];
2083
+ const editor = [];
2084
+ const seen = new Set();
2085
+ for (const item of items) {
2086
+ if (seen.has(item.externalId)) {
2087
+ failures.push({ externalId: item.externalId, error: "duplicate-in-page: this externalId appears twice in the page." });
2088
+ continue;
2089
+ }
2090
+ seen.add(item.externalId);
2091
+ const mapping = mappingByExternalId.get(item.externalId);
2092
+ if (mapping && mapping.syncHash === hash(item)) {
2093
+ entityByExternalId.set(item.externalId, mapping.entityId);
2094
+ unchanged += 1;
2095
+ }
2096
+ else if (mapping || isOrphan(item.slug)) {
2097
+ editor.push(item);
2098
+ }
2099
+ else {
2100
+ fresh.push(item);
2101
+ }
2102
+ }
2103
+ if (editor.length > 0) {
2104
+ const result = await this.convergeCatalogItems(orgId, storeId, editor, actor);
2105
+ if (!result.ok)
2106
+ return result;
2107
+ failures.push(...result.value.failures);
2108
+ warnings.push(...result.value.warnings);
2109
+ const failedIds = new Set(result.value.failures.map((failure) => failure.externalId));
2110
+ const survivors = editor.filter((item) => !failedIds.has(item.externalId));
2111
+ survivors.forEach((item, index) => {
2112
+ const entityId = result.value.entityIds[index];
2113
+ if (entityId !== undefined)
2114
+ entityByExternalId.set(item.externalId, entityId);
2115
+ });
2116
+ updated += survivors.length;
2117
+ }
2118
+ const createdItems = [];
2119
+ // Two stores onboarding at once can both resolve a handle as free and then both create it. The
2120
+ // loser's collision is transient: re-resolve those items once (the winner is now visible, so they
2121
+ // take the store-qualified slug) and try again. A second collision is reported as the item's.
2122
+ let pending = fresh;
2123
+ for (let attempt = 0; pending.length > 0; attempt += 1) {
2124
+ // The slug is resolved on the import row only: the map row's hash stays the hash of the item
2125
+ // as the store sent it, so the next page still reads it as unchanged.
2126
+ const report = await this.catalog.importProducts(pending.map((item) => toImportProduct({ ...item, slug: slugFor.get(item.slug)?.slug ?? item.slug })), { sourceStoreId: storeId, errorPolicy: "reject-failed-rows" }, actor);
2127
+ if (!report.ok)
2128
+ return PluginErr(report.error.message, report.error.code);
2129
+ const collided = [];
2130
+ for (const [index, row] of report.value.rows.entries()) {
2131
+ const item = pending[index];
2132
+ if (!item)
2133
+ continue;
2134
+ if (row.status === "failed") {
2135
+ const failure = { externalId: row.ref, error: `${row.code}: ${row.error}` };
2136
+ const lostSlug = row.code === "slug-conflict" || (row.code === "conflict" && isSlugConflict(row.error));
2137
+ if (attempt === 0 && lostSlug)
2138
+ collided.push({ item, failure });
2139
+ else
2140
+ failures.push(failure);
2141
+ continue;
2142
+ }
2143
+ warnings.push(...row.warnings);
2144
+ entityByExternalId.set(item.externalId, row.entityId);
2145
+ createdItems.push({ item, entityId: row.entityId, variantIds: row.variantIds });
2146
+ }
2147
+ pending = [];
2148
+ if (collided.length > 0) {
2149
+ const again = await this.resolveStoreSlugs(orgId, storeId, collided.map(({ item }) => item.slug));
2150
+ for (const { item, failure } of collided) {
2151
+ const before = slugFor.get(item.slug)?.slug ?? item.slug;
2152
+ const after = again.get(item.slug);
2153
+ // Retry only when another STORE took the handle: a slug held by a product made in
2154
+ // Merchant Center resolves to the same slug again, and stays the loud conflict it was.
2155
+ if (after === undefined || after.slug === before) {
2156
+ failures.push(failure);
2157
+ continue;
2158
+ }
2159
+ slugFor.set(item.slug, after);
2160
+ pending.push(item);
2161
+ }
2162
+ }
2163
+ }
2164
+ if (createdItems.length > 0) {
2165
+ const now = new Date();
2166
+ await this.db.insert(channelEntityMap).values(createdItems.flatMap(({ item, entityId, variantIds }) => [
2167
+ { organizationId: orgId, storeId, kind: "entity", externalId: item.externalId, entityId, syncHash: hash(item), lastSyncedAt: now },
2168
+ ...item.variants.flatMap((variant) => {
2169
+ const variantId = variantIds[variant.externalId];
2170
+ return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant", externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
2171
+ }),
2172
+ ])).onConflictDoNothing();
2173
+ }
2174
+ const media = await this.importHeroes(orgId, createdItems, actor);
2175
+ const entityIds = [];
2176
+ const emitted = new Set();
2177
+ for (const item of items) {
2178
+ const entityId = entityByExternalId.get(item.externalId);
2179
+ if (entityId === undefined || emitted.has(entityId))
2180
+ continue;
2181
+ emitted.add(entityId);
2182
+ entityIds.push(entityId);
2183
+ }
2184
+ return Ok({
2185
+ created: createdItems.length,
2186
+ unchanged,
2187
+ updated,
2188
+ entityIds,
2189
+ failures,
2190
+ heroesImported: media.heroesImported,
2191
+ mediaFailures: media.mediaFailures,
2192
+ deferredMedia: media.deferredMedia,
2193
+ warnings,
2194
+ });
2195
+ }
2196
+ async importHeroes(orgId, createdItems, actor) {
2197
+ const mediaFailures = [];
2198
+ const deferredMedia = [];
2199
+ const selections = createdItems.flatMap(({ item, entityId, variantIds }) => {
2200
+ const selection = selectImportImages(item);
2201
+ if (selection.perVariant.length > 0)
2202
+ deferredMedia.push({ externalId: item.externalId, entityId, images: selection.perVariant });
2203
+ return selection.hero ? [{ item, entityId, variantIds, hero: selection.hero }] : [];
2204
+ });
2205
+ if (selections.length === 0)
2206
+ return { heroesImported: 0, mediaFailures, deferredMedia };
2207
+ // One read for every hero the page might already hold (a re-import after the map was lost).
2208
+ const urlHashes = [...new Set(selections.map(({ hero }) => hash(hero.url)))];
2209
+ const existingAssets = await this.db.select({ id: mediaAssets.id, metadata: mediaAssets.metadata }).from(mediaAssets).where(and(eq(mediaAssets.organizationId, orgId), inArray(sql `${mediaAssets.metadata}->>'channelImageUrlHash'`, urlHashes)));
2210
+ const assetByUrlHash = new Map();
2211
+ for (const asset of existingAssets) {
2212
+ const urlHash = asset.metadata?.channelImageUrlHash;
2213
+ if (typeof urlHash === "string")
2214
+ assetByUrlHash.set(urlHash, asset.id);
2215
+ }
2216
+ const outcomes = [];
2217
+ const resolveHero = async ({ item, entityId, variantIds, hero }) => {
2218
+ const urlHash = hash(hero.url);
2219
+ const existing = assetByUrlHash.get(urlHash);
2220
+ if (existing !== undefined) {
2221
+ outcomes.push({ entityId, mediaAssetId: existing, hero, variantIds, imported: false });
2222
+ return;
2223
+ }
2224
+ const fetched = await fetchBounded(hero.url, HERO_IMAGE_BYTE_CAP);
2225
+ const failure = (reason, detail) => {
2226
+ mediaFailures.push({ externalId: item.externalId, ...(hero.externalId !== undefined ? { imageExternalId: hero.externalId } : {}), url: hero.url, reason, detail });
2227
+ };
2228
+ if (!fetched.ok) {
2229
+ failure(fetched.reason, fetched.detail);
2230
+ return;
2231
+ }
2232
+ const extension = fetched.contentType.split("/", 2)[1] ?? "jpg";
2233
+ const uploaded = await this.media.upload({
2234
+ filename: `${hero.externalId ?? urlHash}.${extension}`,
2235
+ contentType: fetched.contentType,
2236
+ data: fetched.bytes.buffer,
2237
+ ...(hero.alt !== undefined ? { alt: hero.alt } : {}),
2238
+ metadata: { channelImageUrlHash: urlHash, ...(hero.externalId !== undefined ? { channelImageExternalId: hero.externalId } : {}) },
2239
+ origin: "imported",
2240
+ }, actor);
2241
+ if (!uploaded.ok) {
2242
+ failure(uploaded.error.code === "VALIDATION_FAILED" ? "unsupported" : "storage", uploaded.error.message);
2243
+ return;
2244
+ }
2245
+ assetByUrlHash.set(urlHash, uploaded.value.id);
2246
+ outcomes.push({ entityId, mediaAssetId: uploaded.value.id, hero, variantIds, imported: true });
2247
+ };
2248
+ // Six outbound connections per Worker invocation, two per image (download + storage put).
2249
+ const MAX_IN_FLIGHT = 3;
2250
+ let next = 0;
2251
+ await Promise.all(Array.from({ length: Math.min(MAX_IN_FLIGHT, selections.length) }, async () => {
2252
+ for (;;) {
2253
+ const selection = selections[next];
2254
+ next += 1;
2255
+ if (selection === undefined)
2256
+ return;
2257
+ await resolveHero(selection);
2258
+ }
2259
+ }));
2260
+ if (outcomes.length > 0) {
2261
+ await this.db.insert(entityMedia).values(outcomes.flatMap(({ entityId, mediaAssetId, hero, variantIds }) => [
2262
+ { entityId, mediaAssetId, role: "primary", sortOrder: hero.sortOrder ?? 0 },
2263
+ ...(hero.variantExternalIds ?? []).flatMap((externalId) => {
2264
+ const variantId = variantIds[externalId];
2265
+ return variantId === undefined ? [] : [{ entityId, variantId, mediaAssetId, role: hero.role, sortOrder: hero.sortOrder ?? 0 }];
2266
+ }),
2267
+ ])).onConflictDoNothing();
2268
+ }
2269
+ return { heroesImported: outcomes.filter((outcome) => outcome.imported).length, mediaFailures, deferredMedia };
2270
+ }
1691
2271
  async importCatalog(orgId, storeId, actor, options) {
1692
2272
  const store = await this.getStoreRecord(orgId, storeId);
1693
2273
  if (!store || store.status !== "connected") {
@@ -2089,6 +2669,10 @@ export class ChannelConnectorService {
2089
2669
  async convergeCatalogItems(orgId, storeId, items, actor, force = false, dryRun = false) {
2090
2670
  if (dryRun)
2091
2671
  return this.estimateCatalogItems(orgId, storeId, items);
2672
+ // One converge, one taxonomy snapshot. Cleared HERE rather than left to the service's lifetime:
2673
+ // the instance can outlive a batch on a warm isolate, and a taxonomy cached across batches would
2674
+ // go stale in a way nothing reports.
2675
+ this.taxonomyCache = null;
2092
2676
  let imported = 0;
2093
2677
  let converged = 0;
2094
2678
  let entitiesTouched = 0;
@@ -2102,6 +2686,8 @@ export class ChannelConnectorService {
2102
2686
  const failures = [];
2103
2687
  const entityIds = [];
2104
2688
  const committed = new Set();
2689
+ // One resolution for the whole batch, not two round trips per changed product.
2690
+ const slugFor = await this.resolveStoreSlugs(orgId, storeId, items.map((item) => item.slug));
2105
2691
  for (const item of items) {
2106
2692
  consumed += 1;
2107
2693
  try {
@@ -2127,8 +2713,15 @@ export class ChannelConnectorService {
2127
2713
  existingEntity = entity;
2128
2714
  }
2129
2715
  }
2716
+ let resolvedSlug = slugFor.get(item.slug) ?? { slug: item.slug, family: [item.slug] };
2717
+ // A slug lost to a concurrent writer (another store onboarding the same handle) is transient:
2718
+ // re-resolve once, and the winner being visible now moves this item to its qualified slug.
2719
+ const reresolveSlug = async () => {
2720
+ resolvedSlug = (await this.resolveStoreSlugs(orgId, storeId, [item.slug])).get(item.slug) ?? resolvedSlug;
2721
+ slugFor.set(item.slug, resolvedSlug);
2722
+ };
2130
2723
  if (entityId === undefined) {
2131
- const [orphan] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.sourceStoreId, storeId), eq(sellableEntities.slug, item.slug)));
2724
+ const [orphan] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.sourceStoreId, storeId), inArray(sellableEntities.slug, resolvedSlug.family))).limit(1);
2132
2725
  if (orphan) {
2133
2726
  entityId = orphan.id;
2134
2727
  existingEntity = orphan;
@@ -2136,33 +2729,46 @@ export class ChannelConnectorService {
2136
2729
  }
2137
2730
  else {
2138
2731
  const status = item.status;
2139
- try {
2140
- entityId = await this.transact(async (tx) => {
2141
- const txContext = createTxContext(tx, { actor });
2142
- const created = await this.catalog.create({
2143
- type: "product",
2144
- slug: item.slug,
2145
- sourceStoreId: storeId,
2146
- metadata: mergeMetadata(undefined, item.metadata ?? {}),
2147
- ...(status !== undefined ? { status, isVisible: status === "active" } : {}),
2148
- }, actor, txContext);
2149
- if (!created.ok)
2150
- throw new Error(created.error.message);
2151
- await tx.insert(channelEntityMap).values({
2152
- organizationId: orgId,
2153
- storeId,
2154
- kind: "entity",
2155
- externalId: item.externalId,
2156
- entityId: created.value.id,
2157
- syncHash: PENDING_ENTITY_MAP_SYNC_HASH,
2158
- heldFieldPaths: [],
2159
- forcedPushFieldPaths: [],
2160
- });
2161
- return created.value.id;
2732
+ const createEntity = (slug) => this.transact(async (tx) => {
2733
+ const txContext = createTxContext(tx, { actor });
2734
+ const created = await this.catalog.create({
2735
+ type: "product",
2736
+ slug,
2737
+ sourceStoreId: storeId,
2738
+ metadata: mergeMetadata(undefined, item.metadata ?? {}),
2739
+ ...(status !== undefined ? { status, isVisible: status === "active" } : {}),
2740
+ }, actor, txContext);
2741
+ if (!created.ok)
2742
+ throw new Error(created.error.message);
2743
+ await tx.insert(channelEntityMap).values({
2744
+ organizationId: orgId,
2745
+ storeId,
2746
+ kind: "entity",
2747
+ externalId: item.externalId,
2748
+ entityId: created.value.id,
2749
+ syncHash: PENDING_ENTITY_MAP_SYNC_HASH,
2750
+ heldFieldPaths: [],
2751
+ forcedPushFieldPaths: [],
2162
2752
  });
2753
+ return created.value.id;
2754
+ });
2755
+ let createError;
2756
+ for (let attempt = 0; attempt < 2 && entityId === undefined; attempt += 1) {
2757
+ try {
2758
+ entityId = await createEntity(resolvedSlug.slug);
2759
+ }
2760
+ catch (error) {
2761
+ createError = error;
2762
+ if (attempt > 0 || !isSlugConflict(error))
2763
+ break;
2764
+ const before = resolvedSlug.slug;
2765
+ await reresolveSlug();
2766
+ if (resolvedSlug.slug === before)
2767
+ break;
2768
+ }
2163
2769
  }
2164
- catch (error) {
2165
- failures.push({ externalId: item.externalId, error: error instanceof Error ? error.message : "Failed to create catalog entity." });
2770
+ if (entityId === undefined) {
2771
+ failures.push({ externalId: item.externalId, error: createError instanceof Error ? createError.message : "Failed to create catalog entity." });
2166
2772
  continue;
2167
2773
  }
2168
2774
  isNew = true;
@@ -2214,8 +2820,9 @@ export class ChannelConnectorService {
2214
2820
  ? writable.status ?? (existingEntity.status === "archived" ? "active" : undefined)
2215
2821
  : undefined;
2216
2822
  const updateInput = {};
2217
- if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== writable.slug) {
2218
- updateInput.slug = writable.slug;
2823
+ const keptSlug = this.slugToKeep(existingEntity.slug, resolvedSlug);
2824
+ if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== keptSlug) {
2825
+ updateInput.slug = keptSlug;
2219
2826
  }
2220
2827
  if (hash(remoteMetadata) !== hash(existingEntity.metadata ?? {}))
2221
2828
  updateInput.metadata = remoteMetadata;
@@ -2227,11 +2834,29 @@ export class ChannelConnectorService {
2227
2834
  ? Object.keys(updateInput).length > 0
2228
2835
  : remoteChanged || existingEntity.status === "archived";
2229
2836
  if (shouldUpdate) {
2230
- converged += 1;
2231
2837
  if (Object.keys(updateInput).length > 0) {
2232
- const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
2233
- if (!updated.ok) {
2234
- failures.push({ externalId: item.externalId, error: updated.error.message });
2838
+ const mappedEntityId = entityMapping.entityId;
2839
+ const update = async () => {
2840
+ try {
2841
+ const updated = await this.catalog.update(mappedEntityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
2842
+ return updated.ok ? undefined : updated.error;
2843
+ }
2844
+ catch (error) {
2845
+ return error;
2846
+ }
2847
+ };
2848
+ let updateError = await update();
2849
+ if (updateError !== undefined && updateInput.slug !== undefined && isSlugConflict(updateError)) {
2850
+ await reresolveSlug();
2851
+ const retrySlug = this.slugToKeep(existingEntity.slug, resolvedSlug);
2852
+ if (retrySlug === existingEntity.slug)
2853
+ delete updateInput.slug;
2854
+ else
2855
+ updateInput.slug = retrySlug;
2856
+ updateError = Object.keys(updateInput).length > 0 ? await update() : undefined;
2857
+ }
2858
+ if (updateError !== undefined) {
2859
+ failures.push({ externalId: item.externalId, error: errorMessage(updateError) });
2235
2860
  continue;
2236
2861
  }
2237
2862
  entityTouched = true;
@@ -2245,8 +2870,12 @@ export class ChannelConnectorService {
2245
2870
  if (!attributes.ok)
2246
2871
  return attributes;
2247
2872
  const variantIds = await this.upsertVariants(orgId, storeId, entityId, writable, optionAxes.value.value, actor, warnings, !heldSharedPaths.includes("options") && owners.get("options") !== "platform", item);
2248
- if (!variantIds.ok)
2249
- return variantIds;
2873
+ // A variant the store's own data makes unwritable (a sku another of its products holds) is
2874
+ // that item's failure, reported with its error — not a page error the caller would retry.
2875
+ if (!variantIds.ok) {
2876
+ failures.push({ externalId: item.externalId, error: variantIds.error });
2877
+ continue;
2878
+ }
2250
2879
  const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
2251
2880
  if (!taxonomy.ok)
2252
2881
  return taxonomy;
@@ -2260,6 +2889,10 @@ export class ChannelConnectorService {
2260
2889
  entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
2261
2890
  if (entityTouched)
2262
2891
  entitiesTouched += 1;
2892
+ // Counted from what was written, not from the stored hash: a hash that moved while the
2893
+ // product did not (a blanked map row, a change in how an item serialises) is not drift.
2894
+ if (entityTouched && entityMapping && !isNew)
2895
+ converged += 1;
2263
2896
  if (entityTouched) {
2264
2897
  const revision = await this.catalog.recordEntityRevision(entityId, actor, "import");
2265
2898
  if (!revision.ok) {
@@ -2382,12 +3015,14 @@ export class ChannelConnectorService {
2382
3015
  if (!mapping)
2383
3016
  continue;
2384
3017
  const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
2385
- if (current?.quantityOnHand === level.available)
3018
+ // Stock cannot sit below zero here, so negative remote stock compares as the zero it is stored as.
3019
+ const quantity = Math.max(0, level.available);
3020
+ if (current?.quantityOnHand === quantity)
2386
3021
  continue;
2387
3022
  const result = await inventoryService.setAbsolute({
2388
3023
  entityId: mapping.entityId,
2389
3024
  ...(mapping.variantId ? { variantId: mapping.variantId } : {}),
2390
- quantity: level.available,
3025
+ quantity,
2391
3026
  reason: `Inventory reconciliation from ${store.provider}`,
2392
3027
  }, actor);
2393
3028
  if (!result.ok)
@@ -2594,12 +3229,14 @@ export class ChannelConnectorService {
2594
3229
  if (!mapping)
2595
3230
  continue;
2596
3231
  const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
2597
- if (current?.quantityOnHand === level.available)
3232
+ // Stock cannot sit below zero here, so negative remote stock compares as the zero it is stored as.
3233
+ const quantity = Math.max(0, level.available);
3234
+ if (current?.quantityOnHand === quantity)
2598
3235
  continue;
2599
3236
  const result = await inventoryService.setAbsolute({
2600
3237
  entityId: mapping.entityId,
2601
3238
  ...(mapping.variantId ? { variantId: mapping.variantId } : {}),
2602
- quantity: level.available,
3239
+ quantity,
2603
3240
  reason: `Inventory sync from ${store.provider}`,
2604
3241
  }, actor);
2605
3242
  if (!result.ok)
@@ -2866,8 +3503,11 @@ export class ChannelConnectorService {
2866
3503
  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}).`);
2867
3504
  const writable = held.writable;
2868
3505
  const updateInput = {};
2869
- if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && entity.slug !== writable.slug) {
2870
- updateInput.slug = writable.slug;
3506
+ if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && typeof writable.slug === "string") {
3507
+ const resolved = (await this.resolveStoreSlugs(orgId, storeId, [writable.slug])).get(writable.slug);
3508
+ const slug = resolved ? this.slugToKeep(entity.slug, resolved) : writable.slug;
3509
+ if (entity.slug !== slug)
3510
+ updateInput.slug = slug;
2871
3511
  }
2872
3512
  if (Object.keys(writable.metadata ?? {}).length > 0) {
2873
3513
  const remoteEntityMetadata = mergeMetadata(entity.metadata, writable.metadata ?? {});