@porulle/plugin-channel-connector 0.54.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/deletion-policy.d.ts +19 -0
- package/dist/deletion-policy.js +28 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -1
- package/dist/schema.d.ts +98 -0
- package/dist/schema.js +15 -0
- package/dist/service.d.ts +30 -0
- package/dist/service.js +153 -52
- package/package.json +4 -4
- package/src/deletion-policy.ts +30 -0
- package/src/index.ts +4 -0
- package/src/schema.ts +20 -0
- package/src/service.ts +185 -58
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -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
|
@@ -3,10 +3,11 @@ 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";
|
|
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";
|
|
@@ -289,6 +290,7 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
289
290
|
schema: () => ({
|
|
290
291
|
connectedStores,
|
|
291
292
|
channelEntityMap,
|
|
293
|
+
channelEntityLinks,
|
|
292
294
|
channelCatalogPushes,
|
|
293
295
|
channelCatalogPushEvents,
|
|
294
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
|
@@ -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,22 @@ 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
|
+
* 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.
|
|
376
|
+
*/
|
|
377
|
+
private commitEntityLinks;
|
|
360
378
|
private applyMedia;
|
|
361
379
|
private getStoreRecord;
|
|
362
380
|
/**
|
|
@@ -453,6 +471,18 @@ export declare class ChannelConnectorService {
|
|
|
453
471
|
private saveBackfillState;
|
|
454
472
|
backfillCatalog(orgId: string, storeId: string, actor: Actor, options?: BackfillCatalogOptions): Promise<PluginResult<BackfillCatalogReport>>;
|
|
455
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;
|
|
456
486
|
private convergeCatalogItems;
|
|
457
487
|
reconcile(orgId: string, storeId: string, actor: Actor): Promise<PluginResult<ReconcileReport>>;
|
|
458
488
|
getReconcileStatus(orgId: string, storeId: string): Promise<PluginResult<{
|
package/dist/service.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
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, 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,
|
|
7
|
-
import {
|
|
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
|
+
import { planAbsentArchives } from "./deletion-policy.js";
|
|
8
|
+
import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityLinks, 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 = {
|
|
10
11
|
mock: 100,
|
|
@@ -1333,18 +1334,19 @@ 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
|
-
//
|
|
1339
|
-
|
|
1340
|
-
|
|
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: [] };
|
|
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();
|
|
1344
1345
|
const categoryRows = taxonomy.categories;
|
|
1345
1346
|
for (const slug of new Set(item.categories ?? [])) {
|
|
1346
1347
|
let category = categoryRows.find((row) => row.slug === slug);
|
|
1347
1348
|
if (category?.status === "archived") {
|
|
1349
|
+
listed.add(`category:${category.id}`);
|
|
1348
1350
|
warnings.push(`Skipped archived category "${slug}".`);
|
|
1349
1351
|
continue;
|
|
1350
1352
|
}
|
|
@@ -1355,12 +1357,8 @@ export class ChannelConnectorService {
|
|
|
1355
1357
|
category = created.value;
|
|
1356
1358
|
categoryRows.push(category);
|
|
1357
1359
|
}
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
const linked = await this.catalog.addToCategory(entityId, category.id, actor);
|
|
1361
|
-
if (!linked.ok)
|
|
1362
|
-
return PluginErr(linked.error.message);
|
|
1363
|
-
changed = true;
|
|
1360
|
+
links.categories.push({ entityId, categoryId: category.id, sortOrder: 0 });
|
|
1361
|
+
listed.add(`category:${category.id}`);
|
|
1364
1362
|
}
|
|
1365
1363
|
const brandRows = taxonomy.brands;
|
|
1366
1364
|
if (item.brand) {
|
|
@@ -1373,12 +1371,8 @@ export class ChannelConnectorService {
|
|
|
1373
1371
|
brand = created.value;
|
|
1374
1372
|
brandRows.push(brand);
|
|
1375
1373
|
}
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
if (!linked.ok)
|
|
1379
|
-
return PluginErr(linked.error.message);
|
|
1380
|
-
changed = true;
|
|
1381
|
-
}
|
|
1374
|
+
links.brands.push({ entityId, brandId: brand.id, sortOrder: 0 });
|
|
1375
|
+
listed.add(`brand:${brand.id}`);
|
|
1382
1376
|
}
|
|
1383
1377
|
const tagRows = taxonomy.tags;
|
|
1384
1378
|
for (const slug of new Set(item.tags ?? [])) {
|
|
@@ -1390,13 +1384,68 @@ export class ChannelConnectorService {
|
|
|
1390
1384
|
return PluginErr(`Tag "${slug}" was not persisted.`);
|
|
1391
1385
|
tagRows.push(tag);
|
|
1392
1386
|
}
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1387
|
+
links.tags.push({ entityId, tagId: tag.id });
|
|
1388
|
+
listed.add(`tag:${tag.id}`);
|
|
1389
|
+
}
|
|
1390
|
+
return Ok({ ...links, listed });
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Writes one item's planned links and versions the entity for them, in ONE transaction: the
|
|
1394
|
+
* entity's `updated_at` moves with the links or not at all, and `catalog.afterUpdate` fires once
|
|
1395
|
+
* for the item with every link path that really changed (`["categories","tags"]`), not once per
|
|
1396
|
+
* link. Returns those paths; empty means nothing changed.
|
|
1397
|
+
*
|
|
1398
|
+
* An entity CREATED by this converge is not versioned for its links: its creation already put it
|
|
1399
|
+
* in front of every consumer, so a bump here would re-project a product in the same breath as its
|
|
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.
|
|
1406
|
+
*/
|
|
1407
|
+
async commitEntityLinks(orgId, storeId, entityId, planned, listed, previousRoles, isNew, actor) {
|
|
1408
|
+
try {
|
|
1409
|
+
return Ok(await this.transact(async (tx) => {
|
|
1410
|
+
const written = await writeEntityLinks(tx, orgId, planned);
|
|
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) ?? [])]);
|
|
1431
|
+
for (const row of written.placed) {
|
|
1432
|
+
const previous = previousRoles.get(`${row.mediaAssetId}:${row.variantId}`);
|
|
1433
|
+
if (previous !== undefined)
|
|
1434
|
+
paths.add(`media.${previous}`);
|
|
1435
|
+
}
|
|
1436
|
+
const changed = [...paths].sort();
|
|
1437
|
+
if (changed.length > 0 && !isNew) {
|
|
1438
|
+
await this.catalog.notifyEntityChanged(entityId, changed, actor, createTxContext(tx, { actor }));
|
|
1439
|
+
}
|
|
1440
|
+
return changed;
|
|
1441
|
+
}));
|
|
1442
|
+
}
|
|
1443
|
+
catch (error) {
|
|
1444
|
+
return PluginErr(error instanceof Error ? error.message : "Failed to write the entity's links.");
|
|
1396
1445
|
}
|
|
1397
|
-
return Ok({ changed });
|
|
1398
1446
|
}
|
|
1399
1447
|
async applyMedia(orgId, entityId, item, variantIds, actor, warnings, owners) {
|
|
1448
|
+
// Uploads what is missing and PLANS the entity's media links; `commitEntityLinks` writes them.
|
|
1400
1449
|
const images = item.images ?? [];
|
|
1401
1450
|
const externalIds = [...new Set(images.map((image) => image.externalId).filter((id) => id != null))];
|
|
1402
1451
|
const urlHashes = [...new Set(images.map((image) => hash(image.url)))];
|
|
@@ -1412,8 +1461,10 @@ export class ChannelConnectorService {
|
|
|
1412
1461
|
: await this.db.select().from(mediaAssets).where(and(eq(mediaAssets.organizationId, orgId), or(...keyPredicates)));
|
|
1413
1462
|
const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entityId));
|
|
1414
1463
|
let imported = 0;
|
|
1415
|
-
let
|
|
1464
|
+
let uploaded = false;
|
|
1416
1465
|
const skipped = [];
|
|
1466
|
+
const planned = { media: [], mediaPlacements: [] };
|
|
1467
|
+
const previousRoles = new Map();
|
|
1417
1468
|
// A Cloudflare Worker may hold at most six simultaneous outbound connections per
|
|
1418
1469
|
// invocation, and one image costs two of them — the download and the storage put — so
|
|
1419
1470
|
// 6 / 2 = 3 images may be in flight. A fourth would queue behind the platform limit
|
|
@@ -1526,7 +1577,7 @@ export class ChannelConnectorService {
|
|
|
1526
1577
|
warnings.push(...resolved.imageWarnings);
|
|
1527
1578
|
imported += resolved.imported;
|
|
1528
1579
|
if (resolved.imageChanged)
|
|
1529
|
-
|
|
1580
|
+
uploaded = true;
|
|
1530
1581
|
}
|
|
1531
1582
|
for (const [imageIndex, image] of images.entries()) {
|
|
1532
1583
|
const mediaAssetId = resolvedImages[imageIndex]?.mediaAssetId;
|
|
@@ -1554,21 +1605,12 @@ export class ChannelConnectorService {
|
|
|
1554
1605
|
continue;
|
|
1555
1606
|
}
|
|
1556
1607
|
if (existingLink.role !== image.role || existingLink.sortOrder !== (image.sortOrder ?? 0)) {
|
|
1557
|
-
|
|
1558
|
-
|
|
1608
|
+
planned.mediaPlacements.push({ entityId, variantId: target.variantId ?? null, mediaAssetId, role: image.role, sortOrder: image.sortOrder ?? 0 });
|
|
1609
|
+
previousRoles.set(`${mediaAssetId}:${target.variantId ?? null}`, existingLink.role);
|
|
1559
1610
|
}
|
|
1560
1611
|
continue;
|
|
1561
1612
|
}
|
|
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;
|
|
1613
|
+
planned.media.push({ entityId, variantId: target.variantId ?? null, mediaAssetId, role: image.role, sortOrder: image.sortOrder ?? 0 });
|
|
1572
1614
|
links.push({
|
|
1573
1615
|
entityId,
|
|
1574
1616
|
mediaAssetId,
|
|
@@ -1579,7 +1621,7 @@ export class ChannelConnectorService {
|
|
|
1579
1621
|
});
|
|
1580
1622
|
}
|
|
1581
1623
|
}
|
|
1582
|
-
return Ok({ imported,
|
|
1624
|
+
return Ok({ imported, uploaded, skipped, links: planned, previousRoles });
|
|
1583
1625
|
}
|
|
1584
1626
|
async getStoreRecord(orgId, id) {
|
|
1585
1627
|
const rows = await this.db
|
|
@@ -2364,6 +2406,15 @@ export class ChannelConnectorService {
|
|
|
2364
2406
|
return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant", externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
|
|
2365
2407
|
}),
|
|
2366
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`);
|
|
2367
2418
|
}
|
|
2368
2419
|
const media = await this.importHeroes(orgId, createdItems, actor);
|
|
2369
2420
|
const entityIds = [];
|
|
@@ -2452,13 +2503,13 @@ export class ChannelConnectorService {
|
|
|
2452
2503
|
}
|
|
2453
2504
|
}));
|
|
2454
2505
|
if (outcomes.length > 0) {
|
|
2455
|
-
await this.db
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2506
|
+
await writeEntityLinks(this.db, orgId, { media: outcomes.flatMap(({ entityId, mediaAssetId, hero, variantIds }) => [
|
|
2507
|
+
{ entityId, variantId: null, mediaAssetId, role: "primary", sortOrder: hero.sortOrder ?? 0 },
|
|
2508
|
+
...(hero.variantExternalIds ?? []).flatMap((externalId) => {
|
|
2509
|
+
const variantId = variantIds[externalId];
|
|
2510
|
+
return variantId === undefined ? [] : [{ entityId, variantId, mediaAssetId, role: hero.role, sortOrder: hero.sortOrder ?? 0 }];
|
|
2511
|
+
}),
|
|
2512
|
+
]) });
|
|
2462
2513
|
}
|
|
2463
2514
|
return { heroesImported: outcomes.filter((outcome) => outcome.imported).length, mediaFailures, deferredMedia };
|
|
2464
2515
|
}
|
|
@@ -2860,6 +2911,45 @@ export class ChannelConnectorService {
|
|
|
2860
2911
|
}
|
|
2861
2912
|
return Ok(stats);
|
|
2862
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
|
+
}
|
|
2863
2953
|
async convergeCatalogItems(orgId, storeId, items, actor, force = false, dryRun = false) {
|
|
2864
2954
|
if (dryRun)
|
|
2865
2955
|
return this.estimateCatalogItems(orgId, storeId, items);
|
|
@@ -2885,6 +2975,7 @@ export class ChannelConnectorService {
|
|
|
2885
2975
|
// Upstream SKU/barcode changes on already-mapped variants, for the whole batch at once, so that
|
|
2886
2976
|
// variants exchanging SKUs across products in this batch land together.
|
|
2887
2977
|
const identity = await this.applyUpstreamVariantIdentity(orgId, storeId, items);
|
|
2978
|
+
await this.claimUnrecordedLinks(orgId, storeId, items);
|
|
2888
2979
|
for (const item of items) {
|
|
2889
2980
|
consumed += 1;
|
|
2890
2981
|
try {
|
|
@@ -3085,11 +3176,17 @@ export class ChannelConnectorService {
|
|
|
3085
3176
|
const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
|
|
3086
3177
|
if (!media.ok)
|
|
3087
3178
|
return media;
|
|
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);
|
|
3181
|
+
if (!links.ok) {
|
|
3182
|
+
failures.push({ externalId: item.externalId, error: links.error });
|
|
3183
|
+
continue;
|
|
3184
|
+
}
|
|
3088
3185
|
attributesCreated += attributes.value.created;
|
|
3089
3186
|
mediaImported += media.value.imported;
|
|
3090
3187
|
variantsGivenOptionValues += variantIds.value.repaired;
|
|
3091
3188
|
skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
|
|
3092
|
-
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed ||
|
|
3189
|
+
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || links.value.length > 0 || media.value.uploaded || attributes.value.changed
|
|
3093
3190
|
|| identity.written.has(item.externalId);
|
|
3094
3191
|
const skuClashes = identity.clashes.get(item.externalId);
|
|
3095
3192
|
if (skuClashes) {
|
|
@@ -3214,11 +3311,14 @@ export class ChannelConnectorService {
|
|
|
3214
3311
|
const converged = await this.convergeCatalogItems(orgId, storeId, items, actor);
|
|
3215
3312
|
if (!converged.ok)
|
|
3216
3313
|
return converged;
|
|
3217
|
-
|
|
3314
|
+
// Planned BEFORE anything is archived: an empty or truncated fetch is refused whole (see
|
|
3315
|
+
// `planAbsentArchives`), and the refusal is reported, never half-applied.
|
|
3316
|
+
const plan = planAbsentArchives(entityMappings.map((mapping) => mapping.externalId), items.map((item) => item.externalId));
|
|
3317
|
+
const toArchive = new Set("archive" in plan ? plan.archive : []);
|
|
3218
3318
|
let archived = 0;
|
|
3219
3319
|
const skipped = [...converged.value.skipped];
|
|
3220
3320
|
for (const mapping of entityMappings) {
|
|
3221
|
-
if (
|
|
3321
|
+
if (!toArchive.has(mapping.externalId))
|
|
3222
3322
|
continue;
|
|
3223
3323
|
const [entity] = await this.db.select({ status: sellableEntities.status }).from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, mapping.entityId)));
|
|
3224
3324
|
if (entity?.status !== "archived") {
|
|
@@ -3266,7 +3366,8 @@ export class ChannelConnectorService {
|
|
|
3266
3366
|
archived,
|
|
3267
3367
|
inventoryUpdated,
|
|
3268
3368
|
openConflicts: openConflictRows.length,
|
|
3269
|
-
driftAlert: converged.value.imported + converged.value.converged + archived > threshold,
|
|
3369
|
+
driftAlert: "refused" in plan || converged.value.imported + converged.value.converged + archived > threshold,
|
|
3370
|
+
...("refused" in plan ? { refused: plan.refused } : {}),
|
|
3270
3371
|
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
3271
3372
|
...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
|
|
3272
3373
|
...(converged.value.warnings.length > 0 ? { warnings: converged.value.warnings } : {}),
|
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"
|