libp2r2p 0.6.0 → 0.8.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.
@@ -3,7 +3,9 @@
3
3
  // userSigner,
4
4
  // contentKeySigner, // optional when userSigner handles content keys internally
5
5
  // nymSigner: optionalDefaultNymSigner,
6
- // channels: [{ signer: privateChannelSigner, relays, mode: 'leecher', seeders: optionalSeederPubkeys }],
6
+ // staleChannelSeconds: optionalStaleChannelRetention,
7
+ // identityStorageRetentionSeconds: optionalIdentityStorageRetention,
8
+ // channels: [{ signer: privateChannelSigner, relays, mode: 'leecher', seeders: optionalSeederPubkeys, offlineRecoverySeconds: optionalChannelOverride }],
7
9
  // onContentKeyChange: event => reviewContentKeyUse(event),
8
10
  // onError: err => reportPrivateMessengerError(err)
9
11
  // })
@@ -29,7 +31,8 @@
29
31
  // - Each watched channel stores lastSeenAt/lastWatchedAt in IndexedDB.
30
32
  // - Re-watching after reload fetches the gap from lastSeenAt to now.
31
33
  // - Browser offline/online events add explicit offline ranges with a small skew.
32
- // - Ranges older than 7 days are ignored; channel state not watched for 45 days is pruned.
34
+ // - Recovery defaults to 7 days and can be overridden per channel; zero disables durable recovery.
35
+ // - Channel state not actively leased or watched within the configured stale window is pruned.
33
36
  // - Seeders announce presence every 10min and are used for the relay-uncovered left edge of a missed range.
34
37
  // - Configured seeders are all asked; auto-discovered seeders are capped to the 8 most recently active.
35
38
  // - Seeder/watchtower channels store reconstructed router events in a separate IndexedDB queue and auto-reply to recovery asks.
@@ -40,10 +43,19 @@ import * as privateMessage from '../private-message/index.js'
40
43
  import { bytesToBase64 } from '../base64/index.js'
41
44
  import { getRelaysByPubkey, pickRelaysForPubkeys, subscribeRelayListUpdates } from '../relay/index.js'
42
45
  import * as privateChannel from '../private-channel/index.js'
43
- import { cleanupTemporaryStorage as cleanupTemporaryChannelStorage } from '../temporary-storage/index.js'
46
+ import { DEFAULT_RECEIVED_CHUNK_TTL_MS } from '../private-channel/services/received-chunks.js'
44
47
  import { createQueue } from '../idb-queue/index.js'
45
48
  import { createChannelStateStore } from './services/channel-state.js'
46
49
  import { DEFAULT_STALE_CHANNEL_SECONDS } from './constants/index.js'
