@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/src/service.ts CHANGED
@@ -23,8 +23,12 @@ import type {
23
23
  PluginResult,
24
24
  PluginTxFn,
25
25
  CatalogWriteContext,
26
+ ImportProduct,
27
+ ImportProductsOptions,
28
+ ImportProductsReport,
26
29
  TxContext,
27
30
  } from "@porulle/core";
31
+ import type { ChannelCatalogImage } from "@porulle/core";
28
32
  import { isValidFieldPath, requireUserId } from "@porulle/core";
29
33
  import type { FieldOwner, FieldPath } from "@porulle/core";
30
34
  import type { JobsAdapter } from "@porulle/core";
@@ -523,6 +527,11 @@ interface CatalogService {
523
527
  actor: Actor | null,
524
528
  ): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
525
529
  seedImportedFieldOwnership(entityId: string, storeId: string, fieldPaths: FieldPath[]): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
530
+ importProducts(
531
+ page: ImportProduct[],
532
+ options: ImportProductsOptions,
533
+ actor: Actor,
534
+ ): Promise<ServiceResult<ImportProductsReport>>;
526
535
  createOptionType(
527
536
  input: { entityId: string; name: string; values?: string[] },
528
537
  actor: Actor,
@@ -627,6 +636,39 @@ function hash(value: unknown): string {
627
636
  return createHash("sha256").update(JSON.stringify(value)).digest("hex");
628
637
  }
629
638
 
639
+ /**
640
+ * The suffix a store's product takes when its handle is already another store's slug: the first
641
+ * label of the store domain (`kelly-felder.myshopify.com` → `kelly-felder`), slugified.
642
+ */
643
+ export function storeSlugSuffix(storeDomain: string): string {
644
+ const label = storeDomain.trim().toLowerCase().split(".")[0] ?? "";
645
+ return label.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
646
+ }
647
+
648
+ const SLUG_CONFLICT = /sellable_entities_org_slug_unique|Entity with slug .+ already exists|Slug ".+" already exists/;
649
+
650
+ /**
651
+ * Whether a create or update lost a slug to another writer — core's pre-check message, or the
652
+ * unique index itself, which a driver error can carry several `cause`s deep.
653
+ */
654
+ export function isSlugConflict(error: unknown): boolean {
655
+ let cursor: unknown = error;
656
+ for (let depth = 0; depth < 5; depth += 1) {
657
+ if (typeof cursor === "string") return SLUG_CONFLICT.test(cursor);
658
+ if (typeof cursor !== "object" || cursor === null) return false;
659
+ if ("constraint" in cursor && cursor.constraint === "sellable_entities_org_slug_unique") return true;
660
+ if ("message" in cursor && typeof cursor.message === "string" && SLUG_CONFLICT.test(cursor.message)) return true;
661
+ cursor = "cause" in cursor ? cursor.cause : undefined;
662
+ }
663
+ return false;
664
+ }
665
+
666
+ function errorMessage(error: unknown): string {
667
+ if (typeof error === "string") return error;
668
+ if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") return error.message;
669
+ return String(error);
670
+ }
671
+
630
672
  /** Mid-import map rows carry this until convergence finishes; must not equal any real remote hash. */
631
673
  const PENDING_ENTITY_MAP_SYNC_HASH = "";
632
674
 
@@ -891,6 +933,167 @@ function redactStore(store: ConnectedStore): PublicConnectedStore {
891
933
  };
892
934
  }
893
935
 
