async function asyncGroupToRows({ asyncColumns }, selectStart, selectEnd, columns, rowFormat) {
  const groupData = new Array(selectEnd)

  // columnData[i] for asyncColumns[i]
  // TODO: do it without flatten
  const columnDatas = await Promise.all(asyncColumns.map(({ data }) => data.then(flatten)))

  // careful mapping of column order for rowFormat: array
  const includedColumnNames = asyncColumns
    .map(child => child.pathInSchema[0])
    .filter(name => !columns || columns.includes(name))
  const columnOrder = columns ?? includedColumnNames
  const columnIndexes = columnOrder.map(name => asyncColumns.findIndex(column => column.pathInSchema[0] === name))

  // transpose columns into rows
  for (let row = selectStart; row < selectEnd; row++) {
    if (rowFormat === 'object') {
      // return each row as an object
      /** @type {Record<string, any>} */
      const rowData = {}
      for (let i = 0; i < asyncColumns.length; i++) {
        rowData[asyncColumns[i].pathInSchema[0]] = columnDatas[i][row]
      }
      groupData[row] = rowData
    } else {
      // return each row as an array
      const rowData = new Array(asyncColumns.length)
      for (let i = 0; i < columnOrder.length; i++) {
        if (columnIndexes[i] >= 0) {
          rowData[i] = columnDatas[columnIndexes[i]][row]
        }
      }
      groupData[row] = rowData
    }
  }
  return groupData
}
@param {AsyncRowGroup} asyncGroup
@param {number} selectStart
@param {number} selectEnd
@param {string[] | undefined} columns
@param {'object' | 'array'} [rowFormat]
@returns {Promise<Record<string, any>[]>} resolves to row data