OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / lib / peer.js

1187 lines · javascript ref:
// Peer — your feed, the feeds you hold, and the timeline you compute from them.
//
// ── Following is a record, not a subscription ──────────────────────────────────────────────────
//
// To follow someone you write a record in YOUR OWN feed naming their feed key. Nothing is written
// to theirs, and they need not be online, consent, or ever know. That is the same rule every
// interaction obeys here, and it is what keeps a feed's writer set at its owner's devices no matter
// how many people follow it.
//
// ── The timeline is a projection, and it is LOCAL ──────────────────────────────────────────────
//
// There is no relay and no aggregator. The timeline is a pure function of the feeds this node
// happens to hold, so two peers legitimately compute different timelines from the same follow list.
// A feed you follow but have not replicated contributes NOTHING — which is correct, and is why
// `missing()` exists: a gap you can name is a different thing from a gap that is silently empty.
//
// ⚠ No networking. Feeds are handed in. There is no transport seam yet, and adding one should not
// change anything in this file.

import b4a from 'b4a'
import crypto from 'hypercore-crypto'
// ⛔ The codec, not a regex. A mention is a markdown LINK with a `favela://` target, and deciding
// that from the source text means re-implementing link parsing — including the cases where a `@`
// inside code fences or a link label is not a mention at all.
import { strip } from 'favela-markdown/lib/strip.js'
import { NETWORKS } from 'favela-markdown/lib/reach.js'

// Collections. Bare names, deliberately: reverse-DNS is atproto's convention and this stack is not
// atproto's. A third party's collection would be namespaced; ours are not.
export const POST = 'post'
export const FOLLOW = 'follow'
export const LIKE = 'like'
// ⛔ REACTION is the primitive and `like` is a reserved VALUE of it — keet's shape, and the only
// one of the three networks that gets like for free rather than bolting reactions beside it
// (`keet-core/lib/core/contract/reactions.js`).
//
// ⛔ `like` is the string, never an emoji. A bridge must be able to name the one reaction both
// targets carry without guessing, and if the value were 👍 then "did they like it or react with a
// thumb" would be undecidable in both directions. The narrower target carries no other reaction, so
// every other value is a stated `loss[]` where that applies — which the BRIDGE states, not us.
export const REACTION = 'reaction'
// ⛔ A BODYLESS edge, per `interaction.md` §5.2: "if it has bytes it gets a file; if it does not,
// the log entry is the whole thing". Same class as like and follow, and deliberately NOT the same
// as reply, which is "the edge that is also a document". It is one of the five verbs the spec calls
// consensus-critical (§5.4) — both bridge targets carry it, under their own names, which is the
// bridges' business and not this file's.
//
// ⛔ And not the same as a QUOTE. `record-vocabulary.md` §3.5: "quotes are just links" — a quote is
// an ordinary post whose body references the quoted record, so it has a body and its own text. A
// repost adds nothing of its own, which is exactly what separates the two everywhere.
export const REPOST = 'repost'
// ⛔ A bodyless edge naming an ACTOR, like follow — `interaction.md` §5.2 and §5.4.
//
// ⛔ What it can and cannot do has to be said plainly, because the honest answer is weaker than
// people expect. A feed is public and replication is the READER's to perform, so a block cannot
// stop someone reading you, holding your feed, or writing about you. It is a decision about what
// YOU render, published as a signal — `SOCIAL-MODEL.md` §6.7's "signals, not enforcement", one
// level down. Anything stronger would be a promise this substrate cannot keep.
export const BLOCK = 'block'
export const LIKE_VALUE = 'like'
// A label, not a payload. Long enough for an emoji sequence with modifiers, short enough that the
// collection cannot become a message channel.
const MAX_VALUE = 64
export const REPLY = 'reply'

// The published profile. ONE record per feed, named by a constant — a feed is one identity, so
// there is nothing to enumerate.
//
// ⛔ Why this is a RECORD and not only a registry row. The registry is LOCAL to a node: it holds
// browser credentials and never replicates. So a profile kept only there is invisible to everyone
// who reads your feed — no name, no picture, nothing to search. Seeing who wrote something requires
// the profile to travel, and the feed is the only thing that travels.
//
// ⛔ What travels is a SELF-LABEL, and that is the whole justification. NAMING.md §2 forbids a name
// in a record because pgh's NameRegistry is first-claim-wins and a released name is RECLAIMABLE, so
// a claimed name in immutable bytes can later denote a different person. A self-label is claimed
// from nobody, unique to nobody, and reclaimable by nobody — it denotes only "what this feed's owner
// calls themselves". The resolvable handle is a different object and belongs in a HandleRegistry,
// beside this rather than instead of it.
//
// ⚠ Which is why the key is ALWAYS rendered next to it. A self-label is a claim, and two feeds may
// carry the same one.
export const PROFILE = 'profile'
// Which networks this identity publishes to. A PREFERENCE, in the feed, so it follows the identity
// to every device rather than living on whichever machine happened to set it.
export const REACH = 'reach'
// ⛔ The blob POINTER collection — record-vocabulary §3.5. A tiny record in the feed so every reader
// converges on which levels a blob has and the oid to fetch each by, while the bytes stay out of the
// log. Keyed by the BASE oid, which is what `favela://blob/<oid>` already names.
export const BLOB = 'blob'
export const SELF = 'self'

const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex'))

// Non-overlapping occurrences of `needle` in `haystack`. Both are already lower-cased by the caller;
// no normalisation happens here, deliberately — normalising would change what an author wrote for
// reasons invisible to them, which is the rule the strip already follows.
function countOf (haystack, needle) {
  let n = 0
  let i = haystack.indexOf(needle)
  while (i !== -1) {
    n++
    i = haystack.indexOf(needle, i + needle.length)
  }
  return n
}

export class Peer {
  // A feed key is 32 bytes of hex, which is a legal single path segment, so a follow record is named
  // by the key itself. No mapping table, and the name is a pure function of what it names.
  static nameFor (key) { return hex(key) }

