@mmmbuto/nexuscrew 0.9.64 → 0.9.66

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': {
@@ -14,6 +14,7 @@
14
14
  const express = require('express');
15
15
  const nodesStore = require('../nodes/store.js');
16
16
  const eventFeedAcl = require('./event-feed-acl.js');
17
+ const askCell = require('./ask-cell.js');
17
18
 
18
19
  const SNAPSHOT_MAX_BYTES = 3 * 1024 * 1024;
19
20
  const SNAPSHOT_MAX_ASKS = 100;
@@ -197,6 +198,7 @@ function createEventFeedRoutes(deps) {
197
198
  async function buildSnapshot({ peer, self }) {
198
199
  // Cursor captured atomically with the snapshot content: the feed engine is
199
200
  // synchronous, so one turn later nothing has been inserted in between.
201
+ const dismissals = deps.noticeDismissals || null;
200
202
  const st = deps.eventFeed.status(peer.nodeId);
201
203
  const cursor = st ? `${st.viewEpoch}:${st.seq}` : null;
202
204
  const viewEpoch = st ? st.viewEpoch : null;
@@ -210,6 +212,12 @@ function createEventFeedRoutes(deps) {
210
212
  // alias ancora aperti. Non si spedisce Nessuno snapshot: si rilancia un
211
213
  // errore tipizzato che l'handler traduce in 503 {error:"asks-unreadable"}.
212
214
  let asks = [];
215
+ // SOURCE counts, taken BEFORE any slice: the marker means "there was more
216
+ // than this page holds", and only the source can tell. A page sliced to
217
+ // exactly the cap from a source no bigger than the cap is the WHOLE set.
218
+ let askSourceCount = 0;
219
+ let notifySourceCount = 0;
220
+ let cellSourceCount = 0;
213
221
  try {
214
222
  // Health gate PRIMA di list(): un asks.json ESISTENTE ma MALFORMATO
215
223
  // (virgola finale, asks non array) produce un list() [] SILENZIOSO — senza
@@ -234,10 +242,15 @@ function createEventFeedRoutes(deps) {
234
242
  // guardia e' esplicita sui campi di provenienza, non dedotta dalla
235
243
  // visibilita' della cella.
236
244
  .filter((a) => !a.originNode && !(a.ownerId && self && a.ownerId !== self))
237
- .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
+ // The cell is resolved ONCE, by this owner, through its own definitions, and
246
+ // it travels with the ask: a session with a custom tmux name does not decode
247
+ // to a cell on the receiving side, and the receiver must not guess it. Only
248
+ // a well-formed id is published; an unresolved one is simply absent.
249
+ .map((a) => ({ id: a.id, question: a.question, options: a.options, session: a.session, ts: a.ts, cell: cellForSession(a.session) }))
250
+ .filter((a) => peer.allows({ scope: 'cell', cellId: a.cell }))
251
+ .map(({ cell, ...ask }) => (askCell.isCellId(cell) ? { ...ask, cellId: cell } : ask));
252
+ askSourceCount = visible.length;
253
+ asks = visible.slice(0, SNAPSHOT_MAX_ASKS);
241
254
  } catch (e) {
242
255
  // Una riga, nessun dato sensibile: il messaggio dello store e' gia'
243
256
  // limitato a nome file e causa (v. persist.js readJsonSafe).
@@ -248,7 +261,12 @@ function createEventFeedRoutes(deps) {
248
261
  }
249
262
 
250
263
  // Notify history: local-only, ACL-filtered per envelope, capped.
251
- const notifications = deps.history.list()
264
+ const visibleNotifications = deps.history.list()
265
+ // Cleared notices are gone from the owner's view BEFORE the ACL: the
266
+ // dismissal is the owner's own statement about its own notice, and every
267
+ // device that reads this snapshot inherits it (that is what makes the
268
+ // cleanup true everywhere, not just on the device that asked).
269
+ .filter((e) => !(dismissals && dismissals.isDismissed(e.eventId)))
252
270
  .filter((e) => peer.allows(e.envelope))
253
271
  .map((e) => {
254
272
  // Node frames can carry cell lists (fleet state): project them with
@@ -258,8 +276,9 @@ function createEventFeedRoutes(deps) {
258
276
  return { ...e, envelope: { ...e.envelope, frame: { ...e.envelope.frame, cells } } };
259
277
  }
260
278
  return e;
261
- })
262
- .slice(-SNAPSHOT_MAX_NOTIFY);
279
+ });
280
+ notifySourceCount = visibleNotifications.length;
281
+ const notifications = visibleNotifications.slice(-SNAPSHOT_MAX_NOTIFY);
263
282
 
264
283
  // Fleet state: projected by visibility, hidden cells contribute nothing.
265
284
  let fleetState = { available: false, cells: [] };
@@ -267,7 +286,9 @@ function createEventFeedRoutes(deps) {
267
286
  const fleet = await deps.fleetP;
268
287
  if (fleet && typeof fleet.status === 'function') {
269
288
  const stt = await fleet.status();
270
- const raw = (stt && Array.isArray(stt.cells) ? stt.cells : [])
289
+ const sourceCells = (stt && Array.isArray(stt.cells) ? stt.cells : []);
290
+ cellSourceCount = sourceCells.length;
291
+ const raw = sourceCells
271
292
  .slice(0, SNAPSHOT_MAX_CELLS)
272
293
  .map((c) => ({ cell: c && c.cell, active: c && c.active === true }));
273
294
  fleetState = { available: stt && stt.available === true, cells: peer.projectCells(raw) };
@@ -289,10 +310,14 @@ function createEventFeedRoutes(deps) {
289
310
  nodeState: { nodeId: self },
290
311
  historyStatus: deps.history.status(),
291
312
  };
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.
313
+ // The marker means "the source had MORE than this page holds": decided on
314
+ // the source counts, BEFORE the slice. Marking a page that is exactly at
315
+ // the cap told the client "incomplete" about complete pages — and made a
316
+ // full-window owner unimportable forever (a notification history cannot
317
+ // even exceed its own cap).
318
+ if (askSourceCount > SNAPSHOT_MAX_ASKS
319
+ || notifySourceCount > SNAPSHOT_MAX_NOTIFY
320
+ || cellSourceCount > SNAPSHOT_MAX_CELLS) {
296
321
  built.resyncRequired = true;
297
322
  }
298
323
  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
+ };