@porulle/plugin-channel-connector 0.36.0 → 0.38.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/schema.js +2 -2
- package/dist/service.js +102 -19
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +2 -2
- package/src/schema.ts +2 -2
- package/src/service.ts +114 -16
package/dist/schema.js
CHANGED
|
@@ -48,7 +48,7 @@ export const channelCatalogConflicts = pgTable("channel_catalog_conflicts", {
|
|
|
48
48
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
49
49
|
organizationId: text("organization_id").notNull(),
|
|
50
50
|
storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
|
|
51
|
-
entityId: uuid("entity_id").notNull(),
|
|
51
|
+
entityId: uuid("entity_id").references(() => sellableEntities.id, { onDelete: "cascade" }).notNull(),
|
|
52
52
|
fieldPath: text("field_path").notNull(),
|
|
53
53
|
platformValue: jsonb("platform_value").$type().notNull(),
|
|
54
54
|
storeValue: jsonb("store_value").$type().notNull(),
|
|
@@ -102,7 +102,7 @@ export const channelCatalogPushes = pgTable("channel_catalog_pushes", {
|
|
|
102
102
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
103
103
|
organizationId: text("organization_id").notNull(),
|
|
104
104
|
storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
|
|
105
|
-
entityId: uuid("entity_id").notNull(),
|
|
105
|
+
entityId: uuid("entity_id").references(() => sellableEntities.id, { onDelete: "cascade" }).notNull(),
|
|
106
106
|
payloadSnapshot: jsonb("payload_snapshot").$type(),
|
|
107
107
|
state: text("state", { enum: ["pending", "exported", "confirmed", "failed", "abandoned"] })
|
|
108
108
|
.notNull()
|
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
|
|
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
|
-
|
|
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
|
|
908
|
-
|
|
909
|
-
return
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
|
|
920
|
-
|
|
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
|
-
|
|
924
|
-
|
|
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
|
-
|
|
941
|
-
|
|
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
|