libp2r2p 0.10.19 → 0.10.20

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
@@ -126,8 +126,9 @@ const messenger = await createPrivateMessenger({
126
126
  })
127
127
 
128
128
  async function logMessages () {
129
- for await (const message of messenger.messages()) {
129
+ for await (const { message, ack } of messenger.messages()) {
130
130
  console.log(message.type, message.payload)
131
+ await ack()
131
132
  }
132
133
  }
133
134
 
@@ -316,23 +317,101 @@ needed by private channels. For double-DH content-key use, pass a
316
317
  `contentKeySigner` or a signer implementation that handles content keys
317
318
  internally.
318
319
 
319
- Messages are stored in a bounded, durable IndexedDB queue until consumed or
320
+ Messages are stored in a bounded, durable IndexedDB queue until acknowledged or
320
321
  until the principal identity has been inactive for 60 days:
321
322
 
322
323
  ```js
323
324
  async function handleMessages () {
324
- for await (const message of messenger.messages()) {
325
- if (message.type === 'message') {
326
- console.log(message.payload)
327
- }
325
+ for await (const { message, ack } of messenger.messages()) {
326
+ await persistMessageIdempotently(message)
327
+ await ack() // only after the destination commits
328
328
  }
329
329
  }
330
330
 
331
331
  handleMessages().catch(err => console.warn('private messenger messages failed', err))
332
332
  ```
333
333
 
334
- For one-at-a-time consumption, use `await messenger.nextMessage()`. Queue
335
- clearing is asynchronous too: `await messenger.clearChannel(channelPubkey)`.
334
+ For one-at-a-time consumption, `await messenger.nextMessage()` returns
335
+ `{ message, ack, nack }` or `null` if no record is currently available. `ack()`
336
+ removes the reserved record; `nack()` releases it for another attempt. Both
337
+ return a boolean and are idempotent for the same reservation. An expired or
338
+ superseded reservation returns `false` and cannot delete a later delivery.
339
+
340
+ Delivery leases last 30 seconds and renew every 10 seconds while held. Closing
341
+ the messenger or returning the iterator releases its unacknowledged deliveries;
342
+ returning an iterator also cancels an empty pending read. A crashed/suspended
343
+ consumer's expired leases are reclaimable by another instance. Apps must save
344
+ idempotently: a crash between their commit and `ack()` can repeat delivery.
345
+ The queue is shared by identity, so concurrent consumers claim distinct records.
346
+
347
+ The app-message queue defaults to 16 MiB and rejects writes at capacity, without
348
+ evicting pending messages. Capacity pressure pauses ingestion and records a
349
+ recovery gap; successful acknowledgments resume it when possible. While capacity-paused, a
350
+ one-second capacity check also detects space released by another instance. Oversized
351
+ records and storage failures report errors and pause with reason `storage`;
352
+ after correcting the cause, call `resume('storage')`. Retention cleanup and
353
+ explicit `clearQueue()`/`clearChannel()` remain destructive. The queue's budget
354
+ bounds serialized storage, not the size of every intermediate decoded message.
355
+
356
+ ### Forwarded authors and isolated consumers
357
+
358
+ `broadcastRumor({ rumor, ... })` accepts an optional `rumor.pubkey`. Omission
359
+ means the sender; a different pubkey forwards that claimed author and requires
360
+ the original `created_at`. A supplied ID must match the reconstructed event.
361
+ The wire retains the original fields; recipients never overwrite an explicit
362
+ author with the transport sender. Signed events retain their signature.
363
+
364
+ Received messages expose `senderPubkey` and `provenance` alongside `event`,
365
+ `outer`, `meta` and `payload`. Provenance is `direct`, `hearsay`, or `signed`,
366
+ calculated by the receiver. Pending deduplication includes provenance, so an original can upgrade a queued
367
+ hearsay copy of the same event. Private-channel callbacks also expose these fields
368
+ in their metadata. Nothing is attached to the Nostr event. A rumor naming the
369
+ recipient is still hearsay unless independently matched to a known original.
370
+ Forwarded controls cannot invoke ask/reply/presence/recovery handlers in the
371
+ name of another author. Direct rumors are not transferable signed proof.
372
+
373
+ Each PrivateMessenger owns a `createPrivateMessageSession()` from
374
+ `libp2r2p/private-message`. A session groups relay reads for one receiver and
375
+ owns its watches, callbacks, and scoped receive-fragment progress. Independent
376
+ sessions can watch the same channel, including with the same receiver, without
377
+ overwriting or closing each other's reads. The module-level watch helpers are
378
+ a convenience scope per receiver; use explicit sessions for separate consumers.
379
+ `receivedChunkScope` can also scope lower-level private-channel reads; receiver
380
+ pubkeys are included in fragment keys. Expiration/cleanup still use the shared
381
+ receive-chunk database and budget.
382
+
383
+ ### Pause, resume and recovery
384
+
385
+ `pause(reason)` and `resume(reason)` accept nonempty strings. Reasons compose:
386
+ removing `network` cannot override an application's `vault` pause. Pause stops
387
+ live watches, recovery fetches and presence publishing, but retains channel
388
+ configuration and pending deliveries. Already-started operations can finish;
389
+ failed ingestion remains recoverable. Send attempts while paused reject.
390
+
391
+ `watch(channels)` declares desired subscriptions. `unwatch(channels)` removes
392
+ that intent while retaining configuration and history. Browser `online` only
393
+ clears the internal `network` pause, never restarts explicitly unwatched
394
+ channels. Updating retained channel configuration preserves explicit unwatch;
395
+ new channels are watched unless paused. Applications may explicitly watch again.
396
+
397
+ Pauses, unwatch and close record the start of the gap, even for channels that
398
+ have never received a message. Rewatch/resume uses live delivery plus historical
399
+ recovery with overlap. A failed resume remains retryable under its pause reason.
400
+ Before opening a watch, the messenger persists a pending recovery interval.
401
+ A first watch scans the configured recovery window (seven days by default),
402
+ including messages sent before this identity first opened the channel. Successful
403
+ scans persist `recoveredThrough`, even when empty; later watches resume from the
404
+ latest persisted message or scan checkpoint with overlap. Pending older ranges
405
+ remain until successfully recovered, even if newer live messages arrive.
406
+ This also covers abrupt termination without `close()` and channels with no
407
+ messages. Applications still supply signers and desired channels on reopening;
408
+ storage is scoped by origin and user pubkey, not the transient session ID.
409
+ Failed ingestion or incomplete relay fetch reports never complete the gap.
410
+ `lastSeenAt` tracks messages persisted in the incoming queue, not app commits.
411
+ The default recovery window is seven days; recovery still depends on available
412
+ relay/seeder data, and retention limits cannot guarantee indefinite delivery.
413
+
414
+ Queue clearing remains asynchronous: `await messenger.clearChannel(channelPubkey)`.
336
415
 
337
416
  Use explicit subpath imports for bundle size. The package root re-exports the
338
417
  main messenger API for convenience, but applications that only need one piece
@@ -626,3 +705,22 @@ limit, retaining the full URL and exposing decoded `url.nfile` and MIME `url.m`.
626
705
 
627
706
  The NIP-94 extension also carries optional `download` intent; see
628
707
  [nip94/README.md](nip94/README.md#download-intent) for event and inline URL forms.
708
+
709
+ ## Reliable IndexedDB queue reservations
710
+
711
+ `createQueue({ prefix, evictionPolicy: 'reject', maxBytes })` opts out of
712
+ capacity eviction. Oversized items reject with `QUEUE_ITEM_TOO_LARGE`, full
713
+ queues with `QUEUE_CAPACITY_EXCEEDED`; browser quota errors propagate without
714
+ trimming existing records. Other eviction policies retain their cache behavior.
715
+ Opening a reject-policy queue under a smaller budget preserves existing items.
716
+
717
+ `queue.reserve({ leaseMs = 30000 })` returns `null` or
718
+ `{ item, ack, nack, renew }`. Reservation token/deadline are internal record
719
+ metadata, persisted atomically in the same IndexedDB transaction as selection.
720
+ `renew()` extends a still-valid lease. Low-level callers own renewal and release;
721
+ PrivateMessenger supplies both. Do not mix reservations with destructive or
722
+ position-shifting queue operations on the same queue except explicit removal.
723
+ A stale token cannot acknowledge a newly reserved or replaced record.
724
+
725
+ `queue.getCapacity()` returns `{ usedBytes, maxBytes }`; capacity rejections
726
+ also expose `requiredBytes` and `maxBytes`, without retaining the rejected item.
@@ -56,6 +56,7 @@ function byteLength (value) {
56
56
 
57
57
  function normalizeEvictionPolicy (policy) {
58
58
  if (policy === 'opposite-end' || policy === undefined || policy === null) return 'opposite-end'
59
+ if (policy === 'reject') return 'reject'
59
60
  if (policy === 'fifo' || policy === 'head') return 'head'
60
61
  if (policy === 'lifo' || policy === 'tail') return 'tail'
61
62
  throw new ValidationError('QUEUE_INVALID_EVICTION_POLICY')
@@ -468,6 +469,12 @@ export async function createQueue ({
468
469
  async function evictToFit (tx, state, requiredBytes, options = {}) {
469
470
  if (!hasByteLimit()) return
470
471
  if (requiredBytes > sessionMaxBytes) throw new Error('QUEUE_ITEM_TOO_LARGE')
472
+ if (configuredEvictionPolicy === 'reject') {
473
+ if (state.usedBytes + requiredBytes > sessionMaxBytes) {
474
+ throw Object.assign(new Error('QUEUE_CAPACITY_EXCEEDED'), { requiredBytes, maxBytes: sessionMaxBytes })
475
+ }
476
+ return
477
+ }
471
478
  // Make room for the write and retain eviction headroom for the next one.
472
479
  const targetBytes = targetBytesAfterWrite(requiredBytes)
473
480
  while (state.usedBytes + requiredBytes > targetBytes) {
@@ -477,7 +484,7 @@ export async function createQueue ({
477
484
  }
478
485
 
479
486
  async function evictToBytes (tx, state, targetBytes, options = {}) {
480
- if (!hasByteLimit()) return
487
+ if (!hasByteLimit() || configuredEvictionPolicy === 'reject') return
481
488
  while (state.usedBytes > targetBytes) {
482
489
  if (!await evictOne(tx, state, options)) break
483
490
  }
@@ -590,7 +597,7 @@ export async function createQueue ({
590
597
  if (wakeWaiters) wake()
591
598
  return value
592
599
  } catch (err) {
593
- if (retried || !hasByteLimit() || !isQuotaExceeded(err)) throw err
600
+ if (configuredEvictionPolicy === 'reject' || retried || !hasByteLimit() || !isQuotaExceeded(err)) throw err
594
601
  // Browser quota can be lower than the configured logical budget.
595
602
  // Retry once with a smaller in-memory budget after atomic rollback.
596
603
  lowerSessionMaxBytes(requiredBytes)
@@ -748,6 +755,61 @@ export async function createQueue ({
748
755
  })
749
756
  }
750
757
 
758
+ // Reservations are stored atomically with the item; another tab/process can
759
+ // reclaim an expired lease after a crash. Destructive/positional queue APIs
760
+ // must not be mixed with reservations on the same queue.
761
+ async function reserve ({ leaseMs = 30000, now = Date.now() } = {}) {
762
+ if (!Number.isSafeInteger(leaseMs) || leaseMs <= 0 || !Number.isSafeInteger(now) || now < 0) throw new ValidationError('QUEUE_INVALID_LEASE')
763
+ const token = globalThis.crypto.randomUUID()
764
+ const reserved = await mutate(async tx => {
765
+ const p = deferred()
766
+ let cursor = (await run('openCursor', [], ITEMS_STORE, null, { tx, p })).result
767
+ while (cursor) {
768
+ const record = cursor.value
769
+ if (!record.reservation || record.reservation.until <= now) {
770
+ record.reservation = { token, until: now + leaseMs }
771
+ await putRecord(tx, record)
772
+ return record
773
+ }
774
+ cursor = await nextCursor(cursor, p)
775
+ }
776
+ return null
777
+ })
778
+ if (!reserved) return null
779
+ let settled = false
780
+ let completedAction
781
+ let completedResult
782
+ let tail = Promise.resolve()
783
+ const update = (action, at = Date.now()) => {
784
+ const operation = tail.then(async () => {
785
+ if (settled) return action === completedAction ? completedResult : false
786
+ const result = await mutate(async (tx, state) => {
787
+ const record = await getRecord(tx, reserved.position)
788
+ if (record?.reservation?.token !== token || record.reservation.until <= at) return false
789
+ if (action === 'ack') {
790
+ await deleteRecord(tx, record.position)
791
+ state.usedBytes = Math.max(0, state.usedBytes - record.byteSize)
792
+ await trimBounds(tx, state)
793
+ } else {
794
+ if (action === 'renew') record.reservation.until = at + leaseMs
795
+ else delete record.reservation
796
+ await putRecord(tx, record)
797
+ }
798
+ return true
799
+ }, { wakeWaiters: action !== 'renew' })
800
+ if (action !== 'renew' || !result) {
801
+ settled = true
802
+ completedAction = action
803
+ completedResult = result
804
+ }
805
+ return result
806
+ })
807
+ tail = operation.catch(() => {})
808
+ return operation
809
+ }
810
+ return { item: reserved.item, ack: () => update('ack'), nack: () => update('nack'), renew: () => update('renew') }
811
+ }
812
+
751
813
  async function snapshotStoredItems () {
752
814
  return snapshot(async tx => {
753
815
  const state = await readState(tx)
@@ -819,6 +881,8 @@ export async function createQueue ({
819
881
  })
820
882
 
821
883
  return {
884
+ reserve,
885
+ getCapacity: () => snapshot(async tx => ({ usedBytes: (await readState(tx)).usedBytes, maxBytes: sessionMaxBytes })),
822
886
  enqueue: push,
823
887
  push,
824
888
  pop,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.10.19",
3
+ "version": "0.10.20",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -0,0 +1,26 @@
1
+ import { getEventHash, isSerializableEvent } from '../../event/index.js'
2
+ import { ValidationError } from '../../error/index.js'
3
+
4
+ // Transport sender and claimed author are distinct. This also validates a
5
+ // supplied ID before discarding it from the unsigned wire representation.
6
+ export function normalizeRumor (rumor, senderPubkey) {
7
+ if (!rumor || typeof rumor !== 'object' || Array.isArray(rumor) || 'sig' in rumor) throw new ValidationError('INVALID_RUMOR')
8
+ const event = {
9
+ pubkey: rumor.pubkey === undefined ? senderPubkey : rumor.pubkey,
10
+ kind: rumor.kind,
11
+ created_at: rumor.created_at,
12
+ tags: rumor.tags,
13
+ content: rumor.content
14
+ }
15
+ if (!isSerializableEvent(event)) throw new ValidationError('INVALID_RUMOR')
16
+ const id = getEventHash(event)
17
+ if (rumor.id !== undefined && rumor.id !== id) throw new ValidationError('INVALID_RUMOR_ID')
18
+ return { ...event, id }
19
+ }
20
+
21
+ export function deliveryInfo (event, senderPubkey) {
22
+ return {
23
+ senderPubkey,
24
+ provenance: event.sig ? 'signed' : event.pubkey === senderPubkey ? 'direct' : 'hearsay'
25
+ }
26
+ }
@@ -5,6 +5,7 @@ import { hexToBytes } from '../base16/index.js'
5
5
  import { isValidContentKeyProof, isValidIykcProof, makeContentKeyEventForPubkey, parseContentKeyEvent } from '../content-key/event/index.js'
6
6
  import { getIykcProofs } from '../content-key/index.js'
7
7
  import { ValidationError } from '../error/index.js'
8
+ import { normalizeRumor, deliveryInfo } from './helpers/rumor.js'
8
9
  import * as nip44v3 from '../nip44-v3/index.js'
9
10
  import { relayPool } from '../relay/index.js'
10
11
  import { JSONL_CHUNK_BYTES, NYM_CARRIER_CHUNK_CHARS } from './helpers/chunk-size.js'
@@ -371,8 +372,7 @@ function eventFromPayload ({ payloadCiphertext, messageSeckey, senderPubkey }) {
371
372
  const messagePubkey = getPublicKey(messageSecretKey)
372
373
  const decrypted = JSON.parse(nip44v3.decrypt(messageSecretKey, messagePubkey, ROUTER_KIND, NIP44_V3_SCOPE, payloadCiphertext))
373
374
  if (hasEventSignature(decrypted)) return assertValidSignedInnerEvent(decrypted)
374
- const normalized = { ...decrypted, pubkey: senderPubkey }
375
- return { ...normalized, id: getEventHash(normalized) }
375
+ return normalizeRumor(decrypted, senderPubkey)
376
376
  }
377
377
 
378
378
  async function unwrapRecipientEnvelope ({ payloadCiphertext, envelope, receiverSigner, receiverPubkey, senderPubkey, imkcPubkey, rowScope = '' }) {
@@ -670,6 +670,7 @@ function createProcessor ({
670
670
  onSeedEvent,
671
671
  onContentKeyUsage,
672
672
  onError,
673
+ receivedChunkScope = '',
673
674
  receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS,
674
675
  receivedChunkTtlMsByPubkey,
675
676
  receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES,
@@ -678,6 +679,7 @@ function createProcessor ({
678
679
  ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES
679
680
  }) {
680
681
  const receivedChunks = createReceivedChunkStore({
682
+ scope: `${receivedChunkScope}:${receiverPubkey || ''}`,
681
683
  ttlMs: receivedChunkTtlMs,
682
684
  maxBytes: receivedChunkMaxBytes,
683
685
  indexedDB: receivedChunkIndexedDB
@@ -750,7 +752,8 @@ function createProcessor ({
750
752
  await onNymEvent?.(event, outer, {
751
753
  carrier: carriers[0],
752
754
  carriers,
753
- channelPubkey
755
+ channelPubkey,
756
+ ...deliveryInfo(event, carriers[0].pubkey)
754
757
  })
755
758
  await receivedChunks.removeGroup(groupKey)
756
759
  return
@@ -833,7 +836,7 @@ function createProcessor ({
833
836
  })
834
837
 
835
838
  if (event && !mustScanWholeBundle) {
836
- await onEvent?.(event, outer, { router: joinedRouter(router), channelPubkey })
839
+ await onEvent?.(event, outer, { router: joinedRouter(router), channelPubkey, ...deliveryInfo(event, senderPubkey) })
837
840
  ignoredGroups.add(groupKey)
838
841
  await receivedChunks.removeGroup(groupKey)
839
842
  return
@@ -855,7 +858,7 @@ function createProcessor ({
855
858
  onContentKeyUsage
856
859
  })
857
860
  if (shouldSeed) await onSeedEvent?.({ recordType: 'routerRow_v1', outer, router: completeRouter, channelPubkey, jsonl, innerEventIdsByRowIndex })
858
- if (event) await onEvent?.(event, outer, { router: completeRouter, channelPubkey, jsonl })
861
+ if (event) await onEvent?.(event, outer, { router: completeRouter, channelPubkey, jsonl, ...deliveryInfo(event, senderPubkey) })
859
862
 
860
863
  await receivedChunks.removeGroup(groupKey)
861
864
  } catch (err) {
@@ -863,6 +866,7 @@ function createProcessor ({
863
866
  ignoredGroups.add(groupKey)
864
867
  await receivedChunks.removeGroup(groupKey).catch(() => {})
865
868
  }
869
+ if (groupKey && !shouldIgnoreGroupError(err)) await receivedChunks.removeGroup(groupKey).catch(() => {})
866
870
  onError?.(err)
867
871
  }
868
872
  }
@@ -894,7 +898,7 @@ function shouldIgnoreGroupError (err) {
894
898
  ].includes(err?.message)
895
899
  }
896
900
 
897
- export async function fetch ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, since, until, limit, mode = 'leecher', modeByPubkey, 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, _getEvents = getEvents }) {
901
+ export async function fetch ({ signal, receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, since, until, limit, 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, _getEvents = getEvents }) {
898
902
  if (!relays?.length) throw new ValidationError('NO_RELAYS')
899
903
  const authors = privateChannelPubkeyList({ privateChannelPubkey, privateChannelPubkeys })
900
904
  const filter = { kinds: [PRIVATE_BROADCAST_KIND] }
@@ -903,22 +907,30 @@ export async function fetch ({ receiverSigner, iykcSigner, privateChannelSigner
903
907
  if (until != null) filter.until = until
904
908
  if (limit != null) filter.limit = limit
905
909
 
906
- const { result } = await _getEvents(filter, relays, {
910
+ const { result, errors = [], relays: report = [] } = await _getEvents(filter, relays, {
907
911
  timeout: 5000,
908
- timeoutAfterFirstEose: null
912
+ timeoutAfterFirstEose: null,
913
+ ...(signal ? { signal } : {})
909
914
  })
910
915
  const events = result.map(({ event }) => event)
911
916
  events.sort((a, b) => a.created_at - b.created_at)
912
- const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
917
+ const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkScope, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
913
918
  try {
914
- for (const event of events) await processOuterEvent(event)
919
+ for (const event of events) {
920
+ signal?.throwIfAborted()
921
+ await processOuterEvent(event)
922
+ }
923
+ signal?.throwIfAborted()
924
+ if (errors.length || report.some(entry => !['eose', 'satisfied'].includes(entry.status))) {
925
+ throw new AggregateError(errors.map(entry => entry.reason), 'PRIVATE_CHANNEL_FETCH_INCOMPLETE')
926
+ }
915
927
  return events
916
928
  } finally {
917
929
  processOuterEvent.close()
918
930
  }
919
931
  }
920
932
 
921
- 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, 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 }) {
933
+ 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 }) {
922
934
  if (!relays?.length) throw new ValidationError('NO_RELAYS')
923
935
  if (receiverSigner && !receiverSigner?.nip44DecryptDoubleDH && !receiverSigner?.nip44v3Decrypt) throw new ValidationError('RECEIVER_SIGNER_NIP44V3_DECRYPT_UNSUPPORTED')
924
936
  if (!privateChannelReaderSigner && !privateChannelReaderSignersByPubkey && !privateChannelSigner && !privateChannelSignersByPubkey) throw new ValidationError('PRIVATE_CHANNEL_READER_REQUIRED')
@@ -927,7 +939,7 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
927
939
  const filter = { kinds: [PRIVATE_BROADCAST_KIND], since }
928
940
  if (authors.length) filter.authors = authors
929
941
  if (limit != null) filter.limit = limit
930
- const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
942
+ const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkScope, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
931
943
  const controller = new AbortController()
932
944
  const events = liveOnly
933
945
  ? _liveEventsGenerator(filter, relays, {
@@ -20,7 +20,7 @@ IndexedDB schema, scoped per received-chunk prefix:
20
20
  database `${prefix}:idb`, version 2
21
21
 
22
22
  groups, keyPath "groupKey"
23
- groupKey `${channelPubkey}:${routerPubkey}`
23
+ groupKey optional consumer scope + `${channelPubkey}:${routerPubkey}`
24
24
  channelPubkey/routerPubkey private-channel coordinates
25
25
  total expected chunk count
26
26
  received/receivedCount sparse received-index map and its count
@@ -181,6 +181,7 @@ function wait (ms) {
181
181
 
182
182
  export function createReceivedChunkStore ({
183
183
  prefix = DEFAULT_PREFIX,
184
+ scope = '',
184
185
  indexedDB = globalThis.indexedDB,
185
186
  ttlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS,
186
187
  maxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES
@@ -220,7 +221,7 @@ export function createReceivedChunkStore ({
220
221
  }
221
222
 
222
223
  function groupKeyFor (channelPubkey, routerPubkey) {
223
- return `${channelPubkey}:${routerPubkey}`
224
+ return `${scope ? `${scope}:` : ''}${channelPubkey}:${routerPubkey}`
224
225
  }
225
226
 
226
227
  async function readUsage (tx) {