@porulle/plugin-channel-connector 0.55.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.
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ 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";
@@ -290,6 +290,7 @@ export function channelConnectorPlugin(options = {}) {
290
290
  schema: () => ({
291
291
  connectedStores,
292
292
  channelEntityMap,
293
+ channelEntityLinks,
293
294
  channelCatalogPushes,
294
295
  channelCatalogPushEvents,
295
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
@@ -368,6 +368,11 @@ export declare class ChannelConnectorService {
368
368
  * An entity CREATED by this converge is not versioned for its links: its creation already put it
369
369
  * in front of every consumer, so a bump here would re-project a product in the same breath as its
370
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.
371
376
  */
372
377
  private commitEntityLinks;
373
378
  private applyMedia;
@@ -466,6 +471,18 @@ export declare class ChannelConnectorService {
466
471
  private saveBackfillState;
467
472
  backfillCatalog(orgId: string, storeId: string, actor: Actor, options?: BackfillCatalogOptions): Promise<PluginResult<BackfillCatalogReport>>;
468
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;
469
486
  private convergeCatalogItems;
470
487
  reconcile(orgId: string, storeId: string, actor: Actor): Promise<PluginResult<ReconcileReport>>;
471
488
  getReconcileStatus(orgId: string, storeId: string): Promise<PluginResult<{
package/dist/service.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
- import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, linkFieldPaths, writeEntityLinks, } 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, 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, entityBrands, entityCategories, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
7
7
  import { planAbsentArchives } from "./deletion-policy.js";
8
- import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
8
+ import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityLinks, channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
9
9
  import { mergeCatalogFieldMapping, normalizeCatalogFieldMapping, selectCatalogFieldMapping, } from "./catalog-field-mapping.js";
10
10
  export const CATALOG_PUSH_BATCH_SIZES = {
11
11
  mock: 100,
@@ -1339,10 +1339,14 @@ export class ChannelConnectorService {
1339
1339
  // with the entity's version bump; a link that already exists writes nothing there.
1340
1340
  const taxonomy = await this.taxonomyFor(orgId);
1341
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();
1342
1345
  const categoryRows = taxonomy.categories;
1343
1346
  for (const slug of new Set(item.categories ?? [])) {
1344
1347
  let category = categoryRows.find((row) => row.slug === slug);
1345
1348
  if (category?.status === "archived") {
1349
+ listed.add(`category:${category.id}`);
1346
1350
  warnings.push(`Skipped archived category "${slug}".`);
1347
1351
  continue;
1348
1352
  }
@@ -1354,6 +1358,7 @@ export class ChannelConnectorService {
1354
1358
  categoryRows.push(category);
1355
1359
  }
1356
1360
  links.categories.push({ entityId, categoryId: category.id, sortOrder: 0 });
1361
+ listed.add(`category:${category.id}`);
1357
1362
  }
1358
1363
  const brandRows = taxonomy.brands;
1359
1364
  if (item.brand) {
@@ -1367,6 +1372,7 @@ export class ChannelConnectorService {
1367
1372
  brandRows.push(brand);
1368
1373
  }
1369
1374
  links.brands.push({ entityId, brandId: brand.id, sortOrder: 0 });
1375
+ listed.add(`brand:${brand.id}`);
1370
1376
  }
1371
1377
  const tagRows = taxonomy.tags;
1372
1378
  for (const slug of new Set(item.tags ?? [])) {
@@ -1379,8 +1385,9 @@ export class ChannelConnectorService {
1379
1385
  tagRows.push(tag);
1380
1386
  }
1381
1387
  links.tags.push({ entityId, tagId: tag.id });
1388
+ listed.add(`tag:${tag.id}`);
1382
1389
  }
1383
- return Ok(links);
1390
+ return Ok({ ...links, listed });
1384
1391
  }
1385
1392
  /**
1386
1393
  * Writes one item's planned links and versions the entity for them, in ONE transaction: the
@@ -1391,12 +1398,36 @@ export class ChannelConnectorService {
1391
1398
  * An entity CREATED by this converge is not versioned for its links: its creation already put it
1392
1399
  * in front of every consumer, so a bump here would re-project a product in the same breath as its
1393
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.
1394
1406
  */
1395
- async commitEntityLinks(orgId, entityId, planned, previousRoles, isNew, actor) {
1407
+ async commitEntityLinks(orgId, storeId, entityId, planned, listed, previousRoles, isNew, actor) {
1396
1408
  try {
1397
1409
  return Ok(await this.transact(async (tx) => {
1398
1410
  const written = await writeEntityLinks(tx, orgId, planned);
1399
- const paths = new Set(linkFieldPaths(written).get(entityId));
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) ?? [])]);
1400
1431
  for (const row of written.placed) {
1401
1432
  const previous = previousRoles.get(`${row.mediaAssetId}:${row.variantId}`);
1402
1433
  if (previous !== undefined)
@@ -2375,6 +2406,15 @@ export class ChannelConnectorService {
2375
2406
  return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant", externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
2376
2407
  }),
2377
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`);
2378
2418
  }
2379
2419
  const media = await this.importHeroes(orgId, createdItems, actor);
2380
2420
  const entityIds = [];
@@ -2871,6 +2911,45 @@ export class ChannelConnectorService {
2871
2911
  }
2872
2912
  return Ok(stats);
2873
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
+ }
2874
2953
  async convergeCatalogItems(orgId, storeId, items, actor, force = false, dryRun = false) {
2875
2954
  if (dryRun)
2876
2955
  return this.estimateCatalogItems(orgId, storeId, items);
@@ -2896,6 +2975,7 @@ export class ChannelConnectorService {
2896
2975
  // Upstream SKU/barcode changes on already-mapped variants, for the whole batch at once, so that
2897
2976
  // variants exchanging SKUs across products in this batch land together.
2898
2977
  const identity = await this.applyUpstreamVariantIdentity(orgId, storeId, items);
2978
+ await this.claimUnrecordedLinks(orgId, storeId, items);
2899
2979
  for (const item of items) {
2900
2980
  consumed += 1;
2901
2981
  try {
@@ -3096,7 +3176,8 @@ export class ChannelConnectorService {
3096
3176
  const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
3097
3177
  if (!media.ok)
3098
3178
  return media;
3099
- const links = await this.commitEntityLinks(orgId, entityId, { ...taxonomy.value, ...media.value.links }, media.value.previousRoles, isNew, actor);
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);
3100
3181
  if (!links.ok) {
3101
3182
  failures.push({ externalId: item.externalId, error: links.error });
3102
3183
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.55.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.55.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"
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  channelCatalogPushes,
21
21
  channelCatalogConflicts,
22
22
  channelCatalogConflictEvents,
23
+ channelEntityLinks,
23
24
  channelEntityMap,
24
25
  channelExportEvents,
25
26
  channelOrderExports,
@@ -453,6 +454,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
453
454
  schema: () => ({
454
455
  connectedStores,
455
456
  channelEntityMap,
457
+ channelEntityLinks,
456
458
  channelCatalogPushes,
457
459
  channelCatalogPushEvents,
458
460
  channelCatalogConflicts,
package/src/schema.ts CHANGED
@@ -72,6 +72,26 @@ export const channelEntityMap = pgTable(
72
72
  }),
73
73
  );
74
74
 
75
+ /**
76
+ * The category, brand and tag links a store's converge CREATED on an entity — link provenance, per
77
+ * store, the way `channel_entity_map` records the variants a store owns. A converge removes a link
78
+ * the store dropped only when the link is on record here: a link the merchant added (or another
79
+ * store did) has no row for this store and is never this store's to take away.
80
+ */
81
+ export const channelEntityLinks = pgTable(
82
+ "channel_entity_links",
83
+ {
84
+ organizationId: text("organization_id").notNull(),
85
+ storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
86
+ entityId: uuid("entity_id").references(() => sellableEntities.id, { onDelete: "cascade" }).notNull(),
87
+ kind: text("kind", { enum: ["category", "brand", "tag"] }).notNull(),
88
+ targetId: uuid("target_id").notNull(),
89
+ },
90
+ (table) => ({
91
+ linkUnique: uniqueIndex("channel_entity_links_store_entity_kind_target_unique").on(table.storeId, table.entityId, table.kind, table.targetId),
92
+ }),
93
+ );
94
+
75
95
  export const channelCatalogConflicts = pgTable(
76
96
  "channel_catalog_conflicts",
77
97
  {
package/src/service.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  createTxContext,
8
8
  createSystemActor,
9
9
  linkFieldPaths,
10
+ removeEntityLinks,
10
11
  writeEntityLinks,
11
12
  } from "@porulle/core";
12
13
  import type {
@@ -36,13 +37,16 @@ import { isValidFieldPath, requireUserId } from "@porulle/core";
36
37
  import type { FieldOwner, FieldPath } from "@porulle/core";
37
38
  import type { JobsAdapter } from "@porulle/core";
38
39
  import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
39
- import { and, desc, eq, inArray, isNull, lte, or, sql } from "@porulle/core/drizzle";
40
+ import { and, desc, eq, inArray, isNull, lte, or, sql, type SQL } from "@porulle/core/drizzle";
40
41
  import {
41
42
  brands,
42
43
  categories,
43
44
  customerAddresses,
44
45
  customers,
46
+ entityBrands,
47
+ entityCategories,
45
48
  entityMedia,
49
+ entityTags,
46
50
  inventoryLevels,
47
51
  mediaAssets,
48
52
  optionTypes,
@@ -66,6 +70,7 @@ import {
66
70
  channelCatalogPushes,
67
71
  channelCatalogConflicts,
68
72
  channelCatalogConflictEvents,
73
+ channelEntityLinks,
69
74
  channelEntityMap,
70
75
  channelExportEvents,
71
76
  channelOrderExports,
@@ -2048,16 +2053,20 @@ export class ChannelConnectorService {
2048
2053
  item: ChannelCatalogItem,
2049
2054
  actor: Actor,
2050
2055
  warnings: string[],
2051
- ): Promise<PluginResult<Pick<PlannedLinks, "categories" | "brands" | "tags">>> {
2056
+ ): Promise<PluginResult<Pick<PlannedLinks, "categories" | "brands" | "tags"> & { listed: Set<string> }>> {
2052
2057
  // Resolves (creating where missing) the category, brand and tag rows the item names, and PLANS
2053
2058
  // the entity's links to them. The links are written by `commitEntityLinks`, in one transaction
2054
2059
  // with the entity's version bump; a link that already exists writes nothing there.
2055
2060
  const taxonomy = await this.taxonomyFor(orgId);
2056
2061
  const links: Pick<PlannedLinks, "categories" | "brands" | "tags"> = { categories: [], brands: [], tags: [] };
2062
+ // Every link the item names, as `${kind}:${id}` — including an archived category it names but
2063
+ // is not linked to again, so a link the store still lists is never read as dropped.
2064
+ const listed = new Set<string>();
2057
2065
  const categoryRows = taxonomy.categories;
2058
2066
  for (const slug of new Set(item.categories ?? [])) {
2059
2067
  let category = categoryRows.find((row) => row.slug === slug);
2060
2068
  if (category?.status === "archived") {
2069
+ listed.add(`category:${category.id}`);
2061
2070
  warnings.push(`Skipped archived category "${slug}".`);
2062
2071
  continue;
2063
2072
  }
@@ -2073,6 +2082,7 @@ export class ChannelConnectorService {
2073
2082
  categoryRows.push(category);
2074
2083
  }
2075
2084
  links.categories.push({ entityId, categoryId: category.id, sortOrder: 0 });
2085
+ listed.add(`category:${category.id}`);
2076
2086
  }
2077
2087
 
2078
2088
  const brandRows = taxonomy.brands;
@@ -2091,6 +2101,7 @@ export class ChannelConnectorService {
2091
2101
  brandRows.push(brand);
2092
2102
  }
2093
2103
  links.brands.push({ entityId, brandId: brand.id, sortOrder: 0 });
2104
+ listed.add(`brand:${brand.id}`);
2094
2105
  }
2095
2106
 
2096
2107
  const tagRows = taxonomy.tags;
@@ -2106,8 +2117,9 @@ export class ChannelConnectorService {
2106
2117
  tagRows.push(tag);
2107
2118
  }
2108
2119
  links.tags.push({ entityId, tagId: tag.id });
2120
+ listed.add(`tag:${tag.id}`);
2109
2121
  }
2110
- return Ok(links);
2122
+ return Ok({ ...links, listed });
2111
2123
  }
2112
2124
 
2113
2125
  /**
@@ -2119,11 +2131,18 @@ export class ChannelConnectorService {
2119
2131
  * An entity CREATED by this converge is not versioned for its links: its creation already put it
2120
2132
  * in front of every consumer, so a bump here would re-project a product in the same breath as its
2121
2133
  * first projection — the cold-import cost this rule exists to avoid.
2134
+ *
2135
+ * It also REMOVES the category, brand and tag links the store no longer lists — only those on
2136
+ * record in `channel_entity_links` as this store's, so a link the merchant added survives. Rows
2137
+ * this converge inserts go on record; a product imported before provenance existed is claimed by
2138
+ * `claimUnrecordedLinks` at the top of the converge.
2122
2139
  */
2123
2140
  private async commitEntityLinks(
2124
2141
  orgId: string,
2142
+ storeId: string,
2125
2143
  entityId: string,
2126
2144
  planned: PlannedLinks,
2145
+ listed: Set<string>,
2127
2146
  previousRoles: Map<string, string>,
2128
2147
  isNew: boolean,
2129
2148
  actor: Actor,
@@ -2131,7 +2150,30 @@ export class ChannelConnectorService {
2131
2150
  try {
2132
2151
  return Ok(await this.transact(async (tx) => {
2133
2152
  const written = await writeEntityLinks(tx, orgId, planned);
2134
- const paths = new Set(linkFieldPaths(written).get(entityId));
2153
+ const owned = isNew ? [] : await tx.select({ kind: channelEntityLinks.kind, targetId: channelEntityLinks.targetId }).from(channelEntityLinks)
2154
+ .where(and(eq(channelEntityLinks.storeId, storeId), eq(channelEntityLinks.entityId, entityId)));
2155
+ const dropped = owned.filter((row) => !listed.has(`${row.kind}:${row.targetId}`));
2156
+ const removed = await removeEntityLinks(tx, orgId, {
2157
+ categories: dropped.filter((row) => row.kind === "category").map((row) => ({ entityId, categoryId: row.targetId })),
2158
+ brands: dropped.filter((row) => row.kind === "brand").map((row) => ({ entityId, brandId: row.targetId })),
2159
+ tags: dropped.filter((row) => row.kind === "tag").map((row) => ({ entityId, tagId: row.targetId })),
2160
+ });
2161
+ if (dropped.length > 0) {
2162
+ await tx.delete(channelEntityLinks).where(and(
2163
+ eq(channelEntityLinks.storeId, storeId),
2164
+ eq(channelEntityLinks.entityId, entityId),
2165
+ or(...dropped.map((row) => and(eq(channelEntityLinks.kind, row.kind), eq(channelEntityLinks.targetId, row.targetId)))),
2166
+ ));
2167
+ }
2168
+ const record = [
2169
+ ...written.categories.map((row) => ({ kind: "category" as const, targetId: row.categoryId })),
2170
+ ...written.brands.map((row) => ({ kind: "brand" as const, targetId: row.brandId })),
2171
+ ...written.tags.map((row) => ({ kind: "tag" as const, targetId: row.tagId })),
2172
+ ];
2173
+ if (record.length > 0) {
2174
+ await tx.insert(channelEntityLinks).values(record.map((row) => ({ organizationId: orgId, storeId, entityId, ...row }))).onConflictDoNothing();
2175
+ }
2176
+ const paths = new Set([...(linkFieldPaths(written).get(entityId) ?? []), ...(linkFieldPaths(removed).get(entityId) ?? [])]);
2135
2177
  for (const row of written.placed) {
2136
2178
  const previous = previousRoles.get(`${row.mediaAssetId}:${row.variantId}`);
2137
2179
  if (previous !== undefined) paths.add(`media.${previous}`);
@@ -3254,6 +3296,15 @@ export class ChannelConnectorService {
3254
3296
  return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant" as const, externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
3255
3297
  }),
3256
3298
  ])).onConflictDoNothing();
3299
+ // Every category / brand / tag link on an entity this page just created was written by this
3300
+ // import, so all of it is this store's on record (`channel_entity_links`) — one statement.
3301
+ const created = sql.join(createdItems.map(({ entityId }) => sql`${entityId}::uuid`), sql`, `);
3302
+ await this.db.execute(sql`
3303
+ insert into ${channelEntityLinks} (organization_id, store_id, entity_id, kind, target_id)
3304
+ select ${orgId}, ${storeId}::uuid, entity_id, 'category', category_id from ${entityCategories} where entity_id in (${created})
3305
+ union all select ${orgId}, ${storeId}::uuid, entity_id, 'brand', brand_id from ${entityBrands} where entity_id in (${created})
3306
+ union all select ${orgId}, ${storeId}::uuid, entity_id, 'tag', tag_id from ${entityTags} where entity_id in (${created})
3307
+ on conflict do nothing`);
3257
3308
  }
3258
3309
 
3259
3310
  const media = await this.importHeroes(orgId, createdItems, actor);
@@ -3851,6 +3902,52 @@ export class ChannelConnectorService {
3851
3902
  return Ok(stats);
3852
3903
  }
3853
3904
 
3905
+ /**
3906
+ * Link provenance for products imported before `channel_entity_links` existed: a mapped product
3907
+ * of this batch with NOTHING on record for this store claims, once, the category / brand / tag
3908
+ * links it has that the store lists NOW. Runs for unchanged items too — an unchanged item skips
3909
+ * its converge, and the converge that follows may already be the drop, too late to claim.
3910
+ *
3911
+ * Its limit, stated rather than hidden: a link upstream had dropped BEFORE this claim was never
3912
+ * the store's on record, so it stays (the stale set the old add-only converge left; repaired by a
3913
+ * re-import, not by a heuristic delete). One select per batch; the claims only while unclaimed
3914
+ * products remain.
3915
+ */
3916
+ private async claimUnrecordedLinks(orgId: string, storeId: string, items: ChannelCatalogItem[]): Promise<void> {
3917
+ if (items.length === 0) return;
3918
+ const unclaimed = await this.db.select({ externalId: channelEntityMap.externalId, entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(
3919
+ eq(channelEntityMap.organizationId, orgId),
3920
+ eq(channelEntityMap.storeId, storeId),
3921
+ eq(channelEntityMap.kind, "entity"),
3922
+ inArray(channelEntityMap.externalId, items.map((item) => item.externalId)),
3923
+ sql`not exists (select 1 from ${channelEntityLinks} where ${channelEntityLinks.storeId} = ${channelEntityMap.storeId} and ${channelEntityLinks.entityId} = ${channelEntityMap.entityId})`,
3924
+ ));
3925
+ if (unclaimed.length === 0) return;
3926
+ const entityOf = new Map(unclaimed.map((row) => [row.externalId, row.entityId]));
3927
+ const listed = (pick: (item: ChannelCatalogItem) => readonly string[]) => items.flatMap((item) => {
3928
+ const entityId = entityOf.get(item.externalId);
3929
+ return entityId === undefined ? [] : [...new Set(pick(item))].map((slug) => sql`(${entityId}::uuid, ${slug}::text)`);
3930
+ });
3931
+ const claim = async (kind: "category" | "brand" | "tag", pairs: SQL[], link: SQL) => {
3932
+ if (pairs.length === 0) return;
3933
+ await this.db.execute(sql`
3934
+ insert into ${channelEntityLinks} (organization_id, store_id, entity_id, kind, target_id)
3935
+ select ${orgId}, ${storeId}::uuid, v.entity_id, ${kind}, t.id
3936
+ from (values ${sql.join(pairs, sql`, `)}) as v(entity_id, slug)
3937
+ ${link}
3938
+ on conflict do nothing`);
3939
+ };
3940
+ await claim("category", listed((item) => item.categories ?? []),
3941
+ sql`join ${categories} t on t.slug = v.slug and t.organization_id = ${orgId}
3942
+ join ${entityCategories} l on l.entity_id = v.entity_id and l.category_id = t.id`);
3943
+ await claim("brand", listed((item) => (item.brand ? [item.brand] : [])),
3944
+ sql`join ${brands} t on t.slug = v.slug and t.organization_id = ${orgId}
3945
+ join ${entityBrands} l on l.entity_id = v.entity_id and l.brand_id = t.id`);
3946
+ await claim("tag", listed((item) => item.tags ?? []),
3947
+ sql`join ${tags} t on t.slug = v.slug and t.organization_id = ${orgId}
3948
+ join ${entityTags} l on l.entity_id = v.entity_id and l.tag_id = t.id`);
3949
+ }
3950
+
3854
3951
  private async convergeCatalogItems(
3855
3952
  orgId: string,
3856
3953
  storeId: string,
@@ -3882,6 +3979,7 @@ export class ChannelConnectorService {
3882
3979
  // Upstream SKU/barcode changes on already-mapped variants, for the whole batch at once, so that
3883
3980
  // variants exchanging SKUs across products in this batch land together.
3884
3981
  const identity = await this.applyUpstreamVariantIdentity(orgId, storeId, items);
3982
+ await this.claimUnrecordedLinks(orgId, storeId, items);
3885
3983
  for (const item of items) {
3886
3984
  consumed += 1;
3887
3985
  try {
@@ -4095,7 +4193,8 @@ export class ChannelConnectorService {
4095
4193
  if (!taxonomy.ok) { failures.push({ externalId: item.externalId, error: taxonomy.error }); continue; }
4096
4194
  const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
4097
4195
  if (!media.ok) return media;
4098
- const links = await this.commitEntityLinks(orgId, entityId, { ...taxonomy.value, ...media.value.links }, media.value.previousRoles, isNew, actor);
4196
+ const { listed, ...taxonomyLinks } = taxonomy.value;
4197
+ const links = await this.commitEntityLinks(orgId, storeId, entityId, { ...taxonomyLinks, ...media.value.links }, listed, media.value.previousRoles, isNew, actor);
4099
4198
  if (!links.ok) { failures.push({ externalId: item.externalId, error: links.error }); continue; }
4100
4199
  attributesCreated += attributes.value.created;
4101
4200
  mediaImported += media.value.imported;