libp2r2p 0.11.23 → 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.23",
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
  }
@@ -72,7 +72,7 @@ The coordinator resolves the peer's channel and queues the request until the
72
72
  channel exists. Priorities expire after `priorityTtlMs` (10 minutes) or once
73
73
  covered, and are dropped with recovery state. They are process-local and reset
74
74
  on reload.
75
- Callbacks are `onOutbox`, `onMedia`,
75
+ Callbacks are optional `onDebug`, `onOutbox`, `onMedia`,
76
76
  `onError`, and optional `onSendError(error, { id, peer })`. The caller maps its
77
77
  account permissions/connectivity/lock state to availability and owns view subscriptions. There are no DOM or UI imports.
78
78
 
@@ -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
@@ -226,3 +229,57 @@ closing shared sockets or cancelling another subscriber for the same event ID.
226
229
  Operations already transmitted cannot be recalled from relays. A messenger pause
227
230
  cancels its outstanding publication work and leaves the outbox pending; resume
228
231
  creates a fresh publication lifetime, without treating the pause as a user abort.
232
+
233
+ ## Independent local persistence and publication
234
+
235
+ One encrypted outbox feeds two independent serial processors. Local persistence
236
+ never waits for peer preparation or an in-flight publication. Remote entries
237
+ become publishable only after their local progress checkpoint commits; failed
238
+ checkpoints are retried even if the copies have already committed. Text may
239
+ continue passing uploads between chunks after its local stage is ready.
240
+
241
+ Self entries emit `status: 'saved'` as soon as local persistence succeeds. They
242
+ do not write a redundant localSaved checkpoint before removal. Remote entries
243
+ emit that status after the first valid relay ACK for every required publication,
244
+ without waiting for redundant relays. Outbox cleanup runs independently; failures
245
+ retain saved status and retry only removal with the session backoff, never
246
+ `onSendError`. Consumers must accept saved entries while removal is pending.
247
+ A crash before removal replays the original event identity into idempotent storage.
248
+ Account shutdown fences late admissions and waits for both processors and cleanup.
249
+
250
+ `onDebug(record)` is disabled by default. Records carry fixed stage names, public
251
+ event/peer identifiers and elapsed milliseconds for admission, local processing,
252
+ channel preparation, publication acceptance and cleanup. Callback failures never
253
+ change delivery. Connect only in development; never log events or crypto buffers.
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) {
@@ -44,7 +45,7 @@ function messengerStatusSnapshot (value) {
44
45
  return { closed: value.closed, paused: value.paused, pauseReasons: [...value.pauseReasons] }
45
46
  }
46
47
 
47
- export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], fallbackDelayMs = null, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, pendingDecryptionReady = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _isOnline = isOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
48
+ export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], fallbackDelayMs = null, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, pendingDecryptionReady = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, onDebug, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _isOnline = isOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
48
49
  fallbackRelays = normalizeFallbackRelays(fallbackRelays)
49
50
  fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
50
51
  if (typeof pendingDecryptionReady !== 'boolean') throw new ValidationError('INVALID_PENDING_DECRYPTION_READY')
@@ -65,7 +66,16 @@ 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()
71
+ const localReady = new Set()
72
+ const localIdleWaiters = new Set()
73
+ const cleanups = new Map()
74
+ const queuedAt = new WeakMap()
75
+ const debug = (entry, stage, startedAt, coordinates = {}) => {
76
+ if (!onDebug) return
77
+ try { onDebug({ stage, eventId: entry.id, peer: entry.peer, at: Date.now(), ...coordinates, ...(startedAt != null ? { elapsedMs: Date.now() - startedAt } : {}) }) } catch {}
78
+ }
69
79
  const offlineSends = new Set()
70
80
  const pausedSends = new Set()
71
81
  let messengerStatus
@@ -89,7 +99,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
89
99
  return
90
100
  }
91
101
  clearSendRetry()
92
- 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()) } }
93
103
  pausedSends.clear()
94
104
  // The callback can run inside construction/resume, before configure settles.
95
105
  queueMicrotask(() => pump())
@@ -123,15 +133,41 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
123
133
  let messengerWork
124
134
  let fileSetupWork
125
135
  let sending
136
+ let enqueuing = 0
137
+ let localSaving = false
138
+ let relocal = false
126
139
  let repump
127
140
  let draining
128
141
  let redrain = false
129
142
  const idleWaiters = new Set()
