libp2r2p 0.11.31 → 0.11.33

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
@@ -1284,3 +1284,14 @@ The 0.11.31 pending-decryption retry groups records by
1284
1284
  remaining records only when the local signer confirms `CONTENT_KEY_NOT_FOUND`
1285
1285
  with `hasOwnContentKey: false`. MAC, proof, parsing and operational failures are
1286
1286
  never generalized to the group, and every deferred record remains persisted.
1287
+
1288
+ The 0.11.32 local recovery-seed queue serializes incoming and outgoing writes
1289
+ within one PrivateMessenger. Duplicate `bySeedKey` races retry up to five times:
1290
+ NYM rows remain idempotent and router rows re-read the winning timestamps before
1291
+ replacement. External event-store seed adapters retain their independent batched
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.31",
3
+ "version": "0.11.33",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -27,8 +27,13 @@ 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
+ - Mutating recovery stores share one operation tail per event store, so seed and
34
+ grant writes cannot overlap for the same store. Writes pass the internal
35
+ `recovery: true` scheduling hint to `addPersonalCopy`; row-returning reads keep
36
+ normal decrypt-and-validate behavior.
32
37
  - `iterate` is an async generator. Queries can name `recordId`, `channelPubkey`,
33
38
  `receiverPubkey`, `since`/`until`, or the file tuple `controlChannelPubkey`,
34
39
  `fileChannelPubkey`, `receiverPubkey`, and `indices`. `signal` cancels reads.
@@ -7,8 +7,19 @@ 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 mutationTails = new WeakMap()
10
11
  const chunks = values => Array.from({ length: Math.ceil(values.length / RECOVERY_BATCH_SIZE) }, (_, index) =>
11
12
  values.slice(index * RECOVERY_BATCH_SIZE, (index + 1) * RECOVERY_BATCH_SIZE))
13
+ function serializeMutation (eventStore, operation) {
14
+ const previous = mutationTails.get(eventStore) ?? Promise.resolve()
15
+ const run = previous.catch(() => {}).then(operation)
16
+ const tail = run.catch(() => {})
17
+ mutationTails.set(eventStore, tail)
18
+ tail.then(() => {
19
+ if (mutationTails.get(eventStore) === tail) mutationTails.delete(eventStore)
20
+ })
21
+ return run
22
+ }
12
23
  function assertOptions (eventStore, signer) {
13
24
  if (!eventStore?.query || !eventStore?.addPersonalCopy || !signer?.getPublicKey || !signer?.obfuscate || !signer?.nip44v3?.decrypt) throw new ValidationError('INVALID_EVENT_STORE_OPTIONS')
14
25
  }
@@ -103,8 +114,27 @@ export function createPersonalCopyRecoveryStorage ({ eventStore, signer, now = n
103
114
  yield { row, wrapper }
104
115
  }
105
116
  }
