function decodeOps(ops, forDocument) {
  const newOps = []
  for (let op of ops) {
    const obj = op.objCtr === null ? "_root" : `${op.objCtr}@${op.objActor}`
    const elemId = op.keyStr
      ? undefined
      : op.keyCtr === 0
        ? "_head"
        : `${op.keyCtr}@${op.keyActor}`
    const action = ACTIONS[op.action] || op.action
    const newOp = elemId
      ? { obj, elemId, action }
      : { obj, key: op.keyStr, action }
    newOp.insert = !!op.insert
    if (ACTIONS[op.action] === "set" || ACTIONS[op.action] === "inc") {
      newOp.value = op.valLen
      if (op.valLen_datatype) newOp.datatype = op.valLen_datatype
    }
    if (!!op.chldCtr !== !!op.chldActor) {
      throw new RangeError(
        `Mismatched child columns: ${op.chldCtr} and ${op.chldActor}`,
      )
    }
    if (op.chldCtr !== null) newOp.child = `${op.chldCtr}@${op.chldActor}`
    if (forDocument) {
      newOp.id = `${op.idCtr}@${op.idActor}`
      newOp.succ = op.succNum.map(succ => `${succ.succCtr}@${succ.succActor}`)
      checkSortedOpIds(
        op.succNum.map(succ => ({
          counter: succ.succCtr,
          actorId: succ.succActor,
        })),
      )
    } else {
      newOp.pred = op.predNum.map(pred => `${pred.predCtr}@${pred.predActor}`)
      checkSortedOpIds(
        op.predNum.map(pred => ({
          counter: pred.predCtr,
          actorId: pred.predActor,
        })),
      )
    }
    newOps.push(newOp)
  }
  return newOps
}
Takes a change as decoded by `decodeColumns`, and changes it into the form
expected by the rest of the backend. If `forDocument` is true, we use the op
structure of a whole document, otherwise we use the op structure for an
individual change.