WOrmMongodb.mjs

import mongodb from 'mongodb'
import stream from 'stream'
import cloneDeep from 'lodash-es/cloneDeep.js'
import each from 'lodash-es/each.js'
import every from 'lodash-es/every.js'
import filter from 'lodash-es/filter.js'
import get from 'lodash-es/get.js'
import map from 'lodash-es/map.js'
import omit from 'lodash-es/omit.js'
import size from 'lodash-es/size.js'
import evem from 'wsemi/src/evem.mjs'
import genPm from 'wsemi/src/genPm.mjs'
import genIDSeq from 'wsemi/src/genIDSeq.mjs'
import isbol from 'wsemi/src/isbol.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import isarr from 'wsemi/src/isarr.mjs'
import isearr from 'wsemi/src/isearr.mjs'
import iseobj from 'wsemi/src/iseobj.mjs'
import isnum from 'wsemi/src/isnum.mjs'
import isu8arr from 'wsemi/src/isu8arr.mjs'
import pmSeries from 'wsemi/src/pmSeries.mjs'


/**
 * 操作資料庫(MongoDB)
 *
 * 本套件之主鍵欄位固定為id,尚未支援由呼叫端指定。id為無業務語義之識別碼。
 * opt.autoGenPk預設為true,insert、save與insertGfs於輸入未帶有效id時自動產生;del於任一設定下皆不補值。
 * opt.autoGenPk為false時套件一律不產生id,未帶有效id者以reject拋出,且id之唯一性與格式皆由呼叫端自負。
 *
 * @class
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {String} [opt.url='mongodb://127.0.0.1:27017'] 輸入連接資料庫字串,預設'mongodb://127.0.0.1:27017'
 * @param {String} [opt.db='worm'] 輸入使用資料庫名稱字串,預設'worm'
 * @param {String} [opt.cl='test'] 輸入使用資料表名稱字串,預設'test'
 * @param {Boolean} [opt.autoGenPk=true] 輸入是否於輸入未帶有效主鍵時自動產生主鍵值,預設true。為false時主鍵須由呼叫端自備,屬依賴注入之定位,主鍵之唯一性、格式與是否與既有資料衝突皆由呼叫端自負。本設定為建構層設定,不得於insert與save之option逐次覆寫
 * @returns {Object} 回傳操作資料庫物件,各事件功能詳見說明
 */
