libp2r2p 0.7.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.
package/README.md CHANGED
@@ -21,6 +21,8 @@ const messenger = await createPrivateMessenger({
21
21
  userSigner,
22
22
  contentKeySigner,
23
23
  offlineRecoverySeconds: 7 * 24 * 60 * 60,
24
+ staleChannelSeconds: 45 * 24 * 60 * 60,
25
+ identityStorageRetentionSeconds: 60 * 24 * 60 * 60,
24
26
  channels: [{
25
27
  signer: privateChannelSigner,
26
28
  relays: ['wss://relay.example'],
@@ -152,21 +154,42 @@ know database names or enumerate IndexedDB. Pass `temporaryStorageArea` only
152
154
  when the messenger was configured to use a Storage area other than the default
153
155
  `sessionStorage`.
154
156
 
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.
157
+ Each principal signer owns an internal storage set containing message,
158
+ recovery-seed, and channel-state databases. The messenger updates its activity
159
+ lease while it is open and closes all handles in `await messenger.close()`.
160
+ The complete set is removed after `identityStorageRetentionSeconds` without
161
+ use (60 days by default), including messages that were never consumed.
159
162
  Maintenance runs on every initialization and every six hours while a messenger
160
163
  is active; failed deletions remain journaled and retry automatically.
161
164
 
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.
165
+ Channel recovery state inside an otherwise active identity uses the separate
166
+ `staleChannelSeconds` cleanup policy (45 days by default). Active instances
167
+ record the channels they administer, so a channel remains protected while any
168
+ instance or tab still uses it. Offline recovery defaults to seven days.
164
169
  Set `offlineRecoverySeconds` on an individual channel to override the
165
170
  messenger default for its recovery seeds, offline ranges, new outer-event
166
171
  expiration, and new incomplete receive groups. Updating a channel applies a
167
172
  shorter window immediately to its stored seeds and ranges; increasing it does
168
173
  not recreate data already removed.
169
174
 
175
+ The effective recovery duration is capped by both `staleChannelSeconds` and
176
+ `identityStorageRetentionSeconds`. The requested per-channel value remains
177
+ stored separately, so raising a cap affects new retention decisions without
178
+ recreating data already removed. Both retention policies can be changed by an
179
+ instance at runtime; omitted fields retain their current persisted values:
180
+
181
+ ```js
182
+ await messenger.update({
183
+ staleChannelSeconds: 30 * 24 * 60 * 60,
184
+ identityStorageRetentionSeconds: 90 * 24 * 60 * 60
185
+ })
186
+ ```
187
+
188
+ The policies are persisted per principal identity. If multiple instances use
189
+ the same identity, the last confirmed policy update wins and is propagated to
190
+ the others. A zero policy disables durable recovery immediately. Identity
191
+ storage itself remains protected until the final active lease closes.
192
+
170
193
  Set a channel's `offlineRecoverySeconds` to `0` to disable durable recovery for
171
194
  that channel. The messenger then stores no recovery seeds, tracks no offline
172
195
  ranges, contacts no seeders, publishes no seeder presence, and uses no recovery
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -3,6 +3,8 @@
3
3
  // userSigner,
4
4
  // contentKeySigner, // optional when userSigner handles content keys internally
5
5
  // nymSigner: optionalDefaultNymSigner,
6
+ // staleChannelSeconds: optionalStaleChannelRetention,
7
+ // identityStorageRetentionSeconds: optionalIdentityStorageRetention,
6
8
  // channels: [{ signer: privateChannelSigner, relays, mode: 'leecher', seeders: optionalSeederPubkeys, offlineRecoverySeconds: optionalChannelOverride }],
7
9
  // onContentKeyChange: event => reviewContentKeyUse(event),
8
10
  // onError: err => reportPrivateMessengerError(err)
@@ -30,7 +32,7 @@
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
34
  // - Recovery defaults to 7 days and can be overridden per channel; zero disables durable recovery.
33
- // - Channel state not watched for 45 days is pruned.
35
+ // - Channel state not actively leased or watched within the configured stale window is pruned.
34
36
  // - Seeders announce presence every 10min and are used for the relay-uncovered left edge of a missed range.
35
37
  // - Configured seeders are all asked; auto-discovered seeders are capped to the 8 most recently active.
36
38
  // - Seeder/watchtower channels store reconstructed router events in a separate IndexedDB queue and auto-reply to recovery asks.
@@ -47,9 +49,11 @@ import { createChannelStateStore } from './services/channel-state.js'
47
49
  import { DEFAULT_STALE_CHANNEL_SECONDS } from './constants/index.js'
48
50
  import {
49
51
  activatePrivateMessengerStorage,
52
+ DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS,
50
53
  maintainPrivateMessengerStorage,
51
54
  PRIVATE_MESSENGER_STORAGE_HEARTBEAT_MS,
52
55
  PRIVATE_MESSENGER_STORAGE_MAINTENANCE_MS,
56
+ readPrivateMessengerStorage,
53
57
  releasePrivateMessengerStorage
54
58
  } from './services/storage-maintenance.js'
55
59
  import {
@@ -66,6 +70,7 @@ import {
66
70
  } from './recovery/index.js'
67
71
 
68
72
  export { DEFAULT_STALE_CHANNEL_SECONDS } from './constants/index.js'
73
+ export { DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS } from './services/storage-maintenance.js'
69
74
  export {
70
75
  compactSeedNymCarriers,
71
76
  compactSeedRouterRows,
@@ -81,6 +86,7 @@ export {
81
86
 
82
87
  const DEFAULT_OFFLINE_RECOVERY_SECONDS = 7 * 24 * 60 * 60
83
88
  const MAX_OFFLINE_RECOVERY_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1000)
89
+ const STORAGE_POLICY_BROADCAST_CHANNEL = 'libp2r2p:private-messenger:storage-policy'
84
90
  const DEFAULT_OFFLINE_SKEW_SECONDS = 30
85
91
  const DEFAULT_RELOAD_GAP_DELAY_MS = 500
86
92
  const DEFAULT_SEEDER_PRESENCE_INTERVAL_MS = 10 * 60 * 1000
@@ -125,6 +131,20 @@ function normalizeOfflineRecoverySeconds (value) {
125
131
  return value
126
132
  }
127
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
+
128
148
  function uniq (values) {
129
149
  return [...new Set((values || []).filter(Boolean))]
130
150
  }
@@ -142,6 +162,10 @@ function parseJson (raw, fallback) {
142
162
  try { return JSON.parse(raw || '') } catch { return fallback }
143
163
  }
144
164
 
165
+ function sameStateValue (left, right) {
166
+ return JSON.stringify(left) === JSON.stringify(right)
167
+ }
168
+
145
169
  function storesRecoverySeeds (mode) {
146
170
  return mode === 'seeder' || mode === 'watchtower'
147
171
  }
@@ -164,6 +188,7 @@ export class PrivateMessenger {
164
188
  constructor ({
165
189
  offlineRecoverySeconds = DEFAULT_OFFLINE_RECOVERY_SECONDS,
166
190
  staleChannelSeconds = DEFAULT_STALE_CHANNEL_SECONDS,
191
+ identityStorageRetentionSeconds = DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS,
167
192
  offlineSkewSeconds = DEFAULT_OFFLINE_SKEW_SECONDS,
168
193
  reloadGapDelayMs = DEFAULT_RELOAD_GAP_DELAY_MS,
169
194
  seederPresenceIntervalMs = DEFAULT_SEEDER_PRESENCE_INTERVAL_MS,
@@ -188,10 +213,12 @@ export class PrivateMessenger {
188
213
  _setInterval = globalThis.setInterval.bind(globalThis),
189
214
  _clearInterval = globalThis.clearInterval.bind(globalThis),
190
215
  _storageSetInterval = globalThis.setInterval.bind(globalThis),
191
- _storageClearInterval = globalThis.clearInterval.bind(globalThis)
216
+ _storageClearInterval = globalThis.clearInterval.bind(globalThis),
217
+ _BroadcastChannel = _indexedDB === globalThis.indexedDB ? globalThis.BroadcastChannel : undefined
192
218
  } = {}) {
193
219
  this.offlineRecoverySeconds = normalizeOfflineRecoverySeconds(offlineRecoverySeconds)
194
- this.staleChannelSeconds = staleChannelSeconds
220
+ this.staleChannelSeconds = normalizeStaleChannelSeconds(staleChannelSeconds)
221
+ this.identityStorageRetentionSeconds = normalizeIdentityStorageRetentionSeconds(identityStorageRetentionSeconds)
195
222
  this.offlineSkewSeconds = offlineSkewSeconds
196
223
  this.reloadGapDelayMs = reloadGapDelayMs
197
224
  this.seederPresenceIntervalMs = seederPresenceIntervalMs
@@ -217,6 +244,7 @@ export class PrivateMessenger {
217
244
  this._clearInterval = _clearInterval
218
245
  this._storageSetInterval = _storageSetInterval
219
246
  this._storageClearInterval = _storageClearInterval
247
+ this._BroadcastChannel = _BroadcastChannel
220
248
 
221
249
  this.userSigner = null
222
250
  this.contentKeySigner = null
@@ -245,6 +273,10 @@ export class PrivateMessenger {
245
273
  this.storageHeartbeatTimer = null
246
274
  this.storageMaintenanceTimer = null
247
275
  this.storageMaintenancePromise = null
276
+ this.storagePolicyRevision = 0
277
+ this.storagePolicyNeedsApply = false
278
+ this.storagePolicyBroadcast = null
279
+ this.storagePolicyRefreshTail = Promise.resolve()
248
280
  this.closePromise = null
249
281
  this.initSettledPromise = null
250
282
  this.initialized = false
@@ -264,13 +296,20 @@ export class PrivateMessenger {
264
296
  this.nymSigner = nymSigner || null
265
297
  this.userPubkey = await userSigner.getPublicKey()
266
298
  this.prefix = `libp2r2p:private-messenger:${this.userPubkey}`
267
- await activatePrivateMessengerStorage({
299
+ const storageSnapshot = await activatePrivateMessengerStorage({
268
300
  userPubkey: this.userPubkey,
269
301
  leaseId: this.storageLeaseId,
302
+ activeChannelPubkeys: [],
303
+ storagePolicy: {
304
+ staleChannelSeconds: this.staleChannelSeconds,
305
+ identityStorageRetentionSeconds: this.identityStorageRetentionSeconds
306
+ },
270
307
  indexedDB: this._indexedDB
271
308
  })
309
+ this.applyStoragePolicySnapshot(storageSnapshot)
272
310
  this.storageActive = true
273
311
  this.lastStorageTouch = Date.now()
312
+ this.startStoragePolicyBroadcast()
274
313
  this.assertOpen()
275
314
  await PrivateMessenger.maintainStorage({
276
315
  indexedDB: this._indexedDB,
@@ -302,13 +341,15 @@ export class PrivateMessenger {
302
341
  this.assertOpen()
303
342
  this.startStorageMaintenance()
304
343
  this.state = { channels: await this.stateStore.load() }
305
- await this.cleanupStaleChannels()
306
344
  await this.update({ userSigner, contentKeySigner, nymSigner: this.nymSigner, channels, relays, mode })
307
345
  await this.pruneStoredSeeds()
308
346
  this.initialized = true
347
+ await this.refreshAndApplyStoragePolicy()
348
+ this.broadcastStoragePolicyChange()
309
349
  return this
310
350
  } catch (err) {
311
351
  this.stopStorageMaintenance()
352
+ this.stopStoragePolicyBroadcast()
312
353
  try { await this.queueOperationTail } catch {}
313
354
  try { await this.stateWriteTail } catch {}
314
355
  await Promise.allSettled([
@@ -338,10 +379,102 @@ export class PrivateMessenger {
338
379
  if (this.closePromise) throw new Error('PRIVATE_MESSENGER_CLOSED')
339
380
  }
340
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,
438
+ indexedDB: this._indexedDB
439
+ })
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()
469
+ })
470
+ this.storagePolicyRefreshTail = refresh
471
+ return refresh
472
+ }
473
+
341
474
  startStorageMaintenance () {
342
475
  if (this.storageHeartbeatTimer || this.storageMaintenanceTimer) return
343
476
  this.storageHeartbeatTimer = this._storageSetInterval(() => (
344
- this.touchStorageActivity({ force: true }).catch(err => {
477
+ this.runStorageHeartbeat().catch(err => {
345
478
  try { this.onError?.(err) } catch {}
346
479
  })
347
480
  ), PRIVATE_MESSENGER_STORAGE_HEARTBEAT_MS)
@@ -362,13 +495,15 @@ export class PrivateMessenger {
362
495
 
363
496
  runStorageMaintenance () {
364
497
  if (this.storageMaintenancePromise) return this.storageMaintenancePromise
365
- const maintenance = Promise.all([
366
- PrivateMessenger.maintainStorage({
498
+ const maintenance = (async () => {
499
+ await PrivateMessenger.maintainStorage({
367
500
  indexedDB: this._indexedDB,
368
501
  temporaryStorageArea: this.temporaryStorageArea
369
- }),
370
- this.pruneStoredSeeds()
371
- ]).catch(err => {
502
+ })
503
+ await this.refreshAndApplyStoragePolicy()
504
+ await this.cleanupStaleChannels()
505
+ await this.pruneStoredSeeds()
506
+ })().catch(err => {
372
507
  try { this.onError?.(err) } catch {}
373
508
  }).finally(() => {
374
509
  if (this.storageMaintenancePromise === maintenance) this.storageMaintenancePromise = null
@@ -377,6 +512,12 @@ export class PrivateMessenger {
377
512
  return maintenance
378
513
  }
379
514
 
515
+ async runStorageHeartbeat () {
516
+ await this.stampActiveChannelActivity()
517
+ await this.touchStorageActivity({ force: true })
518
+ await this.applyPendingStoragePolicy()
519
+ }
520
+
380
521
  async touchStorageActivity ({ force = false } = {}) {
381
522
  if (!this.storageActive || this.closePromise) return false
382
523
  if (this.storageTouchPromise) return this.storageTouchPromise
@@ -387,9 +528,13 @@ export class PrivateMessenger {
387
528
  const touch = activatePrivateMessengerStorage({
388
529
  userPubkey: this.userPubkey,
389
530
  leaseId: this.storageLeaseId,
531
+ activeChannelPubkeys: [...this.channels.keys()],
390
532
  indexedDB: this._indexedDB,
391
533
  now
392
- }).then(() => true, err => {
534
+ }).then(snapshot => {
535
+ this.applyStoragePolicySnapshot(snapshot)
536
+ return true
537
+ }, err => {
393
538
  this.lastStorageTouch = previousTouch
394
539
  throw err
395
540
  }).finally(() => {
@@ -435,8 +580,24 @@ export class PrivateMessenger {
435
580
  })
436
581
  }
437
582
 
438
- async update ({ userSigner = this.userSigner, contentKeySigner = this.contentKeySigner, nymSigner = this.nymSigner, channels = [...this.channels.values()], relays = [], mode = 'leecher' } = {}) {
583
+ async update (options = {}) {
439
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
440
601
  if (userSigner) {
441
602
  const userPubkey = await userSigner.getPublicKey?.()
442
603
  if (!userPubkey) throw new Error('USER_SIGNER_REQUIRED')
@@ -449,19 +610,48 @@ export class PrivateMessenger {
449
610
  const nextChannels = await this.normalizeChannels(channels, { relays, mode })
450
611
  this.assertOpen()
451
612
  const nextPubkeys = new Set(nextChannels.map(channel => channel.pubkey))
452
-
453
- for (const pubkey of [...this.channels.keys()]) {
454
- if (!nextPubkeys.has(pubkey)) {
455
- await this.unwatch(pubkey)
456
- 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
457
621
  }
458
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()
459
646
  for (const channel of nextChannels) this.channels.set(channel.pubkey, channel)
460
647
 
461
- await this.cleanupStaleChannels()
648
+ await this.cleanupStaleChannels({ storageSnapshot })
462
649
  await this.applyRecoveryPolicies(nextChannels)
463
650
  await this.watch()
464
651
  await this.reconcilePresencePublishers()
652
+ if (this.storagePolicyRevision === storageSnapshot.policyRevision) {
653
+ this.storagePolicyNeedsApply = false
654
+ }
465
655
  return this
466
656
  }
467
657
 
@@ -551,14 +741,19 @@ export class PrivateMessenger {
551
741
  return structuredClone(this.state)
552
742
  }
553
743
 
554
- writeState (state) {
555
- this.touchStorageActivity().catch(err => this.onError?.(err))
744
+ writeState (state, { touchStorage = true } = {}) {
745
+ if (touchStorage) this.touchStorageActivity().catch(err => this.onError?.(err))
746
+ const previous = this.state?.channels || {}
556
747
  const next = {
557
748
  channels: isPlainObject(state?.channels) ? structuredClone(state.channels) : {}
558
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))
559
753
  this.state = next
560
- const snapshot = structuredClone(next.channels)
561
- 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))
562
757
  this.stateWriteTail = write.catch(err => {
563
758
  try { this.onError?.(err) } catch {}
564
759
  })
@@ -569,6 +764,29 @@ export class PrivateMessenger {
569
764
  await this.stateWriteTail
570
765
  }
571
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
+
572
790
  updateChannelState (pubkey, patch) {
573
791
  const state = this.readState()
574
792
  const current = state.channels[pubkey] || {}
@@ -578,9 +796,19 @@ export class PrivateMessenger {
578
796
  }
579
797
 
580
798
  removeChannelState (pubkey) {
581
- const state = this.readState()
582
- delete state.channels[pubkey]
583
- 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
584
812
  }
585
813
 
586
814
  markSeen (pubkey, createdAt = nowSeconds()) {
@@ -1270,7 +1498,7 @@ export class PrivateMessenger {
1270
1498
  return channel.autoDeletionCapability ?? this.autoDeletionCapability
1271
1499
  }
1272
1500
 
1273
- offlineRecoverySecondsFor (channelOrPubkey) {
1501
+ requestedOfflineRecoverySecondsFor (channelOrPubkey) {
1274
1502
  const channel = typeof channelOrPubkey === 'string'
1275
1503
  ? this.channels.get(channelOrPubkey)
1276
1504
  : channelOrPubkey
@@ -1282,6 +1510,18 @@ export class PrivateMessenger {
1282
1510
  : normalizeOfflineRecoverySeconds(persisted)
1283
1511
  }
1284
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
+
1285
1525
  eventExpirationSecondsFor (channel) {
1286
1526
  return this.offlineRecoverySecondsFor(channel) || privateChannel.EXPIRATION_SECONDS
1287
1527
  }
@@ -1297,13 +1537,14 @@ export class PrivateMessenger {
1297
1537
  const now = nowSeconds()
1298
1538
  for (const channel of channels) {
1299
1539
  const current = state.channels[channel.pubkey] || {}
1300
- const seconds = channel.offlineRecoverySeconds
1301
- current.offlineRecoverySeconds = seconds
1302
- if (!seconds) {
1540
+ const requestedSeconds = channel.offlineRecoverySeconds
1541
+ const effectiveSeconds = this.offlineRecoverySecondsFor(channel)
1542
+ current.offlineRecoverySeconds = requestedSeconds
1543
+ if (!effectiveSeconds) {
1303
1544
  delete current.openOfflineStart
1304
1545
  current.offlineRanges = []
1305
1546
  } else {
1306
- const cutoff = now - seconds
1547
+ const cutoff = now - effectiveSeconds
1307
1548
  current.offlineRanges = mergeRanges((current.offlineRanges || [])
1308
1549
  .filter(range => range.end >= cutoff)
1309
1550
  .map(range => ({ ...range, start: Math.max(range.start, cutoff) })))
@@ -1568,6 +1809,7 @@ export class PrivateMessenger {
1568
1809
  await this.flushStateWrites()
1569
1810
  await this.queue.removeBy('byChannel', pubkey)
1570
1811
  await this.seedQueue.removeBy('byChannel', pubkey)
1812
+ await this.touchStorageActivity({ force: true })
1571
1813
  this.ensureRelayListWatcher()
1572
1814
  })
1573
1815
  }
@@ -1576,19 +1818,26 @@ export class PrivateMessenger {
1576
1818
  return this.runQueueOperation(() => this.queue.clear())
1577
1819
  }
1578
1820
 
1579
- async cleanupStaleChannels () {
1821
+ async cleanupStaleChannels ({ storageSnapshot } = {}) {
1580
1822
  if (!this.prefix) return
1823
+ storageSnapshot ||= await this.readStoragePolicySnapshot()
1824
+ if (!storageSnapshot) return
1825
+ const activeChannelPubkeys = new Set(storageSnapshot.activeChannelPubkeys || [])
1581
1826
  return this.runQueueOperation(async () => {
1582
- const state = this.readState()
1583
- 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 = []
1584
1831
  for (const [pubkey, channel] of Object.entries(state.channels)) {
1832
+ if (activeChannelPubkeys.has(pubkey)) continue
1585
1833
  if ((channel.lastWatchedAt || 0) >= cutoff) continue
1586
1834
  delete state.channels[pubkey]
1835
+ stalePubkeys.push(pubkey)
1587
1836
  await this.queue?.removeBy('byChannel', pubkey)
1588
1837
  await this.seedQueue?.removeBy('byChannel', pubkey)
1589
1838
  }
1590
- this.writeState(state)
1591
- await this.flushStateWrites()
1839
+ this.state = state
1840
+ if (stalePubkeys.length) await this.removeChannelStates(stalePubkeys)
1592
1841
  })
1593
1842
  }
1594
1843
 
@@ -1636,15 +1885,18 @@ export class PrivateMessenger {
1636
1885
  this.stopOffline = null
1637
1886
  this.stopOnline = null
1638
1887
  this.stopStorageMaintenance()
1888
+ this.stopStoragePolicyBroadcast()
1639
1889
 
1640
1890
  this.closePromise = (async () => {
1641
1891
  let unwatchError
1642
1892
  try { await unwatchPromise } catch (err) { unwatchError = err }
1643
1893
  await initSettledPromise
1894
+ await this.stampActiveChannelActivity()
1644
1895
  await this.queueOperationTail
1645
1896
  await this.stateWriteTail
1646
1897
  try { await this.storageTouchPromise } catch {}
1647
1898
  await this.storageMaintenancePromise
1899
+ try { await this.storagePolicyRefreshTail } catch {}
1648
1900
  await Promise.all([
1649
1901
  this.queue?.close?.(),
1650
1902
  this.seedQueue?.close?.(),
@@ -1658,6 +1910,12 @@ export class PrivateMessenger {
1658
1910
  })
1659
1911
  }
1660
1912
  this.storageActive = false
1913
+ if (this.identityStorageRetentionSeconds === 0) {
1914
+ await PrivateMessenger.maintainStorage({
1915
+ indexedDB: this._indexedDB,
1916
+ temporaryStorageArea: this.temporaryStorageArea
1917
+ })
1918
+ }
1661
1919
  if (unwatchError) throw unwatchError
1662
1920
  })()
1663
1921
  return this.closePromise
@@ -11,12 +11,14 @@ database `${prefix}:state:idb`, version 1
11
11
  channels, keyPath "pubkey"
12
12
  pubkey watched channel public key
13
13
  value evolving channel-recovery state, including last-seen/watched times,
14
- mode, relays, seeders, effective offline-recovery retention, offline
14
+ mode, relays, seeders, requested offline-recovery retention, offline
15
15
  ranges, active offline-range start,
16
16
  per-seeder activity, and sent/received content-key usage
17
17
 
18
- The complete channels snapshot is replaced atomically on each persisted state
19
- change; value remains an extensible internal object rather than a public schema.
18
+ Changed channel records are upserted individually and explicit removals are
19
+ applied in the same transaction. This prevents one active instance from
20
+ clearing state owned by another instance of the same principal identity.
21
+ `value` remains an extensible internal object rather than a public schema.
20
22
  */
21
23
 
22
24
  function deferred () {
@@ -108,16 +110,34 @@ export async function createChannelStateStore ({ prefix, indexedDB = globalThis.
108
110
  })
109
111
  }
110
112
 
111
- async function replace (channels) {
113
+ async function update (channels, removedPubkeys = []) {
112
114
  const snapshot = cloneChannels(channels)
115
+ const removals = [...new Set(removedPubkeys || [])]
113
116
  await runTransaction('readwrite', async tx => {
114
- await run('clear', [], CHANNELS_STORE, null, { tx })
117
+ for (const pubkey of removals) await run('delete', [pubkey], CHANNELS_STORE, null, { tx })
115
118
  for (const [pubkey, value] of Object.entries(snapshot)) {
116
119
  await run('put', [{ pubkey, value }], CHANNELS_STORE, null, { tx })
117
120
  }
118
121
  })
119
122
  }
120
123
 
124
+ async function touch (pubkeys, lastWatchedAt) {
125
+ const uniquePubkeys = [...new Set(pubkeys || [])]
126
+ if (!uniquePubkeys.length) return
127
+ await runTransaction('readwrite', async tx => {
128
+ for (const pubkey of uniquePubkeys) {
129
+ const current = (await run('get', [pubkey], CHANNELS_STORE, null, { tx })).result
130
+ await run('put', [{
131
+ pubkey,
132
+ value: {
133
+ ...(current?.value && typeof current.value === 'object' ? current.value : {}),
134
+ lastWatchedAt
135
+ }
136
+ }], CHANNELS_STORE, null, { tx })
137
+ }
138
+ })
139
+ }
140
+
121
141
  function close () {
122
142
  if (closePromise) return closePromise
123
143
  closed = true
@@ -128,5 +148,5 @@ export async function createChannelStateStore ({ prefix, indexedDB = globalThis.
128
148
  return closePromise
129
149
  }
130
150
 
131
- return { load, replace, close }
151
+ return { load, update, touch, close }
132
152
  }
@@ -1,14 +1,16 @@
1
1
  import { run } from '../../idb/index.js'
2
2
  import { cleanupReceivedChunkStorage } from '../../private-channel/services/received-chunks.js'
3
3
  import { cleanupTemporaryStorage } from '../../temporary-storage/index.js'
4
+ import { DEFAULT_STALE_CHANNEL_SECONDS } from '../constants/index.js'
4
5
 
5
6
  const REGISTRY_DATABASE = 'libp2r2p:private-messenger:registry:idb'
6
7
  const REGISTRY_VERSION = 1
7
- const BUNDLES_STORE = 'bundles'
8
+ const STORAGE_SETS_STORE = 'storageSets'
8
9
  const LEASES_STORE = 'leases'
9
10
  const LOCK_NAME = 'libp2r2p:private-messenger:storage-maintenance'
10
11
  const LEASE_MS = 2 * 60 * 60 * 1000 // 2 hours
11
- const RETENTION_MS = 60 * 24 * 60 * 60 * 1000 // 60 days
12
+ const DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS = 60 * 24 * 60 * 60
13
+ const MAX_RETENTION_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1000)
12
14
  const MAX_RETRY_MS = 5 * 60 * 1000
13
15
 
14
16
  const localLocks = new WeakMap()
@@ -19,19 +21,23 @@ IndexedDB schema, shared by every PrivateMessenger instance on this origin:
19
21
 
20
22
  database "libp2r2p:private-messenger:registry:idb", version 1
21
23
 
22
- bundles, keyPath "userPubkey"
24
+ storageSets, keyPath "userPubkey"
23
25
  userPubkey primary signer public key and storage-set coordinate
24
26
  lastUsedAt most recent durable activity in milliseconds
25
27
  leaseUntil active-instance lease expiry in milliseconds
26
28
  status "ready" or "delete_pending"
27
29
  attempts consecutive failed storage-set deletions
28
30
  nextAttemptAt earliest retry time in milliseconds
31
+ staleChannelSeconds channel-state retention requested by the last configuring instance
32
+ identityStorageRetentionSeconds identity database-set retention after its last activity
33
+ policyRevision monotonically increasing storage-policy revision
29
34
 
30
35
  leases, keyPath "key"
31
36
  key `${userPubkey}:${leaseId}`
32
37
  userPubkey principal signer public key
33
38
  leaseId per-PrivateMessenger instance identifier
34
39
  leaseUntil instance lease expiry in milliseconds
40
+ activeChannelPubkeys channels currently administered by that instance
35
41
 
36
42
  leases indexes
37
43
  byUserPubkey userPubkey
@@ -53,8 +59,8 @@ function openRegistry (indexedDB) {
53
59
  request.onblocked = () => reject(new Error('IDB_DATABASE_BLOCKED'))
54
60
  request.onupgradeneeded = () => {
55
61
  const db = request.result
56
- if (!db.objectStoreNames.contains(BUNDLES_STORE)) {
57
- db.createObjectStore(BUNDLES_STORE, { keyPath: 'userPubkey' })
62
+ if (!db.objectStoreNames.contains(STORAGE_SETS_STORE)) {
63
+ db.createObjectStore(STORAGE_SETS_STORE, { keyPath: 'userPubkey' })
58
64
  }
59
65
  if (!db.objectStoreNames.contains(LEASES_STORE)) {
60
66
  const leases = db.createObjectStore(LEASES_STORE, { keyPath: 'key' })
@@ -72,7 +78,7 @@ function openRegistry (indexedDB) {
72
78
  async function withRegistryTransaction (indexedDB, mode, work) {
73
79
  const db = await openRegistry(indexedDB)
74
80
  try {
75
- const tx = db.transaction([BUNDLES_STORE, LEASES_STORE], mode)
81
+ const tx = db.transaction([STORAGE_SETS_STORE, LEASES_STORE], mode)
76
82
  const done = transactionDone(tx)
77
83
  try {
78
84
  const result = await work(tx)
@@ -100,9 +106,20 @@ async function readLeases (tx, userPubkey) {
100
106
  return records.filter(record => record?.userPubkey === userPubkey)
101
107
  }
102
108
 
103
- async function refreshLeaseUntil (tx, bundle, now, { removeExpired = false } = {}) {
104
- const leases = await readLeases(tx, bundle.userPubkey)
109
+ function normalizeRetentionSeconds (value, fallback) {
110
+ return Number.isSafeInteger(value) && value >= 0 && value <= MAX_RETENTION_SECONDS
111
+ ? value
112
+ : fallback
113
+ }
114
+
115
+ function normalizeActiveChannelPubkeys (values) {
116
+ return [...new Set((values || []).filter(value => typeof value === 'string' && value))].sort()
117
+ }
118
+
119
+ async function refreshLeaseState (tx, storageSet, now, { removeExpired = false } = {}) {
120
+ const leases = await readLeases(tx, storageSet.userPubkey)
105
121
  let leaseUntil = 0
122
+ const activeChannelPubkeys = new Set()
106
123
  for (const lease of leases) {
107
124
  const expiresAt = Math.max(0, Number(lease.leaseUntil) || 0)
108
125
  if (expiresAt <= now) {
@@ -110,12 +127,18 @@ async function refreshLeaseUntil (tx, bundle, now, { removeExpired = false } = {
110
127
  continue
111
128
  }
112
129
  leaseUntil = Math.max(leaseUntil, expiresAt)
130
+ for (const pubkey of normalizeActiveChannelPubkeys(lease.activeChannelPubkeys)) {
131
+ activeChannelPubkeys.add(pubkey)
132
+ }
133
+ }
134
+ storageSet.leaseUntil = leaseUntil
135
+ return {
136
+ leaseUntil,
137
+ activeChannelPubkeys: [...activeChannelPubkeys].sort()
113
138
  }
114
- bundle.leaseUntil = leaseUntil
115
- return leaseUntil
116
139
  }
117
140
 
118
- function normalizeBundle (record) {
141
+ function normalizeStorageSet (record) {
119
142
  if (!record?.userPubkey) return null
120
143
  return {
121
144
  userPubkey: String(record.userPubkey),
@@ -123,7 +146,24 @@ function normalizeBundle (record) {
123
146
  leaseUntil: Math.max(0, Number(record.leaseUntil) || 0),
124
147
  status: record.status === 'delete_pending' ? 'delete_pending' : 'ready',
125
148
  attempts: Math.max(0, Math.floor(Number(record.attempts) || 0)),
126
- nextAttemptAt: Math.max(0, Number(record.nextAttemptAt) || 0)
149
+ nextAttemptAt: Math.max(0, Number(record.nextAttemptAt) || 0),
150
+ staleChannelSeconds: normalizeRetentionSeconds(
151
+ record.staleChannelSeconds,
152
+ DEFAULT_STALE_CHANNEL_SECONDS
153
+ ),
154
+ identityStorageRetentionSeconds: normalizeRetentionSeconds(
155
+ record.identityStorageRetentionSeconds,
156
+ DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS
157
+ ),
158
+ policyRevision: Math.max(0, Math.floor(Number(record.policyRevision) || 0))
159
+ }
160
+ }
161
+
162
+ function storagePolicy (storageSet) {
163
+ return {
164
+ staleChannelSeconds: storageSet.staleChannelSeconds,
165
+ identityStorageRetentionSeconds: storageSet.identityStorageRetentionSeconds,
166
+ policyRevision: storageSet.policyRevision
127
167
  }
128
168
  }
129
169
 
@@ -178,23 +218,29 @@ function retryDelay (attempts) {
178
218
  return Math.min(MAX_RETRY_MS, 1000 * (2 ** Math.min(9, Math.max(0, attempts - 1))))
179
219
  }
180
220
 
181
- async function markAndListDueBundles (indexedDB, now) {
221
+ async function markAndListDueStorageSets (indexedDB, now) {
182
222
  return withRegistryTransaction(indexedDB, 'readwrite', async tx => {
183
- const records = (await run('getAll', [], BUNDLES_STORE, null, { tx })).result
223
+ const records = (await run('getAll', [], STORAGE_SETS_STORE, null, { tx })).result
184
224
  const due = []
185
225
  for (const raw of records) {
186
- const bundle = normalizeBundle(raw)
187
- if (!bundle) continue
188
- await refreshLeaseUntil(tx, bundle, now, { removeExpired: true })
189
- const leaseActive = bundle.leaseUntil > now
190
- if (bundle.status === 'ready' && !leaseActive && bundle.lastUsedAt + RETENTION_MS <= now) {
191
- bundle.status = 'delete_pending'
192
- bundle.attempts = 0
193
- bundle.nextAttemptAt = now
226
+ const storageSet = normalizeStorageSet(raw)
227
+ if (!storageSet) continue
228
+ await refreshLeaseState(tx, storageSet, now, { removeExpired: true })
229
+ const leaseActive = storageSet.leaseUntil > now
230
+ const retentionMs = storageSet.identityStorageRetentionSeconds * 1000
231
+ const expired = storageSet.lastUsedAt + retentionMs <= now
232
+ if (leaseActive || !expired) {
233
+ storageSet.status = 'ready'
234
+ storageSet.attempts = 0
235
+ storageSet.nextAttemptAt = 0
236
+ } else if (storageSet.status === 'ready') {
237
+ storageSet.status = 'delete_pending'
238
+ storageSet.attempts = 0
239
+ storageSet.nextAttemptAt = now
194
240
  }
195
- await run('put', [bundle], BUNDLES_STORE, null, { tx })
196
- if (bundle.status === 'delete_pending' && !leaseActive && bundle.nextAttemptAt <= now) {
197
- due.push(bundle)
241
+ await run('put', [storageSet], STORAGE_SETS_STORE, null, { tx })
242
+ if (storageSet.status === 'delete_pending' && !leaseActive && storageSet.nextAttemptAt <= now) {
243
+ due.push(storageSet)
198
244
  }
199
245
  }
200
246
  return due
@@ -203,35 +249,43 @@ async function markAndListDueBundles (indexedDB, now) {
203
249
 
204
250
  async function finishDeleteAttempt (indexedDB, attempted, deleted, now) {
205
251
  return withRegistryTransaction(indexedDB, 'readwrite', async tx => {
206
- const current = normalizeBundle((await run('get', [attempted.userPubkey], BUNDLES_STORE, null, { tx })).result)
207
- if (!current || current.status !== 'delete_pending' || current.leaseUntil > now) return false
252
+ const current = normalizeStorageSet((await run('get', [attempted.userPubkey], STORAGE_SETS_STORE, null, { tx })).result)
253
+ if (!current || current.status !== 'delete_pending') return false
254
+ await refreshLeaseState(tx, current, now, { removeExpired: true })
255
+ if (current.leaseUntil > now) {
256
+ current.status = 'ready'
257
+ current.attempts = 0
258
+ current.nextAttemptAt = 0
259
+ await run('put', [current], STORAGE_SETS_STORE, null, { tx })
260
+ return false
261
+ }
208
262
  if (deleted) {
209
263
  for (const lease of await readLeases(tx, attempted.userPubkey)) {
210
264
  await run('delete', [lease.key], LEASES_STORE, null, { tx })
211
265
  }
212
- await run('delete', [attempted.userPubkey], BUNDLES_STORE, null, { tx })
266
+ await run('delete', [attempted.userPubkey], STORAGE_SETS_STORE, null, { tx })
213
267
  return true
214
268
  }
215
269
  current.attempts++
216
270
  current.nextAttemptAt = now + retryDelay(current.attempts)
217
- await run('put', [current], BUNDLES_STORE, null, { tx })
271
+ await run('put', [current], STORAGE_SETS_STORE, null, { tx })
218
272
  return false
219
273
  })
220
274
  }
221
275
 
222
276
  async function maintainRegistry (indexedDB, now) {
223
- const due = await markAndListDueBundles(indexedDB, now)
224
- for (const bundle of due) {
225
- const deleted = await deleteStorageSet(indexedDB, bundle.userPubkey)
226
- await finishDeleteAttempt(indexedDB, bundle, deleted, now)
277
+ const due = await markAndListDueStorageSets(indexedDB, now)
278
+ for (const storageSet of due) {
279
+ const deleted = await deleteStorageSet(indexedDB, storageSet.userPubkey)
280
+ await finishDeleteAttempt(indexedDB, storageSet, deleted, now)
227
281
  }
228
282
  const nextAttemptAt = await withRegistryTransaction(indexedDB, 'readonly', async tx => {
229
- const records = (await run('getAll', [], BUNDLES_STORE, null, { tx })).result
283
+ const records = (await run('getAll', [], STORAGE_SETS_STORE, null, { tx })).result
230
284
  let earliest = Infinity
231
285
  for (const raw of records) {
232
- const bundle = normalizeBundle(raw)
233
- if (bundle?.status !== 'delete_pending' || bundle.leaseUntil > now) continue
234
- earliest = Math.min(earliest, bundle.nextAttemptAt)
286
+ const storageSet = normalizeStorageSet(raw)
287
+ if (storageSet?.status !== 'delete_pending' || storageSet.leaseUntil > now) continue
288
+ earliest = Math.min(earliest, storageSet.nextAttemptAt)
235
289
  }
236
290
  return Number.isFinite(earliest) ? earliest : null
237
291
  })
@@ -268,6 +322,8 @@ export async function maintainPrivateMessengerStorage ({
268
322
  export function activatePrivateMessengerStorage ({
269
323
  userPubkey,
270
324
  leaseId,
325
+ activeChannelPubkeys,
326
+ storagePolicy: nextPolicy,
271
327
  indexedDB = globalThis.indexedDB,
272
328
  now = Date.now()
273
329
  } = {}) {
@@ -276,22 +332,61 @@ export function activatePrivateMessengerStorage ({
276
332
  return withMaintenanceLock(indexedDB, () => withRegistryTransaction(indexedDB, 'readwrite', async tx => {
277
333
  userPubkey = String(userPubkey)
278
334
  leaseId = String(leaseId)
279
- const current = normalizeBundle((await run('get', [userPubkey], BUNDLES_STORE, null, { tx })).result)
335
+ const current = normalizeStorageSet((await run('get', [userPubkey], STORAGE_SETS_STORE, null, { tx })).result)
336
+ const currentLease = (await run('get', [leaseKey(userPubkey, leaseId)], LEASES_STORE, null, { tx })).result
337
+ const channels = activeChannelPubkeys === undefined
338
+ ? normalizeActiveChannelPubkeys(currentLease?.activeChannelPubkeys)
339
+ : normalizeActiveChannelPubkeys(activeChannelPubkeys)
280
340
  await run('put', [{
281
341
  key: leaseKey(userPubkey, leaseId),
282
342
  userPubkey,
283
343
  leaseId,
284
- leaseUntil: now + LEASE_MS
344
+ leaseUntil: now + LEASE_MS,
345
+ activeChannelPubkeys: channels
285
346
  }], LEASES_STORE, null, { tx })
286
- const bundle = {
347
+ const hasPolicy = nextPolicy !== undefined
348
+ const storageSet = {
287
349
  userPubkey,
288
350
  lastUsedAt: now,
289
351
  leaseUntil: Math.max(current?.leaseUntil || 0, now + LEASE_MS),
290
352
  status: 'ready',
291
353
  attempts: 0,
292
- nextAttemptAt: 0
354
+ nextAttemptAt: 0,
355
+ staleChannelSeconds: hasPolicy
356
+ ? nextPolicy.staleChannelSeconds
357
+ : current?.staleChannelSeconds ?? DEFAULT_STALE_CHANNEL_SECONDS,
358
+ identityStorageRetentionSeconds: hasPolicy
359
+ ? nextPolicy.identityStorageRetentionSeconds
360
+ : current?.identityStorageRetentionSeconds ?? DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS,
361
+ policyRevision: hasPolicy
362
+ ? (current?.policyRevision || 0) + 1
363
+ : current?.policyRevision || 0
364
+ }
365
+ await run('put', [storageSet], STORAGE_SETS_STORE, null, { tx })
366
+ const leaseState = await refreshLeaseState(tx, storageSet, now)
367
+ return {
368
+ ...storagePolicy(storageSet),
369
+ activeChannelPubkeys: leaseState.activeChannelPubkeys
370
+ }
371
+ }))
372
+ }
373
+
374
+ export function readPrivateMessengerStorage ({
375
+ userPubkey,
376
+ indexedDB = globalThis.indexedDB,
377
+ now = Date.now()
378
+ } = {}) {
379
+ if (!userPubkey) return Promise.reject(new Error('USER_PUBKEY_REQUIRED'))
380
+ return withMaintenanceLock(indexedDB, () => withRegistryTransaction(indexedDB, 'readwrite', async tx => {
381
+ userPubkey = String(userPubkey)
382
+ const storageSet = normalizeStorageSet((await run('get', [userPubkey], STORAGE_SETS_STORE, null, { tx })).result)
383
+ if (!storageSet) return null
384
+ const leaseState = await refreshLeaseState(tx, storageSet, now, { removeExpired: true })
385
+ await run('put', [storageSet], STORAGE_SETS_STORE, null, { tx })
386
+ return {
387
+ ...storagePolicy(storageSet),
388
+ activeChannelPubkeys: leaseState.activeChannelPubkeys
293
389
  }
294
- await run('put', [bundle], BUNDLES_STORE, null, { tx })
295
390
  }))
296
391
  }
297
392
 
@@ -306,19 +401,20 @@ export function releasePrivateMessengerStorage ({
306
401
  return withMaintenanceLock(indexedDB, () => withRegistryTransaction(indexedDB, 'readwrite', async tx => {
307
402
  userPubkey = String(userPubkey)
308
403
  leaseId = String(leaseId)
309
- const current = normalizeBundle((await run('get', [userPubkey], BUNDLES_STORE, null, { tx })).result)
404
+ const current = normalizeStorageSet((await run('get', [userPubkey], STORAGE_SETS_STORE, null, { tx })).result)
310
405
  if (!current) return false
311
406
  await run('delete', [leaseKey(userPubkey, leaseId)], LEASES_STORE, null, { tx })
312
- await refreshLeaseUntil(tx, current, now, { removeExpired: true })
407
+ await refreshLeaseState(tx, current, now, { removeExpired: true })
313
408
  current.lastUsedAt = now
314
409
  current.status = 'ready'
315
410
  current.attempts = 0
316
411
  current.nextAttemptAt = 0
317
- await run('put', [current], BUNDLES_STORE, null, { tx })
412
+ await run('put', [current], STORAGE_SETS_STORE, null, { tx })
318
413
  return true
319
414
  }))
320
415
  }
321
416
 
322
417
  export const PRIVATE_MESSENGER_STORAGE_HEARTBEAT_MS = 60 * 60 * 1000
323
418
  export const PRIVATE_MESSENGER_STORAGE_MAINTENANCE_MS = 6 * 60 * 60 * 1000
324
- export const PRIVATE_MESSENGER_STORAGE_RETENTION_MS = RETENTION_MS
419
+ export const PRIVATE_MESSENGER_IDENTITY_STORAGE_RETENTION_MS = DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS * 1000
420
+ export { DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS }