@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 +22 -0
- package/package.json +5 -5
- package/src/NotificationManager.ts +22 -5
- package/src/provider/NotificationProvider.ts +2 -0
- package/src/tableDoctor.ts +92 -0
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.
|
|
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.
|
|
34
|
-
"@zerotal/orm": "1.
|
|
35
|
-
"@zerotal/queue": "1.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
};
|