@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/dist/service.d.ts CHANGED
@@ -231,6 +231,16 @@ export interface CatalogPushPreviewResult {
231
231
  }
232
232
  export declare function canExportTransition(from: ExportState, to: ExportState): boolean;
233
233
  export declare function canCatalogPushTransition(from: CatalogPushState, to: CatalogPushState): boolean;
234
+ /**
235
+ * The suffix a store's product takes when its handle is already another store's slug: the first
236
+ * label of the store domain (`kelly-felder.myshopify.com` → `kelly-felder`), slugified.
237
+ */
238
+ export declare function storeSlugSuffix(storeDomain: string): string;
239
+ /**
240
+ * Whether a create or update lost a slug to another writer — core's pre-check message, or the
241
+ * unique index itself, which a driver error can carry several `cause`s deep.
242
+ */
243
+ export declare function isSlugConflict(error: unknown): boolean;
234
244
  export declare const CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS: number;
235
245
  /**
236
246
  * A hero is streamed inside the page's own invocation, so it is bounded: a 30 MB TIFF a merchant
@@ -326,6 +336,23 @@ export declare class ChannelConnectorService {
326
336
  private applyTaxonomy;
327
337
  private applyMedia;
328
338
  private getStoreRecord;
339
+ /**
340
+ * The slug each wanted handle takes for THIS store. Slugs stay unique across the organization —
341
+ * the storefront resolves `/:idOrSlug` org-wide — but one platform organization holds many
342
+ * merchants, and two of them may sell the same handle.
343
+ *
344
+ * A handle's FAMILY for a store is, in order: the bare handle, `<handle>-<store suffix>`, and
345
+ * `<handle>-<store suffix>-<store id prefix>`. The last is unique per store, so a third store with
346
+ * the same domain label still gets a slug of its own. A new product takes the first member no
347
+ * other store holds. An existing product whose slug is already in its handle's family KEEPS it
348
+ * (see `slugToKeep`): a slug, once assigned, is a shared link and is never recomputed.
349
+ */
350
+ private resolveStoreSlugs;
351
+ private readonly slugSuffixByStore;
352
+ private storeSlugSuffixFor;
353
+ /** The slug an existing product converges to: its current one while that is still in the family
354
+ * of the handle the store sends, so a slug is never recomputed out from under a shared link. */
355
+ private slugToKeep;
329
356
  getStoreByDomain(shopDomain: string): Promise<ConnectedStore | undefined>;
330
357
  getStoresByDomain(shopDomain: string): Promise<ConnectedStore[]>;
331
358
  resolveCatalogFieldMapping(store: Pick<ConnectedStore, "provider" | "catalogFieldMapping">, filterableCustomFields?: ReadonlySet<string> | Readonly<Record<string, boolean>>, warnings?: string[]): CatalogFieldMapping;
package/dist/service.js CHANGED
@@ -3,7 +3,7 @@ import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr,
3
3
  import { isValidFieldPath, requireUserId } from "@porulle/core";
4
4
  import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
5
5
  import { and, desc, eq, inArray, isNull, lte, or, sql } from "@porulle/core/drizzle";
