libp2r2p 0.11.26 → 0.11.27

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
@@ -1252,3 +1252,8 @@ uploads budget each chunk independently. Monotonic clocks never enter storage or
1252
1252
  Nostr events. Omitted timestamps keep the publication-local delay.
1253
1253
  `RelayPool.sendEvent` accepts a consumer `signal` without closing shared sockets.
1254
1254
  See [fallback and cancellation](private-messenger/session/README.md#relay-fallback).
1255
+
1256
+ The 0.11.27 session publishes independently of local event-store persistence after
1257
+ its initial durable intent and exact-envelope checkpoint. `deliveryConfirmed`
1258
+ and the copied `localPersistence` summary distinguish relay acceptance from
1259
+ local storage. See `private-messenger/session/README.md` for retry and cleanup.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.26",
3
+ "version": "0.11.27",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -77,7 +77,8 @@ Callbacks are optional `onDebug`, `onOutbox`, `onMedia`,
77
77
  account permissions/connectivity/lock state to availability and owns view subscriptions. There are no DOM or UI imports.
78
78
 
79
79
  `onSendError` identifies the main outbox message even when its quote, metadata,
80
- file chunks, or local persistence failed. The original error is also reported to
80
+ file chunks, or self-chat persistence failed. Remote local-persistence failures
81
+ use onError and the copied localPersistence summary, never a send failure. The original error is also reported to
81
82
  `onError` unchanged. It fires once per failed send attempt (including retries),
82
83
  not when persisted failures are read, nor for inbox/download/deletion operations
83
84
  or cancelled/closed sends. Apps own route visibility and user-intent filtering;
@@ -233,10 +234,10 @@ creates a fresh publication lifetime, without treating the pause as a user abort
233
234
  ## Independent local persistence and publication
234
235
 
235
236
  One encrypted outbox feeds two independent serial processors. Local persistence
236
- never waits for peer preparation or an in-flight publication. Remote entries
237
- become publishable only after their local progress checkpoint commits; failed
238
- checkpoints are retried even if the copies have already committed. Text may
239
- continue passing uploads between chunks after its local stage is ready.
237
+ never waits for peer preparation or an in-flight publication. After the initial encrypted intent commits, remote preparation and publication
238
+ never wait for event-store persistence. Only the exact prepared-envelope
239
+ checkpoint gates transmission. Text continues passing uploads between chunks;
240
+ its local stage need not have completed. Both stages use the admitted inner ID.
240
241
 
241
242
  Self entries emit `status: 'saved'` as soon as local persistence succeeds. They
242
243
  do not write a redundant localSaved checkpoint before removal. Remote entries
@@ -275,7 +276,7 @@ First acceptance of every required envelope emits saved independently of archive
275
276
  or cleanup. ACK progress checkpoints serialize with other entry writes. A crash
276
277
  between ACK and its checkpoint may replay the same envelope; acknowledged durable
277
278
  progress only archives. Archive failure never becomes `onSendError` or changes
278
- saved status. Removal waits for archival and outstanding entry writes.
279
+ saved status. Removal waits for local persistence, archival and outstanding entry writes.
279
280
 
280
281
  At most one unit awaits archival per entry; a stalled upload yields its remote
281
282
  slot to other texts rather than accumulating the entire file. Cancel stops wire
@@ -283,3 +284,35 @@ work but retains potentially transmitted seeds until archival finishes. Close
283
284
  drains active local operations and leaves durable backoff work for restart.
284
285
  Diagnostics add publication/seed IDs, checkpoint time, archive wait/execution and
285
286
  first-ACK timing, without message or ciphertext contents.
287
+
288
+ ## Independent delivery status (0.11.27)
289
+
290
+ `deliveryConfirmed` is an optional boolean in encrypted outbox JSON. It records
291
+ relay confirmation separately from `localSaved`. Old entries normalize from
292
+ saved delivery status, never from local progress; their event/file indices and
293
+ prepared envelopes remain unchanged. No database migration is needed.
294
+
295
+ `onOutbox` adds `localPersistence: { state, retryable }`, where state is pending,
296
+ saved or failed. It is a presentation summary with no native error objects.
297
+ A saved remote entry may still have pending/failed local work. Keep it visible
298
+ through reload until the local store catches up. `retry(id)` on a confirmed
299
+ entry repeats local/archival cleanup only and never renews network urgency.
300
+ Recoverable local failures retain bounded backoff without clearing terminal
301
+ remote errors or releasing network pauses. Self-chat still confirms on commit.
302
+
303
+ Entry updates serialize through existing-only checkpoints; not-yet-started
304
+ writes coalesce and capture the latest monotonic progress at transaction start.
305
+ Prepared-publication callers wait for the resulting durable snapshot. An ACK
306
+ checkpoint may merge local progress but never requires it. Shutdown/lock waits
307
+ for admitted local writes; cancellation cannot recreate a removed intent.
308
+
309
+ The local failure summary remains failed while a retry is queued or running;
310
+ unrelated snapshots cannot hide its warning before commit. Retry admission is
311
+ runtime-only and temporarily makes that summary non-retryable. A failed retry
312
+ restores retryability after its operation ends; a successful commit clears the
313
+ failure. This does not change the already confirmed remote status.
314
+
315
+ Legacy single-stage failure flags with incomplete local progress normalize into
316
+ local failure state, preserving manual-only retry refusals. The new delivery
317
+ marker distinguishes those records from a modern remote failure racing local
318
+ persistence. Both formats enter the same processors after normalization.
@@ -4,13 +4,19 @@ import { isRecoverableStorageFailure } from '../../helpers/pause-recovery.js'
4
4
 
5
5
  export function createPublicationJournal ({ entries, store, archive, changed, onError, debug, setTimer = setTimeout, clearTimer = clearTimeout, random = Math.random }) {
6
6
  const writes = new Map()
7
+ const waitingWrites = new Map()
7
8
  const failed = new Set()
8
9
  const durable = new WeakSet()
9
10
  const queued = new WeakMap()
10
11
  let working; let stopped = false; let enabled = false; let timer; let delay = 1000
11
12
  function write (entry) {
13
+ if (waitingWrites.has(entry.id)) return waitingWrites.get(entry.id)
12
14
  const previous = writes.get(entry.id) || Promise.resolve()
13
- const work = previous.catch(() => {}).then(() => store().put(structuredClone(entry), { existing: true }))
15
+ const work = previous.catch(() => {}).then(() => {
16
+ waitingWrites.delete(entry.id)
17
+ return store().put(structuredClone(entry), { existing: true })
18
+ })
19
+ waitingWrites.set(entry.id, work)
14
20
  writes.set(entry.id, work)
15
21
  work.finally(() => { if (writes.get(entry.id) === work) writes.delete(entry.id) }).catch(() => {})
16
22
  return work
@@ -69,8 +69,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
69
69
  const attemptStarts = new Map()
70
70
  const networkUrgency = new Map()
71
71
  const sendControllers = new Map()
72
- const localReady = new Set()
73
72
  const localIdleWaiters = new Set()
73
+ // Last local failure survives retry admission until an actual commit.
74
+ const localRetries = new Set()
74
75
  const cleanups = new Map()
75
76
  const queuedAt = new WeakMap()
76
77
  const debug = (entry, stage, startedAt, coordinates = {}) => {
@@ -104,13 +105,14 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
104
105
  for (const entry of entries.values()) {
105
106
  if (entry.peer !== owner && entry.failed && entry.status === 'pending') pausedSends.add(entry.id)
106
107
  }
107
- if (![...entries.values()].some(entry => pendingRetryable(entry) && !pausedSends.has(entry.id))) clearSendRetry()
108
+ if (![...entries.values()].some(entry => (entry.localFailed && entry.localRetryable) || (pendingRetryable(entry) && !pausedSends.has(entry.id)))) clearSendRetry()
108
109
  watchOfflineSends()
109
110
  return
110
111
  }
111
112
  clearSendRetry()
112
113
  for (const id of pausedSends) { const entry = entries.get(id); if (entry) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) } }
113
114
  pausedSends.clear()
115
+ if ([...entries.values()].some(entry => entry.localFailed && entry.localRetryable)) scheduleSendRetry()
114
116
  watchOfflineSends()
115
117
  // The callback can run inside construction/resume, before configure settles.
116
118
  queueMicrotask(() => pump())
@@ -153,7 +155,12 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
153
155
  let redrain = false
154
156
  const idleWaiters = new Set()
155
157
  const settled = () => { if (!sending && !localSaving && !enqueuing && !draining && !cleanups.size) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
156
- const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion && !entry.cancelled))
158
+ const localComplete = entry => entry.localSaved.every(Boolean)
159
+ const completed = entry => localComplete(entry) && (entry.peer === owner || entry.deliveryConfirmed)
160
+ const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion && !entry.cancelled).map(entry => ({
161
+ ...entry,
162
+ localPersistence: { state: localComplete(entry) ? 'saved' : entry.localFailed ? 'failed' : 'pending', retryable: Boolean(entry.localFailed && entry.localRetryable && !localRetries.has(entry.id)) }
163
+ })))
157
164
  const journal = createPublicationJournal({
158
165
  entries, store: () => storage, onError: reportError, debug, setTimer: _setTimeout, clearTimer: _clearTimeout, random: _random,
159
166
  archive: async (entry, rows) => {
@@ -165,7 +172,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
165
172
  },
166
173
  changed: entry => {
167
174
  watchOfflineSends()
168
- if (!entry.prepared && (entry.status === 'saved' || entry.cancelled)) cleanupEntry(entry)
175
+ if (!entry.prepared && (completed(entry) || entry.cancelled)) cleanupEntry(entry)
169
176
  else if (!closed && available) pumpRemote()
170
177
  }
171
178
  })
