@warlock.js/notifications 5.12.0 → 5.13.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 CHANGED
@@ -4,6 +4,12 @@ All notable changes to `@warlock.js/notifications` are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
6
 
7
+ ## 5.13.0 - 2026-09-17
8
+
9
+ ### Fixed
10
+
11
+ - Restored the portable `typecheck` script after Core's path-alias resolver was made safe for an absent first target.
12
+
7
13
  ## 5.11.0 - 2026-09-14
8
14
 
9
15
  _Released in lockstep with the `@warlock.js/*` family; no package-specific changes in 5.11.0._
package/llms-full.txt CHANGED
@@ -570,85 +570,106 @@ A handler that throws is logged and swallowed — the other handlers for the sam
570
570
 
571
571
  ---
572
572
  name: queue-notifications
573
- description: '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/*`.'
573
+ description: "Send notifications asynchronously through the herald backend or the BullMQ adapter. Configure a QueueDispatcher in config/notifications.ts, then use notif.queue(to, data, options?) to enqueue rendered jobs. Herald uses heraldQueue() and startNotificationsWorker(); queueNotificationDispatcher() from @warlock.js/queue/notifications uses BullMQ, supports retries, and honours SendOptions.delay. Without a queue configured, .queue() rejects NoQueueDispatcherError. Triggers: heraldQueue, startNotificationsWorker, queueNotificationDispatcher, 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
574
  ---
575
575
 
576
576
  # Queue notifications (async)
577
577
 
578
- Move slow channels (SMTP, HTTP) off the request path. `.queue()` publishes a
579
- rendered job to herald; a worker delivers it.
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
580
 
581
- ## Wire the dispatcher
581
+ ## Wire the herald dispatcher
582
582
 
583
583
  ```ts title="src/config/notifications.ts"
584
- import { type NotificationConfig, heraldQueue, inApp, mailChannel } from "@warlock.js/notifications";
584
+ import {
585
+ type NotificationConfig,
586
+ heraldQueue,
587
+ inApp,
588
+ mailChannel,
589
+ } from "@warlock.js/notifications";
585
590
  import { Notification } from "app/notifications/notification.model";
586
591
 
587
592
  const config: NotificationConfig = {
588
593
  channels: { mail: mailChannel(), database: inApp.configure({ model: Notification }) },
589
- queue: heraldQueue(), // → `.queue()` now works
594
+ queue: heraldQueue(),
590
595
  };
591
596
 
592
- export default config; // the notifications connector registers it at boot
597
+ export default config;
593
598
  ```
594
599
 
595
- `heraldQueue({ channel?, broker? })` publishes to `"notifications.dispatch"` on
596
- the default broker unless overridden. It lazy-imports `@warlock.js/herald`; if
597
- that package isn't installed, the first `.queue()` throws an install message.
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
+ With `@warlock.js/queue` configured, use its notification dispatcher instead
607
+ of the herald dispatcher:
608
+
609
+ ```ts title="src/config/notifications.ts"
610
+ import { type NotificationConfig, mailChannel } from "@warlock.js/notifications";
611
+ import { queueNotificationDispatcher } from "@warlock.js/queue/notifications";
612
+
613
+ const config: NotificationConfig = {
614
+ channels: { mail: mailChannel() },
615
+ queue: queueNotificationDispatcher({ attempts: 3 }),
616
+ };
617
+
618
+ export default config;
619
+ ```
598
620
 
599
- Without `queue` configured, `.queue()` rejects `NoQueueDispatcherError` — a loud
600
- config error, not a silent no-op.
621
+ The BullMQ adapter 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`.
601
624
 
602
- ## Run the worker
625
+ ## Run the herald worker
603
626
 
604
- In a worker entrypoint (or the web process), after the config + the herald
605
- broker are up:
627
+ In a worker entrypoint (or the web process), after the config and herald broker
628
+ are up:
606
629
 
607
630
  ```ts
608
631
  import { startNotificationsWorker } from "@warlock.js/notifications";
609
632
 
