libp2r2p 0.11.10 → 0.11.12

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
@@ -532,15 +532,42 @@ are attached. These diagnostics reach the messenger's `onError` unchanged.
532
532
  A single incomplete relay keeps the interval pending even if another relay
533
533
  returned events; received messages may already have been queued. This is a
534
534
  history completeness failure, not evidence that an outgoing message failed.
535
- Existing rewatch/resume and relay-list refresh paths retry pending intervals;
536
- there is no dedicated periodic retry timer for a failed history fetch.
535
+ Rewatch/resume, relay-list refresh and reload-gap paths schedule bounded retries
536
+ for pending intervals (1s doubling to 30s). Recovery tracks per-relay coverage in
537
+ memory: a tier where some relay returned events and EOSE retries the remaining
538
+ relays a few times (3), an empty-EOSE tier gets more attempts (6), and an
539
+ all-failed tier gets the most (10) before coverage from EOSE relays is accepted
540
+ and the uncovered subranges plus the pre-oldest left edge are requested from
541
+ seeders. Offline attempts never consume the budget, mark coverage or contact
542
+ seeders; they wait for connectivity. The per-relay tracker is process-local, so a
543
+ restart falls back to the persisted whole interval.
544
+
545
+ `prioritizeRange(channelPubkey, { since, until, type })` registers an
546
+ application-directed priority window for the next recovery pass. `unread-page`
547
+ requires `since` (a read anchor); `tail` derives a bounded window from the
548
+ newest persisted range using `priorityTailSeconds` (six hours by default).
549
+ Priority subranges are fetched before older history; relays are ordered by
550
+ recent EOSE, consecutive failures and latency; and the first priority page may
551
+ hedge up to `priorityHedgeRelays` (two) relays with `priorityHedgeDelayMs`
552
+ (500 ms), aborting the loser. When relays do not cover the window, seeders with
553
+ recent presence (`announcedAt`/`lastActiveAt` within `seederOnlineSeconds`) are
554
+ asked only for that subrange; a successful ask counts as coverage. Priority
555
+ probes never consume the relay tier budget, and priorities/statistics are
556
+ process-local: they expire after `priorityTtlMs` (ten minutes) or once covered,
557
+ and clear with channel/recovery state. Presence payloads and persisted ranges
558
+ are unchanged.
559
+
537
560
  `private-channel.fetchHistory(options)` (0.11.6) is the complete-interval API
538
561
  used by messenger recovery. It accepts the signers, processor/fragment options,
539
562
  callbacks and abort signal of `fetch`, plus inclusive `since`/`until` bounds
540
563
  (defaults: 0 and the start time of the call). It returns only
541
564
  `{ oldestCreatedAt, receivedEventCount, relays }`; an empty scan has a null oldest
542
- time. No complete-history event array is retained. `fetch` keeps its existing
543
- single-query array contract and optional limit.
565
+ time. Passing `partial: true` (used by messenger recovery) returns per-relay
566
+ `covered`/`pending`/`events` plus `pendingByRelay`, `anyEose`, `anyEoseWithEvents`
567
+ and `allFailed` instead of throwing on partial failure, and `resume` restricts
568
+ each relay to the given pending subranges. No complete-history event array is
569
+ retained. `fetch` keeps its existing single-query array contract and optional
570
+ limit.
544
571
 
545
572
  History queries each relay independently with limit 16. A response with 16 events
546
573
  or status `satisfied` is saturated, even if it already reports EOSE or contains
@@ -0,0 +1,47 @@
1
+ // Inclusive integer ranges [start, end]. Merge treats adjacent ranges as one.
2
+ export function mergeRanges (ranges = []) {
3
+ const sorted = (ranges || [])
4
+ .filter(range => range && Number.isFinite(range.start) && Number.isFinite(range.end) && range.end >= range.start)
5
+ .map(range => ({ start: range.start, end: range.end }))
6
+ .sort((a, b) => a.start - b.start || a.end - b.end)
7
+ const out = []
8
+ for (const range of sorted) {
9
+ const last = out[out.length - 1]
10
+ if (!last || range.start > last.end + 1) out.push(range)
11
+ else last.end = Math.max(last.end, range.end)
12
+ }
13
+ return out
14
+ }
15
+
16
+ // Parts of `range` not covered by `covered`.
17
+ export function subtractRanges (range, covered = []) {
18
+ if (!range || range.end < range.start) return []
19
+ const merged = mergeRanges(covered)
20
+ const out = []
21
+ let cursor = range.start
22
+ for (const entry of merged) {
23
+ if (entry.end < cursor) continue
24
+ if (entry.start > range.end) break
25
+ if (entry.start > cursor) out.push({ start: cursor, end: Math.min(entry.start - 1, range.end) })
26
+ cursor = Math.max(cursor, entry.end + 1)
27
+ if (cursor > range.end) break
28
+ }
29
+ if (cursor <= range.end) out.push({ start: cursor, end: range.end })
30
+ return out
31
+ }
32
+
33
+ export function intersectRanges (left = [], right = []) {
34
+ const a = mergeRanges(left)
35
+ const b = mergeRanges(right)
36
+ const out = []
37
+ let i = 0
38
+ let j = 0
39
+ while (i < a.length && j < b.length) {
40
+ const start = Math.max(a[i].start, b[j].start)
41
+ const end = Math.min(a[i].end, b[j].end)
42
+ if (start <= end) out.push({ start, end })
43
+ if (a[i].end < b[j].end) i++
44
+ else j++
45
+ }
46
+ return out
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.10",
3
+ "version": "0.11.12",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -1,6 +1,7 @@
1
1
  import { ValidationError } from '../../error/index.js'
2
2
  import { normalizeRelayUrl } from '../../url/index.js'
3
3
  import { createAbortableSemaphore } from '../../helpers/abortable-semaphore.js'
4
+ import { mergeRanges } from '../../helpers/ranges.js'
4
5
  import { incompleteFetchError } from './fetch-error.js'
5
6
 
6
7
  const PAGE_SIZE = 16
@@ -23,7 +24,7 @@ function acquireRelay (relay, signal) {
23
24
 
24
25
  // Queries bounded temporal leaves, oldest first. A saturated one-second leaf
25
26
  // cannot be split using standard NIP-01; expand it only up to the safe ceiling.
26
- export async function readHistory ({ filter, relays, receiverPubkey, signal, getEvents, processEvent, acquirePage }) {
27
+ export async function readHistory ({ filter, relays, receiverPubkey, signal, getEvents, processEvent, acquirePage, partial = false, resume = null }) {
27
28
  const since = filter.since ?? 0
28
29
  const until = filter.until ?? Math.floor(Date.now() / 1000)
29
30
  if (![since, until].every(value => Number.isSafeInteger(value) && value >= 0) || since > until) throw new ValidationError('INVALID_PRIVATE_CHANNEL_HISTORY_RANGE')
@@ -44,7 +45,11 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
44
45
  }
45
46
  }
46
47
  for (const relay of urls) {
47
- const intervals = [{ since, until, limit: PAGE_SIZE }]
48
+ const relayStartCount = receivedEventCount
49
+ let relayElapsedMs = 0
50
+ const intervals = resumeIntervalsFor({ resume, relay, since, until })
51
+ .map(interval => ({ since: interval.start, until: interval.end, limit: PAGE_SIZE }))
52
+ const covered = []
48
53
  let outcome = { relay, status: 'eose' }
49
54
  while (intervals.length) {
50
55
  signal?.throwIfAborted()
@@ -77,14 +82,20 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
77
82
  signal?.throwIfAborted()
78
83
  errors.push({ relay, reason: error })
79
84
  outcome = { relay, status: 'error', error }
85
+ intervals.push(interval)
80
86
  break
81
- } finally { elapsedMs += performance.now() - started }
87
+ } finally {
88
+ const took = performance.now() - started
89
+ elapsedMs += took
90
+ relayElapsedMs += took
91
+ }
82
92
  signal?.throwIfAborted()
83
93
  const status = page.relays?.find(item => item.relay === relay)
84
94
  if (page.errors?.length || !status || !['eose', 'satisfied'].includes(status.status)) {
85
95
  await process(page.result)
86
96
  errors.push(...(page.errors ?? []))
87
97
  outcome = status ?? { relay, status: 'error' }
98
+ intervals.push(interval)
88
99
  break
89
100
  }
90
101
  if (status.status === 'satisfied' || page.result.length >= interval.limit) {
@@ -98,10 +109,12 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
98
109
  const error = pageLimitError()
99
110
  errors.push({ relay, reason: error })
100
111
  outcome = { relay, status: 'error', error }
112
+ intervals.push(interval)
101
113
  break
102
114
  }
103
115
  } else {
104
116
  await process(page.result)
117
+ covered.push({ start: interval.since, end: interval.until })
105
118
  }
106
119
  signal?.throwIfAborted()
107
120
  } finally {
@@ -109,9 +122,35 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
109
122
  releaseRelay?.()
110
123
  }
