OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay / test / integration / sync-engine.test.js

347 lines · javascript ref:
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()
})