@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/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
|
|
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 ||
|
|
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
|
-
|
|
2030
|
-
|
|
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 =
|
|
2131
|
+
const item = pending[index];
|
|
2035
2132
|
if (!item)
|
|
2036
2133
|
continue;
|
|
2037
2134
|
if (row.status === "failed") {
|
|
2038
|
-
|
|
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
|
-
|
|
2046
|
-
|
|
2047
|
-
await this.
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
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),
|
|
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
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
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
|
-
|
|
2631
|
-
failures.push({ externalId: item.externalId, error:
|
|
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
|
-
|
|
2684
|
-
|
|
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
|
|
2699
|
-
|
|
2700
|
-
|
|
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
|
-
|
|
2715
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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") &&
|
|
3336
|
-
|
|
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 ?? {});
|