@ultimat3/notify 25.2.0 → 26.1.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/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 two shipped channels |
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 push vendor and its Slack webhook — a channel is where an SDK belongs.
174
- declare const pushService: {
175
- send(token: string, payload: unknown, init: { signal: AbortSignal }): Promise<void>;
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 push = deliveryChannel('push', async ({ recipient, event, signal }) => {
181
- await pushService.send(recipient.to ?? '', event.params, { signal });
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()` and `mailChannel({ mailer })` ship. `mailChannel` takes a **structural** `Mailer` —
195
- one method, no dependency on `@ultimat3/mail`, which is the same tier.
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": "25.2.0",
3
+ "version": "26.1.0",
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": "25.2.0",
39
- "@ultimat3/jobs": "25.2.0",
40
- "@ultimat3/schema": "25.2.0",
41
- "@ultimat3/time": "25.2.0"
38
+ "@ultimat3/core": "26.1.0",
39
+ "@ultimat3/jobs": "26.1.0",
40
+ "@ultimat3/schema": "26.1.0",
41
+ "@ultimat3/time": "26.1.0"
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/errors.ts CHANGED
@@ -170,13 +170,13 @@ export class NotifyDeliveryFailedError extends UltimateError {
170
170
  super({
171
171
  code: 'X_NOTIFY_DELIVERY_FAILED',
172
172
  cause: `channel "${input.channel}" of notifier "${input.notifier}" failed for ${String(input.recipients)} recipient(s): ${renderThrowable(input.cause)}`,
173
- // `x jobs ls` first, and never `x jobs show` handed a notifier NAME: that command takes a job id
173
+ // `x jobs list` first, and never `x jobs show` handed a notifier NAME: that command takes a job id
174
174
  // positional and resolves it through `inspectJob`, which answers `X_JOB_UNKNOWN` for
175
175
  // anything else — so a notifier NAME made the one command this refusal printed fail every
176
176
  // time it was run. A `fix:` that fails is worse than none, because the reader spends their
177
177
  // trust on it before finding out. The two-step shape is what `wiki/Error-Codes.md` already
178
178
  // documents for this code.
179
- fix: `x jobs ls --json # find the run, then: x jobs show <id> --json — the failing step is deliver:${input.channel}`,
179
+ fix: `x jobs list --json # find the run, then: x jobs show <id> --json — the failing step is deliver:${input.channel}`,
180
180
  meta: { notifier: input.notifier, channel: input.channel },
181
181
  });
182
182
  }
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,