@fluojs/notifications 1.0.1 → 1.0.2

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.ko.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  <p><a href="./README.md"><kbd>English</kbd></a> <strong><kbd>한국어</kbd></strong></p>
4
4
 
5
- fluo를 위한 채널 중립(notification channel-agnostic) 알림 오케스트레이션 패키지입니다. 알림 채널의 공통 계약을 고정하고, Nest-like 모듈 API를 제공하며, 선택적인 큐 기반 전달 심(seam)과 라이프사이클 이벤트 발행 심을 노출합니다.
5
+ fluo를 위한 채널 중립(notification channel-agnostic) 알림 오케스트레이션 패키지입니다. 알림 채널의 공통 계약을 고정하고, 친숙한 dynamic-module 사용감을 가진 명시적 모듈 등록 API를 제공하며, 선택적인 큐 기반 전달 심(seam)과 라이프사이클 이벤트 발행 심을 노출합니다.
6
6
 
7
7
  ## 목차
8
8
 
@@ -34,7 +34,7 @@ npm install @fluojs/notifications
34
34
 
35
35
  ### 1. foundation 모듈 등록
36
36
 
37
- 알림 모듈 등록은 `NotificationsModule.forRoot(...)` 또는 `NotificationsModule.forRootAsync(...)`로 구성합니다.
37
+ 알림 모듈 등록은 `channels`에 명시적인 `NotificationChannel` 값을 전달하는 `NotificationsModule.forRoot(...)` 또는 `NotificationsModule.forRootAsync(...)`로 구성합니다.
38
38
 
39
39
  ```typescript
40
40
  import { Module } from '@fluojs/core';
@@ -91,11 +91,13 @@ export class WelcomeService {
91
91
 
92
92
  `NotificationsModule.forRoot(...)`와 `NotificationsModule.forRootAsync(...)`는 기본적으로 `NotificationsService`, `NOTIFICATIONS`, `NOTIFICATION_CHANNELS`를 global provider로 export합니다. 이 provider들이 notifications module을 import한 module 안에서만 보이도록 유지하려면 `global: false`를 설정합니다. 애플리케이션 서비스는 fluo의 class-level `@Inject(...)` decorator로 의존성을 선언해야 standard-decorator DI container가 parameter decorator 없이 서비스를 resolve할 수 있습니다.
93
93
 
94
+ Migration boundary: channel registration은 metadata 기반이 아니라 value 기반입니다. NestJS provider discovery, `@Injectable()` metadata, `emitDecoratorMetadata`에 기대어 channel이 등록된다고 가정하지 마세요. 애플리케이션 코드에서 `NotificationChannel` object를 만들거나 `NotificationsModule.forRootAsync({ inject, useFactory, global? })`에서 반환한 뒤, `channels` option으로 전달합니다.
95
+
94
96
  ## 일반적인 패턴
95
97
 
96
98
  ### 큐 기반 대량 전달
97
99
 
98
- 많은 알림을 백그라운드 워커로 넘기고 싶다면 선택적인 queue seam을 사용합니다.
100
+ 많은 알림을 백그라운드 워커로 넘기고 싶다면 선택적인 queue seam을 사용합니다. Queue adapter는 애플리케이션 소유 integration이며, `@fluojs/notifications`는 abstract adapter contract만 호출합니다.
99
101
 
100
102
  ```typescript
