@porulle/plugin-channel-connector 0.47.0 → 0.48.1

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.47.0",
3
+ "version": "0.48.1",
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.47.0"
22
+ "@porulle/core": "0.48.1"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^24.5.2",
package/src/index.ts CHANGED
@@ -104,11 +104,25 @@ export const CHANNEL_MAX_BATCHES_PER_SWEEP = 5_000;
104
104
 
105
105
  /** What one bounded batch reports back: whether the store is drained, how much work it did, and
106
106
  * where it stopped. Everything here crosses a durable-step boundary, so it must stay JSON. */
107
+ import type { CatalogConvergenceFailure } from "./service.js";
108
+
107
109
  interface BatchOutcome {
108
110
  exhausted: boolean;
109
111
  counted: number;
110
112
  cursor: unknown;
111
113
  warnings?: string[];
114
+ /**
115
+ * The entities this batch committed, in input order, failures excluded — so the caller can emit
116
+ * ONE message naming the page instead of one enqueue per product.
117
+ *
118
+ * Optional in the type and unconditional in the value the service returns. `undefined` means no
119
+ * batch produced an outcome (the initial `last` below, or a plugin build that predates this
120
+ * field); `[]` means a batch ran and committed nothing. A caller that collapses those two enqueues
121
+ * nothing and reports success.
122
+ */
123
+ entityIds?: string[];
124
+ /** The items that did not land, so the caller can record them against the run rather than drop them. */
125
+ failures?: CatalogConvergenceFailure[];
112
126
  }
113
127
 
114
128
  /**
@@ -321,6 +335,15 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
321
335
  counted: page.value.imported,
322
336
  cursor: page.value.cursor ?? null,
323
337
  ...(page.value.warnings ? { warnings: page.value.warnings } : {}),
338
+ // UNCONDITIONAL, unlike `warnings` and `failures` beside it. The step's return value is
339
+ // the only per-batch seam a host application has — `walkBatches` keeps just `last` — so
340
+ // this is where a page gets its identity. Omitting it when empty would make "this batch
341
+ // committed nothing" and "this plugin build does not report entities" the same
342
+ // `undefined` at the seam, and a caller that collapses those enqueues nothing and
343
+ // reports success. The service's own return declares it unconditional for the same
344
+ // reason; dropping it here would have undone that one line later.
345
+ entityIds: page.value.entityIds,
346
+ ...(page.value.failures ? { failures: page.value.failures } : {}),
324
347
  };
325
348
  },
326
349
  );
package/src/service.ts CHANGED
@@ -324,6 +324,26 @@ interface CatalogConvergenceStats {
324
324
  * REJECT_FAILED_ROWS as against REJECT_EVERYTHING; this is the former.
325
325
  */
326
326
  failures: CatalogConvergenceFailure[];
327
+ /**
328
+ * The entities this batch actually COMMITTED, in input order, with failures excluded.
329
+ *
330
+ * It exists so a caller can name the page it just converged — one queue message carrying these
331
+ * ids instead of one enqueue per product. Everything about that use makes the exact membership
332
+ * load-bearing, so the ways it can be wrong are worth stating rather than discovering:
333
+ *
334
+ * - **A failed item must not appear.** Every failure path above `continue`s before the push, so
335
+ * an id is added only after the item's last write. An id here that was never committed makes
336
+ * the consumer pay a model call for a row that does not exist.
337
+ * - **Order is input order.** The page message is rebuilt from this array, so a stable order is
338
+ * what makes the same page produce the same message on a Workflow retry.
339
+ * - **No duplicates.** A connector that returns one `externalId` twice in a page would otherwise
340
+ * put the same entity in the message twice, and the consumer would pay for it twice. The push
341
+ * is de-duplicated on first occurrence.
342
+ * - **It must stay JSON.** This crosses a durable step boundary as part of `BatchOutcome`.
343
+ * - **It is per BATCH, never accumulated across a walk.** `walkBatches` keeps only `last`, so a
344
+ * 3,000-product store never builds a 3,000-element array against the 1 MiB step-result limit.
345
+ */
346
+ entityIds: string[];
327
347
  }
328
348
 
329
349
  type ImportResumePosition = { pageCursor: string | null; offset: number };
@@ -2420,6 +2440,18 @@ export class ChannelConnectorService {
2420
2440
  conflicts?: CatalogFieldConflict[];
2421
2441
  warnings?: string[];
2422
2442
  failures?: CatalogConvergenceFailure[];
2443
+ /**
2444
+ * The entities this batch committed — see `CatalogConvergenceStats.entityIds`.
2445
+ *
2446
+ * Declared on the BOUNDED overload only. This is the one a per-batch step wrapper calls, so it
2447
+ * is the one with a page to name. The unbounded overload walks a whole catalogue and would hand
2448
+ * back thousands of ids across a durable step boundary, which is the opposite of the point.
2449
+ *
2450
+ * Required rather than optional here, unlike its siblings, for the reason the implementation
2451
+ * states: a caller must be able to tell "this batch committed nothing" from "this build does
2452
+ * not report entities", and one of those is `[]` while the other is `undefined`.
2453
+ */
2454
+ entityIds: string[];
2423
2455
  }>>;
