@porulle/plugin-channel-connector 0.67.0 → 0.68.1

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
@@ -361,13 +361,15 @@ export function channelConnectorPlugin(options = {}) {
361
361
  const userId = actor?.userId;
362
362
  if (!userId)
363
363
  return oauthError(403, "USER_REQUIRED", "Connecting a store needs a signed-in user.");
364
- const claims = await service.connectClaims({ orgId, actor, raw });
365
- if (!claims.ok)
366
- return connectOutcome(oauth.postConnectRedirect, { error: claims.code ?? "CONNECT_REFUSED", message: claims.error });
367
364
  const typed = String(query.shop ?? query.store ?? "");
368
365
  const storeDomain = connector.normalizeStoreDomain ? connector.normalizeStoreDomain(typed) : typed;
369
366
  if (!storeDomain)
370
367
  return connectOutcome(oauth.postConnectRedirect, { error: "INVALID_STORE_DOMAIN", message: `"${typed}" does not name a ${provider} store.` });
368
+ // Refused HERE, before the merchant reaches the provider: a grant issued for a shop retires
369
+ // that shop's other grants, so a refusal at the callback would already have broken them.
370
+ const claims = await service.connectClaims({ orgId, actor, raw, storeDomain });
371
+ if (!claims.ok)
372
+ return connectOutcome(oauth.postConnectRedirect, { error: claims.code ?? "CONNECT_REFUSED", message: claims.error });
371
373
  const state = signState({
372
374
  provider,
373
375
  orgId,
@@ -514,7 +516,9 @@ export function channelConnectorPlugin(options = {}) {
514
516
  webhookSecret: z.string().min(1).optional(),
515
517
  }))
516
518
  .handler(async ({ input, orgId, actor, raw }) => {
517
- const claims = unwrap(await service.connectClaims({ orgId, actor, raw }));
519
+ const requested = input;
520
+ const normalize = service.getConnector(requested.provider)?.normalizeStoreDomain;
521
+ const claims = unwrap(await service.connectClaims({ orgId, actor, raw, storeDomain: (normalize ? normalize(requested.storeDomain) : undefined) ?? requested.storeDomain }));
518
522
  return unwrap(await service.connectStore(orgId, input, { orgId, userId: actor?.userId ?? null, claims, raw }));
519
523
  });
520
524
  channels.get("/stores")
@@ -15,10 +15,15 @@ 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
- export declare function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore): Promise<Result<ChannelStore, ChannelConnectorError>>;
18
+ export declare function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore, options?: {
19
+ force?: boolean;
20
+ }): Promise<Result<ChannelStore, ChannelConnectorError>>;
19
21
  /**
20
22
  * The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
21
23
  * call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
22
24
  * credentials never expire.
25
+ *
26
+ * A call the provider answers with {@link CHANNEL_CREDENTIALS_REJECTED} — a token retired before its
27
+ * stated expiry — is retried ONCE on credentials refreshed by force. A second rejection is the answer.
23
28
  */
24
29
  export declare function withLiveCredentials(connector: ChannelConnector, db: PluginDb): ChannelConnector;
@@ -1,4 +1,4 @@
1
- import { Err, Ok } from "@porulle/core";
1
+ import { CHANNEL_CREDENTIALS_REJECTED, Err, Ok } from "@porulle/core";
2
2
  import { and, eq, sql } from "@porulle/core/drizzle";
3
3
  import { connectedStores } from "./schema.js";
4
4
  /**
@@ -17,10 +17,10 @@ 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
- export async function resolveLiveCredentials(connector, db, store) {
20
+ export async function resolveLiveCredentials(connector, db, store, options = {}) {
21
21
  if (!connector.liveCredentials)
22
22
  return Ok(store);
23
- const answer = await connector.liveCredentials(store);
23
+ const answer = await connector.liveCredentials(store, options);
24
24
  if (!answer.ok) {
25
25
  if (answer.error.retriable !== true) {
26
26
  await db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
@@ -44,6 +44,9 @@ export async function resolveLiveCredentials(connector, db, store) {
44
44
  * The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
45
45
  * call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
46
46
  * credentials never expire.
47
+ *
48
+ * A call the provider answers with {@link CHANNEL_CREDENTIALS_REJECTED} — a token retired before its
49
+ * stated expiry — is retried ONCE on credentials refreshed by force. A second rejection is the answer.
47
50
  */
