@porulle/plugin-channel-connector 0.49.0 → 0.50.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.49.0",
3
+ "version": "0.50.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,7 +19,7 @@
19
19
  "dependencies": {
20
20
  "@hono/zod-openapi": "^1.2.2",
21
21
  "hono": "^4.12.5",
22
- "@porulle/core": "0.49.0"
22
+ "@porulle/core": "0.50.1"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^24.5.2",
package/src/service.ts CHANGED
@@ -40,6 +40,8 @@ import {
40
40
  customerAddresses,
41
41
  customers,
42
42
  entityMedia,
43
+ entityBrands,
44
+ entityCategories,
43
45
  entityTags,
44
46
  inventoryLevels,
45
47
  mediaAssets,
@@ -636,6 +638,39 @@ function hash(value: unknown): string {
636
638
  return createHash("sha256").update(JSON.stringify(value)).digest("hex");
637
639
  }
638
640
 
641
+ /**
642
+ * The suffix a store's product takes when its handle is already another store's slug: the first
643
+ * label of the store domain (`kelly-felder.myshopify.com` → `kelly-felder`), slugified.
644
+ */
645
+ export function storeSlugSuffix(storeDomain: string): string {
646
+ const label = storeDomain.trim().toLowerCase().split(".")[0] ?? "";
647
+ return label.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
648
+ }
649
+
650
+ const SLUG_CONFLICT = /sellable_entities_org_slug_unique|Entity with slug .+ already exists|Slug ".+" already exists/;
651
+
652
+ /**
653
+ * Whether a create or update lost a slug to another writer — core's pre-check message, or the
654
+ * unique index itself, which a driver error can carry several `cause`s deep.
655
+ */
656
+ export function isSlugConflict(error: unknown): boolean {
657
+ let cursor: unknown = error;
658
+ for (let depth = 0; depth < 5; depth += 1) {
659
+ if (typeof cursor === "string") return SLUG_CONFLICT.test(cursor);
660
+ if (typeof cursor !== "object" || cursor === null) return false;
661
+ if ("constraint" in cursor && cursor.constraint === "sellable_entities_org_slug_unique") return true;
662
+ if ("message" in cursor && typeof cursor.message === "string" && SLUG_CONFLICT.test(cursor.message)) return true;
663
+ cursor = "cause" in cursor ? cursor.cause : undefined;
664
+ }
665
+ return false;
666
+ }
667
+
668
+ function errorMessage(error: unknown): string {
669
+ if (typeof error === "string") return error;
670
+ if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") return error.message;
671
+ return String(error);
672
+ }
673
+
639
674
  /** Mid-import map rows carry this until convergence finishes; must not equal any real remote hash. */
640
675
  const PENDING_ENTITY_MAP_SYNC_HASH = "";
641
676
 
@@ -1600,6 +1635,22 @@ export class ChannelConnectorService {
1600
1635
  existingOptionValues.set(row.variantId, list);
1601
1636
  }
1602
1637
  }
1638
+ // The product's plain base prices, read once. `setBasePrice` upserts and fires its hook even at
1639
+ // the same amount, so an unchanged price is skipped here — and a price that IS written is what
1640
+ // makes the variant "changed", which is what `converged` counts.
1641
+ const storedPrices = item.variants.some((variant) => (variant.prices ?? []).length > 0)
1642
+ ? await this.db.select({ variantId: prices.variantId, currency: prices.currency, amount: prices.amount, compareAtAmount: prices.compareAtAmount })
1643
+ .from(prices)
1644
+ .where(and(
1645
+ eq(prices.organizationId, orgId),
1646
+ eq(prices.entityId, entityId),
1647
+ isNull(prices.customerGroupId),
1648
+ isNull(prices.minQuantity),
1649
+ isNull(prices.maxQuantity),
1650
+ isNull(prices.validFrom),
1651
+ isNull(prices.validUntil),
1652
+ ))
1653
+ : [];
1603
1654
  for (const sourceVariant of item.variants) {
1604
1655
  const fullSourceVariant = fullItem.variants.find((variant) => variant.externalId === sourceVariant.externalId) ?? sourceVariant;
1605
1656
  let mapping = mappings.find((row) => row.externalId === sourceVariant.externalId);
@@ -1705,6 +1756,9 @@ export class ChannelConnectorService {
1705
1756
  }
1706
1757
  }
1707
1758
  for (const price of sourceVariant.prices ?? []) {
1759
+ const currency = price.currency.trim().toUpperCase();
1760
+ const stored = storedPrices.find((row) => row.variantId === variantId && row.currency === currency);
1761
+ if (stored && stored.amount === price.amount && stored.compareAtAmount === (price.compareAtAmount ?? null)) continue;
1708
1762
  const priced = await this.pricing.setBasePrice({
1709
1763
  entityId,
1710
1764
  variantId,
@@ -1713,6 +1767,7 @@ export class ChannelConnectorService {
1713
1767
  compareAtAmount: price.compareAtAmount ?? null,
1714
1768
  }, actor);
1715
1769
  if (!priced.ok) return PluginErr(priced.error.message);
1770
+ changed = true;
1716
1771
  }
1717
1772
  // Was UNCONDITIONAL: one UPDATE per variant on every import, including a re-sync where the
1718
1773
  // variant is byte-identical. `mapping.syncHash` is already in hand from the select above, so
@@ -1779,8 +1834,15 @@ export class ChannelConnectorService {
1779
1834
  item: ChannelCatalogItem,
1780
1835
  actor: Actor,
1781
1836
  warnings: string[],
1782
- ): Promise<PluginResult<void>> {
1837
+ ): Promise<PluginResult<{ changed: boolean }>> {
1783
1838
  const taxonomy = await this.taxonomyFor(orgId);
1839
+ // The links this entity already has, read only for the classes the item names. A link already
1840
+ // there is not re-written, and a link that is added is what reports the taxonomy as changed.
1841
+ const linkedCategories = new Set((item.categories ?? []).length === 0 ? [] : (await this.db
1842
+ .select({ id: entityCategories.categoryId }).from(entityCategories).where(eq(entityCategories.entityId, entityId))).map((row) => row.id));
1843
+ const linkedBrands = new Set(!item.brand ? [] : (await this.db
1844
+ .select({ id: entityBrands.brandId }).from(entityBrands).where(eq(entityBrands.entityId, entityId))).map((row) => row.id));
1845
+ let changed = false;
1784
1846
  const categoryRows = taxonomy.categories;
1785
1847
  for (const slug of new Set(item.categories ?? [])) {
1786
1848
  let category = categoryRows.find((row) => row.slug === slug);
@@ -1796,8 +1858,10 @@ export class ChannelConnectorService {
1796
1858
  category = createdCategory;
1797
1859
  categoryRows.push(category);
1798
1860
  }
1861
+ if (linkedCategories.has(category.id)) continue;
1799
1862
  const linked = await this.catalog.addToCategory(entityId, category.id, actor);
1800
1863
  if (!linked.ok) return PluginErr(linked.error.message);
1864
+ changed = true;
1801
1865
  }
1802
1866
 
1803
1867
  const brandRows = taxonomy.brands;
@@ -1811,8 +1875,11 @@ export class ChannelConnectorService {
1811
1875
  brand = createdBrand;
1812
1876
  brandRows.push(brand);
1813
1877
  }
1814
- const linked = await this.catalog.addToBrand(entityId, brand.id, actor);
1815
- if (!linked.ok) return PluginErr(linked.error.message);
1878
+ if (!linkedBrands.has(brand.id)) {
1879
+ const linked = await this.catalog.addToBrand(entityId, brand.id, actor);
1880
+ if (!linked.ok) return PluginErr(linked.error.message);
1881
+ changed = true;
1882
+ }
1816
1883
  }
1817
1884
 
1818
1885
  const tagRows = taxonomy.tags;
@@ -1827,9 +1894,10 @@ export class ChannelConnectorService {
1827
1894
  if (!tag) return PluginErr(`Tag "${slug}" was not persisted.`);
1828
1895
  tagRows.push(tag);
1829
1896
  }
1830
- await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing();
1897
+ const added = await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing().returning({ tagId: entityTags.tagId });
1898
+ if (added.length > 0) changed = true;
1831
1899
  }
1832
- return Ok(undefined);
1900
+ return Ok({ changed });
1833
1901
  }
