@rytass/wms-module-graphql 0.5.0 → 0.6.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/README.md CHANGED
@@ -96,19 +96,45 @@ Intervene in and get notified about mutations. Every root mutation carries a typ
96
96
  - **`afterListeners`** run **after the outermost transaction commits** — never notified about rolled-back changes.
97
97
 
98
98
  ```ts
99
+ import { memberBaseActorExtractor } from '@rytass/wms-module-graphql/adapters/member-base';
100
+
99
101
  WmsModuleGraphqlModule.forRootAsync({
100
102
  ...coreOptions,
103
+
104
+ // (v2) populate payload.actor on every event — reads req.payload → { id, domain }
105
+ actorExtractor: memberBaseActorExtractor,
106
+
107
+ // (v2) append materialId/batchId/locationId/loaderId to stock event payloads
108
+ enrichStockEvents: true,
109
+
101
110
  beforeHooks: {
102
- 'ship-inventory-order.shipped': async ({ args }) => {
103
- if (!allowed(args)) throw new ForbiddenException('blocked');
111
+ 'ship-inventory-order.shipped': async ({ args, actor }) => {
112
+ if (!allowed(args, actor)) throw new ForbiddenException('blocked');
104
113
  },
105
114
  },
106
115
  afterListeners: {
116
+ // v2: payload.args carries items[]; payload.actor carries { id, domain }
117
+ 'ship-inventory-order.shipped': async ({ payload }) => {
118
+ await erp.markShipped(payload.id, payload.result, payload.actor);
119
+ },
120
+ // v2 with enrichStockEvents: materialId/batchId/locationId/loaderId also available
107
121
  'stock.allocated': async ({ payload }) => { await erp.reserve(payload); },
108
122
  },
109
123
  });
110
124
  ```
111
125
 