117
+ async function * existingRecordIds (type, values, { includeExpired = false } = {}) {
118
+ const ids = [...new Set(values)]
119
+ if (!ids.length) return
120
+ const filter = await exactFilterFor(type)
121
+ const mirrorsById = new Map(await Promise.all(ids.map(async recordId => [await mirror('d', recordId), recordId])))
122
+ filter['#o'] = [...mirrorsById.keys()]
123
+ for await (const wrapper of pages(eventStore, filter)) {
124
+ const expiry = Number(wrapper.tags?.find(tag => Array.isArray(tag) && tag[0] === 'expiration')?.[1])
125
+ if (!includeExpired && (!Number.isSafeInteger(expiry) || expiry <= now())) continue
126
+ for (const tag of wrapper.tags || []) {
127
+ if (!Array.isArray(tag) || tag[0] !== 'o') continue
128
+ const recordId = mirrorsById.get(tag[1])
129
+ if (recordId) yield recordId
130
+ }
131
+ }
132
+ }
106
133
  function store (type) {
107
134
  async function putMany (rows) {
135
+ return serializeMutation(eventStore, () => putManyNow(rows))
136
+ }
137
+ async function putManyNow (rows) {
108
138
  check()
109
139
  if (!Array.isArray(rows)) throw new ValidationError('INVALID_RECOVERY_RECORDS')
110
140
  const encoded = rows.map(row => {
@@ -115,11 +145,14 @@ export function createPersonalCopyRecoveryStorage ({ eventStore, signer, now = n
115
145
  const unique = new Map(encoded.filter(Boolean).map(record => [record.recordId, record]))
116
146
  const stored = new Map()
117
147
  for (const recordIds of chunks([...unique.keys()])) {
118
- for await (const { row } of records(type, { recordIds, includeExpired: true })) stored.set(row.recordId, row)
148
+ for await (const recordId of existingRecordIds(type, recordIds, { includeExpired: true })) {
149
+ const record = unique.get(recordId)
150
+ if (record) stored.set(recordId, { ...record.row, recordId })
151
+ }
119
152
  }
120
153
  for (const [recordId, record] of unique) {
121
154
  if (stored.has(recordId)) continue
122
- const saved = committed(await eventStore.addPersonalCopy(record.event, { context: '' }))
155
+ const saved = committed(await eventStore.addPersonalCopy(record.event, { context: '', recovery: true }))
123
156
  // A pending kind-5 tombstone can reject the insertion; do not cache it.
124
157
  if (saved.result.stored === false && saved.result.code === 'blocked') stored.set(recordId, undefined)
125
158
  else stored.set(recordId, { ...record.row, recordId })
@@ -132,7 +165,7 @@ export function createPersonalCopyRecoveryStorage ({ eventStore, signer, now = n
132
165
  const recordIds = rows.map(row => row.recordId || encodeRecord(type, row).tags[0][1])
133
166
  const present = new Set()
134
167
  for (const batch of chunks([...new Set(recordIds)])) {
135
- for await (const { row } of records(type, { recordIds: batch })) present.add(row.recordId)
168
+ for await (const recordId of existingRecordIds(type, batch)) present.add(recordId)
136
169
  }
137
170
  return recordIds.map(recordId => present.has(recordId))
138
171
  }
@@ -154,20 +187,22 @@ export function createPersonalCopyRecoveryStorage ({ eventStore, signer, now = n
154
187
  }
155
188
  },
156
189
  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)
190
+ return serializeMutation(eventStore, async () => {
191
+ check()
192
+ const [owner] = await ready()
193
+ for (let i = 0; i < rows.length; i += 100) {
194
+ const group = rows.slice(i, i + 100).filter(row => row.expiresAt > now())
195
+ if (!group.length) continue
196
+ const tags = []; let createdAt = now(), expiresAt = 0
197
+ for (const row of group) {
198
+ const recordId = row.recordId || encodeRecord(type, row).tags[0][1]
199
+ for await (const { wrapper } of records(type, { recordId })) createdAt = Math.max(createdAt, wrapper.created_at)
200
+ tags.push(['a', `30078:${owner}:${recordId}`]); expiresAt = Math.max(expiresAt, row.expiresAt)
201
+ }
202
+ if (expiresAt <= createdAt) continue
203
+ committed(await eventStore.addPersonalCopy({ kind: 5, created_at: createdAt, tags: [...tags, ['k', '30078'], ['expiration', String(expiresAt)]], content: '' }, { context: '', recovery: true }))
167
204
  }
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
- }
205
+ })
171
206
  }
172
207
  }
173
208
  }
@@ -108,6 +108,7 @@ export {
108
108
  const DEFAULT_OFFLINE_RECOVERY_SECONDS = 7 * 24 * 60 * 60
109
109
  const MAX_OFFLINE_RECOVERY_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1000)
110
110
  const RECOVERY_STORAGE_BATCH_SIZE = 100
111
+ const SEED_QUEUE_UPSERT_ATTEMPTS = 5
111
112
  const STORAGE_POLICY_BROADCAST_CHANNEL = 'libp2r2p:private-messenger:storage-policy'
112
113
  const DEFAULT_OFFLINE_SKEW_SECONDS = 30
113
114
  const DEFAULT_RELOAD_GAP_DELAY_MS = 500
@@ -374,6 +375,7 @@ export class PrivateMessenger {
374
375
  this.watchRevisionByChannel = new Map()
375
376
  this.presenceTimers = new Map()
376
377
  this.presenceJobs = new Map()
378
+ this.seedQueueOperationTail = Promise.resolve()
377
379
  this.stopRelayListWatcher = null
378
380
  this.relayListWatcherPubkey = ''
379
381
  this.relayListRefreshPromise = null
@@ -686,6 +688,13 @@ export class PrivateMessenger {
686
688
  return this.runQueueOperation(operation)
687
689
  }
688
690
 
691
+ runSeedQueueOperation (operation) {
692
+ if (this.closePromise) return Promise.reject(new Error('PRIVATE_MESSENGER_CLOSED'))
693
+ const run = this.seedQueueOperationTail.then(operation)
694
+ this.seedQueueOperationTail = run.catch(() => {})
695
+ return run
696
+ }
697
+
689
698
  debug (action, detail = {}) {
690
699
  try {
691
700
  this.onDebug?.({ source: 'private-messenger', action, ...detail })
@@ -2140,8 +2149,8 @@ export class PrivateMessenger {
2140
2149
 
2141
2150
  async enqueueSeed (channelPubkey, seed) {
2142
2151
  if (!this.offlineRecoverySecondsFor(channelPubkey)) return
2143
- const receivedAt = nowSeconds()
2144
2152
  if (this.seedStorage) {
2153
+ const receivedAt = nowSeconds()
2145
2154
  const rows = seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length
2146
2155
  ? [{ recordType: NYM_CARRIER_SEED_RECORD_TYPE, carriers: compactSeedNymCarriers(seed.carriers) }]
2147
2156
  : compactSeedRouterRows(seed)
@@ -2155,22 +2164,37 @@ export class PrivateMessenger {
2155
2164
  }
2156
2165
  return
2157
2166
  }
2167
+ return this.runSeedQueueOperation(() => this.enqueueQueueSeed(channelPubkey, seed))
2168
+ }
2169
+
2170
+ async enqueueQueueSeed (channelPubkey, seed) {
2171
+ this.assertOpen()
2172
+ if (!this.channels.has(channelPubkey) || !this.offlineRecoverySecondsFor(channelPubkey)) return
2173
+ const receivedAt = nowSeconds()
2158
2174
  if (seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length) {
2159
2175
  const carriers = compactSeedNymCarriers(seed.carriers)
2160
2176
  const recordTime = nymCarrierRecordTime({ carriers }) || seed.outer?.created_at || receivedAt
2161
2177
  const key = nymCarrierSeedKey({ channelPubkey, carriers })
2162
2178
  const seedKey = key ? `nym:${key}` : ''
2163
- if (seedKey && await this.seedQueue.someBy('bySeedKey', seedKey)) return
2164
- await this.seedQueue.enqueue({
2165
- type: 'seed',
2166
- recordType: NYM_CARRIER_SEED_RECORD_TYPE,
2167
- channelPubkey,
2168
- receivedAt,
2169
- carriers,
2170
- meta: { channelPubkey: seed.channelPubkey },
2171
- [SEED_KEY]: seedKey || undefined,
2172
- [SEED_TIME]: recordTime
2173
- })
2179
+ for (let attempt = 0; attempt < SEED_QUEUE_UPSERT_ATTEMPTS; attempt++) {
2180
+ if (seedKey && await this.seedQueue.someBy('bySeedKey', seedKey)) break
2181
+ try {
2182
+ await this.seedQueue.enqueue({
2183
+ type: 'seed',
2184
+ recordType: NYM_CARRIER_SEED_RECORD_TYPE,
2185
+ channelPubkey,
2186
+ receivedAt,
2187
+ carriers,
2188
+ meta: { channelPubkey: seed.channelPubkey },
2189
+ [SEED_KEY]: seedKey || undefined,
2190
+ [SEED_TIME]: recordTime
2191
+ })
2192
+ break
2193
+ } catch (error) {
2194
+ if (error?.name !== 'ConstraintError') throw error
2195
+ if (attempt === SEED_QUEUE_UPSERT_ATTEMPTS - 1) throw error
2196
+ }
2197
+ }
2174
2198
  await this.pruneStoredSeeds(channelPubkey)
2175
2199
  return
2176
2200
  }
@@ -2180,21 +2204,29 @@ export class PrivateMessenger {
2180
2204
  const rowTime = row.lastSeenAt || row.router?.created_at || receivedAt
2181
2205
  const rowKey = routerSeedRowKey({ ...row, channelPubkey })
2182
2206
  const seedKey = `router:${rowKey}`
2183
- const [previous] = await this.seedQueue.removeBy('bySeedKey', seedKey)
2184
- const firstSeenAt = Math.min(previous?.firstSeenAt ?? rowTime, row.firstSeenAt ?? rowTime)
2185
- const lastSeenAt = Math.max(previous?.lastSeenAt ?? rowTime, row.lastSeenAt ?? rowTime)
2186
- await this.seedQueue.enqueue({
2187
- ...row,
2188
- type: 'seed',
2189
- recordType: ROUTER_SEED_RECORD_TYPE,
2190
- channelPubkey,
2191
- receivedAt,
2192
- firstSeenAt,
2193
- lastSeenAt,
2194
- meta: { channelPubkey: seed.channelPubkey },
2195
- [SEED_KEY]: seedKey,
2196
- [SEED_TIME]: lastSeenAt || rowTime
2197
- })
2207
+ for (let attempt = 0; attempt < SEED_QUEUE_UPSERT_ATTEMPTS; attempt++) {
2208
+ const [previous] = await this.seedQueue.removeBy('bySeedKey', seedKey)
2209
+ const firstSeenAt = Math.min(previous?.firstSeenAt ?? rowTime, row.firstSeenAt ?? rowTime)
2210
+ const lastSeenAt = Math.max(previous?.lastSeenAt ?? rowTime, row.lastSeenAt ?? rowTime)
2211
+ try {
2212
+ await this.seedQueue.enqueue({
2213
+ ...row,
2214
+ type: 'seed',
2215
+ recordType: ROUTER_SEED_RECORD_TYPE,
2216
+ channelPubkey,
2217
+ receivedAt,
2218
+ firstSeenAt,
2219
+ lastSeenAt,
2220
+ meta: { channelPubkey: seed.channelPubkey },
2221
+ [SEED_KEY]: seedKey,
2222
+ [SEED_TIME]: lastSeenAt || rowTime
2223
+ })
2224
+ break
2225
+ } catch (error) {
2226
+ if (error?.name !== 'ConstraintError') throw error
2227
+ if (attempt === SEED_QUEUE_UPSERT_ATTEMPTS - 1) throw error
2228
+ }
2229
+ }
2198
2230
  }
2199
2231
  await this.pruneStoredSeeds(channelPubkey)
2200
2232
  }
@@ -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
@@ -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,6 +166,16 @@ 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
+ debug(entry, 'recovery-archive-window', waitedAt, { foregroundIdle: idle, rowCount: rows.length })
168
179
  if (recoveryStorage?.seeds) {
169
180
  if (typeof recoveryStorage.seeds.putMany === 'function') await recoveryStorage.seeds.putMany(rows)
170
181
  else for (const row of rows) await recoveryStorage.seeds.put(row)