@zerotal/notifications 1.9.0 → 1.11.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
@@ -8,6 +8,83 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.11.0] — 2026-08-31
12
+
13
+ ### Changed
14
+
15
+ - **The database channel is built on first use, not at construction.** Registering the
16
+ provider used to open a `DatabaseChannel` against `database.table` — default
17
+ `notifications` — and create that table, whether or not any notification ever routed
18
+ there. An app that never uses the channel now never touches the table, and the
19
+ manager no longer resolves a database connection while it is being constructed.
20
+
21
+ ### Added
22
+
23
+ - **A doctor check for a `notifications` table that is not the framework's.**
24
+ `notifications` is a very ordinary name for a table an app already owns, and one
25
+ app's was a completely different shape — `household_id`, `title`, `body`,
26
+ `action_url`, with an in-app inbox reading it. The only thing between the
27
+ framework's channel and a wrong-shaped insert into that inbox was that no
28
+ notification's `channels()` happened to return `"database"`. A convention holding
29
+ back data corruption is a convention that will lose, so `zt doctor` now says so
30
+ before the first notification does. Silent when the table does not exist yet, which
31
+ is every fresh checkout.
32
+
33
+ ## [1.10.0] — 2026-08-30
34
+
35
+ ### Fixed
36
+
37
+ - **SMTP submission on port 587 works.** The STARTTLS upgrade completed its handshake
38
+ and then sent nothing: `upgradeTLS()` returns the new socket while the handshake is
39
+ still in flight, and a write issued in that window is **dropped** — not buffered,
40
+ not an error, gone. The `EHLO` that has to follow STARTTLS went into that gap on
41
+ every send, so the server waited for a command that never came while the client
42
+ waited for a reply that never came, until the read timed out.
43
+
44
+ Everything either side looked healthy, which is what made it expensive: the server
45
+ logged a good TLS session and then a connection lost. Port 465 was unaffected,
46
+ because implicit TLS finishes before `Bun.connect()` resolves and there is no window
47
+ to write into — so mail worked on the port nobody documents, and configuring the 587
48
+ that every provider _does_ document produced silence. No error, no bounce, no log
49
+ line, and password resets that never arrived. `upgradeTLS()` now resolves only once
50
+ the handshake has completed, bounded by the reply timeout.
51
+
52
+ - **TLS certificates are actually verified, on both transports.** `rejectUnauthorized`
53
+ is not enforced by the runtime: Bun reports `authorized: true` for a self-signed
54
+ certificate on `Bun.connect()` and on `upgradeTLS()` alike, whether the flag is set
55
+ or not, and puts the real reason in `authorizationError` beside it. Trusting the
56
+ flag made TLS decorative — the connection was encrypted, and would have accepted
57
+ that encryption from anyone in the network path, which is the one property TLS
58
+ exists to provide. The driver now reads the handshake result itself and fails
59
+ closed; `rejectUnauthorized: false` still accepts a self-signed relay, deliberately.
60
+
61
+ - **The server greeting cannot be lost to a race.** `Bun.connect()` resolves after the
62
+ socket opens, and the 220 can arrive before the `await` returns — the data callback
63
+ reached for a connection object that did not exist yet. Early bytes are now buffered
64
+ and replayed.
65
+
66
+ ### Added
67
+
68
+ - **A doctor check for a production `mail.driver` of `"log"`.** It is the default, it
69
+ is right in development, and it is the quietest possible production failure: every
70
+ message is written to a log file, nothing reports a problem, and password resets
71
+ stop arriving until someone asks why. `zt doctor` reports it and `zt deploy` refuses
72
+ on it.
73
+
74
+ Two tiers, because the same value means two things. An app with a real
75
+ `mail.from.address` configured `log` by accident, or had it knocked back, and its
76
+ mail is going nowhere — that fails. An app still on the placeholder address probably
77
+ sends no mail at all, and failing its deploy over a setting it never touched would be
78
+ the framework inventing a problem — that warns.
79
+
80
+ ### Testing
81
+
82
+ - `SmtpSubmission.test.ts` runs the whole 587 flow against a real STARTTLS peer.
83
+ The existing tests could not: they upgrade into a peer that is already speaking TLS,
84
+ because Bun cannot be a STARTTLS _server_ — and the bug lived in the one step that
85
+ arrangement skips. The fixture peer runs under Node, which can wrap a connected
86
+ socket as a TLS server. The limitation was the harness's, never the client's.
87
+
11
88
  ## [1.9.0] — 2026-08-29
