libp2r2p 0.5.0 → 0.6.0

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
@@ -148,6 +148,16 @@ does not clear persisted messages, recovery material, or channel state. Pass
148
148
  `temporaryStorageArea: localStorage` when constructing a messenger to opt into
149
149
  a different Storage area.
150
150
 
151
+ Recovery metadata is separate from that temporary send staging. Per-channel
152
+ `lastSeenAt`, offline ranges, and related state are stored in IndexedDB. Raw
153
+ incomplete receive chunks are also stored in IndexedDB, expire after one hour,
154
+ and share a 16 MiB logical budget. Capacity eviction removes whole
155
+ least-recently-used message groups so a partial group is never mistaken for a
156
+ complete one. `receivedChunkTtlMs`, `receivedChunkMaxBytes`, and
157
+ `receivedChunkIndexedDB` may be supplied to the private-channel APIs when an
158
+ embedding environment needs different limits or an injected IDB factory.
159
+ Legacy Web Storage recovery records are neither read nor migrated.
160
+
151
161
  Signers are expected to expose the Nostr-style methods used by the messenger,
152
162
  including `getPublicKey()`, `signEvent(event)`, and the NIP-44 v3 methods
153
163
  needed by private channels. For double-DH content-key use, pass a
@@ -7,6 +7,29 @@ const STATE_KEY = 'queue'
7
7
  const DEFAULT_EVICTION_HEADROOM_RATIO = 0.1
8
8
  const MAX_EVICTION_HEADROOM_BYTES = 64 * 1024 // 64 KiB
9
9
 
