libp2r2p 0.11.34 → 0.11.35

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
@@ -1301,3 +1301,9 @@ The 0.11.34 recovery adapter marks paged event-store reads with the internal
1301
1301
  over new background read transactions. `putMany` also accepts an optional
1302
1302
  development diagnostic callback with aggregate existing/inserted/skipped counts;
1303
1303
  it never exposes ciphertext or record payloads.
1304
+
1305
+ The 0.11.35 session lets self-chat local persistence and its initial encrypted
1306
+ outbox checkpoint run concurrently. The entry remains hidden and ineligible for
1307
+ saved/publication/cleanup until admission; remote entries keep checkpoint-first
1308
+ ordering. A failed local commit after admission remains retryable through the
1309
+ existing outbox path.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.34",
3
+ "version": "0.11.35",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -251,13 +251,19 @@ creates a fresh publication lifetime, without treating the pause as a user abort
251
251
  ## Independent local persistence and publication
252
252
 
253
253
  One encrypted outbox feeds two independent serial processors. Local persistence
254
- never waits for peer preparation or an in-flight publication. After the initial encrypted intent commits, remote preparation and publication
255
- never wait for event-store persistence. Only the exact prepared-envelope
256
- checkpoint gates transmission. Text continues passing uploads between chunks;
257
- its local stage need not have completed. Both stages use the admitted inner ID.
258
-
259
- Self entries emit `status: 'saved'` as soon as local persistence succeeds. They
260
- do not write a redundant localSaved checkpoint before removal. Remote entries
254
+ never waits for peer preparation or an in-flight publication. Remote entries
255
+ join those processors only after their initial encrypted intent commits, while
256
+ self entries may start local persistence alongside that checkpoint. An
257
+ in-flight self entry remains internal until admission: it is absent from
258
+ `onOutbox`, cannot publish or clean up, and cannot report saved. Remote
259
+ preparation and publication never wait for event-store persistence. Only the
260
+ exact prepared-envelope checkpoint gates transmission. Text continues passing
261
+ uploads between chunks; its local stage need not have completed. Both stages use
262
+ the admitted inner ID.
263
+
264
+ Self entries emit `status: 'saved'` once local persistence succeeds and the
265
+ initial encrypted checkpoint admits the entry. They do not write a redundant
266
+ localSaved checkpoint before removal. Remote entries
261
267
  emit that status after the first valid relay ACK for every required publication,
262
268
  without waiting for redundant relays. Outbox cleanup runs independently; failures
263
269
  retain saved status and retry only removal with the session backoff, never
@@ -67,6 +67,12 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
67
67
  const channels = new Map()
68
68
  const entries = new Map()
69
69
  const cancelled = new Set()
70
+ // Self entries can persist locally while their encrypted checkpoint is in
71
+ // flight, but they are not visible to consumers until that checkpoint wins.
72
+ const unadmitted = new WeakSet()
73
+ const admissionFailed = new WeakSet()
74
+ const localWorks = new WeakMap()
75
+ const localErrors = new WeakMap()
70
76
  const attemptStarts = new Map()
71
77
  const networkUrgency = new Map()
72
78
  const sendControllers = new Map()
@@ -106,14 +112,14 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
106
112
  for (const entry of entries.values()) {
107
113
  if (entry.peer !== owner && entry.failed && entry.status === 'pending') pausedSends.add(entry.id)
108
114
  }
109
- if (![...entries.values()].some(entry => (entry.localFailed && entry.localRetryable) || (pendingRetryable(entry) && !pausedSends.has(entry.id)))) clearSendRetry()
115
+ if (![...entries.values()].some(entry => !unadmitted.has(entry) && ((entry.localFailed && entry.localRetryable) || (pendingRetryable(entry) && !pausedSends.has(entry.id))))) clearSendRetry()
110
116
  watchOfflineSends()
111
117
  return
112
118
  }
113
119
  clearSendRetry()
114
120
  for (const id of pausedSends) { const entry = entries.get(id); if (entry) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) } }
115
121
  pausedSends.clear()
116
- if ([...entries.values()].some(entry => entry.localFailed && entry.localRetryable)) scheduleSendRetry()
122
+ if ([...entries.values()].some(entry => !unadmitted.has(entry) && entry.localFailed && entry.localRetryable)) scheduleSendRetry()
117
123
  watchOfflineSends()
118
124
  // The callback can run inside construction/resume, before configure settles.
119
125
  queueMicrotask(() => pump())
@@ -158,8 +164,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
158
164
  const idleWaiters = new Set()
