@ultimat3/notify 19.2.0 → 19.3.1
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 +22 -0
- package/package.json +5 -5
- package/src/inbox-pg.ts +25 -5
- package/src/inbox.ts +26 -1
package/CLAUDE.md
CHANGED
|
@@ -77,6 +77,28 @@ rather than hidden, and a durable store can close it.
|
|
|
77
77
|
One entry point, deliberately: every module runs on the server, so there is no browser half to split
|
|
78
78
|
off.
|
|
79
79
|
|
|
80
|
+
**The two inbox stores answer one question one way, and the two places they did not were both in
|
|
81
|
+
`list`/`markRead`** (`As of 2026-09`). `markRead`'s contract is that an id belonging to nobody is
|
|
82
|
+
*simply absent* — the memory store skips it — while the Postgres one bound the caller's ids into
|
|
83
|
+
`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`
|
|
85
|
+
with no round trip when nothing survives; the statement keeps its `::uuid[]` cast and its primary
|
|
86
|
+
key index, which `id::text = any($2)` would have given up. And the memory `list` now sorts
|
|
87
|
+
`(createdAt desc, notifier, key)` — the total order `SQL_NOTIFY_INBOX_PAGE` takes — because
|
|
88
|
+
`createdAt` alone is partial and a bounded page over two notifications written in one millisecond
|
|
89
|
+
can drop one and repeat the other.
|
|
90
|
+
|
|
91
|
+
**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
|
|
94
|
+
`JSON.stringify([recipient, notifier, key])` and compares by code point — so equal-`createdAt` rows
|
|
95
|
+
came back one way in dev and the other in production, which is the drop-and-repeat the tail exists
|
|
96
|
+
to prevent, on whichever driver nobody tested against. `(notifier, key)` is unique within a
|
|
97
|
+
recipient by the table's own `unique (recipient, notifier, key)`, which is also what makes `add`
|
|
98
|
+
idempotent. The statement spells `collate "C"` on both columns: the memory store compares by code
|
|
99
|
+
point and a database initialised under ICU or `en_US.UTF-8` orders text by locale rules, so without
|
|
100
|
+
it the two would split on exactly the Unicode keys nobody writes a test for.
|
|
101
|
+
|
|
80
102
|
| Rule | Detail |
|
|
81
103
|
|---|---|
|
|
82
104
|
| Exports | `src/index.ts`, explicit, no `export *` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/notify",
|
|
3
|
-
"version": "19.
|
|
3
|
+
"version": "19.3.1",
|
|
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": "19.
|
|
39
|
-
"@ultimat3/jobs": "19.
|
|
40
|
-
"@ultimat3/schema": "19.
|
|
41
|
-
"@ultimat3/time": "19.
|
|
38
|
+
"@ultimat3/core": "19.3.1",
|
|
39
|
+
"@ultimat3/jobs": "19.3.1",
|
|
40
|
+
"@ultimat3/schema": "19.3.1",
|
|
41
|
+
"@ultimat3/time": "19.3.1"
|
|
42
42
|
}
|
|
43
43
|
}
|
package/src/inbox-pg.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The shared in-app inbox: one Postgres table, applied by the boot the way `x_jobs` is.
|
|
2
2
|
// Statements are spelled out so an agent can run the exact one it saw in a log.
|
|
3
3
|
|
|
4
|
-
import { finiteCount, uuid } from '@ultimat3/core';
|
|
4
|
+
import { finiteCount, isUuid, uuid } from '@ultimat3/core';
|
|
5
5
|
import type { PgExecutor } from '@ultimat3/jobs';
|
|
6
6
|
import type { InboxRow, InboxStore, InboxWrite } from './inbox';
|
|
7
7
|
import { DEFAULT_INBOX_PAGE } from './inbox';
|
|
@@ -54,12 +54,23 @@ where recipient = $2 and notifier = $3 and key = $4
|
|
|
54
54
|
and not exists (select 1 from inserted)
|
|
55
55
|
`;
|
|
56
56
|
|
|
57
|
-
/**
|
|
58
|
-
*
|
|
57
|
+
/**
|
|
58
|
+
* Newest first, `(created_at desc, notifier, key)` — unique within a recipient by the table's own
|
|
59
|
+
* `unique (recipient, notifier, key)`, so the order is total and a bounded page cannot drop or
|
|
60
|
+
* repeat a row when two notifications land in the same millisecond.
|
|
61
|
+
*
|
|
62
|
+
* `collate "C"` and not the column's collation: `createMemoryInboxStore` compares the same two
|
|
63
|
+
* columns by CODE POINT, and a database initialised under an ICU or a `en_US.UTF-8` collation
|
|
64
|
+
* orders text by locale rules — case-insensitively, ignoring punctuation at the first level — so
|
|
65
|
+
* the two stores would disagree on exactly the Unicode keys nobody writes a test for. `id` was the
|
|
66
|
+
* tail until 2026-09-06 and could not be: a UUIDv7 ordered by its 16 bytes here and a
|
|
67
|
+
* `JSON.stringify([recipient, notifier, key])` ordered by code point there is two total orders that
|
|
68
|
+
* agree on nothing.
|
|
69
|
+
*/
|
|
59
70
|
export const SQL_NOTIFY_INBOX_PAGE = `
|
|
60
71
|
select ${COLUMNS} from x_notify_inbox
|
|
61
72
|
where recipient = $1 and ($2::boolean is not true or read_at is null)
|
|
62
|
-
order by created_at desc,
|
|
73
|
+
order by created_at desc, notifier collate "C", key collate "C"
|
|
63
74
|
limit $3
|
|
64
75
|
`;
|
|
65
76
|
|
|
@@ -190,9 +201,18 @@ export function createPgInboxStore(options: PgInboxStoreOptions): PgInboxStore {
|
|
|
190
201
|
return rows[0]?.unread ?? 0;
|
|
191
202
|
},
|
|
192
203
|
async markRead(input) {
|
|
204
|
+
// The ids are NAMED by the caller and bound into `any($2::uuid[])`, so one that is not a
|
|
205
|
+
// uuid is Postgres 22P02 out of a method whose contract says an id belonging to nobody is
|
|
206
|
+
// simply absent — the memory store skips it. Raising would lose the whole batch: nineteen
|
|
207
|
+
// good ids marked by nobody because a twentieth was a typo. Screened, never cast: a
|
|
208
|
+
// `id::text = any($2)` would answer the same and give up the primary key index.
|
|
209
|
+
const ids = [...input.ids].filter(isUuid);
|
|
210
|
+
// No round trip for a batch that can match nothing — `any('{}')` costs a statement to
|
|
211
|
+
// answer what this line already knows.
|
|
212
|
+
if (ids.length === 0) return 0;
|
|
193
213
|
const rows = await executor.query<{ id: string }>(SQL_NOTIFY_INBOX_MARK_READ, [
|
|
194
214
|
input.recipient,
|
|
195
|
-
|
|
215
|
+
ids,
|
|
196
216
|
input.at,
|
|
197
217
|
]);
|
|
198
218
|
return rows.length;
|
package/src/inbox.ts
CHANGED
|
@@ -59,6 +59,27 @@ export interface MemoryInboxStore extends InboxStore {
|
|
|
59
59
|
clear(): void;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/** Codepoint order, never `localeCompare`: Postgres compares the tail key by bytes, not by locale. */
|
|
63
|
+
const compareText = (a: string, b: string): number => {
|
|
64
|
+
if (a === b) return 0;
|
|
65
|
+
return a < b ? -1 : 1;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The tail key both stores order on: `(notifier, key)`, which is unique within a recipient because
|
|
70
|
+
* `add` is idempotent on `(recipient, notifier, key)` and the Postgres table declares that UNIQUE.
|
|
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
|
|
74
|
+
* `JSON.stringify([recipient, notifier, key])` and orders it by code point. Two total orders that
|
|
75
|
+
* agree on nothing — so two notifications written in one millisecond came back in one order in dev
|
|
76
|
+
* and the other in production, and a bounded page dropped one and repeated the other on exactly the
|
|
77
|
+
* driver nobody was testing against. Two drivers behind one interface must not answer one question
|
|
78
|
+
* two ways.
|
|
79
|
+
*/
|
|
80
|
+
const compareTail = (a: InboxRow, b: InboxRow): number =>
|
|
81
|
+
compareText(a.notifier, b.notifier) || compareText(a.key, b.key);
|
|
82
|
+
|
|
62
83
|
const idOf = (write: { recipient: string; notifier: string; key: string }): string =>
|
|
63
84
|
JSON.stringify([write.recipient, write.notifier, write.key]);
|
|
64
85
|
|
|
@@ -70,10 +91,14 @@ const idOf = (write: { recipient: string; notifier: string; key: string }): stri
|
|
|
70
91
|
export function createMemoryInboxStore(): MemoryInboxStore {
|
|
71
92
|
const rows = new Map<string, InboxRow>();
|
|
72
93
|
|
|
94
|
+
// `(createdAt desc, notifier, key)` — the same TOTAL order `SQL_NOTIFY_INBOX_PAGE` takes, and
|
|
95
|
+
// for the same reason: `createdAt` alone is partial, so two notifications written in one
|
|
96
|
+
// millisecond can swap places between two reads and a bounded page then drops one and repeats
|
|
97
|
+
// the other.
|
|
73
98
|
const own = (recipient: string): InboxRow[] =>
|
|
74
99
|
[...rows.values()]
|
|
75
100
|
.filter((row) => row.recipient === recipient)
|
|
76
|
-
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
101
|
+
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || compareTail(a, b));
|
|
77
102
|
|
|
78
103
|
return {
|
|
79
104
|
get size(): number {
|