126
+ **v2 payload enrichment** (upcoming minor — `core@0.3.0` / `graphql@0.6.0`):
127
+
128
+ - Operation events gain `args` (the GraphQL input that triggered the mutation) and `actor`
129
+ (`WmsActor | null`). Nine action mutations return `boolean`; their real information is in
130
+ `args` and the stock events they fan out.
131
+ - Stock events gain opt-in `materialId / batchId / locationId / loaderId` when
132
+ `enrichStockEvents: true` is set. **Caveat**: split / merge / reclassify order-items have
133
+ no `materialId` / `batchId` at the item level — only `locationId` / `loaderId` are
134
+ available for those three domains even with eager enrichment.
135
+ - `WmsOperationResultMap` provides a per-event typed overlay so subscribers receive the
136
+ concrete DTO (`MaterialDto`, `ShipInventoryOrderDto`, …) instead of `unknown`.
137
+
112
138
  Post-commit delivery is guaranteed by an `AsyncLocalStorage` dispatch scope (events buffer during the request and flush only after commit). Prefer `@OnEvent`? Opt into the optional [`@nestjs/event-emitter`](https://docs.nestjs.com/techniques/events) bridge from `@rytass/wms-module-graphql/adapters/event-emitter` — every event also emits as `wms.<event>`. Nothing configured ⇒ no events, zero overhead.
113
139
 
114
140
  ➡️ **Full design, the complete 56-event list & limitations: [`docs/events.md`](./docs/events.md)**.
@@ -154,11 +180,11 @@ Full documentation is shipped inside this package under the `docs/` directory (a
154
180
 
155
181
  GitHub hosted links (always up-to-date):
156
182
 
157
- - [Integration Guide — §4 Backend setup](https://github.com/Rytass/WMS-Module/blob/main/docs/integration-guide.md#4-backend-setup-nestjs--graphql--typeorm)
158
- - [Integration Guide — §13.1 Existing GraphQLModule co-existence](https://github.com/Rytass/WMS-Module/blob/main/docs/integration-guide.md#13-integrating-into-an-existing-nestjs--nextjs-project)
183
+ - [Integration Guide — §4 Backend setup](https://github.com/Rytass/WMS-Module/blob/main/libs/graphql/docs/integration-guide.md#4-backend-setup-nestjs--graphql--typeorm)
184
+ - [Integration Guide — §13.1 Existing GraphQLModule co-existence](https://github.com/Rytass/WMS-Module/blob/main/libs/graphql/docs/integration-guide.md#13-integrating-into-an-existing-nestjs--nextjs-project)
159
185
  - [Authorization — design & recipes](https://github.com/Rytass/WMS-Module/blob/main/libs/graphql/docs/authorization.md)
160
186
  - [Events & Hooks — design & event list](https://github.com/Rytass/WMS-Module/blob/main/libs/graphql/docs/events.md)
161
- - [API Reference — @rytass/wms-module-graphql](https://github.com/Rytass/WMS-Module/blob/main/docs/api-reference.md#rytasswms-module-graphql)
187
+ - [API Reference — @rytass/wms-module-graphql](https://github.com/Rytass/WMS-Module/blob/main/libs/graphql/docs/api-reference.md#rytasswms-module-graphql)
162
188
 
163
189
  ## License
164
190
 
@@ -1,3 +1,4 @@
1
+ import type { WmsActorExtractor } from '@rytass/wms-module-core';
1
2
  import type { WmsAuthChecker, WmsOperationRequirement } from '../lib/auth';
2
3
  /**
3
4
  * A Casbin `[subject, action]` tuple, e.g. `['wms:material', 'read']`.
@@ -33,3 +34,13 @@ export interface MemberBaseWmsAuthCheckerOptions {
33
34
  * (Apollo's default).
34
35
  */
35
36
  export declare const createMemberBaseWmsAuthChecker: (options: MemberBaseWmsAuthCheckerOptions) => WmsAuthChecker;
37
+ /**
38
+ * A {@link WmsActorExtractor} for `@rytass/member-base-nestjs-module`. Reads the
39
+ * verified JWT payload that `CasbinGuard` attaches to the request and surfaces it
40
+ * as the event/hook `actor`. Returns `null` for unauthenticated requests.
41
+ *
42
+ * Pass to `WmsModuleGraphqlModule.forRootAsync({ actorExtractor })` so every
43
+ * operation event and before-hook carries `{ id, domain }`. Imports nothing from
44
+ * member-base — it only reads the runtime request fields.
45
+ */
46
+ export declare const memberBaseActorExtractor: WmsActorExtractor;
@@ -43,5 +43,21 @@ const createMemberBaseWmsAuthChecker = (options) => {
43
43
  });
44
44
  };
45
45
  };
46
+ /**
47
+ * A {@link WmsActorExtractor} for `@rytass/member-base-nestjs-module`. Reads the
48
+ * verified JWT payload that `CasbinGuard` attaches to the request and surfaces it
49
+ * as the event/hook `actor`. Returns `null` for unauthenticated requests.
50
+ *
51
+ * Pass to `WmsModuleGraphqlModule.forRootAsync({ actorExtractor })` so every
52
+ * operation event and before-hook carries `{ id, domain }`. Imports nothing from
53
+ * member-base — it only reads the runtime request fields.
54
+ */
55
+ const memberBaseActorExtractor = (context) => {
56
+ const payload = extractRequest(context)?.payload;
57
+ if (!payload?.id) {
58
+ return null;
59
+ }
60
+ return { id: payload.id, domain: payload.domain, raw: payload };
61
+ };
46
62
 
47
- export { createMemberBaseWmsAuthChecker };
63
+ export { createMemberBaseWmsAuthChecker, memberBaseActorExtractor };
@@ -455,37 +455,57 @@ Opt-in `beforeHooks` (veto) and post-commit `afterListeners` for every mutation.
455
455
 
456
456
  ### Root exports (`@rytass/wms-module-graphql`, re-exported from `@rytass/wms-module-core`)
457
457
 
458
- | Export | Kind | Signature / shape |
459
- |---|---|---|
460
- | `WmsMutationEvent` | decorator | `({ event: WmsOperationEventName, resolveId? }) => MethodDecorator` |
461
- | `WmsEventModuleOptions` | type | `{ beforeHooks?: WmsBeforeHookMap; afterListeners?: WmsAfterListenerMap }` — part of `forRootAsync` options |
462
- | `WmsBeforeHookMap` | type | `{ [N in WmsEventName]?: (ctx: WmsBeforeHookContext<N>) => void \| Promise<void> }` |
463
- | `WmsAfterListenerMap` | type | `{ [N in WmsEventName]?: (ctx: WmsAfterEventContext<N>) => void \| Promise<void> }` |
464
- | `WmsBeforeHookContext` | type | `{ event; args: Record<string, unknown>; executionContext: ExecutionContext }` |
465
- | `WmsAfterEventContext` | type | `{ event; payload: WmsEventPayloadMap[event] }` |
466
- | `WmsDomainEvent` | type | `{ name: WmsEventName; payload }` |
467
- | `WmsEventName` / `WmsOperationEventName` / `WmsStockEventName` | type | event-name unions (38 operation + 18 stock) |
468
- | `WmsEventPayloadMap` | type | event name → payload |
469
- | `WmsOperationEventPayload` | type | `{ id: string \| null; result: unknown }` |
470
- | `WmsStockEventPayload` | type | `{ orderId; itemId; quantityDelta; changeType: ChangeType \| null; inspectionResult? }` |
458
+ | Export | Kind | Signature / shape |
459
+ |-----------------------------------------------------------------|-----------|----------------------------------------------------------------------------------------------------------------------------------|
460
+ | `WmsMutationEvent` | decorator | `({ event: WmsOperationEventName, resolveId? }) => MethodDecorator` |
461
+ | `WmsEventModuleOptions` | type | `{ beforeHooks?, afterListeners?, actorExtractor?, enrichStockEvents? }` — part of `forRootAsync` options |
462
+ | `WmsBeforeHookMap` | type | `{ [N in WmsEventName]?: (ctx: WmsBeforeHookContext<N>) => void \| Promise<void> }` |
463
+ | `WmsAfterListenerMap` | type | `{ [N in WmsEventName]?: (ctx: WmsAfterEventContext<N>) => void \| Promise<void> }` |
464
+ | `WmsBeforeHookContext` | type | `{ event; args: Record<string, unknown>; executionContext: ExecutionContext; actor: WmsActor \| null }` (v2 adds `actor`) |
465
+ | `WmsAfterEventContext` | type | `{ event; payload: WmsEventPayloadMap[event] }` |
466
+ | `WmsDomainEvent` | type | `{ name: WmsEventName; payload }` |
467
+ | `WmsEventName` / `WmsOperationEventName` / `WmsStockEventName` | type | event-name unions (38 operation + 18 stock) |
468
+ | `WmsEventPayloadMap` | type | event name → payload |
469
+ | `WmsOperationEventPayload` | type | v1: `{ id: string \| null; result: unknown }` — v2: `{ id; result; args: Readonly<Record<string, unknown>>; actor: WmsActor \| null }` |
470
+ | `WmsStockEventPayload` | type | `{ orderId; itemId; quantityDelta; changeType: ChangeType \| null; inspectionResult? }` — v2 adds optional `materialId?`, `batchId?`, `locationId?`, `loaderId?` when `enrichStockEvents: true` |
471
+ | `WmsActor` | type | `{ readonly id: string; readonly domain?: string; readonly raw?: unknown }` — actor identity (v2) |
472
+ | `WmsActorExtractor` | type | `(context: ExecutionContext) => WmsActor \| null` — injectable extractor (v2); token `WMS_ACTOR_EXTRACTOR` |
473
+ | `WmsOperationResultMap` | type | Maps each `WmsOperationEventName` to its concrete `result` DTO (e.g. `'material.created' → MaterialDto`); powers typed listeners (v2) |
471
474
 
472
475
  ### Adapter subpath (`@rytass/wms-module-graphql/adapters/event-emitter`)
473
476
 
474
- | Export | Kind | Purpose |
475
- |---|---|---|
477
+ | Export | Kind | Purpose |
478
+ |-------------------------------|------------|------------------------------------------------------------------------------------------------------------------------------------------|
476
479
  | `wmsEventEmitterBridgeProvider` | `Provider` | Pass to `forRootAsync({ eventEmitterBridge })`; fans every event onto `EventEmitter2` as `wms.<event>` for `@OnEvent`. Requires `@nestjs/event-emitter` (optional peer) + `EventEmitterModule.forRoot()`. |
477
480
 
481
+ ### Adapter subpath (`@rytass/wms-module-graphql/adapters/member-base`) — event extras (v2)
482
+
483
+ | Export | Kind | Purpose |
484
+ |-----------------------------|-----------|-------------------------------------------------------------------------------------------------------------------------------------|
485
+ | `memberBaseActorExtractor` | extractor | `WmsActorExtractor` that reads `GqlExecutionContext.create(ctx).getContext().req?.payload` → `{ id, domain }`. Pass to `forRootAsync({ actorExtractor })`. |
486
+
478
487
  ```ts
488
+ import { memberBaseActorExtractor } from '@rytass/wms-module-graphql/adapters/member-base';
489
+ import { wmsEventEmitterBridgeProvider } from '@rytass/wms-module-graphql/adapters/event-emitter';
490
+
479
491
  WmsModuleGraphqlModule.forRootAsync({
480
492
  ...coreOptions,
481
- beforeHooks: { 'ship-inventory-order.shipped': async ({ args }) => { if (!ok(args)) throw new ForbiddenException(); } },
482
- afterListeners: { 'stock.allocated': async ({ payload }) => { await sync(payload); } },
493
+ actorExtractor: memberBaseActorExtractor, // v2: populates payload.actor
494
+ enrichStockEvents: true, // v2: appends ids to stock payloads
495
+ beforeHooks: {
496
+ 'ship-inventory-order.shipped': async ({ args, actor }) => {
497
+ if (!ok(args, actor)) throw new ForbiddenException();
498
+ },
499
+ },
500
+ afterListeners: {
501
+ 'stock.allocated': async ({ payload }) => { await sync(payload); },
502
+ },
483
503
  // optional @OnEvent bridge:
484
504
  // eventEmitterBridge: wmsEventEmitterBridgeProvider,
485
505
  });
486
506
  ```
487
507
 
488
- `beforeHooks` / `afterListeners` are **not** part of the GraphQL schema — they are runtime hooks. With none configured the interceptor passes through with zero overhead.
508
+ `beforeHooks` / `afterListeners` are **not** part of the GraphQL schema — they are runtime hooks. With none configured the interceptor passes through with zero overhead. All v2 options (`actorExtractor`, `enrichStockEvents`) are fully optional; omitting them preserves the v1 behaviour exactly.
489
509
 
490
510
  ---
491
511
 
package/docs/events.md CHANGED
@@ -36,6 +36,23 @@ Two hook points per event:
36
36
  Everything is optional. With nothing configured the module behaves exactly as
37
37
  before — no events, zero overhead.
38
38
 
39
+ ### v2 payload enrichment (upcoming minor)
40
+
41
+ Operation event payloads will gain two new fields — `args` (the GraphQL input
42
+ that triggered the mutation) and `actor` (who performed it, obtained via an
43
+ injectable `actorExtractor`) — turning the shape from `{ id, result }` into
44
+ `{ id, result, args, actor }`. The `result` field will also become **per-event
45
+ typed** via `WmsOperationResultMap` so subscribers receive the concrete DTO
46
+ instead of `unknown`. Nine action mutations (`received`, `deallocated`, …) still
47
+ return `boolean`; their real information comes from `args` and the stock events
48
+ they fan out.
49
+
50
+ Stock event payloads gain an opt-in `enrichStockEvents: true` flag on
51
+ `forRootAsync` that appends `materialId / batchId / locationId / loaderId` (one
52
+ indexed PK lookup per event, same transaction). Without the flag the lazy shape
53
+ is unchanged. See [§6 Payloads](#6-payloads) for the full shapes and the
54
+ split/merge/reclassify partial-enrichment caveat.
55
+
39
56
  ---
40
57
 
41
58
  ## 2. Architecture — post-commit guarantee
@@ -82,6 +99,7 @@ is inferred from the key.
82
99
 
83
100
  ```ts
84
101
  import { WmsModuleGraphqlModule } from '@rytass/wms-module-graphql';
102
+ import { memberBaseActorExtractor } from '@rytass/wms-module-graphql/adapters/member-base';
85
103
  import { ForbiddenException } from '@nestjs/common';
86
104
 
87
105
  WmsModuleGraphqlModule.forRootAsync({
@@ -90,6 +108,12 @@ WmsModuleGraphqlModule.forRootAsync({
90
108
  defaultLoaderTypeKey: 'DEFAULT',
91
109
  defaultLocationKey: 'DEFAULT',
92
110
 
111
+ // (v2) extract the actor from the request; member-base reads req.payload → { id, domain }
112
+ actorExtractor: memberBaseActorExtractor,
113
+
114
+ // (v2) append materialId/batchId/locationId/loaderId to stock event payloads
115
+ enrichStockEvents: true,
116
+
93
117
  // intervene — runs before any DB work; throw to veto
94
118
  beforeHooks: {
95
119
  'ship-inventory-order.shipped': async ({ args, executionContext }) => {
@@ -101,10 +125,12 @@ WmsModuleGraphqlModule.forRootAsync({
101
125
 
102
126
  // notify — runs after commit
103
127
  afterListeners: {
128
+ // v2: payload.args carries the items[] input; payload.actor carries { id, domain }
104
129
  'ship-inventory-order.shipped': async ({ payload }) => {
105
- await erp.markShipped(payload.id, payload.result);
130
+ await erp.markShipped(payload.id, payload.result, payload.actor);
106
131
  },
107
132
  'stock.allocated': async ({ payload }) => {
133
+ // v2 with enrichStockEvents: materialId/batchId/locationId/loaderId also available
108
134
  await erp.reserve(payload.orderId, payload.itemId, payload.quantityDelta);
109
135
  },
110
136
  },
@@ -117,6 +143,39 @@ WmsModuleGraphqlModule.forRootAsync({
117
143
  - An `afterListener` receives `{ event, payload }` — the committed result (see
118
144
  [§6](#6-payloads)). It may be async; a thrown error is logged and isolated, it
119
145
  never affects the committed mutation or other listeners.
146
+ - **(v2)** `payload.args` carries the full GraphQL input that triggered the mutation
147
+ — particularly useful for the 9 boolean action events whose `result` is
148
+ `true`/`false` only.
149
+ - **(v2)** `payload.actor` is `WmsActor | null` — populated when `actorExtractor`
150
+ is configured, `null` otherwise. Shape: `{ id: string; domain?: string; raw?:
151
+ unknown }`. The `memberBaseActorExtractor` helper (exported from the
152
+ `@rytass/wms-module-graphql/adapters/member-base` subpath) reads
153
+ `req.payload` and returns `{ id, domain }`.
154
+
155
+ ### 3.1 The 9 boolean action events
156
+
157
+ Nine mutations return `boolean` and cannot carry a rich DTO `result` without a
158
+ breaking GraphQL schema change. For subscribers, the meaningful signal is:
159
+
160
+ 1. **`args`** — the exact items input (see §5 for each event's args shape).
161
+ 2. **Stock events** — the operation fans out one `stock.*` event per affected item
162
+ (see the "Fans out to stock event" column in §5).
163
+
164
+ The operation event itself only confirms success (`result: true`).
165
+
166
+ | Event | args key items shape |
167
+ |------------------------------------------|--------------------------------------------------------------|
168
+ | `receive-inventory-order.received` | `{ itemId, quantity, batchKey?, locationKey?, loaderKey?, inspectionResult? }` |
169
+ | `ship-inventory-order.shipped` | `{ itemId, quantity }` |
170
+ | `ship-inventory-order.ship-cancelled` | `{ itemId, quantity? }` |
171
+ | `transfer-order.transferred` | `{ itemId, quantity }` |
172
+ | `transfer-order.transfer-cancelled` | `{ itemId, quantity? }` |
173
+ | `hold-inventory-order.released` | `{ aggregatedKey, quantity, unitId? }` |
174
+ | `allocate-inventory-order.deallocated` | `{ aggregatedKey, quantity, unitId? }` |
175
+ | `transfer-customer-order.returned` | `{ aggregatedKey, quantity, unitId?, locationKey, loaderKey? }` |
176
+ | `transfer-vendor-order.returned` | `{ aggregatedKey, quantity, unitId?, locationKey, loaderKey? }` |
177
+
178
+ All are passed as `@Args('items', …) items: T[]`, so `args.items` is the array.
120
179
 
121
180
  ---
122
181
 
@@ -158,52 +217,62 @@ and the bridge can be used together — both fire.
158
217
 
159
218
  ### Operation events (38) — `<domain>.<past-tense-action>`
160
219
 
161
- | Event | Mutation | Fans out to stock event |
162
- |---|---|---|
163
- | `receive-inventory-order.created` | createReceiveInventoryOrder | — |
164
- | `receive-inventory-order.updated` | updateReceiveInventoryOrder | — |
165
- | `receive-inventory-order.received` | receive | `stock.received` |
166
- | `ship-inventory-order.created` | createShipInventoryOrder | — |
167
- | `ship-inventory-order.updated` | updateShipInventoryOrder | `stock.in-shipment` |
168
- | `ship-inventory-order.shipped` | ship | `stock.shipped` |
169
- | `ship-inventory-order.ship-cancelled` | cancelShip | `stock.shipped` (negative) |
170
- | `transfer-order.created` | createTransferOrder | `stock.in-transfer` |
171
- | `transfer-order.updated` | updateTransferOrder | `stock.in-transfer` |
172
- | `transfer-order.transferred` | transfer | `stock.transferred` |
173
- | `transfer-order.transfer-cancelled` | cancelTransfer | `stock.in-transfer` (reversal) |
174
- | `hold-inventory-order.created` | createHoldInventoryOrder | `stock.held` |
175
- | `hold-inventory-order.updated` | updateHoldInventoryOrder | `stock.held` |
176
- | `hold-inventory-order.released` | release | `stock.released` |
177
- | `allocate-inventory-order.created` | createAllocateInventoryOrder | `stock.allocated` |
178
- | `allocate-inventory-order.updated` | updateAllocateInventoryOrder | `stock.allocated` |
179
- | `allocate-inventory-order.deallocated` | deallocate | `stock.deallocated` |
180
- | `transfer-vendor-order.created` | createTransferVendorOrder | `stock.vendor-transferred` |
181
- | `transfer-vendor-order.updated` | updateTransferVendorOrder | `stock.vendor-transferred` |
182
- | `transfer-vendor-order.returned` | returnTransferVendorOrder | `stock.vendor-returned` |
183
- | `transfer-customer-order.created` | createTransferCustomerOrder | `stock.customer-held` |
184
- | `transfer-customer-order.updated` | updateTransferCustomerOrder | `stock.customer-held` |
185
- | `transfer-customer-order.returned` | returnTransferCustomerOrder | `stock.customer-returned` |
186
- | `quality-inspection-order.inspected` | inspectMaterials | `stock.inspection-resolved` |
187
- | `scrape-order.scraped` | scrapeMaterials | `stock.scraped` |
188
- | `reclassify-inventory-order.reclassified` | reclassifyInventoryOrder | `stock.reclassified` |
189
- | `batch.merged` | mergeBatch | `stock.merged` |
190
- | `batch.splitted` | splitBatch | `stock.splitted` |
191
- | `material.created` | createMaterial | — |
192
- | `material.updated` | updateMaterial | — |
193
- | `loader.created` | createLoader | — |
194
- | `loader.batch-created` | createBatchLoader | — |
195
- | `loader.updated` | updateLoader | — |
196
- | `loader.type-created` | createLoaderType | — |
197
- | `loader.type-updated` | updateLoaderType | — |
198
- | `location.created` | createLocation | — |
199
- | `location.updated` | updateLocation | — |
200
- | `warehouse-map.updated` | updateWarehouseMap | — |
220
+ The **v2 `result` type** column shows the concrete type subscribers receive via
221
+ `WmsOperationResultMap`. Events marked ⚠️ **boolean** are the 9 action mutations
222
+ whose `result` is always `true`/`false`; their real signal is `args` + the stock
223
+ events they fan out (see §3.1).
224
+
225
+ | Event | Mutation | v2 `result` type | Fans out to stock event |
226
+ |---------------------------------------------|-------------------------------|-----------------------------------|-------------------------------|
227
+ | `receive-inventory-order.created` | createReceiveInventoryOrder | `ReceiveInventoryOrderDto` | — |
228
+ | `receive-inventory-order.updated` | updateReceiveInventoryOrder | `ReceiveInventoryOrderDto` | — |
229
+ | `receive-inventory-order.received` | receive | ⚠️ `boolean` | `stock.received` |
230
+ | `ship-inventory-order.created` | createShipInventoryOrder | `ShipInventoryOrderDto` | — |
231
+ | `ship-inventory-order.updated` | updateShipInventoryOrder | `ShipInventoryOrderDto` | `stock.in-shipment` |
232
+ | `ship-inventory-order.shipped` | ship | ⚠️ `boolean` | `stock.shipped` |
233
+ | `ship-inventory-order.ship-cancelled` | cancelShip | ⚠️ `boolean` | `stock.shipped` (negative) |
234
+ | `transfer-order.created` | createTransferOrder | `TransferOrderDto` | `stock.in-transfer` |
235
+ | `transfer-order.updated` | updateTransferOrder | `TransferOrderDto` | `stock.in-transfer` |
236
+ | `transfer-order.transferred` | transfer | ⚠️ `boolean` | `stock.transferred` |
237
+ | `transfer-order.transfer-cancelled` | cancelTransfer | ⚠️ `boolean` | `stock.in-transfer` (reversal)|
238
+ | `hold-inventory-order.created` | createHoldInventoryOrder | `HoldInventoryOrderDto` | `stock.held` |
239
+ | `hold-inventory-order.updated` | updateHoldInventoryOrder | `HoldInventoryOrderDto` | `stock.held` |
240
+ | `hold-inventory-order.released` | release | ⚠️ `boolean` | `stock.released` |
241
+ | `allocate-inventory-order.created` | createAllocateInventoryOrder | `AllocateInventoryOrderDto` | `stock.allocated` |
242
+ | `allocate-inventory-order.updated` | updateAllocateInventoryOrder | `AllocateInventoryOrderDto` | `stock.allocated` |
243
+ | `allocate-inventory-order.deallocated` | deallocate | ⚠️ `boolean` | `stock.deallocated` |
244
+ | `transfer-vendor-order.created` | createTransferVendorOrder | `TransferVendorOrderDto` | `stock.vendor-transferred` |
245
+ | `transfer-vendor-order.updated` | updateTransferVendorOrder | `TransferVendorOrderDto` | `stock.vendor-transferred` |
246
+ | `transfer-vendor-order.returned` | returnTransferVendorOrder | ⚠️ `boolean` | `stock.vendor-returned` |
247
+ | `transfer-customer-order.created` | createTransferCustomerOrder | `TransferCustomerOrderDto` | `stock.customer-held` |
248
+ | `transfer-customer-order.updated` | updateTransferCustomerOrder | `TransferCustomerOrderDto` | `stock.customer-held` |
249
+ | `transfer-customer-order.returned` | returnTransferCustomerOrder | ⚠️ `boolean` | `stock.customer-returned` |
250
+ | `quality-inspection-order.inspected` | inspectMaterials | `QualityInspectionOrderDto` | `stock.inspection-resolved` |
251
+ | `scrape-order.scraped` | scrapeMaterials | `ScrapeOrderDto` | `stock.scraped` |
252
+ | `reclassify-inventory-order.reclassified` | reclassifyInventoryOrder | `ReclassifyInventoryOrderDto` | `stock.reclassified` |
253
+ | `batch.merged` | mergeBatch | `MergeBatchOrderDto` | `stock.merged` |
254
+ | `batch.splitted` | splitBatch | `SplitBatchOrderDto` | `stock.splitted` |
255
+ | `material.created` | createMaterial | `MaterialDto` | — |
256
+ | `material.updated` | updateMaterial | `MaterialDto` | — |
257
+ | `loader.created` | createLoader | `LoaderDto[]` | — |
258
+ | `loader.batch-created` | createBatchLoader | `LoaderDto[]` | — |
259
+ | `loader.updated` | updateLoader | `LoaderDto` | — |
260
+ | `loader.type-created` | createLoaderType | `LoaderTypeDto` | — |
261
+ | `loader.type-updated` | updateLoaderType | `LoaderTypeDto` | — |
262
+ | `location.created` | createLocation | `LocationDto` | — |
263
+ | `location.updated` | updateLocation | `LocationDto` | — |
264
+ | `warehouse-map.updated` | updateWarehouseMap | `MapDataDto` | — |
201
265
 
202
266
  > The "Fans out to stock event" column is **indicative**, not a hard binding. A
203
267
  > mutation never names a stock event directly — `StockChangeSubscriber` derives
204
268
  > it from the `*_order_change` table that was written and the sign of
205
269
  > `quantityChanged`. So a mutation emits a stock event only on the code paths
206
270
  > that actually write a change row (e.g. an `update*` only when it adds items).
271
+ >
272
+ > The "v2 `result` type" column refers to the typed overlay provided by
273
+ > `WmsOperationResultMap` (exported from `@rytass/wms-module-graphql`). In v1
274
+ > `result` is typed `unknown`; consumer code using the typed maps gets the
275
+ > concrete DTO automatically.
207
276
 
208
277
  ### Stock domain events (18) — `stock.<change-type>`
209
278
 
@@ -227,27 +296,141 @@ with a negative `quantityDelta` (`cancelShip` -> `stock.shipped`,
227
296
 
228
297
  ## 6. Payloads
229
298
 
230
- **Operation event** — `WmsOperationEventPayload`:
299
+ ### Operation event — `WmsOperationEventPayload`
300
+
301
+ **v1 shape** (currently shipped):
302
+
303
+ ```ts
304
+ {
305
+ id: string | null; // primary id of the operation's order/entity
306
+ result: unknown; // the mutation's return value (DTO, or boolean for actions)
307
+ }
308
+ ```
309
+
310
+ **v2 shape** (upcoming minor — `core@0.3.0` / `graphql@0.6.0`):
311
+
312
+ ```ts
313
+ {
314
+ readonly id: string | null; // (unchanged) primary id
315
+ readonly result: TResult; // now per-event typed via WmsOperationResultMap
316
+ readonly args: Readonly<Record<string, unknown>>; // NEW — the GraphQL input (intent)
317
+ readonly actor: WmsActor | null; // NEW — who performed the mutation
318
+ }
319
+ ```
320
+
321
+ **`WmsActor`** shape:
231
322
 
232
323
  ```ts
233
- { id: string | null; // primary id of the operation's order/entity
234
- result: unknown } // the mutation's return value (DTO, or boolean for actions)
324
+ interface WmsActor {
325
+ readonly id: string; // subject identifier
326
+ readonly domain?: string; // optional domain / tenant discriminator
327
+ readonly raw?: unknown; // host-specific extras; opaque to the lib
328
+ }
235
329
  ```
236
330
 
237
- **Stock event** — `WmsStockEventPayload`:
331
+ `actor` is `null` when no `actorExtractor` is configured. The
332
+ `memberBaseActorExtractor` helper (from
333
+ `@rytass/wms-module-graphql/adapters/member-base`) reads `req.payload` and
334
+ returns `{ id, domain }` — the same fields `CasbinGuard` already puts on the
335
+ JWT payload.
336
+
337
+ `WmsBeforeHookContext` also gains `actor` (so before-hooks can veto by identity):
238
338
 
239
339
  ```ts
240
- { orderId: string; // = change.id; correlation id with the operation event
241
- itemId: string; // = change.itemId
242
- quantityDelta: number; // = change.quantityChanged (sign = direction)
243
- changeType: ChangeType | null; // null only for stock.inspection-resolved
244
- inspectionResult?: boolean | null } // present only on stock.inspection-resolved
340
+ interface WmsBeforeHookContext<N extends WmsEventName> {
341
+ event: N;
342
+ args: Record<string, unknown>;
343
+ executionContext: ExecutionContext;
344
+ actor: WmsActor | null; // v2 addition
345
+ }
245
346
  ```
246
347
 
348
+ ### Typed result overlay — `WmsOperationResultMap`
349
+
350
+ The v2 graphql package exports a `WmsOperationResultMap` interface that maps
351
+ every operation event name to its concrete `result` type. Consumers who use the
352
+ typed `beforeHooks` / `afterListeners` maps automatically receive the resolved
353
+ DTO instead of `unknown`:
354
+
355
+ ```ts
356
+ // listener receives ShipInventoryOrderDto, not unknown
357
+ afterListeners: {
358
+ 'ship-inventory-order.updated': async ({ payload }) => {
359
+ console.log(payload.result.id); // typed as string
360
+ },
361
+ // listener receives boolean
362
+ 'ship-inventory-order.shipped': async ({ payload }) => {
363
+ console.log(payload.result); // typed as boolean
364
+ console.log(payload.args); // { items: [{ itemId, quantity }, ...] }
365
+ console.log(payload.actor?.id); // actor id if actorExtractor is set
366
+ },
367
+ }
368
+ ```
369
+
370
+ ---
371
+
372
+ ### Stock event — `WmsStockEventPayload`
373
+
374
+ **Lazy shape** (default — unchanged):
375
+
376
+ ```ts
377
+ {
378
+ readonly orderId: string; // = change.id; correlation id with the operation event
379
+ readonly itemId: string; // = change.itemId
380
+ readonly quantityDelta: number; // = change.quantityChanged (sign = direction)
381
+ readonly changeType: ChangeType | null; // null only for stock.inspection-resolved
382
+ readonly inspectionResult?: boolean | null; // present only on stock.inspection-resolved
383
+ }
384
+ ```
385
+
386
+ **Enriched shape** (opt-in, `enrichStockEvents: true`):
387
+
388
+ The following fields are appended when the flag is enabled. They are loaded in
389
+ the **same transaction** via a single indexed PK lookup on the change's item row:
390
+
391
+ ```ts
392
+ readonly materialId?: string | null;
393
+ readonly batchId?: string | null;
394
+ readonly locationId?: string | null;
395
+ readonly loaderId?: string | null;
396
+ ```
397
+
398
+ > All four fields are optional/nullable — a `null` value means the column is
399
+ > nullable in the underlying order-item row and happened to be null at the time
400
+ > of the change. On any lookup error the system degrades to the lazy payload.
401
+
402
+ **Per-domain enrichment completeness** (`enrichStockEvents: true`):
403
+
404
+ | Domain (stock event) | materialId | batchId | locationId | loaderId |
405
+ |---------------------------------------------------|:----------:|:-------:|:----------:|:--------:|
406
+ | receive (`stock.received`) | yes | yes * | yes * | yes * |
407
+ | ship (`stock.in-shipment` / `stock.shipped`) | yes | yes | yes * | yes * |
408
+ | transfer-order (`stock.in-transfer` / `.transferred`) | yes | yes | yes | yes * |
409
+ | transfer-vendor (`stock.vendor-*`) | yes | yes | yes * | yes * |
410
+ | transfer-customer (`stock.customer-*`) | yes | yes | yes * | yes * |
411
+ | hold (`stock.held` / `stock.released`) | yes | yes | yes * | yes * |
412
+ | allocate (`stock.allocated` / `.deallocated`) | yes | yes | yes * | yes * |
413
+ | scrape (`stock.scraped`) | yes | yes | yes * | yes * |
414
+ | quality-inspection (`stock.inspection-resolved`) | yes | yes | yes * | yes * |
415
+ | **split (`stock.splitted`)** | **no** | **no** | yes * | yes * |
416
+ | **merge (`stock.merged`)** | **no** | **no** | yes | yes * |
417
+ | **reclassify (`stock.reclassified`)** | **no** | **no** | yes | yes * |
418
+
419
+ `*` = column is nullable; may be `null` even when enrichment is on.
420
+
421
+ > **Important**: split / merge / reclassify order-items have **no `materialId` /
422
+ > `batchId`** — those domains operate at batch-aggregation level and keep that
423
+ > metadata on the parent order, not the item. With `enrichStockEvents: true`
424
+ > these three event types only receive `locationId` / `loaderId`. If you need
425
+ > `materialId` / `batchId` for these domains, look them up from the parent order
426
+ > by `orderId` inside your after-listener.
427
+
428
+ ---
429
+
247
430
  `orderId` is the same on the operation event (`payload.id`) and the stock events
248
431
  it fans out to, so listeners can correlate the two tiers. For richer
249
- batch/material/location details, look them up by `orderId` / `itemId` inside the
250
- after-listener — the data is committed by then.
432
+ batch/material/location details when `enrichStockEvents` is off, look them up by
433
+ `orderId` / `itemId` inside the after-listener — the data is committed by then.
251
434
 
252
435
  ---
253
436
 
@@ -279,25 +462,38 @@ after-listener — the data is committed by then.
279
462
  From the package root (`@rytass/wms-module-graphql`, re-exported from
280
463
  `@rytass/wms-module-core`):
281
464
 
282
- | Export | Kind | Purpose |
283
- |---|---|---|
284
- | `WmsMutationEvent({ event })` | decorator | Annotates a mutation (already applied across the library). |
285
- | `WmsBeforeHookMap` / `WmsAfterListenerMap` | type | The `beforeHooks` / `afterListeners` option shapes. |
286
- | `WmsEventModuleOptions` | type | `{ beforeHooks?, afterListeners? }` (part of `forRootAsync`). |
287
- | `WmsBeforeHookContext` / `WmsAfterEventContext` | type | Hook/listener argument shapes. |
288
- | `WmsDomainEvent` | type | `{ name, payload }`. |
289
- | `WmsEventName` / `WmsOperationEventName` / `WmsStockEventName` | type | Event-name unions. |
290
- | `WmsEventPayloadMap` | type | Event name -> payload mapping. |
291
- | `WmsOperationEventPayload` / `WmsStockEventPayload` | type | Payload shapes. |
465
+ | Export | Kind | Purpose |
466
+ |-------------------------------------------------------------------|-----------|--------------------------------------------------------------------------------------------------------------|
467
+ | `WmsMutationEvent({ event })` | decorator | Annotates a mutation (already applied across the library). |
468
+ | `WmsBeforeHookMap` / `WmsAfterListenerMap` | type | The `beforeHooks` / `afterListeners` option shapes. |
469
+ | `WmsEventModuleOptions` | type | `{ beforeHooks?, afterListeners?, actorExtractor?, enrichStockEvents? }` (part of `forRootAsync`). |
470
+ | `WmsBeforeHookContext` / `WmsAfterEventContext` | type | Hook/listener argument shapes (v2: `WmsBeforeHookContext` gains `actor`). |
471
+ | `WmsDomainEvent` | type | `{ name, payload }`. |
472
+ | `WmsEventName` / `WmsOperationEventName` / `WmsStockEventName` | type | Event-name unions. |
473
+ | `WmsEventPayloadMap` | type | Event name -> payload mapping. |
474
+ | `WmsOperationEventPayload` | type | `{ id, result, args, actor }` (v2); `{ id, result }` in v1. |
475
+ | `WmsStockEventPayload` | type | `{ orderId, itemId, quantityDelta, changeType, inspectionResult? }` + optional enrich fields (v2). |
476
+ | `WmsActor` | type | `{ id: string; domain?: string; raw?: unknown }` — actor identity shape (v2). |
477
+ | `WmsActorExtractor` | type | `(context: ExecutionContext) => WmsActor \| null` — injected via `forRootAsync({ actorExtractor })` (v2). |
478
+ | `WmsOperationResultMap` | type | Event name → concrete `result` DTO mapping; enables per-event typed listeners (v2, lives in graphql lib). |
292
479
 
293
480
  From the adapter subpath (`@rytass/wms-module-graphql/adapters/event-emitter`):
294
481
 
295
- | Export | Kind | Purpose |
296
- |---|---|---|
297
- | `wmsEventEmitterBridgeProvider` | provider | Pass to `forRootAsync({ eventEmitterBridge })` to fan events onto `@nestjs/event-emitter`. |
482
+ | Export | Kind | Purpose |
483
+ |-------------------------------|----------|-------------------------------------------------------------------------------------------------------|
484
+ | `wmsEventEmitterBridgeProvider` | provider | Pass to `forRootAsync({ eventEmitterBridge })` to fan events onto `@nestjs/event-emitter`. |
485
+
486
+ From the adapter subpath (`@rytass/wms-module-graphql/adapters/member-base`):
487
+
488
+ | Export | Kind | Purpose |
489
+ |-----------------------------|-----------|-----------------------------------------------------------------------------------------------------------|
490
+ | `memberBaseActorExtractor` | extractor | `WmsActorExtractor` that reads `req.payload` → `{ id, domain }` (v2). Pass to `forRootAsync({ actorExtractor })`. |
298
491
 
299
- `forRootAsync` accepts `beforeHooks`, `afterListeners` and `eventEmitterBridge`
300
- on its single options object — all optional.
492
+ `forRootAsync` accepts `beforeHooks`, `afterListeners`, `eventEmitterBridge`,
493
+ `actorExtractor`, and `enrichStockEvents` on its single options object — all
494
+ optional. Omitting `actorExtractor` leaves `actor: null` on every payload;
495
+ omitting `enrichStockEvents` (or setting it `false`) keeps the lazy stock
496
+ payload shape.
301
497
 
302
498
  ---
303
499