@ultimat3/notify 17.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
@@ -83,12 +84,28 @@ off.
83
84
  | Files | one responsibility each, < 200 lines, tests beside the source |
84
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 |
85
86
 
86
- ## Homeless work this package cannot do
87
+ ## Retention
87
88
 
88
- `packages/cli/src/dev-queue.ts`'s `applySchema` installs the jobs, idempotency, rate-limit and
89
- auth-limit tables. **It does not install `x_notify_deliveries` or `x_notify_inbox`**, so an app using
90
- the Postgres stores runs that DDL itself until they join the list — the same gap
91
- `SQL_AUDIT_TABLE` has.
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.
92
109
 
93
110
  Commands: `bun test packages/notify/src`, `bun run boundaries`,
94
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": "17.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": "17.0.0",
39
- "@ultimat3/jobs": "17.0.0",
40
- "@ultimat3/schema": "17.0.0",
41
- "@ultimat3/time": "17.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
@@ -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 {
@@ -160,6 +197,17 @@ export function createPgInboxStore(options: PgInboxStoreOptions): InboxStore {
160
197
  ]);
161
198
  return rows.length;
162
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
+ },
163
211
  async markSeen(input) {
164
212
  const rows = await executor.query<{ id: string }>(SQL_NOTIFY_INBOX_MARK_SEEN, [
165
213
  input.recipient,
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),
@@ -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
+ }