936
+ /**
937
+ * A hero is streamed inside the page's own invocation, so it is bounded: a 30 MB TIFF a merchant
938
+ * uploaded by mistake must not buffer into a 128 MiB isolate. Anything larger is reported, not
939
+ * stored, and the product still lands — the index reads text first and media later.
940
+ */
941
+ export const HERO_IMAGE_BYTE_CAP = 1024 * 1024;
942
+
943
+ export type CatalogMediaFailureReason = "too-large" | "download-failed" | "unsupported" | "storage";
944
+
945
+ export interface CatalogMediaFailure {
946
+ externalId: string;
947
+ imageExternalId?: string;
948
+ url: string;
949
+ reason: CatalogMediaFailureReason;
950
+ detail: string;
951
+ }
952
+
953
+ /** Media the page did NOT fetch: the first photo of each variant the hero does not show. */
954
+ export interface CatalogDeferredMedia {
955
+ externalId: string;
956
+ entityId: string;
957
+ images: ChannelCatalogImage[];
958
+ }
959
+
960
+ export interface CatalogPageConvergence extends Record<string, unknown> {
961
+ created: number;
962
+ unchanged: number;
963
+ updated: number;
964
+ /** Input order, failures excluded, no duplicates — the page message is rebuilt from this. */
965
+ entityIds: string[];
966
+ failures: CatalogConvergenceFailure[];
967
+ heroesImported: number;
968
+ mediaFailures: CatalogMediaFailure[];
969
+ deferredMedia: CatalogDeferredMedia[];
970
+ warnings: string[];
971
+ }
972
+
973
+ export interface ImportImageSelection {
974
+ hero: ChannelCatalogImage | null;
975
+ /** In variant order; one image per variant the hero does not cover; no url twice. */
976
+ perVariant: ChannelCatalogImage[];
977
+ }
978
+
979
+ function imageOrder(a: ChannelCatalogImage, b: ChannelCatalogImage): number {
980
+ return (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
981
+ }
982
+
983
+ /**
984
+ * Ruling 2026-09-22: import the hero plus the FIRST photo of each other variant, nothing more.
985
+ * A "blue long dress" query must be able to show the blue variant, and a fourth photo of the red
986
+ * one adds nothing the index can use. Variants are read off the images' own variant references,
987
+ * so a connector that lists images against variants it does not enumerate still gets one each.
988
+ */
989
+ export function selectImportImages(item: ChannelCatalogItem): ImportImageSelection {
990
+ const images = [...(item.images ?? [])].sort(imageOrder);
991
+ const hero = images.find((image) => image.role === "primary") ?? images[0] ?? null;
992
+ if (!hero) return { hero: null, perVariant: [] };
993
+ const covered = new Set(hero.variantExternalIds ?? []);
994
+ const usedUrls = new Set([hero.url]);
995
+ const perVariant: ChannelCatalogImage[] = [];
996
+ const variantRefs = [...new Set(images.flatMap((image) => image.variantExternalIds ?? []))];
997
+ for (const ref of variantRefs) {
998
+ if (covered.has(ref)) continue;
999
+ const image = images.find((candidate) => candidate.variantExternalIds?.includes(ref) && !usedUrls.has(candidate.url));
1000
+ for (const shown of image?.variantExternalIds ?? []) covered.add(shown);
1001
+ covered.add(ref);
1002
+ if (!image) continue;
1003
+ usedUrls.add(image.url);
1004
+ perVariant.push(image);
1005
+ }
1006
+ return { hero, perVariant };
1007
+ }
1008
+
1009
+ type BoundedFetch =
1010
+ | { ok: true; bytes: Uint8Array<ArrayBuffer>; contentType: string }
1011
+ | { ok: false; reason: CatalogMediaFailureReason; detail: string };
1012
+
1013
+ /** Streams a response and refuses mid-stream past `cap`; a lying `content-length` cannot get around it. */
1014
+ async function fetchBounded(url: string, cap: number): Promise<BoundedFetch> {
1015
+ let response: Response;
1016
+ try {
1017
+ response = await fetch(url);
1018
+ } catch (error) {
1019
+ return { ok: false, reason: "download-failed", detail: error instanceof Error ? error.message : "download failed" };
1020
+ }
1021
+ if (!response.ok) return { ok: false, reason: "download-failed", detail: `download returned ${response.status}` };
1022
+ const declared = Number(response.headers.get("content-length"));
1023
+ if (Number.isFinite(declared) && declared > cap) return { ok: false, reason: "too-large", detail: `content-length ${declared} exceeds ${cap}` };
1024
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim() || "image/jpeg";
1025
+ const reader = response.body?.getReader();
1026
+ if (!reader) {
1027
+ const buffer = await response.arrayBuffer();
1028
+ if (buffer.byteLength > cap) return { ok: false, reason: "too-large", detail: `${buffer.byteLength} bytes exceeds ${cap}` };
1029
+ return { ok: true, bytes: new Uint8Array(buffer), contentType };
1030
+ }
1031
+ const chunks: Uint8Array[] = [];
1032
+ let total = 0;
1033
+ for (;;) {
1034
+ const { done, value } = await reader.read();
1035
+ if (done) break;
1036
+ total += value.byteLength;
1037
+ if (total > cap) {
1038
+ await reader.cancel();
1039
+ return { ok: false, reason: "too-large", detail: `stream exceeded ${cap} bytes` };
1040
+ }
1041
+ chunks.push(value);
1042
+ }
1043
+ const bytes = new Uint8Array(total);
1044
+ let offset = 0;
1045
+ for (const chunk of chunks) {
1046
+ bytes.set(chunk, offset);
1047
+ offset += chunk.byteLength;
1048
+ }
1049
+ return { ok: true, bytes, contentType };
1050
+ }
1051
+
1052
+ function toImportProduct(item: ChannelCatalogItem): ImportProduct {
1053
+ const attributes = item.attributes?.length
1054
+ ? item.attributes
1055
+ : [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
1056
+ return {
1057
+ ref: item.externalId,
1058
+ slug: item.slug,
1059
+ ...(item.status !== undefined ? { status: item.status, isVisible: item.status === "active" } : {}),
1060
+ metadata: mergeMetadata(undefined, item.metadata ?? {}),
1061
+ attributes: attributes.map((attribute) => ({
1062
+ locale: attribute.locale,
1063
+ title: attribute.title,
1064
+ ...(attribute.subtitle !== undefined ? { subtitle: attribute.subtitle } : {}),
1065
+ ...(attribute.description !== undefined ? { description: attribute.description } : {}),
1066
+ ...(attribute.richDescription !== undefined ? { richDescription: attribute.richDescription } : {}),
1067
+ ...(attribute.seoTitle !== undefined ? { seoTitle: attribute.seoTitle } : {}),
1068
+ ...(attribute.seoDescription !== undefined ? { seoDescription: attribute.seoDescription } : {}),
1069
+ })),
1070
+ ...(item.options !== undefined ? {
1071
+ options: item.options.map((option) => ({
1072
+ name: option.name,
1073
+ displayName: option.displayName,
1074
+ ...(option.sortOrder !== undefined ? { sortOrder: option.sortOrder } : {}),
1075
+ values: option.values.map((value) => ({
1076
+ value: value.value,
1077
+ displayValue: value.displayValue,
1078
+ ...(value.sortOrder !== undefined ? { sortOrder: value.sortOrder } : {}),
1079
+ })),
1080
+ })),
1081
+ } : {}),
1082
+ variants: item.variants.map((variant) => ({
1083
+ ref: variant.externalId,
1084
+ ...(variant.sku !== undefined ? { sku: variant.sku } : {}),
1085
+ ...(variant.barcode !== undefined ? { barcode: variant.barcode } : {}),
1086
+ ...(variant.optionValues !== undefined ? { options: variant.optionValues } : {}),
1087
+ ...(variant.prices !== undefined ? { prices: variant.prices } : {}),
1088
+ ...(variant.metadata !== undefined ? { metadata: variant.metadata } : {}),
1089
+ })),
1090
+ ...(item.tags !== undefined ? { tags: item.tags } : {}),
1091
+ ...(item.brand !== undefined ? { brand: item.brand } : {}),
1092
+ ...(item.categories !== undefined ? { categories: item.categories } : {}),
1093
+ ownedFieldPaths: importedFieldPaths(item),
1094
+ };
1095
+ }
1096
+
894
1097
  export class ChannelConnectorService {
895
1098
  private readonly connectors = new Map<string, ChannelConnector>();
896
1099
  private readonly transact: PluginTxFn;
@@ -1313,41 +1516,77 @@ export class ChannelConnectorService {
1313
1516
  actor: Actor,
1314
1517
  ): Promise<PluginResult<{ value: Map<string, Map<string, string>>; changed: boolean }>> {
1315
1518
  const optionValueIds = new Map<string, Map<string, string>>();
1316
- const existingTypes = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entityId));
1519
+ // PROJECTED, not `select()`. Three reasons, and the third is the one that saves statements:
1520
+ // the row's other columns are never read; a projection types the locally-constructed row below
1521
+ // without a cast; and carrying `displayName`/`sortOrder` is what lets the update be SKIPPED when
1522
+ // they already hold. A re-import that changes nothing is the common case for a sync, and it used
1523
+ // to issue one UPDATE per option type and one per option value regardless.
1524
+ const existingTypes: { id: string; name: string; displayName: string | null; sortOrder: number | null }[] =
1525
+ await this.db
1526
+ .select({ id: optionTypes.id, name: optionTypes.name, displayName: optionTypes.displayName, sortOrder: optionTypes.sortOrder })
1527
+ .from(optionTypes)
1528
+ .where(eq(optionTypes.entityId, entityId));
1317
1529
  let changed = false;
1318
1530
  for (const [typeIndex, sourceType] of (item.options ?? []).entries()) {
1319
1531
  let optionType = existingTypes.find((row) => row.name === sourceType.name);
1320
1532
  if (!optionType) {
1321
1533
  const created = await this.catalog.createOptionType({ entityId, name: sourceType.name, values: [] }, actor);
1322
1534
  if (!created.ok) return PluginErr(created.error.message);
1323
- const [createdType] = await this.db.select().from(optionTypes).where(eq(optionTypes.id, created.value.id));
1324
- if (!createdType) return PluginErr(`Option type "${sourceType.name}" was not persisted.`);
1325
- optionType = createdType;
1535
+ // The created row is CONSTRUCTED rather than read back: `createOptionType` returns the id and
1536
+ // the name is what we just sent.
1537
+ //
1538
+ // The two nulls are DELIBERATE PLACEHOLDERS, not a claim about the database. `createOptionType`
1539
+ // actually persists `displayName: input.name, sortOrder: 0` (core entity-service.ts:595) and
1540
+ // `display_name` is NOT NULL (core catalog/schema.ts:307) — so a fresh row never holds null.
1541
+ // Constructing nulls here makes the comparison below unequal on any input, which is what forces
1542
+ // the update that writes the caller's real `displayName`/`sortOrder` over those defaults.
1543
+ optionType = { id: created.value.id, name: sourceType.name, displayName: null, sortOrder: null };
1326
1544
  existingTypes.push(optionType);
1327
1545
  changed = true;
1328
1546
  }
1329
- await this.db.update(optionTypes).set({
1330
- displayName: sourceType.displayName,
1331
- sortOrder: sourceType.sortOrder ?? typeIndex,
1332
- }).where(eq(optionTypes.id, optionType.id));
1547
+ const desiredTypeSort = sourceType.sortOrder ?? typeIndex;
1548
+ // `?? null` NORMALISES, and it is load-bearing rather than tidy. The stored value is `null` or a
1549
+ // string; a connector that omits `displayName` sends `undefined`, and `null !== undefined` is
1550
+ // true — so without this every such connector issued one UPDATE per option type on every sync
1551
+ // and the conditional bought it nothing. Latent on today's corpus, whose connector always sends
1552
+ // both fields.
1553
+ const desiredTypeDisplay = sourceType.displayName ?? null;
1554
+ if (optionType.displayName !== desiredTypeDisplay || optionType.sortOrder !== desiredTypeSort) {
1555
+ await this.db.update(optionTypes).set({
1556
+ displayName: sourceType.displayName,
1557
+ sortOrder: desiredTypeSort,
1558
+ }).where(eq(optionTypes.id, optionType.id));
1559
+ optionType.displayName = desiredTypeDisplay;
1560
+ optionType.sortOrder = desiredTypeSort;
1561
+ }
1333
1562
 
1334
- const existingValues = await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, optionType.id));
1563
+ const existingValues: { id: string; value: string; displayValue: string | null; sortOrder: number | null }[] =
1564
+ await this.db
1565
+ .select({ id: optionValues.id, value: optionValues.value, displayValue: optionValues.displayValue, sortOrder: optionValues.sortOrder })
1566
+ .from(optionValues)
1567
+ .where(eq(optionValues.optionTypeId, optionType.id));
1335
1568
  const valueIds = new Map<string, string>();
1336
1569
  for (const [valueIndex, sourceValue] of sourceType.values.entries()) {
1337
1570
  let optionValue = existingValues.find((row) => row.value === sourceValue.value);
1338
1571
  if (!optionValue) {
1339
1572
  const created = await this.catalog.createOptionValue({ optionTypeId: optionType.id, value: sourceValue.value }, actor);
1340
1573
  if (!created.ok) return PluginErr(created.error.message);
1341
- const [createdValue] = await this.db.select().from(optionValues).where(eq(optionValues.id, created.value.id));
1342
- if (!createdValue) return PluginErr(`Option value "${sourceValue.value}" was not persisted.`);
1343
- optionValue = createdValue;
1574
+ // Same placeholder reasoning as the option type above: core persists `displayValue: input.value,
1575
+ // sortOrder: 0` (entity-service.ts:615), and the nulls force the update that overwrites them.
1576
+ optionValue = { id: created.value.id, value: sourceValue.value, displayValue: null, sortOrder: null };
1344
1577
  existingValues.push(optionValue);
1345
1578
  changed = true;
1346
1579
  }
1347
- await this.db.update(optionValues).set({
1348
- displayValue: sourceValue.displayValue,
1349
- sortOrder: sourceValue.sortOrder ?? valueIndex,
1350
- }).where(eq(optionValues.id, optionValue.id));
1580
+ const desiredValueSort = sourceValue.sortOrder ?? valueIndex;
1581
+ const desiredValueDisplay = sourceValue.displayValue ?? null;
1582
+ if (optionValue.displayValue !== desiredValueDisplay || optionValue.sortOrder !== desiredValueSort) {
1583
+ await this.db.update(optionValues).set({
1584
+ displayValue: sourceValue.displayValue,
1585
+ sortOrder: desiredValueSort,
1586
+ }).where(eq(optionValues.id, optionValue.id));
1587
+ optionValue.displayValue = desiredValueDisplay;
1588
+ optionValue.sortOrder = desiredValueSort;
1589
+ }
1351
1590
  valueIds.set(sourceValue.value, optionValue.id);
1352
1591
  }
1353
1592
  optionValueIds.set(sourceType.name, valueIds);
@@ -1375,10 +1614,52 @@ export class ChannelConnectorService {
1375
1614
  eq(channelEntityMap.kind, "variant"),
1376
1615
  eq(channelEntityMap.entityId, entityId),
1377
1616
  ));