48
51
  export function withLiveCredentials(connector, db) {
49
52
  if (!connector.liveCredentials)
@@ -52,7 +55,13 @@ export function withLiveCredentials(connector, db) {
52
55
  const current = await resolveLiveCredentials(connector, db, store);
53
56
  if (!current.ok)
54
57
  return current;
55
- return call.call(connector, current.value, ...args);
58
+ const first = await call.call(connector, current.value, ...args);
59
+ if (first.ok || first.error.code !== CHANNEL_CREDENTIALS_REJECTED)
60
+ return first;
61
+ const refreshed = await resolveLiveCredentials(connector, db, current.value, { force: true });
62
+ if (!refreshed.ok)
63
+ return refreshed;
64
+ return call.call(connector, refreshed.value, ...args);
56
65
  };
57
66
  return {
58
67
  ...connector,
package/dist/service.d.ts CHANGED
@@ -120,9 +120,14 @@ export interface StoreConnectActor {
120
120
  }
121
121
  /**
122
122
  * Resolves, from the request that STARTS a connection, the facts the consumer needs to bind the store
123
- * later. Throw to refuse the start. Runs for OAuth start and for `POST /stores`.
123
+ * later. Throw to refuse the start — before the merchant is sent to the provider, which matters: a
124
+ * provider such as Shopify retires a shop's other grants the moment a new one is issued, so a
125
+ * connection refused only at the callback has already broken the shop's existing one. `storeDomain` is
126
+ * the store being connected, canonicalised. Runs for OAuth start and for `POST /stores`.
124
127
  */
125
- export type ConnectClaims = (context: StoreReadContext) => Promise<Record<string, string>> | Record<string, string>;
128
+ export type ConnectClaims = (context: StoreReadContext & {
129
+ storeDomain: string;
130
+ }) => Promise<Record<string, string>> | Record<string, string>;
126
131
  /**
127
132
  * Binds a just-connected store to whatever the consumer means by an owner, INSIDE the transaction
128
133
  * that wrote the store row — so a store and its binding commit together or not at all. Throw to
@@ -510,7 +515,9 @@ export declare class ChannelConnectorService {
510
515
  /** The store with credentials good for a call the host makes itself, e.g. its own Admin API write. */
511
516
  liveStore(orgId: string, storeId: string): Promise<PluginResult<ChannelStore>>;
512
517
  /** The consumer's claims for a connection starting from this request. See {@link ConnectClaims}. */
513
- connectClaims(context: StoreReadContext): Promise<PluginResult<Record<string, string>>>;
518
+ connectClaims(context: StoreReadContext & {
519
+ storeDomain: string;
520
+ }): Promise<PluginResult<Record<string, string>>>;
514
521
  /** The caller's allow-list, or null for unconfined. See {@link ConfineStores}. */
515
522
  private allowedStores;
516
523
  /** NOT_FOUND for a store outside the caller's set, exactly as for one that does not exist. */
package/dist/service.js CHANGED
@@ -1299,6 +1299,16 @@ export class ChannelConnectorService {
1299
1299
  continue;
1300
1300
  }
1301
1301
  variantIds.set(sourceVariant.externalId, variantId);
1302
+ // The provider's per-variant facts (the inventory item a stock webhook names, the weight shipping
1303
+ // prices by) merge into the variant per key, as entity metadata does: the source's keys overwrite
1304
+ // their own and nothing else. The import fast path writes them at creation; without this the
1305
+ // editor path — every reconcile — left them off, and a stock webhook could not find its variant.
1306
+ const sourceMetadata = sourceVariant.metadata ?? {};
1307
+ if (Object.keys(sourceMetadata).length > 0) {
1308
+ await this.db.update(variants)
1309
+ .set({ metadata: sql `coalesce(${variants.metadata}, '{}'::jsonb) || ${JSON.stringify(sourceMetadata)}::jsonb` })
1310
+ .where(and(eq(variants.id, variantId), sql `not (coalesce(${variants.metadata}, '{}'::jsonb) @> ${JSON.stringify(sourceMetadata)}::jsonb)`));
1311
+ }
1302
1312
  if (applyOptionValues) {
1303
1313
  const desiredOptionValueIds = Object.entries(sourceVariant.optionValues ?? {})
1304
1314
  .map(([name, value]) => optionValueIds.get(name)?.get(value))
@@ -3476,14 +3486,21 @@ export class ChannelConnectorService {
3476
3486
  archived += 1;
3477
3487
  }
3478
3488
  }
3479
- const inventory = await connector.fetchInventory(store, mappings.map((mapping) => mapping.externalId));
3489
+ // Stock is levelled against the mappings as they stand AFTER convergence. Read before it, as the
3490
+ // archive plan above must be, a first import walked an empty list and left every product it had
3491
+ // just created with no inventory level at all. Re-read only when convergence wrote something: an
3492
+ // unchanged reconcile creates no mapping and keeps its statement budget.
3493
+ const levelled = converged.value.imported + converged.value.converged > 0
3494
+ ? await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId)))
3495
+ : mappings;
3496
+ const inventory = await connector.fetchInventory(store, levelled.map((mapping) => mapping.externalId));
3480
3497
  if (!inventory.ok)
