@ultimat3/notify 23.0.0 → 24.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
@@ -11,9 +11,9 @@ returns a `job`, which is the whole design.
11
11
 
12
12
  | Never | Because |
13
13
  |---|---|
14
- | `@ultimat3/mail` | Same tier (4). A `Mailer` is declared **structurally** in `channel-mail.ts` — one method, no dependency — exactly as `@ultimat3/action`'s `PgExecutor` mirrors `@ultimat3/db`. Moving `notify` to tier 5 to legalise the import would put notifications above `render`, `pwa` and `ui` for one channel's transport, and would then need a `cli → notify` edge the way `cli → scraping` does. |
14
+ | `@ultimat3/mail` | Same tier (4). A `Mailer` is declared **structurally** in `channel-mail.ts` — one method, no dependency — exactly as `PgExecutor` (`@ultimat3/core`) mirrors `@ultimat3/db`. Moving `notify` to tier 5 to legalise the import would put notifications above `render`, `pwa` and `ui` for one channel's transport, and would then need a `cli → notify` edge the way `cli → scraping` does. |
15
15
  | `@ultimat3/render`, `@ultimat3/ui`, `@ultimat3/ai`, `@ultimat3/mcp`, `@ultimat3/pwa` | Same tier. A notification has no view — the inbox is rendered by the app's page out of `InboxStore.list`, which is data. |
16
- | `@ultimat3/db`, `@ultimat3/entity` | Legal downward, and deliberately not taken. The two tables this package owns are **DDL constants applied by the boot** (`SQL_NOTIFY_DELIVERIES_TABLE`, `SQL_NOTIFY_INBOX_TABLE`), the way `x_jobs`, `x_idempotency` and `x_audit` are — never `entity()` declarations, which would put framework tables in the app's migration graph and make an app's `x db gen` responsible for them. The Postgres stores take a structural `PgExecutor`, imported as a **type** from `@ultimat3/jobs` rather than re-declared, because a third copy of a one-method interface is a third place to look. |
16
+ | `@ultimat3/db`, `@ultimat3/entity` | Legal downward, and deliberately not taken. The three tables this package owns are **DDL constants applied by the boot** (`SQL_NOTIFY_DELIVERIES_TABLE`, `SQL_NOTIFY_INBOX_TABLE`, `SQL_NOTIFY_DIGESTS_TABLE`), the way `x_jobs`, `x_idempotency` and `x_audit` are — never `entity()` declarations, which would put framework tables in the app's migration graph and make an app's `x db gen` responsible for them. The Postgres stores take a structural `PgExecutor`, imported as a **type** from `@ultimat3/core` (its one declaration; `bun run flight-copies` refuses another, `X_HELPER_COPY`). |
17
17
  | `@ultimat3/policy` | A notification is addressed to exactly one person and the audience is `recipients`. There is no row a policy could decide about here. The **inbox read surface** is where authz belongs, and that is the app's query. |
18
18
 
19
19
  ## What ships, and what must never
@@ -55,7 +55,16 @@ A job body runs **before** its checkpoint lands, so both layers are load-bearing
55
55
  `attempt.ts` is the only place a send happens, so there is exactly one implementation of that order.
56
56
  The **one at-most-once seam** is a digest flush: `DigestStore.drain` empties the window, and a
57
57
  process killed between the drain and its checkpoint loses that batch. It is stated in `digest.ts`
58
- rather than hidden, and a durable store can close it.
58
+ rather than hidden. `createPgDigestStore` makes the window itself durable and shared — the
59
+ partial unique index `x_notify_digests_open_idx` lets two replicas' first appends collide so ONE
60
+ opens the window and owns the flush; an elapsed open window is sealed and the append retried — but
61
+ its `drain` is still a delete, so the seam is unchanged (`digest-parity.live.test.ts` runs one
62
+ suite against both stores).
63
+
64
+ **The audience is deduplicated by `id` once, as it leaves the `open` step** (`As of 2026-10-02`).
65
+ Every per-recipient step is named by the id, so a resolver that named one person twice failed
66
+ `X_STEP_DUPLICATE` part-way through the fan-out, on every retry. Outside the step rather than in it,
67
+ so an `open` checkpointed with duplicates by an earlier version is repaired on replay too.
59
68
 
