function generateSyncMessage(backend, syncState) {
  if (!backend) {
    throw new Error("generateSyncMessage called with no Automerge document")
  }
  if (!syncState) {
    throw new Error(
      "generateSyncMessage requires a syncState, which can be created with initSyncState()",
    )
  }

  let {
    sharedHeads,
    lastSentHeads,
    theirHeads,
    theirNeed,
    theirHave,
    sentHashes,
  } = syncState
  const ourHeads = Backend.getHeads(backend)

  // Hashes to explicitly request from the remote peer: any missing dependencies of unapplied
  // changes, and any of the remote peer's heads that we don't know about
  const ourNeed = Backend.getMissingDeps(backend, theirHeads || [])

  // There are two reasons why ourNeed may be nonempty: 1. we might be missing dependencies due to
  // Bloom filter false positives; 2. we might be missing heads that the other peer mentioned
  // because they (intentionally) only sent us a subset of changes. In case 1, we leave the `have`
  // field of the message empty because we just want to fill in the missing dependencies for now.
  // In case 2, or if ourNeed is empty, we send a Bloom filter to request any unsent changes.
  let ourHave = []
  if (!theirHeads || ourNeed.every(hash => theirHeads.includes(hash))) {
    ourHave = [makeBloomFilter(backend, sharedHeads)]
  }

  // Fall back to a full re-sync if the sender's last sync state includes hashes
  // that we don't know. This could happen if we crashed after the last sync and
  // failed to persist changes that the other node already sent us.
  if (theirHave && theirHave.length > 0) {
    const lastSync = theirHave[0].lastSync
    if (!lastSync.every(hash => Backend.getChangeByHash(backend, hash))) {
      // we need to queue them to send us a fresh sync message, the one they sent is uninteligible so we don't know what they need
      const resetMsg = {
        heads: ourHeads,
        need: [],
        have: [{ lastSync: [], bloom: new Uint8Array(0) }],
        changes: [],
      }
      return [syncState, encodeSyncMessage(resetMsg)]
    }
  }

  // XXX: we should limit ourselves to only sending a subset of all the messages, probably limited by a total message size
  //      these changes should ideally be RLE encoded but we haven't implemented that yet.
  let changesToSend =
    Array.isArray(theirHave) && Array.isArray(theirNeed)
      ? getChangesToSend(backend, theirHave, theirNeed)
      : []

  // If the heads are equal, we're in sync and don't need to do anything further
  const headsUnchanged =
    Array.isArray(lastSentHeads) && compareArrays(ourHeads, lastSentHeads)
  const headsEqual =
    Array.isArray(theirHeads) && compareArrays(ourHeads, theirHeads)
  if (headsUnchanged && headsEqual && changesToSend.length === 0) {
    // no need to send a sync message if we know we're synced!
    return [syncState, null]
  }

  // TODO: this recomputes the SHA-256 hash of each change; we should restructure this to avoid the
  // unnecessary recomputation
  changesToSend = changesToSend.filter(
    change => !sentHashes[decodeChangeMeta(change, true).hash],
  )

  // Regular response to a sync message: send any changes that the other node
  // doesn't have. We leave the "have" field empty because the previous message
  // generated by `syncStart` already indicated what changes we have.
  const syncMessage = {
    heads: ourHeads,
    have: ourHave,
    need: ourNeed,
    changes: changesToSend,
  }
  if (changesToSend.length > 0) {
    sentHashes = copyObject(sentHashes)
    for (const change of changesToSend) {
      sentHashes[decodeChangeMeta(change, true).hash] = true
    }
  }

  syncState = Object.assign({}, syncState, {
    lastSentHeads: ourHeads,
    sentHashes,
  })
  return [syncState, encodeSyncMessage(syncMessage)]
}
Given a backend and what we believe to be the state of our peer, generate a message which tells
them about we have and includes any changes we believe they need