@porulle/plugin-channel-connector 0.54.0 → 0.56.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
@@ -3,10 +3,11 @@ import { CommerceConflictError, CommerceInvalidTransitionError, CommerceNotFound
3
3
  import { z } from "@hono/zod-openapi";
4
4
  import { and, eq } from "@porulle/core/drizzle";
5
5
  import { processedWebhookEvents } from "@porulle/core/schema";
6
- import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityMap, channelExportEvents, channelOrderExports, channelRefundEvents, channelRefundRequests, connectedStores, } from "./schema.js";
6
+ import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityLinks, channelEntityMap, channelExportEvents, channelOrderExports, channelRefundEvents, channelRefundRequests, connectedStores, } from "./schema.js";
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";
@@ -289,6 +290,7 @@ export function channelConnectorPlugin(options = {}) {
289
290
  schema: () => ({
290
291
  connectedStores,
291
292
  channelEntityMap,
293
+ channelEntityLinks,
292
294
  channelCatalogPushes,
293
295
  channelCatalogPushEvents,
294
296
  channelCatalogConflicts,
package/dist/schema.d.ts CHANGED
@@ -555,6 +555,104 @@ export declare const channelEntityMap: import("drizzle-orm/pg-core/table").PgTab
555
555
  };
556
556
  dialect: "pg";
557
557
  }>;
558
+ /**
559
+ * The category, brand and tag links a store's converge CREATED on an entity — link provenance, per
560
+ * store, the way `channel_entity_map` records the variants a store owns. A converge removes a link
561
+ * the store dropped only when the link is on record here: a link the merchant added (or another
562
+ * store did) has no row for this store and is never this store's to take away.
563
+ */
564
+ export declare const channelEntityLinks: import("drizzle-orm/pg-core/table").PgTableWithColumns<{
565
+ name: "channel_entity_links";
566
+ schema: undefined;
567
+ columns: {
568
+ organizationId: import("@porulle/core/drizzle").PgColumn<{
569
+ name: "organization_id";
570
+ tableName: "channel_entity_links";
571
+ dataType: "string";
572
+ columnType: "PgText";
573
+ data: string;
574
+ driverParam: string;
575
+ notNull: true;
576
+ hasDefault: false;
577
+ isPrimaryKey: false;
578
+ isAutoincrement: false;
579
+ hasRuntimeDefault: false;
580
+ enumValues: [string, ...string[]];
581
+ baseColumn: never;
582
+ identity: undefined;
583
+ generated: undefined;
584
+ }, {}, {}>;
585
+ storeId: import("@porulle/core/drizzle").PgColumn<{
586
+ name: "store_id";
587
+ tableName: "channel_entity_links";
588
+ dataType: "string";
589
+ columnType: "PgUUID";
590
+ data: string;
591
+ driverParam: string;
592
+ notNull: true;
593
+ hasDefault: false;
594
+ isPrimaryKey: false;
595
+ isAutoincrement: false;
596
+ hasRuntimeDefault: false;
597
+ enumValues: undefined;
598
+ baseColumn: never;
599
+ identity: undefined;
600
+ generated: undefined;
601
+ }, {}, {}>;
602
+ entityId: import("@porulle/core/drizzle").PgColumn<{
603
+ name: "entity_id";
604
+ tableName: "channel_entity_links";
605
+ dataType: "string";
606
+ columnType: "PgUUID";
607
+ data: string;
608
+ driverParam: string;
609
+ notNull: true;
610
+ hasDefault: false;
611
+ isPrimaryKey: false;
612
+ isAutoincrement: false;
613
+ hasRuntimeDefault: false;
614
+ enumValues: undefined;
615
+ baseColumn: never;
616
+ identity: undefined;
617
+ generated: undefined;
618
+ }, {}, {}>;
619
+ kind: import("@porulle/core/drizzle").PgColumn<{
620
+ name: "kind";
621
+ tableName: "channel_entity_links";
622
+ dataType: "string";
623
+ columnType: "PgText";
624
+ data: "brand" | "category" | "tag";
625
+ driverParam: string;
626
+ notNull: true;
627
+ hasDefault: false;
628
+ isPrimaryKey: false;
629
+ isAutoincrement: false;
630
+ hasRuntimeDefault: false;
631
+ enumValues: ["category", "brand", "tag"];
632
+ baseColumn: never;
633
+ identity: undefined;
634
+ generated: undefined;
635
+ }, {}, {}>;
636
+ targetId: import("@porulle/core/drizzle").PgColumn<{
637
+ name: "target_id";
638
+ tableName: "channel_entity_links";
639
+ dataType: "string";
640
+ columnType: "PgUUID";
641
+ data: string;
642
+ driverParam: string;
643
+ notNull: true;
644
+ hasDefault: false;
645
+ isPrimaryKey: false;
646
+ isAutoincrement: false;
647
+ hasRuntimeDefault: false;
648
+ enumValues: undefined;
649
+ baseColumn: never;
650
+ identity: undefined;
651
+ generated: undefined;
652
+ }, {}, {}>;
653
+ };
654
+ dialect: "pg";
655
+ }>;
558
656
  export declare const channelCatalogConflicts: import("drizzle-orm/pg-core/table").PgTableWithColumns<{
559
657
  name: "channel_catalog_conflicts";
560
658
  schema: undefined;
package/dist/schema.js CHANGED
@@ -44,6 +44,21 @@ export const channelEntityMap = pgTable("channel_entity_map", {
44
44
  storeIdx: index("idx_channel_entity_map_store").on(table.storeId),
45
45
  externalUnique: uniqueIndex("channel_entity_map_store_kind_external_unique").on(table.storeId, table.kind, table.externalId),
46
46
  }));
47
+ /**
48
+ * The category, brand and tag links a store's converge CREATED on an entity — link provenance, per
49
+ * store, the way `channel_entity_map` records the variants a store owns. A converge removes a link
50
+ * the store dropped only when the link is on record here: a link the merchant added (or another
51
+ * store did) has no row for this store and is never this store's to take away.
52
+ */
53
+ export const channelEntityLinks = pgTable("channel_entity_links", {
54
+ organizationId: text("organization_id").notNull(),
55
+ storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
56
+ entityId: uuid("entity_id").references(() => sellableEntities.id, { onDelete: "cascade" }).notNull(),
57
+ kind: text("kind", { enum: ["category", "brand", "tag"] }).notNull(),
58
+ targetId: uuid("target_id").notNull(),
59
+ }, (table) => ({
60
+ linkUnique: uniqueIndex("channel_entity_links_store_entity_kind_target_unique").on(table.storeId, table.entityId, table.kind, table.targetId),
61
+ }));
47
62
  export const channelCatalogConflicts = pgTable("channel_catalog_conflicts", {
48
63
  id: uuid("id").defaultRandom().primaryKey(),
49
64
  organizationId: text("organization_id").notNull(),
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,22 @@ 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
+ * It also REMOVES the category, brand and tag links the store no longer lists — only those on
373
+ * record in `channel_entity_links` as this store's, so a link the merchant added survives. Rows
374
+ * this converge inserts go on record; a product imported before provenance existed is claimed by
375
+ * `claimUnrecordedLinks` at the top of the converge.
376
+ */
377
+ private commitEntityLinks;
360
378
  private applyMedia;
361
379
  private getStoreRecord;
362
380
  /**
@@ -453,6 +471,18 @@ export declare class ChannelConnectorService {
453
471
  private saveBackfillState;
454
472
  backfillCatalog(orgId: string, storeId: string, actor: Actor, options?: BackfillCatalogOptions): Promise<PluginResult<BackfillCatalogReport>>;
455
473
  private estimateCatalogItems;
474
+ /**
475
+ * Link provenance for products imported before `channel_entity_links` existed: a mapped product
476
+ * of this batch with NOTHING on record for this store claims, once, the category / brand / tag
477
+ * links it has that the store lists NOW. Runs for unchanged items too — an unchanged item skips
478
+ * its converge, and the converge that follows may already be the drop, too late to claim.
479
+ *
480
+ * Its limit, stated rather than hidden: a link upstream had dropped BEFORE this claim was never
481
+ * the store's on record, so it stays (the stale set the old add-only converge left; repaired by a
482
+ * re-import, not by a heuristic delete). One select per batch; the claims only while unclaimed
483
+ * products remain.
484
+ */
485
+ private claimUnrecordedLinks;
456
486
  private convergeCatalogItems;
457
487
  reconcile(orgId: string, storeId: string, actor: Actor): Promise<PluginResult<ReconcileReport>>;
458
488
  getReconcileStatus(orgId: string, storeId: string): Promise<PluginResult<{
package/dist/service.js CHANGED
@@ -1,10 +1,11 @@
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, removeEntityLinks, 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";
7
- import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
6
+ import { brands, categories, customerAddresses, customers, entityBrands, entityCategories, entityMedia, entityTags, 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";
8
+ import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityLinks, 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 = {
10
11
  mock: 100,
@@ -1333,18 +1334,19 @@ 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: [] };
1342
+ // Every link the item names, as `${kind}:${id}` — including an archived category it names but
1343
+ // is not linked to again, so a link the store still lists is never read as dropped.
1344
+ const listed = new Set();
1344
1345
  const categoryRows = taxonomy.categories;
1345
1346
  for (const slug of new Set(item.categories ?? [])) {
1346
1347
  let category = categoryRows.find((row) => row.slug === slug);
1347
1348
  if (category?.status === "archived") {
1349
+ listed.add(`category:${category.id}`);
1348
1350
  warnings.push(`Skipped archived category "${slug}".`);
1349
1351
  continue;
1350
1352
  }
@@ -1355,12 +1357,8 @@ export class ChannelConnectorService {
1355
1357
  category = created.value;
1356
1358
  categoryRows.push(category);
1357
1359
  }
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;
1360
+ links.categories.push({ entityId, categoryId: category.id, sortOrder: 0 });
1361
+ listed.add(`category:${category.id}`);
1364
1362
  }
1365
1363
  const brandRows = taxonomy.brands;
1366
1364
  if (item.brand) {
@@ -1373,12 +1371,8 @@ export class ChannelConnectorService {
1373
1371
  brand = created.value;
1374
1372
  brandRows.push(brand);
1375
1373
  }
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
- }
1374
+ links.brands.push({ entityId, brandId: brand.id, sortOrder: 0 });
1375
+ listed.add(`brand:${brand.id}`);
1382
1376
  }
1383
1377
  const tagRows = taxonomy.tags;
1384
1378
  for (const slug of new Set(item.tags ?? [])) {
@@ -1390,13 +1384,68 @@ export class ChannelConnectorService {
1390
1384
  return PluginErr(`Tag "${slug}" was not persisted.`);
1391
1385
  tagRows.push(tag);
1392
1386
  }
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;
1387
+ links.tags.push({ entityId, tagId: tag.id });
1388
+ listed.add(`tag:${tag.id}`);
1389
+ }
1390
+ return Ok({ ...links, listed });
1391
+ }
1392
+ /**
1393
+ * Writes one item's planned links and versions the entity for them, in ONE transaction: the
1394
+ * entity's `updated_at` moves with the links or not at all, and `catalog.afterUpdate` fires once
1395
+ * for the item with every link path that really changed (`["categories","tags"]`), not once per
1396
+ * link. Returns those paths; empty means nothing changed.
1397
+ *
1398
+ * An entity CREATED by this converge is not versioned for its links: its creation already put it
1399
+ * in front of every consumer, so a bump here would re-project a product in the same breath as its
1400
+ * first projection — the cold-import cost this rule exists to avoid.
1401
+ *
1402
+ * It also REMOVES the category, brand and tag links the store no longer lists — only those on
1403
+ * record in `channel_entity_links` as this store's, so a link the merchant added survives. Rows
1404
+ * this converge inserts go on record; a product imported before provenance existed is claimed by
1405
+ * `claimUnrecordedLinks` at the top of the converge.
1406
+ */
1407
+ async commitEntityLinks(orgId, storeId, entityId, planned, listed, previousRoles, isNew, actor) {
1408
+ try {
1409
+ return Ok(await this.transact(async (tx) => {
1410
+ const written = await writeEntityLinks(tx, orgId, planned);
1411
+ const owned = isNew ? [] : await tx.select({ kind: channelEntityLinks.kind, targetId: channelEntityLinks.targetId }).from(channelEntityLinks)
1412
+ .where(and(eq(channelEntityLinks.storeId, storeId), eq(channelEntityLinks.entityId, entityId)));
1413
+ const dropped = owned.filter((row) => !listed.has(`${row.kind}:${row.targetId}`));
1414
+ const removed = await removeEntityLinks(tx, orgId, {
1415
+ categories: dropped.filter((row) => row.kind === "category").map((row) => ({ entityId, categoryId: row.targetId })),
1416
+ brands: dropped.filter((row) => row.kind === "brand").map((row) => ({ entityId, brandId: row.targetId })),
1417
+ tags: dropped.filter((row) => row.kind === "tag").map((row) => ({ entityId, tagId: row.targetId })),
1418
+ });
1419
+ if (dropped.length > 0) {
1420
+ await tx.delete(channelEntityLinks).where(and(eq(channelEntityLinks.storeId, storeId), eq(channelEntityLinks.entityId, entityId), or(...dropped.map((row) => and(eq(channelEntityLinks.kind, row.kind), eq(channelEntityLinks.targetId, row.targetId))))));
1421
+ }
1422
+ const record = [
1423
+ ...written.categories.map((row) => ({ kind: "category", targetId: row.categoryId })),
1424
+ ...written.brands.map((row) => ({ kind: "brand", targetId: row.brandId })),
1425
+ ...written.tags.map((row) => ({ kind: "tag", targetId: row.tagId })),
1426
+ ];
1427
+ if (record.length > 0) {
1428
+ await tx.insert(channelEntityLinks).values(record.map((row) => ({ organizationId: orgId, storeId, entityId, ...row }))).onConflictDoNothing();
1429
+ }
1430
+ const paths = new Set([...(linkFieldPaths(written).get(entityId) ?? []), ...(linkFieldPaths(removed).get(entityId) ?? [])]);
1431
+ for (const row of written.placed) {
1432
+ const previous = previousRoles.get(`${row.mediaAssetId}:${row.variantId}`);
1433
+ if (previous !== undefined)
1434
+ paths.add(`media.${previous}`);
1435
+ }
1436
+ const changed = [...paths].sort();
1437
+ if (changed.length > 0 && !isNew) {
1438
+ await this.catalog.notifyEntityChanged(entityId, changed, actor, createTxContext(tx, { actor }));
1439
+ }
1440
+ return changed;
1441
+ }));
1442
+ }
1443
+ catch (error) {
1444
+ return PluginErr(error instanceof Error ? error.message : "Failed to write the entity's links.");
1396
1445
  }
1397
- return Ok({ changed });
1398
1446
  }
1399
1447
  async applyMedia(orgId, entityId, item, variantIds, actor, warnings, owners) {
1448
+ // Uploads what is missing and PLANS the entity's media links; `commitEntityLinks` writes them.
1400
1449
  const images = item.images ?? [];
1401
1450
  const externalIds = [...new Set(images.map((image) => image.externalId).filter((id) => id != null))];
1402
1451
  const urlHashes = [...new Set(images.map((image) => hash(image.url)))];
@@ -1412,8 +1461,10 @@ export class ChannelConnectorService {
1412
1461
  : await this.db.select().from(mediaAssets).where(and(eq(mediaAssets.organizationId, orgId), or(...keyPredicates)));
1413
1462
  const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entityId));
1414
1463
  let imported = 0;
1415
- let changed = false;
1464
+ let uploaded = false;
1416
1465
  const skipped = [];
1466
+ const planned = { media: [], mediaPlacements: [] };
1467
+ const previousRoles = new Map();
1417
1468
  // A Cloudflare Worker may hold at most six simultaneous outbound connections per
1418
1469
  // invocation, and one image costs two of them — the download and the storage put — so
1419
1470
  // 6 / 2 = 3 images may be in flight. A fourth would queue behind the platform limit
@@ -1526,7 +1577,7 @@ export class ChannelConnectorService {
1526
1577
  warnings.push(...resolved.imageWarnings);
1527
1578
  imported += resolved.imported;
1528
1579
  if (resolved.imageChanged)
1529
- changed = true;
1580
+ uploaded = true;
1530
1581
  }
1531
1582
  for (const [imageIndex, image] of images.entries()) {
1532
1583
  const mediaAssetId = resolvedImages[imageIndex]?.mediaAssetId;
@@ -1554,21 +1605,12 @@ export class ChannelConnectorService {
1554
1605
  continue;
1555
1606
  }
1556
1607
  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;
1608
+ planned.mediaPlacements.push({ entityId, variantId: target.variantId ?? null, mediaAssetId, role: image.role, sortOrder: image.sortOrder ?? 0 });
1609
+ previousRoles.set(`${mediaAssetId}:${target.variantId ?? null}`, existingLink.role);
1559
1610
  }
1560
1611
  continue;
1561
1612
  }
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;
1613
+ planned.media.push({ entityId, variantId: target.variantId ?? null, mediaAssetId, role: image.role, sortOrder: image.sortOrder ?? 0 });
1572
1614
  links.push({
1573
1615
  entityId,
1574
1616
  mediaAssetId,
@@ -1579,7 +1621,7 @@ export class ChannelConnectorService {
1579
1621
  });
1580
1622
  }
1581
1623
  }
1582
- return Ok({ imported, changed, skipped });
1624
+ return Ok({ imported, uploaded, skipped, links: planned, previousRoles });
1583
1625
  }