12
89
 
13
90
  ### Documented
package/api-surface.md CHANGED
@@ -375,8 +375,8 @@ interface NotificationChannel = {
375
375
  interface NotificationConfigShape = {
376
376
  database: { table: string;}
377
377
  mail: MailConfigShape
378
- slack?: { webhook?: string;}
379
- sms?: SmsConfigShape
378
+ slack?: { webhook?: string;} | undefined
379
+ sms?: SmsConfigShape | undefined
380
380
  }
381
381
 
382
382
  interface NotificationRecord = {
@@ -415,8 +415,8 @@ interface SlackMessage = {
415
415
 
416
416
  interface SmsConfigShape = {
417
417
  driver: 'twilio' | 'vonage'
418
- twilio?: TwilioConfigShape
419
- vonage?: VonageConfigShape
418
+ twilio?: TwilioConfigShape | undefined
419
+ vonage?: VonageConfigShape | undefined
420
420
  }
421
421
 
422
422
  interface SmsMessage = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/notifications",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -30,12 +30,12 @@
30
30
  "typecheck": "tsc --noEmit"
31
31
  },
32
32
  "dependencies": {
33
- "@zerotal/core": "1.9.0",
34
- "@zerotal/orm": "1.9.0",
35
- "@zerotal/queue": "1.9.0"
33
+ "@zerotal/core": "1.11.0",
34
+ "@zerotal/orm": "1.11.0",
35
+ "@zerotal/queue": "1.11.0"
36
36
  },
37
37
  "devDependencies": {
38
- "@zerotal/broadcasting": "1.9.0",
38
+ "@zerotal/broadcasting": "1.11.0",
39
39
  "typescript": "^5.8.0"
40
40
  },
41
41
  "description": "Multi-channel notifications for Zerotal — mail (SMTP/Resend), database, broadcast, Slack, and SMS.",
@@ -23,13 +23,29 @@ export class NotificationManager {
23
23
  private readonly _resolvers = new Map<string, ChannelResolver>();
24
24
  /** Resolved channel instances, memoized per name. */
25
25
  private readonly _channels = new Map<string, NotificationChannel>();
26
- private readonly _db: DatabaseChannel;
26
+ /**
27
+ * The database channel, built on first use rather than at construction.
28
+ *
29
+ * It used to be built in the constructor, which meant every app that registered
30
+ * this provider opened a `DatabaseChannel` against `database.table` — default
31
+ * `notifications` — whether or not any notification ever routed there. `notifications`
32
+ * is a very ordinary name for a table an app already owns, and one app's is a
33
+ * completely different shape: `household_id`, `user_id`, `title`, `body`,
34
+ * `action_url`, with an in-app inbox reading it. The only thing standing between
35
+ * the framework's channel and a wrong-shaped insert into that inbox was that no
36
+ * notification's `channels()` happened to return `"database"`. A convention holding
37
+ * back data corruption is a convention that will lose.
38
+ *
39
+ * Building it lazily does not make the collision safe — `notificationsTableCheck`
40
+ * is what reports that — but it means an app that never uses the channel never
41
+ * touches the table, and it stops the manager resolving a database connection at
42
+ * construction time.
43
+ */
44
+ private _dbChannel: DatabaseChannel | undefined;
27
45
 
28
46
  constructor(private readonly config: NotificationConfigShape) {
29
- this._db = new DatabaseChannel(config.database.table, _getConnection());
30
-
31
47
  this._resolvers.set("mail", () => new MailChannel(config.mail));
32
- this._resolvers.set("database", () => this._db);
48
+ this._resolvers.set("database", () => this.database);
33
49
  this._resolvers.set("broadcast", () => new BroadcastChannel());
34
50
 
35
51
  this._resolvers.set("slack", () => {
@@ -241,7 +257,8 @@ export class NotificationManager {
241
257
 
242
258
  /** Expose the database channel for direct queries (unread, markAsRead). */
243
259
  get database(): DatabaseChannel {
244
- return this._db;
260
+ this._dbChannel ??= new DatabaseChannel(this.config.database.table, _getConnection());
261
+ return this._dbChannel;
245
262
  }
246
263
 
247
264
  private async _dispatch(
package/src/doctor.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The deployment check `zt doctor` and `zt deploy` run against the mail config.
3
+ *
4
+ * `mail.driver` defaults to `"log"`, which is right in development and is the
5
+ * quietest possible production failure: every message is written to a log file and
6
+ * nothing anywhere reports a problem. No error, no bounce, no send — just password
7
+ * resets that never arrive, found days later by someone asking why.
8
+ *
9
+ * @module
10
+ */
11
+ import { deployEnv, isProdLike } from "@zerotal/core";
12
+ import type { Application } from "@zerotal/core";
13
+ import type { DoctorCheck, DoctorCheckResult } from "@zerotal/core";
14
+ import { NotificationConfig } from "./config.ts";
15
+ import type { NotificationConfigShape } from "./types.ts";
16
+
17
+ /**
18
+ * The from-address a project that never configured mail still has.
19
+ *
20
+ * It is what tells "this app does not send mail" apart from "this app sends mail
21
+ * and its driver got knocked back to the default", which are the same setting and
22
+ * very different problems. Anyone who set up mail changed this.
23
+ */
24
+ const PLACEHOLDER_FROM = "hello@example.com";
25
+
26
+ /** The loaded notification config, or the defaults when the app declares none. */
27
+ function _mailConfig(app: Application): NotificationConfigShape["mail"] | undefined {
28
+ try {
29
+ const config = app.container.makeSync("config") as {
30
+ get<T>(key: string, fallback: T): T;
31
+ };
32
+ return config.get<NotificationConfigShape>("notifications", NotificationConfig()).mail;
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Refuse a production release whose mail goes to a log file.
40
+ *
41
+ * Two tiers, because the same value means two things. An app with a real
42
+ * from-address configured `log` by accident — or had it knocked back — and its mail
43
+ * is silently going nowhere: that fails. An app still on the placeholder address
44
+ * probably sends no mail at all, and failing its deploy over a setting it never
45
+ * touched would be the framework inventing a problem: that warns.
46
+ */
47
+ export const mailDriverCheck: DoctorCheck = {
48
+ id: "mail-driver",
49
+ label: "Mail driver",
50
+ run(app: Application): DoctorCheckResult {
51
+ const mail = _mailConfig(app);
52
+ if (!mail) return { status: "ok", message: "no mail config to check" };
53
+
54
+ if (mail.driver !== "log") {
55
+ return { status: "ok", message: `${mail.driver}` };
56
+ }
57
+
58
+ if (!isProdLike(deployEnv())) {
59
+ return { status: "ok", message: "log (fine outside production)" };
60
+ }
61
+
62
+ const configured = mail.from.address !== PLACEHOLDER_FROM;
63
+ return {
64
+ status: configured ? "fail" : "warn",
65
+ message: configured
66
+ ? `mail.driver is "log" in production, but mail.from.address is set to ` +
67
+ `${mail.from.address} — every message this app sends is being written to a ` +
68
+ `log file and delivered to nobody.`
69
+ : `mail.driver is "log" in production. Nothing is delivered. ` +
70
+ `(mail.from.address is still the default, so this app may not send mail at all.)`,
71
+ fix: 'Set mail.driver to "smtp" or "resend" in config/notifications.ts, and its credentials with it.',
72
+ };
73
+ },
74
+ };
@@ -19,6 +19,12 @@ const DEFAULT_TIMEOUT_MS = 30_000;
19
19
  * Every reply is parsed and its status code checked, so a rejected recipient or a
20
20
  * failed authentication raises {@link SmtpResponseError} instead of being
21
21
  * mistaken for a successful send.
22
+ *
23
+ * Certificate verification is enforced here rather than by the runtime. Bun does
24
+ * not honour `rejectUnauthorized` on either `Bun.connect()` or `upgradeTLS()` — it
25
+ * reports the peer as authorized and puts the real reason beside it — so both
26
+ * transports check the handshake result themselves and fail closed. See
27
+ * `_certificateFailure`.
22
28
  */
23
29
  export class SmtpDriver implements MailDriver {
24
30
  constructor(
@@ -142,6 +148,34 @@ interface SmtpReply {
142
148
  text: string;
143
149
  }
144
150
 
151
+ /**
152
+ * Whether a completed handshake should be treated as a failure.
153
+ *
154
+ * **`rejectUnauthorized` is not enforced by the runtime.** Bun reports
155
+ * `authorized: true` for a self-signed certificate whether the flag is on or off,
156
+ * on `Bun.connect()` and on `upgradeTLS()` alike, and hands back the real reason in
157
+ * `authorizationError` beside it. Trusting the flag made TLS decorative on both
158
+ * paths: the connection was encrypted and would have accepted that encryption from
159
+ * anyone in the network path, which is the property TLS exists to provide.
160
+ *
161
+ * So the error is the signal and the flag is not. Verification is the framework's
162
+ * to enforce, and it fails closed.
163
+ *
164
+ * @param authorized - What the runtime claims about the peer.
165
+ * @param authorizationError - Why it would not have been authorized, if anything.
166
+ * @param rejectUnauthorized - Whether this connection asked for verification.
167
+ * @returns The error to fail with, or `undefined` to accept the peer.
168
+ */
169
+ function _certificateFailure(
170
+ authorized: boolean,
171
+ authorizationError: Error | null | undefined,
172
+ rejectUnauthorized: boolean,
173
+ ): Error | undefined {
174
+ if (!rejectUnauthorized) return undefined;
175
+ if (authorized && !authorizationError) return undefined;
176
+ return authorizationError ?? new Error("the server's certificate could not be verified");
177
+ }
178
+
145
179
  /**
146
180
  * A single SMTP session — owns the socket, buffers incoming bytes, and hands
147
181
  * back one complete reply at a time.
@@ -210,9 +244,26 @@ export class SmtpConnection {
210
244
  port: number,
211
245
  options: { tls: boolean; rejectUnauthorized: boolean; timeoutMs: number },
212
246
  ): Promise<SmtpConnection> {
213
- let conn!: SmtpConnection;
247
+ // The session, once there is one. A box rather than a bare `let`, because the
248
+ // socket callbacks below are installed before it exists and have to see it
249
+ // appear — `Bun.connect()` resolves after the socket is open, so the server's
250
+ // 220 greeting can beat the `await` that produces it.
251
+ const session: { conn?: SmtpConnection } = {};
252
+
253
+ // Bytes that arrive in that window. Dropping the greeting deadlocks the session
254
+ // on its own first read.
255
+ const early: Uint8Array[] = [];
256
+
257
+ let settle: (error?: Error) => void = () => {};
258
+ const verified = options.tls
259
+ ? new Promise<void>((resolve, reject) => {
260
+ settle = (error) => (error ? reject(error) : resolve());
261
+ })
262
+ : undefined;
263
+
264
+ let socket: import("bun").Socket<undefined>;
214
265
  try {
215
- const socket = await Bun.connect<undefined>({
266
+ socket = await Bun.connect<undefined>({
216
267
  hostname: host,
217
268
  port,
218
269
  ...(options.tls
@@ -220,14 +271,23 @@ export class SmtpConnection {
220
271
  : {}),
221
272
  socket: {
222
273
  // Generation 0: the connection as opened. An upgrade supersedes it.
223
- data: (_s, data) => conn._onData(data, 0),
224
- error: (_s, err) => conn._onError(err, 0),
225
- close: () => conn._onClose(0),
274
+ data: (_s, data) => {
275
+ if (session.conn) session.conn._onData(data, 0);
276
+ else early.push(data);
277
+ },
278
+ error: (_s, err) => {
279
+ settle(err);
280
+ session.conn?._onError(err, 0);
281
+ },
282
+ close: () => {
283
+ settle(new Error("the server closed the connection during the TLS handshake"));
284
+ session.conn?._onClose(0);
285
+ },
286
+ handshake: (_s, authorized, authorizationError) =>
287
+ settle(_certificateFailure(authorized, authorizationError, options.rejectUnauthorized)),
226
288
  open: () => {},
227
289
  },
228
290
  });
229
- conn = new SmtpConnection(socket, host, port, options.timeoutMs, options.tls);
230
- return conn;
231
291
  } catch (error) {
232
292
  throw new SmtpConnectionError(
233
293
  `Could not connect to SMTP server ${host}:${port} — ${
@@ -235,20 +295,78 @@ export class SmtpConnection {
235
295
  }`,
236
296
  );
237
297
  }
298
+
299
+ const conn = new SmtpConnection(socket, host, port, options.timeoutMs, options.tls);
300
+ session.conn = conn;
301
+ for (const chunk of early) conn._onData(chunk, 0);
302
+
303
+ // Implicit TLS (465) verifies here, for the same reason STARTTLS verifies in
304
+ // `upgradeTLS`: `rejectUnauthorized` is not enforced by the runtime, so a
305
+ // connection that asked for a verified certificate has to check that it got one.
306
+ if (verified) {
307
+ try {
308
+ await conn._withTimeout(verified, `completing the TLS handshake with ${host}`);
309
+ } catch (error) {
310
+ conn.close();
311
+ throw new SmtpConnectionError(
312
+ `TLS connection to ${host}:${port} failed — ${
313
+ error instanceof Error ? error.message : String(error)
314
+ }`,
315
+ );
316
+ }
317
+ }
318
+
319
+ return conn;
238
320
  }
239
321
 
240
- /** Upgrade a plaintext connection to TLS after a 220 response to STARTTLS. */
322
+ /**
323
+ * Upgrade a plaintext connection to TLS after a 220 response to STARTTLS.
324
+ *
325
+ * Resolves once the handshake has actually completed, and that is the whole
326
+ * point of it being async. `upgradeTLS()` returns the new socket immediately,
327
+ * while the handshake is still in flight, and **a write issued in that window is
328
+ * dropped** — not buffered and flushed, not an error, just gone. The `EHLO` that
329
+ * has to follow STARTTLS was written into that gap, so the server sat waiting
330
+ * for a command that was never sent while the client sat waiting for a reply
331
+ * that was never coming, until the read timed out thirty seconds later.
332
+ *
333
+ * What made it expensive to place is that everything either side looks right.
334
+ * The handshake completes; the server logs a healthy TLS session and then a
335
+ * connection lost. Port 465 is unaffected, because implicit TLS finishes its
336
+ * handshake before `Bun.connect()` resolves and there is no window to write
337
+ * into. So mail worked, on the port that is not the one every provider
338
+ * documents, and configuring the documented 587 produced silence — no error, no
339
+ * bounce, no log line, just password resets that never arrived.
340
+ *
341
+ * @param host - Hostname, used as the TLS server name.
342
+ * @param rejectUnauthorized - Refuse a certificate that does not verify.
343
+ * @throws {@link SmtpConnectionError} When the upgrade or the handshake fails.
344
+ */
241
345
  async upgradeTLS(host: string, rejectUnauthorized: boolean): Promise<void> {
242
346
  try {
243
347
  // Bumped before the call, so the plaintext socket's callbacks are already
244
348
  // stale by the time the handshake can deliver its first record.
245
349
  const generation = ++this._generation;
350
+
351
+ let settle!: (error?: Error) => void;
352
+ const handshake = new Promise<void>((resolve, reject) => {
353
+ settle = (error) => (error ? reject(error) : resolve());
354
+ });
355
+
246
356
  const [, tls] = this._socket.upgradeTLS<undefined>({
247
357
  tls: { rejectUnauthorized, serverName: host },
248
358
  socket: {
249
359
  data: (_s, data) => this._onData(data, generation),
250
- error: (_s, err) => this._onError(err, generation),
251
- close: () => this._onClose(generation),
360
+ error: (_s, err) => {
361
+ settle(err);
362
+ this._onError(err, generation);
363
+ },
364
+ close: () => {
365
+ settle(new Error("the server closed the connection during the TLS handshake"));
366
+ this._onClose(generation);
367
+ },
368
+ handshake: (_s, authorized, authorizationError) =>
369
+ settle(_certificateFailure(authorized, authorizationError, rejectUnauthorized)),
252
370
  open: () => {},
253
371
  },
254
372
  });
@@ -258,6 +376,8 @@ export class SmtpConnection {
258
376
  // belongs to the discarded plaintext stream.
259
377
  this._buffer = "";
260
378
  this._replies = [];
379
+
380
+ await this._withTimeout(handshake, `completing the TLS handshake with ${host}`);
261
381
  } catch (error) {
262
382
  throw new SmtpConnectionError(
263
383
  `STARTTLS upgrade failed for ${host} — ${
@@ -267,6 +387,33 @@ export class SmtpConnection {
267
387
  }
268
388
  }
269
389
 
390
+ /**
391
+ * Reject `work` if it has not settled within the session's reply timeout.
392
+ *
393
+ * A handshake that never completes and never errors is a hang with no reply to
394
+ * time out on, so the read timeout that covers every other step does not cover
395
+ * this one.
396
+ *
397
+ * @param work - The promise to bound.
398
+ * @param what - Named in the timeout message.
399
+ */
400
+ private async _withTimeout<T>(work: Promise<T>, what: string): Promise<T> {
401
+ let timer: ReturnType<typeof setTimeout> | undefined;
402
+ try {
403
+ return await Promise.race([
404
+ work,
405
+ new Promise<never>((_resolve, reject) => {
406
+ timer = setTimeout(
407
+ () => reject(new Error(`timed out after ${this._timeoutMs}ms ${what}`)),
408
+ this._timeoutMs,
409
+ );
410
+ }),
411
+ ]);
412
+ } finally {
413
+ if (timer) clearTimeout(timer);
414
+ }
415
+ }
416
+
270
417
  /** Write a command plus its CRLF terminator. */
271
418
  send(command: string): void {
272
419
  this.write(`${command}\r\n`);
@@ -6,6 +6,8 @@ import { NotificationConfig } from "../config.ts";
6
6
  import { installNotificationsObservability } from "../observability.ts";
7
7
  import { installNotificationStats } from "../stats.ts";
8
8
  import { installNotificationsAdmin } from "../admin.ts";
9
+ import { mailDriverCheck } from "../doctor.ts";
10
+ import { notificationsTableCheck } from "../tableDoctor.ts";
9
11
 
10
12
  declare module "@zerotal/core" {
11
13
  interface ContainerBindings {
@@ -35,6 +37,11 @@ export class NotificationProvider extends ServiceProvider {
35
37
  // The admin panel binds its contribution surface during registration, so the
36
38
  // booting phase reaches it regardless of provider order.
37
39
  installNotificationsAdmin(this.app);
40
+
41
+ // `zt doctor`, and through it `zt deploy`: mail going to a log file in
42
+ // production is the quietest failure this package has.
43
+ this.app.registerDoctorCheck?.(mailDriverCheck);
44
+ this.app.registerDoctorCheck?.(notificationsTableCheck);
38
45
  }
39
46
 
40
47
  override async onBooted(): Promise<void> {
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The check for a `notifications` table that is not the framework's.
3
+ *
4
+ * `database.table` defaults to `notifications`, which is a very ordinary name for a
5
+ * table an app already owns. One app's was a completely different shape —
6
+ * `household_id`, `user_id`, `title`, `body`, `action_url`, with an in-app inbox
7
+ * reading it — and the only thing standing between the framework's channel and a
8
+ * wrong-shaped insert into that inbox was that no notification's `channels()`
9
+ * happened to return `"database"`.
10
+ *
11
+ * A convention holding back data corruption is a convention that will lose. This
12
+ * says so at `zt doctor` time, before the first notification does.
13
+ *
14
+ * @module
15
+ */
16
+ import type { Application } from "@zerotal/core";
17
+ import type { DoctorCheck, DoctorCheckResult } from "@zerotal/core";
18
+ import { NotificationConfig } from "./config.ts";
19
+ import type { NotificationConfigShape } from "./types.ts";
20
+
21
+ /** Columns `DatabaseChannel` writes on every insert. */
22
+ const REQUIRED = [
23
+ "id",
24
+ "notifiable_type",
25
+ "notifiable_id",
26
+ "type",
27
+ "data",
28
+ "read_at",
29
+ "created_at",
30
+ ] as const;
31
+
32
+ /** The table's column names, or `null` when it does not exist / cannot be read. */
33
+ async function _columnsOf(sql: unknown, table: string): Promise<Set<string> | null> {
34
+ const query = sql as (strings: TemplateStringsArray, ...v: unknown[]) => Promise<unknown[]>;
35
+ try {
36
+ // `pragma_table_info` is the table-valued form, so the name binds as a parameter
37
+ // rather than being concatenated into the statement.
38
+ const rows = (await query`SELECT name FROM pragma_table_info(${table})`) as {
39
+ name?: string;
40
+ }[];
41
+ if (rows.length === 0) return null;
42
+ return new Set(rows.map((r) => String(r.name)));
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Report a configured notifications table whose shape is not the one the channel
50
+ * writes.
51
+ *
52
+ * Silent when the table does not exist yet — that is the normal state before the
53
+ * migration runs, and warning about it would fire on every fresh checkout.
54
+ */
55
+ export const notificationsTableCheck: DoctorCheck = {
56
+ id: "notifications-table",
57
+ label: "Notifications table",
58
+ async run(app: Application): Promise<DoctorCheckResult> {
59
+ let table = "notifications";
60
+ let sql: unknown;
61
+ try {
62
+ const config = app.container.makeSync("config") as { get<T>(k: string, d: T): T };
63
+ table = config.get<NotificationConfigShape>("notifications", NotificationConfig()).database
64
+ .table;
65
+ sql = app.container.makeSync("db");
66
+ } catch {
67
+ return { status: "ok", message: "no database connection to check" };
68
+ }
69
+ if (!sql) return { status: "ok", message: "no database connection to check" };
70
+
71
+ const columns = await _columnsOf(sql, table);
72
+ if (columns === null) {
73
+ // Not created yet, or not SQLite. Either way there is nothing to compare.
74
+ return { status: "ok", message: `${table} not present yet` };
75
+ }
76
+
77
+ const missing = REQUIRED.filter((name) => !columns.has(name));
78
+ if (missing.length === 0) return { status: "ok", message: `${table} matches` };
79
+
80
+ return {
81
+ status: "fail",
82
+ message:
83
+ `The table "${table}" exists but is missing ${missing.join(", ")} — so it is not the ` +
84
+ `table @zerotal/notifications writes, and a notification routed to the "database" ` +
85
+ `channel would insert the wrong shape into it.`,
86
+ fix:
87
+ `Point the framework elsewhere with database.table in config/notifications.ts ` +
88
+ `(e.g. "framework_notifications"), or run the package's migration to create the ` +
89
+ `table it expects.`,
90
+ };
91
+ },
92
+ };
package/src/types.ts CHANGED
@@ -98,8 +98,8 @@ export interface VonageConfigShape {
98
98
 
99
99
  export interface SmsConfigShape {
100
100
  driver: "twilio" | "vonage";
101
- twilio?: TwilioConfigShape;
102
- vonage?: VonageConfigShape;
101
+ twilio?: TwilioConfigShape | undefined;
102
+ vonage?: VonageConfigShape | undefined;
103
103
  }
104
104
 
105
105
  export interface NotificationConfigShape {
@@ -117,13 +117,15 @@ export interface NotificationConfigShape {
117
117
  * The per-notification toSlack() method provides the webhookUrl, so this is
118
118
  * only needed if you want a global fallback URL.
119
119
  */
120
- slack?: {
121
- /** Default webhook URL — can be overridden per-notification in toSlack(). */
122
- webhook?: string;
123
- };
120
+ slack?:
121
+ | {
122
+ /** Default webhook URL — can be overridden per-notification in toSlack(). */
123
+ webhook?: string;
124
+ }
125
+ | undefined;
124
126
  /**
125
127
  * SMS provider settings (Twilio or Vonage).
126
128
  * Required when any notification uses the 'sms' channel.
127
129
  */
128
- sms?: SmsConfigShape;
130
+ sms?: SmsConfigShape | undefined;
129
131
  }