60
69
  ## Files
61
70
 
@@ -70,8 +79,9 @@ rather than hidden, and a durable store can close it.
70
79
  | `ledger.ts` · `ledger-pg.ts` | the delivery ledger, memory and Postgres |
71
80
  | `inbox.ts` · `inbox-pg.ts` | the in-app inbox, memory and Postgres |
72
81
  | `preferences.ts` · `digest.ts` | the gate and the window, as seams |
82
+ | `digest-pg.ts` | the digest window in Postgres: one row per window, at most one OPEN per slot |
73
83
  | `stores.ts` | the one installer for all four |
74
- | `retention.ts` | the two sweeps, read off the installed seam |
84
+ | `retention.ts` | the three sweeps, read off the installed seam |
75
85
  | `errors.ts` | this package's `X_NOTIFY_*` codes and their titles |
76
86
 
77
87
  One entry point, deliberately: every module runs on the server, so there is no browser half to split
@@ -108,7 +118,7 @@ it the two would split on exactly the Unicode keys nobody writes a test for.
108
118
 
109
119
  ## Retention
110
120
 
111
- Both tables are swept by the boot's hourly `x.purge` job, and neither store is handed to it:
121
+ All three tables are swept by the boot's hourly `x.purge` job, and no store is handed to it:
112
122
  `setNotifyStores` is an APP's boot line that runs when the app's modules import, after the boot that
113
123
  installs the sweep. So `retention.ts` reads the seam **per attempt** — the same shape as
114
124
  `purgeAuthLimits()` — and answers `0` for a memory store or none at all, which is a boot that made a
@@ -117,15 +127,23 @@ decision rather than a failure.
117
127
  | Table | Window | Named where |
118
128
  |---|---|---|
119
129
  | `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` |
130
+ | `x_notify_digests` | `PgDigestStoreOptions.retentionMs`, default 7 days after the window closed | beside the statement that reads it, like the ledger's |
120
131
  | `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) |
121
132
 
122
133
  `purgeBefore` and `purgeExpired` live on the **Postgres stores' own wider types**
123
- (`PgInboxStore`, `PgDeliveryLedger`), never on `InboxStore`/`DeliveryLedger`: adding a method to the
124
- seam every implementation must satisfy is a breaking change for an app that wrote its own, and a
134
+ (`PgInboxStore`, `PgDeliveryLedger`, `PgDigestStore`), never on the seams: adding a method to
135
+ the seam every implementation must satisfy is a breaking change for an app that wrote its own, and a
125
136
  heap map bounded by process life has nothing to delete. Exactly the shape `PostgresIdempotencyStore`
126
137
  already has.
127
138
 
128
- `packages/cli/src/framework-schema.ts` applies both tables' DDL on every boot, **whether or not that
139
+ `x_notify_digests`: every window is deleted by its own flush's `drain`, or by the next window's
140
+ drain of the same slot (`ends_at <=`). The one neither reaches — a flush that dead-lettered, on a
141
+ slot that never digests again — is the sweep's (`purgeNotifyDigests` → `PgDigestStore.purgeExpired`,
142
+ `As of 2026-10-02`): windows CLOSED more than `retentionMs` before the job's clock, default
143
+ `DEFAULT_DIGEST_RETENTION_MS` (7 days, longer than any flush's retries), deleted in batches of
144
+ `DIGEST_PURGE_BATCH` and at most `DIGEST_PURGE_MAX_BATCHES` statements per pass.
145
+
146
+ `packages/cli/src/framework-schema.ts` applies every notify table's DDL on every boot, **whether or not that
129
147
  boot calls `setNotifyStores`** — this file said it did not until 2026-08-27, which is a sentence that
130
148
  outlived its fact.
131
149
 
package/README.md CHANGED
@@ -98,8 +98,8 @@ One call, at boot. Whole-object replacement, never a merge.
98
98
  ```ts
99
99
  import type { PgExecutor } from '@ultimat3/jobs';
100
100
  import {
101
- createMemoryDigestStore,
102
101
  createPgDeliveryLedger,
102
+ createPgDigestStore,
103
103
  createPgInboxStore,
104
104
  setNotifyStores,
105
105
  } from '@ultimat3/notify';