1834
1902
 
1835
1903
  private async applyMedia(
@@ -2050,6 +2118,65 @@ export class ChannelConnectorService {
2050
2118
  return rows[0] as ConnectedStore | undefined;
2051
2119
  }
2052
2120
 
2121
+ /**
2122
+ * The slug each wanted handle takes for THIS store. Slugs stay unique across the organization —
2123
+ * the storefront resolves `/:idOrSlug` org-wide — but one platform organization holds many
2124
+ * merchants, and two of them may sell the same handle.
2125
+ *
2126
+ * A handle's FAMILY for a store is, in order: the bare handle, `<handle>-<store suffix>`, and
2127
+ * `<handle>-<store suffix>-<store id prefix>`. The last is unique per store, so a third store with
2128
+ * the same domain label still gets a slug of its own. A new product takes the first member no
2129
+ * other store holds. An existing product whose slug is already in its handle's family KEEPS it
2130
+ * (see `slugToKeep`): a slug, once assigned, is a shared link and is never recomputed.
2131
+ */
2132
+ private async resolveStoreSlugs(
2133
+ orgId: string,
2134
+ storeId: string,
2135
+ handles: readonly string[],
2136
+ ): Promise<Map<string, { slug: string; family: string[] }>> {
2137
+ const wanted = [...new Set(handles)];
2138
+ const resolved = new Map<string, { slug: string; family: string[] }>();
2139
+ if (wanted.length === 0) return resolved;
2140
+ const suffix = await this.storeSlugSuffixFor(orgId, storeId);
2141
+ const idPart = storeId.replace(/[^a-z0-9]/gi, "").slice(0, 8).toLowerCase();
2142
+ const familyOf = (handle: string): string[] => suffix
2143
+ ? [handle, `${handle}-${suffix}`, `${handle}-${suffix}-${idPart}`]
2144
+ : [handle, `${handle}-${idPart}`];
2145
+ const families = new Map(wanted.map((handle) => [handle, familyOf(handle)]));
2146
+ const owners = await this.db.select({ slug: sellableEntities.slug, sourceStoreId: sellableEntities.sourceStoreId })
2147
+ .from(sellableEntities)
2148
+ .where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.slug, [...families.values()].flat())));
2149
+ // Only ANOTHER STORE's product moves this one to a qualified slug. A product a person made in
2150
+ // Merchant Center (no source store) holding the handle stays a loud slug conflict, as before.
2151
+ const heldElsewhere = new Set(owners.filter((row) => row.sourceStoreId !== null && row.sourceStoreId !== storeId).map((row) => row.slug));
2152
+ for (const [handle, family] of families) {
2153
+ // Every member held by another store is only reachable through a hand-made slug; the last
2154
+ // member is then used anyway and the create fails loudly on the unique index.
2155
+ const slug = family.find((candidate) => !heldElsewhere.has(candidate)) ?? family[family.length - 1] ?? handle;
2156
+ resolved.set(handle, { slug, family });
2157
+ }
2158
+ return resolved;
2159
+ }
2160
+
2161
+ // ponytail: cached for the service instance's lifetime (one task invocation), so a batched import
2162
+ // reads the store once. A store whose domain changes mid-invocation keeps the old suffix until the
2163
+ // next one — a new slug family only, never a rename of a slug already assigned.
2164
+ private readonly slugSuffixByStore = new Map<string, string>();
2165
+
2166
+ private async storeSlugSuffixFor(orgId: string, storeId: string): Promise<string> {
2167
+ const cached = this.slugSuffixByStore.get(storeId);
2168
+ if (cached !== undefined) return cached;
2169
+ const suffix = storeSlugSuffix((await this.getStoreRecord(orgId, storeId))?.storeDomain ?? "");
2170
+ this.slugSuffixByStore.set(storeId, suffix);
2171
+ return suffix;
2172
+ }
2173
+
2174
+ /** The slug an existing product converges to: its current one while that is still in the family
2175
+ * of the handle the store sends, so a slug is never recomputed out from under a shared link. */
2176
+ private slugToKeep(currentSlug: string, resolved: { slug: string; family: string[] }): string {
2177
+ return resolved.family.includes(currentSlug) ? currentSlug : resolved.slug;
2178
+ }
2179
+
2053
2180
  async getStoreByDomain(shopDomain: string): Promise<ConnectedStore | undefined> {
2054
2181
  const rows = await this.db
2055
2182
  .select()
@@ -2796,13 +2923,15 @@ export class ChannelConnectorService {
2796
2923
  inArray(channelEntityMap.externalId, externalIds),
2797
2924
  ));