101
103
  NotificationsModule.forRoot({
@@ -124,11 +126,11 @@ Behavioral contract 메모:
124
126
  - `dispatchMany(..., { continueOnError: true })`는 direct delivery 또는 순차 queue fallback enqueue에서 첫 실패를 던지는 대신 실패들을 수집합니다.
125
127
  - queue enqueue가 실패하면 서비스는 enqueue 에러를 다시 던지기 전에 결정적인 `notification.dispatch.failed` 라이프사이클 이벤트를 발행합니다. queued bulk dispatch는 queue 미구성, channel 해석, provider/adapter failure 경로를 포함해 이미 `requested`를 발행한 모든 notification에 대해 terminal `queued` 또는 `failed` 이벤트도 발행합니다.
126
128
  - `enqueueMany(...)`가 없으면 대량 queue delivery는 input order대로 각 job을 개별 enqueue하는 방식으로 fallback합니다. `continueOnError: true`이면 성공한 enqueue는 `results`에 남고 실패한 enqueue는 `failures`로 반환됩니다. 그렇지 않으면 첫 enqueue failure를 다시 던지기 전에 아직 terminal 상태가 없는 나머지 requested fallback job에 `failed` 라이프사이클 이벤트를 발행합니다.
127
- - foundation 패키지는 특정 큐 구현을 가정하거나 import하지 않습니다.
129
+ - foundation 패키지는 특정 큐 구현을 가정하거나 import하지 않고, queue client/worker를 만들거나 애플리케이션 소유 queue resource를 close/drain하지 않습니다.
128
130
 
129
131
  ### 이벤트 발행자를 통한 라이프사이클 발행
130
132
 
131
- foundation 패키지를 `@fluojs/event-bus` 구현에 직접 결합하지 않고도 caller-visible 라이프사이클 이벤트를 발행할 수 있습니다.
133
+ foundation 패키지를 `@fluojs/event-bus` 구현에 직접 결합하지 않고도 caller-visible 라이프사이클 이벤트를 발행할 수 있습니다. Event publisher 역시 애플리케이션 소유이며, foundation 패키지는 concrete event bus를 create/import/close/drain하지 않습니다.
132
134
 
133
135
  ```typescript
134
136
  NotificationsModule.forRoot({
@@ -160,6 +162,7 @@ foundation 패키지는 의도적으로 다음을 **포함하지 않습니다**:
160
162
  - 내장 email, Slack, Discord 구현
161
163
  - 직접적인 `process.env` 접근
162
164
  - `@fluojs/queue` 또는 `@fluojs/event-bus`의 concrete runtime 타입 의존성
165
+ - concrete queue 또는 event-bus resource를 create/import/close/drain하는 것. Queue adapter와 event publisher는 애플리케이션 소유 integration입니다.
163
166
  - provider별 payload 의미를 공유 계약에 인코딩하는 것
164
167
 
165
168
  이 제한 사항은 leaf 패키지가 하나의 안정적인 오케스트레이션 계층 위에서 독립적으로 진화할 수 있도록 하는 package contract의 일부입니다.
@@ -208,7 +211,7 @@ foundation 패키지는 의도적으로 다음을 **포함하지 않습니다**:
208
211
  - `NotificationQueueNotConfiguredError`
209
212
 
210
213
  상태 snapshot은 platform diagnostics를 위해 `operationMode`, dependency diagnostics, ownership, readiness, health 필드를 포함합니다.
211
- Queue adapter가 구성되면 `details.dependencies`에 `notifications.queue-adapter`가 포함되고, lifecycle event가 event publisher를 통해 발행되면 `notifications.event-publisher`가 포함됩니다. 이러한 선택적 통합은 `ownership.externallyManaged: true`로 표시되지만, foundation 패키지가 concrete queue 또는 event-bus 리소스를 닫지 않으므로 `ownsResources: false`를 유지합니다.
214
+ Queue adapter가 구성되면 `details.dependencies`에 `notifications.queue-adapter`가 포함되고, lifecycle event가 event publisher를 통해 발행되면 `notifications.event-publisher`가 포함됩니다. 이러한 선택적 통합은 `ownership.externallyManaged: true`로 표시되지만, foundation 패키지가 concrete queue 또는 event-bus 리소스를 create/close/drain하지 않으므로 `ownsResources: false`를 유지합니다.
212
215
 
213
216
  ## 관련 패키지
214
217
 
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  <p><strong><kbd>English</kbd></strong> <a href="./README.ko.md"><kbd>한국어</kbd></a></p>
4
4
 
5
- Channel-agnostic notification orchestration for fluo. It freezes the shared contract for notification channels, provides a Nest-like module API, and exposes optional queue-backed delivery and lifecycle event publication seams.
5
+ Channel-agnostic notification orchestration for fluo. It freezes the shared contract for notification channels, provides explicit module registration with familiar dynamic-module ergonomics, and exposes optional queue-backed delivery and lifecycle event publication seams.
6
6
 
7
7
  ## Table of Contents
8
8
 
@@ -34,7 +34,7 @@ npm install @fluojs/notifications
34
34
 
35
35
  ### 1. Register the foundation module
36
36
 
37
- Register notifications with `NotificationsModule.forRoot(...)` or `NotificationsModule.forRootAsync(...)`.
37
+ Register notifications with `NotificationsModule.forRoot(...)` or `NotificationsModule.forRootAsync(...)` by passing explicit `NotificationChannel` values in `channels`.
38
38
 
39
39
  ```typescript
40
40
  import { Module } from '@fluojs/core';
@@ -91,11 +91,13 @@ export class WelcomeService {
91
91
 
92
92
  `NotificationsModule.forRoot(...)` and `NotificationsModule.forRootAsync(...)` export `NotificationsService`, `NOTIFICATIONS`, and `NOTIFICATION_CHANNELS` as global providers by default. Set `global: false` when these providers should stay visible only to the module that imports the notifications module. Application services should declare dependencies with fluo's class-level `@Inject(...)` decorator so the standard-decorator DI container can resolve the service without parameter decorators.
93
93
 
94
+ Migration boundary: channel registration is value-based, not metadata-based. Do not rely on NestJS provider discovery, `@Injectable()` metadata, or `emitDecoratorMetadata` to register channels. Build `NotificationChannel` objects in application code or return them from `NotificationsModule.forRootAsync({ inject, useFactory, global? })`, then pass them through the `channels` option.
95
+
94
96
  ## Common Patterns
95
97
 
96
98
  ### Queue-backed bulk delivery
97
99
 
98
- Use the optional queue seam when many notifications should be deferred to background workers.
100
+ Use the optional queue seam when many notifications should be deferred to background workers. The queue adapter is an application-owned integration; `@fluojs/notifications` only calls the abstract adapter contract.
99
101
 
100
102
  ```typescript
101
103
  NotificationsModule.forRoot({
@@ -124,11 +126,11 @@ Behavioral contract notes:
124
126
  - `dispatchMany(..., { continueOnError: true })` collects failures instead of throwing on the first failed direct delivery or sequential queue fallback enqueue.
125
127
  - When queue enqueue fails, the service emits deterministic `notification.dispatch.failed` lifecycle events before rethrowing the enqueue error to the caller. Queued bulk dispatch also publishes a terminal `queued` or `failed` event for every notification that already emitted `requested`, including queue-missing, channel-resolution, and provider/adapter failure paths.
126
128
  - If `enqueueMany(...)` is unavailable, bulk queue delivery falls back to enqueueing each job individually in input order. With `continueOnError: true`, successful enqueues remain visible in `results` while failed enqueues are returned in `failures`; without it, the first enqueue failure is rethrown after the remaining requested fallback jobs receive `failed` lifecycle events.
127
- - The foundation package does not assume or import a concrete queue implementation.
129
+ - The foundation package does not assume or import a concrete queue implementation, create queue clients/workers, or close/drain application-owned queue resources.
128
130
 
129
131
  ### Lifecycle publication through an event publisher
130
132
 
131
- Publish caller-visible lifecycle events without coupling the foundation package to `@fluojs/event-bus` directly.
133
+ Publish caller-visible lifecycle events without coupling the foundation package to `@fluojs/event-bus` directly. The event publisher is also application-owned; the foundation package does not create, import, close, or drain a concrete event bus.
132
134
 
133
135
  ```typescript
134
136
  NotificationsModule.forRoot({
@@ -160,6 +162,7 @@ The foundation package intentionally does **not**:
160
162
  - ship built-in email, Slack, or Discord implementations
161
163
  - inspect `process.env` directly
162
164
  - depend on `@fluojs/queue` or `@fluojs/event-bus` concrete runtime types
165
+ - create, import, close, or drain concrete queue or event-bus resources; queue adapters and event publishers are application-owned integrations
163
166
  - encode provider-specific payload semantics into the shared contract
164
167
 
165
168
  These limitations are part of the package contract so leaf packages can evolve independently while sharing one stable orchestration layer.
@@ -208,7 +211,7 @@ These limitations are part of the package contract so leaf packages can evolve i
208
211
  - `NotificationQueueNotConfiguredError`
209
212
 
210
213
  Status snapshots include `operationMode`, dependency diagnostics, ownership, readiness, and health fields for platform diagnostics.
211
- When a queue adapter is configured, `details.dependencies` includes `notifications.queue-adapter`; when lifecycle events are published through an event publisher, it includes `notifications.event-publisher`. Those optional integrations mark `ownership.externallyManaged: true` while the foundation package still reports `ownsResources: false` because it does not close concrete queue or event-bus resources.
214
+ When a queue adapter is configured, `details.dependencies` includes `notifications.queue-adapter`; when lifecycle events are published through an event publisher, it includes `notifications.event-publisher`. Those optional integrations mark `ownership.externallyManaged: true` while the foundation package still reports `ownsResources: false` because it does not create, close, or drain concrete queue or event-bus resources.
212
215
 
213
216
  ## Related Packages
214
217
 
package/dist/service.d.ts CHANGED
@@ -57,9 +57,10 @@ export declare class NotificationsService implements Notifications {
57
57
  private shouldQueueSingleDispatch;
58
58
  private shouldQueue;
59
59
  private publishLifecycleEvent;
60
- private publishLifecycleEventSafely;
60
+ private publishLifecycleEventBestEffort;
61
61
  private publishFailureLifecycleEvent;
62
62
  private publishFailureLifecycleEvents;
63
+ private publishRequestedLifecycleEvents;
63
64
  private dispatchManyThroughSequentialQueueFallback;
64
65
  }
65
66
  //# sourceMappingURL=service.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EACV,oCAAoC,EACpC,mBAAmB,EACnB,+BAA+B,EAC/B,+BAA+B,EAC/B,2BAA2B,EAC3B,2BAA2B,EAC3B,0BAA0B,EAE1B,aAAa,EAEd,MAAM,YAAY,CAAC;AAEpB;;;;;;;GAOG;AACH,qBACa,oBAAqB,YAAW,aAAa;IAItD,OAAO,CAAC,QAAQ,CAAC,OAAO;IAH1B,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA0C;gBAGtD,OAAO,EAAE,oCAAoC,EAC9D,QAAQ,EAAE,SAAS,mBAAmB,EAAE;IAO1C;;;;;;;;;;;;;;;;;;;OAmBG;IACG,QAAQ,CAAC,QAAQ,SAAS,2BAA2B,EACzD,YAAY,EAAE,QAAQ,EACtB,OAAO,GAAE,2BAAgC,GACxC,OAAO,CAAC,0BAA0B,CAAC;IA+DtC;;;;;;;;OAQG;IACG,YAAY,CAAC,QAAQ,SAAS,2BAA2B,EAC7D,aAAa,EAAE,SAAS,QAAQ,EAAE,EAClC,OAAO,GAAE,+BAAoC,GAC5C,OAAO,CAAC,+BAA+B,CAAC,QAAQ,CAAC,CAAC;IAmGrD;;;;OAIG;IACH,4BAA4B;IAS5B,OAAO,CAAC,cAAc;IAStB,OAAO,CAAC,gBAAgB;IAQxB,OAAO,CAAC,cAAc;IAUtB,OAAO,CAAC,mBAAmB;IAY3B,OAAO,CAAC,mBAAmB;IAQ3B,OAAO,CAAC,4BAA4B;IAQpC,OAAO,CAAC,yBAAyB;IAIjC,OAAO,CAAC,WAAW;YAYL,qBAAqB;YA4BrB,2BAA2B;YAc3B,4BAA4B;YAY5B,6BAA6B;YAoB7B,0CAA0C;CA2DzD"}
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EACV,oCAAoC,EACpC,mBAAmB,EACnB,+BAA+B,EAC/B,+BAA+B,EAC/B,2BAA2B,EAC3B,2BAA2B,EAC3B,0BAA0B,EAE1B,aAAa,EAEd,MAAM,YAAY,CAAC;AAEpB;;;;;;;GAOG;AACH,qBACa,oBAAqB,YAAW,aAAa;IAItD,OAAO,CAAC,QAAQ,CAAC,OAAO;IAH1B,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA0C;gBAGtD,OAAO,EAAE,oCAAoC,EAC9D,QAAQ,EAAE,SAAS,mBAAmB,EAAE;IAO1C;;;;;;;;;;;;;;;;;;;OAmBG;IACG,QAAQ,CAAC,QAAQ,SAAS,2BAA2B,EACzD,YAAY,EAAE,QAAQ,EACtB,OAAO,GAAE,2BAAgC,GACxC,OAAO,CAAC,0BAA0B,CAAC;IAmEtC;;;;;;;;OAQG;IACG,YAAY,CAAC,QAAQ,SAAS,2BAA2B,EAC7D,aAAa,EAAE,SAAS,QAAQ,EAAE,EAClC,OAAO,GAAE,+BAAoC,GAC5C,OAAO,CAAC,+BAA+B,CAAC,QAAQ,CAAC,CAAC;IAiGrD;;;;OAIG;IACH,4BAA4B;IAS5B,OAAO,CAAC,cAAc;IAStB,OAAO,CAAC,gBAAgB;IAQxB,OAAO,CAAC,cAAc;IAUtB,OAAO,CAAC,mBAAmB;IAY3B,OAAO,CAAC,mBAAmB;IAQ3B,OAAO,CAAC,4BAA4B;IAQpC,OAAO,CAAC,yBAAyB;IAIjC,OAAO,CAAC,WAAW;YAYL,qBAAqB;YA4BrB,+BAA+B;YAgB/B,4BAA4B;YAmB5B,6BAA6B;YA0B7B,+BAA+B;YAmB/B,0CAA0C;CA4DzD"}
package/dist/service.js CHANGED
@@ -50,12 +50,12 @@ class NotificationsService {
50
50
  * ```
51
51
  */
52
52
  async dispatch(notification, options = {}) {
53
- await this.publishLifecycleEventSafely('notification.dispatch.requested', notification, options);
53
+ const requestedPublicationError = await this.publishLifecycleEventBestEffort('notification.dispatch.requested', notification, options);
54
54
  if (this.shouldQueueSingleDispatch(options)) {
55
55
  try {
56
56
  this.requireChannel(notification.channel);
57
57
  } catch (error) {
58
- await this.publishFailureLifecycleEvent(notification, options, error);
58
+ await this.publishFailureLifecycleEvent(notification, options, error, requestedPublicationError);
59
59
  throw error;
60
60
  }
61
61
  const job = this.createQueueJob(notification);
@@ -67,10 +67,10 @@ class NotificationsService {
67
67
  queued: true,
68
68
  status: 'queued'
69
69
  };
70
- await this.publishLifecycleEventSafely('notification.dispatch.queued', notification, options, result.deliveryId);
70
+ await this.publishLifecycleEventBestEffort('notification.dispatch.queued', notification, options, result.deliveryId);
71
71
  return result;
72
72
  } catch (error) {
73
- await this.publishFailureLifecycleEvent(notification, options, error);
73
+ await this.publishFailureLifecycleEvent(notification, options, error, requestedPublicationError);
74
74
  throw error;
75
75
  }
76
76
  }
@@ -78,7 +78,7 @@ class NotificationsService {
78
78
  try {
79
79
  channel = this.requireChannel(notification.channel);
80
80
  } catch (error) {
81
- await this.publishFailureLifecycleEvent(notification, options, error);
81
+ await this.publishFailureLifecycleEvent(notification, options, error, requestedPublicationError);
82
82
  throw error;
83
83
  }
84
84
  try {
@@ -92,10 +92,10 @@ class NotificationsService {
92
92
  queued: delivery.status === 'queued',
93
93
  status: delivery.status ?? 'delivered'
94
94
  };
95
- await this.publishLifecycleEventSafely(result.queued ? 'notification.dispatch.queued' : 'notification.dispatch.delivered', notification, options, result.deliveryId);
95
+ await this.publishLifecycleEventBestEffort(result.queued ? 'notification.dispatch.queued' : 'notification.dispatch.delivered', notification, options, result.deliveryId);
96
96
  return result;
97
97
  } catch (error) {
98
- await this.publishFailureLifecycleEvent(notification, options, error);
98
+ await this.publishFailureLifecycleEvent(notification, options, error, requestedPublicationError);
99
99
  throw error;
100
100
  }
101
101
  }
@@ -120,14 +120,12 @@ class NotificationsService {
120
120
  };
121
121
  }
122
122
  if (this.shouldQueue(notifications.length, options)) {
123
- for (const notification of notifications) {
124
- await this.publishLifecycleEventSafely('notification.dispatch.requested', notification, options);
125
- }
123
+ const requestedPublicationErrors = await this.publishRequestedLifecycleEvents(notifications, options);
126
124
  let queue;
127
125
  try {
128
126
  queue = this.requireQueueAdapter();
129
127
  } catch (error) {
130
- await this.publishFailureLifecycleEvents(notifications, options, error);
128
+ await this.publishFailureLifecycleEvents(notifications, options, error, requestedPublicationErrors);
131
129
  throw error;
132
130
  }
133
131
  try {
@@ -135,18 +133,18 @@ class NotificationsService {
135
133
  this.requireChannel(notification.channel);
136
134
  }
137
135
  } catch (error) {
138
- await this.publishFailureLifecycleEvents(notifications, options, error);
136
+ await this.publishFailureLifecycleEvents(notifications, options, error, requestedPublicationErrors);
139
137
  throw error;
140
138
  }
141
139
  const jobs = notifications.map(notification => this.createQueueJob(notification));
142
140
  if (!queue.enqueueMany) {
143
- return this.dispatchManyThroughSequentialQueueFallback(notifications, jobs, options);
141
+ return this.dispatchManyThroughSequentialQueueFallback(notifications, jobs, options, requestedPublicationErrors);
144
142
  }
145
143
  let ids;
146
144
  try {
147
- ids = await queue.enqueueMany(jobs);
145
+ ids = validateQueueBatchDeliveryIds(await queue.enqueueMany(jobs), jobs.length);
148
146
  } catch (error) {
149
- await this.publishFailureLifecycleEvents(notifications, options, error);
147
+ await this.publishFailureLifecycleEvents(notifications, options, error, requestedPublicationErrors);
150
148
  throw error;
151
149
  }
152
150
  const results = notifications.map((notification, index) => ({
@@ -157,7 +155,7 @@ class NotificationsService {
157
155
  }));
158
156
  for (let index = 0; index < notifications.length; index += 1) {
159
157
  const notification = notifications[index];
160
- await this.publishLifecycleEventSafely('notification.dispatch.queued', notification, options, results[index]?.deliveryId);
158
+ await this.publishLifecycleEventBestEffort('notification.dispatch.queued', notification, options, results[index]?.deliveryId);
161
159
  }
162
160
  return {
163
161
  failed: 0,
@@ -276,28 +274,45 @@ class NotificationsService {
276
274
  };
277
275
  await this.options.events.publisher.publish(event);
278
276
  }
279
- async publishLifecycleEventSafely(name, notification, options, deliveryId, error) {
277
+ async publishLifecycleEventBestEffort(name, notification, options, deliveryId, error) {
280
278
  try {
281
279
  await this.publishLifecycleEvent(name, notification, options, deliveryId, error);
282
- } catch {
283
- return;
280
+ } catch (publicationError) {
281
+ return publicationError;
284
282
  }
283
+ return undefined;
285
284
  }
286
- async publishFailureLifecycleEvent(notification, options, error) {
285
+ async publishFailureLifecycleEvent(notification, options, error, ...precedingPublicationErrors) {
286
+ const priorPublicationErrors = precedingPublicationErrors.filter(entry => entry !== undefined);
287
287
  try {
288
288
  await this.publishLifecycleEvent('notification.dispatch.failed', notification, options, undefined, error);
289
289
  } catch (publicationError) {
290
- throw createLifecyclePublicationFailureError(error, publicationError);
290
+ throw createLifecyclePublicationFailureError(error, ...priorPublicationErrors, publicationError);
291
+ }
292
+ if (priorPublicationErrors.length > 0) {
293
+ throw createLifecyclePublicationFailureError(error, ...priorPublicationErrors);
291
294
  }
292
295
  }
293
- async publishFailureLifecycleEvents(notifications, options, error) {
296
+ async publishFailureLifecycleEvents(notifications, options, error, precedingPublicationErrors = []) {
297
+ const priorPublicationErrors = precedingPublicationErrors.filter(entry => entry !== undefined);
294
298
  const failurePublicationResults = await Promise.allSettled(notifications.map(notification => this.publishLifecycleEvent('notification.dispatch.failed', notification, options, undefined, error)));
295
299
  const publicationFailures = failurePublicationResults.filter(result => result.status === 'rejected').map(result => result.reason);
296
300
  if (publicationFailures.length > 0) {
297
- throw createLifecyclePublicationFailureError(error, ...publicationFailures);
301
+ throw createLifecyclePublicationFailureError(error, ...priorPublicationErrors, ...publicationFailures);
302
+ }
303
+ if (priorPublicationErrors.length > 0) {
304
+ throw createLifecyclePublicationFailureError(error, ...priorPublicationErrors);
298
305
  }
299
306
  }
300
- async dispatchManyThroughSequentialQueueFallback(notifications, jobs, options) {
307
+ async publishRequestedLifecycleEvents(notifications, options) {
308
+ const publicationErrors = [];
309
+ for (const notification of notifications) {
310
+ const publicationError = await this.publishLifecycleEventBestEffort('notification.dispatch.requested', notification, options);
311
+ publicationErrors.push(publicationError);
312
+ }
313
+ return publicationErrors;
314
+ }
315
+ async dispatchManyThroughSequentialQueueFallback(notifications, jobs, options, requestedPublicationErrors) {
301
316
  const queue = this.requireQueueAdapter();
302
317
  const results = [];
303
318
  const failures = [];
@@ -316,18 +331,18 @@ class NotificationsService {
316
331
  status: 'queued'
317
332
  };
318
333
  results.push(result);
319
- await this.publishLifecycleEventSafely('notification.dispatch.queued', notification, options, deliveryId);
334
+ await this.publishLifecycleEventBestEffort('notification.dispatch.queued', notification, options, deliveryId);
320
335
  } catch (error) {
321
336
  const failure = {
322
337
  error: error instanceof Error ? error : new Error('Notification queue enqueue failed.'),
323
338
  notification
324
339
  };
325
340
  if (!(options.continueOnError ?? false)) {
326
- await this.publishFailureLifecycleEvents(notifications.slice(index), options, error);
341
+ await this.publishFailureLifecycleEvents(notifications.slice(index), options, error, requestedPublicationErrors.slice(index));
327
342
  throw error;
328
343
  }
329
344
  try {
330
- await this.publishFailureLifecycleEvent(notification, options, error);
345
+ await this.publishFailureLifecycleEvent(notification, options, error, requestedPublicationErrors[index]);
331
346
  } catch (publicationError) {
332
347
  failure.error = publicationError instanceof Error ? publicationError : createLifecyclePublicationFailureError(error, publicationError);
333
348
  }
@@ -347,6 +362,31 @@ class NotificationsService {
347
362
  }
348
363
  }
349
364
  export { _NotificationsService as NotificationsService };
365
+ function validateQueueBatchDeliveryIds(value, expectedCount) {
366
+ if (!Array.isArray(value)) {
367
+ throw createQueueBatchResultIntegrityError(`expected ${expectedCount} queue ids but received a non-array result`);
368
+ }
369
+ if (value.length !== expectedCount) {
370
+ throw createQueueBatchResultIntegrityError(`expected ${expectedCount} queue ids but received ${value.length}`);
371
+ }
372
+ const ids = [];
373
+ for (let index = 0; index < value.length; index += 1) {
374
+ if (!Object.hasOwn(value, index)) {
375
+ throw createQueueBatchResultIntegrityError(`queue id at index ${index} must be present`);
376
+ }
377
+ const entry = value[index];
378
+ if (typeof entry !== 'string' || entry.length === 0) {
379
+ throw createQueueBatchResultIntegrityError(`queue id at index ${index} must be a non-empty string`);
380
+ }
381
+ ids.push(entry);
382
+ }
383
+ return ids;
384
+ }
385
+ function createQueueBatchResultIntegrityError(message) {
386
+ const error = new Error(`Notifications queue adapter returned an invalid enqueueMany() result: ${message}.`);
387
+ error.name = 'NotificationQueueResultIntegrityError';
388
+ return error;
389
+ }
350
390
  function createLifecyclePublicationFailureError(dispatchError, ...publicationErrors) {
351
391
  const primaryMessage = dispatchError instanceof Error ? dispatchError.message : 'Notification dispatch failed.';
352
392
  return new AggregateError([dispatchError, ...publicationErrors], `Notification dispatch failed, and failed lifecycle event publication also failed: ${primaryMessage}`);
@@ -364,9 +404,31 @@ function stableStringify(value) {
364
404
  if (value === null || typeof value !== 'object') {
365
405
  return JSON.stringify(value) ?? String(value);
366
406
  }
407
+ if (value instanceof Date) {
408
+ return Number.isNaN(value.getTime()) ? 'Date:Invalid' : `Date:${JSON.stringify(value.toISOString())}`;
409
+ }
410
+ if (value instanceof URL) {
411
+ return `URL:${JSON.stringify(value.href)}`;
412
+ }
413
+ if (value instanceof URLSearchParams) {
414
+ return `URLSearchParams:${JSON.stringify(value.toString())}`;
415
+ }
416
+ if (value instanceof RegExp) {
417
+ return `RegExp:${JSON.stringify(value.source)}/${value.flags}`;
418
+ }
419
+ if (value instanceof Map) {
420
+ const entries = Array.from(value.entries()).map(([key, entry]) => `[${stableStringify(key)},${stableStringify(entry)}]`).sort();
421
+ return `Map:{${entries.join(',')}}`;
422
+ }
423
+ if (value instanceof Set) {
424
+ const entries = Array.from(value.values()).map(entry => stableStringify(entry)).sort();
425
+ return `Set:[${entries.join(',')}]`;
426
+ }
367
427
  if (Array.isArray(value)) {
368
428
  return `[${value.map(entry => stableStringify(entry)).join(',')}]`;
369
429
  }
430
+ const prototype = Object.getPrototypeOf(value);
431
+ const objectTag = prototype && prototype !== Object.prototype ? `${prototype.constructor?.name ?? 'Object'}:` : '';
370
432
  const entries = Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([left], [right]) => left.localeCompare(right));
371
- return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`).join(',')}}`;
433
+ return `${objectTag}{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`).join(',')}}`;
372
434
  }
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "event-bus",
10
10
  "channels"
11
11
  ],
12
- "version": "1.0.1",
12
+ "version": "1.0.2",
13
13
  "private": false,
14
14
  "license": "MIT",
15
15
  "repository": {
@@ -37,8 +37,8 @@
37
37
  ],
38
38
  "dependencies": {
39
39
  "@fluojs/core": "^1.0.3",
40
- "@fluojs/di": "^1.0.3",
41
- "@fluojs/runtime": "^1.1.1"
40
+ "@fluojs/di": "^1.1.0",
41
+ "@fluojs/runtime": "^1.1.8"
42
42
  },
43
43
  "devDependencies": {
44
44
  "vitest": "^3.2.4"