130
- const settled = () => { if (!sending && !draining) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
131
- const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion))
143
+ const settled = () => { if (!sending && !localSaving && !enqueuing && !draining && !cleanups.size) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
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
132
160
  const ready = () => (initialized ??= (async () => {
133
- storage = await openOutbox({ owner, signer })
134
- for (const entry of await storage.list()) entries.set(entry.id, entry)
161
+ storage = await openOutbox({ owner, signer, onDebug })
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)
165
+ entries.set(entry.id, entry)
166
+ journal.adopt(entry)
167
+ attemptStarts.set(entry.id, performance.now())
168
+ if (entry.localSaved.every(Boolean)) localReady.add(entry.id)
169
+ queuedAt.set(entry, Date.now())
170
+ }
135
171
  emit()
136
172
  })().catch(async error => { await storage?.close(); storage = null; initialized = null; throw error }))
137
173
  async function createMessenger () {
@@ -141,7 +177,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
141
177
  let candidate
142
178
  try {
143
179
  candidate = await Messenger({
144
- 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,
145
181
  onMessageQueued: () => { if (messengerBinding === binding) return drain() },
146
182
  onStateChanged: state => {
147
183
  if (closed || messengerBinding !== binding) return
@@ -167,6 +203,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
167
203
 
168
204
  const preparation = createPeerPreparation({
169
205
  prepare: async (peer, signal) => {
206
+ const startedAt = Date.now()
170
207
  const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
171
208
  const pubkey = await scoped.getPublicKey()
172
209
  signal.throwIfAborted()
@@ -175,6 +212,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
175
212
  if (peer === pendingDecryptionPeer) messenger?.setPendingDecryptionPriority?.(channel.pubkey)
176
213
  await schedule()
177
214
  signal.throwIfAborted()
215
+ if (onDebug) { try { onDebug({ stage: 'channel-ready', peer, elapsedMs: Date.now() - startedAt }) } catch {} }
178
216
  return channel
179
217
  },
180
218
  onReady: () => { drain(); pump() }, onError: reportError,
@@ -219,7 +257,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
219
257
  fileSetupWork = setupFiles().catch(error => { fileSetupWork = null; if (!closed) reportError(error) })
220
258
  }
221
259
  fileSetupWork.then(() => { if (!closed && available) restoreDownloads() }).catch(reportError)
222
- drain(); pump()
260
+ journal.wake(); drain(); pump()
223
261
  }
224
262
  async function setupFiles () {
225
263
  if (!fileTransfers) {
@@ -227,7 +265,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
227
265
  messenger, onError: reportError,
228
266
  resolveChannel: ({ peerPubkey, info }) => messengerSigner(signer.withSharedKey(peerPubkey, info)),
229
267
  storage: chunkStorage,
230
- seedStorage: recoveryStorage?.seeds,
268
+ seedStorage,
231
269
  authorizationStorage: recoveryStorage?.authorizations
232
270
  })
233
271
  fileTransfers.observe(state => {
@@ -351,10 +389,24 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
351
389
  if (again && !storageBlocked && !closed && available) queueMicrotask(() => drain())
352
390
  }
353
391
  }
354
- async function publish (peer, event, signal) {
392
+ async function publish (entry, event, signal) {
393
+ const peer = entry.peer
355
394
  if (preparation.error(peer)) throw preparation.error(peer)
356
395
  if (!available || !peers.has(peer) || !channels.has(peer)) throw unavailableError(new Error('CHAT_UNAVAILABLE'), signal)
357
- 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')
358
410
  let report
359
411
  try {
360
412
  report = event.sig ? await messenger.broadcastEvent({ ...options, event }) : await messenger.broadcastRumor({ ...options, rumor: event })
@@ -365,7 +417,25 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
365
417
  // Published is relay acceptance, never a peer receipt.
366
418
  await assertMessagePublished(report, event)
367
419
  }
368
- async function sendEntry (entry) {
420
+ async function cleanupEntry (entry) {
421
+ if (entry.prepared) { journal.wake(); return }
422
+ if (cleanups.has(entry.id)) return cleanups.get(entry.id)
423
+ const startedAt = Date.now()
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)
426
+ debug(entry, 'outbox-cleanup', startedAt)
427
+ emit()
428
+ }).catch(error => {
429
+ // Delivery has already succeeded. Cleanup must never become a send error.
430
+ entry.failed = true; entry.retryable = true
431
+ reportError(error)
432
+ if (!closed && available) scheduleSendRetry()
433
+ }).finally(() => { cleanups.delete(entry.id); if (![...entries.values()].some(pendingRetryable) && !cleanups.size) resetSendRetry(); settled() })
434
+ cleanups.set(entry.id, work)
435
+ return work
436
+ }
437
+ async function sendEntry (entry, localOnly = false) {
438
+ if (entry.status === 'saved') return
369
439
  const releaseForeground = foreground.enter()
370
440
  entry.status = 'pending'; emit()
371
441
  const controller = new AbortController()
@@ -375,19 +445,32 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
375
445
  if (storage.has && !await storage.has(entry.id)) cancelled.add(entry.id)
376
446
  if (closed || !available || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) throw new Error('CHAT_UNAVAILABLE')
377
447
  }
378
- // Personal copies must commit even if publishing attachment bytes
379
- // fails offline. Remote progress remains independently retryable.
380
- for (let index = 0; index < entry.events.length; index++) {
381
- await active()
382
- const event = entry.events[index]
383
- if (!entry.localSaved[index]) {
448
+ if (localOnly) {
449
+ debug(entry, 'local-start', queuedAt.get(entry))
450
+ const startedAt = Date.now()
451
+ for (let index = 0; index < entry.events.length; index++) {
452
+ await active()
453
+ if (entry.localSaved[index]) continue
454
+ const event = entry.events[index]
384
455
  const hearsay = !event.sig && event.pubkey !== owner
385
456
  const result = await messageStorage.save(event, { peerPubkey: entry.peer, hearsay })
386
457
  if (result?.result?.code === 'blocked') { cancelled.add(entry.id); await active() }
387
458
  if (!result?.result?.ok) throw Object.assign(new Error('MESSAGE_STORAGE_FAILED'), { code: result?.result?.code?.toUpperCase() })
388
459
  entry.localSaved[index] = true
389
- await storage.put(entry, { existing: true })
390
460
  }
461
+ debug(entry, 'local-stored', startedAt)
462
+ await active()
463
+ if (entry.peer === owner) {
464
+ entry.status = 'saved'; emit()
465
+ cleanupEntry(entry)
466
+ } else {
467
+ // Retry this checkpoint even when a preceding attempt saved all copies
468
+ // but failed to persist progress. Only then admit remote publication.
469
+ await journal.write(entry)
470
+ await active()
471
+ localReady.add(entry.id)
472
+ }
473
+ return
391
474
  }
392
475
  if (entry.peer !== owner && !preparation.get(entry.peer)) {
393
476
  if (preparation.error(entry.peer)) throw preparation.error(entry.peer)
@@ -397,52 +480,68 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
397
480
  if (entry.peer !== owner && messengerStatus?.paused) {
398
481
  throw unavailableError(Object.assign(new Error('PRIVATE_MESSENGER_PAUSED'), { pauseReasons: [...messengerStatus.pauseReasons] }), controller.signal)
399
482
  }
400
- // Read/publish one chunk at a time. Progress is durable after each
401
- // accepted chunk; replay after interruption keeps its original identity.
402
- 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) {
403
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 }
404
488
  await active()
405
- 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 })
406
- 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()
407
492
  await active()
408
493
  const chunk = chunkStorage ? await chunkStorage.read(file.root, entry.chunkIndex, file) : await fileTransfers.readChunk(file.root, entry.chunkIndex)
409
494
  if (!chunk || decodeIrfsChunk(chunk).root !== file.root) { if (file.optional) break; throw new Error('FILE_UNAVAILABLE') }
410
- if (!fileTransfers) throw new Error('FILE_TRANSFER_UNAVAILABLE')
411
- await fileTransfers.publishChunk({ controlChannelPubkey: channels.get(entry.peer).pubkey, peerPubkey: entry.peer, root: file.root, size: file.size }, wireEvent(chunk, owner), { signal: controller.signal })
412
- 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)
413
503
  entry.uploadProgress = { completed, total: entry.files.reduce((sum, value) => sum + value.size, 0) }
414
504
  emit()
415
505
  await active()
416
- await storage.put({ ...entry, chunkIndex: entry.chunkIndex + 1 }, { existing: true })
417
- // Newly queued text and deletion controls can pass an in-flight file.
506
+ if (entry.prepared) { journal.accept(entry); return }
507
+ await journal.write(entry)
508
+ if ([...entries.values()].some(pending => !pending.files.length && pending.peer !== owner && !pending.failed && !localReady.has(pending.id))) {
509
+ await pumpLocal()
510
+ if (localSaving) await new Promise(resolve => localIdleWaiters.add(resolve))
511
+ }
418
512
  let admitted = 0
419
513
  for (const pending of entries.values()) {
420
514
  if (closed || !available || admitted >= 8) break
421
- if (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
422
516
  admitted++
423
517
  await sendEntry(pending)
424
518
  }
425
519
  }
426
520
  }
427
- for (; entry.index < entry.events.length; entry.index++) {
521
+ while (entry.index < entry.events.length) {
428
522
  await active()
429
523
  const event = entry.events[entry.index]
430
- if (entry.peer !== owner) await publish(entry.peer, event, controller.signal)
524
+ const startedAt = Date.now()
525
+ await publish(entry, event, controller.signal)
526
+ debug(entry, 'publication-accepted', startedAt)
431
527
  await active()
432
- // The final durable operation is removal. Avoid encrypting a checkpoint
433
- // that would immediately be deleted; earlier context stages still checkpoint.
434
- 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)
435
532
  }
436
- await storage.remove(entry.id)
437
- entries.delete(entry.id)
438
- emit()
533
+ entry.status = 'saved'; emit()
534
+ cleanupEntry(entry)
439
535
  } catch (cause) {
440
536
  // Pauses can surface at any stage (text publication, file authorization
441
537
  // or chunk delivery). Normalize all of them into the retryable path.
442
538
  const error = cause?.retryWhenAvailable ? cause : (isUnavailableSend(cause) ? unavailableError(cause, controller.signal) : cause)
443
- 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 {
444
543
  entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
445
- await storage.put(entry, { existing: true }).catch(reportError)
544
+ await journal.write(entry).catch(reportError)
446
545
  if (entry.peer !== owner && error.retryWhenAvailable && messenger?.readStatus().paused) pausedSends.add(entry.id)
447
546
  else if (isUnavailableSend(cause) && cause.pauseReasons?.length && available && !closed) {
448
547
  // Release can race persistence of the failed attempt. Do not lose
@@ -483,6 +582,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
483
582
  for (const entry of entries.values()) {
484
583
  if (!pendingRetryable(entry) || pausedSends.has(entry.id)) continue
485
584
  entry.failed = false
585
+ attemptStarts.set(entry.id, performance.now())
586
+ if (entry.status === 'saved') { cleanupEntry(entry); continue }
486
587
  retried = true
487
588
  }
488
589
  if (retried) pump()
@@ -499,16 +600,32 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
499
600
  if (closed || !available) return
500
601
  for (const id of offlineSends) {
501
602
  const entry = entries.get(id)
502
- if (entry?.retryable) entry.failed = false
603
+ if (entry?.retryable) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) }
503
604
  }
504
605
  offlineSends.clear()
505
606
  return schedule()
506
607
  })
507
608
  }
