@web-ts-toolkit/message-service 0.43.0 → 0.47.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 +230 -57
- package/index.d.mts +696 -111
- package/index.d.ts +696 -111
- package/index.js +1382 -686
- package/index.mjs +1363 -676
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -13,6 +13,8 @@ Peer dependencies:
|
|
|
13
13
|
- `mongoose >= 8`
|
|
14
14
|
- `express >= 5`
|
|
15
15
|
|
|
16
|
+
Bundling note: `handlebars` and the workspace deps are bundled (`tsup` externals are only `mongoose`/`express`), so only `mongoose` + `express` are peers.
|
|
17
|
+
|
|
16
18
|
## Highlights
|
|
17
19
|
|
|
18
20
|
- **Message persistence schemas** — `Message`, `MessageArchive`, and `MessageRequest` schemas with timestamps, indexes, archiving, and idempotency reservations
|
|
@@ -76,6 +78,9 @@ const myTemplate: MessageTemplate = {
|
|
|
76
78
|
fromUser: user._id,
|
|
77
79
|
toRoles: ['reviewer'],
|
|
78
80
|
payload,
|
|
81
|
+
// Content (`senderContent`/`receiverContent`) renders from `templateData`,
|
|
82
|
+
// not from `payload`. Map caller input here so `{{name}}` renders.
|
|
83
|
+
templateData: { name: String(payload.name ?? '') },
|
|
79
84
|
}),
|
|
80
85
|
actions: [
|
|
81
86
|
{
|
|
@@ -97,6 +102,37 @@ const { router, service } = createMessageRoutes({
|
|
|
97
102
|
app.use('/api/messages', myAuthMiddleware, router.original);
|
|
98
103
|
```
|
|
99
104
|
|
|
105
|
+
Prerequisites for the flow above: Node `>=22`, `mongoose >= 8`, `express >= 5`,
|
|
106
|
+
and a MongoDB replica set (or sharded cluster) when you use `clientRequestId`
|
|
107
|
+
idempotency or transactional archival — standalone servers throw
|
|
108
|
+
`MessageTransactionRequiredError` instead of partially persisting. The auth
|
|
109
|
+
middleware above is required: routes resolve `req._user || req.user` and return
|
|
110
|
+
`401` before any service, template, payment, model, or action effect when the
|
|
111
|
+
identity is missing or malformed.
|
|
112
|
+
|
|
113
|
+
Create a message and act on it (content renders from `templateData`):
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
const senderId = new mongoose.Types.ObjectId();
|
|
117
|
+
const reviewerId = new mongoose.Types.ObjectId();
|
|
118
|
+
const created = await service.createMessage({
|
|
119
|
+
templateCd: 'welcome.request',
|
|
120
|
+
user: { _id: senderId },
|
|
121
|
+
payload: { name: 'Ada' },
|
|
122
|
+
});
|
|
123
|
+
// `created` is `Array<IMessage | IMessageArchive>`; fresh creates resolve
|
|
124
|
+
// active documents only, while completed `clientRequestId` replays may mix in
|
|
125
|
+
// `IMessageArchive` records (narrow with `'archivedAt' in doc`).
|
|
126
|
+
const message = created[0];
|
|
127
|
+
if (!message || 'archivedAt' in message) throw new Error('expected an active message');
|
|
128
|
+
console.log(message.receiverContent.title); // 'Review Ada'
|
|
129
|
+
|
|
130
|
+
const listed = await service.getActions(String(message._id), 'receiver', {
|
|
131
|
+
user: { _id: reviewerId, roles: ['reviewer'] },
|
|
132
|
+
});
|
|
133
|
+
console.log(listed?.actions.map((action) => action.actionCd)); // ['approve']
|
|
134
|
+
```
|
|
135
|
+
|
|
100
136
|
For per-app template isolation, pass a custom `TemplateRegistry`:
|
|
101
137
|
|
|
102
138
|
```typescript
|
|
@@ -117,22 +153,23 @@ const { router, service } = createMessageRoutes({
|
|
|
117
153
|
|
|
118
154
|
Creates a standalone Express router with message template routes. Returns `{ router, service }`. Mount the returned JsonRouter with `router.original`.
|
|
119
155
|
|
|
120
|
-
All user-facing routes require a resolved user with a non-empty `
|
|
156
|
+
All user-facing routes require a resolved user with a valid `_id` (non-empty string or `ObjectId`; missing, null, empty, numeric, array, and plain-object ids return `401`). 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
157
|
|
|
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
|
|
158
|
+
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 enforces the shared principal contract: `getUser` must return a user with a non-empty string or `ObjectId` `_id` before continuing.
|
|
123
159
|
|
|
124
|
-
| Option | Type
|
|
125
|
-
| ------------------------------ |
|
|
126
|
-
| `getModel` | `(name: string) => Model`
|
|
127
|
-
| `paymentProvider` | `PaymentProvider`
|
|
128
|
-
| `onPaymentCompensationFailure` | `(event) => void \| Promise<void>`
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
135
|
-
| `
|
|
160
|
+
| Option | Type | Description |
|
|
161
|
+
| ------------------------------ | ------------------------------------------ | --------------------------------------------------------------------------------- |
|
|
162
|
+
| `getModel` | `(name: string) => Model` | Mongoose model getter |
|
|
163
|
+
| `paymentProvider` | `PaymentProvider` | Optional payment provider (enables payment session handling) |
|
|
164
|
+
| `onPaymentCompensationFailure` | `(event) => void \| Promise<void>` | Optional hook called when expiring an uncommitted payment session fails |
|
|
165
|
+
| `onTransactionCleanupFailure` | `MessageTransactionCleanupFailureObserver` | Best-effort diagnostics when a service-owned transaction's `endSession()` rejects |
|
|
166
|
+
| `adminRoles` | `string[]` | Roles that receive messages when no `toUser`/`toRoles` is specified |
|
|
167
|
+
| `registry` | `TemplateRegistry` | Custom template registry (default: `defaultRegistry`) |
|
|
168
|
+
| `authMiddleware` | `((req, res, next) => void)[]` | Custom middleware applied to all routes before route validation |
|
|
169
|
+
| `getUser` | `(req) => MessageUser \| undefined` | Extract authenticated user (default: `req._user \|\| req.user`) |
|
|
170
|
+
| `getPermissions` | `(req) => Record<string, boolean>` | Extract permissions (default: `req._permissions \|\| {}`) |
|
|
171
|
+
| `getIdentity` | `(req) => Record<string, unknown>` | Extract identity (default: `req._identity \|\| {}`) |
|
|
172
|
+
| `adminPermissionKey` | `string` | Permission key for admin read-only view of actions (default: `'is.admin'`) |
|
|
136
173
|
|
|
137
174
|
Routes:
|
|
138
175
|
|
|
@@ -150,17 +187,113 @@ Route input validation happens before database or template lookup:
|
|
|
150
187
|
|
|
151
188
|
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
189
|
|
|
190
|
+
### Public message DTOs (MSGR-03 migration)
|
|
191
|
+
|
|
192
|
+
`POST /new/:templateCd` returns `PublicMessageDto[]` for both fresh creation and
|
|
193
|
+
completed replay. The shipped Node host's list endpoint returns the same DTOs in
|
|
194
|
+
its existing `{ success: true, data }` envelope. Root named exports
|
|
195
|
+
`serializePublicMessage`, `PublicMessageDto`, `PublicMessageParty`, and
|
|
196
|
+
`PublicMessageSource` let custom HTTP adapters use this contract too:
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
import {
|
|
200
|
+
serializePublicMessage,
|
|
201
|
+
type MessageService,
|
|
202
|
+
type MessageUser,
|
|
203
|
+
type PublicMessageDto,
|
|
204
|
+
} from '@web-ts-toolkit/message-service';
|
|
205
|
+
|
|
206
|
+
export async function publicInbox(service: MessageService, user: MessageUser): Promise<PublicMessageDto[]> {
|
|
207
|
+
const messages = await service.listMessages({ user });
|
|
208
|
+
return messages.map(serializePublicMessage);
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The explicit allowlist is:
|
|
213
|
+
|
|
214
|
+
- `_id`, `templateCd`, `type`, `fromUser`, `toUser`, `toRoles`;
|
|
215
|
+
- `senderContent` and `receiverContent` (only `title`, `long`, `short`),
|
|
216
|
+
`documents` (attachment IDs), `payload`, `display`;
|
|
217
|
+
- `paymentCd` and `paymentSession` (intentional business payment reference);
|
|
218
|
+
- `actionState`, `actionCd`, `actionAttemptId`, `createdAt`, `updatedAt`;
|
|
219
|
+
- archives additionally expose `archivedBy`, `archivedAt`, `actionNotificationState`.
|
|
220
|
+
|
|
221
|
+
IDs are strings and dates are ISO strings. Unpopulated parties are IDs or `null`;
|
|
222
|
+
populated parties contain only `{ _id, displayName?, email? }`, with presentation
|
|
223
|
+
fields included only when they are strings. Missing/unsupported party references
|
|
224
|
+
become `null`; populated attachments and `archivedBy` become IDs. Populate
|
|
225
|
+
`email`/`displayName` only when appropriate for the response audience. The helper
|
|
226
|
+
expects stored message fields (including timestamps), accepts hydrated or plain
|
|
227
|
+
records, and performs no authorization itself.
|
|
228
|
+
|
|
229
|
+
Migration: clients must stop reading raw diagnostics, `actionOwnerToken`, claim
|
|
230
|
+
identity/timestamps/leases, notification-attempt timestamps, `clientRequest*`
|
|
231
|
+
bookkeeping, `__v`, or arbitrary schema/populated-user fields from HTTP messages.
|
|
232
|
+
They are no longer returned. Use `_id` for message identity and keep the submitted
|
|
233
|
+
request key client-side for replay. The intentional `actionAttemptId` contract
|
|
234
|
+
remains available for authorized outcome correlation, including existing 409/202
|
|
235
|
+
action responses; it is not a worker ownership credential. For archives, use
|
|
236
|
+
`archivedAt` to detect terminal state (`actionState` is the stored action snapshot).
|
|
237
|
+
|
|
238
|
+
Direct `createMessage`, find, and list service results remain hydrated Mongoose
|
|
239
|
+
records with internal diagnostics and lifecycle methods where applicable; the
|
|
240
|
+
serializer does not mutate them. Operational observers retain their raw errors.
|
|
241
|
+
`payload`, `display`, text content, and action-handler results are host-controlled
|
|
242
|
+
business data, **not automatically sanitized**. Custom fields require an explicit
|
|
243
|
+
host DTO extension after authorization rather than spreading stored documents.
|
|
244
|
+
|
|
245
|
+
### Transaction cleanup diagnostics
|
|
246
|
+
|
|
247
|
+
Migration (MSGR-01): once MongoDB's `withTransaction()` resolves, session cleanup
|
|
248
|
+
failure no longer rejects creation or archival. Paid batches retain their payment
|
|
249
|
+
sessions and replay normally; committed actions continue to the documented
|
|
250
|
+
post-commit sender notification (`sent`, or `ActionNotificationPendingError` with
|
|
251
|
+
stored `failed` state). Direct `archive()` resolves successfully after commit.
|
|
252
|
+
If the transaction rejects, cleanup cannot replace its primary error. If a
|
|
253
|
+
transaction callback and its abort both reject, the callback error takes precedence.
|
|
254
|
+
|
|
255
|
+
Configure the root-exported `MessageTransactionCleanupFailureObserver` through
|
|
256
|
+
`MessageServiceOptions.onTransactionCleanupFailure` for service writes, and
|
|
257
|
+
`buildMessageSchema({ onTransactionCleanupFailure })` for direct document archival.
|
|
258
|
+
The root-exported `MessageTransactionCleanupFailureEvent` contains:
|
|
259
|
+
|
|
260
|
+
- `operation`: `createBatch`, `actionArchive`, or `directArchive`;
|
|
261
|
+
- `stage`: `endSession`, and `error`: the raw cleanup rejection;
|
|
262
|
+
- `outcome: 'committed'` after confirmed commit, or `outcome: 'failed'` with
|
|
263
|
+
`originalError` when the transaction rejected.
|
|
264
|
+
|
|
265
|
+
These are internal diagnostics, not HTTP response fields. The optional observer
|
|
266
|
+
is awaited once per failed package-owned session cleanup; synchronous throws and
|
|
267
|
+
asynchronous rejections are swallowed. Keep observers bounded (a never-settling
|
|
268
|
+
observer delays the call). Use them for logging/metrics, not payment compensation
|
|
269
|
+
or action retries. Without an observer, cleanup errors are suppressed.
|
|
270
|
+
|
|
271
|
+
Route construction forwards `onTransactionCleanupFailure` unchanged. With
|
|
272
|
+
`createMessageRoutes({ service })`, configure the observer on that service;
|
|
273
|
+
combining an injected service with this construction option is rejected at runtime
|
|
274
|
+
and by strict TypeScript. Schema configuration is independent: service options do
|
|
275
|
+
not reconfigure document methods. Direct archival uses the document's owning
|
|
276
|
+
connection; a session attached with `$session(session)` is borrowed and never
|
|
277
|
+
ended/reported by the package. Its owner remains responsible for cleanup; direct
|
|
278
|
+
archival still calls `withTransaction()` and does not join an already-open transaction.
|
|
279
|
+
|
|
280
|
+
`failed` means no commit was confirmed to this process, **not** proof of rollback.
|
|
281
|
+
Driver retries remain in effect. An unresolved commit acknowledgement/network
|
|
282
|
+
failure or process death can leave an ambiguous outcome; this hook supplies no
|
|
283
|
+
commit reconciliation or payment crash recovery. Reconcile such outcomes with
|
|
284
|
+
MongoDB and the payment provider out of band before retrying external effects.
|
|
285
|
+
|
|
153
286
|
### `MessageService`
|
|
154
287
|
|
|
155
288
|
Core service for creating messages, getting actions, and handling actions. Constructed by `createMessageRoutes`, or directly:
|
|
156
289
|
|
|
157
290
|
```typescript
|
|
158
|
-
import { MessageService, TemplateRegistry } from '@web-ts-toolkit/message-service';
|
|
291
|
+
import { MessageService, NoopPaymentProvider, TemplateRegistry } from '@web-ts-toolkit/message-service';
|
|
159
292
|
|
|
160
293
|
const registry = new TemplateRegistry();
|
|
161
294
|
const service = new MessageService({
|
|
162
295
|
getModel: mongoose.model.bind(mongoose),
|
|
163
|
-
paymentProvider:
|
|
296
|
+
paymentProvider: new NoopPaymentProvider(),
|
|
164
297
|
adminRoles: ['superadmin'],
|
|
165
298
|
registry,
|
|
166
299
|
});
|
|
@@ -168,16 +301,39 @@ const service = new MessageService({
|
|
|
168
301
|
|
|
169
302
|
Methods:
|
|
170
303
|
|
|
171
|
-
- `createMessage(params)` — create from template. Supports `clientRequestId` for requester-and-template-scoped idempotency.
|
|
172
|
-
- `createNotification(params)` — create a generic (action-less) notification. Accepts `fromUser`, `toUser
|
|
304
|
+
- `createMessage(params)` — create from template. Supports `clientRequestId` for requester-and-template-scoped idempotency. Returns `Array<IMessage | IMessageArchive>` (fresh creates resolve active documents; completed replays merge active + archive records, narrowed with `'archivedAt' in doc`). `user` (and `payerUser` when provided) must carry a valid principal id (non-empty string or `ObjectId`); invalid principals throw `InvalidMessageUserError` before any template, payment, or model effect in both branches.
|
|
305
|
+
- `createNotification(params)` — create a generic (action-less) notification. Accepts raw `UserId` values (`fromUser`, `toUser`), `toRoles`, `receiverContent`/`senderContent` (`{ title, long, short? }`), `documents`. Returns `IMessage`. Trusted host-level operation: performs no principal validation.
|
|
173
306
|
- `listMessages({ user, limit?, skip?, populate? })` — list messages visible to a user.
|
|
174
307
|
- `countMessages(user)` — count messages visible to a user.
|
|
175
|
-
- `findMessage(id, { populate?, select? })` — find a message by id (active or archive).
|
|
176
|
-
- `findMessageOrThrow(id, { populate?, select? })` — same as `findMessage`, but throws `MessageNotFoundError`.
|
|
177
|
-
- `getActions(
|
|
308
|
+
- `findMessage(id, { populate?, select? })` — find a message by id (active or archive). Trusted host-level operation: performs no principal validation.
|
|
309
|
+
- `findMessageOrThrow(id, { populate?, select? })` — same as `findMessage`, but throws `MessageNotFoundError`. Trusted host-level operation.
|
|
310
|
+
- `getActions(messageId, usertype, { user, permissions?, isAdmin?, populate? })` — read available actions from storage by the requested ID on the configured connection. `user` is required and validated before any model lookup or template operation. Returns `{ uiTemplate, actions }` or `null`; admin/archived views return the resolved `uiTemplate` with an empty list without evaluating conditions. The old `message` option is deprecated and ignored.
|
|
178
311
|
- `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.
|
|
179
312
|
- `buildVisibilityFilter(user)` — get the Mongoose filter for messages visible to a user.
|
|
180
313
|
|
|
314
|
+
Action-read migration: omit `getActions`' deprecated `message` option. It is
|
|
315
|
+
accepted for source compatibility but ignored, even when its ID matches. Every
|
|
316
|
+
listing reads the requested ID (active, then archive) through the service's
|
|
317
|
+
configured model resolver/connection. Missing records return `null`. The trusted
|
|
318
|
+
`findMessage` APIs still return hydrated snapshots without authorization; do not
|
|
319
|
+
use a cached result as proof of current access.
|
|
320
|
+
|
|
321
|
+
Relationships are checked against stored `fromUser`, `toUser`, and `toRoles`
|
|
322
|
+
before optional `populate`. Eligible active conditions receive that message with
|
|
323
|
+
the requested presentation population applied; admin/archive views skip both
|
|
324
|
+
population and conditions. Mutation conditions must also support unpopulated
|
|
325
|
+
references because `handleAction` rechecks the atomic claim without population.
|
|
326
|
+
Relationship methods accept string/ObjectId identities and populated `_id`s;
|
|
327
|
+
null, deleted, or projected-away populated references never match an identity.
|
|
328
|
+
An explicit recipient role can still grant access independently. These methods
|
|
329
|
+
do not check host account liveness: a valid authenticated principal can match a
|
|
330
|
+
canonical stored ID even if presentation population finds no user. Hosts own
|
|
331
|
+
account revocation and must clear stored relationships when that is their policy.
|
|
332
|
+
|
|
333
|
+
Listings and archived outcomes reflect their database read snapshot, not a
|
|
334
|
+
reservation or a promise of future access. Later writes may change availability;
|
|
335
|
+
active mutations retain their atomic claim-bound authorization and fencing.
|
|
336
|
+
|
|
181
337
|
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
338
|
|
|
183
339
|
`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.
|
|
@@ -187,11 +343,13 @@ Pagination contract: `defaultListLimit` and `maxListLimit` must be finite intege
|
|
|
187
343
|
In-memory registry for trusted message templates.
|
|
188
344
|
|
|
189
345
|
```typescript
|
|
346
|
+
import { TemplateRegistry, type RegisteredMessageTemplate } from '@web-ts-toolkit/message-service';
|
|
347
|
+
|
|
190
348
|
const registry = new TemplateRegistry();
|
|
191
349
|
registry.register(template);
|
|
192
|
-
registry.find('template-cd');
|
|
350
|
+
const found: RegisteredMessageTemplate | undefined = registry.find('template-cd');
|
|
193
351
|
registry.has('template-cd');
|
|
194
|
-
registry.getAll();
|
|
352
|
+
const all: RegisteredMessageTemplate[] = registry.getAll();
|
|
195
353
|
registry.unregister('template-cd');
|
|
196
354
|
registry.clear();
|
|
197
355
|
```
|
|
@@ -200,7 +358,7 @@ registry.clear();
|
|
|
200
358
|
|
|
201
359
|
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
360
|
|
|
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.
|
|
361
|
+
Supported package-root exports are the service (`MessageService`, `isValidMessageUserId`, `requireMessageUserId`, `MAX_MESSAGE_SERVICE_TIMEOUT_MS`), route factory (`createMessageRoutes` plus its `MessageRoutes*` option types), schema factories and model-name constants, template helpers (`TemplateRegistry`, `defaultRegistry`, `includesAction`, `interpolateTemplate`, `interpolateMessageContent`, `resolveUiTemplate`, `filterActions`, `isActionAllowed`, `hasExplicitPermissionGrant`), no-op providers, provider/template/message types including `UserId`, `MessageTemplate`, `RegisteredMessageTemplate`, `RegisteredMessageAction`, `RegisteredUiTemplate`, `IMessage`, `IMessageArchive`, and the typed errors listed below. Deep `dist/*` or `src/*` imports are not part of the public contract. Conditional exports keep working: `import` resolves `./dist/index.mjs` with `./dist/index.d.mts` types, `require` resolves `./dist/index.js` with `./dist/index.d.ts` types.
|
|
204
362
|
|
|
205
363
|
`includesAction(templateCd, actionCd, registry)` requires the registry explicitly so archive behavior never falls back to unrelated global state.
|
|
206
364
|
|
|
@@ -208,19 +366,27 @@ Templates are trusted application code, not caller-supplied configuration. `prep
|
|
|
208
366
|
|
|
209
367
|
`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
368
|
|
|
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()`
|
|
369
|
+
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. `find()`/`getAll()` return readonly `RegisteredMessageTemplate` views — mutating them throws at runtime (strict TypeScript also rejects the write). Function fields keep their original identity, but mutating the object originally passed to `register()` cannot accidentally change registered action authorization, handlers, or rendered content. Register a replacement template when behavior should change.
|
|
212
370
|
|
|
213
371
|
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
372
|
|
|
215
373
|
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
374
|
|
|
375
|
+
Rendering/action separation: message content (`senderContent`/`receiverContent`) is rendered at creation time from `PrepareResult.templateData` via `interpolateMessageContent`, without evaluating action `condition` predicates. Action availability is evaluated only when actions are actually requested/executed (`getActions` for eligible active messages via `filterActions`, `handleAction` via `isActionAllowed`) against the persisted message, and action `name`/confirmation strings compile against persisted `message.payload` — not `templateData`. The two data sources are deliberately distinct (no unified interpolation-data feature); missing values in either source render as empty strings. Read-only/admin (`isAdmin`) and archived `getActions` views return the resolved `uiTemplate` with an empty action list and never invoke conditions. The public combined helper `interpolateTemplate` is preserved (content + filtered actions) with unchanged `noEscape: true` plain-text semantics.
|
|
376
|
+
|
|
217
377
|
### Action lifecycle notes
|
|
218
378
|
|
|
219
|
-
- Active messages start with `actionState: 'active'`. `handleAction()`
|
|
379
|
+
- Active messages start with `actionState: 'active'`. `handleAction()` authorizes and selects the handler against the persisted document returned by the atomic claim — not the caller-supplied copy — covering `templateCd`, `toUser`/`fromUser`, `toRoles`, and condition payload data. A stale copy authorized before a stored change cannot execute. If a **fresh attempt** is denied before its handler runs, fenced release restores `active` and clears its action code, attempt ID, owner token, claimant, claim time, lease, and failure diagnostic to `null`. A legitimate caller can then choose any currently allowed action, including a different code in a replacement template, with a new attempt ID. A denied **retry or takeover** instead becomes `retryable`, retaining the same attempt ID and action restriction because earlier execution may have had effects. If ownership changed before either release, the replacement is untouched and the denied caller still receives its authorization error. Supported update protocol: the guarantee covers state as of the atomic claim; hosts must not mutate action-relevant fields concurrently with in-flight actions outside a coordinated protocol, and a fresh read alone is not a substitute for the claim-bound check.
|
|
380
|
+
- Migration: a fresh post-claim denial now leaves the message `active`, without a persisted denial diagnostic or reserved action choice, rather than `retryable`. Handle the authorization error and reload current message state before choosing a valid action. Existing retryable/expired attempts remain conservative same-action retries; this change does not automatically reset older denied attempts whose execution history is unknown.
|
|
381
|
+
- Archived outcomes re-read the archive by the supplied record's ID on its owning connection, retaining resolver/source-connection consistency checks. They require a sender/receiver relationship with that stored record before any current attempt ID or notification state is disclosed. Unrelated callers receive `ActionNotAllowedError` (403 on HTTP) with no attempt IDs on both direct and HTTP paths, including the claim-fallback path when the active copy is gone. Authorized sender/receiver retries remain available even when the template has been unregistered. Migration: supplied archive fields no longer determine the outcome; deleted/missing archives throw `MessageNotFoundError` rather than replaying cached notification state.
|
|
220
382
|
- 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
383
|
- 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
384
|
- 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.
|
|
385
|
+
- Each lease acquisition mints a distinct `actionOwnerToken` while `actionAttemptId` stays stable for external deduplication. The retryable mark, the archive commit, and the post-commit notification-state update are all conditioned on the current owner token: a worker whose lease expired and was taken over receives `ActionConflictError` and cannot change the replacement's claim, archive, or notification state. Archive commits additionally pre-check ownership before inserting and map an archive `_id` conflict to `ActionConflictError`, so exactly one acquisition commits.
|
|
386
|
+
- Long-running handlers must complete within the 30-second action lease; there is no lease-renewal API. Fencing guards persisted state only — it cannot stop an already-running external call, so handlers must still deduplicate external effects with `ctx.actionAttemptId`.
|
|
387
|
+
- Migration: active records written before fencing have no `actionOwnerToken`. A same-action takeover still matches those records, keeps their attempt id, and mints a token, so no backfill is required. Archives record the committing `actionOwnerToken` alongside `actionAttemptId`.
|
|
223
388
|
- 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.
|
|
389
|
+
- Trusted host-level direct archival (`message.archive(actionCd, archivedBy, registry)`) uses the same atomicity on the document's owning connection: it re-reads persisted state inside the transaction, refuses a live in-flight service claim with `ActionConflictError` (only an explicitly expired `processing` lease or `active`/`retryable` may move), and commits archive insert plus conditional active delete together so concurrent direct archive versus service action cannot double-commit. It performs no sender/receiver, permission, or condition authorization — hosts must authorize before calling and must never expose it to untrusted callers. Invalid action/template/user input throws (`TemplateNotFoundError`/`ActionNotFoundError`, `InvalidMessageUserError`) instead of silently succeeding; repeats throw `MessageArchivedError` (archive exists) or `MessageNotFoundError`; deployments without transaction support throw `MessageTransactionRequiredError` before writing. Migration: callers relying on silent no-op success or on transaction-free standalone archival must handle the new errors and use replica-set transactions.
|
|
224
390
|
- 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
391
|
- 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
392
|
- 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'`.
|
|
@@ -258,11 +424,12 @@ When you use `clientRequestId`, register all three models. `MessageRequest` stor
|
|
|
258
424
|
- 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
425
|
- 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
426
|
- 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 `[]`.
|
|
427
|
+
- Payment sessions created for messages that fail before persistence, or for idempotent batches whose transaction does not commit, are expired through `PaymentProvider.expireSession()`. Every newly created session tracked from preparation through the commit attempt is attempted — including earlier batch items when a later item's provider/render step fails, and sessions left uncommitted by model resolution, `startSession()`, persistence, or rollback failures. Every known session is attempted even when an earlier expiration or the `onPaymentCompensationFailure` observer fails; a lone session throws `PaymentSessionCompensationError`, multi-session batches throw `PaymentSessionCompensationAggregateError` (a subclass carrying every per-session failure in `failures` with the triggering error as `originalError`). Committed sessions are never expired because post-commit housekeeping failed, and non-idempotent sequential creation keeps per-item semantics (only the failing item is compensated; already-committed items are retained). Ambiguous provider/commit outcomes the process cannot observe must be reconciled with the provider out of band. A completed same-scope retry replays the committed message batch and does not create another payment session.
|
|
428
|
+
- 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 merged across the active and archive collections (`Array<IMessage | IMessageArchive>`, sorted by item index). Action archival never breaks replay and never reruns preparation/payment: some/all archived batches replay from current records, with archive entries returned as persisted `IMessageArchive` (no fabricated active methods; narrow with `'archivedAt' in doc`). A commit landing between the reservation read and the message reads is reconciled (reservation re-read; one bounded collection re-read) rather than reported as corruption. Completed zero-item results are replayed as `[]`.
|
|
263
429
|
- 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.
|
|
430
|
+
- Timing configuration is validated at construction and throws `InvalidMessageServiceOptionError` for out-of-range values: `clientRequestLeaseMs` and `clientRequestPollMs` must be finite safe integers in `[1, 2147483647]` ms (positive lease/poll); `clientRequestWaitMs` must be in `[0, 2147483647]` ms (nonnegative wait, `0` is a supported immediate check that raises `ClientRequestPendingError` without sleeping when the first attempt cannot acquire or replay). `NaN`, infinite, fractional, negative (and zero lease/poll), and timer-overflow values above `2^31 - 1` (Node's maximum `setTimeout` delay, exported as `MAX_MESSAGE_SERVICE_TIMEOUT_MS`) are rejected. The duplicate-wait deadline bounds polling only — it never cancels a hung database/provider operation; each reservation/message read is awaited to completion, then the deadline decides whether to poll again or throw pending. Tests use injected `clientRequestDelay`/`clientRequestNow` hooks so boundary behavior is deterministic without long sleeps; production defaults use `setTimeout`/`Date.now`, with persisted `leaseExpiresAt` wall-clock timestamps (`new Date(now())`) and the stale-takeover filter compared against the same clock.
|
|
264
431
|
- 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.
|
|
432
|
+
- If creation fails, the reservation is recorded as `failed` (raw diagnostic in `failureMessage`) and later retries raise `ClientRequestFailedError`. The direct-service error carries a stable public message (`clientRequestId "…" previously failed; retry with a new clientRequestId`) with the recorded diagnostic retained on `error.failureReason`/`cause`; the HTTP route returns 409 with that stable message only, never the recorded cause text. If persisted state is corrupt, such as a completed reservation missing an expected item index across both collections after reconciliation, the service raises `ClientRequestInconsistentStateError` rather than returning a partial array. Retain reservations and archive documents for the idempotency-replay window: archive TTL/deletion turns later replays into `ClientRequestInconsistentStateError`, and cross-owner/template IDs remain isolated scopes. The archive declares the same scoped `clientRequest*` indexes as the active collection (including a unique scope+item-index index).
|
|
266
433
|
|
|
267
434
|
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.
|
|
268
435
|
|
|
@@ -278,12 +445,14 @@ import {
|
|
|
278
445
|
ActionRetryableError,
|
|
279
446
|
ActionNotificationPendingError,
|
|
280
447
|
InvalidClientRequestIdError,
|
|
448
|
+
InvalidMessageServiceOptionError,
|
|
281
449
|
InvalidPaginationValueError,
|
|
282
450
|
ClientRequestPendingError,
|
|
283
451
|
ClientRequestFailedError,
|
|
284
452
|
ClientRequestInconsistentStateError,
|
|
285
453
|
MessageTransactionRequiredError,
|
|
286
454
|
PaymentSessionCompensationError,
|
|
455
|
+
PaymentSessionCompensationAggregateError,
|
|
287
456
|
MessageArchivedError,
|
|
288
457
|
InvalidMessageUserError,
|
|
289
458
|
ActionTemplateMismatchError,
|
|
@@ -294,27 +463,29 @@ import {
|
|
|
294
463
|
|
|
295
464
|
The package brands exported errors so `instanceof` works across mixed ESM/CommonJS imports in one process.
|
|
296
465
|
|
|
297
|
-
| Error
|
|
298
|
-
|
|
|
299
|
-
| `MessageNotFoundError`
|
|
300
|
-
| `TemplateNotFoundError`
|
|
301
|
-
| `ActionNotFoundError`
|
|
302
|
-
| `ActionNotAllowedError`
|
|
303
|
-
| `MessageArchivedError`
|
|
304
|
-
| `InvalidMessageUserError`
|
|
305
|
-
| `InvalidClientRequestIdError`
|
|
306
|
-
| `ActionConflictError`
|
|
307
|
-
| `ActionRetryableError`
|
|
308
|
-
| `ActionNotificationPendingError`
|
|
309
|
-
| `ClientRequestPendingError`
|
|
310
|
-
| `ClientRequestFailedError`
|
|
311
|
-
| `ClientRequestInconsistentStateError`
|
|
312
|
-
| `MessageTransactionRequiredError`
|
|
313
|
-
| `InvalidPaginationValueError`
|
|
314
|
-
| `
|
|
315
|
-
| `
|
|
316
|
-
| `
|
|
317
|
-
| `
|
|
466
|
+
| Error | Direct-service meaning | Route status |
|
|
467
|
+
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- |
|
|
468
|
+
| `MessageNotFoundError` | `findMessageOrThrow()` could not find an active or archived message | 404 |
|
|
469
|
+
| `TemplateNotFoundError` | Requested `templateCd` is not registered | 404 |
|
|
470
|
+
| `ActionNotFoundError` | Requested action is not present on the message template | 404 |
|
|
471
|
+
| `ActionNotAllowedError` | User lacks sender/receiver, permission, or condition access | 403 |
|
|
472
|
+
| `MessageArchivedError` | Mutation was requested for an archived message | 410 |
|
|
473
|
+
| `InvalidMessageUserError` | Missing/invalid user identity at the service boundary | 401 |
|
|
474
|
+
| `InvalidClientRequestIdError` | `clientRequestId` is not a valid bounded string | 400 |
|
|
475
|
+
| `ActionConflictError` | Another live action attempt owns the message | 409 |
|
|
476
|
+
| `ActionRetryableError` | Handler failed before archive commit; same action may retry with the persisted `actionAttemptId` | 409 |
|
|
477
|
+
| `ActionNotificationPendingError` | Business action committed, but sender notification still needs reconciliation | 202 |
|
|
478
|
+
| `ClientRequestPendingError` | Duplicate create is still live; retry after completion or lease expiry | 409 |
|
|
479
|
+
| `ClientRequestFailedError` | The scoped create operation has a recorded terminal failure; public message is stable (`…previously failed; retry with a new clientRequestId`) and never includes the recorded `failureMessage` | 409 with the stable message only |
|
|
480
|
+
| `ClientRequestInconsistentStateError` | Persisted reservation/message state is corrupt | propagated as a server error |
|
|
481
|
+
| `MessageTransactionRequiredError` | MongoDB deployment cannot provide required idempotent batch transactions | propagated as a server error |
|
|
482
|
+
| `InvalidPaginationValueError` | Invalid service construction or list pagination input | propagated as a server error unless handled by host code |
|
|
483
|
+
| `InvalidMessageServiceOptionError` | Invalid `clientRequestLeaseMs`/`clientRequestWaitMs`/`clientRequestPollMs` (non-finite, fractional, out-of-range, or timer-overflow) | propagated as a server error unless handled by host code |
|
|
484
|
+
| `ActionTemplateMismatchError` | Direct `handleAction()` template argument does not match the message | propagated as a server error unless handled by host code |
|
|
485
|
+
| `MessageModelResolutionError` | Configured active/archive/request/user model cannot be resolved | propagated as a server error |
|
|
486
|
+
| `PaymentSessionCompensationError` | Create failed and at least one uncommitted payment session could not be expired (single session; also the base class of the aggregate below) | propagated as a server error |
|
|
487
|
+
| `PaymentSessionCompensationAggregateError` | Multi-session create failed and at least one uncommitted payment session could not be expired; `failures` carries every per-session `{ sessionId, compensationError, hookError }` with the triggering error as `originalError` | propagated as a server error |
|
|
488
|
+
| `TemplateRegistryValidationError` | Invalid trusted template metadata at registration time | not thrown by route handlers |
|
|
318
489
|
|
|
319
490
|
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.
|
|
320
491
|
|
|
@@ -341,10 +512,12 @@ Pass it to `buildMessageSchema({ emailNotifier: provider.sendNotification.bind(p
|
|
|
341
512
|
### Payment Provider
|
|
342
513
|
|
|
343
514
|
```typescript
|
|
344
|
-
import { PaymentProvider } from '@web-ts-toolkit/message-service';
|
|
515
|
+
import { type PaymentProvider, type UserId } from '@web-ts-toolkit/message-service';
|
|
345
516
|
|
|
346
|
-
async function createCheckoutSession(user:
|
|
347
|
-
void
|
|
517
|
+
async function createCheckoutSession(user: UserId, code: string, priceArgs?: Record<string, unknown>): Promise<string> {
|
|
518
|
+
void user;
|
|
519
|
+
void code;
|
|
520
|
+
void priceArgs;
|
|
348
521
|
return 'session_123';
|
|
349
522
|
}
|
|
350
523
|
|
|
@@ -357,13 +530,13 @@ async function refundCheckoutSession(sessionId: string): Promise<void> {
|
|
|
357
530
|
}
|
|
358
531
|
|
|
359
532
|
class StripePaymentProvider implements PaymentProvider {
|
|
360
|
-
async createSession(user, code, priceArgs) {
|
|
533
|
+
async createSession(user: UserId, code: string, priceArgs?: Record<string, unknown>) {
|
|
361
534
|
return await createCheckoutSession(user, code, priceArgs);
|
|
362
535
|
}
|
|
363
|
-
async expireSession(sessionId) {
|
|
536
|
+
async expireSession(sessionId: string) {
|
|
364
537
|
await expireCheckoutSession(sessionId);
|
|
365
538
|
}
|
|
366
|
-
async refundPayment(sessionId) {
|
|
539
|
+
async refundPayment(sessionId: string) {
|
|
367
540
|
await refundCheckoutSession(sessionId);
|
|
368
541
|
}
|
|
369
542
|
}
|
|
@@ -371,7 +544,7 @@ class StripePaymentProvider implements PaymentProvider {
|
|
|
371
544
|
|
|
372
545
|
Pass it to `MessageService({ paymentProvider })` or `createMessageRoutes({ paymentProvider })`.
|
|
373
546
|
|
|
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
|
|
547
|
+
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 any prepared item, model/session setup step, or the idempotent batch transaction fails before commit, the service expires every newly created uncommitted session it tracked from preparation through the commit attempt (including earlier batch items when a later item's provider/render step fails, and sessions stranded by model resolution, `startSession()`, persistence, or rollback failures). Every known session is attempted even when an earlier expiration or the observer fails: `onPaymentCompensationFailure` is called once per failed session with the session id, scope, original error, and expiration error, then a lone session throws `PaymentSessionCompensationError` while multi-session batches throw `PaymentSessionCompensationAggregateError` (carrying every failure in `failures`); the idempotency reservation is recorded as failed according to the create state machine. Committed sessions are never expired because post-commit housekeeping failed, and non-idempotent sequential creation keeps per-item semantics (only the failing item is compensated; already-committed items are retained). Provider methods stay bound to the provider instance. Process death before a returned session is recorded, or any ambiguous provider/commit outcome, must be reconciled with the provider out of band.
|
|
375
548
|
|
|
376
549
|
## License
|
|
377
550
|
|