@rytass/wms-module-graphql 0.4.0 → 0.5.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/README.md +27 -0
- package/adapters/event-emitter.d.ts +29 -0
- package/adapters/event-emitter.js +41 -0
- package/docs/README.md +3 -0
- package/docs/api-reference.md +40 -0
- package/docs/events.md +304 -0
- package/docs/integration-guide.md +83 -2
- package/index.cjs.js +128 -1
- package/index.d.ts +2 -0
- package/index.js +3 -0
- package/lib/events/index.d.ts +3 -0
- package/lib/events/wms-mutation-event.constants.d.ts +12 -0
- package/lib/events/wms-mutation-event.constants.js +4 -0
- package/lib/events/wms-mutation-event.decorator.d.ts +15 -0
- package/lib/events/wms-mutation-event.decorator.js +20 -0
- package/lib/events/wms-mutation.interceptor.d.ts +19 -0
- package/lib/events/wms-mutation.interceptor.js +72 -0
- package/lib/graphql.module.d.ts +15 -5
- package/lib/graphql.module.js +8 -2
- package/lib/mutations/allocate-inventory-order.mutation.js +5 -0
- package/lib/mutations/hold-inventory-order.mutation.js +5 -0
- package/lib/mutations/loader.mutation.js +7 -0
- package/lib/mutations/location.mutation.js +4 -0
- package/lib/mutations/material.mutation.js +4 -0
- package/lib/mutations/merge-batch.mutation.js +3 -0
- package/lib/mutations/quality-inspection-order.mutation.js +3 -0
- package/lib/mutations/receive-inventory-order.mutation.js +5 -0
- package/lib/mutations/reclassify-inventory-order.mutation.js +3 -0
- package/lib/mutations/scrape-order.mutation.js +3 -0
- package/lib/mutations/ship-inventory-order.mutation.js +6 -0
- package/lib/mutations/split-batch.mutation.js +3 -0
- package/lib/mutations/transfer-customer-order.mutation.js +5 -0
- package/lib/mutations/transfer-order.mutation.js +6 -0
- package/lib/mutations/transfer-vendor-order.mutation.js +5 -0
- package/lib/mutations/warehouse-map.mutation.js +3 -0
- package/package.json +13 -3
package/README.md
CHANGED
|
@@ -88,6 +88,31 @@ The member-base adapter reuses everything `CasbinGuard` already put on the reque
|
|
|
88
88
|
|
|
89
89
|
➡️ **Full design & recipes: [`docs/authorization.md`](./docs/authorization.md)** (the problem, execution order, member-base / custom-JWT / no-guard recipes, the single coupling point, public API, FAQ).
|
|
90
90
|
|
|
91
|
+
## Events & Hooks
|
|
92
|
+
|
|
93
|
+
Intervene in and get notified about mutations. Every root mutation carries a typed **operation event** (`material.created`, `ship-inventory-order.shipped`, …) and fans out **stock domain events** (`stock.allocated`, `stock.shipped`, …) driven by the `ChangeType` state machine. Wire two opt-in hook points via `forRootAsync`:
|
|
94
|
+
|
|
95
|
+
- **`beforeHooks`** run before any DB work — **throw to veto** the mutation (zero side effects).
|
|
96
|
+
- **`afterListeners`** run **after the outermost transaction commits** — never notified about rolled-back changes.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
WmsModuleGraphqlModule.forRootAsync({
|
|
100
|
+
...coreOptions,
|
|
101
|
+
beforeHooks: {
|
|
102
|
+
'ship-inventory-order.shipped': async ({ args }) => {
|
|
103
|
+
if (!allowed(args)) throw new ForbiddenException('blocked');
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
afterListeners: {
|
|
107
|
+
'stock.allocated': async ({ payload }) => { await erp.reserve(payload); },
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
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
|
+
|
|
114
|
+
➡️ **Full design, the complete 56-event list & limitations: [`docs/events.md`](./docs/events.md)**.
|
|
115
|
+
|
|
91
116
|
## DataLoader context
|
|
92
117
|
|
|
93
118
|
The module ships a `HelperModule` (auto-registered) that provides a singleton `LoaderService`. To avoid N+1 queries, the WMS resolvers expect a per-request DataLoader trio at `context.loaders`:
|
|
@@ -124,6 +149,7 @@ Full documentation is shipped inside this package under the `docs/` directory (a
|
|
|
124
149
|
|---|---|
|
|
125
150
|
| [`docs/integration-guide.md`](./docs/integration-guide.md) | Step-by-step wiring guide for NestJS + Next.js consumers |
|
|
126
151
|
| [`docs/authorization.md`](./docs/authorization.md) | Complete resolver-authorization design & integration recipes (member-base / custom JWT / no-guard) |
|
|
152
|
+
| [`docs/events.md`](./docs/events.md) | Mutation event/hook system — before-hooks (veto), post-commit after-listeners, complete event list, event-emitter bridge |
|
|
127
153
|
| [`docs/api-reference.md`](./docs/api-reference.md) | Complete export catalog with signatures and usage examples |
|
|
128
154
|
|
|
129
155
|
GitHub hosted links (always up-to-date):
|
|
@@ -131,6 +157,7 @@ GitHub hosted links (always up-to-date):
|
|
|
131
157
|
- [Integration Guide — §4 Backend setup](https://github.com/Rytass/WMS-Module/blob/main/docs/integration-guide.md#4-backend-setup-nestjs--graphql--typeorm)
|
|
132
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)
|
|
133
159
|
- [Authorization — design & recipes](https://github.com/Rytass/WMS-Module/blob/main/libs/graphql/docs/authorization.md)
|
|
160
|
+
- [Events & Hooks — design & event list](https://github.com/Rytass/WMS-Module/blob/main/libs/graphql/docs/events.md)
|
|
134
161
|
- [API Reference — @rytass/wms-module-graphql](https://github.com/Rytass/WMS-Module/blob/main/docs/api-reference.md#rytasswms-module-graphql)
|
|
135
162
|
|
|
136
163
|
## License
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Provider } from '@nestjs/common';
|
|
2
|
+
/**
|
|
3
|
+
* Bridges WMS domain events onto `@nestjs/event-emitter`'s `EventEmitter2`, so
|
|
4
|
+
* consumers can subscribe with `@OnEvent('wms.<event-name>')` instead of (or in
|
|
5
|
+
* addition to) the typed `afterListeners`.
|
|
6
|
+
*
|
|
7
|
+
* Pass this into `WmsModuleGraphqlModule.forRootAsync({ eventEmitterBridge })`.
|
|
8
|
+
* Importing this module is the only place `@nestjs/event-emitter` is referenced,
|
|
9
|
+
* keeping it an entirely optional peer dependency — the core graphql module
|
|
10
|
+
* never imports it.
|
|
11
|
+
*
|
|
12
|
+
* Prerequisite: register `EventEmitterModule.forRoot()` in your app so an
|
|
13
|
+
* `EventEmitter2` instance is available for injection.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { wmsEventEmitterBridgeProvider } from '@rytass/wms-module-graphql/adapters/event-emitter';
|
|
18
|
+
*
|
|
19
|
+
* WmsModuleGraphqlModule.forRootAsync({
|
|
20
|
+
* ...coreOptions,
|
|
21
|
+
* eventEmitterBridge: wmsEventEmitterBridgeProvider,
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* // elsewhere
|
|
25
|
+
* @OnEvent('wms.stock.allocated')
|
|
26
|
+
* handle(payload: WmsStockEventPayload) { ... }
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export declare const wmsEventEmitterBridgeProvider: Provider;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
2
|
+
import { WMS_EVENT_EMITTER_BRIDGE } from '@rytass/wms-module-core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bridges WMS domain events onto `@nestjs/event-emitter`'s `EventEmitter2`, so
|
|
6
|
+
* consumers can subscribe with `@OnEvent('wms.<event-name>')` instead of (or in
|
|
7
|
+
* addition to) the typed `afterListeners`.
|
|
8
|
+
*
|
|
9
|
+
* Pass this into `WmsModuleGraphqlModule.forRootAsync({ eventEmitterBridge })`.
|
|
10
|
+
* Importing this module is the only place `@nestjs/event-emitter` is referenced,
|
|
11
|
+
* keeping it an entirely optional peer dependency — the core graphql module
|
|
12
|
+
* never imports it.
|
|
13
|
+
*
|
|
14
|
+
* Prerequisite: register `EventEmitterModule.forRoot()` in your app so an
|
|
15
|
+
* `EventEmitter2` instance is available for injection.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* import { wmsEventEmitterBridgeProvider } from '@rytass/wms-module-graphql/adapters/event-emitter';
|
|
20
|
+
*
|
|
21
|
+
* WmsModuleGraphqlModule.forRootAsync({
|
|
22
|
+
* ...coreOptions,
|
|
23
|
+
* eventEmitterBridge: wmsEventEmitterBridgeProvider,
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* // elsewhere
|
|
27
|
+
* @OnEvent('wms.stock.allocated')
|
|
28
|
+
* handle(payload: WmsStockEventPayload) { ... }
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
const wmsEventEmitterBridgeProvider = {
|
|
32
|
+
provide: WMS_EVENT_EMITTER_BRIDGE,
|
|
33
|
+
useFactory: (emitter) => ({
|
|
34
|
+
forward: (event) => {
|
|
35
|
+
emitter.emit(`wms.${event.name}`, event.payload);
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
inject: [EventEmitter2],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export { wmsEventEmitterBridgeProvider };
|
package/docs/README.md
CHANGED
|
@@ -8,6 +8,7 @@ Reference material for consumers of `@rytass/wms-module-{core,graphql,react}`.
|
|
|
8
8
|
|------------------------------------------------|---------------------------------------------------------------------------------------------------|
|
|
9
9
|
| [Integration Guide](./integration-guide.md) | Step-by-step instructions for wiring the three packages into a new consumer project (NestJS + Next.js + PostgreSQL). Includes ready-to-paste `_app.tsx`, `app.module.ts`, `next.config.ts`, the 19 Pages Router shell pattern, AuthGuard stub, seed script template, deployment notes, and known issues. |
|
|
10
10
|
| [Authorization](./authorization.md) | Complete design of the resolver-level authorization protocol (`@WmsOperation` + `WmsAuthGuard` + `authChecker`). Explains the global deny-by-default guard pitfall, execution order, and integration recipes for member-base / Casbin, custom JWT / RBAC, and no-guard setups. Read this if any WMS query/mutation returns *forbidden*. |
|
|
11
|
+
| [Events & Hooks](./events.md) | The mutation event/hook system — `beforeHooks` (veto before any DB work) and `afterListeners` (post-commit notify), the `AsyncLocalStorage` post-commit mechanism, the optional `@nestjs/event-emitter` bridge, and the complete list of 38 operation + 18 stock domain events with payloads. |
|
|
11
12
|
| [API Reference](./api-reference.md) | Complete export catalog for all three packages — every NestJS module, TypeORM entity, GraphQL resolver, DTO, React component, hook, and utility, organized by role with source paths and usage examples. Mirrors the actual `src/index.ts` barrel exports of `@rytass/wms-module-graphql@0.3.0` and `@rytass/wms-module-{core,react}@0.1.6`. |
|
|
12
13
|
|
|
13
14
|
## Quick links
|
|
@@ -26,6 +27,8 @@ Reference material for consumers of `@rytass/wms-module-{core,graphql,react}`.
|
|
|
26
27
|
| Replace the `AuthGuard` stub with real auth | [Integration Guide section 8.4](./integration-guide.md#84-real-auth-integration) |
|
|
27
28
|
| Authorize WMS resolvers with member-base / Casbin / RBAC | [Authorization](./authorization.md) |
|
|
28
29
|
| Fix "every WMS query/mutation is forbidden" | [Authorization section 1](./authorization.md#1-the-problem) |
|
|
30
|
+
| Hook into / get notified about mutations | [Events & Hooks](./events.md) |
|
|
31
|
+
| See every event the system emits | [Events & Hooks section 5](./events.md#5-complete-event-list) |
|
|
29
32
|
| Decide between PostgreSQL and SQLite | [Integration Guide section 9](./integration-guide.md#9-database-support-matrix) |
|
|
30
33
|
| Build a custom UI on top of the lib's GraphQL operations| [Integration Guide section 7.3](./integration-guide.md#73-graphql-operations--typed-hooks) |
|
|
31
34
|
|
package/docs/api-reference.md
CHANGED
|
@@ -449,6 +449,46 @@ The adapter imports nothing from member-base — it reads the `payload` / `enfor
|
|
|
449
449
|
|
|
450
450
|
---
|
|
451
451
|
|
|
452
|
+
## Events & Hooks
|
|
453
|
+
|
|
454
|
+
Opt-in `beforeHooks` (veto) and post-commit `afterListeners` for every mutation. Each root mutation carries `@WmsMutationEvent({ event })` (already applied); a per-mutation interceptor runs the before-hook, opens an `AsyncLocalStorage` dispatch scope, and flushes collected events after the transaction commits. Stock domain events are collected centrally by a TypeORM subscriber on `*_order_change` inserts. Full design & the complete event list: [`docs/events.md`](./events.md).
|
|
455
|
+
|
|
456
|
+
### Root exports (`@rytass/wms-module-graphql`, re-exported from `@rytass/wms-module-core`)
|
|
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? }` |
|
|
471
|
+
|
|
472
|
+
### Adapter subpath (`@rytass/wms-module-graphql/adapters/event-emitter`)
|
|
473
|
+
|
|
474
|
+
| Export | Kind | Purpose |
|
|
475
|
+
|---|---|---|
|
|
476
|
+
| `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
|
+
|
|
478
|
+
```ts
|
|
479
|
+
WmsModuleGraphqlModule.forRootAsync({
|
|
480
|
+
...coreOptions,
|
|
481
|
+
beforeHooks: { 'ship-inventory-order.shipped': async ({ args }) => { if (!ok(args)) throw new ForbiddenException(); } },
|
|
482
|
+
afterListeners: { 'stock.allocated': async ({ payload }) => { await sync(payload); } },
|
|
483
|
+
// optional @OnEvent bridge:
|
|
484
|
+
// eventEmitterBridge: wmsEventEmitterBridgeProvider,
|
|
485
|
+
});
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
`beforeHooks` / `afterListeners` are **not** part of the GraphQL schema — they are runtime hooks. With none configured the interceptor passes through with zero overhead.
|
|
489
|
+
|
|
490
|
+
---
|
|
491
|
+
|
|
452
492
|
## Resolvers(`@Resolver()` decorator class)
|
|
453
493
|
|
|
454
494
|
以下 Resolver 類別處理 GraphQL `@ResolveField`,解析關聯欄位(防止 N+1):
|
package/docs/events.md
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# Events & Hooks
|
|
2
|
+
|
|
3
|
+
How a consumer **intervenes in** (vetoes) and **gets notified about** WMS
|
|
4
|
+
mutations, with a host-neutral, opt-in API that mirrors the
|
|
5
|
+
[authorization](./authorization.md) layer's wiring.
|
|
6
|
+
|
|
7
|
+
- [1. What you get](#1-what-you-get)
|
|
8
|
+
- [2. Architecture — post-commit guarantee](#2-architecture--post-commit-guarantee)
|
|
9
|
+
- [3. Integration](#3-integration)
|
|
10
|
+
- [4. The `@nestjs/event-emitter` bridge](#4-the-nestjsevent-emitter-bridge)
|
|
11
|
+
- [5. Complete event list](#5-complete-event-list)
|
|
12
|
+
- [6. Payloads](#6-payloads)
|
|
13
|
+
- [7. Guarantees & limitations](#7-guarantees--limitations)
|
|
14
|
+
- [8. Public API](#8-public-api)
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 1. What you get
|
|
19
|
+
|
|
20
|
+
Two tiers of events, both opt-in:
|
|
21
|
+
|
|
22
|
+
- **Operation events** — exactly one per root mutation (`material.created`,
|
|
23
|
+
`ship-inventory-order.shipped`, …). 38 in total.
|
|
24
|
+
- **Stock domain events** — driven by the `ChangeType` state machine
|
|
25
|
+
(`stock.allocated`, `stock.shipped`, …), emitted once per stock movement, so a
|
|
26
|
+
single operation can fan out several. 18 in total.
|
|
27
|
+
|
|
28
|
+
Two hook points per event:
|
|
29
|
+
|
|
30
|
+
- **`beforeHooks`** run *before* any DB work. Throwing **vetoes** the whole
|
|
31
|
+
mutation — nothing is written, no after-listener fires. Use for custom
|
|
32
|
+
authorization, validation, quota enforcement.
|
|
33
|
+
- **`afterListeners`** run *after the outermost transaction commits*. Use for
|
|
34
|
+
audit logging, ERP/webhook sync, cache invalidation, notifications.
|
|
35
|
+
|
|
36
|
+
Everything is optional. With nothing configured the module behaves exactly as
|
|
37
|
+
before — no events, zero overhead.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 2. Architecture — post-commit guarantee
|
|
42
|
+
|
|
43
|
+
The hard requirement is that an after-listener must **never** be told about a
|
|
44
|
+
change that was rolled back. WMS guarantees this with an `AsyncLocalStorage`
|
|
45
|
+
dispatch scope opened by a per-mutation interceptor:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
GraphQL Mutation (@WmsMutationEvent)
|
|
49
|
+
|
|
|
50
|
+
v
|
|
51
|
+
WmsMutationInterceptor
|
|
52
|
+
1. beforeHook(event, args) --throw--> VETO (no transaction, clean error)
|
|
53
|
+
2. als.run(scope, async () => {
|
|
54
|
+
result = await handler() // services open tx, write *_order_change rows
|
|
55
|
+
}) // -> StockChangeSubscriber.afterInsert
|
|
56
|
+
| // collects stock.* events into the scope
|
|
57
|
+
| handler resolved == transaction committed
|
|
58
|
+
v
|
|
59
|
+
3. flush(scope.buffer)
|
|
60
|
+
afterListener(event) (isolated; one failure never blocks others)
|
|
61
|
+
emitterBridge.forward(event) --> EventEmitter2 (optional)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Why it is post-commit: TypeORM's `dataSource.transaction(cb)` resolves only
|
|
65
|
+
*after* `COMMIT`. The interceptor awaits the whole handler before flushing, so
|
|
66
|
+
the buffer is dispatched strictly after commit. If the transaction rolls back
|
|
67
|
+
the handler rejects, the interceptor never flushes, and the buffered events are
|
|
68
|
+
discarded.
|
|
69
|
+
|
|
70
|
+
Stock events are collected centrally by a single TypeORM subscriber
|
|
71
|
+
(`StockChangeSubscriber`) that listens to every `*_order_change` insert and
|
|
72
|
+
derives the `ChangeType` from the table name and the sign of `quantityChanged`.
|
|
73
|
+
The 12 domain services are untouched, and any future code path that writes a
|
|
74
|
+
change row is covered automatically.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## 3. Integration
|
|
79
|
+
|
|
80
|
+
`beforeHooks` / `afterListeners` are typed maps keyed by event name; the payload
|
|
81
|
+
is inferred from the key.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { WmsModuleGraphqlModule } from '@rytass/wms-module-graphql';
|
|
85
|
+
import { ForbiddenException } from '@nestjs/common';
|
|
86
|
+
|
|
87
|
+
WmsModuleGraphqlModule.forRootAsync({
|
|
88
|
+
useFactory: async () => ({}),
|
|
89
|
+
defaultLoaderSerialId: 'DEFAULT',
|
|
90
|
+
defaultLoaderTypeKey: 'DEFAULT',
|
|
91
|
+
defaultLocationKey: 'DEFAULT',
|
|
92
|
+
|
|
93
|
+
// intervene — runs before any DB work; throw to veto
|
|
94
|
+
beforeHooks: {
|
|
95
|
+
'ship-inventory-order.shipped': async ({ args, executionContext }) => {
|
|
96
|
+
if (!(await canShip(args, executionContext))) {
|
|
97
|
+
throw new ForbiddenException('shipment blocked');
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
// notify — runs after commit
|
|
103
|
+
afterListeners: {
|
|
104
|
+
'ship-inventory-order.shipped': async ({ payload }) => {
|
|
105
|
+
await erp.markShipped(payload.id, payload.result);
|
|
106
|
+
},
|
|
107
|
+
'stock.allocated': async ({ payload }) => {
|
|
108
|
+
await erp.reserve(payload.orderId, payload.itemId, payload.quantityDelta);
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- A `beforeHook` receives `{ event, args, executionContext }` — the mutation
|
|
115
|
+
**intent** (raw GraphQL args), since no result exists yet. Throw (e.g. a
|
|
116
|
+
`ForbiddenException`) to abort with zero side effects.
|
|
117
|
+
- An `afterListener` receives `{ event, payload }` — the committed result (see
|
|
118
|
+
[§6](#6-payloads)). It may be async; a thrown error is logged and isolated, it
|
|
119
|
+
never affects the committed mutation or other listeners.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## 4. The `@nestjs/event-emitter` bridge
|
|
124
|
+
|
|
125
|
+
Prefer decoupled `@OnEvent` subscribers? Opt into the bridge. It is the only
|
|
126
|
+
thing that imports `@nestjs/event-emitter` (an **optional** peer dependency), so
|
|
127
|
+
the core module never depends on it.
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import { EventEmitterModule } from '@nestjs/event-emitter';
|
|
131
|
+
import { wmsEventEmitterBridgeProvider } from '@rytass/wms-module-graphql/adapters/event-emitter';
|
|
132
|
+
|
|
133
|
+
@Module({
|
|
134
|
+
imports: [
|
|
135
|
+
EventEmitterModule.forRoot(),
|
|
136
|
+
WmsModuleGraphqlModule.forRootAsync({
|
|
137
|
+
...coreOptions,
|
|
138
|
+
eventEmitterBridge: wmsEventEmitterBridgeProvider,
|
|
139
|
+
}),
|
|
140
|
+
],
|
|
141
|
+
})
|
|
142
|
+
export class AppModule {}
|
|
143
|
+
|
|
144
|
+
// anywhere
|
|
145
|
+
@Injectable()
|
|
146
|
+
class StockSync {
|
|
147
|
+
@OnEvent('wms.stock.allocated')
|
|
148
|
+
handle(payload: WmsStockEventPayload) { /* ... */ }
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Every event is emitted as `wms.<event-name>` with its payload. `afterListeners`
|
|
153
|
+
and the bridge can be used together — both fire.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## 5. Complete event list
|
|
158
|
+
|
|
159
|
+
### Operation events (38) — `<domain>.<past-tense-action>`
|
|
160
|
+
|
|
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 | — |
|
|
201
|
+
|
|
202
|
+
> The "Fans out to stock event" column is **indicative**, not a hard binding. A
|
|
203
|
+
> mutation never names a stock event directly — `StockChangeSubscriber` derives
|
|
204
|
+
> it from the `*_order_change` table that was written and the sign of
|
|
205
|
+
> `quantityChanged`. So a mutation emits a stock event only on the code paths
|
|
206
|
+
> that actually write a change row (e.g. an `update*` only when it adds items).
|
|
207
|
+
|
|
208
|
+
### Stock domain events (18) — `stock.<change-type>`
|
|
209
|
+
|
|
210
|
+
`stock.received`, `stock.in-shipment`, `stock.shipped`, `stock.in-transfer`,
|
|
211
|
+
`stock.transferred`, `stock.vendor-transferred`, `stock.vendor-returned`,
|
|
212
|
+
`stock.held`, `stock.released`, `stock.customer-held`,
|
|
213
|
+
`stock.customer-returned`, `stock.splitted`, `stock.merged`, `stock.allocated`,
|
|
214
|
+
`stock.deallocated`, `stock.reclassified`, `stock.scraped`,
|
|
215
|
+
`stock.inspection-resolved`.
|
|
216
|
+
|
|
217
|
+
The first 17 map 1:1 to `ChangeType`. `stock.inspection-resolved` has no
|
|
218
|
+
`ChangeType` (quality inspection only flips the `inspectionResult` flag) and
|
|
219
|
+
carries `changeType: null` plus `inspectionResult`.
|
|
220
|
+
|
|
221
|
+
A single operation can emit **several** stock events — one per `*_order_change`
|
|
222
|
+
row written, i.e. one per affected item. Cancellations re-use the forward event
|
|
223
|
+
with a negative `quantityDelta` (`cancelShip` -> `stock.shipped`,
|
|
224
|
+
`cancelTransfer` -> `stock.in-transfer`); branch on the sign, not the name only.
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## 6. Payloads
|
|
229
|
+
|
|
230
|
+
**Operation event** — `WmsOperationEventPayload`:
|
|
231
|
+
|
|
232
|
+
```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)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Stock event** — `WmsStockEventPayload`:
|
|
238
|
+
|
|
239
|
+
```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
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`orderId` is the same on the operation event (`payload.id`) and the stock events
|
|
248
|
+
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.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## 7. Guarantees & limitations
|
|
255
|
+
|
|
256
|
+
- **Post-commit, in-order.** After-listeners fire strictly after commit, in
|
|
257
|
+
collection order, with the operation event last.
|
|
258
|
+
- **Veto is side-effect-free.** A throwing before-hook runs before any
|
|
259
|
+
transaction; nothing is written.
|
|
260
|
+
- **Listener isolation.** A throwing after-listener is logged and skipped; it
|
|
261
|
+
never rolls back the (already committed) mutation or blocks other listeners.
|
|
262
|
+
- **In-process, not persistent.** If the process crashes after commit but before
|
|
263
|
+
flush, those events are lost. This is an in-process event bus, not an
|
|
264
|
+
at-least-once outbox. If you need durable delivery, persist inside your
|
|
265
|
+
after-listener is *not* enough — use an external CDC/outbox.
|
|
266
|
+
- **"Handler resolve = post-commit" invariant.** Relies on every mutation
|
|
267
|
+
resolver `await`-ing all its writes (it does today). Never fire-and-forget an
|
|
268
|
+
un-awaited DB write inside a mutation.
|
|
269
|
+
- **Uneven transaction boundaries.** `receive*` wrap the transaction at the
|
|
270
|
+
resolver, `ship/transfer/cancel*/reclassify/merge/split` at the service, and
|
|
271
|
+
the `create/update` series have no explicit transaction (multiple awaits).
|
|
272
|
+
Events for the no-transaction operations fire after the writes complete, but
|
|
273
|
+
those operations were never atomic to begin with.
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## 8. Public API
|
|
278
|
+
|
|
279
|
+
From the package root (`@rytass/wms-module-graphql`, re-exported from
|
|
280
|
+
`@rytass/wms-module-core`):
|
|
281
|
+
|
|
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. |
|
|
292
|
+
|
|
293
|
+
From the adapter subpath (`@rytass/wms-module-graphql/adapters/event-emitter`):
|
|
294
|
+
|
|
295
|
+
| Export | Kind | Purpose |
|
|
296
|
+
|---|---|---|
|
|
297
|
+
| `wmsEventEmitterBridgeProvider` | provider | Pass to `forRootAsync({ eventEmitterBridge })` to fan events onto `@nestjs/event-emitter`. |
|
|
298
|
+
|
|
299
|
+
`forRootAsync` accepts `beforeHooks`, `afterListeners` and `eventEmitterBridge`
|
|
300
|
+
on its single options object — all optional.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
See also: [Authorization](./authorization.md) · [API Reference](./api-reference.md) · [Integration Guide](./integration-guide.md)
|
|
@@ -16,7 +16,9 @@ Target audience: engineers integrating WMS into a new product who want a working
|
|
|
16
16
|
5. [Frontend setup (Next.js Pages Router)](#5-frontend-setup-nextjs-pages-router)
|
|
17
17
|
6. [Pages Router shell pattern (the 19 thin shells)](#6-pages-router-shell-pattern-the-19-thin-shells)
|
|
18
18
|
7. [Apollo client wiring](#7-apollo-client-wiring)
|
|
19
|
-
8. [Authentication
|
|
19
|
+
8. [Authentication, authorization & events](#8-authentication-authorization--events)
|
|
20
|
+
- [8.5 Backend authorization (resolver-level)](#85-backend-authorization-resolver-level)
|
|
21
|
+
- [8.6 Mutation events & hooks](#86-mutation-events--hooks)
|
|
20
22
|
9. [Database support matrix](#9-database-support-matrix)
|
|
21
23
|
10. [Seed data for demos](#10-seed-data-for-demos)
|
|
22
24
|
11. [Deployment notes](#11-deployment-notes)
|
|
@@ -739,7 +741,7 @@ The full list of generated `Document` constants and their types is in
|
|
|
739
741
|
|
|
740
742
|
---
|
|
741
743
|
|
|
742
|
-
## 8. Authentication
|
|
744
|
+
## 8. Authentication, authorization & events
|
|
743
745
|
|
|
744
746
|
The lib ships **no frontend auth** — the page components assume they render in an authenticated context and call queries on mount without checking session state. On the **backend**, `@rytass/wms-module-graphql` ships an *opt-in, host-neutral authorization protocol* you wire to your own permission system (member-base / Casbin / JWT / anything). See [§8.5 Backend authorization](#85-backend-authorization-resolver-level).
|
|
745
747
|
|
|
@@ -869,6 +871,85 @@ The WMS `resource`/`action` strings are **internal metadata only** — they are
|
|
|
869
871
|
|
|
870
872
|
---
|
|
871
873
|
|
|
874
|
+
### 8.6 Mutation events & hooks
|
|
875
|
+
|
|
876
|
+
> 📖 Condensed version. The full design, the complete list of 38 operation + 18 stock domain events, payloads, and limitations live in [**`docs/events.md`**](./events.md).
|
|
877
|
+
|
|
878
|
+
Every WMS mutation can be **intervened in** and **observed** through two opt-in hook points wired on the same `WmsModuleGraphqlModule.forRootAsync({ ... })` call as everything else:
|
|
879
|
+
|
|
880
|
+
- **`beforeHooks`** run *before any DB work* — `throw` to **veto** the mutation (zero side effects).
|
|
881
|
+
- **`afterListeners`** run *after the outermost transaction commits* — you are never notified about a rolled-back change.
|
|
882
|
+
|
|
883
|
+
```ts
|
|
884
|
+
// apps/api/src/app/app.module.ts
|
|
885
|
+
import { ForbiddenException } from '@nestjs/common';
|
|
886
|
+
import { WmsModuleGraphqlModule } from '@rytass/wms-module-graphql';
|
|
887
|
+
|
|
888
|
+
WmsModuleGraphqlModule.forRootAsync({
|
|
889
|
+
// ...the same core options as §4.2 (defaultLoaderSerialId, useFactory, etc.)...
|
|
890
|
+
|
|
891
|
+
beforeHooks: {
|
|
892
|
+
// intent payload = the raw GraphQL args; throw to abort
|
|
893
|
+
'ship-inventory-order.shipped': async ({ args, executionContext }) => {
|
|
894
|
+
if (!(await canShip(args, executionContext))) {
|
|
895
|
+
throw new ForbiddenException('shipment blocked');
|
|
896
|
+
}
|
|
897
|
+
},
|
|
898
|
+
},
|
|
899
|
+
|
|
900
|
+
afterListeners: {
|
|
901
|
+
// operation event — fires once per mutation, with the result
|
|
902
|
+
'ship-inventory-order.shipped': async ({ payload }) => {
|
|
903
|
+
await erp.markShipped(payload.id, payload.result);
|
|
904
|
+
},
|
|
905
|
+
// stock domain event — fires once per affected item (post-commit)
|
|
906
|
+
'stock.allocated': async ({ payload }) => {
|
|
907
|
+
await erp.reserve(payload.orderId, payload.itemId, payload.quantityDelta);
|
|
908
|
+
},
|
|
909
|
+
},
|
|
910
|
+
});
|
|
911
|
+
```
|
|
912
|
+
|
|
913
|
+
Two tiers of events you can listen to:
|
|
914
|
+
|
|
915
|
+
- **Operation events** — one per mutation: `material.created`, `receive-inventory-order.received`, `ship-inventory-order.shipped`, … (38 total). Payload: `{ id, result }`.
|
|
916
|
+
- **Stock domain events** — driven by the `ChangeType` state machine: `stock.allocated`, `stock.shipped`, `stock.held`, … (18 total). Payload: `{ orderId, itemId, quantityDelta, changeType, inspectionResult? }`. `orderId` matches the operation event's `id`, so you can correlate the two tiers.
|
|
917
|
+
|
|
918
|
+
Both maps are fully typed — the payload is inferred from the event-name key. With neither configured, the interceptor passes through with **zero overhead** (no behaviour change).
|
|
919
|
+
|
|
920
|
+
#### Optional: `@OnEvent` via `@nestjs/event-emitter`
|
|
921
|
+
|
|
922
|
+
Prefer decoupled subscribers? Install `@nestjs/event-emitter` (an optional peer dependency) and opt into the bridge — every event is then also emitted as `wms.<event-name>`:
|
|
923
|
+
|
|
924
|
+
```ts
|
|
925
|
+
import { EventEmitterModule } from '@nestjs/event-emitter';
|
|
926
|
+
import { wmsEventEmitterBridgeProvider } from '@rytass/wms-module-graphql/adapters/event-emitter';
|
|
927
|
+
|
|
928
|
+
@Module({
|
|
929
|
+
imports: [
|
|
930
|
+
EventEmitterModule.forRoot(),
|
|
931
|
+
WmsModuleGraphqlModule.forRootAsync({
|
|
932
|
+
...coreOptions,
|
|
933
|
+
eventEmitterBridge: wmsEventEmitterBridgeProvider, // <- enables the bridge
|
|
934
|
+
}),
|
|
935
|
+
],
|
|
936
|
+
})
|
|
937
|
+
export class AppModule {}
|
|
938
|
+
|
|
939
|
+
// elsewhere
|
|
940
|
+
@Injectable()
|
|
941
|
+
class StockSync {
|
|
942
|
+
@OnEvent('wms.stock.allocated')
|
|
943
|
+
handle(payload: WmsStockEventPayload) { /* ... */ }
|
|
944
|
+
}
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
The bridge is the only thing that imports `@nestjs/event-emitter`, so it stays an entirely optional dependency — the core module never requires it. `afterListeners` and the bridge can be used together.
|
|
948
|
+
|
|
949
|
+
> ⚠️ Delivery is **in-process and post-commit**, not a durable outbox: if the process crashes after commit but before dispatch, those events are lost. See [`docs/events.md` §7](./events.md#7-guarantees--limitations) for the full guarantees.
|
|
950
|
+
|
|
951
|
+
---
|
|
952
|
+
|
|
872
953
|
## 9. Database support matrix
|
|
873
954
|
|
|
874
955
|
| Database | Status | Notes |
|