OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / test / record-store.test.js

182 lines · javascript ref:
// The record store — markdown bytes on disk, content-addressed, one file per record.
//
// Four invariants are carried over from the atproto stack DELIBERATELY, because each was paid for:
//
//  1. getRaw returns the bytes that put wrote, never a re-encode. Re-encoding someone's record is
//     what destroyed two of wj62's posts (dropped `langs`, bytes stopped hashing to their pointer).
//  2. ensureLocal VERIFIES local bytes against the expected oid. A local hit that skipped this is
//     what let two co-owners hold "identical" record sets and publish different content.
//  3. On a verification mismatch it returns null — never the stale copy. Stalling loudly beats
//     advancing with content nobody agreed on.
//  4. Deletion of the bytes is not deletion of the record. Absence is ambiguous in a replicated
//     system; the tombstone lives in the log, and this layer only ever drops bytes.

import test from 'brittle'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import b4a from 'b4a'
import { serialize } from 'favela-markdown/lib/record.js'
import { RecordStore } from '../lib/record-store.js'

const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-peer-'))
const rec = (body, frontmatter = { createdAt: '2026-09-06T00:00:00Z' }) => ({ frontmatter, body })

test('a record is stored as markdown bytes at collection/name.md', async (t) => {
  const root = tmp()
  const store = new RecordStore(root)
  const r = rec('hello **world**')

  await store.put('post', 'a1', r)

  const p = join(root, 'records', 'post', 'a1.md')
  t.ok(fs.existsSync(p), 'one file per record, .md on disk')
  t.alike(fs.readFileSync(p), b4a.from(serialize(r), 'utf8'), 'bytes are the codec output VERBATIM')
})

test('getRaw returns the stored bytes and the parsed record', async (t) => {
  const store = new RecordStore(tmp())
  const r = rec('a body with an é and an — em dash')

  await store.put('post', 'a1', r)
  const raw = await store.getRaw('post', 'a1')

  t.alike(raw.bytes, b4a.from(serialize(r), 'utf8'), 'never a re-encode (invariant 1)')
  // The codec pins exactly one trailing newline, so what comes back is the CANONICAL body, not
  // the input verbatim. That is the law worth asserting — serialize(parse(x)) === x — because
  // "no trailing newline" and "two" are both states an editor produces and neither is stable.
  t.is(raw.record.body, r.body + '\n', 'body round-trips to canonical form')
  t.is(serialize(raw.record), serialize(r), 'and re-serializes to identical bytes')
  t.alike(raw.record.frontmatter, r.frontmatter, 'frontmatter round-trips')
})

test('get and list read back in byte order', async (t) => {
  const store = new RecordStore(tmp())
  await store.put('post', 'b', rec('second'))
  await store.put('post', 'a', rec('first'))

  t.is((await store.get('post', 'a')).body, 'first\n')
  const seen = []
  for await (const x of store.list('post')) seen.push(x)
  t.alike(seen.map((x) => x.name), ['a', 'b'], 'byte order, extension stripped')
})

test('all() spans collections in byte order', async (t) => {
  const store = new RecordStore(tmp())
  await store.put('post', 'b1', rec('post'))
  await store.put('like', 'a1', rec(''))

  const all = await store.all()
  t.alike(all.map((x) => x.key), ['like/a1', 'post/b1'], 'stable order across collections')
})

test('a missing record reads as null, not a throw', async (t) => {
  const store = new RecordStore(tmp())
  t.is(await store.get('post', 'nope'), null)
  t.is(await store.getRaw('post', 'nope'), null)
})

test('del drops the bytes only — the tombstone is the log\'s job (invariant 4)', async (t) => {
  const root = tmp()
  const store = new RecordStore(root)
  await store.put('post', 'a1', rec('x'))

  await store.del('post', 'a1')
  t.absent(fs.existsSync(join(root, 'records', 'post', 'a1.md')), 'bytes gone')
  t.is(await store.get('post', 'a1'), null, 'reads as absent')
})

test('⛔ ensureLocal VERIFIES bytes against the expected oid (invariants 2 and 3)', async (t) => {
  const root = tmp()
  const hashes = new Map()
  // A stand-in overlay: content hash = the bytes themselves, so a mismatch is easy to construct.
  const overlay = {
    async registerFile (_p, disk) { const b = fs.readFileSync(disk); const h = b4a.toString(b, 'hex'); hashes.set(disk, h); return { contentHash: h, size: b.length } },
    async hashFile (p) { try { const b = fs.readFileSync(p); return { contentHash: b4a.toString(b, 'hex'), size: b.length } } catch { return null } },
    async fetchFile () { return null }, // no peer has it
    async unregisterFile () {}
  }
  const store = new RecordStore(root, { overlay })
  const { oid } = await store.put('post', 'a1', rec('the agreed content'))

  t.is((await store.ensureLocal('post', 'a1', oid)).body, 'the agreed content\n', 'verified local hit')

  // Now the local copy is superseded — the same name, different bytes than the pointer names.
  await store.put('post', 'a1', rec('a divergent local copy'))
  t.is(await store.ensureLocal('post', 'a1', oid), null,
    'a mismatch returns NULL and stalls loudly — never the stale copy')
})

