@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/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.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,15 +19,15 @@
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.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^24.5.2",
26
26
  "eslint": "^9.39.1",
27
27
  "typescript": "5.9.2",
28
28
  "vitest": "^3.2.4",
29
- "@porulle/eslint-config": "0.1.0",
30
- "@porulle/typescript-config": "0.1.0"
29
+ "@porulle/typescript-config": "0.1.0",
30
+ "@porulle/eslint-config": "0.1.0"
31
31
  },
32
32
  "publishConfig": {
33
33
  "access": "public"
package/src/service.ts CHANGED
@@ -636,6 +636,39 @@ function hash(value: unknown): string {
636
636
  return createHash("sha256").update(JSON.stringify(value)).digest("hex");
637
637
  }
638
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
+
639
672
  /** Mid-import map rows carry this until convergence finishes; must not equal any real remote hash. */
640
673
  const PENDING_ENTITY_MAP_SYNC_HASH = "";
641
674
 
@@ -2050,6 +2083,65 @@ export class ChannelConnectorService {
2050
2083
  return rows[0] as ConnectedStore | undefined;
2051
2084
  }
2052
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
+
2053
2145
  async getStoreByDomain(shopDomain: string): Promise<ConnectedStore | undefined> {
2054
2146
  const rows = await this.db
2055
2147
  .select()
@@ -2796,13 +2888,15 @@ export class ChannelConnectorService {
2796
2888
  inArray(channelEntityMap.externalId, externalIds),
2797
2889
  ));
2798
2890
  const mappingByExternalId = new Map(mappings.map((row) => [row.externalId, row]));
2799
- const slugs = [...new Set(items.map((item) => item.slug))];
2891
+ const slugFor = await this.resolveStoreSlugs(orgId, storeId, items.map((item) => item.slug));
2892
+ const slugs = [...new Set([...slugFor.values()].flatMap((resolved) => resolved.family))];
2800
2893
  const orphans = slugs.length === 0 ? [] : await this.db.select({ slug: sellableEntities.slug }).from(sellableEntities).where(and(
2801
2894
  eq(sellableEntities.organizationId, orgId),
2802
2895
  eq(sellableEntities.sourceStoreId, storeId),
2803
2896
  inArray(sellableEntities.slug, slugs),
2804
2897
  ));
2805
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));
2806
2900
 
2807
2901
  const fresh: ChannelCatalogItem[] = [];
2808
2902
  const editor: ChannelCatalogItem[] = [];
@@ -2817,7 +2911,7 @@ export class ChannelConnectorService {
2817
2911
  if (mapping && mapping.syncHash === hash(item)) {
2818
2912
  entityByExternalId.set(item.externalId, mapping.entityId);
2819
2913
  unchanged += 1;
2820
- } else if (mapping || orphanSlugs.has(item.slug)) {
2914
+ } else if (mapping || isOrphan(item.slug)) {
2821
2915
  editor.push(item);
2822
2916
  } else {
2823
2917
  fresh.push(item);
@@ -2839,31 +2933,58 @@ export class ChannelConnectorService {
2839
2933
  }
2840
2934
 
2841
2935
  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);
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
+ );
2844
2948
  if (!report.ok) return PluginErr(report.error.message, report.error.code);
2949
+ const collided: Array<{ item: ChannelCatalogItem; failure: CatalogConvergenceFailure }> = [];
2845
2950
  for (const [index, row] of report.value.rows.entries()) {
2846
- const item = fresh[index];
2951
+ const item = pending[index];
2847
2952
  if (!item) continue;
2848
2953
  if (row.status === "failed") {
2849
- failures.push({ externalId: row.ref, error: `${row.code}: ${row.error}` });
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);
2850
2958
  continue;
2851
2959
  }
2852
2960
  warnings.push(...row.warnings);
2853
2961
  entityByExternalId.set(item.externalId, row.entityId);
2854
2962
  createdItems.push({ item, entityId: row.entityId, variantIds: row.variantIds });
2855
2963
  }
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();
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
+ }
2865
2976
  }
2866
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
+ }
2867
2988
 
2868
2989
  const media = await this.importHeroes(orgId, createdItems, actor);
2869
2990
  const entityIds: string[] = [];
