@porulle/plugin-channel-connector 0.57.1 → 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,
@@ -3552,6 +3555,8 @@ export class ChannelConnectorService {
3552
3555
  const connector = this.connectors.get(store.provider);
3553
3556
  if (!connector)
3554
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);
3555
3560
  const inventory = await connector.fetchInventory(store);
3556
3561
  if (!inventory.ok)
3557
3562
  return PluginErr(inventory.error.message);
@@ -3613,6 +3618,49 @@ export class ChannelConnectorService {
3613
3618
  }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
3614
3619
  return Ok({ synced, exhausted });
3615
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
+ }
3616
3664
  async handleWebhook(orgId, storeId, event) {
3617
3665
  const store = await this.getStoreRecord(orgId, storeId);
3618
3666
  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.58.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.57.1"
22
+ "@porulle/core": "0.58.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/typescript-config": "0.1.0",
30
- "@porulle/eslint-config": "0.1.0"
29
+ "@porulle/eslint-config": "0.1.0",
30
+ "@porulle/typescript-config": "0.1.0"
31
31
  },
32
32
  "publishConfig": {
33
33
  "access": "public"
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,
@@ -4598,6 +4601,7 @@ export class ChannelConnectorService {
4598
4601
  if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
4599
4602
  const connector = this.connectors.get(store.provider);
4600
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);
4601
4605
  const inventory = await connector.fetchInventory(store as ChannelStore);
4602
4606
  if (!inventory.ok) return PluginErr(inventory.error.message);
4603
4607
  const mappings = await this.db.select().from(channelEntityMap).where(and(
@@ -4666,6 +4670,65 @@ export class ChannelConnectorService {
4666
4670
  return Ok({ synced, exhausted });
4667
4671
  }
4668
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
+
4669
4732
  async handleWebhook(orgId: string, storeId: string, event: { id: string; type: string; data: unknown }): Promise<PluginResult<{ processed: true; data?: ChannelComplianceData; redacted?: number }>> {
4670
4733
  const store = await this.getStoreRecord(orgId, storeId);
4671
4734
  if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");