// ⛔⛔ THE ONE THAT SHIPPED, and it is the reason a profile change never reached anybody.
//
// Invariant 3 says a mismatch must not return the stale copy. It does not say STOP — but that is
// what the code did: on a positive mismatch it returned null and never reached the fetch below,
// which was only reachable when there was no local file at all.
//
// Posts never hit it, because a post is written under a fresh name and there is no local file to be
// stale. The PROFILE is the one record in the model that is MUTABLE and overwritten in place at the
// same (collection, name) — so the moment its author edited it, every holder's copy went stale, the
// pointer named bytes they would never go and get, and the record read as unresolved FOR EVER.
//
// Measured live 2026-09-08: ashatron set a bio and a new avatar; the pointer replicated to the
// bridge in seconds and the actor's `summary` and `icon` both went NULL — it lost the profile it
// already had. The new avatar's BYTES were fetchable through the same overlay at the same moment
// (200, 1377 bytes), which is what ruled out a transport problem and pointed here.
//
// ⚠ Verified AFTER the fetch too. `files.js` and `avatars.js` both shipped a bug where the fetch
// landed at `r.destPath` and the caller re-read its own path, quietly returning the old bytes
// (0.0.46). Re-hashing is what makes "fetched" mean "fetched the agreed content" rather than
// "something is on disk now".
test('⛔⛔ a STALE local copy is REFETCHED, not just refused', async (t) => {
  const root = tmp()
  // The peer that has the bytes: content hash = the bytes, so any content is addressable by them.
  const remote = new Map()
  const overlay = {
    async registerFile (_p, disk) { const b = fs.readFileSync(disk); const h = b4a.toString(b, 'hex'); remote.set(h, b); return { contentHash: h, size: b.length } },
    async hashFile (p) { try { const b = fs.readFileSync(p); return { contentHash: b4a.toString(b, 'hex'), size: b.length } } catch { return null } },
    async fetchFile (oid, { destPath } = {}) {
      const b = remote.get(oid)
      if (!b) return null
      fs.writeFileSync(destPath, b)
      return { contentHash: oid, size: b.length, destPath }
    },
    async unregisterFile () {}
  }
  const store = new RecordStore(root, { overlay })

  // What the group agreed on, registered with the overlay so a peer can serve it.
  const agreed = serialize(rec('the profile everyone agreed on'))
  const tmpPath = join(root, 'agreed.md')
  fs.writeFileSync(tmpPath, agreed)
  const { contentHash: agreedOid } = await overlay.registerFile('x', tmpPath)

  // This node holds an OLDER copy under the same name — exactly a profile before its author edited it.
  await store.put('profile', 'self', rec('the profile as it used to be'))
  t.not((await store.localOid('profile', 'self')).oid, agreedOid, 'genuinely stale')

  const got = await store.ensureLocal('profile', 'self', agreedOid)
  t.ok(got, 'resolves — a mutable record must not be permanently unreadable because it changed')
  t.is(got.body, 'the profile everyone agreed on\n', 'and it is the content the POINTER names')
  t.is((await store.localOid('profile', 'self')).oid, agreedOid, 'the disk copy converged too')
})

test('⛔ but content that does not hash to the pointer is still refused after a fetch', async (t) => {
  const root = tmp()
  const overlay = {
    async registerFile (_p, disk) { const b = fs.readFileSync(disk); return { contentHash: b4a.toString(b, 'hex'), size: b.length } },
    async hashFile (p) { try { const b = fs.readFileSync(p); return { contentHash: b4a.toString(b, 'hex'), size: b.length } } catch { return null } },
    // A peer that answers with the WRONG bytes. Claiming success is the dangerous case: the caller
    // would otherwise re-read its own path and publish whatever landed there.
    async fetchFile (oid, { destPath } = {}) {
      fs.writeFileSync(destPath, serialize(rec('not what was asked for')))
      return { contentHash: oid, size: 1, destPath }
    },
    async unregisterFile () {}
  }
  const store = new RecordStore(root, { overlay })
  await store.put('profile', 'self', rec('the profile as it used to be'))

  t.is(await store.ensureLocal('profile', 'self', 'ffff'.repeat(16)), null,
    'null — stalling beats advancing with content nobody agreed on')
})