610
- await startNotificationsWorker(); // subscribes to "notifications.dispatch"
633
+ await startNotificationsWorker();
611
634
  ```
612
635
 
613
- The worker pulls each job, looks the channel up by name, and runs
614
- `channel.send({ payload, route, options })`. The job carries an
615
- ALREADY-RENDERED payload + resolved route (rendering happens at enqueue), so the
616
- worker needs no recipient model.
636
+ The worker looks up the channel by name and runs
637
+ `channel.send({ payload, route, options })`. The worker needs no recipient model
638
+ because rendering happened at enqueue time.
617
639
 
618
640
  ## Send
619
641
 
620
642
  ```ts
621
- await orderShipped.queue(user, { order }); // async
643
+ await orderShipped.queue(user, { order });
622
644
  await orderShipped.queue(buyers, { order }, { delay: "10m" });
623
- await notify.mail(user, payload, { /* sync — notify.* doesn't queue */ });
645
+ await notify.mail(user, payload, {/* synchronous - notify.* does not queue */});
624
646
  ```
625
647
 
626
- `.queue()` renders synchronously then enqueues. Fan-out renders per recipient
627
- and enqueues one job each.
648
+ `.queue()` renders per recipient and enqueues one job per recipient. Without a
649
+ configured dispatcher, it rejects `NoQueueDispatcherError`.
628
650
 
629
651
  ## Idempotency
630
652
 
631
- Pass `idempotencyKey` so a redelivered/retried job doesn't create a duplicate
632
- in-app row — `createFor` find-or-creates on the key.
653
+ Pass `idempotencyKey` so a redelivered or retried job does not create a duplicate
654
+ in-app row. `createFor` finds or creates the row by that key.
633
655
 
634
656
  ```ts
635
657
  await orderShipped.queue(user, { order }, { idempotencyKey: `ship:${order.id}` });
