@porulle/plugin-channel-connector 0.49.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/service.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
@@ -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;
@@ -1350,6 +1385,59 @@ export class ChannelConnectorService {
1350
1385
  .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, id)));
1351
1386
  return rows[0];
1352
1387
  }
1388
+ /**
1389
+ * The slug each wanted handle takes for THIS store. Slugs stay unique across the organization —
1390
+ * the storefront resolves `/:idOrSlug` org-wide — but one platform organization holds many
1391
+ * merchants, and two of them may sell the same handle.
1392
+ *
1393
+ * A handle's FAMILY for a store is, in order: the bare handle, `<handle>-<store suffix>`, and
1394
+ * `<handle>-<store suffix>-<store id prefix>`. The last is unique per store, so a third store with
1395
+ * the same domain label still gets a slug of its own. A new product takes the first member no
1396
+ * other store holds. An existing product whose slug is already in its handle's family KEEPS it
1397
+ * (see `slugToKeep`): a slug, once assigned, is a shared link and is never recomputed.
1398
+ */
1399
+ async resolveStoreSlugs(orgId, storeId, handles) {
1400
+ const wanted = [...new Set(handles)];
1401
+ const resolved = new Map();
1402
+ if (wanted.length === 0)
1403
+ return resolved;
1404
+ const suffix = await this.storeSlugSuffixFor(orgId, storeId);
1405
+ const idPart = storeId.replace(/[^a-z0-9]/gi, "").slice(0, 8).toLowerCase();
1406
+ const familyOf = (handle) => suffix
1407
+ ? [handle, `${handle}-${suffix}`, `${handle}-${suffix}-${idPart}`]
1408
+ : [handle, `${handle}-${idPart}`];
1409
+ const families = new Map(wanted.map((handle) => [handle, familyOf(handle)]));
1410
+ const owners = await this.db.select({ slug: sellableEntities.slug, sourceStoreId: sellableEntities.sourceStoreId })
1411
+ .from(sellableEntities)
1412
+ .where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.slug, [...families.values()].flat())));
1413
+ // Only ANOTHER STORE's product moves this one to a qualified slug. A product a person made in
1414
+ // Merchant Center (no source store) holding the handle stays a loud slug conflict, as before.
1415
+ const heldElsewhere = new Set(owners.filter((row) => row.sourceStoreId !== null && row.sourceStoreId !== storeId).map((row) => row.slug));
1416
+ for (const [handle, family] of families) {
1417
+ // Every member held by another store is only reachable through a hand-made slug; the last
1418
+ // member is then used anyway and the create fails loudly on the unique index.
1419
+ const slug = family.find((candidate) => !heldElsewhere.has(candidate)) ?? family[family.length - 1] ?? handle;
1420
+ resolved.set(handle, { slug, family });
1421
+ }
1422
+ return resolved;
1423
+ }
1424
+ // ponytail: cached for the service instance's lifetime (one task invocation), so a batched import
1425
+ // reads the store once. A store whose domain changes mid-invocation keeps the old suffix until the
1426
+ // next one — a new slug family only, never a rename of a slug already assigned.
1427
+ slugSuffixByStore = new Map();
1428
+ async storeSlugSuffixFor(orgId, storeId) {
1429
+ const cached = this.slugSuffixByStore.get(storeId);
1430
+ if (cached !== undefined)
1431
+ return cached;
1432
+ const suffix = storeSlugSuffix((await this.getStoreRecord(orgId, storeId))?.storeDomain ?? "");
1433
+ this.slugSuffixByStore.set(storeId, suffix);
1434
+ return suffix;
1435
+ }
1436
+ /** The slug an existing product converges to: its current one while that is still in the family
1437
+ * of the handle the store sends, so a slug is never recomputed out from under a shared link. */
1438
+ slugToKeep(currentSlug, resolved) {
1439
+ return resolved.family.includes(currentSlug) ? currentSlug : resolved.slug;
1440
+ }
1353
1441
  async getStoreByDomain(shopDomain) {
1354
1442
  const rows = await this.db
1355
1443
  .select()
@@ -1986,9 +2074,11 @@ export class ChannelConnectorService {
1986
2074
  const externalIds = [...new Set(items.map((item) => item.externalId))];
1987
2075
  const mappings = externalIds.length === 0 ? [] : await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), inArray(channelEntityMap.externalId, externalIds)));
