@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 +8 -0
- package/dist/service.js +48 -0
- package/package.json +4 -4
- package/src/service.ts +64 -1
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.
|
|
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.
|
|
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/
|
|
30
|
-
"@porulle/
|
|
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");
|