@mmmbuto/nexuscrew 0.9.64 → 0.9.65

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.
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+ // lib/notify/event-feed-notices-routes.js — the OWNER's notice dismissal surface.
3
+ //
4
+ // DELETE /event-feed/notices/:eventId clear one notice
5
+ // POST /event-feed/notices/dismiss-all clear the visible set (closed body)
6
+ //
7
+ // The owner is the source of truth for its own notices, so this is where a
8
+ // dismissal is registered: the snapshot it serves stops carrying the entry
9
+ // (every other device inherits that at its next read) and a closure frame is
10
+ // published for the peers that already hold the card live.
11
+ //
12
+ // Authorization is the ask-action pattern, reused verbatim: kill switch →
13
+ // proven federated origin → chain ending here (direct or two hops) → visibility
14
+ // ACL of the origin → `eventsAccess` + `askReplyAccess` on EVERY ring. Seeing a
15
+ // notice is not what grants clearing it, and no new grant name is invented.
16
+ //
17
+ // Unknown and out-of-scope ids get the SAME opaque 404: an id that does not
18
+ // exist and an id the peer may not see must be indistinguishable. The budget is
19
+ // its own instance (6/min/peer, 30/min global) and `dismiss-all` counts once:
20
+ // N DELETEs through two hops would die in the per-request budget.
21
+
22
+ const express = require('express');
23
+ const { createAskActionGate, createAskRateLimiter } = require('./event-feed-asks-routes.js');
24
+
25
+ // The event id is a crypto.randomUUID() minted by the producer registry: it is
26
+ // validated here BEFORE any use — a path is never built from a request value.
27
+ const EVENT_ID_RE = /^[0-9a-f-]{36}$/;
28
+ // Same cap as SNAPSHOT_MAX_NOTIFY: `dismiss-all` covers what the peer can see,
29
+ // which is what the snapshot it just read held.
30
+ const MAX_VISIBLE = 50;
31
+
32
+ function createEventFeedNoticesRoutes(deps) {
33
+ const r = express.Router();
34
+ const json = express.json({ limit: '8kb' });
35
+ const gate = createAskActionGate(deps);
36
+ const rate = createAskRateLimiter({ now: deps.now });
37
+ const log = typeof deps.log === 'function' ? deps.log : () => {};
38
+
39
+ // Resolve the notice for THIS peer: it must be in the history of the owner
40
+ // AND visible to the ASKING peer under the grants of the owner, by the same
41
+ // rule the snapshot applies to the envelope it would ship. Visibility is a
42
+ // decision of the ORIGIN (cross-chain transit is gated earlier in the hop
43
+ // chain): a relay never gains a visibility of its own.
44
+ function resolveNoticeForPeer(peer, eventId) {
45
+ const entry = deps.history.list().find((e) => e && e.eventId === eventId);
46
+ if (!entry || !entry.envelope) return null;
47
+ if (!peer.allowsEnvelope(entry.envelope)) return null;
48
+ return entry;
49
+ }
50
+
51
+ // The closure inherits scope and cell of the original, so the publisher
52
+ // delivers it to exactly the peers that could see that notice. A failure to
53
+ // publish never turns a registered dismissal into an error: the entry is
54
+ // already gone from the snapshot, which is the durable half.
55
+ async function publishClosures(entries) {
56
+ for (const entry of entries) {
57
+ try {
58
+ await deps.closeNotice({
59
+ scope: entry.envelope.scope,
60
+ cellId: entry.envelope.scope === 'cell' ? entry.envelope.cellId : null,
61
+ eventId: entry.eventId,
62
+ });
63
+ } catch (e) {
64
+ log(`notices: closure not published for ${String(entry.eventId).slice(0, 8)}: ${String((e && e.message) || e)}`);
65
+ }
66
+ }
67
+ }
68
+
69
+ r.delete('/:eventId', async (req, res) => {
70
+ const g = await gate(req, res);
71
+ if (!g) return;
72
+ const verdict = rate.check(g.peer.nodeId, g.actor);
73
+ if (!verdict.allowed) return res.status(429).json({ error: 'dismiss rate', reason: verdict.reason });
74
+ if (deps.readonly && deps.readonly()) {
75
+ return res.status(403).json({ error: 'READONLY: mutazione bloccata' });
76
+ }
77
+ if (g.peer.grants.askReplyAccess !== true) {
78
+ return res.status(403).json({ error: 'risorsa non concessa a questo nodo', reason: 'grant-required:ask-action' });
79
+ }
80
+ const eventId = String(req.params.eventId || '');
81
+ if (!EVENT_ID_RE.test(eventId)) {
82
+ return res.status(400).json({ error: 'eventId non valido' });
83
+ }
84
+ const entry = resolveNoticeForPeer(g.peer, eventId);
85
+ if (!entry) {
86
+ // Unknown and invisible are the same answer, so the surface cannot be
87
+ // used to probe the owner's history.
88
+ return res.status(404).json({ error: 'notify inesistente' });
89
+ }
90
+ const already = deps.dismissals.isDismissed(eventId);
91
+ if (already) return res.json({ dismissed: true, idempotent: true });
92
+ const written = deps.dismissals.dismiss(eventId);
93
+ if (!written.ok) {
94
+ // The store refused to represent the dismissal (declared as a
95
+ // degradation): answering 200 would claim a truth we do not hold.
96
+ return res.status(503).json({ error: 'dismissals unavailable', reason: written.reason });
97
+ }
98
+ await publishClosures([entry]);
99
+ return res.json({ dismissed: true, idempotent: false });
100
+ });
101
+
102
+ r.post('/dismiss-all', json, async (req, res) => {
103
+ const g = await gate(req, res);
104
+ if (!g) return;
105
+ const verdict = rate.check(g.peer.nodeId, g.actor);
106
+ if (!verdict.allowed) return res.status(429).json({ error: 'dismiss rate', reason: verdict.reason });
107
+ if (deps.readonly && deps.readonly()) {
108
+ return res.status(403).json({ error: 'READONLY: mutazione bloccata' });
109
+ }
110
+ if (g.peer.grants.askReplyAccess !== true) {
111
+ return res.status(403).json({ error: 'risorsa non concessa a questo nodo', reason: 'grant-required:ask-action' });
112
+ }
113
+ // CLOSED body: the set to clear is what the peer can see, never a list the
114
+ // request brings. A body that tries to choose it is a 400 before anything.
115
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
116
+ for (const key of Object.keys(body)) {
117
+ return res.status(400).json({ error: `campo non ammesso: "${key}"` });
118
+ }
119
+ const visible = deps.history.list()
120
+ .filter((e) => e && e.envelope && g.peer.allowsEnvelope(e.envelope))
121
+ .slice(-MAX_VISIBLE);
122
+ const cleared = [];
123
+ for (const entry of visible) {
124
+ if (deps.dismissals.isDismissed(entry.eventId)) continue;
125
+ const written = deps.dismissals.dismiss(entry.eventId);
126
+ if (written.ok) cleared.push(entry);
127
+ }
128
+ await publishClosures(cleared);
129
+ return res.json({ dismissed: cleared.length });
130
+ });
131
+
132
+ return r;
133
+ }
134
+
135
+ module.exports = { createEventFeedNoticesRoutes, EVENT_ID_RE, MAX_VISIBLE };
@@ -94,6 +94,27 @@ const PRODUCERS = {
94
94
  ts: typeof p.ts === 'number' ? p.ts : undefined,
95
95
  }),
