@porulle/plugin-channel-connector 0.39.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/dist/hooks.js +157 -10
- package/dist/service.d.ts +15 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/hooks.ts +194 -14
- package/src/service.ts +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/plugin-channel-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@hono/zod-openapi": "^1.2.2",
|
|
21
21
|
"hono": "^4.12.5",
|
|
22
|
-
"@porulle/core": "0.
|
|
22
|
+
"@porulle/core": "0.40.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/node": "^24.5.2",
|
package/src/hooks.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { resolveOrgIdForCommerce } from "@porulle/core";
|
|
2
|
-
import type {
|
|
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
|
-
|
|
39
|
-
|
|
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;
|