@ultimat3/notify 24.0.0 → 25.1.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 +20 -8
- package/README.md +22 -20
- package/package.json +6 -6
- package/src/channel-in-app.ts +2 -2
- package/src/channel-mail.ts +2 -2
- package/src/channel.ts +1 -1
- package/src/digest-pg.ts +44 -16
- package/src/digest.ts +20 -2
- package/src/errors.ts +5 -5
- package/src/fanout-digest.ts +24 -17
- package/src/fanout-walk.ts +2 -0
- package/src/fanout.ts +2 -2
- package/src/inbox-pg.ts +8 -8
- package/src/inbox.ts +5 -10
- package/src/index.ts +12 -15
- package/src/ledger-pg.ts +7 -5
- package/src/ledger.ts +12 -8
- package/src/preferences.ts +1 -1
- package/src/retention.ts +8 -8
- package/src/stores.ts +4 -4
package/CLAUDE.md
CHANGED
|
@@ -55,12 +55,24 @@ A job body runs **before** its checkpoint lands, so both layers are load-bearing
|
|
|
55
55
|
`attempt.ts` is the only place a send happens, so there is exactly one implementation of that order.
|
|
56
56
|
The **one at-most-once seam** is a digest flush: `DigestStore.drain` empties the window, and a
|
|
57
57
|
process killed between the drain and its checkpoint loses that batch. It is stated in `digest.ts`
|
|
58
|
-
rather than hidden. `
|
|
58
|
+
rather than hidden. `postgresDigestStore` makes the window itself durable and shared — the
|
|
59
59
|
partial unique index `x_notify_digests_open_idx` lets two replicas' first appends collide so ONE
|
|
60
60
|
opens the window and owns the flush; an elapsed open window is sealed and the append retried — but
|
|
61
61
|
its `drain` is still a delete, so the seam is unchanged (`digest-parity.live.test.ts` runs one
|
|
62
62
|
suite against both stores).
|
|
63
63
|
|
|
64
|
+
**A digest append is ONE STEP PER RECIPIENT, and idempotent per appender** (`As of 2026-10-05`).
|
|
65
|
+
The append pass was one `step.run`, and a step that fails part-way re-runs WHOLE: recipients already
|
|
66
|
+
appended read `opened: false` from the window they had opened and it was never drained, and a window
|
|
67
|
+
they had JOINED could have been drained and deleted by its owner meanwhile, so their event went out
|
|
68
|
+
in a second digest. Now `digest:<channel>:<recipient>` is a step each (the step store is the
|
|
69
|
+
durable fence; one row per recipient, as `deliver:` already writes), and behind it
|
|
70
|
+
`DigestAppend.appender` (`${runId}:${recipient}`) is stored per window — `appended_by text[]`,
|
|
71
|
+
opener first — so an append whose answer was lost, or a run whose step history is gone, gets back
|
|
72
|
+
the window it landed in while that window exists. Two executions of one appender at once (a lapsed
|
|
73
|
+
lease) cannot both join: the `do update` refuses an appender already in the row. Pinned by
|
|
74
|
+
`fanout-digest.test.ts` and `digest-parity.live.test.ts`.
|
|
75
|
+
|
|
64
76
|
**The audience is deduplicated by `id` once, as it leaves the `open` step** (`As of 2026-10-02`).
|
|
65
77
|
Every per-recipient step is named by the id, so a resolver that named one person twice failed
|
|
66
78
|
`X_STEP_DUPLICATE` part-way through the fan-out, on every retry. Outside the step rather than in it,
|
|
@@ -91,7 +103,7 @@ off.
|
|
|
91
103
|
`list`/`markRead`** (`As of 2026-09`). `markRead`'s contract is that an id belonging to nobody is
|
|
92
104
|
*simply absent* — the memory store skips it — while the Postgres one bound the caller's ids into
|
|
93
105
|
`any($2::uuid[])`, so one malformed id raised 22P02 out of the store and the nineteen good ids in
|
|
94
|
-
the batch went unmarked. `
|
|
106
|
+
the batch went unmarked. `postgresInboxStore` screens with `isUuid` before binding and answers `0`
|
|
95
107
|
with no round trip when nothing survives; the statement keeps its `::uuid[]` cast and its primary
|
|
96
108
|
key index, which `id::text = any($2)` would have given up. And the memory `list` now sorts
|
|
97
109
|
`(createdAt desc, notifier, key)` — the total order `SQL_NOTIFY_INBOX_PAGE` takes — because
|
|
@@ -99,8 +111,8 @@ key index, which `id::text = any($2)` would have given up. And the memory `list`
|
|
|
99
111
|
can drop one and repeat the other.
|
|
100
112
|
|
|
101
113
|
**The tail is `(notifier, key)` and never `id`, `As of 2026-09-06`.** Both stores had a tail and
|
|
102
|
-
they were two different total orders: `
|
|
103
|
-
its 16 BYTES, while `
|
|
114
|
+
they were two different total orders: `postgresInboxStore` mints a UUIDv7 that Postgres compares by
|
|
115
|
+
its 16 BYTES, while `memoryInboxStore` derives its id from
|
|
104
116
|
`JSON.stringify([recipient, notifier, key])` and compares by code point — so equal-`createdAt` rows
|
|
105
117
|
came back one way in dev and the other in production, which is the drop-and-repeat the tail exists
|
|
106
118
|
to prevent, on whichever driver nobody tested against. `(notifier, key)` is unique within a
|
|
@@ -126,19 +138,19 @@ decision rather than a failure.
|
|
|
126
138
|
|
|
127
139
|
| Table | Window | Named where |
|
|
128
140
|
|---|---|---|
|
|
129
|
-
| `x_notify_deliveries` | `
|
|
130
|
-
| `x_notify_digests` | `
|
|
141
|
+
| `x_notify_deliveries` | `PostgresDeliveryLedgerOptions.windowMs`, default 24 h | beside the statement that reads it. **Never shorter than the app's idempotency window** — a job replayed inside that window against a purged claim claims cleanly and sends twice. Pass `idempotency.windowMs` |
|
|
142
|
+
| `x_notify_digests` | `PostgresDigestStoreOptions.retentionMs`, default 7 days after the window closed | beside the statement that reads it, like the ledger's |
|
|
131
143
|
| `x_notify_inbox` | `notify.inboxReadRetentionMs` / `notify.inboxUnreadRetentionMs` in `AppConfig`, **both absent by default** | the app's `app.config.ts`, because an inbox row is a message a person has not read yet and when it disappears is a product decision (axiom 8) |
|
|
132
144
|
|
|
133
145
|
`purgeBefore` and `purgeExpired` live on the **Postgres stores' own wider types**
|
|
134
|
-
(`
|
|
146
|
+
(`PostgresInboxStore`, `PostgresDeliveryLedger`, `PostgresDigestStore`), never on the seams: adding a method to
|
|
135
147
|
the seam every implementation must satisfy is a breaking change for an app that wrote its own, and a
|
|
136
148
|
heap map bounded by process life has nothing to delete. Exactly the shape `PostgresIdempotencyStore`
|
|
137
149
|
already has.
|
|
138
150
|
|
|
139
151
|
`x_notify_digests`: every window is deleted by its own flush's `drain`, or by the next window's
|
|
140
152
|
drain of the same slot (`ends_at <=`). The one neither reaches — a flush that dead-lettered, on a
|
|
141
|
-
slot that never digests again — is the sweep's (`purgeNotifyDigests` → `
|
|
153
|
+
slot that never digests again — is the sweep's (`purgeNotifyDigests` → `PostgresDigestStore.purgeExpired`,
|
|
142
154
|
`As of 2026-10-02`): windows CLOSED more than `retentionMs` before the job's clock, default
|
|
143
155
|
`DEFAULT_DIGEST_RETENTION_MS` (7 days, longer than any flush's retries), deleted in batches of
|
|
144
156
|
`DIGEST_PURGE_BATCH` and at most `DIGEST_PURGE_MAX_BATCHES` statements per pass.
|
package/README.md
CHANGED
|
@@ -98,13 +98,13 @@ One call, at boot. Whole-object replacement, never a merge.
|
|
|
98
98
|
```ts
|
|
99
99
|
import type { PgExecutor } from '@ultimat3/jobs';
|
|
100
100
|
import {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
postgresDeliveryLedger,
|
|
102
|
+
postgresDigestStore,
|
|
103
|
+
postgresInboxStore,
|
|
104
104
|
setNotifyStores,
|
|
105
105
|
} from '@ultimat3/notify';
|
|
106
106
|
|
|
107
|
-
declare const executor: PgExecutor; // `@ultimat3/
|
|
107
|
+
declare const executor: PgExecutor; // `@ultimat3/db`'s dbExecutor()
|
|
108
108
|
declare const idempotency: { readonly windowMs: number }; // the boot's own store
|
|
109
109
|
// The app's preference table, behind whatever taxonomy it named.
|
|
110
110
|
declare const prefs: {
|
|
@@ -114,10 +114,10 @@ declare const prefs: {
|
|
|
114
114
|
setNotifyStores({
|
|
115
115
|
// `windowMs` is how long a settled claim is kept. NEVER SHORTER than your idempotency window —
|
|
116
116
|
// a job replayed inside that window against a purged claim claims cleanly and sends twice.
|
|
117
|
-
ledger:
|
|
118
|
-
inbox:
|
|
117
|
+
ledger: postgresDeliveryLedger({ executor, windowMs: idempotency.windowMs }),
|
|
118
|
+
inbox: postgresInboxStore({ executor }),
|
|
119
119
|
// `x_notify_digests`: a window survives a restart and every replica joins the same one.
|
|
120
|
-
digest:
|
|
120
|
+
digest: postgresDigestStore({ executor }),
|
|
121
121
|
preferences: {
|
|
122
122
|
// The GATE ships; what it reads never does. Your taxonomy, your quiet hours.
|
|
123
123
|
allows: ({ recipient, notifier: name, channel, ctx }) =>
|
|
@@ -128,28 +128,28 @@ setNotifyStores({
|
|
|
128
128
|
|
|
129
129
|
| Store | Default | Why |
|
|
130
130
|
|---|---|---|
|
|
131
|
-
| `ledger` | `
|
|
131
|
+
| `ledger` | `memoryDeliveryLedger()` | one process is genuinely deduped by a heap map, and no ledger at all means a replay sends twice |
|
|
132
132
|
| `preferences` | `allowAllPreferences()` | denying by default is a notifier that silently delivers nothing |
|
|
133
133
|
| `inbox` | **none** — `X_NOTIFY_STORE_MISSING` | a message written to nowhere is worse than a refusal |
|
|
134
|
-
| `digest` | **none** — `X_NOTIFY_STORE_MISSING` | same. `
|
|
134
|
+
| `digest` | **none** — `X_NOTIFY_STORE_MISSING` | same. `memoryDigestStore()` for one process; `postgresDigestStore({ executor })` keeps a window across a restart and gives two replicas ONE window (a partial unique index on the open one), so exactly one run owns its flush |
|
|
135
135
|
|
|
136
|
-
`executor` is a structural `{ query(sql, params) }` — `@ultimat3/
|
|
137
|
-
|
|
136
|
+
`executor` is a structural `{ query(sql, params) }` — `@ultimat3/db`'s `dbExecutor()` is the
|
|
137
|
+
framework's own. `Bun.sql` does **not** satisfy it.
|
|
138
138
|
|
|
139
139
|
## Retention
|
|
140
140
|
|
|
141
141
|
All three tables grow with traffic, and the boot's hourly `x.purge` job sweeps them — but only
|
|
142
|
-
against the **Postgres** stores. `
|
|
143
|
-
and `
|
|
144
|
-
memory store sweeps nothing. The methods are on those stores' own wider types (`
|
|
145
|
-
`
|
|
142
|
+
against the **Postgres** stores. `postgresInboxStore` carries `purgeBefore`, `postgresDeliveryLedger`
|
|
143
|
+
and `postgresDigestStore` carry `purgeExpired`; the memory ones do not, and a boot that installed a
|
|
144
|
+
memory store sweeps nothing. The methods are on those stores' own wider types (`PostgresInboxStore`,
|
|
145
|
+
`PostgresDeliveryLedger`, `PostgresDigestStore`), not on the seams, so an app that wrote its own implementation
|
|
146
146
|
is unaffected.
|
|
147
147
|
|
|
148
148
|
| Table | Window | Default |
|
|
149
149
|
|---|---|---|
|
|
150
|
-
| `x_notify_deliveries` | `
|
|
150
|
+
| `x_notify_deliveries` | `postgresDeliveryLedger({ windowMs })` | 24 h. A settled claim ages from its **last** attempt — `settle` moves `at` |
|
|
151
151
|
| `x_notify_inbox` | `notify.inboxReadRetentionMs` / `notify.inboxUnreadRetentionMs` in `app.config.ts` | **neither** — never swept |
|
|
152
|
-
| `x_notify_digests` | `
|
|
152
|
+
| `x_notify_digests` | `postgresDigestStore({ retentionMs })` | 7 days after a window CLOSED. A drained window is already gone; this takes one whose flush dead-lettered. Batched: 1,000 rows per statement, at most 50 per pass |
|
|
153
153
|
|
|
154
154
|
The inbox default is deliberate and is not a missing number. An inbox row is a message a person has
|
|
155
155
|
not read yet, so when it disappears is your decision, not the framework's — which is why the key
|
|
@@ -168,7 +168,7 @@ A read row ages from `read_at` and an unread one from `created_at` — ageing a
|
|
|
168
168
|
## Channels
|
|
169
169
|
|
|
170
170
|
```ts
|
|
171
|
-
import { bulkChannel,
|
|
171
|
+
import { bulkChannel, deliveryChannel } from '@ultimat3/notify';
|
|
172
172
|
|
|
173
173
|
// The app's push vendor and its Slack webhook — a channel is where an SDK belongs.
|
|
174
174
|
declare const pushService: {
|
|
@@ -177,7 +177,7 @@ declare const pushService: {
|
|
|
177
177
|
declare const SLACK_URL: string;
|
|
178
178
|
|
|
179
179
|
// One call per recipient. The retry unit is one address.
|
|
180
|
-
export const push =
|
|
180
|
+
export const push = deliveryChannel('push', async ({ recipient, event, signal }) => {
|
|
181
181
|
await pushService.send(recipient.to ?? '', event.params, { signal });
|
|
182
182
|
});
|
|
183
183
|
|
|
@@ -225,7 +225,9 @@ delay sends nothing.
|
|
|
225
225
|
A job body runs before its checkpoint lands. Two layers stop a double send: the step checkpoint
|
|
226
226
|
(`deliver:<channel>:<recipient>`), and the delivery ledger's atomic claim on
|
|
227
227
|
`(notifier, key, channel, coalesce(recipient, ''))` — taken before the send, settled after. A claim
|
|
228
|
-
that already reads `sent` answers `false`.
|
|
228
|
+
that already reads `sent` answers `false`. `memoryDeliveryLedger` keys and settles exactly as the
|
|
229
|
+
Postgres ledger does — a null and an empty recipient are one row, and a `settle` with no claim
|
|
230
|
+
records nothing (`ledger-parity.live.test.ts` runs one script against both).
|
|
229
231
|
|
|
230
232
|
A bulk channel claims **one** row for the whole audience, with a null recipient: half a bulk POST is
|
|
231
233
|
not a state this package can represent. That null is why the key coalesces rather than naming the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/notify",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.1.0",
|
|
4
4
|
"description": "Notifications: one declaration, many channels — fan-out, preference gate, digest window, delivery ledger, in-app inbox",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -28,16 +28,16 @@
|
|
|
28
28
|
"LICENSE"
|
|
29
29
|
],
|
|
30
30
|
"engines": {
|
|
31
|
-
"bun": ">=1.4.
|
|
31
|
+
"bun": ">=1.4.2"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
35
35
|
"test": "bun test"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@ultimat3/core": "
|
|
39
|
-
"@ultimat3/jobs": "
|
|
40
|
-
"@ultimat3/schema": "
|
|
41
|
-
"@ultimat3/time": "
|
|
38
|
+
"@ultimat3/core": "25.1.0",
|
|
39
|
+
"@ultimat3/jobs": "25.1.0",
|
|
40
|
+
"@ultimat3/schema": "25.1.0",
|
|
41
|
+
"@ultimat3/time": "25.1.0"
|
|
42
42
|
}
|
|
43
43
|
}
|
package/src/channel-in-app.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// declared where every other delivery is declared, rather than a table that fills itself.
|
|
4
4
|
|
|
5
5
|
import type { NotifyChannel } from './channel';
|
|
6
|
-
import {
|
|
6
|
+
import { deliveryChannel } from './channel';
|
|
7
7
|
import { requireInbox } from './stores';
|
|
8
8
|
|
|
9
9
|
export interface InAppChannelOptions {
|
|
@@ -17,7 +17,7 @@ export const IN_APP_CHANNEL = 'in-app';
|
|
|
17
17
|
export function inAppChannel<Params = unknown>(
|
|
18
18
|
options: InAppChannelOptions = {},
|
|
19
19
|
): NotifyChannel<Params> {
|
|
20
|
-
return
|
|
20
|
+
return deliveryChannel<Params>(options.name ?? IN_APP_CHANNEL, async ({ recipient, batch }) => {
|
|
21
21
|
// Every event in the batch, not just the newest: a digest window over an inbox still owes the
|
|
22
22
|
// reader one row per thing that happened. `add` is idempotent on (recipient, notifier, key),
|
|
23
23
|
// so a replayed attempt writes nothing new and does not move an existing row's timestamps.
|
package/src/channel-mail.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// }) } })
|
|
12
12
|
|
|
13
13
|
import type { NotifyChannel } from './channel';
|
|
14
|
-
import {
|
|
14
|
+
import { deliveryChannel } from './channel';
|
|
15
15
|
import type { NotifyEvent, Recipient } from './notification';
|
|
16
16
|
|
|
17
17
|
/** What a mailer is handed. Everything a template needs to render, and nothing about transport. */
|
|
@@ -55,7 +55,7 @@ export function mailChannel<Params = unknown>(
|
|
|
55
55
|
options: MailChannelOptions<Params>,
|
|
56
56
|
): NotifyChannel<Params> {
|
|
57
57
|
const addressOf = options.addressOf ?? ((recipient: Recipient) => recipient.to);
|
|
58
|
-
return
|
|
58
|
+
return deliveryChannel<Params>(
|
|
59
59
|
options.name ?? MAIL_CHANNEL,
|
|
60
60
|
async ({ recipient, batch, ctx, signal }) => {
|
|
61
61
|
const to = addressOf(recipient);
|
package/src/channel.ts
CHANGED
|
@@ -51,7 +51,7 @@ export type AnyNotifyChannel<Params = unknown> = NotifyChannel<Params> | BulkNot
|
|
|
51
51
|
* One recipient per call. The name is durable — it is a column of the delivery ledger — so it is
|
|
52
52
|
* given here rather than derived from a variable name that a bundler may rewrite.
|
|
53
53
|
*/
|
|
54
|
-
export function
|
|
54
|
+
export function deliveryChannel<Params = unknown>(
|
|
55
55
|
name: string,
|
|
56
56
|
deliver: (args: DeliveryArgs<Params>) => Promise<void> | void,
|
|
57
57
|
): NotifyChannel<Params> {
|
package/src/digest-pg.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The Postgres digest store: one row per window, so a `digested` event outlives the process that
|
|
2
|
-
// appended it and every replica coalesces into the same window. `
|
|
2
|
+
// appended it and every replica coalesces into the same window. `memoryDigestStore` is the
|
|
3
3
|
// same queue of windows in one heap; this is what a deployment with more than one process installs.
|
|
4
4
|
|
|
5
5
|
import type { PgExecutor } from '@ultimat3/core';
|
|
@@ -35,21 +35,46 @@ create index if not exists x_notify_digests_slot_idx
|
|
|
35
35
|
on x_notify_digests (recipient, notifier, channel, group_key, ends_at);
|
|
36
36
|
|
|
37
37
|
create index if not exists x_notify_digests_ends_idx on x_notify_digests (ends_at);
|
|
38
|
+
|
|
39
|
+
alter table x_notify_digests add column if not exists appended_by text[] not null default '{}';
|
|
38
40
|
`;
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
|
-
* Join the open window, or open one
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* the
|
|
43
|
+
* Join the open window, or open one — unless this appender (`$8`) already landed in a window of the
|
|
44
|
+
* slot, which is a replayed step: then that window's answer comes back and nothing is written.
|
|
45
|
+
* `appended_by[1]` is the opener, so a replay by the opener still reads `opened`, and owns the
|
|
46
|
+
* flush it owned the first time. `(xmax = 0)` is true for the row this statement INSERTED and false
|
|
47
|
+
* for one it updated. An open window that has already elapsed is not joined: the `where` on the
|
|
48
|
+
* `do update` refuses it and no row comes back, which is the store's cue to seal it and try again.
|
|
49
|
+
* A row written before `appended_by` existed holds `'{}'`, matches no appender, and opens nothing.
|
|
50
|
+
*
|
|
51
|
+
* The `any` on the `do update` is for two executions of ONE appender at once (a lapsed lease): both
|
|
52
|
+
* pass `prior` in their own snapshots, and the loser's conflict re-reads the winner's COMMITTED row.
|
|
53
|
+
* It refuses to join it twice and no row comes back — the store's next pass then finds it in
|
|
54
|
+
* `prior`, exactly as it finds an elapsed window sealed.
|
|
45
55
|
*/
|
|
46
56
|
export const SQL_NOTIFY_DIGEST_APPEND = `
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
with prior as (
|
|
58
|
+
select ends_at, coalesce(appended_by[1] = $8::text, false) as opened
|
|
59
|
+
from x_notify_digests
|
|
60
|
+
where recipient = $1 and notifier = $2 and channel = $3 and group_key = $4
|
|
61
|
+
and $8::text = any(appended_by)
|
|
62
|
+
order by seq
|
|
63
|
+
limit 1
|
|
64
|
+
), joined as (
|
|
65
|
+
insert into x_notify_digests (recipient, notifier, channel, group_key, ends_at, events, appended_by)
|
|
66
|
+
select $1, $2, $3, $4, $6::timestamptz, $5::jsonb, array[$8::text]
|
|
67
|
+
where not exists (select 1 from prior)
|
|
68
|
+
on conflict (recipient, notifier, channel, group_key) where not sealed
|
|
69
|
+
do update set events = x_notify_digests.events || excluded.events,
|
|
70
|
+
appended_by = x_notify_digests.appended_by || excluded.appended_by
|
|
71
|
+
where x_notify_digests.ends_at > $7::timestamptz
|
|
72
|
+
and not ($8::text = any(x_notify_digests.appended_by))
|
|
73
|
+
returning ends_at, (xmax = 0) as opened
|
|
74
|
+
)
|
|
75
|
+
select ends_at, opened from prior
|
|
76
|
+
union all
|
|
77
|
+
select ends_at, opened from joined
|
|
53
78
|
`;
|
|
54
79
|
|
|
55
80
|
/** Seal the slot's open window once it has elapsed, so the next append opens a fresh one. */
|
|
@@ -131,27 +156,27 @@ const slotArgs = (slot: DigestSlot): readonly unknown[] => [
|
|
|
131
156
|
const epochOf = (value: Date | string): number =>
|
|
132
157
|
(value instanceof Date ? value : new Date(value)).getTime();
|
|
133
158
|
|
|
134
|
-
export interface
|
|
159
|
+
export interface PostgresDigestStoreOptions {
|
|
135
160
|
readonly executor: PgExecutor;
|
|
136
161
|
/** How long a closed window is kept. Defaults to `DEFAULT_DIGEST_RETENTION_MS`. */
|
|
137
162
|
readonly retentionMs?: number | undefined;
|
|
138
163
|
}
|
|
139
164
|
|
|
140
165
|
/**
|
|
141
|
-
* The Postgres store's own wider type, exactly as `
|
|
166
|
+
* The Postgres store's own wider type, exactly as `PostgresDeliveryLedger` is: `purgeExpired` is not on
|
|
142
167
|
* `DigestStore`, because a heap map bounded by process life has nothing to delete and a new
|
|
143
168
|
* method on the seam breaks every app that wrote its own store.
|
|
144
169
|
*/
|
|
145
|
-
export interface
|
|
170
|
+
export interface PostgresDigestStore extends DigestStore {
|
|
146
171
|
readonly retentionMs: number;
|
|
147
172
|
/** Delete windows closed more than `retentionMs` before `nowMs` (the job's clock); answer how many. */
|
|
148
173
|
purgeExpired(nowMs: number): Promise<number>;
|
|
149
174
|
}
|
|
150
175
|
|
|
151
|
-
export function
|
|
176
|
+
export function postgresDigestStore(options: PostgresDigestStoreOptions): PostgresDigestStore {
|
|
152
177
|
const { executor } = options;
|
|
153
178
|
const retentionMs = finiteCount(
|
|
154
|
-
'
|
|
179
|
+
'postgresDigestStore',
|
|
155
180
|
'retentionMs',
|
|
156
181
|
options.retentionMs ?? DEFAULT_DIGEST_RETENTION_MS,
|
|
157
182
|
1,
|
|
@@ -183,8 +208,11 @@ export function createPgDigestStore(options: PgDigestStoreOptions): PgDigestStor
|
|
|
183
208
|
events,
|
|
184
209
|
endsAt,
|
|
185
210
|
now,
|
|
211
|
+
input.appender,
|
|
186
212
|
]);
|
|
187
213
|
if (row !== undefined) return { opened: row.opened, endsAt: epochOf(row.ends_at) };
|
|
214
|
+
// No row: the open window elapsed, or this appender's own twin committed it first. The seal
|
|
215
|
+
// is a no-op on a window still open, and either way the next pass answers from `prior`.
|
|
188
216
|
await executor.query(SQL_NOTIFY_DIGEST_SEAL, [...slot, now]);
|
|
189
217
|
}
|
|
190
218
|
// Reached only when every retry met a window that ANOTHER appender had just opened and that
|
package/src/digest.ts
CHANGED
|
@@ -26,6 +26,13 @@ export interface DigestAppend {
|
|
|
26
26
|
readonly event: NotifyEvent<unknown>;
|
|
27
27
|
readonly windowMs: number;
|
|
28
28
|
readonly now: Date;
|
|
29
|
+
/**
|
|
30
|
+
* Who is appending: the fan-out passes its job's `runId` and the recipient, which a retry keeps.
|
|
31
|
+
* The append runs inside a step that re-runs WHOLE when any append in it fails, so an appender
|
|
32
|
+
* that already landed must get the SAME answer back and add nothing — a replay that read
|
|
33
|
+
* `opened: false` left a window nobody owned, and so nobody ever drained.
|
|
34
|
+
*/
|
|
35
|
+
readonly appender: string;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
export interface DigestBucket {
|
|
@@ -65,6 +72,8 @@ const slotKey = (slot: DigestSlot): string =>
|
|
|
65
72
|
interface OpenBucket {
|
|
66
73
|
endsAt: number;
|
|
67
74
|
readonly events: NotifyEvent<unknown>[];
|
|
75
|
+
/** Every appender this window took, opener first — what makes a replayed append a no-op. */
|
|
76
|
+
readonly appenders: string[];
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
export interface MemoryDigestStore extends DigestStore {
|
|
@@ -78,7 +87,7 @@ export interface MemoryDigestStore extends DigestStore {
|
|
|
78
87
|
* event the earlier window held. A closed window is now sealed under its `endsAt`, and a later
|
|
79
88
|
* append opens the next one beside it.
|
|
80
89
|
*/
|
|
81
|
-
export function
|
|
90
|
+
export function memoryDigestStore(): MemoryDigestStore {
|
|
82
91
|
const slots = new Map<string, OpenBucket[]>();
|
|
83
92
|
return {
|
|
84
93
|
get open(): number {
|
|
@@ -90,15 +99,24 @@ export function createMemoryDigestStore(): MemoryDigestStore {
|
|
|
90
99
|
const id = slotKey(input.slot);
|
|
91
100
|
const at = input.now.getTime();
|
|
92
101
|
const windows = slots.get(id) ?? [];
|
|
102
|
+
// A replay answers the window it already landed in, sealed or not, and adds nothing.
|
|
103
|
+
const prior = windows.find((window) => window.appenders.includes(input.appender));
|
|
104
|
+
if (prior !== undefined) {
|
|
105
|
+
return Promise.resolve({
|
|
106
|
+
opened: prior.appenders[0] === input.appender,
|
|
107
|
+
endsAt: prior.endsAt,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
93
110
|
const newest = windows.at(-1);
|
|
94
111
|
// An elapsed window is SEALED — its flush drains it — and never appended to: the event
|
|
95
112
|
// opens the next window, which is one extra delivery rather than a lost one.
|
|
96
113
|
if (newest !== undefined && newest.endsAt > at) {
|
|
97
114
|
newest.events.push(input.event);
|
|
115
|
+
newest.appenders.push(input.appender);
|
|
98
116
|
return Promise.resolve({ opened: false, endsAt: newest.endsAt });
|
|
99
117
|
}
|
|
100
118
|
const endsAt = at + input.windowMs;
|
|
101
|
-
windows.push({ endsAt, events: [input.event] });
|
|
119
|
+
windows.push({ endsAt, events: [input.event], appenders: [input.appender] });
|
|
102
120
|
slots.set(id, windows);
|
|
103
121
|
return Promise.resolve({ opened: true, endsAt });
|
|
104
122
|
},
|
package/src/errors.ts
CHANGED
|
@@ -96,7 +96,7 @@ export class NotifyChannelDuplicateError extends UltimateError {
|
|
|
96
96
|
super({
|
|
97
97
|
code: 'X_NOTIFY_CHANNEL_DUPLICATE',
|
|
98
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}") —
|
|
99
|
+
fix: `give the second channel its own name on notifier("${input.notifier}") — deliveryChannel('${input.channel}-digest', …) — or merge the two deliver entries`,
|
|
100
100
|
meta: { notifier: input.notifier, channel: input.channel },
|
|
101
101
|
});
|
|
102
102
|
}
|
|
@@ -122,7 +122,7 @@ export class NotifyFanoutTooWideError extends UltimateError {
|
|
|
122
122
|
* A channel or a window asked for a store nothing installed.
|
|
123
123
|
*
|
|
124
124
|
* The delivery ledger is deliberately NOT one of these: it has a correct-for-one-process default
|
|
125
|
-
* (`
|
|
125
|
+
* (`memoryDeliveryLedger`), the way every other driver seam in this framework does. An inbox
|
|
126
126
|
* and a digest window have no such default — there is nowhere to put the row — so they refuse.
|
|
127
127
|
*/
|
|
128
128
|
export class NotifyStoreMissingError extends UltimateError {
|
|
@@ -131,8 +131,8 @@ export class NotifyStoreMissingError extends UltimateError {
|
|
|
131
131
|
input.store === 'inbox' ? 'a channel that writes the in-app inbox' : 'a digest window';
|
|
132
132
|
const install =
|
|
133
133
|
input.store === 'inbox'
|
|
134
|
-
? '
|
|
135
|
-
: '
|
|
134
|
+
? 'memoryInboxStore() }) at boot, or postgresInboxStore({ executor }) to share it across replicas'
|
|
135
|
+
: 'memoryDigestStore() }) at boot';
|
|
136
136
|
super({
|
|
137
137
|
code: 'X_NOTIFY_STORE_MISSING',
|
|
138
138
|
cause: `notifier "${input.notifier}" delivers through ${what} and no ${input.store} store is installed, so the rows have nowhere to go`,
|
|
@@ -152,7 +152,7 @@ export class NotifyDigestUnsupportedError extends UltimateError {
|
|
|
152
152
|
super({
|
|
153
153
|
code: 'X_NOTIFY_DIGEST_UNSUPPORTED',
|
|
154
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
|
|
155
|
+
fix: `drop digest from the "${input.channel}" entry on notifier("${input.notifier}") and give it a wait instead, or deliver it through deliveryChannel('${input.channel}', …) one recipient at a time`,
|
|
156
156
|
meta: { notifier: input.notifier, channel: input.channel },
|
|
157
157
|
});
|
|
158
158
|
}
|
package/src/fanout-digest.ts
CHANGED
|
@@ -21,7 +21,7 @@ export interface DigestFlush<Params> {
|
|
|
21
21
|
|
|
22
22
|
export async function flushDigest<Params>(input: DigestFlush<Params>): Promise<void> {
|
|
23
23
|
const { walk, channel, delivery, allowed } = input;
|
|
24
|
-
const { plan, event, ctx, step, tally } = walk;
|
|
24
|
+
const { plan, event, ctx, runId, step, tally } = walk;
|
|
25
25
|
const windowMs = delivery.digestMs ?? 0;
|
|
26
26
|
const digest = requireDigest(plan.name);
|
|
27
27
|
const group = delivery.group?.(event) ?? plan.name;
|
|
@@ -32,26 +32,33 @@ export async function flushDigest<Params>(input: DigestFlush<Params>): Promise<v
|
|
|
32
32
|
group,
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
//
|
|
36
|
-
// same event lands in
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
35
|
+
// ONE STEP PER APPEND, and the step store is the fence: a completed append is never redone, so
|
|
36
|
+
// the same event never lands in a digest twice. One step for the whole pass re-ran every append
|
|
37
|
+
// when any one failed, and the window a re-run append had joined may already have been drained
|
|
38
|
+
// and deleted by its owner — nothing in the store remembered it, so the event went out again in
|
|
39
|
+
// a second digest. The appender id is the store's own defence behind the step: an append whose
|
|
40
|
+
// write committed but whose answer was lost, or a run whose step history is gone, gets back the
|
|
41
|
+
// window it already landed in — `opened` included — for as long as that window exists.
|
|
42
|
+
// Each owner's OWN window end: two recipients' windows need not close together, and the drain
|
|
43
|
+
// takes the window it names.
|
|
44
|
+
const opened: { owned: { readonly id: string; readonly endsAt: number }[]; endsAt: number } = {
|
|
45
|
+
owned: [],
|
|
46
|
+
endsAt: 0,
|
|
47
|
+
};
|
|
48
|
+
for (const recipient of allowed) {
|
|
49
|
+
const bucket = await step.run(`digest:${channel.name}:${recipient.id}`, () =>
|
|
50
|
+
digest.append({
|
|
44
51
|
slot: slotFor(recipient.id),
|
|
45
52
|
event,
|
|
46
53
|
windowMs,
|
|
47
54
|
now: ctx.now(),
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
+
appender: `${runId}:${recipient.id}`,
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
if (!bucket.opened) continue;
|
|
59
|
+
opened.owned.push({ id: recipient.id, endsAt: bucket.endsAt });
|
|
60
|
+
opened.endsAt = Math.max(opened.endsAt, bucket.endsAt);
|
|
61
|
+
}
|
|
55
62
|
|
|
56
63
|
tally.digested += allowed.length - opened.owned.length;
|
|
57
64
|
if (opened.owned.length === 0) return;
|
package/src/fanout-walk.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface Walk<Params> {
|
|
|
21
21
|
readonly event: NotifyEvent<Params>;
|
|
22
22
|
readonly audience: readonly Recipient[];
|
|
23
23
|
readonly ctx: Ctx;
|
|
24
|
+
/** The job run's id, which every retry of it keeps — what names this run's digest appends. */
|
|
25
|
+
readonly runId: string;
|
|
24
26
|
readonly step: StepApi;
|
|
25
27
|
readonly tally: Tally;
|
|
26
28
|
}
|
package/src/fanout.ts
CHANGED
|
@@ -22,7 +22,7 @@ export async function runFanout<Params>(
|
|
|
22
22
|
plan: NotifyPlan<Params>,
|
|
23
23
|
args: JobRunArgs<NotifyPayload<Params>>,
|
|
24
24
|
): Promise<NotifyReport> {
|
|
25
|
-
const { input, step, ctx } = args;
|
|
25
|
+
const { input, step, ctx, runId } = args;
|
|
26
26
|
// One step for BOTH facts, because both must survive a replay: a re-resolved audience would
|
|
27
27
|
// deliver to whoever subscribed during the wait, and a re-read clock would write a different
|
|
28
28
|
// `at` into the inbox on every attempt. Epoch ms rather than a `Date` — a step's return value
|
|
@@ -49,7 +49,7 @@ export async function runFanout<Params>(
|
|
|
49
49
|
at: new Date(open.at),
|
|
50
50
|
};
|
|
51
51
|
const tally: Tally = { delivered: 0, suppressed: 0, skipped: 0, replayed: 0, digested: 0 };
|
|
52
|
-
const walk: Walk<Params> = { plan, event, audience, ctx, step, tally };
|
|
52
|
+
const walk: Walk<Params> = { plan, event, audience, ctx, runId, step, tally };
|
|
53
53
|
|
|
54
54
|
// `deliveries` is sorted by `waitMs`, so this sleeps the DELTA and never the sum: an in-app
|
|
55
55
|
// channel with no wait fires now even when the email beside it waits an hour.
|
package/src/inbox-pg.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Statements are spelled out so an agent can run the exact one it saw in a log.
|
|
3
3
|
|
|
4
4
|
import type { PgExecutor } from '@ultimat3/core';
|
|
5
|
-
import { finiteCount, isUuid,
|
|
5
|
+
import { finiteCount, isUuid, uuidV7 } from '@ultimat3/core';
|
|
6
6
|
import type { InboxRow, InboxStore, InboxWrite } from './inbox';
|
|
7
7
|
import { DEFAULT_INBOX_PAGE } from './inbox';
|
|
8
8
|
|
|
@@ -59,7 +59,7 @@ where recipient = $2 and notifier = $3 and key = $4
|
|
|
59
59
|
* `unique (recipient, notifier, key)`, so the order is total and a bounded page cannot drop or
|
|
60
60
|
* repeat a row when two notifications land in the same millisecond.
|
|
61
61
|
*
|
|
62
|
-
* `collate "C"` and not the column's collation: `
|
|
62
|
+
* `collate "C"` and not the column's collation: `memoryInboxStore` compares the same two
|
|
63
63
|
* columns by CODE POINT, and a database initialised under an ICU or a `en_US.UTF-8` collation
|
|
64
64
|
* orders text by locale rules — case-insensitively, ignoring punctuation at the first level — so
|
|
65
65
|
* the two stores would disagree on exactly the Unicode keys nobody writes a test for. `id` was the
|
|
@@ -130,7 +130,7 @@ const toRow = (row: InboxDbRow): InboxRow => ({
|
|
|
130
130
|
readAt: row.read_at === null ? null : asDate(row.read_at),
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
-
export interface
|
|
133
|
+
export interface PostgresInboxStoreOptions {
|
|
134
134
|
readonly executor: PgExecutor;
|
|
135
135
|
/**
|
|
136
136
|
* Ids for new rows. A `random = Math.random` default parameter is this repo's injectable seam
|
|
@@ -153,16 +153,16 @@ export interface InboxPurgeBefore {
|
|
|
153
153
|
|
|
154
154
|
/**
|
|
155
155
|
* The Postgres inbox's own wider type. `purgeBefore` is HERE and not on `InboxStore` for the
|
|
156
|
-
* reason `
|
|
156
|
+
* reason `PostgresDeliveryLedger.purgeExpired` is not on `DeliveryLedger`: adding a method to the seam
|
|
157
157
|
* every implementation must satisfy is a breaking change for an app that wrote its own.
|
|
158
158
|
*/
|
|
159
|
-
export interface
|
|
159
|
+
export interface PostgresInboxStore extends InboxStore {
|
|
160
160
|
purgeBefore(before: InboxPurgeBefore): Promise<number>;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
export function
|
|
163
|
+
export function postgresInboxStore(options: PostgresInboxStoreOptions): PostgresInboxStore {
|
|
164
164
|
const { executor } = options;
|
|
165
|
-
const newId = options.newId ??
|
|
165
|
+
const newId = options.newId ?? uuidV7;
|
|
166
166
|
return {
|
|
167
167
|
async add(write: InboxWrite) {
|
|
168
168
|
const rows = await executor.query<InboxDbRow>(SQL_NOTIFY_INBOX_ADD, [
|
|
@@ -184,7 +184,7 @@ export function createPgInboxStore(options: PgInboxStoreOptions): PgInboxStore {
|
|
|
184
184
|
// what a driver does with a `NaN` parameter is the driver's business — the memory store beside
|
|
185
185
|
// it answered `[]` for the same input. `??` cannot see it, because `NaN` is not nullish.
|
|
186
186
|
const limit = finiteCount(
|
|
187
|
-
'
|
|
187
|
+
'postgresInboxStore',
|
|
188
188
|
'limit',
|
|
189
189
|
query.limit ?? DEFAULT_INBOX_PAGE,
|
|
190
190
|
0,
|
package/src/inbox.ts
CHANGED
|
@@ -69,8 +69,8 @@ const compareText = (a: string, b: string): number => {
|
|
|
69
69
|
* The tail key both stores order on: `(notifier, key)`, which is unique within a recipient because
|
|
70
70
|
* `add` is idempotent on `(recipient, notifier, key)` and the Postgres table declares that UNIQUE.
|
|
71
71
|
*
|
|
72
|
-
* `id` cannot be it, and that is the whole reason this exists. `
|
|
73
|
-
* and Postgres orders `uuid` by its 16 BYTES; `
|
|
72
|
+
* `id` cannot be it, and that is the whole reason this exists. `postgresInboxStore` mints a UUIDv7
|
|
73
|
+
* and Postgres orders `uuid` by its 16 BYTES; `memoryInboxStore` derives its id from
|
|
74
74
|
* `JSON.stringify([recipient, notifier, key])` and orders it by code point. Two total orders that
|
|
75
75
|
* agree on nothing — so two notifications written in one millisecond came back in one order in dev
|
|
76
76
|
* and the other in production, and a bounded page dropped one and repeated the other on exactly the
|
|
@@ -88,7 +88,7 @@ const idOf = (write: { recipient: string; notifier: string; key: string }): stri
|
|
|
88
88
|
* memory ledger — a ledger that forgets a row starts sending duplicates, where an inbox that
|
|
89
89
|
* forgets one just loses a message nobody was going to read after the restart anyway.
|
|
90
90
|
*/
|
|
91
|
-
export function
|
|
91
|
+
export function memoryInboxStore(): MemoryInboxStore {
|
|
92
92
|
const rows = new Map<string, InboxRow>();
|
|
93
93
|
|
|
94
94
|
// `(createdAt desc, notifier, key)` — the same TOTAL order `SQL_NOTIFY_INBOX_PAGE` takes, and
|
|
@@ -122,18 +122,13 @@ export function createMemoryInboxStore(): MemoryInboxStore {
|
|
|
122
122
|
return Promise.resolve(row);
|
|
123
123
|
},
|
|
124
124
|
// `async` and not `Promise.resolve`, so a refused `limit` REJECTS here exactly as it does in
|
|
125
|
-
// `
|
|
125
|
+
// `postgresInboxStore`: two drivers behind one interface must not answer one question two ways,
|
|
126
126
|
// and a sync throw against a rejected promise is a difference a caller can see.
|
|
127
127
|
async list(query) {
|
|
128
128
|
// `slice(0, NaN)` is `[]` — an empty inbox reported as the whole of it — and
|
|
129
129
|
// `slice(0, Infinity)` is every row the recipient ever received, which is the unbounded read
|
|
130
130
|
// `limit`'s own doc forbids. `??` reaches neither: `NaN` is not nullish.
|
|
131
|
-
const limit = finiteCount(
|
|
132
|
-
'createMemoryInboxStore',
|
|
133
|
-
'limit',
|
|
134
|
-
query.limit ?? DEFAULT_INBOX_PAGE,
|
|
135
|
-
0,
|
|
136
|
-
);
|
|
131
|
+
const limit = finiteCount('memoryInboxStore', 'limit', query.limit ?? DEFAULT_INBOX_PAGE, 0);
|
|
137
132
|
return own(query.recipient)
|
|
138
133
|
.filter((row) => query.unreadOnly !== true || row.readAt === null)
|
|
139
134
|
.slice(0, limit);
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,7 @@ export type {
|
|
|
16
16
|
DeliveryArgs,
|
|
17
17
|
NotifyChannel,
|
|
18
18
|
} from './channel';
|
|
19
|
-
export { bulkChannel,
|
|
19
|
+
export { bulkChannel, deliveryChannel, isBulkChannel } from './channel';
|
|
20
20
|
export type { InAppChannelOptions } from './channel-in-app';
|
|
21
21
|
export { IN_APP_CHANNEL, inAppChannel } from './channel-in-app';
|
|
22
22
|
export type { MailChannelOptions, Mailer, NotifyMail } from './channel-mail';
|
|
@@ -28,11 +28,11 @@ export type {
|
|
|
28
28
|
DigestStore,
|
|
29
29
|
MemoryDigestStore,
|
|
30
30
|
} from './digest';
|
|
31
|
-
export {
|
|
32
|
-
export type {
|
|
31
|
+
export { memoryDigestStore } from './digest';
|
|
32
|
+
export type { PostgresDigestStore, PostgresDigestStoreOptions } from './digest-pg';
|
|
33
33
|
export {
|
|
34
|
-
createPgDigestStore,
|
|
35
34
|
DEFAULT_DIGEST_RETENTION_MS,
|
|
35
|
+
postgresDigestStore,
|
|
36
36
|
SQL_NOTIFY_DIGESTS_TABLE,
|
|
37
37
|
} from './digest-pg';
|
|
38
38
|
export type { NotifyErrorCode } from './errors';
|
|
@@ -49,12 +49,10 @@ export {
|
|
|
49
49
|
// @ultimat3/jobs: a second way to execute a fan-out would bypass the job the factory built, and
|
|
50
50
|
// with it the retry policy, the cancellation and the manifest row.
|
|
51
51
|
export type { InboxQuery, InboxRow, InboxStore, InboxWrite, MemoryInboxStore } from './inbox';
|
|
52
|
-
export {
|
|
53
|
-
export type { InboxPurgeBefore,
|
|
52
|
+
export { DEFAULT_INBOX_PAGE, memoryInboxStore } from './inbox';
|
|
53
|
+
export type { InboxPurgeBefore, PostgresInboxStore, PostgresInboxStoreOptions } from './inbox-pg';
|
|
54
54
|
export {
|
|
55
|
-
|
|
56
|
-
SQL_NOTIFY_INBOX_MARK_READ,
|
|
57
|
-
SQL_NOTIFY_INBOX_PAGE,
|
|
55
|
+
postgresInboxStore,
|
|
58
56
|
SQL_NOTIFY_INBOX_TABLE,
|
|
59
57
|
} from './inbox-pg';
|
|
60
58
|
export type {
|
|
@@ -63,17 +61,16 @@ export type {
|
|
|
63
61
|
DeliveryRecord,
|
|
64
62
|
DeliveryStatus,
|
|
65
63
|
MemoryDeliveryLedger,
|
|
66
|
-
|
|
64
|
+
MemoryDeliveryLedgerOptions,
|
|
67
65
|
} from './ledger';
|
|
68
66
|
export {
|
|
69
|
-
createMemoryDeliveryLedger,
|
|
70
67
|
DELIVERY_STATUSES,
|
|
71
68
|
isDeliveryStatus,
|
|
69
|
+
memoryDeliveryLedger,
|
|
72
70
|
} from './ledger';
|
|
73
|
-
export type {
|
|
71
|
+
export type { PostgresDeliveryLedger, PostgresDeliveryLedgerOptions } from './ledger-pg';
|
|
74
72
|
export {
|
|
75
|
-
|
|
76
|
-
SQL_NOTIFY_CLAIM,
|
|
73
|
+
postgresDeliveryLedger,
|
|
77
74
|
SQL_NOTIFY_DELIVERIES_TABLE,
|
|
78
75
|
} from './ledger-pg';
|
|
79
76
|
export type { NotifyEvent, Recipient } from './notification';
|
|
@@ -93,7 +90,7 @@ export type {
|
|
|
93
90
|
} from './plan';
|
|
94
91
|
export { toDurationMs } from './plan';
|
|
95
92
|
export type { MemoryPreferenceStore, PreferenceQuery, PreferenceStore } from './preferences';
|
|
96
|
-
export { allowAllPreferences,
|
|
93
|
+
export { allowAllPreferences, memoryPreferenceStore } from './preferences';
|
|
97
94
|
export { purgeNotifyDeliveries, purgeNotifyDigests, purgeNotifyInbox } from './retention';
|
|
98
95
|
export type { InstalledNotifyStores, NotifyStores } from './stores';
|
|
99
96
|
export {
|
package/src/ledger-pg.ts
CHANGED
|
@@ -102,14 +102,14 @@ delete from x_notify_deliveries where at < $1
|
|
|
102
102
|
*/
|
|
103
103
|
const SQL_NOTIFY_DELIVERIES_PURGE_COUNTED = `${SQL_NOTIFY_DELIVERIES_PURGE} returning key`;
|
|
104
104
|
|
|
105
|
-
export interface
|
|
105
|
+
export interface PostgresDeliveryLedgerOptions {
|
|
106
106
|
readonly executor: PgExecutor;
|
|
107
107
|
/**
|
|
108
108
|
* How long a settled claim is kept. Defaults to `DEFAULT_DELIVERY_WINDOW_MS`.
|
|
109
109
|
*
|
|
110
110
|
* NEVER SHORTER THAN YOUR IDEMPOTENCY WINDOW, and this is the one dangerous direction: a job
|
|
111
111
|
* replayed inside the idempotency window, against a delivery claim that has already been
|
|
112
|
-
* purged, claims cleanly and sends the notification a second time. `
|
|
112
|
+
* purged, claims cleanly and sends the notification a second time. `postgresDeliveryLedger({
|
|
113
113
|
* executor, windowMs: idempotency.windowMs })` makes that impossible by construction rather
|
|
114
114
|
* than by two defaults that happen to agree.
|
|
115
115
|
*/
|
|
@@ -125,15 +125,17 @@ export const DEFAULT_DELIVERY_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
|
125
125
|
* seam every implementation must satisfy is a breaking change for an app that wrote its own.
|
|
126
126
|
* Exactly the shape `PostgresIdempotencyStore` already has.
|
|
127
127
|
*/
|
|
128
|
-
export interface
|
|
128
|
+
export interface PostgresDeliveryLedger extends DeliveryLedger {
|
|
129
129
|
readonly windowMs: number;
|
|
130
130
|
purgeExpired(nowMs: number): Promise<number>;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
export function
|
|
133
|
+
export function postgresDeliveryLedger(
|
|
134
|
+
options: PostgresDeliveryLedgerOptions,
|
|
135
|
+
): PostgresDeliveryLedger {
|
|
134
136
|
const { executor } = options;
|
|
135
137
|
const windowMs = finiteCount(
|
|
136
|
-
'
|
|
138
|
+
'postgresDeliveryLedger',
|
|
137
139
|
'windowMs',
|
|
138
140
|
options.windowMs ?? DEFAULT_DELIVERY_WINDOW_MS,
|
|
139
141
|
1,
|
package/src/ledger.ts
CHANGED
|
@@ -58,11 +58,12 @@ export interface DeliveryLedger {
|
|
|
58
58
|
* The tuple as one string. `JSON.stringify` of an ARRAY rather than a joined separator: a
|
|
59
59
|
* recipient id or a channel name containing the separator would otherwise collide two distinct
|
|
60
60
|
* deliveries into one ledger row, and the failure mode is a notification nobody ever receives.
|
|
61
|
+
* `recipient ?? ''` is the Postgres ledger's `coalesce(recipient, '')`: one identity, both stores.
|
|
61
62
|
*/
|
|
62
63
|
const keyOf = (claim: DeliveryClaim): string =>
|
|
63
|
-
JSON.stringify([claim.notifier, claim.key, claim.recipient, claim.channel]);
|
|
64
|
+
JSON.stringify([claim.notifier, claim.key, claim.recipient ?? '', claim.channel]);
|
|
64
65
|
|
|
65
|
-
export interface
|
|
66
|
+
export interface MemoryDeliveryLedgerOptions {
|
|
66
67
|
/**
|
|
67
68
|
* Rows kept before the oldest is evicted. A process-local ledger is a DEV ledger — it forgets
|
|
68
69
|
* on restart and it is private to one replica — so it is bounded rather than allowed to grow
|
|
@@ -82,11 +83,11 @@ export interface MemoryDeliveryLedger extends DeliveryLedger {
|
|
|
82
83
|
|
|
83
84
|
/**
|
|
84
85
|
* The default, and honest about what it is: one process, no durability. Installed by a test and by
|
|
85
|
-
* `x dev`; a deployment with more than one replica needs `
|
|
86
|
+
* `x dev`; a deployment with more than one replica needs `postgresDeliveryLedger`, because a claim
|
|
86
87
|
* this replica took is invisible to the one that replays the job.
|
|
87
88
|
*/
|
|
88
|
-
export function
|
|
89
|
-
options:
|
|
89
|
+
export function memoryDeliveryLedger(
|
|
90
|
+
options: MemoryDeliveryLedgerOptions = {},
|
|
90
91
|
): MemoryDeliveryLedger {
|
|
91
92
|
// `while (rows.size > max)` is the eviction, so a `max` that is not a number is not a large cap
|
|
92
93
|
// — it is no cap, and this ledger grows into the heap of a process that was told it was bounded.
|
|
@@ -94,7 +95,7 @@ export function createMemoryDeliveryLedger(
|
|
|
94
95
|
// here intact. A floor of 1 because a ledger that keeps zero rows cannot refuse a replay, which
|
|
95
96
|
// is its one job, and it fails at it silently.
|
|
96
97
|
const max = finiteCount(
|
|
97
|
-
'
|
|
98
|
+
'memoryDeliveryLedger',
|
|
98
99
|
'max',
|
|
99
100
|
options.max ?? DEFAULT_MAX_DELIVERY_RECORDS,
|
|
100
101
|
1,
|
|
@@ -122,8 +123,9 @@ export function createMemoryDeliveryLedger(
|
|
|
122
123
|
const id = keyOf(claim);
|
|
123
124
|
const existing = rows.get(id);
|
|
124
125
|
if (existing?.status === 'sent') return Promise.resolve(false);
|
|
126
|
+
// The row it found keeps its own identity, as the pg `on conflict do update` does.
|
|
125
127
|
rows.set(id, {
|
|
126
|
-
...claim,
|
|
128
|
+
...(existing ?? claim),
|
|
127
129
|
status: 'sending',
|
|
128
130
|
attempts: (existing?.attempts ?? 0) + 1,
|
|
129
131
|
at,
|
|
@@ -134,7 +136,9 @@ export function createMemoryDeliveryLedger(
|
|
|
134
136
|
settle(claim, status, at) {
|
|
135
137
|
const id = keyOf(claim);
|
|
136
138
|
const existing = rows.get(id);
|
|
137
|
-
|
|
139
|
+
// No claim, no row: the pg `update` matches nothing, and a row written here would refuse
|
|
140
|
+
// the claim after it — a notification dev never sends and production does.
|
|
141
|
+
if (existing !== undefined) rows.set(id, { ...existing, status, at });
|
|
138
142
|
return Promise.resolve();
|
|
139
143
|
},
|
|
140
144
|
find(claim) {
|
package/src/preferences.ts
CHANGED
|
@@ -52,7 +52,7 @@ const optOutKey = (recipient: string, notifier: string, channel: string): string
|
|
|
52
52
|
* A test double and a dev store, not a product. Real preferences live in the app's own table
|
|
53
53
|
* beside the taxonomy that names them; this exists so the gate can be exercised without one.
|
|
54
54
|
*/
|
|
55
|
-
export function
|
|
55
|
+
export function memoryPreferenceStore(): MemoryPreferenceStore {
|
|
56
56
|
const denied = new Set<string>();
|
|
57
57
|
return {
|
|
58
58
|
allows(query) {
|
package/src/retention.ts
CHANGED
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
// owns the hourly sweep, so the sweep cannot hold the stores — it can only ask, per attempt, what
|
|
4
4
|
// is installed now. Same shape and the same reason as `purgeAuthLimits()`.
|
|
5
5
|
|
|
6
|
-
import type {
|
|
7
|
-
import type { InboxPurgeBefore,
|
|
8
|
-
import type {
|
|
6
|
+
import type { PostgresDigestStore } from './digest-pg';
|
|
7
|
+
import type { InboxPurgeBefore, PostgresInboxStore } from './inbox-pg';
|
|
8
|
+
import type { PostgresDeliveryLedger } from './ledger-pg';
|
|
9
9
|
import { notifyStores } from './stores';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Whether the installed store can delete by age at all.
|
|
13
13
|
*
|
|
14
|
-
* A DECLARED capability check, never duck typing: `
|
|
14
|
+
* A DECLARED capability check, never duck typing: `PostgresInboxStore` and `PostgresDeliveryLedger` widen the
|
|
15
15
|
* seam their memory siblings satisfy, so "has this method" is exactly "is this the Postgres one".
|
|
16
16
|
* The memory stores deliberately have none — a heap map is bounded by process life, and adding the
|
|
17
17
|
* method to `InboxStore`/`DeliveryLedger` would break every app that wrote its own implementation.
|
|
@@ -31,13 +31,13 @@ const purgeable = <T>(store: unknown, method: string): store is T =>
|
|
|
31
31
|
*/
|
|
32
32
|
export async function purgeNotifyInbox(before: InboxPurgeBefore): Promise<number> {
|
|
33
33
|
const store = notifyStores().inbox;
|
|
34
|
-
if (!purgeable<
|
|
34
|
+
if (!purgeable<PostgresInboxStore>(store, 'purgeBefore')) return 0;
|
|
35
35
|
return store.purgeBefore(before);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* Delete delivery claims past the ledger's own window, and answer how many. The window is the
|
|
40
|
-
* LEDGER's, never this caller's: `
|
|
40
|
+
* LEDGER's, never this caller's: `postgresDeliveryLedger({ windowMs })` is where an app states it,
|
|
41
41
|
* beside the statement that reads it, so there is one number rather than two that can disagree.
|
|
42
42
|
*
|
|
43
43
|
* `nowMs` is the job's clock for the reason `x_rate_limit`'s target states — `at` is written by
|
|
@@ -46,7 +46,7 @@ export async function purgeNotifyInbox(before: InboxPurgeBefore): Promise<number
|
|
|
46
46
|
*/
|
|
47
47
|
export async function purgeNotifyDeliveries(nowMs: number): Promise<number> {
|
|
48
48
|
const ledger = notifyStores().ledger;
|
|
49
|
-
if (!purgeable<
|
|
49
|
+
if (!purgeable<PostgresDeliveryLedger>(ledger, 'purgeExpired')) return 0;
|
|
50
50
|
return ledger.purgeExpired(nowMs);
|
|
51
51
|
}
|
|
52
52
|
|
|
@@ -57,6 +57,6 @@ export async function purgeNotifyDeliveries(nowMs: number): Promise<number> {
|
|
|
57
57
|
*/
|
|
58
58
|
export async function purgeNotifyDigests(nowMs: number): Promise<number> {
|
|
59
59
|
const digest = notifyStores().digest;
|
|
60
|
-
if (!purgeable<
|
|
60
|
+
if (!purgeable<PostgresDigestStore>(digest, 'purgeExpired')) return 0;
|
|
61
61
|
return digest.purgeExpired(nowMs);
|
|
62
62
|
}
|
package/src/stores.ts
CHANGED
|
@@ -9,13 +9,13 @@ import type { DigestStore } from './digest';
|
|
|
9
9
|
import { NotifyStoreMissingError } from './errors';
|
|
10
10
|
import type { InboxStore } from './inbox';
|
|
11
11
|
import type { DeliveryLedger } from './ledger';
|
|
12
|
-
import {
|
|
12
|
+
import { memoryDeliveryLedger } from './ledger';
|
|
13
13
|
import type { PreferenceStore } from './preferences';
|
|
14
14
|
import { allowAllPreferences } from './preferences';
|
|
15
15
|
|
|
16
16
|
export interface NotifyStores {
|
|
17
17
|
/**
|
|
18
|
-
* Defaults to `
|
|
18
|
+
* Defaults to `memoryDeliveryLedger()`. A default is correct here and nowhere else in this
|
|
19
19
|
* interface: one process with one replica is genuinely deduped by a heap map, and the failure
|
|
20
20
|
* mode of having none at all — a replayed attempt sending twice — is worse than the failure mode
|
|
21
21
|
* of a dev default, which is a second send only after a restart.
|
|
@@ -39,7 +39,7 @@ export interface InstalledNotifyStores {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
const defaults = (): InstalledNotifyStores => ({
|
|
42
|
-
ledger:
|
|
42
|
+
ledger: memoryDeliveryLedger(),
|
|
43
43
|
inbox: undefined,
|
|
44
44
|
preferences: allowAllPreferences(),
|
|
45
45
|
digest: undefined,
|
|
@@ -51,7 +51,7 @@ let installed: InstalledNotifyStores = defaults();
|
|
|
51
51
|
* `RuntimeOverrides` exists to refuse, and a merge would hide the second call's omissions. */
|
|
52
52
|
export function setNotifyStores(stores: NotifyStores): void {
|
|
53
53
|
installed = {
|
|
54
|
-
ledger: stores.ledger ??
|
|
54
|
+
ledger: stores.ledger ?? memoryDeliveryLedger(),
|
|
55
55
|
inbox: stores.inbox,
|
|
56
56
|
preferences: stores.preferences ?? allowAllPreferences(),
|
|
57
57
|
digest: stores.digest,
|