@@ -174,11 +181,17 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
174
181
  storage = await openOutbox({ owner, signer, onDebug })
175
182
  for (const entry of await storage.list()) {
176
183
  // Old encrypted entries retain progress and enter this same pipeline.
184
+ const legacyLocalFailure = entry.deliveryConfirmed === undefined && entry.failed && !entry.localSaved.every(Boolean)
185
+ if (legacyLocalFailure) {
186
+ entry.localFailed = true; entry.localRetryable = Boolean(entry.retryable || entry.status === 'pending')
187
+ if (entry.peer !== owner) { entry.failed = false; entry.status = 'pending' }
188
+ }
189
+ entry.deliveryConfirmed ??= entry.peer !== owner && entry.status === 'saved'
190
+ if (entry.deliveryConfirmed) entry.status = 'saved'
177
191
  if (entry.cancelled) cancelled.add(entry.id)
178
192
  entries.set(entry.id, entry)
179
193
  journal.adopt(entry)
180
194
  attemptStarts.set(entry.id, performance.now())
181
- if (entry.localSaved.every(Boolean)) localReady.add(entry.id)
182
195
  queuedAt.set(entry, Date.now())
183
196
  }
184
197
  emit()
@@ -432,10 +445,12 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
432
445
  }
433
446
  async function cleanupEntry (entry) {
434
447
  if (entry.prepared) { journal.wake(); return }
448
+ if (!entry.cancelled && !completed(entry)) return
449
+ if (entry.failed && entry.status === 'saved') return
435
450
  if (cleanups.has(entry.id)) return cleanups.get(entry.id)
436
451
  const startedAt = Date.now()
437
452
  const work = Promise.resolve().then(async () => { await journal.flush(entry); await storage.remove(entry.id) }).then(() => {
438
- entries.delete(entry.id); localReady.delete(entry.id); attemptStarts.delete(entry.id)
453
+ entries.delete(entry.id); localRetries.delete(entry.id); attemptStarts.delete(entry.id)
439
454
  debug(entry, 'outbox-cleanup', startedAt)
440
455
  emit()
441
456
  }).catch(error => {
@@ -447,7 +462,49 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
447
462
  cleanups.set(entry.id, work)
448
463
  return work
449
464
  }
450
- async function sendEntry (entry, localOnly = false) {
465
+ async function saveEntry (entry) {
466
+ if (entry.peer === owner) { entry.status = 'pending'; emit() }
467
+ const releaseForeground = foreground.enter()
468
+ const active = async () => {
469
+ if (storage.has && !await storage.has(entry.id)) { cancelled.add(entry.id); sendControllers.get(entry.id)?.abort() }
470
+ if (closed || !available || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) throw new Error('CHAT_UNAVAILABLE')
471
+ }
472
+ const startedAt = Date.now()
473
+ try {
474
+ debug(entry, 'local-start', queuedAt.get(entry))
475
+ for (let index = 0; index < entry.events.length; index++) {
476
+ await active()
477
+ if (entry.localSaved[index]) continue
478
+ const event = entry.events[index]
479
+ const hearsay = !event.sig && event.pubkey !== owner
480
+ const result = await messageStorage.save(event, { peerPubkey: entry.peer, hearsay })
481
+ if (result?.result?.code === 'blocked') { cancelled.add(entry.id); entry.cancelled = true; sendControllers.get(entry.id)?.abort(); await active() }
482
+ if (!result?.result?.ok) throw Object.assign(new Error('MESSAGE_STORAGE_FAILED'), { code: result?.result?.code?.toUpperCase() })
483
+ entry.localSaved[index] = true
484
+ }
485
+ entry.localFailed = false
486
+ debug(entry, 'local-stored', startedAt)
487
+ if (entry.peer === owner) entry.status = 'saved'
488
+ emit()
489
+ if (completed(entry)) cleanupEntry(entry)
490
+ else await journal.write(entry)
491
+ } catch (error) {
492
+ if (cancelled.has(entry.id)) { sendControllers.get(entry.id)?.abort(); entry.cancelled = true; cleanupEntry(entry) } else if (!closed && available) {
493
+ entry.localFailed = true; entry.localRetryable = retryable(error)
494
+ if (entry.peer === owner) { entry.status = 'error'; entry.retryable = entry.localRetryable }
495
+ await journal.write(entry).catch(reportError)
496
+ reportError(error)
497
+ if (entry.localRetryable) scheduleSendRetry()
498
+ if (entry.peer === owner && !entry.deletion) onSendError(error, { id: entry.id, peer: entry.peer })
499
+ emit()
500
+ }
501
+ } finally {
502
+ const retried = localRetries.delete(entry.id)
503
+ releaseForeground()
504
+ if (retried && entry.localFailed) emit()
505
+ }
506
+ }
507
+ async function sendEntry (entry) {
451
508
  if (entry.status === 'saved') return
452
509
  const releaseForeground = foreground.enter()
453
510
  entry.status = 'pending'; emit()
@@ -455,36 +512,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
455
512
  sendControllers.set(entry.id, controller)
456
513
  try {
457
514
  const active = async () => {
458
- if (storage.has && !await storage.has(entry.id)) cancelled.add(entry.id)
515
+ if (storage.has && !await storage.has(entry.id)) { cancelled.add(entry.id); sendControllers.get(entry.id)?.abort() }
459
516
  if (closed || !available || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) throw new Error('CHAT_UNAVAILABLE')
460
517
  }
461
- if (localOnly) {
462
- debug(entry, 'local-start', queuedAt.get(entry))
463
- const startedAt = Date.now()
464
- for (let index = 0; index < entry.events.length; index++) {
465
- await active()
466
- if (entry.localSaved[index]) continue
467
- const event = entry.events[index]
468
- const hearsay = !event.sig && event.pubkey !== owner
469
- const result = await messageStorage.save(event, { peerPubkey: entry.peer, hearsay })
470
- if (result?.result?.code === 'blocked') { cancelled.add(entry.id); await active() }
471
- if (!result?.result?.ok) throw Object.assign(new Error('MESSAGE_STORAGE_FAILED'), { code: result?.result?.code?.toUpperCase() })
472
- entry.localSaved[index] = true
473
- }
474
- debug(entry, 'local-stored', startedAt)
475
- await active()
476
- if (entry.peer === owner) {
477
- entry.status = 'saved'; emit()
478
- cleanupEntry(entry)
479
- } else {
480
- // Retry this checkpoint even when a preceding attempt saved all copies
481
- // but failed to persist progress. Only then admit remote publication.
482
- await journal.write(entry)
483
- await active()
484
- localReady.add(entry.id)
485
- }
486
- return
487
- }
488
518
  if (entry.peer !== owner && !preparation.get(entry.peer)) {
489
519
  if (preparation.error(entry.peer)) throw preparation.error(entry.peer)
490
520
  preparation.request(entry.peer).catch(() => {})
@@ -518,14 +548,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
518
548
  await active()
519
549
  if (entry.prepared) { journal.accept(entry); return }
520
550
  await journal.write(entry)
521
- if ([...entries.values()].some(pending => !pending.files.length && pending.peer !== owner && !pending.failed && !localReady.has(pending.id))) {
522
- await pumpLocal()
523
- if (localSaving) await new Promise(resolve => localIdleWaiters.add(resolve))
524
- }
525
551
  let admitted = 0
526
552
  for (const pending of entries.values()) {
527
553
  if (closed || !available || admitted >= 8) break
528
- if (!localReady.has(pending.id) || pending.peer === owner || pending.status === 'saved' || pending.files.length || pending.failed || cancelled.has(pending.id) || pending.prepared?.accepted || !peers.has(pending.peer)) continue
554
+ if (pending.peer === owner || pending.status === 'saved' || pending.files.length || pending.failed || cancelled.has(pending.id) || pending.prepared?.accepted || !peers.has(pending.peer)) continue
529
555
  admitted++
530
556
  await sendEntry(pending)
531
557
  }
@@ -539,21 +565,23 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
539
565
  debug(entry, 'publication-accepted', startedAt)
540
566
  await active()
541
567
  entry.index++
542
- if (entry.index === entry.events.length) { entry.status = 'saved'; emit() }
568
+ if (entry.index === entry.events.length) { entry.deliveryConfirmed = true; entry.status = 'saved'; emit() }
543
569
  if (entry.prepared) { journal.accept(entry); return }
544
570
  if (entry.index < entry.events.length) await journal.write(entry)
545
571
  }
546
- entry.status = 'saved'; emit()
572
+ entry.deliveryConfirmed = true; entry.status = 'saved'; emit()
573
+ await journal.write(entry)
547
574
  cleanupEntry(entry)
548
575
  } catch (cause) {
549
576
  // Pauses can surface at any stage (text publication, file authorization
550
577
  // or chunk delivery). Normalize all of them into the retryable path.
551
- const error = cause?.retryWhenAvailable ? cause : (isUnavailableSend(cause) ? unavailableError(cause, controller.signal) : cause)
578
+ const error = !available || closed ? unavailableError(cause) : cause?.retryWhenAvailable ? cause : (isUnavailableSend(cause) ? unavailableError(cause, controller.signal) : cause)
552
579
  if (cancelled.has(entry.id)) {
553
580
  entry.cancelled = true
554
581
  if (entry.prepared) { await journal.write(entry); journal.wake() } else { await storage.remove(entry.id); entries.delete(entry.id) }
555
582
  } else {
556
- entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
583
+ if (!entry.deliveryConfirmed) entry.status = error.retryWhenAvailable ? 'pending' : 'error'
584
+ entry.failed = true; entry.retryable = retryable(error)
557
585
  await journal.write(entry).catch(reportError)
558
586
  if (entry.peer !== owner && error.retryWhenAvailable && messenger?.readStatus().paused) pausedSends.add(entry.id)
559
587
  else if (isUnavailableSend(cause) && cause.pauseReasons?.length && available && !closed) {
@@ -566,8 +594,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
566
594
  // The failed wire event may be a quote or file chunk. Report the
567
595
  // owning outbox item separately, without mutating the native error.
568
596
  if (error.retryWhenOnline) offlineSends.add(entry.id)
569
- if (entry.failed && error.retryWhenAvailable && !pausedSends.has(entry.id)) scheduleSendRetry()
570
- if (!closed && !entry.deletion && !cancelled.has(entry.id) && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
597
+ if (entry.failed && (error.retryWhenAvailable || (entry.deliveryConfirmed && entry.retryable)) && !pausedSends.has(entry.id)) scheduleSendRetry()
598
+ if (!closed && !entry.deletion && !cancelled.has(entry.id) && !entry.deliveryConfirmed && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
571
599
  }
572
600
  emit()
573
601
  } finally { sendControllers.delete(entry.id); releaseForeground() }
@@ -583,7 +611,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
583
611
  clearSendRetry()
584
612
  sendRetryDelay = SEND_RETRY_MIN_MS
585
613
  }
586
- const pendingRetryable = entry => Boolean(entry.failed && !cancelled.has(entry.id) && (entry.retryable || entry.status === 'pending'))
614
+ const pendingRetryable = entry => Boolean(!cancelled.has(entry.id) && ((entry.localFailed && entry.localRetryable) || (entry.failed && (entry.retryable || entry.status === 'pending'))))
587
615
  function scheduleSendRetry () {
588
616
  if (closed || !available || sendRetryTimer) return
589
617
  const delay = Math.min(SEND_RETRY_MAX_MS, sendRetryDelay * (0.8 + _random() * 0.4))
@@ -593,13 +621,15 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
593
621
  if (closed || !available) return
594
622
  let retried = false
595
623
  for (const entry of entries.values()) {
596
- if (!pendingRetryable(entry) || pausedSends.has(entry.id)) continue
624
+ if (!pendingRetryable(entry)) continue
625
+ if (entry.localFailed && entry.localRetryable) { localRetries.add(entry.id); retried = true }
626
+ if (pausedSends.has(entry.id) || !entry.failed || !(entry.retryable || entry.status === 'pending')) continue
597
627
  entry.failed = false
598
628
  attemptStarts.set(entry.id, performance.now())
599
629
  if (entry.status === 'saved') { cleanupEntry(entry); continue }
600
630
  retried = true
601
631
  }
602
- if (retried) pump()
632
+ if (retried) { emit(); pump() }
603
633
  }, delay)
604
634
  sendRetryTimer.unref?.()
605
635
  }
@@ -654,14 +684,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
654
684
  relocal = false
655
685
  for (const entry of entries.values()) {
656
686
  if (closed || !available) break
657
- if (entry.failed || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) continue
658
- if (entry.status === 'saved') { cleanupEntry(entry); continue }
659
- if (localReady.has(entry.id) && entry.peer !== owner) continue
660
- await sendEntry(entry, true)
687
+ if ((entry.localFailed && !localRetries.has(entry.id)) || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) continue
688
+ if (localComplete(entry) && !entry.localFailed) { if (entry.status === 'saved') cleanupEntry(entry); continue }
689
+ await saveEntry(entry)
661
690
  pumpRemote()
662
691
  }
663
692
  } while (relocal)
664
- } finally { localSaving = false; for (const resolve of localIdleWaiters) resolve(); localIdleWaiters.clear(); settled() }
693
+ } finally {
694
+ if ([...entries.values()].some(entry => (entry.localFailed && entry.localRetryable) || (entry.status === 'saved' && entry.failed && entry.retryable))) scheduleSendRetry()
695
+ localSaving = false; for (const resolve of localIdleWaiters) resolve(); localIdleWaiters.clear(); settled()
696
+ }
665
697
  }
