// The view shim — hyperbee2 wearing hyperbee's face.
//
// hyper-identity-core's registry and apply were written against a Hyperbee configured
// `keyEncoding: 'utf-8', valueEncoding: 'json'`. hyperbee2 has NEITHER, and no `put`/`del` at all:
// keys and values are raw Buffers and every mutation goes through a `write()` batch
// (`tryPut`/`tryDelete` → `await flush()`). Measured against hyperbee2 2.13.2.
//
// So this file is the entire cost of the port. 65 call sites in the core depend on the Hyperbee
// shape — 31 puts and 13 deletes in apply, 14 gets and 7 range scans in the registry — and every
// one of them stays unchanged because this presents that shape.
//
// ── ⛔ THE PART THAT IS NOT A CODEC ────────────────────────────────────────────────────────────
//
// apply calls `createReadStream` ON THE BATCH, at three sites. One of them is `hasAnyUser`, which
// decides whether a new user is promoted to admin:
//
// const isFirstUser = !(await hasAnyUser(b, prefix))
//
// Against a Hyperbee that is trivially correct — a `put` is visible to a read stream immediately.
// Against a batch it is not: writes sit unflushed. If two CREATE_USER ops land in ONE apply batch
// and the scan cannot see the first, the second is ALSO promoted, and the log now has two admins.
// No error, no warning; a permissions bug produced by a storage detail.
//
// That is why `BatchView` merges its pending writes into every scan rather than keeping a plain
// map of them. A pending map is enough for read-your-own-writes by KEY (favela-peer's feed-base.js
// gets away with exactly that) and is NOT enough the moment apply iterates a RANGE.
import b4a from 'b4a'
const encKey = (k) => (typeof k === 'string' ? b4a.from(k, 'utf8') : k)
const decKey = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'utf8'))
const encVal = (v) => b4a.from(JSON.stringify(v), 'utf8')
const decVal = (v) => {
if (v == null) return null
// A value that will not parse is data we did not write. Returning null loses it; throwing inside
// apply wedges the log (apply must be TOTAL). Neither is good, so it surfaces as null and the
// caller treats it as absent — the same thing a missing key does.
try { return JSON.parse(b4a.toString(v, 'utf8')) } catch { return null }
}
// Range bounds are encoded with the SAME function as keys, so `user:\xff` bounds `user:…` exactly
// as it did under utf-8 keyEncoding. Comparison is by BYTES, never by JS string order — those
// diverge above the BMP, and a username is user-supplied text.
function inRange (key, range) {
if (range.gt !== undefined && b4a.compare(key, encKey(range.gt)) <= 0) return false
if (range.gte !== undefined && b4a.compare(key, encKey(range.gte)) < 0) return false
if (range.lt !== undefined && b4a.compare(key, encKey(range.lt)) >= 0) return false
if (range.lte !== undefined && b4a.compare(key, encKey(range.lte)) > 0) return false
return true
}
const streamOpts = (range) => {
const o = {}
for (const b of ['gt', 'gte', 'lt', 'lte']) if (range[b] !== undefined) o[b] = encKey(range[b])
return o
}
/**
* The view the core sees. `get` + `createReadStream` for the registry's reads, and `batch()` for
* apply — which calls `view.batch({ update: false })`, then `flush()` or `close()` on what it gets.
*/
export class Hyperbee2View {
constructor (bee) { this.bee = bee }
// apply's write handle. The option is accepted and ignored: hyperbee2 has no auto-update on a
// write batch, so `{ update: false }` is already its only behaviour.
batch (opts = {}) { return new BatchView(this.bee) }
async get (key) {
const node = await this.bee.get(encKey(key))
if (!node) return null
return { key: decKey(node.key), value: decVal(node.value) }
}
async * createReadStream (range = {}) {
let n = 0
// `limit` is applied here rather than passed down, because it is not part of hyperbee2's
// documented range options and a silently-ignored limit reads as "scan found everything".
for await (const node of this.bee.createReadStream(streamOpts(range))) {
yield { key: decKey(node.key), value: decVal(node.value) }
if (range.limit !== undefined && ++n >= range.limit) return
}
}
}
/**
* A writable view backed by ONE hyperbee2 batch, with pending writes visible to reads.
*
* One batch per apply CALL, not per mutation. The autobee spike's shim flushed a batch per write
* and was never benchmarked; apply performs up to 44 mutations in a call, so that is 44 commits
* where a Hyperbee did none.
*/
export class BatchView {
constructor (bee) {
this.bee = bee
this.batch = bee.write()
// key (utf-8 string) -> value, or DELETED. Ordered by insertion; scans sort what they need.
this.pending = new Map()
}
async get (key) {
const k = decKey(key)
if (this.pending.has(k)) {
const v = this.pending.get(k)
return v === BatchView.DELETED ? null : { key: k, value: v }
}
const node = await this.bee.get(encKey(key))
if (!node) return null
return { key: decKey(node.key), value: decVal(node.value) }
}
async put (key, value) {
const k = decKey(key)
this.pending.set(k, value)
this.batch.tryPut(encKey(key), encVal(value))
}
async del (key) {
const k = decKey(key)
this.pending.set(k, BatchView.DELETED)
this.batch.tryDelete(encKey(key))
}
/**
* A range scan that sees this batch's own unflushed writes.
*
* ⛔ Read the header. Without the merge, `hasAnyUser` misses a user created earlier in the same
* batch and every user in that batch is promoted to admin.
*
* Merges two ordered sources by byte order: the committed stream, and the pending writes that
* fall in range. Pending wins on a tie — it is newer — and a pending deletion removes the key
* from the result rather than yielding a tombstone.
*/
async * createReadStream (range = {}) {
const pend = []
for (const [k, v] of this.pending) {
const ek = encKey(k)
if (inRange(ek, range)) pend.push({ ek, key: k, value: v })
}
pend.sort((a, b) => b4a.compare(a.ek, b.ek))
let i = 0
let n = 0
const take = (key, value) => {
if (range.limit !== undefined && n >= range.limit) return 'stop'
n++
return { key, value }
}
for await (const node of this.bee.createReadStream(streamOpts(range))) {
const ek = node.key
// Emit every pending key that sorts before this one.
while (i < pend.length && b4a.compare(pend[i].ek, ek) < 0) {
const p = pend[i++]
if (p.value === BatchView.DELETED) continue
const out = take(p.key, p.value)
if (out === 'stop') return
yield out
}
// Same key: the pending write supersedes the committed one.
if (i < pend.length && b4a.compare(pend[i].ek, ek) === 0) {
const p = pend[i++]
if (p.value === BatchView.DELETED) continue
const out = take(p.key, p.value)
if (out === 'stop') return
yield out
continue
}
const out = take(decKey(ek), decVal(node.value))
if (out === 'stop') return
yield out
}
// Whatever pending keys sort after everything committed.
while (i < pend.length) {
const p = pend[i++]
if (p.value === BatchView.DELETED) continue
const out = take(p.key, p.value)
if (out === 'stop') return
yield out
}
}
async flush () { await this.batch.flush() }
// apply calls this when a batch throws, to drop the uncommitted writes rather than half-apply
// them. hyperbee2's write batch has its own close(); the pending map goes with it.
async close () {
this.pending.clear()
if (typeof this.batch.close === 'function') await this.batch.close()
}
}
// A sentinel, not `null` — `null` is a legal stored value and would read as "deleted".
BatchView.DELETED = Symbol('deleted')
export default { Hyperbee2View, BatchView }