50
+ import {
51
+ activatePrivateMessengerStorage,
52
+ DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS,
53
+ maintainPrivateMessengerStorage,
54
+ PRIVATE_MESSENGER_STORAGE_HEARTBEAT_MS,
55
+ PRIVATE_MESSENGER_STORAGE_MAINTENANCE_MS,
56
+ readPrivateMessengerStorage,
57
+ releasePrivateMessengerStorage
58
+ } from './services/storage-maintenance.js'
47
59
  import {
48
60
  compactSeedNymCarriers,
49
61
  compactSeedRouterRows,
@@ -58,6 +70,7 @@ import {
58
70
  } from './recovery/index.js'
59
71
 
60
72
  export { DEFAULT_STALE_CHANNEL_SECONDS } from './constants/index.js'
73
+ export { DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS } from './services/storage-maintenance.js'
61
74
  export {
62
75
  compactSeedNymCarriers,
63
76
  compactSeedRouterRows,
@@ -72,6 +85,8 @@ export {
72
85
  } from './recovery/index.js'
73
86
 
74
87
  const DEFAULT_OFFLINE_RECOVERY_SECONDS = 7 * 24 * 60 * 60
88
+ const MAX_OFFLINE_RECOVERY_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1000)
89
+ const STORAGE_POLICY_BROADCAST_CHANNEL = 'libp2r2p:private-messenger:storage-policy'
75
90
  const DEFAULT_OFFLINE_SKEW_SECONDS = 30
76
91
  const DEFAULT_RELOAD_GAP_DELAY_MS = 500
77
92
  const DEFAULT_SEEDER_PRESENCE_INTERVAL_MS = 10 * 60 * 1000
@@ -109,6 +124,27 @@ function nowSeconds () {
109
124
  return Math.floor(Date.now() / 1000)
110
125
  }
111
126
 
127
+ function normalizeOfflineRecoverySeconds (value) {
128
+ if (!Number.isSafeInteger(value) || value < 0 || value > MAX_OFFLINE_RECOVERY_SECONDS) {
129
+ throw new Error('INVALID_OFFLINE_RECOVERY_SECONDS')
130
+ }
131
+ return value
132
+ }
133
+
134
+ function normalizeStaleChannelSeconds (value) {
135
+ if (!Number.isSafeInteger(value) || value < 0 || value > MAX_OFFLINE_RECOVERY_SECONDS) {
136
+ throw new Error('INVALID_STALE_CHANNEL_SECONDS')
137
+ }
138
+ return value
139
+ }
140
+
141
+ function normalizeIdentityStorageRetentionSeconds (value) {
142
+ if (!Number.isSafeInteger(value) || value < 0 || value > MAX_OFFLINE_RECOVERY_SECONDS) {
143
+ throw new Error('INVALID_IDENTITY_STORAGE_RETENTION_SECONDS')
144
+ }
145
+ return value
146
+ }
147
+
112
148
  function uniq (values) {
113
149
  return [...new Set((values || []).filter(Boolean))]
114
150
  }
@@ -126,18 +162,33 @@ function parseJson (raw, fallback) {
126
162
  try { return JSON.parse(raw || '') } catch { return fallback }
127
163
  }
128
164
 
165
+ function sameStateValue (left, right) {
166
+ return JSON.stringify(left) === JSON.stringify(right)
167
+ }
168
+
129
169
  function storesRecoverySeeds (mode) {
130
170
  return mode === 'seeder' || mode === 'watchtower'
131
171
  }
132
172
 
173
+ function randomStorageLeaseId () {
174
+ if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
175
+ const bytes = globalThis.crypto?.getRandomValues?.(new Uint8Array(16))
176
+ if (bytes) return [...bytes].map(value => value.toString(16).padStart(2, '0')).join('')
177
+ return `${Date.now().toString(36)}:${Math.random().toString(36).slice(2)}`
178
+ }
179
+
133
180
  export class PrivateMessenger {
134
- static cleanupTemporaryStorage ({ storageArea = globalThis.sessionStorage } = {}) {
135
- cleanupTemporaryChannelStorage({ storageArea })
181
+ static maintainStorage ({
182
+ indexedDB = globalThis.indexedDB,
183
+ temporaryStorageArea = globalThis.sessionStorage
184
+ } = {}) {
185
+ return maintainPrivateMessengerStorage({ indexedDB, temporaryStorageArea })
136
186
  }
137
187
 
138
188
  constructor ({
139
189
  offlineRecoverySeconds = DEFAULT_OFFLINE_RECOVERY_SECONDS,
140
190
  staleChannelSeconds = DEFAULT_STALE_CHANNEL_SECONDS,
191
+ identityStorageRetentionSeconds = DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS,
141
192
  offlineSkewSeconds = DEFAULT_OFFLINE_SKEW_SECONDS,
142
193
  reloadGapDelayMs = DEFAULT_RELOAD_GAP_DELAY_MS,
143
194
  seederPresenceIntervalMs = DEFAULT_SEEDER_PRESENCE_INTERVAL_MS,
@@ -160,10 +211,14 @@ export class PrivateMessenger {
160
211
  _subscribeRelayListUpdates = subscribeRelayListUpdates,
161
212
  _setTimeout = globalThis.setTimeout.bind(globalThis),
162
213
  _setInterval = globalThis.setInterval.bind(globalThis),
163
- _clearInterval = globalThis.clearInterval.bind(globalThis)
214
+ _clearInterval = globalThis.clearInterval.bind(globalThis),
215
+ _storageSetInterval = globalThis.setInterval.bind(globalThis),
216
+ _storageClearInterval = globalThis.clearInterval.bind(globalThis),
217
+ _BroadcastChannel = _indexedDB === globalThis.indexedDB ? globalThis.BroadcastChannel : undefined
164
218
  } = {}) {
165
- this.offlineRecoverySeconds = offlineRecoverySeconds
166
- this.staleChannelSeconds = staleChannelSeconds
219
+ this.offlineRecoverySeconds = normalizeOfflineRecoverySeconds(offlineRecoverySeconds)
220
+ this.staleChannelSeconds = normalizeStaleChannelSeconds(staleChannelSeconds)
221
+ this.identityStorageRetentionSeconds = normalizeIdentityStorageRetentionSeconds(identityStorageRetentionSeconds)
167
222
  this.offlineSkewSeconds = offlineSkewSeconds
168
223
  this.reloadGapDelayMs = reloadGapDelayMs
169
224
  this.seederPresenceIntervalMs = seederPresenceIntervalMs
@@ -187,6 +242,9 @@ export class PrivateMessenger {
187
242
  this._setTimeout = _setTimeout
188
243
  this._setInterval = _setInterval
189
244
  this._clearInterval = _clearInterval
245
+ this._storageSetInterval = _storageSetInterval
246
+ this._storageClearInterval = _storageClearInterval
247
+ this._BroadcastChannel = _BroadcastChannel
190
248
 
191
249
  this.userSigner = null
192
250
  this.contentKeySigner = null
@@ -208,42 +266,287 @@ export class PrivateMessenger {
208
266
  this.stopOnline = null
209
267
  this.stopOffline = null
210
268
  this.queueOperationTail = Promise.resolve()
269
+ this.storageActive = false
270
+ this.storageLeaseId = randomStorageLeaseId()
271
+ this.lastStorageTouch = 0
272
+ this.storageTouchPromise = null
273
+ this.storageHeartbeatTimer = null
274
+ this.storageMaintenanceTimer = null
275
+ this.storageMaintenancePromise = null
276
+ this.storagePolicyRevision = 0
277
+ this.storagePolicyNeedsApply = false
278
+ this.storagePolicyBroadcast = null
279
+ this.storagePolicyRefreshTail = Promise.resolve()
280
+ this.closePromise = null
281
+ this.initSettledPromise = null
282
+ this.initialized = false
211
283
  }
212
284
 
213
285
  async init ({ userSigner, contentKeySigner, nymSigner, channels = [], relays = [], mode = 'leecher' }) {
214
286
  if (!userSigner?.getPublicKey) throw new Error('USER_SIGNER_REQUIRED')
215
- PrivateMessenger.cleanupTemporaryStorage({ storageArea: this.temporaryStorageArea })
216
- this.userSigner = userSigner
217
- this.contentKeySigner = contentKeySigner || null
218
- this.nymSigner = nymSigner || null
219
- this.userPubkey = await userSigner.getPublicKey()
220
- this.contentKeyPubkey = await this.contentKeySigner?.getPublicKey?.() || ''
221
- this.prefix = `libp2r2p:private-messenger:${this.userPubkey}`
222
- this.queue = await createQueue({
223
- prefix: this.prefix,
224
- indexes: MESSAGE_QUEUE_INDEXES,
225
- maxBytes: this.messageQueueMaxBytes,
226
- evictionPolicy: 'fifo',
287
+ this.assertOpen()
288
+ if (this.initSettledPromise) throw new Error('PRIVATE_MESSENGER_INIT_IN_PROGRESS')
289
+ if (this.initialized) throw new Error('PRIVATE_MESSENGER_ALREADY_INITIALIZED')
290
+ let settleInit
291
+ const initSettledPromise = new Promise(resolve => { settleInit = resolve })
292
+ this.initSettledPromise = initSettledPromise
293
+ try {
294
+ this.userSigner = userSigner
295
+ this.contentKeySigner = contentKeySigner || null
296
+ this.nymSigner = nymSigner || null
297
+ this.userPubkey = await userSigner.getPublicKey()
298
+ this.prefix = `libp2r2p:private-messenger:${this.userPubkey}`
299
+ const storageSnapshot = await activatePrivateMessengerStorage({
300
+ userPubkey: this.userPubkey,
301
+ leaseId: this.storageLeaseId,
302
+ activeChannelPubkeys: [],
303
+ storagePolicy: {
304
+ staleChannelSeconds: this.staleChannelSeconds,
305
+ identityStorageRetentionSeconds: this.identityStorageRetentionSeconds
306
+ },
307
+ indexedDB: this._indexedDB
308
+ })
309
+ this.applyStoragePolicySnapshot(storageSnapshot)
310
+ this.storageActive = true
311
+ this.lastStorageTouch = Date.now()
312
+ this.startStoragePolicyBroadcast()
313
+ this.assertOpen()
314
+ await PrivateMessenger.maintainStorage({
315
+ indexedDB: this._indexedDB,
316
+ temporaryStorageArea: this.temporaryStorageArea
317
+ })
318
+ this.assertOpen()
319
+ this.contentKeyPubkey = await this.contentKeySigner?.getPublicKey?.() || ''
320
+ this.assertOpen()
321
+ this.queue = await createQueue({
322
+ prefix: this.prefix,
323
+ indexes: MESSAGE_QUEUE_INDEXES,
324
+ maxBytes: this.messageQueueMaxBytes,
325
+ evictionPolicy: 'fifo',
326
+ indexedDB: this._indexedDB
327
+ })
328
+ this.assertOpen()
329
+ this.seedQueue = await createQueue({
330
+ prefix: `${this.prefix}:seeds`,
331
+ indexes: SEED_QUEUE_INDEXES,
332
+ maxBytes: this.seedQueueMaxBytes,
333
+ evictionPolicy: 'fifo',
334
+ indexedDB: this._indexedDB
335
+ })
336
+ this.assertOpen()
337
+ this.stateStore = await createChannelStateStore({
338
+ prefix: this.prefix,
339
+ indexedDB: this._indexedDB
340
+ })
341
+ this.assertOpen()
342
+ this.startStorageMaintenance()
343
+ this.state = { channels: await this.stateStore.load() }
344
+ await this.update({ userSigner, contentKeySigner, nymSigner: this.nymSigner, channels, relays, mode })
345
+ await this.pruneStoredSeeds()
346
+ this.initialized = true
347
+ await this.refreshAndApplyStoragePolicy()
348
+ this.broadcastStoragePolicyChange()
349
+ return this
350
+ } catch (err) {
351
+ this.stopStorageMaintenance()
352
+ this.stopStoragePolicyBroadcast()
353
+ try { await this.queueOperationTail } catch {}
354
+ try { await this.stateWriteTail } catch {}
355
+ await Promise.allSettled([
356
+ this.queue?.close?.(),
357
+ this.seedQueue?.close?.(),
358
+ this.stateStore?.close?.()
359
+ ])
360
+ if (this.storageActive) {
361
+ try {
362
+ await releasePrivateMessengerStorage({
363
+ userPubkey: this.userPubkey,
364
+ leaseId: this.storageLeaseId,
365
+ indexedDB: this._indexedDB
366
+ })
367
+ } catch {}
368
+ }
369
+ this.storageActive = false
370
+ this.initialized = false
371
+ throw err
372
+ } finally {
373
+ settleInit()
374
+ if (this.initSettledPromise === initSettledPromise) this.initSettledPromise = null
375
+ }
376
+ }
377
+
378
+ assertOpen () {
379
+ if (this.closePromise) throw new Error('PRIVATE_MESSENGER_CLOSED')
380
+ }
381
+
382
+ applyStoragePolicySnapshot (snapshot) {
383
+ if (!snapshot) return false
384
+ const staleChannelSeconds = normalizeStaleChannelSeconds(snapshot.staleChannelSeconds)
385
+ const identityStorageRetentionSeconds = normalizeIdentityStorageRetentionSeconds(
386
+ snapshot.identityStorageRetentionSeconds
387
+ )
388
+ const policyRevision = Math.max(0, Number(snapshot.policyRevision) || 0)
389
+ const changed = this.staleChannelSeconds !== staleChannelSeconds ||
390
+ this.identityStorageRetentionSeconds !== identityStorageRetentionSeconds
391
+ this.staleChannelSeconds = staleChannelSeconds
392
+ this.identityStorageRetentionSeconds = identityStorageRetentionSeconds
393
+ this.storagePolicyRevision = policyRevision
394
+ if (changed && this.initialized) this.storagePolicyNeedsApply = true
395
+ return changed
396
+ }
397
+
398
+ startStoragePolicyBroadcast () {
399
+ if (this.storagePolicyBroadcast || typeof this._BroadcastChannel !== 'function') return
400
+ try {
401
+ const channel = new this._BroadcastChannel(STORAGE_POLICY_BROADCAST_CHANNEL)
402
+ channel.unref?.()
403
+ channel.onmessage = event => {
404
+ const message = event?.data
405
+ if (message?.userPubkey !== this.userPubkey) return
406
+ if (!Number.isSafeInteger(message.policyRevision) || message.policyRevision <= (this.storagePolicyRevision || 0)) return
407
+ this.refreshAndApplyStoragePolicy().catch(err => {
408
+ try { this.onError?.(err) } catch {}
409
+ })
410
+ }
411
+ this.storagePolicyBroadcast = channel
412
+ } catch {}
413
+ }
414
+
415
+ stopStoragePolicyBroadcast () {
416
+ const channel = this.storagePolicyBroadcast
417
+ this.storagePolicyBroadcast = null
418
+ if (!channel) return
419
+ channel.onmessage = null
420
+ channel.close?.()
421
+ }
422
+
423
+ broadcastStoragePolicyChange () {
424
+ try {
425
+ this.storagePolicyBroadcast?.postMessage({
426
+ userPubkey: this.userPubkey,
427
+ policyRevision: this.storagePolicyRevision || 0
428
+ })
429
+ } catch (err) {
430
+ try { this.onError?.(err) } catch {}
431
+ }
432
+ }
433
+
434
+ async readStoragePolicySnapshot () {
435
+ if (!this.userPubkey) return null
436
+ const snapshot = await readPrivateMessengerStorage({
437
+ userPubkey: this.userPubkey,
227
438
  indexedDB: this._indexedDB
228
439
  })
229
- this.seedQueue = await createQueue({
230
- prefix: `${this.prefix}:seeds`,
231
- indexes: SEED_QUEUE_INDEXES,
232
- maxBytes: this.seedQueueMaxBytes,
233
- evictionPolicy: 'fifo',
234
- indexedDB: this._indexedDB
440
+ this.applyStoragePolicySnapshot(snapshot)
441
+ return snapshot
442
+ }
443
+
444
+ async applyPendingStoragePolicy () {
445
+ if (!this.storagePolicyNeedsApply || !this.initialized || this.closePromise) return false
446
+ this.storagePolicyNeedsApply = false
447
+ try {
448
+ const channels = [...this.channels.values()]
449
+ await this.applyRecoveryPolicies(channels)
450
+ await this.cleanupStaleChannels()
451
+ const pubkeys = [...this.channels.keys()]
452
+ if (pubkeys.length) {
453
+ await this.unwatch(pubkeys)
454
+ await this.watch(pubkeys)
455
+ }
456
+ await this.reconcilePresencePublishers()
457
+ return true
458
+ } catch (err) {
459
+ this.storagePolicyNeedsApply = true
460
+ throw err
461
+ }
462
+ }
463
+
464
+ refreshAndApplyStoragePolicy () {
465
+ const previous = this.storagePolicyRefreshTail || Promise.resolve()
466
+ const refresh = previous.catch(() => {}).then(async () => {
467
+ await this.readStoragePolicySnapshot()
468
+ return this.applyPendingStoragePolicy()
235
469
  })
236
- this.stateStore = await createChannelStateStore({
237
- prefix: this.prefix,
238
- indexedDB: this._indexedDB
470
+ this.storagePolicyRefreshTail = refresh
471
+ return refresh
472
+ }
473
+
474
+ startStorageMaintenance () {
475
+ if (this.storageHeartbeatTimer || this.storageMaintenanceTimer) return
476
+ this.storageHeartbeatTimer = this._storageSetInterval(() => (
477
+ this.runStorageHeartbeat().catch(err => {
478
+ try { this.onError?.(err) } catch {}
479
+ })
480
+ ), PRIVATE_MESSENGER_STORAGE_HEARTBEAT_MS)
481
+ this.storageMaintenanceTimer = this._storageSetInterval(
482
+ () => this.runStorageMaintenance(),
483
+ PRIVATE_MESSENGER_STORAGE_MAINTENANCE_MS
484
+ )
485
+ this.storageHeartbeatTimer?.unref?.()
486
+ this.storageMaintenanceTimer?.unref?.()
487
+ }
488
+
489
+ stopStorageMaintenance () {
490
+ if (this.storageHeartbeatTimer) this._storageClearInterval(this.storageHeartbeatTimer)
491
+ if (this.storageMaintenanceTimer) this._storageClearInterval(this.storageMaintenanceTimer)
492
+ this.storageHeartbeatTimer = null
493
+ this.storageMaintenanceTimer = null
494
+ }
495
+
496
+ runStorageMaintenance () {
497
+ if (this.storageMaintenancePromise) return this.storageMaintenancePromise
498
+ const maintenance = (async () => {
499
+ await PrivateMessenger.maintainStorage({
500
+ indexedDB: this._indexedDB,
501
+ temporaryStorageArea: this.temporaryStorageArea
502
+ })
503
+ await this.refreshAndApplyStoragePolicy()
504
+ await this.cleanupStaleChannels()
505
+ await this.pruneStoredSeeds()
506
+ })().catch(err => {
507
+ try { this.onError?.(err) } catch {}
508
+ }).finally(() => {
509
+ if (this.storageMaintenancePromise === maintenance) this.storageMaintenancePromise = null
239
510
  })
240
- this.state = { channels: await this.stateStore.load() }
241
- await this.cleanupStaleChannels()
242
- await this.update({ userSigner, contentKeySigner, nymSigner: this.nymSigner, channels, relays, mode })
243
- return this
511
+ this.storageMaintenancePromise = maintenance
512
+ return maintenance
513
+ }
514
+
515
+ async runStorageHeartbeat () {
516
+ await this.stampActiveChannelActivity()
517
+ await this.touchStorageActivity({ force: true })
518
+ await this.applyPendingStoragePolicy()
519
+ }
520
+
521
+ async touchStorageActivity ({ force = false } = {}) {
522
+ if (!this.storageActive || this.closePromise) return false
523
+ if (this.storageTouchPromise) return this.storageTouchPromise
524
+ const now = Date.now()
525
+ if (!force && now - this.lastStorageTouch < PRIVATE_MESSENGER_STORAGE_HEARTBEAT_MS) return false
526
+ const previousTouch = this.lastStorageTouch
527
+ this.lastStorageTouch = now
528
+ const touch = activatePrivateMessengerStorage({
529
+ userPubkey: this.userPubkey,
530
+ leaseId: this.storageLeaseId,
531
+ activeChannelPubkeys: [...this.channels.keys()],
532
+ indexedDB: this._indexedDB,
533
+ now
534
+ }).then(snapshot => {
535
+ this.applyStoragePolicySnapshot(snapshot)
536
+ return true
537
+ }, err => {
538
+ this.lastStorageTouch = previousTouch
539
+ throw err
540
+ }).finally(() => {
541
+ if (this.storageTouchPromise === touch) this.storageTouchPromise = null
542
+ })
543
+ this.storageTouchPromise = touch
544
+ return touch
244
545
  }
245
546
 
246
547
  runQueueOperation (operation) {
548
+ if (this.closePromise) return Promise.reject(new Error('PRIVATE_MESSENGER_CLOSED'))
549
+ this.touchStorageActivity().catch(err => this.onError?.(err))
247
550
  const run = this.queueOperationTail.then(operation)
248
551
  this.queueOperationTail = run.catch(err => {
249
552
  try { this.onError?.(err) } catch {}
@@ -277,7 +580,24 @@ export class PrivateMessenger {
277
580
  })
278
581
  }
279
582
 
280
- async update ({ userSigner = this.userSigner, contentKeySigner = this.contentKeySigner, nymSigner = this.nymSigner, channels = [...this.channels.values()], relays = [], mode = 'leecher' } = {}) {
583
+ async update (options = {}) {
584
+ this.assertOpen()
585
+ const {
586
+ userSigner = this.userSigner,
587
+ contentKeySigner = this.contentKeySigner,
588
+ nymSigner = this.nymSigner,
589
+ channels = [...this.channels.values()],
590
+ relays = [],
591
+ mode = 'leecher'
592
+ } = options
593
+ const updatesStalePolicy = Object.hasOwn(options, 'staleChannelSeconds')
594
+ const updatesIdentityPolicy = Object.hasOwn(options, 'identityStorageRetentionSeconds')
595
+ let nextStaleChannelSeconds = updatesStalePolicy
596
+ ? normalizeStaleChannelSeconds(options.staleChannelSeconds)
597
+ : this.staleChannelSeconds
598
+ let nextIdentityStorageRetentionSeconds = updatesIdentityPolicy
599
+ ? normalizeIdentityStorageRetentionSeconds(options.identityStorageRetentionSeconds)
600
+ : this.identityStorageRetentionSeconds
281
601
  if (userSigner) {
282
602
  const userPubkey = await userSigner.getPublicKey?.()
283
603
  if (!userPubkey) throw new Error('USER_SIGNER_REQUIRED')
@@ -288,19 +608,50 @@ export class PrivateMessenger {
288
608
  this.nymSigner = nymSigner || null
289
609
  this.contentKeyPubkey = await this.contentKeySigner?.getPublicKey?.() || ''
290
610
  const nextChannels = await this.normalizeChannels(channels, { relays, mode })
611
+ this.assertOpen()
291
612
  const nextPubkeys = new Set(nextChannels.map(channel => channel.pubkey))
292
-
293
- for (const pubkey of [...this.channels.keys()]) {
294
- if (!nextPubkeys.has(pubkey)) {
295
- this.unwatch(pubkey)
296
- this.channels.delete(pubkey)
613
+ const removedPubkeys = [...this.channels.keys()].filter(pubkey => !nextPubkeys.has(pubkey))
614
+ const updatesStoragePolicy = updatesStalePolicy || updatesIdentityPolicy
615
+
616
+ if (updatesStoragePolicy) {
617
+ const currentPolicy = await this.readStoragePolicySnapshot()
618
+ if (!updatesStalePolicy) nextStaleChannelSeconds = currentPolicy?.staleChannelSeconds ?? this.staleChannelSeconds
619
+ if (!updatesIdentityPolicy) {
620
+ nextIdentityStorageRetentionSeconds = currentPolicy?.identityStorageRetentionSeconds ?? this.identityStorageRetentionSeconds
297
621
  }
298
622
  }
623
+
624
+ if (removedPubkeys.length) {
625
+ await this.stampChannelActivity(removedPubkeys)
626
+ }
627
+
628
+ const storageSnapshot = await activatePrivateMessengerStorage({
629
+ userPubkey: this.userPubkey,
630
+ leaseId: this.storageLeaseId,
631
+ activeChannelPubkeys: [...nextPubkeys],
632
+ storagePolicy: updatesStoragePolicy
633
+ ? {
634
+ staleChannelSeconds: nextStaleChannelSeconds,
635
+ identityStorageRetentionSeconds: nextIdentityStorageRetentionSeconds
636
+ }
637
+ : undefined,
638
+ indexedDB: this._indexedDB
639
+ })
640
+ this.applyStoragePolicySnapshot(storageSnapshot)
641
+ this.lastStorageTouch = Date.now()
642
+ if (updatesStoragePolicy) this.broadcastStoragePolicyChange()
643
+
644
+ await this.unwatch([...this.channels.keys()])
645
+ this.channels.clear()
299
646
  for (const channel of nextChannels) this.channels.set(channel.pubkey, channel)
300
647
 
301
- await this.cleanupStaleChannels()
648
+ await this.cleanupStaleChannels({ storageSnapshot })
649
+ await this.applyRecoveryPolicies(nextChannels)
302
650
  await this.watch()
303
651
  await this.reconcilePresencePublishers()
652
+ if (this.storagePolicyRevision === storageSnapshot.policyRevision) {
653
+ this.storagePolicyNeedsApply = false
654
+ }
304
655
  return this
305
656
  }
306
657
 
@@ -323,6 +674,9 @@ export class PrivateMessenger {
323
674
  const autoDeletionCapability = channel.autoDeletionCapability === undefined
324
675
  ? undefined
325
676
  : normalizeAutoDeletionCapability(channel.autoDeletionCapability)
677
+ const offlineRecoverySeconds = normalizeOfflineRecoverySeconds(
678
+ channel.offlineRecoverySeconds ?? this.offlineRecoverySeconds
679
+ )
326
680
  out.push({
327
681
  pubkey,
328
682
  signer,
@@ -334,6 +688,7 @@ export class PrivateMessenger {
334
688
  usesNip65WatchRelays: !hasChannelRelays && !hasDefaultRelays,
335
689
  mode,
336
690
  seeders: uniq(channel.seeders),
691
+ offlineRecoverySeconds,
337
692
  autoDeletionCapability
338
693
  })
339
694
  }
@@ -355,6 +710,7 @@ export class PrivateMessenger {
355
710
  }
356
711
 
357
712
  async recoveryMirrorRelays (channelPubkey) {
713
+ if (!this.offlineRecoverySecondsFor(channelPubkey)) return []
358
714
  const seeders = this.recoverySeeders(channelPubkey)
359
715
  if (!seeders.length) return []
360
716
  try {
@@ -385,13 +741,19 @@ export class PrivateMessenger {
385
741
  return structuredClone(this.state)
386
742
  }
387
743
 
388
- writeState (state) {
744
+ writeState (state, { touchStorage = true } = {}) {
745
+ if (touchStorage) this.touchStorageActivity().catch(err => this.onError?.(err))
746
+ const previous = this.state?.channels || {}
389
747
  const next = {
390
748
  channels: isPlainObject(state?.channels) ? structuredClone(state.channels) : {}
391
749
  }
750
+ const changed = Object.fromEntries(Object.entries(next.channels)
751
+ .filter(([pubkey, value]) => !sameStateValue(previous[pubkey], value)))
752
+ const removed = Object.keys(previous).filter(pubkey => !Object.hasOwn(next.channels, pubkey))
392
753
  this.state = next
393
- const snapshot = structuredClone(next.channels)
394
- const write = this.stateWriteTail.then(() => this.stateStore.replace(snapshot))
754
+ if (!Object.keys(changed).length && !removed.length) return this.stateWriteTail
755
+ const snapshot = structuredClone(changed)
756
+ const write = this.stateWriteTail.then(() => this.stateStore.update(snapshot, removed))
395
757
  this.stateWriteTail = write.catch(err => {
396
758
  try { this.onError?.(err) } catch {}
397
759
  })
@@ -402,6 +764,29 @@ export class PrivateMessenger {
402
764
  await this.stateWriteTail
403
765
  }
404
766
 
767
+ async stampChannelActivity (pubkeys) {
768
+ if (!this.stateStore || !this.state) return false
769
+ pubkeys = [...new Set(pubkeys || [])].filter(pubkey => this.state.channels[pubkey])
770
+ if (!pubkeys.length) return false
771
+ const lastWatchedAt = nowSeconds()
772
+ for (const pubkey of pubkeys) {
773
+ this.state.channels[pubkey] = {
774
+ ...this.state.channels[pubkey],
775
+ lastWatchedAt
776
+ }
777
+ }
778
+ const write = this.stateWriteTail.then(() => this.stateStore.touch(pubkeys, lastWatchedAt))
779
+ this.stateWriteTail = write.catch(err => {
780
+ try { this.onError?.(err) } catch {}
781
+ })
782
+ await write
783
+ return true
784
+ }
785
+
786
+ stampActiveChannelActivity () {
787
+ return this.stampChannelActivity([...this.channels.keys()])
788
+ }
789
+
405
790
  updateChannelState (pubkey, patch) {
406
791
  const state = this.readState()
407
792
  const current = state.channels[pubkey] || {}
@@ -411,9 +796,19 @@ export class PrivateMessenger {
411
796
  }
412
797
 
413
798
  removeChannelState (pubkey) {
414
- const state = this.readState()
415
- delete state.channels[pubkey]
416
- this.writeState(state)
799
+ this.removeChannelStates([pubkey])
800
+ }
801
+
802
+ removeChannelStates (pubkeys) {
803
+ pubkeys = [...new Set(pubkeys || [])]
804
+ for (const pubkey of pubkeys) delete this.state.channels[pubkey]
805
+ if (!pubkeys.length) return this.stateWriteTail
806
+ this.touchStorageActivity().catch(err => this.onError?.(err))
807
+ const write = this.stateWriteTail.then(() => this.stateStore.update({}, pubkeys))
808
+ this.stateWriteTail = write.catch(err => {
809
+ try { this.onError?.(err) } catch {}
810
+ })
811
+ return write
417
812
  }
418
813
 
419
814
  markSeen (pubkey, createdAt = nowSeconds()) {
@@ -432,6 +827,7 @@ export class PrivateMessenger {
432
827
  }
433
828
 
434
829
  recoverySeeders (pubkey) {
830
+ if (!this.offlineRecoverySecondsFor(pubkey)) return []
435
831
  const channel = this.channels.get(pubkey)
436
832
  const configuredSeeders = channel?.seeders || []
437
833
  if (configuredSeeders.length) return configuredSeeders.filter(seeder => seeder !== this.userPubkey)
@@ -446,6 +842,7 @@ export class PrivateMessenger {
446
842
  }
447
843
 
448
844
  markSeederActive (channelPubkey, seederPubkey, { announced = false, at = nowSeconds() } = {}) {
845
+ if (!this.offlineRecoverySecondsFor(channelPubkey)) return false
449
846
  const state = this.readState()
450
847
  const current = state.channels[channelPubkey] || {}
451
848
  const activity = current.seederActivity || {}
@@ -459,6 +856,7 @@ export class PrivateMessenger {
459
856
  current.seederActivity = activity
460
857
  state.channels[channelPubkey] = current
461
858
  this.writeState(state)
859
+ return true
462
860
  }
463
861
 
464
862
  trackSeederActivity (channelPubkey, message) {
@@ -544,8 +942,10 @@ export class PrivateMessenger {
544
942
  }
545
943
 
546
944
  addOfflineRange (pubkey, start, end) {
945
+ const recoverySeconds = this.offlineRecoverySecondsFor(pubkey)
946
+ if (!recoverySeconds) return
547
947
  const now = nowSeconds()
548
- const minStart = now - this.offlineRecoverySeconds
948
+ const minStart = now - recoverySeconds
549
949
  const normalized = {
550
950
  start: Math.max(0, Math.floor(start)),
551
951
  end: Math.floor(end)
@@ -567,10 +967,17 @@ export class PrivateMessenger {
567
967
  closeOpenOfflineRanges () {
568
968
  const state = this.readState()
569
969
  const end = nowSeconds()
570
- const minStart = end - this.offlineRecoverySeconds
571
970
  for (const pubkey of Object.keys(state.channels)) {
572
971
  const current = state.channels[pubkey]
573
972
  if (!current.openOfflineStart) continue
973
+ const recoverySeconds = this.offlineRecoverySecondsFor(pubkey)
974
+ if (!recoverySeconds) {
975
+ delete current.openOfflineStart
976
+ current.offlineRanges = []
977
+ state.channels[pubkey] = current
978
+ continue
979
+ }
980
+ const minStart = end - recoverySeconds
574
981
  const start = Math.max(minStart, Math.max(0, current.openOfflineStart))
575
982
  if (end > start) {
576
983
  current.offlineRanges = mergeRanges((current.offlineRanges || []).concat([{ start, end }]))
@@ -582,11 +989,13 @@ export class PrivateMessenger {
582
989
  }
583
990
 
584
991
  async watch (channels = [...this.channels.keys()], { scheduleReloadGap = true } = {}) {
992
+ this.assertOpen()
585
993
  const channelPubkeys = uniq(channels)
586
994
  for (const pubkey of channelPubkeys) {
587
995
  const channel = this.channels.get(pubkey)
588
996
  if (!channel) throw new Error('UNKNOWN_CHANNEL')
589
997
  const watchRelays = await this.resolveWatchRelays(channel)
998
+ this.assertOpen()
590
999
  const stop = await this._privateMessage.watch({
591
1000
  channels: [pubkey],
592
1001
  relays: watchRelays,
@@ -604,16 +1013,21 @@ export class PrivateMessenger {
604
1013
  onMessage: message => this.queueIncoming(() => this.handleMessage(pubkey, message)),
605
1014
  onSeed: seed => this.queueIncoming(() => this.enqueueSeed(pubkey, seed)),
606
1015
  onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
607
- receivedChunkTtlMs: this.offlineRecoverySeconds * 1000,
1016
+ receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
608
1017
  receivedChunkIndexedDB: this._indexedDB,
609
1018
  onError: err => this.onError?.(err)
610
1019
  })
1020
+ if (this.closePromise) {
1021
+ await stop?.()
1022
+ this.assertOpen()
1023
+ }
611
1024
  this.stopByChannel.set(pubkey, stop)
612
1025
  this.updateChannelState(pubkey, {
613
1026
  lastWatchedAt: nowSeconds(),
614
1027
  mode: channel.mode,
615
1028
  relays: watchRelays,
616
- seeders: channel.seeders
1029
+ seeders: channel.seeders,
1030
+ offlineRecoverySeconds: channel.offlineRecoverySeconds
617
1031
  })
618
1032
  this.debug('watch', {
619
1033
  channelPubkey: pubkey,
@@ -631,12 +1045,15 @@ export class PrivateMessenger {
631
1045
 
632
1046
  unwatch (channels) {
633
1047
  const channelPubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.stopByChannel.keys()]
1048
+ const closing = []
634
1049
  for (const pubkey of channelPubkeys) {
635
- this.stopByChannel.get(pubkey)?.()
1050
+ const close = this.stopByChannel.get(pubkey)?.()
1051
+ if (close && typeof close.then === 'function') closing.push(close)
636
1052
  this.stopByChannel.delete(pubkey)
637
1053
  this.stopPresencePublisher(pubkey)
638
1054
  }
639
1055
  this.ensureRelayListWatcher()
1056
+ return Promise.allSettled(closing)
640
1057
  }
641
1058
 
642
1059
  nip65WatchChannelPubkeys () {
@@ -762,6 +1179,7 @@ export class PrivateMessenger {
762
1179
  }
763
1180
 
764
1181
  async enqueueSeed (channelPubkey, seed) {
1182
+ if (!this.offlineRecoverySecondsFor(channelPubkey)) return
765
1183
  const receivedAt = nowSeconds()
766
1184
  if (seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length) {
767
1185
  const carriers = compactSeedNymCarriers(seed.carriers)
@@ -818,6 +1236,7 @@ export class PrivateMessenger {
818
1236
 
819
1237
  async nextMessage () {
820
1238
  // seedQueue is retained replay material for recovery replies, not an app-message stream.
1239
+ this.touchStorageActivity().catch(err => this.onError?.(err))
821
1240
  await this.queueOperationTail
822
1241
  return withoutQueueMetadata(await this.queue.shift())
823
1242
  }
@@ -833,7 +1252,7 @@ export class PrivateMessenger {
833
1252
  privateChannelReaderPubkey: channel.readerPubkey,
834
1253
  receiverPubkey,
835
1254
  ...routing,
836
- expirationSeconds: this.offlineRecoverySeconds,
1255
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
837
1256
  temporaryStorageArea: this.temporaryStorageArea,
838
1257
  deletionPubkey,
839
1258
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
@@ -859,7 +1278,7 @@ export class PrivateMessenger {
859
1278
  question,
860
1279
  receiverPubkey,
861
1280
  ...routing,
862
- expirationSeconds: this.offlineRecoverySeconds,
1281
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
863
1282
  temporaryStorageArea: this.temporaryStorageArea,
864
1283
  deletionPubkey,
865
1284
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
@@ -883,7 +1302,7 @@ export class PrivateMessenger {
883
1302
  privateChannelReaderPubkey: channel.readerPubkey,
884
1303
  receiverPubkey,
885
1304
  ...routing,
886
- expirationSeconds: this.offlineRecoverySeconds,
1305
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
887
1306
  temporaryStorageArea: this.temporaryStorageArea,
888
1307
  deletionPubkey,
889
1308
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
@@ -907,7 +1326,7 @@ export class PrivateMessenger {
907
1326
  privateChannelReaderPubkey: channel.readerPubkey,
908
1327
  receiverPubkeys,
909
1328
  ...routing,
910
- expirationSeconds: this.offlineRecoverySeconds,
1329
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
911
1330
  temporaryStorageArea: this.temporaryStorageArea,
912
1331
  deletionPubkey,
913
1332
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
@@ -931,7 +1350,7 @@ export class PrivateMessenger {
931
1350
  privateChannelReaderPubkey: channel.readerPubkey,
932
1351
  receiverPubkeys,
933
1352
  ...routing,
934
- expirationSeconds: this.offlineRecoverySeconds,
1353
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
935
1354
  temporaryStorageArea: this.temporaryStorageArea,
936
1355
  deletionPubkey,
937
1356
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
@@ -951,7 +1370,7 @@ export class PrivateMessenger {
951
1370
  privateChannelReaderPubkey: channel.readerPubkey,
952
1371
  receiverPubkeys,
953
1372
  ...routing,
954
- expirationSeconds: this.offlineRecoverySeconds,
1373
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
955
1374
  temporaryStorageArea: this.temporaryStorageArea,
956
1375
  deletionPubkey,
957
1376
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
@@ -970,7 +1389,7 @@ export class PrivateMessenger {
970
1389
  privateChannelSigner: channel.signer,
971
1390
  privateChannelReaderPubkey: channel.readerPubkey,
972
1391
  ...routing,
973
- expirationSeconds: this.offlineRecoverySeconds,
1392
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
974
1393
  deletionPubkey,
975
1394
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
976
1395
  rumor
@@ -987,7 +1406,7 @@ export class PrivateMessenger {
987
1406
  privateChannelSigner: channel.signer,
988
1407
  privateChannelReaderPubkey: channel.readerPubkey,
989
1408
  ...routing,
990
- expirationSeconds: this.offlineRecoverySeconds,
1409
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
991
1410
  deletionPubkey,
992
1411
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
993
1412
  event
@@ -996,6 +1415,7 @@ export class PrivateMessenger {
996
1415
 
997
1416
  async publishSeederPresence (channelPubkey = this.defaultChannelPubkey()) {
998
1417
  const channel = this.requireWritableChannel(channelPubkey)
1418
+ if (!this.offlineRecoverySecondsFor(channel)) return null
999
1419
  const receiverPubkeys = uniq([...this.knownSeeders(channelPubkey), this.userPubkey])
1000
1420
  const routing = await this.resolveSendRouting({ channel, receiverPubkeys })
1001
1421
  this.debugSend('yell', channelPubkey, { code: SEEDER_PRESENCE_CODE, receiverPubkeys })
@@ -1006,7 +1426,7 @@ export class PrivateMessenger {
1006
1426
  privateChannelReaderPubkey: channel.readerPubkey,
1007
1427
  receiverPubkeys,
1008
1428
  ...routing,
1009
- expirationSeconds: this.offlineRecoverySeconds,
1429
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
1010
1430
  temporaryStorageArea: this.temporaryStorageArea,
1011
1431
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
1012
1432
  code: SEEDER_PRESENCE_CODE,
@@ -1016,6 +1436,7 @@ export class PrivateMessenger {
1016
1436
  }
1017
1437
 
1018
1438
  async startPresencePublisher (channelPubkey) {
1439
+ if (!this.offlineRecoverySecondsFor(channelPubkey)) return
1019
1440
  if (this.presenceTimers.has(channelPubkey)) return
1020
1441
  try {
1021
1442
  await this.publishSeederPresence(channelPubkey)
@@ -1040,10 +1461,10 @@ export class PrivateMessenger {
1040
1461
  async reconcilePresencePublishers () {
1041
1462
  const starts = []
1042
1463
  for (const pubkey of [...this.presenceTimers.keys()]) {
1043
- if (!storesRecoverySeeds(this.channels.get(pubkey)?.mode)) this.stopPresencePublisher(pubkey)
1464
+ if (!storesRecoverySeeds(this.channels.get(pubkey)?.mode) || !this.offlineRecoverySecondsFor(pubkey)) this.stopPresencePublisher(pubkey)
1044
1465
  }
1045
1466
  for (const [pubkey, channel] of this.channels) {
1046
- if (storesRecoverySeeds(channel.mode)) starts.push(this.startPresencePublisher(pubkey))
1467
+ if (storesRecoverySeeds(channel.mode) && this.offlineRecoverySecondsFor(channel)) starts.push(this.startPresencePublisher(pubkey))
1047
1468
  else this.stopPresencePublisher(pubkey)
1048
1469
  }
1049
1470
  await Promise.all(starts)
@@ -1077,6 +1498,66 @@ export class PrivateMessenger {
1077
1498
  return channel.autoDeletionCapability ?? this.autoDeletionCapability
1078
1499
  }
1079
1500
 
1501
+ requestedOfflineRecoverySecondsFor (channelOrPubkey) {
1502
+ const channel = typeof channelOrPubkey === 'string'
1503
+ ? this.channels.get(channelOrPubkey)
1504
+ : channelOrPubkey
1505
+ if (channel?.offlineRecoverySeconds !== undefined) return channel.offlineRecoverySeconds
1506
+ const pubkey = typeof channelOrPubkey === 'string' ? channelOrPubkey : channelOrPubkey?.pubkey
1507
+ const persisted = pubkey ? this.state.channels[pubkey]?.offlineRecoverySeconds : undefined
1508
+ return persisted === undefined
1509
+ ? this.offlineRecoverySeconds
1510
+ : normalizeOfflineRecoverySeconds(persisted)
1511
+ }
1512
+
1513
+ offlineRecoverySecondsFor (channelOrPubkey) {
1514
+ return Math.min(
1515
+ this.requestedOfflineRecoverySecondsFor(channelOrPubkey),
1516
+ this.staleChannelSeconds,
1517
+ this.identityStorageRetentionSeconds
1518
+ )
1519
+ }
1520
+
1521
+ staleChannelSecondsForCleanup () {
1522
+ return Math.min(this.staleChannelSeconds, this.identityStorageRetentionSeconds)
1523
+ }
1524
+
1525
+ eventExpirationSecondsFor (channel) {
1526
+ return this.offlineRecoverySecondsFor(channel) || privateChannel.EXPIRATION_SECONDS
1527
+ }
1528
+
1529
+ receivedChunkTtlMsFor (channel) {
1530
+ const seconds = this.offlineRecoverySecondsFor(channel)
1531
+ return seconds ? seconds * 1000 : DEFAULT_RECEIVED_CHUNK_TTL_MS
1532
+ }
1533
+
1534
+ async applyRecoveryPolicies (channels) {
1535
+ return this.runQueueOperation(async () => {
1536
+ const state = this.readState()
1537
+ const now = nowSeconds()
1538
+ for (const channel of channels) {
1539
+ const current = state.channels[channel.pubkey] || {}
1540
+ const requestedSeconds = channel.offlineRecoverySeconds
1541
+ const effectiveSeconds = this.offlineRecoverySecondsFor(channel)
1542
+ current.offlineRecoverySeconds = requestedSeconds
1543
+ if (!effectiveSeconds) {
1544
+ delete current.openOfflineStart
1545
+ current.offlineRanges = []
1546
+ } else {
1547
+ const cutoff = now - effectiveSeconds
1548
+ current.offlineRanges = mergeRanges((current.offlineRanges || [])
1549
+ .filter(range => range.end >= cutoff)
1550
+ .map(range => ({ ...range, start: Math.max(range.start, cutoff) })))
1551
+ if (current.openOfflineStart) current.openOfflineStart = Math.max(current.openOfflineStart, cutoff)
1552
+ }
1553
+ state.channels[channel.pubkey] = current
1554
+ }
1555
+ this.writeState(state)
1556
+ await this.flushStateWrites()
1557
+ for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
1558
+ })
1559
+ }
1560
+
1080
1561
  requireNymSigner (channel, override) {
1081
1562
  const signer = override || channel?.nymSigner || this.nymSigner
1082
1563
  if (!signer?.getPublicKey) throw new Error('NYM_SIGNER_REQUIRED')
@@ -1088,6 +1569,7 @@ export class PrivateMessenger {
1088
1569
  }
1089
1570
 
1090
1571
  scheduleReloadGap (pubkey) {
1572
+ if (!this.offlineRecoverySecondsFor(pubkey)) return
1091
1573
  const current = this.readState().channels[pubkey]
1092
1574
  const start = current?.openOfflineStart || current?.lastSeenAt
1093
1575
  if (!start) return
@@ -1117,6 +1599,7 @@ export class PrivateMessenger {
1117
1599
  const state = this.readState()
1118
1600
  const start = Math.max(0, nowSeconds() - this.offlineSkewSeconds)
1119
1601
  for (const pubkey of this.channels.keys()) {
1602
+ if (!this.offlineRecoverySecondsFor(pubkey)) continue
1120
1603
  const current = state.channels[pubkey] || {}
1121
1604
  current.openOfflineStart ||= start
1122
1605
  state.channels[pubkey] = current
@@ -1139,6 +1622,7 @@ export class PrivateMessenger {
1139
1622
  }
1140
1623
 
1141
1624
  async askSeedersForMissingRange (channelPubkey, since, until) {
1625
+ if (!this.offlineRecoverySecondsFor(channelPubkey)) return []
1142
1626
  if (!this.channels.get(channelPubkey)?.signer) return []
1143
1627
  const seeders = this.recoverySeeders(channelPubkey)
1144
1628
  if (!seeders.length || until < since) return []
@@ -1175,11 +1659,14 @@ export class PrivateMessenger {
1175
1659
  question: message.event,
1176
1660
  receiverPubkey: message.event?.pubkey,
1177
1661
  since,
1178
- until
1662
+ until,
1663
+ sendEmptyReply: !this.offlineRecoverySecondsFor(channelPubkey)
1179
1664
  })
1180
1665
 
1181
- for await (const seed of this.seedQueue.storedItemsBy('byChannel', channelPubkey)) {
1182
- await packer.update(seed)
1666
+ if (this.offlineRecoverySecondsFor(channelPubkey)) {
1667
+ for await (const seed of this.seedQueue.storedItemsBy('byChannel', channelPubkey)) {
1668
+ await packer.update(seed)
1669
+ }
1183
1670
  }
1184
1671
  await packer.finalize()
1185
1672
  }
@@ -1263,12 +1750,14 @@ export class PrivateMessenger {
1263
1750
  async recoverOfflineRanges (channels = [...this.stopByChannel.keys()]) {
1264
1751
  const state = this.readState()
1265
1752
  const now = nowSeconds()
1266
- const minStart = now - this.offlineRecoverySeconds
1267
1753
 
1268
1754
  for (const pubkey of uniq(channels)) {
1269
1755
  const channel = this.channels.get(pubkey)
1270
1756
  const current = state.channels[pubkey]
1271
1757
  if (!channel || !current?.offlineRanges?.length) continue
1758
+ const recoverySeconds = this.offlineRecoverySecondsFor(channel)
1759
+ if (!recoverySeconds) continue
1760
+ const minStart = now - recoverySeconds
1272
1761
 
1273
1762
  const remaining = []
1274
1763
  for (const range of current.offlineRanges) {
@@ -1288,7 +1777,7 @@ export class PrivateMessenger {
1288
1777
  until: range.end,
1289
1778
  mode: channel.mode,
1290
1779
  modeByPubkey: { [pubkey]: channel.mode },
1291
- receivedChunkTtlMs: this.offlineRecoverySeconds * 1000,
1780
+ receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
1292
1781
  receivedChunkIndexedDB: this._indexedDB,
1293
1782
  onEvent: (event, outer, meta) => this.queueIncoming(() => this.enqueueRumor(eventType(event), pubkey, { event, outer, meta, payload: parseEventContent(event) })),
1294
1783
  onNymEvent: (event, outer, meta) => this.queueIncoming(() => this.enqueueRumor('nym', pubkey, { event, outer, meta, payload: parseEventContent(event) })),
@@ -1313,13 +1802,14 @@ export class PrivateMessenger {
1313
1802
 
1314
1803
  async clearChannel (pubkey) {
1315
1804
  return this.runQueueOperation(async () => {
1316
- this.unwatch(pubkey)
1317
- this._privateMessage.clearChannelState?.(pubkey)
1805
+ await this.unwatch(pubkey)
1806
+ await this._privateMessage.clearChannelState?.(pubkey)
1318
1807
  this.channels.delete(pubkey)
1319
1808
  this.removeChannelState(pubkey)
1320
1809
  await this.flushStateWrites()
1321
1810
  await this.queue.removeBy('byChannel', pubkey)
1322
1811
  await this.seedQueue.removeBy('byChannel', pubkey)
1812
+ await this.touchStorageActivity({ force: true })
1323
1813
  this.ensureRelayListWatcher()
1324
1814
  })
1325
1815
  }
@@ -1328,42 +1818,64 @@ export class PrivateMessenger {
1328
1818
  return this.runQueueOperation(() => this.queue.clear())
1329
1819
  }
1330
1820
 
1331
- async cleanupStaleChannels () {
1821
+ async cleanupStaleChannels ({ storageSnapshot } = {}) {
1332
1822
  if (!this.prefix) return
1823
+ storageSnapshot ||= await this.readStoragePolicySnapshot()
1824
+ if (!storageSnapshot) return
1825
+ const activeChannelPubkeys = new Set(storageSnapshot.activeChannelPubkeys || [])
1333
1826
  return this.runQueueOperation(async () => {
1334
- const state = this.readState()
1335
- const cutoff = nowSeconds() - this.staleChannelSeconds
1827
+ await this.flushStateWrites()
1828
+ const state = { channels: await this.stateStore.load() }
1829
+ const cutoff = nowSeconds() - this.staleChannelSecondsForCleanup()
1830
+ const stalePubkeys = []
1336
1831
  for (const [pubkey, channel] of Object.entries(state.channels)) {
1832
+ if (activeChannelPubkeys.has(pubkey)) continue
1337
1833
  if ((channel.lastWatchedAt || 0) >= cutoff) continue
1338
1834
  delete state.channels[pubkey]
1835
+ stalePubkeys.push(pubkey)
1339
1836
  await this.queue?.removeBy('byChannel', pubkey)
1340
1837
  await this.seedQueue?.removeBy('byChannel', pubkey)
1341
1838
  }
1342
- this.writeState(state)
1343
- await this.flushStateWrites()
1839
+ this.state = state
1840
+ if (stalePubkeys.length) await this.removeChannelStates(stalePubkeys)
1344
1841
  })
1345
1842
  }
1346
1843
 
1347
1844
  async pruneStoredSeeds (channelPubkey) {
1348
1845
  if (!this.seedQueue) return
1349
- const cutoff = nowSeconds() - this.offlineRecoverySeconds
1350
1846
  const keyRange = globalThis.IDBKeyRange
1351
- if (channelPubkey && keyRange?.bound) {
1352
- await this.seedQueue.removeBy('byChannelTime', keyRange.bound([channelPubkey, 0], [channelPubkey, cutoff], false, true))
1847
+ if (!channelPubkey) {
1848
+ const pubkeys = new Set([...Object.keys(this.state.channels), ...this.channels.keys()])
1849
+ for (const pubkey of pubkeys) await this.pruneStoredSeeds(pubkey)
1850
+ await this.seedQueue.removeWhere(item => !pubkeys.has(item.channelPubkey))
1353
1851
  return
1354
1852
  }
1355
- if (!channelPubkey && keyRange?.upperBound) {
1356
- await this.seedQueue.removeBy('byTime', keyRange.upperBound(cutoff, true))
1853
+ const recoverySeconds = this.offlineRecoverySecondsFor(channelPubkey)
1854
+ if (!recoverySeconds) {
1855
+ await this.seedQueue.removeBy('byChannel', channelPubkey)
1856
+ return
1857
+ }
1858
+ const cutoff = nowSeconds() - recoverySeconds
1859
+ if (cutoff <= 0) return
1860
+ if (keyRange?.bound) {
1861
+ await this.seedQueue.removeBy('byChannelTime', keyRange.bound([channelPubkey, 0], [channelPubkey, cutoff], false, true))
1357
1862
  return
1358
1863
  }
1359
1864
  await this.seedQueue.removeWhere(item => {
1360
- if (channelPubkey && item.channelPubkey !== channelPubkey) return false
1865
+ if (item.channelPubkey !== channelPubkey) return false
1361
1866
  return (seedRecordTime(item) || item.receivedAt || 0) < cutoff
1362
1867
  })
1363
1868
  }
1364
1869
 
1365
1870
  close () {
1366
- this.unwatch()
1871
+ if (this.closePromise) return this.closePromise
1872
+ const initSettledPromise = this.initSettledPromise
1873
+ let unwatchPromise
1874
+ try {
1875
+ unwatchPromise = Promise.resolve(this.unwatch())
1876
+ } catch (err) {
1877
+ unwatchPromise = Promise.reject(err)
1878
+ }
1367
1879
  for (const pubkey of [...this.presenceTimers.keys()]) this.stopPresencePublisher(pubkey)
1368
1880
  this.stopRelayListWatcher?.()
1369
1881
  this.stopRelayListWatcher = null
@@ -1372,6 +1884,41 @@ export class PrivateMessenger {
1372
1884
  this.stopOnline?.()
1373
1885
  this.stopOffline = null
1374
1886
  this.stopOnline = null
1887
+ this.stopStorageMaintenance()
1888
+ this.stopStoragePolicyBroadcast()
1889
+
1890
+ this.closePromise = (async () => {
1891
+ let unwatchError
1892
+ try { await unwatchPromise } catch (err) { unwatchError = err }
1893
+ await initSettledPromise
1894
+ await this.stampActiveChannelActivity()
1895
+ await this.queueOperationTail
1896
+ await this.stateWriteTail
1897
+ try { await this.storageTouchPromise } catch {}
1898
+ await this.storageMaintenancePromise
1899
+ try { await this.storagePolicyRefreshTail } catch {}
1900
+ await Promise.all([
1901
+ this.queue?.close?.(),
1902
+ this.seedQueue?.close?.(),
1903
+ this.stateStore?.close?.()
1904
+ ])
1905
+ if (this.storageActive) {
1906
+ await releasePrivateMessengerStorage({
1907
+ userPubkey: this.userPubkey,
1908
+ leaseId: this.storageLeaseId,
1909
+ indexedDB: this._indexedDB
1910
+ })
1911
+ }
1912
+ this.storageActive = false
1913
+ if (this.identityStorageRetentionSeconds === 0) {
1914
+ await PrivateMessenger.maintainStorage({
1915
+ indexedDB: this._indexedDB,
1916
+ temporaryStorageArea: this.temporaryStorageArea
1917
+ })
1918
+ }
1919
+ if (unwatchError) throw unwatchError
1920
+ })()
1921
+ return this.closePromise
1375
1922
  }
1376
1923
  }
1377
1924
 
@@ -1452,11 +1999,9 @@ function nymCarrierSeedKey (record) {
1452
1999
 
1453
2000
  function withoutQueueMetadata (item) {
1454
2001
  if (!item) return null
1455
- const {
1456
- [SEED_KEY]: ignoredSeedKey,
1457
- [SEED_TIME]: ignoredSeedTime,
1458
- ...value
1459
- } = item
2002
+ const value = { ...item }
2003
+ delete value[SEED_KEY]
2004
+ delete value[SEED_TIME]
1460
2005
  return value
1461
2006
  }
1462
2007