// 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 }