libp2r2p 0.11.32 → 0.11.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/package.json +1 -1
- package/private-messenger/event-store/README.md +15 -2
- package/private-messenger/event-store/index.js +81 -20
- package/private-messenger/session/README.md +6 -0
- package/private-messenger/session/helpers/foreground-work.js +30 -1
- package/private-messenger/session/index.js +39 -3
package/README.md
CHANGED
|
@@ -1290,3 +1290,14 @@ within one PrivateMessenger. Duplicate `bySeedKey` races retry up to five times:
|
|
|
1290
1290
|
NYM rows remain idempotent and router rows re-read the winning timestamps before
|
|
1291
1291
|
replacement. External event-store seed adapters retain their independent batched
|
|
1292
1292
|
writes.
|
|
1293
|
+
|
|
1294
|
+
The 0.11.33 recovery adapter checks immutable wrapper mirrors without decrypting
|
|
1295
|
+
snapshots and serializes recovery mutations per event store. Session archives
|
|
1296
|
+
wait for foreground quiet for up to five seconds, execute one batch, then
|
|
1297
|
+
re-evaluate so pending recovery cannot monopolize local persistence.
|
|
1298
|
+
|
|
1299
|
+
The 0.11.34 recovery adapter marks paged event-store reads with the internal
|
|
1300
|
+
`recovery: true` hint so a compatible launcher can prioritize foreground writes
|
|
1301
|
+
over new background read transactions. `putMany` also accepts an optional
|
|
1302
|
+
development diagnostic callback with aggregate existing/inserted/skipped counts;
|
|
1303
|
+
it never exposes ciphertext or record payloads.
|
package/package.json
CHANGED
|
@@ -27,8 +27,21 @@ without decrypting the archive; the store may also purge expired wrappers itself
|
|
|
27
27
|
query. Identical inputs share one immutable result. `putMany` returns
|
|
28
28
|
`undefined` for an expired input without querying or writing; `hasMany` returns
|
|
29
29
|
`false` for expired snapshots. Batched checks use the recovery type as an AND
|
|
30
|
-
filter and the record coordinates as an OR filter
|
|
31
|
-
|
|
30
|
+
filter and the record coordinates as an OR filter. Existence checks compare the
|
|
31
|
+
immutable wrapper `o` mirrors to the requested record IDs and do not decrypt or
|
|
32
|
+
decode snapshots. Duplicate `putMany` inputs return their supplied immutable row.
|
|
33
|
+
- Recovery pages pass the optional internal `{ recovery: true }` query hint.
|
|
34
|
+
Launchers may use it to admit the readonly transaction as background without
|
|
35
|
+
granting an app elevated priority. Stores that do not recognize the hint keep
|
|
36
|
+
the previous query behavior.
|
|
37
|
+
- `putMany(rows, { onDiagnostic })` accepts an optional development callback. One
|
|
38
|
+
sanitized report identifies row, unique, existing, inserted, blocked, expired,
|
|
39
|
+
stored and skipped counts after each batch. It never includes ciphertext,
|
|
40
|
+
decoded rows, record payloads or signer material.
|
|
41
|
+
- Mutating recovery stores share one operation tail per event store, so seed and
|
|
42
|
+
grant writes cannot overlap for the same store. Writes and paged reads pass the
|
|
43
|
+
internal `recovery: true` scheduling hint to the corresponding event-store
|
|
44
|
+
methods; row-returning reads keep normal decrypt-and-validate behavior.
|
|
32
45
|
- `iterate` is an async generator. Queries can name `recordId`, `channelPubkey`,
|
|
33
46
|
`receiverPubkey`, `since`/`until`, or the file tuple `controlChannelPubkey`,
|
|
34
47
|
`fileChannelPubkey`, `receiverPubkey`, and `indices`. `signal` cancels reads.
|
|
@@ -7,8 +7,20 @@ export { encodeRecord as encodeRecoveryRecord, decodeRecord as decodeRecoveryRec
|
|
|
7
7
|
|
|
8
8
|
const nowSeconds = () => Math.floor(Date.now() / 1000)
|
|
9
9
|
const RECOVERY_BATCH_SIZE = 100
|
|
10
|
+
const RECOVERY_QUERY_OPTIONS = Object.freeze({ recovery: true })
|
|
11
|
+
const mutationTails = new WeakMap()
|
|
10
12
|
const chunks = values => Array.from({ length: Math.ceil(values.length / RECOVERY_BATCH_SIZE) }, (_, index) =>
|
|
11
13
|
values.slice(index * RECOVERY_BATCH_SIZE, (index + 1) * RECOVERY_BATCH_SIZE))
|
|
14
|
+
function serializeMutation (eventStore, operation) {
|
|
15
|
+
const previous = mutationTails.get(eventStore) ?? Promise.resolve()
|
|
16
|
+
const run = previous.catch(() => {}).then(operation)
|
|
17
|
+
const tail = run.catch(() => {})
|
|
18
|
+
mutationTails.set(eventStore, tail)
|
|
19
|
+
tail.then(() => {
|
|
20
|
+
if (mutationTails.get(eventStore) === tail) mutationTails.delete(eventStore)
|
|
21
|
+
})
|
|
22
|
+
return run
|
|
23
|
+
}
|
|
12
24
|
function assertOptions (eventStore, signer) {
|
|
13
25
|
if (!eventStore?.query || !eventStore?.addPersonalCopy || !signer?.getPublicKey || !signer?.obfuscate || !signer?.nip44v3?.decrypt) throw new ValidationError('INVALID_EVENT_STORE_OPTIONS')
|
|
14
26
|
}
|
|
@@ -21,7 +33,10 @@ async function * pages (eventStore, filter, signal) {
|
|
|
21
33
|
let until = filter.until, excluded = []
|
|
22
34
|
while (true) {
|
|
23
35
|
signal?.throwIfAborted()
|
|
24
|
-
const { results } = await eventStore.query(
|
|
36
|
+
const { results } = await eventStore.query(
|
|
37
|
+
{ ...filter, ...(until === undefined ? {} : { until }), ...(excluded.length ? { '!ids': excluded } : {}), limit: 128, search: 'sort:desc' },
|
|
38
|
+
RECOVERY_QUERY_OPTIONS
|
|
39
|
+
)
|
|
25
40
|
if (!results.length) return
|
|
26
41
|
for (const event of results) { signal?.throwIfAborted(); yield event }
|
|
27
42
|
const boundary = results.at(-1).created_at
|
|
@@ -103,8 +118,27 @@ export function createPersonalCopyRecoveryStorage ({ eventStore, signer, now = n
|
|
|
103
118
|
yield { row, wrapper }
|
|
104
119
|
}
|
|
105
120
|
}
|
|
121
|
+
async function * existingRecordIds (type, values, { includeExpired = false } = {}) {
|
|
122
|
+
const ids = [...new Set(values)]
|
|
123
|
+
if (!ids.length) return
|
|
124
|
+
const filter = await exactFilterFor(type)
|
|
125
|
+
const mirrorsById = new Map(await Promise.all(ids.map(async recordId => [await mirror('d', recordId), recordId])))
|
|
126
|
+
filter['#o'] = [...mirrorsById.keys()]
|
|
127
|
+
for await (const wrapper of pages(eventStore, filter)) {
|
|
128
|
+
const expiry = Number(wrapper.tags?.find(tag => Array.isArray(tag) && tag[0] === 'expiration')?.[1])
|
|
129
|
+
if (!includeExpired && (!Number.isSafeInteger(expiry) || expiry <= now())) continue
|
|
130
|
+
for (const tag of wrapper.tags || []) {
|
|
131
|
+
if (!Array.isArray(tag) || tag[0] !== 'o') continue
|
|
132
|
+
const recordId = mirrorsById.get(tag[1])
|
|
133
|
+
if (recordId) yield recordId
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
106
137
|
function store (type) {
|
|
107
|
-
async function putMany (rows) {
|
|
138
|
+
async function putMany (rows, options = {}) {
|
|
139
|
+
return serializeMutation(eventStore, () => putManyNow(rows, options))
|
|
140
|
+
}
|
|
141
|
+
async function putManyNow (rows, { onDiagnostic } = {}) {
|
|
108
142
|
check()
|
|
109
143
|
if (!Array.isArray(rows)) throw new ValidationError('INVALID_RECOVERY_RECORDS')
|
|
110
144
|
const encoded = rows.map(row => {
|
|
@@ -115,15 +149,40 @@ export function createPersonalCopyRecoveryStorage ({ eventStore, signer, now = n
|
|
|
115
149
|
const unique = new Map(encoded.filter(Boolean).map(record => [record.recordId, record]))
|
|
116
150
|
const stored = new Map()
|
|
117
151
|
for (const recordIds of chunks([...unique.keys()])) {
|
|
118
|
-
for await (const
|
|
152
|
+
for await (const recordId of existingRecordIds(type, recordIds, { includeExpired: true })) {
|
|
153
|
+
const record = unique.get(recordId)
|
|
154
|
+
if (record) stored.set(recordId, { ...record.row, recordId })
|
|
155
|
+
}
|
|
119
156
|
}
|
|
157
|
+
const existingCount = stored.size
|
|
158
|
+
let insertedCount = 0
|
|
159
|
+
let blockedCount = 0
|
|
120
160
|
for (const [recordId, record] of unique) {
|
|
121
161
|
if (stored.has(recordId)) continue
|
|
122
|
-
const saved = committed(await eventStore.addPersonalCopy(record.event, { context: '' }))
|
|
162
|
+
const saved = committed(await eventStore.addPersonalCopy(record.event, { context: '', recovery: true }))
|
|
123
163
|
// A pending kind-5 tombstone can reject the insertion; do not cache it.
|
|
124
|
-
if (saved.result.stored === false && saved.result.code === 'blocked')
|
|
125
|
-
|
|
164
|
+
if (saved.result.stored === false && saved.result.code === 'blocked') {
|
|
165
|
+
stored.set(recordId, undefined)
|
|
166
|
+
blockedCount++
|
|
167
|
+
} else {
|
|
168
|
+
stored.set(recordId, { ...record.row, recordId })
|
|
169
|
+
insertedCount++
|
|
170
|
+
}
|
|
126
171
|
}
|
|
172
|
+
const expiredCount = encoded.filter(record => record === null).length
|
|
173
|
+
try {
|
|
174
|
+
onDiagnostic?.({
|
|
175
|
+
type,
|
|
176
|
+
rowCount: rows.length,
|
|
177
|
+
uniqueCount: unique.size,
|
|
178
|
+
existingCount,
|
|
179
|
+
insertedCount,
|
|
180
|
+
blockedCount,
|
|
181
|
+
expiredCount,
|
|
182
|
+
storedCount: existingCount + insertedCount,
|
|
183
|
+
skippedCount: existingCount + blockedCount + expiredCount
|
|
184
|
+
})
|
|
185
|
+
} catch {}
|
|
127
186
|
return encoded.map(record => record ? stored.get(record.recordId) : undefined)
|
|
128
187
|
}
|
|
129
188
|
async function hasMany (rows) {
|
|
@@ -132,7 +191,7 @@ export function createPersonalCopyRecoveryStorage ({ eventStore, signer, now = n
|
|
|
132
191
|
const recordIds = rows.map(row => row.recordId || encodeRecord(type, row).tags[0][1])
|
|
133
192
|
const present = new Set()
|
|
134
193
|
for (const batch of chunks([...new Set(recordIds)])) {
|
|
135
|
-
for await (const
|
|
194
|
+
for await (const recordId of existingRecordIds(type, batch)) present.add(recordId)
|
|
136
195
|
}
|
|
137
196
|
return recordIds.map(recordId => present.has(recordId))
|
|
138
197
|
}
|
|
@@ -154,20 +213,22 @@ export function createPersonalCopyRecoveryStorage ({ eventStore, signer, now = n
|
|
|
154
213
|
}
|
|
155
214
|
},
|
|
156
215
|
async revoke (rows) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
216
|
+
return serializeMutation(eventStore, async () => {
|
|
217
|
+
check()
|
|
218
|
+
const [owner] = await ready()
|
|
219
|
+
for (let i = 0; i < rows.length; i += 100) {
|
|
220
|
+
const group = rows.slice(i, i + 100).filter(row => row.expiresAt > now())
|
|
221
|
+
if (!group.length) continue
|
|
222
|
+
const tags = []; let createdAt = now(), expiresAt = 0
|
|
223
|
+
for (const row of group) {
|
|
224
|
+
const recordId = row.recordId || encodeRecord(type, row).tags[0][1]
|
|
225
|
+
for await (const { wrapper } of records(type, { recordId })) createdAt = Math.max(createdAt, wrapper.created_at)
|
|
226
|
+
tags.push(['a', `30078:${owner}:${recordId}`]); expiresAt = Math.max(expiresAt, row.expiresAt)
|
|
227
|
+
}
|
|
228
|
+
if (expiresAt <= createdAt) continue
|
|
229
|
+
committed(await eventStore.addPersonalCopy({ kind: 5, created_at: createdAt, tags: [...tags, ['k', '30078'], ['expiration', String(expiresAt)]], content: '' }, { context: '', recovery: true }))
|
|
167
230
|
}
|
|
168
|
-
|
|
169
|
-
committed(await eventStore.addPersonalCopy({ kind: 5, created_at: createdAt, tags: [...tags, ['k', '30078'], ['expiration', String(expiresAt)]], content: '' }, { context: '' }))
|
|
170
|
-
}
|
|
231
|
+
})
|
|
171
232
|
}
|
|
172
233
|
}
|
|
173
234
|
}
|
|
@@ -130,6 +130,8 @@ reevaluate a local failure without clearing another owner's storage pause.
|
|
|
130
130
|
|
|
131
131
|
`enqueue({ peer, event, context, requiredFiles, deletion })` preserves prepared
|
|
132
132
|
identities. Local message commits and remote stages have independent checkpoints.
|
|
133
|
+
Recovery archival waits up to five seconds for foreground work, then runs one
|
|
134
|
+
durable batch and re-evaluates; postponement never drops packet rows.
|
|
133
135
|
It sends original/thumbnail chunks before announcements, yields for small messages,
|
|
134
136
|
and never interprets relay rejection text as signer permission denial. Inbox ACK
|
|
135
137
|
follows durable storage; failed storage nacks and pauses ingestion. Hearsay cannot
|
|
@@ -268,6 +270,10 @@ event/peer identifiers and elapsed milliseconds for admission, local processing,
|
|
|
268
270
|
channel preparation, publication acceptance and cleanup. Callback failures never
|
|
269
271
|
change delivery. Connect only in development; never log events or crypto buffers.
|
|
270
272
|
Custom outbox factories also receive this optional callback for storage timings.
|
|
273
|
+
Recovery archive completion also reports `publicationId`, one aggregate
|
|
274
|
+
`recovery-archive-result` per `putMany`, and sanitized row/unique/existing/
|
|
275
|
+
inserted/blocked/expired/stored/skipped counts. A single-row batch may include
|
|
276
|
+
its opaque `seedRecordId`.
|
|
271
277
|
|
|
272
278
|
## Durable prepared publications
|
|
273
279
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Local outbox persistence and user publications take precedence over the next
|
|
2
2
|
// optional presence signer call. Already-started signer calls are not recalled.
|
|
3
|
-
export function createForegroundWork () {
|
|
3
|
+
export function createForegroundWork ({ setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
|
|
4
4
|
let active = 0
|
|
5
5
|
const waiters = new Set()
|
|
6
6
|
const observers = new Set()
|
|
@@ -28,6 +28,35 @@ export function createForegroundWork () {
|
|
|
28
28
|
combined.addEventListener('abort', finish, { once: true })
|
|
29
29
|
})
|
|
30
30
|
},
|
|
31
|
+
waitUntilIdle ({ maxWaitMs = 5000, signal } = {}) {
|
|
32
|
+
if (!Number.isFinite(maxWaitMs) || maxWaitMs < 0) throw new TypeError('INVALID_FOREGROUND_MAX_WAIT')
|
|
33
|
+
const combined = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal
|
|
34
|
+
combined.throwIfAborted()
|
|
35
|
+
if (!active) return Promise.resolve(true)
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const timeout = { timer: undefined }
|
|
38
|
+
const cleanup = () => {
|
|
39
|
+
waiters.delete(finishIdle)
|
|
40
|
+
if (timeout.timer !== undefined) clearTimer(timeout.timer)
|
|
41
|
+
combined.removeEventListener('abort', finishAbort)
|
|
42
|
+
}
|
|
43
|
+
const finishIdle = () => {
|
|
44
|
+
cleanup()
|
|
45
|
+
resolve(true)
|
|
46
|
+
}
|
|
47
|
+
const finishAbort = () => {
|
|
48
|
+
cleanup()
|
|
49
|
+
reject(combined.reason)
|
|
50
|
+
}
|
|
51
|
+
waiters.add(finishIdle)
|
|
52
|
+
combined.addEventListener('abort', finishAbort, { once: true })
|
|
53
|
+
timeout.timer = setTimer(() => {
|
|
54
|
+
cleanup()
|
|
55
|
+
resolve(false)
|
|
56
|
+
}, maxWaitMs)
|
|
57
|
+
timeout.timer?.unref?.()
|
|
58
|
+
})
|
|
59
|
+
},
|
|
31
60
|
observe (listener) { observers.add(listener); listener(active > 0); return () => observers.delete(listener) },
|
|
32
61
|
close () { lifetime.abort(); waiters.clear(); observers.clear() }
|
|
33
62
|
}
|
|
@@ -28,6 +28,7 @@ const unavailableSend = /PRIVATE_MESSENGER_PAUSED|CHAT_UNAVAILABLE|MESSENGER_UNA
|
|
|
28
28
|
const isUnavailableSend = error => unavailableSend.test(`${error?.code || ''} ${error?.message || ''}`)
|
|
29
29
|
const SEND_RETRY_MIN_MS = 1000
|
|
30
30
|
const SEND_RETRY_MAX_MS = 30000
|
|
31
|
+
const ARCHIVE_FOREGROUND_WAIT_MS = 5000
|
|
31
32
|
// A paused or unavailable messenger must keep the outbox entry retryable
|
|
32
33
|
// instead of surfacing a send failure the user has to retry by hand.
|
|
33
34
|
const unavailableError = (error, signal) => Object.assign(new Error(error?.message || 'CHAT_UNAVAILABLE'), {
|
|
@@ -59,7 +60,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
59
60
|
onError(error)
|
|
60
61
|
}
|
|
61
62
|
const userSigner = messengerSigner(signer)
|
|
62
|
-
const foreground = createForegroundWork()
|
|
63
|
+
const foreground = createForegroundWork({ setTimer: _setTimeout, clearTimer: _clearTimeout })
|
|
63
64
|
messageStorage ||= createEventStoreMessageStorage({ eventStore })
|
|
64
65
|
chunkStorage ||= eventStore ? createEventStoreChunkStorage({ eventStore }) : undefined
|
|
65
66
|
const peers = new Set()
|
|
@@ -165,14 +166,49 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
165
166
|
const journal = createPublicationJournal({
|
|
166
167
|
entries, store: () => storage, onError: reportError, debug, setTimer: _setTimeout, clearTimer: _clearTimeout, random: _random,
|
|
167
168
|
archive: async (entry, rows) => {
|
|
169
|
+
const waitedAt = Date.now()
|
|
170
|
+
let idle
|
|
171
|
+
try {
|
|
172
|
+
idle = await foreground.waitUntilIdle({ maxWaitMs: ARCHIVE_FOREGROUND_WAIT_MS })
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (closed) return
|
|
175
|
+
throw error
|
|
176
|
+
}
|
|
177
|
+
if (closed) return
|
|
178
|
+
const archiveStartedAt = Date.now()
|
|
179
|
+
debug(entry, 'recovery-archive-window', waitedAt, { foregroundIdle: idle, rowCount: rows.length })
|
|
180
|
+
let archived
|
|
181
|
+
let diagnosticReported = false
|
|
182
|
+
const onDiagnostic = details => {
|
|
183
|
+
diagnosticReported = true
|
|
184
|
+
debug(entry, 'recovery-archive-result', archiveStartedAt, {
|
|
185
|
+
publicationId: entry.prepared.publication.events[0].id,
|
|
186
|
+
...details
|
|
187
|
+
})
|
|
188
|
+
}
|
|
168
189
|
if (recoveryStorage?.seeds) {
|
|
169
|
-
if (typeof recoveryStorage.seeds.putMany === 'function') await recoveryStorage.seeds.putMany(rows)
|
|
170
|
-
else
|
|
190
|
+
if (typeof recoveryStorage.seeds.putMany === 'function') archived = await recoveryStorage.seeds.putMany(rows, { onDiagnostic })
|
|
191
|
+
else {
|
|
192
|
+
archived = []
|
|
193
|
+
for (const row of rows) archived.push(await recoveryStorage.seeds.put(row))
|
|
194
|
+
}
|
|
171
195
|
} else if (entry.prepared.target.file) {
|
|
172
196
|
for (const seed of entry.prepared.publication.seeds) await fileTransfers.archiveSeed(entry.prepared.target.file, seed)
|
|
173
197
|
} else {
|
|
174
198
|
for (const seed of entry.prepared.publication.seeds) await messenger.enqueueSeed(seed.channelPubkey, seed)
|
|
175
199
|
}
|
|
200
|
+
if (!diagnosticReported) {
|
|
201
|
+
const results = Array.isArray(archived) ? archived : []
|
|
202
|
+
const storedCount = results.filter(Boolean).length
|
|
203
|
+
debug(entry, 'recovery-archive-result', archiveStartedAt, {
|
|
204
|
+
publicationId: entry.prepared.publication.events[0].id,
|
|
205
|
+
rowCount: rows.length,
|
|
206
|
+
uniqueCount: new Set(rows.map(row => row.recordId).filter(Boolean)).size,
|
|
207
|
+
storedCount,
|
|
208
|
+
skippedCount: results.length ? results.length - storedCount : undefined,
|
|
209
|
+
...(rows.length === 1 && rows[0].recordId ? { seedRecordId: rows[0].recordId } : {})
|
|
210
|
+
})
|
|
211
|
+
}
|
|
176
212
|
},
|
|
177
213
|
changed: entry => {
|
|
178
214
|
watchOfflineSends()
|