@warlock.js/notifications 5.24.0 → 5.26.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.
Files changed (103) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/cjs/index.cjs +1 -1
  3. package/cjs/rabbitmq-driver-DvWaCPES.cjs +3 -0
  4. package/cjs/{src-DbAWktJm.cjs → src-BVlqtCR2.cjs} +22 -6
  5. package/cjs/src-BVlqtCR2.cjs.map +1 -0
  6. package/esm/herald/src/communicators/broker-registry.mjs.map +1 -1
  7. package/esm/herald/src/communicators/broker.mjs.map +1 -1
  8. package/esm/herald/src/drivers/rabbitmq/rabbitmq-channel.mjs.map +1 -1
  9. package/esm/herald/src/drivers/rabbitmq/rabbitmq-driver.mjs.map +1 -1
  10. package/esm/herald/src/message-managers/prepare-consumer-subscription.mjs.map +1 -1
  11. package/esm/herald/src/utils/connect-to-broker.mjs.map +1 -1
  12. package/esm/seal/src/config.mjs.map +1 -1
  13. package/esm/seal/src/factory/validate.mjs.map +1 -1
  14. package/esm/seal/src/factory/validators.mjs +1 -1
  15. package/esm/seal/src/factory/validators.mjs.map +1 -1
  16. package/esm/seal/src/helpers/date-helpers.mjs.map +1 -1
  17. package/esm/seal/src/helpers/get-field-value.mjs.map +1 -1
  18. package/esm/seal/src/helpers/path-helpers.mjs.map +1 -1
  19. package/esm/seal/src/mutators/array-mutators.mjs.map +1 -1
  20. package/esm/seal/src/mutators/date-mutators.mjs.map +1 -1
  21. package/esm/seal/src/mutators/number-mutators.mjs.map +1 -1
  22. package/esm/seal/src/mutators/object-mutators.mjs.map +1 -1
  23. package/esm/seal/src/mutators/scalar-mutators.mjs.map +1 -1
  24. package/esm/seal/src/mutators/string-mutators.mjs.map +1 -1
  25. package/esm/seal/src/rules/array/array-rules.mjs.map +1 -1
  26. package/esm/seal/src/rules/color/color-rules.mjs.map +1 -1
  27. package/esm/seal/src/rules/common/enum.mjs.map +1 -1
  28. package/esm/seal/src/rules/common/equals-field-rules.mjs.map +1 -1
  29. package/esm/seal/src/rules/common/type-rules.mjs.map +1 -1
  30. package/esm/seal/src/rules/conditional/forbidden-if-rules.mjs.map +1 -1
  31. package/esm/seal/src/rules/conditional/present-if-rules.mjs.map +1 -1
  32. package/esm/seal/src/rules/conditional/present-unless-rules.mjs.map +1 -1
  33. package/esm/seal/src/rules/conditional/present-with-rules.mjs.map +1 -1
  34. package/esm/seal/src/rules/conditional/present-without-rules.mjs.map +1 -1
  35. package/esm/seal/src/rules/conditional/required-if-rules.mjs.map +1 -1
  36. package/esm/seal/src/rules/conditional/required-unless-rules.mjs.map +1 -1
  37. package/esm/seal/src/rules/conditional/required-with-rules.mjs.map +1 -1
  38. package/esm/seal/src/rules/conditional/required-without-rules.mjs.map +1 -1
  39. package/esm/seal/src/rules/core/equal.mjs.map +1 -1
  40. package/esm/seal/src/rules/core/forbidden.mjs.map +1 -1
  41. package/esm/seal/src/rules/core/required.mjs.map +1 -1
  42. package/esm/seal/src/rules/core/union.mjs.map +1 -1
  43. package/esm/seal/src/rules/core/when.mjs.map +1 -1
  44. package/esm/seal/src/rules/date/date-comparison-rules.mjs.map +1 -1
  45. package/esm/seal/src/rules/date/date-day-rules.mjs.map +1 -1
  46. package/esm/seal/src/rules/date/date-field-comparison-rules.mjs.map +1 -1
  47. package/esm/seal/src/rules/date/date-period-rules.mjs.map +1 -1
  48. package/esm/seal/src/rules/date/date-relative-rules.mjs.map +1 -1
  49. package/esm/seal/src/rules/date/date-special-rules.mjs.map +1 -1
  50. package/esm/seal/src/rules/date/date.mjs.map +1 -1
  51. package/esm/seal/src/rules/length/length-rules.mjs.map +1 -1
  52. package/esm/seal/src/rules/number/number-rules.mjs.map +1 -1
  53. package/esm/seal/src/rules/scalar/accepted-rule.mjs.map +1 -1
  54. package/esm/seal/src/rules/scalar/declined-rule.mjs.map +1 -1
  55. package/esm/seal/src/rules/string/alpha.mjs.map +1 -1
  56. package/esm/seal/src/rules/string/credit-card.mjs.map +1 -1
  57. package/esm/seal/src/rules/string/email.mjs.map +1 -1
  58. package/esm/seal/src/rules/string/pattern.mjs.map +1 -1
  59. package/esm/seal/src/rules/string/string-comparison.mjs.map +1 -1
  60. package/esm/seal/src/rules/string/strong-password-rule.mjs.map +1 -1
  61. package/esm/seal/src/rules/string/url.mjs.map +1 -1
  62. package/esm/seal/src/rules/string/without-whitespace.mjs.map +1 -1
  63. package/esm/seal/src/types/date-types.mjs.map +1 -1
  64. package/esm/seal/src/validators/any-validator.mjs.map +1 -1
  65. package/esm/seal/src/validators/array-validator.mjs +1 -0
  66. package/esm/seal/src/validators/array-validator.mjs.map +1 -1
  67. package/esm/seal/src/validators/base-validator.mjs +12 -1
  68. package/esm/seal/src/validators/base-validator.mjs.map +1 -1
  69. package/esm/seal/src/validators/boolean-validator.mjs +1 -0
  70. package/esm/seal/src/validators/boolean-validator.mjs.map +1 -1
  71. package/esm/seal/src/validators/date-validator.mjs +1 -0
  72. package/esm/seal/src/validators/date-validator.mjs.map +1 -1
  73. package/esm/seal/src/validators/float-validator.mjs.map +1 -1
  74. package/esm/seal/src/validators/int-validator.mjs.map +1 -1
  75. package/esm/seal/src/validators/literal-validator.mjs +1 -3
  76. package/esm/seal/src/validators/literal-validator.mjs.map +1 -1
  77. package/esm/seal/src/validators/methods/equality-conditional-methods.mjs.map +1 -1
  78. package/esm/seal/src/validators/methods/forbidden-methods.mjs.map +1 -1
  79. package/esm/seal/src/validators/methods/present-methods.mjs.map +1 -1
  80. package/esm/seal/src/validators/methods/required-methods.mjs.map +1 -1
  81. package/esm/seal/src/validators/number-validator.mjs +1 -0
  82. package/esm/seal/src/validators/number-validator.mjs.map +1 -1
  83. package/esm/seal/src/validators/numeric-validator.mjs.map +1 -1
  84. package/esm/seal/src/validators/object-validator.mjs +1 -1
  85. package/esm/seal/src/validators/object-validator.mjs.map +1 -1
  86. package/esm/seal/src/validators/record-validator.mjs.map +1 -1
  87. package/esm/seal/src/validators/scalar-validator.mjs +1 -0
  88. package/esm/seal/src/validators/scalar-validator.mjs.map +1 -1
  89. package/esm/seal/src/validators/string-validator.mjs.map +1 -1
  90. package/esm/seal/src/validators/tuple-validator.mjs.map +1 -1
  91. package/esm/seal/src/validators/union-validator.mjs.map +1 -1
  92. package/llms-full.txt +901 -891
  93. package/package.json +7 -7
  94. package/skills/configure-notifications/SKILL.md +138 -138
  95. package/skills/define-channel/SKILL.md +107 -107
  96. package/skills/define-notification/SKILL.md +133 -133
  97. package/skills/observe-notifications/SKILL.md +66 -66
  98. package/skills/queue-notifications/SKILL.md +110 -110
  99. package/skills/send-ad-hoc/SKILL.md +95 -95
  100. package/skills/use-in-app/SKILL.md +172 -172
  101. package/skills/write-notification-migration/SKILL.md +66 -66
  102. package/cjs/rabbitmq-driver-C0o3Pz15.cjs +0 -3
  103. package/cjs/src-DbAWktJm.cjs.map +0 -1