1584
1626
  async getStoreRecord(orgId, id) {
1585
1627
  const rows = await this.db
@@ -2364,6 +2406,15 @@ export class ChannelConnectorService {
2364
2406
  return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant", externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
2365
2407
  }),
2366
2408
  ])).onConflictDoNothing();
2409
+ // Every category / brand / tag link on an entity this page just created was written by this
2410
+ // import, so all of it is this store's on record (`channel_entity_links`) — one statement.
2411
+ const created = sql.join(createdItems.map(({ entityId }) => sql `${entityId}::uuid`), sql `, `);
2412
+ await this.db.execute(sql `
2413
+ insert into ${channelEntityLinks} (organization_id, store_id, entity_id, kind, target_id)
2414
+ select ${orgId}, ${storeId}::uuid, entity_id, 'category', category_id from ${entityCategories} where entity_id in (${created})
2415
+ union all select ${orgId}, ${storeId}::uuid, entity_id, 'brand', brand_id from ${entityBrands} where entity_id in (${created})
2416
+ union all select ${orgId}, ${storeId}::uuid, entity_id, 'tag', tag_id from ${entityTags} where entity_id in (${created})
2417
+ on conflict do nothing`);
2367
2418
  }
2368
2419
  const media = await this.importHeroes(orgId, createdItems, actor);
