libp2r2p 0.0.1

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.
Files changed (63) hide show
  1. package/AGENTS.md +20 -0
  2. package/README.md +129 -0
  3. package/base16/index.js +14 -0
  4. package/base64/index.js +29 -0
  5. package/content-key/event/index.js +85 -0
  6. package/content-key/index.js +1 -0
  7. package/content-key/services/iykc-proof.js +129 -0
  8. package/double-dh/index.js +166 -0
  9. package/ecdh/index.js +8 -0
  10. package/idb/index.js +64 -0
  11. package/idb-queue/index.js +793 -0
  12. package/index.js +21 -0
  13. package/key/index.js +139 -0
  14. package/network/index.js +68 -0
  15. package/nip44-v3/index.js +175 -0
  16. package/nip46/constants/index.js +5 -0
  17. package/nip46/helpers/frame.js +51 -0
  18. package/nip46/helpers/url.js +96 -0
  19. package/nip46/index.js +5 -0
  20. package/nip46/services/bunker-signer.js +49 -0
  21. package/nip46/services/client.js +252 -0
  22. package/nip46/services/server-session.js +225 -0
  23. package/nip46/services/transport.js +265 -0
  24. package/package.json +49 -0
  25. package/private-channel/constants/index.js +5 -0
  26. package/private-channel/helpers/chunk-size.js +61 -0
  27. package/private-channel/helpers/chunks.js +282 -0
  28. package/private-channel/helpers/event.js +68 -0
  29. package/private-channel/index.js +942 -0
  30. package/private-channel/services/received-chunks.js +398 -0
  31. package/private-message/index.js +672 -0
  32. package/private-messenger/constants/index.js +1 -0
  33. package/private-messenger/index.js +1431 -0
  34. package/private-messenger/recovery/index.js +316 -0
  35. package/relay/constants/index.js +18 -0
  36. package/relay/helpers/hll.js +62 -0
  37. package/relay/helpers/publish.js +100 -0
  38. package/relay/helpers/routing.js +36 -0
  39. package/relay/helpers/timer.js +4 -0
  40. package/relay/index.js +4 -0
  41. package/relay/services/query.js +224 -0
  42. package/relay/services/relay-connection.js +156 -0
  43. package/relay/services/relay-pool.js +908 -0
  44. package/temporary-storage/index.js +85 -0
  45. package/tests/base16.test.js +19 -0
  46. package/tests/base64.test.js +18 -0
  47. package/tests/content-key-event.test.js +48 -0
  48. package/tests/fixtures/nip44v3-vectors.json +1 -0
  49. package/tests/helpers/test-signer.js +185 -0
  50. package/tests/idb-queue.test.js +153 -0
  51. package/tests/idb.test.js +91 -0
  52. package/tests/nip44-v3.test.js +73 -0
  53. package/tests/nip46.test.js +668 -0
  54. package/tests/private-channel.test.js +1899 -0
  55. package/tests/private-message.test.js +460 -0
  56. package/tests/private-messenger.test.js +1715 -0
  57. package/tests/queries.test.js +101 -0
  58. package/tests/queue-parity.test.js +105 -0
  59. package/tests/relay-hll.test.js +32 -0
  60. package/tests/relay-pool.test.js +2067 -0
  61. package/tests/temporary-storage.test.js +89 -0
  62. package/tests/web-storage-queue.test.js +480 -0
  63. package/web-storage-queue/index.js +652 -0
