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.
@@ -295,7 +295,8 @@ export function createMissingMessageReplyPacker ({
295
295
  receiverPubkey = question?.pubkey,
296
296
  since,
297
297
  until,
298
- eventsPerChunk = DEFAULT_EVENTS_PER_CHUNK
298
+ eventsPerChunk = DEFAULT_EVENTS_PER_CHUNK,
299
+ sendEmptyReply = false
299
300
  }) {
300
301
  if (!messenger?.reply) throw new Error('MESSENGER_REQUIRED')
301
302
  if (!question?.id) throw new Error('QUESTION_REQUIRED')
@@ -311,6 +312,7 @@ export function createMissingMessageReplyPacker ({
311
312
  code: MISSING_MESSAGES_REPLY_CODE,
312
313
  payload: { since: range.since, until: range.until },
313
314
  eventsPerChunk,
315
+ sendEmptyReply,
314
316
  recordsFromInput: seed => compactRecordsFromSeed(seed, { receiverPubkey, since: range.since, until: range.until })
315
317
  })
316
318
  }
@@ -11,11 +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, offline ranges, active offline-range start,
14
+ mode, relays, seeders, requested offline-recovery retention, offline
15
+ ranges, active offline-range start,
15
16
  per-seeder activity, and sent/received content-key usage
16
17
 
17
- The complete channels snapshot is replaced atomically on each persisted state
18
- change; value remains an extensible internal object rather than a public schema.
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.
19
22
  */
20
23
 
