@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
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The vocabulary a notification is addressed with: who it is for, and what happened.
|
|
2
|
+
//
|
|
3
|
+
// Both shapes are STRUCTURAL and deliberately thin. A `Recipient` is not a user row and never
|
|
4
|
+
// becomes one: this package cannot read an app's `users` table, and a channel that needs more than
|
|
5
|
+
// an id resolves it itself inside `deliver`.
|
|
6
|
+
|
|
7
|
+
import type { Schema } from '@ultimat3/schema';
|
|
8
|
+
import { t } from '@ultimat3/schema';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whoever a notification is addressed to.
|
|
12
|
+
*
|
|
13
|
+
* `id` is the only required field, because it is the only one the framework itself reads: it is
|
|
14
|
+
* half of the delivery ledger's unique key and half of an inbox row. Everything else is a hint a
|
|
15
|
+
* channel may use.
|
|
16
|
+
*
|
|
17
|
+
* `to` is ONE transport address — an email, a webhook URL, a device token — and not a bag keyed by
|
|
18
|
+
* channel. A bag would be a `Record` this package reads with a data key, which is exactly the
|
|
19
|
+
* prototype-index defect `bun run proto-index` refuses; and an app whose recipients carry two
|
|
20
|
+
* different addresses already has the lookup, so its channel's `deliver` is where it belongs.
|
|
21
|
+
*/
|
|
22
|
+
export interface Recipient {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
/** BCP-47. A channel that renders text for a person must not guess this. */
|
|
25
|
+
readonly locale?: string | undefined;
|
|
26
|
+
/** IANA zone. Required by the house rule for any date a channel renders. */
|
|
27
|
+
readonly tz?: string | undefined;
|
|
28
|
+
/** The transport address, when the app already has it at fan-out time. */
|
|
29
|
+
readonly to?: string | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validated at the queue boundary, because a recipient list handed to `.enqueue()` is serialised
|
|
34
|
+
* JSON by the time the worker reads it — the one place an unvalidated `to` could reach a channel.
|
|
35
|
+
*/
|
|
36
|
+
export const recipientSchema: Schema<unknown, Recipient> = t.object({
|
|
37
|
+
id: t.string,
|
|
38
|
+
locale: t.locale.optional(),
|
|
39
|
+
tz: t.timezone.optional(),
|
|
40
|
+
to: t.string.optional(),
|
|
41
|
+
}) as unknown as Schema<unknown, Recipient>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One thing that happened, once. `noticed` writes this as an `Event` row; here it is a value the
|
|
45
|
+
* fan-out carries, and it is persisted only by the channels that persist — the inbox writes it,
|
|
46
|
+
* the ledger records that a channel accepted it, and a notifier with neither installed writes no
|
|
47
|
+
* rows at all.
|
|
48
|
+
*/
|
|
49
|
+
export interface NotifyEvent<Params = unknown> {
|
|
50
|
+
/** The notifier's name — its queue key, its manifest row, and the ledger's first column. */
|
|
51
|
+
readonly notifier: string;
|
|
52
|
+
/**
|
|
53
|
+
* What makes two invocations the SAME notification. Declared by the author as `key`, and used
|
|
54
|
+
* twice on purpose: it is the job's `idempotencyKey` and the delivery ledger's event column.
|
|
55
|
+
* Those are one question — "have we already told them this?" — asked at two layers, so two
|
|
56
|
+
* values would be two answers that can disagree.
|
|
57
|
+
*/
|
|
58
|
+
readonly key: string;
|
|
59
|
+
/** The validated payload. */
|
|
60
|
+
readonly params: Params;
|
|
61
|
+
/** When the fan-out ran, from `ctx.now()` — never `new Date()`, so a frozen clock holds. */
|
|
62
|
+
readonly at: Date;
|
|
63
|
+
}
|
package/src/notifier.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// `notifier()` — one declaration, many channels, declared as a `job` and NOT as a ninth primitive.
|
|
2
|
+
//
|
|
3
|
+
// A notification is durable background work with an input schema, an idempotency key, a retry
|
|
4
|
+
// policy and a queue, which is the definition of a `job` — so this file is a FACTORY over `job()`,
|
|
5
|
+
// exactly as `llm()` is one over `action()` and `backfill()` is one over `job()`. That is what
|
|
6
|
+
// gives a notifier `.enqueue()`, the worker's cancellation, the dead-letter path, `x jobs show` and
|
|
7
|
+
// a manifest row without a line here.
|
|
8
|
+
//
|
|
9
|
+
// The declaration lives here and the fan-out lives in `fanout.ts` — the same split `backfill.ts`
|
|
10
|
+
// and `backfill-pass.ts` already have.
|
|
11
|
+
|
|
12
|
+
import type { JobHandle, JobTenant, RetryPolicy } from '@ultimat3/jobs';
|
|
13
|
+
import { DEFAULT_RETRY, job } from '@ultimat3/jobs';
|
|
14
|
+
import type { Schema } from '@ultimat3/schema';
|
|
15
|
+
import { t } from '@ultimat3/schema';
|
|
16
|
+
import { isBulkChannel } from './channel';
|
|
17
|
+
import {
|
|
18
|
+
NotifyChannelDuplicateError,
|
|
19
|
+
NotifyChannelsEmptyError,
|
|
20
|
+
NotifyDigestUnsupportedError,
|
|
21
|
+
} from './errors';
|
|
22
|
+
import { runFanout } from './fanout';
|
|
23
|
+
import type { Recipient } from './notification';
|
|
24
|
+
import { recipientSchema } from './notification';
|
|
25
|
+
import type {
|
|
26
|
+
ChannelDelivery,
|
|
27
|
+
NotifyDuration,
|
|
28
|
+
NotifyPayload,
|
|
29
|
+
NotifyPlan,
|
|
30
|
+
RecipientArgs,
|
|
31
|
+
ResolvedDelivery,
|
|
32
|
+
} from './plan';
|
|
33
|
+
import { toDurationMs } from './plan';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One run is one queue row and one step trace, and every recipient in it is a durable step row. The
|
|
37
|
+
* ceiling is a real number rather than a shrug: past it the shape is a bulk channel or a paged
|
|
38
|
+
* sweep, and `X_NOTIFY_FANOUT_TOO_WIDE` says so with both numbers in it.
|
|
39
|
+
*/
|
|
40
|
+
export const DEFAULT_MAX_RECIPIENTS = 500;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `NoInfer` on every field but `input`, deliberately. Without it `Params` is inferred from all six
|
|
44
|
+
* at once and `deliver: [inAppChannel()]` — whose own default is `unknown` — wins, so an author
|
|
45
|
+
* who did not write `notifier<Params>(…)` got `params` typed `unknown` in `key`, `tenant`,
|
|
46
|
+
* `recipients` and every gate. The schema is the ONE declaration of what the params are, which is
|
|
47
|
+
* the same rule every other primitive in this framework follows; this makes the inference agree.
|
|
48
|
+
*/
|
|
49
|
+
export interface NotifierDefinition<Params> {
|
|
50
|
+
/**
|
|
51
|
+
* REQUIRED, unlike a job's. A notifier's name is a durable key — the queue row, the delivery
|
|
52
|
+
* ledger, every inbox row and the app's preference taxonomy all carry it — so it is never left
|
|
53
|
+
* to whichever export name a module happened to use.
|
|
54
|
+
*/
|
|
55
|
+
readonly name: string;
|
|
56
|
+
/** The params, as a schema and not a `required_params` list: validated once at the boundary,
|
|
57
|
+
* which is also what gives the notifier a manifest row and a typed client. */
|
|
58
|
+
readonly input: Schema<unknown, Params>;
|
|
59
|
+
/** REQUIRED, exactly as on `job()`. A notifier IS a job and declares the org it runs under. */
|
|
60
|
+
readonly tenant: JobTenant<NoInfer<Params>>;
|
|
61
|
+
/**
|
|
62
|
+
* What makes two invocations the SAME notification, for the queue AND for the delivery ledger.
|
|
63
|
+
* Required for the reason `job().idempotencyKey` is: queues deliver at least once, so "did this
|
|
64
|
+
* already go out?" is a question every notifier must be able to answer.
|
|
65
|
+
*/
|
|
66
|
+
readonly key: (params: NoInfer<Params>) => string;
|
|
67
|
+
/**
|
|
68
|
+
* The audience, resolved on the worker inside a durable step. Omit it and every enqueue must
|
|
69
|
+
* name its own recipients — `noticed`'s two modes, and both are here because both are real: a
|
|
70
|
+
* "post liked" notifier derives its audience, and an admin broadcast is handed one.
|
|
71
|
+
*/
|
|
72
|
+
readonly recipients?:
|
|
73
|
+
| ((
|
|
74
|
+
args: RecipientArgs<NoInfer<Params>>,
|
|
75
|
+
) => Promise<readonly Recipient[]> | readonly Recipient[])
|
|
76
|
+
| undefined;
|
|
77
|
+
/** At least one. Order does not matter — the fan-out sorts by `wait`. */
|
|
78
|
+
readonly deliver: readonly ChannelDelivery<NoInfer<Params>>[];
|
|
79
|
+
readonly queue?: string | undefined;
|
|
80
|
+
readonly retry?: RetryPolicy | undefined;
|
|
81
|
+
readonly timeout?: NotifyDuration | undefined;
|
|
82
|
+
/** Defaults to `DEFAULT_MAX_RECIPIENTS`. */
|
|
83
|
+
readonly maxRecipients?: number | undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Normalised once, at declaration: the run body never re-parses a duration per recipient. */
|
|
87
|
+
function resolve<Params>(
|
|
88
|
+
name: string,
|
|
89
|
+
deliveries: readonly ChannelDelivery<Params>[],
|
|
90
|
+
): readonly ResolvedDelivery<Params>[] {
|
|
91
|
+
if (deliveries.length === 0) throw new NotifyChannelsEmptyError({ notifier: name });
|
|
92
|
+
const seen = new Set<string>();
|
|
93
|
+
const resolved = deliveries.map((delivery): ResolvedDelivery<Params> => {
|
|
94
|
+
const channel = delivery.channel;
|
|
95
|
+
if (seen.has(channel.name)) {
|
|
96
|
+
throw new NotifyChannelDuplicateError({ notifier: name, channel: channel.name });
|
|
97
|
+
}
|
|
98
|
+
seen.add(channel.name);
|
|
99
|
+
if (delivery.digest !== undefined && isBulkChannel(channel)) {
|
|
100
|
+
throw new NotifyDigestUnsupportedError({ notifier: name, channel: channel.name });
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
channel,
|
|
104
|
+
waitMs: delivery.wait === undefined ? 0 : toDurationMs(delivery.wait),
|
|
105
|
+
when: delivery.if,
|
|
106
|
+
unless: delivery.unless,
|
|
107
|
+
digestMs: delivery.digest === undefined ? undefined : toDurationMs(delivery.digest.window),
|
|
108
|
+
group: delivery.digest?.group,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
// Ascending, so the fan-out sleeps the delta between one channel and the next. A stable sort
|
|
112
|
+
// keeps two channels with the same wait in declaration order, which is the order their step
|
|
113
|
+
// names appear in a trace.
|
|
114
|
+
return [...resolved].sort((a, b) => a.waitMs - b.waitMs);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function notifier<Params>(
|
|
118
|
+
definition: NotifierDefinition<Params>,
|
|
119
|
+
): JobHandle<NotifyPayload<Params>> {
|
|
120
|
+
const deliveries = resolve(definition.name, definition.deliver);
|
|
121
|
+
const declared = definition.recipients;
|
|
122
|
+
const plan: NotifyPlan<Params> = {
|
|
123
|
+
name: definition.name,
|
|
124
|
+
maxRecipients: definition.maxRecipients ?? DEFAULT_MAX_RECIPIENTS,
|
|
125
|
+
deliveries,
|
|
126
|
+
keyFor: (params) => definition.key(params),
|
|
127
|
+
// Bound to the definition rather than torn off it, so an author who writes `recipients` as a
|
|
128
|
+
// method rather than an arrow still gets the right `this`. An enqueue that names no audience
|
|
129
|
+
// and a notifier that resolves none is an empty fan-out, not an error: a broadcast with no
|
|
130
|
+
// subscribers left is a legitimate run that delivers nothing.
|
|
131
|
+
recipientsFor: (args) => (declared === undefined ? [] : declared.call(definition, args)),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Nested rather than spread into the job's input: an app's params may legitimately carry a field
|
|
135
|
+
// called `recipients`, and a reserved top-level key would collide with the first notification
|
|
136
|
+
// that is *about* recipients.
|
|
137
|
+
const payload = t.object({
|
|
138
|
+
params: definition.input,
|
|
139
|
+
recipients: t.array(recipientSchema).optional(),
|
|
140
|
+
}) as unknown as Schema<unknown, NotifyPayload<Params>>;
|
|
141
|
+
|
|
142
|
+
return job<NotifyPayload<Params>>({
|
|
143
|
+
name: definition.name,
|
|
144
|
+
input: payload,
|
|
145
|
+
// The declared key verbatim. One value for the queue's dedupe and the ledger's event column,
|
|
146
|
+
// because they ask one question — a second spelling would be two answers that can disagree.
|
|
147
|
+
idempotencyKey: (value) => definition.key(value.params),
|
|
148
|
+
// Forwarded, never decided here: a notifier that declared its tenant and then ran under
|
|
149
|
+
// somebody else's would be a factory deciding authz.
|
|
150
|
+
tenant:
|
|
151
|
+
typeof definition.tenant === 'function'
|
|
152
|
+
? (value: NotifyPayload<Params>) =>
|
|
153
|
+
(definition.tenant as (params: Params) => string)(value.params)
|
|
154
|
+
: definition.tenant,
|
|
155
|
+
retry: definition.retry ?? DEFAULT_RETRY,
|
|
156
|
+
...(definition.queue === undefined ? {} : { queue: definition.queue }),
|
|
157
|
+
...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
|
|
158
|
+
run: (args) => runFanout(plan, args),
|
|
159
|
+
});
|
|
160
|
+
}
|
package/src/plan.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The declaration an app writes, and the resolved plan the fan-out runs. Two shapes rather than
|
|
2
|
+
// one: everything an author may express in `DurationInput` or omit is normalised ONCE at
|
|
3
|
+
// declaration, so the run body never re-parses a duration or re-decides a default per recipient.
|
|
4
|
+
|
|
5
|
+
import type { Ctx } from '@ultimat3/core';
|
|
6
|
+
import { parseDuration } from '@ultimat3/time';
|
|
7
|
+
import type { AnyNotifyChannel } from './channel';
|
|
8
|
+
import type { NotifyEvent, Recipient } from './notification';
|
|
9
|
+
|
|
10
|
+
/** `'5m'` | `300_000`. Numbers pass through so a caller may stay explicit, exactly as jobs does. */
|
|
11
|
+
export type NotifyDuration = string | number;
|
|
12
|
+
|
|
13
|
+
export const toDurationMs = (duration: NotifyDuration): number =>
|
|
14
|
+
typeof duration === 'number' ? duration : parseDuration(duration);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What a notifier is enqueued with.
|
|
18
|
+
*
|
|
19
|
+
* `noticed`'s `PostLiked.with(params).deliver(recipients)` in one object. Nested rather than
|
|
20
|
+
* flattened: an app's params may legitimately carry a field called `recipients`, and a payload
|
|
21
|
+
* that could collide with the framework's own reserved key is a bug waiting for the first app
|
|
22
|
+
* whose notification is *about* recipients.
|
|
23
|
+
*/
|
|
24
|
+
export interface NotifyPayload<Params> {
|
|
25
|
+
readonly params: Params;
|
|
26
|
+
/**
|
|
27
|
+
* The audience, when the caller already knows it. Omitted, the notifier's own `recipients`
|
|
28
|
+
* resolver runs inside a durable step — which is the form to prefer, because it is re-derived
|
|
29
|
+
* on the worker rather than serialised through the queue.
|
|
30
|
+
*/
|
|
31
|
+
readonly recipients?: readonly Recipient[] | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What `if` / `unless` decide about. */
|
|
35
|
+
export interface DeliveryGate<Params> {
|
|
36
|
+
readonly event: NotifyEvent<Params>;
|
|
37
|
+
readonly ctx: Ctx;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DigestWindow<Params> {
|
|
41
|
+
/** How long the window stays open, from its FIRST event. Rolling, never calendar-aligned. */
|
|
42
|
+
readonly window: NotifyDuration;
|
|
43
|
+
/**
|
|
44
|
+
* What coalesces together within one recipient's slot. Defaults to the notifier's name — every
|
|
45
|
+
* `post.commented` for one person in one digest. Return a thread id for one digest per thread.
|
|
46
|
+
*/
|
|
47
|
+
readonly group?: ((event: NotifyEvent<Params>) => string) | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ChannelDelivery<Params> {
|
|
51
|
+
readonly channel: AnyNotifyChannel<Params>;
|
|
52
|
+
/**
|
|
53
|
+
* How long to hold this channel before it fires. `noticed`'s `wait`, and it behaves the way
|
|
54
|
+
* `noticed` promises and most hand-rolled versions do not: `if` and `unless` are evaluated
|
|
55
|
+
* AFTER it, so a five-minute delay whose condition went false in minute three sends nothing.
|
|
56
|
+
*/
|
|
57
|
+
readonly wait?: NotifyDuration | undefined;
|
|
58
|
+
/** Fire only when this answers true. Evaluated after `wait`. */
|
|
59
|
+
readonly if?: ((gate: DeliveryGate<Params>) => boolean | Promise<boolean>) | undefined;
|
|
60
|
+
/** Fire unless this answers true. Both may be declared; both must pass. */
|
|
61
|
+
readonly unless?: ((gate: DeliveryGate<Params>) => boolean | Promise<boolean>) | undefined;
|
|
62
|
+
/** Coalesce into one delivery per recipient per window. Individual channels only. */
|
|
63
|
+
readonly digest?: DigestWindow<Params> | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One delivery with every duration in ms and every default already decided. */
|
|
67
|
+
export interface ResolvedDelivery<Params> {
|
|
68
|
+
readonly channel: AnyNotifyChannel<Params>;
|
|
69
|
+
readonly waitMs: number;
|
|
70
|
+
readonly when: ((gate: DeliveryGate<Params>) => boolean | Promise<boolean>) | undefined;
|
|
71
|
+
readonly unless: ((gate: DeliveryGate<Params>) => boolean | Promise<boolean>) | undefined;
|
|
72
|
+
readonly digestMs: number | undefined;
|
|
73
|
+
readonly group: ((event: NotifyEvent<Params>) => string) | undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface RecipientArgs<Params> {
|
|
77
|
+
readonly input: Params;
|
|
78
|
+
readonly ctx: Ctx;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Everything `runFanout` reads. Built once by `notifier()`, never rebuilt per run. */
|
|
82
|
+
export interface NotifyPlan<Params> {
|
|
83
|
+
readonly name: string;
|
|
84
|
+
readonly maxRecipients: number;
|
|
85
|
+
/** Sorted by `waitMs` ascending — the fan-out sleeps the DELTA between one and the next, so a
|
|
86
|
+
* channel with no wait fires immediately even when a later one waits an hour. */
|
|
87
|
+
readonly deliveries: readonly ResolvedDelivery<Params>[];
|
|
88
|
+
keyFor(params: Params): string;
|
|
89
|
+
recipientsFor(args: RecipientArgs<Params>): Promise<readonly Recipient[]> | readonly Recipient[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** What one run reports, bounded so `x jobs show` can print it. */
|
|
93
|
+
export interface NotifyReport {
|
|
94
|
+
readonly recipients: number;
|
|
95
|
+
/** Deliveries this run actually handed to a channel. */
|
|
96
|
+
readonly delivered: number;
|
|
97
|
+
/** Suppressed by the preference gate. */
|
|
98
|
+
readonly suppressed: number;
|
|
99
|
+
/** Skipped by `if` / `unless`, counted per (recipient, channel) so it compares with `delivered`. */
|
|
100
|
+
readonly skipped: number;
|
|
101
|
+
/** Already `sent` in the ledger — a replay that correctly did nothing. */
|
|
102
|
+
readonly replayed: number;
|
|
103
|
+
/** Appended to an open digest window that somebody else owns the flush of. */
|
|
104
|
+
readonly digested: number;
|
|
105
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The preference gate: one question, asked once per (recipient, notifier, channel), after the wait.
|
|
2
|
+
//
|
|
3
|
+
// `noticed` has no gate at all — it leaves opt-out to a `config.if` closure the app writes per
|
|
4
|
+
// channel, which is four teams writing the same closure four times. So the GATE ships here. What
|
|
5
|
+
// does NOT ship, and never will, is what it consults: the notification taxonomy is the app's, and
|
|
6
|
+
// so is `quietHours` — "quiet" is 22:00–07:00 in the recipient's zone for one product, working
|
|
7
|
+
// hours only for the next, and a framework that picked one would be shipping a business
|
|
8
|
+
// convention (axiom 8).
|
|
9
|
+
//
|
|
10
|
+
// Which is why this file declares an interface and two trivial implementations and nothing else.
|
|
11
|
+
|
|
12
|
+
import type { Ctx } from '@ultimat3/core';
|
|
13
|
+
import type { NotifyEvent, Recipient } from './notification';
|
|
14
|
+
|
|
15
|
+
export interface PreferenceQuery<Params = unknown> {
|
|
16
|
+
readonly recipient: Recipient;
|
|
17
|
+
/** The notifier's name — the app's taxonomy key, whatever its taxonomy is. */
|
|
18
|
+
readonly notifier: string;
|
|
19
|
+
readonly channel: string;
|
|
20
|
+
readonly event: NotifyEvent<Params>;
|
|
21
|
+
/** The run's context: `ctx.now()` is the clock a quiet-hours rule must read, never `Date.now()`. */
|
|
22
|
+
readonly ctx: Ctx;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PreferenceStore {
|
|
26
|
+
/**
|
|
27
|
+
* `false` suppresses THIS channel and nothing else — the other channels of the same notifier
|
|
28
|
+
* still fire. That is the whole point of asking per channel: "email me weekly, ping me in-app
|
|
29
|
+
* immediately" is one recipient's normal answer, and a gate that returned one boolean for the
|
|
30
|
+
* notification could not express it.
|
|
31
|
+
*/
|
|
32
|
+
allows(query: PreferenceQuery): Promise<boolean> | boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The default, and it is `true`. An app that installs nothing gets `noticed`'s behaviour, which is
|
|
37
|
+
* the right default for a framework: silence-by-default would mean a notifier that delivers
|
|
38
|
+
* nothing until an app writes a store, and the first symptom would be a missing email.
|
|
39
|
+
*/
|
|
40
|
+
export const allowAllPreferences = (): PreferenceStore => ({ allows: () => true });
|
|
41
|
+
|
|
42
|
+
export interface MemoryPreferenceStore extends PreferenceStore {
|
|
43
|
+
/** Opt `recipient` out of `channel` for `notifier`. `'*'` as the notifier is every notifier. */
|
|
44
|
+
deny(input: { recipient: string; notifier: string; channel: string }): void;
|
|
45
|
+
clear(): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const optOutKey = (recipient: string, notifier: string, channel: string): string =>
|
|
49
|
+
JSON.stringify([recipient, notifier, channel]);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A test double and a dev store, not a product. Real preferences live in the app's own table
|
|
53
|
+
* beside the taxonomy that names them; this exists so the gate can be exercised without one.
|
|
54
|
+
*/
|
|
55
|
+
export function createMemoryPreferenceStore(): MemoryPreferenceStore {
|
|
56
|
+
const denied = new Set<string>();
|
|
57
|
+
return {
|
|
58
|
+
allows(query) {
|
|
59
|
+
const id = query.recipient.id;
|
|
60
|
+
return (
|
|
61
|
+
!denied.has(optOutKey(id, query.notifier, query.channel)) &&
|
|
62
|
+
!denied.has(optOutKey(id, '*', query.channel))
|
|
63
|
+
);
|
|
64
|
+
},
|
|
65
|
+
deny(input) {
|
|
66
|
+
denied.add(optOutKey(input.recipient, input.notifier, input.channel));
|
|
67
|
+
},
|
|
68
|
+
clear() {
|
|
69
|
+
denied.clear();
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
package/src/stores.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// The four seams a fan-out reads, installed once per process. Same shape as `setJobDriver` in
|
|
2
|
+
// @ultimat3/jobs and for the same reason: a notifier is declared at import time, long before a
|
|
3
|
+
// boot has a database, so the store cannot be a constructor argument.
|
|
4
|
+
//
|
|
5
|
+
// One installer, never four. Handing them over in one call is what makes "the ledger is Postgres
|
|
6
|
+
// but the inbox is still the memory one" a visible line of code rather than a forgotten fifth call.
|
|
7
|
+
|
|
8
|
+
import type { DigestStore } from './digest';
|
|
9
|
+
import { NotifyStoreMissingError } from './errors';
|
|
10
|
+
import type { InboxStore } from './inbox';
|
|
11
|
+
import type { DeliveryLedger } from './ledger';
|
|
12
|
+
import { createMemoryDeliveryLedger } from './ledger';
|
|
13
|
+
import type { PreferenceStore } from './preferences';
|
|
14
|
+
import { allowAllPreferences } from './preferences';
|
|
15
|
+
|
|
16
|
+
export interface NotifyStores {
|
|
17
|
+
/**
|
|
18
|
+
* Defaults to `createMemoryDeliveryLedger()`. A default is correct here and nowhere else in this
|
|
19
|
+
* interface: one process with one replica is genuinely deduped by a heap map, and the failure
|
|
20
|
+
* mode of having none at all — a replayed attempt sending twice — is worse than the failure mode
|
|
21
|
+
* of a dev default, which is a second send only after a restart.
|
|
22
|
+
*/
|
|
23
|
+
readonly ledger?: DeliveryLedger | undefined;
|
|
24
|
+
/** No default: a channel that writes the inbox refuses rather than dropping the row. */
|
|
25
|
+
readonly inbox?: InboxStore | undefined;
|
|
26
|
+
/** Defaults to `allowAllPreferences()` — `noticed`'s behaviour, and the safe one for a boot
|
|
27
|
+
* that has not written a preferences table yet. Denying by default would mean a notifier that
|
|
28
|
+
* silently delivers nothing, whose first symptom is a missing email. */
|
|
29
|
+
readonly preferences?: PreferenceStore | undefined;
|
|
30
|
+
/** No default: a digest window has nowhere to coalesce into. */
|
|
31
|
+
readonly digest?: DigestStore | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface InstalledNotifyStores {
|
|
35
|
+
readonly ledger: DeliveryLedger;
|
|
36
|
+
readonly inbox: InboxStore | undefined;
|
|
37
|
+
readonly preferences: PreferenceStore;
|
|
38
|
+
readonly digest: DigestStore | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const defaults = (): InstalledNotifyStores => ({
|
|
42
|
+
ledger: createMemoryDeliveryLedger(),
|
|
43
|
+
inbox: undefined,
|
|
44
|
+
preferences: allowAllPreferences(),
|
|
45
|
+
digest: undefined,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
let installed: InstalledNotifyStores = defaults();
|
|
49
|
+
|
|
50
|
+
/** Whole-object replacement, never a merge: two calls with different halves is the split-brain
|
|
51
|
+
* `RuntimeOverrides` exists to refuse, and a merge would hide the second call's omissions. */
|
|
52
|
+
export function setNotifyStores(stores: NotifyStores): void {
|
|
53
|
+
installed = {
|
|
54
|
+
ledger: stores.ledger ?? createMemoryDeliveryLedger(),
|
|
55
|
+
inbox: stores.inbox,
|
|
56
|
+
preferences: stores.preferences ?? allowAllPreferences(),
|
|
57
|
+
digest: stores.digest,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const notifyStores = (): InstalledNotifyStores => installed;
|
|
62
|
+
|
|
63
|
+
/** Tests only — the same escape `resetJobDriver` offers, so one suite cannot leak into the next. */
|
|
64
|
+
export function resetNotifyStores(): void {
|
|
65
|
+
installed = defaults();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The inbox, or the refusal that names the install call. Never an optional chain at the call
|
|
69
|
+
* site: a silently skipped inbox write is a notification the user never sees and nobody logs. */
|
|
70
|
+
export function requireInbox(notifier: string): InboxStore {
|
|
71
|
+
const store = installed.inbox;
|
|
72
|
+
if (store === undefined) throw new NotifyStoreMissingError({ notifier, store: 'inbox' });
|
|
73
|
+
return store;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function requireDigest(notifier: string): DigestStore {
|
|
77
|
+
const store = installed.digest;
|
|
78
|
+
if (store === undefined) throw new NotifyStoreMissingError({ notifier, store: 'digest' });
|
|
79
|
+
return store;
|
|
80
|
+
}
|
package/src/type-pins.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Compile-time pins for the inference this package promises. Source, not a `.test.ts`, on purpose:
|
|
2
|
+
// `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b` never reads a test file and a claim
|
|
3
|
+
// written there can never fail. Nothing here emits — a regression is a build error (axiom 3).
|
|
4
|
+
|
|
5
|
+
import type { JobHandle } from '@ultimat3/jobs';
|
|
6
|
+
import { t } from '@ultimat3/schema';
|
|
7
|
+
import { inAppChannel } from './channel-in-app';
|
|
8
|
+
import { notifier } from './notifier';
|
|
9
|
+
import type { NotifyPayload } from './plan';
|
|
10
|
+
|
|
11
|
+
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
12
|
+
type Assert<T extends true> = T;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* THE README'S DECLARATION, without an explicit type argument — the form every app writes and the
|
|
16
|
+
* one the `typecheck` step cannot read out of a README fence.
|
|
17
|
+
*
|
|
18
|
+
* It is here because it did not compile. Before `NoInfer` landed on every field but `input`,
|
|
19
|
+
* `Params` was inferred from all six at once and `deliver: [inAppChannel()]` — whose own default is
|
|
20
|
+
* `unknown` — won, so `params.postId` below was a `TS18046: 'params' is of type 'unknown'`. The
|
|
21
|
+
* schema is the one declaration of what the params are; this is what makes the inference agree.
|
|
22
|
+
*/
|
|
23
|
+
function readmeDeclaration() {
|
|
24
|
+
return notifier({
|
|
25
|
+
name: 'notify.type-pin',
|
|
26
|
+
input: t.object({ postId: t.uuid, orgId: t.uuid, author: t.string }),
|
|
27
|
+
tenant: (params) => params.orgId,
|
|
28
|
+
key: (params) => `pin:${params.postId}`,
|
|
29
|
+
recipients: ({ input }) => [{ id: input.author }],
|
|
30
|
+
deliver: [{ channel: inAppChannel(), unless: ({ event }) => event.params.author === 'system' }],
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Inside a function that is never CALLED, so this module registers nothing. `notifier()` builds a
|
|
36
|
+
* `job()` and a job's name is claimed at declaration — a module-scope call here would put a
|
|
37
|
+
* phantom `notify.type-pin` in every app's queue registry, which is the "built and never called"
|
|
38
|
+
* defect in its most literal form.
|
|
39
|
+
*/
|
|
40
|
+
type Inferred = ReturnType<typeof readmeDeclaration>;
|
|
41
|
+
|
|
42
|
+
/** A notifier IS a `job`, so every worker path and every `x jobs` command reaches it unchanged. */
|
|
43
|
+
export type _NotifierIsAJobHandle = Assert<
|
|
44
|
+
Inferred extends JobHandle<NotifyPayload<{ postId: string; orgId: string; author: string }>>
|
|
45
|
+
? true
|
|
46
|
+
: false
|
|
47
|
+
>;
|
|
48
|
+
|
|
49
|
+
/** The payload nests `params`; a flattened one would collide with an app field called
|
|
50
|
+
* `recipients`, which is the reason it is nested at all. */
|
|
51
|
+
export type _PayloadNestsParams = Assert<
|
|
52
|
+
Parameters<Inferred['idempotencyKeyFor']>[0] extends { params: { postId: string } } ? true : false
|
|
53
|
+
>;
|