@ultimat3/notify 23.0.0 → 25.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,28 @@ 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. `postgresDigestStore` 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
+ **A digest append is ONE STEP PER RECIPIENT, and idempotent per appender** (`As of 2026-10-05`).
65
+ The append pass was one `step.run`, and a step that fails part-way re-runs WHOLE: recipients already
66
+ appended read `opened: false` from the window they had opened and it was never drained, and a window
67
+ they had JOINED could have been drained and deleted by its owner meanwhile, so their event went out
68
+ in a second digest. Now `digest:<channel>:<recipient>` is a step each (the step store is the
69
+ durable fence; one row per recipient, as `deliver:` already writes), and behind it
70
+ `DigestAppend.appender` (`${runId}:${recipient}`) is stored per window — `appended_by text[]`,
71
+ opener first — so an append whose answer was lost, or a run whose step history is gone, gets back
72
+ the window it landed in while that window exists. Two executions of one appender at once (a lapsed
73
+ lease) cannot both join: the `do update` refuses an appender already in the row. Pinned by
74
+ `fanout-digest.test.ts` and `digest-parity.live.test.ts`.
75
+
76
+ **The audience is deduplicated by `id` once, as it leaves the `open` step** (`As of 2026-10-02`).
77
+ Every per-recipient step is named by the id, so a resolver that named one person twice failed
78
+ `X_STEP_DUPLICATE` part-way through the fan-out, on every retry. Outside the step rather than in it,
79
+ so an `open` checkpointed with duplicates by an earlier version is repaired on replay too.
59
80
 
60
81
  ## Files
61
82
 
@@ -70,8 +91,9 @@ rather than hidden, and a durable store can close it.
70
91
  | `ledger.ts` · `ledger-pg.ts` | the delivery ledger, memory and Postgres |
71
92
  | `inbox.ts` · `inbox-pg.ts` | the in-app inbox, memory and Postgres |
72
93
  | `preferences.ts` · `digest.ts` | the gate and the window, as seams |
94
+ | `digest-pg.ts` | the digest window in Postgres: one row per window, at most one OPEN per slot |
73
95
  | `stores.ts` | the one installer for all four |
74
- | `retention.ts` | the two sweeps, read off the installed seam |
96
+ | `retention.ts` | the three sweeps, read off the installed seam |
75
97
  | `errors.ts` | this package's `X_NOTIFY_*` codes and their titles |
76
98
 
77
99
  One entry point, deliberately: every module runs on the server, so there is no browser half to split
@@ -81,7 +103,7 @@ off.
81
103
  `list`/`markRead`** (`As of 2026-09`). `markRead`'s contract is that an id belonging to nobody is
82
104
  *simply absent* — the memory store skips it — while the Postgres one bound the caller's ids into
83
105
  `any($2::uuid[])`, so one malformed id raised 22P02 out of the store and the nineteen good ids in
84
- the batch went unmarked. `createPgInboxStore` screens with `isUuid` before binding and answers `0`
106
+ the batch went unmarked. `postgresInboxStore` screens with `isUuid` before binding and answers `0`
85
107
  with no round trip when nothing survives; the statement keeps its `::uuid[]` cast and its primary
86
108
  key index, which `id::text = any($2)` would have given up. And the memory `list` now sorts
87
109
  `(createdAt desc, notifier, key)` — the total order `SQL_NOTIFY_INBOX_PAGE` takes — because
@@ -89,8 +111,8 @@ key index, which `id::text = any($2)` would have given up. And the memory `list`
89
111
  can drop one and repeat the other.
90
112
 
91
113
  **The tail is `(notifier, key)` and never `id`, `As of 2026-09-06`.** Both stores had a tail and
92
- they were two different total orders: `createPgInboxStore` mints a UUIDv7 that Postgres compares by
93
- its 16 BYTES, while `createMemoryInboxStore` derives its id from
114
+ they were two different total orders: `postgresInboxStore` mints a UUIDv7 that Postgres compares by
115
+ its 16 BYTES, while `memoryInboxStore` derives its id from
94
116
  `JSON.stringify([recipient, notifier, key])` and compares by code point — so equal-`createdAt` rows
95
117
  came back one way in dev and the other in production, which is the drop-and-repeat the tail exists
96
118
  to prevent, on whichever driver nobody tested against. `(notifier, key)` is unique within a
@@ -108,7 +130,7 @@ it the two would split on exactly the Unicode keys nobody writes a test for.
108
130
 
109
131
  ## Retention
110
132
 
111
- Both tables are swept by the boot's hourly `x.purge` job, and neither store is handed to it:
133
+ All three tables are swept by the boot's hourly `x.purge` job, and no store is handed to it:
112
134
  `setNotifyStores` is an APP's boot line that runs when the app's modules import, after the boot that
113
135
  installs the sweep. So `retention.ts` reads the seam **per attempt** — the same shape as
114
136
  `purgeAuthLimits()` — and answers `0` for a memory store or none at all, which is a boot that made a
@@ -116,16 +138,24 @@ decision rather than a failure.
116
138
 
117
139
  | Table | Window | Named where |
118
140
  |---|---|---|
119
- | `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` |
141
+ | `x_notify_deliveries` | `PostgresDeliveryLedgerOptions.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` |
142
+ | `x_notify_digests` | `PostgresDigestStoreOptions.retentionMs`, default 7 days after the window closed | beside the statement that reads it, like the ledger's |
120
143
  | `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
144
 
122
145
  `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
146
+ (`PostgresInboxStore`, `PostgresDeliveryLedger`, `PostgresDigestStore`), never on the seams: adding a method to
147
+ the seam every implementation must satisfy is a breaking change for an app that wrote its own, and a
125
148
  heap map bounded by process life has nothing to delete. Exactly the shape `PostgresIdempotencyStore`
126
149
  already has.
127
150
 
128
- `packages/cli/src/framework-schema.ts` applies both tables' DDL on every boot, **whether or not that
151
+ `x_notify_digests`: every window is deleted by its own flush's `drain`, or by the next window's
152
+ drain of the same slot (`ends_at <=`). The one neither reaches — a flush that dead-lettered, on a
153
+ slot that never digests again — is the sweep's (`purgeNotifyDigests` → `PostgresDigestStore.purgeExpired`,
154
+ `As of 2026-10-02`): windows CLOSED more than `retentionMs` before the job's clock, default
155
+ `DEFAULT_DIGEST_RETENTION_MS` (7 days, longer than any flush's retries), deleted in batches of
156
+ `DIGEST_PURGE_BATCH` and at most `DIGEST_PURGE_MAX_BATCHES` statements per pass.
157
+
158
+ `packages/cli/src/framework-schema.ts` applies every notify table's DDL on every boot, **whether or not that
129
159
  boot calls `setNotifyStores`** — this file said it did not until 2026-08-27, which is a sentence that
130
160
  outlived its fact.
131
161
 
package/README.md CHANGED
@@ -98,9 +98,9 @@ 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
- createPgDeliveryLedger,
103
- createPgInboxStore,
101
+ postgresDeliveryLedger,
102
+ postgresDigestStore,
103
+ postgresInboxStore,
104
104
  setNotifyStores,
105
105
  } from '@ultimat3/notify';
