@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.
@@ -0,0 +1,162 @@
1
+ // The shared in-app inbox: one Postgres table, applied by the boot the way `x_jobs` is.
2
+ // Statements are spelled out so an agent can run the exact one it saw in a log.
3
+
4
+ import { uuid } from '@ultimat3/core';
5
+ import type { PgExecutor } from '@ultimat3/jobs';
6
+ import type { InboxRow, InboxStore, InboxWrite } from './inbox';
7
+ import { DEFAULT_INBOX_PAGE } from './inbox';
8
+
9
+ /**
10
+ * `unique (recipient, notifier, key)` is what makes `add` idempotent, and it is the same identity
11
+ * the delivery ledger claims on — one notification, one row, however many times the job replays.
12
+ *
13
+ * The unread badge wants a PARTIAL index on `read_at is null`, which is the query that runs on
14
+ * every page load. It is spelled out here because this table is DDL rather than an `entity()`
15
+ * declaration, so nothing has to work around the invariant DSL's missing null predicate.
16
+ */
17
+ export const SQL_NOTIFY_INBOX_TABLE = `
18
+ create table if not exists x_notify_inbox (
19
+ id uuid primary key,
20
+ recipient text not null,
21
+ notifier text not null,
22
+ key text not null,
23
+ params jsonb not null,
24
+ created_at timestamptz not null default now(),
25
+ seen_at timestamptz,
26
+ read_at timestamptz,
27
+ unique (recipient, notifier, key)
28
+ );
29
+
30
+ create index if not exists x_notify_inbox_page_idx
31
+ on x_notify_inbox (recipient, created_at desc, id);
32
+
33
+ create index if not exists x_notify_inbox_unread_idx
34
+ on x_notify_inbox (recipient) where read_at is null;
35
+ `;
36
+
37
+ const COLUMNS = 'id, recipient, notifier, key, params, created_at, seen_at, read_at';
38
+
39
+ /**
40
+ * Convergent: a second write of the same notification returns the FIRST row rather than moving
41
+ * its timestamps, so replaying the job leaves a message the user already read still read.
42
+ */
43
+ export const SQL_NOTIFY_INBOX_ADD = `
44
+ with inserted as (
45
+ insert into x_notify_inbox (id, recipient, notifier, key, params, created_at)
46
+ values ($1, $2, $3, $4, $5, $6)
47
+ on conflict (recipient, notifier, key) do nothing
48
+ returning ${COLUMNS}
49
+ )
50
+ select ${COLUMNS} from inserted
51
+ union all
52
+ select ${COLUMNS} from x_notify_inbox
53
+ where recipient = $2 and notifier = $3 and key = $4
54
+ and not exists (select 1 from inserted)
55
+ `;
56
+
57
+ /** Newest first, `(created_at desc, id)` — the tail key is unique, so the order is total and a
58
+ * bounded page cannot drop or repeat a row when two notifications land in the same millisecond. */
59
+ export const SQL_NOTIFY_INBOX_PAGE = `
60
+ select ${COLUMNS} from x_notify_inbox
61
+ where recipient = $1 and ($2::boolean is not true or read_at is null)
62
+ order by created_at desc, id
63
+ limit $3
64
+ `;
65
+
66
+ export const SQL_NOTIFY_INBOX_UNREAD = `
67
+ select count(*)::int as unread from x_notify_inbox where recipient = $1 and read_at is null
68
+ `;
69
+
70
+ /** Scoped by recipient, so an id somebody else named is not found and not written. */
71
+ export const SQL_NOTIFY_INBOX_MARK_READ = `
72
+ update x_notify_inbox set read_at = $3
73
+ where recipient = $1 and id = any($2::uuid[]) and read_at is null
74
+ returning id
75
+ `;
76
+
77
+ export const SQL_NOTIFY_INBOX_MARK_SEEN = `
78
+ update x_notify_inbox set seen_at = $2 where recipient = $1 and seen_at is null returning id
79
+ `;
80
+
81
+ interface InboxDbRow {
82
+ readonly id: string;
83
+ readonly recipient: string;
84
+ readonly notifier: string;
85
+ readonly key: string;
86
+ readonly params: unknown;
87
+ readonly created_at: Date | string;
88
+ readonly seen_at: Date | string | null;
89
+ readonly read_at: Date | string | null;
90
+ }
91
+
92
+ const asDate = (value: Date | string): Date => (value instanceof Date ? value : new Date(value));
93
+
94
+ const toRow = (row: InboxDbRow): InboxRow => ({
95
+ id: row.id,
96
+ recipient: row.recipient,
97
+ notifier: row.notifier,
98
+ key: row.key,
99
+ params: row.params,
100
+ createdAt: asDate(row.created_at),
101
+ seenAt: row.seen_at === null ? null : asDate(row.seen_at),
102
+ readAt: row.read_at === null ? null : asDate(row.read_at),
103
+ });
104
+
105
+ export interface PgInboxStoreOptions {
106
+ readonly executor: PgExecutor;
107
+ /**
108
+ * Ids for new rows. A `random = Math.random` default parameter is this repo's injectable seam
109
+ * and this is the same shape: a test that needs a fixed id passes one, and shipped source never
110
+ * calls a die it cannot control.
111
+ */
112
+ readonly newId?: () => string;
113
+ }
114
+
115
+ export function createPgInboxStore(options: PgInboxStoreOptions): InboxStore {
116
+ const { executor } = options;
117
+ const newId = options.newId ?? uuid;
118
+ return {
119
+ async add(write: InboxWrite) {
120
+ const rows = await executor.query<InboxDbRow>(SQL_NOTIFY_INBOX_ADD, [
121
+ newId(),
122
+ write.recipient,
123
+ write.notifier,
124
+ write.key,
125
+ JSON.stringify(write.params),
126
+ write.createdAt,
127
+ ]);
128
+ const row = rows[0];
129
+ // The statement's `union all` always answers exactly one row — the inserted one, or the
130
+ // existing one it conflicted with. Nothing back means the row was deleted between the two
131
+ // halves, which is a race no caller can repair, so it reads as the write that just happened.
132
+ return row === undefined ? { id: newId(), ...write, seenAt: null, readAt: null } : toRow(row);
133
+ },
134
+ async list(query) {
135
+ const rows = await executor.query<InboxDbRow>(SQL_NOTIFY_INBOX_PAGE, [
136
+ query.recipient,
137
+ query.unreadOnly === true,
138
+ query.limit ?? DEFAULT_INBOX_PAGE,
139
+ ]);
140
+ return rows.map(toRow);
141
+ },
142
+ async unreadCount(recipient) {
143
+ const rows = await executor.query<{ unread: number }>(SQL_NOTIFY_INBOX_UNREAD, [recipient]);
144
+ return rows[0]?.unread ?? 0;
145
+ },
146
+ async markRead(input) {
147
+ const rows = await executor.query<{ id: string }>(SQL_NOTIFY_INBOX_MARK_READ, [
148
+ input.recipient,
149
+ [...input.ids],
150
+ input.at,
151
+ ]);
152
+ return rows.length;
153
+ },
154
+ async markSeen(input) {
155
+ const rows = await executor.query<{ id: string }>(SQL_NOTIFY_INBOX_MARK_SEEN, [
156
+ input.recipient,
157
+ input.at,
158
+ ]);
159
+ return rows.length;
160
+ },
161
+ };
162
+ }
package/src/inbox.ts ADDED
@@ -0,0 +1,131 @@
1
+ // The in-app inbox: one row per (recipient, notification), with `seenAt` and `readAt`.
2
+ //
3
+ // SEEN and READ are two facts, exactly as `noticed` has them: seen is "the badge showed it", read
4
+ // is "they opened it". Collapsing them loses the only thing a badge can be derived from — and the
5
+ // count is DERIVED from `readAt is null`, never stored, because a stored counter and a row set
6
+ // drift the first time a write half-lands.
7
+
8
+ export interface InboxRow {
9
+ /** Stable within a store. `(recipient, notifier, key)` is what makes it unique. */
10
+ readonly id: string;
11
+ readonly recipient: string;
12
+ readonly notifier: string;
13
+ readonly key: string;
14
+ /** The notifier's validated params, as written. Denormalised on purpose: an inbox row records
15
+ * what it said at the time, so deleting the thing it points at leaves the row readable. */
16
+ readonly params: unknown;
17
+ readonly createdAt: Date;
18
+ readonly seenAt: Date | null;
19
+ readonly readAt: Date | null;
20
+ }
21
+
22
+ export interface InboxQuery {
23
+ readonly recipient: string;
24
+ /** Bounded everywhere: an inbox is a page, never "everything since you signed up". */
25
+ readonly limit?: number | undefined;
26
+ /** `noticed`'s `unread` scope. Omitted reads the whole page. */
27
+ readonly unreadOnly?: boolean | undefined;
28
+ }
29
+
30
+ export interface InboxWrite {
31
+ readonly recipient: string;
32
+ readonly notifier: string;
33
+ readonly key: string;
34
+ readonly params: unknown;
35
+ readonly createdAt: Date;
36
+ }
37
+
38
+ export interface InboxStore {
39
+ /**
40
+ * Idempotent on `(recipient, notifier, key)`. Answers the row as it now stands, so a replay
41
+ * gets the FIRST write's timestamps back rather than moving them — the same convergence rule
42
+ * `markRead` follows, and what makes an at-least-once job safe to point at this store.
43
+ */
44
+ add(write: InboxWrite): Promise<InboxRow>;
45
+ list(query: InboxQuery): Promise<readonly InboxRow[]>;
46
+ unreadCount(recipient: string): Promise<number>;
47
+ /** `noticed`'s `mark_as_read`, scoped: an id belonging to somebody else is simply absent. */
48
+ markRead(input: { recipient: string; ids: readonly string[]; at: Date }): Promise<number>;
49
+ /** Every unseen row for this recipient — what a rendered badge asserts. */
50
+ markSeen(input: { recipient: string; at: Date }): Promise<number>;
51
+ }
52
+
53
+ export const DEFAULT_INBOX_PAGE = 50;
54
+
55
+ export interface MemoryInboxStore extends InboxStore {
56
+ readonly size: number;
57
+ clear(): void;
58
+ }
59
+
60
+ const idOf = (write: { recipient: string; notifier: string; key: string }): string =>
61
+ JSON.stringify([write.recipient, write.notifier, write.key]);
62
+
63
+ /**
64
+ * The dev inbox: one process, no durability, unbounded. Unbounded deliberately and unlike the
65
+ * memory ledger — a ledger that forgets a row starts sending duplicates, where an inbox that
66
+ * forgets one just loses a message nobody was going to read after the restart anyway.
67
+ */
68
+ export function createMemoryInboxStore(): MemoryInboxStore {
69
+ const rows = new Map<string, InboxRow>();
70
+
71
+ const own = (recipient: string): InboxRow[] =>
72
+ [...rows.values()]
73
+ .filter((row) => row.recipient === recipient)
74
+ .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
75
+
76
+ return {
77
+ get size(): number {
78
+ return rows.size;
79
+ },
80
+ add(write) {
81
+ const id = idOf(write);
82
+ const existing = rows.get(id);
83
+ if (existing !== undefined) return Promise.resolve(existing);
84
+ const row: InboxRow = {
85
+ id,
86
+ recipient: write.recipient,
87
+ notifier: write.notifier,
88
+ key: write.key,
89
+ params: write.params,
90
+ createdAt: write.createdAt,
91
+ seenAt: null,
92
+ readAt: null,
93
+ };
94
+ rows.set(id, row);
95
+ return Promise.resolve(row);
96
+ },
97
+ list(query) {
98
+ const page = own(query.recipient)
99
+ .filter((row) => query.unreadOnly !== true || row.readAt === null)
100
+ .slice(0, query.limit ?? DEFAULT_INBOX_PAGE);
101
+ return Promise.resolve(page);
102
+ },
103
+ unreadCount(recipient) {
104
+ return Promise.resolve(own(recipient).filter((row) => row.readAt === null).length);
105
+ },
106
+ markRead(input) {
107
+ let marked = 0;
108
+ for (const id of input.ids) {
109
+ const row = rows.get(id);
110
+ // The recipient check is the SCOPE, not an optimisation: it is what makes an id somebody
111
+ // else named simply absent, so no write can reach a row that is not the caller's own.
112
+ if (row === undefined || row.recipient !== input.recipient || row.readAt !== null) continue;
113
+ rows.set(id, { ...row, readAt: input.at });
114
+ marked += 1;
115
+ }
116
+ return Promise.resolve(marked);
117
+ },
118
+ markSeen(input) {
119
+ let marked = 0;
120
+ for (const row of own(input.recipient)) {
121
+ if (row.seenAt !== null) continue;
122
+ rows.set(row.id, { ...row, seenAt: input.at });
123
+ marked += 1;
124
+ }
125
+ return Promise.resolve(marked);
126
+ },
127
+ clear() {
128
+ rows.clear();
129
+ },
130
+ };
131
+ }
package/src/index.ts ADDED
@@ -0,0 +1,104 @@
1
+ // Public API of @ultimat3/notify. Explicit re-exports only — no `export *`.
2
+ //
3
+ // ONE entry point, deliberately: every module here runs on the server. There is no browser half to
4
+ // split off, because the only client-side surface a notification has is the inbox rendered by a
5
+ // page and the socket `@ultimat3/realtime` already owns.
6
+
7
+ /** Re-exported so a `notifier` file needs one import, not two. Same object as schema's. */
8
+ export type { Infer } from '@ultimat3/schema';
9
+ export { t } from '@ultimat3/schema';
10
+ export type { AttemptInput } from './attempt';
11
+ export { attemptDelivery } from './attempt';
12
+ export type {
13
+ AnyNotifyChannel,
14
+ BulkDeliveryArgs,
15
+ BulkNotifyChannel,
16
+ DeliveryArgs,
17
+ NotifyChannel,
18
+ } from './channel';
19
+ export { bulkChannel, channel, isBulkChannel } from './channel';
20
+ export type { InAppChannelOptions } from './channel-in-app';
21
+ export { IN_APP_CHANNEL, inAppChannel } from './channel-in-app';
22
+ export type { MailChannelOptions, Mailer, NotifyMail } from './channel-mail';
23
+ export { MAIL_CHANNEL, mailChannel } from './channel-mail';
24
+ export type {
25
+ DigestAppend,
26
+ DigestBucket,
27
+ DigestSlot,
28
+ DigestStore,
29
+ MemoryDigestStore,
30
+ } from './digest';
31
+ export { createMemoryDigestStore } from './digest';
32
+ export type { NotifyErrorCode } from './errors';
33
+ export {
34
+ NOTIFY_ERROR_CODES,
35
+ NotifyChannelDuplicateError,
36
+ NotifyChannelsEmptyError,
37
+ NotifyDeliveryFailedError,
38
+ NotifyDigestUnsupportedError,
39
+ NotifyFanoutTooWideError,
40
+ NotifyStoreMissingError,
41
+ } from './errors';
42
+ // `runFanout` is deliberately absent, for the reason `registerJob` is absent from
43
+ // @ultimat3/jobs: a second way to execute a fan-out would bypass the job the factory built, and
44
+ // with it the retry policy, the cancellation and the manifest row.
45
+ export type { InboxQuery, InboxRow, InboxStore, InboxWrite, MemoryInboxStore } from './inbox';
46
+ export { createMemoryInboxStore, DEFAULT_INBOX_PAGE } from './inbox';
47
+ export type { PgInboxStoreOptions } from './inbox-pg';
48
+ export {
49
+ createPgInboxStore,
50
+ SQL_NOTIFY_INBOX_ADD,
51
+ SQL_NOTIFY_INBOX_MARK_READ,
52
+ SQL_NOTIFY_INBOX_MARK_SEEN,
53
+ SQL_NOTIFY_INBOX_PAGE,
54
+ SQL_NOTIFY_INBOX_TABLE,
55
+ SQL_NOTIFY_INBOX_UNREAD,
56
+ } from './inbox-pg';
57
+ export type {
58
+ DeliveryClaim,
59
+ DeliveryLedger,
60
+ DeliveryRecord,
61
+ DeliveryStatus,
62
+ MemoryDeliveryLedger,
63
+ MemoryLedgerOptions,
64
+ } from './ledger';
65
+ export {
66
+ createMemoryDeliveryLedger,
67
+ DEFAULT_MAX_DELIVERY_RECORDS,
68
+ DELIVERY_STATUSES,
69
+ isDeliveryStatus,
70
+ } from './ledger';
71
+ export type { PgDeliveryLedgerOptions } from './ledger-pg';
72
+ export {
73
+ createPgDeliveryLedger,
74
+ SQL_NOTIFY_CLAIM,
75
+ SQL_NOTIFY_DELIVERIES_TABLE,
76
+ SQL_NOTIFY_FIND,
77
+ SQL_NOTIFY_SETTLE,
78
+ } from './ledger-pg';
79
+ export type { NotifyEvent, Recipient } from './notification';
80
+ export { recipientSchema } from './notification';
81
+ export type { NotifierDefinition } from './notifier';
82
+ export { DEFAULT_MAX_RECIPIENTS, notifier } from './notifier';
83
+ export type {
84
+ ChannelDelivery,
85
+ DeliveryGate,
86
+ DigestWindow,
87
+ NotifyDuration,
88
+ NotifyPayload,
89
+ NotifyPlan,
90
+ NotifyReport,
91
+ RecipientArgs,
92
+ ResolvedDelivery,
93
+ } from './plan';
94
+ export { toDurationMs } from './plan';
95
+ export type { MemoryPreferenceStore, PreferenceQuery, PreferenceStore } from './preferences';
96
+ export { allowAllPreferences, createMemoryPreferenceStore } from './preferences';
97
+ export type { InstalledNotifyStores, NotifyStores } from './stores';
98
+ export {
99
+ notifyStores,
100
+ requireDigest,
101
+ requireInbox,
102
+ resetNotifyStores,
103
+ setNotifyStores,
104
+ } from './stores';
@@ -0,0 +1,122 @@
1
+ // The shared delivery ledger: one Postgres table, `insert … on conflict` for the atomicity.
2
+ // Without it `replicas: 3` means a job replayed on another node sends a second copy of the same
3
+ // notification — the claim this replica took lives in its own heap and nowhere else.
4
+ //
5
+ // Statements are spelled out so an agent can run the exact one it saw in a log.
6
+
7
+ import type { PgExecutor } from '@ultimat3/jobs';
8
+ import type { DeliveryClaim, DeliveryLedger, DeliveryRecord, DeliveryStatus } from './ledger';
9
+ import { isDeliveryStatus } from './ledger';
10
+
11
+ /**
12
+ * Applied the way `SQL_JOBS_TABLE` and `SQL_IDEMPOTENCY_TABLE` are — by the boot, not by an app
13
+ * migration. `create table if not exists` is a no-op against a database that already has it, so a
14
+ * new column is added by `alter table … add column if not exists` and never by editing the
15
+ * `create`.
16
+ *
17
+ * The unique key is an EXPRESSION index over `coalesce(recipient, '')` rather than a plain
18
+ * four-column one. A bulk channel's claim covers the whole audience and so stores a NULL
19
+ * recipient; NULLs are distinct in a unique index on every Postgres before 15, which would let
20
+ * one bulk send be claimed an unbounded number of times. The stored value stays a true NULL —
21
+ * only the index reads it as `''`.
22
+ */
23
+ export const SQL_NOTIFY_DELIVERIES_TABLE = `
24
+ create table if not exists x_notify_deliveries (
25
+ notifier text not null,
26
+ key text not null,
27
+ recipient text,
28
+ channel text not null,
29
+ status text not null default 'sending',
30
+ attempts integer not null default 1,
31
+ at timestamptz not null default now()
32
+ );
33
+
34
+ create unique index if not exists x_notify_deliveries_claim_idx
35
+ on x_notify_deliveries (notifier, key, channel, coalesce(recipient, ''));
36
+
37
+ create index if not exists x_notify_deliveries_at_idx on x_notify_deliveries (at);
38
+ `;
39
+
40
+ /**
41
+ * The claim, atomic in one statement. The `do update` fires ONLY for a row that is not already
42
+ * `sent`, so a returned row always means this caller owns the delivery and must send. No row back
43
+ * means the notification already went out and this attempt is a replay.
44
+ */
45
+ export const SQL_NOTIFY_CLAIM = `
46
+ insert into x_notify_deliveries (notifier, key, recipient, channel, status, attempts, at)
47
+ values ($1, $2, $3, $4, 'sending', 1, $5)
48
+ on conflict (notifier, key, channel, coalesce(recipient, ''))
49
+ do update set attempts = x_notify_deliveries.attempts + 1, status = 'sending', at = excluded.at
50
+ where x_notify_deliveries.status <> 'sent'
51
+ returning attempts
52
+ `;
53
+
54
+ export const SQL_NOTIFY_SETTLE = `
55
+ update x_notify_deliveries set status = $5, at = $6
56
+ where notifier = $1 and key = $2 and channel = $4 and coalesce(recipient, '') = coalesce($3, '')
57
+ `;
58
+
59
+ export const SQL_NOTIFY_FIND = `
60
+ select notifier, key, recipient, channel, status, attempts, at
61
+ from x_notify_deliveries
62
+ where notifier = $1 and key = $2 and channel = $4 and coalesce(recipient, '') = coalesce($3, '')
63
+ `;
64
+
65
+ interface DeliveryRow {
66
+ readonly notifier: string;
67
+ readonly key: string;
68
+ readonly recipient: string | null;
69
+ readonly channel: string;
70
+ readonly status: string;
71
+ readonly attempts: number;
72
+ readonly at: Date | string;
73
+ }
74
+
75
+ /** Positional in the order every statement above declares, so the four share one builder. */
76
+ const argsOf = (claim: DeliveryClaim): readonly unknown[] => [
77
+ claim.notifier,
78
+ claim.key,
79
+ claim.recipient,
80
+ claim.channel,
81
+ ];
82
+
83
+ export interface PgDeliveryLedgerOptions {
84
+ readonly executor: PgExecutor;
85
+ }
86
+
87
+ export function createPgDeliveryLedger(options: PgDeliveryLedgerOptions): DeliveryLedger {
88
+ const { executor } = options;
89
+ return {
90
+ async claim(claim, at) {
91
+ const rows = await executor.query<{ attempts: number }>(SQL_NOTIFY_CLAIM, [
92
+ ...argsOf(claim),
93
+ at,
94
+ ]);
95
+ return rows.length > 0;
96
+ },
97
+ async settle(claim, status, at) {
98
+ await executor.query(SQL_NOTIFY_SETTLE, [...argsOf(claim), status, at]);
99
+ },
100
+ async find(claim) {
101
+ const rows = await executor.query<DeliveryRow>(SQL_NOTIFY_FIND, argsOf(claim));
102
+ const row = rows[0];
103
+ return row === undefined ? undefined : toRecord(row);
104
+ },
105
+ };
106
+ }
107
+
108
+ /**
109
+ * A status column this package did not write — a hand-edited row, or a column an older version
110
+ * wrote — reads as `failed` rather than being cast through. Casting would let an unknown string
111
+ * flow out as a `DeliveryStatus` the type says it cannot be; `failed` is the safe reading, because
112
+ * only `sent` suppresses a resend and nothing else may be allowed to imply it.
113
+ */
114
+ const toRecord = (row: DeliveryRow): DeliveryRecord => ({
115
+ notifier: row.notifier,
116
+ key: row.key,
117
+ recipient: row.recipient,
118
+ channel: row.channel,
119
+ status: (isDeliveryStatus(row.status) ? row.status : 'failed') satisfies DeliveryStatus,
120
+ attempts: row.attempts,
121
+ at: row.at instanceof Date ? row.at : new Date(row.at),
122
+ });
package/src/ledger.ts ADDED
@@ -0,0 +1,136 @@
1
+ // The delivery ledger: the row that makes a replayed attempt stop short of a second send.
2
+ //
3
+ // A `step.run` checkpoint is the FIRST layer and it is not enough on its own — a job body runs
4
+ // before its checkpoint lands, so an attempt killed between the provider's 200 and the step write
5
+ // replays the send. This is the second layer: the claim is taken atomically before the send, and a
6
+ // claim that already reads `sent` answers `false` and the fan-out skips.
7
+
8
+ export const DELIVERY_STATUSES = ['sending', 'sent', 'failed'] as const;
9
+
10
+ export type DeliveryStatus = (typeof DELIVERY_STATUSES)[number];
11
+
12
+ /** A status column this package did not write is not a `DeliveryStatus`; the pg store reads one
13
+ * back on every `find` and must not cast an unknown string through the type that decides whether a
14
+ * notification is resent. */
15
+ export const isDeliveryStatus = (value: unknown): value is DeliveryStatus =>
16
+ typeof value === 'string' && (DELIVERY_STATUSES as readonly string[]).includes(value);
17
+
18
+ /**
19
+ * One delivery's identity: which notification, to whom, over which channel.
20
+ *
21
+ * `recipient` is `null` for a bulk channel and that is the whole difference between the two
22
+ * arities at this layer — one row for the audience rather than one per address.
23
+ */
24
+ export interface DeliveryClaim {
25
+ readonly notifier: string;
26
+ /** The notifier's `key` for this run — what makes two invocations the same notification. */
27
+ readonly key: string;
28
+ readonly recipient: string | null;
29
+ readonly channel: string;
30
+ }
31
+
32
+ export interface DeliveryRecord extends DeliveryClaim {
33
+ readonly status: DeliveryStatus;
34
+ /** How many attempts have taken this claim. A `sent` row is never re-claimed, so this counts
35
+ * crashes and provider failures, which is exactly what makes a flapping channel visible. */
36
+ readonly attempts: number;
37
+ readonly at: Date;
38
+ }
39
+
40
+ export interface DeliveryLedger {
41
+ /**
42
+ * Take ownership of this delivery. `true` means send; `false` means a completed row already
43
+ * exists and this attempt must not send.
44
+ *
45
+ * A row left `sending` by a killed process IS re-claimable, deliberately: the alternative is a
46
+ * notification that silently never arrives because the process that owned it died. At-least-once
47
+ * is the guarantee this package offers and the honest one — `settle` is what closes the window.
48
+ */
49
+ claim(claim: DeliveryClaim, at: Date): Promise<boolean>;
50
+ /** Record the outcome. Only `sent` blocks a future claim. */
51
+ settle(claim: DeliveryClaim, status: DeliveryStatus, at: Date): Promise<void>;
52
+ find(claim: DeliveryClaim): Promise<DeliveryRecord | undefined>;
53
+ }
54
+
55
+ /**
56
+ * The tuple as one string. `JSON.stringify` of an ARRAY rather than a joined separator: a
57
+ * recipient id or a channel name containing the separator would otherwise collide two distinct
58
+ * deliveries into one ledger row, and the failure mode is a notification nobody ever receives.
59
+ */
60
+ const keyOf = (claim: DeliveryClaim): string =>
61
+ JSON.stringify([claim.notifier, claim.key, claim.recipient, claim.channel]);
62
+
63
+ export interface MemoryLedgerOptions {
64
+ /**
65
+ * Rows kept before the oldest is evicted. A process-local ledger is a DEV ledger — it forgets
66
+ * on restart and it is private to one replica — so it is bounded rather than allowed to grow
67
+ * into the heap, and it says so by publishing `size`.
68
+ */
69
+ readonly max?: number;
70
+ }
71
+
72
+ export const DEFAULT_MAX_DELIVERY_RECORDS = 10_000;
73
+
74
+ export interface MemoryDeliveryLedger extends DeliveryLedger {
75
+ readonly size: number;
76
+ /** Rows evicted for the cap. Non-zero means this ledger can no longer refuse a replay. */
77
+ readonly dropped: number;
78
+ clear(): void;
79
+ }
80
+
81
+ /**
82
+ * The default, and honest about what it is: one process, no durability. Installed by a test and by
83
+ * `x dev`; a deployment with more than one replica needs `createPgDeliveryLedger`, because a claim
84
+ * this replica took is invisible to the one that replays the job.
85
+ */
86
+ export function createMemoryDeliveryLedger(
87
+ options: MemoryLedgerOptions = {},
88
+ ): MemoryDeliveryLedger {
89
+ const max = options.max ?? DEFAULT_MAX_DELIVERY_RECORDS;
90
+ const rows = new Map<string, DeliveryRecord>();
91
+ let dropped = 0;
92
+
93
+ const evict = (): void => {
94
+ while (rows.size > max) {
95
+ const oldest = rows.keys().next();
96
+ if (oldest.done === true) return;
97
+ rows.delete(oldest.value);
98
+ dropped += 1;
99
+ }
100
+ };
101
+
102
+ return {
103
+ get size(): number {
104
+ return rows.size;
105
+ },
106
+ get dropped(): number {
107
+ return dropped;
108
+ },
109
+ claim(claim, at) {
110
+ const id = keyOf(claim);
111
+ const existing = rows.get(id);
112
+ if (existing?.status === 'sent') return Promise.resolve(false);
113
+ rows.set(id, {
114
+ ...claim,
115
+ status: 'sending',
116
+ attempts: (existing?.attempts ?? 0) + 1,
117
+ at,
118
+ });
119
+ evict();
120
+ return Promise.resolve(true);
121
+ },
122
+ settle(claim, status, at) {
123
+ const id = keyOf(claim);
124
+ const existing = rows.get(id);
125
+ rows.set(id, { ...claim, status, attempts: existing?.attempts ?? 1, at });
126
+ return Promise.resolve();
127
+ },
128
+ find(claim) {
129
+ return Promise.resolve(rows.get(keyOf(claim)));
130
+ },
131
+ clear() {
132
+ rows.clear();
133
+ dropped = 0;
134
+ },
135
+ };
136
+ }