@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 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": "15.0.0",
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": "15.0.0",
39
- "@ultimat3/jobs": "15.0.0",
40
- "@ultimat3/schema": "15.0.0",
41
- "@ultimat3/time": "15.0.0"
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
- query.limit ?? DEFAULT_INBOX_PAGE,
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
- list(query) {
98
- const page = own(query.recipient)
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, query.limit ?? DEFAULT_INBOX_PAGE);
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
- const max = options.max ?? DEFAULT_MAX_DELIVERY_RECORDS;
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: delivery.wait === undefined ? 0 : toDurationMs(delivery.wait),
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: delivery.digest === undefined ? undefined : toDurationMs(delivery.digest.window),
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
- maxRecipients: definition.maxRecipients ?? DEFAULT_MAX_RECIPIENTS,
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
- export const toDurationMs = (duration: NotifyDuration): number =>
14
- typeof duration === 'number' ? duration : parseDuration(duration);
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.