@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,30 @@
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
+
15
+ export type AbsentArchivePlan = { archive: string[] } | { refused: string };
16
+
17
+ export function planAbsentArchives(mappedExternalIds: Iterable<string>, presentExternalIds: Iterable<string>): AbsentArchivePlan {
18
+ const mapped = [...new Set(mappedExternalIds)];
19
+ const present = new Set(presentExternalIds);
20
+ const absent = mapped.filter((externalId) => !present.has(externalId));
21
+ if (absent.length === 0) return { archive: [] };
22
+ if (present.size === 0) {
23
+ return { refused: `The fetch listed no products while ${mapped.length} are mapped; refusing to archive them.` };
24
+ }
25
+ const bound = Math.max(ABSENT_ARCHIVE_FLOOR, Math.floor(ABSENT_ARCHIVE_FRACTION * mapped.length));
26
+ if (absent.length > bound) {
27
+ return { refused: `${absent.length} of ${mapped.length} mapped products are absent from the fetch, above the bound of ${bound}; refusing to archive them.` };
28
+ }
29
+ return { archive: absent };
30
+ }
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,
@@ -53,6 +54,8 @@ type ChannelRouteContext = {
53
54
  raw?: unknown;
54
55
  };
55
56
 
57
+ export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
58
+ export type { AbsentArchivePlan } from "./deletion-policy.js";
56
59
  export { mockChannelConnector } from "./mock-connector.js";
57
60
  export type { MockChannelConnectorOptions } from "./mock-connector.js";
