libp2r2p 0.10.21 → 0.10.23

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
@@ -21,6 +21,7 @@ never decorated with `meta`; relay provenance belongs to its enclosing item:
21
21
  { type: 'event', event, relay }
22
22
  { type: 'error', relay, error }
23
23
  { type: 'eose', relays: [{ relay, status, error }] } // error is optional
24
+ { type: 'live-progress', relay, epoch, since, until } // live and live-enabled feeds only
24
25
  ```
25
26
 
26
27
  `eose` means the **initial read attempt is complete**, including empty reads and
@@ -101,6 +102,51 @@ content-key, private-channel and NIP-46 helpers unwrap pool results internally
101
102
  and retain their higher-level event contracts. Count, publication and disconnect
102
103
  return formats are unchanged.
103
104
 
105
+ ### Live overlap and progress (0.10.22)
106
+
107
+ Every live subscription opens with `since = max(filter.since ?? 0, now - 600, 0)`,
108
+ including its first connection. This accepts events that arrive after opening
109
+ but carry a timestamp up to ten minutes earlier. Explicit filter boundaries still
110
+ apply, and retained events before EOSE remain excluded from strictly-live output.
111
+
112
+ Reconnect recovery overlaps each relay's last received timestamp by 600 seconds,
113
+ without going below the caller's `since` or zero. Empty connections use their
114
+ opening time instead. Cursors advance independently even for events deduplicated
115
+ across relays; future timestamps are capped at their local receipt time. Failed
116
+ or interrupted recoveries retain their earlier baseline for the next reconnect.
117
+
118
+ Live readers automatically enqueue a `live-progress` envelope every 60 seconds
119
+ while ready, including when no events arrive. There is no enabling option.
120
+ `epoch` is a numeric connection-attempt identifier scoped to this iterator;
121
+ compare it together with `relay`, not across separate iterators. `since` is the
122
+ local EOSE time (or the explicit filter lower bound if later), and `until` is the
123
+ local time at the tick, both inclusive Unix seconds. Repeated markers describe
124
+ the same continuous epoch with an increasing `until`.
125
+
126
+ Progress starts only after that relay's EOSE and successful completion of any
127
+ pending recovery. It pauses on disconnection, does not span connection epochs,
128
+ and stops on cancellation, draining or `until`. History-only and empty-relay
129
+ reads do not emit it. Timers never issue extra network queries.
130
+
131
+ Markers share the bounded queue with events and follow all earlier received
132
+ items. A feed preserves that ordering behind its initial history and queued live
133
+ events. Consumers must finish persisting preceding events before persisting
134
+ progress, and must not treat a marker as proof of historical completeness or as
135
+ coverage of a disconnected interval. This is client-observed continuity, not a
136
+ relay acknowledgement; delayed relay delivery or clock skew beyond the overlap
137
+ can still leave gaps. A backward local clock cannot regress an emitted boundary.
138
+
139
+ Every involuntary live close emits an error and still permits automatic recovery.
140
+ A close without an explicit cause uses `error.code === 'RELAY_LIVE_INTERRUPTED'`
141
+ and `category === 'transport'`; a pre-EOSE close retains status `closed` in the
142
+ initial report. Explicit causes are preserved. Caller cancellation, draining and
143
+ expiry of `filter.until` do not manufacture interruption errors.
144
+
145
+ Process Nostr events only under `item.type === 'event'`. Handle controls you need
146
+ and ignore unknown types; a new control is neither an event nor stream completion.
147
+ The internal query, private-channel and NIP-46 consumers retain their existing
148
+ higher-level event-only contracts.
149
+
104
150
  ### Read admission and bounded snapshots (0.10.21)
105
151
 
106
152
  Each pool coordinates event reads by normalized connection URL. Constructor
@@ -774,6 +820,24 @@ The NIP-94 extension also carries optional `download` intent; see
774
820
 
775
821
  ## Reliable IndexedDB queue reservations
776
822
 
823
+ `queue.putBy(indexName, item, { existingOnly = false })` atomically inserts or
824
+ replaces an item through a declared unique, non-multiEntry index. Its key is
825
+ extracted from the item's index keyPath, including nested paths and compound
826
+ keys. Existing items retain their position; new keys append to the tail. It
827
+ returns `true` after commit, or `false` without inserting when `existingOnly`
828
+ is true and the key is absent. This allows late progress checkpoints to avoid
829
+ recreating a cancelled record. Values are cloned before asynchronous work.
830
+
831
+ Unknown, non-unique or multiEntry indexes throw `ValidationError` with
832
+ `QUEUE_PUT_INDEX_INVALID`; missing or invalid IndexedDB keys use
833
+ `QUEUE_PUT_KEY_INVALID`. Cloning, storage and other unique-index failures retain
834
+ their operational errors. Lookup, replacement and byte accounting share one
835
+ transaction and roll back together. Capacity policy matches `setAt` for a
836
+ replacement and `push` for an insertion. Replacing a reserved item invalidates
837
+ the old token: its `ack`, `nack` and `renew` return false. Successful writes wake
838
+ local waiting consumers. Like the other indexed methods, `putBy` is exclusive
839
+ to `idb-queue`; the Web Storage queue's common API is unchanged.
840
+
777
841
  `createQueue({ prefix, evictionPolicy: 'reject', maxBytes })` opts out of
778
842
  capacity eviction. Oversized items reject with `QUEUE_ITEM_TOO_LARGE`, full
779
843
  queues with `QUEUE_CAPACITY_EXCEEDED`; browser quota errors propagate without
@@ -725,6 +725,34 @@ export async function createQueue ({
725
725
  })
726
726
  }
727
727
 
728
+ async function putBy (indexName, item, { existingOnly = false } = {}) {
729
+ const definition = indexDefinitions.find(index => index.name === indexName)
730
+ if (!definition?.unique || definition.multiEntry) throw new ValidationError('QUEUE_PUT_INDEX_INVALID')
731
+ // Snapshot before awaiting IndexedDB so the lookup key and stored value
732
+ // cannot diverge if the caller changes its object during the transaction.
733
+ const value = structuredClone(item)
734
+ const readKey = path => path.split('.').reduce((part, key) => part?.[key], value)
735
+ const key = Array.isArray(definition.keyPath) ? definition.keyPath.map(readKey) : readKey(definition.keyPath)
736
+ try { indexedDB.cmp(key, key) } catch { throw new ValidationError('QUEUE_PUT_KEY_INVALID') }
737
+ const requiredBytes = itemForStorage(0, value).byteSize
738
+ const written = await mutate(async (tx, state) => {
739
+ const { result: previous } = await run('get', [key], ITEMS_STORE, indexName, { tx })
740
+ if (previous) {
741
+ const index = previous.position - state.head
742
+ await putItem(tx, state, previous.position, value, {
743
+ direction: evictionDirectionFor('setAt', { index, length: state.tail - state.head }),
744
+ protectedPositions: new Set([previous.position])
745
+ })
746
+ } else {
747
+ if (existingOnly) return false
748
+ await pushInTransaction(tx, state, value)
749
+ }
750
+ return true
751
+ }, { requiredBytes })
752
+ if (written) wake()
753
+ return written
754
+ }
755
+
728
756
  async function getBy (indexName, query) {
729
757
  // Explicit index operations avoid scanning queue values in JavaScript.
730
758
  return snapshot(async tx => {
@@ -899,6 +927,7 @@ export async function createQueue ({
899
927
  removeWhere,
900
928
  some,
901
929
  clear,
930
+ putBy,
902
931
  getBy,
903
932
  someBy,
904
933
  removeBy,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.10.21",
3
+ "version": "0.10.23",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -13,6 +13,8 @@ const COUNT_TIMEOUT_MS = 5000
13
13
  const COUNT_TIMEOUT_AFTER_FIRST_COUNT_MS = 500
14
14
  const SEND_TIMEOUT_UNTIL_FIRST_FULFILLMENT_MS = 3000
15
15
  const SEND_TIMEOUT_MS = 30000
16
+ const LIVE_OVERLAP_SECONDS = 600
17
+ const LIVE_PROGRESS_INTERVAL_MS = 60000
16
18
 
17
19
  // Returns a function that should be called for each received event (valid or invalid).
18
20
  // Calls onSatisfied() and stops counting once the filter is fully satisfied per relay:
@@ -607,8 +609,34 @@ export class RelayPool {
607
609
  // Preserve until for forwarding to the live sub filter and the teardown timer
608
610
  const filterUntil = filter.until > 0 ? filter.until : null
609
611
 
610
- // lastSeenAt: the highest created_at received so far; used as since on reconnect gap fill
611
- let lastSeenAt = (filter.since > 0) ? filter.since : null
612
+ // Recovery uses each relay's received timestamps, or its opening time when empty.
613
+ const lastSeenAt = new Map()
614
+ const openedAt = new Map()
615
+ const pendingGaps = new Map()
616
+ const attempts = new Map()
617
+ let nextEpoch = 0
618
+ const sinceFloor = Math.max(0, filter.since ?? 0)
619
+ const overlappingSince = timestamp => Math.max(sinceFloor, timestamp - LIVE_OVERLAP_SECONDS)
620
+ const observeEvent = (event, url) => {
621
+ // Every relay advances independently, including cross-relay duplicates.
622
+ // Future-dated events cannot hide an offline interval during recovery.
623
+ const timestamp = Math.min(event.created_at, Math.floor(Date.now() / 1000))
624
+ if (Number.isFinite(timestamp) && timestamp > (lastSeenAt.get(url) ?? -Infinity)) lastSeenAt.set(url, timestamp)
625
+ }
626
+ const scheduleProgress = attempt => {
627
+ if (isDone || attempt.closed || attempts.get(attempt.url) !== attempt ||
628
+ attempt.since === null || !attempt.recovered || attempt.timer) return
629
+ attempt.timer = maybeUnref(setTimeout(() => {
630
+ attempt.timer = null
631
+ if (isDone || attempt.closed || attempts.get(attempt.url) !== attempt) return
632
+ const until = Math.min(filterUntil ?? Infinity, Math.floor(Date.now() / 1000))
633
+ if (until > attempt.lastProgress && until >= attempt.since) {
634
+ enqueue({ type: 'live-progress', relay: attempt.url, epoch: attempt.epoch, since: attempt.since, until })
635
+ attempt.lastProgress = until
636
+ }
637
+ scheduleProgress(attempt)
638
+ }, LIVE_PROGRESS_INTERVAL_MS))
639
+ }
612
640
 
613
641
  // Bounded dedup set to handle overlap between reconnect gap fill and live sub.
614
642
  const seenIds = new Set()
@@ -661,6 +689,10 @@ export class RelayPool {
661
689
  clearTimeout(untilTimer)
662
690
  finishReady('closed', false)
663
691
  gapAc.abort()
692
+ for (const attempt of attempts.values()) {
693
+ clearTimeout(attempt.timer)
694
+ attempt.stop.abort()
695
+ }
664
696
  for (const timer of retryTimers.values()) clearTimeout(timer)
665
697
  retryTimers.clear()
666
698
  liveSubs.forEach(sub => sub.close())
@@ -677,7 +709,6 @@ export class RelayPool {
677
709
  if (seenIds.size >= 500) seenIds.delete(seenIds.values().next().value) // evict oldest
678
710
  seenIds.add(event.id)
679
711
  }
680
- if (event.created_at > (lastSeenAt ?? 0)) lastSeenAt = event.created_at
681
712
  enqueue({ type: 'event', event, relay: url })
682
713
  }
683
714
 
@@ -714,10 +745,10 @@ export class RelayPool {
714
745
  maybeFinishInitialReady()
715
746
  }
716
747
  }
717
- const reportClosed = url => {
748
+ const reportClosed = (url, error) => {
718
749
  if (!isReady && initialPending.delete(url)) {
719
750
  clearTimeout(readyTimeouts.get(url))
720
- initialOutcomes.set(url, { relay: url, status: 'closed' })
751
+ initialOutcomes.set(url, { relay: url, status: 'closed', error })
721
752
  maybeFinishInitialReady()
722
753
  }
723
754
  }
@@ -727,7 +758,7 @@ export class RelayPool {
727
758
  const nextDelay = Math.min(reconnectDelay * 2, 5 * 60_000)
728
759
  const timer = maybeUnref(setTimeout(() => {
729
760
  retryTimers.delete(url)
730
- subscribeToRelay(url, lastSeenAt, nextDelay)
761
+ subscribeToRelay(url, pendingGaps.get(url) ?? lastSeenAt.get(url) ?? openedAt.get(url) ?? null, nextDelay)
731
762
  }, reconnectDelay))
732
763
  retryTimers.set(url, timer)
733
764
  }
@@ -740,24 +771,35 @@ export class RelayPool {
740
771
 
741
772
  // Runs a reconnect gap fill for a single relay and returns a promise that resolves
742
773
  // when it completes. now is shared with the live sub so both use the same boundary.
743
- const runReconnectGapFill = (url, gapSince, now, slots) => {
774
+ const runReconnectGapFill = (url, gapSince, now, slots, attempt) => {
744
775
  const gapUntil = filterUntil !== null ? Math.min(now, filterUntil) : now
745
- const gapFilter = { ...baseFilter, since: gapSince, until: gapUntil }
776
+ const gapFilter = { ...baseFilter, since: overlappingSince(gapSince), until: gapUntil }
746
777
  const gapGen = _gapEventsGenerator(gapFilter, [url], {
747
778
  _admissions: new Map([[url, Promise.resolve(slots)]]),
748
779
  queueTimeout,
749
780
  timeout: timeoutForReconnectGap,
750
781
  timeoutAfterFirstEose: timeoutAfterFirstReconnectGapEose,
751
782
  signal,
752
- _stopSignal: gapAc.signal
783
+ _stopSignal: AbortSignal.any([gapAc.signal, attempt.stop.signal])
753
784
  })
754
785
  return (async () => {
786
+ let completed = false
787
+ let failed = false
755
788
  for await (const item of gapGen) {
756
- if (item?.type === 'event') pushEvent(item.event, url, true)
757
- else if (item?.type === 'error' && !isDone) enqueue(item)
789
+ if (item?.type === 'event') {
790
+ observeEvent(item.event, url)
791
+ pushEvent(item.event, url, true)
792
+ } else if (item?.type === 'error') {
793
+ failed = true
794
+ if (!isDone) enqueue(item)
795
+ } else if (item?.type === 'eose') {
796
+ completed = item.relays?.length === 1 && ['eose', 'satisfied'].includes(item.relays[0].status)
797
+ }
758
798
  }
799
+ return completed && !failed
759
800
  })().catch(err => {
760
801
  reportFailure(url, asError(err))
802
+ return false
761
803
  }).finally(() => slots.history.release())
762
804
  }
763
805
 
@@ -766,7 +808,10 @@ export class RelayPool {
766
808
  // Don't reconnect if we're past the until boundary
767
809
  if (filterUntil !== null && now >= filterUntil) return
768
810
  const reservation = !initialAdmissions.has(url) && _admissions?.get(url)
769
- const recovering = gapSince !== null && gapSince > 0
811
+ const recovering = gapSince !== null
812
+ if (recovering) pendingGaps.set(url, gapSince)
813
+ const attempt = { url, epoch: ++nextEpoch, since: null, lastProgress: -Infinity, recovered: !recovering, timer: null, closed: false, stop: new AbortController() }
814
+ attempts.set(url, attempt)
770
815
  let recoveryLease
771
816
  initialAdmissions.add(url)
772
817
  Promise.resolve(reservation || this.#admission.acquire(normalizeRelayUrl(url), { live: true, history: recovering, signal: gapAc.signal, queueTimeout })).then(async slots => {
@@ -790,20 +835,22 @@ export class RelayPool {
790
835
 
791
836
  // Buffer post-EOSE live events while a reconnect gap fill is running so
792
837
  // the historical gap is yielded before newer socket events.
793
- let liveBuffer = (gapSince !== null && gapSince > 0) ? [] : null
838
+ let liveBuffer = recovering ? [] : null
794
839
  let liveEose = false
795
840
  let bufferedBytes = 0
796
841
 
797
842
  // Open the live sub first so the relay starts buffering incoming events
798
843
  // before we scan its database for the reconnect gap fill.
799
844
  // Forward until to the relay so it can enforce the boundary server-side.
800
- const liveFilter = { ...baseFilter, since: now, limit: 0 }
845
+ if (!openedAt.has(url)) openedAt.set(url, now)
846
+ const liveFilter = { ...baseFilter, since: overlappingSince(now), limit: 0 }
801
847
  if (filterUntil !== null) liveFilter.until = filterUntil
802
848
  const liveSub = relay.subscribe([liveFilter], {
803
849
  onevent: (event) => {
804
850
  // A limit:0 relay may still send retained events before EOSE. Do not
805
851
  // expose them from a strictly-live stream.
806
- if (isDone || liveSubs.get(url) !== liveSub || !liveEose) return
852
+ if (isDone || liveSubs.get(url) !== liveSub || attempt.closed || !liveEose) return
853
+ observeEvent(event, url)
807
854
  if (liveBuffer) {
808
855
  const bytes = encoder.encode(JSON.stringify(event)).byteLength
809
856
  if (liveBuffer.length >= maxBufferedLiveEvents || bufferedBytes + bytes > maxBufferedLiveBytes) {
@@ -815,33 +862,44 @@ export class RelayPool {
815
862
  } else pushEvent(event, url)
816
863
  },
817
864
  onclose: error => {
865
+ if (attempt.closed || attempts.get(url) !== attempt) return
866
+ attempt.closed = true
867
+ clearTimeout(attempt.timer)
868
+ attempt.stop.abort()
818
869
  if (liveSubs.get(url) === liveSub) liveSubs.delete(url)
819
- else if (liveSubs.has(url)) return
820
870
  lease.release()
821
871
  if (liveLeases.get(url) === lease) liveLeases.delete(url)
822
872
  readyRelays.delete(url)
823
- if (!isDone) {
824
- if (error !== undefined) reportFailure(url, asError(error))
825
- else if (!liveEose) reportClosed(url)
826
- }
827
873
  if (isDone) return
874
+ if (error !== undefined) reportFailure(url, asError(error))
875
+ else {
876
+ const interruption = Object.assign(categorizeRelayError(new Error('RELAY_LIVE_INTERRUPTED'), 'transport'), { code: 'RELAY_LIVE_INTERRUPTED' })
877
+ enqueue({ type: 'error', relay: url, error: interruption })
878
+ reportClosed(url, interruption)
879
+ }
828
880
  scheduleReconnect(url, reconnectDelay)
829
881
  },
830
882
  oneose: () => {
831
- if (isDone || (liveSubs.has(url) && liveSubs.get(url) !== liveSub)) return
883
+ if (isDone || attempt.closed || attempts.get(url) !== attempt || liveEose) return
832
884
  liveEose = true
885
+ attempt.since = Math.max(sinceFloor, Math.floor(Date.now() / 1000))
833
886
  markInitialEose(url)
887
+ scheduleProgress(attempt)
834
888
  }
835
889
  })
836
890
  if (isDone) { liveSub.close(); recoveryLease?.release(); return }
837
891
  liveSubs.set(url, liveSub)
838
892
 
839
- if (gapSince !== null && gapSince > 0) {
840
- const task = runReconnectGapFill(url, gapSince, now, slots).finally(() => {
893
+ if (recovering) {
894
+ const task = runReconnectGapFill(url, gapSince, now, slots, attempt).then(recovered => {
895
+ attempt.recovered = recovered
896
+ if (recovered && !attempt.closed && attempts.get(url) === attempt) pendingGaps.delete(url)
897
+ }).finally(() => {
841
898
  const buf = liveBuffer
842
899
  liveBuffer = null
843
900
  for (const event of buf) pushEvent(event, url, true)
844
901
  gapTasks.delete(task)
902
+ scheduleProgress(attempt)
845
903
  p.resolve()
846
904
  })
847
905
  gapTasks.add(task)
@@ -939,7 +997,7 @@ export class RelayPool {
939
997
 
940
998
  // limit:0 is a live-only stream and reports that stream's initial window.
941
999
  if (filter.limit === 0) {
942
- yield * _liveGenerator(filter, relays, options)
1000
+ yield * _liveGenerator(filter, relays, { ...options, maxBufferedLiveEvents, maxBufferedLiveBytes })
943
1001
  return
944
1002
  }
945
1003