OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / test / log.test.js

142 lines · javascript ref:
// RecordLog — the convergent half. Names and pointers live here; bytes live in the store.
//
// The three states this layer exists to keep apart, because conflating any two of them has already
// cost us a production incident:
//
//   ABSENT      no entry. "I have never heard of this" — which in a replicated log ALSO means
//               "not replicated to me yet". Never assume deleted.
//   DELETED     a tombstone. Positive evidence that someone deleted it. Not the same as absent.
//   UNRESOLVED  a pointer we hold whose bytes we cannot obtain. The record exists and we cannot
//               read it. Not absent, not deleted — and a caller must stall rather than proceed.
//
// The KV is injected: a StateBaseV3/autobase in production, a Map in these tests. This layer must
// be provable without a swarm.

import test from 'brittle'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import { RecordStore } from '../lib/record-store.js'
import { RecordLog, ABSENT, DELETED, UNRESOLVED, OK } from '../lib/log.js'

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

// The smallest KV with the shape a linearized autobase view presents.
const memkv = () => {
  const m = new Map()
  return {
    m,
    async set (k, v) { m.set(k, v) },
    async get (k) { return m.has(k) ? m.get(k) : null },
    async del (k) { m.delete(k) },
    async list (prefix = '') {
      const out = {}
      for (const [k, v] of m) if (k.startsWith(prefix)) out[k] = v
      return out
    }
  }
}

const mkLog = () => {
  const kv = memkv()
  const store = new RecordStore(tmp())
  return { kv, store, log: new RecordLog(kv, store) }
}

test('put writes the bytes, then a pointer naming them', async (t) => {
  const { kv, log } = mkLog()
  await log.put('post', 'a1', rec('hello'))

  const ptr = await kv.get('post/a1')
  t.ok(ptr && typeof ptr.size === 'number', 'the pointer carries a size')
  t.ok('$oid' in ptr, 'and an $oid field, even when there is no overlay to fill it')
  t.is(Object.keys(ptr).sort().join(','), '$oid,size', 'a pointer is exactly {$oid,size} — nothing else')
})

test('get resolves a pointer back to the record', async (t) => {
  const { log } = mkLog()
  await log.put('post', 'a1', rec('hello'))
  t.is((await log.get('post', 'a1')).body, 'hello\n')
})

test('⛔ ABSENT and DELETED are different answers', async (t) => {
  const { log } = mkLog()
  await log.put('post', 'a1', rec('x'))
  await log.del('post', 'a1')

  t.is((await log.resolve('post', 'a1')).status, DELETED, 'a tombstone is positive evidence')
  t.is((await log.resolve('post', 'never')).status, ABSENT, 'no entry is NOT a deletion')
  t.is(await log.get('post', 'a1'), null, 'both read as null through get()')
  t.is(await log.get('post', 'never'), null)
})

test('⛔ UNRESOLVED is not DELETED — a pointer whose bytes we lack', async (t) => {
  const { kv, log } = mkLog()
  // A pointer converged from a peer; this node has no bytes and no overlay to fetch them.
  await kv.set('post/a1', { $oid: 'deadbeef', size: 12 })

  const r = await log.resolve('post', 'a1')
  t.is(r.status, UNRESOLVED, 'the record exists and we cannot read it')
  t.is(r.record, null, 'no record to hand back')
  t.ok(r.pointer && r.pointer.$oid === 'deadbeef', 'but we can say what we are missing')
})

test('⛔ an inline value is REFUSED at write, not merely discouraged', async (t) => {
  const { log } = mkLog()
  // Measured: a value with no $oid resolves for its author and is null on every other peer
  // (overlay-repo-store ensureLocal; loser-club, three records, that DID cannot be co-owned).
  // So the shape is rejected here rather than left as a rule someone has to remember.
  await t.exception(() => log.setPointer('post', 'a1', { frontmatter: {}, body: 'inline' }),
    'a record body in the log is not a pointer')
  await t.exception(() => log.setPointer('post', 'a1', { size: 3 }), 'no $oid is not a pointer')
})

test('del tombstones the pointer AND drops the bytes, in that order', async (t) => {
  const { kv, store, log } = mkLog()
  await log.put('post', 'a1', rec('x'))
  await log.del('post', 'a1')

  t.alike(await kv.get('post/a1'), { $deleted: true }, 'the tombstone replaces the pointer')
  t.is(await store.get('post', 'a1'), null, 'and the bytes are gone')
})

test('list skips tombstones and reserved keys', async (t) => {
  const { kv, log } = mkLog()
  await log.put('post', 'b1', rec('kept'))
  await log.put('post', 'a1', rec('gone'))
  await log.del('post', 'a1')
  await kv.set('__control__/head', { anything: true }) // reserved: never a record

  const got = await log.list()
  t.alike(got.map((x) => x.key), ['post/b1'], 'only live records, in byte order')
  t.is(got[0].record.body, 'kept\n')
})

test('list can scope to one collection', async (t) => {
  const { log } = mkLog()
  await log.put('post', 'a1', rec('p'))
  await log.put('like', 'a1', rec(''))

  t.alike((await log.list('post')).map((x) => x.key), ['post/a1'])
})

test('an unresolved pointer does not silently vanish from list', async (t) => {
  const { kv, log } = mkLog()
  await log.put('post', 'a1', rec('here'))
  await kv.set('post/b1', { $oid: 'missing', size: 9 })

  const live = await log.list()
  t.alike(live.map((x) => x.key), ['post/a1'], 'list() yields readable records only')

  const un = await log.unresolved()
  t.alike(un.map((x) => x.key), ['post/b1'], 'and unresolved() names what is missing, loudly')
})

test('a key with no separator is not a record', async (t) => {
  const { kv, log } = mkLog()
  await kv.set('__lease__', { held: true })
  t.alike(await log.list(), [], 'single-segment control keys never resolve as records')
  t.is((await log.resolve('post', 'a1')).status, ABSENT)
})