@porulle/plugin-channel-connector 0.73.2 → 0.74.0

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.d.ts CHANGED
@@ -27,5 +27,5 @@ export declare const CHANNEL_MAX_BATCHES_PER_SWEEP = 5000;
27
27
  export { signState, verifyState } from "./oauth-state.js";
28
28
  export type { BackfillCatalogOptions, BackfillCatalogReport, BuildCatalogPushItemsOptions, BuildCatalogPushItemsResult, CatalogPushAssemblyField, CatalogPushAssemblyImage, CatalogPushAssemblyItem, CatalogPushPreviewBefore, CatalogPushPreviewBeforeStatus, CatalogPushPreviewDiff, CatalogPushPreviewItem, CatalogPushPreviewResult, CatalogPushPreviewUnavailable, PushCatalogToStoreResult, CatalogPushJobResult, CatalogConvergenceFailure, CatalogFieldConflict, CatalogFieldSkip, CatalogPushFieldSkip, CatalogPushSkipReason, CatalogConflictState, CatalogWriteSettings, AfterStoreConnected, BindConnectedStore, ChannelComplianceData, ChannelConnectorPluginOptions, ConfineStores, ConnectClaims, OnStoreCatalogChanged, StoreConnectActor, StoreReadContext, ChannelStockLine, ExportState, PublicConnectedStore, ReconcileReport, } from "./service.js";
29
29
  export type { OAuthStatePayload, OAuthStateResult } from "./oauth-state.js";
30
- export type { ChannelCatalogPush, ChannelCatalogPushEvent, ChannelCatalogConflict, ChannelCatalogConflictEvent, ChannelEntityMapEntry, ChannelExportEvent, ChannelOrderExport, ChannelRefundEvent, ChannelRefundRequest, ConnectedStore, } from "./schema.js";
30
+ export type { ChannelCatalogPush, ChannelCatalogPushEvent, ChannelCatalogConflict, ChannelCatalogConflictEvent, ChannelEntityMapEntry, ChannelExportEvent, ChannelOrderExport, ChannelRefundEvent, ChannelRefundRequest, ConnectedStore, StoreHealth, } from "./schema.js";
31
31
  export declare function channelConnectorPlugin(options?: ChannelConnectorPluginOptions): import("@porulle/core").CommercePlugin;
package/dist/index.js CHANGED
@@ -143,6 +143,12 @@ function callbackUri(raw, redirect, provider) {
143
143
  }
144
144
  return new URL(`/api/channels/oauth/${provider}/callback`, origin).toString();
145
145
  }
146
+ const refreshOrderInput = z.object({ orgId: z.string(), storeId: z.string(), remoteOrderId: z.string() });
147
+ const completeConnectInput = z.object({
148
+ orgId: z.string(),
149
+ storeId: z.string(),
150
+ actor: z.object({ orgId: z.string(), userId: z.string(), claims: z.record(z.string(), z.string()) }),
151
+ });
146
152
  export function channelConnectorPlugin(options = {}) {
147
153
  const jobs = [
148
154
  {
@@ -300,7 +306,34 @@ export function channelConnectorPlugin(options = {}) {
300
306
  });
301
307
  if (!result.ok)
302
308
  throw new Error(result.error);
303
- return { output: { processed: true } };
309
+ return { output: { processed: result.value.processed } };
310
+ },
311
+ },
312
+ {
313
+ // A read point found a store order not read for a while; its current state is applied as if
314
+ // the delivery for it had arrived. Serialized with that store's deliveries.
315
+ slug: "channel/refresh-order",
316
+ concurrency: { key: (input) => `webhook:${String(input.storeId)}` },
317
+ handler: async ({ input, ctx }) => {
318
+ const parsed = refreshOrderInput.parse(input);
319
+ const service = new ChannelConnectorService(ctx.db, ctx.services, options);
320
+ const result = await service.refreshRemoteOrder(parsed.orgId, parsed.storeId, parsed.remoteOrderId);
321
+ if (!result.ok)
322
+ throw new Error(result.error);
323
+ return { output: result.value };
324
+ },
325
+ },
326
+ {
327
+ // The second half of a connect whose callback had to be answered at once (WooCommerce's): subscribe
328
+ // the store and start its first import. Not retried: a failure leaves the store in `error` with
329
+ // the reason the merchant reads, and reconnecting is the retry.
330
+ slug: "channel/complete-connect",
331
+ concurrency: { key: (input) => `connect:${String(input.storeId)}` },
332
+ handler: async ({ input, ctx }) => {
333
+ const parsed = completeConnectInput.parse(input);
334
+ const service = new ChannelConnectorService(ctx.db, ctx.services, options);
335
+ const result = await service.completeConnect(parsed.orgId, parsed.storeId, parsed.actor);
336
+ return { output: result.ok ? { status: result.value.status } : { error: result.code ?? "COMPLETE_CONNECT_FAILED", message: result.error } };
304
337
  },
305
338
  },
