@porulle/plugin-channel-connector 0.38.0 → 0.40.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.38.0",
3
+ "version": "0.40.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,15 +19,15 @@
19
19
  "dependencies": {
20
20
  "@hono/zod-openapi": "^1.2.2",
21
21
  "hono": "^4.12.5",
22
- "@porulle/core": "0.38.0"
22
+ "@porulle/core": "0.40.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^24.5.2",
26
26
  "eslint": "^9.39.1",
27
27
  "typescript": "5.9.2",
28
28
  "vitest": "^3.2.4",
29
- "@porulle/typescript-config": "0.1.0",
30
- "@porulle/eslint-config": "0.1.0"
29
+ "@porulle/eslint-config": "0.1.0",
30
+ "@porulle/typescript-config": "0.1.0"
31
31
  },
32
32
  "publishConfig": {
33
33
  "access": "public"
package/src/hooks.ts CHANGED
@@ -1,10 +1,16 @@
1
1
  import { resolveOrgIdForCommerce } from "@porulle/core";
2
- import type { Actor, CommerceConfig, PluginHookRegistration } from "@porulle/core";
2
+ import type {
3
+ Actor,
4
+ CommerceConfig,
5
+ HookContext,
6
+ PluginHookRegistration,
7
+ } from "@porulle/core";
3
8
  import { and, eq, inArray } from "@porulle/core/drizzle";
4
9
  import { sellableEntities } from "@porulle/core/schema";
5
10
  import {
6
11
  ChannelConnectorService,
7
12
  type ChannelConnectorPluginOptions,
13
+ type ChannelPushTrigger,
8
14
  type ChannelStockLine,
9
15
  } from "./service.js";
10
16
  import {
@@ -12,6 +18,190 @@ import {
12
18
  recordUpdateFieldPaths,
13
19
  } from "./catalog-push-trigger.js";
14
20
 
21
+ /**
22
+ * The state an order sits in while it waits for a payment gateway. Core owns it
23
+ * and its machine runs `pending_payment -> confirmed | cancelled`.
24
+ */
25
+ const PENDING_PAYMENT = "pending_payment";
26
+
27
+ /** The committed order, as much of it as the push needs. */
28
+ interface PushOrderResult {
29
+ id: string;
30
+ status: string | undefined;
31
+ entityIds: string[];
32
+ }
33
+
34
+ /** The transition that produced a committed order, on `orders.afterStatusChange`. */
35
+ interface StatusTransition {
36
+ fromStatus: string;
37
+ newStatus: string;
38
+ }
39
+
40
+ function isRecord(value: unknown): value is Record<string, unknown> {
41
+ return typeof value === "object" && value !== null;
42
+ }
43
+
44
+ /**
45
+ * Parse the committed order out of a hook payload rather than asserting its
46
+ * shape. The kernel is a trust boundary for this plugin: an order that arrives
47
+ * without an `id`, or whose line items are not the shape we expect, is skipped
48
+ * rather than pushed against a half-read payload.
49
+ *
50
+ * `orders.afterCreate` and `orders.afterStatusChange` both deliver the committed
51
+ * order as `result`, so one parser serves both.
52
+ */
53
+ function parseOrderResult(args: unknown): PushOrderResult | null {
54
+ if (!isRecord(args)) return null;
55
+ const { result } = args;
56
+ if (!isRecord(result)) return null;
57
+ const { id, status, lineItems } = result;
58
+ if (typeof id !== "string" || id === "") return null;
59
+
60
+ const entityIds = Array.isArray(lineItems)
61
+ ? lineItems.flatMap((line) =>
62
+ isRecord(line) && typeof line.entityId === "string" ? [line.entityId] : []
63
+ )
64
+ : [];
65
+
66
+ return {
67
+ id,
68
+ status: typeof status === "string" ? status : undefined,
69
+ entityIds,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Parse the transition from a hook payload. `data` carries it on
75
+ * `orders.afterStatusChange`; a payload without both statuses is not a
76
+ * transition this plugin can reason about, so it pushes nothing.
77
+ */
78
+ function parseStatusTransition(args: unknown): StatusTransition | null {
79
+ if (!isRecord(args)) return null;
80
+ const { data } = args;
81
+ if (!isRecord(data)) return null;
82
+ const { fromStatus, newStatus } = data;
83
+ if (typeof fromStatus !== "string" || typeof newStatus !== "string") return null;
84
+ return { fromStatus, newStatus };
85
+ }
86
+
87
+ /**
88
+ * Narrow a hook payload to one carrying the kernel's own `HookContext`.
89
+ *
90
+ * A type predicate rather than a cast: the capabilities it carries are live
91
+ * objects — a Drizzle handle and a jobs adapter — which cannot be validated by
92
+ * shape, so this checks that they are present and callable and lets the kernel's
93
+ * published type describe them. A payload missing either is skipped rather than
94
+ * pushed against.
95
+ */
96
+ function hasHookContext(args: unknown): args is { context: HookContext } {
97
+ if (!isRecord(args)) return false;
98
+ const { context } = args;
99
+ if (!isRecord(context)) return false;
100
+ const { db, jobs } = context;
101
+ return (
102
+ isRecord(db) &&
103
+ isRecord(jobs) &&
104
+ typeof jobs.enqueue === "function"
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Enqueue one `channel/push-order` job per store that owns a line of this order.
110
+ * `concurrencyKey` + `supersedes` collapse repeats on the same order and store.
111
+ */
112
+ async function pushForOrder(
113
+ order: PushOrderResult,
114
+ context: HookContext,
115
+ ): Promise<void> {
116
+ if (order.entityIds.length === 0) return;
117
+ const orgId = resolveOrgIdForCommerce(context.actor, context.commerceConfig);
118
+
119
+ const entities = await context.db
120
+ .select({
121
+ id: sellableEntities.id,
122
+ sourceStoreId: sellableEntities.sourceStoreId,
123
+ })
124
+ .from(sellableEntities)
125
+ .where(and(
126
+ eq(sellableEntities.organizationId, orgId),
127
+ inArray(sellableEntities.id, order.entityIds),
128
+ ));
129
+
130
+ const stores = new Set(
131
+ entities
132
+ .map((entity) => entity.sourceStoreId)
133
+ .filter((storeId): storeId is string => storeId !== null),
134
+ );
135
+
136
+ await Promise.all([...stores].map((storeId) =>
137
+ context.jobs.enqueue(
138
+ "channel/push-order",
139
+ { orgId, storeId, orderId: order.id },
140
+ {
141
+ organizationId: orgId,
142
+ concurrencyKey: `push:${order.id}:${storeId}`,
143
+ supersedes: true,
144
+ },
145
+ )
146
+ ));
147
+ }
148
+
149
+ /**
150
+ * The hooks that trigger the order push, by mode. The switch is exhaustive over
151
+ * `ChannelPushTrigger`, so a fourth mode fails to compile rather than silently
152
+ * registering nothing.
153
+ *
154
+ * `"payment"` registers BOTH hooks on purpose. An order created directly in
155
+ * `pending` — a store with no payment step, which is most consumers — never
156
+ * transitions out of `pending_payment`, so dropping `orders.afterCreate`
157
+ * entirely would silently stop pushing for them.
158
+ *
159
+ * On the transition side the predicate is `fromStatus === PENDING_PAYMENT`, not
160
+ * "the new status looks paid". That is exactly-once by construction: core
161
+ * commits the status with a compare-and-swap, so only one caller wins a given
162
+ * transition. Keying on the new status alone would re-push on every later move
163
+ * (`confirmed -> processing -> fulfilled`), and `exportOrder` short-circuits only
164
+ * on an already-`confirmed` export — one still `exported` is pushed again.
165
+ */
166
+ function pushHooks(mode: ChannelPushTrigger): PluginHookRegistration[] {
167
+ switch (mode) {
168
+ case false:
169
+ return [];
170
+
171
+ case "create":
172
+ return [{
173
+ key: "orders.afterCreate",
174
+ async handler(args: unknown) {
175
+ const order = parseOrderResult(args);
176
+ if (order === null || !hasHookContext(args)) return;
177
+ await pushForOrder(order, args.context);
178
+ },
179
+ }];
180
+
181
+ case "payment":
182
+ return [{
183
+ key: "orders.afterCreate",
184
+ async handler(args: unknown) {
185
+ const order = parseOrderResult(args);
186
+ if (order === null || !hasHookContext(args)) return;
187
+ // A gateway order waits for its notify; anything else is unchanged.
188
+ if (order.status === PENDING_PAYMENT) return;
189
+ await pushForOrder(order, args.context);
190
+ },
191
+ }, {
192
+ key: "orders.afterStatusChange",
193
+ async handler(args: unknown) {
194
+ const transition = parseStatusTransition(args);
195
+ const order = parseOrderResult(args);
196
+ if (transition === null || order === null || !hasHookContext(args)) return;
197
+ if (transition.fromStatus !== PENDING_PAYMENT) return;
198
+ if (transition.newStatus === "cancelled") return;
199
+ await pushForOrder(order, args.context);
200
+ },
201
+ }];
202
+ }
203
+ }
204
+
15
205
  export function buildHooks(options: ChannelConnectorPluginOptions): PluginHookRegistration[] {
16
206
  return [{
17
207
  key: "checkout.beforePayment",
@@ -34,19 +224,9 @@ export function buildHooks(options: ChannelConnectorPluginOptions): PluginHookRe
34
224
  );
35
225
  return data;
36
226
  },
37
- }, {
38
- key: "orders.afterCreate",
39
- async handler(args: unknown) {
40
- const { result, context } = args as {
41
- result: { id: string; lineItems?: Array<{ entityId: string }> };
42
- context: { actor: Actor | null; commerceConfig?: CommerceConfig | null; db: ConstructorParameters<typeof ChannelConnectorService>[0]; services: Record<string, unknown>; jobs: { enqueue(task: string, input: Record<string, unknown>, options: { organizationId: string; concurrencyKey: string; supersedes: boolean }): Promise<string> } };
43
- };
44
- const orgId = resolveOrgIdForCommerce(context.actor, context.commerceConfig);
45
- const entities = await context.db.select({ id: sellableEntities.id, sourceStoreId: sellableEntities.sourceStoreId }).from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.id, (result.lineItems ?? []).map((line) => line.entityId))));
46
- const stores = new Set(entities.map((entity) => entity.sourceStoreId).filter((storeId): storeId is string => storeId !== null));
47
- await Promise.all([...stores].map((storeId) => context.jobs.enqueue("channel/push-order", { orgId, storeId, orderId: result.id }, { organizationId: orgId, concurrencyKey: `push:${result.id}:${storeId}`, supersedes: true })));
48
- },
49
- }, {
227
+ },
228
+ ...pushHooks(options.pushOrderOn ?? "payment"),
229
+ {
50
230
  key: "catalog.beforeUpdate",
51
231
  handler(args: unknown) {
52
232
  const { data, context } = args as {
package/src/service.ts CHANGED
@@ -224,8 +224,24 @@ export interface ChannelConnectorPluginOptions {
224
224
  newStoreDays?: number;
225
225
  driftAlertThreshold?: number;
226
226
  reconcileJitterWindowMs?: number;
227
+ /**
228
+ * When the order push fires. Default `"payment"`.
229
+ *
230
+ * - `"payment"` — an order created in `pending_payment` is NOT pushed; it is
231
+ * pushed when it leaves that state for anything but `cancelled`. An order
232
+ * created in `pending` is pushed on creation, as before, because a store
233
+ * with no payment step has no transition to hang the push on.
234
+ * - `"create"` — the pre-0.40.0 trigger: push as soon as the order row exists,
235
+ * whatever its status.
236
+ * - `false` — never push automatically; the consumer enqueues
237
+ * `channel/push-order` itself.
238
+ */
239
+ pushOrderOn?: ChannelPushTrigger;
227
240
  }
228
241
 
242
+ /** When {@link ChannelConnectorPluginOptions.pushOrderOn} fires the order push. */
243
+ export type ChannelPushTrigger = "create" | "payment" | false;
244
+
229
245
  export interface ChannelStockLine {
230
246
  entityId: string;
231
247
  variantId?: string;