@porulle/plugin-channel-connector 0.73.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.73.3",
3
+ "version": "0.74.0",
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.73.3"
25
+ "@porulle/core": "0.74.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^24.5.2",
package/src/index.ts CHANGED
@@ -260,6 +260,7 @@ export type {
260
260
  ChannelRefundEvent,
261
261
  ChannelRefundRequest,
262
262
  ConnectedStore,
263
+ StoreHealth,
263
264
  } from "./schema.js";
264
265
 
265
266
  function unwrap<T>(result: PluginResult<T>): T {
@@ -312,6 +313,14 @@ function callbackUri(raw: unknown, redirect: string, provider: string): string {
312
313
  return new URL(`/api/channels/oauth/${provider}/callback`, origin).toString();
313
314
  }
314
315
 
316
+ const refreshOrderInput = z.object({ orgId: z.string(), storeId: z.string(), remoteOrderId: z.string() });
317
+
318
+ const completeConnectInput = z.object({
319
+ orgId: z.string(),
320
+ storeId: z.string(),
321
+ actor: z.object({ orgId: z.string(), userId: z.string(), claims: z.record(z.string(), z.string()) }),
322
+ });
323
+
315
324
  export function channelConnectorPlugin(options: ChannelConnectorPluginOptions = {}) {
316
325
  const jobs: TaskDefinition[] = [
317
326
  {
@@ -470,7 +479,33 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
470
479
  data: input.data,
471
480
  });
472
481
  if (!result.ok) throw new Error(result.error);
473
- return { output: { processed: true } };
482
+ return { output: { processed: result.value.processed } };
483
+ },
484
+ },
485
+ {
486
+ // A read point found a store order not read for a while; its current state is applied as if
487
+ // the delivery for it had arrived. Serialized with that store's deliveries.
488
+ slug: "channel/refresh-order",
489
+ concurrency: { key: (input: Record<string, unknown>) => `webhook:${String(input.storeId)}` },
490
+ handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
491
+ const parsed = refreshOrderInput.parse(input);
492
+ const service = new ChannelConnectorService(ctx.db, ctx.services, options);
493
+ const result = await service.refreshRemoteOrder(parsed.orgId, parsed.storeId, parsed.remoteOrderId);
494
+ if (!result.ok) throw new Error(result.error);
495
+ return { output: result.value };
496
+ },
497
+ },
498
+ {
499
+ // The second half of a connect whose callback had to be answered at once (WooCommerce's): subscribe
500
+ // the store and start its first import. Not retried: a failure leaves the store in `error` with
501
+ // the reason the merchant reads, and reconnecting is the retry.
502
+ slug: "channel/complete-connect",
503
+ concurrency: { key: (input: Record<string, unknown>) => `connect:${String(input.storeId)}` },
504
+ handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
505
+ const parsed = completeConnectInput.parse(input);
506
+ const service = new ChannelConnectorService(ctx.db, ctx.services, options);
507
+ const result = await service.completeConnect(parsed.orgId, parsed.storeId, parsed.actor);
508
+ return { output: result.ok ? { status: result.value.status } : { error: result.code ?? "COMPLETE_CONNECT_FAILED", message: result.error } };
474
509
  },
475
510
  },
476
511
  {
@@ -564,6 +599,11 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
564
599
  return oauthRedirect(authUrl.value);
565
600
  });
566
601
 
