@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/src/service.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
createTxContext,
|
|
8
8
|
createSystemActor,
|
|
9
9
|
linkFieldPaths,
|
|
10
|
+
removeEntityLinks,
|
|
10
11
|
writeEntityLinks,
|
|
11
12
|
} from "@porulle/core";
|
|
12
13
|
import type {
|
|
@@ -36,13 +37,16 @@ import { isValidFieldPath, requireUserId } from "@porulle/core";
|
|
|
36
37
|
import type { FieldOwner, FieldPath } from "@porulle/core";
|
|
37
38
|
import type { JobsAdapter } from "@porulle/core";
|
|
38
39
|
import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
|
|
39
|
-
import { and, desc, eq, inArray, isNull, lte, or, sql } from "@porulle/core/drizzle";
|
|
40
|
+
import { and, desc, eq, inArray, isNull, lte, or, sql, type SQL } from "@porulle/core/drizzle";
|
|
40
41
|
import {
|
|
41
42
|
brands,
|
|
42
43
|
categories,
|
|
43
44
|
customerAddresses,
|
|
44
45
|
customers,
|
|
46
|
+
entityBrands,
|
|
47
|
+
entityCategories,
|
|
45
48
|
entityMedia,
|
|
49
|
+
entityTags,
|
|
46
50
|
inventoryLevels,
|
|
47
51
|
mediaAssets,
|
|
48
52
|
optionTypes,
|
|
@@ -66,6 +70,7 @@ import {
|
|
|
66
70
|
channelCatalogPushes,
|
|
67
71
|
channelCatalogConflicts,
|
|
68
72
|
channelCatalogConflictEvents,
|
|
73
|
+
channelEntityLinks,
|
|
69
74
|
channelEntityMap,
|
|
70
75
|
channelExportEvents,
|
|
71
76
|
channelOrderExports,
|
|
@@ -632,10 +637,32 @@ export function canCatalogPushTransition(from: CatalogPushState, to: CatalogPush
|
|
|
632
637
|
return catalogPushTransitions[from].includes(to);
|
|
633
638
|
}
|
|
634
639
|
|
|
635
|
-
|
|
636
|
-
|
|
640
|
+
/**
|
|
641
|
+
* JSON with every object's keys sorted, recursively — the one form every hash here is taken over.
|
|
642
|
+
*
|
|
643
|
+
* The sync hash was `sha256(JSON.stringify(item))`, and JSON.stringify writes keys in insertion
|
|
644
|
+
* order. The host's page consumer rebuilds each landed item in its own key order while reconcile
|
|
645
|
+
* hashes the adapter's objects in theirs, so the fast path and reconcile hashed the SAME content
|
|
646
|
+
* differently and reconcile re-converged every freshly imported product. Content, not key order,
|
|
647
|
+
* is what a hash here must answer. `undefined` values are dropped exactly as JSON.stringify drops
|
|
648
|
+
* them; array order is kept (it carries meaning: images, variants, options).
|
|
649
|
+
*/
|
|
650
|
+
function canonicalJson(value: unknown): string {
|
|
651
|
+
if (Array.isArray(value)) return `[${value.map((entry) => (entry === undefined ? "null" : canonicalJson(entry))).join(",")}]`;
|
|
652
|
+
if (value !== null && typeof value === "object" && !(value instanceof Date)) {
|
|
653
|
+
const entries = Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
654
|
+
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
|
|
655
|
+
}
|
|
656
|
+
return JSON.stringify(value) ?? "null";
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** THE sync hash: every path that records or compares a `channel_entity_map.sync_hash` uses this. */
|
|
660
|
+
export function channelSyncHash(value: unknown): string {
|
|
661
|
+
return createHash("sha256").update(canonicalJson(value)).digest("hex");
|
|
637
662
|
}
|
|
638
663
|
|
|
664
|
+
const hash = channelSyncHash;
|
|
665
|
+
|
|
639
666
|
/**
|
|
640
667
|
* The suffix a store's product takes when its handle is already another store's slug: the first
|
|
641
668
|
* label of the store domain (`kelly-felder.myshopify.com` → `kelly-felder`), slugified.
|
|
@@ -2048,16 +2075,20 @@ export class ChannelConnectorService {
|
|
|
2048
2075
|
item: ChannelCatalogItem,
|
|
2049
2076
|
actor: Actor,
|
|
2050
2077
|
warnings: string[],
|
|
2051
|
-
): Promise<PluginResult<Pick<PlannedLinks, "categories" | "brands" | "tags"
|
|
2078
|
+
): Promise<PluginResult<Pick<PlannedLinks, "categories" | "brands" | "tags"> & { listed: Set<string> }>> {
|
|
2052
2079
|
// Resolves (creating where missing) the category, brand and tag rows the item names, and PLANS
|
|
2053
2080
|
// the entity's links to them. The links are written by `commitEntityLinks`, in one transaction
|
|
2054
2081
|
// with the entity's version bump; a link that already exists writes nothing there.
|
|
2055
2082
|
const taxonomy = await this.taxonomyFor(orgId);
|
|
2056
2083
|
const links: Pick<PlannedLinks, "categories" | "brands" | "tags"> = { categories: [], brands: [], tags: [] };
|
|
2084
|
+
// Every link the item names, as `${kind}:${id}` — including an archived category it names but
|
|
2085
|
+
// is not linked to again, so a link the store still lists is never read as dropped.
|
|
2086
|
+
const listed = new Set<string>();
|
|
2057
2087
|
const categoryRows = taxonomy.categories;
|
|
2058
2088
|
for (const slug of new Set(item.categories ?? [])) {
|
|
2059
2089
|
let category = categoryRows.find((row) => row.slug === slug);
|
|
2060
2090
|
if (category?.status === "archived") {
|
|
2091
|
+
listed.add(`category:${category.id}`);
|
|
2061
2092
|
warnings.push(`Skipped archived category "${slug}".`);
|
|
2062
2093
|
continue;
|
|
2063
2094
|
}
|
|
@@ -2073,6 +2104,7 @@ export class ChannelConnectorService {
|
|
|
2073
2104
|
categoryRows.push(category);
|
|
2074
2105
|
}
|
|
2075
2106
|
links.categories.push({ entityId, categoryId: category.id, sortOrder: 0 });
|
|
2107
|
+
listed.add(`category:${category.id}`);
|
|
2076
2108
|
}
|
|
2077
2109
|
|
|
2078
2110
|
const brandRows = taxonomy.brands;
|
|
@@ -2091,6 +2123,7 @@ export class ChannelConnectorService {
|
|
|
2091
2123
|
brandRows.push(brand);
|
|
2092
2124
|
}
|
|
2093
2125
|
links.brands.push({ entityId, brandId: brand.id, sortOrder: 0 });
|
|
2126
|
+
listed.add(`brand:${brand.id}`);
|
|
2094
2127
|
}
|
|
2095
2128
|
|
|
2096
2129
|
const tagRows = taxonomy.tags;
|
|
@@ -2106,8 +2139,9 @@ export class ChannelConnectorService {
|
|
|
2106
2139
|
tagRows.push(tag);
|
|
2107
2140
|
}
|
|
2108
2141
|
links.tags.push({ entityId, tagId: tag.id });
|
|
2142
|
+
listed.add(`tag:${tag.id}`);
|
|
2109
2143
|
}
|
|
2110
|
-
return Ok(links);
|
|
2144
|
+
return Ok({ ...links, listed });
|
|
2111
2145
|
}
|
|
2112
2146
|
|
|
2113
2147
|
/**
|
|
@@ -2119,11 +2153,18 @@ export class ChannelConnectorService {
|
|
|
2119
2153
|
* An entity CREATED by this converge is not versioned for its links: its creation already put it
|
|
2120
2154
|
* in front of every consumer, so a bump here would re-project a product in the same breath as its
|
|
2121
2155
|
* first projection — the cold-import cost this rule exists to avoid.
|
|
2156
|
+
*
|
|
2157
|
+
* It also REMOVES the category, brand and tag links the store no longer lists — only those on
|
|
2158
|
+
* record in `channel_entity_links` as this store's, so a link the merchant added survives. Rows
|
|
2159
|
+
* this converge inserts go on record; a product imported before provenance existed is claimed by
|
|
2160
|
+
* `claimUnrecordedLinks` at the top of the converge.
|
|
2122
2161
|
*/
|
|
2123
2162
|
private async commitEntityLinks(
|
|
2124
2163
|
orgId: string,
|
|
2164
|
+
storeId: string,
|
|
2125
2165
|
entityId: string,
|
|
2126
2166
|
planned: PlannedLinks,
|
|
2167
|
+
listed: Set<string>,
|
|
2127
2168
|
previousRoles: Map<string, string>,
|
|
2128
2169
|
isNew: boolean,
|
|
2129
2170
|
actor: Actor,
|
|
@@ -2131,7 +2172,30 @@ export class ChannelConnectorService {
|
|
|
2131
2172
|
try {
|
|
2132
2173
|
return Ok(await this.transact(async (tx) => {
|
|
2133
2174
|
const written = await writeEntityLinks(tx, orgId, planned);
|
|
2134
|
-
const
|
|
2175
|
+
const owned = isNew ? [] : await tx.select({ kind: channelEntityLinks.kind, targetId: channelEntityLinks.targetId }).from(channelEntityLinks)
|
|
2176
|
+
.where(and(eq(channelEntityLinks.storeId, storeId), eq(channelEntityLinks.entityId, entityId)));
|
|
2177
|
+
const dropped = owned.filter((row) => !listed.has(`${row.kind}:${row.targetId}`));
|
|
2178
|
+
const removed = await removeEntityLinks(tx, orgId, {
|
|
2179
|
+
categories: dropped.filter((row) => row.kind === "category").map((row) => ({ entityId, categoryId: row.targetId })),
|
|
2180
|
+
brands: dropped.filter((row) => row.kind === "brand").map((row) => ({ entityId, brandId: row.targetId })),
|
|
2181
|
+
tags: dropped.filter((row) => row.kind === "tag").map((row) => ({ entityId, tagId: row.targetId })),
|
|
2182
|
+
});
|
|
2183
|
+
if (dropped.length > 0) {
|
|
2184
|
+
await tx.delete(channelEntityLinks).where(and(
|
|
2185
|
+
eq(channelEntityLinks.storeId, storeId),
|
|
2186
|
+
eq(channelEntityLinks.entityId, entityId),
|
|
2187
|
+
or(...dropped.map((row) => and(eq(channelEntityLinks.kind, row.kind), eq(channelEntityLinks.targetId, row.targetId)))),
|
|
2188
|
+
));
|
|
2189
|
+
}
|
|
2190
|
+
const record = [
|
|
2191
|
+
...written.categories.map((row) => ({ kind: "category" as const, targetId: row.categoryId })),
|
|
2192
|
+
...written.brands.map((row) => ({ kind: "brand" as const, targetId: row.brandId })),
|
|
2193
|
+
...written.tags.map((row) => ({ kind: "tag" as const, targetId: row.tagId })),
|
|
2194
|
+
];
|
|
2195
|
+
if (record.length > 0) {
|
|
2196
|
+
await tx.insert(channelEntityLinks).values(record.map((row) => ({ organizationId: orgId, storeId, entityId, ...row }))).onConflictDoNothing();
|
|
2197
|
+
}
|
|
2198
|
+
const paths = new Set([...(linkFieldPaths(written).get(entityId) ?? []), ...(linkFieldPaths(removed).get(entityId) ?? [])]);
|
|
2135
2199
|
for (const row of written.placed) {
|
|
2136
2200
|
const previous = previousRoles.get(`${row.mediaAssetId}:${row.variantId}`);
|
|
2137
2201
|
if (previous !== undefined) paths.add(`media.${previous}`);
|
|
@@ -3254,6 +3318,15 @@ export class ChannelConnectorService {
|
|
|
3254
3318
|
return variantId === undefined ? [] : [{ organizationId: orgId, storeId, kind: "variant" as const, externalId: variant.externalId, entityId, variantId, syncHash: hash(variant), lastSyncedAt: now }];
|
|
3255
3319
|
}),
|
|
3256
3320
|
])).onConflictDoNothing();
|
|
3321
|
+
// Every category / brand / tag link on an entity this page just created was written by this
|
|
3322
|
+
// import, so all of it is this store's on record (`channel_entity_links`) — one statement.
|
|
3323
|
+
const created = sql.join(createdItems.map(({ entityId }) => sql`${entityId}::uuid`), sql`, `);
|
|
3324
|
+
await this.db.execute(sql`
|
|
3325
|
+
insert into ${channelEntityLinks} (organization_id, store_id, entity_id, kind, target_id)
|
|
3326
|
+
select ${orgId}, ${storeId}::uuid, entity_id, 'category', category_id from ${entityCategories} where entity_id in (${created})
|
|
3327
|
+
union all select ${orgId}, ${storeId}::uuid, entity_id, 'brand', brand_id from ${entityBrands} where entity_id in (${created})
|
|
3328
|
+
union all select ${orgId}, ${storeId}::uuid, entity_id, 'tag', tag_id from ${entityTags} where entity_id in (${created})
|
|
3329
|
+
on conflict do nothing`);
|
|
3257
3330
|
}
|
|
3258
3331
|
|
|
3259
3332
|
const media = await this.importHeroes(orgId, createdItems, actor);
|
|
@@ -3851,6 +3924,54 @@ export class ChannelConnectorService {
|
|
|
3851
3924
|
return Ok(stats);
|
|
3852
3925
|
}
|
|
3853
3926
|
|
|
3927
|
+
/**
|
|
3928
|
+
* Link provenance for products imported before `channel_entity_links` existed: a mapped product
|
|
3929
|
+
* of this batch with NOTHING on record for this store claims, once, the category / brand / tag
|
|
3930
|
+
* links it has that the store lists NOW. Runs for unchanged items too — an unchanged item skips
|
|
3931
|
+
* its converge, and the converge that follows may already be the drop, too late to claim.
|
|
3932
|
+
*
|
|
3933
|
+
* Its limit, stated rather than hidden: a link upstream had dropped BEFORE this claim was never
|
|
3934
|
+
* the store's on record, so it stays (the stale set the old add-only converge left; repaired by a
|
|
3935
|
+
* re-import, not by a heuristic delete). One select per batch; the claims only while unclaimed
|
|
3936
|
+
* products remain.
|
|
3937
|
+
*/
|
|
3938
|
+
private async claimUnrecordedLinks(orgId: string, storeId: string, items: ChannelCatalogItem[]): Promise<void> {
|
|
3939
|
+
// Nothing listed, nothing to claim: no query. Otherwise one select per converge call (a batch),
|
|
3940
|
+
// never one per product.
|
|
3941
|
+
if (!items.some((item) => (item.tags?.length ?? 0) > 0 || (item.categories?.length ?? 0) > 0 || item.brand !== undefined)) return;
|
|
3942
|
+
const unclaimed = await this.db.select({ externalId: channelEntityMap.externalId, entityId: channelEntityMap.entityId }).from(channelEntityMap).where(and(
|
|
3943
|
+
eq(channelEntityMap.organizationId, orgId),
|
|
3944
|
+
eq(channelEntityMap.storeId, storeId),
|
|
3945
|
+
eq(channelEntityMap.kind, "entity"),
|
|
3946
|
+
inArray(channelEntityMap.externalId, items.map((item) => item.externalId)),
|
|
3947
|
+
sql`not exists (select 1 from ${channelEntityLinks} where ${channelEntityLinks.storeId} = ${channelEntityMap.storeId} and ${channelEntityLinks.entityId} = ${channelEntityMap.entityId})`,
|
|
3948
|
+
));
|
|
3949
|
+
if (unclaimed.length === 0) return;
|
|
3950
|
+
const entityOf = new Map(unclaimed.map((row) => [row.externalId, row.entityId]));
|
|
3951
|
+
const listed = (pick: (item: ChannelCatalogItem) => readonly string[]) => items.flatMap((item) => {
|
|
3952
|
+
const entityId = entityOf.get(item.externalId);
|
|
3953
|
+
return entityId === undefined ? [] : [...new Set(pick(item))].map((slug) => sql`(${entityId}::uuid, ${slug}::text)`);
|
|
3954
|
+
});
|
|
3955
|
+
const claim = async (kind: "category" | "brand" | "tag", pairs: SQL[], link: SQL) => {
|
|
3956
|
+
if (pairs.length === 0) return;
|
|
3957
|
+
await this.db.execute(sql`
|
|
3958
|
+
insert into ${channelEntityLinks} (organization_id, store_id, entity_id, kind, target_id)
|
|
3959
|
+
select ${orgId}, ${storeId}::uuid, v.entity_id, ${kind}, t.id
|
|
3960
|
+
from (values ${sql.join(pairs, sql`, `)}) as v(entity_id, slug)
|
|
3961
|
+
${link}
|
|
3962
|
+
on conflict do nothing`);
|
|
3963
|
+
};
|
|
3964
|
+
await claim("category", listed((item) => item.categories ?? []),
|
|
3965
|
+
sql`join ${categories} t on t.slug = v.slug and t.organization_id = ${orgId}
|
|
3966
|
+
join ${entityCategories} l on l.entity_id = v.entity_id and l.category_id = t.id`);
|
|
3967
|
+
await claim("brand", listed((item) => (item.brand ? [item.brand] : [])),
|
|
3968
|
+
sql`join ${brands} t on t.slug = v.slug and t.organization_id = ${orgId}
|
|
3969
|
+
join ${entityBrands} l on l.entity_id = v.entity_id and l.brand_id = t.id`);
|
|
3970
|
+
await claim("tag", listed((item) => item.tags ?? []),
|
|
3971
|
+
sql`join ${tags} t on t.slug = v.slug and t.organization_id = ${orgId}
|
|
3972
|
+
join ${entityTags} l on l.entity_id = v.entity_id and l.tag_id = t.id`);
|
|
3973
|
+
}
|
|
3974
|
+
|
|
3854
3975
|
private async convergeCatalogItems(
|
|
3855
3976
|
orgId: string,
|
|
3856
3977
|
storeId: string,
|
|
@@ -3882,6 +4003,7 @@ export class ChannelConnectorService {
|
|
|
3882
4003
|
// Upstream SKU/barcode changes on already-mapped variants, for the whole batch at once, so that
|
|
3883
4004
|
// variants exchanging SKUs across products in this batch land together.
|
|
3884
4005
|
const identity = await this.applyUpstreamVariantIdentity(orgId, storeId, items);
|
|
4006
|
+
await this.claimUnrecordedLinks(orgId, storeId, items);
|
|
3885
4007
|
for (const item of items) {
|
|
3886
4008
|
consumed += 1;
|
|
3887
4009
|
try {
|
|
@@ -4095,7 +4217,8 @@ export class ChannelConnectorService {
|
|
|
4095
4217
|
if (!taxonomy.ok) { failures.push({ externalId: item.externalId, error: taxonomy.error }); continue; }
|
|
4096
4218
|
const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
|
|
4097
4219
|
if (!media.ok) return media;
|
|
4098
|
-
const
|
|
4220
|
+
const { listed, ...taxonomyLinks } = taxonomy.value;
|
|
4221
|
+
const links = await this.commitEntityLinks(orgId, storeId, entityId, { ...taxonomyLinks, ...media.value.links }, listed, media.value.previousRoles, isNew, actor);
|
|
4099
4222
|
if (!links.ok) { failures.push({ externalId: item.externalId, error: links.error }); continue; }
|
|
4100
4223
|
attributesCreated += attributes.value.created;
|
|
4101
4224
|
mediaImported += media.value.imported;
|