3481
3498
  return PluginErr(inventory.error.message);
3482
3499
  const existingLevels = await this.db.select().from(inventoryLevels).where(eq(inventoryLevels.organizationId, orgId));
3483
3500
  const inventoryService = this.services.inventory;
3484
3501
  let inventoryUpdated = 0;
3485
3502
  for (const level of inventory.value) {
3486
- const mapping = mappings.find((entry) => entry.externalId === level.externalId);
3503
+ const mapping = levelled.find((entry) => entry.externalId === level.externalId);
3487
3504
  if (!mapping)
3488
3505
  continue;
3489
3506
  const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.67.0",
3
+ "version": "0.68.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -22,7 +22,7 @@
22
22
  "dependencies": {
23
23
  "@hono/zod-openapi": "^1.2.2",
24
24
  "hono": "^4.12.5",
25
- "@porulle/core": "0.67.0"
25
+ "@porulle/core": "0.68.1"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^24.5.2",
package/src/index.ts CHANGED
@@ -532,11 +532,13 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
532
532
  // here, carried in the signed state. A caller with no user (an API key) cannot start one.
533
533
  const userId = actor?.userId;
534
534
  if (!userId) return oauthError(403, "USER_REQUIRED", "Connecting a store needs a signed-in user.");
535
- const claims = await service.connectClaims({ orgId, actor, raw });
536
- if (!claims.ok) return connectOutcome(oauth.postConnectRedirect, { error: claims.code ?? "CONNECT_REFUSED", message: claims.error });
537
535
  const typed = String((query as { shop?: string; store?: string }).shop ?? (query as { store?: string }).store ?? "");
538
536
  const storeDomain = connector.normalizeStoreDomain ? connector.normalizeStoreDomain(typed) : typed;
539
537
  if (!storeDomain) return connectOutcome(oauth.postConnectRedirect, { error: "INVALID_STORE_DOMAIN", message: `"${typed}" does not name a ${provider} store.` });
538
+ // Refused HERE, before the merchant reaches the provider: a grant issued for a shop retires
539
+ // that shop's other grants, so a refusal at the callback would already have broken them.
540
+ const claims = await service.connectClaims({ orgId, actor, raw, storeDomain });
541
+ if (!claims.ok) return connectOutcome(oauth.postConnectRedirect, { error: claims.code ?? "CONNECT_REFUSED", message: claims.error });
540
542
  const state = signState({
541
543
  provider,
542
544
  orgId,
@@ -669,7 +671,9 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
669
671
  webhookSecret: z.string().min(1).optional(),
670
672
  }))
671
673
  .handler(async ({ input, orgId, actor, raw }: ChannelRouteContext) => {
672
- const claims = unwrap(await service.connectClaims({ orgId, actor, raw }));
674
+ const requested = input as { provider: string; storeDomain: string };
675
+ const normalize = service.getConnector(requested.provider)?.normalizeStoreDomain;
676
+ const claims = unwrap(await service.connectClaims({ orgId, actor, raw, storeDomain: (normalize ? normalize(requested.storeDomain) : undefined) ?? requested.storeDomain }));
673
677
  return unwrap(await service.connectStore(
674
678
  orgId,
675
679
  input as {
@@ -1,4 +1,4 @@
1
- import { Err, Ok } from "@porulle/core";
1
+ import { CHANNEL_CREDENTIALS_REJECTED, Err, Ok } from "@porulle/core";
2
2
  import type { ChannelConnector, ChannelConnectorError, ChannelStore, PluginDb, Result } from "@porulle/core";
3
3
  import { and, eq, sql } from "@porulle/core/drizzle";
4
4
  import { connectedStores } from "./schema.js";
@@ -19,9 +19,9 @@ 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
- export async function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore): Promise<Result<ChannelStore, ChannelConnectorError>> {
22
+ export async function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore, options: { force?: boolean } = {}): Promise<Result<ChannelStore, ChannelConnectorError>> {
23
23
  if (!connector.liveCredentials) return Ok(store);
24
- const answer = await connector.liveCredentials(store);
24
+ const answer = await connector.liveCredentials(store, options);
25
25
  if (!answer.ok) {
26
26
  if (answer.error.retriable !== true) {
27
27
  await db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
@@ -45,13 +45,20 @@ type StoreCall<A extends unknown[], T> = (store: ChannelStore, ...args: A) => Pr
45
45
  * The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
46
46
  * call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
47
47
  * credentials never expire.
48
+ *
49
+ * A call the provider answers with {@link CHANNEL_CREDENTIALS_REJECTED} — a token retired before its
50
+ * stated expiry — is retried ONCE on credentials refreshed by force. A second rejection is the answer.
48
51
  */
49
52
  export function withLiveCredentials(connector: ChannelConnector, db: PluginDb): ChannelConnector {
50
53
  if (!connector.liveCredentials) return connector;
51
54
  const around = <A extends unknown[], T>(call: StoreCall<A, T>): StoreCall<A, T> => async (store, ...args) => {
52
55
  const current = await resolveLiveCredentials(connector, db, store);
53
56
  if (!current.ok) return current;
54
- return call.call(connector, current.value, ...args);
57
+ const first = await call.call(connector, current.value, ...args);
58
+ if (first.ok || first.error.code !== CHANNEL_CREDENTIALS_REJECTED) return first;
59
+ const refreshed = await resolveLiveCredentials(connector, db, current.value, { force: true });
60
+ if (!refreshed.ok) return refreshed;
61
+ return call.call(connector, refreshed.value, ...args);
55
62
  };
56
63
  return {
57
64
  ...connector,
package/src/service.ts CHANGED
@@ -266,9 +266,12 @@ export interface StoreConnectActor {
266
266
 
267
267
  /**
268
268
  * Resolves, from the request that STARTS a connection, the facts the consumer needs to bind the store
269
- * later. Throw to refuse the start. Runs for OAuth start and for `POST /stores`.
269
+ * later. Throw to refuse the start — before the merchant is sent to the provider, which matters: a
270
+ * provider such as Shopify retires a shop's other grants the moment a new one is issued, so a
271
+ * connection refused only at the callback has already broken the shop's existing one. `storeDomain` is
272
+ * the store being connected, canonicalised. Runs for OAuth start and for `POST /stores`.
270
273
  */
271
- export type ConnectClaims = (context: StoreReadContext) => Promise<Record<string, string>> | Record<string, string>;
274
+ export type ConnectClaims = (context: StoreReadContext & { storeDomain: string }) => Promise<Record<string, string>> | Record<string, string>;
272
275
 
273
276
  /**
274
277
  * Binds a just-connected store to whatever the consumer means by an owner, INSIDE the transaction
@@ -2057,6 +2060,16 @@ export class ChannelConnectorService {
2057
2060
  continue;
2058
2061
  }
2059
2062
  variantIds.set(sourceVariant.externalId, variantId);
2063
+ // The provider's per-variant facts (the inventory item a stock webhook names, the weight shipping
2064
+ // prices by) merge into the variant per key, as entity metadata does: the source's keys overwrite
2065
+ // their own and nothing else. The import fast path writes them at creation; without this the
2066
+ // editor path — every reconcile — left them off, and a stock webhook could not find its variant.
2067
+ const sourceMetadata = sourceVariant.metadata ?? {};
2068
+ if (Object.keys(sourceMetadata).length > 0) {
2069
+ await this.db.update(variants)
2070
+ .set({ metadata: sql`coalesce(${variants.metadata}, '{}'::jsonb) || ${JSON.stringify(sourceMetadata)}::jsonb` })
2071
+ .where(and(eq(variants.id, variantId), sql`not (coalesce(${variants.metadata}, '{}'::jsonb) @> ${JSON.stringify(sourceMetadata)}::jsonb)`));
2072
+ }
2060
2073
  if (applyOptionValues) {
2061
2074
  const desiredOptionValueIds = Object.entries(sourceVariant.optionValues ?? {})
2062
2075
  .map(([name, value]) => optionValueIds.get(name)?.get(value))
@@ -3198,7 +3211,7 @@ export class ChannelConnectorService {
3198
3211
  }
3199
3212
 
3200
3213
  /** The consumer's claims for a connection starting from this request. See {@link ConnectClaims}. */
3201
- async connectClaims(context: StoreReadContext): Promise<PluginResult<Record<string, string>>> {
3214
+ async connectClaims(context: StoreReadContext & { storeDomain: string }): Promise<PluginResult<Record<string, string>>> {
3202
3215
  if (!this.options.connectClaims) return Ok({});
3203
3216
  try {
3204
3217
  return Ok(await this.options.connectClaims(context));
@@ -4546,7 +4559,14 @@ export class ChannelConnectorService {
4546
4559
  }
4547
4560
  }
4548
4561
 
4549
- const inventory = await connector.fetchInventory(store as ChannelStore, mappings.map((mapping) => mapping.externalId));
4562
+ // Stock is levelled against the mappings as they stand AFTER convergence. Read before it, as the
4563
+ // archive plan above must be, a first import walked an empty list and left every product it had
4564
+ // just created with no inventory level at all. Re-read only when convergence wrote something: an
4565
+ // unchanged reconcile creates no mapping and keeps its statement budget.
4566
+ const levelled = converged.value.imported + converged.value.converged > 0
4567
+ ? await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId)))
4568
+ : mappings;
4569
+ const inventory = await connector.fetchInventory(store as ChannelStore, levelled.map((mapping) => mapping.externalId));
4550
4570
  if (!inventory.ok) return PluginErr(inventory.error.message);
4551
4571
  const existingLevels = await this.db.select().from(inventoryLevels).where(eq(inventoryLevels.organizationId, orgId));
4552
4572
  const inventoryService = this.services.inventory as {
@@ -4554,7 +4574,7 @@ export class ChannelConnectorService {
4554
4574
  };
4555
4575
  let inventoryUpdated = 0;
4556
4576
  for (const level of inventory.value) {
4557
- const mapping = mappings.find((entry) => entry.externalId === level.externalId);
4577
+ const mapping = levelled.find((entry) => entry.externalId === level.externalId);
4558
4578
  if (!mapping) continue;
4559
4579
  const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
4560
4580
  // Stock cannot sit below zero here, so negative remote stock compares as the zero it is stored as.