@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/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
- const existingTypes = await this.db.select().from(optionTypes).where(eq(optionTypes.entityId, entityId));
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
- const [createdType] = await this.db.select().from(optionTypes).where(eq(optionTypes.id, created.value.id));
1324
- if (!createdType) return PluginErr(`Option type "${sourceType.name}" was not persisted.`);
1325
- optionType = createdType;
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
- await this.db.update(optionTypes).set({
1330
- displayName: sourceType.displayName,
1331
- sortOrder: sourceType.sortOrder ?? typeIndex,
1332
- }).where(eq(optionTypes.id, optionType.id));
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 = await this.db.select().from(optionValues).where(eq(optionValues.optionTypeId, optionType.id));
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
- const [createdValue] = await this.db.select().from(optionValues).where(eq(optionValues.id, created.value.id));
1342
- if (!createdValue) return PluginErr(`Option value "${sourceValue.value}" was not persisted.`);
1343
- optionValue = createdValue;
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
- await this.db.update(optionValues).set({
1348
- displayValue: sourceValue.displayValue,
1349
- sortOrder: sourceValue.sortOrder ?? valueIndex,
1350
- }).where(eq(optionValues.id, optionValue.id));
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
- const [createdMapping] = await this.db.insert(channelEntityMap).values({
1402
- organizationId: orgId,
1403
- storeId,
1404
- kind: "variant",
1405
- externalId: sourceVariant.externalId,
1406
- entityId,
1407
- variantId,
1408
- syncHash: hash(fullSourceVariant),
1409
- }).returning();
1410
- mapping = createdMapping;
1411
- if (mapping) mappings.push(mapping);
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 currentOptionValues = await this.db.select().from(variantOptionValues).where(eq(variantOptionValues.variantId, variantId));
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
- if (mapping) {
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: hash(fullSourceVariant),
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 categoryRows = await this.db.select().from(categories).where(eq(categories.organizationId, orgId));
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 = await this.db.select().from(brands).where(eq(brands.organizationId, orgId));
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 = await this.db.select().from(tags).where(eq(tags.organizationId, orgId));
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;