OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / observations.js

139 lines · javascript ref:
// observations.js — what other people did to a peer's records, held until that peer collects it.
//
// ⛔ AN OBSERVATION IS NOT AN EDGE, and the distinction is the whole design. An edge a favela peer
// authors is a record in THEIR feed and needs nothing here. A like from a Mastodon or Bluesky account
// is not ours to author: writing one into somebody's feed would be authoring as them, which
// hyper-social #61 §4 removed the room primitive to avoid. So what this holds is weaker and honest —
// *we observed that this actor did this to this record of yours, at this time*.
//
// ⛔ THIS BRIDGE ACCUMULATES AND SERVES. THE PEER ASKS AND STORES. We hold a registered peer's feed
// and are NOT a writer on it, so we never write their pointer or their segments. This is a buffer
// that gets drained, not an archive — see OBSERVED-INTERACTIONS.md §9.
//
// ⚠ WHICH IS WHY THE CODEC IS NOT HERE. `favela-web/lib/interaction-codec.js` packs 50,000
// interactions into 0.50 MB by interning actors and delta-encoding time, and that earns its place
// where data is kept for ever — on the PEER. Compressing a buffer that is drained and pruned would be
// symmetry for its own sake, and it would make this file harder to read at a debugger.
//
// ⚠ M1 SCOPE: append-only JSONL, matching followers.js and inbound-store.js rather than inventing a
// third storage pattern for the same lifetime of data. A row is ~120 bytes and this is a serving
// buffer, so the size that matters is bounded by the cap below, not by the network's enthusiasm.
//
// ⛔ SCOPED BY RECIPIENT, ALWAYS. One store serves every registered identity on this bridge, and an
// observation belongs to exactly one of them. `recipient` is the field added to the inbound store on
// 2026-09-15 for precisely this reason: without it a per-peer read is the only thing standing between
// one identity's social graph and another's, and that is too much weight for a transport check.

import fs from 'bare-fs'
import { dirname, join } from 'bare-path'

// Kinds, as small integers because they are written once per row. Tombstones are their own kind
// rather than a flag: `interaction.md` §4.3 makes reversal positive evidence everywhere else, because
// absence is ambiguous in a replicated log and "they un-liked" must not read as "we never saw it".
export const KIND = { LIKE: 0, REPOST: 1, FOLLOW: 2, REPLY: 3, UNDO: 4 }

// Bounded, for the same reason inbound-store.js is: whatever we accept, somebody else decides how
// much of it there is. A serving buffer that grows without limit is a disk-exhaustion bug with a
// signature check in front of it.
export const DEFAULT_CAP = 20_000

export class ObservationStore {
  constructor ({ path, cap = DEFAULT_CAP } = {}) {
    if (!path) throw new Error('ObservationStore: a path is required')
    this._path = path
    this._cap = Math.max(1, cap)
    this._rows = null
    this._seq = 0
  }

  _load () {
    if (this._rows) return
    this._rows = []
    let raw = ''
    try { raw = fs.readFileSync(this._path, 'utf8') } catch { raw = '' }
    for (const line of raw.split('\n')) {
      if (!line.trim()) continue
      // ⚠ A single corrupt line must not cost the whole file. An append-only log written by a process
      // that can be killed at any moment will eventually have a torn tail.
      try {
        const row = JSON.parse(line)
        if (row && typeof row.seq === 'number') {
          this._rows.push(row)
          if (row.seq > this._seq) this._seq = row.seq
        }
      } catch { /* torn or partial — skip it, keep the rest */ }
    }
  }

  _flush () {
    try {
      fs.mkdirSync(dirname(this._path), { recursive: true })
      fs.writeFileSync(this._path, this._rows.map((r) => JSON.stringify(r)).join('\n') + '\n')
    } catch { /* a buffer that cannot be persisted still serves from memory this run */ }
  }

  /**
   * Record one observation. Returns the row, or null when it names nothing we can serve.
   *
   * ⛔ REFUSED RATHER THAN STORED when there is no recipient or no actor: a row nobody can be given
   * and nobody can be attributed to is not a weaker observation, it is noise that will be read later
   * as data.
   */
  note ({ recipient, subject = null, actor, kind = KIND.LIKE, at = Date.now(), id = null } = {}) {
    if (typeof recipient !== 'string' || !recipient) return null
    if (typeof actor !== 'string' || !actor) return null
    this._load()

    // ⚠ DEDUPED BY id WHEN THERE IS ONE. A remote server retrying delivery is not a second like, and
    // the inbox already treats a repeat as a duplicate rather than an event.
    if (id && this._rows.some((r) => r.id === id)) return null

    const row = {
      seq: ++this._seq,
      recipient,
      subject,
      actor,
      kind: (kind | 0),
      at: Number(at) || Date.now(),
      id: id || null
    }
    this._rows.push(row)
    // Oldest first out: a peer that has been away for longer than the cap re-collects from whatever
    // survives, and the alternative — refusing new rows when full — loses the ones we just learned.
    if (this._rows.length > this._cap) this._rows = this._rows.slice(this._rows.length - this._cap)
    this._flush()
    return row
  }

  /**
   * What one peer has not collected yet.
   *
   * ⛔ `recipient` IS REQUIRED and there is deliberately no "everything" call. A store that could
   * return every identity's observations would make the door's authorisation the only thing standing
   * between one peer's social graph and another's.
   */
  list ({ recipient, since = 0, limit = 500 } = {}) {
    if (typeof recipient !== 'string' || !recipient) return { rows: [], cursor: since || 0, more: false }
    this._load()
    const after = Number(since) || 0
    const mine = this._rows.filter((r) => r.recipient === recipient && r.seq > after)
    const n = Math.max(1, Math.min(Number(limit) || 500, 1000))
    const rows = mine.slice(0, n)
    return {
      rows,
      // ⚠ The cursor advances only over rows actually returned. Advancing it past a page we did not
      // send would silently skip observations, which is the failure this whole area keeps producing.
      cursor: rows.length ? rows[rows.length - 1].seq : after,
      more: mine.length > rows.length
    }
  }

  /** How many are held for one peer — the cheap answer, with no rows moved. */
  count ({ recipient } = {}) {
    this._load()
    return this._rows.filter((r) => !recipient || r.recipient === recipient).length
  }
}

export default { ObservationStore, KIND, DEFAULT_CAP }