import test from 'brittle'
import { tmpStore } from './helpers.js'
import { FileIndex } from '../lib/file-index.js'
import { SyncEngine } from '../lib/sync-engine.js'
async function setup () {
const store = tmpStore('sync')
const index = new FileIndex(store)
await index.ready()
const engine = new SyncEngine(index, store)
await engine.ready()
return { store, index, engine }
}
// ── Change logging ────────────────────────────────────────────
test('logPut appends to feed', async (t) => {
const { engine } = await setup()
await engine.logPut('/docs/a.txt', { contentHash: 'h1', size: 100, mtime: 1000 })
await engine.logPut('/docs/b.txt', { contentHash: 'h2', size: 200, mtime: 2000 })
t.is(engine.feed.length, 2)
const entry = await engine.feed.get(0)
t.is(entry.op, 0) // OP_PUT
t.is(entry.path, '/docs/a.txt')
t.is(entry.hash, 'h1')
t.is(entry.size, 100)
await engine.close()
})
test('logDel appends deletion to feed', async (t) => {
const { engine } = await setup()
await engine.logPut('/file.txt', { contentHash: 'h1', size: 100, mtime: 1000 })
await engine.logDel('/file.txt')
t.is(engine.feed.length, 2)
const del = await engine.feed.get(1)
t.is(del.op, 1) // OP_DEL
t.is(del.path, '/file.txt')
t.is(del.hash, null)
await engine.close()
})
test('feedKey returns hex string', async (t) => {
const { engine } = await setup()
t.is(typeof engine.feedKey, 'string')
t.is(engine.feedKey.length, 64)
await engine.close()
})
// ── Feed diffing ──────────────────────────────────────────────
test('getChanges returns entries since seq', async (t) => {
const { engine } = await setup()
await engine.logPut('/a', { contentHash: 'h1', size: 1, mtime: 1 })
await engine.logPut('/b', { contentHash: 'h2', size: 2, mtime: 2 })
await engine.logPut('/c', { contentHash: 'h3', size: 3, mtime: 3 })
const all = await engine.getChanges(engine.feed, -1)
t.is(all.length, 3)
const since1 = await engine.getChanges(engine.feed, 0)
t.is(since1.length, 2)
t.is(since1[0].path, '/b')
t.is(since1[1].path, '/c')
const since2 = await engine.getChanges(engine.feed, 1)
t.is(since2.length, 1)
t.is(since2[0].path, '/c')
await engine.close()
})
test('buildChangeMap keeps latest per path', async (t) => {
const { engine } = await setup()
await engine.logPut('/a', { contentHash: 'old', size: 1, mtime: 1 })
await engine.logPut('/b', { contentHash: 'h2', size: 2, mtime: 2 })
await engine.logPut('/a', { contentHash: 'new', size: 3, mtime: 3 })
const changes = await engine.getChanges(engine.feed, -1)
const map = engine.buildChangeMap(changes)
t.is(map.size, 2)
t.is(map.get('/a').hash, 'new', 'latest version wins')
t.is(map.get('/b').hash, 'h2')
await engine.close()
})
// ── Peer feeds ────────────────────────────────────────────────
test('addPeerFeed + getPeerFeed', async (t) => {
const { engine } = await setup()
const peerStore = tmpStore('peer')
const peerCore = peerStore.get({ name: 'sync-feed', valueEncoding: 'json' })
await peerCore.ready()
const peerKey = peerCore.key.toString('hex')
const feed = engine.addPeerFeed(peerKey)
t.ok(feed)
t.is(engine.getPeerFeed(peerKey), feed, 'same instance returned')
t.is(engine.getPeerFeed('nonexistent'), null)
await engine.close()
})
// ── Sync diff ─────────────────────────────────────────────────
test('diff — only they have a file → pull', async (t) => {
// Create peer with their own store and feed
const peerStore = tmpStore('peer')
const peerFeed = peerStore.get({ name: 'peer-feed', valueEncoding: 'json' })
await peerFeed.ready()
await peerFeed.append({ op: 0, path: '/their-file.txt', hash: 'hh1', size: 100, mtime: 1000, timestamp: Date.now() })
// Set up local engine with empty feed
const { engine } = await setup()
const peerKey = peerFeed.key.toString('hex')
engine.addPeerFeed(peerFeed.key)
// Need to replicate the peer feed into our corestore
// Simpler: directly use the peer feed object
engine._peerFeeds.set(peerKey, peerFeed)
const actions = await engine.diff(peerKey)
const pullActions = actions.filter(a => a.action === 'pull')
t.is(pullActions.length, 1)
t.is(pullActions[0].path, '/their-file.txt')
await engine.close()
})
test('diff — only I have a file → push', async (t) => {
const peerStore = tmpStore('peer')
const peerFeed = peerStore.get({ name: 'peer-feed', valueEncoding: 'json' })
await peerFeed.ready()
const { engine } = await setup()
await engine.logPut('/my-file.txt', { contentHash: 'mh1', size: 50, mtime: 500 })
const peerKey = peerFeed.key.toString('hex')
engine._peerFeeds.set(peerKey, peerFeed)
const actions = await engine.diff(peerKey)
const pushActions = actions.filter(a => a.action === 'push')
t.is(pushActions.length, 1)
t.is(pushActions[0].path, '/my-file.txt')
await engine.close()
})
test('diff — same hash both sides → skip', async (t) => {
const peerStore = tmpStore('peer')
const peerFeed = peerStore.get({ name: 'peer-feed', valueEncoding: 'json' })
await peerFeed.ready()
await peerFeed.append({ op: 0, path: '/shared.txt', hash: 'same', size: 100, mtime: 1000, timestamp: Date.now() })
const { engine } = await setup()
await engine.logPut('/shared.txt', { contentHash: 'same', size: 100, mtime: 1000 })
const peerKey = peerFeed.key.toString('hex')
engine._peerFeeds.set(peerKey, peerFeed)
const actions = await engine.diff(peerKey)
const skipActions = actions.filter(a => a.action === 'skip')
t.is(skipActions.length, 1)
t.is(skipActions[0].path, '/shared.txt')
await engine.close()
})
test('diff — both changed with ancestor → conflict', async (t) => {
const peerStore = tmpStore('peer')
const peerFeed = peerStore.get({ name: 'peer-feed', valueEncoding: 'json' })
await peerFeed.ready()
await peerFeed.append({ op: 0, path: '/doc.txt', hash: 'their-version', size: 200, mtime: 2000, timestamp: Date.now() })
const { engine, index } = await setup()
await engine.logPut('/doc.txt', { contentHash: 'my-version', size: 150, mtime: 1500 })
const peerKey = peerFeed.key.toString('hex')
engine._peerFeeds.set(peerKey, peerFeed)
// Set ancestor — both sides were at 'original' before
await index.putSyncState(peerKey, '/doc.txt', { lastSeq: 0, lastHash: 'original' })
const actions = await engine.diff(peerKey)
const conflicts = actions.filter(a => a.action === 'conflict')
t.is(conflicts.length, 1)
t.is(conflicts[0].path, '/doc.txt')
await engine.close()
})
test('diff — they changed, I have ancestor → pull', async (t) => {
const peerStore = tmpStore('peer')
const peerFeed = peerStore.get({ name: 'peer-feed', valueEncoding: 'json' })
await peerFeed.ready()
await peerFeed.append({ op: 0, path: '/doc.txt', hash: 'updated', size: 200, mtime: 2000, timestamp: Date.now() })
const { engine, index } = await setup()
await engine.logPut('/doc.txt', { contentHash: 'original', size: 100, mtime: 1000 })
const peerKey = peerFeed.key.toString('hex')
engine._peerFeeds.set(peerKey, peerFeed)
// My hash matches ancestor — only they changed
await index.putSyncState(peerKey, '/doc.txt', { lastSeq: 0, lastHash: 'original' })
const actions = await engine.diff(peerKey)
const pulls = actions.filter(a => a.action === 'pull')
t.is(pulls.length, 1)
await engine.close()
})
test('diff — I changed, they have ancestor → push', async (t) => {
const peerStore = tmpStore('peer')
const peerFeed = peerStore.get({ name: 'peer-feed', valueEncoding: 'json' })
await peerFeed.ready()
await peerFeed.append({ op: 0, path: '/doc.txt', hash: 'original', size: 100, mtime: 1000, timestamp: Date.now() })
const { engine, index } = await setup()
await engine.logPut('/doc.txt', { contentHash: 'updated', size: 200, mtime: 2000 })
const peerKey = peerFeed.key.toString('hex')
engine._peerFeeds.set(peerKey, peerFeed)
// Their hash matches ancestor — only I changed
await index.putSyncState(peerKey, '/doc.txt', { lastSeq: 0, lastHash: 'original' })
const actions = await engine.diff(peerKey)
const pushes = actions.filter(a => a.action === 'push')
t.is(pushes.length, 1)
await engine.close()
})
// ── Conflict resolution ───────────────────────────────────────
test('resolveConflict — mine-wins', async (t) => {
const { engine } = await setup()
const conflict = { path: '/doc.txt', local: { seq: 5, hash: 'mine' }, remote: { seq: 3, hash: 'theirs' } }
const result = engine.resolveConflict(conflict, 'mine-wins')
t.is(result.action, 'push')
t.is(result.winner, 'local')
await engine.close()
})
test('resolveConflict — theirs-wins', async (t) => {
const { engine } = await setup()
const conflict = { path: '/doc.txt', local: { seq: 5, hash: 'mine' }, remote: { seq: 3, hash: 'theirs' } }
const result = engine.resolveConflict(conflict, 'theirs-wins')
t.is(result.action, 'pull')
t.is(result.winner, 'remote')
await engine.close()
})
test('resolveConflict — newest-wins uses seq number', async (t) => {
const { engine } = await setup()
const conflict1 = { path: '/a', local: { seq: 5 }, remote: { seq: 3 } }
t.is(engine.resolveConflict(conflict1, 'newest-wins').winner, 'local')
const conflict2 = { path: '/b', local: { seq: 2 }, remote: { seq: 7 } }
t.is(engine.resolveConflict(conflict2, 'newest-wins').winner, 'remote')
await engine.close()
})
test('resolveConflict — keep-both', async (t) => {
const { engine } = await setup()
const conflict = { path: '/doc.txt', local: { seq: 5 }, remote: { seq: 3 } }
const result = engine.resolveConflict(conflict, 'keep-both')
t.is(result.action, 'keep-both')
await engine.close()
})
test('resolveConflict — prompt adds to conflict queue', async (t) => {
const { engine } = await setup()
const conflict = { path: '/doc.txt', local: { seq: 5 }, remote: { seq: 3 } }
engine.resolveConflict(conflict, 'prompt')
t.is(engine.conflicts.length, 1)
t.is(engine.conflicts[0].path, '/doc.txt')
await engine.close()
})
test('resolveQueued removes from queue', async (t) => {
const { engine } = await setup()
const conflict = { path: '/doc.txt', local: { seq: 5 }, remote: { seq: 3 } }
engine.resolveConflict(conflict, 'prompt')
t.is(engine.conflicts.length, 1)
const result = engine.resolveQueued('/doc.txt', 'overwrite')
t.is(result.action, 'pull')
t.is(engine.conflicts.length, 0)
await engine.close()
})
test('resolveQueued returns null for unknown path', async (t) => {
const { engine } = await setup()
t.is(engine.resolveQueued('/nope', 'overwrite'), null)
await engine.close()
})
// ── markSynced ────────────────────────────────────────────────
test('markSynced updates file index sync state', async (t) => {
const { engine, index } = await setup()
await engine.markSynced('peer1', '/doc.txt', 'abc123', 42)
const state = await index.getSyncState('peer1', '/doc.txt')
t.ok(state)
t.is(state.lastHash, 'abc123')
t.is(state.lastSeq, 42)
await engine.close()
})