@ultimat3/notify 21.0.0 → 22.1.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
@@ -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": "21.0.0",
3
+ "version": "22.1.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": "21.0.0",
39
- "@ultimat3/jobs": "21.0.0",
40
- "@ultimat3/schema": "21.0.0",
41
- "@ultimat3/time": "21.0.0"
38
+ "@ultimat3/core": "22.1.0",
39
+ "@ultimat3/jobs": "22.1.0",
40
+ "@ultimat3/schema": "22.1.0",
41
+ "@ultimat3/time": "22.1.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
- drain(slot: DigestSlot): Promise<readonly NotifyEvent<unknown>[]>;
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 buckets = new Map<string, OpenBucket>();
82
+ const slots = new Map<string, OpenBucket[]>();
71
83
  return {
72
84
  get open(): number {
73
- return buckets.size;
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 existing = buckets.get(id);
79
- // A bucket whose window has already elapsed is not a bucket to append to: its owner is gone
80
- // (a crashed flush) and the event would sit there until an unrelated third event arrived.
81
- // Re-opening is the repair, and it costs one extra delivery rather than a lost one.
82
- if (existing !== undefined && existing.endsAt > at) {
83
- existing.events.push(input.event);
84
- return Promise.resolve({ opened: false, endsAt: existing.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
- buckets.set(id, { endsAt, events: [input.event] });
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 bucket = buckets.get(id);
93
- buckets.delete(id);
94
- return Promise.resolve(bucket?.events ?? []);
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
- buckets.clear();
114
+ slots.clear();
98
115
  },
99
116
  };
100
117
  }
@@ -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
- const owned: string[] = [];
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,