libp2r2p 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  }
@@ -0,0 +1,132 @@
1
+ import { run } from '../../idb/index.js'
2
+
3
+ const DATABASE_VERSION = 1
4
+ const CHANNELS_STORE = 'channels'
5
+
6
+ /*
7
+ IndexedDB schema, scoped per private-messenger prefix:
8
+
9
+ database `${prefix}:state:idb`, version 1
10
+
11
+ channels, keyPath "pubkey"
12
+ pubkey watched channel public key
13
+ value evolving channel-recovery state, including last-seen/watched times,
14
+ mode, relays, seeders, effective offline-recovery retention, offline
15
+ ranges, active offline-range start,
16
+ per-seeder activity, and sent/received content-key usage
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.
20
+ */
21
+
22
+ function deferred () {
23
+ let resolve
24
+ let reject
25
+ const promise = new Promise((nextResolve, nextReject) => {
26
+ resolve = nextResolve
27
+ reject = nextReject
28
+ })
29
+ return { promise, resolve, reject }
30
+ }
31
+
32
+ function transactionDone (tx) {
33
+ const pending = deferred()
34
+ tx.oncomplete = () => pending.resolve()
35
+ tx.onabort = () => pending.reject(tx.error || new Error('IDB_TRANSACTION_ABORTED'))
36
+ tx.onerror = () => pending.reject(tx.error || new Error('IDB_TRANSACTION_FAILED'))
37
+ return pending.promise
38
+ }
39
+
40
+ function openDatabase (indexedDB, name) {
41
+ if (!indexedDB?.open) return Promise.reject(new Error('IDB_UNAVAILABLE'))
42
+ return new Promise((resolve, reject) => {
43
+ const request = indexedDB.open(name, DATABASE_VERSION)
44
+ request.onerror = () => reject(request.error || new Error('IDB_OPEN_FAILED'))
45
+ request.onblocked = () => reject(new Error('IDB_DATABASE_BLOCKED'))
46
+ request.onupgradeneeded = () => {
47
+ const db = request.result
48
+ if (!db.objectStoreNames.contains(CHANNELS_STORE)) {
49
+ db.createObjectStore(CHANNELS_STORE, { keyPath: 'pubkey' })
50
+ }
51
+ }
52
+ request.onsuccess = () => {
53
+ const db = request.result
54
+ db.onversionchange = () => db.close()
55
+ resolve(db)
56
+ }
57
+ })
58
+ }
59
+
60
+ async function transaction (db, mode, work) {
61
+ const tx = db.transaction([CHANNELS_STORE], mode)
62
+ // Attach completion handlers before issuing the first request. The callback
63
+ // may await only requests belonging to this transaction.
64
+ const done = transactionDone(tx)
65
+ try {
66
+ const result = await work(tx)
67
+ await done
68
+ return result
69
+ } catch (err) {
70
+ try { tx.abort() } catch {}
71
+ try { await done } catch {}
72
+ throw err
73
+ }
74
+ }
75
+
76
+ function cloneChannels (channels) {
77
+ return structuredClone(channels || {})
78
+ }
79
+
80
+ export async function createChannelStateStore ({ prefix, indexedDB = globalThis.indexedDB } = {}) {
81
+ if (!prefix) throw new Error('PRIVATE_MESSENGER_STATE_PREFIX_REQUIRED')
82
+ const db = await openDatabase(indexedDB, `${prefix}:state:idb`)
83
+ let closed = false
84
+ let activeTransactions = 0
85
+ let closePromise = null
86
+ const closeWaiters = new Set()
87
+
88
+ async function runTransaction (mode, work) {
89
+ if (closed) throw new Error('PRIVATE_MESSENGER_STATE_CLOSED')
90
+ activeTransactions++
91
+ try {
92
+ return await transaction(db, mode, work)
93
+ } finally {
94
+ activeTransactions--
95
+ if (!activeTransactions) {
96
+ for (const resolve of closeWaiters) resolve()
97
+ closeWaiters.clear()
98
+ }
99
+ }
100
+ }
101
+
102
+ async function load () {
103
+ return runTransaction('readonly', async tx => {
104
+ const records = (await run('getAll', [], CHANNELS_STORE, null, { tx })).result
105
+ return Object.fromEntries(records
106
+ .filter(record => typeof record?.pubkey === 'string' && record.pubkey)
107
+ .map(({ pubkey, value }) => [pubkey, value && typeof value === 'object' ? value : {}]))
108
+ })
109
+ }
110
+
111
+ async function replace (channels) {
112
+ const snapshot = cloneChannels(channels)
113
+ await runTransaction('readwrite', async tx => {
114
+ await run('clear', [], CHANNELS_STORE, null, { tx })
115
+ for (const [pubkey, value] of Object.entries(snapshot)) {
116
+ await run('put', [{ pubkey, value }], CHANNELS_STORE, null, { tx })
117
+ }
118
+ })
119
+ }
120
+
121
+ function close () {
122
+ if (closePromise) return closePromise
123
+ closed = true
124
+ closePromise = (activeTransactions
125
+ ? new Promise(resolve => closeWaiters.add(resolve))
126
+ : Promise.resolve())
127
+ .then(() => { db.close() })
128
+ return closePromise
129
+ }
130
+
131
+ return { load, replace, close }
132
+ }
@@ -0,0 +1,324 @@
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
+
5
+ const REGISTRY_DATABASE = 'libp2r2p:private-messenger:registry:idb'
6
+ const REGISTRY_VERSION = 1
7
+ const BUNDLES_STORE = 'bundles'
8
+ const LEASES_STORE = 'leases'
9
+ const LOCK_NAME = 'libp2r2p:private-messenger:storage-maintenance'
10
+ const LEASE_MS = 2 * 60 * 60 * 1000 // 2 hours
11
+ const RETENTION_MS = 60 * 24 * 60 * 60 * 1000 // 60 days
12
+ const MAX_RETRY_MS = 5 * 60 * 1000
13
+
14
+ const localLocks = new WeakMap()
15
+ const retryTimers = new WeakMap()
16
+
17
+ /*
18
+ IndexedDB schema, shared by every PrivateMessenger instance on this origin:
19
+
20
+ database "libp2r2p:private-messenger:registry:idb", version 1
21
+
22
+ bundles, keyPath "userPubkey"
23
+ userPubkey primary signer public key and storage-set coordinate
24
+ lastUsedAt most recent durable activity in milliseconds
25
+ leaseUntil active-instance lease expiry in milliseconds
26
+ status "ready" or "delete_pending"
27
+ attempts consecutive failed storage-set deletions
28
+ nextAttemptAt earliest retry time in milliseconds
29
+
30
+ leases, keyPath "key"
31
+ key `${userPubkey}:${leaseId}`
32
+ userPubkey principal signer public key
33
+ leaseId per-PrivateMessenger instance identifier
34
+ leaseUntil instance lease expiry in milliseconds
35
+
36
+ leases indexes
37
+ byUserPubkey userPubkey
38
+ */
39
+
40
+ function transactionDone (tx) {
41
+ return new Promise((resolve, reject) => {
42
+ tx.oncomplete = () => resolve()
43
+ tx.onabort = () => reject(tx.error || new Error('IDB_TRANSACTION_ABORTED'))
44
+ tx.onerror = () => reject(tx.error || new Error('IDB_TRANSACTION_FAILED'))
45
+ })
46
+ }
47
+
48
+ function openRegistry (indexedDB) {
49
+ if (!indexedDB?.open) return Promise.reject(new Error('IDB_UNAVAILABLE'))
50
+ return new Promise((resolve, reject) => {
51
+ const request = indexedDB.open(REGISTRY_DATABASE, REGISTRY_VERSION)
52
+ request.onerror = () => reject(request.error || new Error('IDB_OPEN_FAILED'))
53
+ request.onblocked = () => reject(new Error('IDB_DATABASE_BLOCKED'))
54
+ request.onupgradeneeded = () => {
55
+ const db = request.result
56
+ if (!db.objectStoreNames.contains(BUNDLES_STORE)) {
57
+ db.createObjectStore(BUNDLES_STORE, { keyPath: 'userPubkey' })
58
+ }
59
+ if (!db.objectStoreNames.contains(LEASES_STORE)) {
60
+ const leases = db.createObjectStore(LEASES_STORE, { keyPath: 'key' })
61
+ leases.createIndex('byUserPubkey', 'userPubkey')
62
+ }
63
+ }
64
+ request.onsuccess = () => {
65
+ const db = request.result
66
+ db.onversionchange = () => db.close()
67
+ resolve(db)
68
+ }
69
+ })
70
+ }
71
+
72
+ async function withRegistryTransaction (indexedDB, mode, work) {
73
+ const db = await openRegistry(indexedDB)
74
+ try {
75
+ const tx = db.transaction([BUNDLES_STORE, LEASES_STORE], mode)
76
+ const done = transactionDone(tx)
77
+ try {
78
+ const result = await work(tx)
79
+ await done
80
+ return result
81
+ } catch (err) {
82
+ try { tx.abort() } catch {}
83
+ try { await done } catch {}
84
+ throw err
85
+ }
86
+ } finally {
87
+ db.close()
88
+ }
89
+ }
90
+
91
+ function leaseKey (userPubkey, leaseId) {
92
+ return `${userPubkey}:${leaseId}`
93
+ }
94
+
95
+ async function readLeases (tx, userPubkey) {
96
+ const keyRange = globalThis.IDBKeyRange
97
+ const records = keyRange?.only
98
+ ? (await run('getAll', [keyRange.only(userPubkey)], LEASES_STORE, 'byUserPubkey', { tx })).result
99
+ : (await run('getAll', [], LEASES_STORE, null, { tx })).result
100
+ return records.filter(record => record?.userPubkey === userPubkey)
101
+ }
102
+
103
+ async function refreshLeaseUntil (tx, bundle, now, { removeExpired = false } = {}) {
104
+ const leases = await readLeases(tx, bundle.userPubkey)
105
+ let leaseUntil = 0
106
+ for (const lease of leases) {
107
+ const expiresAt = Math.max(0, Number(lease.leaseUntil) || 0)
108
+ if (expiresAt <= now) {
109
+ if (removeExpired) await run('delete', [lease.key], LEASES_STORE, null, { tx })
110
+ continue
111
+ }
112
+ leaseUntil = Math.max(leaseUntil, expiresAt)
113
+ }
114
+ bundle.leaseUntil = leaseUntil
115
+ return leaseUntil
116
+ }
117
+
118
+ function normalizeBundle (record) {
119
+ if (!record?.userPubkey) return null
120
+ return {
121
+ userPubkey: String(record.userPubkey),
122
+ lastUsedAt: Math.max(0, Number(record.lastUsedAt) || 0),
123
+ leaseUntil: Math.max(0, Number(record.leaseUntil) || 0),
124
+ status: record.status === 'delete_pending' ? 'delete_pending' : 'ready',
125
+ attempts: Math.max(0, Math.floor(Number(record.attempts) || 0)),
126
+ nextAttemptAt: Math.max(0, Number(record.nextAttemptAt) || 0)
127
+ }
128
+ }
129
+
130
+ function withLocalLock (indexedDB, work) {
131
+ const prior = localLocks.get(indexedDB) || Promise.resolve()
132
+ const next = prior.catch(() => {}).then(work)
133
+ localLocks.set(indexedDB, next.catch(() => {}))
134
+ return next
135
+ }
136
+
137
+ function withMaintenanceLock (indexedDB, work) {
138
+ if (!indexedDB?.open) return Promise.reject(new Error('IDB_UNAVAILABLE'))
139
+ const locks = globalThis.navigator?.locks
140
+ if (typeof locks?.request === 'function') return locks.request(LOCK_NAME, work)
141
+ return withLocalLock(indexedDB, work)
142
+ }
143
+
144
+ function storageDatabaseNames (userPubkey) {
145
+ const prefix = `libp2r2p:private-messenger:${userPubkey}`
146
+ return [
147
+ `${prefix}:idb-queue`,
148
+ `${prefix}:seeds:idb-queue`,
149
+ `${prefix}:state:idb`
150
+ ]
151
+ }
152
+
153
+ function deleteDatabase (indexedDB, name) {
154
+ return new Promise(resolve => {
155
+ let request
156
+ try {
157
+ request = indexedDB.deleteDatabase(name)
158
+ } catch {
159
+ resolve(false)
160
+ return
161
+ }
162
+ request.onsuccess = () => resolve(true)
163
+ request.onerror = () => resolve(false)
164
+ // A blocked delete request remains live and will continue automatically
165
+ // after every other connection closes. Keep the maintenance lock until
166
+ // that definitive success/error instead of scheduling a second request.
167
+ request.onblocked = () => {}
168
+ })
169
+ }
170
+
171
+ async function deleteStorageSet (indexedDB, userPubkey) {
172
+ const results = await Promise.all(storageDatabaseNames(userPubkey)
173
+ .map(name => deleteDatabase(indexedDB, name)))
174
+ return results.every(Boolean)
175
+ }
176
+
177
+ function retryDelay (attempts) {
178
+ return Math.min(MAX_RETRY_MS, 1000 * (2 ** Math.min(9, Math.max(0, attempts - 1))))
179
+ }
180
+
181
+ async function markAndListDueBundles (indexedDB, now) {
182
+ return withRegistryTransaction(indexedDB, 'readwrite', async tx => {
183
+ const records = (await run('getAll', [], BUNDLES_STORE, null, { tx })).result
184
+ const due = []
185
+ 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
194
+ }
195
+ await run('put', [bundle], BUNDLES_STORE, null, { tx })
196
+ if (bundle.status === 'delete_pending' && !leaseActive && bundle.nextAttemptAt <= now) {
197
+ due.push(bundle)
198
+ }
199
+ }
200
+ return due
201
+ })
202
+ }
203
+
204
+ async function finishDeleteAttempt (indexedDB, attempted, deleted, now) {
205
+ 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
208
+ if (deleted) {
209
+ for (const lease of await readLeases(tx, attempted.userPubkey)) {
210
+ await run('delete', [lease.key], LEASES_STORE, null, { tx })
211
+ }
212
+ await run('delete', [attempted.userPubkey], BUNDLES_STORE, null, { tx })
213
+ return true
214
+ }
215
+ current.attempts++
216
+ current.nextAttemptAt = now + retryDelay(current.attempts)
217
+ await run('put', [current], BUNDLES_STORE, null, { tx })
218
+ return false
219
+ })
220
+ }
221
+
222
+ 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)
227
+ }
228
+ const nextAttemptAt = await withRegistryTransaction(indexedDB, 'readonly', async tx => {
229
+ const records = (await run('getAll', [], BUNDLES_STORE, null, { tx })).result
230
+ let earliest = Infinity
231
+ 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)
235
+ }
236
+ return Number.isFinite(earliest) ? earliest : null
237
+ })
238
+ return { processed: due.length, nextAttemptAt }
239
+ }
240
+
241
+ function schedulePendingRetry (indexedDB, temporaryStorageArea, nextAttemptAt, now) {
242
+ const previous = retryTimers.get(indexedDB)
243
+ if (previous) clearTimeout(previous)
244
+ if (nextAttemptAt === null) {
245
+ retryTimers.delete(indexedDB)
246
+ return
247
+ }
248
+ const timer = setTimeout(() => {
249
+ retryTimers.delete(indexedDB)
250
+ maintainPrivateMessengerStorage({ indexedDB, temporaryStorageArea }).catch(() => {})
251
+ }, Math.min(MAX_RETRY_MS, Math.max(0, nextAttemptAt - now)))
252
+ timer?.unref?.()
253
+ retryTimers.set(indexedDB, timer)
254
+ }
255
+
256
+ export async function maintainPrivateMessengerStorage ({
257
+ indexedDB = globalThis.indexedDB,
258
+ temporaryStorageArea = globalThis.sessionStorage,
259
+ now = Date.now()
260
+ } = {}) {
261
+ if (temporaryStorageArea) cleanupTemporaryStorage({ storageArea: temporaryStorageArea })
262
+ await cleanupReceivedChunkStorage({ indexedDB, now })
263
+ const result = await withMaintenanceLock(indexedDB, () => maintainRegistry(indexedDB, now))
264
+ schedulePendingRetry(indexedDB, temporaryStorageArea, result.nextAttemptAt, now)
265
+ return result.processed
266
+ }
267
+
268
+ export function activatePrivateMessengerStorage ({
269
+ userPubkey,
270
+ leaseId,
271
+ indexedDB = globalThis.indexedDB,
272
+ now = Date.now()
273
+ } = {}) {
274
+ if (!userPubkey) return Promise.reject(new Error('USER_PUBKEY_REQUIRED'))
275
+ if (!leaseId) return Promise.reject(new Error('PRIVATE_MESSENGER_LEASE_ID_REQUIRED'))
276
+ return withMaintenanceLock(indexedDB, () => withRegistryTransaction(indexedDB, 'readwrite', async tx => {
277
+ userPubkey = String(userPubkey)
278
+ leaseId = String(leaseId)
279
+ const current = normalizeBundle((await run('get', [userPubkey], BUNDLES_STORE, null, { tx })).result)
280
+ await run('put', [{
281
+ key: leaseKey(userPubkey, leaseId),
282
+ userPubkey,
283
+ leaseId,
284
+ leaseUntil: now + LEASE_MS
285
+ }], LEASES_STORE, null, { tx })
286
+ const bundle = {
287
+ userPubkey,
288
+ lastUsedAt: now,
289
+ leaseUntil: Math.max(current?.leaseUntil || 0, now + LEASE_MS),
290
+ status: 'ready',
291
+ attempts: 0,
292
+ nextAttemptAt: 0
293
+ }
294
+ await run('put', [bundle], BUNDLES_STORE, null, { tx })
295
+ }))
296
+ }
297
+
298
+ export function releasePrivateMessengerStorage ({
299
+ userPubkey,
300
+ leaseId,
301
+ indexedDB = globalThis.indexedDB,
302
+ now = Date.now()
303
+ } = {}) {
304
+ if (!userPubkey) return Promise.resolve(false)
305
+ if (!leaseId) return Promise.reject(new Error('PRIVATE_MESSENGER_LEASE_ID_REQUIRED'))
306
+ return withMaintenanceLock(indexedDB, () => withRegistryTransaction(indexedDB, 'readwrite', async tx => {
307
+ userPubkey = String(userPubkey)
308
+ leaseId = String(leaseId)
309
+ const current = normalizeBundle((await run('get', [userPubkey], BUNDLES_STORE, null, { tx })).result)
310
+ if (!current) return false
311
+ await run('delete', [leaseKey(userPubkey, leaseId)], LEASES_STORE, null, { tx })
312
+ await refreshLeaseUntil(tx, current, now, { removeExpired: true })
313
+ current.lastUsedAt = now
314
+ current.status = 'ready'
315
+ current.attempts = 0
316
+ current.nextAttemptAt = 0
317
+ await run('put', [current], BUNDLES_STORE, null, { tx })
318
+ return true
319
+ }))
320
+ }
321
+
322
+ export const PRIVATE_MESSENGER_STORAGE_HEARTBEAT_MS = 60 * 60 * 1000
323
+ export const PRIVATE_MESSENGER_STORAGE_MAINTENANCE_MS = 6 * 60 * 60 * 1000
324
+ export const PRIVATE_MESSENGER_STORAGE_RETENTION_MS = RETENTION_MS