@zerotal/notifications 1.10.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,28 @@ 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
+
11
33
  ## [1.10.0] — 2026-08-30
12
34
 
13
35
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/notifications",
3
- "version": "1.10.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.10.0",
34
- "@zerotal/orm": "1.10.0",
35
- "@zerotal/queue": "1.10.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.10.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(
@@ -7,6 +7,7 @@ import { installNotificationsObservability } from "../observability.ts";
7
7
  import { installNotificationStats } from "../stats.ts";
8
8
  import { installNotificationsAdmin } from "../admin.ts";
9
9
  import { mailDriverCheck } from "../doctor.ts";
10
+ import { notificationsTableCheck } from "../tableDoctor.ts";
10
11
 
11
12
  declare module "@zerotal/core" {
12
13
  interface ContainerBindings {
@@ -40,6 +41,7 @@ export class NotificationProvider extends ServiceProvider {
40
41
  // `zt doctor`, and through it `zt deploy`: mail going to a log file in
41
42
  // production is the quietest failure this package has.
42
43
  this.app.registerDoctorCheck?.(mailDriverCheck);
44
+ this.app.registerDoctorCheck?.(notificationsTableCheck);
43
45
  }
44
46
 
45
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
+ };