@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
package/llms-full.txt CHANGED
@@ -6,392 +6,392 @@
6
6
 
7
7
  ## configure-notifications `@warlock.js/notifications/configure-notifications/SKILL.md`
8
8
 
9
- ---
10
- name: configure-notifications
11
- description: 'Wire `@warlock.js/notifications` via a declarative `src/config/notifications.ts` that exports a `NotificationConfig` default — the notifications connector registers it at boot, so app code never calls `setNotificationConfig`. `channels`: registry mapping name → factory result (`mailChannel`, `inApp.configure`, custom `defineChannel`); `queue`: optional `QueueDispatcher` (`.queue()` throws without it); `preferences`: optional `PreferenceProvider` (drops channels the recipient muted); `rateLimit`: optional `RateLimiter` (drops channels exceeding per-recipient budgets). Triggers: `config/notifications.ts`, `NotificationConfig`, `setNotificationConfig`, `getNotificationConfig`, `mailChannel`, `inApp.configure`, "configure notifications", "wire notifications at boot", "set preferences provider", "set rate limit"; typical import `import { type NotificationConfig, mailChannel, inApp } from "@warlock.js/notifications"`. Skip: building a reusable notification — `@warlock.js/notifications/define-notification/SKILL.md`; the in-app read API — `@warlock.js/notifications/use-in-app/SKILL.md`; adding a custom channel — `@warlock.js/notifications/define-channel/SKILL.md`.'
12
- ---
13
-
14
- # Configure notifications
15
-
16
- `src/config/notifications.ts` exports a `NotificationConfig` as its default; the notifications connector registers it at boot and the dispatcher reads from it on every send. The file is **declarative** — you never call `setNotificationConfig` yourself (the connector does, the same way the rest of `src/config/*.ts` is wired).
17
-
18
- > `npx warlock add notifications` ejects this file pre-wired (mail + in-app) alongside the `Notification` model + migration and the recipient-scoped read/dismiss routes + controllers. Edit the ejected config to taste — the sections below are the full surface.
19
-
20
- ## Minimal
21
-
22
- ```ts title="src/config/notifications.ts"
23
- import { type NotificationConfig, inApp, mailChannel } from "@warlock.js/notifications";
24
- import { Notification } from "app/notifications/notification.model";
25
-
26
- const config: NotificationConfig = {
27
- channels: {
28
- mail: mailChannel({ from: "no-reply@store.com" }),
29
- database: inApp.configure({ model: Notification }),
30
- },
31
- };
32
-
33
- export default config;
34
- ```
35
-
36
- The `database` channel is returned by `inApp.configure({ model | repository })` — it binds the in-app store AND returns the channel, so the read side + the write side share ONE repository instance.
37
-
38
- ## Full surface
39
-
40
- ```ts
41
- const config: NotificationConfig = {
42
- channels: {
43
- mail: mailChannel({ from }),
44
- database: inApp.configure({ model: Notification }),
45
-
46
- // custom channels
47
- discord: discordChannel(), // defineChannel + declare module
48
- },
49
-
50
- // optional gates — both app-owned; package ships no defaults
51
- preferences: userPreferences, // drops channels recipient muted
52
- rateLimit, // drops channels over budget
53
- queue: heraldQueue(), // backs `.queue()` (needs @warlock.js/herald)
54
- };
55
-
56
- export default config;
57
- ```
58
-
59
- ## Channel keys must match the registry
60
-
61
- `channels: { mail, database, discord }` — keys correspond to `NotificationChannels` entries. Built-ins (`mail`, `database`) ship in the registry; custom channels extend it with `declare module`.
62
-
63
- ```ts
64
- // in your custom channel file
65
- declare module "@warlock.js/notifications" {
66
- interface NotificationChannels {
67
- discord: { content: string };
68
- }
69
- }
70
- ```
71
-
72
- After that, `notify.discord(...)`, `via: ["discord"]`, and the `discord:` key in `defineNotification` all autocomplete + type-check.
73
-
74
- ## `inApp.configure({ model | repository })`
75
-
76
- Two paths; mutually exclusive at the type level.
77
-
78
- ```ts
79
- // 90% case — pass the model class; default repo built internally.
80
- database: inApp.configure({ model: Notification }),
81
-
82
- // 10% case — pass a custom repo (only for EXTRA query methods; column names
83
- // come from the model's columnMap, not a repo override).
84
- database: inApp.configure({ repository: notificationsRepository }),
85
- ```
86
-
87
- There is NO zero-arg form — the package ships no concrete table/model.
88
-
89
- ## `preferences` — pre-send opt-in gate (optional)
90
-
91
- ```ts
92
- import type { PreferenceProvider } from "@warlock.js/notifications";
93
-
94
- export const userPreferences: PreferenceProvider = {
95
- resolveChannels(user, type, requested) {
96
- const muted: Record<string, string[]> = user.get("preferences.muted") ?? {};
97
- return requested.filter((c) => !(muted[c] ?? []).includes(type));
98
- },
99
- };
100
- ```
101
-
102
- Dropped channels fire a `skipped` event with reason `"preference"`. `SendOptions.force === true` bypasses this gate.
103
-
104
- ## `rateLimit` — pre-send safety valve (optional)
105
-
106
- ```ts
107
- import { cache } from "@warlock.js/cache";
108
- import type { RateLimiter } from "@warlock.js/notifications";
109
-
110
- export const rateLimit: RateLimiter = {
111
- async allow(user, channel, type) {
112
- const key = `notif.rl.${user.id}.${channel}.${type}`;
113
- const count = await cache.increment(key, 1);
114
- if (count === 1) await cache.set(key, 1, { ttl: 3600 });
115
- return count <= 5;
116
- },
117
- };
118
- ```
119
-
120
- Dropped channels fire a `skipped` event with reason `"rate-limit"`. `force` does NOT bypass this — rate limits are a safety valve, not a UX preference.
121
-
122
- ## Reading the config back
123
-
124
- ```ts
125
- import { getNotificationConfig } from "@warlock.js/notifications";
126
-
127
- const cfg = getNotificationConfig(); // throws NotificationsNotConfiguredError if unset
128
- ```
129
-
130
- ## Tests
131
-
132
- Tests call `setNotificationConfig` directly — bypassing the connector — to set state, then `resetNotificationConfig` to tear down:
133
-
134
- ```ts
135
- import { resetNotificationConfig, setNotificationConfig } from "@warlock.js/notifications";
136
-
137
- beforeEach(() => setNotificationConfig({ channels: { ... } }));
138
- afterEach(() => resetNotificationConfig());
139
- ```
140
-
141
- ## See also
142
-
143
- - [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — package front door.
144
- - [`define-notification/SKILL.md`](../define-notification/SKILL.md) — reusable multi-channel definitions.
145
- - [`use-in-app/SKILL.md`](../use-in-app/SKILL.md) — in-app read API.
146
- - [`define-channel/SKILL.md`](../define-channel/SKILL.md) — custom channels.
9
+ ---
10
+ name: configure-notifications
11
+ description: 'Wire `@warlock.js/notifications` via a declarative `src/config/notifications.ts` that exports a `NotificationConfig` default — the notifications connector registers it at boot, so app code never calls `setNotificationConfig`. `channels`: registry mapping name → factory result (`mailChannel`, `inApp.configure`, custom `defineChannel`); `queue`: optional `QueueDispatcher` (`.queue()` throws without it); `preferences`: optional `PreferenceProvider` (drops channels the recipient muted); `rateLimit`: optional `RateLimiter` (drops channels exceeding per-recipient budgets). Triggers: `config/notifications.ts`, `NotificationConfig`, `setNotificationConfig`, `getNotificationConfig`, `mailChannel`, `inApp.configure`, "configure notifications", "wire notifications at boot", "set preferences provider", "set rate limit"; typical import `import { type NotificationConfig, mailChannel, inApp } from "@warlock.js/notifications"`. Skip: building a reusable notification — `@warlock.js/notifications/define-notification/SKILL.md`; the in-app read API — `@warlock.js/notifications/use-in-app/SKILL.md`; adding a custom channel — `@warlock.js/notifications/define-channel/SKILL.md`.'
12
+ ---
13
+
14
+ # Configure notifications
15
+
16
+ `src/config/notifications.ts` exports a `NotificationConfig` as its default; the notifications connector registers it at boot and the dispatcher reads from it on every send. The file is **declarative** — you never call `setNotificationConfig` yourself (the connector does, the same way the rest of `src/config/*.ts` is wired).
17
+
18
+ > `npx warlock add notifications` ejects this file pre-wired (mail + in-app) alongside the `Notification` model + migration and the recipient-scoped read/dismiss routes + controllers. Edit the ejected config to taste — the sections below are the full surface.
19
+
20
+ ## Minimal
21
+
22
+ ```ts title="src/config/notifications.ts"
23
+ import { type NotificationConfig, inApp, mailChannel } from "@warlock.js/notifications";
24
+ import { Notification } from "app/notifications/notification.model";
25
+
26
+ const config: NotificationConfig = {
27
+ channels: {
28
+ mail: mailChannel({ from: "no-reply@store.com" }),
29
+ database: inApp.configure({ model: Notification }),
30
+ },
31
+ };
32
+
33
+ export default config;
34
+ ```
35
+
36
+ The `database` channel is returned by `inApp.configure({ model | repository })` — it binds the in-app store AND returns the channel, so the read side + the write side share ONE repository instance.
37
+
38
+ ## Full surface
39
+
40
+ ```ts
41
+ const config: NotificationConfig = {
42
+ channels: {
43
+ mail: mailChannel({ from }),
44
+ database: inApp.configure({ model: Notification }),
45
+
46
+ // custom channels
47
+ discord: discordChannel(), // defineChannel + declare module
48
+ },
49
+
50
+ // optional gates — both app-owned; package ships no defaults
51
+ preferences: userPreferences, // drops channels recipient muted
52
+ rateLimit, // drops channels over budget
53
+ queue: heraldQueue(), // backs `.queue()` (needs @warlock.js/herald)
54
+ };
55
+
56
+ export default config;
57
+ ```
58
+
59
+ ## Channel keys must match the registry
60
+
61
+ `channels: { mail, database, discord }` — keys correspond to `NotificationChannels` entries. Built-ins (`mail`, `database`) ship in the registry; custom channels extend it with `declare module`.
62
+
63
+ ```ts
64
+ // in your custom channel file
65
+ declare module "@warlock.js/notifications" {
66
+ interface NotificationChannels {
67
+ discord: { content: string };
68
+ }
69
+ }
70
+ ```
71
+
72
+ After that, `notify.discord(...)`, `via: ["discord"]`, and the `discord:` key in `defineNotification` all autocomplete + type-check.
73
+
74
+ ## `inApp.configure({ model | repository })`
75
+
76
+ Two paths; mutually exclusive at the type level.
77
+
78
+ ```ts
79
+ // 90% case — pass the model class; default repo built internally.
80
+ database: inApp.configure({ model: Notification }),
81
+
82
+ // 10% case — pass a custom repo (only for EXTRA query methods; column names
83
+ // come from the model's columnMap, not a repo override).
84
+ database: inApp.configure({ repository: notificationsRepository }),
85
+ ```
86
+
87
+ There is NO zero-arg form — the package ships no concrete table/model.
88
+
89
+ ## `preferences` — pre-send opt-in gate (optional)
90
+
91
+ ```ts
92
+ import type { PreferenceProvider } from "@warlock.js/notifications";
93
+
94
+ export const userPreferences: PreferenceProvider = {
95
+ resolveChannels(user, type, requested) {
96
+ const muted: Record<string, string[]> = user.get("preferences.muted") ?? {};
97
+ return requested.filter((c) => !(muted[c] ?? []).includes(type));
98
+ },
99
+ };
100
+ ```
101
+
102
+ Dropped channels fire a `skipped` event with reason `"preference"`. `SendOptions.force === true` bypasses this gate.
103
+
104
+ ## `rateLimit` — pre-send safety valve (optional)
105
+
106
+ ```ts
107
+ import { cache } from "@warlock.js/cache";
108
+ import type { RateLimiter } from "@warlock.js/notifications";
109
+
110
+ export const rateLimit: RateLimiter = {
111
+ async allow(user, channel, type) {
112
+ const key = `notif.rl.${user.id}.${channel}.${type}`;
113
+ const count = await cache.increment(key, 1);
114
+ if (count === 1) await cache.set(key, 1, { ttl: 3600 });
115
+ return count <= 5;
116
+ },
117
+ };
118
+ ```
119
+
120
+ Dropped channels fire a `skipped` event with reason `"rate-limit"`. `force` does NOT bypass this — rate limits are a safety valve, not a UX preference.
121
+
122
+ ## Reading the config back
123
+
124
+ ```ts
125
+ import { getNotificationConfig } from "@warlock.js/notifications";
126
+
127
+ const cfg = getNotificationConfig(); // throws NotificationsNotConfiguredError if unset
128
+ ```
129
+
130
+ ## Tests
131
+
132
+ Tests call `setNotificationConfig` directly — bypassing the connector — to set state, then `resetNotificationConfig` to tear down:
133
+
134
+ ```ts
135
+ import { resetNotificationConfig, setNotificationConfig } from "@warlock.js/notifications";
136
+
137
+ beforeEach(() => setNotificationConfig({ channels: { ... } }));
138
+ afterEach(() => resetNotificationConfig());
139
+ ```
140
+
141
+ ## See also
142
+
143
+ - [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — package front door.
144
+ - [`define-notification/SKILL.md`](../define-notification/SKILL.md) — reusable multi-channel definitions.
145
+ - [`use-in-app/SKILL.md`](../use-in-app/SKILL.md) — in-app read API.
146
+ - [`define-channel/SKILL.md`](../define-channel/SKILL.md) — custom channels.
147
147
 
148
148
 
149
149
  ## define-channel `@warlock.js/notifications/define-channel/SKILL.md`
150
150
 
151
- ---
152
- name: define-channel
153
- description: 'Add a custom notification channel — `defineChannel<P>({ name, route?, send })` returns a `Channel<P>` you register in the `channels` map of `setNotificationConfig`. `name` matches the key in `NotificationChannels` (extend via `declare module` for typing); `route(notifiable)` resolves the recipient''s address (defaults to `{ id }` when omitted); `send({ payload, route, notifiable, options })` does the actual transport. Use for Slack/Discord/internal-webhook/anything else — `fetch`-based with NO SDK is the simplest variant. Channels that load a heavy SDK should follow the lazy-import pattern from the develop-feature playbook. Triggers: `defineChannel`, `Channel<P>`, "custom notification channel", "add discord channel", "slack channel", "webhook channel", "new channel type", `declare module "@warlock.js/notifications"`; typical import `import { defineChannel } from "@warlock.js/notifications"`. Skip: lazy-loading an optional SDK behind the channel — `D:/xampp/htdocs/mongez/node/.claude/skills/develop-warlock.js-feature/SKILL.md`; using built-in channels — `@warlock.js/notifications/configure-notifications/SKILL.md`.'
154
- ---
155
-
156
- # `defineChannel` — custom channels
157
-
158
- The escape hatch for channel types the package doesn't ship.
159
-
160
- ## Minimal — `fetch`-based webhook (no SDK)
161
-
162
- ```ts title="src/app/notifications/channels/discord.channel.ts"
163
- import { defineChannel } from "@warlock.js/notifications";
164
-
165
- type DiscordPayload = { content: string };
166
-
167
- export const discordChannel = () =>
168
- defineChannel<DiscordPayload>({
169
- name: "discord",
170
- route: (notifiable) => notifiable.get("discord_webhook") as string,
171
- async send({ payload, route }) {
172
- const response = await fetch(route as string, {
173
- method: "POST",
174
- headers: { "content-type": "application/json" },
175
- body: JSON.stringify(payload),
176
- });
177
- if (!response.ok) throw new Error(`Discord send failed: ${response.status}`);
178
- },
179
- });
180
-
181
- // Teach TypeScript about the new channel — picks up notify.discord and
182
- // `defineNotification` typing.
183
- declare module "@warlock.js/notifications" {
184
- interface NotificationChannels {
185
- discord: DiscordPayload;
186
- }
187
- }
188
- ```
189
-
190
- Register in `config/notifications.ts`:
191
-
192
- ```ts
193
- channels: {
194
- // ...
195
- discord: discordChannel(),
196
- }
197
- ```
198
-
199
- Now `notify.discord(user, { content: "🎉" })` works, and `discord:` is a valid renderer key in `defineNotification`.
200
-
201
- ## `route` resolution
202
-
203
- | What `route` returns | Effect |
204
- |---|---|
205
- | `string` | Used directly as the address (`"user@example.com"`, webhook URL). |
206
- | `{ id: Id }` | The recipient's storage id — for database/internal channels. |
207
- | `undefined` | Dispatcher falls back to `{ id: notifiable.id }`. |
208
- | `route` omitted | Same as returning `undefined` — `{ id }` fallback. |
209
-
210
- For raw-target ad-hoc sends (`notify.discord("https://hooks.../foo", payload)`), the raw string wins over `route()`.
211
-
212
- ## `send` — the contract
213
-
214
- ```ts
215
- async send({
216
- payload, // P — the channel's payload type
217
- route, // string | { id: Id } — resolved by `route()` or raw target
218
- notifiable, // Notifiable | undefined — undefined for raw-target ad-hoc
219
- options, // SendOptions — delay/locale/meta/idempotencyKey/force/type
220
- }): Promise<void>
221
- ```
222
-
223
- Throw to fail the send. The dispatcher catches per channel, fires a `failed` event, and continues with siblings (per-channel isolation).
224
-
225
- ## SDK-backed channels — lazy-import the SDK
226
-
227
- Channels that wrap a heavy SDK (Twilio, Slack SDK, FCM SDK) should follow the lazy-import pattern so apps that don't use them don't pay the install/load cost. The recipe + race-safe template lives in [`develop-warlock.js-feature`](../../../../.claude/skills/develop-warlock.js-feature/SKILL.md) — copy from there; do not invent a variant.
228
-
229
- Sketch:
230
-
231
- ```ts
232
- import type { WebClient } from "@slack/web-api";
233
- let Slack: typeof import("@slack/web-api");
234
- let isModuleExists: boolean | null = null;
235
-
236
- async function loadSlack() { /* try/catch await import; set flag */ }
237
- loadSlack();
238
-
239
- export const slackChannel = (config: { token: string }) =>
240
- defineChannel<{ text: string }>({
241
- name: "slack",
242
- route: (n) => n.get("slack_channel"),
243
- async send({ payload, route }) {
244
- await loadSlack();
245
- if (!isModuleExists) throw new Error(INSTALL_INSTRUCTIONS);
246
- const client = new Slack.WebClient(config.token);
247
- await client.chat.postMessage({ channel: route as string, text: payload.text });
248
- },
249
- });
250
- ```
251
-
252
- ## See also
253
-
254
- - [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) — register channels in the config.
255
- - [`send-ad-hoc/SKILL.md`](../send-ad-hoc/SKILL.md) — `notify.<custom-channel>` works after registration.
256
- - [`define-notification/SKILL.md`](../define-notification/SKILL.md) — multi-channel renderers with the custom channel key.
257
- - `D:/xampp/htdocs/mongez/node/.claude/skills/develop-warlock.js-feature/SKILL.md` — lazy-import pattern for SDK-backed channels.
151
+ ---
152
+ name: define-channel
153
+ description: 'Add a custom notification channel — `defineChannel<P>({ name, route?, send })` returns a `Channel<P>` you register in the `channels` map of `setNotificationConfig`. `name` matches the key in `NotificationChannels` (extend via `declare module` for typing); `route(notifiable)` resolves the recipient''s address (defaults to `{ id }` when omitted); `send({ payload, route, notifiable, options })` does the actual transport. Use for Slack/Discord/internal-webhook/anything else — `fetch`-based with NO SDK is the simplest variant. Channels that load a heavy SDK should follow the lazy-import pattern from the develop-feature playbook. Triggers: `defineChannel`, `Channel<P>`, "custom notification channel", "add discord channel", "slack channel", "webhook channel", "new channel type", `declare module "@warlock.js/notifications"`; typical import `import { defineChannel } from "@warlock.js/notifications"`. Skip: lazy-loading an optional SDK behind the channel — `D:/xampp/htdocs/mongez/node/.claude/skills/develop-warlock.js-feature/SKILL.md`; using built-in channels — `@warlock.js/notifications/configure-notifications/SKILL.md`.'
154
+ ---
155
+
156
+ # `defineChannel` — custom channels
157
+
158
+ The escape hatch for channel types the package doesn't ship.
159
+
160
+ ## Minimal — `fetch`-based webhook (no SDK)
161
+
162
+ ```ts title="src/app/notifications/channels/discord.channel.ts"
163
+ import { defineChannel } from "@warlock.js/notifications";
164
+
165
+ type DiscordPayload = { content: string };
166
+
167
+ export const discordChannel = () =>
168
+ defineChannel<DiscordPayload>({
169
+ name: "discord",
170
+ route: (notifiable) => notifiable.get("discord_webhook") as string,
171
+ async send({ payload, route }) {
172
+ const response = await fetch(route as string, {
173
+ method: "POST",
174
+ headers: { "content-type": "application/json" },
175
+ body: JSON.stringify(payload),
176
+ });
177
+ if (!response.ok) throw new Error(`Discord send failed: ${response.status}`);
178
+ },
179
+ });
180
+
181
+ // Teach TypeScript about the new channel — picks up notify.discord and
182
+ // `defineNotification` typing.
183
+ declare module "@warlock.js/notifications" {
184
+ interface NotificationChannels {
185
+ discord: DiscordPayload;
186
+ }
187
+ }
188
+ ```
189
+
190
+ Register in `config/notifications.ts`:
191
+
192
+ ```ts
193
+ channels: {
194
+ // ...
195
+ discord: discordChannel(),
196
+ }
197
+ ```
198
+
199
+ Now `notify.discord(user, { content: "🎉" })` works, and `discord:` is a valid renderer key in `defineNotification`.
200
+
201
+ ## `route` resolution
202
+
203
+ | What `route` returns | Effect |
204
+ |---|---|
205
+ | `string` | Used directly as the address (`"user@example.com"`, webhook URL). |
206
+ | `{ id: Id }` | The recipient's storage id — for database/internal channels. |
207
+ | `undefined` | Dispatcher falls back to `{ id: notifiable.id }`. |
208
+ | `route` omitted | Same as returning `undefined` — `{ id }` fallback. |
209
+
210
+ For raw-target ad-hoc sends (`notify.discord("https://hooks.../foo", payload)`), the raw string wins over `route()`.
211
+
212
+ ## `send` — the contract
213
+
214
+ ```ts
215
+ async send({
216
+ payload, // P — the channel's payload type
217
+ route, // string | { id: Id } — resolved by `route()` or raw target
218
+ notifiable, // Notifiable | undefined — undefined for raw-target ad-hoc
219
+ options, // SendOptions — delay/locale/meta/idempotencyKey/force/type
220
+ }): Promise<void>
221
+ ```
222
+
223
+ Throw to fail the send. The dispatcher catches per channel, fires a `failed` event, and continues with siblings (per-channel isolation).
224
+
225
+ ## SDK-backed channels — lazy-import the SDK
226
+
227
+ Channels that wrap a heavy SDK (Twilio, Slack SDK, FCM SDK) should follow the lazy-import pattern so apps that don't use them don't pay the install/load cost. The recipe + race-safe template lives in [`develop-warlock.js-feature`](../../../../.claude/skills/develop-warlock.js-feature/SKILL.md) — copy from there; do not invent a variant.
228
+
229
+ Sketch:
230
+
231
+ ```ts
232
+ import type { WebClient } from "@slack/web-api";
233
+ let Slack: typeof import("@slack/web-api");
234
+ let isModuleExists: boolean | null = null;
235
+
236
+ async function loadSlack() { /* try/catch await import; set flag */ }
237
+ loadSlack();
238
+
239
+ export const slackChannel = (config: { token: string }) =>
240
+ defineChannel<{ text: string }>({
241
+ name: "slack",
242
+ route: (n) => n.get("slack_channel"),
243
+ async send({ payload, route }) {
244
+ await loadSlack();
245
+ if (!isModuleExists) throw new Error(INSTALL_INSTRUCTIONS);
246
+ const client = new Slack.WebClient(config.token);
247
+ await client.chat.postMessage({ channel: route as string, text: payload.text });
248
+ },
249
+ });
250
+ ```
251
+
252
+ ## See also
253
+
254
+ - [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) — register channels in the config.
255
+ - [`send-ad-hoc/SKILL.md`](../send-ad-hoc/SKILL.md) — `notify.<custom-channel>` works after registration.
256
+ - [`define-notification/SKILL.md`](../define-notification/SKILL.md) — multi-channel renderers with the custom channel key.
257
+ - `D:/xampp/htdocs/mongez/node/.claude/skills/develop-warlock.js-feature/SKILL.md` — lazy-import pattern for SDK-backed channels.
258
258
 
259
259
 
260
260
  ## define-notification `@warlock.js/notifications/define-notification/SKILL.md`
261
261
 
262
- ---
263
- name: define-notification
264
- 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`.'
265
- ---
266
-
267
- # `defineNotification` — reusable multi-channel notification
268
-
269
- The reusable pattern. Define a notification once with `type` + `via` + a renderer per channel; fire it anywhere with `.send` / `.queue` / `.only`.
270
-
271
- ## Shape
272
-
273
- ```ts
274
- defineNotification<Data>({
275
- type: string, // REQUIRED — gate key + database default
276
- via: ChannelName[] | (data, to) => ChannelName[], // static array OR per-recipient callback
277
- mail?: (data, to, ctx) => MailPayload,
278
- database?: (data, to, ctx) => Omit<DatabasePayload, "type">, // `type` defaulted from `def.type`
279
- // ...one renderer per channel listed in `via`
280
- });
281
- ```
282
-
283
- Returns `{ send, queue, only }`:
284
-
285
- | Method | Signature | What it does |
286
- |---|---|---|
287
- | `.send` | `(to, data, options?)` | Render + dispatch synchronously. |
288
- | `.queue` | `(to, data, options?)` | Render + enqueue. Throws if no queue dispatcher is configured. |
289
- | `.only(...channels)` | returns `{send, queue, only}` | Restrict dispatch to a subset of channels. |
290
-
291
- `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).
292
-
293
- ## Static `via`
294
-
295
- ```ts
296
- export const welcome = defineNotification<{ name: string }>({
297
- type: "welcome",
298
- via: ["mail"],
299
- mail: ({ name }) => ({ subject: "Welcome", html: `<p>Hi ${name}</p>` }),
300
- });
301
-
302
- await welcome.send(user, { name: "Hasan" });
303
- ```
304
-
305
- ## Dynamic `via` per recipient
306
-
307
- ```ts
308
- export const orderShipped = defineNotification<{ order: Order }>({
309
- type: "order.shipped",
310
- via: (_data, to) =>
311
- to.get("telegram_chat_id")
312
- ? ["database", "telegram"]
313
- : ["database", "mail"],
314
- database: ({ order }) => ({ title: `Order #${order.number} shipped` }),
315
- mail: ({ order }, to) => ({
316
- subject: `Order #${order.number} shipped`,
317
- html: `<p>Hi ${to.get("name")}, on the way.</p>`,
318
- }),
319
- // telegram: ... (Phase 2 — bridges)
320
- });
321
- ```
322
-
323
- ## The renderer's 3rd arg — `RenderContext`
324
-
325
- ```ts
326
- mail: ({ campaignId }, to, { locale = "en", meta }) => ({
327
- subject: subjectsByLocale[locale],
328
- html: htmlByLocale[locale],
329
- }),
330
-
331
- // usage:
332
- await marketing.queue(user, payload, {
333
- locale: user.get("locale"),
334
- meta: { campaignId, source: "blast" },
335
- });
336
- ```
337
-
338
- `ctx` carries `locale` + `meta` from `SendOptions`. `meta` flows through to every observability event too.
339
-
340
- ## Database channel — `type` is defaulted
341
-
342
- The `database` renderer may OMIT `type` — the dispatcher injects `def.type` automatically. This keeps the notification type in ONE place.
343
-
344
- ```ts
345
- defineNotification({
346
- type: "order.shipped", // ← single source of truth
347
- via: ["database"],
348
- database: () => ({ title: "Shipped" }), // type omitted; dispatcher injects "order.shipped"
349
- });
350
- ```
351
-
352
- ## Fan-out
353
-
354
- ```ts
355
- await orderShipped.send([buyer, salesRep], { order });
356
- // One render+dispatch per recipient. Database does N inserts (Phase 0);
357
- // Phase 1 ships a single bulk insert via `Channel.sendMany?`.
358
- ```
359
-
360
- ## `.only(...)` — restrict channels
361
-
362
- ```ts
363
- await orderShipped.only("mail").send(user, { order });
364
- // `via` is filtered to just "mail"; other channels are skipped.
365
- ```
366
-
367
- ## Queue (Phase 2)
368
-
369
- ```ts
370
- await orderShipped.queue(user, { order }, { delay: "10m" });
371
- // Throws NoQueueDispatcherError if no `queue` is configured in config/notifications.ts.
372
- ```
373
-
374
- ## SendOptions
375
-
376
- | Field | Effect |
377
- |---|---|
378
- | `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. |
379
- | `locale` | Passed to renderers via `RenderContext.locale`. |
380
- | `meta` | Passed to renderers + included in every observability event. |
381
- | `idempotencyKey` | Injected into the database payload (dedupe via unique index). |
382
- | `force` | Bypass `PreferenceProvider`. Does NOT bypass `RateLimiter`. |
383
- | `type` | Ignored by `defineNotification` — see `send-ad-hoc` for ad-hoc gating. |
384
-
385
- ## Failure isolation
386
-
387
- 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.
388
-
389
- ## See also
390
-
391
- - [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — package front door.
392
- - [`send-ad-hoc/SKILL.md`](../send-ad-hoc/SKILL.md) — `notify.<channel>` per-channel shorthand.
393
- - [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) — wire channels + preferences + rate-limit + queue.
394
- - [`observe-notifications/SKILL.md`](../observe-notifications/SKILL.md) — `notifications.on("sending" | "sent" | "failed" | "skipped", …)`.
262
+ ---
263
+ name: define-notification
264
+ 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`.'
265
+ ---
266
+
267
+ # `defineNotification` — reusable multi-channel notification
268
+
269
+ The reusable pattern. Define a notification once with `type` + `via` + a renderer per channel; fire it anywhere with `.send` / `.queue` / `.only`.
270
+
271
+ ## Shape
272
+
273
+ ```ts
274
+ defineNotification<Data>({
275
+ type: string, // REQUIRED — gate key + database default
276
+ via: ChannelName[] | (data, to) => ChannelName[], // static array OR per-recipient callback
277
+ mail?: (data, to, ctx) => MailPayload,
278
+ database?: (data, to, ctx) => Omit<DatabasePayload, "type">, // `type` defaulted from `def.type`
279
+ // ...one renderer per channel listed in `via`
280
+ });
281
+ ```
282
+
283
+ Returns `{ send, queue, only }`:
284
+
285
+ | Method | Signature | What it does |
286
+ |---|---|---|
287
+ | `.send` | `(to, data, options?)` | Render + dispatch synchronously. |
288
+ | `.queue` | `(to, data, options?)` | Render + enqueue. Throws if no queue dispatcher is configured. |
289
+ | `.only(...channels)` | returns `{send, queue, only}` | Restrict dispatch to a subset of channels. |
290
+
291
+ `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).
292
+
293
+ ## Static `via`
294
+
295
+ ```ts
296
+ export const welcome = defineNotification<{ name: string }>({
297
+ type: "welcome",
298
+ via: ["mail"],
299
+ mail: ({ name }) => ({ subject: "Welcome", html: `<p>Hi ${name}</p>` }),
300
+ });
301
+
302
+ await welcome.send(user, { name: "Hasan" });
303
+ ```
304
+
305
+ ## Dynamic `via` per recipient
306
+
307
+ ```ts
308
+ export const orderShipped = defineNotification<{ order: Order }>({
309
+ type: "order.shipped",
310
+ via: (_data, to) =>
311
+ to.get("telegram_chat_id")
312
+ ? ["database", "telegram"]
313
+ : ["database", "mail"],
314
+ database: ({ order }) => ({ title: `Order #${order.number} shipped` }),
315
+ mail: ({ order }, to) => ({
316
+ subject: `Order #${order.number} shipped`,
317
+ html: `<p>Hi ${to.get("name")}, on the way.</p>`,
318
+ }),
319
+ // telegram: ... (Phase 2 — bridges)
320
+ });
321
+ ```
322
+
323
+ ## The renderer's 3rd arg — `RenderContext`
324
+
325
+ ```ts
326
+ mail: ({ campaignId }, to, { locale = "en", meta }) => ({
327
+ subject: subjectsByLocale[locale],
328
+ html: htmlByLocale[locale],
329
+ }),
330
+
331
+ // usage:
332
+ await marketing.queue(user, payload, {
333
+ locale: user.get("locale"),
334
+ meta: { campaignId, source: "blast" },
335
+ });
336
+ ```
337
+
338
+ `ctx` carries `locale` + `meta` from `SendOptions`. `meta` flows through to every observability event too.
339
+
340
+ ## Database channel — `type` is defaulted
341
+
342
+ The `database` renderer may OMIT `type` — the dispatcher injects `def.type` automatically. This keeps the notification type in ONE place.
343
+
344
+ ```ts
345
+ defineNotification({
346
+ type: "order.shipped", // ← single source of truth
347
+ via: ["database"],
348
+ database: () => ({ title: "Shipped" }), // type omitted; dispatcher injects "order.shipped"
349
+ });
350
+ ```
351
+
352
+ ## Fan-out
353
+
354
+ ```ts
355
+ await orderShipped.send([buyer, salesRep], { order });
356
+ // One render+dispatch per recipient. Database does N inserts (Phase 0);
357
+ // Phase 1 ships a single bulk insert via `Channel.sendMany?`.
358
+ ```
359
+
360
+ ## `.only(...)` — restrict channels
361
+
362
+ ```ts
363
+ await orderShipped.only("mail").send(user, { order });
364
+ // `via` is filtered to just "mail"; other channels are skipped.
365
+ ```
366
+
367
+ ## Queue (Phase 2)
368
+
369
+ ```ts
370
+ await orderShipped.queue(user, { order }, { delay: "10m" });
371
+ // Throws NoQueueDispatcherError if no `queue` is configured in config/notifications.ts.
372
+ ```
373
+
374
+ ## SendOptions
375
+
376
+ | Field | Effect |
377
+ |---|---|
378
+ | `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. |
379
+ | `locale` | Passed to renderers via `RenderContext.locale`. |
380
+ | `meta` | Passed to renderers + included in every observability event. |
381
+ | `idempotencyKey` | Injected into the database payload (dedupe via unique index). |
382
+ | `force` | Bypass `PreferenceProvider`. Does NOT bypass `RateLimiter`. |
383
+ | `type` | Ignored by `defineNotification` — see `send-ad-hoc` for ad-hoc gating. |
384
+
385
+ ## Failure isolation
386
+
387
+ 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.
388
+
389
+ ## See also
390
+
391
+ - [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — package front door.
392
+ - [`send-ad-hoc/SKILL.md`](../send-ad-hoc/SKILL.md) — `notify.<channel>` per-channel shorthand.
393
+ - [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) — wire channels + preferences + rate-limit + queue.
394
+ - [`observe-notifications/SKILL.md`](../observe-notifications/SKILL.md) — `notifications.on("sending" | "sent" | "failed" | "skipped", …)`.
395
395
 
396
396
 
397
397
  ## notifications-basics `@warlock.js/notifications/notifications-basics/SKILL.md`
@@ -498,534 +498,544 @@ await inApp.markAsRead(user, "ntf_123");
498
498
 
499
499
  ## observe-notifications `@warlock.js/notifications/observe-notifications/SKILL.md`
500
500
 
501
- ---
502
- name: observe-notifications
503
- 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`.'
504
- ---
505
-
506
- # Observe notifications
507
-
508
- Every dispatch emits a `sending` event then a terminal `sent` / `failed` — or a `skipped` if a gate dropped it — per (channel, recipient).
509
-
510
- ```ts
511
- import { notifications } from "@warlock.js/notifications";
512
-
513
- // latency + delivery, keyed by recipient
514
- notifications.on("sent", ({ channel, notifiable, durationMs, options }) =>
515
- metrics.timing(`notif.${channel}.sent`, durationMs, { userId: notifiable?.id, ...options.meta }));
516
-
517
- notifications.on("failed", ({ channel, notifiable, error }) =>
518
- log.error(`notif.${channel}`, error, { userId: notifiable?.id }));
519
-
520
- notifications.on("skipped", ({ channel, reason }) =>
521
- metrics.inc(`notif.${channel}.skipped.${reason}`));
522
-
523
- // pair `sending` → terminal by dispatchId — open a span / arm a hung-send watchdog
524
- notifications.on("sending", ({ dispatchId, channel, notifiable }) =>
525
- tracer.open(dispatchId, { channel, userId: notifiable?.id }));
526
- ```
527
-
528
- ## Events
529
-
530
- | Event | Fires when | Payload |
531
- |---|---|---|
532
- | `sending` | Gates passed; about to hit the transport (sync) / enqueue (queue) | `{ dispatchId, channel, notifiable?, payload, options }` |
533
- | `sent` | Channel dispatched OR job enqueued | `{ dispatchId, channel, notifiable?, payload, options, durationMs }` |
534
- | `failed` | `channel.send` (or `queue.dispatch`) threw | `{ dispatchId, channel, notifiable?, payload, error, options, durationMs }` |
535
- | `skipped` | A pre-send gate dropped this channel | `{ dispatchId, channel, notifiable?, reason, options }` — `reason: "preference" \| "rate-limit"` |
536
-
537
- `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.
538
-
539
- `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.
540
-
541
- `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.
542
-
543
- ## Unsubscribe
544
-
545
- ```ts
546
- const off = notifications.on("sent", handler);
547
- // later
548
- off();
549
-
550
- // or
551
- notifications.off("sent", handler);
552
- ```
553
-
554
- ## Fan-out emits N events
555
-
556
- `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.
557
-
558
- ## Handler isolation
559
-
560
- 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.
561
-
562
- ## See also
563
-
564
- - [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — front door + mental model.
565
- - [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) — `preferences` / `rateLimit` slots that emit `skipped`.
566
- - [`send-ad-hoc/SKILL.md`](../send-ad-hoc/SKILL.md) — when `notifiable` is undefined in events.
501
+ ---
502
+ name: observe-notifications
503
+ 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`.'
504
+ ---
505
+
506
+ # Observe notifications
507
+
508
+ Every dispatch emits a `sending` event then a terminal `sent` / `failed` — or a `skipped` if a gate dropped it — per (channel, recipient).
509
+
510
+ ```ts
511
+ import { notifications } from "@warlock.js/notifications";
512
+
513
+ // latency + delivery, keyed by recipient
514
+ notifications.on("sent", ({ channel, notifiable, durationMs, options }) =>
515
+ metrics.timing(`notif.${channel}.sent`, durationMs, { userId: notifiable?.id, ...options.meta }));
516
+
517
+ notifications.on("failed", ({ channel, notifiable, error }) =>
518
+ log.error(`notif.${channel}`, error, { userId: notifiable?.id }));
519
+
520
+ notifications.on("skipped", ({ channel, reason }) =>
521
+ metrics.inc(`notif.${channel}.skipped.${reason}`));
522
+
523
+ // pair `sending` → terminal by dispatchId — open a span / arm a hung-send watchdog
524
+ notifications.on("sending", ({ dispatchId, channel, notifiable }) =>
525
+ tracer.open(dispatchId, { channel, userId: notifiable?.id }));
526
+ ```
527
+
528
+ ## Events
529
+
530
+ | Event | Fires when | Payload |
531
+ |---|---|---|
532
+ | `sending` | Gates passed; about to hit the transport (sync) / enqueue (queue) | `{ dispatchId, channel, notifiable?, payload, options }` |
533
+ | `sent` | Channel dispatched OR job enqueued | `{ dispatchId, channel, notifiable?, payload, options, durationMs }` |
534
+ | `failed` | `channel.send` (or `queue.dispatch`) threw | `{ dispatchId, channel, notifiable?, payload, error, options, durationMs }` |
535
+ | `skipped` | A pre-send gate dropped this channel | `{ dispatchId, channel, notifiable?, reason, options }` — `reason: "preference" \| "rate-limit"` |
536
+
537
+ `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.
538
+
539
+ `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.
540
+
541
+ `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.
542
+
543
+ ## Unsubscribe
544
+
545
+ ```ts
546
+ const off = notifications.on("sent", handler);
547
+ // later
548
+ off();
549
+
550
+ // or
551
+ notifications.off("sent", handler);
552
+ ```
553
+
554
+ ## Fan-out emits N events
555
+
556
+ `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.
557
+
558
+ ## Handler isolation
559
+
560
+ 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.
561
+
562
+ ## See also
563
+
564
+ - [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — front door + mental model.
565
+ - [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) — `preferences` / `rateLimit` slots that emit `skipped`.
566
+ - [`send-ad-hoc/SKILL.md`](../send-ad-hoc/SKILL.md) — when `notifiable` is undefined in events.
567
567
 
568
568
 
569
569
  ## queue-notifications `@warlock.js/notifications/queue-notifications/SKILL.md`
570
570
 
571
- ---
572
- name: queue-notifications
573
- 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/*."
574
- ---
575
-
576
- # Queue notifications (async)
577
-
578
- Move slow channels (SMTP, HTTP) off the request path. `.queue()` renders the
579
- payload and resolves the route before passing a serializable job to a dispatcher.
580
-
581
- ## Wire the herald dispatcher
582
-
583
- ```ts title="src/config/notifications.ts"
584
- import {
585
- type NotificationConfig,
586
- heraldQueue,
587
- inApp,
588
- mailChannel,
589
- } from "@warlock.js/notifications";
590
- import { Notification } from "app/notifications/notification.model";
591
-
592
- const config: NotificationConfig = {
593
- channels: { mail: mailChannel(), database: inApp.configure({ model: Notification }) },
594
- queue: heraldQueue(),
595
- };
596
-
597
- export default config;
598
- ```
599
-
600
- `heraldQueue({ channel?, broker? })` publishes to `"notifications.dispatch"`
601
- on the default broker unless overridden. It lazy-imports `@warlock.js/herald`;
602
- if that package is missing, the first `.queue()` throws an install message.
603
-
604
- ## Use BullMQ for delayed or retried delivery
605
-
606
- Use the `bullmq` driver instead of the herald dispatcher — it ships inside
607
- `@warlock.js/notifications` itself and lazy-loads `@warlock.js/queue` (an
608
- OPTIONAL peer) the first time `.queue()` runs:
609
-
610
- ```ts title="src/config/notifications.ts"
611
- import { type NotificationConfig, bullmqQueue, mailChannel } from "@warlock.js/notifications";
612
-
613
- const config: NotificationConfig = {
614
- channels: { mail: mailChannel() },
615
- queue: bullmqQueue({ attempts: 3, backoff: { type: "exponential", delay: 5000 } }),
616
- };
617
-
618
- export default config;
619
- ```
620
-
621
- The BullMQ driver honours `SendOptions.delay`: numeric delays are seconds and
622
- string delays use the normal duration syntax, such as `"10m"`. It also supports
623
- BullMQ retries through `attempts` and `backoff`. If `@warlock.js/queue` isn't
624
- installed, the first `.queue()` throws `QueuePackageNotInstalledError` naming
625
- `warlock add queue`.
626
-
627
- > **Removed (5.15.0):** `queueNotificationDispatcher` from
628
- > `@warlock.js/queue/notifications` — deprecated in 5.14, removed this
629
- > release, along with the `@warlock.js/notifications` peer dependency it was
630
- > the only reason `@warlock.js/queue` carried. Use `bullmqQueue` above — same
631
- > option names (`queue`, `attempts`, `backoff`).
632
-
633
- ## Run the herald worker
634
-
635
- In a worker entrypoint (or the web process), after the config and herald broker
636
- are up:
637
-
638
- ```ts
639
- import { startNotificationsWorker } from "@warlock.js/notifications";
640
-
641
- await startNotificationsWorker();
642
- ```
643
-
644
- The worker looks up the channel by name and runs
645
- `channel.send({ payload, route, options })`. The worker needs no recipient model
646
- because rendering happened at enqueue time.
647
-
648
- ## Send
649
-
650
- ```ts
651
- await orderShipped.queue(user, { order });
652
- await orderShipped.queue(buyers, { order }, { delay: "10m" });
653
- await notify.mail(user, payload, {/* synchronous - notify.* does not queue */});
654
- ```
655
-
656
- `.queue()` renders per recipient and enqueues one job per recipient. Without a
657
- configured dispatcher, it rejects `NoQueueDispatcherError`.
658
-
659
- ## Idempotency
660
-
661
- Pass `idempotencyKey` so a redelivered or retried job does not create a duplicate
662
- in-app row. `createFor` finds or creates the row by that key.
663
-
664
- ```ts
665
- await orderShipped.queue(user, { order }, { idempotencyKey: `ship:${order.id}` });
666
- ```
667
-
668
- ## Current limits
669
-
670
- - With the herald backend, `delay` is carried on the job but not honoured; the
671
- worker delivers immediately. Use the BullMQ adapter for delayed delivery.
672
- - Herald has no retry or dead-letter handling yet; failed `channel.send` calls
673
- are logged and acknowledged.
674
- - Rate-limit budget is consumed at enqueue time for queued sends.
675
-
676
- ## See also
677
-
678
- - [`define-notification/SKILL.md`](../define-notification/SKILL.md) - `.queue()` on a defined notification.
679
- - [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) - the `queue` config slot.
680
- - [`@warlock.js/herald/herald-basics/SKILL.md`](../../../herald/skills/herald-basics/SKILL.md) - connecting the broker.
571
+ ---
572
+ name: queue-notifications
573
+ 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/*."
574
+ ---
575
+
576
+ # Queue notifications (async)
577
+
578
+ Move slow channels (SMTP, HTTP) off the request path. `.queue()` renders the
579
+ payload and resolves the route before passing a serializable job to a dispatcher.
580
+
581
+ ## Wire the herald dispatcher
582
+
583
+ ```ts title="src/config/notifications.ts"
584
+ import {
585
+ type NotificationConfig,
586
+ heraldQueue,
587
+ inApp,
588
+ mailChannel,
589
+ } from "@warlock.js/notifications";
590
+ import { Notification } from "app/notifications/notification.model";
591
+
592
+ const config: NotificationConfig = {
593
+ channels: { mail: mailChannel(), database: inApp.configure({ model: Notification }) },
594
+ queue: heraldQueue(),
595
+ };
596
+
597
+ export default config;
598
+ ```
599
+
600
+ `heraldQueue({ channel?, broker? })` publishes to `"notifications.dispatch"`
601
+ on the default broker unless overridden. It lazy-imports `@warlock.js/herald`;
602
+ if that package is missing, the first `.queue()` throws an install message.
603
+
604
+ ## Use BullMQ for delayed or retried delivery
605
+
606
+ Use the `bullmq` driver instead of the herald dispatcher — it ships inside
607
+ `@warlock.js/notifications` itself and lazy-loads `@warlock.js/queue` (an
608
+ OPTIONAL peer) the first time `.queue()` runs:
609
+
610
+ ```ts title="src/config/notifications.ts"
611
+ import { type NotificationConfig, bullmqQueue, mailChannel } from "@warlock.js/notifications";
612
+
613
+ const config: NotificationConfig = {
614
+ channels: { mail: mailChannel() },
615
+ queue: bullmqQueue({ attempts: 3, backoff: { type: "exponential", delay: 5000 } }),
616
+ };
617
+
618
+ export default config;
619
+ ```
620
+
621
+ The BullMQ driver honours `SendOptions.delay`: numeric delays are seconds and
622
+ string delays use the normal duration syntax, such as `"10m"`. It also supports
623
+ BullMQ retries through `attempts` and `backoff`. If `@warlock.js/queue` isn't
624
+ installed, the first `.queue()` throws `QueuePackageNotInstalledError` naming
625
+ `warlock add queue`.
626
+
627
+ > **Removed (5.15.0):** `queueNotificationDispatcher` from
628
+ > `@warlock.js/queue/notifications` — deprecated in 5.14, removed this
629
+ > release, along with the `@warlock.js/notifications` peer dependency it was
630
+ > the only reason `@warlock.js/queue` carried. Use `bullmqQueue` above — same
631
+ > option names (`queue`, `attempts`, `backoff`).
632
+
633
+ ## Run the herald worker
634
+
635
+ In a worker entrypoint (or the web process), after the config and herald broker
636
+ are up:
637
+
638
+ ```ts
639
+ import { startNotificationsWorker } from "@warlock.js/notifications";
640
+
641
+ await startNotificationsWorker();
642
+ ```
643
+
644
+ The worker looks up the channel by name and runs
645
+ `channel.send({ payload, route, options })`. The worker needs no recipient model
646
+ because rendering happened at enqueue time.
647
+
648
+ ## Send
649
+
650
+ ```ts
651
+ await orderShipped.queue(user, { order });
652
+ await orderShipped.queue(buyers, { order }, { delay: "10m" });
653
+ await notify.mail(user, payload, {/* synchronous - notify.* does not queue */});
654
+ ```
655
+
656
+ `.queue()` renders per recipient and enqueues one job per recipient. Without a
657
+ configured dispatcher, it rejects `NoQueueDispatcherError`.
658
+
659
+ ## Idempotency
660
+
661
+ Pass `idempotencyKey` so a redelivered or retried job does not create a duplicate
662
+ in-app row. `createFor` finds or creates the row by that key.
663
+
664
+ ```ts
665
+ await orderShipped.queue(user, { order }, { idempotencyKey: `ship:${order.id}` });
666
+ ```
667
+
668
+ ## Current limits
669
+
670
+ - With the herald backend, `delay` is carried on the job but not honoured; the
671
+ worker delivers immediately. Use the BullMQ adapter for delayed delivery.
672
+ - Herald has no retry or dead-letter handling yet; failed `channel.send` calls
673
+ are logged and acknowledged.
674
+ - Rate-limit budget is consumed at enqueue time for queued sends.
675
+
676
+ ## See also
677
+
678
+ - [`define-notification/SKILL.md`](../define-notification/SKILL.md) - `.queue()` on a defined notification.
679
+ - [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) - the `queue` config slot.
680
+ - [`@warlock.js/herald/herald-basics/SKILL.md`](../../../herald/skills/herald-basics/SKILL.md) - connecting the broker.
681
681
 
682
682
 
683
683
  ## send-ad-hoc `@warlock.js/notifications/send-ad-hoc/SKILL.md`
684
684
 
685
- ---
686
- name: send-ad-hoc
687
- description: 'Ad-hoc per-channel send via the `notify.<channel>(to, payload, options?)` Proxy facade. Works for any channel registered in `NotificationChannels` (built-in or custom). `to` accepts a `Notifiable` model OR a raw route string (`"x@y.com"`); `payload` is typed per channel via the registry; `options` is the standard `SendOptions`. `notify.channel(name).send(...)` is the runtime escape when the channel name is not a compile-time literal. Type-based preference gating applies when a type is known — explicit via `options.type` OR auto-detected from a database payload''s `type` field. For multi-channel sends use `defineNotification` instead — there is no inline `notify(to, { via, ... })` form on purpose. Triggers: `notify.<channel>`, `notify.channel(name)`, `notify.mail`, `notify.database`, "send a one-off notification", "ad-hoc notification", "send an email without defining a notification", "raw email target", "dynamic channel name"; typical import `import { notify } from "@warlock.js/notifications"`. Skip: reusable multi-channel notifications — `@warlock.js/notifications/define-notification/SKILL.md`; configuring channels — `@warlock.js/notifications/configure-notifications/SKILL.md`.'
688
- ---
689
-
690
- # `notify.<channel>` — ad-hoc, per-channel sends
691
-
692
- When you don't need a reusable definition — fire one channel, one recipient.
693
-
694
- ```ts
695
- import { notify } from "@warlock.js/notifications";
696
-
697
- await notify.mail(user, { subject: "Welcome", html: "<p>Hi!</p>" });
698
- await notify.database(user, { type: "welcome", title: "Welcome!" });
699
- ```
700
-
701
- For **multi-channel** sends, use [`defineNotification`](../define-notification/SKILL.md) — that's the reusable pattern and the only mental model for "send through several channels at once" (no inline multi-channel overload).
702
-
703
- ## Shape
704
-
705
- ```ts
706
- notify.<channel>(
707
- to: Notifiable | string, // model OR raw route
708
- payload: NotificationChannels[channel], // typed per channel
709
- options?: SendOptions,
710
- ): Promise<void>
711
- ```
712
-
713
- ## Raw target — no model
714
-
715
- Pass a string instead of a model to use the string as the route directly:
716
-
717
- ```ts
718
- await notify.mail("guest@example.com", { subject: "Receipt", html: "<p>…</p>" });
719
- await notify.whatsapp("+201234567890", { body: "Promo: 20% off!" }); // Phase 2
720
- ```
721
-
722
- When the recipient isn't a model, `PreferenceProvider` and `RateLimiter` are skipped — there's no `Notifiable` to consult.
723
-
724
- ## Runtime-dynamic channel name
725
-
726
- When the channel isn't a literal, use `notify.channel(name).send(...)`:
727
-
728
- ```ts
729
- const channelName = await chooseChannelForUser(user);
730
- await notify.channel(channelName).send(user, payload);
731
- ```
732
-
733
- This is the only path with `unknown`-typed payloads — prefer the literal `notify.<channel>` when you can. `to` is still what decides the recipient: even for the `database` channel, a `recipientId`-shaped key on `payload` (or the model's physical recipient column name) is stripped before the row is built, so an untyped payload assembled from request JSON can't redirect the write to another recipient — see [`use-in-app/SKILL.md`](../use-in-app/SKILL.md#createfor-write-side--no-idor-either-since-4150).
734
-
735
- ## Opt into preference/rate-limit gating
736
-
737
- Ad-hoc sends bypass the preference gate unless a notification `type` is known. Two ways to provide it:
738
-
739
- ```ts
740
- // 1. Explicit — wins regardless of payload.
741
- await notify.mail(user, payload, { type: "marketing.weekly" });
742
-
743
- // 2. Auto-detected — database payloads carry their own `type` field.
744
- await notify.database(user, { type: "welcome", title: "Welcome!" });
745
- // ^^^^^^^^^^^^^^^ used for gating
746
- ```
747
-
748
- `SendOptions.force === true` bypasses preferences. `RateLimiter` is consulted whenever a type is known (regardless of `force`).
749
-
750
- ## Custom channels
751
-
752
- After registering a custom channel (see [`define-channel`](../define-channel/SKILL.md)), it's first-class:
753
-
754
- ```ts
755
- await notify.discord(user, { content: "Build finished 🎉" });
756
- await notify.slack(staff, { text: "Deploy starting" });
757
- ```
758
-
759
- ## SendOptions (3rd arg)
760
-
761
- | Field | Effect |
762
- |---|---|
763
- | `delay` | Reserved for `.queue` paths; not used by `notify.<channel>` (sync). |
764
- | `locale` | Forwarded to the channel's `send({ options })`. |
765
- | `meta` | Forwarded to the channel + included in every observability event. |
766
- | `idempotencyKey` | Injected into the database payload (dedupe). |
767
- | `force` | Bypass `PreferenceProvider`. Does NOT bypass `RateLimiter`. |
768
- | `type` | Notification type for gating (ad-hoc only). |
769
-
770
- ## Failure surfaces
771
-
772
- `notify.<channel>` rejects on configuration errors (`ChannelNotFoundError`) — those are loud, immediate, and don't fire a `failed` event. Channel-level send failures rethrow AND fire a `failed` event with the channel + error.
773
-
774
- ## See also
775
-
776
- - [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — front door + mental model.
777
- - [`define-notification/SKILL.md`](../define-notification/SKILL.md) — reusable multi-channel.
778
- - [`define-channel/SKILL.md`](../define-channel/SKILL.md) — register a custom channel.
779
- - [`observe-notifications/SKILL.md`](../observe-notifications/SKILL.md) — `sending` / `sent` / `failed` / `skipped` events.
685
+ ---
686
+ name: send-ad-hoc
687
+ description: 'Ad-hoc per-channel send via the `notify.<channel>(to, payload, options?)` Proxy facade. Works for any channel registered in `NotificationChannels` (built-in or custom). `to` accepts a `Notifiable` model OR a raw route string (`"x@y.com"`); `payload` is typed per channel via the registry; `options` is the standard `SendOptions`. `notify.channel(name).send(...)` is the runtime escape when the channel name is not a compile-time literal. Type-based preference gating applies when a type is known — explicit via `options.type` OR auto-detected from a database payload''s `type` field. For multi-channel sends use `defineNotification` instead — there is no inline `notify(to, { via, ... })` form on purpose. Triggers: `notify.<channel>`, `notify.channel(name)`, `notify.mail`, `notify.database`, "send a one-off notification", "ad-hoc notification", "send an email without defining a notification", "raw email target", "dynamic channel name"; typical import `import { notify } from "@warlock.js/notifications"`. Skip: reusable multi-channel notifications — `@warlock.js/notifications/define-notification/SKILL.md`; configuring channels — `@warlock.js/notifications/configure-notifications/SKILL.md`.'
688
+ ---
689
+
690
+ # `notify.<channel>` — ad-hoc, per-channel sends
691
+
692
+ When you don't need a reusable definition — fire one channel, one recipient.
693
+
694
+ ```ts
695
+ import { notify } from "@warlock.js/notifications";
696
+
697
+ await notify.mail(user, { subject: "Welcome", html: "<p>Hi!</p>" });
698
+ await notify.database(user, { type: "welcome", title: "Welcome!" });
699
+ ```
700
+
701
+ For **multi-channel** sends, use [`defineNotification`](../define-notification/SKILL.md) — that's the reusable pattern and the only mental model for "send through several channels at once" (no inline multi-channel overload).
702
+
703
+ ## Shape
704
+
705
+ ```ts
706
+ notify.<channel>(
707
+ to: Notifiable | string, // model OR raw route
708
+ payload: NotificationChannels[channel], // typed per channel
709
+ options?: SendOptions,
710
+ ): Promise<void>
711
+ ```
712
+
713
+ ## Raw target — no model
714
+
715
+ Pass a string instead of a model to use the string as the route directly:
716
+
717
+ ```ts
718
+ await notify.mail("guest@example.com", { subject: "Receipt", html: "<p>…</p>" });
719
+ await notify.whatsapp("+201234567890", { body: "Promo: 20% off!" }); // Phase 2
720
+ ```
721
+
722
+ When the recipient isn't a model, `PreferenceProvider` and `RateLimiter` are skipped — there's no `Notifiable` to consult.
723
+
724
+ ## Runtime-dynamic channel name
725
+
726
+ When the channel isn't a literal, use `notify.channel(name).send(...)`:
727
+
728
+ ```ts
729
+ const channelName = await chooseChannelForUser(user);
730
+ await notify.channel(channelName).send(user, payload);
731
+ ```
732
+
733
+ This is the only path with `unknown`-typed payloads — prefer the literal `notify.<channel>` when you can. `to` is still what decides the recipient: even for the `database` channel, a `recipientId`-shaped key on `payload` (or the model's physical recipient column name) is stripped before the row is built, so an untyped payload assembled from request JSON can't redirect the write to another recipient — see [`use-in-app/SKILL.md`](../use-in-app/SKILL.md#createfor-write-side--no-idor-either-since-4150).
734
+
735
+ ## Opt into preference/rate-limit gating
736
+
737
+ Ad-hoc sends bypass the preference gate unless a notification `type` is known. Two ways to provide it:
738
+
739
+ ```ts
740
+ // 1. Explicit — wins regardless of payload.
741
+ await notify.mail(user, payload, { type: "marketing.weekly" });
742
+
743
+ // 2. Auto-detected — database payloads carry their own `type` field.
744
+ await notify.database(user, { type: "welcome", title: "Welcome!" });
745
+ // ^^^^^^^^^^^^^^^ used for gating
746
+ ```
747
+
748
+ `SendOptions.force === true` bypasses preferences. `RateLimiter` is consulted whenever a type is known (regardless of `force`).
749
+
750
+ ## Custom channels
751
+
752
+ After registering a custom channel (see [`define-channel`](../define-channel/SKILL.md)), it's first-class:
753
+
754
+ ```ts
755
+ await notify.discord(user, { content: "Build finished 🎉" });
756
+ await notify.slack(staff, { text: "Deploy starting" });
757
+ ```
758
+
759
+ ## SendOptions (3rd arg)
760
+
761
+ | Field | Effect |
762
+ |---|---|
763
+ | `delay` | Reserved for `.queue` paths; not used by `notify.<channel>` (sync). |
764
+ | `locale` | Forwarded to the channel's `send({ options })`. |
765
+ | `meta` | Forwarded to the channel + included in every observability event. |
766
+ | `idempotencyKey` | Injected into the database payload (dedupe). |
767
+ | `force` | Bypass `PreferenceProvider`. Does NOT bypass `RateLimiter`. |
768
+ | `type` | Notification type for gating (ad-hoc only). |
769
+
770
+ ## Failure surfaces
771
+
772
+ `notify.<channel>` rejects on configuration errors (`ChannelNotFoundError`) — those are loud, immediate, and don't fire a `failed` event. Channel-level send failures rethrow AND fire a `failed` event with the channel + error.
773
+
774
+ ## See also
775
+
776
+ - [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — front door + mental model.
777
+ - [`define-notification/SKILL.md`](../define-notification/SKILL.md) — reusable multi-channel.
778
+ - [`define-channel/SKILL.md`](../define-channel/SKILL.md) — register a custom channel.
779
+ - [`observe-notifications/SKILL.md`](../observe-notifications/SKILL.md) — `sending` / `sent` / `failed` / `skipped` events.
780
780
 
781
781
 
782
782
  ## use-in-app `@warlock.js/notifications/use-in-app/SKILL.md`
783
783
 
784
- ---
785
- name: use-in-app
786
- description: 'Use the `inApp` facade for the in-app/database channel''s READ side: `inApp.configure({ model: Notification })` or `{ repository: myRepo }` binds the store AND returns the channel; `inApp.list / listUnread / countUnread / markAsRead / markAsUnread` are RECIPIENT-SCOPED by construction (no IDOR — `markAsRead(user, id)` cannot flip another user''s row even with a foreign id). `countUnread` uses `RepositoryManager.countCached` for the badge fast-path. Triggers: `inApp.configure`, `inApp.list`, `inApp.listUnread`, `inApp.countUnread`, `inApp.markAsRead`, `inApp.markAsUnread`, `BaseNotificationsRepository`, `DatabaseNotification`, `NotificationsFilter`, "in-app notifications", "mark notification as read", "unread count", "notification badge", "IDOR-safe markAsRead"; typical import `import { inApp } from "@warlock.js/notifications"`. Skip: writing notifications (use `defineNotification` or `notify.database`) — `@warlock.js/notifications/define-notification/SKILL.md`; the migration columns — `@warlock.js/notifications/write-notification-migration/SKILL.md`; wiring it into config — `@warlock.js/notifications/configure-notifications/SKILL.md`.'
787
- ---
788
-
789
- # `inApp` — in-app notification read API
790
-
791
- The in-app database channel has two sides:
792
-
793
- - **Write side** — `notify.database(user, {...})` and the database channel inside `defineNotification` create rows.
794
- - **Read side** — `inApp.list` / `listUnread` / `countUnread` / `markAsRead` / `markAsUnread` query and mutate.
795
-
796
- `inApp.configure({ model | repository })` binds the same repository for both sides, so cache invalidation just works.
797
-
798
- ## Configure (once, in `config/notifications.ts`)
799
-
800
- ```ts
801
- // 90% case — pass the model class; default repo built internally.
802
- database: inApp.configure({ model: Notification }),
803
-
804
- // 10% case — pass a custom repo.
805
- database: inApp.configure({ repository: notificationsRepository }),
806
- ```
807
-
808
- ## Reads
809
-
810
- ```ts
811
- import { inApp } from "@warlock.js/notifications";
812
-
813
- // all rows for this recipient (paginated via the repo's standard `list`)
814
- const all = await inApp.list(user);
815
-
816
- // unread-only — pass `filter` to add type/etc. constraints
817
- const unread = await inApp.listUnread(user, { type: "order.shipped" });
818
-
819
- // cached unread count — backs the badge
820
- const badge = await inApp.countUnread(user);
821
-
822
- // one notification — for a detail view (recipient-scoped)
823
- const one = await inApp.find(user, "ntf_123");
824
- ```
825
-
826
- All read methods accept a cascade `Notifiable` model OR a raw `id` (string | number) — convenient when you only have an id without fetching the user.
827
-
828
- ```ts
829
- await inApp.list("user_42");
830
- await inApp.countUnread(123);
831
- ```
832
-
833
- ## Writes (recipient-scoped → no IDOR)
834
-
835
- ```ts
836
- // mark ALL unread rows for this recipient as read
837
- await inApp.markAsRead(user);
838
-
839
- // mark ONE specific row — STILL scoped to this recipient
840
- await inApp.markAsRead(user, "ntf_123");
841
-
842
- // inverse
843
- await inApp.markAsUnread(user, "ntf_123");
844
-
845
- // delete / dismiss — one row, or clear all for this recipient
846
- await inApp.dismiss(user, "ntf_123");
847
- await inApp.dismiss(user);
848
- ```
849
-
850
- The recipient-id is forced into the `where` clause. A controller that receives an `id` from the request and naively calls `inApp.markAsRead(currentUser, id)` cannot accidentally flip another user's row — even if the request contains a forged id. The mutation will simply update 0 rows.
851
-
852
- ## Behind the scenes — `columnMap` drives everything
853
-
854
- The model declares its physical columns ONCE via `static columnMap`. The
855
- shipped `DatabaseNotification` accessors and `BaseNotificationsRepository`
856
- (read filter + write mapping) both derive from it, so they can never drift:
857
-
858
- ```ts
859
- abstract class DatabaseNotification extends Model implements NotificationContract {
860
- public static columnMap: NotificationColumnMap = {};
861
- // recipientId / tenantId / isRead / readAt / markRead all read `columnMap`.
862
- }
863
-
864
- class BaseNotificationsRepository extends RepositoryManager {
865
- // constructor resolves the model's columnMap → builds `filterBy` +
866
- // createFor / markRead / markUnread / deleteFor from it.
867
- public createFor(recipientId, input, tenantId?) { /* one insert, starts unread */ }
868
- }
869
- ```
870
-
871
- ### `createFor` write side — no IDOR either (since 4.15.0)
872
-
873
- `recipientId` is always the trusted argument, applied **after** the payload — `createFor`/`createManyFor` strip server-owned keys (`id`, `recipientId`, `tenant`, `readAt`, `isRead`, and their resolved `columnMap` physical column names) off `input` before merging. Before this, a caller passing an untyped payload straight through (request JSON, a channel's `send` args) could smuggle its own `recipientId` — or the raw physical column name — and write the notification into someone else's inbox. `type` / `title` / `body` / `payload` / `idempotencyKey` are untouched; you don't need to sanitize `input` yourself.
874
-
875
- ### `columnMap` — map logical roles → your columns
876
-
877
- ```ts
878
- export type NotificationColumnMap = {
879
- recipient?: string; // recipient FK. Default "user_id"
880
- tenant?: string; // multi-tenant scope, written from the recipient. Omit → single-tenant
881
- readAt?: string; // read-timestamp column
882
- isRead?: string; // read-flag column (indexed)
883
- };
884
- ```
885
-
886
- **Read-state is chosen by which keys are PRESENT:**
887
-
888
- - `readAt` only → unread = `read_at IS NULL`; marking read stamps it.
889
- - `isRead` only → unread = `is_read = false`; no timestamp.
890
- - both → `is_read` is the indexed filter flag, `read_at` records WHEN.
891
-
892
- Declare neither and you get the `readAt`-only default — so a model can omit
893
- `columnMap` entirely. `unread` is the mode-agnostic read filter on `inApp` /
894
- the repo; there is no `isRead` filter key to remember.
895
-
896
- ## Your model — declare `table`, `schema`, and `columnMap`
897
-
898
- ```ts title="src/app/notifications/notification.model.ts"
899
- import { RegisterModel } from "@warlock.js/cascade";
900
- import { v } from "@warlock.js/seal";
901
- import { DatabaseNotification, type NotificationColumnMap } from "@warlock.js/notifications";
902
-
903
- // Mirrors the migration columns; cascade validates + casts every write.
904
- const notificationSchema = v.object({
905
- user_id: v.string(),
906
- type: v.string(),
907
- title: v.string(),
908
- body: v.string().nullish(),
909
- payload: v.record(v.any()).nullish(),
910
- read_at: v.date().nullish(),
911
- idempotency_key: v.string().nullish(),
912
- });
913
-
914
- @RegisterModel()
915
- export class Notification extends DatabaseNotification {
916
- public static table = "notifications";
917
- public static schema = notificationSchema;
918
- // read_at-only, single-tenant. Add `tenant: "organization_id"` for multi-tenant,
919
- // swap to `isRead: "is_read"` (or add it) to change the read-state representation.
920
- public static columnMap: NotificationColumnMap = { readAt: "read_at" };
921
- }
922
- ```
923
-
924
- To change columns, edit `columnMap` — the migration (`notificationColumns`), the
925
- repo filter, and the accessors all follow. For a different driver's naming
926
- (e.g. MongoDB camelCase), name the columns in `columnMap`
927
- (`{ recipient: "userId", readAt: "readAt" }`).
928
-
929
- ## Custom repository (advanced, optional)
930
-
931
- `columnMap` already covers column naming. Subclass only for EXTRA query methods:
932
-
933
- ```ts title="src/app/notifications/notifications.repository.ts"
934
- import { BaseNotificationsRepository } from "@warlock.js/notifications";
935
- import { Notification } from "./notification.model";
936
-
937
- export class NotificationsRepository extends BaseNotificationsRepository<Notification> {
938
- public source = Notification; // its columnMap still drives filter + write mapping
939
- // …extra methods specific to your app
940
- }
941
- export const notificationsRepository = new NotificationsRepository();
942
- ```
943
-
944
- Then swap config:
945
-
946
- ```ts
947
- database: inApp.configure({ repository: notificationsRepository }),
948
- ```
949
-
950
- ## See also
951
-
952
- - [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — package front door.
953
- - [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) — wire `inApp.configure` into the config.
954
- - [`write-notification-migration/SKILL.md`](../write-notification-migration/SKILL.md) — `notificationColumns(Notification)`, named from `columnMap`.
955
- - [`define-notification/SKILL.md`](../define-notification/SKILL.md) — create rows via the database channel.
784
+ ---
785
+ name: use-in-app
786
+ description: 'Use the `inApp` facade for the in-app/database channel''s READ side: `inApp.configure({ model: Notification })` or `{ repository: myRepo }` binds the store AND returns the channel; `inApp.list / listUnread / countUnread / markAsRead / markAsUnread` are RECIPIENT-SCOPED by construction (no IDOR — `markAsRead(user, id)` cannot flip another user''s row even with a foreign id). `countUnread` uses `RepositoryManager.countCached` for the badge fast-path. Triggers: `inApp.configure`, `inApp.list`, `inApp.listUnread`, `inApp.countUnread`, `inApp.markAsRead`, `inApp.markAsUnread`, `BaseNotificationsRepository`, `DatabaseNotification`, `NotificationsFilter`, "in-app notifications", "mark notification as read", "unread count", "notification badge", "IDOR-safe markAsRead"; typical import `import { inApp } from "@warlock.js/notifications"`. Skip: writing notifications (use `defineNotification` or `notify.database`) — `@warlock.js/notifications/define-notification/SKILL.md`; the migration columns — `@warlock.js/notifications/write-notification-migration/SKILL.md`; wiring it into config — `@warlock.js/notifications/configure-notifications/SKILL.md`.'
787
+ ---
788
+
789
+ # `inApp` — in-app notification read API
790
+
791
+ The in-app database channel has two sides:
792
+
793
+ - **Write side** — `notify.database(user, {...})` and the database channel inside `defineNotification` create rows.
794
+ - **Read side** — `inApp.list` / `listUnread` / `countUnread` / `markAsRead` / `markAsUnread` query and mutate.
795
+
796
+ `inApp.configure({ model | repository })` binds the same repository for both sides, so cache invalidation just works.
797
+
798
+ ## Configure (once, in `config/notifications.ts`)
799
+
800
+ ```ts
801
+ // 90% case — pass the model class; default repo built internally.
802
+ database: inApp.configure({ model: Notification }),
803
+
804
+ // 10% case — pass a custom repo.
805
+ database: inApp.configure({ repository: notificationsRepository }),
806
+ ```
807
+
808
+ ## Reads
809
+
810
+ ```ts
811
+ import { inApp } from "@warlock.js/notifications";
812
+
813
+ // all rows for this recipient (paginated via the repo's standard `list`)
814
+ const all = await inApp.list(user);
815
+
816
+ // unread-only — pass `filter` to add type/etc. constraints
817
+ const unread = await inApp.listUnread(user, { type: "order.shipped" });
818
+
819
+ // cached unread count — backs the badge
820
+ const badge = await inApp.countUnread(user);
821
+
822
+ // one notification — for a detail view (recipient-scoped)
823
+ const one = await inApp.find(user, "ntf_123");
824
+ ```
825
+
826
+ All read methods accept a cascade `Notifiable` model OR a raw `id` (string | number) — convenient when you only have an id without fetching the user.
827
+
828
+ ```ts
829
+ await inApp.list("user_42");
830
+ await inApp.countUnread(123);
831
+ ```
832
+
833
+ ## Writes (recipient-scoped → no IDOR)
834
+
835
+ ```ts
836
+ // mark ALL unread rows for this recipient as read
837
+ await inApp.markAsRead(user);
838
+
839
+ // mark ONE specific row — STILL scoped to this recipient
840
+ await inApp.markAsRead(user, "ntf_123");
841
+
842
+ // inverse
843
+ await inApp.markAsUnread(user, "ntf_123");
844
+
845
+ // delete / dismiss — one row, or clear all for this recipient
846
+ await inApp.dismiss(user, "ntf_123");
847
+ await inApp.dismiss(user);
848
+ ```
849
+
850
+ The recipient-id is forced into the `where` clause. A controller that receives an `id` from the request and naively calls `inApp.markAsRead(currentUser, id)` cannot accidentally flip another user's row — even if the request contains a forged id. The mutation will simply update 0 rows.
851
+
852
+ ## Behind the scenes — `columnMap` drives everything
853
+
854
+ The model declares its physical columns ONCE via `static columnMap`. The
855
+ shipped `DatabaseNotification` accessors and `BaseNotificationsRepository`
856
+ (read filter + write mapping) both derive from it, so they can never drift:
857
+
858
+ ```ts
859
+ abstract class DatabaseNotification extends Model implements NotificationContract {
860
+ public static columnMap: NotificationColumnMap = {};
861
+ // recipientId / tenantId / isRead / readAt / markRead all read `columnMap`.
862
+ }
863
+
864
+ class BaseNotificationsRepository extends RepositoryManager {
865
+ // constructor resolves the model's columnMap → builds `filterBy` +
866
+ // createFor / markRead / markUnread / deleteFor from it.
867
+ public createFor(recipientId, input, tenantId?) { /* one insert, starts unread */ }
868
+ }
869
+ ```
870
+
871
+ ### `createFor` write side — no IDOR either (since 4.15.0)
872
+
873
+ `recipientId` is always the trusted argument, applied **after** the payload — `createFor`/`createManyFor` strip server-owned keys (`id`, `recipientId`, `tenant`, `readAt`, `isRead`, and their resolved `columnMap` physical column names) off `input` before merging. Before this, a caller passing an untyped payload straight through (request JSON, a channel's `send` args) could smuggle its own `recipientId` — or the raw physical column name — and write the notification into someone else's inbox. `type` / `title` / `body` / `payload` / `idempotencyKey` are untouched; you don't need to sanitize `input` yourself.
874
+
875
+ ### `columnMap` — map logical roles → your columns
876
+
877
+ ```ts
878
+ export type NotificationColumnMap = {
879
+ recipient?: string; // recipient FK. Default "user_id"
880
+ tenant?: string; // multi-tenant scope, written from the recipient. Omit → single-tenant
881
+ readAt?: string; // read-timestamp column
882
+ isRead?: string; // read-flag column (indexed)
883
+ };
884
+ ```
885
+
886
+ **Read-state is chosen by which keys are PRESENT:**
887
+
888
+ - `readAt` only → unread = `read_at IS NULL`; marking read stamps it.
889
+ - `isRead` only → unread = `is_read = false`; no timestamp.
890
+ - both → `is_read` is the indexed filter flag, `read_at` records WHEN.
891
+
892
+ Declare neither and you get the `readAt`-only default — so a model can omit
893
+ `columnMap` entirely. `unread` is the mode-agnostic read filter on `inApp` /
894
+ the repo; there is no `isRead` filter key to remember.
895
+
896
+ ## Your model — declare `table`, `schema`, and `columnMap`
897
+
898
+ ```ts title="src/app/notifications/notification.model.ts"
899
+ import { RegisterModel } from "@warlock.js/cascade";
900
+ import { v } from "@warlock.js/seal";
901
+ import { DatabaseNotification, type NotificationColumnMap } from "@warlock.js/notifications";
902
+
903
+ // Mirrors the migration columns; cascade validates + casts every write.
904
+ const notificationSchema = v.object({
905
+ user_id: v.string(),
906
+ type: v.string(),
907
+ title: v.string(),
908
+ body: v.string().nullish(),
909
+ payload: v.record(v.any()).nullish(),
910
+ read_at: v.date().nullish(),
911
+ idempotency_key: v.string().nullish(),
912
+ });
913
+
914
+ @RegisterModel()
915
+ export class Notification extends DatabaseNotification {
916
+ public static table = "notifications";
917
+ public static schema = notificationSchema;
918
+ // read_at-only, single-tenant. Add `tenant: "organization_id"` for multi-tenant,
919
+ // swap to `isRead: "is_read"` (or add it) to change the read-state representation.
920
+ public static columnMap: NotificationColumnMap = { readAt: "read_at" };
921
+ }
922
+ ```
923
+
924
+ To change columns, edit `columnMap` — the migration (`notificationColumns`), the
925
+ repo filter, and the accessors all follow. For a different driver's naming
926
+ (e.g. MongoDB camelCase), name the columns in `columnMap`
927
+ (`{ recipient: "userId", readAt: "readAt" }`).
928
+
929
+ ## Custom repository (advanced, optional)
930
+
931
+ `columnMap` already covers column naming. Subclass only for EXTRA query methods:
932
+
933
+ ```ts title="src/app/notifications/notifications.repository.ts"
934
+ import { BaseNotificationsRepository } from "@warlock.js/notifications";
935
+ import { Notification } from "./notification.model";
936
+
937
+ export class NotificationsRepository extends BaseNotificationsRepository<Notification> {
938
+ public source = Notification; // its columnMap still drives filter + write mapping
939
+ // …extra methods specific to your app
940
+ }
941
+ export const notificationsRepository = new NotificationsRepository();
942
+ ```
943
+
944
+ Then swap config:
945
+
946
+ ```ts
947
+ database: inApp.configure({ repository: notificationsRepository }),
948
+ ```
949
+
950
+ ## See also
951
+
952
+ - [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — package front door.
953
+ - [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) — wire `inApp.configure` into the config.
954
+ - [`write-notification-migration/SKILL.md`](../write-notification-migration/SKILL.md) — `notificationColumns(Notification)`, named from `columnMap`.
955
+ - [`define-notification/SKILL.md`](../define-notification/SKILL.md) — create rows via the database channel.
956
956
 
957
957
 
958
958
  ## write-notification-migration `@warlock.js/notifications/write-notification-migration/SKILL.md`
959
959
 
960
- ---
961
- name: write-notification-migration
962
- description: 'Create the notifications table with the `notificationColumns(Notification)` factory + cascade `Migration.create`. Column NAMES come from the model''s `columnMap` (recipient / tenant / readAt / isRead); the fixed columns are `type` / `title` / `body` / `payload` / `idempotency_key`. Read-state follows columnMap presence: `readAt` → nullable timestamp, `isRead` → indexed boolean, both → both. Defaults to `user_id` + `read_at` when no columnMap. Spread + extend for extras (FK references, composite indexes). Triggers: `notificationColumns`, `Migration.create` for notifications, "create the notifications table", "notification migration", "add organization_id to notifications", `is_read` column, `read_at` column, `idempotency_key` column; typical import `import { Migration } from "@warlock.js/cascade"; import { notificationColumns } from "@warlock.js/notifications"`. Skip: generic cascade migration writing — `@warlock.js/cascade/write-migration/SKILL.md`; the model itself — `@warlock.js/notifications/use-in-app/SKILL.md`.'
963
- ---
964
-
965
- # Write the notifications migration
966
-
967
- The package ships no table or migration (thin eject). You own the migration; the package gives you a column factory that names columns from your model's `columnMap`, so it's one line.
968
-
969
- > `npx warlock add notifications` scaffolds this migration (+ the model) for you. Reach for this skill when you need to customize columns or add extras.
970
-
971
- ## Common case — one line
972
-
973
- ```ts title="src/app/notifications/migrations/01-01-2026_00-00-00-notifications.migration.ts"
974
- import { Migration } from "@warlock.js/cascade";
975
- import { notificationColumns } from "@warlock.js/notifications";
976
- import { Notification } from "../notification.model";
977
-
978
- export default Migration.create(Notification, notificationColumns(Notification));
979
- ```
980
-
981
- `notificationColumns(Notification)` reads the model's `columnMap` and returns the
982
- matching columns:
983
-
984
- | columnMap | Columns |
985
- |---|---|
986
- | _(none / default)_ | `user_id`, `type`, `title`, `body`, `payload`, `read_at`, `idempotency_key` |
987
- | `{ tenant: "organization_id", readAt: "read_at" }` | adds `organization_id`; read_at-only |
988
- | `{ isRead: "is_read" }` | swaps `read_at` for `is_read` (indexed boolean) |
989
- | `{ isRead: "is_read", readAt: "read_at" }` | both read-state columns |
990
-
991
- The recipient / tenant / read-state column NAMES come from `columnMap`; `type` /
992
- `title` / `body` / `payload` / `idempotency_key` are fixed. `id` / `createdAt` /
993
- `updatedAt` are added automatically by cascade.
994
-
995
- ## With extras — spread + extend
996
-
997
- Add your own columns — a category, a source channel, a tenant id, anything:
998
-
999
- ```ts
1000
- import { Migration, string } from "@warlock.js/cascade";
1001
- import { notificationColumns } from "@warlock.js/notifications";
1002
- import { Notification } from "../notification.model";
1003
-
1004
- export default Migration.create(Notification, {
1005
- ...notificationColumns(Notification),
1006
- category: string().index().nullable(),
1007
- });
1008
- ```
1009
-
1010
- Mirror any extra column in the model `schema`. To make it filterable, add a method to a custom repository (see `use-in-app/SKILL.md`).
1011
-
1012
- ## Column naming follows `columnMap`
1013
-
1014
- The factory does NOT guess names from the driver — it reads the model's `columnMap`. To match a different convention (e.g. MongoDB camelCase), name the columns there and the migration follows:
1015
-
1016
- ```ts
1017
- class Notification extends DatabaseNotification {
1018
- public static columnMap = { recipient: "userId", readAt: "readAt" };
1019
- }
1020
- // → notificationColumns(Notification) emits `userId` + `readAt`
1021
- ```
1022
-
1023
- Without a model passed, defaults to `user_id` + `read_at` (read_at-only). See [`use-in-app/SKILL.md`](../use-in-app/SKILL.md) for the full `columnMap` shape.
1024
-
1025
- ## See also
1026
-
1027
- - [`use-in-app/SKILL.md`](../use-in-app/SKILL.md) — the model + repository the migration backs.
1028
- - [`@warlock.js/cascade/write-migration/SKILL.md`](../../../cascade/skills/write-migration/SKILL.md) — generic Cascade migration form, `Migration.alter`, etc.
1029
- - [`@warlock.js/cascade/manage-data-sources/SKILL.md`](../../../cascade/skills/manage-data-sources/SKILL.md) — multi-data-source setup.
960
+ ---
961
+ name: write-notification-migration
962
+ description: 'Create the notifications table with the `notificationColumns(Notification)` factory + cascade `Migration.create`. Column NAMES come from the model''s `columnMap` (recipient / tenant / readAt / isRead); the fixed columns are `type` / `title` / `body` / `payload` / `idempotency_key`. Set `{ idType: "bigint" }` for integer-key apps; UUID remains the default. Read-state follows columnMap presence: `readAt` → nullable timestamp, `isRead` → indexed boolean, both → both. Defaults to `user_id` + `read_at` when no columnMap. Spread + extend for extras (FK references, composite indexes). Triggers: `notificationColumns`, `Migration.create` for notifications, "create the notifications table", "notification migration", "add organization_id to notifications", `is_read` column, `read_at` column, `idempotency_key` column; typical import `import { Migration } from "@warlock.js/cascade"; import { notificationColumns } from "@warlock.js/notifications"`. Skip: generic cascade migration writing — `@warlock.js/cascade/write-migration/SKILL.md`; the model itself — `@warlock.js/notifications/use-in-app/SKILL.md`.'
963
+ ---
964
+
965
+ # Write the notifications migration
966
+
967
+ The package ships no table or migration (thin eject). You own the migration; the package gives you a column factory that names columns from your model's `columnMap`, so it's one line.
968
+
969
+ > `npx warlock add notifications` scaffolds this migration (+ the model) for you. Reach for this skill when you need to customize columns or add extras.
970
+
971
+ ## Common case — one line
972
+
973
+ ```ts title="src/app/notifications/migrations/01-01-2026_00-00-00-notifications.migration.ts"
974
+ import { Migration } from "@warlock.js/cascade";
975
+ import { notificationColumns } from "@warlock.js/notifications";
976
+ import { Notification } from "../notification.model";
977
+
978
+ export default Migration.create(Notification, notificationColumns(Notification));
979
+ ```
980
+
981
+ `notificationColumns(Notification)` reads the model's `columnMap` and returns the
982
+ matching columns:
983
+
984
+ | columnMap | Columns |
985
+ |---|---|
986
+ | _(none / default)_ | `user_id`, `type`, `title`, `body`, `payload`, `read_at`, `idempotency_key` |
987
+ | `{ tenant: "organization_id", readAt: "read_at" }` | adds `organization_id`; read_at-only |
988
+ | `{ isRead: "is_read" }` | swaps `read_at` for `is_read` (indexed boolean) |
989
+ | `{ isRead: "is_read", readAt: "read_at" }` | both read-state columns |
990
+
991
+ The recipient / tenant / read-state column NAMES come from `columnMap`; `type` /
992
+ `title` / `body` / `payload` / `idempotency_key` are fixed. `id` / `createdAt` /
993
+ `updatedAt` are added automatically by cascade.
994
+
995
+ ## Match your app's identifier type
996
+
997
+ Recipient and tenant columns default to UUID. For applications whose primary keys
998
+ are `bigint`, `integer`, or strings, pass `idType`; it is applied to every
999
+ identifier column the factory creates:
1000
+
1001
+ ```ts
1002
+ export default Migration.create(Notification, notificationColumns(Notification, { idType: "bigint" }));
1003
+ ```
1004
+
1005
+ ## With extras — spread + extend
1006
+
1007
+ Add your own columns — a category, a source channel, a tenant id, anything:
1008
+
1009
+ ```ts
1010
+ import { Migration, string } from "@warlock.js/cascade";
1011
+ import { notificationColumns } from "@warlock.js/notifications";
1012
+ import { Notification } from "../notification.model";
1013
+
1014
+ export default Migration.create(Notification, {
1015
+ ...notificationColumns(Notification, { idType: "bigint" }),
1016
+ category: string().index().nullable(),
1017
+ });
1018
+ ```
1019
+
1020
+ Mirror any extra column in the model `schema`. To make it filterable, add a method to a custom repository (see `use-in-app/SKILL.md`).
1021
+
1022
+ ## Column naming follows `columnMap`
1023
+
1024
+ The factory does NOT guess names from the driver — it reads the model's `columnMap`. To match a different convention (e.g. MongoDB camelCase), name the columns there and the migration follows:
1025
+
1026
+ ```ts
1027
+ class Notification extends DatabaseNotification {
1028
+ public static columnMap = { recipient: "userId", readAt: "readAt" };
1029
+ }
1030
+ // → notificationColumns(Notification) emits `userId` + `readAt`
1031
+ ```
1032
+
1033
+ Without a model passed, defaults to `user_id` + `read_at` (read_at-only). See [`use-in-app/SKILL.md`](../use-in-app/SKILL.md) for the full `columnMap` shape.
1034
+
1035
+ ## See also
1036
+
1037
+ - [`use-in-app/SKILL.md`](../use-in-app/SKILL.md) — the model + repository the migration backs.
1038
+ - [`@warlock.js/cascade/write-migration/SKILL.md`](../../../cascade/skills/write-migration/SKILL.md) — generic Cascade migration form, `Migration.alter`, etc.
1039
+ - [`@warlock.js/cascade/manage-data-sources/SKILL.md`](../../../cascade/skills/manage-data-sources/SKILL.md) — multi-data-source setup.
1030
1040
 
1031
1041