@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 +27 -0
- package/dist/service.js +229 -55
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +4 -4
- package/src/service.ts +221 -53
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/plugin-channel-connector",
|
|
3
|
-
"version": "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.
|
|
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/
|
|
30
|
-
"@porulle/
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
2843
|
-
|
|
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 =
|
|
2951
|
+
const item = pending[index];
|
|
2847
2952
|
if (!item) continue;
|
|
2848
2953
|
if (row.status === "failed") {
|
|
2849
|
-
|
|
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
|
-
|
|
2857
|
-
|
|
2858
|
-
await this.
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
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
|
-
|
|
3561
|
-
|
|
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
|
-
|
|
3622
|
-
|
|
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
|
|
3636
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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") &&
|
|
4331
|
-
|
|
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 ?? {});
|