libp2r2p 0.11.24 → 0.11.25

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
@@ -633,9 +633,13 @@ tasks cannot deliver into a new channel generation. Mandatory persistence still
633
633
  owns its storage pause; incomplete history remains a pending recovery interval.
634
634
  Subscription setup is bounded separately from presence, so a slow announcement
635
635
  does not hold the other channels' readers. Sessions prioritize user persistence
636
- and publication before the next background presence signer call. Signing an outer
637
- envelope can overlap recovery-seed persistence, but no envelope is yielded for
638
- publication unless both succeed; only one outer is prepared at a time.
636
+ and publication before the next background presence signer call. `onPreparedSeed`
637
+ receives its existing router/jsonl fields plus `publication`, containing all
638
+ signed fragments, recipient/channel identities and recovery rows for that logical
639
+ event. The callback commits durable recovery before publication: a direct
640
+ messenger uses its seed store; the session uses its encrypted outbox journal and
641
+ archives indexed personal copies independently. No alternative legacy publisher
642
+ is retained. Only one logical event/chunk is prepared per session entry at a time.
639
643
 
640
644
  `private-channel.fetch` reports incomplete reads as an `AggregateError` with
641
645
  stable `code: 'PRIVATE_CHANNEL_FETCH_INCOMPLETE'`. Its message names relays,
@@ -1241,5 +1245,9 @@ Private messenger/session constructors accept optional `fallbackDelayMs` (`null`
1241
1245
  by default). Automatic NIP-65 sends can start configured fallback relays after
1242
1246
  the chosen delay while primary publications retain their deadlines. Explicit
1243
1247
  routes keep sequential fallback; every encrypted recipient must be covered.
1248
+ Session `enqueue` accepts optional monotonic `attemptStartedAt`; supplied values
1249
+ make preparation consume that delay. Retries/reloads start a fresh attempt and
1250
+ uploads budget each chunk independently. Monotonic clocks never enter storage or
1251
+ Nostr events. Omitted timestamps keep the publication-local delay.
1244
1252
  `RelayPool.sendEvent` accepts a consumer `signal` without closing shared sockets.