10
+ /*
11
+ IndexedDB schema, scoped per queue prefix:
12
+
13
+ database `${prefix}:idb-queue`, version managed additively
14
+
15
+ items, keyPath "position"
16
+ position integer ordering key between the queue head and tail
17
+ byteSize logical serialized size used by the queue capacity limit
18
+ item caller-provided queued value
19
+
20
+ items indexes
21
+ Declared by each createQueue() caller. A requested keyPath such as "status"
22
+ is stored as "item.status"; compound paths receive the same "item." prefix.
23
+ The database version is incremented when declared indexes need to be added.
24
+
25
+ state, keyPath "key"
26
+ key state record name, currently "queue"
27
+ head/tail half-open range containing allocated item positions
28
+ usedBytes sum of the queued items' logical byte sizes
29
+
30
+ The queue state record is absent while the queue is empty.
31
+ */
32
+
10
33
  function deferred () {
11
34
  let resolve
12
35
  let reject
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -670,14 +670,14 @@ function createProcessor ({
670
670
  onError,
671
671
  receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS,
672
672
  receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES,
673
- receivedChunkStorageArea,
673
+ receivedChunkIndexedDB = globalThis.indexedDB,
674
674
  ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS,
675
675
  ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES
676
676
  }) {
677
677
  const receivedChunks = createReceivedChunkStore({
678
678
  ttlMs: receivedChunkTtlMs,
679
679
  maxBytes: receivedChunkMaxBytes,
680
- storageArea: receivedChunkStorageArea
680
+ indexedDB: receivedChunkIndexedDB
681
681
  })
682
682
  const ignoredGroups = createTtlSet({
683
683
  ttlMs: ignoredGroupTtlMs,
@@ -707,14 +707,14 @@ function createProcessor ({
707
707
  groupKey = receivedChunks.groupKeyFor(channelPubkey, nymCarrierGroupId(carrier))
708
708
  if (ignoredGroups.has(groupKey)) return
709
709
 
710
- const meta = receivedChunks.put({
710
+ const meta = await receivedChunks.put({
711
711
  channelPubkey,
712
712
  routerPubkey: nymCarrierGroupId(carrier),
713
713
  index,
714
714
  total,
715
- content: JSON.stringify(carrier)
715
+ contentBytes: encoder.encode(JSON.stringify(carrier))
716
716
  })
717
- const status = receivedChunks.status(meta)
717
+ const status = await receivedChunks.status(meta)
718
718
  onChunk?.({
719
719
  outer,
720
720
  nymCarrier: carrier,
@@ -726,7 +726,7 @@ function createProcessor ({
726
726
  })
727
727
  if (status.received < total) return
728
728
 
729
- const carriers = receivedChunks.readChunkContents(groupKey).map(raw => JSON.parse(raw))
729
+ const carriers = (await receivedChunks.readChunkContents(groupKey)).map(raw => JSON.parse(raw))
730
730
  const event = eventFromNymCarriers(carriers)
731
731
  const shouldSeed = storesRecoverySeeds(channelMode)
732
732
  if (shouldSeed) {
@@ -744,7 +744,7 @@ function createProcessor ({
744
744
  carriers,
745
745
  channelPubkey
746
746
  })
747
- receivedChunks.removeGroup(groupKey)
747
+ await receivedChunks.removeGroup(groupKey)
748
748
  return
749
749
  }
750
750
  const router = decrypted
@@ -757,14 +757,14 @@ function createProcessor ({
757
757
 
758
758
  const imkcPubkey = readImkcTag(router)
759
759
  assertValidRouterImkcProof({ router, senderPubkey, imkcPubkey, imkcProof: readImkcProof(router) })
760
- const meta = receivedChunks.put({
760
+ const meta = await receivedChunks.put({
761
761
  channelPubkey,
762
762
  routerPubkey: router.pubkey,
763
763
  index,
764
764
  total,
765
- content: router.content
765
+ contentBytes: base64ToBytes(router.content)
766
766
  })
767
- const status = receivedChunks.status(meta)
767
+ const status = await receivedChunks.status(meta)
768
768
  onChunk?.({
769
769
  outer,
770
770
  router,
@@ -826,15 +826,15 @@ function createProcessor ({
826
826
  if (event && !mustScanWholeBundle) {
827
827
  await onEvent?.(event, outer, { router: joinedRouter(router), channelPubkey })
828
828
  ignoredGroups.add(groupKey)
829
- receivedChunks.removeGroup(groupKey)
829
+ await receivedChunks.removeGroup(groupKey)
830
830
  return
831
831
  }
832
832
 
833
833
  if (!drained.complete) return
834
834
 
835
835
  const receiverPubkeys = drained.meta?.receiverPubkeys || []
836
- const content = shouldSeed ? receivedChunks.readEnvelopeBundleContent(groupKey) : ''
837
- const jsonl = shouldSeed ? receivedChunks.readEnvelopeBundleText(groupKey) : ''
836
+ const content = shouldSeed ? await receivedChunks.readEnvelopeBundleContent(groupKey) : ''
837
+ const jsonl = shouldSeed ? await receivedChunks.readEnvelopeBundleText(groupKey) : ''
838
838
  const completeRouter = joinedRouter(router, content)
839
839
 
840
840
  emitSentContentKeyUsage({
@@ -848,11 +848,11 @@ function createProcessor ({
848
848
  if (shouldSeed) await onSeedEvent?.({ recordType: 'routerRow_v1', outer, router: completeRouter, channelPubkey, jsonl, innerEventIdsByRowIndex })
849
849
  if (event) await onEvent?.(event, outer, { router: completeRouter, channelPubkey, jsonl })
850
850
 
851
- receivedChunks.removeGroup(groupKey)
851
+ await receivedChunks.removeGroup(groupKey)
852
852
  } catch (err) {
853
853
  if (shouldIgnoreGroupError(err) && groupKey) {
854
854
  ignoredGroups.add(groupKey)
855
- receivedChunks.removeGroup(groupKey)
855
+ await receivedChunks.removeGroup(groupKey).catch(() => {})
856
856
  }
857
857
  onError?.(err)
858
858
  }
@@ -875,13 +875,14 @@ function shouldIgnoreGroupError (err) {
875
875
  'INVALID_RECIPIENT_ENVELOPE',
876
876
  'INVALID_SIGNED_INNER_EVENT',
877
877
  'MISSING_PAYLOAD_ENVELOPE',
878
+ 'RECEIVED_CHUNK_GROUP_TOO_LARGE',
878
879
  'MISMATCHED_NYM_CARRIER_CHUNKS',
879
880
  'MISSING_NYM_CARRIER_CHUNK',
880
881
  'MISSING_NYM_CARRIER_ID'
881
882
  ].includes(err?.message)
882
883
  }
883
884
 
884
- 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, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkStorageArea, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _getEvents = getEvents }) {
885
+ 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, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _getEvents = getEvents }) {
885
886
  if (!relays?.length) throw new Error('NO_RELAYS')
886
887
  const authors = privateChannelPubkeyList({ privateChannelPubkey, privateChannelPubkeys })
887
888
  const filter = { kinds: [PRIVATE_BROADCAST_KIND] }
@@ -895,12 +896,12 @@ export async function fetch ({ receiverSigner, iykcSigner, privateChannelSigner
895
896
  timeoutAfterFirstEose: null
896
897
  })
897
898
  events.sort((a, b) => a.created_at - b.created_at)
898
- const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkTtlMs, receivedChunkMaxBytes, receivedChunkStorageArea, ignoredGroupTtlMs, ignoredGroupMaxEntries })
899
+ const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkTtlMs, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
899
900
  for (const event of events) await processOuterEvent(event)
900
901
  return events
901
902
  }
902
903
 
903
- 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, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkStorageArea, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _liveEventsGenerator = getLiveEventsGenerator, _eventsFeedGenerator = getEventsFeedGenerator }) {
904
+ 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, 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 }) {
904
905
  if (!relays?.length) throw new Error('NO_RELAYS')
905
906
  if (receiverSigner && !receiverSigner?.nip44DecryptDoubleDH && !receiverSigner?.nip44v3Decrypt) throw new Error('RECEIVER_SIGNER_NIP44V3_DECRYPT_UNSUPPORTED')
906
907
  if (!privateChannelReaderSigner && !privateChannelReaderSignersByPubkey && !privateChannelSigner && !privateChannelSignersByPubkey) throw new Error('PRIVATE_CHANNEL_READER_REQUIRED')
@@ -909,7 +910,7 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
909
910
  const filter = { kinds: [PRIVATE_BROADCAST_KIND], since }
910
911
  if (authors.length) filter.authors = authors
911
912
  if (limit != null) filter.limit = limit
912
- const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkTtlMs, receivedChunkMaxBytes, receivedChunkStorageArea, ignoredGroupTtlMs, ignoredGroupMaxEntries })
913
+ const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkTtlMs, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
913
914
  const controller = new AbortController()
914
915
  const events = liveOnly
915
916
  ? _liveEventsGenerator(filter, relays, {
@@ -1,34 +1,115 @@
1
- import { base64ToBytes, bytesToBase64 } from '../../base64/index.js'
2
- import { JSONL_CHUNK_BYTES } from '../helpers/chunk-size.js'
1
+ import { bytesToBase64 } from '../../base64/index.js'
2
+ import { run } from '../../idb/index.js'
3
3
 
4
4
  export const DEFAULT_RECEIVED_CHUNK_TTL_MS = 60 * 60 * 1000 // 1 hour
5
- // For illustration purposes: A 280-character rumor would take approximately
6
- // 23 chunks (~0.65 MiB) to be sent if encrypted to 1000 participants.
7
- export const DEFAULT_RECEIVED_CHUNK_MAX_BYTES = Math.min(JSONL_CHUNK_BYTES * 64, 3 * 1024 * 1024 /* 3 MiB cap */)
5
+ export const DEFAULT_RECEIVED_CHUNK_MAX_BYTES = 16 * 1024 * 1024 // 16 MiB
8
6
 
9
7
  const DEFAULT_PREFIX = 'libp2r2p:private-channel:received'
10
- const encoder = new TextEncoder()
8
+ const DATABASE_VERSION = 1
9
+ const GROUPS_STORE = 'groups'
10
+ const CHUNKS_STORE = 'chunks'
11
+ const STATE_STORE = 'state'
12
+ const USAGE_KEY = 'usage'
13
+ const DRAIN_LEASE_MS = 30_000
11
14
  const decoder = new TextDecoder()
12
15
 
13
- // Receive buffers survive reloads briefly, unlike send-side temporary chunks.
14
- // Stale cleanup handles peers that never publish the remaining chunks.
16
+ /*
17
+ IndexedDB schema, scoped per received-chunk prefix:
18
+
19
+ database `${prefix}:idb`, version 1
20
+
21
+ groups, keyPath "groupKey"
22
+ groupKey `${channelPubkey}:${routerPubkey}`
23
+ channelPubkey/routerPubkey private-channel coordinates
24
+ total expected chunk count
25
+ received/receivedCount sparse received-index map and its count
26
+ nextIndex/rowIndex/carry incremental payload decoding state
27
+ payloadCiphertext accumulated encrypted payload text
28
+ receiverPubkeys deduplicated intended receivers
29
+ byteSize logical bytes charged to this group
30
+ createdAt/updatedAt lifecycle timestamps in milliseconds
31
+ drainToken/drainUntil optional drain lease owner and expiry
32
+
33
+ groups indexes
34
+ byUpdatedAt updatedAt
35
+
36
+ chunks, keyPath ["groupKey", "index"]
37
+ groupKey owning group coordinate
38
+ index zero-based chunk position
39
+ bytes raw chunk bytes stored as Uint8Array
40
+
41
+ chunks indexes
42
+ byGroup groupKey
43
+
44
+ state, keyPath "key"
45
+ key state record name, currently "usage"
46
+ usedBytes total logical bytes held by all groups
47
+ */
48
+
49
+ function deferred () {
50
+ let resolve
51
+ let reject
52
+ const promise = new Promise((nextResolve, nextReject) => {
53
+ resolve = nextResolve
54
+ reject = nextReject
55
+ })
56
+ return { promise, resolve, reject }
57
+ }
58
+
59
+ function transactionDone (tx) {
60
+ const p = deferred()
61
+ tx.oncomplete = () => p.resolve()
62
+ tx.onabort = () => p.reject(tx.error || new Error('IDB_TRANSACTION_ABORTED'))
63
+ tx.onerror = () => p.reject(tx.error || new Error('IDB_TRANSACTION_FAILED'))
64
+ return p.promise
65
+ }
66
+
67
+ function openDatabase (indexedDB, name) {
68
+ if (!indexedDB?.open) return Promise.reject(new Error('IDB_UNAVAILABLE'))
69
+ return new Promise((resolve, reject) => {
70
+ const request = indexedDB.open(name, DATABASE_VERSION)
71
+ request.onerror = () => reject(request.error || new Error('IDB_OPEN_FAILED'))
72
+ request.onblocked = () => reject(new Error('IDB_DATABASE_BLOCKED'))
73
+ request.onupgradeneeded = () => {
74
+ const db = request.result
75
+ const tx = request.transaction
76
+ let groups
77
+ if (!db.objectStoreNames.contains(GROUPS_STORE)) {
78
+ groups = db.createObjectStore(GROUPS_STORE, { keyPath: 'groupKey' })
79
+ } else {
80
+ groups = tx.objectStore(GROUPS_STORE)
81
+ }
82
+ if (!groups.indexNames.contains('byUpdatedAt')) groups.createIndex('byUpdatedAt', 'updatedAt')
83
+
84
+ let chunks
85
+ if (!db.objectStoreNames.contains(CHUNKS_STORE)) {
86
+ chunks = db.createObjectStore(CHUNKS_STORE, { keyPath: ['groupKey', 'index'] })
87
+ } else {
88
+ chunks = tx.objectStore(CHUNKS_STORE)
89
+ }
90
+ if (!chunks.indexNames.contains('byGroup')) chunks.createIndex('byGroup', 'groupKey')
15
91
 
16
- function byteLength (value) {
17
- return encoder.encode(String(value)).length
92
+ if (!db.objectStoreNames.contains(STATE_STORE)) db.createObjectStore(STATE_STORE, { keyPath: 'key' })
93
+ }
94
+ request.onsuccess = () => {
95
+ const db = request.result
96
+ db.onversionchange = () => db.close()
97
+ resolve(db)
98
+ }
99
+ })
18
100
  }
19
101
 
20
- function parseJson (raw, fallback) {
21
- try { return JSON.parse(raw || '') } catch { return fallback }
102
+ function normalizeBytes (value) {
103
+ if (value instanceof Uint8Array) return new Uint8Array(value)
104
+ if (value instanceof ArrayBuffer) return new Uint8Array(value)
105
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
106
+ throw new Error('RECEIVED_CHUNK_BYTES_REQUIRED')
22
107
  }
23
108
 
24
109
  function uniq (values) {
25
110
  return [...new Set((values || []).filter(Boolean))]
26
111
  }
27
112
 
28
- function normalizeGroupKeys (value) {
29
- return Array.isArray(value) ? uniq(value.filter(key => typeof key === 'string' && key)) : []
30
- }
31
-
32
113
  function normalizeReceived (received) {
33
114
  if (!received || typeof received !== 'object' || Array.isArray(received)) return {}
34
115
  return Object.fromEntries(
@@ -38,14 +119,14 @@ function normalizeReceived (received) {
38
119
  )
39
120
  }
40
121
 
41
- function normalizeMeta (meta, groupKey) {
122
+ function normalizeMeta (meta) {
42
123
  if (!meta || typeof meta !== 'object') return null
43
124
  const total = Number(meta.total)
44
125
  const nextIndex = Number(meta.nextIndex)
45
126
  const rowIndex = Number(meta.rowIndex)
46
- if (!Number.isSafeInteger(total) || total < 1) return null
127
+ if (!meta.groupKey || !Number.isSafeInteger(total) || total < 1) return null
47
128
  return {
48
- groupKey,
129
+ groupKey: String(meta.groupKey),
49
130
  channelPubkey: String(meta.channelPubkey || ''),
50
131
  routerPubkey: String(meta.routerPubkey || ''),
51
132
  total,
@@ -55,10 +136,19 @@ function normalizeMeta (meta, groupKey) {
55
136
  rowIndex: Number.isSafeInteger(rowIndex) && rowIndex >= 0 ? rowIndex : 0,
56
137
  carry: typeof meta.carry === 'string' ? meta.carry : '',
57
138
  payloadCiphertext: typeof meta.payloadCiphertext === 'string' ? meta.payloadCiphertext : '',
58
- receiverPubkeys: uniq(meta.receiverPubkeys || []),
139
+ receiverPubkeys: uniq(meta.receiverPubkeys),
59
140
  byteSize: Math.max(0, Number(meta.byteSize) || 0),
60
141
  createdAt: Number(meta.createdAt) || Date.now(),
61
- updatedAt: Number(meta.updatedAt) || Date.now()
142
+ updatedAt: Number(meta.updatedAt) || Date.now(),
143
+ drainToken: typeof meta.drainToken === 'string' ? meta.drainToken : '',
144
+ drainUntil: Math.max(0, Number(meta.drainUntil) || 0)
145
+ }
146
+ }
147
+
148
+ function normalizeUsage (value) {
149
+ return {
150
+ key: USAGE_KEY,
151
+ usedBytes: Number.isSafeInteger(value?.usedBytes) && value.usedBytes >= 0 ? value.usedBytes : 0
62
152
  }
63
153
  }
64
154
 
@@ -70,217 +160,218 @@ function isQuotaExceeded (err) {
70
160
  /quota/i.test(err?.message || '')
71
161
  }
72
162
 
73
- function joinBase64Chunks (parts) {
74
- let out = ''
75
- let carry = new Uint8Array()
76
-
77
- for (const part of parts) {
78
- const bytes = base64ToBytes(part)
79
- const joined = new Uint8Array(carry.length + bytes.length)
80
- joined.set(carry)
81
- joined.set(bytes, carry.length)
82
-
83
- const completeLength = joined.length - (joined.length % 3)
84
- if (completeLength) out += bytesToBase64(joined.slice(0, completeLength))
85
- carry = joined.slice(completeLength)
86
- }
163
+ function randomToken () {
164
+ const bytes = globalThis.crypto?.getRandomValues?.(new Uint8Array(16))
165
+ if (bytes) return [...bytes].map(value => value.toString(16).padStart(2, '0')).join('')
166
+ return `${Date.now().toString(16)}:${Math.random().toString(16).slice(2)}`
167
+ }
87
168
 
88
- if (carry.length) out += bytesToBase64(carry)
89
- return out
169
+ function wait (ms) {
170
+ return new Promise(resolve => setTimeout(resolve, ms))
90
171
  }
91
172
 
92
173
  export function createReceivedChunkStore ({
93
174
  prefix = DEFAULT_PREFIX,
94
- storageArea = globalThis.localStorage,
175
+ indexedDB = globalThis.indexedDB,
95
176
  ttlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS,
96
177
  maxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES
97
178
  } = {}) {
98
- const groupsKey = `${prefix}:groups`
99
179
  const configuredTtlMs = Number.isFinite(ttlMs) && ttlMs >= 0 ? ttlMs : DEFAULT_RECEIVED_CHUNK_TTL_MS
100
180
  const configuredMaxBytes = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : Infinity
181
+ let dbPromise
182
+ let readyPromise
101
183
 
102
- function storage () {
103
- return storageArea
104
- }
105
-
106
- function metaKey (groupKey) {
107
- return `${prefix}:group:${groupKey}:meta`
184
+ function database () {
185
+ dbPromise ||= openDatabase(indexedDB, `${prefix}:idb`)
186
+ return dbPromise
108
187
  }
109
188
 
110
- function chunkKey (groupKey, index) {
111
- return `${prefix}:group:${groupKey}:chunk:${index}`
189
+ async function transaction (storeNames, mode, work) {
190
+ const db = await database()
191
+ const tx = db.transaction(storeNames, mode)
192
+ const done = transactionDone(tx)
193
+ try {
194
+ // `work` may await only IDB requests issued through `run` with this tx.
195
+ const result = await work(tx)
196
+ await done
197
+ return result
198
+ } catch (err) {
199
+ try { tx.abort() } catch {}
200
+ try { await done } catch {}
201
+ throw err
202
+ }
112
203
  }
113
204
 
114
205
  function groupKeyFor (channelPubkey, routerPubkey) {
115
206
  return `${channelPubkey}:${routerPubkey}`
116
207
  }
117
208
 
118
- function readGroupKeys () {
119
- return normalizeGroupKeys(parseJson(storage().getItem(groupsKey), []))
209
+ async function readUsage (tx) {
210
+ return normalizeUsage((await run('get', [USAGE_KEY], STATE_STORE, null, { tx })).result)
120
211
  }
121
212
 
122
- function writeGroupKeys (keys) {
123
- const normalized = normalizeGroupKeys(keys)
124
- if (normalized.length) storage().setItem(groupsKey, JSON.stringify(normalized))
125
- else storage().removeItem(groupsKey)
213
+ async function writeUsage (tx, usage) {
214
+ await run('put', [normalizeUsage(usage)], STATE_STORE, null, { tx })
126
215
  }
127
216
 
128
- function addGroupKey (groupKey) {
129
- const keys = readGroupKeys()
130
- if (!keys.includes(groupKey)) writeGroupKeys(keys.concat(groupKey))
217
+ async function deleteGroupInTransaction (tx, groupKey, usage, knownMeta) {
218
+ const meta = knownMeta || normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
219
+ const keys = (await run('getAllKeys', [globalThis.IDBKeyRange?.only?.(groupKey) ?? groupKey], CHUNKS_STORE, 'byGroup', { tx })).result
220
+ for (const key of keys) await run('delete', [key], CHUNKS_STORE, null, { tx })
221
+ await run('delete', [groupKey], GROUPS_STORE, null, { tx })
222
+ if (usage && meta) usage.usedBytes = Math.max(0, usage.usedBytes - meta.byteSize)
223
+ return Boolean(meta || keys.length)
131
224
  }
132
225
 
133
- function removeGroupKey (groupKey) {
134
- writeGroupKeys(readGroupKeys().filter(key => key !== groupKey))
226
+ async function oldestGroupsInTransaction (tx, except = '') {
227
+ const values = (await run('getAll', [], GROUPS_STORE, null, { tx })).result
228
+ return values
229
+ .map(normalizeMeta)
230
+ .filter(meta => meta && meta.groupKey !== except)
231
+ .sort((left, right) => left.updatedAt - right.updatedAt || left.groupKey.localeCompare(right.groupKey))
135
232
  }
136
233
 
137
- function readMeta (groupKey) {
138
- return normalizeMeta(parseJson(storage().getItem(metaKey(groupKey)), null), groupKey)
139
- }
140
-
141
- function writeMeta (meta) {
142
- storage().setItem(metaKey(meta.groupKey), JSON.stringify(meta))
143
- }
144
-
145
- function removeGroup (groupKey) {
146
- const meta = readMeta(groupKey)
147
- if (meta) {
148
- for (const index of Object.keys(meta.received)) {
149
- storage().removeItem(chunkKey(groupKey, index))
234
+ async function cleanupStaleRaw (nowMs = Date.now()) {
235
+ const cutoff = nowMs - configuredTtlMs
236
+ return transaction([GROUPS_STORE, CHUNKS_STORE, STATE_STORE], 'readwrite', async tx => {
237
+ const usage = await readUsage(tx)
238
+ const groups = await oldestGroupsInTransaction(tx)
239
+ let removed = 0
240
+ for (const meta of groups) {
241
+ if (meta.updatedAt > cutoff && (!Number.isFinite(configuredMaxBytes) || usage.usedBytes <= configuredMaxBytes)) continue
242
+ await deleteGroupInTransaction(tx, meta.groupKey, usage, meta)
243
+ removed++
150
244
  }
151
- }
152
- storage().removeItem(metaKey(groupKey))
153
- removeGroupKey(groupKey)
245
+ await writeUsage(tx, usage)
246
+ return removed
247
+ })
154
248
  }
155
249
 
156
- function allMetas () {
157
- const keys = readGroupKeys()
158
- const metas = []
159
- const liveKeys = []
160
- for (const groupKey of keys) {
161
- const meta = readMeta(groupKey)
162
- if (!meta) continue
163
- metas.push(meta)
164
- liveKeys.push(groupKey)
165
- }
166
- if (liveKeys.length !== keys.length) writeGroupKeys(liveKeys)
167
- return metas
250
+ function ready () {
251
+ readyPromise ||= cleanupStaleRaw()
252
+ return readyPromise
168
253
  }
169
254
 
170
- function totalStoredBytes () {
171
- return allMetas().reduce((total, meta) => {
172
- return total + meta.byteSize + byteLength(JSON.stringify(meta))
173
- }, 0)
255
+ async function cleanupStale (nowMs = Date.now()) {
256
+ await ready()
257
+ return cleanupStaleRaw(nowMs)
174
258
  }
175
259
 
176
- function oldestGroupKey ({ except } = {}) {
177
- return allMetas()
178
- .filter(meta => meta.groupKey !== except)
179
- .sort((a, b) => a.updatedAt - b.updatedAt)[0]?.groupKey || ''
180
- }
260
+ async function putOnce ({ channelPubkey, routerPubkey, index, total, contentBytes }) {
261
+ const groupKey = groupKeyFor(channelPubkey, routerPubkey)
262
+ const bytes = normalizeBytes(contentBytes)
263
+ const now = Date.now()
181
264
 
182
- function evictOldestUntilFits (requiredBytes = 0, { except } = {}) {
183
- if (!Number.isFinite(configuredMaxBytes)) return
184
- while (totalStoredBytes() + requiredBytes > configuredMaxBytes) {
185
- const groupKey = oldestGroupKey({ except })
186
- if (!groupKey) break
187
- removeGroup(groupKey)
188
- }
189
- }
265
+ return transaction([GROUPS_STORE, CHUNKS_STORE, STATE_STORE], 'readwrite', async tx => {
266
+ const usage = await readUsage(tx)
267
+ let meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
268
+ const staleCutoff = now - configuredTtlMs
269
+ if (meta && meta.updatedAt <= staleCutoff) {
270
+ await deleteGroupInTransaction(tx, groupKey, usage, meta)
271
+ meta = null
272
+ }
273
+ if (meta && meta.total !== total) {
274
+ await deleteGroupInTransaction(tx, groupKey, usage, meta)
275
+ meta = null
276
+ }
277
+ if (!meta) {
278
+ meta = normalizeMeta({
279
+ groupKey,
280
+ channelPubkey,
281
+ routerPubkey,
282
+ total,
283
+ received: {},
284
+ receivedCount: 0,
285
+ nextIndex: 0,
286
+ rowIndex: 0,
287
+ carry: '',
288
+ payloadCiphertext: '',
289
+ receiverPubkeys: [],
290
+ byteSize: 0,
291
+ createdAt: now,
292
+ updatedAt: now
293
+ })
294
+ }
190
295
 
191
- function writeChunk (key, value, requiredBytes, except) {
192
- evictOldestUntilFits(requiredBytes, { except })
193
- try {
194
- storage().setItem(key, value)
195
- return
196
- } catch (err) {
197
- if (!isQuotaExceeded(err)) throw err
198
- }
296
+ const existing = (await run('get', [[groupKey, index]], CHUNKS_STORE, null, { tx })).result
297
+ if (!existing) {
298
+ if (bytes.byteLength > configuredMaxBytes || meta.byteSize + bytes.byteLength > configuredMaxBytes) {
299
+ await deleteGroupInTransaction(tx, groupKey, usage, meta)
300
+ await writeUsage(tx, usage)
301
+ return { tooLarge: true, meta: null }
302
+ }
199
303
 
200
- let groupKey = oldestGroupKey({ except })
201
- while (groupKey) {
202
- removeGroup(groupKey)
203
- try {
204
- storage().setItem(key, value)
205
- return
206
- } catch (err) {
207
- if (!isQuotaExceeded(err)) throw err
304
+ const requiredBytes = bytes.byteLength
305
+ const candidates = await oldestGroupsInTransaction(tx, groupKey)
306
+ for (const candidate of candidates) {
307
+ if (candidate.updatedAt > staleCutoff) continue
308
+ await deleteGroupInTransaction(tx, candidate.groupKey, usage, candidate)
309
+ }
310
+ for (const candidate of candidates) {
311
+ if (candidate.updatedAt <= staleCutoff) continue
312
+ if (!Number.isFinite(configuredMaxBytes) || usage.usedBytes + requiredBytes <= configuredMaxBytes) break
313
+ await deleteGroupInTransaction(tx, candidate.groupKey, usage, candidate)
314
+ }
315
+ if (Number.isFinite(configuredMaxBytes) && usage.usedBytes + requiredBytes > configuredMaxBytes) {
316
+ await deleteGroupInTransaction(tx, groupKey, usage, meta)
317
+ await writeUsage(tx, usage)
318
+ return { tooLarge: true, meta: null }
319
+ }
320
+
321
+ await run('put', [{ groupKey, index, bytes }], CHUNKS_STORE, null, { tx })
322
+ meta.received[String(index)] = true
323
+ meta.receivedCount++
324
+ meta.byteSize += requiredBytes
325
+ usage.usedBytes += requiredBytes
208
326
  }
209
- groupKey = oldestGroupKey({ except })
210
- }
211
327
 
212
- storage().setItem(key, value)
328
+ meta.total = total
329
+ meta.updatedAt = now
330
+ await run('put', [meta], GROUPS_STORE, null, { tx })
331
+ await writeUsage(tx, usage)
332
+ return { tooLarge: false, meta }
333
+ })
213
334
  }
214
335
 
215
- function cleanupStale (nowMs = Date.now()) {
216
- const cutoff = nowMs - configuredTtlMs
217
- for (const meta of allMetas()) {
218
- if (meta.updatedAt <= cutoff) removeGroup(meta.groupKey)
219
- }
220
- evictOldestUntilFits(0)
336
+ async function evictOldestGroup (except = '') {
337
+ return transaction([GROUPS_STORE, CHUNKS_STORE, STATE_STORE], 'readwrite', async tx => {
338
+ const usage = await readUsage(tx)
339
+ const oldest = (await oldestGroupsInTransaction(tx, except))[0]
340
+ if (!oldest) return false
341
+ await deleteGroupInTransaction(tx, oldest.groupKey, usage, oldest)
342
+ await writeUsage(tx, usage)
343
+ return true
344
+ })
221
345
  }
222
346
 
223
- function put ({ channelPubkey, routerPubkey, index, total, content }) {
347
+ async function put ({ channelPubkey, routerPubkey, index, total, contentBytes }) {
224
348
  if (!channelPubkey || !routerPubkey) throw new Error('RECEIVED_CHUNK_GROUP_REQUIRED')
225
349
  if (!Number.isSafeInteger(index) || !Number.isSafeInteger(total) || index < 0 || total < 1 || index >= total) {
226
350
  throw new Error('INVALID_RECEIVED_CHUNK_INDEX')
227
351
  }
228
-
229
- cleanupStale()
230
-
231
- const groupKey = groupKeyFor(channelPubkey, routerPubkey)
232
- const now = Date.now()
233
- const chunk = String(content || '')
234
- const nextBytes = byteLength(chunk)
235
- let meta = readMeta(groupKey)
236
-
237
- if (meta && meta.total !== total) {
238
- removeGroup(groupKey)
239
- meta = null
240
- }
241
-
242
- if (!meta) {
243
- meta = normalizeMeta({
244
- channelPubkey,
245
- routerPubkey,
246
- total,
247
- received: {},
248
- receivedCount: 0,
249
- nextIndex: 0,
250
- rowIndex: 0,
251
- carry: '',
252
- payloadCiphertext: '',
253
- receiverPubkeys: [],
254
- byteSize: 0,
255
- createdAt: now,
256
- updatedAt: now
257
- }, groupKey)
258
- addGroupKey(groupKey)
259
- writeMeta(meta)
260
- }
261
-
262
- if (!meta.received[String(index)]) {
263
- if (meta.byteSize + nextBytes > configuredMaxBytes) {
264
- removeGroup(groupKey)
265
- throw new Error('RECEIVED_CHUNK_GROUP_TOO_LARGE')
352
+ const bytes = normalizeBytes(contentBytes)
353
+ await ready()
354
+ while (true) {
355
+ try {
356
+ const result = await putOnce({ channelPubkey, routerPubkey, index, total, contentBytes: bytes })
357
+ if (result.tooLarge) throw new Error('RECEIVED_CHUNK_GROUP_TOO_LARGE')
358
+ return result.meta
359
+ } catch (err) {
360
+ if (!isQuotaExceeded(err) || !await evictOldestGroup(groupKeyFor(channelPubkey, routerPubkey))) throw err
266
361
  }
267
- writeChunk(chunkKey(groupKey, index), chunk, nextBytes, groupKey)
268
- meta.received[String(index)] = true
269
- meta.receivedCount++
270
- meta.byteSize += nextBytes
271
362
  }
363
+ }
272
364
 
273
- meta.total = total
274
- meta.updatedAt = now
275
- writeMeta(meta)
276
- evictOldestUntilFits(0, { except: groupKey })
277
- return meta
365
+ async function readMeta (groupKey) {
366
+ await ready()
367
+ return transaction([GROUPS_STORE], 'readonly', async tx => {
368
+ return normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
369
+ })
278
370
  }
279
371
 
280
- function status (metaOrGroupKey) {
281
- const meta = typeof metaOrGroupKey === 'string' ? readMeta(metaOrGroupKey) : metaOrGroupKey
372
+ async function status (metaOrGroupKey) {
373
+ const meta = typeof metaOrGroupKey === 'string' ? await readMeta(metaOrGroupKey) : normalizeMeta(metaOrGroupKey)
282
374
  if (!meta) return { received: 0, missing: [] }
283
-
284
375
  const missing = []
285
376
  let received = 0
286
377
  for (let index = 0; index < meta.total; index++) {
@@ -291,98 +382,174 @@ export function createReceivedChunkStore ({
291
382
  }
292
383
 
293
384
  function rememberReceiverPubkey (meta, pubkey) {
294
- if (!pubkey || meta.receiverPubkeys.includes(pubkey)) return
295
- meta.receiverPubkeys.push(pubkey)
385
+ if (pubkey && !meta.receiverPubkeys.includes(pubkey)) meta.receiverPubkeys.push(pubkey)
296
386
  }
297
387
 
298
388
  function rememberPayloadCiphertext (meta, ciphertext) {
299
389
  if (!meta.payloadCiphertext) meta.payloadCiphertext = ciphertext
300
390
  }
301
391
 
302
- async function drainAvailable (groupKey, { onLine } = {}) {
303
- const meta = readMeta(groupKey)
304
- if (!meta) return { complete: false, stopped: false, meta: null }
392
+ async function claimDrain (groupKey, token) {
393
+ while (true) {
394
+ const now = Date.now()
395
+ const result = await transaction([GROUPS_STORE], 'readwrite', async tx => {
396
+ const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
397
+ if (!meta) return { meta: null, waitMs: 0 }
398
+ if (meta.drainToken && meta.drainToken !== token && meta.drainUntil > now) {
399
+ return { meta: null, waitMs: Math.min(DRAIN_LEASE_MS, meta.drainUntil - now) }
400
+ }
401
+ meta.drainToken = token
402
+ meta.drainUntil = now + DRAIN_LEASE_MS
403
+ await run('put', [meta], GROUPS_STORE, null, { tx })
404
+ return { meta, waitMs: 0 }
405
+ })
406
+ if (!result.waitMs) return result.meta
407
+ await wait(result.waitMs)
408
+ }
409
+ }
305
410
 
306
- while (meta.nextIndex < meta.total) {
307
- const index = meta.nextIndex
308
- const raw = storage().getItem(chunkKey(groupKey, index))
309
- if (raw == null) break
411
+ async function readDrainChunk (groupKey, token) {
412
+ return transaction([GROUPS_STORE, CHUNKS_STORE], 'readonly', async tx => {
413
+ const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
414
+ if (!meta || meta.drainToken !== token) return { meta: null, bytes: null }
415
+ if (meta.nextIndex >= meta.total) return { meta, bytes: null }
416
+ const chunk = (await run('get', [[groupKey, meta.nextIndex]], CHUNKS_STORE, null, { tx })).result
417
+ return { meta, bytes: chunk ? normalizeBytes(chunk.bytes) : null }
418
+ })
419
+ }
420
+
421
+ async function commitDrainMeta (nextMeta, token) {
422
+ return transaction([GROUPS_STORE], 'readwrite', async tx => {
423
+ const current = normalizeMeta((await run('get', [nextMeta.groupKey], GROUPS_STORE, null, { tx })).result)
424
+ if (!current || current.drainToken !== token) return null
425
+ current.nextIndex = nextMeta.nextIndex
426
+ current.rowIndex = nextMeta.rowIndex
427
+ current.carry = nextMeta.carry
428
+ current.payloadCiphertext = nextMeta.payloadCiphertext
429
+ current.receiverPubkeys = uniq(nextMeta.receiverPubkeys)
430
+ current.updatedAt = nextMeta.updatedAt
431
+ current.drainUntil = Date.now() + DRAIN_LEASE_MS
432
+ await run('put', [current], GROUPS_STORE, null, { tx })
433
+ return current
434
+ })
435
+ }
310
436
 
311
- const text = `${meta.carry}${decoder.decode(base64ToBytes(raw))}`
312
- let start = 0
313
- let end = text.indexOf('\n', start)
437
+ async function releaseDrain (groupKey, token) {
438
+ return transaction([GROUPS_STORE], 'readwrite', async tx => {
439
+ const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
440
+ if (!meta || meta.drainToken !== token) return false
441
+ meta.drainToken = ''
442
+ meta.drainUntil = 0
443
+ await run('put', [meta], GROUPS_STORE, null, { tx })
444
+ return true
445
+ })
446
+ }
447
+
448
+ async function drainAvailable (groupKey, { onLine } = {}) {
449
+ await ready()
450
+ const token = randomToken()
451
+ let meta = await claimDrain(groupKey, token)
452
+ if (!meta) return { complete: false, stopped: false, meta: null }
453
+ try {
454
+ while (meta.nextIndex < meta.total) {
455
+ const snapshot = await readDrainChunk(groupKey, token)
456
+ meta = snapshot.meta
457
+ if (!meta || !snapshot.bytes) break
458
+
459
+ const text = `${meta.carry}${decoder.decode(snapshot.bytes)}`
460
+ let start = 0
461
+ let end = text.indexOf('\n', start)
462
+ while (end !== -1) {
463
+ const line = text.slice(start, end)
464
+ start = end + 1
465
+ if (line) {
466
+ const result = await onLine?.(line, meta.rowIndex, meta, { rememberPayloadCiphertext, rememberReceiverPubkey })
467
+ meta.rowIndex++
468
+ if (result?.stop) {
469
+ meta.updatedAt = Date.now()
470
+ meta = await commitDrainMeta(meta, token)
471
+ return { complete: false, stopped: true, meta }
472
+ }
473
+ }
474
+ end = text.indexOf('\n', start)
475
+ }
476
+ meta.carry = text.slice(start)
477
+ meta.nextIndex++
478
+ meta.updatedAt = Date.now()
479
+ meta = await commitDrainMeta(meta, token)
480
+ if (!meta) break
481
+ }
314
482
 
315
- while (end !== -1) {
316
- const line = text.slice(start, end)
317
- start = end + 1
318
- if (line) {
319
- const result = await onLine?.(line, meta.rowIndex, meta, { rememberPayloadCiphertext, rememberReceiverPubkey })
483
+ if (meta && meta.nextIndex >= meta.total) {
484
+ if (meta.carry) {
485
+ const result = await onLine?.(meta.carry, meta.rowIndex, meta, { rememberPayloadCiphertext, rememberReceiverPubkey })
320
486
  meta.rowIndex++
487
+ meta.carry = ''
321
488
  if (result?.stop) {
322
489
  meta.updatedAt = Date.now()
323
- writeMeta(meta)
490
+ meta = await commitDrainMeta(meta, token)
324
491
  return { complete: false, stopped: true, meta }
325
492
  }
326
493
  }
327
- end = text.indexOf('\n', start)
494
+ meta.updatedAt = Date.now()
495
+ meta = await commitDrainMeta(meta, token)
496
+ return { complete: Boolean(meta), stopped: false, meta }
328
497
  }
329
-
330
- meta.carry = text.slice(start)
331
-
332
- meta.nextIndex++
333
- meta.updatedAt = Date.now()
334
- writeMeta(meta)
498
+ return { complete: false, stopped: false, meta }
499
+ } finally {
500
+ await releaseDrain(groupKey, token).catch(() => {})
335
501
  }
502
+ }
336
503
 
337
- if (meta.nextIndex >= meta.total) {
338
- if (meta.carry) {
339
- const result = await onLine?.(meta.carry, meta.rowIndex, meta, { rememberPayloadCiphertext, rememberReceiverPubkey })
340
- meta.rowIndex++
341
- meta.carry = ''
342
- if (result?.stop) {
343
- meta.updatedAt = Date.now()
344
- writeMeta(meta)
345
- return { complete: false, stopped: true, meta }
346
- }
504
+ async function readChunkBytes (groupKey) {
505
+ await ready()
506
+ return transaction([GROUPS_STORE, CHUNKS_STORE], 'readonly', async tx => {
507
+ const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
508
+ if (!meta) return { meta: null, chunks: [] }
509
+ const chunks = []
510
+ for (let index = 0; index < meta.total; index++) {
511
+ const chunk = (await run('get', [[groupKey, index]], CHUNKS_STORE, null, { tx })).result
512
+ if (!chunk) throw new Error('RECEIVED_CHUNK_MISSING')
513
+ chunks.push(normalizeBytes(chunk.bytes))
347
514
  }
348
- meta.updatedAt = Date.now()
349
- writeMeta(meta)
350
- return { complete: true, stopped: false, meta }
351
- }
352
-
353
- return { complete: false, stopped: false, meta }
515
+ return { meta, chunks }
516
+ })
354
517
  }
355
518
 
356
- function readEnvelopeBundleContent (groupKey) {
357
- const meta = readMeta(groupKey)
358
- if (!meta) return ''
359
- const parts = []
360
- for (let index = 0; index < meta.total; index++) {
361
- const chunk = storage().getItem(chunkKey(groupKey, index))
362
- if (chunk == null) throw new Error('RECEIVED_CHUNK_MISSING')
363
- parts.push(chunk)
364
- }
365
- return joinBase64Chunks(parts)
519
+ async function readChunkContents (groupKey) {
520
+ return (await readChunkBytes(groupKey)).chunks.map(bytes => decoder.decode(bytes))
366
521
  }
367
522
 
368
- function readEnvelopeBundleText (groupKey) {
369
- const content = readEnvelopeBundleContent(groupKey)
370
- return decoder.decode(base64ToBytes(content))
523
+ async function readEnvelopeBundleContent (groupKey) {
524
+ const { chunks } = await readChunkBytes(groupKey)
525
+ return bytesToBase64(joinChunks(chunks))
371
526
  }
372
527
 
373
- function readChunkContents (groupKey) {
374
- const meta = readMeta(groupKey)
375
- if (!meta) return []
376
- const parts = []
377
- for (let index = 0; index < meta.total; index++) {
378
- const chunk = storage().getItem(chunkKey(groupKey, index))
379
- if (chunk == null) throw new Error('RECEIVED_CHUNK_MISSING')
380
- parts.push(chunk)
528
+ function joinChunks (chunks) {
529
+ let length = 0
530
+ for (const chunk of chunks) length += chunk.byteLength
531
+ const joined = new Uint8Array(length)
532
+ let offset = 0
533
+ for (const chunk of chunks) {
534
+ joined.set(chunk, offset)
535
+ offset += chunk.byteLength
381
536
  }
382
- return parts
537
+ return joined
383
538
  }
384
539
 
385
- cleanupStale()
540
+ async function readEnvelopeBundleText (groupKey) {
541
+ return decoder.decode(joinChunks((await readChunkBytes(groupKey)).chunks))
542
+ }
543
+
544
+ async function removeGroup (groupKey) {
545
+ await ready()
546
+ return transaction([GROUPS_STORE, CHUNKS_STORE, STATE_STORE], 'readwrite', async tx => {
547
+ const usage = await readUsage(tx)
548
+ const removed = await deleteGroupInTransaction(tx, groupKey, usage)
549
+ await writeUsage(tx, usage)
550
+ return removed
551
+ })
552
+ }
386
553
 
387
554
  return {
388
555
  cleanupStale,
@@ -301,7 +301,7 @@ function rebuildSubscriptions ({ _subscribe = privateChannel.subscribe, graceful
301
301
  modeByPubkey: modesForChannels(channelList),
302
302
  receivedChunkTtlMs: maxWatchNumber(channelList, 'receivedChunkTtlMs'),
303
303
  receivedChunkMaxBytes: maxWatchNumber(channelList, 'receivedChunkMaxBytes'),
304
- receivedChunkStorageArea: firstWatchValue(channelList, 'receivedChunkStorageArea'),
304
+ receivedChunkIndexedDB: firstWatchValue(channelList, 'receivedChunkIndexedDB'),
305
305
  ignoredGroupTtlMs: maxWatchNumber(channelList, 'ignoredGroupTtlMs'),
306
306
  ignoredGroupMaxEntries: maxWatchNumber(channelList, 'ignoredGroupMaxEntries'),
307
307
  limit: 0,
@@ -348,7 +348,7 @@ export async function watch ({
348
348
  onError,
349
349
  receivedChunkTtlMs,
350
350
  receivedChunkMaxBytes,
351
- receivedChunkStorageArea,
351
+ receivedChunkIndexedDB,
352
352
  ignoredGroupTtlMs,
353
353
  ignoredGroupMaxEntries,
354
354
  since = nowSeconds(),
@@ -372,7 +372,7 @@ export async function watch ({
372
372
  mode,
373
373
  receivedChunkTtlMs,
374
374
  receivedChunkMaxBytes,
375
- receivedChunkStorageArea,
375
+ receivedChunkIndexedDB,
376
376
  ignoredGroupTtlMs,
377
377
  ignoredGroupMaxEntries,
378
378
  callbacks,
@@ -388,7 +388,7 @@ export async function watch ({
388
388
  current.mode === next.mode &&
389
389
  current.receivedChunkTtlMs === next.receivedChunkTtlMs &&
390
390
  current.receivedChunkMaxBytes === next.receivedChunkMaxBytes &&
391
- current.receivedChunkStorageArea === next.receivedChunkStorageArea &&
391
+ current.receivedChunkIndexedDB === next.receivedChunkIndexedDB &&
392
392
  current.ignoredGroupTtlMs === next.ignoredGroupTtlMs &&
393
393
  current.ignoredGroupMaxEntries === next.ignoredGroupMaxEntries
394
394
  ) {
@@ -26,7 +26,7 @@
26
26
  // await messenger.clearChannel(channelPubkey)
27
27
  //
28
28
  // Missed-message recovery:
29
- // - Each watched channel stores lastSeenAt/lastWatchedAt in localStorage.
29
+ // - Each watched channel stores lastSeenAt/lastWatchedAt in IndexedDB.
30
30
  // - Re-watching after reload fetches the gap from lastSeenAt to now.
31
31
  // - Browser offline/online events add explicit offline ranges with a small skew.
32
32
  // - Ranges older than 7 days are ignored; channel state not watched for 45 days is pruned.
@@ -42,6 +42,7 @@ import { getRelaysByPubkey, pickRelaysForPubkeys, subscribeRelayListUpdates } fr
42
42
  import * as privateChannel from '../private-channel/index.js'
43
43
  import { cleanupTemporaryStorage as cleanupTemporaryChannelStorage } from '../temporary-storage/index.js'
44
44
  import { createQueue } from '../idb-queue/index.js'
45
+ import { createChannelStateStore } from './services/channel-state.js'
45
46
  import { DEFAULT_STALE_CHANNEL_SECONDS } from './constants/index.js'
46
47
  import {
47
48
  compactSeedNymCarriers,
@@ -121,14 +122,14 @@ function isPlainObject (value) {
121
122
  return value && typeof value === 'object' && !Array.isArray(value)
122
123
  }
123
124
 
124
- function storesRecoverySeeds (mode) {
125
- return mode === 'seeder' || mode === 'watchtower'
126
- }
127
-
128
125
  function parseJson (raw, fallback) {
129
126
  try { return JSON.parse(raw || '') } catch { return fallback }
130
127
  }
131
128
 
129
+ function storesRecoverySeeds (mode) {
130
+ return mode === 'seeder' || mode === 'watchtower'
131
+ }
132
+
132
133
  export class PrivateMessenger {
133
134
  static cleanupTemporaryStorage ({ storageArea = globalThis.sessionStorage } = {}) {
134
135
  cleanupTemporaryChannelStorage({ storageArea })
@@ -195,6 +196,9 @@ export class PrivateMessenger {
195
196
  this.prefix = ''
196
197
  this.queue = null
197
198
  this.seedQueue = null
199
+ this.stateStore = null
200
+ this.state = { channels: {} }
201
+ this.stateWriteTail = Promise.resolve()
198
202
  this.channels = new Map()
199
203
  this.stopByChannel = new Map()
200
204
  this.presenceTimers = new Map()
@@ -229,6 +233,11 @@ export class PrivateMessenger {
229
233
  evictionPolicy: 'fifo',
230
234
  indexedDB: this._indexedDB
231
235
  })
236
+ this.stateStore = await createChannelStateStore({
237
+ prefix: this.prefix,
238
+ indexedDB: this._indexedDB
239
+ })
240
+ this.state = { channels: await this.stateStore.load() }
232
241
  await this.cleanupStaleChannels()
233
242
  await this.update({ userSigner, contentKeySigner, nymSigner: this.nymSigner, channels, relays, mode })
234
243
  return this
@@ -372,18 +381,25 @@ export class PrivateMessenger {
372
381
  return { relayToReceivers: derived, recoveryRelays }
373
382
  }
374
383
 
375
- stateKey () {
376
- return `${this.prefix}:state`
377
- }
378
-
379
384
  readState () {
380
- const state = parseJson(localStorage.getItem(this.stateKey()), { channels: {} })
381
- if (!isPlainObject(state.channels)) state.channels = {}
382
- return state
385
+ return structuredClone(this.state)
383
386
  }
384
387
 
385
388
  writeState (state) {
386
- localStorage.setItem(this.stateKey(), JSON.stringify(state))
389
+ const next = {
390
+ channels: isPlainObject(state?.channels) ? structuredClone(state.channels) : {}
391
+ }
392
+ this.state = next
393
+ const snapshot = structuredClone(next.channels)
394
+ const write = this.stateWriteTail.then(() => this.stateStore.replace(snapshot))
395
+ this.stateWriteTail = write.catch(err => {
396
+ try { this.onError?.(err) } catch {}
397
+ })
398
+ return write
399
+ }
400
+
401
+ async flushStateWrites () {
402
+ await this.stateWriteTail
387
403
  }
388
404
 
389
405
  updateChannelState (pubkey, patch) {
@@ -589,6 +605,7 @@ export class PrivateMessenger {
589
605
  onSeed: seed => this.queueIncoming(() => this.enqueueSeed(pubkey, seed)),
590
606
  onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
591
607
  receivedChunkTtlMs: this.offlineRecoverySeconds * 1000,
608
+ receivedChunkIndexedDB: this._indexedDB,
592
609
  onError: err => this.onError?.(err)
593
610
  })
594
611
  this.stopByChannel.set(pubkey, stop)
@@ -1272,6 +1289,7 @@ export class PrivateMessenger {
1272
1289
  mode: channel.mode,
1273
1290
  modeByPubkey: { [pubkey]: channel.mode },
1274
1291
  receivedChunkTtlMs: this.offlineRecoverySeconds * 1000,
1292
+ receivedChunkIndexedDB: this._indexedDB,
1275
1293
  onEvent: (event, outer, meta) => this.queueIncoming(() => this.enqueueRumor(eventType(event), pubkey, { event, outer, meta, payload: parseEventContent(event) })),
1276
1294
  onNymEvent: (event, outer, meta) => this.queueIncoming(() => this.enqueueRumor('nym', pubkey, { event, outer, meta, payload: parseEventContent(event) })),
1277
1295
  onSeedEvent: seed => this.queueIncoming(() => this.enqueueSeed(pubkey, seed)),
@@ -1299,6 +1317,7 @@ export class PrivateMessenger {
1299
1317
  this._privateMessage.clearChannelState?.(pubkey)
1300
1318
  this.channels.delete(pubkey)
1301
1319
  this.removeChannelState(pubkey)
1320
+ await this.flushStateWrites()
1302
1321
  await this.queue.removeBy('byChannel', pubkey)
1303
1322
  await this.seedQueue.removeBy('byChannel', pubkey)
1304
1323
  this.ensureRelayListWatcher()
@@ -1321,6 +1340,7 @@ export class PrivateMessenger {
1321
1340
  await this.seedQueue?.removeBy('byChannel', pubkey)
1322
1341
  }
1323
1342
  this.writeState(state)
1343
+ await this.flushStateWrites()
1324
1344
  })
1325
1345
  }
1326
1346
 
@@ -0,0 +1,103 @@
1
+ import { run } from '../../idb/index.js'
2
+
3
+ const DATABASE_VERSION = 1
4
+ const CHANNELS_STORE = 'channels'
5
+
6
+ /*
7
+ IndexedDB schema, scoped per private-messenger prefix:
8
+
9
+ database `${prefix}:state:idb`, version 1
10
+
11
+ channels, keyPath "pubkey"
12
+ pubkey watched channel public key
13
+ value evolving channel-recovery state, including last-seen/watched times,
14
+ mode, relays, seeders, offline ranges, active offline-range start,
15
+ per-seeder activity, and sent/received content-key usage
16
+
17
+ The complete channels snapshot is replaced atomically on each persisted state
18
+ change; value remains an extensible internal object rather than a public schema.
19
+ */
20
+
21
+ function deferred () {
22
+ let resolve
23
+ let reject
24
+ const promise = new Promise((nextResolve, nextReject) => {
25
+ resolve = nextResolve
26
+ reject = nextReject
27
+ })
28
+ return { promise, resolve, reject }
29
+ }
30
+
31
+ function transactionDone (tx) {
32
+ const pending = deferred()
33
+ tx.oncomplete = () => pending.resolve()
34
+ tx.onabort = () => pending.reject(tx.error || new Error('IDB_TRANSACTION_ABORTED'))
35
+ tx.onerror = () => pending.reject(tx.error || new Error('IDB_TRANSACTION_FAILED'))
36
+ return pending.promise
37
+ }
38
+
39
+ function openDatabase (indexedDB, name) {
40
+ if (!indexedDB?.open) return Promise.reject(new Error('IDB_UNAVAILABLE'))
41
+ return new Promise((resolve, reject) => {
42
+ const request = indexedDB.open(name, DATABASE_VERSION)
43
+ request.onerror = () => reject(request.error || new Error('IDB_OPEN_FAILED'))
44
+ request.onblocked = () => reject(new Error('IDB_DATABASE_BLOCKED'))
45
+ request.onupgradeneeded = () => {
46
+ const db = request.result
47
+ if (!db.objectStoreNames.contains(CHANNELS_STORE)) {
48
+ db.createObjectStore(CHANNELS_STORE, { keyPath: 'pubkey' })
49
+ }
50
+ }
51
+ request.onsuccess = () => {
52
+ const db = request.result
53
+ db.onversionchange = () => db.close()
54
+ resolve(db)
55
+ }
56
+ })
57
+ }
58
+
59
+ async function transaction (db, mode, work) {
60
+ const tx = db.transaction([CHANNELS_STORE], mode)
61
+ // Attach completion handlers before issuing the first request. The callback
62
+ // may await only requests belonging to this transaction.
63
+ const done = transactionDone(tx)
64
+ try {
65
+ const result = await work(tx)
66
+ await done
67
+ return result
68
+ } catch (err) {
69
+ try { tx.abort() } catch {}
70
+ try { await done } catch {}
71
+ throw err
72
+ }
73
+ }
74
+
75
+ function cloneChannels (channels) {
76
+ return structuredClone(channels || {})
77
+ }
78
+
79
+ export async function createChannelStateStore ({ prefix, indexedDB = globalThis.indexedDB } = {}) {
80
+ if (!prefix) throw new Error('PRIVATE_MESSENGER_STATE_PREFIX_REQUIRED')
81
+ const db = await openDatabase(indexedDB, `${prefix}:state:idb`)
82
+
83
+ async function load () {
84
+ return transaction(db, 'readonly', async tx => {
85
+ const records = (await run('getAll', [], CHANNELS_STORE, null, { tx })).result
86
+ return Object.fromEntries(records
87
+ .filter(record => typeof record?.pubkey === 'string' && record.pubkey)
88
+ .map(({ pubkey, value }) => [pubkey, value && typeof value === 'object' ? value : {}]))
89
+ })
90
+ }
91
+
92
+ async function replace (channels) {
93
+ const snapshot = cloneChannels(channels)
94
+ await transaction(db, 'readwrite', async tx => {
95
+ await run('clear', [], CHANNELS_STORE, null, { tx })
96
+ for (const [pubkey, value] of Object.entries(snapshot)) {
97
+ await run('put', [{ pubkey, value }], CHANNELS_STORE, null, { tx })
98
+ }
99
+ })
100
+ }
101
+
102
+ return { load, replace }
103
+ }