@porulle/plugin-channel-connector 0.53.1 → 0.55.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/deletion-policy.d.ts +19 -0
- package/dist/deletion-policy.js +28 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/service.d.ts +13 -0
- package/dist/service.js +71 -51
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/deletion-policy.ts +30 -0
- package/src/index.ts +2 -0
- package/src/service.ts +87 -59
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE deletion policy for a store's mapped products that a fetch no longer lists — one home for
|
|
3
|
+
* the rule, shared by `reconcile()` and the app's import finalize barrier.
|
|
4
|
+
*
|
|
5
|
+
* A fetch that succeeded is not a fetch that was complete: a merchant API hiccup, pagination that
|
|
6
|
+
* stopped early, or an auth scope change returns fewer products than the store has, and archiving
|
|
7
|
+
* "everything absent" then wipes the store. Found on the sim, 2026-09-24. So the plan REFUSES:
|
|
8
|
+
* - an empty fetch over a store that has mapped products;
|
|
9
|
+
* - more absent products than max(ABSENT_ARCHIVE_FLOOR, floor(ABSENT_ARCHIVE_FRACTION × mapped)).
|
|
10
|
+
* A refusal archives nothing; the caller surfaces the reason for a person to look at.
|
|
11
|
+
*/
|
|
12
|
+
export declare const ABSENT_ARCHIVE_FLOOR = 5;
|
|
13
|
+
export declare const ABSENT_ARCHIVE_FRACTION = 0.2;
|
|
14
|
+
export type AbsentArchivePlan = {
|
|
15
|
+
archive: string[];
|
|
16
|
+
} | {
|
|
17
|
+
refused: string;
|
|
18
|
+
};
|
|
19
|
+
export declare function planAbsentArchives(mappedExternalIds: Iterable<string>, presentExternalIds: Iterable<string>): AbsentArchivePlan;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE deletion policy for a store's mapped products that a fetch no longer lists — one home for
|
|
3
|
+
* the rule, shared by `reconcile()` and the app's import finalize barrier.
|
|
4
|
+
*
|
|
5
|
+
* A fetch that succeeded is not a fetch that was complete: a merchant API hiccup, pagination that
|
|
6
|
+
* stopped early, or an auth scope change returns fewer products than the store has, and archiving
|
|
7
|
+
* "everything absent" then wipes the store. Found on the sim, 2026-09-24. So the plan REFUSES:
|
|
8
|
+
* - an empty fetch over a store that has mapped products;
|
|
9
|
+
* - more absent products than max(ABSENT_ARCHIVE_FLOOR, floor(ABSENT_ARCHIVE_FRACTION × mapped)).
|
|
10
|
+
* A refusal archives nothing; the caller surfaces the reason for a person to look at.
|
|
11
|
+
*/
|
|
12
|
+
export const ABSENT_ARCHIVE_FLOOR = 5;
|
|
13
|
+
export const ABSENT_ARCHIVE_FRACTION = 0.2;
|
|
14
|
+
export function planAbsentArchives(mappedExternalIds, presentExternalIds) {
|
|
15
|
+
const mapped = [...new Set(mappedExternalIds)];
|
|
16
|
+
const present = new Set(presentExternalIds);
|
|
17
|
+
const absent = mapped.filter((externalId) => !present.has(externalId));
|
|
18
|
+
if (absent.length === 0)
|
|
19
|
+
return { archive: [] };
|
|
20
|
+
if (present.size === 0) {
|
|
21
|
+
return { refused: `The fetch listed no products while ${mapped.length} are mapped; refusing to archive them.` };
|
|
22
|
+
}
|
|
23
|
+
const bound = Math.max(ABSENT_ARCHIVE_FLOOR, Math.floor(ABSENT_ARCHIVE_FRACTION * mapped.length));
|
|
24
|
+
if (absent.length > bound) {
|
|
25
|
+
return { refused: `${absent.length} of ${mapped.length} mapped products are absent from the fetch, above the bound of ${bound}; refusing to archive them.` };
|
|
26
|
+
}
|
|
27
|
+
return { archive: absent };
|
|
28
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type ChannelConnectorPluginOptions } from "./service.js";
|
|
2
|
+
export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
|
|
3
|
+
export type { AbsentArchivePlan } from "./deletion-policy.js";
|
|
2
4
|
export { mockChannelConnector } from "./mock-connector.js";
|
|
3
5
|
export type { MockChannelConnectorOptions } from "./mock-connector.js";
|
|
4
6
|
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";
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts
|
|
|
7
7
|
import { ChannelConnectorService, catalogPushConcurrencyKey, CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION, } from "./service.js";
|
|
8
8
|
import { buildHooks } from "./hooks.js";
|
|
9
9
|
import { oauthStateEventId, signState, verifyState } from "./oauth-state.js";
|
|
10
|
+
export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
|
|
10
11
|
export { mockChannelConnector } from "./mock-connector.js";
|
|
11
12
|
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";
|
|
12
13
|
export { isValidCatalogMappingFieldPath, matchFieldPath, mergeCatalogFieldMapping, normalizeCatalogFieldMapping, compareCatalogFieldMappingSpecificity, providerCatalogFieldMappingDefaults, selectCatalogFieldMapping, validateCatalogMappingRow, } from "./catalog-field-mapping.js";
|
package/dist/service.d.ts
CHANGED
|
@@ -39,6 +39,8 @@ export interface ReconcileReport extends Record<string, unknown> {
|
|
|
39
39
|
inventoryUpdated: number;
|
|
40
40
|
openConflicts: number;
|
|
41
41
|
driftAlert: boolean;
|
|
42
|
+
/** Why this reconcile archived nothing although mapped products were absent (`planAbsentArchives`). */
|
|
43
|
+
refused?: string;
|
|
42
44
|
skipped?: CatalogFieldSkip[];
|
|
43
45
|
conflicts?: CatalogFieldConflict[];
|
|
44
46
|
warnings?: string[];
|
|
@@ -357,6 +359,17 @@ export declare class ChannelConnectorService {
|
|
|
357
359
|
*/
|
|
358
360
|
private createTaxonomyOrAdopt;
|
|
359
361
|
private applyTaxonomy;
|
|
362
|
+
/**
|
|
363
|
+
* Writes one item's planned links and versions the entity for them, in ONE transaction: the
|
|
364
|
+
* entity's `updated_at` moves with the links or not at all, and `catalog.afterUpdate` fires once
|
|
365
|
+
* for the item with every link path that really changed (`["categories","tags"]`), not once per
|
|
366
|
+
* link. Returns those paths; empty means nothing changed.
|
|
367
|
+
*
|
|
368
|
+
* An entity CREATED by this converge is not versioned for its links: its creation already put it
|
|
369
|
+
* in front of every consumer, so a bump here would re-project a product in the same breath as its
|
|
370
|
+
* first projection — the cold-import cost this rule exists to avoid.
|
|
371
|
+
*/
|
|
372
|
+
private commitEntityLinks;
|
|
360
373
|
private applyMedia;
|
|
361
374
|
private getStoreRecord;
|
|
362
375
|
/**
|
package/dist/service.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, } from "@porulle/core";
|
|
2
|
+
import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, linkFieldPaths, writeEntityLinks, } from "@porulle/core";
|
|
3
3
|
import { isValidFieldPath, requireUserId } from "@porulle/core";
|
|
4
4
|
import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
|
|
5
5
|
import { and, desc, eq, inArray, isNull, lte, or, sql } from "@porulle/core/drizzle";
|
|
6
|
-
import { brands, categories, customerAddresses, customers, entityMedia,
|
|
6
|
+
import { brands, categories, customerAddresses, customers, entityMedia, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
|
|
7
|
+
import { planAbsentArchives } from "./deletion-policy.js";
|
|
7
8
|
import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
|
|
8
9
|
import { mergeCatalogFieldMapping, normalizeCatalogFieldMapping, selectCatalogFieldMapping, } from "./catalog-field-mapping.js";
|
|
9
10
|
export const CATALOG_PUSH_BATCH_SIZES = {
|
|
@@ -1333,14 +1334,11 @@ export class ChannelConnectorService {
|
|
|
1333
1334
|
return existing !== undefined ? Ok(existing) : PluginErr(`${label}: ${errorMessage(failure)}`);
|
|
1334
1335
|
}
|
|
1335
1336
|
async applyTaxonomy(orgId, entityId, item, actor, warnings) {
|
|
1337
|
+
// Resolves (creating where missing) the category, brand and tag rows the item names, and PLANS
|
|
1338
|
+
// the entity's links to them. The links are written by `commitEntityLinks`, in one transaction
|
|
1339
|
+
// with the entity's version bump; a link that already exists writes nothing there.
|
|
1336
1340
|
const taxonomy = await this.taxonomyFor(orgId);
|
|
1337
|
-
|
|
1338
|
-
// there is not re-written, and a link that is added is what reports the taxonomy as changed.
|
|
1339
|
-
const linkedCategories = new Set((item.categories ?? []).length === 0 ? [] : (await this.db
|
|
1340
|
-
.select({ id: entityCategories.categoryId }).from(entityCategories).where(eq(entityCategories.entityId, entityId))).map((row) => row.id));
|
|
1341
|
-
const linkedBrands = new Set(!item.brand ? [] : (await this.db
|
|
1342
|
-
.select({ id: entityBrands.brandId }).from(entityBrands).where(eq(entityBrands.entityId, entityId))).map((row) => row.id));
|
|
1343
|
-
let changed = false;
|
|
1341
|
+
const links = { categories: [], brands: [], tags: [] };
|
|
1344
1342
|
const categoryRows = taxonomy.categories;
|
|
1345
1343
|
for (const slug of new Set(item.categories ?? [])) {
|
|
1346
1344
|
let category = categoryRows.find((row) => row.slug === slug);
|
|
@@ -1355,12 +1353,7 @@ export class ChannelConnectorService {
|
|
|
1355
1353
|
category = created.value;
|
|
1356
1354
|
categoryRows.push(category);
|
|
1357
1355
|
}
|
|
1358
|
-
|
|
1359
|
-
continue;
|
|
1360
|
-
const linked = await this.catalog.addToCategory(entityId, category.id, actor);
|
|
1361
|
-
if (!linked.ok)
|
|
1362
|
-
return PluginErr(linked.error.message);
|
|
1363
|
-
changed = true;
|
|
1356
|
+
links.categories.push({ entityId, categoryId: category.id, sortOrder: 0 });
|
|
1364
1357
|
}
|
|
1365
1358
|
const brandRows = taxonomy.brands;
|
|
1366
1359
|
if (item.brand) {
|
|
@@ -1373,12 +1366,7 @@ export class ChannelConnectorService {
|
|
|
1373
1366
|
brand = created.value;
|
|
1374
1367
|
brandRows.push(brand);
|
|
1375
1368
|
}
|
|
1376
|
-
|
|
1377
|
-
const linked = await this.catalog.addToBrand(entityId, brand.id, actor);
|
|
1378
|
-
if (!linked.ok)
|
|
1379
|
-
return PluginErr(linked.error.message);
|
|
1380
|
-
changed = true;
|
|
1381
|
-
}
|
|
1369
|
+
links.brands.push({ entityId, brandId: brand.id, sortOrder: 0 });
|
|
1382
1370
|
}
|
|
1383
1371
|
const tagRows = taxonomy.tags;
|
|
1384
1372
|
for (const slug of new Set(item.tags ?? [])) {
|
|
@@ -1390,13 +1378,43 @@ export class ChannelConnectorService {
|
|
|
1390
1378
|
return PluginErr(`Tag "${slug}" was not persisted.`);
|
|
1391
1379
|
tagRows.push(tag);
|
|
1392
1380
|
}
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1381
|
+
links.tags.push({ entityId, tagId: tag.id });
|
|
1382
|
+
}
|
|
1383
|
+
return Ok(links);
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Writes one item's planned links and versions the entity for them, in ONE transaction: the
|
|
1387
|
+
* entity's `updated_at` moves with the links or not at all, and `catalog.afterUpdate` fires once
|
|
1388
|
+
* for the item with every link path that really changed (`["categories","tags"]`), not once per
|
|
1389
|
+
* link. Returns those paths; empty means nothing changed.
|
|
1390
|
+
*
|
|
1391
|
+
* An entity CREATED by this converge is not versioned for its links: its creation already put it
|
|
1392
|
+
* in front of every consumer, so a bump here would re-project a product in the same breath as its
|
|
1393
|
+
* first projection — the cold-import cost this rule exists to avoid.
|
|
1394
|
+
*/
|
|
1395
|
+
async commitEntityLinks(orgId, entityId, planned, previousRoles, isNew, actor) {
|
|
1396
|
+
try {
|
|
1397
|
+
return Ok(await this.transact(async (tx) => {
|
|
1398
|
+
const written = await writeEntityLinks(tx, orgId, planned);
|
|
1399
|
+
const paths = new Set(linkFieldPaths(written).get(entityId));
|
|
1400
|
+
for (const row of written.placed) {
|
|
1401
|
+
const previous = previousRoles.get(`${row.mediaAssetId}:${row.variantId}`);
|
|
1402
|
+
if (previous !== undefined)
|
|
1403
|
+
paths.add(`media.${previous}`);
|
|
1404
|
+
}
|
|
1405
|
+
const changed = [...paths].sort();
|
|
1406
|
+
if (changed.length > 0 && !isNew) {
|
|
1407
|
+
await this.catalog.notifyEntityChanged(entityId, changed, actor, createTxContext(tx, { actor }));
|
|
1408
|
+
}
|
|
1409
|
+
return changed;
|
|
1410
|
+
}));
|
|
1411
|
+
}
|
|
1412
|
+
catch (error) {
|
|
1413
|
+
return PluginErr(error instanceof Error ? error.message : "Failed to write the entity's links.");
|
|
1396
1414
|
}
|
|
1397
|
-
return Ok({ changed });
|
|
1398
1415
|
}
|
|
1399
1416
|
async applyMedia(orgId, entityId, item, variantIds, actor, warnings, owners) {
|
|
1417
|
+
// Uploads what is missing and PLANS the entity's media links; `commitEntityLinks` writes them.
|
|
1400
1418
|
const images = item.images ?? [];
|
|
1401
1419
|
const externalIds = [...new Set(images.map((image) => image.externalId).filter((id) => id != null))];
|
|
1402
1420
|
const urlHashes = [...new Set(images.map((image) => hash(image.url)))];
|
|
@@ -1412,8 +1430,10 @@ export class ChannelConnectorService {
|
|
|
1412
1430
|
: await this.db.select().from(mediaAssets).where(and(eq(mediaAssets.organizationId, orgId), or(...keyPredicates)));
|
|
1413
1431
|
const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entityId));
|
|
1414
1432
|
let imported = 0;
|
|
1415
|
-
let
|
|
1433
|
+
let uploaded = false;
|
|
1416
1434
|
const skipped = [];
|
|
1435
|
+
const planned = { media: [], mediaPlacements: [] };
|
|
1436
|
+
const previousRoles = new Map();
|
|
1417
1437
|
// A Cloudflare Worker may hold at most six simultaneous outbound connections per
|
|
1418
1438
|
// invocation, and one image costs two of them — the download and the storage put — so
|
|
1419
1439
|
// 6 / 2 = 3 images may be in flight. A fourth would queue behind the platform limit
|
|
@@ -1526,7 +1546,7 @@ export class ChannelConnectorService {
|
|
|
1526
1546
|
warnings.push(...resolved.imageWarnings);
|
|
1527
1547
|
imported += resolved.imported;
|
|
1528
1548
|
if (resolved.imageChanged)
|
|
1529
|
-
|
|
1549
|
+
uploaded = true;
|
|
1530
1550
|
}
|
|
1531
1551
|
for (const [imageIndex, image] of images.entries()) {
|
|
1532
1552
|
const mediaAssetId = resolvedImages[imageIndex]?.mediaAssetId;
|
|
@@ -1554,21 +1574,12 @@ export class ChannelConnectorService {
|
|
|
1554
1574
|
continue;
|
|
1555
1575
|
}
|
|
1556
1576
|
if (existingLink.role !== image.role || existingLink.sortOrder !== (image.sortOrder ?? 0)) {
|
|
1557
|
-
|
|
1558
|
-
|
|
1577
|
+
planned.mediaPlacements.push({ entityId, variantId: target.variantId ?? null, mediaAssetId, role: image.role, sortOrder: image.sortOrder ?? 0 });
|
|
1578
|
+
previousRoles.set(`${mediaAssetId}:${target.variantId ?? null}`, existingLink.role);
|
|
1559
1579
|
}
|
|
1560
1580
|
continue;
|
|
1561
1581
|
}
|
|
1562
|
-
|
|
1563
|
-
entityId,
|
|
1564
|
-
mediaAssetId,
|
|
1565
|
-
role: image.role,
|
|
1566
|
-
sortOrder: image.sortOrder ?? 0,
|
|
1567
|
-
...(target.variantId !== undefined ? { variantId: target.variantId } : {}),
|
|
1568
|
-
}, actor);
|
|
1569
|
-
if (!attached.ok)
|
|
1570
|
-
return PluginErr(attached.error.message);
|
|
1571
|
-
changed = true;
|
|
1582
|
+
planned.media.push({ entityId, variantId: target.variantId ?? null, mediaAssetId, role: image.role, sortOrder: image.sortOrder ?? 0 });
|
|
1572
1583
|
links.push({
|
|
1573
1584
|
entityId,
|
|
1574
1585
|
mediaAssetId,
|
|
@@ -1579,7 +1590,7 @@ export class ChannelConnectorService {
|
|
|
1579
1590
|
});
|
|
1580
1591
|
}
|
|
1581
1592
|
}
|
|
1582
|
-
return Ok({ imported,
|
|
1593
|
+
return Ok({ imported, uploaded, skipped, links: planned, previousRoles });
|
|
1583
1594
|
}
|
|
1584
1595
|
async getStoreRecord(orgId, id) {
|
|
1585
1596
|
const rows = await this.db
|
|
@@ -2452,13 +2463,13 @@ export class ChannelConnectorService {
|
|
|
2452
2463
|
}
|
|
2453
2464
|
}));
|
|
2454
2465
|
if (outcomes.length > 0) {
|
|
2455
|
-
await this.db
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2466
|
+
await writeEntityLinks(this.db, orgId, { media: outcomes.flatMap(({ entityId, mediaAssetId, hero, variantIds }) => [
|
|
2467
|
+
{ entityId, variantId: null, mediaAssetId, role: "primary", sortOrder: hero.sortOrder ?? 0 },
|
|
2468
|
+
...(hero.variantExternalIds ?? []).flatMap((externalId) => {
|
|
2469
|
+
const variantId = variantIds[externalId];
|
|
2470
|
+
return variantId === undefined ? [] : [{ entityId, variantId, mediaAssetId, role: hero.role, sortOrder: hero.sortOrder ?? 0 }];
|
|
2471
|
+
}),
|
|
2472
|
+
]) });
|
|
2462
2473
|
}
|
|
2463
2474
|
return { heroesImported: outcomes.filter((outcome) => outcome.imported).length, mediaFailures, deferredMedia };
|
|
2464
2475
|
}
|
|
@@ -3085,11 +3096,16 @@ export class ChannelConnectorService {
|
|
|
3085
3096
|
const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
|
|
3086
3097
|
if (!media.ok)
|
|
3087
3098
|
return media;
|
|
3099
|
+
const links = await this.commitEntityLinks(orgId, entityId, { ...taxonomy.value, ...media.value.links }, media.value.previousRoles, isNew, actor);
|
|
3100
|
+
if (!links.ok) {
|
|
3101
|
+
failures.push({ externalId: item.externalId, error: links.error });
|
|
3102
|
+
continue;
|
|
3103
|
+
}
|
|
3088
3104
|
attributesCreated += attributes.value.created;
|
|
3089
3105
|
mediaImported += media.value.imported;
|
|
3090
3106
|
variantsGivenOptionValues += variantIds.value.repaired;
|
|
3091
3107
|
skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
|
|
3092
|
-
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed ||
|
|
3108
|
+
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || links.value.length > 0 || media.value.uploaded || attributes.value.changed
|
|
3093
3109
|
|| identity.written.has(item.externalId);
|
|
3094
3110
|
const skuClashes = identity.clashes.get(item.externalId);
|
|
3095
3111
|
if (skuClashes) {
|
|
@@ -3214,11 +3230,14 @@ export class ChannelConnectorService {
|
|
|
3214
3230
|
const converged = await this.convergeCatalogItems(orgId, storeId, items, actor);
|
|
3215
3231
|
if (!converged.ok)
|
|
3216
3232
|
return converged;
|
|
3217
|
-
|
|
3233
|
+
// Planned BEFORE anything is archived: an empty or truncated fetch is refused whole (see
|
|
3234
|
+
// `planAbsentArchives`), and the refusal is reported, never half-applied.
|
|
3235
|
+
const plan = planAbsentArchives(entityMappings.map((mapping) => mapping.externalId), items.map((item) => item.externalId));
|
|
3236
|
+
const toArchive = new Set("archive" in plan ? plan.archive : []);
|
|
3218
3237
|
let archived = 0;
|
|
3219
3238
|
const skipped = [...converged.value.skipped];
|
|
3220
3239
|
for (const mapping of entityMappings) {
|
|
3221
|
-
if (
|
|
3240
|
+
if (!toArchive.has(mapping.externalId))
|
|
3222
3241
|
continue;
|
|
3223
3242
|
const [entity] = await this.db.select({ status: sellableEntities.status }).from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, mapping.entityId)));
|
|
3224
3243
|
if (entity?.status !== "archived") {
|
|
@@ -3266,7 +3285,8 @@ export class ChannelConnectorService {
|
|
|
3266
3285
|
archived,
|
|
3267
3286
|
inventoryUpdated,
|
|
3268
3287
|
openConflicts: openConflictRows.length,
|
|
3269
|
-
driftAlert: converged.value.imported + converged.value.converged + archived > threshold,
|
|
3288
|
+
driftAlert: "refused" in plan || converged.value.imported + converged.value.converged + archived > threshold,
|
|
3289
|
+
...("refused" in plan ? { refused: plan.refused } : {}),
|
|
3270
3290
|
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
3271
3291
|
...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
|
|
3272
3292
|
...(converged.value.warnings.length > 0 ? { warnings: converged.value.warnings } : {}),
|