111
124
  }
112
- report.push(outcome)
125
+ report.push({
126
+ ...outcome,
127
+ ...(partial
128
+ ? {
129
+ covered: mergeRanges(covered),
130
+ pending: mergeRanges(intervals.map(interval => ({ start: interval.since, end: interval.until }))),
131
+ events: receivedEventCount - relayStartCount,
132
+ elapsedMs: Math.round(relayElapsedMs)
133
+ }
134
+ : {})
135
+ })
113
136
  }
114
137
  signal?.throwIfAborted()
138
+ if (partial) {
139
+ const pendingByRelay = {}
140
+ for (const entry of report) {
141
+ if (entry.pending?.length) pendingByRelay[entry.relay] = entry.pending
142
+ }
143
+ return {
144
+ oldestCreatedAt,
145
+ receivedEventCount,
146
+ elapsedMs: Math.round(elapsedMs),
147
+ relays: report,
148
+ pendingByRelay,
149
+ anyEose: report.some(entry => entry.status === 'eose'),
150
+ anyEoseWithEvents: report.some(entry => entry.status === 'eose' && (entry.events ?? 0) > 0),
151
+ allFailed: report.every(entry => entry.status !== 'eose')
152
+ }
153
+ }
115
154
  if (errors.length || report.some(item => item.status !== 'eose')) {
116
155
  const error = incompleteFetchError({ errors, report, receivedEventCount, elapsedMs: Math.round(elapsedMs), request: { relays: urls, channelPubkeys: filter.authors ?? [], receiverPubkey, since, until, limit: PAGE_SIZE, timeoutMs: TIMEOUT_MS } })
117
156
  error.operation = 'private-channel.fetchHistory'
@@ -119,3 +158,14 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
119
158
  }
120
159
  return { oldestCreatedAt, receivedEventCount, relays: report }
121
160
  }
