@porulle/plugin-channel-connector 0.53.1 → 0.55.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.
@@ -0,0 +1,19 @@
1
+ /**
2
+ * THE deletion policy for a store's mapped products that a fetch no longer lists — one home for
3
+ * the rule, shared by `reconcile()` and the app's import finalize barrier.
4
+ *
5
+ * A fetch that succeeded is not a fetch that was complete: a merchant API hiccup, pagination that
6
+ * stopped early, or an auth scope change returns fewer products than the store has, and archiving
7
+ * "everything absent" then wipes the store. Found on the sim, 2026-09-24. So the plan REFUSES:
8
+ * - an empty fetch over a store that has mapped products;
9
+ * - more absent products than max(ABSENT_ARCHIVE_FLOOR, floor(ABSENT_ARCHIVE_FRACTION × mapped)).
10
+ * A refusal archives nothing; the caller surfaces the reason for a person to look at.
11
+ */
12
+ export declare const ABSENT_ARCHIVE_FLOOR = 5;
13
+ export declare const ABSENT_ARCHIVE_FRACTION = 0.2;
14
+ export type AbsentArchivePlan = {
15
+ archive: string[];
16
+ } | {
17
+ refused: string;
18
+ };
19
+ export declare function planAbsentArchives(mappedExternalIds: Iterable<string>, presentExternalIds: Iterable<string>): AbsentArchivePlan;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * THE deletion policy for a store's mapped products that a fetch no longer lists — one home for
3
+ * the rule, shared by `reconcile()` and the app's import finalize barrier.
4
+ *
5
+ * A fetch that succeeded is not a fetch that was complete: a merchant API hiccup, pagination that
6
+ * stopped early, or an auth scope change returns fewer products than the store has, and archiving
7
+ * "everything absent" then wipes the store. Found on the sim, 2026-09-24. So the plan REFUSES:
8
+ * - an empty fetch over a store that has mapped products;
9
+ * - more absent products than max(ABSENT_ARCHIVE_FLOOR, floor(ABSENT_ARCHIVE_FRACTION × mapped)).
10
+ * A refusal archives nothing; the caller surfaces the reason for a person to look at.
11
+ */
12
+ export const ABSENT_ARCHIVE_FLOOR = 5;
13
+ export const ABSENT_ARCHIVE_FRACTION = 0.2;
14
+ export function planAbsentArchives(mappedExternalIds, presentExternalIds) {
15
+ const mapped = [...new Set(mappedExternalIds)];
16
+ const present = new Set(presentExternalIds);
17
+ const absent = mapped.filter((externalId) => !present.has(externalId));
18
+ if (absent.length === 0)
19
+ return { archive: [] };
20
+ if (present.size === 0) {
21
+ return { refused: `The fetch listed no products while ${mapped.length} are mapped; refusing to archive them.` };
22
+ }
23
+ const bound = Math.max(ABSENT_ARCHIVE_FLOOR, Math.floor(ABSENT_ARCHIVE_FRACTION * mapped.length));
24
+ if (absent.length > bound) {
25
+ return { refused: `${absent.length} of ${mapped.length} mapped products are absent from the fetch, above the bound of ${bound}; refusing to archive them.` };
26
+ }
27
+ return { archive: absent };
28
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { type ChannelConnectorPluginOptions } from "./service.js";
2
+ export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
3
+ export type { AbsentArchivePlan } from "./deletion-policy.js";
2
4
  export { mockChannelConnector } from "./mock-connector.js";
3
5
  export type { MockChannelConnectorOptions } from "./mock-connector.js";
4
6
  export { ChannelConnectorService, HERO_IMAGE_BYTE_CAP, selectImportImages, CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS, CATALOG_PUSH_BATCH_SIZES, CATALOG_PUSH_MAX_ATTEMPTS, canCatalogPushTransition, canExportTransition, catalogPushConcurrencyKey, catalogPushRetryDelayMs, CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION, isCatalogPushBreakerOpen, } from "./service.js";
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts
7
7
  import { ChannelConnectorService, catalogPushConcurrencyKey, CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION, } from "./service.js";
8
8
  import { buildHooks } from "./hooks.js";
9
9
  import { oauthStateEventId, signState, verifyState } from "./oauth-state.js";
10
+ export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
10
11
  export { mockChannelConnector } from "./mock-connector.js";
11
12
  export { ChannelConnectorService, HERO_IMAGE_BYTE_CAP, selectImportImages, CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS, CATALOG_PUSH_BATCH_SIZES, CATALOG_PUSH_MAX_ATTEMPTS, canCatalogPushTransition, canExportTransition, catalogPushConcurrencyKey, catalogPushRetryDelayMs, CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION, isCatalogPushBreakerOpen, } from "./service.js";
12
13
  export { isValidCatalogMappingFieldPath, matchFieldPath, mergeCatalogFieldMapping, normalizeCatalogFieldMapping, compareCatalogFieldMappingSpecificity, providerCatalogFieldMappingDefaults, selectCatalogFieldMapping, validateCatalogMappingRow, } from "./catalog-field-mapping.js";
package/dist/service.d.ts CHANGED
@@ -39,6 +39,8 @@ export interface ReconcileReport extends Record<string, unknown> {
39
39
  inventoryUpdated: number;
40
40
  openConflicts: number;
41
41
  driftAlert: boolean;
42
+ /** Why this reconcile archived nothing although mapped products were absent (`planAbsentArchives`). */
43
+ refused?: string;
42
44
  skipped?: CatalogFieldSkip[];
43
45
  conflicts?: CatalogFieldConflict[];
44
46
  warnings?: string[];
@@ -357,6 +359,17 @@ export declare class ChannelConnectorService {
357
359
  */
358
360
  private createTaxonomyOrAdopt;
359
361
  private applyTaxonomy;
362
+ /**
363
+ * Writes one item's planned links and versions the entity for them, in ONE transaction: the
364
+ * entity's `updated_at` moves with the links or not at all, and `catalog.afterUpdate` fires once
365
+ * for the item with every link path that really changed (`["categories","tags"]`), not once per
366
+ * link. Returns those paths; empty means nothing changed.
367
+ *
368
+ * An entity CREATED by this converge is not versioned for its links: its creation already put it
369
+ * in front of every consumer, so a bump here would re-project a product in the same breath as its
370
+ * first projection — the cold-import cost this rule exists to avoid.
371
+ */
372
+ private commitEntityLinks;
360
373
  private applyMedia;
361
374
  private getStoreRecord;
362
375
  /**
package/dist/service.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
- import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, } from "@porulle/core";
2
+ import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, linkFieldPaths, writeEntityLinks, } from "@porulle/core";
3
3
  import { isValidFieldPath, requireUserId } from "@porulle/core";
4
4
  import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
5
5
  import { and, desc, eq, inArray, isNull, lte, or, sql } from "@porulle/core/drizzle";
6
- import { brands, categories, customerAddresses, customers, entityMedia, entityBrands, entityCategories, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
6
+ import { brands, categories, customerAddresses, customers, entityMedia, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
7
+ import { planAbsentArchives } from "./deletion-policy.js";
7
8
  import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
8
9
  import { mergeCatalogFieldMapping, normalizeCatalogFieldMapping, selectCatalogFieldMapping, } from "./catalog-field-mapping.js";
9
10
  export const CATALOG_PUSH_BATCH_SIZES = {
@@ -1333,14 +1334,11 @@ export class ChannelConnectorService {
1333
1334
  return existing !== undefined ? Ok(existing) : PluginErr(`${label}: ${errorMessage(failure)}`);
1334
1335
  }
1335
1336
  async applyTaxonomy(orgId, entityId, item, actor, warnings) {
1337
+ // Resolves (creating where missing) the category, brand and tag rows the item names, and PLANS
1338
+ // the entity's links to them. The links are written by `commitEntityLinks`, in one transaction
1339
+ // with the entity's version bump; a link that already exists writes nothing there.
1336
1340
  const taxonomy = await this.taxonomyFor(orgId);
1337
- // The links this entity already has, read only for the classes the item names. A link already
1338
- // there is not re-written, and a link that is added is what reports the taxonomy as changed.
1339
- const linkedCategories = new Set((item.categories ?? []).length === 0 ? [] : (await this.db
1340
- .select({ id: entityCategories.categoryId }).from(entityCategories).where(eq(entityCategories.entityId, entityId))).map((row) => row.id));
1341
- const linkedBrands = new Set(!item.brand ? [] : (await this.db
1342
- .select({ id: entityBrands.brandId }).from(entityBrands).where(eq(entityBrands.entityId, entityId))).map((row) => row.id));
1343
- let changed = false;
1341
+ const links = { categories: [], brands: [], tags: [] };
1344
1342
  const categoryRows = taxonomy.categories;
1345
1343
  for (const slug of new Set(item.categories ?? [])) {
1346
1344
  let category = categoryRows.find((row) => row.slug === slug);
@@ -1355,12 +1353,7 @@ export class ChannelConnectorService {
1355
1353
  category = created.value;
1356
1354
  categoryRows.push(category);
1357
1355
  }
1358
- if (linkedCategories.has(category.id))
1359
- continue;
1360
- const linked = await this.catalog.addToCategory(entityId, category.id, actor);
1361
- if (!linked.ok)
1362
- return PluginErr(linked.error.message);
1363
- changed = true;
1356
+ links.categories.push({ entityId, categoryId: category.id, sortOrder: 0 });
1364
1357
  }
1365
1358
  const brandRows = taxonomy.brands;
1366
1359
  if (item.brand) {
@@ -1373,12 +1366,7 @@ export class ChannelConnectorService {
1373
1366
  brand = created.value;
1374
1367
  brandRows.push(brand);
1375
1368
  }
1376
- if (!linkedBrands.has(brand.id)) {
1377
- const linked = await this.catalog.addToBrand(entityId, brand.id, actor);
1378
- if (!linked.ok)
1379
- return PluginErr(linked.error.message);
1380
- changed = true;
1381
- }
1369
+ links.brands.push({ entityId, brandId: brand.id, sortOrder: 0 });
1382
1370
  }
1383
1371
  const tagRows = taxonomy.tags;
1384
1372
  for (const slug of new Set(item.tags ?? [])) {
@@ -1390,13 +1378,43 @@ export class ChannelConnectorService {
1390
1378
  return PluginErr(`Tag "${slug}" was not persisted.`);
1391
1379
  tagRows.push(tag);
1392
1380
  }
1393
- const added = await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing().returning({ tagId: entityTags.tagId });
1394
- if (added.length > 0)
1395
- changed = true;
1381
+ links.tags.push({ entityId, tagId: tag.id });
1382
+ }
1383
+ return Ok(links);
1384
+ }
1385
+ /**
1386
+ * Writes one item's planned links and versions the entity for them, in ONE transaction: the
1387
+ * entity's `updated_at` moves with the links or not at all, and `catalog.afterUpdate` fires once
1388
+ * for the item with every link path that really changed (`["categories","tags"]`), not once per
1389
+ * link. Returns those paths; empty means nothing changed.
1390
+ *
1391
+ * An entity CREATED by this converge is not versioned for its links: its creation already put it
1392
+ * in front of every consumer, so a bump here would re-project a product in the same breath as its
1393
+ * first projection — the cold-import cost this rule exists to avoid.
1394
+ */
1395
+ async commitEntityLinks(orgId, entityId, planned, previousRoles, isNew, actor) {
1396
+ try {
1397
+ return Ok(await this.transact(async (tx) => {
1398
+ const written = await writeEntityLinks(tx, orgId, planned);
1399
+ const paths = new Set(linkFieldPaths(written).get(entityId));
1400
+ for (const row of written.placed) {
1401
+ const previous = previousRoles.get(`${row.mediaAssetId}:${row.variantId}`);
1402
+ if (previous !== undefined)
1403
+ paths.add(`media.${previous}`);
1404
+ }
1405
+ const changed = [...paths].sort();
1406
+ if (changed.length > 0 && !isNew) {
1407
+ await this.catalog.notifyEntityChanged(entityId, changed, actor, createTxContext(tx, { actor }));
1408
+ }
1409
+ return changed;
1410
+ }));
1411
+ }
1412
+ catch (error) {
1413
+ return PluginErr(error instanceof Error ? error.message : "Failed to write the entity's links.");
1396
1414
  }
1397
- return Ok({ changed });
1398
1415
  }
1399
1416
  async applyMedia(orgId, entityId, item, variantIds, actor, warnings, owners) {
1417
+ // Uploads what is missing and PLANS the entity's media links; `commitEntityLinks` writes them.
1400
1418
  const images = item.images ?? [];
1401
1419
  const externalIds = [...new Set(images.map((image) => image.externalId).filter((id) => id != null))];
1402
1420
  const urlHashes = [...new Set(images.map((image) => hash(image.url)))];
@@ -1412,8 +1430,10 @@ export class ChannelConnectorService {
1412
1430
  : await this.db.select().from(mediaAssets).where(and(eq(mediaAssets.organizationId, orgId), or(...keyPredicates)));
1413
1431
  const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entityId));
1414
1432
  let imported = 0;
1415
- let changed = false;
1433
+ let uploaded = false;
1416
1434
  const skipped = [];
1435
+ const planned = { media: [], mediaPlacements: [] };
1436
+ const previousRoles = new Map();
1417
1437
  // A Cloudflare Worker may hold at most six simultaneous outbound connections per
1418
1438
  // invocation, and one image costs two of them — the download and the storage put — so
1419
1439
  // 6 / 2 = 3 images may be in flight. A fourth would queue behind the platform limit
@@ -1526,7 +1546,7 @@ export class ChannelConnectorService {
1526
1546
  warnings.push(...resolved.imageWarnings);
1527
1547
  imported += resolved.imported;
1528
1548
  if (resolved.imageChanged)
1529
- changed = true;
1549
+ uploaded = true;
1530
1550
  }
1531
1551
  for (const [imageIndex, image] of images.entries()) {
1532
1552
  const mediaAssetId = resolvedImages[imageIndex]?.mediaAssetId;
@@ -1554,21 +1574,12 @@ export class ChannelConnectorService {
1554
1574
  continue;
1555
1575
  }
1556
1576
  if (existingLink.role !== image.role || existingLink.sortOrder !== (image.sortOrder ?? 0)) {
1557
- await this.db.update(entityMedia).set({ role: image.role, sortOrder: image.sortOrder ?? 0 }).where(and(eq(entityMedia.entityId, entityId), eq(entityMedia.mediaAssetId, mediaAssetId), target.variantId === undefined ? isNull(entityMedia.variantId) : eq(entityMedia.variantId, target.variantId)));
1558
- changed = true;
1577
+ planned.mediaPlacements.push({ entityId, variantId: target.variantId ?? null, mediaAssetId, role: image.role, sortOrder: image.sortOrder ?? 0 });
1578
+ previousRoles.set(`${mediaAssetId}:${target.variantId ?? null}`, existingLink.role);
1559
1579
  }
1560
1580
  continue;
1561
1581
  }
1562
- const attached = await this.media.attachToEntity({
1563
- entityId,
1564
- mediaAssetId,
1565
- role: image.role,
1566
- sortOrder: image.sortOrder ?? 0,
1567
- ...(target.variantId !== undefined ? { variantId: target.variantId } : {}),
1568
- }, actor);
1569
- if (!attached.ok)
1570
- return PluginErr(attached.error.message);
1571
- changed = true;
1582
+ planned.media.push({ entityId, variantId: target.variantId ?? null, mediaAssetId, role: image.role, sortOrder: image.sortOrder ?? 0 });
1572
1583
  links.push({
1573
1584
  entityId,
1574
1585
  mediaAssetId,
@@ -1579,7 +1590,7 @@ export class ChannelConnectorService {
1579
1590
  });
1580
1591
  }
1581
1592
  }
1582
- return Ok({ imported, changed, skipped });
1593
+ return Ok({ imported, uploaded, skipped, links: planned, previousRoles });
1583
1594
  }
1584
1595
  async getStoreRecord(orgId, id) {
1585
1596
  const rows = await this.db
@@ -2452,13 +2463,13 @@ export class ChannelConnectorService {
2452
2463
  }
2453
2464
  }));
