@web-ts-toolkit/message-service 0.51.0 → 0.53.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 +833 -11
- package/index.d.mts +270 -943
- package/index.d.ts +270 -943
- package/index.js +174 -43
- package/index.mjs +172 -43
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -13,6 +13,10 @@ Peer dependencies:
|
|
|
13
13
|
- `mongoose >= 8`
|
|
14
14
|
- `express >= 5`
|
|
15
15
|
|
|
16
|
+
Runtime: Node `>=22`. Use package-root **named imports** (or CommonJS
|
|
17
|
+
`require('@web-ts-toolkit/message-service')`); there is no default export or public
|
|
18
|
+
deep-import subpath.
|
|
19
|
+
|
|
16
20
|
Bundling note: `handlebars` and the workspace deps are bundled (`tsup` externals are only `mongoose`/`express`), so only `mongoose` + `express` are peers.
|
|
17
21
|
|
|
18
22
|
## Highlights
|
|
@@ -147,6 +151,131 @@ const { router, service } = createMessageRoutes({
|
|
|
147
151
|
});
|
|
148
152
|
```
|
|
149
153
|
|
|
154
|
+
### Independent applications in one process
|
|
155
|
+
|
|
156
|
+
Use **separate connections/databases or disjoint collections**, an explicit registry
|
|
157
|
+
per app, and matching schema/service model names. Model names alone do not isolate
|
|
158
|
+
collections: Mongoose normally derives collection names from model names. This is
|
|
159
|
+
separate-app configuration, not shared-collection tenant enforcement. Explicit
|
|
160
|
+
registries never fall back to `defaultRegistry`; configured connections never fall
|
|
161
|
+
back to global models. Direct `archive()` always requires the app's registry.
|
|
162
|
+
|
|
163
|
+
This standalone factory uses only root named imports. Call once per connection;
|
|
164
|
+
register trusted templates in each registry before serving requests. Register the
|
|
165
|
+
configured `${prefix}User` model first if using default email lookup; a custom
|
|
166
|
+
directory resolver needs no User model. Email is disabled when no provider is passed.
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
import type mongoose from 'mongoose';
|
|
170
|
+
import {
|
|
171
|
+
MessageService,
|
|
172
|
+
TemplateRegistry,
|
|
173
|
+
buildMessageSchema,
|
|
174
|
+
buildMessageArchiveSchema,
|
|
175
|
+
buildMessageRequestSchema,
|
|
176
|
+
type EmailProvider,
|
|
177
|
+
type EmailRecipientResolver,
|
|
178
|
+
type PaymentProvider,
|
|
179
|
+
} from '@web-ts-toolkit/message-service';
|
|
180
|
+
|
|
181
|
+
export async function createIndependentMessaging(
|
|
182
|
+
connection: mongoose.Connection,
|
|
183
|
+
options: {
|
|
184
|
+
prefix: string;
|
|
185
|
+
partyStorage: 'nativeObjectId' | 'opaqueString';
|
|
186
|
+
registry: TemplateRegistry;
|
|
187
|
+
paymentProvider?: PaymentProvider;
|
|
188
|
+
emailProvider?: EmailProvider;
|
|
189
|
+
emailRecipientResolver?: EmailRecipientResolver;
|
|
190
|
+
},
|
|
191
|
+
) {
|
|
192
|
+
const modelNames = {
|
|
193
|
+
active: `${options.prefix}Message`,
|
|
194
|
+
archive: `${options.prefix}Archive`,
|
|
195
|
+
request: `${options.prefix}Request`,
|
|
196
|
+
user: `${options.prefix}User`,
|
|
197
|
+
};
|
|
198
|
+
const activeSchema = buildMessageSchema({
|
|
199
|
+
connection,
|
|
200
|
+
archiveModelName: modelNames.archive,
|
|
201
|
+
userModelName: modelNames.user,
|
|
202
|
+
emailNotifier: options.emailProvider?.sendNotification.bind(options.emailProvider),
|
|
203
|
+
emailRecipientResolver: options.emailRecipientResolver,
|
|
204
|
+
});
|
|
205
|
+
const archiveSchema = buildMessageArchiveSchema();
|
|
206
|
+
if (options.partyStorage === 'opaqueString') {
|
|
207
|
+
for (const schema of [activeSchema, archiveSchema]) {
|
|
208
|
+
schema.add({
|
|
209
|
+
fromUser: { type: String, default: null, trim: true, minlength: 1 },
|
|
210
|
+
toUser: { type: String, default: null, trim: true, minlength: 1 },
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
archiveSchema.add({
|
|
214
|
+
archivedBy: { type: String, default: null, trim: true, minlength: 1, index: true },
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const Message = connection.model(modelNames.active, activeSchema);
|
|
218
|
+
const Archive = connection.model(modelNames.archive, archiveSchema);
|
|
219
|
+
const Request = connection.model(modelNames.request, buildMessageRequestSchema());
|
|
220
|
+
await Promise.all([Message.init(), Archive.init(), Request.init()]);
|
|
221
|
+
const service = new MessageService({
|
|
222
|
+
connection,
|
|
223
|
+
modelNames,
|
|
224
|
+
registry: options.registry,
|
|
225
|
+
paymentProvider: options.paymentProvider,
|
|
226
|
+
});
|
|
227
|
+
return { service, Message, Archive, Request, modelNames };
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Create app A with `partyStorage: 'nativeObjectId'`, app B with
|
|
232
|
+
`partyStorage: 'opaqueString'`, distinct `prefix`/connections/registries/providers,
|
|
233
|
+
then mount each with `createMessageRoutes({ service, getUser })`. Each host's
|
|
234
|
+
`getUser(req)` maps its own authenticated request shape to `MessageUser`; list
|
|
235
|
+
through `service.listMessages({ user })` and serialize with `serializePublicMessage`.
|
|
236
|
+
Both apps may use the same `templateCd`, textual requester ID, and `clientRequestId`
|
|
237
|
+
without sharing reservations. Pass the correct registry to direct document
|
|
238
|
+
`archive(actionCd, actorId, registry)`; it is a trusted host primitive, not authorization.
|
|
239
|
+
String storage must cover **both parties and archive `archivedBy`**, with the
|
|
240
|
+
[identity/migration policy below](#user-id-storage-and-normalization-msga-03-migration).
|
|
241
|
+
Transactions still require a replica set/sharded cluster. Idempotent creates skip
|
|
242
|
+
schema email hooks; standalone notifications can use each app's bound notifier and
|
|
243
|
+
[optional custom directory](#recipient-lookup-msga-05).
|
|
244
|
+
|
|
245
|
+
#### Bounded installed-consumer compatibility
|
|
246
|
+
|
|
247
|
+
The packed consumer tests exercise Express **5.0.0** / Mongoose **8.0.0** and the
|
|
248
|
+
exact Express/Mongoose versions installed in this workspace (currently 5.x/9.x).
|
|
249
|
+
Both lanes compile root-import ESM/CommonJS NodeNext and Bundler consumers with
|
|
250
|
+
`strict: true`, `skipLibCheck: false`, and run the two-app MongoDB lifecycle in
|
|
251
|
+
both module formats. The minimum-runtime CI lane executes the installed consumers
|
|
252
|
+
on actual Node **22.0.0**; newer Node may run build/install/TypeScript tooling.
|
|
253
|
+
This bounded check does not certify every intervening or future peer release.
|
|
254
|
+
|
|
255
|
+
MSGA-11 compatibility correction: schema constants now retain explicit portable
|
|
256
|
+
Mongoose schema types, rather than expanding Mongoose 9-only inferred generics in
|
|
257
|
+
the shipped declarations. Active/archive document `_id` is explicitly ObjectId
|
|
258
|
+
across Mongoose 8 and 9. Runtime schemas, bundling, exports and peer/runtime floors
|
|
259
|
+
are unchanged; no data migration is required.
|
|
260
|
+
|
|
261
|
+
Reproduce from the repository after installing build dependencies and Node 22.0.0:
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
mkdir -p _tmp-msga
|
|
265
|
+
git check-ignore _tmp-msga
|
|
266
|
+
export ASDF_NODEJS_VERSION=26.11.1
|
|
267
|
+
export TMPDIR="$PWD/_tmp-msga"
|
|
268
|
+
export MESSAGE_SERVICE_CONSUMER_NODE="$(asdf where nodejs 22.0.0)/bin/node"
|
|
269
|
+
export MESSAGE_SERVICE_EXPECT_NODE=22.0.0
|
|
270
|
+
pnpm --filter @web-ts-toolkit/message-service... build
|
|
271
|
+
pnpm --filter @web-ts-toolkit/message-service exec vitest run --config ../../vitest.config.ts --fileParallelism=false test/message-service.packed-consumer.test.ts -t MSGA-11
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Each lane prints exact resolved peers/types/compiler, build-tool and consumer Node,
|
|
275
|
+
MongoDB version and completed checks. `MESSAGE_SERVICE_REPORT_DIR` optionally saves
|
|
276
|
+
JSON evidence outside packed temp consumers. Without the runtime override, local
|
|
277
|
+
tests execute with their test-runner Node, whose version is reported explicitly.
|
|
278
|
+
|
|
150
279
|
## API
|
|
151
280
|
|
|
152
281
|
### `createMessageRoutes(options)`
|
|
@@ -155,16 +284,17 @@ Creates a standalone Express router with message template routes. Returns `{ rou
|
|
|
155
284
|
|
|
156
285
|
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.
|
|
157
286
|
|
|
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.
|
|
287
|
+
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` and optional `roles` that are undefined or an array of strings before continuing. Malformed roles also return `401` before service/model lookup. See [authenticated principals](#authenticated-principals-msga-02-migration) for getter/hydrated users and the action-handler snapshot.
|
|
159
288
|
|
|
160
289
|
| Option | Type | Description |
|
|
161
290
|
| ------------------------------ | ------------------------------------------ | --------------------------------------------------------------------------------- |
|
|
162
291
|
| `getModel` | `(name: string) => Model` | Mongoose model getter |
|
|
163
|
-
| `paymentProvider` | `PaymentProvider`
|
|
292
|
+
| `paymentProvider` | `PaymentProvider \| null` | Required for nonempty payment codes; optional for code-free items and replay |
|
|
164
293
|
| `onPaymentCompensationFailure` | `(event) => void \| Promise<void>` | Optional hook called when expiring an uncommitted payment session fails |
|
|
165
294
|
| `onTransactionCleanupFailure` | `MessageTransactionCleanupFailureObserver` | Best-effort diagnostics when a service-owned transaction's `endSession()` rejects |
|
|
166
|
-
| `adminRoles` | `string[]` |
|
|
295
|
+
| `adminRoles` | `string[]` | Fallback roles when `toUser` is absent and `toRoles` is omitted or empty |
|
|
167
296
|
| `registry` | `TemplateRegistry` | Custom template registry (default: `defaultRegistry`) |
|
|
297
|
+
| `actionLeaseMs` | `number` | Action lease in ms (default: `30000`) |
|
|
168
298
|
| `authMiddleware` | `((req, res, next) => void)[]` | Custom middleware applied to all routes before route validation |
|
|
169
299
|
| `getUser` | `(req) => MessageUser \| undefined` | Extract authenticated user (default: `req._user \|\| req.user`) |
|
|
170
300
|
| `getPermissions` | `(req) => Record<string, boolean>` | Extract permissions (default: `req._permissions \|\| {}`) |
|
|
@@ -288,14 +418,15 @@ MongoDB and the payment provider out of band before retrying external effects.
|
|
|
288
418
|
Core service for creating messages, getting actions, and handling actions. Constructed by `createMessageRoutes`, or directly:
|
|
289
419
|
|
|
290
420
|
```typescript
|
|
291
|
-
import { MessageService,
|
|
421
|
+
import { MessageService, TemplateRegistry } from '@web-ts-toolkit/message-service';
|
|
292
422
|
|
|
293
423
|
const registry = new TemplateRegistry();
|
|
294
424
|
const service = new MessageService({
|
|
295
425
|
getModel: mongoose.model.bind(mongoose),
|
|
296
|
-
paymentProvider
|
|
426
|
+
// Add paymentProvider for templates/items with a nonempty paymentCd.
|
|
297
427
|
adminRoles: ['superadmin'],
|
|
298
428
|
registry,
|
|
429
|
+
actionLeaseMs: 90_000, // Optional; default is 30000 ms. Expiry permits takeover.
|
|
299
430
|
});
|
|
300
431
|
```
|
|
301
432
|
|
|
@@ -308,7 +439,7 @@ Methods:
|
|
|
308
439
|
- `findMessage(id, { populate?, select? })` — find a message by id (active or archive). Trusted host-level operation: performs no principal validation.
|
|
309
440
|
- `findMessageOrThrow(id, { populate?, select? })` — same as `findMessage`, but throws `MessageNotFoundError`. Trusted host-level operation.
|
|
310
441
|
- `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.
|
|
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.
|
|
442
|
+
- `handleAction(templateCd, actionCd, { message, user, permissions?, input?, req? })` — execute an action with a durable claim, optional synchronous pure input parsing, stable handler attempt key, transactional archive, and post-commit sender notification status. Direct input needs no Express request.
|
|
312
443
|
- `buildVisibilityFilter(user)` — get the Mongoose filter for messages visible to a user.
|
|
313
444
|
|
|
314
445
|
Action-read migration: omit `getActions`' deprecated `message` option. It is
|
|
@@ -338,6 +469,260 @@ Pagination contract: `defaultListLimit` and `maxListLimit` must be finite intege
|
|
|
338
469
|
|
|
339
470
|
`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.
|
|
340
471
|
|
|
472
|
+
### Authenticated principals (MSGA-02 migration)
|
|
473
|
+
|
|
474
|
+
`MessageUser` accepts plain objects, prototype getters, and hydrated Mongoose
|
|
475
|
+
users. Its documented fields are `_id`, optional `id`, optional `roles`, and
|
|
476
|
+
optional `displayName`. Equivalent callers receive the same inbox, action, and
|
|
477
|
+
archived-outcome decisions. User-facing service calls (including list/count) and
|
|
478
|
+
routes validate principals before resolving models or running template/provider
|
|
479
|
+
effects. `createMessage` also validates `payerUser` when supplied.
|
|
480
|
+
|
|
481
|
+
- Omitted/undefined `roles` and `[]` are valid and grant no role membership.
|
|
482
|
+
- Provided roles must be an array containing only strings. `null`, strings,
|
|
483
|
+
array-like objects, mixed/non-string elements, and sparse arrays are invalid.
|
|
484
|
+
The service throws `InvalidMessageUserError`; HTTP routes return `401`.
|
|
485
|
+
Invalid roles reject the principal even for direct sender/recipient matches or
|
|
486
|
+
admin read-only views. Document `isSender`/`isReceiver` helpers return `false`
|
|
487
|
+
for malformed roles.
|
|
488
|
+
- Role membership uses exact, case-sensitive string equality. No coercion,
|
|
489
|
+
trimming, partial-array acceptance, or substring matching is applied.
|
|
490
|
+
|
|
491
|
+
Action authorization and `ActionContext.user` explicitly read the four documented
|
|
492
|
+
fields, including getters, and copy the roles array. Action `_id` retains its
|
|
493
|
+
existing normalized string form. No arbitrary user properties, Mongoose internals,
|
|
494
|
+
or private fields are copied, and user-document serializers are not invoked.
|
|
495
|
+
Caller objects are not mutated. Preparation still receives the extracted user
|
|
496
|
+
itself, and callbacks receive the original `req`; custom extractors continue to
|
|
497
|
+
receive that same Express request. Stored targets remain authoritative for active
|
|
498
|
+
claims and archived outcomes.
|
|
499
|
+
|
|
500
|
+
Migration: authentication adapters that supplied string/null/mixed roles must
|
|
501
|
+
return a string array, or omit roles/use `undefined` when absent. Handlers that
|
|
502
|
+
relied on extra spread user properties should obtain explicitly selected host
|
|
503
|
+
data from their original typed request or a template-factory closure. This change
|
|
504
|
+
requires no stored-message migration. Trusted host-level find, notification, and
|
|
505
|
+
direct-archive APIs retain their existing authorization boundaries.
|
|
506
|
+
|
|
507
|
+
### User-ID storage and normalization (MSGA-03 migration)
|
|
508
|
+
|
|
509
|
+
The default schemas store `fromUser`, `toUser`, and archive `archivedBy` as native
|
|
510
|
+
MongoDB ObjectIds. They work without extra configuration for ObjectId users.
|
|
511
|
+
`UserId` also accepts strings, but arbitrary external/UUID identities need the
|
|
512
|
+
following **complete String-schema recipe**, using package-root named imports:
|
|
513
|
+
|
|
514
|
+
```typescript
|
|
515
|
+
import type mongoose from 'mongoose';
|
|
516
|
+
import {
|
|
517
|
+
MessageService,
|
|
518
|
+
TemplateRegistry,
|
|
519
|
+
MESSAGE_MODEL_NAME,
|
|
520
|
+
MESSAGE_ARCHIVE_MODEL_NAME,
|
|
521
|
+
MESSAGE_REQUEST_MODEL_NAME,
|
|
522
|
+
buildMessageSchema,
|
|
523
|
+
buildMessageArchiveSchema,
|
|
524
|
+
buildMessageRequestSchema,
|
|
525
|
+
} from '@web-ts-toolkit/message-service';
|
|
526
|
+
|
|
527
|
+
// Call once per connection, before compiling these models. Register the host's
|
|
528
|
+
// trusted templates in registry; use a replica set/sharded cluster for transactions.
|
|
529
|
+
export async function createStringIdMessaging(connection: mongoose.Connection, registry: TemplateRegistry) {
|
|
530
|
+
const activeSchema = buildMessageSchema({ connection }); // email disabled by default
|
|
531
|
+
const archiveSchema = buildMessageArchiveSchema();
|
|
532
|
+
for (const schema of [activeSchema, archiveSchema]) {
|
|
533
|
+
schema.add({
|
|
534
|
+
fromUser: { type: String, default: null, trim: true, minlength: 1 },
|
|
535
|
+
toUser: { type: String, default: null, trim: true, minlength: 1 },
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
archiveSchema.add({
|
|
539
|
+
archivedBy: { type: String, default: null, trim: true, minlength: 1, index: true },
|
|
540
|
+
});
|
|
541
|
+
const Message = connection.model(MESSAGE_MODEL_NAME, activeSchema);
|
|
542
|
+
const Archive = connection.model(MESSAGE_ARCHIVE_MODEL_NAME, archiveSchema);
|
|
543
|
+
const Request = connection.model(MESSAGE_REQUEST_MODEL_NAME, buildMessageRequestSchema());
|
|
544
|
+
await Promise.all([Message.init(), Archive.init(), Request.init()]);
|
|
545
|
+
return new MessageService({ connection, registry });
|
|
546
|
+
}
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
Use principals such as `{ _id: 'oidc|Acme:Alice' }` or a UUID string with this
|
|
550
|
+
service. Template preparation can return those strings in `fromUser`/`toUser`.
|
|
551
|
+
The factory schemas retain methods, hooks, timestamps, and lifecycle/replay
|
|
552
|
+
indexes; overriding only active parties is insufficient because both service
|
|
553
|
+
actions and direct `message.archive()` write archive parties **and `archivedBy`**.
|
|
554
|
+
`MessageRequest` needs no overrides: its owner is already a String scope key.
|
|
555
|
+
Custom model names must also agree between service `modelNames` and active-schema
|
|
556
|
+
`archiveModelName`. If using population or the default email lookup, register a
|
|
557
|
+
User model with a matching String `_id` on this connection and configure the
|
|
558
|
+
appropriate model/ref; no User model is needed without populate when email is
|
|
559
|
+
disabled or a [custom recipient resolver](#recipient-lookup-msga-05) owns lookup.
|
|
560
|
+
Message `_id`, attachment IDs, and the HTTP `:id` contract remain ObjectId.
|
|
561
|
+
|
|
562
|
+
#### Identity policy
|
|
563
|
+
|
|
564
|
+
- User-facing principal strings are trimmed, never lowercased or Unicode-normalized.
|
|
565
|
+
Surrounding whitespace is not identity-significant. Preparation still receives
|
|
566
|
+
the original principal, without mutation. New template messages trim both the
|
|
567
|
+
prepared sender/recipient and the default sender before persistence, including
|
|
568
|
+
direct and idempotent creation; a validated padded sender cannot disappear from
|
|
569
|
+
its own normalized inbox. Service archival stores the normalized actor; direct
|
|
570
|
+
archival trims its validated `archivedBy`. Native ObjectId inputs remain native
|
|
571
|
+
until the configured schema casts them.
|
|
572
|
+
- **Stored native ObjectId:** relationship helpers (including populated `_id`s)
|
|
573
|
+
accept that ObjectId or its 24-hex string in either case, with surrounding padding
|
|
574
|
+
trimmed. This agrees with default Mongoose inbox query casting. Public IDs use
|
|
575
|
+
lowercase hex. The string form in action-handler `user._id` retains the caller's
|
|
576
|
+
trimmed case; it is not a universal canonical business key.
|
|
577
|
+
- **Stored String:** equality is exact and case-sensitive after trimming, even
|
|
578
|
+
for 24-hex strings. `Alice` and `alice`, or uppercase/lowercase hex-looking
|
|
579
|
+
strings, are distinct identities. An ObjectId principal becomes lowercase hex,
|
|
580
|
+
so in a String schema it matches only that exact lowercase identity. Supply
|
|
581
|
+
strings consistently for opaque identities. Use MongoDB's default simple/binary
|
|
582
|
+
collation; case-insensitive collations or custom lowercase/casting setters do not
|
|
583
|
+
satisfy this contract. Each configured collection must use a consistent party
|
|
584
|
+
BSON type; heterogeneous legacy ObjectId/String storage is not supported by this
|
|
585
|
+
recipe without migration.
|
|
586
|
+
- **Request scopes are textual and unchanged:** string requester IDs retain their
|
|
587
|
+
trimmed case, ObjectId requester IDs use lowercase hex. Thus an ObjectId and its
|
|
588
|
+
lowercase hex share a scope; uppercase hex is a **different scope**, even with
|
|
589
|
+
default ObjectId party storage. Relationship equivalence does not merge requests.
|
|
590
|
+
Replays use the exact stored `{ clientRequestOwnerId, templateCd, clientRequestId }`
|
|
591
|
+
across reservations, active messages, and archives. Do not switch representation
|
|
592
|
+
during retries. Completed replay preserves existing stored values and never
|
|
593
|
+
reruns preparation to normalize old messages.
|
|
594
|
+
|
|
595
|
+
The trusted-host `createNotification` boundary still takes raw IDs without
|
|
596
|
+
principal validation or service normalization; the String recipe's schema setters
|
|
597
|
+
trim these and other Mongoose host writes. Direct `archive()` still performs no
|
|
598
|
+
relationship/permission authorization; the host must authorize it. Neither recipe
|
|
599
|
+
adds arbitrary top-level preparation-field persistence. The DTO serializer emits
|
|
600
|
+
stored string case unchanged, ObjectIds as hex, and archive `archivedBy` as an ID;
|
|
601
|
+
it performs no identity repair or authorization.
|
|
602
|
+
|
|
603
|
+
#### Existing records and scope migration
|
|
604
|
+
|
|
605
|
+
This release changes new template party writes and direct archive ownership to
|
|
606
|
+
trim strings, and makes uppercase hex principals agree with stored native ObjectId
|
|
607
|
+
relationships. It does **not** rewrite any record or request scope. No storage
|
|
608
|
+
migration is needed for consistently typed, unpadded records. Do not use IDs whose
|
|
609
|
+
leading/trailing whitespace is significant; they were already incompatible with
|
|
610
|
+
principal validation and inbox query normalization.
|
|
611
|
+
|
|
612
|
+
For legacy padded string parties/ownership, inspect raw BSON in **both** active and
|
|
613
|
+
archive collections (hydration/setters may hide padding), map them to the host's
|
|
614
|
+
authoritative identities, check collisions, then rewrite approved `fromUser`,
|
|
615
|
+
`toUser`, and `archivedBy` values with writers/actions drained. A String-schema
|
|
616
|
+
change alone does not repair persisted padding or change BSON types. Moving an
|
|
617
|
+
ObjectId deployment to external string identities additionally needs a host-owned
|
|
618
|
+
mapping for these three fields, populated User IDs, and relevant host audit/claim
|
|
619
|
+
identity references; blindly stringifying references is not an external-ID mapping.
|
|
620
|
+
Changing principal values also changes requester scopes: apply the coordinated
|
|
621
|
+
scope migration below or retain an exact-scope legacy retry adapter.
|
|
622
|
+
|
|
623
|
+
Prefer keeping the current request-scope convention. If a host changes requester
|
|
624
|
+
values or chooses to canonicalize ObjectId hex strings before service calls, apply
|
|
625
|
+
it consistently at all authentication/service adapters and treat the rollout as a scope migration:
|
|
626
|
+
drain creation/action workers and retries, inventory upper/lowercase (and any
|
|
627
|
+
historically padded/custom) owners for each template/key, reconcile conflicting
|
|
628
|
+
reservations and their **entire** message batches using application knowledge, and
|
|
629
|
+
update `clientRequestOwnerId` consistently in MessageRequest, active, and archive
|
|
630
|
+
records while preserving item indexes/counts and compound unique indexes. Never
|
|
631
|
+
blindly lowercase all owners: opaque IDs may differ only by case, and two completed
|
|
632
|
+
ObjectId-representation scopes may already contain separate business effects.
|
|
633
|
+
If histories cannot be reconciled, retain their old exact-scope retry adapter until
|
|
634
|
+
the host's retention/retry window ends. There is no automatic alias lookup, scope
|
|
635
|
+
merge, or general migration tool in this package.
|
|
636
|
+
|
|
637
|
+
### Recipient defaults (MSGA-01 migration)
|
|
638
|
+
|
|
639
|
+
For template-based `createMessage`, `adminRoles` (default `[]`) supplies recipients
|
|
640
|
+
only when each prepared item has neither a direct user nor nonempty role recipients:
|
|
641
|
+
|
|
642
|
+
| Prepared `toUser` | Prepared `toRoles` | Stored `toRoles` |
|
|
643
|
+
| --------------------------- | ------------------ | -------------------------- |
|
|
644
|
+
| Absent (`undefined`/`null`) | Omitted or `[]` | Copy of `adminRoles` |
|
|
645
|
+
| Present | Omitted or `[]` | `[]` |
|
|
646
|
+
| Absent or present | Nonempty array | Copy of the explicit array |
|
|
647
|
+
|
|
648
|
+
An explicit empty role array **does not suppress fallback** for an otherwise
|
|
649
|
+
unaddressed item. To create an unaddressed item without fallback roles, configure
|
|
650
|
+
`adminRoles: []` (or omit the option). Explicit `toUser` + `toRoles` combinations
|
|
651
|
+
remain independent recipient grants: the direct user and matching role members
|
|
652
|
+
can see the message and qualify for receiver actions, subject to each action's
|
|
653
|
+
permission and condition checks. Include admin roles explicitly in `toRoles` if
|
|
654
|
+
a directly addressed message should also reach them. Generic `createNotification`
|
|
655
|
+
stores its supplied recipients without applying this template fallback.
|
|
656
|
+
|
|
657
|
+
Migration: new directly addressed template messages no longer implicitly add
|
|
658
|
+
`adminRoles`. Existing active/archive records and completed idempotent replays
|
|
659
|
+
retain their stored recipients. Review legacy `toRoles` in both collections and
|
|
660
|
+
correct unintended grants according to application policy; stored roles govern
|
|
661
|
+
inbox visibility, action authorization, and archived-outcome access. Legacy
|
|
662
|
+
implicit fallback cannot be distinguished automatically from an intentional
|
|
663
|
+
direct-user-plus-role combination.
|
|
664
|
+
|
|
665
|
+
### Prepared attachments (MSGA-06)
|
|
666
|
+
|
|
667
|
+
The root-exported `PrepareResult.documents?: mongoose.Types.ObjectId[]` selects
|
|
668
|
+
attachments for each fresh `createMessage` item in both direct and idempotent
|
|
669
|
+
creation:
|
|
670
|
+
|
|
671
|
+
| Prepared `documents` | Root message attachments |
|
|
672
|
+
| ---------------------- | ----------------------------------------------------------------- |
|
|
673
|
+
| Nonempty array | The prepared selection |
|
|
674
|
+
| `[]` | No attachments, even when the original payload contains documents |
|
|
675
|
+
| Omitted or `undefined` | Original `PrepareContext.payload.documents`, or `[]` when absent |
|
|
676
|
+
|
|
677
|
+
The fallback always reads the **original caller payload**, even when preparation
|
|
678
|
+
returns a replacement `payload`. A prepared business `payload.documents` does not
|
|
679
|
+
select root attachments. Each selected array is shallow-copied per item, including
|
|
680
|
+
fallback arrays and arrays reused by multiple fan-out items; the service does not
|
|
681
|
+
mutate caller/preparation arrays. Attachment IDs retain the existing ObjectId type.
|
|
682
|
+
|
|
683
|
+
Inject the host's attachment validation/authorization into a template factory. This
|
|
684
|
+
recipe sends approved attachments to reviewers and deliberately clears them for
|
|
685
|
+
an audit copy, without editing the caller's payload:
|
|
686
|
+
|
|
687
|
+
```typescript
|
|
688
|
+
import type mongoose from 'mongoose';
|
|
689
|
+
import type { MessageTemplate, PrepareContext, PrepareResult } from '@web-ts-toolkit/message-service';
|
|
690
|
+
|
|
691
|
+
export function createAttachmentReviewTemplate(
|
|
692
|
+
selectApprovedDocuments: (ctx: PrepareContext) => Promise<mongoose.Types.ObjectId[]>,
|
|
693
|
+
): MessageTemplate {
|
|
694
|
+
return {
|
|
695
|
+
templateCd: 'attachment.review',
|
|
696
|
+
type: 'request',
|
|
697
|
+
description: 'Review approved attachments and create an attachment-free audit copy',
|
|
698
|
+
senderContent: { title: 'Sent {{name}}', long: 'Review requested', short: 'Sent' },
|
|
699
|
+
receiverContent: { title: 'Review {{name}}', long: 'Review the request', short: 'Review' },
|
|
700
|
+
uiTemplate: 'default-message',
|
|
701
|
+
prepareMessage: async (ctx): Promise<PrepareResult[]> => {
|
|
702
|
+
const documents = await selectApprovedDocuments(ctx);
|
|
703
|
+
const name = String(ctx.payload.name ?? '');
|
|
704
|
+
return [
|
|
705
|
+
{ toRoles: ['reviewer'], documents, templateData: { name }, payload: { name } },
|
|
706
|
+
{ toRoles: ['auditor'], documents: [], templateData: { name }, payload: { name } },
|
|
707
|
+
];
|
|
708
|
+
},
|
|
709
|
+
actions: [],
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
Content still renders only from `templateData`; business `payload` is stored
|
|
715
|
+
separately and supplies later action labels/confirmations. Document construction
|
|
716
|
+
remains an explicit field allowlist: extra preparation properties cannot override
|
|
717
|
+
message identity, lifecycle, or request ownership.
|
|
718
|
+
|
|
719
|
+
**Adoption/migration:** templates omitting `documents` keep the legacy fallback.
|
|
720
|
+
If preparation must filter/replace attachments, return the approved IDs explicitly;
|
|
721
|
+
if it must remove them, return `documents: []`. Replacing or clearing only the
|
|
722
|
+
business payload does not clear root attachments. Existing active/archive records
|
|
723
|
+
and completed replays keep their stored attachment sets without rerunning
|
|
724
|
+
preparation. No schema/index or automatic stored-data migration is needed.
|
|
725
|
+
|
|
341
726
|
### `TemplateRegistry`
|
|
342
727
|
|
|
343
728
|
In-memory registry for trusted message templates.
|
|
@@ -358,7 +743,7 @@ registry.clear();
|
|
|
358
743
|
|
|
359
744
|
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.
|
|
360
745
|
|
|
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.
|
|
746
|
+
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`, `MessageAction`, `ActionContext`, `PrepareContext`, `PrepareResult`, `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.
|
|
362
747
|
|
|
363
748
|
`includesAction(templateCd, actionCd, registry)` requires the registry explicitly so archive behavior never falls back to unrelated global state.
|
|
364
749
|
|
|
@@ -383,7 +768,7 @@ Rendering/action separation: message content (`senderContent`/`receiverContent`)
|
|
|
383
768
|
- 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.
|
|
384
769
|
- 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
770
|
- 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
|
-
-
|
|
771
|
+
- Configure `actionLeaseMs` for the workload (default `30000` ms). Expiry permits a same-action takeover; it neither cancels the handler nor automatically rejects the still-current owner's completion or failure. Without takeover, that owner can still archive successfully or report a normal retryable failure after expiry. There is no heartbeat or 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
772
|
- 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`.
|
|
388
773
|
- 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
774
|
- 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.
|
|
@@ -391,6 +776,284 @@ Rendering/action separation: message content (`senderContent`/`receiverContent`)
|
|
|
391
776
|
- 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.
|
|
392
777
|
- 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'`.
|
|
393
778
|
|
|
779
|
+
### Action lease configuration (MSGA-08)
|
|
780
|
+
|
|
781
|
+
`MessageServiceOptions.actionLeaseMs` is an optional finite positive safe integer
|
|
782
|
+
in `[1, MAX_MESSAGE_SERVICE_TIMEOUT_MS]` milliseconds (`2147483647`, the shared
|
|
783
|
+
platform timeout ceiling). The default remains **30000 ms**, used only when omitted
|
|
784
|
+
or explicitly `undefined`. Zero, negative, fractional, `NaN`, infinite, `null`, other
|
|
785
|
+
non-number and above-ceiling values throw
|
|
786
|
+
`InvalidMessageServiceOptionError` at construction, before any model lookup.
|
|
787
|
+
`createMessageRoutes({ getModel, actionLeaseMs, ... })` forwards the same option.
|
|
788
|
+
With `createMessageRoutes({ service })`, configure the supplied service itself;
|
|
789
|
+
combining `service` and `actionLeaseMs` is rejected by strict TypeScript and at runtime.
|
|
790
|
+
|
|
791
|
+
Each fresh claim, retryable same-action retry, or expired same-action takeover
|
|
792
|
+
persists `actionLeaseExpiresAt = actionClaimedAt + actionLeaseMs` using wall-clock
|
|
793
|
+
time. Takeover is eligible at the exact expiry boundary (`<= now`); a different
|
|
794
|
+
action cannot replace an outstanding attempt, even after expiry or handler failure.
|
|
795
|
+
The attempt ID remains stable while each acquisition rotates the private owner
|
|
796
|
+
token. Expiry alone causes no state transition, cancellation, automatic rejection,
|
|
797
|
+
or renewal. A takeover fences the old owner's later completion/failure writes with
|
|
798
|
+
`ActionConflictError`, but running handlers and external calls may overlap.
|
|
799
|
+
|
|
800
|
+
**Migration/configuration:** omitting the option or passing `undefined` preserves
|
|
801
|
+
the existing 30-second policy. Explicit `null` previously defaulted silently and
|
|
802
|
+
now fails at construction; omit the option or use `undefined` to request the default.
|
|
803
|
+
No schema/index/backfill is required. The setting affects each new
|
|
804
|
+
acquisition only, including retries/takeovers; it does not rewrite stored deadlines.
|
|
805
|
+
Configure coordinating service instances consistently and keep their clocks aligned.
|
|
806
|
+
Choose a lease with headroom for the handler and archival transaction to reduce
|
|
807
|
+
overlap; a longer lease also delays recovery after process death. There is no
|
|
808
|
+
heartbeat/renewal mechanism or guarantee that a handler finishes before expiry.
|
|
809
|
+
Continue deduplicating effects by `ctx.actionAttemptId` regardless of duration.
|
|
810
|
+
|
|
811
|
+
### Action input and host-owned commands (MSGA-15)
|
|
812
|
+
|
|
813
|
+
`handleAction(..., { input })` delivers explicit submitted data as `ActionContext.input`.
|
|
814
|
+
The HTTP action route supplies **`input: req.body`**, with no new envelope, while
|
|
815
|
+
preserving the exact original `req` for handlers, notification callbacks and
|
|
816
|
+
extractors. Direct calls never infer input from `req.body`. Without `parseInput`,
|
|
817
|
+
input passes through unchanged, including undefined, null, arrays and scalars;
|
|
818
|
+
HTTP receives whatever the host/body-parser middleware supplies (an absent wire
|
|
819
|
+
body may become `{}`). No package-level command schema or deep freeze is implied.
|
|
820
|
+
|
|
821
|
+
An action can opt into `parseInput: (raw: unknown) => unknown`. It runs once per
|
|
822
|
+
acquisition **after atomic claim and authoritative authorization/current action
|
|
823
|
+
selection**, before the handler. Return a fresh normalized, allowlisted command;
|
|
824
|
+
treat raw input as read-only. Parsing must be synchronous, deterministic and
|
|
825
|
+
effect-free. **Async parsers are not supported**: Promise/thenable outputs fail
|
|
826
|
+
as server configuration errors, their rejections are consumed, and no handler
|
|
827
|
+
runs. Purity is a trusted-template contract, not a sandbox. Parsers never run for
|
|
828
|
+
unauthorized callers, creation, any action listing (including admin/archive),
|
|
829
|
+
or terminal/pending-notification archived outcomes.
|
|
830
|
+
|
|
831
|
+
- Throw root-exported `ActionInputValidationError` for invalid submitted input.
|
|
832
|
+
HTTP returns 400 with fixed `invalid action input`, without diagnostic text,
|
|
833
|
+
causes or attempt IDs. Unexpected parser failures return sanitized 500; direct
|
|
834
|
+
callers retain the original diagnostic as `error.cause`.
|
|
835
|
+
- Either parser failure uses owner-fenced pre-handler release. Fresh claims
|
|
836
|
+
return to `active` and clear all reservation fields, allowing corrected input
|
|
837
|
+
or a different allowed action. Retry/takeover claims become `retryable` with
|
|
838
|
+
their stable attempt ID/action retained: an earlier owner may have had effects.
|
|
839
|
+
A lost-owner release leaves the replacement untouched and reports this caller's
|
|
840
|
+
error; other release failures remain operational failures.
|
|
841
|
+
- Errors **inside `runHandler`**, including `ActionInputValidationError` or host
|
|
842
|
+
command conflicts, keep the ordinary `ActionRetryableError` contract (HTTP 409).
|
|
843
|
+
Successful actions still archive terminally (later HTTP 410 or notification-
|
|
844
|
+
pending 202); there is no new package result-replay API.
|
|
845
|
+
|
|
846
|
+
`action.payload` and confirmation fields (`requireNotes`, `documents`, etc.) are
|
|
847
|
+
presentation hints. `message.payload` contains persisted business targets and
|
|
848
|
+
condition data. Neither is submitted input or an enforced validation schema.
|
|
849
|
+
Hosts own body-parser size limits, direct-call bounds, runtime/schema validation,
|
|
850
|
+
attachment access and resource-specific permissions; recheck domain grants on
|
|
851
|
+
retries **before returning cached business results**.
|
|
852
|
+
|
|
853
|
+
The concrete example below captures a business service in a template closure.
|
|
854
|
+
It requires a fresh Mongoose connection to a transaction-capable MongoDB replica
|
|
855
|
+
set/sharded cluster, with the package's Node/peer baseline. A unique `_id` and
|
|
856
|
+
insert-only upsert reserve an immutable versioned command under `actionAttemptId`.
|
|
857
|
+
The host transaction couples only its local business mutation and durable result;
|
|
858
|
+
package archival is a separate transaction. This sample also binds the actor.
|
|
859
|
+
|
|
860
|
+
```typescript
|
|
861
|
+
import mongoose from 'mongoose';
|
|
862
|
+
import {
|
|
863
|
+
ActionInputValidationError,
|
|
864
|
+
MessageService,
|
|
865
|
+
TemplateRegistry,
|
|
866
|
+
buildMessageSchema,
|
|
867
|
+
buildMessageArchiveSchema,
|
|
868
|
+
buildMessageRequestSchema,
|
|
869
|
+
MESSAGE_MODEL_NAME,
|
|
870
|
+
MESSAGE_ARCHIVE_MODEL_NAME,
|
|
871
|
+
MESSAGE_REQUEST_MODEL_NAME,
|
|
872
|
+
type MessageTemplate,
|
|
873
|
+
} from '@web-ts-toolkit/message-service';
|
|
874
|
+
|
|
875
|
+
type RejectInput = { notes: string };
|
|
876
|
+
type RejectCommand = RejectInput & {
|
|
877
|
+
attemptId: string;
|
|
878
|
+
messageId: string;
|
|
879
|
+
approvalId: string;
|
|
880
|
+
actorId: string;
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
export class HostCommandConflictError extends Error {}
|
|
884
|
+
|
|
885
|
+
export function parseRejectInput(raw: unknown): RejectInput {
|
|
886
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new ActionInputValidationError();
|
|
887
|
+
const notes = (raw as { notes?: unknown }).notes;
|
|
888
|
+
if (
|
|
889
|
+
Object.keys(raw).some((key) => key !== 'notes') ||
|
|
890
|
+
typeof notes !== 'string' ||
|
|
891
|
+
notes.length > 2000 ||
|
|
892
|
+
!notes.trim()
|
|
893
|
+
) {
|
|
894
|
+
throw new ActionInputValidationError();
|
|
895
|
+
}
|
|
896
|
+
return { notes: notes.trim() };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
export function createRejectionWorkflow(connection: mongoose.Connection) {
|
|
900
|
+
const Approval = connection.model(
|
|
901
|
+
'ReviewApproval',
|
|
902
|
+
new mongoose.Schema({
|
|
903
|
+
reviewer: { type: String, required: true },
|
|
904
|
+
state: { type: String, default: 'pending' },
|
|
905
|
+
notes: { type: String, default: '' },
|
|
906
|
+
effectCount: { type: Number, default: 0 },
|
|
907
|
+
}),
|
|
908
|
+
);
|
|
909
|
+
const Command = connection.model(
|
|
910
|
+
'ReviewRejectCommand',
|
|
911
|
+
new mongoose.Schema({
|
|
912
|
+
_id: { type: String, required: true }, // MongoDB's unique _id index
|
|
913
|
+
canonical: { type: String, required: true },
|
|
914
|
+
notes: { type: String, required: true },
|
|
915
|
+
result: { type: mongoose.Schema.Types.Mixed, default: null },
|
|
916
|
+
}),
|
|
917
|
+
);
|
|
918
|
+
|
|
919
|
+
async function reject(c: RejectCommand) {
|
|
920
|
+
if (!(await Approval.exists({ _id: c.approvalId, reviewer: c.actorId })))
|
|
921
|
+
throw new Error('domain permission denied');
|
|
922
|
+
// Versioned, allowlisted tuple binds message, action/template, target and actor.
|
|
923
|
+
const canonical = JSON.stringify([
|
|
924
|
+
1,
|
|
925
|
+
'approval-review-v1',
|
|
926
|
+
'reject',
|
|
927
|
+
c.messageId,
|
|
928
|
+
c.approvalId,
|
|
929
|
+
c.actorId,
|
|
930
|
+
c.notes,
|
|
931
|
+
]);
|
|
932
|
+
try {
|
|
933
|
+
await Command.updateOne({ _id: c.attemptId }, { $setOnInsert: { canonical, notes: c.notes } }, { upsert: true });
|
|
934
|
+
} catch (error) {
|
|
935
|
+
if (!(error instanceof Error && 'code' in error && error.code === 11000)) throw error;
|
|
936
|
+
// A concurrent insert won; compare its durable original in the transaction.
|
|
937
|
+
}
|
|
938
|
+
return connection.transaction(async (session) => {
|
|
939
|
+
// Reauthorize even cached-result reads. Only local MongoDB writes here.
|
|
940
|
+
const approval = await Approval.findOne({ _id: c.approvalId, reviewer: c.actorId }).session(session).lean();
|
|
941
|
+
if (!approval) throw new Error('domain permission denied');
|
|
942
|
+
const stored = await Command.findById(c.attemptId).session(session).lean();
|
|
943
|
+
if (!stored) throw new Error('command missing');
|
|
944
|
+
if (stored.canonical !== canonical) throw new HostCommandConflictError();
|
|
945
|
+
if (stored.result) return stored.result as { rejected: true; notes: string };
|
|
946
|
+
if (approval.state !== 'pending') throw new Error('approval already decided');
|
|
947
|
+
const changed = await Approval.updateOne(
|
|
948
|
+
{ _id: c.approvalId, reviewer: c.actorId, state: 'pending' },
|
|
949
|
+
{ $set: { state: 'rejected', notes: stored.notes }, $inc: { effectCount: 1 } },
|
|
950
|
+
{ session },
|
|
951
|
+
);
|
|
952
|
+
if (changed.matchedCount !== 1) throw new Error('approval changed');
|
|
953
|
+
const result = { rejected: true as const, notes: stored.notes };
|
|
954
|
+
const completed = await Command.updateOne(
|
|
955
|
+
{ _id: c.attemptId, canonical, result: null },
|
|
956
|
+
{ $set: { result } },
|
|
957
|
+
{ session },
|
|
958
|
+
);
|
|
959
|
+
if (completed.matchedCount !== 1) throw new Error('command changed');
|
|
960
|
+
return result;
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function template(approvalId: string, reviewer: mongoose.Types.ObjectId): MessageTemplate {
|
|
965
|
+
return {
|
|
966
|
+
templateCd: 'approval-review-v1',
|
|
967
|
+
type: 'request',
|
|
968
|
+
description: 'Review a host-authorized target',
|
|
969
|
+
senderContent: { title: 'Sent', long: 'Review requested', short: 'Sent' },
|
|
970
|
+
receiverContent: { title: 'Review', long: 'Please review', short: 'Review' },
|
|
971
|
+
uiTemplate: 'card',
|
|
972
|
+
prepareMessage: async ({ user }) => ({ fromUser: user._id, toUser: reviewer, payload: { approvalId } }),
|
|
973
|
+
actions: [
|
|
974
|
+
{
|
|
975
|
+
actionCd: 'reject',
|
|
976
|
+
name: 'Reject',
|
|
977
|
+
variant: 'danger',
|
|
978
|
+
sender: false,
|
|
979
|
+
receiver: true,
|
|
980
|
+
confirmation: { title: 'Reject', message: 'Reject request?', requireNotes: true },
|
|
981
|
+
parseInput: parseRejectInput,
|
|
982
|
+
async runHandler({ message, user, actionAttemptId, input }) {
|
|
983
|
+
if (!actionAttemptId) throw new Error('attempt missing');
|
|
984
|
+
return reject({
|
|
985
|
+
// Business service captured by this template closure.
|
|
986
|
+
attemptId: actionAttemptId,
|
|
987
|
+
messageId: String(message._id),
|
|
988
|
+
approvalId: String(message.payload.approvalId), // authoritative claimed target
|
|
989
|
+
actorId: String(user._id),
|
|
990
|
+
notes: (input as RejectInput).notes, // this action's parser output
|
|
991
|
+
});
|
|
992
|
+
},
|
|
993
|
+
},
|
|
994
|
+
],
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
return { Approval, Command, reject, template };
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Executable local example on a fresh connection; a real host supplies its
|
|
1001
|
+
// authorized existing approval/reviewer instead of creating this fixture target.
|
|
1002
|
+
export async function rejectApprovalExample(connection: mongoose.Connection) {
|
|
1003
|
+
const Message = connection.model(MESSAGE_MODEL_NAME, buildMessageSchema());
|
|
1004
|
+
const Archive = connection.model(MESSAGE_ARCHIVE_MODEL_NAME, buildMessageArchiveSchema());
|
|
1005
|
+
const Request = connection.model(MESSAGE_REQUEST_MODEL_NAME, buildMessageRequestSchema());
|
|
1006
|
+
const business = createRejectionWorkflow(connection);
|
|
1007
|
+
await Promise.all([
|
|
1008
|
+
Message.init(),
|
|
1009
|
+
Archive.init(),
|
|
1010
|
+
Request.init(),
|
|
1011
|
+
business.Approval.init(),
|
|
1012
|
+
business.Command.init(),
|
|
1013
|
+
]);
|
|
1014
|
+
const reviewer = new mongoose.Types.ObjectId();
|
|
1015
|
+
const approval = await business.Approval.create({ reviewer: String(reviewer) });
|
|
1016
|
+
const registry = new TemplateRegistry();
|
|
1017
|
+
registry.register(business.template(String(approval._id), reviewer));
|
|
1018
|
+
const service = new MessageService({ connection, registry });
|
|
1019
|
+
const [message] = await service.createMessage({
|
|
1020
|
+
templateCd: 'approval-review-v1',
|
|
1021
|
+
user: { _id: new mongoose.Types.ObjectId() },
|
|
1022
|
+
});
|
|
1023
|
+
return service.handleAction(message.templateCd, 'reject', {
|
|
1024
|
+
message,
|
|
1025
|
+
user: { _id: reviewer },
|
|
1026
|
+
input: { notes: ' Original reason ' }, // no req / Express dependency
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
```
|
|
1030
|
+
|
|
1031
|
+
**First durable host reservation wins**, not the first package claim/request. If
|
|
1032
|
+
an owner dies before reserving, a later authorized owner may reserve a different
|
|
1033
|
+
valid command under that attempt. Once reserved, equivalent normalized input
|
|
1034
|
+
replays the original result, including after archive failure or process restart;
|
|
1035
|
+
changed input produces a controlled handler-entered 409 without replacing command
|
|
1036
|
+
or result or repeating the mutation. Missing required input is invalid, never an
|
|
1037
|
+
implicit cached-result lookup. Keep records/results for the entire retry window,
|
|
1038
|
+
with supported versioned decoders/handlers; avoid hashing arbitrary raw request JSON.
|
|
1039
|
+
|
|
1040
|
+
**MSGA-15 migration:** existing `req` handlers continue to work. Move command reads
|
|
1041
|
+
to validated `ctx.input`; reading changed `req.body` alongside it defeats stability.
|
|
1042
|
+
The example is for a new template population. Inventory/drain existing processing/
|
|
1043
|
+
retryable attempts or retain an explicit legacy path; recover known originals only
|
|
1044
|
+
from trusted host records after effect reconciliation. Unknown legacy originals
|
|
1045
|
+
need host review, not first-seen-body backfill, cleared claims or new attempt IDs.
|
|
1046
|
+
There is no new package snapshot, schema/index migration, or legacy classification
|
|
1047
|
+
flag. Actor changes and command schema changes require an explicit host policy.
|
|
1048
|
+
|
|
1049
|
+
For external effects, send the stable attempt key **and immutable original command**
|
|
1050
|
+
to a downstream system that durably deduplicates concurrent owners and ambiguous
|
|
1051
|
+
retries. Recording a result only after a remote call leaves a crash gap. External
|
|
1052
|
+
calls must stay outside automatically retried MongoDB transaction callbacks; an
|
|
1053
|
+
outbox still needs downstream deduplication or explicit reconciliation. Neither
|
|
1054
|
+
the package nor this local example guarantees exactly-once remote execution,
|
|
1055
|
+
cancels stale workers, or jointly commits business writes with the package archive.
|
|
1056
|
+
|
|
394
1057
|
### Schema factories
|
|
395
1058
|
|
|
396
1059
|
```typescript
|
|
@@ -408,7 +1071,7 @@ const MessageArchive = mongoose.model(MESSAGE_ARCHIVE_MODEL_NAME, buildMessageAr
|
|
|
408
1071
|
const MessageRequest = mongoose.model(MESSAGE_REQUEST_MODEL_NAME, buildMessageRequestSchema());
|
|
409
1072
|
```
|
|
410
1073
|
|
|
411
|
-
`buildMessageSchema({ emailNotifier, emailNotificationExclusions, onEmailDeliveryFailure, userModelName, archiveModelName })` lets you opt in to email notifications with
|
|
1074
|
+
`buildMessageSchema({ connection, emailNotifier, emailRecipientResolver, emailNotificationExclusions, onEmailDeliveryFailure, userModelName, archiveModelName })` lets you opt in to email notifications with rendered-title exclusions. When `emailNotifier` is omitted or `null` (the default), no email hook is registered and a supplied resolver is never called.
|
|
412
1075
|
|
|
413
1076
|
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.
|
|
414
1077
|
|
|
@@ -416,6 +1079,15 @@ Email lookup and notifier failures do not roll back a committed message. Pass `o
|
|
|
416
1079
|
|
|
417
1080
|
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.
|
|
418
1081
|
|
|
1082
|
+
The default recipient adapter resolves `userModelName` (default `'User'`) on the
|
|
1083
|
+
hydrated message's owning connection and calls
|
|
1084
|
+
`findById(toUser).select('email').lean()`. Register that model on the same connection
|
|
1085
|
+
before building the schema, and pass `connection` for its eager registration check.
|
|
1086
|
+
Runtime lookup always follows the document's connection, even if the schema was
|
|
1087
|
+
built against another connection. A missing user or missing/empty `email` skips
|
|
1088
|
+
delivery. To use nested addresses, preferences, or an external directory, configure
|
|
1089
|
+
[`emailRecipientResolver`](#recipient-lookup-msga-05) instead.
|
|
1090
|
+
|
|
419
1091
|
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.
|
|
420
1092
|
|
|
421
1093
|
### Idempotent create notes
|
|
@@ -441,6 +1113,7 @@ import {
|
|
|
441
1113
|
TemplateNotFoundError,
|
|
442
1114
|
ActionNotFoundError,
|
|
443
1115
|
ActionNotAllowedError,
|
|
1116
|
+
ActionInputValidationError,
|
|
444
1117
|
ActionConflictError,
|
|
445
1118
|
ActionRetryableError,
|
|
446
1119
|
ActionNotificationPendingError,
|
|
@@ -451,6 +1124,7 @@ import {
|
|
|
451
1124
|
ClientRequestFailedError,
|
|
452
1125
|
ClientRequestInconsistentStateError,
|
|
453
1126
|
MessageTransactionRequiredError,
|
|
1127
|
+
PaymentProviderRequiredError,
|
|
454
1128
|
PaymentSessionCompensationError,
|
|
455
1129
|
PaymentSessionCompensationAggregateError,
|
|
456
1130
|
MessageArchivedError,
|
|
@@ -469,8 +1143,9 @@ The package brands exported errors so `instanceof` works across mixed ESM/Common
|
|
|
469
1143
|
| `TemplateNotFoundError` | Requested `templateCd` is not registered | 404 |
|
|
470
1144
|
| `ActionNotFoundError` | Requested action is not present on the message template | 404 |
|
|
471
1145
|
| `ActionNotAllowedError` | User lacks sender/receiver, permission, or condition access | 403 |
|
|
1146
|
+
| `ActionInputValidationError` | Pure pre-handler parser rejected this submission; fresh claims release, retry attempts retain their ID/action | 400, fixed `invalid action input` |
|
|
472
1147
|
| `MessageArchivedError` | Mutation was requested for an archived message | 410 |
|
|
473
|
-
| `InvalidMessageUserError` | Missing/invalid user identity at the service boundary
|
|
1148
|
+
| `InvalidMessageUserError` | Missing/invalid user identity or malformed principal roles at the service boundary | 401 |
|
|
474
1149
|
| `InvalidClientRequestIdError` | `clientRequestId` is not a valid bounded string | 400 |
|
|
475
1150
|
| `ActionConflictError` | Another live action attempt owns the message | 409 |
|
|
476
1151
|
| `ActionRetryableError` | Handler failed before archive commit; same action may retry with the persisted `actionAttemptId` | 409 |
|
|
@@ -480,9 +1155,10 @@ The package brands exported errors so `instanceof` works across mixed ESM/Common
|
|
|
480
1155
|
| `ClientRequestInconsistentStateError` | Persisted reservation/message state is corrupt | propagated as a server error |
|
|
481
1156
|
| `MessageTransactionRequiredError` | MongoDB deployment cannot provide required idempotent batch transactions | propagated as a server error |
|
|
482
1157
|
| `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)
|
|
1158
|
+
| `InvalidMessageServiceOptionError` | Invalid `actionLeaseMs`/`clientRequestLeaseMs`/`clientRequestWaitMs`/`clientRequestPollMs` (non-finite, fractional, out-of-range, or timer-overflow) | propagated as a server error unless handled by host code |
|
|
484
1159
|
| `ActionTemplateMismatchError` | Direct `handleAction()` template argument does not match the message | propagated as a server error unless handled by host code |
|
|
485
1160
|
| `MessageModelResolutionError` | Configured active/archive/request/user model cannot be resolved | propagated as a server error |
|
|
1161
|
+
| `PaymentProviderRequiredError` | A fresh item has a nonempty effective `paymentCd` but the service provider is absent/null; trusted server configuration must be corrected | 500, sanitized `Internal Server Error` |
|
|
486
1162
|
| `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
1163
|
| `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
1164
|
| `TemplateRegistryValidationError` | Invalid trusted template metadata at registration time | not thrown by route handlers |
|
|
@@ -509,8 +1185,154 @@ class SendGridEmailProvider implements EmailProvider {
|
|
|
509
1185
|
|
|
510
1186
|
Pass it to `buildMessageSchema({ emailNotifier: provider.sendNotification.bind(provider), onEmailDeliveryFailure })`.
|
|
511
1187
|
|
|
1188
|
+
#### Recipient lookup (MSGA-05)
|
|
1189
|
+
|
|
1190
|
+
`emailRecipientResolver?: EmailRecipientResolver` replaces the default User.email
|
|
1191
|
+
adapter. It receives `EmailRecipientResolverContext` with:
|
|
1192
|
+
|
|
1193
|
+
- `recipientId`: the direct `toUser` after schema casting (ObjectId by default,
|
|
1194
|
+
opaque string with the [String-ID recipe](#user-id-storage-and-normalization-msga-03-migration)).
|
|
1195
|
+
- `connection`: the hydrated message's owning Mongoose connection.
|
|
1196
|
+
- `message`: saved fields, including `_id`, `templateCd`, `payload`, `display`, and
|
|
1197
|
+
rendered content. This is a shallow read-only view of the hydrated document, not
|
|
1198
|
+
a cloned/frozen snapshot; do not mutate it or nested data. Its type exposes no
|
|
1199
|
+
document methods.
|
|
1200
|
+
|
|
1201
|
+
The resolver can return a string synchronously or asynchronously. `null`,
|
|
1202
|
+
`undefined`, and `''` skip delivery without a failure event or default lookup.
|
|
1203
|
+
Nonempty addresses are forwarded unchanged: the host owns validation, normalization,
|
|
1204
|
+
and preference decisions. A custom resolver needs **no local User model**, and
|
|
1205
|
+
`userModelName`/its eager check are ignored. Other model registration needed for
|
|
1206
|
+
host population or the resolver's own queries remains the host's responsibility.
|
|
1207
|
+
|
|
1208
|
+
For a nested contact and an opt-out preference, register the host's `Contact` model
|
|
1209
|
+
on the message connection, then pass this resolver to `buildMessageSchema` along
|
|
1210
|
+
with a notifier:
|
|
1211
|
+
|
|
1212
|
+
```typescript
|
|
1213
|
+
import type { EmailRecipientResolver } from '@web-ts-toolkit/message-service';
|
|
1214
|
+
|
|
1215
|
+
export const resolveNestedEmail: EmailRecipientResolver = async ({ connection, recipientId }) => {
|
|
1216
|
+
const Contact = connection.model<{ contact?: { email?: string; notifications?: boolean } }>('Contact');
|
|
1217
|
+
const recipient = await Contact.findById(recipientId).select('contact').lean();
|
|
1218
|
+
return recipient?.contact?.notifications ? recipient.contact.email : null;
|
|
1219
|
+
};
|
|
1220
|
+
```
|
|
1221
|
+
|
|
1222
|
+
For an external directory, inject the host client and provider. This schema factory
|
|
1223
|
+
registers no User model; compile it on `connection` as usual. Bind both methods if
|
|
1224
|
+
they depend on their instance's `this`:
|
|
1225
|
+
|
|
1226
|
+
```typescript
|
|
1227
|
+
import type mongoose from 'mongoose';
|
|
1228
|
+
import {
|
|
1229
|
+
buildMessageSchema,
|
|
1230
|
+
type EmailProvider,
|
|
1231
|
+
type EmailRecipientResolver,
|
|
1232
|
+
type MessageSchemaConfig,
|
|
1233
|
+
} from '@web-ts-toolkit/message-service';
|
|
1234
|
+
|
|
1235
|
+
export interface ContactDirectory {
|
|
1236
|
+
resolveEmail: EmailRecipientResolver;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
export function buildDirectoryMessageSchema(
|
|
1240
|
+
connection: mongoose.Connection,
|
|
1241
|
+
directory: ContactDirectory,
|
|
1242
|
+
provider: EmailProvider,
|
|
1243
|
+
onEmailDeliveryFailure?: MessageSchemaConfig['onEmailDeliveryFailure'],
|
|
1244
|
+
) {
|
|
1245
|
+
return buildMessageSchema({
|
|
1246
|
+
connection,
|
|
1247
|
+
emailRecipientResolver: directory.resolveEmail.bind(directory),
|
|
1248
|
+
emailNotifier: provider.sendNotification.bind(provider),
|
|
1249
|
+
onEmailDeliveryFailure,
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
```
|
|
1253
|
+
|
|
1254
|
+
Resolver throws/rejections report `onEmailDeliveryFailure` stage `recipientLookup`;
|
|
1255
|
+
notifier failures report stage `notifier`. Events retain the original `error`,
|
|
1256
|
+
`messageId`, `recipientId`, and trimmed `title`. Both failures occur after persistence
|
|
1257
|
+
and leave the save successful, even if the observer throws/rejects. All callbacks
|
|
1258
|
+
are awaited, so external lookups/delivery/observers should have host timeouts.
|
|
1259
|
+
|
|
1260
|
+
Lookup runs only for new successful saves with a direct recipient and nonblank,
|
|
1261
|
+
nonexcluded rendered receiver title. Role-only messages do not fan out to email.
|
|
1262
|
+
Disabled email, excluded/blank titles, updates, and **all session-bound saves** skip
|
|
1263
|
+
both resolver and notifier (including sessions outside transactions and committed
|
|
1264
|
+
transactions). Idempotent creation uses transactions and therefore also skips email;
|
|
1265
|
+
completed replay never resends. Body selection remains the longer rendered receiver
|
|
1266
|
+
`long`/`short` value (short wins ties). For reliable transactional delivery, use a
|
|
1267
|
+
host-owned outbox; this hook has no durable retry.
|
|
1268
|
+
|
|
1269
|
+
**Migration:** the resolver is optional; omitting it preserves the existing local
|
|
1270
|
+
User.email adapter, eager registration check, timing, and failure behavior. To
|
|
1271
|
+
replace a dummy User registration, configure a custom resolver before compiling
|
|
1272
|
+
the Message model. Address resolution does not change party storage, population,
|
|
1273
|
+
message recipients, or action permissions and requires no data/index migration.
|
|
1274
|
+
|
|
512
1275
|
### Payment Provider
|
|
513
1276
|
|
|
1277
|
+
#### Required sessions (MSGA-14 migration)
|
|
1278
|
+
|
|
1279
|
+
Every fresh prepared item with a nonempty effective `paymentCd` **must create a
|
|
1280
|
+
session**, including messages with no direct recipient, no roles, and no fallback
|
|
1281
|
+
roles. The effective code remains `item.paymentCd || template.paymentCd || ''`:
|
|
1282
|
+
|
|
1283
|
+
| Template code | Prepared item code | Effective code / behavior |
|
|
1284
|
+
| --------------- | ---------------------- | -------------------------------------------------------- |
|
|
1285
|
+
| Omitted or `''` | Omitted or `''` | `''`; no provider call, null session |
|
|
1286
|
+
| Nonempty `T` | Omitted or `''` | `T`; an empty override cannot disable the default |
|
|
1287
|
+
| Any | Nonempty `I` | `I`; item overrides the template |
|
|
1288
|
+
| Any | Whitespace-only string | Exact string; codes are not trimmed or grammar-validated |
|
|
1289
|
+
|
|
1290
|
+
Only trusted preparation can set an item code; a raw `payload.paymentCd` does not
|
|
1291
|
+
override it unless preparation explicitly maps it. One session is requested per
|
|
1292
|
+
paid item, not per recipient. The payer is `(payerUser || user)._id`, independently
|
|
1293
|
+
of prepared `fromUser`/`toUser`/roles; HTTP uses the authenticated user as payer.
|
|
1294
|
+
Only that item's `priceArgs` is forwarded.
|
|
1295
|
+
|
|
1296
|
+
| Provider | Nonempty effective code, with any recipients or none |
|
|
1297
|
+
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
|
|
1298
|
+
| Omitted/undefined or `null` | `PaymentProviderRequiredError` before this item's write; HTTP 500 with sanitized `Internal Server Error` |
|
|
1299
|
+
| `NoopPaymentProvider`, or any provider returning `null`/`''` | Existing `Error('payment session creation failed')`; no item write |
|
|
1300
|
+
| Provider rejects/throws | Original rejection propagates, unless compensation itself fails; existing route error mappings still apply |
|
|
1301
|
+
| Provider returns a nonempty session ID | Store that session if persistence succeeds |
|
|
1302
|
+
|
|
1303
|
+
The provider remains optional for code-free items, which skip it even if it would
|
|
1304
|
+
throw. `NoopPaymentProvider` cannot satisfy a paid code and is not an implicit
|
|
1305
|
+
default. A paid template that prepares `null` or `[]` has no items to pay for.
|
|
1306
|
+
|
|
1307
|
+
Checks run per fresh item after completed replay selection. Direct batches persist
|
|
1308
|
+
sequentially: a later configuration/provider failure stops the batch and retains
|
|
1309
|
+
the already-committed prefix and its sessions. Idempotent batches commit all items
|
|
1310
|
+
and reservation completion atomically; on failure every known uncommitted session
|
|
1311
|
+
is compensated. Sessions are created outside retryable transaction callbacks.
|
|
1312
|
+
A failed reservation remains failed after provider configuration is repaired;
|
|
1313
|
+
same-key retries return `ClientRequestFailedError` (HTTP 409). Reconcile retained
|
|
1314
|
+
items/provider effects before starting a new operation with a new key.
|
|
1315
|
+
|
|
1316
|
+
Completed same-scope replays return current active/archive records in item order,
|
|
1317
|
+
including legacy nonempty-code/null-session records, without preparation or payment
|
|
1318
|
+
work. They remain valid with absent/null/no-op/throwing providers and after template
|
|
1319
|
+
replacement/removal or payload/payer changes. Principal/payer validation still runs.
|
|
1320
|
+
|
|
1321
|
+
**Migration:** fresh code-bearing items that previously committed sessionless with
|
|
1322
|
+
an absent/null provider now fail; configured providers also run for unaddressed
|
|
1323
|
+
items that previously skipped them. Configure a real provider when checkout is
|
|
1324
|
+
intended. Move metadata-only codes into `payload` or `display` and remove their
|
|
1325
|
+
template/item `paymentCd`. For mixed free/paid items, omit a template default and
|
|
1326
|
+
assign per-item paid codes; `''` cannot clear a paid default. Audit existing
|
|
1327
|
+
nonempty-code/null-session active/archive records using host business knowledge.
|
|
1328
|
+
No schema, index, request-scope, or automatic data migration is needed: do not
|
|
1329
|
+
automatically backfill sessions, charge, rewrite legacy codes, invalidate completed
|
|
1330
|
+
reservations, or reinterpret existing actions. Nullable session types remain valid
|
|
1331
|
+
for code-free and legacy records.
|
|
1332
|
+
|
|
1333
|
+
Session creation is not proof of payment. Completion, capture, refunds/webhooks,
|
|
1334
|
+
and payment-based business authorization remain host/provider responsibilities.
|
|
1335
|
+
|
|
514
1336
|
```typescript
|
|
515
1337
|
import { type PaymentProvider, type UserId } from '@web-ts-toolkit/message-service';
|
|
516
1338
|
|