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.
- package/README.md +73 -13
- package/idb-queue/index.js +37 -11
- package/package.json +1 -1
- package/private-channel/index.js +35 -18
- package/private-channel/services/received-chunks.js +71 -30
- package/private-message/index.js +58 -13
- package/private-messenger/index.js +631 -86
- package/private-messenger/recovery/index.js +3 -1
- package/private-messenger/services/channel-state.js +57 -8
- package/private-messenger/services/storage-maintenance.js +420 -0
|
@@ -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
|
|
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
|
-
|
|
18
|
-
|
|
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
|
|
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
|
|
113
|
+
async function update (channels, removedPubkeys = []) {
|
|
93
114
|
const snapshot = cloneChannels(channels)
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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 }
|