306
339
  {
@@ -395,6 +428,11 @@ export function channelConnectorPlugin(options = {}) {
395
428
  return connectOutcome(oauth.postConnectRedirect, { error: authUrl.error.code, message: authUrl.error.message });
396
429
  return oauthRedirect(authUrl.value);
397
430
  });
431
+ // Two shapes of callback reach here. Shopify's is the merchant's BROWSER (GET): the connection
432
+ // completes in the request and the browser lands on the outcome. WooCommerce's is the STORE
433
+ // posting the keys (POST), and it deletes them on anything but a 200 or after 60 seconds, so
434
+ // the keys are saved, the answer is immediate, and the rest runs as `channel/complete-connect`.
435
+ // Its browser then returns separately (GET with `return=1`) and lands on the store's real state.
398
436
  const handleOAuthCallback = async ({ params, raw }) => {
399
437
  const oauth = options.oauth;
400
438
  if (!oauth?.stateSecret || !oauth.postConnectRedirect)
@@ -407,36 +445,54 @@ export function channelConnectorPlugin(options = {}) {
407
445
  return oauthError(501, "OAUTH_UNSUPPORTED", `Connector "${provider}" does not support OAuth onboarding.`);
408
446
  const request = raw.req.raw;
409
447
  const requestUrl = new URL(request.url);
448
+ const posted = request.method === "POST";
410
449
  const state = requestUrl.searchParams.get("state");
411
- const refused = (error, message) => connectOutcome(oauth.postConnectRedirect, { error, message });
450
+ // A store posting keys is answered with a status it understands; a browser is sent to the outcome.
451
+ const refused = (error, message, status = 400) => posted
452
+ ? oauthError(status, error, message)
453
+ : connectOutcome(oauth.postConnectRedirect, { error, message });
412
454
  if (!state)
413
455
  return refused("INVALID_OAUTH_STATE", "The connection was not started here, or its link was altered.");
414
- const landing = provider === "woocommerce" && request.method === "GET" && requestUrl.searchParams.get("return") === "1";
415
456
  const verified = verifyState(state, oauth.stateSecret, Math.floor(Date.now() / 1000));
416
457
  if (!verified.ok || verified.value.provider !== provider)
417
458
  return refused("INVALID_OAUTH_STATE", "The connection link expired; start again.");
418
- if (landing)
419
- return oauthRedirect(oauth.postConnectRedirect);
459
+ if (!posted && requestUrl.searchParams.get("return") === "1") {
460
+ if (requestUrl.searchParams.get("success") === "0")
461
+ return refused("CONNECT_DECLINED", "You declined the connection in your store, so nothing was connected.");
462
+ const landed = await service.storeByDomain(verified.value.orgId, provider, verified.value.shopDomain);
463
+ if (!landed)
464
+ return refused("CONNECT_NOT_RECEIVED", "Your store did not send its keys. Approve the connection again.");
465
+ return connectOutcome(oauth.postConnectRedirect, { connected: landed.id });
466
+ }
420
467
  const [consumed] = await db.insert(processedWebhookEvents).values({
421
468
  eventId: oauthStateEventId(verified.value.jti),
422
469
  provider: `oauth:${provider}`,
423
470
  eventType: "oauth_state",
424
471
  }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
425
472
  if (!consumed)
426
- return refused("OAUTH_STATE_REPLAYED", "This connection link was already used; start again.");
427
- const completed = await connector.completeAuth(request, { storeDomain: verified.value.shopDomain });
473
+ return refused("OAUTH_STATE_REPLAYED", "This connection link was already used; start again.", 409);
474
+ const completed = await connector.completeAuth(request, { storeDomain: verified.value.shopDomain, state });
428
475
  if (!completed.ok)
429
476
  return refused(completed.error.code, completed.error.message);
430
477
  if (completed.value.storeDomain !== verified.value.shopDomain)
431
478
  return refused("OAUTH_STORE_MISMATCH", "The store that answered is not the one the connection was started for.");
432
- const connected = await service.connectStore(verified.value.orgId, {
433
- provider,
434
- storeDomain: verified.value.shopDomain,
435
- credentials: completed.value.credentials,
436
- }, { orgId: verified.value.orgId, userId: verified.value.userId, claims: verified.value.claims });
437
- if (!connected.ok)
438
- return refused(connected.code ?? "STORE_CONNECTION_FAILED", connected.error);
439
- return connectOutcome(oauth.postConnectRedirect, { connected: connected.value.id });
479
+ const actor = { orgId: verified.value.orgId, userId: verified.value.userId, claims: verified.value.claims };
480
+ const input = { provider, storeDomain: verified.value.shopDomain, credentials: completed.value.credentials };
481
+ if (!posted) {
482
+ const connected = await service.connectStore(verified.value.orgId, input, actor);
483
+ if (!connected.ok)
484
+ return refused(connected.code ?? "STORE_CONNECTION_FAILED", connected.error);
485
+ return connectOutcome(oauth.postConnectRedirect, { connected: connected.value.id });
486
+ }
487
+ const saved = await service.saveConnectingStore(verified.value.orgId, input, actor);
488
+ if (!saved.ok)
489
+ return refused(saved.code ?? "STORE_CONNECTION_FAILED", saved.error, 409);
490
+ await ctx.services.jobs.enqueue("channel/complete-connect", { orgId: verified.value.orgId, storeId: saved.value.id, actor }, {
491
+ organizationId: verified.value.orgId,
492
+ concurrencyKey: `connect:${saved.value.id}`,
493
+ supersedes: false,
494
+ });
495
+ return new Response(JSON.stringify({ data: { received: true } }), { status: 200, headers: { "content-type": "application/json" } });
440
496
  };
441
497
  channels.get("/oauth/{provider}/callback")
442
498
  .summary("Complete channel OAuth onboarding")
@@ -446,31 +502,44 @@ export function channelConnectorPlugin(options = {}) {
446
502
  .summary("Receive channel OAuth credentials")
447
503
  .params(z.object({ provider: z.string().min(1) }))
448
504
  .handler(handleOAuthCallback);
505
+ // Every delivery for a provider that signs per STORE (WooCommerce) arrives here. The answer is
506
+ // 200 for anything verified, before any work: WooCommerce never retries a delivery, counts every
507
+ // non-2xx (and every redirect) as a failure, and silently disables a subscription after repeated
508
+ // failures. The work runs as a job, which retries through the queue, never through the store.
449
509
  channels.post("/webhooks/{storeId}")
450
510
  .summary("Receive a channel webhook")
511
+ .params(z.object({ storeId: z.string().uuid() }))
451
512
  .handler(async ({ params, raw }) => {
452
513
  const context = raw;
453
- const storeId = params.storeId;
454
- const [store] = await db.select().from(connectedStores).where(eq(connectedStores.id, storeId));
514
+ const [store] = await db.select().from(connectedStores).where(eq(connectedStores.id, params.storeId));
455
515
  if (!store || !store.webhookSecret)
456
- return context.json({ error: { code: "UNAUTHORIZED", message: "Webhook store is not available." } }, 401);
516
+ return context.json({ error: { code: "NOT_FOUND", message: "No store receives webhooks here." } }, 404);
457
517
  const connector = service.getConnector(store.provider);
458
518
  if (!connector?.verifyWebhook)
459
- return context.json({ error: { code: "UNAUTHORIZED", message: "Webhook provider is not configured for per-store deliveries." } }, 401);
519
+ return context.json({ error: { code: "NOT_FOUND", message: "This store's provider does not deliver per-store webhooks." } }, 404);
460
520
  const verified = await connector.verifyWebhook(store, context.req.raw);
461
521
  if (!verified.ok)
462
522
  return context.json({ error: { code: "UNAUTHORIZED", message: "Invalid webhook signature." } }, 401);
463
- const [inserted] = await db.insert(processedWebhookEvents).values({ eventId: verified.value.id, provider: store.provider, eventType: verified.value.type }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
464
- if (!inserted)
523
+ // The unsigned ping a provider sends when a subscription is created carries nothing to do.
524
+ if (verified.value === null)
525
+ return context.json({ data: { received: true } });
526
+ const delivery = verified.value;
527
+ // The connector's key is unique within one store; the store id makes it unique here.
528
+ const [marked] = await db.insert(processedWebhookEvents).values({ eventId: `${store.id}:${delivery.id}`, provider: store.provider, eventType: delivery.type }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
529
+ if (!marked)
465
530
  return context.json({ data: { received: true, duplicate: true } });
466
- const handled = await service.handleWebhook(store.organizationId, store.id, verified.value);
467
- if (!handled.ok)
468
- return context.json({ error: { code: "WEBHOOK_PROCESSING_FAILED", message: handled.error } }, 422);
469
- return context.json({ data: {
470
- received: true,
471
- ...(handled.value.data ? { data: handled.value.data } : {}),
472
- ...(handled.value.redacted !== undefined ? { redacted: handled.value.redacted } : {}),
473
- } });
531
+ try {
532
+ await ctx.services.jobs.enqueue("channel/apply-webhook", { orgId: store.organizationId, storeId: store.id, id: delivery.id, topic: delivery.type, data: delivery.data }, {
533
+ organizationId: store.organizationId,
534
+ concurrencyKey: `webhook:${store.id}`,
535
+ supersedes: false,
536
+ });
537
+ }
538
+ catch (error) {
539
+ await db.delete(processedWebhookEvents).where(eq(processedWebhookEvents.id, marked.id));
540
+ return context.json({ error: { code: "WEBHOOK_NOT_ACCEPTED", message: error instanceof Error ? error.message : "The delivery could not be queued." } }, 503);
541
+ }
542
+ return context.json({ data: { received: true } });
474
543
  });
475
544
  // Every delivery for a provider that signs per APP — Shopify's catalogue, stock, order, uninstall
476
545
  // and mandatory compliance topics alike — arrives here, at the one address its app configuration
@@ -628,6 +697,10 @@ export function channelConnectorPlugin(options = {}) {
628
697
  const values = input;
629
698
  return unwrap(await service.previewCatalogPush(orgId, params.storeId, values.entityIds));
630
699
  });
700
+ channels.post("/stores/{id}/health")
701
+ .summary("Check a store's webhooks and key, repairing what can be repaired")
702
+ .permission("channels:connect")
703
+ .handler(async ({ params, orgId, actor, raw }) => unwrap(await service.checkStoreHealth(orgId, params.id, { orgId, actor, raw })));
631
704
  channels.post("/stores/{id}/disconnect")
632
705
  .summary("Disconnect a channel store")
633
706
  .permission("channels:connect")
@@ -18,12 +18,15 @@ import type { ChannelConnector, ChannelConnectorError, ChannelStore, PluginDb, R
18
18
  export declare function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore, options?: {
19
19
  force?: boolean;
20
20
  }): 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.";
21
23
  /**
22
24
  * The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
23
- * call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
24
- * credentials never expire.
25
+ * call site can start on a lapsed token by forgetting to ask.
25
26
  *
26
27
  * 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.
28
+ * stated expiry — is retried ONCE on credentials refreshed by force, for a connector that can refresh.
29
+ * A rejection that cannot be refreshed away (a second one, or a connector whose keys never expire,
30
+ * such as WooCommerce's) marks the store `error` so it reads as "reconnect".
28
31
  */
29
32
  export declare function withLiveCredentials(connector: ChannelConnector, db: PluginDb): ChannelConnector;
@@ -40,17 +40,21 @@ export async function resolveLiveCredentials(connector, db, store, options = {})
40
40
  return Err({ code: "STORE_NOT_FOUND", message: `Connected store ${store.id} no longer exists.`, retriable: false });
41
41
  return Ok({ ...store, credentials: current.credentials });
42
42
  }
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
+ async function markCredentialsRejected(db, storeId) {
46
+ await db.update(connectedStores).set({ status: "error", statusReason: CREDENTIALS_REJECTED_REASON, updatedAt: new Date() }).where(eq(connectedStores.id, storeId));
47
+ }
43
48
  /**
44
49
  * The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
45
- * call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
46
- * credentials never expire.
50
+ * call site can start on a lapsed token by forgetting to ask.
47
51
  *
48
52
  * 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.
53
+ * stated expiry — is retried ONCE on credentials refreshed by force, for a connector that can refresh.
54
+ * A rejection that cannot be refreshed away (a second one, or a connector whose keys never expire,
55
+ * such as WooCommerce's) marks the store `error` so it reads as "reconnect".
50
56
  */
51
57
  export function withLiveCredentials(connector, db) {
52
- if (!connector.liveCredentials)
53
- return connector;
54
58
  const around = (call) => async (store, ...args) => {
55
59
  const current = await resolveLiveCredentials(connector, db, store);
56
60
  if (!current.ok)
@@ -58,10 +62,17 @@ export function withLiveCredentials(connector, db) {
58
62
  const first = await call.call(connector, current.value, ...args);
59
63
  if (first.ok || first.error.code !== CHANNEL_CREDENTIALS_REJECTED)
60
64
  return first;
65
+ if (!connector.liveCredentials) {
66
+ await markCredentialsRejected(db, store.id);
67
+ return first;
68
+ }
61
69
  const refreshed = await resolveLiveCredentials(connector, db, current.value, { force: true });
62
70
  if (!refreshed.ok)
63
71
  return refreshed;
64
- return call.call(connector, refreshed.value, ...args);
72
+ const second = await call.call(connector, refreshed.value, ...args);
73
+ if (!second.ok && second.error.code === CHANNEL_CREDENTIALS_REJECTED)
74
+ await markCredentialsRejected(db, store.id);
75
+ return second;
65
76
  };
66
77
  return {
67
78
  ...connector,
@@ -76,6 +87,11 @@ export function withLiveCredentials(connector, db) {
76
87
  ...(connector.pushCatalog ? { pushCatalog: around(connector.pushCatalog) } : {}),
77
88
  ...(connector.reserve ? { reserve: around(connector.reserve) } : {}),
78
89
  ...(connector.registerWebhooks ? { registerWebhooks: around(connector.registerWebhooks) } : {}),
90
+ ...(connector.unregisterWebhooks ? { unregisterWebhooks: around(connector.unregisterWebhooks) } : {}),
91
+ ...(connector.webhookHealth ? { webhookHealth: around(connector.webhookHealth) } : {}),
92
+ ...(connector.decodeWebhook ? { decodeWebhook: around(connector.decodeWebhook) } : {}),
93
+ ...(connector.orderEvents ? { orderEvents: around(connector.orderEvents) } : {}),
79
94
  ...(connector.cancelOrder ? { cancelOrder: around(connector.cancelOrder) } : {}),
95
+ ...(connector.requestReturn ? { requestReturn: around(connector.requestReturn) } : {}),
80
96
  };
81
97
  }
@@ -24,6 +24,68 @@ export declare function mockChannelConnector(options?: MockChannelConnectorOptio
24
24
  items: ChannelCatalogItem[];
25
25
  nextCursor: null;
26
26
  }, never>>;
27
+ /** The catalogue as it stands when asked: a test changes `options.catalog` to change "the store". */
28
+ fetchCatalogItems(_store: ChannelStore, externalIds: string[]): Promise<import("@porulle/core").Result<ChannelCatalogItem[], never>>;
29
+ decodeWebhook(_store: ChannelStore, event: import("@porulle/core").ChannelWebhookEvent): Promise<{
30
+ ok: false;
31
+ error: {
32
+ code: string;
33
+ message: string;
34
+ retriable: false;
35
+ };
36
+ } | {
37
+ ok: true;
38
+ value: ({
39
+ kind: "product.changed";
40
+ externalIds: string[];
41
+ } | {
42
+ kind: "product.deleted";
43
+ externalIds: string[];
44
+ } | {
45
+ kind: "inventory.changed";
46
+ levels: {
47
+ externalId: string;
48
+ available: number;
49
+ }[];
50
+ } | {
51
+ kind: "order.cancelled";
52
+ remoteOrderId: string;
53
+ } | {
54
+ kind: "order.fulfilled";
55
+ remoteOrderId: string;
56
+ partial: boolean;
57
+ shipments: {
58
+ remoteId: string;
59
+ lines: {
60
+ externalVariantId: string;
61
+ quantity: number;
62
+ }[];
63
+ carrier?: string;
64
+ trackingNumber?: string;
65
+ trackingUrl?: string;
66
+ source?: string;
67
+ }[];
68
+ } | {
69
+ kind: "refund.created";
70
+ remoteOrderId: string;
71
+ remoteRefundId: string;
72
+ lines: {
73
+ externalVariantId: string;
74
+ quantity: number;
75
+ }[];
76
+ } | {
77
+ kind: "return.updated";
78
+ remoteReturnId: string;
79
+ status: "cancelled" | "approved" | "declined" | "closed";
80
+ } | {
81
+ kind: "connection.revoked";
82
+ } | {
83
+ kind: "compliance.request";
84
+ request: "customer_data" | "customer_redact" | "shop_redact";
85
+ data: Record<string, unknown>;
86
+ })[];
87
+ meta?: Record<string, unknown>;
88
+ }>;
27
89
  fetchInventory(_store: ChannelStore, ids: string[] | undefined): Promise<{
28
90
  ok: false;
29
91
  error: CommerceValidationError;
@@ -1,4 +1,5 @@
1
1
  import { CommerceValidationError, Err, Ok, defineChannelConnector, } from "@porulle/core";
2
+ import { z } from "zod";
2
3
  const defaultCatalog = [{
3
4
  externalId: "mock-product-1",
4
5
  slug: "mock-channel-product",
@@ -48,6 +49,27 @@ const defaultCatalog = [{
48
49
  prices: [{ currency: "USD", amount: 2500 }],
49
50
  }],
50
51
  }];
52
+ const level = z.object({ externalId: z.string(), available: z.number() });
53
+ const shipment = z.object({
54
+ remoteId: z.string(),
55
+ carrier: z.string().exactOptional(),
56
+ trackingNumber: z.string().exactOptional(),
57
+ trackingUrl: z.string().exactOptional(),
58
+ lines: z.array(z.object({ externalVariantId: z.string(), quantity: z.number().int() })),
59
+ source: z.string().exactOptional(),
60
+ });
61
+ /** A mock delivery's body IS what it means: one {@link ChannelEvent} or a list of them. */
62
+ const channelEventSchema = z.discriminatedUnion("kind", [
63
+ z.object({ kind: z.literal("product.changed"), externalIds: z.array(z.string()) }),
64
+ z.object({ kind: z.literal("product.deleted"), externalIds: z.array(z.string()) }),
65
+ z.object({ kind: z.literal("inventory.changed"), levels: z.array(level) }),
66
+ z.object({ kind: z.literal("order.cancelled"), remoteOrderId: z.string() }),
67
+ z.object({ kind: z.literal("order.fulfilled"), remoteOrderId: z.string(), partial: z.boolean(), shipments: z.array(shipment) }),
68
+ z.object({ kind: z.literal("refund.created"), remoteOrderId: z.string(), remoteRefundId: z.string(), lines: z.array(z.object({ externalVariantId: z.string(), quantity: z.number().int() })) }),
69
+ z.object({ kind: z.literal("return.updated"), remoteReturnId: z.string(), status: z.enum(["approved", "declined", "closed", "cancelled"]) }),
70
+ z.object({ kind: z.literal("connection.revoked") }),
71
+ z.object({ kind: z.literal("compliance.request"), request: z.enum(["customer_data", "customer_redact", "shop_redact"]), data: z.record(z.string(), z.unknown()) }),
72
+ ]);
51
73
  export function mockChannelConnector(options = {}) {
52
74
  const orders = new Map();
53
75
  const catalog = new Map();
@@ -63,6 +85,17 @@ export function mockChannelConnector(options = {}) {
63
85
  async importCatalog(_store) {
64
86
  return Ok({ items: options.catalog ?? defaultCatalog, nextCursor: null });
65
87
  },
88
+ /** The catalogue as it stands when asked: a test changes `options.catalog` to change "the store". */
89
+ async fetchCatalogItems(_store, externalIds) {
90
+ const wanted = new Set(externalIds);
91
+ return Ok((options.catalog ?? defaultCatalog).filter((item) => wanted.has(item.externalId)));
92
+ },
93
+ async decodeWebhook(_store, event) {
94
+ const parsed = z.union([channelEventSchema, z.array(channelEventSchema)]).safeParse(event.data);
95
+ if (!parsed.success)
96
+ return Err({ code: "MOCK_WEBHOOK_MALFORMED", message: parsed.error.message, retriable: false });
97
+ return Ok(Array.isArray(parsed.data) ? parsed.data : [parsed.data]);
98
+ },
66
99
  async fetchInventory(_store, ids) {
67
100
  const requestedIds = ids ?? [];
68
101
  options.onFetchInventory?.(requestedIds);
package/dist/schema.d.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  import type { ChannelOrderAddress, ChannelPushCatalogItem } from "@porulle/core";
2
2
  import type { CatalogFieldMapping } from "./catalog-field-mapping.js";
3
+ /** What the last on-visit check found. `webhooks`: subscriptions all active, recreated, or still failing. */
4
+ export interface StoreHealth {
5
+ checkedAt: string;
6
+ webhooks: "ok" | "repaired" | "failing" | "not_applicable";
7
+ repaired: number;
8
+ missing: string[];
9
+ keyValid: boolean;
10
+ /** Why the check could not finish, when it could not. */
11
+ error?: string;
12
+ }
3
13
  export declare const connectedStores: import("drizzle-orm/pg-core/table").PgTableWithColumns<{
4
14
  name: "connected_stores";
5
15
  schema: undefined;
@@ -96,14 +106,67 @@ export declare const connectedStores: import("drizzle-orm/pg-core/table").PgTabl
96
106
  tableName: "connected_stores";
97
107
  dataType: "string";
98
108
  columnType: "PgText";
99
- data: "error" | "connected" | "disconnected";
109
+ data: "error" | "connecting" | "connected" | "disconnected";
100
110
  driverParam: string;
101
111
  notNull: true;
102
112
  hasDefault: true;
103
113
  isPrimaryKey: false;
104
114
  isAutoincrement: false;
105
115
  hasRuntimeDefault: false;
106
- enumValues: ["connected", "disconnected", "error"];
116
+ enumValues: ["connecting", "connected", "disconnected", "error"];
117
+ baseColumn: never;
118
+ identity: undefined;
119
+ generated: undefined;
120
+ }, {}, {}>;
121
+ statusReason: import("@porulle/core/drizzle").PgColumn<{
122
+ name: "status_reason";
123
+ tableName: "connected_stores";
124
+ dataType: "string";
125
+ columnType: "PgText";
126
+ data: string;
127
+ driverParam: string;
128
+ notNull: false;
129
+ hasDefault: false;
130
+ isPrimaryKey: false;
131
+ isAutoincrement: false;
132
+ hasRuntimeDefault: false;
133
+ enumValues: [string, ...string[]];
134
+ baseColumn: never;
135
+ identity: undefined;
136
+ generated: undefined;
137
+ }, {}, {}>;
138
+ health: import("@porulle/core/drizzle").PgColumn<{
139
+ name: "health";
140
+ tableName: "connected_stores";
141
+ dataType: "json";
142
+ columnType: "PgJsonb";
143
+ data: StoreHealth;
144
+ driverParam: unknown;
145
+ notNull: false;
146
+ hasDefault: false;
147
+ isPrimaryKey: false;
148
+ isAutoincrement: false;
149
+ hasRuntimeDefault: false;
150
+ enumValues: undefined;
151
+ baseColumn: never;
152
+ identity: undefined;
153
+ generated: undefined;
154
+ }, {}, {
155
+ $type: StoreHealth;
156
+ }>;
157
+ lastEventAt: import("@porulle/core/drizzle").PgColumn<{
158
+ name: "last_event_at";
159
+ tableName: "connected_stores";
160
+ dataType: "date";
161
+ columnType: "PgTimestamp";
162
+ data: Date;
163
+ driverParam: string;
164
+ notNull: false;
165
+ hasDefault: false;
166
+ isPrimaryKey: false;
167
+ isAutoincrement: false;
168
+ hasRuntimeDefault: false;
169
+ enumValues: undefined;
107
170
  baseColumn: never;
108
171
  identity: undefined;
109
172
  generated: undefined;
@@ -1161,6 +1224,23 @@ export declare const channelOrderExports: import("drizzle-orm/pg-core/table").Pg
1161
1224
  identity: undefined;
1162
1225
  generated: undefined;
1163
1226
  }, {}, {}>;
1227
+ remoteCheckedAt: import("@porulle/core/drizzle").PgColumn<{
1228
+ name: "remote_checked_at";
1229
+ tableName: "channel_order_exports";
1230
+ dataType: "date";
1231
+ columnType: "PgTimestamp";
1232
+ data: Date;
1233
+ driverParam: string;
1234
+ notNull: false;
1235
+ hasDefault: false;
1236
+ isPrimaryKey: false;
1237
+ isAutoincrement: false;
1238
+ hasRuntimeDefault: false;
1239
+ enumValues: undefined;
1240
+ baseColumn: never;
1241
+ identity: undefined;
1242
+ generated: undefined;
1243
+ }, {}, {}>;
1164
1244
  attempts: import("@porulle/core/drizzle").PgColumn<{
1165
1245
  name: "attempts";
1166
1246
  tableName: "channel_order_exports";
package/dist/schema.js CHANGED
@@ -6,9 +6,15 @@ export const connectedStores = pgTable("connected_stores", {
6
6
  provider: text("provider").notNull(),
7
7
  credentials: jsonb("credentials").$type().notNull(),
8
8
  storeDomain: text("store_domain").notNull(),
9
- status: text("status", { enum: ["connected", "disconnected", "error"] })
9
+ status: text("status", { enum: ["connecting", "connected", "disconnected", "error"] })
10
10
  .notNull()
11
11
  .default("connected"),
12
+ /** Why the store is in `error`, in words the merchant can act on. Null in every other status. */
13
+ statusReason: text("status_reason"),
14
+ /** The last check of the store's webhooks and key, made on a merchant's visit (never on a schedule). */
15
+ health: jsonb("health").$type(),
16
+ /** When the store last delivered a webhook we accepted. */
17
+ lastEventAt: timestamp("last_event_at", { withTimezone: true }),
12
18
  catalogWriteEnabled: boolean("catalog_write_enabled").notNull().default(false),
13
19
  catalogFieldMapping: jsonb("catalog_field_mapping").$type().notNull().default([]),
14
20
  catalogCursor: text("catalog_cursor"),
@@ -103,6 +109,8 @@ export const channelOrderExports = pgTable("channel_order_exports", {
103
109
  failureKind: text("failure_kind", { enum: ["definitive", "transient"] }),
104
110
  remoteOrderId: text("remote_order_id"),
105
111
  remoteUrl: text("remote_url"),
112
+ /** When the store's side of the order was last read by a read point catching up a missed delivery. */
113
+ remoteCheckedAt: timestamp("remote_checked_at", { withTimezone: true }),
106
114
  attempts: integer("attempts").notNull().default(0),
107
115
  lastError: text("last_error"),
108
116
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),