@porulle/plugin-channel-connector 0.58.0 → 0.60.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,6 +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
+ export { channelSyncHash, withDistinctVariantSkus } from "./service.js";
4
4
  export type { AbsentArchivePlan } from "./deletion-policy.js";
5
5
  export { mockChannelConnector } from "./mock-connector.js";
6
6
  export type { MockChannelConnectorOptions } from "./mock-connector.js";
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import { ChannelConnectorService, catalogPushConcurrencyKey, CHANNEL_INVENTORY_M
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
+ export { channelSyncHash, withDistinctVariantSkus } from "./service.js";
12
12
  export { mockChannelConnector } from "./mock-connector.js";
13
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";
14
14
  export { isValidCatalogMappingFieldPath, matchFieldPath, mergeCatalogFieldMapping, normalizeCatalogFieldMapping, compareCatalogFieldMappingSpecificity, providerCatalogFieldMappingDefaults, selectCatalogFieldMapping, validateCatalogMappingRow, } from "./catalog-field-mapping.js";
package/dist/service.d.ts CHANGED
@@ -237,6 +237,18 @@ export interface CatalogPushPreviewResult {
237
237
  export declare function canExportTransition(from: ExportState, to: ExportState): boolean;
238
238
  export declare function canCatalogPushTransition(from: CatalogPushState, to: CatalogPushState): boolean;
239
239
  /** THE sync hash: every path that records or compares a `channel_entity_map.sync_hash` uses this. */
240
+ /**
241
+ * A product whose own variants repeat a SKU would break the `(source_store_id, sku)` unique key and
242
+ * lose the whole product. Per item, group variants by SKU: the smallest externalId (string order)
243
+ * keeps it, every other variant becomes `${sku}-${externalId}`. Deterministic, independent of
244
+ * upstream order, and idempotent (a normalised item has no repeats left).
245
+ *
246
+ * Applied at the service's item intake — every `importCatalog` read and both converge entries — so
247
+ * converge, the sync hash and reconcile's `applyUpstreamVariantIdentity` all see the same variants.
248
+ * When only the host normalised, reconcile compared the stored suffixed SKU with the raw upstream one
249
+ * and recorded a `variants.sku` conflict on every pass.
250
+ */
251
+ export declare function withDistinctVariantSkus(item: ChannelCatalogItem): ChannelCatalogItem;
240
252
  export declare function channelSyncHash(value: unknown): string;
241
253
  /**
242
254
  * The suffix a store's product takes when its handle is already another store's slug: the first
@@ -301,6 +313,8 @@ export declare class ChannelConnectorService {
301
313
  private readonly jobs;
302
314
  private readonly options;
303
315
  constructor(db: PluginDb, services: Record<string, unknown>, options?: ChannelConnectorPluginOptions, transaction?: PluginTxFn);
316
+ /** Every catalogue read goes through here, so each item leaves the intake normalised once. */
317
+ private readCatalogPage;
304
318
  getConnector(providerId: string): ChannelConnector | undefined;
305
319
  private get catalog();
306
320
  private get media();
@@ -436,7 +450,7 @@ export declare class ChannelConnectorService {
436
450
  * linked at entity level as `primary` plus to the variants it shows. The first photo of every
437
451
  * other variant comes back in `deferredMedia` for the host to land later.
438
452
  */
439
- convergeCatalogPage(orgId: string, storeId: string, items: ChannelCatalogItem[], actor: Actor): Promise<PluginResult<CatalogPageConvergence>>;
453
+ convergeCatalogPage(orgId: string, storeId: string, rawItems: ChannelCatalogItem[], actor: Actor): Promise<PluginResult<CatalogPageConvergence>>;
440
454
  private importHeroes;
441
455
  importCatalog(orgId: string, storeId: string, actor: Actor, options: {
442
456
  maxItems: number;
package/dist/service.js CHANGED
@@ -146,6 +146,35 @@ function canonicalJson(value) {
146
146
  return JSON.stringify(value) ?? "null";
147
147
  }
148
148
  /** THE sync hash: every path that records or compares a `channel_entity_map.sync_hash` uses this. */
149
+ /**
150
+ * A product whose own variants repeat a SKU would break the `(source_store_id, sku)` unique key and
151
+ * lose the whole product. Per item, group variants by SKU: the smallest externalId (string order)
152
+ * keeps it, every other variant becomes `${sku}-${externalId}`. Deterministic, independent of
153
+ * upstream order, and idempotent (a normalised item has no repeats left).
154
+ *
155
+ * Applied at the service's item intake — every `importCatalog` read and both converge entries — so
156
+ * converge, the sync hash and reconcile's `applyUpstreamVariantIdentity` all see the same variants.
157
+ * When only the host normalised, reconcile compared the stored suffixed SKU with the raw upstream one
158
+ * and recorded a `variants.sku` conflict on every pass.
159
+ */
160
+ export function withDistinctVariantSkus(item) {
161
+ const keeper = new Map();
162
+ for (const variant of item.variants) {
163
+ if (variant.sku === undefined)
164
+ continue;
165
+ const held = keeper.get(variant.sku);
166
+ if (held === undefined || variant.externalId < held)
167
+ keeper.set(variant.sku, variant.externalId);
168
+ }
169
+ if (keeper.size === item.variants.filter((variant) => variant.sku !== undefined).length)
170
+ return item;
171
+ return {
172
+ ...item,
173
+ variants: item.variants.map((variant) => variant.sku === undefined || keeper.get(variant.sku) === variant.externalId
174
+ ? variant
175
+ : { ...variant, sku: `${variant.sku}-${variant.externalId}` }),
176
+ };
177
+ }
149
178
  export function channelSyncHash(value) {
150
179
  return createHash("sha256").update(canonicalJson(value)).digest("hex");
151
180
  }
@@ -581,6 +610,13 @@ export class ChannelConnectorService {
581
610
  this.jobs = options.jobs ?? services.jobs;
582
611
  this.transact = transaction ?? ((fn) => this.db.transaction(fn));
583
612
  }
613
+ /** Every catalogue read goes through here, so each item leaves the intake normalised once. */
614
+ async readCatalogPage(connector, store, cursor) {
615
+ const page = await connector.importCatalog(store, cursor);
616
+ if (!page.ok)
617
+ return page;
618
+ return { ...page, value: { ...page.value, items: page.value.items.map(withDistinctVariantSkus) } };
619
+ }
584
620
  getConnector(providerId) {
585
621
  return this.connectors.get(providerId);
586
622
  }
@@ -2316,7 +2352,7 @@ export class ChannelConnectorService {
2316
2352
  const connector = this.connectors.get(store.provider);
2317
2353
  if (!connector)
2318
2354
  return PluginErr(`No connector registered for provider "${store.provider}".`);
2319
- const page = await connector.importCatalog(store, cursor ?? undefined);
2355
+ const page = await this.readCatalogPage(connector, store, cursor ?? undefined);
2320
2356
  if (!page.ok)
2321
2357
  return PluginErr(page.error.message);
2322
2358
  return Ok({ items: page.value.items, nextCursor: page.value.nextCursor ?? null });
@@ -2331,7 +2367,9 @@ export class ChannelConnectorService {
2331
2367
  * linked at entity level as `primary` plus to the variants it shows. The first photo of every
2332
2368
  * other variant comes back in `deferredMedia` for the host to land later.
2333
2369
  */
2334
- async convergeCatalogPage(orgId, storeId, items, actor) {
2370
+ async convergeCatalogPage(orgId, storeId, rawItems, actor) {
2371
+ // A host may hand items it never read through this service's intake; the rule is idempotent.
2372
+ const items = rawItems.map(withDistinctVariantSkus);
2335
2373
  const failures = [];
2336
2374
  const warnings = [];
2337
2375
  const entityByExternalId = new Map();
@@ -2558,7 +2596,7 @@ export class ChannelConnectorService {
2558
2596
  const items = [];
2559
2597
  let pageCursor = resume.pageCursor ?? undefined;
2560
2598
  do {
2561
- const page = await connector.importCatalog(store, pageCursor);
2599
+ const page = await this.readCatalogPage(connector, store, pageCursor);
2562
2600
  if (!page.ok)
2563
2601
  return PluginErr(page.error.message);
2564
2602
  items.push(...page.value.items);
@@ -2590,7 +2628,7 @@ export class ChannelConnectorService {
2590
2628
  const failures = [];
2591
2629
  const entityIds = [];
2592
2630
  while (remaining > 0) {
2593
- const page = await connector.importCatalog(store, pageCursor ?? undefined);
2631
+ const page = await this.readCatalogPage(connector, store, pageCursor ?? undefined);
2594
2632
  if (!page.ok)
2595
2633
  return PluginErr(page.error.message);
2596
2634
  const pageItems = page.value.items;
@@ -2753,7 +2791,7 @@ export class ChannelConnectorService {
2753
2791
  });
2754
2792
  }
2755
2793
  do {
2756
- const page = await connector.importCatalog(store, cursor);
2794
+ const page = await this.readCatalogPage(connector, store, cursor);
2757
2795
  if (!page.ok)
2758
2796
  return PluginErr(page.error.message);
2759
2797
  const converged = await this.convergeCatalogItems(orgId, storeId, page.value.items, actor, true, dryRun);
@@ -2982,7 +3020,8 @@ export class ChannelConnectorService {
2982
3020
  await claim("tag", listed((item) => item.tags ?? []), sql `join ${tags} t on t.slug = v.slug and t.organization_id = ${orgId}
2983
3021
  join ${entityTags} l on l.entity_id = v.entity_id and l.tag_id = t.id`);
2984
3022
  }
2985
- async convergeCatalogItems(orgId, storeId, items, actor, force = false, dryRun = false) {
3023
+ async convergeCatalogItems(orgId, storeId, rawItems, actor, force = false, dryRun = false) {
3024
+ const items = rawItems.map(withDistinctVariantSkus);
2986
3025
  if (dryRun)
2987
3026
  return this.estimateCatalogItems(orgId, storeId, items);
2988
3027
  // One converge, one taxonomy snapshot. Cleared HERE rather than left to the service's lifetime:
@@ -3334,7 +3373,7 @@ export class ChannelConnectorService {
3334
3373
  const items = [];
3335
3374
  let cursor;
3336
3375
  do {
3337
- const page = await connector.importCatalog(store, cursor);
3376
+ const page = await this.readCatalogPage(connector, store, cursor);
3338
3377
  if (!page.ok)
3339
3378
  return PluginErr(page.error.message);
3340
3379
  items.push(...page.value.items);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.58.0",
3
+ "version": "0.60.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.58.0"
22
+ "@porulle/core": "0.60.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/eslint-config": "0.1.0",
30
- "@porulle/typescript-config": "0.1.0"
29
+ "@porulle/typescript-config": "0.1.0",
30
+ "@porulle/eslint-config": "0.1.0"
31
31
  },
32
32
  "publishConfig": {
33
33
  "access": "public"
package/src/index.ts CHANGED
@@ -55,7 +55,7 @@ type ChannelRouteContext = {
55
55
  };
56
56
 
57
57
  export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
58
- export { channelSyncHash } from "./service.js";
58
+ export { channelSyncHash, withDistinctVariantSkus } from "./service.js";
59
59
  export type { AbsentArchivePlan } from "./deletion-policy.js";
60
60
  export { mockChannelConnector } from "./mock-connector.js";
61
61
  export type { MockChannelConnectorOptions } from "./mock-connector.js";
package/src/service.ts CHANGED
@@ -660,6 +660,34 @@ function canonicalJson(value: unknown): string {
660
660
  }
661
661
 
662
662
  /** THE sync hash: every path that records or compares a `channel_entity_map.sync_hash` uses this. */
663
+ /**
664
+ * A product whose own variants repeat a SKU would break the `(source_store_id, sku)` unique key and
665
+ * lose the whole product. Per item, group variants by SKU: the smallest externalId (string order)
666
+ * keeps it, every other variant becomes `${sku}-${externalId}`. Deterministic, independent of
667
+ * upstream order, and idempotent (a normalised item has no repeats left).
668
+ *
669
+ * Applied at the service's item intake — every `importCatalog` read and both converge entries — so
670
+ * converge, the sync hash and reconcile's `applyUpstreamVariantIdentity` all see the same variants.
671
+ * When only the host normalised, reconcile compared the stored suffixed SKU with the raw upstream one
672
+ * and recorded a `variants.sku` conflict on every pass.
673
+ */
674
+ export function withDistinctVariantSkus(item: ChannelCatalogItem): ChannelCatalogItem {
675
+ const keeper = new Map<string, string>();
676
+ for (const variant of item.variants) {
677
+ if (variant.sku === undefined) continue;
678
+ const held = keeper.get(variant.sku);
679
+ if (held === undefined || variant.externalId < held) keeper.set(variant.sku, variant.externalId);
680
+ }
681
+ if (keeper.size === item.variants.filter((variant) => variant.sku !== undefined).length) return item;
682
+ return {
683
+ ...item,
684
+ variants: item.variants.map((variant) =>
685
+ variant.sku === undefined || keeper.get(variant.sku) === variant.externalId
686
+ ? variant
687
+ : { ...variant, sku: `${variant.sku}-${variant.externalId}` }),
688
+ };
689
+ }
690
+
663
691
  export function channelSyncHash(value: unknown): string {
664
692
  return createHash("sha256").update(canonicalJson(value)).digest("hex");
665
693
  }
@@ -1178,6 +1206,13 @@ export class ChannelConnectorService {
1178
1206
  this.transact = transaction ?? ((fn) => this.db.transaction(fn));
1179
1207
  }
1180
1208
 
1209
+ /** Every catalogue read goes through here, so each item leaves the intake normalised once. */
1210
+ private async readCatalogPage(connector: ChannelConnector, store: ChannelStore, cursor: string | undefined): ReturnType<ChannelConnector["importCatalog"]> {
1211
+ const page = await connector.importCatalog(store, cursor);
1212
+ if (!page.ok) return page;
1213
+ return { ...page, value: { ...page.value, items: page.value.items.map(withDistinctVariantSkus) } };
1214
+ }
1215
+
1181
1216
  getConnector(providerId: string): ChannelConnector | undefined {
1182
1217
  return this.connectors.get(providerId);
1183
1218
  }
@@ -3196,7 +3231,7 @@ export class ChannelConnectorService {
3196
3231
  if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
3197
3232
  const connector = this.connectors.get(store.provider);
3198
3233
  if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`);
3199
- const page = await connector.importCatalog(store as ChannelStore, cursor ?? undefined);
3234
+ const page = await this.readCatalogPage(connector, store as ChannelStore, cursor ?? undefined);
3200
3235
  if (!page.ok) return PluginErr(page.error.message);
3201
3236
  return Ok({ items: page.value.items, nextCursor: page.value.nextCursor ?? null });
3202
3237
  }
@@ -3214,9 +3249,11 @@ export class ChannelConnectorService {
3214
3249
  async convergeCatalogPage(
3215
3250
  orgId: string,
3216
3251
  storeId: string,
3217
- items: ChannelCatalogItem[],
3252
+ rawItems: ChannelCatalogItem[],
3218
3253
  actor: Actor,
3219
3254
  ): Promise<PluginResult<CatalogPageConvergence>> {
3255
+ // A host may hand items it never read through this service's intake; the rule is idempotent.
3256
+ const items = rawItems.map(withDistinctVariantSkus);
3220
3257
  const failures: CatalogConvergenceFailure[] = [];
3221
3258
  const warnings: string[] = [];
3222
3259
  const entityByExternalId = new Map<string, string>();
@@ -3512,7 +3549,7 @@ export class ChannelConnectorService {
3512
3549
  const items: ChannelCatalogItem[] = [];
3513
3550
  let pageCursor: string | undefined = resume.pageCursor ?? undefined;
3514
3551
  do {
3515
- const page = await connector.importCatalog(store as ChannelStore, pageCursor);
3552
+ const page = await this.readCatalogPage(connector, store as ChannelStore, pageCursor);
3516
3553
  if (!page.ok) return PluginErr(page.error.message);
3517
3554
  items.push(...page.value.items);
3518
3555
  pageCursor = page.value.nextCursor ?? undefined;
@@ -3546,7 +3583,7 @@ export class ChannelConnectorService {
3546
3583
  const entityIds: string[] = [];
3547
3584
 
3548
3585
  while (remaining > 0) {
3549
- const page = await connector.importCatalog(store as ChannelStore, pageCursor ?? undefined);
3586
+ const page = await this.readCatalogPage(connector, store as ChannelStore, pageCursor ?? undefined);
3550
3587
  if (!page.ok) return PluginErr(page.error.message);
3551
3588
 
3552
3589
  const pageItems = page.value.items;
@@ -3732,7 +3769,7 @@ export class ChannelConnectorService {
3732
3769
  });
3733
3770
  }
3734
3771
  do {
3735
- const page = await connector.importCatalog(store as ChannelStore, cursor);
3772
+ const page = await this.readCatalogPage(connector, store as ChannelStore, cursor);
3736
3773
  if (!page.ok) return PluginErr(page.error.message);
3737
3774
  const converged = await this.convergeCatalogItems(orgId, storeId, page.value.items, actor, true, dryRun);
3738
3775
  if (!converged.ok) return converged;
@@ -3984,11 +4021,12 @@ export class ChannelConnectorService {
3984
4021
  private async convergeCatalogItems(
3985
4022
  orgId: string,
3986
4023
  storeId: string,
3987
- items: ChannelCatalogItem[],
4024
+ rawItems: ChannelCatalogItem[],
3988
4025
  actor: Actor,
3989
4026
  force = false,
3990
4027
  dryRun = false,
3991
4028
  ): Promise<PluginResult<CatalogConvergenceStats>> {
4029
+ const items = rawItems.map(withDistinctVariantSkus);
3992
4030
  if (dryRun) return this.estimateCatalogItems(orgId, storeId, items);
3993
4031
  // One converge, one taxonomy snapshot. Cleared HERE rather than left to the service's lifetime:
3994
4032
  // the instance can outlive a batch on a warm isolate, and a taxonomy cached across batches would
@@ -4355,7 +4393,7 @@ export class ChannelConnectorService {
4355
4393
  const items: ChannelCatalogItem[] = [];
4356
4394
  let cursor: string | undefined;
4357
4395
  do {
4358
- const page = await connector.importCatalog(store as ChannelStore, cursor);
4396
+ const page = await this.readCatalogPage(connector, store as ChannelStore, cursor);
4359
4397
  if (!page.ok) return PluginErr(page.error.message);
4360
4398
  items.push(...page.value.items);
4361
4399
  cursor = page.value.nextCursor ?? undefined;