@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 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
- ## Homeless work this package cannot do
86
-
87
- `packages/cli/src/dev-queue.ts`'s `applySchema` installs the jobs, idempotency, rate-limit and
88
- auth-limit tables. **It does not install `x_notify_deliveries` or `x_notify_inbox`**, so an app using
89
- the Postgres stores runs that DDL itself until they join the list — the same gap
90
- `SQL_AUDIT_TABLE` has.
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
- ledger: createPgDeliveryLedger({ executor }),
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": "16.0.0",
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.3.0"
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": "16.0.0",
39
- "@ultimat3/jobs": "16.0.0",
40
- "@ultimat3/schema": "16.0.0",
41
- "@ultimat3/time": "16.0.0"
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
- export function createPgInboxStore(options: PgInboxStoreOptions): InboxStore {
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
- query.limit ?? DEFAULT_INBOX_PAGE,
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
- 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/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
- export function createPgDeliveryLedger(options: PgDeliveryLedgerOptions): DeliveryLedger {
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
- 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.
@@ -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
+ }