159
165
  const settled = () => { if (!sending && !localSaving && !enqueuing && !draining && !cleanups.size) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
160
166
  const localComplete = entry => entry.localSaved.every(Boolean)
161
- const completed = entry => localComplete(entry) && (entry.peer === owner || entry.deliveryConfirmed)
162
- const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion && !entry.cancelled).map(entry => ({
167
+ const completed = entry => !unadmitted.has(entry) && localComplete(entry) && (entry.peer === owner || entry.deliveryConfirmed)
168
+ const emit = () => onOutbox([...entries.values()].filter(entry => !unadmitted.has(entry) && !entry.deletion && !entry.cancelled).map(entry => ({
163
169
  ...entry,
164
170
  localPersistence: { state: localComplete(entry) ? 'saved' : entry.localFailed ? 'failed' : 'pending', retryable: Boolean(entry.localFailed && entry.localRetryable && !localRetries.has(entry.id)) }
165
171
  })))
@@ -484,6 +490,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
484
490
  await assertMessagePublished(report, event)
485
491
  }
486
492
  async function cleanupEntry (entry) {
493
+ if (unadmitted.has(entry)) return
487
494
  if (entry.prepared) { journal.wake(); return }
488
495
  if (!entry.cancelled && !completed(entry)) return
489
496
  if (entry.failed && entry.status === 'saved') return
@@ -503,10 +510,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
503
510
  return work
504
511
  }
505
512
  async function saveEntry (entry) {
506
- if (entry.peer === owner) { entry.status = 'pending'; emit() }
513
+ if (entry.peer === owner && !unadmitted.has(entry)) { entry.status = 'pending'; emit() }
507
514
  const releaseForeground = foreground.enter()
508
515
  const active = async () => {
509
- if (storage.has && !await storage.has(entry.id)) { cancelled.add(entry.id); sendControllers.get(entry.id)?.abort() }
516
+ if (admissionFailed.has(entry)) throw new Error('CHAT_UNAVAILABLE')
517
+ if (!unadmitted.has(entry) && storage.has && !await storage.has(entry.id)) { cancelled.add(entry.id); sendControllers.get(entry.id)?.abort() }
510
518
  if (closed || !available || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) throw new Error('CHAT_UNAVAILABLE')
511
519
  }
512
520
  const startedAt = Date.now()
@@ -524,12 +532,18 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
524
532
  }
525
533
  entry.localFailed = false
526
534
  debug(entry, 'local-stored', startedAt)
535
+ if (unadmitted.has(entry)) return
527
536
  if (entry.peer === owner) entry.status = 'saved'
528
537
  emit()
529
538
  if (completed(entry)) cleanupEntry(entry)
530
539
  else await journal.write(entry)
531
540
  } catch (error) {
541
+ if (admissionFailed.has(entry)) return
532
542
  if (cancelled.has(entry.id)) { sendControllers.get(entry.id)?.abort(); entry.cancelled = true; cleanupEntry(entry) } else if (!closed && available) {
543
+ if (unadmitted.has(entry)) {
544
+ entry.localFailed = true; entry.localRetryable = retryable(error); localErrors.set(entry, error)
545
+ return
546
+ }
533
547
  entry.localFailed = true; entry.localRetryable = retryable(error)
534
548
  if (entry.peer === owner) { entry.status = 'error'; entry.retryable = entry.localRetryable }
535
549
  await journal.write(entry).catch(reportError)
@@ -653,7 +667,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
653
667
  clearSendRetry()
654
668
  sendRetryDelay = SEND_RETRY_MIN_MS
655
669
  }
656
- const pendingRetryable = entry => Boolean(!cancelled.has(entry.id) && ((entry.localFailed && entry.localRetryable) || (entry.failed && (entry.retryable || entry.status === 'pending'))))
670
+ const pendingRetryable = entry => Boolean(!unadmitted.has(entry) && !cancelled.has(entry.id) && ((entry.localFailed && entry.localRetryable) || (entry.failed && (entry.retryable || entry.status === 'pending'))))
657
671
  function scheduleSendRetry () {
658
672
  if (closed || !available || sendRetryTimer) return
659
673
  let nextRetryAt = 0
@@ -741,14 +755,21 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
741
755
  relocal = false
742
756
  for (const entry of entries.values()) {
743
757
  if (closed || !available) break
758
+ if (admissionFailed.has(entry)) continue
744
759
  if ((entry.localFailed && !localRetries.has(entry.id)) || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) continue
745
- if (localComplete(entry) && !entry.localFailed) { if (entry.status === 'saved') cleanupEntry(entry); continue }
746
- await saveEntry(entry)
760
+ if (localComplete(entry) && !entry.localFailed) {
761
+ if (!unadmitted.has(entry) && entry.peer === owner && entry.status !== 'saved') await saveEntry(entry)
762
+ else if (entry.status === 'saved') cleanupEntry(entry)
763
+ continue
764
+ }
765
+ const work = saveEntry(entry)
766
+ localWorks.set(entry, work)
767
+ try { await work } finally { localWorks.delete(entry) }
747
768
  pumpRemote()
748
769
  }
749
770
  } while (relocal)
750
771
  } finally {
751
- if ([...entries.values()].some(entry => (entry.localFailed && entry.localRetryable) || (entry.status === 'saved' && entry.failed && entry.retryable))) scheduleSendRetry()
772
+ if ([...entries.values()].some(entry => !unadmitted.has(entry) && ((entry.localFailed && entry.localRetryable) || (entry.status === 'saved' && entry.failed && entry.retryable)))) scheduleSendRetry()
752
773
  localSaving = false; for (const resolve of localIdleWaiters) resolve(); localIdleWaiters.clear(); settled()
753
774
  }
754
775
  }
