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