@@ -3486,6 +3607,8 @@ export class ChannelConnectorService {
3486
3607
  const failures: CatalogConvergenceFailure[] = [];
3487
3608
  const entityIds: string[] = [];
3488
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));
3489
3612
  for (const item of items) {
3490
3613
  consumed += 1;
3491
3614
  try {
@@ -3522,43 +3645,61 @@ export class ChannelConnectorService {
3522
3645
  existingEntity = entity;
3523
3646
  }
3524
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
+ };
3525
3655
  if (entityId === undefined) {
3526
3656
  const [orphan] = await this.db.select().from(sellableEntities).where(and(
3527
3657
  eq(sellableEntities.organizationId, orgId),
3528
3658
  eq(sellableEntities.sourceStoreId, storeId),
3529
- eq(sellableEntities.slug, item.slug),
3530
- ));
3659
+ inArray(sellableEntities.slug, resolvedSlug.family),
3660
+ )).limit(1);
3531
3661
  if (orphan) {
3532
3662
  entityId = orphan.id;
3533
3663
  existingEntity = orphan;
3534
3664
  adoptedOrphan = true;
3535
3665
  } else {
3536
3666
  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;
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: [],
3559
3686
  });
3560
- } catch (error) {
3561
- 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." });
3562
3703
  continue;
3563
3704
  }
3564
3705
  isNew = true;
@@ -3618,8 +3759,9 @@ export class ChannelConnectorService {
3618
3759
  status?: string;
3619
3760
  isVisible?: boolean;
3620
3761
  } = {};
3621
- if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== writable.slug) {
3622
- 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;
3623
3765
  }
3624
3766
  if (hash(remoteMetadata) !== hash(existingEntity.metadata ?? {})) updateInput.metadata = remoteMetadata;
3625
3767
  if (remoteStatus !== undefined && !blockedPaths.has("entity.status") && remoteStatus !== existingEntity.status) {
@@ -3630,10 +3772,25 @@ export class ChannelConnectorService {
3630
3772
  ? Object.keys(updateInput).length > 0
3631
3773
  : remoteChanged || existingEntity.status === "archived";
3632
3774
  if (shouldUpdate) {
3633
- converged += 1;
3634
3775
  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; }
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; }
3637
3794
  entityTouched = true;
3638
3795
  }
3639
3796
  }
@@ -3654,7 +3811,9 @@ export class ChannelConnectorService {
3654
3811
  !heldSharedPaths.includes("options") && owners.get("options") !== "platform",
3655
3812
  item,
3656
3813
  );
3657
- 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; }
3658
3817
  const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
3659
3818
  if (!taxonomy.ok) return taxonomy;
3660
3819
  const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
@@ -3665,6 +3824,9 @@ export class ChannelConnectorService {
3665
3824
  skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
3666
3825
  entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
3667
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;
3668
3830
 
3669
3831
  if (entityTouched) {
3670
3832
  const revision = await this.catalog.recordEntityRevision(entityId, actor, "import");
@@ -3800,11 +3962,13 @@ export class ChannelConnectorService {
3800
3962
  const mapping = mappings.find((entry) => entry.externalId === level.externalId);
3801
3963
  if (!mapping) continue;
3802
3964
  const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
3803
- 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;
3804
3968
  const result = await inventoryService.setAbsolute({
3805
3969
  entityId: mapping.entityId,
3806
3970
  ...(mapping.variantId ? { variantId: mapping.variantId } : {}),
3807
- quantity: level.available,
3971
+ quantity,
3808
3972
  reason: `Inventory reconciliation from ${store.provider}`,
3809
3973
  }, actor);
3810
3974
  if (!result.ok) return PluginErr(result.error?.message ?? "Inventory reconciliation failed.");
@@ -4043,11 +4207,13 @@ export class ChannelConnectorService {
4043
4207
  const mapping = mappings.find((entry) => entry.externalId === level.externalId);
4044
4208
  if (!mapping) continue;
4045
4209
  const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
4046
- 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;
4047
4213
  const result = await inventoryService.setAbsolute({
4048
4214
  entityId: mapping.entityId,
4049
4215
  ...(mapping.variantId ? { variantId: mapping.variantId } : {}),
4050
- quantity: level.available,
4216
+ quantity,
4051
4217
  reason: `Inventory sync from ${store.provider}`,
4052
4218
  }, actor);
4053
4219
  if (!result.ok) return PluginErr(result.error?.message ?? "Inventory sync failed.");
@@ -4327,8 +4493,10 @@ export class ChannelConnectorService {
4327
4493
  status?: string;
4328
4494
  isVisible?: boolean;
4329
4495
  } = {};
4330
- if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && entity.slug !== writable.slug) {
4331
- 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;
4332
4500
  }
4333
4501
  if (Object.keys(writable.metadata ?? {}).length > 0) {
4334
4502
  const remoteEntityMetadata = mergeMetadata(entity.metadata, writable.metadata ?? {});