libp2r2p 0.6.0 → 0.7.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
@@ -20,10 +20,12 @@ import { createPrivateMessenger } from 'libp2r2p/private-messenger'
20
20
  const messenger = await createPrivateMessenger({
21
21
  userSigner,
22
22
  contentKeySigner,
23
+ offlineRecoverySeconds: 7 * 24 * 60 * 60,
23
24
  channels: [{
24
25
  signer: privateChannelSigner,
25
26
  relays: ['wss://relay.example'],
26
- mode: 'leecher'
27
+ mode: 'leecher',
28
+ offlineRecoverySeconds: 30 * 24 * 60 * 60
27
29
  }],
28
30
  onError: err => console.warn('private messenger failed', err)
29
31
  })
@@ -126,45 +128,80 @@ one `['k', '3560']` tag, explicit matching `e` targets, and no `a` tags. A
126
128
  regular NIP-09 kind `5` request signed by the outer event's private-channel key
127
129
  must not delete a kind `3560` event, whether or not that event has an `s` tag.
128
130
 
129
- ### Temporary Send Storage
131
+ ### Storage Maintenance
130
132
 
131
133
  While an outgoing private message is being assembled, the messenger keeps
132
134
  encrypted envelope rows and router chunks in `sessionStorage`. They are
133
135
  removed when the send finishes, but an interrupted browser operation can leave
134
136
  them behind until cleanup runs.
135
137
 
136
- `PrivateMessenger.init()` performs cleanup automatically. Call
137
- `PrivateMessenger.cleanupTemporaryStorage()` once during app startup when
138
- messenger initialization may be delayed, such as while an account is locked:
138
+ `PrivateMessenger.init()` awaits storage maintenance automatically. Call
139
+ `PrivateMessenger.maintainStorage()` during app startup when messenger
140
+ initialization may be delayed, such as while an account is locked:
139
141
 
140
142
  ```js
141
143
  import { PrivateMessenger } from 'libp2r2p/private-messenger'
142
144
 
143
- PrivateMessenger.cleanupTemporaryStorage()
145
+ PrivateMessenger.maintainStorage().catch(console.warn)
144
146
  ```
145
147
 
146
- Call it before any private-message send using that storage area starts. It
147
- does not clear persisted messages, recovery material, or channel state. Pass
148
- `temporaryStorageArea: localStorage` when constructing a messenger to opt into
149
- a different Storage area.
148
+ Maintenance removes interrupted-send staging, expired receive chunks, and
149
+ storage belonging to inactive principal identities. It also
150
+ resumes any interrupted database-set deletion. An application does not need to
151
+ know database names or enumerate IndexedDB. Pass `temporaryStorageArea` only
152
+ when the messenger was configured to use a Storage area other than the default
153
+ `sessionStorage`.
154
+
155
+ Each principal signer owns internal message, recovery-seed, and channel-state
156
+ databases. The messenger updates their activity lease while it is open and
157
+ closes all handles in `await messenger.close()`. The complete set is removed
158
+ after 60 days without use, including messages that were never consumed.
159
+ Maintenance runs on every initialization and every six hours while a messenger
160
+ is active; failed deletions remain journaled and retry automatically.
161
+
162
+ Channel recovery state inside an otherwise active identity retains its separate
163
+ 45-day stale-channel cleanup policy. Offline recovery defaults to seven days.
164
+ Set `offlineRecoverySeconds` on an individual channel to override the
165
+ messenger default for its recovery seeds, offline ranges, new outer-event
166
+ expiration, and new incomplete receive groups. Updating a channel applies a
167
+ shorter window immediately to its stored seeds and ranges; increasing it does
168
+ not recreate data already removed.
169
+
170
+ Set a channel's `offlineRecoverySeconds` to `0` to disable durable recovery for
171
+ that channel. The messenger then stores no recovery seeds, tracks no offline
172
+ ranges, contacts no seeders, publishes no seeder presence, and uses no recovery
173
+ mirror relays. Live delivery remains usable: new outer events retain the
174
+ private-channel two-day technical expiration and incomplete receive groups use
175
+ the one-hour technical TTL. Existing signed events and receive groups retain
176
+ the deadlines chosen when they were created.
150
177
 
