hierarchical-approval 2.3.0 → 2.4.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/CHANGELOG.md CHANGED
@@ -7,6 +7,47 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  _Nothing yet._
9
9
 
10
+ ## [2.4.0] - 2026-09-04
11
+
12
+ ### Added — `DigestNotificationAdapter`
13
+
14
+ - **Batches notifications per recipient instead of sending one per event.** An
15
+ approver on twenty documents received twenty separate messages a day, which is
16
+ how approval email ends up filtered into a folder nobody reads — the
17
+ notifications defeat themselves.
18
+
19
+ ```ts
20
+ import { DigestNotificationAdapter } from 'hierarchical-approval/plugins/notify';
21
+
22
+ const digest = new DigestNotificationAdapter({
23
+ intervalMs: 15 * 60_000,
24
+ send: async ({ recipient, events }) => mailer.send(recipient, summarise(events)),
25
+ });
26
+ ```
27
+
28
+ - **Urgent events still go straight through.** Batching a rejection or a
29
+ completed approval behind a digest window would make the library's own
30
+ notifications the reason a decision was late, so `approval:rejected`,
31
+ `approval:completed`, `approval:sla_breached` and `approval:expired` bypass
32
+ the buffer by default — configurable via `passthrough`.
33
+
34
+ - **`maxBatchSize`** (default 50) flushes a recipient early under a burst so the
35
+ buffer stays bounded, and flushes only *that* recipient: a burst aimed at one
36
+ person must not force everybody else's digest out early. Omit `intervalMs` to
37
+ disable the timer and drive `flush()` from a cron job or queue worker instead.
38
+
39
+ - A failed `send` is logged and swallowed, as the notification-adapter contract
40
+ requires, and the buffer is cleared before sending so a failure cannot replay
41
+ the same events into every subsequent digest.
42
+
43
+ - Buffers are in memory: a restart drops what has not been flushed. That is the
44
+ right trade for a convenience digest but not for delivery guarantees — put
45
+ `OutboxNotificationAdapter` underneath when an event must not be lost.
46
+
47
+ New exports from `hierarchical-approval/plugins/notify`:
48
+ `DigestNotificationAdapter`, `DigestNotificationAdapterOptions`, `Digest`,
49
+ `DigestSendFn`.
50
+
10
51
  ## [2.3.0] - 2026-09-04
11
52
 
12
53
  ### Added — retention
