@porulle/plugin-channel-connector 0.44.0 → 0.46.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.44.0",
3
+ "version": "0.46.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.44.0"
22
+ "@porulle/core": "0.46.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
@@ -200,6 +200,7 @@ export type {
200
200
  CatalogPushPreviewUnavailable,
201
201
  PushCatalogToStoreResult,
202
202
  CatalogPushJobResult,
203
+ CatalogConvergenceFailure,
203
204
  CatalogFieldConflict,
204
205
  CatalogFieldSkip,
205
206
  CatalogPushFieldSkip,
package/src/service.ts CHANGED
@@ -293,6 +293,15 @@ export interface BackfillCatalogReport extends BackfillCounts, Record<string, un
293
293
  warnings?: string[];
294
294
  }
295
295
 
296
+ /**
297
+ * One item the converge loop could not finish. `externalId` is the merchant's id, because the
298
+ * entity may not exist yet and a caller reporting the failure has nothing else to name it by.
299
+ */
300
+ export interface CatalogConvergenceFailure {
301
+ externalId: string;
302
+ error: string;
303
+ }
304
+
296
305
  interface CatalogConvergenceStats {
297
306
  imported: number;
298
307
  converged: number;
@@ -304,6 +313,17 @@ interface CatalogConvergenceStats {
304
313
  skipped: CatalogFieldSkip[];
305
314
  conflicts: CatalogFieldConflict[];
306
315
  warnings: string[];
316
+ /**
317
+ * Items that failed. The batch CONTINUES past each one and counts it in `consumed`, so the
318
+ * store cursor advances and a later page is still reached.
319
+ *
320
+ * It used to `return PluginErr` on the first failure, which returned before the cursor write in
321
+ * `importCatalog` while the items already converged stayed committed. The retry then re-fetched
322
+ * the same page, re-converged the same prefix and failed on the same item: one malformed product
323
+ * halted the rest of a merchant's catalogue permanently. Saleor calls this choice
324
+ * REJECT_FAILED_ROWS as against REJECT_EVERYTHING; this is the former.
325
+ */
326
+ failures: CatalogConvergenceFailure[];
307
327
  }
308
328
 
309
329
  type ImportResumePosition = { pageCursor: string | null; offset: number };
@@ -2399,6 +2419,7 @@ export class ChannelConnectorService {
2399
2419
  skipped?: CatalogFieldSkip[];
2400
2420
  conflicts?: CatalogFieldConflict[];
2401
2421
  warnings?: string[];
2422
+ failures?: CatalogConvergenceFailure[];
2402
2423
  }>>;
2403
2424
  async importCatalog(
2404
2425
  orgId: string,
@@ -2411,6 +2432,7 @@ export class ChannelConnectorService {
2411
2432
  skipped?: CatalogFieldSkip[];
2412
2433
  conflicts?: CatalogFieldConflict[];
2413
2434
  warnings?: string[];
2435
+ failures?: CatalogConvergenceFailure[];
2414
2436
  }>>;
2415
2437
  async importCatalog(
2416
2438
  orgId: string,
@@ -2424,6 +2446,7 @@ export class ChannelConnectorService {
2424
2446
  skipped?: CatalogFieldSkip[];
2425
2447
  conflicts?: CatalogFieldConflict[];
2426
2448
  warnings?: string[];
2449
+ failures?: CatalogConvergenceFailure[];
2427
2450
  }>> {
2428
2451
  const store = await this.getStoreRecord(orgId, storeId);
2429
2452
  if (!store || store.status !== "connected") {
@@ -2459,6 +2482,7 @@ export class ChannelConnectorService {
2459
2482
  ...(result.value.skipped.length > 0 ? { skipped: uniqueSkipped(result.value.skipped) } : {}),
2460
2483
  ...(result.value.conflicts.length > 0 ? { conflicts: result.value.conflicts } : {}),
2461
2484
  ...(result.value.warnings.length > 0 ? { warnings: result.value.warnings } : {}),
2485
+ ...(result.value.failures.length > 0 ? { failures: result.value.failures } : {}),
2462
2486
  });
2463
2487
  }
2464
2488
 
@@ -2469,6 +2493,7 @@ export class ChannelConnectorService {
2469
2493
  const skipped: CatalogFieldSkip[] = [];
2470
2494
  const conflicts: CatalogFieldConflict[] = [];
2471
2495
  const warnings: string[] = [];
2496
+ const failures: CatalogConvergenceFailure[] = [];
2472
2497
 
2473
2498
  while (remaining > 0) {
2474
2499
  const page = await connector.importCatalog(store as ChannelStore, pageCursor ?? undefined);
@@ -2497,6 +2522,7 @@ export class ChannelConnectorService {
2497
2522
  skipped.push(...result.value.skipped);
2498
2523
  conflicts.push(...result.value.conflicts);
2499
2524
  warnings.push(...result.value.warnings);
2525
+ failures.push(...result.value.failures);
2500
2526
  offset += result.value.consumed;
2501
2527
 
2502
2528
  if (offset >= pageItems.length) {
@@ -2529,6 +2555,9 @@ export class ChannelConnectorService {
2529
2555
  ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
2530
2556
  ...(conflicts.length > 0 ? { conflicts } : {}),
2531
2557
  ...(warnings.length > 0 ? { warnings } : {}),
2558
+ // Always surfaced when non-empty. A silently dropped product is worse than the halt this
2559
+ // replaced: the caller must be able to see which externalIds did not land.
2560
+ ...(failures.length > 0 ? { failures } : {}),
2532
2561
  });
2533
2562
  }
2534
2563
 
@@ -2716,6 +2745,7 @@ export class ChannelConnectorService {
2716
2745
  skipped: [],
2717
2746
  conflicts: [],
2718
2747
  warnings: [],
2748
+ failures: [], // a dry run converges nothing, so it can fail nothing
2719
2749
  };
2720
2750
  const assets = await this.db.select().from(mediaAssets).where(eq(mediaAssets.organizationId, orgId));
2721
2751
  for (const item of items) {
@@ -2863,8 +2893,10 @@ export class ChannelConnectorService {
2863
2893
  const skipped: CatalogFieldSkip[] = [];
2864
2894
  const conflicts: CatalogFieldConflict[] = [];
2865
2895
  const warnings: string[] = [];
2896
+ const failures: CatalogConvergenceFailure[] = [];
2866
2897
  for (const item of items) {
2867
2898
  consumed += 1;
2899
+ try {
2868
2900
  const remoteHash = hash(item);
2869
2901
  const existing = await this.db
2870
2902
  .select()
@@ -2934,7 +2966,8 @@ export class ChannelConnectorService {
2934
2966
  return created.value.id;
2935
2967
  });
2936
2968
  } catch (error) {
2937
- return PluginErr(error instanceof Error ? error.message : "Failed to create catalog entity.");
2969
+ failures.push({ externalId: item.externalId, error: error instanceof Error ? error.message : "Failed to create catalog entity." });
2970
+ continue;
2938
2971
  }
2939
2972
  isNew = true;
2940
2973
  imported += 1;
@@ -2945,7 +2978,7 @@ export class ChannelConnectorService {
2945
2978
  const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
2946
2979
  const seedPaths = importedFieldPaths(item).filter((path) => !ownershipBeforeSeed.has(path));
2947
2980
  const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
2948
- if (!seeded.ok) return PluginErr(seeded.error.message);
2981
+ if (!seeded.ok) { failures.push({ externalId: item.externalId, error: seeded.error.message }); continue; }
2949
2982
  for (const path of seedPaths) ownershipBeforeSeed.set(path, "store");
2950
2983
  const owners = ownershipBeforeSeed;
2951
2984
  const outboundEcho = entityMapping ? this.isOutboundEcho(entityMapping, item) : false;
@@ -3008,7 +3041,7 @@ export class ChannelConnectorService {
3008
3041
  converged += 1;
3009
3042
  if (Object.keys(updateInput).length > 0) {
3010
3043
  const updated = await this.catalog.update(entityMapping.entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
3011
- if (!updated.ok) return PluginErr(updated.error.message);
3044
+ if (!updated.ok) { failures.push({ externalId: item.externalId, error: updated.error.message }); continue; }
3012
3045
  entityTouched = true;
3013
3046
  }
3014
3047
  }
@@ -3043,7 +3076,7 @@ export class ChannelConnectorService {
3043
3076
 
3044
3077
  if (entityTouched) {
3045
3078
  const revision = await this.catalog.recordEntityRevision(entityId, actor, "import");
3046
- if (!revision.ok) return PluginErr(revision.error.message);
3079
+ if (!revision.ok) { failures.push({ externalId: item.externalId, error: revision.error.message }); continue; }
3047
3080
  }
3048
3081
 
3049
3082
  const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
@@ -3089,6 +3122,12 @@ export class ChannelConnectorService {
3089
3122
  eq(channelEntityMap.entityId, entityId),
3090
3123
  eq(channelEntityMap.kind, "variant"),
3091
3124
  ));
3125
+ } catch (error) {
3126
+ // Anything the stages throw rather than returning. Same disposition: record it against the
3127
+ // item and carry on, so an unforeseen throw costs one product and not the remaining page.
3128
+ failures.push({ externalId: item.externalId, error: error instanceof Error ? error.message : String(error) });
3129
+ continue;
3130
+ }
3092
3131
  }
3093
3132
  return Ok({
3094
3133
  imported,
@@ -3101,6 +3140,7 @@ export class ChannelConnectorService {
3101
3140
  skipped,
3102
3141
  conflicts,
3103
3142
  warnings,
3143
+ failures,
3104
3144
  });
3105
3145
  }
3106
3146