@porulle/plugin-channel-connector 0.78.0 → 0.79.1

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.d.ts CHANGED
@@ -355,6 +355,10 @@ export declare const channelOrderAddressSchema: z.ZodObject<{
355
355
  * A hero is streamed inside the page's own invocation, so it is bounded: a 30 MB TIFF a merchant
356
356
  * uploaded by mistake must not buffer into a 128 MiB isolate. Anything larger is reported, not
357
357
  * stored, and the product still lands — the index reads text first and media later.
358
+ *
359
+ * 4 MiB, not 1: at 1 MiB the cap refused ordinary product photographs — 16 of 100 Kelly Felder
360
+ * heroes and 11 of 100 Arienti, all between 1 and 2 MiB, measured 2026-10-06 — and a product with
361
+ * no photo is left out of every agent feed. Three in flight at 4 MiB stays far under the isolate.
358
362
  */
359
363
  export declare const HERO_IMAGE_BYTE_CAP: number;
360
364
  export type CatalogMediaFailureReason = "too-large" | "download-failed" | "unsupported" | "storage";
@@ -391,12 +395,16 @@ export interface ImportImageSelection {
391
395
  hero: ChannelCatalogImage | null;
392
396
  /** In variant order; one image per variant the hero does not cover; no url twice. */
393
397
  perVariant: ChannelCatalogImage[];
398
+ /** The store's further photos in its order, as entity-level `gallery` images; no url twice. */
399
+ gallery: ChannelCatalogImage[];
394
400
  }
395
401
  /**
396
- * Ruling 2026-09-22: import the hero plus the FIRST photo of each other variant, nothing more.
397
- * A "blue long dress" query must be able to show the blue variant, and a fourth photo of the red
398
- * one adds nothing the index can use. Variants are read off the images' own variant references,
399
- * so a connector that lists images against variants it does not enumerate still gets one each.
402
+ * The hero, then the first photo of each other variant (a "blue long dress" query must be able to
403
+ * show the blue variant), then the store's further photos as the gallery, up to
404
+ * `IMPORT_IMAGE_LIMIT` in all. Agent feeds publish the gallery as additional images and enrichment
405
+ * reads it; only the hero is embedded, so a gallery photo costs an upload and no model call.
406
+ * Variants are read off the images' own variant references, so a connector that lists images
407
+ * against variants it does not enumerate still gets one each.
400
408
  */
401
409
  export declare function selectImportImages(item: ChannelCatalogItem): ImportImageSelection;
402
410
  export declare class ChannelConnectorService {
@@ -598,7 +606,7 @@ export declare class ChannelConnectorService {
598
606
  *
599
607
  * Media: only each new item's hero is fetched here, streamed under `HERO_IMAGE_BYTE_CAP`, and
600
608
  * linked at entity level as `primary` plus to the variants it shows. The first photo of every
601
- * other variant comes back in `deferredMedia` for the host to land later.
609
+ * other variant, then the bounded gallery, come back in `deferredMedia` for the host to land later.
602
610
  */
603
611
  convergeCatalogPage(orgId: string, storeId: string, rawItems: ChannelCatalogItem[], actor: Actor): Promise<PluginResult<CatalogPageConvergence>>;
604
612
  private importHeroes;
package/dist/service.js CHANGED
@@ -507,22 +507,30 @@ function redactStore(store) {
507
507
  * A hero is streamed inside the page's own invocation, so it is bounded: a 30 MB TIFF a merchant
508
508
  * uploaded by mistake must not buffer into a 128 MiB isolate. Anything larger is reported, not
509
509
  * stored, and the product still lands — the index reads text first and media later.
510
+ *
511
+ * 4 MiB, not 1: at 1 MiB the cap refused ordinary product photographs — 16 of 100 Kelly Felder
512
+ * heroes and 11 of 100 Arienti, all between 1 and 2 MiB, measured 2026-10-06 — and a product with
513
+ * no photo is left out of every agent feed. Three in flight at 4 MiB stays far under the isolate.
510
514
  */
511
- export const HERO_IMAGE_BYTE_CAP = 1024 * 1024;
515
+ export const HERO_IMAGE_BYTE_CAP = 4 * 1024 * 1024;
516
+ /** The hero, the variant photos and the gallery together never exceed this — what a projection reads. */
517
+ const IMPORT_IMAGE_LIMIT = 6;
512
518
  function imageOrder(a, b) {
513
519
  return (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
514
520
  }
515
521
  /**
516
- * Ruling 2026-09-22: import the hero plus the FIRST photo of each other variant, nothing more.
517
- * A "blue long dress" query must be able to show the blue variant, and a fourth photo of the red
518
- * one adds nothing the index can use. Variants are read off the images' own variant references,
519
- * so a connector that lists images against variants it does not enumerate still gets one each.
522
+ * The hero, then the first photo of each other variant (a "blue long dress" query must be able to
523
+ * show the blue variant), then the store's further photos as the gallery, up to
524
+ * `IMPORT_IMAGE_LIMIT` in all. Agent feeds publish the gallery as additional images and enrichment
525
+ * reads it; only the hero is embedded, so a gallery photo costs an upload and no model call.
526
+ * Variants are read off the images' own variant references, so a connector that lists images
527
+ * against variants it does not enumerate still gets one each.
520
528
  */
521
529
  export function selectImportImages(item) {
522
530
  const images = [...(item.images ?? [])].sort(imageOrder);
523
531
  const hero = images.find((image) => image.role === "primary") ?? images[0] ?? null;
524
532
  if (!hero)
525
- return { hero: null, perVariant: [] };
533
+ return { hero: null, perVariant: [], gallery: [] };
526
534
  const covered = new Set(hero.variantExternalIds ?? []);
527
535
  const usedUrls = new Set([hero.url]);
528
536
  const perVariant = [];
@@ -539,7 +547,17 @@ export function selectImportImages(item) {
539
547
  usedUrls.add(image.url);
540
548
  perVariant.push(image);
541
549
  }
542
- return { hero, perVariant };
550
+ const gallery = [];
551
+ for (const image of images) {
552
+ if (1 + perVariant.length + gallery.length >= IMPORT_IMAGE_LIMIT)
553
+ break;
554
+ if (usedUrls.has(image.url))
555
+ continue;
556
+ usedUrls.add(image.url);
557
+ // A further photo of a variant already shown is a product photo, not that variant's image.
558
+ gallery.push({ ...image, role: "gallery", variantExternalIds: [] });
559
+ }
560
+ return { hero, perVariant, gallery };
543
561
  }
544
562
  /** Streams a response and refuses mid-stream past `cap`; a lying `content-length` cannot get around it. */
545
563
  async function fetchBounded(url, cap) {
@@ -1555,12 +1573,11 @@ export class ChannelConnectorService {
1555
1573
  async applyMedia(orgId, entityId, item, variantIds, actor, warnings, owners) {
1556
1574
  // Uploads what is missing and PLANS the entity's media links; `commitEntityLinks` writes them.
1557
1575
  //
1558
- // Only the images the import ruling allows — the SAME `selectImportImages` the page fast path
1559
- // uses: the hero plus the first photo of each other variant. This path attached EVERY image
1560
- // the item listed, so the first converge of a product whose price changed pulled in the whole
1561
- // gallery the fast path had deliberately left out: an upload, an embed and a bump per photo.
1576
+ // The SAME `selectImportImages` the page fast path uses — hero, variant photos, bounded gallery.
1577
+ // This path once attached EVERY image the item listed, so a product's first price change
1578
+ // pulled in photos the fast path had left out: an upload and an entity bump per photo.
1562
1579
  const selection = selectImportImages(item);
1563
- const images = selection.hero === null ? [] : [selection.hero, ...selection.perVariant];
1580
+ const images = selection.hero === null ? [] : [selection.hero, ...selection.perVariant, ...selection.gallery];
1564
1581
  const externalIds = [...new Set(images.map((image) => image.externalId).filter((id) => id != null))];
1565
1582
  const urlHashes = [...new Set(images.map((image) => hash(image.url)))];
1566
1583
  const keyPredicates = [];
@@ -2550,7 +2567,7 @@ export class ChannelConnectorService {
2550
2567
  *
2551
2568
  * Media: only each new item's hero is fetched here, streamed under `HERO_IMAGE_BYTE_CAP`, and
2552
2569
  * linked at entity level as `primary` plus to the variants it shows. The first photo of every
2553
- * other variant comes back in `deferredMedia` for the host to land later.
2570
+ * other variant, then the bounded gallery, come back in `deferredMedia` for the host to land later.
2554
2571
  */
2555
2572
  async convergeCatalogPage(orgId, storeId, rawItems, actor) {
2556
2573
  // A host may hand items it never read through this service's intake; the rule is idempotent.
@@ -2702,8 +2719,9 @@ export class ChannelConnectorService {
2702
2719
  const deferredMedia = [];
2703
2720
  const selections = createdItems.flatMap(({ item, entityId, variantIds }) => {
2704
2721
  const selection = selectImportImages(item);
2705
- if (selection.perVariant.length > 0)
2706
- deferredMedia.push({ externalId: item.externalId, entityId, images: selection.perVariant });
2722
+ const deferred = [...selection.perVariant, ...selection.gallery];
2723
+ if (deferred.length > 0)
2724
+ deferredMedia.push({ externalId: item.externalId, entityId, images: deferred });
2707
2725
  return selection.hero ? [{ item, entityId, variantIds, hero: selection.hero }] : [];
2708
2726
  });
2709
2727
  if (selections.length === 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.78.0",
3
+ "version": "0.79.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -22,7 +22,7 @@
22
22
  "dependencies": {
23
23
  "@hono/zod-openapi": "^1.2.2",
24
24
  "hono": "^4.12.5",
25
- "@porulle/core": "0.78.0"
25
+ "@porulle/core": "0.79.1"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^24.5.2",
package/src/service.ts CHANGED
@@ -1135,8 +1135,12 @@ function redactStore(store: ConnectedStore): PublicConnectedStore {
1135
1135
  * A hero is streamed inside the page's own invocation, so it is bounded: a 30 MB TIFF a merchant
1136
1136
  * uploaded by mistake must not buffer into a 128 MiB isolate. Anything larger is reported, not
1137
1137
  * stored, and the product still lands — the index reads text first and media later.
1138
+ *
1139
+ * 4 MiB, not 1: at 1 MiB the cap refused ordinary product photographs — 16 of 100 Kelly Felder
1140
+ * heroes and 11 of 100 Arienti, all between 1 and 2 MiB, measured 2026-10-06 — and a product with
1141
+ * no photo is left out of every agent feed. Three in flight at 4 MiB stays far under the isolate.
1138
1142
  */
1139
- export const HERO_IMAGE_BYTE_CAP = 1024 * 1024;
1143
+ export const HERO_IMAGE_BYTE_CAP = 4 * 1024 * 1024;
1140
1144
 
1141
1145
  export type CatalogMediaFailureReason = "too-large" | "download-failed" | "unsupported" | "storage";
1142
1146
 
@@ -1176,22 +1180,29 @@ export interface ImportImageSelection {
1176
1180
  hero: ChannelCatalogImage | null;
1177
1181
  /** In variant order; one image per variant the hero does not cover; no url twice. */
1178
1182
  perVariant: ChannelCatalogImage[];
1183
+ /** The store's further photos in its order, as entity-level `gallery` images; no url twice. */
1184
+ gallery: ChannelCatalogImage[];
1179
1185
  }
1180
1186
 
1187
+ /** The hero, the variant photos and the gallery together never exceed this — what a projection reads. */
1188
+ const IMPORT_IMAGE_LIMIT = 6;
1189
+
1181
1190
  function imageOrder(a: ChannelCatalogImage, b: ChannelCatalogImage): number {
1182
1191
  return (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
1183
1192
  }
1184
1193
 
1185
1194
  /**
1186
- * Ruling 2026-09-22: import the hero plus the FIRST photo of each other variant, nothing more.
1187
- * A "blue long dress" query must be able to show the blue variant, and a fourth photo of the red
1188
- * one adds nothing the index can use. Variants are read off the images' own variant references,
1189
- * so a connector that lists images against variants it does not enumerate still gets one each.
1195
+ * The hero, then the first photo of each other variant (a "blue long dress" query must be able to
1196
+ * show the blue variant), then the store's further photos as the gallery, up to
1197
+ * `IMPORT_IMAGE_LIMIT` in all. Agent feeds publish the gallery as additional images and enrichment
1198
+ * reads it; only the hero is embedded, so a gallery photo costs an upload and no model call.
1199
+ * Variants are read off the images' own variant references, so a connector that lists images
1200
+ * against variants it does not enumerate still gets one each.
1190
1201
  */
1191
1202
  export function selectImportImages(item: ChannelCatalogItem): ImportImageSelection {
1192
1203
  const images = [...(item.images ?? [])].sort(imageOrder);
1193
1204
  const hero = images.find((image) => image.role === "primary") ?? images[0] ?? null;
1194
- if (!hero) return { hero: null, perVariant: [] };
1205
+ if (!hero) return { hero: null, perVariant: [], gallery: [] };
1195
1206
  const covered = new Set(hero.variantExternalIds ?? []);
1196
1207
  const usedUrls = new Set([hero.url]);
1197
1208
  const perVariant: ChannelCatalogImage[] = [];
@@ -1205,7 +1216,15 @@ export function selectImportImages(item: ChannelCatalogItem): ImportImageSelecti
1205
1216
  usedUrls.add(image.url);
1206
1217
  perVariant.push(image);
1207
1218
  }
1208
- return { hero, perVariant };
1219
+ const gallery: ChannelCatalogImage[] = [];
1220
+ for (const image of images) {
1221
+ if (1 + perVariant.length + gallery.length >= IMPORT_IMAGE_LIMIT) break;
1222
+ if (usedUrls.has(image.url)) continue;
1223
+ usedUrls.add(image.url);
1224
+ // A further photo of a variant already shown is a product photo, not that variant's image.
1225
+ gallery.push({ ...image, role: "gallery", variantExternalIds: [] });
1226
+ }
1227
+ return { hero, perVariant, gallery };
1209
1228
  }
1210
1229
 
1211
1230
  type BoundedFetch =
@@ -2400,12 +2419,11 @@ export class ChannelConnectorService {
2400
2419
  }>> {
2401
2420
  // Uploads what is missing and PLANS the entity's media links; `commitEntityLinks` writes them.
2402
2421
  //
2403
- // Only the images the import ruling allows — the SAME `selectImportImages` the page fast path
2404
- // uses: the hero plus the first photo of each other variant. This path attached EVERY image
2405
- // the item listed, so the first converge of a product whose price changed pulled in the whole
2406
- // gallery the fast path had deliberately left out: an upload, an embed and a bump per photo.
2422
+ // The SAME `selectImportImages` the page fast path uses — hero, variant photos, bounded gallery.
2423
+ // This path once attached EVERY image the item listed, so a product's first price change
2424
+ // pulled in photos the fast path had left out: an upload and an entity bump per photo.
2407
2425
  const selection = selectImportImages(item);
2408
- const images = selection.hero === null ? [] : [selection.hero, ...selection.perVariant];
2426
+ const images = selection.hero === null ? [] : [selection.hero, ...selection.perVariant, ...selection.gallery];
2409
2427
  const externalIds = [...new Set(images.map((image) => image.externalId).filter((id): id is string => id != null))];
2410
2428
  const urlHashes = [...new Set(images.map((image) => hash(image.url)))];
2411
2429
  const keyPredicates = [];
@@ -3501,7 +3519,7 @@ export class ChannelConnectorService {
3501
3519
  *
3502
3520
  * Media: only each new item's hero is fetched here, streamed under `HERO_IMAGE_BYTE_CAP`, and
3503
3521
  * linked at entity level as `primary` plus to the variants it shows. The first photo of every
3504
- * other variant comes back in `deferredMedia` for the host to land later.
3522
+ * other variant, then the bounded gallery, come back in `deferredMedia` for the host to land later.
3505
3523
  */
3506
3524
  async convergeCatalogPage(
3507
3525
  orgId: string,
@@ -3669,7 +3687,8 @@ export class ChannelConnectorService {
3669
3687
  const deferredMedia: CatalogDeferredMedia[] = [];
3670
3688
  const selections = createdItems.flatMap(({ item, entityId, variantIds }) => {
3671
3689
  const selection = selectImportImages(item);
3672
- if (selection.perVariant.length > 0) deferredMedia.push({ externalId: item.externalId, entityId, images: selection.perVariant });
3690
+ const deferred = [...selection.perVariant, ...selection.gallery];
3691
+ if (deferred.length > 0) deferredMedia.push({ externalId: item.externalId, entityId, images: deferred });
3673
3692
  return selection.hero ? [{ item, entityId, variantIds, hero: selection.hero }] : [];
3674
3693
  });
3675
3694
  if (selections.length === 0) return { heroesImported: 0, mediaFailures, deferredMedia };