6
- import { brands, categories, customerAddresses, customers, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
6
+ import { brands, categories, customerAddresses, customers, entityMedia, entityBrands, entityCategories, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
7
7
  import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
8
8
  import { mergeCatalogFieldMapping, normalizeCatalogFieldMapping, selectCatalogFieldMapping, } from "./catalog-field-mapping.js";
9
9
  export const CATALOG_PUSH_BATCH_SIZES = {
@@ -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;
@@ -938,6 +973,14 @@ export class ChannelConnectorService {
938
973
  existingOptionValues.set(row.variantId, list);
939
974
  }
940
975
  }
976
+ // The product's plain base prices, read once. `setBasePrice` upserts and fires its hook even at
977
+ // the same amount, so an unchanged price is skipped here — and a price that IS written is what
978
+ // makes the variant "changed", which is what `converged` counts.
979
+ const storedPrices = item.variants.some((variant) => (variant.prices ?? []).length > 0)
980
+ ? await this.db.select({ variantId: prices.variantId, currency: prices.currency, amount: prices.amount, compareAtAmount: prices.compareAtAmount })
981
+ .from(prices)
982
+ .where(and(eq(prices.organizationId, orgId), eq(prices.entityId, entityId), isNull(prices.customerGroupId), isNull(prices.minQuantity), isNull(prices.maxQuantity), isNull(prices.validFrom), isNull(prices.validUntil)))
983
+ : [];
941
984
  for (const sourceVariant of item.variants) {
942
985
  const fullSourceVariant = fullItem.variants.find((variant) => variant.externalId === sourceVariant.externalId) ?? sourceVariant;
943
986
  let mapping = mappings.find((row) => row.externalId === sourceVariant.externalId);
@@ -1046,6 +1089,10 @@ export class ChannelConnectorService {
1046
1089
  }
1047
1090
  }
1048
1091
  for (const price of sourceVariant.prices ?? []) {
1092
+ const currency = price.currency.trim().toUpperCase();
1093
+ const stored = storedPrices.find((row) => row.variantId === variantId && row.currency === currency);
1094
+ if (stored && stored.amount === price.amount && stored.compareAtAmount === (price.compareAtAmount ?? null))
1095
+ continue;
1049
1096
  const priced = await this.pricing.setBasePrice({
1050
1097
  entityId,
1051
1098
  variantId,
@@ -1055,6 +1102,7 @@ export class ChannelConnectorService {
1055
1102
  }, actor);
1056
1103
  if (!priced.ok)
1057
1104
  return PluginErr(priced.error.message);
1105
+ changed = true;
1058
1106
  }
1059
1107
  // Was UNCONDITIONAL: one UPDATE per variant on every import, including a re-sync where the
1060
1108
  // variant is byte-identical. `mapping.syncHash` is already in hand from the select above, so
@@ -1106,6 +1154,13 @@ export class ChannelConnectorService {
1106
1154
  }
1107
1155
  async applyTaxonomy(orgId, entityId, item, actor, warnings) {
1108
1156
  const taxonomy = await this.taxonomyFor(orgId);
1157
+ // The links this entity already has, read only for the classes the item names. A link already
1158
+ // there is not re-written, and a link that is added is what reports the taxonomy as changed.
1159
+ const linkedCategories = new Set((item.categories ?? []).length === 0 ? [] : (await this.db
1160
+ .select({ id: entityCategories.categoryId }).from(entityCategories).where(eq(entityCategories.entityId, entityId))).map((row) => row.id));
1161
+ const linkedBrands = new Set(!item.brand ? [] : (await this.db
1162
+ .select({ id: entityBrands.brandId }).from(entityBrands).where(eq(entityBrands.entityId, entityId))).map((row) => row.id));
1163
+ let changed = false;
1109
1164
  const categoryRows = taxonomy.categories;
1110
1165
  for (const slug of new Set(item.categories ?? [])) {
1111
1166
  let category = categoryRows.find((row) => row.slug === slug);
@@ -1123,9 +1178,12 @@ export class ChannelConnectorService {
1123
1178
  category = createdCategory;
1124
1179
  categoryRows.push(category);
1125
1180
  }
1181
+ if (linkedCategories.has(category.id))
1182
+ continue;
1126
1183
  const linked = await this.catalog.addToCategory(entityId, category.id, actor);
1127
1184
  if (!linked.ok)
1128
1185
  return PluginErr(linked.error.message);
1186
+ changed = true;
1129
1187
  }
1130
1188
  const brandRows = taxonomy.brands;
1131
1189
  if (item.brand) {
@@ -1140,9 +1198,12 @@ export class ChannelConnectorService {
1140
1198
  brand = createdBrand;
1141
1199
  brandRows.push(brand);
1142
1200
  }
1143
- const linked = await this.catalog.addToBrand(entityId, brand.id, actor);
1144
- if (!linked.ok)
1145
- return PluginErr(linked.error.message);
1201
+ if (!linkedBrands.has(brand.id)) {
1202
+ const linked = await this.catalog.addToBrand(entityId, brand.id, actor);
1203
+ if (!linked.ok)
1204
+ return PluginErr(linked.error.message);
1205
+ changed = true;
1206
+ }
1146
1207
  }
1147
1208
  const tagRows = taxonomy.tags;
1148
1209
  for (const slug of new Set(item.tags ?? [])) {
@@ -1154,9 +1215,11 @@ export class ChannelConnectorService {
1154
1215
  return PluginErr(`Tag "${slug}" was not persisted.`);
1155
1216
  tagRows.push(tag);
1156
1217
  }
1157
- await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing();
1218
+ const added = await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing().returning({ tagId: entityTags.tagId });
1219
+ if (added.length > 0)
1220
+ changed = true;
1158
1221
  }
1159
- return Ok(undefined);
1222
+ return Ok({ changed });
1160
1223
  }
1161
1224
  async applyMedia(orgId, entityId, item, variantIds, actor, warnings, owners) {
1162
1225
  const images = item.images ?? [];
@@ -1350,6 +1413,59 @@ export class ChannelConnectorService {
1350
1413
  .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, id)));
1351
1414
  return rows[0];
1352
1415
  }
1416
+ /**
1417
+ * The slug each wanted handle takes for THIS store. Slugs stay unique across the organization —
1418
+ * the storefront resolves `/:idOrSlug` org-wide — but one platform organization holds many
1419
+ * merchants, and two of them may sell the same handle.
1420
+ *
1421
+ * A handle's FAMILY for a store is, in order: the bare handle, `<handle>-<store suffix>`, and
1422
+ * `<handle>-<store suffix>-<store id prefix>`. The last is unique per store, so a third store with
1423
+ * the same domain label still gets a slug of its own. A new product takes the first member no
1424
+ * other store holds. An existing product whose slug is already in its handle's family KEEPS it
1425
+ * (see `slugToKeep`): a slug, once assigned, is a shared link and is never recomputed.
1426
+ */
1427
+ async resolveStoreSlugs(orgId, storeId, handles) {
1428
+ const wanted = [...new Set(handles)];
1429
+ const resolved = new Map();
1430
+ if (wanted.length === 0)
1431
+ return resolved;
1432
+ const suffix = await this.storeSlugSuffixFor(orgId, storeId);
1433
+ const idPart = storeId.replace(/[^a-z0-9]/gi, "").slice(0, 8).toLowerCase();
1434
+ const familyOf = (handle) => suffix
1435
+ ? [handle, `${handle}-${suffix}`, `${handle}-${suffix}-${idPart}`]
1436
+ : [handle, `${handle}-${idPart}`];
1437
+ const families = new Map(wanted.map((handle) => [handle, familyOf(handle)]));
1438
+ const owners = await this.db.select({ slug: sellableEntities.slug, sourceStoreId: sellableEntities.sourceStoreId })
1439
+ .from(sellableEntities)
1440
+ .where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.slug, [...families.values()].flat())));
1441
+ // Only ANOTHER STORE's product moves this one to a qualified slug. A product a person made in
1442
+ // Merchant Center (no source store) holding the handle stays a loud slug conflict, as before.
1443
+ const heldElsewhere = new Set(owners.filter((row) => row.sourceStoreId !== null && row.sourceStoreId !== storeId).map((row) => row.slug));
1444
+ for (const [handle, family] of families) {
1445
+ // Every member held by another store is only reachable through a hand-made slug; the last
1446
+ // member is then used anyway and the create fails loudly on the unique index.
1447
+ const slug = family.find((candidate) => !heldElsewhere.has(candidate)) ?? family[family.length - 1] ?? handle;
1448
+ resolved.set(handle, { slug, family });
1449
+ }
1450
+ return resolved;
1451
+ }
1452
+ // ponytail: cached for the service instance's lifetime (one task invocation), so a batched import
1453
+ // reads the store once. A store whose domain changes mid-invocation keeps the old suffix until the
1454
+ // next one — a new slug family only, never a rename of a slug already assigned.
1455
+ slugSuffixByStore = new Map();
1456
+ async storeSlugSuffixFor(orgId, storeId) {
1457
+ const cached = this.slugSuffixByStore.get(storeId);
1458
+ if (cached !== undefined)
1459
+ return cached;
1460
+ const suffix = storeSlugSuffix((await this.getStoreRecord(orgId, storeId))?.storeDomain ?? "");
1461
+ this.slugSuffixByStore.set(storeId, suffix);
1462
+ return suffix;
1463
+ }
1464
+ /** The slug an existing product converges to: its current one while that is still in the family
1465
+ * of the handle the store sends, so a slug is never recomputed out from under a shared link. */
1466
+ slugToKeep(currentSlug, resolved) {
1467
+ return resolved.family.includes(currentSlug) ? currentSlug : resolved.slug;
1468
+ }
1353
1469
  async getStoreByDomain(shopDomain) {
1354
1470
  const rows = await this.db
1355
1471
  .select()
@@ -1986,9 +2102,11 @@ export class ChannelConnectorService {
1986
2102
  const externalIds = [...new Set(items.map((item) => item.externalId))];
1987
2103
  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)));
