@shaferllc/keel 0.35.0 → 0.36.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/README.md CHANGED
@@ -178,6 +178,7 @@ See [docs/architecture.md](./docs/architecture.md) for the full picture.
178
178
  | [Factories & Seeders](./docs/factories.md) | Built-in Faker, model factories, seeders |
179
179
  | [Mail](./docs/mail.md) | Fluent mailer, pluggable transports, edge-safe |
180
180
  | [Queues & Jobs](./docs/queues.md) | Dispatch jobs, pluggable drivers, workers |
181
+ | [Notifications](./docs/notifications.md) | Multi-channel (mail/db), queueable |
181
182
  | [Events](./docs/events.md) | Emit/listen decoupling, async listeners |
182
183
  | [Cache](./docs/cache.md) | TTLs, the remember pattern, pluggable stores |
183
184
  | [Logger](./docs/logger.md) | Leveled structured logging, child loggers |
@@ -234,6 +235,7 @@ config, and the console. On deck:
234
235
  - [x] Model attribute casts + mass-assignment guarding — **v0.33.0**
235
236
  - [x] Mail (fluent mailer, pluggable transports) — **v0.34.0**
236
237
  - [x] Queues / background jobs (pluggable drivers) — **v0.35.0**
238
+ - [x] Notifications (multi-channel, queueable) — **v0.36.0**
237
239
  - [ ] Publish `src/core` as the `@keel/core` package
238
240
 
239
241
  See [CHANGELOG.md](./CHANGELOG.md) for release history.
