@zerotal/notifications 1.0.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.
@@ -0,0 +1,295 @@
1
+ import type { SQLInstance } from "@zerotal/orm";
2
+ import type { Notifiable } from "./types.ts";
3
+ import { NotificationError } from "./errors.ts";
4
+ import { notifiableType } from "./serialization.ts";
5
+ import type { Notification } from "./Notification.ts";
6
+
7
+ /** Build a TemplateStringsArray from a plain string array (mirrors ORM helper). */
8
+ function tpl(strings: string[]): TemplateStringsArray {
9
+ return Object.freeze(
10
+ Object.assign([...strings], { raw: [...strings] }),
11
+ ) as unknown as TemplateStringsArray;
12
+ }
13
+
14
+ /** Execute sql with a dynamically-constructed template (table name in static parts). */
15
+ async function run<T = Record<string, unknown>>(
16
+ conn: SQLInstance,
17
+ parts: string[],
18
+ ...values: unknown[]
19
+ ): Promise<T[]> {
20
+ return conn<T>(tpl(parts), ...values);
21
+ }
22
+
23
+ /** Allow only simple identifier characters — guards against injection in table names. */
24
+ function safeIdentifier(name: string): string {
25
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
26
+ throw new NotificationError(
27
+ `Invalid table name: '${name}'`,
28
+ "E_NOTIFICATION_INVALID_TABLE",
29
+ 500,
30
+ );
31
+ }
32
+ return name;
33
+ }
34
+
35
+ /** How many rows `all()` and `unread()` return when no limit is given. */
36
+ const DEFAULT_LIMIT = 100;
37
+
38
+ /** Paging and ordering options for an inbox query. */
39
+ export interface InboxQuery {
40
+ /** Maximum rows to return. Default: 100. Pass 0 for no limit. */
41
+ limit?: number;
42
+ /** Rows to skip, for paging. Default: 0. */
43
+ offset?: number;
44
+ }
45
+
46
+ /**
47
+ * Stores notifications in the database.
48
+ * The table and its indexes are created automatically on first use.
49
+ *
50
+ * Schema:
51
+ * id TEXT PRIMARY KEY — UUID
52
+ * notifiable_type TEXT NOT NULL — recipient's model class name
53
+ * notifiable_id TEXT NOT NULL — stringified notifiable id
54
+ * type TEXT NOT NULL — notification class name
55
+ * data TEXT NOT NULL — JSON-encoded toDatabase() result
56
+ * read_at TEXT — ISO timestamp when read; NULL = unread
57
+ * created_at TEXT NOT NULL — ISO timestamp
58
+ *
59
+ * Every read is scoped by `(notifiable_type, notifiable_id)`, not by id alone:
60
+ * ids are only unique within a model, so a `User#1` and a `Team#1` would
61
+ * otherwise share one inbox.
62
+ */
63
+ export class DatabaseChannel {
64
+ private readonly _t: string;
65
+ private _ready: Promise<void>;
66
+
67
+ constructor(
68
+ table: string,
69
+ private readonly _sql: SQLInstance,
70
+ ) {
71
+ this._t = safeIdentifier(table);
72
+ this._ready = this._ensureTable();
73
+ // The constructor cannot await, so a failure here would otherwise surface as
74
+ // an unhandled rejection before the first query re-awaits it.
75
+ this._ready.catch(() => undefined);
76
+ }
77
+
78
+ private async _ensureTable(): Promise<void> {
79
+ await run(this._sql, [
80
+ `CREATE TABLE IF NOT EXISTS ${this._t} (
81
+ id TEXT PRIMARY KEY,
82
+ notifiable_type TEXT NOT NULL,
83
+ notifiable_id TEXT NOT NULL,
84
+ type TEXT NOT NULL,
85
+ data TEXT NOT NULL,
86
+ read_at TEXT,
87
+ created_at TEXT NOT NULL
88
+ )`,
89
+ ]);
90
+
91
+ // The inbox is read far more often than it is written, and always by
92
+ // recipient — usually filtered to unread and ordered by recency.
93
+ await run(this._sql, [
94
+ `CREATE INDEX IF NOT EXISTS ${this._t}_notifiable_idx
95
+ ON ${this._t} (notifiable_type, notifiable_id, created_at)`,
96
+ ]);
97
+ await run(this._sql, [
98
+ `CREATE INDEX IF NOT EXISTS ${this._t}_unread_idx
99
+ ON ${this._t} (notifiable_type, notifiable_id, read_at)`,
100
+ ]);
101
+ }
102
+
103
+ async send(notifiable: Notifiable, notification: Notification): Promise<void> {
104
+ await this._ready;
105
+ const id = crypto.randomUUID();
106
+ const type = notification.constructor.name;
107
+ const data = JSON.stringify(await notification.toDatabase(notifiable));
108
+ const createdAt = new Date().toISOString();
109
+
110
+ // N values → N+1 string parts (tagged-template invariant).
111
+ await run(
112
+ this._sql,
113
+ [
114
+ `INSERT INTO ${this._t} (id, notifiable_type, notifiable_id, type, data, read_at, created_at) VALUES (`,
115
+ ", ", // between id and notifiable_type
116
+ ", ", // between notifiable_type and notifiable_id
117
+ ", ", // between notifiable_id and type
118
+ ", ", // between type and data
119
+ ", ", // between data and read_at
120
+ ", ", // between read_at and created_at
121
+ ")", // after created_at
122
+ ],
123
+ id,
124
+ notifiableType(notifiable),
125
+ String(notifiable.id),
126
+ type,
127
+ data,
128
+ null,
129
+ createdAt,
130
+ );
131
+ }
132
+
133
+ /** Mark a notification as read by its id. */
134
+ async markAsRead(id: string): Promise<void> {
135
+ await this._ready;
136
+ const readAt = new Date().toISOString();
137
+ await run(this._sql, [`UPDATE ${this._t} SET read_at = `, ` WHERE id = `, ""], readAt, id);
138
+ }
139
+
140
+ /** Mark a notification as unread by its id. */
141
+ async markAsUnread(id: string): Promise<void> {
142
+ await this._ready;
143
+ await run(this._sql, [`UPDATE ${this._t} SET read_at = NULL WHERE id = `, ""], id);
144
+ }
145
+
146
+ /** Return unread notifications for a notifiable, newest first. */
147
+ async unread(notifiable: Notifiable, query: InboxQuery = {}): Promise<NotificationRecord[]> {
148
+ await this._ready;
149
+ return run<NotificationRecord>(
150
+ this._sql,
151
+ [
152
+ `SELECT * FROM ${this._t} WHERE notifiable_type = `,
153
+ ` AND notifiable_id = `,
154
+ ` AND read_at IS NULL ORDER BY created_at DESC${this._paging(query)}`,
155
+ ],
156
+ notifiableType(notifiable),
157
+ String(notifiable.id),
158
+ );
159
+ }
160
+
161
+ /** Return notifications for a notifiable, newest first. */
162
+ async all(notifiable: Notifiable, query: InboxQuery = {}): Promise<NotificationRecord[]> {
163
+ await this._ready;
164
+ return run<NotificationRecord>(
165
+ this._sql,
166
+ [
167
+ `SELECT * FROM ${this._t} WHERE notifiable_type = `,
168
+ ` AND notifiable_id = `,
169
+ ` ORDER BY created_at DESC${this._paging(query)}`,
170
+ ],
171
+ notifiableType(notifiable),
172
+ String(notifiable.id),
173
+ );
174
+ }
175
+
176
+ /** How many unread notifications a notifiable has — for a badge, without loading rows. */
177
+ async unreadCount(notifiable: Notifiable): Promise<number> {
178
+ await this._ready;
179
+ const rows = await run<{ count: number }>(
180
+ this._sql,
181
+ [
182
+ `SELECT COUNT(*) AS count FROM ${this._t} WHERE notifiable_type = `,
183
+ ` AND notifiable_id = `,
184
+ ` AND read_at IS NULL`,
185
+ ],
186
+ notifiableType(notifiable),
187
+ String(notifiable.id),
188
+ );
189
+ return Number(rows[0]?.count ?? 0);
190
+ }
191
+
192
+ /** Mark every unread notification for a notifiable as read. */
193
+ async markAllAsRead(notifiable: Notifiable): Promise<void> {
194
+ await this._ready;
195
+ const readAt = new Date().toISOString();
196
+ await run(
197
+ this._sql,
198
+ [
199
+ `UPDATE ${this._t} SET read_at = `,
200
+ ` WHERE notifiable_type = `,
201
+ ` AND notifiable_id = `,
202
+ ` AND read_at IS NULL`,
203
+ ],
204
+ readAt,
205
+ notifiableType(notifiable),
206
+ String(notifiable.id),
207
+ );
208
+ }
209
+
210
+ /**
211
+ * The most recent stored notifications across every notifiable, newest first.
212
+ * Backs the admin console; use `all()` for one recipient's inbox.
213
+ */
214
+ async recent(limit = 100): Promise<NotificationRecord[]> {
215
+ await this._ready;
216
+ const capped = Math.max(1, Math.floor(limit));
217
+ return run<NotificationRecord>(this._sql, [
218
+ `SELECT * FROM ${this._t} ORDER BY created_at DESC LIMIT ${capped}`,
219
+ ]);
220
+ }
221
+
222
+ /** Delete one stored notification by id. */
223
+ async delete(id: string): Promise<void> {
224
+ await this._ready;
225
+ await run(this._sql, [`DELETE FROM ${this._t} WHERE id = `, ""], id);
226
+ }
227
+
228
+ /** Delete every stored notification for a notifiable. */
229
+ async clear(notifiable: Notifiable): Promise<void> {
230
+ await this._ready;
231
+ await run(
232
+ this._sql,
233
+ [`DELETE FROM ${this._t} WHERE notifiable_type = `, ` AND notifiable_id = `, ""],
234
+ notifiableType(notifiable),
235
+ String(notifiable.id),
236
+ );
237
+ }
238
+
239
+ /**
240
+ * Delete read notifications older than `days`, across every notifiable.
241
+ * Backs `notifications:prune` — an inbox table grows without bound otherwise.
242
+ *
243
+ * @param days - Age threshold in days.
244
+ * @param includeUnread - Also prune notifications never read. Default: false.
245
+ * @returns How many rows were deleted.
246
+ */
247
+ async prune(days: number, includeUnread = false): Promise<number> {
248
+ await this._ready;
249
+ const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
250
+
251
+ const before = await this._count();
252
+ if (includeUnread) {
253
+ await run(this._sql, [`DELETE FROM ${this._t} WHERE created_at < `, ""], cutoff);
254
+ } else {
255
+ await run(
256
+ this._sql,
257
+ [`DELETE FROM ${this._t} WHERE created_at < `, ` AND read_at IS NOT NULL`],
258
+ cutoff,
259
+ );
260
+ }
261
+ return before - (await this._count());
262
+ }
263
+
264
+ /** Total stored notifications, across every notifiable. */
265
+ async _count(): Promise<number> {
266
+ const rows = await run<{ count: number }>(this._sql, [
267
+ `SELECT COUNT(*) AS count FROM ${this._t}`,
268
+ ]);
269
+ return Number(rows[0]?.count ?? 0);
270
+ }
271
+
272
+ /**
273
+ * Render LIMIT/OFFSET into the static part of the template.
274
+ *
275
+ * Both are coerced to non-negative integers before interpolation — they are
276
+ * numbers by type, but this is a string concatenated into SQL, so the
277
+ * narrowing is enforced rather than assumed.
278
+ */
279
+ private _paging(query: InboxQuery): string {
280
+ const limit = query.limit === undefined ? DEFAULT_LIMIT : Math.max(0, Math.floor(query.limit));
281
+ const offset = Math.max(0, Math.floor(query.offset ?? 0));
282
+ if (limit === 0) return offset > 0 ? ` LIMIT -1 OFFSET ${offset}` : "";
283
+ return ` LIMIT ${limit}${offset > 0 ? ` OFFSET ${offset}` : ""}`;
284
+ }
285
+ }
286
+
287
+ export interface NotificationRecord {
288
+ id: string;
289
+ notifiable_type: string;
290
+ notifiable_id: string;
291
+ type: string;
292
+ data: string;
293
+ read_at: string | null;
294
+ created_at: string;
295
+ }
@@ -0,0 +1,93 @@
1
+ import { FrameworkEvents } from "@zerotal/core";
2
+ import { MessageSent, MessageFailed } from "./events.ts";
3
+ import type { Notifiable, MailConfigShape } from "./types.ts";
4
+ import type { Notification } from "./Notification.ts";
5
+ import type { MailAddress, MailDriver } from "./drivers/MailDriver.ts";
6
+ import { LogDriver } from "./drivers/LogDriver.ts";
7
+ import { SmtpDriver } from "./drivers/SmtpDriver.ts";
8
+ import { ResendDriver } from "./drivers/ResendDriver.ts";
9
+
10
+ /**
11
+ * The `mail` notification channel — renders a notification's `toMail()` MailMessage and
12
+ * delivers it through the configured driver (log / SMTP / Resend). Built-in to
13
+ * @zerotal/notifications, so the mail channel works without any extra package.
14
+ */
15
+ export class MailChannel {
16
+ private readonly _driver: MailDriver;
17
+ private readonly _from: MailAddress;
18
+
19
+ constructor(config: MailConfigShape) {
20
+ this._from = config.from;
21
+ this._driver = MailChannel._buildDriver(config);
22
+ }
23
+
24
+ private static _buildDriver(config: MailConfigShape): MailDriver {
25
+ switch (config.driver) {
26
+ case "smtp":
27
+ return new SmtpDriver(
28
+ config.smtp.host,
29
+ config.smtp.port,
30
+ config.smtp.username,
31
+ config.smtp.password,
32
+ config.smtp.secure,
33
+ {
34
+ ...(config.smtp.allowInsecureAuth !== undefined
35
+ ? { allowInsecureAuth: config.smtp.allowInsecureAuth }
36
+ : {}),
37
+ ...(config.smtp.rejectUnauthorized !== undefined
38
+ ? { rejectUnauthorized: config.smtp.rejectUnauthorized }
39
+ : {}),
40
+ ...(config.smtp.timeoutMs !== undefined ? { timeoutMs: config.smtp.timeoutMs } : {}),
41
+ ...(config.smtp.clientName !== undefined ? { clientName: config.smtp.clientName } : {}),
42
+ },
43
+ );
44
+ case "resend":
45
+ return new ResendDriver(config.resend.apiKey);
46
+ case "log":
47
+ default:
48
+ return new LogDriver(config.log.channel);
49
+ }
50
+ }
51
+
52
+ async send(notifiable: Notifiable, notification: Notification): Promise<void> {
53
+ const message = await notification.toMail(notifiable);
54
+ // The recipient defaults to the notifiable's email unless the MailMessage set
55
+ // one, or the notifiable routes 'mail' somewhere else (e.g. a billing address).
56
+ const address = notifiable.routeNotificationFor?.("mail") ?? notifiable.email;
57
+ const fallbackTo: MailAddress[] = address
58
+ ? [{ address, ...(notifiable.name ? { name: notifiable.name } : {}) }]
59
+ : [];
60
+ const payload = message.toPayload(this._from, fallbackTo);
61
+
62
+ // Mail telemetry — these FrameworkEvents are what feeds the monitor's Mail tab.
63
+ // The notification class is the "mailer" identity; recipients/subject/body come
64
+ // from the resolved payload so the panel mirrors exactly what was put on the wire.
65
+ const className = notification.constructor?.name ?? "Notification";
66
+ const to = payload.to.map((a) => a.address);
67
+ const startedAt = performance.now();
68
+ try {
69
+ await this._driver.send(payload);
70
+ FrameworkEvents.emit(
71
+ new MessageSent(
72
+ className,
73
+ to,
74
+ payload.subject,
75
+ payload.html ?? payload.text ?? "",
76
+ performance.now() - startedAt,
77
+ false,
78
+ ),
79
+ );
80
+ } catch (error) {
81
+ FrameworkEvents.emit(
82
+ new MessageFailed(
83
+ className,
84
+ to,
85
+ payload.subject,
86
+ performance.now() - startedAt,
87
+ error instanceof Error ? error.message : String(error),
88
+ ),
89
+ );
90
+ throw error;
91
+ }
92
+ }
93
+ }
@@ -0,0 +1,95 @@
1
+ // ── Notifiable ────────────────────────────────────────────────────────────────
2
+ //
3
+ // Notifiable model mixin — compose it
4
+ // onto any model that should receive notifications (typically User):
5
+ //
6
+ // import { BaseModelWith } from "@zerotal/orm";
7
+ // import { Notifiable } from "@zerotal/notifications";
8
+ //
9
+ // export class User extends BaseModelWith(Notifiable) {
10
+ // @column() email!: string;
11
+ // }
12
+ //
13
+ // await user.notify(new OrderShipped(order)); // send now, across all channels
14
+ // await user.notifyLater(new OrderShipped(order)); // queue for background delivery
15
+ // const unread = await user.unreadNotifications(); // database-channel inbox
16
+ // await user.markNotificationsAsRead();
17
+ //
18
+ // The model only needs an `id` (and `email`/`phone` for the mail/sms channels) to
19
+ // satisfy the Notifiable contract. These methods delegate to the same
20
+ // NotificationManager the `Notify` facade uses, so behaviour is identical — this is
21
+ // purely an ergonomic, object-oriented entry point.
22
+ //
23
+ // `Notifiable` is both the mixin (value) and the contract (type) — a function+interface
24
+ // merge — so `import { Notifiable }` composes and `import type { Notifiable }` annotates.
25
+
26
+ import { currentApp } from "@zerotal/core";
27
+ import type { Notifiable as NotifiableContract } from "./types.ts";
28
+ import type { Notification } from "./Notification.ts";
29
+ import type { NotificationManager } from "./NotificationManager.ts";
30
+ import type { InboxQuery, NotificationRecord } from "./DatabaseChannel.ts";
31
+
32
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic mixin base bound
33
+ type Constructor<T = object> = new (...args: any[]) => T;
34
+
35
+ /** Resolve the live NotificationManager from the container (bound by NotificationProvider). */
36
+ function _manager(): NotificationManager {
37
+ return currentApp().container.makeSync("notifications") as NotificationManager;
38
+ }
39
+
40
+ /**
41
+ * The one cast this module makes: a mixin's `this` is typed by its generic base,
42
+ * which cannot promise the `id`/`email` fields the Notifiable contract reads —
43
+ * those come from the composing model's columns. The manager reads them at
44
+ * delivery time, so a model composed without an `id` fails there, not here.
45
+ */
46
+ function asNotifiable(model: object): Notifiable {
47
+ return model as unknown as Notifiable;
48
+ }
49
+
50
+ export function Notifiable<TBase extends Constructor>(Base: TBase) {
51
+ return class extends Base {
52
+ /** Send a notification to this model now, across the notification's channels. */
53
+ async notify(notification: Notification): Promise<void> {
54
+ await _manager().send(asNotifiable(this), notification);
55
+ }
56
+
57
+ /** Queue a notification for background delivery via @zerotal/queue. */
58
+ async notifyLater(notification: Notification): Promise<void> {
59
+ await _manager().queue(asNotifiable(this), notification);
60
+ }
61
+
62
+ /** All stored (database-channel) notifications for this model, newest first. */
63
+ async notifications(query?: InboxQuery): Promise<NotificationRecord[]> {
64
+ return _manager().database.all(asNotifiable(this), query);
65
+ }
66
+
67
+ /** Unread stored notifications for this model, newest first. */
68
+ async unreadNotifications(query?: InboxQuery): Promise<NotificationRecord[]> {
69
+ return _manager().database.unread(asNotifiable(this), query);
70
+ }
71
+
72
+ /** How many unread notifications this model has — for a badge, without loading rows. */
73
+ async unreadNotificationCount(): Promise<number> {
74
+ return _manager().database.unreadCount(asNotifiable(this));
75
+ }
76
+
77
+ /** Mark all of this model's unread notifications as read. */
78
+ async markNotificationsAsRead(): Promise<void> {
79
+ await _manager().database.markAllAsRead(asNotifiable(this));
80
+ }
81
+
82
+ /** Delete every stored notification for this model. */
83
+ async clearNotifications(): Promise<void> {
84
+ await _manager().database.clear(asNotifiable(this));
85
+ }
86
+ };
87
+ }
88
+
89
+ /**
90
+ * A model composed with the {@link Notifiable} mixin. Declared here so `Notifiable` is both a
91
+ * value (the mixin) and a type (the contract) — it merges with the function above. Extends the
92
+ * base notifiable contract, so instances pass anywhere a notifiable is expected.
93
+ */
94
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- the empty body is the point: it merges the name with the mixin function above
95
+ export interface Notifiable extends NotifiableContract {}
@@ -0,0 +1,114 @@
1
+ import { NotificationContractError } from "./errors.ts";
2
+ import type { Notifiable } from "./types.ts";
3
+ import type { MailMessage } from "./messages/MailMessage.ts";
4
+ import type { SlackMessage } from "./SlackChannel.ts";
5
+ import type { SmsMessage } from "./SmsChannel.ts";
6
+ import type { BroadcastMessage } from "./BroadcastMessage.ts";
7
+
8
+ /**
9
+ * Base class for all notifications.
10
+ *
11
+ * Extend this class, declare which channels() to use, then implement
12
+ * the corresponding to*() method for each declared channel.
13
+ *
14
+ * Supported channels:
15
+ * 'mail' — implement toMail() → returns a MailMessage
16
+ * 'database' — implement toDatabase() → returns a plain object
17
+ * 'slack' — implement toSlack() → returns a SlackMessage
18
+ * 'sms' — implement toSms() → returns an SmsMessage
19
+ *
20
+ * @example
21
+ * export class OrderShippedNotification extends Notification {
22
+ * constructor(private order: Order) { super(); }
23
+ *
24
+ * channels() { return ['mail', 'slack', 'database']; }
25
+ *
26
+ * toMail(notifiable: Notifiable): MailMessage {
27
+ * return new MailMessage()
28
+ * .subject(`Order #${this.order.id} shipped`)
29
+ * .line('Your order is on its way.')
30
+ * .action('Track', `https://app.test/orders/${this.order.id}`);
31
+ * }
32
+ *
33
+ * toSlack(_notifiable: Notifiable): SlackMessage | Promise<SlackMessage> {
34
+ * return {
35
+ * webhookUrl: 'https://hooks.slack.com/services/...',
36
+ * text: `Order #${this.order.id} was shipped!`,
37
+ * };
38
+ * }
39
+ *
40
+ * toDatabase() {
41
+ * return { orderId: this.order.id, status: 'shipped' };
42
+ * }
43
+ * }
44
+ */
45
+ export abstract class Notification {
46
+ /**
47
+ * Declare which channels to deliver on: `'mail'`, `'database'`, `'slack'`,
48
+ * `'sms'`, `'broadcast'`, or any channel registered with `extend()`.
49
+ *
50
+ * The recipient is passed in, so routing can follow their preferences.
51
+ * Ignore the parameter when every recipient gets the same channels.
52
+ *
53
+ * @example
54
+ * channels(user: Notifiable) {
55
+ * return user.wantsSms ? ["database", "sms"] : ["database", "mail"];
56
+ * }
57
+ */
58
+ abstract channels(notifiable?: Notifiable): string[];
59
+
60
+ toMail(_notifiable: Notifiable): MailMessage | Promise<MailMessage> {
61
+ throw new NotificationContractError(this.constructor.name, "toMail", "mail");
62
+ }
63
+
64
+ toDatabase(_notifiable: Notifiable): Record<string, unknown> | Promise<Record<string, unknown>> {
65
+ throw new NotificationContractError(this.constructor.name, "toDatabase", "database");
66
+ }
67
+
68
+ toSlack(_notifiable: Notifiable): SlackMessage | Promise<SlackMessage> {
69
+ throw new NotificationContractError(this.constructor.name, "toSlack", "slack");
70
+ }
71
+
72
+ toSms(_notifiable: Notifiable): SmsMessage | Promise<SmsMessage> {
73
+ throw new NotificationContractError(this.constructor.name, "toSms", "sms");
74
+ }
75
+
76
+ /**
77
+ * The real-time representation for the 'broadcast' channel. Return a `BroadcastMessage`
78
+ * (or a plain data object). Required when `channels()` includes `'broadcast'`.
79
+ */
80
+ toBroadcast(
81
+ _notifiable: Notifiable,
82
+ ):
83
+ | BroadcastMessage
84
+ | Record<string, unknown>
85
+ | Promise<BroadcastMessage | Record<string, unknown>> {
86
+ throw new NotificationContractError(this.constructor.name, "toBroadcast", "broadcast");
87
+ }
88
+
89
+ /** The wire `type` of a broadcast notification (lets clients distinguish kinds). Default: class name. */
90
+ broadcastType(): string {
91
+ return this.constructor.name;
92
+ }
93
+
94
+ /**
95
+ * Serialize this notification's state so it can be queued.
96
+ *
97
+ * The default copies own enumerable fields, which covers the usual case of a
98
+ * constructor assigning plain values. Override it when the notification holds
99
+ * something JSON cannot carry — a model instance, a Date-keyed Map, a closure
100
+ * — and pair the override with a matching `static fromPayload()`.
101
+ *
102
+ * @example
103
+ * override payload() {
104
+ * return { orderId: this.order.id };
105
+ * }
106
+ *
107
+ * static override async fromPayload(data: Record<string, unknown>) {
108
+ * return new OrderShipped(await Order.find(data.orderId as number));
109
+ * }
110
+ */
111
+ payload(): Record<string, unknown> {
112
+ return { ...this } as Record<string, unknown>;
113
+ }
114
+ }