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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.32",
3
+ "version": "0.11.34",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -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, then still decode and
31
- validate every returned snapshot.
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({ ...filter, ...(until === undefined ? {} : { until }), ...(excluded.length ? { '!ids': excluded } : {}), limit: 128, search: 'sort:desc' })
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 { row } of records(type, { recordIds, includeExpired: true })) stored.set(row.recordId, row)
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') stored.set(recordId, undefined)
125
- else stored.set(recordId, { ...record.row, recordId })
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 { row } of records(type, { recordIds: batch })) present.add(row.recordId)
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
- check()
158
- const [owner] = await ready()
159
- for (let i = 0; i < rows.length; i += 100) {
160
- const group = rows.slice(i, i + 100).filter(row => row.expiresAt > now())
161
- if (!group.length) continue
162
- const tags = []; let createdAt = now(), expiresAt = 0
163
- for (const row of group) {
164
- const recordId = row.recordId || encodeRecord(type, row).tags[0][1]
165
- for await (const { wrapper } of records(type, { recordId })) createdAt = Math.max(createdAt, wrapper.created_at)
166
- tags.push(['a', `30078:${owner}:${recordId}`]); expiresAt = Math.max(expiresAt, row.expiresAt)
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
- if (expiresAt <= createdAt) continue
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 for (const row of rows) await recoveryStorage.seeds.put(row)
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()