libp2r2p 0.11.11 → 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
@@ -541,6 +541,22 @@ and the uncovered subranges plus the pre-oldest left edge are requested from
541
541
  seeders. Offline attempts never consume the budget, mark coverage or contact
542
542
  seeders; they wait for connectivity. The per-relay tracker is process-local, so a
543
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
+
544
560
  `private-channel.fetchHistory(options)` (0.11.6) is the complete-interval API
545
561
  used by messenger recovery. It accepts the signers, processor/fragment options,
546
562
  callbacks and abort signal of `fetch`, plus inclusive `since`/`until` bounds
package/helpers/ranges.js CHANGED
@@ -29,3 +29,19 @@ export function subtractRanges (range, covered = []) {
29
29
  if (cursor <= range.end) out.push({ start: cursor, end: range.end })
30
30
  return out
31
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.11",
3
+ "version": "0.11.12",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -46,6 +46,7 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
46
46
  }
47
47
  for (const relay of urls) {
48
48
  const relayStartCount = receivedEventCount
49
+ let relayElapsedMs = 0
49
50
  const intervals = resumeIntervalsFor({ resume, relay, since, until })
50
51
  .map(interval => ({ since: interval.start, until: interval.end, limit: PAGE_SIZE }))
51
52
  const covered = []
@@ -83,7 +84,11 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
83
84
  outcome = { relay, status: 'error', error }
84
85
  intervals.push(interval)
85
86
  break
86
- } finally { elapsedMs += performance.now() - started }
87
+ } finally {
88
+ const took = performance.now() - started
89
+ elapsedMs += took
90
+ relayElapsedMs += took
91
+ }
87
92
  signal?.throwIfAborted()
88
93
  const status = page.relays?.find(item => item.relay === relay)
89
94
  if (page.errors?.length || !status || !['eose', 'satisfied'].includes(status.status)) {
@@ -123,7 +128,8 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
123
128
  ? {
124
129
  covered: mergeRanges(covered),
125
130
  pending: mergeRanges(intervals.map(interval => ({ start: interval.since, end: interval.until }))),
126
- events: receivedEventCount - relayStartCount
131
+ events: receivedEventCount - relayStartCount,
132
+ elapsedMs: Math.round(relayElapsedMs)
127
133
  }
128
134
  : {})
129
135
  })
@@ -43,7 +43,7 @@
43
43
  // - For other event-list replies, use createEventReplyPacker({ messenger, question, code }).update(event).
44
44
 
45
45
  import { createAbortableSemaphore } from '../helpers/abortable-semaphore.js'
46
- import { mergeRanges, subtractRanges } from '../helpers/ranges.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'
@@ -130,6 +130,10 @@ export const RECOVERY_RELAY_RETRY_LIMITS = Object.freeze({
130
130
  allFailed: 10
131
131
  })
132
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
133
137
 
