@ultimat3/notify 12.0.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 +93 -0
- package/LICENSE +21 -0
- package/README.md +216 -0
- package/package.json +43 -0
- package/src/attempt.ts +48 -0
- package/src/channel-in-app.ts +34 -0
- package/src/channel-mail.ts +75 -0
- package/src/channel.ts +76 -0
- package/src/digest.ts +100 -0
- package/src/errors.ts +183 -0
- package/src/fanout-digest.ts +113 -0
- package/src/fanout-walk.ts +33 -0
- package/src/fanout.ts +173 -0
- package/src/inbox-pg.ts +162 -0
- package/src/inbox.ts +131 -0
- package/src/index.ts +104 -0
- package/src/ledger-pg.ts +122 -0
- package/src/ledger.ts +136 -0
- package/src/notification.ts +63 -0
- package/src/notifier.ts +160 -0
- package/src/plan.ts +105 -0
- package/src/preferences.ts +72 -0
- package/src/stores.ts +80 -0
- package/src/type-pins.ts +53 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# @ultimat3/notify — boundary
|
|
2
|
+
|
|
3
|
+
Tier 4. May import tiers 0-3. Never sideways, never upward.
|
|
4
|
+
|
|
5
|
+
**Its real imports are `core`, `schema` (tier 0), `time` (tier 1) and `jobs` (tier 3), so tier 4 is
|
|
6
|
+
its floor** — one above the highest tier it reaches. It sits AT that floor, so it has no
|
|
7
|
+
`FLOOR_ABOVE` row and needs none. The import that fixes it there is `@ultimat3/jobs`: `notifier()`
|
|
8
|
+
returns a `job`, which is the whole design.
|
|
9
|
+
|
|
10
|
+
## What it may never import, and why
|
|
11
|
+
|
|
12
|
+
| Never | Because |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `@ultimat3/mail` | Same tier (4). A `Mailer` is declared **structurally** in `channel-mail.ts` — one method, no dependency — exactly as `@ultimat3/action`'s `PgExecutor` 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. |
|
|
16
|
+
| `@ultimat3/db`, `@ultimat3/entity` | Legal downward, and deliberately not taken. The two tables this package owns are **DDL constants applied by the boot** (`SQL_NOTIFY_DELIVERIES_TABLE`, `SQL_NOTIFY_INBOX_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/jobs` rather than re-declared, because a third copy of a one-method interface is a third place to look. |
|
|
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
|
+
|
|
19
|
+
## What ships, and what must never
|
|
20
|
+
|
|
21
|
+
`docs/idea/20-large-app-readiness.md` scores this row **Ship, as a job factory** — *"channel fan-out,
|
|
22
|
+
preference gate, digest window, delivery ledger, in-app inbox"* — and is equally explicit about the
|
|
23
|
+
other half: **the notification taxonomy and `quietHours` must never ship.**
|
|
24
|
+
|
|
25
|
+
So `PreferenceStore` is an interface with two trivial implementations and nothing else. This package
|
|
26
|
+
ships the **gate**; the app ships what the gate reads — which notification types exist, what a
|
|
27
|
+
"marketing" one is, and what hours are quiet in which recipient's zone. `DigestWindow.window` is a
|
|
28
|
+
**rolling duration** and never a calendar time, for the same reason plus one more: no date is
|
|
29
|
+
computed here without an explicit IANA zone, so a window this package could get wrong is a window it
|
|
30
|
+
does not offer. "Every day at 09:00 local" is a `task()` and the app's schedule.
|
|
31
|
+
|
|
32
|
+
## `notifier()` is a job, not a ninth primitive
|
|
33
|
+
|
|
34
|
+
A notification is durable background work with an input schema, an idempotency key, a retry policy
|
|
35
|
+
and a queue — the definition of a `job`. `notifier()` therefore *returns* one, and inherits
|
|
36
|
+
`.enqueue()`, `.as()`, the worker's cancellation, the dead-letter path, `x jobs show` and its
|
|
37
|
+
manifest row. Its row is in `PRIMITIVE_FACTORIES` (`packages/core/src/registrar.ts`);
|
|
38
|
+
`scripts/primitive-factories.test.ts` fails if the export and the row disagree.
|
|
39
|
+
|
|
40
|
+
## At-least-once, in two layers
|
|
41
|
+
|
|
42
|
+
A job body runs **before** its checkpoint lands, so both layers are load-bearing:
|
|
43
|
+
|
|
44
|
+
1. `step.run('deliver:<channel>:<recipient>')` — an ordinary retry replays a completed send from the
|
|
45
|
+
step store and does not call the channel.
|
|
46
|
+
2. `DeliveryLedger.claim` — an atomic claim keyed `(notifier, key, channel, coalesce(recipient, ''))`,
|
|
47
|
+
taken before the send and settled after it. A claim that already reads `sent` answers `false`, so
|
|
48
|
+
an attempt that lost its step history entirely still does not send twice. **The `coalesce` is
|
|
49
|
+
load-bearing, not decoration**: a bulk channel claims one row for the whole audience with a NULL
|
|
50
|
+
recipient, and NULLs are DISTINCT in a plain unique index — so without it a bulk claim is
|
|
51
|
+
claimable without bound and every replay re-sends the whole audience. `ledger-pg.ts` spells it in
|
|
52
|
+
the index and again in `SQL_NOTIFY_CLAIM`'s `on conflict`, and `errors.test.ts` pins all three
|
|
53
|
+
spellings against each other.
|
|
54
|
+
|
|
55
|
+
`attempt.ts` is the only place a send happens, so there is exactly one implementation of that order.
|
|
56
|
+
The **one at-most-once seam** is a digest flush: `DigestStore.drain` empties the window, and a
|
|
57
|
+
process killed between the drain and its checkpoint loses that batch. It is stated in `digest.ts`
|
|
58
|
+
rather than hidden, and a durable store can close it.
|
|
59
|
+
|
|
60
|
+
## Files
|
|
61
|
+
|
|
62
|
+
| Module | Owns |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `notifier.ts` | the declaration, and the `job()` it builds |
|
|
65
|
+
| `plan.ts` | the declaration types, and the resolved plan every duration is normalised into |
|
|
66
|
+
| `fanout.ts` | the run body: audience, wait order, gates, delivery |
|
|
67
|
+
| `fanout-digest.ts` · `fanout-walk.ts` | the digest branch, and the state both halves share |
|
|
68
|
+
| `attempt.ts` | claim → send → settle, once |
|
|
69
|
+
| `channel.ts` · `channel-in-app.ts` · `channel-mail.ts` | the seam and the two shipped channels |
|
|
70
|
+
| `ledger.ts` · `ledger-pg.ts` | the delivery ledger, memory and Postgres |
|
|
71
|
+
| `inbox.ts` · `inbox-pg.ts` | the in-app inbox, memory and Postgres |
|
|
72
|
+
| `preferences.ts` · `digest.ts` | the gate and the window, as seams |
|
|
73
|
+
| `stores.ts` | the one installer for all four |
|
|
74
|
+
| `errors.ts` | this package's `X_NOTIFY_*` codes and their titles |
|
|
75
|
+
|
|
76
|
+
One entry point, deliberately: every module runs on the server, so there is no browser half to split
|
|
77
|
+
off.
|
|
78
|
+
|
|
79
|
+
| Rule | Detail |
|
|
80
|
+
|---|---|
|
|
81
|
+
| Exports | `src/index.ts`, explicit, no `export *` |
|
|
82
|
+
| Errors | `src/errors.ts`, subclass `UltimateError`, never a bare `Error` |
|
|
83
|
+
| Files | one responsibility each, < 200 lines, tests beside the source |
|
|
84
|
+
|
|
85
|
+
## Homeless work this package cannot do
|
|
86
|
+
|
|
87
|
+
`packages/cli/src/dev-queue.ts`'s `applySchema` installs the jobs, idempotency, rate-limit and
|
|
88
|
+
auth-limit tables. **It does not install `x_notify_deliveries` or `x_notify_inbox`**, so an app using
|
|
89
|
+
the Postgres stores runs that DDL itself until they join the list — the same gap
|
|
90
|
+
`SQL_AUDIT_TABLE` has.
|
|
91
|
+
|
|
92
|
+
Commands: `bun test packages/notify/src`, `bun run boundaries`,
|
|
93
|
+
`bunx biome check packages/notify`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 developerz.ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# @ultimat3/notify
|
|
2
|
+
|
|
3
|
+
Notifications as one declaration: channel fan-out, a preference gate, a digest window, a delivery
|
|
4
|
+
ledger and an in-app inbox.
|
|
5
|
+
|
|
6
|
+
`notifier()` is a **job factory** — it returns a `JobHandle`, so a notification inherits retry, the
|
|
7
|
+
dead-letter path, cancellation, `x jobs show` and its manifest row. It is not a ninth primitive.
|
|
8
|
+
|
|
9
|
+
## The declaration
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import type { MailDefinition } from '@ultimat3/mail';
|
|
13
|
+
import { send } from '@ultimat3/mail';
|
|
14
|
+
import type { Recipient } from '@ultimat3/notify';
|
|
15
|
+
import { inAppChannel, mailChannel, notifier, t } from '@ultimat3/notify';
|
|
16
|
+
|
|
17
|
+
interface CommentPosted {
|
|
18
|
+
readonly postId: string;
|
|
19
|
+
readonly orgId: string;
|
|
20
|
+
readonly author: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The app's own template, and the app's own repository — `./mail.ts` and a service its boot
|
|
24
|
+
// installed. Neither ships here: this package knows a mailer and an audience, not your tables.
|
|
25
|
+
declare const postCommented: MailDefinition<CommentPosted>;
|
|
26
|
+
declare const posts: {
|
|
27
|
+
subscribers(postId: string, signal: AbortSignal): Promise<readonly Recipient[]>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const commentPosted = notifier({
|
|
31
|
+
name: 'post.commented',
|
|
32
|
+
input: t.object({ postId: t.uuid, orgId: t.uuid, author: t.string }),
|
|
33
|
+
// Required, exactly as on job(): the org this run acts under.
|
|
34
|
+
tenant: (params) => params.orgId,
|
|
35
|
+
// Required, exactly as job().idempotencyKey is — and it is the SAME value: the queue's dedupe
|
|
36
|
+
// key and the delivery ledger's event column ask one question.
|
|
37
|
+
key: (params) => `comment:${params.postId}`,
|
|
38
|
+
// Resolved on the worker, inside a durable step. Omit it and every enqueue names its own.
|
|
39
|
+
// `ctx.signal` is the run's cancellation — hand it to anything that can block.
|
|
40
|
+
recipients: ({ input, ctx }) => posts.subscribers(input.postId, ctx.signal),
|
|
41
|
+
deliver: [
|
|
42
|
+
// Fires immediately: no wait.
|
|
43
|
+
{ channel: inAppChannel() },
|
|
44
|
+
// Waits ten minutes, then re-reads the condition. A subscriber who muted the thread in
|
|
45
|
+
// minute three gets nothing.
|
|
46
|
+
{
|
|
47
|
+
// The type argument is what types `mail.batch[n].params`: `NoInfer` on `deliver` stops the
|
|
48
|
+
// channel from deciding what the params are — the schema above already did.
|
|
49
|
+
channel: mailChannel<CommentPosted>({
|
|
50
|
+
mailer: {
|
|
51
|
+
send: async (mail) => {
|
|
52
|
+
// `batch` is never empty: one event for an immediate send, the whole window for a
|
|
53
|
+
// digest. Oldest first, so the newest is the last.
|
|
54
|
+
for (const event of mail.batch) {
|
|
55
|
+
await send(postCommented, event.params, { to: mail.to, locale: mail.locale ?? 'en' });
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
wait: '10m',
|
|
61
|
+
unless: ({ event }) => event.params.author === 'system',
|
|
62
|
+
// Coalesce every comment on this post for one person into one email per hour.
|
|
63
|
+
digest: { window: '1h', group: (event) => event.params.postId },
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Enqueued like any other job:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import type { JobHandle } from '@ultimat3/jobs';
|
|
73
|
+
import type { NotifyPayload } from '@ultimat3/notify';
|
|
74
|
+
|
|
75
|
+
// The declaration above: `notifier()` returns a JobHandle, so this IS the job's own enqueue.
|
|
76
|
+
declare const commentPosted: JobHandle<
|
|
77
|
+
NotifyPayload<{ postId: string; orgId: string; author: string }>
|
|
78
|
+
>;
|
|
79
|
+
declare const postId: string;
|
|
80
|
+
declare const orgId: string;
|
|
81
|
+
declare const author: string;
|
|
82
|
+
|
|
83
|
+
await commentPosted.enqueue({ params: { postId, orgId, author } });
|
|
84
|
+
// …or with the audience handed in, `noticed`'s `.deliver(recipients)`:
|
|
85
|
+
await commentPosted.enqueue({
|
|
86
|
+
params: { postId, orgId, author },
|
|
87
|
+
recipients: [{ id: 'u_1', to: 'ana@example.com', locale: 'en-GB', tz: 'Europe/London' }],
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The payload nests `params` rather than spreading it, so an app whose notification is *about*
|
|
92
|
+
recipients cannot collide with the framework's own key.
|
|
93
|
+
|
|
94
|
+
## Installing the stores
|
|
95
|
+
|
|
96
|
+
One call, at boot. Whole-object replacement, never a merge.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import type { PgExecutor } from '@ultimat3/jobs';
|
|
100
|
+
import {
|
|
101
|
+
createMemoryDigestStore,
|
|
102
|
+
createPgDeliveryLedger,
|
|
103
|
+
createPgInboxStore,
|
|
104
|
+
setNotifyStores,
|
|
105
|
+
} from '@ultimat3/notify';
|
|
106
|
+
|
|
107
|
+
declare const executor: PgExecutor; // `@ultimat3/cli`'s pgExecutorFor(client)
|
|
108
|
+
// The app's preference table, behind whatever taxonomy it named.
|
|
109
|
+
declare const prefs: {
|
|
110
|
+
allows(recipient: string, notifier: string, channel: string, at: Date): boolean;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
setNotifyStores({
|
|
114
|
+
ledger: createPgDeliveryLedger({ executor }),
|
|
115
|
+
inbox: createPgInboxStore({ executor }),
|
|
116
|
+
digest: createMemoryDigestStore(),
|
|
117
|
+
preferences: {
|
|
118
|
+
// The GATE ships; what it reads never does. Your taxonomy, your quiet hours.
|
|
119
|
+
allows: ({ recipient, notifier: name, channel, ctx }) =>
|
|
120
|
+
prefs.allows(recipient.id, name, channel, ctx.now()),
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
| Store | Default | Why |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| `ledger` | `createMemoryDeliveryLedger()` | one process is genuinely deduped by a heap map, and no ledger at all means a replay sends twice |
|
|
128
|
+
| `preferences` | `allowAllPreferences()` | denying by default is a notifier that silently delivers nothing |
|
|
129
|
+
| `inbox` | **none** — `X_NOTIFY_STORE_MISSING` | a message written to nowhere is worse than a refusal |
|
|
130
|
+
| `digest` | **none** — `X_NOTIFY_STORE_MISSING` | same |
|
|
131
|
+
|
|
132
|
+
`executor` is a structural `{ query(sql, params) }` — `@ultimat3/cli`'s `pgExecutorFor(client)` over
|
|
133
|
+
a `DbClient` is the framework's own. `Bun.sql` does **not** satisfy it.
|
|
134
|
+
|
|
135
|
+
## Channels
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { bulkChannel, channel } from '@ultimat3/notify';
|
|
139
|
+
|
|
140
|
+
// The app's push vendor and its Slack webhook — a channel is where an SDK belongs.
|
|
141
|
+
declare const pushService: {
|
|
142
|
+
send(token: string, payload: unknown, init: { signal: AbortSignal }): Promise<void>;
|
|
143
|
+
};
|
|
144
|
+
declare const SLACK_URL: string;
|
|
145
|
+
|
|
146
|
+
// One call per recipient. The retry unit is one address.
|
|
147
|
+
export const push = channel('push', async ({ recipient, event, signal }) => {
|
|
148
|
+
await pushService.send(recipient.to ?? '', event.params, { signal });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ONE call for the whole audience — a Slack post, a webhook, a digest to an ops channel.
|
|
152
|
+
export const slack = bulkChannel('slack', async ({ recipients, event, signal }) => {
|
|
153
|
+
await fetch(SLACK_URL, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
body: JSON.stringify({ text: `${String(recipients.length)} watchers`, event: event.key }),
|
|
156
|
+
signal,
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`inAppChannel()` and `mailChannel({ mailer })` ship. `mailChannel` takes a **structural** `Mailer` —
|
|
162
|
+
one method, no dependency on `@ultimat3/mail`, which is the same tier.
|
|
163
|
+
|
|
164
|
+
## The inbox
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import type { Ctx } from '@ultimat3/core';
|
|
168
|
+
import { requireInbox } from '@ultimat3/notify';
|
|
169
|
+
|
|
170
|
+
declare const ctx: Ctx; // the request context; `ctx.actor` is who is reading
|
|
171
|
+
|
|
172
|
+
const store = requireInbox('post.commented');
|
|
173
|
+
const page = await store.list({ recipient: ctx.actor.id, limit: 50 });
|
|
174
|
+
const badge = await store.unreadCount(ctx.actor.id);
|
|
175
|
+
await store.markSeen({ recipient: ctx.actor.id, at: ctx.now() });
|
|
176
|
+
await store.markRead({ recipient: ctx.actor.id, ids: [page[0]?.id ?? ''], at: ctx.now() });
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`seenAt` and `readAt` are two facts — showing the badge does not dismiss the message — and the
|
|
180
|
+
unread count is **derived** from `readAt is null`, never stored, so the badge and the list cannot
|
|
181
|
+
disagree. Every write is scoped by recipient: an id somebody else named is simply absent.
|
|
182
|
+
|
|
183
|
+
## Delivery order
|
|
184
|
+
|
|
185
|
+
`deliver` entries are sorted by `wait` ascending and the fan-out sleeps the **delta**, so a channel
|
|
186
|
+
with no wait fires immediately even when a later one waits an hour. `if` and `unless` are evaluated
|
|
187
|
+
**after** the wait, on the attempt that delivers — a condition that goes false during a ten-minute
|
|
188
|
+
delay sends nothing.
|
|
189
|
+
|
|
190
|
+
## At-least-once
|
|
191
|
+
|
|
192
|
+
A job body runs before its checkpoint lands. Two layers stop a double send: the step checkpoint
|
|
193
|
+
(`deliver:<channel>:<recipient>`), and the delivery ledger's atomic claim on
|
|
194
|
+
`(notifier, key, channel, coalesce(recipient, ''))` — taken before the send, settled after. A claim
|
|
195
|
+
that already reads `sent` answers `false`.
|
|
196
|
+
|
|
197
|
+
A bulk channel claims **one** row for the whole audience, with a null recipient: half a bulk POST is
|
|
198
|
+
not a state this package can represent. That null is why the key coalesces rather than naming the
|
|
199
|
+
column: NULLs are distinct in a plain unique index, so a bulk claim would otherwise be claimable
|
|
200
|
+
without bound and every replay would re-send the whole audience.
|
|
201
|
+
|
|
202
|
+
## Errors
|
|
203
|
+
|
|
204
|
+
| Code | When |
|
|
205
|
+
|---|---|
|
|
206
|
+
| `X_NOTIFY_CHANNELS_EMPTY` | `deliver: []` — refused at declaration |
|
|
207
|
+
| `X_NOTIFY_CHANNEL_DUPLICATE` | two deliveries name one channel; the ledger keys on it |
|
|
208
|
+
| `X_NOTIFY_DIGEST_UNSUPPORTED` | a digest window on a bulk channel |
|
|
209
|
+
| `X_NOTIFY_FANOUT_TOO_WIDE` | more recipients than `maxRecipients` (default 500) |
|
|
210
|
+
| `X_NOTIFY_STORE_MISSING` | an inbox or digest channel with no store installed |
|
|
211
|
+
| `X_NOTIFY_DELIVERY_FAILED` | a channel's `deliver` threw; the run retries on its policy |
|
|
212
|
+
|
|
213
|
+
## Boundary
|
|
214
|
+
|
|
215
|
+
Tier 4. May import tiers 0-3 — enforced by `bun run boundaries`. Its real imports are `core`,
|
|
216
|
+
`schema`, `time` and `jobs`, so tier 4 is its floor. See [`CLAUDE.md`](./CLAUDE.md).
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ultimat3/notify",
|
|
3
|
+
"version": "12.0.0",
|
|
4
|
+
"description": "Notifications: one declaration, many channels — fan-out, preference gate, digest window, delivery ledger, in-app inbox",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/developerz-ai/ultimate.git",
|
|
10
|
+
"directory": "packages/notify"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public",
|
|
14
|
+
"provenance": true
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"sideEffects": [
|
|
20
|
+
"./src/errors.ts"
|
|
21
|
+
],
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"!src/**/*.test.ts",
|
|
25
|
+
"!src/**/*-fixture.ts",
|
|
26
|
+
"CLAUDE.md",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"bun": ">=1.3.0"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
35
|
+
"test": "bun test"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@ultimat3/core": "12.0.0",
|
|
39
|
+
"@ultimat3/jobs": "12.0.0",
|
|
40
|
+
"@ultimat3/schema": "12.0.0",
|
|
41
|
+
"@ultimat3/time": "12.0.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/attempt.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// One delivery attempt, ledger-first. Every send in this package goes through here, so there is
|
|
2
|
+
// exactly one place that decides "has this already gone out" and exactly one that records the
|
|
3
|
+
// answer.
|
|
4
|
+
//
|
|
5
|
+
// The order is claim → send → settle, and it is not interchangeable. Settling first would mark a
|
|
6
|
+
// send that has not happened; claiming after would let two attempts pass the check together.
|
|
7
|
+
|
|
8
|
+
import type { Ctx } from '@ultimat3/core';
|
|
9
|
+
import { NotifyDeliveryFailedError } from './errors';
|
|
10
|
+
import type { DeliveryClaim, DeliveryLedger } from './ledger';
|
|
11
|
+
|
|
12
|
+
export interface AttemptInput {
|
|
13
|
+
readonly ledger: DeliveryLedger;
|
|
14
|
+
readonly claim: DeliveryClaim;
|
|
15
|
+
readonly notifier: string;
|
|
16
|
+
readonly channel: string;
|
|
17
|
+
/** How many addresses this one attempt covers — 1 for an individual channel, the audience for
|
|
18
|
+
* a bulk one. Reported in the refusal, because "the Slack post failed" and "one of 400 emails
|
|
19
|
+
* failed" are different incidents. */
|
|
20
|
+
readonly recipients: number;
|
|
21
|
+
readonly ctx: Ctx;
|
|
22
|
+
send(signal: AbortSignal): Promise<void> | void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `true` when this attempt sent, `false` when the ledger already held a completed delivery — the
|
|
27
|
+
* replay case, and the whole reason this function exists.
|
|
28
|
+
*
|
|
29
|
+
* A throwing channel settles `failed` and then rethrows as `X_NOTIFY_DELIVERY_FAILED`: the row
|
|
30
|
+
* stays re-claimable, so the job's own retry policy decides whether it goes out, and the dead
|
|
31
|
+
* letter names the channel rather than whatever the provider's SDK threw.
|
|
32
|
+
*/
|
|
33
|
+
export async function attemptDelivery(input: AttemptInput, signal: AbortSignal): Promise<boolean> {
|
|
34
|
+
if (!(await input.ledger.claim(input.claim, input.ctx.now()))) return false;
|
|
35
|
+
try {
|
|
36
|
+
await input.send(signal);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
await input.ledger.settle(input.claim, 'failed', input.ctx.now());
|
|
39
|
+
throw new NotifyDeliveryFailedError({
|
|
40
|
+
notifier: input.notifier,
|
|
41
|
+
channel: input.channel,
|
|
42
|
+
recipients: input.recipients,
|
|
43
|
+
cause: error,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
await input.ledger.settle(input.claim, 'sent', input.ctx.now());
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The in-app inbox as a channel. `noticed` writes a notification row unconditionally; here it is
|
|
2
|
+
// an opt-in channel, so a notifier that only sends email writes no rows at all — one mechanism,
|
|
3
|
+
// declared where every other delivery is declared, rather than a table that fills itself.
|
|
4
|
+
|
|
5
|
+
import type { NotifyChannel } from './channel';
|
|
6
|
+
import { channel } from './channel';
|
|
7
|
+
import { requireInbox } from './stores';
|
|
8
|
+
|
|
9
|
+
export interface InAppChannelOptions {
|
|
10
|
+
/** The ledger and preference key this delivery is known by. Change it only to run two inbox
|
|
11
|
+
* channels on one notifier — e.g. a per-org feed beside a personal one. */
|
|
12
|
+
readonly name?: string | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const IN_APP_CHANNEL = 'in-app';
|
|
16
|
+
|
|
17
|
+
export function inAppChannel<Params = unknown>(
|
|
18
|
+
options: InAppChannelOptions = {},
|
|
19
|
+
): NotifyChannel<Params> {
|
|
20
|
+
return channel<Params>(options.name ?? IN_APP_CHANNEL, async ({ recipient, batch }) => {
|
|
21
|
+
// Every event in the batch, not just the newest: a digest window over an inbox still owes the
|
|
22
|
+
// reader one row per thing that happened. `add` is idempotent on (recipient, notifier, key),
|
|
23
|
+
// so a replayed attempt writes nothing new and does not move an existing row's timestamps.
|
|
24
|
+
for (const entry of batch) {
|
|
25
|
+
await requireInbox(entry.notifier).add({
|
|
26
|
+
recipient: recipient.id,
|
|
27
|
+
notifier: entry.notifier,
|
|
28
|
+
key: entry.key,
|
|
29
|
+
params: entry.params,
|
|
30
|
+
createdAt: entry.at,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Email as a channel, over a STRUCTURAL mailer rather than an import of @ultimat3/mail.
|
|
2
|
+
//
|
|
3
|
+
// That is the tier argument, and it is the same one `@ultimat3/action`'s `PgExecutor` makes about
|
|
4
|
+
// `@ultimat3/db`. `@ultimat3/mail` is tier 4 and so is this package, so importing it would be a
|
|
5
|
+
// sideways edge — and moving `notify` to tier 5 to legalise it would put notifications above
|
|
6
|
+
// `render`, `pwa` and `ui` for no reason other than one channel's transport. A mailer is a driver
|
|
7
|
+
// seam, so it is declared here as the shape it is: one method, no dependency.
|
|
8
|
+
//
|
|
9
|
+
// mailChannel({ mailer: { send: (mail) => send(postLiked, mail.batch[0].params, {
|
|
10
|
+
// to: mail.to, locale: mail.locale ?? 'en',
|
|
11
|
+
// }) } })
|
|
12
|
+
|
|
13
|
+
import type { NotifyChannel } from './channel';
|
|
14
|
+
import { channel } from './channel';
|
|
15
|
+
import type { NotifyEvent, Recipient } from './notification';
|
|
16
|
+
|
|
17
|
+
/** What a mailer is handed. Everything a template needs to render, and nothing about transport. */
|
|
18
|
+
export interface NotifyMail<Params = unknown> {
|
|
19
|
+
readonly to: string;
|
|
20
|
+
/** BCP-47, from the recipient. `undefined` when the app did not resolve one — never guessed. */
|
|
21
|
+
readonly locale: string | undefined;
|
|
22
|
+
/** IANA zone, from the recipient. A mail that formats a date and reads this `undefined` must
|
|
23
|
+
* refuse rather than fall back: no ambient time zone, anywhere. */
|
|
24
|
+
readonly tz: string | undefined;
|
|
25
|
+
/** Oldest first. One entry for an immediate send; the whole window for a digest. */
|
|
26
|
+
readonly batch: readonly NotifyEvent<Params>[];
|
|
27
|
+
readonly signal: AbortSignal;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The seam. `@ultimat3/mail`'s `send(definition, params, { to, locale })` satisfies it in one
|
|
32
|
+
* line, and so does a transactional-email SDK an app already pays for.
|
|
33
|
+
*/
|
|
34
|
+
export interface Mailer<Params = unknown> {
|
|
35
|
+
send(mail: NotifyMail<Params>): Promise<void> | void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const MAIL_CHANNEL = 'email';
|
|
39
|
+
|
|
40
|
+
export interface MailChannelOptions<Params> {
|
|
41
|
+
readonly mailer: Mailer<Params>;
|
|
42
|
+
readonly name?: string | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* Where the address comes from. Defaults to `recipient.to`.
|
|
45
|
+
*
|
|
46
|
+
* `undefined` back means this recipient has no address, which is NOT a failure: the delivery is
|
|
47
|
+
* settled as sent and logged as `notify.address_missing`, because retrying the same event will
|
|
48
|
+
* find the same missing address and the only outcome of pretending otherwise is a dead letter
|
|
49
|
+
* per addressless recipient.
|
|
50
|
+
*/
|
|
51
|
+
readonly addressOf?: ((recipient: Recipient) => string | undefined) | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function mailChannel<Params = unknown>(
|
|
55
|
+
options: MailChannelOptions<Params>,
|
|
56
|
+
): NotifyChannel<Params> {
|
|
57
|
+
const addressOf = options.addressOf ?? ((recipient: Recipient) => recipient.to);
|
|
58
|
+
return channel<Params>(
|
|
59
|
+
options.name ?? MAIL_CHANNEL,
|
|
60
|
+
async ({ recipient, batch, ctx, signal }) => {
|
|
61
|
+
const to = addressOf(recipient);
|
|
62
|
+
if (to === undefined || to === '') {
|
|
63
|
+
ctx.logger.warn('notify.address_missing', { recipient: recipient.id });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
await options.mailer.send({
|
|
67
|
+
to,
|
|
68
|
+
locale: recipient.locale,
|
|
69
|
+
tz: recipient.tz,
|
|
70
|
+
batch,
|
|
71
|
+
signal,
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// What a channel IS: a name plus one `deliver`, in two arities. Individual channels are called
|
|
2
|
+
// once per recipient; bulk channels once for the whole audience.
|
|
3
|
+
//
|
|
4
|
+
// The split is `noticed`'s and it is not cosmetic. An email is instantiated per person and a Slack
|
|
5
|
+
// post is one POST for everybody, so a framework that only knows the first shape makes an app send
|
|
6
|
+
// the same webhook a hundred times.
|
|
7
|
+
|
|
8
|
+
import type { Ctx } from '@ultimat3/core';
|
|
9
|
+
import type { NotifyEvent, Recipient } from './notification';
|
|
10
|
+
|
|
11
|
+
/** What every `deliver` gets, whichever arity. */
|
|
12
|
+
interface DeliveryCommon<Params> {
|
|
13
|
+
readonly ctx: Ctx;
|
|
14
|
+
/**
|
|
15
|
+
* The events this delivery covers. One entry for an immediate send; every event the window
|
|
16
|
+
* coalesced, oldest first, for a digest flush. A channel that ignores it and reads `event`
|
|
17
|
+
* alone is correct for the immediate case and lossy for the digest one, which is why the field
|
|
18
|
+
* is not optional.
|
|
19
|
+
*/
|
|
20
|
+
readonly batch: readonly NotifyEvent<Params>[];
|
|
21
|
+
/** The newest event in `batch` — the one a single-event channel renders. */
|
|
22
|
+
readonly event: NotifyEvent<Params>;
|
|
23
|
+
/** The run's cancellation folded with this step's ceiling. Hand it to `fetch`. */
|
|
24
|
+
readonly signal: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DeliveryArgs<Params = unknown> extends DeliveryCommon<Params> {
|
|
28
|
+
readonly recipient: Recipient;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface BulkDeliveryArgs<Params = unknown> extends DeliveryCommon<Params> {
|
|
32
|
+
/** Everyone the preference gate allowed. Never empty — the fan-out skips an empty audience. */
|
|
33
|
+
readonly recipients: readonly Recipient[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface NotifyChannel<Params = unknown> {
|
|
37
|
+
readonly name: string;
|
|
38
|
+
readonly bulk: false;
|
|
39
|
+
deliver(args: DeliveryArgs<Params>): Promise<void> | void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface BulkNotifyChannel<Params = unknown> {
|
|
43
|
+
readonly name: string;
|
|
44
|
+
readonly bulk: true;
|
|
45
|
+
deliver(args: BulkDeliveryArgs<Params>): Promise<void> | void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type AnyNotifyChannel<Params = unknown> = NotifyChannel<Params> | BulkNotifyChannel<Params>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One recipient per call. The name is durable — it is a column of the delivery ledger — so it is
|
|
52
|
+
* given here rather than derived from a variable name that a bundler may rewrite.
|
|
53
|
+
*/
|
|
54
|
+
export function channel<Params = unknown>(
|
|
55
|
+
name: string,
|
|
56
|
+
deliver: (args: DeliveryArgs<Params>) => Promise<void> | void,
|
|
57
|
+
): NotifyChannel<Params> {
|
|
58
|
+
return { name, bulk: false, deliver };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One call for the whole audience. The ledger claims ONE row per (event, channel) rather than one
|
|
63
|
+
* per recipient, because the send is one thing: half a bulk POST is not a state this package can
|
|
64
|
+
* represent, and pretending otherwise would let a replay re-post to everybody to repair one
|
|
65
|
+
* address.
|
|
66
|
+
*/
|
|
67
|
+
export function bulkChannel<Params = unknown>(
|
|
68
|
+
name: string,
|
|
69
|
+
deliver: (args: BulkDeliveryArgs<Params>) => Promise<void> | void,
|
|
70
|
+
): BulkNotifyChannel<Params> {
|
|
71
|
+
return { name, bulk: true, deliver };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const isBulkChannel = <Params>(
|
|
75
|
+
value: AnyNotifyChannel<Params>,
|
|
76
|
+
): value is BulkNotifyChannel<Params> => value.bulk;
|