58
61
  export {
@@ -451,6 +454,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
451
454
  schema: () => ({
452
455
  connectedStores,
453
456
  channelEntityMap,
457
+ channelEntityLinks,
454
458
  channelCatalogPushes,
455
459
  channelCatalogPushEvents,
456
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
@@ -6,9 +6,13 @@ import {
6
6
  PluginErr,
7
7
  createTxContext,
8
8
  createSystemActor,
9
+ linkFieldPaths,
10
+ removeEntityLinks,
11
+ writeEntityLinks,
9
12
  } from "@porulle/core";
10
13
  import type {
11
14
  Actor,
15
+ EntityLinkRows,
12
16
  ChannelCatalogItem,
13
17
  ChannelConnector,
14
18
  ChannelOrderSlice,
@@ -33,15 +37,15 @@ import { isValidFieldPath, requireUserId } from "@porulle/core";
33
37
  import type { FieldOwner, FieldPath } from "@porulle/core";
34
38
  import type { JobsAdapter } from "@porulle/core";
35
39
  import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
36
- 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";
37
41
  import {
38
42
  brands,
39
43
  categories,
40
44
  customerAddresses,
41
45
  customers,
42
- entityMedia,
43
46
  entityBrands,
44
47
  entityCategories,
48
+ entityMedia,
45
49
  entityTags,
46
50
  inventoryLevels,
47
51
  mediaAssets,
@@ -60,11 +64,13 @@ import {
60
64
  variantOptionValues,
61
65
  } from "@porulle/core/schema";
62
66
  import type { SellableEntityRevisionSnapshot } from "@porulle/core/schema";
67
+ import { planAbsentArchives } from "./deletion-policy.js";
63
68
  import {
64
69
  channelCatalogPushEvents,
65
70
  channelCatalogPushes,
66
71
  channelCatalogConflicts,
67
72
  channelCatalogConflictEvents,
73
+ channelEntityLinks,
68
74
  channelEntityMap,
69
75
  channelExportEvents,
70
76
  channelOrderExports,
@@ -164,6 +170,8 @@ export interface ReconcileReport extends Record<string, unknown> {
164
170
  inventoryUpdated: number;
165
171
  openConflicts: number;
166
172
  driftAlert: boolean;
173
+ /** Why this reconcile archived nothing although mapped products were absent (`planAbsentArchives`). */
174
+ refused?: string;
167
175
  skipped?: CatalogFieldSkip[];
168
176
  conflicts?: CatalogFieldConflict[];
169
177
  warnings?: string[];
@@ -517,6 +525,8 @@ interface CatalogService {
517
525
  actor: Actor,
518
526
  ctx?: CatalogWriteContext,
519
527
  ): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
528
+ /** Moves the entity's updated_at and fires catalog.afterUpdate for a change to related rows. */
529
+ notifyEntityChanged(entityId: string, changedFieldPaths: readonly string[], actor: Actor | null, ctx?: TxContext<PluginDb>): Promise<void>;
520
530
  recordEntityRevision(
521
531
  entityId: string,
522
532
  actor: Actor,
@@ -581,16 +591,6 @@ interface MediaService {
581
591
  },
582
592
  actor: Actor,
583
593
  ): Promise<ServiceResult<{ id: string; url: string }>>;
584
- attachToEntity(
585
- input: {
586
- entityId: string;
587
- mediaAssetId: string;
588
- role: "primary" | "gallery" | "thumbnail" | "video" | "document";
589
- variantId?: string;
590
- sortOrder?: number;
591
- },
592
- actor: Actor,
593
- ): Promise<ServiceResult<undefined>>;
594
594
  listEntityMedia(
595
595
  entityId: string,
596
596
  opts?: { variantId?: string; orgId?: string },
@@ -1127,6 +1127,9 @@ function toImportProduct(item: ChannelCatalogItem): ImportProduct {
1127
1127
  };
1128
1128
  }
1129
1129
 
1130
+ /** One item's link writes, planned before the transaction that commits them (`commitEntityLinks`). */
1131
+ type PlannedLinks = { [K in keyof EntityLinkRows]-?: Array<NonNullable<EntityLinkRows[K]>[number]> };
1132
+
1130
1133
  export class ChannelConnectorService {
1131
1134
  private readonly connectors = new Map<string, ChannelConnector>();
1132
1135
  private readonly transact: PluginTxFn;
@@ -2050,19 +2053,20 @@ export class ChannelConnectorService {
2050
2053
  item: ChannelCatalogItem,
2051
2054
  actor: Actor,
2052
2055
  warnings: string[],
2053
- ): Promise<PluginResult<{ changed: boolean }>> {
2056
+ ): Promise<PluginResult<Pick<PlannedLinks, "categories" | "brands" | "tags"> & { listed: Set<string> }>> {
2057
+ // Resolves (creating where missing) the category, brand and tag rows the item names, and PLANS
2058
+ // the entity's links to them. The links are written by `commitEntityLinks`, in one transaction
2059
+ // with the entity's version bump; a link that already exists writes nothing there.
2054
2060
  const taxonomy = await this.taxonomyFor(orgId);
2055
- // The links this entity already has, read only for the classes the item names. A link already
2056
- // there is not re-written, and a link that is added is what reports the taxonomy as changed.
2057
- const linkedCategories = new Set((item.categories ?? []).length === 0 ? [] : (await this.db
2058
- .select({ id: entityCategories.categoryId }).from(entityCategories).where(eq(entityCategories.entityId, entityId))).map((row) => row.id));
2059
- const linkedBrands = new Set(!item.brand ? [] : (await this.db
2060
- .select({ id: entityBrands.brandId }).from(entityBrands).where(eq(entityBrands.entityId, entityId))).map((row) => row.id));
2061
- let changed = false;
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>();
2062
2065
  const categoryRows = taxonomy.categories;
2063
2066
  for (const slug of new Set(item.categories ?? [])) {
2064
2067
  let category = categoryRows.find((row) => row.slug === slug);
2065
2068
  if (category?.status === "archived") {
2069
+ listed.add(`category:${category.id}`);
2066
2070
  warnings.push(`Skipped archived category "${slug}".`);
2067
2071
  continue;
2068
2072
  }
@@ -2077,10 +2081,8 @@ export class ChannelConnectorService {
2077
2081
  category = created.value;
2078
2082
  categoryRows.push(category);
2079
2083
  }
2080
- if (linkedCategories.has(category.id)) continue;
2081
- const linked = await this.catalog.addToCategory(entityId, category.id, actor);
2082
- if (!linked.ok) return PluginErr(linked.error.message);
2083
- changed = true;
2084
+ links.categories.push({ entityId, categoryId: category.id, sortOrder: 0 });
2085
+ listed.add(`category:${category.id}`);
2084
2086
  }
2085
2087
 
2086
2088
  const brandRows = taxonomy.brands;
@@ -2098,11 +2100,8 @@ export class ChannelConnectorService {
2098
2100
  brand = created.value;
2099
2101
  brandRows.push(brand);
2100
2102
  }
2101
- if (!linkedBrands.has(brand.id)) {
2102
- const linked = await this.catalog.addToBrand(entityId, brand.id, actor);
2103
- if (!linked.ok) return PluginErr(linked.error.message);
2104
- changed = true;
2105
- }
2103
+ links.brands.push({ entityId, brandId: brand.id, sortOrder: 0 });
2104
+ listed.add(`brand:${brand.id}`);
2106
2105
  }
2107
2106
 
2108
2107
  const tagRows = taxonomy.tags;
@@ -2117,10 +2116,77 @@ export class ChannelConnectorService {
2117
2116
  if (!tag) return PluginErr(`Tag "${slug}" was not persisted.`);
2118
2117
  tagRows.push(tag);
2119
2118
  }
2120
- const added = await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing().returning({ tagId: entityTags.tagId });
2121
- if (added.length > 0) changed = true;
2119
+ links.tags.push({ entityId, tagId: tag.id });
2120
+ listed.add(`tag:${tag.id}`);
2121
+ }
2122
+ return Ok({ ...links, listed });
2123
+ }
2124
+
2125
+ /**
2126
+ * Writes one item's planned links and versions the entity for them, in ONE transaction: the
2127
+ * entity's `updated_at` moves with the links or not at all, and `catalog.afterUpdate` fires once
2128
+ * for the item with every link path that really changed (`["categories","tags"]`), not once per
2129
+ * link. Returns those paths; empty means nothing changed.
2130
+ *
2131
+ * An entity CREATED by this converge is not versioned for its links: its creation already put it
2132
+ * in front of every consumer, so a bump here would re-project a product in the same breath as its
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.
2139
+ */
2140
+ private async commitEntityLinks(
2141
+ orgId: string,
2142
+ storeId: string,
2143
+ entityId: string,
2144
+ planned: PlannedLinks,
2145
+ listed: Set<string>,
2146
+ previousRoles: Map<string, string>,
2147
+ isNew: boolean,
2148
+ actor: Actor,
2149
+ ): Promise<PluginResult<string[]>> {
2150
+ try {
2151
+ return Ok(await this.transact(async (tx) => {
2152
+ const written = await writeEntityLinks(tx, orgId, planned);
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) ?? [])]);
2177
+ for (const row of written.placed) {
2178
+ const previous = previousRoles.get(`${row.mediaAssetId}:${row.variantId}`);
2179
+ if (previous !== undefined) paths.add(`media.${previous}`);
2180
+ }
2181
+ const changed = [...paths].sort();
2182
+ if (changed.length > 0 && !isNew) {
2183
+ await this.catalog.notifyEntityChanged(entityId, changed, actor, createTxContext(tx, { actor }));
2184
+ }
2185
+ return changed;
2186
+ }));
2187
+ } catch (error) {
2188
+ return PluginErr(error instanceof Error ? error.message : "Failed to write the entity's links.");
2122
2189
  }
2123
- return Ok({ changed });
2124
2190
  }
2125
2191
 
2126
2192
  private async applyMedia(
@@ -2131,7 +2197,15 @@ export class ChannelConnectorService {
2131
2197
  actor: Actor,
2132
2198
  warnings: string[],
2133
2199
  owners: Map<FieldPath, FieldOwner>,
2134
- ): Promise<PluginResult<{ imported: number; changed: boolean; skipped: FieldPath[] }>> {
2200
+ ): Promise<PluginResult<{
2201
+ imported: number;
2202
+ uploaded: boolean;
2203
+ skipped: FieldPath[];
2204
+ links: Pick<PlannedLinks, "media" | "mediaPlacements">;
2205
+ /** The role a re-placed link held before, keyed `${mediaAssetId}:${variantId}` — its path changes too. */
2206
+ previousRoles: Map<string, string>;
2207
+ }>> {
2208
+ // Uploads what is missing and PLANS the entity's media links; `commitEntityLinks` writes them.
2135
2209
  const images = item.images ?? [];
2136
2210
  const externalIds = [...new Set(images.map((image) => image.externalId).filter((id): id is string => id != null))];
2137
2211
  const urlHashes = [...new Set(images.map((image) => hash(image.url)))];
@@ -2150,8 +2224,10 @@ export class ChannelConnectorService {
2150
2224
  ));
2151
2225
  const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entityId));
2152
2226
  let imported = 0;
2153
- let changed = false;
2227
+ let uploaded = false;
2154
2228
  const skipped: FieldPath[] = [];
2229
+ const planned: Pick<PlannedLinks, "media" | "mediaPlacements"> = { media: [], mediaPlacements: [] };
2230
+ const previousRoles = new Map<string, string>();
2155
2231
 
2156
2232
  // A Cloudflare Worker may hold at most six simultaneous outbound connections per
2157
2233
  // invocation, and one image costs two of them — the download and the storage put — so
@@ -2273,7 +2349,7 @@ export class ChannelConnectorService {
2273
2349
  for (const resolved of resolvedImages) {
2274
2350
  warnings.push(...resolved.imageWarnings);
2275
2351
  imported += resolved.imported;
2276
- if (resolved.imageChanged) changed = true;
2352
+ if (resolved.imageChanged) uploaded = true;
2277
2353
  }
2278
2354
 
2279
2355
  for (const [imageIndex, image] of images.entries()) {
@@ -2302,24 +2378,12 @@ export class ChannelConnectorService {
2302
2378
  if (skipped.includes(currentRolePath) || skipped.includes(incomingRolePath)) continue;
2303
2379
  }
2304
2380
  if (existingLink.role !== image.role || existingLink.sortOrder !== (image.sortOrder ?? 0)) {
2305
- await this.db.update(entityMedia).set({ role: image.role, sortOrder: image.sortOrder ?? 0 }).where(and(
2306
- eq(entityMedia.entityId, entityId),
2307
- eq(entityMedia.mediaAssetId, mediaAssetId),
2308
- target.variantId === undefined ? isNull(entityMedia.variantId) : eq(entityMedia.variantId, target.variantId),
2309
- ));
2310
- changed = true;
2381
+ planned.mediaPlacements.push({ entityId, variantId: target.variantId ?? null, mediaAssetId, role: image.role, sortOrder: image.sortOrder ?? 0 });
2382
+ previousRoles.set(`${mediaAssetId}:${target.variantId ?? null}`, existingLink.role);
2311
2383
  }
2312
2384
  continue;
2313
2385
  }
2314
- const attached = await this.media.attachToEntity({
2315
- entityId,
2316
- mediaAssetId,
2317
- role: image.role,
2318
- sortOrder: image.sortOrder ?? 0,
2319
- ...(target.variantId !== undefined ? { variantId: target.variantId } : {}),
2320
- }, actor);
2321
- if (!attached.ok) return PluginErr(attached.error.message);
2322
- changed = true;
2386
+ planned.media.push({ entityId, variantId: target.variantId ?? null, mediaAssetId, role: image.role, sortOrder: image.sortOrder ?? 0 });
2323
2387
  links.push({
2324
2388
  entityId,
2325
2389
  mediaAssetId,
@@ -2330,7 +2394,7 @@ export class ChannelConnectorService {
2330
2394
  });
2331
2395
  }
2332
2396
  }
2333
- return Ok({ imported, changed, skipped });
2397
+ return Ok({ imported, uploaded, skipped, links: planned, previousRoles });
2334
2398
  }