134
138
  function textToBase64 (text) {
135
139
  return bytesToBase64(encoder.encode(text))
@@ -213,6 +217,10 @@ export class PrivateMessenger {
213
217
  reloadGapDelayMs = DEFAULT_RELOAD_GAP_DELAY_MS,
214
218
  seederPresenceIntervalMs = DEFAULT_SEEDER_PRESENCE_INTERVAL_MS,
215
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,
216
224
  maxDynamicRecoverySeeders = DEFAULT_MAX_DYNAMIC_RECOVERY_SEEDERS,
217
225
  messageQueueMaxBytes = DEFAULT_MESSAGE_QUEUE_MAX_BYTES,
218
226
  seedQueueMaxBytes = DEFAULT_SEED_QUEUE_MAX_BYTES,
@@ -247,6 +255,19 @@ export class PrivateMessenger {
247
255
  this.reloadGapDelayMs = reloadGapDelayMs
248
256
  this.seederPresenceIntervalMs = seederPresenceIntervalMs
249
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
250
271
  this.maxDynamicRecoverySeeders = maxDynamicRecoverySeeders
251
272
  this.messageQueueMaxBytes = messageQueueMaxBytes
252
273
  this.seedQueueMaxBytes = seedQueueMaxBytes
@@ -299,6 +320,7 @@ export class PrivateMessenger {
299
320
  this.recoveryAdmission = createAbortableSemaphore(2)
300
321
  this.recoveryControllers = new Set()
301
322
  this.recoveryRanges = new Map()
323
+ this.recoveryPriority = new Map()
302
324
  this.recoveryOnline = { at: 0, value: true }
303
325
  this.resumeWork = null
304
326
  this.capacityTimer = null
@@ -675,7 +697,7 @@ export class PrivateMessenger {
675
697
 
676
698
  if (removedPubkeys.length) {
677
699
  await this.stampChannelActivity(removedPubkeys)
678
- for (const pubkey of removedPubkeys) this.dropRecoveryRanges(pubkey)
700
+ for (const pubkey of removedPubkeys) this.dropRecoveryState(pubkey)
679
701
  }
680
702
 
681
703
  const storageSnapshot = await activatePrivateMessengerStorage({
@@ -1214,6 +1236,7 @@ export class PrivateMessenger {
1214
1236
  start: range.start,
1215
1237
  end: range.end,
1216
1238
  covered: [],
1239
+ claimed: [],
1217
1240
  oldestCreatedAt: null,
1218
1241
  relays: new Map(),
1219
1242
  tier: '',
@@ -1241,20 +1264,34 @@ export class PrivateMessenger {
1241
1264
  dead: false,
1242
1265
  eose: false,
1243
1266
  events: 0,
1244
- error: null
1267
+ error: null,
1268
+ consecutiveFailures: 0,
1269
+ latencyMs: null,
1270
+ lastEoseAt: 0,
1271
+ lastErrorAt: 0
1245
1272
  })
1246
1273
  }
1247
1274
  }
1248
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
+
1249
1288
  recoveryRelayPlan (record) {
1250
1289
  const plan = {}
1251
- let hasWork = false
1252
- for (const [relay, state] of record.relays) {
1253
- if (state.dead || state.eose || !state.pending?.length) continue
1290
+ const ordered = this.orderedRecoveryRelays(record)
1291
+ for (const [relay, state] of ordered) {
1254
1292
  plan[relay] = state.pending
1255
- hasWork = true
1256
1293
  }
1257
- return hasWork ? plan : null
1294
+ return ordered.length ? plan : null
1258
1295
  }
1259
1296
 
1260
1297
  hasActiveRecoveryRelays (channelPubkey) {
@@ -1289,7 +1326,65 @@ export class PrivateMessenger {
1289
1326
  }
1290
1327
  }
1291
1328
 
1292
- updateRecoveryRecord (record, history) {
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)) } = {}) {
1293
1388
  record.covered = mergeRanges([
1294
1389
  ...record.covered,
1295
1390
  ...(history.relays || []).flatMap(entry => entry.covered || [])
@@ -1306,13 +1401,30 @@ export class PrivateMessenger {
1306
1401
  const relayErrors = [...(entry.errors || [])]
1307
1402
  if (entry.error) relayErrors.push(entry.error)
1308
1403
  state.error = relayErrors[0] || null
1309
- const done = entry.status === 'eose' && !(entry.pending?.length)
1404
+ const done = entry.status === 'eose' && !(entry.pending?.length) && completeRelays.has(relay)
1310
1405
  state.covered = mergeRanges([...(state.covered || []), ...(entry.covered || [])])
1311
1406
  state.eose = done
1312
- state.pending = done ? [] : subtractRanges({ start: record.start, end: record.end }, state.covered)
1407
+ state.pending = done
1408
+ ? []
1409
+ : subtractRanges({ start: record.start, end: record.end }, mergeRanges([...state.covered, ...record.claimed]))
1313
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
+ }
1314
1416
  if (done) {
1315
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') {
1316
1428
  record.everEose = true
1317
1429
  if (state.events > 0) record.everEoseWithEvents = true
1318
1430
  }
@@ -1321,6 +1433,52 @@ export class PrivateMessenger {
1321
1433
  record.lastErrors = (history.relays || []).flatMap(entry => [...(entry.errors || []), ...(entry.error ? [entry.error] : [])])
1322
1434
  }
1323
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
+
1324
1482
  applyRecoveryRetryBudget (record) {
1325
1483
  const limit = RECOVERY_RELAY_RETRY_LIMITS[record.tier] ?? RECOVERY_RELAY_RETRY_LIMITS.allFailed
1326
1484
  for (const state of record.relays.values()) {
@@ -1462,7 +1620,7 @@ export class PrivateMessenger {
1462
1620
  const pubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.desiredChannels]
1463
1621
  for (const extension of this.extensions) extension.unwatch?.(pubkeys)
1464
1622
  for (const pubkey of pubkeys) this.desiredChannels.delete(pubkey)
1465
- for (const pubkey of pubkeys) this.dropRecoveryRanges(pubkey)
1623
+ for (const pubkey of pubkeys) this.dropRecoveryState(pubkey)
1466
1624
  this.recordInterruption(pubkeys)
1467
1625
  for (const controller of this.recoveryControllers) {
1468
1626
  if (pubkeys.includes(controller.channelPubkey)) controller.abort()
@@ -2156,7 +2314,7 @@ export class PrivateMessenger {
2156
2314
  this.writeState(state)
2157
2315
  await this.flushStateWrites()
2158
2316
  for (const channel of channels) {
2159
- if (!this.offlineRecoverySecondsFor(channel)) this.dropRecoveryRanges(channel.pubkey)
2317
+ if (!this.offlineRecoverySecondsFor(channel)) this.dropRecoveryState(channel.pubkey)
2160
2318
  }
2161
2319
  for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
2162
2320
  })
@@ -2231,15 +2389,15 @@ export class PrivateMessenger {
2231
2389
  return asks
2232
2390
  }
2233
2391
 
2234
- async #askSeedersForMissingRangeAttempt (channelPubkey, since, until) {
2392
+ async #askSeedersForMissingRangeAttempt (channelPubkey, since, until, { seeders } = {}) {
2235
2393
  if (!this.offlineRecoverySecondsFor(channelPubkey)) return { asks: [], failures: [] }
2236
2394
  if (!this.channels.get(channelPubkey)?.signer) return { asks: [], failures: [] }
2237
- const seeders = this.recoverySeeders(channelPubkey)
2238
- if (!seeders.length || until < since) return { asks: [], failures: [] }
2395
+ const candidates = seeders ?? this.recoverySeeders(channelPubkey)
2396
+ if (!candidates.length || until < since) return { asks: [], failures: [] }
2239
2397
 
2240
2398
  const asks = []
2241
2399
  const failures = []
2242
- for (const seeder of seeders) {
2400
+ for (const seeder of candidates) {
2243
2401
  try {
2244
2402
  const ask = await this.ask({
2245
2403
  channelPubkey,
@@ -2385,6 +2543,146 @@ export class PrivateMessenger {
2385
2543
  return failures
2386
2544
  }
2387
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
+
2388
2686
  async recoverChannelRanges (channels) {
2389
2687
  const failures = []
2390
2688
  const state = this.readState()
@@ -2395,17 +2693,21 @@ export class PrivateMessenger {
2395
2693
  const channel = this.channels.get(pubkey)
2396
2694
  const current = state.channels[pubkey]
2397
2695
  if (!channel || !current?.offlineRanges?.length) {
2398
- this.dropRecoveryRanges(pubkey)
2696
+ this.dropRecoveryState(pubkey)
2399
2697
  continue
2400
2698
  }
2401
2699
  const recoverySeconds = this.offlineRecoverySecondsFor(channel)
2402
2700
  if (!recoverySeconds) {
2403
- this.dropRecoveryRanges(pubkey)
2701
+ this.dropRecoveryState(pubkey)
2404
2702
  continue
2405
2703
  }
2406
2704
  const minStart = now - recoverySeconds
2407
2705
  const processedRanges = new Set(current.offlineRanges.map(range => `${range.start}:${range.end}`))
2408
2706
 
2707
+ if (online) {
2708
+ try { await this.runPriorityLane(pubkey, channel, current.offlineRanges) } catch (error) { this.onError?.(error) }
2709
+ }
2710
+
2409
2711
  const remaining = []
2410
2712
  let recoveredThrough = current.recoveredThrough || 0
2411
2713
  for (const range of current.offlineRanges) {
@@ -2423,32 +2725,16 @@ export class PrivateMessenger {
2423
2725
  this.reconcileRecoveryRelays(record, fetchRelays)
2424
2726
  const plan = this.recoveryRelayPlan(record)
2425
2727
  if (plan) {
2426
- const history = await this._privateChannel.fetchHistory({
2427
- _acquirePage: signal => this.recoveryAdmission.acquire(signal),
2428
- signal: controller.signal,
2429
- receivedChunkScope: this.storageLeaseId,
2430
- receiverSigner: this.userSigner,
2431
- iykcSigner: this.contentKeySigner,
2432
- privateChannelSigner: channel.signer,
2433
- privateChannelReaderSigner: channel.readerSigner,
2434
- privateChannelReaderPubkey: channel.readerPubkey,
2435
- privateChannelPubkeys: [pubkey],
2436
- receiverPubkey: this.userPubkey,
2728
+ const history = await this._privateChannel.fetchHistory(this.recoveryFetchOptions({
2729
+ channelPubkey: pubkey,
2730
+ channel,
2437
2731
  relays: Object.keys(plan),
2438
- since: Math.max(0, range.start),
2732
+ since: range.start,
2439
2733
  until: range.end,
2734
+ signal: controller.signal,
2440
2735
  partial: true,
2441
- resume: plan,
2442
- mode: channel.mode,
2443
- modeByPubkey: { [pubkey]: channel.mode },
2444
- receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
2445
- receivedChunkIndexedDB: this._indexedDB,
2446
- onEvent: (event, outer, meta) => this.receive(pubkey, () => this.enqueueRumor(eventType(event), pubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
2447
- onNymEvent: (event, outer, meta) => this.receive(pubkey, () => this.enqueueRumor('nym', pubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
2448
- onSeedEvent: seed => this.receive(pubkey, () => this.enqueueSeed(pubkey, seed), seed),
2449
- onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
2450
- onError: err => { throw err }
2451
- })
2736
+ resume: plan
2737
+ }))
2452
2738
  controller.signal.throwIfAborted()
2453
2739
  if (this.isPartialRecoveryHistory(history)) {
2454
2740
  this.updateRecoveryRecord(record, history)
@@ -2517,7 +2803,7 @@ export class PrivateMessenger {
2517
2803
  async clearChannel (pubkey) {
2518
2804
  await this.unwatch(pubkey)
2519
2805
  await this._privateMessage.clearChannelState?.(pubkey)
2520
- this.dropRecoveryRanges(pubkey)
2806
+ this.dropRecoveryState(pubkey)
2521
2807
  return this.runQueueOperation(async () => {
2522
2808
  this.channels.delete(pubkey)
2523
2809
  this.removeChannelState(pubkey)
@@ -2551,7 +2837,7 @@ export class PrivateMessenger {
2551
2837
  await this.queue?.removeBy('byChannel', pubkey)
2552
2838
  await (this.seedStorage ? this.seedStorage.removeLocal({ channelPubkey: pubkey }) : this.seedQueue?.removeBy('byChannel', pubkey))
2553
2839
  }
2554
- for (const pubkey of stalePubkeys) this.dropRecoveryRanges(pubkey)
2840
+ for (const pubkey of stalePubkeys) this.dropRecoveryState(pubkey)
2555
2841
  this.state = state
2556
2842
  if (stalePubkeys.length) await this.removeChannelStates(stalePubkeys)
2557
2843
  })
@@ -2643,6 +2929,7 @@ export class PrivateMessenger {
2643
2929
  })
2644
2930
  }
2645
2931
  this.recoveryRanges.clear()
2932
+ this.recoveryPriority.clear()
2646
2933
  if (unwatchError) throw unwatchError
2647
2934
  })()
2648
2935
  return this.closePromise
@@ -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.
@@ -46,6 +46,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
46
46
  const downloadIntents = new Map()
47
47
  const downloadEpochs = new Map()
48
48
  const activeDownloads = new Map()
49
+ const pendingPriorityRanges = new Map()
49
50
  let downloadWriteTail = Promise.resolve()
50
51
  const writeDownloadIntent = operation => {
51
52
  const work = downloadWriteTail.catch(() => {}).then(operation)
@@ -89,6 +90,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
89
90
  if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, onMessageQueued: () => drain(), onError: reportError })
90
91
  if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
91
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
+ }
92
103
  await messenger.resume('signer')
93
104
  if (!fileTransfers) {
94
105
  fileTransfers = FileTransfer({
@@ -322,6 +333,23 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
322
333
  return {}
323
334
  }
324
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
+ },
325
353
  async setPeers (values) {
326
354
  peers.clear(); for (const peer of values) if (peer !== owner) peers.add(peer)
327
355
  for (const peer of deniedPeers) if (!peers.has(peer)) deniedPeers.delete(peer)
@@ -367,7 +395,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
367
395
  },
368
396
  async close () {
369
397
  closed = true; available = false; lifecycle++
370
- stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear()
398
+ stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
371
399
  for (const controller of sendControllers.values()) controller.abort()
372
400
  await Promise.allSettled([configuring, initialized])
373
401
  await messenger?.pause('closed')