2369
2420
  const entityIds = [];
@@ -2452,13 +2503,13 @@ export class ChannelConnectorService {
2452
2503
  }
2453
2504
  }));
2454
2505
  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();
2506
+ await writeEntityLinks(this.db, orgId, { media: outcomes.flatMap(({ entityId, mediaAssetId, hero, variantIds }) => [
2507
+ { entityId, variantId: null, mediaAssetId, role: "primary", sortOrder: hero.sortOrder ?? 0 },
2508
+ ...(hero.variantExternalIds ?? []).flatMap((externalId) => {
2509
+ const variantId = variantIds[externalId];
2510
+ return variantId === undefined ? [] : [{ entityId, variantId, mediaAssetId, role: hero.role, sortOrder: hero.sortOrder ?? 0 }];
2511
+ }),
2512
+ ]) });
2462
2513
  }
2463
2514
  return { heroesImported: outcomes.filter((outcome) => outcome.imported).length, mediaFailures, deferredMedia };
2464
2515
  }
@@ -2860,6 +2911,45 @@ export class ChannelConnectorService {
2860
2911
  }
2861
2912
  return Ok(stats);
2862
2913
  }
2914
+ /**
2915
+ * Link provenance for products imported before `channel_entity_links` existed: a mapped product
2916
+ * of this batch with NOTHING on record for this store claims, once, the category / brand / tag
2917
+ * links it has that the store lists NOW. Runs for unchanged items too — an unchanged item skips
2918
+ * its converge, and the converge that follows may already be the drop, too late to claim.
2919
+ *
2920
+ * Its limit, stated rather than hidden: a link upstream had dropped BEFORE this claim was never
2921
+ * the store's on record, so it stays (the stale set the old add-only converge left; repaired by a
2922
+ * re-import, not by a heuristic delete). One select per batch; the claims only while unclaimed
2923
+ * products remain.
2924
+ */
2925
+ async claimUnrecordedLinks(orgId, storeId, items) {
2926
+ if (items.length === 0)
2927
+ return;
2928
+ const unclaimed = await this.db.select({ externalId: channelEntityMap.externalId, entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), inArray(channelEntityMap.externalId, items.map((item) => item.externalId)), sql `not exists (select 1 from ${channelEntityLinks} where ${channelEntityLinks.storeId} = ${channelEntityMap.storeId} and ${channelEntityLinks.entityId} = ${channelEntityMap.entityId})`));
2929
+ if (unclaimed.length === 0)
2930
+ return;
2931
+ const entityOf = new Map(unclaimed.map((row) => [row.externalId, row.entityId]));
2932
+ const listed = (pick) => items.flatMap((item) => {
2933
+ const entityId = entityOf.get(item.externalId);
2934
+ return entityId === undefined ? [] : [...new Set(pick(item))].map((slug) => sql `(${entityId}::uuid, ${slug}::text)`);
2935
+ });
2936
+ const claim = async (kind, pairs, link) => {
2937
+ if (pairs.length === 0)
2938
+ return;
2939
+ await this.db.execute(sql `
2940
+ insert into ${channelEntityLinks} (organization_id, store_id, entity_id, kind, target_id)
2941
+ select ${orgId}, ${storeId}::uuid, v.entity_id, ${kind}, t.id
2942
+ from (values ${sql.join(pairs, sql `, `)}) as v(entity_id, slug)
2943
+ ${link}
2944
+ on conflict do nothing`);
2945
+ };
2946
+ await claim("category", listed((item) => item.categories ?? []), sql `join ${categories} t on t.slug = v.slug and t.organization_id = ${orgId}
2947
+ join ${entityCategories} l on l.entity_id = v.entity_id and l.category_id = t.id`);
2948
+ await claim("brand", listed((item) => (item.brand ? [item.brand] : [])), sql `join ${brands} t on t.slug = v.slug and t.organization_id = ${orgId}
2949
+ join ${entityBrands} l on l.entity_id = v.entity_id and l.brand_id = t.id`);
2950
+ await claim("tag", listed((item) => item.tags ?? []), sql `join ${tags} t on t.slug = v.slug and t.organization_id = ${orgId}
2951
+ join ${entityTags} l on l.entity_id = v.entity_id and l.tag_id = t.id`);
2952
+ }
2863
2953
  async convergeCatalogItems(orgId, storeId, items, actor, force = false, dryRun = false) {
2864
2954
  if (dryRun)
2865
2955
  return this.estimateCatalogItems(orgId, storeId, items);
@@ -2885,6 +2975,7 @@ export class ChannelConnectorService {
2885
2975
  // Upstream SKU/barcode changes on already-mapped variants, for the whole batch at once, so that
2886
2976
  // variants exchanging SKUs across products in this batch land together.
2887
2977
  const identity = await this.applyUpstreamVariantIdentity(orgId, storeId, items);
2978
+ await this.claimUnrecordedLinks(orgId, storeId, items);
2888
2979
  for (const item of items) {
2889
2980
  consumed += 1;
2890
2981
  try {
@@ -3085,11 +3176,17 @@ export class ChannelConnectorService {
3085
3176
  const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
3086
3177
  if (!media.ok)
3087
3178
  return media;
3179
+ const { listed, ...taxonomyLinks } = taxonomy.value;
3180
+ const links = await this.commitEntityLinks(orgId, storeId, entityId, { ...taxonomyLinks, ...media.value.links }, listed, media.value.previousRoles, isNew, actor);
3181
+ if (!links.ok) {
3182
+ failures.push({ externalId: item.externalId, error: links.error });
3183
+ continue;
3184
+ }
3088
3185
  attributesCreated += attributes.value.created;
3089
3186
  mediaImported += media.value.imported;
3090
3187
  variantsGivenOptionValues += variantIds.value.repaired;
3091
3188
  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
3189
+ entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || links.value.length > 0 || media.value.uploaded || attributes.value.changed
3093
3190
  || identity.written.has(item.externalId);
3094
3191
  const skuClashes = identity.clashes.get(item.externalId);
3095
3192
  if (skuClashes) {
@@ -3214,11 +3311,14 @@ export class ChannelConnectorService {
3214
3311
  const converged = await this.convergeCatalogItems(orgId, storeId, items, actor);
3215
3312
  if (!converged.ok)
3216
3313
  return converged;
3217
- const present = new Set(items.map((item) => item.externalId));
3314
+ // Planned BEFORE anything is archived: an empty or truncated fetch is refused whole (see
3315
+ // `planAbsentArchives`), and the refusal is reported, never half-applied.
3316
+ const plan = planAbsentArchives(entityMappings.map((mapping) => mapping.externalId), items.map((item) => item.externalId));
3317
+ const toArchive = new Set("archive" in plan ? plan.archive : []);
3218
3318
  let archived = 0;
3219
3319
  const skipped = [...converged.value.skipped];
3220
3320
  for (const mapping of entityMappings) {
3221
- if (present.has(mapping.externalId))
3321
+ if (!toArchive.has(mapping.externalId))
3222
3322
  continue;
3223
3323
  const [entity] = await this.db.select({ status: sellableEntities.status }).from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, mapping.entityId)));
3224
3324
  if (entity?.status !== "archived") {
@@ -3266,7 +3366,8 @@ export class ChannelConnectorService {
3266
3366
  archived,
3267
3367
  inventoryUpdated,
3268
3368
  openConflicts: openConflictRows.length,
3269
- driftAlert: converged.value.imported + converged.value.converged + archived > threshold,
3369
+ driftAlert: "refused" in plan || converged.value.imported + converged.value.converged + archived > threshold,
3370
+ ...("refused" in plan ? { refused: plan.refused } : {}),
3270
3371
  ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
3271
3372
  ...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
3272
3373
  ...(converged.value.warnings.length > 0 ? { warnings: converged.value.warnings } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.54.0",
3
+ "version": "0.56.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.54.0"
22
+ "@porulle/core": "0.56.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"