2798
2925
  const mappingByExternalId = new Map(mappings.map((row) => [row.externalId, row]));
2799
- const slugs = [...new Set(items.map((item) => item.slug))];
2926
+ const slugFor = await this.resolveStoreSlugs(orgId, storeId, items.map((item) => item.slug));
2927
+ const slugs = [...new Set([...slugFor.values()].flatMap((resolved) => resolved.family))];
2800
2928
  const orphans = slugs.length === 0 ? [] : await this.db.select({ slug: sellableEntities.slug }).from(sellableEntities).where(and(
2801
2929
  eq(sellableEntities.organizationId, orgId),
2802
2930
  eq(sellableEntities.sourceStoreId, storeId),
2803
2931
  inArray(sellableEntities.slug, slugs),
2804
2932
  ));
2805
2933
  const orphanSlugs = new Set(orphans.map((row) => row.slug));
2934
+ const isOrphan = (handle: string): boolean => (slugFor.get(handle)?.family ?? [handle]).some((slug) => orphanSlugs.has(slug));
2806
2935
 
2807
2936
  const fresh: ChannelCatalogItem[] = [];
2808
2937
  const editor: ChannelCatalogItem[] = [];
@@ -2817,7 +2946,7 @@ export class ChannelConnectorService {
2817
2946
  if (mapping && mapping.syncHash === hash(item)) {
2818
2947
  entityByExternalId.set(item.externalId, mapping.entityId);
2819
2948
  unchanged += 1;
2820
- } else if (mapping || orphanSlugs.has(item.slug)) {
2949
+ } else if (mapping || isOrphan(item.slug)) {
2821
2950
  editor.push(item);
2822
2951
  } else {
2823
2952
  fresh.push(item);
@@ -2839,31 +2968,58 @@ export class ChannelConnectorService {
2839
2968
  }
2840
2969
 
2841
2970
  const createdItems: Array<{ item: ChannelCatalogItem; entityId: string; variantIds: Record<string, string> }> = [];
2842
- if (fresh.length > 0) {
2843
- const report = await this.catalog.importProducts(fresh.map(toImportProduct), { sourceStoreId: storeId, errorPolicy: "reject-failed-rows" }, actor);
2971
+ // Two stores onboarding at once can both resolve a handle as free and then both create it. The
2972
+ // loser's collision is transient: re-resolve those items once (the winner is now visible, so they
2973
+ // take the store-qualified slug) and try again. A second collision is reported as the item's.
2974
+ let pending = fresh;
2975
+ for (let attempt = 0; pending.length > 0; attempt += 1) {
2976
+ // The slug is resolved on the import row only: the map row's hash stays the hash of the item
2977
+ // as the store sent it, so the next page still reads it as unchanged.
2978
+ const report = await this.catalog.importProducts(
2979
+ pending.map((item) => toImportProduct({ ...item, slug: slugFor.get(item.slug)?.slug ?? item.slug })),
2980
+ { sourceStoreId: storeId, errorPolicy: "reject-failed-rows" },
2981
+ actor,
2982
+ );
2844
2983
  if (!report.ok) return PluginErr(report.error.message, report.error.code);
2984
+ const collided: Array<{ item: ChannelCatalogItem; failure: CatalogConvergenceFailure }> = [];
2845
2985
  for (const [index, row] of report.value.rows.entries()) {
2846
- const item = fresh[index];
2986
+ const item = pending[index];
2847
2987
  if (!item) continue;
2848
2988
  if (row.status === "failed") {
2849
- failures.push({ externalId: row.ref, error: `${row.code}: ${row.error}` });
2989
+ const failure = { externalId: row.ref, error: `${row.code}: ${row.error}` };
2990
+ const lostSlug = row.code === "slug-conflict" || (row.code === "conflict" && isSlugConflict(row.error));
2991
+ if (attempt === 0 && lostSlug) collided.push({ item, failure });
2992
+ else failures.push(failure);
2850
2993
  continue;
2851
2994
  }
2852
2995
  warnings.push(...row.warnings);
2853
2996
  entityByExternalId.set(item.externalId, row.entityId);
2854
2997
  createdItems.push({ item, entityId: row.entityId, variantIds: row.variantIds });
2855
2998
  }
2856
- if (createdItems.length > 0) {
2857
- const now = new Date();
2858
- await this.db.insert(channelEntityMap).values(createdItems.flatMap(({ item, entityId, variantIds }) => [
2859
- { organizationId: orgId, storeId, kind: "entity" as const, externalId: item.externalId, entityId, syncHash: hash(item), lastSyncedAt: now },
2860
- ...item.variants.flatMap((variant) => {
2861
- const variantId = variantIds[variant.externalId];
2862
- return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant" as const, externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
2863
- }),
2864
- ])).onConflictDoNothing();
2999
+ pending = [];
3000
+ if (collided.length > 0) {
3001
+ const again = await this.resolveStoreSlugs(orgId, storeId, collided.map(({ item }) => item.slug));
3002
+ for (const { item, failure } of collided) {
3003
+ const before = slugFor.get(item.slug)?.slug ?? item.slug;
3004
+ const after = again.get(item.slug);
3005
+ // Retry only when another STORE took the handle: a slug held by a product made in
3006
+ // Merchant Center resolves to the same slug again, and stays the loud conflict it was.
3007
+ if (after === undefined || after.slug === before) { failures.push(failure); continue; }
3008
+ slugFor.set(item.slug, after);
3009
+ pending.push(item);
3010
+ }
2865
3011
  }
2866
3012
  }
3013
+ if (createdItems.length > 0) {
3014
+ const now = new Date();
3015
+ await this.db.insert(channelEntityMap).values(createdItems.flatMap(({ item, entityId, variantIds }) => [
3016
+ { organizationId: orgId, storeId, kind: "entity" as const, externalId: item.externalId, entityId, syncHash: hash(item), lastSyncedAt: now },
3017
+ ...item.variants.flatMap((variant) => {
3018
+ const variantId = variantIds[variant.externalId];
3019
+ return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant" as const, externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
3020
+ }),
3021
+ ])).onConflictDoNothing();
3022
+ }
2867
3023
 
2868
3024
  const media = await this.importHeroes(orgId, createdItems, actor);
2869
3025
  const entityIds: string[] = [];
@@ -3486,6 +3642,8 @@ export class ChannelConnectorService {
3486
3642
  const failures: CatalogConvergenceFailure[] = [];
3487
3643
  const entityIds: string[] = [];
3488
3644
  const committed = new Set<string>();
3645
+ // One resolution for the whole batch, not two round trips per changed product.
3646
+ const slugFor = await this.resolveStoreSlugs(orgId, storeId, items.map((item) => item.slug));
3489
3647
  for (const item of items) {
3490
3648
  consumed += 1;
3491
3649
  try {
@@ -3522,43 +3680,61 @@ export class ChannelConnectorService {
3522
3680
  existingEntity = entity;
3523
3681
  }
3524
3682
  }
3683
+ let resolvedSlug = slugFor.get(item.slug) ?? { slug: item.slug, family: [item.slug] };
3684
+ // A slug lost to a concurrent writer (another store onboarding the same handle) is transient:
3685
+ // re-resolve once, and the winner being visible now moves this item to its qualified slug.
3686
+ const reresolveSlug = async (): Promise<void> => {
3687
+ resolvedSlug = (await this.resolveStoreSlugs(orgId, storeId, [item.slug])).get(item.slug) ?? resolvedSlug;
3688
+ slugFor.set(item.slug, resolvedSlug);
3689
+ };
3525
3690
  if (entityId === undefined) {
3526
3691
  const [orphan] = await this.db.select().from(sellableEntities).where(and(
3527
3692
  eq(sellableEntities.organizationId, orgId),
3528
3693
  eq(sellableEntities.sourceStoreId, storeId),
3529
- eq(sellableEntities.slug, item.slug),
3530
- ));
3694
+ inArray(sellableEntities.slug, resolvedSlug.family),
3695
+ )).limit(1);
3531
3696
  if (orphan) {
3532
3697
  entityId = orphan.id;
3533
3698
  existingEntity = orphan;
3534
3699
  adoptedOrphan = true;
3535
3700
  } else {
3536
3701
  const status = item.status;
3537
- try {
3538
- entityId = await this.transact(async (tx) => {
3539
- const txContext = createTxContext(tx, { actor });
3540
- const created = await this.catalog.create({
3541
- type: "product",
3542
- slug: item.slug,
3543
- sourceStoreId: storeId,
3544
- metadata: mergeMetadata(undefined, item.metadata ?? {}),
3545
- ...(status !== undefined ? { status, isVisible: status === "active" } : {}),
3546
- }, actor, txContext);
3547
- if (!created.ok) throw new Error(created.error.message);
3548
- await tx.insert(channelEntityMap).values({
3549
- organizationId: orgId,
3550
- storeId,
3551
- kind: "entity",
3552
- externalId: item.externalId,
3553
- entityId: created.value.id,
3554
- syncHash: PENDING_ENTITY_MAP_SYNC_HASH,
3555
- heldFieldPaths: [],
3556
- forcedPushFieldPaths: [],
3557
- });
3558
- return created.value.id;
3702
+ const createEntity = (slug: string): Promise<string> => this.transact(async (tx) => {
3703
+ const txContext = createTxContext(tx, { actor });
3704
+ const created = await this.catalog.create({
3705
+ type: "product",
3706
+ slug,
3707
+ sourceStoreId: storeId,
3708
+ metadata: mergeMetadata(undefined, item.metadata ?? {}),
3709
+ ...(status !== undefined ? { status, isVisible: status === "active" } : {}),
3710
+ }, actor, txContext);
3711
+ if (!created.ok) throw new Error(created.error.message);
3712
+ await tx.insert(channelEntityMap).values({
3713
+ organizationId: orgId,
3714
+ storeId,
3715
+ kind: "entity",
3716
+ externalId: item.externalId,
3717
+ entityId: created.value.id,
3718
+ syncHash: PENDING_ENTITY_MAP_SYNC_HASH,
3719
+ heldFieldPaths: [],
3720
+ forcedPushFieldPaths: [],
3559
3721
  });
3560
- } catch (error) {
3561
- failures.push({ externalId: item.externalId, error: error instanceof Error ? error.message : "Failed to create catalog entity." });
3722
+ return created.value.id;
3723
+ });
3724
+ let createError: unknown;
3725
+ for (let attempt = 0; attempt < 2 && entityId === undefined; attempt += 1) {
3726
+ try {
3727
+ entityId = await createEntity(resolvedSlug.slug);
3728
+ } catch (error) {
3729
+ createError = error;
3730
+ if (attempt > 0 || !isSlugConflict(error)) break;
3731
+ const before = resolvedSlug.slug;
3732
+ await reresolveSlug();
3733
+ if (resolvedSlug.slug === before) break;
3734
+ }
3735
+ }
3736
+ if (entityId === undefined) {
3737
+ failures.push({ externalId: item.externalId, error: createError instanceof Error ? createError.message : "Failed to create catalog entity." });
3562
3738
  continue;
3563
3739
  }
3564
3740
  isNew = true;
@@ -3618,8 +3794,9 @@ export class ChannelConnectorService {
3618
3794
  status?: string;
3619
3795
  isVisible?: boolean;
3620
3796
  } = {};
3621
- if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== writable.slug) {
3622
- updateInput.slug = writable.slug;
3797
+ const keptSlug = this.slugToKeep(existingEntity.slug, resolvedSlug);
3798
+ if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== keptSlug) {
3799
+ updateInput.slug = keptSlug;
3623
3800
  }
3624
3801
  if (hash(remoteMetadata) !== hash(existingEntity.metadata ?? {})) updateInput.metadata = remoteMetadata;
3625
3802
  if (remoteStatus !== undefined && !blockedPaths.has("entity.status") && remoteStatus !== existingEntity.status) {
@@ -3630,10 +3807,25 @@ export class ChannelConnectorService {
3630
3807
  ? Object.keys(updateInput).length > 0
3631
3808
  : remoteChanged || existingEntity.status === "archived";
3632
3809
  if (shouldUpdate) {
3633
- converged += 1;
3634
3810
  if (Object.keys(updateInput).length > 0) {
3635
- const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
3636
- if (!updated.ok) { failures.push({ externalId: item.externalId, error: updated.error.message }); continue; }
3811
+ const mappedEntityId = entityMapping.entityId;
3812
+ const update = async (): Promise<unknown> => {
3813
+ try {
3814
+ const updated = await this.catalog.update(mappedEntityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
3815
+ return updated.ok ? undefined : updated.error;
3816
+ } catch (error) {
3817
+ return error;
3818
+ }
3819
+ };
3820
+ let updateError = await update();
3821
+ if (updateError !== undefined && updateInput.slug !== undefined && isSlugConflict(updateError)) {
3822
+ await reresolveSlug();
3823
+ const retrySlug = this.slugToKeep(existingEntity.slug, resolvedSlug);
3824
+ if (retrySlug === existingEntity.slug) delete updateInput.slug;
3825
+ else updateInput.slug = retrySlug;
3826
+ updateError = Object.keys(updateInput).length > 0 ? await update() : undefined;
3827
+ }
3828
+ if (updateError !== undefined) { failures.push({ externalId: item.externalId, error: errorMessage(updateError) }); continue; }
3637
3829
  entityTouched = true;
3638
3830
  }
3639
3831
  }
@@ -3654,7 +3846,9 @@ export class ChannelConnectorService {
3654
3846
  !heldSharedPaths.includes("options") && owners.get("options") !== "platform",
3655
3847
  item,
3656
3848
  );
3657
- if (!variantIds.ok) return variantIds;
3849
+ // A variant the store's own data makes unwritable (a sku another of its products holds) is
3850
+ // that item's failure, reported with its error — not a page error the caller would retry.
3851
+ if (!variantIds.ok) { failures.push({ externalId: item.externalId, error: variantIds.error }); continue; }
3658
3852
  const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
3659
3853
  if (!taxonomy.ok) return taxonomy;
3660
3854
  const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
@@ -3663,8 +3857,11 @@ export class ChannelConnectorService {
3663
3857
  mediaImported += media.value.imported;
3664
3858
  variantsGivenOptionValues += variantIds.value.repaired;
3665
3859
  skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
3666
- entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
3860
+ entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || taxonomy.value.changed || media.value.changed || attributes.value.changed;
3667
3861
  if (entityTouched) entitiesTouched += 1;
3862
+ // Counted from what was written, not from the stored hash: a hash that moved while the
3863
+ // product did not (a blanked map row, a change in how an item serialises) is not drift.
3864
+ if (entityTouched && entityMapping && !isNew) converged += 1;
3668
3865
 
3669
3866
  if (entityTouched) {
3670
3867
  const revision = await this.catalog.recordEntityRevision(entityId, actor, "import");
@@ -3800,11 +3997,13 @@ export class ChannelConnectorService {
3800
3997
  const mapping = mappings.find((entry) => entry.externalId === level.externalId);
3801
3998
  if (!mapping) continue;
3802
3999
  const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
3803
- if (current?.quantityOnHand === level.available) continue;
4000
+ // Stock cannot sit below zero here, so negative remote stock compares as the zero it is stored as.
4001
+ const quantity = Math.max(0, level.available);
4002
+ if (current?.quantityOnHand === quantity) continue;
3804
4003
  const result = await inventoryService.setAbsolute({
3805
4004
  entityId: mapping.entityId,
3806
4005
  ...(mapping.variantId ? { variantId: mapping.variantId } : {}),
3807
- quantity: level.available,
4006
+ quantity,
3808
4007
  reason: `Inventory reconciliation from ${store.provider}`,
3809
4008
  }, actor);
3810
4009
  if (!result.ok) return PluginErr(result.error?.message ?? "Inventory reconciliation failed.");
@@ -4043,11 +4242,13 @@ export class ChannelConnectorService {
4043
4242
  const mapping = mappings.find((entry) => entry.externalId === level.externalId);
4044
4243
  if (!mapping) continue;
4045
4244
  const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
4046
- if (current?.quantityOnHand === level.available) continue;
4245
+ // Stock cannot sit below zero here, so negative remote stock compares as the zero it is stored as.
4246
+ const quantity = Math.max(0, level.available);
4247
+ if (current?.quantityOnHand === quantity) continue;
4047
4248
  const result = await inventoryService.setAbsolute({
4048
4249
  entityId: mapping.entityId,
4049
4250
  ...(mapping.variantId ? { variantId: mapping.variantId } : {}),
4050
- quantity: level.available,
4251
+ quantity,
4051
4252
  reason: `Inventory sync from ${store.provider}`,
4052
4253
  }, actor);
4053
4254
  if (!result.ok) return PluginErr(result.error?.message ?? "Inventory sync failed.");
@@ -4327,8 +4528,10 @@ export class ChannelConnectorService {
4327
4528
  status?: string;
4328
4529
  isVisible?: boolean;
4329
4530
  } = {};
4330
- if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && entity.slug !== writable.slug) {
4331
- updateInput.slug = writable.slug;
4531
+ if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && typeof writable.slug === "string") {
4532
+ const resolved = (await this.resolveStoreSlugs(orgId, storeId, [writable.slug])).get(writable.slug);
4533
+ const slug = resolved ? this.slugToKeep(entity.slug, resolved) : writable.slug;
4534
+ if (entity.slug !== slug) updateInput.slug = slug;
4332
4535
  }
4333
4536
  if (Object.keys(writable.metadata ?? {}).length > 0) {
4334
4537
  const remoteEntityMetadata = mergeMetadata(entity.metadata, writable.metadata ?? {});