@porulle/plugin-channel-connector 0.55.0 → 0.57.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.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type ChannelConnectorPluginOptions } from "./service.js";
2
2
  export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
3
+ export { channelSyncHash } from "./service.js";
3
4
  export type { AbsentArchivePlan } from "./deletion-policy.js";
4
5
  export { mockChannelConnector } from "./mock-connector.js";
5
6
  export type { MockChannelConnectorOptions } from "./mock-connector.js";
package/dist/index.js CHANGED
@@ -3,11 +3,12 @@ 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
10
  export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
11
+ export { channelSyncHash } from "./service.js";
11
12
  export { mockChannelConnector } from "./mock-connector.js";
12
13
  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";
13
14
  export { isValidCatalogMappingFieldPath, matchFieldPath, mergeCatalogFieldMapping, normalizeCatalogFieldMapping, compareCatalogFieldMappingSpecificity, providerCatalogFieldMappingDefaults, selectCatalogFieldMapping, validateCatalogMappingRow, } from "./catalog-field-mapping.js";
@@ -290,6 +291,7 @@ export function channelConnectorPlugin(options = {}) {
290
291
  schema: () => ({
291
292
  connectedStores,
292
293
  channelEntityMap,
294
+ channelEntityLinks,
293
295
  channelCatalogPushes,
294
296
  channelCatalogPushEvents,
295
297
  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
@@ -236,6 +236,8 @@ export interface CatalogPushPreviewResult {
236
236
  }
237
237
  export declare function canExportTransition(from: ExportState, to: ExportState): boolean;
238
238
  export declare function canCatalogPushTransition(from: CatalogPushState, to: CatalogPushState): boolean;
239
+ /** THE sync hash: every path that records or compares a `channel_entity_map.sync_hash` uses this. */
240
+ export declare function channelSyncHash(value: unknown): string;
239
241
  /**
240
242
  * The suffix a store's product takes when its handle is already another store's slug: the first
241
243
  * label of the store domain (`kelly-felder.myshopify.com` → `kelly-felder`), slugified.
@@ -368,6 +370,11 @@ export declare class ChannelConnectorService {
368
370
  * An entity CREATED by this converge is not versioned for its links: its creation already put it
369
371
  * in front of every consumer, so a bump here would re-project a product in the same breath as its
370
372
  * first projection — the cold-import cost this rule exists to avoid.
373
+ *
374
+ * It also REMOVES the category, brand and tag links the store no longer lists — only those on
375
+ * record in `channel_entity_links` as this store's, so a link the merchant added survives. Rows
376
+ * this converge inserts go on record; a product imported before provenance existed is claimed by
377
+ * `claimUnrecordedLinks` at the top of the converge.
371
378
  */
372
379
  private commitEntityLinks;
373
380
  private applyMedia;
@@ -466,6 +473,18 @@ export declare class ChannelConnectorService {
466
473
  private saveBackfillState;
467
474
  backfillCatalog(orgId: string, storeId: string, actor: Actor, options?: BackfillCatalogOptions): Promise<PluginResult<BackfillCatalogReport>>;
468
475
  private estimateCatalogItems;
476
+ /**
477
+ * Link provenance for products imported before `channel_entity_links` existed: a mapped product
478
+ * of this batch with NOTHING on record for this store claims, once, the category / brand / tag
479
+ * links it has that the store lists NOW. Runs for unchanged items too — an unchanged item skips
480
+ * its converge, and the converge that follows may already be the drop, too late to claim.
481
+ *
482
+ * Its limit, stated rather than hidden: a link upstream had dropped BEFORE this claim was never
483
+ * the store's on record, so it stays (the stale set the old add-only converge left; repaired by a
484
+ * re-import, not by a heuristic delete). One select per batch; the claims only while unclaimed
485
+ * products remain.
486
+ */
487
+ private claimUnrecordedLinks;
469
488
  private convergeCatalogItems;
470
489
  reconcile(orgId: string, storeId: string, actor: Actor): Promise<PluginResult<ReconcileReport>>;
471
490
  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,
@@ -123,9 +123,30 @@ const catalogPushTransitions = {
123
123
  export function canCatalogPushTransition(from, to) {
124
124
  return catalogPushTransitions[from].includes(to);
125
125
  }
126
- function hash(value) {
127
- return createHash("sha256").update(JSON.stringify(value)).digest("hex");
126
+ /**
127
+ * JSON with every object's keys sorted, recursively — the one form every hash here is taken over.
128
+ *
129
+ * The sync hash was `sha256(JSON.stringify(item))`, and JSON.stringify writes keys in insertion
130
+ * order. The host's page consumer rebuilds each landed item in its own key order while reconcile
131
+ * hashes the adapter's objects in theirs, so the fast path and reconcile hashed the SAME content
132
+ * differently and reconcile re-converged every freshly imported product. Content, not key order,
133
+ * is what a hash here must answer. `undefined` values are dropped exactly as JSON.stringify drops
134
+ * them; array order is kept (it carries meaning: images, variants, options).
135
+ */
136
+ function canonicalJson(value) {
137
+ if (Array.isArray(value))
138
+ return `[${value.map((entry) => (entry === undefined ? "null" : canonicalJson(entry))).join(",")}]`;
139
+ if (value !== null && typeof value === "object" && !(value instanceof Date)) {
140
+ const entries = Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
141
+ return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
142
+ }
143
+ return JSON.stringify(value) ?? "null";
128
144
  }
145
+ /** THE sync hash: every path that records or compares a `channel_entity_map.sync_hash` uses this. */
146
+ export function channelSyncHash(value) {
147
+ return createHash("sha256").update(canonicalJson(value)).digest("hex");
148
+ }
149
+ const hash = channelSyncHash;
129
150
  /**
130
151
  * The suffix a store's product takes when its handle is already another store's slug: the first
131
152
  * label of the store domain (`kelly-felder.myshopify.com` → `kelly-felder`), slugified.
@@ -1339,10 +1360,14 @@ export class ChannelConnectorService {
1339
1360
  // with the entity's version bump; a link that already exists writes nothing there.
1340
1361
  const taxonomy = await this.taxonomyFor(orgId);
1341
1362
  const links = { categories: [], brands: [], tags: [] };
1363
+ // Every link the item names, as `${kind}:${id}` — including an archived category it names but
1364
+ // is not linked to again, so a link the store still lists is never read as dropped.
1365
+ const listed = new Set();
1342
1366
  const categoryRows = taxonomy.categories;
1343
1367
  for (const slug of new Set(item.categories ?? [])) {
1344
1368
  let category = categoryRows.find((row) => row.slug === slug);
1345
1369
  if (category?.status === "archived") {
1370
+ listed.add(`category:${category.id}`);
1346
1371
  warnings.push(`Skipped archived category "${slug}".`);
1347
1372
  continue;
1348
1373
  }
@@ -1354,6 +1379,7 @@ export class ChannelConnectorService {
1354
1379
  categoryRows.push(category);
1355
1380
  }
1356
1381
  links.categories.push({ entityId, categoryId: category.id, sortOrder: 0 });
1382
+ listed.add(`category:${category.id}`);
1357
1383
  }
1358
1384
  const brandRows = taxonomy.brands;
1359
1385
  if (item.brand) {
@@ -1367,6 +1393,7 @@ export class ChannelConnectorService {
1367
1393
  brandRows.push(brand);
1368
1394
  }
1369
1395
  links.brands.push({ entityId, brandId: brand.id, sortOrder: 0 });
1396
+ listed.add(`brand:${brand.id}`);
1370
1397
  }
1371
1398
  const tagRows = taxonomy.tags;
1372
1399
  for (const slug of new Set(item.tags ?? [])) {
@@ -1379,8 +1406,9 @@ export class ChannelConnectorService {
1379
1406
  tagRows.push(tag);
1380
1407
  }
1381
1408
  links.tags.push({ entityId, tagId: tag.id });
1409
+ listed.add(`tag:${tag.id}`);
1382
1410
  }
1383
- return Ok(links);
1411
+ return Ok({ ...links, listed });
1384
1412
  }
1385
1413
  /**
1386
1414
  * Writes one item's planned links and versions the entity for them, in ONE transaction: the
@@ -1391,12 +1419,36 @@ export class ChannelConnectorService {
1391
1419
  * An entity CREATED by this converge is not versioned for its links: its creation already put it
1392
1420
  * in front of every consumer, so a bump here would re-project a product in the same breath as its
1393
1421
  * first projection — the cold-import cost this rule exists to avoid.
1422
+ *
1423
+ * It also REMOVES the category, brand and tag links the store no longer lists — only those on
1424
+ * record in `channel_entity_links` as this store's, so a link the merchant added survives. Rows
1425
+ * this converge inserts go on record; a product imported before provenance existed is claimed by
1426
+ * `claimUnrecordedLinks` at the top of the converge.
1394
1427
  */
1395
- async commitEntityLinks(orgId, entityId, planned, previousRoles, isNew, actor) {
1428
+ async commitEntityLinks(orgId, storeId, entityId, planned, listed, previousRoles, isNew, actor) {
1396
1429
  try {
1397
1430
  return Ok(await this.transact(async (tx) => {
1398
1431
  const written = await writeEntityLinks(tx, orgId, planned);
1399
- const paths = new Set(linkFieldPaths(written).get(entityId));
1432
+ const owned = isNew ? [] : await tx.select({ kind: channelEntityLinks.kind, targetId: channelEntityLinks.targetId }).from(channelEntityLinks)
1433
+ .where(and(eq(channelEntityLinks.storeId, storeId), eq(channelEntityLinks.entityId, entityId)));
1434
+ const dropped = owned.filter((row) => !listed.has(`${row.kind}:${row.targetId}`));
1435
+ const removed = await removeEntityLinks(tx, orgId, {
1436
+ categories: dropped.filter((row) => row.kind === "category").map((row) => ({ entityId, categoryId: row.targetId })),
1437
+ brands: dropped.filter((row) => row.kind === "brand").map((row) => ({ entityId, brandId: row.targetId })),
1438
+ tags: dropped.filter((row) => row.kind === "tag").map((row) => ({ entityId, tagId: row.targetId })),
1439
+ });
1440
+ if (dropped.length > 0) {
1441
+ 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))))));
1442
+ }
1443
+ const record = [
1444
+ ...written.categories.map((row) => ({ kind: "category", targetId: row.categoryId })),
1445
+ ...written.brands.map((row) => ({ kind: "brand", targetId: row.brandId })),
1446
+ ...written.tags.map((row) => ({ kind: "tag", targetId: row.tagId })),
1447
+ ];
1448
+ if (record.length > 0) {
1449
+ await tx.insert(channelEntityLinks).values(record.map((row) => ({ organizationId: orgId, storeId, entityId, ...row }))).onConflictDoNothing();
1450
+ }
1451
+ const paths = new Set([...(linkFieldPaths(written).get(entityId) ?? []), ...(linkFieldPaths(removed).get(entityId) ?? [])]);
1400
1452
  for (const row of written.placed) {
1401
1453
  const previous = previousRoles.get(`${row.mediaAssetId}:${row.variantId}`);
1402
1454
  if (previous !== undefined)
@@ -2375,6 +2427,15 @@ export class ChannelConnectorService {
2375
2427
  return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant", externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
2376
2428
  }),
2377
2429
  ])).onConflictDoNothing();
2430
+ // Every category / brand / tag link on an entity this page just created was written by this
2431
+ // import, so all of it is this store's on record (`channel_entity_links`) — one statement.
2432
+ const created = sql.join(createdItems.map(({ entityId }) => sql `${entityId}::uuid`), sql `, `);
2433
+ await this.db.execute(sql `
2434
+ insert into ${channelEntityLinks} (organization_id, store_id, entity_id, kind, target_id)
2435
+ select ${orgId}, ${storeId}::uuid, entity_id, 'category', category_id from ${entityCategories} where entity_id in (${created})
2436
+ union all select ${orgId}, ${storeId}::uuid, entity_id, 'brand', brand_id from ${entityBrands} where entity_id in (${created})
2437
+ union all select ${orgId}, ${storeId}::uuid, entity_id, 'tag', tag_id from ${entityTags} where entity_id in (${created})
2438
+ on conflict do nothing`);
2378
2439
  }
2379
2440
  const media = await this.importHeroes(orgId, createdItems, actor);
2380
2441
  const entityIds = [];
@@ -2871,6 +2932,47 @@ export class ChannelConnectorService {
2871
2932
  }
2872
2933
  return Ok(stats);
2873
2934
  }
2935
+ /**
2936
+ * Link provenance for products imported before `channel_entity_links` existed: a mapped product
2937
+ * of this batch with NOTHING on record for this store claims, once, the category / brand / tag
2938
+ * links it has that the store lists NOW. Runs for unchanged items too — an unchanged item skips
2939
+ * its converge, and the converge that follows may already be the drop, too late to claim.
2940
+ *
2941
+ * Its limit, stated rather than hidden: a link upstream had dropped BEFORE this claim was never
2942
+ * the store's on record, so it stays (the stale set the old add-only converge left; repaired by a
2943
+ * re-import, not by a heuristic delete). One select per batch; the claims only while unclaimed
2944
+ * products remain.
2945
+ */
2946
+ async claimUnrecordedLinks(orgId, storeId, items) {
2947
+ // Nothing listed, nothing to claim: no query. Otherwise one select per converge call (a batch),
2948
+ // never one per product.
2949
+ if (!items.some((item) => (item.tags?.length ?? 0) > 0 || (item.categories?.length ?? 0) > 0 || item.brand !== undefined))
2950
+ return;
2951
+ 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})`));
2952
+ if (unclaimed.length === 0)
2953
+ return;
2954
+ const entityOf = new Map(unclaimed.map((row) => [row.externalId, row.entityId]));
2955
+ const listed = (pick) => items.flatMap((item) => {
2956
+ const entityId = entityOf.get(item.externalId);
2957
+ return entityId === undefined ? [] : [...new Set(pick(item))].map((slug) => sql `(${entityId}::uuid, ${slug}::text)`);
2958
+ });
2959
+ const claim = async (kind, pairs, link) => {
2960
+ if (pairs.length === 0)
2961
+ return;
2962
+ await this.db.execute(sql `
2963
+ insert into ${channelEntityLinks} (organization_id, store_id, entity_id, kind, target_id)
2964
+ select ${orgId}, ${storeId}::uuid, v.entity_id, ${kind}, t.id
2965
+ from (values ${sql.join(pairs, sql `, `)}) as v(entity_id, slug)
2966
+ ${link}
2967
+ on conflict do nothing`);
2968
+ };
2969
+ await claim("category", listed((item) => item.categories ?? []), sql `join ${categories} t on t.slug = v.slug and t.organization_id = ${orgId}
2970
+ join ${entityCategories} l on l.entity_id = v.entity_id and l.category_id = t.id`);
2971
+ await claim("brand", listed((item) => (item.brand ? [item.brand] : [])), sql `join ${brands} t on t.slug = v.slug and t.organization_id = ${orgId}
2972
+ join ${entityBrands} l on l.entity_id = v.entity_id and l.brand_id = t.id`);
2973
+ await claim("tag", listed((item) => item.tags ?? []), sql `join ${tags} t on t.slug = v.slug and t.organization_id = ${orgId}
2974
+ join ${entityTags} l on l.entity_id = v.entity_id and l.tag_id = t.id`);
2975
+ }
2874
2976
  async convergeCatalogItems(orgId, storeId, items, actor, force = false, dryRun = false) {
2875
2977
  if (dryRun)
2876
2978
  return this.estimateCatalogItems(orgId, storeId, items);
@@ -2896,6 +2998,7 @@ export class ChannelConnectorService {
2896
2998
  // Upstream SKU/barcode changes on already-mapped variants, for the whole batch at once, so that
2897
2999
  // variants exchanging SKUs across products in this batch land together.
2898
3000
  const identity = await this.applyUpstreamVariantIdentity(orgId, storeId, items);
3001
+ await this.claimUnrecordedLinks(orgId, storeId, items);
2899
3002
  for (const item of items) {
2900
3003
  consumed += 1;
2901
3004
  try {
@@ -3096,7 +3199,8 @@ export class ChannelConnectorService {
3096
3199
  const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
3097
3200
  if (!media.ok)
3098
3201
  return media;
3099
- const links = await this.commitEntityLinks(orgId, entityId, { ...taxonomy.value, ...media.value.links }, media.value.previousRoles, isNew, actor);
3202
+ const { listed, ...taxonomyLinks } = taxonomy.value;
3203
+ const links = await this.commitEntityLinks(orgId, storeId, entityId, { ...taxonomyLinks, ...media.value.links }, listed, media.value.previousRoles, isNew, actor);
3100
3204
  if (!links.ok) {
3101
3205
  failures.push({ externalId: item.externalId, error: links.error });
3102
3206
  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.57.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.57.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,
@@ -54,6 +55,7 @@ type ChannelRouteContext = {
54
55
  };
55
56
 
56
57
  export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
58
+ export { channelSyncHash } from "./service.js";
57
59
  export type { AbsentArchivePlan } from "./deletion-policy.js";
58
60
  export { mockChannelConnector } from "./mock-connector.js";
59
61
  export type { MockChannelConnectorOptions } from "./mock-connector.js";
@@ -453,6 +455,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
453
455
  schema: () => ({
454
456
  connectedStores,
455
457
  channelEntityMap,
458
+ channelEntityLinks,
456
459
  channelCatalogPushes,
457
460
  channelCatalogPushEvents,
458
461
  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
  {