@@ -156,12 +156,16 @@ var OutboxNotificationAdapter = class {
156
156
  record.lastError = message;
157
157
  if (record.attempts >= this.maxAttempts) {
158
158
  record.status = "dead";
159
- this.logger.error("OutboxNotificationAdapter: dead-lettered after exhausting retries", err, {
160
- id: record.id,
161
- tenantId: record.tenantId,
162
- attempts: record.attempts,
163
- maxAttempts: this.maxAttempts
164
- });
159
+ this.logger.error(
160
+ "OutboxNotificationAdapter: dead-lettered after exhausting retries",
161
+ err,
162
+ {
163
+ id: record.id,
164
+ tenantId: record.tenantId,
165
+ attempts: record.attempts,
166
+ maxAttempts: this.maxAttempts
167
+ }
168
+ );
165
169
  } else {
166
170
  record.nextAttemptAt = this.clock.now().getTime() + this.computeBackoff(record.attempts);
167
171
  this.logger.warn("OutboxNotificationAdapter: delivery failed, scheduling retry", {
@@ -241,9 +245,7 @@ var CompositeNotificationAdapter = class {
241
245
  const results = await Promise.allSettled(
242
246
  // Wrap each call so a synchronous throw inside a child's notify is also
243
247
  // captured as a rejection rather than escaping the fan-out.
244
- this.children.map(
245
- (child) => Promise.resolve().then(() => child.adapter.notify(event))
246
- )
248
+ this.children.map((child) => Promise.resolve().then(() => child.adapter.notify(event)))
247
249
  );
248
250
  results.forEach((result, i) => {
249
251
  if (result.status === "rejected") {
@@ -367,7 +369,101 @@ var TemplatedNotificationAdapter = class {
367
369
  }
368
370
  };
369
371
 
372
+ // src/plugins/notify/DigestNotificationAdapter.ts
373
+ var DEFAULT_PASSTHROUGH = [
374
+ "approval:rejected",
375
+ "approval:completed",
376
+ "approval:sla_breached",
377
+ "approval:expired"
378
+ ];
379
+ var DEFAULT_MAX_BATCH_SIZE = 50;
380
+ var DigestNotificationAdapter = class {
381
+ constructor(opts) {
382
+ this.buffers = /* @__PURE__ */ new Map();
383
+ this.timer = null;
384
+ this.send = opts.send;
385
+ this.passthrough = new Set(opts.passthrough ?? DEFAULT_PASSTHROUGH);
386
+ this.maxBatchSize = opts.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
387
+ this.intervalMs = opts.intervalMs;
388
+ this.logger = opts.logger ?? noopLogger;
389
+ this.clock = opts.clock ?? systemClock;
390
+ if (this.intervalMs !== void 0) {
391
+ if (this.intervalMs <= 0) {
392
+ throw new Error("DigestNotificationAdapter: intervalMs must be a positive number.");
393
+ }
394
+ this.timer = setInterval(() => {
395
+ void this.flush().catch((err) => {
396
+ this.logger.error("DigestNotificationAdapter: scheduled flush failed", err);
397
+ });
398
+ }, this.intervalMs);
399
+ this.timer.unref?.();
400
+ }
401
+ }
402
+ /** Recipients currently holding buffered events. */
403
+ get pendingRecipients() {
404
+ return this.buffers.size;
405
+ }
406
+ async notify(event) {
407
+ if (this.passthrough.has(event.type)) {
408
+ await this.deliver({
409
+ recipient: "",
410
+ events: [event],
411
+ since: event.timestamp,
412
+ flushedAt: this.clock.now()
413
+ });
414
+ return;
415
+ }
416
+ const now = this.clock.now();
417
+ const full = [];
418
+ for (const recipient of event.recipients) {
419
+ const buffer = this.buffers.get(recipient) ?? { events: [], since: now };
420
+ buffer.events.push(event);
421
+ this.buffers.set(recipient, buffer);
422
+ if (buffer.events.length >= this.maxBatchSize) full.push(recipient);
423
+ }
424
+ for (const recipient of full) {
425
+ await this.flushRecipient(recipient);
426
+ }
427
+ }
428
+ /** Deliver every buffered digest now. Safe to call from a cron job or on shutdown. */
429
+ async flush() {
430
+ for (const recipient of [...this.buffers.keys()]) {
431
+ await this.flushRecipient(recipient);
432
+ }
433
+ }
434
+ /** Stop the timer and deliver whatever is buffered. */
435
+ async stop() {
436
+ if (this.timer !== null) {
437
+ clearInterval(this.timer);
438
+ this.timer = null;
439
+ }
440
+ await this.flush();
441
+ }
442
+ async flushRecipient(recipient) {
443
+ const buffer = this.buffers.get(recipient);
444
+ if (!buffer || buffer.events.length === 0) return;
445
+ this.buffers.delete(recipient);
446
+ await this.deliver({
447
+ recipient,
448
+ events: buffer.events,
449
+ since: buffer.since,
450
+ flushedAt: this.clock.now()
451
+ });
452
+ }
453
+ async deliver(digest) {
454
+ try {
455
+ await this.send(digest);
456
+ } catch (err) {
457
+ this.logger.error("DigestNotificationAdapter: send failed", err, {
458
+ recipient: digest.recipient,
459
+ events: digest.events.length
460
+ });
461
+ }
462
+ }
463
+ };
464
+
370
465
  exports.CompositeNotificationAdapter = CompositeNotificationAdapter;
466
+ exports.DigestNotificationAdapter = DigestNotificationAdapter;
371
467
  exports.InMemoryOutboxStore = InMemoryOutboxStore;
372
468
  exports.OutboxNotificationAdapter = OutboxNotificationAdapter;
373
469
  exports.TemplatedNotificationAdapter = TemplatedNotificationAdapter;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/Clock.ts","../../src/utils/Logger.ts","../../src/plugins/notify/InMemoryOutboxStore.ts","../../src/plugins/notify/OutboxNotificationAdapter.ts","../../src/plugins/notify/CompositeNotificationAdapter.ts","../../src/plugins/notify/TemplatedNotificationAdapter.ts"],"names":[],"mappings":"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","file":"notify.cjs","sourcesContent":["export interface Clock {\n now(): Date;\n}\n\nexport const systemClock: Clock = { now: () => new Date() };\n","export interface Logger {\n info(msg: string, context?: Record<string, unknown>): void;\n warn(msg: string, context?: Record<string, unknown>): void;\n error(msg: string, err?: unknown, context?: Record<string, unknown>): void;\n fatal(msg: string, err?: unknown, context?: Record<string, unknown>): void;\n debug(msg: string, context?: Record<string, unknown>): void;\n}\n\nexport const noopLogger: Logger = {\n info: () => {},\n warn: () => {},\n error: () => {},\n fatal: () => {},\n debug: () => {},\n};\n","import type { IOutboxStore, OutboxRecord } from './IOutboxStore.js';\n\n/**\n * Default, dependency-free {@link IOutboxStore} backed by an in-process Map.\n *\n * Ordering: {@link due} and {@link pending} return records sorted by\n * `enqueuedAt` (then by id as a tiebreaker), giving FIFO best-effort within a\n * single `(tenant, instance)` partition. There is no cross-partition ordering\n * guarantee.\n *\n * Records are stored by reference; the adapter mutates and writes them back via\n * {@link update}, so reads reflect the latest state. This is intentional for the\n * in-memory case — a remote store would serialize instead.\n */\nexport class InMemoryOutboxStore implements IOutboxStore {\n private readonly records = new Map<string, OutboxRecord>();\n\n async enqueue(record: OutboxRecord): Promise<void> {\n this.records.set(record.id, record);\n }\n\n async due(now: number): Promise<OutboxRecord[]> {\n return this.sorted().filter((r) => r.status === 'pending' && r.nextAttemptAt <= now);\n }\n\n async update(record: OutboxRecord): Promise<void> {\n // Only persist if the record is still tracked (not removed concurrently).\n if (this.records.has(record.id)) {\n this.records.set(record.id, record);\n }\n }\n\n async remove(id: string): Promise<void> {\n this.records.delete(id);\n }\n\n async pending(): Promise<OutboxRecord[]> {\n return this.sorted().filter((r) => r.status === 'pending');\n }\n\n async deadLettered(): Promise<OutboxRecord[]> {\n return this.sorted().filter((r) => r.status === 'dead');\n }\n\n /** Test/ops helper — total records currently retained (pending + dead). */\n get size(): number {\n return this.records.size;\n }\n\n private sorted(): OutboxRecord[] {\n return [...this.records.values()].sort((a, b) =>\n a.enqueuedAt !== b.enqueuedAt ? a.enqueuedAt - b.enqueuedAt : a.id.localeCompare(b.id),\n );\n }\n}\n","import type { Clock } from '../../utils/Clock.js';\nimport { systemClock } from '../../utils/Clock.js';\nimport type { Logger } from '../../utils/Logger.js';\nimport { noopLogger } from '../../utils/Logger.js';\nimport type { INotificationAdapter, NotificationEvent } from '../../adapters/INotificationAdapter.js';\nimport type { IOutboxStore, OutboxRecord } from './IOutboxStore.js';\nimport { InMemoryOutboxStore } from './InMemoryOutboxStore.js';\n\n/**\n * Transport that performs the actual side-effecting delivery of a single event\n * (send an email, post to a queue, call a webhook, …).\n *\n * It MAY throw synchronously or reject asynchronously — both are treated\n * identically as a failed attempt and trigger a retry. A normal resolution\n * counts as a successful delivery.\n */\nexport type NotificationTransport = (event: NotificationEvent) => void | Promise<void>;\n\n/** Configuration for {@link OutboxNotificationAdapter}. All fields optional except `transport`. */\nexport interface OutboxNotificationAdapterOptions {\n /** Side-effecting delivery function. Required. */\n transport: NotificationTransport;\n /** Persistence for queued events. Defaults to an {@link InMemoryOutboxStore}. */\n store?: IOutboxStore;\n /** Time source. Defaults to {@link systemClock}. Inject a manual clock for deterministic tests. */\n clock?: Clock;\n /** Structured logger. Defaults to {@link noopLogger}. */\n logger?: Logger;\n /**\n * Maximum delivery attempts before an event is dead-lettered. Must be >= 1.\n * `1` means no retries (single failure → dead-letter). Defaults to `5`.\n */\n maxAttempts?: number;\n /** Base backoff in milliseconds for the first retry. Defaults to `1000`. */\n baseDelayMs?: number;\n /** Multiplier applied per attempt (exponential). Defaults to `2`. */\n backoffFactor?: number;\n /**\n * Upper bound on a single backoff delay, in milliseconds. Caps the schedule so\n * very high attempt counts never overflow to `Infinity`/negative. Defaults to\n * `5 * 60_000` (5 minutes).\n */\n maxDelayMs?: number;\n /** Monotonic id generator for records. Defaults to a counter + timestamp. */\n idGenerator?: () => string;\n}\n\n/**\n * Reliable, store-and-forward {@link INotificationAdapter}.\n *\n * `notify()` only enqueues the event into a pluggable outbox store and returns;\n * it never throws (enqueue failures are caught, logged, and swallowed). Actual\n * delivery happens in {@link drain}, which is driven by ops (a poller/cron) or\n * tests. Delivery retries on failure with deterministic exponential backoff\n * computed from the injected {@link Clock}; on exhausting `maxAttempts` the\n * record is moved to a dead-letter list rather than dropped.\n *\n * Ordering: within a single `(tenantId, instanceId)` partition delivery is FIFO\n * best-effort (oldest-enqueued due record first). There is no ordering guarantee\n * across partitions, and a record awaiting a future retry does not block later\n * records in the same partition from being attempted.\n *\n * Drop-in for `ApprovalEngineOptions.notificationAdapter` with no engine change.\n */\nexport class OutboxNotificationAdapter implements INotificationAdapter {\n private readonly transport: NotificationTransport;\n private readonly store: IOutboxStore;\n private readonly clock: Clock;\n private readonly logger: Logger;\n private readonly maxAttempts: number;\n private readonly baseDelayMs: number;\n private readonly backoffFactor: number;\n private readonly maxDelayMs: number;\n private readonly idGenerator: () => string;\n private seq = 0;\n\n /** Guards against concurrent {@link drain} runs causing double-delivery. */\n private draining: Promise<number> | null = null;\n\n constructor(options: OutboxNotificationAdapterOptions) {\n this.transport = options.transport;\n this.store = options.store ?? new InMemoryOutboxStore();\n this.clock = options.clock ?? systemClock;\n this.logger = options.logger ?? noopLogger;\n this.maxAttempts = Math.max(1, Math.floor(options.maxAttempts ?? 5));\n this.baseDelayMs = Math.max(0, options.baseDelayMs ?? 1000);\n this.backoffFactor = options.backoffFactor ?? 2;\n this.maxDelayMs = Math.max(0, options.maxDelayMs ?? 5 * 60_000);\n this.idGenerator = options.idGenerator ?? (() => `${this.clock.now().getTime()}-${this.seq++}`);\n }\n\n /**\n * Enqueue an event for reliable delivery. Never throws: a failure to persist\n * is logged and swallowed so the engine's emit path is never broken.\n */\n async notify(event: NotificationEvent): Promise<void> {\n try {\n const now = this.clock.now().getTime();\n const record: OutboxRecord = {\n id: this.idGenerator(),\n partitionKey: `${event.tenantId}:${event.instanceId}`,\n tenantId: event.tenantId,\n event,\n status: 'pending',\n attempts: 0,\n nextAttemptAt: now,\n enqueuedAt: now,\n };\n await this.store.enqueue(record);\n } catch (err) {\n this.logger.error('OutboxNotificationAdapter: failed to enqueue event', err, {\n type: event.type,\n instanceId: event.instanceId,\n tenantId: event.tenantId,\n });\n }\n }\n\n /**\n * Attempt delivery of all currently due-and-pending records.\n *\n * Idempotent and safe to call repeatedly and concurrently: if a drain is\n * already in flight, the same promise is returned rather than starting a\n * second pass, so a delivered event is never delivered twice beyond\n * at-least-once semantics. Records whose `nextAttemptAt` is in the future are\n * not attempted prematurely. Never throws — store/transport errors are caught\n * and logged.\n *\n * @returns the number of records successfully delivered in this pass.\n */\n async drain(): Promise<number> {\n if (this.draining) return this.draining;\n this.draining = this.runDrain();\n try {\n return await this.draining;\n } finally {\n this.draining = null;\n }\n }\n\n private async runDrain(): Promise<number> {\n let delivered = 0;\n let due: OutboxRecord[];\n try {\n due = await this.store.due(this.clock.now().getTime());\n } catch (err) {\n this.logger.error('OutboxNotificationAdapter: failed to read due records', err);\n return 0;\n }\n\n for (const record of due) {\n // Re-check status defensively in case the store handed back a stale row.\n if (record.status !== 'pending') continue;\n const ok = await this.attemptDelivery(record);\n if (ok) delivered++;\n }\n return delivered;\n }\n\n /** Run one delivery attempt for a record and persist the resulting state. */\n private async attemptDelivery(record: OutboxRecord): Promise<boolean> {\n record.attempts++;\n try {\n // Await covers both async rejection and a returned promise; the try also\n // catches a synchronous throw from the transport.\n await this.transport(record.event);\n try {\n await this.store.remove(record.id);\n } catch (err) {\n // Delivery succeeded but cleanup failed: log. At-least-once means a\n // future drain may redeliver — acceptable and documented.\n this.logger.error('OutboxNotificationAdapter: delivered but failed to remove record', err, {\n id: record.id,\n tenantId: record.tenantId,\n });\n }\n this.logger.debug('OutboxNotificationAdapter: delivered', {\n id: record.id,\n type: record.event.type,\n attempts: record.attempts,\n });\n return true;\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n record.lastError = message;\n if (record.attempts >= this.maxAttempts) {\n record.status = 'dead';\n this.logger.error('OutboxNotificationAdapter: dead-lettered after exhausting retries', err, {\n id: record.id,\n tenantId: record.tenantId,\n attempts: record.attempts,\n maxAttempts: this.maxAttempts,\n });\n } else {\n record.nextAttemptAt = this.clock.now().getTime() + this.computeBackoff(record.attempts);\n this.logger.warn('OutboxNotificationAdapter: delivery failed, scheduling retry', {\n id: record.id,\n tenantId: record.tenantId,\n attempts: record.attempts,\n nextAttemptAt: record.nextAttemptAt,\n error: message,\n });\n }\n try {\n await this.store.update(record);\n } catch (updateErr) {\n this.logger.error('OutboxNotificationAdapter: failed to persist record state', updateErr, {\n id: record.id,\n });\n }\n return false;\n }\n }\n\n /**\n * Deterministic exponential backoff for the Nth attempt (1-based), capped at\n * `maxDelayMs`. Guards against overflow: a non-finite intermediate value\n * collapses to the cap, so very high attempt counts never yield\n * `Infinity`/`NaN`/negative delays.\n */\n private computeBackoff(attempt: number): number {\n const raw = this.baseDelayMs * Math.pow(this.backoffFactor, attempt - 1);\n if (!Number.isFinite(raw) || raw < 0) return this.maxDelayMs;\n return Math.min(raw, this.maxDelayMs);\n }\n\n /**\n * Records still awaiting first delivery or a retry. Exposed for ops dashboards\n * and tests so a stuck transport (growing pending list) is observable.\n */\n async pending(): Promise<OutboxRecord[]> {\n try {\n return await this.store.pending();\n } catch (err) {\n this.logger.error('OutboxNotificationAdapter: failed to read pending records', err);\n return [];\n }\n }\n\n /**\n * Records that exhausted all retries. Exposed so ops can detect and replay a\n * stuck transport; the list is never silently dropped/truncated.\n */\n async deadLettered(): Promise<OutboxRecord[]> {\n try {\n return await this.store.deadLettered();\n } catch (err) {\n this.logger.error('OutboxNotificationAdapter: failed to read dead-lettered records', err);\n return [];\n }\n }\n}\n","import type { Logger } from '../../utils/Logger.js';\nimport { noopLogger } from '../../utils/Logger.js';\nimport type { INotificationAdapter, NotificationEvent } from '../../adapters/INotificationAdapter.js';\n\n/** A child adapter paired with a stable name for diagnostics/logging. */\nexport interface NamedNotificationChild {\n /** Human-readable identity used when logging this child's failures. */\n name: string;\n adapter: INotificationAdapter;\n}\n\n/** Either a bare adapter or a {@link NamedNotificationChild}. */\nexport type CompositeChild = INotificationAdapter | NamedNotificationChild;\n\n/** Configuration for {@link CompositeNotificationAdapter}. */\nexport interface CompositeNotificationAdapterOptions {\n /** Child adapters to fan out to. May be empty (resolves as a no-op). */\n children: CompositeChild[];\n /** Structured logger. Defaults to {@link noopLogger}. */\n logger?: Logger;\n}\n\nfunction isNamed(child: CompositeChild): child is NamedNotificationChild {\n // A real bare adapter always exposes its own notify(); never treat it as a\n // NamedNotificationChild even if it happens to also carry an `adapter` property.\n if (typeof (child as INotificationAdapter).notify === 'function') return false;\n // Otherwise it's a NamedNotificationChild only if it wraps a non-null `adapter`\n // object whose own `.notify` is callable.\n const adapter = (child as NamedNotificationChild).adapter as unknown;\n return (\n typeof adapter === 'object' &&\n adapter !== null &&\n typeof (adapter as INotificationAdapter).notify === 'function'\n );\n}\n\n/**\n * Fans a single {@link notify} call out to N child {@link INotificationAdapter}s\n * concurrently.\n *\n * Uses `Promise.allSettled` so one failing/slow child never blocks delivery to\n * the others. Never throws: every child rejection is collected and logged with\n * that child's identity. Zero children resolves immediately as a no-op.\n *\n * Drop-in for `ApprovalEngineOptions.notificationAdapter`.\n */\nexport class CompositeNotificationAdapter implements INotificationAdapter {\n private readonly children: NamedNotificationChild[];\n private readonly logger: Logger;\n\n constructor(options: CompositeNotificationAdapterOptions) {\n this.children = options.children.map((child, i) =>\n isNamed(child) ? child : { name: `child[${i}]`, adapter: child },\n );\n this.logger = options.logger ?? noopLogger;\n }\n\n /**\n * Deliver the event to every child concurrently. Resolves once all children\n * settle; never rejects.\n */\n async notify(event: NotificationEvent): Promise<void> {\n if (this.children.length === 0) return;\n\n const results = await Promise.allSettled(\n // Wrap each call so a synchronous throw inside a child's notify is also\n // captured as a rejection rather than escaping the fan-out.\n this.children.map((child) =>\n Promise.resolve().then(() => child.adapter.notify(event)),\n ),\n );\n\n results.forEach((result, i) => {\n if (result.status === 'rejected') {\n const child = this.children[i]!;\n this.logger.error('CompositeNotificationAdapter: child failed to notify', result.reason, {\n child: child.name,\n type: event.type,\n instanceId: event.instanceId,\n tenantId: event.tenantId,\n });\n }\n });\n }\n}\n","import type { Logger } from '../../utils/Logger.js';\nimport { noopLogger } from '../../utils/Logger.js';\nimport type { ApprovalEventName } from '../../types/events.js';\nimport type { INotificationAdapter, NotificationEvent } from '../../adapters/INotificationAdapter.js';\n\n/** The fully rendered, channel-ready message handed to the send fn. */\nexport interface RenderedNotification {\n /** Logical delivery channel (e.g. 'email', 'slack', 'sms'). */\n channel: string;\n /** Resolved recipient address(es) for the channel. */\n to: string[];\n /** Short headline / email subject. */\n subject: string;\n /** Human-readable body. */\n body: string;\n}\n\n/** The shape a template function returns (channel/to are derived separately). */\nexport interface RenderedMessage {\n subject: string;\n body: string;\n}\n\n/**\n * A template entry for one {@link ApprovalEventName}. Either:\n * - a function `(event) => { subject, body }`, for full programmatic control, or\n * - a `{ subject, body }` pair of strings with `{placeholder}` tokens that are\n * interpolated from the event and its payload.\n */\nexport type NotificationTemplate =\n | ((event: NotificationEvent) => RenderedMessage)\n | { subject: string; body: string };\n\n/** Map of event name → template. Any subset of events may be configured. */\nexport type TemplateMap = Partial<Record<ApprovalEventName, NotificationTemplate>>;\n\n/** Side-effecting send function the adapter forwards rendered messages to. */\nexport type SendFn = (message: RenderedNotification) => void | Promise<void>;\n\n/** Configuration for {@link TemplatedNotificationAdapter}. */\nexport interface TemplatedNotificationAdapterOptions {\n /** Side-effecting send function. Required. */\n send: SendFn;\n /** Per-event templates. Events without an entry use {@link fallbackTemplate} (if any). */\n templates?: TemplateMap;\n /**\n * Template used when no per-event entry exists. If omitted, events without a\n * template are skipped (logged) rather than throwing. Set to a function or a\n * `{subject, body}` string pair to guarantee a message for every event.\n */\n fallbackTemplate?: NotificationTemplate;\n /**\n * Derive the channel for an event. Defaults to the constant `'default'`.\n */\n channelFor?: (event: NotificationEvent) => string;\n /**\n * Derive recipient address(es). Defaults to `event.recipients`. When this\n * returns an empty array the adapter falls back to {@link defaultRecipients}\n * (if set) and otherwise skips the send gracefully.\n */\n recipientsFor?: (event: NotificationEvent) => string[];\n /**\n * Recipients used when {@link recipientsFor} yields none (e.g. cancelled /\n * expired / sla_breached events carry empty `recipients`). If also empty the\n * send is skipped rather than dispatched to nobody.\n */\n defaultRecipients?: string[];\n /**\n * Token substituted for a `{placeholder}` that resolves to `undefined`/`null`\n * or references a field absent on the payload. Defaults to `''` (empty\n * string). Interpolation never throws on unknown placeholders.\n */\n unknownPlaceholderToken?: string;\n /** Structured logger. Defaults to {@link noopLogger}. */\n logger?: Logger;\n}\n\nconst PLACEHOLDER = /\\{([^{}]+)\\}/g;\n\n/**\n * Renders a human-readable message per {@link ApprovalEventName} from a\n * configurable template map and forwards `{ channel, to, subject, body }` to an\n * injected send function.\n *\n * Templates are resolved by event name; a missing template falls back to\n * `fallbackTemplate` or — if none is configured — the event is skipped (logged)\n * rather than throwing. String templates support `{placeholder}` interpolation\n * pulled from top-level event fields and `event.payload` fields; an unknown or\n * absent field renders to `unknownPlaceholderToken` and never throws.\n *\n * `notify()` never throws — send errors and any rendering issues are caught and\n * logged. Drop-in for `ApprovalEngineOptions.notificationAdapter`.\n */\nexport class TemplatedNotificationAdapter implements INotificationAdapter {\n private readonly send: SendFn;\n private readonly templates: TemplateMap;\n private readonly fallbackTemplate?: NotificationTemplate;\n private readonly channelFor: (event: NotificationEvent) => string;\n private readonly recipientsFor: (event: NotificationEvent) => string[];\n private readonly defaultRecipients: string[];\n private readonly unknownPlaceholderToken: string;\n private readonly logger: Logger;\n\n constructor(options: TemplatedNotificationAdapterOptions) {\n this.send = options.send;\n this.templates = options.templates ?? {};\n this.fallbackTemplate = options.fallbackTemplate;\n this.channelFor = options.channelFor ?? (() => 'default');\n this.recipientsFor = options.recipientsFor ?? ((event) => event.recipients ?? []);\n this.defaultRecipients = options.defaultRecipients ?? [];\n this.unknownPlaceholderToken = options.unknownPlaceholderToken ?? '';\n this.logger = options.logger ?? noopLogger;\n }\n\n /**\n * Render and dispatch the event. Never throws: missing templates, empty\n * recipients, and send failures are all handled and logged.\n */\n async notify(event: NotificationEvent): Promise<void> {\n try {\n const template = this.templates[event.type] ?? this.fallbackTemplate;\n if (!template) {\n this.logger.debug('TemplatedNotificationAdapter: no template for event, skipping', {\n type: event.type,\n instanceId: event.instanceId,\n });\n return;\n }\n\n const to = this.resolveRecipients(event);\n if (to.length === 0) {\n this.logger.debug('TemplatedNotificationAdapter: no recipients for event, skipping', {\n type: event.type,\n instanceId: event.instanceId,\n });\n return;\n }\n\n const rendered = this.render(template, event);\n const message: RenderedNotification = {\n channel: this.channelFor(event),\n to,\n subject: rendered.subject,\n body: rendered.body,\n };\n\n await this.send(message);\n } catch (err) {\n this.logger.error('TemplatedNotificationAdapter: failed to render/send notification', err, {\n type: event.type,\n instanceId: event.instanceId,\n tenantId: event.tenantId,\n });\n }\n }\n\n private resolveRecipients(event: NotificationEvent): string[] {\n const derived = this.recipientsFor(event);\n if (derived.length > 0) return derived;\n return this.defaultRecipients;\n }\n\n private render(template: NotificationTemplate, event: NotificationEvent): RenderedMessage {\n if (typeof template === 'function') {\n return template(event);\n }\n return {\n subject: this.interpolate(template.subject, event),\n body: this.interpolate(template.body, event),\n };\n }\n\n /**\n * Replace `{token}` occurrences in `text`. A token is resolved against\n * top-level event fields first, then `event.payload`. Dotted paths\n * (`payload.level`, `a.b.c`) are supported. Anything unresolved renders to\n * `unknownPlaceholderToken`. Never throws.\n */\n private interpolate(text: string, event: NotificationEvent): string {\n return text.replace(PLACEHOLDER, (_match, rawKey: string) => {\n const key = rawKey.trim();\n const value = this.lookup(event, key);\n if (value === undefined || value === null) return this.unknownPlaceholderToken;\n return this.stringify(value);\n });\n }\n\n /** Resolve a (possibly dotted) key against the event then its payload. */\n private lookup(event: NotificationEvent, key: string): unknown {\n const fromEvent = this.dig(event as unknown as Record<string, unknown>, key);\n if (fromEvent !== undefined) return fromEvent;\n return this.dig(event.payload as unknown as Record<string, unknown>, key);\n }\n\n private dig(root: Record<string, unknown> | undefined, path: string): unknown {\n if (!root) return undefined;\n let current: unknown = root;\n for (const segment of path.split('.')) {\n if (current === null || typeof current !== 'object') return undefined;\n current = (current as Record<string, unknown>)[segment];\n }\n return current;\n }\n\n private stringify(value: unknown): string {\n if (value instanceof Date) return value.toISOString();\n if (Array.isArray(value)) return value.map((v) => this.stringify(v)).join(', ');\n if (typeof value === 'object') {\n try {\n return JSON.stringify(value);\n } catch {\n return this.unknownPlaceholderToken;\n }\n }\n return String(value);\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/Clock.ts","../../src/utils/Logger.ts","../../src/plugins/notify/InMemoryOutboxStore.ts","../../src/plugins/notify/OutboxNotificationAdapter.ts","../../src/plugins/notify/CompositeNotificationAdapter.ts","../../src/plugins/notify/TemplatedNotificationAdapter.ts","../../src/plugins/notify/DigestNotificationAdapter.ts"],"names":[],"mappings":"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","file":"notify.cjs","sourcesContent":["export interface Clock {\n now(): Date;\n}\n\nexport const systemClock: Clock = { now: () => new Date() };\n","export interface Logger {\n info(msg: string, context?: Record<string, unknown>): void;\n warn(msg: string, context?: Record<string, unknown>): void;\n error(msg: string, err?: unknown, context?: Record<string, unknown>): void;\n fatal(msg: string, err?: unknown, context?: Record<string, unknown>): void;\n debug(msg: string, context?: Record<string, unknown>): void;\n}\n\nexport const noopLogger: Logger = {\n info: () => {},\n warn: () => {},\n error: () => {},\n fatal: () => {},\n debug: () => {},\n};\n","import type { IOutboxStore, OutboxRecord } from './IOutboxStore.js';\n\n/**\n * Default, dependency-free {@link IOutboxStore} backed by an in-process Map.\n *\n * Ordering: {@link due} and {@link pending} return records sorted by\n * `enqueuedAt` (then by id as a tiebreaker), giving FIFO best-effort within a\n * single `(tenant, instance)` partition. There is no cross-partition ordering\n * guarantee.\n *\n * Records are stored by reference; the adapter mutates and writes them back via\n * {@link update}, so reads reflect the latest state. This is intentional for the\n * in-memory case — a remote store would serialize instead.\n */\nexport class InMemoryOutboxStore implements IOutboxStore {\n private readonly records = new Map<string, OutboxRecord>();\n\n async enqueue(record: OutboxRecord): Promise<void> {\n this.records.set(record.id, record);\n }\n\n async due(now: number): Promise<OutboxRecord[]> {\n return this.sorted().filter((r) => r.status === 'pending' && r.nextAttemptAt <= now);\n }\n\n async update(record: OutboxRecord): Promise<void> {\n // Only persist if the record is still tracked (not removed concurrently).\n if (this.records.has(record.id)) {\n this.records.set(record.id, record);\n }\n }\n\n async remove(id: string): Promise<void> {\n this.records.delete(id);\n }\n\n async pending(): Promise<OutboxRecord[]> {\n return this.sorted().filter((r) => r.status === 'pending');\n }\n\n async deadLettered(): Promise<OutboxRecord[]> {\n return this.sorted().filter((r) => r.status === 'dead');\n }\n\n /** Test/ops helper — total records currently retained (pending + dead). */\n get size(): number {\n return this.records.size;\n }\n\n private sorted(): OutboxRecord[] {\n return [...this.records.values()].sort((a, b) =>\n a.enqueuedAt !== b.enqueuedAt ? a.enqueuedAt - b.enqueuedAt : a.id.localeCompare(b.id),\n );\n }\n}\n","import type { Clock } from '../../utils/Clock.js';\nimport { systemClock } from '../../utils/Clock.js';\nimport type { Logger } from '../../utils/Logger.js';\nimport { noopLogger } from '../../utils/Logger.js';\nimport type {\n INotificationAdapter,\n NotificationEvent,\n} from '../../adapters/INotificationAdapter.js';\nimport type { IOutboxStore, OutboxRecord } from './IOutboxStore.js';\nimport { InMemoryOutboxStore } from './InMemoryOutboxStore.js';\n\n/**\n * Transport that performs the actual side-effecting delivery of a single event\n * (send an email, post to a queue, call a webhook, …).\n *\n * It MAY throw synchronously or reject asynchronously — both are treated\n * identically as a failed attempt and trigger a retry. A normal resolution\n * counts as a successful delivery.\n */\nexport type NotificationTransport = (event: NotificationEvent) => void | Promise<void>;\n\n/** Configuration for {@link OutboxNotificationAdapter}. All fields optional except `transport`. */\nexport interface OutboxNotificationAdapterOptions {\n /** Side-effecting delivery function. Required. */\n transport: NotificationTransport;\n /** Persistence for queued events. Defaults to an {@link InMemoryOutboxStore}. */\n store?: IOutboxStore;\n /** Time source. Defaults to {@link systemClock}. Inject a manual clock for deterministic tests. */\n clock?: Clock;\n /** Structured logger. Defaults to {@link noopLogger}. */\n logger?: Logger;\n /**\n * Maximum delivery attempts before an event is dead-lettered. Must be >= 1.\n * `1` means no retries (single failure → dead-letter). Defaults to `5`.\n */\n maxAttempts?: number;\n /** Base backoff in milliseconds for the first retry. Defaults to `1000`. */\n baseDelayMs?: number;\n /** Multiplier applied per attempt (exponential). Defaults to `2`. */\n backoffFactor?: number;\n /**\n * Upper bound on a single backoff delay, in milliseconds. Caps the schedule so\n * very high attempt counts never overflow to `Infinity`/negative. Defaults to\n * `5 * 60_000` (5 minutes).\n */\n maxDelayMs?: number;\n /** Monotonic id generator for records. Defaults to a counter + timestamp. */\n idGenerator?: () => string;\n}\n\n/**\n * Reliable, store-and-forward {@link INotificationAdapter}.\n *\n * `notify()` only enqueues the event into a pluggable outbox store and returns;\n * it never throws (enqueue failures are caught, logged, and swallowed). Actual\n * delivery happens in {@link drain}, which is driven by ops (a poller/cron) or\n * tests. Delivery retries on failure with deterministic exponential backoff\n * computed from the injected {@link Clock}; on exhausting `maxAttempts` the\n * record is moved to a dead-letter list rather than dropped.\n *\n * Ordering: within a single `(tenantId, instanceId)` partition delivery is FIFO\n * best-effort (oldest-enqueued due record first). There is no ordering guarantee\n * across partitions, and a record awaiting a future retry does not block later\n * records in the same partition from being attempted.\n *\n * Drop-in for `ApprovalEngineOptions.notificationAdapter` with no engine change.\n */\nexport class OutboxNotificationAdapter implements INotificationAdapter {\n private readonly transport: NotificationTransport;\n private readonly store: IOutboxStore;\n private readonly clock: Clock;\n private readonly logger: Logger;\n private readonly maxAttempts: number;\n private readonly baseDelayMs: number;\n private readonly backoffFactor: number;\n private readonly maxDelayMs: number;\n private readonly idGenerator: () => string;\n private seq = 0;\n\n /** Guards against concurrent {@link drain} runs causing double-delivery. */\n private draining: Promise<number> | null = null;\n\n constructor(options: OutboxNotificationAdapterOptions) {\n this.transport = options.transport;\n this.store = options.store ?? new InMemoryOutboxStore();\n this.clock = options.clock ?? systemClock;\n this.logger = options.logger ?? noopLogger;\n this.maxAttempts = Math.max(1, Math.floor(options.maxAttempts ?? 5));\n this.baseDelayMs = Math.max(0, options.baseDelayMs ?? 1000);\n this.backoffFactor = options.backoffFactor ?? 2;\n this.maxDelayMs = Math.max(0, options.maxDelayMs ?? 5 * 60_000);\n this.idGenerator = options.idGenerator ?? (() => `${this.clock.now().getTime()}-${this.seq++}`);\n }\n\n /**\n * Enqueue an event for reliable delivery. Never throws: a failure to persist\n * is logged and swallowed so the engine's emit path is never broken.\n */\n async notify(event: NotificationEvent): Promise<void> {\n try {\n const now = this.clock.now().getTime();\n const record: OutboxRecord = {\n id: this.idGenerator(),\n partitionKey: `${event.tenantId}:${event.instanceId}`,\n tenantId: event.tenantId,\n event,\n status: 'pending',\n attempts: 0,\n nextAttemptAt: now,\n enqueuedAt: now,\n };\n await this.store.enqueue(record);\n } catch (err) {\n this.logger.error('OutboxNotificationAdapter: failed to enqueue event', err, {\n type: event.type,\n instanceId: event.instanceId,\n tenantId: event.tenantId,\n });\n }\n }\n\n /**\n * Attempt delivery of all currently due-and-pending records.\n *\n * Idempotent and safe to call repeatedly and concurrently: if a drain is\n * already in flight, the same promise is returned rather than starting a\n * second pass, so a delivered event is never delivered twice beyond\n * at-least-once semantics. Records whose `nextAttemptAt` is in the future are\n * not attempted prematurely. Never throws — store/transport errors are caught\n * and logged.\n *\n * @returns the number of records successfully delivered in this pass.\n */\n async drain(): Promise<number> {\n if (this.draining) return this.draining;\n this.draining = this.runDrain();\n try {\n return await this.draining;\n } finally {\n this.draining = null;\n }\n }\n\n private async runDrain(): Promise<number> {\n let delivered = 0;\n let due: OutboxRecord[];\n try {\n due = await this.store.due(this.clock.now().getTime());\n } catch (err) {\n this.logger.error('OutboxNotificationAdapter: failed to read due records', err);\n return 0;\n }\n\n for (const record of due) {\n // Re-check status defensively in case the store handed back a stale row.\n if (record.status !== 'pending') continue;\n const ok = await this.attemptDelivery(record);\n if (ok) delivered++;\n }\n return delivered;\n }\n\n /** Run one delivery attempt for a record and persist the resulting state. */\n private async attemptDelivery(record: OutboxRecord): Promise<boolean> {\n record.attempts++;\n try {\n // Await covers both async rejection and a returned promise; the try also\n // catches a synchronous throw from the transport.\n await this.transport(record.event);\n try {\n await this.store.remove(record.id);\n } catch (err) {\n // Delivery succeeded but cleanup failed: log. At-least-once means a\n // future drain may redeliver — acceptable and documented.\n this.logger.error('OutboxNotificationAdapter: delivered but failed to remove record', err, {\n id: record.id,\n tenantId: record.tenantId,\n });\n }\n this.logger.debug('OutboxNotificationAdapter: delivered', {\n id: record.id,\n type: record.event.type,\n attempts: record.attempts,\n });\n return true;\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n record.lastError = message;\n if (record.attempts >= this.maxAttempts) {\n record.status = 'dead';\n this.logger.error(\n 'OutboxNotificationAdapter: dead-lettered after exhausting retries',\n err,\n {\n id: record.id,\n tenantId: record.tenantId,\n attempts: record.attempts,\n maxAttempts: this.maxAttempts,\n },\n );\n } else {\n record.nextAttemptAt = this.clock.now().getTime() + this.computeBackoff(record.attempts);\n this.logger.warn('OutboxNotificationAdapter: delivery failed, scheduling retry', {\n id: record.id,\n tenantId: record.tenantId,\n attempts: record.attempts,\n nextAttemptAt: record.nextAttemptAt,\n error: message,\n });\n }\n try {\n await this.store.update(record);\n } catch (updateErr) {\n this.logger.error('OutboxNotificationAdapter: failed to persist record state', updateErr, {\n id: record.id,\n });\n }\n return false;\n }\n }\n\n /**\n * Deterministic exponential backoff for the Nth attempt (1-based), capped at\n * `maxDelayMs`. Guards against overflow: a non-finite intermediate value\n * collapses to the cap, so very high attempt counts never yield\n * `Infinity`/`NaN`/negative delays.\n */\n private computeBackoff(attempt: number): number {\n const raw = this.baseDelayMs * Math.pow(this.backoffFactor, attempt - 1);\n if (!Number.isFinite(raw) || raw < 0) return this.maxDelayMs;\n return Math.min(raw, this.maxDelayMs);\n }\n\n /**\n * Records still awaiting first delivery or a retry. Exposed for ops dashboards\n * and tests so a stuck transport (growing pending list) is observable.\n */\n async pending(): Promise<OutboxRecord[]> {\n try {\n return await this.store.pending();\n } catch (err) {\n this.logger.error('OutboxNotificationAdapter: failed to read pending records', err);\n return [];\n }\n }\n\n /**\n * Records that exhausted all retries. Exposed so ops can detect and replay a\n * stuck transport; the list is never silently dropped/truncated.\n */\n async deadLettered(): Promise<OutboxRecord[]> {\n try {\n return await this.store.deadLettered();\n } catch (err) {\n this.logger.error('OutboxNotificationAdapter: failed to read dead-lettered records', err);\n return [];\n }\n }\n}\n","import type { Logger } from '../../utils/Logger.js';\nimport { noopLogger } from '../../utils/Logger.js';\nimport type {\n INotificationAdapter,\n NotificationEvent,\n} from '../../adapters/INotificationAdapter.js';\n\n/** A child adapter paired with a stable name for diagnostics/logging. */\nexport interface NamedNotificationChild {\n /** Human-readable identity used when logging this child's failures. */\n name: string;\n adapter: INotificationAdapter;\n}\n\n/** Either a bare adapter or a {@link NamedNotificationChild}. */\nexport type CompositeChild = INotificationAdapter | NamedNotificationChild;\n\n/** Configuration for {@link CompositeNotificationAdapter}. */\nexport interface CompositeNotificationAdapterOptions {\n /** Child adapters to fan out to. May be empty (resolves as a no-op). */\n children: CompositeChild[];\n /** Structured logger. Defaults to {@link noopLogger}. */\n logger?: Logger;\n}\n\nfunction isNamed(child: CompositeChild): child is NamedNotificationChild {\n // A real bare adapter always exposes its own notify(); never treat it as a\n // NamedNotificationChild even if it happens to also carry an `adapter` property.\n if (typeof (child as INotificationAdapter).notify === 'function') return false;\n // Otherwise it's a NamedNotificationChild only if it wraps a non-null `adapter`\n // object whose own `.notify` is callable.\n const adapter = (child as NamedNotificationChild).adapter as unknown;\n return (\n typeof adapter === 'object' &&\n adapter !== null &&\n typeof (adapter as INotificationAdapter).notify === 'function'\n );\n}\n\n/**\n * Fans a single {@link notify} call out to N child {@link INotificationAdapter}s\n * concurrently.\n *\n * Uses `Promise.allSettled` so one failing/slow child never blocks delivery to\n * the others. Never throws: every child rejection is collected and logged with\n * that child's identity. Zero children resolves immediately as a no-op.\n *\n * Drop-in for `ApprovalEngineOptions.notificationAdapter`.\n */\nexport class CompositeNotificationAdapter implements INotificationAdapter {\n private readonly children: NamedNotificationChild[];\n private readonly logger: Logger;\n\n constructor(options: CompositeNotificationAdapterOptions) {\n this.children = options.children.map((child, i) =>\n isNamed(child) ? child : { name: `child[${i}]`, adapter: child },\n );\n this.logger = options.logger ?? noopLogger;\n }\n\n /**\n * Deliver the event to every child concurrently. Resolves once all children\n * settle; never rejects.\n */\n async notify(event: NotificationEvent): Promise<void> {\n if (this.children.length === 0) return;\n\n const results = await Promise.allSettled(\n // Wrap each call so a synchronous throw inside a child's notify is also\n // captured as a rejection rather than escaping the fan-out.\n this.children.map((child) => Promise.resolve().then(() => child.adapter.notify(event))),\n );\n\n results.forEach((result, i) => {\n if (result.status === 'rejected') {\n const child = this.children[i]!;\n this.logger.error('CompositeNotificationAdapter: child failed to notify', result.reason, {\n child: child.name,\n type: event.type,\n instanceId: event.instanceId,\n tenantId: event.tenantId,\n });\n }\n });\n }\n}\n","import type { Logger } from '../../utils/Logger.js';\nimport { noopLogger } from '../../utils/Logger.js';\nimport type { ApprovalEventName } from '../../types/events.js';\nimport type {\n INotificationAdapter,\n NotificationEvent,\n} from '../../adapters/INotificationAdapter.js';\n\n/** The fully rendered, channel-ready message handed to the send fn. */\nexport interface RenderedNotification {\n /** Logical delivery channel (e.g. 'email', 'slack', 'sms'). */\n channel: string;\n /** Resolved recipient address(es) for the channel. */\n to: string[];\n /** Short headline / email subject. */\n subject: string;\n /** Human-readable body. */\n body: string;\n}\n\n/** The shape a template function returns (channel/to are derived separately). */\nexport interface RenderedMessage {\n subject: string;\n body: string;\n}\n\n/**\n * A template entry for one {@link ApprovalEventName}. Either:\n * - a function `(event) => { subject, body }`, for full programmatic control, or\n * - a `{ subject, body }` pair of strings with `{placeholder}` tokens that are\n * interpolated from the event and its payload.\n */\nexport type NotificationTemplate =\n | ((event: NotificationEvent) => RenderedMessage)\n | { subject: string; body: string };\n\n/** Map of event name → template. Any subset of events may be configured. */\nexport type TemplateMap = Partial<Record<ApprovalEventName, NotificationTemplate>>;\n\n/** Side-effecting send function the adapter forwards rendered messages to. */\nexport type SendFn = (message: RenderedNotification) => void | Promise<void>;\n\n/** Configuration for {@link TemplatedNotificationAdapter}. */\nexport interface TemplatedNotificationAdapterOptions {\n /** Side-effecting send function. Required. */\n send: SendFn;\n /** Per-event templates. Events without an entry use {@link fallbackTemplate} (if any). */\n templates?: TemplateMap;\n /**\n * Template used when no per-event entry exists. If omitted, events without a\n * template are skipped (logged) rather than throwing. Set to a function or a\n * `{subject, body}` string pair to guarantee a message for every event.\n */\n fallbackTemplate?: NotificationTemplate;\n /**\n * Derive the channel for an event. Defaults to the constant `'default'`.\n */\n channelFor?: (event: NotificationEvent) => string;\n /**\n * Derive recipient address(es). Defaults to `event.recipients`. When this\n * returns an empty array the adapter falls back to {@link defaultRecipients}\n * (if set) and otherwise skips the send gracefully.\n */\n recipientsFor?: (event: NotificationEvent) => string[];\n /**\n * Recipients used when {@link recipientsFor} yields none (e.g. cancelled /\n * expired / sla_breached events carry empty `recipients`). If also empty the\n * send is skipped rather than dispatched to nobody.\n */\n defaultRecipients?: string[];\n /**\n * Token substituted for a `{placeholder}` that resolves to `undefined`/`null`\n * or references a field absent on the payload. Defaults to `''` (empty\n * string). Interpolation never throws on unknown placeholders.\n */\n unknownPlaceholderToken?: string;\n /** Structured logger. Defaults to {@link noopLogger}. */\n logger?: Logger;\n}\n\nconst PLACEHOLDER = /\\{([^{}]+)\\}/g;\n\n/**\n * Renders a human-readable message per {@link ApprovalEventName} from a\n * configurable template map and forwards `{ channel, to, subject, body }` to an\n * injected send function.\n *\n * Templates are resolved by event name; a missing template falls back to\n * `fallbackTemplate` or — if none is configured — the event is skipped (logged)\n * rather than throwing. String templates support `{placeholder}` interpolation\n * pulled from top-level event fields and `event.payload` fields; an unknown or\n * absent field renders to `unknownPlaceholderToken` and never throws.\n *\n * `notify()` never throws — send errors and any rendering issues are caught and\n * logged. Drop-in for `ApprovalEngineOptions.notificationAdapter`.\n */\nexport class TemplatedNotificationAdapter implements INotificationAdapter {\n private readonly send: SendFn;\n private readonly templates: TemplateMap;\n private readonly fallbackTemplate?: NotificationTemplate;\n private readonly channelFor: (event: NotificationEvent) => string;\n private readonly recipientsFor: (event: NotificationEvent) => string[];\n private readonly defaultRecipients: string[];\n private readonly unknownPlaceholderToken: string;\n private readonly logger: Logger;\n\n constructor(options: TemplatedNotificationAdapterOptions) {\n this.send = options.send;\n this.templates = options.templates ?? {};\n this.fallbackTemplate = options.fallbackTemplate;\n this.channelFor = options.channelFor ?? (() => 'default');\n this.recipientsFor = options.recipientsFor ?? ((event) => event.recipients ?? []);\n this.defaultRecipients = options.defaultRecipients ?? [];\n this.unknownPlaceholderToken = options.unknownPlaceholderToken ?? '';\n this.logger = options.logger ?? noopLogger;\n }\n\n /**\n * Render and dispatch the event. Never throws: missing templates, empty\n * recipients, and send failures are all handled and logged.\n */\n async notify(event: NotificationEvent): Promise<void> {\n try {\n const template = this.templates[event.type] ?? this.fallbackTemplate;\n if (!template) {\n this.logger.debug('TemplatedNotificationAdapter: no template for event, skipping', {\n type: event.type,\n instanceId: event.instanceId,\n });\n return;\n }\n\n const to = this.resolveRecipients(event);\n if (to.length === 0) {\n this.logger.debug('TemplatedNotificationAdapter: no recipients for event, skipping', {\n type: event.type,\n instanceId: event.instanceId,\n });\n return;\n }\n\n const rendered = this.render(template, event);\n const message: RenderedNotification = {\n channel: this.channelFor(event),\n to,\n subject: rendered.subject,\n body: rendered.body,\n };\n\n await this.send(message);\n } catch (err) {\n this.logger.error('TemplatedNotificationAdapter: failed to render/send notification', err, {\n type: event.type,\n instanceId: event.instanceId,\n tenantId: event.tenantId,\n });\n }\n }\n\n private resolveRecipients(event: NotificationEvent): string[] {\n const derived = this.recipientsFor(event);\n if (derived.length > 0) return derived;\n return this.defaultRecipients;\n }\n\n private render(template: NotificationTemplate, event: NotificationEvent): RenderedMessage {\n if (typeof template === 'function') {\n return template(event);\n }\n return {\n subject: this.interpolate(template.subject, event),\n body: this.interpolate(template.body, event),\n };\n }\n\n /**\n * Replace `{token}` occurrences in `text`. A token is resolved against\n * top-level event fields first, then `event.payload`. Dotted paths\n * (`payload.level`, `a.b.c`) are supported. Anything unresolved renders to\n * `unknownPlaceholderToken`. Never throws.\n */\n private interpolate(text: string, event: NotificationEvent): string {\n return text.replace(PLACEHOLDER, (_match, rawKey: string) => {\n const key = rawKey.trim();\n const value = this.lookup(event, key);\n if (value === undefined || value === null) return this.unknownPlaceholderToken;\n return this.stringify(value);\n });\n }\n\n /** Resolve a (possibly dotted) key against the event then its payload. */\n private lookup(event: NotificationEvent, key: string): unknown {\n const fromEvent = this.dig(event as unknown as Record<string, unknown>, key);\n if (fromEvent !== undefined) return fromEvent;\n return this.dig(event.payload as unknown as Record<string, unknown>, key);\n }\n\n private dig(root: Record<string, unknown> | undefined, path: string): unknown {\n if (!root) return undefined;\n let current: unknown = root;\n for (const segment of path.split('.')) {\n if (current === null || typeof current !== 'object') return undefined;\n current = (current as Record<string, unknown>)[segment];\n }\n return current;\n }\n\n private stringify(value: unknown): string {\n if (value instanceof Date) return value.toISOString();\n if (Array.isArray(value)) return value.map((v) => this.stringify(v)).join(', ');\n if (typeof value === 'object') {\n try {\n return JSON.stringify(value);\n } catch {\n return this.unknownPlaceholderToken;\n }\n }\n return String(value);\n }\n}\n","import type { Clock } from '../../utils/Clock.js';\nimport { systemClock } from '../../utils/Clock.js';\nimport type { Logger } from '../../utils/Logger.js';\nimport { noopLogger } from '../../utils/Logger.js';\nimport type {\n INotificationAdapter,\n NotificationEvent,\n} from '../../adapters/INotificationAdapter.js';\n\n/** One recipient's accumulated events, handed to {@link DigestSendFn} on flush. */\nexport interface Digest {\n recipient: string;\n /** Events for this recipient, oldest first. */\n events: NotificationEvent[];\n /** When the earliest event in this digest arrived. */\n since: Date;\n /** When the digest was flushed. */\n flushedAt: Date;\n}\n\n/** Delivers one recipient's digest. Must not throw — failures are logged and swallowed. */\nexport type DigestSendFn = (digest: Digest) => Promise<void> | void;\n\nexport interface DigestNotificationAdapterOptions {\n /** Called once per recipient per flush. */\n send: DigestSendFn;\n /**\n * Event types delivered immediately instead of being batched. A rejection or\n * a completed approval is news the recipient acts on now; batching it behind\n * a digest window would make the library's own notifications the reason a\n * decision was late.\n *\n * Defaults to rejections, completions, SLA breaches and expiries.\n */\n passthrough?: NotificationEvent['type'][];\n /**\n * Flush a recipient's digest once it reaches this many events, regardless of\n * the timer. Prevents an unbounded buffer under a burst.\n */\n maxBatchSize?: number;\n /**\n * Flush every recipient this often, in milliseconds. Omit to disable the\n * timer and flush only via {@link DigestNotificationAdapter.flush} — the right\n * choice when a cron job or queue worker owns the schedule.\n */\n intervalMs?: number;\n logger?: Logger;\n clock?: Clock;\n}\n\n/** Event types that reach the recipient immediately unless the caller says otherwise. */\nconst DEFAULT_PASSTHROUGH: NotificationEvent['type'][] = [\n 'approval:rejected',\n 'approval:completed',\n 'approval:sla_breached',\n 'approval:expired',\n];\n\nconst DEFAULT_MAX_BATCH_SIZE = 50;\n\n/**\n * Batches notifications per recipient instead of sending one per event.\n *\n * An approver on twenty documents receives twenty separate messages a day from\n * a naive adapter, which is how approval email ends up filtered into a folder\n * nobody reads — the notifications defeat themselves. This collects events per\n * recipient and delivers one digest.\n *\n * **Urgent events still go straight through.** Batching a rejection or a\n * completed approval behind a digest window would make the library's own\n * notifications the reason a decision was late, so those bypass the buffer by\n * default; see {@link DigestNotificationAdapterOptions.passthrough}.\n *\n * Buffers live in memory. A process restart drops whatever has not been\n * flushed, which is the right trade for a convenience digest but not for\n * delivery guarantees — put {@link OutboxNotificationAdapter} underneath when\n * an event must not be lost.\n *\n * @example\n * ```ts\n * const digest = new DigestNotificationAdapter({\n * intervalMs: 15 * 60_000,\n * send: async ({ recipient, events }) => mailer.send(recipient, summarise(events)),\n * });\n * const engine = new ApprovalEngine({ adapter, notificationAdapter: digest });\n * // ...on shutdown\n * await digest.stop();\n * ```\n */\nexport class DigestNotificationAdapter implements INotificationAdapter {\n private readonly buffers = new Map<string, { events: NotificationEvent[]; since: Date }>();\n private readonly send: DigestSendFn;\n private readonly passthrough: Set<NotificationEvent['type']>;\n private readonly maxBatchSize: number;\n private readonly intervalMs?: number;\n private readonly logger: Logger;\n private readonly clock: Clock;\n private timer: ReturnType<typeof setInterval> | null = null;\n\n constructor(opts: DigestNotificationAdapterOptions) {\n this.send = opts.send;\n this.passthrough = new Set(opts.passthrough ?? DEFAULT_PASSTHROUGH);\n this.maxBatchSize = opts.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;\n this.intervalMs = opts.intervalMs;\n this.logger = opts.logger ?? noopLogger;\n this.clock = opts.clock ?? systemClock;\n\n if (this.intervalMs !== undefined) {\n if (this.intervalMs <= 0) {\n throw new Error('DigestNotificationAdapter: intervalMs must be a positive number.');\n }\n this.timer = setInterval(() => {\n void this.flush().catch((err) => {\n this.logger.error('DigestNotificationAdapter: scheduled flush failed', err);\n });\n }, this.intervalMs);\n // Never hold the process open for a convenience digest.\n this.timer.unref?.();\n }\n }\n\n /** Recipients currently holding buffered events. */\n get pendingRecipients(): number {\n return this.buffers.size;\n }\n\n async notify(event: NotificationEvent): Promise<void> {\n if (this.passthrough.has(event.type)) {\n await this.deliver({\n recipient: '',\n events: [event],\n since: event.timestamp,\n flushedAt: this.clock.now(),\n });\n return;\n }\n\n const now = this.clock.now();\n const full: string[] = [];\n\n for (const recipient of event.recipients) {\n const buffer = this.buffers.get(recipient) ?? { events: [], since: now };\n buffer.events.push(event);\n this.buffers.set(recipient, buffer);\n if (buffer.events.length >= this.maxBatchSize) full.push(recipient);\n }\n\n // Flush over-full recipients only; a burst aimed at one person must not\n // force everybody else's digest out early.\n for (const recipient of full) {\n await this.flushRecipient(recipient);\n }\n }\n\n /** Deliver every buffered digest now. Safe to call from a cron job or on shutdown. */\n async flush(): Promise<void> {\n for (const recipient of [...this.buffers.keys()]) {\n await this.flushRecipient(recipient);\n }\n }\n\n /** Stop the timer and deliver whatever is buffered. */\n async stop(): Promise<void> {\n if (this.timer !== null) {\n clearInterval(this.timer);\n this.timer = null;\n }\n await this.flush();\n }\n\n private async flushRecipient(recipient: string): Promise<void> {\n const buffer = this.buffers.get(recipient);\n if (!buffer || buffer.events.length === 0) return;\n // Drop the buffer before sending: a send that throws must not replay the\n // same events into the next digest forever.\n this.buffers.delete(recipient);\n\n await this.deliver({\n recipient,\n events: buffer.events,\n since: buffer.since,\n flushedAt: this.clock.now(),\n });\n }\n\n private async deliver(digest: Digest): Promise<void> {\n try {\n await this.send(digest);\n } catch (err) {\n this.logger.error('DigestNotificationAdapter: send failed', err, {\n recipient: digest.recipient,\n events: digest.events.length,\n });\n }\n }\n}\n"]}
@@ -346,4 +346,92 @@ declare class TemplatedNotificationAdapter implements INotificationAdapter {
346
346
  private stringify;
347
347
  }
348
348
 
349
- export { type CompositeChild, CompositeNotificationAdapter, type CompositeNotificationAdapterOptions, type IOutboxStore, InMemoryOutboxStore, type NamedNotificationChild, type NotificationTemplate, type NotificationTransport, OutboxNotificationAdapter, type OutboxNotificationAdapterOptions, type OutboxRecord, type OutboxRecordStatus, type RenderedMessage, type RenderedNotification, type SendFn, type TemplateMap, TemplatedNotificationAdapter, type TemplatedNotificationAdapterOptions };
349
+ /** One recipient's accumulated events, handed to {@link DigestSendFn} on flush. */
350
+ interface Digest {
351
+ recipient: string;
352
+ /** Events for this recipient, oldest first. */
353
+ events: NotificationEvent[];
354
+ /** When the earliest event in this digest arrived. */
355
+ since: Date;
356
+ /** When the digest was flushed. */
357
+ flushedAt: Date;
358
+ }
359
+ /** Delivers one recipient's digest. Must not throw — failures are logged and swallowed. */
360
+ type DigestSendFn = (digest: Digest) => Promise<void> | void;
361
+ interface DigestNotificationAdapterOptions {
362
+ /** Called once per recipient per flush. */
363
+ send: DigestSendFn;
364
+ /**
365
+ * Event types delivered immediately instead of being batched. A rejection or
366
+ * a completed approval is news the recipient acts on now; batching it behind
367
+ * a digest window would make the library's own notifications the reason a
368
+ * decision was late.
369
+ *
370
+ * Defaults to rejections, completions, SLA breaches and expiries.
371
+ */
372
+ passthrough?: NotificationEvent['type'][];
373
+ /**
374
+ * Flush a recipient's digest once it reaches this many events, regardless of
375
+ * the timer. Prevents an unbounded buffer under a burst.
376
+ */
377
+ maxBatchSize?: number;
378
+ /**
379
+ * Flush every recipient this often, in milliseconds. Omit to disable the
380
+ * timer and flush only via {@link DigestNotificationAdapter.flush} — the right
381
+ * choice when a cron job or queue worker owns the schedule.
382
+ */
383
+ intervalMs?: number;
384
+ logger?: Logger;
385
+ clock?: Clock;
386
+ }
387
+ /**
388
+ * Batches notifications per recipient instead of sending one per event.
389
+ *
390
+ * An approver on twenty documents receives twenty separate messages a day from
391
+ * a naive adapter, which is how approval email ends up filtered into a folder
392
+ * nobody reads — the notifications defeat themselves. This collects events per
393
+ * recipient and delivers one digest.
394
+ *
395
+ * **Urgent events still go straight through.** Batching a rejection or a
396
+ * completed approval behind a digest window would make the library's own
397
+ * notifications the reason a decision was late, so those bypass the buffer by
398
+ * default; see {@link DigestNotificationAdapterOptions.passthrough}.
399
+ *
400
+ * Buffers live in memory. A process restart drops whatever has not been
401
+ * flushed, which is the right trade for a convenience digest but not for
402
+ * delivery guarantees — put {@link OutboxNotificationAdapter} underneath when
403
+ * an event must not be lost.
404
+ *
405
+ * @example
406
+ * ```ts
407
+ * const digest = new DigestNotificationAdapter({
408
+ * intervalMs: 15 * 60_000,
409
+ * send: async ({ recipient, events }) => mailer.send(recipient, summarise(events)),
410
+ * });
411
+ * const engine = new ApprovalEngine({ adapter, notificationAdapter: digest });
412
+ * // ...on shutdown
413
+ * await digest.stop();
414
+ * ```
415
+ */
416
+ declare class DigestNotificationAdapter implements INotificationAdapter {
417
+ private readonly buffers;
418
+ private readonly send;
419
+ private readonly passthrough;
420
+ private readonly maxBatchSize;
421
+ private readonly intervalMs?;
422
+ private readonly logger;
423
+ private readonly clock;
424
+ private timer;
425
+ constructor(opts: DigestNotificationAdapterOptions);
426
+ /** Recipients currently holding buffered events. */
427
+ get pendingRecipients(): number;
428
+ notify(event: NotificationEvent): Promise<void>;
429
+ /** Deliver every buffered digest now. Safe to call from a cron job or on shutdown. */
430
+ flush(): Promise<void>;
431
+ /** Stop the timer and deliver whatever is buffered. */
432
+ stop(): Promise<void>;
433
+ private flushRecipient;
434
+ private deliver;
435
+ }
436
+
437
+ export { type CompositeChild, CompositeNotificationAdapter, type CompositeNotificationAdapterOptions, type Digest, DigestNotificationAdapter, type DigestNotificationAdapterOptions, type DigestSendFn, type IOutboxStore, InMemoryOutboxStore, type NamedNotificationChild, type NotificationTemplate, type NotificationTransport, OutboxNotificationAdapter, type OutboxNotificationAdapterOptions, type OutboxRecord, type OutboxRecordStatus, type RenderedMessage, type RenderedNotification, type SendFn, type TemplateMap, TemplatedNotificationAdapter, type TemplatedNotificationAdapterOptions };
@@ -346,4 +346,92 @@ declare class TemplatedNotificationAdapter implements INotificationAdapter {
346
346
  private stringify;
347
347
  }
348
348
 
349
- export { type CompositeChild, CompositeNotificationAdapter, type CompositeNotificationAdapterOptions, type IOutboxStore, InMemoryOutboxStore, type NamedNotificationChild, type NotificationTemplate, type NotificationTransport, OutboxNotificationAdapter, type OutboxNotificationAdapterOptions, type OutboxRecord, type OutboxRecordStatus, type RenderedMessage, type RenderedNotification, type SendFn, type TemplateMap, TemplatedNotificationAdapter, type TemplatedNotificationAdapterOptions };
349
+ /** One recipient's accumulated events, handed to {@link DigestSendFn} on flush. */
350
+ interface Digest {
351
+ recipient: string;
352
+ /** Events for this recipient, oldest first. */
353
+ events: NotificationEvent[];
354
+ /** When the earliest event in this digest arrived. */
355
+ since: Date;
356
+ /** When the digest was flushed. */
357
+ flushedAt: Date;
358
+ }
359
+ /** Delivers one recipient's digest. Must not throw — failures are logged and swallowed. */
360
+ type DigestSendFn = (digest: Digest) => Promise<void> | void;
361
+ interface DigestNotificationAdapterOptions {
362
+ /** Called once per recipient per flush. */
363
+ send: DigestSendFn;
364
+ /**
365
+ * Event types delivered immediately instead of being batched. A rejection or
366
+ * a completed approval is news the recipient acts on now; batching it behind
367
+ * a digest window would make the library's own notifications the reason a
368
+ * decision was late.
369
+ *
370
+ * Defaults to rejections, completions, SLA breaches and expiries.
371
+ */
372
+ passthrough?: NotificationEvent['type'][];
373
+ /**
374
+ * Flush a recipient's digest once it reaches this many events, regardless of
375
+ * the timer. Prevents an unbounded buffer under a burst.
376
+ */
377
+ maxBatchSize?: number;
378
+ /**
379
+ * Flush every recipient this often, in milliseconds. Omit to disable the
380
+ * timer and flush only via {@link DigestNotificationAdapter.flush} — the right
381
+ * choice when a cron job or queue worker owns the schedule.
382
+ */
383
+ intervalMs?: number;
384
+ logger?: Logger;
385
+ clock?: Clock;
386
+ }
387
+ /**
388
+ * Batches notifications per recipient instead of sending one per event.
389
+ *
390
+ * An approver on twenty documents receives twenty separate messages a day from
391
+ * a naive adapter, which is how approval email ends up filtered into a folder
392
+ * nobody reads — the notifications defeat themselves. This collects events per
393
+ * recipient and delivers one digest.
394
+ *
395
+ * **Urgent events still go straight through.** Batching a rejection or a
396
+ * completed approval behind a digest window would make the library's own
397
+ * notifications the reason a decision was late, so those bypass the buffer by
398
+ * default; see {@link DigestNotificationAdapterOptions.passthrough}.
399
+ *
400
+ * Buffers live in memory. A process restart drops whatever has not been
401
+ * flushed, which is the right trade for a convenience digest but not for
402
+ * delivery guarantees — put {@link OutboxNotificationAdapter} underneath when
403
+ * an event must not be lost.
404
+ *
405
+ * @example
406
+ * ```ts
407
+ * const digest = new DigestNotificationAdapter({
408
+ * intervalMs: 15 * 60_000,
409
+ * send: async ({ recipient, events }) => mailer.send(recipient, summarise(events)),
410
+ * });
411
+ * const engine = new ApprovalEngine({ adapter, notificationAdapter: digest });
412
+ * // ...on shutdown
413
+ * await digest.stop();
414
+ * ```
415
+ */
416
+ declare class DigestNotificationAdapter implements INotificationAdapter {
417
+ private readonly buffers;
418
+ private readonly send;
419
+ private readonly passthrough;
420
+ private readonly maxBatchSize;
421
+ private readonly intervalMs?;
422
+ private readonly logger;
423
+ private readonly clock;
424
+ private timer;
425
+ constructor(opts: DigestNotificationAdapterOptions);
426
+ /** Recipients currently holding buffered events. */
427
+ get pendingRecipients(): number;
428
+ notify(event: NotificationEvent): Promise<void>;
429
+ /** Deliver every buffered digest now. Safe to call from a cron job or on shutdown. */
430
+ flush(): Promise<void>;
431
+ /** Stop the timer and deliver whatever is buffered. */
432
+ stop(): Promise<void>;
433
+ private flushRecipient;
434
+ private deliver;
435
+ }
436
+
437
+ export { type CompositeChild, CompositeNotificationAdapter, type CompositeNotificationAdapterOptions, type Digest, DigestNotificationAdapter, type DigestNotificationAdapterOptions, type DigestSendFn, type IOutboxStore, InMemoryOutboxStore, type NamedNotificationChild, type NotificationTemplate, type NotificationTransport, OutboxNotificationAdapter, type OutboxNotificationAdapterOptions, type OutboxRecord, type OutboxRecordStatus, type RenderedMessage, type RenderedNotification, type SendFn, type TemplateMap, TemplatedNotificationAdapter, type TemplatedNotificationAdapterOptions };
@@ -154,12 +154,16 @@ var OutboxNotificationAdapter = class {
154
154
  record.lastError = message;
155
155
  if (record.attempts >= this.maxAttempts) {
156
156
  record.status = "dead";
157
- this.logger.error("OutboxNotificationAdapter: dead-lettered after exhausting retries", err, {
158
- id: record.id,
159
- tenantId: record.tenantId,
160
- attempts: record.attempts,
161
- maxAttempts: this.maxAttempts
162
- });
157
+ this.logger.error(
158
+ "OutboxNotificationAdapter: dead-lettered after exhausting retries",
159
+ err,
160
+ {
161
+ id: record.id,
162
+ tenantId: record.tenantId,
163
+ attempts: record.attempts,
164
+ maxAttempts: this.maxAttempts
165
+ }
166
+ );
163
167
  } else {
164
168
  record.nextAttemptAt = this.clock.now().getTime() + this.computeBackoff(record.attempts);
165
169
  this.logger.warn("OutboxNotificationAdapter: delivery failed, scheduling retry", {
@@ -239,9 +243,7 @@ var CompositeNotificationAdapter = class {
239
243
  const results = await Promise.allSettled(
240
244
  // Wrap each call so a synchronous throw inside a child's notify is also
241
245
  // captured as a rejection rather than escaping the fan-out.
242
- this.children.map(
243
- (child) => Promise.resolve().then(() => child.adapter.notify(event))
244
- )
246
+ this.children.map((child) => Promise.resolve().then(() => child.adapter.notify(event)))
245
247
  );
246
248
  results.forEach((result, i) => {
247
249
  if (result.status === "rejected") {
@@ -365,6 +367,99 @@ var TemplatedNotificationAdapter = class {
365
367
  }
366
368
  };
367
369
 
368
- export { CompositeNotificationAdapter, InMemoryOutboxStore, OutboxNotificationAdapter, TemplatedNotificationAdapter };
370
+ // src/plugins/notify/DigestNotificationAdapter.ts
371
+ var DEFAULT_PASSTHROUGH = [
372
+ "approval:rejected",
373
+ "approval:completed",
374
+ "approval:sla_breached",
375
+ "approval:expired"
376
+ ];
377
+ var DEFAULT_MAX_BATCH_SIZE = 50;
378
+ var DigestNotificationAdapter = class {
379
+ constructor(opts) {
380
+ this.buffers = /* @__PURE__ */ new Map();
381
+ this.timer = null;
382
+ this.send = opts.send;
383
+ this.passthrough = new Set(opts.passthrough ?? DEFAULT_PASSTHROUGH);
384
+ this.maxBatchSize = opts.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
385
+ this.intervalMs = opts.intervalMs;
386
+ this.logger = opts.logger ?? noopLogger;
387
+ this.clock = opts.clock ?? systemClock;
388
+ if (this.intervalMs !== void 0) {
389
+ if (this.intervalMs <= 0) {
390
+ throw new Error("DigestNotificationAdapter: intervalMs must be a positive number.");
391
+ }
392
+ this.timer = setInterval(() => {
393
+ void this.flush().catch((err) => {
394
+ this.logger.error("DigestNotificationAdapter: scheduled flush failed", err);
395
+ });
396
+ }, this.intervalMs);
397
+ this.timer.unref?.();
398
+ }
399
+ }
400
+ /** Recipients currently holding buffered events. */
401
+ get pendingRecipients() {
402
+ return this.buffers.size;
403
+ }
404
+ async notify(event) {
405
+ if (this.passthrough.has(event.type)) {
406
+ await this.deliver({
407
+ recipient: "",
408
+ events: [event],
409
+ since: event.timestamp,
410
+ flushedAt: this.clock.now()
411
+ });
412
+ return;
413
+ }
414
+ const now = this.clock.now();
415
+ const full = [];
416
+ for (const recipient of event.recipients) {
417
+ const buffer = this.buffers.get(recipient) ?? { events: [], since: now };
418
+ buffer.events.push(event);
419
+ this.buffers.set(recipient, buffer);
420
+ if (buffer.events.length >= this.maxBatchSize) full.push(recipient);
421
+ }
422
+ for (const recipient of full) {
423
+ await this.flushRecipient(recipient);
424
+ }
425
+ }
426
+ /** Deliver every buffered digest now. Safe to call from a cron job or on shutdown. */
427
+ async flush() {
428
+ for (const recipient of [...this.buffers.keys()]) {
429
+ await this.flushRecipient(recipient);
430
+ }
431
+ }
432
+ /** Stop the timer and deliver whatever is buffered. */
433
+ async stop() {
434
+ if (this.timer !== null) {
435
+ clearInterval(this.timer);
436
+ this.timer = null;
437
+ }
438
+ await this.flush();
439
+ }
440
+ async flushRecipient(recipient) {
441
+ const buffer = this.buffers.get(recipient);
442
+ if (!buffer || buffer.events.length === 0) return;
443
+ this.buffers.delete(recipient);
444
+ await this.deliver({
445
+ recipient,
446
+ events: buffer.events,
447
+ since: buffer.since,
448
+ flushedAt: this.clock.now()
449
+ });
450
+ }
451
+ async deliver(digest) {
452
+ try {
453
+ await this.send(digest);
454
+ } catch (err) {
455
+ this.logger.error("DigestNotificationAdapter: send failed", err, {
456
+ recipient: digest.recipient,
457
+ events: digest.events.length
458
+ });
459
+ }
460
+ }
461
+ };
462
+
463
+ export { CompositeNotificationAdapter, DigestNotificationAdapter, InMemoryOutboxStore, OutboxNotificationAdapter, TemplatedNotificationAdapter };
369
464
  //# sourceMappingURL=notify.js.map
370
465
  //# sourceMappingURL=notify.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/Clock.ts","../../src/utils/Logger.ts","../../src/plugins/notify/InMemoryOutboxStore.ts","../../src/plugins/notify/OutboxNotificationAdapter.ts","../../src/plugins/notify/CompositeNotificationAdapter.ts","../../src/plugins/notify/TemplatedNotificationAdapter.ts"],"names":[],"mappings":"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","file":"notify.js","sourcesContent":["export interface Clock {\n now(): Date;\n}\n\nexport const systemClock: Clock = { now: () => new Date() };\n","export interface Logger {\n info(msg: string, context?: Record<string, unknown>): void;\n warn(msg: string, context?: Record<string, unknown>): void;\n error(msg: string, err?: unknown, context?: Record<string, unknown>): void;\n fatal(msg: string, err?: unknown, context?: Record<string, unknown>): void;\n debug(msg: string, context?: Record<string, unknown>): void;\n}\n\nexport const noopLogger: Logger = {\n info: () => {},\n warn: () => {},\n error: () => {},\n fatal: () => {},\n debug: () => {},\n};\n","import type { IOutboxStore, OutboxRecord } from './IOutboxStore.js';\n\n/**\n * Default, dependency-free {@link IOutboxStore} backed by an in-process Map.\n *\n * Ordering: {@link due} and {@link pending} return records sorted by\n * `enqueuedAt` (then by id as a tiebreaker), giving FIFO best-effort within a\n * single `(tenant, instance)` partition. There is no cross-partition ordering\n * guarantee.\n *\n * Records are stored by reference; the adapter mutates and writes them back via\n * {@link update}, so reads reflect the latest state. This is intentional for the\n * in-memory case — a remote store would serialize instead.\n */\nexport class InMemoryOutboxStore implements IOutboxStore {\n private readonly records = new Map<string, OutboxRecord>();\n\n async enqueue(record: OutboxRecord): Promise<void> {\n this.records.set(record.id, record);\n }\n\n async due(now: number): Promise<OutboxRecord[]> {\n return this.sorted().filter((r) => r.status === 'pending' && r.nextAttemptAt <= now);\n }\n\n async update(record: OutboxRecord): Promise<void> {\n // Only persist if the record is still tracked (not removed concurrently).\n if (this.records.has(record.id)) {\n this.records.set(record.id, record);\n }\n }\n\n async remove(id: string): Promise<void> {\n this.records.delete(id);\n }\n\n async pending(): Promise<OutboxRecord[]> {\n return this.sorted().filter((r) => r.status === 'pending');\n }\n\n async deadLettered(): Promise<OutboxRecord[]> {\n return this.sorted().filter((r) => r.status === 'dead');\n }\n\n /** Test/ops helper — total records currently retained (pending + dead). */\n get size(): number {\n return this.records.size;\n }\n\n private sorted(): OutboxRecord[] {\n return [...this.records.values()].sort((a, b) =>\n a.enqueuedAt !== b.enqueuedAt ? a.enqueuedAt - b.enqueuedAt : a.id.localeCompare(b.id),\n );\n }\n}\n","import type { Clock } from '../../utils/Clock.js';\nimport { systemClock } from '../../utils/Clock.js';\nimport type { Logger } from '../../utils/Logger.js';\nimport { noopLogger } from '../../utils/Logger.js';\nimport type { INotificationAdapter, NotificationEvent } from '../../adapters/INotificationAdapter.js';\nimport type { IOutboxStore, OutboxRecord } from './IOutboxStore.js';\nimport { InMemoryOutboxStore } from './InMemoryOutboxStore.js';\n\n/**\n * Transport that performs the actual side-effecting delivery of a single event\n * (send an email, post to a queue, call a webhook, …).\n *\n * It MAY throw synchronously or reject asynchronously — both are treated\n * identically as a failed attempt and trigger a retry. A normal resolution\n * counts as a successful delivery.\n */\nexport type NotificationTransport = (event: NotificationEvent) => void | Promise<void>;\n\n/** Configuration for {@link OutboxNotificationAdapter}. All fields optional except `transport`. */\nexport interface OutboxNotificationAdapterOptions {\n /** Side-effecting delivery function. Required. */\n transport: NotificationTransport;\n /** Persistence for queued events. Defaults to an {@link InMemoryOutboxStore}. */\n store?: IOutboxStore;\n /** Time source. Defaults to {@link systemClock}. Inject a manual clock for deterministic tests. */\n clock?: Clock;\n /** Structured logger. Defaults to {@link noopLogger}. */\n logger?: Logger;\n /**\n * Maximum delivery attempts before an event is dead-lettered. Must be >= 1.\n * `1` means no retries (single failure → dead-letter). Defaults to `5`.\n */\n maxAttempts?: number;\n /** Base backoff in milliseconds for the first retry. Defaults to `1000`. */\n baseDelayMs?: number;\n /** Multiplier applied per attempt (exponential). Defaults to `2`. */\n backoffFactor?: number;\n /**\n * Upper bound on a single backoff delay, in milliseconds. Caps the schedule so\n * very high attempt counts never overflow to `Infinity`/negative. Defaults to\n * `5 * 60_000` (5 minutes).\n */\n maxDelayMs?: number;\n /** Monotonic id generator for records. Defaults to a counter + timestamp. */\n idGenerator?: () => string;\n}\n\n/**\n * Reliable, store-and-forward {@link INotificationAdapter}.\n *\n * `notify()` only enqueues the event into a pluggable outbox store and returns;\n * it never throws (enqueue failures are caught, logged, and swallowed). Actual\n * delivery happens in {@link drain}, which is driven by ops (a poller/cron) or\n * tests. Delivery retries on failure with deterministic exponential backoff\n * computed from the injected {@link Clock}; on exhausting `maxAttempts` the\n * record is moved to a dead-letter list rather than dropped.\n *\n * Ordering: within a single `(tenantId, instanceId)` partition delivery is FIFO\n * best-effort (oldest-enqueued due record first). There is no ordering guarantee\n * across partitions, and a record awaiting a future retry does not block later\n * records in the same partition from being attempted.\n *\n * Drop-in for `ApprovalEngineOptions.notificationAdapter` with no engine change.\n */\nexport class OutboxNotificationAdapter implements INotificationAdapter {\n private readonly transport: NotificationTransport;\n private readonly store: IOutboxStore;\n private readonly clock: Clock;\n private readonly logger: Logger;\n private readonly maxAttempts: number;\n private readonly baseDelayMs: number;\n private readonly backoffFactor: number;\n private readonly maxDelayMs: number;\n private readonly idGenerator: () => string;\n private seq = 0;\n\n /** Guards against concurrent {@link drain} runs causing double-delivery. */\n private draining: Promise<number> | null = null;\n\n constructor(options: OutboxNotificationAdapterOptions) {\n this.transport = options.transport;\n this.store = options.store ?? new InMemoryOutboxStore();\n this.clock = options.clock ?? systemClock;\n this.logger = options.logger ?? noopLogger;\n this.maxAttempts = Math.max(1, Math.floor(options.maxAttempts ?? 5));\n this.baseDelayMs = Math.max(0, options.baseDelayMs ?? 1000);\n this.backoffFactor = options.backoffFactor ?? 2;\n this.maxDelayMs = Math.max(0, options.maxDelayMs ?? 5 * 60_000);\n this.idGenerator = options.idGenerator ?? (() => `${this.clock.now().getTime()}-${this.seq++}`);\n }\n\n /**\n * Enqueue an event for reliable delivery. Never throws: a failure to persist\n * is logged and swallowed so the engine's emit path is never broken.\n */\n async notify(event: NotificationEvent): Promise<void> {\n try {\n const now = this.clock.now().getTime();\n const record: OutboxRecord = {\n id: this.idGenerator(),\n partitionKey: `${event.tenantId}:${event.instanceId}`,\n tenantId: event.tenantId,\n event,\n status: 'pending',\n attempts: 0,\n nextAttemptAt: now,\n enqueuedAt: now,\n };\n await this.store.enqueue(record);\n } catch (err) {\n this.logger.error('OutboxNotificationAdapter: failed to enqueue event', err, {\n type: event.type,\n instanceId: event.instanceId,\n tenantId: event.tenantId,\n });\n }\n }\n\n /**\n * Attempt delivery of all currently due-and-pending records.\n *\n * Idempotent and safe to call repeatedly and concurrently: if a drain is\n * already in flight, the same promise is returned rather than starting a\n * second pass, so a delivered event is never delivered twice beyond\n * at-least-once semantics. Records whose `nextAttemptAt` is in the future are\n * not attempted prematurely. Never throws — store/transport errors are caught\n * and logged.\n *\n * @returns the number of records successfully delivered in this pass.\n */\n async drain(): Promise<number> {\n if (this.draining) return this.draining;\n this.draining = this.runDrain();\n try {\n return await this.draining;\n } finally {\n this.draining = null;\n }\n }\n\n private async runDrain(): Promise<number> {\n let delivered = 0;\n let due: OutboxRecord[];\n try {\n due = await this.store.due(this.clock.now().getTime());\n } catch (err) {\n this.logger.error('OutboxNotificationAdapter: failed to read due records', err);\n return 0;\n }\n\n for (const record of due) {\n // Re-check status defensively in case the store handed back a stale row.\n if (record.status !== 'pending') continue;\n const ok = await this.attemptDelivery(record);\n if (ok) delivered++;\n }\n return delivered;\n }\n\n /** Run one delivery attempt for a record and persist the resulting state. */\n private async attemptDelivery(record: OutboxRecord): Promise<boolean> {\n record.attempts++;\n try {\n // Await covers both async rejection and a returned promise; the try also\n // catches a synchronous throw from the transport.\n await this.transport(record.event);\n try {\n await this.store.remove(record.id);\n } catch (err) {\n // Delivery succeeded but cleanup failed: log. At-least-once means a\n // future drain may redeliver — acceptable and documented.\n this.logger.error('OutboxNotificationAdapter: delivered but failed to remove record', err, {\n id: record.id,\n tenantId: record.tenantId,\n });\n }\n this.logger.debug('OutboxNotificationAdapter: delivered', {\n id: record.id,\n type: record.event.type,\n attempts: record.attempts,\n });\n return true;\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n record.lastError = message;\n if (record.attempts >= this.maxAttempts) {\n record.status = 'dead';\n this.logger.error('OutboxNotificationAdapter: dead-lettered after exhausting retries', err, {\n id: record.id,\n tenantId: record.tenantId,\n attempts: record.attempts,\n maxAttempts: this.maxAttempts,\n });\n } else {\n record.nextAttemptAt = this.clock.now().getTime() + this.computeBackoff(record.attempts);\n this.logger.warn('OutboxNotificationAdapter: delivery failed, scheduling retry', {\n id: record.id,\n tenantId: record.tenantId,\n attempts: record.attempts,\n nextAttemptAt: record.nextAttemptAt,\n error: message,\n });\n }\n try {\n await this.store.update(record);\n } catch (updateErr) {\n this.logger.error('OutboxNotificationAdapter: failed to persist record state', updateErr, {\n id: record.id,\n });\n }\n return false;\n }\n }\n\n /**\n * Deterministic exponential backoff for the Nth attempt (1-based), capped at\n * `maxDelayMs`. Guards against overflow: a non-finite intermediate value\n * collapses to the cap, so very high attempt counts never yield\n * `Infinity`/`NaN`/negative delays.\n */\n private computeBackoff(attempt: number): number {\n const raw = this.baseDelayMs * Math.pow(this.backoffFactor, attempt - 1);\n if (!Number.isFinite(raw) || raw < 0) return this.maxDelayMs;\n return Math.min(raw, this.maxDelayMs);\n }\n\n /**\n * Records still awaiting first delivery or a retry. Exposed for ops dashboards\n * and tests so a stuck transport (growing pending list) is observable.\n */\n async pending(): Promise<OutboxRecord[]> {\n try {\n return await this.store.pending();\n } catch (err) {\n this.logger.error('OutboxNotificationAdapter: failed to read pending records', err);\n return [];\n }\n }\n\n /**\n * Records that exhausted all retries. Exposed so ops can detect and replay a\n * stuck transport; the list is never silently dropped/truncated.\n */\n async deadLettered(): Promise<OutboxRecord[]> {\n try {\n return await this.store.deadLettered();\n } catch (err) {\n this.logger.error('OutboxNotificationAdapter: failed to read dead-lettered records', err);\n return [];\n }\n }\n}\n","import type { Logger } from '../../utils/Logger.js';\nimport { noopLogger } from '../../utils/Logger.js';\nimport type { INotificationAdapter, NotificationEvent } from '../../adapters/INotificationAdapter.js';\n\n/** A child adapter paired with a stable name for diagnostics/logging. */\nexport interface NamedNotificationChild {\n /** Human-readable identity used when logging this child's failures. */\n name: string;\n adapter: INotificationAdapter;\n}\n\n/** Either a bare adapter or a {@link NamedNotificationChild}. */\nexport type CompositeChild = INotificationAdapter | NamedNotificationChild;\n\n/** Configuration for {@link CompositeNotificationAdapter}. */\nexport interface CompositeNotificationAdapterOptions {\n /** Child adapters to fan out to. May be empty (resolves as a no-op). */\n children: CompositeChild[];\n /** Structured logger. Defaults to {@link noopLogger}. */\n logger?: Logger;\n}\n\nfunction isNamed(child: CompositeChild): child is NamedNotificationChild {\n // A real bare adapter always exposes its own notify(); never treat it as a\n // NamedNotificationChild even if it happens to also carry an `adapter` property.\n if (typeof (child as INotificationAdapter).notify === 'function') return false;\n // Otherwise it's a NamedNotificationChild only if it wraps a non-null `adapter`\n // object whose own `.notify` is callable.\n const adapter = (child as NamedNotificationChild).adapter as unknown;\n return (\n typeof adapter === 'object' &&\n adapter !== null &&\n typeof (adapter as INotificationAdapter).notify === 'function'\n );\n}\n\n/**\n * Fans a single {@link notify} call out to N child {@link INotificationAdapter}s\n * concurrently.\n *\n * Uses `Promise.allSettled` so one failing/slow child never blocks delivery to\n * the others. Never throws: every child rejection is collected and logged with\n * that child's identity. Zero children resolves immediately as a no-op.\n *\n * Drop-in for `ApprovalEngineOptions.notificationAdapter`.\n */\nexport class CompositeNotificationAdapter implements INotificationAdapter {\n private readonly children: NamedNotificationChild[];\n private readonly logger: Logger;\n\n constructor(options: CompositeNotificationAdapterOptions) {\n this.children = options.children.map((child, i) =>\n isNamed(child) ? child : { name: `child[${i}]`, adapter: child },\n );\n this.logger = options.logger ?? noopLogger;\n }\n\n /**\n * Deliver the event to every child concurrently. Resolves once all children\n * settle; never rejects.\n */\n async notify(event: NotificationEvent): Promise<void> {\n if (this.children.length === 0) return;\n\n const results = await Promise.allSettled(\n // Wrap each call so a synchronous throw inside a child's notify is also\n // captured as a rejection rather than escaping the fan-out.\n this.children.map((child) =>\n Promise.resolve().then(() => child.adapter.notify(event)),\n ),\n );\n\n results.forEach((result, i) => {\n if (result.status === 'rejected') {\n const child = this.children[i]!;\n this.logger.error('CompositeNotificationAdapter: child failed to notify', result.reason, {\n child: child.name,\n type: event.type,\n instanceId: event.instanceId,\n tenantId: event.tenantId,\n });\n }\n });\n }\n}\n","import type { Logger } from '../../utils/Logger.js';\nimport { noopLogger } from '../../utils/Logger.js';\nimport type { ApprovalEventName } from '../../types/events.js';\nimport type { INotificationAdapter, NotificationEvent } from '../../adapters/INotificationAdapter.js';\n\n/** The fully rendered, channel-ready message handed to the send fn. */\nexport interface RenderedNotification {\n /** Logical delivery channel (e.g. 'email', 'slack', 'sms'). */\n channel: string;\n /** Resolved recipient address(es) for the channel. */\n to: string[];\n /** Short headline / email subject. */\n subject: string;\n /** Human-readable body. */\n body: string;\n}\n\n/** The shape a template function returns (channel/to are derived separately). */\nexport interface RenderedMessage {\n subject: string;\n body: string;\n}\n\n/**\n * A template entry for one {@link ApprovalEventName}. Either:\n * - a function `(event) => { subject, body }`, for full programmatic control, or\n * - a `{ subject, body }` pair of strings with `{placeholder}` tokens that are\n * interpolated from the event and its payload.\n */\nexport type NotificationTemplate =\n | ((event: NotificationEvent) => RenderedMessage)\n | { subject: string; body: string };\n\n/** Map of event name → template. Any subset of events may be configured. */\nexport type TemplateMap = Partial<Record<ApprovalEventName, NotificationTemplate>>;\n\n/** Side-effecting send function the adapter forwards rendered messages to. */\nexport type SendFn = (message: RenderedNotification) => void | Promise<void>;\n\n/** Configuration for {@link TemplatedNotificationAdapter}. */\nexport interface TemplatedNotificationAdapterOptions {\n /** Side-effecting send function. Required. */\n send: SendFn;\n /** Per-event templates. Events without an entry use {@link fallbackTemplate} (if any). */\n templates?: TemplateMap;\n /**\n * Template used when no per-event entry exists. If omitted, events without a\n * template are skipped (logged) rather than throwing. Set to a function or a\n * `{subject, body}` string pair to guarantee a message for every event.\n */\n fallbackTemplate?: NotificationTemplate;\n /**\n * Derive the channel for an event. Defaults to the constant `'default'`.\n */\n channelFor?: (event: NotificationEvent) => string;\n /**\n * Derive recipient address(es). Defaults to `event.recipients`. When this\n * returns an empty array the adapter falls back to {@link defaultRecipients}\n * (if set) and otherwise skips the send gracefully.\n */\n recipientsFor?: (event: NotificationEvent) => string[];\n /**\n * Recipients used when {@link recipientsFor} yields none (e.g. cancelled /\n * expired / sla_breached events carry empty `recipients`). If also empty the\n * send is skipped rather than dispatched to nobody.\n */\n defaultRecipients?: string[];\n /**\n * Token substituted for a `{placeholder}` that resolves to `undefined`/`null`\n * or references a field absent on the payload. Defaults to `''` (empty\n * string). Interpolation never throws on unknown placeholders.\n */\n unknownPlaceholderToken?: string;\n /** Structured logger. Defaults to {@link noopLogger}. */\n logger?: Logger;\n}\n\nconst PLACEHOLDER = /\\{([^{}]+)\\}/g;\n\n/**\n * Renders a human-readable message per {@link ApprovalEventName} from a\n * configurable template map and forwards `{ channel, to, subject, body }` to an\n * injected send function.\n *\n * Templates are resolved by event name; a missing template falls back to\n * `fallbackTemplate` or — if none is configured — the event is skipped (logged)\n * rather than throwing. String templates support `{placeholder}` interpolation\n * pulled from top-level event fields and `event.payload` fields; an unknown or\n * absent field renders to `unknownPlaceholderToken` and never throws.\n *\n * `notify()` never throws — send errors and any rendering issues are caught and\n * logged. Drop-in for `ApprovalEngineOptions.notificationAdapter`.\n */\nexport class TemplatedNotificationAdapter implements INotificationAdapter {\n private readonly send: SendFn;\n private readonly templates: TemplateMap;\n private readonly fallbackTemplate?: NotificationTemplate;\n private readonly channelFor: (event: NotificationEvent) => string;\n private readonly recipientsFor: (event: NotificationEvent) => string[];\n private readonly defaultRecipients: string[];\n private readonly unknownPlaceholderToken: string;\n private readonly logger: Logger;\n\n constructor(options: TemplatedNotificationAdapterOptions) {\n this.send = options.send;\n this.templates = options.templates ?? {};\n this.fallbackTemplate = options.fallbackTemplate;\n this.channelFor = options.channelFor ?? (() => 'default');\n this.recipientsFor = options.recipientsFor ?? ((event) => event.recipients ?? []);\n this.defaultRecipients = options.defaultRecipients ?? [];\n this.unknownPlaceholderToken = options.unknownPlaceholderToken ?? '';\n this.logger = options.logger ?? noopLogger;\n }\n\n /**\n * Render and dispatch the event. Never throws: missing templates, empty\n * recipients, and send failures are all handled and logged.\n */\n async notify(event: NotificationEvent): Promise<void> {\n try {\n const template = this.templates[event.type] ?? this.fallbackTemplate;\n if (!template) {\n this.logger.debug('TemplatedNotificationAdapter: no template for event, skipping', {\n type: event.type,\n instanceId: event.instanceId,\n });\n return;\n }\n\n const to = this.resolveRecipients(event);\n if (to.length === 0) {\n this.logger.debug('TemplatedNotificationAdapter: no recipients for event, skipping', {\n type: event.type,\n instanceId: event.instanceId,\n });\n return;\n }\n\n const rendered = this.render(template, event);\n const message: RenderedNotification = {\n channel: this.channelFor(event),\n to,\n subject: rendered.subject,\n body: rendered.body,\n };\n\n await this.send(message);\n } catch (err) {\n this.logger.error('TemplatedNotificationAdapter: failed to render/send notification', err, {\n type: event.type,\n instanceId: event.instanceId,\n tenantId: event.tenantId,\n });\n }\n }\n\n private resolveRecipients(event: NotificationEvent): string[] {\n const derived = this.recipientsFor(event);\n if (derived.length > 0) return derived;\n return this.defaultRecipients;\n }\n\n private render(template: NotificationTemplate, event: NotificationEvent): RenderedMessage {\n if (typeof template === 'function') {\n return template(event);\n }\n return {\n subject: this.interpolate(template.subject, event),\n body: this.interpolate(template.body, event),\n };\n }\n\n /**\n * Replace `{token}` occurrences in `text`. A token is resolved against\n * top-level event fields first, then `event.payload`. Dotted paths\n * (`payload.level`, `a.b.c`) are supported. Anything unresolved renders to\n * `unknownPlaceholderToken`. Never throws.\n */\n private interpolate(text: string, event: NotificationEvent): string {\n return text.replace(PLACEHOLDER, (_match, rawKey: string) => {\n const key = rawKey.trim();\n const value = this.lookup(event, key);\n if (value === undefined || value === null) return this.unknownPlaceholderToken;\n return this.stringify(value);\n });\n }\n\n /** Resolve a (possibly dotted) key against the event then its payload. */\n private lookup(event: NotificationEvent, key: string): unknown {\n const fromEvent = this.dig(event as unknown as Record<string, unknown>, key);\n if (fromEvent !== undefined) return fromEvent;\n return this.dig(event.payload as unknown as Record<string, unknown>, key);\n }\n\n private dig(root: Record<string, unknown> | undefined, path: string): unknown {\n if (!root) return undefined;\n let current: unknown = root;\n for (const segment of path.split('.')) {\n if (current === null || typeof current !== 'object') return undefined;\n current = (current as Record<string, unknown>)[segment];\n }\n return current;\n }\n\n private stringify(value: unknown): string {\n if (value instanceof Date) return value.toISOString();\n if (Array.isArray(value)) return value.map((v) => this.stringify(v)).join(', ');\n if (typeof value === 'object') {\n try {\n return JSON.stringify(value);\n } catch {\n return this.unknownPlaceholderToken;\n }\n }\n return String(value);\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/Clock.ts","../../src/utils/Logger.ts","../../src/plugins/notify/InMemoryOutboxStore.ts","../../src/plugins/notify/OutboxNotificationAdapter.ts","../../src/plugins/notify/CompositeNotificationAdapter.ts","../../src/plugins/notify/TemplatedNotificationAdapter.ts","../../src/plugins/notify/DigestNotificationAdapter.ts"],"names":[],"mappings":"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","file":"notify.js","sourcesContent":["export interface Clock {\n now(): Date;\n}\n\nexport const systemClock: Clock = { now: () => new Date() };\n","export interface Logger {\n info(msg: string, context?: Record<string, unknown>): void;\n warn(msg: string, context?: Record<string, unknown>): void;\n error(msg: string, err?: unknown, context?: Record<string, unknown>): void;\n fatal(msg: string, err?: unknown, context?: Record<string, unknown>): void;\n debug(msg: string, context?: Record<string, unknown>): void;\n}\n\nexport const noopLogger: Logger = {\n info: () => {},\n warn: () => {},\n error: () => {},\n fatal: () => {},\n debug: () => {},\n};\n","import type { IOutboxStore, OutboxRecord } from './IOutboxStore.js';\n\n/**\n * Default, dependency-free {@link IOutboxStore} backed by an in-process Map.\n *\n * Ordering: {@link due} and {@link pending} return records sorted by\n * `enqueuedAt` (then by id as a tiebreaker), giving FIFO best-effort within a\n * single `(tenant, instance)` partition. There is no cross-partition ordering\n * guarantee.\n *\n * Records are stored by reference; the adapter mutates and writes them back via\n * {@link update}, so reads reflect the latest state. This is intentional for the\n * in-memory case — a remote store would serialize instead.\n */\nexport class InMemoryOutboxStore implements IOutboxStore {\n private readonly records = new Map<string, OutboxRecord>();\n\n async enqueue(record: OutboxRecord): Promise<void> {\n this.records.set(record.id, record);\n }\n\n async due(now: number): Promise<OutboxRecord[]> {\n return this.sorted().filter((r) => r.status === 'pending' && r.nextAttemptAt <= now);\n }\n\n async update(record: OutboxRecord): Promise<void> {\n // Only persist if the record is still tracked (not removed concurrently).\n if (this.records.has(record.id)) {\n this.records.set(record.id, record);\n }\n }\n\n async remove(id: string): Promise<void> {\n this.records.delete(id);\n }\n\n async pending(): Promise<OutboxRecord[]> {\n return this.sorted().filter((r) => r.status === 'pending');\n }\n\n async deadLettered(): Promise<OutboxRecord[]> {\n return this.sorted().filter((r) => r.status === 'dead');\n }\n\n /** Test/ops helper — total records currently retained (pending + dead). */\n get size(): number {\n return this.records.size;\n }\n\n private sorted(): OutboxRecord[] {\n return [...this.records.values()].sort((a, b) =>\n a.enqueuedAt !== b.enqueuedAt ? a.enqueuedAt - b.enqueuedAt : a.id.localeCompare(b.id),\n );\n }\n}\n","import type { Clock } from '../../utils/Clock.js';\nimport { systemClock } from '../../utils/Clock.js';\nimport type { Logger } from '../../utils/Logger.js';\nimport { noopLogger } from '../../utils/Logger.js';\nimport type {\n INotificationAdapter,\n NotificationEvent,\n} from '../../adapters/INotificationAdapter.js';\nimport type { IOutboxStore, OutboxRecord } from './IOutboxStore.js';\nimport { InMemoryOutboxStore } from './InMemoryOutboxStore.js';\n\n/**\n * Transport that performs the actual side-effecting delivery of a single event\n * (send an email, post to a queue, call a webhook, …).\n *\n * It MAY throw synchronously or reject asynchronously — both are treated\n * identically as a failed attempt and trigger a retry. A normal resolution\n * counts as a successful delivery.\n */\nexport type NotificationTransport = (event: NotificationEvent) => void | Promise<void>;\n\n/** Configuration for {@link OutboxNotificationAdapter}. All fields optional except `transport`. */\nexport interface OutboxNotificationAdapterOptions {\n /** Side-effecting delivery function. Required. */\n transport: NotificationTransport;\n /** Persistence for queued events. Defaults to an {@link InMemoryOutboxStore}. */\n store?: IOutboxStore;\n /** Time source. Defaults to {@link systemClock}. Inject a manual clock for deterministic tests. */\n clock?: Clock;\n /** Structured logger. Defaults to {@link noopLogger}. */\n logger?: Logger;\n /**\n * Maximum delivery attempts before an event is dead-lettered. Must be >= 1.\n * `1` means no retries (single failure → dead-letter). Defaults to `5`.\n */\n maxAttempts?: number;\n /** Base backoff in milliseconds for the first retry. Defaults to `1000`. */\n baseDelayMs?: number;\n /** Multiplier applied per attempt (exponential). Defaults to `2`. */\n backoffFactor?: number;\n /**\n * Upper bound on a single backoff delay, in milliseconds. Caps the schedule so\n * very high attempt counts never overflow to `Infinity`/negative. Defaults to\n * `5 * 60_000` (5 minutes).\n */\n maxDelayMs?: number;\n /** Monotonic id generator for records. Defaults to a counter + timestamp. */\n idGenerator?: () => string;\n}\n\n/**\n * Reliable, store-and-forward {@link INotificationAdapter}.\n *\n * `notify()` only enqueues the event into a pluggable outbox store and returns;\n * it never throws (enqueue failures are caught, logged, and swallowed). Actual\n * delivery happens in {@link drain}, which is driven by ops (a poller/cron) or\n * tests. Delivery retries on failure with deterministic exponential backoff\n * computed from the injected {@link Clock}; on exhausting `maxAttempts` the\n * record is moved to a dead-letter list rather than dropped.\n *\n * Ordering: within a single `(tenantId, instanceId)` partition delivery is FIFO\n * best-effort (oldest-enqueued due record first). There is no ordering guarantee\n * across partitions, and a record awaiting a future retry does not block later\n * records in the same partition from being attempted.\n *\n * Drop-in for `ApprovalEngineOptions.notificationAdapter` with no engine change.\n */\nexport class OutboxNotificationAdapter implements INotificationAdapter {\n private readonly transport: NotificationTransport;\n private readonly store: IOutboxStore;\n private readonly clock: Clock;\n private readonly logger: Logger;\n private readonly maxAttempts: number;\n private readonly baseDelayMs: number;\n private readonly backoffFactor: number;\n private readonly maxDelayMs: number;\n private readonly idGenerator: () => string;\n private seq = 0;\n\n /** Guards against concurrent {@link drain} runs causing double-delivery. */\n private draining: Promise<number> | null = null;\n\n constructor(options: OutboxNotificationAdapterOptions) {\n this.transport = options.transport;\n this.store = options.store ?? new InMemoryOutboxStore();\n this.clock = options.clock ?? systemClock;\n this.logger = options.logger ?? noopLogger;\n this.maxAttempts = Math.max(1, Math.floor(options.maxAttempts ?? 5));\n this.baseDelayMs = Math.max(0, options.baseDelayMs ?? 1000);\n this.backoffFactor = options.backoffFactor ?? 2;\n this.maxDelayMs = Math.max(0, options.maxDelayMs ?? 5 * 60_000);\n this.idGenerator = options.idGenerator ?? (() => `${this.clock.now().getTime()}-${this.seq++}`);\n }\n\n /**\n * Enqueue an event for reliable delivery. Never throws: a failure to persist\n * is logged and swallowed so the engine's emit path is never broken.\n */\n async notify(event: NotificationEvent): Promise<void> {\n try {\n const now = this.clock.now().getTime();\n const record: OutboxRecord = {\n id: this.idGenerator(),\n partitionKey: `${event.tenantId}:${event.instanceId}`,\n tenantId: event.tenantId,\n event,\n status: 'pending',\n attempts: 0,\n nextAttemptAt: now,\n enqueuedAt: now,\n };\n await this.store.enqueue(record);\n } catch (err) {\n this.logger.error('OutboxNotificationAdapter: failed to enqueue event', err, {\n type: event.type,\n instanceId: event.instanceId,\n tenantId: event.tenantId,\n });\n }\n }\n\n /**\n * Attempt delivery of all currently due-and-pending records.\n *\n * Idempotent and safe to call repeatedly and concurrently: if a drain is\n * already in flight, the same promise is returned rather than starting a\n * second pass, so a delivered event is never delivered twice beyond\n * at-least-once semantics. Records whose `nextAttemptAt` is in the future are\n * not attempted prematurely. Never throws — store/transport errors are caught\n * and logged.\n *\n * @returns the number of records successfully delivered in this pass.\n */\n async drain(): Promise<number> {\n if (this.draining) return this.draining;\n this.draining = this.runDrain();\n try {\n return await this.draining;\n } finally {\n this.draining = null;\n }\n }\n\n private async runDrain(): Promise<number> {\n let delivered = 0;\n let due: OutboxRecord[];\n try {\n due = await this.store.due(this.clock.now().getTime());\n } catch (err) {\n this.logger.error('OutboxNotificationAdapter: failed to read due records', err);\n return 0;\n }\n\n for (const record of due) {\n // Re-check status defensively in case the store handed back a stale row.\n if (record.status !== 'pending') continue;\n const ok = await this.attemptDelivery(record);\n if (ok) delivered++;\n }\n return delivered;\n }\n\n /** Run one delivery attempt for a record and persist the resulting state. */\n private async attemptDelivery(record: OutboxRecord): Promise<boolean> {\n record.attempts++;\n try {\n // Await covers both async rejection and a returned promise; the try also\n // catches a synchronous throw from the transport.\n await this.transport(record.event);\n try {\n await this.store.remove(record.id);\n } catch (err) {\n // Delivery succeeded but cleanup failed: log. At-least-once means a\n // future drain may redeliver — acceptable and documented.\n this.logger.error('OutboxNotificationAdapter: delivered but failed to remove record', err, {\n id: record.id,\n tenantId: record.tenantId,\n });\n }\n this.logger.debug('OutboxNotificationAdapter: delivered', {\n id: record.id,\n type: record.event.type,\n attempts: record.attempts,\n });\n return true;\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n record.lastError = message;\n if (record.attempts >= this.maxAttempts) {\n record.status = 'dead';\n this.logger.error(\n 'OutboxNotificationAdapter: dead-lettered after exhausting retries',\n err,\n {\n id: record.id,\n tenantId: record.tenantId,\n attempts: record.attempts,\n maxAttempts: this.maxAttempts,\n },\n );\n } else {\n record.nextAttemptAt = this.clock.now().getTime() + this.computeBackoff(record.attempts);\n this.logger.warn('OutboxNotificationAdapter: delivery failed, scheduling retry', {\n id: record.id,\n tenantId: record.tenantId,\n attempts: record.attempts,\n nextAttemptAt: record.nextAttemptAt,\n error: message,\n });\n }\n try {\n await this.store.update(record);\n } catch (updateErr) {\n this.logger.error('OutboxNotificationAdapter: failed to persist record state', updateErr, {\n id: record.id,\n });\n }\n return false;\n }\n }\n\n /**\n * Deterministic exponential backoff for the Nth attempt (1-based), capped at\n * `maxDelayMs`. Guards against overflow: a non-finite intermediate value\n * collapses to the cap, so very high attempt counts never yield\n * `Infinity`/`NaN`/negative delays.\n */\n private computeBackoff(attempt: number): number {\n const raw = this.baseDelayMs * Math.pow(this.backoffFactor, attempt - 1);\n if (!Number.isFinite(raw) || raw < 0) return this.maxDelayMs;\n return Math.min(raw, this.maxDelayMs);\n }\n\n /**\n * Records still awaiting first delivery or a retry. Exposed for ops dashboards\n * and tests so a stuck transport (growing pending list) is observable.\n */\n async pending(): Promise<OutboxRecord[]> {\n try {\n return await this.store.pending();\n } catch (err) {\n this.logger.error('OutboxNotificationAdapter: failed to read pending records', err);\n return [];\n }\n }\n\n /**\n * Records that exhausted all retries. Exposed so ops can detect and replay a\n * stuck transport; the list is never silently dropped/truncated.\n */\n async deadLettered(): Promise<OutboxRecord[]> {\n try {\n return await this.store.deadLettered();\n } catch (err) {\n this.logger.error('OutboxNotificationAdapter: failed to read dead-lettered records', err);\n return [];\n }\n }\n}\n","import type { Logger } from '../../utils/Logger.js';\nimport { noopLogger } from '../../utils/Logger.js';\nimport type {\n INotificationAdapter,\n NotificationEvent,\n} from '../../adapters/INotificationAdapter.js';\n\n/** A child adapter paired with a stable name for diagnostics/logging. */\nexport interface NamedNotificationChild {\n /** Human-readable identity used when logging this child's failures. */\n name: string;\n adapter: INotificationAdapter;\n}\n\n/** Either a bare adapter or a {@link NamedNotificationChild}. */\nexport type CompositeChild = INotificationAdapter | NamedNotificationChild;\n\n/** Configuration for {@link CompositeNotificationAdapter}. */\nexport interface CompositeNotificationAdapterOptions {\n /** Child adapters to fan out to. May be empty (resolves as a no-op). */\n children: CompositeChild[];\n /** Structured logger. Defaults to {@link noopLogger}. */\n logger?: Logger;\n}\n\nfunction isNamed(child: CompositeChild): child is NamedNotificationChild {\n // A real bare adapter always exposes its own notify(); never treat it as a\n // NamedNotificationChild even if it happens to also carry an `adapter` property.\n if (typeof (child as INotificationAdapter).notify === 'function') return false;\n // Otherwise it's a NamedNotificationChild only if it wraps a non-null `adapter`\n // object whose own `.notify` is callable.\n const adapter = (child as NamedNotificationChild).adapter as unknown;\n return (\n typeof adapter === 'object' &&\n adapter !== null &&\n typeof (adapter as INotificationAdapter).notify === 'function'\n );\n}\n\n/**\n * Fans a single {@link notify} call out to N child {@link INotificationAdapter}s\n * concurrently.\n *\n * Uses `Promise.allSettled` so one failing/slow child never blocks delivery to\n * the others. Never throws: every child rejection is collected and logged with\n * that child's identity. Zero children resolves immediately as a no-op.\n *\n * Drop-in for `ApprovalEngineOptions.notificationAdapter`.\n */\nexport class CompositeNotificationAdapter implements INotificationAdapter {\n private readonly children: NamedNotificationChild[];\n private readonly logger: Logger;\n\n constructor(options: CompositeNotificationAdapterOptions) {\n this.children = options.children.map((child, i) =>\n isNamed(child) ? child : { name: `child[${i}]`, adapter: child },\n );\n this.logger = options.logger ?? noopLogger;\n }\n\n /**\n * Deliver the event to every child concurrently. Resolves once all children\n * settle; never rejects.\n */\n async notify(event: NotificationEvent): Promise<void> {\n if (this.children.length === 0) return;\n\n const results = await Promise.allSettled(\n // Wrap each call so a synchronous throw inside a child's notify is also\n // captured as a rejection rather than escaping the fan-out.\n this.children.map((child) => Promise.resolve().then(() => child.adapter.notify(event))),\n );\n\n results.forEach((result, i) => {\n if (result.status === 'rejected') {\n const child = this.children[i]!;\n this.logger.error('CompositeNotificationAdapter: child failed to notify', result.reason, {\n child: child.name,\n type: event.type,\n instanceId: event.instanceId,\n tenantId: event.tenantId,\n });\n }\n });\n }\n}\n","import type { Logger } from '../../utils/Logger.js';\nimport { noopLogger } from '../../utils/Logger.js';\nimport type { ApprovalEventName } from '../../types/events.js';\nimport type {\n INotificationAdapter,\n NotificationEvent,\n} from '../../adapters/INotificationAdapter.js';\n\n/** The fully rendered, channel-ready message handed to the send fn. */\nexport interface RenderedNotification {\n /** Logical delivery channel (e.g. 'email', 'slack', 'sms'). */\n channel: string;\n /** Resolved recipient address(es) for the channel. */\n to: string[];\n /** Short headline / email subject. */\n subject: string;\n /** Human-readable body. */\n body: string;\n}\n\n/** The shape a template function returns (channel/to are derived separately). */\nexport interface RenderedMessage {\n subject: string;\n body: string;\n}\n\n/**\n * A template entry for one {@link ApprovalEventName}. Either:\n * - a function `(event) => { subject, body }`, for full programmatic control, or\n * - a `{ subject, body }` pair of strings with `{placeholder}` tokens that are\n * interpolated from the event and its payload.\n */\nexport type NotificationTemplate =\n | ((event: NotificationEvent) => RenderedMessage)\n | { subject: string; body: string };\n\n/** Map of event name → template. Any subset of events may be configured. */\nexport type TemplateMap = Partial<Record<ApprovalEventName, NotificationTemplate>>;\n\n/** Side-effecting send function the adapter forwards rendered messages to. */\nexport type SendFn = (message: RenderedNotification) => void | Promise<void>;\n\n/** Configuration for {@link TemplatedNotificationAdapter}. */\nexport interface TemplatedNotificationAdapterOptions {\n /** Side-effecting send function. Required. */\n send: SendFn;\n /** Per-event templates. Events without an entry use {@link fallbackTemplate} (if any). */\n templates?: TemplateMap;\n /**\n * Template used when no per-event entry exists. If omitted, events without a\n * template are skipped (logged) rather than throwing. Set to a function or a\n * `{subject, body}` string pair to guarantee a message for every event.\n */\n fallbackTemplate?: NotificationTemplate;\n /**\n * Derive the channel for an event. Defaults to the constant `'default'`.\n */\n channelFor?: (event: NotificationEvent) => string;\n /**\n * Derive recipient address(es). Defaults to `event.recipients`. When this\n * returns an empty array the adapter falls back to {@link defaultRecipients}\n * (if set) and otherwise skips the send gracefully.\n */\n recipientsFor?: (event: NotificationEvent) => string[];\n /**\n * Recipients used when {@link recipientsFor} yields none (e.g. cancelled /\n * expired / sla_breached events carry empty `recipients`). If also empty the\n * send is skipped rather than dispatched to nobody.\n */\n defaultRecipients?: string[];\n /**\n * Token substituted for a `{placeholder}` that resolves to `undefined`/`null`\n * or references a field absent on the payload. Defaults to `''` (empty\n * string). Interpolation never throws on unknown placeholders.\n */\n unknownPlaceholderToken?: string;\n /** Structured logger. Defaults to {@link noopLogger}. */\n logger?: Logger;\n}\n\nconst PLACEHOLDER = /\\{([^{}]+)\\}/g;\n\n/**\n * Renders a human-readable message per {@link ApprovalEventName} from a\n * configurable template map and forwards `{ channel, to, subject, body }` to an\n * injected send function.\n *\n * Templates are resolved by event name; a missing template falls back to\n * `fallbackTemplate` or — if none is configured — the event is skipped (logged)\n * rather than throwing. String templates support `{placeholder}` interpolation\n * pulled from top-level event fields and `event.payload` fields; an unknown or\n * absent field renders to `unknownPlaceholderToken` and never throws.\n *\n * `notify()` never throws — send errors and any rendering issues are caught and\n * logged. Drop-in for `ApprovalEngineOptions.notificationAdapter`.\n */\nexport class TemplatedNotificationAdapter implements INotificationAdapter {\n private readonly send: SendFn;\n private readonly templates: TemplateMap;\n private readonly fallbackTemplate?: NotificationTemplate;\n private readonly channelFor: (event: NotificationEvent) => string;\n private readonly recipientsFor: (event: NotificationEvent) => string[];\n private readonly defaultRecipients: string[];\n private readonly unknownPlaceholderToken: string;\n private readonly logger: Logger;\n\n constructor(options: TemplatedNotificationAdapterOptions) {\n this.send = options.send;\n this.templates = options.templates ?? {};\n this.fallbackTemplate = options.fallbackTemplate;\n this.channelFor = options.channelFor ?? (() => 'default');\n this.recipientsFor = options.recipientsFor ?? ((event) => event.recipients ?? []);\n this.defaultRecipients = options.defaultRecipients ?? [];\n this.unknownPlaceholderToken = options.unknownPlaceholderToken ?? '';\n this.logger = options.logger ?? noopLogger;\n }\n\n /**\n * Render and dispatch the event. Never throws: missing templates, empty\n * recipients, and send failures are all handled and logged.\n */\n async notify(event: NotificationEvent): Promise<void> {\n try {\n const template = this.templates[event.type] ?? this.fallbackTemplate;\n if (!template) {\n this.logger.debug('TemplatedNotificationAdapter: no template for event, skipping', {\n type: event.type,\n instanceId: event.instanceId,\n });\n return;\n }\n\n const to = this.resolveRecipients(event);\n if (to.length === 0) {\n this.logger.debug('TemplatedNotificationAdapter: no recipients for event, skipping', {\n type: event.type,\n instanceId: event.instanceId,\n });\n return;\n }\n\n const rendered = this.render(template, event);\n const message: RenderedNotification = {\n channel: this.channelFor(event),\n to,\n subject: rendered.subject,\n body: rendered.body,\n };\n\n await this.send(message);\n } catch (err) {\n this.logger.error('TemplatedNotificationAdapter: failed to render/send notification', err, {\n type: event.type,\n instanceId: event.instanceId,\n tenantId: event.tenantId,\n });\n }\n }\n\n private resolveRecipients(event: NotificationEvent): string[] {\n const derived = this.recipientsFor(event);\n if (derived.length > 0) return derived;\n return this.defaultRecipients;\n }\n\n private render(template: NotificationTemplate, event: NotificationEvent): RenderedMessage {\n if (typeof template === 'function') {\n return template(event);\n }\n return {\n subject: this.interpolate(template.subject, event),\n body: this.interpolate(template.body, event),\n };\n }\n\n /**\n * Replace `{token}` occurrences in `text`. A token is resolved against\n * top-level event fields first, then `event.payload`. Dotted paths\n * (`payload.level`, `a.b.c`) are supported. Anything unresolved renders to\n * `unknownPlaceholderToken`. Never throws.\n */\n private interpolate(text: string, event: NotificationEvent): string {\n return text.replace(PLACEHOLDER, (_match, rawKey: string) => {\n const key = rawKey.trim();\n const value = this.lookup(event, key);\n if (value === undefined || value === null) return this.unknownPlaceholderToken;\n return this.stringify(value);\n });\n }\n\n /** Resolve a (possibly dotted) key against the event then its payload. */\n private lookup(event: NotificationEvent, key: string): unknown {\n const fromEvent = this.dig(event as unknown as Record<string, unknown>, key);\n if (fromEvent !== undefined) return fromEvent;\n return this.dig(event.payload as unknown as Record<string, unknown>, key);\n }\n\n private dig(root: Record<string, unknown> | undefined, path: string): unknown {\n if (!root) return undefined;\n let current: unknown = root;\n for (const segment of path.split('.')) {\n if (current === null || typeof current !== 'object') return undefined;\n current = (current as Record<string, unknown>)[segment];\n }\n return current;\n }\n\n private stringify(value: unknown): string {\n if (value instanceof Date) return value.toISOString();\n if (Array.isArray(value)) return value.map((v) => this.stringify(v)).join(', ');\n if (typeof value === 'object') {\n try {\n return JSON.stringify(value);\n } catch {\n return this.unknownPlaceholderToken;\n }\n }\n return String(value);\n }\n}\n","import type { Clock } from '../../utils/Clock.js';\nimport { systemClock } from '../../utils/Clock.js';\nimport type { Logger } from '../../utils/Logger.js';\nimport { noopLogger } from '../../utils/Logger.js';\nimport type {\n INotificationAdapter,\n NotificationEvent,\n} from '../../adapters/INotificationAdapter.js';\n\n/** One recipient's accumulated events, handed to {@link DigestSendFn} on flush. */\nexport interface Digest {\n recipient: string;\n /** Events for this recipient, oldest first. */\n events: NotificationEvent[];\n /** When the earliest event in this digest arrived. */\n since: Date;\n /** When the digest was flushed. */\n flushedAt: Date;\n}\n\n/** Delivers one recipient's digest. Must not throw — failures are logged and swallowed. */\nexport type DigestSendFn = (digest: Digest) => Promise<void> | void;\n\nexport interface DigestNotificationAdapterOptions {\n /** Called once per recipient per flush. */\n send: DigestSendFn;\n /**\n * Event types delivered immediately instead of being batched. A rejection or\n * a completed approval is news the recipient acts on now; batching it behind\n * a digest window would make the library's own notifications the reason a\n * decision was late.\n *\n * Defaults to rejections, completions, SLA breaches and expiries.\n */\n passthrough?: NotificationEvent['type'][];\n /**\n * Flush a recipient's digest once it reaches this many events, regardless of\n * the timer. Prevents an unbounded buffer under a burst.\n */\n maxBatchSize?: number;\n /**\n * Flush every recipient this often, in milliseconds. Omit to disable the\n * timer and flush only via {@link DigestNotificationAdapter.flush} — the right\n * choice when a cron job or queue worker owns the schedule.\n */\n intervalMs?: number;\n logger?: Logger;\n clock?: Clock;\n}\n\n/** Event types that reach the recipient immediately unless the caller says otherwise. */\nconst DEFAULT_PASSTHROUGH: NotificationEvent['type'][] = [\n 'approval:rejected',\n 'approval:completed',\n 'approval:sla_breached',\n 'approval:expired',\n];\n\nconst DEFAULT_MAX_BATCH_SIZE = 50;\n\n/**\n * Batches notifications per recipient instead of sending one per event.\n *\n * An approver on twenty documents receives twenty separate messages a day from\n * a naive adapter, which is how approval email ends up filtered into a folder\n * nobody reads — the notifications defeat themselves. This collects events per\n * recipient and delivers one digest.\n *\n * **Urgent events still go straight through.** Batching a rejection or a\n * completed approval behind a digest window would make the library's own\n * notifications the reason a decision was late, so those bypass the buffer by\n * default; see {@link DigestNotificationAdapterOptions.passthrough}.\n *\n * Buffers live in memory. A process restart drops whatever has not been\n * flushed, which is the right trade for a convenience digest but not for\n * delivery guarantees — put {@link OutboxNotificationAdapter} underneath when\n * an event must not be lost.\n *\n * @example\n * ```ts\n * const digest = new DigestNotificationAdapter({\n * intervalMs: 15 * 60_000,\n * send: async ({ recipient, events }) => mailer.send(recipient, summarise(events)),\n * });\n * const engine = new ApprovalEngine({ adapter, notificationAdapter: digest });\n * // ...on shutdown\n * await digest.stop();\n * ```\n */\nexport class DigestNotificationAdapter implements INotificationAdapter {\n private readonly buffers = new Map<string, { events: NotificationEvent[]; since: Date }>();\n private readonly send: DigestSendFn;\n private readonly passthrough: Set<NotificationEvent['type']>;\n private readonly maxBatchSize: number;\n private readonly intervalMs?: number;\n private readonly logger: Logger;\n private readonly clock: Clock;\n private timer: ReturnType<typeof setInterval> | null = null;\n\n constructor(opts: DigestNotificationAdapterOptions) {\n this.send = opts.send;\n this.passthrough = new Set(opts.passthrough ?? DEFAULT_PASSTHROUGH);\n this.maxBatchSize = opts.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;\n this.intervalMs = opts.intervalMs;\n this.logger = opts.logger ?? noopLogger;\n this.clock = opts.clock ?? systemClock;\n\n if (this.intervalMs !== undefined) {\n if (this.intervalMs <= 0) {\n throw new Error('DigestNotificationAdapter: intervalMs must be a positive number.');\n }\n this.timer = setInterval(() => {\n void this.flush().catch((err) => {\n this.logger.error('DigestNotificationAdapter: scheduled flush failed', err);\n });\n }, this.intervalMs);\n // Never hold the process open for a convenience digest.\n this.timer.unref?.();\n }\n }\n\n /** Recipients currently holding buffered events. */\n get pendingRecipients(): number {\n return this.buffers.size;\n }\n\n async notify(event: NotificationEvent): Promise<void> {\n if (this.passthrough.has(event.type)) {\n await this.deliver({\n recipient: '',\n events: [event],\n since: event.timestamp,\n flushedAt: this.clock.now(),\n });\n return;\n }\n\n const now = this.clock.now();\n const full: string[] = [];\n\n for (const recipient of event.recipients) {\n const buffer = this.buffers.get(recipient) ?? { events: [], since: now };\n buffer.events.push(event);\n this.buffers.set(recipient, buffer);\n if (buffer.events.length >= this.maxBatchSize) full.push(recipient);\n }\n\n // Flush over-full recipients only; a burst aimed at one person must not\n // force everybody else's digest out early.\n for (const recipient of full) {\n await this.flushRecipient(recipient);\n }\n }\n\n /** Deliver every buffered digest now. Safe to call from a cron job or on shutdown. */\n async flush(): Promise<void> {\n for (const recipient of [...this.buffers.keys()]) {\n await this.flushRecipient(recipient);\n }\n }\n\n /** Stop the timer and deliver whatever is buffered. */\n async stop(): Promise<void> {\n if (this.timer !== null) {\n clearInterval(this.timer);\n this.timer = null;\n }\n await this.flush();\n }\n\n private async flushRecipient(recipient: string): Promise<void> {\n const buffer = this.buffers.get(recipient);\n if (!buffer || buffer.events.length === 0) return;\n // Drop the buffer before sending: a send that throws must not replay the\n // same events into the next digest forever.\n this.buffers.delete(recipient);\n\n await this.deliver({\n recipient,\n events: buffer.events,\n since: buffer.since,\n flushedAt: this.clock.now(),\n });\n }\n\n private async deliver(digest: Digest): Promise<void> {\n try {\n await this.send(digest);\n } catch (err) {\n this.logger.error('DigestNotificationAdapter: send failed', err, {\n recipient: digest.recipient,\n events: digest.events.length,\n });\n }\n }\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hierarchical-approval",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "TypeScript-first hierarchical approval engine for ERP developers",
5
5
  "keywords": [
6
6
  "approval",