1245
1253
  See [fallback and cancellation](private-messenger/session/README.md#relay-fallback).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.24",
3
+ "version": "0.11.25",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -143,6 +143,7 @@ async function prepareRoutedMessage ({ senderSigner, imkcSigner, privateChannelS
143
143
  channelPubkey,
144
144
  channelReaderPubkey,
145
145
  preparedRows,
146
+ innerEventId: getEventHash({ ...event, pubkey: event.pubkey || senderPubkey }),
146
147
  imkcPubkey,
147
148
  imkcProof
148
149
  }
@@ -152,69 +153,74 @@ async function prepareRoutedMessage ({ senderSigner, imkcSigner, privateChannelS
152
153
  }
153
154
  }
154
155
 
155
- async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, expirationSeconds = EXPIRATION_SECONDS, context }) {
156
- const routerSeckey = generateSecretKey()
157
- const routerPubkey = getPublicKey(routerSeckey)
158
- const receiverPubkeyList = receiverPubkeys(receivers)
159
- const routerReceiverTag = receiverTag ?? (receiverPubkeyList.length === 1 ? receiverPubkeyList[0] : '')
160
- const rowIndexes = preparedRowIndexesForReceivers(context.preparedRows, receivers)
161
- const total = preparedChunkCount(context.preparedRows, rowIndexes)
162
- const persistSeeds = async () => {
163
- if (!onPreparedSeed) return
164
- const router = makeRouterEvent({ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey, imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, chunkIndex: 0, chunkTotal: 1, fileChunkIndex, content: '' })
156
+ async function * wrapPreparedEvents ({ privateChannelSigner, senderSigner, event, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, expirationSeconds = EXPIRATION_SECONDS, context, preparedPublication }) {
157
+ let publication = preparedPublication
158
+ if (publication) {
159
+ const channelPubkey = await privateChannelSigner.getPublicKey()
160
+ const expected = receiverPubkeys(receivers).sort().join(',')
161
+ const innerEventId = getEventHash({ ...event, pubkey: event.pubkey || await senderSigner.getPublicKey() })
162
+ if (publication.innerEventId !== innerEventId || publication.channelPubkey !== channelPubkey || !Array.isArray(publication.receiverPubkeys) || publication.receiverPubkeys.slice().sort().join(',') !== expected ||
163
+ !Array.isArray(publication.events) || !publication.events.length || !Array.isArray(publication.seeds) ||
164
+ publication.events.some(event => !isValidEvent(event) || event.pubkey !== channelPubkey || event.kind !== PRIVATE_BROADCAST_KIND)) {
165
+ throw new ValidationError('INVALID_PREPARED_PUBLICATION')
166
+ }
167
+ if (publication.events.some(event => Number(event.tags.find(tag => tag[0] === 'expiration')?.[1]) <= nowSeconds())) throw new ValidationError('PREPARED_PUBLICATION_EXPIRED')
168
+ } else {
169
+ const routerSeckey = generateSecretKey()
170
+ const routerPubkey = getPublicKey(routerSeckey)
171
+ const receiverPubkeyList = receiverPubkeys(receivers)
172
+ const routerReceiverTag = receiverTag ?? (receiverPubkeyList.length === 1 ? receiverPubkeyList[0] : '')
173
+ const rowIndexes = preparedRowIndexesForReceivers(context.preparedRows, receivers)
174
+ const total = preparedChunkCount(context.preparedRows, rowIndexes)
175
+ const seedRouter = makeRouterEvent({ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey, imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, chunkIndex: 0, chunkTotal: 1, fileChunkIndex, content: '' })
165
176
  const payloadRow = readPreparedRow(context.preparedRows, 0)
166
- for (const rowIndex of rowIndexes) {
167
- await onPreparedSeed({ channelPubkey: context.channelPubkey, router, jsonl: `${payloadRow}\n${readPreparedRow(context.preparedRows, rowIndex)}\n` })
177
+ const seeds = rowIndexes.map(rowIndex => ({ channelPubkey: context.channelPubkey, router: seedRouter, jsonl: `${payloadRow}\n${readPreparedRow(context.preparedRows, rowIndex)}\n` }))
178
+ const events = []
179
+ let index = 0
180
+ for (const content of preparedChunks(context.preparedRows, rowIndexes)) {
181
+ const router = finalizeEvent(makeRouterEvent({
182
+ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey,
183
+ imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, fileChunkIndex,
184
+ chunkIndex: index++, chunkTotal: total, content
185
+ }), routerSeckey)
186
+ const createdAt = nowSeconds()
187
+ const outer = await privateChannelSigner.signEvent({
188
+ kind: PRIVATE_BROADCAST_KIND, created_at: createdAt,
189
+ tags: privateBroadcastTags({ deletionPubkey, createdAt, expirationSeconds }),
190
+ content: await nip44v3EncryptText(privateChannelSigner, context.channelReaderPubkey, PRIVATE_BROADCAST_KIND, JSON.stringify(router))
191
+ })
192
+ if (eventByteLength(outer) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
193
+ events.push(outer)
168
194
  }
195
+ publication = { innerEventId: context.innerEventId, channelPubkey: context.channelPubkey, receiverPubkeys: receiverPubkeyList, events, seeds }
169
196
  }
170
- let index = 0
171
- for (const content of preparedChunks(context.preparedRows, rowIndexes)) {
172
- const router = finalizeEvent(makeRouterEvent({
173
- pubkey: routerPubkey,
174
- senderPubkey: context.senderPubkey,
175
- imkcPubkey: context.imkcPubkey,
176
- imkcProof: context.imkcProof,
177
- receiverPubkey: routerReceiverTag,
178
- fileChunkIndex,
179
- chunkIndex: index++,
180
- chunkTotal: total,
181
- content
182
- }), routerSeckey)
183
- const createdAt = nowSeconds()
184
- const prepareOuter = async () => privateChannelSigner.signEvent({
185
- kind: PRIVATE_BROADCAST_KIND,
186
- created_at: createdAt,
187
- tags: privateBroadcastTags({ deletionPubkey, createdAt, expirationSeconds }),
188
- content: await nip44v3EncryptText(privateChannelSigner, context.channelReaderPubkey, PRIVATE_BROADCAST_KIND, JSON.stringify(router))
189
- })
190
- // These independent steps may run together, but no envelope is yielded
191
- // until every seed is durable. Settle both before releasing prepared rows.
192
- const [persistence, prepared] = await Promise.allSettled([index === 1 ? persistSeeds() : undefined, prepareOuter()])
193
- if (persistence.status === 'rejected') throw persistence.reason
194
- if (prepared.status === 'rejected') throw prepared.reason
195
- const outer = prepared.value
196
- if (eventByteLength(outer) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
197
- yield outer
197
+ // The callback commits recovery data before any bytes leave this publisher.
198
+ // A session commits its encrypted journal; a direct messenger commits seeds.
199
+ if (onPreparedSeed) {
200
+ for (const seed of publication.seeds) await onPreparedSeed({ ...seed, publication })
198
201
  }
202
+ yield * publication.events
199
203
  }
200
204
 
201
205
  // Streaming version of wrapEvent
202
- export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, expirationSeconds = EXPIRATION_SECONDS, temporaryStorageArea, _getIykcProofs = getIykcProofs }) {
206
+ export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, expirationSeconds = EXPIRATION_SECONDS, temporaryStorageArea, _getIykcProofs = getIykcProofs, preparedPublication }) {
203
207
  const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
204
- const context = await prepareRoutedMessage({
205
- senderSigner,
206
- imkcSigner,
207
- privateChannelSigner,
208
- privateChannelReaderPubkey,
209
- receivers,
210
- event,
211
- temporaryStorageArea,
212
- _getIykcProofs
213
- })
208
+ const context = preparedPublication
209
+ ? null
210
+ : await prepareRoutedMessage({
211
+ senderSigner,
212
+ imkcSigner,
213
+ privateChannelSigner,
214
+ privateChannelReaderPubkey,
215
+ receivers,
216
+ event,
217
+ temporaryStorageArea,
218
+ _getIykcProofs
219
+ })
214
220
  try {
215
- yield * wrapPreparedEvents({ privateChannelSigner, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context })
221
+ yield * wrapPreparedEvents({ privateChannelSigner, senderSigner, event, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context, preparedPublication })
216
222
  } finally {
217
- cleanupEnvelopeRows(context.preparedRows)
223
+ if (context) cleanupEnvelopeRows(context.preparedRows)
218
224
  }
219
225
  }
220
226
 
@@ -552,35 +558,37 @@ function withRecoveryRelays (relays, recoveryRelays) {
552
558
  return uniq([...(relays || []), ...(recoveryRelays || [])])
553
559
  }
554
560
 
555
- export async function publish ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs = getIykcProofs, _publish = sendToRelays }) {
561
+ export async function publish ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs = getIykcProofs, _publish = sendToRelays, preparedPublication }) {
556
562
  const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
557
563
  const results = []
558
564
  // Relays are grouped only when they have the exact same recipient pubkey set
559
565
  const groups = groupedRelayReceivers({ relayToReceivers, receivers })
560
566
  if (groups) {
561
- const context = await prepareRoutedMessage({
562
- senderSigner,
563
- imkcSigner,
564
- privateChannelSigner,
565
- privateChannelReaderPubkey,
566
- receivers,
567
- event,
568
- temporaryStorageArea,
569
- _getIykcProofs
570
- })
567
+ const context = preparedPublication
568
+ ? null
569
+ : await prepareRoutedMessage({
570
+ senderSigner,
571
+ imkcSigner,
572
+ privateChannelSigner,
573
+ privateChannelReaderPubkey,
574
+ receivers,
575
+ event,
576
+ temporaryStorageArea,
577
+ _getIykcProofs
578
+ })
571
579
  try {
572
580
  for (const group of groups) {
573
- for await (const wrappedEvent of wrapPreparedEvents({ privateChannelSigner, receivers: group.receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context })) {
581
+ for await (const wrappedEvent of wrapPreparedEvents({ privateChannelSigner, senderSigner, event, receivers: group.receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context, preparedPublication })) {
574
582
  results.push(await _publish(wrappedEvent, withRecoveryRelays(group.relays, recoveryRelays), { receiverPubkeys: receiverPubkeys(group.receivers), primaryRelays: group.relays }))
575
583
  }
576
584
  }
577
585
  } finally {
578
- cleanupEnvelopeRows(context.preparedRows)
586
+ if (context) cleanupEnvelopeRows(context.preparedRows)
579
587
  }
580
588
  return results
581
589
  }
582
590
 
583
- for await (const wrappedEvent of wrapEvents({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, event, expirationSeconds, temporaryStorageArea, _getIykcProofs })) {
591
+ for await (const wrappedEvent of wrapEvents({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, event, expirationSeconds, temporaryStorageArea, _getIykcProofs, preparedPublication })) {
584
592
  results.push(await _publish(wrappedEvent, withRecoveryRelays(relays, recoveryRelays), { receiverPubkeys: receiverPubkeys(receivers), primaryRelays: relays }))
585
593
  }
586
594
  return results
@@ -1,7 +1,7 @@
1
1
  import { normalizeRelayUrl } from '../url/index.js'
2
2
  import { isValidEvent } from '../event/index.js'
3
3
  import { ValidationError } from '../error/index.js'
4
- import { generateKeypair } from '../key/index.js'
4
+ import { generateKeypair, keypairFromSeckey } from '../key/index.js'
5
5
  import * as privateChannel from '../private-channel/index.js'
6
6
  import { normalizeRumor, deliveryInfo } from '../private-channel/helpers/rumor.js'
7
7
 
@@ -41,11 +41,19 @@ function normalizeDeletionPubkey (deletionPubkey) {
41
41
  return deletionPubkey.toLowerCase()
42
42
  }
43
43
 
44
- function resolveDeletionCapability ({ deletionPubkey, deletionSeckey, autoDeletionCapability = true }) {
44
+ function resolveDeletionCapability ({ deletionPubkey, deletionSeckey, autoDeletionCapability = true, preparedPublication }) {
45
45
  if (deletionSeckey !== undefined) throw new ValidationError('DELETION_SECKEY_NOT_ACCEPTED')
46
46
  if (typeof autoDeletionCapability !== 'boolean') throw new ValidationError('AUTO_DELETION_CAPABILITY_BOOLEAN_REQUIRED')
47
47
 
48
48
  const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
49
+ if (preparedPublication) {
50
+ const capability = preparedPublication.deletionCapability
51
+ if (!capability || (capability.deletionPubkey !== undefined && normalizeDeletionPubkey(capability.deletionPubkey) !== capability.deletionPubkey) ||
52
+ (normalizedDeletionPubkey && normalizedDeletionPubkey !== capability.deletionPubkey) ||
53
+ (capability.deletionSeckey !== undefined && keypairFromSeckey(capability.deletionSeckey).pubkey !== capability.deletionPubkey) ||
54
+ preparedPublication.events?.some(event => event.tags.find(tag => tag[0] === 's')?.[1] !== capability.deletionPubkey)) throw new ValidationError('INVALID_PREPARED_PUBLICATION')
55
+ return { ...capability }
56
+ }
49
57
  if (normalizedDeletionPubkey) return { deletionPubkey: normalizedDeletionPubkey }
50
58
  if (!autoDeletionCapability) return {}
51
59
 
@@ -53,6 +61,14 @@ function resolveDeletionCapability ({ deletionPubkey, deletionSeckey, autoDeleti
53
61
  return { deletionPubkey: pubkey, deletionSeckey: seckey }
54
62
  }
55
63
 
64
+ function preparedSeedHandler (onPreparedSeed, deletion) {
65
+ if (!onPreparedSeed) return undefined
66
+ return seed => {
67
+ seed.publication.deletionCapability = deletion
68
+ return onPreparedSeed(seed)
69
+ }
70
+ }
71
+
56
72
  function withDelivery (result, reports, deletionSeckey) {
57
73
  const delivery = { reports }
58
74
  if (deletionSeckey !== undefined) delivery.deletionSeckey = deletionSeckey
@@ -652,10 +668,11 @@ async function sendPrivateMessage ({
652
668
  _getIykcProofs,
653
669
  fileChunkIndex,
654
670
  onPreparedSeed,
671
+ preparedPublication,
655
672
  _publish = privateChannel.publish
656
673
  }) {
657
674
  if (!privateChannelSigner?.getPublicKey) throw new ValidationError('PRIVATE_CHANNEL_WRITER_REQUIRED')
658
- return _publish({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs })
675
+ return _publish({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, preparedPublication, _getIykcProofs })
659
676
  }
660
677
 
661
678
  async function sendNymMessage ({
@@ -860,13 +877,14 @@ export async function broadcastRumor ({
860
877
  autoDeletionCapability = true,
861
878
  fileChunkIndex,
862
879
  onPreparedSeed,
880
+ preparedPublication,
863
881
  _publish = privateChannel.publish
864
882
  }) {
865
883
  const receivers = uniq(receiverPubkeys)
866
884
  if (!receivers.length) throw new ValidationError('NO_RECEIVERS')
867
885
  const { event, wireEvent } = await makeOutgoingRumor({ senderSigner, rumor })
868
- const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
869
- const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
886
+ const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability, preparedPublication })
887
+ const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed: preparedSeedHandler(onPreparedSeed, deletion), preparedPublication, _getIykcProofs, _publish })
870
888
  return withDelivery({ rumor: event }, reports, deletion.deletionSeckey)
871
889
  }
872
890
 
@@ -888,13 +906,14 @@ export async function broadcastEvent ({
888
906
  autoDeletionCapability = true,
889
907
  fileChunkIndex,
890
908
  onPreparedSeed,
909
+ preparedPublication,
891
910
  _publish = privateChannel.publish
892
911
  }) {
893
912
  const receivers = uniq(receiverPubkeys)
894
913
  if (!receivers.length) throw new ValidationError('NO_RECEIVERS')
895
914
  const wireEvent = assertValidSignedEvent({ ...event, tags: cloneTags(event?.tags) })
896
- const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
897
- const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
915
+ const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability, preparedPublication })
916
+ const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed: preparedSeedHandler(onPreparedSeed, deletion), preparedPublication, _getIykcProofs, _publish })
898
917
  return withDelivery({ event: wireEvent }, reports, deletion.deletionSeckey)
899
918
  }
900
919
 
@@ -144,14 +144,14 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
144
144
  if (decoded.root !== file.root || (index !== undefined && decoded.index !== index) || (file.size !== undefined && (decoded.total !== Math.ceil(file.size / IRFS_CHUNK_BYTES) || (decoded.index === decoded.total - 1 && decoded.contentBytes.length !== file.size - decoded.index * IRFS_CHUNK_BYTES)))) throw new ValidationError('FILE_CHUNK_DESCRIPTOR_MISMATCH')
145
145
  return decoded
146
146
  }