2454
2465
  if (outcomes.length > 0) {
2455
- await this.db.insert(entityMedia).values(outcomes.flatMap(({ entityId, mediaAssetId, hero, variantIds }) => [
2456
- { entityId, mediaAssetId, role: "primary", sortOrder: hero.sortOrder ?? 0 },
2457
- ...(hero.variantExternalIds ?? []).flatMap((externalId) => {
2458
- const variantId = variantIds[externalId];
2459
- return variantId === undefined ? [] : [{ entityId, variantId, mediaAssetId, role: hero.role, sortOrder: hero.sortOrder ?? 0 }];
2460
- }),
2461
- ])).onConflictDoNothing();
2466
+ await writeEntityLinks(this.db, orgId, { media: outcomes.flatMap(({ entityId, mediaAssetId, hero, variantIds }) => [
2467
+ { entityId, variantId: null, mediaAssetId, role: "primary", sortOrder: hero.sortOrder ?? 0 },
2468
+ ...(hero.variantExternalIds ?? []).flatMap((externalId) => {
2469
+ const variantId = variantIds[externalId];
2470
+ return variantId === undefined ? [] : [{ entityId, variantId, mediaAssetId, role: hero.role, sortOrder: hero.sortOrder ?? 0 }];
2471
+ }),
2472
+ ]) });
2462
2473
  }