161
+
162
+ function resumeIntervalsFor ({ resume, relay, since, until }) {
163
+ const pending = resume && Object.prototype.hasOwnProperty.call(resume, relay) ? resume[relay] : null
164
+ const intervals = Array.isArray(pending) ? pending : [{ start: since, end: until }]
165
+ return mergeRanges(intervals
166
+ .map(interval => ({
167
+ start: Math.max(since, Math.floor(Number(interval?.start))),
168
+ end: Math.min(until, Math.floor(Number(interval?.end)))
169
+ }))
170
+ .filter(interval => Number.isSafeInteger(interval.start) && Number.isSafeInteger(interval.end) && interval.end >= interval.start))
171
+ }
@@ -972,12 +972,12 @@ export async function fetch ({ signal, receiverSigner, iykcSigner, privateChanne
972
972
 
973
973
  // Complete interval recovery with bounded pages and one fragment processor.
974
974
  // Existing fetch() retains its one-query/event-array contract.
975
- export async function fetchHistory ({ receiverSigner, privateChannelSigner = receiverSigner, _getEvents = getEvents, _acquirePage, ...options }) {
975
+ export async function fetchHistory ({ receiverSigner, privateChannelSigner = receiverSigner, _getEvents = getEvents, _acquirePage, partial = false, resume = null, ...options }) {
976
976
  const authors = privateChannelPubkeyList(options)
977
977
  const filter = { kinds: [PRIVATE_BROADCAST_KIND], ...(authors.length ? { authors } : {}), since: options.since, until: options.until }
978
978
  const processOuterEvent = createProcessor({ ...options, receiverSigner, privateChannelSigner, onError: error => { options.onError?.(error); throw error } })
979
979
  try {
980
- return await readHistory({ filter, relays: options.relays, receiverPubkey: options.receiverPubkey, signal: options.signal, getEvents: _getEvents, processEvent: processOuterEvent, acquirePage: _acquirePage })
980
+ return await readHistory({ filter, relays: options.relays, receiverPubkey: options.receiverPubkey, signal: options.signal, getEvents: _getEvents, processEvent: processOuterEvent, acquirePage: _acquirePage, partial, resume })
981
981
  } finally { processOuterEvent.close() }
982
982
  }
983
983
 
@@ -24,3 +24,19 @@ export function recoveryRetryDelay (errors, delay) {
24
24
  errors.forEach(visit)
25
25
  return retry && !blocked ? Math.max(delay, retryAt - Date.now()) : null
26
26
  }
27
+
28
+ // Permanent per-relay failures must stop that relay's retries instead of
29
+ // consuming the range retry budget.
30
+ export function isPermanentRecoveryError (error) {
31
+ const seen = new Set()
32
+ const visit = value => {
33
+ if (!value || seen.has(value)) return false
34
+ seen.add(value)
35
+ if (value.code === 'PRIVATE_CHANNEL_HISTORY_PAGE_LIMIT' || value.name === 'ValidationError' ||
36
+ /^(auth-required:|restricted:|blocked:|invalid:|pow:)/.test(value.message || '')) return true
37
+ for (const child of value.errors || []) if (visit(child)) return true
38
+ for (const entry of value.relays || []) if (visit(entry.error)) return true
39
+ return visit(value.cause)
40
+ }
41
+ return visit(error)
42
+ }
@@ -1,4 +1,3 @@
1
- import { recoveryRetryDelay } from './helpers/recovery-retry.js'
2
1
  // Expected use:
3
2
  // const messenger = await createPrivateMessenger({
4
3
  // userSigner,
@@ -44,9 +43,12 @@ import { recoveryRetryDelay } from './helpers/recovery-retry.js'
44
43
  // - For other event-list replies, use createEventReplyPacker({ messenger, question, code }).update(event).
45
44
 
46
45
  import { createAbortableSemaphore } from '../helpers/abortable-semaphore.js'
46
+ import { mergeRanges, subtractRanges, intersectRanges } from '../helpers/ranges.js'
47
47
  import * as privateMessage from '../private-message/index.js'
48
48
  import { isOnline } from '../network/index.js'
49
49
  import { createSendRelayRouting, normalizeFallbackRelays } from './helpers/send-routing.js'
50
+ import { incompleteFetchError } from '../private-channel/helpers/fetch-error.js'
51
+ import { recoveryRetryDelay, isPermanentRecoveryError } from './helpers/recovery-retry.js'
50
52
  import { normalizeRelayUrl } from '../url/index.js'
51
53
  import { deliveryInfo } from '../private-channel/helpers/rumor.js'
52
54
  import { bytesToBase64 } from '../base64/index.js'
@@ -122,6 +124,17 @@ const SEED_QUEUE_INDEXES = {
122
124
  const encoder = new TextEncoder()
123
125
  const noContentKeys = async () => ({})
124
126
 
127
+ export const RECOVERY_RELAY_RETRY_LIMITS = Object.freeze({
128
+ partial: 3,
129
+ emptyEose: 6,
130
+ allFailed: 10
131
+ })
132
+ const RECOVERY_ONLINE_CACHE_MS = 5000
133
+ const DEFAULT_PRIORITY_TAIL_SECONDS = 6 * 3600
134
+ const DEFAULT_PRIORITY_TTL_MS = 10 * 60 * 1000
135
+ const DEFAULT_PRIORITY_HEDGE_DELAY_MS = 500
136
+ const DEFAULT_PRIORITY_HEDGE_RELAYS = 2
137
+
125
138
  function textToBase64 (text) {
126
139
  return bytesToBase64(encoder.encode(text))
127
140
  }
@@ -204,6 +217,10 @@ export class PrivateMessenger {
204
217
  reloadGapDelayMs = DEFAULT_RELOAD_GAP_DELAY_MS,
205
218
  seederPresenceIntervalMs = DEFAULT_SEEDER_PRESENCE_INTERVAL_MS,
206
219
  seederOnlineSeconds = DEFAULT_SEEDER_ONLINE_SECONDS,
220
+ priorityTailSeconds = DEFAULT_PRIORITY_TAIL_SECONDS,
221
+ priorityTtlMs = DEFAULT_PRIORITY_TTL_MS,
222
+ priorityHedgeDelayMs = DEFAULT_PRIORITY_HEDGE_DELAY_MS,
223
+ priorityHedgeRelays = DEFAULT_PRIORITY_HEDGE_RELAYS,
207
224
  maxDynamicRecoverySeeders = DEFAULT_MAX_DYNAMIC_RECOVERY_SEEDERS,
208
225
  messageQueueMaxBytes = DEFAULT_MESSAGE_QUEUE_MAX_BYTES,
209
226
  seedQueueMaxBytes = DEFAULT_SEED_QUEUE_MAX_BYTES,
@@ -238,6 +255,19 @@ export class PrivateMessenger {
238
255
  this.reloadGapDelayMs = reloadGapDelayMs
239
256
  this.seederPresenceIntervalMs = seederPresenceIntervalMs
240
257
  this.seederOnlineSeconds = seederOnlineSeconds
258
+ this.priorityTailSeconds = Number.isFinite(priorityTailSeconds) && priorityTailSeconds >= 0
259
+ ? Math.floor(priorityTailSeconds)
260
+ : DEFAULT_PRIORITY_TAIL_SECONDS
261
+ this.priorityTtlMs = Number.isFinite(priorityTtlMs) && priorityTtlMs > 0
262
+ ? Math.floor(priorityTtlMs)
263
+ : DEFAULT_PRIORITY_TTL_MS
264
+ this.priorityHedgeDelayMs = Number.isFinite(priorityHedgeDelayMs) && priorityHedgeDelayMs >= 0
265
+ ? Math.floor(priorityHedgeDelayMs)
266
+ : DEFAULT_PRIORITY_HEDGE_DELAY_MS
267
+ const hedgeRelays = Math.floor(Number(priorityHedgeRelays))
268
+ this.priorityHedgeRelays = Number.isSafeInteger(hedgeRelays) && hedgeRelays >= 1
269
+ ? Math.min(hedgeRelays, 3)
270
+ : DEFAULT_PRIORITY_HEDGE_RELAYS
241
271
  this.maxDynamicRecoverySeeders = maxDynamicRecoverySeeders
242
272
  this.messageQueueMaxBytes = messageQueueMaxBytes
243
273
  this.seedQueueMaxBytes = seedQueueMaxBytes
@@ -289,6 +319,9 @@ export class PrivateMessenger {
289
319
  this.recoveries = new Map()
290
320
  this.recoveryAdmission = createAbortableSemaphore(2)
291
321
  this.recoveryControllers = new Set()
322
+ this.recoveryRanges = new Map()
323
+ this.recoveryPriority = new Map()
324
+ this.recoveryOnline = { at: 0, value: true }
292
325
  this.resumeWork = null
293
326
  this.capacityTimer = null
294
327
  this.capacityCheck = null
@@ -664,6 +697,7 @@ export class PrivateMessenger {
664
697
 
665
698
  if (removedPubkeys.length) {
666
699
  await this.stampChannelActivity(removedPubkeys)
700
+ for (const pubkey of removedPubkeys) this.dropRecoveryState(pubkey)
667
701
  }
668
702
 
669
703
  const storageSnapshot = await activatePrivateMessengerStorage({
@@ -1174,7 +1208,347 @@ export class PrivateMessenger {
1174
1208
  this.liveRecoveryTimers.delete(pubkey)
1175
1209
  }
1176
1210
 
1211
+ async checkRecoveryOnline () {
1212
+ const now = Date.now()
1213
+ if (now - this.recoveryOnline.at < RECOVERY_ONLINE_CACHE_MS) return this.recoveryOnline.value
1214
+ let value = true
1215
+ try {
1216
+ if (globalThis.navigator?.onLine === false) value = false
1217
+ else if (this._isOnline !== isOnline || typeof globalThis.document !== 'undefined') {
1218
+ value = (await this._isOnline()) !== false
1219
+ }
1220
+ } catch { value = false }
1221
+ this.recoveryOnline = { at: Date.now(), value }
1222
+ return value
1223
+ }
1224
+
1225
+ recoveryRangeKey (channelPubkey, range) {
1226
+ return `${channelPubkey}:${range.start}:${range.end}`
1227
+ }
1228
+
1229
+ recoveryRangeRecord (channelPubkey, range) {
1230
+ const key = this.recoveryRangeKey(channelPubkey, range)
1231
+ let record = this.recoveryRanges.get(key)
1232
+ if (!record) {
1233
+ record = {
1234
+ key,
1235
+ channelPubkey,
1236
+ start: range.start,
1237
+ end: range.end,
1238
+ covered: [],
1239
+ claimed: [],
1240
+ oldestCreatedAt: null,
1241
+ relays: new Map(),
1242
+ tier: '',
1243
+ everEose: false,
1244
+ everEoseWithEvents: false,
1245
+ attempts: 0,
1246
+ lastErrors: []
1247
+ }
1248
+ this.recoveryRanges.set(key, record)
1249
+ }
1250
+ return record
1251
+ }
1252
+
1253
+ reconcileRecoveryRelays (record, relays) {
1254
+ const current = new Set(relays)
1255
+ for (const relay of [...record.relays.keys()]) {
1256
+ if (!current.has(relay)) record.relays.delete(relay)
1257
+ }
1258
+ for (const relay of current) {
1259
+ if (record.relays.has(relay)) continue
1260
+ record.relays.set(relay, {
1261
+ pending: [{ start: record.start, end: record.end }],
1262
+ covered: [],
1263
+ attempts: 0,
1264
+ dead: false,
1265
+ eose: false,
1266
+ events: 0,
1267
+ error: null,
1268
+ consecutiveFailures: 0,
1269
+ latencyMs: null,
1270
+ lastEoseAt: 0,
1271
+ lastErrorAt: 0
1272
+ })
1273
+ }
1274
+ }
1275
+
1276
+ orderedRecoveryRelays (record) {
1277
+ return [...record.relays.entries()]
1278
+ .filter(([, state]) => !state.dead && !state.eose && state.pending?.length)
1279
+ .sort((a, b) => {
1280
+ const failures = (a[1].consecutiveFailures || 0) - (b[1].consecutiveFailures || 0)
1281
+ if (failures) return failures
1282
+ const latency = (a[1].latencyMs ?? Infinity) - (b[1].latencyMs ?? Infinity)
1283
+ if (latency) return latency
1284
+ return (b[1].lastEoseAt || 0) - (a[1].lastEoseAt || 0)
1285
+ })
1286
+ }
1287
+
1288
+ recoveryRelayPlan (record) {
1289
+ const plan = {}
1290
+ const ordered = this.orderedRecoveryRelays(record)
1291
+ for (const [relay, state] of ordered) {
1292
+ plan[relay] = state.pending
1293
+ }
1294
+ return ordered.length ? plan : null
1295
+ }
1296
+
1297
+ hasActiveRecoveryRelays (channelPubkey) {
1298
+ for (const record of this.recoveryRanges.values()) {
1299
+ if (record.channelPubkey !== channelPubkey) continue
1300
+ if (this.recoveryRecordActive(record)) return true
1301
+ }
1302
+ return false
1303
+ }
1304
+
1305
+ recoveryRecordActive (record) {
1306
+ for (const state of record.relays.values()) {
1307
+ if (!state.dead && !state.eose && state.pending?.length) return true
1308
+ }
1309
+ return false
1310
+ }
1311
+
1312
+ hasPendingRecovery (channelPubkey) {
1313
+ return (this.readState().channels[channelPubkey]?.offlineRanges || []).length > 0
1314
+ }
1315
+
1316
+ dropRecoveryRanges (channelPubkey) {
1317
+ for (const [key, record] of this.recoveryRanges) {
1318
+ if (record.channelPubkey === channelPubkey) this.recoveryRanges.delete(key)
1319
+ }
1320
+ }
1321
+
1322
+ pruneRecoveryRanges (channelPubkey, ranges) {
1323
+ const keep = new Set((ranges || []).map(range => this.recoveryRangeKey(channelPubkey, range)))
1324
+ for (const [key, record] of this.recoveryRanges) {
1325
+ if (record.channelPubkey === channelPubkey && !keep.has(key)) this.recoveryRanges.delete(key)
1326
+ }
1327
+ }
1328
+
1329
+ dropRecoveryState (channelPubkey) {
1330
+ this.dropRecoveryRanges(channelPubkey)
1331
+ this.recoveryPriority.delete(channelPubkey)
1332
+ }
1333
+
1334
+ recoveryPriorities (channelPubkey) {
1335
+ const entries = this.recoveryPriority.get(channelPubkey) || []
1336
+ if (!entries.length) return []
1337
+ const now = Date.now()
1338
+ const active = entries.filter(entry => entry.expiresAt > now)
1339
+ if (active.length !== entries.length) {
1340
+ if (active.length) this.recoveryPriority.set(channelPubkey, active)
1341
+ else this.recoveryPriority.delete(channelPubkey)
1342
+ }
1343
+ return active
1344
+ }
1345
+
1346
+ async prioritizeRange (channelPubkey, { since, until, type = 'unread-page', ttlMs } = {}) {
1347
+ if (typeof channelPubkey !== 'string' || !/^[0-9a-f]{64}$/i.test(channelPubkey)) throw new ValidationError('INVALID_CHANNEL_PUBKEY')
1348
+ if (type !== 'unread-page' && type !== 'tail') throw new ValidationError('INVALID_PRIORITY_TYPE')
1349
+ if (since !== undefined && !Number.isSafeInteger(since)) throw new ValidationError('INVALID_PRIORITY_RANGE')
1350
+ if (until !== undefined && !Number.isSafeInteger(until)) throw new ValidationError('INVALID_PRIORITY_RANGE')
1351
+ if (type === 'unread-page' && since === undefined) throw new ValidationError('PRIORITY_SINCE_REQUIRED')
1352
+
1353
+ const now = nowSeconds()
1354
+ const ranges = (this.readState().channels[channelPubkey]?.offlineRanges || []).slice()
1355
+ if (!ranges.length) return false
1356
+
1357
+ let start = since
1358
+ let end = until
1359
+ if (type === 'unread-page') {
1360
+ const range = ranges.filter(candidate => candidate.end >= start).sort((a, b) => a.start - b.start)[0]
1361
+ if (!range) return false
1362
+ start = Math.max(start, range.start)
1363
+ if (end === undefined) end = Math.min(range.end, now)
1364
+ } else {
1365
+ const range = ranges.sort((a, b) => b.end - a.end)[0]
1366
+ if (end === undefined) end = Math.min(range.end, now)
1367
+ if (start === undefined) start = Math.max(range.start, end - this.priorityTailSeconds)
1368
+ }
1369
+
1370
+ const recoverySeconds = this.offlineRecoverySecondsFor(channelPubkey)
1371
+ if (recoverySeconds) {
1372
+ start = Math.max(start, now - recoverySeconds)
1373
+ end = Math.min(end, now)
1374
+ }
1375
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || end < start) return false
1376
+
1377
+ const ttl = Number.isSafeInteger(ttlMs) && ttlMs > 0 ? ttlMs : this.priorityTtlMs
1378
+ const entries = this.recoveryPriority.get(channelPubkey) || []
1379
+ const createdAt = Date.now()
1380
+ if (!entries.some(entry => entry.start === start && entry.end === end && entry.type === type)) {
1381
+ entries.push({ start, end, type, createdAt, expiresAt: createdAt + ttl })
1382
+ this.recoveryPriority.set(channelPubkey, entries)
1383
+ }
1384
+ return true
1385
+ }
1386
+
1387
+ updateRecoveryRecord (record, history, { completeRelays = new Set((history.relays || []).map(entry => entry.relay)) } = {}) {
1388
+ record.covered = mergeRanges([
1389
+ ...record.covered,
1390
+ ...(history.relays || []).flatMap(entry => entry.covered || [])
1391
+ ])
1392
+ if (history.oldestCreatedAt != null) {
1393
+ record.oldestCreatedAt = record.oldestCreatedAt == null
1394
+ ? history.oldestCreatedAt
1395
+ : Math.min(record.oldestCreatedAt, history.oldestCreatedAt)
1396
+ }
1397
+ const byRelay = new Map((history.relays || []).map(entry => [entry.relay, entry]))
1398
+ for (const [relay, state] of record.relays) {
1399
+ const entry = byRelay.get(relay)
1400
+ if (!entry) continue
1401
+ const relayErrors = [...(entry.errors || [])]
1402
+ if (entry.error) relayErrors.push(entry.error)
1403
+ state.error = relayErrors[0] || null
1404
+ const done = entry.status === 'eose' && !(entry.pending?.length) && completeRelays.has(relay)
1405
+ state.covered = mergeRanges([...(state.covered || []), ...(entry.covered || [])])
1406
+ state.eose = done
1407
+ state.pending = done
1408
+ ? []
1409
+ : subtractRanges({ start: record.start, end: record.end }, mergeRanges([...state.covered, ...record.claimed]))
1410
+ state.events += entry.events || 0
1411
+ if (Number.isFinite(entry.elapsedMs)) {
1412
+ state.latencyMs = state.latencyMs == null
1413
+ ? entry.elapsedMs
1414
+ : Math.round(state.latencyMs * 0.7 + entry.elapsedMs * 0.3)
1415
+ }
1416
+ if (done) {
1417
+ state.attempts = 0
1418
+ state.consecutiveFailures = 0
1419
+ state.lastEoseAt = nowSeconds()
1420
+ } else if (entry.status === 'eose') {
1421
+ state.consecutiveFailures = 0
1422
+ state.lastEoseAt = nowSeconds()
1423
+ } else {
1424
+ state.consecutiveFailures = (state.consecutiveFailures || 0) + 1
1425
+ state.lastErrorAt = nowSeconds()
1426
+ }
1427
+ if (entry.status === 'eose') {
1428
+ record.everEose = true
1429
+ if (state.events > 0) record.everEoseWithEvents = true
1430
+ }
1431
+ }
1432
+ record.tier = record.everEoseWithEvents ? 'partial' : record.everEose ? 'emptyEose' : 'allFailed'
1433
+ record.lastErrors = (history.relays || []).flatMap(entry => [...(entry.errors || []), ...(entry.error ? [entry.error] : [])])
1434
+ }
1435
+
1436
+ applyRecoveryCoverage (record, ranges) {
1437
+ if (!ranges?.length) return
1438
+ record.claimed = mergeRanges([...record.claimed, ...ranges])
1439
+ record.covered = mergeRanges([...record.covered, ...ranges])
1440
+ for (const state of record.relays.values()) {
1441
+ if (state.eose) continue
1442
+ state.pending = subtractRanges({ start: record.start, end: record.end }, mergeRanges([...state.covered, ...record.claimed]))
1443
+ }
1444
+ }
1445
+
1446
+ activeRecoverySeeders (channelPubkey) {
1447
+ if (!this.offlineRecoverySecondsFor(channelPubkey)) return []
1448
+ const channel = this.channels.get(channelPubkey)
1449
+ if (!channel?.signer) return []
1450
+ const activity = this.readState().channels[channelPubkey]?.seederActivity || {}
1451
+ const cutoff = nowSeconds() - this.seederOnlineSeconds
1452
+ const candidates = new Set([...(channel.seeders || []), ...Object.keys(activity)])
1453
+ return [...candidates].filter(seeder => {
1454
+ if (!seeder || seeder === this.userPubkey) return false
1455
+ const entry = activity[seeder]
1456
+ const recent = Math.max(entry?.announcedAt || 0, entry?.lastActiveAt || 0)
1457
+ return recent >= cutoff
1458
+ })
1459
+ }
1460
+
1461
+ prunePriorityEntries (channelPubkey) {
1462
+ const entries = this.recoveryPriorities(channelPubkey)
1463
+ if (!entries.length) return
1464
+ const records = [...this.recoveryRanges.values()].filter(record => record.channelPubkey === channelPubkey)
1465
+ const next = entries.filter(entry => {
1466
+ let intersects = false
1467
+ for (const record of records) {
1468
+ const parts = intersectRanges(
1469
+ [{ start: entry.start, end: entry.end }],
1470
+ [{ start: record.start, end: record.end }]
1471
+ )
1472
+ if (!parts.length) continue
1473
+ intersects = true
1474
+ if (subtractRanges(parts[0], record.covered).length) return true
1475
+ }
1476
+ return !intersects
1477
+ })
1478
+ if (next.length) this.recoveryPriority.set(channelPubkey, next)
1479
+ else this.recoveryPriority.delete(channelPubkey)
1480
+ }
1481
+
1482
+ applyRecoveryRetryBudget (record) {
1483
+ const limit = RECOVERY_RELAY_RETRY_LIMITS[record.tier] ?? RECOVERY_RELAY_RETRY_LIMITS.allFailed
1484
+ for (const state of record.relays.values()) {
1485
+ if (state.eose || state.dead || !state.pending?.length) continue
1486
+ state.attempts++
1487
+ if (state.attempts >= limit || isPermanentRecoveryError(state.error)) state.dead = true
1488
+ }
1489
+ record.attempts++
1490
+ }
1491
+
1492
+ recoveryIncompleteError (record, history, range) {
1493
+ const errors = []
1494
+ for (const entry of history.relays || []) {
1495
+ for (const reason of entry.errors || []) errors.push({ relay: entry.relay, reason })
1496
+ if (entry.error) errors.push({ relay: entry.relay, reason: entry.error })
1497
+ }
1498
+ const error = incompleteFetchError({
1499
+ errors,
1500
+ report: (history.relays || []).map(entry => ({
1501
+ relay: entry.relay,
1502
+ status: entry.status,
1503
+ ...(entry.error ? { error: entry.error } : {})
1504
+ })),
1505
+ receivedEventCount: history.receivedEventCount || 0,
1506
+ elapsedMs: history.elapsedMs || 0,
1507
+ request: {
1508
+ relays: [...record.relays.keys()],
1509
+ channelPubkeys: [record.channelPubkey],
1510
+ receiverPubkey: this.userPubkey,
1511
+ since: range.start,
1512
+ until: range.end,
1513
+ limit: 16,
1514
+ timeoutMs: 5000
1515
+ }
1516
+ })
1517
+ error.operation = 'private-channel.fetchHistory'
1518
+ return error
1519
+ }
1520
+
1521
+ isPartialRecoveryHistory (history) {
1522
+ return Boolean(history) && !Array.isArray(history) &&
1523
+ Array.isArray(history.relays) &&
1524
+ history.pendingByRelay !== undefined &&
1525
+ typeof history.anyEose === 'boolean'
1526
+ }
1527
+
1528
+ async finalizeRecoveryRange ({ channelPubkey, range, record }) {
1529
+ if (!await this.checkRecoveryOnline()) return { complete: false, failures: [], offline: true }
1530
+ const uncovered = subtractRanges({ start: range.start, end: range.end }, record.covered)
1531
+ const oldest = record.oldestCreatedAt
1532
+ const leftEdge = oldest == null
1533
+ ? [{ start: range.start, end: range.end }]
1534
+ : oldest > range.start
1535
+ ? [{ start: range.start, end: Math.min(oldest, range.end) }]
1536
+ : []
1537
+ const seedRanges = mergeRanges([...uncovered, ...leftEdge])
1538
+ const failures = []
1539
+ for (const seedRange of seedRanges) {
1540
+ const attempt = await this.#askSeedersForMissingRangeAttempt(channelPubkey, seedRange.start, seedRange.end)
1541
+ failures.push(...attempt.failures.map(failure => failure.error))
1542
+ }
1543
+ record.lastErrors = failures
1544
+ return { complete: failures.length === 0, failures }
1545
+ }
1546
+
1177
1547
  scheduleLiveRecovery (pubkey) {
1548
+ return this.scheduleOfflineRecovery(pubkey)
1549
+ }
1550
+
1551
+ scheduleOfflineRecovery (pubkey, { startDelayMs = 0 } = {}) {
1178
1552
  const current = this.liveRecoveryTimers.get(pubkey)
1179
1553
  if (current) { current.again = true; return }
1180
1554
  const work = { delay: 1000, timer: null, again: false }
@@ -1199,7 +1573,11 @@ export class PrivateMessenger {
1199
1573
  }
1200
1574
  if (!isCurrent()) return
1201
1575
  const pending = this.readState().channels[pubkey]?.offlineRanges?.length
1202
- const retry = pending ? recoveryRetryDelay(errors, Math.min(30000, work.delay * (0.8 + Math.random() * 0.4))) : null
1576
+ let retry = null
1577
+ if (pending) {
1578
+ retry = recoveryRetryDelay(errors, Math.min(30000, work.delay * (0.8 + Math.random() * 0.4)))
1579
+ if (retry === null && this.hasActiveRecoveryRelays(pubkey)) retry = Math.min(30000, work.delay)
1580
+ }
1203
1581
  if (work.again || retry !== null) {
1204
1582
  schedule(work.again ? 0 : retry)
1205
1583
  work.delay = Math.min(30000, work.delay * 2)
@@ -1207,7 +1585,7 @@ export class PrivateMessenger {
1207
1585
  }, delay)
1208
1586
  work.timer?.unref?.()
1209
1587
  }
1210
- schedule(0)
1588
+ schedule(startDelayMs)
1211
1589
  }
1212
1590
 
1213
1591
  recordInterruption (channels, at = nowSeconds()) {
@@ -1242,6 +1620,7 @@ export class PrivateMessenger {
1242
1620
  const pubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.desiredChannels]
1243
1621
  for (const extension of this.extensions) extension.unwatch?.(pubkeys)
1244
1622
  for (const pubkey of pubkeys) this.desiredChannels.delete(pubkey)
1623
+ for (const pubkey of pubkeys) this.dropRecoveryState(pubkey)
1245
1624
  this.recordInterruption(pubkeys)
1246
1625
  for (const controller of this.recoveryControllers) {
1247
1626
  if (pubkeys.includes(controller.channelPubkey)) controller.abort()
@@ -1278,6 +1657,9 @@ export class PrivateMessenger {
1278
1657
  if (this.pauseReasons.size || this.closePromise) return
1279
1658
  await this.reconcilePresencePublishers()
1280
1659
  await this.recoverOfflineRanges(channels)
1660
+ for (const pubkey of channels) {
1661
+ if (this.hasPendingRecovery(pubkey)) this.scheduleOfflineRecovery(pubkey, { startDelayMs: 1000 })
1662
+ }
1281
1663
  for (const extension of this.extensions) await extension.resume?.()
1282
1664
  })()
1283
1665
  this.resumeWork = work
@@ -1348,6 +1730,9 @@ export class PrivateMessenger {
1348
1730
  }
1349
1731
  await this.watch(channelPubkeys, { scheduleReloadGap: false })
1350
1732
  await this.recoverOfflineRanges(channelPubkeys)
1733
+ for (const pubkey of channelPubkeys) {
1734
+ if (this.hasPendingRecovery(pubkey)) this.scheduleOfflineRecovery(pubkey, { startDelayMs: 1000 })
1735
+ }
1351
1736
  }
1352
1737
 
1353
1738
  async handleAsk (channelPubkey, message) {
@@ -1928,6 +2313,9 @@ export class PrivateMessenger {
1928
2313
  }
1929
2314
  this.writeState(state)
1930
2315
  await this.flushStateWrites()
2316
+ for (const channel of channels) {
2317
+ if (!this.offlineRecoverySecondsFor(channel)) this.dropRecoveryState(channel.pubkey)
2318
+ }
1931
2319
  for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
1932
2320
  })
1933
2321
  }
@@ -1968,6 +2356,9 @@ export class PrivateMessenger {
1968
2356
  if ((this.watchRevisionByChannel.get(pubkey) || 0) !== revision) return
1969
2357
  if (start != null) this.addOfflineRange(pubkey, Math.max(0, start - this.offlineSkewSeconds), nowSeconds())
1970
2358
  try { await this.recoverOfflineRanges([pubkey]) } catch (err) { this.onError?.(err) }
2359
+ if (!this.closePromise && !this.pauseReasons.size && this.desiredChannels.has(pubkey) && this.hasPendingRecovery(pubkey)) {
2360
+ this.scheduleOfflineRecovery(pubkey, { startDelayMs: 1000 })
2361
+ }
1971
2362
  }, this.reloadGapDelayMs)
1972
2363
  this.reloadGapTimers.set(pubkey, { timer, token, revision })
1973
2364
  }
@@ -1998,15 +2389,15 @@ export class PrivateMessenger {
1998
2389
  return asks
1999
2390
  }
2000
2391
 
2001
- async #askSeedersForMissingRangeAttempt (channelPubkey, since, until) {
2392
+ async #askSeedersForMissingRangeAttempt (channelPubkey, since, until, { seeders } = {}) {
2002
2393
  if (!this.offlineRecoverySecondsFor(channelPubkey)) return { asks: [], failures: [] }
2003
2394
  if (!this.channels.get(channelPubkey)?.signer) return { asks: [], failures: [] }
2004
- const seeders = this.recoverySeeders(channelPubkey)
2005
- if (!seeders.length || until < since) return { asks: [], failures: [] }
2395
+ const candidates = seeders ?? this.recoverySeeders(channelPubkey)
2396
+ if (!candidates.length || until < since) return { asks: [], failures: [] }
2006
2397
 
2007
2398
  const asks = []
2008
2399
  const failures = []
2009
- for (const seeder of seeders) {
2400
+ for (const seeder of candidates) {
2010
2401
  try {
2011
2402
  const ask = await this.ask({
2012
2403
  channelPubkey,
@@ -2152,24 +2543,176 @@ export class PrivateMessenger {
2152
2543
  return failures
2153
2544
  }
2154
2545
 
2546
+ recoveryFetchOptions ({ channelPubkey, channel, relays, since, until, signal, partial = false, resume = null }) {
2547
+ return {
2548
+ _acquirePage: pageSignal => this.recoveryAdmission.acquire(pageSignal),
2549
+ signal,
2550
+ receivedChunkScope: this.storageLeaseId,
2551
+ receiverSigner: this.userSigner,
2552
+ iykcSigner: this.contentKeySigner,
2553
+ privateChannelSigner: channel.signer,
2554
+ privateChannelReaderSigner: channel.readerSigner,
2555
+ privateChannelReaderPubkey: channel.readerPubkey,
2556
+ privateChannelPubkeys: [channelPubkey],
2557
+ receiverPubkey: this.userPubkey,
2558
+ relays,
2559
+ since: Math.max(0, since),
2560
+ until,
2561
+ partial,
2562
+ resume,
2563
+ mode: channel.mode,
2564
+ modeByPubkey: { [channelPubkey]: channel.mode },
2565
+ receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
2566
+ receivedChunkIndexedDB: this._indexedDB,
2567
+ onEvent: (event, outer, meta) => this.receive(channelPubkey, () => this.enqueueRumor(eventType(event), channelPubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
2568
+ onNymEvent: (event, outer, meta) => this.receive(channelPubkey, () => this.enqueueRumor('nym', channelPubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
2569
+ onSeedEvent: seed => this.receive(channelPubkey, () => this.enqueueSeed(channelPubkey, seed), seed),
2570
+ onContentKeyUsage: usage => this.handleContentKeyUsage(channelPubkey, usage),
2571
+ onError: err => { throw err }
2572
+ }
2573
+ }
2574
+
2575
+ async hedgedPriorityFetch ({ channelPubkey, channel, candidates, since, until }) {
2576
+ const launched = []
2577
+ let hedgeTimer = null
2578
+ let secondStarted = false
2579
+ const launch = candidate => {
2580
+ const controller = new AbortController()
2581
+ const promise = this._privateChannel
2582
+ .fetchHistory(this.recoveryFetchOptions({
2583
+ channelPubkey,
2584
+ channel,
2585
+ relays: [candidate.relay],
2586
+ since,
2587
+ until,
2588
+ signal: controller.signal,
2589
+ partial: true,
2590
+ resume: { [candidate.relay]: candidate.pending }
2591
+ }))
2592
+ .then(
2593
+ history => ({ ok: true, relay: candidate.relay, controller, history }),
2594
+ error => ({ ok: false, relay: candidate.relay, controller, error })
2595
+ )
2596
+ promise.catch(() => {})
2597
+ launched.push({ promise, controller })
2598
+ return promise
2599
+ }
2600
+ try {
2601
+ const first = launch(candidates[0])
2602
+ const hedge = candidates.length > 1
2603
+ ? new Promise(resolve => {
2604
+ hedgeTimer = this._setTimeout(() => {
2605
+ secondStarted = true
2606
+ resolve(launch(candidates[1]))
2607
+ }, this.priorityHedgeDelayMs)
2608
+ })
2609
+ : null
2610
+ let settled = hedge ? await Promise.race([first, hedge]) : await first
2611
+ if (!settled.ok) {
2612
+ if (secondStarted && launched.length > 1) settled = await launched[launched.length - 1].promise
2613
+ else if (candidates.length > 1) settled = await launch(candidates[1])
2614
+ }
2615
+ if (!settled.ok) return null
2616
+ for (const entry of launched) {
2617
+ if (entry.controller === settled.controller) continue
2618
+ try { entry.controller.abort() } catch { /* aborted */ }
2619
+ }
2620
+ return settled
2621
+ } finally {
2622
+ if (hedgeTimer) this._clearTimeout(hedgeTimer)
2623
+ }
2624
+ }
2625
+
2626
+ async askPrioritySeeders (channelPubkey, intervals) {
2627
+ if (!intervals.length) return []
2628
+ if (!await this.checkRecoveryOnline()) return []
2629
+ const seeders = this.activeRecoverySeeders(channelPubkey)
2630
+ if (!seeders.length) return []
2631
+ const failures = []
2632
+ const claimed = []
2633
+ for (const interval of mergeRanges(intervals)) {
2634
+ const attempt = await this.#askSeedersForMissingRangeAttempt(channelPubkey, interval.start, interval.end, { seeders })
2635
+ failures.push(...attempt.failures.map(failure => failure.error))
2636
+ if (!attempt.failures.length) claimed.push(interval)
2637
+ }
2638
+ if (claimed.length) {
2639
+ for (const record of this.recoveryRanges.values()) {
2640
+ if (record.channelPubkey !== channelPubkey) continue
2641
+ const applied = intersectRanges(claimed, [{ start: record.start, end: record.end }])
2642
+ if (applied.length) this.applyRecoveryCoverage(record, applied)
2643
+ }
2644
+ }
2645
+ return failures
2646
+ }
2647
+
2648
+ async runPriorityLane (channelPubkey, channel, ranges) {
2649
+ const priorities = this.recoveryPriorities(channelPubkey)
2650
+ if (!priorities.length) return
2651
+ const fetchRelays = await this.resolveWatchRelays(channel)
2652
+ for (const range of ranges) {
2653
+ const record = this.recoveryRangeRecord(channelPubkey, range)
2654
+ this.reconcileRecoveryRelays(record, fetchRelays)
2655
+ const priorityIntervals = intersectRanges(
2656
+ priorities.map(entry => ({ start: entry.start, end: entry.end })),
2657
+ [{ start: range.start, end: range.end }]
2658
+ )
2659
+ if (!priorityIntervals.length) continue
2660
+ const pending = subtractRanges({ start: range.start, end: range.end }, record.covered)
2661
+ const wanted = intersectRanges(priorityIntervals, pending)
2662
+ if (!wanted.length) continue
2663
+ const candidates = this.orderedRecoveryRelays(record)
2664
+ .map(([relay, state]) => ({ relay, pending: intersectRanges(state.pending, wanted) }))
2665
+ .filter(candidate => candidate.pending.length)
2666
+ .slice(0, this.priorityHedgeRelays)
2667
+ if (candidates.length) {
2668
+ const winner = await this.hedgedPriorityFetch({
2669
+ channelPubkey,
2670
+ channel,
2671
+ candidates,
2672
+ since: wanted[0].start,
2673
+ until: wanted[wanted.length - 1].end
2674
+ })
2675
+ if (winner?.history) {
2676
+ if (this.isPartialRecoveryHistory(winner.history)) this.updateRecoveryRecord(record, winner.history, { completeRelays: new Set() })
2677
+ else this.applyRecoveryCoverage(record, wanted)
2678
+ }
2679
+ }
2680
+ const uncovered = intersectRanges(wanted, subtractRanges({ start: range.start, end: range.end }, record.covered))
2681
+ if (uncovered.length) await this.askPrioritySeeders(channelPubkey, uncovered)
2682
+ }
2683
+ this.prunePriorityEntries(channelPubkey)
2684
+ }
2685
+
2155
2686
  async recoverChannelRanges (channels) {
2156
2687
  const failures = []
2157
2688
  const state = this.readState()
2158
2689
  const now = nowSeconds()
2690
+ const online = await this.checkRecoveryOnline()
2159
2691
 
2160
2692
  for (const pubkey of uniq(channels)) {
2161
2693
  const channel = this.channels.get(pubkey)
2162
2694
  const current = state.channels[pubkey]
2163
- if (!channel || !current?.offlineRanges?.length) continue
2695
+ if (!channel || !current?.offlineRanges?.length) {
2696
+ this.dropRecoveryState(pubkey)
2697
+ continue
2698
+ }
2164
2699
  const recoverySeconds = this.offlineRecoverySecondsFor(channel)
2165
- if (!recoverySeconds) continue
2700
+ if (!recoverySeconds) {
2701
+ this.dropRecoveryState(pubkey)
2702
+ continue
2703
+ }
2166
2704
  const minStart = now - recoverySeconds
2167
2705
  const processedRanges = new Set(current.offlineRanges.map(range => `${range.start}:${range.end}`))
2168
2706
 
2707
+ if (online) {
2708
+ try { await this.runPriorityLane(pubkey, channel, current.offlineRanges) } catch (error) { this.onError?.(error) }
2709
+ }
2710
+
2169
2711
  const remaining = []
2170
2712
  let recoveredThrough = current.recoveredThrough || 0
2171
2713
  for (const range of current.offlineRanges) {
2172
2714
  if (range.end < minStart) continue
2715
+ if (!online) { remaining.push(range); continue }
2173
2716
  const watchRevision = this.watchRevisionByChannel.get(pubkey) || 0
2174
2717
  const controller = new AbortController()
2175
2718
  controller.channelPubkey = pubkey
@@ -2178,38 +2721,65 @@ export class PrivateMessenger {
2178
2721
  if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(pubkey)) throw new Error('PRIVATE_MESSENGER_PAUSED')
2179
2722
  const fetchRelays = await this.resolveWatchRelays(channel)
2180
2723
  controller.signal.throwIfAborted()
2181
- const history = await this._privateChannel.fetchHistory({
2182
- _acquirePage: signal => this.recoveryAdmission.acquire(signal),
2183
- signal: controller.signal,
2184
- receivedChunkScope: this.storageLeaseId,
2185
- receiverSigner: this.userSigner,
2186
- iykcSigner: this.contentKeySigner,
2187
- privateChannelSigner: channel.signer,
2188
- privateChannelReaderSigner: channel.readerSigner,
2189
- privateChannelReaderPubkey: channel.readerPubkey,
2190
- privateChannelPubkeys: [pubkey],
2191
- receiverPubkey: this.userPubkey,
2192
- relays: fetchRelays,
2193
- since: Math.max(0, range.start),
2194
- until: range.end,
2195
- mode: channel.mode,
2196
- modeByPubkey: { [pubkey]: channel.mode },
2197
- receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
2198
- receivedChunkIndexedDB: this._indexedDB,
2199
- onEvent: (event, outer, meta) => this.receive(pubkey, () => this.enqueueRumor(eventType(event), pubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
2200
- onNymEvent: (event, outer, meta) => this.receive(pubkey, () => this.enqueueRumor('nym', pubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
2201
- onSeedEvent: seed => this.receive(pubkey, () => this.enqueueSeed(pubkey, seed), seed),
2202
- onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
2203
- onError: err => { throw err }
2204
- })
2205
- controller.signal.throwIfAborted()
2206
- const attempt = await this.#askSeedersForRelayLeftEdgeAttempt(pubkey, range, history.oldestCreatedAt)
2724
+ const record = this.recoveryRangeRecord(pubkey, range)
2725
+ this.reconcileRecoveryRelays(record, fetchRelays)
2726
+ const plan = this.recoveryRelayPlan(record)
2727
+ if (plan) {
2728
+ const history = await this._privateChannel.fetchHistory(this.recoveryFetchOptions({
2729
+ channelPubkey: pubkey,
2730
+ channel,
2731
+ relays: Object.keys(plan),
2732
+ since: range.start,
2733
+ until: range.end,
2734
+ signal: controller.signal,
2735
+ partial: true,
2736
+ resume: plan
2737
+ }))
2738
+ controller.signal.throwIfAborted()
2739
+ if (this.isPartialRecoveryHistory(history)) {
2740
+ this.updateRecoveryRecord(record, history)
2741
+ this.applyRecoveryRetryBudget(record)
2742
+ if ((history.relays || []).some(entry => entry.status !== 'eose' || entry.pending?.length)) {
2743
+ const error = this.recoveryIncompleteError(record, history, range)
2744
+ this.onError?.(error)
2745
+ failures.push(error)
2746
+ }
2747
+ } else {
2748
+ // Legacy fetch seam (tests/custom channels): resolves only when
2749
+ // the whole range was read, so treat it as fully relay-covered.
2750
+ record.covered = mergeRanges([...record.covered, { start: range.start, end: range.end }])
2751
+ if (history?.oldestCreatedAt != null) {
2752
+ record.oldestCreatedAt = record.oldestCreatedAt == null
2753
+ ? history.oldestCreatedAt
2754
+ : Math.min(record.oldestCreatedAt, history.oldestCreatedAt)
2755
+ }
2756
+ for (const state of record.relays.values()) {
2757
+ state.eose = true
2758
+ state.pending = []
2759
+ }
2760
+ record.tier = 'partial'
2761
+ record.lastErrors = []
2762
+ }
2763
+ }
2207
2764
  const lifecycleChanged = controller.signal.aborted || this.closePromise || !this.channels.has(pubkey) || !this.stopByChannel.has(pubkey) ||
2208
2765
  (this.watchRevisionByChannel.get(pubkey) || 0) !== watchRevision
2209
- failures.push(...attempt.failures.map(failure => failure.error))
2210
- if (lifecycleChanged || attempt.failures.length) remaining.push(range)
2211
- else recoveredThrough = Math.max(recoveredThrough, range.end)
2766
+ if (lifecycleChanged) {
2767
+ remaining.push(range)
2768
+ continue
2769
+ }
2770
+ if (this.recoveryRecordActive(record)) {
2771
+ remaining.push(range)
2772
+ continue
2773
+ }
2774
+ const result = await this.finalizeRecoveryRange({ channelPubkey: pubkey, range, record })
2775
+ failures.push(...result.failures)
2776
+ if (result.complete) recoveredThrough = Math.max(recoveredThrough, range.end)
2777
+ else remaining.push(range)
2212
2778
  } catch (err) {
2779
+ const record = this.recoveryRanges.get(this.recoveryRangeKey(pubkey, range))
2780
+ if (record && isPermanentRecoveryError(err)) {
2781
+ for (const state of record.relays.values()) state.dead = true
2782
+ }
2213
2783
  if (!(controller.liveInterrupted && err === controller.signal.reason)) this.onError?.(err)
2214
2784
  failures.push(err)
2215
2785
  remaining.push(range)
@@ -2223,6 +2793,7 @@ export class PrivateMessenger {
2223
2793
  recoveredThrough: Math.max(fresh.channels[pubkey]?.recoveredThrough || 0, recoveredThrough),
2224
2794
  offlineRanges: mergeRanges(concurrentRanges.concat(remaining))
2225
2795
  }
2796
+ this.pruneRecoveryRanges(pubkey, remaining)
2226
2797
  this.writeState(fresh)
2227
2798
  await this.flushStateWrites()
2228
2799
  }
@@ -2232,6 +2803,7 @@ export class PrivateMessenger {
2232
2803
  async clearChannel (pubkey) {
2233
2804
  await this.unwatch(pubkey)
2234
2805
  await this._privateMessage.clearChannelState?.(pubkey)
2806
+ this.dropRecoveryState(pubkey)
2235
2807
  return this.runQueueOperation(async () => {
2236
2808
  this.channels.delete(pubkey)
2237
2809
  this.removeChannelState(pubkey)
@@ -2265,6 +2837,7 @@ export class PrivateMessenger {
2265
2837
  await this.queue?.removeBy('byChannel', pubkey)
2266
2838
  await (this.seedStorage ? this.seedStorage.removeLocal({ channelPubkey: pubkey }) : this.seedQueue?.removeBy('byChannel', pubkey))
2267
2839
  }
2840
+ for (const pubkey of stalePubkeys) this.dropRecoveryState(pubkey)
2268
2841
  this.state = state
2269
2842
  if (stalePubkeys.length) await this.removeChannelStates(stalePubkeys)
2270
2843
  })
@@ -2355,22 +2928,14 @@ export class PrivateMessenger {
2355
2928
  temporaryStorageArea: this.temporaryStorageArea
2356
2929
  })
2357
2930
  }
2931
+ this.recoveryRanges.clear()
2932
+ this.recoveryPriority.clear()
2358
2933
  if (unwatchError) throw unwatchError
2359
2934
  })()
2360
2935
  return this.closePromise
2361
2936
  }
2362
2937
  }
2363
2938
 
2364
- function mergeRanges (ranges) {
2365
- const out = []
2366
- for (const range of ranges) {
2367
- const last = out[out.length - 1]
2368
- if (!last || range.start > last.end + 1) out.push({ ...range })
2369
- else last.end = Math.max(last.end, range.end)
2370
- }
2371
- return out
2372
- }
2373
-
2374
2939
  function eventType (event) {
2375
2940
  if (event.kind === privateMessage.ASK_KIND) return 'ask'
2376
2941
  if (event.kind === privateMessage.REPLY_KIND) return 'reply'
@@ -17,9 +17,17 @@ key published after an earlier miss. Set it to `false` when the signer owns that
17
17
  `createMessengerSigner` adapts this byte signer to the transport's Base64 API.
18
18
 
19
19
  Methods: `setPeers`, `setAvailable(boolean)`, `prefetchContentKeys(peers?)`,
20
- `enqueue`, `retry`, `cancel`, `download`, `cancelDownload`, and `close`.
20
+ `prioritizeRange(peer, options)`, `enqueue`, `retry`, `cancel`, `download`,
21
+ `cancelDownload`, and `close`.
21
22
  `prefetchContentKeys` warms the content-key lookup cache for the owner and the
22
23
  given peers (default: current peers); it is best-effort and never rejects.
24
+ `prioritizeRange` registers an in-memory priority window for a conversation:
25
+ `type: 'unread-page'` requires `since` (the read anchor), while `type: 'tail'`
26
+ defaults to the newest persisted range minus `priorityTailSeconds` (6 hours).
27
+ The coordinator resolves the peer's channel and queues the request until the
28
+ channel exists. Priorities expire after `priorityTtlMs` (10 minutes) or once
29
+ covered, and are dropped with recovery state. They are process-local and reset
30
+ on reload.
23
31
  Callbacks are `onOutbox`, `onMedia`,
24
32
  `onError`, and optional `onSendError(error, { id, peer })`. The caller maps its
25
33
  account permissions/connectivity/lock state to availability and owns view subscriptions. There are no DOM or UI imports.
@@ -21,6 +21,14 @@ const retryable = error => error?.code === 'MESSAGE_NOT_PUBLISHED' || !/DENIED|P
21
21
 
22
22
  export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _getIykcProofs = getIykcProofs, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
23
23
  fallbackRelays = normalizeFallbackRelays(fallbackRelays)
24
+ const reportedErrors = new WeakSet()
25
+ const reportError = error => {
26
+ if (error && (typeof error === 'object' || typeof error === 'function')) {
27
+ if (reportedErrors.has(error)) return
28
+ reportedErrors.add(error)
29
+ }
30
+ onError(error)
31
+ }
24
32
  const userSigner = messengerSigner(signer)
25
33
  messageStorage ||= createEventStoreMessageStorage({ eventStore })
26
34
  chunkStorage ||= eventStore ? createEventStoreChunkStorage({ eventStore }) : undefined
@@ -37,6 +45,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
37
45
  let downloads
38
46
  const downloadIntents = new Map()
39
47
  const downloadEpochs = new Map()
48
+ const activeDownloads = new Map()
49
+ const pendingPriorityRanges = new Map()
40
50
  let downloadWriteTail = Promise.resolve()
41
51
  const writeDownloadIntent = operation => {
42
52
  const work = downloadWriteTail.catch(() => {}).then(operation)
@@ -72,18 +82,28 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
72
82
  const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
73
83
  channel = { signer: scoped, pubkey: await scoped.getPublicKey(), mode, seeders: seedersForPeer(peer) }
74
84
  channels.set(peer, channel)
75
- } catch (error) { if (!retryable(error)) deniedPeers.add(peer); onError(error); continue }
85
+ } catch (error) { if (!retryable(error)) deniedPeers.add(peer); reportError(error); continue }
76
86
  }
77
87
  if (closed || !available || version !== lifecycle) return
78
88
  values.push(channel)
79
89
  }
80
- if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, onMessageQueued: () => drain(), onError })
90
+ if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, onMessageQueued: () => drain(), onError: reportError })
81
91
  if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
82
92
  await messenger.update({ channels: values })
93
+ if (pendingPriorityRanges.size && typeof messenger.prioritizeRange === 'function') {
94
+ for (const [peer, list] of [...pendingPriorityRanges]) {
95
+ const channel = channels.get(peer)
96
+ if (!channel) continue
97
+ for (const request of list) {
98
+ try { await messenger.prioritizeRange(channel.pubkey, request) } catch (error) { reportError(error) }
99
+ }
100
+ pendingPriorityRanges.delete(peer)
101
+ }
102
+ }
83
103
  await messenger.resume('signer')
84
104
  if (!fileTransfers) {
85
105
  fileTransfers = FileTransfer({
86
- messenger, onError,
106
+ messenger, onError: reportError,
87
107
  resolveChannel: ({ peerPubkey, info }) => messengerSigner(signer.withSharedKey(peerPubkey, info)),
88
108
  storage: chunkStorage,
89
109
  seedStorage: recoveryStorage?.seeds,
@@ -95,20 +115,30 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
95
115
  onMedia({ ...state, peer })
96
116
  if (state.status === 'complete') {
97
117
  const id = intentId({ peer, root: state.root })
98
- if (downloadIntents.delete(id)) writeDownloadIntent(() => downloads?.remove(id)).catch(onError)
118
+ if (downloadIntents.delete(id)) writeDownloadIntent(() => downloads?.remove(id)).catch(reportError)
99
119
  }
100
120
  }
101
121
  })
102
122
  downloads = await openDownloads({ owner, signer })
103
123
  for (const entry of await downloads.list()) downloadIntents.set(entry.id, entry)
104
124
  }
105
- for (const entry of downloadIntents.values()) if (peers.has(entry.file.peer)) download(entry.file, { manual: true }).catch(onError)
125
+ for (const entry of downloadIntents.values()) {
126
+ if (!peers.has(entry.file.peer) || activeDownloads.has(entry.id)) continue
127
+ const work = download(entry.file, { manual: true })
128
+ .catch(error => {
129
+ // A failed intent stays queued for the next configure; report each
130
+ // real attempt once even if configure runs while it is in flight.
131
+ if (downloadIntents.has(entry.id)) reportError(error)
132
+ })
133
+ .finally(() => { if (activeDownloads.get(entry.id) === work) activeDownloads.delete(entry.id) })
134
+ activeDownloads.set(entry.id, work)
135
+ }
106
136
  drain(); pump()
107
137
  }
108
138
  function schedule () {
109
139
  const work = configuring.catch(() => {}).then(configure)
110
140
  configuring = work
111
- work.catch(onError)
141
+ work.catch(reportError)
112
142
  return work
113
143
  }
114
144
  async function drain () {
@@ -130,7 +160,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
130
160
  const valid = event.pubkey === peer || message.provenance === 'hearsay' || message.provenance === 'signed'
131
161
  if (!valid || !isSerializableEvent(event) || !allowedKinds.includes(event.kind) || (event.sig && !isValidEvent(event)) || getEventHash(event) !== message.event.id) { await ack(); continue }
132
162
  if (event.kind === 34601) {
133
- try { decodeIrfsChunk(event) } catch (error) { onError(error); await ack(); continue }
163
+ try { decodeIrfsChunk(event) } catch (error) { reportError(error); await ack(); continue }
134
164
  }
135
165
  if (event.kind === 5 && (message.provenance === 'hearsay' || event.pubkey !== peer)) { await ack(); continue }
136
166
  if (!available || closed) { await nack(); break }
@@ -139,7 +169,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
139
169
  await ack()
140
170
  } catch (error) {
141
171
  await nack()
142
- onError(error)
172
+ reportError(error)
143
173
  // Retain the failed record and suspend network ingestion until recovery.
144
174
  await messenger.pause('storage')
145
175
  break
@@ -218,8 +248,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
218
248
  } catch (error) {
219
249
  if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
220
250
  entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
221
- await storage.put(entry, { existing: true }).catch(onError)
222
- onError(error)
251
+ await storage.put(entry, { existing: true }).catch(reportError)
252
+ reportError(error)
223
253
  // The failed wire event may be a quote or file chunk. Report the
224
254
  // owning outbox item separately, without mutating the native error.
225
255
  if (error.retryWhenOnline) offlineSends.add(entry.id)
@@ -303,6 +333,23 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
303
333
  return {}
304
334
  }
305
335
  },
336
+ async prioritizeRange (peer, options = {}) {
337
+ if (!/^[0-9a-f]{64}$/.test(peer || '')) throw new TypeError('INVALID_PRIORITY_PEER')
338
+ const { since, until, type = 'unread-page' } = options || {}
339
+ if (type !== 'unread-page' && type !== 'tail') throw new TypeError('INVALID_PRIORITY_TYPE')
340
+ if (since !== undefined && !Number.isSafeInteger(since)) throw new TypeError('INVALID_PRIORITY_RANGE')
341
+ if (until !== undefined && !Number.isSafeInteger(until)) throw new TypeError('INVALID_PRIORITY_RANGE')
342
+ if (type === 'unread-page' && since === undefined) throw new TypeError('PRIORITY_SINCE_REQUIRED')
343
+ const request = { since, until, type }
344
+ const channel = channels.get(peer)
345
+ if (channel && typeof messenger?.prioritizeRange === 'function') {
346
+ try { return await messenger.prioritizeRange(channel.pubkey, request) } catch (error) { reportError(error); return false }
347
+ }
348
+ const list = pendingPriorityRanges.get(peer) || []
349
+ list.push(request)
350
+ pendingPriorityRanges.set(peer, list)
351
+ return true
352
+ },
306
353
  async setPeers (values) {
307
354
  peers.clear(); for (const peer of values) if (peer !== owner) peers.add(peer)
308
355
  for (const peer of deniedPeers) if (!peers.has(peer)) deniedPeers.delete(peer)
@@ -348,14 +395,14 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
348
395
  },
349
396
  async close () {
350
397
  closed = true; available = false; lifecycle++
351
- stopOnlineRetry(); offlineSends.clear()
398
+ stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
352
399
  for (const controller of sendControllers.values()) controller.abort()
353
400
  await Promise.allSettled([configuring, initialized])
354
401
  await messenger?.pause('closed')
355
402
  if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
356
403
  await messenger?.close()
357
404
  await storage?.close()
358
- await downloadWriteTail.catch(onError)
405
+ await downloadWriteTail.catch(reportError)
359
406
  await downloads?.close()
360
407
  }
361
408
  }