Files
modules/indexes-search/hyper-p2p-inverted-index/index.js
T
Raven ScottandCursor bed98ff300 feat(modules): manual deepen pear, trust, crdt, storage, gossip, network
Trust gate and detached registry helpers, blind handoff cancel, LWW
snapshot/compare, fork forceChoose, dedup restore, DHT prune, indexer
bus subscribers, inverted hasDoc, similarity listIds.

Co-authored-by: Cursor <[email protected]>
2026-05-21 02:31:04 -04:00

182 lines
4.9 KiB
JavaScript

require('bare-process/global')
const EventEmitter = require('bare-events')
const b4a = require('b4a')
const { assertNonEmpty } = require('../../_shared/lib/errors.js')
const { initModuleSwarm, gossipSend } = require('../../_shared/p2p-bare.js')
const PROTOCOL = 'inverted-index/v1'
function normalizeTerm (term) {
return String(term).toLowerCase().trim()
}
class HyperP2PInvertedIndex extends EventEmitter {
constructor (opts = {}) {
super()
this.topic = opts.topic || null
this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair()
this.peerHex = b4a.toString(this.keyPair.publicKey, 'hex')
this._terms = new Map()
this._docs = new Map()
this._stats = { indexed: 0, removed: 0, queries: 0, gossipIn: 0, gossipOut: 0 }
this.swarm = null
this._peerMsgs = null
}
index (docId, terms) {
assertNonEmpty(docId, 'docId')
const normalized = (terms || []).map(normalizeTerm).filter(Boolean)
const prev = this._docs.get(docId) || new Set()
for (const term of normalized) {
const set = this._terms.get(term) || new Set()
set.add(docId)
this._terms.set(term, set)
if (!prev.has(term)) {
this._gossip({ type: 'term-index-sync', op: 'add', term, docId, peer: this.peerHex, at: Date.now() })
}
}
this._docs.set(docId, new Set(normalized))
this._stats.indexed++
this.emit('index', { docId, terms: normalized })
return { docId, terms: normalized }
}
search (term) {
this._stats.queries++
const set = this._terms.get(normalizeTerm(term))
return set ? [...set].sort() : []
}
searchAll (terms, mode = 'and') {
if (!Array.isArray(terms) || !terms.length) return []
const normalized = terms.map(normalizeTerm).filter(Boolean)
if (!normalized.length) return []
this._stats.queries++
if (mode === 'or') {
const out = new Set()
for (const term of normalized) {
for (const docId of this.search(term)) out.add(docId)
}
return [...out].sort()
}
let hits = null
for (const term of normalized) {
const set = this._terms.get(term)
const ids = set ? [...set] : []
if (hits === null) hits = new Set(ids)
else {
const next = new Set()
for (const id of ids) if (hits.has(id)) next.add(id)
hits = next
}
}
return hits ? [...hits].sort() : []
}
getDocTerms (docId) {
const terms = this._docs.get(docId)
return terms ? [...terms].sort() : []
}
removeDoc (docId) {
assertNonEmpty(docId, 'docId')
const terms = this._docs.get(docId)
if (!terms) return false
for (const term of terms) {
const set = this._terms.get(term)
if (set) {
set.delete(docId)
if (!set.size) this._terms.delete(term)
}
this._gossip({ type: 'term-index-sync', op: 'remove', term, docId, peer: this.peerHex, at: Date.now() })
}
this._docs.delete(docId)
this._stats.removed++
this.emit('remove', { docId })
return true
}
listTerms () {
return [...this._terms.keys()].sort()
}
docCount () {
return this._docs.size
}
hasDoc (docId) {
return this._docs.has(String(docId))
}
topTerms (limit = 10) {
const ranked = [...this._terms.entries()]
.map(([term, set]) => ({ term, docs: set.size }))
.sort((a, b) => b.docs - a.docs)
return ranked.slice(0, Math.max(0, limit | 0))
}
_gossip (data) {
if (!this._peerMsgs) return
gossipSend(this, data)
this._stats.gossipOut++
}
_onGossip (data) {
if (!data || data.type !== 'term-index-sync') return
this._stats.gossipIn++
const term = normalizeTerm(data.term)
const docId = data.docId
if (!term || !docId) return
if (data.op === 'remove') {
const set = this._terms.get(term)
if (set) {
set.delete(docId)
if (!set.size) this._terms.delete(term)
}
const docTerms = this._docs.get(docId)
if (docTerms) docTerms.delete(term)
this.emit('remote-remove', { term, docId, peer: data.peer })
return
}
const set = this._terms.get(term) || new Set()
set.add(docId)
this._terms.set(term, set)
const docTerms = this._docs.get(docId) || new Set()
docTerms.add(term)
this._docs.set(docId, docTerms)
this.emit('remote-index', { term, docId, peer: data.peer })
}
getStats () {
return {
...this._stats,
terms: this._terms.size,
docs: this._docs.size,
protocol: PROTOCOL
}
}
async ready () {
if (this.swarm || !this.topic) return this
await initModuleSwarm(this, {
keyPair: this.keyPair,
topic: this.topic,
protocol: PROTOCOL,
onmessage: (data) => this._onGossip(data)
})
return this
}
async close () {
this._terms.clear()
this._docs.clear()
if (this.swarm) await this.swarm.destroy().catch(() => {})
this.swarm = null
this.emit('closed')
}
}
module.exports = { HyperP2PInvertedIndex, PROTOCOL }