96
96
  },
97
+ // Closure of a notice the OWNER cleared: the frame carries the event id the
98
+ // receivers already hold, and inherits the scope (and the cell) of that
99
+ // original — that is what delivers it to exactly the peers that could see it.
100
+ 'notify-closed': {
101
+ scope: 'cell',
102
+ requires: ['cellId'],
103
+ build: (p) => ({
104
+ type: 'notify-closed',
105
+ eventId: cleanStr(p.eventId, 64) || '',
106
+ ts: typeof p.ts === 'number' ? p.ts : undefined,
107
+ }),
108
+ },
109
+ 'notify-closed-node': {
110
+ scope: 'node',
111
+ requires: [],
112
+ build: (p) => ({
113
+ type: 'notify-closed-node',
114
+ eventId: cleanStr(p.eventId, 64) || '',
115
+ ts: typeof p.ts === 'number' ? p.ts : undefined,
116
+ }),
117
+ },
97
118
  // A file notice: name/caption/session only. A file event is an alert — the
98
119
  // download is a separate, separately authorized operation.
99
120
  'file-notice': {
@@ -197,6 +197,7 @@ function createEventFeedRoutes(deps) {
197
197
  async function buildSnapshot({ peer, self }) {
198
198
  // Cursor captured atomically with the snapshot content: the feed engine is
199
199
  // synchronous, so one turn later nothing has been inserted in between.
200
+ const dismissals = deps.noticeDismissals || null;
200
201
  const st = deps.eventFeed.status(peer.nodeId);
201
202
  const cursor = st ? `${st.viewEpoch}:${st.seq}` : null;
202
203
  const viewEpoch = st ? st.viewEpoch : null;
@@ -210,6 +211,12 @@ function createEventFeedRoutes(deps) {
210
211
  // alias ancora aperti. Non si spedisce Nessuno snapshot: si rilancia un
211
212
  // errore tipizzato che l'handler traduce in 503 {error:"asks-unreadable"}.
212
213
  let asks = [];
214
+ // SOURCE counts, taken BEFORE any slice: the marker means "there was more
215
+ // than this page holds", and only the source can tell. A page sliced to
216
+ // exactly the cap from a source no bigger than the cap is the WHOLE set.
217
+ let askSourceCount = 0;
218
+ let notifySourceCount = 0;
219
+ let cellSourceCount = 0;
213
220
  try {
214
221
  // Health gate PRIMA di list(): un asks.json ESISTENTE ma MALFORMATO
215
222
  // (virgola finale, asks non array) produce un list() [] SILENZIOSO — senza
@@ -235,9 +242,9 @@ function createEventFeedRoutes(deps) {
235
242
  // visibilita' della cella.
236
243
  .filter((a) => !a.originNode && !(a.ownerId && self && a.ownerId !== self))
237
244
  .map((a) => ({ id: a.id, question: a.question, options: a.options, session: a.session, ts: a.ts }))
238
- .filter((a) => peer.allows({ scope: 'cell', cellId: cellForSession(a.session) }))
239
- .slice(0, SNAPSHOT_MAX_ASKS);
240
- asks = visible;
245
+ .filter((a) => peer.allows({ scope: 'cell', cellId: cellForSession(a.session) }));
246
+ askSourceCount = visible.length;
247
+ asks = visible.slice(0, SNAPSHOT_MAX_ASKS);
241
248
  } catch (e) {
242
249
  // Una riga, nessun dato sensibile: il messaggio dello store e' gia'
243
250
  // limitato a nome file e causa (v. persist.js readJsonSafe).
@@ -248,7 +255,12 @@ function createEventFeedRoutes(deps) {
248
255
  }
249
256
 
250
257
  // Notify history: local-only, ACL-filtered per envelope, capped.
251
- const notifications = deps.history.list()
258
+ const visibleNotifications = deps.history.list()
259
+ // Cleared notices are gone from the owner's view BEFORE the ACL: the
260
+ // dismissal is the owner's own statement about its own notice, and every
261
+ // device that reads this snapshot inherits it (that is what makes the
262
+ // cleanup true everywhere, not just on the device that asked).
263
+ .filter((e) => !(dismissals && dismissals.isDismissed(e.eventId)))
252
264
  .filter((e) => peer.allows(e.envelope))
253
265
  .map((e) => {
254
266
  // Node frames can carry cell lists (fleet state): project them with
@@ -258,8 +270,9 @@ function createEventFeedRoutes(deps) {
258
270
  return { ...e, envelope: { ...e.envelope, frame: { ...e.envelope.frame, cells } } };
259
271
  }
260
272
  return e;
261
- })
262
- .slice(-SNAPSHOT_MAX_NOTIFY);
273
+ });
274
+ notifySourceCount = visibleNotifications.length;
275
+ const notifications = visibleNotifications.slice(-SNAPSHOT_MAX_NOTIFY);
263
276
 
264
277
  // Fleet state: projected by visibility, hidden cells contribute nothing.
265
278
  let fleetState = { available: false, cells: [] };
@@ -267,7 +280,9 @@ function createEventFeedRoutes(deps) {
267
280
  const fleet = await deps.fleetP;
268
281
  if (fleet && typeof fleet.status === 'function') {
269
282
  const stt = await fleet.status();
270
- const raw = (stt && Array.isArray(stt.cells) ? stt.cells : [])
283
+ const sourceCells = (stt && Array.isArray(stt.cells) ? stt.cells : []);
284
+ cellSourceCount = sourceCells.length;
285
+ const raw = sourceCells
271
286
  .slice(0, SNAPSHOT_MAX_CELLS)
272
287
  .map((c) => ({ cell: c && c.cell, active: c && c.active === true }));
273
288
  fleetState = { available: stt && stt.available === true, cells: peer.projectCells(raw) };
@@ -289,10 +304,14 @@ function createEventFeedRoutes(deps) {
289
304
  nodeState: { nodeId: self },
290
305
  historyStatus: deps.history.status(),
291
306
  };
292
- if (asks.length >= SNAPSHOT_MAX_ASKS
293
- || notifications.length >= SNAPSHOT_MAX_NOTIFY
294
- || (fleetState.cells || []).length >= SNAPSHOT_MAX_CELLS) {
295
- // at a cap: the client must treat this as a floor, not a whole.
307
+ // The marker means "the source had MORE than this page holds": decided on
308
+ // the source counts, BEFORE the slice. Marking a page that is exactly at
309
+ // the cap told the client "incomplete" about complete pages — and made a
310
+ // full-window owner unimportable forever (a notification history cannot
311
+ // even exceed its own cap).
312
+ if (askSourceCount > SNAPSHOT_MAX_ASKS
313
+ || notifySourceCount > SNAPSHOT_MAX_NOTIFY
314
+ || cellSourceCount > SNAPSHOT_MAX_CELLS) {
296
315
  built.resyncRequired = true;
297
316
  }
298
317
  return built;
@@ -0,0 +1,336 @@
1
+ 'use strict';
2
+ // lib/notify/notice-dismissals-queue.js — durable queue for notice dismissals
3
+ // this node could not hand to the owner yet, plus the drainer that retries them.
4
+ //
5
+ // The local intent is ALREADY honoured when a record lands here (the card is
6
+ // gone from this node's view): what is pending is the owner's copy, so the other
7
+ // devices stop seeing the notice too. Records carry no content — owner, event
8
+ // id, times, sync state — never a title, never a body.
9
+ //
10
+ // Bounds and failure policy mirror the dismissals store and the history: 200
11
+ // records, 64 KiB, retention equal to the notice's own life (15 minutes),
12
+ // atomic tmp+rename 0600, degradation declared in status(). An entry whose
13
+ // notice has expired is not worth carrying: it leaves the queue.
14
+
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+
18
+ const MAX_ENTRIES = 200;
19
+ const MAX_BYTES = 64 * 1024;
20
+ const MAX_MS = 15 * 60 * 1000;
21
+ const SCHEMA = 'nexuscrew-notice-dismissal-queue-v1';
22
+ const OWNER_ID_RE = /^[a-f0-9]{32}$/;
23
+ const STATES = new Set(['pending', 'blocked']);
24
+
25
+ function createNoticeDismissalQueue({ filePath, now = Date.now, limits = {} }) {
26
+ const maxEntries = limits.maxEntries || MAX_ENTRIES;
27
+ const maxBytes = limits.maxBytes || MAX_BYTES;
28
+ const maxMs = limits.maxMs || MAX_MS;
29
+
30
+ let cache = null;
31
+ let degraded = null;
32
+ // The last load could not read or understand the file: the in-memory list
33
+ // is INCOMPLETE until a retry succeeds. While it lasts, the operations the
34
+ // node accepts go to an ordered journal and nothing is written: a successful
35
+ // read replays the journal on top of the disk before writing.
36
+ let loadFailed = false;
37
+ let journal = [];
38
+
39
+ function validateList(raw) {
40
+ if (!Array.isArray(raw)) return null;
41
+ const out = [];
42
+ for (const entry of raw) {
43
+ if (!entry || typeof entry !== 'object') return null;
44
+ if (!OWNER_ID_RE.test(String(entry.ownerId || ''))) return null;
45
+ if (typeof entry.eventId !== 'string' || entry.eventId.length === 0) return null;
46
+ if (!STATES.has(entry.syncState)) return null;
47
+ if (typeof entry.at !== 'number' || !Number.isFinite(entry.at)) return null;
48
+ out.push({
49
+ ownerId: entry.ownerId, eventId: entry.eventId,
50
+ at: entry.at, expiresAt: Number.isFinite(entry.expiresAt) ? entry.expiresAt : entry.at + maxMs,
51
+ syncState: entry.syncState,
52
+ attempts: Number.isSafeInteger(entry.attempts) && entry.attempts >= 0 ? entry.attempts : 0,
53
+ nextRetryAt: Number.isFinite(entry.nextRetryAt) ? entry.nextRetryAt : 0,
54
+ ...(typeof entry.lastReason === 'string' ? { lastReason: entry.lastReason } : {}),
55
+ ...(typeof entry.retryBinding === 'string' || entry.retryBinding === null ? { retryBinding: entry.retryBinding } : {}),
56
+ });
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function prune(entries, t) {
62
+ let out = entries.filter((e) => e.expiresAt > t);
63
+ if (out.length > maxEntries) out = out.slice(out.length - maxEntries);
64
+ return out;
65
+ }
66
+
67
+ // Pure disk read, no cache: {ok:true, entries} or {ok:false} with the
68
+ // degradation reason set. ENOENT is a legitimate absence, not a failure.
69
+ function readFromDisk() {
70
+ let raw;
71
+ try { raw = fs.readFileSync(filePath, 'utf8'); }
72
+ catch (e) {
73
+ if (!(e && e.code === 'ENOENT')) degraded = 'queue-read-failed';
74
+ return e && e.code === 'ENOENT' ? { ok: true, entries: [] } : { ok: false };
75
+ }
76
+ if (Buffer.byteLength(raw, 'utf8') > maxBytes * 4) { degraded = 'queue-oversize'; return { ok: false }; }
77
+ let parsed = null;
78
+ try { parsed = JSON.parse(raw); } catch (_) { degraded = 'queue-schema'; return { ok: false }; }
79
+ const entries = parsed && parsed.schema === SCHEMA ? validateList(parsed.entries) : null;
80
+ if (!entries) { degraded = 'queue-schema'; return { ok: false }; }
81
+ return { ok: true, entries: prune(entries, now()) };
82
+ }
83
+
84
+ function load() {
85
+ if (cache) return cache;
86
+ const read = readFromDisk();
87
+ if (!read.ok) {
88
+ loadFailed = true;
89
+ cache = [];
90
+ return cache;
91
+ }
92
+ loadFailed = false;
93
+ cache = read.entries;
94
+ return cache;
95
+ }
96
+
97
+ function persist(entries) {
98
+ let body;
99
+ try { body = JSON.stringify({ schema: SCHEMA, savedAt: now(), entries }); }
100
+ catch (_) { degraded = 'queue-write-failed'; return { ok: false, reason: 'write-failed', list: entries }; }
101
+ if (Buffer.byteLength(body, 'utf8') > maxBytes) { degraded = 'queue-oversize'; return { ok: false, reason: 'oversize', list: entries }; }
102
+ try {
103
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
104
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
105
+ fs.writeFileSync(tmp, body, { mode: 0o600 });
106
+ fs.renameSync(tmp, filePath);
107
+ } catch (_) { degraded = 'queue-write-failed'; return { ok: false, reason: 'write-failed', list: entries }; }
108
+ // A successful write holds everything this queue knows: the degradation is
109
+ // over and the answers go back to attesting full durability. A past
110
+ // failure must not stick to every future answer.
111
+ loadFailed = false;
112
+ degraded = null;
113
+ return { ok: true, list: entries };
114
+ }
115
+
116
+ // Recovery from a failed load: the disk is the base and the journal replays
117
+ // on top of it IN ORDER (the node's current intent wins), then the normal
118
+ // prune applies — the oldest records leave, a fresh accepted intent never
119
+ // does. Returns false when the disk is still unreadable: nothing is written
120
+ // then, the intent stays in memory (journal included) and the answers keep
121
+ // declaring durable:false with the read degradation reason.
122
+ function attemptRecovery() {
123
+ const read = readFromDisk();
124
+ if (!read.ok) return false;
125
+ let entries = read.entries;
126
+ for (const op of journal) {
127
+ if (op.op === 'enqueue') {
128
+ if (!entries.some((e) => e.ownerId === op.ownerId && e.eventId === op.eventId)) entries.push(op.record);
129
+ } else if (op.op === 'update') {
130
+ const i = entries.findIndex((e) => e.ownerId === op.ownerId && e.eventId === op.eventId);
131
+ if (i >= 0) entries[i] = { ...entries[i], ...op.patch };
132
+ } else {
133
+ entries = entries.filter((e) => !(e.ownerId === op.ownerId && e.eventId === op.eventId));
134
+ }
135
+ }
136
+ const rebuilt = prune(entries, now());
137
+ cache = rebuilt;
138
+ const written = persist(rebuilt);
139
+ if (!written.ok) return false;
140
+ journal = [];
141
+ loadFailed = false;
142
+ degraded = null;
143
+ return true;
144
+ }
145
+
146
+ function indexOf(entries, ownerId, eventId) {
147
+ return entries.findIndex((e) => e.ownerId === ownerId && e.eventId === eventId);
148
+ }
149
+
150
+ function get(ownerId, eventId) {
151
+ const entries = prune(load(), now());
152
+ const i = indexOf(entries, ownerId, eventId);
153
+ return i < 0 ? null : { ...entries[i] };
154
+ }
155
+
156
+ function list(ownerId) {
157
+ const entries = prune(load(), now());
158
+ return entries.filter((e) => (ownerId ? e.ownerId === ownerId : true)).map((e) => ({ ...e }));
159
+ }
160
+
161
+ function enqueue({ ownerId, eventId } = {}) {
162
+ if (!OWNER_ID_RE.test(String(ownerId || ''))) return { ok: false, reason: 'bad-owner' };
163
+ if (typeof eventId !== 'string' || !eventId) return { ok: false, reason: 'bad-event' };
164
+ const entries = prune(load(), now());
165
+ const i = indexOf(entries, ownerId, eventId);
166
+ if (i >= 0) return { ok: true, record: { ...entries[i] }, existed: true };
167
+ const t = now();
168
+ const record = { ownerId, eventId, at: t, expiresAt: t + maxMs, syncState: 'pending', attempts: 0, nextRetryAt: 0 };
169
+ entries.push(record);
170
+ const kept = prune(entries, t);
171
+ cache = kept;
172
+ if (loadFailed) {
173
+ journal.push({ op: 'enqueue', ownerId, eventId, record });
174
+ attemptRecovery();
175
+ return { ok: true, record: { ...record }, existed: false };
176
+ }
177
+ const written = persist(kept);
178
+ if (written.ok) degraded = null;
179
+ return { ok: true, record: { ...record }, existed: false };
180
+ }
181
+
182
+ function update(ownerId, eventId, patch = {}) {
183
+ const entries = prune(load(), now());
184
+ const i = indexOf(entries, ownerId, eventId);
185
+ if (i < 0) return { ok: false, reason: 'unknown' };
186
+ entries[i] = { ...entries[i], ...patch, ownerId, eventId };
187
+ cache = prune(entries, now());
188
+ if (loadFailed) {
189
+ journal.push({ op: 'update', ownerId, eventId, patch });
190
+ attemptRecovery();
191
+ return { ok: true, record: get(ownerId, eventId) || { ...entries[i] } };
192
+ }
193
+ const written = persist(entries);
194
+ if (written.ok) degraded = null;
195
+ return { ok: true, record: { ...entries[i] } };
196
+ }
197
+
198
+ // Confirmed: the owner has it, the record leaves the queue. While the load
199
+ // is failing the removal is journaled even when the record is not in the
200
+ // incomplete in-memory list: the replay applies it to what the disk holds.
201
+ function confirm(ownerId, eventId) {
202
+ const entries = prune(load(), now());
203
+ const i = indexOf(entries, ownerId, eventId);
204
+ if (loadFailed) {
205
+ journal.push({ op: 'confirm', ownerId, eventId });
206
+ if (i >= 0) entries.splice(i, 1);
207
+ cache = prune(entries, now());
208
+ attemptRecovery();
209
+ return { ok: true, removed: i >= 0 };
210
+ }
211
+ if (i < 0) return { ok: true, removed: false };
212
+ entries.splice(i, 1);
213
+ cache = entries;
214
+ const written = persist(entries);
215
+ if (written.ok) degraded = null;
216
+ return { ok: true, removed: true };
217
+ }
218
+
219
+ function status() {
220
+ const entries = prune(load(), now());
221
+ return {
222
+ degraded: degraded || null,
223
+ count: entries.length,
224
+ pending: entries.filter((e) => e.syncState === 'pending').length,
225
+ blocked: entries.filter((e) => e.syncState === 'blocked').length,
226
+ };
227
+ }
228
+
229
+ return { enqueue, get, list, update, confirm, status, MAX_MS };
230
+ }
231
+
232
+ function deferDelay(attempts, random, retryAfterMs = 0) {
233
+ const base = Math.min(300000, 1000 * 2 ** Math.min(attempts, 9)) * (0.5 + random() * 0.5);
234
+ return Math.max(retryAfterMs, base);
235
+ }
236
+
237
+ // The drainer: one attempt per owner at a time, a global budget and a per-owner
238
+ // one, exponential backoff with jitter, and `blocked` records that stay put
239
+ // until the binding to that owner changes (a re-pair is the only thing that can
240
+ // make a refusal meaningful again).
241
+ function createNoticeDismissalDrainer({
242
+ queue, relay, now = Date.now, random = Math.random,
243
+ setTimer = setTimeout, clearTimer = clearTimeout, intervalMs = 15000,
244
+ globalBudget = 4, ownerBudget = 1, budgetWindowMs = 60000,
245
+ bindingForOwner = () => null, readonly = () => false, log = () => {},
246
+ } = {}) {
247
+ const flights = new Set();
248
+ const globalHits = [];
249
+ const ownerHits = new Map();
250
+ let stopped = false;
251
+ let timer = null;
252
+ let running = null;
253
+
254
+ function defer(record, reason, blocked = false, retryAfterMs = 0) {
255
+ const attempts = (record.attempts || 0) + 1;
256
+ return queue.update(record.ownerId, record.eventId, {
257
+ syncState: blocked ? 'blocked' : 'pending',
258
+ retryBinding: bindingForOwner(record.ownerId),
259
+ attempts, lastReason: reason || 'owner-unreachable',
260
+ nextRetryAt: now() + deferDelay(attempts, random, retryAfterMs),
261
+ });
262
+ }
263
+
264
+ function rejected(record, result = {}) {
265
+ const reason = result.reason || 'owner-unreachable';
266
+ const pending = result.transient === true || (result.code === 429) || (typeof result.code === 'number' && result.code >= 500);
267
+ return defer(record, reason, !pending, result.retryAfterMs || 0);
268
+ }
269
+
270
+ async function attempt(record) {
271
+ const key = `${record.ownerId}|${record.eventId}`;
272
+ if (flights.has(key) || stopped || readonly()) return;
273
+ flights.add(key);
274
+ try {
275
+ const current = queue.get(record.ownerId, record.eventId);
276
+ if (!current || current.syncState !== 'pending') return;
277
+ const result = await relay.relayNoticeDismiss({ ownerId: record.ownerId, eventId: record.eventId });
278
+ if (stopped || readonly()) return;
279
+ const latest = queue.get(record.ownerId, record.eventId);
280
+ if (!latest || latest.syncState !== 'pending' || latest.at !== current.at) return;
281
+ if (result.ok) { queue.confirm(latest.ownerId, latest.eventId); return; }
282
+ if (result.reason === 'unsupported') { defer(latest, 'unsupported', true); return; }
283
+ rejected(latest, result);
284
+ } catch (error) {
285
+ const current = queue.get(record.ownerId, record.eventId);
286
+ if (!stopped && current && current.syncState === 'pending') defer(current, 'owner-unreachable');
287
+ log(`notice dismissal synchronization failed: ${String(error.message || error)}`);
288
+ } finally { flights.delete(key); }
289
+ }
290
+
291
+ async function run() {
292
+ if (stopped || readonly()) return;
293
+ const t = now();
294
+ const pruneHits = (hits) => { while (hits.length && hits[0] <= t - budgetWindowMs) hits.shift(); };
295
+ pruneHits(globalHits);
296
+ for (const [ownerId, hits] of ownerHits) { pruneHits(hits); if (!hits.length) ownerHits.delete(ownerId); }
297
+ for (let record of queue.list()) {
298
+ if (globalHits.length >= globalBudget) break;
299
+ if (record.syncState === 'blocked') {
300
+ const binding = bindingForOwner(record.ownerId);
301
+ if (binding === null || binding === record.retryBinding) continue;
302
+ const resumed = queue.update(record.ownerId, record.eventId, { syncState: 'pending', nextRetryAt: 0, retryBinding: binding });
303
+ if (resumed.ok) record = resumed.record; else continue;
304
+ }
305
+ if (record.syncState !== 'pending' || (record.nextRetryAt || 0) > now()) continue;
306
+ const hits = ownerHits.get(record.ownerId) || [];
307
+ if (hits.length >= ownerBudget) continue;
308
+ hits.push(t); ownerHits.set(record.ownerId, hits); globalHits.push(t);
309
+ await attempt(record);
310
+ }
311
+ }
312
+
313
+ function drain() {
314
+ if (running) return running;
315
+ const flight = run().finally(() => { if (running === flight) running = null; });
316
+ running = flight;
317
+ return flight;
318
+ }
319
+
320
+ function schedule() {
321
+ if (stopped || timer) return;
322
+ timer = setTimer(() => { timer = null; void drain().catch(error => log(String(error.message || error))).finally(schedule); }, intervalMs);
323
+ if (timer && typeof timer.unref === 'function') timer.unref();
324
+ }
325
+
326
+ function start() { schedule(); void drain().catch(error => log(String(error.message || error))); }
327
+ function stop() { stopped = true; if (timer) clearTimer(timer); timer = null; }
328
+ function nudge() { if (!stopped) void drain().catch(error => log(String(error.message || error))); }
329
+
330
+ return { start, stop, drain, nudge };
331
+ }
332
+
333
+ module.exports = {
334
+ createNoticeDismissalQueue, createNoticeDismissalDrainer,
335
+ MAX_ENTRIES, MAX_BYTES, MAX_MS, SCHEMA,
336
+ };