106
106
 
@@ -114,9 +114,10 @@ declare const prefs: {
114
114
  setNotifyStores({
115
115
  // `windowMs` is how long a settled claim is kept. NEVER SHORTER than your idempotency window —
116
116
  // a job replayed inside that window against a purged claim claims cleanly and sends twice.
117
- ledger: createPgDeliveryLedger({ executor, windowMs: idempotency.windowMs }),
118
- inbox: createPgInboxStore({ executor }),
119
- digest: createMemoryDigestStore(),
117
+ ledger: postgresDeliveryLedger({ executor, windowMs: idempotency.windowMs }),
118
+ inbox: postgresInboxStore({ executor }),
119
+ // `x_notify_digests`: a window survives a restart and every replica joins the same one.
120
+ digest: postgresDigestStore({ 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 }) =>
@@ -127,26 +128,28 @@ setNotifyStores({
127
128
 
128
129
  | Store | Default | Why |
129
130
  |---|---|---|
130
- | `ledger` | `createMemoryDeliveryLedger()` | one process is genuinely deduped by a heap map, and no ledger at all means a replay sends twice |
131
+ | `ledger` | `memoryDeliveryLedger()` | 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. `memoryDigestStore()` for one process; `postgresDigestStore({ 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. `postgresInboxStore` carries `purgeBefore`, `postgresDeliveryLedger`
143
+ and `postgresDigestStore` 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 (`PostgresInboxStore`,
145
+ `PostgresDeliveryLedger`, `PostgresDigestStore`), 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
- | `x_notify_deliveries` | `createPgDeliveryLedger({ windowMs })` | 24 h. A settled claim ages from its **last** attempt — `settle` moves `at` |
150
+ | `x_notify_deliveries` | `postgresDeliveryLedger({ 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` | `postgresDigestStore({ 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
@@ -165,7 +168,7 @@ A read row ages from `read_at` and an unread one from `created_at` — ageing a
165
168
  ## Channels
166
169
 
167
170
  ```ts
168
- import { bulkChannel, channel } from '@ultimat3/notify';
171
+ import { bulkChannel, deliveryChannel } from '@ultimat3/notify';
169
172
 
170
173
  // The app's push vendor and its Slack webhook — a channel is where an SDK belongs.
171
174
  declare const pushService: {
@@ -174,7 +177,7 @@ declare const pushService: {
174
177
  declare const SLACK_URL: string;
175
178
 
176
179
  // One call per recipient. The retry unit is one address.
177
- export const push = channel('push', async ({ recipient, event, signal }) => {
180
+ export const push = deliveryChannel('push', async ({ recipient, event, signal }) => {
178
181
  await pushService.send(recipient.to ?? '', event.params, { signal });
179
182
  });
180
183
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/notify",
3
- "version": "23.0.0",
3
+ "version": "25.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.4.0"
31
+ "bun": ">=1.4.2"
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": "23.0.0",
39
- "@ultimat3/jobs": "23.0.0",
40
- "@ultimat3/schema": "23.0.0",
41
- "@ultimat3/time": "23.0.0"
38
+ "@ultimat3/core": "25.0.0",
39
+ "@ultimat3/jobs": "25.0.0",
40
+ "@ultimat3/schema": "25.0.0",
41
+ "@ultimat3/time": "25.0.0"
42
42
  }
43
43
  }
@@ -3,7 +3,7 @@
3
3
  // declared where every other delivery is declared, rather than a table that fills itself.
4
4
 
5
5
  import type { NotifyChannel } from './channel';
6
- import { channel } from './channel';
6
+ import { deliveryChannel } from './channel';
7
7
  import { requireInbox } from './stores';
8
8
 
9
9
  export interface InAppChannelOptions {
@@ -17,7 +17,7 @@ export const IN_APP_CHANNEL = 'in-app';
17
17
  export function inAppChannel<Params = unknown>(
18
18
  options: InAppChannelOptions = {},
19
19
  ): NotifyChannel<Params> {
20
- return channel<Params>(options.name ?? IN_APP_CHANNEL, async ({ recipient, batch }) => {
20
+ return deliveryChannel<Params>(options.name ?? IN_APP_CHANNEL, async ({ recipient, batch }) => {
21
21
  // Every event in the batch, not just the newest: a digest window over an inbox still owes the
22
22
  // reader one row per thing that happened. `add` is idempotent on (recipient, notifier, key),
23
23
  // so a replayed attempt writes nothing new and does not move an existing row's timestamps.
@@ -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
@@ -11,7 +11,7 @@
11
11
  // }) } })
12
12
 
13
13
  import type { NotifyChannel } from './channel';
14
- import { channel } from './channel';
14
+ import { deliveryChannel } from './channel';
15
15
  import type { NotifyEvent, Recipient } from './notification';
16
16
 
17
17
  /** What a mailer is handed. Everything a template needs to render, and nothing about transport. */
@@ -55,7 +55,7 @@ export function mailChannel<Params = unknown>(
55
55
  options: MailChannelOptions<Params>,
56
56
  ): NotifyChannel<Params> {
57
57
  const addressOf = options.addressOf ?? ((recipient: Recipient) => recipient.to);
58
- return channel<Params>(
58
+ return deliveryChannel<Params>(
59
59
  options.name ?? MAIL_CHANNEL,
60
60
  async ({ recipient, batch, ctx, signal }) => {
61
61
  const to = addressOf(recipient);
package/src/channel.ts CHANGED
@@ -51,7 +51,7 @@ export type AnyNotifyChannel<Params = unknown> = NotifyChannel<Params> | BulkNot
51
51
  * One recipient per call. The name is durable — it is a column of the delivery ledger — so it is
52
52
  * given here rather than derived from a variable name that a bundler may rewrite.
53
53
  */
54
- export function channel<Params = unknown>(
54
+ export function deliveryChannel<Params = unknown>(
55
55
  name: string,
56
56
  deliver: (args: DeliveryArgs<Params>) => Promise<void> | void,
57
57
  ): NotifyChannel<Params> {
@@ -0,0 +1,237 @@
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. `memoryDigestStore` 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
+ alter table x_notify_digests add column if not exists appended_by text[] not null default '{}';
40
+ `;
41
+
42
+ /**
43
+ * Join the open window, or open one — unless this appender (`$8`) already landed in a window of the
44
+ * slot, which is a replayed step: then that window's answer comes back and nothing is written.
45
+ * `appended_by[1]` is the opener, so a replay by the opener still reads `opened`, and owns the
46
+ * flush it owned the first time. `(xmax = 0)` is true for the row this statement INSERTED and false
47
+ * for one it updated. An open window that has already elapsed is not joined: the `where` on the
48
+ * `do update` refuses it and no row comes back, which is the store's cue to seal it and try again.
49
+ * A row written before `appended_by` existed holds `'{}'`, matches no appender, and opens nothing.
50
+ *
51
+ * The `any` on the `do update` is for two executions of ONE appender at once (a lapsed lease): both
52
+ * pass `prior` in their own snapshots, and the loser's conflict re-reads the winner's COMMITTED row.
53
+ * It refuses to join it twice and no row comes back — the store's next pass then finds it in
54
+ * `prior`, exactly as it finds an elapsed window sealed.
55
+ */
56
+ export const SQL_NOTIFY_DIGEST_APPEND = `
57
+ with prior as (
58
+ select ends_at, coalesce(appended_by[1] = $8::text, false) as opened
59
+ from x_notify_digests
60
+ where recipient = $1 and notifier = $2 and channel = $3 and group_key = $4
61
+ and $8::text = any(appended_by)
62
+ order by seq
63
+ limit 1
64
+ ), joined as (
65
+ insert into x_notify_digests (recipient, notifier, channel, group_key, ends_at, events, appended_by)
66
+ select $1, $2, $3, $4, $6::timestamptz, $5::jsonb, array[$8::text]
67
+ where not exists (select 1 from prior)
68
+ on conflict (recipient, notifier, channel, group_key) where not sealed
69
+ do update set events = x_notify_digests.events || excluded.events,
70
+ appended_by = x_notify_digests.appended_by || excluded.appended_by
71
+ where x_notify_digests.ends_at > $7::timestamptz
72
+ and not ($8::text = any(x_notify_digests.appended_by))
73
+ returning ends_at, (xmax = 0) as opened
74
+ )
75
+ select ends_at, opened from prior
76
+ union all
77
+ select ends_at, opened from joined
78
+ `;
79
+
80
+ /** Seal the slot's open window once it has elapsed, so the next append opens a fresh one. */
81
+ export const SQL_NOTIFY_DIGEST_SEAL = `
82
+ update x_notify_digests set sealed = true
83
+ where recipient = $1 and notifier = $2 and channel = $3 and group_key = $4
84
+ and not sealed and ends_at <= $5
85
+ `;
86
+
87
+ /**
88
+ * Take every window closing at or before `$5` — its own and an older one a crashed flush left —
89
+ * or, with `$5` null, the oldest window alone. Oldest first, by close time and then by the order
90
+ * the windows were opened in, which is what the memory store's queue answers.
91
+ */
92
+ export const SQL_NOTIFY_DIGEST_DRAIN = `
93
+ with taken as (
94
+ delete from x_notify_digests
95
+ where recipient = $1 and notifier = $2 and channel = $3 and group_key = $4
96
+ and (
97
+ ($5::timestamptz is not null and ends_at <= $5)
98
+ or ($5::timestamptz is null and seq = (
99
+ select min(seq) from x_notify_digests
100
+ where recipient = $1 and notifier = $2 and channel = $3 and group_key = $4
101
+ ))
102
+ )
103
+ returning seq, ends_at, events
104
+ )
105
+ select events from taken order by ends_at, seq
106
+ `;
107
+
108
+ /**
109
+ * The retention sweep's one statement: windows that CLOSED before `$1`, oldest first, at most `$2`
110
+ * of them. A window is deleted by its own flush's drain or by the next window's drain of the slot;
111
+ * this catches the one neither reaches — a flush that dead-lettered on a slot that never digests
112
+ * again. Bounded per statement, so one pass never holds a lock across the whole table.
113
+ */
114
+ export const SQL_NOTIFY_DIGESTS_PURGE = `
115
+ delete from x_notify_digests
116
+ where seq in (
117
+ select seq from x_notify_digests where ends_at < $1 order by ends_at, seq limit $2
118
+ )
119
+ returning seq
120
+ `;
121
+
122
+ /**
123
+ * How long a CLOSED window is kept before the sweep may take it: a week. Longer than any retry
124
+ * schedule a notifier's flush runs on, so the sweep only ever takes a window whose flush is gone.
125
+ */
126
+ export const DEFAULT_DIGEST_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
127
+ /** Rows per delete statement. */
128
+ export const DIGEST_PURGE_BATCH = 1000;
129
+ /** Statements per pass: the hourly sweep goes on where this one stopped. */
130
+ export const DIGEST_PURGE_MAX_BATCHES = 50;
131
+
132
+ /** Each seal answers one elapsed window; more than this in one append is clocks that disagree. */
133
+ const MAX_SEALS = 8;
134
+
135
+ interface AppendRow {
136
+ readonly ends_at: Date | string;
137
+ readonly opened: boolean;
138
+ }
139
+
140
+ /** `at` round-trips through jsonb as an ISO string; the type promises a `Date`, so it is rebuilt. */
141
+ interface StoredEvent extends Omit<NotifyEvent<unknown>, 'at'> {
142
+ readonly at: string;
143
+ }
144
+
145
+ interface DrainRow {
146
+ readonly events: readonly StoredEvent[];
147
+ }
148
+
149
+ const slotArgs = (slot: DigestSlot): readonly unknown[] => [
150
+ slot.recipient,
151
+ slot.notifier,
152
+ slot.channel,
153
+ slot.group,
154
+ ];
155
+
156
+ const epochOf = (value: Date | string): number =>
157
+ (value instanceof Date ? value : new Date(value)).getTime();
158
+
159
+ export interface PostgresDigestStoreOptions {
160
+ readonly executor: PgExecutor;
161
+ /** How long a closed window is kept. Defaults to `DEFAULT_DIGEST_RETENTION_MS`. */
162
+ readonly retentionMs?: number | undefined;
163
+ }
164
+
165
+ /**
166
+ * The Postgres store's own wider type, exactly as `PostgresDeliveryLedger` is: `purgeExpired` is not on
167
+ * `DigestStore`, because a heap map bounded by process life has nothing to delete and a new
168
+ * method on the seam breaks every app that wrote its own store.
169
+ */
170
+ export interface PostgresDigestStore extends DigestStore {
171
+ readonly retentionMs: number;
172
+ /** Delete windows closed more than `retentionMs` before `nowMs` (the job's clock); answer how many. */
173
+ purgeExpired(nowMs: number): Promise<number>;
174
+ }
175
+
176
+ export function postgresDigestStore(options: PostgresDigestStoreOptions): PostgresDigestStore {
177
+ const { executor } = options;
178
+ const retentionMs = finiteCount(
179
+ 'postgresDigestStore',
180
+ 'retentionMs',
181
+ options.retentionMs ?? DEFAULT_DIGEST_RETENTION_MS,
182
+ 1,
183
+ );
184
+ return {
185
+ retentionMs,
186
+ async purgeExpired(nowMs) {
187
+ // The JOB's clock, as the ledger's sweep: `ends_at` was computed from the appender's clock.
188
+ const before = new Date(finiteCount('purgeExpired', 'nowMs', nowMs, 0) - retentionMs);
189
+ let removed = 0;
190
+ for (let batch = 0; batch < DIGEST_PURGE_MAX_BATCHES; batch += 1) {
191
+ const rows = await executor.query<{ seq: number }>(SQL_NOTIFY_DIGESTS_PURGE, [
192
+ before,
193
+ DIGEST_PURGE_BATCH,
194
+ ]);
195
+ removed += rows.length;
196
+ if (rows.length < DIGEST_PURGE_BATCH) break;
197
+ }
198
+ return removed;
199
+ },
200
+ async append(input) {
201
+ const slot = slotArgs(input.slot);
202
+ const now = input.now;
203
+ const events = JSON.stringify([input.event]);
204
+ const endsAt = new Date(now.getTime() + input.windowMs);
205
+ for (let seals = 0; seals <= MAX_SEALS; seals += 1) {
206
+ const [row] = await executor.query<AppendRow>(SQL_NOTIFY_DIGEST_APPEND, [
207
+ ...slot,
208
+ events,
209
+ endsAt,
210
+ now,
211
+ input.appender,
212
+ ]);
213
+ if (row !== undefined) return { opened: row.opened, endsAt: epochOf(row.ends_at) };
214
+ // No row: the open window elapsed, or this appender's own twin committed it first. The seal
215
+ // is a no-op on a window still open, and either way the next pass answers from `prior`.
216
+ await executor.query(SQL_NOTIFY_DIGEST_SEAL, [...slot, now]);
217
+ }
218
+ // Reached only when every retry met a window that ANOTHER appender had just opened and that
219
+ // had already elapsed by this process's clock — two replicas a whole window apart in time.
220
+ throw new UltimateError({
221
+ code: 'X_INVARIANT',
222
+ 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`,
223
+ fix: 'psql "$DATABASE_URL" -c "select notifier, recipient, ends_at, sealed, now() from x_notify_digests order by ends_at desc limit 10"',
224
+ meta: { notifier: input.slot.notifier, seals: MAX_SEALS },
225
+ });
226
+ },
227
+ async drain(slot, endsAt) {
228
+ const rows = await executor.query<DrainRow>(SQL_NOTIFY_DIGEST_DRAIN, [
229
+ ...slotArgs(slot),
230
+ endsAt === undefined ? null : new Date(endsAt),
231
+ ]);
232
+ return rows.flatMap((row) =>
233
+ row.events.map((event) => ({ ...event, at: new Date(event.at) })),
234
+ );
235
+ },
236
+ };
237
+ }
package/src/digest.ts CHANGED
@@ -26,6 +26,13 @@ export interface DigestAppend {
26
26
  readonly event: NotifyEvent<unknown>;
27
27
  readonly windowMs: number;
28
28
  readonly now: Date;
29
+ /**
30
+ * Who is appending: the fan-out passes its job's `runId` and the recipient, which a retry keeps.
31
+ * The append runs inside a step that re-runs WHOLE when any append in it fails, so an appender
32
+ * that already landed must get the SAME answer back and add nothing — a replay that read
33
+ * `opened: false` left a window nobody owned, and so nobody ever drained.
34
+ */
35
+ readonly appender: string;
29
36
  }
30
37
 
31
38
  export interface DigestBucket {
@@ -65,6 +72,8 @@ const slotKey = (slot: DigestSlot): string =>
65
72
  interface OpenBucket {
66
73
  endsAt: number;
67
74
  readonly events: NotifyEvent<unknown>[];
75
+ /** Every appender this window took, opener first — what makes a replayed append a no-op. */
76
+ readonly appenders: string[];
68
77
  }
69
78
 
70
79
  export interface MemoryDigestStore extends DigestStore {
@@ -78,7 +87,7 @@ export interface MemoryDigestStore extends DigestStore {
78
87
  * event the earlier window held. A closed window is now sealed under its `endsAt`, and a later
79
88
  * append opens the next one beside it.
80
89
  */
81
- export function createMemoryDigestStore(): MemoryDigestStore {
90
+ export function memoryDigestStore(): MemoryDigestStore {
82
91
  const slots = new Map<string, OpenBucket[]>();
83
92
  return {
84
93
  get open(): number {
@@ -90,15 +99,24 @@ export function createMemoryDigestStore(): MemoryDigestStore {
90
99
  const id = slotKey(input.slot);
91
100
  const at = input.now.getTime();
92
101
  const windows = slots.get(id) ?? [];
102
+ // A replay answers the window it already landed in, sealed or not, and adds nothing.
103
+ const prior = windows.find((window) => window.appenders.includes(input.appender));
104
+ if (prior !== undefined) {
105
+ return Promise.resolve({
106
+ opened: prior.appenders[0] === input.appender,
107
+ endsAt: prior.endsAt,
108
+ });
109
+ }
93
110
  const newest = windows.at(-1);
94
111
  // An elapsed window is SEALED — its flush drains it — and never appended to: the event
95
112
  // opens the next window, which is one extra delivery rather than a lost one.
96
113
  if (newest !== undefined && newest.endsAt > at) {
97
114
  newest.events.push(input.event);
115
+ newest.appenders.push(input.appender);
98
116
  return Promise.resolve({ opened: false, endsAt: newest.endsAt });
99
117
  }
100
118
  const endsAt = at + input.windowMs;
101
- windows.push({ endsAt, events: [input.event] });
119
+ windows.push({ endsAt, events: [input.event], appenders: [input.appender] });
102
120
  slots.set(id, windows);
103
121
  return Promise.resolve({ opened: true, endsAt });
104
122
  },
package/src/errors.ts CHANGED
@@ -96,7 +96,7 @@ export class NotifyChannelDuplicateError extends UltimateError {
96
96
  super({
97
97
  code: 'X_NOTIFY_CHANNEL_DUPLICATE',
98
98
  cause: `notifier "${input.notifier}" declares the channel "${input.channel}" twice, and the delivery ledger keys on it — the second would be deduped away`,
99
- fix: `give the second channel its own name on notifier("${input.notifier}") — channel('${input.channel}-digest', …) — or merge the two deliver entries`,
99
+ fix: `give the second channel its own name on notifier("${input.notifier}") — deliveryChannel('${input.channel}-digest', …) — or merge the two deliver entries`,
100
100
  meta: { notifier: input.notifier, channel: input.channel },
101
101
  });
102
102
  }
@@ -122,7 +122,7 @@ export class NotifyFanoutTooWideError extends UltimateError {
122
122
  * A channel or a window asked for a store nothing installed.
123
123
  *
124
124
  * The delivery ledger is deliberately NOT one of these: it has a correct-for-one-process default
125
- * (`createMemoryDeliveryLedger`), the way every other driver seam in this framework does. An inbox
125
+ * (`memoryDeliveryLedger`), the way every other driver seam in this framework does. An inbox
126
126
  * and a digest window have no such default — there is nowhere to put the row — so they refuse.
127
127
  */
128
128
  export class NotifyStoreMissingError extends UltimateError {
@@ -131,8 +131,8 @@ export class NotifyStoreMissingError extends UltimateError {
131
131
  input.store === 'inbox' ? 'a channel that writes the in-app inbox' : 'a digest window';
132
132
  const install =
133
133
  input.store === 'inbox'
134
- ? 'createMemoryInboxStore() }) at boot, or createPgInboxStore({ executor }) to share it across replicas'
135
- : 'createMemoryDigestStore() }) at boot';
134
+ ? 'memoryInboxStore() }) at boot, or postgresInboxStore({ executor }) to share it across replicas'
135
+ : 'memoryDigestStore() }) at boot';
136
136
  super({
137
137
  code: 'X_NOTIFY_STORE_MISSING',
138
138
  cause: `notifier "${input.notifier}" delivers through ${what} and no ${input.store} store is installed, so the rows have nowhere to go`,
@@ -152,7 +152,7 @@ export class NotifyDigestUnsupportedError extends UltimateError {
152
152
  super({
153
153
  code: 'X_NOTIFY_DIGEST_UNSUPPORTED',
154
154
  cause: `notifier "${input.notifier}" declares a digest window on the bulk channel "${input.channel}", and a window coalesces per recipient where a bulk send has none`,
155
- fix: `drop digest from the "${input.channel}" entry on notifier("${input.notifier}") and give it a wait instead, or deliver it through channel('${input.channel}', …) one recipient at a time`,
155
+ fix: `drop digest from the "${input.channel}" entry on notifier("${input.notifier}") and give it a wait instead, or deliver it through deliveryChannel('${input.channel}', …) one recipient at a time`,
156
156
  meta: { notifier: input.notifier, channel: input.channel },
157
157
  });
158
158
  }
@@ -21,7 +21,7 @@ export interface DigestFlush<Params> {
21
21
 
22
22
  export async function flushDigest<Params>(input: DigestFlush<Params>): Promise<void> {
23
23
  const { walk, channel, delivery, allowed } = input;
24
- const { plan, event, ctx, step, tally } = walk;
24
+ const { plan, event, ctx, runId, step, tally } = walk;
25
25
  const windowMs = delivery.digestMs ?? 0;
26
26
  const digest = requireDigest(plan.name);
27
27
  const group = delivery.group?.(event) ?? plan.name;
@@ -32,26 +32,33 @@ export async function flushDigest<Params>(input: DigestFlush<Params>): Promise<v
32
32
  group,
33
33
  });
34
34
 
35
- // One step for the whole append pass: appending is what a replayed attempt must NOT redo, or the
36
- // same event lands in the digest twice.
37
- const opened = await step.run(`digest:${channel.name}`, async () => {
38
- // Each owner's OWN window end: two recipients' windows need not close together, and the
39
- // drain takes the window it names.
40
- const owned: { readonly id: string; readonly endsAt: number }[] = [];
41
- let endsAt = 0;
42
- for (const recipient of allowed) {
43
- const bucket = await digest.append({
35
+ // ONE STEP PER APPEND, and the step store is the fence: a completed append is never redone, so
36
+ // the same event never lands in a digest twice. One step for the whole pass re-ran every append
37
+ // when any one failed, and the window a re-run append had joined may already have been drained
38
+ // and deleted by its owner — nothing in the store remembered it, so the event went out again in
39
+ // a second digest. The appender id is the store's own defence behind the step: an append whose
40
+ // write committed but whose answer was lost, or a run whose step history is gone, gets back the
41
+ // window it already landed in — `opened` included — for as long as that window exists.
42
+ // Each owner's OWN window end: two recipients' windows need not close together, and the drain
43
+ // takes the window it names.
44
+ const opened: { owned: { readonly id: string; readonly endsAt: number }[]; endsAt: number } = {
45
+ owned: [],
46
+ endsAt: 0,
47
+ };
48
+ for (const recipient of allowed) {
49
+ const bucket = await step.run(`digest:${channel.name}:${recipient.id}`, () =>
50
+ digest.append({
44
51
  slot: slotFor(recipient.id),
45
52
  event,
46
53
  windowMs,
47
54
  now: ctx.now(),
48
- });
49
- if (!bucket.opened) continue;
50
- owned.push({ id: recipient.id, endsAt: bucket.endsAt });
51
- endsAt = Math.max(endsAt, bucket.endsAt);
52
- }
53
- return { owned, endsAt };
54
- });
55
+ appender: `${runId}:${recipient.id}`,
56
+ }),
57
+ );
58
+ if (!bucket.opened) continue;
59
+ opened.owned.push({ id: recipient.id, endsAt: bucket.endsAt });
60
+ opened.endsAt = Math.max(opened.endsAt, bucket.endsAt);
61
+ }
55
62
 
56
63
  tally.digested += allowed.length - opened.owned.length;
57
64
  if (opened.owned.length === 0) return;
@@ -21,6 +21,8 @@ export interface Walk<Params> {
21
21
  readonly event: NotifyEvent<Params>;
22
22
  readonly audience: readonly Recipient[];
23
23
  readonly ctx: Ctx;
24
+ /** The job run's id, which every retry of it keeps — what names this run's digest appends. */
25
+ readonly runId: string;
24
26
  readonly step: StepApi;
25
27
  readonly tally: Tally;
26
28
  }
package/src/fanout.ts CHANGED
@@ -22,7 +22,7 @@ export async function runFanout<Params>(
22
22
  plan: NotifyPlan<Params>,
23
23
  args: JobRunArgs<NotifyPayload<Params>>,
24
24
  ): Promise<NotifyReport> {
25
- const { input, step, ctx } = args;
25
+ const { input, step, ctx, runId } = args;
26
26
  // One step for BOTH facts, because both must survive a replay: a re-resolved audience would
27
27
  // deliver to whoever subscribed during the wait, and a re-read clock would write a different
28
28
  // `at` into the inbox on every attempt. Epoch ms rather than a `Date` — a step's return value
@@ -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,
@@ -47,7 +49,7 @@ export async function runFanout<Params>(
47
49
  at: new Date(open.at),
48
50
  };
49
51
  const tally: Tally = { delivered: 0, suppressed: 0, skipped: 0, replayed: 0, digested: 0 };
50
- const walk: Walk<Params> = { plan, event, audience, ctx, step, tally };
52
+ const walk: Walk<Params> = { plan, event, audience, ctx, runId, step, tally };
51
53
 
52
54
  // `deliveries` is sorted by `waitMs`, so this sleeps the DELTA and never the sum: an in-app
53
55
  // channel with no wait fires now even when the email beside it waits an hour.
@@ -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 { finiteCount, isUuid, uuid } from '@ultimat3/core';
5
- import type { PgExecutor } from '@ultimat3/jobs';
4
+ import type { PgExecutor } from '@ultimat3/core';
5
+ import { finiteCount, isUuid, uuidV7 } from '@ultimat3/core';
6
6
  import type { InboxRow, InboxStore, InboxWrite } from './inbox';
7
7
  import { DEFAULT_INBOX_PAGE } from './inbox';
8
8
 
@@ -59,7 +59,7 @@ where recipient = $2 and notifier = $3 and key = $4
59
59
  * `unique (recipient, notifier, key)`, so the order is total and a bounded page cannot drop or
60
60
  * repeat a row when two notifications land in the same millisecond.
61
61
  *
62
- * `collate "C"` and not the column's collation: `createMemoryInboxStore` compares the same two
62
+ * `collate "C"` and not the column's collation: `memoryInboxStore` compares the same two
63
63
  * columns by CODE POINT, and a database initialised under an ICU or a `en_US.UTF-8` collation
64
64
  * orders text by locale rules — case-insensitively, ignoring punctuation at the first level — so
65
65
  * the two stores would disagree on exactly the Unicode keys nobody writes a test for. `id` was the
@@ -130,7 +130,7 @@ const toRow = (row: InboxDbRow): InboxRow => ({
130
130
  readAt: row.read_at === null ? null : asDate(row.read_at),
131
131
  });
132
132
 
133
- export interface PgInboxStoreOptions {
133
+ export interface PostgresInboxStoreOptions {
134
134
  readonly executor: PgExecutor;
135
135
  /**
136
136
  * Ids for new rows. A `random = Math.random` default parameter is this repo's injectable seam
@@ -153,16 +153,16 @@ export interface InboxPurgeBefore {
153
153
 
154
154
  /**
155
155
  * The Postgres inbox's own wider type. `purgeBefore` is HERE and not on `InboxStore` for the
156
- * reason `PgDeliveryLedger.purgeExpired` is not on `DeliveryLedger`: adding a method to the seam
156
+ * reason `PostgresDeliveryLedger.purgeExpired` is not on `DeliveryLedger`: adding a method to the seam
157
157
  * every implementation must satisfy is a breaking change for an app that wrote its own.
158
158
  */
159
- export interface PgInboxStore extends InboxStore {
159
+ export interface PostgresInboxStore extends InboxStore {
160
160
  purgeBefore(before: InboxPurgeBefore): Promise<number>;
161
161
  }
162
162
 
163
- export function createPgInboxStore(options: PgInboxStoreOptions): PgInboxStore {
163
+ export function postgresInboxStore(options: PostgresInboxStoreOptions): PostgresInboxStore {
164
164
  const { executor } = options;
165
- const newId = options.newId ?? uuid;
165
+ const newId = options.newId ?? uuidV7;
166
166
  return {
167
167
  async add(write: InboxWrite) {
168
168
  const rows = await executor.query<InboxDbRow>(SQL_NOTIFY_INBOX_ADD, [
@@ -184,7 +184,7 @@ export function createPgInboxStore(options: PgInboxStoreOptions): PgInboxStore {
184
184
  // what a driver does with a `NaN` parameter is the driver's business — the memory store beside
185
185
  // it answered `[]` for the same input. `??` cannot see it, because `NaN` is not nullish.
186
186
  const limit = finiteCount(
187
- 'createPgInboxStore',
187
+ 'postgresInboxStore',
188
188
  'limit',
189
189
  query.limit ?? DEFAULT_INBOX_PAGE,
190
190
  0,
package/src/inbox.ts CHANGED
@@ -69,8 +69,8 @@ const compareText = (a: string, b: string): number => {
69
69
  * The tail key both stores order on: `(notifier, key)`, which is unique within a recipient because
70
70
  * `add` is idempotent on `(recipient, notifier, key)` and the Postgres table declares that UNIQUE.
71
71
  *
72
- * `id` cannot be it, and that is the whole reason this exists. `createPgInboxStore` mints a UUIDv7
73
- * and Postgres orders `uuid` by its 16 BYTES; `createMemoryInboxStore` derives its id from
72
+ * `id` cannot be it, and that is the whole reason this exists. `postgresInboxStore` mints a UUIDv7
73
+ * and Postgres orders `uuid` by its 16 BYTES; `memoryInboxStore` derives its id from
74
74
  * `JSON.stringify([recipient, notifier, key])` and orders it by code point. Two total orders that
75
75
  * agree on nothing — so two notifications written in one millisecond came back in one order in dev
76
76
  * and the other in production, and a bounded page dropped one and repeated the other on exactly the
@@ -88,7 +88,7 @@ const idOf = (write: { recipient: string; notifier: string; key: string }): stri
88
88
  * memory ledger — a ledger that forgets a row starts sending duplicates, where an inbox that
89
89
  * forgets one just loses a message nobody was going to read after the restart anyway.
90
90
  */
91
- export function createMemoryInboxStore(): MemoryInboxStore {
91
+ export function memoryInboxStore(): MemoryInboxStore {
92
92
  const rows = new Map<string, InboxRow>();
93
93
 
94
94
  // `(createdAt desc, notifier, key)` — the same TOTAL order `SQL_NOTIFY_INBOX_PAGE` takes, and
@@ -122,18 +122,13 @@ export function createMemoryInboxStore(): MemoryInboxStore {
122
122
  return Promise.resolve(row);
123
123
  },
124
124
  // `async` and not `Promise.resolve`, so a refused `limit` REJECTS here exactly as it does in
125
- // `createPgInboxStore`: two drivers behind one interface must not answer one question two ways,
125
+ // `postgresInboxStore`: two drivers behind one interface must not answer one question two ways,
126
126
  // and a sync throw against a rejected promise is a difference a caller can see.
127
127
  async list(query) {
128
128
  // `slice(0, NaN)` is `[]` — an empty inbox reported as the whole of it — and
129
129
  // `slice(0, Infinity)` is every row the recipient ever received, which is the unbounded read
130
130
  // `limit`'s own doc forbids. `??` reaches neither: `NaN` is not nullish.
131
- const limit = finiteCount(
132
- 'createMemoryInboxStore',
133
- 'limit',
134
- query.limit ?? DEFAULT_INBOX_PAGE,
135
- 0,
136
- );
131
+ const limit = finiteCount('memoryInboxStore', 'limit', query.limit ?? DEFAULT_INBOX_PAGE, 0);
137
132
  return own(query.recipient)
138
133
  .filter((row) => query.unreadOnly !== true || row.readAt === null)
139
134
  .slice(0, limit);
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@ export type {
16
16
  DeliveryArgs,
17
17
  NotifyChannel,
18
18
  } from './channel';
19
- export { bulkChannel, channel, isBulkChannel } from './channel';
19
+ export { bulkChannel, deliveryChannel, isBulkChannel } from './channel';
20
20
  export type { InAppChannelOptions } from './channel-in-app';
21
21
  export { IN_APP_CHANNEL, inAppChannel } from './channel-in-app';
22
22
  export type { MailChannelOptions, Mailer, NotifyMail } from './channel-mail';
@@ -28,7 +28,13 @@ export type {
28
28
  DigestStore,
29
29
  MemoryDigestStore,
30
30
  } from './digest';
31
- export { createMemoryDigestStore } from './digest';
31
+ export { memoryDigestStore } from './digest';
32
+ export type { PostgresDigestStore, PostgresDigestStoreOptions } from './digest-pg';
33
+ export {
34
+ DEFAULT_DIGEST_RETENTION_MS,
35
+ postgresDigestStore,
36
+ SQL_NOTIFY_DIGESTS_TABLE,
37
+ } from './digest-pg';
32
38
  export type { NotifyErrorCode } from './errors';
33
39
  export {
34
40
  NOTIFY_ERROR_CODES,
@@ -43,12 +49,10 @@ export {
43
49
  // @ultimat3/jobs: a second way to execute a fan-out would bypass the job the factory built, and
44
50
  // with it the retry policy, the cancellation and the manifest row.
45
51
  export type { InboxQuery, InboxRow, InboxStore, InboxWrite, MemoryInboxStore } from './inbox';
46
- export { createMemoryInboxStore, DEFAULT_INBOX_PAGE } from './inbox';
47
- export type { InboxPurgeBefore, PgInboxStore, PgInboxStoreOptions } from './inbox-pg';
52
+ export { DEFAULT_INBOX_PAGE, memoryInboxStore } from './inbox';
53
+ export type { InboxPurgeBefore, PostgresInboxStore, PostgresInboxStoreOptions } from './inbox-pg';
48
54
  export {
49
- createPgInboxStore,
50
- SQL_NOTIFY_INBOX_MARK_READ,
51
- SQL_NOTIFY_INBOX_PAGE,
55
+ postgresInboxStore,
52
56
  SQL_NOTIFY_INBOX_TABLE,
53
57
  } from './inbox-pg';
54
58
  export type {
@@ -57,17 +61,16 @@ export type {
57
61
  DeliveryRecord,
58
62
  DeliveryStatus,
59
63
  MemoryDeliveryLedger,
60
- MemoryLedgerOptions,
64
+ MemoryDeliveryLedgerOptions,
61
65
  } from './ledger';
62
66
  export {
63
- createMemoryDeliveryLedger,
64
67
  DELIVERY_STATUSES,
65
68
  isDeliveryStatus,
69
+ memoryDeliveryLedger,
66
70
  } from './ledger';
67
- export type { PgDeliveryLedger, PgDeliveryLedgerOptions } from './ledger-pg';
71
+ export type { PostgresDeliveryLedger, PostgresDeliveryLedgerOptions } from './ledger-pg';
68
72
  export {
69
- createPgDeliveryLedger,
70
- SQL_NOTIFY_CLAIM,
73
+ postgresDeliveryLedger,
71
74
  SQL_NOTIFY_DELIVERIES_TABLE,
72
75
  } from './ledger-pg';
73
76
  export type { NotifyEvent, Recipient } from './notification';
@@ -87,8 +90,8 @@ export type {
87
90
  } from './plan';
88
91
  export { toDurationMs } from './plan';
89
92
  export type { MemoryPreferenceStore, PreferenceQuery, PreferenceStore } from './preferences';
90
- export { allowAllPreferences, createMemoryPreferenceStore } from './preferences';
91
- export { purgeNotifyDeliveries, purgeNotifyInbox } from './retention';
93
+ export { allowAllPreferences, memoryPreferenceStore } from './preferences';
94
+ export { purgeNotifyDeliveries, purgeNotifyDigests, purgeNotifyInbox } from './retention';
92
95
  export type { InstalledNotifyStores, NotifyStores } from './stores';
93
96
  export {
94
97
  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
 
@@ -102,14 +102,14 @@ delete from x_notify_deliveries where at < $1
102
102
  */
103
103
  const SQL_NOTIFY_DELIVERIES_PURGE_COUNTED = `${SQL_NOTIFY_DELIVERIES_PURGE} returning key`;
104
104
 
105
- export interface PgDeliveryLedgerOptions {
105
+ export interface PostgresDeliveryLedgerOptions {
106
106
  readonly executor: PgExecutor;
107
107
  /**
108
108
  * How long a settled claim is kept. Defaults to `DEFAULT_DELIVERY_WINDOW_MS`.
109
109
  *
110
110
  * NEVER SHORTER THAN YOUR IDEMPOTENCY WINDOW, and this is the one dangerous direction: a job
111
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({
112
+ * purged, claims cleanly and sends the notification a second time. `postgresDeliveryLedger({
113
113
  * executor, windowMs: idempotency.windowMs })` makes that impossible by construction rather
114
114
  * than by two defaults that happen to agree.
115
115
  */
@@ -125,15 +125,17 @@ export const DEFAULT_DELIVERY_WINDOW_MS = 24 * 60 * 60 * 1000;
125
125
  * seam every implementation must satisfy is a breaking change for an app that wrote its own.
126
126
  * Exactly the shape `PostgresIdempotencyStore` already has.
127
127
  */
128
- export interface PgDeliveryLedger extends DeliveryLedger {
128
+ export interface PostgresDeliveryLedger extends DeliveryLedger {
129
129
  readonly windowMs: number;
130
130
  purgeExpired(nowMs: number): Promise<number>;
131
131
  }
132
132
 
133
- export function createPgDeliveryLedger(options: PgDeliveryLedgerOptions): PgDeliveryLedger {
133
+ export function postgresDeliveryLedger(
134
+ options: PostgresDeliveryLedgerOptions,
135
+ ): PostgresDeliveryLedger {
134
136
  const { executor } = options;
135
137
  const windowMs = finiteCount(
136
- 'createPgDeliveryLedger',
138
+ 'postgresDeliveryLedger',
137
139
  'windowMs',
138
140
  options.windowMs ?? DEFAULT_DELIVERY_WINDOW_MS,
139
141
  1,
package/src/ledger.ts CHANGED
@@ -62,7 +62,7 @@ export interface DeliveryLedger {
62
62
  const keyOf = (claim: DeliveryClaim): string =>
63
63
  JSON.stringify([claim.notifier, claim.key, claim.recipient, claim.channel]);
64
64
 
65
- export interface MemoryLedgerOptions {
65
+ export interface MemoryDeliveryLedgerOptions {
66
66
  /**
67
67
  * Rows kept before the oldest is evicted. A process-local ledger is a DEV ledger — it forgets
68
68
  * on restart and it is private to one replica — so it is bounded rather than allowed to grow
@@ -82,11 +82,11 @@ export interface MemoryDeliveryLedger extends DeliveryLedger {
82
82
 
83
83
  /**
84
84
  * The default, and honest about what it is: one process, no durability. Installed by a test and by
85
- * `x dev`; a deployment with more than one replica needs `createPgDeliveryLedger`, because a claim
85
+ * `x dev`; a deployment with more than one replica needs `postgresDeliveryLedger`, because a claim
86
86
  * this replica took is invisible to the one that replays the job.
87
87
  */
88
- export function createMemoryDeliveryLedger(
89
- options: MemoryLedgerOptions = {},
88
+ export function memoryDeliveryLedger(
89
+ options: MemoryDeliveryLedgerOptions = {},
90
90
  ): MemoryDeliveryLedger {
91
91
  // `while (rows.size > max)` is the eviction, so a `max` that is not a number is not a large cap
92
92
  // — it is no cap, and this ledger grows into the heap of a process that was told it was bounded.
@@ -94,7 +94,7 @@ export function createMemoryDeliveryLedger(
94
94
  // here intact. A floor of 1 because a ledger that keeps zero rows cannot refuse a replay, which
95
95
  // is its one job, and it fails at it silently.
96
96
  const max = finiteCount(
97
- 'createMemoryDeliveryLedger',
97
+ 'memoryDeliveryLedger',
98
98
  'max',
99
99
  options.max ?? DEFAULT_MAX_DELIVERY_RECORDS,
100
100
  1,
@@ -52,7 +52,7 @@ const optOutKey = (recipient: string, notifier: string, channel: string): string
52
52
  * A test double and a dev store, not a product. Real preferences live in the app's own table
53
53
  * beside the taxonomy that names them; this exists so the gate can be exercised without one.
54
54
  */
55
- export function createMemoryPreferenceStore(): MemoryPreferenceStore {
55
+ export function memoryPreferenceStore(): MemoryPreferenceStore {
56
56
  const denied = new Set<string>();
57
57
  return {
58
58
  allows(query) {
package/src/retention.ts CHANGED
@@ -1,16 +1,17 @@
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 { InboxPurgeBefore, PgInboxStore } from './inbox-pg';
7
- import type { PgDeliveryLedger } from './ledger-pg';
6
+ import type { PostgresDigestStore } from './digest-pg';
7
+ import type { InboxPurgeBefore, PostgresInboxStore } from './inbox-pg';
8
+ import type { PostgresDeliveryLedger } from './ledger-pg';
8
9
  import { notifyStores } from './stores';
9
10
 
10
11
  /**
11
12
  * Whether the installed store can delete by age at all.
12
13
  *
13
- * A DECLARED capability check, never duck typing: `PgInboxStore` and `PgDeliveryLedger` widen the
14
+ * A DECLARED capability check, never duck typing: `PostgresInboxStore` and `PostgresDeliveryLedger` widen the
14
15
  * seam their memory siblings satisfy, so "has this method" is exactly "is this the Postgres one".
15
16
  * The memory stores deliberately have none — a heap map is bounded by process life, and adding the
16
17
  * method to `InboxStore`/`DeliveryLedger` would break every app that wrote its own implementation.
@@ -30,13 +31,13 @@ const purgeable = <T>(store: unknown, method: string): store is T =>
30
31
  */
31
32
  export async function purgeNotifyInbox(before: InboxPurgeBefore): Promise<number> {
32
33
  const store = notifyStores().inbox;
33
- if (!purgeable<PgInboxStore>(store, 'purgeBefore')) return 0;
34
+ if (!purgeable<PostgresInboxStore>(store, 'purgeBefore')) return 0;
34
35
  return store.purgeBefore(before);
35
36
  }
36
37
 
37
38
  /**
38
39
  * 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
+ * LEDGER's, never this caller's: `postgresDeliveryLedger({ windowMs })` is where an app states it,
40
41
  * beside the statement that reads it, so there is one number rather than two that can disagree.
41
42
  *
42
43
  * `nowMs` is the job's clock for the reason `x_rate_limit`'s target states — `at` is written by
@@ -45,6 +46,17 @@ export async function purgeNotifyInbox(before: InboxPurgeBefore): Promise<number
45
46
  */
46
47
  export async function purgeNotifyDeliveries(nowMs: number): Promise<number> {
47
48
  const ledger = notifyStores().ledger;
48
- if (!purgeable<PgDeliveryLedger>(ledger, 'purgeExpired')) return 0;
49
+ if (!purgeable<PostgresDeliveryLedger>(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<PostgresDigestStore>(digest, 'purgeExpired')) return 0;
61
+ return digest.purgeExpired(nowMs);
62
+ }
package/src/stores.ts CHANGED
@@ -9,13 +9,13 @@ import type { DigestStore } from './digest';
9
9
  import { NotifyStoreMissingError } from './errors';
10
10
  import type { InboxStore } from './inbox';
11
11
  import type { DeliveryLedger } from './ledger';
12
- import { createMemoryDeliveryLedger } from './ledger';
12
+ import { memoryDeliveryLedger } from './ledger';
13
13
  import type { PreferenceStore } from './preferences';
14
14
  import { allowAllPreferences } from './preferences';
15
15
 
16
16
  export interface NotifyStores {
17
17
  /**
18
- * Defaults to `createMemoryDeliveryLedger()`. A default is correct here and nowhere else in this
18
+ * Defaults to `memoryDeliveryLedger()`. A default is correct here and nowhere else in this
19
19
  * interface: one process with one replica is genuinely deduped by a heap map, and the failure
20
20
  * mode of having none at all — a replayed attempt sending twice — is worse than the failure mode
21
21
  * of a dev default, which is a second send only after a restart.
@@ -39,7 +39,7 @@ export interface InstalledNotifyStores {
39
39
  }
40
40
 
41
41
  const defaults = (): InstalledNotifyStores => ({
42
- ledger: createMemoryDeliveryLedger(),
42
+ ledger: memoryDeliveryLedger(),
43
43
  inbox: undefined,
44
44
  preferences: allowAllPreferences(),
45
45
  digest: undefined,
@@ -51,7 +51,7 @@ let installed: InstalledNotifyStores = defaults();
51
51
  * `RuntimeOverrides` exists to refuse, and a merge would hide the second call's omissions. */
52
52
  export function setNotifyStores(stores: NotifyStores): void {
53
53
  installed = {
54
- ledger: stores.ledger ?? createMemoryDeliveryLedger(),
54
+ ledger: stores.ledger ?? memoryDeliveryLedger(),
55
55
  inbox: stores.inbox,
56
56
  preferences: stores.preferences ?? allowAllPreferences(),
57
57
  digest: stores.digest,