@porulle/plugin-channel-connector 0.57.0 → 0.58.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/service.d.ts CHANGED
@@ -501,6 +501,14 @@ export declare class ChannelConnectorService {
501
501
  synced: number;
502
502
  exhausted?: boolean;
503
503
  }>>;
504
+ /**
505
+ * One step of a store's inventory sync for a connector that pages its inventory: ONE page fetch,
506
+ * ONE mapping read for that page's variants, and ONE `inventory.setAbsoluteMany` (a constant
507
+ * number of statements, one `inventory.afterAdjustMany` for the page). The next page's cursor is
508
+ * stored on the store, so no step re-reads what an earlier one levelled — the old path re-read
509
+ * the whole inventory and every map row on every 20-level step.
510
+ */
511
+ private syncInventoryPage;
504
512
  handleWebhook(orgId: string, storeId: string, event: {
505
513
  id: string;
506
514
  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,
@@ -1467,7 +1470,13 @@ export class ChannelConnectorService {
1467
1470
  }
1468
1471
  async applyMedia(orgId, entityId, item, variantIds, actor, warnings, owners) {
1469
1472
  // Uploads what is missing and PLANS the entity's media links; `commitEntityLinks` writes them.
1470
- const images = item.images ?? [];
1473
+ //
1474
+ // Only the images the import ruling allows — the SAME `selectImportImages` the page fast path
1475
+ // uses: the hero plus the first photo of each other variant. This path attached EVERY image
1476
+ // the item listed, so the first converge of a product whose price changed pulled in the whole
1477
+ // gallery the fast path had deliberately left out: an upload, an embed and a bump per photo.
1478
+ const selection = selectImportImages(item);
1479
+ const images = selection.hero === null ? [] : [selection.hero, ...selection.perVariant];
1471
1480
  const externalIds = [...new Set(images.map((image) => image.externalId).filter((id) => id != null))];
1472
1481
  const urlHashes = [...new Set(images.map((image) => hash(image.url)))];
1473
1482
  const keyPredicates = [];
@@ -3546,6 +3555,8 @@ export class ChannelConnectorService {
3546
3555
  const connector = this.connectors.get(store.provider);
3547
3556
  if (!connector)
3548
3557
  return PluginErr(`No connector registered for provider "${store.provider}".`);
3558
+ if (connector.fetchInventoryPage)
3559
+ return this.syncInventoryPage(orgId, storeId, store, connector.fetchInventoryPage.bind(connector), actor);
3549
3560
  const inventory = await connector.fetchInventory(store);
3550
3561
  if (!inventory.ok)
3551
3562
  return PluginErr(inventory.error.message);
@@ -3607,6 +3618,49 @@ export class ChannelConnectorService {
3607
3618
  }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
3608
3619
  return Ok({ synced, exhausted });
3609
3620
  }
3621
+ /**
3622
+ * One step of a store's inventory sync for a connector that pages its inventory: ONE page fetch,
3623
+ * ONE mapping read for that page's variants, and ONE `inventory.setAbsoluteMany` (a constant
3624
+ * number of statements, one `inventory.afterAdjustMany` for the page). The next page's cursor is
3625
+ * stored on the store, so no step re-reads what an earlier one levelled — the old path re-read
3626
+ * the whole inventory and every map row on every 20-level step.
3627
+ */
3628
+ async syncInventoryPage(orgId, storeId, store, fetchPage, actor) {
3629
+ const { pageCursor } = parseInventoryResumePosition(store.inventoryCursor);
3630
+ const page = await fetchPage(store, pageCursor ?? null);
3631
+ if (!page.ok)
3632
+ return PluginErr(page.error.message);
3633
+ const externalIds = [...new Set(page.value.levels.map((level) => level.externalId))];
3634
+ const mappings = externalIds.length === 0 ? [] : await this.db.select({
3635
+ externalId: channelEntityMap.externalId, kind: channelEntityMap.kind, entityId: channelEntityMap.entityId, variantId: channelEntityMap.variantId,
3636
+ }).from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), inArray(channelEntityMap.externalId, externalIds)));
3637
+ // A variant's own mapping wins over a product's mapping of the same external id.
3638
+ const byExternalId = new Map();
3639
+ for (const mapping of mappings) {
3640
+ if (!byExternalId.has(mapping.externalId) || mapping.kind === "variant")
3641
+ byExternalId.set(mapping.externalId, mapping);
3642
+ }
3643
+ const rows = page.value.levels.flatMap((level) => {
3644
+ const mapping = byExternalId.get(level.externalId);
3645
+ return mapping === undefined ? [] : [{
3646
+ entityId: mapping.entityId,
3647
+ ...(mapping.variantId ? { variantId: mapping.variantId } : {}),
3648
+ quantity: Math.max(0, level.available),
3649
+ }];
3650
+ });
3651
+ const inventoryService = this.services.inventory;
3652
+ const written = await inventoryService.setAbsoluteMany(rows, actor, undefined, { reason: `Inventory sync from ${store.provider}` });
3653
+ if (!written.ok)
3654
+ return PluginErr(written.error.message);
3655
+ const synced = written.value.entities.reduce((total, entity) => total + entity.levels.length, 0);
3656
+ const exhausted = page.value.nextCursor === null;
3657
+ await this.db.update(connectedStores).set({
3658
+ inventoryCursor: exhausted ? null : JSON.stringify({ pageCursor: page.value.nextCursor }),
3659
+ ...(exhausted ? { lastSyncAt: new Date() } : {}),
3660
+ updatedAt: new Date(),
3661
+ }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
3662
+ return Ok({ synced, exhausted });
3663
+ }
3610
3664
  async handleWebhook(orgId, storeId, event) {
3611
3665
  const store = await this.getStoreRecord(orgId, storeId);
3612
3666
  if (!store)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.57.0",
3
+ "version": "0.58.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.0"
22
+ "@porulle/core": "0.58.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^24.5.2",
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,
@@ -2228,7 +2231,13 @@ export class ChannelConnectorService {
2228
2231
  previousRoles: Map<string, string>;
2229
2232
  }>> {
2230
2233
  // Uploads what is missing and PLANS the entity's media links; `commitEntityLinks` writes them.
2231
- const images = item.images ?? [];
2234
+ //
2235
+ // Only the images the import ruling allows — the SAME `selectImportImages` the page fast path
2236
+ // uses: the hero plus the first photo of each other variant. This path attached EVERY image
2237
+ // the item listed, so the first converge of a product whose price changed pulled in the whole
2238
+ // gallery the fast path had deliberately left out: an upload, an embed and a bump per photo.
2239
+ const selection = selectImportImages(item);
2240
+ const images = selection.hero === null ? [] : [selection.hero, ...selection.perVariant];
2232
2241
  const externalIds = [...new Set(images.map((image) => image.externalId).filter((id): id is string => id != null))];
2233
2242
  const urlHashes = [...new Set(images.map((image) => hash(image.url)))];
2234
2243
  const keyPredicates = [];
@@ -4592,6 +4601,7 @@ export class ChannelConnectorService {
4592
4601
  if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
4593
4602
  const connector = this.connectors.get(store.provider);
4594
4603
  if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`);
4604
+ if (connector.fetchInventoryPage) return this.syncInventoryPage(orgId, storeId, store, connector.fetchInventoryPage.bind(connector), actor);
4595
4605
  const inventory = await connector.fetchInventory(store as ChannelStore);
4596
4606
  if (!inventory.ok) return PluginErr(inventory.error.message);
4597
4607
  const mappings = await this.db.select().from(channelEntityMap).where(and(
@@ -4660,6 +4670,65 @@ export class ChannelConnectorService {
4660
4670
  return Ok({ synced, exhausted });
4661
4671
  }
4662
4672
 
4673
+ /**
4674
+ * One step of a store's inventory sync for a connector that pages its inventory: ONE page fetch,
4675
+ * ONE mapping read for that page's variants, and ONE `inventory.setAbsoluteMany` (a constant
4676
+ * number of statements, one `inventory.afterAdjustMany` for the page). The next page's cursor is
4677
+ * stored on the store, so no step re-reads what an earlier one levelled — the old path re-read
4678
+ * the whole inventory and every map row on every 20-level step.
4679
+ */
4680
+ private async syncInventoryPage(
4681
+ orgId: string,
4682
+ storeId: string,
4683
+ store: typeof connectedStores.$inferSelect,
4684
+ fetchPage: NonNullable<ChannelConnector["fetchInventoryPage"]>,
4685
+ actor: Actor,
4686
+ ): Promise<PluginResult<{ synced: number; exhausted?: boolean }>> {
4687
+ const { pageCursor } = parseInventoryResumePosition(store.inventoryCursor);
4688
+ const page = await fetchPage(store as ChannelStore, pageCursor ?? null);
4689
+ if (!page.ok) return PluginErr(page.error.message);
4690
+ const externalIds = [...new Set(page.value.levels.map((level) => level.externalId))];
4691
+ const mappings = externalIds.length === 0 ? [] : await this.db.select({
4692
+ externalId: channelEntityMap.externalId, kind: channelEntityMap.kind, entityId: channelEntityMap.entityId, variantId: channelEntityMap.variantId,
4693
+ }).from(channelEntityMap).where(and(
4694
+ eq(channelEntityMap.organizationId, orgId),
4695
+ eq(channelEntityMap.storeId, storeId),
4696
+ inArray(channelEntityMap.externalId, externalIds),
4697
+ ));
4698
+ // A variant's own mapping wins over a product's mapping of the same external id.
4699
+ const byExternalId = new Map<string, (typeof mappings)[number]>();
4700
+ for (const mapping of mappings) {
4701
+ if (!byExternalId.has(mapping.externalId) || mapping.kind === "variant") byExternalId.set(mapping.externalId, mapping);
4702
+ }
4703
+ const rows = page.value.levels.flatMap((level) => {
4704
+ const mapping = byExternalId.get(level.externalId);
4705
+ return mapping === undefined ? [] : [{
4706
+ entityId: mapping.entityId,
4707
+ ...(mapping.variantId ? { variantId: mapping.variantId } : {}),
4708
+ quantity: Math.max(0, level.available),
4709
+ }];
4710
+ });
4711
+ const inventoryService = this.services.inventory as {
4712
+ setAbsoluteMany(
4713
+ rows: ReadonlyArray<{ entityId: string; variantId?: string; quantity: number }>,
4714
+ actor: Actor,
4715
+ ctx?: undefined,
4716
+ options?: { reason?: string },
4717
+ ): Promise<{ ok: true; value: { entities: Array<{ levels: unknown[] }> } } | { ok: false; error: { message: string } }>;
4718
+ };
4719
+ const written = await inventoryService.setAbsoluteMany(rows, actor, undefined, { reason: `Inventory sync from ${store.provider}` });
4720
+ if (!written.ok) return PluginErr(written.error.message);
4721
+ const synced = written.value.entities.reduce((total, entity) => total + entity.levels.length, 0);
4722
+
4723
+ const exhausted = page.value.nextCursor === null;
4724
+ await this.db.update(connectedStores).set({
4725
+ inventoryCursor: exhausted ? null : JSON.stringify({ pageCursor: page.value.nextCursor }),
4726
+ ...(exhausted ? { lastSyncAt: new Date() } : {}),
4727
+ updatedAt: new Date(),
4728
+ }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
4729
+ return Ok({ synced, exhausted });
4730
+ }
4731
+
4663
4732
  async handleWebhook(orgId: string, storeId: string, event: { id: string; type: string; data: unknown }): Promise<PluginResult<{ processed: true; data?: ChannelComplianceData; redacted?: number }>> {
4664
4733
  const store = await this.getStoreRecord(orgId, storeId);
4665
4734
  if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");