@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.37.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.37.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/typescript-config": "0.1.0",
30
- "@porulle/eslint-config": "0.1.0"
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 assets = await this.db.select().from(mediaAssets).where(eq(mediaAssets.organizationId, orgId));
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
- for (const image of item.images ?? []) {
1445
- const urlHash = hash(image.url);
1446
- const asset = assets.find((row) => {
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
- let mediaAssetId = asset?.id;
1452
- if (!mediaAssetId) {
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
- warnings.push(`Skipped image "${image.externalId ?? image.url}": ${error instanceof Error ? error.message : "download failed"}.`);
1458
- continue;
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
- warnings.push(`Skipped image "${image.externalId ?? image.url}": download returned ${response.status}.`);
1462
- continue;
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
- warnings.push(`Skipped image "${image.externalId ?? image.url}": ${uploaded.error.code === "STORAGE_NOT_SUPPORTED" ? "storage adapter is not configured" : uploaded.error.message}.`);
1479
- continue;
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