147
- async function routing (file, receiverPubkey, signal) {
147
+ async function routing (file, receiverPubkey, signal, attemptStartedAt, onDebug) {
148
148
  const parent = messenger.requireWritableChannel(file.controlChannelPubkey)
149
- return messenger.resolveSendRouting({ channel: parent, receiverPubkeys: [receiverPubkey], signal })
149
+ return messenger.resolveSendRouting({ channel: parent, receiverPubkeys: [receiverPubkey], signal, attemptStartedAt, onDebug })
150
150
  }
151
151
  function sendOptions (file, routes) {
152
152
  return { senderSigner: messenger.userSigner, imkcSigner: messenger.contentKeySigner, privateChannelSigner: file.signer, ...routes, temporaryStorageArea: messenger.temporaryStorageArea, expirationSeconds: messenger.eventExpirationSecondsFor(file.controlChannelPubkey), _getIykcProofs: messenger.contentKeyLookup() }
153
153
  }
154
- async function publishChunk (descriptor, event, { signal } = {}) {
154
+ async function publishChunk (descriptor, event, { signal, attemptStartedAt, onPreparedSeed, preparedPublication, onDebug } = {}) {
155
155
  const file = await register(descriptor)
156
156
  usable(file)
157
157
  const decoded = checked(file, event)
@@ -162,7 +162,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
162
162
  }
163
163
  let result
164
164
  try {
165
- const options = { ...sendOptions(file, await routing(file, file.peerPubkey, signal)), receiverPubkeys: [file.peerPubkey], fileChunkIndex: decoded.index, ...(messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? { onPreparedSeed: seed => saveSeed(file, seed) } : {}) }
165
+ const options = { ...sendOptions(file, await routing(file, file.peerPubkey, signal, attemptStartedAt, onDebug)), receiverPubkeys: [file.peerPubkey], fileChunkIndex: decoded.index, preparedPublication, ...(onPreparedSeed ? { onPreparedSeed } : messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? { onPreparedSeed: seed => saveSeed(file, seed) } : {}) }
166
166
  result = event.sig ? await _messages.broadcastEvent({ ...options, event }) : await _messages.broadcastRumor({ ...options, rumor: event })
167
167
  await assertMessagePublished(result, result?.rumor || result?.event || { ...event, pubkey: event.pubkey || messenger.userPubkey })
168
168
  } catch (error) { await uploadLeases.get(file.root)?.(); uploadLeases.delete(file.root); throw error }
@@ -463,5 +463,5 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
463
463
  const timer = setInterval(() => { prune().catch(onError) }, 60000)
464
464
  timer.unref?.()
465
465
  messenger.extensions.add(extension)
466
- return { register, authorizeSeeding, publishChunk, download, readChunk: (root, index) => storage.read(root, index), stream: (descriptor, options = {}) => storage.stream ? storage.stream(descriptor, options) : chunkStream({ read: storage.read.bind(storage), ...descriptor, ...options }), close: extension.close, cancel (control, root) { const job = jobs.get(keyFor(control, root)); if (job) { job.pauseRequested = false; cancelJob(job) } }, observe (listener) { listeners.add(listener); for (const job of jobs.values()) emit(job); return () => listeners.delete(listener) } }
466
+ return { register, authorizeSeeding, publishChunk, archiveSeed: async (descriptor, seed) => saveSeed(await register(descriptor), seed), download, readChunk: (root, index) => storage.read(root, index), stream: (descriptor, options = {}) => storage.stream ? storage.stream(descriptor, options) : chunkStream({ read: storage.read.bind(storage), ...descriptor, ...options }), close: extension.close, cancel (control, root) { const job = jobs.get(keyFor(control, root)); if (job) { job.pauseRequested = false; cancelJob(job) } }, observe (listener) { listeners.add(listener); for (const job of jobs.values()) emit(job); return () => listeners.delete(listener) } }
467
467
  }
@@ -2,7 +2,7 @@ import { isReplaceableRelayFailure } from '../../relay/helpers/failure.js'
2
2
 
3
3
  // Each invocation owns one already signed outer event and its encrypted subset.
4
4
  // Lanes share coverage and the tried set; only started reports outlive the race.
5
- export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tried, nextPrimary, nextFallback, sendEvent, isOnline, isCurrent, signal, pauseSignal, remember, delay, now, setTimer, clearTimer }) {
5
+ export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tried, nextPrimary, nextFallback, sendEvent, isOnline, isCurrent, signal, pauseSignal, remember, delay, attemptStartedAt, monotonicNow = () => performance.now(), now, setTimer, clearTimer }) {
6
6
  const pending = new Set(receivers)
7
7
  const summaries = []
8
8
  const transportSignal = signal && pauseSignal ? AbortSignal.any([signal, pauseSignal]) : signal || pauseSignal
@@ -120,7 +120,8 @@ export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tr
120
120
  }
121
121
  if (transportSignal?.aborted || !isCurrent()) { finish(false); return outcome.promise }
122
122
  transportSignal?.addEventListener('abort', interrupted, { once: true })
