@porulle/plugin-channel-connector 0.37.0 → 0.39.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/service.js CHANGED
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, } from "@porulle/core";
3
3
  import { isValidFieldPath, requireUserId } from "@porulle/core";
4
4
  import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
5
- import { and, desc, eq, inArray, isNull, lte } from "@porulle/core/drizzle";
5
+ import { and, desc, eq, inArray, isNull, lte, or, sql } from "@porulle/core/drizzle";
6
6
  import { brands, categories, customerAddresses, customers, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
7
7
  import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
8
8
  import { mergeCatalogFieldMapping, normalizeCatalogFieldMapping, selectCatalogFieldMapping, } from "./catalog-field-mapping.js";
@@ -897,31 +897,74 @@ export class ChannelConnectorService {
897
897
  return Ok(undefined);
898
898
  }
899
899
  async applyMedia(orgId, entityId, item, variantIds, actor, warnings, owners) {
900
- const assets = await this.db.select().from(mediaAssets).where(eq(mediaAssets.organizationId, orgId));
900
+ const images = item.images ?? [];
901
+ const externalIds = [...new Set(images.map((image) => image.externalId).filter((id) => id != null))];
902
+ const urlHashes = [...new Set(images.map((image) => hash(image.url)))];
903
+ const keyPredicates = [];
904
+ if (externalIds.length > 0) {
905
+ keyPredicates.push(inArray(sql `${mediaAssets.metadata}->>'channelImageExternalId'`, externalIds));
906
+ }
907
+ if (urlHashes.length > 0) {
908
+ keyPredicates.push(inArray(sql `${mediaAssets.metadata}->>'channelImageUrlHash'`, urlHashes));
909
+ }
910
+ const assets = keyPredicates.length === 0
911
+ ? []
912
+ : await this.db.select().from(mediaAssets).where(and(eq(mediaAssets.organizationId, orgId), or(...keyPredicates)));
901
913
  const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entityId));
902
914
  let imported = 0;
903
915
  let changed = false;
904
916
  const skipped = [];
