libp2r2p 0.11.33 → 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
@@ -1295,3 +1295,15 @@ The 0.11.33 recovery adapter checks immutable wrapper mirrors without decrypting
1295
1295
  snapshots and serializes recovery mutations per event store. Session archives
1296
1296
  wait for foreground quiet for up to five seconds, execute one batch, then
1297
1297
  re-evaluate so pending recovery cannot monopolize local persistence.
1298
+
1299
+ The 0.11.34 recovery adapter marks paged event-store reads with the internal
1300
+ `recovery: true` hint so a compatible launcher can prioritize foreground writes
1301
+ over new background read transactions. `putMany` also accepts an optional
1302
+ development diagnostic callback with aggregate existing/inserted/skipped counts;
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.33",
3
+ "version": "0.11.35",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -30,10 +30,18 @@ without decrypting the archive; the store may also purge expired wrappers itself
30
30
  filter and the record coordinates as an OR filter. Existence checks compare the
31
31
  immutable wrapper `o` mirrors to the requested record IDs and do not decrypt or
32
32
  decode snapshots. Duplicate `putMany` inputs return their supplied immutable row.
33
+ - Recovery pages pass the optional internal `{ recovery: true }` query hint.
34
+ Launchers may use it to admit the readonly transaction as background without
35
+ granting an app elevated priority. Stores that do not recognize the hint keep
36
+ the previous query behavior.
37
+ - `putMany(rows, { onDiagnostic })` accepts an optional development callback. One
38
+ sanitized report identifies row, unique, existing, inserted, blocked, expired,
39
+ stored and skipped counts after each batch. It never includes ciphertext,
40
+ decoded rows, record payloads or signer material.
33
41
  - Mutating recovery stores share one operation tail per event store, so seed and
34
- grant writes cannot overlap for the same store. Writes pass the internal
35
- `recovery: true` scheduling hint to `addPersonalCopy`; row-returning reads keep
36
- normal decrypt-and-validate behavior.
42
+ grant writes cannot overlap for the same store. Writes and paged reads pass the
43
+ internal `recovery: true` scheduling hint to the corresponding event-store
44
+ methods; row-returning reads keep normal decrypt-and-validate behavior.
37
45
  - `iterate` is an async generator. Queries can name `recordId`, `channelPubkey`,
38
46
  `receiverPubkey`, `since`/`until`, or the file tuple `controlChannelPubkey`,
39
47
  `fileChannelPubkey`, `receiverPubkey`, and `indices`. `signal` cancels reads.
