@porulle/plugin-channel-connector 0.48.1 → 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/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/service.d.ts +116 -1
- package/dist/service.js +717 -77
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/index.ts +9 -0
- package/src/service.ts +787 -72
package/src/service.ts
CHANGED
|
@@ -23,8 +23,12 @@ import type {
|
|
|
23
23
|
PluginResult,
|
|
24
24
|
PluginTxFn,
|
|
25
25
|
CatalogWriteContext,
|
|
26
|
+
ImportProduct,
|
|
27
|
+
ImportProductsOptions,
|
|
28
|
+
ImportProductsReport,
|
|
26
29
|
TxContext,
|
|
27
30
|
} from "@porulle/core";
|
|
31
|
+
import type { ChannelCatalogImage } from "@porulle/core";
|
|
28
32
|
import { isValidFieldPath, requireUserId } from "@porulle/core";
|
|
29
33
|
import type { FieldOwner, FieldPath } from "@porulle/core";
|
|
30
34
|
import type { JobsAdapter } from "@porulle/core";
|
|
@@ -523,6 +527,11 @@ interface CatalogService {
|
|
|
523
527
|
actor: Actor | null,
|
|
524
528
|
): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
|
|
525
529
|
seedImportedFieldOwnership(entityId: string, storeId: string, fieldPaths: FieldPath[]): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
|
|
530
|
+
importProducts(
|
|
531
|
+
page: ImportProduct[],
|
|
532
|
+
options: ImportProductsOptions,
|
|
533
|
+
actor: Actor,
|
|
534
|
+
): Promise<ServiceResult<ImportProductsReport>>;
|
|
526
535
|
createOptionType(
|
|
527
536
|
input: { entityId: string; name: string; values?: string[] },
|
|
528
537
|
actor: Actor,
|
|
@@ -627,6 +636,39 @@ function hash(value: unknown): string {
|
|
|
627
636
|
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
628
637
|
}
|
|
629
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
|
+
|
|
630
672
|
/** Mid-import map rows carry this until convergence finishes; must not equal any real remote hash. */
|
|
631
673
|
const PENDING_ENTITY_MAP_SYNC_HASH = "";
|
|
632
674
|
|
|
@@ -891,6 +933,167 @@ function redactStore(store: ConnectedStore): PublicConnectedStore {
|
|
|
891
933
|
};
|
|
892
934
|
}
|
|
893
935
|
|
|
936
|
+
/**
|
|
937
|
+
* A hero is streamed inside the page's own invocation, so it is bounded: a 30 MB TIFF a merchant
|
|
938
|
+
* uploaded by mistake must not buffer into a 128 MiB isolate. Anything larger is reported, not
|
|
939
|
+
* stored, and the product still lands — the index reads text first and media later.
|
|
940
|
+
*/
|
|
941
|
+
export const HERO_IMAGE_BYTE_CAP = 1024 * 1024;
|
|
942
|
+
|
|
943
|
+
export type CatalogMediaFailureReason = "too-large" | "download-failed" | "unsupported" | "storage";
|
|
944
|
+
|
|
945
|
+
export interface CatalogMediaFailure {
|
|
946
|
+
externalId: string;
|
|
947
|
+
imageExternalId?: string;
|
|
948
|
+
url: string;
|
|
949
|
+
reason: CatalogMediaFailureReason;
|
|
950
|
+
detail: string;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/** Media the page did NOT fetch: the first photo of each variant the hero does not show. */
|
|
954
|
+
export interface CatalogDeferredMedia {
|
|
955
|
+
externalId: string;
|
|
956
|
+
entityId: string;
|
|
957
|
+
images: ChannelCatalogImage[];
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
export interface CatalogPageConvergence extends Record<string, unknown> {
|
|
961
|
+
created: number;
|
|
962
|
+
unchanged: number;
|
|
963
|
+
updated: number;
|
|
964
|
+
/** Input order, failures excluded, no duplicates — the page message is rebuilt from this. */
|
|
965
|
+
entityIds: string[];
|
|
966
|
+
failures: CatalogConvergenceFailure[];
|
|
967
|
+
heroesImported: number;
|
|
968
|
+
mediaFailures: CatalogMediaFailure[];
|
|
969
|
+
deferredMedia: CatalogDeferredMedia[];
|
|
970
|
+
warnings: string[];
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
export interface ImportImageSelection {
|
|
974
|
+
hero: ChannelCatalogImage | null;
|
|
975
|
+
/** In variant order; one image per variant the hero does not cover; no url twice. */
|
|
976
|
+
perVariant: ChannelCatalogImage[];
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function imageOrder(a: ChannelCatalogImage, b: ChannelCatalogImage): number {
|
|
980
|
+
return (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Ruling 2026-09-22: import the hero plus the FIRST photo of each other variant, nothing more.
|
|
985
|
+
* A "blue long dress" query must be able to show the blue variant, and a fourth photo of the red
|
|
986
|
+
* one adds nothing the index can use. Variants are read off the images' own variant references,
|
|
987
|
+
* so a connector that lists images against variants it does not enumerate still gets one each.
|
|
988
|
+
*/
|
|
989
|
+
export function selectImportImages(item: ChannelCatalogItem): ImportImageSelection {
|
|
990
|
+
const images = [...(item.images ?? [])].sort(imageOrder);
|
|
991
|
+
const hero = images.find((image) => image.role === "primary") ?? images[0] ?? null;
|
|
992
|
+
if (!hero) return { hero: null, perVariant: [] };
|
|
993
|
+
const covered = new Set(hero.variantExternalIds ?? []);
|
|
994
|
+
const usedUrls = new Set([hero.url]);
|
|
995
|
+
const perVariant: ChannelCatalogImage[] = [];
|
|
996
|
+
const variantRefs = [...new Set(images.flatMap((image) => image.variantExternalIds ?? []))];
|
|
997
|
+
for (const ref of variantRefs) {
|
|
998
|
+
if (covered.has(ref)) continue;
|
|
999
|
+
const image = images.find((candidate) => candidate.variantExternalIds?.includes(ref) && !usedUrls.has(candidate.url));
|
|
1000
|
+
for (const shown of image?.variantExternalIds ?? []) covered.add(shown);
|
|
1001
|
+
covered.add(ref);
|
|
1002
|
+
if (!image) continue;
|
|
1003
|
+
usedUrls.add(image.url);
|
|
1004
|
+
perVariant.push(image);
|
|
1005
|
+
}
|
|
1006
|
+
return { hero, perVariant };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
type BoundedFetch =
|
|
1010
|
+
| { ok: true; bytes: Uint8Array<ArrayBuffer>; contentType: string }
|
|
1011
|
+
| { ok: false; reason: CatalogMediaFailureReason; detail: string };
|
|
1012
|
+
|
|
1013
|
+
/** Streams a response and refuses mid-stream past `cap`; a lying `content-length` cannot get around it. */
|
|
1014
|
+
async function fetchBounded(url: string, cap: number): Promise<BoundedFetch> {
|
|
1015
|
+
let response: Response;
|
|
1016
|
+
try {
|
|
1017
|
+
response = await fetch(url);
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
return { ok: false, reason: "download-failed", detail: error instanceof Error ? error.message : "download failed" };
|
|
1020
|
+
}
|
|
1021
|
+
if (!response.ok) return { ok: false, reason: "download-failed", detail: `download returned ${response.status}` };
|
|
1022
|
+
const declared = Number(response.headers.get("content-length"));
|
|
1023
|
+
if (Number.isFinite(declared) && declared > cap) return { ok: false, reason: "too-large", detail: `content-length ${declared} exceeds ${cap}` };
|
|
1024
|
+
const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim() || "image/jpeg";
|
|
1025
|
+
const reader = response.body?.getReader();
|
|
1026
|
+
if (!reader) {
|
|
1027
|
+
const buffer = await response.arrayBuffer();
|
|
1028
|
+
if (buffer.byteLength > cap) return { ok: false, reason: "too-large", detail: `${buffer.byteLength} bytes exceeds ${cap}` };
|
|
1029
|
+
return { ok: true, bytes: new Uint8Array(buffer), contentType };
|
|
1030
|
+
}
|
|
1031
|
+
const chunks: Uint8Array[] = [];
|
|
1032
|
+
let total = 0;
|
|
1033
|
+
for (;;) {
|
|
1034
|
+
const { done, value } = await reader.read();
|
|
1035
|
+
if (done) break;
|
|
1036
|
+
total += value.byteLength;
|
|
1037
|
+
if (total > cap) {
|
|
1038
|
+
await reader.cancel();
|
|
1039
|
+
return { ok: false, reason: "too-large", detail: `stream exceeded ${cap} bytes` };
|
|
1040
|
+
}
|
|
1041
|
+
chunks.push(value);
|
|
1042
|
+
}
|
|
1043
|
+
const bytes = new Uint8Array(total);
|
|
1044
|
+
let offset = 0;
|
|
1045
|
+
for (const chunk of chunks) {
|
|
1046
|
+
bytes.set(chunk, offset);
|
|
1047
|
+
offset += chunk.byteLength;
|
|
1048
|
+
}
|
|
1049
|
+
return { ok: true, bytes, contentType };
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function toImportProduct(item: ChannelCatalogItem): ImportProduct {
|
|
1053
|
+
const attributes = item.attributes?.length
|
|
1054
|
+
? item.attributes
|
|
1055
|
+
: [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
|
|
1056
|
+
return {
|
|
1057
|
+
ref: item.externalId,
|
|
1058
|
+
slug: item.slug,
|
|
1059
|
+
...(item.status !== undefined ? { status: item.status, isVisible: item.status === "active" } : {}),
|
|
1060
|
+
metadata: mergeMetadata(undefined, item.metadata ?? {}),
|
|
1061
|
+
attributes: attributes.map((attribute) => ({
|
|
1062
|
+
locale: attribute.locale,
|
|
1063
|
+
title: attribute.title,
|
|
1064
|
+
...(attribute.subtitle !== undefined ? { subtitle: attribute.subtitle } : {}),
|
|
1065
|
+
...(attribute.description !== undefined ? { description: attribute.description } : {}),
|
|
1066
|
+
...(attribute.richDescription !== undefined ? { richDescription: attribute.richDescription } : {}),
|
|
1067
|
+
...(attribute.seoTitle !== undefined ? { seoTitle: attribute.seoTitle } : {}),
|
|
1068
|
+
...(attribute.seoDescription !== undefined ? { seoDescription: attribute.seoDescription } : {}),
|
|
1069
|
+
})),
|
|
1070
|
+
...(item.options !== undefined ? {
|
|
1071
|
+
options: item.options.map((option) => ({
|
|
1072
|
+
name: option.name,
|
|
1073
|
+
displayName: option.displayName,
|
|
1074
|
+
...(option.sortOrder !== undefined ? { sortOrder: option.sortOrder } : {}),
|
|
1075
|
+
values: option.values.map((value) => ({
|
|
1076
|
+
value: value.value,
|
|
1077
|
+
displayValue: value.displayValue,
|
|
1078
|
+
...(value.sortOrder !== undefined ? { sortOrder: value.sortOrder } : {}),
|
|
1079
|
+
})),
|
|
1080
|
+
})),
|
|
1081
|
+
} : {}),
|
|
1082
|
+
variants: item.variants.map((variant) => ({
|
|
1083
|
+
ref: variant.externalId,
|
|
1084
|
+
...(variant.sku !== undefined ? { sku: variant.sku } : {}),
|
|
1085
|
+
...(variant.barcode !== undefined ? { barcode: variant.barcode } : {}),
|
|
1086
|
+
...(variant.optionValues !== undefined ? { options: variant.optionValues } : {}),
|
|
1087
|
+
...(variant.prices !== undefined ? { prices: variant.prices } : {}),
|
|
1088
|
+
...(variant.metadata !== undefined ? { metadata: variant.metadata } : {}),
|
|
1089
|
+
})),
|
|
1090
|
+
...(item.tags !== undefined ? { tags: item.tags } : {}),
|
|
1091
|
+
...(item.brand !== undefined ? { brand: item.brand } : {}),
|
|
1092
|
+
...(item.categories !== undefined ? { categories: item.categories } : {}),
|
|
1093
|
+
ownedFieldPaths: importedFieldPaths(item),
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
|
|
894
1097
|
export class ChannelConnectorService {
|
|
895
1098
|
private readonly connectors = new Map<string, ChannelConnector>();
|
|
896
1099
|
private readonly transact: PluginTxFn;
|
|
@@ -1313,41 +1516,77 @@ export class ChannelConnectorService {
|
|
|
1313
1516
|
actor: Actor,
|
|
1314
1517
|
): Promise<PluginResult<{ value: Map<string, Map<string, string>>; changed: boolean }>> {
|
|
1315
1518
|
const optionValueIds = new Map<string, Map<string, string>>();
|
|
1316
|
-
|
|
1519
|
+
// PROJECTED, not `select()`. Three reasons, and the third is the one that saves statements:
|
|
1520
|
+
// the row's other columns are never read; a projection types the locally-constructed row below
|
|
1521
|
+
// without a cast; and carrying `displayName`/`sortOrder` is what lets the update be SKIPPED when
|
|
1522
|
+
// they already hold. A re-import that changes nothing is the common case for a sync, and it used
|
|
1523
|
+
// to issue one UPDATE per option type and one per option value regardless.
|
|
1524
|
+
const existingTypes: { id: string; name: string; displayName: string | null; sortOrder: number | null }[] =
|
|
1525
|
+
await this.db
|
|
1526
|
+
.select({ id: optionTypes.id, name: optionTypes.name, displayName: optionTypes.displayName, sortOrder: optionTypes.sortOrder })
|
|
1527
|
+
.from(optionTypes)
|
|
1528
|
+
.where(eq(optionTypes.entityId, entityId));
|
|
1317
1529
|
let changed = false;
|
|
1318
1530
|
for (const [typeIndex, sourceType] of (item.options ?? []).entries()) {
|
|
1319
1531
|
let optionType = existingTypes.find((row) => row.name === sourceType.name);
|
|
1320
1532
|
if (!optionType) {
|
|
1321
1533
|
const created = await this.catalog.createOptionType({ entityId, name: sourceType.name, values: [] }, actor);
|
|
1322
1534
|
if (!created.ok) return PluginErr(created.error.message);
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1535
|
+
// The created row is CONSTRUCTED rather than read back: `createOptionType` returns the id and
|
|
1536
|
+
// the name is what we just sent.
|
|
1537
|
+
//
|
|
1538
|
+
// The two nulls are DELIBERATE PLACEHOLDERS, not a claim about the database. `createOptionType`
|
|
1539
|
+
// actually persists `displayName: input.name, sortOrder: 0` (core entity-service.ts:595) and
|
|
1540
|
+
// `display_name` is NOT NULL (core catalog/schema.ts:307) — so a fresh row never holds null.
|
|
1541
|
+
// Constructing nulls here makes the comparison below unequal on any input, which is what forces
|
|
1542
|
+
// the update that writes the caller's real `displayName`/`sortOrder` over those defaults.
|
|
1543
|
+
optionType = { id: created.value.id, name: sourceType.name, displayName: null, sortOrder: null };
|
|
1326
1544
|
existingTypes.push(optionType);
|
|
1327
1545
|
changed = true;
|
|
1328
1546
|
}
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1547
|
+
const desiredTypeSort = sourceType.sortOrder ?? typeIndex;
|
|
1548
|
+
// `?? null` NORMALISES, and it is load-bearing rather than tidy. The stored value is `null` or a
|
|
1549
|
+
// string; a connector that omits `displayName` sends `undefined`, and `null !== undefined` is
|
|
1550
|
+
// true — so without this every such connector issued one UPDATE per option type on every sync
|
|
1551
|
+
// and the conditional bought it nothing. Latent on today's corpus, whose connector always sends
|
|
1552
|
+
// both fields.
|
|
1553
|
+
const desiredTypeDisplay = sourceType.displayName ?? null;
|
|
1554
|
+
if (optionType.displayName !== desiredTypeDisplay || optionType.sortOrder !== desiredTypeSort) {
|
|
1555
|
+
await this.db.update(optionTypes).set({
|
|
1556
|
+
displayName: sourceType.displayName,
|
|
1557
|
+
sortOrder: desiredTypeSort,
|
|
1558
|
+
}).where(eq(optionTypes.id, optionType.id));
|
|
1559
|
+
optionType.displayName = desiredTypeDisplay;
|
|
1560
|
+
optionType.sortOrder = desiredTypeSort;
|
|
1561
|
+
}
|
|
1333
1562
|
|
|
1334
|
-
const existingValues
|
|
1563
|
+
const existingValues: { id: string; value: string; displayValue: string | null; sortOrder: number | null }[] =
|
|
1564
|
+
await this.db
|
|
1565
|
+
.select({ id: optionValues.id, value: optionValues.value, displayValue: optionValues.displayValue, sortOrder: optionValues.sortOrder })
|
|
1566
|
+
.from(optionValues)
|
|
1567
|
+
.where(eq(optionValues.optionTypeId, optionType.id));
|
|
1335
1568
|
const valueIds = new Map<string, string>();
|
|
1336
1569
|
for (const [valueIndex, sourceValue] of sourceType.values.entries()) {
|
|
1337
1570
|
let optionValue = existingValues.find((row) => row.value === sourceValue.value);
|
|
1338
1571
|
if (!optionValue) {
|
|
1339
1572
|
const created = await this.catalog.createOptionValue({ optionTypeId: optionType.id, value: sourceValue.value }, actor);
|
|
1340
1573
|
if (!created.ok) return PluginErr(created.error.message);
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
optionValue =
|
|
1574
|
+
// Same placeholder reasoning as the option type above: core persists `displayValue: input.value,
|
|
1575
|
+
// sortOrder: 0` (entity-service.ts:615), and the nulls force the update that overwrites them.
|
|
1576
|
+
optionValue = { id: created.value.id, value: sourceValue.value, displayValue: null, sortOrder: null };
|
|
1344
1577
|
existingValues.push(optionValue);
|
|
1345
1578
|
changed = true;
|
|
1346
1579
|
}
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1580
|
+
const desiredValueSort = sourceValue.sortOrder ?? valueIndex;
|
|
1581
|
+
const desiredValueDisplay = sourceValue.displayValue ?? null;
|
|
1582
|
+
if (optionValue.displayValue !== desiredValueDisplay || optionValue.sortOrder !== desiredValueSort) {
|
|
1583
|
+
await this.db.update(optionValues).set({
|
|
1584
|
+
displayValue: sourceValue.displayValue,
|
|
1585
|
+
sortOrder: desiredValueSort,
|
|
1586
|
+
}).where(eq(optionValues.id, optionValue.id));
|
|
1587
|
+
optionValue.displayValue = desiredValueDisplay;
|
|
1588
|
+
optionValue.sortOrder = desiredValueSort;
|
|
1589
|
+
}
|
|
1351
1590
|
valueIds.set(sourceValue.value, optionValue.id);
|
|
1352
1591
|
}
|
|
1353
1592
|
optionValueIds.set(sourceType.name, valueIds);
|
|
@@ -1375,10 +1614,52 @@ export class ChannelConnectorService {
|
|
|
1375
1614
|
eq(channelEntityMap.kind, "variant"),
|
|
1376
1615
|
eq(channelEntityMap.entityId, entityId),
|
|
1377
1616
|
));
|
|
1617
|
+
// ONE read of the existing option-value rows for every already-mapped variant, instead of one
|
|
1618
|
+
// per variant inside the loop. Measured on the deployed Worker at 17.0 calls per product, which
|
|
1619
|
+
// is one per offer on a catalogue averaging 13 offers per product. A variant CREATED below is
|
|
1620
|
+
// absent from this map and correctly reads as empty: its rows are written in this same pass.
|
|
1621
|
+
const mappedVariantIds = mappings
|
|
1622
|
+
.map((row) => row.variantId)
|
|
1623
|
+
.filter((variantId): variantId is string => variantId !== null);
|
|
1624
|
+
const existingOptionValues = new Map<string, string[]>();
|
|
1625
|
+
if (mappedVariantIds.length > 0) {
|
|
1626
|
+
const rows = await this.db
|
|
1627
|
+
.select({ variantId: variantOptionValues.variantId, optionValueId: variantOptionValues.optionValueId })
|
|
1628
|
+
.from(variantOptionValues)
|
|
1629
|
+
.where(inArray(variantOptionValues.variantId, mappedVariantIds));
|
|
1630
|
+
for (const row of rows) {
|
|
1631
|
+
const list = existingOptionValues.get(row.variantId) ?? [];
|
|
1632
|
+
list.push(row.optionValueId);
|
|
1633
|
+
existingOptionValues.set(row.variantId, list);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1378
1636
|
for (const sourceVariant of item.variants) {
|
|
1379
1637
|
const fullSourceVariant = fullItem.variants.find((variant) => variant.externalId === sourceVariant.externalId) ?? sourceVariant;
|
|
1380
1638
|
let mapping = mappings.find((row) => row.externalId === sourceVariant.externalId);
|
|
1381
1639
|
let variantId = mapping?.variantId;
|
|
1640
|
+
// ADOPT BEFORE CREATE. A variant-kind mapping row whose `variantId` is null has lost its link
|
|
1641
|
+
// — the key survived, the target did not. Creating a replacement is what the loop used to do,
|
|
1642
|
+
// and it cannot work: the orphaned variant still holds the sku, so `variants_native_org_sku_unique`
|
|
1643
|
+
// refuses the insert and the item fails on this and every later sync. The row can never heal.
|
|
1644
|
+
//
|
|
1645
|
+
// `sku` is the store's own natural key for a variant, so re-resolving by it is what restores
|
|
1646
|
+
// the link the null destroyed. Scoped to this entity because a sku is unique per organization
|
|
1647
|
+
// and adopting another entity's variant would be worse than failing.
|
|
1648
|
+
if (mapping && !variantId && sourceVariant.sku) {
|
|
1649
|
+
const [adopted] = await this.db
|
|
1650
|
+
.select({ id: variants.id })
|
|
1651
|
+
.from(variants)
|
|
1652
|
+
.where(and(eq(variants.entityId, entityId), eq(variants.sku, sourceVariant.sku)))
|
|
1653
|
+
.limit(1);
|
|
1654
|
+
if (adopted) {
|
|
1655
|
+
variantId = adopted.id;
|
|
1656
|
+
// Write the link back, or the row stays broken and every later sync pays this lookup again.
|
|
1657
|
+
await this.db.update(channelEntityMap)
|
|
1658
|
+
.set({ variantId })
|
|
1659
|
+
.where(eq(channelEntityMap.id, mapping.id));
|
|
1660
|
+
mapping.variantId = variantId;
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1382
1663
|
const createdVariant = !variantId;
|
|
1383
1664
|
if (!variantId) {
|
|
1384
1665
|
const options: Record<string, string> = {};
|
|
@@ -1398,17 +1679,36 @@ export class ChannelConnectorService {
|
|
|
1398
1679
|
}, actor);
|
|
1399
1680
|
if (!created.ok) return PluginErr(created.error.message);
|
|
1400
1681
|
variantId = created.value.id;
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1682
|
+
if (mapping) {
|
|
1683
|
+
// REPAIR IN PLACE. `variantId` is nullable because this table also holds `kind: "entity"`
|
|
1684
|
+
// rows, which legitimately have none — but a VARIANT row with a null `variantId` is a
|
|
1685
|
+
// broken invariant, not a state to work around. The row already occupies
|
|
1686
|
+
// `channel_entity_map_store_kind_external_unique` on (store, kind, externalId), so the
|
|
1687
|
+
// insert below would raise a unique violation and fail the whole item. Updating the row
|
|
1688
|
+
// we already hold is the only shape that converges.
|
|
1689
|
+
await this.db.update(channelEntityMap).set({
|
|
1690
|
+
entityId,
|
|
1691
|
+
variantId,
|
|
1692
|
+
syncHash: hash(fullSourceVariant),
|
|
1693
|
+
}).where(eq(channelEntityMap.id, mapping.id));
|
|
1694
|
+
mapping.entityId = entityId;
|
|
1695
|
+
mapping.variantId = variantId;
|
|
1696
|
+
mapping.syncHash = hash(fullSourceVariant);
|
|
1697
|
+
} else {
|
|
1698
|
+
const [createdMapping] = await this.db.insert(channelEntityMap).values({
|
|
1699
|
+
organizationId: orgId,
|
|
1700
|
+
storeId,
|
|
1701
|
+
kind: "variant",
|
|
1702
|
+
externalId: sourceVariant.externalId,
|
|
1703
|
+
entityId,
|
|
1704
|
+
variantId,
|
|
1705
|
+
syncHash: hash(fullSourceVariant),
|
|
1706
|
+
}).returning();
|
|
1707
|
+
mapping = createdMapping;
|
|
1708
|
+
// Pushed so a payload repeating this externalId resolves the row it just created rather
|
|
1709
|
+
// than creating a second variant for it.
|
|
1710
|
+
if (mapping) mappings.push(mapping);
|
|
1711
|
+
}
|
|
1412
1712
|
}
|
|
1413
1713
|
if (!variantId) {
|
|
1414
1714
|
warnings.push(`Skipped variant "${sourceVariant.externalId}": no local variant mapping exists.`);
|
|
@@ -1419,8 +1719,7 @@ export class ChannelConnectorService {
|
|
|
1419
1719
|
const desiredOptionValueIds = Object.entries(sourceVariant.optionValues ?? {})
|
|
1420
1720
|
.map(([name, value]) => optionValueIds.get(name)?.get(value))
|
|
1421
1721
|
.filter((optionValueId): optionValueId is string => optionValueId !== undefined);
|
|
1422
|
-
const
|
|
1423
|
-
const currentIds = currentOptionValues.map((row) => row.optionValueId).sort();
|
|
1722
|
+
const currentIds = [...(existingOptionValues.get(variantId) ?? [])].sort();
|
|
1424
1723
|
const desiredIds = [...new Set(desiredOptionValueIds)].sort();
|
|
1425
1724
|
if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
|
|
1426
1725
|
await this.db.delete(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
|
|
@@ -1428,6 +1727,9 @@ export class ChannelConnectorService {
|
|
|
1428
1727
|
await this.db.insert(variantOptionValues).values(desiredIds.map((optionValueId) => ({ variantId, optionValueId }))).onConflictDoNothing();
|
|
1429
1728
|
repaired += 1;
|
|
1430
1729
|
}
|
|
1730
|
+
// The map is the read model for this pass; a payload repeating an externalId must not see
|
|
1731
|
+
// the pre-fetched state after this write.
|
|
1732
|
+
existingOptionValues.set(variantId, [...desiredIds]);
|
|
1431
1733
|
changed = true;
|
|
1432
1734
|
}
|
|
1433
1735
|
if (createdVariant && desiredIds.length > 0) {
|
|
@@ -1445,15 +1747,65 @@ export class ChannelConnectorService {
|
|
|
1445
1747
|
}, actor);
|
|
1446
1748
|
if (!priced.ok) return PluginErr(priced.error.message);
|
|
1447
1749
|
}
|
|
1448
|
-
|
|
1750
|
+
// Was UNCONDITIONAL: one UPDATE per variant on every import, including a re-sync where the
|
|
1751
|
+
// variant is byte-identical. `mapping.syncHash` is already in hand from the select above, so
|
|
1752
|
+
// the comparison is free and the write disappears on an unchanged variant — which is the
|
|
1753
|
+
// ordinary case for a store that syncs repeatedly.
|
|
1754
|
+
const nextSyncHash = hash(fullSourceVariant);
|
|
1755
|
+
if (mapping && mapping.syncHash !== nextSyncHash) {
|
|
1449
1756
|
await this.db.update(channelEntityMap).set({
|
|
1450
|
-
syncHash:
|
|
1757
|
+
syncHash: nextSyncHash,
|
|
1451
1758
|
}).where(eq(channelEntityMap.id, mapping.id));
|
|
1759
|
+
mapping.syncHash = nextSyncHash;
|
|
1452
1760
|
}
|
|
1453
1761
|
}
|
|
1454
1762
|
return Ok({ value: variantIds, repaired, changed });
|
|
1455
1763
|
}
|
|
1456
1764
|
|
|
1765
|
+
/**
|
|
1766
|
+
* The organization's categories, brands and tags, read ONCE per converge run instead of once per
|
|
1767
|
+
* product.
|
|
1768
|
+
*
|
|
1769
|
+
* `applyTaxonomy` takes an `entityId` and is called unconditionally for every item, and each of
|
|
1770
|
+
* its three lookups was a whole-table read filtered by `organization_id`. Measured on the deployed
|
|
1771
|
+
* Worker: 1.0 call per product per class, three classes, every product — an organization's whole
|
|
1772
|
+
* category list re-read for each of twenty products in a batch that cannot have changed it.
|
|
1773
|
+
*
|
|
1774
|
+
* Rows created DURING the run are appended by the callers below, exactly as they were appended to
|
|
1775
|
+
* the per-product arrays before, so an item that introduces a category is still seen by the next
|
|
1776
|
+
* item. The cache is cleared at the top of `convergeCatalogItems`, so its lifetime is one converge
|
|
1777
|
+
* rather than the lifetime of the service.
|
|
1778
|
+
*
|
|
1779
|
+
* The staleness window widens from one product to one batch: a category created by ANOTHER process
|
|
1780
|
+
* mid-batch is not seen here. That was already true within a product — these lists were always a
|
|
1781
|
+
* snapshot — and the create paths below go through `this.catalog`, which refuses a duplicate slug
|
|
1782
|
+
* rather than writing one. So the failure mode is unchanged in kind and wider in window, which is
|
|
1783
|
+
* the trade this comment exists to state rather than hide.
|
|
1784
|
+
*/
|
|
1785
|
+
private taxonomyCache: {
|
|
1786
|
+
orgId: string;
|
|
1787
|
+
categories: (typeof categories.$inferSelect)[];
|
|
1788
|
+
brands: (typeof brands.$inferSelect)[];
|
|
1789
|
+
tags: (typeof tags.$inferSelect)[];
|
|
1790
|
+
} | null = null;
|
|
1791
|
+
|
|
1792
|
+
private async taxonomyFor(orgId: string): Promise<{
|
|
1793
|
+
categories: (typeof categories.$inferSelect)[];
|
|
1794
|
+
brands: (typeof brands.$inferSelect)[];
|
|
1795
|
+
tags: (typeof tags.$inferSelect)[];
|
|
1796
|
+
}> {
|
|
1797
|
+
// Keyed on orgId as well as presence: one service instance serving two organizations must not
|
|
1798
|
+
// hand the second one the first one's taxonomy.
|
|
1799
|
+
if (this.taxonomyCache?.orgId === orgId) return this.taxonomyCache;
|
|
1800
|
+
const [categoryRows, brandRows, tagRows] = await Promise.all([
|
|
1801
|
+
this.db.select().from(categories).where(eq(categories.organizationId, orgId)),
|
|
1802
|
+
this.db.select().from(brands).where(eq(brands.organizationId, orgId)),
|
|
1803
|
+
this.db.select().from(tags).where(eq(tags.organizationId, orgId)),
|
|
1804
|
+
]);
|
|
1805
|
+
this.taxonomyCache = { orgId, categories: categoryRows, brands: brandRows, tags: tagRows };
|
|
1806
|
+
return this.taxonomyCache;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1457
1809
|
private async applyTaxonomy(
|
|
1458
1810
|
orgId: string,
|
|
1459
1811
|
entityId: string,
|
|
@@ -1461,7 +1813,8 @@ export class ChannelConnectorService {
|
|
|
1461
1813
|
actor: Actor,
|
|
1462
1814
|
warnings: string[],
|
|
1463
1815
|
): Promise<PluginResult<void>> {
|
|
1464
|
-
const
|
|
1816
|
+
const taxonomy = await this.taxonomyFor(orgId);
|
|
1817
|
+
const categoryRows = taxonomy.categories;
|
|
1465
1818
|
for (const slug of new Set(item.categories ?? [])) {
|
|
1466
1819
|
let category = categoryRows.find((row) => row.slug === slug);
|
|
1467
1820
|
if (category?.status === "archived") {
|
|
@@ -1480,7 +1833,7 @@ export class ChannelConnectorService {
|
|
|
1480
1833
|
if (!linked.ok) return PluginErr(linked.error.message);
|
|
1481
1834
|
}
|
|
1482
1835
|
|
|
1483
|
-
const brandRows =
|
|
1836
|
+
const brandRows = taxonomy.brands;
|
|
1484
1837
|
if (item.brand) {
|
|
1485
1838
|
let brand = brandRows.find((row) => row.slug === item.brand);
|
|
1486
1839
|
if (!brand) {
|
|
@@ -1495,7 +1848,7 @@ export class ChannelConnectorService {
|
|
|
1495
1848
|
if (!linked.ok) return PluginErr(linked.error.message);
|
|
1496
1849
|
}
|
|
1497
1850
|
|
|
1498
|
-
const tagRows =
|
|
1851
|
+
const tagRows = taxonomy.tags;
|
|
1499
1852
|
for (const slug of new Set(item.tags ?? [])) {
|
|
1500
1853
|
let tag = tagRows.find((row) => row.slug === slug);
|
|
1501
1854
|
if (!tag) {
|
|
@@ -1730,6 +2083,65 @@ export class ChannelConnectorService {
|
|
|
1730
2083
|
return rows[0] as ConnectedStore | undefined;
|
|
1731
2084
|
}
|
|
1732
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
|
+
|
|
1733
2145
|
async getStoreByDomain(shopDomain: string): Promise<ConnectedStore | undefined> {
|
|
1734
2146
|
const rows = await this.db
|
|
1735
2147
|
.select()
|
|
@@ -2427,6 +2839,258 @@ export class ChannelConnectorService {
|
|
|
2427
2839
|
}));
|
|
2428
2840
|
}
|
|
2429
2841
|
|
|
2842
|
+
/**
|
|
2843
|
+
* One page from the connector, nothing written. The host lands the page durably (R2 + its
|
|
2844
|
+
* ledger) and hands it to `convergeCatalogPage` from a queue consumer; the two halves are
|
|
2845
|
+
* separate so a consumer retry never re-fetches the merchant's API.
|
|
2846
|
+
*/
|
|
2847
|
+
async fetchCatalogPage(
|
|
2848
|
+
orgId: string,
|
|
2849
|
+
storeId: string,
|
|
2850
|
+
cursor: string | null,
|
|
2851
|
+
): Promise<PluginResult<{ items: ChannelCatalogItem[]; nextCursor: string | null }>> {
|
|
2852
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
2853
|
+
if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
2854
|
+
const connector = this.connectors.get(store.provider);
|
|
2855
|
+
if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`);
|
|
2856
|
+
const page = await connector.importCatalog(store as ChannelStore, cursor ?? undefined);
|
|
2857
|
+
if (!page.ok) return PluginErr(page.error.message);
|
|
2858
|
+
return Ok({ items: page.value.items, nextCursor: page.value.nextCursor ?? null });
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
/**
|
|
2862
|
+
* Converges a page: items this store has never mapped take the import fast path
|
|
2863
|
+
* (`catalog.importProducts`, one transaction, multi-row writes); items already mapped and
|
|
2864
|
+
* unchanged cost nothing; items mapped-but-changed, and orphans (an entity of this store with
|
|
2865
|
+
* the item's slug but no map row), take the editor path, which owns ownership and conflicts.
|
|
2866
|
+
*
|
|
2867
|
+
* Media: only each new item's hero is fetched here, streamed under `HERO_IMAGE_BYTE_CAP`, and
|
|
2868
|
+
* linked at entity level as `primary` plus to the variants it shows. The first photo of every
|
|
2869
|
+
* other variant comes back in `deferredMedia` for the host to land later.
|
|
2870
|
+
*/
|
|
2871
|
+
async convergeCatalogPage(
|
|
2872
|
+
orgId: string,
|
|
2873
|
+
storeId: string,
|
|
2874
|
+
items: ChannelCatalogItem[],
|
|
2875
|
+
actor: Actor,
|
|
2876
|
+
): Promise<PluginResult<CatalogPageConvergence>> {
|
|
2877
|
+
const failures: CatalogConvergenceFailure[] = [];
|
|
2878
|
+
const warnings: string[] = [];
|
|
2879
|
+
const entityByExternalId = new Map<string, string>();
|
|
2880
|
+
let unchanged = 0;
|
|
2881
|
+
let updated = 0;
|
|
2882
|
+
|
|
2883
|
+
const externalIds = [...new Set(items.map((item) => item.externalId))];
|
|
2884
|
+
const mappings = externalIds.length === 0 ? [] : await this.db.select().from(channelEntityMap).where(and(
|
|
2885
|
+
eq(channelEntityMap.organizationId, orgId),
|
|
2886
|
+
eq(channelEntityMap.storeId, storeId),
|
|
2887
|
+
eq(channelEntityMap.kind, "entity"),
|
|
2888
|
+
inArray(channelEntityMap.externalId, externalIds),
|
|
2889
|
+
));
|
|
2890
|
+
const mappingByExternalId = new Map(mappings.map((row) => [row.externalId, row]));
|
|
2891
|
+
const slugFor = await this.resolveStoreSlugs(orgId, storeId, items.map((item) => item.slug));
|
|
2892
|
+
const slugs = [...new Set([...slugFor.values()].flatMap((resolved) => resolved.family))];
|
|
2893
|
+
const orphans = slugs.length === 0 ? [] : await this.db.select({ slug: sellableEntities.slug }).from(sellableEntities).where(and(
|
|
2894
|
+
eq(sellableEntities.organizationId, orgId),
|
|
2895
|
+
eq(sellableEntities.sourceStoreId, storeId),
|
|
2896
|
+
inArray(sellableEntities.slug, slugs),
|
|
2897
|
+
));
|
|
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));
|
|
2900
|
+
|
|
2901
|
+
const fresh: ChannelCatalogItem[] = [];
|
|
2902
|
+
const editor: ChannelCatalogItem[] = [];
|
|
2903
|
+
const seen = new Set<string>();
|
|
2904
|
+
for (const item of items) {
|
|
2905
|
+
if (seen.has(item.externalId)) {
|
|
2906
|
+
failures.push({ externalId: item.externalId, error: "duplicate-in-page: this externalId appears twice in the page." });
|
|
2907
|
+
continue;
|
|
2908
|
+
}
|
|
2909
|
+
seen.add(item.externalId);
|
|
2910
|
+
const mapping = mappingByExternalId.get(item.externalId);
|
|
2911
|
+
if (mapping && mapping.syncHash === hash(item)) {
|
|
2912
|
+
entityByExternalId.set(item.externalId, mapping.entityId);
|
|
2913
|
+
unchanged += 1;
|
|
2914
|
+
} else if (mapping || isOrphan(item.slug)) {
|
|
2915
|
+
editor.push(item);
|
|
2916
|
+
} else {
|
|
2917
|
+
fresh.push(item);
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
if (editor.length > 0) {
|
|
2922
|
+
const result = await this.convergeCatalogItems(orgId, storeId, editor, actor);
|
|
2923
|
+
if (!result.ok) return result;
|
|
2924
|
+
failures.push(...result.value.failures);
|
|
2925
|
+
warnings.push(...result.value.warnings);
|
|
2926
|
+
const failedIds = new Set(result.value.failures.map((failure) => failure.externalId));
|
|
2927
|
+
const survivors = editor.filter((item) => !failedIds.has(item.externalId));
|
|
2928
|
+
survivors.forEach((item, index) => {
|
|
2929
|
+
const entityId = result.value.entityIds[index];
|
|
2930
|
+
if (entityId !== undefined) entityByExternalId.set(item.externalId, entityId);
|
|
2931
|
+
});
|
|
2932
|
+
updated += survivors.length;
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
const createdItems: Array<{ item: ChannelCatalogItem; entityId: string; variantIds: Record<string, string> }> = [];
|
|
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
|
+
);
|
|
2948
|
+
if (!report.ok) return PluginErr(report.error.message, report.error.code);
|
|
2949
|
+
const collided: Array<{ item: ChannelCatalogItem; failure: CatalogConvergenceFailure }> = [];
|
|
2950
|
+
for (const [index, row] of report.value.rows.entries()) {
|
|
2951
|
+
const item = pending[index];
|
|
2952
|
+
if (!item) continue;
|
|
2953
|
+
if (row.status === "failed") {
|
|
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);
|
|
2958
|
+
continue;
|
|
2959
|
+
}
|
|
2960
|
+
warnings.push(...row.warnings);
|
|
2961
|
+
entityByExternalId.set(item.externalId, row.entityId);
|
|
2962
|
+
createdItems.push({ item, entityId: row.entityId, variantIds: row.variantIds });
|
|
2963
|
+
}
|
|
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
|
+
}
|
|
2976
|
+
}
|
|
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
|
+
}
|
|
2988
|
+
|
|
2989
|
+
const media = await this.importHeroes(orgId, createdItems, actor);
|
|
2990
|
+
const entityIds: string[] = [];
|
|
2991
|
+
const emitted = new Set<string>();
|
|
2992
|
+
for (const item of items) {
|
|
2993
|
+
const entityId = entityByExternalId.get(item.externalId);
|
|
2994
|
+
if (entityId === undefined || emitted.has(entityId)) continue;
|
|
2995
|
+
emitted.add(entityId);
|
|
2996
|
+
entityIds.push(entityId);
|
|
2997
|
+
}
|
|
2998
|
+
return Ok({
|
|
2999
|
+
created: createdItems.length,
|
|
3000
|
+
unchanged,
|
|
3001
|
+
updated,
|
|
3002
|
+
entityIds,
|
|
3003
|
+
failures,
|
|
3004
|
+
heroesImported: media.heroesImported,
|
|
3005
|
+
mediaFailures: media.mediaFailures,
|
|
3006
|
+
deferredMedia: media.deferredMedia,
|
|
3007
|
+
warnings,
|
|
3008
|
+
});
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
private async importHeroes(
|
|
3012
|
+
orgId: string,
|
|
3013
|
+
createdItems: Array<{ item: ChannelCatalogItem; entityId: string; variantIds: Record<string, string> }>,
|
|
3014
|
+
actor: Actor,
|
|
3015
|
+
): Promise<Pick<CatalogPageConvergence, "heroesImported" | "mediaFailures" | "deferredMedia">> {
|
|
3016
|
+
const mediaFailures: CatalogMediaFailure[] = [];
|
|
3017
|
+
const deferredMedia: CatalogDeferredMedia[] = [];
|
|
3018
|
+
const selections = createdItems.flatMap(({ item, entityId, variantIds }) => {
|
|
3019
|
+
const selection = selectImportImages(item);
|
|
3020
|
+
if (selection.perVariant.length > 0) deferredMedia.push({ externalId: item.externalId, entityId, images: selection.perVariant });
|
|
3021
|
+
return selection.hero ? [{ item, entityId, variantIds, hero: selection.hero }] : [];
|
|
3022
|
+
});
|
|
3023
|
+
if (selections.length === 0) return { heroesImported: 0, mediaFailures, deferredMedia };
|
|
3024
|
+
|
|
3025
|
+
// One read for every hero the page might already hold (a re-import after the map was lost).
|
|
3026
|
+
const urlHashes = [...new Set(selections.map(({ hero }) => hash(hero.url)))];
|
|
3027
|
+
const existingAssets = await this.db.select({ id: mediaAssets.id, metadata: mediaAssets.metadata }).from(mediaAssets).where(and(
|
|
3028
|
+
eq(mediaAssets.organizationId, orgId),
|
|
3029
|
+
inArray(sql`${mediaAssets.metadata}->>'channelImageUrlHash'`, urlHashes),
|
|
3030
|
+
));
|
|
3031
|
+
const assetByUrlHash = new Map<string, string>();
|
|
3032
|
+
for (const asset of existingAssets) {
|
|
3033
|
+
const urlHash = asset.metadata?.channelImageUrlHash;
|
|
3034
|
+
if (typeof urlHash === "string") assetByUrlHash.set(urlHash, asset.id);
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
type HeroOutcome = { entityId: string; mediaAssetId: string; hero: ChannelCatalogImage; variantIds: Record<string, string>; imported: boolean };
|
|
3038
|
+
const outcomes: HeroOutcome[] = [];
|
|
3039
|
+
const resolveHero = async ({ item, entityId, variantIds, hero }: (typeof selections)[number]): Promise<void> => {
|
|
3040
|
+
const urlHash = hash(hero.url);
|
|
3041
|
+
const existing = assetByUrlHash.get(urlHash);
|
|
3042
|
+
if (existing !== undefined) {
|
|
3043
|
+
outcomes.push({ entityId, mediaAssetId: existing, hero, variantIds, imported: false });
|
|
3044
|
+
return;
|
|
3045
|
+
}
|
|
3046
|
+
const fetched = await fetchBounded(hero.url, HERO_IMAGE_BYTE_CAP);
|
|
3047
|
+
const failure = (reason: CatalogMediaFailureReason, detail: string): void => {
|
|
3048
|
+
mediaFailures.push({ externalId: item.externalId, ...(hero.externalId !== undefined ? { imageExternalId: hero.externalId } : {}), url: hero.url, reason, detail });
|
|
3049
|
+
};
|
|
3050
|
+
if (!fetched.ok) {
|
|
3051
|
+
failure(fetched.reason, fetched.detail);
|
|
3052
|
+
return;
|
|
3053
|
+
}
|
|
3054
|
+
const extension = fetched.contentType.split("/", 2)[1] ?? "jpg";
|
|
3055
|
+
const uploaded = await this.media.upload({
|
|
3056
|
+
filename: `${hero.externalId ?? urlHash}.${extension}`,
|
|
3057
|
+
contentType: fetched.contentType,
|
|
3058
|
+
data: fetched.bytes.buffer,
|
|
3059
|
+
...(hero.alt !== undefined ? { alt: hero.alt } : {}),
|
|
3060
|
+
metadata: { channelImageUrlHash: urlHash, ...(hero.externalId !== undefined ? { channelImageExternalId: hero.externalId } : {}) },
|
|
3061
|
+
origin: "imported",
|
|
3062
|
+
}, actor);
|
|
3063
|
+
if (!uploaded.ok) {
|
|
3064
|
+
failure(uploaded.error.code === "VALIDATION_FAILED" ? "unsupported" : "storage", uploaded.error.message);
|
|
3065
|
+
return;
|
|
3066
|
+
}
|
|
3067
|
+
assetByUrlHash.set(urlHash, uploaded.value.id);
|
|
3068
|
+
outcomes.push({ entityId, mediaAssetId: uploaded.value.id, hero, variantIds, imported: true });
|
|
3069
|
+
};
|
|
3070
|
+
// Six outbound connections per Worker invocation, two per image (download + storage put).
|
|
3071
|
+
const MAX_IN_FLIGHT = 3;
|
|
3072
|
+
let next = 0;
|
|
3073
|
+
await Promise.all(Array.from({ length: Math.min(MAX_IN_FLIGHT, selections.length) }, async () => {
|
|
3074
|
+
for (;;) {
|
|
3075
|
+
const selection = selections[next];
|
|
3076
|
+
next += 1;
|
|
3077
|
+
if (selection === undefined) return;
|
|
3078
|
+
await resolveHero(selection);
|
|
3079
|
+
}
|
|
3080
|
+
}));
|
|
3081
|
+
|
|
3082
|
+
if (outcomes.length > 0) {
|
|
3083
|
+
await this.db.insert(entityMedia).values(outcomes.flatMap(({ entityId, mediaAssetId, hero, variantIds }) => [
|
|
3084
|
+
{ entityId, mediaAssetId, role: "primary" as const, sortOrder: hero.sortOrder ?? 0 },
|
|
3085
|
+
...(hero.variantExternalIds ?? []).flatMap((externalId) => {
|
|
3086
|
+
const variantId = variantIds[externalId];
|
|
3087
|
+
return variantId === undefined ? [] : [{ entityId, variantId, mediaAssetId, role: hero.role, sortOrder: hero.sortOrder ?? 0 }];
|
|
3088
|
+
}),
|
|
3089
|
+
])).onConflictDoNothing();
|
|
3090
|
+
}
|
|
3091
|
+
return { heroesImported: outcomes.filter((outcome) => outcome.imported).length, mediaFailures, deferredMedia };
|
|
3092
|
+
}
|
|
3093
|
+
|
|
2430
3094
|
async importCatalog(
|
|
2431
3095
|
orgId: string,
|
|
2432
3096
|
storeId: string,
|
|
@@ -2926,6 +3590,10 @@ export class ChannelConnectorService {
|
|
|
2926
3590
|
dryRun = false,
|
|
2927
3591
|
): Promise<PluginResult<CatalogConvergenceStats>> {
|
|
2928
3592
|
if (dryRun) return this.estimateCatalogItems(orgId, storeId, items);
|
|
3593
|
+
// One converge, one taxonomy snapshot. Cleared HERE rather than left to the service's lifetime:
|
|
3594
|
+
// the instance can outlive a batch on a warm isolate, and a taxonomy cached across batches would
|
|
3595
|
+
// go stale in a way nothing reports.
|
|
3596
|
+
this.taxonomyCache = null;
|
|
2929
3597
|
let imported = 0;
|
|
2930
3598
|
let converged = 0;
|
|
2931
3599
|
let entitiesTouched = 0;
|
|
@@ -2939,6 +3607,8 @@ export class ChannelConnectorService {
|
|
|
2939
3607
|
const failures: CatalogConvergenceFailure[] = [];
|
|
2940
3608
|
const entityIds: string[] = [];
|
|
2941
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));
|
|
2942
3612
|
for (const item of items) {
|
|
2943
3613
|
consumed += 1;
|
|
2944
3614
|
try {
|
|
@@ -2975,43 +3645,61 @@ export class ChannelConnectorService {
|
|
|
2975
3645
|
existingEntity = entity;
|
|
2976
3646
|
}
|
|
2977
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
|
+
};
|
|
2978
3655
|
if (entityId === undefined) {
|
|
2979
3656
|
const [orphan] = await this.db.select().from(sellableEntities).where(and(
|
|
2980
3657
|
eq(sellableEntities.organizationId, orgId),
|
|
2981
3658
|
eq(sellableEntities.sourceStoreId, storeId),
|
|
2982
|
-
|
|
2983
|
-
));
|
|
3659
|
+
inArray(sellableEntities.slug, resolvedSlug.family),
|
|
3660
|
+
)).limit(1);
|
|
2984
3661
|
if (orphan) {
|
|
2985
3662
|
entityId = orphan.id;
|
|
2986
3663
|
existingEntity = orphan;
|
|
2987
3664
|
adoptedOrphan = true;
|
|
2988
3665
|
} else {
|
|
2989
3666
|
const status = item.status;
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
forcedPushFieldPaths: [],
|
|
3010
|
-
});
|
|
3011
|
-
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: [],
|
|
3012
3686
|
});
|
|
3013
|
-
|
|
3014
|
-
|
|
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." });
|
|
3015
3703
|
continue;
|
|
3016
3704
|
}
|
|
3017
3705
|
isNew = true;
|
|
@@ -3071,8 +3759,9 @@ export class ChannelConnectorService {
|
|
|
3071
3759
|
status?: string;
|
|
3072
3760
|
isVisible?: boolean;
|
|
3073
3761
|
} = {};
|
|
3074
|
-
|
|
3075
|
-
|
|
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;
|
|
3076
3765
|
}
|
|
3077
3766
|
if (hash(remoteMetadata) !== hash(existingEntity.metadata ?? {})) updateInput.metadata = remoteMetadata;
|
|
3078
3767
|
if (remoteStatus !== undefined && !blockedPaths.has("entity.status") && remoteStatus !== existingEntity.status) {
|
|
@@ -3083,10 +3772,25 @@ export class ChannelConnectorService {
|
|
|
3083
3772
|
? Object.keys(updateInput).length > 0
|
|
3084
3773
|
: remoteChanged || existingEntity.status === "archived";
|
|
3085
3774
|
if (shouldUpdate) {
|
|
3086
|
-
converged += 1;
|
|
3087
3775
|
if (Object.keys(updateInput).length > 0) {
|
|
3088
|
-
const
|
|
3089
|
-
|
|
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; }
|
|
3090
3794
|
entityTouched = true;
|
|
3091
3795
|
}
|
|
3092
3796
|
}
|
|
@@ -3107,7 +3811,9 @@ export class ChannelConnectorService {
|
|
|
3107
3811
|
!heldSharedPaths.includes("options") && owners.get("options") !== "platform",
|
|
3108
3812
|
item,
|
|
3109
3813
|
);
|
|
3110
|
-
|
|
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; }
|
|
3111
3817
|
const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
|
|
3112
3818
|
if (!taxonomy.ok) return taxonomy;
|
|
3113
3819
|
const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
|
|
@@ -3118,6 +3824,9 @@ export class ChannelConnectorService {
|
|
|
3118
3824
|
skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
|
|
3119
3825
|
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
|
|
3120
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;
|
|
3121
3830
|
|
|
3122
3831
|
if (entityTouched) {
|
|
3123
3832
|
const revision = await this.catalog.recordEntityRevision(entityId, actor, "import");
|
|
@@ -3253,11 +3962,13 @@ export class ChannelConnectorService {
|
|
|
3253
3962
|
const mapping = mappings.find((entry) => entry.externalId === level.externalId);
|
|
3254
3963
|
if (!mapping) continue;
|
|
3255
3964
|
const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
|
|
3256
|
-
|
|
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;
|
|
3257
3968
|
const result = await inventoryService.setAbsolute({
|
|
3258
3969
|
entityId: mapping.entityId,
|
|
3259
3970
|
...(mapping.variantId ? { variantId: mapping.variantId } : {}),
|
|
3260
|
-
quantity
|
|
3971
|
+
quantity,
|
|
3261
3972
|
reason: `Inventory reconciliation from ${store.provider}`,
|
|
3262
3973
|
}, actor);
|
|
3263
3974
|
if (!result.ok) return PluginErr(result.error?.message ?? "Inventory reconciliation failed.");
|
|
@@ -3496,11 +4207,13 @@ export class ChannelConnectorService {
|
|
|
3496
4207
|
const mapping = mappings.find((entry) => entry.externalId === level.externalId);
|
|
3497
4208
|
if (!mapping) continue;
|
|
3498
4209
|
const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
|
|
3499
|
-
|
|
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;
|
|
3500
4213
|
const result = await inventoryService.setAbsolute({
|
|
3501
4214
|
entityId: mapping.entityId,
|
|
3502
4215
|
...(mapping.variantId ? { variantId: mapping.variantId } : {}),
|
|
3503
|
-
quantity
|
|
4216
|
+
quantity,
|
|
3504
4217
|
reason: `Inventory sync from ${store.provider}`,
|
|
3505
4218
|
}, actor);
|
|
3506
4219
|
if (!result.ok) return PluginErr(result.error?.message ?? "Inventory sync failed.");
|
|
@@ -3780,8 +4493,10 @@ export class ChannelConnectorService {
|
|
|
3780
4493
|
status?: string;
|
|
3781
4494
|
isVisible?: boolean;
|
|
3782
4495
|
} = {};
|
|
3783
|
-
if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") &&
|
|
3784
|
-
|
|
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;
|
|
3785
4500
|
}
|
|
3786
4501
|
if (Object.keys(writable.metadata ?? {}).length > 0) {
|
|
3787
4502
|
const remoteEntityMetadata = mergeMetadata(entity.metadata, writable.metadata ?? {});
|