@porulle/plugin-channel-connector 0.53.1 → 0.55.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.53.1",
3
+ "version": "0.55.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,7 +19,7 @@
19
19
  "dependencies": {
20
20
  "@hono/zod-openapi": "^1.2.2",
21
21
  "hono": "^4.12.5",
22
- "@porulle/core": "0.53.1"
22
+ "@porulle/core": "0.55.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^24.5.2",
@@ -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
@@ -53,6 +53,8 @@ type ChannelRouteContext = {
53
53
  raw?: unknown;
54
54
  };
55
55
 
56
+ export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
57
+ export type { AbsentArchivePlan } from "./deletion-policy.js";
56
58
  export { mockChannelConnector } from "./mock-connector.js";
57
59
  export type { MockChannelConnectorOptions } from "./mock-connector.js";
58
60
  export {
package/src/service.ts CHANGED
@@ -6,9 +6,12 @@ import {
6
6
  PluginErr,
7
7
  createTxContext,
8
8
  createSystemActor,
9
+ linkFieldPaths,
10
+ writeEntityLinks,
9
11
  } from "@porulle/core";
10
12
  import type {
11
13
  Actor,
14
+ EntityLinkRows,
12
15
  ChannelCatalogItem,
13
16
  ChannelConnector,
14
17
  ChannelOrderSlice,
@@ -40,9 +43,6 @@ import {
40
43
  customerAddresses,
41
44
  customers,
42
45
  entityMedia,
43
- entityBrands,
44
- entityCategories,
45
- entityTags,
46
46
  inventoryLevels,
47
47
  mediaAssets,
48
48
  optionTypes,
@@ -60,6 +60,7 @@ import {
60
60
  variantOptionValues,
61
61
  } from "@porulle/core/schema";
62
62
  import type { SellableEntityRevisionSnapshot } from "@porulle/core/schema";
63
+ import { planAbsentArchives } from "./deletion-policy.js";
63
64
  import {
64
65
  channelCatalogPushEvents,
65
66
  channelCatalogPushes,
@@ -164,6 +165,8 @@ export interface ReconcileReport extends Record<string, unknown> {
164
165
  inventoryUpdated: number;
165
166
  openConflicts: number;
166
167
  driftAlert: boolean;
168
+ /** Why this reconcile archived nothing although mapped products were absent (`planAbsentArchives`). */
169
+ refused?: string;
167
170
  skipped?: CatalogFieldSkip[];
168
171
  conflicts?: CatalogFieldConflict[];
169
172
  warnings?: string[];
@@ -517,6 +520,8 @@ interface CatalogService {
517
520
  actor: Actor,
518
521
  ctx?: CatalogWriteContext,
519
522
  ): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
523
+ /** Moves the entity's updated_at and fires catalog.afterUpdate for a change to related rows. */
524
+ notifyEntityChanged(entityId: string, changedFieldPaths: readonly string[], actor: Actor | null, ctx?: TxContext<PluginDb>): Promise<void>;
520
525
  recordEntityRevision(
521
526
  entityId: string,
522
527
  actor: Actor,
@@ -581,16 +586,6 @@ interface MediaService {
581
586
  },
582
587
  actor: Actor,
583
588
  ): 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
589
  listEntityMedia(
595
590
  entityId: string,
596
591
  opts?: { variantId?: string; orgId?: string },
@@ -1127,6 +1122,9 @@ function toImportProduct(item: ChannelCatalogItem): ImportProduct {
1127
1122
  };
1128
1123
  }
1129
1124
 
1125
+ /** One item's link writes, planned before the transaction that commits them (`commitEntityLinks`). */
1126
+ type PlannedLinks = { [K in keyof EntityLinkRows]-?: Array<NonNullable<EntityLinkRows[K]>[number]> };
1127
+
1130
1128
  export class ChannelConnectorService {
1131
1129
  private readonly connectors = new Map<string, ChannelConnector>();
1132
1130
  private readonly transact: PluginTxFn;
@@ -2050,15 +2048,12 @@ export class ChannelConnectorService {
2050
2048
  item: ChannelCatalogItem,
2051
2049
  actor: Actor,
2052
2050
  warnings: string[],
2053
- ): Promise<PluginResult<{ changed: boolean }>> {
2051
+ ): Promise<PluginResult<Pick<PlannedLinks, "categories" | "brands" | "tags">>> {
2052
+ // Resolves (creating where missing) the category, brand and tag rows the item names, and PLANS
2053
+ // the entity's links to them. The links are written by `commitEntityLinks`, in one transaction
2054
+ // with the entity's version bump; a link that already exists writes nothing there.
2054
2055
  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;
2056
+ const links: Pick<PlannedLinks, "categories" | "brands" | "tags"> = { categories: [], brands: [], tags: [] };
2062
2057
  const categoryRows = taxonomy.categories;
2063
2058
  for (const slug of new Set(item.categories ?? [])) {
2064
2059
  let category = categoryRows.find((row) => row.slug === slug);
@@ -2077,10 +2072,7 @@ export class ChannelConnectorService {
2077
2072
  category = created.value;
2078
2073
  categoryRows.push(category);
2079
2074
  }
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;
2075
+ links.categories.push({ entityId, categoryId: category.id, sortOrder: 0 });
2084
2076
  }
2085
2077
 
2086
2078
  const brandRows = taxonomy.brands;
@@ -2098,11 +2090,7 @@ export class ChannelConnectorService {
2098
2090
  brand = created.value;
2099
2091
  brandRows.push(brand);
2100
2092
  }
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
- }
2093
+ links.brands.push({ entityId, brandId: brand.id, sortOrder: 0 });
2106
2094
  }
2107
2095
 
2108
2096
  const tagRows = taxonomy.tags;
@@ -2117,10 +2105,46 @@ export class ChannelConnectorService {
2117
2105
  if (!tag) return PluginErr(`Tag "${slug}" was not persisted.`);
2118
2106
  tagRows.push(tag);
2119
2107
  }
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;
2108
+ links.tags.push({ entityId, tagId: tag.id });
2109
+ }
2110
+ return Ok(links);
2111
+ }
2112
+
2113
+ /**
2114
+ * Writes one item's planned links and versions the entity for them, in ONE transaction: the
2115
+ * entity's `updated_at` moves with the links or not at all, and `catalog.afterUpdate` fires once
2116
+ * for the item with every link path that really changed (`["categories","tags"]`), not once per
2117
+ * link. Returns those paths; empty means nothing changed.
2118
+ *
2119
+ * An entity CREATED by this converge is not versioned for its links: its creation already put it
2120
+ * in front of every consumer, so a bump here would re-project a product in the same breath as its
2121
+ * first projection — the cold-import cost this rule exists to avoid.
2122
+ */
2123
+ private async commitEntityLinks(
2124
+ orgId: string,
2125
+ entityId: string,
2126
+ planned: PlannedLinks,
2127
+ previousRoles: Map<string, string>,
2128
+ isNew: boolean,
2129
+ actor: Actor,
2130
+ ): Promise<PluginResult<string[]>> {
2131
+ try {
2132
+ return Ok(await this.transact(async (tx) => {
2133
+ const written = await writeEntityLinks(tx, orgId, planned);
2134
+ const paths = new Set(linkFieldPaths(written).get(entityId));
2135
+ for (const row of written.placed) {
2136
+ const previous = previousRoles.get(`${row.mediaAssetId}:${row.variantId}`);
2137
+ if (previous !== undefined) paths.add(`media.${previous}`);
2138
+ }
2139
+ const changed = [...paths].sort();
2140
+ if (changed.length > 0 && !isNew) {
2141
+ await this.catalog.notifyEntityChanged(entityId, changed, actor, createTxContext(tx, { actor }));
2142
+ }
2143
+ return changed;
2144
+ }));
2145
+ } catch (error) {
2146
+ return PluginErr(error instanceof Error ? error.message : "Failed to write the entity's links.");
2122
2147
  }
2123
- return Ok({ changed });
2124
2148
  }
2125
2149
 
2126
2150
  private async applyMedia(
@@ -2131,7 +2155,15 @@ export class ChannelConnectorService {
2131
2155
  actor: Actor,
2132
2156
  warnings: string[],
2133
2157
  owners: Map<FieldPath, FieldOwner>,
2134
- ): Promise<PluginResult<{ imported: number; changed: boolean; skipped: FieldPath[] }>> {
2158
+ ): Promise<PluginResult<{
2159
+ imported: number;
2160
+ uploaded: boolean;
2161
+ skipped: FieldPath[];
2162
+ links: Pick<PlannedLinks, "media" | "mediaPlacements">;
2163
+ /** The role a re-placed link held before, keyed `${mediaAssetId}:${variantId}` — its path changes too. */
2164
+ previousRoles: Map<string, string>;
2165
+ }>> {
2166
+ // Uploads what is missing and PLANS the entity's media links; `commitEntityLinks` writes them.
2135
2167
  const images = item.images ?? [];
2136
2168
  const externalIds = [...new Set(images.map((image) => image.externalId).filter((id): id is string => id != null))];
2137
2169
  const urlHashes = [...new Set(images.map((image) => hash(image.url)))];
@@ -2150,8 +2182,10 @@ export class ChannelConnectorService {
2150
2182
  ));
2151
2183
  const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entityId));
2152
2184
  let imported = 0;
2153
- let changed = false;
2185
+ let uploaded = false;
2154
2186
  const skipped: FieldPath[] = [];
2187
+ const planned: Pick<PlannedLinks, "media" | "mediaPlacements"> = { media: [], mediaPlacements: [] };
2188
+ const previousRoles = new Map<string, string>();
2155
2189
 
2156
2190
  // A Cloudflare Worker may hold at most six simultaneous outbound connections per
2157
2191
  // invocation, and one image costs two of them — the download and the storage put — so
@@ -2273,7 +2307,7 @@ export class ChannelConnectorService {
2273
2307
  for (const resolved of resolvedImages) {
2274
2308
  warnings.push(...resolved.imageWarnings);
2275
2309
  imported += resolved.imported;
2276
- if (resolved.imageChanged) changed = true;
2310
+ if (resolved.imageChanged) uploaded = true;
2277
2311
  }
2278
2312
 
2279
2313
  for (const [imageIndex, image] of images.entries()) {
@@ -2302,24 +2336,12 @@ export class ChannelConnectorService {
2302
2336
  if (skipped.includes(currentRolePath) || skipped.includes(incomingRolePath)) continue;
2303
2337
  }
2304
2338
  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;
2339
+ planned.mediaPlacements.push({ entityId, variantId: target.variantId ?? null, mediaAssetId, role: image.role, sortOrder: image.sortOrder ?? 0 });
2340
+ previousRoles.set(`${mediaAssetId}:${target.variantId ?? null}`, existingLink.role);
2311
2341
  }
2312
2342
  continue;
2313
2343
  }
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;
2344
+ planned.media.push({ entityId, variantId: target.variantId ?? null, mediaAssetId, role: image.role, sortOrder: image.sortOrder ?? 0 });
2323
2345
  links.push({
2324
2346
  entityId,
2325
2347
  mediaAssetId,
@@ -2330,7 +2352,7 @@ export class ChannelConnectorService {
2330
2352
  });
2331
2353
  }
2332
2354
  }
2333
- return Ok({ imported, changed, skipped });
2355
+ return Ok({ imported, uploaded, skipped, links: planned, previousRoles });
2334
2356
  }
2335
2357
 
2336
2358
  private async getStoreRecord(orgId: string, id: string): Promise<ConnectedStore | undefined> {
@@ -3328,13 +3350,13 @@ export class ChannelConnectorService {
3328
3350
  }));