@@ -7,6 +7,7 @@ export { encodeRecord as encodeRecoveryRecord, decodeRecord as decodeRecoveryRec
7
7
 
8
8
  const nowSeconds = () => Math.floor(Date.now() / 1000)
9
9
  const RECOVERY_BATCH_SIZE = 100
10
+ const RECOVERY_QUERY_OPTIONS = Object.freeze({ recovery: true })
10
11
  const mutationTails = new WeakMap()
11
12
  const chunks = values => Array.from({ length: Math.ceil(values.length / RECOVERY_BATCH_SIZE) }, (_, index) =>
12
13
  values.slice(index * RECOVERY_BATCH_SIZE, (index + 1) * RECOVERY_BATCH_SIZE))
@@ -32,7 +33,10 @@ async function * pages (eventStore, filter, signal) {
32
33
  let until = filter.until, excluded = []
33
34
  while (true) {
34
35
  signal?.throwIfAborted()
35
- const { results } = await eventStore.query({ ...filter, ...(until === undefined ? {} : { until }), ...(excluded.length ? { '!ids': excluded } : {}), limit: 128, search: 'sort:desc' })
36
+ const { results } = await eventStore.query(
37
+ { ...filter, ...(until === undefined ? {} : { until }), ...(excluded.length ? { '!ids': excluded } : {}), limit: 128, search: 'sort:desc' },
38
+ RECOVERY_QUERY_OPTIONS
39
+ )
36
40
  if (!results.length) return
37
41
  for (const event of results) { signal?.throwIfAborted(); yield event }
38
42
  const boundary = results.at(-1).created_at
@@ -131,10 +135,10 @@ export function createPersonalCopyRecoveryStorage ({ eventStore, signer, now = n
131
135
  }
132
136
  }
133
137
  function store (type) {
134
- async function putMany (rows) {
135
- return serializeMutation(eventStore, () => putManyNow(rows))
138
+ async function putMany (rows, options = {}) {
139
+ return serializeMutation(eventStore, () => putManyNow(rows, options))
136
140
  }
137
- async function putManyNow (rows) {
141
+ async function putManyNow (rows, { onDiagnostic } = {}) {
138
142
  check()
139
143
  if (!Array.isArray(rows)) throw new ValidationError('INVALID_RECOVERY_RECORDS')
140
144
  const encoded = rows.map(row => {
@@ -150,13 +154,35 @@ export function createPersonalCopyRecoveryStorage ({ eventStore, signer, now = n
150
154
  if (record) stored.set(recordId, { ...record.row, recordId })
151
155
  }
152
156
  }
157
+ const existingCount = stored.size
158
+ let insertedCount = 0
159
+ let blockedCount = 0
153
160
  for (const [recordId, record] of unique) {
154
161
  if (stored.has(recordId)) continue
155
162
  const saved = committed(await eventStore.addPersonalCopy(record.event, { context: '', recovery: true }))
156
163
  // A pending kind-5 tombstone can reject the insertion; do not cache it.
157
- if (saved.result.stored === false && saved.result.code === 'blocked') stored.set(recordId, undefined)
158
- else stored.set(recordId, { ...record.row, recordId })
164
+ if (saved.result.stored === false && saved.result.code === 'blocked') {
165
+ stored.set(recordId, undefined)
166
+ blockedCount++
167
+ } else {
168
+ stored.set(recordId, { ...record.row, recordId })
169
+ insertedCount++
170
+ }
159
171
  }
172
+ const expiredCount = encoded.filter(record => record === null).length
173
+ try {
174
+ onDiagnostic?.({
175
+ type,
176
+ rowCount: rows.length,
177
+ uniqueCount: unique.size,
178
+ existingCount,
179
+ insertedCount,
180
+ blockedCount,
181
+ expiredCount,
182
+ storedCount: existingCount + insertedCount,
183
+ skippedCount: existingCount + blockedCount + expiredCount
184
+ })
185
+ } catch {}
160
186
  return encoded.map(record => record ? stored.get(record.recordId) : undefined)
161
187
  }
162
188
  async function hasMany (rows) {
@@ -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
@@ -270,6 +276,10 @@ event/peer identifiers and elapsed milliseconds for admission, local processing,
270
276
  channel preparation, publication acceptance and cleanup. Callback failures never
271
277
  change delivery. Connect only in development; never log events or crypto buffers.
272
278
  Custom outbox factories also receive this optional callback for storage timings.
279
+ Recovery archive completion also reports `publicationId`, one aggregate
280
+ `recovery-archive-result` per `putMany`, and sanitized row/unique/existing/
281
+ inserted/blocked/expired/stored/skipped counts. A single-row batch may include
282
+ its opaque `seedRecordId`.
273
283
 
274
284
  ## Durable prepared publications
275
285
 
@@ -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
  })))
@@ -175,15 +181,40 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
175
181
  throw error
176
182
  }
177
183
  if (closed) return
184
+ const archiveStartedAt = Date.now()
178
185
  debug(entry, 'recovery-archive-window', waitedAt, { foregroundIdle: idle, rowCount: rows.length })
186
+ let archived
187
+ let diagnosticReported = false
188
+ const onDiagnostic = details => {
189
+ diagnosticReported = true
190
+ debug(entry, 'recovery-archive-result', archiveStartedAt, {
191
+ publicationId: entry.prepared.publication.events[0].id,
192
+ ...details
193
+ })
194
+ }
179
195
  if (recoveryStorage?.seeds) {
180
- if (typeof recoveryStorage.seeds.putMany === 'function') await recoveryStorage.seeds.putMany(rows)
181
- else for (const row of rows) await recoveryStorage.seeds.put(row)
196
+ if (typeof recoveryStorage.seeds.putMany === 'function') archived = await recoveryStorage.seeds.putMany(rows, { onDiagnostic })
197
+ else {
198
+ archived = []
199
+ for (const row of rows) archived.push(await recoveryStorage.seeds.put(row))
200
+ }
182
201
  } else if (entry.prepared.target.file) {
183
202
  for (const seed of entry.prepared.publication.seeds) await fileTransfers.archiveSeed(entry.prepared.target.file, seed)
184
203
  } else {
185
204
  for (const seed of entry.prepared.publication.seeds) await messenger.enqueueSeed(seed.channelPubkey, seed)
186
205
  }
206
+ if (!diagnosticReported) {
207
+ const results = Array.isArray(archived) ? archived : []
208
+ const storedCount = results.filter(Boolean).length
209
+ debug(entry, 'recovery-archive-result', archiveStartedAt, {
210
+ publicationId: entry.prepared.publication.events[0].id,
211
+ rowCount: rows.length,
212
+ uniqueCount: new Set(rows.map(row => row.recordId).filter(Boolean)).size,
213
+ storedCount,
214
+ skippedCount: results.length ? results.length - storedCount : undefined,
215
+ ...(rows.length === 1 && rows[0].recordId ? { seedRecordId: rows[0].recordId } : {})
216
+ })
217
+ }
187
218
  },
188
219
  changed: entry => {
189
220
  watchOfflineSends()
@@ -459,6 +490,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
459
490
  await assertMessagePublished(report, event)
460
491
  }
461
492
  async function cleanupEntry (entry) {
493
+ if (unadmitted.has(entry)) return
462
494
  if (entry.prepared) { journal.wake(); return }
463
495
  if (!entry.cancelled && !completed(entry)) return
464
496
  if (entry.failed && entry.status === 'saved') return
@@ -478,10 +510,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
478
510
  return work
479
511
  }
480
512
  async function saveEntry (entry) {
481
- if (entry.peer === owner) { entry.status = 'pending'; emit() }
513
+ if (entry.peer === owner && !unadmitted.has(entry)) { entry.status = 'pending'; emit() }
482
514
  const releaseForeground = foreground.enter()
483
515
  const active = async () => {
484
- 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() }
485
518
  if (closed || !available || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) throw new Error('CHAT_UNAVAILABLE')
486
519
  }
487
520
  const startedAt = Date.now()
@@ -499,12 +532,18 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
499
532
  }
500
533
  entry.localFailed = false
501
534
  debug(entry, 'local-stored', startedAt)
535
+ if (unadmitted.has(entry)) return
502
536
  if (entry.peer === owner) entry.status = 'saved'
503
537
  emit()
504
538
  if (completed(entry)) cleanupEntry(entry)
505
539
  else await journal.write(entry)
506
540
  } catch (error) {
541
+ if (admissionFailed.has(entry)) return
507
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
+ }
508
547
  entry.localFailed = true; entry.localRetryable = retryable(error)
509
548
  if (entry.peer === owner) { entry.status = 'error'; entry.retryable = entry.localRetryable }
510
549
  await journal.write(entry).catch(reportError)
@@ -628,7 +667,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
628
667
  clearSendRetry()
629
668
  sendRetryDelay = SEND_RETRY_MIN_MS
630
669
  }