666
698
  async function pumpRemote () {
667
699
  if (!available || closed || !storage || !messenger) return
@@ -673,7 +705,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
673
705
  for (const entry of entries.values()) {
674
706
  if (closed || !available) break
675
707
  if (entry.prepared?.accepted) { journal.wake(); continue }
676
- if (entry.peer === owner || entry.status === 'saved' || !localReady.has(entry.id) || !peers.has(entry.peer) || entry.failed || cancelled.has(entry.id)) continue
708
+ if (entry.peer === owner || entry.status === 'saved' || !peers.has(entry.peer) || entry.failed || cancelled.has(entry.id)) continue
677
709
  if (!preparation.get(entry.peer) && !preparation.error(entry.peer)) {
678
710
  preparation.request(entry.peer).catch(() => {})
679
711
  continue
@@ -803,15 +835,21 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
803
835
  else clearSendRetry()
804
836
  if (changed) lifecycle++
805
837
  if (!available) {
838
+ for (const controller of sendControllers.values()) controller.abort()
806
839
  journal.setAvailable(false)
807
840
  preparation.setAvailable(false)
808
841
  for (const peer of channels.keys()) if (!preparation.get(peer)) channels.delete(peer)
809
- inboxRecovery.stop(); await messenger?.pause('signer'); return
842
+ inboxRecovery.stop(); await messenger?.pause('signer')
843
+ if (localSaving) await new Promise(resolve => localIdleWaiters.add(resolve))
844
+ return
810
845
  }
811
846
  await ready()
812
847
  journal.setAvailable(true)
813
848
  pumpLocal()
814
- for (const entry of entries.values()) if (pendingRetryable(entry)) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) }
849
+ for (const entry of entries.values()) {
850
+ if (entry.localRetryable) localRetries.add(entry.id)
851
+ if (entry.failed && (entry.retryable || entry.status === 'pending')) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) }
852
+ }
815
853
  if (storageBlocked) inboxRecovery.start({ immediate: true })
