@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/index.ts CHANGED
@@ -1,16 +1,16 @@
1
- export { CHANNEL_TYPES, TEMPLATE_KEYS, channelConfigError, configForEvent, eventOverridesError, type DefaultWording, type EventWording, ntfyConfigError, pushoverConfigError, telegramConfigError } from './channelInput.js'
2
- export { CHANNEL_DESTINATIONS, CHANNEL_SECRETS } from './channelSecrets.js'
3
- export {
4
- createAlertPolicy, DEFAULT_ALERT_POLICY, isValidTimezone, isWithinQuietHours, localMinutes, quietHoursEndAt,
5
- type AlertPolicy, type GroupingPolicy, type ThrottlePolicy,
6
- } from './alertPolicy.js'
7
- export { createNotifyDbModule, notifyMigrations } from './migrations.js'
8
- export { defineNotifyTables, type NotifyTableOptions, type NotifyTables } from './tables.js'
9
- export { buildWebhookRequest, escapeJsonString, placeholderInAuthority, MAX_DELIVERY_ATTEMPTS, RETRY_DELAYS_MS, substituteVars } from './template.js'
10
- export {
11
- createNotifyEngine, type ChannelRow, type DeliveryDetails, type DeliveryRow, type NotifyEngine, type NotifyEngineOptions, type SmtpMessage,
12
- } from './engine.js'
13
- export { readableTimestamp, renderSegments, type Notice, type NoticeFact, type NoticeSegment, type NoticeSeverity } from './notice.js'
14
- export { createTransports, type TransportChannel, type TransportOptions, type Transports } from './transports.js'
15
- export { createNotifyRoutes, type KeyResult, type NotifyRoutesOptions } from './routes.js'
16
- export { createDeliveryWorker, type DeliveryWorkerOptions, type WorkerTask } from './worker.js'
1
+ export { CHANNEL_TYPES, TEMPLATE_KEYS, channelConfigError, configForEvent, eventOverridesError, type DefaultWording, type EventWording, ntfyConfigError, pushoverConfigError, telegramConfigError } from './channelInput.js'
2
+ export { CHANNEL_DESTINATIONS, CHANNEL_SECRETS } from './channelSecrets.js'
3
+ export {
4
+ createAlertPolicy, DEFAULT_ALERT_POLICY, isValidTimezone, isWithinQuietHours, localMinutes, quietHoursEndAt,
5
+ type AlertPolicy, type GroupingPolicy, type ThrottlePolicy,
6
+ } from './alertPolicy.js'
7
+ export { createNotifyDbModule, notifyMigrations } from './migrations.js'
8
+ export { defineNotifyTables, type NotifyTableOptions, type NotifyTables } from './tables.js'
9
+ export { buildWebhookRequest, escapeJsonString, placeholderInAuthority, MAX_DELIVERY_ATTEMPTS, RETRY_DELAYS_MS, substituteVars } from './template.js'
10
+ export {
11
+ createNotifyEngine, type ChannelRow, type DeliveryDetails, type DeliveryRow, type NotifyEngine, type NotifyEngineOptions, type SmtpMessage,
12
+ } from './engine.js'
13
+ export { readableTimestamp, renderSegments, type Notice, type NoticeFact, type NoticeSegment, type NoticeSeverity } from './notice.js'
14
+ export { createTransports, type TransportChannel, type TransportOptions, type Transports } from './transports.js'
15
+ export { createNotifyRoutes, type KeyResult, type NotifyRoutesOptions } from './routes.js'
16
+ export { createDeliveryWorker, type DeliveryWorkerOptions, type WorkerTask } from './worker.js'
package/src/migrations.ts CHANGED
@@ -1,88 +1,88 @@
1
- import type { DbModule, MigrationStep } from '@better-forge/db'
2
- import { defineNotifyTables, type NotifyTableOptions } from './tables.js'
3
-
4
- /** Notification channels, their delivery history and SMTP settings. Steps run in this order. */
5
- export function notifyMigrations(options: Pick<NotifyTableOptions, 'sourceIdColumn' | 'sourceNameColumn' | 'smtpFromName'>): readonly MigrationStep[] {
6
- const { sourceIdColumn, sourceNameColumn, smtpFromName } = options
7
- return [
8
- { kind: 'exec', sql: `
9
- CREATE TABLE IF NOT EXISTS notification_channels (
10
- id INTEGER PRIMARY KEY AUTOINCREMENT,
11
- name TEXT NOT NULL,
12
- type TEXT NOT NULL,
13
- config TEXT NOT NULL DEFAULT '{}',
14
- enabled INTEGER NOT NULL DEFAULT 1,
15
- notify_on_recovery INTEGER NOT NULL DEFAULT 0,
16
- created_at INTEGER NOT NULL,
17
- updated_at INTEGER NOT NULL
18
- );
19
- ` },
20
- { kind: 'exec', sql: `
21
- CREATE TABLE IF NOT EXISTS notification_deliveries (
22
- id INTEGER PRIMARY KEY AUTOINCREMENT,
23
- channel_id INTEGER NOT NULL,
24
- channel_name TEXT NOT NULL,
25
- channel_type TEXT NOT NULL,
26
- ${sourceIdColumn} INTEGER,
27
- ${sourceNameColumn} TEXT NOT NULL,
28
- event_type TEXT NOT NULL,
29
- status TEXT NOT NULL DEFAULT 'pending',
30
- target_status TEXT NOT NULL,
31
- previous_status TEXT NOT NULL,
32
- variables TEXT NOT NULL,
33
- attempt_count INTEGER NOT NULL DEFAULT 0,
34
- max_attempts INTEGER NOT NULL DEFAULT 3,
35
- next_attempt_at INTEGER,
36
- last_attempt_at INTEGER,
37
- delivered_at INTEGER,
38
- last_error TEXT,
39
- created_at INTEGER NOT NULL,
40
- updated_at INTEGER NOT NULL
41
- );
42
- CREATE INDEX IF NOT EXISTS idx_notification_deliveries_status_next ON notification_deliveries(status, next_attempt_at);
43
- CREATE INDEX IF NOT EXISTS idx_notification_deliveries_created ON notification_deliveries(created_at);
44
- ` },
45
- { kind: 'exec', sql: `
46
- CREATE TABLE IF NOT EXISTS notification_delivery_attempts (
47
- id INTEGER PRIMARY KEY AUTOINCREMENT,
48
- delivery_id INTEGER NOT NULL,
49
- attempt_number INTEGER NOT NULL,
50
- status TEXT NOT NULL,
51
- error TEXT,
52
- started_at INTEGER NOT NULL,
53
- completed_at INTEGER NOT NULL,
54
- FOREIGN KEY (delivery_id) REFERENCES notification_deliveries(id) ON DELETE CASCADE
55
- );
56
- CREATE INDEX IF NOT EXISTS idx_notification_delivery_attempts_delivery ON notification_delivery_attempts(delivery_id, attempt_number);
57
- ` },
58
- { kind: 'exec', sql: `
59
- CREATE TABLE IF NOT EXISTS smtp_settings (
60
- id INTEGER PRIMARY KEY,
61
- host TEXT NOT NULL DEFAULT '',
62
- port INTEGER NOT NULL DEFAULT 587,
63
- secure INTEGER NOT NULL DEFAULT 0,
64
- user TEXT NOT NULL DEFAULT '',
65
- password TEXT NOT NULL DEFAULT '',
66
- from_address TEXT NOT NULL DEFAULT '',
67
- from_name TEXT NOT NULL DEFAULT '${smtpFromName}',
68
- updated_at INTEGER NOT NULL
69
- );
70
- ` },
71
- { kind: 'column', sql: `ALTER TABLE smtp_settings ADD COLUMN vault_config TEXT`, desc: 'smtp_settings.vault_config' },
72
- { kind: 'column', sql: `ALTER TABLE notification_channels ADD COLUMN alert_policy TEXT NOT NULL DEFAULT '{}'`, desc: 'notification_channels.alert_policy' },
73
- // SQLite cannot add a UNIQUE column. The application fills the keys of existing rows and then adds the unique index.
74
- { kind: 'column', sql: `ALTER TABLE notification_channels ADD COLUMN key TEXT NOT NULL DEFAULT ''`, desc: 'notification_channels.key' },
75
- { kind: 'column', sql: `ALTER TABLE notification_deliveries ADD COLUMN suppression_reason TEXT`, desc: 'notification_deliveries.suppression_reason' },
76
- { kind: 'column', sql: `ALTER TABLE notification_deliveries ADD COLUMN group_key TEXT`, desc: 'notification_deliveries.group_key' },
77
- { kind: 'exec', sql: `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_group ON notification_deliveries(group_key, status)` },
78
- { kind: 'exec', sql: `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_throttle ON notification_deliveries(channel_id, ${sourceIdColumn}, event_type, created_at)` },
79
- ]
80
- }
81
-
82
- /**
83
- * The notification tables and their migrations, for the application's list of database modules. The unique index on
84
- * the channel \`key\` and the column that backs it are created by the application, once existing rows were given a key.
85
- */
86
- export function createNotifyDbModule(options: NotifyTableOptions) {
87
- return { id: 'notify', tables: defineNotifyTables(options), migrations: notifyMigrations(options) } satisfies DbModule
88
- }
1
+ import type { DbModule, MigrationStep } from '@better-forge/db'
2
+ import { defineNotifyTables, type NotifyTableOptions } from './tables.js'
3
+
4
+ /** Notification channels, their delivery history and SMTP settings. Steps run in this order. */
5
+ export function notifyMigrations(options: Pick<NotifyTableOptions, 'sourceIdColumn' | 'sourceNameColumn' | 'smtpFromName'>): readonly MigrationStep[] {
6
+ const { sourceIdColumn, sourceNameColumn, smtpFromName } = options
7
+ return [
8
+ { kind: 'exec', sql: `
9
+ CREATE TABLE IF NOT EXISTS notification_channels (
10
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
11
+ name TEXT NOT NULL,
12
+ type TEXT NOT NULL,
13
+ config TEXT NOT NULL DEFAULT '{}',
14
+ enabled INTEGER NOT NULL DEFAULT 1,
15
+ notify_on_recovery INTEGER NOT NULL DEFAULT 0,
16
+ created_at INTEGER NOT NULL,
17
+ updated_at INTEGER NOT NULL
18
+ );
19
+ ` },
20
+ { kind: 'exec', sql: `
21
+ CREATE TABLE IF NOT EXISTS notification_deliveries (
22
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
23
+ channel_id INTEGER NOT NULL,
24
+ channel_name TEXT NOT NULL,
25
+ channel_type TEXT NOT NULL,
26
+ ${sourceIdColumn} INTEGER,
27
+ ${sourceNameColumn} TEXT NOT NULL,
28
+ event_type TEXT NOT NULL,
29
+ status TEXT NOT NULL DEFAULT 'pending',
30
+ target_status TEXT NOT NULL,
31
+ previous_status TEXT NOT NULL,
32
+ variables TEXT NOT NULL,
33
+ attempt_count INTEGER NOT NULL DEFAULT 0,
34
+ max_attempts INTEGER NOT NULL DEFAULT 3,
35
+ next_attempt_at INTEGER,
36
+ last_attempt_at INTEGER,
37
+ delivered_at INTEGER,
38
+ last_error TEXT,
39
+ created_at INTEGER NOT NULL,
40
+ updated_at INTEGER NOT NULL
41
+ );
42
+ CREATE INDEX IF NOT EXISTS idx_notification_deliveries_status_next ON notification_deliveries(status, next_attempt_at);
43
+ CREATE INDEX IF NOT EXISTS idx_notification_deliveries_created ON notification_deliveries(created_at);
44
+ ` },
45
+ { kind: 'exec', sql: `
46
+ CREATE TABLE IF NOT EXISTS notification_delivery_attempts (
47
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
48
+ delivery_id INTEGER NOT NULL,
49
+ attempt_number INTEGER NOT NULL,
50
+ status TEXT NOT NULL,
51
+ error TEXT,
52
+ started_at INTEGER NOT NULL,
53
+ completed_at INTEGER NOT NULL,
54
+ FOREIGN KEY (delivery_id) REFERENCES notification_deliveries(id) ON DELETE CASCADE
55
+ );
56
+ CREATE INDEX IF NOT EXISTS idx_notification_delivery_attempts_delivery ON notification_delivery_attempts(delivery_id, attempt_number);
57
+ ` },
58
+ { kind: 'exec', sql: `
59
+ CREATE TABLE IF NOT EXISTS smtp_settings (
60
+ id INTEGER PRIMARY KEY,
61
+ host TEXT NOT NULL DEFAULT '',
62
+ port INTEGER NOT NULL DEFAULT 587,
63
+ secure INTEGER NOT NULL DEFAULT 0,
64
+ user TEXT NOT NULL DEFAULT '',
65
+ password TEXT NOT NULL DEFAULT '',
66
+ from_address TEXT NOT NULL DEFAULT '',
67
+ from_name TEXT NOT NULL DEFAULT '${smtpFromName}',
68
+ updated_at INTEGER NOT NULL
69
+ );
70
+ ` },
71
+ { kind: 'column', sql: `ALTER TABLE smtp_settings ADD COLUMN vault_config TEXT`, desc: 'smtp_settings.vault_config' },
72
+ { kind: 'column', sql: `ALTER TABLE notification_channels ADD COLUMN alert_policy TEXT NOT NULL DEFAULT '{}'`, desc: 'notification_channels.alert_policy' },
73
+ // SQLite cannot add a UNIQUE column. The application fills the keys of existing rows and then adds the unique index.
74
+ { kind: 'column', sql: `ALTER TABLE notification_channels ADD COLUMN key TEXT NOT NULL DEFAULT ''`, desc: 'notification_channels.key' },
75
+ { kind: 'column', sql: `ALTER TABLE notification_deliveries ADD COLUMN suppression_reason TEXT`, desc: 'notification_deliveries.suppression_reason' },
76
+ { kind: 'column', sql: `ALTER TABLE notification_deliveries ADD COLUMN group_key TEXT`, desc: 'notification_deliveries.group_key' },
77
+ { kind: 'exec', sql: `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_group ON notification_deliveries(group_key, status)` },
78
+ { kind: 'exec', sql: `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_throttle ON notification_deliveries(channel_id, ${sourceIdColumn}, event_type, created_at)` },
79
+ ]
80
+ }
81
+
82
+ /**
83
+ * The notification tables and their migrations, for the application's list of database modules. The unique index on
84
+ * the channel \`key\` and the column that backs it are created by the application, once existing rows were given a key.
85
+ */
86
+ export function createNotifyDbModule(options: NotifyTableOptions) {
87
+ return { id: 'notify', tables: defineNotifyTables(options), migrations: notifyMigrations(options) } satisfies DbModule
88
+ }
package/src/notice.ts CHANGED
@@ -1,68 +1,68 @@
1
- /** How serious an event is; the rich channels paint it in a colour family and the push channels map it to a priority. */
2
- export type NoticeSeverity = 'critical' | 'warning' | 'ok'
3
-
4
- /**
5
- * A piece of a headline. Plain text stays as written; a `subject` (what the event is about) and a `state` (what became
6
- * of it) are the parts a channel emphasises in its own markup. A `kind` names what sort of thing the subject is
7
- * ("Server"), which a channel may leave out where its title is already crowded.
8
- */
9
- export type NoticeSegment = string | { kind: string } | { subject: string } | { state: string }
10
-
11
- export interface NoticeFact {
12
- label: string
13
- value: string
14
- /** The label for channels that have room for a longer one. */
15
- fullLabel?: string
16
- }
17
-
18
- /**
19
- * What the channels say about one event, independent of the application that raised it. The application builds it
20
- * from its own variables; every channel turns it into its own payload.
21
- */
22
- export interface Notice {
23
- severity: NoticeSeverity
24
- headline: NoticeSegment[]
25
- /** The line under the headline in channels that have one. */
26
- subtitle: NoticeSegment[]
27
- facts: NoticeFact[]
28
- /** The error or details of the event, shown after the facts. */
29
- detail?: { label: string; value: string }
30
- /** When the event happened, as an ISO instant (anything else is shown as is). */
31
- at: string
32
- /** Names `at` in the footer ("Checked at"). */
33
- atLabel: string
34
- }
35
-
36
- /** How a channel writes the emphasised segments of a headline. */
37
- export interface SegmentMarkup {
38
- /** Applied to every text segment, emphasised or not. */
39
- text?: (value: string) => string
40
- /** Defaults to writing the kind as plain text. */
41
- kind?: (value: string) => string
42
- subject: (value: string) => string
43
- state: (value: string) => string
44
- }
45
-
46
- export function renderSegments(segments: NoticeSegment[], markup: SegmentMarkup): string {
47
- const text = markup.text ?? ((value: string) => value)
48
- return segments.map((segment) => {
49
- if (typeof segment === 'string') return text(segment)
50
- if ('kind' in segment) return markup.kind ? markup.kind(text(segment.kind)) : text(segment.kind)
51
- if ('subject' in segment) return markup.subject(text(segment.subject))
52
- return markup.state(text(segment.state))
53
- }).join('')
54
- }
55
-
56
- const plainMarkup: SegmentMarkup = { subject: (value) => value, state: (value) => value }
57
-
58
- /** A headline without any markup. */
59
- export function plainSegments(segments: NoticeSegment[]): string {
60
- return renderSegments(segments, plainMarkup)
61
- }
62
-
63
- /** "2026-10-09T17:00:22.988Z" → "2026-10-09 17:00:22 UTC"; anything that is not an ISO instant is shown as is. */
64
- export function readableTimestamp(iso: string | undefined): string {
65
- if (!iso) return 'unknown'
66
- const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.\d+)?Z$/.exec(iso)
67
- return match ? `${match[1]} ${match[2]} UTC` : iso
68
- }
1
+ /** How serious an event is; the rich channels paint it in a colour family and the push channels map it to a priority. */
2
+ export type NoticeSeverity = 'critical' | 'warning' | 'ok'
3
+
4
+ /**
5
+ * A piece of a headline. Plain text stays as written; a `subject` (what the event is about) and a `state` (what became
6
+ * of it) are the parts a channel emphasises in its own markup. A `kind` names what sort of thing the subject is
7
+ * ("Server"), which a channel may leave out where its title is already crowded.
8
+ */
9
+ export type NoticeSegment = string | { kind: string } | { subject: string } | { state: string }
10
+
11
+ export interface NoticeFact {
12
+ label: string
13
+ value: string
14
+ /** The label for channels that have room for a longer one. */
15
+ fullLabel?: string
16
+ }
17
+
18
+ /**
19
+ * What the channels say about one event, independent of the application that raised it. The application builds it
20
+ * from its own variables; every channel turns it into its own payload.
21
+ */
22
+ export interface Notice {
23
+ severity: NoticeSeverity
24
+ headline: NoticeSegment[]
25
+ /** The line under the headline in channels that have one. */
26
+ subtitle: NoticeSegment[]
27
+ facts: NoticeFact[]
28
+ /** The error or details of the event, shown after the facts. */
29
+ detail?: { label: string; value: string }
30
+ /** When the event happened, as an ISO instant (anything else is shown as is). */
31
+ at: string
32
+ /** Names `at` in the footer ("Checked at"). */
33
+ atLabel: string
34
+ }
35
+
36
+ /** How a channel writes the emphasised segments of a headline. */
37
+ export interface SegmentMarkup {
38
+ /** Applied to every text segment, emphasised or not. */
39
+ text?: (value: string) => string
40
+ /** Defaults to writing the kind as plain text. */
41
+ kind?: (value: string) => string
42
+ subject: (value: string) => string
43
+ state: (value: string) => string
44
+ }
45
+
46
+ export function renderSegments(segments: NoticeSegment[], markup: SegmentMarkup): string {
47
+ const text = markup.text ?? ((value: string) => value)
48
+ return segments.map((segment) => {
49
+ if (typeof segment === 'string') return text(segment)
50
+ if ('kind' in segment) return markup.kind ? markup.kind(text(segment.kind)) : text(segment.kind)
51
+ if ('subject' in segment) return markup.subject(text(segment.subject))
52
+ return markup.state(text(segment.state))
53
+ }).join('')
54
+ }
55
+
56
+ const plainMarkup: SegmentMarkup = { subject: (value) => value, state: (value) => value }
57
+
58
+ /** A headline without any markup. */
59
+ export function plainSegments(segments: NoticeSegment[]): string {
60
+ return renderSegments(segments, plainMarkup)
61
+ }
62
+
63
+ /** "2026-10-09T17:00:22.988Z" → "2026-10-09 17:00:22 UTC"; anything that is not an ISO instant is shown as is. */
64
+ export function readableTimestamp(iso: string | undefined): string {
65
+ if (!iso) return 'unknown'
66
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.\d+)?Z$/.exec(iso)
67
+ return match ? `${match[1]} ${match[2]} UTC` : iso
68
+ }