@porulle/plugin-channel-connector 0.55.0 → 0.56.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.js +2 -1
- package/dist/schema.d.ts +98 -0
- package/dist/schema.js +15 -0
- package/dist/service.d.ts +17 -0
- package/dist/service.js +88 -7
- package/package.json +4 -4
- package/src/index.ts +2 -0
- package/src/schema.ts +20 -0
- package/src/service.ts +104 -5
- package/dist/tsconfig.tsbuildinfo +0 -1
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { CommerceConflictError, CommerceInvalidTransitionError, CommerceNotFound
|
|
|
3
3
|
import { z } from "@hono/zod-openapi";
|
|
4
4
|
import { and, eq } from "@porulle/core/drizzle";
|
|
5
5
|
import { processedWebhookEvents } from "@porulle/core/schema";
|
|
6
|
-
import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityMap, channelExportEvents, channelOrderExports, channelRefundEvents, channelRefundRequests, connectedStores, } from "./schema.js";
|
|
6
|
+
import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityLinks, channelEntityMap, channelExportEvents, channelOrderExports, channelRefundEvents, channelRefundRequests, connectedStores, } from "./schema.js";
|
|
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";
|
|
@@ -290,6 +290,7 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
290
290
|
schema: () => ({
|
|
291
291
|
connectedStores,
|
|
292
292
|
channelEntityMap,
|
|
293
|
+
channelEntityLinks,
|
|
293
294
|
channelCatalogPushes,
|
|
294
295
|
channelCatalogPushEvents,
|
|
295
296
|
channelCatalogConflicts,
|
package/dist/schema.d.ts
CHANGED
|
@@ -555,6 +555,104 @@ export declare const channelEntityMap: import("drizzle-orm/pg-core/table").PgTab
|
|
|
555
555
|
};
|
|
556
556
|
dialect: "pg";
|
|
557
557
|
}>;
|
|
558
|
+
/**
|
|
559
|
+
* The category, brand and tag links a store's converge CREATED on an entity — link provenance, per
|
|
560
|
+
* store, the way `channel_entity_map` records the variants a store owns. A converge removes a link
|
|
561
|
+
* the store dropped only when the link is on record here: a link the merchant added (or another
|
|
562
|
+
* store did) has no row for this store and is never this store's to take away.
|
|
563
|
+
*/
|
|
564
|
+
export declare const channelEntityLinks: import("drizzle-orm/pg-core/table").PgTableWithColumns<{
|
|
565
|
+
name: "channel_entity_links";
|
|
566
|
+
schema: undefined;
|
|
567
|
+
columns: {
|
|
568
|
+
organizationId: import("@porulle/core/drizzle").PgColumn<{
|
|
569
|
+
name: "organization_id";
|
|
570
|
+
tableName: "channel_entity_links";
|
|
571
|
+
dataType: "string";
|
|
572
|
+
columnType: "PgText";
|
|
573
|
+
data: string;
|
|
574
|
+
driverParam: string;
|
|
575
|
+
notNull: true;
|
|
576
|
+
hasDefault: false;
|
|
577
|
+
isPrimaryKey: false;
|
|
578
|
+
isAutoincrement: false;
|
|
579
|
+
hasRuntimeDefault: false;
|
|
580
|
+
enumValues: [string, ...string[]];
|
|
581
|
+
baseColumn: never;
|
|
582
|
+
identity: undefined;
|
|
583
|
+
generated: undefined;
|
|
584
|
+
}, {}, {}>;
|
|
585
|
+
storeId: import("@porulle/core/drizzle").PgColumn<{
|
|
586
|
+
name: "store_id";
|
|
587
|
+
tableName: "channel_entity_links";
|
|
588
|
+
dataType: "string";
|
|
589
|
+
columnType: "PgUUID";
|
|
590
|
+
data: string;
|
|
591
|
+
driverParam: string;
|
|
592
|
+
notNull: true;
|
|
593
|
+
hasDefault: false;
|
|
594
|
+
isPrimaryKey: false;
|
|
595
|
+
isAutoincrement: false;
|
|
596
|
+
hasRuntimeDefault: false;
|
|
597
|
+
enumValues: undefined;
|
|
598
|
+
baseColumn: never;
|
|
599
|
+
identity: undefined;
|
|
600
|
+
generated: undefined;
|
|
601
|
+
}, {}, {}>;
|
|
602
|
+
entityId: import("@porulle/core/drizzle").PgColumn<{
|
|
603
|
+
name: "entity_id";
|
|
604
|
+
tableName: "channel_entity_links";
|
|
605
|
+
dataType: "string";
|
|
606
|
+
columnType: "PgUUID";
|
|
607
|
+
data: string;
|
|
608
|
+
driverParam: string;
|
|
609
|
+
notNull: true;
|
|
610
|
+
hasDefault: false;
|
|
611
|
+
isPrimaryKey: false;
|
|
612
|
+
isAutoincrement: false;
|
|
613
|
+
hasRuntimeDefault: false;
|
|
614
|
+
enumValues: undefined;
|
|
615
|
+
baseColumn: never;
|
|
616
|
+
identity: undefined;
|
|
617
|
+
generated: undefined;
|
|
618
|
+
}, {}, {}>;
|
|
619
|
+
kind: import("@porulle/core/drizzle").PgColumn<{
|
|
620
|
+
name: "kind";
|
|
621
|
+
tableName: "channel_entity_links";
|
|
622
|
+
dataType: "string";
|
|
623
|
+
columnType: "PgText";
|
|
624
|
+
data: "brand" | "category" | "tag";
|
|
625
|
+
driverParam: string;
|
|
626
|
+
notNull: true;
|
|
627
|
+
hasDefault: false;
|
|
628
|
+
isPrimaryKey: false;
|
|
629
|
+
isAutoincrement: false;
|
|
630
|
+
hasRuntimeDefault: false;
|
|
631
|
+
enumValues: ["category", "brand", "tag"];
|
|
632
|
+
baseColumn: never;
|
|
633
|
+
identity: undefined;
|
|
634
|
+
generated: undefined;
|
|
635
|
+
}, {}, {}>;
|
|
636
|
+
targetId: import("@porulle/core/drizzle").PgColumn<{
|
|
637
|
+
name: "target_id";
|
|
638
|
+
tableName: "channel_entity_links";
|
|
639
|
+
dataType: "string";
|
|
640
|
+
columnType: "PgUUID";
|
|
641
|
+
data: string;
|
|
642
|
+
driverParam: string;
|
|
643
|
+
notNull: true;
|
|
644
|
+
hasDefault: false;
|
|
645
|
+
isPrimaryKey: false;
|
|
646
|
+
isAutoincrement: false;
|
|
647
|
+
hasRuntimeDefault: false;
|
|
648
|
+
enumValues: undefined;
|
|
649
|
+
baseColumn: never;
|
|
650
|
+
identity: undefined;
|
|
651
|
+
generated: undefined;
|
|
652
|
+
}, {}, {}>;
|
|
653
|
+
};
|
|
654
|
+
dialect: "pg";
|
|
655
|
+
}>;
|
|
558
656
|
export declare const channelCatalogConflicts: import("drizzle-orm/pg-core/table").PgTableWithColumns<{
|
|
559
657
|
name: "channel_catalog_conflicts";
|
|
560
658
|
schema: undefined;
|
package/dist/schema.js
CHANGED
|
@@ -44,6 +44,21 @@ export const channelEntityMap = pgTable("channel_entity_map", {
|
|
|
44
44
|
storeIdx: index("idx_channel_entity_map_store").on(table.storeId),
|
|
45
45
|
externalUnique: uniqueIndex("channel_entity_map_store_kind_external_unique").on(table.storeId, table.kind, table.externalId),
|
|
46
46
|
}));
|
|
47
|
+
/**
|
|
48
|
+
* The category, brand and tag links a store's converge CREATED on an entity — link provenance, per
|
|
49
|
+
* store, the way `channel_entity_map` records the variants a store owns. A converge removes a link
|
|
50
|
+
* the store dropped only when the link is on record here: a link the merchant added (or another
|
|
51
|
+
* store did) has no row for this store and is never this store's to take away.
|
|
52
|
+
*/
|
|
53
|
+
export const channelEntityLinks = pgTable("channel_entity_links", {
|
|
54
|
+
organizationId: text("organization_id").notNull(),
|
|
55
|
+
storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
|
|
56
|
+
entityId: uuid("entity_id").references(() => sellableEntities.id, { onDelete: "cascade" }).notNull(),
|
|
57
|
+
kind: text("kind", { enum: ["category", "brand", "tag"] }).notNull(),
|
|
58
|
+
targetId: uuid("target_id").notNull(),
|
|
59
|
+
}, (table) => ({
|
|
60
|
+
linkUnique: uniqueIndex("channel_entity_links_store_entity_kind_target_unique").on(table.storeId, table.entityId, table.kind, table.targetId),
|
|
61
|
+
}));
|
|
47
62
|
export const channelCatalogConflicts = pgTable("channel_catalog_conflicts", {
|
|
48
63
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
49
64
|
organizationId: text("organization_id").notNull(),
|
package/dist/service.d.ts
CHANGED
|
@@ -368,6 +368,11 @@ export declare class ChannelConnectorService {
|
|
|
368
368
|
* An entity CREATED by this converge is not versioned for its links: its creation already put it
|
|
369
369
|
* in front of every consumer, so a bump here would re-project a product in the same breath as its
|
|
370
370
|
* first projection — the cold-import cost this rule exists to avoid.
|
|
371
|
+
*
|
|
372
|
+
* It also REMOVES the category, brand and tag links the store no longer lists — only those on
|
|
373
|
+
* record in `channel_entity_links` as this store's, so a link the merchant added survives. Rows
|
|
374
|
+
* this converge inserts go on record; a product imported before provenance existed is claimed by
|
|
375
|
+
* `claimUnrecordedLinks` at the top of the converge.
|
|
371
376
|
*/
|
|
372
377
|
private commitEntityLinks;
|
|
373
378
|
private applyMedia;
|
|
@@ -466,6 +471,18 @@ export declare class ChannelConnectorService {
|
|
|
466
471
|
private saveBackfillState;
|
|
467
472
|
backfillCatalog(orgId: string, storeId: string, actor: Actor, options?: BackfillCatalogOptions): Promise<PluginResult<BackfillCatalogReport>>;
|
|
468
473
|
private estimateCatalogItems;
|
|
474
|
+
/**
|
|
475
|
+
* Link provenance for products imported before `channel_entity_links` existed: a mapped product
|
|
476
|
+
* of this batch with NOTHING on record for this store claims, once, the category / brand / tag
|
|
477
|
+
* links it has that the store lists NOW. Runs for unchanged items too — an unchanged item skips
|
|
478
|
+
* its converge, and the converge that follows may already be the drop, too late to claim.
|
|
479
|
+
*
|
|
480
|
+
* Its limit, stated rather than hidden: a link upstream had dropped BEFORE this claim was never
|
|
481
|
+
* the store's on record, so it stays (the stale set the old add-only converge left; repaired by a
|
|
482
|
+
* re-import, not by a heuristic delete). One select per batch; the claims only while unclaimed
|
|
483
|
+
* products remain.
|
|
484
|
+
*/
|
|
485
|
+
private claimUnrecordedLinks;
|
|
469
486
|
private convergeCatalogItems;
|
|
470
487
|
reconcile(orgId: string, storeId: string, actor: Actor): Promise<PluginResult<ReconcileReport>>;
|
|
471
488
|
getReconcileStatus(orgId: string, storeId: string): Promise<PluginResult<{
|
package/dist/service.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, linkFieldPaths, writeEntityLinks, } from "@porulle/core";
|
|
2
|
+
import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, linkFieldPaths, removeEntityLinks, 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, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
|
|
6
|
+
import { brands, categories, customerAddresses, customers, entityBrands, entityCategories, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
|
|
7
7
|
import { planAbsentArchives } from "./deletion-policy.js";
|
|
8
|
-
import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
|
|
8
|
+
import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityLinks, channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
|
|
9
9
|
import { mergeCatalogFieldMapping, normalizeCatalogFieldMapping, selectCatalogFieldMapping, } from "./catalog-field-mapping.js";
|
|
10
10
|
export const CATALOG_PUSH_BATCH_SIZES = {
|
|
11
11
|
mock: 100,
|
|
@@ -1339,10 +1339,14 @@ export class ChannelConnectorService {
|
|
|
1339
1339
|
// with the entity's version bump; a link that already exists writes nothing there.
|
|
1340
1340
|
const taxonomy = await this.taxonomyFor(orgId);
|
|
1341
1341
|
const links = { categories: [], brands: [], tags: [] };
|
|
1342
|
+
// Every link the item names, as `${kind}:${id}` — including an archived category it names but
|
|
1343
|
+
// is not linked to again, so a link the store still lists is never read as dropped.
|
|
1344
|
+
const listed = new Set();
|
|
1342
1345
|
const categoryRows = taxonomy.categories;
|
|
1343
1346
|
for (const slug of new Set(item.categories ?? [])) {
|
|
1344
1347
|
let category = categoryRows.find((row) => row.slug === slug);
|
|
1345
1348
|
if (category?.status === "archived") {
|
|
1349
|
+
listed.add(`category:${category.id}`);
|
|
1346
1350
|
warnings.push(`Skipped archived category "${slug}".`);
|
|
1347
1351
|
continue;
|
|
1348
1352
|
}
|
|
@@ -1354,6 +1358,7 @@ export class ChannelConnectorService {
|
|
|
1354
1358
|
categoryRows.push(category);
|
|
1355
1359
|
}
|
|
1356
1360
|
links.categories.push({ entityId, categoryId: category.id, sortOrder: 0 });
|
|
1361
|
+
listed.add(`category:${category.id}`);
|
|
1357
1362
|
}
|
|
1358
1363
|
const brandRows = taxonomy.brands;
|
|
1359
1364
|
if (item.brand) {
|
|
@@ -1367,6 +1372,7 @@ export class ChannelConnectorService {
|
|
|
1367
1372
|
brandRows.push(brand);
|
|
1368
1373
|
}
|
|
1369
1374
|
links.brands.push({ entityId, brandId: brand.id, sortOrder: 0 });
|
|
1375
|
+
listed.add(`brand:${brand.id}`);
|
|
1370
1376
|
}
|
|
1371
1377
|
const tagRows = taxonomy.tags;
|
|
1372
1378
|
for (const slug of new Set(item.tags ?? [])) {
|
|
@@ -1379,8 +1385,9 @@ export class ChannelConnectorService {
|
|
|
1379
1385
|
tagRows.push(tag);
|
|
1380
1386
|
}
|
|
1381
1387
|
links.tags.push({ entityId, tagId: tag.id });
|
|
1388
|
+
listed.add(`tag:${tag.id}`);
|
|
1382
1389
|
}
|
|
1383
|
-
return Ok(links);
|
|
1390
|
+
return Ok({ ...links, listed });
|
|
1384
1391
|
}
|
|
1385
1392
|
/**
|
|
1386
1393
|
* Writes one item's planned links and versions the entity for them, in ONE transaction: the
|
|
@@ -1391,12 +1398,36 @@ export class ChannelConnectorService {
|
|
|
1391
1398
|
* An entity CREATED by this converge is not versioned for its links: its creation already put it
|
|
1392
1399
|
* in front of every consumer, so a bump here would re-project a product in the same breath as its
|
|
1393
1400
|
* first projection — the cold-import cost this rule exists to avoid.
|
|
1401
|
+
*
|
|
1402
|
+
* It also REMOVES the category, brand and tag links the store no longer lists — only those on
|
|
1403
|
+
* record in `channel_entity_links` as this store's, so a link the merchant added survives. Rows
|
|
1404
|
+
* this converge inserts go on record; a product imported before provenance existed is claimed by
|
|
1405
|
+
* `claimUnrecordedLinks` at the top of the converge.
|
|
1394
1406
|
*/
|
|
1395
|
-
async commitEntityLinks(orgId, entityId, planned, previousRoles, isNew, actor) {
|
|
1407
|
+
async commitEntityLinks(orgId, storeId, entityId, planned, listed, previousRoles, isNew, actor) {
|
|
1396
1408
|
try {
|
|
1397
1409
|
return Ok(await this.transact(async (tx) => {
|
|
1398
1410
|
const written = await writeEntityLinks(tx, orgId, planned);
|
|
1399
|
-
const
|
|
1411
|
+
const owned = isNew ? [] : await tx.select({ kind: channelEntityLinks.kind, targetId: channelEntityLinks.targetId }).from(channelEntityLinks)
|
|
1412
|
+
.where(and(eq(channelEntityLinks.storeId, storeId), eq(channelEntityLinks.entityId, entityId)));
|
|
1413
|
+
const dropped = owned.filter((row) => !listed.has(`${row.kind}:${row.targetId}`));
|
|
1414
|
+
const removed = await removeEntityLinks(tx, orgId, {
|
|
1415
|
+
categories: dropped.filter((row) => row.kind === "category").map((row) => ({ entityId, categoryId: row.targetId })),
|
|
1416
|
+
brands: dropped.filter((row) => row.kind === "brand").map((row) => ({ entityId, brandId: row.targetId })),
|
|
1417
|
+
tags: dropped.filter((row) => row.kind === "tag").map((row) => ({ entityId, tagId: row.targetId })),
|
|
1418
|
+
});
|
|
1419
|
+
if (dropped.length > 0) {
|
|
1420
|
+
await tx.delete(channelEntityLinks).where(and(eq(channelEntityLinks.storeId, storeId), eq(channelEntityLinks.entityId, entityId), or(...dropped.map((row) => and(eq(channelEntityLinks.kind, row.kind), eq(channelEntityLinks.targetId, row.targetId))))));
|
|
1421
|
+
}
|
|
1422
|
+
const record = [
|
|
1423
|
+
...written.categories.map((row) => ({ kind: "category", targetId: row.categoryId })),
|
|
1424
|
+
...written.brands.map((row) => ({ kind: "brand", targetId: row.brandId })),
|
|
1425
|
+
...written.tags.map((row) => ({ kind: "tag", targetId: row.tagId })),
|
|
1426
|
+
];
|
|
1427
|
+
if (record.length > 0) {
|
|
1428
|
+
await tx.insert(channelEntityLinks).values(record.map((row) => ({ organizationId: orgId, storeId, entityId, ...row }))).onConflictDoNothing();
|
|
1429
|
+
}
|
|
1430
|
+
const paths = new Set([...(linkFieldPaths(written).get(entityId) ?? []), ...(linkFieldPaths(removed).get(entityId) ?? [])]);
|
|
1400
1431
|
for (const row of written.placed) {
|
|
1401
1432
|
const previous = previousRoles.get(`${row.mediaAssetId}:${row.variantId}`);
|
|
1402
1433
|
if (previous !== undefined)
|
|
@@ -2375,6 +2406,15 @@ export class ChannelConnectorService {
|
|
|
2375
2406
|
return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant", externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
|
|
2376
2407
|
}),
|
|
2377
2408
|
])).onConflictDoNothing();
|
|
2409
|
+
// Every category / brand / tag link on an entity this page just created was written by this
|
|
2410
|
+
// import, so all of it is this store's on record (`channel_entity_links`) — one statement.
|
|
2411
|
+
const created = sql.join(createdItems.map(({ entityId }) => sql `${entityId}::uuid`), sql `, `);
|
|
2412
|
+
await this.db.execute(sql `
|
|
2413
|
+
insert into ${channelEntityLinks} (organization_id, store_id, entity_id, kind, target_id)
|
|
2414
|
+
select ${orgId}, ${storeId}::uuid, entity_id, 'category', category_id from ${entityCategories} where entity_id in (${created})
|
|
2415
|
+
union all select ${orgId}, ${storeId}::uuid, entity_id, 'brand', brand_id from ${entityBrands} where entity_id in (${created})
|
|
2416
|
+
union all select ${orgId}, ${storeId}::uuid, entity_id, 'tag', tag_id from ${entityTags} where entity_id in (${created})
|
|
2417
|
+
on conflict do nothing`);
|
|
2378
2418
|
}
|
|
2379
2419
|
const media = await this.importHeroes(orgId, createdItems, actor);
|
|
2380
2420
|
const entityIds = [];
|
|
@@ -2871,6 +2911,45 @@ export class ChannelConnectorService {
|
|
|
2871
2911
|
}
|
|
2872
2912
|
return Ok(stats);
|
|
2873
2913
|
}
|
|
2914
|
+
/**
|
|
2915
|
+
* Link provenance for products imported before `channel_entity_links` existed: a mapped product
|
|
2916
|
+
* of this batch with NOTHING on record for this store claims, once, the category / brand / tag
|
|
2917
|
+
* links it has that the store lists NOW. Runs for unchanged items too — an unchanged item skips
|
|
2918
|
+
* its converge, and the converge that follows may already be the drop, too late to claim.
|
|
2919
|
+
*
|
|
2920
|
+
* Its limit, stated rather than hidden: a link upstream had dropped BEFORE this claim was never
|
|
2921
|
+
* the store's on record, so it stays (the stale set the old add-only converge left; repaired by a
|
|
2922
|
+
* re-import, not by a heuristic delete). One select per batch; the claims only while unclaimed
|
|
2923
|
+
* products remain.
|
|
2924
|
+
*/
|
|
2925
|
+
async claimUnrecordedLinks(orgId, storeId, items) {
|
|
2926
|
+
if (items.length === 0)
|
|
2927
|
+
return;
|
|
2928
|
+
const unclaimed = await this.db.select({ externalId: channelEntityMap.externalId, entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), inArray(channelEntityMap.externalId, items.map((item) => item.externalId)), sql `not exists (select 1 from ${channelEntityLinks} where ${channelEntityLinks.storeId} = ${channelEntityMap.storeId} and ${channelEntityLinks.entityId} = ${channelEntityMap.entityId})`));
|
|
2929
|
+
if (unclaimed.length === 0)
|
|
2930
|
+
return;
|
|
2931
|
+
const entityOf = new Map(unclaimed.map((row) => [row.externalId, row.entityId]));
|
|
2932
|
+
const listed = (pick) => items.flatMap((item) => {
|
|
2933
|
+
const entityId = entityOf.get(item.externalId);
|
|
2934
|
+
return entityId === undefined ? [] : [...new Set(pick(item))].map((slug) => sql `(${entityId}::uuid, ${slug}::text)`);
|
|
2935
|
+
});
|
|
2936
|
+
const claim = async (kind, pairs, link) => {
|
|
2937
|
+
if (pairs.length === 0)
|
|
2938
|
+
return;
|
|
2939
|
+
await this.db.execute(sql `
|
|
2940
|
+
insert into ${channelEntityLinks} (organization_id, store_id, entity_id, kind, target_id)
|
|
2941
|
+
select ${orgId}, ${storeId}::uuid, v.entity_id, ${kind}, t.id
|
|
2942
|
+
from (values ${sql.join(pairs, sql `, `)}) as v(entity_id, slug)
|
|
2943
|
+
${link}
|
|
2944
|
+
on conflict do nothing`);
|
|
2945
|
+
};
|
|
2946
|
+
await claim("category", listed((item) => item.categories ?? []), sql `join ${categories} t on t.slug = v.slug and t.organization_id = ${orgId}
|
|
2947
|
+
join ${entityCategories} l on l.entity_id = v.entity_id and l.category_id = t.id`);
|
|
2948
|
+
await claim("brand", listed((item) => (item.brand ? [item.brand] : [])), sql `join ${brands} t on t.slug = v.slug and t.organization_id = ${orgId}
|
|
2949
|
+
join ${entityBrands} l on l.entity_id = v.entity_id and l.brand_id = t.id`);
|
|
2950
|
+
await claim("tag", listed((item) => item.tags ?? []), sql `join ${tags} t on t.slug = v.slug and t.organization_id = ${orgId}
|
|
2951
|
+
join ${entityTags} l on l.entity_id = v.entity_id and l.tag_id = t.id`);
|
|
2952
|
+
}
|
|
2874
2953
|
async convergeCatalogItems(orgId, storeId, items, actor, force = false, dryRun = false) {
|
|
2875
2954
|
if (dryRun)
|
|
2876
2955
|
return this.estimateCatalogItems(orgId, storeId, items);
|
|
@@ -2896,6 +2975,7 @@ export class ChannelConnectorService {
|
|
|
2896
2975
|
// Upstream SKU/barcode changes on already-mapped variants, for the whole batch at once, so that
|
|
2897
2976
|
// variants exchanging SKUs across products in this batch land together.
|
|
2898
2977
|
const identity = await this.applyUpstreamVariantIdentity(orgId, storeId, items);
|
|
2978
|
+
await this.claimUnrecordedLinks(orgId, storeId, items);
|
|
2899
2979
|
for (const item of items) {
|
|
2900
2980
|
consumed += 1;
|
|
2901
2981
|
try {
|
|
@@ -3096,7 +3176,8 @@ export class ChannelConnectorService {
|
|
|
3096
3176
|
const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
|
|
3097
3177
|
if (!media.ok)
|
|
3098
3178
|
return media;
|
|
3099
|
-
const
|
|
3179
|
+
const { listed, ...taxonomyLinks } = taxonomy.value;
|
|
3180
|
+
const links = await this.commitEntityLinks(orgId, storeId, entityId, { ...taxonomyLinks, ...media.value.links }, listed, media.value.previousRoles, isNew, actor);
|
|
3100
3181
|
if (!links.ok) {
|
|
3101
3182
|
failures.push({ externalId: item.externalId, error: links.error });
|
|
3102
3183
|
continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/plugin-channel-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.56.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.56.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/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
channelCatalogPushes,
|
|
21
21
|
channelCatalogConflicts,
|
|
22
22
|
channelCatalogConflictEvents,
|
|
23
|
+
channelEntityLinks,
|
|
23
24
|
channelEntityMap,
|
|
24
25
|
channelExportEvents,
|
|
25
26
|
channelOrderExports,
|
|
@@ -453,6 +454,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
453
454
|
schema: () => ({
|
|
454
455
|
connectedStores,
|
|
455
456
|
channelEntityMap,
|
|
457
|
+
channelEntityLinks,
|
|
456
458
|
channelCatalogPushes,
|
|
457
459
|
channelCatalogPushEvents,
|
|
458
460
|
channelCatalogConflicts,
|
package/src/schema.ts
CHANGED
|
@@ -72,6 +72,26 @@ export const channelEntityMap = pgTable(
|
|
|
72
72
|
}),
|
|
73
73
|
);
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The category, brand and tag links a store's converge CREATED on an entity — link provenance, per
|
|
77
|
+
* store, the way `channel_entity_map` records the variants a store owns. A converge removes a link
|
|
78
|
+
* the store dropped only when the link is on record here: a link the merchant added (or another
|
|
79
|
+
* store did) has no row for this store and is never this store's to take away.
|
|
80
|
+
*/
|
|
81
|
+
export const channelEntityLinks = pgTable(
|
|
82
|
+
"channel_entity_links",
|
|
83
|
+
{
|
|
84
|
+
organizationId: text("organization_id").notNull(),
|
|
85
|
+
storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
|
|
86
|
+
entityId: uuid("entity_id").references(() => sellableEntities.id, { onDelete: "cascade" }).notNull(),
|
|
87
|
+
kind: text("kind", { enum: ["category", "brand", "tag"] }).notNull(),
|
|
88
|
+
targetId: uuid("target_id").notNull(),
|
|
89
|
+
},
|
|
90
|
+
(table) => ({
|
|
91
|
+
linkUnique: uniqueIndex("channel_entity_links_store_entity_kind_target_unique").on(table.storeId, table.entityId, table.kind, table.targetId),
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
|
|
75
95
|
export const channelCatalogConflicts = pgTable(
|
|
76
96
|
"channel_catalog_conflicts",
|
|
77
97
|
{
|
package/src/service.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
createTxContext,
|
|
8
8
|
createSystemActor,
|
|
9
9
|
linkFieldPaths,
|
|
10
|
+
removeEntityLinks,
|
|
10
11
|
writeEntityLinks,
|
|
11
12
|
} from "@porulle/core";
|
|
12
13
|
import type {
|
|
@@ -36,13 +37,16 @@ import { isValidFieldPath, requireUserId } from "@porulle/core";
|
|
|
36
37
|
import type { FieldOwner, FieldPath } from "@porulle/core";
|
|
37
38
|
import type { JobsAdapter } from "@porulle/core";
|
|
38
39
|
import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
|
|
39
|
-
import { and, desc, eq, inArray, isNull, lte, or, sql } from "@porulle/core/drizzle";
|
|
40
|
+
import { and, desc, eq, inArray, isNull, lte, or, sql, type SQL } from "@porulle/core/drizzle";
|
|
40
41
|
import {
|
|
41
42
|
brands,
|
|
42
43
|
categories,
|
|
43
44
|
customerAddresses,
|
|
44
45
|
customers,
|
|
46
|
+
entityBrands,
|
|
47
|
+
entityCategories,
|
|
45
48
|
entityMedia,
|
|
49
|
+
entityTags,
|
|
46
50
|
inventoryLevels,
|
|
47
51
|
mediaAssets,
|
|
48
52
|
optionTypes,
|
|
@@ -66,6 +70,7 @@ import {
|
|
|
66
70
|
channelCatalogPushes,
|
|
67
71
|
channelCatalogConflicts,
|
|
68
72
|
channelCatalogConflictEvents,
|
|
73
|
+
channelEntityLinks,
|
|
69
74
|
channelEntityMap,
|
|
70
75
|
channelExportEvents,
|
|
71
76
|
channelOrderExports,
|
|
@@ -2048,16 +2053,20 @@ export class ChannelConnectorService {
|
|
|
2048
2053
|
item: ChannelCatalogItem,
|
|
2049
2054
|
actor: Actor,
|
|
2050
2055
|
warnings: string[],
|
|
2051
|
-
): Promise<PluginResult<Pick<PlannedLinks, "categories" | "brands" | "tags"
|
|
2056
|
+
): Promise<PluginResult<Pick<PlannedLinks, "categories" | "brands" | "tags"> & { listed: Set<string> }>> {
|
|
2052
2057
|
// Resolves (creating where missing) the category, brand and tag rows the item names, and PLANS
|
|
2053
2058
|
// the entity's links to them. The links are written by `commitEntityLinks`, in one transaction
|
|
2054
2059
|
// with the entity's version bump; a link that already exists writes nothing there.
|
|
2055
2060
|
const taxonomy = await this.taxonomyFor(orgId);
|
|
2056
2061
|
const links: Pick<PlannedLinks, "categories" | "brands" | "tags"> = { categories: [], brands: [], tags: [] };
|
|
2062
|
+
// Every link the item names, as `${kind}:${id}` — including an archived category it names but
|
|
2063
|
+
// is not linked to again, so a link the store still lists is never read as dropped.
|
|
2064
|
+
const listed = new Set<string>();
|
|
2057
2065
|
const categoryRows = taxonomy.categories;
|
|
2058
2066
|
for (const slug of new Set(item.categories ?? [])) {
|
|
2059
2067
|
let category = categoryRows.find((row) => row.slug === slug);
|
|
2060
2068
|
if (category?.status === "archived") {
|
|
2069
|
+
listed.add(`category:${category.id}`);
|
|
2061
2070
|
warnings.push(`Skipped archived category "${slug}".`);
|
|
2062
2071
|
continue;
|
|
2063
2072
|
}
|
|
@@ -2073,6 +2082,7 @@ export class ChannelConnectorService {
|
|
|
2073
2082
|
categoryRows.push(category);
|
|
2074
2083
|
}
|
|
2075
2084
|
links.categories.push({ entityId, categoryId: category.id, sortOrder: 0 });
|
|
2085
|
+
listed.add(`category:${category.id}`);
|
|
2076
2086
|
}
|
|
2077
2087
|
|
|
2078
2088
|
const brandRows = taxonomy.brands;
|
|
@@ -2091,6 +2101,7 @@ export class ChannelConnectorService {
|
|
|
2091
2101
|
brandRows.push(brand);
|
|
2092
2102
|
}
|
|
2093
2103
|
links.brands.push({ entityId, brandId: brand.id, sortOrder: 0 });
|
|
2104
|
+
listed.add(`brand:${brand.id}`);
|
|
2094
2105
|
}
|
|
2095
2106
|
|
|
2096
2107
|
const tagRows = taxonomy.tags;
|
|
@@ -2106,8 +2117,9 @@ export class ChannelConnectorService {
|
|
|
2106
2117
|
tagRows.push(tag);
|
|
2107
2118
|
}
|
|
2108
2119
|
links.tags.push({ entityId, tagId: tag.id });
|
|
2120
|
+
listed.add(`tag:${tag.id}`);
|
|
2109
2121
|
}
|
|
2110
|
-
return Ok(links);
|
|
2122
|
+
return Ok({ ...links, listed });
|
|
2111
2123
|
}
|
|
2112
2124
|
|
|
2113
2125
|
/**
|
|
@@ -2119,11 +2131,18 @@ export class ChannelConnectorService {
|
|
|
2119
2131
|
* An entity CREATED by this converge is not versioned for its links: its creation already put it
|
|
2120
2132
|
* in front of every consumer, so a bump here would re-project a product in the same breath as its
|
|
2121
2133
|
* first projection — the cold-import cost this rule exists to avoid.
|
|
2134
|
+
*
|
|
2135
|
+
* It also REMOVES the category, brand and tag links the store no longer lists — only those on
|
|
2136
|
+
* record in `channel_entity_links` as this store's, so a link the merchant added survives. Rows
|
|
2137
|
+
* this converge inserts go on record; a product imported before provenance existed is claimed by
|
|
2138
|
+
* `claimUnrecordedLinks` at the top of the converge.
|
|
2122
2139
|
*/
|
|
2123
2140
|
private async commitEntityLinks(
|
|
2124
2141
|
orgId: string,
|
|
2142
|
+
storeId: string,
|
|
2125
2143
|
entityId: string,
|
|
2126
2144
|
planned: PlannedLinks,
|
|
2145
|
+
listed: Set<string>,
|
|
2127
2146
|
previousRoles: Map<string, string>,
|
|
2128
2147
|
isNew: boolean,
|
|
2129
2148
|
actor: Actor,
|
|
@@ -2131,7 +2150,30 @@ export class ChannelConnectorService {
|
|
|
2131
2150
|
try {
|
|
2132
2151
|
return Ok(await this.transact(async (tx) => {
|
|
2133
2152
|
const written = await writeEntityLinks(tx, orgId, planned);
|
|
2134
|
-
const
|
|
2153
|
+
const owned = isNew ? [] : await tx.select({ kind: channelEntityLinks.kind, targetId: channelEntityLinks.targetId }).from(channelEntityLinks)
|
|
2154
|
+
.where(and(eq(channelEntityLinks.storeId, storeId), eq(channelEntityLinks.entityId, entityId)));
|
|
2155
|
+
const dropped = owned.filter((row) => !listed.has(`${row.kind}:${row.targetId}`));
|
|
2156
|
+
const removed = await removeEntityLinks(tx, orgId, {
|
|
2157
|
+
categories: dropped.filter((row) => row.kind === "category").map((row) => ({ entityId, categoryId: row.targetId })),
|
|
2158
|
+
brands: dropped.filter((row) => row.kind === "brand").map((row) => ({ entityId, brandId: row.targetId })),
|
|
2159
|
+
tags: dropped.filter((row) => row.kind === "tag").map((row) => ({ entityId, tagId: row.targetId })),
|
|
2160
|
+
});
|
|
2161
|
+
if (dropped.length > 0) {
|
|
2162
|
+
await tx.delete(channelEntityLinks).where(and(
|
|
2163
|
+
eq(channelEntityLinks.storeId, storeId),
|
|
2164
|
+
eq(channelEntityLinks.entityId, entityId),
|
|
2165
|
+
or(...dropped.map((row) => and(eq(channelEntityLinks.kind, row.kind), eq(channelEntityLinks.targetId, row.targetId)))),
|
|
2166
|
+
));
|
|
2167
|
+
}
|
|
2168
|
+
const record = [
|
|
2169
|
+
...written.categories.map((row) => ({ kind: "category" as const, targetId: row.categoryId })),
|
|
2170
|
+
...written.brands.map((row) => ({ kind: "brand" as const, targetId: row.brandId })),
|
|
2171
|
+
...written.tags.map((row) => ({ kind: "tag" as const, targetId: row.tagId })),
|
|
2172
|
+
];
|
|
2173
|
+
if (record.length > 0) {
|
|
2174
|
+
await tx.insert(channelEntityLinks).values(record.map((row) => ({ organizationId: orgId, storeId, entityId, ...row }))).onConflictDoNothing();
|
|
2175
|
+
}
|
|
2176
|
+
const paths = new Set([...(linkFieldPaths(written).get(entityId) ?? []), ...(linkFieldPaths(removed).get(entityId) ?? [])]);
|
|
2135
2177
|
for (const row of written.placed) {
|
|
2136
2178
|
const previous = previousRoles.get(`${row.mediaAssetId}:${row.variantId}`);
|
|
2137
2179
|
if (previous !== undefined) paths.add(`media.${previous}`);
|
|
@@ -3254,6 +3296,15 @@ export class ChannelConnectorService {
|
|
|
3254
3296
|
return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant" as const, externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
|
|
3255
3297
|
}),
|
|
3256
3298
|
])).onConflictDoNothing();
|
|
3299
|
+
// Every category / brand / tag link on an entity this page just created was written by this
|
|
3300
|
+
// import, so all of it is this store's on record (`channel_entity_links`) — one statement.
|
|
3301
|
+
const created = sql.join(createdItems.map(({ entityId }) => sql`${entityId}::uuid`), sql`, `);
|
|
3302
|
+
await this.db.execute(sql`
|
|
3303
|
+
insert into ${channelEntityLinks} (organization_id, store_id, entity_id, kind, target_id)
|
|
3304
|
+
select ${orgId}, ${storeId}::uuid, entity_id, 'category', category_id from ${entityCategories} where entity_id in (${created})
|
|
3305
|
+
union all select ${orgId}, ${storeId}::uuid, entity_id, 'brand', brand_id from ${entityBrands} where entity_id in (${created})
|
|
3306
|
+
union all select ${orgId}, ${storeId}::uuid, entity_id, 'tag', tag_id from ${entityTags} where entity_id in (${created})
|
|
3307
|
+
on conflict do nothing`);
|
|
3257
3308
|
}
|
|
3258
3309
|
|
|
3259
3310
|
const media = await this.importHeroes(orgId, createdItems, actor);
|
|
@@ -3851,6 +3902,52 @@ export class ChannelConnectorService {
|
|
|
3851
3902
|
return Ok(stats);
|
|
3852
3903
|
}
|
|
3853
3904
|
|
|
3905
|
+
/**
|
|
3906
|
+
* Link provenance for products imported before `channel_entity_links` existed: a mapped product
|
|
3907
|
+
* of this batch with NOTHING on record for this store claims, once, the category / brand / tag
|
|
3908
|
+
* links it has that the store lists NOW. Runs for unchanged items too — an unchanged item skips
|
|
3909
|
+
* its converge, and the converge that follows may already be the drop, too late to claim.
|
|
3910
|
+
*
|
|
3911
|
+
* Its limit, stated rather than hidden: a link upstream had dropped BEFORE this claim was never
|
|
3912
|
+
* the store's on record, so it stays (the stale set the old add-only converge left; repaired by a
|
|
3913
|
+
* re-import, not by a heuristic delete). One select per batch; the claims only while unclaimed
|
|
3914
|
+
* products remain.
|
|
3915
|
+
*/
|
|
3916
|
+
private async claimUnrecordedLinks(orgId: string, storeId: string, items: ChannelCatalogItem[]): Promise<void> {
|
|
3917
|
+
if (items.length === 0) return;
|
|
3918
|
+
const unclaimed = await this.db.select({ externalId: channelEntityMap.externalId, entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(
|
|
3919
|
+
eq(channelEntityMap.organizationId, orgId),
|
|
3920
|
+
eq(channelEntityMap.storeId, storeId),
|
|
3921
|
+
eq(channelEntityMap.kind, "entity"),
|
|
3922
|
+
inArray(channelEntityMap.externalId, items.map((item) => item.externalId)),
|
|
3923
|
+
sql`not exists (select 1 from ${channelEntityLinks} where ${channelEntityLinks.storeId} = ${channelEntityMap.storeId} and ${channelEntityLinks.entityId} = ${channelEntityMap.entityId})`,
|
|
3924
|
+
));
|
|
3925
|
+
if (unclaimed.length === 0) return;
|
|
3926
|
+
const entityOf = new Map(unclaimed.map((row) => [row.externalId, row.entityId]));
|
|
3927
|
+
const listed = (pick: (item: ChannelCatalogItem) => readonly string[]) => items.flatMap((item) => {
|
|
3928
|
+
const entityId = entityOf.get(item.externalId);
|
|
3929
|
+
return entityId === undefined ? [] : [...new Set(pick(item))].map((slug) => sql`(${entityId}::uuid, ${slug}::text)`);
|
|
3930
|
+
});
|
|
3931
|
+
const claim = async (kind: "category" | "brand" | "tag", pairs: SQL[], link: SQL) => {
|
|
3932
|
+
if (pairs.length === 0) return;
|
|
3933
|
+
await this.db.execute(sql`
|
|
3934
|
+
insert into ${channelEntityLinks} (organization_id, store_id, entity_id, kind, target_id)
|
|
3935
|
+
select ${orgId}, ${storeId}::uuid, v.entity_id, ${kind}, t.id
|
|
3936
|
+
from (values ${sql.join(pairs, sql`, `)}) as v(entity_id, slug)
|
|
3937
|
+
${link}
|
|
3938
|
+
on conflict do nothing`);
|
|
3939
|
+
};
|
|
3940
|
+
await claim("category", listed((item) => item.categories ?? []),
|
|
3941
|
+
sql`join ${categories} t on t.slug = v.slug and t.organization_id = ${orgId}
|
|
3942
|
+
join ${entityCategories} l on l.entity_id = v.entity_id and l.category_id = t.id`);
|
|
3943
|
+
await claim("brand", listed((item) => (item.brand ? [item.brand] : [])),
|
|
3944
|
+
sql`join ${brands} t on t.slug = v.slug and t.organization_id = ${orgId}
|
|
3945
|
+
join ${entityBrands} l on l.entity_id = v.entity_id and l.brand_id = t.id`);
|
|
3946
|
+
await claim("tag", listed((item) => item.tags ?? []),
|
|
3947
|
+
sql`join ${tags} t on t.slug = v.slug and t.organization_id = ${orgId}
|
|
3948
|
+
join ${entityTags} l on l.entity_id = v.entity_id and l.tag_id = t.id`);
|
|
3949
|
+
}
|
|
3950
|
+
|
|
3854
3951
|
private async convergeCatalogItems(
|
|
3855
3952
|
orgId: string,
|
|
3856
3953
|
storeId: string,
|
|
@@ -3882,6 +3979,7 @@ export class ChannelConnectorService {
|
|
|
3882
3979
|
// Upstream SKU/barcode changes on already-mapped variants, for the whole batch at once, so that
|
|
3883
3980
|
// variants exchanging SKUs across products in this batch land together.
|
|
3884
3981
|
const identity = await this.applyUpstreamVariantIdentity(orgId, storeId, items);
|
|
3982
|
+
await this.claimUnrecordedLinks(orgId, storeId, items);
|
|
3885
3983
|
for (const item of items) {
|
|
3886
3984
|
consumed += 1;
|
|
3887
3985
|
try {
|
|
@@ -4095,7 +4193,8 @@ export class ChannelConnectorService {
|
|
|
4095
4193
|
if (!taxonomy.ok) { failures.push({ externalId: item.externalId, error: taxonomy.error }); continue; }
|
|
4096
4194
|
const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
|
|
4097
4195
|
if (!media.ok) return media;
|
|
4098
|
-
const
|
|
4196
|
+
const { listed, ...taxonomyLinks } = taxonomy.value;
|
|
4197
|
+
const links = await this.commitEntityLinks(orgId, storeId, entityId, { ...taxonomyLinks, ...media.value.links }, listed, media.value.previousRoles, isNew, actor);
|
|
4099
4198
|
if (!links.ok) { failures.push({ externalId: item.externalId, error: links.error }); continue; }
|
|
4100
4199
|
attributesCreated += attributes.value.created;
|
|
4101
4200
|
mediaImported += media.value.imported;
|