@@ -116,7 +116,8 @@ setNotifyStores({
116
116
  // a job replayed inside that window against a purged claim claims cleanly and sends twice.
117
117
  ledger: createPgDeliveryLedger({ executor, windowMs: idempotency.windowMs }),
118
118
  inbox: createPgInboxStore({ executor }),
119
- digest: createMemoryDigestStore(),
119
+ // `x_notify_digests`: a window survives a restart and every replica joins the same one.
120
+ digest: createPgDigestStore({ executor }),
120
121
  preferences: {
121
122
  // The GATE ships; what it reads never does. Your taxonomy, your quiet hours.
122
123
  allows: ({ recipient, notifier: name, channel, ctx }) =>
@@ -130,23 +131,25 @@ setNotifyStores({
130
131
  | `ledger` | `createMemoryDeliveryLedger()` | one process is genuinely deduped by a heap map, and no ledger at all means a replay sends twice |
131
132
  | `preferences` | `allowAllPreferences()` | denying by default is a notifier that silently delivers nothing |
132
133
  | `inbox` | **none** — `X_NOTIFY_STORE_MISSING` | a message written to nowhere is worse than a refusal |
133
- | `digest` | **none** — `X_NOTIFY_STORE_MISSING` | same |
134
+ | `digest` | **none** — `X_NOTIFY_STORE_MISSING` | same. `createMemoryDigestStore()` for one process; `createPgDigestStore({ executor })` keeps a window across a restart and gives two replicas ONE window (a partial unique index on the open one), so exactly one run owns its flush |
134
135
 
135
136
  `executor` is a structural `{ query(sql, params) }` — `@ultimat3/cli`'s `pgExecutorFor(client)` over
136
137
  a `DbClient` is the framework's own. `Bun.sql` does **not** satisfy it.
137
138
 
138
139
  ## Retention
139
140
 
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.
141
+ All three tables grow with traffic, and the boot's hourly `x.purge` job sweeps them — but only
142
+ against the **Postgres** stores. `createPgInboxStore` carries `purgeBefore`, `createPgDeliveryLedger`
143
+ and `createPgDigestStore` carry `purgeExpired`; the memory ones do not, and a boot that installed a
144
+ memory store sweeps nothing. The methods are on those stores' own wider types (`PgInboxStore`,
145
+ `PgDeliveryLedger`, `PgDigestStore`), not on the seams, so an app that wrote its own implementation
146
+ is unaffected.
145
147
 
146
148
  | Table | Window | Default |
147
149
  |---|---|---|
148
150
  | `x_notify_deliveries` | `createPgDeliveryLedger({ windowMs })` | 24 h. A settled claim ages from its **last** attempt — `settle` moves `at` |
149
151
  | `x_notify_inbox` | `notify.inboxReadRetentionMs` / `notify.inboxUnreadRetentionMs` in `app.config.ts` | **neither** — never swept |
152
+ | `x_notify_digests` | `createPgDigestStore({ retentionMs })` | 7 days after a window CLOSED. A drained window is already gone; this takes one whose flush dead-lettered. Batched: 1,000 rows per statement, at most 50 per pass |
150
153
 
151
154
  The inbox default is deliberate and is not a missing number. An inbox row is a message a person has
152
155
  not read yet, so when it disappears is your decision, not the framework's — which is why the key
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/notify",
3
- "version": "23.0.0",
3
+ "version": "24.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": "23.0.0",
39
- "@ultimat3/jobs": "23.0.0",
40
- "@ultimat3/schema": "23.0.0",
41
- "@ultimat3/time": "23.0.0"
38
+ "@ultimat3/core": "24.0.0",
39
+ "@ultimat3/jobs": "24.0.0",
40
+ "@ultimat3/schema": "24.0.0",
41
+ "@ultimat3/time": "24.0.0"
42
42
  }
43
43
  }
@@ -1,6 +1,6 @@
1
1
  // Email as a channel, over a STRUCTURAL mailer rather than an import of @ultimat3/mail.
2
2
  //
3
- // That is the tier argument, and it is the same one `@ultimat3/action`'s `PgExecutor` makes about
3
+ // That is the tier argument, and it is the same one `PgExecutor` (`@ultimat3/core`) makes about
4
4
  // `@ultimat3/db`. `@ultimat3/mail` is tier 4 and so is this package, so importing it would be a
5
5
  // sideways edge — and moving `notify` to tier 5 to legalise it would put notifications above
6
6
  // `render`, `pwa` and `ui` for no reason other than one channel's transport. A mailer is a driver
@@ -0,0 +1,209 @@
1
+ // The Postgres digest store: one row per window, so a `digested` event outlives the process that
2
+ // appended it and every replica coalesces into the same window. `createMemoryDigestStore` is the
3
+ // same queue of windows in one heap; this is what a deployment with more than one process installs.
4
+
5
+ import type { PgExecutor } from '@ultimat3/core';
6
+ import { finiteCount, UltimateError } from '@ultimat3/core';
7
+ import type { DigestSlot, DigestStore } from './digest';
8
+ import type { NotifyEvent } from './notification';
9
+
10
+ /**
11
+ * Applied by the boot beside `SQL_NOTIFY_DELIVERIES_TABLE` and `SQL_NOTIFY_INBOX_TABLE`, never by
12
+ * an app migration — a new column is `alter table … add column if not exists`, never an edit here.
13
+ *
14
+ * The PARTIAL unique index is the whole concurrency story: at most one OPEN window per slot, so two
15
+ * replicas appending the first event of a window at once collide on it and exactly one of them
16
+ * opens the window and owns its flush. A window is SEALED once it has elapsed and an append wanted
17
+ * a new one; sealed windows wait for their flush and are not indexed, so a slot can hold several.
18
+ */
19
+ export const SQL_NOTIFY_DIGESTS_TABLE = `
20
+ create table if not exists x_notify_digests (
21
+ seq bigint generated always as identity primary key,
22
+ recipient text not null,
23
+ notifier text not null,
24
+ channel text not null,
25
+ group_key text not null,
26
+ ends_at timestamptz not null,
27
+ sealed boolean not null default false,
28
+ events jsonb not null
29
+ );
30
+
31
+ create unique index if not exists x_notify_digests_open_idx
32
+ on x_notify_digests (recipient, notifier, channel, group_key) where not sealed;
33
+
34
+ create index if not exists x_notify_digests_slot_idx
35
+ on x_notify_digests (recipient, notifier, channel, group_key, ends_at);
36
+
37
+ create index if not exists x_notify_digests_ends_idx on x_notify_digests (ends_at);
38
+ `;
39
+
40
+ /**
41
+ * Join the open window, or open one. `(xmax = 0)` is true for the row this statement INSERTED and
42
+ * false for one it updated, which is `DigestBucket.opened`. An open window that has already
43
+ * elapsed is not joined: the `where` on the `do update` refuses it and no row comes back, which is
44
+ * the store's cue to seal it and try again.
45
+ */
46
+ export const SQL_NOTIFY_DIGEST_APPEND = `
47
+ insert into x_notify_digests (recipient, notifier, channel, group_key, ends_at, events)
48
+ values ($1, $2, $3, $4, $6, $5::jsonb)
49
+ on conflict (recipient, notifier, channel, group_key) where not sealed
50
+ do update set events = x_notify_digests.events || excluded.events
51
+ where x_notify_digests.ends_at > $7
52
+ returning ends_at, (xmax = 0) as opened
53
+ `;
54
+
55
+ /** Seal the slot's open window once it has elapsed, so the next append opens a fresh one. */
56
+ export const SQL_NOTIFY_DIGEST_SEAL = `
57
+ update x_notify_digests set sealed = true
58
+ where recipient = $1 and notifier = $2 and channel = $3 and group_key = $4
59
+ and not sealed and ends_at <= $5
60
+ `;
61
+
62
+ /**
63
+ * Take every window closing at or before `$5` — its own and an older one a crashed flush left —
64
+ * or, with `$5` null, the oldest window alone. Oldest first, by close time and then by the order
65
+ * the windows were opened in, which is what the memory store's queue answers.
66
+ */
67
+ export const SQL_NOTIFY_DIGEST_DRAIN = `
68
+ with taken as (
69
+ delete from x_notify_digests
70
+ where recipient = $1 and notifier = $2 and channel = $3 and group_key = $4
71
+ and (
72
+ ($5::timestamptz is not null and ends_at <= $5)
73
+ or ($5::timestamptz is null and seq = (
74
+ select min(seq) from x_notify_digests
75
+ where recipient = $1 and notifier = $2 and channel = $3 and group_key = $4
76
+ ))
77
+ )
78
+ returning seq, ends_at, events
79
+ )
80
+ select events from taken order by ends_at, seq
81
+ `;
82
+
83
+ /**
84
+ * The retention sweep's one statement: windows that CLOSED before `$1`, oldest first, at most `$2`
85
+ * of them. A window is deleted by its own flush's drain or by the next window's drain of the slot;
86
+ * this catches the one neither reaches — a flush that dead-lettered on a slot that never digests
87
+ * again. Bounded per statement, so one pass never holds a lock across the whole table.
88
+ */
89
+ export const SQL_NOTIFY_DIGESTS_PURGE = `
90
+ delete from x_notify_digests
91
+ where seq in (
92
+ select seq from x_notify_digests where ends_at < $1 order by ends_at, seq limit $2
93
+ )
94
+ returning seq
95
+ `;
96
+
97
+ /**
98
+ * How long a CLOSED window is kept before the sweep may take it: a week. Longer than any retry
99
+ * schedule a notifier's flush runs on, so the sweep only ever takes a window whose flush is gone.
100
+ */
101
+ export const DEFAULT_DIGEST_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
102
+ /** Rows per delete statement. */
103
+ export const DIGEST_PURGE_BATCH = 1000;
104
+ /** Statements per pass: the hourly sweep goes on where this one stopped. */
105
+ export const DIGEST_PURGE_MAX_BATCHES = 50;
106
+
107
+ /** Each seal answers one elapsed window; more than this in one append is clocks that disagree. */
108
+ const MAX_SEALS = 8;
109
+
110
+ interface AppendRow {
111
+ readonly ends_at: Date | string;
112
+ readonly opened: boolean;
113
+ }
114
+
115
+ /** `at` round-trips through jsonb as an ISO string; the type promises a `Date`, so it is rebuilt. */
116
+ interface StoredEvent extends Omit<NotifyEvent<unknown>, 'at'> {
117
+ readonly at: string;
118
+ }
119
+
120
+ interface DrainRow {
121
+ readonly events: readonly StoredEvent[];
122
+ }
123
+
124
+ const slotArgs = (slot: DigestSlot): readonly unknown[] => [
125
+ slot.recipient,
126
+ slot.notifier,
127
+ slot.channel,
128
+ slot.group,
129
+ ];
130
+
131
+ const epochOf = (value: Date | string): number =>
132
+ (value instanceof Date ? value : new Date(value)).getTime();
133
+
134
+ export interface PgDigestStoreOptions {
135
+ readonly executor: PgExecutor;
136
+ /** How long a closed window is kept. Defaults to `DEFAULT_DIGEST_RETENTION_MS`. */
137
+ readonly retentionMs?: number | undefined;
138
+ }
139
+
140
+ /**
141
+ * The Postgres store's own wider type, exactly as `PgDeliveryLedger` is: `purgeExpired` is not on
142
+ * `DigestStore`, because a heap map bounded by process life has nothing to delete and a new
143
+ * method on the seam breaks every app that wrote its own store.
144
+ */
145
+ export interface PgDigestStore extends DigestStore {
146
+ readonly retentionMs: number;
147
+ /** Delete windows closed more than `retentionMs` before `nowMs` (the job's clock); answer how many. */
148
+ purgeExpired(nowMs: number): Promise<number>;
149
+ }
150
+
151
+ export function createPgDigestStore(options: PgDigestStoreOptions): PgDigestStore {
152
+ const { executor } = options;
153
+ const retentionMs = finiteCount(
154
+ 'createPgDigestStore',
155
+ 'retentionMs',
156
+ options.retentionMs ?? DEFAULT_DIGEST_RETENTION_MS,
157
+ 1,
158
+ );
159
+ return {
160
+ retentionMs,
161
+ async purgeExpired(nowMs) {
162
+ // The JOB's clock, as the ledger's sweep: `ends_at` was computed from the appender's clock.
163
+ const before = new Date(finiteCount('purgeExpired', 'nowMs', nowMs, 0) - retentionMs);
164
+ let removed = 0;
165
+ for (let batch = 0; batch < DIGEST_PURGE_MAX_BATCHES; batch += 1) {
166
+ const rows = await executor.query<{ seq: number }>(SQL_NOTIFY_DIGESTS_PURGE, [
167
+ before,
168
+ DIGEST_PURGE_BATCH,
169
+ ]);
170
+ removed += rows.length;
171
+ if (rows.length < DIGEST_PURGE_BATCH) break;
172
+ }
173
+ return removed;
174
+ },
175
+ async append(input) {
176
+ const slot = slotArgs(input.slot);
177
+ const now = input.now;
178
+ const events = JSON.stringify([input.event]);
179
+ const endsAt = new Date(now.getTime() + input.windowMs);
180
+ for (let seals = 0; seals <= MAX_SEALS; seals += 1) {
181
+ const [row] = await executor.query<AppendRow>(SQL_NOTIFY_DIGEST_APPEND, [
182
+ ...slot,
183
+ events,
184
+ endsAt,
185
+ now,
186
+ ]);
187
+ if (row !== undefined) return { opened: row.opened, endsAt: epochOf(row.ends_at) };
188
+ await executor.query(SQL_NOTIFY_DIGEST_SEAL, [...slot, now]);
189
+ }
190
+ // Reached only when every retry met a window that ANOTHER appender had just opened and that
191
+ // had already elapsed by this process's clock — two replicas a whole window apart in time.
192
+ throw new UltimateError({
193
+ code: 'X_INVARIANT',
194
+ cause: `the digest window for notifier "${input.slot.notifier}" kept closing before this append could join it (${String(MAX_SEALS)} seals): its ${String(input.windowMs)} ms window is shorter than one append's round trip, or two replicas' clocks are a whole window apart`,
195
+ fix: 'psql "$DATABASE_URL" -c "select notifier, recipient, ends_at, sealed, now() from x_notify_digests order by ends_at desc limit 10"',
196
+ meta: { notifier: input.slot.notifier, seals: MAX_SEALS },
197
+ });
198
+ },
199
+ async drain(slot, endsAt) {
200
+ const rows = await executor.query<DrainRow>(SQL_NOTIFY_DIGEST_DRAIN, [
201
+ ...slotArgs(slot),
202
+ endsAt === undefined ? null : new Date(endsAt),
203
+ ]);
204
+ return rows.flatMap((row) =>
205
+ row.events.map((event) => ({ ...event, at: new Date(event.at) })),
206
+ );
207
+ },
208
+ };
209
+ }
package/src/fanout.ts CHANGED
@@ -31,7 +31,9 @@ export async function runFanout<Params>(
31
31
  at: ctx.now().getTime(),
32
32
  recipients: input.recipients ?? [...(await plan.recipientsFor({ input: input.params, ctx }))],
33
33
  }));