508
- async function pump () {
609
+ async function pumpLocal () {
509
610
  if (!available || closed || !storage) return
510
- // A retry that lands while a pump is already running must not be skipped
511
- // just because that pass already walked past the entry.
611
+ if (localSaving) { relocal = true; return }
612
+ localSaving = true
613
+ try {
614
+ do {
615
+ relocal = false
616
+ for (const entry of entries.values()) {
617
+ if (closed || !available) break
618
+ if (entry.failed || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) continue
619
+ if (entry.status === 'saved') { cleanupEntry(entry); continue }
620
+ if (localReady.has(entry.id) && entry.peer !== owner) continue
621
+ await sendEntry(entry, true)
622
+ pumpRemote()
623
+ }
624
+ } while (relocal)
625
+ } finally { localSaving = false; for (const resolve of localIdleWaiters) resolve(); localIdleWaiters.clear(); settled() }
626
+ }
627
+ async function pumpRemote () {
628
+ if (!available || closed || !storage || !messenger) return
512
629
  if (sending) { repump = true; return }
513
630
  sending = true
514
631
  try {
@@ -516,22 +633,24 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
516
633
  repump = false
517
634
  for (const entry of entries.values()) {
518
635
  if (closed || !available) break
519
- if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
520
- if (entry.peer !== owner && entry.localSaved.every(Boolean) && !preparation.get(entry.peer) && !preparation.error(entry.peer)) {
636
+ if (entry.prepared?.accepted) { journal.wake(); continue }
637
+ if (entry.peer === owner || entry.status === 'saved' || !localReady.has(entry.id) || !peers.has(entry.peer) || entry.failed || cancelled.has(entry.id)) continue
638
+ if (!preparation.get(entry.peer) && !preparation.error(entry.peer)) {
521
639
  preparation.request(entry.peer).catch(() => {})
522
640
  continue
523
641
  }
524
- if (entry.peer !== owner && entry.files.length && !fileTransfers) continue
642
+ if (entry.files.length && !fileTransfers) continue
525
643
  await sendEntry(entry)
526
644
  }
527
645
  } while (repump)
528
646
  } finally {
529
647
  sending = false
530
648
  watchOfflineSends()
531
- if (![...entries.values()].some(pendingRetryable)) resetSendRetry()
649
+ if (![...entries.values()].some(pendingRetryable) && !cleanups.size) resetSendRetry()
532
650
  settled()
533
651
  }
534
652
  }
653
+ async function pump () { await Promise.all([pumpLocal(), pumpRemote()]) }
535
654
  const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
536
655
  async function download (file, options = {}) {
537
656
  if (file.peer === owner) return file
@@ -643,19 +762,24 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
643
762
  else clearSendRetry()
644
763
  if (changed) lifecycle++
645
764
  if (!available) {
765
+ journal.setAvailable(false)
646
766
  preparation.setAvailable(false)
647
767
  for (const peer of channels.keys()) if (!preparation.get(peer)) channels.delete(peer)
648
768
  inboxRecovery.stop(); await messenger?.pause('signer'); return
649
769
  }
650
770
  await ready()
651
- for (const entry of entries.values()) if (pendingRetryable(entry)) entry.failed = false
771
+ journal.setAvailable(true)
772
+ pumpLocal()
773
+ for (const entry of entries.values()) if (pendingRetryable(entry)) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) }
652
774
  if (storageBlocked) inboxRecovery.start({ immediate: true })
