@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/dist/service.js
CHANGED
|
@@ -125,6 +125,41 @@ export function canCatalogPushTransition(from, to) {
|
|
|
125
125
|
function hash(value) {
|
|
126
126
|
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
127
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* The suffix a store's product takes when its handle is already another store's slug: the first
|
|
130
|
+
* label of the store domain (`kelly-felder.myshopify.com` → `kelly-felder`), slugified.
|
|
131
|
+
*/
|
|
132
|
+
export function storeSlugSuffix(storeDomain) {
|
|
133
|
+
const label = storeDomain.trim().toLowerCase().split(".")[0] ?? "";
|
|
134
|
+
return label.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
135
|
+
}
|
|
136
|
+
const SLUG_CONFLICT = /sellable_entities_org_slug_unique|Entity with slug .+ already exists|Slug ".+" already exists/;
|
|
137
|
+
/**
|
|
138
|
+
* Whether a create or update lost a slug to another writer — core's pre-check message, or the
|
|
139
|
+
* unique index itself, which a driver error can carry several `cause`s deep.
|
|
140
|
+
*/
|
|
141
|
+
export function isSlugConflict(error) {
|
|
142
|
+
let cursor = error;
|
|
143
|
+
for (let depth = 0; depth < 5; depth += 1) {
|
|
144
|
+
if (typeof cursor === "string")
|
|
145
|
+
return SLUG_CONFLICT.test(cursor);
|
|
146
|
+
if (typeof cursor !== "object" || cursor === null)
|
|
147
|
+
return false;
|
|
148
|
+
if ("constraint" in cursor && cursor.constraint === "sellable_entities_org_slug_unique")
|
|
149
|
+
return true;
|
|
150
|
+
if ("message" in cursor && typeof cursor.message === "string" && SLUG_CONFLICT.test(cursor.message))
|
|
151
|
+
return true;
|
|
152
|
+
cursor = "cause" in cursor ? cursor.cause : undefined;
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
function errorMessage(error) {
|
|
157
|
+
if (typeof error === "string")
|
|
158
|
+
return error;
|
|
159
|
+
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
|
|
160
|
+
return error.message;
|
|
161
|
+
return String(error);
|
|
162
|
+
}
|
|
128
163
|
/** Mid-import map rows carry this until convergence finishes; must not equal any real remote hash. */
|
|
129
164
|
const PENDING_ENTITY_MAP_SYNC_HASH = "";
|
|
130
165
|
export const CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS = 15 * 60 * 1000;
|
|
@@ -364,6 +399,131 @@ function redactStore(store) {
|
|
|
364
399
|
updatedAt: store.updatedAt,
|
|
365
400
|
};
|
|
366
401
|
}
|
|
402
|
+
/**
|
|
403
|
+
* A hero is streamed inside the page's own invocation, so it is bounded: a 30 MB TIFF a merchant
|
|
404
|
+
* uploaded by mistake must not buffer into a 128 MiB isolate. Anything larger is reported, not
|
|
405
|
+
* stored, and the product still lands — the index reads text first and media later.
|
|
406
|
+
*/
|
|
407
|
+
export const HERO_IMAGE_BYTE_CAP = 1024 * 1024;
|
|
408
|
+
function imageOrder(a, b) {
|
|
409
|
+
return (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Ruling 2026-09-22: import the hero plus the FIRST photo of each other variant, nothing more.
|
|
413
|
+
* A "blue long dress" query must be able to show the blue variant, and a fourth photo of the red
|
|
414
|
+
* one adds nothing the index can use. Variants are read off the images' own variant references,
|
|
415
|
+
* so a connector that lists images against variants it does not enumerate still gets one each.
|
|
416
|
+
*/
|
|
417
|
+
export function selectImportImages(item) {
|
|
418
|
+
const images = [...(item.images ?? [])].sort(imageOrder);
|
|
419
|
+
const hero = images.find((image) => image.role === "primary") ?? images[0] ?? null;
|
|
420
|
+
if (!hero)
|
|
421
|
+
return { hero: null, perVariant: [] };
|
|
422
|
+
const covered = new Set(hero.variantExternalIds ?? []);
|
|
423
|
+
const usedUrls = new Set([hero.url]);
|
|
424
|
+
const perVariant = [];
|
|
425
|
+
const variantRefs = [...new Set(images.flatMap((image) => image.variantExternalIds ?? []))];
|
|
426
|
+
for (const ref of variantRefs) {
|
|
427
|
+
if (covered.has(ref))
|
|
428
|
+
continue;
|
|
429
|
+
const image = images.find((candidate) => candidate.variantExternalIds?.includes(ref) && !usedUrls.has(candidate.url));
|
|
430
|
+
for (const shown of image?.variantExternalIds ?? [])
|
|
431
|
+
covered.add(shown);
|
|
432
|
+
covered.add(ref);
|
|
433
|
+
if (!image)
|
|
434
|
+
continue;
|
|
435
|
+
usedUrls.add(image.url);
|
|
436
|
+
perVariant.push(image);
|
|
437
|
+
}
|
|
438
|
+
return { hero, perVariant };
|
|
439
|
+
}
|
|
440
|
+
/** Streams a response and refuses mid-stream past `cap`; a lying `content-length` cannot get around it. */
|
|
441
|
+
async function fetchBounded(url, cap) {
|
|
442
|
+
let response;
|
|
443
|
+
try {
|
|
444
|
+
response = await fetch(url);
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
return { ok: false, reason: "download-failed", detail: error instanceof Error ? error.message : "download failed" };
|
|
448
|
+
}
|
|
449
|
+
if (!response.ok)
|
|
450
|
+
return { ok: false, reason: "download-failed", detail: `download returned ${response.status}` };
|
|
451
|
+
const declared = Number(response.headers.get("content-length"));
|
|
452
|
+
if (Number.isFinite(declared) && declared > cap)
|
|
453
|
+
return { ok: false, reason: "too-large", detail: `content-length ${declared} exceeds ${cap}` };
|
|
454
|
+
const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim() || "image/jpeg";
|
|
455
|
+
const reader = response.body?.getReader();
|
|
456
|
+
if (!reader) {
|
|
457
|
+
const buffer = await response.arrayBuffer();
|
|
458
|
+
if (buffer.byteLength > cap)
|
|
459
|
+
return { ok: false, reason: "too-large", detail: `${buffer.byteLength} bytes exceeds ${cap}` };
|
|
460
|
+
return { ok: true, bytes: new Uint8Array(buffer), contentType };
|
|
461
|
+
}
|
|
462
|
+
const chunks = [];
|
|
463
|
+
let total = 0;
|
|
464
|
+
for (;;) {
|
|
465
|
+
const { done, value } = await reader.read();
|
|
466
|
+
if (done)
|
|
467
|
+
break;
|
|
468
|
+
total += value.byteLength;
|
|
469
|
+
if (total > cap) {
|
|
470
|
+
await reader.cancel();
|
|
471
|
+
return { ok: false, reason: "too-large", detail: `stream exceeded ${cap} bytes` };
|
|
472
|
+
}
|
|
473
|
+
chunks.push(value);
|
|
474
|
+
}
|
|
475
|
+
const bytes = new Uint8Array(total);
|
|
476
|
+
let offset = 0;
|
|
477
|
+
for (const chunk of chunks) {
|
|
478
|
+
bytes.set(chunk, offset);
|
|
479
|
+
offset += chunk.byteLength;
|
|
480
|
+
}
|
|
481
|
+
return { ok: true, bytes, contentType };
|
|
482
|
+
}
|
|
483
|
+
function toImportProduct(item) {
|
|
484
|
+
const attributes = item.attributes?.length
|
|
485
|
+
? item.attributes
|
|
486
|
+
: [{ locale: "en", title: item.title, ...(item.description !== undefined ? { description: item.description } : {}) }];
|
|
487
|
+
return {
|
|
488
|
+
ref: item.externalId,
|
|
489
|
+
slug: item.slug,
|
|
490
|
+
...(item.status !== undefined ? { status: item.status, isVisible: item.status === "active" } : {}),
|
|
491
|
+
metadata: mergeMetadata(undefined, item.metadata ?? {}),
|
|
492
|
+
attributes: attributes.map((attribute) => ({
|
|
493
|
+
locale: attribute.locale,
|
|
494
|
+
title: attribute.title,
|
|
495
|
+
...(attribute.subtitle !== undefined ? { subtitle: attribute.subtitle } : {}),
|
|
496
|
+
...(attribute.description !== undefined ? { description: attribute.description } : {}),
|
|
497
|
+
...(attribute.richDescription !== undefined ? { richDescription: attribute.richDescription } : {}),
|
|
498
|
+
...(attribute.seoTitle !== undefined ? { seoTitle: attribute.seoTitle } : {}),
|
|
499
|
+
...(attribute.seoDescription !== undefined ? { seoDescription: attribute.seoDescription } : {}),
|
|
500
|
+
})),
|
|
501
|
+
...(item.options !== undefined ? {
|
|
502
|
+
options: item.options.map((option) => ({
|
|
503
|
+
name: option.name,
|
|
504
|
+
displayName: option.displayName,
|
|
505
|
+
...(option.sortOrder !== undefined ? { sortOrder: option.sortOrder } : {}),
|
|
506
|
+
values: option.values.map((value) => ({
|
|
507
|
+
value: value.value,
|
|
508
|
+
displayValue: value.displayValue,
|
|
509
|
+
...(value.sortOrder !== undefined ? { sortOrder: value.sortOrder } : {}),
|
|
510
|
+
})),
|
|
511
|
+
})),
|
|
512
|
+
} : {}),
|
|
513
|
+
variants: item.variants.map((variant) => ({
|
|
514
|
+
ref: variant.externalId,
|
|
515
|
+
...(variant.sku !== undefined ? { sku: variant.sku } : {}),
|
|
516
|
+
...(variant.barcode !== undefined ? { barcode: variant.barcode } : {}),
|
|
517
|
+
...(variant.optionValues !== undefined ? { options: variant.optionValues } : {}),
|
|
518
|
+
...(variant.prices !== undefined ? { prices: variant.prices } : {}),
|
|
519
|
+
...(variant.metadata !== undefined ? { metadata: variant.metadata } : {}),
|
|
520
|
+
})),
|
|
521
|
+
...(item.tags !== undefined ? { tags: item.tags } : {}),
|
|
522
|
+
...(item.brand !== undefined ? { brand: item.brand } : {}),
|
|
523
|
+
...(item.categories !== undefined ? { categories: item.categories } : {}),
|
|
524
|
+
ownedFieldPaths: importedFieldPaths(item),
|
|
525
|
+
};
|
|
526
|
+
}
|
|
367
527
|
export class ChannelConnectorService {
|
|
368
528
|
db;
|
|
369
529
|
services;
|
|
@@ -713,7 +873,15 @@ export class ChannelConnectorService {
|
|
|
713
873
|
}
|
|
714
874
|
async upsertOptionAxes(entityId, item, actor) {
|
|
715
875
|
const optionValueIds = new Map();
|
|
716
|
-
|
|
876
|
+
// PROJECTED, not `select()`. Three reasons, and the third is the one that saves statements:
|
|
877
|
+
// the row's other columns are never read; a projection types the locally-constructed row below
|
|
878
|
+
// without a cast; and carrying `displayName`/`sortOrder` is what lets the update be SKIPPED when
|
|
879
|
+
// they already hold. A re-import that changes nothing is the common case for a sync, and it used
|
|
880
|
+
// to issue one UPDATE per option type and one per option value regardless.
|
|
881
|
+
const existingTypes = await this.db
|
|
882
|
+
.select({ id: optionTypes.id, name: optionTypes.name, displayName: optionTypes.displayName, sortOrder: optionTypes.sortOrder })
|
|
883
|
+
.from(optionTypes)
|
|
884
|
+
.where(eq(optionTypes.entityId, entityId));
|
|
717
885
|
let changed = false;
|
|
718
886
|
for (const [typeIndex, sourceType] of (item.options ?? []).entries()) {
|
|
719
887
|
let optionType = existingTypes.find((row) => row.name === sourceType.name);
|
|
@@ -721,18 +889,37 @@ export class ChannelConnectorService {
|
|
|
721
889
|
const created = await this.catalog.createOptionType({ entityId, name: sourceType.name, values: [] }, actor);
|
|
722
890
|
if (!created.ok)
|
|
723
891
|
return PluginErr(created.error.message);
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
892
|
+
// The created row is CONSTRUCTED rather than read back: `createOptionType` returns the id and
|
|
893
|
+
// the name is what we just sent.
|
|
894
|
+
//
|
|
895
|
+
// The two nulls are DELIBERATE PLACEHOLDERS, not a claim about the database. `createOptionType`
|
|
896
|
+
// actually persists `displayName: input.name, sortOrder: 0` (core entity-service.ts:595) and
|
|
897
|
+
// `display_name` is NOT NULL (core catalog/schema.ts:307) — so a fresh row never holds null.
|
|
898
|
+
// Constructing nulls here makes the comparison below unequal on any input, which is what forces
|
|
899
|
+
// the update that writes the caller's real `displayName`/`sortOrder` over those defaults.
|
|
900
|
+
optionType = { id: created.value.id, name: sourceType.name, displayName: null, sortOrder: null };
|
|
728
901
|
existingTypes.push(optionType);
|
|
729
902
|
changed = true;
|
|
730
903
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
904
|
+
const desiredTypeSort = sourceType.sortOrder ?? typeIndex;
|
|
905
|
+
// `?? null` NORMALISES, and it is load-bearing rather than tidy. The stored value is `null` or a
|
|
906
|
+
// string; a connector that omits `displayName` sends `undefined`, and `null !== undefined` is
|
|
907
|
+
// true — so without this every such connector issued one UPDATE per option type on every sync
|
|
908
|
+
// and the conditional bought it nothing. Latent on today's corpus, whose connector always sends
|
|
909
|
+
// both fields.
|
|
910
|
+
const desiredTypeDisplay = sourceType.displayName ?? null;
|
|
911
|
+
if (optionType.displayName !== desiredTypeDisplay || optionType.sortOrder !== desiredTypeSort) {
|
|
912
|
+
await this.db.update(optionTypes).set({
|
|
913
|
+
displayName: sourceType.displayName,
|
|
914
|
+
sortOrder: desiredTypeSort,
|
|
915
|
+
}).where(eq(optionTypes.id, optionType.id));
|
|
916
|
+
optionType.displayName = desiredTypeDisplay;
|
|
917
|
+
optionType.sortOrder = desiredTypeSort;
|
|
918
|
+
}
|
|
919
|
+
const existingValues = await this.db
|
|
920
|
+
.select({ id: optionValues.id, value: optionValues.value, displayValue: optionValues.displayValue, sortOrder: optionValues.sortOrder })
|
|
921
|
+
.from(optionValues)
|
|
922
|
+
.where(eq(optionValues.optionTypeId, optionType.id));
|
|
736
923
|
const valueIds = new Map();
|
|
737
924
|
for (const [valueIndex, sourceValue] of sourceType.values.entries()) {
|
|
738
925
|
let optionValue = existingValues.find((row) => row.value === sourceValue.value);
|
|
@@ -740,17 +927,22 @@ export class ChannelConnectorService {
|
|
|
740
927
|
const created = await this.catalog.createOptionValue({ optionTypeId: optionType.id, value: sourceValue.value }, actor);
|
|
741
928
|
if (!created.ok)
|
|
742
929
|
return PluginErr(created.error.message);
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
optionValue = createdValue;
|
|
930
|
+
// Same placeholder reasoning as the option type above: core persists `displayValue: input.value,
|
|
931
|
+
// sortOrder: 0` (entity-service.ts:615), and the nulls force the update that overwrites them.
|
|
932
|
+
optionValue = { id: created.value.id, value: sourceValue.value, displayValue: null, sortOrder: null };
|
|
747
933
|
existingValues.push(optionValue);
|
|
748
934
|
changed = true;
|
|
749
935
|
}
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
936
|
+
const desiredValueSort = sourceValue.sortOrder ?? valueIndex;
|
|
937
|
+
const desiredValueDisplay = sourceValue.displayValue ?? null;
|
|
938
|
+
if (optionValue.displayValue !== desiredValueDisplay || optionValue.sortOrder !== desiredValueSort) {
|
|
939
|
+
await this.db.update(optionValues).set({
|
|
940
|
+
displayValue: sourceValue.displayValue,
|
|
941
|
+
sortOrder: desiredValueSort,
|
|
942
|
+
}).where(eq(optionValues.id, optionValue.id));
|
|
943
|
+
optionValue.displayValue = desiredValueDisplay;
|
|
944
|
+
optionValue.sortOrder = desiredValueSort;
|
|
945
|
+
}
|
|
754
946
|
valueIds.set(sourceValue.value, optionValue.id);
|
|
755
947
|
}
|
|
756
948
|
optionValueIds.set(sourceType.name, valueIds);
|
|
@@ -762,10 +954,52 @@ export class ChannelConnectorService {
|
|
|
762
954
|
let repaired = 0;
|
|
763
955
|
let changed = false;
|
|
764
956
|
const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), eq(channelEntityMap.entityId, entityId)));
|
|
957
|
+
// ONE read of the existing option-value rows for every already-mapped variant, instead of one
|
|
958
|
+
// per variant inside the loop. Measured on the deployed Worker at 17.0 calls per product, which
|
|
959
|
+
// is one per offer on a catalogue averaging 13 offers per product. A variant CREATED below is
|
|
960
|
+
// absent from this map and correctly reads as empty: its rows are written in this same pass.
|
|
961
|
+
const mappedVariantIds = mappings
|
|
962
|
+
.map((row) => row.variantId)
|
|
963
|
+
.filter((variantId) => variantId !== null);
|
|
964
|
+
const existingOptionValues = new Map();
|
|
965
|
+
if (mappedVariantIds.length > 0) {
|
|
966
|
+
const rows = await this.db
|
|
967
|
+
.select({ variantId: variantOptionValues.variantId, optionValueId: variantOptionValues.optionValueId })
|
|
968
|
+
.from(variantOptionValues)
|
|
969
|
+
.where(inArray(variantOptionValues.variantId, mappedVariantIds));
|
|
970
|
+
for (const row of rows) {
|
|
971
|
+
const list = existingOptionValues.get(row.variantId) ?? [];
|
|
972
|
+
list.push(row.optionValueId);
|
|
973
|
+
existingOptionValues.set(row.variantId, list);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
765
976
|
for (const sourceVariant of item.variants) {
|
|
766
977
|
const fullSourceVariant = fullItem.variants.find((variant) => variant.externalId === sourceVariant.externalId) ?? sourceVariant;
|
|
767
978
|
let mapping = mappings.find((row) => row.externalId === sourceVariant.externalId);
|
|
768
979
|
let variantId = mapping?.variantId;
|
|
980
|
+
// ADOPT BEFORE CREATE. A variant-kind mapping row whose `variantId` is null has lost its link
|
|
981
|
+
// — the key survived, the target did not. Creating a replacement is what the loop used to do,
|
|
982
|
+
// and it cannot work: the orphaned variant still holds the sku, so `variants_native_org_sku_unique`
|
|
983
|
+
// refuses the insert and the item fails on this and every later sync. The row can never heal.
|
|
984
|
+
//
|
|
985
|
+
// `sku` is the store's own natural key for a variant, so re-resolving by it is what restores
|
|
986
|
+
// the link the null destroyed. Scoped to this entity because a sku is unique per organization
|
|
987
|
+
// and adopting another entity's variant would be worse than failing.
|
|
988
|
+
if (mapping && !variantId && sourceVariant.sku) {
|
|
989
|
+
const [adopted] = await this.db
|
|
990
|
+
.select({ id: variants.id })
|
|
991
|
+
.from(variants)
|
|
992
|
+
.where(and(eq(variants.entityId, entityId), eq(variants.sku, sourceVariant.sku)))
|
|
993
|
+
.limit(1);
|
|
994
|
+
if (adopted) {
|
|
995
|
+
variantId = adopted.id;
|
|
996
|
+
// Write the link back, or the row stays broken and every later sync pays this lookup again.
|
|
997
|
+
await this.db.update(channelEntityMap)
|
|
998
|
+
.set({ variantId })
|
|
999
|
+
.where(eq(channelEntityMap.id, mapping.id));
|
|
1000
|
+
mapping.variantId = variantId;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
769
1003
|
const createdVariant = !variantId;
|
|
770
1004
|
if (!variantId) {
|
|
771
1005
|
const options = {};
|
|
@@ -786,18 +1020,38 @@ export class ChannelConnectorService {
|
|
|
786
1020
|
if (!created.ok)
|
|
787
1021
|
return PluginErr(created.error.message);
|
|
788
1022
|
variantId = created.value.id;
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
1023
|
+
if (mapping) {
|
|
1024
|
+
// REPAIR IN PLACE. `variantId` is nullable because this table also holds `kind: "entity"`
|
|
1025
|
+
// rows, which legitimately have none — but a VARIANT row with a null `variantId` is a
|
|
1026
|
+
// broken invariant, not a state to work around. The row already occupies
|
|
1027
|
+
// `channel_entity_map_store_kind_external_unique` on (store, kind, externalId), so the
|
|
1028
|
+
// insert below would raise a unique violation and fail the whole item. Updating the row
|
|
1029
|
+
// we already hold is the only shape that converges.
|
|
1030
|
+
await this.db.update(channelEntityMap).set({
|
|
1031
|
+
entityId,
|
|
1032
|
+
variantId,
|
|
1033
|
+
syncHash: hash(fullSourceVariant),
|
|
1034
|
+
}).where(eq(channelEntityMap.id, mapping.id));
|
|
1035
|
+
mapping.entityId = entityId;
|
|
1036
|
+
mapping.variantId = variantId;
|
|
1037
|
+
mapping.syncHash = hash(fullSourceVariant);
|
|
1038
|
+
}
|
|
1039
|
+
else {
|
|
1040
|
+
const [createdMapping] = await this.db.insert(channelEntityMap).values({
|
|
1041
|
+
organizationId: orgId,
|
|
1042
|
+
storeId,
|
|
1043
|
+
kind: "variant",
|
|
1044
|
+
externalId: sourceVariant.externalId,
|
|
1045
|
+
entityId,
|
|
1046
|
+
variantId,
|
|
1047
|
+
syncHash: hash(fullSourceVariant),
|
|
1048
|
+
}).returning();
|
|
1049
|
+
mapping = createdMapping;
|
|
1050
|
+
// Pushed so a payload repeating this externalId resolves the row it just created rather
|
|
1051
|
+
// than creating a second variant for it.
|
|
1052
|
+
if (mapping)
|
|
1053
|
+
mappings.push(mapping);
|
|
1054
|
+
}
|
|
801
1055
|
}
|
|
802
1056
|
if (!variantId) {
|
|
803
1057
|
warnings.push(`Skipped variant "${sourceVariant.externalId}": no local variant mapping exists.`);
|
|
@@ -808,8 +1062,7 @@ export class ChannelConnectorService {
|
|
|
808
1062
|
const desiredOptionValueIds = Object.entries(sourceVariant.optionValues ?? {})
|
|
809
1063
|
.map(([name, value]) => optionValueIds.get(name)?.get(value))
|
|
810
1064
|
.filter((optionValueId) => optionValueId !== undefined);
|
|
811
|
-
const
|
|
812
|
-
const currentIds = currentOptionValues.map((row) => row.optionValueId).sort();
|
|
1065
|
+
const currentIds = [...(existingOptionValues.get(variantId) ?? [])].sort();
|
|
813
1066
|
const desiredIds = [...new Set(desiredOptionValueIds)].sort();
|
|
814
1067
|
if (currentIds.length !== desiredIds.length || currentIds.some((id, index) => id !== desiredIds[index])) {
|
|
815
1068
|
await this.db.delete(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
|
|
@@ -817,6 +1070,9 @@ export class ChannelConnectorService {
|
|
|
817
1070
|
await this.db.insert(variantOptionValues).values(desiredIds.map((optionValueId) => ({ variantId, optionValueId }))).onConflictDoNothing();
|
|
818
1071
|
repaired += 1;
|
|
819
1072
|
}
|
|
1073
|
+
// The map is the read model for this pass; a payload repeating an externalId must not see
|
|
1074
|
+
// the pre-fetched state after this write.
|
|
1075
|
+
existingOptionValues.set(variantId, [...desiredIds]);
|
|
820
1076
|
changed = true;
|
|
821
1077
|
}
|
|
822
1078
|
if (createdVariant && desiredIds.length > 0) {
|
|
@@ -835,16 +1091,57 @@ export class ChannelConnectorService {
|
|
|
835
1091
|
if (!priced.ok)
|
|
836
1092
|
return PluginErr(priced.error.message);
|
|
837
1093
|
}
|
|
838
|
-
|
|
1094
|
+
// Was UNCONDITIONAL: one UPDATE per variant on every import, including a re-sync where the
|
|
1095
|
+
// variant is byte-identical. `mapping.syncHash` is already in hand from the select above, so
|
|
1096
|
+
// the comparison is free and the write disappears on an unchanged variant — which is the
|
|
1097
|
+
// ordinary case for a store that syncs repeatedly.
|
|
1098
|
+
const nextSyncHash = hash(fullSourceVariant);
|
|
1099
|
+
if (mapping && mapping.syncHash !== nextSyncHash) {
|
|
839
1100
|
await this.db.update(channelEntityMap).set({
|
|
840
|
-
syncHash:
|
|
1101
|
+
syncHash: nextSyncHash,
|
|
841
1102
|
}).where(eq(channelEntityMap.id, mapping.id));
|
|
1103
|
+
mapping.syncHash = nextSyncHash;
|
|
842
1104
|
}
|
|
843
1105
|
}
|
|
844
1106
|
return Ok({ value: variantIds, repaired, changed });
|
|
845
1107
|
}
|
|
1108
|
+
/**
|
|
1109
|
+
* The organization's categories, brands and tags, read ONCE per converge run instead of once per
|
|
1110
|
+
* product.
|
|
1111
|
+
*
|
|
1112
|
+
* `applyTaxonomy` takes an `entityId` and is called unconditionally for every item, and each of
|
|
1113
|
+
* its three lookups was a whole-table read filtered by `organization_id`. Measured on the deployed
|
|
1114
|
+
* Worker: 1.0 call per product per class, three classes, every product — an organization's whole
|
|
1115
|
+
* category list re-read for each of twenty products in a batch that cannot have changed it.
|
|
1116
|
+
*
|
|
1117
|
+
* Rows created DURING the run are appended by the callers below, exactly as they were appended to
|
|
1118
|
+
* the per-product arrays before, so an item that introduces a category is still seen by the next
|
|
1119
|
+
* item. The cache is cleared at the top of `convergeCatalogItems`, so its lifetime is one converge
|
|
1120
|
+
* rather than the lifetime of the service.
|
|
1121
|
+
*
|
|
1122
|
+
* The staleness window widens from one product to one batch: a category created by ANOTHER process
|
|
1123
|
+
* mid-batch is not seen here. That was already true within a product — these lists were always a
|
|
1124
|
+
* snapshot — and the create paths below go through `this.catalog`, which refuses a duplicate slug
|
|
1125
|
+
* rather than writing one. So the failure mode is unchanged in kind and wider in window, which is
|
|
1126
|
+
* the trade this comment exists to state rather than hide.
|
|
1127
|
+
*/
|
|
1128
|
+
taxonomyCache = null;
|
|
1129
|
+
async taxonomyFor(orgId) {
|
|
1130
|
+
// Keyed on orgId as well as presence: one service instance serving two organizations must not
|
|
1131
|
+
// hand the second one the first one's taxonomy.
|
|
1132
|
+
if (this.taxonomyCache?.orgId === orgId)
|
|
1133
|
+
return this.taxonomyCache;
|
|
1134
|
+
const [categoryRows, brandRows, tagRows] = await Promise.all([
|
|
1135
|
+
this.db.select().from(categories).where(eq(categories.organizationId, orgId)),
|
|
1136
|
+
this.db.select().from(brands).where(eq(brands.organizationId, orgId)),
|
|
1137
|
+
this.db.select().from(tags).where(eq(tags.organizationId, orgId)),
|
|
1138
|
+
]);
|
|
1139
|
+
this.taxonomyCache = { orgId, categories: categoryRows, brands: brandRows, tags: tagRows };
|
|
1140
|
+
return this.taxonomyCache;
|
|
1141
|
+
}
|
|
846
1142
|
async applyTaxonomy(orgId, entityId, item, actor, warnings) {
|
|
847
|
-
const
|
|
1143
|
+
const taxonomy = await this.taxonomyFor(orgId);
|
|
1144
|
+
const categoryRows = taxonomy.categories;
|
|
848
1145
|
for (const slug of new Set(item.categories ?? [])) {
|
|
849
1146
|
let category = categoryRows.find((row) => row.slug === slug);
|
|
850
1147
|
if (category?.status === "archived") {
|
|
@@ -865,7 +1162,7 @@ export class ChannelConnectorService {
|
|
|
865
1162
|
if (!linked.ok)
|
|
866
1163
|
return PluginErr(linked.error.message);
|
|
867
1164
|
}
|
|
868
|
-
const brandRows =
|
|
1165
|
+
const brandRows = taxonomy.brands;
|
|
869
1166
|
if (item.brand) {
|
|
870
1167
|
let brand = brandRows.find((row) => row.slug === item.brand);
|
|
871
1168
|
if (!brand) {
|
|
@@ -882,7 +1179,7 @@ export class ChannelConnectorService {
|
|
|
882
1179
|
if (!linked.ok)
|
|
883
1180
|
return PluginErr(linked.error.message);
|
|
884
1181
|
}
|
|
885
|
-
const tagRows =
|
|
1182
|
+
const tagRows = taxonomy.tags;
|
|
886
1183
|
for (const slug of new Set(item.tags ?? [])) {
|
|
887
1184
|
let tag = tagRows.find((row) => row.slug === slug);
|
|
888
1185
|
if (!tag) {
|
|
@@ -1088,6 +1385,59 @@ export class ChannelConnectorService {
|
|
|
1088
1385
|
.where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, id)));
|
|
1089
1386
|
return rows[0];
|
|
1090
1387
|
}
|
|
1388
|
+
/**
|
|
1389
|
+
* The slug each wanted handle takes for THIS store. Slugs stay unique across the organization —
|
|
1390
|
+
* the storefront resolves `/:idOrSlug` org-wide — but one platform organization holds many
|
|
1391
|
+
* merchants, and two of them may sell the same handle.
|
|
1392
|
+
*
|
|
1393
|
+
* A handle's FAMILY for a store is, in order: the bare handle, `<handle>-<store suffix>`, and
|
|
1394
|
+
* `<handle>-<store suffix>-<store id prefix>`. The last is unique per store, so a third store with
|
|
1395
|
+
* the same domain label still gets a slug of its own. A new product takes the first member no
|
|
1396
|
+
* other store holds. An existing product whose slug is already in its handle's family KEEPS it
|
|
1397
|
+
* (see `slugToKeep`): a slug, once assigned, is a shared link and is never recomputed.
|
|
1398
|
+
*/
|
|
1399
|
+
async resolveStoreSlugs(orgId, storeId, handles) {
|
|
1400
|
+
const wanted = [...new Set(handles)];
|
|
1401
|
+
const resolved = new Map();
|
|
1402
|
+
if (wanted.length === 0)
|
|
1403
|
+
return resolved;
|
|
1404
|
+
const suffix = await this.storeSlugSuffixFor(orgId, storeId);
|
|
1405
|
+
const idPart = storeId.replace(/[^a-z0-9]/gi, "").slice(0, 8).toLowerCase();
|
|
1406
|
+
const familyOf = (handle) => suffix
|
|
1407
|
+
? [handle, `${handle}-${suffix}`, `${handle}-${suffix}-${idPart}`]
|
|
1408
|
+
: [handle, `${handle}-${idPart}`];
|
|
1409
|
+
const families = new Map(wanted.map((handle) => [handle, familyOf(handle)]));
|
|
1410
|
+
const owners = await this.db.select({ slug: sellableEntities.slug, sourceStoreId: sellableEntities.sourceStoreId })
|
|
1411
|
+
.from(sellableEntities)
|
|
1412
|
+
.where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.slug, [...families.values()].flat())));
|
|
1413
|
+
// Only ANOTHER STORE's product moves this one to a qualified slug. A product a person made in
|
|
1414
|
+
// Merchant Center (no source store) holding the handle stays a loud slug conflict, as before.
|
|
1415
|
+
const heldElsewhere = new Set(owners.filter((row) => row.sourceStoreId !== null && row.sourceStoreId !== storeId).map((row) => row.slug));
|
|
1416
|
+
for (const [handle, family] of families) {
|
|
1417
|
+
// Every member held by another store is only reachable through a hand-made slug; the last
|
|
1418
|
+
// member is then used anyway and the create fails loudly on the unique index.
|
|
1419
|
+
const slug = family.find((candidate) => !heldElsewhere.has(candidate)) ?? family[family.length - 1] ?? handle;
|
|
1420
|
+
resolved.set(handle, { slug, family });
|
|
1421
|
+
}
|
|
1422
|
+
return resolved;
|
|
1423
|
+
}
|
|
1424
|
+
// ponytail: cached for the service instance's lifetime (one task invocation), so a batched import
|
|
1425
|
+
// reads the store once. A store whose domain changes mid-invocation keeps the old suffix until the
|
|
1426
|
+
// next one — a new slug family only, never a rename of a slug already assigned.
|
|
1427
|
+
slugSuffixByStore = new Map();
|
|
1428
|
+
async storeSlugSuffixFor(orgId, storeId) {
|
|
1429
|
+
const cached = this.slugSuffixByStore.get(storeId);
|
|
1430
|
+
if (cached !== undefined)
|
|
1431
|
+
return cached;
|
|
1432
|
+
const suffix = storeSlugSuffix((await this.getStoreRecord(orgId, storeId))?.storeDomain ?? "");
|
|
1433
|
+
this.slugSuffixByStore.set(storeId, suffix);
|
|
1434
|
+
return suffix;
|
|
1435
|
+
}
|
|
1436
|
+
/** The slug an existing product converges to: its current one while that is still in the family
|
|
1437
|
+
* of the handle the store sends, so a slug is never recomputed out from under a shared link. */
|
|
1438
|
+
slugToKeep(currentSlug, resolved) {
|
|
1439
|
+
return resolved.family.includes(currentSlug) ? currentSlug : resolved.slug;
|
|
1440
|
+
}
|
|
1091
1441
|
async getStoreByDomain(shopDomain) {
|
|
1092
1442
|
const rows = await this.db
|
|
1093
1443
|
.select()
|
|
@@ -1688,6 +2038,236 @@ export class ChannelConnectorService {
|
|
|
1688
2038
|
}
|
|
1689
2039
|
}));
|
|
1690
2040
|
}
|
|
2041
|
+
/**
|
|
2042
|
+
* One page from the connector, nothing written. The host lands the page durably (R2 + its
|
|
2043
|
+
* ledger) and hands it to `convergeCatalogPage` from a queue consumer; the two halves are
|
|
2044
|
+
* separate so a consumer retry never re-fetches the merchant's API.
|
|
2045
|
+
*/
|
|
2046
|
+
async fetchCatalogPage(orgId, storeId, cursor) {
|
|
2047
|
+
const store = await this.getStoreRecord(orgId, storeId);
|
|
2048
|
+
if (!store || store.status !== "connected")
|
|
2049
|
+
return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
2050
|
+
const connector = this.connectors.get(store.provider);
|
|
2051
|
+
if (!connector)
|
|
2052
|
+
return PluginErr(`No connector registered for provider "${store.provider}".`);
|
|
2053
|
+
const page = await connector.importCatalog(store, cursor ?? undefined);
|
|
2054
|
+
if (!page.ok)
|
|
2055
|
+
return PluginErr(page.error.message);
|
|
2056
|
+
return Ok({ items: page.value.items, nextCursor: page.value.nextCursor ?? null });
|
|
2057
|
+
}
|
|
2058
|
+
/**
|
|
2059
|
+
* Converges a page: items this store has never mapped take the import fast path
|
|
2060
|
+
* (`catalog.importProducts`, one transaction, multi-row writes); items already mapped and
|
|
2061
|
+
* unchanged cost nothing; items mapped-but-changed, and orphans (an entity of this store with
|
|
2062
|
+
* the item's slug but no map row), take the editor path, which owns ownership and conflicts.
|
|
2063
|
+
*
|
|
2064
|
+
* Media: only each new item's hero is fetched here, streamed under `HERO_IMAGE_BYTE_CAP`, and
|
|
2065
|
+
* linked at entity level as `primary` plus to the variants it shows. The first photo of every
|
|
2066
|
+
* other variant comes back in `deferredMedia` for the host to land later.
|
|
2067
|
+
*/
|
|
2068
|
+
async convergeCatalogPage(orgId, storeId, items, actor) {
|
|
2069
|
+
const failures = [];
|
|
2070
|
+
const warnings = [];
|
|
2071
|
+
const entityByExternalId = new Map();
|
|
2072
|
+
let unchanged = 0;
|
|
2073
|
+
let updated = 0;
|
|
2074
|
+
const externalIds = [...new Set(items.map((item) => item.externalId))];
|
|
2075
|
+
const mappings = externalIds.length === 0 ? [] : await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), inArray(channelEntityMap.externalId, externalIds)));
|
|
2076
|
+
const mappingByExternalId = new Map(mappings.map((row) => [row.externalId, row]));
|
|
2077
|
+
const slugFor = await this.resolveStoreSlugs(orgId, storeId, items.map((item) => item.slug));
|
|
2078
|
+
const slugs = [...new Set([...slugFor.values()].flatMap((resolved) => resolved.family))];
|
|
2079
|
+
const orphans = slugs.length === 0 ? [] : await this.db.select({ slug: sellableEntities.slug }).from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.sourceStoreId, storeId), inArray(sellableEntities.slug, slugs)));
|
|
2080
|
+
const orphanSlugs = new Set(orphans.map((row) => row.slug));
|
|
2081
|
+
const isOrphan = (handle) => (slugFor.get(handle)?.family ?? [handle]).some((slug) => orphanSlugs.has(slug));
|
|
2082
|
+
const fresh = [];
|
|
2083
|
+
const editor = [];
|
|
2084
|
+
const seen = new Set();
|
|
2085
|
+
for (const item of items) {
|
|
2086
|
+
if (seen.has(item.externalId)) {
|
|
2087
|
+
failures.push({ externalId: item.externalId, error: "duplicate-in-page: this externalId appears twice in the page." });
|
|
2088
|
+
continue;
|
|
2089
|
+
}
|
|
2090
|
+
seen.add(item.externalId);
|
|
2091
|
+
const mapping = mappingByExternalId.get(item.externalId);
|
|
2092
|
+
if (mapping && mapping.syncHash === hash(item)) {
|
|
2093
|
+
entityByExternalId.set(item.externalId, mapping.entityId);
|
|
2094
|
+
unchanged += 1;
|
|
2095
|
+
}
|
|
2096
|
+
else if (mapping || isOrphan(item.slug)) {
|
|
2097
|
+
editor.push(item);
|
|
2098
|
+
}
|
|
2099
|
+
else {
|
|
2100
|
+
fresh.push(item);
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
if (editor.length > 0) {
|
|
2104
|
+
const result = await this.convergeCatalogItems(orgId, storeId, editor, actor);
|
|
2105
|
+
if (!result.ok)
|
|
2106
|
+
return result;
|
|
2107
|
+
failures.push(...result.value.failures);
|
|
2108
|
+
warnings.push(...result.value.warnings);
|
|
2109
|
+
const failedIds = new Set(result.value.failures.map((failure) => failure.externalId));
|
|
2110
|
+
const survivors = editor.filter((item) => !failedIds.has(item.externalId));
|
|
2111
|
+
survivors.forEach((item, index) => {
|
|
2112
|
+
const entityId = result.value.entityIds[index];
|
|
2113
|
+
if (entityId !== undefined)
|
|
2114
|
+
entityByExternalId.set(item.externalId, entityId);
|
|
2115
|
+
});
|
|
2116
|
+
updated += survivors.length;
|
|
2117
|
+
}
|
|
2118
|
+
const createdItems = [];
|
|
2119
|
+
// Two stores onboarding at once can both resolve a handle as free and then both create it. The
|
|
2120
|
+
// loser's collision is transient: re-resolve those items once (the winner is now visible, so they
|
|
2121
|
+
// take the store-qualified slug) and try again. A second collision is reported as the item's.
|
|
2122
|
+
let pending = fresh;
|
|
2123
|
+
for (let attempt = 0; pending.length > 0; attempt += 1) {
|
|
2124
|
+
// The slug is resolved on the import row only: the map row's hash stays the hash of the item
|
|
2125
|
+
// as the store sent it, so the next page still reads it as unchanged.
|
|
2126
|
+
const report = await this.catalog.importProducts(pending.map((item) => toImportProduct({ ...item, slug: slugFor.get(item.slug)?.slug ?? item.slug })), { sourceStoreId: storeId, errorPolicy: "reject-failed-rows" }, actor);
|
|
2127
|
+
if (!report.ok)
|
|
2128
|
+
return PluginErr(report.error.message, report.error.code);
|
|
2129
|
+
const collided = [];
|
|
2130
|
+
for (const [index, row] of report.value.rows.entries()) {
|
|
2131
|
+
const item = pending[index];
|
|
2132
|
+
if (!item)
|
|
2133
|
+
continue;
|
|
2134
|
+
if (row.status === "failed") {
|
|
2135
|
+
const failure = { externalId: row.ref, error: `${row.code}: ${row.error}` };
|
|
2136
|
+
const lostSlug = row.code === "slug-conflict" || (row.code === "conflict" && isSlugConflict(row.error));
|
|
2137
|
+
if (attempt === 0 && lostSlug)
|
|
2138
|
+
collided.push({ item, failure });
|
|
2139
|
+
else
|
|
2140
|
+
failures.push(failure);
|
|
2141
|
+
continue;
|
|
2142
|
+
}
|
|
2143
|
+
warnings.push(...row.warnings);
|
|
2144
|
+
entityByExternalId.set(item.externalId, row.entityId);
|
|
2145
|
+
createdItems.push({ item, entityId: row.entityId, variantIds: row.variantIds });
|
|
2146
|
+
}
|
|
2147
|
+
pending = [];
|
|
2148
|
+
if (collided.length > 0) {
|
|
2149
|
+
const again = await this.resolveStoreSlugs(orgId, storeId, collided.map(({ item }) => item.slug));
|
|
2150
|
+
for (const { item, failure } of collided) {
|
|
2151
|
+
const before = slugFor.get(item.slug)?.slug ?? item.slug;
|
|
2152
|
+
const after = again.get(item.slug);
|
|
2153
|
+
// Retry only when another STORE took the handle: a slug held by a product made in
|
|
2154
|
+
// Merchant Center resolves to the same slug again, and stays the loud conflict it was.
|
|
2155
|
+
if (after === undefined || after.slug === before) {
|
|
2156
|
+
failures.push(failure);
|
|
2157
|
+
continue;
|
|
2158
|
+
}
|
|
2159
|
+
slugFor.set(item.slug, after);
|
|
2160
|
+
pending.push(item);
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
if (createdItems.length > 0) {
|
|
2165
|
+
const now = new Date();
|
|
2166
|
+
await this.db.insert(channelEntityMap).values(createdItems.flatMap(({ item, entityId, variantIds }) => [
|
|
2167
|
+
{ organizationId: orgId, storeId, kind: "entity", externalId: item.externalId, entityId, syncHash: hash(item), lastSyncedAt: now },
|
|
2168
|
+
...item.variants.flatMap((variant) => {
|
|
2169
|
+
const variantId = variantIds[variant.externalId];
|
|
2170
|
+
return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant", externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
|
|
2171
|
+
}),
|
|
2172
|
+
])).onConflictDoNothing();
|
|
2173
|
+
}
|
|
2174
|
+
const media = await this.importHeroes(orgId, createdItems, actor);
|
|
2175
|
+
const entityIds = [];
|
|
2176
|
+
const emitted = new Set();
|
|
2177
|
+
for (const item of items) {
|
|
2178
|
+
const entityId = entityByExternalId.get(item.externalId);
|
|
2179
|
+
if (entityId === undefined || emitted.has(entityId))
|
|
2180
|
+
continue;
|
|
2181
|
+
emitted.add(entityId);
|
|
2182
|
+
entityIds.push(entityId);
|
|
2183
|
+
}
|
|
2184
|
+
return Ok({
|
|
2185
|
+
created: createdItems.length,
|
|
2186
|
+
unchanged,
|
|
2187
|
+
updated,
|
|
2188
|
+
entityIds,
|
|
2189
|
+
failures,
|
|
2190
|
+
heroesImported: media.heroesImported,
|
|
2191
|
+
mediaFailures: media.mediaFailures,
|
|
2192
|
+
deferredMedia: media.deferredMedia,
|
|
2193
|
+
warnings,
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2196
|
+
async importHeroes(orgId, createdItems, actor) {
|
|
2197
|
+
const mediaFailures = [];
|
|
2198
|
+
const deferredMedia = [];
|
|
2199
|
+
const selections = createdItems.flatMap(({ item, entityId, variantIds }) => {
|
|
2200
|
+
const selection = selectImportImages(item);
|
|
2201
|
+
if (selection.perVariant.length > 0)
|
|
2202
|
+
deferredMedia.push({ externalId: item.externalId, entityId, images: selection.perVariant });
|
|
2203
|
+
return selection.hero ? [{ item, entityId, variantIds, hero: selection.hero }] : [];
|
|
2204
|
+
});
|
|
2205
|
+
if (selections.length === 0)
|
|
2206
|
+
return { heroesImported: 0, mediaFailures, deferredMedia };
|
|
2207
|
+
// One read for every hero the page might already hold (a re-import after the map was lost).
|
|
2208
|
+
const urlHashes = [...new Set(selections.map(({ hero }) => hash(hero.url)))];
|
|
2209
|
+
const existingAssets = await this.db.select({ id: mediaAssets.id, metadata: mediaAssets.metadata }).from(mediaAssets).where(and(eq(mediaAssets.organizationId, orgId), inArray(sql `${mediaAssets.metadata}->>'channelImageUrlHash'`, urlHashes)));
|
|
2210
|
+
const assetByUrlHash = new Map();
|
|
2211
|
+
for (const asset of existingAssets) {
|
|
2212
|
+
const urlHash = asset.metadata?.channelImageUrlHash;
|
|
2213
|
+
if (typeof urlHash === "string")
|
|
2214
|
+
assetByUrlHash.set(urlHash, asset.id);
|
|
2215
|
+
}
|
|
2216
|
+
const outcomes = [];
|
|
2217
|
+
const resolveHero = async ({ item, entityId, variantIds, hero }) => {
|
|
2218
|
+
const urlHash = hash(hero.url);
|
|
2219
|
+
const existing = assetByUrlHash.get(urlHash);
|
|
2220
|
+
if (existing !== undefined) {
|
|
2221
|
+
outcomes.push({ entityId, mediaAssetId: existing, hero, variantIds, imported: false });
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
const fetched = await fetchBounded(hero.url, HERO_IMAGE_BYTE_CAP);
|
|
2225
|
+
const failure = (reason, detail) => {
|
|
2226
|
+
mediaFailures.push({ externalId: item.externalId, ...(hero.externalId !== undefined ? { imageExternalId: hero.externalId } : {}), url: hero.url, reason, detail });
|
|
2227
|
+
};
|
|
2228
|
+
if (!fetched.ok) {
|
|
2229
|
+
failure(fetched.reason, fetched.detail);
|
|
2230
|
+
return;
|
|
2231
|
+
}
|
|
2232
|
+
const extension = fetched.contentType.split("/", 2)[1] ?? "jpg";
|
|
2233
|
+
const uploaded = await this.media.upload({
|
|
2234
|
+
filename: `${hero.externalId ?? urlHash}.${extension}`,
|
|
2235
|
+
contentType: fetched.contentType,
|
|
2236
|
+
data: fetched.bytes.buffer,
|
|
2237
|
+
...(hero.alt !== undefined ? { alt: hero.alt } : {}),
|
|
2238
|
+
metadata: { channelImageUrlHash: urlHash, ...(hero.externalId !== undefined ? { channelImageExternalId: hero.externalId } : {}) },
|
|
2239
|
+
origin: "imported",
|
|
2240
|
+
}, actor);
|
|
2241
|
+
if (!uploaded.ok) {
|
|
2242
|
+
failure(uploaded.error.code === "VALIDATION_FAILED" ? "unsupported" : "storage", uploaded.error.message);
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
assetByUrlHash.set(urlHash, uploaded.value.id);
|
|
2246
|
+
outcomes.push({ entityId, mediaAssetId: uploaded.value.id, hero, variantIds, imported: true });
|
|
2247
|
+
};
|
|
2248
|
+
// Six outbound connections per Worker invocation, two per image (download + storage put).
|
|
2249
|
+
const MAX_IN_FLIGHT = 3;
|
|
2250
|
+
let next = 0;
|
|
2251
|
+
await Promise.all(Array.from({ length: Math.min(MAX_IN_FLIGHT, selections.length) }, async () => {
|
|
2252
|
+
for (;;) {
|
|
2253
|
+
const selection = selections[next];
|
|
2254
|
+
next += 1;
|
|
2255
|
+
if (selection === undefined)
|
|
2256
|
+
return;
|
|
2257
|
+
await resolveHero(selection);
|
|
2258
|
+
}
|
|
2259
|
+
}));
|
|
2260
|
+
if (outcomes.length > 0) {
|
|
2261
|
+
await this.db.insert(entityMedia).values(outcomes.flatMap(({ entityId, mediaAssetId, hero, variantIds }) => [
|
|
2262
|
+
{ entityId, mediaAssetId, role: "primary", sortOrder: hero.sortOrder ?? 0 },
|
|
2263
|
+
...(hero.variantExternalIds ?? []).flatMap((externalId) => {
|
|
2264
|
+
const variantId = variantIds[externalId];
|
|
2265
|
+
return variantId === undefined ? [] : [{ entityId, variantId, mediaAssetId, role: hero.role, sortOrder: hero.sortOrder ?? 0 }];
|
|
2266
|
+
}),
|
|
2267
|
+
])).onConflictDoNothing();
|
|
2268
|
+
}
|
|
2269
|
+
return { heroesImported: outcomes.filter((outcome) => outcome.imported).length, mediaFailures, deferredMedia };
|
|
2270
|
+
}
|
|
1691
2271
|
async importCatalog(orgId, storeId, actor, options) {
|
|
1692
2272
|
const store = await this.getStoreRecord(orgId, storeId);
|
|
1693
2273
|
if (!store || store.status !== "connected") {
|
|
@@ -2089,6 +2669,10 @@ export class ChannelConnectorService {
|
|
|
2089
2669
|
async convergeCatalogItems(orgId, storeId, items, actor, force = false, dryRun = false) {
|
|
2090
2670
|
if (dryRun)
|
|
2091
2671
|
return this.estimateCatalogItems(orgId, storeId, items);
|
|
2672
|
+
// One converge, one taxonomy snapshot. Cleared HERE rather than left to the service's lifetime:
|
|
2673
|
+
// the instance can outlive a batch on a warm isolate, and a taxonomy cached across batches would
|
|
2674
|
+
// go stale in a way nothing reports.
|
|
2675
|
+
this.taxonomyCache = null;
|
|
2092
2676
|
let imported = 0;
|
|
2093
2677
|
let converged = 0;
|
|
2094
2678
|
let entitiesTouched = 0;
|
|
@@ -2102,6 +2686,8 @@ export class ChannelConnectorService {
|
|
|
2102
2686
|
const failures = [];
|
|
2103
2687
|
const entityIds = [];
|
|
2104
2688
|
const committed = new Set();
|
|
2689
|
+
// One resolution for the whole batch, not two round trips per changed product.
|
|
2690
|
+
const slugFor = await this.resolveStoreSlugs(orgId, storeId, items.map((item) => item.slug));
|
|
2105
2691
|
for (const item of items) {
|
|
2106
2692
|
consumed += 1;
|
|
2107
2693
|
try {
|
|
@@ -2127,8 +2713,15 @@ export class ChannelConnectorService {
|
|
|
2127
2713
|
existingEntity = entity;
|
|
2128
2714
|
}
|
|
2129
2715
|
}
|
|
2716
|
+
let resolvedSlug = slugFor.get(item.slug) ?? { slug: item.slug, family: [item.slug] };
|
|
2717
|
+
// A slug lost to a concurrent writer (another store onboarding the same handle) is transient:
|
|
2718
|
+
// re-resolve once, and the winner being visible now moves this item to its qualified slug.
|
|
2719
|
+
const reresolveSlug = async () => {
|
|
2720
|
+
resolvedSlug = (await this.resolveStoreSlugs(orgId, storeId, [item.slug])).get(item.slug) ?? resolvedSlug;
|
|
2721
|
+
slugFor.set(item.slug, resolvedSlug);
|
|
2722
|
+
};
|
|
2130
2723
|
if (entityId === undefined) {
|
|
2131
|
-
const [orphan] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.sourceStoreId, storeId),
|
|
2724
|
+
const [orphan] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.sourceStoreId, storeId), inArray(sellableEntities.slug, resolvedSlug.family))).limit(1);
|
|
2132
2725
|
if (orphan) {
|
|
2133
2726
|
entityId = orphan.id;
|
|
2134
2727
|
existingEntity = orphan;
|
|
@@ -2136,33 +2729,46 @@ export class ChannelConnectorService {
|
|
|
2136
2729
|
}
|
|
2137
2730
|
else {
|
|
2138
2731
|
const status = item.status;
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
forcedPushFieldPaths: [],
|
|
2160
|
-
});
|
|
2161
|
-
return created.value.id;
|
|
2732
|
+
const createEntity = (slug) => this.transact(async (tx) => {
|
|
2733
|
+
const txContext = createTxContext(tx, { actor });
|
|
2734
|
+
const created = await this.catalog.create({
|
|
2735
|
+
type: "product",
|
|
2736
|
+
slug,
|
|
2737
|
+
sourceStoreId: storeId,
|
|
2738
|
+
metadata: mergeMetadata(undefined, item.metadata ?? {}),
|
|
2739
|
+
...(status !== undefined ? { status, isVisible: status === "active" } : {}),
|
|
2740
|
+
}, actor, txContext);
|
|
2741
|
+
if (!created.ok)
|
|
2742
|
+
throw new Error(created.error.message);
|
|
2743
|
+
await tx.insert(channelEntityMap).values({
|
|
2744
|
+
organizationId: orgId,
|
|
2745
|
+
storeId,
|
|
2746
|
+
kind: "entity",
|
|
2747
|
+
externalId: item.externalId,
|
|
2748
|
+
entityId: created.value.id,
|
|
2749
|
+
syncHash: PENDING_ENTITY_MAP_SYNC_HASH,
|
|
2750
|
+
heldFieldPaths: [],
|
|
2751
|
+
forcedPushFieldPaths: [],
|
|
2162
2752
|
});
|
|
2753
|
+
return created.value.id;
|
|
2754
|
+
});
|
|
2755
|
+
let createError;
|
|
2756
|
+
for (let attempt = 0; attempt < 2 && entityId === undefined; attempt += 1) {
|
|
2757
|
+
try {
|
|
2758
|
+
entityId = await createEntity(resolvedSlug.slug);
|
|
2759
|
+
}
|
|
2760
|
+
catch (error) {
|
|
2761
|
+
createError = error;
|
|
2762
|
+
if (attempt > 0 || !isSlugConflict(error))
|
|
2763
|
+
break;
|
|
2764
|
+
const before = resolvedSlug.slug;
|
|
2765
|
+
await reresolveSlug();
|
|
2766
|
+
if (resolvedSlug.slug === before)
|
|
2767
|
+
break;
|
|
2768
|
+
}
|
|
2163
2769
|
}
|
|
2164
|
-
|
|
2165
|
-
failures.push({ externalId: item.externalId, error:
|
|
2770
|
+
if (entityId === undefined) {
|
|
2771
|
+
failures.push({ externalId: item.externalId, error: createError instanceof Error ? createError.message : "Failed to create catalog entity." });
|
|
2166
2772
|
continue;
|
|
2167
2773
|
}
|
|
2168
2774
|
isNew = true;
|
|
@@ -2214,8 +2820,9 @@ export class ChannelConnectorService {
|
|
|
2214
2820
|
? writable.status ?? (existingEntity.status === "archived" ? "active" : undefined)
|
|
2215
2821
|
: undefined;
|
|
2216
2822
|
const updateInput = {};
|
|
2217
|
-
|
|
2218
|
-
|
|
2823
|
+
const keptSlug = this.slugToKeep(existingEntity.slug, resolvedSlug);
|
|
2824
|
+
if (ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && existingEntity.slug !== keptSlug) {
|
|
2825
|
+
updateInput.slug = keptSlug;
|
|
2219
2826
|
}
|
|
2220
2827
|
if (hash(remoteMetadata) !== hash(existingEntity.metadata ?? {}))
|
|
2221
2828
|
updateInput.metadata = remoteMetadata;
|
|
@@ -2227,11 +2834,29 @@ export class ChannelConnectorService {
|
|
|
2227
2834
|
? Object.keys(updateInput).length > 0
|
|
2228
2835
|
: remoteChanged || existingEntity.status === "archived";
|
|
2229
2836
|
if (shouldUpdate) {
|
|
2230
|
-
converged += 1;
|
|
2231
2837
|
if (Object.keys(updateInput).length > 0) {
|
|
2232
|
-
const
|
|
2233
|
-
|
|
2234
|
-
|
|
2838
|
+
const mappedEntityId = entityMapping.entityId;
|
|
2839
|
+
const update = async () => {
|
|
2840
|
+
try {
|
|
2841
|
+
const updated = await this.catalog.update(mappedEntityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
|
|
2842
|
+
return updated.ok ? undefined : updated.error;
|
|
2843
|
+
}
|
|
2844
|
+
catch (error) {
|
|
2845
|
+
return error;
|
|
2846
|
+
}
|
|
2847
|
+
};
|
|
2848
|
+
let updateError = await update();
|
|
2849
|
+
if (updateError !== undefined && updateInput.slug !== undefined && isSlugConflict(updateError)) {
|
|
2850
|
+
await reresolveSlug();
|
|
2851
|
+
const retrySlug = this.slugToKeep(existingEntity.slug, resolvedSlug);
|
|
2852
|
+
if (retrySlug === existingEntity.slug)
|
|
2853
|
+
delete updateInput.slug;
|
|
2854
|
+
else
|
|
2855
|
+
updateInput.slug = retrySlug;
|
|
2856
|
+
updateError = Object.keys(updateInput).length > 0 ? await update() : undefined;
|
|
2857
|
+
}
|
|
2858
|
+
if (updateError !== undefined) {
|
|
2859
|
+
failures.push({ externalId: item.externalId, error: errorMessage(updateError) });
|
|
2235
2860
|
continue;
|
|
2236
2861
|
}
|
|
2237
2862
|
entityTouched = true;
|
|
@@ -2245,8 +2870,12 @@ export class ChannelConnectorService {
|
|
|
2245
2870
|
if (!attributes.ok)
|
|
2246
2871
|
return attributes;
|
|
2247
2872
|
const variantIds = await this.upsertVariants(orgId, storeId, entityId, writable, optionAxes.value.value, actor, warnings, !heldSharedPaths.includes("options") && owners.get("options") !== "platform", item);
|
|
2248
|
-
|
|
2249
|
-
|
|
2873
|
+
// A variant the store's own data makes unwritable (a sku another of its products holds) is
|
|
2874
|
+
// that item's failure, reported with its error — not a page error the caller would retry.
|
|
2875
|
+
if (!variantIds.ok) {
|
|
2876
|
+
failures.push({ externalId: item.externalId, error: variantIds.error });
|
|
2877
|
+
continue;
|
|
2878
|
+
}
|
|
2250
2879
|
const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
|
|
2251
2880
|
if (!taxonomy.ok)
|
|
2252
2881
|
return taxonomy;
|
|
@@ -2260,6 +2889,10 @@ export class ChannelConnectorService {
|
|
|
2260
2889
|
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
|
|
2261
2890
|
if (entityTouched)
|
|
2262
2891
|
entitiesTouched += 1;
|
|
2892
|
+
// Counted from what was written, not from the stored hash: a hash that moved while the
|
|
2893
|
+
// product did not (a blanked map row, a change in how an item serialises) is not drift.
|
|
2894
|
+
if (entityTouched && entityMapping && !isNew)
|
|
2895
|
+
converged += 1;
|
|
2263
2896
|
if (entityTouched) {
|
|
2264
2897
|
const revision = await this.catalog.recordEntityRevision(entityId, actor, "import");
|
|
2265
2898
|
if (!revision.ok) {
|
|
@@ -2382,12 +3015,14 @@ export class ChannelConnectorService {
|
|
|
2382
3015
|
if (!mapping)
|
|
2383
3016
|
continue;
|
|
2384
3017
|
const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
|
|
2385
|
-
|
|
3018
|
+
// Stock cannot sit below zero here, so negative remote stock compares as the zero it is stored as.
|
|
3019
|
+
const quantity = Math.max(0, level.available);
|
|
3020
|
+
if (current?.quantityOnHand === quantity)
|
|
2386
3021
|
continue;
|
|
2387
3022
|
const result = await inventoryService.setAbsolute({
|
|
2388
3023
|
entityId: mapping.entityId,
|
|
2389
3024
|
...(mapping.variantId ? { variantId: mapping.variantId } : {}),
|
|
2390
|
-
quantity
|
|
3025
|
+
quantity,
|
|
2391
3026
|
reason: `Inventory reconciliation from ${store.provider}`,
|
|
2392
3027
|
}, actor);
|
|
2393
3028
|
if (!result.ok)
|
|
@@ -2594,12 +3229,14 @@ export class ChannelConnectorService {
|
|
|
2594
3229
|
if (!mapping)
|
|
2595
3230
|
continue;
|
|
2596
3231
|
const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
|
|
2597
|
-
|
|
3232
|
+
// Stock cannot sit below zero here, so negative remote stock compares as the zero it is stored as.
|
|
3233
|
+
const quantity = Math.max(0, level.available);
|
|
3234
|
+
if (current?.quantityOnHand === quantity)
|
|
2598
3235
|
continue;
|
|
2599
3236
|
const result = await inventoryService.setAbsolute({
|
|
2600
3237
|
entityId: mapping.entityId,
|
|
2601
3238
|
...(mapping.variantId ? { variantId: mapping.variantId } : {}),
|
|
2602
|
-
quantity
|
|
3239
|
+
quantity,
|
|
2603
3240
|
reason: `Inventory sync from ${store.provider}`,
|
|
2604
3241
|
}, actor);
|
|
2605
3242
|
if (!result.ok)
|
|
@@ -2866,8 +3503,11 @@ export class ChannelConnectorService {
|
|
|
2866
3503
|
const warnings = conflicts.map((conflict) => `Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
|
|
2867
3504
|
const writable = held.writable;
|
|
2868
3505
|
const updateInput = {};
|
|
2869
|
-
if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") &&
|
|
2870
|
-
|
|
3506
|
+
if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && typeof writable.slug === "string") {
|
|
3507
|
+
const resolved = (await this.resolveStoreSlugs(orgId, storeId, [writable.slug])).get(writable.slug);
|
|
3508
|
+
const slug = resolved ? this.slugToKeep(entity.slug, resolved) : writable.slug;
|
|
3509
|
+
if (entity.slug !== slug)
|
|
3510
|
+
updateInput.slug = slug;
|
|
2871
3511
|
}
|
|
2872
3512
|
if (Object.keys(writable.metadata ?? {}).length > 0) {
|
|
2873
3513
|
const remoteEntityMetadata = mergeMetadata(entity.metadata, writable.metadata ?? {});
|