OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity2 / lib / view-shim.js

198 lines · javascript ref:
// 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 }