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.
@@ -1,34 +1,118 @@
1
- import { base64ToBytes, bytesToBase64 } from '../../base64/index.js'
2
- import { JSONL_CHUNK_BYTES } from '../helpers/chunk-size.js'
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
- // For illustration purposes: A 280-character rumor would take approximately
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 encoder = new TextEncoder()
8
+ const DATABASE_VERSION = 2
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
- // Receive buffers survive reloads briefly, unlike send-side temporary chunks.
14
- // Stale cleanup handles peers that never publish the remaining chunks.
16
+ /*
17
+ IndexedDB schema, scoped per received-chunk prefix:
18
+
19
+ database `${prefix}:idb`, version 2
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
+ ttlMs/expiresAt group-specific retention and absolute expiry
32
+ drainToken/drainUntil optional drain lease owner and expiry
33
+
34
+ groups indexes
35
+ byUpdatedAt updatedAt
36
+ byExpiresAt expiresAt
37
+
38
+ chunks, keyPath ["groupKey", "index"]
39
+ groupKey owning group coordinate
40
+ index zero-based chunk position
41
+ bytes raw chunk bytes stored as Uint8Array
42
+
43
+ chunks indexes
44
+ byGroup groupKey
45
+
46
+ state, keyPath "key"
47
+ key state record name, currently "usage"
48
+ usedBytes total logical bytes held by all groups
49
+ */
50
+
51
+ function deferred () {
52
+ let resolve
53
+ let reject
54
+ const promise = new Promise((nextResolve, nextReject) => {
55
+ resolve = nextResolve
56
+ reject = nextReject
57
+ })
58
+ return { promise, resolve, reject }
59
+ }
60
+
61
+ function transactionDone (tx) {
62
+ const p = deferred()
63
+ tx.oncomplete = () => p.resolve()
64
+ tx.onabort = () => p.reject(tx.error || new Error('IDB_TRANSACTION_ABORTED'))
65
+ tx.onerror = () => p.reject(tx.error || new Error('IDB_TRANSACTION_FAILED'))
66
+ return p.promise
67
+ }
68
+
69
+ function openDatabase (indexedDB, name) {
70
+ if (!indexedDB?.open) return Promise.reject(new Error('IDB_UNAVAILABLE'))
71
+ return new Promise((resolve, reject) => {
72
+ const request = indexedDB.open(name, DATABASE_VERSION)
73
+ request.onerror = () => reject(request.error || new Error('IDB_OPEN_FAILED'))
74
+ request.onblocked = () => reject(new Error('IDB_DATABASE_BLOCKED'))
75
+ request.onupgradeneeded = () => {
76
+ const db = request.result
77
+ const tx = request.transaction
78
+ let groups
79
+ if (!db.objectStoreNames.contains(GROUPS_STORE)) {
80
+ groups = db.createObjectStore(GROUPS_STORE, { keyPath: 'groupKey' })
81
+ } else {
82
+ groups = tx.objectStore(GROUPS_STORE)
83
+ }
84
+ if (!groups.indexNames.contains('byUpdatedAt')) groups.createIndex('byUpdatedAt', 'updatedAt')
85
+ if (!groups.indexNames.contains('byExpiresAt')) groups.createIndex('byExpiresAt', 'expiresAt')
86
+
87
+ let chunks
88
+ if (!db.objectStoreNames.contains(CHUNKS_STORE)) {
89
+ chunks = db.createObjectStore(CHUNKS_STORE, { keyPath: ['groupKey', 'index'] })
90
+ } else {
91
+ chunks = tx.objectStore(CHUNKS_STORE)
92
+ }
93
+ if (!chunks.indexNames.contains('byGroup')) chunks.createIndex('byGroup', 'groupKey')
15
94
 
16
- function byteLength (value) {
17
- return encoder.encode(String(value)).length
95
+ if (!db.objectStoreNames.contains(STATE_STORE)) db.createObjectStore(STATE_STORE, { keyPath: 'key' })
96
+ }
97
+ request.onsuccess = () => {
98
+ const db = request.result
99
+ db.onversionchange = () => db.close()
100
+ resolve(db)
101
+ }
102
+ })
18
103
  }
19
104
 
20
- function parseJson (raw, fallback) {
21
- try { return JSON.parse(raw || '') } catch { return fallback }
105
+ function normalizeBytes (value) {
106
+ if (value instanceof Uint8Array) return new Uint8Array(value)
107
+ if (value instanceof ArrayBuffer) return new Uint8Array(value)
108
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
109
+ throw new Error('RECEIVED_CHUNK_BYTES_REQUIRED')
22
110
  }
23
111
 
24
112
  function uniq (values) {
25
113
  return [...new Set((values || []).filter(Boolean))]
26
114
  }
27
115
 
28
- function normalizeGroupKeys (value) {
29
- return Array.isArray(value) ? uniq(value.filter(key => typeof key === 'string' && key)) : []
30
- }
31
-
32
116
  function normalizeReceived (received) {
33
117
  if (!received || typeof received !== 'object' || Array.isArray(received)) return {}
34
118
  return Object.fromEntries(
@@ -38,14 +122,16 @@ function normalizeReceived (received) {
38
122
  )
39
123
  }
40
124
 
41
- function normalizeMeta (meta, groupKey) {
125
+ function normalizeMeta (meta, fallbackTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS) {
42
126
  if (!meta || typeof meta !== 'object') return null
43
127
  const total = Number(meta.total)
44
128
  const nextIndex = Number(meta.nextIndex)
45
129
  const rowIndex = Number(meta.rowIndex)
46
- if (!Number.isSafeInteger(total) || total < 1) return null
130
+ if (!meta.groupKey || !Number.isSafeInteger(total) || total < 1) return null
131
+ const updatedAt = Number(meta.updatedAt) || Date.now()
132
+ const ttlMs = Number.isFinite(meta.ttlMs) && meta.ttlMs >= 0 ? meta.ttlMs : fallbackTtlMs
47
133
  return {
48
- groupKey,
134
+ groupKey: String(meta.groupKey),
49
135
  channelPubkey: String(meta.channelPubkey || ''),
50
136
  routerPubkey: String(meta.routerPubkey || ''),
51
137
  total,
@@ -55,10 +141,21 @@ function normalizeMeta (meta, groupKey) {
55
141
  rowIndex: Number.isSafeInteger(rowIndex) && rowIndex >= 0 ? rowIndex : 0,
56
142
  carry: typeof meta.carry === 'string' ? meta.carry : '',
57
143
  payloadCiphertext: typeof meta.payloadCiphertext === 'string' ? meta.payloadCiphertext : '',
58
- receiverPubkeys: uniq(meta.receiverPubkeys || []),
144
+ receiverPubkeys: uniq(meta.receiverPubkeys),
59
145
  byteSize: Math.max(0, Number(meta.byteSize) || 0),
60
146
  createdAt: Number(meta.createdAt) || Date.now(),
61
- updatedAt: Number(meta.updatedAt) || Date.now()
147
+ updatedAt,
148
+ ttlMs,
149
+ expiresAt: Number(meta.expiresAt) || updatedAt + ttlMs,
150
+ drainToken: typeof meta.drainToken === 'string' ? meta.drainToken : '',
151
+ drainUntil: Math.max(0, Number(meta.drainUntil) || 0)
152
+ }
153
+ }
154
+
155
+ function normalizeUsage (value) {
156
+ return {
157
+ key: USAGE_KEY,
158
+ usedBytes: Number.isSafeInteger(value?.usedBytes) && value.usedBytes >= 0 ? value.usedBytes : 0
62
159
  }
63
160
  }
64
161
 
@@ -70,217 +167,228 @@ function isQuotaExceeded (err) {
70
167
  /quota/i.test(err?.message || '')
71
168
  }
72
169
 
73
- function joinBase64Chunks (parts) {
74
- let out = ''
75
- let carry = new Uint8Array()
76
-
77
- for (const part of parts) {
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
- }
170
+ function randomToken () {
171
+ const bytes = globalThis.crypto?.getRandomValues?.(new Uint8Array(16))
172
+ if (bytes) return [...bytes].map(value => value.toString(16).padStart(2, '0')).join('')
173
+ return `${Date.now().toString(16)}:${Math.random().toString(16).slice(2)}`
174
+ }
87
175
 
88
- if (carry.length) out += bytesToBase64(carry)
89
- return out
176
+ function wait (ms) {
177
+ return new Promise(resolve => setTimeout(resolve, ms))
90
178
  }
91
179
 
92
180
  export function createReceivedChunkStore ({
93
181
  prefix = DEFAULT_PREFIX,
94
- storageArea = globalThis.localStorage,
182
+ indexedDB = globalThis.indexedDB,
95
183
  ttlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS,
96
184
  maxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES
97
185
  } = {}) {
98
- const groupsKey = `${prefix}:groups`
99
186
  const configuredTtlMs = Number.isFinite(ttlMs) && ttlMs >= 0 ? ttlMs : DEFAULT_RECEIVED_CHUNK_TTL_MS
100
187
  const configuredMaxBytes = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : Infinity
101
-
102
- function storage () {
103
- return storageArea
104
- }
105
-
106
- function metaKey (groupKey) {
107
- return `${prefix}:group:${groupKey}:meta`
188
+ let dbPromise
189
+ let readyPromise
190
+ let closed = false
191
+
192
+ function database () {
193
+ if (closed) return Promise.reject(new Error('RECEIVED_CHUNK_STORE_CLOSED'))
194
+ dbPromise ||= openDatabase(indexedDB, `${prefix}:idb`)
195
+ return dbPromise.then(db => {
196
+ if (closed) {
197
+ db.close()
198
+ throw new Error('RECEIVED_CHUNK_STORE_CLOSED')
199
+ }
200
+ return db
201
+ })
108
202
  }
109
203
 
110
- function chunkKey (groupKey, index) {
111
- return `${prefix}:group:${groupKey}:chunk:${index}`
204
+ async function transaction (storeNames, mode, work) {
205
+ const db = await database()
206
+ const tx = db.transaction(storeNames, mode)
207
+ const done = transactionDone(tx)
208
+ try {
209
+ // `work` may await only IDB requests issued through `run` with this tx.
210
+ const result = await work(tx)
211
+ await done
212
+ return result
213
+ } catch (err) {
214
+ try { tx.abort() } catch {}
215
+ try { await done } catch {}
216
+ throw err
217
+ }
112
218
  }
113
219
 
114
220
  function groupKeyFor (channelPubkey, routerPubkey) {
115
221
  return `${channelPubkey}:${routerPubkey}`
116
222
  }
117
223
 
118
- function readGroupKeys () {
119
- return normalizeGroupKeys(parseJson(storage().getItem(groupsKey), []))
120
- }
121
-
122
- function writeGroupKeys (keys) {
123
- const normalized = normalizeGroupKeys(keys)
124
- if (normalized.length) storage().setItem(groupsKey, JSON.stringify(normalized))
125
- else storage().removeItem(groupsKey)
224
+ async function readUsage (tx) {
225
+ return normalizeUsage((await run('get', [USAGE_KEY], STATE_STORE, null, { tx })).result)
126
226
  }
127
227
 
128
- function addGroupKey (groupKey) {
129
- const keys = readGroupKeys()
130
- if (!keys.includes(groupKey)) writeGroupKeys(keys.concat(groupKey))
228
+ async function writeUsage (tx, usage) {
229
+ await run('put', [normalizeUsage(usage)], STATE_STORE, null, { tx })
131
230
  }
132
231
 
133
- function removeGroupKey (groupKey) {
134
- writeGroupKeys(readGroupKeys().filter(key => key !== groupKey))
232
+ async function deleteGroupInTransaction (tx, groupKey, usage, knownMeta) {
233
+ const meta = knownMeta || normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
234
+ const keys = (await run('getAllKeys', [globalThis.IDBKeyRange?.only?.(groupKey) ?? groupKey], CHUNKS_STORE, 'byGroup', { tx })).result
235
+ for (const key of keys) await run('delete', [key], CHUNKS_STORE, null, { tx })
236
+ await run('delete', [groupKey], GROUPS_STORE, null, { tx })
237
+ if (usage && meta) usage.usedBytes = Math.max(0, usage.usedBytes - meta.byteSize)
238
+ return Boolean(meta || keys.length)
135
239
  }
136
240
 
137
- function readMeta (groupKey) {
138
- return normalizeMeta(parseJson(storage().getItem(metaKey(groupKey)), null), groupKey)
241
+ async function oldestGroupsInTransaction (tx, except = '') {
242
+ const values = (await run('getAll', [], GROUPS_STORE, null, { tx })).result
243
+ return values
244
+ .map(value => normalizeMeta(value, configuredTtlMs))
245
+ .filter(meta => meta && meta.groupKey !== except)
246
+ .sort((left, right) => left.updatedAt - right.updatedAt || left.groupKey.localeCompare(right.groupKey))
139
247
  }
140
248
 
141
- function writeMeta (meta) {
142
- storage().setItem(metaKey(meta.groupKey), JSON.stringify(meta))
143
- }
144
-
145
- function removeGroup (groupKey) {
146
- const meta = readMeta(groupKey)
147
- if (meta) {
148
- for (const index of Object.keys(meta.received)) {
149
- storage().removeItem(chunkKey(groupKey, index))
249
+ async function cleanupStaleRaw (nowMs = Date.now()) {
250
+ return transaction([GROUPS_STORE, CHUNKS_STORE, STATE_STORE], 'readwrite', async tx => {
251
+ const usage = await readUsage(tx)
252
+ const groups = await oldestGroupsInTransaction(tx)
253
+ let removed = 0
254
+ for (const meta of groups) {
255
+ if (meta.expiresAt > nowMs && (!Number.isFinite(configuredMaxBytes) || usage.usedBytes <= configuredMaxBytes)) continue
256
+ await deleteGroupInTransaction(tx, meta.groupKey, usage, meta)
257
+ removed++
150
258
  }
151
- }
152
- storage().removeItem(metaKey(groupKey))
153
- removeGroupKey(groupKey)
154
- }
155
-
156
- function allMetas () {
157
- const keys = readGroupKeys()
158
- const metas = []
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
259
+ await writeUsage(tx, usage)
260
+ return removed
261
+ })
168
262
  }
169
263
 
170
- function totalStoredBytes () {
171
- return allMetas().reduce((total, meta) => {
172
- return total + meta.byteSize + byteLength(JSON.stringify(meta))
173
- }, 0)
264
+ function ready (nowMs = Date.now()) {
265
+ readyPromise ||= cleanupStaleRaw(nowMs)
266
+ return readyPromise
174
267
  }
175
268
 
176
- function oldestGroupKey ({ except } = {}) {
177
- return allMetas()
178
- .filter(meta => meta.groupKey !== except)
179
- .sort((a, b) => a.updatedAt - b.updatedAt)[0]?.groupKey || ''
269
+ async function cleanupStale (nowMs = Date.now()) {
270
+ await ready(nowMs)
271
+ return cleanupStaleRaw(nowMs)
180
272
  }
181
273
 
182
- function evictOldestUntilFits (requiredBytes = 0, { except } = {}) {
183
- if (!Number.isFinite(configuredMaxBytes)) return
184
- while (totalStoredBytes() + requiredBytes > configuredMaxBytes) {
185
- const groupKey = oldestGroupKey({ except })
186
- if (!groupKey) break
187
- removeGroup(groupKey)
188
- }
189
- }
274
+ async function putOnce ({ channelPubkey, routerPubkey, index, total, contentBytes, ttlMs }) {
275
+ const groupKey = groupKeyFor(channelPubkey, routerPubkey)
276
+ const bytes = normalizeBytes(contentBytes)
277
+ const now = Date.now()
278
+ const groupTtlMs = Number.isFinite(ttlMs) && ttlMs >= 0 ? ttlMs : configuredTtlMs
279
+
280
+ return transaction([GROUPS_STORE, CHUNKS_STORE, STATE_STORE], 'readwrite', async tx => {
281
+ const usage = await readUsage(tx)
282
+ let meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
283
+ if (meta && meta.expiresAt <= now) {
284
+ await deleteGroupInTransaction(tx, groupKey, usage, meta)
285
+ meta = null
286
+ }
287
+ if (meta && meta.total !== total) {
288
+ await deleteGroupInTransaction(tx, groupKey, usage, meta)
289
+ meta = null
290
+ }
291
+ if (!meta) {
292
+ meta = normalizeMeta({
293
+ groupKey,
294
+ channelPubkey,
295
+ routerPubkey,
296
+ total,
297
+ received: {},
298
+ receivedCount: 0,
299
+ nextIndex: 0,
300
+ rowIndex: 0,
301
+ carry: '',
302
+ payloadCiphertext: '',
303
+ receiverPubkeys: [],
304
+ byteSize: 0,
305
+ createdAt: now,
306
+ updatedAt: now,
307
+ ttlMs: groupTtlMs,
308
+ expiresAt: now + groupTtlMs
309
+ }, configuredTtlMs)
310
+ }
190
311
 
191
- function writeChunk (key, value, requiredBytes, except) {
192
- evictOldestUntilFits(requiredBytes, { except })
193
- try {
194
- storage().setItem(key, value)
195
- return
196
- } catch (err) {
197
- if (!isQuotaExceeded(err)) throw err
198
- }
312
+ const existing = (await run('get', [[groupKey, index]], CHUNKS_STORE, null, { tx })).result
313
+ if (!existing) {
314
+ if (bytes.byteLength > configuredMaxBytes || meta.byteSize + bytes.byteLength > configuredMaxBytes) {
315
+ await deleteGroupInTransaction(tx, groupKey, usage, meta)
316
+ await writeUsage(tx, usage)
317
+ return { tooLarge: true, meta: null }
318
+ }
199
319
 
200
- let groupKey = oldestGroupKey({ except })
201
- while (groupKey) {
202
- removeGroup(groupKey)
203
- try {
204
- storage().setItem(key, value)
205
- return
206
- } catch (err) {
207
- if (!isQuotaExceeded(err)) throw err
320
+ const requiredBytes = bytes.byteLength
321
+ const candidates = await oldestGroupsInTransaction(tx, groupKey)
322
+ for (const candidate of candidates) {
323
+ if (candidate.expiresAt > now) continue
324
+ await deleteGroupInTransaction(tx, candidate.groupKey, usage, candidate)
325
+ }
326
+ for (const candidate of candidates) {
327
+ if (candidate.expiresAt <= now) continue
328
+ if (!Number.isFinite(configuredMaxBytes) || usage.usedBytes + requiredBytes <= configuredMaxBytes) break
329
+ await deleteGroupInTransaction(tx, candidate.groupKey, usage, candidate)
330
+ }
331
+ if (Number.isFinite(configuredMaxBytes) && usage.usedBytes + requiredBytes > configuredMaxBytes) {
332
+ await deleteGroupInTransaction(tx, groupKey, usage, meta)
333
+ await writeUsage(tx, usage)
334
+ return { tooLarge: true, meta: null }
335
+ }
336
+
337
+ await run('put', [{ groupKey, index, bytes }], CHUNKS_STORE, null, { tx })
338
+ meta.received[String(index)] = true
339
+ meta.receivedCount++
340
+ meta.byteSize += requiredBytes
341
+ usage.usedBytes += requiredBytes
208
342
  }
209
- groupKey = oldestGroupKey({ except })
210
- }
211
343
 
212
- storage().setItem(key, value)
344
+ meta.total = total
345
+ meta.updatedAt = now
346
+ meta.expiresAt = now + meta.ttlMs
347
+ await run('put', [meta], GROUPS_STORE, null, { tx })
348
+ await writeUsage(tx, usage)
349
+ return { tooLarge: false, meta }
350
+ })
213
351
  }
214
352
 
215
- function cleanupStale (nowMs = Date.now()) {
216
- const cutoff = nowMs - configuredTtlMs
217
- for (const meta of allMetas()) {
218
- if (meta.updatedAt <= cutoff) removeGroup(meta.groupKey)
219
- }
220
- evictOldestUntilFits(0)
353
+ async function evictOldestGroup (except = '') {
354
+ return transaction([GROUPS_STORE, CHUNKS_STORE, STATE_STORE], 'readwrite', async tx => {
355
+ const usage = await readUsage(tx)
356
+ const oldest = (await oldestGroupsInTransaction(tx, except))[0]
357
+ if (!oldest) return false
358
+ await deleteGroupInTransaction(tx, oldest.groupKey, usage, oldest)
359
+ await writeUsage(tx, usage)
360
+ return true
361
+ })
221
362
  }
222
363
 
223
- function put ({ channelPubkey, routerPubkey, index, total, content }) {
364
+ async function put ({ channelPubkey, routerPubkey, index, total, contentBytes, ttlMs }) {
224
365
  if (!channelPubkey || !routerPubkey) throw new Error('RECEIVED_CHUNK_GROUP_REQUIRED')
225
366
  if (!Number.isSafeInteger(index) || !Number.isSafeInteger(total) || index < 0 || total < 1 || index >= total) {
226
367
  throw new Error('INVALID_RECEIVED_CHUNK_INDEX')
227
368
  }
228
-
229
- cleanupStale()
230
-
231
- const groupKey = groupKeyFor(channelPubkey, routerPubkey)
232
- const now = Date.now()
233
- const chunk = String(content || '')
234
- const nextBytes = byteLength(chunk)
235
- let meta = readMeta(groupKey)
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')
369
+ const bytes = normalizeBytes(contentBytes)
370
+ await ready()
371
+ while (true) {
372
+ try {
373
+ const result = await putOnce({ channelPubkey, routerPubkey, index, total, contentBytes: bytes, ttlMs })
374
+ if (result.tooLarge) throw new Error('RECEIVED_CHUNK_GROUP_TOO_LARGE')
375
+ return result.meta
376
+ } catch (err) {
377
+ if (!isQuotaExceeded(err) || !await evictOldestGroup(groupKeyFor(channelPubkey, routerPubkey))) throw err
266
378
  }
267
- writeChunk(chunkKey(groupKey, index), chunk, nextBytes, groupKey)
268
- meta.received[String(index)] = true
269
- meta.receivedCount++
270
- meta.byteSize += nextBytes
271
379
  }
380
+ }
272
381
 
273
- meta.total = total
274
- meta.updatedAt = now
275
- writeMeta(meta)
276
- evictOldestUntilFits(0, { except: groupKey })
277
- return meta
382
+ async function readMeta (groupKey) {
383
+ await ready()
384
+ return transaction([GROUPS_STORE], 'readonly', async tx => {
385
+ return normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
386
+ })
278
387
  }
279
388
 
280
- function status (metaOrGroupKey) {
281
- const meta = typeof metaOrGroupKey === 'string' ? readMeta(metaOrGroupKey) : metaOrGroupKey
389
+ async function status (metaOrGroupKey) {
390
+ const meta = typeof metaOrGroupKey === 'string' ? await readMeta(metaOrGroupKey) : normalizeMeta(metaOrGroupKey, configuredTtlMs)
282
391
  if (!meta) return { received: 0, missing: [] }
283
-
284
392
  const missing = []
285
393
  let received = 0
286
394
  for (let index = 0; index < meta.total; index++) {
@@ -291,98 +399,183 @@ export function createReceivedChunkStore ({
291
399
  }
292
400
 
293
401
  function rememberReceiverPubkey (meta, pubkey) {
294
- if (!pubkey || meta.receiverPubkeys.includes(pubkey)) return
295
- meta.receiverPubkeys.push(pubkey)
402
+ if (pubkey && !meta.receiverPubkeys.includes(pubkey)) meta.receiverPubkeys.push(pubkey)
296
403
  }
297
404
 
298
405
  function rememberPayloadCiphertext (meta, ciphertext) {
299
406
  if (!meta.payloadCiphertext) meta.payloadCiphertext = ciphertext
300
407
  }
301
408
 
302
- async function drainAvailable (groupKey, { onLine } = {}) {
303
- const meta = readMeta(groupKey)
304
- if (!meta) return { complete: false, stopped: false, meta: null }
409
+ async function claimDrain (groupKey, token) {
410
+ while (true) {
411
+ const now = Date.now()
412
+ const result = await transaction([GROUPS_STORE], 'readwrite', async tx => {
413
+ const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
414
+ if (!meta) return { meta: null, waitMs: 0 }
415
+ if (meta.drainToken && meta.drainToken !== token && meta.drainUntil > now) {
416
+ return { meta: null, waitMs: Math.min(DRAIN_LEASE_MS, meta.drainUntil - now) }
417
+ }
418
+ meta.drainToken = token
419
+ meta.drainUntil = now + DRAIN_LEASE_MS
420
+ meta.updatedAt = now
421
+ meta.expiresAt = now + meta.ttlMs
422
+ await run('put', [meta], GROUPS_STORE, null, { tx })
423
+ return { meta, waitMs: 0 }
424
+ })
425
+ if (!result.waitMs) return result.meta
426
+ await wait(result.waitMs)
427
+ }
428
+ }
429
+
430
+ async function readDrainChunk (groupKey, token) {
431
+ return transaction([GROUPS_STORE, CHUNKS_STORE], 'readonly', async tx => {
432
+ const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
433
+ if (!meta || meta.drainToken !== token) return { meta: null, bytes: null }
434
+ if (meta.nextIndex >= meta.total) return { meta, bytes: null }
435
+ const chunk = (await run('get', [[groupKey, meta.nextIndex]], CHUNKS_STORE, null, { tx })).result
436
+ return { meta, bytes: chunk ? normalizeBytes(chunk.bytes) : null }
437
+ })
438
+ }
439
+
440
+ async function commitDrainMeta (nextMeta, token) {
441
+ return transaction([GROUPS_STORE], 'readwrite', async tx => {
442
+ const current = normalizeMeta((await run('get', [nextMeta.groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
443
+ if (!current || current.drainToken !== token) return null
444
+ current.nextIndex = nextMeta.nextIndex
445
+ current.rowIndex = nextMeta.rowIndex
446
+ current.carry = nextMeta.carry
447
+ current.payloadCiphertext = nextMeta.payloadCiphertext
448
+ current.receiverPubkeys = uniq(nextMeta.receiverPubkeys)
449
+ current.updatedAt = nextMeta.updatedAt
450
+ current.expiresAt = current.updatedAt + current.ttlMs
451
+ current.drainUntil = Date.now() + DRAIN_LEASE_MS
452
+ await run('put', [current], GROUPS_STORE, null, { tx })
453
+ return current
454
+ })
455
+ }
305
456
 
306
- while (meta.nextIndex < meta.total) {
307
- const index = meta.nextIndex
308
- const raw = storage().getItem(chunkKey(groupKey, index))
309
- if (raw == null) break
457
+ async function releaseDrain (groupKey, token) {
458
+ return transaction([GROUPS_STORE], 'readwrite', async tx => {
459
+ const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
460
+ if (!meta || meta.drainToken !== token) return false
461
+ meta.drainToken = ''
462
+ meta.drainUntil = 0
463
+ await run('put', [meta], GROUPS_STORE, null, { tx })
464
+ return true
465
+ })
466
+ }
310
467
 
311
- const text = `${meta.carry}${decoder.decode(base64ToBytes(raw))}`
312
- let start = 0
313
- let end = text.indexOf('\n', start)
468
+ async function drainAvailable (groupKey, { onLine } = {}) {
469
+ await ready()
470
+ const token = randomToken()
471
+ let meta = await claimDrain(groupKey, token)
472
+ if (!meta) return { complete: false, stopped: false, meta: null }
473
+ try {
474
+ while (meta.nextIndex < meta.total) {
475
+ const snapshot = await readDrainChunk(groupKey, token)
476
+ meta = snapshot.meta
477
+ if (!meta || !snapshot.bytes) break
478
+
479
+ const text = `${meta.carry}${decoder.decode(snapshot.bytes)}`
480
+ let start = 0
481
+ let end = text.indexOf('\n', start)
482
+ while (end !== -1) {
483
+ const line = text.slice(start, end)
484
+ start = end + 1
485
+ if (line) {
486
+ const result = await onLine?.(line, meta.rowIndex, meta, { rememberPayloadCiphertext, rememberReceiverPubkey })
487
+ meta.rowIndex++
488
+ if (result?.stop) {
489
+ meta.updatedAt = Date.now()
490
+ meta = await commitDrainMeta(meta, token)
491
+ return { complete: false, stopped: true, meta }
492
+ }
493
+ }
494
+ end = text.indexOf('\n', start)
495
+ }
496
+ meta.carry = text.slice(start)
497
+ meta.nextIndex++
498
+ meta.updatedAt = Date.now()
499
+ meta = await commitDrainMeta(meta, token)
500
+ if (!meta) break
501
+ }
314
502
 
315
- while (end !== -1) {
316
- const line = text.slice(start, end)
317
- start = end + 1
318
- if (line) {
319
- const result = await onLine?.(line, meta.rowIndex, meta, { rememberPayloadCiphertext, rememberReceiverPubkey })
503
+ if (meta && meta.nextIndex >= meta.total) {
504
+ if (meta.carry) {
505
+ const result = await onLine?.(meta.carry, meta.rowIndex, meta, { rememberPayloadCiphertext, rememberReceiverPubkey })
320
506
  meta.rowIndex++
507
+ meta.carry = ''
321
508
  if (result?.stop) {
322
509
  meta.updatedAt = Date.now()
323
- writeMeta(meta)
510
+ meta = await commitDrainMeta(meta, token)
324
511
  return { complete: false, stopped: true, meta }
325
512
  }
326
513
  }
327
- end = text.indexOf('\n', start)
514
+ meta.updatedAt = Date.now()
515
+ meta = await commitDrainMeta(meta, token)
516
+ return { complete: Boolean(meta), stopped: false, meta }
328
517
  }
329
-
330
- meta.carry = text.slice(start)
331
-
332
- meta.nextIndex++
333
- meta.updatedAt = Date.now()
334
- writeMeta(meta)
518
+ return { complete: false, stopped: false, meta }
519
+ } finally {
520
+ await releaseDrain(groupKey, token).catch(() => {})
335
521
  }
522
+ }
336
523
 
337
- if (meta.nextIndex >= meta.total) {
338
- if (meta.carry) {
339
- const result = await onLine?.(meta.carry, meta.rowIndex, meta, { rememberPayloadCiphertext, rememberReceiverPubkey })
340
- meta.rowIndex++
341
- meta.carry = ''
342
- if (result?.stop) {
343
- meta.updatedAt = Date.now()
344
- writeMeta(meta)
345
- return { complete: false, stopped: true, meta }
346
- }
524
+ async function readChunkBytes (groupKey) {
525
+ await ready()
526
+ return transaction([GROUPS_STORE, CHUNKS_STORE], 'readonly', async tx => {
527
+ const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
528
+ if (!meta) return { meta: null, chunks: [] }
529
+ const chunks = []
530
+ for (let index = 0; index < meta.total; index++) {
531
+ const chunk = (await run('get', [[groupKey, index]], CHUNKS_STORE, null, { tx })).result
532
+ if (!chunk) throw new Error('RECEIVED_CHUNK_MISSING')
533
+ chunks.push(normalizeBytes(chunk.bytes))
347
534
  }
348
- meta.updatedAt = Date.now()
349
- writeMeta(meta)
350
- return { complete: true, stopped: false, meta }
351
- }
535
+ return { meta, chunks }
536
+ })
537
+ }
352
538
 
353
- return { complete: false, stopped: false, meta }
539
+ async function readChunkContents (groupKey) {
540
+ return (await readChunkBytes(groupKey)).chunks.map(bytes => decoder.decode(bytes))
354
541
  }
355
542
 
356
- function readEnvelopeBundleContent (groupKey) {
357
- const meta = readMeta(groupKey)
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)
543
+ async function readEnvelopeBundleContent (groupKey) {
544
+ const { chunks } = await readChunkBytes(groupKey)
545
+ return bytesToBase64(joinChunks(chunks))
546
+ }
547
+
548
+ function joinChunks (chunks) {
549
+ let length = 0
550
+ for (const chunk of chunks) length += chunk.byteLength
551
+ const joined = new Uint8Array(length)
552
+ let offset = 0
553
+ for (const chunk of chunks) {
554
+ joined.set(chunk, offset)
555
+ offset += chunk.byteLength
364
556
  }
365
- return joinBase64Chunks(parts)
557
+ return joined
366
558
  }
367
559
 
368
- function readEnvelopeBundleText (groupKey) {
369
- const content = readEnvelopeBundleContent(groupKey)
370
- return decoder.decode(base64ToBytes(content))
560
+ async function readEnvelopeBundleText (groupKey) {
561
+ return decoder.decode(joinChunks((await readChunkBytes(groupKey)).chunks))
371
562
  }
372
563
 
373
- function readChunkContents (groupKey) {
374
- const meta = readMeta(groupKey)
375
- if (!meta) return []
376
- const parts = []
377
- for (let index = 0; index < meta.total; index++) {
378
- const chunk = storage().getItem(chunkKey(groupKey, index))
379
- if (chunk == null) throw new Error('RECEIVED_CHUNK_MISSING')
380
- parts.push(chunk)
381
- }
382
- return parts
564
+ async function removeGroup (groupKey) {
565
+ await ready()
566
+ return transaction([GROUPS_STORE, CHUNKS_STORE, STATE_STORE], 'readwrite', async tx => {
567
+ const usage = await readUsage(tx)
568
+ const removed = await deleteGroupInTransaction(tx, groupKey, usage)
569
+ await writeUsage(tx, usage)
570
+ return removed
571
+ })
383
572
  }
384
573
 
385
- cleanupStale()
574
+ function close () {
575
+ if (closed) return
576
+ closed = true
577
+ dbPromise?.then(db => db.close()).catch(() => {})
578
+ }
386
579
 
387
580
  return {
388
581
  cleanupStale,
@@ -392,7 +585,22 @@ export function createReceivedChunkStore ({
392
585
  readChunkContents,
393
586
  readEnvelopeBundleContent,
394
587
  readEnvelopeBundleText,
588
+ ready,
395
589
  removeGroup,
396
- status
590
+ status,
591
+ close
592
+ }
593
+ }
594
+
595
+ export async function cleanupReceivedChunkStorage ({
596
+ indexedDB = globalThis.indexedDB,
597
+ prefix = DEFAULT_PREFIX,
598
+ now = Date.now()
599
+ } = {}) {
600
+ const store = createReceivedChunkStore({ prefix, indexedDB })
601
+ try {
602
+ return await store.cleanupStale(now)
603
+ } finally {
604
+ store.close()
397
605
  }
398
606
  }