OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / followers.js

73 lines · javascript ref:
// followers.js — the follower collection.
//
// ⚠ M1 SCOPE: a JSON file. DESIGN.md §3 says this state belongs in HyperDB (the delivery queue
// needs secondary indexes by host/status/next-retry, which a flat file cannot give), and §7 says
// it eventually lives on the virtual-ap-device so it replicates across the user's own devices.
// This exists so M1 can prove the federation loop without also building the storage layer —
// replace it before there is anything worth losing.
//
// Followers are the one piece of inbox state that is NOT reconstructible: if we lose the list we
// stop delivering to people who still believe they follow us, and nothing tells them. Treat it as
// durable even while the backend is this crude.

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

export class FollowerStore {
  constructor (path) {
    this._path = path
    this._set = new Set()
    this._loaded = false
  }

  _load () {
    if (this._loaded) return
    try {
      const raw = fs.readFileSync(this._path, 'utf8')
      for (const uri of JSON.parse(raw)) this._set.add(uri)
    } catch {
      // absent or unreadable: start empty. A corrupt file must not stop the daemon booting —
      // but it IS logged by the caller, because silently starting with zero followers is the
      // failure mode that loses people quietly.
    }
    this._loaded = true
  }

  _flush () {
    fs.mkdirSync(dirname(this._path), { recursive: true })
    // write-then-rename so a crash mid-write can't truncate the list to nothing
    const tmp = this._path + '.tmp'
    fs.writeFileSync(tmp, JSON.stringify([...this._set], null, 2))
    fs.renameSync(tmp, this._path)
  }

  async add (uri) {
    this._load()
    if (this._set.has(uri)) return false
    this._set.add(uri)
    this._flush()
    return true
  }

  async remove (uri) {
    this._load()
    if (!this._set.delete(uri)) return false
    this._flush()
    return true
  }

  async list () { this._load(); return [...this._set] }
  async has (uri) { this._load(); return this._set.has(uri) }
  async count () { this._load(); return this._set.size }
}

// In-memory variant for tests — same interface, no disk.
export class MemoryFollowerStore {
  constructor (initial = []) { this._set = new Set(initial) }
  async add (uri) { const had = this._set.has(uri); this._set.add(uri); return !had }
  async remove (uri) { return this._set.delete(uri) }
  async list () { return [...this._set] }
  async has (uri) { return this._set.has(uri) }
  async count () { return this._set.size }
}