@better-forge/notify 0.1.0 → 0.1.1

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/src/engine.ts CHANGED
@@ -1,423 +1,423 @@
1
- import { and, asc, desc, eq, gt, gte, inArray, isNotNull, like, lt, lte, ne, sql } from 'drizzle-orm'
2
- import type { NotificationSuppressionReason, VaultRef } from '@better-forge/contracts'
3
- import type { SqliteDatabase } from '@better-forge/db'
4
- import { createAlertPolicy, isWithinQuietHours, quietHoursEndAt, type AlertPolicy } from './alertPolicy.js'
5
- import type { NotifyTables } from './tables.js'
6
- import { MAX_DELIVERY_ATTEMPTS, RETRY_DELAYS_MS } from './template.js'
7
-
8
- export type ChannelRow = NotifyTables['notificationChannels']['$inferSelect']
9
- export type DeliveryRow = NotifyTables['notificationDeliveries']['$inferSelect']
10
-
11
- /** What a delivery is about. A delivery without a source (a test, a digest) has none. */
12
- export interface DeliveryDetails {
13
- sourceId: number | null
14
- sourceName: string
15
- eventType: string
16
- }
17
-
18
- export interface NotifyEngineOptions {
19
- db: SqliteDatabase
20
- tables: NotifyTables
21
- /** Stored name of the grouping threshold in a channel's alert policy; see `createAlertPolicy`. */
22
- minSourcesKey: string
23
- /** The event types the application raises that alert hygiene treats specially. */
24
- events: {
25
- /** Rate-capped per source; the only event a quiet-hours or throttle rule can drop. */
26
- alert: string
27
- /** Closes an alert. Together with `alert` it tells whether a source still has an alert open. */
28
- recovery: string
29
- /** Never folded into a digest. */
30
- ungrouped: string
31
- }
32
- /** Sends one delivery's variables through the channel. Throws on failure. */
33
- deliver: (channel: ChannelRow, vars: Record<string, string>) => Promise<void>
34
- /** The notification that stands in for several single-source events: its variables and the name it is recorded under. */
35
- digest: (rows: DeliveryRow[], now: number) => { vars: Record<string, string>; sourceName: string }
36
- /** Resolves a vault reference to its secret fields (used for SMTP credentials). */
37
- resolveSecret: (ref: VaultRef) => Promise<Record<string, string>>
38
- }
39
-
40
- export interface SmtpMessage {
41
- to: string
42
- subject: string
43
- text: string
44
- /** Sent alongside `text` as multipart/alternative; clients that cannot render HTML show the text. */
45
- html?: string
46
- headers?: Record<string, string>
47
- }
48
-
49
- type DeliveryVariant =
50
- | { status: 'pending'; releaseAt?: number; groupKey?: string | null }
51
- | { status: 'suppressed'; reason: NotificationSuppressionReason }
52
-
53
- interface DeliveryPlan {
54
- /** When the delivery may first be attempted. */
55
- releaseAt: number
56
- /** Digest window this delivery belongs to, or null when it stands alone. */
57
- groupKey: string | null
58
- suppressionReason: NotificationSuppressionReason | null
59
- }
60
-
61
- /** Delivery history older than this is purged. */
62
- const DELIVERY_RETENTION_MS = 180 * 24 * 60 * 60 * 1000
63
-
64
- /**
65
- * The delivery engine: records every notification, applies the channel's alert hygiene, sends with retries, folds
66
- * bursts into digests and sends SMTP mail. Locks and in-flight sets live in the returned closure, so two engines
67
- * never share state.
68
- */
69
- export function createNotifyEngine(options: NotifyEngineOptions) {
70
- const { db, deliver, resolveSecret } = options
71
- const { notificationChannels, notificationDeliveries, notificationDeliveryAttempts, smtpSettings } = options.tables
72
- const { parseAlertPolicy } = createAlertPolicy({ minSourcesKey: options.minSourcesKey })
73
- const activeDeliveries = new Set<number>()
74
-
75
- /**
76
- * Writes one delivery row. A pending row is picked up for sending at `releaseAt`; a suppressed row
77
- * records an event that alert hygiene dropped, so the history still shows what was not sent.
78
- */
79
- async function insertDelivery(
80
- channel: ChannelRow,
81
- vars: Record<string, string>,
82
- details: DeliveryDetails,
83
- variant: DeliveryVariant,
84
- ): Promise<number> {
85
- const now = Date.now()
86
- const [delivery] = await db.insert(notificationDeliveries).values({
87
- channelId: channel.id,
88
- channelName: channel.name,
89
- channelType: channel.type,
90
- sourceId: details.sourceId,
91
- sourceName: details.sourceName,
92
- eventType: details.eventType,
93
- status: variant.status,
94
- targetStatus: vars['status'] ?? 'unknown',
95
- previousStatus: vars['previous_status'] ?? 'unknown',
96
- variables: JSON.stringify(vars),
97
- attemptCount: 0,
98
- maxAttempts: MAX_DELIVERY_ATTEMPTS,
99
- ...(variant.status === 'pending'
100
- ? { nextAttemptAt: variant.releaseAt ?? now, groupKey: variant.groupKey ?? null }
101
- : { nextAttemptAt: null, suppressionReason: variant.reason }),
102
- createdAt: now,
103
- updatedAt: now,
104
- }).returning({ id: notificationDeliveries.id })
105
- return delivery!.id
106
- }
107
-
108
- async function attemptDelivery(deliveryId: number, now = Date.now()): Promise<void> {
109
- if (activeDeliveries.has(deliveryId)) return
110
- activeDeliveries.add(deliveryId)
111
- try {
112
- const delivery = (await db.select().from(notificationDeliveries).where(eq(notificationDeliveries.id, deliveryId)))[0]
113
- if (!delivery || delivery.status === 'delivered' || delivery.attemptCount >= delivery.maxAttempts) return
114
- const channel = (await db.select().from(notificationChannels).where(eq(notificationChannels.id, delivery.channelId)))[0]
115
- const attemptNumber = delivery.attemptCount + 1
116
- const startedAt = Date.now()
117
- try {
118
- if (!channel) throw new Error('Notification channel no longer exists')
119
- if (channel.enabled !== 1) throw new Error('Notification channel is disabled')
120
- await deliver(channel, JSON.parse(delivery.variables) as Record<string, string>)
121
- const completedAt = Date.now()
122
- await db.insert(notificationDeliveryAttempts).values({ deliveryId, attemptNumber, status: 'delivered', error: null, startedAt, completedAt })
123
- await db.update(notificationDeliveries).set({
124
- status: 'delivered', attemptCount: attemptNumber, nextAttemptAt: null,
125
- lastAttemptAt: completedAt, deliveredAt: completedAt, lastError: null, updatedAt: completedAt,
126
- }).where(eq(notificationDeliveries.id, deliveryId))
127
- } catch (error) {
128
- const completedAt = Date.now()
129
- const message = error instanceof Error ? error.message : String(error)
130
- const exhausted = attemptNumber >= delivery.maxAttempts
131
- const delay = RETRY_DELAYS_MS[Math.min(attemptNumber - 1, RETRY_DELAYS_MS.length - 1)]!
132
- await db.insert(notificationDeliveryAttempts).values({ deliveryId, attemptNumber, status: 'failed', error: message, startedAt, completedAt })
133
- await db.update(notificationDeliveries).set({
134
- status: exhausted ? 'failed' : 'pending', attemptCount: attemptNumber,
135
- nextAttemptAt: exhausted ? null : now + delay, lastAttemptAt: completedAt,
136
- lastError: message, updatedAt: completedAt,
137
- }).where(eq(notificationDeliveries.id, deliveryId))
138
- console.error(`[notifier] Delivery ${deliveryId}, attempt ${attemptNumber} failed: ${message}`)
139
- }
140
- } finally { activeDeliveries.delete(deliveryId) }
141
- }
142
-
143
- // ── Alert hygiene: rate caps, quiet hours and digest windows ───────────────
144
-
145
- /** Alerts already sent or queued for this source on this channel inside the throttle window. */
146
- async function countRecentAlerts(channelId: number, sourceId: number, windowStart: number): Promise<number> {
147
- const [row] = await db.select({ count: sql<number>`count(*)` }).from(notificationDeliveries).where(and(
148
- eq(notificationDeliveries.channelId, channelId),
149
- eq(notificationDeliveries.sourceId, sourceId),
150
- eq(notificationDeliveries.eventType, options.events.alert),
151
- ne(notificationDeliveries.status, 'suppressed'),
152
- gte(notificationDeliveries.createdAt, windowStart),
153
- ))
154
- return row?.count ?? 0
155
- }
156
-
157
- /** The digest window this channel already has open, so a burst lands in a single group. */
158
- async function findOpenGroup(channelId: number, eventType: string, now: number): Promise<{ groupKey: string; releaseAt: number } | null> {
159
- const [row] = await db.select({ groupKey: notificationDeliveries.groupKey, releaseAt: notificationDeliveries.nextAttemptAt })
160
- .from(notificationDeliveries)
161
- .where(and(
162
- eq(notificationDeliveries.channelId, channelId),
163
- eq(notificationDeliveries.status, 'pending'),
164
- like(notificationDeliveries.groupKey, `ch${channelId}:${eventType}:w%`),
165
- gt(notificationDeliveries.nextAttemptAt, now),
166
- ))
167
- .orderBy(asc(notificationDeliveries.nextAttemptAt))
168
- .limit(1)
169
- return row?.groupKey && row.releaseAt !== null ? { groupKey: row.groupKey, releaseAt: row.releaseAt } : null
170
- }
171
-
172
- /**
173
- * Applies a channel's alert hygiene to one event. Order matters: the rate cap runs first so a
174
- * throttled alert never holds a digest window open, then quiet hours decide whether the event is
175
- * dropped or held, then grouping decides which digest it joins.
176
- */
177
- async function planDelivery(
178
- channel: ChannelRow,
179
- policy: AlertPolicy,
180
- details: DeliveryDetails,
181
- now: number,
182
- ): Promise<DeliveryPlan> {
183
- // Recoveries are never throttled — the all-clear must always get through.
184
- if (policy.throttle.enabled && details.eventType === options.events.alert && details.sourceId !== null) {
185
- const windowStart = now - policy.throttle.windowMinutes * 60_000
186
- const recent = await countRecentAlerts(channel.id, details.sourceId, windowStart)
187
- if (recent >= policy.throttle.maxAlerts) return { releaseAt: now, groupKey: null, suppressionReason: 'throttled' }
188
- }
189
-
190
- let releaseAt = now
191
- let quietUntil: number | null = null
192
- if (isWithinQuietHours(policy.quietHours, now)) {
193
- if (policy.quietHours.mode === 'suppress') return { releaseAt: now, groupKey: null, suppressionReason: 'quiet-hours' }
194
- quietUntil = quietHoursEndAt(policy.quietHours, now)
195
- releaseAt = Math.max(releaseAt, quietUntil)
196
- }
197
-
198
- let groupKey: string | null = null
199
- if (policy.grouping.enabled && details.eventType !== options.events.ungrouped) {
200
- if (quietUntil !== null) {
201
- // Everything held for the same quiet window is released at once — digest it as one message.
202
- groupKey = `ch${channel.id}:${details.eventType}:q${quietUntil}`
203
- } else {
204
- const open = await findOpenGroup(channel.id, details.eventType, now)
205
- if (open) {
206
- groupKey = open.groupKey
207
- releaseAt = Math.max(releaseAt, open.releaseAt)
208
- } else {
209
- groupKey = `ch${channel.id}:${details.eventType}:w${now}`
210
- releaseAt = Math.max(releaseAt, now + policy.grouping.windowSeconds * 1000)
211
- }
212
- }
213
- }
214
-
215
- return { releaseAt, groupKey, suppressionReason: null }
216
- }
217
-
218
- /**
219
- * Planning reads a channel's recent deliveries and then writes one. Two events for the same
220
- * channel must not interleave across those awaits, or a burst — precisely what grouping exists
221
- * for — would open one digest window per source, and a rate cap would be counted from a stale
222
- * total. Sending happens outside the lock so a slow endpoint never stalls the queue.
223
- */
224
- const channelLocks = new Map<number, Promise<unknown>>()
225
-
226
- function withChannelLock<T>(channelId: number, work: () => Promise<T>): Promise<T> {
227
- const previous = channelLocks.get(channelId) ?? Promise.resolve()
228
- const next = previous.then(work, work)
229
- channelLocks.set(channelId, next.catch(() => undefined))
230
- return next
231
- }
232
-
233
- /**
234
- * Collapses every due digest window into a single notification. A window that did not reach
235
- * `minSources` distinct sources is simply released as ordinary individual deliveries, so
236
- * grouping costs at most `windowSeconds` of latency and never swallows a lone alert.
237
- */
238
- let flushChain: Promise<unknown> = Promise.resolve()
239
-
240
- function flushGroups(now = Date.now()): Promise<number> {
241
- // The retry worker fires every 30s without awaiting the previous pass; overlapping flushes
242
- // would each turn the same window into its own digest.
243
- const run = () => flushDueGroups(now)
244
- const next = flushChain.then(run, run)
245
- flushChain = next.catch(() => undefined)
246
- return next
247
- }
248
-
249
- async function flushDueGroups(now: number): Promise<number> {
250
- const pending = await db.select().from(notificationDeliveries).where(and(
251
- eq(notificationDeliveries.status, 'pending'),
252
- isNotNull(notificationDeliveries.groupKey),
253
- lte(notificationDeliveries.nextAttemptAt, now),
254
- )).orderBy(asc(notificationDeliveries.createdAt))
255
- if (pending.length === 0) return 0
256
-
257
- const groups = new Map<string, DeliveryRow[]>()
258
- for (const row of pending) {
259
- const bucket = groups.get(row.groupKey!)
260
- if (bucket) bucket.push(row)
261
- else groups.set(row.groupKey!, [row])
262
- }
263
-
264
- let digests = 0
265
- for (const rows of groups.values()) {
266
- const ids = rows.map((row) => row.id)
267
- const channel = (await db.select().from(notificationChannels).where(eq(notificationChannels.id, rows[0]!.channelId)))[0]
268
- const policy = channel ? parseAlertPolicy(channel.alertPolicy) : null
269
- const distinctSources = new Set(rows.map((row) => row.sourceId ?? -row.id)).size
270
-
271
- if (!channel || !policy?.grouping.enabled || distinctSources < policy.grouping.minSources) {
272
- // Not a burst — or grouping was switched off mid-window. Send them individually.
273
- await db.update(notificationDeliveries).set({ groupKey: null, updatedAt: now })
274
- .where(inArray(notificationDeliveries.id, ids))
275
- continue
276
- }
277
-
278
- const { vars, sourceName } = options.digest(rows, now)
279
- // Retire the members first: a crash between the two writes must not let both forms go out.
280
- await db.update(notificationDeliveries).set({
281
- status: 'suppressed', suppressionReason: 'grouped', nextAttemptAt: null, updatedAt: now,
282
- }).where(inArray(notificationDeliveries.id, ids))
283
- await insertDelivery(channel, vars, {
284
- sourceId: null,
285
- sourceName,
286
- eventType: rows[0]!.eventType,
287
- }, { status: 'pending', releaseAt: now })
288
- digests++
289
- }
290
- return digests
291
- }
292
-
293
- async function processDue(now = Date.now()): Promise<number> {
294
- await flushGroups(now)
295
- const due = await db.select({ id: notificationDeliveries.id }).from(notificationDeliveries).where(
296
- and(eq(notificationDeliveries.status, 'pending'), lte(notificationDeliveries.nextAttemptAt, now)),
297
- )
298
- await Promise.allSettled(due.map((delivery) => attemptDelivery(delivery.id, now)))
299
- return due.length
300
- }
301
-
302
- async function retryDelivery(deliveryId: number): Promise<boolean> {
303
- const delivery = (await db.select().from(notificationDeliveries).where(eq(notificationDeliveries.id, deliveryId)))[0]
304
- if (!delivery || delivery.status !== 'failed') return false
305
- const now = Date.now()
306
- await db.update(notificationDeliveries).set({
307
- status: 'pending', maxAttempts: delivery.attemptCount + MAX_DELIVERY_ATTEMPTS,
308
- nextAttemptAt: now, deliveredAt: null, lastError: null, updatedAt: now,
309
- }).where(eq(notificationDeliveries.id, deliveryId))
310
- await attemptDelivery(deliveryId, now)
311
- return true
312
- }
313
-
314
- async function purgeOldDeliveries(now = Date.now()): Promise<void> {
315
- await db.delete(notificationDeliveries).where(lt(notificationDeliveries.createdAt, now - DELIVERY_RETENTION_MS))
316
- }
317
-
318
- /** True when the latest alert-or-recovery event recorded for this source is an alert. */
319
- async function hasOpenAlert(sourceId: number): Promise<boolean> {
320
- // Suppressed rows count: an alert that quiet hours or a digest swallowed was still raised.
321
- const [last] = await db.select({ eventType: notificationDeliveries.eventType }).from(notificationDeliveries)
322
- .where(and(
323
- eq(notificationDeliveries.sourceId, sourceId),
324
- inArray(notificationDeliveries.eventType, [options.events.alert, options.events.recovery]),
325
- ))
326
- .orderBy(desc(notificationDeliveries.id))
327
- .limit(1)
328
- return last?.eventType === options.events.alert
329
- }
330
-
331
- /**
332
- * Plans one event for one channel under its lock and records it. Resolves to the delivery to send right away, or
333
- * null when nothing is to be sent now (suppressed, or held for quiet hours or a digest window). Recording is
334
- * separate from sending so a caller can record every channel's delivery in its own transaction and send afterwards:
335
- * a recorded delivery that was never sent is picked up by `processDue`.
336
- */
337
- async function plan(channel: ChannelRow, vars: Record<string, string>, details: DeliveryDetails): Promise<number | null> {
338
- return withChannelLock(channel.id, async () => {
339
- const now = Date.now()
340
- const delivery = await planDelivery(channel, parseAlertPolicy(channel.alertPolicy), details, now)
341
-
342
- if (delivery.suppressionReason) {
343
- await insertDelivery(channel, vars, details, { status: 'suppressed', reason: delivery.suppressionReason })
344
- return null
345
- }
346
-
347
- const deliveryId = await insertDelivery(channel, vars, details, { status: 'pending', releaseAt: delivery.releaseAt, groupKey: delivery.groupKey })
348
- return delivery.releaseAt <= now ? deliveryId : null
349
- })
350
- }
351
-
352
- /** Plans one event for one channel and sends it right away when nothing holds it back. */
353
- async function dispatch(channel: ChannelRow, vars: Record<string, string>, details: DeliveryDetails): Promise<void> {
354
- const sendNow = await plan(channel, vars, details)
355
- if (sendNow !== null) await attemptDelivery(sendNow)
356
- }
357
-
358
- /** Records a delivery that bypasses alert hygiene and sends it at once; resolves to the outcome. */
359
- async function sendDirect(
360
- channel: ChannelRow,
361
- vars: Record<string, string>,
362
- details: DeliveryDetails,
363
- ): Promise<{ ok: true } | { ok: false; error: string }> {
364
- const deliveryId = await insertDelivery(channel, vars, details, { status: 'pending' })
365
- await attemptDelivery(deliveryId)
366
- const delivery = (await db.select().from(notificationDeliveries).where(eq(notificationDeliveries.id, deliveryId)))[0]!
367
- return delivery.status === 'delivered' ? { ok: true } : { ok: false, error: delivery.lastError ?? 'Delivery failed' }
368
- }
369
-
370
- // ── SMTP ───────────────────────────────────────────────────────────────────
371
-
372
- async function isSmtpConfigured(): Promise<boolean> {
373
- const smtp = (await db.select({ host: smtpSettings.host }).from(smtpSettings))[0]
374
- return !!smtp?.host
375
- }
376
-
377
- /** Sends one message through the configured SMTP server. Throws when SMTP is not set up. */
378
- async function sendSmtpMail(message: SmtpMessage): Promise<void> {
379
- const smtp = (await db.select().from(smtpSettings))[0]
380
- if (!smtp?.host) throw new Error('SMTP not configured')
381
-
382
- let smtpUser = smtp.user
383
- let smtpPass = smtp.password
384
- if (smtp.vaultConfig) {
385
- const ref = JSON.parse(smtp.vaultConfig) as VaultRef
386
- const creds = await resolveSecret(ref)
387
- smtpUser = creds['username'] ?? creds['user'] ?? smtpUser
388
- smtpPass = creds['password'] ?? creds['value'] ?? smtpPass
389
- // A vault reference means sign-in was intended; without a user nodemailer would skip auth silently.
390
- if (!smtpUser) throw new Error('SMTP vault secret has no username: use a User / Password secret or map a username in a JSON secret')
391
- }
392
-
393
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
394
- const nodemailer: any = await import('nodemailer')
395
- const nm = nodemailer.default ?? nodemailer
396
- const transporter = nm.createTransport({
397
- host: smtp.host,
398
- port: smtp.port,
399
- secure: !!smtp.secure,
400
- auth: smtpUser ? { user: smtpUser, pass: smtpPass } : undefined,
401
- })
402
-
403
- const from = smtp.fromAddress
404
- ? `"${smtp.fromName}" <${smtp.fromAddress}>`
405
- : smtp.fromName
406
-
407
- await transporter.sendMail({
408
- from,
409
- to: message.to,
410
- subject: message.subject,
411
- text: message.text,
412
- ...(message.html ? { html: message.html } : {}),
413
- ...(message.headers ? { headers: message.headers } : {}),
414
- })
415
- }
416
-
417
- return {
418
- plan, dispatch, sendDirect, attemptDelivery, flushGroups, processDue, retryDelivery, purgeOldDeliveries, hasOpenAlert,
419
- isSmtpConfigured, sendSmtpMail,
420
- }
421
- }
422
-
423
- export type NotifyEngine = ReturnType<typeof createNotifyEngine>
1
+ import { and, asc, desc, eq, gt, gte, inArray, isNotNull, like, lt, lte, ne, sql } from 'drizzle-orm'
2
+ import type { NotificationSuppressionReason, VaultRef } from '@better-forge/contracts'
3
+ import type { SqliteDatabase } from '@better-forge/db'
4
+ import { createAlertPolicy, isWithinQuietHours, quietHoursEndAt, type AlertPolicy } from './alertPolicy.js'
5
+ import type { NotifyTables } from './tables.js'
6
+ import { MAX_DELIVERY_ATTEMPTS, RETRY_DELAYS_MS } from './template.js'
7
+
8
+ export type ChannelRow = NotifyTables['notificationChannels']['$inferSelect']
9
+ export type DeliveryRow = NotifyTables['notificationDeliveries']['$inferSelect']
10
+
11
+ /** What a delivery is about. A delivery without a source (a test, a digest) has none. */
12
+ export interface DeliveryDetails {
13
+ sourceId: number | null
14
+ sourceName: string
15
+ eventType: string
16
+ }
17
+
18
+ export interface NotifyEngineOptions {
19
+ db: SqliteDatabase
20
+ tables: NotifyTables
21
+ /** Stored name of the grouping threshold in a channel's alert policy; see `createAlertPolicy`. */
22
+ minSourcesKey: string
23
+ /** The event types the application raises that alert hygiene treats specially. */
24
+ events: {
25
+ /** Rate-capped per source; the only event a quiet-hours or throttle rule can drop. */
26
+ alert: string
27
+ /** Closes an alert. Together with `alert` it tells whether a source still has an alert open. */
28
+ recovery: string
29
+ /** Never folded into a digest. */
30
+ ungrouped: string
31
+ }
32
+ /** Sends one delivery's variables through the channel. Throws on failure. */
33
+ deliver: (channel: ChannelRow, vars: Record<string, string>) => Promise<void>
34
+ /** The notification that stands in for several single-source events: its variables and the name it is recorded under. */
35
+ digest: (rows: DeliveryRow[], now: number) => { vars: Record<string, string>; sourceName: string }
36
+ /** Resolves a vault reference to its secret fields (used for SMTP credentials). */
37
+ resolveSecret: (ref: VaultRef) => Promise<Record<string, string>>
38
+ }
39
+
40
+ export interface SmtpMessage {
41
+ to: string
42
+ subject: string
43
+ text: string
44
+ /** Sent alongside `text` as multipart/alternative; clients that cannot render HTML show the text. */
45
+ html?: string
46
+ headers?: Record<string, string>
47
+ }
48
+
49
+ type DeliveryVariant =
50
+ | { status: 'pending'; releaseAt?: number; groupKey?: string | null }
51
+ | { status: 'suppressed'; reason: NotificationSuppressionReason }
52
+
53
+ interface DeliveryPlan {
54
+ /** When the delivery may first be attempted. */
55
+ releaseAt: number
56
+ /** Digest window this delivery belongs to, or null when it stands alone. */
57
+ groupKey: string | null
58
+ suppressionReason: NotificationSuppressionReason | null
59
+ }
60
+
61
+ /** Delivery history older than this is purged. */
62
+ const DELIVERY_RETENTION_MS = 180 * 24 * 60 * 60 * 1000
63
+
64
+ /**
65
+ * The delivery engine: records every notification, applies the channel's alert hygiene, sends with retries, folds
66
+ * bursts into digests and sends SMTP mail. Locks and in-flight sets live in the returned closure, so two engines
67
+ * never share state.
68
+ */
69
+ export function createNotifyEngine(options: NotifyEngineOptions) {
70
+ const { db, deliver, resolveSecret } = options
71
+ const { notificationChannels, notificationDeliveries, notificationDeliveryAttempts, smtpSettings } = options.tables
72
+ const { parseAlertPolicy } = createAlertPolicy({ minSourcesKey: options.minSourcesKey })
73
+ const activeDeliveries = new Set<number>()
74
+
75
+ /**
76
+ * Writes one delivery row. A pending row is picked up for sending at `releaseAt`; a suppressed row
77
+ * records an event that alert hygiene dropped, so the history still shows what was not sent.
78
+ */
79
+ async function insertDelivery(
80
+ channel: ChannelRow,
81
+ vars: Record<string, string>,
82
+ details: DeliveryDetails,
83
+ variant: DeliveryVariant,
84
+ ): Promise<number> {
85
+ const now = Date.now()
86
+ const [delivery] = await db.insert(notificationDeliveries).values({
87
+ channelId: channel.id,
88
+ channelName: channel.name,
89
+ channelType: channel.type,
90
+ sourceId: details.sourceId,
91
+ sourceName: details.sourceName,
92
+ eventType: details.eventType,
93
+ status: variant.status,
94
+ targetStatus: vars['status'] ?? 'unknown',
95
+ previousStatus: vars['previous_status'] ?? 'unknown',
96
+ variables: JSON.stringify(vars),
97
+ attemptCount: 0,
98
+ maxAttempts: MAX_DELIVERY_ATTEMPTS,
99
+ ...(variant.status === 'pending'
100
+ ? { nextAttemptAt: variant.releaseAt ?? now, groupKey: variant.groupKey ?? null }
101
+ : { nextAttemptAt: null, suppressionReason: variant.reason }),
102
+ createdAt: now,
103
+ updatedAt: now,
104
+ }).returning({ id: notificationDeliveries.id })
105
+ return delivery!.id
106
+ }
107
+
108
+ async function attemptDelivery(deliveryId: number, now = Date.now()): Promise<void> {
109
+ if (activeDeliveries.has(deliveryId)) return
110
+ activeDeliveries.add(deliveryId)
111
+ try {
112
+ const delivery = (await db.select().from(notificationDeliveries).where(eq(notificationDeliveries.id, deliveryId)))[0]
113
+ if (!delivery || delivery.status === 'delivered' || delivery.attemptCount >= delivery.maxAttempts) return
114
+ const channel = (await db.select().from(notificationChannels).where(eq(notificationChannels.id, delivery.channelId)))[0]
115
+ const attemptNumber = delivery.attemptCount + 1
116
+ const startedAt = Date.now()
117
+ try {
118
+ if (!channel) throw new Error('Notification channel no longer exists')
119
+ if (channel.enabled !== 1) throw new Error('Notification channel is disabled')
120
+ await deliver(channel, JSON.parse(delivery.variables) as Record<string, string>)
121
+ const completedAt = Date.now()
122
+ await db.insert(notificationDeliveryAttempts).values({ deliveryId, attemptNumber, status: 'delivered', error: null, startedAt, completedAt })
123
+ await db.update(notificationDeliveries).set({
124
+ status: 'delivered', attemptCount: attemptNumber, nextAttemptAt: null,
125
+ lastAttemptAt: completedAt, deliveredAt: completedAt, lastError: null, updatedAt: completedAt,
126
+ }).where(eq(notificationDeliveries.id, deliveryId))
127
+ } catch (error) {
128
+ const completedAt = Date.now()
129
+ const message = error instanceof Error ? error.message : String(error)
130
+ const exhausted = attemptNumber >= delivery.maxAttempts
131
+ const delay = RETRY_DELAYS_MS[Math.min(attemptNumber - 1, RETRY_DELAYS_MS.length - 1)]!
132
+ await db.insert(notificationDeliveryAttempts).values({ deliveryId, attemptNumber, status: 'failed', error: message, startedAt, completedAt })
133
+ await db.update(notificationDeliveries).set({
134
+ status: exhausted ? 'failed' : 'pending', attemptCount: attemptNumber,
135
+ nextAttemptAt: exhausted ? null : now + delay, lastAttemptAt: completedAt,
136
+ lastError: message, updatedAt: completedAt,
137
+ }).where(eq(notificationDeliveries.id, deliveryId))
138
+ console.error(`[notifier] Delivery ${deliveryId}, attempt ${attemptNumber} failed: ${message}`)
139
+ }
140
+ } finally { activeDeliveries.delete(deliveryId) }
141
+ }
142
+
143
+ // ── Alert hygiene: rate caps, quiet hours and digest windows ───────────────
144
+
145
+ /** Alerts already sent or queued for this source on this channel inside the throttle window. */
146
+ async function countRecentAlerts(channelId: number, sourceId: number, windowStart: number): Promise<number> {
147
+ const [row] = await db.select({ count: sql<number>`count(*)` }).from(notificationDeliveries).where(and(
148
+ eq(notificationDeliveries.channelId, channelId),
149
+ eq(notificationDeliveries.sourceId, sourceId),
150
+ eq(notificationDeliveries.eventType, options.events.alert),
151
+ ne(notificationDeliveries.status, 'suppressed'),
152
+ gte(notificationDeliveries.createdAt, windowStart),
153
+ ))
154
+ return row?.count ?? 0
155
+ }
156
+
157
+ /** The digest window this channel already has open, so a burst lands in a single group. */
158
+ async function findOpenGroup(channelId: number, eventType: string, now: number): Promise<{ groupKey: string; releaseAt: number } | null> {
159
+ const [row] = await db.select({ groupKey: notificationDeliveries.groupKey, releaseAt: notificationDeliveries.nextAttemptAt })
160
+ .from(notificationDeliveries)
161
+ .where(and(
162
+ eq(notificationDeliveries.channelId, channelId),
163
+ eq(notificationDeliveries.status, 'pending'),
164
+ like(notificationDeliveries.groupKey, `ch${channelId}:${eventType}:w%`),
165
+ gt(notificationDeliveries.nextAttemptAt, now),
166
+ ))
167
+ .orderBy(asc(notificationDeliveries.nextAttemptAt))
168
+ .limit(1)
169
+ return row?.groupKey && row.releaseAt !== null ? { groupKey: row.groupKey, releaseAt: row.releaseAt } : null
170
+ }
171
+
172
+ /**
173
+ * Applies a channel's alert hygiene to one event. Order matters: the rate cap runs first so a
174
+ * throttled alert never holds a digest window open, then quiet hours decide whether the event is
175
+ * dropped or held, then grouping decides which digest it joins.
176
+ */
177
+ async function planDelivery(
178
+ channel: ChannelRow,
179
+ policy: AlertPolicy,
180
+ details: DeliveryDetails,
181
+ now: number,
182
+ ): Promise<DeliveryPlan> {
183
+ // Recoveries are never throttled — the all-clear must always get through.
184
+ if (policy.throttle.enabled && details.eventType === options.events.alert && details.sourceId !== null) {
185
+ const windowStart = now - policy.throttle.windowMinutes * 60_000
186
+ const recent = await countRecentAlerts(channel.id, details.sourceId, windowStart)
187
+ if (recent >= policy.throttle.maxAlerts) return { releaseAt: now, groupKey: null, suppressionReason: 'throttled' }
188
+ }
189
+
190
+ let releaseAt = now
191
+ let quietUntil: number | null = null
192
+ if (isWithinQuietHours(policy.quietHours, now)) {
193
+ if (policy.quietHours.mode === 'suppress') return { releaseAt: now, groupKey: null, suppressionReason: 'quiet-hours' }
194
+ quietUntil = quietHoursEndAt(policy.quietHours, now)
195
+ releaseAt = Math.max(releaseAt, quietUntil)
196
+ }
197
+
198
+ let groupKey: string | null = null
199
+ if (policy.grouping.enabled && details.eventType !== options.events.ungrouped) {
200
+ if (quietUntil !== null) {
201
+ // Everything held for the same quiet window is released at once — digest it as one message.
202
+ groupKey = `ch${channel.id}:${details.eventType}:q${quietUntil}`
203
+ } else {
204
+ const open = await findOpenGroup(channel.id, details.eventType, now)
205
+ if (open) {
206
+ groupKey = open.groupKey
207
+ releaseAt = Math.max(releaseAt, open.releaseAt)
208
+ } else {
209
+ groupKey = `ch${channel.id}:${details.eventType}:w${now}`
210
+ releaseAt = Math.max(releaseAt, now + policy.grouping.windowSeconds * 1000)
211
+ }
212
+ }
213
+ }
214
+
215
+ return { releaseAt, groupKey, suppressionReason: null }
216
+ }
217
+
218
+ /**
219
+ * Planning reads a channel's recent deliveries and then writes one. Two events for the same
220
+ * channel must not interleave across those awaits, or a burst — precisely what grouping exists
221
+ * for — would open one digest window per source, and a rate cap would be counted from a stale
222
+ * total. Sending happens outside the lock so a slow endpoint never stalls the queue.
223
+ */
224
+ const channelLocks = new Map<number, Promise<unknown>>()
225
+
226
+ function withChannelLock<T>(channelId: number, work: () => Promise<T>): Promise<T> {
227
+ const previous = channelLocks.get(channelId) ?? Promise.resolve()
228
+ const next = previous.then(work, work)
229
+ channelLocks.set(channelId, next.catch(() => undefined))
230
+ return next
231
+ }
232
+
233
+ /**
234
+ * Collapses every due digest window into a single notification. A window that did not reach
235
+ * `minSources` distinct sources is simply released as ordinary individual deliveries, so
236
+ * grouping costs at most `windowSeconds` of latency and never swallows a lone alert.
237
+ */
238
+ let flushChain: Promise<unknown> = Promise.resolve()
239
+
240
+ function flushGroups(now = Date.now()): Promise<number> {
241
+ // The retry worker fires every 30s without awaiting the previous pass; overlapping flushes
242
+ // would each turn the same window into its own digest.
243
+ const run = () => flushDueGroups(now)
244
+ const next = flushChain.then(run, run)
245
+ flushChain = next.catch(() => undefined)
246
+ return next
247
+ }
248
+
249
+ async function flushDueGroups(now: number): Promise<number> {
250
+ const pending = await db.select().from(notificationDeliveries).where(and(
251
+ eq(notificationDeliveries.status, 'pending'),
252
+ isNotNull(notificationDeliveries.groupKey),
253
+ lte(notificationDeliveries.nextAttemptAt, now),
254
+ )).orderBy(asc(notificationDeliveries.createdAt))
255
+ if (pending.length === 0) return 0
256
+
257
+ const groups = new Map<string, DeliveryRow[]>()
258
+ for (const row of pending) {
259
+ const bucket = groups.get(row.groupKey!)
260
+ if (bucket) bucket.push(row)
261
+ else groups.set(row.groupKey!, [row])
262
+ }
263
+
264
+ let digests = 0
265
+ for (const rows of groups.values()) {
266
+ const ids = rows.map((row) => row.id)
267
+ const channel = (await db.select().from(notificationChannels).where(eq(notificationChannels.id, rows[0]!.channelId)))[0]
268
+ const policy = channel ? parseAlertPolicy(channel.alertPolicy) : null
269
+ const distinctSources = new Set(rows.map((row) => row.sourceId ?? -row.id)).size
270
+
271
+ if (!channel || !policy?.grouping.enabled || distinctSources < policy.grouping.minSources) {
272
+ // Not a burst — or grouping was switched off mid-window. Send them individually.
273
+ await db.update(notificationDeliveries).set({ groupKey: null, updatedAt: now })
274
+ .where(inArray(notificationDeliveries.id, ids))
275
+ continue
276
+ }
277
+
278
+ const { vars, sourceName } = options.digest(rows, now)
279
+ // Retire the members first: a crash between the two writes must not let both forms go out.
280
+ await db.update(notificationDeliveries).set({
281
+ status: 'suppressed', suppressionReason: 'grouped', nextAttemptAt: null, updatedAt: now,
282
+ }).where(inArray(notificationDeliveries.id, ids))
283
+ await insertDelivery(channel, vars, {
284
+ sourceId: null,
285
+ sourceName,
286
+ eventType: rows[0]!.eventType,
287
+ }, { status: 'pending', releaseAt: now })
288
+ digests++
289
+ }
290
+ return digests
291
+ }
292
+
293
+ async function processDue(now = Date.now()): Promise<number> {
294
+ await flushGroups(now)
295
+ const due = await db.select({ id: notificationDeliveries.id }).from(notificationDeliveries).where(
296
+ and(eq(notificationDeliveries.status, 'pending'), lte(notificationDeliveries.nextAttemptAt, now)),
297
+ )
298
+ await Promise.allSettled(due.map((delivery) => attemptDelivery(delivery.id, now)))
299
+ return due.length
300
+ }
301
+
302
+ async function retryDelivery(deliveryId: number): Promise<boolean> {
303
+ const delivery = (await db.select().from(notificationDeliveries).where(eq(notificationDeliveries.id, deliveryId)))[0]
304
+ if (!delivery || delivery.status !== 'failed') return false
305
+ const now = Date.now()
306
+ await db.update(notificationDeliveries).set({
307
+ status: 'pending', maxAttempts: delivery.attemptCount + MAX_DELIVERY_ATTEMPTS,
308
+ nextAttemptAt: now, deliveredAt: null, lastError: null, updatedAt: now,
309
+ }).where(eq(notificationDeliveries.id, deliveryId))
310
+ await attemptDelivery(deliveryId, now)
311
+ return true
312
+ }
313
+
314
+ async function purgeOldDeliveries(now = Date.now()): Promise<void> {
315
+ await db.delete(notificationDeliveries).where(lt(notificationDeliveries.createdAt, now - DELIVERY_RETENTION_MS))
316
+ }
317
+
318
+ /** True when the latest alert-or-recovery event recorded for this source is an alert. */
319
+ async function hasOpenAlert(sourceId: number): Promise<boolean> {
320
+ // Suppressed rows count: an alert that quiet hours or a digest swallowed was still raised.
321
+ const [last] = await db.select({ eventType: notificationDeliveries.eventType }).from(notificationDeliveries)
322
+ .where(and(
323
+ eq(notificationDeliveries.sourceId, sourceId),
324
+ inArray(notificationDeliveries.eventType, [options.events.alert, options.events.recovery]),
325
+ ))
326
+ .orderBy(desc(notificationDeliveries.id))
327
+ .limit(1)
328
+ return last?.eventType === options.events.alert
329
+ }
330
+
331
+ /**
332
+ * Plans one event for one channel under its lock and records it. Resolves to the delivery to send right away, or
333
+ * null when nothing is to be sent now (suppressed, or held for quiet hours or a digest window). Recording is
334
+ * separate from sending so a caller can record every channel's delivery in its own transaction and send afterwards:
335
+ * a recorded delivery that was never sent is picked up by `processDue`.
336
+ */
337
+ async function plan(channel: ChannelRow, vars: Record<string, string>, details: DeliveryDetails): Promise<number | null> {
338
+ return withChannelLock(channel.id, async () => {
339
+ const now = Date.now()
340
+ const delivery = await planDelivery(channel, parseAlertPolicy(channel.alertPolicy), details, now)
341
+
342
+ if (delivery.suppressionReason) {
343
+ await insertDelivery(channel, vars, details, { status: 'suppressed', reason: delivery.suppressionReason })
344
+ return null
345
+ }
346
+
347
+ const deliveryId = await insertDelivery(channel, vars, details, { status: 'pending', releaseAt: delivery.releaseAt, groupKey: delivery.groupKey })
348
+ return delivery.releaseAt <= now ? deliveryId : null
349
+ })
350
+ }
351
+
352
+ /** Plans one event for one channel and sends it right away when nothing holds it back. */
353
+ async function dispatch(channel: ChannelRow, vars: Record<string, string>, details: DeliveryDetails): Promise<void> {
354
+ const sendNow = await plan(channel, vars, details)
355
+ if (sendNow !== null) await attemptDelivery(sendNow)
356
+ }
357
+
358
+ /** Records a delivery that bypasses alert hygiene and sends it at once; resolves to the outcome. */
359
+ async function sendDirect(
360
+ channel: ChannelRow,
361
+ vars: Record<string, string>,
362
+ details: DeliveryDetails,
363
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
364
+ const deliveryId = await insertDelivery(channel, vars, details, { status: 'pending' })
365
+ await attemptDelivery(deliveryId)
366
+ const delivery = (await db.select().from(notificationDeliveries).where(eq(notificationDeliveries.id, deliveryId)))[0]!
367
+ return delivery.status === 'delivered' ? { ok: true } : { ok: false, error: delivery.lastError ?? 'Delivery failed' }
368
+ }
369
+
370
+ // ── SMTP ───────────────────────────────────────────────────────────────────
371
+
372
+ async function isSmtpConfigured(): Promise<boolean> {
373
+ const smtp = (await db.select({ host: smtpSettings.host }).from(smtpSettings))[0]
374
+ return !!smtp?.host
375
+ }
376
+
377
+ /** Sends one message through the configured SMTP server. Throws when SMTP is not set up. */
378
+ async function sendSmtpMail(message: SmtpMessage): Promise<void> {
379
+ const smtp = (await db.select().from(smtpSettings))[0]
380
+ if (!smtp?.host) throw new Error('SMTP not configured')
381
+
382
+ let smtpUser = smtp.user
383
+ let smtpPass = smtp.password
384
+ if (smtp.vaultConfig) {
385
+ const ref = JSON.parse(smtp.vaultConfig) as VaultRef
386
+ const creds = await resolveSecret(ref)
387
+ smtpUser = creds['username'] ?? creds['user'] ?? smtpUser
388
+ smtpPass = creds['password'] ?? creds['value'] ?? smtpPass
389
+ // A vault reference means sign-in was intended; without a user nodemailer would skip auth silently.
390
+ if (!smtpUser) throw new Error('SMTP vault secret has no username: use a User / Password secret or map a username in a JSON secret')
391
+ }
392
+
393
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
394
+ const nodemailer: any = await import('nodemailer')
395
+ const nm = nodemailer.default ?? nodemailer
396
+ const transporter = nm.createTransport({
397
+ host: smtp.host,
398
+ port: smtp.port,
399
+ secure: !!smtp.secure,
400
+ auth: smtpUser ? { user: smtpUser, pass: smtpPass } : undefined,
401
+ })
402
+
403
+ const from = smtp.fromAddress
404
+ ? `"${smtp.fromName}" <${smtp.fromAddress}>`
405
+ : smtp.fromName
406
+
407
+ await transporter.sendMail({
408
+ from,
409
+ to: message.to,
410
+ subject: message.subject,
411
+ text: message.text,
412
+ ...(message.html ? { html: message.html } : {}),
413
+ ...(message.headers ? { headers: message.headers } : {}),
414
+ })
415
+ }
416
+
417
+ return {
418
+ plan, dispatch, sendDirect, attemptDelivery, flushGroups, processDue, retryDelivery, purgeOldDeliveries, hasOpenAlert,
419
+ isSmtpConfigured, sendSmtpMail,
420
+ }
421
+ }
422
+
423
+ export type NotifyEngine = ReturnType<typeof createNotifyEngine>