import test from 'brittle'
import { tmpStore } from './helpers.js'
import { FileIndex } from '../lib/file-index.js'
// ── File entries ──────────────────────────────────────────────
test('putFile + getFile', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
await index.putFile('/docs/report.pdf', {
contentHash: 'abc123',
size: 524288,
mtime: 1712345678
})
const file = await index.getFile('/docs/report.pdf')
t.ok(file)
t.is(file.contentHash, 'abc123')
t.is(file.size, 524288)
t.is(file.mtime, 1712345678)
t.is(file.hashing, false)
t.is(file.executable, false)
t.is(file.linkname, null)
await index.close()
})
test('putFile with hashing flag', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
await index.putFile('/large.bin', {
contentHash: null,
size: 200000000000,
mtime: 1712345678,
hashing: true
})
const file = await index.getFile('/large.bin')
t.is(file.contentHash, null)
t.is(file.hashing, true)
await index.close()
})
test('putFile overwrites existing entry', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
await index.putFile('/file.txt', { contentHash: 'old', size: 100, mtime: 1000 })
await index.putFile('/file.txt', { contentHash: 'new', size: 200, mtime: 2000 })
const file = await index.getFile('/file.txt')
t.is(file.contentHash, 'new')
t.is(file.size, 200)
t.is(file.mtime, 2000)
await index.close()
})
test('getFile returns null for missing', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
t.is(await index.getFile('/nope'), null)
await index.close()
})
test('hasFile returns true/false', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
t.is(await index.hasFile('/missing'), false)
await index.putFile('/exists', { contentHash: 'x', size: 1, mtime: 1 })
t.is(await index.hasFile('/exists'), true)
await index.close()
})
test('delFile removes file and its chunk map', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
await index.putFile('/delete-me', { contentHash: 'x', size: 1, mtime: 1 })
await index.putChunkMap('/delete-me', [{ hash: 'h1', offset: 0, length: 100 }])
t.is(await index.hasFile('/delete-me'), true)
t.is(await index.hasChunkMap('/delete-me'), true)
await index.delFile('/delete-me')
t.is(await index.hasFile('/delete-me'), false)
t.is(await index.hasChunkMap('/delete-me'), false)
await index.close()
})
test('listFiles returns all files', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
await index.putFile('/docs/a.txt', { contentHash: 'h1', size: 100, mtime: 1 })
await index.putFile('/docs/b.txt', { contentHash: 'h2', size: 200, mtime: 2 })
await index.putFile('/images/pic.jpg', { contentHash: 'h3', size: 300, mtime: 3 })
const all = await index.listFiles()
t.is(all.length, 3)
const docs = await index.listFiles('/docs/')
t.is(docs.length, 2)
t.is(docs[0].path, '/docs/a.txt')
t.is(docs[1].path, '/docs/b.txt')
await index.close()
})
// ── Chunk maps ────────────────────────────────────────────────
test('putChunkMap + getChunkMap', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
const chunks = [
{ hash: 'c1', offset: 0, length: 16384 },
{ hash: 'c2', offset: 16384, length: 12288 },
{ hash: 'c3', offset: 28672, length: 8192 }
]
await index.putChunkMap('/docs/report.pdf', chunks)
const result = await index.getChunkMap('/docs/report.pdf')
t.ok(result)
t.is(result.length, 3)
t.is(result[0].hash, 'c1')
t.is(result[0].offset, 0)
t.is(result[0].length, 16384)
t.is(result[2].hash, 'c3')
t.is(result[2].offset, 28672)
await index.close()
})
test('getChunkMap returns null for missing', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
t.is(await index.getChunkMap('/nope'), null)
await index.close()
})
test('hasChunkMap returns true/false', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
t.is(await index.hasChunkMap('/nope'), false)
await index.putChunkMap('/file', [{ hash: 'h', offset: 0, length: 100 }])
t.is(await index.hasChunkMap('/file'), true)
await index.close()
})
test('delChunkMap removes chunk map only', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
await index.putFile('/file', { contentHash: 'x', size: 1, mtime: 1 })
await index.putChunkMap('/file', [{ hash: 'h', offset: 0, length: 100 }])
await index.delChunkMap('/file')
t.is(await index.hasFile('/file'), true, 'file entry still exists')
t.is(await index.hasChunkMap('/file'), false, 'chunk map removed')
await index.close()
})
// ── Sync state ────────────────────────────────────────────────
test('putSyncState + getSyncState', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
await index.putSyncState('peer1', '/docs/report.pdf', {
lastSeq: 42,
lastHash: 'abc123'
})
const state = await index.getSyncState('peer1', '/docs/report.pdf')
t.ok(state)
t.is(state.lastSeq, 42)
t.is(state.lastHash, 'abc123')
await index.close()
})
test('getSyncState returns null for missing', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
t.is(await index.getSyncState('peer1', '/nope'), null)
await index.close()
})
test('listSyncStates returns all states for a peer', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
await index.putSyncState('peer1', '/a.txt', { lastSeq: 1, lastHash: 'h1' })
await index.putSyncState('peer1', '/b.txt', { lastSeq: 2, lastHash: 'h2' })
await index.putSyncState('peer2', '/a.txt', { lastSeq: 5, lastHash: 'h5' })
const peer1States = await index.listSyncStates('peer1')
t.is(peer1States.length, 2)
t.is(peer1States[0].path, '/a.txt')
t.is(peer1States[0].lastSeq, 1)
t.is(peer1States[1].path, '/b.txt')
const peer2States = await index.listSyncStates('peer2')
t.is(peer2States.length, 1)
await index.close()
})
test('delSyncState removes specific state', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
await index.putSyncState('peer1', '/a.txt', { lastSeq: 1, lastHash: 'h1' })
await index.putSyncState('peer1', '/b.txt', { lastSeq: 2, lastHash: 'h2' })
await index.delSyncState('peer1', '/a.txt')
t.is(await index.getSyncState('peer1', '/a.txt'), null)
t.ok(await index.getSyncState('peer1', '/b.txt'))
await index.close()
})
// ── Sync config ───────────────────────────────────────────────
test('putSyncConfig + getSyncConfig', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
const config = {
folders: [
{ path: '/docs/', strategy: 'prompt', quota: null, peers: [] },
{ path: '/media/', strategy: 'newest-wins', quota: '50GB', peers: ['peer1'] }
]
}
await index.putSyncConfig(config)
const result = await index.getSyncConfig()
t.ok(result)
t.is(result.folders.length, 2)
t.is(result.folders[0].path, '/docs/')
t.is(result.folders[0].strategy, 'prompt')
t.is(result.folders[1].quota, '50GB')
await index.close()
})
test('getSyncConfig returns null when not set', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
t.is(await index.getSyncConfig(), null)
await index.close()
})
// ── Stats ─────────────────────────────────────────────────────
test('stats returns correct counts', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
await index.putFile('/a', { contentHash: 'h1', size: 100, mtime: 1 })
await index.putFile('/b', { contentHash: 'h2', size: 200, mtime: 2 })
await index.putChunkMap('/a', [{ hash: 'c1', offset: 0, length: 100 }])
await index.putSyncState('peer1', '/a', { lastSeq: 1, lastHash: 'h1' })
const s = await index.stats()
t.is(s.fileCount, 2)
t.is(s.chunkMapCount, 1)
t.is(s.syncStateCount, 1)
await index.close()
})
// ── No blob storage ───────────────────────────────────────────
test('file index stores no content data', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
await index.putFile('/file.bin', {
contentHash: 'abc123',
size: 1048576,
mtime: 1712345678
})
await index.putChunkMap('/file.bin', [
{ hash: 'c1', offset: 0, length: 16384 },
{ hash: 'c2', offset: 16384, length: 16384 }
])
// Verify: no 'data' field anywhere
const file = await index.getFile('/file.bin')
t.absent(file.data, 'no data field on file entry')
const chunks = await index.getChunkMap('/file.bin')
for (const c of chunks) {
t.absent(c.data, 'no data field on chunk map entry')
}
await index.close()
})
// ── Tree entries ──────────────────────────────────────────────
function fakeHash (byte) {
return Buffer.alloc(32, byte).toString('hex')
}
function makeTree (size, entries = []) {
return { entries, size }
}
test('putTree + getTree + hasTree', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
const h = fakeHash(0xaa)
const tree = makeTree(100, [
{ kind: 0, exec: 0, name: 'foo.js', childHash: fakeHash(0x01), size: 50 },
{ kind: 0, exec: 0, name: 'bar.js', childHash: fakeHash(0x02), size: 50 }
])
t.absent(await index.hasTree(h))
await index.putTree(h, tree)
t.ok(await index.hasTree(h))
const got = await index.getTree(h)
t.ok(got)
t.is(got.size, 100)
t.is(got.entries.length, 2)
t.is(got.entries[0].name, 'foo.js')
await index.close()
})
test('getTree returns null for missing hash', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
t.is(await index.getTree(fakeHash(0xff)), null)
await index.close()
})
test('delTree removes the tree', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
const h = fakeHash(0x11)
await index.putTree(h, makeTree(0))
t.ok(await index.hasTree(h))
await index.delTree(h)
t.absent(await index.hasTree(h))
await index.close()
})
test('putTree is idempotent (same hash overwrites with same content)', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
const h = fakeHash(0x22)
await index.putTree(h, makeTree(50, []))
await index.putTree(h, makeTree(50, []))
t.ok(await index.hasTree(h))
await index.close()
})
test('putTreePath + getTreePath', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
await index.putTreePath('/node_modules/hyper-svc', fakeHash(0x33))
t.is(await index.getTreePath('/node_modules/hyper-svc'), fakeHash(0x33))
t.is(await index.getTreePath('/does/not/exist'), null)
// Rewriting the path points to a new hash
await index.putTreePath('/node_modules/hyper-svc', fakeHash(0x44))
t.is(await index.getTreePath('/node_modules/hyper-svc'), fakeHash(0x44))
await index.close()
})
test('delTreePath removes path mapping without touching the tree', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
const h = fakeHash(0x55)
await index.putTree(h, makeTree(10))
await index.putTreePath('/some/path', h)
await index.delTreePath('/some/path')
t.is(await index.getTreePath('/some/path'), null)
t.ok(await index.hasTree(h), 'tree itself still present after path deletion')
await index.close()
})
test('listTrees enumerates stored trees', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
await index.putTree(fakeHash(0x01), makeTree(100, [{ kind: 0, exec: 0, name: 'a', childHash: fakeHash(0x10), size: 10 }]))
await index.putTree(fakeHash(0x02), makeTree(200, [
{ kind: 0, exec: 0, name: 'b', childHash: fakeHash(0x20), size: 20 },
{ kind: 0, exec: 0, name: 'c', childHash: fakeHash(0x30), size: 30 }
]))
const all = await index.listTrees()
t.is(all.length, 2)
// Sorted by hash (Hyperbee key order)
t.is(all[0].hash, fakeHash(0x01))
t.is(all[0].entryCount, 1)
t.is(all[0].size, 100)
t.is(all[1].hash, fakeHash(0x02))
t.is(all[1].entryCount, 2)
const limited = await index.listTrees({ limit: 1 })
t.is(limited.length, 1)
await index.close()
})
test('stats includes tree and treePath counts', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
await index.putFile('/a.txt', { contentHash: 'f1', size: 10, mtime: 1 })
await index.putTree(fakeHash(0x01), makeTree(10))
await index.putTree(fakeHash(0x02), makeTree(20))
await index.putTreePath('/a', fakeHash(0x01))
const s = await index.stats()
t.is(s.fileCount, 1)
t.is(s.treeCount, 2)
t.is(s.treePathCount, 1)
await index.close()
})
test('tree storage does not interfere with existing file entries', async (t) => {
const index = new FileIndex(tmpStore())
await index.ready()
// File with name that could plausibly overlap with a tree path
await index.putFile('/foo', { contentHash: 'h1', size: 100, mtime: 1000 })
// Tree at a hash — different key namespace
await index.putTree(fakeHash(0x88), makeTree(50))
// Treepath for the same path
await index.putTreePath('/foo', fakeHash(0x88))
// All three coexist
const file = await index.getFile('/foo')
t.ok(file)
t.is(file.contentHash, 'h1')
const tree = await index.getTree(fakeHash(0x88))
t.ok(tree)
const tp = await index.getTreePath('/foo')
t.is(tp, fakeHash(0x88))
await index.close()
})