@porulle/plugin-channel-connector 0.58.0 → 0.60.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 +15 -1
- package/dist/service.js +46 -7
- package/package.json +4 -4
- package/src/index.ts +1 -1
- package/src/service.ts +45 -7
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;
|
package/dist/service.js
CHANGED
|
@@ -146,6 +146,35 @@ function canonicalJson(value) {
|
|
|
146
146
|
return JSON.stringify(value) ?? "null";
|
|
147
147
|
}
|
|
148
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
|
+
}
|
|
149
178
|
export function channelSyncHash(value) {
|
|
150
179
|
return createHash("sha256").update(canonicalJson(value)).digest("hex");
|
|
151
180
|
}
|
|
@@ -581,6 +610,13 @@ export class ChannelConnectorService {
|
|
|
581
610
|
this.jobs = options.jobs ?? services.jobs;
|
|
582
611
|
this.transact = transaction ?? ((fn) => this.db.transaction(fn));
|
|
583
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
|
+
}
|
|
584
620
|
getConnector(providerId) {
|
|
585
621
|
return this.connectors.get(providerId);
|
|
586
622
|
}
|
|
@@ -2316,7 +2352,7 @@ export class ChannelConnectorService {
|
|
|
2316
2352
|
const connector = this.connectors.get(store.provider);
|
|
2317
2353
|
if (!connector)
|
|
2318
2354
|
return PluginErr(`No connector registered for provider "${store.provider}".`);
|
|
2319
|
-
const page = await
|
|
2355
|
+
const page = await this.readCatalogPage(connector, store, cursor ?? undefined);
|
|
2320
2356
|
if (!page.ok)
|
|
2321
2357
|
return PluginErr(page.error.message);
|
|
2322
2358
|
return Ok({ items: page.value.items, nextCursor: page.value.nextCursor ?? null });
|
|
@@ -2331,7 +2367,9 @@ export class ChannelConnectorService {
|
|
|
2331
2367
|
* linked at entity level as `primary` plus to the variants it shows. The first photo of every
|
|
2332
2368
|
* other variant comes back in `deferredMedia` for the host to land later.
|
|
2333
2369
|
*/
|
|
2334
|
-
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);
|
|
2335
2373
|
const failures = [];
|
|
2336
2374
|
const warnings = [];
|
|
2337
2375
|
const entityByExternalId = new Map();
|
|
@@ -2558,7 +2596,7 @@ export class ChannelConnectorService {
|
|
|
2558
2596
|
const items = [];
|
|
2559
2597
|
let pageCursor = resume.pageCursor ?? undefined;
|
|
2560
2598
|
do {
|
|
2561
|
-
const page = await
|
|
2599
|
+
const page = await this.readCatalogPage(connector, store, pageCursor);
|
|
2562
2600
|
if (!page.ok)
|
|
2563
2601
|
return PluginErr(page.error.message);
|
|
2564
2602
|
items.push(...page.value.items);
|
|
@@ -2590,7 +2628,7 @@ export class ChannelConnectorService {
|
|
|
2590
2628
|
const failures = [];
|
|
2591
2629
|
const entityIds = [];
|
|
2592
2630
|
while (remaining > 0) {
|
|
2593
|
-
const page = await
|
|
2631
|
+
const page = await this.readCatalogPage(connector, store, pageCursor ?? undefined);
|
|
2594
2632
|
if (!page.ok)
|
|
2595
2633
|
return PluginErr(page.error.message);
|
|
2596
2634
|
const pageItems = page.value.items;
|
|
@@ -2753,7 +2791,7 @@ export class ChannelConnectorService {
|
|
|
2753
2791
|
});
|
|
2754
2792
|
}
|
|
2755
2793
|
do {
|
|
2756
|
-
const page = await
|
|
2794
|
+
const page = await this.readCatalogPage(connector, store, cursor);
|
|
2757
2795
|
if (!page.ok)
|
|
2758
2796
|
return PluginErr(page.error.message);
|
|
2759
2797
|
const converged = await this.convergeCatalogItems(orgId, storeId, page.value.items, actor, true, dryRun);
|
|
@@ -2982,7 +3020,8 @@ export class ChannelConnectorService {
|
|
|
2982
3020
|
await claim("tag", listed((item) => item.tags ?? []), sql `join ${tags} t on t.slug = v.slug and t.organization_id = ${orgId}
|
|
2983
3021
|
join ${entityTags} l on l.entity_id = v.entity_id and l.tag_id = t.id`);
|
|
2984
3022
|
}
|
|
2985
|
-
async convergeCatalogItems(orgId, storeId,
|
|
3023
|
+
async convergeCatalogItems(orgId, storeId, rawItems, actor, force = false, dryRun = false) {
|
|
3024
|
+
const items = rawItems.map(withDistinctVariantSkus);
|
|
2986
3025
|
if (dryRun)
|
|
2987
3026
|
return this.estimateCatalogItems(orgId, storeId, items);
|
|
2988
3027
|
// One converge, one taxonomy snapshot. Cleared HERE rather than left to the service's lifetime:
|
|
@@ -3334,7 +3373,7 @@ export class ChannelConnectorService {
|
|
|
3334
3373
|
const items = [];
|
|
3335
3374
|
let cursor;
|
|
3336
3375
|
do {
|
|
3337
|
-
const page = await
|
|
3376
|
+
const page = await this.readCatalogPage(connector, store, cursor);
|
|
3338
3377
|
if (!page.ok)
|
|
3339
3378
|
return PluginErr(page.error.message);
|
|
3340
3379
|
items.push(...page.value.items);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/plugin-channel-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.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.60.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/typescript-config": "0.1.0",
|
|
30
|
+
"@porulle/eslint-config": "0.1.0"
|
|
31
31
|
},
|
|
32
32
|
"publishConfig": {
|
|
33
33
|
"access": "public"
|
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
|
@@ -660,6 +660,34 @@ function canonicalJson(value: unknown): string {
|
|
|
660
660
|
}
|
|
661
661
|
|
|
662
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
|
+
|
|
663
691
|
export function channelSyncHash(value: unknown): string {
|
|
664
692
|
return createHash("sha256").update(canonicalJson(value)).digest("hex");
|
|
665
693
|
}
|
|
@@ -1178,6 +1206,13 @@ export class ChannelConnectorService {
|
|
|
1178
1206
|
this.transact = transaction ?? ((fn) => this.db.transaction(fn));
|
|
1179
1207
|
}
|
|
1180
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
|
+
|
|
1181
1216
|
getConnector(providerId: string): ChannelConnector | undefined {
|
|
1182
1217
|
return this.connectors.get(providerId);
|
|
1183
1218
|
}
|
|
@@ -3196,7 +3231,7 @@ export class ChannelConnectorService {
|
|
|
3196
3231
|
if (!store || store.status !== "connected") return PluginErr("Connected store not found.", "NOT_FOUND");
|
|
3197
3232
|
const connector = this.connectors.get(store.provider);
|
|
3198
3233
|
if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`);
|
|
3199
|
-
const page = await
|
|
3234
|
+
const page = await this.readCatalogPage(connector, store as ChannelStore, cursor ?? undefined);
|
|
3200
3235
|
if (!page.ok) return PluginErr(page.error.message);
|
|
3201
3236
|
return Ok({ items: page.value.items, nextCursor: page.value.nextCursor ?? null });
|
|
3202
3237
|
}
|
|
@@ -3214,9 +3249,11 @@ export class ChannelConnectorService {
|
|
|
3214
3249
|
async convergeCatalogPage(
|
|
3215
3250
|
orgId: string,
|
|
3216
3251
|
storeId: string,
|
|
3217
|
-
|
|
3252
|
+
rawItems: ChannelCatalogItem[],
|
|
3218
3253
|
actor: Actor,
|
|
3219
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);
|
|
3220
3257
|
const failures: CatalogConvergenceFailure[] = [];
|
|
3221
3258
|
const warnings: string[] = [];
|
|
3222
3259
|
const entityByExternalId = new Map<string, string>();
|
|
@@ -3512,7 +3549,7 @@ export class ChannelConnectorService {
|
|
|
3512
3549
|
const items: ChannelCatalogItem[] = [];
|
|
3513
3550
|
let pageCursor: string | undefined = resume.pageCursor ?? undefined;
|
|
3514
3551
|
do {
|
|
3515
|
-
const page = await
|
|
3552
|
+
const page = await this.readCatalogPage(connector, store as ChannelStore, pageCursor);
|
|
3516
3553
|
if (!page.ok) return PluginErr(page.error.message);
|
|
3517
3554
|
items.push(...page.value.items);
|
|
3518
3555
|
pageCursor = page.value.nextCursor ?? undefined;
|
|
@@ -3546,7 +3583,7 @@ export class ChannelConnectorService {
|
|
|
3546
3583
|
const entityIds: string[] = [];
|
|
3547
3584
|
|
|
3548
3585
|
while (remaining > 0) {
|
|
3549
|
-
const page = await
|
|
3586
|
+
const page = await this.readCatalogPage(connector, store as ChannelStore, pageCursor ?? undefined);
|
|
3550
3587
|
if (!page.ok) return PluginErr(page.error.message);
|
|
3551
3588
|
|
|
3552
3589
|
const pageItems = page.value.items;
|
|
@@ -3732,7 +3769,7 @@ export class ChannelConnectorService {
|
|
|
3732
3769
|
});
|
|
3733
3770
|
}
|
|
3734
3771
|
do {
|
|
3735
|
-
const page = await
|
|
3772
|
+
const page = await this.readCatalogPage(connector, store as ChannelStore, cursor);
|
|
3736
3773
|
if (!page.ok) return PluginErr(page.error.message);
|
|
3737
3774
|
const converged = await this.convergeCatalogItems(orgId, storeId, page.value.items, actor, true, dryRun);
|
|
3738
3775
|
if (!converged.ok) return converged;
|
|
@@ -3984,11 +4021,12 @@ export class ChannelConnectorService {
|
|
|
3984
4021
|
private async convergeCatalogItems(
|
|
3985
4022
|
orgId: string,
|
|
3986
4023
|
storeId: string,
|
|
3987
|
-
|
|
4024
|
+
rawItems: ChannelCatalogItem[],
|
|
3988
4025
|
actor: Actor,
|
|
3989
4026
|
force = false,
|
|
3990
4027
|
dryRun = false,
|
|
3991
4028
|
): Promise<PluginResult<CatalogConvergenceStats>> {
|
|
4029
|
+
const items = rawItems.map(withDistinctVariantSkus);
|
|
3992
4030
|
if (dryRun) return this.estimateCatalogItems(orgId, storeId, items);
|
|
3993
4031
|
// One converge, one taxonomy snapshot. Cleared HERE rather than left to the service's lifetime:
|
|
3994
4032
|
// the instance can outlive a batch on a warm isolate, and a taxonomy cached across batches would
|
|
@@ -4355,7 +4393,7 @@ export class ChannelConnectorService {
|
|
|
4355
4393
|
const items: ChannelCatalogItem[] = [];
|
|
4356
4394
|
let cursor: string | undefined;
|
|
4357
4395
|
do {
|
|
4358
|
-
const page = await
|
|
4396
|
+
const page = await this.readCatalogPage(connector, store as ChannelStore, cursor);
|
|
4359
4397
|
if (!page.ok) return PluginErr(page.error.message);
|
|
4360
4398
|
items.push(...page.value.items);
|
|
4361
4399
|
cursor = page.value.nextCursor ?? undefined;
|