@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/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,
@@ -632,10 +637,32 @@ export function canCatalogPushTransition(from: CatalogPushState, to: CatalogPush
632
637
  return catalogPushTransitions[from].includes(to);
633
638
  }
634
639
 
635
- function hash(value: unknown): string {
636
- return createHash("sha256").update(JSON.stringify(value)).digest("hex");
640
+ /**
641
+ * JSON with every object's keys sorted, recursively — the one form every hash here is taken over.
642
+ *
643
+ * The sync hash was `sha256(JSON.stringify(item))`, and JSON.stringify writes keys in insertion
644
+ * order. The host's page consumer rebuilds each landed item in its own key order while reconcile
645
+ * hashes the adapter's objects in theirs, so the fast path and reconcile hashed the SAME content
646
+ * differently and reconcile re-converged every freshly imported product. Content, not key order,
647
+ * is what a hash here must answer. `undefined` values are dropped exactly as JSON.stringify drops
648
+ * them; array order is kept (it carries meaning: images, variants, options).
649
+ */
650
+ function canonicalJson(value: unknown): string {
651
+ if (Array.isArray(value)) return `[${value.map((entry) => (entry === undefined ? "null" : canonicalJson(entry))).join(",")}]`;
652
+ if (value !== null && typeof value === "object" && !(value instanceof Date)) {
653
+ const entries = Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
654
+ return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
655
+ }
656
+ return JSON.stringify(value) ?? "null";
657
+ }
658
+
659
+ /** THE sync hash: every path that records or compares a `channel_entity_map.sync_hash` uses this. */
660
+ export function channelSyncHash(value: unknown): string {
661
+ return createHash("sha256").update(canonicalJson(value)).digest("hex");
637
662
  }
638
663
 
664
+ const hash = channelSyncHash;
665
+
639
666
  /**
640
667
  * The suffix a store's product takes when its handle is already another store's slug: the first
641
668
  * label of the store domain (`kelly-felder.myshopify.com` → `kelly-felder`), slugified.
@@ -2048,16 +2075,20 @@ export class ChannelConnectorService {
2048
2075
  item: ChannelCatalogItem,
2049
2076
  actor: Actor,
2050
2077
  warnings: string[],
2051
- ): Promise<PluginResult<Pick<PlannedLinks, "categories" | "brands" | "tags">>> {
2078
+ ): Promise<PluginResult<Pick<PlannedLinks, "categories" | "brands" | "tags"> & { listed: Set<string> }>> {
2052
2079
  // Resolves (creating where missing) the category, brand and tag rows the item names, and PLANS
2053
2080
  // the entity's links to them. The links are written by `commitEntityLinks`, in one transaction
2054
2081
  // with the entity's version bump; a link that already exists writes nothing there.
2055
2082
  const taxonomy = await this.taxonomyFor(orgId);
2056
2083
  const links: Pick<PlannedLinks, "categories" | "brands" | "tags"> = { categories: [], brands: [], tags: [] };
2084
+ // Every link the item names, as `${kind}:${id}` — including an archived category it names but
2085
+ // is not linked to again, so a link the store still lists is never read as dropped.
2086
+ const listed = new Set<string>();
2057
2087
  const categoryRows = taxonomy.categories;
2058
2088
  for (const slug of new Set(item.categories ?? [])) {
2059
2089
  let category = categoryRows.find((row) => row.slug === slug);
2060
2090
  if (category?.status === "archived") {
2091
+ listed.add(`category:${category.id}`);
2061
2092
  warnings.push(`Skipped archived category "${slug}".`);
2062
2093
  continue;
2063
2094
  }
@@ -2073,6 +2104,7 @@ export class ChannelConnectorService {
2073
2104
  categoryRows.push(category);
2074
2105
  }
2075
2106
  links.categories.push({ entityId, categoryId: category.id, sortOrder: 0 });
2107
+ listed.add(`category:${category.id}`);
2076
2108
  }
2077
2109
 
2078
2110
  const brandRows = taxonomy.brands;
@@ -2091,6 +2123,7 @@ export class ChannelConnectorService {
2091
2123
  brandRows.push(brand);
2092
2124
  }
2093
2125
  links.brands.push({ entityId, brandId: brand.id, sortOrder: 0 });
2126
+ listed.add(`brand:${brand.id}`);
2094
2127
  }
2095
2128
 
2096
2129
  const tagRows = taxonomy.tags;
@@ -2106,8 +2139,9 @@ export class ChannelConnectorService {
2106
2139
  tagRows.push(tag);
2107
2140
  }
2108
2141
  links.tags.push({ entityId, tagId: tag.id });
2142
+ listed.add(`tag:${tag.id}`);
2109
2143
  }
2110
- return Ok(links);
2144
+ return Ok({ ...links, listed });
2111
2145
  }
2112
2146
 
2113
2147
  /**
@@ -2119,11 +2153,18 @@ export class ChannelConnectorService {
2119
2153
  * An entity CREATED by this converge is not versioned for its links: its creation already put it
2120
2154
  * in front of every consumer, so a bump here would re-project a product in the same breath as its
2121
2155
  * first projection — the cold-import cost this rule exists to avoid.
2156
+ *
2157
+ * It also REMOVES the category, brand and tag links the store no longer lists — only those on
2158
+ * record in `channel_entity_links` as this store's, so a link the merchant added survives. Rows
2159
+ * this converge inserts go on record; a product imported before provenance existed is claimed by
2160
+ * `claimUnrecordedLinks` at the top of the converge.
2122
2161
  */
2123
2162
  private async commitEntityLinks(
2124
2163
  orgId: string,
2164
+ storeId: string,
2125
2165
  entityId: string,
2126
2166
  planned: PlannedLinks,
2167
+ listed: Set<string>,
2127
2168
  previousRoles: Map<string, string>,
2128
2169
  isNew: boolean,
2129
2170
  actor: Actor,
@@ -2131,7 +2172,30 @@ export class ChannelConnectorService {
2131
2172
  try {
2132
2173
  return Ok(await this.transact(async (tx) => {
2133
2174
  const written = await writeEntityLinks(tx, orgId, planned);
2134
- const paths = new Set(linkFieldPaths(written).get(entityId));
2175
+ const owned = isNew ? [] : await tx.select({ kind: channelEntityLinks.kind, targetId: channelEntityLinks.targetId }).from(channelEntityLinks)
2176
+ .where(and(eq(channelEntityLinks.storeId, storeId), eq(channelEntityLinks.entityId, entityId)));
2177
+ const dropped = owned.filter((row) => !listed.has(`${row.kind}:${row.targetId}`));
2178
+ const removed = await removeEntityLinks(tx, orgId, {
2179
+ categories: dropped.filter((row) => row.kind === "category").map((row) => ({ entityId, categoryId: row.targetId })),
2180
+ brands: dropped.filter((row) => row.kind === "brand").map((row) => ({ entityId, brandId: row.targetId })),
2181
+ tags: dropped.filter((row) => row.kind === "tag").map((row) => ({ entityId, tagId: row.targetId })),
2182
+ });
2183
+ if (dropped.length > 0) {
2184
+ await tx.delete(channelEntityLinks).where(and(
2185
+ eq(channelEntityLinks.storeId, storeId),
2186
+ eq(channelEntityLinks.entityId, entityId),
2187
+ or(...dropped.map((row) => and(eq(channelEntityLinks.kind, row.kind), eq(channelEntityLinks.targetId, row.targetId)))),
2188
+ ));
2189
+ }
2190
+ const record = [
2191
+ ...written.categories.map((row) => ({ kind: "category" as const, targetId: row.categoryId })),
2192
+ ...written.brands.map((row) => ({ kind: "brand" as const, targetId: row.brandId })),
2193
+ ...written.tags.map((row) => ({ kind: "tag" as const, targetId: row.tagId })),
2194
+ ];
2195
+ if (record.length > 0) {
2196
+ await tx.insert(channelEntityLinks).values(record.map((row) => ({ organizationId: orgId, storeId, entityId, ...row }))).onConflictDoNothing();
2197
+ }
2198
+ const paths = new Set([...(linkFieldPaths(written).get(entityId) ?? []), ...(linkFieldPaths(removed).get(entityId) ?? [])]);
2135
2199
  for (const row of written.placed) {
2136
2200
  const previous = previousRoles.get(`${row.mediaAssetId}:${row.variantId}`);
2137
2201
  if (previous !== undefined) paths.add(`media.${previous}`);
@@ -3254,6 +3318,15 @@ export class ChannelConnectorService {
3254
3318
  return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant" as const, externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
3255
3319
  }),
3256
3320
  ])).onConflictDoNothing();
3321
+ // Every category / brand / tag link on an entity this page just created was written by this
3322
+ // import, so all of it is this store's on record (`channel_entity_links`) — one statement.
3323
+ const created = sql.join(createdItems.map(({ entityId }) => sql`${entityId}::uuid`), sql`, `);
3324
+ await this.db.execute(sql`
3325
+ insert into ${channelEntityLinks} (organization_id, store_id, entity_id, kind, target_id)
3326
+ select ${orgId}, ${storeId}::uuid, entity_id, 'category', category_id from ${entityCategories} where entity_id in (${created})
3327
+ union all select ${orgId}, ${storeId}::uuid, entity_id, 'brand', brand_id from ${entityBrands} where entity_id in (${created})
3328
+ union all select ${orgId}, ${storeId}::uuid, entity_id, 'tag', tag_id from ${entityTags} where entity_id in (${created})
3329
+ on conflict do nothing`);
3257
3330
  }
3258
3331
 
3259
3332
  const media = await this.importHeroes(orgId, createdItems, actor);
@@ -3851,6 +3924,54 @@ export class ChannelConnectorService {
3851
3924
  return Ok(stats);
3852
3925
  }
3853
3926
 
3927
+ /**
3928
+ * Link provenance for products imported before `channel_entity_links` existed: a mapped product
3929
+ * of this batch with NOTHING on record for this store claims, once, the category / brand / tag
3930
+ * links it has that the store lists NOW. Runs for unchanged items too — an unchanged item skips
3931
+ * its converge, and the converge that follows may already be the drop, too late to claim.
3932
+ *
3933
+ * Its limit, stated rather than hidden: a link upstream had dropped BEFORE this claim was never
3934
+ * the store's on record, so it stays (the stale set the old add-only converge left; repaired by a
3935
+ * re-import, not by a heuristic delete). One select per batch; the claims only while unclaimed
3936
+ * products remain.
3937
+ */
3938
+ private async claimUnrecordedLinks(orgId: string, storeId: string, items: ChannelCatalogItem[]): Promise<void> {
3939
+ // Nothing listed, nothing to claim: no query. Otherwise one select per converge call (a batch),
3940
+ // never one per product.
3941
+ if (!items.some((item) => (item.tags?.length ?? 0) > 0 || (item.categories?.length ?? 0) > 0 || item.brand !== undefined)) return;
3942
+ const unclaimed = await this.db.select({ externalId: channelEntityMap.externalId, entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(
3943
+ eq(channelEntityMap.organizationId, orgId),
3944
+ eq(channelEntityMap.storeId, storeId),
3945
+ eq(channelEntityMap.kind, "entity"),
3946
+ inArray(channelEntityMap.externalId, items.map((item) => item.externalId)),
3947
+ sql`not exists (select 1 from ${channelEntityLinks} where ${channelEntityLinks.storeId} = ${channelEntityMap.storeId} and ${channelEntityLinks.entityId} = ${channelEntityMap.entityId})`,
3948
+ ));
3949
+ if (unclaimed.length === 0) return;
3950
+ const entityOf = new Map(unclaimed.map((row) => [row.externalId, row.entityId]));
3951
+ const listed = (pick: (item: ChannelCatalogItem) => readonly string[]) => items.flatMap((item) => {
3952
+ const entityId = entityOf.get(item.externalId);
3953
+ return entityId === undefined ? [] : [...new Set(pick(item))].map((slug) => sql`(${entityId}::uuid, ${slug}::text)`);
3954
+ });
3955
+ const claim = async (kind: "category" | "brand" | "tag", pairs: SQL[], link: SQL) => {
3956
+ if (pairs.length === 0) return;
3957
+ await this.db.execute(sql`
3958
+ insert into ${channelEntityLinks} (organization_id, store_id, entity_id, kind, target_id)
3959
+ select ${orgId}, ${storeId}::uuid, v.entity_id, ${kind}, t.id
3960
+ from (values ${sql.join(pairs, sql`, `)}) as v(entity_id, slug)
3961
+ ${link}
3962
+ on conflict do nothing`);
3963
+ };
3964
+ await claim("category", listed((item) => item.categories ?? []),
3965
+ sql`join ${categories} t on t.slug = v.slug and t.organization_id = ${orgId}
3966
+ join ${entityCategories} l on l.entity_id = v.entity_id and l.category_id = t.id`);
3967
+ await claim("brand", listed((item) => (item.brand ? [item.brand] : [])),
3968
+ sql`join ${brands} t on t.slug = v.slug and t.organization_id = ${orgId}
3969
+ join ${entityBrands} l on l.entity_id = v.entity_id and l.brand_id = t.id`);
3970
+ await claim("tag", listed((item) => item.tags ?? []),
3971
+ sql`join ${tags} t on t.slug = v.slug and t.organization_id = ${orgId}
3972
+ join ${entityTags} l on l.entity_id = v.entity_id and l.tag_id = t.id`);
3973
+ }
3974
+
3854
3975
  private async convergeCatalogItems(
3855
3976
  orgId: string,
3856
3977
  storeId: string,
@@ -3882,6 +4003,7 @@ export class ChannelConnectorService {
3882
4003
  // Upstream SKU/barcode changes on already-mapped variants, for the whole batch at once, so that
3883
4004
  // variants exchanging SKUs across products in this batch land together.
3884
4005
  const identity = await this.applyUpstreamVariantIdentity(orgId, storeId, items);
4006
+ await this.claimUnrecordedLinks(orgId, storeId, items);
3885
4007
  for (const item of items) {
3886
4008
  consumed += 1;
3887
4009
  try {
@@ -4095,7 +4217,8 @@ export class ChannelConnectorService {
4095
4217
  if (!taxonomy.ok) { failures.push({ externalId: item.externalId, error: taxonomy.error }); continue; }
4096
4218
  const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
4097
4219
  if (!media.ok) return media;
4098
- const links = await this.commitEntityLinks(orgId, entityId, { ...taxonomy.value, ...media.value.links }, media.value.previousRoles, isNew, actor);
4220
+ const { listed, ...taxonomyLinks } = taxonomy.value;
4221
+ const links = await this.commitEntityLinks(orgId, storeId, entityId, { ...taxonomyLinks, ...media.value.links }, listed, media.value.previousRoles, isNew, actor);
4099
4222
  if (!links.ok) { failures.push({ externalId: item.externalId, error: links.error }); continue; }
4100
4223
  attributesCreated += attributes.value.created;
4101
4224
  mediaImported += media.value.imported;