653
775
  if (!changed && messenger && !configurationFailed) { preparation.setAvailable(true); pump(); return configuring }
654
776
  return schedule()
655
777
  },
656
- async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false }) {
778
+ async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false, attemptStartedAt }) {
657
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')
658
781
  const releaseForeground = foreground.enter()
782
+ enqueuing++
659
783
  try {
660
784
  await ready()
661
785
  const main = wireEvent(event, owner)
@@ -663,26 +787,35 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
663
787
  const events = [...context.map(value => wireEvent(value, owner)), main]
664
788
  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 }] : [])])
665
789
  const entry = { id, peer, event: { ...main, id }, events, files, index: 0, fileIndex: 0, chunkIndex: 0, localSaved: context.map(() => true).concat(false), status: 'pending', deletion }
790
+ const startedAt = Date.now()
791
+ debug(entry, 'outbox-start')
666
792
  await storage.put(entry)
793
+ debug(entry, 'outbox-stored', startedAt)
794
+ queuedAt.set(entry, Date.now())
795
+ if (attemptStartedAt !== undefined) attemptStarts.set(id, attemptStartedAt)
796
+ if (cancelled.has(id)) { await storage.remove(id); throw new Error('CHAT_UNAVAILABLE') }
797
+ // An accepted durable intent survives shutdown/locking for replay. Only
798
+ // explicit cancellation removes it; no late callback or work is admitted.
799
+ if (closed) return id
667
800
  entries.set(id, entry); emit(); pump()
668
- if (peer !== owner) preparePeer(peer).catch(() => {})
801
+ if (available && peer !== owner) preparePeer(peer).catch(() => {})
669
802
  return id
670
- } finally { releaseForeground() }
803
+ } finally { releaseForeground(); enqueuing--; settled() }
671
804
  },
672
805
  async retry (id) {
673
806
  offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
674
807
  if (storageBlocked) inboxRecovery.start({ immediate: true })
675
808
  await ready()
676
809
  const entry = entries.get(id)
677
- 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() }
678
811
  },
679
812
  async cancel (id) {
680
813
  cancelled.add(id)
681
814
  sendControllers.get(id)?.abort()
682
815
  offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
683
816
  await ready()
684
- entries.delete(id)
685
- 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) }
686
819
  emit()
687
820
  },
688
821
  close () {
@@ -693,7 +826,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
693
826
  for (const controller of sendControllers.values()) controller.abort()
694
827
  await Promise.allSettled([configuring, initialized, messengerWork, fileSetupWork])
695
828
  await messenger?.pause('closed')
696
- if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
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))
697
832
  await messenger?.close()
698
833
  await storage?.close()
699
834
  await downloadWriteTail.catch(reportError)