@@ -1,133 +1,133 @@
1
- ---
2
- name: define-notification
3
- description: 'Build a reusable multi-channel notification with `defineNotification<Data>({ type, via, ...renderers })`. `type` is the stable identifier preference/rate-limit gates use AND defaults the database channel''s `type`. `via` is `ChannelName[]` OR `(data, to) => ChannelName[]` for per-recipient channel selection. Renderers are `(data, to, ctx) => NotificationChannels[C]`; `ctx` carries `locale` + `meta` from `SendOptions`. The returned object exposes `.send(to|to[], data, options?)`, `.queue(to|to[], data, options?)`, and `.only(...channels)`. Per-recipient + per-channel `Promise.allSettled` isolates failures; the database channel''s `type` is auto-defaulted from `def.type`; `SendOptions.idempotencyKey` is injected into the database payload. Triggers: `defineNotification`, `via`, "reusable notification", "multi-channel notification", "send through several channels at once", "render per channel", `RenderContext`, `def.type`; "force on a notification", "queue with delay"; typical import `import { defineNotification } from "@warlock.js/notifications"`. Skip: single-channel ad-hoc sends — `@warlock.js/notifications/send-ad-hoc/SKILL.md`; adding a brand-new channel — `@warlock.js/notifications/define-channel/SKILL.md`; observing dispatch outcomes — `@warlock.js/notifications/observe-notifications/SKILL.md`.'
4
- ---
5
-
6
- # `defineNotification` — reusable multi-channel notification
7
-
8
- The reusable pattern. Define a notification once with `type` + `via` + a renderer per channel; fire it anywhere with `.send` / `.queue` / `.only`.
9
-
10
- ## Shape
11
-
12
- ```ts
13
- defineNotification<Data>({
14
- type: string, // REQUIRED — gate key + database default
15
- via: ChannelName[] | (data, to) => ChannelName[], // static array OR per-recipient callback
16
- mail?: (data, to, ctx) => MailPayload,
17
- database?: (data, to, ctx) => Omit<DatabasePayload, "type">, // `type` defaulted from `def.type`
18
- // ...one renderer per channel listed in `via`
19
- });
20
- ```
21
-
22
- Returns `{ send, queue, only }`:
23
-
24
- | Method | Signature | What it does |
25
- |---|---|---|
26
- | `.send` | `(to, data, options?)` | Render + dispatch synchronously. |
27
- | `.queue` | `(to, data, options?)` | Render + enqueue. Throws if no queue dispatcher is configured. |
28
- | `.only(...channels)` | returns `{send, queue, only}` | Restrict dispatch to a subset of channels. |
29
-
30
- `to` accepts a `Notifiable` OR a `Notifiable[]`. Fan-out emits one render+dispatch per recipient with per-recipient + per-channel `Promise.allSettled` (one failure does not abort the others).
31
-
32
- ## Static `via`
33
-
34
- ```ts
35
- export const welcome = defineNotification<{ name: string }>({
36
- type: "welcome",
37
- via: ["mail"],
38
- mail: ({ name }) => ({ subject: "Welcome", html: `<p>Hi ${name}</p>` }),
39
- });
40
-
41
- await welcome.send(user, { name: "Hasan" });
42
- ```
43
-
44
- ## Dynamic `via` per recipient
45
-
46
- ```ts
47
- export const orderShipped = defineNotification<{ order: Order }>({
48
- type: "order.shipped",
49
- via: (_data, to) =>
50
- to.get("telegram_chat_id")
51
- ? ["database", "telegram"]
52
- : ["database", "mail"],
53
- database: ({ order }) => ({ title: `Order #${order.number} shipped` }),
54
- mail: ({ order }, to) => ({
55
- subject: `Order #${order.number} shipped`,
56
- html: `<p>Hi ${to.get("name")}, on the way.</p>`,
57
- }),
58
- // telegram: ... (Phase 2 — bridges)
59
- });
60
- ```
61
-
62
- ## The renderer's 3rd arg — `RenderContext`
63
-
64
- ```ts
65
- mail: ({ campaignId }, to, { locale = "en", meta }) => ({
66
- subject: subjectsByLocale[locale],
67
- html: htmlByLocale[locale],
68
- }),
69
-
70
- // usage:
71
- await marketing.queue(user, payload, {
72
- locale: user.get("locale"),
73
- meta: { campaignId, source: "blast" },
74
- });
75
- ```
76
-
77
- `ctx` carries `locale` + `meta` from `SendOptions`. `meta` flows through to every observability event too.
78
-
79
- ## Database channel — `type` is defaulted
80
-
81
- The `database` renderer may OMIT `type` — the dispatcher injects `def.type` automatically. This keeps the notification type in ONE place.
82
-
83
- ```ts
84
- defineNotification({
85
- type: "order.shipped", // ← single source of truth
86
- via: ["database"],
87
- database: () => ({ title: "Shipped" }), // type omitted; dispatcher injects "order.shipped"
88
- });
89
- ```
90
-
91
- ## Fan-out
92
-
93
- ```ts
94
- await orderShipped.send([buyer, salesRep], { order });
95
- // One render+dispatch per recipient. Database does N inserts (Phase 0);
96
- // Phase 1 ships a single bulk insert via `Channel.sendMany?`.
97
- ```
98
-
99
- ## `.only(...)` — restrict channels
100
-
101
- ```ts
102
- await orderShipped.only("mail").send(user, { order });
103
- // `via` is filtered to just "mail"; other channels are skipped.
104
- ```
105
-
106
- ## Queue (Phase 2)
107
-
108
- ```ts
109
- await orderShipped.queue(user, { order }, { delay: "10m" });
110
- // Throws NoQueueDispatcherError if no `queue` is configured in config/notifications.ts.
111
- ```
112
-
113
- ## SendOptions
114
-
115
- | Field | Effect |
116
- |---|---|
117
- | `delay` | Reserved — `"10m"` / `"3d"` / `600` (seconds). NOT honored yet: both `.send()` and the current `.queue()` worker dispatch immediately; delay-aware delivery is a follow-up. |
118
- | `locale` | Passed to renderers via `RenderContext.locale`. |
119
- | `meta` | Passed to renderers + included in every observability event. |
120
- | `idempotencyKey` | Injected into the database payload (dedupe via unique index). |
121
- | `force` | Bypass `PreferenceProvider`. Does NOT bypass `RateLimiter`. |
122
- | `type` | Ignored by `defineNotification` — see `send-ad-hoc` for ad-hoc gating. |
123
-
124
- ## Failure isolation
125
-
126
- Per-recipient: each recipient gets an independent `Promise.allSettled`. Per-channel: each channel within a recipient gets an independent `allSettled`. A throw becomes a `failed` event on the event bus AND re-throws within its own slot — never aborts siblings.
127
-
128
- ## See also
129
-
130
- - [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — package front door.
131
- - [`send-ad-hoc/SKILL.md`](../send-ad-hoc/SKILL.md) — `notify.<channel>` per-channel shorthand.
132
- - [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) — wire channels + preferences + rate-limit + queue.
133
- - [`observe-notifications/SKILL.md`](../observe-notifications/SKILL.md) — `notifications.on("sending" | "sent" | "failed" | "skipped", …)`.
1
+ ---
2
+ name: define-notification
3
+ description: 'Build a reusable multi-channel notification with `defineNotification<Data>({ type, via, ...renderers })`. `type` is the stable identifier preference/rate-limit gates use AND defaults the database channel''s `type`. `via` is `ChannelName[]` OR `(data, to) => ChannelName[]` for per-recipient channel selection. Renderers are `(data, to, ctx) => NotificationChannels[C]`; `ctx` carries `locale` + `meta` from `SendOptions`. The returned object exposes `.send(to|to[], data, options?)`, `.queue(to|to[], data, options?)`, and `.only(...channels)`. Per-recipient + per-channel `Promise.allSettled` isolates failures; the database channel''s `type` is auto-defaulted from `def.type`; `SendOptions.idempotencyKey` is injected into the database payload. Triggers: `defineNotification`, `via`, "reusable notification", "multi-channel notification", "send through several channels at once", "render per channel", `RenderContext`, `def.type`; "force on a notification", "queue with delay"; typical import `import { defineNotification } from "@warlock.js/notifications"`. Skip: single-channel ad-hoc sends — `@warlock.js/notifications/send-ad-hoc/SKILL.md`; adding a brand-new channel — `@warlock.js/notifications/define-channel/SKILL.md`; observing dispatch outcomes — `@warlock.js/notifications/observe-notifications/SKILL.md`.'
4
+ ---
5
+
6
+ # `defineNotification` — reusable multi-channel notification
7
+
8
+ The reusable pattern. Define a notification once with `type` + `via` + a renderer per channel; fire it anywhere with `.send` / `.queue` / `.only`.
9
+
10
+ ## Shape
11
+
12
+ ```ts
13
+ defineNotification<Data>({
14
+ type: string, // REQUIRED — gate key + database default
15
+ via: ChannelName[] | (data, to) => ChannelName[], // static array OR per-recipient callback
16
+ mail?: (data, to, ctx) => MailPayload,
17
+ database?: (data, to, ctx) => Omit<DatabasePayload, "type">, // `type` defaulted from `def.type`
18
+ // ...one renderer per channel listed in `via`
19
+ });
20
+ ```
21
+
22
+ Returns `{ send, queue, only }`:
23
+
24
+ | Method | Signature | What it does |
25
+ |---|---|---|
26
+ | `.send` | `(to, data, options?)` | Render + dispatch synchronously. |
27
+ | `.queue` | `(to, data, options?)` | Render + enqueue. Throws if no queue dispatcher is configured. |
28
+ | `.only(...channels)` | returns `{send, queue, only}` | Restrict dispatch to a subset of channels. |
29
+
30
+ `to` accepts a `Notifiable` OR a `Notifiable[]`. Fan-out emits one render+dispatch per recipient with per-recipient + per-channel `Promise.allSettled` (one failure does not abort the others).
31
+
32
+ ## Static `via`
33
+
34
+ ```ts
35
+ export const welcome = defineNotification<{ name: string }>({
36
+ type: "welcome",
37
+ via: ["mail"],
38
+ mail: ({ name }) => ({ subject: "Welcome", html: `<p>Hi ${name}</p>` }),
39
+ });
40
+
41
+ await welcome.send(user, { name: "Hasan" });
42
+ ```
43
+
44
+ ## Dynamic `via` per recipient
45
+
46
+ ```ts
47
+ export const orderShipped = defineNotification<{ order: Order }>({
48
+ type: "order.shipped",
49
+ via: (_data, to) =>
50
+ to.get("telegram_chat_id")
51
+ ? ["database", "telegram"]
52
+ : ["database", "mail"],
53
+ database: ({ order }) => ({ title: `Order #${order.number} shipped` }),
54
+ mail: ({ order }, to) => ({
55
+ subject: `Order #${order.number} shipped`,
56
+ html: `<p>Hi ${to.get("name")}, on the way.</p>`,
57
+ }),
58
+ // telegram: ... (Phase 2 — bridges)
59
+ });
60
+ ```
61
+
62
+ ## The renderer's 3rd arg — `RenderContext`
63
+
64
+ ```ts
65
+ mail: ({ campaignId }, to, { locale = "en", meta }) => ({
66
+ subject: subjectsByLocale[locale],
67
+ html: htmlByLocale[locale],
68
+ }),
69
+
70
+ // usage:
71
+ await marketing.queue(user, payload, {
72
+ locale: user.get("locale"),
73
+ meta: { campaignId, source: "blast" },
74
+ });
75
+ ```
76
+
77
+ `ctx` carries `locale` + `meta` from `SendOptions`. `meta` flows through to every observability event too.
78
+
79
+ ## Database channel — `type` is defaulted
80
+
81
+ The `database` renderer may OMIT `type` — the dispatcher injects `def.type` automatically. This keeps the notification type in ONE place.
82
+
83
+ ```ts
84
+ defineNotification({
85
+ type: "order.shipped", // ← single source of truth
86
+ via: ["database"],
87
+ database: () => ({ title: "Shipped" }), // type omitted; dispatcher injects "order.shipped"
88
+ });
89
+ ```
90
+
91
+ ## Fan-out
92
+
93
+ ```ts
94
+ await orderShipped.send([buyer, salesRep], { order });
95
+ // One render+dispatch per recipient. Database does N inserts (Phase 0);
96
+ // Phase 1 ships a single bulk insert via `Channel.sendMany?`.
97
+ ```
98
+
99
+ ## `.only(...)` — restrict channels
100
+
101
+ ```ts
102
+ await orderShipped.only("mail").send(user, { order });
103
+ // `via` is filtered to just "mail"; other channels are skipped.
104
+ ```
105
+
106
+ ## Queue (Phase 2)
107
+
108
+ ```ts
109
+ await orderShipped.queue(user, { order }, { delay: "10m" });
110
+ // Throws NoQueueDispatcherError if no `queue` is configured in config/notifications.ts.
111
+ ```
112
+
113
+ ## SendOptions
114
+
115
+ | Field | Effect |
116
+ |---|---|
117
+ | `delay` | Reserved — `"10m"` / `"3d"` / `600` (seconds). NOT honored yet: both `.send()` and the current `.queue()` worker dispatch immediately; delay-aware delivery is a follow-up. |
118
+ | `locale` | Passed to renderers via `RenderContext.locale`. |
119
+ | `meta` | Passed to renderers + included in every observability event. |
120
+ | `idempotencyKey` | Injected into the database payload (dedupe via unique index). |
121
+ | `force` | Bypass `PreferenceProvider`. Does NOT bypass `RateLimiter`. |
122
+ | `type` | Ignored by `defineNotification` — see `send-ad-hoc` for ad-hoc gating. |
123
+
124
+ ## Failure isolation
125
+
126
+ Per-recipient: each recipient gets an independent `Promise.allSettled`. Per-channel: each channel within a recipient gets an independent `allSettled`. A throw becomes a `failed` event on the event bus AND re-throws within its own slot — never aborts siblings.
127
+
128
+ ## See also
129
+
130
+ - [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — package front door.
131
+ - [`send-ad-hoc/SKILL.md`](../send-ad-hoc/SKILL.md) — `notify.<channel>` per-channel shorthand.
132
+ - [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) — wire channels + preferences + rate-limit + queue.
133
+ - [`observe-notifications/SKILL.md`](../observe-notifications/SKILL.md) — `notifications.on("sending" | "sent" | "failed" | "skipped", …)`.
@@ -1,66 +1,66 @@
1
- ---
2
- name: observe-notifications
3
- description: 'Wire metrics, logging, tracing, and audits via `notifications.on(event, handler)` — four events fire per (channel, recipient): `sending` (gates passed, about to hit the transport), `sent` (succeeded), `failed` (channel threw — carries the Error), `skipped` (a pre-send gate dropped it — carries `reason: "preference" | "rate-limit"`). Every event carries the recipient as `notifiable?` (undefined only for raw-target ad-hoc sends) and a `dispatchId` — `sending` and its terminal `sent`/`failed` share it, so observers can pair them (spans, latency, hung-send detection). `sent`/`failed` also carry `durationMs`. Handler exceptions are logged-and-swallowed so one bad listener can not break the dispatcher. `on(...)` returns an unsubscribe function; `off(event, handler)` exists for symmetry. Triggers: `notifications.on`, `notifications.off`, `"sending"` event, `"sent"` event, `"failed"` event, `"skipped"` event, `dispatchId`, `durationMs`, `NotificationEvents`, "notification metrics", "notification latency", "trace notifications", "notification audit log", "track notification drops"; typical import `import { notifications } from "@warlock.js/notifications"`. Skip: building notifications — `@warlock.js/notifications/define-notification/SKILL.md`; configuring the preference/rate-limit gates that produce `skipped` — `@warlock.js/notifications/configure-notifications/SKILL.md`.'
4
- ---
5
-
6
- # Observe notifications
7
-
8
- Every dispatch emits a `sending` event then a terminal `sent` / `failed` — or a `skipped` if a gate dropped it — per (channel, recipient).
9
-
10
- ```ts
11
- import { notifications } from "@warlock.js/notifications";
12
-
13
- // latency + delivery, keyed by recipient
14
- notifications.on("sent", ({ channel, notifiable, durationMs, options }) =>
15
- metrics.timing(`notif.${channel}.sent`, durationMs, { userId: notifiable?.id, ...options.meta }));
16
-
17
- notifications.on("failed", ({ channel, notifiable, error }) =>
18
- log.error(`notif.${channel}`, error, { userId: notifiable?.id }));
19
-
20
- notifications.on("skipped", ({ channel, reason }) =>
21
- metrics.inc(`notif.${channel}.skipped.${reason}`));
22
-
23
- // pair `sending` → terminal by dispatchId — open a span / arm a hung-send watchdog
24
- notifications.on("sending", ({ dispatchId, channel, notifiable }) =>
25
- tracer.open(dispatchId, { channel, userId: notifiable?.id }));
26
- ```
27
-
28
- ## Events
29
-
30
- | Event | Fires when | Payload |
31
- |---|---|---|
32
- | `sending` | Gates passed; about to hit the transport (sync) / enqueue (queue) | `{ dispatchId, channel, notifiable?, payload, options }` |
33
- | `sent` | Channel dispatched OR job enqueued | `{ dispatchId, channel, notifiable?, payload, options, durationMs }` |
34
- | `failed` | `channel.send` (or `queue.dispatch`) threw | `{ dispatchId, channel, notifiable?, payload, error, options, durationMs }` |
35
- | `skipped` | A pre-send gate dropped this channel | `{ dispatchId, channel, notifiable?, reason, options }` — `reason: "preference" \| "rate-limit"` |
36
-
37
- `notifiable` is the recipient **model** (undefined only for raw-target ad-hoc sends like `notify.mail("x@y.com", …)`) — read `notifiable?.id` / `notifiable?.get(...)` to key your metrics or logs.
38
-
39
- `dispatchId` is unique per (channel, recipient) dispatch — `sending` and its terminal `sent`/`failed` share it, so you can pair them for a span, a latency measurement, or a watchdog that flags a `sending` with no terminal (a hung send). `durationMs` is transport time on a sync send, enqueue time on a queued one.
40
-
41
- `options` is the `SendOptions` passed at the call site — including `meta`. Tagging a send with `meta: { campaignId }` lets downstream observers join the event back to the campaign.
42
-
43
- ## Unsubscribe
44
-
45
- ```ts
46
- const off = notifications.on("sent", handler);
47
- // later
48
- off();
49
-
50
- // or
51
- notifications.off("sent", handler);
52
- ```
53
-
54
- ## Fan-out emits N events
55
-
56
- `orderShipped.send([buyer, salesRep], { order })` with `via: ["mail", "database"]` fires up to **4** `sent` events (2 recipients × 2 channels). Aggregate by `meta` or by `channel + notifiable.id` in your observer.
57
-
58
- ## Handler isolation
59
-
60
- A handler that throws is logged and swallowed — the other handlers for the same event still run, and the dispatcher never observes the throw. Observers can't **abort** a send (there's no veto here — use `preferences` / `rateLimit` for that). They can delay it, though: `sending` is awaited before the transport, so keep that handler fast.
61
-
62
- ## See also
63
-
64
- - [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — front door + mental model.
65
- - [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) — `preferences` / `rateLimit` slots that emit `skipped`.
66
- - [`send-ad-hoc/SKILL.md`](../send-ad-hoc/SKILL.md) — when `notifiable` is undefined in events.
1
+ ---
2
+ name: observe-notifications
3
+ description: 'Wire metrics, logging, tracing, and audits via `notifications.on(event, handler)` — four events fire per (channel, recipient): `sending` (gates passed, about to hit the transport), `sent` (succeeded), `failed` (channel threw — carries the Error), `skipped` (a pre-send gate dropped it — carries `reason: "preference" | "rate-limit"`). Every event carries the recipient as `notifiable?` (undefined only for raw-target ad-hoc sends) and a `dispatchId` — `sending` and its terminal `sent`/`failed` share it, so observers can pair them (spans, latency, hung-send detection). `sent`/`failed` also carry `durationMs`. Handler exceptions are logged-and-swallowed so one bad listener can not break the dispatcher. `on(...)` returns an unsubscribe function; `off(event, handler)` exists for symmetry. Triggers: `notifications.on`, `notifications.off`, `"sending"` event, `"sent"` event, `"failed"` event, `"skipped"` event, `dispatchId`, `durationMs`, `NotificationEvents`, "notification metrics", "notification latency", "trace notifications", "notification audit log", "track notification drops"; typical import `import { notifications } from "@warlock.js/notifications"`. Skip: building notifications — `@warlock.js/notifications/define-notification/SKILL.md`; configuring the preference/rate-limit gates that produce `skipped` — `@warlock.js/notifications/configure-notifications/SKILL.md`.'
4
+ ---
5
+
6
+ # Observe notifications
7
+
8
+ Every dispatch emits a `sending` event then a terminal `sent` / `failed` — or a `skipped` if a gate dropped it — per (channel, recipient).
9
+
10
+ ```ts
11
+ import { notifications } from "@warlock.js/notifications";
12
+
13
+ // latency + delivery, keyed by recipient
14
+ notifications.on("sent", ({ channel, notifiable, durationMs, options }) =>
15
+ metrics.timing(`notif.${channel}.sent`, durationMs, { userId: notifiable?.id, ...options.meta }));
16
+
17
+ notifications.on("failed", ({ channel, notifiable, error }) =>
18
+ log.error(`notif.${channel}`, error, { userId: notifiable?.id }));
19
+
20
+ notifications.on("skipped", ({ channel, reason }) =>
21
+ metrics.inc(`notif.${channel}.skipped.${reason}`));
22
+
23
+ // pair `sending` → terminal by dispatchId — open a span / arm a hung-send watchdog
24
+ notifications.on("sending", ({ dispatchId, channel, notifiable }) =>
25
+ tracer.open(dispatchId, { channel, userId: notifiable?.id }));
26
+ ```
27
+
28
+ ## Events
29
+
30
+ | Event | Fires when | Payload |
31
+ |---|---|---|
32
+ | `sending` | Gates passed; about to hit the transport (sync) / enqueue (queue) | `{ dispatchId, channel, notifiable?, payload, options }` |
33
+ | `sent` | Channel dispatched OR job enqueued | `{ dispatchId, channel, notifiable?, payload, options, durationMs }` |
34
+ | `failed` | `channel.send` (or `queue.dispatch`) threw | `{ dispatchId, channel, notifiable?, payload, error, options, durationMs }` |
35
+ | `skipped` | A pre-send gate dropped this channel | `{ dispatchId, channel, notifiable?, reason, options }` — `reason: "preference" \| "rate-limit"` |
36
+
37
+ `notifiable` is the recipient **model** (undefined only for raw-target ad-hoc sends like `notify.mail("x@y.com", …)`) — read `notifiable?.id` / `notifiable?.get(...)` to key your metrics or logs.
38
+
39
+ `dispatchId` is unique per (channel, recipient) dispatch — `sending` and its terminal `sent`/`failed` share it, so you can pair them for a span, a latency measurement, or a watchdog that flags a `sending` with no terminal (a hung send). `durationMs` is transport time on a sync send, enqueue time on a queued one.
40
+
41
+ `options` is the `SendOptions` passed at the call site — including `meta`. Tagging a send with `meta: { campaignId }` lets downstream observers join the event back to the campaign.
42
+
43
+ ## Unsubscribe
44
+
45
+ ```ts
46
+ const off = notifications.on("sent", handler);
47
+ // later
48
+ off();
49
+
50
+ // or
51
+ notifications.off("sent", handler);
52
+ ```
53
+
54
+ ## Fan-out emits N events
55
+
56
+ `orderShipped.send([buyer, salesRep], { order })` with `via: ["mail", "database"]` fires up to **4** `sent` events (2 recipients × 2 channels). Aggregate by `meta` or by `channel + notifiable.id` in your observer.
57
+
58
+ ## Handler isolation
59
+
60
+ A handler that throws is logged and swallowed — the other handlers for the same event still run, and the dispatcher never observes the throw. Observers can't **abort** a send (there's no veto here — use `preferences` / `rateLimit` for that). They can delay it, though: `sending` is awaited before the transport, so keep that handler fast.
61
+
62
+ ## See also
63
+
64
+ - [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — front door + mental model.
65
+ - [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) — `preferences` / `rateLimit` slots that emit `skipped`.
66
+ - [`send-ad-hoc/SKILL.md`](../send-ad-hoc/SKILL.md) — when `notifiable` is undefined in events.
@@ -1,110 +1,110 @@
1
- ---
2
- name: queue-notifications
3
- description: "Send notifications asynchronously through the herald backend or the BullMQ driver. Configure a QueueDispatcher in config/notifications.ts, then use notif.queue(to, data, options?) to enqueue rendered jobs. Herald uses heraldQueue() and startNotificationsWorker(); bullmqQueue({ attempts, backoff }) from @warlock.js/notifications uses BullMQ (lazy-loading the optional @warlock.js/queue peer), supports retries, and honours SendOptions.delay. Without a queue configured, .queue() rejects NoQueueDispatcherError. Triggers: heraldQueue, bullmqQueue, startNotificationsWorker, QueueDispatcher, .queue(, async notifications, queue notifications, notification worker, background notifications, notifications.dispatch. Skip: synchronous sends - @warlock.js/notifications/define-notification/SKILL.md; herald itself - @warlock.js/herald/*."
4
- ---
5
-
6
- # Queue notifications (async)
7
-
8
- Move slow channels (SMTP, HTTP) off the request path. `.queue()` renders the
9
- payload and resolves the route before passing a serializable job to a dispatcher.
10
-
11
- ## Wire the herald dispatcher
12
-
13
- ```ts title="src/config/notifications.ts"
14
- import {
15
- type NotificationConfig,
16
- heraldQueue,
17
- inApp,
18
- mailChannel,
19
- } from "@warlock.js/notifications";
20
- import { Notification } from "app/notifications/notification.model";
21
-
22
- const config: NotificationConfig = {
23
- channels: { mail: mailChannel(), database: inApp.configure({ model: Notification }) },
24
- queue: heraldQueue(),
25
- };
26
-
27
- export default config;
28
- ```
29
-
30
- `heraldQueue({ channel?, broker? })` publishes to `"notifications.dispatch"`
31
- on the default broker unless overridden. It lazy-imports `@warlock.js/herald`;
32
- if that package is missing, the first `.queue()` throws an install message.
33
-
34
- ## Use BullMQ for delayed or retried delivery
35
-
36
- Use the `bullmq` driver instead of the herald dispatcher — it ships inside
37
- `@warlock.js/notifications` itself and lazy-loads `@warlock.js/queue` (an
38
- OPTIONAL peer) the first time `.queue()` runs:
39
-
40
- ```ts title="src/config/notifications.ts"
41
- import { type NotificationConfig, bullmqQueue, mailChannel } from "@warlock.js/notifications";
42
-
43
- const config: NotificationConfig = {
44
- channels: { mail: mailChannel() },
45
- queue: bullmqQueue({ attempts: 3, backoff: { type: "exponential", delay: 5000 } }),
46
- };
47
-
48
- export default config;
49
- ```
50
-
51
- The BullMQ driver honours `SendOptions.delay`: numeric delays are seconds and
52
- string delays use the normal duration syntax, such as `"10m"`. It also supports
53
- BullMQ retries through `attempts` and `backoff`. If `@warlock.js/queue` isn't
54
- installed, the first `.queue()` throws `QueuePackageNotInstalledError` naming
55
- `warlock add queue`.
56
-
57
- > **Removed (5.15.0):** `queueNotificationDispatcher` from
58
- > `@warlock.js/queue/notifications` — deprecated in 5.14, removed this
59
- > release, along with the `@warlock.js/notifications` peer dependency it was
60
- > the only reason `@warlock.js/queue` carried. Use `bullmqQueue` above — same
61
- > option names (`queue`, `attempts`, `backoff`).
62
-
63
- ## Run the herald worker
64
-
65
- In a worker entrypoint (or the web process), after the config and herald broker
66
- are up:
67
-
68
- ```ts
69
- import { startNotificationsWorker } from "@warlock.js/notifications";
70
-
71
- await startNotificationsWorker();
72
- ```
73
-
74
- The worker looks up the channel by name and runs
75
- `channel.send({ payload, route, options })`. The worker needs no recipient model
76
- because rendering happened at enqueue time.
77
-
78
- ## Send
79
-
80
- ```ts
81
- await orderShipped.queue(user, { order });
82
- await orderShipped.queue(buyers, { order }, { delay: "10m" });
83
- await notify.mail(user, payload, {/* synchronous - notify.* does not queue */});
84
- ```
85
-
86
- `.queue()` renders per recipient and enqueues one job per recipient. Without a
87
- configured dispatcher, it rejects `NoQueueDispatcherError`.
88
-
89
- ## Idempotency
90
-
91
- Pass `idempotencyKey` so a redelivered or retried job does not create a duplicate
92
- in-app row. `createFor` finds or creates the row by that key.
93
-
94
- ```ts
95
- await orderShipped.queue(user, { order }, { idempotencyKey: `ship:${order.id}` });
96
- ```
97
-
98
- ## Current limits
99
-
100
- - With the herald backend, `delay` is carried on the job but not honoured; the
101
- worker delivers immediately. Use the BullMQ adapter for delayed delivery.
102
- - Herald has no retry or dead-letter handling yet; failed `channel.send` calls
103
- are logged and acknowledged.
104
- - Rate-limit budget is consumed at enqueue time for queued sends.
105
-
106
- ## See also
107
-
108
- - [`define-notification/SKILL.md`](../define-notification/SKILL.md) - `.queue()` on a defined notification.
109
- - [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) - the `queue` config slot.
110
- - [`@warlock.js/herald/herald-basics/SKILL.md`](../../../herald/skills/herald-basics/SKILL.md) - connecting the broker.
1
+ ---
2
+ name: queue-notifications
3
+ description: "Send notifications asynchronously through the herald backend or the BullMQ driver. Configure a QueueDispatcher in config/notifications.ts, then use notif.queue(to, data, options?) to enqueue rendered jobs. Herald uses heraldQueue() and startNotificationsWorker(); bullmqQueue({ attempts, backoff }) from @warlock.js/notifications uses BullMQ (lazy-loading the optional @warlock.js/queue peer), supports retries, and honours SendOptions.delay. Without a queue configured, .queue() rejects NoQueueDispatcherError. Triggers: heraldQueue, bullmqQueue, startNotificationsWorker, QueueDispatcher, .queue(, async notifications, queue notifications, notification worker, background notifications, notifications.dispatch. Skip: synchronous sends - @warlock.js/notifications/define-notification/SKILL.md; herald itself - @warlock.js/herald/*."
4
+ ---
5
+
6
+ # Queue notifications (async)
7
+
8
+ Move slow channels (SMTP, HTTP) off the request path. `.queue()` renders the
9
+ payload and resolves the route before passing a serializable job to a dispatcher.
10
+
11
+ ## Wire the herald dispatcher
12
+
13
+ ```ts title="src/config/notifications.ts"
14
+ import {
15
+ type NotificationConfig,
16
+ heraldQueue,
17
+ inApp,
18
+ mailChannel,
19
+ } from "@warlock.js/notifications";
20
+ import { Notification } from "app/notifications/notification.model";
21
+
22
+ const config: NotificationConfig = {
23
+ channels: { mail: mailChannel(), database: inApp.configure({ model: Notification }) },
24
+ queue: heraldQueue(),
25
+ };
26
+
27
+ export default config;
28
+ ```
29
+
30
+ `heraldQueue({ channel?, broker? })` publishes to `"notifications.dispatch"`
31
+ on the default broker unless overridden. It lazy-imports `@warlock.js/herald`;
32
+ if that package is missing, the first `.queue()` throws an install message.
33
+
34
+ ## Use BullMQ for delayed or retried delivery
35
+
36
+ Use the `bullmq` driver instead of the herald dispatcher — it ships inside
37
+ `@warlock.js/notifications` itself and lazy-loads `@warlock.js/queue` (an
38
+ OPTIONAL peer) the first time `.queue()` runs:
39
+
40
+ ```ts title="src/config/notifications.ts"
41
+ import { type NotificationConfig, bullmqQueue, mailChannel } from "@warlock.js/notifications";
42
+
43
+ const config: NotificationConfig = {
44
+ channels: { mail: mailChannel() },
45
+ queue: bullmqQueue({ attempts: 3, backoff: { type: "exponential", delay: 5000 } }),
46
+ };
47
+
48
+ export default config;
49
+ ```
50
+
51
+ The BullMQ driver honours `SendOptions.delay`: numeric delays are seconds and
52
+ string delays use the normal duration syntax, such as `"10m"`. It also supports
53
+ BullMQ retries through `attempts` and `backoff`. If `@warlock.js/queue` isn't
54
+ installed, the first `.queue()` throws `QueuePackageNotInstalledError` naming
55
+ `warlock add queue`.
56
+
57
+ > **Removed (5.15.0):** `queueNotificationDispatcher` from
58
+ > `@warlock.js/queue/notifications` — deprecated in 5.14, removed this
59
+ > release, along with the `@warlock.js/notifications` peer dependency it was
60
+ > the only reason `@warlock.js/queue` carried. Use `bullmqQueue` above — same
61
+ > option names (`queue`, `attempts`, `backoff`).
62
+
63
+ ## Run the herald worker
64
+
65
+ In a worker entrypoint (or the web process), after the config and herald broker
66
+ are up:
67
+
68
+ ```ts
69
+ import { startNotificationsWorker } from "@warlock.js/notifications";
70
+
71
+ await startNotificationsWorker();
72
+ ```
73
+
74
+ The worker looks up the channel by name and runs
75
+ `channel.send({ payload, route, options })`. The worker needs no recipient model
76
+ because rendering happened at enqueue time.
77
+
78
+ ## Send
79
+
80
+ ```ts
81
+ await orderShipped.queue(user, { order });
82
+ await orderShipped.queue(buyers, { order }, { delay: "10m" });
83
+ await notify.mail(user, payload, {/* synchronous - notify.* does not queue */});
84
+ ```
85
+
86
+ `.queue()` renders per recipient and enqueues one job per recipient. Without a
87
+ configured dispatcher, it rejects `NoQueueDispatcherError`.
88
+
89
+ ## Idempotency
90
+
91
+ Pass `idempotencyKey` so a redelivered or retried job does not create a duplicate
92
+ in-app row. `createFor` finds or creates the row by that key.
93
+
94
+ ```ts
95
+ await orderShipped.queue(user, { order }, { idempotencyKey: `ship:${order.id}` });
96
+ ```
97
+
98
+ ## Current limits
99
+
100
+ - With the herald backend, `delay` is carried on the job but not honoured; the
101
+ worker delivers immediately. Use the BullMQ adapter for delayed delivery.
102
+ - Herald has no retry or dead-letter handling yet; failed `channel.send` calls
103
+ are logged and acknowledged.
104
+ - Rate-limit budget is consumed at enqueue time for queued sends.
105
+
106
+ ## See also
107
+
108
+ - [`define-notification/SKILL.md`](../define-notification/SKILL.md) - `.queue()` on a defined notification.
109
+ - [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) - the `queue` config slot.
110
+ - [`@warlock.js/herald/herald-basics/SKILL.md`](../../../herald/skills/herald-basics/SKILL.md) - connecting the broker.