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