libp2r2p 0.11.27 → 0.11.29

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
@@ -1257,3 +1257,16 @@ The 0.11.27 session publishes independently of local event-store persistence aft
1257
1257
  its initial durable intent and exact-envelope checkpoint. `deliveryConfirmed`
1258
1258
  and the copied `localPersistence` summary distinguish relay acceptance from
1259
1259
  local storage. See `private-messenger/session/README.md` for retry and cleanup.
1260
+
1261
+ The 0.11.28 private messenger preserves classified publication failures for
1262
+ seeder recovery asks. Diagnostics include the logical ask ID, channel, seeder
1263
+ and range, plus report and relay classifications; arbitrary relay text is not
1264
+ logged. Native report causes remain available privately to recovery retry
1265
+ policy. Required publication coverage, first-ACK semantics and range recovery
1266
+ behavior are unchanged.
1267
+
1268
+ The 0.11.29 event-store recovery adapter batches immutable seed existence checks
1269
+ and writes in groups of at most 100. Batched checks combine one recovery-type
1270
+ AND filter with coordinate OR filters, preserve input order and continue to
1271
+ decode and validate every returned snapshot. Custom adapters without batched
1272
+ methods retain the sequential fallback.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.27",
3
+ "version": "0.11.29",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -18,10 +18,16 @@ without decrypting the archive; the store may also purge expired wrappers itself
18
18
 
19
19
  ## Contracts and ownership
20
20
 
21
- - Seeds: `put(record)`, `iterate(query)`, `has(record)`, `removeLocal(query)`,
22
- `prune({ now })`, and `revoke(records)`.
21
+ - Seeds: `put(record)`, `putMany(records)`, `iterate(query)`, `has(record)`,
22
+ `hasMany(records)`, `removeLocal(query)`, `prune({ now })`, and
23
+ `revoke(records)`.
23
24
  - Authorizations: the same operations plus `find(query)`, choosing the newest
24
25
  valid share, then the shorter expiry and lexicographically smaller record ID.
26
+ - Batched methods preserve input order and process at most 100 coordinates per
27
+ query. Identical inputs share one immutable result; expired inputs return
28
+ `undefined` without a query or write. Batched checks use the recovery type
29
+ as an AND filter and the record coordinates as an OR filter, then still decode
30
+ and validate every returned snapshot.
25
31
  - `iterate` is an async generator. Queries can name `recordId`, `channelPubkey`,
26
32
  `receiverPubkey`, `since`/`until`, or the file tuple `controlChannelPubkey`,
27
33
  `fileChannelPubkey`, `receiverPubkey`, and `indices`. `signal` cancels reads.
@@ -6,6 +6,9 @@ import { encodeRecord, decodeRecord, selector, days, PREFIX } from './helpers/co
6
6
  export { encodeRecord as encodeRecoveryRecord, decodeRecord as decodeRecoveryRecord } from './helpers/codec.js'
7
7
 
8
8
  const nowSeconds = () => Math.floor(Date.now() / 1000)
9
+ const RECOVERY_BATCH_SIZE = 100
10
+ const chunks = values => Array.from({ length: Math.ceil(values.length / RECOVERY_BATCH_SIZE) }, (_, index) =>
11
+ values.slice(index * RECOVERY_BATCH_SIZE, (index + 1) * RECOVERY_BATCH_SIZE))
9
12
  function assertOptions (eventStore, signer) {
10
13
  if (!eventStore?.query || !eventStore?.addPersonalCopy || !signer?.getPublicKey || !signer?.obfuscate || !signer?.nip44v3?.decrypt) throw new ValidationError('INVALID_EVENT_STORE_OPTIONS')
11
14
  }
