libp2r2p 0.11.28 → 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
@@ -1264,3 +1264,9 @@ and range, plus report and relay classifications; arbitrary relay text is not
1264
1264
  logged. Native report causes remain available privately to recovery retry
1265
1265
  policy. Required publication coverage, first-ACK semantics and range recovery
1266
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.28",
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
  },
@@ -106,6 +106,7 @@ export {
106
106
 
107
107
  const DEFAULT_OFFLINE_RECOVERY_SECONDS = 7 * 24 * 60 * 60
108
108
  const MAX_OFFLINE_RECOVERY_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1000)
109
+ const RECOVERY_STORAGE_BATCH_SIZE = 100
109
110
  const STORAGE_POLICY_BROADCAST_CHANNEL = 'libp2r2p:private-messenger:storage-policy'
110
111
  const DEFAULT_OFFLINE_SKEW_SECONDS = 30
111
112
  const DEFAULT_RELOAD_GAP_DELAY_MS = 500
@@ -2148,9 +2149,13 @@ export class PrivateMessenger {
2148
2149
  const rows = seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length
2149
2150
  ? [{ recordType: NYM_CARRIER_SEED_RECORD_TYPE, carriers: compactSeedNymCarriers(seed.carriers) }]
2150
2151
  : compactSeedRouterRows(seed)
2151
- for (const row of rows) {
2152
+ const records = rows.map(row => {
2152
2153
  const time = seedRecordTime(row) || receivedAt
2153
- 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)
2154
2159
  }
2155
2160
  return
2156
2161
  }
@@ -2799,6 +2804,16 @@ export class PrivateMessenger {
2799
2804
  if (this.offlineRecoverySecondsFor(channelPubkey)) {
2800
2805
  const seen = new Set()
2801
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
+ }
2802
2817
  const pending = this._pendingSeedRecords({ channelPubkey, receiverPubkey: message.event?.pubkey, since, until })
2803
2818
  for (const seed of pending) {
2804
2819
  if (seed.channelPubkey !== channelPubkey || (seed.receiverPubkey && seed.receiverPubkey !== message.event?.pubkey) || seed.expiresAt <= nowSeconds()) continue
@@ -2806,11 +2821,17 @@ export class PrivateMessenger {
2806
2821
  if (until !== undefined && (seed.firstSeenAt || seed.router.created_at) > until) continue
2807
2822
  seen.add(key(seed)); await packer.update(seed)
2808
2823
  }
2824
+ const stored = []
2809
2825
  for await (const seed of this.seedStorage ? this.seedStorage.iterate({ channelPubkey, receiverPubkey: message.event?.pubkey, since, until }) : this.seedQueue.storedItemsBy('byChannel', channelPubkey)) {
2810
2826
  if (seen.has(key(seed)) || seedRecordTime(seed) < nowSeconds() - this.offlineRecoverySecondsFor(channelPubkey)) continue
2811
- if (this.seedStorage && !await this.seedStorage.has(seed)) continue
2812
- 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))
2813
2833
  }
2834
+ if (stored.length) await sendStored(stored)
2814
2835
  }
2815
2836
  await packer.finalize()
2816
2837
  }