@porulle/plugin-channel-connector 0.50.0 → 0.50.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +5 -1
- package/dist/service.d.ts +9 -0
- package/dist/service.js +71 -21
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/index.ts +5 -1
- package/src/service.ts +90 -17
package/dist/index.js
CHANGED
|
@@ -151,7 +151,11 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
151
151
|
handler: async ({ input, ctx }) => {
|
|
152
152
|
const orgId = String(input.orgId);
|
|
153
153
|
const jobs = ctx.services.jobs;
|
|
154
|
-
|
|
154
|
+
// Only stores a registered connector can reach: a reconcile for any other provider (the
|
|
155
|
+
// seed's `manual` point-of-sale store) can only fail with "No connector registered".
|
|
156
|
+
const providers = new Set((options.connectors ?? []).map((connector) => connector.providerId));
|
|
157
|
+
const stores = (await ctx.db.select().from(connectedStores).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.status, "connected"))))
|
|
158
|
+
.filter((store) => providers.has(store.provider));
|
|
155
159
|
const window = options.reconcileJitterWindowMs ?? 60 * 60 * 1000;
|
|
156
160
|
for (const store of stores) {
|
|
157
161
|
const offset = createHash("sha256").update(store.id).digest().readUInt32BE(0) % window;
|
package/dist/service.d.ts
CHANGED
|
@@ -42,6 +42,9 @@ export interface ReconcileReport extends Record<string, unknown> {
|
|
|
42
42
|
skipped?: CatalogFieldSkip[];
|
|
43
43
|
conflicts?: CatalogFieldConflict[];
|
|
44
44
|
warnings?: string[];
|
|
45
|
+
/** Items this reconcile could not converge, each with its error. A reconcile that dropped an
|
|
46
|
+
* item is never reported as a clean one. */
|
|
47
|
+
failures?: CatalogConvergenceFailure[];
|
|
45
48
|
}
|
|
46
49
|
export interface CatalogFieldConflict {
|
|
47
50
|
entityId: string;
|
|
@@ -333,6 +336,12 @@ export declare class ChannelConnectorService {
|
|
|
333
336
|
*/
|
|
334
337
|
private taxonomyCache;
|
|
335
338
|
private taxonomyFor;
|
|
339
|
+
/**
|
|
340
|
+
* Create a category or brand, or adopt the one another writer created first. The taxonomy
|
|
341
|
+
* snapshot is per converge, so two stores converging at once can both see a slug missing and both
|
|
342
|
+
* create it; the loser's conflict — returned or thrown — means the row it wanted now exists.
|
|
343
|
+
*/
|
|
344
|
+
private createTaxonomyOrAdopt;
|
|
336
345
|
private applyTaxonomy;
|
|
337
346
|
private applyMedia;
|
|
338
347
|
private getStoreRecord;
|
package/dist/service.js
CHANGED
|
@@ -3,7 +3,7 @@ import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr,
|
|
|
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, entityTags, 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, entityMedia, entityBrands, entityCategories, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
|
|
7
7
|
import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
|
|
8
8
|
import { mergeCatalogFieldMapping, normalizeCatalogFieldMapping, selectCatalogFieldMapping, } from "./catalog-field-mapping.js";
|
|
9
9
|
export const CATALOG_PUSH_BATCH_SIZES = {
|
|
@@ -973,6 +973,14 @@ export class ChannelConnectorService {
|
|
|
973
973
|
existingOptionValues.set(row.variantId, list);
|
|
974
974
|
}
|
|
975
975
|
}
|
|
976
|
+
// The product's plain base prices, read once. `setBasePrice` upserts and fires its hook even at
|
|
977
|
+
// the same amount, so an unchanged price is skipped here — and a price that IS written is what
|
|
978
|
+
// makes the variant "changed", which is what `converged` counts.
|
|
979
|
+
const storedPrices = item.variants.some((variant) => (variant.prices ?? []).length > 0)
|
|
980
|
+
? await this.db.select({ variantId: prices.variantId, currency: prices.currency, amount: prices.amount, compareAtAmount: prices.compareAtAmount })
|
|
981
|
+
.from(prices)
|
|
982
|
+
.where(and(eq(prices.organizationId, orgId), eq(prices.entityId, entityId), isNull(prices.customerGroupId), isNull(prices.minQuantity), isNull(prices.maxQuantity), isNull(prices.validFrom), isNull(prices.validUntil)))
|
|
983
|
+
: [];
|
|
976
984
|
for (const sourceVariant of item.variants) {
|
|
977
985
|
const fullSourceVariant = fullItem.variants.find((variant) => variant.externalId === sourceVariant.externalId) ?? sourceVariant;
|
|
978
986
|
let mapping = mappings.find((row) => row.externalId === sourceVariant.externalId);
|
|
@@ -1081,6 +1089,10 @@ export class ChannelConnectorService {
|
|
|
1081
1089
|
}
|
|
1082
1090
|
}
|
|
1083
1091
|
for (const price of sourceVariant.prices ?? []) {
|
|
1092
|
+
const currency = price.currency.trim().toUpperCase();
|
|
1093
|
+
const stored = storedPrices.find((row) => row.variantId === variantId && row.currency === currency);
|
|
1094
|
+
if (stored && stored.amount === price.amount && stored.compareAtAmount === (price.compareAtAmount ?? null))
|
|
1095
|
+
continue;
|
|
1084
1096
|
const priced = await this.pricing.setBasePrice({
|
|
1085
1097
|
entityId,
|
|
1086
1098
|
variantId,
|
|
@@ -1090,6 +1102,7 @@ export class ChannelConnectorService {
|
|
|
1090
1102
|
}, actor);
|
|
1091
1103
|
if (!priced.ok)
|
|
1092
1104
|
return PluginErr(priced.error.message);
|
|
1105
|
+
changed = true;
|
|
1093
1106
|
}
|
|
1094
1107
|
// Was UNCONDITIONAL: one UPDATE per variant on every import, including a re-sync where the
|
|
1095
1108
|
// variant is byte-identical. `mapping.syncHash` is already in hand from the select above, so
|
|
@@ -1139,8 +1152,39 @@ export class ChannelConnectorService {
|
|
|
1139
1152
|
this.taxonomyCache = { orgId, categories: categoryRows, brands: brandRows, tags: tagRows };
|
|
1140
1153
|
return this.taxonomyCache;
|
|
1141
1154
|
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Create a category or brand, or adopt the one another writer created first. The taxonomy
|
|
1157
|
+
* snapshot is per converge, so two stores converging at once can both see a slug missing and both
|
|
1158
|
+
* create it; the loser's conflict — returned or thrown — means the row it wanted now exists.
|
|
1159
|
+
*/
|
|
1160
|
+
async createTaxonomyOrAdopt(create, byId, bySlug, label) {
|
|
1161
|
+
let failure = `${label} was not persisted.`;
|
|
1162
|
+
try {
|
|
1163
|
+
const created = await create();
|
|
1164
|
+
if (created.ok) {
|
|
1165
|
+
const row = await byId(created.value.id);
|
|
1166
|
+
if (row !== undefined)
|
|
1167
|
+
return Ok(row);
|
|
1168
|
+
}
|
|
1169
|
+
else {
|
|
1170
|
+
failure = created.error;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
catch (error) {
|
|
1174
|
+
failure = error;
|
|
1175
|
+
}
|
|
1176
|
+
const existing = await bySlug();
|
|
1177
|
+
return existing !== undefined ? Ok(existing) : PluginErr(`${label}: ${errorMessage(failure)}`);
|
|
1178
|
+
}
|
|
1142
1179
|
async applyTaxonomy(orgId, entityId, item, actor, warnings) {
|
|
1143
1180
|
const taxonomy = await this.taxonomyFor(orgId);
|
|
1181
|
+
// The links this entity already has, read only for the classes the item names. A link already
|
|
1182
|
+
// there is not re-written, and a link that is added is what reports the taxonomy as changed.
|
|
1183
|
+
const linkedCategories = new Set((item.categories ?? []).length === 0 ? [] : (await this.db
|
|
1184
|
+
.select({ id: entityCategories.categoryId }).from(entityCategories).where(eq(entityCategories.entityId, entityId))).map((row) => row.id));
|
|
1185
|
+
const linkedBrands = new Set(!item.brand ? [] : (await this.db
|
|
1186
|
+
.select({ id: entityBrands.brandId }).from(entityBrands).where(eq(entityBrands.entityId, entityId))).map((row) => row.id));
|
|
1187
|
+
let changed = false;
|
|
1144
1188
|
const categoryRows = taxonomy.categories;
|
|
1145
1189
|
for (const slug of new Set(item.categories ?? [])) {
|
|
1146
1190
|
let category = categoryRows.find((row) => row.slug === slug);
|
|
@@ -1149,35 +1193,36 @@ export class ChannelConnectorService {
|
|
|
1149
1193
|
continue;
|
|
1150
1194
|
}
|
|
1151
1195
|
if (!category) {
|
|
1152
|
-
const created = await this.catalog.createCategory({ slug }, actor);
|
|
1196
|
+
const created = await this.createTaxonomyOrAdopt(() => this.catalog.createCategory({ slug }, actor), async (id) => (await this.db.select().from(categories).where(eq(categories.id, id)))[0], async () => (await this.db.select().from(categories).where(and(eq(categories.organizationId, orgId), eq(categories.slug, slug))))[0], `Category "${slug}"`);
|
|
1153
1197
|
if (!created.ok)
|
|
1154
|
-
return
|
|
1155
|
-
|
|
1156
|
-
if (!createdCategory)
|
|
1157
|
-
return PluginErr(`Category "${slug}" was not persisted.`);
|
|
1158
|
-
category = createdCategory;
|
|
1198
|
+
return created;
|
|
1199
|
+
category = created.value;
|
|
1159
1200
|
categoryRows.push(category);
|
|
1160
1201
|
}
|
|
1202
|
+
if (linkedCategories.has(category.id))
|
|
1203
|
+
continue;
|
|
1161
1204
|
const linked = await this.catalog.addToCategory(entityId, category.id, actor);
|
|
1162
1205
|
if (!linked.ok)
|
|
1163
1206
|
return PluginErr(linked.error.message);
|
|
1207
|
+
changed = true;
|
|
1164
1208
|
}
|
|
1165
1209
|
const brandRows = taxonomy.brands;
|
|
1166
1210
|
if (item.brand) {
|
|
1167
1211
|
let brand = brandRows.find((row) => row.slug === item.brand);
|
|
1168
1212
|
if (!brand) {
|
|
1169
|
-
const
|
|
1213
|
+
const brandSlug = item.brand;
|
|
1214
|
+
const created = await this.createTaxonomyOrAdopt(() => this.catalog.createBrand({ slug: brandSlug, displayName: brandSlug }, actor), async (id) => (await this.db.select().from(brands).where(eq(brands.id, id)))[0], async () => (await this.db.select().from(brands).where(and(eq(brands.organizationId, orgId), eq(brands.slug, brandSlug))))[0], `Brand "${brandSlug}"`);
|
|
1170
1215
|
if (!created.ok)
|
|
1171
|
-
return
|
|
1172
|
-
|
|
1173
|
-
if (!createdBrand)
|
|
1174
|
-
return PluginErr(`Brand "${item.brand}" was not persisted.`);
|
|
1175
|
-
brand = createdBrand;
|
|
1216
|
+
return created;
|
|
1217
|
+
brand = created.value;
|
|
1176
1218
|
brandRows.push(brand);
|
|
1177
1219
|
}
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1220
|
+
if (!linkedBrands.has(brand.id)) {
|
|
1221
|
+
const linked = await this.catalog.addToBrand(entityId, brand.id, actor);
|
|
1222
|
+
if (!linked.ok)
|
|
1223
|
+
return PluginErr(linked.error.message);
|
|
1224
|
+
changed = true;
|
|
1225
|
+
}
|
|
1181
1226
|
}
|
|
1182
1227
|
const tagRows = taxonomy.tags;
|
|
1183
1228
|
for (const slug of new Set(item.tags ?? [])) {
|
|
@@ -1189,9 +1234,11 @@ export class ChannelConnectorService {
|
|
|
1189
1234
|
return PluginErr(`Tag "${slug}" was not persisted.`);
|
|
1190
1235
|
tagRows.push(tag);
|
|
1191
1236
|
}
|
|
1192
|
-
await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing();
|
|
1237
|
+
const added = await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing().returning({ tagId: entityTags.tagId });
|
|
1238
|
+
if (added.length > 0)
|
|
1239
|
+
changed = true;
|
|
1193
1240
|
}
|
|
1194
|
-
return Ok(
|
|
1241
|
+
return Ok({ changed });
|
|
1195
1242
|
}
|
|
1196
1243
|
async applyMedia(orgId, entityId, item, variantIds, actor, warnings, owners) {
|
|
1197
1244
|
const images = item.images ?? [];
|
|
@@ -2877,8 +2924,10 @@ export class ChannelConnectorService {
|
|
|
2877
2924
|
continue;
|
|
2878
2925
|
}
|
|
2879
2926
|
const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
|
|
2880
|
-
if (!taxonomy.ok)
|
|
2881
|
-
|
|
2927
|
+
if (!taxonomy.ok) {
|
|
2928
|
+
failures.push({ externalId: item.externalId, error: taxonomy.error });
|
|
2929
|
+
continue;
|
|
2930
|
+
}
|
|
2882
2931
|
const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
|
|
2883
2932
|
if (!media.ok)
|
|
2884
2933
|
return media;
|
|
@@ -2886,7 +2935,7 @@ export class ChannelConnectorService {
|
|
|
2886
2935
|
mediaImported += media.value.imported;
|
|
2887
2936
|
variantsGivenOptionValues += variantIds.value.repaired;
|
|
2888
2937
|
skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
|
|
2889
|
-
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
|
|
2938
|
+
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || taxonomy.value.changed || media.value.changed || attributes.value.changed;
|
|
2890
2939
|
if (entityTouched)
|
|
2891
2940
|
entitiesTouched += 1;
|
|
2892
2941
|
// Counted from what was written, not from the stored hash: a hash that moved while the
|
|
@@ -3041,6 +3090,7 @@ export class ChannelConnectorService {
|
|
|
3041
3090
|
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
3042
3091
|
...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
|
|
3043
3092
|
...(converged.value.warnings.length > 0 ? { warnings: converged.value.warnings } : {}),
|
|
3093
|
+
...(converged.value.failures.length > 0 ? { failures: converged.value.failures } : {}),
|
|
3044
3094
|
};
|
|
3045
3095
|
await this.db.update(connectedStores).set({
|
|
3046
3096
|
lastReconcileAt: new Date(),
|