@ultimat3/notify 16.0.0 → 18.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 +25 -7
- package/README.md +31 -1
- package/package.json +6 -6
- package/src/inbox-pg.ts +60 -3
- package/src/inbox.ts +17 -4
- package/src/index.ts +6 -2
- package/src/ledger-pg.ts +64 -1
- package/src/ledger.ts +13 -1
- package/src/notifier.ts +21 -3
- package/src/plan.ts +23 -2
- package/src/retention.ts +50 -0
package/CLAUDE.md
CHANGED
|
@@ -71,6 +71,7 @@ rather than hidden, and a durable store can close it.
|
|
|
71
71
|
| `inbox.ts` · `inbox-pg.ts` | the in-app inbox, memory and Postgres |
|
|
72
72
|
| `preferences.ts` · `digest.ts` | the gate and the window, as seams |
|
|
73
73
|
| `stores.ts` | the one installer for all four |
|
|
74
|
+
| `retention.ts` | the two sweeps, read off the installed seam |
|
|
74
75
|
| `errors.ts` | this package's `X_NOTIFY_*` codes and their titles |
|
|
75
76
|
|
|
76
77
|
One entry point, deliberately: every module runs on the server, so there is no browser half to split
|
|
@@ -81,13 +82,30 @@ off.
|
|
|
81
82
|
| Exports | `src/index.ts`, explicit, no `export *` |
|
|
82
83
|
| Errors | `src/errors.ts`, subclass `UltimateError`, never a bare `Error` |
|
|
83
84
|
| Files | one responsibility each, < 200 lines, tests beside the source |
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
`
|
|
85
|
+
| Durations | one vocabulary — `@ultimat3/time`'s. `toDurationMs` is a **narrowing** of `toMs`, never a copy of it: `toMs` screens finiteness and stops, because a negative or fractional duration is real there (`toSeconds(-3000)` is a tested `-3`); a `wait` and a digest `window` are counts of whole FORWARD milliseconds, and the refusal names which declaration was wrong since one notifier holds several. `plan-bounds.test.ts` calls BOTH on the same inputs — the two disagreed once (#372), when only this side was screened, and only a test that calls both can see it come back |
|
|
86
|
+
|
|
87
|
+
## Retention
|
|
88
|
+
|
|
89
|
+
Both tables are swept by the boot's hourly `x.purge` job, and neither store is handed to it:
|
|
90
|
+
`setNotifyStores` is an APP's boot line that runs when the app's modules import, after the boot that
|
|
91
|
+
installs the sweep. So `retention.ts` reads the seam **per attempt** — the same shape as
|
|
92
|
+
`purgeAuthLimits()` — and answers `0` for a memory store or none at all, which is a boot that made a
|
|
93
|
+
decision rather than a failure.
|
|
94
|
+
|
|
95
|
+
| Table | Window | Named where |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `x_notify_deliveries` | `PgDeliveryLedgerOptions.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` |
|
|
98
|
+
| `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) |
|
|
99
|
+
|
|
100
|
+
`purgeBefore` and `purgeExpired` live on the **Postgres stores' own wider types**
|
|
101
|
+
(`PgInboxStore`, `PgDeliveryLedger`), never on `InboxStore`/`DeliveryLedger`: adding a method to the
|
|
102
|
+
seam every implementation must satisfy is a breaking change for an app that wrote its own, and a
|
|
103
|
+
heap map bounded by process life has nothing to delete. Exactly the shape `PostgresIdempotencyStore`
|
|
104
|
+
already has.
|
|
105
|
+
|
|
106
|
+
`packages/cli/src/framework-schema.ts` applies both tables' DDL on every boot, **whether or not that
|
|
107
|
+
boot calls `setNotifyStores`** — this file said it did not until 2026-08-27, which is a sentence that
|
|
108
|
+
outlived its fact.
|
|
91
109
|
|
|
92
110
|
Commands: `bun test packages/notify/src`, `bun run boundaries`,
|
|
93
111
|
`bunx biome check packages/notify`.
|
package/README.md
CHANGED
|
@@ -105,13 +105,16 @@ import {
|
|
|
105
105
|
} from '@ultimat3/notify';
|
|
106
106
|
|
|
107
107
|
declare const executor: PgExecutor; // `@ultimat3/cli`'s pgExecutorFor(client)
|
|
108
|
+
declare const idempotency: { readonly windowMs: number }; // the boot's own store
|
|
108
109
|
// The app's preference table, behind whatever taxonomy it named.
|
|
109
110
|
declare const prefs: {
|
|
110
111
|
allows(recipient: string, notifier: string, channel: string, at: Date): boolean;
|
|
111
112
|
};
|
|
112
113
|
|
|
113
114
|
setNotifyStores({
|
|
114
|
-
|
|
115
|
+
// `windowMs` is how long a settled claim is kept. NEVER SHORTER than your idempotency window —
|
|
116
|
+
// a job replayed inside that window against a purged claim claims cleanly and sends twice.
|
|
117
|
+
ledger: createPgDeliveryLedger({ executor, windowMs: idempotency.windowMs }),
|
|
115
118
|
inbox: createPgInboxStore({ executor }),
|
|
116
119
|
digest: createMemoryDigestStore(),
|
|
117
120
|
preferences: {
|
|
@@ -132,6 +135,33 @@ setNotifyStores({
|
|
|
132
135
|
`executor` is a structural `{ query(sql, params) }` — `@ultimat3/cli`'s `pgExecutorFor(client)` over
|
|
133
136
|
a `DbClient` is the framework's own. `Bun.sql` does **not** satisfy it.
|
|
134
137
|
|
|
138
|
+
## Retention
|
|
139
|
+
|
|
140
|
+
Both tables grow with traffic, and the boot's hourly `x.purge` job sweeps both — but only against
|
|
141
|
+
the **Postgres** stores. `createPgInboxStore` carries `purgeBefore` and `createPgDeliveryLedger`
|
|
142
|
+
carries `purgeExpired`; the memory ones do not, and a boot that installed a memory store sweeps
|
|
143
|
+
nothing. The methods are on those stores' own wider types (`PgInboxStore`, `PgDeliveryLedger`), not
|
|
144
|
+
on `InboxStore`/`DeliveryLedger`, so an app that wrote its own implementation is unaffected.
|
|
145
|
+
|
|
146
|
+
| Table | Window | Default |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| `x_notify_deliveries` | `createPgDeliveryLedger({ windowMs })` | 24 h. A settled claim ages from its **last** attempt — `settle` moves `at` |
|
|
149
|
+
| `x_notify_inbox` | `notify.inboxReadRetentionMs` / `notify.inboxUnreadRetentionMs` in `app.config.ts` | **neither** — never swept |
|
|
150
|
+
|
|
151
|
+
The inbox default is deliberate and is not a missing number. An inbox row is a message a person has
|
|
152
|
+
not read yet, so when it disappears is your decision, not the framework's — which is why the key
|
|
153
|
+
lives in **your** config and why there are two of them: read notices gone in a month with unread
|
|
154
|
+
ones kept forever is the shape most apps want, and it is only expressible if the two windows are
|
|
155
|
+
separate.
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
// app.config.ts
|
|
159
|
+
notify: { inboxReadRetentionMs: 30 * 24 * 60 * 60 * 1000 }
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
A read row ages from `read_at` and an unread one from `created_at` — ageing a read row from
|
|
163
|
+
`created_at` would delete a notification the moment the recipient opened an old one.
|
|
164
|
+
|
|
135
165
|
## Channels
|
|
136
166
|
|
|
137
167
|
```ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/notify",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "18.0.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.
|
|
31
|
+
"bun": ">=1.4.0"
|
|
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": "18.0.0",
|
|
39
|
+
"@ultimat3/jobs": "18.0.0",
|
|
40
|
+
"@ultimat3/schema": "18.0.0",
|
|
41
|
+
"@ultimat3/time": "18.0.0"
|
|
42
42
|
}
|
|
43
43
|
}
|
package/src/inbox-pg.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The shared in-app inbox: one Postgres table, applied by the boot the way `x_jobs` is.
|
|
2
2
|
// Statements are spelled out so an agent can run the exact one it saw in a log.
|
|
3
3
|
|
|
4
|
-
import { uuid } from '@ultimat3/core';
|
|
4
|
+
import { finiteCount, uuid } from '@ultimat3/core';
|
|
5
5
|
import type { PgExecutor } from '@ultimat3/jobs';
|
|
6
6
|
import type { InboxRow, InboxStore, InboxWrite } from './inbox';
|
|
7
7
|
import { DEFAULT_INBOX_PAGE } from './inbox';
|
|
@@ -74,6 +74,23 @@ where recipient = $1 and id = any($2::uuid[]) and read_at is null
|
|
|
74
74
|
returning id
|
|
75
75
|
`;
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Both windows in ONE statement, and each half is inert when its window is absent: a `null`
|
|
79
|
+
* cutoff makes its `is not null` guard false, so the other half runs alone. One round trip, and
|
|
80
|
+
* — more importantly — no way to express "purge read rows" and "purge unread rows" as two
|
|
81
|
+
* statements that could disagree about which rows are which.
|
|
82
|
+
*
|
|
83
|
+
* A READ row ages from `read_at` and an UNREAD one from `created_at`. Ageing a read row from
|
|
84
|
+
* `created_at` would delete a notification the moment the recipient opened it, if it happened to
|
|
85
|
+
* be old — which is the opposite of what a read window means.
|
|
86
|
+
*/
|
|
87
|
+
export const SQL_NOTIFY_INBOX_PURGE = `
|
|
88
|
+
delete from x_notify_inbox
|
|
89
|
+
where ($1::timestamptz is not null and read_at is not null and read_at < $1)
|
|
90
|
+
or ($2::timestamptz is not null and read_at is null and created_at < $2)
|
|
91
|
+
returning id
|
|
92
|
+
`;
|
|
93
|
+
|
|
77
94
|
export const SQL_NOTIFY_INBOX_MARK_SEEN = `
|
|
78
95
|
update x_notify_inbox set seen_at = $2 where recipient = $1 and seen_at is null returning id
|
|
79
96
|
`;
|
|
@@ -112,7 +129,27 @@ export interface PgInboxStoreOptions {
|
|
|
112
129
|
readonly newId?: () => string;
|
|
113
130
|
}
|
|
114
131
|
|
|
115
|
-
|
|
132
|
+
/**
|
|
133
|
+
* The two cutoffs, each `undefined` where its window is unset. Never a single window: the axiom-8
|
|
134
|
+
* objection to sweeping an inbox is only about UNREAD messages, so the two have to be separately
|
|
135
|
+
* expressible or an app that wants read notices gone in a month is forced to choose between
|
|
136
|
+
* deleting unread ones too and sweeping nothing.
|
|
137
|
+
*/
|
|
138
|
+
export interface InboxPurgeBefore {
|
|
139
|
+
readonly read?: Date | undefined;
|
|
140
|
+
readonly unread?: Date | undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The Postgres inbox's own wider type. `purgeBefore` is HERE and not on `InboxStore` for the
|
|
145
|
+
* reason `PgDeliveryLedger.purgeExpired` is not on `DeliveryLedger`: adding a method to the seam
|
|
146
|
+
* every implementation must satisfy is a breaking change for an app that wrote its own.
|
|
147
|
+
*/
|
|
148
|
+
export interface PgInboxStore extends InboxStore {
|
|
149
|
+
purgeBefore(before: InboxPurgeBefore): Promise<number>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function createPgInboxStore(options: PgInboxStoreOptions): PgInboxStore {
|
|
116
153
|
const { executor } = options;
|
|
117
154
|
const newId = options.newId ?? uuid;
|
|
118
155
|
return {
|
|
@@ -132,10 +169,19 @@ export function createPgInboxStore(options: PgInboxStoreOptions): InboxStore {
|
|
|
132
169
|
return row === undefined ? { id: newId(), ...write, seenAt: null, readAt: null } : toRow(row);
|
|
133
170
|
},
|
|
134
171
|
async list(query) {
|
|
172
|
+
// Screened here and not left to Postgres: this number is bound straight into `limit $3`, and
|
|
173
|
+
// what a driver does with a `NaN` parameter is the driver's business — the memory store beside
|
|
174
|
+
// it answered `[]` for the same input. `??` cannot see it, because `NaN` is not nullish.
|
|
175
|
+
const limit = finiteCount(
|
|
176
|
+
'createPgInboxStore',
|
|
177
|
+
'limit',
|
|
178
|
+
query.limit ?? DEFAULT_INBOX_PAGE,
|
|
179
|
+
0,
|
|
180
|
+
);
|
|
135
181
|
const rows = await executor.query<InboxDbRow>(SQL_NOTIFY_INBOX_PAGE, [
|
|
136
182
|
query.recipient,
|
|
137
183
|
query.unreadOnly === true,
|
|
138
|
-
|
|
184
|
+
limit,
|
|
139
185
|
]);
|
|
140
186
|
return rows.map(toRow);
|
|
141
187
|
},
|
|
@@ -151,6 +197,17 @@ export function createPgInboxStore(options: PgInboxStoreOptions): InboxStore {
|
|
|
151
197
|
]);
|
|
152
198
|
return rows.length;
|
|
153
199
|
},
|
|
200
|
+
async purgeBefore(before) {
|
|
201
|
+
// Neither window set is not an error and not a no-op to leave to Postgres: the statement
|
|
202
|
+
// would run, match nothing and cost a scan on every hourly sweep of every app that never
|
|
203
|
+
// configured retention, which is every app by default.
|
|
204
|
+
if (before.read === undefined && before.unread === undefined) return 0;
|
|
205
|
+
const rows = await executor.query<{ id: string }>(SQL_NOTIFY_INBOX_PURGE, [
|
|
206
|
+
before.read ?? null,
|
|
207
|
+
before.unread ?? null,
|
|
208
|
+
]);
|
|
209
|
+
return rows.length;
|
|
210
|
+
},
|
|
154
211
|
async markSeen(input) {
|
|
155
212
|
const rows = await executor.query<{ id: string }>(SQL_NOTIFY_INBOX_MARK_SEEN, [
|
|
156
213
|
input.recipient,
|
package/src/inbox.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// count is DERIVED from `readAt is null`, never stored, because a stored counter and a row set
|
|
6
6
|
// drift the first time a write half-lands.
|
|
7
7
|
|
|
8
|
+
import { finiteCount } from '@ultimat3/core';
|
|
9
|
+
|
|
8
10
|
export interface InboxRow {
|
|
9
11
|
/** Stable within a store. `(recipient, notifier, key)` is what makes it unique. */
|
|
10
12
|
readonly id: string;
|
|
@@ -94,11 +96,22 @@ export function createMemoryInboxStore(): MemoryInboxStore {
|
|
|
94
96
|
rows.set(id, row);
|
|
95
97
|
return Promise.resolve(row);
|
|
96
98
|
},
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
// `async` and not `Promise.resolve`, so a refused `limit` REJECTS here exactly as it does in
|
|
100
|
+
// `createPgInboxStore`: two drivers behind one interface must not answer one question two ways,
|
|
101
|
+
// and a sync throw against a rejected promise is a difference a caller can see.
|
|
102
|
+
async list(query) {
|
|
103
|
+
// `slice(0, NaN)` is `[]` — an empty inbox reported as the whole of it — and
|
|
104
|
+
// `slice(0, Infinity)` is every row the recipient ever received, which is the unbounded read
|
|
105
|
+
// `limit`'s own doc forbids. `??` reaches neither: `NaN` is not nullish.
|
|
106
|
+
const limit = finiteCount(
|
|
107
|
+
'createMemoryInboxStore',
|
|
108
|
+
'limit',
|
|
109
|
+
query.limit ?? DEFAULT_INBOX_PAGE,
|
|
110
|
+
0,
|
|
111
|
+
);
|
|
112
|
+
return own(query.recipient)
|
|
99
113
|
.filter((row) => query.unreadOnly !== true || row.readAt === null)
|
|
100
|
-
.slice(0,
|
|
101
|
-
return Promise.resolve(page);
|
|
114
|
+
.slice(0, limit);
|
|
102
115
|
},
|
|
103
116
|
unreadCount(recipient) {
|
|
104
117
|
return Promise.resolve(own(recipient).filter((row) => row.readAt === null).length);
|
package/src/index.ts
CHANGED
|
@@ -44,13 +44,14 @@ export {
|
|
|
44
44
|
// with it the retry policy, the cancellation and the manifest row.
|
|
45
45
|
export type { InboxQuery, InboxRow, InboxStore, InboxWrite, MemoryInboxStore } from './inbox';
|
|
46
46
|
export { createMemoryInboxStore, DEFAULT_INBOX_PAGE } from './inbox';
|
|
47
|
-
export type { PgInboxStoreOptions } from './inbox-pg';
|
|
47
|
+
export type { InboxPurgeBefore, PgInboxStore, PgInboxStoreOptions } from './inbox-pg';
|
|
48
48
|
export {
|
|
49
49
|
createPgInboxStore,
|
|
50
50
|
SQL_NOTIFY_INBOX_ADD,
|
|
51
51
|
SQL_NOTIFY_INBOX_MARK_READ,
|
|
52
52
|
SQL_NOTIFY_INBOX_MARK_SEEN,
|
|
53
53
|
SQL_NOTIFY_INBOX_PAGE,
|
|
54
|
+
SQL_NOTIFY_INBOX_PURGE,
|
|
54
55
|
SQL_NOTIFY_INBOX_TABLE,
|
|
55
56
|
SQL_NOTIFY_INBOX_UNREAD,
|
|
56
57
|
} from './inbox-pg';
|
|
@@ -68,10 +69,12 @@ export {
|
|
|
68
69
|
DELIVERY_STATUSES,
|
|
69
70
|
isDeliveryStatus,
|
|
70
71
|
} from './ledger';
|
|
71
|
-
export type { PgDeliveryLedgerOptions } from './ledger-pg';
|
|
72
|
+
export type { PgDeliveryLedger, PgDeliveryLedgerOptions } from './ledger-pg';
|
|
72
73
|
export {
|
|
73
74
|
createPgDeliveryLedger,
|
|
75
|
+
DEFAULT_DELIVERY_WINDOW_MS,
|
|
74
76
|
SQL_NOTIFY_CLAIM,
|
|
77
|
+
SQL_NOTIFY_DELIVERIES_PURGE,
|
|
75
78
|
SQL_NOTIFY_DELIVERIES_TABLE,
|
|
76
79
|
SQL_NOTIFY_FIND,
|
|
77
80
|
SQL_NOTIFY_SETTLE,
|
|
@@ -94,6 +97,7 @@ export type {
|
|
|
94
97
|
export { toDurationMs } from './plan';
|
|
95
98
|
export type { MemoryPreferenceStore, PreferenceQuery, PreferenceStore } from './preferences';
|
|
96
99
|
export { allowAllPreferences, createMemoryPreferenceStore } from './preferences';
|
|
100
|
+
export { purgeNotifyDeliveries, purgeNotifyInbox } from './retention';
|
|
97
101
|
export type { InstalledNotifyStores, NotifyStores } from './stores';
|
|
98
102
|
export {
|
|
99
103
|
notifyStores,
|
package/src/ledger-pg.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Statements are spelled out so an agent can run the exact one it saw in a log.
|
|
6
6
|
|
|
7
|
+
import { finiteCount } from '@ultimat3/core';
|
|
7
8
|
import type { PgExecutor } from '@ultimat3/jobs';
|
|
8
9
|
import type { DeliveryClaim, DeliveryLedger, DeliveryRecord, DeliveryStatus } from './ledger';
|
|
9
10
|
import { isDeliveryStatus } from './ledger';
|
|
@@ -80,13 +81,75 @@ const argsOf = (claim: DeliveryClaim): readonly unknown[] => [
|
|
|
80
81
|
claim.channel,
|
|
81
82
|
];
|
|
82
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Delete every claim older than the window, and answer how many. `at` is the claim's own
|
|
86
|
+
* timestamp and `SQL_NOTIFY_SETTLE` moves it, so a row ages from its LAST attempt rather than its
|
|
87
|
+
* first — a delivery still being retried is never swept out from under the retry.
|
|
88
|
+
*
|
|
89
|
+
* Unconditional on status, deliberately. A `sending` row past the window belonged to a process
|
|
90
|
+
* that died and never came back; keeping it forever protects nothing, because a re-claim is
|
|
91
|
+
* already allowed on it (see `DeliveryLedger.claim`).
|
|
92
|
+
*/
|
|
93
|
+
export const SQL_NOTIFY_DELIVERIES_PURGE = `
|
|
94
|
+
delete from x_notify_deliveries where at < $1
|
|
95
|
+
`;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The statement the store actually runs. A bare `delete` answers NO ROWS through `PgExecutor`, so
|
|
99
|
+
* a count read off it is zero forever — a sweep that reports it deleted nothing while deleting
|
|
100
|
+
* everything, which is indistinguishable in a log from a sweep that is not wired at all.
|
|
101
|
+
* `SQL_IDEMPOTENCY_PURGE` is spliced the same way for the same reason.
|
|
102
|
+
*/
|
|
103
|
+
const SQL_NOTIFY_DELIVERIES_PURGE_COUNTED = `${SQL_NOTIFY_DELIVERIES_PURGE} returning key`;
|
|
104
|
+
|
|
83
105
|
export interface PgDeliveryLedgerOptions {
|
|
84
106
|
readonly executor: PgExecutor;
|
|
107
|
+
/**
|
|
108
|
+
* How long a settled claim is kept. Defaults to `DEFAULT_DELIVERY_WINDOW_MS`.
|
|
109
|
+
*
|
|
110
|
+
* NEVER SHORTER THAN YOUR IDEMPOTENCY WINDOW, and this is the one dangerous direction: a job
|
|
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. `createPgDeliveryLedger({
|
|
113
|
+
* executor, windowMs: idempotency.windowMs })` makes that impossible by construction rather
|
|
114
|
+
* than by two defaults that happen to agree.
|
|
115
|
+
*/
|
|
116
|
+
readonly windowMs?: number | undefined;
|
|
85
117
|
}
|
|
86
118
|
|
|
87
|
-
|
|
119
|
+
/** 24 hours — the same default `postgresIdempotencyStore` carries, for the reason above. */
|
|
120
|
+
export const DEFAULT_DELIVERY_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The Postgres ledger's own wider type. `purgeExpired` is HERE and not on `DeliveryLedger`
|
|
124
|
+
* because a heap map bounded by process life has nothing to delete, and adding a method to the
|
|
125
|
+
* seam every implementation must satisfy is a breaking change for an app that wrote its own.
|
|
126
|
+
* Exactly the shape `PostgresIdempotencyStore` already has.
|
|
127
|
+
*/
|
|
128
|
+
export interface PgDeliveryLedger extends DeliveryLedger {
|
|
129
|
+
readonly windowMs: number;
|
|
130
|
+
purgeExpired(nowMs: number): Promise<number>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createPgDeliveryLedger(options: PgDeliveryLedgerOptions): PgDeliveryLedger {
|
|
88
134
|
const { executor } = options;
|
|
135
|
+
const windowMs = finiteCount(
|
|
136
|
+
'createPgDeliveryLedger',
|
|
137
|
+
'windowMs',
|
|
138
|
+
options.windowMs ?? DEFAULT_DELIVERY_WINDOW_MS,
|
|
139
|
+
1,
|
|
140
|
+
);
|
|
89
141
|
return {
|
|
142
|
+
windowMs,
|
|
143
|
+
async purgeExpired(nowMs) {
|
|
144
|
+
// The JOB's clock, not the server's, and the same rule `x_rate_limit`'s target states: `at`
|
|
145
|
+
// is written by whichever process took the delivery, so a cutoff computed from `now()` in
|
|
146
|
+
// Postgres measures the offset between two clocks instead of the age of the row.
|
|
147
|
+
const before = new Date(finiteCount('purgeExpired', 'nowMs', nowMs, 0) - windowMs);
|
|
148
|
+
const rows = await executor.query<{ key: string }>(SQL_NOTIFY_DELIVERIES_PURGE_COUNTED, [
|
|
149
|
+
before,
|
|
150
|
+
]);
|
|
151
|
+
return rows.length;
|
|
152
|
+
},
|
|
90
153
|
async claim(claim, at) {
|
|
91
154
|
const rows = await executor.query<{ attempts: number }>(SQL_NOTIFY_CLAIM, [
|
|
92
155
|
...argsOf(claim),
|
package/src/ledger.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// replays the send. This is the second layer: the claim is taken atomically before the send, and a
|
|
6
6
|
// claim that already reads `sent` answers `false` and the fan-out skips.
|
|
7
7
|
|
|
8
|
+
import { finiteCount } from '@ultimat3/core';
|
|
9
|
+
|
|
8
10
|
export const DELIVERY_STATUSES = ['sending', 'sent', 'failed'] as const;
|
|
9
11
|
|
|
10
12
|
export type DeliveryStatus = (typeof DELIVERY_STATUSES)[number];
|
|
@@ -86,7 +88,17 @@ export interface MemoryDeliveryLedger extends DeliveryLedger {
|
|
|
86
88
|
export function createMemoryDeliveryLedger(
|
|
87
89
|
options: MemoryLedgerOptions = {},
|
|
88
90
|
): MemoryDeliveryLedger {
|
|
89
|
-
|
|
91
|
+
// `while (rows.size > max)` is the eviction, so a `max` that is not a number is not a large cap
|
|
92
|
+
// — it is no cap, and this ledger grows into the heap of a process that was told it was bounded.
|
|
93
|
+
// `??` guards nullish and `NaN` is not, so `Number(process.env.…)` on an unset variable arrives
|
|
94
|
+
// here intact. A floor of 1 because a ledger that keeps zero rows cannot refuse a replay, which
|
|
95
|
+
// is its one job, and it fails at it silently.
|
|
96
|
+
const max = finiteCount(
|
|
97
|
+
'createMemoryDeliveryLedger',
|
|
98
|
+
'max',
|
|
99
|
+
options.max ?? DEFAULT_MAX_DELIVERY_RECORDS,
|
|
100
|
+
1,
|
|
101
|
+
);
|
|
90
102
|
const rows = new Map<string, DeliveryRecord>();
|
|
91
103
|
let dropped = 0;
|
|
92
104
|
|
package/src/notifier.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// The declaration lives here and the fan-out lives in `fanout.ts` — the same split `backfill.ts`
|
|
10
10
|
// and `backfill-pass.ts` already have.
|
|
11
11
|
|
|
12
|
+
import { finiteCount } from '@ultimat3/core';
|
|
12
13
|
import type { JobHandle, JobTenant, RetryPolicy } from '@ultimat3/jobs';
|
|
13
14
|
import { DEFAULT_RETRY, job } from '@ultimat3/jobs';
|
|
14
15
|
import type { Schema } from '@ultimat3/schema';
|
|
@@ -101,10 +102,16 @@ function resolve<Params>(
|
|
|
101
102
|
}
|
|
102
103
|
return {
|
|
103
104
|
channel,
|
|
104
|
-
waitMs:
|
|
105
|
+
waitMs:
|
|
106
|
+
delivery.wait === undefined
|
|
107
|
+
? 0
|
|
108
|
+
: toDurationMs(delivery.wait, `deliver[${channel.name}].wait`),
|
|
105
109
|
when: delivery.if,
|
|
106
110
|
unless: delivery.unless,
|
|
107
|
-
digestMs:
|
|
111
|
+
digestMs:
|
|
112
|
+
delivery.digest === undefined
|
|
113
|
+
? undefined
|
|
114
|
+
: toDurationMs(delivery.digest.window, `deliver[${channel.name}].digest.window`),
|
|
108
115
|
group: delivery.digest?.group,
|
|
109
116
|
};
|
|
110
117
|
});
|
|
@@ -121,7 +128,18 @@ export function notifier<Params>(
|
|
|
121
128
|
const declared = definition.recipients;
|
|
122
129
|
const plan: NotifyPlan<Params> = {
|
|
123
130
|
name: definition.name,
|
|
124
|
-
|
|
131
|
+
// `audience.length > maxRecipients` is the ONLY thing between a notifier and an unbounded
|
|
132
|
+
// fan-out, and `NaN` makes it false for every audience — the ceiling stops existing rather than
|
|
133
|
+
// being enforced wrongly. Refused where the notifier is declared, which is where an author can
|
|
134
|
+
// act on it. Floor of 0: a notifier whose audience must be empty is loud (every non-empty run
|
|
135
|
+
// is `X_NOTIFY_FANOUT_TOO_WIDE`), so it is a declaration and not the silent failure this
|
|
136
|
+
// screen exists for.
|
|
137
|
+
maxRecipients: finiteCount(
|
|
138
|
+
`notifier ${definition.name}`,
|
|
139
|
+
'maxRecipients',
|
|
140
|
+
definition.maxRecipients ?? DEFAULT_MAX_RECIPIENTS,
|
|
141
|
+
0,
|
|
142
|
+
),
|
|
125
143
|
deliveries,
|
|
126
144
|
keyFor: (params) => definition.key(params),
|
|
127
145
|
// Bound to the definition rather than torn off it, so an author who writes `recipients` as a
|
package/src/plan.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// declaration, so the run body never re-parses a duration or re-decides a default per recipient.
|
|
4
4
|
|
|
5
5
|
import type { Ctx } from '@ultimat3/core';
|
|
6
|
+
import { finiteCount } from '@ultimat3/core';
|
|
6
7
|
import { parseDuration } from '@ultimat3/time';
|
|
7
8
|
import type { AnyNotifyChannel } from './channel';
|
|
8
9
|
import type { NotifyEvent, Recipient } from './notification';
|
|
@@ -10,8 +11,28 @@ import type { NotifyEvent, Recipient } from './notification';
|
|
|
10
11
|
/** `'5m'` | `300_000`. Numbers pass through so a caller may stay explicit, exactly as jobs does. */
|
|
11
12
|
export type NotifyDuration = string | number;
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* `parseDuration` refuses every string it cannot read, so the STRING arm is already total. The
|
|
16
|
+
* number arm was the hole: it passed straight through, and a `NaN` there makes `waitMs > slept`
|
|
17
|
+
* false for every delivery (a declared delay that silently does not happen) and `at + windowMs`
|
|
18
|
+
* `NaN` (a digest bucket whose `endsAt > at` never holds, so every event opens its own window and
|
|
19
|
+
* the coalescer coalesces nothing). `option` names which declaration was wrong, since one notifier
|
|
20
|
+
* may hold several.
|
|
21
|
+
*
|
|
22
|
+
* NOT A COPY OF `@ultimat3/time`'s `toMs`, and the difference is the whole reason both exist:
|
|
23
|
+
* `toMs` is the framework's duration vocabulary and a negative or fractional duration is REAL
|
|
24
|
+
* there — `toSeconds(-3000)` is a tested `-3` — so it screens finiteness and stops. A notifier's
|
|
25
|
+
* `wait` and digest `window` are counts of whole forward milliseconds, and the refusal has to name
|
|
26
|
+
* the declaration. `plan-bounds.test.ts` calls BOTH on the same inputs, which is what keeps the
|
|
27
|
+
* two from drifting apart again the way they did when only this one was screened.
|
|
28
|
+
*/
|
|
29
|
+
export const toDurationMs = (duration: NotifyDuration, option = 'duration'): number =>
|
|
30
|
+
finiteCount(
|
|
31
|
+
'notifier',
|
|
32
|
+
option,
|
|
33
|
+
typeof duration === 'number' ? duration : parseDuration(duration),
|
|
34
|
+
0,
|
|
35
|
+
);
|
|
15
36
|
|
|
16
37
|
/**
|
|
17
38
|
* What a notifier is enqueued with.
|
package/src/retention.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The two sweeps over this package's tables, read off the installed seam rather than off a store
|
|
2
|
+
// somebody handed the caller. `setNotifyStores` is an APP's boot line and runs after the boot that
|
|
3
|
+
// owns the hourly sweep, so the sweep cannot hold the stores — it can only ask, per attempt, what
|
|
4
|
+
// is installed now. Same shape and the same reason as `purgeAuthLimits()`.
|
|
5
|
+
|
|
6
|
+
import type { InboxPurgeBefore, PgInboxStore } from './inbox-pg';
|
|
7
|
+
import type { PgDeliveryLedger } from './ledger-pg';
|
|
8
|
+
import { notifyStores } from './stores';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whether the installed store can delete by age at all.
|
|
12
|
+
*
|
|
13
|
+
* A DECLARED capability check, never duck typing: `PgInboxStore` and `PgDeliveryLedger` widen the
|
|
14
|
+
* seam their memory siblings satisfy, so "has this method" is exactly "is this the Postgres one".
|
|
15
|
+
* The memory stores deliberately have none — a heap map is bounded by process life, and adding the
|
|
16
|
+
* method to `InboxStore`/`DeliveryLedger` would break every app that wrote its own implementation.
|
|
17
|
+
*/
|
|
18
|
+
const purgeable = <T>(store: unknown, method: string): store is T =>
|
|
19
|
+
typeof store === 'object' &&
|
|
20
|
+
store !== null &&
|
|
21
|
+
typeof (store as Record<string, unknown>)[method] === 'function';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Delete inbox rows past whichever windows the app named, and answer how many.
|
|
25
|
+
*
|
|
26
|
+
* ZERO IS THE ANSWER FOR "nothing to do", and there are three ways to reach it — no inbox
|
|
27
|
+
* installed, a memory inbox, or both windows unset. None of them is an error: an app that never
|
|
28
|
+
* configured retention has made a decision, and a sweep that threw would take the other framework
|
|
29
|
+
* tables' sweep down with it.
|
|
30
|
+
*/
|
|
31
|
+
export async function purgeNotifyInbox(before: InboxPurgeBefore): Promise<number> {
|
|
32
|
+
const store = notifyStores().inbox;
|
|
33
|
+
if (!purgeable<PgInboxStore>(store, 'purgeBefore')) return 0;
|
|
34
|
+
return store.purgeBefore(before);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Delete delivery claims past the ledger's own window, and answer how many. The window is the
|
|
39
|
+
* LEDGER's, never this caller's: `createPgDeliveryLedger({ windowMs })` is where an app states it,
|
|
40
|
+
* beside the statement that reads it, so there is one number rather than two that can disagree.
|
|
41
|
+
*
|
|
42
|
+
* `nowMs` is the job's clock for the reason `x_rate_limit`'s target states — `at` is written by
|
|
43
|
+
* whichever process took the delivery, so a cutoff computed inside Postgres measures the offset
|
|
44
|
+
* between two clocks rather than the age of the row.
|
|
45
|
+
*/
|
|
46
|
+
export async function purgeNotifyDeliveries(nowMs: number): Promise<number> {
|
|
47
|
+
const ledger = notifyStores().ledger;
|
|
48
|
+
if (!purgeable<PgDeliveryLedger>(ledger, 'purgeExpired')) return 0;
|
|
49
|
+
return ledger.purgeExpired(nowMs);
|
|
50
|
+
}
|