@ultimat3/notify 20.2.1 → 22.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/README.md +14 -0
- package/package.json +5 -5
- package/src/digest.ts +33 -16
- package/src/fanout-digest.ts +6 -4
- package/src/index.ts +0 -10
package/README.md
CHANGED
|
@@ -240,6 +240,20 @@ without bound and every replay would re-send the whole audience.
|
|
|
240
240
|
| `X_NOTIFY_STORE_MISSING` | an inbox or digest channel with no store installed |
|
|
241
241
|
| `X_NOTIFY_DELIVERY_FAILED` | a channel's `deliver` threw; the run retries on its policy |
|
|
242
242
|
|
|
243
|
+
### Error classes
|
|
244
|
+
|
|
245
|
+
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
246
|
+
a job boundary the class is gone and the `code` is what survives — match on that.
|
|
247
|
+
|
|
248
|
+
| Class | Code | Declared in |
|
|
249
|
+
|---|---|---|
|
|
250
|
+
| `NotifyChannelDuplicateError` | `X_NOTIFY_CHANNEL_DUPLICATE` | `src/errors.ts` |
|
|
251
|
+
| `NotifyChannelsEmptyError` | `X_NOTIFY_CHANNELS_EMPTY` | `src/errors.ts` |
|
|
252
|
+
| `NotifyDeliveryFailedError` | `X_NOTIFY_DELIVERY_FAILED` | `src/errors.ts` |
|
|
253
|
+
| `NotifyDigestUnsupportedError` | `X_NOTIFY_DIGEST_UNSUPPORTED` | `src/errors.ts` |
|
|
254
|
+
| `NotifyFanoutTooWideError` | `X_NOTIFY_FANOUT_TOO_WIDE` | `src/errors.ts` |
|
|
255
|
+
| `NotifyStoreMissingError` | `X_NOTIFY_STORE_MISSING` | `src/errors.ts` |
|
|
256
|
+
|
|
243
257
|
## Boundary
|
|
244
258
|
|
|
245
259
|
Tier 4. May import tiers 0-3 — enforced by `bun run boundaries`. Its real imports are `core`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/notify",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.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": "
|
|
39
|
-
"@ultimat3/jobs": "
|
|
40
|
-
"@ultimat3/schema": "
|
|
41
|
-
"@ultimat3/time": "
|
|
38
|
+
"@ultimat3/core": "22.0.0",
|
|
39
|
+
"@ultimat3/jobs": "22.0.0",
|
|
40
|
+
"@ultimat3/schema": "22.0.0",
|
|
41
|
+
"@ultimat3/time": "22.0.0"
|
|
42
42
|
}
|
|
43
43
|
}
|
package/src/digest.ts
CHANGED
|
@@ -50,7 +50,13 @@ export interface DigestStore {
|
|
|
50
50
|
* close it entirely by flipping a row's status here and deleting on `settle`. The memory store
|
|
51
51
|
* below cannot.
|
|
52
52
|
*/
|
|
53
|
-
|
|
53
|
+
/**
|
|
54
|
+
* `endsAt` names the window this flush owns (what `append` answered it). Every window of the
|
|
55
|
+
* slot closing at or before it is taken — its own, and an OLDER one a crashed flush left behind
|
|
56
|
+
* — while a newer window, opened after this one closed, stays for its own flush. Omitted, only
|
|
57
|
+
* the oldest window is taken.
|
|
58
|
+
*/
|
|
59
|
+
drain(slot: DigestSlot, endsAt?: number): Promise<readonly NotifyEvent<unknown>[]>;
|
|
54
60
|
}
|
|
55
61
|
|
|
56
62
|
const slotKey = (slot: DigestSlot): string =>
|
|
@@ -66,35 +72,46 @@ export interface MemoryDigestStore extends DigestStore {
|
|
|
66
72
|
clear(): void;
|
|
67
73
|
}
|
|
68
74
|
|
|
75
|
+
/**
|
|
76
|
+
* A QUEUE of windows per slot, oldest first. One bucket per slot replaced a closed window that its
|
|
77
|
+
* flush had not drained yet, so an append arriving between a window's end and its drain lost every
|
|
78
|
+
* event the earlier window held. A closed window is now sealed under its `endsAt`, and a later
|
|
79
|
+
* append opens the next one beside it.
|
|
80
|
+
*/
|
|
69
81
|
export function createMemoryDigestStore(): MemoryDigestStore {
|
|
70
|
-
const
|
|
82
|
+
const slots = new Map<string, OpenBucket[]>();
|
|
71
83
|
return {
|
|
72
84
|
get open(): number {
|
|
73
|
-
|
|
85
|
+
let count = 0;
|
|
86
|
+
for (const windows of slots.values()) count += windows.length;
|
|
87
|
+
return count;
|
|
74
88
|
},
|
|
75
89
|
append(input) {
|
|
76
90
|
const id = slotKey(input.slot);
|
|
77
91
|
const at = input.now.getTime();
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
return Promise.resolve({ opened: false, endsAt:
|
|
92
|
+
const windows = slots.get(id) ?? [];
|
|
93
|
+
const newest = windows.at(-1);
|
|
94
|
+
// An elapsed window is SEALED — its flush drains it — and never appended to: the event
|
|
95
|
+
// opens the next window, which is one extra delivery rather than a lost one.
|
|
96
|
+
if (newest !== undefined && newest.endsAt > at) {
|
|
97
|
+
newest.events.push(input.event);
|
|
98
|
+
return Promise.resolve({ opened: false, endsAt: newest.endsAt });
|
|
85
99
|
}
|
|
86
100
|
const endsAt = at + input.windowMs;
|
|
87
|
-
|
|
101
|
+
windows.push({ endsAt, events: [input.event] });
|
|
102
|
+
slots.set(id, windows);
|
|
88
103
|
return Promise.resolve({ opened: true, endsAt });
|
|
89
104
|
},
|
|
90
|
-
drain(slot) {
|
|
105
|
+
drain(slot, endsAt) {
|
|
91
106
|
const id = slotKey(slot);
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
107
|
+
const windows = slots.get(id) ?? [];
|
|
108
|
+
const cut = endsAt === undefined ? 1 : windows.filter((w) => w.endsAt <= endsAt).length;
|
|
109
|
+
const taken = windows.splice(0, Math.max(cut, 0));
|
|
110
|
+
if (windows.length === 0) slots.delete(id);
|
|
111
|
+
return Promise.resolve(taken.flatMap((window) => window.events));
|
|
95
112
|
},
|
|
96
113
|
clear() {
|
|
97
|
-
|
|
114
|
+
slots.clear();
|
|
98
115
|
},
|
|
99
116
|
};
|
|
100
117
|
}
|
package/src/fanout-digest.ts
CHANGED
|
@@ -35,7 +35,9 @@ export async function flushDigest<Params>(input: DigestFlush<Params>): Promise<v
|
|
|
35
35
|
// One step for the whole append pass: appending is what a replayed attempt must NOT redo, or the
|
|
36
36
|
// same event lands in the digest twice.
|
|
37
37
|
const opened = await step.run(`digest:${channel.name}`, async () => {
|
|
38
|
-
|
|
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 }[] = [];
|
|
39
41
|
let endsAt = 0;
|
|
40
42
|
for (const recipient of allowed) {
|
|
41
43
|
const bucket = await digest.append({
|
|
@@ -45,7 +47,7 @@ export async function flushDigest<Params>(input: DigestFlush<Params>): Promise<v
|
|
|
45
47
|
now: ctx.now(),
|
|
46
48
|
});
|
|
47
49
|
if (!bucket.opened) continue;
|
|
48
|
-
owned.push(recipient.id);
|
|
50
|
+
owned.push({ id: recipient.id, endsAt: bucket.endsAt });
|
|
49
51
|
endsAt = Math.max(endsAt, bucket.endsAt);
|
|
50
52
|
}
|
|
51
53
|
return { owned, endsAt };
|
|
@@ -58,7 +60,7 @@ export async function flushDigest<Params>(input: DigestFlush<Params>): Promise<v
|
|
|
58
60
|
if (remaining > 0) await step.sleep(`digest-wait:${channel.name}`, remaining);
|
|
59
61
|
|
|
60
62
|
const byId = new Map(allowed.map((recipient) => [recipient.id, recipient]));
|
|
61
|
-
for (const id of opened.owned) {
|
|
63
|
+
for (const { id, endsAt } of opened.owned) {
|
|
62
64
|
const recipient = byId.get(id);
|
|
63
65
|
if (recipient === undefined) continue;
|
|
64
66
|
// Drain and send are TWO steps on purpose. The drain's result is checkpointed, so an ordinary
|
|
@@ -66,7 +68,7 @@ export async function flushDigest<Params>(input: DigestFlush<Params>): Promise<v
|
|
|
66
68
|
// now empty. What it does not close is a process killed between the drain and its checkpoint;
|
|
67
69
|
// `DigestStore.drain` says so in its own words, and a durable store can do better.
|
|
68
70
|
const batch = await step.run(`digest-drain:${channel.name}:${id}`, () =>
|
|
69
|
-
digest.drain(slotFor(id)),
|
|
71
|
+
digest.drain(slotFor(id), endsAt),
|
|
70
72
|
);
|
|
71
73
|
if (batch.length === 0) continue;
|
|
72
74
|
const events = rehydrate<Params>(batch);
|
package/src/index.ts
CHANGED
|
@@ -47,13 +47,9 @@ export { createMemoryInboxStore, DEFAULT_INBOX_PAGE } from './inbox';
|
|
|
47
47
|
export type { InboxPurgeBefore, PgInboxStore, PgInboxStoreOptions } from './inbox-pg';
|
|
48
48
|
export {
|
|
49
49
|
createPgInboxStore,
|
|
50
|
-
SQL_NOTIFY_INBOX_ADD,
|
|
51
50
|
SQL_NOTIFY_INBOX_MARK_READ,
|
|
52
|
-
SQL_NOTIFY_INBOX_MARK_SEEN,
|
|
53
51
|
SQL_NOTIFY_INBOX_PAGE,
|
|
54
|
-
SQL_NOTIFY_INBOX_PURGE,
|
|
55
52
|
SQL_NOTIFY_INBOX_TABLE,
|
|
56
|
-
SQL_NOTIFY_INBOX_UNREAD,
|
|
57
53
|
} from './inbox-pg';
|
|
58
54
|
export type {
|
|
59
55
|
DeliveryClaim,
|
|
@@ -65,19 +61,14 @@ export type {
|
|
|
65
61
|
} from './ledger';
|
|
66
62
|
export {
|
|
67
63
|
createMemoryDeliveryLedger,
|
|
68
|
-
DEFAULT_MAX_DELIVERY_RECORDS,
|
|
69
64
|
DELIVERY_STATUSES,
|
|
70
65
|
isDeliveryStatus,
|
|
71
66
|
} from './ledger';
|
|
72
67
|
export type { PgDeliveryLedger, PgDeliveryLedgerOptions } from './ledger-pg';
|
|
73
68
|
export {
|
|
74
69
|
createPgDeliveryLedger,
|
|
75
|
-
DEFAULT_DELIVERY_WINDOW_MS,
|
|
76
70
|
SQL_NOTIFY_CLAIM,
|
|
77
|
-
SQL_NOTIFY_DELIVERIES_PURGE,
|
|
78
71
|
SQL_NOTIFY_DELIVERIES_TABLE,
|
|
79
|
-
SQL_NOTIFY_FIND,
|
|
80
|
-
SQL_NOTIFY_SETTLE,
|
|
81
72
|
} from './ledger-pg';
|
|
82
73
|
export type { NotifyEvent, Recipient } from './notification';
|
|
83
74
|
export { recipientSchema } from './notification';
|
|
@@ -101,7 +92,6 @@ export { purgeNotifyDeliveries, purgeNotifyInbox } from './retention';
|
|
|
101
92
|
export type { InstalledNotifyStores, NotifyStores } from './stores';
|
|
102
93
|
export {
|
|
103
94
|
notifyStores,
|
|
104
|
-
requireDigest,
|
|
105
95
|
requireInbox,
|
|
106
96
|
resetNotifyStores,
|
|
107
97
|
setNotifyStores,
|