@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/src/digest.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The digest window: N events for one recipient over one channel, coalesced into one delivery.
|
|
2
|
+
//
|
|
3
|
+
// A ROLLING window measured in milliseconds from the first event, and deliberately not a calendar
|
|
4
|
+
// one. "Every day at 09:00" needs an IANA zone per recipient and a cron, which is `task()` plus the
|
|
5
|
+
// app's own schedule — and this repo's rule is that no date is computed without an explicit zone,
|
|
6
|
+
// so a window this package could get wrong is a window it does not offer. `windowMs` needs no zone
|
|
7
|
+
// because it does no calendar arithmetic at all.
|
|
8
|
+
|
|
9
|
+
import type { NotifyEvent } from './notification';
|
|
10
|
+
|
|
11
|
+
/** One coalescing bucket's identity. */
|
|
12
|
+
export interface DigestSlot {
|
|
13
|
+
readonly recipient: string;
|
|
14
|
+
readonly notifier: string;
|
|
15
|
+
readonly channel: string;
|
|
16
|
+
/**
|
|
17
|
+
* What the window groups BY, within the slot. Defaults to the notifier's name, so every
|
|
18
|
+
* `post.commented` for one person coalesces; an app that wants one digest per thread returns
|
|
19
|
+
* the thread id. The taxonomy that names those groups is the app's, exactly as with preferences.
|
|
20
|
+
*/
|
|
21
|
+
readonly group: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DigestAppend {
|
|
25
|
+
readonly slot: DigestSlot;
|
|
26
|
+
readonly event: NotifyEvent<unknown>;
|
|
27
|
+
readonly windowMs: number;
|
|
28
|
+
readonly now: Date;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DigestBucket {
|
|
32
|
+
/**
|
|
33
|
+
* True when THIS append opened the window. Exactly one caller gets it per window, and that
|
|
34
|
+
* caller owns the flush — which is what stops N concurrent runs each scheduling their own.
|
|
35
|
+
*/
|
|
36
|
+
readonly opened: boolean;
|
|
37
|
+
/** Epoch ms the window closes at. */
|
|
38
|
+
readonly endsAt: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DigestStore {
|
|
42
|
+
append(input: DigestAppend): Promise<DigestBucket>;
|
|
43
|
+
/**
|
|
44
|
+
* Everything the window collected, oldest first, and the bucket is closed.
|
|
45
|
+
*
|
|
46
|
+
* THE ONE AT-MOST-ONCE SEAM IN THIS PACKAGE, stated rather than hidden: a process killed between
|
|
47
|
+
* this call and the send loses that batch, because the events are no longer anywhere to replay
|
|
48
|
+
* from. The fan-out narrows it by checkpointing the drained batch in its own `step.run` before
|
|
49
|
+
* the send — an ordinary retry replays from that checkpoint — and a durable implementation can
|
|
50
|
+
* close it entirely by flipping a row's status here and deleting on `settle`. The memory store
|
|
51
|
+
* below cannot.
|
|
52
|
+
*/
|
|
53
|
+
drain(slot: DigestSlot): Promise<readonly NotifyEvent<unknown>[]>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const slotKey = (slot: DigestSlot): string =>
|
|
57
|
+
JSON.stringify([slot.recipient, slot.notifier, slot.channel, slot.group]);
|
|
58
|
+
|
|
59
|
+
interface OpenBucket {
|
|
60
|
+
endsAt: number;
|
|
61
|
+
readonly events: NotifyEvent<unknown>[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface MemoryDigestStore extends DigestStore {
|
|
65
|
+
readonly open: number;
|
|
66
|
+
clear(): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createMemoryDigestStore(): MemoryDigestStore {
|
|
70
|
+
const buckets = new Map<string, OpenBucket>();
|
|
71
|
+
return {
|
|
72
|
+
get open(): number {
|
|
73
|
+
return buckets.size;
|
|
74
|
+
},
|
|
75
|
+
append(input) {
|
|
76
|
+
const id = slotKey(input.slot);
|
|
77
|
+
const at = input.now.getTime();
|
|
78
|
+
const existing = buckets.get(id);
|
|
79
|
+
// A bucket whose window has already elapsed is not a bucket to append to: its owner is gone
|
|
80
|
+
// (a crashed flush) and the event would sit there until an unrelated third event arrived.
|
|
81
|
+
// Re-opening is the repair, and it costs one extra delivery rather than a lost one.
|
|
82
|
+
if (existing !== undefined && existing.endsAt > at) {
|
|
83
|
+
existing.events.push(input.event);
|
|
84
|
+
return Promise.resolve({ opened: false, endsAt: existing.endsAt });
|
|
85
|
+
}
|
|
86
|
+
const endsAt = at + input.windowMs;
|
|
87
|
+
buckets.set(id, { endsAt, events: [input.event] });
|
|
88
|
+
return Promise.resolve({ opened: true, endsAt });
|
|
89
|
+
},
|
|
90
|
+
drain(slot) {
|
|
91
|
+
const id = slotKey(slot);
|
|
92
|
+
const bucket = buckets.get(id);
|
|
93
|
+
buckets.delete(id);
|
|
94
|
+
return Promise.resolve(bucket?.events ?? []);
|
|
95
|
+
},
|
|
96
|
+
clear() {
|
|
97
|
+
buckets.clear();
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// The X_* codes owned by @ultimat3/notify. Each names the exact change that resolves it.
|
|
2
|
+
//
|
|
3
|
+
// No `docs:` line, and that is deliberate: `UltimateError`'s constructor resolves the registered
|
|
4
|
+
// descriptor, whose default is `ERROR_DOCS_URL` in @ultimat3/core. A URL written out here is a
|
|
5
|
+
// second answer to a question core already answers, and the last one went stale host and all.
|
|
6
|
+
import {
|
|
7
|
+
registerErrorCodes,
|
|
8
|
+
registerErrorRetry,
|
|
9
|
+
renderThrowable,
|
|
10
|
+
UltimateError,
|
|
11
|
+
} from '@ultimat3/core';
|
|
12
|
+
|
|
13
|
+
export const NOTIFY_ERROR_CODES = [
|
|
14
|
+
'X_NOTIFY_CHANNELS_EMPTY',
|
|
15
|
+
'X_NOTIFY_CHANNEL_DUPLICATE',
|
|
16
|
+
'X_NOTIFY_FANOUT_TOO_WIDE',
|
|
17
|
+
'X_NOTIFY_STORE_MISSING',
|
|
18
|
+
'X_NOTIFY_DELIVERY_FAILED',
|
|
19
|
+
'X_NOTIFY_DIGEST_UNSUPPORTED',
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export type NotifyErrorCode = (typeof NOTIFY_ERROR_CODES)[number];
|
|
23
|
+
|
|
24
|
+
export const NOTIFY_ERROR_TITLES: Readonly<Record<NotifyErrorCode, string>> = {
|
|
25
|
+
X_NOTIFY_CHANNELS_EMPTY: 'the notifier declares no channels',
|
|
26
|
+
X_NOTIFY_CHANNEL_DUPLICATE: 'two deliveries share one channel name',
|
|
27
|
+
X_NOTIFY_FANOUT_TOO_WIDE: 'the audience is larger than one run may fan out to',
|
|
28
|
+
X_NOTIFY_STORE_MISSING: 'a channel needs a store nothing installed',
|
|
29
|
+
X_NOTIFY_DELIVERY_FAILED: 'a channel did not accept the delivery',
|
|
30
|
+
X_NOTIFY_DIGEST_UNSUPPORTED: 'a digest window is declared on a bulk channel',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// One unconditional call, so a second package claiming one of notify's codes throws
|
|
34
|
+
// X_ERROR_CODE_DUPLICATE instead of losing silently to whichever module imported first.
|
|
35
|
+
registerErrorCodes(
|
|
36
|
+
Object.fromEntries(Object.entries(NOTIFY_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The codes of this package's that can be thrown INSIDE a notifier's run, classified.
|
|
41
|
+
* `executeJob` reads this through `nextRetryForError`, which short-circuits on `terminal` and on
|
|
42
|
+
* NOTHING else — so an unclassified code falls through to the attempt count and spends the whole
|
|
43
|
+
* policy re-proving an answer that cannot change. `classifyThrown` reads an unregistered code as
|
|
44
|
+
* unclassified even when its instance carries `terminal`, which is why this has to be explicit
|
|
45
|
+
* rather than left to `DEFAULT_ERROR_RETRY`.
|
|
46
|
+
*
|
|
47
|
+
* Both terminal ones were live defects until 2026-08-24: an audience of 900 against
|
|
48
|
+
* `maxRecipients: 500` re-resolved the audience and re-counted it once per attempt, and a missing
|
|
49
|
+
* inbox store dead-lettered five attempts later than it knew the answer.
|
|
50
|
+
*
|
|
51
|
+
* THE THREE DECLARATION-TIME CODES ARE DELIBERATELY ABSENT, and that is the audit rather than an
|
|
52
|
+
* omission. `X_NOTIFY_CHANNELS_EMPTY`, `X_NOTIFY_CHANNEL_DUPLICATE` and
|
|
53
|
+
* `X_NOTIFY_DIGEST_UNSUPPORTED` are all thrown by `resolve()` inside `notifier()`, which runs at
|
|
54
|
+
* module load — the worker is not running, `classifyThrown` is never reached, and a row for them
|
|
55
|
+
* would be a claim nothing reads. `errors.test.ts` pins the split both ways, so a code that moves
|
|
56
|
+
* from one side to the other fails a test rather than going quiet.
|
|
57
|
+
*/
|
|
58
|
+
registerErrorRetry({
|
|
59
|
+
// The audience is what it is. Retrying counts it again and refuses again — and each attempt
|
|
60
|
+
// re-runs `recipientsFor`, so the policy is spent on repeated work as well as repeated time.
|
|
61
|
+
X_NOTIFY_FANOUT_TOO_WIDE: 'terminal',
|
|
62
|
+
// A store is installed at boot or it is not. No attempt of a running worker installs one.
|
|
63
|
+
X_NOTIFY_STORE_MISSING: 'terminal',
|
|
64
|
+
// The one a retry can fix: a provider blip, a timeout, a 503. It read correctly before this
|
|
65
|
+
// block existed only because `unclassified` happens to fall through to the attempt count —
|
|
66
|
+
// declaring it is what makes that an answer rather than a coincidence, and what stops a later
|
|
67
|
+
// sweep through this file from making the whole package terminal.
|
|
68
|
+
X_NOTIFY_DELIVERY_FAILED: 'retryable',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** Refused where it is written: a notifier with no channel is a job that fans out to nobody. */
|
|
72
|
+
export class NotifyChannelsEmptyError extends UltimateError {
|
|
73
|
+
constructor(input: { notifier: string }) {
|
|
74
|
+
super({
|
|
75
|
+
code: 'X_NOTIFY_CHANNELS_EMPTY',
|
|
76
|
+
cause: `notifier "${input.notifier}" declares no channels, so every run resolves recipients and delivers nothing`,
|
|
77
|
+
fix: `add at least one entry to deliver: [] on notifier("${input.notifier}") — inAppChannel() is the one with no external driver`,
|
|
78
|
+
meta: { notifier: input.notifier },
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Two deliveries on one notifier naming the same channel. Refused because the ledger's unique key
|
|
85
|
+
* is `(notifier, key, channel, coalesce(recipient, ''))` (`ledger-pg.ts`, and `SQL_NOTIFY_CLAIM`'s
|
|
86
|
+
* `on conflict` spells the same expression): the second delivery would claim a row the first
|
|
87
|
+
* already owns and be dropped as a duplicate, so one of the two would silently never send.
|
|
88
|
+
*
|
|
89
|
+
* The `coalesce` is the load-bearing half and is not a detail. A bulk delivery claims with a NULL
|
|
90
|
+
* recipient, and NULLs are DISTINCT in a plain unique index — so without it a bulk claim would be
|
|
91
|
+
* claimable without bound and every replay would re-send the whole audience. Stating the key
|
|
92
|
+
* without it is what invites the "simplification" that puts the bug back.
|
|
93
|
+
*/
|
|
94
|
+
export class NotifyChannelDuplicateError extends UltimateError {
|
|
95
|
+
constructor(input: { notifier: string; channel: string }) {
|
|
96
|
+
super({
|
|
97
|
+
code: 'X_NOTIFY_CHANNEL_DUPLICATE',
|
|
98
|
+
cause: `notifier "${input.notifier}" declares the channel "${input.channel}" twice, and the delivery ledger keys on it — the second would be deduped away`,
|
|
99
|
+
fix: `give the second channel its own name on notifier("${input.notifier}") — channel('${input.channel}-digest', …) — or merge the two deliver entries`,
|
|
100
|
+
meta: { notifier: input.notifier, channel: input.channel },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One step per recipient is what makes a provider blip re-send one address rather than all of
|
|
107
|
+
* them, and a step is a durable row — so the fan-out has a width past which it is the wrong shape
|
|
108
|
+
* entirely. Refused with a number rather than degraded silently.
|
|
109
|
+
*/
|
|
110
|
+
export class NotifyFanoutTooWideError extends UltimateError {
|
|
111
|
+
constructor(input: { notifier: string; recipients: number; max: number }) {
|
|
112
|
+
super({
|
|
113
|
+
code: 'X_NOTIFY_FANOUT_TOO_WIDE',
|
|
114
|
+
cause: `notifier "${input.notifier}" resolved ${String(input.recipients)} recipients and the per-run ceiling is ${String(input.max)} — each one is a durable step row`,
|
|
115
|
+
fix: `deliver through a bulkChannel() on notifier("${input.notifier}"), which sends one payload for every recipient, or page the audience with backfill() from @ultimat3/jobs`,
|
|
116
|
+
meta: { notifier: input.notifier, recipients: input.recipients, max: input.max },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A channel or a window asked for a store nothing installed.
|
|
123
|
+
*
|
|
124
|
+
* The delivery ledger is deliberately NOT one of these: it has a correct-for-one-process default
|
|
125
|
+
* (`createMemoryDeliveryLedger`), the way every other driver seam in this framework does. An inbox
|
|
126
|
+
* and a digest window have no such default — there is nowhere to put the row — so they refuse.
|
|
127
|
+
*/
|
|
128
|
+
export class NotifyStoreMissingError extends UltimateError {
|
|
129
|
+
constructor(input: { notifier: string; store: 'inbox' | 'digest' }) {
|
|
130
|
+
const what =
|
|
131
|
+
input.store === 'inbox' ? 'a channel that writes the in-app inbox' : 'a digest window';
|
|
132
|
+
const install =
|
|
133
|
+
input.store === 'inbox'
|
|
134
|
+
? 'createMemoryInboxStore() }) at boot, or createPgInboxStore({ executor }) to share it across replicas'
|
|
135
|
+
: 'createMemoryDigestStore() }) at boot';
|
|
136
|
+
super({
|
|
137
|
+
code: 'X_NOTIFY_STORE_MISSING',
|
|
138
|
+
cause: `notifier "${input.notifier}" delivers through ${what} and no ${input.store} store is installed, so the rows have nowhere to go`,
|
|
139
|
+
fix: `call setNotifyStores({ ${input.store}: ${install}`,
|
|
140
|
+
meta: { notifier: input.notifier, store: input.store },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A digest window declared on a bulk channel. Refused where it is written: a bulk send has one
|
|
147
|
+
* payload for the whole audience and a window coalesces PER RECIPIENT, so the two have no shared
|
|
148
|
+
* meaning — and inventing one would be this package deciding whose events get grouped.
|
|
149
|
+
*/
|
|
150
|
+
export class NotifyDigestUnsupportedError extends UltimateError {
|
|
151
|
+
constructor(input: { notifier: string; channel: string }) {
|
|
152
|
+
super({
|
|
153
|
+
code: 'X_NOTIFY_DIGEST_UNSUPPORTED',
|
|
154
|
+
cause: `notifier "${input.notifier}" declares a digest window on the bulk channel "${input.channel}", and a window coalesces per recipient where a bulk send has none`,
|
|
155
|
+
fix: `drop digest from the "${input.channel}" entry on notifier("${input.notifier}") and give it a wait instead, or deliver it through channel('${input.channel}', …) one recipient at a time`,
|
|
156
|
+
meta: { notifier: input.notifier, channel: input.channel },
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A channel's `deliver` threw. Wrapped rather than rethrown so the dead-letter row carries a
|
|
163
|
+
* stable code and names the channel — a raw provider error names only itself.
|
|
164
|
+
*
|
|
165
|
+
* `renderThrowable` and never `${cause}`: a caught value is annotated by nobody, and a provider
|
|
166
|
+
* rejection is routinely an object whose `toString` throws (`bun run scripts/catch-render.ts`).
|
|
167
|
+
*/
|
|
168
|
+
export class NotifyDeliveryFailedError extends UltimateError {
|
|
169
|
+
constructor(input: { notifier: string; channel: string; recipients: number; cause: unknown }) {
|
|
170
|
+
super({
|
|
171
|
+
code: 'X_NOTIFY_DELIVERY_FAILED',
|
|
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
|
|
174
|
+
// positional and resolves it through `inspectJob`, which answers `X_JOB_UNKNOWN` for
|
|
175
|
+
// anything else — so a notifier NAME made the one command this refusal printed fail every
|
|
176
|
+
// time it was run. A `fix:` that fails is worse than none, because the reader spends their
|
|
177
|
+
// trust on it before finding out. The two-step shape is what `wiki/Error-Codes.md` already
|
|
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}`,
|
|
180
|
+
meta: { notifier: input.notifier, channel: input.channel },
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The digest branch of the fan-out: append every allowed recipient's event to its window, and let
|
|
2
|
+
// whichever run OPENED a window own the flush.
|
|
3
|
+
//
|
|
4
|
+
// Exactly one run opens a given window, so exactly one sleeps on it — which is what stops fifty
|
|
5
|
+
// events in one hour from scheduling fifty flushes of the same digest.
|
|
6
|
+
|
|
7
|
+
import { attemptDelivery } from './attempt';
|
|
8
|
+
import type { NotifyChannel } from './channel';
|
|
9
|
+
import type { DigestSlot } from './digest';
|
|
10
|
+
import type { Walk } from './fanout-walk';
|
|
11
|
+
import type { NotifyEvent, Recipient } from './notification';
|
|
12
|
+
import type { ResolvedDelivery } from './plan';
|
|
13
|
+
import { notifyStores, requireDigest } from './stores';
|
|
14
|
+
|
|
15
|
+
export interface DigestFlush<Params> {
|
|
16
|
+
readonly walk: Walk<Params>;
|
|
17
|
+
readonly channel: NotifyChannel<Params>;
|
|
18
|
+
readonly delivery: ResolvedDelivery<Params>;
|
|
19
|
+
readonly allowed: readonly Recipient[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function flushDigest<Params>(input: DigestFlush<Params>): Promise<void> {
|
|
23
|
+
const { walk, channel, delivery, allowed } = input;
|
|
24
|
+
const { plan, event, ctx, step, tally } = walk;
|
|
25
|
+
const windowMs = delivery.digestMs ?? 0;
|
|
26
|
+
const digest = requireDigest(plan.name);
|
|
27
|
+
const group = delivery.group?.(event) ?? plan.name;
|
|
28
|
+
const slotFor = (recipient: string): DigestSlot => ({
|
|
29
|
+
recipient,
|
|
30
|
+
notifier: plan.name,
|
|
31
|
+
channel: channel.name,
|
|
32
|
+
group,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// One step for the whole append pass: appending is what a replayed attempt must NOT redo, or the
|
|
36
|
+
// same event lands in the digest twice.
|
|
37
|
+
const opened = await step.run(`digest:${channel.name}`, async () => {
|
|
38
|
+
const owned: string[] = [];
|
|
39
|
+
let endsAt = 0;
|
|
40
|
+
for (const recipient of allowed) {
|
|
41
|
+
const bucket = await digest.append({
|
|
42
|
+
slot: slotFor(recipient.id),
|
|
43
|
+
event,
|
|
44
|
+
windowMs,
|
|
45
|
+
now: ctx.now(),
|
|
46
|
+
});
|
|
47
|
+
if (!bucket.opened) continue;
|
|
48
|
+
owned.push(recipient.id);
|
|
49
|
+
endsAt = Math.max(endsAt, bucket.endsAt);
|
|
50
|
+
}
|
|
51
|
+
return { owned, endsAt };
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
tally.digested += allowed.length - opened.owned.length;
|
|
55
|
+
if (opened.owned.length === 0) return;
|
|
56
|
+
|
|
57
|
+
const remaining = opened.endsAt - ctx.now().getTime();
|
|
58
|
+
if (remaining > 0) await step.sleep(`digest-wait:${channel.name}`, remaining);
|
|
59
|
+
|
|
60
|
+
const byId = new Map(allowed.map((recipient) => [recipient.id, recipient]));
|
|
61
|
+
for (const id of opened.owned) {
|
|
62
|
+
const recipient = byId.get(id);
|
|
63
|
+
if (recipient === undefined) continue;
|
|
64
|
+
// Drain and send are TWO steps on purpose. The drain's result is checkpointed, so an ordinary
|
|
65
|
+
// retry of the send replays the batch from the step store rather than from a window that is
|
|
66
|
+
// now empty. What it does not close is a process killed between the drain and its checkpoint;
|
|
67
|
+
// `DigestStore.drain` says so in its own words, and a durable store can do better.
|
|
68
|
+
const batch = await step.run(`digest-drain:${channel.name}:${id}`, () =>
|
|
69
|
+
digest.drain(slotFor(id)),
|
|
70
|
+
);
|
|
71
|
+
if (batch.length === 0) continue;
|
|
72
|
+
const events = rehydrate<Params>(batch);
|
|
73
|
+
const newest = events[events.length - 1] ?? event;
|
|
74
|
+
const sent = await step.run(`digest-send:${channel.name}:${id}`, (signal) =>
|
|
75
|
+
attemptDelivery(
|
|
76
|
+
{
|
|
77
|
+
ledger: notifyStores().ledger,
|
|
78
|
+
// The window, not the event: a digest is one delivery for many events, so its ledger
|
|
79
|
+
// identity is the slot plus the window it closed. Two different windows for the same
|
|
80
|
+
// recipient are two deliveries and must not dedupe into one.
|
|
81
|
+
claim: {
|
|
82
|
+
notifier: plan.name,
|
|
83
|
+
key: `digest:${group}:${String(opened.endsAt)}`,
|
|
84
|
+
recipient: id,
|
|
85
|
+
channel: channel.name,
|
|
86
|
+
},
|
|
87
|
+
notifier: plan.name,
|
|
88
|
+
channel: channel.name,
|
|
89
|
+
recipients: 1,
|
|
90
|
+
ctx,
|
|
91
|
+
send: (abort) =>
|
|
92
|
+
channel.deliver({ ctx, recipient, event: newest, batch: events, signal: abort }),
|
|
93
|
+
},
|
|
94
|
+
signal,
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
if (sent) tally.delivered += 1;
|
|
98
|
+
else tally.replayed += 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A step's return value round-trips through JSON, so a replayed batch arrives with `at` as an ISO
|
|
104
|
+
* string that the type still calls a `Date`. Rebuilt here rather than trusted — a channel that
|
|
105
|
+
* formats it would throw on `at.getTime` only on the replay path, which is the path nothing tests.
|
|
106
|
+
*
|
|
107
|
+
* The cast is the digest store's untyped edge: it holds `NotifyEvent<unknown>` because one store
|
|
108
|
+
* serves every notifier, and what comes out of a slot is exactly what this notifier put in.
|
|
109
|
+
*/
|
|
110
|
+
const rehydrate = <Params>(
|
|
111
|
+
batch: readonly NotifyEvent<unknown>[],
|
|
112
|
+
): readonly NotifyEvent<Params>[] =>
|
|
113
|
+
batch.map((entry) => ({ ...entry, at: new Date(entry.at) })) as readonly NotifyEvent<Params>[];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The state one fan-out run carries, in its own module so `fanout.ts` and `fanout-digest.ts` can
|
|
2
|
+
// both name it without importing each other. Nothing here decides anything — it is the shape the
|
|
3
|
+
// two halves of the walk agree on.
|
|
4
|
+
|
|
5
|
+
import type { Ctx } from '@ultimat3/core';
|
|
6
|
+
import type { StepApi } from '@ultimat3/jobs';
|
|
7
|
+
import type { NotifyEvent, Recipient } from './notification';
|
|
8
|
+
import type { NotifyPlan } from './plan';
|
|
9
|
+
|
|
10
|
+
/** Mutated in place by every branch, so one run has one set of counters rather than a merge. */
|
|
11
|
+
export interface Tally {
|
|
12
|
+
delivered: number;
|
|
13
|
+
suppressed: number;
|
|
14
|
+
skipped: number;
|
|
15
|
+
replayed: number;
|
|
16
|
+
digested: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Walk<Params> {
|
|
20
|
+
readonly plan: NotifyPlan<Params>;
|
|
21
|
+
readonly event: NotifyEvent<Params>;
|
|
22
|
+
readonly audience: readonly Recipient[];
|
|
23
|
+
readonly ctx: Ctx;
|
|
24
|
+
readonly step: StepApi;
|
|
25
|
+
readonly tally: Tally;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A computed `tally[flag ? 'a' : 'b']` is a dynamic property write on a shipped object, which is
|
|
29
|
+
* the shape `bun run proto-index` exists to keep out of this tree. Two named fields instead. */
|
|
30
|
+
export const countSend = (tally: Tally, sent: boolean): void => {
|
|
31
|
+
if (sent) tally.delivered += 1;
|
|
32
|
+
else tally.replayed += 1;
|
|
33
|
+
};
|
package/src/fanout.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// The run body every notifier's job executes: resolve the audience once, then walk the channels in
|
|
2
|
+
// wait order, gating and delivering each.
|
|
3
|
+
//
|
|
4
|
+
// ONE job for the whole fan-out, where `noticed` enqueues a job per (recipient × channel). The
|
|
5
|
+
// reason is `step`: a step IS the retry unit here, so a provider blip on recipient 40 of 50 re-sends
|
|
6
|
+
// recipient 40 and replays the other 39 from the step store in microseconds — the same guarantee a
|
|
7
|
+
// job-per-recipient buys, without N queue rows, N idempotency keys and N manifest entries.
|
|
8
|
+
|
|
9
|
+
import type { JobRunArgs } from '@ultimat3/jobs';
|
|
10
|
+
import { attemptDelivery } from './attempt';
|
|
11
|
+
import type { NotifyChannel } from './channel';
|
|
12
|
+
import { isBulkChannel } from './channel';
|
|
13
|
+
import { NotifyFanoutTooWideError } from './errors';
|
|
14
|
+
import { flushDigest } from './fanout-digest';
|
|
15
|
+
import type { Tally, Walk } from './fanout-walk';
|
|
16
|
+
import { countSend } from './fanout-walk';
|
|
17
|
+
import type { NotifyEvent, Recipient } from './notification';
|
|
18
|
+
import type { NotifyPayload, NotifyPlan, NotifyReport, ResolvedDelivery } from './plan';
|
|
19
|
+
import { notifyStores } from './stores';
|
|
20
|
+
|
|
21
|
+
export async function runFanout<Params>(
|
|
22
|
+
plan: NotifyPlan<Params>,
|
|
23
|
+
args: JobRunArgs<NotifyPayload<Params>>,
|
|
24
|
+
): Promise<NotifyReport> {
|
|
25
|
+
const { input, step, ctx } = args;
|
|
26
|
+
// One step for BOTH facts, because both must survive a replay: a re-resolved audience would
|
|
27
|
+
// deliver to whoever subscribed during the wait, and a re-read clock would write a different
|
|
28
|
+
// `at` into the inbox on every attempt. Epoch ms rather than a `Date` — a step's return value
|
|
29
|
+
// round-trips through JSON, and a `Date` comes back a string.
|
|
30
|
+
const open = await step.run('open', async () => ({
|
|
31
|
+
at: ctx.now().getTime(),
|
|
32
|
+
recipients: input.recipients ?? [...(await plan.recipientsFor({ input: input.params, ctx }))],
|
|
33
|
+
}));
|
|
34
|
+
const audience = open.recipients;
|
|
35
|
+
if (audience.length > plan.maxRecipients) {
|
|
36
|
+
throw new NotifyFanoutTooWideError({
|
|
37
|
+
notifier: plan.name,
|
|
38
|
+
recipients: audience.length,
|
|
39
|
+
max: plan.maxRecipients,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const event: NotifyEvent<Params> = {
|
|
44
|
+
notifier: plan.name,
|
|
45
|
+
key: plan.keyFor(input.params),
|
|
46
|
+
params: input.params,
|
|
47
|
+
at: new Date(open.at),
|
|
48
|
+
};
|
|
49
|
+
const tally: Tally = { delivered: 0, suppressed: 0, skipped: 0, replayed: 0, digested: 0 };
|
|
50
|
+
const walk: Walk<Params> = { plan, event, audience, ctx, step, tally };
|
|
51
|
+
|
|
52
|
+
// `deliveries` is sorted by `waitMs`, so this sleeps the DELTA and never the sum: an in-app
|
|
53
|
+
// channel with no wait fires now even when the email beside it waits an hour.
|
|
54
|
+
let slept = 0;
|
|
55
|
+
for (const delivery of plan.deliveries) {
|
|
56
|
+
if (delivery.waitMs > slept) {
|
|
57
|
+
await step.sleep(`wait:${delivery.channel.name}`, delivery.waitMs - slept);
|
|
58
|
+
slept = delivery.waitMs;
|
|
59
|
+
}
|
|
60
|
+
await deliverOne(walk, delivery);
|
|
61
|
+
}
|
|
62
|
+
return { recipients: audience.length, ...tally };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function deliverOne<Params>(
|
|
66
|
+
walk: Walk<Params>,
|
|
67
|
+
delivery: ResolvedDelivery<Params>,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
const { event, ctx, tally } = walk;
|
|
70
|
+
// AFTER the sleep above, which is `noticed`'s documented order and the half most hand-rolled
|
|
71
|
+
// versions get wrong: a five-minute delay whose condition went false in minute three must send
|
|
72
|
+
// nothing. Evaluating before the wait would decide on a world that no longer exists.
|
|
73
|
+
if (delivery.when !== undefined && !(await delivery.when({ event, ctx }))) {
|
|
74
|
+
tally.skipped += walk.audience.length;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (delivery.unless !== undefined && (await delivery.unless({ event, ctx }))) {
|
|
78
|
+
tally.skipped += walk.audience.length;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const allowed = await allowedBy(walk, delivery.channel.name);
|
|
83
|
+
if (allowed.length === 0) return;
|
|
84
|
+
|
|
85
|
+
const channel = delivery.channel;
|
|
86
|
+
if (delivery.digestMs !== undefined) {
|
|
87
|
+
// `notifier()` refused a digest on a bulk channel at declaration, so this narrowing cannot
|
|
88
|
+
// fail at run time — it is here because the type system cannot read that refusal.
|
|
89
|
+
if (isBulkChannel(channel)) return;
|
|
90
|
+
await flushDigest({ walk, channel: channel as NotifyChannel<Params>, delivery, allowed });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (isBulkChannel(channel)) {
|
|
94
|
+
const claim = {
|
|
95
|
+
notifier: walk.plan.name,
|
|
96
|
+
key: event.key,
|
|
97
|
+
// ONE row for the audience: a bulk send is one thing, and half of it is not a state this
|
|
98
|
+
// package can represent.
|
|
99
|
+
recipient: null,
|
|
100
|
+
channel: channel.name,
|
|
101
|
+
};
|
|
102
|
+
const sent = await walk.step.run(`deliver:${channel.name}`, (signal) =>
|
|
103
|
+
attemptDelivery(
|
|
104
|
+
{
|
|
105
|
+
ledger: notifyStores().ledger,
|
|
106
|
+
claim,
|
|
107
|
+
notifier: walk.plan.name,
|
|
108
|
+
channel: channel.name,
|
|
109
|
+
recipients: allowed.length,
|
|
110
|
+
ctx,
|
|
111
|
+
send: (abort) =>
|
|
112
|
+
channel.deliver({ ctx, recipients: allowed, event, batch: [event], signal: abort }),
|
|
113
|
+
},
|
|
114
|
+
signal,
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
countSend(tally, sent);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const recipient of allowed) {
|
|
122
|
+
const claim = {
|
|
123
|
+
notifier: walk.plan.name,
|
|
124
|
+
key: event.key,
|
|
125
|
+
recipient: recipient.id,
|
|
126
|
+
channel: channel.name,
|
|
127
|
+
};
|
|
128
|
+
// The recipient's id names the step because the id survives a replay and a loop index does
|
|
129
|
+
// not — a step name is the replay key (X_STEP_DUPLICATE).
|
|
130
|
+
const sent = await walk.step.run(`deliver:${channel.name}:${recipient.id}`, (signal) =>
|
|
131
|
+
attemptDelivery(
|
|
132
|
+
{
|
|
133
|
+
ledger: notifyStores().ledger,
|
|
134
|
+
claim,
|
|
135
|
+
notifier: walk.plan.name,
|
|
136
|
+
channel: channel.name,
|
|
137
|
+
recipients: 1,
|
|
138
|
+
ctx,
|
|
139
|
+
send: (abort) =>
|
|
140
|
+
channel.deliver({ ctx, recipient, event, batch: [event], signal: abort }),
|
|
141
|
+
},
|
|
142
|
+
signal,
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
countSend(tally, sent);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The preference gate, per recipient and per channel — deliberately NOT inside a durable step. It
|
|
151
|
+
* is a read, so replaying it costs a query rather than a row, and re-asking on a retry is the more
|
|
152
|
+
* correct answer anyway: somebody who opted out during a five-minute `wait` should not receive the
|
|
153
|
+
* mail the first attempt had already decided to send.
|
|
154
|
+
*/
|
|
155
|
+
async function allowedBy<Params>(
|
|
156
|
+
walk: Walk<Params>,
|
|
157
|
+
channel: string,
|
|
158
|
+
): Promise<readonly Recipient[]> {
|
|
159
|
+
const { preferences } = notifyStores();
|
|
160
|
+
const allowed: Recipient[] = [];
|
|
161
|
+
for (const recipient of walk.audience) {
|
|
162
|
+
const ok = await preferences.allows({
|
|
163
|
+
recipient,
|
|
164
|
+
notifier: walk.plan.name,
|
|
165
|
+
channel,
|
|
166
|
+
event: walk.event,
|
|
167
|
+
ctx: walk.ctx,
|
|
168
|
+
});
|
|
169
|
+
if (ok) allowed.push(recipient);
|
|
170
|
+
else walk.tally.suppressed += 1;
|
|
171
|
+
}
|
|
172
|
+
return allowed;
|
|
173
|
+
}
|