@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 CHANGED
@@ -15,8 +15,8 @@ Peer dependencies:
15
15
 
16
16
  ## Highlights
17
17
 
18
- - **Message & MessageArchive schemas** — drop-in Mongoose schemas with timestamps, indexes, and archiving
19
- - **Schema factories** — `buildMessageSchema(config?)` and `buildMessageArchiveSchema()` for per-app configuration
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 { createMessageRoutes, defaultRegistry, MessageTemplate } from '@web-ts-toolkit/message-service';
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 { buildMessageSchema, buildMessageArchiveSchema, MESSAGE_MODEL_NAME } from '@web-ts-toolkit/message-service';
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('MessageArchive', buildMessageArchiveSchema());
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
- const existing = await this.findByClientRequestId(clientRequestId);
441
- if (existing) return existing;
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 (const m of items) {
459
- results.push(await this.persistItem(template, m, ctx, clientRequestId));
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
- const existing = await this.findByClientRequestId(clientRequestId);
382
- if (existing) return existing;
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 (const m of items) {
400
- results.push(await this.persistItem(template, m, ctx, clientRequestId));
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.28.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.28.0",
34
- "@web-ts-toolkit/utils": "0.28.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": {