@fluojs/notifications 1.0.1 → 1.0.3
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 +14 -11
- package/README.md +14 -11
- package/dist/service.d.ts +2 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +163 -32
- package/package.json +6 -5
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) 알림 오케스트레이션 패키지입니다. 알림 채널의 공통 계약을 고정하고,
|
|
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({
|
|
@@ -116,19 +118,19 @@ NotificationsModule.forRoot({
|
|
|
116
118
|
|
|
117
119
|
Behavioral contract 메모:
|
|
118
120
|
|
|
119
|
-
- 알림 개수가 `bulkThreshold` 이상이면 대량 큐 위임이
|
|
121
|
+
- 알림 개수가 `bulkThreshold` 이상이면 대량 큐 위임이 시작되며, `dispatchMany(..., { queue: true })`는 batch가 threshold보다 작아도 queue-backed delivery를 명시적으로 강제합니다.
|
|
120
122
|
- `dispatch()`는 queue adapter가 구성되어 있어도 기본적으로 직접 전달을 유지합니다. 단건 알림을 큐로 보내려면 `dispatch(..., { queue: true })`를 사용합니다.
|
|
121
123
|
- queue adapter가 있어도 직접 전달을 강제하려면 `dispatch(..., { queue: false })`를 사용합니다.
|
|
122
|
-
- 큐 기반 전달은 단건 dispatch에서는 opt-in이고, `dispatchMany(...)`에서는 threshold 기반으로 동작합니다.
|
|
123
|
-
- Queue job에는 `notification.id`가 있으면 그 값을, 없으면 notification envelope에서 파생한 deterministic `id` idempotency key가 포함됩니다. Queue adapter는 deduplication을 지원하는 backing queue에 이 값을 전달해야 합니다.
|
|
124
|
+
- 큐 기반 전달은 단건 dispatch에서는 opt-in이고, `dispatchMany(...)`에서는 caller가 `{ queue: true }`를 명시하지 않는 한 threshold 기반으로 동작합니다.
|
|
125
|
+
- Queue job에는 `notification.id`가 있으면 그 값을, 없으면 notification envelope의 runtime-neutral serialization에서 파생한 deterministic fallback `id` idempotency key가 포함됩니다. Queue adapter는 deduplication을 지원하는 backing queue에 이 값을 전달해야 합니다. 생성된 fallback key는 cyclic opaque payload를 포함해 동등한 지원 입력에 대해 deterministic하지만, cross-release durable identity 계약은 아닙니다. 애플리케이션 또는 package upgrade를 넘어 안정적인 identity가 필요하면 `notification.id`를 설정하세요.
|
|
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({
|
|
@@ -151,7 +153,7 @@ NotificationsModule.forRoot({
|
|
|
151
153
|
- `notification.dispatch.delivered`
|
|
152
154
|
- `notification.dispatch.failed`
|
|
153
155
|
|
|
154
|
-
`events.publisher`가 구성되어 있으면 `publishLifecycleEvents: false`를 설정하지 않는 한 lifecycle event publication은 기본으로 켜집니다. 채널 delivery가 `externalId`를 생략하면 시간이나 난수에 의존하지
|
|
156
|
+
`events.publisher`가 구성되어 있으면 `publishLifecycleEvents: false`를 설정하지 않는 한 lifecycle event publication은 기본으로 켜집니다. 채널 delivery가 `externalId`를 생략하면 시간이나 난수에 의존하지 않는 deterministic fallback delivery id가 부여되어 dispatch result가 호출자에게 안정적으로 유지됩니다. 생성된 fallback id는 현재 envelope shape를 위한 runtime-neutral key이지, 문서화된 full-payload hash 계약이 아닙니다. release를 넘어 durable identity가 필요하면 `notification.id`를 설정하세요. 채널 해석 실패는 `NotificationChannelNotFoundError`를 던지기 전에 `requested` 이후 `failed` 이벤트를 발행하며, 이는 영구적인 구성 오류로 취급해야 합니다. Queue enqueue와 provider delivery 실패도 `failed` 이벤트를 발행하지만, retry 여부는 underlying adapter/provider error를 기준으로 분류해야 합니다. 성공 경로 lifecycle event의 publication failure는 이미 전달된 알림을 애플리케이션 실패로 바꾸지 않도록 best-effort로 유지됩니다. `notification.dispatch.failed` publication failure는 원래 dispatch error와 publisher error를 모두 포함하는 `AggregateError`로 호출자에게 드러나므로 failed-event 보장이 조용히 약해지지 않습니다.
|
|
155
157
|
|
|
156
158
|
### 의도적인 제한 사항
|
|
157
159
|
|
|
@@ -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의 일부입니다.
|
|
@@ -207,8 +210,8 @@ foundation 패키지는 의도적으로 다음을 **포함하지 않습니다**:
|
|
|
207
210
|
- `NotificationChannelNotFoundError`
|
|
208
211
|
- `NotificationQueueNotConfiguredError`
|
|
209
212
|
|
|
210
|
-
상태 snapshot은 platform diagnostics를 위해 `
|
|
211
|
-
Queue adapter가 구성되면 `details.dependencies`에 `notifications.queue-adapter`가 포함되고, lifecycle event가 event publisher를 통해 발행되면 `notifications.event-publisher`가 포함됩니다. 이러한 선택적 통합은 `ownership.externallyManaged: true`로 표시되지만, foundation 패키지가 concrete queue 또는 event-bus 리소스를
|
|
213
|
+
상태 snapshot은 platform diagnostics를 위해 `readiness`, `health`, `ownership`, 그리고 `details` object를 포함합니다.
|
|
214
|
+
`operationMode`, `dependencies`, `bulkQueueThreshold`, `queueConfigured`, `eventPublisherConfigured`는 `details` 아래에 있으며 top-level snapshot field가 아닙니다. 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
|
|
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({
|
|
@@ -116,19 +118,19 @@ NotificationsModule.forRoot({
|
|
|
116
118
|
|
|
117
119
|
Behavioral contract notes:
|
|
118
120
|
|
|
119
|
-
- Bulk queue delegation starts when the notification count reaches `bulkThreshold
|
|
121
|
+
- Bulk queue delegation starts when the notification count reaches `bulkThreshold`, and `dispatchMany(..., { queue: true })` explicitly forces queue-backed delivery even when the batch is below that threshold.
|
|
120
122
|
- `dispatch()` stays direct by default even when a queue adapter is configured. Use `dispatch(..., { queue: true })` to opt one single notification into queue-backed delivery.
|
|
121
123
|
- Use `dispatch(..., { queue: false })` to force direct delivery even when a queue adapter exists.
|
|
122
|
-
- Queue-backed delivery is opt-in for single dispatch and threshold-driven for `dispatchMany(...)`.
|
|
123
|
-
- Queue jobs include a deterministic `id` idempotency key derived from `notification.id` when present, otherwise from the notification envelope. Queue adapters should pass this value to backing queues that support deduplication.
|
|
124
|
+
- Queue-backed delivery is opt-in for single dispatch and threshold-driven for `dispatchMany(...)` unless the caller explicitly passes `{ queue: true }`.
|
|
125
|
+
- Queue jobs include a deterministic fallback `id` idempotency key derived from `notification.id` when present, otherwise from a runtime-neutral serialization of the notification envelope. Queue adapters should pass this value to backing queues that support deduplication. The generated fallback key is deterministic for equivalent supported inputs, including cyclic opaque payloads, but it is not a durable cross-release identity contract; set `notification.id` when callers need stable identity across application or package upgrades.
|
|
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({
|
|
@@ -151,7 +153,7 @@ Published event names:
|
|
|
151
153
|
- `notification.dispatch.delivered`
|
|
152
154
|
- `notification.dispatch.failed`
|
|
153
155
|
|
|
154
|
-
If `events.publisher` is configured, lifecycle event publication defaults to on unless `publishLifecycleEvents: false` is set. Channel deliveries that omit `externalId` receive a deterministic fallback delivery id
|
|
156
|
+
If `events.publisher` is configured, lifecycle event publication defaults to on unless `publishLifecycleEvents: false` is set. Channel deliveries that omit `externalId` receive a deterministic fallback delivery id so dispatch results remain stable for callers without relying on time or random data. Generated fallback ids are runtime-neutral keys for the current envelope shape, not a documented full-payload hash contract; set `notification.id` when callers need durable identity across releases. Channel resolution failures publish `requested` and then `failed` events before throwing `NotificationChannelNotFoundError`; treat those failures as permanent configuration errors. Queue enqueue and provider delivery failures also publish `failed` events, but callers should classify their retry behavior from the underlying adapter/provider error. Publication failures for success-path lifecycle events remain best-effort so a delivered notification is not converted into an application failure. Publication failures for `notification.dispatch.failed` are caller-visible as `AggregateError` values that include both the original dispatch error and the publisher error so failed-event guarantees are not silently weakened.
|
|
155
157
|
|
|
156
158
|
### Intentional limitations
|
|
157
159
|
|
|
@@ -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.
|
|
@@ -207,8 +210,8 @@ These limitations are part of the package contract so leaf packages can evolve i
|
|
|
207
210
|
- `NotificationChannelNotFoundError`
|
|
208
211
|
- `NotificationQueueNotConfiguredError`
|
|
209
212
|
|
|
210
|
-
Status snapshots include `
|
|
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.
|
|
213
|
+
Status snapshots include `readiness`, `health`, `ownership`, and a `details` object for platform diagnostics.
|
|
214
|
+
`operationMode`, `dependencies`, `bulkQueueThreshold`, `queueConfigured`, and `eventPublisherConfigured` live under `details`; they are not top-level snapshot fields. 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
|
|
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
|
package/dist/service.d.ts.map
CHANGED
|
@@ -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;
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
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);
|
|
298
312
|
}
|
|
313
|
+
return publicationErrors;
|
|
299
314
|
}
|
|
300
|
-
async dispatchManyThroughSequentialQueueFallback(notifications, jobs, options) {
|
|
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.
|
|
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,26 +362,142 @@ 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}`);
|
|
353
393
|
}
|
|
354
394
|
function stableNotificationHash(notification) {
|
|
355
395
|
let hash = 0x811c9dc5;
|
|
356
|
-
const input = stableStringify(notification);
|
|
396
|
+
const input = stableStringify(notification, createStableStringifyContext());
|
|
357
397
|
for (let index = 0; index < input.length; index += 1) {
|
|
358
398
|
hash ^= input.charCodeAt(index);
|
|
359
399
|
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
360
400
|
}
|
|
361
401
|
return hash.toString(36).padStart(7, '0');
|
|
362
402
|
}
|
|
363
|
-
function
|
|
364
|
-
|
|
403
|
+
function createStableStringifyContext() {
|
|
404
|
+
return {
|
|
405
|
+
nextReferenceId: 0,
|
|
406
|
+
seen: new WeakMap()
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function enterStableObject(value, context) {
|
|
410
|
+
const existingReferenceId = context.seen.get(value);
|
|
411
|
+
if (existingReferenceId !== undefined) {
|
|
412
|
+
return existingReferenceId;
|
|
413
|
+
}
|
|
414
|
+
context.nextReferenceId += 1;
|
|
415
|
+
context.seen.set(value, context.nextReferenceId);
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
function createCollectionSortContext(parent) {
|
|
419
|
+
const context = createStableStringifyContext();
|
|
420
|
+
context.nextReferenceId = 1;
|
|
421
|
+
context.seen.set(parent, 1);
|
|
422
|
+
return context;
|
|
423
|
+
}
|
|
424
|
+
function stableCollectionSortKey(value, parent) {
|
|
425
|
+
return stableStringify(value, createCollectionSortContext(parent));
|
|
426
|
+
}
|
|
427
|
+
function compareStableString(left, right) {
|
|
428
|
+
if (left < right) {
|
|
429
|
+
return -1;
|
|
430
|
+
}
|
|
431
|
+
if (left > right) {
|
|
432
|
+
return 1;
|
|
433
|
+
}
|
|
434
|
+
return 0;
|
|
435
|
+
}
|
|
436
|
+
function stableStringify(value, context) {
|
|
437
|
+
if (value === null) {
|
|
438
|
+
return 'null';
|
|
439
|
+
}
|
|
440
|
+
if (typeof value !== 'object') {
|
|
441
|
+
if (typeof value === 'bigint') {
|
|
442
|
+
return `BigInt:${value.toString()}`;
|
|
443
|
+
}
|
|
365
444
|
return JSON.stringify(value) ?? String(value);
|
|
366
445
|
}
|
|
446
|
+
const circularReferenceId = enterStableObject(value, context);
|
|
447
|
+
if (circularReferenceId !== undefined) {
|
|
448
|
+
return `Circular:${circularReferenceId}`;
|
|
449
|
+
}
|
|
450
|
+
if (value instanceof Date) {
|
|
451
|
+
const serialized = Number.isNaN(value.getTime()) ? 'Date:Invalid' : `Date:${JSON.stringify(value.toISOString())}`;
|
|
452
|
+
context.seen.delete(value);
|
|
453
|
+
return serialized;
|
|
454
|
+
}
|
|
455
|
+
if (value instanceof URL) {
|
|
456
|
+
const serialized = `URL:${JSON.stringify(value.href)}`;
|
|
457
|
+
context.seen.delete(value);
|
|
458
|
+
return serialized;
|
|
459
|
+
}
|
|
460
|
+
if (value instanceof URLSearchParams) {
|
|
461
|
+
const serialized = `URLSearchParams:${JSON.stringify(value.toString())}`;
|
|
462
|
+
context.seen.delete(value);
|
|
463
|
+
return serialized;
|
|
464
|
+
}
|
|
465
|
+
if (value instanceof RegExp) {
|
|
466
|
+
const serialized = `RegExp:${JSON.stringify(value.source)}/${value.flags}`;
|
|
467
|
+
context.seen.delete(value);
|
|
468
|
+
return serialized;
|
|
469
|
+
}
|
|
470
|
+
if (value instanceof Map) {
|
|
471
|
+
const entries = Array.from(value.entries()).map(([key, entry]) => ({
|
|
472
|
+
entry,
|
|
473
|
+
key,
|
|
474
|
+
sortKey: `[${stableCollectionSortKey(key, value)},${stableCollectionSortKey(entry, value)}]`
|
|
475
|
+
})).sort((left, right) => compareStableString(left.sortKey, right.sortKey)).map(({
|
|
476
|
+
key,
|
|
477
|
+
entry
|
|
478
|
+
}) => `[${stableStringify(key, context)},${stableStringify(entry, context)}]`);
|
|
479
|
+
context.seen.delete(value);
|
|
480
|
+
return `Map:{${entries.join(',')}}`;
|
|
481
|
+
}
|
|
482
|
+
if (value instanceof Set) {
|
|
483
|
+
const entries = Array.from(value.values()).map(entry => ({
|
|
484
|
+
entry,
|
|
485
|
+
sortKey: stableCollectionSortKey(entry, value)
|
|
486
|
+
})).sort((left, right) => compareStableString(left.sortKey, right.sortKey)).map(({
|
|
487
|
+
entry
|
|
488
|
+
}) => stableStringify(entry, context));
|
|
489
|
+
context.seen.delete(value);
|
|
490
|
+
return `Set:[${entries.join(',')}]`;
|
|
491
|
+
}
|
|
367
492
|
if (Array.isArray(value)) {
|
|
368
|
-
|
|
493
|
+
const serialized = `[${value.map(entry => stableStringify(entry, context)).join(',')}]`;
|
|
494
|
+
context.seen.delete(value);
|
|
495
|
+
return serialized;
|
|
369
496
|
}
|
|
497
|
+
const prototype = Object.getPrototypeOf(value);
|
|
498
|
+
const objectTag = prototype && prototype !== Object.prototype ? `${prototype.constructor?.name ?? 'Object'}:` : '';
|
|
370
499
|
const entries = Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([left], [right]) => left.localeCompare(right));
|
|
371
|
-
|
|
500
|
+
const serialized = `${objectTag}{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry, context)}`).join(',')}}`;
|
|
501
|
+
context.seen.delete(value);
|
|
502
|
+
return serialized;
|
|
372
503
|
}
|
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"event-bus",
|
|
10
10
|
"channels"
|
|
11
11
|
],
|
|
12
|
-
"version": "1.0.
|
|
12
|
+
"version": "1.0.3",
|
|
13
13
|
"private": false,
|
|
14
14
|
"license": "MIT",
|
|
15
15
|
"repository": {
|
|
@@ -36,12 +36,13 @@
|
|
|
36
36
|
"dist"
|
|
37
37
|
],
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@fluojs/core": "^1.0
|
|
40
|
-
"@fluojs/di": "^
|
|
41
|
-
"@fluojs/runtime": "^
|
|
39
|
+
"@fluojs/core": "^1.1.0",
|
|
40
|
+
"@fluojs/di": "^2.0.0",
|
|
41
|
+
"@fluojs/runtime": "^2.0.1"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"vitest": "^3.2.4"
|
|
44
|
+
"vitest": "^3.2.4",
|
|
45
|
+
"@fluojs/testing": "^2.0.0"
|
|
45
46
|
},
|
|
46
47
|
"scripts": {
|
|
47
48
|
"prebuild": "node ../../tooling/scripts/clean-dist.mjs",
|