151
178
  Recovery metadata is separate from that temporary send staging. Per-channel
152
179
  `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
180
+ incomplete receive chunks are also stored in IndexedDB and share a 16 MiB
181
+ logical budget. Direct private-channel calls give each new group a one-hour
182
+ TTL; PrivateMessenger groups use the effective recovery window of their
183
+ channel, or one hour when durable recovery is disabled. The TTL is persisted
184
+ per group, so another caller opening the shared database or a later channel
185
+ configuration update cannot change it. Capacity
186
+ eviction removes whole
155
187
  least-recently-used message groups so a partial group is never mistaken for a
156
188
  complete one. `receivedChunkTtlMs`, `receivedChunkMaxBytes`, and
157
189
  `receivedChunkIndexedDB` may be supplied to the private-channel APIs when an
158
190
  embedding environment needs different limits or an injected IDB factory.
159
191
  Legacy Web Storage recovery records are neither read nor migrated.
160
192
 
193
+ The recovery-seed queue has a shared 64 MiB logical budget by default and uses
194
+ FIFO eviction. A channel recovery duration is therefore a maximum retention
195
+ window, not a guarantee that every seed remains available until its deadline.
196
+
161
197
  Signers are expected to expose the Nostr-style methods used by the messenger,
162
198
  including `getPublicKey()`, `signEvent(event)`, and the NIP-44 v3 methods
163
199
  needed by private channels. For double-DH content-key use, pass a
164
200
  `contentKeySigner` or a signer implementation that handles content keys
165
201
  internally.
166
202
 
167
- Messages are stored in a bounded, durable IndexedDB queue until consumed:
203
+ Messages are stored in a bounded, durable IndexedDB queue until consumed or
204
+ until the principal identity has been inactive for 60 days:
168
205
 
169
206
  ```js
