libp2r2p 0.11.5 → 0.11.6

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
@@ -534,6 +534,42 @@ 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
535
  Existing rewatch/resume and relay-list refresh paths retry pending intervals;
536
536
  there is no dedicated periodic retry timer for a failed history fetch.
537
+ `private-channel.fetchHistory(options)` (0.11.6) is the complete-interval API
538
+ used by messenger recovery. It accepts the signers, processor/fragment options,
539
+ callbacks and abort signal of `fetch`, plus inclusive `since`/`until` bounds
540
+ (defaults: 0 and the start time of the call). It returns only
541
+ `{ 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.
544
+
545
+ History queries each relay independently with limit 16. A response with 16 events
546
+ or status `satisfied` is saturated, even if it already reports EOSE or contains
547
+ fewer valid events. Saturated intervals split into nonoverlapping inclusive
548
+ halves; completed pages process chronologically, oldest half first. Single-second
549
+ intervals grow through 16, 32, 64, 128 and 256. Each response is also bounded to
550
+ 4 MiB of serialized UTF-8 event data. At the dense-second or volume ceiling,
551
+ `PRIVATE_CHANNEL_FETCH_INCOMPLETE` retains the operational cause
552
+ `PRIVATE_CHANNEL_HISTORY_PAGE_LIMIT`; the interval stays pending. There is no
553
+ nonstandard cursor, disk spool, dropped excess or successful incomplete scan.
554
+
555
+ A fragment processor and the existing scoped fragment storage span the entire
556
+ call, including pages and relays. Callbacks are awaited before requesting the
557
+ next page. Processing/persistence failures propagate; completed pages may already
558
+ be ingested and retries remain subject to the existing idempotence contract.
559
+ The summary count includes processed outer events per relay, including replicas,
560
+ not unique decrypted messages or discarded saturated probes. It does not include
561
+ all events observed in those probes.
562
+
563
+ Recovery admits at most two pages per PrivateMessenger and one per normalized
564
+ relay across the same library instance, including page processing time. Continuous
565
+ live watches are outside those recovery gates. Waiters are abortable and FIFO,
566
+ with at most 256 queued per gate (`PRIVATE_CHANNEL_HISTORY_QUEUE_FULL` on excess).
567
+ The five-second network timeout begins after admission; reported read time also
568
+ includes admission but excludes processing. Seeders still supplement the oldest
569
+ relay result. Checkpoints advance only after the entire interval and existing
570
+ seeder stages succeed; interruption preserves its original pending range. No
571
+ persisted state format changes or migrations are needed.
572
+
537
573
  Subscription relay errors forwarded to `onError` include `relay` when known
538
574
  and `operation: 'private-channel.subscribe'`. A per-notification wrapper retains
539
575
  the native message, name, code, category and WebSocket close fields; `cause`
@@ -0,0 +1,41 @@
1
+ // A bounded FIFO for asynchronous work. Acquired leases release explicitly;
2
+ // queued cancellations never consume capacity or keep idle keyed gates alive.
3
+ export function createAbortableSemaphore (capacity, onIdle = () => {}) {
4
+ const queue = []
5
+ let active = 0
6
+ const drain = () => {
7
+ while (active < capacity && queue.length) {
8
+ const job = queue.shift()
9
+ job.signal?.removeEventListener('abort', job.abort)
10
+ active++
11
+ let released = false
12
+ job.resolve(() => {
13
+ if (released) return
14
+ released = true
15
+ active--
16
+ drain()
17
+ })
18
+ }
19
+ if (!active && !queue.length) onIdle()
20
+ }
21
+ return {
22
+ acquire (signal) {
23
+ signal?.throwIfAborted()
24
+ if (queue.length >= 256) return Promise.reject(Object.assign(new Error('PRIVATE_CHANNEL_HISTORY_QUEUE_FULL'), { code: 'PRIVATE_CHANNEL_HISTORY_QUEUE_FULL' }))
25
+ return new Promise((resolve, reject) => {
26
+ const job = {
27
+ signal, resolve, abort: () => {
28
+ const index = queue.indexOf(job)
29
+ if (index < 0) return
30
+ queue.splice(index, 1)
31
+ reject(signal.reason)
32
+ drain()
33
+ }
34
+ }
35
+ signal?.addEventListener('abort', job.abort, { once: true })
36
+ queue.push(job)
37
+ drain()
38
+ })
39
+ }
40
+ }
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.5",
3
+ "version": "0.11.6",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -22,6 +22,7 @@
22
22
  "ecdh",
23
23
  "error",
24
24
  "event",
25
+ "helpers",
25
26
  "idb",
26
27
  "idb-queue",
27
28
  "index.js",
@@ -0,0 +1,121 @@
1
+ import { ValidationError } from '../../error/index.js'
2
+ import { normalizeRelayUrl } from '../../url/index.js'
3
+ import { createAbortableSemaphore } from '../../helpers/abortable-semaphore.js'
4
+ import { incompleteFetchError } from './fetch-error.js'
5
+
6
+ const PAGE_SIZE = 16
7
+ const MAX_PAGE_SIZE = 256
8
+ const MAX_PAGE_BYTES = 4 * 1024 * 1024
9
+ const TIMEOUT_MS = 5000
10
+ const relayGates = new Map()
11
+ const encoder = new TextEncoder()
12
+ const pageLimitError = () => Object.assign(new Error('PRIVATE_CHANNEL_HISTORY_PAGE_LIMIT'), { code: 'PRIVATE_CHANNEL_HISTORY_PAGE_LIMIT' })
13
+
14
+ function acquireRelay (relay, signal) {
15
+ signal?.throwIfAborted()
16
+ let gate = relayGates.get(relay)
17
+ if (!gate) {
18
+ gate = createAbortableSemaphore(1, () => { if (relayGates.get(relay) === gate) relayGates.delete(relay) })
19
+ relayGates.set(relay, gate)
20
+ }
21
+ return gate.acquire(signal)
22
+ }
23
+
24
+ // Queries bounded temporal leaves, oldest first. A saturated one-second leaf
25
+ // 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
+ const since = filter.since ?? 0
28
+ const until = filter.until ?? Math.floor(Date.now() / 1000)
29
+ if (![since, until].every(value => Number.isSafeInteger(value) && value >= 0) || since > until) throw new ValidationError('INVALID_PRIVATE_CHANNEL_HISTORY_RANGE')
30
+ if (!Array.isArray(relays) || !relays.length) throw new ValidationError('NO_RELAYS')
31
+ const urls = [...new Set(relays.map(normalizeRelayUrl))]
32
+ const report = []
33
+ const errors = []
34
+ let oldestCreatedAt = null
35
+ let receivedEventCount = 0
36
+ let elapsedMs = 0
37
+ const process = async result => {
38
+ result.sort((a, b) => a.event.created_at - b.event.created_at || String(a.event.id).localeCompare(String(b.event.id)))
39
+ for (const { event } of result) {
40
+ signal?.throwIfAborted()
41
+ await processEvent(event)
42
+ receivedEventCount++
43
+ oldestCreatedAt = Math.min(oldestCreatedAt ?? event.created_at, event.created_at)
44
+ }
45
+ }
46
+ for (const relay of urls) {
47
+ const intervals = [{ since, until, limit: PAGE_SIZE }]
48
+ let outcome = { relay, status: 'eose' }
49
+ while (intervals.length) {
50
+ signal?.throwIfAborted()
51
+ const interval = intervals.pop()
52
+ const started = performance.now()
53
+ let releaseRelay
54
+ let releasePage
55
+ let page
56
+ try {
57
+ let bytes = 0
58
+ try {
59
+ releaseRelay = await acquireRelay(relay, signal)
60
+ releasePage = await acquirePage?.(signal)
61
+ signal?.throwIfAborted()
62
+ page = await getEvents({ ...filter, ...interval }, [relay], {
63
+ signal, timeout: TIMEOUT_MS, timeoutAfterFirstEose: null,
64
+ callback: item => {
65
+ if (item.type !== 'event') return
66
+ bytes += encoder.encode(JSON.stringify(item.event)).byteLength
67
+ if (bytes > MAX_PAGE_BYTES) throw pageLimitError()
68
+ }
69
+ })
70
+ // Also enforce the contract for injected readers that omit callbacks.
71
+ let resultBytes = 0
72
+ for (const { event } of page.result) {
73
+ resultBytes += encoder.encode(JSON.stringify(event)).byteLength
74
+ if (resultBytes > MAX_PAGE_BYTES) throw pageLimitError()
75
+ }
76
+ } catch (error) {
77
+ signal?.throwIfAborted()
78
+ errors.push({ relay, reason: error })
79
+ outcome = { relay, status: 'error', error }
80
+ break
81
+ } finally { elapsedMs += performance.now() - started }
82
+ signal?.throwIfAborted()
83
+ const status = page.relays?.find(item => item.relay === relay)
84
+ if (page.errors?.length || !status || !['eose', 'satisfied'].includes(status.status)) {
85
+ await process(page.result)
86
+ errors.push(...(page.errors ?? []))
87
+ outcome = status ?? { relay, status: 'error' }
88
+ break
89
+ }
90
+ if (status.status === 'satisfied' || page.result.length >= interval.limit) {
91
+ if (interval.since < interval.until) {
92
+ const middle = Math.floor(interval.since + (interval.until - interval.since) / 2)
93
+ intervals.push({ since: middle + 1, until: interval.until, limit: PAGE_SIZE }, { since: interval.since, until: middle, limit: PAGE_SIZE })
94
+ } else if (interval.limit < MAX_PAGE_SIZE) {
95
+ intervals.push({ ...interval, limit: interval.limit * 2 })
96
+ } else {
97
+ await process(page.result)
98
+ const error = pageLimitError()
99
+ errors.push({ relay, reason: error })
100
+ outcome = { relay, status: 'error', error }
101
+ break
102
+ }
103
+ } else {
104
+ await process(page.result)
105
+ }
106
+ signal?.throwIfAborted()
107
+ } finally {
108
+ releasePage?.()
109
+ releaseRelay?.()
110
+ }
111
+ }
112
+ report.push(outcome)
113
+ }
114
+ signal?.throwIfAborted()
115
+ if (errors.length || report.some(item => item.status !== 'eose')) {
116
+ 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
+ error.operation = 'private-channel.fetchHistory'
118
+ throw error
119
+ }
120
+ return { oldestCreatedAt, receivedEventCount, relays: report }
121
+ }
@@ -7,6 +7,7 @@ import { getIykcProofs } from '../content-key/index.js'
7
7
  import { ValidationError } from '../error/index.js'
8
8
  import { normalizeRumor, deliveryInfo } from './helpers/rumor.js'
9
9
  import { incompleteFetchError } from './helpers/fetch-error.js'
10
+ import { readHistory } from './helpers/history.js'
10
11
  import { subscriptionError } from './helpers/subscription-error.js'
11
12
  import * as nip44v3 from '../nip44-v3/index.js'
12
13
  import { relayPool } from '../relay/index.js'
@@ -955,6 +956,17 @@ export async function fetch ({ signal, receiverSigner, iykcSigner, privateChanne
955
956
  }
956
957
  }
957
958
 
959
+ // Complete interval recovery with bounded pages and one fragment processor.
960
+ // Existing fetch() retains its one-query/event-array contract.
961
+ export async function fetchHistory ({ receiverSigner, privateChannelSigner = receiverSigner, _getEvents = getEvents, _acquirePage, ...options }) {
962
+ const authors = privateChannelPubkeyList(options)
963
+ const filter = { kinds: [PRIVATE_BROADCAST_KIND], ...(authors.length ? { authors } : {}), since: options.since, until: options.until }
964
+ const processOuterEvent = createProcessor({ ...options, receiverSigner, privateChannelSigner, onError: error => { options.onError?.(error); throw error } })
965
+ try {
966
+ return await readHistory({ filter, relays: options.relays, receiverPubkey: options.receiverPubkey, signal: options.signal, getEvents: _getEvents, processEvent: processOuterEvent, acquirePage: _acquirePage })
967
+ } finally { processOuterEvent.close() }
968
+ }
969
+
958
970
  export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, since = nowSeconds() - 5, limit, liveOnly = false, mode = 'leecher', modeByPubkey, receivedChunkScope = '', receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _liveEventsGenerator = getLiveEventsGenerator, _eventsFeedGenerator = getEventsFeedGenerator }) {
959
971
  if (!relays?.length) throw new ValidationError('NO_RELAYS')
960
972
  if (receiverSigner && !receiverSigner?.nip44DecryptDoubleDH && !receiverSigner?.nip44v3Decrypt) throw new ValidationError('RECEIVER_SIGNER_NIP44V3_DECRYPT_UNSUPPORTED')
@@ -42,6 +42,7 @@
42
42
  // - Seeder replies stream compact routers with createMissingMessageReplyPacker({ messenger, question }).update(seed), then finalize(optionalLastSeed).
43
43
  // - For other event-list replies, use createEventReplyPacker({ messenger, question, code }).update(event).
44
44
 
45
+ import { createAbortableSemaphore } from '../helpers/abortable-semaphore.js'
45
46
  import * as privateMessage from '../private-message/index.js'
46
47
  import { isOnline } from '../network/index.js'
47
48
  import { createSendRelayRouting, normalizeFallbackRelays } from './helpers/send-routing.js'
@@ -285,6 +286,7 @@ export class PrivateMessenger {
285
286
  this.deliveryReads = new Set()
286
287
  this.deliveryWaiters = new Set()
287
288
  this.recoveries = new Map()
289
+ this.recoveryAdmission = createAbortableSemaphore(2)
288
290
  this.recoveryControllers = new Set()
289
291
  this.resumeWork = null
290
292
  this.capacityTimer = null
@@ -1950,12 +1952,11 @@ export class PrivateMessenger {
1950
1952
  }
1951
1953
 
1952
1954
  async askSeedersForRelayLeftEdge (channelPubkey, range, fetchedEvents) {
1953
- const { asks } = await this.#askSeedersForRelayLeftEdgeAttempt(channelPubkey, range, fetchedEvents)
1955
+ const { asks } = await this.#askSeedersForRelayLeftEdgeAttempt(channelPubkey, range, oldestCreatedAt(fetchedEvents))
1954
1956
  return asks
1955
1957
  }
1956
1958
 
1957
- async #askSeedersForRelayLeftEdgeAttempt (channelPubkey, range, fetchedEvents) {
1958
- const oldest = oldestCreatedAt(fetchedEvents)
1959
+ async #askSeedersForRelayLeftEdgeAttempt (channelPubkey, range, oldest) {
1959
1960
  const until = oldest == null ? range.end : Math.min(range.end, oldest)
1960
1961
  if (until < range.start) return { asks: [], failures: [] }
1961
1962
  return this.#askSeedersForMissingRangeAttempt(channelPubkey, range.start, until)
@@ -2098,7 +2099,8 @@ export class PrivateMessenger {
2098
2099
  if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(pubkey)) throw new Error('PRIVATE_MESSENGER_PAUSED')
2099
2100
  const fetchRelays = await this.resolveWatchRelays(channel)
2100
2101
  controller.signal.throwIfAborted()
2101
- const fetchedEvents = await this._privateChannel.fetch({
2102
+ const history = await this._privateChannel.fetchHistory({
2103
+ _acquirePage: signal => this.recoveryAdmission.acquire(signal),
2102
2104
  signal: controller.signal,
2103
2105
  receivedChunkScope: this.storageLeaseId,
2104
2106
  receiverSigner: this.userSigner,
@@ -2120,9 +2122,9 @@ export class PrivateMessenger {
2120
2122
  onSeedEvent: seed => this.receive(pubkey, () => this.enqueueSeed(pubkey, seed), seed),
2121
2123
  onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
2122
2124
  onError: err => { throw err }
2123
- }) || []
2125
+ })
2124
2126
  controller.signal.throwIfAborted()
2125
- const attempt = await this.#askSeedersForRelayLeftEdgeAttempt(pubkey, range, fetchedEvents)
2127
+ const attempt = await this.#askSeedersForRelayLeftEdgeAttempt(pubkey, range, history.oldestCreatedAt)
2126
2128
  const lifecycleChanged = this.closePromise || !this.channels.has(pubkey) || !this.stopByChannel.has(pubkey) ||
2127
2129
  (this.watchRevisionByChannel.get(pubkey) || 0) !== watchRevision
2128
2130
  if (lifecycleChanged || attempt.failures.length) remaining.push(range)