123
- const deadline = now() + delay
123
+ const remaining = attemptStartedAt === undefined ? delay : Math.max(0, delay - Math.max(0, monotonicNow() - attemptStartedAt))
124
+ const deadline = now() + remaining
124
125
  const failed = reason => {
125
126
  if (done) return
126
127
  done = true; clearTimer(timer); timer = null
@@ -32,10 +32,11 @@ function routeGroups (routes) {
32
32
  return [...groups.values()]
33
33
  }
34
34
 
35
- export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey, primaryRelays, primaryRelayToReceivers, fallbackRelays = [], fallbackDelayMs = null, exclusions, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, pauseSignal, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
35
+ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey, primaryRelays, primaryRelayToReceivers, fallbackRelays = [], fallbackDelayMs = null, exclusions, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, pauseSignal, attemptStartedAt, monotonicNow = () => performance.now(), now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
36
36
  // Keep discovery's defaults when NIP-65 is unavailable. Explicit maps/lists
37
37
  // retain their fanout; configured fallbacks are a separate last-resort stage.
38
38
  fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
39
+ if (attemptStartedAt !== undefined && (!Number.isFinite(attemptStartedAt) || attemptStartedAt < 0)) throw new ValidationError('INVALID_ATTEMPT_STARTED_AT')
39
40
  const transportSignal = signal && pauseSignal ? AbortSignal.any([signal, pauseSignal]) : signal || pauseSignal
40
41
  const explicit = Boolean(primaryRelays || primaryRelayToReceivers)
41
42
  const primaryRoutes = primaryRelayToReceivers || (primaryRelays
@@ -98,7 +99,7 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
98
99
  event, receivers, first: first.length ? { receivers, relays: first } : null, mirrors, tried,
99
100
  nextPrimary: pending => pick(pending, false), nextFallback: pending => pick(pending, true),
100
101
  sendEvent, isOnline, isCurrent, signal, pauseSignal, remember,
101
- delay: fallbackDelayMs, now, setTimer, clearTimer
102
+ delay: fallbackDelayMs, attemptStartedAt, monotonicNow, now, setTimer, clearTimer
102
103
  })
103
104
  if (!result.success && current()) exclusions.clear()
104
105
  return result
@@ -233,6 +233,7 @@ export class PrivateMessenger {
233
233
  messageQueueMaxBytes = DEFAULT_MESSAGE_QUEUE_MAX_BYTES,
234
234
  seedQueueMaxBytes = DEFAULT_SEED_QUEUE_MAX_BYTES,
235
235
  seedStorage,
236
+ _pendingSeedRecords = () => [],
236
237
  temporaryStorageArea = globalThis.sessionStorage,
237
238
  autoDeletionCapability = true,
238
239
  _indexedDB = globalThis.indexedDB,
@@ -290,6 +291,7 @@ export class PrivateMessenger {
290
291
  this.messageQueueMaxBytes = messageQueueMaxBytes
291
292
  this.seedQueueMaxBytes = seedQueueMaxBytes
292
293
  this.seedStorage = seedStorage
294
+ this._pendingSeedRecords = _pendingSeedRecords
293
295
  this.temporaryStorageArea = temporaryStorageArea
294
296
  this.autoDeletionCapability = normalizeAutoDeletionCapability(autoDeletionCapability)
295
297
  this._indexedDB = _indexedDB
@@ -887,7 +889,8 @@ export class PrivateMessenger {
887
889
  return uniq([...primary, ...this.fallbackRelays].map(normalizeRelayUrl))
888
890
  }
889
891
 
890
- async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers, signal }) {
892
+ async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers, signal, attemptStartedAt, onDebug }) {
893
+ if (attemptStartedAt !== undefined && (!Number.isFinite(attemptStartedAt) || attemptStartedAt < 0)) throw new ValidationError('INVALID_ATTEMPT_STARTED_AT')
891
894
  const recoveryRelays = await this.recoveryMirrorRelays(channel.pubkey)
892
895
  if (relayToReceivers && !this.fallbackRelays.length) return { relayToReceivers, recoveryRelays }
893
896
  const fixed = relayToReceivers ? null : relays?.length ? uniq(relays) : channel.sendRelays.length ? channel.sendRelays : channel.relays.length ? channel.relays : null
@@ -903,12 +906,17 @@ export class PrivateMessenger {
903
906
  peers: fixed && !recipients.length ? [this.userPubkey] : recipients, relaysByPubkey, recoveryRelays, primaryRelays: fixed?.map(normalizeRelayUrl), primaryRelayToReceivers: relayToReceivers, fallbackRelays: this.fallbackRelays,
904
907
  signal: signal ? AbortSignal.any([this.sendRoutingLifetime.signal, signal]) : this.sendRoutingLifetime.signal,
905
908
  exclusions: this.sendRelayExclusions.get(key).exclusions,
906
- fallbackDelayMs: this.fallbackDelayMs, pauseSignal: this.sendRoutingPause.signal,
909
+ fallbackDelayMs: this.fallbackDelayMs, attemptStartedAt, pauseSignal: this.sendRoutingPause.signal,
907
910
  setTimer: this._setTimeout, clearTimer: this._clearTimeout,
908
911
  pickRelays: this._pickRelaysForPubkeys,
909
912
  publish: this._privateChannel.publish || privateChannel.publish,
910
913
  publishNymEvent: this._privateChannel.publishNymEvent || privateChannel.publishNymEvent,
911
- sendEvent: (...args) => relayPool.sendEvent(...args),
914
+ sendEvent: async (...args) => {
915
+ const startedAt = onDebug ? performance.now() : 0
916
+ const result = await relayPool.sendEvent(...args)
917
+ if (result.success && onDebug) { try { onDebug({ stage: 'first-ack', publicationId: args[0].id, elapsedMs: performance.now() - startedAt }) } catch {} }
918
+ return result
919
+ },
912
920
  isOnline: this._isOnline,
913
921
  isCurrent: () => !this.closePromise && !this.pauseReasons.size && this.channels.has(channel.pubkey)
914
922
  })
@@ -2401,11 +2409,11 @@ export class PrivateMessenger {
2401
2409
  } finally { releaseActivity() }
2402
2410
  }
2403
2411
 
2404
- async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey, signal }) {
2412
+ async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey, signal, attemptStartedAt, onPreparedSeed, preparedPublication, onDebug }) {
2405
2413
  const channel = this.requireWritableChannel(channelPubkey)
2406
2414
  const releaseActivity = this.decryptionScheduler.activity(channelPubkey)
2407
2415
  try {
2408
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal })
2416
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal, attemptStartedAt, onDebug })
2409
2417
  this.debugSend('broadcastRumor', channelPubkey, { receiverPubkeys })
2410
2418
  return await this._privateMessage.broadcastRumor({
2411
2419
  senderSigner: this.userSigner,
@@ -2419,17 +2427,18 @@ export class PrivateMessenger {
2419
2427
  deletionPubkey,
2420
2428
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2421
2429
  rumor,
2422
- onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
2430
+ onPreparedSeed: onPreparedSeed || this.outgoingSeedHandler(channelPubkey),
2431
+ preparedPublication,
2423
2432
  _getIykcProofs: this.contentKeyLookup()
2424
2433
  })
2425
2434
  } finally { releaseActivity() }
2426
2435
  }
2427
2436
 
2428
- async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey, signal }) {
2437
+ async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey, signal, attemptStartedAt, onPreparedSeed, preparedPublication, onDebug }) {
2429
2438
  const channel = this.requireWritableChannel(channelPubkey)
2430
2439
  const releaseActivity = this.decryptionScheduler.activity(channelPubkey)
2431
2440
  try {
2432
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal })
2441
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal, attemptStartedAt, onDebug })
2433
2442
  this.debugSend('broadcastEvent', channelPubkey, { receiverPubkeys })
2434
2443
  return await this._privateMessage.broadcastEvent({
2435
2444
  senderSigner: this.userSigner,
@@ -2443,7 +2452,8 @@ export class PrivateMessenger {
2443
2452
  deletionPubkey,
2444
2453
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2445
2454
  event,
2446
- onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
2455
+ onPreparedSeed: onPreparedSeed || this.outgoingSeedHandler(channelPubkey),
2456
+ preparedPublication,
2447
2457
  _getIykcProofs: this.contentKeyLookup()
2448
2458
  })
2449
2459
  } finally { releaseActivity() }
@@ -2782,8 +2792,17 @@ export class PrivateMessenger {
2782
2792
  })
2783
2793
 