1617
+ // ONE read of the existing option-value rows for every already-mapped variant, instead of one
1618
+ // per variant inside the loop. Measured on the deployed Worker at 17.0 calls per product, which
1619
+ // is one per offer on a catalogue averaging 13 offers per product. A variant CREATED below is
1620
+ // absent from this map and correctly reads as empty: its rows are written in this same pass.
1621
+ const mappedVariantIds = mappings
1622
+ .map((row) => row.variantId)
1623
+ .filter((variantId): variantId is string => variantId !== null);
1624
+ const existingOptionValues = new Map<string, string[]>();
1625
+ if (mappedVariantIds.length > 0) {
1626
+ const rows = await this.db
1627
+ .select({ variantId: variantOptionValues.variantId, optionValueId: variantOptionValues.optionValueId })
1628
+ .from(variantOptionValues)
1629
+ .where(inArray(variantOptionValues.variantId, mappedVariantIds));
1630
+ for (const row of rows) {
1631
+ const list = existingOptionValues.get(row.variantId) ?? [];
1632
+ list.push(row.optionValueId);
1633
+ existingOptionValues.set(row.variantId, list);
1634
+ }
1635
+ }
1378
1636
  for (const sourceVariant of item.variants) {
1379
1637
  const fullSourceVariant = fullItem.variants.find((variant) => variant.externalId === sourceVariant.externalId) ?? sourceVariant;
1380
1638
  let mapping = mappings.find((row) => row.externalId === sourceVariant.externalId);
1381
1639
  let variantId = mapping?.variantId;
1640
+ // ADOPT BEFORE CREATE. A variant-kind mapping row whose `variantId` is null has lost its link
1641
+ // — the key survived, the target did not. Creating a replacement is what the loop used to do,
1642
+ // and it cannot work: the orphaned variant still holds the sku, so `variants_native_org_sku_unique`
1643
+ // refuses the insert and the item fails on this and every later sync. The row can never heal.
1644
+ //
1645
+ // `sku` is the store's own natural key for a variant, so re-resolving by it is what restores
1646
+ // the link the null destroyed. Scoped to this entity because a sku is unique per organization
1647
+ // and adopting another entity's variant would be worse than failing.
1648
+ if (mapping && !variantId && sourceVariant.sku) {
1649
+ const [adopted] = await this.db
1650
+ .select({ id: variants.id })
1651
+ .from(variants)
1652
+ .where(and(eq(variants.entityId, entityId), eq(variants.sku, sourceVariant.sku)))
1653
+ .limit(1);
1654
+ if (adopted) {
1655
+ variantId = adopted.id;
1656
+ // Write the link back, or the row stays broken and every later sync pays this lookup again.
1657
+ await this.db.update(channelEntityMap)
1658
+ .set({ variantId })
1659
+ .where(eq(channelEntityMap.id, mapping.id));
1660
+ mapping.variantId = variantId;
1661
+ }
1662
+ }
1382
1663
  const createdVariant = !variantId;
1383
1664
  if (!variantId) {
1384
1665
  const options: Record<string, string> = {};
@@ -1398,17 +1679,36 @@ export class ChannelConnectorService {
1398
1679
  }, actor);
1399
1680
  if (!created.ok) return PluginErr(created.error.message);
1400
1681
  variantId = created.value.id;
1401
- const [createdMapping] = await this.db.insert(channelEntityMap).values({
1402
- organizationId: orgId,
1403
- storeId,
1404
- kind: "variant",
1405
- externalId: sourceVariant.externalId,
1406
- entityId,
1407
- variantId,
1408
- syncHash: hash(fullSourceVariant),
1409
- }).returning();
1410
- mapping = createdMapping;
1411
- if (mapping) mappings.push(mapping);
1682
+ if (mapping) {
1683
+ // REPAIR IN PLACE. `variantId` is nullable because this table also holds `kind: "entity"`
1684
+ // rows, which legitimately have none — but a VARIANT row with a null `variantId` is a
1685
+ // broken invariant, not a state to work around. The row already occupies
1686
+ // `channel_entity_map_store_kind_external_unique` on (store, kind, externalId), so the
1687
+ // insert below would raise a unique violation and fail the whole item. Updating the row
1688
+ // we already hold is the only shape that converges.
1689
+ await this.db.update(channelEntityMap).set({
1690
+ entityId,
1691
+ variantId,
1692
+ syncHash: hash(fullSourceVariant),
1693
+ }).where(eq(channelEntityMap.id, mapping.id));
1694
+ mapping.entityId = entityId;
1695
+ mapping.variantId = variantId;
1696
+ mapping.syncHash = hash(fullSourceVariant);
1697
+ } else {
1698
+ const [createdMapping] = await this.db.insert(channelEntityMap).values({
1699
+ organizationId: orgId,
1700
+ storeId,
1701
+ kind: "variant",
1702
+ externalId: sourceVariant.externalId,
1703
+ entityId,
1704
+ variantId,
1705
+ syncHash: hash(fullSourceVariant),
1706
+ }).returning();
1707
+ mapping = createdMapping;
1708
+ // Pushed so a payload repeating this externalId resolves the row it just created rather
1709
+ // than creating a second variant for it.
1710
+ if (mapping) mappings.push(mapping);
1711
+ }
1412
1712
  }
