@ultimat3/notify 19.1.3 → 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 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.1.3",
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.1.3",
39
- "@ultimat3/jobs": "19.1.3",
40
- "@ultimat3/schema": "19.1.3",
41
- "@ultimat3/time": "19.1.3"
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
- /** Newest first, `(created_at desc, id)` — the tail key is unique, so the order is total and a
58
- * bounded page cannot drop or repeat a row when two notifications land in the same millisecond. */
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, id
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
- [...input.ids],
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 {