2784
2794
  if (this.offlineRecoverySecondsFor(channelPubkey)) {
2795
+ const seen = new Set()
2796
+ const key = seed => seed.router ? routerSeedRowKey(seed) : nymCarrierSeedKey(seed)
2797
+ const pending = this._pendingSeedRecords({ channelPubkey, receiverPubkey: message.event?.pubkey, since, until })
2798
+ for (const seed of pending) {
2799
+ if (seed.channelPubkey !== channelPubkey || (seed.receiverPubkey && seed.receiverPubkey !== message.event?.pubkey) || seed.expiresAt <= nowSeconds()) continue
2800
+ if (since !== undefined && (seed.lastSeenAt || seed.router.created_at) < since) continue
2801
+ if (until !== undefined && (seed.firstSeenAt || seed.router.created_at) > until) continue
2802
+ seen.add(key(seed)); await packer.update(seed)
2803
+ }
2785
2804
  for await (const seed of this.seedStorage ? this.seedStorage.iterate({ channelPubkey, receiverPubkey: message.event?.pubkey, since, until }) : this.seedQueue.storedItemsBy('byChannel', channelPubkey)) {
2786
- if (seedRecordTime(seed) < nowSeconds() - this.offlineRecoverySecondsFor(channelPubkey)) continue
2805
+ if (seen.has(key(seed)) || seedRecordTime(seed) < nowSeconds() - this.offlineRecoverySecondsFor(channelPubkey)) continue
2787
2806
  if (this.seedStorage && !await this.seedStorage.has(seed)) continue
2788
2807
  await packer.update(seed)
2789
2808
  }
@@ -170,8 +170,11 @@ Optional `fallbackDelayMs` defaults to `null` (sequential primary exhaustion).
170
170
  A nonnegative integer up to 2147483647 opts automatic NIP-65 routing into early
171
171
  fallback; explicit relay lists/maps retain the sequential policy. With `3000`,
172
172
  start at most two eligible fallback relays after three seconds without complete
173
- acceptance, or immediately after primary exhaustion. The deadline belongs to
174
- one signed outer event; later batches do not renew it. Primary operations retain
173
+ acceptance, or immediately after primary exhaustion. Optional `enqueue({ ..., attemptStartedAt })`
174
+ uses `performance.now()` from the caller's realm to include preparation in the
175
+ budget. Without it, the deadline starts at publication. Later batches do not
176
+ renew it; retries/reloads start a new attempt and uploads budget each chunk.
177
+ Primary operations retain
175
178
  their deadlines and can still win. Failed fallback operations leave primary
176
179
  alternatives available; no relay is tried twice. Multiple recipients require
177
180
  accepted coverage of every encrypted recipient subset. No wrapper, ID, recipient
@@ -249,3 +252,34 @@ event/peer identifiers and elapsed milliseconds for admission, local processing,
249
252
  channel preparation, publication acceptance and cleanup. Callback failures never
250
253
  change delivery. Connect only in development; never log events or crypto buffers.
251
254
  Custom outbox factories also receive this optional callback for storage timings.
255
+
256
+ ## Durable prepared publications
257
+
258
+ Each remote entry may contain optional `prepared` progress in its existing
259
+ encrypted JSON. It holds the signed envelope fragments, compact recovery seeds,
260
+ recipient/channel and inner-event identities, event/chunk position, retention
261
+ and accepted/archived flags. Old entries without it retain `localSaved`, index
262
+ and file/chunk progress and enter the same preparation path; there is no database
263
+ migration or alternate publisher. Replays validate signatures and identity and
264
+ reuse the exact envelopes without repeating cryptography or extending expiry.
265
+
266
+ The existing `onPreparedSeed` callback now also supplies `publication`. The
267
+ session commits that minimal journal before transmitting; direct messengers
268
+ retain their local seed-store durability. Only after that commit can relay
269
+ publication proceed. One independent serial worker converts recovery seeds into
270
+ their configured archive. Pending durable rows participate in recovery reads
271
+ immediately, including during a blocked archive write. No new seeds are enabled
272
+ for modes which did not previously retain them.
273
+
274
+ First acceptance of every required envelope emits saved independently of archive
275
+ or cleanup. ACK progress checkpoints serialize with other entry writes. A crash
276
+ between ACK and its checkpoint may replay the same envelope; acknowledged durable
277
+ progress only archives. Archive failure never becomes `onSendError` or changes
278
+ saved status. Removal waits for archival and outstanding entry writes.
279
+
280
+ At most one unit awaits archival per entry; a stalled upload yields its remote
281
+ slot to other texts rather than accumulating the entire file. Cancel stops wire
282
+ work but retains potentially transmitted seeds until archival finishes. Close
283
+ drains active local operations and leaves durable backoff work for restart.
284
+ Diagnostics add publication/seed IDs, checkpoint time, archive wait/execution and
285
+ first-ACK timing, without message or ciphertext contents.
@@ -0,0 +1,137 @@
1
+ import { compactSeedRouterRows } from '../../index.js'
2
+ import { encodeRecord } from '../../event-store/helpers/codec.js'
3
+ import { isRecoverableStorageFailure } from '../../helpers/pause-recovery.js'
4
+
5
+ export function createPublicationJournal ({ entries, store, archive, changed, onError, debug, setTimer = setTimeout, clearTimer = clearTimeout, random = Math.random }) {
6
+ const writes = new Map()
7
+ const failed = new Set()
8
+ const durable = new WeakSet()
9
+ const queued = new WeakMap()
10
+ let working; let stopped = false; let enabled = false; let timer; let delay = 1000
11
+ function write (entry) {
12
+ const previous = writes.get(entry.id) || Promise.resolve()
13
+ const work = previous.catch(() => {}).then(() => store().put(structuredClone(entry), { existing: true }))
14
+ writes.set(entry.id, work)
15
+ work.finally(() => { if (writes.get(entry.id) === work) writes.delete(entry.id) }).catch(() => {})
16
+ return work
17
+ }
18
+ function rows (entry) {
19
+ const packet = entry.prepared
20
+ if (!packet?.recover) return []
21
+ return packet.publication.seeds.flatMap(seed => compactSeedRouterRows(seed).map(row => {
22
+ const descriptor = packet.target.file
23
+ const value = {
24
+ ...row, channelPubkey: seed.channelPubkey, receivedAt: packet.receivedAt,
25
+ expiresAt: row.router.created_at + packet.retentionSeconds,
26
+ ...(descriptor
27
+ ? {
28
+ root: descriptor.root, controlChannelPubkey: descriptor.controlChannelPubkey,
29
+ fileChannelPubkey: packet.publication.channelPubkey, peerPubkey: descriptor.peerPubkey,
30
+ chunkIndex: packet.target.chunkIndex,
31
+ key: `${descriptor.controlChannelPubkey}:${packet.publication.channelPubkey}:${row.receiverPubkey}:${packet.target.chunkIndex}`
32
+ }
33
+ : {})
34
+ }
35
+ value.recordId = encodeRecord('seed', value).tags[0][1]
36
+ return value
37
+ }))
38
+ }
39
+ function retryLater () {
40
+ if (stopped || !enabled || timer || !failed.size) return
41
+ timer = setTimer(() => { timer = null; failed.clear(); wake() }, Math.min(30000, delay * (0.8 + random() * 0.4)))
42
+ timer?.unref?.()
43
+ delay = Math.min(30000, delay * 2)
44
+ }
45
+ function wake () {
46
+ if (stopped || !enabled || working) return working
47
+ const work = Promise.resolve().then(async () => {
48
+ for (const entry of entries.values()) {
49
+ if (stopped || !enabled) break
50
+ const packet = entry.prepared
51
+ if (!packet || failed.has(entry.id)) continue
52
+ if (packet.archived && !packet.accepted && !entry.cancelled) continue
53
+ const started = Date.now()
54
+ try {
55
+ if (!durable.has(packet)) { await write(entry); durable.add(packet) }
56
+ if (!packet.archived) {
57
+ debug(entry, 'recovery-archive-start', queued.get(packet), { publicationId: packet.publication.events[0].id })
58
+ if (packet.recover) await archive(entry, rows(entry))
59
+ packet.archived = true
60
+ debug(entry, 'recovery-archived', started)
61
+ }
62
+ if (packet.accepted || entry.cancelled) delete entry.prepared
63
+ await write(entry)
64
+ changed(entry)
65
+ } catch (error) {
66
+ failed.add(entry.id)
67
+ onError(error)
68
+ if (isRecoverableStorageFailure(error)) retryLater()
69
+ }
70
+ }
71
+ }).finally(() => {
72
+ if (working === work) working = null
73
+ if (!failed.size) delay = 1000
74
+ // An ACK can arrive while another entry is being archived.
75
+ if (!stopped && [...entries.values()].some(entry => entry.prepared && !failed.has(entry.id) && (!entry.prepared.archived || entry.prepared.accepted || entry.cancelled))) wake()
76
+ })
77
+ working = work
78
+ return work
79
+ }
80
+ return {
81
+ write,
82
+ async flush (entry) { while (writes.has(entry.id)) await writes.get(entry.id) },
83
+ adopt (entry) { if (entry.prepared) { durable.add(entry.prepared); queued.set(entry.prepared, Date.now()) } },
84
+ async stage (entry, seed, target, retentionSeconds) {
85
+ const started = Date.now()
86
+ if (entry.prepared && entry.prepared.publication.events[0].id !== seed.publication.events[0].id) throw new Error('PUBLICATION_ARCHIVE_PENDING')
87
+ entry.prepared ||= { publication: structuredClone(seed.publication), target, receivedAt: Math.floor(Date.now() / 1000), retentionSeconds, recover: retentionSeconds > 0, accepted: false, archived: retentionSeconds <= 0 }
88
+ if (durable.has(entry.prepared)) return
89
+ queued.set(entry.prepared, Date.now())
90
+ await write(entry)
91
+ durable.add(entry.prepared)
92
+ debug(entry, 'publication-checkpoint', started, { publicationId: seed.publication.events[0].id })
93
+ for (const row of rows(entry)) debug(entry, 'recovery-pending', undefined, { seedRecordId: row.recordId, publicationId: seed.publication.events[0].id })
94
+ wake()
95
+ },
96
+ wake,
97
+ accept (entry) {
98
+ entry.prepared.accepted = true
99
+ // An unrelated archive may be blocked. Its worker must not hold this ACK.
100
+ write(entry).catch(error => { failed.add(entry.id); onError(error); retryLater() }).finally(wake)
101
+ wake()
102
+ },
103
+ setAvailable (value) {
104
+ enabled = value
105
+ if (!enabled) { if (timer) clearTimer(timer); timer = null } else { failed.clear(); wake() }
106
+ },
107
+ retry () { failed.clear(); if (timer) clearTimer(timer); timer = null; return wake() },
108
+ pendingRows () { return [...entries.values()].flatMap(rows).filter(row => row.expiresAt > Math.floor(Date.now() / 1000)) },
109
+ async close () {
110
+ stopped = true
111
+ if (timer) clearTimer(timer)
112
+ await working
113
+ await Promise.allSettled([...writes.values()])
114
+ }
115
+ }
116
+ }
117
+
118
+ export function pendingSeedMatches (row, query) {
119
+ for (const key of ['channelPubkey', 'controlChannelPubkey', 'fileChannelPubkey', 'receiverPubkey', 'recordId']) if (query[key] && row[key] !== query[key]) return false
120
+ if (query.indices && !query.indices.includes(row.chunkIndex)) return false
121
+ if (query.since !== undefined && (row.lastSeenAt ?? row.router.created_at) < query.since) return false
122
+ if (query.until !== undefined && (row.firstSeenAt ?? row.router.created_at) > query.until) return false
123
+ return true
124
+ }
125
+
126
+ export function withPendingSeeds (storage, pending) {
127
+ return {
128
+ ...storage,
129
+ async * iterate (query = {}) {
130
+ const seen = new Set()
131
+ // Pending durable rows are immediately usable, even while store.put waits.
132
+ for (const row of pending()) if (pendingSeedMatches(row, query)) { seen.add(row.recordId); yield row }
133
+ for await (const row of storage.iterate(query)) if (!seen.has(row.recordId)) yield row
134
+ },
135
+ has: row => pending().some(value => value.recordId === row.recordId) || storage.has(row)
136
+ }
137
+ }
@@ -14,6 +14,7 @@ import { createChatOutbox } from './helpers/work-storage.js'
14
14
  import { messengerSigner } from './helpers/signer.js'
15
15
  import { assertMessagePublished } from '../helpers/publication.js'
16
16
  import { normalizeFallbackRelays, normalizeFallbackDelay } from '../helpers/send-routing.js'
17
+ import { createPublicationJournal, withPendingSeeds } from './helpers/publication-journal.js'
17
18
  import { createPauseRecovery, isRecoverableStorageFailure } from '../helpers/pause-recovery.js'
18
19
 
19
20
  export function wireEvent (value, owner) {
@@ -65,14 +66,15 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
65
66
  const channels = new Map()
66
67
  const entries = new Map()
67
68
  const cancelled = new Set()
69
+ const attemptStarts = new Map()
68
70
  const sendControllers = new Map()
69
71
  const localReady = new Set()
70
72
  const localIdleWaiters = new Set()
71
73
  const cleanups = new Map()
72
74
  const queuedAt = new WeakMap()
73
- const debug = (entry, stage, startedAt) => {
75
+ const debug = (entry, stage, startedAt, coordinates = {}) => {
74
76
  if (!onDebug) return
75
- try { onDebug({ stage, eventId: entry.id, peer: entry.peer, at: Date.now(), ...(startedAt != null ? { elapsedMs: Date.now() - startedAt } : {}) }) } catch {}
77
+ try { onDebug({ stage, eventId: entry.id, peer: entry.peer, at: Date.now(), ...coordinates, ...(startedAt != null ? { elapsedMs: Date.now() - startedAt } : {}) }) } catch {}
76
78
  }
77
79
  const offlineSends = new Set()
78
80
  const pausedSends = new Set()
@@ -97,7 +99,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
97
99
  return
98
100
  }
99
101
  clearSendRetry()
100
- for (const id of pausedSends) { const entry = entries.get(id); if (entry) entry.failed = false }
102
+ for (const id of pausedSends) { const entry = entries.get(id); if (entry) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) } }
101
103
  pausedSends.clear()