1988
2076
  const mappingByExternalId = new Map(mappings.map((row) => [row.externalId, row]));
1989
- const slugs = [...new Set(items.map((item) => item.slug))];
2077
+ const slugFor = await this.resolveStoreSlugs(orgId, storeId, items.map((item) => item.slug));
2078
+ const slugs = [...new Set([...slugFor.values()].flatMap((resolved) => resolved.family))];
1990
2079
  const orphans = slugs.length === 0 ? [] : await this.db.select({ slug: sellableEntities.slug }).from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.sourceStoreId, storeId), inArray(sellableEntities.slug, slugs)));
1991
2080
  const orphanSlugs = new Set(orphans.map((row) => row.slug));
2081
+ const isOrphan = (handle) => (slugFor.get(handle)?.family ?? [handle]).some((slug) => orphanSlugs.has(slug));
1992
2082
  const fresh = [];
1993
2083
  const editor = [];
1994
2084
  const seen = new Set();
@@ -2003,7 +2093,7 @@ export class ChannelConnectorService {
2003
2093
  entityByExternalId.set(item.externalId, mapping.entityId);
2004
2094
  unchanged += 1;
2005
2095
  }
2006
- else if (mapping || orphanSlugs.has(item.slug)) {
2096
+ else if (mapping || isOrphan(item.slug)) {
2007
2097
  editor.push(item);
2008
2098
  }
2009
2099
  else {
@@ -2026,33 +2116,61 @@ export class ChannelConnectorService {
2026
2116
  updated += survivors.length;
2027
2117
  }
2028
2118
  const createdItems = [];
2029
- if (fresh.length > 0) {
2030
- const report = await this.catalog.importProducts(fresh.map(toImportProduct), { sourceStoreId: storeId, errorPolicy: "reject-failed-rows" }, actor);
2119
+ // Two stores onboarding at once can both resolve a handle as free and then both create it. The
2120
+ // loser's collision is transient: re-resolve those items once (the winner is now visible, so they
2121
+ // take the store-qualified slug) and try again. A second collision is reported as the item's.
2122
+ let pending = fresh;
2123
+ for (let attempt = 0; pending.length > 0; attempt += 1) {
2124
+ // The slug is resolved on the import row only: the map row's hash stays the hash of the item
2125
+ // as the store sent it, so the next page still reads it as unchanged.
2126
+ const report = await this.catalog.importProducts(pending.map((item) => toImportProduct({ ...item, slug: slugFor.get(item.slug)?.slug ?? item.slug })), { sourceStoreId: storeId, errorPolicy: "reject-failed-rows" }, actor);
2031
2127
  if (!report.ok)
2032
2128
  return PluginErr(report.error.message, report.error.code);
2129
+ const collided = [];
2033
2130
  for (const [index, row] of report.value.rows.entries()) {
2034
- const item = fresh[index];
2131
+ const item = pending[index];
2035
2132
  if (!item)
2036
2133
  continue;
2037
2134
  if (row.status === "failed") {
2038
- failures.push({ externalId: row.ref, error: `${row.code}: ${row.error}` });
2135
+ const failure = { externalId: row.ref, error: `${row.code}: ${row.error}` };
2136
+ const lostSlug = row.code === "slug-conflict" || (row.code === "conflict" && isSlugConflict(row.error));
2137
+ if (attempt === 0 && lostSlug)
2138
+ collided.push({ item, failure });
2139
+ else
2140
+ failures.push(failure);
2039
2141
  continue;
2040
2142
  }
2041
2143
  warnings.push(...row.warnings);
2042
2144
  entityByExternalId.set(item.externalId, row.entityId);
2043
2145
  createdItems.push({ item, entityId: row.entityId, variantIds: row.variantIds });
2044
2146
  }
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();
2147
+ pending = [];
2148
+ if (collided.length > 0) {
2149
+ const again = await this.resolveStoreSlugs(orgId, storeId, collided.map(({ item }) => item.slug));
2150
+ for (const { item, failure } of collided) {
2151
+ const before = slugFor.get(item.slug)?.slug ?? item.slug;
2152
+ const after = again.get(item.slug);
2153
+ // Retry only when another STORE took the handle: a slug held by a product made in
2154
+ // Merchant Center resolves to the same slug again, and stays the loud conflict it was.
2155
+ if (after === undefined || after.slug === before) {
2156
+ failures.push(failure);
2157
+ continue;
2158
+ }
2159
+ slugFor.set(item.slug, after);
2160
+ pending.push(item);
2161
+ }
2054
2162
  }
2055
2163
  }
2164
+ if (createdItems.length > 0) {
2165
+ const now = new Date();
2166
+ await this.db.insert(channelEntityMap).values(createdItems.flatMap(({ item, entityId, variantIds }) => [
2167
+ { organizationId: orgId, storeId, kind: "entity", externalId: item.externalId, entityId, syncHash: hash(item), lastSyncedAt: now },
2168
+ ...item.variants.flatMap((variant) => {
2169
+ const variantId = variantIds[variant.externalId];
2170
+ return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant", externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
2171
+ }),
2172
+ ])).onConflictDoNothing();
2173
+ }
2056
2174
  const media = await this.importHeroes(orgId, createdItems, actor);
2057
2175
  const entityIds = [];
2058
2176
  const emitted = new Set();
@@ -2568,6 +2686,8 @@ export class ChannelConnectorService {
2568
2686
  const failures = [];
2569
2687
  const entityIds = [];
2570
2688
  const committed = new Set();
2689
+ // One resolution for the whole batch, not two round trips per changed product.
2690
+ const slugFor = await this.resolveStoreSlugs(orgId, storeId, items.map((item) => item.slug));
2571
2691
  for (const item of items) {
2572
2692
  consumed += 1;
2573
2693
  try {
@@ -2593,8 +2713,15 @@ export class ChannelConnectorService {
2593
2713
  existingEntity = entity;
2594
2714
  }
2595
2715
  }
2716
+ let resolvedSlug = slugFor.get(item.slug) ?? { slug: item.slug, family: [item.slug] };
2717
+ // A slug lost to a concurrent writer (another store onboarding the same handle) is transient:
2718
+ // re-resolve once, and the winner being visible now moves this item to its qualified slug.
2719
+ const reresolveSlug = async () => {
2720
+ resolvedSlug = (await this.resolveStoreSlugs(orgId, storeId, [item.slug])).get(item.slug) ?? resolvedSlug;
2721
+ slugFor.set(item.slug, resolvedSlug);
2722
+ };
2596
2723
  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)));
2724
+ const [orphan] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.sourceStoreId, storeId), inArray(sellableEntities.slug, resolvedSlug.family))).limit(1);
2598
2725
  if (orphan) {
2599
2726
  entityId = orphan.id;
2600
2727
  existingEntity = orphan;
@@ -2602,33 +2729,46 @@ export class ChannelConnectorService {
2602
2729
  }
2603
2730
  else {
2604
2731
  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;
2732
+ const createEntity = (slug) => this.transact(async (tx) => {
2733
+ const txContext = createTxContext(tx, { actor });
2734
+ const created = await this.catalog.create({
2735
+ type: "product",
2736
+ slug,
2737
+ sourceStoreId: storeId,
2738
+ metadata: mergeMetadata(undefined, item.metadata ?? {}),
2739
+ ...(status !== undefined ? { status, isVisible: status === "active" } : {}),
2740
+ }, actor, txContext);
2741
+ if (!created.ok)
2742
+ throw new Error(created.error.message);
2743
+ await tx.insert(channelEntityMap).values({
2744
+ organizationId: orgId,
2745
+ storeId,
2746
+ kind: "entity",
2747
+ externalId: item.externalId,
2748
+ entityId: created.value.id,
2749
+ syncHash: PENDING_ENTITY_MAP_SYNC_HASH,
2750
+ heldFieldPaths: [],
2751
+ forcedPushFieldPaths: [],
2628
2752
  });
2753
+ return created.value.id;
2754
+ });
2755
+ let createError;
2756
+ for (let attempt = 0; attempt < 2 && entityId === undefined; attempt += 1) {
2757
+ try {
2758
+ entityId = await createEntity(resolvedSlug.slug);
2759
+ }
2760
+ catch (error) {
2761
+ createError = error;
2762
+ if (attempt > 0 || !isSlugConflict(error))
2763
+ break;
2764
+ const before = resolvedSlug.slug;
2765
+ await reresolveSlug();
2766
+ if (resolvedSlug.slug === before)
2767
+ break;
2768
+ }
2629
2769
  }
2630
- catch (error) {
2631
- failures.push({ externalId: item.externalId, error: error instanceof Error ? error.message : "Failed to create catalog entity." });
2770
+ if (entityId === undefined) {
2771
+ failures.push({ externalId: item.externalId, error: createError instanceof Error ? createError.message : "Failed to create catalog entity." });
2632
2772
  continue;
2633
2773
  }
2634
2774
  isNew = true;
@@ -2680,8 +2820,9 @@ export class ChannelConnectorService {
2680
2820
  ? writable.status ?? (existingEntity.status === "archived" ? "active" : undefined)
2681
2821
  : undefined;
2682
2822
  const updateInput = {};
2683
- if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== writable.slug) {
2684
- updateInput.slug = writable.slug;
2823
+ const keptSlug = this.slugToKeep(existingEntity.slug, resolvedSlug);
2824
+ if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== keptSlug) {
2825
+ updateInput.slug = keptSlug;
2685
2826
  }
2686
2827
  if (hash(remoteMetadata) !== hash(existingEntity.metadata ?? {}))
2687
2828
  updateInput.metadata = remoteMetadata;
@@ -2693,11 +2834,29 @@ export class ChannelConnectorService {
2693
2834
  ? Object.keys(updateInput).length > 0
2694
2835
  : remoteChanged || existingEntity.status === "archived";
2695
2836
  if (shouldUpdate) {
2696
- converged += 1;
2697
2837
  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 });
2838
+ const mappedEntityId = entityMapping.entityId;
2839
+ const update = async () => {
2840
+ try {
2841
+ const updated = await this.catalog.update(mappedEntityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
2842
+ return updated.ok ? undefined : updated.error;
2843
+ }
2844
+ catch (error) {
2845
+ return error;
2846
+ }
2847
+ };
2848
+ let updateError = await update();
2849
+ if (updateError !== undefined && updateInput.slug !== undefined && isSlugConflict(updateError)) {
2850
+ await reresolveSlug();
2851
+ const retrySlug = this.slugToKeep(existingEntity.slug, resolvedSlug);
2852
+ if (retrySlug === existingEntity.slug)
2853
+ delete updateInput.slug;
2854
+ else
2855
+ updateInput.slug = retrySlug;
2856
+ updateError = Object.keys(updateInput).length > 0 ? await update() : undefined;
2857
+ }
2858
+ if (updateError !== undefined) {
2859
+ failures.push({ externalId: item.externalId, error: errorMessage(updateError) });
2701
2860
  continue;
2702
2861
  }
2703
2862
  entityTouched = true;
@@ -2711,8 +2870,12 @@ export class ChannelConnectorService {
2711
2870
  if (!attributes.ok)
2712
2871
  return attributes;
2713
2872
  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;
2873
+ // A variant the store's own data makes unwritable (a sku another of its products holds) is
2874
+ // that item's failure, reported with its error — not a page error the caller would retry.
2875
+ if (!variantIds.ok) {
2876
+ failures.push({ externalId: item.externalId, error: variantIds.error });
2877
+ continue;
2878
+ }
2716
2879
  const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
2717
2880
  if (!taxonomy.ok)
2718
2881
  return taxonomy;
@@ -2726,6 +2889,10 @@ export class ChannelConnectorService {
2726
2889
  entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
2727
2890
  if (entityTouched)
2728
2891
  entitiesTouched += 1;
2892
+ // Counted from what was written, not from the stored hash: a hash that moved while the
2893
+ // product did not (a blanked map row, a change in how an item serialises) is not drift.
2894
+ if (entityTouched && entityMapping && !isNew)
2895
+ converged += 1;
2729
2896
  if (entityTouched) {
2730
2897
  const revision = await this.catalog.recordEntityRevision(entityId, actor, "import");
2731
2898
  if (!revision.ok) {
@@ -2848,12 +3015,14 @@ export class ChannelConnectorService {
2848
3015
  if (!mapping)
2849
3016
  continue;
2850
3017
  const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
2851
- if (current?.quantityOnHand === level.available)
3018
+ // Stock cannot sit below zero here, so negative remote stock compares as the zero it is stored as.
3019
+ const quantity = Math.max(0, level.available);
3020
+ if (current?.quantityOnHand === quantity)
2852
3021
  continue;
2853
3022
  const result = await inventoryService.setAbsolute({
2854
3023
  entityId: mapping.entityId,
2855
3024
  ...(mapping.variantId ? { variantId: mapping.variantId } : {}),
2856
- quantity: level.available,
3025
+ quantity,
2857
3026
  reason: `Inventory reconciliation from ${store.provider}`,
2858
3027
  }, actor);
2859
3028
  if (!result.ok)
@@ -3060,12 +3229,14 @@ export class ChannelConnectorService {
3060
3229
  if (!mapping)
3061
3230
  continue;
3062
3231
  const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
3063
- if (current?.quantityOnHand === level.available)
3232
+ // Stock cannot sit below zero here, so negative remote stock compares as the zero it is stored as.
3233
+ const quantity = Math.max(0, level.available);
3234
+ if (current?.quantityOnHand === quantity)
3064
3235
  continue;
3065
3236
  const result = await inventoryService.setAbsolute({
3066
3237
  entityId: mapping.entityId,
3067
3238
  ...(mapping.variantId ? { variantId: mapping.variantId } : {}),
3068
- quantity: level.available,
3239
+ quantity,
3069
3240
  reason: `Inventory sync from ${store.provider}`,
3070
3241
  }, actor);
3071
3242
  if (!result.ok)
@@ -3332,8 +3503,11 @@ export class ChannelConnectorService {
3332
3503
  const warnings = conflicts.map((conflict) => `Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
3333
3504
  const writable = held.writable;
3334
3505
  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;
3506
+ if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && typeof writable.slug === "string") {
3507
+ const resolved = (await this.resolveStoreSlugs(orgId, storeId, [writable.slug])).get(writable.slug);
3508
+ const slug = resolved ? this.slugToKeep(entity.slug, resolved) : writable.slug;
3509
+ if (entity.slug !== slug)
3510
+ updateInput.slug = slug;
3337
3511
  }
3338
3512
  if (Object.keys(writable.metadata ?? {}).length > 0) {
3339
3513
  const remoteEntityMetadata = mergeMetadata(entity.metadata, writable.metadata ?? {});