  // A stable id for a target, used as the record NAME of a like.
  //
  // Deriving the name from the target is what makes liking idempotent: liking twice writes the same
  // key twice, which is one record, not two. keet reaches the same place from the other direction by
  // enforcing uniqueness of {id, sender, reaction} inside apply.
  //
  // It is a hash rather than the components joined, because a separator that cannot appear in a
  // collection or a name does not exist — and a joined string would silently collide for two
  // different targets. The components are stored in the record's frontmatter, so nothing needs to
  // read this backwards.
  /**
   * The record NAME of a reaction — derived from (target, value).
   *
   * ⛔ The value is IN the id, which is what lets one person hold 👍 and ❤️ on one target while
   * each stays idempotent. keet reaches the same place by enforcing uniqueness of
   * {message, sender, reaction} inside apply; deriving the key does it without a rule.
   */
  static reactionId (target, value) {
    const t = Peer.normalizeTarget(target)
    if (!t) throw new Error('Peer: a target needs { feed, collection, name }')
    return b4a.toString(crypto.data(
      b4a.from(JSON.stringify([t.feed, t.collection, t.name, String(value)]), 'utf8')), 'hex')
  }

  static normalizeValue (value) {
    const v = typeof value === 'string' ? value.trim() : ''
    if (!v || v.length > MAX_VALUE) return null
    // No control characters: a reaction is rendered next to a byline and must not carry newlines.
    return /[\u0000-\u001f\u007f]/.test(v) ? null : v
  }

  static targetId (target) {
    const t = Peer.normalizeTarget(target)
    if (!t) throw new Error('Peer: a target needs { feed, collection, name }')
    return b4a.toString(crypto.data(b4a.from(JSON.stringify([t.feed, t.collection, t.name]), 'utf8')), 'hex')
  }

  // ⛔ Components, never a URI string. The address form is open (hyper-social #61 §12 #1) and a URI
  // baked into a record's bytes is exactly what record-vocabulary §8 forbids rewriting. Components
  // serialise into whatever the scheme becomes; a URI does not un-serialise into components that
  // were never stored.
  static normalizeTarget (target) {
    if (!target || typeof target !== 'object') return null
    const feed = hex(target.feed)
    const { collection, name } = target
    if (typeof feed !== 'string' || !feed) return null
    if (typeof collection !== 'string' || !collection) return null
    if (typeof name !== 'string' || !name) return null
    return { feed, collection, name }
  }

  constructor (feed) {
    this.feed = feed
    this.feeds = new Map() // hex key -> Feed, the ones we actually hold
    // ⛔ Held to READ, not because I follow them. A peeked feed is readable — its page renders —
    // and is excluded from the timeline, because a stranger whose posts appear in your timeline is
    // someone you are following whether or not a record says so.
    this.peeked = new Set()
    // Feeds I have blocked. Cached like `_follows`, and consulted in ONE place — see `_readable`.
    this._blocked = new Set()
    // ⛔ How a knock proves who is knocking: `bind(writerKeyHex)` → a proof binding that writer core
    // to this device's identity root. The node sets it from its credential (node.js); unset means
    // this device is not linked, and a knock refuses rather than going out unproven (feed.js).
    this.bind = null
    // Observation feeds a bridge vouched for: hex feed key -> the founder root it vouched for (hyper-social #82, I1).
    // Held like any feed, but read ONLY as notifications from outside actors, and only while that feed's founder
    // IS the pinned root. The node fills this from the stored grants (bridge.js acceptObserver).
    this.observers = new Map()
  }

  _binder () {
    if (typeof this.bind !== 'function') {
      throw new Error('this device is not linked, so it cannot prove who is knocking — run `favela link`')
    }
    return this.bind
  }

  // The identity root behind a live knock that named `feedKey`, or null. Callers (the CLI, the
  // surface) still speak in feed keys; a knock is keyed by the root that proved it.
  async _knockRootFor (feedKey) {
    const feed = Peer.nameFor(feedKey)
    for (const k of await this.feed.knocks()) if (k.feed === feed) return k.root
    return null
  }

  async ready () {
    await this.feed.ready?.()
    // What I follow, cached so `peek` can refuse to demote one. Rebuilt on `update()`, because the
    // follow set is a projection of my own log like everything else.
    this._follows = new Set(await this.following())
    this._blocked = await this.blocked()
    return this
  }

  // Record the follow, and hold the feed if one was supplied. The two are independent on purpose:
  // you can record a follow for a feed you cannot reach yet, and hold a feed you never followed.
  /**
   * Hold a feed to READ it, without following.
   *
   * Writes nothing — no record enters my feed, and nothing enters theirs. ⚠ Not invisible: holding
   * a feed means joining its discovery topic, so the peer can see that somebody arrived. It is
   * "not announced", not "anonymous".
   */
  /**
   * Stop rendering a feed. Writes nothing to theirs, and cannot stop them doing anything.
   *
   * ⛔ It names a FEED, not an identity root, because rendering is per-feed and follow — the
   * operation it undoes in practice — names a feed too. A root-scoped block would be the stronger
   * form and this is not it.
   */
  async block (key) {
    const name = Peer.nameFor(key)
    // ⛔ Blocking myself would delete me from my own timeline, which is nobody's intent and is
    // trivially reachable from a page that lists feeds including your own.
    if (name === hex(this.feed.key)) throw new Error('you cannot block yourself')
    await this.feed.put(BLOCK, name, { frontmatter: { createdAt: new Date().toISOString() }, body: '' })
    this._blocked.add(name)
    return name
  }

  // A tombstone, like every other undo here.
  async unblock (key) {
    const name = Peer.nameFor(key)
    await this.feed.del(BLOCK, name)
    this._blocked.delete(name)
  }

  /** Feeds I have blocked. */
  async blocked () {
    return new Set((await this.feed.list(BLOCK)).map((r) => r.name))
  }

  peek (key, feed) {
    const name = Peer.nameFor(key)
    if (feed) this.feeds.set(name, feed)
    // ⚠ Never demote a feed I already follow — that would silently drop them out of my timeline.
    if (!this._follows.has(name)) this.peeked.add(name)
    return name
  }

  async follow (key, feed = null) {
    const name = Peer.nameFor(key)
    // Wall time is fine HERE. The determinism rule binds `apply`, which is replayed on every boot
    // and reorg; this is the write path, which runs once. An epoch placeholder would have been
    // deterministic and false, and a record that lies to avoid a rule the rule does not impose is
    // the worse trade.
    await this.feed.put(FOLLOW, name, {
      frontmatter: { createdAt: new Date().toISOString() },
      body: ''
    })
    if (feed) this.feeds.set(name, feed)
    // A peek that stayed a peek after you followed would leave the timeline silently short.
    this.peeked.delete(name)
    this._follows.add(name)
    return name
  }

  // Tombstone, never a silent removal — absence is ambiguous, and a peer replicating my feed has to
  // be able to tell "unfollowed" from "not replicated to me yet".
  async unfollow (key) {
    const name = Peer.nameFor(key)
    await this.feed.del(FOLLOW, name)
    this.feeds.delete(name)
    this.peeked.delete(name)
    this._follows.delete(name)
  }