2424
2456
  async importCatalog(
2425
2457
  orgId: string,
@@ -2447,6 +2479,7 @@ export class ChannelConnectorService {
2447
2479
  conflicts?: CatalogFieldConflict[];
2448
2480
  warnings?: string[];
2449
2481
  failures?: CatalogConvergenceFailure[];
2482
+ entityIds?: string[];
2450
2483
  }>> {
2451
2484
  const store = await this.getStoreRecord(orgId, storeId);
2452
2485
  if (!store || store.status !== "connected") {
@@ -2494,6 +2527,7 @@ export class ChannelConnectorService {
2494
2527
  const conflicts: CatalogFieldConflict[] = [];
2495
2528
  const warnings: string[] = [];
2496
2529
  const failures: CatalogConvergenceFailure[] = [];
2530
+ const entityIds: string[] = [];
2497
2531
 
2498
2532
  while (remaining > 0) {
2499
2533
  const page = await connector.importCatalog(store as ChannelStore, pageCursor ?? undefined);
@@ -2523,6 +2557,7 @@ export class ChannelConnectorService {
2523
2557
  conflicts.push(...result.value.conflicts);
2524
2558
  warnings.push(...result.value.warnings);
2525
2559
  failures.push(...result.value.failures);
2560
+ entityIds.push(...result.value.entityIds);
2526
2561
  offset += result.value.consumed;
2527
2562
 
2528
2563
  if (offset >= pageItems.length) {
@@ -2558,6 +2593,13 @@ export class ChannelConnectorService {
2558
2593
  // Always surfaced when non-empty. A silently dropped product is worse than the halt this
2559
2594
  // replaced: the caller must be able to see which externalIds did not land.
2560
2595
  ...(failures.length > 0 ? { failures } : {}),
2596
+ // UNCONDITIONAL, unlike every optional field above it, and the asymmetry is deliberate.
2597
+ // The caller turns this into one queue message naming the page it just converged. If the key
2598
+ // were omitted when empty, a caller reading `outcome.entityIds` could not tell "this batch
2599
+ // committed nothing" from "this plugin version does not report entities" — both read as
2600
+ // `undefined`, and the second one silently produces an import that enqueues nothing. An
2601
+ // empty array says the first; a missing key says the second. They deserve different answers.
2602
+ entityIds,
2561
2603
  });
2562
2604
  }
2563
2605
 
@@ -2746,6 +2788,7 @@ export class ChannelConnectorService {
2746
2788
  conflicts: [],
2747
2789
  warnings: [],
2748
2790
  failures: [], // a dry run converges nothing, so it can fail nothing
2791
+ entityIds: [], // ...and commits nothing, so it names nothing
2749
2792
  };
2750
2793
  const assets = await this.db.select().from(mediaAssets).where(eq(mediaAssets.organizationId, orgId));
2751
2794
  for (const item of items) {
@@ -2894,6 +2937,8 @@ export class ChannelConnectorService {
2894
2937
  const conflicts: CatalogFieldConflict[] = [];
2895
2938
  const warnings: string[] = [];
2896
2939
  const failures: CatalogConvergenceFailure[] = [];
2940
+ const entityIds: string[] = [];
2941
+ const committed = new Set<string>();
2897
2942
  for (const item of items) {
2898
2943
  consumed += 1;
2899
2944
  try {
@@ -3122,6 +3167,14 @@ export class ChannelConnectorService {
3122
3167
  eq(channelEntityMap.entityId, entityId),
3123
3168
  eq(channelEntityMap.kind, "variant"),
3124
3169
  ));
3170
+ // LAST statement of the try, and that position is the whole guarantee: every failure path
3171
+ // above reaches `continue` before here, so an id is recorded only once the item's writes are
3172
+ // done. De-duplicated because a connector returning one externalId twice in a page would
3173
+ // otherwise have the consumer pay for the same entity twice.
3174
+ if (!committed.has(entityId)) {
3175
+ committed.add(entityId);
3176
+ entityIds.push(entityId);
3177
+ }
3125
3178
  } catch (error) {
3126
3179
  // Anything the stages throw rather than returning. Same disposition: record it against the
3127
3180
  // item and carry on, so an unforeseen throw costs one product and not the remaining page.
@@ -3141,6 +3194,7 @@ export class ChannelConnectorService {
3141
3194
  conflicts,
3142
3195
  warnings,
3143
3196
  failures,
3197
+ entityIds,
3144
3198
  });
3145
3199
  }
3146
3200