@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/inbox-pg.ts
ADDED
|
@@ -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';
|
package/src/ledger-pg.ts
ADDED
|
@@ -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
|
+
}
|