602
+ // Two shapes of callback reach here. Shopify's is the merchant's BROWSER (GET): the connection
603
+ // completes in the request and the browser lands on the outcome. WooCommerce's is the STORE
604
+ // posting the keys (POST), and it deletes them on anything but a 200 or after 60 seconds, so
605
+ // the keys are saved, the answer is immediate, and the rest runs as `channel/complete-connect`.
606
+ // Its browser then returns separately (GET with `return=1`) and lands on the store's real state.
567
607
  const handleOAuthCallback = async ({ params, raw }: { params: Record<string, string>; raw: unknown }) => {
568
608
  const oauth = options.oauth;
569
609
  if (!oauth?.stateSecret || !oauth.postConnectRedirect) return oauthError(501, "OAUTH_NOT_CONFIGURED", "Channel OAuth is not configured.");
@@ -573,29 +613,45 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
573
613
  if (!connector.completeAuth) return oauthError(501, "OAUTH_UNSUPPORTED", `Connector "${provider}" does not support OAuth onboarding.`);
574
614
  const request = (raw as { req: { raw: Request } }).req.raw;
575
615
  const requestUrl = new URL(request.url);
616
+ const posted = request.method === "POST";
576
617
  const state = requestUrl.searchParams.get("state");
577
- const refused = (error: string, message: string): Response => connectOutcome(oauth.postConnectRedirect, { error, message });
618
+ // A store posting keys is answered with a status it understands; a browser is sent to the outcome.
619
+ const refused = (error: string, message: string, status = 400): Response => posted
620
+ ? oauthError(status, error, message)
621
+ : connectOutcome(oauth.postConnectRedirect, { error, message });
578
622
  if (!state) return refused("INVALID_OAUTH_STATE", "The connection was not started here, or its link was altered.");
579
- const landing = provider === "woocommerce" && request.method === "GET" && requestUrl.searchParams.get("return") === "1";
580
623
  const verified = verifyState(state, oauth.stateSecret, Math.floor(Date.now() / 1000));
581
624
  if (!verified.ok || verified.value.provider !== provider) return refused("INVALID_OAUTH_STATE", "The connection link expired; start again.");
582
- if (landing) return oauthRedirect(oauth.postConnectRedirect);
625
+ if (!posted && requestUrl.searchParams.get("return") === "1") {
626
+ if (requestUrl.searchParams.get("success") === "0") return refused("CONNECT_DECLINED", "You declined the connection in your store, so nothing was connected.");
627
+ const landed = await service.storeByDomain(verified.value.orgId, provider, verified.value.shopDomain);
628
+ if (!landed) return refused("CONNECT_NOT_RECEIVED", "Your store did not send its keys. Approve the connection again.");
629
+ return connectOutcome(oauth.postConnectRedirect, { connected: landed.id });
630
+ }
583
631
  const [consumed] = await db.insert(processedWebhookEvents).values({
584
632
  eventId: oauthStateEventId(verified.value.jti),
585
633
  provider: `oauth:${provider}`,
586
634
  eventType: "oauth_state",
587
635
  }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
588
- if (!consumed) return refused("OAUTH_STATE_REPLAYED", "This connection link was already used; start again.");
589
- const completed = await connector.completeAuth(request, { storeDomain: verified.value.shopDomain });
636
+ if (!consumed) return refused("OAUTH_STATE_REPLAYED", "This connection link was already used; start again.", 409);
637
+ const completed = await connector.completeAuth(request, { storeDomain: verified.value.shopDomain, state });
590
638
  if (!completed.ok) return refused(completed.error.code, completed.error.message);
591
639
  if (completed.value.storeDomain !== verified.value.shopDomain) return refused("OAUTH_STORE_MISMATCH", "The store that answered is not the one the connection was started for.");
592
- const connected = await service.connectStore(verified.value.orgId, {
593
- provider,
594
- storeDomain: verified.value.shopDomain,
595
- credentials: completed.value.credentials,
596
- }, { orgId: verified.value.orgId, userId: verified.value.userId, claims: verified.value.claims });
597
- if (!connected.ok) return refused(connected.code ?? "STORE_CONNECTION_FAILED", connected.error);
598
- return connectOutcome(oauth.postConnectRedirect, { connected: connected.value.id });
640
+ const actor = { orgId: verified.value.orgId, userId: verified.value.userId, claims: verified.value.claims };
641
+ const input = { provider, storeDomain: verified.value.shopDomain, credentials: completed.value.credentials };
642
+ if (!posted) {
643
+ const connected = await service.connectStore(verified.value.orgId, input, actor);
644
+ if (!connected.ok) return refused(connected.code ?? "STORE_CONNECTION_FAILED", connected.error);
645
+ return connectOutcome(oauth.postConnectRedirect, { connected: connected.value.id });
646
+ }
647
+ const saved = await service.saveConnectingStore(verified.value.orgId, input, actor);
648
+ if (!saved.ok) return refused(saved.code ?? "STORE_CONNECTION_FAILED", saved.error, 409);
649
+ await (ctx.services.jobs as JobsAdapter).enqueue("channel/complete-connect", { orgId: verified.value.orgId, storeId: saved.value.id, actor }, {
650
+ organizationId: verified.value.orgId,
651
+ concurrencyKey: `connect:${saved.value.id}`,
652
+ supersedes: false,
653
+ });
654
+ return new Response(JSON.stringify({ data: { received: true } }), { status: 200, headers: { "content-type": "application/json" } });
599
655
  };
600
656
 
601
657
  channels.get("/oauth/{provider}/callback")
@@ -608,26 +664,38 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
608
664
  .params(z.object({ provider: z.string().min(1) }))
609
665
  .handler(handleOAuthCallback);
610
666
 
667
+ // Every delivery for a provider that signs per STORE (WooCommerce) arrives here. The answer is
668
+ // 200 for anything verified, before any work: WooCommerce never retries a delivery, counts every
669
+ // non-2xx (and every redirect) as a failure, and silently disables a subscription after repeated
670
+ // failures. The work runs as a job, which retries through the queue, never through the store.
611
671
  channels.post("/webhooks/{storeId}")
612
672
  .summary("Receive a channel webhook")
673
+ .params(z.object({ storeId: z.string().uuid() }))
613
674
  .handler(async ({ params, raw }) => {
614
- const context = raw as { req: { raw: Request; header(name: string): string | undefined }; json(data: unknown, status?: number): Response };
615
- const storeId = params.storeId!;
616
- const [store] = await db.select().from(connectedStores).where(eq(connectedStores.id, storeId));
617
- if (!store || !store.webhookSecret) return context.json({ error: { code: "UNAUTHORIZED", message: "Webhook store is not available." } }, 401);
675
+ const context = raw as { req: { raw: Request }; json(data: unknown, status?: number): Response };
676
+ const [store] = await db.select().from(connectedStores).where(eq(connectedStores.id, params.storeId!));
677
+ if (!store || !store.webhookSecret) return context.json({ error: { code: "NOT_FOUND", message: "No store receives webhooks here." } }, 404);
618
678
  const connector = service.getConnector(store.provider);
619
- if (!connector?.verifyWebhook) return context.json({ error: { code: "UNAUTHORIZED", message: "Webhook provider is not configured for per-store deliveries." } }, 401);
679
+ if (!connector?.verifyWebhook) return context.json({ error: { code: "NOT_FOUND", message: "This store's provider does not deliver per-store webhooks." } }, 404);
620
680
  const verified = await connector.verifyWebhook(store, context.req.raw);
621
681
  if (!verified.ok) return context.json({ error: { code: "UNAUTHORIZED", message: "Invalid webhook signature." } }, 401);
622
- const [inserted] = await db.insert(processedWebhookEvents).values({ eventId: verified.value.id, provider: store.provider, eventType: verified.value.type }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
623
- if (!inserted) return context.json({ data: { received: true, duplicate: true } });
624
- const handled = await service.handleWebhook(store.organizationId, store.id, verified.value);
625
- if (!handled.ok) return context.json({ error: { code: "WEBHOOK_PROCESSING_FAILED", message: handled.error } }, 422);
626
- return context.json({ data: {
627
- received: true,
628
- ...(handled.value.data ? { data: handled.value.data } : {}),
629
- ...(handled.value.redacted !== undefined ? { redacted: handled.value.redacted } : {}),
630
- } });
682
+ // The unsigned ping a provider sends when a subscription is created carries nothing to do.
683
+ if (verified.value === null) return context.json({ data: { received: true } });
684
+ const delivery = verified.value;
685
+ // The connector's key is unique within one store; the store id makes it unique here.
686
+ const [marked] = await db.insert(processedWebhookEvents).values({ eventId: `${store.id}:${delivery.id}`, provider: store.provider, eventType: delivery.type }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
687
+ if (!marked) return context.json({ data: { received: true, duplicate: true } });
688
+ try {
689
+ await (ctx.services.jobs as JobsAdapter).enqueue("channel/apply-webhook", { orgId: store.organizationId, storeId: store.id, id: delivery.id, topic: delivery.type, data: delivery.data }, {
690
+ organizationId: store.organizationId,
691
+ concurrencyKey: `webhook:${store.id}`,
692
+ supersedes: false,
693
+ });
694
+ } catch (error) {
695
+ await db.delete(processedWebhookEvents).where(eq(processedWebhookEvents.id, marked.id));
696
+ return context.json({ error: { code: "WEBHOOK_NOT_ACCEPTED", message: error instanceof Error ? error.message : "The delivery could not be queued." } }, 503);
697
+ }
698
+ return context.json({ data: { received: true } });
631
699
  });
632
700
 
633
701
  // Every delivery for a provider that signs per APP — Shopify's catalogue, stock, order, uninstall
@@ -802,6 +870,11 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
802
870
  return unwrap(await service.previewCatalogPush(orgId, params.storeId!, values.entityIds));
803
871
  });
804
872
 
873
+ channels.post("/stores/{id}/health")
874
+ .summary("Check a store's webhooks and key, repairing what can be repaired")
875
+ .permission("channels:connect")
876
+ .handler(async ({ params, orgId, actor, raw }: ChannelRouteContext) => unwrap(await service.checkStoreHealth(orgId, params.id!, { orgId, actor, raw })));
877
+
805
878
  channels.post("/stores/{id}/disconnect")
806
879
  .summary("Disconnect a channel store")
807
880
  .permission("channels:connect")
@@ -41,24 +41,37 @@ export async function resolveLiveCredentials(connector: ChannelConnector, db: Pl
41
41
 
42
42
  type StoreCall<A extends unknown[], T> = (store: ChannelStore, ...args: A) => Promise<Result<T, ChannelConnectorError>>;
43
43
 
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
+ async function markCredentialsRejected(db: PluginDb, storeId: string): Promise<void> {
48
+ await db.update(connectedStores).set({ status: "error", statusReason: CREDENTIALS_REJECTED_REASON, updatedAt: new Date() }).where(eq(connectedStores.id, storeId));
49
+ }
50
+
44
51
  /**
45
52
  * The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
46
- * call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
47
- * credentials never expire.
53
+ * call site can start on a lapsed token by forgetting to ask.
48
54
  *
49
55
  * 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.
56
+ * stated expiry — is retried ONCE on credentials refreshed by force, for a connector that can refresh.
57
+ * A rejection that cannot be refreshed away (a second one, or a connector whose keys never expire,
58
+ * such as WooCommerce's) marks the store `error` so it reads as "reconnect".
51
59
  */
52
60
  export function withLiveCredentials(connector: ChannelConnector, db: PluginDb): ChannelConnector {
53
- if (!connector.liveCredentials) return connector;
54
61
  const around = <A extends unknown[], T>(call: StoreCall<A, T>): StoreCall<A, T> => async (store, ...args) => {
55
62
  const current = await resolveLiveCredentials(connector, db, store);
56
63
  if (!current.ok) return current;
57
64
  const first = await call.call(connector, current.value, ...args);
58
65
  if (first.ok || first.error.code !== CHANNEL_CREDENTIALS_REJECTED) return first;
66
+ if (!connector.liveCredentials) {
67
+ await markCredentialsRejected(db, store.id);
68
+ return first;
69
+ }
59
70
  const refreshed = await resolveLiveCredentials(connector, db, current.value, { force: true });
60
71
  if (!refreshed.ok) return refreshed;
61
- 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) await markCredentialsRejected(db, store.id);
74
+ return second;
62
75
  };
63
76
  return {
64
77
  ...connector,
@@ -73,6 +86,11 @@ export function withLiveCredentials(connector: ChannelConnector, db: PluginDb):
73
86
  ...(connector.pushCatalog ? { pushCatalog: around(connector.pushCatalog) } : {}),
74
87
  ...(connector.reserve ? { reserve: around(connector.reserve) } : {}),
75
88
  ...(connector.registerWebhooks ? { registerWebhooks: around(connector.registerWebhooks) } : {}),
89
+ ...(connector.unregisterWebhooks ? { unregisterWebhooks: around(connector.unregisterWebhooks) } : {}),
90
+ ...(connector.webhookHealth ? { webhookHealth: around(connector.webhookHealth) } : {}),
91
+ ...(connector.decodeWebhook ? { decodeWebhook: around(connector.decodeWebhook) } : {}),
92
+ ...(connector.orderEvents ? { orderEvents: around(connector.orderEvents) } : {}),
76
93
  ...(connector.cancelOrder ? { cancelOrder: around(connector.cancelOrder) } : {}),
94
+ ...(connector.requestReturn ? { requestReturn: around(connector.requestReturn) } : {}),
77
95
  };
78
96
  }
@@ -4,8 +4,10 @@ import {
4
4
  Ok,
5
5
  defineChannelConnector,
6
6
  } from "@porulle/core";
7
+ import { z } from "zod";
7
8
  import type {
8
9
  ChannelCatalogItem,
10
+ ChannelEvent,
9
11
  ChannelConnectorError,
10
12
  ChannelInventoryLevel,
11
13
  ChannelOrderSlice,
@@ -76,6 +78,28 @@ const defaultCatalog: ChannelCatalogItem[] = [{
76
78
  }],
77
79
  }];