1988
2104
  const mappingByExternalId = new Map(mappings.map((row) => [row.externalId, row]));
1989
- const slugs = [...new Set(items.map((item) => item.slug))];
2105
+ const slugFor = await this.resolveStoreSlugs(orgId, storeId, items.map((item) => item.slug));
2106
+ const slugs = [...new Set([...slugFor.values()].flatMap((resolved) => resolved.family))];
1990
2107
  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)));
1991
2108
  const orphanSlugs = new Set(orphans.map((row) => row.slug));
2109
+ const isOrphan = (handle) => (slugFor.get(handle)?.family ?? [handle]).some((slug) => orphanSlugs.has(slug));
1992
2110
  const fresh = [];
1993
2111
  const editor = [];
1994
2112
  const seen = new Set();
@@ -2003,7 +2121,7 @@ export class ChannelConnectorService {
2003
2121
  entityByExternalId.set(item.externalId, mapping.entityId);
2004
2122
  unchanged += 1;
2005
2123
  }
2006
- else if (mapping || orphanSlugs.has(item.slug)) {
2124
+ else if (mapping || isOrphan(item.slug)) {
2007
2125
  editor.push(item);
2008
2126
  }
2009
2127
  else {
@@ -2026,33 +2144,61 @@ export class ChannelConnectorService {
2026
2144
  updated += survivors.length;
2027
2145
  }
2028
2146
  const createdItems = [];
2029
- if (fresh.length > 0) {
2030
- const report = await this.catalog.importProducts(fresh.map(toImportProduct), { sourceStoreId: storeId, errorPolicy: "reject-failed-rows" }, actor);
2147
+ // Two stores onboarding at once can both resolve a handle as free and then both create it. The
2148
+ // loser's collision is transient: re-resolve those items once (the winner is now visible, so they
2149
+ // take the store-qualified slug) and try again. A second collision is reported as the item's.
2150
+ let pending = fresh;
2151
+ for (let attempt = 0; pending.length > 0; attempt += 1) {
2152
+ // The slug is resolved on the import row only: the map row's hash stays the hash of the item
2153
+ // as the store sent it, so the next page still reads it as unchanged.
2154
+ 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);
2031
2155
  if (!report.ok)
2032
2156
  return PluginErr(report.error.message, report.error.code);
2157
+ const collided = [];
2033
2158
  for (const [index, row] of report.value.rows.entries()) {
2034
- const item = fresh[index];
2159
+ const item = pending[index];
2035
2160
  if (!item)
2036
2161
  continue;
2037
2162
  if (row.status === "failed") {
2038
- failures.push({ externalId: row.ref, error: `${row.code}: ${row.error}` });
2163
+ const failure = { externalId: row.ref, error: `${row.code}: ${row.error}` };
2164
+ const lostSlug = row.code === "slug-conflict" || (row.code === "conflict" && isSlugConflict(row.error));
2165
+ if (attempt === 0 && lostSlug)
2166
+ collided.push({ item, failure });
2167
+ else
2168
+ failures.push(failure);
2039
2169
  continue;
2040
2170
  }
2041
2171
  warnings.push(...row.warnings);
2042
2172
  entityByExternalId.set(item.externalId, row.entityId);
2043
2173
  createdItems.push({ item, entityId: row.entityId, variantIds: row.variantIds });
2044
2174
  }
2045
- if (createdItems.length > 0) {
2046
- const now = new Date();
2047
- await this.db.insert(channelEntityMap).values(createdItems.flatMap(({ item, entityId, variantIds }) => [
2048
- { organizationId: orgId, storeId, kind: "entity", externalId: item.externalId, entityId, syncHash: hash(item), lastSyncedAt: now },
2049
- ...item.variants.flatMap((variant) => {
2050
- const variantId = variantIds[variant.externalId];
2051
- return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant", externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
2052
- }),
2053
- ])).onConflictDoNothing();
2175
+ pending = [];
2176
+ if (collided.length > 0) {
2177
+ const again = await this.resolveStoreSlugs(orgId, storeId, collided.map(({ item }) => item.slug));
2178
+ for (const { item, failure } of collided) {
2179
+ const before = slugFor.get(item.slug)?.slug ?? item.slug;
2180
+ const after = again.get(item.slug);
2181
+ // Retry only when another STORE took the handle: a slug held by a product made in
2182
+ // Merchant Center resolves to the same slug again, and stays the loud conflict it was.
2183
+ if (after === undefined || after.slug === before) {
2184
+ failures.push(failure);
2185
+ continue;
2186
+ }
2187
+ slugFor.set(item.slug, after);
2188
+ pending.push(item);
2189
+ }
2054
2190
  }
2055
2191
  }
2192
+ if (createdItems.length > 0) {
2193
+ const now = new Date();
2194
+ await this.db.insert(channelEntityMap).values(createdItems.flatMap(({ item, entityId, variantIds }) => [
2195
+ { organizationId: orgId, storeId, kind: "entity", externalId: item.externalId, entityId, syncHash: hash(item), lastSyncedAt: now },
2196
+ ...item.variants.flatMap((variant) => {
2197
+ const variantId = variantIds[variant.externalId];
2198
+ return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant", externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
2199
+ }),
2200
+ ])).onConflictDoNothing();
2201
+ }
2056
2202
  const media = await this.importHeroes(orgId, createdItems, actor);
2057
2203
  const entityIds = [];
2058
2204
  const emitted = new Set();
@@ -2568,6 +2714,8 @@ export class ChannelConnectorService {
2568
2714
  const failures = [];
2569
2715
  const entityIds = [];
2570
2716
  const committed = new Set();
2717
+ // One resolution for the whole batch, not two round trips per changed product.
2718
+ const slugFor = await this.resolveStoreSlugs(orgId, storeId, items.map((item) => item.slug));
2571
2719
  for (const item of items) {
2572
2720
  consumed += 1;
2573
2721
  try {
@@ -2593,8 +2741,15 @@ export class ChannelConnectorService {
2593
2741
  existingEntity = entity;
2594
2742
  }
2595
2743
  }
2744
+ let resolvedSlug = slugFor.get(item.slug) ?? { slug: item.slug, family: [item.slug] };
2745
+ // A slug lost to a concurrent writer (another store onboarding the same handle) is transient:
2746
+ // re-resolve once, and the winner being visible now moves this item to its qualified slug.
2747
+ const reresolveSlug = async () => {
2748
+ resolvedSlug = (await this.resolveStoreSlugs(orgId, storeId, [item.slug])).get(item.slug) ?? resolvedSlug;
2749
+ slugFor.set(item.slug, resolvedSlug);
2750
+ };
2596
2751
  if (entityId === undefined) {
2597
- const [orphan] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.sourceStoreId, storeId), eq(sellableEntities.slug, item.slug)));
2752
+ 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);
2598
2753
  if (orphan) {
2599
2754
  entityId = orphan.id;
2600
2755
  existingEntity = orphan;
@@ -2602,33 +2757,46 @@ export class ChannelConnectorService {
2602
2757
  }
2603
2758
  else {
2604
2759
  const status = item.status;
2605
- try {
2606
- entityId = await this.transact(async (tx) => {
2607
- const txContext = createTxContext(tx, { actor });
2608
- const created = await this.catalog.create({
2609
- type: "product",
2610
- slug: item.slug,
2611
- sourceStoreId: storeId,
2612
- metadata: mergeMetadata(undefined, item.metadata ?? {}),
2613
- ...(status !== undefined ? { status, isVisible: status === "active" } : {}),
2614
- }, actor, txContext);
2615
- if (!created.ok)
2616
- throw new Error(created.error.message);
2617
- await tx.insert(channelEntityMap).values({
2618
- organizationId: orgId,
2619
- storeId,
2620
- kind: "entity",
2621
- externalId: item.externalId,
2622
- entityId: created.value.id,
2623
- syncHash: PENDING_ENTITY_MAP_SYNC_HASH,
2624
- heldFieldPaths: [],
2625
- forcedPushFieldPaths: [],
2626
- });
2627
- return created.value.id;
2760
+ const createEntity = (slug) => this.transact(async (tx) => {
2761
+ const txContext = createTxContext(tx, { actor });
2762
+ const created = await this.catalog.create({
2763
+ type: "product",
2764
+ slug,
2765
+ sourceStoreId: storeId,
2766
+ metadata: mergeMetadata(undefined, item.metadata ?? {}),
2767
+ ...(status !== undefined ? { status, isVisible: status === "active" } : {}),
2768
+ }, actor, txContext);
2769
+ if (!created.ok)
2770
+ throw new Error(created.error.message);
2771
+ await tx.insert(channelEntityMap).values({
2772
+ organizationId: orgId,
2773
+ storeId,
2774
+ kind: "entity",
2775
+ externalId: item.externalId,
2776
+ entityId: created.value.id,
2777
+ syncHash: PENDING_ENTITY_MAP_SYNC_HASH,
2778
+ heldFieldPaths: [],
2779
+ forcedPushFieldPaths: [],
2628
2780
  });
2781
+ return created.value.id;
2782
+ });
2783
+ let createError;
2784
+ for (let attempt = 0; attempt < 2 && entityId === undefined; attempt += 1) {
2785
+ try {
2786
+ entityId = await createEntity(resolvedSlug.slug);
2787
+ }
2788
+ catch (error) {
2789
+ createError = error;
2790
+ if (attempt > 0 || !isSlugConflict(error))
2791
+ break;
2792
+ const before = resolvedSlug.slug;
2793
+ await reresolveSlug();
2794
+ if (resolvedSlug.slug === before)
2795
+ break;
2796
+ }
2629
2797
  }
2630
- catch (error) {
2631
- failures.push({ externalId: item.externalId, error: error instanceof Error ? error.message : "Failed to create catalog entity." });
2798
+ if (entityId === undefined) {
2799
+ failures.push({ externalId: item.externalId, error: createError instanceof Error ? createError.message : "Failed to create catalog entity." });
2632
2800
  continue;
2633
2801
  }
2634
2802
  isNew = true;
@@ -2680,8 +2848,9 @@ export class ChannelConnectorService {
2680
2848
  ? writable.status ?? (existingEntity.status === "archived" ? "active" : undefined)
2681
2849
  : undefined;
2682
2850
  const updateInput = {};
2683
- if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== writable.slug) {
2684
- updateInput.slug = writable.slug;
2851
+ const keptSlug = this.slugToKeep(existingEntity.slug, resolvedSlug);
2852
+ if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== keptSlug) {
2853
+ updateInput.slug = keptSlug;
2685
2854
  }
2686
2855
  if (hash(remoteMetadata) !== hash(existingEntity.metadata ?? {}))
2687
2856
  updateInput.metadata = remoteMetadata;
@@ -2693,11 +2862,29 @@ export class ChannelConnectorService {
2693
2862
  ? Object.keys(updateInput).length > 0
2694
2863
  : remoteChanged || existingEntity.status === "archived";
2695
2864
  if (shouldUpdate) {
2696
- converged += 1;
2697
2865
  if (Object.keys(updateInput).length > 0) {
2698
- const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
2699
- if (!updated.ok) {
2700
- failures.push({ externalId: item.externalId, error: updated.error.message });
2866
+ const mappedEntityId = entityMapping.entityId;
2867
+ const update = async () => {
2868
+ try {
2869
+ const updated = await this.catalog.update(mappedEntityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
2870
+ return updated.ok ? undefined : updated.error;
2871
+ }
2872
+ catch (error) {
2873
+ return error;
2874
+ }
2875
+ };
2876
+ let updateError = await update();
2877
+ if (updateError !== undefined && updateInput.slug !== undefined && isSlugConflict(updateError)) {
2878
+ await reresolveSlug();
2879
+ const retrySlug = this.slugToKeep(existingEntity.slug, resolvedSlug);
2880
+ if (retrySlug === existingEntity.slug)
2881
+ delete updateInput.slug;
2882
+ else
2883
+ updateInput.slug = retrySlug;
2884
+ updateError = Object.keys(updateInput).length > 0 ? await update() : undefined;
2885
+ }
2886
+ if (updateError !== undefined) {
2887
+ failures.push({ externalId: item.externalId, error: errorMessage(updateError) });
2701
2888
  continue;
2702
2889
  }
2703
2890
  entityTouched = true;
@@ -2711,8 +2898,12 @@ export class ChannelConnectorService {
2711
2898
  if (!attributes.ok)
2712
2899
  return attributes;
2713
2900
  const variantIds = await this.upsertVariants(orgId, storeId, entityId, writable, optionAxes.value.value, actor, warnings, !heldSharedPaths.includes("options") && owners.get("options") !== "platform", item);
2714
- if (!variantIds.ok)
2715
- return variantIds;
2901
+ // A variant the store's own data makes unwritable (a sku another of its products holds) is
2902
+ // that item's failure, reported with its error — not a page error the caller would retry.
2903
+ if (!variantIds.ok) {
2904
+ failures.push({ externalId: item.externalId, error: variantIds.error });
2905
+ continue;
2906
+ }
2716
2907
  const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
2717
2908
  if (!taxonomy.ok)
2718
2909
  return taxonomy;
@@ -2723,9 +2914,13 @@ export class ChannelConnectorService {
2723
2914
  mediaImported += media.value.imported;
2724
2915
  variantsGivenOptionValues += variantIds.value.repaired;
2725
2916
  skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
2726
- entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
2917
+ entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || taxonomy.value.changed || media.value.changed || attributes.value.changed;
2727
2918
  if (entityTouched)
2728
2919
  entitiesTouched += 1;
2920
+ // Counted from what was written, not from the stored hash: a hash that moved while the
2921
+ // product did not (a blanked map row, a change in how an item serialises) is not drift.
2922
+ if (entityTouched && entityMapping && !isNew)
2923
+ converged += 1;
2729
2924
  if (entityTouched) {
2730
2925
  const revision = await this.catalog.recordEntityRevision(entityId, actor, "import");
2731
2926
  if (!revision.ok) {
@@ -2848,12 +3043,14 @@ export class ChannelConnectorService {
2848
3043
  if (!mapping)
2849
3044
  continue;
2850
3045
  const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
2851
- if (current?.quantityOnHand === level.available)
3046
+ // Stock cannot sit below zero here, so negative remote stock compares as the zero it is stored as.
3047
+ const quantity = Math.max(0, level.available);
3048
+ if (current?.quantityOnHand === quantity)
2852
3049
  continue;
2853
3050
  const result = await inventoryService.setAbsolute({
2854
3051
  entityId: mapping.entityId,
2855
3052
  ...(mapping.variantId ? { variantId: mapping.variantId } : {}),
2856
- quantity: level.available,
3053
+ quantity,
2857
3054
  reason: `Inventory reconciliation from ${store.provider}`,
2858
3055
  }, actor);
2859
3056
  if (!result.ok)
@@ -3060,12 +3257,14 @@ export class ChannelConnectorService {
3060
3257
  if (!mapping)
3061
3258
  continue;
3062
3259
  const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
3063
- if (current?.quantityOnHand === level.available)
3260
+ // Stock cannot sit below zero here, so negative remote stock compares as the zero it is stored as.
3261
+ const quantity = Math.max(0, level.available);
3262
+ if (current?.quantityOnHand === quantity)
3064
3263
  continue;
3065
3264
  const result = await inventoryService.setAbsolute({
3066
3265
  entityId: mapping.entityId,
3067
3266
  ...(mapping.variantId ? { variantId: mapping.variantId } : {}),
3068
- quantity: level.available,
3267
+ quantity,
3069
3268
  reason: `Inventory sync from ${store.provider}`,
3070
3269
  }, actor);
3071
3270
  if (!result.ok)
@@ -3332,8 +3531,11 @@ export class ChannelConnectorService {
3332
3531
  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}).`);
3333
3532
  const writable = held.writable;
3334
3533
  const updateInput = {};
3335
- if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && entity.slug !== writable.slug) {
3336
- updateInput.slug = writable.slug;
3534
+ if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && typeof writable.slug === "string") {
3535
+ const resolved = (await this.resolveStoreSlugs(orgId, storeId, [writable.slug])).get(writable.slug);
3536
+ const slug = resolved ? this.slugToKeep(entity.slug, resolved) : writable.slug;
3537
+ if (entity.slug !== slug)
3538
+ updateInput.slug = slug;
3337
3539
  }
3338
3540
  if (Object.keys(writable.metadata ?? {}).length > 0) {
3339
3541
  const remoteEntityMetadata = mergeMetadata(entity.metadata, writable.metadata ?? {});