libp2r2p 0.5.0 → 0.6.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 +10 -0
- package/idb-queue/index.js +23 -0
- package/package.json +1 -1
- package/private-channel/index.js +20 -19
- package/private-channel/services/received-chunks.js +410 -243
- package/private-message/index.js +4 -4
- package/private-messenger/index.js +33 -13
- package/private-messenger/services/channel-state.js +103 -0
package/README.md
CHANGED
|
@@ -148,6 +148,16 @@ does not clear persisted messages, recovery material, or channel state. Pass
|
|
|
148
148
|
`temporaryStorageArea: localStorage` when constructing a messenger to opt into
|
|
149
149
|
a different Storage area.
|
|
150
150
|
|
|
151
|
+
Recovery metadata is separate from that temporary send staging. Per-channel
|
|
152
|
+
`lastSeenAt`, offline ranges, and related state are stored in IndexedDB. Raw
|
|
153
|
+
incomplete receive chunks are also stored in IndexedDB, expire after one hour,
|
|
154
|
+
and share a 16 MiB logical budget. Capacity eviction removes whole
|
|
155
|
+
least-recently-used message groups so a partial group is never mistaken for a
|
|
156
|
+
complete one. `receivedChunkTtlMs`, `receivedChunkMaxBytes`, and
|
|
157
|
+
`receivedChunkIndexedDB` may be supplied to the private-channel APIs when an
|
|
158
|
+
embedding environment needs different limits or an injected IDB factory.
|
|
159
|
+
Legacy Web Storage recovery records are neither read nor migrated.
|
|
160
|
+
|
|
151
161
|
Signers are expected to expose the Nostr-style methods used by the messenger,
|
|
152
162
|
including `getPublicKey()`, `signEvent(event)`, and the NIP-44 v3 methods
|
|
153
163
|
needed by private channels. For double-DH content-key use, pass a
|
package/idb-queue/index.js
CHANGED
|
@@ -7,6 +7,29 @@ const STATE_KEY = 'queue'
|
|
|
7
7
|
const DEFAULT_EVICTION_HEADROOM_RATIO = 0.1
|
|
8
8
|
const MAX_EVICTION_HEADROOM_BYTES = 64 * 1024 // 64 KiB
|
|
9
9
|
|
|
10
|
+
/*
|
|
11
|
+
IndexedDB schema, scoped per queue prefix:
|
|
12
|
+
|
|
13
|
+
database `${prefix}:idb-queue`, version managed additively
|
|
14
|
+
|
|
15
|
+
items, keyPath "position"
|
|
16
|
+
position integer ordering key between the queue head and tail
|
|
17
|
+
byteSize logical serialized size used by the queue capacity limit
|
|
18
|
+
item caller-provided queued value
|
|
19
|
+
|
|
20
|
+
items indexes
|
|
21
|
+
Declared by each createQueue() caller. A requested keyPath such as "status"
|
|
22
|
+
is stored as "item.status"; compound paths receive the same "item." prefix.
|
|
23
|
+
The database version is incremented when declared indexes need to be added.
|
|
24
|
+
|
|
25
|
+
state, keyPath "key"
|
|
26
|
+
key state record name, currently "queue"
|
|
27
|
+
head/tail half-open range containing allocated item positions
|
|
28
|
+
usedBytes sum of the queued items' logical byte sizes
|
|
29
|
+
|
|
30
|
+
The queue state record is absent while the queue is empty.
|
|
31
|
+
*/
|
|
32
|
+
|
|
10
33
|
function deferred () {
|
|
11
34
|
let resolve
|
|
12
35
|
let reject
|
package/package.json
CHANGED
package/private-channel/index.js
CHANGED
|
@@ -670,14 +670,14 @@ function createProcessor ({
|
|
|
670
670
|
onError,
|
|
671
671
|
receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS,
|
|
672
672
|
receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES,
|
|
673
|
-
|
|
673
|
+
receivedChunkIndexedDB = globalThis.indexedDB,
|
|
674
674
|
ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS,
|
|
675
675
|
ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES
|
|
676
676
|
}) {
|
|
677
677
|
const receivedChunks = createReceivedChunkStore({
|
|
678
678
|
ttlMs: receivedChunkTtlMs,
|
|
679
679
|
maxBytes: receivedChunkMaxBytes,
|
|
680
|
-
|
|
680
|
+
indexedDB: receivedChunkIndexedDB
|
|
681
681
|
})
|
|
682
682
|
const ignoredGroups = createTtlSet({
|
|
683
683
|
ttlMs: ignoredGroupTtlMs,
|
|
@@ -707,14 +707,14 @@ function createProcessor ({
|
|
|
707
707
|
groupKey = receivedChunks.groupKeyFor(channelPubkey, nymCarrierGroupId(carrier))
|
|
708
708
|
if (ignoredGroups.has(groupKey)) return
|
|
709
709
|
|
|
710
|
-
const meta = receivedChunks.put({
|
|
710
|
+
const meta = await receivedChunks.put({
|
|
711
711
|
channelPubkey,
|
|
712
712
|
routerPubkey: nymCarrierGroupId(carrier),
|
|
713
713
|
index,
|
|
714
714
|
total,
|
|
715
|
-
|
|
715
|
+
contentBytes: encoder.encode(JSON.stringify(carrier))
|
|
716
716
|
})
|
|
717
|
-
const status = receivedChunks.status(meta)
|
|
717
|
+
const status = await receivedChunks.status(meta)
|
|
718
718
|
onChunk?.({
|
|
719
719
|
outer,
|
|
720
720
|
nymCarrier: carrier,
|
|
@@ -726,7 +726,7 @@ function createProcessor ({
|
|
|
726
726
|
})
|
|
727
727
|
if (status.received < total) return
|
|
728
728
|
|
|
729
|
-
const carriers = receivedChunks.readChunkContents(groupKey).map(raw => JSON.parse(raw))
|
|
729
|
+
const carriers = (await receivedChunks.readChunkContents(groupKey)).map(raw => JSON.parse(raw))
|
|
730
730
|
const event = eventFromNymCarriers(carriers)
|
|
731
731
|
const shouldSeed = storesRecoverySeeds(channelMode)
|
|
732
732
|
if (shouldSeed) {
|
|
@@ -744,7 +744,7 @@ function createProcessor ({
|
|
|
744
744
|
carriers,
|
|
745
745
|
channelPubkey
|
|
746
746
|
})
|
|
747
|
-
receivedChunks.removeGroup(groupKey)
|
|
747
|
+
await receivedChunks.removeGroup(groupKey)
|
|
748
748
|
return
|
|
749
749
|
}
|
|
750
750
|
const router = decrypted
|
|
@@ -757,14 +757,14 @@ function createProcessor ({
|
|
|
757
757
|
|
|
758
758
|
const imkcPubkey = readImkcTag(router)
|
|
759
759
|
assertValidRouterImkcProof({ router, senderPubkey, imkcPubkey, imkcProof: readImkcProof(router) })
|
|
760
|
-
const meta = receivedChunks.put({
|
|
760
|
+
const meta = await receivedChunks.put({
|
|
761
761
|
channelPubkey,
|
|
762
762
|
routerPubkey: router.pubkey,
|
|
763
763
|
index,
|
|
764
764
|
total,
|
|
765
|
-
|
|
765
|
+
contentBytes: base64ToBytes(router.content)
|
|
766
766
|
})
|
|
767
|
-
const status = receivedChunks.status(meta)
|
|
767
|
+
const status = await receivedChunks.status(meta)
|
|
768
768
|
onChunk?.({
|
|
769
769
|
outer,
|
|
770
770
|
router,
|
|
@@ -826,15 +826,15 @@ function createProcessor ({
|
|
|
826
826
|
if (event && !mustScanWholeBundle) {
|
|
827
827
|
await onEvent?.(event, outer, { router: joinedRouter(router), channelPubkey })
|
|
828
828
|
ignoredGroups.add(groupKey)
|
|
829
|
-
receivedChunks.removeGroup(groupKey)
|
|
829
|
+
await receivedChunks.removeGroup(groupKey)
|
|
830
830
|
return
|
|
831
831
|
}
|
|
832
832
|
|
|
833
833
|
if (!drained.complete) return
|
|
834
834
|
|
|
835
835
|
const receiverPubkeys = drained.meta?.receiverPubkeys || []
|
|
836
|
-
const content = shouldSeed ? receivedChunks.readEnvelopeBundleContent(groupKey) : ''
|
|
837
|
-
const jsonl = shouldSeed ? receivedChunks.readEnvelopeBundleText(groupKey) : ''
|
|
836
|
+
const content = shouldSeed ? await receivedChunks.readEnvelopeBundleContent(groupKey) : ''
|
|
837
|
+
const jsonl = shouldSeed ? await receivedChunks.readEnvelopeBundleText(groupKey) : ''
|
|
838
838
|
const completeRouter = joinedRouter(router, content)
|
|
839
839
|
|
|
840
840
|
emitSentContentKeyUsage({
|
|
@@ -848,11 +848,11 @@ function createProcessor ({
|
|
|
848
848
|
if (shouldSeed) await onSeedEvent?.({ recordType: 'routerRow_v1', outer, router: completeRouter, channelPubkey, jsonl, innerEventIdsByRowIndex })
|
|
849
849
|
if (event) await onEvent?.(event, outer, { router: completeRouter, channelPubkey, jsonl })
|
|
850
850
|
|
|
851
|
-
receivedChunks.removeGroup(groupKey)
|
|
851
|
+
await receivedChunks.removeGroup(groupKey)
|
|
852
852
|
} catch (err) {
|
|
853
853
|
if (shouldIgnoreGroupError(err) && groupKey) {
|
|
854
854
|
ignoredGroups.add(groupKey)
|
|
855
|
-
receivedChunks.removeGroup(groupKey)
|
|
855
|
+
await receivedChunks.removeGroup(groupKey).catch(() => {})
|
|
856
856
|
}
|
|
857
857
|
onError?.(err)
|
|
858
858
|
}
|
|
@@ -875,13 +875,14 @@ function shouldIgnoreGroupError (err) {
|
|
|
875
875
|
'INVALID_RECIPIENT_ENVELOPE',
|
|
876
876
|
'INVALID_SIGNED_INNER_EVENT',
|
|
877
877
|
'MISSING_PAYLOAD_ENVELOPE',
|
|
878
|
+
'RECEIVED_CHUNK_GROUP_TOO_LARGE',
|
|
878
879
|
'MISMATCHED_NYM_CARRIER_CHUNKS',
|
|
879
880
|
'MISSING_NYM_CARRIER_CHUNK',
|
|
880
881
|
'MISSING_NYM_CARRIER_ID'
|
|
881
882
|
].includes(err?.message)
|
|
882
883
|
}
|
|
883
884
|
|
|
884
|
-
export async function fetch ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, since, until, limit, mode = 'leecher', modeByPubkey, receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES,
|
|
885
|
+
export async function fetch ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, since, until, limit, mode = 'leecher', modeByPubkey, receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _getEvents = getEvents }) {
|
|
885
886
|
if (!relays?.length) throw new Error('NO_RELAYS')
|
|
886
887
|
const authors = privateChannelPubkeyList({ privateChannelPubkey, privateChannelPubkeys })
|
|
887
888
|
const filter = { kinds: [PRIVATE_BROADCAST_KIND] }
|
|
@@ -895,12 +896,12 @@ export async function fetch ({ receiverSigner, iykcSigner, privateChannelSigner
|
|
|
895
896
|
timeoutAfterFirstEose: null
|
|
896
897
|
})
|
|
897
898
|
events.sort((a, b) => a.created_at - b.created_at)
|
|
898
|
-
const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkTtlMs, receivedChunkMaxBytes,
|
|
899
|
+
const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkTtlMs, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
|
|
899
900
|
for (const event of events) await processOuterEvent(event)
|
|
900
901
|
return events
|
|
901
902
|
}
|
|
902
903
|
|
|
903
|
-
export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, since = nowSeconds() - 5, limit, liveOnly = false, mode = 'leecher', modeByPubkey, receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES,
|
|
904
|
+
export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, since = nowSeconds() - 5, limit, liveOnly = false, mode = 'leecher', modeByPubkey, receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _liveEventsGenerator = getLiveEventsGenerator, _eventsFeedGenerator = getEventsFeedGenerator }) {
|
|
904
905
|
if (!relays?.length) throw new Error('NO_RELAYS')
|
|
905
906
|
if (receiverSigner && !receiverSigner?.nip44DecryptDoubleDH && !receiverSigner?.nip44v3Decrypt) throw new Error('RECEIVER_SIGNER_NIP44V3_DECRYPT_UNSUPPORTED')
|
|
906
907
|
if (!privateChannelReaderSigner && !privateChannelReaderSignersByPubkey && !privateChannelSigner && !privateChannelSignersByPubkey) throw new Error('PRIVATE_CHANNEL_READER_REQUIRED')
|
|
@@ -909,7 +910,7 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
|
|
|
909
910
|
const filter = { kinds: [PRIVATE_BROADCAST_KIND], since }
|
|
910
911
|
if (authors.length) filter.authors = authors
|
|
911
912
|
if (limit != null) filter.limit = limit
|
|
912
|
-
const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkTtlMs, receivedChunkMaxBytes,
|
|
913
|
+
const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkTtlMs, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
|
|
913
914
|
const controller = new AbortController()
|
|
914
915
|
const events = liveOnly
|
|
915
916
|
? _liveEventsGenerator(filter, relays, {
|
|
@@ -1,34 +1,115 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { bytesToBase64 } from '../../base64/index.js'
|
|
2
|
+
import { run } from '../../idb/index.js'
|
|
3
3
|
|
|
4
4
|
export const DEFAULT_RECEIVED_CHUNK_TTL_MS = 60 * 60 * 1000 // 1 hour
|
|
5
|
-
|
|
6
|
-
// 23 chunks (~0.65 MiB) to be sent if encrypted to 1000 participants.
|
|
7
|
-
export const DEFAULT_RECEIVED_CHUNK_MAX_BYTES = Math.min(JSONL_CHUNK_BYTES * 64, 3 * 1024 * 1024 /* 3 MiB cap */)
|
|
5
|
+
export const DEFAULT_RECEIVED_CHUNK_MAX_BYTES = 16 * 1024 * 1024 // 16 MiB
|
|
8
6
|
|
|
9
7
|
const DEFAULT_PREFIX = 'libp2r2p:private-channel:received'
|
|
10
|
-
const
|
|
8
|
+
const DATABASE_VERSION = 1
|
|
9
|
+
const GROUPS_STORE = 'groups'
|
|
10
|
+
const CHUNKS_STORE = 'chunks'
|
|
11
|
+
const STATE_STORE = 'state'
|
|
12
|
+
const USAGE_KEY = 'usage'
|
|
13
|
+
const DRAIN_LEASE_MS = 30_000
|
|
11
14
|
const decoder = new TextDecoder()
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
/*
|
|
17
|
+
IndexedDB schema, scoped per received-chunk prefix:
|
|
18
|
+
|
|
19
|
+
database `${prefix}:idb`, version 1
|
|
20
|
+
|
|
21
|
+
groups, keyPath "groupKey"
|
|
22
|
+
groupKey `${channelPubkey}:${routerPubkey}`
|
|
23
|
+
channelPubkey/routerPubkey private-channel coordinates
|
|
24
|
+
total expected chunk count
|
|
25
|
+
received/receivedCount sparse received-index map and its count
|
|
26
|
+
nextIndex/rowIndex/carry incremental payload decoding state
|
|
27
|
+
payloadCiphertext accumulated encrypted payload text
|
|
28
|
+
receiverPubkeys deduplicated intended receivers
|
|
29
|
+
byteSize logical bytes charged to this group
|
|
30
|
+
createdAt/updatedAt lifecycle timestamps in milliseconds
|
|
31
|
+
drainToken/drainUntil optional drain lease owner and expiry
|
|
32
|
+
|
|
33
|
+
groups indexes
|
|
34
|
+
byUpdatedAt updatedAt
|
|
35
|
+
|
|
36
|
+
chunks, keyPath ["groupKey", "index"]
|
|
37
|
+
groupKey owning group coordinate
|
|
38
|
+
index zero-based chunk position
|
|
39
|
+
bytes raw chunk bytes stored as Uint8Array
|
|
40
|
+
|
|
41
|
+
chunks indexes
|
|
42
|
+
byGroup groupKey
|
|
43
|
+
|
|
44
|
+
state, keyPath "key"
|
|
45
|
+
key state record name, currently "usage"
|
|
46
|
+
usedBytes total logical bytes held by all groups
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
function deferred () {
|
|
50
|
+
let resolve
|
|
51
|
+
let reject
|
|
52
|
+
const promise = new Promise((nextResolve, nextReject) => {
|
|
53
|
+
resolve = nextResolve
|
|
54
|
+
reject = nextReject
|
|
55
|
+
})
|
|
56
|
+
return { promise, resolve, reject }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function transactionDone (tx) {
|
|
60
|
+
const p = deferred()
|
|
61
|
+
tx.oncomplete = () => p.resolve()
|
|
62
|
+
tx.onabort = () => p.reject(tx.error || new Error('IDB_TRANSACTION_ABORTED'))
|
|
63
|
+
tx.onerror = () => p.reject(tx.error || new Error('IDB_TRANSACTION_FAILED'))
|
|
64
|
+
return p.promise
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function openDatabase (indexedDB, name) {
|
|
68
|
+
if (!indexedDB?.open) return Promise.reject(new Error('IDB_UNAVAILABLE'))
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const request = indexedDB.open(name, DATABASE_VERSION)
|
|
71
|
+
request.onerror = () => reject(request.error || new Error('IDB_OPEN_FAILED'))
|
|
72
|
+
request.onblocked = () => reject(new Error('IDB_DATABASE_BLOCKED'))
|
|
73
|
+
request.onupgradeneeded = () => {
|
|
74
|
+
const db = request.result
|
|
75
|
+
const tx = request.transaction
|
|
76
|
+
let groups
|
|
77
|
+
if (!db.objectStoreNames.contains(GROUPS_STORE)) {
|
|
78
|
+
groups = db.createObjectStore(GROUPS_STORE, { keyPath: 'groupKey' })
|
|
79
|
+
} else {
|
|
80
|
+
groups = tx.objectStore(GROUPS_STORE)
|
|
81
|
+
}
|
|
82
|
+
if (!groups.indexNames.contains('byUpdatedAt')) groups.createIndex('byUpdatedAt', 'updatedAt')
|
|
83
|
+
|
|
84
|
+
let chunks
|
|
85
|
+
if (!db.objectStoreNames.contains(CHUNKS_STORE)) {
|
|
86
|
+
chunks = db.createObjectStore(CHUNKS_STORE, { keyPath: ['groupKey', 'index'] })
|
|
87
|
+
} else {
|
|
88
|
+
chunks = tx.objectStore(CHUNKS_STORE)
|
|
89
|
+
}
|
|
90
|
+
if (!chunks.indexNames.contains('byGroup')) chunks.createIndex('byGroup', 'groupKey')
|
|
15
91
|
|
|
16
|
-
|
|
17
|
-
|
|
92
|
+
if (!db.objectStoreNames.contains(STATE_STORE)) db.createObjectStore(STATE_STORE, { keyPath: 'key' })
|
|
93
|
+
}
|
|
94
|
+
request.onsuccess = () => {
|
|
95
|
+
const db = request.result
|
|
96
|
+
db.onversionchange = () => db.close()
|
|
97
|
+
resolve(db)
|
|
98
|
+
}
|
|
99
|
+
})
|
|
18
100
|
}
|
|
19
101
|
|
|
20
|
-
function
|
|
21
|
-
|
|
102
|
+
function normalizeBytes (value) {
|
|
103
|
+
if (value instanceof Uint8Array) return new Uint8Array(value)
|
|
104
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value)
|
|
105
|
+
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
|
106
|
+
throw new Error('RECEIVED_CHUNK_BYTES_REQUIRED')
|
|
22
107
|
}
|
|
23
108
|
|
|
24
109
|
function uniq (values) {
|
|
25
110
|
return [...new Set((values || []).filter(Boolean))]
|
|
26
111
|
}
|
|
27
112
|
|
|
28
|
-
function normalizeGroupKeys (value) {
|
|
29
|
-
return Array.isArray(value) ? uniq(value.filter(key => typeof key === 'string' && key)) : []
|
|
30
|
-
}
|
|
31
|
-
|
|
32
113
|
function normalizeReceived (received) {
|
|
33
114
|
if (!received || typeof received !== 'object' || Array.isArray(received)) return {}
|
|
34
115
|
return Object.fromEntries(
|
|
@@ -38,14 +119,14 @@ function normalizeReceived (received) {
|
|
|
38
119
|
)
|
|
39
120
|
}
|
|
40
121
|
|
|
41
|
-
function normalizeMeta (meta
|
|
122
|
+
function normalizeMeta (meta) {
|
|
42
123
|
if (!meta || typeof meta !== 'object') return null
|
|
43
124
|
const total = Number(meta.total)
|
|
44
125
|
const nextIndex = Number(meta.nextIndex)
|
|
45
126
|
const rowIndex = Number(meta.rowIndex)
|
|
46
|
-
if (!Number.isSafeInteger(total) || total < 1) return null
|
|
127
|
+
if (!meta.groupKey || !Number.isSafeInteger(total) || total < 1) return null
|
|
47
128
|
return {
|
|
48
|
-
groupKey,
|
|
129
|
+
groupKey: String(meta.groupKey),
|
|
49
130
|
channelPubkey: String(meta.channelPubkey || ''),
|
|
50
131
|
routerPubkey: String(meta.routerPubkey || ''),
|
|
51
132
|
total,
|
|
@@ -55,10 +136,19 @@ function normalizeMeta (meta, groupKey) {
|
|
|
55
136
|
rowIndex: Number.isSafeInteger(rowIndex) && rowIndex >= 0 ? rowIndex : 0,
|
|
56
137
|
carry: typeof meta.carry === 'string' ? meta.carry : '',
|
|
57
138
|
payloadCiphertext: typeof meta.payloadCiphertext === 'string' ? meta.payloadCiphertext : '',
|
|
58
|
-
receiverPubkeys: uniq(meta.receiverPubkeys
|
|
139
|
+
receiverPubkeys: uniq(meta.receiverPubkeys),
|
|
59
140
|
byteSize: Math.max(0, Number(meta.byteSize) || 0),
|
|
60
141
|
createdAt: Number(meta.createdAt) || Date.now(),
|
|
61
|
-
updatedAt: Number(meta.updatedAt) || Date.now()
|
|
142
|
+
updatedAt: Number(meta.updatedAt) || Date.now(),
|
|
143
|
+
drainToken: typeof meta.drainToken === 'string' ? meta.drainToken : '',
|
|
144
|
+
drainUntil: Math.max(0, Number(meta.drainUntil) || 0)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function normalizeUsage (value) {
|
|
149
|
+
return {
|
|
150
|
+
key: USAGE_KEY,
|
|
151
|
+
usedBytes: Number.isSafeInteger(value?.usedBytes) && value.usedBytes >= 0 ? value.usedBytes : 0
|
|
62
152
|
}
|
|
63
153
|
}
|
|
64
154
|
|
|
@@ -70,217 +160,218 @@ function isQuotaExceeded (err) {
|
|
|
70
160
|
/quota/i.test(err?.message || '')
|
|
71
161
|
}
|
|
72
162
|
|
|
73
|
-
function
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const bytes = base64ToBytes(part)
|
|
79
|
-
const joined = new Uint8Array(carry.length + bytes.length)
|
|
80
|
-
joined.set(carry)
|
|
81
|
-
joined.set(bytes, carry.length)
|
|
82
|
-
|
|
83
|
-
const completeLength = joined.length - (joined.length % 3)
|
|
84
|
-
if (completeLength) out += bytesToBase64(joined.slice(0, completeLength))
|
|
85
|
-
carry = joined.slice(completeLength)
|
|
86
|
-
}
|
|
163
|
+
function randomToken () {
|
|
164
|
+
const bytes = globalThis.crypto?.getRandomValues?.(new Uint8Array(16))
|
|
165
|
+
if (bytes) return [...bytes].map(value => value.toString(16).padStart(2, '0')).join('')
|
|
166
|
+
return `${Date.now().toString(16)}:${Math.random().toString(16).slice(2)}`
|
|
167
|
+
}
|
|
87
168
|
|
|
88
|
-
|
|
89
|
-
return
|
|
169
|
+
function wait (ms) {
|
|
170
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
90
171
|
}
|
|
91
172
|
|
|
92
173
|
export function createReceivedChunkStore ({
|
|
93
174
|
prefix = DEFAULT_PREFIX,
|
|
94
|
-
|
|
175
|
+
indexedDB = globalThis.indexedDB,
|
|
95
176
|
ttlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS,
|
|
96
177
|
maxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES
|
|
97
178
|
} = {}) {
|
|
98
|
-
const groupsKey = `${prefix}:groups`
|
|
99
179
|
const configuredTtlMs = Number.isFinite(ttlMs) && ttlMs >= 0 ? ttlMs : DEFAULT_RECEIVED_CHUNK_TTL_MS
|
|
100
180
|
const configuredMaxBytes = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : Infinity
|
|
181
|
+
let dbPromise
|
|
182
|
+
let readyPromise
|
|
101
183
|
|
|
102
|
-
function
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
function metaKey (groupKey) {
|
|
107
|
-
return `${prefix}:group:${groupKey}:meta`
|
|
184
|
+
function database () {
|
|
185
|
+
dbPromise ||= openDatabase(indexedDB, `${prefix}:idb`)
|
|
186
|
+
return dbPromise
|
|
108
187
|
}
|
|
109
188
|
|
|
110
|
-
function
|
|
111
|
-
|
|
189
|
+
async function transaction (storeNames, mode, work) {
|
|
190
|
+
const db = await database()
|
|
191
|
+
const tx = db.transaction(storeNames, mode)
|
|
192
|
+
const done = transactionDone(tx)
|
|
193
|
+
try {
|
|
194
|
+
// `work` may await only IDB requests issued through `run` with this tx.
|
|
195
|
+
const result = await work(tx)
|
|
196
|
+
await done
|
|
197
|
+
return result
|
|
198
|
+
} catch (err) {
|
|
199
|
+
try { tx.abort() } catch {}
|
|
200
|
+
try { await done } catch {}
|
|
201
|
+
throw err
|
|
202
|
+
}
|
|
112
203
|
}
|
|
113
204
|
|
|
114
205
|
function groupKeyFor (channelPubkey, routerPubkey) {
|
|
115
206
|
return `${channelPubkey}:${routerPubkey}`
|
|
116
207
|
}
|
|
117
208
|
|
|
118
|
-
function
|
|
119
|
-
return
|
|
209
|
+
async function readUsage (tx) {
|
|
210
|
+
return normalizeUsage((await run('get', [USAGE_KEY], STATE_STORE, null, { tx })).result)
|
|
120
211
|
}
|
|
121
212
|
|
|
122
|
-
function
|
|
123
|
-
|
|
124
|
-
if (normalized.length) storage().setItem(groupsKey, JSON.stringify(normalized))
|
|
125
|
-
else storage().removeItem(groupsKey)
|
|
213
|
+
async function writeUsage (tx, usage) {
|
|
214
|
+
await run('put', [normalizeUsage(usage)], STATE_STORE, null, { tx })
|
|
126
215
|
}
|
|
127
216
|
|
|
128
|
-
function
|
|
129
|
-
const
|
|
130
|
-
|
|
217
|
+
async function deleteGroupInTransaction (tx, groupKey, usage, knownMeta) {
|
|
218
|
+
const meta = knownMeta || normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
|
|
219
|
+
const keys = (await run('getAllKeys', [globalThis.IDBKeyRange?.only?.(groupKey) ?? groupKey], CHUNKS_STORE, 'byGroup', { tx })).result
|
|
220
|
+
for (const key of keys) await run('delete', [key], CHUNKS_STORE, null, { tx })
|
|
221
|
+
await run('delete', [groupKey], GROUPS_STORE, null, { tx })
|
|
222
|
+
if (usage && meta) usage.usedBytes = Math.max(0, usage.usedBytes - meta.byteSize)
|
|
223
|
+
return Boolean(meta || keys.length)
|
|
131
224
|
}
|
|
132
225
|
|
|
133
|
-
function
|
|
134
|
-
|
|
226
|
+
async function oldestGroupsInTransaction (tx, except = '') {
|
|
227
|
+
const values = (await run('getAll', [], GROUPS_STORE, null, { tx })).result
|
|
228
|
+
return values
|
|
229
|
+
.map(normalizeMeta)
|
|
230
|
+
.filter(meta => meta && meta.groupKey !== except)
|
|
231
|
+
.sort((left, right) => left.updatedAt - right.updatedAt || left.groupKey.localeCompare(right.groupKey))
|
|
135
232
|
}
|
|
136
233
|
|
|
137
|
-
function
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (meta) {
|
|
148
|
-
for (const index of Object.keys(meta.received)) {
|
|
149
|
-
storage().removeItem(chunkKey(groupKey, index))
|
|
234
|
+
async function cleanupStaleRaw (nowMs = Date.now()) {
|
|
235
|
+
const cutoff = nowMs - configuredTtlMs
|
|
236
|
+
return transaction([GROUPS_STORE, CHUNKS_STORE, STATE_STORE], 'readwrite', async tx => {
|
|
237
|
+
const usage = await readUsage(tx)
|
|
238
|
+
const groups = await oldestGroupsInTransaction(tx)
|
|
239
|
+
let removed = 0
|
|
240
|
+
for (const meta of groups) {
|
|
241
|
+
if (meta.updatedAt > cutoff && (!Number.isFinite(configuredMaxBytes) || usage.usedBytes <= configuredMaxBytes)) continue
|
|
242
|
+
await deleteGroupInTransaction(tx, meta.groupKey, usage, meta)
|
|
243
|
+
removed++
|
|
150
244
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
245
|
+
await writeUsage(tx, usage)
|
|
246
|
+
return removed
|
|
247
|
+
})
|
|
154
248
|
}
|
|
155
249
|
|
|
156
|
-
function
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const liveKeys = []
|
|
160
|
-
for (const groupKey of keys) {
|
|
161
|
-
const meta = readMeta(groupKey)
|
|
162
|
-
if (!meta) continue
|
|
163
|
-
metas.push(meta)
|
|
164
|
-
liveKeys.push(groupKey)
|
|
165
|
-
}
|
|
166
|
-
if (liveKeys.length !== keys.length) writeGroupKeys(liveKeys)
|
|
167
|
-
return metas
|
|
250
|
+
function ready () {
|
|
251
|
+
readyPromise ||= cleanupStaleRaw()
|
|
252
|
+
return readyPromise
|
|
168
253
|
}
|
|
169
254
|
|
|
170
|
-
function
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}, 0)
|
|
255
|
+
async function cleanupStale (nowMs = Date.now()) {
|
|
256
|
+
await ready()
|
|
257
|
+
return cleanupStaleRaw(nowMs)
|
|
174
258
|
}
|
|
175
259
|
|
|
176
|
-
function
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
260
|
+
async function putOnce ({ channelPubkey, routerPubkey, index, total, contentBytes }) {
|
|
261
|
+
const groupKey = groupKeyFor(channelPubkey, routerPubkey)
|
|
262
|
+
const bytes = normalizeBytes(contentBytes)
|
|
263
|
+
const now = Date.now()
|
|
181
264
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
if (
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
265
|
+
return transaction([GROUPS_STORE, CHUNKS_STORE, STATE_STORE], 'readwrite', async tx => {
|
|
266
|
+
const usage = await readUsage(tx)
|
|
267
|
+
let meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
|
|
268
|
+
const staleCutoff = now - configuredTtlMs
|
|
269
|
+
if (meta && meta.updatedAt <= staleCutoff) {
|
|
270
|
+
await deleteGroupInTransaction(tx, groupKey, usage, meta)
|
|
271
|
+
meta = null
|
|
272
|
+
}
|
|
273
|
+
if (meta && meta.total !== total) {
|
|
274
|
+
await deleteGroupInTransaction(tx, groupKey, usage, meta)
|
|
275
|
+
meta = null
|
|
276
|
+
}
|
|
277
|
+
if (!meta) {
|
|
278
|
+
meta = normalizeMeta({
|
|
279
|
+
groupKey,
|
|
280
|
+
channelPubkey,
|
|
281
|
+
routerPubkey,
|
|
282
|
+
total,
|
|
283
|
+
received: {},
|
|
284
|
+
receivedCount: 0,
|
|
285
|
+
nextIndex: 0,
|
|
286
|
+
rowIndex: 0,
|
|
287
|
+
carry: '',
|
|
288
|
+
payloadCiphertext: '',
|
|
289
|
+
receiverPubkeys: [],
|
|
290
|
+
byteSize: 0,
|
|
291
|
+
createdAt: now,
|
|
292
|
+
updatedAt: now
|
|
293
|
+
})
|
|
294
|
+
}
|
|
190
295
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
296
|
+
const existing = (await run('get', [[groupKey, index]], CHUNKS_STORE, null, { tx })).result
|
|
297
|
+
if (!existing) {
|
|
298
|
+
if (bytes.byteLength > configuredMaxBytes || meta.byteSize + bytes.byteLength > configuredMaxBytes) {
|
|
299
|
+
await deleteGroupInTransaction(tx, groupKey, usage, meta)
|
|
300
|
+
await writeUsage(tx, usage)
|
|
301
|
+
return { tooLarge: true, meta: null }
|
|
302
|
+
}
|
|
199
303
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
304
|
+
const requiredBytes = bytes.byteLength
|
|
305
|
+
const candidates = await oldestGroupsInTransaction(tx, groupKey)
|
|
306
|
+
for (const candidate of candidates) {
|
|
307
|
+
if (candidate.updatedAt > staleCutoff) continue
|
|
308
|
+
await deleteGroupInTransaction(tx, candidate.groupKey, usage, candidate)
|
|
309
|
+
}
|
|
310
|
+
for (const candidate of candidates) {
|
|
311
|
+
if (candidate.updatedAt <= staleCutoff) continue
|
|
312
|
+
if (!Number.isFinite(configuredMaxBytes) || usage.usedBytes + requiredBytes <= configuredMaxBytes) break
|
|
313
|
+
await deleteGroupInTransaction(tx, candidate.groupKey, usage, candidate)
|
|
314
|
+
}
|
|
315
|
+
if (Number.isFinite(configuredMaxBytes) && usage.usedBytes + requiredBytes > configuredMaxBytes) {
|
|
316
|
+
await deleteGroupInTransaction(tx, groupKey, usage, meta)
|
|
317
|
+
await writeUsage(tx, usage)
|
|
318
|
+
return { tooLarge: true, meta: null }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
await run('put', [{ groupKey, index, bytes }], CHUNKS_STORE, null, { tx })
|
|
322
|
+
meta.received[String(index)] = true
|
|
323
|
+
meta.receivedCount++
|
|
324
|
+
meta.byteSize += requiredBytes
|
|
325
|
+
usage.usedBytes += requiredBytes
|
|
208
326
|
}
|
|
209
|
-
groupKey = oldestGroupKey({ except })
|
|
210
|
-
}
|
|
211
327
|
|
|
212
|
-
|
|
328
|
+
meta.total = total
|
|
329
|
+
meta.updatedAt = now
|
|
330
|
+
await run('put', [meta], GROUPS_STORE, null, { tx })
|
|
331
|
+
await writeUsage(tx, usage)
|
|
332
|
+
return { tooLarge: false, meta }
|
|
333
|
+
})
|
|
213
334
|
}
|
|
214
335
|
|
|
215
|
-
function
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
336
|
+
async function evictOldestGroup (except = '') {
|
|
337
|
+
return transaction([GROUPS_STORE, CHUNKS_STORE, STATE_STORE], 'readwrite', async tx => {
|
|
338
|
+
const usage = await readUsage(tx)
|
|
339
|
+
const oldest = (await oldestGroupsInTransaction(tx, except))[0]
|
|
340
|
+
if (!oldest) return false
|
|
341
|
+
await deleteGroupInTransaction(tx, oldest.groupKey, usage, oldest)
|
|
342
|
+
await writeUsage(tx, usage)
|
|
343
|
+
return true
|
|
344
|
+
})
|
|
221
345
|
}
|
|
222
346
|
|
|
223
|
-
function put ({ channelPubkey, routerPubkey, index, total,
|
|
347
|
+
async function put ({ channelPubkey, routerPubkey, index, total, contentBytes }) {
|
|
224
348
|
if (!channelPubkey || !routerPubkey) throw new Error('RECEIVED_CHUNK_GROUP_REQUIRED')
|
|
225
349
|
if (!Number.isSafeInteger(index) || !Number.isSafeInteger(total) || index < 0 || total < 1 || index >= total) {
|
|
226
350
|
throw new Error('INVALID_RECEIVED_CHUNK_INDEX')
|
|
227
351
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (meta && meta.total !== total) {
|
|
238
|
-
removeGroup(groupKey)
|
|
239
|
-
meta = null
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (!meta) {
|
|
243
|
-
meta = normalizeMeta({
|
|
244
|
-
channelPubkey,
|
|
245
|
-
routerPubkey,
|
|
246
|
-
total,
|
|
247
|
-
received: {},
|
|
248
|
-
receivedCount: 0,
|
|
249
|
-
nextIndex: 0,
|
|
250
|
-
rowIndex: 0,
|
|
251
|
-
carry: '',
|
|
252
|
-
payloadCiphertext: '',
|
|
253
|
-
receiverPubkeys: [],
|
|
254
|
-
byteSize: 0,
|
|
255
|
-
createdAt: now,
|
|
256
|
-
updatedAt: now
|
|
257
|
-
}, groupKey)
|
|
258
|
-
addGroupKey(groupKey)
|
|
259
|
-
writeMeta(meta)
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
if (!meta.received[String(index)]) {
|
|
263
|
-
if (meta.byteSize + nextBytes > configuredMaxBytes) {
|
|
264
|
-
removeGroup(groupKey)
|
|
265
|
-
throw new Error('RECEIVED_CHUNK_GROUP_TOO_LARGE')
|
|
352
|
+
const bytes = normalizeBytes(contentBytes)
|
|
353
|
+
await ready()
|
|
354
|
+
while (true) {
|
|
355
|
+
try {
|
|
356
|
+
const result = await putOnce({ channelPubkey, routerPubkey, index, total, contentBytes: bytes })
|
|
357
|
+
if (result.tooLarge) throw new Error('RECEIVED_CHUNK_GROUP_TOO_LARGE')
|
|
358
|
+
return result.meta
|
|
359
|
+
} catch (err) {
|
|
360
|
+
if (!isQuotaExceeded(err) || !await evictOldestGroup(groupKeyFor(channelPubkey, routerPubkey))) throw err
|
|
266
361
|
}
|
|
267
|
-
writeChunk(chunkKey(groupKey, index), chunk, nextBytes, groupKey)
|
|
268
|
-
meta.received[String(index)] = true
|
|
269
|
-
meta.receivedCount++
|
|
270
|
-
meta.byteSize += nextBytes
|
|
271
362
|
}
|
|
363
|
+
}
|
|
272
364
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
365
|
+
async function readMeta (groupKey) {
|
|
366
|
+
await ready()
|
|
367
|
+
return transaction([GROUPS_STORE], 'readonly', async tx => {
|
|
368
|
+
return normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
|
|
369
|
+
})
|
|
278
370
|
}
|
|
279
371
|
|
|
280
|
-
function status (metaOrGroupKey) {
|
|
281
|
-
const meta = typeof metaOrGroupKey === 'string' ? readMeta(metaOrGroupKey) : metaOrGroupKey
|
|
372
|
+
async function status (metaOrGroupKey) {
|
|
373
|
+
const meta = typeof metaOrGroupKey === 'string' ? await readMeta(metaOrGroupKey) : normalizeMeta(metaOrGroupKey)
|
|
282
374
|
if (!meta) return { received: 0, missing: [] }
|
|
283
|
-
|
|
284
375
|
const missing = []
|
|
285
376
|
let received = 0
|
|
286
377
|
for (let index = 0; index < meta.total; index++) {
|
|
@@ -291,98 +382,174 @@ export function createReceivedChunkStore ({
|
|
|
291
382
|
}
|
|
292
383
|
|
|
293
384
|
function rememberReceiverPubkey (meta, pubkey) {
|
|
294
|
-
if (
|
|
295
|
-
meta.receiverPubkeys.push(pubkey)
|
|
385
|
+
if (pubkey && !meta.receiverPubkeys.includes(pubkey)) meta.receiverPubkeys.push(pubkey)
|
|
296
386
|
}
|
|
297
387
|
|
|
298
388
|
function rememberPayloadCiphertext (meta, ciphertext) {
|
|
299
389
|
if (!meta.payloadCiphertext) meta.payloadCiphertext = ciphertext
|
|
300
390
|
}
|
|
301
391
|
|
|
302
|
-
async function
|
|
303
|
-
|
|
304
|
-
|
|
392
|
+
async function claimDrain (groupKey, token) {
|
|
393
|
+
while (true) {
|
|
394
|
+
const now = Date.now()
|
|
395
|
+
const result = await transaction([GROUPS_STORE], 'readwrite', async tx => {
|
|
396
|
+
const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
|
|
397
|
+
if (!meta) return { meta: null, waitMs: 0 }
|
|
398
|
+
if (meta.drainToken && meta.drainToken !== token && meta.drainUntil > now) {
|
|
399
|
+
return { meta: null, waitMs: Math.min(DRAIN_LEASE_MS, meta.drainUntil - now) }
|
|
400
|
+
}
|
|
401
|
+
meta.drainToken = token
|
|
402
|
+
meta.drainUntil = now + DRAIN_LEASE_MS
|
|
403
|
+
await run('put', [meta], GROUPS_STORE, null, { tx })
|
|
404
|
+
return { meta, waitMs: 0 }
|
|
405
|
+
})
|
|
406
|
+
if (!result.waitMs) return result.meta
|
|
407
|
+
await wait(result.waitMs)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
305
410
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
if (
|
|
411
|
+
async function readDrainChunk (groupKey, token) {
|
|
412
|
+
return transaction([GROUPS_STORE, CHUNKS_STORE], 'readonly', async tx => {
|
|
413
|
+
const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
|
|
414
|
+
if (!meta || meta.drainToken !== token) return { meta: null, bytes: null }
|
|
415
|
+
if (meta.nextIndex >= meta.total) return { meta, bytes: null }
|
|
416
|
+
const chunk = (await run('get', [[groupKey, meta.nextIndex]], CHUNKS_STORE, null, { tx })).result
|
|
417
|
+
return { meta, bytes: chunk ? normalizeBytes(chunk.bytes) : null }
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function commitDrainMeta (nextMeta, token) {
|
|
422
|
+
return transaction([GROUPS_STORE], 'readwrite', async tx => {
|
|
423
|
+
const current = normalizeMeta((await run('get', [nextMeta.groupKey], GROUPS_STORE, null, { tx })).result)
|
|
424
|
+
if (!current || current.drainToken !== token) return null
|
|
425
|
+
current.nextIndex = nextMeta.nextIndex
|
|
426
|
+
current.rowIndex = nextMeta.rowIndex
|
|
427
|
+
current.carry = nextMeta.carry
|
|
428
|
+
current.payloadCiphertext = nextMeta.payloadCiphertext
|
|
429
|
+
current.receiverPubkeys = uniq(nextMeta.receiverPubkeys)
|
|
430
|
+
current.updatedAt = nextMeta.updatedAt
|
|
431
|
+
current.drainUntil = Date.now() + DRAIN_LEASE_MS
|
|
432
|
+
await run('put', [current], GROUPS_STORE, null, { tx })
|
|
433
|
+
return current
|
|
434
|
+
})
|
|
435
|
+
}
|
|
310
436
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
437
|
+
async function releaseDrain (groupKey, token) {
|
|
438
|
+
return transaction([GROUPS_STORE], 'readwrite', async tx => {
|
|
439
|
+
const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
|
|
440
|
+
if (!meta || meta.drainToken !== token) return false
|
|
441
|
+
meta.drainToken = ''
|
|
442
|
+
meta.drainUntil = 0
|
|
443
|
+
await run('put', [meta], GROUPS_STORE, null, { tx })
|
|
444
|
+
return true
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function drainAvailable (groupKey, { onLine } = {}) {
|
|
449
|
+
await ready()
|
|
450
|
+
const token = randomToken()
|
|
451
|
+
let meta = await claimDrain(groupKey, token)
|
|
452
|
+
if (!meta) return { complete: false, stopped: false, meta: null }
|
|
453
|
+
try {
|
|
454
|
+
while (meta.nextIndex < meta.total) {
|
|
455
|
+
const snapshot = await readDrainChunk(groupKey, token)
|
|
456
|
+
meta = snapshot.meta
|
|
457
|
+
if (!meta || !snapshot.bytes) break
|
|
458
|
+
|
|
459
|
+
const text = `${meta.carry}${decoder.decode(snapshot.bytes)}`
|
|
460
|
+
let start = 0
|
|
461
|
+
let end = text.indexOf('\n', start)
|
|
462
|
+
while (end !== -1) {
|
|
463
|
+
const line = text.slice(start, end)
|
|
464
|
+
start = end + 1
|
|
465
|
+
if (line) {
|
|
466
|
+
const result = await onLine?.(line, meta.rowIndex, meta, { rememberPayloadCiphertext, rememberReceiverPubkey })
|
|
467
|
+
meta.rowIndex++
|
|
468
|
+
if (result?.stop) {
|
|
469
|
+
meta.updatedAt = Date.now()
|
|
470
|
+
meta = await commitDrainMeta(meta, token)
|
|
471
|
+
return { complete: false, stopped: true, meta }
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
end = text.indexOf('\n', start)
|
|
475
|
+
}
|
|
476
|
+
meta.carry = text.slice(start)
|
|
477
|
+
meta.nextIndex++
|
|
478
|
+
meta.updatedAt = Date.now()
|
|
479
|
+
meta = await commitDrainMeta(meta, token)
|
|
480
|
+
if (!meta) break
|
|
481
|
+
}
|
|
314
482
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (line) {
|
|
319
|
-
const result = await onLine?.(line, meta.rowIndex, meta, { rememberPayloadCiphertext, rememberReceiverPubkey })
|
|
483
|
+
if (meta && meta.nextIndex >= meta.total) {
|
|
484
|
+
if (meta.carry) {
|
|
485
|
+
const result = await onLine?.(meta.carry, meta.rowIndex, meta, { rememberPayloadCiphertext, rememberReceiverPubkey })
|
|
320
486
|
meta.rowIndex++
|
|
487
|
+
meta.carry = ''
|
|
321
488
|
if (result?.stop) {
|
|
322
489
|
meta.updatedAt = Date.now()
|
|
323
|
-
|
|
490
|
+
meta = await commitDrainMeta(meta, token)
|
|
324
491
|
return { complete: false, stopped: true, meta }
|
|
325
492
|
}
|
|
326
493
|
}
|
|
327
|
-
|
|
494
|
+
meta.updatedAt = Date.now()
|
|
495
|
+
meta = await commitDrainMeta(meta, token)
|
|
496
|
+
return { complete: Boolean(meta), stopped: false, meta }
|
|
328
497
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
meta.nextIndex++
|
|
333
|
-
meta.updatedAt = Date.now()
|
|
334
|
-
writeMeta(meta)
|
|
498
|
+
return { complete: false, stopped: false, meta }
|
|
499
|
+
} finally {
|
|
500
|
+
await releaseDrain(groupKey, token).catch(() => {})
|
|
335
501
|
}
|
|
502
|
+
}
|
|
336
503
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
504
|
+
async function readChunkBytes (groupKey) {
|
|
505
|
+
await ready()
|
|
506
|
+
return transaction([GROUPS_STORE, CHUNKS_STORE], 'readonly', async tx => {
|
|
507
|
+
const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
|
|
508
|
+
if (!meta) return { meta: null, chunks: [] }
|
|
509
|
+
const chunks = []
|
|
510
|
+
for (let index = 0; index < meta.total; index++) {
|
|
511
|
+
const chunk = (await run('get', [[groupKey, index]], CHUNKS_STORE, null, { tx })).result
|
|
512
|
+
if (!chunk) throw new Error('RECEIVED_CHUNK_MISSING')
|
|
513
|
+
chunks.push(normalizeBytes(chunk.bytes))
|
|
347
514
|
}
|
|
348
|
-
meta
|
|
349
|
-
|
|
350
|
-
return { complete: true, stopped: false, meta }
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
return { complete: false, stopped: false, meta }
|
|
515
|
+
return { meta, chunks }
|
|
516
|
+
})
|
|
354
517
|
}
|
|
355
518
|
|
|
356
|
-
function
|
|
357
|
-
|
|
358
|
-
if (!meta) return ''
|
|
359
|
-
const parts = []
|
|
360
|
-
for (let index = 0; index < meta.total; index++) {
|
|
361
|
-
const chunk = storage().getItem(chunkKey(groupKey, index))
|
|
362
|
-
if (chunk == null) throw new Error('RECEIVED_CHUNK_MISSING')
|
|
363
|
-
parts.push(chunk)
|
|
364
|
-
}
|
|
365
|
-
return joinBase64Chunks(parts)
|
|
519
|
+
async function readChunkContents (groupKey) {
|
|
520
|
+
return (await readChunkBytes(groupKey)).chunks.map(bytes => decoder.decode(bytes))
|
|
366
521
|
}
|
|
367
522
|
|
|
368
|
-
function
|
|
369
|
-
const
|
|
370
|
-
return
|
|
523
|
+
async function readEnvelopeBundleContent (groupKey) {
|
|
524
|
+
const { chunks } = await readChunkBytes(groupKey)
|
|
525
|
+
return bytesToBase64(joinChunks(chunks))
|
|
371
526
|
}
|
|
372
527
|
|
|
373
|
-
function
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
528
|
+
function joinChunks (chunks) {
|
|
529
|
+
let length = 0
|
|
530
|
+
for (const chunk of chunks) length += chunk.byteLength
|
|
531
|
+
const joined = new Uint8Array(length)
|
|
532
|
+
let offset = 0
|
|
533
|
+
for (const chunk of chunks) {
|
|
534
|
+
joined.set(chunk, offset)
|
|
535
|
+
offset += chunk.byteLength
|
|
381
536
|
}
|
|
382
|
-
return
|
|
537
|
+
return joined
|
|
383
538
|
}
|
|
384
539
|
|
|
385
|
-
|
|
540
|
+
async function readEnvelopeBundleText (groupKey) {
|
|
541
|
+
return decoder.decode(joinChunks((await readChunkBytes(groupKey)).chunks))
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function removeGroup (groupKey) {
|
|
545
|
+
await ready()
|
|
546
|
+
return transaction([GROUPS_STORE, CHUNKS_STORE, STATE_STORE], 'readwrite', async tx => {
|
|
547
|
+
const usage = await readUsage(tx)
|
|
548
|
+
const removed = await deleteGroupInTransaction(tx, groupKey, usage)
|
|
549
|
+
await writeUsage(tx, usage)
|
|
550
|
+
return removed
|
|
551
|
+
})
|
|
552
|
+
}
|
|
386
553
|
|
|
387
554
|
return {
|
|
388
555
|
cleanupStale,
|
package/private-message/index.js
CHANGED
|
@@ -301,7 +301,7 @@ function rebuildSubscriptions ({ _subscribe = privateChannel.subscribe, graceful
|
|
|
301
301
|
modeByPubkey: modesForChannels(channelList),
|
|
302
302
|
receivedChunkTtlMs: maxWatchNumber(channelList, 'receivedChunkTtlMs'),
|
|
303
303
|
receivedChunkMaxBytes: maxWatchNumber(channelList, 'receivedChunkMaxBytes'),
|
|
304
|
-
|
|
304
|
+
receivedChunkIndexedDB: firstWatchValue(channelList, 'receivedChunkIndexedDB'),
|
|
305
305
|
ignoredGroupTtlMs: maxWatchNumber(channelList, 'ignoredGroupTtlMs'),
|
|
306
306
|
ignoredGroupMaxEntries: maxWatchNumber(channelList, 'ignoredGroupMaxEntries'),
|
|
307
307
|
limit: 0,
|
|
@@ -348,7 +348,7 @@ export async function watch ({
|
|
|
348
348
|
onError,
|
|
349
349
|
receivedChunkTtlMs,
|
|
350
350
|
receivedChunkMaxBytes,
|
|
351
|
-
|
|
351
|
+
receivedChunkIndexedDB,
|
|
352
352
|
ignoredGroupTtlMs,
|
|
353
353
|
ignoredGroupMaxEntries,
|
|
354
354
|
since = nowSeconds(),
|
|
@@ -372,7 +372,7 @@ export async function watch ({
|
|
|
372
372
|
mode,
|
|
373
373
|
receivedChunkTtlMs,
|
|
374
374
|
receivedChunkMaxBytes,
|
|
375
|
-
|
|
375
|
+
receivedChunkIndexedDB,
|
|
376
376
|
ignoredGroupTtlMs,
|
|
377
377
|
ignoredGroupMaxEntries,
|
|
378
378
|
callbacks,
|
|
@@ -388,7 +388,7 @@ export async function watch ({
|
|
|
388
388
|
current.mode === next.mode &&
|
|
389
389
|
current.receivedChunkTtlMs === next.receivedChunkTtlMs &&
|
|
390
390
|
current.receivedChunkMaxBytes === next.receivedChunkMaxBytes &&
|
|
391
|
-
current.
|
|
391
|
+
current.receivedChunkIndexedDB === next.receivedChunkIndexedDB &&
|
|
392
392
|
current.ignoredGroupTtlMs === next.ignoredGroupTtlMs &&
|
|
393
393
|
current.ignoredGroupMaxEntries === next.ignoredGroupMaxEntries
|
|
394
394
|
) {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// await messenger.clearChannel(channelPubkey)
|
|
27
27
|
//
|
|
28
28
|
// Missed-message recovery:
|
|
29
|
-
// - Each watched channel stores lastSeenAt/lastWatchedAt in
|
|
29
|
+
// - Each watched channel stores lastSeenAt/lastWatchedAt in IndexedDB.
|
|
30
30
|
// - Re-watching after reload fetches the gap from lastSeenAt to now.
|
|
31
31
|
// - Browser offline/online events add explicit offline ranges with a small skew.
|
|
32
32
|
// - Ranges older than 7 days are ignored; channel state not watched for 45 days is pruned.
|
|
@@ -42,6 +42,7 @@ import { getRelaysByPubkey, pickRelaysForPubkeys, subscribeRelayListUpdates } fr
|
|
|
42
42
|
import * as privateChannel from '../private-channel/index.js'
|
|
43
43
|
import { cleanupTemporaryStorage as cleanupTemporaryChannelStorage } from '../temporary-storage/index.js'
|
|
44
44
|
import { createQueue } from '../idb-queue/index.js'
|
|
45
|
+
import { createChannelStateStore } from './services/channel-state.js'
|
|
45
46
|
import { DEFAULT_STALE_CHANNEL_SECONDS } from './constants/index.js'
|
|
46
47
|
import {
|
|
47
48
|
compactSeedNymCarriers,
|
|
@@ -121,14 +122,14 @@ function isPlainObject (value) {
|
|
|
121
122
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
function storesRecoverySeeds (mode) {
|
|
125
|
-
return mode === 'seeder' || mode === 'watchtower'
|
|
126
|
-
}
|
|
127
|
-
|
|
128
125
|
function parseJson (raw, fallback) {
|
|
129
126
|
try { return JSON.parse(raw || '') } catch { return fallback }
|
|
130
127
|
}
|
|
131
128
|
|
|
129
|
+
function storesRecoverySeeds (mode) {
|
|
130
|
+
return mode === 'seeder' || mode === 'watchtower'
|
|
131
|
+
}
|
|
132
|
+
|
|
132
133
|
export class PrivateMessenger {
|
|
133
134
|
static cleanupTemporaryStorage ({ storageArea = globalThis.sessionStorage } = {}) {
|
|
134
135
|
cleanupTemporaryChannelStorage({ storageArea })
|
|
@@ -195,6 +196,9 @@ export class PrivateMessenger {
|
|
|
195
196
|
this.prefix = ''
|
|
196
197
|
this.queue = null
|
|
197
198
|
this.seedQueue = null
|
|
199
|
+
this.stateStore = null
|
|
200
|
+
this.state = { channels: {} }
|
|
201
|
+
this.stateWriteTail = Promise.resolve()
|
|
198
202
|
this.channels = new Map()
|
|
199
203
|
this.stopByChannel = new Map()
|
|
200
204
|
this.presenceTimers = new Map()
|
|
@@ -229,6 +233,11 @@ export class PrivateMessenger {
|
|
|
229
233
|
evictionPolicy: 'fifo',
|
|
230
234
|
indexedDB: this._indexedDB
|
|
231
235
|
})
|
|
236
|
+
this.stateStore = await createChannelStateStore({
|
|
237
|
+
prefix: this.prefix,
|
|
238
|
+
indexedDB: this._indexedDB
|
|
239
|
+
})
|
|
240
|
+
this.state = { channels: await this.stateStore.load() }
|
|
232
241
|
await this.cleanupStaleChannels()
|
|
233
242
|
await this.update({ userSigner, contentKeySigner, nymSigner: this.nymSigner, channels, relays, mode })
|
|
234
243
|
return this
|
|
@@ -372,18 +381,25 @@ export class PrivateMessenger {
|
|
|
372
381
|
return { relayToReceivers: derived, recoveryRelays }
|
|
373
382
|
}
|
|
374
383
|
|
|
375
|
-
stateKey () {
|
|
376
|
-
return `${this.prefix}:state`
|
|
377
|
-
}
|
|
378
|
-
|
|
379
384
|
readState () {
|
|
380
|
-
|
|
381
|
-
if (!isPlainObject(state.channels)) state.channels = {}
|
|
382
|
-
return state
|
|
385
|
+
return structuredClone(this.state)
|
|
383
386
|
}
|
|
384
387
|
|
|
385
388
|
writeState (state) {
|
|
386
|
-
|
|
389
|
+
const next = {
|
|
390
|
+
channels: isPlainObject(state?.channels) ? structuredClone(state.channels) : {}
|
|
391
|
+
}
|
|
392
|
+
this.state = next
|
|
393
|
+
const snapshot = structuredClone(next.channels)
|
|
394
|
+
const write = this.stateWriteTail.then(() => this.stateStore.replace(snapshot))
|
|
395
|
+
this.stateWriteTail = write.catch(err => {
|
|
396
|
+
try { this.onError?.(err) } catch {}
|
|
397
|
+
})
|
|
398
|
+
return write
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async flushStateWrites () {
|
|
402
|
+
await this.stateWriteTail
|
|
387
403
|
}
|
|
388
404
|
|
|
389
405
|
updateChannelState (pubkey, patch) {
|
|
@@ -589,6 +605,7 @@ export class PrivateMessenger {
|
|
|
589
605
|
onSeed: seed => this.queueIncoming(() => this.enqueueSeed(pubkey, seed)),
|
|
590
606
|
onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
|
|
591
607
|
receivedChunkTtlMs: this.offlineRecoverySeconds * 1000,
|
|
608
|
+
receivedChunkIndexedDB: this._indexedDB,
|
|
592
609
|
onError: err => this.onError?.(err)
|
|
593
610
|
})
|
|
594
611
|
this.stopByChannel.set(pubkey, stop)
|
|
@@ -1272,6 +1289,7 @@ export class PrivateMessenger {
|
|
|
1272
1289
|
mode: channel.mode,
|
|
1273
1290
|
modeByPubkey: { [pubkey]: channel.mode },
|
|
1274
1291
|
receivedChunkTtlMs: this.offlineRecoverySeconds * 1000,
|
|
1292
|
+
receivedChunkIndexedDB: this._indexedDB,
|
|
1275
1293
|
onEvent: (event, outer, meta) => this.queueIncoming(() => this.enqueueRumor(eventType(event), pubkey, { event, outer, meta, payload: parseEventContent(event) })),
|
|
1276
1294
|
onNymEvent: (event, outer, meta) => this.queueIncoming(() => this.enqueueRumor('nym', pubkey, { event, outer, meta, payload: parseEventContent(event) })),
|
|
1277
1295
|
onSeedEvent: seed => this.queueIncoming(() => this.enqueueSeed(pubkey, seed)),
|
|
@@ -1299,6 +1317,7 @@ export class PrivateMessenger {
|
|
|
1299
1317
|
this._privateMessage.clearChannelState?.(pubkey)
|
|
1300
1318
|
this.channels.delete(pubkey)
|
|
1301
1319
|
this.removeChannelState(pubkey)
|
|
1320
|
+
await this.flushStateWrites()
|
|
1302
1321
|
await this.queue.removeBy('byChannel', pubkey)
|
|
1303
1322
|
await this.seedQueue.removeBy('byChannel', pubkey)
|
|
1304
1323
|
this.ensureRelayListWatcher()
|
|
@@ -1321,6 +1340,7 @@ export class PrivateMessenger {
|
|
|
1321
1340
|
await this.seedQueue?.removeBy('byChannel', pubkey)
|
|
1322
1341
|
}
|
|
1323
1342
|
this.writeState(state)
|
|
1343
|
+
await this.flushStateWrites()
|
|
1324
1344
|
})
|
|
1325
1345
|
}
|
|
1326
1346
|
|
|
@@ -0,0 +1,103 @@
|
|
|
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, offline ranges, active offline-range start,
|
|
15
|
+
per-seeder activity, and sent/received content-key usage
|
|
16
|
+
|
|
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.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
function deferred () {
|
|
22
|
+
let resolve
|
|
23
|
+
let reject
|
|
24
|
+
const promise = new Promise((nextResolve, nextReject) => {
|
|
25
|
+
resolve = nextResolve
|
|
26
|
+
reject = nextReject
|
|
27
|
+
})
|
|
28
|
+
return { promise, resolve, reject }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function transactionDone (tx) {
|
|
32
|
+
const pending = deferred()
|
|
33
|
+
tx.oncomplete = () => pending.resolve()
|
|
34
|
+
tx.onabort = () => pending.reject(tx.error || new Error('IDB_TRANSACTION_ABORTED'))
|
|
35
|
+
tx.onerror = () => pending.reject(tx.error || new Error('IDB_TRANSACTION_FAILED'))
|
|
36
|
+
return pending.promise
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function openDatabase (indexedDB, name) {
|
|
40
|
+
if (!indexedDB?.open) return Promise.reject(new Error('IDB_UNAVAILABLE'))
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const request = indexedDB.open(name, DATABASE_VERSION)
|
|
43
|
+
request.onerror = () => reject(request.error || new Error('IDB_OPEN_FAILED'))
|
|
44
|
+
request.onblocked = () => reject(new Error('IDB_DATABASE_BLOCKED'))
|
|
45
|
+
request.onupgradeneeded = () => {
|
|
46
|
+
const db = request.result
|
|
47
|
+
if (!db.objectStoreNames.contains(CHANNELS_STORE)) {
|
|
48
|
+
db.createObjectStore(CHANNELS_STORE, { keyPath: 'pubkey' })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
request.onsuccess = () => {
|
|
52
|
+
const db = request.result
|
|
53
|
+
db.onversionchange = () => db.close()
|
|
54
|
+
resolve(db)
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function transaction (db, mode, work) {
|
|
60
|
+
const tx = db.transaction([CHANNELS_STORE], mode)
|
|
61
|
+
// Attach completion handlers before issuing the first request. The callback
|
|
62
|
+
// may await only requests belonging to this transaction.
|
|
63
|
+
const done = transactionDone(tx)
|
|
64
|
+
try {
|
|
65
|
+
const result = await work(tx)
|
|
66
|
+
await done
|
|
67
|
+
return result
|
|
68
|
+
} catch (err) {
|
|
69
|
+
try { tx.abort() } catch {}
|
|
70
|
+
try { await done } catch {}
|
|
71
|
+
throw err
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function cloneChannels (channels) {
|
|
76
|
+
return structuredClone(channels || {})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function createChannelStateStore ({ prefix, indexedDB = globalThis.indexedDB } = {}) {
|
|
80
|
+
if (!prefix) throw new Error('PRIVATE_MESSENGER_STATE_PREFIX_REQUIRED')
|
|
81
|
+
const db = await openDatabase(indexedDB, `${prefix}:state:idb`)
|
|
82
|
+
|
|
83
|
+
async function load () {
|
|
84
|
+
return transaction(db, 'readonly', async tx => {
|
|
85
|
+
const records = (await run('getAll', [], CHANNELS_STORE, null, { tx })).result
|
|
86
|
+
return Object.fromEntries(records
|
|
87
|
+
.filter(record => typeof record?.pubkey === 'string' && record.pubkey)
|
|
88
|
+
.map(({ pubkey, value }) => [pubkey, value && typeof value === 'object' ? value : {}]))
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function replace (channels) {
|
|
93
|
+
const snapshot = cloneChannels(channels)
|
|
94
|
+
await transaction(db, 'readwrite', async tx => {
|
|
95
|
+
await run('clear', [], CHANNELS_STORE, null, { tx })
|
|
96
|
+
for (const [pubkey, value] of Object.entries(snapshot)) {
|
|
97
|
+
await run('put', [{ pubkey, value }], CHANNELS_STORE, null, { tx })
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { load, replace }
|
|
103
|
+
}
|