  // ── the profile ────────────────────────────────────────────────────────────
  //
  // An ordinary record: same put, same pointer, same tombstone, same replication as a post.

  /**
   * Publish this feed's profile. Only the fields given are touched — a settings form showing two
   * fields must not erase a third it never rendered.
   */
  /**
   * Which networks this identity publishes to, and under which identifier.
   *
   * ⛔ OFF BY DEFAULT, AND ABSENCE FAILS CLOSED. No record means `[]` — publish nowhere. Reading a
   * missing value as "everything" is the one mistake here that cannot be taken back, because the
   * result is words under someone's name on a network that does not forget.
   *
   * ⚠ This is a preference, NOT a bridge. The peer names networks — `favela-markdown/lib/reach.js`
   * defines them and this file already depends on it — and holds nothing that knows what any of
   * them are: no schema, no signer, no wire format. Whatever reads this preference and acts on it
   * is a separate component, which is what `test/boundary.test.js` exists to keep true.
   */
  async reach () {
    await this.feed.update()
    const rec = await this.feed.get(REACH, SELF)
    const fm = (rec && rec.frontmatter) || {}
    return {
      networks: Array.isArray(fm.networks) ? fm.networks : [],
      // identity.md §5.1 — the DESIGNATED credential. With none, a projection that needs one is
      // withheld rather than published under a guess.
      credential: fm.credential || null,
      // The name a bridge grant produced, e.g. `alice@ap.example`. Absent until one is claimed.
      fediverse: fm.fediverse || null,
      // ⛔ AND WHICH BRIDGE CARRIES IT (hyper-social #82, I1): its swarm key, public. bridges.json is per device, so
      // without this a second device — or any device after a registration older than bridges.json — could not ask
      // its bridge for the observation feed. Here it replicates with the identity.
      fediverseBridge: fm.fediverseBridge || null
    }
  }

  /**
   * The mip pointer for one blob of THIS feed, or null.
   *
   * ⛔ Null means "no pointer", which for a blob written before levels existed is the SINGLE-LEVEL
   * case and not an error — record-vocabulary §3.5 calls today's avatar exactly that. A caller must
   * treat absence as one level, never as a missing asset.
   */
  async blobPointer (oid) {
    return this.blobPointerOf(this.feed.key, oid)
  }

  /** One feed's pointer for a blob, or null. Reads any feed this node holds. */
  async blobPointerOf (feedKey, oid) {
    if (typeof oid !== 'string' || !oid) return null
    const id = hex(feedKey)
    for (const [author, feed] of this._readable()) {
      if (author !== id) continue
      const rec = await feed.get(BLOB, oid)
      const fm = (rec && rec.frontmatter) || null
      if (!fm || !Array.isArray(fm.levels)) return null
      return fm
    }
    return null
  }

  /**
   * Publish the pointer for a blob's pyramid.
   *
   * ⛔ THE POINTER, NOT A MANIFEST FILE. §3.5: one pointer, N oids, zero extra artifacts. It rides
   * the feed so a bridge holding this feed can choose a level without asking anyone.
   */
  async putBlobPointer (oid, pointer) {
    if (typeof oid !== 'string' || !oid) throw new Error('putBlobPointer: an oid is required')
    await this.feed.update()
    await this.feed.put(BLOB, oid, { frontmatter: pointer, body: '' })
    return pointer
  }

  /** Merges: a write naming only `networks` leaves the credential alone, and the reverse. */
  async setReach ({ networks, credential, fediverse, fediverseBridge } = {}) {
    await this.feed.update()
    const current = await this.reach()
    const fm = { networks: current.networks, credential: current.credential, fediverse: current.fediverse, fediverseBridge: current.fediverseBridge }
    if (fediverseBridge !== undefined) {
      if (fediverseBridge !== null && !/^[0-9a-f]{64}$/i.test(String(fediverseBridge))) {
        throw new Error('setReach: fediverseBridge must be a 64-hex bridge key')
      }
      fm.fediverseBridge = fediverseBridge ? String(fediverseBridge).toLowerCase() : null
    }

    if (networks !== undefined) {
      if (!Array.isArray(networks)) throw new Error('setReach: networks must be an array')
      for (const n of networks) {
        // ⛔ Refused, not silently dropped. A name nothing can resolve to a bridge would sit in the
        // feed looking enabled, and the reader would have no way to tell it apart from one that works.
        if (n === 'favela') {
          throw new Error('setReach: favela is the substrate, not a destination — it cannot be switched on or off')
        }
        if (!NETWORKS.includes(n)) throw new Error('setReach: unknown network "' + String(n) + '"')
      }
      fm.networks = [...new Set(networks)]
    }
    if (credential !== undefined) {
      fm.credential = credential ? String(credential).slice(0, 256) : null
    }
    // ⛔ A HANDLE, NOT A CREDENTIAL. identity.md §5.1's `credential` is the DESIGNATED ATPROTO one
    // and says so; the fediverse has no equivalent because §6 makes a bridge a RESOURCE you hold a
    // grant on. What is recorded here is the name that grant produced — `alice@ap.example` — so the
    // node can say where it publishes without pretending the two networks work the same way.
    //
    // ⚠ The daemon never learns what a fediverse IS. `fediverse` is already one of reach.js's
    // NETWORKS, so this names a destination it can already name and nothing more; boundary.test.js
    // forbids the projection vocabulary, not the word.
    if (fediverse !== undefined) {
      fm.fediverse = fediverse ? String(fediverse).slice(0, 256) : null
    }
    if (!fm.credential) delete fm.credential
    if (!fm.fediverse) delete fm.fediverse
    if (!fm.fediverseBridge) delete fm.fediverseBridge
    await this.feed.put(REACH, SELF, { frontmatter: fm, body: '' })
    return this.reach()
  }