1413
1713
  if (!variantId) {
1414
1714
  warnings.push(`Skipped variant "${sourceVariant.externalId}": no local variant mapping exists.`);
@@ -1419,8 +1719,7 @@ export class ChannelConnectorService {
1419
1719
  const desiredOptionValueIds = Object.entries(sourceVariant.optionValues ?? {})
1420
1720
  .map(([name, value]) => optionValueIds.get(name)?.get(value))
1421
1721
  .filter((optionValueId): optionValueId is string => optionValueId !== undefined);
1422
- const currentOptionValues = await this.db.select().from(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
1423
- const currentIds = currentOptionValues.map((row) => row.optionValueId).sort();
1722
+ const currentIds = [...(existingOptionValues.get(variantId) ?? [])].sort();
1424
1723
  const desiredIds = [...new Set(desiredOptionValueIds)].sort();
1425
1724
  if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
1426
1725
  await this.db.delete(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
@@ -1428,6 +1727,9 @@ export class ChannelConnectorService {
1428
1727
  await this.db.insert(variantOptionValues).values(desiredIds.map((optionValueId) => ({ variantId, optionValueId }))).onConflictDoNothing();
1429
1728
  repaired += 1;
1430
1729
  }
1730
+ // The map is the read model for this pass; a payload repeating an externalId must not see
1731
+ // the pre-fetched state after this write.
1732
+ existingOptionValues.set(variantId, [...desiredIds]);
1431
1733
  changed = true;
1432
1734
  }
1433
1735
  if (createdVariant && desiredIds.length > 0) {
@@ -1445,15 +1747,65 @@ export class ChannelConnectorService {
1445
1747
  }, actor);
1446
1748
  if (!priced.ok) return PluginErr(priced.error.message);
1447
1749
  }
1448
- if (mapping) {
1750
+ // Was UNCONDITIONAL: one UPDATE per variant on every import, including a re-sync where the
1751
+ // variant is byte-identical. `mapping.syncHash` is already in hand from the select above, so
1752
+ // the comparison is free and the write disappears on an unchanged variant — which is the
1753
+ // ordinary case for a store that syncs repeatedly.
1754
+ const nextSyncHash = hash(fullSourceVariant);
1755
+ if (mapping && mapping.syncHash !== nextSyncHash) {
1449
1756
  await this.db.update(channelEntityMap).set({
1450
- syncHash: hash(fullSourceVariant),
1757
+ syncHash: nextSyncHash,
1451
1758
  }).where(eq(channelEntityMap.id, mapping.id));
1759
+ mapping.syncHash = nextSyncHash;
1452
1760
  }
1453
1761
  }
1454
1762
  return Ok({ value: variantIds, repaired, changed });
1455
1763
  }
1456
1764
 
1765
+ /**
1766
+ * The organization's categories, brands and tags, read ONCE per converge run instead of once per
1767
+ * product.
1768
+ *
1769
+ * `applyTaxonomy` takes an `entityId` and is called unconditionally for every item, and each of
1770
+ * its three lookups was a whole-table read filtered by `organization_id`. Measured on the deployed
1771
+ * Worker: 1.0 call per product per class, three classes, every product — an organization's whole
1772
+ * category list re-read for each of twenty products in a batch that cannot have changed it.
1773
+ *
1774
+ * Rows created DURING the run are appended by the callers below, exactly as they were appended to
1775
+ * the per-product arrays before, so an item that introduces a category is still seen by the next
1776
+ * item. The cache is cleared at the top of `convergeCatalogItems`, so its lifetime is one converge
1777
+ * rather than the lifetime of the service.
1778
+ *
1779
+ * The staleness window widens from one product to one batch: a category created by ANOTHER process
1780
+ * mid-batch is not seen here. That was already true within a product — these lists were always a
1781
+ * snapshot — and the create paths below go through `this.catalog`, which refuses a duplicate slug
1782
+ * rather than writing one. So the failure mode is unchanged in kind and wider in window, which is
1783
+ * the trade this comment exists to state rather than hide.
1784
+ */
1785
+ private taxonomyCache: {
1786
+ orgId: string;
1787
+ categories: (typeof categories.$inferSelect)[];
1788
+ brands: (typeof brands.$inferSelect)[];
1789
+ tags: (typeof tags.$inferSelect)[];
1790
+ } | null = null;
1791
+
1792
+ private async taxonomyFor(orgId: string): Promise<{
1793
+ categories: (typeof categories.$inferSelect)[];
1794
+ brands: (typeof brands.$inferSelect)[];
1795
+ tags: (typeof tags.$inferSelect)[];
1796
+ }> {
1797
+ // Keyed on orgId as well as presence: one service instance serving two organizations must not
1798
+ // hand the second one the first one's taxonomy.
1799
+ if (this.taxonomyCache?.orgId === orgId) return this.taxonomyCache;
1800
+ const [categoryRows, brandRows, tagRows] = await Promise.all([
1801
+ this.db.select().from(categories).where(eq(categories.organizationId, orgId)),
1802
+ this.db.select().from(brands).where(eq(brands.organizationId, orgId)),
1803
+ this.db.select().from(tags).where(eq(tags.organizationId, orgId)),
1804
+ ]);
1805
+ this.taxonomyCache = { orgId, categories: categoryRows, brands: brandRows, tags: tagRows };
1806
+ return this.taxonomyCache;
1807
+ }
1808
+
1457
1809
  private async applyTaxonomy(
1458
1810
  orgId: string,
1459
1811
  entityId: string,
@@ -1461,7 +1813,8 @@ export class ChannelConnectorService {
1461
1813
  actor: Actor,
1462
1814
  warnings: string[],
1463
1815
  ): Promise<PluginResult<void>> {
1464
- const categoryRows = await this.db.select().from(categories).where(eq(categories.organizationId, orgId));
1816
+ const taxonomy = await this.taxonomyFor(orgId);
1817
+ const categoryRows = taxonomy.categories;
1465
1818
  for (const slug of new Set(item.categories ?? [])) {
1466
1819
  let category = categoryRows.find((row) => row.slug === slug);
1467
1820
  if (category?.status === "archived") {
@@ -1480,7 +1833,7 @@ export class ChannelConnectorService {
1480
1833
  if (!linked.ok) return PluginErr(linked.error.message);
1481
1834
  }
1482
1835
 
1483
- const brandRows = await this.db.select().from(brands).where(eq(brands.organizationId, orgId));
1836
+ const brandRows = taxonomy.brands;
1484
1837
  if (item.brand) {
1485
1838
  let brand = brandRows.find((row) => row.slug === item.brand);
1486
1839
  if (!brand) {
@@ -1495,7 +1848,7 @@ export class ChannelConnectorService {
1495
1848
  if (!linked.ok) return PluginErr(linked.error.message);
1496
1849
  }
1497
1850
 
1498
- const tagRows = await this.db.select().from(tags).where(eq(tags.organizationId, orgId));
1851
+ const tagRows = taxonomy.tags;
1499
1852
  for (const slug of new Set(item.tags ?? [])) {
1500
1853
  let tag = tagRows.find((row) => row.slug === slug);
1501
1854
  if (!tag) {
@@ -1730,6 +2083,65 @@ export class ChannelConnectorService {
1730
2083
  return rows[0] as ConnectedStore | undefined;
1731
2084
  }
1732
2085
 
2086
+ /**
2087
+ * The slug each wanted handle takes for THIS store. Slugs stay unique across the organization —
2088
+ * the storefront resolves `/:idOrSlug` org-wide — but one platform organization holds many
2089
+ * merchants, and two of them may sell the same handle.
2090
+ *
2091
+ * A handle's FAMILY for a store is, in order: the bare handle, `<handle>-<store suffix>`, and
2092
+ * `<handle>-<store suffix>-<store id prefix>`. The last is unique per store, so a third store with
2093
+ * the same domain label still gets a slug of its own. A new product takes the first member no
2094
+ * other store holds. An existing product whose slug is already in its handle's family KEEPS it
2095
+ * (see `slugToKeep`): a slug, once assigned, is a shared link and is never recomputed.
2096
+ */
2097
+ private async resolveStoreSlugs(
2098
+ orgId: string,
2099
+ storeId: string,
2100
+ handles: readonly string[],
2101
+ ): Promise<Map<string, { slug: string; family: string[] }>> {
2102
+ const wanted = [...new Set(handles)];
2103
+ const resolved = new Map<string, { slug: string; family: string[] }>();
2104
+ if (wanted.length === 0) return resolved;
2105
+ const suffix = await this.storeSlugSuffixFor(orgId, storeId);
2106
+ const idPart = storeId.replace(/[^a-z0-9]/gi, "").slice(0, 8).toLowerCase();
2107
+ const familyOf = (handle: string): string[] => suffix
2108
+ ? [handle, `${handle}-${suffix}`, `${handle}-${suffix}-${idPart}`]
2109
+ : [handle, `${handle}-${idPart}`];
2110
+ const families = new Map(wanted.map((handle) => [handle, familyOf(handle)]));
2111
+ const owners = await this.db.select({ slug: sellableEntities.slug, sourceStoreId: sellableEntities.sourceStoreId })
2112
+ .from(sellableEntities)
2113
+ .where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.slug, [...families.values()].flat())));
2114
+ // Only ANOTHER STORE's product moves this one to a qualified slug. A product a person made in
2115
+ // Merchant Center (no source store) holding the handle stays a loud slug conflict, as before.
2116
+ const heldElsewhere = new Set(owners.filter((row) => row.sourceStoreId !== null && row.sourceStoreId !== storeId).map((row) => row.slug));
2117
+ for (const [handle, family] of families) {
2118
+ // Every member held by another store is only reachable through a hand-made slug; the last
2119
+ // member is then used anyway and the create fails loudly on the unique index.
2120
+ const slug = family.find((candidate) => !heldElsewhere.has(candidate)) ?? family[family.length - 1] ?? handle;
2121
+ resolved.set(handle, { slug, family });
2122
+ }
2123
+ return resolved;
2124
+ }
2125
+
2126
+ // ponytail: cached for the service instance's lifetime (one task invocation), so a batched import
2127
+ // reads the store once. A store whose domain changes mid-invocation keeps the old suffix until the
2128
+ // next one — a new slug family only, never a rename of a slug already assigned.
2129
+ private readonly slugSuffixByStore = new Map<string, string>();
2130
+
2131
+ private async storeSlugSuffixFor(orgId: string, storeId: string): Promise<string> {
2132
+ const cached = this.slugSuffixByStore.get(storeId);
2133
+ if (cached !== undefined) return cached;
2134
+ const suffix = storeSlugSuffix((await this.getStoreRecord(orgId, storeId))?.storeDomain ?? "");
2135
+ this.slugSuffixByStore.set(storeId, suffix);
2136
+ return suffix;
2137
+ }
2138
+
2139
+ /** The slug an existing product converges to: its current one while that is still in the family
2140
+ * of the handle the store sends, so a slug is never recomputed out from under a shared link. */
2141
+ private slugToKeep(currentSlug: string, resolved: { slug: string; family: string[] }): string {
2142
+ return resolved.family.includes(currentSlug) ? currentSlug : resolved.slug;
2143
+ }
2144
+
1733
2145
  async getStoreByDomain(shopDomain: string): Promise<ConnectedStore | undefined> {
1734
2146
  const rows = await this.db
1735
2147
  .select()
@@ -2427,6 +2839,258 @@ export class ChannelConnectorService {
2427
2839
  }));
2428
2840
  }
2429
2841
 
2842
+ /**
2843
+ * One page from the connector, nothing written. The host lands the page durably (R2 + its
2844
+ * ledger) and hands it to `convergeCatalogPage` from a queue consumer; the two halves are
2845
+ * separate so a consumer retry never re-fetches the merchant's API.
2846
+ */
2847
+ async fetchCatalogPage(
2848
+ orgId: string,
2849
+ storeId: string,
2850
+ cursor: string | null,
2851
+ ): Promise<PluginResult<{ items: ChannelCatalogItem[]; nextCursor: string | null }>> {
2852
+ const store = await this.getStoreRecord(orgId, storeId);
2853
+ if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
2854
+ const connector = this.connectors.get(store.provider);
2855
+ if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`);
2856
+ const page = await connector.importCatalog(store as ChannelStore, cursor ?? undefined);
2857
+ if (!page.ok) return PluginErr(page.error.message);
2858
+ return Ok({ items: page.value.items, nextCursor: page.value.nextCursor ?? null });
2859
+ }
2860
+
2861
+ /**
2862
+ * Converges a page: items this store has never mapped take the import fast path
2863
+ * (`catalog.importProducts`, one transaction, multi-row writes); items already mapped and
2864
+ * unchanged cost nothing; items mapped-but-changed, and orphans (an entity of this store with
2865
+ * the item's slug but no map row), take the editor path, which owns ownership and conflicts.
2866
+ *
2867
+ * Media: only each new item's hero is fetched here, streamed under `HERO_IMAGE_BYTE_CAP`, and
2868
+ * linked at entity level as `primary` plus to the variants it shows. The first photo of every
2869
+ * other variant comes back in `deferredMedia` for the host to land later.
2870
+ */
2871
+ async convergeCatalogPage(
2872
+ orgId: string,
2873
+ storeId: string,
2874
+ items: ChannelCatalogItem[],
2875
+ actor: Actor,
2876
+ ): Promise<PluginResult<CatalogPageConvergence>> {
2877
+ const failures: CatalogConvergenceFailure[] = [];
2878
+ const warnings: string[] = [];
2879
+ const entityByExternalId = new Map<string, string>();
2880
+ let unchanged = 0;
2881
+ let updated = 0;
2882
+
2883
+ const externalIds = [...new Set(items.map((item) => item.externalId))];
2884
+ const mappings = externalIds.length === 0 ? [] : await this.db.select().from(channelEntityMap).where(and(
2885
+ eq(channelEntityMap.organizationId, orgId),
2886
+ eq(channelEntityMap.storeId, storeId),
2887
+ eq(channelEntityMap.kind, "entity"),
2888
+ inArray(channelEntityMap.externalId, externalIds),
2889
+ ));
2890
+ const mappingByExternalId = new Map(mappings.map((row) => [row.externalId, row]));
2891
+ const slugFor = await this.resolveStoreSlugs(orgId, storeId, items.map((item) => item.slug));
2892
+ const slugs = [...new Set([...slugFor.values()].flatMap((resolved) => resolved.family))];
2893
+ const orphans = slugs.length === 0 ? [] : await this.db.select({ slug: sellableEntities.slug }).from(sellableEntities).where(and(
2894
+ eq(sellableEntities.organizationId, orgId),
2895
+ eq(sellableEntities.sourceStoreId, storeId),
2896
+ inArray(sellableEntities.slug, slugs),
2897
+ ));
2898
+ const orphanSlugs = new Set(orphans.map((row) => row.slug));
2899
+ const isOrphan = (handle: string): boolean => (slugFor.get(handle)?.family ?? [handle]).some((slug) => orphanSlugs.has(slug));
2900
+
2901
+ const fresh: ChannelCatalogItem[] = [];
2902
+ const editor: ChannelCatalogItem[] = [];
2903
+ const seen = new Set<string>();
2904
+ for (const item of items) {
2905
+ if (seen.has(item.externalId)) {
2906
+ failures.push({ externalId: item.externalId, error: "duplicate-in-page: this externalId appears twice in the page." });
2907
+ continue;
2908
+ }
2909
+ seen.add(item.externalId);
2910
+ const mapping = mappingByExternalId.get(item.externalId);
2911
+ if (mapping && mapping.syncHash === hash(item)) {
2912
+ entityByExternalId.set(item.externalId, mapping.entityId);
2913
+ unchanged += 1;
2914
+ } else if (mapping || isOrphan(item.slug)) {
2915
+ editor.push(item);
2916
+ } else {
2917
+ fresh.push(item);
2918
+ }
2919
+ }
2920
+
2921
+ if (editor.length > 0) {
2922
+ const result = await this.convergeCatalogItems(orgId, storeId, editor, actor);
2923
+ if (!result.ok) return result;
2924
+ failures.push(...result.value.failures);
2925
+ warnings.push(...result.value.warnings);
2926
+ const failedIds = new Set(result.value.failures.map((failure) => failure.externalId));
2927
+ const survivors = editor.filter((item) => !failedIds.has(item.externalId));
2928
+ survivors.forEach((item, index) => {
2929
+ const entityId = result.value.entityIds[index];
2930
+ if (entityId !== undefined) entityByExternalId.set(item.externalId, entityId);
2931
+ });
2932
+ updated += survivors.length;
2933
+ }
2934
+
2935
+ const createdItems: Array<{ item: ChannelCatalogItem; entityId: string; variantIds: Record<string, string> }> = [];
2936
+ // Two stores onboarding at once can both resolve a handle as free and then both create it. The
2937
+ // loser's collision is transient: re-resolve those items once (the winner is now visible, so they
2938
+ // take the store-qualified slug) and try again. A second collision is reported as the item's.
2939
+ let pending = fresh;
2940
+ for (let attempt = 0; pending.length > 0; attempt += 1) {
2941
+ // The slug is resolved on the import row only: the map row's hash stays the hash of the item
2942
+ // as the store sent it, so the next page still reads it as unchanged.
2943
+ const report = await this.catalog.importProducts(
2944
+ pending.map((item) => toImportProduct({ ...item, slug: slugFor.get(item.slug)?.slug ?? item.slug })),
2945
+ { sourceStoreId: storeId, errorPolicy: "reject-failed-rows" },
2946
+ actor,
2947
+ );
2948
+ if (!report.ok) return PluginErr(report.error.message, report.error.code);
2949
+ const collided: Array<{ item: ChannelCatalogItem; failure: CatalogConvergenceFailure }> = [];
2950
+ for (const [index, row] of report.value.rows.entries()) {
2951
+ const item = pending[index];
2952
+ if (!item) continue;
2953
+ if (row.status === "failed") {
2954
+ const failure = { externalId: row.ref, error: `${row.code}: ${row.error}` };
2955
+ const lostSlug = row.code === "slug-conflict" || (row.code === "conflict" && isSlugConflict(row.error));
2956
+ if (attempt === 0 && lostSlug) collided.push({ item, failure });
2957
+ else failures.push(failure);
2958
+ continue;
2959
+ }
2960
+ warnings.push(...row.warnings);
2961
+ entityByExternalId.set(item.externalId, row.entityId);
2962
+ createdItems.push({ item, entityId: row.entityId, variantIds: row.variantIds });
2963
+ }
2964
+ pending = [];
2965
+ if (collided.length > 0) {
2966
+ const again = await this.resolveStoreSlugs(orgId, storeId, collided.map(({ item }) => item.slug));
2967
+ for (const { item, failure } of collided) {
2968
+ const before = slugFor.get(item.slug)?.slug ?? item.slug;
2969
+ const after = again.get(item.slug);
2970
+ // Retry only when another STORE took the handle: a slug held by a product made in
2971
+ // Merchant Center resolves to the same slug again, and stays the loud conflict it was.
2972
+ if (after === undefined || after.slug === before) { failures.push(failure); continue; }
2973
+ slugFor.set(item.slug, after);
2974
+ pending.push(item);
2975
+ }
2976
+ }
2977
+ }
2978
+ if (createdItems.length > 0) {
2979
+ const now = new Date();
2980
+ await this.db.insert(channelEntityMap).values(createdItems.flatMap(({ item, entityId, variantIds }) => [
2981
+ { organizationId: orgId, storeId, kind: "entity" as const, externalId: item.externalId, entityId, syncHash: hash(item), lastSyncedAt: now },
2982
+ ...item.variants.flatMap((variant) => {
2983
+ const variantId = variantIds[variant.externalId];
2984
+ return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant" as const, externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
2985
+ }),
2986
+ ])).onConflictDoNothing();
2987
+ }
2988
+
2989
+ const media = await this.importHeroes(orgId, createdItems, actor);
2990
+ const entityIds: string[] = [];
2991
+ const emitted = new Set<string>();
2992
+ for (const item of items) {
2993
+ const entityId = entityByExternalId.get(item.externalId);
2994
+ if (entityId === undefined || emitted.has(entityId)) continue;
2995
+ emitted.add(entityId);
2996
+ entityIds.push(entityId);
2997
+ }
2998
+ return Ok({
2999
+ created: createdItems.length,
3000
+ unchanged,
3001
+ updated,
3002
+ entityIds,
3003
+ failures,
3004
+ heroesImported: media.heroesImported,
3005
+ mediaFailures: media.mediaFailures,
3006
+ deferredMedia: media.deferredMedia,
3007
+ warnings,
3008
+ });
3009
+ }
3010
+
3011
+ private async importHeroes(
3012
+ orgId: string,
3013
+ createdItems: Array<{ item: ChannelCatalogItem; entityId: string; variantIds: Record<string, string> }>,
3014
+ actor: Actor,
3015
+ ): Promise<Pick<CatalogPageConvergence, "heroesImported" | "mediaFailures" | "deferredMedia">> {
3016
+ const mediaFailures: CatalogMediaFailure[] = [];
3017
+ const deferredMedia: CatalogDeferredMedia[] = [];
3018
+ const selections = createdItems.flatMap(({ item, entityId, variantIds }) => {
3019
+ const selection = selectImportImages(item);
3020
+ if (selection.perVariant.length > 0) deferredMedia.push({ externalId: item.externalId, entityId, images: selection.perVariant });
3021
+ return selection.hero ? [{ item, entityId, variantIds, hero: selection.hero }] : [];
3022
+ });
3023
+ if (selections.length === 0) return { heroesImported: 0, mediaFailures, deferredMedia };
3024
+
3025
+ // One read for every hero the page might already hold (a re-import after the map was lost).
3026
+ const urlHashes = [...new Set(selections.map(({ hero }) => hash(hero.url)))];
3027
+ const existingAssets = await this.db.select({ id: mediaAssets.id, metadata: mediaAssets.metadata }).from(mediaAssets).where(and(
3028
+ eq(mediaAssets.organizationId, orgId),
3029
+ inArray(sql`${mediaAssets.metadata}->>'channelImageUrlHash'`, urlHashes),
3030
+ ));
3031
+ const assetByUrlHash = new Map<string, string>();
3032
+ for (const asset of existingAssets) {
3033
+ const urlHash = asset.metadata?.channelImageUrlHash;
3034
+ if (typeof urlHash === "string") assetByUrlHash.set(urlHash, asset.id);
3035
+ }
3036
+
3037
+ type HeroOutcome = { entityId: string; mediaAssetId: string; hero: ChannelCatalogImage; variantIds: Record<string, string>; imported: boolean };
3038
+ const outcomes: HeroOutcome[] = [];
3039
+ const resolveHero = async ({ item, entityId, variantIds, hero }: (typeof selections)[number]): Promise<void> => {
3040
+ const urlHash = hash(hero.url);
3041
+ const existing = assetByUrlHash.get(urlHash);
3042
+ if (existing !== undefined) {
3043
+ outcomes.push({ entityId, mediaAssetId: existing, hero, variantIds, imported: false });
3044
+ return;
3045
+ }
3046
+ const fetched = await fetchBounded(hero.url, HERO_IMAGE_BYTE_CAP);
3047
+ const failure = (reason: CatalogMediaFailureReason, detail: string): void => {
3048
+ mediaFailures.push({ externalId: item.externalId, ...(hero.externalId !== undefined ? { imageExternalId: hero.externalId } : {}), url: hero.url, reason, detail });
3049
+ };
3050
+ if (!fetched.ok) {
3051
+ failure(fetched.reason, fetched.detail);
3052
+ return;
3053
+ }
3054
+ const extension = fetched.contentType.split("/", 2)[1] ?? "jpg";
3055
+ const uploaded = await this.media.upload({
3056
+ filename: `${hero.externalId ?? urlHash}.${extension}`,
3057
+ contentType: fetched.contentType,
3058
+ data: fetched.bytes.buffer,
3059
+ ...(hero.alt !== undefined ? { alt: hero.alt } : {}),
3060
+ metadata: { channelImageUrlHash: urlHash, ...(hero.externalId !== undefined ? { channelImageExternalId: hero.externalId } : {}) },
3061
+ origin: "imported",
3062
+ }, actor);
3063
+ if (!uploaded.ok) {
3064
+ failure(uploaded.error.code === "VALIDATION_FAILED" ? "unsupported" : "storage", uploaded.error.message);
3065
+ return;
3066
+ }
3067
+ assetByUrlHash.set(urlHash, uploaded.value.id);
3068
+ outcomes.push({ entityId, mediaAssetId: uploaded.value.id, hero, variantIds, imported: true });
3069
+ };
3070
+ // Six outbound connections per Worker invocation, two per image (download + storage put).
3071
+ const MAX_IN_FLIGHT = 3;
3072
+ let next = 0;
3073
+ await Promise.all(Array.from({ length: Math.min(MAX_IN_FLIGHT, selections.length) }, async () => {
3074
+ for (;;) {
3075
+ const selection = selections[next];
3076
+ next += 1;
3077
+ if (selection === undefined) return;
3078
+ await resolveHero(selection);
3079
+ }
3080
+ }));
3081
+
3082
+ if (outcomes.length > 0) {
3083
+ await this.db.insert(entityMedia).values(outcomes.flatMap(({ entityId, mediaAssetId, hero, variantIds }) => [
3084
+ { entityId, mediaAssetId, role: "primary" as const, sortOrder: hero.sortOrder ?? 0 },
3085
+ ...(hero.variantExternalIds ?? []).flatMap((externalId) => {
3086
+ const variantId = variantIds[externalId];
3087
+ return variantId === undefined ? [] : [{ entityId, variantId, mediaAssetId, role: hero.role, sortOrder: hero.sortOrder ?? 0 }];
3088
+ }),
3089
+ ])).onConflictDoNothing();
3090
+ }
3091
+ return { heroesImported: outcomes.filter((outcome) => outcome.imported).length, mediaFailures, deferredMedia };
3092
+ }
3093
+
2430
3094
  async importCatalog(
2431
3095
  orgId: string,
2432
3096
  storeId: string,
@@ -2926,6 +3590,10 @@ export class ChannelConnectorService {
2926
3590
  dryRun = false,
2927
3591
  ): Promise<PluginResult<CatalogConvergenceStats>> {
2928
3592
  if (dryRun) return this.estimateCatalogItems(orgId, storeId, items);
3593
+ // One converge, one taxonomy snapshot. Cleared HERE rather than left to the service's lifetime:
3594
+ // the instance can outlive a batch on a warm isolate, and a taxonomy cached across batches would
3595
+ // go stale in a way nothing reports.
3596
+ this.taxonomyCache = null;
2929
3597
  let imported = 0;
2930
3598
  let converged = 0;
2931
3599
  let entitiesTouched = 0;
@@ -2939,6 +3607,8 @@ export class ChannelConnectorService {
2939
3607
  const failures: CatalogConvergenceFailure[] = [];
2940
3608
  const entityIds: string[] = [];
2941
3609
  const committed = new Set<string>();
3610
+ // One resolution for the whole batch, not two round trips per changed product.
3611
+ const slugFor = await this.resolveStoreSlugs(orgId, storeId, items.map((item) => item.slug));
2942
3612
  for (const item of items) {
2943
3613
  consumed += 1;
2944
3614
  try {
@@ -2975,43 +3645,61 @@ export class ChannelConnectorService {
2975
3645
  existingEntity = entity;
2976
3646
  }
2977
3647
  }
3648
+ let resolvedSlug = slugFor.get(item.slug) ?? { slug: item.slug, family: [item.slug] };
3649
+ // A slug lost to a concurrent writer (another store onboarding the same handle) is transient:
3650
+ // re-resolve once, and the winner being visible now moves this item to its qualified slug.
3651
+ const reresolveSlug = async (): Promise<void> => {
3652
+ resolvedSlug = (await this.resolveStoreSlugs(orgId, storeId, [item.slug])).get(item.slug) ?? resolvedSlug;
3653
+ slugFor.set(item.slug, resolvedSlug);
3654
+ };
2978
3655
  if (entityId === undefined) {
2979
3656
  const [orphan] = await this.db.select().from(sellableEntities).where(and(
2980
3657
  eq(sellableEntities.organizationId, orgId),
2981
3658
  eq(sellableEntities.sourceStoreId, storeId),
2982
- eq(sellableEntities.slug, item.slug),
2983
- ));
3659
+ inArray(sellableEntities.slug, resolvedSlug.family),
3660
+ )).limit(1);
2984
3661
  if (orphan) {
2985
3662
  entityId = orphan.id;
2986
3663
  existingEntity = orphan;
2987
3664
  adoptedOrphan = true;
2988
3665
  } else {
2989
3666
  const status = item.status;
2990
- try {
2991
- entityId = await this.transact(async (tx) => {
2992
- const txContext = createTxContext(tx, { actor });
2993
- const created = await this.catalog.create({
2994
- type: "product",
2995
- slug: item.slug,
2996
- sourceStoreId: storeId,
2997
- metadata: mergeMetadata(undefined, item.metadata ?? {}),
2998
- ...(status !== undefined ? { status, isVisible: status === "active" } : {}),
2999
- }, actor, txContext);
3000
- if (!created.ok) throw new Error(created.error.message);
3001
- await tx.insert(channelEntityMap).values({
3002
- organizationId: orgId,
3003
- storeId,
3004
- kind: "entity",
3005
- externalId: item.externalId,
3006
- entityId: created.value.id,
3007
- syncHash: PENDING_ENTITY_MAP_SYNC_HASH,
3008
- heldFieldPaths: [],
3009
- forcedPushFieldPaths: [],
3010
- });
3011
- return created.value.id;
3667
+ const createEntity = (slug: string): Promise<string> => this.transact(async (tx) => {
3668
+ const txContext = createTxContext(tx, { actor });
3669
+ const created = await this.catalog.create({
3670
+ type: "product",
3671
+ slug,
3672
+ sourceStoreId: storeId,
3673
+ metadata: mergeMetadata(undefined, item.metadata ?? {}),
3674
+ ...(status !== undefined ? { status, isVisible: status === "active" } : {}),
3675
+ }, actor, txContext);
3676
+ if (!created.ok) throw new Error(created.error.message);
3677
+ await tx.insert(channelEntityMap).values({
3678
+ organizationId: orgId,
3679
+ storeId,
3680
+ kind: "entity",
3681
+ externalId: item.externalId,
3682
+ entityId: created.value.id,
3683
+ syncHash: PENDING_ENTITY_MAP_SYNC_HASH,
3684
+ heldFieldPaths: [],
3685
+ forcedPushFieldPaths: [],
3012
3686
  });
3013
- } catch (error) {
3014
- failures.push({ externalId: item.externalId, error: error instanceof Error ? error.message : "Failed to create catalog entity." });
3687
+ return created.value.id;
3688
+ });
3689
+ let createError: unknown;
3690
+ for (let attempt = 0; attempt < 2 && entityId === undefined; attempt += 1) {
3691
+ try {
3692
+ entityId = await createEntity(resolvedSlug.slug);
3693
+ } catch (error) {
3694
+ createError = error;
3695
+ if (attempt > 0 || !isSlugConflict(error)) break;
3696
+ const before = resolvedSlug.slug;
3697
+ await reresolveSlug();
3698
+ if (resolvedSlug.slug === before) break;
3699
+ }
3700
+ }
3701
+ if (entityId === undefined) {
3702
+ failures.push({ externalId: item.externalId, error: createError instanceof Error ? createError.message : "Failed to create catalog entity." });
3015
3703
  continue;
3016
3704
  }
3017
3705
  isNew = true;
@@ -3071,8 +3759,9 @@ export class ChannelConnectorService {
3071
3759
  status?: string;
3072
3760
  isVisible?: boolean;
3073
3761
  } = {};
3074
- if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== writable.slug) {
3075
- updateInput.slug = writable.slug;
3762
+ const keptSlug = this.slugToKeep(existingEntity.slug, resolvedSlug);
3763
+ if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== keptSlug) {
3764
+ updateInput.slug = keptSlug;
3076
3765
  }
3077
3766
  if (hash(remoteMetadata) !== hash(existingEntity.metadata ?? {})) updateInput.metadata = remoteMetadata;
3078
3767
  if (remoteStatus !== undefined && !blockedPaths.has("entity.status") && remoteStatus !== existingEntity.status) {
@@ -3083,10 +3772,25 @@ export class ChannelConnectorService {
3083
3772
  ? Object.keys(updateInput).length > 0
3084
3773
  : remoteChanged || existingEntity.status === "archived";
3085
3774
  if (shouldUpdate) {
3086
- converged += 1;
3087
3775
  if (Object.keys(updateInput).length > 0) {
3088
- const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
3089
- if (!updated.ok) { failures.push({ externalId: item.externalId, error: updated.error.message }); continue; }
3776
+ const mappedEntityId = entityMapping.entityId;
3777
+ const update = async (): Promise<unknown> => {
3778
+ try {
3779
+ const updated = await this.catalog.update(mappedEntityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
3780
+ return updated.ok ? undefined : updated.error;
3781
+ } catch (error) {
3782
+ return error;
3783
+ }
3784
+ };
3785
+ let updateError = await update();
3786
+ if (updateError !== undefined && updateInput.slug !== undefined && isSlugConflict(updateError)) {
3787
+ await reresolveSlug();
3788
+ const retrySlug = this.slugToKeep(existingEntity.slug, resolvedSlug);
3789
+ if (retrySlug === existingEntity.slug) delete updateInput.slug;
3790
+ else updateInput.slug = retrySlug;
3791
+ updateError = Object.keys(updateInput).length > 0 ? await update() : undefined;
3792
+ }
3793
+ if (updateError !== undefined) { failures.push({ externalId: item.externalId, error: errorMessage(updateError) }); continue; }
3090
3794
  entityTouched = true;
3091
3795
  }
3092
3796
  }
@@ -3107,7 +3811,9 @@ export class ChannelConnectorService {
3107
3811
  !heldSharedPaths.includes("options") && owners.get("options") !== "platform",
3108
3812
  item,
3109
3813
  );
3110
- if (!variantIds.ok) return variantIds;
3814
+ // A variant the store's own data makes unwritable (a sku another of its products holds) is
3815
+ // that item's failure, reported with its error — not a page error the caller would retry.
3816
+ if (!variantIds.ok) { failures.push({ externalId: item.externalId, error: variantIds.error }); continue; }
3111
3817
  const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
3112
3818
  if (!taxonomy.ok) return taxonomy;
3113
3819
  const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
@@ -3118,6 +3824,9 @@ export class ChannelConnectorService {
3118
3824
  skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
3119
3825
  entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
3120
3826
  if (entityTouched) entitiesTouched += 1;
3827
+ // Counted from what was written, not from the stored hash: a hash that moved while the
3828
+ // product did not (a blanked map row, a change in how an item serialises) is not drift.
3829
+ if (entityTouched && entityMapping && !isNew) converged += 1;
3121
3830
 
3122
3831
  if (entityTouched) {
3123
3832
  const revision = await this.catalog.recordEntityRevision(entityId, actor, "import");
@@ -3253,11 +3962,13 @@ export class ChannelConnectorService {
3253
3962
  const mapping = mappings.find((entry) => entry.externalId === level.externalId);
3254
3963
  if (!mapping) continue;
3255
3964
  const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
3256
- if (current?.quantityOnHand === level.available) continue;
3965
+ // Stock cannot sit below zero here, so negative remote stock compares as the zero it is stored as.
3966
+ const quantity = Math.max(0, level.available);
3967
+ if (current?.quantityOnHand === quantity) continue;
3257
3968
  const result = await inventoryService.setAbsolute({
3258
3969
  entityId: mapping.entityId,
3259
3970
  ...(mapping.variantId ? { variantId: mapping.variantId } : {}),
3260
- quantity: level.available,
3971
+ quantity,
3261
3972
  reason: `Inventory reconciliation from ${store.provider}`,
3262
3973
  }, actor);
3263
3974
  if (!result.ok) return PluginErr(result.error?.message ?? "Inventory reconciliation failed.");
@@ -3496,11 +4207,13 @@ export class ChannelConnectorService {
3496
4207
  const mapping = mappings.find((entry) => entry.externalId === level.externalId);
3497
4208
  if (!mapping) continue;
3498
4209
  const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
3499
- if (current?.quantityOnHand === level.available) continue;
4210
+ // Stock cannot sit below zero here, so negative remote stock compares as the zero it is stored as.
4211
+ const quantity = Math.max(0, level.available);
4212
+ if (current?.quantityOnHand === quantity) continue;
3500
4213
  const result = await inventoryService.setAbsolute({
3501
4214
  entityId: mapping.entityId,
3502
4215
  ...(mapping.variantId ? { variantId: mapping.variantId } : {}),
3503
- quantity: level.available,
4216
+ quantity,
3504
4217
  reason: `Inventory sync from ${store.provider}`,
3505
4218
  }, actor);
3506
4219
  if (!result.ok) return PluginErr(result.error?.message ?? "Inventory sync failed.");
@@ -3780,8 +4493,10 @@ export class ChannelConnectorService {
3780
4493
  status?: string;
3781
4494
  isVisible?: boolean;
3782
4495
  } = {};
3783
- if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && entity.slug !== writable.slug) {
3784
- updateInput.slug = writable.slug;
4496
+ if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && typeof writable.slug === "string") {
4497
+ const resolved = (await this.resolveStoreSlugs(orgId, storeId, [writable.slug])).get(writable.slug);
4498
+ const slug = resolved ? this.slugToKeep(entity.slug, resolved) : writable.slug;
4499
+ if (entity.slug !== slug) updateInput.slug = slug;
3785
4500
  }
3786
4501
  if (Object.keys(writable.metadata ?? {}).length > 0) {
3787
4502
  const remoteEntityMetadata = mergeMetadata(entity.metadata, writable.metadata ?? {});