@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 +8 -0
- package/dist/service.js +55 -1
- package/package.json +2 -2
- package/src/service.ts +71 -2
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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");
|