636
658
  ```
637
659
 
638
- ## Phase notes / current limits
660
+ ## Current limits
639
661
 
640
- - **`delay` is carried on the job but not yet honored** — the worker delivers
641
- immediately. Delay-aware delivery lands with a scheduled worker.
642
- - **No retry / dead-letter yet** — a failed `channel.send` is logged + acked
643
- (no poison-message loop). DLQ handling is a follow-up.
644
- - Rate-limit budget is consumed at ENQUEUE time for queued sends (see
645
- `RateLimiter` docstring).
662
+ - With the herald backend, `delay` is carried on the job but not honoured; the
663
+ worker delivers immediately. Use the BullMQ adapter for delayed delivery.
664
+ - Herald has no retry or dead-letter handling yet; failed `channel.send` calls
665
+ are logged and acknowledged.
666
+ - Rate-limit budget is consumed at enqueue time for queued sends.
646
667
 
647
668
  ## See also
648
669
 
649
- - [`define-notification/SKILL.md`](../define-notification/SKILL.md) — `.queue()` on a defined notification.
650
- - [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) — the `queue` config slot.
651
- - [`@warlock.js/herald/herald-basics/SKILL.md`](../../../herald/skills/herald-basics/SKILL.md) — connecting the broker.
670
+ - [`define-notification/SKILL.md`](../define-notification/SKILL.md) - `.queue()` on a defined notification.
671
+ - [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) - the `queue` config slot.
672
+ - [`@warlock.js/herald/herald-basics/SKILL.md`](../../../herald/skills/herald-basics/SKILL.md) - connecting the broker.
652
673
 
653
674
 
654
675
  ## send-ad-hoc `@warlock.js/notifications/send-ad-hoc/SKILL.md`
package/llms.txt CHANGED
@@ -11,7 +11,7 @@
11
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
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
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/*`.
14
+ - [queue-notifications](@warlock.js/notifications/queue-notifications/SKILL.md): Send notifications asynchronously through the herald backend or the BullMQ adapter. Configure a QueueDispatcher in config/notifications.ts, then use notif.queue(to, data, options?) to enqueue rendered jobs. Herald uses heraldQueue() and startNotificationsWorker(); queueNotificationDispatcher() from @warlock.js/queue/notifications uses BullMQ, supports retries, and honours SendOptions.delay. Without a queue configured, .queue() rejects NoQueueDispatcherError. Triggers: heraldQueue, startNotificationsWorker, queueNotificationDispatcher, 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/*.
15
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
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
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 CHANGED
@@ -5,13 +5,13 @@
5
5
  "environment": "server"
6
6
  },
7
7
  "dependencies": {
8
- "@warlock.js/cache": "5.12.0",
9
- "@warlock.js/cascade": "5.12.0",
10
- "@warlock.js/core": "5.12.0",
11
- "@warlock.js/logger": "5.12.0"
8
+ "@warlock.js/cache": "5.13.0",
9
+ "@warlock.js/cascade": "5.13.0",
10
+ "@warlock.js/core": "5.13.0",
11
+ "@warlock.js/logger": "5.13.0"
12
12
  },
13
13
  "optionalDependencies": {
14
- "@warlock.js/herald": "5.12.0"
14
+ "@warlock.js/herald": "5.13.0"
15
15
  },
16
16
  "repository": {
17
17
  "type": "git",
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "author": "hassanzohdy",
29
29
  "license": "MIT",
30
- "version": "5.12.0",
30
+ "version": "5.13.0",
31
31
  "main": "./cjs/index.cjs",
32
32
  "module": "./esm/index.mjs",
33
33
  "types": "./esm/index.d.mts",
@@ -1,81 +1,102 @@
1
1
  ---
2
2
  name: queue-notifications
3
- description: '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/*`.'
3
+ description: "Send notifications asynchronously through the herald backend or the BullMQ adapter. Configure a QueueDispatcher in config/notifications.ts, then use notif.queue(to, data, options?) to enqueue rendered jobs. Herald uses heraldQueue() and startNotificationsWorker(); queueNotificationDispatcher() from @warlock.js/queue/notifications uses BullMQ, supports retries, and honours SendOptions.delay. Without a queue configured, .queue() rejects NoQueueDispatcherError. Triggers: heraldQueue, startNotificationsWorker, queueNotificationDispatcher, QueueDispatcher, .queue(, async notifications, queue notifications, notification worker, background notifications, notifications.dispatch. Skip: synchronous sends - @warlock.js/notifications/define-notification/SKILL.md; herald itself - @warlock.js/herald/*."
4
4
  ---
5
5
 
6
6
  # Queue notifications (async)
7
7
 
8
- Move slow channels (SMTP, HTTP) off the request path. `.queue()` publishes a
9
- rendered job to herald; a worker delivers it.
8
+ Move slow channels (SMTP, HTTP) off the request path. `.queue()` renders the
9
+ payload and resolves the route before passing a serializable job to a dispatcher.
10
10
 
11
- ## Wire the dispatcher
11
+ ## Wire the herald dispatcher
12
12
 
13
13
  ```ts title="src/config/notifications.ts"
14
- import { type NotificationConfig, heraldQueue, inApp, mailChannel } from "@warlock.js/notifications";
14
+ import {
15
+ type NotificationConfig,
16
+ heraldQueue,
17
+ inApp,
18
+ mailChannel,
19
+ } from "@warlock.js/notifications";
15
20
  import { Notification } from "app/notifications/notification.model";
16
21
 
17
22
  const config: NotificationConfig = {
18
23
  channels: { mail: mailChannel(), database: inApp.configure({ model: Notification }) },
19
- queue: heraldQueue(), // → `.queue()` now works
24
+ queue: heraldQueue(),
20
25
  };
21
26
 
22
- export default config; // the notifications connector registers it at boot
27
+ export default config;
23
28
  ```
24
29
 
25
- `heraldQueue({ channel?, broker? })` publishes to `"notifications.dispatch"` on
26
- the default broker unless overridden. It lazy-imports `@warlock.js/herald`; if
27
- that package isn't installed, the first `.queue()` throws an install message.
30
+ `heraldQueue({ channel?, broker? })` publishes to `"notifications.dispatch"`
31
+ on the default broker unless overridden. It lazy-imports `@warlock.js/herald`;
32
+ if that package is missing, the first `.queue()` throws an install message.
28
33
 
29
- Without `queue` configured, `.queue()` rejects `NoQueueDispatcherError` — a loud
30
- config error, not a silent no-op.
34
+ ## Use BullMQ for delayed or retried delivery
31
35
 
32
- ## Run the worker
36
+ With `@warlock.js/queue` configured, use its notification dispatcher instead
37
+ of the herald dispatcher:
33
38
 
34
- In a worker entrypoint (or the web process), after the config + the herald
35
- broker are up:
39
+ ```ts title="src/config/notifications.ts"
40
+ import { type NotificationConfig, mailChannel } from "@warlock.js/notifications";
41
+ import { queueNotificationDispatcher } from "@warlock.js/queue/notifications";
42
+
43
+ const config: NotificationConfig = {
44
+ channels: { mail: mailChannel() },
45
+ queue: queueNotificationDispatcher({ attempts: 3 }),
46
+ };
47
+
48
+ export default config;
49
+ ```
50
+
51
+ The BullMQ adapter honours `SendOptions.delay`: numeric delays are seconds and
52
+ string delays use the normal duration syntax, such as `"10m"`. It also supports
53
+ BullMQ retries through `attempts` and `backoff`.
54
+
55
+ ## Run the herald worker
56
+
57
+ In a worker entrypoint (or the web process), after the config and herald broker
58
+ are up:
36
59
 
37
60
  ```ts
38
61
  import { startNotificationsWorker } from "@warlock.js/notifications";
39
62
 
40
- await startNotificationsWorker(); // subscribes to "notifications.dispatch"
63
+ await startNotificationsWorker();
41
64
  ```
42
65
 
43
- The worker pulls each job, looks the channel up by name, and runs
44
- `channel.send({ payload, route, options })`. The job carries an
45
- ALREADY-RENDERED payload + resolved route (rendering happens at enqueue), so the
46
- worker needs no recipient model.
66
+ The worker looks up the channel by name and runs
67
+ `channel.send({ payload, route, options })`. The worker needs no recipient model
68
+ because rendering happened at enqueue time.
47
69
 
48
70
  ## Send
49
71
 
50
72
  ```ts
51
- await orderShipped.queue(user, { order }); // async
73
+ await orderShipped.queue(user, { order });
52
74
  await orderShipped.queue(buyers, { order }, { delay: "10m" });
53
- await notify.mail(user, payload, { /* sync — notify.* doesn't queue */ });
75
+ await notify.mail(user, payload, {/* synchronous - notify.* does not queue */});
54
76
  ```
55
77
 
56
- `.queue()` renders synchronously then enqueues. Fan-out renders per recipient
57
- and enqueues one job each.
78
+ `.queue()` renders per recipient and enqueues one job per recipient. Without a
79
+ configured dispatcher, it rejects `NoQueueDispatcherError`.
58
80
 
59
81
  ## Idempotency
60
82
 
61
- Pass `idempotencyKey` so a redelivered/retried job doesn't create a duplicate
62
- in-app row — `createFor` find-or-creates on the key.
83
+ Pass `idempotencyKey` so a redelivered or retried job does not create a duplicate
84
+ in-app row. `createFor` finds or creates the row by that key.
63
85
 
64
86
  ```ts
