@porulle/plugin-channel-connector 0.48.0 → 0.49.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 +10 -1
- package/dist/service.d.ts +89 -1
- package/dist/service.js +503 -37
- package/package.json +4 -4
- package/src/index.ts +18 -0
- package/src/service.ts +581 -34
- package/dist/tsconfig.tsbuildinfo +0 -1
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,
|
|
@@ -891,6 +900,167 @@ function redactStore(store: ConnectedStore): PublicConnectedStore {
|
|
|
891
900
|
};
|
|
892
901
|
}
|
|
893
902
|
|
|
903
|
+
/**
|
|
904
|
+
* A hero is streamed inside the page's own invocation, so it is bounded: a 30 MB TIFF a merchant
|
|
905
|
+
* uploaded by mistake must not buffer into a 128 MiB isolate. Anything larger is reported, not
|
|
906
|
+
* stored, and the product still lands — the index reads text first and media later.
|
|
907
|
+
*/
|
|
908
|
+
export const HERO_IMAGE_BYTE_CAP = 1024 * 1024;
|
|
909
|
+
|
|
910
|
+
export type CatalogMediaFailureReason = "too-large" | "download-failed" | "unsupported" | "storage";
|
|
911
|
+
|
|
912
|
+
export interface CatalogMediaFailure {
|
|
913
|
+
externalId: string;
|
|
914
|
+
imageExternalId?: string;
|
|
915
|
+
url: string;
|
|
916
|
+
reason: CatalogMediaFailureReason;
|
|
917
|
+
detail: string;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/** Media the page did NOT fetch: the first photo of each variant the hero does not show. */
|
|
921
|
+
export interface CatalogDeferredMedia {
|
|
922
|
+
externalId: string;
|
|
923
|
+
entityId: string;
|
|
924
|
+
images: ChannelCatalogImage[];
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
export interface CatalogPageConvergence extends Record<string, unknown> {
|
|
928
|
+
created: number;
|
|
929
|
+
unchanged: number;
|
|
930
|
+
updated: number;
|
|
931
|
+
/** Input order, failures excluded, no duplicates — the page message is rebuilt from this. */
|
|
932
|
+
entityIds: string[];
|
|
933
|
+
failures: CatalogConvergenceFailure[];
|
|
934
|
+
heroesImported: number;
|
|
935
|
+
mediaFailures: CatalogMediaFailure[];
|
|
936
|
+
deferredMedia: CatalogDeferredMedia[];
|
|
937
|
+
warnings: string[];
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
export interface ImportImageSelection {
|
|
941
|
+
hero: ChannelCatalogImage | null;
|
|
942
|
+
/** In variant order; one image per variant the hero does not cover; no url twice. */
|
|
943
|
+
perVariant: ChannelCatalogImage[];
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function imageOrder(a: ChannelCatalogImage, b: ChannelCatalogImage): number {
|
|
947
|
+
return (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Ruling 2026-09-22: import the hero plus the FIRST photo of each other variant, nothing more.
|
|
952
|
+
* A "blue long dress" query must be able to show the blue variant, and a fourth photo of the red
|
|
953
|
+
* one adds nothing the index can use. Variants are read off the images' own variant references,
|
|
954
|
+
* so a connector that lists images against variants it does not enumerate still gets one each.
|
|
955
|
+
*/
|
|
956
|
+
export function selectImportImages(item: ChannelCatalogItem): ImportImageSelection {
|
|
957
|
+
const images = [...(item.images ?? [])].sort(imageOrder);
|
|
958
|
+
const hero = images.find((image) => image.role === "primary") ?? images[0] ?? null;
|
|
959
|
+
if (!hero) return { hero: null, perVariant: [] };
|
|
960
|
+
const covered = new Set(hero.variantExternalIds ?? []);
|
|
961
|
+
const usedUrls = new Set([hero.url]);
|
|
962
|
+
const perVariant: ChannelCatalogImage[] = [];
|
|
963
|
+
const variantRefs = [...new Set(images.flatMap((image) => image.variantExternalIds ?? []))];
|
|
964
|
+
for (const ref of variantRefs) {
|
|
965
|
+
if (covered.has(ref)) continue;
|
|
966
|
+
const image = images.find((candidate) => candidate.variantExternalIds?.includes(ref) && !usedUrls.has(candidate.url));
|
|
967
|
+
for (const shown of image?.variantExternalIds ?? []) covered.add(shown);
|
|
968
|
+
covered.add(ref);
|
|
969
|
+
if (!image) continue;
|
|
970
|
+
usedUrls.add(image.url);
|
|
971
|
+
perVariant.push(image);
|
|
972
|
+
}
|
|
973
|
+
return { hero, perVariant };
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
type BoundedFetch =
|
|
977
|
+
| { ok: true; bytes: Uint8Array<ArrayBuffer>; contentType: string }
|
|
978
|
+
| { ok: false; reason: CatalogMediaFailureReason; detail: string };
|
|
979
|
+
|
|
980
|
+
/** Streams a response and refuses mid-stream past `cap`; a lying `content-length` cannot get around it. */
|
|
981
|
+
async function fetchBounded(url: string, cap: number): Promise<BoundedFetch> {
|
|
982
|
+
let response: Response;
|
|
983
|
+
try {
|
|
984
|
+
response = await fetch(url);
|
|
985
|
+
} catch (error) {
|
|
986
|
+
return { ok: false, reason: "download-failed", detail: error instanceof Error ? error.message : "download failed" };
|
|
987
|
+
}
|
|
988
|
+
if (!response.ok) return { ok: false, reason: "download-failed", detail: `download returned ${response.status}` };
|
|
989
|
+
const declared = Number(response.headers.get("content-length"));
|
|
990
|
+
if (Number.isFinite(declared) && declared > cap) return { ok: false, reason: "too-large", detail: `content-length ${declared} exceeds ${cap}` };
|
|
991
|
+
const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim() || "image/jpeg";
|
|
992
|
+
const reader = response.body?.getReader();
|
|
993
|
+
if (!reader) {
|
|
994
|
+
const buffer = await response.arrayBuffer();
|
|
995
|
+
if (buffer.byteLength > cap) return { ok: false, reason: "too-large", detail: `${buffer.byteLength} bytes exceeds ${cap}` };
|
|
996
|
+
return { ok: true, bytes: new Uint8Array(buffer), contentType };
|
|
997
|
+
}
|
|
998
|
+
const chunks: Uint8Array[] = [];
|
|
999
|
+
let total = 0;
|
|
1000
|
+
for (;;) {
|
|
1001
|
+
const { done, value } = await reader.read();
|
|
1002
|
+
if (done) break;
|
|
1003
|
+
total += value.byteLength;
|
|
1004
|
+
if (total > cap) {
|
|
1005
|
+
await reader.cancel();
|
|
1006
|
+
return { ok: false, reason: "too-large", detail: `stream exceeded ${cap} bytes` };
|
|
1007
|
+
}
|
|
1008
|
+
chunks.push(value);
|
|
1009
|
+
}
|
|
1010
|
+
const bytes = new Uint8Array(total);
|
|
1011
|
+
let offset = 0;
|
|
1012
|
+
for (const chunk of chunks) {
|
|
1013
|
+
bytes.set(chunk, offset);
|
|
1014
|
+
offset += chunk.byteLength;
|
|
1015
|
+
}
|
|
1016
|
+
return { ok: true, bytes, contentType };
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function toImportProduct(item: ChannelCatalogItem): ImportProduct {
|
|
1020
|
+
const attributes = item.attributes?.length
|
|
1021
|
+
? item.attributes
|
|
1022
|
+
: [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
|
|
1023
|
+
return {
|
|
1024
|
+
ref: item.externalId,
|
|
1025
|
+
slug: item.slug,
|
|
1026
|
+
...(item.status !== undefined ? { status: item.status, isVisible: item.status === "active" } : {}),
|
|
1027
|
+
metadata: mergeMetadata(undefined, item.metadata ?? {}),
|
|
1028
|
+
attributes: attributes.map((attribute) => ({
|
|
1029
|
+
locale: attribute.locale,
|
|
1030
|
+
title: attribute.title,
|
|
1031
|
+
...(attribute.subtitle !== undefined ? { subtitle: attribute.subtitle } : {}),
|
|
1032
|
+
...(attribute.description !== undefined ? { description: attribute.description } : {}),
|
|
1033
|
+
...(attribute.richDescription !== undefined ? { richDescription: attribute.richDescription } : {}),
|
|
1034
|
+
...(attribute.seoTitle !== undefined ? { seoTitle: attribute.seoTitle } : {}),
|
|
1035
|
+
...(attribute.seoDescription !== undefined ? { seoDescription: attribute.seoDescription } : {}),
|
|
1036
|
+
})),
|
|
1037
|
+
...(item.options !== undefined ? {
|
|
1038
|
+
options: item.options.map((option) => ({
|
|
1039
|
+
name: option.name,
|
|
1040
|
+
displayName: option.displayName,
|
|
1041
|
+
...(option.sortOrder !== undefined ? { sortOrder: option.sortOrder } : {}),
|
|
1042
|
+
values: option.values.map((value) => ({
|
|
1043
|
+
value: value.value,
|
|
1044
|
+
displayValue: value.displayValue,
|
|
1045
|
+
...(value.sortOrder !== undefined ? { sortOrder: value.sortOrder } : {}),
|
|
1046
|
+
})),
|
|
1047
|
+
})),
|
|
1048
|
+
} : {}),
|
|
1049
|
+
variants: item.variants.map((variant) => ({
|
|
1050
|
+
ref: variant.externalId,
|
|
1051
|
+
...(variant.sku !== undefined ? { sku: variant.sku } : {}),
|
|
1052
|
+
...(variant.barcode !== undefined ? { barcode: variant.barcode } : {}),
|
|
1053
|
+
...(variant.optionValues !== undefined ? { options: variant.optionValues } : {}),
|
|
1054
|
+
...(variant.prices !== undefined ? { prices: variant.prices } : {}),
|
|
1055
|
+
...(variant.metadata !== undefined ? { metadata: variant.metadata } : {}),
|
|
1056
|
+
})),
|
|
1057
|
+
...(item.tags !== undefined ? { tags: item.tags } : {}),
|
|
1058
|
+
...(item.brand !== undefined ? { brand: item.brand } : {}),
|
|
1059
|
+
...(item.categories !== undefined ? { categories: item.categories } : {}),
|
|
1060
|
+
ownedFieldPaths: importedFieldPaths(item),
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
|
|
894
1064
|
export class ChannelConnectorService {
|
|
895
1065
|
private readonly connectors = new Map<string, ChannelConnector>();
|
|
896
1066
|
private readonly transact: PluginTxFn;
|
|
@@ -1313,41 +1483,77 @@ export class ChannelConnectorService {
|
|
|
1313
1483
|
actor: Actor,
|
|
1314
1484
|
): Promise<PluginResult<{ value: Map<string, Map<string, string>>; changed: boolean }>> {
|
|
1315
1485
|
const optionValueIds = new Map<string, Map<string, string>>();
|
|
1316
|
-
|
|
1486
|
+
// PROJECTED, not `select()`. Three reasons, and the third is the one that saves statements:
|
|
1487
|
+
// the row's other columns are never read; a projection types the locally-constructed row below
|
|
1488
|
+
// without a cast; and carrying `displayName`/`sortOrder` is what lets the update be SKIPPED when
|
|
1489
|
+
// they already hold. A re-import that changes nothing is the common case for a sync, and it used
|
|
1490
|
+
// to issue one UPDATE per option type and one per option value regardless.
|
|
1491
|
+
const existingTypes: { id: string; name: string; displayName: string | null; sortOrder: number | null }[] =
|
|
1492
|
+
await this.db
|
|
1493
|
+
.select({ id: optionTypes.id, name: optionTypes.name, displayName: optionTypes.displayName, sortOrder: optionTypes.sortOrder })
|
|
1494
|
+
.from(optionTypes)
|
|
1495
|
+
.where(eq(optionTypes.entityId, entityId));
|
|
1317
1496
|
let changed = false;
|
|
1318
1497
|
for (const [typeIndex, sourceType] of (item.options ?? []).entries()) {
|
|
1319
1498
|
let optionType = existingTypes.find((row) => row.name === sourceType.name);
|
|
1320
1499
|
if (!optionType) {
|
|
1321
1500
|
const created = await this.catalog.createOptionType({ entityId, name: sourceType.name, values: [] }, actor);
|
|
1322
1501
|
if (!created.ok) return PluginErr(created.error.message);
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1502
|
+
// The created row is CONSTRUCTED rather than read back: `createOptionType` returns the id and
|
|
1503
|
+
// the name is what we just sent.
|
|
1504
|
+
//
|
|
1505
|
+
// The two nulls are DELIBERATE PLACEHOLDERS, not a claim about the database. `createOptionType`
|
|
1506
|
+
// actually persists `displayName: input.name, sortOrder: 0` (core entity-service.ts:595) and
|
|
1507
|
+
// `display_name` is NOT NULL (core catalog/schema.ts:307) — so a fresh row never holds null.
|
|
1508
|
+
// Constructing nulls here makes the comparison below unequal on any input, which is what forces
|
|
1509
|
+
// the update that writes the caller's real `displayName`/`sortOrder` over those defaults.
|
|
1510
|
+
optionType = { id: created.value.id, name: sourceType.name, displayName: null, sortOrder: null };
|
|
1326
1511
|
existingTypes.push(optionType);
|
|
1327
1512
|
changed = true;
|
|
1328
1513
|
}
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1514
|
+
const desiredTypeSort = sourceType.sortOrder ?? typeIndex;
|
|
1515
|
+
// `?? null` NORMALISES, and it is load-bearing rather than tidy. The stored value is `null` or a
|
|
1516
|
+
// string; a connector that omits `displayName` sends `undefined`, and `null !== undefined` is
|
|
1517
|
+
// true — so without this every such connector issued one UPDATE per option type on every sync
|
|
1518
|
+
// and the conditional bought it nothing. Latent on today's corpus, whose connector always sends
|
|
1519
|
+
// both fields.
|
|
1520
|
+
const desiredTypeDisplay = sourceType.displayName ?? null;
|
|
1521
|
+
if (optionType.displayName !== desiredTypeDisplay || optionType.sortOrder !== desiredTypeSort) {
|
|
1522
|
+
await this.db.update(optionTypes).set({
|
|
1523
|
+
displayName: sourceType.displayName,
|
|
1524
|
+
sortOrder: desiredTypeSort,
|
|
1525
|
+
}).where(eq(optionTypes.id, optionType.id));
|
|
1526
|
+
optionType.displayName = desiredTypeDisplay;
|
|
1527
|
+
optionType.sortOrder = desiredTypeSort;
|
|
1528
|
+
}
|
|
1333
1529
|
|
|
1334
|
-
const existingValues
|
|
1530
|
+
const existingValues: { id: string; value: string; displayValue: string | null; sortOrder: number | null }[] =
|
|
1531
|
+
await this.db
|
|
1532
|
+
.select({ id: optionValues.id, value: optionValues.value, displayValue: optionValues.displayValue, sortOrder: optionValues.sortOrder })
|
|
1533
|
+
.from(optionValues)
|
|
1534
|
+
.where(eq(optionValues.optionTypeId, optionType.id));
|
|
1335
1535
|
const valueIds = new Map<string, string>();
|
|
1336
1536
|
for (const [valueIndex, sourceValue] of sourceType.values.entries()) {
|
|
1337
1537
|
let optionValue = existingValues.find((row) => row.value === sourceValue.value);
|
|
1338
1538
|
if (!optionValue) {
|
|
1339
1539
|
const created = await this.catalog.createOptionValue({ optionTypeId: optionType.id, value: sourceValue.value }, actor);
|
|
1340
1540
|
if (!created.ok) return PluginErr(created.error.message);
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
optionValue =
|
|
1541
|
+
// Same placeholder reasoning as the option type above: core persists `displayValue: input.value,
|
|
1542
|
+
// sortOrder: 0` (entity-service.ts:615), and the nulls force the update that overwrites them.
|
|
1543
|
+
optionValue = { id: created.value.id, value: sourceValue.value, displayValue: null, sortOrder: null };
|
|
1344
1544
|
existingValues.push(optionValue);
|
|
1345
1545
|
changed = true;
|
|
1346
1546
|
}
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1547
|
+
const desiredValueSort = sourceValue.sortOrder ?? valueIndex;
|
|
1548
|
+
const desiredValueDisplay = sourceValue.displayValue ?? null;
|
|
1549
|
+
if (optionValue.displayValue !== desiredValueDisplay || optionValue.sortOrder !== desiredValueSort) {
|
|
1550
|
+
await this.db.update(optionValues).set({
|
|
1551
|
+
displayValue: sourceValue.displayValue,
|
|
1552
|
+
sortOrder: desiredValueSort,
|
|
1553
|
+
}).where(eq(optionValues.id, optionValue.id));
|
|
1554
|
+
optionValue.displayValue = desiredValueDisplay;
|
|
1555
|
+
optionValue.sortOrder = desiredValueSort;
|
|
1556
|
+
}
|
|
1351
1557
|
valueIds.set(sourceValue.value, optionValue.id);
|
|
1352
1558
|
}
|
|
1353
1559
|
optionValueIds.set(sourceType.name, valueIds);
|
|
@@ -1375,10 +1581,52 @@ export class ChannelConnectorService {
|
|
|
1375
1581
|
eq(channelEntityMap.kind, "variant"),
|
|
1376
1582
|
eq(channelEntityMap.entityId, entityId),
|
|
1377
1583
|
));
|
|
1584
|
+
// ONE read of the existing option-value rows for every already-mapped variant, instead of one
|
|
1585
|
+
// per variant inside the loop. Measured on the deployed Worker at 17.0 calls per product, which
|
|
1586
|
+
// is one per offer on a catalogue averaging 13 offers per product. A variant CREATED below is
|
|
1587
|
+
// absent from this map and correctly reads as empty: its rows are written in this same pass.
|
|
1588
|
+
const mappedVariantIds = mappings
|
|
1589
|
+
.map((row) => row.variantId)
|
|
1590
|
+
.filter((variantId): variantId is string => variantId !== null);
|
|
1591
|
+
const existingOptionValues = new Map<string, string[]>();
|
|
1592
|
+
if (mappedVariantIds.length > 0) {
|
|
1593
|
+
const rows = await this.db
|
|
1594
|
+
.select({ variantId: variantOptionValues.variantId, optionValueId: variantOptionValues.optionValueId })
|
|
1595
|
+
.from(variantOptionValues)
|
|
1596
|
+
.where(inArray(variantOptionValues.variantId, mappedVariantIds));
|
|
1597
|
+
for (const row of rows) {
|
|
1598
|
+
const list = existingOptionValues.get(row.variantId) ?? [];
|
|
1599
|
+
list.push(row.optionValueId);
|
|
1600
|
+
existingOptionValues.set(row.variantId, list);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1378
1603
|
for (const sourceVariant of item.variants) {
|
|
1379
1604
|
const fullSourceVariant = fullItem.variants.find((variant) => variant.externalId === sourceVariant.externalId) ?? sourceVariant;
|
|
1380
1605
|
let mapping = mappings.find((row) => row.externalId === sourceVariant.externalId);
|
|
1381
1606
|
let variantId = mapping?.variantId;
|
|
1607
|
+
// ADOPT BEFORE CREATE. A variant-kind mapping row whose `variantId` is null has lost its link
|
|
1608
|
+
// — the key survived, the target did not. Creating a replacement is what the loop used to do,
|
|
1609
|
+
// and it cannot work: the orphaned variant still holds the sku, so `variants_native_org_sku_unique`
|
|
1610
|
+
// refuses the insert and the item fails on this and every later sync. The row can never heal.
|
|
1611
|
+
//
|
|
1612
|
+
// `sku` is the store's own natural key for a variant, so re-resolving by it is what restores
|
|
1613
|
+
// the link the null destroyed. Scoped to this entity because a sku is unique per organization
|
|
1614
|
+
// and adopting another entity's variant would be worse than failing.
|
|
1615
|
+
if (mapping && !variantId && sourceVariant.sku) {
|
|
1616
|
+
const [adopted] = await this.db
|
|
1617
|
+
.select({ id: variants.id })
|
|
1618
|
+
.from(variants)
|
|
1619
|
+
.where(and(eq(variants.entityId, entityId), eq(variants.sku, sourceVariant.sku)))
|
|
1620
|
+
.limit(1);
|
|
1621
|
+
if (adopted) {
|
|
1622
|
+
variantId = adopted.id;
|
|
1623
|
+
// Write the link back, or the row stays broken and every later sync pays this lookup again.
|
|
1624
|
+
await this.db.update(channelEntityMap)
|
|
1625
|
+
.set({ variantId })
|
|
1626
|
+
.where(eq(channelEntityMap.id, mapping.id));
|
|
1627
|
+
mapping.variantId = variantId;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1382
1630
|
const createdVariant = !variantId;
|
|
1383
1631
|
if (!variantId) {
|
|
1384
1632
|
const options: Record<string, string> = {};
|
|
@@ -1398,17 +1646,36 @@ export class ChannelConnectorService {
|
|
|
1398
1646
|
}, actor);
|
|
1399
1647
|
if (!created.ok) return PluginErr(created.error.message);
|
|
1400
1648
|
variantId = created.value.id;
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1649
|
+
if (mapping) {
|
|
1650
|
+
// REPAIR IN PLACE. `variantId` is nullable because this table also holds `kind: "entity"`
|
|
1651
|
+
// rows, which legitimately have none — but a VARIANT row with a null `variantId` is a
|
|
1652
|
+
// broken invariant, not a state to work around. The row already occupies
|
|
1653
|
+
// `channel_entity_map_store_kind_external_unique` on (store, kind, externalId), so the
|
|
1654
|
+
// insert below would raise a unique violation and fail the whole item. Updating the row
|
|
1655
|
+
// we already hold is the only shape that converges.
|
|
1656
|
+
await this.db.update(channelEntityMap).set({
|
|
1657
|
+
entityId,
|
|
1658
|
+
variantId,
|
|
1659
|
+
syncHash: hash(fullSourceVariant),
|
|
1660
|
+
}).where(eq(channelEntityMap.id, mapping.id));
|
|
1661
|
+
mapping.entityId = entityId;
|
|
1662
|
+
mapping.variantId = variantId;
|
|
1663
|
+
mapping.syncHash = hash(fullSourceVariant);
|
|
1664
|
+
} else {
|
|
1665
|
+
const [createdMapping] = await this.db.insert(channelEntityMap).values({
|
|
1666
|
+
organizationId: orgId,
|
|
1667
|
+
storeId,
|
|
1668
|
+
kind: "variant",
|
|
1669
|
+
externalId: sourceVariant.externalId,
|
|
1670
|
+
entityId,
|
|
1671
|
+
variantId,
|
|
1672
|
+
syncHash: hash(fullSourceVariant),
|
|
1673
|
+
}).returning();
|
|
1674
|
+
mapping = createdMapping;
|
|
1675
|
+
// Pushed so a payload repeating this externalId resolves the row it just created rather
|
|
1676
|
+
// than creating a second variant for it.
|
|
1677
|
+
if (mapping) mappings.push(mapping);
|
|
1678
|
+
}
|
|
1412
1679
|
}
|
|
1413
1680
|
if (!variantId) {
|
|
1414
1681
|
warnings.push(`Skipped variant "${sourceVariant.externalId}": no local variant mapping exists.`);
|
|
@@ -1419,8 +1686,7 @@ export class ChannelConnectorService {
|
|
|
1419
1686
|
const desiredOptionValueIds = Object.entries(sourceVariant.optionValues ?? {})
|
|
1420
1687
|
.map(([name, value]) => optionValueIds.get(name)?.get(value))
|
|
1421
1688
|
.filter((optionValueId): optionValueId is string => optionValueId !== undefined);
|
|
1422
|
-
const
|
|
1423
|
-
const currentIds = currentOptionValues.map((row) => row.optionValueId).sort();
|
|
1689
|
+
const currentIds = [...(existingOptionValues.get(variantId) ?? [])].sort();
|
|
1424
1690
|
const desiredIds = [...new Set(desiredOptionValueIds)].sort();
|
|
1425
1691
|
if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
|
|
1426
1692
|
await this.db.delete(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
|
|
@@ -1428,6 +1694,9 @@ export class ChannelConnectorService {
|
|
|
1428
1694
|
await this.db.insert(variantOptionValues).values(desiredIds.map((optionValueId) => ({ variantId, optionValueId }))).onConflictDoNothing();
|
|
1429
1695
|
repaired += 1;
|
|
1430
1696
|
}
|
|
1697
|
+
// The map is the read model for this pass; a payload repeating an externalId must not see
|
|
1698
|
+
// the pre-fetched state after this write.
|
|
1699
|
+
existingOptionValues.set(variantId, [...desiredIds]);
|
|
1431
1700
|
changed = true;
|
|
1432
1701
|
}
|
|
1433
1702
|
if (createdVariant && desiredIds.length > 0) {
|
|
@@ -1445,15 +1714,65 @@ export class ChannelConnectorService {
|
|
|
1445
1714
|
}, actor);
|
|
1446
1715
|
if (!priced.ok) return PluginErr(priced.error.message);
|
|
1447
1716
|
}
|
|
1448
|
-
|
|
1717
|
+
// Was UNCONDITIONAL: one UPDATE per variant on every import, including a re-sync where the
|
|
1718
|
+
// variant is byte-identical. `mapping.syncHash` is already in hand from the select above, so
|
|
1719
|
+
// the comparison is free and the write disappears on an unchanged variant — which is the
|
|
1720
|
+
// ordinary case for a store that syncs repeatedly.
|
|
1721
|
+
const nextSyncHash = hash(fullSourceVariant);
|
|
1722
|
+
if (mapping && mapping.syncHash !== nextSyncHash) {
|
|
1449
1723
|
await this.db.update(channelEntityMap).set({
|
|
1450
|
-
syncHash:
|
|
1724
|
+
syncHash: nextSyncHash,
|
|
1451
1725
|
}).where(eq(channelEntityMap.id, mapping.id));
|
|
1726
|
+
mapping.syncHash = nextSyncHash;
|
|
1452
1727
|
}
|
|
1453
1728
|
}
|
|
1454
1729
|
return Ok({ value: variantIds, repaired, changed });
|
|
1455
1730
|
}
|
|
1456
1731
|
|
|
1732
|
+
/**
|
|
1733
|
+
* The organization's categories, brands and tags, read ONCE per converge run instead of once per
|
|
1734
|
+
* product.
|
|
1735
|
+
*
|
|
1736
|
+
* `applyTaxonomy` takes an `entityId` and is called unconditionally for every item, and each of
|
|
1737
|
+
* its three lookups was a whole-table read filtered by `organization_id`. Measured on the deployed
|
|
1738
|
+
* Worker: 1.0 call per product per class, three classes, every product — an organization's whole
|
|
1739
|
+
* category list re-read for each of twenty products in a batch that cannot have changed it.
|
|
1740
|
+
*
|
|
1741
|
+
* Rows created DURING the run are appended by the callers below, exactly as they were appended to
|
|
1742
|
+
* the per-product arrays before, so an item that introduces a category is still seen by the next
|
|
1743
|
+
* item. The cache is cleared at the top of `convergeCatalogItems`, so its lifetime is one converge
|
|
1744
|
+
* rather than the lifetime of the service.
|
|
1745
|
+
*
|
|
1746
|
+
* The staleness window widens from one product to one batch: a category created by ANOTHER process
|
|
1747
|
+
* mid-batch is not seen here. That was already true within a product — these lists were always a
|
|
1748
|
+
* snapshot — and the create paths below go through `this.catalog`, which refuses a duplicate slug
|
|
1749
|
+
* rather than writing one. So the failure mode is unchanged in kind and wider in window, which is
|
|
1750
|
+
* the trade this comment exists to state rather than hide.
|
|
1751
|
+
*/
|
|
1752
|
+
private taxonomyCache: {
|
|
1753
|
+
orgId: string;
|
|
1754
|
+
categories: (typeof categories.$inferSelect)[];
|
|
1755
|
+
brands: (typeof brands.$inferSelect)[];
|
|
1756
|
+
tags: (typeof tags.$inferSelect)[];
|
|
1757
|
+
} | null = null;
|
|
1758
|
+
|
|
1759
|
+
private async taxonomyFor(orgId: string): Promise<{
|
|
1760
|
+
categories: (typeof categories.$inferSelect)[];
|
|
1761
|
+
brands: (typeof brands.$inferSelect)[];
|
|
1762
|
+
tags: (typeof tags.$inferSelect)[];
|
|
1763
|
+
}> {
|
|
1764
|
+
// Keyed on orgId as well as presence: one service instance serving two organizations must not
|
|
1765
|
+
// hand the second one the first one's taxonomy.
|
|
1766
|
+
if (this.taxonomyCache?.orgId === orgId) return this.taxonomyCache;
|
|
1767
|
+
const [categoryRows, brandRows, tagRows] = await Promise.all([
|
|
1768
|
+
this.db.select().from(categories).where(eq(categories.organizationId, orgId)),
|
|
1769
|
+
this.db.select().from(brands).where(eq(brands.organizationId, orgId)),
|
|
1770
|
+
this.db.select().from(tags).where(eq(tags.organizationId, orgId)),
|
|
1771
|
+
]);
|
|
1772
|
+
this.taxonomyCache = { orgId, categories: categoryRows, brands: brandRows, tags: tagRows };
|
|
1773
|
+
return this.taxonomyCache;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1457
1776
|
private async applyTaxonomy(
|
|
1458
1777
|
orgId: string,
|
|
1459
1778
|
entityId: string,
|
|
@@ -1461,7 +1780,8 @@ export class ChannelConnectorService {
|
|
|
1461
1780
|
actor: Actor,
|
|
1462
1781
|
warnings: string[],
|
|
1463
1782
|
): Promise<PluginResult<void>> {
|
|
1464
|
-
const
|
|
1783
|
+
const taxonomy = await this.taxonomyFor(orgId);
|
|
1784
|
+
const categoryRows = taxonomy.categories;
|
|
1465
1785
|
for (const slug of new Set(item.categories ?? [])) {
|
|
1466
1786
|
let category = categoryRows.find((row) => row.slug === slug);
|
|
1467
1787
|
if (category?.status === "archived") {
|
|
@@ -1480,7 +1800,7 @@ export class ChannelConnectorService {
|
|
|
1480
1800
|
if (!linked.ok) return PluginErr(linked.error.message);
|
|
1481
1801
|
}
|
|
1482
1802
|
|
|
1483
|
-
const brandRows =
|
|
1803
|
+
const brandRows = taxonomy.brands;
|
|
1484
1804
|
if (item.brand) {
|
|
1485
1805
|
let brand = brandRows.find((row) => row.slug === item.brand);
|
|
1486
1806
|
if (!brand) {
|
|
@@ -1495,7 +1815,7 @@ export class ChannelConnectorService {
|
|
|
1495
1815
|
if (!linked.ok) return PluginErr(linked.error.message);
|
|
1496
1816
|
}
|
|
1497
1817
|
|
|
1498
|
-
const tagRows =
|
|
1818
|
+
const tagRows = taxonomy.tags;
|
|
1499
1819
|
for (const slug of new Set(item.tags ?? [])) {
|
|
1500
1820
|
let tag = tagRows.find((row) => row.slug === slug);
|
|
1501
1821
|
if (!tag) {
|
|
@@ -2427,6 +2747,229 @@ export class ChannelConnectorService {
|
|
|
2427
2747
|
}));
|
|
2428
2748
|
}
|
|
2429
2749
|
|
|
2750
|
+
/**
|
|
2751
|
+
* One page from the connector, nothing written. The host lands the page durably (R2 + its
|
|
2752
|
+
* ledger) and hands it to `convergeCatalogPage` from a queue consumer; the two halves are
|
|
2753
|
+
* separate so a consumer retry never re-fetches the merchant's API.
|
|
2754
|
+
*/
|
|
2755
|
+
async fetchCatalogPage(
|
|
2756
|
+
orgId: string,
|
|
2757
|
+
storeId: string,
|
|
2758
|
+
cursor: string | null,
|
|
2759
|
+
): Promise<PluginResult<{ items: ChannelCatalogItem[]; nextCursor: string | null }>> {
|
|
2760
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
2761
|
+
if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
2762
|
+
const connector = this.connectors.get(store.provider);
|
|
2763
|
+
if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`);
|
|
2764
|
+
const page = await connector.importCatalog(store as ChannelStore, cursor ?? undefined);
|
|
2765
|
+
if (!page.ok) return PluginErr(page.error.message);
|
|
2766
|
+
return Ok({ items: page.value.items, nextCursor: page.value.nextCursor ?? null });
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
/**
|
|
2770
|
+
* Converges a page: items this store has never mapped take the import fast path
|
|
2771
|
+
* (`catalog.importProducts`, one transaction, multi-row writes); items already mapped and
|
|
2772
|
+
* unchanged cost nothing; items mapped-but-changed, and orphans (an entity of this store with
|
|
2773
|
+
* the item's slug but no map row), take the editor path, which owns ownership and conflicts.
|
|
2774
|
+
*
|
|
2775
|
+
* Media: only each new item's hero is fetched here, streamed under `HERO_IMAGE_BYTE_CAP`, and
|
|
2776
|
+
* linked at entity level as `primary` plus to the variants it shows. The first photo of every
|
|
2777
|
+
* other variant comes back in `deferredMedia` for the host to land later.
|
|
2778
|
+
*/
|
|
2779
|
+
async convergeCatalogPage(
|
|
2780
|
+
orgId: string,
|
|
2781
|
+
storeId: string,
|
|
2782
|
+
items: ChannelCatalogItem[],
|
|
2783
|
+
actor: Actor,
|
|
2784
|
+
): Promise<PluginResult<CatalogPageConvergence>> {
|
|
2785
|
+
const failures: CatalogConvergenceFailure[] = [];
|
|
2786
|
+
const warnings: string[] = [];
|
|
2787
|
+
const entityByExternalId = new Map<string, string>();
|
|
2788
|
+
let unchanged = 0;
|
|
2789
|
+
let updated = 0;
|
|
2790
|
+
|
|
2791
|
+
const externalIds = [...new Set(items.map((item) => item.externalId))];
|
|
2792
|
+
const mappings = externalIds.length === 0 ? [] : await this.db.select().from(channelEntityMap).where(and(
|
|
2793
|
+
eq(channelEntityMap.organizationId, orgId),
|
|
2794
|
+
eq(channelEntityMap.storeId, storeId),
|
|
2795
|
+
eq(channelEntityMap.kind, "entity"),
|
|
2796
|
+
inArray(channelEntityMap.externalId, externalIds),
|
|
2797
|
+
));
|
|
2798
|
+
const mappingByExternalId = new Map(mappings.map((row) => [row.externalId, row]));
|
|
2799
|
+
const slugs = [...new Set(items.map((item) => item.slug))];
|
|
2800
|
+
const orphans = slugs.length === 0 ? [] : await this.db.select({ slug: sellableEntities.slug }).from(sellableEntities).where(and(
|
|
2801
|
+
eq(sellableEntities.organizationId, orgId),
|
|
2802
|
+
eq(sellableEntities.sourceStoreId, storeId),
|
|
2803
|
+
inArray(sellableEntities.slug, slugs),
|
|
2804
|
+
));
|
|
2805
|
+
const orphanSlugs = new Set(orphans.map((row) => row.slug));
|
|
2806
|
+
|
|
2807
|
+
const fresh: ChannelCatalogItem[] = [];
|
|
2808
|
+
const editor: ChannelCatalogItem[] = [];
|
|
2809
|
+
const seen = new Set<string>();
|
|
2810
|
+
for (const item of items) {
|
|
2811
|
+
if (seen.has(item.externalId)) {
|
|
2812
|
+
failures.push({ externalId: item.externalId, error: "duplicate-in-page: this externalId appears twice in the page." });
|
|
2813
|
+
continue;
|
|
2814
|
+
}
|
|
2815
|
+
seen.add(item.externalId);
|
|
2816
|
+
const mapping = mappingByExternalId.get(item.externalId);
|
|
2817
|
+
if (mapping && mapping.syncHash === hash(item)) {
|
|
2818
|
+
entityByExternalId.set(item.externalId, mapping.entityId);
|
|
2819
|
+
unchanged += 1;
|
|
2820
|
+
} else if (mapping || orphanSlugs.has(item.slug)) {
|
|
2821
|
+
editor.push(item);
|
|
2822
|
+
} else {
|
|
2823
|
+
fresh.push(item);
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
if (editor.length > 0) {
|
|
2828
|
+
const result = await this.convergeCatalogItems(orgId, storeId, editor, actor);
|
|
2829
|
+
if (!result.ok) return result;
|
|
2830
|
+
failures.push(...result.value.failures);
|
|
2831
|
+
warnings.push(...result.value.warnings);
|
|
2832
|
+
const failedIds = new Set(result.value.failures.map((failure) => failure.externalId));
|
|
2833
|
+
const survivors = editor.filter((item) => !failedIds.has(item.externalId));
|
|
2834
|
+
survivors.forEach((item, index) => {
|
|
2835
|
+
const entityId = result.value.entityIds[index];
|
|
2836
|
+
if (entityId !== undefined) entityByExternalId.set(item.externalId, entityId);
|
|
2837
|
+
});
|
|
2838
|
+
updated += survivors.length;
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
const createdItems: Array<{ item: ChannelCatalogItem; entityId: string; variantIds: Record<string, string> }> = [];
|
|
2842
|
+
if (fresh.length > 0) {
|
|
2843
|
+
const report = await this.catalog.importProducts(fresh.map(toImportProduct), { sourceStoreId: storeId, errorPolicy: "reject-failed-rows" }, actor);
|
|
2844
|
+
if (!report.ok) return PluginErr(report.error.message, report.error.code);
|
|
2845
|
+
for (const [index, row] of report.value.rows.entries()) {
|
|
2846
|
+
const item = fresh[index];
|
|
2847
|
+
if (!item) continue;
|
|
2848
|
+
if (row.status === "failed") {
|
|
2849
|
+
failures.push({ externalId: row.ref, error: `${row.code}: ${row.error}` });
|
|
2850
|
+
continue;
|
|
2851
|
+
}
|
|
2852
|
+
warnings.push(...row.warnings);
|
|
2853
|
+
entityByExternalId.set(item.externalId, row.entityId);
|
|
2854
|
+
createdItems.push({ item, entityId: row.entityId, variantIds: row.variantIds });
|
|
2855
|
+
}
|
|
2856
|
+
if (createdItems.length > 0) {
|
|
2857
|
+
const now = new Date();
|
|
2858
|
+
await this.db.insert(channelEntityMap).values(createdItems.flatMap(({ item, entityId, variantIds }) => [
|
|
2859
|
+
{ organizationId: orgId, storeId, kind: "entity" as const, externalId: item.externalId, entityId, syncHash: hash(item), lastSyncedAt: now },
|
|
2860
|
+
...item.variants.flatMap((variant) => {
|
|
2861
|
+
const variantId = variantIds[variant.externalId];
|
|
2862
|
+
return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant" as const, externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
|
|
2863
|
+
}),
|
|
2864
|
+
])).onConflictDoNothing();
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
const media = await this.importHeroes(orgId, createdItems, actor);
|
|
2869
|
+
const entityIds: string[] = [];
|
|
2870
|
+
const emitted = new Set<string>();
|
|
2871
|
+
for (const item of items) {
|
|
2872
|
+
const entityId = entityByExternalId.get(item.externalId);
|
|
2873
|
+
if (entityId === undefined || emitted.has(entityId)) continue;
|
|
2874
|
+
emitted.add(entityId);
|
|
2875
|
+
entityIds.push(entityId);
|
|
2876
|
+
}
|
|
2877
|
+
return Ok({
|
|
2878
|
+
created: createdItems.length,
|
|
2879
|
+
unchanged,
|
|
2880
|
+
updated,
|
|
2881
|
+
entityIds,
|
|
2882
|
+
failures,
|
|
2883
|
+
heroesImported: media.heroesImported,
|
|
2884
|
+
mediaFailures: media.mediaFailures,
|
|
2885
|
+
deferredMedia: media.deferredMedia,
|
|
2886
|
+
warnings,
|
|
2887
|
+
});
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
private async importHeroes(
|
|
2891
|
+
orgId: string,
|
|
2892
|
+
createdItems: Array<{ item: ChannelCatalogItem; entityId: string; variantIds: Record<string, string> }>,
|
|
2893
|
+
actor: Actor,
|
|
2894
|
+
): Promise<Pick<CatalogPageConvergence, "heroesImported" | "mediaFailures" | "deferredMedia">> {
|
|
2895
|
+
const mediaFailures: CatalogMediaFailure[] = [];
|
|
2896
|
+
const deferredMedia: CatalogDeferredMedia[] = [];
|
|
2897
|
+
const selections = createdItems.flatMap(({ item, entityId, variantIds }) => {
|
|
2898
|
+
const selection = selectImportImages(item);
|
|
2899
|
+
if (selection.perVariant.length > 0) deferredMedia.push({ externalId: item.externalId, entityId, images: selection.perVariant });
|
|
2900
|
+
return selection.hero ? [{ item, entityId, variantIds, hero: selection.hero }] : [];
|
|
2901
|
+
});
|
|
2902
|
+
if (selections.length === 0) return { heroesImported: 0, mediaFailures, deferredMedia };
|
|
2903
|
+
|
|
2904
|
+
// One read for every hero the page might already hold (a re-import after the map was lost).
|
|
2905
|
+
const urlHashes = [...new Set(selections.map(({ hero }) => hash(hero.url)))];
|
|
2906
|
+
const existingAssets = await this.db.select({ id: mediaAssets.id, metadata: mediaAssets.metadata }).from(mediaAssets).where(and(
|
|
2907
|
+
eq(mediaAssets.organizationId, orgId),
|
|
2908
|
+
inArray(sql`${mediaAssets.metadata}->>'channelImageUrlHash'`, urlHashes),
|
|
2909
|
+
));
|
|
2910
|
+
const assetByUrlHash = new Map<string, string>();
|
|
2911
|
+
for (const asset of existingAssets) {
|
|
2912
|
+
const urlHash = asset.metadata?.channelImageUrlHash;
|
|
2913
|
+
if (typeof urlHash === "string") assetByUrlHash.set(urlHash, asset.id);
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
type HeroOutcome = { entityId: string; mediaAssetId: string; hero: ChannelCatalogImage; variantIds: Record<string, string>; imported: boolean };
|
|
2917
|
+
const outcomes: HeroOutcome[] = [];
|
|
2918
|
+
const resolveHero = async ({ item, entityId, variantIds, hero }: (typeof selections)[number]): Promise<void> => {
|
|
2919
|
+
const urlHash = hash(hero.url);
|
|
2920
|
+
const existing = assetByUrlHash.get(urlHash);
|
|
2921
|
+
if (existing !== undefined) {
|
|
2922
|
+
outcomes.push({ entityId, mediaAssetId: existing, hero, variantIds, imported: false });
|
|
2923
|
+
return;
|
|
2924
|
+
}
|
|
2925
|
+
const fetched = await fetchBounded(hero.url, HERO_IMAGE_BYTE_CAP);
|
|
2926
|
+
const failure = (reason: CatalogMediaFailureReason, detail: string): void => {
|
|
2927
|
+
mediaFailures.push({ externalId: item.externalId, ...(hero.externalId !== undefined ? { imageExternalId: hero.externalId } : {}), url: hero.url, reason, detail });
|
|
2928
|
+
};
|
|
2929
|
+
if (!fetched.ok) {
|
|
2930
|
+
failure(fetched.reason, fetched.detail);
|
|
2931
|
+
return;
|
|
2932
|
+
}
|
|
2933
|
+
const extension = fetched.contentType.split("/", 2)[1] ?? "jpg";
|
|
2934
|
+
const uploaded = await this.media.upload({
|
|
2935
|
+
filename: `${hero.externalId ?? urlHash}.${extension}`,
|
|
2936
|
+
contentType: fetched.contentType,
|
|
2937
|
+
data: fetched.bytes.buffer,
|
|
2938
|
+
...(hero.alt !== undefined ? { alt: hero.alt } : {}),
|
|
2939
|
+
metadata: { channelImageUrlHash: urlHash, ...(hero.externalId !== undefined ? { channelImageExternalId: hero.externalId } : {}) },
|
|
2940
|
+
origin: "imported",
|
|
2941
|
+
}, actor);
|
|
2942
|
+
if (!uploaded.ok) {
|
|
2943
|
+
failure(uploaded.error.code === "VALIDATION_FAILED" ? "unsupported" : "storage", uploaded.error.message);
|
|
2944
|
+
return;
|
|
2945
|
+
}
|
|
2946
|
+
assetByUrlHash.set(urlHash, uploaded.value.id);
|
|
2947
|
+
outcomes.push({ entityId, mediaAssetId: uploaded.value.id, hero, variantIds, imported: true });
|
|
2948
|
+
};
|
|
2949
|
+
// Six outbound connections per Worker invocation, two per image (download + storage put).
|
|
2950
|
+
const MAX_IN_FLIGHT = 3;
|
|
2951
|
+
let next = 0;
|
|
2952
|
+
await Promise.all(Array.from({ length: Math.min(MAX_IN_FLIGHT, selections.length) }, async () => {
|
|
2953
|
+
for (;;) {
|
|
2954
|
+
const selection = selections[next];
|
|
2955
|
+
next += 1;
|
|
2956
|
+
if (selection === undefined) return;
|
|
2957
|
+
await resolveHero(selection);
|
|
2958
|
+
}
|
|
2959
|
+
}));
|
|
2960
|
+
|
|
2961
|
+
if (outcomes.length > 0) {
|
|
2962
|
+
await this.db.insert(entityMedia).values(outcomes.flatMap(({ entityId, mediaAssetId, hero, variantIds }) => [
|
|
2963
|
+
{ entityId, mediaAssetId, role: "primary" as const, sortOrder: hero.sortOrder ?? 0 },
|
|
2964
|
+
...(hero.variantExternalIds ?? []).flatMap((externalId) => {
|
|
2965
|
+
const variantId = variantIds[externalId];
|
|
2966
|
+
return variantId === undefined ? [] : [{ entityId, variantId, mediaAssetId, role: hero.role, sortOrder: hero.sortOrder ?? 0 }];
|
|
2967
|
+
}),
|
|
2968
|
+
])).onConflictDoNothing();
|
|
2969
|
+
}
|
|
2970
|
+
return { heroesImported: outcomes.filter((outcome) => outcome.imported).length, mediaFailures, deferredMedia };
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2430
2973
|
async importCatalog(
|
|
2431
2974
|
orgId: string,
|
|
2432
2975
|
storeId: string,
|
|
@@ -2926,6 +3469,10 @@ export class ChannelConnectorService {
|
|
|
2926
3469
|
dryRun = false,
|
|
2927
3470
|
): Promise<PluginResult<CatalogConvergenceStats>> {
|
|
2928
3471
|
if (dryRun) return this.estimateCatalogItems(orgId, storeId, items);
|
|
3472
|
+
// One converge, one taxonomy snapshot. Cleared HERE rather than left to the service's lifetime:
|
|
3473
|
+
// the instance can outlive a batch on a warm isolate, and a taxonomy cached across batches would
|
|
3474
|
+
// go stale in a way nothing reports.
|
|
3475
|
+
this.taxonomyCache = null;
|
|
2929
3476
|
let imported = 0;
|
|
2930
3477
|
let converged = 0;
|
|
2931
3478
|
let entitiesTouched = 0;
|