21
24
  function deferred () {
@@ -79,9 +82,27 @@ function cloneChannels (channels) {
79
82
  export async function createChannelStateStore ({ prefix, indexedDB = globalThis.indexedDB } = {}) {
80
83
  if (!prefix) throw new Error('PRIVATE_MESSENGER_STATE_PREFIX_REQUIRED')
81
84
  const db = await openDatabase(indexedDB, `${prefix}:state:idb`)
85
+ let closed = false
86
+ let activeTransactions = 0
87
+ let closePromise = null
88
+ const closeWaiters = new Set()
89
+
90
+ async function runTransaction (mode, work) {
91
+ if (closed) throw new Error('PRIVATE_MESSENGER_STATE_CLOSED')
92
+ activeTransactions++
93
+ try {
94
+ return await transaction(db, mode, work)
95
+ } finally {
96
+ activeTransactions--
97
+ if (!activeTransactions) {
98
+ for (const resolve of closeWaiters) resolve()
99
+ closeWaiters.clear()
100
+ }
101
+ }
102
+ }
82
103
 
83
104
  async function load () {
84
- return transaction(db, 'readonly', async tx => {
105
+ return runTransaction('readonly', async tx => {
85
106
  const records = (await run('getAll', [], CHANNELS_STORE, null, { tx })).result
86
107
  return Object.fromEntries(records
87
108
  .filter(record => typeof record?.pubkey === 'string' && record.pubkey)
@@ -89,15 +110,43 @@ export async function createChannelStateStore ({ prefix, indexedDB = globalThis.
89
110
  })
90
111
  }
91
112
 
92
- async function replace (channels) {
113
+ async function update (channels, removedPubkeys = []) {
93
114
  const snapshot = cloneChannels(channels)
94
- await transaction(db, 'readwrite', async tx => {
95
- await run('clear', [], CHANNELS_STORE, null, { tx })
115
+ const removals = [...new Set(removedPubkeys || [])]
116
+ await runTransaction('readwrite', async tx => {
117
+ for (const pubkey of removals) await run('delete', [pubkey], CHANNELS_STORE, null, { tx })
96
118
  for (const [pubkey, value] of Object.entries(snapshot)) {
97
119
  await run('put', [{ pubkey, value }], CHANNELS_STORE, null, { tx })
98
120
  }
99
121
  })
100
122
  }
101
123
 
102
- return { load, replace }
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
+
141
+ function close () {
142
+ if (closePromise) return closePromise
143
+ closed = true
144
+ closePromise = (activeTransactions
145
+ ? new Promise(resolve => closeWaiters.add(resolve))
146
+ : Promise.resolve())
147
+ .then(() => { db.close() })
148
+ return closePromise
149
+ }
150
+
151
+ return { load, update, touch, close }
103
152
  }
@@ -0,0 +1,420 @@
1
+ import { run } from '../../idb/index.js'
2
+ import { cleanupReceivedChunkStorage } from '../../private-channel/services/received-chunks.js'
3
+ import { cleanupTemporaryStorage } from '../../temporary-storage/index.js'
4
+ import { DEFAULT_STALE_CHANNEL_SECONDS } from '../constants/index.js'
5
+
6
+ const REGISTRY_DATABASE = 'libp2r2p:private-messenger:registry:idb'
7
+ const REGISTRY_VERSION = 1
8
+ const STORAGE_SETS_STORE = 'storageSets'
9
+ const LEASES_STORE = 'leases'
10
+ const LOCK_NAME = 'libp2r2p:private-messenger:storage-maintenance'
11
+ const LEASE_MS = 2 * 60 * 60 * 1000 // 2 hours
12
+ const DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS = 60 * 24 * 60 * 60
13
+ const MAX_RETENTION_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1000)
14
+ const MAX_RETRY_MS = 5 * 60 * 1000
15
+
16
+ const localLocks = new WeakMap()
17
+ const retryTimers = new WeakMap()
18
+
19
+ /*
20
+ IndexedDB schema, shared by every PrivateMessenger instance on this origin:
21
+
22
+ database "libp2r2p:private-messenger:registry:idb", version 1
23
+
24
+ storageSets, keyPath "userPubkey"
25
+ userPubkey primary signer public key and storage-set coordinate
26
+ lastUsedAt most recent durable activity in milliseconds
27
+ leaseUntil active-instance lease expiry in milliseconds
28
+ status "ready" or "delete_pending"
29
+ attempts consecutive failed storage-set deletions
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
34
+
35
+ leases, keyPath "key"
36
+ key `${userPubkey}:${leaseId}`
37
+ userPubkey principal signer public key
38
+ leaseId per-PrivateMessenger instance identifier
39
+ leaseUntil instance lease expiry in milliseconds
40
+ activeChannelPubkeys channels currently administered by that instance
41
+
42
+ leases indexes
43
+ byUserPubkey userPubkey
44
+ */
45
+
46
+ function transactionDone (tx) {
47
+ return new Promise((resolve, reject) => {
48
+ tx.oncomplete = () => resolve()
49
+ tx.onabort = () => reject(tx.error || new Error('IDB_TRANSACTION_ABORTED'))
50
+ tx.onerror = () => reject(tx.error || new Error('IDB_TRANSACTION_FAILED'))
51
+ })
52
+ }
53
+
54
+ function openRegistry (indexedDB) {
55
+ if (!indexedDB?.open) return Promise.reject(new Error('IDB_UNAVAILABLE'))
56
+ return new Promise((resolve, reject) => {
57
+ const request = indexedDB.open(REGISTRY_DATABASE, REGISTRY_VERSION)
58
+ request.onerror = () => reject(request.error || new Error('IDB_OPEN_FAILED'))
59
+ request.onblocked = () => reject(new Error('IDB_DATABASE_BLOCKED'))
60
+ request.onupgradeneeded = () => {
61
+ const db = request.result
62
+ if (!db.objectStoreNames.contains(STORAGE_SETS_STORE)) {
63
+ db.createObjectStore(STORAGE_SETS_STORE, { keyPath: 'userPubkey' })
64
+ }
65
+ if (!db.objectStoreNames.contains(LEASES_STORE)) {
66
+ const leases = db.createObjectStore(LEASES_STORE, { keyPath: 'key' })
67
+ leases.createIndex('byUserPubkey', 'userPubkey')
68
+ }
69
+ }
70
+ request.onsuccess = () => {
71
+ const db = request.result
72
+ db.onversionchange = () => db.close()
73
+ resolve(db)
74
+ }
75
+ })
76
+ }
77
+
78
+ async function withRegistryTransaction (indexedDB, mode, work) {
79
+ const db = await openRegistry(indexedDB)
80
+ try {
81
+ const tx = db.transaction([STORAGE_SETS_STORE, LEASES_STORE], mode)
82
+ const done = transactionDone(tx)
83
+ try {
84
+ const result = await work(tx)
85
+ await done
86
+ return result
87
+ } catch (err) {
88
+ try { tx.abort() } catch {}
89
+ try { await done } catch {}
90
+ throw err
91
+ }
92
+ } finally {
93
+ db.close()
94
+ }
95
+ }
96
+
97
+ function leaseKey (userPubkey, leaseId) {
98
+ return `${userPubkey}:${leaseId}`
99
+ }
100
+
101
+ async function readLeases (tx, userPubkey) {
102
+ const keyRange = globalThis.IDBKeyRange
103
+ const records = keyRange?.only
104
+ ? (await run('getAll', [keyRange.only(userPubkey)], LEASES_STORE, 'byUserPubkey', { tx })).result
105
+ : (await run('getAll', [], LEASES_STORE, null, { tx })).result
106
+ return records.filter(record => record?.userPubkey === userPubkey)
107
+ }
108
+
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)
121
+ let leaseUntil = 0
122
+ const activeChannelPubkeys = new Set()
123
+ for (const lease of leases) {
124
+ const expiresAt = Math.max(0, Number(lease.leaseUntil) || 0)
125
+ if (expiresAt <= now) {
126
+ if (removeExpired) await run('delete', [lease.key], LEASES_STORE, null, { tx })
127
+ continue
128
+ }
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()
138
+ }
139
+ }
140
+
141
+ function normalizeStorageSet (record) {
142
+ if (!record?.userPubkey) return null
143
+ return {
144
+ userPubkey: String(record.userPubkey),
145
+ lastUsedAt: Math.max(0, Number(record.lastUsedAt) || 0),
146
+ leaseUntil: Math.max(0, Number(record.leaseUntil) || 0),
147
+ status: record.status === 'delete_pending' ? 'delete_pending' : 'ready',
148
+ attempts: Math.max(0, Math.floor(Number(record.attempts) || 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
167
+ }
168
+ }
169
+
170
+ function withLocalLock (indexedDB, work) {
171
+ const prior = localLocks.get(indexedDB) || Promise.resolve()
172
+ const next = prior.catch(() => {}).then(work)
173
+ localLocks.set(indexedDB, next.catch(() => {}))
174
+ return next
175
+ }
176
+
177
+ function withMaintenanceLock (indexedDB, work) {
178
+ if (!indexedDB?.open) return Promise.reject(new Error('IDB_UNAVAILABLE'))
179
+ const locks = globalThis.navigator?.locks
180
+ if (typeof locks?.request === 'function') return locks.request(LOCK_NAME, work)
181
+ return withLocalLock(indexedDB, work)
182
+ }
183
+
184
+ function storageDatabaseNames (userPubkey) {
185
+ const prefix = `libp2r2p:private-messenger:${userPubkey}`
186
+ return [
187
+ `${prefix}:idb-queue`,
188
+ `${prefix}:seeds:idb-queue`,
189
+ `${prefix}:state:idb`
190
+ ]
191
+ }
192
+
193
+ function deleteDatabase (indexedDB, name) {
194
+ return new Promise(resolve => {
195
+ let request
196
+ try {
197
+ request = indexedDB.deleteDatabase(name)
198
+ } catch {
199
+ resolve(false)
200
+ return
201
+ }
202
+ request.onsuccess = () => resolve(true)
203
+ request.onerror = () => resolve(false)
204
+ // A blocked delete request remains live and will continue automatically
205
+ // after every other connection closes. Keep the maintenance lock until
206
+ // that definitive success/error instead of scheduling a second request.
207
+ request.onblocked = () => {}
208
+ })
209
+ }
210
+
211
+ async function deleteStorageSet (indexedDB, userPubkey) {
212
+ const results = await Promise.all(storageDatabaseNames(userPubkey)
213
+ .map(name => deleteDatabase(indexedDB, name)))
214
+ return results.every(Boolean)
215
+ }
216
+
217
+ function retryDelay (attempts) {
218
+ return Math.min(MAX_RETRY_MS, 1000 * (2 ** Math.min(9, Math.max(0, attempts - 1))))
219
+ }
220
+
221
+ async function markAndListDueStorageSets (indexedDB, now) {
222
+ return withRegistryTransaction(indexedDB, 'readwrite', async tx => {
223
+ const records = (await run('getAll', [], STORAGE_SETS_STORE, null, { tx })).result
224
+ const due = []
225
+ for (const raw of records) {
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
240
+ }
241
+ await run('put', [storageSet], STORAGE_SETS_STORE, null, { tx })
242
+ if (storageSet.status === 'delete_pending' && !leaseActive && storageSet.nextAttemptAt <= now) {
243
+ due.push(storageSet)
244
+ }
245
+ }
246
+ return due
247
+ })
248
+ }
249
+
250
+ async function finishDeleteAttempt (indexedDB, attempted, deleted, now) {
251
+ return withRegistryTransaction(indexedDB, 'readwrite', async tx => {
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
+ }
262
+ if (deleted) {
263
+ for (const lease of await readLeases(tx, attempted.userPubkey)) {
264
+ await run('delete', [lease.key], LEASES_STORE, null, { tx })
265
+ }
266
+ await run('delete', [attempted.userPubkey], STORAGE_SETS_STORE, null, { tx })
267
+ return true
268
+ }
269
+ current.attempts++
270
+ current.nextAttemptAt = now + retryDelay(current.attempts)
271
+ await run('put', [current], STORAGE_SETS_STORE, null, { tx })
272
+ return false
273
+ })
274
+ }
275
+
276
+ async function maintainRegistry (indexedDB, 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)
281
+ }
282
+ const nextAttemptAt = await withRegistryTransaction(indexedDB, 'readonly', async tx => {
283
+ const records = (await run('getAll', [], STORAGE_SETS_STORE, null, { tx })).result
284
+ let earliest = Infinity
285
+ for (const raw of records) {
286
+ const storageSet = normalizeStorageSet(raw)
287
+ if (storageSet?.status !== 'delete_pending' || storageSet.leaseUntil > now) continue
288
+ earliest = Math.min(earliest, storageSet.nextAttemptAt)
289
+ }
290
+ return Number.isFinite(earliest) ? earliest : null
291
+ })
292
+ return { processed: due.length, nextAttemptAt }
293
+ }
294
+
295
+ function schedulePendingRetry (indexedDB, temporaryStorageArea, nextAttemptAt, now) {
296
+ const previous = retryTimers.get(indexedDB)
297
+ if (previous) clearTimeout(previous)
298
+ if (nextAttemptAt === null) {
299
+ retryTimers.delete(indexedDB)
300
+ return
301
+ }
302
+ const timer = setTimeout(() => {
303
+ retryTimers.delete(indexedDB)
304
+ maintainPrivateMessengerStorage({ indexedDB, temporaryStorageArea }).catch(() => {})
305
+ }, Math.min(MAX_RETRY_MS, Math.max(0, nextAttemptAt - now)))
306
+ timer?.unref?.()
307
+ retryTimers.set(indexedDB, timer)
308
+ }
309
+
310
+ export async function maintainPrivateMessengerStorage ({
311
+ indexedDB = globalThis.indexedDB,
312
+ temporaryStorageArea = globalThis.sessionStorage,
313
+ now = Date.now()
314
+ } = {}) {
315
+ if (temporaryStorageArea) cleanupTemporaryStorage({ storageArea: temporaryStorageArea })
316
+ await cleanupReceivedChunkStorage({ indexedDB, now })
317
+ const result = await withMaintenanceLock(indexedDB, () => maintainRegistry(indexedDB, now))
318
+ schedulePendingRetry(indexedDB, temporaryStorageArea, result.nextAttemptAt, now)
319
+ return result.processed
320
+ }
321
+
322
+ export function activatePrivateMessengerStorage ({
323
+ userPubkey,
324
+ leaseId,
325
+ activeChannelPubkeys,
326
+ storagePolicy: nextPolicy,
327
+ indexedDB = globalThis.indexedDB,
328
+ now = Date.now()
329
+ } = {}) {
330
+ if (!userPubkey) return Promise.reject(new Error('USER_PUBKEY_REQUIRED'))
331
+ if (!leaseId) return Promise.reject(new Error('PRIVATE_MESSENGER_LEASE_ID_REQUIRED'))
332
+ return withMaintenanceLock(indexedDB, () => withRegistryTransaction(indexedDB, 'readwrite', async tx => {
333
+ userPubkey = String(userPubkey)
334
+ leaseId = String(leaseId)
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)
340
+ await run('put', [{
341
+ key: leaseKey(userPubkey, leaseId),
342
+ userPubkey,
343
+ leaseId,
344
+ leaseUntil: now + LEASE_MS,
345
+ activeChannelPubkeys: channels
346
+ }], LEASES_STORE, null, { tx })
347
+ const hasPolicy = nextPolicy !== undefined
348
+ const storageSet = {
349
+ userPubkey,
350
+ lastUsedAt: now,
351
+ leaseUntil: Math.max(current?.leaseUntil || 0, now + LEASE_MS),
352
+ status: 'ready',
353
+ attempts: 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
389
+ }
390
+ }))
391
+ }
392
+
393
+ export function releasePrivateMessengerStorage ({
394
+ userPubkey,
395
+ leaseId,
396
+ indexedDB = globalThis.indexedDB,
397
+ now = Date.now()
398
+ } = {}) {
399
+ if (!userPubkey) return Promise.resolve(false)
400
+ if (!leaseId) return Promise.reject(new Error('PRIVATE_MESSENGER_LEASE_ID_REQUIRED'))
401
+ return withMaintenanceLock(indexedDB, () => withRegistryTransaction(indexedDB, 'readwrite', async tx => {
402
+ userPubkey = String(userPubkey)
403
+ leaseId = String(leaseId)
404
+ const current = normalizeStorageSet((await run('get', [userPubkey], STORAGE_SETS_STORE, null, { tx })).result)
405
+ if (!current) return false
406
+ await run('delete', [leaseKey(userPubkey, leaseId)], LEASES_STORE, null, { tx })
407
+ await refreshLeaseState(tx, current, now, { removeExpired: true })
408
+ current.lastUsedAt = now
409
+ current.status = 'ready'
410
+ current.attempts = 0
411
+ current.nextAttemptAt = 0
412
+ await run('put', [current], STORAGE_SETS_STORE, null, { tx })
413
+ return true
414
+ }))
415
+ }
416
+
417
+ export const PRIVATE_MESSENGER_STORAGE_HEARTBEAT_MS = 60 * 60 * 1000
418
+ export const PRIVATE_MESSENGER_STORAGE_MAINTENANCE_MS = 6 * 60 * 60 * 1000
419
+ export const PRIVATE_MESSENGER_IDENTITY_STORAGE_RETENTION_MS = DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS * 1000
420
+ export { DEFAULT_IDENTITY_STORAGE_RETENTION_SECONDS }