65
87
  await orderShipped.queue(user, { order }, { idempotencyKey: `ship:${order.id}` });
66
88
  ```
67
89
 
68
- ## Phase notes / current limits
90
+ ## Current limits
69
91
 
70
- - **`delay` is carried on the job but not yet honored** — the worker delivers
71
- immediately. Delay-aware delivery lands with a scheduled worker.
72
- - **No retry / dead-letter yet** — a failed `channel.send` is logged + acked
73
- (no poison-message loop). DLQ handling is a follow-up.
74
- - Rate-limit budget is consumed at ENQUEUE time for queued sends (see
75
- `RateLimiter` docstring).
92
+ - With the herald backend, `delay` is carried on the job but not honoured; the
93
+ worker delivers immediately. Use the BullMQ adapter for delayed delivery.
94
+ - Herald has no retry or dead-letter handling yet; failed `channel.send` calls
95
+ are logged and acknowledged.
96
+ - Rate-limit budget is consumed at enqueue time for queued sends.
76
97
 
77
98
  ## See also
78
99
 
79
- - [`define-notification/SKILL.md`](../define-notification/SKILL.md) — `.queue()` on a defined notification.
80
- - [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) — the `queue` config slot.
81
- - [`@warlock.js/herald/herald-basics/SKILL.md`](../../../herald/skills/herald-basics/SKILL.md) — connecting the broker.
100
+ - [`define-notification/SKILL.md`](../define-notification/SKILL.md) - `.queue()` on a defined notification.
101
+ - [`configure-notifications/SKILL.md`](../configure-notifications/SKILL.md) - the `queue` config slot.
102
+ - [`@warlock.js/herald/herald-basics/SKILL.md`](../../../herald/skills/herald-basics/SKILL.md) - connecting the broker.