@@ -49,6 +52,11 @@ export function createPersonalCopyRecoveryStorage ({ eventStore, signer, now = n
49
52
  const [owner, context] = await ready()
50
53
  return { kinds: [1006], authors: [owner], '#k': ['30078'], '#c': [context], '#v': ['0', '1'], '&o': [await mirror('t', `${PREFIX}${type}`)] }
51
54
  }
55
+ async function exactFilterFor (type) {
56
+ check()
57
+ const [owner] = await ready()
58
+ return { kinds: [1006], authors: [owner], '&o': [await mirror('t', `${PREFIX}${type}`)] }
59
+ }
52
60
  async function read (wrapper) {
53
61
  check()
54
62
  // Query the store before every use; only decryption of an immutable wrapper
@@ -65,9 +73,13 @@ export function createPersonalCopyRecoveryStorage ({ eventStore, signer, now = n
65
73
  return { ...decoded, wrapper }
66
74
  }
67
75
  async function * records (type, query = {}) {
68
- const filter = await filterFor(type)
69
- if (query.recordId) filter['&o'].push(await mirror('d', query.recordId))
70
- else if (query.fileChannelPubkey && query.receiverPubkey) {
76
+ const exact = typeof query.recordId === 'string' || Array.isArray(query.recordIds)
77
+ const filter = exact ? await exactFilterFor(type) : await filterFor(type)
78
+ if (query.recordId) filter['#o'] = [await mirror('d', query.recordId)]
79
+ else if (Array.isArray(query.recordIds)) {
80
+ if (!query.recordIds.length) return
81
+ filter['#o'] = await Promise.all([...new Set(query.recordIds)].map(recordId => mirror('d', recordId)))
82
+ } else if (query.fileChannelPubkey && query.receiverPubkey) {
71
83
  const indices = type === 'seed' ? query.indices : [undefined]
72
84
  if (indices?.length) filter['#o'] = await Promise.all(indices.map(chunkIndex => mirror('s', selector(type, { ...query, chunkIndex }))))
73
85
  } else if (query.channelPubkey) {
@@ -92,24 +104,44 @@ export function createPersonalCopyRecoveryStorage ({ eventStore, signer, now = n
92
104
  }
93
105
  }
94
106
  function store (type) {
95
- return {
96
- async put (row) {
97
- check()
107
+ async function putMany (rows) {
108
+ check()
109
+ if (!Array.isArray(rows)) throw new ValidationError('INVALID_RECOVERY_RECORDS')
110
+ const encoded = rows.map(row => {
111
+ if (row.expiresAt <= now()) return null
98
112
  const event = encodeRecord(type, row)
99
- if (row.expiresAt <= now()) return
100
- const recordId = event.tags[0][1]
101
- const existing = records(type, { recordId, includeExpired: true })
102
- try { const first = await existing.next(); if (!first.done) return first.value.row } finally { await existing.return() }
103
- const saved = committed(await eventStore.addPersonalCopy(event, { context: '' }))
113
+ return { event, recordId: event.tags[0][1], row }
114
+ })
115
+ const unique = new Map(encoded.filter(Boolean).map(record => [record.recordId, record]))
116
+ const stored = new Map()
117
+ for (const recordIds of chunks([...unique.keys()])) {
118
+ for await (const { row } of records(type, { recordIds, includeExpired: true })) stored.set(row.recordId, row)
119
+ }
120
+ for (const [recordId, record] of unique) {
121
+ if (stored.has(recordId)) continue
122
+ const saved = committed(await eventStore.addPersonalCopy(record.event, { context: '' }))
104
123
  // A pending kind-5 tombstone can reject the insertion; do not cache it.
105
- if (saved.result.stored === false && saved.result.code === 'blocked') return
106
- return { ...row, recordId }
107
- },
124
+ if (saved.result.stored === false && saved.result.code === 'blocked') stored.set(recordId, undefined)
125
+ else stored.set(recordId, { ...record.row, recordId })
126
+ }
127
+ return encoded.map(record => record ? stored.get(record.recordId) : undefined)
128
+ }
129
+ async function hasMany (rows) {
130
+ check()
131
+ if (!Array.isArray(rows)) throw new ValidationError('INVALID_RECOVERY_RECORDS')
132
+ const recordIds = rows.map(row => row.recordId || encodeRecord(type, row).tags[0][1])
133
+ const present = new Set()
134
+ for (const batch of chunks([...new Set(recordIds)])) {
135
+ for await (const { row } of records(type, { recordIds: batch, includeExpired: true })) present.add(row.recordId)
136
+ }
137
+ return recordIds.map(recordId => present.has(recordId))
138
+ }
139
+ return {
140
+ async put (row) { return (await putMany([row]))[0] },
141
+ putMany,
108
142
  async * iterate (query = {}) { for await (const { row } of records(type, query)) yield row },
109
- async has (row) {
110
- const iterator = records(type, { recordId: row.recordId || encodeRecord(type, row).tags[0][1] })
111
- try { return !(await iterator.next()).done } finally { await iterator.return() }
112
- },
143
+ async has (row) { return (await hasMany([row]))[0] },
144
+ hasMany,
113
145
  async removeLocal (query = {}) {
114
146
  for await (const { wrapper } of records(type, { ...query, includeExpired: true })) await eventStore.remove([['e', wrapper.id]])
115
147
  },
@@ -1,5 +1,96 @@
1
1
  import { getEventHash } from '../../event/index.js'
2
2
 
3
+ const safeTokenPattern = /^[A-Za-z0-9][A-Za-z0-9:_-]{0,159}$/
4
+ const safeToken = value => typeof value === 'string' && safeTokenPattern.test(value) ? value : undefined
5
+ const safeRelay = value => {
6
+ if (typeof value !== 'string') return undefined
7
+ try {
8
+ const url = new URL(value)
9
+ if (url.protocol !== 'ws:' && url.protocol !== 'wss:') return undefined
10
+ return `${url.protocol}//${url.host}`
11
+ } catch {
12
+ return undefined
13
+ }
14
+ }
15
+
16
+ function reasonDiagnostic (reason) {
17
+ if (typeof reason === 'string') {
18
+ const prefix = /^([a-z][a-z0-9-]{0,31}):/i.exec(reason)?.[1]
19
+ return { code: safeToken(reason) ?? safeToken(prefix ? `${prefix}:` : undefined) ?? 'UNKNOWN' }
20
+ }
21
+ const prefix = typeof reason?.message === 'string' && /^([a-z][a-z0-9-]{0,31}):/i.exec(reason.message)?.[1]
22
+ const category = safeToken(reason?.category)
23
+ return {
24
+ code: safeToken(reason?.code) ?? safeToken(prefix ? `${prefix}:` : undefined) ?? category ?? 'UNKNOWN',
25
+ ...(category ? { category } : {}),
26
+ ...(safeToken(reason?.name) ? { name: safeToken(reason.name) } : {}),
27
+ ...(Number.isSafeInteger(reason?.retryAt) ? { retryAt: reason.retryAt } : {}),
28
+ ...(Number.isSafeInteger(reason?.retryAfterMs) ? { retryAfterMs: reason.retryAfterMs } : {})
29
+ }
30
+ }
31
+
32
+ function sanitizedReport (report, index) {
33
+ return {
34
+ index,
35
+ ...(Number.isSafeInteger(report?.total) ? { total: report.total } : {}),
36
+ ...(Number.isSafeInteger(report?.fulfilled) ? { fulfilled: report.fulfilled } : {}),
37
+ ...(report?.retryWhenAvailable === true ? { retryWhenAvailable: true } : {}),
38
+ ...(report?.retryWhenOnline === true ? { retryWhenOnline: true } : {}),
39
+ errors: (Array.isArray(report?.errors) ? report.errors : []).map(entry => {
40
+ const relay = safeRelay(entry?.relay)
41
+ const detail = reasonDiagnostic(entry?.reason ?? entry)
42
+ return {
43
+ ...(relay ? { relay } : {}),
44
+ ...(safeToken(entry?.code) ? { ...detail, code: entry.code } : detail)
45
+ }
46
+ })
47
+ }
48
+ }
49
+
50
+ const contextFields = ['operation', 'channelPubkey', 'seederPubkey', 'receiverPubkey', 'eventId']
51
+ const contextNumbers = ['eventKind', 'since', 'until']
52
+
53
+ export function sanitizePublicationError (error, context = {}) {
54
+ const source = error instanceof Error ? error : new Error('PRIVATE_MESSAGE_NOT_PUBLISHED')
55
+ const reports = Array.isArray(source.reports) ? source.reports : []
56
+ const retryReasons = reports.flatMap(report => (Array.isArray(report?.errors) ? report.errors : [])
57
+ .map(entry => entry?.reason).filter(Boolean))
58
+ if (source.code !== 'MESSAGE_NOT_PUBLISHED' && !reports.length) {
59
+ for (const key of contextFields) if (safeToken(context[key])) source[key] = context[key]
60
+ for (const key of contextNumbers) if (Number.isSafeInteger(context[key])) source[key] = context[key]
61
+ return source
62
+ }
63
+
64
+ const sanitized = new Error('PRIVATE_MESSAGE_NOT_PUBLISHED')
65
+ if (source.stack) sanitized.stack = source.stack
66
+ Object.assign(sanitized, {
67
+ code: safeToken(source.code) ?? 'MESSAGE_NOT_PUBLISHED',
68
+ ...(safeToken(source.reason) ? { reason: source.reason } : {}),
69
+ ...(safeToken(source.eventId) ? { eventId: source.eventId } : {}),
70
+ ...(Number.isSafeInteger(source.eventKind) ? { eventKind: source.eventKind } : {}),
71
+ ...(source.retryWhenAvailable === true ? { retryWhenAvailable: true } : {}),
72
+ ...(source.retryWhenOnline === true ? { retryWhenOnline: true } : {}),
73
+ reports: reports.map(sanitizedReport)
74
+ })
75
+ for (const key of contextFields) if (safeToken(context[key])) sanitized[key] = context[key]
76
+ for (const key of contextNumbers) if (Number.isSafeInteger(context[key])) sanitized[key] = context[key]
77
+ Object.defineProperty(sanitized, 'retryReasons', { value: retryReasons })
78
+ return sanitized
79
+ }
80
+
81
+ export function publicationFailureDiagnostic (error) {
82
+ const result = {}
83
+ for (const key of [...contextFields, ...contextNumbers]) {
84
+ const value = error?.[key]
85
+ if (safeToken(value) && contextFields.includes(key)) result[key] = value
86
+ else if (Number.isSafeInteger(value) && contextNumbers.includes(key)) result[key] = value
87
+ }
88
+ for (const key of ['code', 'reason', 'eventId']) if (safeToken(error?.[key])) result[key] = error[key]
89
+ for (const key of ['retryWhenAvailable', 'retryWhenOnline']) if (error?.[key] === true) result[key] = true
90
+ result.reports = (Array.isArray(error?.reports) ? error.reports : []).map(sanitizedReport)
91
+ return result
92
+ }
93
+
3
94
  // A report describes one outer event, not one relay. Each outer event needs
4
95
  // accepted publication covering its routed recipients. Keep the successful fast
5
96
  // path immediate; a partially covered multi-recipient report remains a failure.
@@ -19,6 +19,11 @@ export function recoveryRetryDelay (errors, delay) {
19
19
  if (['timeout', 'cutoff', 'closed'].includes(entry.status)) retry = true
20
20
  visit(entry.error)
21
21
  }
22
+ for (const report of error.reports || []) {
23
+ if (report.retryWhenAvailable || report.retryWhenOnline) retry = true
24
+ for (const entry of report.errors || []) visit(entry.reason ?? entry.error)
25
+ }
26
+ for (const reason of error.retryReasons || []) visit(reason)
22
27
  visit(error.cause)
23
28
  }
24
29
  errors.forEach(visit)
@@ -36,6 +41,10 @@ export function isPermanentRecoveryError (error) {
36
41
  /^(auth-required:|restricted:|blocked:|invalid:|pow:)/.test(value.message || '')) return true
37
42
  for (const child of value.errors || []) if (visit(child)) return true
38
43
  for (const entry of value.relays || []) if (visit(entry.error)) return true
44
+ for (const report of value.reports || []) {
45
+ for (const entry of report.errors || []) if (visit(entry.reason ?? entry.error)) return true
46
+ }
47
+ for (const reason of value.retryReasons || []) if (visit(reason)) return true
39
48
  return visit(value.cause)
40
49
  }
41
50
  return visit(error)
@@ -52,6 +52,7 @@ import { backgroundSigner } from './helpers/background-signer.js'
52
52
  import { createSendRelayRouting, normalizeFallbackRelays, normalizeFallbackDelay } from './helpers/send-routing.js'
53
53
  import { incompleteFetchError } from '../private-channel/helpers/fetch-error.js'
54
54
  import { recoveryRetryDelay, isPermanentRecoveryError } from './helpers/recovery-retry.js'
55
+ import { assertMessagePublished, publicationFailureDiagnostic, sanitizePublicationError } from './helpers/publication.js'
55
56
  import { normalizeRelayUrl } from '../url/index.js'
56
57
  import { deliveryInfo } from '../private-channel/helpers/rumor.js'
57
58
  import { migrateMessageQueue } from './services/migrate-message-queue.js'
@@ -105,6 +106,7 @@ export {
105
106
 
106
107
  const DEFAULT_OFFLINE_RECOVERY_SECONDS = 7 * 24 * 60 * 60
107
108
  const MAX_OFFLINE_RECOVERY_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1000)
109
+ const RECOVERY_STORAGE_BATCH_SIZE = 100
108
110
  const STORAGE_POLICY_BROADCAST_CHANNEL = 'libp2r2p:private-messenger:storage-policy'
109
111
  const DEFAULT_OFFLINE_SKEW_SECONDS = 30
110
112
  const DEFAULT_RELOAD_GAP_DELAY_MS = 500
@@ -2147,9 +2149,13 @@ export class PrivateMessenger {
2147
2149
  const rows = seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length
2148
2150
  ? [{ recordType: NYM_CARRIER_SEED_RECORD_TYPE, carriers: compactSeedNymCarriers(seed.carriers) }]
2149
2151
  : compactSeedRouterRows(seed)
2150
- for (const row of rows) {
2152
+ const records = rows.map(row => {
2151
2153
  const time = seedRecordTime(row) || receivedAt
2152
- await this.seedStorage.put({ ...row, channelPubkey, receivedAt, expiresAt: time + this.offlineRecoverySecondsFor(channelPubkey) })
2154
+ return { ...row, channelPubkey, receivedAt, expiresAt: time + this.offlineRecoverySecondsFor(channelPubkey) }
2155
+ })
2156
+ if (typeof this.seedStorage.putMany === 'function') await this.seedStorage.putMany(records)
2157
+ else {
2158
+ for (const row of records) await this.seedStorage.put(row)
2153
2159
  }
2154
2160
  return
2155
2161
  }
@@ -2755,13 +2761,17 @@ export class PrivateMessenger {
2755
2761
  payload: { since, until }
2756
2762
  })
2757
2763
  asks.push(ask)
2758
- const reports = ask?.delivery?.reports
2759
- if (!Array.isArray(reports) || !reports.length || reports.some(report => report?.success !== true)) {
2760
- throw new Error('PRIVATE_MESSAGE_NOT_PUBLISHED')
2761
- }
2764
+ await assertMessagePublished(ask, ask.question)
2762
2765
  } catch (err) {
2763
- failures.push({ seeder, error: err })
2764
- console.warn('private-messenger seeder recovery ask failed', seeder, err?.message ?? err)
2766
+ const error = sanitizePublicationError(err, {
2767
+ operation: 'seeder-recovery-ask',
2768
+ channelPubkey,
2769
+ seederPubkey: seeder,
2770
+ since,
2771
+ until
2772
+ })
2773
+ failures.push({ seeder, error })
2774
+ console.warn('private-messenger seeder recovery ask failed', JSON.stringify(publicationFailureDiagnostic(error)))
2765
2775
  }
2766
2776
  }
2767
2777
  return { asks, failures }
@@ -2794,6 +2804,16 @@ export class PrivateMessenger {
2794
2804
  if (this.offlineRecoverySecondsFor(channelPubkey)) {
2795
2805
  const seen = new Set()
2796
2806
  const key = seed => seed.router ? routerSeedRowKey(seed) : nymCarrierSeedKey(seed)
2807
+ const sendStored = async seeds => {
2808
+ let present
2809
+ if (typeof this.seedStorage?.hasMany === 'function') present = await this.seedStorage.hasMany(seeds)
2810
+ else {
2811
+ present = []
2812
+ for (const seed of seeds) present.push(await this.seedStorage.has(seed))
2813
+ }
2814
+ if (!Array.isArray(present) || present.length !== seeds.length) throw new ValidationError('INVALID_RECOVERY_STORAGE_RESULT')
2815
+ for (let index = 0; index < seeds.length; index++) if (present[index]) await packer.update(seeds[index])
2816
+ }
2797
2817
  const pending = this._pendingSeedRecords({ channelPubkey, receiverPubkey: message.event?.pubkey, since, until })
2798
2818
  for (const seed of pending) {
2799
2819
  if (seed.channelPubkey !== channelPubkey || (seed.receiverPubkey && seed.receiverPubkey !== message.event?.pubkey) || seed.expiresAt <= nowSeconds()) continue
@@ -2801,11 +2821,17 @@ export class PrivateMessenger {
2801
2821
  if (until !== undefined && (seed.firstSeenAt || seed.router.created_at) > until) continue
2802
2822
  seen.add(key(seed)); await packer.update(seed)
2803
2823
  }
2824
+ const stored = []
2804
2825
  for await (const seed of this.seedStorage ? this.seedStorage.iterate({ channelPubkey, receiverPubkey: message.event?.pubkey, since, until }) : this.seedQueue.storedItemsBy('byChannel', channelPubkey)) {
2805
2826
  if (seen.has(key(seed)) || seedRecordTime(seed) < nowSeconds() - this.offlineRecoverySecondsFor(channelPubkey)) continue
2806
- if (this.seedStorage && !await this.seedStorage.has(seed)) continue
2807
- await packer.update(seed)
2827
+ if (!this.seedStorage) {
2828
+ await packer.update(seed)
2829
+ continue
2830
+ }
2831
+ stored.push(seed)
2832
+ if (stored.length === RECOVERY_STORAGE_BATCH_SIZE) await sendStored(stored.splice(0))
2808
2833
  }
2834
+ if (stored.length) await sendStored(stored)
2809
2835
  }
2810
2836
  await packer.finalize()
2811
2837
  }