@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 +102 -19
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +4 -4
- package/src/service.ts +114 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/plugin-channel-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -19,15 +19,15 @@
|
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@hono/zod-openapi": "^1.2.2",
|
|
21
21
|
"hono": "^4.12.5",
|
|
22
|
-
"@porulle/core": "0.
|
|
22
|
+
"@porulle/core": "0.39.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/node": "^24.5.2",
|
|
26
26
|
"eslint": "^9.39.1",
|
|
27
27
|
"typescript": "5.9.2",
|
|
28
28
|
"vitest": "^3.2.4",
|
|
29
|
-
"@porulle/
|
|
30
|
-
"@porulle/
|
|
29
|
+
"@porulle/eslint-config": "0.1.0",
|
|
30
|
+
"@porulle/typescript-config": "0.1.0"
|
|
31
31
|
},
|
|
32
32
|
"publishConfig": {
|
|
33
33
|
"access": "public"
|
package/src/service.ts
CHANGED
|
@@ -29,7 +29,7 @@ import { isValidFieldPath, requireUserId } from "@porulle/core";
|
|
|
29
29
|
import type { FieldOwner, FieldPath } from "@porulle/core";
|
|
30
30
|
import type { JobsAdapter } from "@porulle/core";
|
|
31
31
|
import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
|
|
32
|
-
import { and, desc, eq, inArray, isNull, lte } from "@porulle/core/drizzle";
|
|
32
|
+
import { and, desc, eq, inArray, isNull, lte, or, sql } from "@porulle/core/drizzle";
|
|
33
33
|
import {
|
|
34
34
|
brands,
|
|
35
35
|
categories,
|
|
@@ -1436,30 +1436,87 @@ export class ChannelConnectorService {
|
|
|
1436
1436
|
warnings: string[],
|
|
1437
1437
|
owners: Map<FieldPath, FieldOwner>,
|
|
1438
1438
|
): Promise<PluginResult<{ imported: number; changed: boolean; skipped: FieldPath[] }>> {
|
|
1439
|
-
const
|
|
1439
|
+
const images = item.images ?? [];
|
|
1440
|
+
const externalIds = [...new Set(images.map((image) => image.externalId).filter((id): id is string => id != null))];
|
|
1441
|
+
const urlHashes = [...new Set(images.map((image) => hash(image.url)))];
|
|
1442
|
+
const keyPredicates = [];
|
|
1443
|
+
if (externalIds.length > 0) {
|
|
1444
|
+
keyPredicates.push(inArray(sql`${mediaAssets.metadata}->>'channelImageExternalId'`, externalIds));
|
|
1445
|
+
}
|
|
1446
|
+
if (urlHashes.length > 0) {
|
|
1447
|
+
keyPredicates.push(inArray(sql`${mediaAssets.metadata}->>'channelImageUrlHash'`, urlHashes));
|
|
1448
|
+
}
|
|
1449
|
+
const assets = keyPredicates.length === 0
|
|
1450
|
+
? []
|
|
1451
|
+
: await this.db.select().from(mediaAssets).where(and(
|
|
1452
|
+
eq(mediaAssets.organizationId, orgId),
|
|
1453
|
+
or(...keyPredicates),
|
|
1454
|
+
));
|
|
1440
1455
|
const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entityId));
|
|
1441
1456
|
let imported = 0;
|
|
1442
1457
|
let changed = false;
|
|
1443
1458
|
const skipped: FieldPath[] = [];
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1459
|
+
|
|
1460
|
+
// A Cloudflare Worker may hold at most six simultaneous outbound connections per
|
|
1461
|
+
// invocation, and one image costs two of them — the download and the storage put — so
|
|
1462
|
+
// 6 / 2 = 3 images may be in flight. A fourth would queue behind the platform limit
|
|
1463
|
+
// rather than go faster.
|
|
1464
|
+
const MAX_CONCURRENT_IMAGE_IMPORTS = 6 / 2;
|
|
1465
|
+
|
|
1466
|
+
type ResolvedImage = {
|
|
1467
|
+
mediaAssetId?: string;
|
|
1468
|
+
imageWarnings: string[];
|
|
1469
|
+
imported: number;
|
|
1470
|
+
imageChanged: boolean;
|
|
1471
|
+
};
|
|
1472
|
+
|
|
1473
|
+
const findMatchingAsset = (image: { externalId?: string; url: string }, urlHash: string) =>
|
|
1474
|
+
assets.find((row) => {
|
|
1447
1475
|
const metadata = row.metadata ?? {};
|
|
1448
1476
|
return (image.externalId != null && metadata.channelImageExternalId === image.externalId)
|
|
1449
1477
|
|| metadata.channelImageUrlHash === urlHash;
|
|
1450
1478
|
});
|
|
1451
|
-
|
|
1452
|
-
|
|
1479
|
+
|
|
1480
|
+
const inFlightKeys = (image: { externalId?: string }, urlHash: string) => {
|
|
1481
|
+
const keys = [`hash:${urlHash}`];
|
|
1482
|
+
if (image.externalId != null) keys.push(`ext:${image.externalId}`);
|
|
1483
|
+
return keys;
|
|
1484
|
+
};
|
|
1485
|
+
|
|
1486
|
+
const inFlightUploads = new Map<string, Promise<ResolvedImage>>();
|
|
1487
|
+
|
|
1488
|
+
const resolveImageAsset = async (image: NonNullable<ChannelCatalogItem["images"]>[number]): Promise<ResolvedImage> => {
|
|
1489
|
+
const urlHash = hash(image.url);
|
|
1490
|
+
const existing = findMatchingAsset(image, urlHash);
|
|
1491
|
+
if (existing) {
|
|
1492
|
+
return { mediaAssetId: existing.id, imageWarnings: [], imported: 0, imageChanged: false };
|
|
1493
|
+
}
|
|
1494
|
+
const keys = inFlightKeys(image, urlHash);
|
|
1495
|
+
for (const key of keys) {
|
|
1496
|
+
const pending = inFlightUploads.get(key);
|
|
1497
|
+
// Sharing one upload must not mean sharing its tally. Serially, a second reference to the
|
|
1498
|
+
// same image found the asset the first had just pushed into `assets` and counted nothing;
|
|
1499
|
+
// returning the first reference's result object here would count one stored object twice
|
|
1500
|
+
// and push its warning twice, and `mediaImported` is reported to the caller.
|
|
1501
|
+
if (pending) return { ...(await pending), imageWarnings: [], imported: 0, imageChanged: false };
|
|
1502
|
+
}
|
|
1503
|
+
const uploadPromise = (async (): Promise<ResolvedImage> => {
|
|
1453
1504
|
let response: Response;
|
|
1454
1505
|
try {
|
|
1455
1506
|
response = await fetch(image.url);
|
|
1456
1507
|
} catch (error) {
|
|
1457
|
-
|
|
1458
|
-
|
|
1508
|
+
return {
|
|
1509
|
+
imageWarnings: [`Skipped image "${image.externalId ?? image.url}": ${error instanceof Error ? error.message : "download failed"}.`],
|
|
1510
|
+
imported: 0,
|
|
1511
|
+
imageChanged: false,
|
|
1512
|
+
};
|
|
1459
1513
|
}
|
|
1460
1514
|
if (!response.ok) {
|
|
1461
|
-
|
|
1462
|
-
|
|
1515
|
+
return {
|
|
1516
|
+
imageWarnings: [`Skipped image "${image.externalId ?? image.url}": download returned ${response.status}.`],
|
|
1517
|
+
imported: 0,
|
|
1518
|
+
imageChanged: false,
|
|
1519
|
+
};
|
|
1463
1520
|
}
|
|
1464
1521
|
const contentType = response.headers.get("content-type")?.split(";", 1)[0] ?? "image/jpeg";
|
|
1465
1522
|
const extension = contentType.split("/", 2)[1] ?? "jpg";
|
|
@@ -1475,15 +1532,56 @@ export class ChannelConnectorService {
|
|
|
1475
1532
|
origin: "imported",
|
|
1476
1533
|
}, actor);
|
|
1477
1534
|
if (!uploaded.ok) {
|
|
1478
|
-
|
|
1479
|
-
|
|
1535
|
+
return {
|
|
1536
|
+
imageWarnings: [`Skipped image "${image.externalId ?? image.url}": ${uploaded.error.code === "STORAGE_NOT_SUPPORTED" ? "storage adapter is not configured" : uploaded.error.message}.`],
|
|
1537
|
+
imported: 0,
|
|
1538
|
+
imageChanged: false,
|
|
1539
|
+
};
|
|
1480
1540
|
}
|
|
1481
|
-
mediaAssetId = uploaded.value.id;
|
|
1482
|
-
imported += 1;
|
|
1483
|
-
changed = true;
|
|
1541
|
+
const mediaAssetId = uploaded.value.id;
|
|
1484
1542
|
const [createdAsset] = await this.db.select().from(mediaAssets).where(eq(mediaAssets.id, mediaAssetId));
|
|
1485
1543
|
if (createdAsset) assets.push(createdAsset);
|
|
1544
|
+
return { mediaAssetId, imageWarnings: [], imported: 1, imageChanged: true };
|
|
1545
|
+
})();
|
|
1546
|
+
for (const key of keys) inFlightUploads.set(key, uploadPromise);
|
|
1547
|
+
try {
|
|
1548
|
+
return await uploadPromise;
|
|
1549
|
+
} finally {
|
|
1550
|
+
for (const key of keys) inFlightUploads.delete(key);
|
|
1486
1551
|
}
|
|
1552
|
+
};
|
|
1553
|
+
|
|
1554
|
+
const resolvedImages: ResolvedImage[] = images.length === 0
|
|
1555
|
+
? []
|
|
1556
|
+
: await (async () => {
|
|
1557
|
+
const results: ResolvedImage[] = new Array(images.length);
|
|
1558
|
+
let nextIndex = 0;
|
|
1559
|
+
const worker = async () => {
|
|
1560
|
+
while (true) {
|
|
1561
|
+
const index = nextIndex;
|
|
1562
|
+
nextIndex += 1;
|
|
1563
|
+
// `index >= images.length` and `images[index] === undefined` are the same condition
|
|
1564
|
+
// here, and under noUncheckedIndexedAccess only the second one the compiler can see.
|
|
1565
|
+
const image = images[index];
|
|
1566
|
+
if (image === undefined) return;
|
|
1567
|
+
results[index] = await resolveImageAsset(image);
|
|
1568
|
+
}
|
|
1569
|
+
};
|
|
1570
|
+
await Promise.all(Array.from(
|
|
1571
|
+
{ length: Math.min(MAX_CONCURRENT_IMAGE_IMPORTS, images.length) },
|
|
1572
|
+
() => worker(),
|
|
1573
|
+
));
|
|
1574
|
+
return results;
|
|
1575
|
+
})();
|
|
1576
|
+
|
|
1577
|
+
for (const resolved of resolvedImages) {
|
|
1578
|
+
warnings.push(...resolved.imageWarnings);
|
|
1579
|
+
imported += resolved.imported;
|
|
1580
|
+
if (resolved.imageChanged) changed = true;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
for (const [imageIndex, image] of images.entries()) {
|
|
1584
|
+
const mediaAssetId = resolvedImages[imageIndex]?.mediaAssetId;
|
|
1487
1585
|
if (!mediaAssetId) continue;
|
|
1488
1586
|
|
|
1489
1587
|
const targets = image.variantExternalIds?.length
|