@web-ts-toolkit/message-service 0.28.0 → 0.29.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 +35 -5
- package/index.d.mts +16 -1
- package/index.d.ts +16 -1
- package/index.js +150 -7
- package/index.mjs +147 -7
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -15,8 +15,8 @@ Peer dependencies:
|
|
|
15
15
|
|
|
16
16
|
## Highlights
|
|
17
17
|
|
|
18
|
-
- **Message
|
|
19
|
-
- **Schema factories** — `buildMessageSchema(config?)` and `
|
|
18
|
+
- **Message persistence schemas** — `Message`, `MessageArchive`, and `MessageRequest` schemas with timestamps, indexes, archiving, and idempotency reservations
|
|
19
|
+
- **Schema factories** — `buildMessageSchema(config?)`, `buildMessageArchiveSchema()`, and `buildMessageRequestSchema()` for per-app configuration
|
|
20
20
|
- **Template engine** — Handlebars interpolation for sender/receiver content
|
|
21
21
|
- **Template registry** — register and lookup templates by `templateCd` (per-instance or global)
|
|
22
22
|
- **Action system** — validate permissions, run handlers, archive messages, send notifications
|
|
@@ -29,10 +29,24 @@ Peer dependencies:
|
|
|
29
29
|
|
|
30
30
|
```typescript
|
|
31
31
|
import mongoose from 'mongoose';
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
buildMessageArchiveSchema,
|
|
34
|
+
buildMessageRequestSchema,
|
|
35
|
+
buildMessageSchema,
|
|
36
|
+
createMessageRoutes,
|
|
37
|
+
defaultRegistry,
|
|
38
|
+
MESSAGE_ARCHIVE_MODEL_NAME,
|
|
39
|
+
MESSAGE_MODEL_NAME,
|
|
40
|
+
MESSAGE_REQUEST_MODEL_NAME,
|
|
41
|
+
MessageTemplate,
|
|
42
|
+
} from '@web-ts-toolkit/message-service';
|
|
33
43
|
|
|
34
44
|
await mongoose.connect('mongodb://localhost/mydb');
|
|
35
45
|
|
|
46
|
+
mongoose.model(MESSAGE_MODEL_NAME, buildMessageSchema());
|
|
47
|
+
mongoose.model(MESSAGE_ARCHIVE_MODEL_NAME, buildMessageArchiveSchema());
|
|
48
|
+
mongoose.model(MESSAGE_REQUEST_MODEL_NAME, buildMessageRequestSchema());
|
|
49
|
+
|
|
36
50
|
const myTemplate: MessageTemplate = {
|
|
37
51
|
/* ... */
|
|
38
52
|
};
|
|
@@ -133,14 +147,30 @@ registry.clear();
|
|
|
133
147
|
### Schema factories
|
|
134
148
|
|
|
135
149
|
```typescript
|
|
136
|
-
import {
|
|
150
|
+
import {
|
|
151
|
+
buildMessageSchema,
|
|
152
|
+
buildMessageArchiveSchema,
|
|
153
|
+
buildMessageRequestSchema,
|
|
154
|
+
MESSAGE_MODEL_NAME,
|
|
155
|
+
MESSAGE_ARCHIVE_MODEL_NAME,
|
|
156
|
+
MESSAGE_REQUEST_MODEL_NAME,
|
|
157
|
+
} from '@web-ts-toolkit/message-service';
|
|
137
158
|
|
|
138
159
|
const Message = mongoose.model(MESSAGE_MODEL_NAME, buildMessageSchema());
|
|
139
|
-
const MessageArchive = mongoose.model(
|
|
160
|
+
const MessageArchive = mongoose.model(MESSAGE_ARCHIVE_MODEL_NAME, buildMessageArchiveSchema());
|
|
161
|
+
const MessageRequest = mongoose.model(MESSAGE_REQUEST_MODEL_NAME, buildMessageRequestSchema());
|
|
140
162
|
```
|
|
141
163
|
|
|
142
164
|
`buildMessageSchema({ emailNotifier, emailNotificationExclusions, userModelName, archiveModelName })` lets you opt in to email notifications with per-template exclusions. When `emailNotifier` is `null` (the default), no pre-save hook is registered.
|
|
143
165
|
|
|
166
|
+
When you use `clientRequestId`, register all three models. `MessageRequest` stores the reservation record that prevents duplicate side effects during concurrent duplicate requests.
|
|
167
|
+
|
|
168
|
+
### Idempotent create notes
|
|
169
|
+
|
|
170
|
+
- `clientRequestId` is safe for concurrent retries only when `MessageRequest` is registered.
|
|
171
|
+
- The winning request reserves the idempotency key before template preparation or payment-session creation runs.
|
|
172
|
+
- Losing requests wait for the winning request to finish, then return the same created messages or the same empty result.
|
|
173
|
+
|
|
144
174
|
### Typed errors
|
|
145
175
|
|
|
146
176
|
```typescript
|
package/index.d.mts
CHANGED
|
@@ -214,6 +214,8 @@ interface IBaseMessage {
|
|
|
214
214
|
paymentCd: string;
|
|
215
215
|
payload: Record<string, unknown>;
|
|
216
216
|
display: Record<string, unknown>;
|
|
217
|
+
clientRequestId: string | null;
|
|
218
|
+
clientRequestItemIndex: number | null;
|
|
217
219
|
createdAt: Date;
|
|
218
220
|
updatedAt: Date;
|
|
219
221
|
}
|
|
@@ -231,6 +233,7 @@ type IMessageArchive = mongoose.Document<unknown, MongooseQueryHelpers, IMessage
|
|
|
231
233
|
/** Model name constants — use these when calling mongoose.model() or getModel() */
|
|
232
234
|
declare const MESSAGE_MODEL_NAME = "Message";
|
|
233
235
|
declare const MESSAGE_ARCHIVE_MODEL_NAME = "MessageArchive";
|
|
236
|
+
declare const MESSAGE_REQUEST_MODEL_NAME = "MessageRequest";
|
|
234
237
|
declare const MessageContentSchema: mongoose.Schema<any, mongoose.Model<any, any, any, any, any, any, any>, {}, {}, {}, {}, {
|
|
235
238
|
_id: false;
|
|
236
239
|
}, {
|
|
@@ -335,6 +338,9 @@ declare function buildMessageArchiveSchema(): mongoose.Schema;
|
|
|
335
338
|
*/
|
|
336
339
|
declare const MessageArchiveSchema: mongoose.Schema;
|
|
337
340
|
|
|
341
|
+
declare function buildMessageRequestSchema(): mongoose.Schema;
|
|
342
|
+
declare const MessageRequestSchema: mongoose.Schema;
|
|
343
|
+
|
|
338
344
|
/**
|
|
339
345
|
* Apply the standard set of action filters (usertype match, permission, condition).
|
|
340
346
|
* This is the single source of truth used by both the template engine (for UI)
|
|
@@ -499,6 +505,8 @@ declare class MessageService {
|
|
|
499
505
|
*/
|
|
500
506
|
clientRequestId?: string;
|
|
501
507
|
}): Promise<IMessage[]>;
|
|
508
|
+
private createMessageWithReservation;
|
|
509
|
+
private createPreparedMessageBatch;
|
|
502
510
|
createNotification(params: {
|
|
503
511
|
fromUser?: UserId | null;
|
|
504
512
|
toUser?: UserId | null;
|
|
@@ -557,6 +565,13 @@ declare class MessageService {
|
|
|
557
565
|
private buildActionContext;
|
|
558
566
|
private runSenderNotification;
|
|
559
567
|
private findByClientRequestId;
|
|
568
|
+
private waitForClientRequestOutcome;
|
|
569
|
+
private tryCreateClientRequestReservation;
|
|
570
|
+
private findClientRequestReservation;
|
|
571
|
+
private completeClientRequestReservation;
|
|
572
|
+
private releaseClientRequestReservation;
|
|
573
|
+
private isDuplicateKeyError;
|
|
574
|
+
private delay;
|
|
560
575
|
private isArchivedMessage;
|
|
561
576
|
private applyPopulate;
|
|
562
577
|
private findByIdWithOptions;
|
|
@@ -604,4 +619,4 @@ declare function createMessageRoutes(options: MessageRoutesOptions): {
|
|
|
604
619
|
service: MessageService;
|
|
605
620
|
};
|
|
606
621
|
|
|
607
|
-
export { type ActionConfirmation, type ActionContext, ActionNotAllowedError, ActionNotFoundError, BaseMessageFields, type EmailNotifier, type EmailProvider, GENERIC_NOTIFICATION_TEMPLATE_CD, type IBaseMessage, type IMessage, type IMessageArchive, type IMessageContent, type IMessageMethods, type InterpolatedAction, type InterpolatedContent, type InterpolationResult, MESSAGE_ARCHIVE_MODEL_NAME, MESSAGE_MODEL_NAME, type MessageAction, MessageArchiveSchema, MessageArchivedError, MessageContentSchema, MessageNotFoundError, type MessageRoutesOptions, MessageSchema, type MessageSchemaConfig, MessageService, type MessageServiceOptions, type MessageTemplate, type MessageType, type MessageUser, NoopEmailProvider, NoopPaymentProvider, type PaymentProvider, type PrepareContext, type PrepareResult, type SenderNotificationContent, TemplateNotFoundError, TemplateRegistry, type UiTemplate, type Usertype, buildMessageArchiveSchema, buildMessageSchema, createMessageRoutes, defaultRegistry, filterActions, includesAction, interpolateTemplate, isActionAllowed };
|
|
622
|
+
export { type ActionConfirmation, type ActionContext, ActionNotAllowedError, ActionNotFoundError, BaseMessageFields, type EmailNotifier, type EmailProvider, GENERIC_NOTIFICATION_TEMPLATE_CD, type IBaseMessage, type IMessage, type IMessageArchive, type IMessageContent, type IMessageMethods, type InterpolatedAction, type InterpolatedContent, type InterpolationResult, MESSAGE_ARCHIVE_MODEL_NAME, MESSAGE_MODEL_NAME, MESSAGE_REQUEST_MODEL_NAME, type MessageAction, MessageArchiveSchema, MessageArchivedError, MessageContentSchema, MessageNotFoundError, MessageRequestSchema, type MessageRoutesOptions, MessageSchema, type MessageSchemaConfig, MessageService, type MessageServiceOptions, type MessageTemplate, type MessageType, type MessageUser, NoopEmailProvider, NoopPaymentProvider, type PaymentProvider, type PrepareContext, type PrepareResult, type SenderNotificationContent, TemplateNotFoundError, TemplateRegistry, type UiTemplate, type Usertype, buildMessageArchiveSchema, buildMessageRequestSchema, buildMessageSchema, createMessageRoutes, defaultRegistry, filterActions, includesAction, interpolateTemplate, isActionAllowed };
|
package/index.d.ts
CHANGED
|
@@ -214,6 +214,8 @@ interface IBaseMessage {
|
|
|
214
214
|
paymentCd: string;
|
|
215
215
|
payload: Record<string, unknown>;
|
|
216
216
|
display: Record<string, unknown>;
|
|
217
|
+
clientRequestId: string | null;
|
|
218
|
+
clientRequestItemIndex: number | null;
|
|
217
219
|
createdAt: Date;
|
|
218
220
|
updatedAt: Date;
|
|
219
221
|
}
|
|
@@ -231,6 +233,7 @@ type IMessageArchive = mongoose.Document<unknown, MongooseQueryHelpers, IMessage
|
|
|
231
233
|
/** Model name constants — use these when calling mongoose.model() or getModel() */
|
|
232
234
|
declare const MESSAGE_MODEL_NAME = "Message";
|
|
233
235
|
declare const MESSAGE_ARCHIVE_MODEL_NAME = "MessageArchive";
|
|
236
|
+
declare const MESSAGE_REQUEST_MODEL_NAME = "MessageRequest";
|
|
234
237
|
declare const MessageContentSchema: mongoose.Schema<any, mongoose.Model<any, any, any, any, any, any, any>, {}, {}, {}, {}, {
|
|
235
238
|
_id: false;
|
|
236
239
|
}, {
|
|
@@ -335,6 +338,9 @@ declare function buildMessageArchiveSchema(): mongoose.Schema;
|
|
|
335
338
|
*/
|
|
336
339
|
declare const MessageArchiveSchema: mongoose.Schema;
|
|
337
340
|
|
|
341
|
+
declare function buildMessageRequestSchema(): mongoose.Schema;
|
|
342
|
+
declare const MessageRequestSchema: mongoose.Schema;
|
|
343
|
+
|
|
338
344
|
/**
|
|
339
345
|
* Apply the standard set of action filters (usertype match, permission, condition).
|
|
340
346
|
* This is the single source of truth used by both the template engine (for UI)
|
|
@@ -499,6 +505,8 @@ declare class MessageService {
|
|
|
499
505
|
*/
|
|
500
506
|
clientRequestId?: string;
|
|
501
507
|
}): Promise<IMessage[]>;
|
|
508
|
+
private createMessageWithReservation;
|
|
509
|
+
private createPreparedMessageBatch;
|
|
502
510
|
createNotification(params: {
|
|
503
511
|
fromUser?: UserId | null;
|
|
504
512
|
toUser?: UserId | null;
|
|
@@ -557,6 +565,13 @@ declare class MessageService {
|
|
|
557
565
|
private buildActionContext;
|
|
558
566
|
private runSenderNotification;
|
|
559
567
|
private findByClientRequestId;
|
|
568
|
+
private waitForClientRequestOutcome;
|
|
569
|
+
private tryCreateClientRequestReservation;
|
|
570
|
+
private findClientRequestReservation;
|
|
571
|
+
private completeClientRequestReservation;
|
|
572
|
+
private releaseClientRequestReservation;
|
|
573
|
+
private isDuplicateKeyError;
|
|
574
|
+
private delay;
|
|
560
575
|
private isArchivedMessage;
|
|
561
576
|
private applyPopulate;
|
|
562
577
|
private findByIdWithOptions;
|
|
@@ -604,4 +619,4 @@ declare function createMessageRoutes(options: MessageRoutesOptions): {
|
|
|
604
619
|
service: MessageService;
|
|
605
620
|
};
|
|
606
621
|
|
|
607
|
-
export { type ActionConfirmation, type ActionContext, ActionNotAllowedError, ActionNotFoundError, BaseMessageFields, type EmailNotifier, type EmailProvider, GENERIC_NOTIFICATION_TEMPLATE_CD, type IBaseMessage, type IMessage, type IMessageArchive, type IMessageContent, type IMessageMethods, type InterpolatedAction, type InterpolatedContent, type InterpolationResult, MESSAGE_ARCHIVE_MODEL_NAME, MESSAGE_MODEL_NAME, type MessageAction, MessageArchiveSchema, MessageArchivedError, MessageContentSchema, MessageNotFoundError, type MessageRoutesOptions, MessageSchema, type MessageSchemaConfig, MessageService, type MessageServiceOptions, type MessageTemplate, type MessageType, type MessageUser, NoopEmailProvider, NoopPaymentProvider, type PaymentProvider, type PrepareContext, type PrepareResult, type SenderNotificationContent, TemplateNotFoundError, TemplateRegistry, type UiTemplate, type Usertype, buildMessageArchiveSchema, buildMessageSchema, createMessageRoutes, defaultRegistry, filterActions, includesAction, interpolateTemplate, isActionAllowed };
|
|
622
|
+
export { type ActionConfirmation, type ActionContext, ActionNotAllowedError, ActionNotFoundError, BaseMessageFields, type EmailNotifier, type EmailProvider, GENERIC_NOTIFICATION_TEMPLATE_CD, type IBaseMessage, type IMessage, type IMessageArchive, type IMessageContent, type IMessageMethods, type InterpolatedAction, type InterpolatedContent, type InterpolationResult, MESSAGE_ARCHIVE_MODEL_NAME, MESSAGE_MODEL_NAME, MESSAGE_REQUEST_MODEL_NAME, type MessageAction, MessageArchiveSchema, MessageArchivedError, MessageContentSchema, MessageNotFoundError, MessageRequestSchema, type MessageRoutesOptions, MessageSchema, type MessageSchemaConfig, MessageService, type MessageServiceOptions, type MessageTemplate, type MessageType, type MessageUser, NoopEmailProvider, NoopPaymentProvider, type PaymentProvider, type PrepareContext, type PrepareResult, type SenderNotificationContent, TemplateNotFoundError, TemplateRegistry, type UiTemplate, type Usertype, buildMessageArchiveSchema, buildMessageRequestSchema, buildMessageSchema, createMessageRoutes, defaultRegistry, filterActions, includesAction, interpolateTemplate, isActionAllowed };
|
package/index.js
CHANGED
|
@@ -36,10 +36,12 @@ __export(index_exports, {
|
|
|
36
36
|
GENERIC_NOTIFICATION_TEMPLATE_CD: () => GENERIC_NOTIFICATION_TEMPLATE_CD,
|
|
37
37
|
MESSAGE_ARCHIVE_MODEL_NAME: () => MESSAGE_ARCHIVE_MODEL_NAME,
|
|
38
38
|
MESSAGE_MODEL_NAME: () => MESSAGE_MODEL_NAME,
|
|
39
|
+
MESSAGE_REQUEST_MODEL_NAME: () => MESSAGE_REQUEST_MODEL_NAME,
|
|
39
40
|
MessageArchiveSchema: () => MessageArchiveSchema,
|
|
40
41
|
MessageArchivedError: () => MessageArchivedError,
|
|
41
42
|
MessageContentSchema: () => MessageContentSchema,
|
|
42
43
|
MessageNotFoundError: () => MessageNotFoundError,
|
|
44
|
+
MessageRequestSchema: () => MessageRequestSchema,
|
|
43
45
|
MessageSchema: () => MessageSchema,
|
|
44
46
|
MessageService: () => MessageService,
|
|
45
47
|
NoopEmailProvider: () => NoopEmailProvider,
|
|
@@ -47,6 +49,7 @@ __export(index_exports, {
|
|
|
47
49
|
TemplateNotFoundError: () => TemplateNotFoundError,
|
|
48
50
|
TemplateRegistry: () => TemplateRegistry,
|
|
49
51
|
buildMessageArchiveSchema: () => buildMessageArchiveSchema,
|
|
52
|
+
buildMessageRequestSchema: () => buildMessageRequestSchema,
|
|
50
53
|
buildMessageSchema: () => buildMessageSchema,
|
|
51
54
|
createMessageRoutes: () => createMessageRoutes,
|
|
52
55
|
defaultRegistry: () => defaultRegistry,
|
|
@@ -61,6 +64,7 @@ module.exports = __toCommonJS(index_exports);
|
|
|
61
64
|
var import_mongoose = __toESM(require("mongoose"));
|
|
62
65
|
var MESSAGE_MODEL_NAME = "Message";
|
|
63
66
|
var MESSAGE_ARCHIVE_MODEL_NAME = "MessageArchive";
|
|
67
|
+
var MESSAGE_REQUEST_MODEL_NAME = "MessageRequest";
|
|
64
68
|
var MessageContentSchema = new import_mongoose.default.Schema(
|
|
65
69
|
{
|
|
66
70
|
title: { type: String, default: "" },
|
|
@@ -85,7 +89,8 @@ var BaseMessageFields = {
|
|
|
85
89
|
paymentCd: { type: String, default: "" },
|
|
86
90
|
payload: { type: import_mongoose.default.Schema.Types.Mixed, default: {} },
|
|
87
91
|
display: { type: import_mongoose.default.Schema.Types.Mixed, default: {} },
|
|
88
|
-
clientRequestId: { type: String, default: null }
|
|
92
|
+
clientRequestId: { type: String, default: null },
|
|
93
|
+
clientRequestItemIndex: { type: Number, default: null }
|
|
89
94
|
};
|
|
90
95
|
|
|
91
96
|
// src/schemas/message.ts
|
|
@@ -228,6 +233,16 @@ function buildMessageSchema(config) {
|
|
|
228
233
|
});
|
|
229
234
|
schema.index({ createdAt: 1 });
|
|
230
235
|
schema.index({ clientRequestId: 1 }, { sparse: true });
|
|
236
|
+
schema.index(
|
|
237
|
+
{ clientRequestId: 1, clientRequestItemIndex: 1 },
|
|
238
|
+
{
|
|
239
|
+
unique: true,
|
|
240
|
+
partialFilterExpression: {
|
|
241
|
+
clientRequestId: { $type: "string" },
|
|
242
|
+
clientRequestItemIndex: { $type: "number" }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
);
|
|
231
246
|
schema.methods.isSender = isSender;
|
|
232
247
|
schema.methods.isReceiver = isReceiver;
|
|
233
248
|
schema.methods.archive = createArchiveMethod({ archiveModelName: resolved.archiveModelName });
|
|
@@ -266,6 +281,25 @@ function buildMessageArchiveSchema() {
|
|
|
266
281
|
}
|
|
267
282
|
var MessageArchiveSchema = buildMessageArchiveSchema();
|
|
268
283
|
|
|
284
|
+
// src/schemas/message-request.ts
|
|
285
|
+
var import_mongoose4 = __toESM(require("mongoose"));
|
|
286
|
+
function buildMessageRequestSchema() {
|
|
287
|
+
const schema = new import_mongoose4.default.Schema(
|
|
288
|
+
{
|
|
289
|
+
clientRequestId: { type: String, required: true },
|
|
290
|
+
state: { type: String, enum: ["pending", "completed"], default: "pending" },
|
|
291
|
+
itemCount: { type: Number, default: null }
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
timestamps: true
|
|
295
|
+
}
|
|
296
|
+
);
|
|
297
|
+
schema.index({ clientRequestId: 1 }, { unique: true });
|
|
298
|
+
schema.index({ updatedAt: 1 });
|
|
299
|
+
return schema;
|
|
300
|
+
}
|
|
301
|
+
var MessageRequestSchema = buildMessageRequestSchema();
|
|
302
|
+
|
|
269
303
|
// src/template-engine.ts
|
|
270
304
|
var import_handlebars = __toESM(require("handlebars"));
|
|
271
305
|
var compiledTemplates = /* @__PURE__ */ new Map();
|
|
@@ -354,6 +388,9 @@ var NoopPaymentProvider = class {
|
|
|
354
388
|
var import_utils = require("@web-ts-toolkit/utils");
|
|
355
389
|
var DEFAULT_LIST_LIMIT = 50;
|
|
356
390
|
var MAX_LIST_LIMIT = 100;
|
|
391
|
+
var DUPLICATE_KEY_ERROR_CODE = 11e3;
|
|
392
|
+
var CLIENT_REQUEST_BATCH_RETRY_COUNT = 20;
|
|
393
|
+
var CLIENT_REQUEST_BATCH_RETRY_DELAY_MS = 10;
|
|
357
394
|
var GENERIC_NOTIFICATION_TEMPLATE_CD = "__generic-notification__";
|
|
358
395
|
var MessageNotFoundError = class extends Error {
|
|
359
396
|
constructor(messageId) {
|
|
@@ -437,9 +474,55 @@ var MessageService = class {
|
|
|
437
474
|
clientRequestId
|
|
438
475
|
} = params;
|
|
439
476
|
if (clientRequestId) {
|
|
440
|
-
|
|
441
|
-
|
|
477
|
+
return this.createMessageWithReservation({
|
|
478
|
+
templateCd,
|
|
479
|
+
user,
|
|
480
|
+
roles,
|
|
481
|
+
identity,
|
|
482
|
+
permissions,
|
|
483
|
+
payload,
|
|
484
|
+
payerUser,
|
|
485
|
+
req,
|
|
486
|
+
clientRequestId
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
return this.createPreparedMessageBatch({
|
|
490
|
+
templateCd,
|
|
491
|
+
user,
|
|
492
|
+
roles,
|
|
493
|
+
identity,
|
|
494
|
+
permissions,
|
|
495
|
+
payload,
|
|
496
|
+
payerUser,
|
|
497
|
+
req
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
async createMessageWithReservation(params) {
|
|
501
|
+
while (true) {
|
|
502
|
+
const existing = await this.findByClientRequestId(params.clientRequestId);
|
|
503
|
+
if (existing) {
|
|
504
|
+
return existing;
|
|
505
|
+
}
|
|
506
|
+
const acquired = await this.tryCreateClientRequestReservation(params.clientRequestId);
|
|
507
|
+
if (acquired) {
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
const waited = await this.waitForClientRequestOutcome(params.clientRequestId);
|
|
511
|
+
if (waited !== null) {
|
|
512
|
+
return waited;
|
|
513
|
+
}
|
|
442
514
|
}
|
|
515
|
+
try {
|
|
516
|
+
const results = await this.createPreparedMessageBatch(params);
|
|
517
|
+
await this.completeClientRequestReservation(params.clientRequestId, results.length);
|
|
518
|
+
return results;
|
|
519
|
+
} catch (error) {
|
|
520
|
+
await this.releaseClientRequestReservation(params.clientRequestId);
|
|
521
|
+
throw error;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
async createPreparedMessageBatch(params) {
|
|
525
|
+
const { templateCd, user, roles, identity, permissions, payload, payerUser, req, clientRequestId } = params;
|
|
443
526
|
const template = this.registry.find(templateCd);
|
|
444
527
|
if (!template) throw new TemplateNotFoundError(templateCd);
|
|
445
528
|
const messageData = await template.prepareMessage({
|
|
@@ -455,8 +538,9 @@ var MessageService = class {
|
|
|
455
538
|
const ctx = { user, roles, identity, permissions, payload, payerUser, req };
|
|
456
539
|
const items = Array.isArray(messageData) ? messageData : [messageData];
|
|
457
540
|
const results = [];
|
|
458
|
-
for (
|
|
459
|
-
|
|
541
|
+
for (let index = 0; index < items.length; index++) {
|
|
542
|
+
const item = items[index];
|
|
543
|
+
results.push(await this.persistItem(template, item, ctx, clientRequestId, clientRequestId ? index : null));
|
|
460
544
|
}
|
|
461
545
|
return results;
|
|
462
546
|
}
|
|
@@ -564,7 +648,7 @@ var MessageService = class {
|
|
|
564
648
|
// -------------------------------------------------------------------------
|
|
565
649
|
// Private helpers
|
|
566
650
|
// -------------------------------------------------------------------------
|
|
567
|
-
async persistItem(template, m, ctx, clientRequestId) {
|
|
651
|
+
async persistItem(template, m, ctx, clientRequestId, clientRequestItemIndex) {
|
|
568
652
|
const toUser = m.toUser ?? null;
|
|
569
653
|
const toRoles = (m.toRoles && m.toRoles.length > 0 ? m.toRoles : this.adminRoles).slice();
|
|
570
654
|
const type = m.type || template.type || "notification";
|
|
@@ -593,7 +677,8 @@ var MessageService = class {
|
|
|
593
677
|
paymentCd,
|
|
594
678
|
payload: m.payload || ctx.payload,
|
|
595
679
|
display: m.display,
|
|
596
|
-
clientRequestId: clientRequestId ?? null
|
|
680
|
+
clientRequestId: clientRequestId ?? null,
|
|
681
|
+
clientRequestItemIndex
|
|
597
682
|
});
|
|
598
683
|
}
|
|
599
684
|
buildActionContext(data) {
|
|
@@ -634,6 +719,61 @@ var MessageService = class {
|
|
|
634
719
|
const docs = await Message.find({ clientRequestId }).sort({ createdAt: 1, _id: 1 }).limit(Number.MAX_SAFE_INTEGER);
|
|
635
720
|
return docs.length > 0 ? docs : null;
|
|
636
721
|
}
|
|
722
|
+
async waitForClientRequestOutcome(clientRequestId) {
|
|
723
|
+
for (let attempt = 0; attempt < CLIENT_REQUEST_BATCH_RETRY_COUNT; attempt++) {
|
|
724
|
+
const docs2 = await this.findByClientRequestId(clientRequestId);
|
|
725
|
+
if (docs2) {
|
|
726
|
+
return docs2;
|
|
727
|
+
}
|
|
728
|
+
const reservation2 = await this.findClientRequestReservation(clientRequestId);
|
|
729
|
+
if (!reservation2) {
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
if (reservation2.state === "completed") {
|
|
733
|
+
return [];
|
|
734
|
+
}
|
|
735
|
+
await this.delay(CLIENT_REQUEST_BATCH_RETRY_DELAY_MS);
|
|
736
|
+
}
|
|
737
|
+
const docs = await this.findByClientRequestId(clientRequestId);
|
|
738
|
+
if (docs) {
|
|
739
|
+
return docs;
|
|
740
|
+
}
|
|
741
|
+
const reservation = await this.findClientRequestReservation(clientRequestId);
|
|
742
|
+
if (reservation?.state === "completed") {
|
|
743
|
+
return [];
|
|
744
|
+
}
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
async tryCreateClientRequestReservation(clientRequestId) {
|
|
748
|
+
const MessageRequest = this.getModel(MESSAGE_REQUEST_MODEL_NAME);
|
|
749
|
+
try {
|
|
750
|
+
await MessageRequest.create({ clientRequestId, state: "pending", itemCount: null });
|
|
751
|
+
return true;
|
|
752
|
+
} catch (error) {
|
|
753
|
+
if (this.isDuplicateKeyError(error)) {
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
throw error;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
async findClientRequestReservation(clientRequestId) {
|
|
760
|
+
const MessageRequest = this.getModel(MESSAGE_REQUEST_MODEL_NAME);
|
|
761
|
+
return await MessageRequest.findOne({ clientRequestId });
|
|
762
|
+
}
|
|
763
|
+
async completeClientRequestReservation(clientRequestId, itemCount) {
|
|
764
|
+
const MessageRequest = this.getModel(MESSAGE_REQUEST_MODEL_NAME);
|
|
765
|
+
await MessageRequest.updateOne({ clientRequestId }, { state: "completed", itemCount });
|
|
766
|
+
}
|
|
767
|
+
async releaseClientRequestReservation(clientRequestId) {
|
|
768
|
+
const MessageRequest = this.getModel(MESSAGE_REQUEST_MODEL_NAME);
|
|
769
|
+
await MessageRequest.deleteOne({ clientRequestId });
|
|
770
|
+
}
|
|
771
|
+
isDuplicateKeyError(error) {
|
|
772
|
+
return error instanceof Error && "code" in error && error.code === DUPLICATE_KEY_ERROR_CODE;
|
|
773
|
+
}
|
|
774
|
+
delay(ms) {
|
|
775
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
776
|
+
}
|
|
637
777
|
isArchivedMessage(message) {
|
|
638
778
|
return "archivedAt" in message;
|
|
639
779
|
}
|
|
@@ -781,10 +921,12 @@ function defaultGetIdentity(req) {
|
|
|
781
921
|
GENERIC_NOTIFICATION_TEMPLATE_CD,
|
|
782
922
|
MESSAGE_ARCHIVE_MODEL_NAME,
|
|
783
923
|
MESSAGE_MODEL_NAME,
|
|
924
|
+
MESSAGE_REQUEST_MODEL_NAME,
|
|
784
925
|
MessageArchiveSchema,
|
|
785
926
|
MessageArchivedError,
|
|
786
927
|
MessageContentSchema,
|
|
787
928
|
MessageNotFoundError,
|
|
929
|
+
MessageRequestSchema,
|
|
788
930
|
MessageSchema,
|
|
789
931
|
MessageService,
|
|
790
932
|
NoopEmailProvider,
|
|
@@ -792,6 +934,7 @@ function defaultGetIdentity(req) {
|
|
|
792
934
|
TemplateNotFoundError,
|
|
793
935
|
TemplateRegistry,
|
|
794
936
|
buildMessageArchiveSchema,
|
|
937
|
+
buildMessageRequestSchema,
|
|
795
938
|
buildMessageSchema,
|
|
796
939
|
createMessageRoutes,
|
|
797
940
|
defaultRegistry,
|
package/index.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import mongoose from "mongoose";
|
|
3
3
|
var MESSAGE_MODEL_NAME = "Message";
|
|
4
4
|
var MESSAGE_ARCHIVE_MODEL_NAME = "MessageArchive";
|
|
5
|
+
var MESSAGE_REQUEST_MODEL_NAME = "MessageRequest";
|
|
5
6
|
var MessageContentSchema = new mongoose.Schema(
|
|
6
7
|
{
|
|
7
8
|
title: { type: String, default: "" },
|
|
@@ -26,7 +27,8 @@ var BaseMessageFields = {
|
|
|
26
27
|
paymentCd: { type: String, default: "" },
|
|
27
28
|
payload: { type: mongoose.Schema.Types.Mixed, default: {} },
|
|
28
29
|
display: { type: mongoose.Schema.Types.Mixed, default: {} },
|
|
29
|
-
clientRequestId: { type: String, default: null }
|
|
30
|
+
clientRequestId: { type: String, default: null },
|
|
31
|
+
clientRequestItemIndex: { type: Number, default: null }
|
|
30
32
|
};
|
|
31
33
|
|
|
32
34
|
// src/schemas/message.ts
|
|
@@ -169,6 +171,16 @@ function buildMessageSchema(config) {
|
|
|
169
171
|
});
|
|
170
172
|
schema.index({ createdAt: 1 });
|
|
171
173
|
schema.index({ clientRequestId: 1 }, { sparse: true });
|
|
174
|
+
schema.index(
|
|
175
|
+
{ clientRequestId: 1, clientRequestItemIndex: 1 },
|
|
176
|
+
{
|
|
177
|
+
unique: true,
|
|
178
|
+
partialFilterExpression: {
|
|
179
|
+
clientRequestId: { $type: "string" },
|
|
180
|
+
clientRequestItemIndex: { $type: "number" }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
);
|
|
172
184
|
schema.methods.isSender = isSender;
|
|
173
185
|
schema.methods.isReceiver = isReceiver;
|
|
174
186
|
schema.methods.archive = createArchiveMethod({ archiveModelName: resolved.archiveModelName });
|
|
@@ -207,6 +219,25 @@ function buildMessageArchiveSchema() {
|
|
|
207
219
|
}
|
|
208
220
|
var MessageArchiveSchema = buildMessageArchiveSchema();
|
|
209
221
|
|
|
222
|
+
// src/schemas/message-request.ts
|
|
223
|
+
import mongoose4 from "mongoose";
|
|
224
|
+
function buildMessageRequestSchema() {
|
|
225
|
+
const schema = new mongoose4.Schema(
|
|
226
|
+
{
|
|
227
|
+
clientRequestId: { type: String, required: true },
|
|
228
|
+
state: { type: String, enum: ["pending", "completed"], default: "pending" },
|
|
229
|
+
itemCount: { type: Number, default: null }
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
timestamps: true
|
|
233
|
+
}
|
|
234
|
+
);
|
|
235
|
+
schema.index({ clientRequestId: 1 }, { unique: true });
|
|
236
|
+
schema.index({ updatedAt: 1 });
|
|
237
|
+
return schema;
|
|
238
|
+
}
|
|
239
|
+
var MessageRequestSchema = buildMessageRequestSchema();
|
|
240
|
+
|
|
210
241
|
// src/template-engine.ts
|
|
211
242
|
import Handlebars from "handlebars";
|
|
212
243
|
var compiledTemplates = /* @__PURE__ */ new Map();
|
|
@@ -295,6 +326,9 @@ var NoopPaymentProvider = class {
|
|
|
295
326
|
import { isString } from "@web-ts-toolkit/utils";
|
|
296
327
|
var DEFAULT_LIST_LIMIT = 50;
|
|
297
328
|
var MAX_LIST_LIMIT = 100;
|
|
329
|
+
var DUPLICATE_KEY_ERROR_CODE = 11e3;
|
|
330
|
+
var CLIENT_REQUEST_BATCH_RETRY_COUNT = 20;
|
|
331
|
+
var CLIENT_REQUEST_BATCH_RETRY_DELAY_MS = 10;
|
|
298
332
|
var GENERIC_NOTIFICATION_TEMPLATE_CD = "__generic-notification__";
|
|
299
333
|
var MessageNotFoundError = class extends Error {
|
|
300
334
|
constructor(messageId) {
|
|
@@ -378,9 +412,55 @@ var MessageService = class {
|
|
|
378
412
|
clientRequestId
|
|
379
413
|
} = params;
|
|
380
414
|
if (clientRequestId) {
|
|
381
|
-
|
|
382
|
-
|
|
415
|
+
return this.createMessageWithReservation({
|
|
416
|
+
templateCd,
|
|
417
|
+
user,
|
|
418
|
+
roles,
|
|
419
|
+
identity,
|
|
420
|
+
permissions,
|
|
421
|
+
payload,
|
|
422
|
+
payerUser,
|
|
423
|
+
req,
|
|
424
|
+
clientRequestId
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
return this.createPreparedMessageBatch({
|
|
428
|
+
templateCd,
|
|
429
|
+
user,
|
|
430
|
+
roles,
|
|
431
|
+
identity,
|
|
432
|
+
permissions,
|
|
433
|
+
payload,
|
|
434
|
+
payerUser,
|
|
435
|
+
req
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
async createMessageWithReservation(params) {
|
|
439
|
+
while (true) {
|
|
440
|
+
const existing = await this.findByClientRequestId(params.clientRequestId);
|
|
441
|
+
if (existing) {
|
|
442
|
+
return existing;
|
|
443
|
+
}
|
|
444
|
+
const acquired = await this.tryCreateClientRequestReservation(params.clientRequestId);
|
|
445
|
+
if (acquired) {
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
const waited = await this.waitForClientRequestOutcome(params.clientRequestId);
|
|
449
|
+
if (waited !== null) {
|
|
450
|
+
return waited;
|
|
451
|
+
}
|
|
383
452
|
}
|
|
453
|
+
try {
|
|
454
|
+
const results = await this.createPreparedMessageBatch(params);
|
|
455
|
+
await this.completeClientRequestReservation(params.clientRequestId, results.length);
|
|
456
|
+
return results;
|
|
457
|
+
} catch (error) {
|
|
458
|
+
await this.releaseClientRequestReservation(params.clientRequestId);
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
async createPreparedMessageBatch(params) {
|
|
463
|
+
const { templateCd, user, roles, identity, permissions, payload, payerUser, req, clientRequestId } = params;
|
|
384
464
|
const template = this.registry.find(templateCd);
|
|
385
465
|
if (!template) throw new TemplateNotFoundError(templateCd);
|
|
386
466
|
const messageData = await template.prepareMessage({
|
|
@@ -396,8 +476,9 @@ var MessageService = class {
|
|
|
396
476
|
const ctx = { user, roles, identity, permissions, payload, payerUser, req };
|
|
397
477
|
const items = Array.isArray(messageData) ? messageData : [messageData];
|
|
398
478
|
const results = [];
|
|
399
|
-
for (
|
|
400
|
-
|
|
479
|
+
for (let index = 0; index < items.length; index++) {
|
|
480
|
+
const item = items[index];
|
|
481
|
+
results.push(await this.persistItem(template, item, ctx, clientRequestId, clientRequestId ? index : null));
|
|
401
482
|
}
|
|
402
483
|
return results;
|
|
403
484
|
}
|
|
@@ -505,7 +586,7 @@ var MessageService = class {
|
|
|
505
586
|
// -------------------------------------------------------------------------
|
|
506
587
|
// Private helpers
|
|
507
588
|
// -------------------------------------------------------------------------
|
|
508
|
-
async persistItem(template, m, ctx, clientRequestId) {
|
|
589
|
+
async persistItem(template, m, ctx, clientRequestId, clientRequestItemIndex) {
|
|
509
590
|
const toUser = m.toUser ?? null;
|
|
510
591
|
const toRoles = (m.toRoles && m.toRoles.length > 0 ? m.toRoles : this.adminRoles).slice();
|
|
511
592
|
const type = m.type || template.type || "notification";
|
|
@@ -534,7 +615,8 @@ var MessageService = class {
|
|
|
534
615
|
paymentCd,
|
|
535
616
|
payload: m.payload || ctx.payload,
|
|
536
617
|
display: m.display,
|
|
537
|
-
clientRequestId: clientRequestId ?? null
|
|
618
|
+
clientRequestId: clientRequestId ?? null,
|
|
619
|
+
clientRequestItemIndex
|
|
538
620
|
});
|
|
539
621
|
}
|
|
540
622
|
buildActionContext(data) {
|
|
@@ -575,6 +657,61 @@ var MessageService = class {
|
|
|
575
657
|
const docs = await Message.find({ clientRequestId }).sort({ createdAt: 1, _id: 1 }).limit(Number.MAX_SAFE_INTEGER);
|
|
576
658
|
return docs.length > 0 ? docs : null;
|
|
577
659
|
}
|
|
660
|
+
async waitForClientRequestOutcome(clientRequestId) {
|
|
661
|
+
for (let attempt = 0; attempt < CLIENT_REQUEST_BATCH_RETRY_COUNT; attempt++) {
|
|
662
|
+
const docs2 = await this.findByClientRequestId(clientRequestId);
|
|
663
|
+
if (docs2) {
|
|
664
|
+
return docs2;
|
|
665
|
+
}
|
|
666
|
+
const reservation2 = await this.findClientRequestReservation(clientRequestId);
|
|
667
|
+
if (!reservation2) {
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
if (reservation2.state === "completed") {
|
|
671
|
+
return [];
|
|
672
|
+
}
|
|
673
|
+
await this.delay(CLIENT_REQUEST_BATCH_RETRY_DELAY_MS);
|
|
674
|
+
}
|
|
675
|
+
const docs = await this.findByClientRequestId(clientRequestId);
|
|
676
|
+
if (docs) {
|
|
677
|
+
return docs;
|
|
678
|
+
}
|
|
679
|
+
const reservation = await this.findClientRequestReservation(clientRequestId);
|
|
680
|
+
if (reservation?.state === "completed") {
|
|
681
|
+
return [];
|
|
682
|
+
}
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
async tryCreateClientRequestReservation(clientRequestId) {
|
|
686
|
+
const MessageRequest = this.getModel(MESSAGE_REQUEST_MODEL_NAME);
|
|
687
|
+
try {
|
|
688
|
+
await MessageRequest.create({ clientRequestId, state: "pending", itemCount: null });
|
|
689
|
+
return true;
|
|
690
|
+
} catch (error) {
|
|
691
|
+
if (this.isDuplicateKeyError(error)) {
|
|
692
|
+
return false;
|
|
693
|
+
}
|
|
694
|
+
throw error;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
async findClientRequestReservation(clientRequestId) {
|
|
698
|
+
const MessageRequest = this.getModel(MESSAGE_REQUEST_MODEL_NAME);
|
|
699
|
+
return await MessageRequest.findOne({ clientRequestId });
|
|
700
|
+
}
|
|
701
|
+
async completeClientRequestReservation(clientRequestId, itemCount) {
|
|
702
|
+
const MessageRequest = this.getModel(MESSAGE_REQUEST_MODEL_NAME);
|
|
703
|
+
await MessageRequest.updateOne({ clientRequestId }, { state: "completed", itemCount });
|
|
704
|
+
}
|
|
705
|
+
async releaseClientRequestReservation(clientRequestId) {
|
|
706
|
+
const MessageRequest = this.getModel(MESSAGE_REQUEST_MODEL_NAME);
|
|
707
|
+
await MessageRequest.deleteOne({ clientRequestId });
|
|
708
|
+
}
|
|
709
|
+
isDuplicateKeyError(error) {
|
|
710
|
+
return error instanceof Error && "code" in error && error.code === DUPLICATE_KEY_ERROR_CODE;
|
|
711
|
+
}
|
|
712
|
+
delay(ms) {
|
|
713
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
714
|
+
}
|
|
578
715
|
isArchivedMessage(message) {
|
|
579
716
|
return "archivedAt" in message;
|
|
580
717
|
}
|
|
@@ -721,10 +858,12 @@ export {
|
|
|
721
858
|
GENERIC_NOTIFICATION_TEMPLATE_CD,
|
|
722
859
|
MESSAGE_ARCHIVE_MODEL_NAME,
|
|
723
860
|
MESSAGE_MODEL_NAME,
|
|
861
|
+
MESSAGE_REQUEST_MODEL_NAME,
|
|
724
862
|
MessageArchiveSchema,
|
|
725
863
|
MessageArchivedError,
|
|
726
864
|
MessageContentSchema,
|
|
727
865
|
MessageNotFoundError,
|
|
866
|
+
MessageRequestSchema,
|
|
728
867
|
MessageSchema,
|
|
729
868
|
MessageService,
|
|
730
869
|
NoopEmailProvider,
|
|
@@ -732,6 +871,7 @@ export {
|
|
|
732
871
|
TemplateNotFoundError,
|
|
733
872
|
TemplateRegistry,
|
|
734
873
|
buildMessageArchiveSchema,
|
|
874
|
+
buildMessageRequestSchema,
|
|
735
875
|
buildMessageSchema,
|
|
736
876
|
createMessageRoutes,
|
|
737
877
|
defaultRegistry,
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@web-ts-toolkit/message-service",
|
|
3
3
|
"description": "Template-driven messaging core for Mongoose + Express applications",
|
|
4
4
|
"homepage": "https://web-ts-toolkit.pages.dev/docs/packages/message-service",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.29.0",
|
|
6
6
|
"sideEffects": [
|
|
7
7
|
"./dist/schemas/message.js",
|
|
8
8
|
"./dist/schemas/message-archive.js"
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"node": ">=20"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@web-ts-toolkit/express-json-router": "0.
|
|
34
|
-
"@web-ts-toolkit/utils": "0.
|
|
33
|
+
"@web-ts-toolkit/express-json-router": "0.29.0",
|
|
34
|
+
"@web-ts-toolkit/utils": "0.29.0",
|
|
35
35
|
"handlebars": "^4.7.8"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|