@@ -28,6 +28,8 @@ export { Mailer, PendingMail, ArrayTransport, LogTransport, fetchTransport, mail
28
28
  export type { Message, Transport, MailerOptions, FetchTransportOptions } from "./mail.js";
29
29
  export { Job, Queue, SyncDriver, MemoryDriver, dispatch, work, setQueue, getQueue, } from "./queue.js";
30
30
  export type { Dispatchable, JobOptions, QueueDriver, Drainable, QueuedJob } from "./queue.js";
31
+ export { Notification, Notifier, MailChannel, DatabaseChannel, ArrayChannel, routeFor, notify, setNotifier, getNotifier, } from "./notification.js";
32
+ export type { Notifiable, MailContent, Channel } from "./notification.js";
31
33
  export { SchemaBuilder, Migrator, TableBuilder, Column } from "./migrations.js";
32
34
  export type { Migration } from "./migrations.js";
33
35
  export { ctx, json, text, html, redirect, param, query, header, body, request, response, } from "./request.js";
@@ -16,6 +16,7 @@ export { Relation, HasOne, HasMany, BelongsTo, BelongsToMany } from "./relations
16
16
  export { Faker, Factory as ModelFactory, factory, Seeder, seed } from "./factory.js";
17
17
  export { Mailer, PendingMail, ArrayTransport, LogTransport, fetchTransport, mail, setMailer, getMailer, } from "./mail.js";
18
18
  export { Job, Queue, SyncDriver, MemoryDriver, dispatch, work, setQueue, getQueue, } from "./queue.js";
19
+ export { Notification, Notifier, MailChannel, DatabaseChannel, ArrayChannel, routeFor, notify, setNotifier, getNotifier, } from "./notification.js";
19
20
  export { SchemaBuilder, Migrator, TableBuilder, Column } from "./migrations.js";
20
21
  export { ctx, json, text, html, redirect, param, query, header, body, request, response, } from "./request.js";
21
22
  export { View } from "./view.js";
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Notifications — send a message to a recipient over one or more channels
3
+ * (mail, database, or your own), inline or through the queue. This is where the
4
+ * mail and queue layers compose: a notification declares *what* to say and
5
+ * *which channels* carry it, and each channel decides *how*.
6
+ *
7
+ * class InvoicePaid extends Notification {
8
+ * constructor(private amount: number) { super(); }
9
+ * via() { return ["mail", "database"]; }
10
+ * toMail() { return { subject: "Payment received", text: `Thanks for $${this.amount}.` }; }
11
+ * toArray() { return { amount: this.amount }; }
12
+ * }
13
+ *
14
+ * await notify(user, new InvoicePaid(4200)); // user.email routes the mail
15
+ *
16
+ * Set `shouldQueue = true` on a notification to deliver it from a queued job
17
+ * instead of inline. Channels are pluggable, so `array` (for tests) or a custom
18
+ * push-to-provider channel slot in the same way transports and drivers do.
19
+ */
20
+ /** A recipient. Anything with routing info — often a `User` model. */
21
+ export interface Notifiable {
22
+ /** Return the address/id for a channel (e.g. an email). Falls back to `email`/`id`. */
23
+ routeNotificationFor?(channel: string): string | number | undefined;
24
+ [key: string]: unknown;
25
+ }
26
+ /** What a notification hands the mail channel. */
27
+ export interface MailContent {
28
+ subject: string;
29
+ text?: string;
30
+ html?: string;
31
+ from?: string;
32
+ /** Override the resolved recipient address. */
33
+ to?: string;
34
+ }
35
+ export declare abstract class Notification {
36
+ /** Deliver from a queued job instead of inline. */
37
+ shouldQueue: boolean;
38
+ /** Channels to deliver on. Default: mail. */
39
+ via(_notifiable: Notifiable): string[];
40
+ /** Build the mail-channel content. Required if `via` includes "mail". */
41
+ toMail?(notifiable: Notifiable): MailContent;
42
+ /** Build the payload stored/serialized by the database and array channels. */
43
+ toArray?(notifiable: Notifiable): Record<string, unknown>;
44
+ }
45
+ /** Resolve where a notifiable receives a given channel. */
46
+ export declare function routeFor(notifiable: Notifiable, channel: string): string | number | undefined;
47
+ /** The bridge that actually delivers a notification on one channel. */
48
+ export interface Channel {
49
+ send(notifiable: Notifiable, notification: Notification): Promise<void>;
50
+ }
51
+ /** Delivers via the mailer, using the notification's `toMail`. */
52
+ export declare class MailChannel implements Channel {
53
+ send(notifiable: Notifiable, notification: Notification): Promise<void>;
54
+ }
55
+ /** Persists the notification's `toArray` payload to a table via the query builder. */
56
+ export declare class DatabaseChannel implements Channel {
57
+ private table;
58
+ constructor(table?: string);
59
+ send(notifiable: Notifiable, notification: Notification): Promise<void>;
60
+ }
61
+ /** Collects deliveries in memory — for tests. */
62
+ export declare class ArrayChannel implements Channel {
63
+ readonly sent: {
64
+ notifiable: Notifiable;
65
+ notification: Notification;
66
+ }[];
67
+ send(notifiable: Notifiable, notification: Notification): Promise<void>;
68
+ }
69
+ export declare class Notifier {
70
+ private channels;
71
+ /** Register (or replace) a channel by name. */
72
+ channel(name: string, channel: Channel): this;
73
+ private deliver;
74
+ /** Send a notification to one or many recipients. */
75
+ send(notifiables: Notifiable | Notifiable[], notification: Notification): Promise<void>;
76
+ }
77
+ /** Register the default notifier used by `notify()`. */
78
+ export declare function setNotifier(instance: Notifier): Notifier;
79
+ /** The default notifier instance (register channels on it). */
80
+ export declare function getNotifier(): Notifier;
81
+ /** Send a notification to one or many notifiables via the default notifier. */
82
+ export declare function notify(notifiables: Notifiable | Notifiable[], notification: Notification): Promise<void>;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Notifications — send a message to a recipient over one or more channels
3
+ * (mail, database, or your own), inline or through the queue. This is where the
4
+ * mail and queue layers compose: a notification declares *what* to say and
5
+ * *which channels* carry it, and each channel decides *how*.
6
+ *
7
+ * class InvoicePaid extends Notification {
8
+ * constructor(private amount: number) { super(); }
9
+ * via() { return ["mail", "database"]; }
10
+ * toMail() { return { subject: "Payment received", text: `Thanks for $${this.amount}.` }; }
11
+ * toArray() { return { amount: this.amount }; }
12
+ * }
13
+ *
14
+ * await notify(user, new InvoicePaid(4200)); // user.email routes the mail
15
+ *
16
+ * Set `shouldQueue = true` on a notification to deliver it from a queued job
17
+ * instead of inline. Channels are pluggable, so `array` (for tests) or a custom
18
+ * push-to-provider channel slot in the same way transports and drivers do.
19
+ */
20
+ import { db } from "./database.js";
21
+ import { getMailer } from "./mail.js";
22
+ import { dispatch } from "./queue.js";
23
+ export class Notification {
24
+ /** Deliver from a queued job instead of inline. */
25
+ shouldQueue = false;
26
+ /** Channels to deliver on. Default: mail. */
27
+ via(_notifiable) {
28
+ return ["mail"];
29
+ }
30
+ }
31
+ /** Resolve where a notifiable receives a given channel. */
32
+ export function routeFor(notifiable, channel) {
33
+ if (typeof notifiable.routeNotificationFor === "function") {
34
+ const route = notifiable.routeNotificationFor(channel);
35
+ if (route !== undefined && route !== null)
36
+ return route;
37
+ }
38
+ if (channel === "mail")
39
+ return notifiable.email;
40
+ return notifiable.id;
41
+ }
42
+ /* -------------------------------- channels -------------------------------- */
43
+ /** Delivers via the mailer, using the notification's `toMail`. */
44
+ export class MailChannel {
45
+ async send(notifiable, notification) {
46
+ if (!notification.toMail) {
47
+ throw new Error(`${notification.constructor.name} has no toMail() for the mail channel.`);
48
+ }
49
+ const content = notification.toMail(notifiable);
50
+ const to = content.to ?? routeFor(notifiable, "mail");
51
+ if (!to) {
52
+ throw new Error("Notification: no mail route (set `email` or `routeNotificationFor`).");
53
+ }
54
+ const message = getMailer().message().to(String(to)).subject(content.subject);
55
+ if (content.from)
56
+ message.from(content.from);
57
+ if (content.text)
58
+ message.text(content.text);
59
+ if (content.html)
60
+ message.html(content.html);
61
+ await message.send();
62
+ }
63
+ }
64
+ /** Persists the notification's `toArray` payload to a table via the query builder. */
65
+ export class DatabaseChannel {
66
+ table;
67
+ constructor(table = "notifications") {
68
+ this.table = table;
69
+ }
70
+ async send(notifiable, notification) {
71
+ const data = notification.toArray ? notification.toArray(notifiable) : {};
72
+ await db(this.table).insert({
73
+ type: notification.constructor.name,
74
+ notifiable_id: routeFor(notifiable, "database") ?? null,
75
+ data: JSON.stringify(data),
76
+ });
77
+ }
78
+ }
79
+ /** Collects deliveries in memory — for tests. */
80
+ export class ArrayChannel {
81
+ sent = [];
82
+ async send(notifiable, notification) {
83
+ this.sent.push({ notifiable, notification });
84
+ }
85
+ }
86
+ /* -------------------------------- notifier -------------------------------- */
87
+ export class Notifier {
88
+ channels = new Map([["mail", new MailChannel()]]);
89
+ /** Register (or replace) a channel by name. */
90
+ channel(name, channel) {
91
+ this.channels.set(name, channel);
92
+ return this;
93
+ }
94
+ async deliver(notifiable, notification) {
95
+ for (const name of notification.via(notifiable)) {
96
+ const channel = this.channels.get(name);
97
+ if (!channel)
98
+ throw new Error(`No notification channel "${name}" registered.`);
99
+ await channel.send(notifiable, notification);
100
+ }
101
+ }
102
+ /** Send a notification to one or many recipients. */
103
+ async send(notifiables, notification) {
104
+ const recipients = Array.isArray(notifiables) ? notifiables : [notifiables];
105
+ const run = async () => {
106
+ for (const recipient of recipients)
107
+ await this.deliver(recipient, notification);
108
+ };
109
+ if (notification.shouldQueue)
110
+ await dispatch(run);
111
+ else
112
+ await run();
113
+ }
114
+ }
115
+ /* --------------------------------- global --------------------------------- */
116
+ let notifier = new Notifier();
117
+ /** Register the default notifier used by `notify()`. */
118
+ export function setNotifier(instance) {
119
+ notifier = instance;
120
+ return notifier;
121
+ }
122
+ /** The default notifier instance (register channels on it). */
123
+ export function getNotifier() {
124
+ return notifier;
125
+ }
126
+ /** Send a notification to one or many notifiables via the default notifier. */
127
+ export function notify(notifiables, notification) {
128
+ return notifier.send(notifiables, notification);
129
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "type": "module",
5
5
  "description": "The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console.",
6
6
  "license": "MIT",
@@ -37,6 +37,7 @@
37
37
  "serve": "tsx bin/keel.ts serve",
38
38
  "dev": "tsx watch bin/keel.ts serve",
39
39
  "typecheck": "tsc --noEmit",
40
+ "typecheck:docs": "npm run build && tsc -p tsconfig.docs.json",
40
41
  "test": "node --import tsx --test tests/*.test.ts",
41
42
  "test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-exclude='tests/**' tests/*.test.ts",
42
43
  "build": "rm -rf dist && tsc -p tsconfig.build.json",