@ultimat3/notify 26.0.0 → 26.1.1
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/CLAUDE.md +2 -2
- package/README.md +54 -7
- package/package.json +5 -5
- package/src/channel-push.ts +87 -0
- package/src/index.ts +2 -0
package/CLAUDE.md
CHANGED
|
@@ -12,7 +12,7 @@ returns a `job`, which is the whole design.
|
|
|
12
12
|
| Never | Because |
|
|
13
13
|
|---|---|
|
|
14
14
|
| `@ultimat3/mail` | Same tier (4). A `Mailer` is declared **structurally** in `channel-mail.ts` — one method, no dependency — exactly as `PgExecutor` (`@ultimat3/core`) mirrors `@ultimat3/db`. Moving `notify` to tier 5 to legalise the import would put notifications above `render`, `pwa` and `ui` for one channel's transport, and would then need a `cli → notify` edge the way `cli → scraping` does. |
|
|
15
|
-
| `@ultimat3/render`, `@ultimat3/ui`, `@ultimat3/ai`, `@ultimat3/mcp`, `@ultimat3/pwa` | Same tier. A notification has no view — the inbox is rendered by the app's page out of `InboxStore.list`, which is data. |
|
|
15
|
+
| `@ultimat3/render`, `@ultimat3/ui`, `@ultimat3/ai`, `@ultimat3/mcp`, `@ultimat3/pwa` | Same tier. A notification has no view — the inbox is rendered by the app's page out of `InboxStore.list`, which is data. `pushChannel` takes a STRUCTURAL `Pusher` (`channel-push.ts`), exactly as `mailChannel` takes a `Mailer`: `@ultimat3/pwa`'s `webPush()` satisfies it, and `NotifyPushMessage` mirrors pwa's `PushNotification` field for field — catalog keys, never text. |
|
|
16
16
|
| `@ultimat3/db`, `@ultimat3/entity` | Legal downward, and deliberately not taken. The three tables this package owns are **DDL constants applied by the boot** (`SQL_NOTIFY_DELIVERIES_TABLE`, `SQL_NOTIFY_INBOX_TABLE`, `SQL_NOTIFY_DIGESTS_TABLE`), the way `x_jobs`, `x_idempotency` and `x_audit` are — never `entity()` declarations, which would put framework tables in the app's migration graph and make an app's `x db gen` responsible for them. The Postgres stores take a structural `PgExecutor`, imported as a **type** from `@ultimat3/core` (its one declaration; `bun run flight-copies` refuses another, `X_HELPER_COPY`). |
|
|
17
17
|
| `@ultimat3/policy` | A notification is addressed to exactly one person and the audience is `recipients`. There is no row a policy could decide about here. The **inbox read surface** is where authz belongs, and that is the app's query. |
|
|
18
18
|
|
|
@@ -87,7 +87,7 @@ so an `open` checkpointed with duplicates by an earlier version is repaired on r
|
|
|
87
87
|
| `fanout.ts` | the run body: audience, wait order, gates, delivery |
|
|
88
88
|
| `fanout-digest.ts` · `fanout-walk.ts` | the digest branch, and the state both halves share |
|
|
89
89
|
| `attempt.ts` | claim → send → settle, once |
|
|
90
|
-
| `channel.ts` · `channel-in-app.ts` · `channel-mail.ts` | the seam and the
|
|
90
|
+
| `channel.ts` · `channel-in-app.ts` · `channel-mail.ts` · `channel-push.ts` | the seam and the three shipped channels |
|
|
91
91
|
| `ledger.ts` · `ledger-pg.ts` | the delivery ledger, memory and Postgres |
|
|
92
92
|
| `inbox.ts` · `inbox-pg.ts` | the in-app inbox, memory and Postgres |
|
|
93
93
|
| `preferences.ts` · `digest.ts` | the gate and the window, as seams |
|
package/README.md
CHANGED
|
@@ -170,15 +170,15 @@ A read row ages from `read_at` and an unread one from `created_at` — ageing a
|
|
|
170
170
|
```ts
|
|
171
171
|
import { bulkChannel, deliveryChannel } from '@ultimat3/notify';
|
|
172
172
|
|
|
173
|
-
// The app's
|
|
174
|
-
declare const
|
|
175
|
-
send(
|
|
173
|
+
// The app's SMS vendor and its Slack webhook — a channel is where an SDK belongs.
|
|
174
|
+
declare const smsService: {
|
|
175
|
+
send(to: string, payload: unknown, init: { signal: AbortSignal }): Promise<void>;
|
|
176
176
|
};
|
|
177
177
|
declare const SLACK_URL: string;
|
|
178
178
|
|
|
179
179
|
// One call per recipient. The retry unit is one address.
|
|
180
|
-
export const
|
|
181
|
-
await
|
|
180
|
+
export const sms = deliveryChannel('sms', async ({ recipient, event, signal }) => {
|
|
181
|
+
await smsService.send(recipient.to ?? '', event.params, { signal });
|
|
182
182
|
});
|
|
183
183
|
|
|
184
184
|
// ONE call for the whole audience — a Slack post, a webhook, a digest to an ops channel.
|
|
@@ -191,8 +191,55 @@ export const slack = bulkChannel('slack', async ({ recipients, event, signal })
|
|
|
191
191
|
});
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
-
`inAppChannel()
|
|
195
|
-
one method, no dependency on `@ultimat3/mail`, which is the same
|
|
194
|
+
`inAppChannel()`, `mailChannel({ mailer })` and `pushChannel({ pusher, message })` ship. `mailChannel`
|
|
195
|
+
takes a **structural** `Mailer` — one method, no dependency on `@ultimat3/mail`, which is the same
|
|
196
|
+
tier — and `pushChannel` a structural `Pusher`, for the same reason about `@ultimat3/pwa`.
|
|
197
|
+
|
|
198
|
+
### Push
|
|
199
|
+
|
|
200
|
+
`pushChannel` sends one notification to **every device the recipient subscribed**, each rendered in
|
|
201
|
+
the locale that browser subscribed with. `@ultimat3/pwa`'s `webPush()` is the pusher: RFC 8291
|
|
202
|
+
encryption and RFC 8292 (VAPID) signing on WebCrypto, over the runtime the boot installs once
|
|
203
|
+
`app.config.ts` says `pwa: { push: true, vapid: { subject } }`.
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
import { notifier, pushChannel, t } from '@ultimat3/notify';
|
|
207
|
+
import { webPush } from '@ultimat3/pwa';
|
|
208
|
+
|
|
209
|
+
export const commentPushed = notifier({
|
|
210
|
+
name: 'post.commented.push',
|
|
211
|
+
input: t.object({ postId: t.uuid, author: t.string }),
|
|
212
|
+
tenant: 'none',
|
|
213
|
+
key: (params) => `comment-push:${params.postId}`,
|
|
214
|
+
deliver: [
|
|
215
|
+
{
|
|
216
|
+
channel: pushChannel<{ postId: string; author: string }>({
|
|
217
|
+
pusher: webPush(),
|
|
218
|
+
// Catalog KEYS, never text. `tag` is the device's collapse key — set it: a retried delivery
|
|
219
|
+
// re-sends to the devices that already got it, and the tag makes that a replacement.
|
|
220
|
+
message: ({ event }) => ({
|
|
221
|
+
titleKey: 'push.comment.title',
|
|
222
|
+
bodyKey: 'push.comment.body',
|
|
223
|
+
params: { who: event.params.author },
|
|
224
|
+
url: `/posts/${event.params.postId}`,
|
|
225
|
+
tag: `post:${event.params.postId}`,
|
|
226
|
+
}),
|
|
227
|
+
}),
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
});
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
| `NotifyPushMessage` | |
|
|
234
|
+
|---|---|
|
|
235
|
+
| `titleKey`, `bodyKey`, `params`, `actions[].titleKey` | catalog keys, rendered per subscription |
|
|
236
|
+
| `url` | a path on this app — a tap opens it, anything else opens the root |
|
|
237
|
+
| `tag`, `renotify`, `requireInteraction`, `icon`, `badge` | the Notifications API's, as the worker shows them; `renotify` needs a `tag` |
|
|
238
|
+
| `ttlSeconds`, `urgency`, `topic` | RFC 8030 — how long the push service holds it, how hard it wakes the device, and its collapse key while the device is offline |
|
|
239
|
+
|
|
240
|
+
A recipient with no subscription is not a failure: the delivery settles, as `mailChannel` settles an
|
|
241
|
+
addressless recipient. A push service that answers 429 or 5xx throws `X_PWA_PUSH_FAILED` after every
|
|
242
|
+
other device was tried, so the job retries; 404/410 deletes the subscription.
|
|
196
243
|
|
|
197
244
|
## The inbox
|
|
198
245
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/notify",
|
|
3
|
-
"version": "26.
|
|
3
|
+
"version": "26.1.1",
|
|
4
4
|
"description": "Notifications: one declaration, many channels — fan-out, preference gate, digest window, delivery ledger, in-app inbox",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"test": "bun test"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@ultimat3/core": "26.
|
|
39
|
-
"@ultimat3/jobs": "26.
|
|
40
|
-
"@ultimat3/schema": "26.
|
|
41
|
-
"@ultimat3/time": "26.
|
|
38
|
+
"@ultimat3/core": "26.1.1",
|
|
39
|
+
"@ultimat3/jobs": "26.1.1",
|
|
40
|
+
"@ultimat3/schema": "26.1.1",
|
|
41
|
+
"@ultimat3/time": "26.1.1"
|
|
42
42
|
}
|
|
43
43
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Web Push as a channel, over a STRUCTURAL pusher rather than an import of @ultimat3/pwa — the tier
|
|
2
|
+
// argument `channel-mail.ts` makes about `@ultimat3/mail`, word for word: `pwa` is tier 4 and so is
|
|
3
|
+
// this package. `@ultimat3/pwa`'s `webPush()` satisfies `Pusher` in one call, and reaches EVERY
|
|
4
|
+
// device the recipient subscribed, each in its own locale:
|
|
5
|
+
//
|
|
6
|
+
// pushChannel<CommentPosted>({
|
|
7
|
+
// pusher: webPush(),
|
|
8
|
+
// message: ({ event }) => ({
|
|
9
|
+
// titleKey: 'push.comment.title',
|
|
10
|
+
// bodyKey: 'push.comment.body',
|
|
11
|
+
// params: { who: event.params.commenter },
|
|
12
|
+
// url: `/posts/${event.params.postId}`,
|
|
13
|
+
// tag: `post:${event.params.postId}`,
|
|
14
|
+
// }),
|
|
15
|
+
// })
|
|
16
|
+
|
|
17
|
+
import type { DeliveryArgs, NotifyChannel } from './channel';
|
|
18
|
+
import { deliveryChannel } from './channel';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What one push says, as catalog KEYS — never text. The pusher renders them per subscription, in
|
|
22
|
+
* the locale that browser subscribed with, because the server sending a push has no request to
|
|
23
|
+
* read a language off and a notification in the wrong language is a real bug.
|
|
24
|
+
*/
|
|
25
|
+
export interface NotifyPushMessage {
|
|
26
|
+
readonly titleKey: string;
|
|
27
|
+
readonly bodyKey: string;
|
|
28
|
+
readonly params?: Readonly<Record<string, string | number>> | undefined;
|
|
29
|
+
/** A PATH on this app, opened by a tap. Another origin opens the app's root instead. */
|
|
30
|
+
readonly url: string;
|
|
31
|
+
/**
|
|
32
|
+
* Collapse key ON THE DEVICE: a newer notification with this tag replaces the older one. Set it —
|
|
33
|
+
* a retried delivery re-sends to the devices that already got it, and a tag is what makes that
|
|
34
|
+
* a replacement rather than a second notification.
|
|
35
|
+
*/
|
|
36
|
+
readonly tag?: string | undefined;
|
|
37
|
+
/** Alert again when `tag` replaces one. Dropped, with a warning, without a tag. */
|
|
38
|
+
readonly renotify?: boolean | undefined;
|
|
39
|
+
readonly requireInteraction?: boolean | undefined;
|
|
40
|
+
readonly icon?: string | undefined;
|
|
41
|
+
readonly badge?: string | undefined;
|
|
42
|
+
readonly actions?: readonly { readonly action: string; readonly titleKey: string }[] | undefined;
|
|
43
|
+
/** Seconds the push service holds it for an offline device. Default one day. */
|
|
44
|
+
readonly ttlSeconds?: number | undefined;
|
|
45
|
+
readonly urgency?: 'very-low' | 'low' | 'normal' | 'high' | undefined;
|
|
46
|
+
/** Collapse key AT THE PUSH SERVICE, for a device that is offline. */
|
|
47
|
+
readonly topic?: string | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The seam. `@ultimat3/pwa`'s `webPush()` is the framework's; a vendor SDK fits it too. */
|
|
51
|
+
export interface Pusher {
|
|
52
|
+
send(push: {
|
|
53
|
+
/** The recipient's id — the actor whose subscriptions receive it. */
|
|
54
|
+
readonly to: string;
|
|
55
|
+
readonly message: NotifyPushMessage;
|
|
56
|
+
readonly signal: AbortSignal;
|
|
57
|
+
}): Promise<void> | void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const PUSH_CHANNEL = 'push';
|
|
61
|
+
|
|
62
|
+
export interface PushChannelOptions<Params> {
|
|
63
|
+
readonly pusher: Pusher;
|
|
64
|
+
/**
|
|
65
|
+
* The message for this delivery. `batch` is the whole digest window when the delivery is
|
|
66
|
+
* digested — render a count from `batch.length`, not one push per event.
|
|
67
|
+
*/
|
|
68
|
+
readonly message: (args: DeliveryArgs<Params>) => NotifyPushMessage;
|
|
69
|
+
readonly name?: string | undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* One delivery per recipient, to every device they subscribed. A recipient with no subscription is
|
|
74
|
+
* NOT a failure — the pusher sends to nobody and the delivery settles, exactly as `mailChannel`
|
|
75
|
+
* settles an addressless recipient: a retry would find the same empty list.
|
|
76
|
+
*/
|
|
77
|
+
export function pushChannel<Params = unknown>(
|
|
78
|
+
options: PushChannelOptions<Params>,
|
|
79
|
+
): NotifyChannel<Params> {
|
|
80
|
+
return deliveryChannel<Params>(options.name ?? PUSH_CHANNEL, async (args) => {
|
|
81
|
+
await options.pusher.send({
|
|
82
|
+
to: args.recipient.id,
|
|
83
|
+
message: options.message(args),
|
|
84
|
+
signal: args.signal,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,8 @@ export type { InAppChannelOptions } from './channel-in-app';
|
|
|
21
21
|
export { IN_APP_CHANNEL, inAppChannel } from './channel-in-app';
|
|
22
22
|
export type { MailChannelOptions, Mailer, NotifyMail } from './channel-mail';
|
|
23
23
|
export { MAIL_CHANNEL, mailChannel } from './channel-mail';
|
|
24
|
+
export type { NotifyPushMessage, PushChannelOptions, Pusher } from './channel-push';
|
|
25
|
+
export { PUSH_CHANNEL, pushChannel } from './channel-push';
|
|
24
26
|
export type {
|
|
25
27
|
DigestAppend,
|
|
26
28
|
DigestBucket,
|