@warlock.js/notifications 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/cjs/index.cjs +1034 -0
- package/cjs/index.cjs.map +1 -0
- package/esm/channels/database-channel.d.mts +10 -0
- package/esm/channels/database-channel.d.mts.map +1 -0
- package/esm/channels/database-channel.mjs +19 -0
- package/esm/channels/database-channel.mjs.map +1 -0
- package/esm/channels/index.d.mts +2 -0
- package/esm/channels/index.mjs +4 -0
- package/esm/channels/mail-channel.d.mts +11 -0
- package/esm/channels/mail-channel.d.mts.map +1 -0
- package/esm/channels/mail-channel.mjs +43 -0
- package/esm/channels/mail-channel.mjs.map +1 -0
- package/esm/config.d.mts +46 -0
- package/esm/config.d.mts.map +1 -0
- package/esm/config.mjs +32 -0
- package/esm/config.mjs.map +1 -0
- package/esm/contracts/channel.contract.d.mts +38 -0
- package/esm/contracts/channel.contract.d.mts.map +1 -0
- package/esm/contracts/index.d.mts +5 -0
- package/esm/contracts/notification.contract.d.mts +18 -0
- package/esm/contracts/notification.contract.d.mts.map +1 -0
- package/esm/contracts/preference-provider.contract.d.mts +20 -0
- package/esm/contracts/preference-provider.contract.d.mts.map +1 -0
- package/esm/contracts/queue-dispatcher.contract.d.mts +23 -0
- package/esm/contracts/queue-dispatcher.contract.d.mts.map +1 -0
- package/esm/contracts/rate-limiter.contract.d.mts +23 -0
- package/esm/contracts/rate-limiter.contract.d.mts.map +1 -0
- package/esm/dispatch/define-channel.d.mts +37 -0
- package/esm/dispatch/define-channel.d.mts.map +1 -0
- package/esm/dispatch/define-channel.mjs +39 -0
- package/esm/dispatch/define-channel.mjs.map +1 -0
- package/esm/dispatch/define-notification.d.mts +22 -0
- package/esm/dispatch/define-notification.d.mts.map +1 -0
- package/esm/dispatch/define-notification.mjs +125 -0
- package/esm/dispatch/define-notification.mjs.map +1 -0
- package/esm/dispatch/dispatch-id.mjs +15 -0
- package/esm/dispatch/dispatch-id.mjs.map +1 -0
- package/esm/dispatch/dispatcher.mjs +120 -0
- package/esm/dispatch/dispatcher.mjs.map +1 -0
- package/esm/dispatch/index.d.mts +4 -0
- package/esm/dispatch/index.mjs +6 -0
- package/esm/dispatch/notifications-event-bus.d.mts +18 -0
- package/esm/dispatch/notifications-event-bus.d.mts.map +1 -0
- package/esm/dispatch/notifications-event-bus.mjs +58 -0
- package/esm/dispatch/notifications-event-bus.mjs.map +1 -0
- package/esm/dispatch/notify.d.mts +20 -0
- package/esm/dispatch/notify.d.mts.map +1 -0
- package/esm/dispatch/notify.mjs +80 -0
- package/esm/dispatch/notify.mjs.map +1 -0
- package/esm/errors.d.mts +32 -0
- package/esm/errors.d.mts.map +1 -0
- package/esm/errors.mjs +56 -0
- package/esm/errors.mjs.map +1 -0
- package/esm/in-app/base-notifications-repository.d.mts +82 -0
- package/esm/in-app/base-notifications-repository.d.mts.map +1 -0
- package/esm/in-app/base-notifications-repository.mjs +187 -0
- package/esm/in-app/base-notifications-repository.mjs.map +1 -0
- package/esm/in-app/column-map.d.mts +65 -0
- package/esm/in-app/column-map.d.mts.map +1 -0
- package/esm/in-app/column-map.mjs +28 -0
- package/esm/in-app/column-map.mjs.map +1 -0
- package/esm/in-app/database-notification.d.mts +30 -0
- package/esm/in-app/database-notification.d.mts.map +1 -0
- package/esm/in-app/database-notification.mjs +70 -0
- package/esm/in-app/database-notification.mjs.map +1 -0
- package/esm/in-app/in-app.d.mts +56 -0
- package/esm/in-app/in-app.d.mts.map +1 -0
- package/esm/in-app/in-app.mjs +67 -0
- package/esm/in-app/in-app.mjs.map +1 -0
- package/esm/in-app/index.d.mts +4 -0
- package/esm/in-app/index.mjs +6 -0
- package/esm/index.d.mts +25 -0
- package/esm/index.mjs +23 -0
- package/esm/migration/index.d.mts +1 -0
- package/esm/migration/index.mjs +3 -0
- package/esm/migration/notification-columns.d.mts +13 -0
- package/esm/migration/notification-columns.d.mts.map +1 -0
- package/esm/migration/notification-columns.mjs +53 -0
- package/esm/migration/notification-columns.mjs.map +1 -0
- package/esm/queue/herald-queue.d.mts +10 -0
- package/esm/queue/herald-queue.d.mts.map +1 -0
- package/esm/queue/herald-queue.mjs +14 -0
- package/esm/queue/herald-queue.mjs.map +1 -0
- package/esm/queue/index.d.mts +3 -0
- package/esm/queue/index.mjs +5 -0
- package/esm/queue/load-herald.d.mts +6 -0
- package/esm/queue/load-herald.d.mts.map +1 -0
- package/esm/queue/load-herald.mjs +35 -0
- package/esm/queue/load-herald.mjs.map +1 -0
- package/esm/queue/notifications-worker.d.mts +9 -0
- package/esm/queue/notifications-worker.d.mts.map +1 -0
- package/esm/queue/notifications-worker.mjs +46 -0
- package/esm/queue/notifications-worker.mjs.map +1 -0
- package/esm/types.d.mts +130 -0
- package/esm/types.d.mts.map +1 -0
- package/llms-full.txt +994 -0
- package/llms.txt +17 -0
- package/package.json +40 -0
- package/skills/README.md +23 -0
- package/skills/configure-notifications/SKILL.md +138 -0
- package/skills/define-channel/SKILL.md +107 -0
- package/skills/define-notification/SKILL.md +133 -0
- package/skills/notifications-basics/SKILL.md +94 -0
- package/skills/observe-notifications/SKILL.md +66 -0
- package/skills/queue-notifications/SKILL.md +81 -0
- package/skills/send-ad-hoc/SKILL.md +95 -0
- package/skills/use-in-app/SKILL.md +168 -0
- package/skills/write-notification-migration/SKILL.md +70 -0
package/llms.txt
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Warlock Notifications
|
|
2
|
+
|
|
3
|
+
> Package: `@warlock.js/notifications`
|
|
4
|
+
|
|
5
|
+
> Multi-channel notifications for Warlock.js — define once, fire anywhere; mail / in-app database / pluggable custom channels with preferences, rate limits, and idempotency.
|
|
6
|
+
|
|
7
|
+
## Skills
|
|
8
|
+
|
|
9
|
+
- [configure-notifications](@warlock.js/notifications/configure-notifications/SKILL.md): 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`.
|
|
10
|
+
- [define-channel](@warlock.js/notifications/define-channel/SKILL.md): 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`.
|
|
11
|
+
- [define-notification](@warlock.js/notifications/define-notification/SKILL.md): 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`.
|
|
12
|
+
- [notifications-basics](@warlock.js/notifications/notifications-basics/SKILL.md): Front-door for `@warlock.js/notifications` — what the package is, when to reach for it, and the four moving parts (channel registry, defineNotification, notify, inApp). Multi-channel dispatch: define once, fire anywhere. Recipients are cascade `Model` instances; payloads are typed per channel via a declaration-merge `NotificationChannels` registry; reusable definitions go through `defineNotification`; ad-hoc per-channel sends go through the `notify.<channel>` proxy; the in-app database channel is read via the `inApp` facade. Phase 1 ships `mail` + `database`; bridges-backed channels (whatsapp/telegram/push/slack) arrive in Phase 2. Triggers: `defineNotification`, `notify.<channel>`, `inApp.configure`, `setNotificationConfig`, `NotificationConfig`, `NotificationChannels`; "send a notification", "in-app notifications", "what channels can I use", "what is `@warlock.js/notifications`", "where do I start"; typical import `import { defineNotification, notify, inApp } from "@warlock.js/notifications"`. Skip: configuring channels in `config/notifications.ts` — `@warlock.js/notifications/configure-notifications/SKILL.md`; building a multi-channel definition — `@warlock.js/notifications/define-notification/SKILL.md`; reading in-app rows — `@warlock.js/notifications/use-in-app/SKILL.md`.
|
|
13
|
+
- [observe-notifications](@warlock.js/notifications/observe-notifications/SKILL.md): 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`.
|
|
14
|
+
- [queue-notifications](@warlock.js/notifications/queue-notifications/SKILL.md): Send notifications asynchronously via the herald-backed queue. `heraldQueue({ channel?, broker? })` is a `QueueDispatcher` you add to the `queue` slot of the declarative `config/notifications.ts` (the connector registers it at boot); then `notif.queue(to, data, options?)` / `notify` publish a rendered job instead of sending inline. `startNotificationsWorker({ channel?, broker? })` runs the consumer (in a worker or the web process) that pulls jobs and runs `channel.send`. Both lazy-import `@warlock.js/herald` (optional peer) — a missing package throws a curated install message at use time. `defineNotification` renders payloads + resolves routes BEFORE queuing, so the job (`{ channel, route, payload, options }`) is fully serializable — no model re-hydration. Without a queue configured, `.queue()` rejects `NoQueueDispatcherError`. Triggers: `heraldQueue`, `startNotificationsWorker`, `QueueDispatcher`, `.queue(`, "async notifications", "queue notifications", "notification worker", "background notifications", `notifications.dispatch`; typical import `import { heraldQueue, startNotificationsWorker } from "@warlock.js/notifications"`. Skip: synchronous sends — `@warlock.js/notifications/define-notification/SKILL.md`; herald itself — `@warlock.js/herald/*`.
|
|
15
|
+
- [send-ad-hoc](@warlock.js/notifications/send-ad-hoc/SKILL.md): 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`.
|
|
16
|
+
- [use-in-app](@warlock.js/notifications/use-in-app/SKILL.md): 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`.
|
|
17
|
+
- [write-notification-migration](@warlock.js/notifications/write-notification-migration/SKILL.md): 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`.
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@warlock.js/notifications",
|
|
3
|
+
"description": "Multi-channel notifications for Warlock.js — define once, fire anywhere; mail / in-app database / pluggable custom channels with preferences, rate limits, and idempotency.",
|
|
4
|
+
"keywords": [
|
|
5
|
+
"notifications",
|
|
6
|
+
"mail",
|
|
7
|
+
"in-app",
|
|
8
|
+
"multi-channel",
|
|
9
|
+
"warlock.js",
|
|
10
|
+
"typescript"
|
|
11
|
+
],
|
|
12
|
+
"author": "hassanzohdy",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/warlockjs/notifications"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@warlock.js/cache": "4.2.0",
|
|
20
|
+
"@warlock.js/cascade": "4.2.0",
|
|
21
|
+
"@warlock.js/core": "4.2.0",
|
|
22
|
+
"@warlock.js/logger": "4.2.0"
|
|
23
|
+
},
|
|
24
|
+
"version": "4.2.0",
|
|
25
|
+
"main": "./cjs/index.cjs",
|
|
26
|
+
"module": "./esm/index.mjs",
|
|
27
|
+
"types": "./esm/index.d.mts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"import": {
|
|
31
|
+
"types": "./esm/index.d.mts",
|
|
32
|
+
"default": "./esm/index.mjs"
|
|
33
|
+
},
|
|
34
|
+
"require": {
|
|
35
|
+
"types": "./esm/index.d.mts",
|
|
36
|
+
"default": "./cjs/index.cjs"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
package/skills/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# @warlock.js/notifications — skills
|
|
2
|
+
|
|
3
|
+
All cross-references use the form `@warlock.js/notifications/<skill>/SKILL.md`.
|
|
4
|
+
|
|
5
|
+
Skills are added as the package's public surface lands. Phase 0 (core dispatch) ships:
|
|
6
|
+
|
|
7
|
+
- (incoming) `notifications-basics/` — what the package is, when to reach for it, the mental model.
|
|
8
|
+
- (incoming) `define-notification/` — reusable multi-channel definitions; `via`, renderers, `(data, to, ctx)`.
|
|
9
|
+
- (incoming) `send-ad-hoc/` — `notify.<channel>` proxy + `notify.channel(name)` runtime form.
|
|
10
|
+
- (incoming) `configure-notifications/` — `config/notifications.ts`, `NotificationConfig`, `SendOptions`.
|
|
11
|
+
- (incoming) `define-channel/` — custom channels via `defineChannel` + `declare module`.
|
|
12
|
+
- (incoming) `use-in-app/` — `inApp.configure({model})`, `listUnread` / `markAsRead` / `markAsUnread`.
|
|
13
|
+
- (incoming) `write-notification-migration/` — `notificationColumns(Notification)`, named from the model's `columnMap`.
|
|
14
|
+
- (incoming) `observe-notifications/` — `notifications.on("sending" | "sent" | "failed" | "skipped", …)`.
|
|
15
|
+
- `queue-notifications/` — async `.queue()` via the herald-backed dispatcher + worker.
|
|
16
|
+
- (deferred — Phase 2, bridges) `gate-with-preferences/` — already covered in `configure-notifications`; promote if it grows.
|
|
17
|
+
- (deferred — Phase 2, bridges) `send-via-whatsapp/`, `send-via-telegram/`, `send-via-push/`, `send-via-slack/`.
|
|
18
|
+
|
|
19
|
+
Regenerate the LLM index after any change:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
node scripts/generate-llms.mjs
|
|
23
|
+
```
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: configure-notifications
|
|
3
|
+
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`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Configure notifications
|
|
7
|
+
|
|
8
|
+
`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).
|
|
9
|
+
|
|
10
|
+
> `npx warlock add notifications` ejects this file pre-wired (mail + in-app) alongside the `Notification` model + migration. Edit the ejected config to taste — the sections below are the full surface.
|
|
11
|
+
|
|
12
|
+
## Minimal
|
|
13
|
+
|
|
14
|
+
```ts title="src/config/notifications.ts"
|
|
15
|
+
import { type NotificationConfig, inApp, mailChannel } from "@warlock.js/notifications";
|
|
16
|
+
import { Notification } from "app/notifications/notification.model";
|
|
17
|
+
|
|
18
|
+
const config: NotificationConfig = {
|
|
19
|
+
channels: {
|
|
20
|
+
mail: mailChannel({ from: "no-reply@store.com" }),
|
|
21
|
+
database: inApp.configure({ model: Notification }),
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default config;
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
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.
|
|
29
|
+
|
|
30
|
+
## Full surface
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
const config: NotificationConfig = {
|
|
34
|
+
channels: {
|
|
35
|
+
mail: mailChannel({ from }),
|
|
36
|
+
database: inApp.configure({ model: Notification }),
|
|
37
|
+
|
|
38
|
+
// custom channels
|
|
39
|
+
discord: discordChannel(), // defineChannel + declare module
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
// optional gates — both app-owned; package ships no defaults
|
|
43
|
+
preferences: userPreferences, // drops channels recipient muted
|
|
44
|
+
rateLimit, // drops channels over budget
|
|
45
|
+
queue: heraldQueue(), // backs `.queue()` (needs @warlock.js/herald)
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export default config;
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Channel keys must match the registry
|
|
52
|
+
|
|
53
|
+
`channels: { mail, database, discord }` — keys correspond to `NotificationChannels` entries. Built-ins (`mail`, `database`) ship in the registry; custom channels extend it with `declare module`.
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// in your custom channel file
|
|
57
|
+
declare module "@warlock.js/notifications" {
|
|
58
|
+
interface NotificationChannels {
|
|
59
|
+
discord: { content: string };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
After that, `notify.discord(...)`, `via: ["discord"]`, and the `discord:` key in `defineNotification` all autocomplete + type-check.
|
|
65
|
+
|
|
66
|
+
## `inApp.configure({ model | repository })`
|
|
67
|
+
|
|
68
|
+
Two paths; mutually exclusive at the type level.
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// 90% case — pass the model class; default repo built internally.
|
|
72
|
+
database: inApp.configure({ model: Notification }),
|
|
73
|
+
|
|
74
|
+
// 10% case — pass a custom repo (only for EXTRA query methods; column names
|
|
75
|
+
// come from the model's columnMap, not a repo override).
|
|
76
|
+
database: inApp.configure({ repository: notificationsRepository }),
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
There is NO zero-arg form — the package ships no concrete table/model.
|
|
80
|
+
|
|
81
|
+
## `preferences` — pre-send opt-in gate (optional)
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import type { PreferenceProvider } from "@warlock.js/notifications";
|
|
85
|
+
|
|
86
|
+
export const userPreferences: PreferenceProvider = {
|
|
87
|
+
resolveChannels(user, type, requested) {
|
|
88
|
+
const muted: Record<string, string[]> = user.get("preferences.muted") ?? {};
|
|
89
|
+
return requested.filter((c) => !(muted[c] ?? []).includes(type));
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Dropped channels fire a `skipped` event with reason `"preference"`. `SendOptions.force === true` bypasses this gate.
|
|
95
|
+
|
|
96
|
+
## `rateLimit` — pre-send safety valve (optional)
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { cache } from "@warlock.js/cache";
|
|
100
|
+
import type { RateLimiter } from "@warlock.js/notifications";
|
|
101
|
+
|
|
102
|
+
export const rateLimit: RateLimiter = {
|
|
103
|
+
async allow(user, channel, type) {
|
|
104
|
+
const key = `notif.rl.${user.id}.${channel}.${type}`;
|
|
105
|
+
const count = await cache.increment(key, 1);
|
|
106
|
+
if (count === 1) await cache.set(key, 1, { ttl: 3600 });
|
|
107
|
+
return count <= 5;
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
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.
|
|
113
|
+
|
|
114
|
+
## Reading the config back
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { getNotificationConfig } from "@warlock.js/notifications";
|
|
118
|
+
|
|
119
|
+
const cfg = getNotificationConfig(); // throws NotificationsNotConfiguredError if unset
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Tests
|
|
123
|
+
|
|
124
|
+
Tests call `setNotificationConfig` directly — bypassing the connector — to set state, then `resetNotificationConfig` to tear down:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import { resetNotificationConfig, setNotificationConfig } from "@warlock.js/notifications";
|
|
128
|
+
|
|
129
|
+
beforeEach(() => setNotificationConfig({ channels: { ... } }));
|
|
130
|
+
afterEach(() => resetNotificationConfig());
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## See also
|
|
134
|
+
|
|
135
|
+
- [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — package front door.
|
|
136
|
+
- [`define-notification/SKILL.md`](../define-notification/SKILL.md) — reusable multi-channel definitions.
|
|
137
|
+
- [`use-in-app/SKILL.md`](../use-in-app/SKILL.md) — in-app read API.
|
|
138
|
+
- [`define-channel/SKILL.md`](../define-channel/SKILL.md) — custom channels.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: define-channel
|
|
3
|
+
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`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# `defineChannel` — custom channels
|
|
7
|
+
|
|
8
|
+
The escape hatch for channel types the package doesn't ship.
|
|
9
|
+
|
|
10
|
+
## Minimal — `fetch`-based webhook (no SDK)
|
|
11
|
+
|
|
12
|
+
```ts title="src/app/notifications/channels/discord.channel.ts"
|
|
13
|
+
import { defineChannel } from "@warlock.js/notifications";
|
|
14
|
+
|
|
15
|
+
type DiscordPayload = { content: string };
|
|
16
|
+
|
|
17
|
+
export const discordChannel = () =>
|
|
18
|
+
defineChannel<DiscordPayload>({
|
|
19
|
+
name: "discord",
|
|
20
|
+
route: (notifiable) => notifiable.get("discord_webhook") as string,
|
|
21
|
+
async send({ payload, route }) {
|
|
22
|
+
const response = await fetch(route as string, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "content-type": "application/json" },
|
|
25
|
+
body: JSON.stringify(payload),
|
|
26
|
+
});
|
|
27
|
+
if (!response.ok) throw new Error(`Discord send failed: ${response.status}`);
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Teach TypeScript about the new channel — picks up notify.discord and
|
|
32
|
+
// `defineNotification` typing.
|
|
33
|
+
declare module "@warlock.js/notifications" {
|
|
34
|
+
interface NotificationChannels {
|
|
35
|
+
discord: DiscordPayload;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Register in `config/notifications.ts`:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
channels: {
|
|
44
|
+
// ...
|
|
45
|
+
discord: discordChannel(),
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Now `notify.discord(user, { content: "🎉" })` works, and `discord:` is a valid renderer key in `defineNotification`.
|
|
50
|
+
|
|
51
|
+
## `route` resolution
|
|
52
|
+
|
|
53
|
+
| What `route` returns | Effect |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `string` | Used directly as the address (`"user@example.com"`, webhook URL). |
|
|
56
|
+
| `{ id: Id }` | The recipient's storage id — for database/internal channels. |
|
|
57
|
+
| `undefined` | Dispatcher falls back to `{ id: notifiable.id }`. |
|
|
58
|
+
| `route` omitted | Same as returning `undefined` — `{ id }` fallback. |
|
|
59
|
+
|
|
60
|
+
For raw-target ad-hoc sends (`notify.discord("https://hooks.../foo", payload)`), the raw string wins over `route()`.
|
|
61
|
+
|
|
62
|
+
## `send` — the contract
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
async send({
|
|
66
|
+
payload, // P — the channel's payload type
|
|
67
|
+
route, // string | { id: Id } — resolved by `route()` or raw target
|
|
68
|
+
notifiable, // Notifiable | undefined — undefined for raw-target ad-hoc
|
|
69
|
+
options, // SendOptions — delay/locale/meta/idempotencyKey/force/type
|
|
70
|
+
}): Promise<void>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Throw to fail the send. The dispatcher catches per channel, fires a `failed` event, and continues with siblings (per-channel isolation).
|
|
74
|
+
|
|
75
|
+
## SDK-backed channels — lazy-import the SDK
|
|
76
|
+
|
|
77
|
+
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.
|
|
78
|
+
|
|
79
|
+
Sketch:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import type { WebClient } from "@slack/web-api";
|
|
83
|
+
let Slack: typeof import("@slack/web-api");
|
|
84
|
+
let isModuleExists: boolean | null = null;
|
|
85
|
+
|
|
86
|
+
async function loadSlack() { /* try/catch await import; set flag */ }
|
|
87
|
+
loadSlack();
|
|
88
|
+
|
|
89
|
+
export const slackChannel = (config: { token: string }) =>
|
|
90
|
+
defineChannel<{ text: string }>({
|
|
91
|
+
name: "slack",
|
|
92
|
+
route: (n) => n.get("slack_channel"),
|
|
93
|
+
async send({ payload, route }) {
|
|
94
|
+
await loadSlack();
|
|
95
|
+
if (!isModuleExists) throw new Error(INSTALL_INSTRUCTIONS);
|
|
96
|
+
const client = new Slack.WebClient(config.token);
|
|
97
|
+
await client.chat.postMessage({ channel: route as string, text: payload.text });
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## See also
|
|
103
|
+
|
|
104
|
+
- [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) — register channels in the config.
|
|
105
|
+
- [`send-ad-hoc/SKILL.md`](../send-ad-hoc/SKILL.md) — `notify.<custom-channel>` works after registration.
|
|
106
|
+
- [`define-notification/SKILL.md`](../define-notification/SKILL.md) — multi-channel renderers with the custom channel key.
|
|
107
|
+
- `D:/xampp/htdocs/mongez/node/.claude/skills/develop-warlock.js-feature/SKILL.md` — lazy-import pattern for SDK-backed channels.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: define-notification
|
|
3
|
+
description: 'Build a reusable multi-channel notification with `defineNotification<Data>({ type, via, ...renderers })`. `type` is the stable identifier preference/rate-limit gates use AND defaults the database channel''s `type`. `via` is `ChannelName[]` OR `(data, to) => ChannelName[]` for per-recipient channel selection. Renderers are `(data, to, ctx) => NotificationChannels[C]`; `ctx` carries `locale` + `meta` from `SendOptions`. The returned object exposes `.send(to|to[], data, options?)`, `.queue(to|to[], data, options?)`, and `.only(...channels)`. Per-recipient + per-channel `Promise.allSettled` isolates failures; the database channel''s `type` is auto-defaulted from `def.type`; `SendOptions.idempotencyKey` is injected into the database payload. Triggers: `defineNotification`, `via`, "reusable notification", "multi-channel notification", "send through several channels at once", "render per channel", `RenderContext`, `def.type`; "force on a notification", "queue with delay"; typical import `import { defineNotification } from "@warlock.js/notifications"`. Skip: single-channel ad-hoc sends — `@warlock.js/notifications/send-ad-hoc/SKILL.md`; adding a brand-new channel — `@warlock.js/notifications/define-channel/SKILL.md`; observing dispatch outcomes — `@warlock.js/notifications/observe-notifications/SKILL.md`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# `defineNotification` — reusable multi-channel notification
|
|
7
|
+
|
|
8
|
+
The reusable pattern. Define a notification once with `type` + `via` + a renderer per channel; fire it anywhere with `.send` / `.queue` / `.only`.
|
|
9
|
+
|
|
10
|
+
## Shape
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
defineNotification<Data>({
|
|
14
|
+
type: string, // REQUIRED — gate key + database default
|
|
15
|
+
via: ChannelName[] | (data, to) => ChannelName[], // static array OR per-recipient callback
|
|
16
|
+
mail?: (data, to, ctx) => MailPayload,
|
|
17
|
+
database?: (data, to, ctx) => Omit<DatabasePayload, "type">, // `type` defaulted from `def.type`
|
|
18
|
+
// ...one renderer per channel listed in `via`
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Returns `{ send, queue, only }`:
|
|
23
|
+
|
|
24
|
+
| Method | Signature | What it does |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `.send` | `(to, data, options?)` | Render + dispatch synchronously. |
|
|
27
|
+
| `.queue` | `(to, data, options?)` | Render + enqueue. Throws if no queue dispatcher is configured. |
|
|
28
|
+
| `.only(...channels)` | returns `{send, queue, only}` | Restrict dispatch to a subset of channels. |
|
|
29
|
+
|
|
30
|
+
`to` accepts a `Notifiable` OR a `Notifiable[]`. Fan-out emits one render+dispatch per recipient with per-recipient + per-channel `Promise.allSettled` (one failure does not abort the others).
|
|
31
|
+
|
|
32
|
+
## Static `via`
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
export const welcome = defineNotification<{ name: string }>({
|
|
36
|
+
type: "welcome",
|
|
37
|
+
via: ["mail"],
|
|
38
|
+
mail: ({ name }) => ({ subject: "Welcome", html: `<p>Hi ${name}</p>` }),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
await welcome.send(user, { name: "Hasan" });
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Dynamic `via` per recipient
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
export const orderShipped = defineNotification<{ order: Order }>({
|
|
48
|
+
type: "order.shipped",
|
|
49
|
+
via: (_data, to) =>
|
|
50
|
+
to.get("telegram_chat_id")
|
|
51
|
+
? ["database", "telegram"]
|
|
52
|
+
: ["database", "mail"],
|
|
53
|
+
database: ({ order }) => ({ title: `Order #${order.number} shipped` }),
|
|
54
|
+
mail: ({ order }, to) => ({
|
|
55
|
+
subject: `Order #${order.number} shipped`,
|
|
56
|
+
html: `<p>Hi ${to.get("name")}, on the way.</p>`,
|
|
57
|
+
}),
|
|
58
|
+
// telegram: ... (Phase 2 — bridges)
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## The renderer's 3rd arg — `RenderContext`
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
mail: ({ campaignId }, to, { locale = "en", meta }) => ({
|
|
66
|
+
subject: subjectsByLocale[locale],
|
|
67
|
+
html: htmlByLocale[locale],
|
|
68
|
+
}),
|
|
69
|
+
|
|
70
|
+
// usage:
|
|
71
|
+
await marketing.queue(user, payload, {
|
|
72
|
+
locale: user.get("locale"),
|
|
73
|
+
meta: { campaignId, source: "blast" },
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`ctx` carries `locale` + `meta` from `SendOptions`. `meta` flows through to every observability event too.
|
|
78
|
+
|
|
79
|
+
## Database channel — `type` is defaulted
|
|
80
|
+
|
|
81
|
+
The `database` renderer may OMIT `type` — the dispatcher injects `def.type` automatically. This keeps the notification type in ONE place.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
defineNotification({
|
|
85
|
+
type: "order.shipped", // ← single source of truth
|
|
86
|
+
via: ["database"],
|
|
87
|
+
database: () => ({ title: "Shipped" }), // type omitted; dispatcher injects "order.shipped"
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Fan-out
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
await orderShipped.send([buyer, salesRep], { order });
|
|
95
|
+
// One render+dispatch per recipient. Database does N inserts (Phase 0);
|
|
96
|
+
// Phase 1 ships a single bulk insert via `Channel.sendMany?`.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## `.only(...)` — restrict channels
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
await orderShipped.only("mail").send(user, { order });
|
|
103
|
+
// `via` is filtered to just "mail"; other channels are skipped.
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Queue (Phase 2)
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
await orderShipped.queue(user, { order }, { delay: "10m" });
|
|
110
|
+
// Throws NoQueueDispatcherError if no `queue` is configured in config/notifications.ts.
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## SendOptions
|
|
114
|
+
|
|
115
|
+
| Field | Effect |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `delay` | Reserved — `"10m"` / `"3d"` / `600` (seconds). NOT honored yet: both `.send()` and the current `.queue()` worker dispatch immediately; delay-aware delivery is a follow-up. |
|
|
118
|
+
| `locale` | Passed to renderers via `RenderContext.locale`. |
|
|
119
|
+
| `meta` | Passed to renderers + included in every observability event. |
|
|
120
|
+
| `idempotencyKey` | Injected into the database payload (dedupe via unique index). |
|
|
121
|
+
| `force` | Bypass `PreferenceProvider`. Does NOT bypass `RateLimiter`. |
|
|
122
|
+
| `type` | Ignored by `defineNotification` — see `send-ad-hoc` for ad-hoc gating. |
|
|
123
|
+
|
|
124
|
+
## Failure isolation
|
|
125
|
+
|
|
126
|
+
Per-recipient: each recipient gets an independent `Promise.allSettled`. Per-channel: each channel within a recipient gets an independent `allSettled`. A throw becomes a `failed` event on the event bus AND re-throws within its own slot — never aborts siblings.
|
|
127
|
+
|
|
128
|
+
## See also
|
|
129
|
+
|
|
130
|
+
- [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — package front door.
|
|
131
|
+
- [`send-ad-hoc/SKILL.md`](../send-ad-hoc/SKILL.md) — `notify.<channel>` per-channel shorthand.
|
|
132
|
+
- [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) — wire channels + preferences + rate-limit + queue.
|
|
133
|
+
- [`observe-notifications/SKILL.md`](../observe-notifications/SKILL.md) — `notifications.on("sending" | "sent" | "failed" | "skipped", …)`.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: notifications-basics
|
|
3
|
+
description: 'Front-door for `@warlock.js/notifications` — what the package is, when to reach for it, and the four moving parts (channel registry, defineNotification, notify, inApp). Multi-channel dispatch: define once, fire anywhere. Recipients are cascade `Model` instances; payloads are typed per channel via a declaration-merge `NotificationChannels` registry; reusable definitions go through `defineNotification`; ad-hoc per-channel sends go through the `notify.<channel>` proxy; the in-app database channel is read via the `inApp` facade. Phase 1 ships `mail` + `database`; bridges-backed channels (whatsapp/telegram/push/slack) arrive in Phase 2. Triggers: `defineNotification`, `notify.<channel>`, `inApp.configure`, `setNotificationConfig`, `NotificationConfig`, `NotificationChannels`; "send a notification", "in-app notifications", "what channels can I use", "what is `@warlock.js/notifications`", "where do I start"; typical import `import { defineNotification, notify, inApp } from "@warlock.js/notifications"`. Skip: configuring channels in `config/notifications.ts` — `@warlock.js/notifications/configure-notifications/SKILL.md`; building a multi-channel definition — `@warlock.js/notifications/define-notification/SKILL.md`; reading in-app rows — `@warlock.js/notifications/use-in-app/SKILL.md`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# `@warlock.js/notifications` — basics
|
|
7
|
+
|
|
8
|
+
Multi-channel notifications for Warlock.js. **Define once, fire anywhere.**
|
|
9
|
+
|
|
10
|
+
## What the package is for
|
|
11
|
+
|
|
12
|
+
You have an event (`order.shipped`, `comment.mentioned`, `password.changed`) and one or more recipients. You want to address them through any combination of channels (mail, in-app, push, ...) with one render. That's what this package is.
|
|
13
|
+
|
|
14
|
+
It is NOT a queue, a template engine, a transport library, or an analytics pipeline — it orchestrates *over* the transports cascade / core / herald / bridges already provide.
|
|
15
|
+
|
|
16
|
+
## Four moving parts
|
|
17
|
+
|
|
18
|
+
1. **`NotificationChannels`** — a TypeScript interface (declaration-merge target) mapping channel name → payload type. Drives `notify.<channel>` typing and `defineNotification` renderers.
|
|
19
|
+
2. **`defineNotification`** — reusable multi-channel notification. Pass a `type`, a `via`, and a renderer per channel. Returns `{ send, queue, only }`.
|
|
20
|
+
3. **`notify`** — Proxy facade for ad-hoc single-channel sends. `notify.<channel>(to, payload, options?)` works for any registered channel.
|
|
21
|
+
4. **`inApp`** — facade for the database channel's read side. `inApp.configure({ model })` in the config returns the channel; `inApp.listUnread` / `markAsRead` / `markAsUnread` are the read API.
|
|
22
|
+
|
|
23
|
+
## Minimal end-to-end
|
|
24
|
+
|
|
25
|
+
`npx warlock add notifications` ejects the config + model + migration. The config is **declarative** — the notifications connector registers its default export at boot, so you never call `setNotificationConfig` yourself.
|
|
26
|
+
|
|
27
|
+
```ts title="src/config/notifications.ts"
|
|
28
|
+
import { type NotificationConfig, mailChannel, inApp } from "@warlock.js/notifications";
|
|
29
|
+
import { Notification } from "app/notifications/notification.model";
|
|
30
|
+
|
|
31
|
+
const config: NotificationConfig = {
|
|
32
|
+
channels: {
|
|
33
|
+
mail: mailChannel({ from: "no-reply@store.com" }),
|
|
34
|
+
database: inApp.configure({ model: Notification }),
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default config;
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```ts title="src/app/orders/notifications/order-shipped.ts"
|
|
42
|
+
import { defineNotification } from "@warlock.js/notifications";
|
|
43
|
+
|
|
44
|
+
export const orderShipped = defineNotification<{ orderId: string; number: string }>({
|
|
45
|
+
type: "order.shipped",
|
|
46
|
+
via: ["database", "mail"],
|
|
47
|
+
database: ({ orderId, number }) => ({
|
|
48
|
+
title: `Order #${number} shipped`,
|
|
49
|
+
payload: { orderId },
|
|
50
|
+
}),
|
|
51
|
+
mail: ({ number }, to) => ({
|
|
52
|
+
subject: `Order #${number} shipped`,
|
|
53
|
+
html: `<p>Hi ${to.get("name")}, on the way.</p>`,
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```ts title="anywhere"
|
|
59
|
+
import { notify, inApp } from "@warlock.js/notifications";
|
|
60
|
+
import { orderShipped } from "app/orders/notifications/order-shipped";
|
|
61
|
+
|
|
62
|
+
await orderShipped.send(user, { orderId: order.id, number: order.number });
|
|
63
|
+
await notify.mail(user, { subject: "Welcome", html: "<p>Hi!</p>" });
|
|
64
|
+
|
|
65
|
+
const unread = await inApp.listUnread(user);
|
|
66
|
+
const badge = await inApp.countUnread(user);
|
|
67
|
+
await inApp.markAsRead(user, "ntf_123");
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Recipient = cascade `Model` instance
|
|
71
|
+
|
|
72
|
+
`Notifiable` is typed as `Model`, so any cascade model is a valid recipient — `.id` (number | string) and `.get(path)` come for free. Routing is a **channel concern**, not a model concern — channels resolve `notifiable.email` / `.phone` / `.id` / etc. by convention (overridable in channel config).
|
|
73
|
+
|
|
74
|
+
## Phase 1 vs Phase 2
|
|
75
|
+
|
|
76
|
+
| Channel | Phase | How |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `mail` | 1 ✅ | core `sendMail` |
|
|
79
|
+
| `database` | 1 ✅ | cascade repo (recipient-scoped reads) |
|
|
80
|
+
| `whatsapp` | 2 | bridges `MessageProvider` |
|
|
81
|
+
| `telegram` | 2 | bridges `MessageProvider` |
|
|
82
|
+
| `slack` | 2 | bridges `MessageProvider` |
|
|
83
|
+
| `push` | 2 | bridges `PushProvider` |
|
|
84
|
+
| your own | 1 ✅ | `defineChannel` + 3-line `declare module` |
|
|
85
|
+
|
|
86
|
+
## See also
|
|
87
|
+
|
|
88
|
+
- [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) — wire `config/notifications.ts`.
|
|
89
|
+
- [`define-notification/SKILL.md`](../define-notification/SKILL.md) — reusable multi-channel definitions.
|
|
90
|
+
- [`send-ad-hoc/SKILL.md`](../send-ad-hoc/SKILL.md) — `notify.<channel>` shorthand.
|
|
91
|
+
- [`use-in-app/SKILL.md`](../use-in-app/SKILL.md) — `inApp` read API.
|
|
92
|
+
- [`define-channel/SKILL.md`](../define-channel/SKILL.md) — custom channels.
|
|
93
|
+
- [`write-notification-migration/SKILL.md`](../write-notification-migration/SKILL.md) — columnMap-driven columns.
|
|
94
|
+
- [`observe-notifications/SKILL.md`](../observe-notifications/SKILL.md) — events + metrics.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: observe-notifications
|
|
3
|
+
description: 'Wire metrics, logging, tracing, and audits via `notifications.on(event, handler)` — four events fire per (channel, recipient): `sending` (gates passed, about to hit the transport), `sent` (succeeded), `failed` (channel threw — carries the Error), `skipped` (a pre-send gate dropped it — carries `reason: "preference" | "rate-limit"`). Every event carries the recipient as `notifiable?` (undefined only for raw-target ad-hoc sends) and a `dispatchId` — `sending` and its terminal `sent`/`failed` share it, so observers can pair them (spans, latency, hung-send detection). `sent`/`failed` also carry `durationMs`. Handler exceptions are logged-and-swallowed so one bad listener can not break the dispatcher. `on(...)` returns an unsubscribe function; `off(event, handler)` exists for symmetry. Triggers: `notifications.on`, `notifications.off`, `"sending"` event, `"sent"` event, `"failed"` event, `"skipped"` event, `dispatchId`, `durationMs`, `NotificationEvents`, "notification metrics", "notification latency", "trace notifications", "notification audit log", "track notification drops"; typical import `import { notifications } from "@warlock.js/notifications"`. Skip: building notifications — `@warlock.js/notifications/define-notification/SKILL.md`; configuring the preference/rate-limit gates that produce `skipped` — `@warlock.js/notifications/configure-notifications/SKILL.md`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Observe notifications
|
|
7
|
+
|
|
8
|
+
Every dispatch emits a `sending` event then a terminal `sent` / `failed` — or a `skipped` if a gate dropped it — per (channel, recipient).
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { notifications } from "@warlock.js/notifications";
|
|
12
|
+
|
|
13
|
+
// latency + delivery, keyed by recipient
|
|
14
|
+
notifications.on("sent", ({ channel, notifiable, durationMs, options }) =>
|
|
15
|
+
metrics.timing(`notif.${channel}.sent`, durationMs, { userId: notifiable?.id, ...options.meta }));
|
|
16
|
+
|
|
17
|
+
notifications.on("failed", ({ channel, notifiable, error }) =>
|
|
18
|
+
log.error(`notif.${channel}`, error, { userId: notifiable?.id }));
|
|
19
|
+
|
|
20
|
+
notifications.on("skipped", ({ channel, reason }) =>
|
|
21
|
+
metrics.inc(`notif.${channel}.skipped.${reason}`));
|
|
22
|
+
|
|
23
|
+
// pair `sending` → terminal by dispatchId — open a span / arm a hung-send watchdog
|
|
24
|
+
notifications.on("sending", ({ dispatchId, channel, notifiable }) =>
|
|
25
|
+
tracer.open(dispatchId, { channel, userId: notifiable?.id }));
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Events
|
|
29
|
+
|
|
30
|
+
| Event | Fires when | Payload |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `sending` | Gates passed; about to hit the transport (sync) / enqueue (queue) | `{ dispatchId, channel, notifiable?, payload, options }` |
|
|
33
|
+
| `sent` | Channel dispatched OR job enqueued | `{ dispatchId, channel, notifiable?, payload, options, durationMs }` |
|
|
34
|
+
| `failed` | `channel.send` (or `queue.dispatch`) threw | `{ dispatchId, channel, notifiable?, payload, error, options, durationMs }` |
|
|
35
|
+
| `skipped` | A pre-send gate dropped this channel | `{ dispatchId, channel, notifiable?, reason, options }` — `reason: "preference" \| "rate-limit"` |
|
|
36
|
+
|
|
37
|
+
`notifiable` is the recipient **model** (undefined only for raw-target ad-hoc sends like `notify.mail("x@y.com", …)`) — read `notifiable?.id` / `notifiable?.get(...)` to key your metrics or logs.
|
|
38
|
+
|
|
39
|
+
`dispatchId` is unique per (channel, recipient) dispatch — `sending` and its terminal `sent`/`failed` share it, so you can pair them for a span, a latency measurement, or a watchdog that flags a `sending` with no terminal (a hung send). `durationMs` is transport time on a sync send, enqueue time on a queued one.
|
|
40
|
+
|
|
41
|
+
`options` is the `SendOptions` passed at the call site — including `meta`. Tagging a send with `meta: { campaignId }` lets downstream observers join the event back to the campaign.
|
|
42
|
+
|
|
43
|
+
## Unsubscribe
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
const off = notifications.on("sent", handler);
|
|
47
|
+
// later
|
|
48
|
+
off();
|
|
49
|
+
|
|
50
|
+
// or
|
|
51
|
+
notifications.off("sent", handler);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Fan-out emits N events
|
|
55
|
+
|
|
56
|
+
`orderShipped.send([buyer, salesRep], { order })` with `via: ["mail", "database"]` fires up to **4** `sent` events (2 recipients × 2 channels). Aggregate by `meta` or by `channel + notifiable.id` in your observer.
|
|
57
|
+
|
|
58
|
+
## Handler isolation
|
|
59
|
+
|
|
60
|
+
A handler that throws is logged and swallowed — the other handlers for the same event still run, and the dispatcher never observes the throw. Observers can't **abort** a send (there's no veto here — use `preferences` / `rateLimit` for that). They can delay it, though: `sending` is awaited before the transport, so keep that handler fast.
|
|
61
|
+
|
|
62
|
+
## See also
|
|
63
|
+
|
|
64
|
+
- [`notifications-basics/SKILL.md`](../notifications-basics/SKILL.md) — front door + mental model.
|
|
65
|
+
- [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) — `preferences` / `rateLimit` slots that emit `skipped`.
|
|
66
|
+
- [`send-ad-hoc/SKILL.md`](../send-ad-hoc/SKILL.md) — when `notifiable` is undefined in events.
|