@porulle/plugin-channel-connector 0.50.1 → 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.1",
3
+ "version": "0.50.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,7 +19,7 @@
19
19
  "dependencies": {
20
20
  "@hono/zod-openapi": "^1.2.2",
21
21
  "hono": "^4.12.5",
22
- "@porulle/core": "0.50.1"
22
+ "@porulle/core": "0.50.2"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^24.5.2",
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
@@ -167,6 +167,9 @@ export interface ReconcileReport extends Record<string, unknown> {
167
167
  skipped?: CatalogFieldSkip[];
168
168
  conflicts?: CatalogFieldConflict[];
169
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[];
170
173
  }
171
174
 
172
175
  export interface CatalogFieldConflict {
@@ -1828,6 +1831,33 @@ export class ChannelConnectorService {
1828
1831
  return this.taxonomyCache;
1829
1832
  }
1830
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
+
1831
1861
  private async applyTaxonomy(
1832
1862
  orgId: string,
1833
1863
  entityId: string,
@@ -1851,11 +1881,14 @@ export class ChannelConnectorService {
1851
1881
  continue;
1852
1882
  }
1853
1883
  if (!category) {
1854
- const created = await this.catalog.createCategory({ slug }, actor);
1855
- if (!created.ok) return PluginErr(created.error.message);
1856
- const [createdCategory] = await this.db.select().from(categories).where(eq(categories.id, created.value.id));
1857
- if (!createdCategory) return PluginErr(`Category "${slug}" was not persisted.`);
1858
- 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;
1859
1892
  categoryRows.push(category);
1860
1893
  }
1861
1894
  if (linkedCategories.has(category.id)) continue;
@@ -1868,11 +1901,15 @@ export class ChannelConnectorService {
1868
1901
  if (item.brand) {
1869
1902
  let brand = brandRows.find((row) => row.slug === item.brand);
1870
1903
  if (!brand) {
1871
- const created = await this.catalog.createBrand({ slug: item.brand, displayName: item.brand }, actor);
1872
- if (!created.ok) return PluginErr(created.error.message);
1873
- const [createdBrand] = await this.db.select().from(brands).where(eq(brands.id, created.value.id));
1874
- if (!createdBrand) return PluginErr(`Brand "${item.brand}" was not persisted.`);
1875
- 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;
1876
1913
  brandRows.push(brand);
1877
1914
  }
1878
1915
  if (!linkedBrands.has(brand.id)) {
@@ -3850,7 +3887,7 @@ export class ChannelConnectorService {
3850
3887
  // that item's failure, reported with its error — not a page error the caller would retry.
3851
3888
  if (!variantIds.ok) { failures.push({ externalId: item.externalId, error: variantIds.error }); continue; }
3852
3889
  const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
3853
- if (!taxonomy.ok) return taxonomy;
3890
+ if (!taxonomy.ok) { failures.push({ externalId: item.externalId, error: taxonomy.error }); continue; }
3854
3891
  const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
3855
3892
  if (!media.ok) return media;
3856
3893
  attributesCreated += attributes.value.created;
@@ -4025,6 +4062,7 @@ export class ChannelConnectorService {
4025
4062
  ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
4026
4063
  ...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
4027
4064
  ...(converged.value.warnings.length > 0 ? { warnings: converged.value.warnings } : {}),
4065
+ ...(converged.value.failures.length > 0 ? { failures: converged.value.failures } : {}),
4028
4066
  };
4029
4067
  await this.db.update(connectedStores).set({
4030
4068
  lastReconcileAt: new Date(),