@web-ts-toolkit/message-service 0.40.1 → 0.41.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 +161 -29
- package/index.d.mts +1118 -36
- package/index.d.ts +1118 -36
- package/index.js +1153 -157
- package/index.mjs +1139 -157
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -22,8 +22,8 @@ Peer dependencies:
|
|
|
22
22
|
- **Action system** — validate permissions, run handlers, archive messages, send notifications
|
|
23
23
|
- **Route factory** — standalone Express routes for template-based creation and action handling
|
|
24
24
|
- **Pluggable providers** — email and payment providers are interfaces, not hard dependencies
|
|
25
|
-
- **Idempotent create** — `clientRequestId` for double-submit protection
|
|
26
|
-
- **Typed errors** —
|
|
25
|
+
- **Idempotent create** — bounded `clientRequestId` scoped to the requester and template for double-submit protection
|
|
26
|
+
- **Typed errors** — exported service and registry errors with stable route status mappings
|
|
27
27
|
|
|
28
28
|
## Quick Start
|
|
29
29
|
|
|
@@ -39,11 +39,25 @@ import {
|
|
|
39
39
|
MESSAGE_ARCHIVE_MODEL_NAME,
|
|
40
40
|
MESSAGE_MODEL_NAME,
|
|
41
41
|
MESSAGE_REQUEST_MODEL_NAME,
|
|
42
|
-
MessageTemplate,
|
|
42
|
+
type MessageTemplate,
|
|
43
|
+
type MessageUser,
|
|
43
44
|
} from '@web-ts-toolkit/message-service';
|
|
44
45
|
|
|
45
46
|
const app = express();
|
|
46
|
-
|
|
47
|
+
app.use(express.json());
|
|
48
|
+
|
|
49
|
+
declare function resolveAuthenticatedUser(req: express.Request): MessageUser | undefined;
|
|
50
|
+
|
|
51
|
+
const myAuthMiddleware: express.RequestHandler = (req, res, next) => {
|
|
52
|
+
const user = resolveAuthenticatedUser(req);
|
|
53
|
+
if (!user) {
|
|
54
|
+
res.status(401).json({ message: 'authentication required' });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
(req as express.Request & { user: MessageUser }).user = user;
|
|
59
|
+
next();
|
|
60
|
+
};
|
|
47
61
|
|
|
48
62
|
await mongoose.connect('mongodb://localhost/mydb');
|
|
49
63
|
|
|
@@ -52,7 +66,27 @@ mongoose.model(MESSAGE_ARCHIVE_MODEL_NAME, buildMessageArchiveSchema());
|
|
|
52
66
|
mongoose.model(MESSAGE_REQUEST_MODEL_NAME, buildMessageRequestSchema());
|
|
53
67
|
|
|
54
68
|
const myTemplate: MessageTemplate = {
|
|
55
|
-
|
|
69
|
+
templateCd: 'welcome.request',
|
|
70
|
+
type: 'request',
|
|
71
|
+
description: 'Welcome request',
|
|
72
|
+
senderContent: { title: 'Welcome {{name}}', long: 'Sent to reviewers', short: 'Sent' },
|
|
73
|
+
receiverContent: { title: 'Review {{name}}', long: 'Please review this request', short: 'Review' },
|
|
74
|
+
uiTemplate: 'default-message',
|
|
75
|
+
prepareMessage: async ({ user, payload }) => ({
|
|
76
|
+
fromUser: user._id,
|
|
77
|
+
toRoles: ['reviewer'],
|
|
78
|
+
payload,
|
|
79
|
+
}),
|
|
80
|
+
actions: [
|
|
81
|
+
{
|
|
82
|
+
actionCd: 'approve',
|
|
83
|
+
name: 'Approve',
|
|
84
|
+
variant: 'primary',
|
|
85
|
+
sender: false,
|
|
86
|
+
receiver: true,
|
|
87
|
+
runHandler: async ({ actionAttemptId }) => ({ actionAttemptId }),
|
|
88
|
+
},
|
|
89
|
+
],
|
|
56
90
|
};
|
|
57
91
|
defaultRegistry.register(myTemplate);
|
|
58
92
|
|
|
@@ -60,7 +94,7 @@ const { router, service } = createMessageRoutes({
|
|
|
60
94
|
getModel: mongoose.model.bind(mongoose),
|
|
61
95
|
});
|
|
62
96
|
|
|
63
|
-
app.use('/api/messages', myAuthMiddleware, router);
|
|
97
|
+
app.use('/api/messages', myAuthMiddleware, router.original);
|
|
64
98
|
```
|
|
65
99
|
|
|
66
100
|
For per-app template isolation, pass a custom `TemplateRegistry`:
|
|
@@ -81,27 +115,41 @@ const { router, service } = createMessageRoutes({
|
|
|
81
115
|
|
|
82
116
|
### `createMessageRoutes(options)`
|
|
83
117
|
|
|
84
|
-
Creates a standalone Express router with message template routes. Returns `{ router, service }`.
|
|
118
|
+
Creates a standalone Express router with message template routes. Returns `{ router, service }`. Mount the returned JsonRouter with `router.original`.
|
|
119
|
+
|
|
120
|
+
All user-facing routes require a resolved user with a non-empty `_id`. By default, routes read `req._user || req.user`; pass `authMiddleware` and/or `getUser` to adapt your authentication stack. A request with no resolved user returns `401` before any service, template, payment, model, or action side effect. Hosts remain responsible for installing authentication, permission population, and CSRF protection before mounting these routes.
|
|
121
|
+
|
|
122
|
+
Custom extractors are preserved: `getUser`, `getPermissions`, and `getIdentity` receive the original Express request and their return values are passed through to `MessageService`. The route factory only validates that `getUser` returns a user with a non-empty `_id` before continuing.
|
|
85
123
|
|
|
86
|
-
| Option
|
|
87
|
-
|
|
|
88
|
-
| `getModel`
|
|
89
|
-
| `paymentProvider`
|
|
90
|
-
| `
|
|
91
|
-
| `
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
124
|
+
| Option | Type | Description |
|
|
125
|
+
| ------------------------------ | ----------------------------------- | -------------------------------------------------------------------------- |
|
|
126
|
+
| `getModel` | `(name: string) => Model` | Mongoose model getter |
|
|
127
|
+
| `paymentProvider` | `PaymentProvider` | Optional payment provider (enables payment session handling) |
|
|
128
|
+
| `onPaymentCompensationFailure` | `(event) => void \| Promise<void>` | Optional hook called when expiring an uncommitted payment session fails |
|
|
129
|
+
| `adminRoles` | `string[]` | Roles that receive messages when no `toUser`/`toRoles` is specified |
|
|
130
|
+
| `registry` | `TemplateRegistry` | Custom template registry (default: `defaultRegistry`) |
|
|
131
|
+
| `authMiddleware` | `((req, res, next) => void)[]` | Custom middleware applied to all routes before route validation |
|
|
132
|
+
| `getUser` | `(req) => MessageUser \| undefined` | Extract authenticated user (default: `req._user \|\| req.user`) |
|
|
133
|
+
| `getPermissions` | `(req) => Record<string, boolean>` | Extract permissions (default: `req._permissions \|\| {}`) |
|
|
134
|
+
| `getIdentity` | `(req) => Record<string, unknown>` | Extract identity (default: `req._identity \|\| {}`) |
|
|
135
|
+
| `adminPermissionKey` | `string` | Permission key for admin read-only view of actions (default: `'is.admin'`) |
|
|
97
136
|
|
|
98
137
|
Routes:
|
|
99
138
|
|
|
100
139
|
- `POST /new/:templateCd` — create message from template. Body: `{ ...payload, clientRequestId? }`
|
|
101
140
|
- `GET /:id/actions/:usertype` — get available actions (`usertype` is `sender` or `receiver`)
|
|
102
|
-
- `GET /:id/action/:actionCd` — execute action (GET)
|
|
103
141
|
- `POST /:id/action/:actionCd` — execute action (POST)
|
|
104
142
|
|
|
143
|
+
Route input validation happens before database or template lookup:
|
|
144
|
+
|
|
145
|
+
- `templateCd` must be 1-128 letters, digits, dots, underscores, or hyphens.
|
|
146
|
+
- `actionCd` must be 1-128 letters, digits, underscores, or hyphens.
|
|
147
|
+
- `id` must be a 24-character hex MongoDB ObjectId string.
|
|
148
|
+
- `usertype` must be `sender` or `receiver`.
|
|
149
|
+
- `clientRequestId`, when present, must be a string, is trimmed by the service, must be non-empty after trimming, and must be at most 128 characters.
|
|
150
|
+
|
|
151
|
+
HTTP contract change: action mutation is POST-only. `GET /:id/action/:actionCd` is not registered and must not be used for state changes; callers should treat a GET response as not found or method-not-allowed depending on surrounding Express middleware.
|
|
152
|
+
|
|
105
153
|
### `MessageService`
|
|
106
154
|
|
|
107
155
|
Core service for creating messages, getting actions, and handling actions. Constructed by `createMessageRoutes`, or directly:
|
|
@@ -120,19 +168,23 @@ const service = new MessageService({
|
|
|
120
168
|
|
|
121
169
|
Methods:
|
|
122
170
|
|
|
123
|
-
- `createMessage(params)` — create from template. Supports `clientRequestId` for idempotency.
|
|
171
|
+
- `createMessage(params)` — create from template. Supports `clientRequestId` for requester-and-template-scoped idempotency.
|
|
124
172
|
- `createNotification(params)` — create a generic (action-less) notification. Accepts `fromUser`, `toUser`, `toRoles`, `receiverContent`, `senderContent`, `documents`.
|
|
125
173
|
- `listMessages({ user, limit?, skip?, populate? })` — list messages visible to a user.
|
|
126
174
|
- `countMessages(user)` — count messages visible to a user.
|
|
127
175
|
- `findMessage(id, { populate?, select? })` — find a message by id (active or archive).
|
|
128
176
|
- `findMessageOrThrow(id, { populate?, select? })` — same as `findMessage`, but throws `MessageNotFoundError`.
|
|
129
177
|
- `getActions(id, usertype, { permissions?, isAdmin?, populate? })` — get available actions.
|
|
130
|
-
- `handleAction(templateCd, actionCd, { message, user, permissions? })` — execute an action.
|
|
178
|
+
- `handleAction(templateCd, actionCd, { message, user, permissions? })` — execute an action with a durable claim, stable handler attempt key, transactional archive, and post-commit sender notification status.
|
|
131
179
|
- `buildVisibilityFilter(user)` — get the Mongoose filter for messages visible to a user.
|
|
132
180
|
|
|
181
|
+
Pagination contract: `defaultListLimit` and `maxListLimit` must be finite integers, `maxListLimit` must be at least 1, and the default must not exceed the max. Request `limit` and `skip` values must also be finite integers; fractional, `NaN`, and infinite values are rejected. Request `limit <= 0` is normalized to 1, high limits are clamped to `maxListLimit`, and negative `skip` is normalized to 0 so invalid input never turns into an unbounded MongoDB query. Results are ordered by `{ createdAt: -1, _id: -1 }` for deterministic offset pages when timestamps tie.
|
|
182
|
+
|
|
183
|
+
`listMessages` uses offset pagination (`skip` + `limit`) for compatibility. Large skips become progressively more expensive because MongoDB still advances over skipped matches before returning the page. Keep `maxListLimit` bounded and prefer a host-owned cursor query using `buildVisibilityFilter(user)` plus the same `{ createdAt: -1, _id: -1 }` ordering for very large inboxes.
|
|
184
|
+
|
|
133
185
|
### `TemplateRegistry`
|
|
134
186
|
|
|
135
|
-
In-memory registry for message templates.
|
|
187
|
+
In-memory registry for trusted message templates.
|
|
136
188
|
|
|
137
189
|
```typescript
|
|
138
190
|
const registry = new TemplateRegistry();
|
|
@@ -146,8 +198,33 @@ registry.clear();
|
|
|
146
198
|
|
|
147
199
|
`defaultRegistry` is a global instance for simple cases.
|
|
148
200
|
|
|
201
|
+
The package supports both ESM `import` and CommonJS `require`. Prefer named root imports. `defaultRegistry` is shared through `globalThis` so mixed ESM/CommonJS consumers in one process observe the same mutable registry, but application code should still prefer an explicit `new TemplateRegistry()` per app/test for isolation.
|
|
202
|
+
|
|
203
|
+
Supported package-root exports are the service (`MessageService`), route factory (`createMessageRoutes`), schema factories and model-name constants, template helpers (`TemplateRegistry`, `defaultRegistry`, `includesAction`, `interpolateTemplate`, `filterActions`, `isActionAllowed`), no-op providers, provider/template/message types including `UserId`, and the typed errors listed below. Deep `dist/*` or `src/*` imports are not part of the public contract.
|
|
204
|
+
|
|
149
205
|
`includesAction(templateCd, actionCd, registry)` requires the registry explicitly so archive behavior never falls back to unrelated global state.
|
|
150
206
|
|
|
207
|
+
Templates are trusted application code, not caller-supplied configuration. `prepareMessage`, `condition`, `runHandler`, and notification functions run with your application's authority and may perform database or external side effects.
|
|
208
|
+
|
|
209
|
+
`register()` validates action metadata deterministically. A template cannot register duplicate `actionCd` values, an action unavailable to both sender and receiver, or multiple `isDefault` actions for the same usertype. Registering the same `templateCd` again still replaces the prior template after the new definition validates.
|
|
210
|
+
|
|
211
|
+
The registry stores a frozen shallow snapshot of authorization/action-critical structure: content objects, `uiTemplate` objects, the action array, action objects, action confirmations, and action payload objects. Function fields keep their original identity, but mutating the object originally passed to `register()` or a template returned by `find()`/`getAll()` cannot accidentally change registered action authorization, handlers, or rendered content. Register a replacement template when behavior should change.
|
|
212
|
+
|
|
213
|
+
Template interpolation uses Handlebars with `noEscape: true`. Rendered strings are plain text values, not sanitized HTML. If a frontend, email adapter, or server-rendered view inserts returned content into HTML, that renderer must HTML-escape the strings at the output boundary. The template engine intentionally does not sanitize markup because templates are trusted code and messages may be rendered in non-HTML contexts.
|
|
214
|
+
|
|
215
|
+
Compiled template strings are cached process-locally for a finite static template set. Do not generate unbounded per-request template strings; if your application needs user-defined or dynamic templates, put a bounded cache and review workflow at that application boundary before passing templates to this package.
|
|
216
|
+
|
|
217
|
+
### Action lifecycle notes
|
|
218
|
+
|
|
219
|
+
- Active messages start with `actionState: 'active'`. `handleAction()` validates the selected template action, permission, sender/receiver, and action condition at the service boundary before running template code.
|
|
220
|
+
- The service claims an action with an atomic conditional `findOneAndUpdate()`. Only an unclaimed active message, a retryable same-action attempt, or an expired same-action processing lease can move to `actionState: 'processing'`; competing same or different actions receive `ActionConflictError` while a live claim exists.
|
|
221
|
+
- The first successful claim stores a stable `actionAttemptId` on the active message and passes it to `action.runHandler(ctx)` as `ctx.actionAttemptId`. If the process dies after an external handler effect but before archival commit, a same-action retry reuses that attempt id. Handlers must use this key with external systems or their own persistence to deduplicate side effects; the service does not claim arbitrary external calls are exactly-once.
|
|
222
|
+
- If the handler throws before archival commit, the active message moves to `actionState: 'retryable'` with `actionFailureMessage`, and `handleAction()` throws `ActionRetryableError`. A later same-action retry may reclaim the same attempt id; different actions continue to conflict with the outstanding attempt.
|
|
223
|
+
- Archive insertion and active-message deletion run in one MongoDB session transaction using the active and archive models from the service model resolver. If either operation cannot commit, the transaction aborts and no archive-only or active-plus-archive split state is reported as success.
|
|
224
|
+
- When an action commits, the archive stores `actionAttemptId` and `actionNotificationState`. Actions without `senderNotification` store `none`. Actions with sender notification store `pending` in the archive transaction, then update to `sent` after post-commit delivery succeeds.
|
|
225
|
+
- If sender notification fails after the archive commits, the archive is updated to `failed` and `handleAction()` throws `ActionNotificationPendingError` rather than rerunning or reporting the business action as uncommitted. A retry against that archived message returns the same committed-notification-pending outcome.
|
|
226
|
+
- Crash recovery boundaries are explicit: before claim means another request can claim; after claim but before/during handler means the live claim conflicts until its lease expires; after handler before archive commit means retry must reuse `actionAttemptId`; during archive commit the transaction commits both archive and active delete or rolls both back; after archive commit before notification delivery leaves archived `actionNotificationState: 'pending'`.
|
|
227
|
+
|
|
151
228
|
### Schema factories
|
|
152
229
|
|
|
153
230
|
```typescript
|
|
@@ -165,15 +242,29 @@ const MessageArchive = mongoose.model(MESSAGE_ARCHIVE_MODEL_NAME, buildMessageAr
|
|
|
165
242
|
const MessageRequest = mongoose.model(MESSAGE_REQUEST_MODEL_NAME, buildMessageRequestSchema());
|
|
166
243
|
```
|
|
167
244
|
|
|
168
|
-
`buildMessageSchema({ emailNotifier, emailNotificationExclusions, userModelName, archiveModelName })` lets you opt in to email notifications with per-template exclusions. When `emailNotifier` is `null` (the default), no
|
|
245
|
+
`buildMessageSchema({ emailNotifier, emailNotificationExclusions, onEmailDeliveryFailure, userModelName, archiveModelName })` lets you opt in to email notifications with per-template exclusions. When `emailNotifier` is `null` (the default), no email hook is registered.
|
|
246
|
+
|
|
247
|
+
Email delivery is best-effort, not durable. The schema sends only for newly created messages after a successful non-transactional save. It deliberately skips documents saved with a Mongoose session because Mongoose save hooks run before the surrounding transaction commits; this package does not include a durable email outbox or retry worker. Hosts that need reliable transactional email should write their own outbox record in the same transaction and deliver from that outbox after commit.
|
|
248
|
+
|
|
249
|
+
Email lookup and notifier failures do not roll back a committed message. Pass `onEmailDeliveryFailure` to observe recipient lookup or notifier failures and connect them to your logger or metrics. If the observer itself throws, that secondary error is swallowed so a committed message create is not reported as failed.
|
|
169
250
|
|
|
170
|
-
|
|
251
|
+
Email exclusion matching uses the rendered receiver title after trim + lowercase normalization. The title passed to the notifier is trimmed but keeps its original case. Existing message updates do not resend email; change-triggered or manual resend workflows should be implemented explicitly by the host application.
|
|
252
|
+
|
|
253
|
+
When you use `clientRequestId`, register all three models. `MessageRequest` stores the reservation record that prevents duplicate side effects during concurrent duplicate requests in the same scope.
|
|
171
254
|
|
|
172
255
|
### Idempotent create notes
|
|
173
256
|
|
|
174
257
|
- `clientRequestId` is safe for concurrent retries only when `MessageRequest` is registered.
|
|
175
|
-
-
|
|
176
|
-
-
|
|
258
|
+
- At the `MessageService` boundary, `clientRequestId` must be a string. It is trimmed before use, must remain non-empty after trimming, and must be at most 128 characters. Case is preserved, so `Request-1` and `request-1` are different idempotency keys.
|
|
259
|
+
- The idempotency scope is exactly the trimmed `clientRequestId`, the requester identity (`String(user._id)`), and `templateCd`. A reused ID from another user or another template starts its own operation and never replays the first scope's messages.
|
|
260
|
+
- The winning request reserves the scoped idempotency key before template preparation or payment-session creation runs.
|
|
261
|
+
- Payment sessions created for messages that fail before persistence, or for idempotent batches whose transaction does not commit, are expired through `PaymentProvider.expireSession()`. A completed same-scope retry replays the committed message batch and does not create another payment session.
|
|
262
|
+
- Reservations move through explicit `pending`, `completed`, and `failed` states. A completed replay is returned only when the completed reservation's `itemCount` exactly matches the persisted distinct `clientRequestItemIndex` values. Completed zero-item results are replayed as `[]`.
|
|
263
|
+
- Losing requests in the same scope wait for the winning request to finish. If the reservation remains live beyond `clientRequestWaitMs`, `ClientRequestPendingError` is thrown and the caller should retry later. If the pending lease expires after `clientRequestLeaseMs`, exactly one retry can atomically take over the reservation.
|
|
264
|
+
- Multi-item idempotent batches are persisted in one MongoDB transaction together with the transition to `completed`. Production deployments that use `clientRequestId` therefore need MongoDB replica set or sharded-cluster transaction support. Standalone MongoDB servers fail with `MessageTransactionRequiredError` instead of returning partially persisted batches.
|
|
265
|
+
- If creation fails, the reservation is recorded as `failed` and later retries raise `ClientRequestFailedError`. If persisted state is corrupt, such as a completed reservation missing an expected item index, the service raises `ClientRequestInconsistentStateError` rather than returning a partial array.
|
|
266
|
+
|
|
267
|
+
Migration note: versions with scoped idempotency add `clientRequestOwnerId` to messages and reservations and replace the old global `clientRequestId` unique indexes with compound indexes on `{ clientRequestOwnerId, templateCd, clientRequestId }` plus `clientRequestItemIndex` for message items. Drop any obsolete deployment-created unique index on `clientRequestId` before building the new schema indexes, otherwise different users or templates can still collide globally.
|
|
177
268
|
|
|
178
269
|
### Typed errors
|
|
179
270
|
|
|
@@ -183,10 +274,49 @@ import {
|
|
|
183
274
|
TemplateNotFoundError,
|
|
184
275
|
ActionNotFoundError,
|
|
185
276
|
ActionNotAllowedError,
|
|
277
|
+
ActionConflictError,
|
|
278
|
+
ActionRetryableError,
|
|
279
|
+
ActionNotificationPendingError,
|
|
280
|
+
InvalidClientRequestIdError,
|
|
281
|
+
InvalidPaginationValueError,
|
|
282
|
+
ClientRequestPendingError,
|
|
283
|
+
ClientRequestFailedError,
|
|
284
|
+
ClientRequestInconsistentStateError,
|
|
285
|
+
MessageTransactionRequiredError,
|
|
286
|
+
PaymentSessionCompensationError,
|
|
287
|
+
MessageArchivedError,
|
|
288
|
+
InvalidMessageUserError,
|
|
289
|
+
ActionTemplateMismatchError,
|
|
290
|
+
MessageModelResolutionError,
|
|
291
|
+
TemplateRegistryValidationError,
|
|
186
292
|
} from '@web-ts-toolkit/message-service';
|
|
187
293
|
```
|
|
188
294
|
|
|
189
|
-
The
|
|
295
|
+
The package brands exported errors so `instanceof` works across mixed ESM/CommonJS imports in one process.
|
|
296
|
+
|
|
297
|
+
| Error | Direct-service meaning | Route status |
|
|
298
|
+
| ------------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------- |
|
|
299
|
+
| `MessageNotFoundError` | `findMessageOrThrow()` could not find an active or archived message | 404 |
|
|
300
|
+
| `TemplateNotFoundError` | Requested `templateCd` is not registered | 404 |
|
|
301
|
+
| `ActionNotFoundError` | Requested action is not present on the message template | 404 |
|
|
302
|
+
| `ActionNotAllowedError` | User lacks sender/receiver, permission, or condition access | 403 |
|
|
303
|
+
| `MessageArchivedError` | Mutation was requested for an archived message | 410 |
|
|
304
|
+
| `InvalidMessageUserError` | Missing/invalid user identity at the service boundary | 401 |
|
|
305
|
+
| `InvalidClientRequestIdError` | `clientRequestId` is not a valid bounded string | 400 |
|
|
306
|
+
| `ActionConflictError` | Another live action attempt owns the message | 409 |
|
|
307
|
+
| `ActionRetryableError` | Handler failed before archive commit; same action may retry with the persisted `actionAttemptId` | 409 |
|
|
308
|
+
| `ActionNotificationPendingError` | Business action committed, but sender notification still needs reconciliation | 202 |
|
|
309
|
+
| `ClientRequestPendingError` | Duplicate create is still live; retry after completion or lease expiry | 409 |
|
|
310
|
+
| `ClientRequestFailedError` | The scoped create operation has a recorded terminal failure | 409 |
|
|
311
|
+
| `ClientRequestInconsistentStateError` | Persisted reservation/message state is corrupt | propagated as a server error |
|
|
312
|
+
| `MessageTransactionRequiredError` | MongoDB deployment cannot provide required idempotent batch transactions | propagated as a server error |
|
|
313
|
+
| `InvalidPaginationValueError` | Invalid service construction or list pagination input | propagated as a server error unless handled by host code |
|
|
314
|
+
| `ActionTemplateMismatchError` | Direct `handleAction()` template argument does not match the message | propagated as a server error unless handled by host code |
|
|
315
|
+
| `MessageModelResolutionError` | Configured active/archive/request/user model cannot be resolved | propagated as a server error |
|
|
316
|
+
| `PaymentSessionCompensationError` | Create failed and at least one uncommitted payment session could not be expired | propagated as a server error |
|
|
317
|
+
| `TemplateRegistryValidationError` | Invalid trusted template metadata at registration time | not thrown by route handlers |
|
|
318
|
+
|
|
319
|
+
Malformed route values and ObjectId cast failures are translated to 400 before service/model/template lookup. Direct callers should treat `ClientRequestPendingError` as retryable, `ClientRequestFailedError` as a stable recorded failure for that scope, and `ClientRequestInconsistentStateError`/`MessageTransactionRequiredError` as operational errors requiring investigation. Action handlers should treat `ActionConflictError` as a live in-progress action, `ActionRetryableError` as a same-action retry using the persisted attempt key, and `ActionNotificationPendingError` as a committed business action whose sender notification still needs host reconciliation. Inspect `PaymentSessionCompensationError` and the compensation hook event before retrying or reconciling with the provider.
|
|
190
320
|
|
|
191
321
|
## Custom Providers
|
|
192
322
|
|
|
@@ -206,7 +336,7 @@ class SendGridEmailProvider implements EmailProvider {
|
|
|
206
336
|
}
|
|
207
337
|
```
|
|
208
338
|
|
|
209
|
-
Pass it to `buildMessageSchema({ emailNotifier: provider.sendNotification.bind(provider) })`.
|
|
339
|
+
Pass it to `buildMessageSchema({ emailNotifier: provider.sendNotification.bind(provider), onEmailDeliveryFailure })`.
|
|
210
340
|
|
|
211
341
|
### Payment Provider
|
|
212
342
|
|
|
@@ -241,6 +371,8 @@ class StripePaymentProvider implements PaymentProvider {
|
|
|
241
371
|
|
|
242
372
|
Pass it to `MessageService({ paymentProvider })` or `createMessageRoutes({ paymentProvider })`.
|
|
243
373
|
|
|
374
|
+
Payment providers must treat `expireSession(sessionId)` and `refundPayment(sessionId)` as idempotent. During message creation, the service creates payment sessions before MongoDB can commit the message. If the message write or idempotent batch transaction fails, the service expires every session it created for that uncommitted work. If expiration fails, the service calls `onPaymentCompensationFailure` with the session id, scope, original error, and expiration error, then throws `PaymentSessionCompensationError`; the idempotency reservation is recorded as failed according to the create state machine.
|
|
375
|
+
|
|
244
376
|
## License
|
|
245
377
|
|
|
246
|
-
|
|
378
|
+
Apache-2.0
|