2463
2474
  return { heroesImported: outcomes.filter((outcome) => outcome.imported).length, mediaFailures, deferredMedia };
2464
2475
  }
@@ -3085,11 +3096,16 @@ export class ChannelConnectorService {
3085
3096
  const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
3086
3097
  if (!media.ok)
3087
3098
  return media;
3099
+ const links = await this.commitEntityLinks(orgId, entityId, { ...taxonomy.value, ...media.value.links }, media.value.previousRoles, isNew, actor);
3100
+ if (!links.ok) {
3101
+ failures.push({ externalId: item.externalId, error: links.error });
3102
+ continue;
3103
+ }
3088
3104
  attributesCreated += attributes.value.created;
3089
3105
  mediaImported += media.value.imported;
3090
3106
  variantsGivenOptionValues += variantIds.value.repaired;
3091
3107
  skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
3092
- entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || taxonomy.value.changed || media.value.changed || attributes.value.changed
3108
+ entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || links.value.length > 0 || media.value.uploaded || attributes.value.changed
3093
3109
  || identity.written.has(item.externalId);
3094
3110
  const skuClashes = identity.clashes.get(item.externalId);
3095
3111
  if (skuClashes) {
@@ -3214,11 +3230,14 @@ export class ChannelConnectorService {
3214
3230
  const converged = await this.convergeCatalogItems(orgId, storeId, items, actor);
3215
3231
  if (!converged.ok)
3216
3232
  return converged;
3217
- const present = new Set(items.map((item) => item.externalId));
3233
+ // Planned BEFORE anything is archived: an empty or truncated fetch is refused whole (see
3234
+ // `planAbsentArchives`), and the refusal is reported, never half-applied.
3235
+ const plan = planAbsentArchives(entityMappings.map((mapping) => mapping.externalId), items.map((item) => item.externalId));
3236
+ const toArchive = new Set("archive" in plan ? plan.archive : []);
3218
3237
  let archived = 0;
3219
3238
  const skipped = [...converged.value.skipped];
3220
3239
  for (const mapping of entityMappings) {
3221
- if (present.has(mapping.externalId))
3240
+ if (!toArchive.has(mapping.externalId))
3222
3241
  continue;
3223
3242
  const [entity] = await this.db.select({ status: sellableEntities.status }).from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, mapping.entityId)));
3224
3243
  if (entity?.status !== "archived") {
@@ -3266,7 +3285,8 @@ export class ChannelConnectorService {
3266
3285
  archived,
3267
3286
  inventoryUpdated,
3268
3287
  openConflicts: openConflictRows.length,
3269
- driftAlert: converged.value.imported + converged.value.converged + archived > threshold,
3288
+ driftAlert: "refused" in plan || converged.value.imported + converged.value.converged + archived > threshold,
3289
+ ...("refused" in plan ? { refused: plan.refused } : {}),
3270
3290
  ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
3271
3291
  ...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
3272
3292
  ...(converged.value.warnings.length > 0 ? { warnings: converged.value.warnings } : {}),