@ultimat3/notify 15.0.0 → 17.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 +1 -0
- package/package.json +5 -5
- package/src/inbox-pg.ts +11 -2
- package/src/inbox.ts +17 -4
- package/src/ledger.ts +13 -1
- package/src/notifier.ts +21 -3
- package/src/plan.ts +23 -2
package/CLAUDE.md
CHANGED
|
@@ -81,6 +81,7 @@ off.
|
|
|
81
81
|
| Exports | `src/index.ts`, explicit, no `export *` |
|
|
82
82
|
| Errors | `src/errors.ts`, subclass `UltimateError`, never a bare `Error` |
|
|
83
83
|
| Files | one responsibility each, < 200 lines, tests beside the source |
|
|
84
|
+
| 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 |
|
|
84
85
|
|
|
85
86
|
## Homeless work this package cannot do
|
|
86
87
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/notify",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "17.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",
|
|
@@ -35,9 +35,9 @@
|
|
|
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": "17.0.0",
|
|
39
|
+
"@ultimat3/jobs": "17.0.0",
|
|
40
|
+
"@ultimat3/schema": "17.0.0",
|
|
41
|
+
"@ultimat3/time": "17.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';
|
|
@@ -132,10 +132,19 @@ export function createPgInboxStore(options: PgInboxStoreOptions): InboxStore {
|
|
|
132
132
|
return row === undefined ? { id: newId(), ...write, seenAt: null, readAt: null } : toRow(row);
|
|
133
133
|
},
|
|
134
134
|
async list(query) {
|
|
135
|
+
// Screened here and not left to Postgres: this number is bound straight into `limit $3`, and
|
|
136
|
+
// what a driver does with a `NaN` parameter is the driver's business — the memory store beside
|
|
137
|
+
// it answered `[]` for the same input. `??` cannot see it, because `NaN` is not nullish.
|
|
138
|
+
const limit = finiteCount(
|
|
139
|
+
'createPgInboxStore',
|
|
140
|
+
'limit',
|
|
141
|
+
query.limit ?? DEFAULT_INBOX_PAGE,
|
|
142
|
+
0,
|
|
143
|
+
);
|
|
135
144
|
const rows = await executor.query<InboxDbRow>(SQL_NOTIFY_INBOX_PAGE, [
|
|
136
145
|
query.recipient,
|
|
137
146
|
query.unreadOnly === true,
|
|
138
|
-
|
|
147
|
+
limit,
|
|
139
148
|
]);
|
|
140
149
|
return rows.map(toRow);
|
|
141
150
|
},
|
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/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.
|