@ultimat3/notify 25.0.0 → 25.2.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/README.md CHANGED
@@ -104,7 +104,7 @@ import {
104
104
  setNotifyStores,
105
105
  } from '@ultimat3/notify';
106
106
 
107
- declare const executor: PgExecutor; // `@ultimat3/cli`'s pgExecutorFor(client)
107
+ declare const executor: PgExecutor; // `@ultimat3/db`'s dbExecutor()
108
108
  declare const idempotency: { readonly windowMs: number }; // the boot's own store
109
109
  // The app's preference table, behind whatever taxonomy it named.
110
110
  declare const prefs: {
@@ -133,8 +133,8 @@ setNotifyStores({
133
133
  | `inbox` | **none** — `X_NOTIFY_STORE_MISSING` | a message written to nowhere is worse than a refusal |
134
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 |
135
135
 
136
- `executor` is a structural `{ query(sql, params) }` — `@ultimat3/cli`'s `pgExecutorFor(client)` over
137
- a `DbClient` is the framework's own. `Bun.sql` does **not** satisfy it.
136
+ `executor` is a structural `{ query(sql, params) }` — `@ultimat3/db`'s `dbExecutor()` is the
137
+ framework's own. `Bun.sql` does **not** satisfy it.
138
138
 
139
139
  ## Retention
140
140
 
@@ -225,7 +225,9 @@ delay sends nothing.
225
225
  A job body runs before its checkpoint lands. Two layers stop a double send: the step checkpoint
226
226
  (`deliver:<channel>:<recipient>`), and the delivery ledger's atomic claim on
227
227
  `(notifier, key, channel, coalesce(recipient, ''))` — taken before the send, settled after. A claim
228
- that already reads `sent` answers `false`.
228
+ that already reads `sent` answers `false`. `memoryDeliveryLedger` keys and settles exactly as the
229
+ Postgres ledger does — a null and an empty recipient are one row, and a `settle` with no claim
230
+ records nothing (`ledger-parity.live.test.ts` runs one script against both).
229
231
 
230
232
  A bulk channel claims **one** row for the whole audience, with a null recipient: half a bulk POST is
231
233
  not a state this package can represent. That null is why the key coalesces rather than naming the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/notify",
3
- "version": "25.0.0",
3
+ "version": "25.2.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": "25.0.0",
39
- "@ultimat3/jobs": "25.0.0",
40
- "@ultimat3/schema": "25.0.0",
41
- "@ultimat3/time": "25.0.0"
38
+ "@ultimat3/core": "25.2.0",
39
+ "@ultimat3/jobs": "25.2.0",
40
+ "@ultimat3/schema": "25.2.0",
41
+ "@ultimat3/time": "25.2.0"
42
42
  }
43
43
  }
package/src/ledger.ts CHANGED
@@ -58,9 +58,10 @@ export interface DeliveryLedger {
58
58
  * The tuple as one string. `JSON.stringify` of an ARRAY rather than a joined separator: a
59
59
  * recipient id or a channel name containing the separator would otherwise collide two distinct
60
60
  * deliveries into one ledger row, and the failure mode is a notification nobody ever receives.
61
+ * `recipient ?? ''` is the Postgres ledger's `coalesce(recipient, '')`: one identity, both stores.
61
62
  */
62
63
  const keyOf = (claim: DeliveryClaim): string =>
63
- JSON.stringify([claim.notifier, claim.key, claim.recipient, claim.channel]);
64
+ JSON.stringify([claim.notifier, claim.key, claim.recipient ?? '', claim.channel]);
64
65
 
65
66
  export interface MemoryDeliveryLedgerOptions {
66
67
  /**
@@ -122,8 +123,9 @@ export function memoryDeliveryLedger(
122
123
  const id = keyOf(claim);
123
124
  const existing = rows.get(id);
124
125
  if (existing?.status === 'sent') return Promise.resolve(false);
126
+ // The row it found keeps its own identity, as the pg `on conflict do update` does.
125
127
  rows.set(id, {
126
- ...claim,
128
+ ...(existing ?? claim),
127
129
  status: 'sending',
128
130
  attempts: (existing?.attempts ?? 0) + 1,
129
131
  at,
@@ -134,7 +136,9 @@ export function memoryDeliveryLedger(
134
136
  settle(claim, status, at) {
135
137
  const id = keyOf(claim);
136
138
  const existing = rows.get(id);
137
- rows.set(id, { ...claim, status, attempts: existing?.attempts ?? 1, at });
139
+ // No claim, no row: the pg `update` matches nothing, and a row written here would refuse
140
+ // the claim after it — a notification dev never sends and production does.
141
+ if (existing !== undefined) rows.set(id, { ...existing, status, at });
138
142
  return Promise.resolve();
139
143
  },
140
144
  find(claim) {