34
- const audience = open.recipients;
34
+ // Deduplicated on the way OUT of the step rather than inside it, so an `open` checkpointed by an
35
+ // earlier version — duplicates and all — is repaired on replay too, not only a fresh resolve.
36
+ const audience = uniqueById(open.recipients);
35
37
  if (audience.length > plan.maxRecipients) {
36
38
  throw new NotifyFanoutTooWideError({
37
39
  notifier: plan.name,
@@ -62,6 +64,21 @@ export async function runFanout<Params>(
62
64
  return { recipients: audience.length, ...tally };
63
65
  }
64
66
 
67
+ /**
68
+ * One entry per recipient id, the FIRST kept. Every per-recipient step is named by the id, so a
69
+ * repeated id was `X_STEP_DUPLICATE` on its second delivery — the fan-out failed part-way, the
70
+ * recipients after it were never notified, and every retry failed at the same place. One person
71
+ * named twice by a resolver is still one person, and gets one delivery per channel.
72
+ */
73
+ function uniqueById(recipients: readonly Recipient[]): Recipient[] {
74
+ const seen = new Set<string>();
75
+ return recipients.filter((recipient) => {
76
+ if (seen.has(recipient.id)) return false;
77
+ seen.add(recipient.id);
78
+ return true;
79
+ });
80
+ }
81
+
65
82
  async function deliverOne<Params>(
66
83
  walk: Walk<Params>,
67
84
  delivery: ResolvedDelivery<Params>,
package/src/inbox-pg.ts CHANGED
@@ -1,8 +1,8 @@
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 type { PgExecutor } from '@ultimat3/core';
4
5
  import { finiteCount, isUuid, uuid } from '@ultimat3/core';
5
- import type { PgExecutor } from '@ultimat3/jobs';
6
6
  import type { InboxRow, InboxStore, InboxWrite } from './inbox';
7
7
  import { DEFAULT_INBOX_PAGE } from './inbox';
8
8
 
package/src/index.ts CHANGED
@@ -29,6 +29,12 @@ export type {
29
29
  MemoryDigestStore,
30
30
  } from './digest';
31
31
  export { createMemoryDigestStore } from './digest';
32
+ export type { PgDigestStore, PgDigestStoreOptions } from './digest-pg';
33
+ export {
34
+ createPgDigestStore,
35
+ DEFAULT_DIGEST_RETENTION_MS,
36
+ SQL_NOTIFY_DIGESTS_TABLE,
37
+ } from './digest-pg';
32
38
  export type { NotifyErrorCode } from './errors';
33
39
  export {
34
40
  NOTIFY_ERROR_CODES,
@@ -88,7 +94,7 @@ export type {
88
94
  export { toDurationMs } from './plan';
89
95
  export type { MemoryPreferenceStore, PreferenceQuery, PreferenceStore } from './preferences';
90
96
  export { allowAllPreferences, createMemoryPreferenceStore } from './preferences';
91
- export { purgeNotifyDeliveries, purgeNotifyInbox } from './retention';
97
+ export { purgeNotifyDeliveries, purgeNotifyDigests, purgeNotifyInbox } from './retention';
92
98
  export type { InstalledNotifyStores, NotifyStores } from './stores';
93
99
  export {
94
100
  notifyStores,
package/src/ledger-pg.ts CHANGED
@@ -4,8 +4,8 @@
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 type { PgExecutor } from '@ultimat3/core';
7
8
  import { finiteCount } from '@ultimat3/core';
8
- import type { PgExecutor } from '@ultimat3/jobs';
9
9
  import type { DeliveryClaim, DeliveryLedger, DeliveryRecord, DeliveryStatus } from './ledger';
10
10
  import { isDeliveryStatus } from './ledger';
11
11
 
package/src/retention.ts CHANGED
@@ -1,8 +1,9 @@
1
- // The two sweeps over this package's tables, read off the installed seam rather than off a store
1
+ // The three sweeps over this package's tables, read off the installed seam rather than off a store
2
2
  // somebody handed the caller. `setNotifyStores` is an APP's boot line and runs after the boot that
3
3
  // owns the hourly sweep, so the sweep cannot hold the stores — it can only ask, per attempt, what
4
4
  // is installed now. Same shape and the same reason as `purgeAuthLimits()`.
5
5
 
6
+ import type { PgDigestStore } from './digest-pg';
6
7
  import type { InboxPurgeBefore, PgInboxStore } from './inbox-pg';
7
8
  import type { PgDeliveryLedger } from './ledger-pg';
8
9
  import { notifyStores } from './stores';
@@ -48,3 +49,14 @@ export async function purgeNotifyDeliveries(nowMs: number): Promise<number> {
48
49
  if (!purgeable<PgDeliveryLedger>(ledger, 'purgeExpired')) return 0;
49
50
  return ledger.purgeExpired(nowMs);
50
51
  }
52
+
53
+ /**
54
+ * Delete digest windows closed longer ago than the store's own `retentionMs`, and answer how many.
55
+ * A window is normally deleted by a drain; this takes the one whose flush dead-lettered on a slot
56
+ * that never digests again. Zero for no store or a memory one, for the reason above.
57
+ */
58
+ export async function purgeNotifyDigests(nowMs: number): Promise<number> {
59
+ const digest = notifyStores().digest;
60
+ if (!purgeable<PgDigestStore>(digest, 'purgeExpired')) return 0;
61
+ return digest.purgeExpired(nowMs);
62
+ }