@porulle/plugin-channel-connector 0.74.1 → 0.74.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.
@@ -15,11 +15,11 @@ import type { ChannelConnector, ChannelConnectorError, ChannelStore, PluginDb, R
15
15
  * token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
16
16
  * later call with a credential error nobody is shown.
17
17
  */
18
+ /** Why a store whose key the provider refused is in `error`. */
19
+ export declare const CREDENTIALS_REJECTED_REASON = "The store no longer accepts the key it gave us (it was revoked, or the user who approved it was removed). Reconnect the store.";
18
20
  export declare function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore, options?: {
19
21
  force?: boolean;
20
22
  }): Promise<Result<ChannelStore, ChannelConnectorError>>;
21
- /** Why a store whose key the provider refused is in `error`. */
22
- export declare const CREDENTIALS_REJECTED_REASON = "The store no longer accepts the key it gave us (it was revoked, or the user who approved it was removed). Reconnect the store.";
23
23
  /**
24
24
  * The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
25
25
  * call site can start on a lapsed token by forgetting to ask.
@@ -17,13 +17,16 @@ import { connectedStores } from "./schema.js";
17
17
  * token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
18
18
  * later call with a credential error nobody is shown.
19
19
  */
20
+ /** Why a store whose key the provider refused is in `error`. */
21
+ export const CREDENTIALS_REJECTED_REASON = "The store no longer accepts the key it gave us (it was revoked, or the user who approved it was removed). Reconnect the store.";
20
22
  export async function resolveLiveCredentials(connector, db, store, options = {}) {
21
23
  if (!connector.liveCredentials)
22
24
  return Ok(store);
23
25
  const answer = await connector.liveCredentials(store, options);
24
26
  if (!answer.ok) {
25
27
  if (answer.error.retriable !== true) {
26
- await db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
28
+ const statusReason = answer.error.code === CHANNEL_CREDENTIALS_REJECTED ? CREDENTIALS_REJECTED_REASON : answer.error.message;
29
+ await db.update(connectedStores).set({ status: "error", statusReason, updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
27
30
  }
28
31
  return answer;
29
32
  }
@@ -40,8 +43,6 @@ export async function resolveLiveCredentials(connector, db, store, options = {})
40
43
  return Err({ code: "STORE_NOT_FOUND", message: `Connected store ${store.id} no longer exists.`, retriable: false });
41
44
  return Ok({ ...store, credentials: current.credentials });
42
45
  }
43
- /** Why a store whose key the provider refused is in `error`. */
44
- export const CREDENTIALS_REJECTED_REASON = "The store no longer accepts the key it gave us (it was revoked, or the user who approved it was removed). Reconnect the store.";
45
46
  async function markCredentialsRejected(db, storeId) {
46
47
  await db.update(connectedStores).set({ status: "error", statusReason: CREDENTIALS_REJECTED_REASON, updatedAt: new Date() }).where(eq(connectedStores.id, storeId));
47
48
  }
package/dist/service.d.ts CHANGED
@@ -701,6 +701,11 @@ export declare class ChannelConnectorService {
701
701
  private orderForRemote;
702
702
  /** Archives this store's product mapped to `externalId`, unless the platform owns its status. */
703
703
  private archiveMappedProduct;
704
+ /**
705
+ * Stock is a variant's. A provider can name a product and its only variant by the same id (a
706
+ * WooCommerce simple product is both), so the variant mapping wins over the entity one, as the
707
+ * paged sync's does; an entity mapping alone (a product imported with no variants) still takes it.
708
+ */
704
709
  private setMappedInventory;
705
710
  private setInventoryLevel;
706
711
  private createRefundRequest;
@@ -760,6 +765,11 @@ export declare class ChannelConnectorService {
760
765
  }>;
761
766
  getExport(orgId: string, id: string): Promise<PluginResult<ChannelOrderExport>>;
762
767
  listFailedExports(orgId: string): Promise<PluginResult<ChannelOrderExport[]>>;
768
+ /**
769
+ * The operator's retry of a failed export: it runs the push again, as a job. The connector looks for
770
+ * an order it may already have created before creating one (an unclear first answer is exactly what
771
+ * left the export failed), so a retry never puts a second order in the store.
772
+ */
763
773
  retryExport(orgId: string, exportId: string, changedBy: string): Promise<PluginResult<ChannelOrderExport>>;
764
774
  abandonExport(orgId: string, exportId: string, changedBy: string, reason?: string): Promise<PluginResult<ChannelOrderExport>>;
765
775
  resolveCatalogPushEntityIds(orgId: string, storeId: string, entityIds?: string[]): Promise<string[]>;
package/dist/service.js CHANGED
@@ -3607,7 +3607,9 @@ export class ChannelConnectorService {
3607
3607
  const inventoryService = this.services.inventory;
3608
3608
  let inventoryUpdated = 0;
3609
3609
  for (const level of inventory.value) {
3610
- const mapping = levelled.find((entry) => entry.externalId === level.externalId);
3610
+ // A product and its only variant can share an id (a WooCommerce simple product): stock is the variant's.
3611
+ const mapping = levelled.find((entry) => entry.externalId === level.externalId && entry.kind === "variant")
3612
+ ?? levelled.find((entry) => entry.externalId === level.externalId);
3611
3613
  if (!mapping)
3612
3614
  continue;
3613
3615
  const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
@@ -4144,8 +4146,14 @@ export class ChannelConnectorService {
4144
4146
  const archived = await this.catalog.archive(mapping.entityId, actor);
4145
4147
  return archived.ok ? Ok([]) : PluginErr(archived.error.message);
4146
4148
  }
4149
+ /**
4150
+ * Stock is a variant's. A provider can name a product and its only variant by the same id (a
4151
+ * WooCommerce simple product is both), so the variant mapping wins over the entity one, as the
4152
+ * paged sync's does; an entity mapping alone (a product imported with no variants) still takes it.
4153
+ */
4147
4154
  async setMappedInventory(orgId, storeId, externalId, quantity, actor) {
4148
- const [mapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.externalId, externalId)));
4155
+ const rows = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.externalId, externalId)));
4156
+ const mapping = rows.find((row) => row.kind === "variant") ?? rows[0];
4149
4157
  if (!mapping)
4150
4158
  return;
4151
4159
  await this.setInventoryLevel(mapping.entityId, mapping.variantId, quantity, actor);
@@ -4601,8 +4609,21 @@ export class ChannelConnectorService {
4601
4609
  .where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.state, "failed")));
