@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 +1 -1
- package/dist/index.js +1 -1
- package/dist/service.d.ts +23 -1
- package/dist/service.js +94 -7
- package/package.json +2 -2
- package/src/index.ts +1 -1
- package/src/service.ts +109 -8
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,
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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");
|