816
854
  if (!changed && messenger && !configurationFailed) { preparation.setAvailable(true); pump(); return configuring }
817
855
  return schedule()
@@ -829,7 +867,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
829
867
  const id = getEventHash(main)
830
868
  const events = [...context.map(value => wireEvent(value, owner)), main]
831
869
  const files = events.filter(value => value.kind === 1063).map(value => ({ ...decodeFileMetadata(value), optional: !requiredFiles.includes(getEventHash(value)) })).filter(file => file.service === 'irfs').flatMap(file => [file, ...(file.thumbnail?.root ? [{ ...file.thumbnail, optional: file.optional }] : [])])
832
- const entry = { id, peer, event: { ...main, id }, events, files, index: 0, fileIndex: 0, chunkIndex: 0, localSaved: context.map(() => true).concat(false), status: 'pending', deletion }
870
+ const entry = { id, peer, event: { ...main, id }, events, files, index: 0, fileIndex: 0, chunkIndex: 0, localSaved: context.map(() => true).concat(false), status: 'pending', deliveryConfirmed: false, deletion }
833
871
  const startedAt = Date.now()
834
872
  debug(entry, 'outbox-start')
835
873
  await storage.put(entry)
@@ -852,6 +890,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
852
890
  await ready()
853
891
  const entry = entries.get(id)