4602
4610
  return Ok(rows);
4603
4611
  }
4604
- retryExport(orgId, exportId, changedBy) {
4605
- return this.transitionExport(orgId, exportId, "exported", changedBy, "Manual retry requested.");
4612
+ /**
4613
+ * The operator's retry of a failed export: it runs the push again, as a job. The connector looks for
4614
+ * an order it may already have created before creating one (an unclear first answer is exactly what
4615
+ * left the export failed), so a retry never puts a second order in the store.
4616
+ */
4617
+ async retryExport(orgId, exportId, changedBy) {
4618
+ const moved = await this.transitionExport(orgId, exportId, "exported", changedBy, "Manual retry requested.");
4619
+ if (!moved.ok)
4620
+ return moved;
4621
+ const jobs = this.services.jobs;
4622
+ if (!jobs)
4623
+ return PluginErr("No jobs adapter is configured, so the retry cannot run.", "JOBS_UNAVAILABLE");
4624
+ const { storeId, orderId } = moved.value;
4625
+ await jobs.enqueue("channel/push-order", { orgId, storeId, orderId }, { organizationId: orgId, concurrencyKey: `push:${orderId}:${storeId}`, supersedes: true });
4626
+ return moved;
4606
4627
  }
4607
4628
  abandonExport(orgId, exportId, changedBy, reason) {
4608
4629
  return this.transitionExport(orgId, exportId, "abandoned", changedBy, reason);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.74.1",
3
+ "version": "0.74.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -22,15 +22,15 @@
22
22
  "dependencies": {
23
23
  "@hono/zod-openapi": "^1.2.2",
24
24
  "hono": "^4.12.5",
25
- "@porulle/core": "0.74.1"
25
+ "@porulle/core": "0.74.2"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^24.5.2",
29
29
  "eslint": "^9.39.1",
30
30
  "typescript": "5.9.2",
31
31
  "vitest": "^3.2.4",
32
- "@porulle/typescript-config": "0.1.0",
33
- "@porulle/eslint-config": "0.1.0"
32
+ "@porulle/eslint-config": "0.1.0",
33
+ "@porulle/typescript-config": "0.1.0"
34
34
  },
35
35
  "publishConfig": {
36
36
  "access": "public"
@@ -19,12 +19,16 @@ import { connectedStores } from "./schema.js";
19
19
  * token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
20
20
  * later call with a credential error nobody is shown.
21
21
  */
22
+ /** Why a store whose key the provider refused is in `error`. */
23
+ export const CREDENTIALS_REJECTED_REASON = "The store no longer accepts the key it gave us (it was revoked, or the user who approved it was removed). Reconnect the store.";
24
+
22
25
  export async function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore, options: { force?: boolean } = {}): Promise<Result<ChannelStore, ChannelConnectorError>> {
23
26
  if (!connector.liveCredentials) return Ok(store);
24
27
  const answer = await connector.liveCredentials(store, options);
25
28
  if (!answer.ok) {
26
29
  if (answer.error.retriable !== true) {
27
- await db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
30
+ const statusReason = answer.error.code === CHANNEL_CREDENTIALS_REJECTED ? CREDENTIALS_REJECTED_REASON : answer.error.message;
31
+ await db.update(connectedStores).set({ status: "error", statusReason, updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
28
32
  }
29
33
  return answer;
30
34
  }
@@ -41,9 +45,6 @@ export async function resolveLiveCredentials(connector: ChannelConnector, db: Pl
41
45
 
42
46
  type StoreCall<A extends unknown[], T> = (store: ChannelStore, ...args: A) => Promise<Result<T, ChannelConnectorError>>;
43
47
 
44
- /** Why a store whose key the provider refused is in `error`. */
45
- export const CREDENTIALS_REJECTED_REASON = "The store no longer accepts the key it gave us (it was revoked, or the user who approved it was removed). Reconnect the store.";
46
-
47
48
  async function markCredentialsRejected(db: PluginDb, storeId: string): Promise<void> {
48
49
  await db.update(connectedStores).set({ status: "error", statusReason: CREDENTIALS_REJECTED_REASON, updatedAt: new Date() }).where(eq(connectedStores.id, storeId));
49
50
  }
package/src/service.ts CHANGED
@@ -4698,7 +4698,9 @@ export class ChannelConnectorService {
4698
4698
  };
4699
4699
  let inventoryUpdated = 0;
4700
4700
  for (const level of inventory.value) {
4701
- const mapping = levelled.find((entry) => entry.externalId === level.externalId);
4701
+ // A product and its only variant can share an id (a WooCommerce simple product): stock is the variant's.
4702
+ const mapping = levelled.find((entry) => entry.externalId === level.externalId && entry.kind === "variant")
4703
+ ?? levelled.find((entry) => entry.externalId === level.externalId);
4702
4704
  if (!mapping) continue;
4703
4705
  const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
4704
4706
  // Stock cannot sit below zero here, so negative remote stock compares as the zero it is stored as.
@@ -5289,8 +5291,14 @@ export class ChannelConnectorService {
5289
5291
  return archived.ok ? Ok([]) : PluginErr(archived.error.message);
5290
5292
  }
5291
5293
 
5294
+ /**
5295
+ * Stock is a variant's. A provider can name a product and its only variant by the same id (a
5296
+ * WooCommerce simple product is both), so the variant mapping wins over the entity one, as the
5297
+ * paged sync's does; an entity mapping alone (a product imported with no variants) still takes it.
5298
+ */
5292
5299
  private async setMappedInventory(orgId: string, storeId: string, externalId: string, quantity: number, actor: Actor): Promise<void> {
5293
- const [mapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.externalId, externalId)));
5300
+ const rows = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.externalId, externalId)));
5301
+ const mapping = rows.find((row) => row.kind === "variant") ?? rows[0];
5294
5302
  if (!mapping) return;
5295
5303
  await this.setInventoryLevel(mapping.entityId, mapping.variantId, quantity, actor);
5296
5304
  }
@@ -5818,12 +5826,23 @@ export class ChannelConnectorService {
5818
5826
  return Ok(rows as ChannelOrderExport[]);
5819
5827
  }
5820
5828
 
5821
- retryExport(
5829
+ /**
5830
+ * The operator's retry of a failed export: it runs the push again, as a job. The connector looks for
5831
+ * an order it may already have created before creating one (an unclear first answer is exactly what
5832
+ * left the export failed), so a retry never puts a second order in the store.
5833
+ */
5834
+ async retryExport(
5822
5835
  orgId: string,
5823
5836
  exportId: string,
5824
5837
  changedBy: string,
5825
5838
  ): Promise<PluginResult<ChannelOrderExport>> {
5826
- return this.transitionExport(orgId, exportId, "exported", changedBy, "Manual retry requested.");
5839
+ const moved = await this.transitionExport(orgId, exportId, "exported", changedBy, "Manual retry requested.");
5840
+ if (!moved.ok) return moved;
5841
+ const jobs = this.services.jobs as JobsAdapter | undefined;
5842
+ if (!jobs) return PluginErr("No jobs adapter is configured, so the retry cannot run.", "JOBS_UNAVAILABLE");
5843
+ const { storeId, orderId } = moved.value;
5844
+ await jobs.enqueue("channel/push-order", { orgId, storeId, orderId }, { organizationId: orgId, concurrencyKey: `push:${orderId}:${storeId}`, supersedes: true });
5845
+ return moved;
5827
5846
  }
5828
5847
 
5829
5848
  abandonExport(