@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,30 @@
|
|
|
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
|
+
|
|
15
|
+
export type AbsentArchivePlan = { archive: string[] } | { refused: string };
|
|
16
|
+
|
|
17
|
+
export function planAbsentArchives(mappedExternalIds: Iterable<string>, presentExternalIds: Iterable<string>): AbsentArchivePlan {
|
|
18
|
+
const mapped = [...new Set(mappedExternalIds)];
|
|
19
|
+
const present = new Set(presentExternalIds);
|
|
20
|
+
const absent = mapped.filter((externalId) => !present.has(externalId));
|
|
21
|
+
if (absent.length === 0) return { archive: [] };
|
|
22
|
+
if (present.size === 0) {
|
|
23
|
+
return { refused: `The fetch listed no products while ${mapped.length} are mapped; refusing to archive them.` };
|
|
24
|
+
}
|
|
25
|
+
const bound = Math.max(ABSENT_ARCHIVE_FLOOR, Math.floor(ABSENT_ARCHIVE_FRACTION * mapped.length));
|
|
26
|
+
if (absent.length > bound) {
|
|
27
|
+
return { refused: `${absent.length} of ${mapped.length} mapped products are absent from the fetch, above the bound of ${bound}; refusing to archive them.` };
|
|
28
|
+
}
|
|
29
|
+
return { archive: absent };
|
|
30
|
+
}
|
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,
|
|
@@ -53,6 +54,8 @@ type ChannelRouteContext = {
|
|
|
53
54
|
raw?: unknown;
|
|
54
55
|
};
|
|
55
56
|
|
|
57
|
+
export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
|
|
58
|
+
export type { AbsentArchivePlan } from "./deletion-policy.js";
|
|
56
59
|
export { mockChannelConnector } from "./mock-connector.js";
|
|
57
60
|
export type { MockChannelConnectorOptions } from "./mock-connector.js";
|
|
58
61
|
export {
|
|
@@ -451,6 +454,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
451
454
|
schema: () => ({
|
|
452
455
|
connectedStores,
|
|
453
456
|
channelEntityMap,
|
|
457
|
+
channelEntityLinks,
|
|
454
458
|
channelCatalogPushes,
|
|
455
459
|
channelCatalogPushEvents,
|
|
456
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
|
@@ -6,9 +6,13 @@ import {
|
|
|
6
6
|
PluginErr,
|
|
7
7
|
createTxContext,
|
|
8
8
|
createSystemActor,
|
|
9
|
+
linkFieldPaths,
|
|
10
|
+
removeEntityLinks,
|
|
11
|
+
writeEntityLinks,
|
|
9
12
|
} from "@porulle/core";
|
|
10
13
|
import type {
|
|
11
14
|
Actor,
|
|
15
|
+
EntityLinkRows,
|
|
12
16
|
ChannelCatalogItem,
|
|
13
17
|
ChannelConnector,
|
|
14
18
|
ChannelOrderSlice,
|
|
@@ -33,15 +37,15 @@ import { isValidFieldPath, requireUserId } from "@porulle/core";
|
|
|
33
37
|
import type { FieldOwner, FieldPath } from "@porulle/core";
|
|
34
38
|
import type { JobsAdapter } from "@porulle/core";
|
|
35
39
|
import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
|
|
36
|
-
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";
|
|
37
41
|
import {
|
|
38
42
|
brands,
|
|
39
43
|
categories,
|
|
40
44
|
customerAddresses,
|
|
41
45
|
customers,
|
|
42
|
-
entityMedia,
|
|
43
46
|
entityBrands,
|
|
44
47
|
entityCategories,
|
|
48
|
+
entityMedia,
|
|
45
49
|
entityTags,
|
|
46
50
|
inventoryLevels,
|
|
47
51
|
mediaAssets,
|
|
@@ -60,11 +64,13 @@ import {
|
|
|
60
64
|
variantOptionValues,
|
|
61
65
|
} from "@porulle/core/schema";
|
|
62
66
|
import type { SellableEntityRevisionSnapshot } from "@porulle/core/schema";
|
|
67
|
+
import { planAbsentArchives } from "./deletion-policy.js";
|
|
63
68
|
import {
|
|
64
69
|
channelCatalogPushEvents,
|
|
65
70
|
channelCatalogPushes,
|
|
66
71
|
channelCatalogConflicts,
|
|
67
72
|
channelCatalogConflictEvents,
|
|
73
|
+
channelEntityLinks,
|
|
68
74
|
channelEntityMap,
|
|
69
75
|
channelExportEvents,
|
|
70
76
|
channelOrderExports,
|
|
@@ -164,6 +170,8 @@ export interface ReconcileReport extends Record<string, unknown> {
|
|
|
164
170
|
inventoryUpdated: number;
|
|
165
171
|
openConflicts: number;
|
|
166
172
|
driftAlert: boolean;
|
|
173
|
+
/** Why this reconcile archived nothing although mapped products were absent (`planAbsentArchives`). */
|
|
174
|
+
refused?: string;
|
|
167
175
|
skipped?: CatalogFieldSkip[];
|
|
168
176
|
conflicts?: CatalogFieldConflict[];
|
|
169
177
|
warnings?: string[];
|
|
@@ -517,6 +525,8 @@ interface CatalogService {
|
|
|
517
525
|
actor: Actor,
|
|
518
526
|
ctx?: CatalogWriteContext,
|
|
519
527
|
): Promise<{ ok: true; value: undefined } | { ok: false; error: { message: string } }>;
|
|
528
|
+
/** Moves the entity's updated_at and fires catalog.afterUpdate for a change to related rows. */
|
|
529
|
+
notifyEntityChanged(entityId: string, changedFieldPaths: readonly string[], actor: Actor | null, ctx?: TxContext<PluginDb>): Promise<void>;
|
|
520
530
|
recordEntityRevision(
|
|
521
531
|
entityId: string,
|
|
522
532
|
actor: Actor,
|
|
@@ -581,16 +591,6 @@ interface MediaService {
|
|
|
581
591
|
},
|
|
582
592
|
actor: Actor,
|
|
583
593
|
): Promise<ServiceResult<{ id: string; url: string }>>;
|
|
584
|
-
attachToEntity(
|
|
585
|
-
input: {
|
|
586
|
-
entityId: string;
|
|
587
|
-
mediaAssetId: string;
|
|
588
|
-
role: "primary" | "gallery" | "thumbnail" | "video" | "document";
|
|
589
|
-
variantId?: string;
|
|
590
|
-
sortOrder?: number;
|
|
591
|
-
},
|
|
592
|
-
actor: Actor,
|
|
593
|
-
): Promise<ServiceResult<undefined>>;
|
|
594
594
|
listEntityMedia(
|
|
595
595
|
entityId: string,
|
|
596
596
|
opts?: { variantId?: string; orgId?: string },
|
|
@@ -1127,6 +1127,9 @@ function toImportProduct(item: ChannelCatalogItem): ImportProduct {
|
|
|
1127
1127
|
};
|
|
1128
1128
|
}
|
|
1129
1129
|
|
|
1130
|
+
/** One item's link writes, planned before the transaction that commits them (`commitEntityLinks`). */
|
|
1131
|
+
type PlannedLinks = { [K in keyof EntityLinkRows]-?: Array<NonNullable<EntityLinkRows[K]>[number]> };
|
|
1132
|
+
|
|
1130
1133
|
export class ChannelConnectorService {
|
|
1131
1134
|
private readonly connectors = new Map<string, ChannelConnector>();
|
|
1132
1135
|
private readonly transact: PluginTxFn;
|
|
@@ -2050,19 +2053,20 @@ export class ChannelConnectorService {
|
|
|
2050
2053
|
item: ChannelCatalogItem,
|
|
2051
2054
|
actor: Actor,
|
|
2052
2055
|
warnings: string[],
|
|
2053
|
-
): Promise<PluginResult<{
|
|
2056
|
+
): Promise<PluginResult<Pick<PlannedLinks, "categories" | "brands" | "tags"> & { listed: Set<string> }>> {
|
|
2057
|
+
// Resolves (creating where missing) the category, brand and tag rows the item names, and PLANS
|
|
2058
|
+
// the entity's links to them. The links are written by `commitEntityLinks`, in one transaction
|
|
2059
|
+
// with the entity's version bump; a link that already exists writes nothing there.
|
|
2054
2060
|
const taxonomy = await this.taxonomyFor(orgId);
|
|
2055
|
-
|
|
2056
|
-
//
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
const linkedBrands = new Set(!item.brand ? [] : (await this.db
|
|
2060
|
-
.select({ id: entityBrands.brandId }).from(entityBrands).where(eq(entityBrands.entityId, entityId))).map((row) => row.id));
|
|
2061
|
-
let changed = false;
|
|
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>();
|
|
2062
2065
|
const categoryRows = taxonomy.categories;
|
|
2063
2066
|
for (const slug of new Set(item.categories ?? [])) {
|
|
2064
2067
|
let category = categoryRows.find((row) => row.slug === slug);
|
|
2065
2068
|
if (category?.status === "archived") {
|
|
2069
|
+
listed.add(`category:${category.id}`);
|
|
2066
2070
|
warnings.push(`Skipped archived category "${slug}".`);
|
|
2067
2071
|
continue;
|
|
2068
2072
|
}
|
|
@@ -2077,10 +2081,8 @@ export class ChannelConnectorService {
|
|
|
2077
2081
|
category = created.value;
|
|
2078
2082
|
categoryRows.push(category);
|
|
2079
2083
|
}
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
if (!linked.ok) return PluginErr(linked.error.message);
|
|
2083
|
-
changed = true;
|
|
2084
|
+
links.categories.push({ entityId, categoryId: category.id, sortOrder: 0 });
|
|
2085
|
+
listed.add(`category:${category.id}`);
|
|
2084
2086
|
}
|
|
2085
2087
|
|
|
2086
2088
|
const brandRows = taxonomy.brands;
|
|
@@ -2098,11 +2100,8 @@ export class ChannelConnectorService {
|
|
|
2098
2100
|
brand = created.value;
|
|
2099
2101
|
brandRows.push(brand);
|
|
2100
2102
|
}
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
if (!linked.ok) return PluginErr(linked.error.message);
|
|
2104
|
-
changed = true;
|
|
2105
|
-
}
|
|
2103
|
+
links.brands.push({ entityId, brandId: brand.id, sortOrder: 0 });
|
|
2104
|
+
listed.add(`brand:${brand.id}`);
|
|
2106
2105
|
}
|
|
2107
2106
|
|
|
2108
2107
|
const tagRows = taxonomy.tags;
|
|
@@ -2117,10 +2116,77 @@ export class ChannelConnectorService {
|
|
|
2117
2116
|
if (!tag) return PluginErr(`Tag "${slug}" was not persisted.`);
|
|
2118
2117
|
tagRows.push(tag);
|
|
2119
2118
|
}
|
|
2120
|
-
|
|
2121
|
-
|
|
2119
|
+
links.tags.push({ entityId, tagId: tag.id });
|
|
2120
|
+
listed.add(`tag:${tag.id}`);
|
|
2121
|
+
}
|
|
2122
|
+
return Ok({ ...links, listed });
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
/**
|
|
2126
|
+
* Writes one item's planned links and versions the entity for them, in ONE transaction: the
|
|
2127
|
+
* entity's `updated_at` moves with the links or not at all, and `catalog.afterUpdate` fires once
|
|
2128
|
+
* for the item with every link path that really changed (`["categories","tags"]`), not once per
|
|
2129
|
+
* link. Returns those paths; empty means nothing changed.
|
|
2130
|
+
*
|
|
2131
|
+
* An entity CREATED by this converge is not versioned for its links: its creation already put it
|
|
2132
|
+
* in front of every consumer, so a bump here would re-project a product in the same breath as its
|
|
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.
|
|
2139
|
+
*/
|
|
2140
|
+
private async commitEntityLinks(
|
|
2141
|
+
orgId: string,
|
|
2142
|
+
storeId: string,
|
|
2143
|
+
entityId: string,
|
|
2144
|
+
planned: PlannedLinks,
|
|
2145
|
+
listed: Set<string>,
|
|
2146
|
+
previousRoles: Map<string, string>,
|
|
2147
|
+
isNew: boolean,
|
|
2148
|
+
actor: Actor,
|
|
2149
|
+
): Promise<PluginResult<string[]>> {
|
|
2150
|
+
try {
|
|
2151
|
+
return Ok(await this.transact(async (tx) => {
|
|
2152
|
+
const written = await writeEntityLinks(tx, orgId, planned);
|
|
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) ?? [])]);
|
|
2177
|
+
for (const row of written.placed) {
|
|
2178
|
+
const previous = previousRoles.get(`${row.mediaAssetId}:${row.variantId}`);
|
|
2179
|
+
if (previous !== undefined) paths.add(`media.${previous}`);
|
|
2180
|
+
}
|
|
2181
|
+
const changed = [...paths].sort();
|
|
2182
|
+
if (changed.length > 0 && !isNew) {
|
|
2183
|
+
await this.catalog.notifyEntityChanged(entityId, changed, actor, createTxContext(tx, { actor }));
|
|
2184
|
+
}
|
|
2185
|
+
return changed;
|
|
2186
|
+
}));
|
|
2187
|
+
} catch (error) {
|
|
2188
|
+
return PluginErr(error instanceof Error ? error.message : "Failed to write the entity's links.");
|
|
2122
2189
|
}
|
|
2123
|
-
return Ok({ changed });
|
|
2124
2190
|
}
|
|
2125
2191
|
|
|
2126
2192
|
private async applyMedia(
|
|
@@ -2131,7 +2197,15 @@ export class ChannelConnectorService {
|
|
|
2131
2197
|
actor: Actor,
|
|
2132
2198
|
warnings: string[],
|
|
2133
2199
|
owners: Map<FieldPath, FieldOwner>,
|
|
2134
|
-
): Promise<PluginResult<{
|
|
2200
|
+
): Promise<PluginResult<{
|
|
2201
|
+
imported: number;
|
|
2202
|
+
uploaded: boolean;
|
|
2203
|
+
skipped: FieldPath[];
|
|
2204
|
+
links: Pick<PlannedLinks, "media" | "mediaPlacements">;
|
|
2205
|
+
/** The role a re-placed link held before, keyed `${mediaAssetId}:${variantId}` — its path changes too. */
|
|
2206
|
+
previousRoles: Map<string, string>;
|
|
2207
|
+
}>> {
|
|
2208
|
+
// Uploads what is missing and PLANS the entity's media links; `commitEntityLinks` writes them.
|
|
2135
2209
|
const images = item.images ?? [];
|
|
2136
2210
|
const externalIds = [...new Set(images.map((image) => image.externalId).filter((id): id is string => id != null))];
|
|
2137
2211
|
const urlHashes = [...new Set(images.map((image) => hash(image.url)))];
|
|
@@ -2150,8 +2224,10 @@ export class ChannelConnectorService {
|
|
|
2150
2224
|
));
|
|
2151
2225
|
const links = await this.db.select().from(entityMedia).where(eq(entityMedia.entityId, entityId));
|
|
2152
2226
|
let imported = 0;
|
|
2153
|
-
let
|
|
2227
|
+
let uploaded = false;
|
|
2154
2228
|
const skipped: FieldPath[] = [];
|
|
2229
|
+
const planned: Pick<PlannedLinks, "media" | "mediaPlacements"> = { media: [], mediaPlacements: [] };
|
|
2230
|
+
const previousRoles = new Map<string, string>();
|
|
2155
2231
|
|
|
2156
2232
|
// A Cloudflare Worker may hold at most six simultaneous outbound connections per
|
|
2157
2233
|
// invocation, and one image costs two of them — the download and the storage put — so
|
|
@@ -2273,7 +2349,7 @@ export class ChannelConnectorService {
|
|
|
2273
2349
|
for (const resolved of resolvedImages) {
|
|
2274
2350
|
warnings.push(...resolved.imageWarnings);
|
|
2275
2351
|
imported += resolved.imported;
|
|
2276
|
-
if (resolved.imageChanged)
|
|
2352
|
+
if (resolved.imageChanged) uploaded = true;
|
|
2277
2353
|
}
|
|
2278
2354
|
|
|
2279
2355
|
for (const [imageIndex, image] of images.entries()) {
|
|
@@ -2302,24 +2378,12 @@ export class ChannelConnectorService {
|
|
|
2302
2378
|
if (skipped.includes(currentRolePath) || skipped.includes(incomingRolePath)) continue;
|
|
2303
2379
|
}
|
|
2304
2380
|
if (existingLink.role !== image.role || existingLink.sortOrder !== (image.sortOrder ?? 0)) {
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
eq(entityMedia.mediaAssetId, mediaAssetId),
|
|
2308
|
-
target.variantId === undefined ? isNull(entityMedia.variantId) : eq(entityMedia.variantId, target.variantId),
|
|
2309
|
-
));
|
|
2310
|
-
changed = true;
|
|
2381
|
+
planned.mediaPlacements.push({ entityId, variantId: target.variantId ?? null, mediaAssetId, role: image.role, sortOrder: image.sortOrder ?? 0 });
|
|
2382
|
+
previousRoles.set(`${mediaAssetId}:${target.variantId ?? null}`, existingLink.role);
|
|
2311
2383
|
}
|
|
2312
2384
|
continue;
|
|
2313
2385
|
}
|
|
2314
|
-
|
|
2315
|
-
entityId,
|
|
2316
|
-
mediaAssetId,
|
|
2317
|
-
role: image.role,
|
|
2318
|
-
sortOrder: image.sortOrder ?? 0,
|
|
2319
|
-
...(target.variantId !== undefined ? { variantId: target.variantId } : {}),
|
|
2320
|
-
}, actor);
|
|
2321
|
-
if (!attached.ok) return PluginErr(attached.error.message);
|
|
2322
|
-
changed = true;
|
|
2386
|
+
planned.media.push({ entityId, variantId: target.variantId ?? null, mediaAssetId, role: image.role, sortOrder: image.sortOrder ?? 0 });
|
|
2323
2387
|
links.push({
|
|
2324
2388
|
entityId,
|
|
2325
2389
|
mediaAssetId,
|
|
@@ -2330,7 +2394,7 @@ export class ChannelConnectorService {
|
|
|
2330
2394
|
});
|
|
2331
2395
|
}
|
|
2332
2396
|
}
|
|
2333
|
-
return Ok({ imported,
|
|
2397
|
+
return Ok({ imported, uploaded, skipped, links: planned, previousRoles });
|
|
2334
2398
|
}
|
|
2335
2399
|
|
|
2336
2400
|
private async getStoreRecord(orgId: string, id: string): Promise<ConnectedStore | undefined> {
|
|
@@ -3232,6 +3296,15 @@ export class ChannelConnectorService {
|
|
|
3232
3296
|
return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant" as const, externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
|
|
3233
3297
|
}),
|
|
3234
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`);
|
|
3235
3308
|
}
|
|
3236
3309
|
|
|
3237
3310
|
const media = await this.importHeroes(orgId, createdItems, actor);
|
|
@@ -3328,13 +3401,13 @@ export class ChannelConnectorService {
|
|
|
3328
3401
|
}));
|
|
3329
3402
|
|
|
3330
3403
|
if (outcomes.length > 0) {
|
|
3331
|
-
await this.db
|
|
3332
|
-
{ entityId, mediaAssetId, role: "primary" as const, sortOrder: hero.sortOrder ?? 0 },
|
|
3404
|
+
await writeEntityLinks(this.db, orgId, { media: outcomes.flatMap(({ entityId, mediaAssetId, hero, variantIds }) => [
|
|
3405
|
+
{ entityId, variantId: null, mediaAssetId, role: "primary" as const, sortOrder: hero.sortOrder ?? 0 },
|
|
3333
3406
|
...(hero.variantExternalIds ?? []).flatMap((externalId) => {
|
|
3334
3407
|
const variantId = variantIds[externalId];
|
|
3335
3408
|
return variantId === undefined ? [] : [{ entityId, variantId, mediaAssetId, role: hero.role, sortOrder: hero.sortOrder ?? 0 }];
|
|
3336
3409
|
}),
|
|
3337
|
-
]))
|
|
3410
|
+
]) });
|
|
3338
3411
|
}
|
|
3339
3412
|
return { heroesImported: outcomes.filter((outcome) => outcome.imported).length, mediaFailures, deferredMedia };
|
|
3340
3413
|
}
|
|
@@ -3829,6 +3902,52 @@ export class ChannelConnectorService {
|
|
|
3829
3902
|
return Ok(stats);
|
|
3830
3903
|
}
|
|
3831
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
|
+
|
|
3832
3951
|
private async convergeCatalogItems(
|
|
3833
3952
|
orgId: string,
|
|
3834
3953
|
storeId: string,
|
|
@@ -3860,6 +3979,7 @@ export class ChannelConnectorService {
|
|
|
3860
3979
|
// Upstream SKU/barcode changes on already-mapped variants, for the whole batch at once, so that
|
|
3861
3980
|
// variants exchanging SKUs across products in this batch land together.
|
|
3862
3981
|
const identity = await this.applyUpstreamVariantIdentity(orgId, storeId, items);
|
|
3982
|
+
await this.claimUnrecordedLinks(orgId, storeId, items);
|
|
3863
3983
|
for (const item of items) {
|
|
3864
3984
|
consumed += 1;
|
|
3865
3985
|
try {
|
|
@@ -4073,11 +4193,14 @@ export class ChannelConnectorService {
|
|
|
4073
4193
|
if (!taxonomy.ok) { failures.push({ externalId: item.externalId, error: taxonomy.error }); continue; }
|
|
4074
4194
|
const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
|
|
4075
4195
|
if (!media.ok) return media;
|
|
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);
|
|
4198
|
+
if (!links.ok) { failures.push({ externalId: item.externalId, error: links.error }); continue; }
|
|
4076
4199
|
attributesCreated += attributes.value.created;
|
|
4077
4200
|
mediaImported += media.value.imported;
|
|
4078
4201
|
variantsGivenOptionValues += variantIds.value.repaired;
|
|
4079
4202
|
skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
|
|
4080
|
-
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed ||
|
|
4203
|
+
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || links.value.length > 0 || media.value.uploaded || attributes.value.changed
|
|
4081
4204
|
|| identity.written.has(item.externalId);
|
|
4082
4205
|
const skuClashes = identity.clashes.get(item.externalId);
|
|
4083
4206
|
if (skuClashes) {
|
|
@@ -4207,11 +4330,14 @@ export class ChannelConnectorService {
|
|
|
4207
4330
|
|
|
4208
4331
|
const converged = await this.convergeCatalogItems(orgId, storeId, items, actor);
|
|
4209
4332
|
if (!converged.ok) return converged;
|
|
4210
|
-
|
|
4333
|
+
// Planned BEFORE anything is archived: an empty or truncated fetch is refused whole (see
|
|
4334
|
+
// `planAbsentArchives`), and the refusal is reported, never half-applied.
|
|
4335
|
+
const plan = planAbsentArchives(entityMappings.map((mapping) => mapping.externalId), items.map((item) => item.externalId));
|
|
4336
|
+
const toArchive = new Set("archive" in plan ? plan.archive : []);
|
|
4211
4337
|
let archived = 0;
|
|
4212
4338
|
const skipped = [...converged.value.skipped];
|
|
4213
4339
|
for (const mapping of entityMappings) {
|
|
4214
|
-
if (
|
|
4340
|
+
if (!toArchive.has(mapping.externalId)) continue;
|
|
4215
4341
|
const [entity] = await this.db.select({ status: sellableEntities.status }).from(sellableEntities).where(and(
|
|
4216
4342
|
eq(sellableEntities.organizationId, orgId),
|
|
4217
4343
|
eq(sellableEntities.id, mapping.entityId),
|
|
@@ -4263,7 +4389,8 @@ export class ChannelConnectorService {
|
|
|
4263
4389
|
archived,
|
|
4264
4390
|
inventoryUpdated,
|
|
4265
4391
|
openConflicts: openConflictRows.length,
|
|
4266
|
-
driftAlert: converged.value.imported + converged.value.converged + archived > threshold,
|
|
4392
|
+
driftAlert: "refused" in plan || converged.value.imported + converged.value.converged + archived > threshold,
|
|
4393
|
+
...("refused" in plan ? { refused: plan.refused } : {}),
|
|
4267
4394
|
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
4268
4395
|
...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
|
|
4269
4396
|
...(converged.value.warnings.length > 0 ? { warnings: converged.value.warnings } : {}),
|