@fluojs/notifications 1.0.2 → 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 CHANGED
@@ -118,11 +118,11 @@ NotificationsModule.forRoot({
118
118
 
119
119
  Behavioral contract 메모:
120
120
 
121
- - 알림 개수가 `bulkThreshold` 이상이면 대량 큐 위임이 시작됩니다.
121
+ - 알림 개수가 `bulkThreshold` 이상이면 대량 큐 위임이 시작되며, `dispatchMany(..., { queue: true })`는 batch가 threshold보다 작아도 queue-backed delivery를 명시적으로 강제합니다.
122
122
  - `dispatch()`는 queue adapter가 구성되어 있어도 기본적으로 직접 전달을 유지합니다. 단건 알림을 큐로 보내려면 `dispatch(..., { queue: true })`를 사용합니다.
123
123
  - queue adapter가 있어도 직접 전달을 강제하려면 `dispatch(..., { queue: false })`를 사용합니다.
124
- - 큐 기반 전달은 단건 dispatch에서는 opt-in이고, `dispatchMany(...)`에서는 threshold 기반으로 동작합니다.
125
- - 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`를 설정하세요.
126
126
  - `dispatchMany(..., { continueOnError: true })`는 direct delivery 또는 순차 queue fallback enqueue에서 첫 실패를 던지는 대신 실패들을 수집합니다.
127
127
  - queue enqueue가 실패하면 서비스는 enqueue 에러를 다시 던지기 전에 결정적인 `notification.dispatch.failed` 라이프사이클 이벤트를 발행합니다. queued bulk dispatch는 queue 미구성, channel 해석, provider/adapter failure 경로를 포함해 이미 `requested`를 발행한 모든 notification에 대해 terminal `queued` 또는 `failed` 이벤트도 발행합니다.
128
128
  - `enqueueMany(...)`가 없으면 대량 queue delivery는 input order대로 각 job을 개별 enqueue하는 방식으로 fallback합니다. `continueOnError: true`이면 성공한 enqueue는 `results`에 남고 실패한 enqueue는 `failures`로 반환됩니다. 그렇지 않으면 첫 enqueue failure를 다시 던지기 전에 아직 terminal 상태가 없는 나머지 requested fallback job에 `failed` 라이프사이클 이벤트를 발행합니다.
@@ -153,7 +153,7 @@ NotificationsModule.forRoot({
153
153
  - `notification.dispatch.delivered`
154
154
  - `notification.dispatch.failed`
155
155
 
156
- `events.publisher`가 구성되어 있으면 `publishLifecycleEvents: false`를 설정하지 않는 한 lifecycle event publication은 기본으로 켜집니다. 채널 delivery가 `externalId`를 생략하면 시간이나 난수에 의존하지 않고 notification envelope에서 파생한 deterministic fallback delivery id가 부여되어 dispatch result가 호출자에게 안정적으로 유지됩니다. 채널 해석 실패는 `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 보장이 조용히 약해지지 않습니다.
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 보장이 조용히 약해지지 않습니다.
157
157
 
158
158
  ### 의도적인 제한 사항
159
159
 
@@ -210,8 +210,8 @@ foundation 패키지는 의도적으로 다음을 **포함하지 않습니다**:
210
210
  - `NotificationChannelNotFoundError`
211
211
  - `NotificationQueueNotConfiguredError`
212
212
 
213
- 상태 snapshot은 platform diagnostics를 위해 `operationMode`, dependency diagnostics, ownership, readiness, health 필드를 포함합니다.
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`를 유지합니다.
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`를 유지합니다.
215
215
 
216
216
  ## 관련 패키지
217
217
 
package/README.md CHANGED
@@ -118,11 +118,11 @@ NotificationsModule.forRoot({
118
118
 
119
119
  Behavioral contract notes:
120
120
 
121
- - 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.
122
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.
123
123
  - Use `dispatch(..., { queue: false })` to force direct delivery even when a queue adapter exists.
124
- - Queue-backed delivery is opt-in for single dispatch and threshold-driven for `dispatchMany(...)`.
125
- - 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.
126
126
  - `dispatchMany(..., { continueOnError: true })` collects failures instead of throwing on the first failed direct delivery or sequential queue fallback enqueue.
127
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.
128
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.
@@ -153,7 +153,7 @@ Published event names:
153
153
  - `notification.dispatch.delivered`
154
154
  - `notification.dispatch.failed`
155
155
 
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 derived from the notification envelope so dispatch results remain stable for callers without relying on time or random data. 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.
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.
157
157
 
158
158
  ### Intentional limitations
159
159
 
@@ -210,8 +210,8 @@ These limitations are part of the package contract so leaf packages can evolve i
210
210
  - `NotificationChannelNotFoundError`
211
211
  - `NotificationQueueNotConfiguredError`
212
212
 
213
- Status snapshots include `operationMode`, dependency diagnostics, ownership, readiness, and health fields for platform diagnostics.
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.
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.
215
215
 
216
216
  ## Related Packages
217
217
 
package/dist/service.js CHANGED
@@ -393,42 +393,111 @@ function createLifecyclePublicationFailureError(dispatchError, ...publicationErr
393
393
  }
394
394
  function stableNotificationHash(notification) {
395
395
  let hash = 0x811c9dc5;
396
- const input = stableStringify(notification);
396
+ const input = stableStringify(notification, createStableStringifyContext());
397
397
  for (let index = 0; index < input.length; index += 1) {
398
398
  hash ^= input.charCodeAt(index);
399
399
  hash = Math.imul(hash, 0x01000193) >>> 0;
400
400
  }
401
401
  return hash.toString(36).padStart(7, '0');
402
402
  }
403
- function stableStringify(value) {
404
- if (value === null || typeof value !== 'object') {
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
+ }
405
444
  return JSON.stringify(value) ?? String(value);
406
445
  }
446
+ const circularReferenceId = enterStableObject(value, context);
447
+ if (circularReferenceId !== undefined) {
448
+ return `Circular:${circularReferenceId}`;
449
+ }
407
450
  if (value instanceof Date) {
408
- return Number.isNaN(value.getTime()) ? 'Date:Invalid' : `Date:${JSON.stringify(value.toISOString())}`;
451
+ const serialized = Number.isNaN(value.getTime()) ? 'Date:Invalid' : `Date:${JSON.stringify(value.toISOString())}`;
452
+ context.seen.delete(value);
453
+ return serialized;
409
454
  }
410
455
  if (value instanceof URL) {
411
- return `URL:${JSON.stringify(value.href)}`;
456
+ const serialized = `URL:${JSON.stringify(value.href)}`;
457
+ context.seen.delete(value);
458
+ return serialized;
412
459
  }
413
460
  if (value instanceof URLSearchParams) {
414
- return `URLSearchParams:${JSON.stringify(value.toString())}`;
461
+ const serialized = `URLSearchParams:${JSON.stringify(value.toString())}`;
462
+ context.seen.delete(value);
463
+ return serialized;
415
464
  }
416
465
  if (value instanceof RegExp) {
417
- return `RegExp:${JSON.stringify(value.source)}/${value.flags}`;
466
+ const serialized = `RegExp:${JSON.stringify(value.source)}/${value.flags}`;
467
+ context.seen.delete(value);
468
+ return serialized;
418
469
  }
419
470
  if (value instanceof Map) {
420
- const entries = Array.from(value.entries()).map(([key, entry]) => `[${stableStringify(key)},${stableStringify(entry)}]`).sort();
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);
421
480
  return `Map:{${entries.join(',')}}`;
422
481
  }
423
482
  if (value instanceof Set) {
424
- const entries = Array.from(value.values()).map(entry => stableStringify(entry)).sort();
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);
425
490
  return `Set:[${entries.join(',')}]`;
426
491
  }
427
492
  if (Array.isArray(value)) {
428
- return `[${value.map(entry => stableStringify(entry)).join(',')}]`;
493
+ const serialized = `[${value.map(entry => stableStringify(entry, context)).join(',')}]`;
494
+ context.seen.delete(value);
495
+ return serialized;
429
496
  }
430
497
  const prototype = Object.getPrototypeOf(value);
431
498
  const objectTag = prototype && prototype !== Object.prototype ? `${prototype.constructor?.name ?? 'Object'}:` : '';
432
499
  const entries = Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([left], [right]) => left.localeCompare(right));
433
- return `${objectTag}{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`).join(',')}}`;
500
+ const serialized = `${objectTag}{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry, context)}`).join(',')}}`;
501
+ context.seen.delete(value);
502
+ return serialized;
434
503
  }
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "event-bus",
10
10
  "channels"
11
11
  ],
12
- "version": "1.0.2",
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.3",
40
- "@fluojs/di": "^1.1.0",
41
- "@fluojs/runtime": "^1.1.8"
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",