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.
- package/AGENTS.md +20 -0
- package/README.md +129 -0
- package/base16/index.js +14 -0
- package/base64/index.js +29 -0
- package/content-key/event/index.js +85 -0
- package/content-key/index.js +1 -0
- package/content-key/services/iykc-proof.js +129 -0
- package/double-dh/index.js +166 -0
- package/ecdh/index.js +8 -0
- package/idb/index.js +64 -0
- package/idb-queue/index.js +793 -0
- package/index.js +21 -0
- package/key/index.js +139 -0
- package/network/index.js +68 -0
- package/nip44-v3/index.js +175 -0
- package/nip46/constants/index.js +5 -0
- package/nip46/helpers/frame.js +51 -0
- package/nip46/helpers/url.js +96 -0
- package/nip46/index.js +5 -0
- package/nip46/services/bunker-signer.js +49 -0
- package/nip46/services/client.js +252 -0
- package/nip46/services/server-session.js +225 -0
- package/nip46/services/transport.js +265 -0
- package/package.json +49 -0
- package/private-channel/constants/index.js +5 -0
- package/private-channel/helpers/chunk-size.js +61 -0
- package/private-channel/helpers/chunks.js +282 -0
- package/private-channel/helpers/event.js +68 -0
- package/private-channel/index.js +942 -0
- package/private-channel/services/received-chunks.js +398 -0
- package/private-message/index.js +672 -0
- package/private-messenger/constants/index.js +1 -0
- package/private-messenger/index.js +1431 -0
- package/private-messenger/recovery/index.js +316 -0
- package/relay/constants/index.js +18 -0
- package/relay/helpers/hll.js +62 -0
- package/relay/helpers/publish.js +100 -0
- package/relay/helpers/routing.js +36 -0
- package/relay/helpers/timer.js +4 -0
- package/relay/index.js +4 -0
- package/relay/services/query.js +224 -0
- package/relay/services/relay-connection.js +156 -0
- package/relay/services/relay-pool.js +908 -0
- package/temporary-storage/index.js +85 -0
- package/tests/base16.test.js +19 -0
- package/tests/base64.test.js +18 -0
- package/tests/content-key-event.test.js +48 -0
- package/tests/fixtures/nip44v3-vectors.json +1 -0
- package/tests/helpers/test-signer.js +185 -0
- package/tests/idb-queue.test.js +153 -0
- package/tests/idb.test.js +91 -0
- package/tests/nip44-v3.test.js +73 -0
- package/tests/nip46.test.js +668 -0
- package/tests/private-channel.test.js +1899 -0
- package/tests/private-message.test.js +460 -0
- package/tests/private-messenger.test.js +1715 -0
- package/tests/queries.test.js +101 -0
- package/tests/queue-parity.test.js +105 -0
- package/tests/relay-hll.test.js +32 -0
- package/tests/relay-pool.test.js +2067 -0
- package/tests/temporary-storage.test.js +89 -0
- package/tests/web-storage-queue.test.js +480 -0
- 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
|
+
}
|