function applyOps(patches, changeState, docState) {
  const [objActorNum, objCtr, keyActorNum, keyCtr, keyStr, idActorNum, idCtr, insert] = changeState.nextOp
  const objActor = objActorNum === null ? null : docState.actorIds[objActorNum]
  const keyActor = keyActorNum === null ? null : docState.actorIds[keyActorNum]
  const ops = {
    objActor, objActorNum, objCtr, keyActor, keyActorNum, keyCtr, keyStr,
    idActor: docState.actorIds[idActorNum], idCtr, insert,
    objId: objActor === null ? '_root' : `${objCtr}@${objActor}`
  }

  const {blockIndex, skipCount, visibleCount} = seekToOp(docState, ops)
  const block = docState.blocks[blockIndex]
  for (let col of block.columns) col.decoder.reset()

  const resetFirstVisible = (skipCount === 0) || (block.firstVisibleActor === undefined) ||
    (!insert && block.firstVisibleActor === keyActorNum && block.firstVisibleCtr === keyCtr)
  const newBlock = {
    columns: undefined,
    bloom: new Uint8Array(block.bloom),
    numOps: skipCount,
    lastKey: block.lastKey,
    numVisible: block.numVisible,
    lastObjectActor: block.lastObjectActor,
    lastObjectCtr: block.lastObjectCtr,
    firstVisibleActor: resetFirstVisible ? undefined : block.firstVisibleActor,
    firstVisibleCtr: resetFirstVisible ? undefined : block.firstVisibleCtr,
    lastVisibleActor: undefined,
    lastVisibleCtr: undefined
  }

  // Copy the operations up to the insertion position (the first skipCount operations)
  const outCols = block.columns.map(col => ({columnId: col.columnId, encoder: encoderByColumnId(col.columnId)}))
  copyColumns(outCols, block.columns, skipCount)

  // Apply the operations from the change. This may cause blockIndex to move forwards if the
  // property being updated straddles a block boundary.
  const {blockIndex: lastBlockIndex, docOpsConsumed} =
    mergeDocChangeOps(patches, newBlock, outCols, changeState, docState, visibleCount, blockIndex)

  // Copy the remaining operations after the insertion position
  const lastBlock = docState.blocks[lastBlockIndex]
  let copyAfterMerge = -skipCount - docOpsConsumed
  for (let i = blockIndex; i <= lastBlockIndex; i++) copyAfterMerge += docState.blocks[i].numOps
  copyColumns(outCols, lastBlock.columns, copyAfterMerge)
  newBlock.numOps += copyAfterMerge

  for (let col of lastBlock.columns) {
    if (!col.decoder.done) throw new RangeError(`excess ops in column ${col.columnId}`)
  }

  newBlock.columns = outCols.map(col => {
    const decoder = decoderByColumnId(col.columnId, col.encoder.buffer)
    return {columnId: col.columnId, decoder}
  })

  if (blockIndex === lastBlockIndex && newBlock.numOps <= MAX_BLOCK_SIZE) {
    // The result is just one output block
    if (copyAfterMerge > 0 && block.lastVisibleActor !== undefined && block.lastVisibleCtr !== undefined) {
      // It's possible that none of the ops after the merge point are visible, in which case the
      // lastVisible may not be strictly correct, because it may refer to an operation before the
      // merge point rather than a list element inserted by the current change. However, this doesn't
      // matter, because the only purpose for which we need it is to check whether one block ends with
      // the same visible element as the next block starts with (to avoid double-counting its index);
      // if the last list element of a block is invisible, the exact value of lastVisible doesn't
      // matter since it will be different from the next block's firstVisible in any case.
      newBlock.lastVisibleActor = block.lastVisibleActor
      newBlock.lastVisibleCtr = block.lastVisibleCtr
    }

    docState.blocks[blockIndex] = newBlock

  } else {
    // Oversized output block must be split into smaller blocks
    const newBlocks = splitBlock(newBlock)
    docState.blocks.splice(blockIndex, lastBlockIndex - blockIndex + 1, ...newBlocks)
  }
}
Applies operations from the change (or series of changes) in `changeState` to the document
`docState`. Passing `changeState` to `readNextChangeOp` allows iterating over the change ops.
`docState` is an object with keys:
  - `actorIds` is an array of actorIds (as hex strings) occurring in the document (values in
    the document's objActor/keyActor/idActor/... columns are indexes into this array).
  - `blocks` is an array of all the blocks of operations in the document.
  - `objectMeta` is a map from objectId to metadata about that object.

`docState` is mutated to contain the updated document state.
`patches` is a patch object that is mutated to reflect the operations applied by this function.