@@ -761,7 +782,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
761
782
  repump = false
762
783
  for (const entry of entries.values()) {
763
784
  if (closed || !available) break
764
- if (entry.prepared?.accepted) { journal.wake(); continue }
785
+ if (unadmitted.has(entry) || entry.prepared?.accepted) { if (entry.prepared?.accepted) journal.wake(); continue }
765
786
  if (entry.peer === owner || entry.status === 'saved' || !peers.has(entry.peer) || entry.failed || cancelled.has(entry.id)) continue
766
787
  if (!preparation.get(entry.peer) && !preparation.error(entry.peer)) {
767
788
  preparation.request(entry.peer).catch(() => {})
@@ -779,6 +800,12 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
779
800
  }
780
801
  }
781
802
  async function pump () { await Promise.all([pumpLocal(), pumpRemote()]) }
803
+ async function discardUnadmitted (entry) {
804
+ admissionFailed.add(entry)
805
+ unadmitted.delete(entry)
806
+ if (entries.get(entry.id) === entry) entries.delete(entry.id)
807
+ await localWorks.get(entry)
808
+ }
782
809
  const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
783
810
  async function download (file, options = {}) {
784
811
  if (file.peer === owner) return file
@@ -904,7 +931,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
904
931
  journal.setAvailable(true)
905
932
  pumpLocal()
906
933
  for (const entry of entries.values()) {
907
- if (entry.localRetryable) localRetries.add(entry.id)
934
+ if (!unadmitted.has(entry) && entry.localRetryable) localRetries.add(entry.id)
908
935
  if (entry.failed && (entry.retryable || entry.status === 'pending')) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) }
909
936
  }
910
937
  if (storageBlocked) inboxRecovery.start({ immediate: true })
@@ -926,17 +953,45 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
926
953
  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 }] : [])])
927
954
  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 }
928
955
  const startedAt = Date.now()
956
+ const earlyLocal = peer === owner
957
+ if (earlyLocal) {
958
+ queuedAt.set(entry, Date.now())
959
+ unadmitted.add(entry)
960
+ entries.set(id, entry)
961
+ pumpLocal()
962
+ }
929
963
  debug(entry, 'outbox-start')
930
- await storage.put(entry)
964
+ try {
965
+ await storage.put(entry)
966
+ } catch (error) {
967
+ if (earlyLocal) await discardUnadmitted(entry)
968
+ throw error
969
+ }
931
970
  debug(entry, 'outbox-stored', startedAt)
932
971
  queuedAt.set(entry, Date.now())
933
972
  if (attemptStartedAt !== undefined) attemptStarts.set(id, attemptStartedAt)
934
- if (cancelled.has(id)) { await storage.remove(id); throw new Error('CHAT_UNAVAILABLE') }
973
+ if (cancelled.has(id)) {
974
+ if (earlyLocal) await discardUnadmitted(entry)
975
+ await storage.remove(id)
976
+ throw new Error('CHAT_UNAVAILABLE')
977
+ }
935
978
  // An accepted durable intent survives shutdown/locking for replay. Only
936
979
  // explicit cancellation removes it; no late callback or work is admitted.
937
- if (closed) return id
938
- entries.set(id, entry)
980
+ if (closed) {
981
+ if (earlyLocal) await discardUnadmitted(entry)
982
+ return id
983
+ }
984
+ if (earlyLocal) unadmitted.delete(entry)
985
+ else entries.set(id, entry)
939
986
  if (peer !== owner && available && lifecycle === urgencyLifecycle) networkUrgency.set(id, urgencyUntil)
987
+ if (earlyLocal && entry.localFailed) {
988
+ entry.status = 'error'; entry.retryable = entry.localRetryable
989
+ const error = localErrors.get(entry)
990
+ if (error) {
991
+ reportError(error)
992
+ if (!entry.deletion) onSendError(error, { id: entry.id, peer: entry.peer })
993
+ }
994
+ }
940
995
  watchOfflineSends(); emit(); pump()
941
996
  if (available && peer !== owner) preparePeer(peer).catch(() => {})
942
997
  return id
@@ -946,7 +1001,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
946
1001
  if (storageBlocked) inboxRecovery.start({ immediate: true })
947
1002
  await ready()
948
1003
  const entry = entries.get(id)
949
- if (!entry) return
1004
+ if (!entry || unadmitted.has(entry)) return
950
1005
  journal.retry()
951
1006
  if (entry.deliveryConfirmed) {
952
1007
  // This action belongs to local recovery, never a fresh network attempt.