631
- 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'))))
632
671
  function scheduleSendRetry () {
633
672
  if (closed || !available || sendRetryTimer) return
634
673
  let nextRetryAt = 0
@@ -716,14 +755,21 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
716
755
  relocal = false
717
756
  for (const entry of entries.values()) {
718
757
  if (closed || !available) break
758
+ if (admissionFailed.has(entry)) continue
719
759
  if ((entry.localFailed && !localRetries.has(entry.id)) || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) continue
720
- if (localComplete(entry) && !entry.localFailed) { if (entry.status === 'saved') cleanupEntry(entry); continue }
721
- 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) }
722
768
  pumpRemote()
723
769
  }
724
770
  } while (relocal)
725
771
  } finally {
726
- 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()
727
773
  localSaving = false; for (const resolve of localIdleWaiters) resolve(); localIdleWaiters.clear(); settled()
728
774
  }
729
775
  }
@@ -736,7 +782,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
736
782
  repump = false
737
783
  for (const entry of entries.values()) {
738
784
  if (closed || !available) break
739
- if (entry.prepared?.accepted) { journal.wake(); continue }
785
+ if (unadmitted.has(entry) || entry.prepared?.accepted) { if (entry.prepared?.accepted) journal.wake(); continue }
740
786
  if (entry.peer === owner || entry.status === 'saved' || !peers.has(entry.peer) || entry.failed || cancelled.has(entry.id)) continue
741
787
  if (!preparation.get(entry.peer) && !preparation.error(entry.peer)) {
742
788
  preparation.request(entry.peer).catch(() => {})
@@ -754,6 +800,12 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
754
800
  }
755
801
  }
756
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
+ }
757
809
  const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
758
810
  async function download (file, options = {}) {
759
811
  if (file.peer === owner) return file
@@ -879,7 +931,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
879
931
  journal.setAvailable(true)
880
932
  pumpLocal()
881
933
  for (const entry of entries.values()) {
882
- if (entry.localRetryable) localRetries.add(entry.id)
934
+ if (!unadmitted.has(entry) && entry.localRetryable) localRetries.add(entry.id)
883
935
  if (entry.failed && (entry.retryable || entry.status === 'pending')) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) }
884
936
  }
885
937
  if (storageBlocked) inboxRecovery.start({ immediate: true })
@@ -901,17 +953,45 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
901
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 }] : [])])
902
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 }
903
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
+ }
904
963
  debug(entry, 'outbox-start')
905
- await storage.put(entry)
964
+ try {
965
+ await storage.put(entry)
966
+ } catch (error) {
967
+ if (earlyLocal) await discardUnadmitted(entry)
968
+ throw error
969
+ }
906
970
  debug(entry, 'outbox-stored', startedAt)
907
971
  queuedAt.set(entry, Date.now())
908
972
  if (attemptStartedAt !== undefined) attemptStarts.set(id, attemptStartedAt)
909
- 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
+ }
910
978
  // An accepted durable intent survives shutdown/locking for replay. Only
911
979
  // explicit cancellation removes it; no late callback or work is admitted.
912
- if (closed) return id
913
- 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)
914
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
+ }
915
995
  watchOfflineSends(); emit(); pump()
916
996
  if (available && peer !== owner) preparePeer(peer).catch(() => {})
917
997
  return id
@@ -921,7 +1001,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
921
1001
  if (storageBlocked) inboxRecovery.start({ immediate: true })
922
1002
  await ready()
923
1003
  const entry = entries.get(id)
924
- if (!entry) return
1004
+ if (!entry || unadmitted.has(entry)) return
925
1005
  journal.retry()
926
1006
  if (entry.deliveryConfirmed) {
927
1007
  // This action belongs to local recovery, never a fresh network attempt.