@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 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
- const stores = await ctx.db.select().from(connectedStores).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.status, "connected")));
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 PluginErr(created.error.message);
1155
- const [createdCategory] = await this.db.select().from(categories).where(eq(categories.id, created.value.id));
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 created = await this.catalog.createBrand({ slug: item.brand, displayName: item.brand }, actor);
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 PluginErr(created.error.message);
1172
- const [createdBrand] = await this.db.select().from(brands).where(eq(brands.id, created.value.id));
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
- const linked = await this.catalog.addToBrand(entityId, brand.id, actor);
1179
- if (!linked.ok)
1180
- return PluginErr(linked.error.message);
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(undefined);
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
- return taxonomy;
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(),