@porulle/plugin-channel-connector 0.55.0 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -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 +19 -0
- package/dist/service.js +113 -9
- package/package.json +4 -4
- package/src/index.ts +3 -0
- package/src/schema.ts +20 -0
- package/src/service.ts +130 -7
- package/dist/tsconfig.tsbuildinfo +0 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ChannelConnectorPluginOptions } from "./service.js";
|
|
2
2
|
export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
|
|
3
|
+
export { channelSyncHash } from "./service.js";
|
|
3
4
|
export type { AbsentArchivePlan } from "./deletion-policy.js";
|
|
4
5
|
export { mockChannelConnector } from "./mock-connector.js";
|
|
5
6
|
export type { MockChannelConnectorOptions } from "./mock-connector.js";
|
package/dist/index.js
CHANGED
|
@@ -3,11 +3,12 @@ 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
10
|
export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
|
|
11
|
+
export { channelSyncHash } from "./service.js";
|
|
11
12
|
export { mockChannelConnector } from "./mock-connector.js";
|
|
12
13
|
export { ChannelConnectorService, HERO_IMAGE_BYTE_CAP, selectImportImages, CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS, CATALOG_PUSH_BATCH_SIZES, CATALOG_PUSH_MAX_ATTEMPTS, canCatalogPushTransition, canExportTransition, catalogPushConcurrencyKey, catalogPushRetryDelayMs, CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION, isCatalogPushBreakerOpen, } from "./service.js";
|
|
13
14
|
export { isValidCatalogMappingFieldPath, matchFieldPath, mergeCatalogFieldMapping, normalizeCatalogFieldMapping, compareCatalogFieldMappingSpecificity, providerCatalogFieldMappingDefaults, selectCatalogFieldMapping, validateCatalogMappingRow, } from "./catalog-field-mapping.js";
|
|
@@ -290,6 +291,7 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
290
291
|
schema: () => ({
|
|
291
292
|
connectedStores,
|
|
292
293
|
channelEntityMap,
|
|
294
|
+
channelEntityLinks,
|
|
293
295
|
channelCatalogPushes,
|
|
294
296
|
channelCatalogPushEvents,
|
|
295
297
|
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
|
@@ -236,6 +236,8 @@ export interface CatalogPushPreviewResult {
|
|
|
236
236
|
}
|
|
237
237
|
export declare function canExportTransition(from: ExportState, to: ExportState): boolean;
|
|
238
238
|
export declare function canCatalogPushTransition(from: CatalogPushState, to: CatalogPushState): boolean;
|
|
239
|
+
/** THE sync hash: every path that records or compares a `channel_entity_map.sync_hash` uses this. */
|
|
240
|
+
export declare function channelSyncHash(value: unknown): string;
|
|
239
241
|
/**
|
|
240
242
|
* The suffix a store's product takes when its handle is already another store's slug: the first
|
|
241
243
|
* label of the store domain (`kelly-felder.myshopify.com` → `kelly-felder`), slugified.
|
|
@@ -368,6 +370,11 @@ export declare class ChannelConnectorService {
|
|
|
368
370
|
* An entity CREATED by this converge is not versioned for its links: its creation already put it
|
|
369
371
|
* in front of every consumer, so a bump here would re-project a product in the same breath as its
|
|
370
372
|
* first projection — the cold-import cost this rule exists to avoid.
|
|
373
|
+
*
|
|
374
|
+
* It also REMOVES the category, brand and tag links the store no longer lists — only those on
|
|
375
|
+
* record in `channel_entity_links` as this store's, so a link the merchant added survives. Rows
|
|
376
|
+
* this converge inserts go on record; a product imported before provenance existed is claimed by
|
|
377
|
+
* `claimUnrecordedLinks` at the top of the converge.
|
|
371
378
|
*/
|
|
372
379
|
private commitEntityLinks;
|
|
373
380
|
private applyMedia;
|
|
@@ -466,6 +473,18 @@ export declare class ChannelConnectorService {
|
|
|
466
473
|
private saveBackfillState;
|
|
467
474
|
backfillCatalog(orgId: string, storeId: string, actor: Actor, options?: BackfillCatalogOptions): Promise<PluginResult<BackfillCatalogReport>>;
|
|
468
475
|
private estimateCatalogItems;
|
|
476
|
+
/**
|
|
477
|
+
* Link provenance for products imported before `channel_entity_links` existed: a mapped product
|
|
478
|
+
* of this batch with NOTHING on record for this store claims, once, the category / brand / tag
|
|
479
|
+
* links it has that the store lists NOW. Runs for unchanged items too — an unchanged item skips
|
|
480
|
+
* its converge, and the converge that follows may already be the drop, too late to claim.
|
|
481
|
+
*
|
|
482
|
+
* Its limit, stated rather than hidden: a link upstream had dropped BEFORE this claim was never
|
|
483
|
+
* the store's on record, so it stays (the stale set the old add-only converge left; repaired by a
|
|
484
|
+
* re-import, not by a heuristic delete). One select per batch; the claims only while unclaimed
|
|
485
|
+
* products remain.
|
|
486
|
+
*/
|
|
487
|
+
private claimUnrecordedLinks;
|
|
469
488
|
private convergeCatalogItems;
|
|
470
489
|
reconcile(orgId: string, storeId: string, actor: Actor): Promise<PluginResult<ReconcileReport>>;
|
|
471
490
|
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,
|
|
@@ -123,9 +123,30 @@ const catalogPushTransitions = {
|
|
|
123
123
|
export function canCatalogPushTransition(from, to) {
|
|
124
124
|
return catalogPushTransitions[from].includes(to);
|
|
125
125
|
}
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
/**
|
|
127
|
+
* JSON with every object's keys sorted, recursively — the one form every hash here is taken over.
|
|
128
|
+
*
|
|
129
|
+
* The sync hash was `sha256(JSON.stringify(item))`, and JSON.stringify writes keys in insertion
|
|
130
|
+
* order. The host's page consumer rebuilds each landed item in its own key order while reconcile
|
|
131
|
+
* hashes the adapter's objects in theirs, so the fast path and reconcile hashed the SAME content
|
|
132
|
+
* differently and reconcile re-converged every freshly imported product. Content, not key order,
|
|
133
|
+
* is what a hash here must answer. `undefined` values are dropped exactly as JSON.stringify drops
|
|
134
|
+
* them; array order is kept (it carries meaning: images, variants, options).
|
|
135
|
+
*/
|
|
136
|
+
function canonicalJson(value) {
|
|
137
|
+
if (Array.isArray(value))
|
|
138
|
+
return `[${value.map((entry) => (entry === undefined ? "null" : canonicalJson(entry))).join(",")}]`;
|
|
139
|
+
if (value !== null && typeof value === "object" && !(value instanceof Date)) {
|
|
140
|
+
const entries = Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
141
|
+
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
|
|
142
|
+
}
|
|
143
|
+
return JSON.stringify(value) ?? "null";
|
|
128
144
|
}
|
|
145
|
+
/** THE sync hash: every path that records or compares a `channel_entity_map.sync_hash` uses this. */
|
|
146
|
+
export function channelSyncHash(value) {
|
|
147
|
+
return createHash("sha256").update(canonicalJson(value)).digest("hex");
|
|
148
|
+
}
|
|
149
|
+
const hash = channelSyncHash;
|
|
129
150
|
/**
|
|
130
151
|
* The suffix a store's product takes when its handle is already another store's slug: the first
|
|
131
152
|
* label of the store domain (`kelly-felder.myshopify.com` → `kelly-felder`), slugified.
|
|
@@ -1339,10 +1360,14 @@ export class ChannelConnectorService {
|
|
|
1339
1360
|
// with the entity's version bump; a link that already exists writes nothing there.
|
|
1340
1361
|
const taxonomy = await this.taxonomyFor(orgId);
|
|
1341
1362
|
const links = { categories: [], brands: [], tags: [] };
|
|
1363
|
+
// Every link the item names, as `${kind}:${id}` — including an archived category it names but
|
|
1364
|
+
// is not linked to again, so a link the store still lists is never read as dropped.
|
|
1365
|
+
const listed = new Set();
|
|
1342
1366
|
const categoryRows = taxonomy.categories;
|
|
1343
1367
|
for (const slug of new Set(item.categories ?? [])) {
|
|
1344
1368
|
let category = categoryRows.find((row) => row.slug === slug);
|
|
1345
1369
|
if (category?.status === "archived") {
|
|
1370
|
+
listed.add(`category:${category.id}`);
|
|
1346
1371
|
warnings.push(`Skipped archived category "${slug}".`);
|
|
1347
1372
|
continue;
|
|
1348
1373
|
}
|
|
@@ -1354,6 +1379,7 @@ export class ChannelConnectorService {
|
|
|
1354
1379
|
categoryRows.push(category);
|
|
1355
1380
|
}
|
|
1356
1381
|
links.categories.push({ entityId, categoryId: category.id, sortOrder: 0 });
|
|
1382
|
+
listed.add(`category:${category.id}`);
|
|
1357
1383
|
}
|
|
1358
1384
|
const brandRows = taxonomy.brands;
|
|
1359
1385
|
if (item.brand) {
|
|
@@ -1367,6 +1393,7 @@ export class ChannelConnectorService {
|
|
|
1367
1393
|
brandRows.push(brand);
|
|
1368
1394
|
}
|
|
1369
1395
|
links.brands.push({ entityId, brandId: brand.id, sortOrder: 0 });
|
|
1396
|
+
listed.add(`brand:${brand.id}`);
|
|
1370
1397
|
}
|
|
1371
1398
|
const tagRows = taxonomy.tags;
|
|
1372
1399
|
for (const slug of new Set(item.tags ?? [])) {
|
|
@@ -1379,8 +1406,9 @@ export class ChannelConnectorService {
|
|
|
1379
1406
|
tagRows.push(tag);
|
|
1380
1407
|
}
|
|
1381
1408
|
links.tags.push({ entityId, tagId: tag.id });
|
|
1409
|
+
listed.add(`tag:${tag.id}`);
|
|
1382
1410
|
}
|
|
1383
|
-
return Ok(links);
|
|
1411
|
+
return Ok({ ...links, listed });
|
|
1384
1412
|
}
|
|
1385
1413
|
/**
|
|
1386
1414
|
* Writes one item's planned links and versions the entity for them, in ONE transaction: the
|
|
@@ -1391,12 +1419,36 @@ export class ChannelConnectorService {
|
|
|
1391
1419
|
* An entity CREATED by this converge is not versioned for its links: its creation already put it
|
|
1392
1420
|
* in front of every consumer, so a bump here would re-project a product in the same breath as its
|
|
1393
1421
|
* first projection — the cold-import cost this rule exists to avoid.
|
|
1422
|
+
*
|
|
1423
|
+
* It also REMOVES the category, brand and tag links the store no longer lists — only those on
|
|
1424
|
+
* record in `channel_entity_links` as this store's, so a link the merchant added survives. Rows
|
|
1425
|
+
* this converge inserts go on record; a product imported before provenance existed is claimed by
|
|
1426
|
+
* `claimUnrecordedLinks` at the top of the converge.
|
|
1394
1427
|
*/
|
|
1395
|
-
async commitEntityLinks(orgId, entityId, planned, previousRoles, isNew, actor) {
|
|
1428
|
+
async commitEntityLinks(orgId, storeId, entityId, planned, listed, previousRoles, isNew, actor) {
|
|
1396
1429
|
try {
|
|
1397
1430
|
return Ok(await this.transact(async (tx) => {
|
|
1398
1431
|
const written = await writeEntityLinks(tx, orgId, planned);
|
|
1399
|
-
const
|
|
1432
|
+
const owned = isNew ? [] : await tx.select({ kind: channelEntityLinks.kind, targetId: channelEntityLinks.targetId }).from(channelEntityLinks)
|
|
1433
|
+
.where(and(eq(channelEntityLinks.storeId, storeId), eq(channelEntityLinks.entityId, entityId)));
|
|
1434
|
+
const dropped = owned.filter((row) => !listed.has(`${row.kind}:${row.targetId}`));
|
|
1435
|
+
const removed = await removeEntityLinks(tx, orgId, {
|
|
1436
|
+
categories: dropped.filter((row) => row.kind === "category").map((row) => ({ entityId, categoryId: row.targetId })),
|
|
1437
|
+
brands: dropped.filter((row) => row.kind === "brand").map((row) => ({ entityId, brandId: row.targetId })),
|
|
1438
|
+
tags: dropped.filter((row) => row.kind === "tag").map((row) => ({ entityId, tagId: row.targetId })),
|
|
1439
|
+
});
|
|
1440
|
+
if (dropped.length > 0) {
|
|
1441
|
+
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))))));
|
|
1442
|
+
}
|
|
1443
|
+
const record = [
|
|
1444
|
+
...written.categories.map((row) => ({ kind: "category", targetId: row.categoryId })),
|
|
1445
|
+
...written.brands.map((row) => ({ kind: "brand", targetId: row.brandId })),
|
|
1446
|
+
...written.tags.map((row) => ({ kind: "tag", targetId: row.tagId })),
|
|
1447
|
+
];
|
|
1448
|
+
if (record.length > 0) {
|
|
1449
|
+
await tx.insert(channelEntityLinks).values(record.map((row) => ({ organizationId: orgId, storeId, entityId, ...row }))).onConflictDoNothing();
|
|
1450
|
+
}
|
|
1451
|
+
const paths = new Set([...(linkFieldPaths(written).get(entityId) ?? []), ...(linkFieldPaths(removed).get(entityId) ?? [])]);
|
|
1400
1452
|
for (const row of written.placed) {
|
|
1401
1453
|
const previous = previousRoles.get(`${row.mediaAssetId}:${row.variantId}`);
|
|
1402
1454
|
if (previous !== undefined)
|
|
@@ -2375,6 +2427,15 @@ export class ChannelConnectorService {
|
|
|
2375
2427
|
return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant", externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
|
|
2376
2428
|
}),
|
|
2377
2429
|
])).onConflictDoNothing();
|
|
2430
|
+
// Every category / brand / tag link on an entity this page just created was written by this
|
|
2431
|
+
// import, so all of it is this store's on record (`channel_entity_links`) — one statement.
|
|
2432
|
+
const created = sql.join(createdItems.map(({ entityId }) => sql `${entityId}::uuid`), sql `, `);
|
|
2433
|
+
await this.db.execute(sql `
|
|
2434
|
+
insert into ${channelEntityLinks} (organization_id, store_id, entity_id, kind, target_id)
|
|
2435
|
+
select ${orgId}, ${storeId}::uuid, entity_id, 'category', category_id from ${entityCategories} where entity_id in (${created})
|
|
2436
|
+
union all select ${orgId}, ${storeId}::uuid, entity_id, 'brand', brand_id from ${entityBrands} where entity_id in (${created})
|
|
2437
|
+
union all select ${orgId}, ${storeId}::uuid, entity_id, 'tag', tag_id from ${entityTags} where entity_id in (${created})
|
|
2438
|
+
on conflict do nothing`);
|
|
2378
2439
|
}
|
|
2379
2440
|
const media = await this.importHeroes(orgId, createdItems, actor);
|
|
2380
2441
|
const entityIds = [];
|
|
@@ -2871,6 +2932,47 @@ export class ChannelConnectorService {
|
|
|
2871
2932
|
}
|
|
2872
2933
|
return Ok(stats);
|
|
2873
2934
|
}
|
|
2935
|
+
/**
|
|
2936
|
+
* Link provenance for products imported before `channel_entity_links` existed: a mapped product
|
|
2937
|
+
* of this batch with NOTHING on record for this store claims, once, the category / brand / tag
|
|
2938
|
+
* links it has that the store lists NOW. Runs for unchanged items too — an unchanged item skips
|
|
2939
|
+
* its converge, and the converge that follows may already be the drop, too late to claim.
|
|
2940
|
+
*
|
|
2941
|
+
* Its limit, stated rather than hidden: a link upstream had dropped BEFORE this claim was never
|
|
2942
|
+
* the store's on record, so it stays (the stale set the old add-only converge left; repaired by a
|
|
2943
|
+
* re-import, not by a heuristic delete). One select per batch; the claims only while unclaimed
|
|
2944
|
+
* products remain.
|
|
2945
|
+
*/
|
|
2946
|
+
async claimUnrecordedLinks(orgId, storeId, items) {
|
|
2947
|
+
// Nothing listed, nothing to claim: no query. Otherwise one select per converge call (a batch),
|
|
2948
|
+
// never one per product.
|
|
2949
|
+
if (!items.some((item) => (item.tags?.length ?? 0) > 0 || (item.categories?.length ?? 0) > 0 || item.brand !== undefined))
|
|
2950
|
+
return;
|
|
2951
|
+
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})`));
|
|
2952
|
+
if (unclaimed.length === 0)
|
|
2953
|
+
return;
|
|
2954
|
+
const entityOf = new Map(unclaimed.map((row) => [row.externalId, row.entityId]));
|
|
2955
|
+
const listed = (pick) => items.flatMap((item) => {
|
|
2956
|
+
const entityId = entityOf.get(item.externalId);
|
|
2957
|
+
return entityId === undefined ? [] : [...new Set(pick(item))].map((slug) => sql `(${entityId}::uuid, ${slug}::text)`);
|
|
2958
|
+
});
|
|
2959
|
+
const claim = async (kind, pairs, link) => {
|
|
2960
|
+
if (pairs.length === 0)
|
|
2961
|
+
return;
|
|
2962
|
+
await this.db.execute(sql `
|
|
2963
|
+
insert into ${channelEntityLinks} (organization_id, store_id, entity_id, kind, target_id)
|
|
2964
|
+
select ${orgId}, ${storeId}::uuid, v.entity_id, ${kind}, t.id
|
|
2965
|
+
from (values ${sql.join(pairs, sql `, `)}) as v(entity_id, slug)
|
|
2966
|
+
${link}
|
|
2967
|
+
on conflict do nothing`);
|
|
2968
|
+
};
|
|
2969
|
+
await claim("category", listed((item) => item.categories ?? []), sql `join ${categories} t on t.slug = v.slug and t.organization_id = ${orgId}
|
|
2970
|
+
join ${entityCategories} l on l.entity_id = v.entity_id and l.category_id = t.id`);
|
|
2971
|
+
await claim("brand", listed((item) => (item.brand ? [item.brand] : [])), sql `join ${brands} t on t.slug = v.slug and t.organization_id = ${orgId}
|
|
2972
|
+
join ${entityBrands} l on l.entity_id = v.entity_id and l.brand_id = t.id`);
|
|
2973
|
+
await claim("tag", listed((item) => item.tags ?? []), sql `join ${tags} t on t.slug = v.slug and t.organization_id = ${orgId}
|
|
2974
|
+
join ${entityTags} l on l.entity_id = v.entity_id and l.tag_id = t.id`);
|
|
2975
|
+
}
|
|
2874
2976
|
async convergeCatalogItems(orgId, storeId, items, actor, force = false, dryRun = false) {
|
|
2875
2977
|
if (dryRun)
|
|
2876
2978
|
return this.estimateCatalogItems(orgId, storeId, items);
|
|
@@ -2896,6 +2998,7 @@ export class ChannelConnectorService {
|
|
|
2896
2998
|
// Upstream SKU/barcode changes on already-mapped variants, for the whole batch at once, so that
|
|
2897
2999
|
// variants exchanging SKUs across products in this batch land together.
|
|
2898
3000
|
const identity = await this.applyUpstreamVariantIdentity(orgId, storeId, items);
|
|
3001
|
+
await this.claimUnrecordedLinks(orgId, storeId, items);
|
|
2899
3002
|
for (const item of items) {
|
|
2900
3003
|
consumed += 1;
|
|
2901
3004
|
try {
|
|
@@ -3096,7 +3199,8 @@ export class ChannelConnectorService {
|
|
|
3096
3199
|
const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
|
|
3097
3200
|
if (!media.ok)
|
|
3098
3201
|
return media;
|
|
3099
|
-
const
|
|
3202
|
+
const { listed, ...taxonomyLinks } = taxonomy.value;
|
|
3203
|
+
const links = await this.commitEntityLinks(orgId, storeId, entityId, { ...taxonomyLinks, ...media.value.links }, listed, media.value.previousRoles, isNew, actor);
|
|
3100
3204
|
if (!links.ok) {
|
|
3101
3205
|
failures.push({ externalId: item.externalId, error: links.error });
|
|
3102
3206
|
continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/plugin-channel-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.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.57.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,
|
|
@@ -54,6 +55,7 @@ type ChannelRouteContext = {
|
|
|
54
55
|
};
|
|
55
56
|
|
|
56
57
|
export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
|
|
58
|
+
export { channelSyncHash } from "./service.js";
|
|
57
59
|
export type { AbsentArchivePlan } from "./deletion-policy.js";
|
|
58
60
|
export { mockChannelConnector } from "./mock-connector.js";
|
|
59
61
|
export type { MockChannelConnectorOptions } from "./mock-connector.js";
|
|
@@ -453,6 +455,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
453
455
|
schema: () => ({
|
|
454
456
|
connectedStores,
|
|
455
457
|
channelEntityMap,
|
|
458
|
+
channelEntityLinks,
|
|
456
459
|
channelCatalogPushes,
|
|
457
460
|
channelCatalogPushEvents,
|
|
458
461
|
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
|
{
|