  async setProfile ({ name, avatar, bio, banner } = {}) {
    await this.feed.update()
    const current = await this.feed.get(PROFILE, SELF)
    const fm = { ...((current && current.frontmatter) || {}) }

    if (name !== undefined) {
      const n = typeof name === 'string'
        ? name.replace(/[\n\r\t]/g, ' ').replace(/[\u0000-\u001f\u007f]/g, '').trim().replace(/\s+/g, ' ').slice(0, 64)
        : ''
      // A blank name is a removal, not an error. Absence asserts nothing; "" would assert that the
      // name is empty.
      if (n) fm.name = n
      else delete fm.name
    }
    if (avatar !== undefined) {
      if (avatar) fm.avatar = String(avatar)
      else delete fm.avatar
    }
    // ⛔ A SECOND PICTURE, not a variant of the first. AS2 has carried both `icon` and `image`
    // since forever and Mastodon renders both, so an identity that can only hold one is missing a
    // field the destination already had (hyper-social #67 §7.6). Same shape as the avatar — an oid,
    // bytes in the overlay — because nothing about a banner needs a second mechanism.
    if (banner !== undefined) {
      if (banner) fm.banner = String(banner)
      else delete fm.banner
    }
    // The record is mutable and overwritten in place, so createdAt keeps meaning creation.
    if (typeof fm.createdAt !== 'string') fm.createdAt = new Date().toISOString()

    /**
     * ⛔ The bio is the record's BODY, not another frontmatter field. It was designed that way and
     * this method has been carefully PRESERVING that body on every write while nothing ever wrote
     * it — so a profile could carry a name and a picture and never a sentence about who someone is.
     *
     * A body means it is markdown like every other record body, and it travels with the feed like
     * every other record. A frontmatter copy would be a second place for the same fact.
     */
    let body = (current && current.body) || ''
    if (bio !== undefined) {
      const b = typeof bio === 'string'
        // Control characters out, but newlines KEPT — it is markdown, and a bio with paragraphs is
        // an ordinary thing to write.
        ? bio.replace(/[\u0000-\u0009\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, 512)
        : ''
      // ⚠ Blank REMOVES it. Absence asserts nothing; an empty string asserts that the bio is empty.
      body = b
    }

    await this.feed.put(PROFILE, SELF, { frontmatter: fm, body })
    return { name: fm.name || null, avatar: fm.avatar || null, banner: fm.banner || null, bio: body.trim() || null }
  }

  /** This feed's own profile. */
  async profile () {
    return (await this.profileOf(this.feed.key)) || { name: null, avatar: null, banner: null, bio: null }
  }

  /**
   * One feed's profile, or null.
   *
   * Null covers all three unreadable states, which is right HERE and nowhere else in this file: the
   * fallback is the key, which is always shown and never wrong. An unreadable POST has no such
   * fallback, which is why `unresolved()` exists and this does not need one.
   */
  async profileOf (feedKey) {
    const id = hex(feedKey)
    for (const [author, feed] of this._readable()) {
      if (author !== id) continue
      const rec = await feed.get(PROFILE, SELF)
      if (!rec) return null
      const fm = rec.frontmatter || {}
      return { name: fm.name || null, avatar: fm.avatar || null, banner: fm.banner || null, bio: (rec.body || '').trim() || null }
    }
    return null
  }

  /**
   * Every readable feed's profile, keyed by feed.
   *
   * A feed with no profile is ABSENT from the map rather than present with empty fields — "has not
   * published one" and "published an empty one" are different facts.
   */
  async profiles () {
    const out = {}
    for (const [author, feed] of this._readable()) {
      const rec = await feed.get(PROFILE, SELF)
      if (!rec) continue
      const fm = rec.frontmatter || {}
      /**
       * ⛔ The identity ROOT travels with the profile. A mention names a root
       * (`markdown-strip.md` §3.4) and a bare `@name` is never a guess — so without a root on the
       * row there is no way to WRITE a mention from any interface, only to read one. That is what
       * shipped: mentions were findable, renderable, listable and unwritable.
       *
       * ⚠ It is a FEED fact rather than a profile one, and it rides here because this is the map
       * every byline already fetches. The alternative is a founder lookup per rendered author.
       */
      const founder = await feed.founder()
      out[author] = {
        name: fm.name || null,
        avatar: fm.avatar || null,
        banner: fm.banner || null,
        bio: (rec.body || '').trim() || null,
        root: (founder && founder.identityRoot) || null
      }
    }
    return out
  }

  /**
   * People whose PROFILE matches — searching who, not what.
   *
   * Kept separate from `search()` rather than folded in: a person and a post are different answers
   * to a query, and a UI that mixed them would have to guess which the reader meant.
   */
  async searchProfiles (query) {
    const q = String(query == null ? '' : query).toLowerCase()
    if (!q) return []
    const out = []
    for (const [author, profile] of Object.entries(await this.profiles())) {
      const name = (profile.name || '').toLowerCase()
      // The KEY is searchable too — pasting a feed key is how you find someone with no name yet.
      if (name.includes(q) || author.toLowerCase().startsWith(q)) out.push({ author, ...profile })
    }
    return out.sort((a, b) => (a.author < b.author ? -1 : 1))
  }

  // ── interactions ──────────────────────────────────────────────────────────────────────────────
  //
  // Every one of these writes to MY feed and names a target in someone's. Nothing is written to
  // theirs, so a feed's writer set never grows past its owner's devices however popular it gets.

  /** React to a target. `like` is the reserved value; anything else is an emoji or a short label. */
  async react (target, value, createdAt = new Date().toISOString()) {
    const t = Peer.normalizeTarget(target)
    if (!t) throw new Error('Peer.react: a target needs { feed, collection, name }')
    const v = Peer.normalizeValue(value)
    if (!v) throw new Error('Peer.react: a reaction value is 1-' + MAX_VALUE + ' printable characters')
    const name = Peer.reactionId(t, v)
    await this.feed.put(REACTION, name, { frontmatter: { createdAt, target: t, value: v }, body: '' })
    await this._ring(t)
    return name
  }

  /**
   * ⛔ RING THE TARGET'S DOORBELL. An interaction lives in its AUTHOR's feed, so the target learns of
   * it by replication — and only if they hold that feed. Without this, a like from anyone whose feed
   * they do not hold is invisible and nothing rings. hyper-social #61 §11.2 wrote that down in
   * advance as the falsification: "A never learns ⇒ the doorbell is mandatory, not optional."
   *
   * ⛔ IT CARRIES NOTHING. One row saying a feed would like to be seen, in a namespace that can never
   * become a record, granting no write and no admission (doorbell-wire.test.js proves both across a
   * real pipe). The edge itself stays in our feed — writing it into theirs would be authoring as
   * them, which #61 §4 removed the room primitive to avoid.
   *
   * ⚠ Bounded by construction: applyKnock keys on the FEED, so a thousand acts are one row. Inherited
   * from the knock, not added here.
   *
   * ⚠ BEST EFFORT, NEVER FATAL. The edge is ours and is already written; an unreachable target must
   * not undo it. But it is said out loud rather than swallowed — a doorbell that silently fails is
   * the exact condition this exists to end.
   */
  async _ring (t) {
    if (!t || t.feed === Peer.nameFor(this.feed.key)) return false // being told about yourself is not a notification
    const theirs = this.feeds.get(t.feed)
    if (!theirs || typeof theirs.knock !== 'function') return false // we do not hold it; there is nothing to append to
    try {
      await theirs.knock(this.feed.key, this._binder())
      return true
    } catch (err) {
      try { console.error(JSON.stringify({ msg: 'doorbell: could not ring — they will not learn of this interaction until they read our feed', feed: t.feed, error: (err && err.message) || String(err) })) } catch {}
      return false
    }
  }

  async unreact (target, value) {
    const v = Peer.normalizeValue(value)
    if (!v) return
    await this.feed.del(REACTION, Peer.reactionId(target, v))
  }

  // `like` is a reaction whose value is the reserved one. Kept as its own method because it is the
  // one value both target networks can carry, so callers that mean exactly that should say so.
  async like (target, createdAt = new Date().toISOString()) {
    return this.react(target, LIKE_VALUE, createdAt)
  }

  async unlike (target) { return this.unreact(target, LIKE_VALUE) }

  // A reply IS a post that names a target — the body is what separates it from a like, which is why
  // the two need no different machinery.
  async reply (target, body, name, createdAt = new Date().toISOString()) {
    const t = Peer.normalizeTarget(target)
    if (!t) throw new Error('Peer.reply: a target needs { feed, collection, name }')
    if (typeof name !== 'string' || !name) throw new Error('Peer.reply: a reply needs its own name')
    await this.feed.put(REPLY, name, { frontmatter: { createdAt, target: t }, body: String(body || '') })
    await this._ring(t)
    return name
  }

  // Everything in `collection`, across readable feeds, whose frontmatter target matches.
  //
  // ⛔ There is no count(). You get the entries and may count what you can SEE — so no number exists
  // that could be mistaken for a global total, and none can be summed with a bridged one.
  async _pointingAt (collection, target) {
    const t = Peer.normalizeTarget(target)
    if (!t) return []
    const out = []
    for (const [author, feed] of this._readable()) {
      for (const row of await feed.list(collection)) {
        const rt = Peer.normalizeTarget(row.record.frontmatter && row.record.frontmatter.target)
        if (!rt) continue
        if (rt.feed === t.feed && rt.collection === t.collection && rt.name === t.name) {
          out.push({ author, ...row })
        }
      }
    }
    return out
  }

  /** Every reaction on a target, from feeds I can read — `{ author, value, ... }` per row. */
  async reactionsOn (target) {
    return (await this._pointingAt(REACTION, target)).map((row) => ({
      ...row, value: (row.record.frontmatter && row.record.frontmatter.value) || null
    }))
  }

  /**
   * Repost a target — no body, and no RECORD written to their feed.
   *
   * ⚠ It does ring their doorbell (`_ring`), which is one row in a reserved namespace and never a
   * record. The older wording here said "nothing written to their feed" and that is no longer exact.
   */
  async repost (target, createdAt = new Date().toISOString()) {
    const t = Peer.normalizeTarget(target)
    if (!t) throw new Error('Peer.repost: a target needs { feed, collection, name }')
    await this.feed.put(REPOST, Peer.targetId(t), { frontmatter: { createdAt, target: t }, body: '' })
    await this._ring(t)
    return Peer.targetId(t)
  }

  // A tombstone, like every other undo here: absence is ambiguous in a replicated log.
  async unrepost (target) {
    await this.feed.del(REPOST, Peer.targetId(target))
  }

  /** Who reposted a target, across the feeds I can read. No count — you may count what you SEE. */
  async repostsOf (target) { return this._pointingAt(REPOST, target) }

  /** The targets I have reposted, as a Set of targetId. My own feed, one listing for a page. */
  async myReposts () {
    return new Set((await this.feed.list(REPOST)).map((r) => r.name))
  }

  async likesOn (target) {
    return (await this.reactionsOn(target)).filter((r) => r.value === LIKE_VALUE)
  }

  /**
   * The targets I have liked — a Set of `targetId`.
   *
   * ⛔ MY OWN feed only. Every like lives in its author's feed, so reading this across readable
   * feeds would report someone else's like as mine and light the button up for everyone.
   *
   * ⚠ Cheap on purpose: a like is NAMED by its target, so this is one listing of one log I already
   * hold — never a scan of everyone's, which is what `likesOn` has to do and why it takes a target.
   * A tombstoned like is absent because `list()` filters to resolvable records.
   */
  async myLikes () {
    const out = new Set()
    for (const [id, values] of await this.myReactions()) {
      if (values.has(LIKE_VALUE)) out.add(id)
    }
    return out
  }

  /**
   * What I have reacted with, as `targetId -> Set(value)`.
   *
   * ⛔ MY OWN feed only, and one listing of it for a whole page. Reading this across readable feeds
   * would report someone else's reaction as mine.
   */
  async myReactions () {
    const out = new Map()
    for (const row of await this.feed.list(REACTION)) {
      const fm = row.record.frontmatter || {}
      const t = Peer.normalizeTarget(fm.target)
      const v = Peer.normalizeValue(fm.value)
      if (!t || !v) continue
      const id = Peer.targetId(t)
      if (!out.has(id)) out.set(id, new Set())
      out.get(id).add(v)
    }
    return out
  }

  /**
   * MY OWN edges in one collection, whole — target components included.
   *
   * ⛔ `myReactions()` COLLAPSES TO targetId -> Set(value), which is right for rendering a page ("is
   * this one liked?") and useless for projecting one: the targetId is a HASH, so the feed, collection
   * and name that built it cannot be recovered from it, and a bridge needs those to name the subject
   * on another network.
   *
   * ⚠ MY OWN FEED ONLY. Reading this across readable feeds would project somebody else's like as mine
   * — the same reason myReactions is scoped that way.
   */
  async ownEdges (collection) {
    const out = []
    for (const row of await this.feed.list(collection)) {
      const fm = (row.record && row.record.frontmatter) || {}
      const target = Peer.normalizeTarget(fm.target)
      // A row whose target will not normalise names nothing, so there is nothing to project. Skipped
      // rather than thrown: one malformed record must not stop a projection pass.
      if (!target) continue
      out.push({
        name: row.key,
        collection,
        target,
        value: fm.value || null,
        createdAt: fm.createdAt || null,
        body: (row.record && row.record.body) || ''
      })
    }
    // Oldest first: an edge is projected in the order it was made, so a reply lands after the thing
    // it replies to when a whole history is projected at once.
    out.sort((a, b) => String(a.createdAt || '') < String(b.createdAt || '') ? -1 : 1)
    return out
  }

  /** Have I liked this target? The single-target form, for a page that renders one post. */
  async likedByMe (target) {
    const t = Peer.normalizeTarget(target)
    if (!t) return false
    const mine = (await this.myReactions()).get(Peer.targetId(t))
    return !!mine && mine.has(LIKE_VALUE)
  }

  // Oldest first — a thread reads forwards, where a timeline reads backwards.
  async repliesTo (target) {
    const rows = await this._pointingAt(REPLY, target)
    rows.sort((a, b) => {
      const ta = (a.record.frontmatter && a.record.frontmatter.createdAt) || ''
      const tb = (b.record.frontmatter && b.record.frontmatter.createdAt) || ''
      if (ta !== tb) return ta < tb ? -1 : 1
      const ka = a.author + '/' + a.key
      const kb = b.author + '/' + b.key
      return ka < kb ? -1 : ka > kb ? 1 : 0
    })
    return rows
  }

  // ── mentions ──────────────────────────────────────────────────────────────────────────────────
  //
  // ⚠ There is no mention EDGE. A mention lives in the body of a post (`markdown-strip.md` §3.4), so
  // finding them is a SCAN of what this node holds — the same class of work as `search`, and it will
  // want an index long before it wants anything else.

  /**
   * Posts naming `root`, across the feeds I can read.
   *
   * ⛔ The target is an identity ROOT. Not a feed key and not a network identifier: §3.4 was revised
   * precisely because a rotatable identifier in canonical bytes makes rotation a rewrite of every
   * record that ever named you.
   */
  async mentionsOf (root) {
    if (typeof root !== 'string' || !root) return []
    const uri = 'favela://' + root.toLowerCase()
    const out = []
    for (const [author, feed] of this._readable()) {
      for (const row of await feed.list(POST)) {
        const body = (row.record && row.record.body) || ''
        // Cheap reject before parsing: the target must appear verbatim for a facet to carry it.
        if (!body.toLowerCase().includes(uri)) continue
        let facets = []
        try { facets = strip(body).facets || [] } catch { continue }
        // ⛔ A facet, never a substring. A bare `@name` is plain text with no facet and must never
        // count — guessing who it meant is how a mention list starts naming people who were not.
        if (!facets.some((f) => f.type === 'mention' && String(f.value).toLowerCase() === uri)) continue
        out.push({ author, ...row })
      }
    }
    return out
  }

  /** Posts naming ME — resolved from my own feed's founder, which is the only root I can prove. */
  async myMentions () {
    const founder = await this.feed.founder()
    if (!founder || !founder.identityRoot) return []
    return this.mentionsOf(founder.identityRoot)
  }

  // ── notifications ─────────────────────────────────────────────────────────────────────────────
  //
  // ⛔ A PROJECTION, per `SOCIAL-MODEL.md` §3: "a pure function from records to a view — derived on
  // read, never stored, never replicated". Nothing is delivered, because nothing needs to be: every
  // interaction is written in its AUTHOR's feed, so if you hold their feed you already have it.
  //
  // That settles what hyper-social #62 was stuck on. #62 framed notification as DELIVERY and found
  // {stranger can notify, survives offline, bounded cost, Sybil-resistant, no relay} unsatisfiable.
  // It is — for the half this does not do. What is genuinely left is a stranger you hold nothing
  // of, which the DOORBELL answers and which is included below, and waking a sleeping device, which
  // is a separate job entirely.
  //
  // ⚠ No READ STATE, deliberately. The obvious cheap version is a "seen" timestamp, and ordering
  // here is by `createdAt` — which `timeline()` already documents as a CLAIM BY ITS AUTHOR, not a
  // fact the log agrees on. A seen-marker compared against attacker-controlled timestamps is
  // trivially defeated in both directions: post in the future to stay unread for ever, or in the
  // past to be missed. Read state needs a local-only store and an ordering nobody else supplies,
  // and shipping the broken version would be worse than not having it.
  //
  // ⚠ It is a SCAN of what this node holds, like `search` and `mentions`. It will want an index
  // long before it wants anything else.

  /**
   * Everything other people did that concerns me, newest first.
   *
   * `kind` is one of: reaction · repost · reply · mention · follow · knock.
   */
  /**
   * What an outside network did to me, from a bridge's observation feed (hyper-social #82, I1).
   *
   * ⛔ ONLY WHILE ITS FOUNDER IS THE ROOT THE BRIDGE VOUCHED FOR. A feed key alone proves nothing about who writes
   * it; the founder does, and it was pinned from a grant the bridge signed. A mismatch yields nothing rather than
   * rows attributed to whoever happens to have founded it.
   */
  async _observationRows (feed, pinnedRoot, mine) {
    let founder = null
    try { founder = await feed.founder() } catch {}
    if (!founder || !founder.identityRoot || founder.identityRoot !== pinnedRoot) return []
    const KIND = { like: 'reaction', repost: 'repost', reply: 'reply', mention: 'mention', follow: 'follow' }
    const out = []
    for (const row of await feed.list('observation')) {
      const fm = (row.record && row.record.frontmatter) || {}
      const kind = KIND[fm.kind]
      if (!kind || typeof fm.actor !== 'string' || !fm.actor) continue
      const t = Peer.normalizeTarget(fm.target)
      const ms = Number(fm.at)
      out.push({
        kind,
        // One observation feed carries every outside network (hyper-social #82, I2): a bsky actor is a DID.
        source: fm.actor.startsWith('did:') ? 'bsky' : 'fediverse',
        author: fm.actor,
        at: Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : '',
        target: t && t.feed === mine ? t : null,
        value: kind === 'reaction' ? 'like' : null,
        name: row.name,
        // The record codec ends a body with a newline; the text the outside actor wrote does not.
        body: ((row.record && row.record.body) || '').replace(/\n+$/, '')
      })
    }
    return out
  }

  async notifications ({ limit = 100 } = {}) {
    const mine = hex(this.feed.key)
    const founder = await this.feed.founder()
    const root = founder && founder.identityRoot ? founder.identityRoot : null
    const out = []

    const at = (row) => (row.record && row.record.frontmatter && row.record.frontmatter.createdAt) || ''
    const aimedAtMe = (row) => {
      const t = Peer.normalizeTarget(row.record && row.record.frontmatter && row.record.frontmatter.target)
      return t && t.feed === mine ? t : null
    }

    for (const [author, feed] of this._readable()) {
      // ⛔ Not my own doing. Liking your own post is ordinary; being told about it is not.
      if (author === mine) continue

      // ⛔ AN OBSERVATION FEED IS NOT AN AUTHOR. A bridge's feed carries what OUTSIDE actors did to me; its rows are
      // theirs, never the bridge's, and it is not scanned for the bridge's own interactions.
      if (this.observers.has(author)) {
        for (const row of await this._observationRows(feed, this.observers.get(author), mine)) out.push(row)
        continue
      }

      for (const collection of [REACTION, REPOST, REPLY]) {
        for (const row of await feed.list(collection)) {
          const target = aimedAtMe(row)
          if (!target) continue
          out.push({
            kind: collection === REACTION ? 'reaction' : collection === REPOST ? 'repost' : 'reply',
            author,
            at: at(row),
            target,
            value: (row.record.frontmatter && row.record.frontmatter.value) || null,
            name: row.name,
            body: (row.record && row.record.body) || ''
          })
        }
      }

      // A follow of ME is a record in THEIR feed named by MY feed key.
      for (const row of await feed.list(FOLLOW)) {
        if (row.name !== mine) continue
        out.push({ kind: 'follow', author, at: at(row), target: null, value: null, name: row.name, body: '' })
      }
    }

    // Mentions reuse the facet-based projection rather than re-deciding what a mention is.
    if (root) {
      for (const row of await this.mentionsOf(root)) {
        if (row.author === mine) continue
        out.push({
          kind: 'mention',
          author: row.author,
          at: (row.record.frontmatter && row.record.frontmatter.createdAt) || '',
          target: { feed: row.author, collection: row.collection, name: row.name },
          value: null,
          name: row.name,
          body: (row.record && row.record.body) || ''
        })
      }
    }

    // ⛔ The doorbell. Everything above needs their feed; this is what somebody you hold NOTHING of
    // can still do, and it is the whole answer to the stranger half of the problem.
    //
    // ⛔ A KNOCK FROM A FEED WE ALREADY HOLD IS NOT NEWS, and it is skipped. Since an interaction
    // rings the doorbell (`_ring`), someone we already follow now rings it every time they react —
    // and we can already READ everything they did, so the knock would be a second notification for
    // an act that already produced one. The doorbell's own purpose is the stranger; that is exactly
    // the set left here.
    //
    // ⚠ Skipped for RENDERING, not dismissed. The row stays in the feed, so accepting it later still
    // works and nothing about consent is decided by this filter.
    const held = new Set([...this.feeds.keys()])
    for (const k of await this.feed.knocks()) {
      if (held.has(k.feed)) continue
      out.push({ kind: 'knock', author: k.feed, at: '', target: null, value: null, name: k.feed, body: '' })
    }

    out.sort((a, b) => {
      if (a.at !== b.at) return a.at < b.at ? 1 : -1
      const ka = a.kind + '/' + a.author + '/' + a.name
      const kb = b.kind + '/' + b.author + '/' + b.name
      return ka < kb ? -1 : ka > kb ? 1 : 0
    })
    return limit === Infinity ? out : out.slice(0, limit)
  }

  // ── the registry ──────────────────────────────────────────────────────────────────────────────
  //
  // A registry is a FEED whose records are listings. Mine is just my own feed used that way; someone
  // else's is one I follow. There is no registry type, no registry server, and nothing central —
  // which is the only thing that keeps the one we happen to run replaceable rather than privileged.

  // Ask a registry to list me. This is a knock, which is to say: the doorbell already IS the consent
  // mechanism, and being listed needs no separate protocol.
  async askToBeListed (registryFeed, myFeedKey = this.feed.key) {
    await registryFeed.knock(myFeedKey, this._binder())
  }

  // Honour a request. Listing then dismissing, in that order — apply refuses a listing whose knock
  // is already tombstoned.
  async listFeed (feedKey, record) {
    const root = await this._knockRootFor(feedKey)
    if (!root) throw new Error('no live, proven knock names that feed — it has not asked to be listed')
    const r = await this.feed.listFeed(root, feedKey, record)
    await this.feed.dismissKnock(root)
    return r
  }

  async delist (feedKey) { await this.feed.delist(feedKey) }

  // Every listing across the registries I hold, plus my own. Duplicates are kept: the same feed
  // listed by two registries is two endorsements, and collapsing them would throw away who vouched.
  //
  // ⛔ `verified` closes the half of consent apply cannot see. A listing records the identity root
  // that knocked; only a reader holding the listed feed can check that the feed's founder IS that
  // root. true = checked and matches; false = checked and does NOT (treat as not listed); null = we do
  // not hold that feed, so it is unchecked.
  async directory () {
    const out = []
    for (const [registry, feed] of this._readable()) {
      for (const l of await feed.listings()) {
        let verified = null
        const listed = this.feeds.get(l.feed)
        if (listed && typeof listed.founder === 'function') {
          const founder = await listed.founder()
          verified = !!(founder && l.root && founder.identityRoot === l.root)
        }
        out.push({ registry, ...l, verified })
      }
    }
    out.sort((a, b) => {
      const ka = a.registry + '/' + a.feed
      const kb = b.registry + '/' + b.feed
      return ka < kb ? -1 : ka > kb ? 1 : 0
    })
    return out
  }

  // ── the doorbell ──────────────────────────────────────────────────────────────────────────────

  async knocks () { return this.feed.knocks() }

  // ⛔ Accepting a knock means I FOLLOW THEM. It does not make them a writer on my feed, and there
  // is deliberately no method here that would. Write access to a feed belongs to its owner's devices
  // and to nobody else.
  async admitKnock (feedKey, feed = null) {
    const name = await this.follow(feedKey, feed)
    const root = await this._knockRootFor(feedKey)
    if (root) await this.feed.dismissKnock(root)
    return name
  }

  async dismissKnock (feedKey) {
    const root = await this._knockRootFor(feedKey)
    if (root) await this.feed.dismissKnock(root)
  }

  async following () {
    const rows = await this.feed.list(FOLLOW)
    return rows.map((r) => r.name).sort()
  }

  // Follows we cannot read: recorded, but no feed held. Named rather than silently absent, because
  // "nobody posted" and "I have not replicated them" look identical in a timeline and are not the
  // same fact.
  async missing () {
    const following = await this.following()
    return following.filter((name) => !this.feeds.has(name))
  }

  // Every feed we can read: our own, plus the held ones. Following is NOT required to be held, and
  // holding is not required to be followed.
  /**
   * Every feed I can read: mine, plus the held ones, MINUS the blocked.
   *
   * ⛔ Blocking is enforced HERE and nowhere else. Every projection in this file goes through this
   * method, so filtering the timeline and forgetting search is how a blocked person reappears in
   * the one view nobody thought about. One place decides.
   *
   * ⚠ A blocked feed is still HELD — still on disk, still replicating. Nothing here can
   * un-replicate what already arrived, and a block that claimed to delete their bytes would be a
   * promise this substrate cannot keep. It stops being rendered; it does not vanish.
   */
  _readable () {
    const out = [[hex(this.feed.key), this.feed]]
    for (const [name, feed] of this.feeds) {
      if (this._blocked.has(name)) continue
      out.push([name, feed])
    }
    return out
  }

  async update () {
    await this.feed.update()
    for (const [, feed] of this.feeds) await feed.update()
    this._follows = new Set(await this.following())
    this._blocked = await this.blocked()
    for (const name of this._follows) this.peeked.delete(name)
  }

  /**
   * The timeline: posts from every readable feed, newest first.
   *
   * ⚠ Ordered by `createdAt`, which is WALL TIME and therefore a claim by its author, not a fact the
   * log agrees on. Positions are per-feed counters and are not comparable across feeds, so there is
   * no cross-feed ordering the substrate can supply. A reader's timeline is a rendering; this is the
   * ordering readers expect, and it is stated rather than implied.
   *
   * Ties break on (author, name) so the same inputs always produce the same order — a timeline that
   * reshuffled on refresh would be indistinguishable from one that had new content.
   */
  async timeline ({ limit = Infinity } = {}) {
    const out = []
    // ⛔ Followed feeds and my own — NOT everything readable. A peeked feed is held so its page can
    // render; putting it here would make reading a stranger's page indistinguishable from
    // following them, which is the whole thing peek exists to avoid.
    for (const [author, feed] of this._readable()) {
      if (this.peeked.has(author)) continue
      for (const row of await feed.list(POST)) {
        out.push({
          key: author + '/' + row.key,
          author,
          collection: row.collection,
          name: row.name,
          record: row.record
        })
      }
    }
    out.sort((a, b) => {
      const ta = (a.record.frontmatter && a.record.frontmatter.createdAt) || ''
      const tb = (b.record.frontmatter && b.record.frontmatter.createdAt) || ''
      if (ta !== tb) return ta < tb ? 1 : -1 // newest first
      return a.key < b.key ? -1 : a.key > b.key ? 1 : 0
    })
    return limit === Infinity ? out : out.slice(0, limit)
  }

  /**
   * Every post by one author, newest first.
   *
   * ⛔ Its own read rather than a filter over a 200-row timeline, which is what the person page
   * did. Once the timeline stopped including peeked feeds, that filter could no longer render the
   * page of somebody you had only peeked — the exact case peek exists for.
   */
  async postsBy (feedKey) {
    const id = hex(feedKey)
    // ⚠ This one reads a feed DIRECTLY rather than through `_readable`, so it repeats the check.
    // A person page that still rendered someone you blocked would be the same bug by another door.
    if (this._blocked.has(id)) return []
    const feed = id === hex(this.feed.key) ? this.feed : this.feeds.get(id)
    if (!feed) return []
    const out = (await feed.list(POST)).map((row) => ({
      key: id + '/' + row.key, author: id, collection: row.collection, name: row.name, record: row.record
    }))
    out.sort((a, b) => {
      const ta = (a.record.frontmatter && a.record.frontmatter.createdAt) || ''
      const tb = (b.record.frontmatter && b.record.frontmatter.createdAt) || ''
      if (ta !== tb) return ta < tb ? 1 : -1
      return a.key < b.key ? -1 : a.key > b.key ? 1 : 0
    })
    return out
  }

  /**
   * Local full-text search over the records this node holds. No index, no crawler, no relay.
   *
   * It searches the markdown SOURCE rather than the stripped text. The source is the canonical
   * bytes, so a substring match over it finds strictly more and never less — "world" matches
   * "**world**", and text inside a table or a link target stays findable. Searching a lossy
   * projection would make findability depend on whatever that projection happened to drop.
   *
   * ⛔ Returns `{ results, unreadable }`. "No results" and "no results, and twelve records I hold
   * pointers for but cannot fetch" are different answers. A search box that renders them alike is
   * the silent-absence bug this codebase refuses everywhere else.
   *
   * No relevance ranking. Ordering is newest first with a deterministic tie-break, the same as the
   * timeline — a score nobody can explain is worse than an order everyone can predict.
   *
   * ⚠ It is a SCAN. Every held record is read on every query. That is honest for the sizes this is
   * built for and it will need an index long before it needs a ranker.
   */
  async search (query, { collection = null, limit = Infinity } = {}) {
    const q = String(query == null ? '' : query).toLowerCase()
    // Fail closed: an empty box is not a request for everything.
    if (!q) return { results: [], unreadable: 0 }

    const results = []
    let unreadable = 0

    for (const [author, feed] of this._readable()) {
      for (const row of await feed.list(collection)) {
        const body = (row.record && row.record.body) || ''
        const hits = countOf(body.toLowerCase(), q)
        if (hits > 0) results.push({ author, hits, ...row })
      }
      // Counted, not skipped. Note what this number is and is not: we cannot know whether an
      // unreadable record WOULD have matched, because we cannot read it. That is precisely why it is
      // reported as a separate figure rather than folded into the results — the honest statement is
      // "n records in scope could not be read", not "n matches were lost".
      unreadable += (await feed.unresolved(collection)).length
    }

    results.sort((a, b) => {
      const ta = (a.record.frontmatter && a.record.frontmatter.createdAt) || ''
      const tb = (b.record.frontmatter && b.record.frontmatter.createdAt) || ''
      if (ta !== tb) return ta < tb ? 1 : -1
      const ka = a.author + '/' + a.key
      const kb = b.author + '/' + b.key
      return ka < kb ? -1 : ka > kb ? 1 : 0
    })

    return { results: limit === Infinity ? results : results.slice(0, limit), unreadable }
  }

  // Records we hold a pointer for and cannot read, across every readable feed. Kept separate from
  // the timeline for the same reason `missing()` is: an unreadable record must never look like an
  // absent one.
  async unresolved () {
    const out = []
    for (const [author, feed] of this._readable()) {
      for (const row of await feed.unresolved()) out.push({ author, ...row })
    }
    return out
  }

  async close () {
    await this.feed.close()
    for (const [, feed] of this.feeds) await feed.close()
  }
}