2335
2399
 
2336
2400
  private async getStoreRecord(orgId: string, id: string): Promise<ConnectedStore | undefined> {
@@ -3232,6 +3296,15 @@ export class ChannelConnectorService {
3232
3296
  return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant" as const, externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
3233
3297
  }),
3234
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`);
3235
3308
  }
3236
3309
 
3237
3310
  const media = await this.importHeroes(orgId, createdItems, actor);
@@ -3328,13 +3401,13 @@ export class ChannelConnectorService {
3328
3401
  }));
3329
3402
 
3330
3403
  if (outcomes.length > 0) {
3331
- await this.db.insert(entityMedia).values(outcomes.flatMap(({ entityId, mediaAssetId, hero, variantIds }) => [
3332
- { entityId, mediaAssetId, role: "primary" as const, sortOrder: hero.sortOrder ?? 0 },
3404
+ await writeEntityLinks(this.db, orgId, { media: outcomes.flatMap(({ entityId, mediaAssetId, hero, variantIds }) => [
3405
+ { entityId, variantId: null, mediaAssetId, role: "primary" as const, sortOrder: hero.sortOrder ?? 0 },
3333
3406
  ...(hero.variantExternalIds ?? []).flatMap((externalId) => {
3334
3407
  const variantId = variantIds[externalId];
3335
3408
  return variantId === undefined ? [] : [{ entityId, variantId, mediaAssetId, role: hero.role, sortOrder: hero.sortOrder ?? 0 }];
3336
3409
  }),
3337
- ])).onConflictDoNothing();
3410
+ ]) });
3338
3411
  }
3339
3412
  return { heroesImported: outcomes.filter((outcome) => outcome.imported).length, mediaFailures, deferredMedia };
3340
3413
  }
@@ -3829,6 +3902,52 @@ export class ChannelConnectorService {
3829
3902
  return Ok(stats);
3830
3903
  }
3831
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
+
3832
3951
  private async convergeCatalogItems(
3833
3952
  orgId: string,
3834
3953
  storeId: string,
@@ -3860,6 +3979,7 @@ export class ChannelConnectorService {
3860
3979
  // Upstream SKU/barcode changes on already-mapped variants, for the whole batch at once, so that
3861
3980
  // variants exchanging SKUs across products in this batch land together.
3862
3981
  const identity = await this.applyUpstreamVariantIdentity(orgId, storeId, items);
3982
+ await this.claimUnrecordedLinks(orgId, storeId, items);
3863
3983
  for (const item of items) {
3864
3984
  consumed += 1;
3865
3985
  try {
@@ -4073,11 +4193,14 @@ export class ChannelConnectorService {
4073
4193
  if (!taxonomy.ok) { failures.push({ externalId: item.externalId, error: taxonomy.error }); continue; }
4074
4194
  const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
4075
4195
  if (!media.ok) return media;
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);
4198
+ if (!links.ok) { failures.push({ externalId: item.externalId, error: links.error }); continue; }
4076
4199
  attributesCreated += attributes.value.created;
4077
4200
  mediaImported += media.value.imported;
4078
4201
  variantsGivenOptionValues += variantIds.value.repaired;
4079
4202
  skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
4080
- entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || taxonomy.value.changed || media.value.changed || attributes.value.changed
4203
+ entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || links.value.length > 0 || media.value.uploaded || attributes.value.changed
4081
4204
  || identity.written.has(item.externalId);
4082
4205
  const skuClashes = identity.clashes.get(item.externalId);
4083
4206
  if (skuClashes) {
@@ -4207,11 +4330,14 @@ export class ChannelConnectorService {
4207
4330
 
4208
4331
  const converged = await this.convergeCatalogItems(orgId, storeId, items, actor);
4209
4332
  if (!converged.ok) return converged;
4210
- const present = new Set(items.map((item) => item.externalId));
4333
+ // Planned BEFORE anything is archived: an empty or truncated fetch is refused whole (see
4334
+ // `planAbsentArchives`), and the refusal is reported, never half-applied.
4335
+ const plan = planAbsentArchives(entityMappings.map((mapping) => mapping.externalId), items.map((item) => item.externalId));
4336
+ const toArchive = new Set("archive" in plan ? plan.archive : []);
4211
4337
  let archived = 0;
4212
4338
  const skipped = [...converged.value.skipped];
4213
4339
  for (const mapping of entityMappings) {
4214
- if (present.has(mapping.externalId)) continue;
4340
+ if (!toArchive.has(mapping.externalId)) continue;
4215
4341
  const [entity] = await this.db.select({ status: sellableEntities.status }).from(sellableEntities).where(and(
4216
4342
  eq(sellableEntities.organizationId, orgId),
4217
4343
  eq(sellableEntities.id, mapping.entityId),
@@ -4263,7 +4389,8 @@ export class ChannelConnectorService {
4263
4389
  archived,
4264
4390
  inventoryUpdated,
4265
4391
  openConflicts: openConflictRows.length,
4266
- driftAlert: converged.value.imported + converged.value.converged + archived > threshold,
4392
+ driftAlert: "refused" in plan || converged.value.imported + converged.value.converged + archived > threshold,
4393
+ ...("refused" in plan ? { refused: plan.refused } : {}),
4267
4394
  ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
4268
4395
  ...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
4269
4396
  ...(converged.value.warnings.length > 0 ? { warnings: converged.value.warnings } : {}),