905
- for (const image of item.images ?? []) {
917
+ // A Cloudflare Worker may hold at most six simultaneous outbound connections per
918
+ // invocation, and one image costs two of them — the download and the storage put — so
919
+ // 6 / 2 = 3 images may be in flight. A fourth would queue behind the platform limit
920
+ // rather than go faster.
921
+ const MAX_CONCURRENT_IMAGE_IMPORTS = 6 / 2;
922
+ const findMatchingAsset = (image, urlHash) => assets.find((row) => {
923
+ const metadata = row.metadata ?? {};
924
+ return (image.externalId != null && metadata.channelImageExternalId === image.externalId)
925
+ || metadata.channelImageUrlHash === urlHash;
926
+ });
927
+ const inFlightKeys = (image, urlHash) => {
928
+ const keys = [`hash:${urlHash}`];
929
+ if (image.externalId != null)
930
+ keys.push(`ext:${image.externalId}`);
931
+ return keys;
932
+ };
933
+ const inFlightUploads = new Map();
934
+ const resolveImageAsset = async (image) => {
906
935
  const urlHash = hash(image.url);
907
- const asset = assets.find((row) => {
908
- const metadata = row.metadata ?? {};
909
- return (image.externalId != null && metadata.channelImageExternalId === image.externalId)
910
- || metadata.channelImageUrlHash === urlHash;
911
- });
912
- let mediaAssetId = asset?.id;
913
- if (!mediaAssetId) {
936
+ const existing = findMatchingAsset(image, urlHash);
937
+ if (existing) {
938
+ return { mediaAssetId: existing.id, imageWarnings: [], imported: 0, imageChanged: false };
939
+ }
940
+ const keys = inFlightKeys(image, urlHash);
941
+ for (const key of keys) {
942
+ const pending = inFlightUploads.get(key);
943
+ // Sharing one upload must not mean sharing its tally. Serially, a second reference to the
944
+ // same image found the asset the first had just pushed into `assets` and counted nothing;
945
+ // returning the first reference's result object here would count one stored object twice
946
+ // and push its warning twice, and `mediaImported` is reported to the caller.
947
+ if (pending)
948
+ return { ...(await pending), imageWarnings: [], imported: 0, imageChanged: false };
949
+ }
950
+ const uploadPromise = (async () => {
914
951
  let response;
915
952
  try {
916
953
  response = await fetch(image.url);
917
954
  }
918
955
  catch (error) {
919
- warnings.push(`Skipped image "${image.externalId ?? image.url}": ${error instanceof Error ? error.message : "download failed"}.`);
920
- continue;
956
+ return {
957
+ imageWarnings: [`Skipped image "${image.externalId ?? image.url}": ${error instanceof Error ? error.message : "download failed"}.`],
958
+ imported: 0,
959
+ imageChanged: false,
960
+ };
921
961
  }
922
962
  if (!response.ok) {
923
- warnings.push(`Skipped image "${image.externalId ?? image.url}": download returned ${response.status}.`);
924
- continue;
963
+ return {
964
+ imageWarnings: [`Skipped image "${image.externalId ?? image.url}": download returned ${response.status}.`],
965
+ imported: 0,
966
+ imageChanged: false,
967
+ };
925
968
  }
926
969
  const contentType = response.headers.get("content-type")?.split(";", 1)[0] ?? "image/jpeg";
927
970
  const extension = contentType.split("/", 2)[1] ?? "jpg";
@@ -937,16 +980,56 @@ export class ChannelConnectorService {
937
980
  origin: "imported",
938
981
  }, actor);
939
982
  if (!uploaded.ok) {
940
- warnings.push(`Skipped image "${image.externalId ?? image.url}": ${uploaded.error.code === "STORAGE_NOT_SUPPORTED" ? "storage adapter is not configured" : uploaded.error.message}.`);
941
- continue;
983
+ return {
984
+ imageWarnings: [`Skipped image "${image.externalId ?? image.url}": ${uploaded.error.code === "STORAGE_NOT_SUPPORTED" ? "storage adapter is not configured" : uploaded.error.message}.`],
985
+ imported: 0,
986
+ imageChanged: false,
987
+ };
942
988
  }
943
- mediaAssetId = uploaded.value.id;
944
- imported += 1;
945
- changed = true;
989
+ const mediaAssetId = uploaded.value.id;
946
990
  const [createdAsset] = await this.db.select().from(mediaAssets).where(eq(mediaAssets.id, mediaAssetId));
947
991
  if (createdAsset)
948
992
  assets.push(createdAsset);
993
+ return { mediaAssetId, imageWarnings: [], imported: 1, imageChanged: true };
994
+ })();
995
+ for (const key of keys)
996
+ inFlightUploads.set(key, uploadPromise);
997
+ try {
998
+ return await uploadPromise;
999
+ }
1000
+ finally {
1001
+ for (const key of keys)
1002
+ inFlightUploads.delete(key);
949
1003
  }
1004
+ };
1005
+ const resolvedImages = images.length === 0
1006
+ ? []
1007
+ : await (async () => {
1008
+ const results = new Array(images.length);
1009
+ let nextIndex = 0;
1010
+ const worker = async () => {
1011
+ while (true) {
1012
+ const index = nextIndex;
1013
+ nextIndex += 1;
1014
+ // `index >= images.length` and `images[index] === undefined` are the same condition
1015
+ // here, and under noUncheckedIndexedAccess only the second one the compiler can see.
1016
+ const image = images[index];
1017
+ if (image === undefined)
1018
+ return;
1019
+ results[index] = await resolveImageAsset(image);
1020
+ }
1021
+ };
1022
+ await Promise.all(Array.from({ length: Math.min(MAX_CONCURRENT_IMAGE_IMPORTS, images.length) }, () => worker()));
1023
+ return results;
1024
+ })();
1025
+ for (const resolved of resolvedImages) {
1026
+ warnings.push(...resolved.imageWarnings);
1027
+ imported += resolved.imported;
1028
+ if (resolved.imageChanged)
1029
+ changed = true;
1030
+ }
1031
+ for (const [imageIndex, image] of images.entries()) {
1032
+ const mediaAssetId = resolvedImages[imageIndex]?.mediaAssetId;
950
1033
  if (!mediaAssetId)
951
1034
  continue;
952
1035
  const targets = image.variantExternalIds?.length