170
207
  async function handleMessages () {
@@ -274,6 +274,10 @@ export async function createQueue ({
274
274
  const waiters = new Set()
275
275
  let sessionMaxBytes = configuredMaxBytes
276
276
  let revision = 0
277
+ let closed = false
278
+ let activeTransactions = 0
279
+ let closePromise = null
280
+ const closeWaiters = new Set()
277
281
 
278
282
  function hasByteLimit () {
279
283
  return Number.isFinite(sessionMaxBytes)
@@ -317,17 +321,27 @@ export async function createQueue ({
317
321
  }
318
322
 
319
323
  async function transaction (mode, work) {
320
- const tx = db.transaction([ITEMS_STORE, STATE_STORE], mode)
321
- const done = transactionDone(tx)
324
+ if (closed) throw new Error('QUEUE_CLOSED')
325
+ activeTransactions++
322
326
  try {
323
- // Keep work limited to IndexedDB requests so the transaction stays active.
324
- const value = await work(tx)
325
- await done
326
- return value
327
- } catch (err) {
328
- try { tx.abort() } catch {}
329
- try { await done } catch {}
330
- throw err
327
+ const tx = db.transaction([ITEMS_STORE, STATE_STORE], mode)
328
+ const done = transactionDone(tx)
329
+ try {
330
+ // Keep work limited to IndexedDB requests so the transaction stays active.
331
+ const value = await work(tx)
332
+ await done
333
+ return value
334
+ } catch (err) {
335
+ try { tx.abort() } catch {}
336
+ try { await done } catch {}
337
+ throw err
338
+ }
339
+ } finally {
340
+ activeTransactions--
341
+ if (!activeTransactions) {
342
+ for (const resolve of closeWaiters) resolve()
343
+ closeWaiters.clear()
344
+ }
331
345
  }
332
346
  }
333
347
 
@@ -771,6 +785,17 @@ export async function createQueue ({
771
785
  for (const record of (await snapshotStoredItems()).reverse()) yield record.item
772
786
  }
773
787
 
788
+ function close () {
789
+ if (closePromise) return closePromise
790
+ closed = true
791
+ wake()
792
+ closePromise = (activeTransactions
793
+ ? new Promise(resolve => closeWaiters.add(resolve))
794
+ : Promise.resolve())
795
+ .then(() => { db.close() })
796
+ return closePromise
797
+ }
798
+
774
799
  async function * storedItemsBy (indexName, query, { direction = 'next' } = {}) {
775
800
  direction = validDirection(direction)
776
801
  const records = await snapshot(async tx => {
@@ -811,6 +836,7 @@ export async function createQueue ({
811
836
  getBy,
812
837
  someBy,
813
838
  removeBy,
814
- storedItemsBy
839
+ storedItemsBy,
840
+ close
815
841
  }
816
842
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -566,8 +566,8 @@ async function decryptRouter ({ content, channelPubkey, channelSigner, channelRe
566
566
 
567
567
  function readValueFromMap (map, key) {
568
568
  if (!map || !key) return null
569
- if (map instanceof Map) return map.get(key) || null
570
- return map[key] || null
569
+ if (map instanceof Map) return map.get(key) ?? null
570
+ return map[key] ?? null
571
571
  }
572
572
 
573
573
  function privateChannelPubkeyList ({ privateChannelPubkey, privateChannelPubkeys }) {
@@ -669,6 +669,7 @@ function createProcessor ({
669
669
  onContentKeyUsage,
670
670
  onError,
671
671
  receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS,
672
+ receivedChunkTtlMsByPubkey,
672
673
  receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES,
673
674
  receivedChunkIndexedDB = globalThis.indexedDB,
674
675
  ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS,
@@ -683,8 +684,11 @@ function createProcessor ({
683
684
  ttlMs: ignoredGroupTtlMs,
684
685
  maxEntries: ignoredGroupMaxEntries
685
686
  })
687
+ const storeReady = receivedChunks.ready()
688
+ storeReady.catch(() => {})
686
689
 
687
- return async function processOuterEvent (outer) {
690
+ async function processOuterEvent (outer) {
691
+ await storeReady
688
692
  let groupKey = ''
689
693
  try {
690
694
  const channelPubkey = outer.pubkey || await privateChannelSigner?.getPublicKey?.()
@@ -692,6 +696,7 @@ function createProcessor ({
692
696
  const channelReaderSigner = readSignerFromMap(privateChannelReaderSignersByPubkey, channelPubkey) || privateChannelReaderSigner || channelSigner
693
697
  const channelReaderPubkey = readValueFromMap(privateChannelReaderPubkeysByPubkey, channelPubkey) || privateChannelReaderPubkey || channelPubkey
694
698
  const channelMode = readValueFromMap(modeByPubkey, channelPubkey) || mode
699
+ const channelReceivedChunkTtlMs = readValueFromMap(receivedChunkTtlMsByPubkey, channelPubkey) ?? receivedChunkTtlMs
695
700
  if (!channelPubkey) throw new Error('PRIVATE_CHANNEL_PUBKEY_REQUIRED')
696
701
 
697
702
  const decrypted = await decryptRouter({
@@ -712,7 +717,8 @@ function createProcessor ({
712
717
  routerPubkey: nymCarrierGroupId(carrier),
713
718
  index,
714
719
  total,
715
- contentBytes: encoder.encode(JSON.stringify(carrier))
720
+ contentBytes: encoder.encode(JSON.stringify(carrier)),
721
+ ttlMs: channelReceivedChunkTtlMs
716
722
  })
717
723
  const status = await receivedChunks.status(meta)
718
724
  onChunk?.({
@@ -762,7 +768,8 @@ function createProcessor ({
762
768
  routerPubkey: router.pubkey,
763
769
  index,
764
770
  total,
765
- contentBytes: base64ToBytes(router.content)
771
+ contentBytes: base64ToBytes(router.content),
772
+ ttlMs: channelReceivedChunkTtlMs
766
773
  })
767
774
  const status = await receivedChunks.status(meta)
768
775
  onChunk?.({
@@ -857,6 +864,9 @@ function createProcessor ({
857
864
  onError?.(err)
858
865
  }
859
866
  }
867
+
868
+ processOuterEvent.close = () => receivedChunks.close()
869
+ return processOuterEvent
860
870
  }
861
871
 
862
872
  function shouldIgnoreGroupError (err) {
@@ -882,7 +892,7 @@ function shouldIgnoreGroupError (err) {
882
892
  ].includes(err?.message)
883
893
  }
884
894
 
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 }) {
895
+ 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 }) {
886
896
  if (!relays?.length) throw new Error('NO_RELAYS')
887
897
  const authors = privateChannelPubkeyList({ privateChannelPubkey, privateChannelPubkeys })
888
898
  const filter = { kinds: [PRIVATE_BROADCAST_KIND] }
@@ -896,12 +906,16 @@ export async function fetch ({ receiverSigner, iykcSigner, privateChannelSigner
896
906
  timeoutAfterFirstEose: null
897
907
  })
898
908
  events.sort((a, b) => a.created_at - b.created_at)
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 })
900
- for (const event of events) await processOuterEvent(event)
901
- return events
909
+ 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 })
910
+ try {
911
+ for (const event of events) await processOuterEvent(event)
912
+ return events
913
+ } finally {
914
+ processOuterEvent.close()
915
+ }
902
916
  }
903
917
 
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 }) {
918
+ 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 }) {
905
919
  if (!relays?.length) throw new Error('NO_RELAYS')
906
920
  if (receiverSigner && !receiverSigner?.nip44DecryptDoubleDH && !receiverSigner?.nip44v3Decrypt) throw new Error('RECEIVER_SIGNER_NIP44V3_DECRYPT_UNSUPPORTED')
907
921
  if (!privateChannelReaderSigner && !privateChannelReaderSignersByPubkey && !privateChannelSigner && !privateChannelSignersByPubkey) throw new Error('PRIVATE_CHANNEL_READER_REQUIRED')
@@ -910,17 +924,17 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
910
924
  const filter = { kinds: [PRIVATE_BROADCAST_KIND], since }
911
925
  if (authors.length) filter.authors = authors
912
926
  if (limit != null) filter.limit = limit
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 })
927
+ 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 })
914
928
  const controller = new AbortController()
915
929
  const events = liveOnly
916
930
  ? _liveEventsGenerator(filter, relays, {
917
- signal: controller.signal
918
- })
931
+ signal: controller.signal
932
+ })
919
933
  : _eventsFeedGenerator(filter, relays, {
920
- signal: controller.signal,
921
- timeout: 5000,
922
- timeoutAfterFirstEose: null
923
- })
934
+ signal: controller.signal,
935
+ timeout: 5000,
936
+ timeoutAfterFirstEose: null
937
+ })
924
938
 
925
939
  async function consumeEvents () {
926
940
  try {
@@ -930,14 +944,17 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
930
944
  }
931
945
  } catch (error) {
932
946
  if (!controller.signal.aborted && error?.message !== 'Aborted') onError?.(error)
947
+ } finally {
948
+ processOuterEvent.close()
933
949
  }
934
950
  }
935
951
 
936
- consumeEvents()
952
+ const consumePromise = consumeEvents()
937
953
 
938
954
  return {
939
955
  close () {
940
956
  controller.abort()
957
+ return consumePromise
941
958
  }
942
959
  }
943
960
  }
@@ -5,7 +5,7 @@ export const DEFAULT_RECEIVED_CHUNK_TTL_MS = 60 * 60 * 1000 // 1 hour
5
5
  export const DEFAULT_RECEIVED_CHUNK_MAX_BYTES = 16 * 1024 * 1024 // 16 MiB
6
6
 
7
7
  const DEFAULT_PREFIX = 'libp2r2p:private-channel:received'
8
- const DATABASE_VERSION = 1
8
+ const DATABASE_VERSION = 2
9
9
  const GROUPS_STORE = 'groups'
10
10
  const CHUNKS_STORE = 'chunks'
11
11
  const STATE_STORE = 'state'
@@ -16,7 +16,7 @@ const decoder = new TextDecoder()
16
16
  /*
17
17
  IndexedDB schema, scoped per received-chunk prefix:
18
18
 
19
- database `${prefix}:idb`, version 1
19
+ database `${prefix}:idb`, version 2
20
20
 
21
21
  groups, keyPath "groupKey"
22
22
  groupKey `${channelPubkey}:${routerPubkey}`
@@ -28,10 +28,12 @@ groups, keyPath "groupKey"
28
28
  receiverPubkeys deduplicated intended receivers
29
29
  byteSize logical bytes charged to this group
30
30
  createdAt/updatedAt lifecycle timestamps in milliseconds
31
+ ttlMs/expiresAt group-specific retention and absolute expiry
31
32
  drainToken/drainUntil optional drain lease owner and expiry
32
33
 
33
34
  groups indexes
34
35
  byUpdatedAt updatedAt
36
+ byExpiresAt expiresAt
35
37
 
36
38
  chunks, keyPath ["groupKey", "index"]
37
39
  groupKey owning group coordinate
@@ -80,6 +82,7 @@ function openDatabase (indexedDB, name) {
80
82
  groups = tx.objectStore(GROUPS_STORE)
81
83
  }
82
84
  if (!groups.indexNames.contains('byUpdatedAt')) groups.createIndex('byUpdatedAt', 'updatedAt')
85
+ if (!groups.indexNames.contains('byExpiresAt')) groups.createIndex('byExpiresAt', 'expiresAt')
83
86
 
84
87
  let chunks
85
88
  if (!db.objectStoreNames.contains(CHUNKS_STORE)) {
@@ -119,12 +122,14 @@ function normalizeReceived (received) {
119
122
  )
120
123
  }
121
124
 
122
- function normalizeMeta (meta) {
125
+ function normalizeMeta (meta, fallbackTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS) {
123
126
  if (!meta || typeof meta !== 'object') return null
124
127
  const total = Number(meta.total)
125
128
  const nextIndex = Number(meta.nextIndex)
126
129
  const rowIndex = Number(meta.rowIndex)
127
130
  if (!meta.groupKey || !Number.isSafeInteger(total) || total < 1) return null
131
+ const updatedAt = Number(meta.updatedAt) || Date.now()
132
+ const ttlMs = Number.isFinite(meta.ttlMs) && meta.ttlMs >= 0 ? meta.ttlMs : fallbackTtlMs
128
133
  return {
129
134
  groupKey: String(meta.groupKey),
130
135
  channelPubkey: String(meta.channelPubkey || ''),
@@ -139,7 +144,9 @@ function normalizeMeta (meta) {
139
144
  receiverPubkeys: uniq(meta.receiverPubkeys),
140
145
  byteSize: Math.max(0, Number(meta.byteSize) || 0),
141
146
  createdAt: Number(meta.createdAt) || Date.now(),
142
- updatedAt: Number(meta.updatedAt) || Date.now(),
147
+ updatedAt,
148
+ ttlMs,
149
+ expiresAt: Number(meta.expiresAt) || updatedAt + ttlMs,
143
150
  drainToken: typeof meta.drainToken === 'string' ? meta.drainToken : '',
144
151
  drainUntil: Math.max(0, Number(meta.drainUntil) || 0)
145
152
  }
@@ -180,10 +187,18 @@ export function createReceivedChunkStore ({
180
187
  const configuredMaxBytes = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : Infinity
181
188
  let dbPromise
182
189
  let readyPromise
190
+ let closed = false
183
191
 
184
192
  function database () {
193
+ if (closed) return Promise.reject(new Error('RECEIVED_CHUNK_STORE_CLOSED'))
185
194
  dbPromise ||= openDatabase(indexedDB, `${prefix}:idb`)
186
- return dbPromise
195
+ return dbPromise.then(db => {
196
+ if (closed) {
197
+ db.close()
198
+ throw new Error('RECEIVED_CHUNK_STORE_CLOSED')
199
+ }
200
+ return db
201
+ })
187
202
  }
188
203
 
189
204
  async function transaction (storeNames, mode, work) {
@@ -215,7 +230,7 @@ export function createReceivedChunkStore ({
215
230
  }
216
231
 
217
232
  async function deleteGroupInTransaction (tx, groupKey, usage, knownMeta) {
218
- const meta = knownMeta || normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
233
+ const meta = knownMeta || normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
219
234
  const keys = (await run('getAllKeys', [globalThis.IDBKeyRange?.only?.(groupKey) ?? groupKey], CHUNKS_STORE, 'byGroup', { tx })).result
220
235
  for (const key of keys) await run('delete', [key], CHUNKS_STORE, null, { tx })
221
236
  await run('delete', [groupKey], GROUPS_STORE, null, { tx })
@@ -226,19 +241,18 @@ export function createReceivedChunkStore ({
226
241
  async function oldestGroupsInTransaction (tx, except = '') {
227
242
  const values = (await run('getAll', [], GROUPS_STORE, null, { tx })).result
228
243
  return values
229
- .map(normalizeMeta)
244
+ .map(value => normalizeMeta(value, configuredTtlMs))
230
245
  .filter(meta => meta && meta.groupKey !== except)
231
246
  .sort((left, right) => left.updatedAt - right.updatedAt || left.groupKey.localeCompare(right.groupKey))
232
247
  }
233
248
 
234
249
  async function cleanupStaleRaw (nowMs = Date.now()) {
235
- const cutoff = nowMs - configuredTtlMs
236
250
  return transaction([GROUPS_STORE, CHUNKS_STORE, STATE_STORE], 'readwrite', async tx => {
237
251
  const usage = await readUsage(tx)
238
252
  const groups = await oldestGroupsInTransaction(tx)
239
253
  let removed = 0
240
254
  for (const meta of groups) {
241
- if (meta.updatedAt > cutoff && (!Number.isFinite(configuredMaxBytes) || usage.usedBytes <= configuredMaxBytes)) continue
255
+ if (meta.expiresAt > nowMs && (!Number.isFinite(configuredMaxBytes) || usage.usedBytes <= configuredMaxBytes)) continue
242
256
  await deleteGroupInTransaction(tx, meta.groupKey, usage, meta)
243
257
  removed++
244
258
  }
@@ -247,26 +261,26 @@ export function createReceivedChunkStore ({
247
261
  })
248
262
  }
249
263
 
250
- function ready () {
251
- readyPromise ||= cleanupStaleRaw()
264
+ function ready (nowMs = Date.now()) {
265
+ readyPromise ||= cleanupStaleRaw(nowMs)
252
266
  return readyPromise
253
267
  }
254
268
 
255
269
  async function cleanupStale (nowMs = Date.now()) {
256
- await ready()
270
+ await ready(nowMs)
257
271
  return cleanupStaleRaw(nowMs)
258
272
  }
259
273
 
260
- async function putOnce ({ channelPubkey, routerPubkey, index, total, contentBytes }) {
274
+ async function putOnce ({ channelPubkey, routerPubkey, index, total, contentBytes, ttlMs }) {
261
275
  const groupKey = groupKeyFor(channelPubkey, routerPubkey)
262
276
  const bytes = normalizeBytes(contentBytes)
263
277
  const now = Date.now()
278
+ const groupTtlMs = Number.isFinite(ttlMs) && ttlMs >= 0 ? ttlMs : configuredTtlMs
264
279
 
265
280
  return transaction([GROUPS_STORE, CHUNKS_STORE, STATE_STORE], 'readwrite', async tx => {
266
281
  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) {
282
+ let meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
283
+ if (meta && meta.expiresAt <= now) {
270
284
  await deleteGroupInTransaction(tx, groupKey, usage, meta)
271
285
  meta = null
272
286
  }
@@ -289,8 +303,10 @@ export function createReceivedChunkStore ({
289
303
  receiverPubkeys: [],
290
304
  byteSize: 0,
291
305
  createdAt: now,
292
- updatedAt: now
293
- })
306
+ updatedAt: now,
307
+ ttlMs: groupTtlMs,
308
+ expiresAt: now + groupTtlMs
309
+ }, configuredTtlMs)
294
310
  }
295
311
 
296
312
  const existing = (await run('get', [[groupKey, index]], CHUNKS_STORE, null, { tx })).result
@@ -304,11 +320,11 @@ export function createReceivedChunkStore ({
304
320
  const requiredBytes = bytes.byteLength
305
321
  const candidates = await oldestGroupsInTransaction(tx, groupKey)
306
322
  for (const candidate of candidates) {
307
- if (candidate.updatedAt > staleCutoff) continue
323
+ if (candidate.expiresAt > now) continue
308
324
  await deleteGroupInTransaction(tx, candidate.groupKey, usage, candidate)
309
325
  }
310
326
  for (const candidate of candidates) {
311
- if (candidate.updatedAt <= staleCutoff) continue
327
+ if (candidate.expiresAt <= now) continue
312
328
  if (!Number.isFinite(configuredMaxBytes) || usage.usedBytes + requiredBytes <= configuredMaxBytes) break
313
329
  await deleteGroupInTransaction(tx, candidate.groupKey, usage, candidate)
314
330
  }
@@ -327,6 +343,7 @@ export function createReceivedChunkStore ({
327
343
 
328
344
  meta.total = total
329
345
  meta.updatedAt = now
346
+ meta.expiresAt = now + meta.ttlMs
330
347
  await run('put', [meta], GROUPS_STORE, null, { tx })
331
348
  await writeUsage(tx, usage)
332
349
  return { tooLarge: false, meta }
@@ -344,7 +361,7 @@ export function createReceivedChunkStore ({
344
361
  })
345
362
  }
346
363
 
347
- async function put ({ channelPubkey, routerPubkey, index, total, contentBytes }) {
364
+ async function put ({ channelPubkey, routerPubkey, index, total, contentBytes, ttlMs }) {
348
365
  if (!channelPubkey || !routerPubkey) throw new Error('RECEIVED_CHUNK_GROUP_REQUIRED')
349
366
  if (!Number.isSafeInteger(index) || !Number.isSafeInteger(total) || index < 0 || total < 1 || index >= total) {
350
367
  throw new Error('INVALID_RECEIVED_CHUNK_INDEX')
@@ -353,7 +370,7 @@ export function createReceivedChunkStore ({
353
370
  await ready()
354
371
  while (true) {
355
372
  try {
356
- const result = await putOnce({ channelPubkey, routerPubkey, index, total, contentBytes: bytes })
373
+ const result = await putOnce({ channelPubkey, routerPubkey, index, total, contentBytes: bytes, ttlMs })
357
374
  if (result.tooLarge) throw new Error('RECEIVED_CHUNK_GROUP_TOO_LARGE')
358
375
  return result.meta
359
376
  } catch (err) {
@@ -365,12 +382,12 @@ export function createReceivedChunkStore ({
365
382
  async function readMeta (groupKey) {
366
383
  await ready()
367
384
  return transaction([GROUPS_STORE], 'readonly', async tx => {
368
- return normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
385
+ return normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
369
386
  })
370
387
  }
371
388
 
372
389
  async function status (metaOrGroupKey) {
373
- const meta = typeof metaOrGroupKey === 'string' ? await readMeta(metaOrGroupKey) : normalizeMeta(metaOrGroupKey)
390
+ const meta = typeof metaOrGroupKey === 'string' ? await readMeta(metaOrGroupKey) : normalizeMeta(metaOrGroupKey, configuredTtlMs)
374
391
  if (!meta) return { received: 0, missing: [] }
375
392
  const missing = []
376
393
  let received = 0
@@ -393,13 +410,15 @@ export function createReceivedChunkStore ({
393
410
  while (true) {
394
411
  const now = Date.now()
395
412
  const result = await transaction([GROUPS_STORE], 'readwrite', async tx => {
396
- const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
413
+ const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
397
414
  if (!meta) return { meta: null, waitMs: 0 }
398
415
  if (meta.drainToken && meta.drainToken !== token && meta.drainUntil > now) {
399
416
  return { meta: null, waitMs: Math.min(DRAIN_LEASE_MS, meta.drainUntil - now) }
400
417
  }
401
418
  meta.drainToken = token
402
419
  meta.drainUntil = now + DRAIN_LEASE_MS
420
+ meta.updatedAt = now
421
+ meta.expiresAt = now + meta.ttlMs
403
422
  await run('put', [meta], GROUPS_STORE, null, { tx })
404
423
  return { meta, waitMs: 0 }
405
424
  })
@@ -410,7 +429,7 @@ export function createReceivedChunkStore ({
410
429
 
411
430
  async function readDrainChunk (groupKey, token) {
412
431
  return transaction([GROUPS_STORE, CHUNKS_STORE], 'readonly', async tx => {
413
- const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
432
+ const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
414
433
  if (!meta || meta.drainToken !== token) return { meta: null, bytes: null }
415
434
  if (meta.nextIndex >= meta.total) return { meta, bytes: null }
416
435
  const chunk = (await run('get', [[groupKey, meta.nextIndex]], CHUNKS_STORE, null, { tx })).result
@@ -420,7 +439,7 @@ export function createReceivedChunkStore ({
420
439
 
421
440
  async function commitDrainMeta (nextMeta, token) {
422
441
  return transaction([GROUPS_STORE], 'readwrite', async tx => {
423
- const current = normalizeMeta((await run('get', [nextMeta.groupKey], GROUPS_STORE, null, { tx })).result)
442
+ const current = normalizeMeta((await run('get', [nextMeta.groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
424
443
  if (!current || current.drainToken !== token) return null
425
444
  current.nextIndex = nextMeta.nextIndex
426
445
  current.rowIndex = nextMeta.rowIndex
@@ -428,6 +447,7 @@ export function createReceivedChunkStore ({
428
447
  current.payloadCiphertext = nextMeta.payloadCiphertext
429
448
  current.receiverPubkeys = uniq(nextMeta.receiverPubkeys)
430
449
  current.updatedAt = nextMeta.updatedAt
450
+ current.expiresAt = current.updatedAt + current.ttlMs
431
451
  current.drainUntil = Date.now() + DRAIN_LEASE_MS
432
452
  await run('put', [current], GROUPS_STORE, null, { tx })
433
453
  return current
@@ -436,7 +456,7 @@ export function createReceivedChunkStore ({
436
456
 
437
457
  async function releaseDrain (groupKey, token) {
438
458
  return transaction([GROUPS_STORE], 'readwrite', async tx => {
439
- const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
459
+ const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
440
460
  if (!meta || meta.drainToken !== token) return false
441
461
  meta.drainToken = ''
442
462
  meta.drainUntil = 0
@@ -504,7 +524,7 @@ export function createReceivedChunkStore ({
504
524
  async function readChunkBytes (groupKey) {
505
525
  await ready()
506
526
  return transaction([GROUPS_STORE, CHUNKS_STORE], 'readonly', async tx => {
507
- const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
527
+ const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
508
528
  if (!meta) return { meta: null, chunks: [] }
509
529
  const chunks = []
510
530
  for (let index = 0; index < meta.total; index++) {
@@ -551,6 +571,12 @@ export function createReceivedChunkStore ({
551
571
  })
552
572
  }
553
573
 
574
+ function close () {
575
+ if (closed) return
576
+ closed = true
577
+ dbPromise?.then(db => db.close()).catch(() => {})
578
+ }
579
+
554
580
  return {
555
581
  cleanupStale,
556
582
  drainAvailable,
@@ -559,7 +585,22 @@ export function createReceivedChunkStore ({
559
585
  readChunkContents,
560
586
  readEnvelopeBundleContent,
561
587
  readEnvelopeBundleText,
588
+ ready,
562
589
  removeGroup,
563
- status
590
+ status,
591
+ close
592
+ }
593
+ }
594
+
595
+ export async function cleanupReceivedChunkStorage ({
596
+ indexedDB = globalThis.indexedDB,
597
+ prefix = DEFAULT_PREFIX,
598
+ now = Date.now()
599
+ } = {}) {
600
+ const store = createReceivedChunkStore({ prefix, indexedDB })
601
+ try {
602
+ return await store.cleanupStale(now)
603
+ } finally {
604
+ store.close()
564
605
  }
565
606
  }