@@ -0,0 +1,793 @@
1
+ import { run } from '../idb/index.js'
2
+
3
+ const encoder = new TextEncoder()
4
+ const ITEMS_STORE = 'items'
5
+ const STATE_STORE = 'state'
6
+ const STATE_KEY = 'queue'
7
+ const DEFAULT_EVICTION_HEADROOM_RATIO = 0.1
8
+ const MAX_EVICTION_HEADROOM_BYTES = 64 * 1024 // 64 KiB
9
+
10
+ function deferred () {
11
+ let resolve
12
+ let reject
13
+ const promise = new Promise((nextResolve, nextReject) => {
14
+ resolve = nextResolve
15
+ reject = nextReject
16
+ })
17
+ return { promise, resolve, reject }
18
+ }
19
+
20
+ function transactionDone (tx) {
21
+ return new Promise((resolve, reject) => {
22
+ tx.oncomplete = () => resolve()
23
+ tx.onabort = () => reject(tx.error || new Error('IDB_TRANSACTION_ABORTED'))
24
+ tx.onerror = () => reject(tx.error || new Error('IDB_TRANSACTION_FAILED'))
25
+ })
26
+ }
27
+
28
+ function byteLength (value) {
29
+ return encoder.encode(String(value)).length
30
+ }
31
+
32
+ function normalizeEvictionPolicy (policy) {
33
+ if (policy === 'opposite-end' || policy === undefined || policy === null) return 'opposite-end'
34
+ if (policy === 'fifo' || policy === 'head') return 'head'
35
+ if (policy === 'lifo' || policy === 'tail') return 'tail'
36
+ throw new Error('QUEUE_INVALID_EVICTION_POLICY')
37
+ }
38
+
39
+ function normalizeState (value) {
40
+ const head = Number.isSafeInteger(value?.head) ? value.head : 0
41
+ const tail = Number.isSafeInteger(value?.tail) && value.tail >= head ? value.tail : head
42
+ const usedBytes = Number.isSafeInteger(value?.usedBytes) && value.usedBytes >= 0 ? value.usedBytes : 0
43
+ return { head, tail, usedBytes }
44
+ }
45
+
46
+ function normalizeIndexes (indexes = {}) {
47
+ if (!indexes || typeof indexes !== 'object' || Array.isArray(indexes)) throw new Error('QUEUE_INDEXES_INVALID')
48
+
49
+ return Object.entries(indexes).map(([name, definition]) => {
50
+ const options = typeof definition === 'string' || Array.isArray(definition)
51
+ ? { keyPath: definition }
52
+ : definition
53
+ const keyPath = options?.keyPath
54
+ if (!name || (!Array.isArray(keyPath) && typeof keyPath !== 'string')) throw new Error('QUEUE_INDEX_INVALID')
55
+ if (Array.isArray(keyPath) && keyPath.some(path => typeof path !== 'string' || !path)) throw new Error('QUEUE_INDEX_INVALID')
56
+ if (typeof keyPath === 'string' && !keyPath) throw new Error('QUEUE_INDEX_INVALID')
57
+ if (options.multiEntry && Array.isArray(keyPath)) throw new Error('QUEUE_INDEX_MULTI_ENTRY_COMPOUND')
58
+ return {
59
+ name,
60
+ keyPath,
61
+ storedKeyPath: Array.isArray(keyPath)
62
+ ? keyPath.map(path => `item.${path}`)
63
+ : `item.${keyPath}`,
64
+ unique: Boolean(options.unique),
65
+ multiEntry: Boolean(options.multiEntry)
66
+ }
67
+ })
68
+ }
69
+
70
+ function keyPathEqual (a, b) {
71
+ return JSON.stringify(a) === JSON.stringify(b)
72
+ }
73
+
74
+ function openDatabase (indexedDB, name, version, onUpgrade) {
75
+ return new Promise((resolve, reject) => {
76
+ let request
77
+ try {
78
+ request = version === undefined ? indexedDB.open(name) : indexedDB.open(name, version)
79
+ } catch (err) {
80
+ reject(err)
81
+ return
82
+ }
83
+ request.onerror = () => reject(request.error || new Error('IDB_OPEN_FAILED'))
84
+ request.onblocked = () => reject(new Error('IDB_DATABASE_BLOCKED'))
85
+ request.onupgradeneeded = event => {
86
+ try {
87
+ onUpgrade(request.result, event.target.transaction)
88
+ } catch (err) {
89
+ try { event.target.transaction.abort() } catch {}
90
+ reject(err)
91
+ }
92
+ }
93
+ request.onsuccess = () => {
94
+ const db = request.result
95
+ db.onversionchange = () => db.close()
96
+ resolve(db)
97
+ }
98
+ })
99
+ }
100
+
101
+ function ensureSchema (db, tx, indexDefinitions) {
102
+ // Upgrades are additive: create missing stores/indexes, but reject a changed
103
+ // definition instead of silently reinterpreting existing queued values.
104
+ let items
105
+ if (!db.objectStoreNames.contains(ITEMS_STORE)) {
106
+ items = db.createObjectStore(ITEMS_STORE, { keyPath: 'position' })
107
+ } else {
108
+ items = tx.objectStore(ITEMS_STORE)
109
+ if (!keyPathEqual(items.keyPath, 'position')) throw new Error('QUEUE_SCHEMA_MISMATCH')
110
+ }
111
+
112
+ if (!db.objectStoreNames.contains(STATE_STORE)) {
113
+ db.createObjectStore(STATE_STORE, { keyPath: 'key' })
114
+ } else if (!keyPathEqual(tx.objectStore(STATE_STORE).keyPath, 'key')) {
115
+ throw new Error('QUEUE_SCHEMA_MISMATCH')
116
+ }
117
+
118
+ for (const definition of indexDefinitions) {
119
+ if (!items.indexNames.contains(definition.name)) {
120
+ items.createIndex(definition.name, definition.storedKeyPath, {
121
+ unique: definition.unique,
122
+ multiEntry: definition.multiEntry
123
+ })
124
+ continue
125
+ }
126
+ const existing = items.index(definition.name)
127
+ if (
128
+ !keyPathEqual(existing.keyPath, definition.storedKeyPath) ||
129
+ existing.unique !== definition.unique ||
130
+ existing.multiEntry !== definition.multiEntry
131
+ ) {
132
+ throw new Error('QUEUE_INDEX_SCHEMA_MISMATCH')
133
+ }
134
+ }
135
+ }
136
+
137
+ async function inspectSchema (db, indexDefinitions) {
138
+ if (!db.objectStoreNames.contains(ITEMS_STORE) || !db.objectStoreNames.contains(STATE_STORE)) {
139
+ return { missing: true, incompatible: false }
140
+ }
141
+ const tx = db.transaction([ITEMS_STORE, STATE_STORE], 'readonly')
142
+ const done = transactionDone(tx)
143
+ const items = tx.objectStore(ITEMS_STORE)
144
+ const state = tx.objectStore(STATE_STORE)
145
+ if (!keyPathEqual(items.keyPath, 'position') || !keyPathEqual(state.keyPath, 'key')) {
146
+ await done
147
+ return { missing: false, incompatible: true }
148
+ }
149
+
150
+ let missing = false
151
+ for (const definition of indexDefinitions) {
152
+ if (!items.indexNames.contains(definition.name)) {
153
+ missing = true
154
+ continue
155
+ }
156
+ const existing = items.index(definition.name)
157
+ if (
158
+ !keyPathEqual(existing.keyPath, definition.storedKeyPath) ||
159
+ existing.unique !== definition.unique ||
160
+ existing.multiEntry !== definition.multiEntry
161
+ ) {
162
+ await done
163
+ return { missing: false, incompatible: true }
164
+ }
165
+ }
166
+ await done
167
+ return { missing, incompatible: false }
168
+ }
169
+
170
+ async function openQueueDatabase (indexedDB, prefix, indexDefinitions) {
171
+ if (!indexedDB?.open) throw new Error('IDB_UNAVAILABLE')
172
+ // A queue owns its database so independently declared indexes do not need a
173
+ // shared application-wide schema or migration coordinator.
174
+ const name = `${prefix}:idb-queue`
175
+
176
+ for (let attempt = 0; attempt < 3; attempt++) {
177
+ const db = await openDatabase(indexedDB, name, undefined, (nextDb, tx) => ensureSchema(nextDb, tx, indexDefinitions))
178
+ const schema = await inspectSchema(db, indexDefinitions)
179
+ if (schema.incompatible) {
180
+ db.close()
181
+ throw new Error('QUEUE_INDEX_SCHEMA_MISMATCH')
182
+ }
183
+ if (!schema.missing) return db
184
+
185
+ const version = db.version + 1
186
+ db.close()
187
+ try {
188
+ const upgraded = await openDatabase(indexedDB, name, version, (nextDb, tx) => ensureSchema(nextDb, tx, indexDefinitions))
189
+ const upgradedSchema = await inspectSchema(upgraded, indexDefinitions)
190
+ if (upgradedSchema.incompatible || upgradedSchema.missing) {
191
+ upgraded.close()
192
+ throw new Error(upgradedSchema.incompatible ? 'QUEUE_INDEX_SCHEMA_MISMATCH' : 'QUEUE_SCHEMA_UPGRADE_FAILED')
193
+ }
194
+ return upgraded
195
+ } catch (err) {
196
+ if (err?.name !== 'VersionError' || attempt === 2) throw err
197
+ }
198
+ }
199
+
200
+ throw new Error('QUEUE_SCHEMA_UPGRADE_FAILED')
201
+ }
202
+
203
+ function itemForStorage (position, item) {
204
+ // IndexedDB does not expose portable per-record byte usage, so keep a stable
205
+ // JSON byte estimate for the queue's logical capacity limit.
206
+ // `position` is the ordering key; queued values remain caller payloads.
207
+ const storedItem = { ...item }
208
+ let byteSize = 0
209
+ let serialized = ''
210
+ while (true) {
211
+ serialized = JSON.stringify({ byteSize, item: storedItem })
212
+ const nextByteSize = byteLength(serialized)
213
+ if (nextByteSize === byteSize) break
214
+ byteSize = nextByteSize
215
+ }
216
+ return { position, byteSize, item: storedItem }
217
+ }
218
+
219
+ function assertIndex (index, length, { allowEnd = false } = {}) {
220
+ const max = allowEnd ? length : length - 1
221
+ if (!Number.isSafeInteger(index) || index < 0 || index > max) throw new Error('QUEUE_INDEX_OUT_OF_RANGE')
222
+ }
223
+
224
+ function validDirection (direction) {
225
+ if (direction === undefined || direction === 'next') return 'next'
226
+ if (direction === 'prev') return 'prev'
227
+ throw new Error('QUEUE_INVALID_DIRECTION')
228
+ }
229
+
230
+ function isQuotaExceeded (err) {
231
+ return err?.name === 'QuotaExceededError' ||
232
+ err?.name === 'NS_ERROR_DOM_QUOTA_REACHED' ||
233
+ err?.code === 22 ||
234
+ err?.code === 1014 ||
235
+ /quota/i.test(err?.message || '')
236
+ }
237
+
238
+ export async function createQueue ({
239
+ prefix,
240
+ indexes = {},
241
+ maxBytes,
242
+ evictionPolicy = 'opposite-end',
243
+ indexedDB = globalThis.indexedDB
244
+ } = {}) {
245
+ if (!prefix) throw new Error('QUEUE_PREFIX_REQUIRED')
246
+
247
+ const indexDefinitions = normalizeIndexes(indexes)
248
+ const db = await openQueueDatabase(indexedDB, prefix, indexDefinitions)
249
+ const configuredMaxBytes = Number.isSafeInteger(maxBytes) && maxBytes > 0 ? maxBytes : Infinity
250
+ const configuredEvictionPolicy = normalizeEvictionPolicy(evictionPolicy)
251
+ const waiters = new Set()
252
+ let sessionMaxBytes = configuredMaxBytes
253
+ let revision = 0
254
+
255
+ function hasByteLimit () {
256
+ return Number.isFinite(sessionMaxBytes)
257
+ }
258
+
259
+ function evictionDirectionFor (operation, { index = 0, length = 0 } = {}) {
260
+ if (configuredEvictionPolicy === 'head') return 'head'
261
+ if (configuredEvictionPolicy === 'tail') return 'tail'
262
+ if (operation === 'unshift') return 'tail'
263
+ if (operation === 'setAt' || operation === 'insertAt') return index <= length / 2 ? 'tail' : 'head'
264
+ return 'head'
265
+ }
266
+
267
+ function evictionHeadroomBytes () {
268
+ if (!hasByteLimit()) return 0
269
+ // Evict a little below the limit so the next write has free capacity even
270
+ // when its storage overhead is slightly larger than the estimate.
271
+ return Math.min(Math.max(1, Math.floor(sessionMaxBytes * DEFAULT_EVICTION_HEADROOM_RATIO)), MAX_EVICTION_HEADROOM_BYTES)
272
+ }
273
+
274
+ function targetBytesAfterWrite (requiredBytes) {
275
+ if (!hasByteLimit()) return Infinity
276
+ return Math.max(requiredBytes, sessionMaxBytes - evictionHeadroomBytes())
277
+ }
278
+
279
+ function lowerSessionMaxBytes (requiredBytes) {
280
+ if (!hasByteLimit()) return
281
+ const next = Math.max(requiredBytes, Math.floor(sessionMaxBytes * 0.8))
282
+ if (next < sessionMaxBytes) sessionMaxBytes = next
283
+ }
284
+
285
+ function wake () {
286
+ revision++
287
+ for (const resolve of waiters) resolve()
288
+ waiters.clear()
289
+ }
290
+
291
+ async function waitForChange (knownRevision) {
292
+ if (revision !== knownRevision) return
293
+ await new Promise(resolve => waiters.add(resolve))
294
+ }
295
+
296
+ async function transaction (mode, work) {
297
+ const tx = db.transaction([ITEMS_STORE, STATE_STORE], mode)
298
+ const done = transactionDone(tx)
299
+ try {
300
+ // Keep work limited to IndexedDB requests so the transaction stays active.
301
+ const value = await work(tx)
302
+ await done
303
+ return value
304
+ } catch (err) {
305
+ try { tx.abort() } catch {}
306
+ try { await done } catch {}
307
+ throw err
308
+ }
309
+ }
310
+
311
+ async function readState (tx) {
312
+ const { result } = await run('get', [STATE_KEY], STATE_STORE, null, { tx })
313
+ return normalizeState(result)
314
+ }
315
+
316
+ async function writeState (tx, state) {
317
+ if (state.head >= state.tail) {
318
+ await run('delete', [STATE_KEY], STATE_STORE, null, { tx })
319
+ return
320
+ }
321
+ await run('put', [{ key: STATE_KEY, ...state }], STATE_STORE, null, { tx })
322
+ }
323
+
324
+ async function allRecords (tx) {
325
+ const { result } = await run('getAll', [], ITEMS_STORE, null, { tx })
326
+ return result.sort((a, b) => a.position - b.position)
327
+ }
328
+
329
+ async function recordsForState (tx, state) {
330
+ // `removeWhere` may leave holes, so queue order is the persisted position
331
+ // range rather than the number of stored records.
332
+ const records = await allRecords(tx)
333
+ return records.filter(record => record.position >= state.head && record.position < state.tail)
334
+ }
335
+
336
+ function applyBounds (state, records) {
337
+ if (!records.length) {
338
+ state.head = 0
339
+ state.tail = 0
340
+ state.usedBytes = 0
341
+ return
342
+ }
343
+ state.head = records[0].position
344
+ state.tail = records[records.length - 1].position + 1
345
+ state.usedBytes = records.reduce((total, record) => total + (record.byteSize || 0), 0)
346
+ }
347
+
348
+ function extendBounds (state, position) {
349
+ if (state.head >= state.tail) {
350
+ state.head = position
351
+ state.tail = position + 1
352
+ return
353
+ }
354
+ state.head = Math.min(state.head, position)
355
+ state.tail = Math.max(state.tail, position + 1)
356
+ }
357
+
358
+ async function getRecord (tx, position) {
359
+ return run('get', [position], ITEMS_STORE, null, { tx }).then(value => value.result || null)
360
+ }
361
+
362
+ async function putRecord (tx, record) {
363
+ await run('put', [record], ITEMS_STORE, null, { tx })
364
+ }
365
+
366
+ async function deleteRecord (tx, position) {
367
+ await run('delete', [position], ITEMS_STORE, null, { tx })
368
+ }
369
+
370
+ async function nextCursor (cursor, p) {
371
+ Object.assign(p, deferred())
372
+ cursor.continue()
373
+ return (await p.promise).result
374
+ }
375
+
376
+ async function storedRecordAtEnd (tx, state, direction, excludedPositions = new Set()) {
377
+ // Queue positions are the object-store primary key, so an end cursor finds
378
+ // an eviction candidate without reading every queued record into memory.
379
+ const p = deferred()
380
+ const args = direction === 'tail' ? [undefined, 'prev'] : []
381
+ let cursor = (await run('openCursor', args, ITEMS_STORE, null, { tx, p })).result
382
+ while (cursor) {
383
+ const record = cursor.value
384
+ if (record.position < state.head) {
385
+ if (direction === 'tail') return null
386
+ } else if (record.position >= state.tail) {
387
+ if (direction === 'head') return null
388
+ } else if (!excludedPositions.has(record.position)) {
389
+ return record
390
+ }
391
+ cursor = await nextCursor(cursor, p)
392
+ }
393
+ return null
394
+ }
395
+
396
+ async function trimBounds (tx, state, protectedPositions = new Set()) {
397
+ // Holes are valid after predicate removal. Find only the stored endpoints,
398
+ // while preserving a protected slot that a pending positional write needs.
399
+ const headRecord = await storedRecordAtEnd(tx, state, 'head')
400
+ const tailRecord = await storedRecordAtEnd(tx, state, 'tail')
401
+ let head = headRecord?.position ?? Infinity
402
+ let tail = tailRecord?.position ?? -Infinity
403
+ for (const position of protectedPositions) {
404
+ if (!Number.isSafeInteger(position) || position < state.head || position >= state.tail) continue
405
+ head = Math.min(head, position)
406
+ tail = Math.max(tail, position)
407
+ }
408
+ if (head === Infinity) {
409
+ state.head = 0
410
+ state.tail = 0
411
+ state.usedBytes = 0
412
+ return
413
+ }
414
+ state.head = head
415
+ state.tail = tail + 1
416
+ }
417
+
418
+ async function evictOne (tx, state, { direction, protectedPositions = new Set() } = {}) {
419
+ // Remove one actual record from the selected end, skipping holes and the
420
+ // slot currently being replaced or inserted.
421
+ const record = await storedRecordAtEnd(tx, state, direction, protectedPositions)
422
+ if (!record) return false
423
+ await deleteRecord(tx, record.position)
424
+ state.usedBytes = Math.max(0, state.usedBytes - (record.byteSize || 0))
425
+ await trimBounds(tx, state, protectedPositions)
426
+ return true
427
+ }
428
+
429
+ async function evictToFit (tx, state, requiredBytes, options = {}) {
430
+ if (!hasByteLimit()) return
431
+ if (requiredBytes > sessionMaxBytes) throw new Error('QUEUE_ITEM_TOO_LARGE')
432
+ // Make room for the write and retain eviction headroom for the next one.
433
+ const targetBytes = targetBytesAfterWrite(requiredBytes)
434
+ while (state.usedBytes + requiredBytes > targetBytes) {
435
+ if (!await evictOne(tx, state, options)) break
436
+ }
437
+ if (state.usedBytes + requiredBytes > sessionMaxBytes) throw new Error('QUEUE_CAPACITY_EXCEEDED')
438
+ }
439
+
440
+ async function evictToBytes (tx, state, targetBytes, options = {}) {
441
+ if (!hasByteLimit()) return
442
+ while (state.usedBytes > targetBytes) {
443
+ if (!await evictOne(tx, state, options)) break
444
+ }
445
+ }
446
+
447
+ async function putItem (tx, state, position, item, options = {}) {
448
+ const previous = await getRecord(tx, position)
449
+ const stored = itemForStorage(position, item)
450
+ const previousByteSize = previous?.byteSize || 0
451
+ const delta = stored.byteSize - previousByteSize
452
+ if (hasByteLimit() && stored.byteSize > sessionMaxBytes) throw new Error('QUEUE_ITEM_TOO_LARGE')
453
+ if (delta > 0) await evictToFit(tx, state, delta, options)
454
+ await putRecord(tx, stored)
455
+ state.usedBytes = Math.max(0, state.usedBytes - previousByteSize + stored.byteSize)
456
+ extendBounds(state, position)
457
+ return stored
458
+ }
459
+
460
+ async function pushInTransaction (tx, state, item) {
461
+ const direction = evictionDirectionFor('push')
462
+ let position = state.tail
463
+ let stored = itemForStorage(position, item)
464
+ if (hasByteLimit() && stored.byteSize > sessionMaxBytes) throw new Error('QUEUE_ITEM_TOO_LARGE')
465
+ await evictToFit(tx, state, stored.byteSize, { direction })
466
+ position = state.tail
467
+ stored = itemForStorage(position, item)
468
+ await putRecord(tx, stored)
469
+ if (state.head >= state.tail) state.head = position
470
+ state.tail = position + 1
471
+ state.usedBytes += stored.byteSize
472
+ return state.tail - state.head
473
+ }
474
+
475
+ async function unshiftInTransaction (tx, state, item) {
476
+ const direction = evictionDirectionFor('unshift')
477
+ let position = state.head - 1
478
+ let stored = itemForStorage(position, item)
479
+ if (hasByteLimit() && stored.byteSize > sessionMaxBytes) throw new Error('QUEUE_ITEM_TOO_LARGE')
480
+ await evictToFit(tx, state, stored.byteSize, { direction })
481
+ position = state.head - 1
482
+ stored = itemForStorage(position, item)
483
+ await putRecord(tx, stored)
484
+ if (state.head >= state.tail) state.tail = position + 1
485
+ state.head = position
486
+ state.usedBytes += stored.byteSize
487
+ return state.tail - state.head
488
+ }
489
+
490
+ async function insertAtInTransaction (tx, state, index, item) {
491
+ const length = state.tail - state.head
492
+ assertIndex(index, length, { allowEnd: true })
493
+ let slot = state.head + index
494
+ let stored = itemForStorage(slot, item)
495
+ if (hasByteLimit() && stored.byteSize > sessionMaxBytes) throw new Error('QUEUE_ITEM_TOO_LARGE')
496
+ await evictToFit(tx, state, stored.byteSize, { direction: evictionDirectionFor('insertAt', { index, length }) })
497
+
498
+ const nextLength = state.tail - state.head
499
+ const nextIndex = Math.min(index, nextLength)
500
+ slot = state.head + nextIndex
501
+ stored = itemForStorage(slot, item)
502
+ if (hasByteLimit() && stored.byteSize > sessionMaxBytes) throw new Error('QUEUE_ITEM_TOO_LARGE')
503
+ await evictToFit(tx, state, stored.byteSize, { direction: evictionDirectionFor('insertAt', { index: nextIndex, length: nextLength }) })
504
+
505
+ // Shift from the tail so each source record is copied before its slot is
506
+ // reused by the preceding record.
507
+ const records = await recordsForState(tx, state)
508
+ for (const record of [...records].reverse()) {
509
+ if (record.position < slot) continue
510
+ await putRecord(tx, { ...record, position: record.position + 1 })
511
+ await deleteRecord(tx, record.position)
512
+ }
513
+ if (state.head >= state.tail) state.head = slot
514
+ state.tail++
515
+ await putRecord(tx, stored)
516
+ state.usedBytes += stored.byteSize
517
+ return nextIndex
518
+ }
519
+
520
+ async function removeAtInTransaction (tx, state, index) {
521
+ const length = state.tail - state.head
522
+ assertIndex(index, length)
523
+ const slot = state.head + index
524
+ const removed = await getRecord(tx, slot)
525
+ const records = await recordsForState(tx, state)
526
+ // Delete first, then shift toward the head to close the logical gap.
527
+ await deleteRecord(tx, slot)
528
+ for (const record of records) {
529
+ if (record.position <= slot) continue
530
+ await putRecord(tx, { ...record, position: record.position - 1 })
531
+ await deleteRecord(tx, record.position)
532
+ }
533
+ const oldTail = state.tail
534
+ state.tail--
535
+ await deleteRecord(tx, oldTail - 1)
536
+ state.usedBytes = Math.max(0, state.usedBytes - (removed?.byteSize || 0))
537
+ applyBounds(state, (await recordsForState(tx, { head: state.head, tail: state.tail, usedBytes: state.usedBytes })))
538
+ return removed?.item || null
539
+ }
540
+
541
+ async function mutate (operation, { requiredBytes = 0, wakeWaiters = false } = {}) {
542
+ let retried = false
543
+ while (true) {
544
+ try {
545
+ const value = await transaction('readwrite', async tx => {
546
+ const state = await readState(tx)
547
+ const result = await operation(tx, state)
548
+ await writeState(tx, state)
549
+ return result
550
+ })
551
+ if (wakeWaiters) wake()
552
+ return value
553
+ } catch (err) {
554
+ if (retried || !hasByteLimit() || !isQuotaExceeded(err)) throw err
555
+ // Browser quota can be lower than the configured logical budget.
556
+ // Retry once with a smaller in-memory budget after atomic rollback.
557
+ lowerSessionMaxBytes(requiredBytes)
558
+ retried = true
559
+ }
560
+ }
561
+ }
562
+
563
+ async function snapshot (select) {
564
+ return transaction('readonly', async tx => select(tx))
565
+ }
566
+
567
+ async function push (item) {
568
+ const requiredBytes = itemForStorage(0, item).byteSize
569
+ return mutate((tx, state) => pushInTransaction(tx, state, item), { requiredBytes, wakeWaiters: true })
570
+ }
571
+
572
+ async function unshift (item) {
573
+ const requiredBytes = itemForStorage(0, item).byteSize
574
+ return mutate((tx, state) => unshiftInTransaction(tx, state, item), { requiredBytes, wakeWaiters: true })
575
+ }
576
+
577
+ async function shift () {
578
+ return mutate(async (tx, state) => {
579
+ while (state.head < state.tail) {
580
+ const position = state.head
581
+ state.head++
582
+ const stored = await getRecord(tx, position)
583
+ await deleteRecord(tx, position)
584
+ if (!stored?.item) continue
585
+ state.usedBytes = Math.max(0, state.usedBytes - (stored.byteSize || 0))
586
+ return stored.item
587
+ }
588
+ state.usedBytes = 0
589
+ return null
590
+ })
591
+ }
592
+
593
+ async function pop () {
594
+ return mutate(async (tx, state) => {
595
+ while (state.tail > state.head) {
596
+ const position = state.tail - 1
597
+ state.tail--
598
+ const stored = await getRecord(tx, position)
599
+ await deleteRecord(tx, position)
600
+ if (!stored?.item) continue
601
+ state.usedBytes = Math.max(0, state.usedBytes - (stored.byteSize || 0))
602
+ return stored.item
603
+ }
604
+ state.usedBytes = 0
605
+ return null
606
+ })
607
+ }
608
+
609
+ async function setAt (index, item) {
610
+ const requiredBytes = itemForStorage(0, item).byteSize
611
+ return mutate(async (tx, state) => {
612
+ const length = state.tail - state.head
613
+ assertIndex(index, length)
614
+ const position = state.head + index
615
+ await putItem(tx, state, position, item, {
616
+ direction: evictionDirectionFor('setAt', { index, length }),
617
+ protectedPositions: new Set([position])
618
+ })
619
+ return index
620
+ }, { requiredBytes, wakeWaiters: true })
621
+ }
622
+
623
+ async function insertAt (index, item) {
624
+ const requiredBytes = itemForStorage(0, item).byteSize
625
+ return mutate((tx, state) => insertAtInTransaction(tx, state, index, item), { requiredBytes, wakeWaiters: true })
626
+ }
627
+
628
+ async function insertWhere (predicate, item, { appendIfMissing = false } = {}) {
629
+ if (typeof predicate !== 'function') throw new Error('QUEUE_PREDICATE_REQUIRED')
630
+ const requiredBytes = itemForStorage(0, item).byteSize
631
+ return mutate(async (tx, state) => {
632
+ const records = await recordsForState(tx, state)
633
+ const byPosition = new Map(records.map(record => [record.position, record]))
634
+ const length = state.tail - state.head
635
+ for (let index = 0; index < length; index++) {
636
+ const record = byPosition.get(state.head + index)
637
+ if (record?.item && predicate(record.item, index)) return insertAtInTransaction(tx, state, index, item)
638
+ }
639
+ if (appendIfMissing) return insertAtInTransaction(tx, state, length, item)
640
+ return null
641
+ }, { requiredBytes, wakeWaiters: true })
642
+ }
643
+
644
+ async function removeAt (index) {
645
+ return mutate((tx, state) => removeAtInTransaction(tx, state, index), { wakeWaiters: true })
646
+ }
647
+
648
+ async function removeWhere (predicate) {
649
+ if (typeof predicate !== 'function') throw new Error('QUEUE_PREDICATE_REQUIRED')
650
+ return mutate(async (tx, state) => {
651
+ const records = await recordsForState(tx, state)
652
+ const removed = new Set()
653
+ for (const record of records) {
654
+ let matches = false
655
+ try { matches = Boolean(predicate(record.item)) } catch { matches = true }
656
+ if (!matches) continue
657
+ removed.add(record.position)
658
+ await deleteRecord(tx, record.position)
659
+ }
660
+ if (removed.size) applyBounds(state, records.filter(record => !removed.has(record.position)))
661
+ })
662
+ }
663
+
664
+ async function some (predicate) {
665
+ if (typeof predicate !== 'function') throw new Error('QUEUE_PREDICATE_REQUIRED')
666
+ return snapshot(async tx => {
667
+ const state = await readState(tx)
668
+ const records = await recordsForState(tx, state)
669
+ return records.some(record => predicate(record.item))
670
+ })
671
+ }
672
+
673
+ async function clear () {
674
+ return mutate(async (tx, state) => {
675
+ await run('clear', [], ITEMS_STORE, null, { tx })
676
+ state.head = 0
677
+ state.tail = 0
678
+ state.usedBytes = 0
679
+ })
680
+ }
681
+
682
+ async function getBy (indexName, query) {
683
+ // Explicit index operations avoid scanning queue values in JavaScript.
684
+ return snapshot(async tx => {
685
+ const { result } = await run('get', [query], ITEMS_STORE, indexName, { tx })
686
+ return result?.item || null
687
+ })
688
+ }
689
+
690
+ async function someBy (indexName, query) {
691
+ return snapshot(async tx => {
692
+ const { result } = await run('getKey', [query], ITEMS_STORE, indexName, { tx })
693
+ return result !== undefined
694
+ })
695
+ }
696
+
697
+ async function removeBy (indexName, query) {
698
+ // IndexedDB selects only matching records; deletion and state repair share
699
+ // one transaction so an interruption cannot leave partial queue state.
700
+ return mutate(async (tx, state) => {
701
+ const { result } = await run('getAll', [query], ITEMS_STORE, indexName, { tx })
702
+ for (const record of result) await deleteRecord(tx, record.position)
703
+ if (result.length) {
704
+ const removedBytes = result.reduce((total, record) => total + (record.byteSize || 0), 0)
705
+ state.usedBytes = Math.max(0, state.usedBytes - removedBytes)
706
+ await trimBounds(tx, state)
707
+ }
708
+ return result.map(record => record.item)
709
+ })
710
+ }
711
+
712
+ async function snapshotStoredItems () {
713
+ return snapshot(async tx => {
714
+ const state = await readState(tx)
715
+ return recordsForState(tx, state)
716
+ })
717
+ }
718
+
719
+ async function * items () {
720
+ while (true) {
721
+ const knownRevision = revision
722
+ const item = await shift()
723
+ if (item) {
724
+ yield item
725
+ } else {
726
+ await waitForChange(knownRevision)
727
+ }
728
+ }
729
+ }
730
+
731
+ async function * reverseItems () {
732
+ while (true) {
733
+ const knownRevision = revision
734
+ const item = await pop()
735
+ if (item) {
736
+ yield item
737
+ } else {
738
+ await waitForChange(knownRevision)
739
+ }
740
+ }
741
+ }
742
+
743
+ async function * storedItems () {
744
+ for (const record of await snapshotStoredItems()) yield record.item
745
+ }
746
+
747
+ async function * reverseStoredItems () {
748
+ for (const record of (await snapshotStoredItems()).reverse()) yield record.item
749
+ }
750
+
751
+ async function * storedItemsBy (indexName, query, { direction = 'next' } = {}) {
752
+ direction = validDirection(direction)
753
+ const records = await snapshot(async tx => {
754
+ const { result } = await run('getAll', [query], ITEMS_STORE, indexName, { tx })
755
+ return result
756
+ })
757
+ if (direction === 'prev') records.reverse()
758
+ for (const record of records) yield record.item
759
+ }
760
+
761
+ await mutate(async (tx, state) => {
762
+ const records = await allRecords(tx)
763
+ applyBounds(state, records)
764
+ if (hasByteLimit()) {
765
+ await evictToBytes(tx, state, Math.min(sessionMaxBytes, targetBytesAfterWrite(0)), {
766
+ direction: evictionDirectionFor('recover')
767
+ })
768
+ }
769
+ })
770
+
771
+ return {
772
+ enqueue: push,
773
+ push,
774
+ pop,
775
+ unshift,
776
+ shift,
777
+ items,
778
+ reverseItems,
779
+ storedItems,
780
+ reverseStoredItems,
781
+ setAt,
782
+ insertAt,
783
+ insertWhere,
784
+ removeAt,
785
+ removeWhere,
786
+ some,
787
+ clear,
788
+ getBy,
789
+ someBy,
790
+ removeBy,
791
+ storedItemsBy
792
+ }
793
+ }