function WOrmMongodb(opt = {}) {


    //default
    if (!opt.url) {
        opt.url = 'mongodb://127.0.0.1:27017'
    }
    if (!opt.db) {
        opt.db = 'worm'
    }
    if (!opt.cl) {
        opt.cl = 'test'
    }


    //autoGenPk, 預設開啟, 為false時主鍵一律由呼叫端自備, 套件不產生亦不補救
    let autoGenPk = get(opt, 'autoGenPk')
    if (!isbol(autoGenPk)) {
        autoGenPk = true
    }


    //_indexReady, 唯一索引只須建立一次, 以旗標記錄避免每次操作皆多一次round-trip
    let _indexReady = false


    //_indexGfsReady, GridFS之唯一索引亦只須建立一次
    let _indexGfsReady = false


    //_canUseTx, 拓樸判定只須一次, 以旗標記錄避免每次操作皆多一次round-trip
    let _canUseTx = null


    //ee, 採wsemi之evem(即eventemitter3), 其於'error'無監聽者時僅回傳false而不拋出,
    //故本套件之操作行為不因呼叫端有無註冊監聽而改變; Node內建之EventEmitter具該拋出語義, 不可用
    let ee = evem()


    //MongoClient
    let MongoClient = mongodb.MongoClient


    /**
     * 取錯誤訊息字串,供逐筆結果之err欄位使用
     *
     * @ignore
     * @param {Error|String} err 輸入錯誤物件或字串
     * @returns {String} 回傳錯誤訊息字串
     */
    function getErrMsg(err) {

        //message
        let message = get(err, 'message')
        if (isestr(message)) {
            return message
        }

        return String(err)
    }


    /**
     * 發出change事件,訂閱函數拋錯不得影響本次操作之結果,故另包try並自行吞掉
     *
     * @ignore
     * @param {String} mode 輸入操作模式字串
     * @param {Array|null} data 輸入本次操作之數據
     * @param {Object|Array} res 輸入本次操作之結果
     * @returns {undefined} 無回傳值
     */
    function emitChange(mode, data, res) {
        try {
            ee.emit('change', mode, data, res)
        }
        catch (err) {
            console.log(err)
        }
    }


    /**
     * 發出error事件,操作發生錯誤時發出,錯誤訊息一律轉為字串
     * 註: 事件僅為附加通知,所送出之資訊必另有正規管道——整批性錯誤經Promise.reject,逐筆失敗經該筆之err欄位
     * 註: 訂閱函數拋錯不得影響本次操作之結果,故另包try並自行吞掉
     * 註: 正常結果不得發出本事件,如insert全數已存在、save合併後內容相同、del主鍵未命中、selectByPk查無數據
     *
     * @ignore
     * @param {String} mode 輸入操作別字串
     * @param {Array|null} data 輸入本次操作之數據
     * @param {Error|String} err 輸入錯誤物件或字串
     * @returns {undefined} 無回傳值
     */
    function emitError(mode, data, err) {
        try {
            ee.emit('error', mode, data, getErrMsg(err))
        }
        catch (errEmit) {
            console.log(errEmit)
        }
    }


    /**
     * 檢查並補齊單筆數據之主鍵
     * autoGenPk為true時未帶有效id者自動產生,為false時往外拋
     * 註: 未帶有效id屬呼叫端未履行契約而非某一筆資料本身之問題,故為整批性錯誤而不降級為該筆ok為0,
     * 若降級為逐筆結果,呼叫端易於整批resolve之下漏看,使[忘了給id]靜默變成[少寫了幾筆]
     * 註: 本函數須於任何寫入之前一次對全部數據完成,令拋錯時同批之有效筆數亦不會被寫入
     *
     * @ignore
     * @param {Object} v 輸入數據物件
     * @param {Number} k 輸入數據於陣列內之索引
     * @returns {Object} 回傳補齊主鍵之數據物件
     */
    function procPk(v, k) {

        //check
        if (!isestr(v.id)) {

            //check, autoGenPk為false時主鍵須由呼叫端自備
            if (!autoGenPk) {
                throw new Error(`invalid data[${k}].id, autoGenPk is false`)
            }

            //genIDSeq
            v.id = genIDSeq()

        }

        return v
    }


    /**
     * 判定是否為唯一索引重複鍵錯誤(11000),批次插入時須全部寫入錯誤皆為重複鍵才算
     *
     * @ignore
     * @param {Error} err 輸入錯誤物件
     * @returns {Boolean} 回傳是否為重複鍵錯誤布林值
     */
    function isDupKeyError(err) {

        //writeErrors, 批次寫入時各筆錯誤置於writeErrors內
        let writeErrors = get(err, 'writeErrors')
        if (isearr(writeErrors)) {
            return every(writeErrors, function(v) {
                return get(v, 'code') === 11000 || get(v, 'err.code') === 11000
            })
        }

        return get(err, 'code') === 11000
    }


    /**
     * 於id欄位建立唯一索引,令[檢查id是否存在]與[寫入]得由MongoDB於單一文件操作內原子完成
     * 註: 若既有資料表內已存在重複id,建立索引會失敗並將錯誤往外拋,須先自行清除重複數據,
     * 此錯誤影響本次操作之全部數據,屬整批性錯誤,故不轉為逐筆之ok為0
     *
     * @ignore
     * @param {Object} collection 輸入資料表物件
     * @returns {Promise} 回傳Promise,resolve回傳undefined,reject回傳錯誤訊息
     */
    async function ensureIndex(collection) {

        //check
        if (_indexReady) {
            return
        }

        //createIndex, 已存在同樣索引時MongoDB不會重建亦不報錯
        await collection.createIndex({ id: 1 }, { unique: true })

        //_indexReady
        _indexReady = true

    }


    /**
     * 使用GridFS,於filename欄位建立唯一索引,令[檢查id是否存在]與[寫入]由MongoDB判定而非由本套件先讀後寫
     * 註: GridFS以files資料表之filename存放id,其本身不具唯一性,須另建唯一索引方能達成[已存在則跳過];
     * 若既有資料表內已存在重複id,建立索引會失敗並將錯誤往外拋,須先自行清除重複數據
     *
     * @ignore
     * @param {Object} database 輸入資料庫物件
     * @returns {Promise} 回傳Promise,resolve回傳undefined,reject回傳錯誤訊息
     */
    async function ensureIndexGfs(database) {

        //check
        if (_indexGfsReady) {
            return
        }

        //createIndex, 已存在同樣索引時MongoDB不會重建亦不報錯
        await database.collection(`${opt.cl}.files`).createIndex({ filename: 1 }, { unique: true })

        //_indexGfsReady
        _indexGfsReady = true

    }


    /**
     * 使用GridFS,上傳單筆數據
     * 註: 已存在同一id時MongoDB以重複鍵錯誤拒絕,惟chunks於files文件之前即已寫入,
     * 故須以files_id清除該次所遺留之chunks,避免累積孤兒數據
     *
     * @ignore
     * @param {Object} bucket 輸入GridFSBucket物件
     * @param {Object} clChunks 輸入GridFS之chunks資料表物件
     * @param {String} id 輸入id字串
     * @param {Uint8Array} u8a 輸入數據Uint8Array
     * @returns {Promise} 回傳Promise,resolve回傳本次是否已插入布林值,reject回傳錯誤訊息
     */
    function _uploadGfs(bucket, clChunks, id, u8a) {

        //pm
        let pm = genPm()

        //ws, files_id於finish前即可取得, 供清除孤兒chunks用
        let ws = bucket.openUploadStream(id)
        let fid = ws.id

        //stream
        let sm = new stream.Readable()
        sm._read = () => {}
        sm.push(Buffer.from(u8a))
        sm.push(null)
        sm.pipe(ws) //pipe是接bucket的Writable, 所以會監聽finish
            .on('error', function(err) {

                //check, 重複鍵錯誤表示已存在同一id而跳過, 屬正常結果而非錯誤
                if (!isDupKeyError(err)) {
                    pm.reject(err)
                    return
                }

                //清除本次所遺留之孤兒chunks
                clChunks.deleteMany({ files_id: fid })
                    .then(function() {
                        pm.resolve(false)
                    })
                    .catch(function(err) {
                        pm.reject(err)
                    })

            })
            .on('finish', function() {

                //resolve
                pm.resolve(true)

            })

        return pm
    }


    /**
     * 查詢數據
     *
     * @memberOf WOrmMongodb
     * @param {Object} [find={}] 輸入查詢條件物件
     * @returns {Promise} 回傳Promise,resolve回傳數據陣列,無符合數據回傳空陣列,reject回傳錯誤訊息
     */
    async function select(find = {}) {
        let isErr = false

        //client
        let client = new MongoClient(opt.url)

        //res
        let res = null
        try {

            //database, collection
            let database = client.db(opt.db)
            let collection = database.collection(opt.cl)

            //find
            let cursor = collection.find(find)

            //toArray
            res = await cursor.toArray()

            //omit, 去除資料庫內部欄位_id, 令回傳欄位與寫入時所給者一致
            res = map(res, function(v) {
                v = omit(v, '_id')
                return v
            })

        }
        catch (err) {
            isErr = true
            res = err
        }
        finally {
            await client.close()
            client = null
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤須於reject之前發出
            emitError('select', null, res)

            return Promise.reject(res)
        }
        return res
    }


    /**
     * 由主鍵查詢單筆數據,因由MongoDB查找且僅回傳單筆,不需如select提取全部符合數據再處理,故數據量大時效能較佳
     * 註: 本套件之主鍵欄位固定為id,尚未支援由呼叫端指定
     * 註: 本函數不得有副作用,故不建立唯一索引
     *
     * @memberOf WOrmMongodb
     * @param {String} pk 輸入主鍵值字串,即數據之id
     * @returns {Promise} 回傳Promise,resolve回傳數據物件,若無此主鍵或主鍵值無效則回傳null,reject回傳錯誤訊息
     */
    async function selectByPk(pk) {
        let isErr = false

        //check
        if (!isestr(pk)) {
            //未給有效主鍵值視為查無數據, 判定基準與insert、save、del內對id之認定一致
            return null
        }

        //client
        let client = new MongoClient(opt.url)

        //res
        let res = null
        try {

            //database, collection
            let database = client.db(opt.db)
            let collection = database.collection(opt.cl)

            //findOne, 以投影去除_id, 令回傳形狀與select一致
            let v = await collection.findOne({ id: pk }, { projection: { _id: 0 } })

            //check, 判定基準與insert、save、del內對既有數據之認定一致
            if (iseobj(v)) {
                res = v
            }
            else {
                //不存在id, 回傳null
                res = null
            }

        }
        catch (err) {
            isErr = true
            res = err
        }
        finally {
            await client.close()
            client = null
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤須於reject之前發出
            emitError('selectByPk', null, res)

            return Promise.reject(res)
        }
        return res
    }


    /**
     * 插入數據,僅於id不存在時寫入,已存在者跳過且不覆寫
     * 由MongoDB於唯一索引上原子完成[檢查id未存在]與[寫入],併發時同一id僅有一次成功
     * 註: 預設回傳單一聚合物件,n為輸入筆數,nInserted為實際插入筆數,全數已存在而nInserted為0屬正常結果
     * 註: option.returnList為true時改回與輸入等長且保序之逐筆陣列,供呼叫端得知[是哪幾筆]為新數據,
     * 聚合計數僅能回答[有幾筆],而去重類呼叫端須知何者為新方能對其執行下游動作
     * 註: opt.autoGenPk為true(預設)時未帶有效id者自動產生,為false時未帶有效id即reject且同批皆不寫入
     *
     * @memberOf WOrmMongodb
     * @param {Object|Array} data 輸入數據物件或陣列
     * @param {Object} [option={}] 輸入設定物件,預設為{}
     * @param {Boolean} [option.returnList=false] 輸入是否改回逐筆結果陣列,預設為false。為true時回傳與輸入等長且保序之陣列,各元素為{ n, nInserted, ok },已插入者nInserted為1、已存在而跳過者為0
     * @returns {Promise} 回傳Promise,resolve回傳插入結果,returnList為true時回傳逐筆結果陣列,reject回傳錯誤訊息
     */
    async function insert(data, option = {}) {
        let isErr = false

        //returnList
        let returnList = get(option, 'returnList')
        if (!isbol(returnList)) {
            returnList = false
        }

        //check
        if (!iseobj(data) && !isearr(data)) {
            if (returnList) {
                return []
            }
            return {
                n: 0,
                nInserted: 0,
                ok: 1,
            }
        }

        //cloneDeep
        data = cloneDeep(data)

        //client
        let client = new MongoClient(opt.url)

        //res
        let res = null
        try {

            //connect, 令連線失敗於寫入前即拋出, 屬影響全部數據之整批性錯誤
            await client.connect()

            //database, collection
            let database = client.db(opt.db)
            let collection = database.collection(opt.cl)

            //check
            if (!isarr(data)) {
                data = [data]
            }

            //check id, 須於任何寫入之前一次完成, 令autoGenPk為false而拋錯時同批之有效筆數亦不會被寫入
            data = map(data, procPk)

            //ensureIndex
            await ensureIndex(collection)

            //nAll, n之基準為輸入筆數
            let nAll = size(data)

            //insertMany, ordered:false令已存在id者跳過而不中斷整批插入,
            //同批含重複id時亦僅首筆成功, 故不須逐筆插入即可取得實際插入筆數
            //ordered:false時writeErrors之index即為未插入者於輸入內之位置, 其餘即為已插入者,
            //故逐筆判定亦由本次同一往返取得, 不須額外查詢
            let nInserted = 0
            let kpSkip = {}
            try {
                let r = await collection.insertMany(data, { ordered: false })
                nInserted = r.insertedCount
            }
            catch (err) {

                //僅重複鍵錯誤可視為[已存在id而跳過], 其餘錯誤影響全部數據, 須往外拋
                if (!isDupKeyError(err)) {
                    throw err
                }

                //重複鍵錯誤時仍可由result取得實際插入筆數
                nInserted = get(err, 'result.insertedCount', 0)

                //kpSkip, 記錄未插入者於輸入內之位置
                each(get(err, 'writeErrors', []), function(v) {
                    let k = get(v, 'index', get(v, 'err.index'))
                    if (isnum(k)) {
                        kpSkip[k] = true
                    }
                })

            }

            //res
            if (returnList) {

                //res, 與輸入等長且保序, 各元素之n恆為1、ok恆為1,
                //因insert之任何錯誤皆屬整批性錯誤而reject, 逐筆元素不出現ok為0與err
                res = map(data, function(v, k) {
                    return {
                        n: 1,
                        nInserted: kpSkip[k] === true ? 0 : 1,
                        ok: 1,
                    }
                })

            }
            else {

                //res, 全數已存在而nInserted為0屬正常結果, 不視為錯誤
                res = {
                    n: nAll,
                    nInserted,
                    ok: 1,
                }

            }

        }
        catch (err) {
            isErr = true
            res = err
        }
        finally {
            await client.close()
            client = null
        }

        //emit
        if (!isErr) {
            emitChange('insert', data, res)
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤須於reject之前發出
            emitError('insert', data, res)

            return Promise.reject(res)
        }
        return res
    }


    /**
     * 判定本次部署是否支援交易
     * 註: MongoDB之交易須replica set或分片叢集,standalone不支援
     * 判定依據為hello回應:replica set帶setName,連至mongos者msg為isdbgrid,standalone兩者皆無
     *
     * @ignore
     * @param {Object} client 輸入MongoClient物件
     * @returns {Promise} 回傳Promise,resolve回傳是否支援交易布林值,reject回傳錯誤訊息
     */
    async function canUseTx(client) {

        //check
        if (isbol(_canUseTx)) {
            return _canUseTx
        }

        //hello
        let hello = await client.db(opt.db).admin().command({ hello: 1 })

        //_canUseTx
        _canUseTx = isestr(get(hello, 'setName')) || get(hello, 'msg') === 'isdbgrid'

        return _canUseTx
    }


    /**
     * 以交易達成insertBulk之全有全無
     * 交易內任一筆失敗即整批回滾,故不會寫入任何一筆,無須補償動作
     *
     * @ignore
     * @param {Object} client 輸入MongoClient物件
     * @param {Object} collection 輸入資料表物件
     * @param {Array} data 輸入數據陣列
     * @returns {Promise} 回傳Promise,resolve回傳undefined,reject回傳錯誤訊息
     */
    async function _insertBulkByTx(client, collection, data) {

        //session
        let session = client.startSession()

        try {

            //withTransaction, ordered:true令首筆衝突即停止, 由交易回滾保證未寫入任何一筆
            await session.withTransaction(async () => {
                await collection.insertMany(data, { session, ordered: true })
            })

        }
        finally {
            await session.endSession()
        }

    }


    /**
     * 以補償動作達成insertBulk之全有全無,供無交易可用之standalone使用
     * 註: 補償係刪除本次呼叫已寫入者,其_id由驅動於送出前在用戶端產生,
     * 與呼叫前既有數據之_id必不相同,故縱使該筆之id已存在亦不會誤刪既有數據;
     * 刪除未寫入者為無操作,故不須精確得知哪幾筆已寫入
     * 註: 行程若於補償途中中止(斷電、強制終止),已寫入之部份可能殘留,此為無交易可用之固有限制
     *
     * @ignore
     * @param {Object} collection 輸入資料表物件
     * @param {Array} data 輸入數據陣列
     * @returns {Promise} 回傳Promise,resolve回傳undefined,reject回傳錯誤訊息
     */
    async function _insertBulkByCompensate(collection, data) {

        //errIns
        let errIns = null

        try {
            await collection.insertMany(data, { ordered: false })
        }
        catch (err) {
            errIns = err
        }

        //check, 全數插入成功即無須補償
        if (errIns === null) {
            return
        }

        //idDels, 取本次由驅動所產生之全部_id
        let idDels = map(data, function(v) {
            return v._id
        })
        idDels = filter(idDels, function(v) {
            return v !== undefined && v !== null
        })

        //補償, 刪除本次已寫入者
        if (size(idDels) > 0) {
            await collection.deleteMany({ _id: { $in: idDels } })
        }

        throw errIns
    }


    /**
     * 批次插入數據,全批視為一個單位:全部插入成功,或一筆都不寫入
     * 任一筆之id已存在(含同批含重複id)即以reject拋出整批性錯誤,且不寫入任何一筆
     * 註: 本函數非insert之加速版,兩者衝突政策不同——insert跳過已存在者而整批ok為1,
     * 本函數則整批reject;需要逐筆處置者改用insert
     * 註: n為輸入筆數,nInserted於成功時恆等於n;不提供逐筆結果,故不出現ok為0與err
     * 註: opt.autoGenPk為true(預設)時未帶有效id者自動產生,為false時未帶有效id即reject且同批皆不寫入
     * 註: 全有全無之達成方式依部署而異——具replica set或分片叢集者以交易包覆,
     * standalone無交易可用而以補償動作達成,其限制詳見_insertBulkByCompensate
     *
     * @memberOf WOrmMongodb
     * @param {Object|Array} data 輸入數據物件或陣列
     * @returns {Promise} 回傳Promise,resolve回傳插入結果,reject回傳錯誤訊息
     */
    async function insertBulk(data) {
        let isErr = false

        //check
        if (!iseobj(data) && !isearr(data)) {
            return {
                n: 0,
                nInserted: 0,
                ok: 1,
            }
        }

        //cloneDeep
        data = cloneDeep(data)

        //client
        let client = new MongoClient(opt.url)

        //res
        let res = null
        try {

            //connect, 令連線失敗於寫入前即拋出, 屬影響全部數據之整批性錯誤
            await client.connect()

            //database, collection
            let database = client.db(opt.db)
            let collection = database.collection(opt.cl)

            //check
            if (!isarr(data)) {
                data = [data]
            }

            //check id, 須於任何寫入之前一次完成
            data = map(data, procPk)

            //ensureIndex
            await ensureIndex(collection)

            //nAll, n之基準為輸入筆數
            let nAll = size(data)

            //check, 有交易可用者一律優先採用交易, 其回滾即為全有全無之保證
            let bTx = await canUseTx(client)
            if (bTx) {
                await _insertBulkByTx(client, collection, data)
            }
            else {
                await _insertBulkByCompensate(collection, data)
            }

            //res, 執行至此表全數插入成功, 故nInserted恆等於n
            res = {
                n: nAll,
                nInserted: nAll,
                ok: 1,
            }

        }
        catch (err) {
            isErr = true
            res = err
        }
        finally {
            await client.close()
            client = null
        }

        //emit
        if (!isErr) {
            emitChange('insertBulk', data, res)
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤須於reject之前發出
            emitError('insertBulk', data, res)

            return Promise.reject(res)
        }
        return res
    }


    /**
     * 儲存單筆數據,由MongoDB於單一updateOne內原子完成[查找id]與[更新或插入]
     * 註: MongoDB單一文件操作本即為原子,故不須開啟transaction(transaction另須replica set,standalone不支援)
     *
     * @ignore
     * @param {Object} collection 輸入資料表物件
     * @param {Object} v 輸入數據物件
     * @param {Boolean} autoInsert 輸入是否於查無數據時自動改以插入處理布林值
     * @returns {Promise} 回傳Promise,resolve回傳本筆儲存結果,本筆失敗時亦resolve並以ok為0回報
     */
    async function saveOne(collection, v, autoInsert) {

        //nTry, 併發upsert可能因他方同時插入同一id而拋重複鍵錯誤,
        //此時該id已存在, 重試即會走更新路徑, 故給予有限次數重試令其收斂
        let nTry = 3

        //rest
        let rest = null

        //inserted, 供結果定案後發出insert事件用
        let inserted = false

        for (let i = 0; i < nTry; i++) {

            try {

                //updateOne, upsert僅於autoInsert時開啟, 未開啟時查無數據即不寫入以免無中生有,
                //由MongoDB於單一操作內原子完成[查找id]與[更新或插入],
                //併發時不會有兩方各自讀到查無數據再各自插入而產生重複id, 亦不會有讀改寫之遺失更新
                let r = await collection.updateOne({ id: v.id }, { $set: v }, {
                    upsert: autoInsert,
                })

                //matchedCount, modifiedCount, upsertedCount
                let matchedCount = get(r, 'matchedCount', 0)
                let modifiedCount = get(r, 'modifiedCount', 0)
                let upsertedCount = get(r, 'upsertedCount', 0)

                if (upsertedCount > 0) {
                    //原不存在而插入

                    //rest
                    rest = {
                        n: 1,
                        nInserted: 1,
                        nModified: 0,
                        ok: 1,
                    }

                    //inserted
                    inserted = true

                }
                else if (matchedCount > 0) {
                    //原已存在, 合併後內容有變更方為更新
                    //由MongoDB於伺服器端將待寫入物件合併進現值後與現值比對, 結果相同者不寫入而回modifiedCount為0,
                    //故僅給部份欄位且該些欄位值皆與現值相同時, 合併結果等於現值, nModified亦為0,
                    //係於同一原子操作內完成比對與寫入, 故不須另行預讀, 亦無預讀值過期之疑慮

                    //rest
                    rest = {
                        n: 1,
                        nInserted: 0,
                        nModified: modifiedCount > 0 ? 1 : 0,
                        ok: 1,
                    }

                }
                else {
                    //查無數據且未開啟autoInsert

                    //rest
                    rest = {
                        n: 0,
                        nInserted: 0,
                        nModified: 0,
                        ok: 1,
                    }

                }

                break
            }
            catch (err) {

                //重複鍵錯誤且未達重試上限則重試
                if (isDupKeyError(err) && i < nTry - 1) {
                    continue
                }

                //其餘視為本筆失敗, 不中斷整批, 由呼叫端以ok與err判讀

                //rest
                rest = {
                    n: 1,
                    nInserted: 0,
                    nModified: 0,
                    ok: 0,
                    err: getErrMsg(err),
                }

                break
            }

        }

        //emit, 須於結果定案後發出, 避免訂閱函數拋錯影響本筆結果
        if (inserted) {
            emitChange('insert', [v], rest)
        }

        //emit, 逐筆失敗須於該筆結果定案後發出, 每筆一次
        if (rest.ok === 0) {
            emitError('save', [v], rest.err)
        }

        return rest
    }


    /**
     * 儲存數據,以id為準更新既有數據,未給之欄位會保留;id不存在且option.autoInsert為true時改為插入
     * 註: n為id命中筆數,命中或經插入而產生皆為1;[內容相同]之判定基準為將待儲存物件合併進現值後結果與現值相同,
     * 相同者不寫入而nModified為0;本筆失敗不中斷整批,該筆以ok為0並附err回報
     * 註: opt.autoGenPk為true(預設)時未帶有效id者自動產生,為false時未帶有效id即reject且同批皆不寫入
     *
     * @memberOf WOrmMongodb
     * @param {Object|Array} data 輸入數據物件或陣列
     * @param {Object} [option={}] 輸入設定物件,預設為{}
     * @param {boolean} [option.autoInsert=true] 輸入是否於儲存時發現原本無數據,則自動改以插入處理,預設為true
     * @returns {Promise} 回傳Promise,resolve回傳與輸入等長之儲存結果陣列,reject回傳錯誤訊息
     */
    async function save(data, option = {}) {
        let isErr = false

        //check
        if (!iseobj(data) && !isearr(data)) {
            return []
        }

        //cloneDeep
        data = cloneDeep(data)

        //autoInsert
        let autoInsert = get(option, 'autoInsert', true)

        //client
        let client = new MongoClient(opt.url)

        //res
        let res = null
        try {

            //connect, 令連線失敗於寫入前即拋出, 屬影響全部數據之整批性錯誤
            await client.connect()

            //database, collection
            let database = client.db(opt.db)
            let collection = database.collection(opt.cl)

            //check
            if (!isarr(data)) {
                data = [data]
            }

            //check id, 須於任何寫入之前一次完成, 令autoGenPk為false而拋錯時同批之有效筆數亦不會被寫入
            data = map(data, procPk)

            //ensureIndex
            await ensureIndex(collection)

            //pmSeries
            res = await pmSeries(data, async(v) => {
                return saveOne(collection, v, autoInsert)
            })

        }
        catch (err) {
            isErr = true
            res = err
        }
        finally {
            await client.close()
            client = null
        }

        //emit
        if (!isErr) {
            emitChange('save', data, res)
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤須於reject之前發出
            emitError('save', data, res)

            return Promise.reject(res)
        }
        return res
    }


    /**
     * 刪除數據
     * 註: n為id命中筆數;未帶有效id者視為本筆無法處理,不送查詢條件且以ok為0並附err回報;
     * 判斷本筆是否真的被刪除一律以nDeleted為準
     *
     * @memberOf WOrmMongodb
     * @param {Object|Array} data 輸入數據物件或陣列
     * @returns {Promise} 回傳Promise,resolve回傳與輸入等長之刪除結果陣列,reject回傳錯誤訊息
     */
    async function del(data) {
        let isErr = false

        //check
        if (!iseobj(data) && !isearr(data)) {
            return []
        }

        //cloneDeep
        data = cloneDeep(data)

        //client
        let client = new MongoClient(opt.url)

        //res
        let res = null
        try {

            //connect, 令連線失敗於刪除前即拋出, 屬影響全部數據之整批性錯誤
            await client.connect()

            //database, collection
            let database = client.db(opt.db)
            let collection = database.collection(opt.cl)

            //check
            if (!isarr(data)) {
                data = [data]
            }

            //pmSeries
            res = await pmSeries(data, async(v) => {

                //id, del不補值, 未帶有效id者視為本筆無法處理
                let id = get(v, 'id')

                //rest
                let rest = null

                //check, 不得將無效id送進查詢條件, 因undefined經序列化為null會誤中id為null之數據
                if (!isestr(id)) {

                    //rest
                    rest = {
                        n: 0,
                        nDeleted: 0,
                        ok: 0,
                        err: `invalid id[${id}]`,
                    }

                }
                else {

                    try {

                        //deleteOne
                        let r = await collection.deleteOne({ id })

                        //nDeleted
                        let nDeleted = get(r, 'deletedCount', 0)

                        //rest, 未命中時nDeleted為0, 屬正常結果
                        rest = {
                            n: nDeleted,
                            nDeleted,
                            ok: 1,
                        }

                    }
                    catch (err) {

                        //本筆失敗不中斷整批

                        //rest
                        rest = {
                            n: 1,
                            nDeleted: 0,
                            ok: 0,
                            err: getErrMsg(err),
                        }

                    }

                }

                //emit, 逐筆失敗須於該筆結果定案後發出, 每筆一次
                if (rest.ok === 0) {
                    emitError('del', [v], rest.err)
                }

                return rest
            })

        }
        catch (err) {
            isErr = true
            res = err
        }
        finally {
            await client.close()
            client = null
        }

        //emit
        if (!isErr) {
            emitChange('del', data, res)
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤須於reject之前發出
            emitError('del', data, res)

            return Promise.reject(res)
        }
        return res
    }


    /**
     * 刪除全部數據,需與del分開,避免未傳數據導致直接刪除全表
     * 註: n為實際刪除筆數,恆等於nDeleted;條件無命中時回傳0亦屬正常結果
     *
     * @memberOf WOrmMongodb
     * @param {Object} [find={}] 輸入刪除條件物件
     * @returns {Promise} 回傳Promise,resolve回傳刪除結果,reject回傳錯誤訊息
     */
    async function delAll(find = {}) {
        let isErr = false

        //client
        let client = new MongoClient(opt.url)

        //res
        let res = null
        try {

            //database, collection
            let database = client.db(opt.db)
            let collection = database.collection(opt.cl)

            //deleteMany
            let r = await collection.deleteMany(find)

            //nDeleted
            let nDeleted = get(r, 'deletedCount', 0)

            //res
            res = {
                n: nDeleted,
                nDeleted,
                ok: 1,
            }

        }
        catch (err) {
            isErr = true
            res = err
        }
        finally {
            await client.close()
            client = null
        }

        //emit
        if (!isErr) {
            emitChange('delAll', null, res)
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤須於reject之前發出
            emitError('delAll', null, res)

            return Promise.reject(res)
        }
        return res
    }


    /**
     * 使用GridFS,插入數據,僅於id不存在時寫入,已存在者跳過且不覆寫
     * 數據物件形狀為{ id, u8a },u8a須為Uint8Array
     * 註: n為輸入筆數,nInserted為實際插入筆數,全數已存在而nInserted為0屬正常結果
     * 註: opt.autoGenPk為true(預設)時未帶有效id者自動產生,為false時未帶有效id即reject且同批皆不寫入
     *
     * @memberOf WOrmMongodb
     * @param {Object|Array} data 輸入數據物件或陣列,各數據物件形狀為{ id, u8a }
     * @returns {Promise} 回傳Promise,resolve回傳插入結果,reject回傳錯誤訊息
     */
    async function insertGfs(data) {
        let isErr = false

        //check
        if (!iseobj(data) && !isearr(data)) {
            return {
                n: 0,
                nInserted: 0,
                ok: 1,
            }
        }

        //client
        let client = new MongoClient(opt.url)

        //res
        let res = null
        try {

            //connect, 令連線失敗於寫入前即拋出, 屬影響全部數據之整批性錯誤
            await client.connect()

            //database
            let database = client.db(opt.db)

            //bucket
            let bucket = new mongodb.GridFSBucket(database, {
                chunkSizeBytes: 10 * 1024 * 1024, //10mb
                bucketName: opt.cl
            })

            //clChunks
            let clChunks = database.collection(`${opt.cl}.chunks`)

            //check
            if (!isarr(data)) {
                data = [data]
            }

            //check id與u8a, 僅淺拷貝數據物件而不深拷貝u8a,
            //因u8a僅供讀取, 深拷貝大量二進位數據成本過高;
            //u8a無效屬呼叫端給值錯誤且整批函數無從逐筆回報, 故往外拋
            data = map(data, function(v, k) {
                v = { ...v }
                v = procPk(v, k)
                if (!isu8arr(v.u8a)) {
                    throw new Error(`invalid data[${k}].u8a`)
                }
                return v
            })

            //ensureIndexGfs
            await ensureIndexGfs(database)

            //nAll, n之基準為輸入筆數
            let nAll = size(data)

            //上傳, 逐筆序列處理以免大量二進位數據同時佔用記憶體
            let nInserted = 0
            await pmSeries(data, async(v) => {
                let inserted = await _uploadGfs(bucket, clChunks, v.id, v.u8a)
                if (inserted) {
                    nInserted++
                }
            })

            //res, 全數已存在而nInserted為0屬正常結果, 不視為錯誤
            res = {
                n: nAll,
                nInserted,
                ok: 1,
            }

        }
        catch (err) {
            isErr = true
            res = err
        }
        finally {
            await client.close()
            client = null
        }

        //emit
        if (!isErr) {
            emitChange('insertGfs', data, res)
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤須於reject之前發出
            emitError('insertGfs', data, res)

            return Promise.reject(res)
        }
        return res
    }


    /**
     * 使用GridFS,由主鍵查詢單筆數據
     * 註: 查無數據或主鍵值無效時回傳null,判定基準與selectByPk一致
     * 註: 本套件之主鍵欄位固定為id,尚未支援由呼叫端指定
     * 本函數不得有副作用,故不建立唯一索引
     *
     * @memberOf WOrmMongodb
     * @param {String} pk 輸入主鍵值字串,即數據之id
     * @returns {Promise} 回傳Promise,resolve回傳數據物件{ id, u8a },若無此主鍵或主鍵值無效則回傳null,reject回傳錯誤訊息
     */
    async function selectByPkGfs(pk) {
        let isErr = false

        //check
        if (!isestr(pk)) {
            //未給有效主鍵值視為查無數據, 判定基準與selectByPk一致
            return null
        }

        //client
        let client = new MongoClient(opt.url)

        //core
        let core = async (id) => {

            //pm
            let pm = genPm()

            //database
            let database = client.db(opt.db)

            //bucket
            let bucket = new mongodb.GridFSBucket(database, {
                chunkSizeBytes: 10 * 1024 * 1024, //10mb
                bucketName: opt.cl
            })

            //buf
            let buf = Buffer.from('')

            //stream
            let sm = bucket.openDownloadStreamByName(id)
            sm.on('data', function (chunk) {
                buf = Buffer.concat([buf, chunk])
            })
            sm.on('error', function (err) {

                //reject
                pm.reject(err)

            })
            sm.on('end', function () {

                //u8a
                let u8a = new Uint8Array(buf)

                //clean memory
                buf = null

                //resolve
                pm.resolve(u8a)

            })

            return pm
        }

        //res
        let res = null
        try {

            //core
            let u8a = await core(pk)

            //res, 形狀與insertGfs所收之數據物件一致
            res = {
                id: pk,
                u8a,
            }

        }
        catch (err) {

            //check, GridFS查無檔案時驅動以code為ENOENT之錯誤回報, 屬正常結果故回傳null
            if (get(err, 'code') === 'ENOENT') {
                res = null
            }
            else {
                isErr = true
                res = err
            }

        }
        finally {
            await client.close()
            client = null
        }

        //check, 查無檔案已於catch內判定為正常結果而未設isErr, 故不會誤發error事件
        if (isErr) {

            //emit, 整批性錯誤須於reject之前發出
            emitError('selectByPkGfs', null, res)

            return Promise.reject(res)
        }
        return res
    }


    //_findGfs, 內部查找函數, 其reject由delGfs與delAllGfs之catch接住並於該處發出error事件, 故本函數不自行發出
    async function _findGfs(find = {}, bucket) {
        let isErr = false

        //res
        let res = null
        try {

            //find
            let cursor = bucket.find(find)

            //toArray
            res = await cursor.toArray()

        }
        catch (err) {
            isErr = true
            res = err
        }

        if (isErr) {
            return Promise.reject(res)
        }
        return res
    }


    /**
     * 使用GridFS,刪除數據
     * 註: n為id命中筆數;未帶有效id者視為本筆無法處理,以ok為0並附err回報;
     * 判斷本筆是否真的被刪除一律以nDeleted為準
     *
     * @memberOf WOrmMongodb
     * @param {Object|Array} data 輸入數據物件或陣列,各數據物件須帶id
     * @returns {Promise} 回傳Promise,resolve回傳與輸入等長之刪除結果陣列,reject回傳錯誤訊息
     */
    async function delGfs(data) {
        let isErr = false

        //check
        if (!iseobj(data) && !isearr(data)) {
            return []
        }

        //client
        let client = new MongoClient(opt.url)

        //res
        let res = null
        try {

            //connect, 令連線失敗於刪除前即拋出, 屬影響全部數據之整批性錯誤
            await client.connect()

            //database
            let database = client.db(opt.db)

            //bucket
            let bucket = new mongodb.GridFSBucket(database, {
                chunkSizeBytes: 10 * 1024 * 1024, //10mb
                bucketName: opt.cl
            })

            //check
            if (!isarr(data)) {
                data = [data]
            }

            //pmSeries
            res = await pmSeries(data, async(v) => {

                //id, delGfs不補值, 未帶有效id者視為本筆無法處理
                let id = get(v, 'id')

                //rest
                let rest = null

                //check, 判定基準與del一致
                if (!isestr(id)) {

                    //rest
                    rest = {
                        n: 0,
                        nDeleted: 0,
                        ok: 0,
                        err: `invalid id[${id}]`,
                    }

                }
                else {

                    try {

                        //_findGfs
                        let ltdt = await _findGfs({ filename: id }, bucket)

                        //delete, 建立唯一索引後同一id至多一筆,
                        //既有數據若尚存重複id則一併刪除並如實回報nDeleted
                        let nDeleted = 0
                        for (let vv of ltdt) {
                            await bucket.delete(vv._id)
                            nDeleted++
                        }

                        //rest, n為命中與否, 未命中時兩者皆為0且屬正常結果
                        rest = {
                            n: nDeleted > 0 ? 1 : 0,
                            nDeleted,
                            ok: 1,
                        }

                    }
                    catch (err) {

                        //本筆失敗不中斷整批

                        //rest
                        rest = {
                            n: 1,
                            nDeleted: 0,
                            ok: 0,
                            err: getErrMsg(err),
                        }

                    }

                }

                //emit, 逐筆失敗須於該筆結果定案後發出, 每筆一次
                if (rest.ok === 0) {
                    emitError('delGfs', [v], rest.err)
                }

                return rest
            })

        }
        catch (err) {
            isErr = true
            res = err
        }
        finally {
            await client.close()
            client = null
        }

        //emit
        if (!isErr) {
            emitChange('delGfs', data, res)
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤須於reject之前發出
            emitError('delGfs', data, res)

            return Promise.reject(res)
        }
        return res
    }


    async function _delGfs(bid, bucket) {

        //delete, 連同chunks一併刪除
        await bucket.delete(bid)

    }


    /**
     * 使用GridFS,刪除全部數據,需與delGfs分開,避免未傳數據導致直接刪除全部
     * 註: n為實際刪除筆數,恆等於nDeleted;條件無命中時回傳0亦屬正常結果
     * 本函數不建立唯一索引,以免既有數據尚存重複id時無法清除
     *
     * @memberOf WOrmMongodb
     * @param {Object} [find={}] 輸入刪除條件物件
     * @returns {Promise} 回傳Promise,resolve回傳刪除結果,reject回傳錯誤訊息
     */
    async function delAllGfs(find = {}) {
        let isErr = false

        //client
        let client = new MongoClient(opt.url)

        //res
        let res = null
        try {

            //database
            let database = client.db(opt.db)

            //bucket
            let bucket = new mongodb.GridFSBucket(database, {
                chunkSizeBytes: 10 * 1024 * 1024, //10mb
                bucketName: opt.cl
            })

            //_findGfs
            let ltdt = await _findGfs(find, bucket)

            //ps
            let ps = map(ltdt, function(v) {
                let bid = v._id
                return _delGfs(bid, bucket)
            })

            //all, 任一刪除失敗即往外拋, 屬整批性錯誤
            await Promise.all(ps)

            //nDeleted, 全數刪除成功方會執行至此, 故實際刪除筆數即為符合條件筆數
            let nDeleted = size(ltdt)

            //res
            res = {
                n: nDeleted,
                nDeleted,
                ok: 1,
            }

        }
        catch (err) {
            isErr = true
            res = err
        }
        finally {
            await client.close()
            client = null
        }

        //emit
        if (!isErr) {
            emitChange('delAllGfs', null, res)
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤須於reject之前發出
            emitError('delAllGfs', null, res)

            return Promise.reject(res)
        }
        return res
    }


    //bind
    ee.select = select
    ee.selectByPk = selectByPk
    ee.insert = insert
    ee.insertBulk = insertBulk
    ee.save = save
    ee.del = del
    ee.delAll = delAll
    ee.selectByPkGfs = selectByPkGfs
    ee.insertGfs = insertGfs
    ee.delGfs = delGfs
    ee.delAllGfs = delAllGfs


    return ee
}


export default WOrmMongodb