3329
3351
 
3330
3352
  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 },
3353
+ await writeEntityLinks(this.db, orgId, { media: outcomes.flatMap(({ entityId, mediaAssetId, hero, variantIds }) => [
3354
+ { entityId, variantId: null, mediaAssetId, role: "primary" as const, sortOrder: hero.sortOrder ?? 0 },
3333
3355
  ...(hero.variantExternalIds ?? []).flatMap((externalId) => {
3334
3356
  const variantId = variantIds[externalId];
3335
3357
  return variantId === undefined ? [] : [{ entityId, variantId, mediaAssetId, role: hero.role, sortOrder: hero.sortOrder ?? 0 }];
3336
3358
  }),
3337
- ])).onConflictDoNothing();
3359
+ ]) });
3338
3360
  }
3339
3361
  return { heroesImported: outcomes.filter((outcome) => outcome.imported).length, mediaFailures, deferredMedia };
3340
3362
  }
@@ -4073,11 +4095,13 @@ export class ChannelConnectorService {
4073
4095
  if (!taxonomy.ok) { failures.push({ externalId: item.externalId, error: taxonomy.error }); continue; }
4074
4096
  const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
4075
4097
  if (!media.ok) return media;
4098
+ const links = await this.commitEntityLinks(orgId, entityId, { ...taxonomy.value, ...media.value.links }, media.value.previousRoles, isNew, actor);
4099
+ if (!links.ok) { failures.push({ externalId: item.externalId, error: links.error }); continue; }
4076
4100
  attributesCreated += attributes.value.created;
4077
4101
  mediaImported += media.value.imported;
4078
4102
  variantsGivenOptionValues += variantIds.value.repaired;
4079
4103
  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
4104
+ entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || links.value.length > 0 || media.value.uploaded || attributes.value.changed
4081
4105
  || identity.written.has(item.externalId);
4082
4106
  const skuClashes = identity.clashes.get(item.externalId);
4083
4107
  if (skuClashes) {
@@ -4207,11 +4231,14 @@ export class ChannelConnectorService {
4207
4231
 
4208
4232
  const converged = await this.convergeCatalogItems(orgId, storeId, items, actor);
4209
4233
  if (!converged.ok) return converged;
4210
- const present = new Set(items.map((item) => item.externalId));
4234
+ // Planned BEFORE anything is archived: an empty or truncated fetch is refused whole (see
4235
+ // `planAbsentArchives`), and the refusal is reported, never half-applied.
4236
+ const plan = planAbsentArchives(entityMappings.map((mapping) => mapping.externalId), items.map((item) => item.externalId));
4237
+ const toArchive = new Set("archive" in plan ? plan.archive : []);
4211
4238
  let archived = 0;
4212
4239
  const skipped = [...converged.value.skipped];
4213
4240
  for (const mapping of entityMappings) {
4214
- if (present.has(mapping.externalId)) continue;
4241
+ if (!toArchive.has(mapping.externalId)) continue;
4215
4242
  const [entity] = await this.db.select({ status: sellableEntities.status }).from(sellableEntities).where(and(
4216
4243
  eq(sellableEntities.organizationId, orgId),
4217
4244
  eq(sellableEntities.id, mapping.entityId),
@@ -4263,7 +4290,8 @@ export class ChannelConnectorService {
4263
4290
  archived,
4264
4291
  inventoryUpdated,
4265
4292
  openConflicts: openConflictRows.length,
4266
- driftAlert: converged.value.imported + converged.value.converged + archived > threshold,
4293
+ driftAlert: "refused" in plan || converged.value.imported + converged.value.converged + archived > threshold,
4294
+ ...("refused" in plan ? { refused: plan.refused } : {}),
4267
4295
  ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
4268
4296
  ...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
4269
4297
  ...(converged.value.warnings.length > 0 ? { warnings: converged.value.warnings } : {}),