@porulle/plugin-channel-connector 0.57.1 → 0.59.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;
@@ -501,6 +515,14 @@ export declare class ChannelConnectorService {
501
515
  synced: number;
502
516
  exhausted?: boolean;
503
517
  }>>;
518
+ /**
519
+ * One step of a store's inventory sync for a connector that pages its inventory: ONE page fetch,
520
+ * ONE mapping read for that page's variants, and ONE `inventory.setAbsoluteMany` (a constant
521
+ * number of statements, one `inventory.afterAdjustMany` for the page). The next page's cursor is
522
+ * stored on the store, so no step re-reads what an earlier one levelled — the old path re-read
523
+ * the whole inventory and every map row on every 20-level step.
524
+ */
525
+ private syncInventoryPage;
504
526
  handleWebhook(orgId: string, storeId: string, event: {
505
527
  id: string;
506
528
  type: string;
package/dist/service.js CHANGED
@@ -88,6 +88,9 @@ function parseInventoryResumePosition(raw) {
88
88
  return { offset: 0 };
89
89
  try {
90
90
  const parsed = JSON.parse(raw);
91
+ if (typeof parsed === "object" && parsed !== null && "pageCursor" in parsed) {
92
+ return { offset: 0, pageCursor: typeof parsed.pageCursor === "string" ? parsed.pageCursor : null };
93
+ }
91
94
  if (typeof parsed === "object" && parsed !== null && "offset" in parsed) {
92
95
  return {
93
96
  offset: typeof parsed.offset === "number" && parsed.offset >= 0 ? parsed.offset : 0,
@@ -143,6 +146,35 @@ function canonicalJson(value) {
143
146
  return JSON.stringify(value) ?? "null";
144
147
  }
145
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
+ }
146
178
  export function channelSyncHash(value) {
147
179
  return createHash("sha256").update(canonicalJson(value)).digest("hex");
148
180
  }
@@ -578,6 +610,13 @@ export class ChannelConnectorService {
578
610
  this.jobs = options.jobs ?? services.jobs;
579
611
  this.transact = transaction ?? ((fn) => this.db.transaction(fn));
580
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
+ }
581
620
  getConnector(providerId) {
582
621
  return this.connectors.get(providerId);
583
622
  }
@@ -2313,7 +2352,7 @@ export class ChannelConnectorService {
2313
2352
  const connector = this.connectors.get(store.provider);
2314
2353
  if (!connector)
2315
2354
  return PluginErr(`No connector registered for provider "${store.provider}".`);
2316
- const page = await connector.importCatalog(store, cursor ?? undefined);
2355
+ const page = await this.readCatalogPage(connector, store, cursor ?? undefined);
2317
2356
  if (!page.ok)
2318
2357
  return PluginErr(page.error.message);
2319
2358
  return Ok({ items: page.value.items, nextCursor: page.value.nextCursor ?? null });
@@ -2328,7 +2367,9 @@ export class ChannelConnectorService {
2328
2367
  * linked at entity level as `primary` plus to the variants it shows. The first photo of every
2329
2368
  * other variant comes back in `deferredMedia` for the host to land later.
2330
2369
  */
2331
- 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);
2332
2373
  const failures = [];
2333
2374
  const warnings = [];
2334
2375
  const entityByExternalId = new Map();
@@ -2555,7 +2596,7 @@ export class ChannelConnectorService {
2555
2596
  const items = [];
2556
2597
  let pageCursor = resume.pageCursor ?? undefined;
2557
2598
  do {
2558
- const page = await connector.importCatalog(store, pageCursor);
2599
+ const page = await this.readCatalogPage(connector, store, pageCursor);
2559
2600
  if (!page.ok)
2560
2601
  return PluginErr(page.error.message);
2561
2602
  items.push(...page.value.items);
@@ -2587,7 +2628,7 @@ export class ChannelConnectorService {
2587
2628
  const failures = [];
2588
2629
  const entityIds = [];
2589
2630
  while (remaining > 0) {
2590
- const page = await connector.importCatalog(store, pageCursor ?? undefined);
2631
+ const page = await this.readCatalogPage(connector, store, pageCursor ?? undefined);
2591
2632
  if (!page.ok)
2592
2633
  return PluginErr(page.error.message);
2593
2634
  const pageItems = page.value.items;
@@ -2750,7 +2791,7 @@ export class ChannelConnectorService {
2750
2791
  });
2751
2792
  }
2752
2793
  do {
2753
- const page = await connector.importCatalog(store, cursor);
2794
+ const page = await this.readCatalogPage(connector, store, cursor);
2754
2795
  if (!page.ok)
2755
2796
  return PluginErr(page.error.message);
2756
2797
  const converged = await this.convergeCatalogItems(orgId, storeId, page.value.items, actor, true, dryRun);
@@ -2979,7 +3020,8 @@ export class ChannelConnectorService {
2979
3020
  await claim("tag", listed((item) => item.tags ?? []), sql `join ${tags} t on t.slug = v.slug and t.organization_id = ${orgId}
2980
3021
  join ${entityTags} l on l.entity_id = v.entity_id and l.tag_id = t.id`);
2981
3022
  }
2982
- 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);
2983
3025
  if (dryRun)
2984
3026
  return this.estimateCatalogItems(orgId, storeId, items);
2985
3027
  // One converge, one taxonomy snapshot. Cleared HERE rather than left to the service's lifetime:
@@ -3331,7 +3373,7 @@ export class ChannelConnectorService {
3331
3373
  const items = [];
3332
3374
  let cursor;
3333
3375
  do {
3334
- const page = await connector.importCatalog(store, cursor);
3376
+ const page = await this.readCatalogPage(connector, store, cursor);
3335
3377
  if (!page.ok)
3336
3378
  return PluginErr(page.error.message);
3337
3379
  items.push(...page.value.items);
@@ -3552,6 +3594,8 @@ export class ChannelConnectorService {
3552
3594
  const connector = this.connectors.get(store.provider);
3553
3595
  if (!connector)
3554
3596
  return PluginErr(`No connector registered for provider "${store.provider}".`);
3597
+ if (connector.fetchInventoryPage)
3598
+ return this.syncInventoryPage(orgId, storeId, store, connector.fetchInventoryPage.bind(connector), actor);
3555
3599
  const inventory = await connector.fetchInventory(store);
3556
3600
  if (!inventory.ok)
3557
3601
  return PluginErr(inventory.error.message);
@@ -3613,6 +3657,49 @@ export class ChannelConnectorService {
3613
3657
  }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
3614
3658
  return Ok({ synced, exhausted });
3615
3659
  }
3660
+ /**
3661
+ * One step of a store's inventory sync for a connector that pages its inventory: ONE page fetch,
3662
+ * ONE mapping read for that page's variants, and ONE `inventory.setAbsoluteMany` (a constant
3663
+ * number of statements, one `inventory.afterAdjustMany` for the page). The next page's cursor is
3664
+ * stored on the store, so no step re-reads what an earlier one levelled — the old path re-read
3665
+ * the whole inventory and every map row on every 20-level step.
3666
+ */
3667
+ async syncInventoryPage(orgId, storeId, store, fetchPage, actor) {
3668
+ const { pageCursor } = parseInventoryResumePosition(store.inventoryCursor);
3669
+ const page = await fetchPage(store, pageCursor ?? null);
3670
+ if (!page.ok)
3671
+ return PluginErr(page.error.message);
3672
+ const externalIds = [...new Set(page.value.levels.map((level) => level.externalId))];
3673
+ const mappings = externalIds.length === 0 ? [] : await this.db.select({
3674
+ externalId: channelEntityMap.externalId, kind: channelEntityMap.kind, entityId: channelEntityMap.entityId, variantId: channelEntityMap.variantId,
3675
+ }).from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), inArray(channelEntityMap.externalId, externalIds)));
3676
+ // A variant's own mapping wins over a product's mapping of the same external id.
3677
+ const byExternalId = new Map();
3678
+ for (const mapping of mappings) {
3679
+ if (!byExternalId.has(mapping.externalId) || mapping.kind === "variant")
3680
+ byExternalId.set(mapping.externalId, mapping);
3681
+ }
3682
+ const rows = page.value.levels.flatMap((level) => {
3683
+ const mapping = byExternalId.get(level.externalId);
3684
+ return mapping === undefined ? [] : [{
3685
+ entityId: mapping.entityId,
3686
+ ...(mapping.variantId ? { variantId: mapping.variantId } : {}),
3687
+ quantity: Math.max(0, level.available),
3688
+ }];
3689
+ });
3690
+ const inventoryService = this.services.inventory;
3691
+ const written = await inventoryService.setAbsoluteMany(rows, actor, undefined, { reason: `Inventory sync from ${store.provider}` });
3692
+ if (!written.ok)
3693
+ return PluginErr(written.error.message);
3694
+ const synced = written.value.entities.reduce((total, entity) => total + entity.levels.length, 0);
3695
+ const exhausted = page.value.nextCursor === null;
3696
+ await this.db.update(connectedStores).set({
3697
+ inventoryCursor: exhausted ? null : JSON.stringify({ pageCursor: page.value.nextCursor }),
3698
+ ...(exhausted ? { lastSyncAt: new Date() } : {}),
3699
+ updatedAt: new Date(),
3700
+ }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
3701
+ return Ok({ synced, exhausted });
3702
+ }
3616
3703
  async handleWebhook(orgId, storeId, event) {
3617
3704
  const store = await this.getStoreRecord(orgId, storeId);
3618
3705
  if (!store)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.57.1",
3
+ "version": "0.59.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.57.1"
22
+ "@porulle/core": "0.59.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^24.5.2",
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
@@ -385,7 +385,7 @@ function encodeImportResumePosition(position: ImportResumePosition): string {
385
385
  return JSON.stringify(position);
386
386
  }
387
387
 
388
- type InventoryResumePosition = { offset: number };
388
+ type InventoryResumePosition = { offset: number; pageCursor?: string | null };
389
389
 
390
390
  /** Resume position for bounded `syncInventory` — stored on `connected_stores.inventory_cursor`.
391
391
  * Last-sync time lives on `connected_stores.lastSyncAt` (set when a run exhausts). */
@@ -393,6 +393,9 @@ function parseInventoryResumePosition(raw: string | null | undefined): Inventory
393
393
  if (!raw) return { offset: 0 };
394
394
  try {
395
395
  const parsed = JSON.parse(raw) as Partial<InventoryResumePosition>;
396
+ if (typeof parsed === "object" && parsed !== null && "pageCursor" in parsed) {
397
+ return { offset: 0, pageCursor: typeof parsed.pageCursor === "string" ? parsed.pageCursor : null };
398
+ }
396
399
  if (typeof parsed === "object" && parsed !== null && "offset" in parsed) {
397
400
  return {
398
401
  offset: typeof parsed.offset === "number" && parsed.offset >= 0 ? parsed.offset : 0,
@@ -657,6 +660,34 @@ function canonicalJson(value: unknown): string {
657
660
  }
658
661
 
659
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
+
660
691
  export function channelSyncHash(value: unknown): string {
661
692
  return createHash("sha256").update(canonicalJson(value)).digest("hex");
662
693
  }
@@ -1175,6 +1206,13 @@ export class ChannelConnectorService {
1175
1206
  this.transact = transaction ?? ((fn) => this.db.transaction(fn));
1176
1207
  }
1177
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
+
1178
1216
  getConnector(providerId: string): ChannelConnector | undefined {
1179
1217
  return this.connectors.get(providerId);
1180
1218
  }
@@ -3193,7 +3231,7 @@ export class ChannelConnectorService {
3193
3231
  if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
3194
3232
  const connector = this.connectors.get(store.provider);
3195
3233
  if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`);
3196
- const page = await connector.importCatalog(store as ChannelStore, cursor ?? undefined);
3234
+ const page = await this.readCatalogPage(connector, store as ChannelStore, cursor ?? undefined);
3197
3235
  if (!page.ok) return PluginErr(page.error.message);
3198
3236
  return Ok({ items: page.value.items, nextCursor: page.value.nextCursor ?? null });
3199
3237
  }
@@ -3211,9 +3249,11 @@ export class ChannelConnectorService {
3211
3249
  async convergeCatalogPage(
3212
3250
  orgId: string,
3213
3251
  storeId: string,
3214
- items: ChannelCatalogItem[],
3252
+ rawItems: ChannelCatalogItem[],
3215
3253
  actor: Actor,
3216
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);
3217
3257
  const failures: CatalogConvergenceFailure[] = [];
3218
3258
  const warnings: string[] = [];
3219
3259
  const entityByExternalId = new Map<string, string>();
@@ -3509,7 +3549,7 @@ export class ChannelConnectorService {
3509
3549
  const items: ChannelCatalogItem[] = [];
3510
3550
  let pageCursor: string | undefined = resume.pageCursor ?? undefined;
3511
3551
  do {
3512
- const page = await connector.importCatalog(store as ChannelStore, pageCursor);
3552
+ const page = await this.readCatalogPage(connector, store as ChannelStore, pageCursor);
3513
3553
  if (!page.ok) return PluginErr(page.error.message);
3514
3554
  items.push(...page.value.items);
3515
3555
  pageCursor = page.value.nextCursor ?? undefined;
@@ -3543,7 +3583,7 @@ export class ChannelConnectorService {
3543
3583
  const entityIds: string[] = [];
3544
3584
 
3545
3585
  while (remaining > 0) {
3546
- const page = await connector.importCatalog(store as ChannelStore, pageCursor ?? undefined);
3586
+ const page = await this.readCatalogPage(connector, store as ChannelStore, pageCursor ?? undefined);
3547
3587
  if (!page.ok) return PluginErr(page.error.message);
3548
3588
 
3549
3589
  const pageItems = page.value.items;
@@ -3729,7 +3769,7 @@ export class ChannelConnectorService {
3729
3769
  });
3730
3770
  }
3731
3771
  do {
3732
- const page = await connector.importCatalog(store as ChannelStore, cursor);
3772
+ const page = await this.readCatalogPage(connector, store as ChannelStore, cursor);
3733
3773
  if (!page.ok) return PluginErr(page.error.message);
3734
3774
  const converged = await this.convergeCatalogItems(orgId, storeId, page.value.items, actor, true, dryRun);
3735
3775
  if (!converged.ok) return converged;
@@ -3981,11 +4021,12 @@ export class ChannelConnectorService {
3981
4021
  private async convergeCatalogItems(
3982
4022
  orgId: string,
3983
4023
  storeId: string,
3984
- items: ChannelCatalogItem[],
4024
+ rawItems: ChannelCatalogItem[],
3985
4025
  actor: Actor,
3986
4026
  force = false,
3987
4027
  dryRun = false,
3988
4028
  ): Promise<PluginResult<CatalogConvergenceStats>> {
4029
+ const items = rawItems.map(withDistinctVariantSkus);
3989
4030
  if (dryRun) return this.estimateCatalogItems(orgId, storeId, items);
3990
4031
  // One converge, one taxonomy snapshot. Cleared HERE rather than left to the service's lifetime:
3991
4032
  // the instance can outlive a batch on a warm isolate, and a taxonomy cached across batches would
@@ -4352,7 +4393,7 @@ export class ChannelConnectorService {
4352
4393
  const items: ChannelCatalogItem[] = [];
4353
4394
  let cursor: string | undefined;
4354
4395
  do {
4355
- const page = await connector.importCatalog(store as ChannelStore, cursor);
4396
+ const page = await this.readCatalogPage(connector, store as ChannelStore, cursor);
4356
4397
  if (!page.ok) return PluginErr(page.error.message);
4357
4398
  items.push(...page.value.items);
4358
4399
  cursor = page.value.nextCursor ?? undefined;
@@ -4598,6 +4639,7 @@ export class ChannelConnectorService {
4598
4639
  if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
4599
4640
  const connector = this.connectors.get(store.provider);
4600
4641
  if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`);
4642
+ if (connector.fetchInventoryPage) return this.syncInventoryPage(orgId, storeId, store, connector.fetchInventoryPage.bind(connector), actor);
4601
4643
  const inventory = await connector.fetchInventory(store as ChannelStore);
4602
4644
  if (!inventory.ok) return PluginErr(inventory.error.message);
4603
4645
  const mappings = await this.db.select().from(channelEntityMap).where(and(
@@ -4666,6 +4708,65 @@ export class ChannelConnectorService {
4666
4708
  return Ok({ synced, exhausted });
4667
4709
  }
4668
4710
 
4711
+ /**
4712
+ * One step of a store's inventory sync for a connector that pages its inventory: ONE page fetch,
4713
+ * ONE mapping read for that page's variants, and ONE `inventory.setAbsoluteMany` (a constant
4714
+ * number of statements, one `inventory.afterAdjustMany` for the page). The next page's cursor is
4715
+ * stored on the store, so no step re-reads what an earlier one levelled — the old path re-read
4716
+ * the whole inventory and every map row on every 20-level step.
4717
+ */
4718
+ private async syncInventoryPage(
4719
+ orgId: string,
4720
+ storeId: string,
4721
+ store: typeof connectedStores.$inferSelect,
4722
+ fetchPage: NonNullable<ChannelConnector["fetchInventoryPage"]>,
4723
+ actor: Actor,
4724
+ ): Promise<PluginResult<{ synced: number; exhausted?: boolean }>> {
4725
+ const { pageCursor } = parseInventoryResumePosition(store.inventoryCursor);
4726
+ const page = await fetchPage(store as ChannelStore, pageCursor ?? null);
4727
+ if (!page.ok) return PluginErr(page.error.message);
4728
+ const externalIds = [...new Set(page.value.levels.map((level) => level.externalId))];
4729
+ const mappings = externalIds.length === 0 ? [] : await this.db.select({
4730
+ externalId: channelEntityMap.externalId, kind: channelEntityMap.kind, entityId: channelEntityMap.entityId, variantId: channelEntityMap.variantId,
4731
+ }).from(channelEntityMap).where(and(
4732
+ eq(channelEntityMap.organizationId, orgId),
4733
+ eq(channelEntityMap.storeId, storeId),
4734
+ inArray(channelEntityMap.externalId, externalIds),
4735
+ ));
4736
+ // A variant's own mapping wins over a product's mapping of the same external id.
4737
+ const byExternalId = new Map<string, (typeof mappings)[number]>();
4738
+ for (const mapping of mappings) {
4739
+ if (!byExternalId.has(mapping.externalId) || mapping.kind === "variant") byExternalId.set(mapping.externalId, mapping);
4740
+ }
4741
+ const rows = page.value.levels.flatMap((level) => {
4742
+ const mapping = byExternalId.get(level.externalId);
4743
+ return mapping === undefined ? [] : [{
4744
+ entityId: mapping.entityId,
4745
+ ...(mapping.variantId ? { variantId: mapping.variantId } : {}),
4746
+ quantity: Math.max(0, level.available),
4747
+ }];
4748
+ });
4749
+ const inventoryService = this.services.inventory as {
4750
+ setAbsoluteMany(
4751
+ rows: ReadonlyArray<{ entityId: string; variantId?: string; quantity: number }>,
4752
+ actor: Actor,
4753
+ ctx?: undefined,
4754
+ options?: { reason?: string },
4755
+ ): Promise<{ ok: true; value: { entities: Array<{ levels: unknown[] }> } } | { ok: false; error: { message: string } }>;
4756
+ };
4757
+ const written = await inventoryService.setAbsoluteMany(rows, actor, undefined, { reason: `Inventory sync from ${store.provider}` });
4758
+ if (!written.ok) return PluginErr(written.error.message);
4759
+ const synced = written.value.entities.reduce((total, entity) => total + entity.levels.length, 0);
4760
+
4761
+ const exhausted = page.value.nextCursor === null;
4762
+ await this.db.update(connectedStores).set({
4763
+ inventoryCursor: exhausted ? null : JSON.stringify({ pageCursor: page.value.nextCursor }),
4764
+ ...(exhausted ? { lastSyncAt: new Date() } : {}),
4765
+ updatedAt: new Date(),
4766
+ }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
4767
+ return Ok({ synced, exhausted });
4768
+ }
4769
+
4669
4770
  async handleWebhook(orgId: string, storeId: string, event: { id: string; type: string; data: unknown }): Promise<PluginResult<{ processed: true; data?: ChannelComplianceData; redacted?: number }>> {
4670
4771
  const store = await this.getStoreRecord(orgId, storeId);
4671
4772
  if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");