854
892
  if (!entry) return
893
+ journal.retry()
894
+ if (entry.deliveryConfirmed) {
895
+ // This action belongs to local recovery, never a fresh network attempt.
896
+ if (entry.localFailed && !entry.localRetryable) return
897
+ if (entry.localFailed) localRetries.add(id)
898
+ entry.failed = false
899
+ resetSendRetry(); emit()
900
+ return pumpLocal()
901
+ }
902
+ if (entry.localFailed) localRetries.add(id)
855
903
  if (entry.peer !== owner && entry.status !== 'saved' && available) networkUrgency.set(id, _now() + 30000)
856
904
  // Renew while the old wait is still subscribed, preserving the monitor's
857
905
  // generation/backoff even if this session is its only consumer.
@@ -863,16 +911,17 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
863
911
  },
864
912
  async cancel (id) {
865
913
  cancelled.add(id)
914
+ localRetries.delete(id)
866
915
  sendControllers.get(id)?.abort()
867
916
  offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
868
917
  await ready()
869
918
  const entry = entries.get(id)
870
- if (entry?.prepared) { entry.cancelled = true; await journal.write(entry); journal.wake() } else { entries.delete(id); localReady.delete(id); await storage.remove(id) }
919
+ if (entry?.prepared) { entry.cancelled = true; await journal.write(entry); journal.wake() } else { entries.delete(id); await storage.remove(id) }
871
920
  emit()
872
921
  },
873
922
  close () {
874
923
  return (closeWork ??= (async () => {
875
- closed = true; available = false; lifecycle++
924
+ closed = true; available = false; lifecycle++; localRetries.clear()
876
925
  stopOnlineRetry(); offlineSends.clear(); networkUrgency.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
877
926
  clearSendRetry(); inboxRecovery.stop(); preparation.close(); foreground.close(); pausedSends.clear()
878
927
  for (const controller of sendControllers.values()) controller.abort()