78
80
 
81
+ const level = z.object({ externalId: z.string(), available: z.number() });
82
+ const shipment = z.object({
83
+ remoteId: z.string(),
84
+ carrier: z.string().exactOptional(),
85
+ trackingNumber: z.string().exactOptional(),
86
+ trackingUrl: z.string().exactOptional(),
87
+ lines: z.array(z.object({ externalVariantId: z.string(), quantity: z.number().int() })),
88
+ source: z.string().exactOptional(),
89
+ });
90
+ /** A mock delivery's body IS what it means: one {@link ChannelEvent} or a list of them. */
91
+ const channelEventSchema = z.discriminatedUnion("kind", [
92
+ z.object({ kind: z.literal("product.changed"), externalIds: z.array(z.string()) }),
93
+ z.object({ kind: z.literal("product.deleted"), externalIds: z.array(z.string()) }),
94
+ z.object({ kind: z.literal("inventory.changed"), levels: z.array(level) }),
95
+ z.object({ kind: z.literal("order.cancelled"), remoteOrderId: z.string() }),
96
+ z.object({ kind: z.literal("order.fulfilled"), remoteOrderId: z.string(), partial: z.boolean(), shipments: z.array(shipment) }),
97
+ 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() })) }),
98
+ z.object({ kind: z.literal("return.updated"), remoteReturnId: z.string(), status: z.enum(["approved", "declined", "closed", "cancelled"]) }),
99
+ z.object({ kind: z.literal("connection.revoked") }),
100
+ z.object({ kind: z.literal("compliance.request"), request: z.enum(["customer_data", "customer_redact", "shop_redact"]), data: z.record(z.string(), z.unknown()) }),
101
+ ]) satisfies z.ZodType<ChannelEvent>;
102
+
79
103
  export function mockChannelConnector(options: MockChannelConnectorOptions = {}) {
80
104
  const orders = new Map<string, ChannelOrderSlice>();
81
105
  const catalog = new Map<string, ChannelPushCatalogItem>();
@@ -92,6 +116,16 @@ export function mockChannelConnector(options: MockChannelConnectorOptions = {})
92
116
  async importCatalog(_store?: ChannelStore) {
93
117
  return Ok({ items: options.catalog ?? defaultCatalog, nextCursor: null });
94
118
  },
119
+ /** The catalogue as it stands when asked: a test changes `options.catalog` to change "the store". */
120
+ async fetchCatalogItems(_store, externalIds) {
121
+ const wanted = new Set(externalIds);
122
+ return Ok((options.catalog ?? defaultCatalog).filter((item) => wanted.has(item.externalId)));
123
+ },
124
+ async decodeWebhook(_store, event) {
125
+ const parsed = z.union([channelEventSchema, z.array(channelEventSchema)]).safeParse(event.data);
126
+ if (!parsed.success) return Err({ code: "MOCK_WEBHOOK_MALFORMED", message: parsed.error.message, retriable: false });
127
+ return Ok(Array.isArray(parsed.data) ? parsed.data : [parsed.data]);
128
+ },
95
129
  async fetchInventory(_store, ids) {
96
130
  const requestedIds = ids ?? [];
97
131
  options.onFetchInventory?.(requestedIds);
package/src/schema.ts CHANGED
@@ -14,6 +14,17 @@ import type { ChannelOrderAddress, ChannelPushCatalogItem, FieldPath } from "@po
14
14
  import { sellableEntities } from "@porulle/core/schema";
15
15
  import type { CatalogFieldMapping } from "./catalog-field-mapping.js";
16
16
 
17
+ /** What the last on-visit check found. `webhooks`: subscriptions all active, recreated, or still failing. */
18
+ export interface StoreHealth {
19
+ checkedAt: string;
20
+ webhooks: "ok" | "repaired" | "failing" | "not_applicable";
21
+ repaired: number;
22
+ missing: string[];
23
+ keyValid: boolean;
24
+ /** Why the check could not finish, when it could not. */
25
+ error?: string;
26
+ }
27
+
17
28
  export const connectedStores = pgTable(
18
29
  "connected_stores",
19
30
  {
@@ -22,9 +33,15 @@ export const connectedStores = pgTable(
22
33
  provider: text("provider").notNull(),
23
34
  credentials: jsonb("credentials").$type<Record<string, unknown>>().notNull(),
24
35
  storeDomain: text("store_domain").notNull(),
25
- status: text("status", { enum: ["connected", "disconnected", "error"] })
36
+ status: text("status", { enum: ["connecting", "connected", "disconnected", "error"] })
26
37
  .notNull()
27
38
  .default("connected"),
39
+ /** Why the store is in `error`, in words the merchant can act on. Null in every other status. */
40
+ statusReason: text("status_reason"),
41
+ /** The last check of the store's webhooks and key, made on a merchant's visit (never on a schedule). */
42
+ health: jsonb("health").$type<StoreHealth>(),
43
+ /** When the store last delivered a webhook we accepted. */
44
+ lastEventAt: timestamp("last_event_at", { withTimezone: true }),
28
45
  catalogWriteEnabled: boolean("catalog_write_enabled").notNull().default(false),
29
46
  catalogFieldMapping: jsonb("catalog_field_mapping").$type<CatalogFieldMapping>().notNull().default([]),
30
47
  catalogCursor: text("catalog_cursor"),
@@ -152,6 +169,8 @@ export const channelOrderExports = pgTable(
152
169
  failureKind: text("failure_kind", { enum: ["definitive", "transient"] }),
153
170
  remoteOrderId: text("remote_order_id"),
154
171
  remoteUrl: text("remote_url"),
172
+ /** When the store's side of the order was last read by a read point catching up a missed delivery. */
173
+ remoteCheckedAt: timestamp("remote_checked_at", { withTimezone: true }),
155
174
  attempts: integer("attempts").notNull().default(0),
156
175
  lastError: text("last_error"),
157
176
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),