102
104
  // The callback can run inside construction/resume, before configure settles.
103
105
  queueMicrotask(() => pump())
@@ -139,11 +141,30 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
139
141
  let redrain = false
140
142
  const idleWaiters = new Set()
141
143
  const settled = () => { if (!sending && !localSaving && !enqueuing && !draining && !cleanups.size) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
142
- const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion))
144
+ const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion && !entry.cancelled))
145
+ const journal = createPublicationJournal({
146
+ entries, store: () => storage, onError: reportError, debug, setTimer: _setTimeout, clearTimer: _clearTimeout, random: _random,
147
+ archive: async (entry, rows) => {
148
+ if (recoveryStorage?.seeds) { for (const row of rows) await recoveryStorage.seeds.put(row) } else if (entry.prepared.target.file) {
149
+ for (const seed of entry.prepared.publication.seeds) await fileTransfers.archiveSeed(entry.prepared.target.file, seed)
150
+ } else {
151
+ for (const seed of entry.prepared.publication.seeds) await messenger.enqueueSeed(seed.channelPubkey, seed)
152
+ }
153
+ },
154
+ changed: entry => {
155
+ if (!entry.prepared && (entry.status === 'saved' || entry.cancelled)) cleanupEntry(entry)
156
+ else if (!closed && available) pumpRemote()
157
+ }
158
+ })
159
+ const seedStorage = recoveryStorage?.seeds ? withPendingSeeds(recoveryStorage.seeds, () => journal.pendingRows()) : undefined
143
160
  const ready = () => (initialized ??= (async () => {
144
161
  storage = await openOutbox({ owner, signer, onDebug })
145
162
  for (const entry of await storage.list()) {
163
+ // Old encrypted entries retain progress and enter this same pipeline.
164
+ if (entry.cancelled) cancelled.add(entry.id)
146
165
  entries.set(entry.id, entry)
166
+ journal.adopt(entry)
167
+ attemptStarts.set(entry.id, performance.now())
147
168
  if (entry.localSaved.every(Boolean)) localReady.add(entry.id)
148
169
  queuedAt.set(entry, Date.now())
149
170
  }
@@ -156,7 +177,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
156
177
  let candidate
157
178
  try {
158
179
  candidate = await Messenger({
159
- fallbackRelays, fallbackDelayMs, pendingDecryptionReady, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait, _observeForeground: foreground.observe,
180
+ fallbackRelays, fallbackDelayMs, pendingDecryptionReady, seedStorage, _pendingSeedRecords: () => journal.pendingRows(), userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait, _observeForeground: foreground.observe,
160
181
  onMessageQueued: () => { if (messengerBinding === binding) return drain() },
161
182
  onStateChanged: state => {
162
183
  if (closed || messengerBinding !== binding) return
@@ -236,7 +257,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
236
257
  fileSetupWork = setupFiles().catch(error => { fileSetupWork = null; if (!closed) reportError(error) })
237
258
  }
238
259
  fileSetupWork.then(() => { if (!closed && available) restoreDownloads() }).catch(reportError)
239
- drain(); pump()
260
+ journal.wake(); drain(); pump()
240
261
  }
241
262
  async function setupFiles () {
242
263
  if (!fileTransfers) {
@@ -244,7 +265,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
244
265
  messenger, onError: reportError,
245
266
  resolveChannel: ({ peerPubkey, info }) => messengerSigner(signer.withSharedKey(peerPubkey, info)),
246
267
  storage: chunkStorage,
247
- seedStorage: recoveryStorage?.seeds,
268
+ seedStorage,
248
269
  authorizationStorage: recoveryStorage?.authorizations
249
270
  })
250
271
  fileTransfers.observe(state => {
@@ -368,10 +389,24 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
368
389
  if (again && !storageBlocked && !closed && available) queueMicrotask(() => drain())
369
390
  }
370
391
  }
371
- async function publish (peer, event, signal) {
392
+ async function publish (entry, event, signal) {
393
+ const peer = entry.peer
372
394
  if (preparation.error(peer)) throw preparation.error(peer)
373
395
  if (!available || !peers.has(peer) || !channels.has(peer)) throw unavailableError(new Error('CHAT_UNAVAILABLE'), signal)
374
- const options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer], signal }
396
+ const channelPubkey = channels.get(peer)?.pubkey
397
+ const retention = mode === 'seeder' || mode === 'watchtower' ? messenger.offlineRecoverySecondsFor?.(channelPubkey) ?? 0 : 0
398
+ const preparationStartedAt = Date.now()
399
+ const options = {
400
+ channelPubkey, receiverPubkeys: [peer], signal, attemptStartedAt: attemptStarts.get(entry.id),
401
+ onDebug: onDebug ? value => debug(entry, value.stage, undefined, value) : undefined,
402
+ preparedPublication: entry.prepared?.publication,
403
+ onPreparedSeed: async seed => {
404
+ debug(entry, 'envelope-prepared', preparationStartedAt, { publicationId: seed.publication.events[0].id })
405
+ await journal.stage(entry, seed, { index: entry.index }, retention)
406
+ signal.throwIfAborted()
407
+ }
408
+ }
409
+ debug(entry, 'envelope-preparation')
375
410
  let report
376
411
  try {
377
412
  report = event.sig ? await messenger.broadcastEvent({ ...options, event }) : await messenger.broadcastRumor({ ...options, rumor: event })
@@ -383,10 +418,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
383
418
  await assertMessagePublished(report, event)
384
419
  }
385
420
  async function cleanupEntry (entry) {
421
+ if (entry.prepared) { journal.wake(); return }
386
422
  if (cleanups.has(entry.id)) return cleanups.get(entry.id)
387
423
  const startedAt = Date.now()
388
- const work = Promise.resolve().then(() => storage.remove(entry.id)).then(() => {
389
- entries.delete(entry.id); localReady.delete(entry.id)
424
+ const work = Promise.resolve().then(async () => { await journal.flush(entry); await storage.remove(entry.id) }).then(() => {
425
+ entries.delete(entry.id); localReady.delete(entry.id); attemptStarts.delete(entry.id)
390
426
  debug(entry, 'outbox-cleanup', startedAt)
391
427
  emit()
392
428
  }).catch(error => {
@@ -399,6 +435,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
399
435
  return work
400
436
  }
401
437
  async function sendEntry (entry, localOnly = false) {
438
+ if (entry.status === 'saved') return
402
439
  const releaseForeground = foreground.enter()
403
440
  entry.status = 'pending'; emit()
404
441
  const controller = new AbortController()
@@ -429,7 +466,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
429
466
  } else {
430
467
  // Retry this checkpoint even when a preceding attempt saved all copies
431
468
  // but failed to persist progress. Only then admit remote publication.
432
- await storage.put(entry, { existing: true })
469
+ await journal.write(entry)
433
470
  await active()
434
471
  localReady.add(entry.id)
435
472
  }
@@ -443,49 +480,55 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
443
480
  if (entry.peer !== owner && messengerStatus?.paused) {
444
481
  throw unavailableError(Object.assign(new Error('PRIVATE_MESSENGER_PAUSED'), { pauseReasons: [...messengerStatus.pauseReasons] }), controller.signal)
445
482
  }
446
- // Read/publish one chunk at a time. Progress is durable after each
447
- // accepted chunk; replay after interruption keeps its original identity.
448
- for (; entry.peer !== owner && entry.fileIndex < entry.files.length; entry.fileIndex++, entry.chunkIndex = 0) {
483
+ if (entry.prepared?.accepted) { journal.wake(); return }
484
+ // One prepared unit per entry. Archive waits yield this entry, not the pump.
485
+ for (; entry.fileIndex < entry.files.length; entry.fileIndex++, entry.chunkIndex = 0) {
449
486
  const file = entry.files[entry.fileIndex]
487
+ const descriptor = { controlChannelPubkey: channels.get(entry.peer).pubkey, peerPubkey: entry.peer, root: file.root, size: file.size }
450
488
  await active()
451
- await fileTransfers.authorizeSeeding({ controlChannelPubkey: channels.get(entry.peer).pubkey, peerPubkey: entry.peer, root: file.root, size: file.size }, { receiverPubkeys: [entry.peer], sharedAt: entry.event.created_at })
452
- for (; entry.chunkIndex < Math.ceil(file.size / IRFS_CHUNK_BYTES); entry.chunkIndex++) {
489
+ await fileTransfers.authorizeSeeding(descriptor, { receiverPubkeys: [entry.peer], sharedAt: entry.event.created_at })
490
+ while (entry.chunkIndex < Math.ceil(file.size / IRFS_CHUNK_BYTES)) {
491
+ const chunkStartedAt = performance.now()
453
492
  await active()
454
493
  const chunk = chunkStorage ? await chunkStorage.read(file.root, entry.chunkIndex, file) : await fileTransfers.readChunk(file.root, entry.chunkIndex)
455
494
  if (!chunk || decodeIrfsChunk(chunk).root !== file.root) { if (file.optional) break; throw new Error('FILE_UNAVAILABLE') }
456
- if (!fileTransfers) throw new Error('FILE_TRANSFER_UNAVAILABLE')
457
- await fileTransfers.publishChunk({ controlChannelPubkey: channels.get(entry.peer).pubkey, peerPubkey: entry.peer, root: file.root, size: file.size }, wireEvent(chunk, owner), { signal: controller.signal })
458
- const completed = entry.files.slice(0, entry.fileIndex).reduce((sum, value) => sum + value.size, 0) + Math.min(file.size, (entry.chunkIndex + 1) * IRFS_CHUNK_BYTES)
495
+ const recover = mode === 'watchtower' ? messenger.offlineRecoverySecondsFor?.(descriptor.controlChannelPubkey) ?? 0 : 0
496
+ const target = { file: descriptor, fileIndex: entry.fileIndex, chunkIndex: entry.chunkIndex }
497
+ await fileTransfers.publishChunk(descriptor, wireEvent(chunk, owner), {
498
+ signal: controller.signal, attemptStartedAt: chunkStartedAt, onDebug: onDebug ? value => debug(entry, value.stage, undefined, value) : undefined, preparedPublication: entry.prepared?.publication,
499
+ onPreparedSeed: async seed => { await journal.stage(entry, seed, target, recover); controller.signal.throwIfAborted() }
500
+ })
501
+ entry.chunkIndex++
502
+ const completed = entry.files.slice(0, entry.fileIndex).reduce((sum, value) => sum + value.size, 0) + Math.min(file.size, entry.chunkIndex * IRFS_CHUNK_BYTES)
459
503
  entry.uploadProgress = { completed, total: entry.files.reduce((sum, value) => sum + value.size, 0) }
460
504
  emit()
461
505
  await active()
462
- await storage.put({ ...entry, chunkIndex: entry.chunkIndex + 1 }, { existing: true })
463
- // Let the independent local stage finish newly admitted texts before
464
- // selecting publications at this chunk boundary. It never awaits us.
506
+ if (entry.prepared) { journal.accept(entry); return }
507
+ await journal.write(entry)
465
508
  if ([...entries.values()].some(pending => !pending.files.length && pending.peer !== owner && !pending.failed && !localReady.has(pending.id))) {
466
509
  await pumpLocal()
467
510
  if (localSaving) await new Promise(resolve => localIdleWaiters.add(resolve))
468
511
  }
469
- // Newly queued text and deletion controls can pass an in-flight file.
470
512
  let admitted = 0
471
513
  for (const pending of entries.values()) {
472
514
  if (closed || !available || admitted >= 8) break
473
- if (!localReady.has(pending.id) || pending.peer === owner || pending.status === 'saved' || pending.files.length || pending.failed || cancelled.has(pending.id) || (pending.peer !== owner && !peers.has(pending.peer))) continue
515
+ 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
474
516
  admitted++
475
517
  await sendEntry(pending)
476
518
  }
477
519
  }
478
520
  }
479
- for (; entry.index < entry.events.length; entry.index++) {
521
+ while (entry.index < entry.events.length) {
480
522
  await active()
481
523
  const event = entry.events[entry.index]
482
524
  const startedAt = Date.now()
483
- await publish(entry.peer, event, controller.signal)
525
+ await publish(entry, event, controller.signal)
484
526
  debug(entry, 'publication-accepted', startedAt)
485
527
  await active()
486
- // The final durable operation is removal. Avoid encrypting a checkpoint
487
- // that would immediately be deleted; earlier context stages still checkpoint.
488
- if (entry.index + 1 < entry.events.length) await storage.put({ ...entry, index: entry.index + 1 }, { existing: true })
528
+ entry.index++
529
+ if (entry.index === entry.events.length) { entry.status = 'saved'; emit() }
530
+ if (entry.prepared) { journal.accept(entry); return }
531
+ if (entry.index < entry.events.length) await journal.write(entry)
489
532
  }
490
533
  entry.status = 'saved'; emit()
491
534
  cleanupEntry(entry)
@@ -493,9 +536,12 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
493
536
  // Pauses can surface at any stage (text publication, file authorization
494
537
  // or chunk delivery). Normalize all of them into the retryable path.
495
538
  const error = cause?.retryWhenAvailable ? cause : (isUnavailableSend(cause) ? unavailableError(cause, controller.signal) : cause)
496
- if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
539
+ if (cancelled.has(entry.id)) {
540
+ entry.cancelled = true
541
+ if (entry.prepared) { await journal.write(entry); journal.wake() } else { await storage.remove(entry.id); entries.delete(entry.id) }
542
+ } else {
497
543
  entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
498
- await storage.put(entry, { existing: true }).catch(reportError)
544
+ await journal.write(entry).catch(reportError)
499
545
  if (entry.peer !== owner && error.retryWhenAvailable && messenger?.readStatus().paused) pausedSends.add(entry.id)
500
546
  else if (isUnavailableSend(cause) && cause.pauseReasons?.length && available && !closed) {
501
547
  // Release can race persistence of the failed attempt. Do not lose
@@ -536,6 +582,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
536
582
  for (const entry of entries.values()) {
537
583
  if (!pendingRetryable(entry) || pausedSends.has(entry.id)) continue
538
584
  entry.failed = false
585
+ attemptStarts.set(entry.id, performance.now())
539
586
  if (entry.status === 'saved') { cleanupEntry(entry); continue }
540
587
  retried = true
541
588
  }
@@ -553,7 +600,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
553
600
  if (closed || !available) return
554
601
  for (const id of offlineSends) {
555
602
  const entry = entries.get(id)
556
- if (entry?.retryable) entry.failed = false
603
+ if (entry?.retryable) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) }
557
604
  }
558
605
  offlineSends.clear()
559
606
  return schedule()
@@ -586,6 +633,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
586
633
  repump = false
587
634
  for (const entry of entries.values()) {
588
635
  if (closed || !available) break
636
+ if (entry.prepared?.accepted) { journal.wake(); continue }
589
637
  if (entry.peer === owner || entry.status === 'saved' || !localReady.has(entry.id) || !peers.has(entry.peer) || entry.failed || cancelled.has(entry.id)) continue
590
638
  if (!preparation.get(entry.peer) && !preparation.error(entry.peer)) {
591
639
  preparation.request(entry.peer).catch(() => {})
@@ -714,19 +762,22 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
714
762
  else clearSendRetry()
715
763
  if (changed) lifecycle++
716
764
  if (!available) {
765
+ journal.setAvailable(false)
717
766
  preparation.setAvailable(false)
718
767
  for (const peer of channels.keys()) if (!preparation.get(peer)) channels.delete(peer)
719
768
  inboxRecovery.stop(); await messenger?.pause('signer'); return
720
769
  }
721
770
  await ready()
771
+ journal.setAvailable(true)
722
772
  pumpLocal()
723
- for (const entry of entries.values()) if (pendingRetryable(entry)) entry.failed = false
773
+ for (const entry of entries.values()) if (pendingRetryable(entry)) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) }
724
774
  if (storageBlocked) inboxRecovery.start({ immediate: true })
725
775
  if (!changed && messenger && !configurationFailed) { preparation.setAvailable(true); pump(); return configuring }
726
776
  return schedule()
727
777
  },
728
- async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false }) {
778
+ async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false, attemptStartedAt }) {
729
779
  if (closed || !available || (peer !== owner && !peers.has(peer))) throw new Error('CHAT_UNAVAILABLE')
780
+ if (attemptStartedAt !== undefined && (!Number.isFinite(attemptStartedAt) || attemptStartedAt < 0)) throw new ValidationError('INVALID_ATTEMPT_STARTED_AT')
730
781
  const releaseForeground = foreground.enter()
731
782
  enqueuing++
732
783
  try {
@@ -741,6 +792,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
741
792
  await storage.put(entry)
742
793
  debug(entry, 'outbox-stored', startedAt)
743
794
  queuedAt.set(entry, Date.now())
795
+ if (attemptStartedAt !== undefined) attemptStarts.set(id, attemptStartedAt)
744
796
  if (cancelled.has(id)) { await storage.remove(id); throw new Error('CHAT_UNAVAILABLE') }
745
797
  // An accepted durable intent survives shutdown/locking for replay. Only
746
798
  // explicit cancellation removes it; no late callback or work is admitted.
@@ -755,15 +807,15 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
755
807
  if (storageBlocked) inboxRecovery.start({ immediate: true })
756
808
  await ready()
757
809
  const entry = entries.get(id)
758
- if (entry) { entry.failed = false; resetSendRetry(); if (entry.peer !== owner) preparePeer(entry.peer).catch(() => {}); return pump() }
810
+ if (entry) { attemptStarts.set(id, performance.now()); journal.retry(); entry.failed = false; resetSendRetry(); if (entry.peer !== owner) preparePeer(entry.peer).catch(() => {}); return pump() }
759
811
  },
760
812
  async cancel (id) {
761
813
  cancelled.add(id)
762
814
  sendControllers.get(id)?.abort()
763
815
  offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
764
816
  await ready()
765
- entries.delete(id); localReady.delete(id)
766
- await storage.remove(id)
817
+ const entry = entries.get(id)
818
+ if (entry?.prepared) { entry.cancelled = true; await journal.write(entry); journal.wake() } else { entries.delete(id); localReady.delete(id); await storage.remove(id) }
767
819
  emit()
768
820
  },
769
821
  close () {
@@ -775,6 +827,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
775
827
  await Promise.allSettled([configuring, initialized, messengerWork, fileSetupWork])
776
828
  await messenger?.pause('closed')
777
829
  if (sending || localSaving || enqueuing || draining || cleanups.size) await new Promise(resolve => idleWaiters.add(resolve))
830
+ await journal.close()
831
+ if (cleanups.size) await new Promise(resolve => idleWaiters.add(resolve))
778
832
  await messenger?.close()
779
833
  await storage?.close()
780
834
  await downloadWriteTail.catch(reportError)