@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.50.0",
3
+ "version": "0.50.2",
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.50.0"
22
+ "@porulle/core": "0.50.2"
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/typescript-config": "0.1.0",
30
- "@porulle/eslint-config": "0.1.0"
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
@@ -308,7 +308,11 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
308
308
  handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
309
309
  const orgId = String(input.orgId);
310
310
  const jobs = ctx.services.jobs as JobsAdapter;
311
- const stores = await ctx.db.select().from(connectedStores).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.status, "connected")));
311
+ // Only stores a registered connector can reach: a reconcile for any other provider (the
312
+ // seed's `manual` point-of-sale store) can only fail with "No connector registered".
313
+ const providers = new Set((options.connectors ?? []).map((connector) => connector.providerId));
314
+ const stores = (await ctx.db.select().from(connectedStores).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.status, "connected"))))
315
+ .filter((store) => providers.has(store.provider));
312
316
  const window = options.reconcileJitterWindowMs ?? 60 * 60 * 1000;
313
317
  for (const store of stores) {
314
318
  const offset = createHash("sha256").update(store.id).digest().readUInt32BE(0) % window;
package/src/service.ts CHANGED
@@ -40,6 +40,8 @@ import {
40
40
  customerAddresses,
41
41
  customers,
42
42
  entityMedia,
43
+ entityBrands,
44
+ entityCategories,
43
45
  entityTags,
44
46
  inventoryLevels,
45
47
  mediaAssets,
@@ -165,6 +167,9 @@ export interface ReconcileReport extends Record<string, unknown> {
165
167
  skipped?: CatalogFieldSkip[];
166
168
  conflicts?: CatalogFieldConflict[];
167
169
  warnings?: string[];
170
+ /** Items this reconcile could not converge, each with its error. A reconcile that dropped an
171
+ * item is never reported as a clean one. */
172
+ failures?: CatalogConvergenceFailure[];
168
173
  }
169
174
 
170
175
  export interface CatalogFieldConflict {
@@ -1633,6 +1638,22 @@ export class ChannelConnectorService {
1633
1638
  existingOptionValues.set(row.variantId, list);
1634
1639
  }
1635
1640
  }
1641
+ // The product's plain base prices, read once. `setBasePrice` upserts and fires its hook even at
1642
+ // the same amount, so an unchanged price is skipped here — and a price that IS written is what
1643
+ // makes the variant "changed", which is what `converged` counts.
1644
+ const storedPrices = item.variants.some((variant) => (variant.prices ?? []).length > 0)
1645
+ ? await this.db.select({ variantId: prices.variantId, currency: prices.currency, amount: prices.amount, compareAtAmount: prices.compareAtAmount })
1646
+ .from(prices)
1647
+ .where(and(
1648
+ eq(prices.organizationId, orgId),
1649
+ eq(prices.entityId, entityId),
1650
+ isNull(prices.customerGroupId),
1651
+ isNull(prices.minQuantity),
1652
+ isNull(prices.maxQuantity),
1653
+ isNull(prices.validFrom),
1654
+ isNull(prices.validUntil),
1655
+ ))
1656
+ : [];
1636
1657
  for (const sourceVariant of item.variants) {
1637
1658
  const fullSourceVariant = fullItem.variants.find((variant) => variant.externalId === sourceVariant.externalId) ?? sourceVariant;
1638
1659
  let mapping = mappings.find((row) => row.externalId === sourceVariant.externalId);
@@ -1738,6 +1759,9 @@ export class ChannelConnectorService {
1738
1759
  }
1739
1760
  }
1740
1761
  for (const price of sourceVariant.prices ?? []) {
1762
+ const currency = price.currency.trim().toUpperCase();
1763
+ const stored = storedPrices.find((row) => row.variantId === variantId && row.currency === currency);
1764
+ if (stored && stored.amount === price.amount && stored.compareAtAmount === (price.compareAtAmount ?? null)) continue;
1741
1765
  const priced = await this.pricing.setBasePrice({
1742
1766
  entityId,
1743
1767
  variantId,
@@ -1746,6 +1770,7 @@ export class ChannelConnectorService {
1746
1770
  compareAtAmount: price.compareAtAmount ?? null,
1747
1771
  }, actor);
1748
1772
  if (!priced.ok) return PluginErr(priced.error.message);
1773
+ changed = true;
1749
1774
  }
1750
1775
  // Was UNCONDITIONAL: one UPDATE per variant on every import, including a re-sync where the
1751
1776
  // variant is byte-identical. `mapping.syncHash` is already in hand from the select above, so
@@ -1806,14 +1831,48 @@ export class ChannelConnectorService {
1806
1831
  return this.taxonomyCache;
1807
1832
  }
1808
1833
 
1834
+ /**
1835
+ * Create a category or brand, or adopt the one another writer created first. The taxonomy
1836
+ * snapshot is per converge, so two stores converging at once can both see a slug missing and both
1837
+ * create it; the loser's conflict — returned or thrown — means the row it wanted now exists.
1838
+ */
1839
+ private async createTaxonomyOrAdopt<Row>(
1840
+ create: () => Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>,
1841
+ byId: (id: string) => Promise<Row | undefined>,
1842
+ bySlug: () => Promise<Row | undefined>,
1843
+ label: string,
1844
+ ): Promise<PluginResult<Row>> {
1845
+ let failure: unknown = `${label} was not persisted.`;
1846
+ try {
1847
+ const created = await create();
1848
+ if (created.ok) {
1849
+ const row = await byId(created.value.id);
1850
+ if (row !== undefined) return Ok(row);
1851
+ } else {
1852
+ failure = created.error;
1853
+ }
1854
+ } catch (error) {
1855
+ failure = error;
1856
+ }
1857
+ const existing = await bySlug();
1858
+ return existing !== undefined ? Ok(existing) : PluginErr(`${label}: ${errorMessage(failure)}`);
1859
+ }
1860
+
1809
1861
  private async applyTaxonomy(
1810
1862
  orgId: string,
1811
1863
  entityId: string,
1812
1864
  item: ChannelCatalogItem,
1813
1865
  actor: Actor,
1814
1866
  warnings: string[],
1815
- ): Promise<PluginResult<void>> {
1867
+ ): Promise<PluginResult<{ changed: boolean }>> {
1816
1868
  const taxonomy = await this.taxonomyFor(orgId);
1869
+ // The links this entity already has, read only for the classes the item names. A link already
1870
+ // there is not re-written, and a link that is added is what reports the taxonomy as changed.
1871
+ const linkedCategories = new Set((item.categories ?? []).length === 0 ? [] : (await this.db
1872
+ .select({ id: entityCategories.categoryId }).from(entityCategories).where(eq(entityCategories.entityId, entityId))).map((row) => row.id));
1873
+ const linkedBrands = new Set(!item.brand ? [] : (await this.db
1874
+ .select({ id: entityBrands.brandId }).from(entityBrands).where(eq(entityBrands.entityId, entityId))).map((row) => row.id));
1875
+ let changed = false;
1817
1876
  const categoryRows = taxonomy.categories;
1818
1877
  for (const slug of new Set(item.categories ?? [])) {
1819
1878
  let category = categoryRows.find((row) => row.slug === slug);
@@ -1822,30 +1881,42 @@ export class ChannelConnectorService {
1822
1881
  continue;
1823
1882
  }
1824
1883
  if (!category) {
1825
- const created = await this.catalog.createCategory({ slug }, actor);
1826
- if (!created.ok) return PluginErr(created.error.message);
1827
- const [createdCategory] = await this.db.select().from(categories).where(eq(categories.id, created.value.id));
1828
- if (!createdCategory) return PluginErr(`Category "${slug}" was not persisted.`);
1829
- category = createdCategory;
1884
+ const created = await this.createTaxonomyOrAdopt(
1885
+ () => this.catalog.createCategory({ slug }, actor),
1886
+ async (id) => (await this.db.select().from(categories).where(eq(categories.id, id)))[0],
1887
+ async () => (await this.db.select().from(categories).where(and(eq(categories.organizationId, orgId), eq(categories.slug, slug))))[0],
1888
+ `Category "${slug}"`,
1889
+ );
1890
+ if (!created.ok) return created;
1891
+ category = created.value;
1830
1892
  categoryRows.push(category);
1831
1893
  }
1894
+ if (linkedCategories.has(category.id)) continue;
1832
1895
  const linked = await this.catalog.addToCategory(entityId, category.id, actor);
1833
1896
  if (!linked.ok) return PluginErr(linked.error.message);
1897
+ changed = true;
1834
1898
  }
1835
1899
 
1836
1900
  const brandRows = taxonomy.brands;
1837
1901
  if (item.brand) {
1838
1902
  let brand = brandRows.find((row) => row.slug === item.brand);
1839
1903
  if (!brand) {
1840
- const created = await this.catalog.createBrand({ slug: item.brand, displayName: item.brand }, actor);
1841
- if (!created.ok) return PluginErr(created.error.message);
1842
- const [createdBrand] = await this.db.select().from(brands).where(eq(brands.id, created.value.id));
1843
- if (!createdBrand) return PluginErr(`Brand "${item.brand}" was not persisted.`);
1844
- brand = createdBrand;
1904
+ const brandSlug = item.brand;
1905
+ const created = await this.createTaxonomyOrAdopt(
1906
+ () => this.catalog.createBrand({ slug: brandSlug, displayName: brandSlug }, actor),
1907
+ async (id) => (await this.db.select().from(brands).where(eq(brands.id, id)))[0],
1908
+ async () => (await this.db.select().from(brands).where(and(eq(brands.organizationId, orgId), eq(brands.slug, brandSlug))))[0],
1909
+ `Brand "${brandSlug}"`,
1910
+ );
1911
+ if (!created.ok) return created;
1912
+ brand = created.value;
1845
1913
  brandRows.push(brand);
1846
1914
  }
1847
- const linked = await this.catalog.addToBrand(entityId, brand.id, actor);
1848
- if (!linked.ok) return PluginErr(linked.error.message);
1915
+ if (!linkedBrands.has(brand.id)) {
1916
+ const linked = await this.catalog.addToBrand(entityId, brand.id, actor);
1917
+ if (!linked.ok) return PluginErr(linked.error.message);
1918
+ changed = true;
1919
+ }
1849
1920
  }
1850
1921
 
1851
1922
  const tagRows = taxonomy.tags;
@@ -1860,9 +1931,10 @@ export class ChannelConnectorService {
1860
1931
  if (!tag) return PluginErr(`Tag "${slug}" was not persisted.`);
1861
1932
  tagRows.push(tag);
1862
1933
  }
1863
- await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing();
1934
+ const added = await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing().returning({ tagId: entityTags.tagId });
1935
+ if (added.length > 0) changed = true;
1864
1936
  }
1865
- return Ok(undefined);
1937
+ return Ok({ changed });
1866
1938
  }
1867
1939
 
1868
1940
  private async applyMedia(
@@ -3815,14 +3887,14 @@ export class ChannelConnectorService {
3815
3887
  // that item's failure, reported with its error — not a page error the caller would retry.
3816
3888
  if (!variantIds.ok) { failures.push({ externalId: item.externalId, error: variantIds.error }); continue; }
3817
3889
  const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
3818
- if (!taxonomy.ok) return taxonomy;
3890
+ if (!taxonomy.ok) { failures.push({ externalId: item.externalId, error: taxonomy.error }); continue; }
3819
3891
  const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
3820
3892
  if (!media.ok) return media;
3821
3893
  attributesCreated += attributes.value.created;
3822
3894
  mediaImported += media.value.imported;
3823
3895
  variantsGivenOptionValues += variantIds.value.repaired;
3824
3896
  skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
3825
- entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
3897
+ entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || taxonomy.value.changed || media.value.changed || attributes.value.changed;
3826
3898
  if (entityTouched) entitiesTouched += 1;
3827
3899
  // Counted from what was written, not from the stored hash: a hash that moved while the
3828
3900
  // product did not (a blanked map row, a change in how an item serialises) is not drift.
@@ -3990,6 +4062,7 @@ export class ChannelConnectorService {
3990
4062
  ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
3991
4063
  ...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
3992
4064
  ...(converged.value.warnings.length > 0 ? { warnings: converged.value.warnings } : {}),
4065
+ ...(converged.value.failures.length > 0 ? { failures: converged.value.failures } : {}),
3993
4066
  };
3994
4067
  await this.db.update(connectedStores).set({
3995
4068
  lastReconcileAt: new Date(),