WSyncWebdataServer.mjs

import fs from 'fs'
import get from 'lodash-es/get.js'
import debounce from 'lodash-es/debounce.js'
import merge from 'lodash-es/merge.js'
import now2str from 'wsemi/src/now2str.mjs'
import genID from 'wsemi/src/genID.mjs'
import evem from 'wsemi/src/evem.mjs'
import haskey from 'wsemi/src/haskey.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import isobj from 'wsemi/src/isobj.mjs'
import iseobj from 'wsemi/src/iseobj.mjs'
import isfun from 'wsemi/src/isfun.mjs'
import j2o from 'wsemi/src/j2o.mjs'
import o2j from 'wsemi/src/o2j.mjs'
import genEmit from './genEmit.mjs'


/**
 * 伺服器端之資料同步器
 *
 * @class
 * @param {Object} instWConverServer 輸入通訊服務實體物件,可使用例如WConverhpServer等建立
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {String} [opt.fpTableTags='tableTags.json'] 輸入儲存各資料表時間戳檔案路徑串,預設'./tableTags.json'
 * @param {Function} [opt.genTag=()=>'{random string}'] 輸入產生不重複識別碼函數,預設()=>'{random string}'
 * @returns {Object} 回傳後端資料同步物件,可監聽事件changeTableTags、error,可使用函數readTableTags、writeTableTags、initTableTags、setTableTags、getTableTags、updateTableTag、clearSync。監聽器須為同步函數,其同步拋錯時轉為error事件通知,本套件自發之error若無監聽者則改以console.error輸出,不會使行程崩潰。同一事件物件只能建立一個本同步器,重建前須先呼叫clearSync;上層掛於ORM等外部物件之監聽須由上層自行移除
 * @example
 *
 * //前後端共用範例
 * import w from 'wsemi'
 * import WSyncWebdataServer from './src/WSyncWebdataServer.mjs'
 * import WSyncWebdataClient from './src/WSyncWebdataClient.mjs'
 *
 * let ms = []
 *
 * let ntg = 0
 * let genTag = () => {
 *     ntg++
 *     return `tag-${ntg}`
 * }
 *
 * //-------- 後端 ---------
 *
 * //EventEmitter, 模擬後端推播至前端
 * let ee = w.evem()
 *
 * //instWConverServer
 * let instWConverServer = w.evem()
 *
 * //wsds
 * let wsds = new WSyncWebdataServer(
 *     instWConverServer,
 *     {
 *         // fpTableTags: 'tableTags-sync-webdata.json',
 *         genTag,
 *     },
 * )
 *
 * //tableTagsSrv
 * let tableTagsSrv = {
 *     tabA: 'tag-0-a',
 *     tabB: 'tag-0-b',
 * }
 *
 * //initTableTags
 * wsds.initTableTags(tableTagsSrv)
 *
 * //readTableTags
 * console.log('server: nowTableTags', wsds.readTableTags())
 * ms.push({ 'server nowTableTags': JSON.stringify(wsds.readTableTags()) })
 *
 * //updateTableTag
 * setTimeout(() => {
 *     wsds.updateTableTag('tabA')
 *     ms.push({ 'server updateTableTag': 'tabA' })
 * }, 500)
 * setTimeout(() => {
 *     wsds.updateTableTag('tabB')
 *     ms.push({ 'server updateTableTag': 'tabB' })
 * }, 750)
 * setTimeout(() => {
 *     wsds.updateTableTag('tabA')
 *     ms.push({ 'server updateTableTag': 'tabA' })
 * }, 1000)
 *
 * //changeTableTags
 * wsds.on('changeTableTags', (nowTableTags) => {
 *     console.log('server: changeTableTags', nowTableTags)
 *
 *     //server push
 *     console.log('server: push')
 *     ms.push({ 'server changeTableTags': JSON.stringify(nowTableTags) })
 *     ee.emit('push', nowTableTags)
 *
 * })
 *
 * //error
 * wsds.on('error', (err) => {
 *     console.log('server: error', err)
 * })
 *
 * //getAPIData, 模擬後端提供API
 * let tabsCount = {
 *     tabA: 0,
 *     tabB: 0,
 * }
 * async function getAPIData(tableName) {
 *     ms.push({ 'server call getAPIData before': tableName })
 *     return new Promise((resolve, reject) => {
 *         setTimeout(() => {
 *             tabsCount[tableName] += 1
 *             ms.push({ 'server call getAPIData after': `table[${tableName}] = ${tabsCount[tableName]}` })
 *             resolve(`table[${tableName}] = ${tabsCount[tableName]}`)
 *         }, 100)
 *     })
 * }
 *
 * //-------- 前端 ---------
 *
 * //instWConverClient
 * let instWConverClient = w.evem()
 *
 * //wsdc
 * let wsdc = new WSyncWebdataClient(instWConverClient, {})
 *
 * //tableTagsCl
 * let tableTagsCl = {
 *     tabA: 'tag-0-a',
 *     tabB: 'tag-0-b',
 * }
 *
 * //setTableTags, 模擬前端將tableTags預先或有變更即儲存至localStorage, 啟動時有既有tableTags
 * wsdc.setTableTags(tableTagsCl)
 * ms.push({ 'clinet setTableTags': JSON.stringify(tableTagsCl) })
 *
 * //push, 模擬前端接收後端推播有變更tableTags
 * ee.on('push', (nowTableTags) => {
 *
 *     //updateTableTags
 *     wsdc.updateTableTags(nowTableTags)
 *     ms.push({ 'clinet updateTableTags': JSON.stringify(nowTableTags) })
 *
 * })
 *
 * //refreshState
 * wsdc.on('refreshState', (msg) => {
 *     console.log('client: refreshState needToRefresh', msg.needToRefresh)
 * })
 *
 * //refreshTable
 * wsdc.on('refreshTable', (input) => {
 *     // console.log('client: refreshTable', input)
 *
 *     //收到有變更tableTags, 模擬呼叫後端API
 *     console.log('client: getAPIData before: ' + input.tableName)
 *     ms.push({ 'clinet call getAPIData before': input.tableName })
 *     getAPIData(input.tableName)
 *         .then((data) => {
 *             console.log('client: getAPIData after: ' + data)
 *             ms.push({ 'clinet call getAPIData after': data })
 *             input.pm.resolve(data) //Use pm.resolve to retrieve the data, and pm.reject the message when get an error.
 *         })
 *         .catch((err) => {
 *             input.pm.reject(err)
 *         })
 *
 * })
 *
 * //getData
 * wsdc.on('getData', (data) => {
 *     console.log('client: getData', data)
 *     //前端取得數據
 * })
 *
 * //beforeUpdateTableTags, afterUpdateTableTags
 * wsdc.on('beforeUpdateTableTags', (msg) => {
 *     // console.log('client: beforeUpdateTableTags', msg)
 * })
 * wsdc.on('afterUpdateTableTags', (msg) => {
 *     // console.log('client: afterUpdateTableTags', msg)
 * })
 *
 * //error
 * wsdc.on('error', (err) => {
 *     console.log('client: error', err)
 * })
 *
 * setTimeout(() => {
 *     console.log('ms', ms)
 * }, 2000)
 *
 * // server: nowTableTags { tabA: 'tag-0-a', tabB: 'tag-0-b' }
 * // server: changeTableTags { tabA: 'tag-1', tabB: 'tag-0-b' }
 * // server: push
 * // client: refreshState needToRefresh true
 * // client: getAPIData before: tabA
 * // client: getAPIData after: table[tabA] = 1
 * // client: getData { tableName: 'tabA', timeTag: 'tag-1', data: 'table[tabA] = 1' }
 * // server: changeTableTags { tabA: 'tag-1', tabB: 'tag-2' }
 * // server: push
 * // client: refreshState needToRefresh true
 * // client: getAPIData before: tabB
 * // client: getAPIData after: table[tabB] = 1
 * // client: getData { tableName: 'tabB', timeTag: 'tag-2', data: 'table[tabB] = 1' }
 * // server: changeTableTags { tabA: 'tag-3', tabB: 'tag-2' }
 * // server: push
 * // client: refreshState needToRefresh true
 * // client: getAPIData before: tabA
 * // client: getAPIData after: table[tabA] = 2
 * // client: getData { tableName: 'tabA', timeTag: 'tag-3', data: 'table[tabA] = 2' }
 * // ms [
 * //   { 'server nowTableTags': '{"tabA":"tag-0-a","tabB":"tag-0-b"}' },
 * //   { 'clinet setTableTags': '{"tabA":"tag-0-a","tabB":"tag-0-b"}' },
 * //   { 'server updateTableTag': 'tabA' },
 * //   { 'server changeTableTags': '{"tabA":"tag-1","tabB":"tag-0-b"}' },
 * //   { 'clinet updateTableTags': '{"tabA":"tag-1","tabB":"tag-0-b"}' },
 * //   { 'clinet call getAPIData before': 'tabA' },
 * //   { 'server call getAPIData before': 'tabA' },
 * //   { 'server updateTableTag': 'tabB' },
 * //   { 'server call getAPIData after': 'table[tabA] = 1' },
 * //   { 'clinet call getAPIData after': 'table[tabA] = 1' },
 * //   { 'server changeTableTags': '{"tabA":"tag-1","tabB":"tag-2"}' },
 * //   { 'clinet updateTableTags': '{"tabA":"tag-1","tabB":"tag-2"}' },
 * //   { 'clinet call getAPIData before': 'tabB' },
 * //   { 'server call getAPIData before': 'tabB' },
 * //   { 'server updateTableTag': 'tabA' },
 * //   { 'server call getAPIData after': 'table[tabB] = 1' },
 * //   { 'clinet call getAPIData after': 'table[tabB] = 1' },
 * //   { 'server changeTableTags': '{"tabA":"tag-3","tabB":"tag-2"}' },
 * //   { 'clinet updateTableTags': '{"tabA":"tag-3","tabB":"tag-2"}' },
 * //   { 'clinet call getAPIData before': 'tabA' },
 * //   { 'server call getAPIData before': 'tabA' },
 * //   { 'server call getAPIData after': 'table[tabA] = 2' },
 * //   { 'clinet call getAPIData after': 'table[tabA] = 2' }
 * // ]
 *
 */
function WSyncWebdataServer(instWConverServer, opt = {}) {
    let nowTableTags = {}
    let isCleared = false
    let isDirty = false //記憶體之時間戳有尚未落檔之變更
    let lastWritten = null //本實體最後寫入檔案之內容, 釋放時據以判斷檔案是否已由其他實體接手

    //check
    if (!iseobj(instWConverServer)) {
        console.log('instWConverServer is not an effective object, and set instWConverServer to an EventEmitter')
        instWConverServer = evem()
    }
    if (!haskey(instWConverServer, 'emit')) {
        throw new Error(`instWConverServer is not an EventEmitter`)
    }

    //fpTableTags
    let fpTableTags = get(opt, 'fpTableTags', '')
    if (!isestr(fpTableTags)) {
        fpTableTags = './tableTags.json'
    }

    //genTag
    let genTag = get(opt, 'genTag')
    if (!isfun(genTag)) {
        genTag = () => {
            return now2str() + '|' + genID(6)
        }
    }

    //eeEmit, 監聽器之同步拋錯一律攔截轉為error事件
    let { emit: eeEmit, emitNow: eeEmitNow, clear: clearEmit } = genEmit(instWConverServer, () => isCleared)

    /**
     * 讀取各資料表時間資料
     *
     * @memberof WSyncWebdataServer
     * @returns {Object} 回傳各資料表時間戳物件
     * @example
     *
     * let tableTags = wsds.readTableTags()
     *
     */
    function readTableTags() {
        let r = {}
        try {
            if (fs.existsSync(fpTableTags)) {
                let c = fs.readFileSync(fpTableTags, 'utf8')
                let o = j2o(c)
                if (iseobj(o)) {
                    r = o
                }
            }
        }
        catch (err) {
            eeEmit('error', {
                msg: 'readTableTags catch',
                err,
            })
        }
        return r
    }

    //writeTableTagsCore, 將記憶體之時間戳寫入檔案, 回傳錯誤或null; 序列化失敗(例如genTag回傳不可序列化之值)時不寫檔, 以免截空既有檔案
    let writeTableTagsCore = () => {
        let r = o2j(nowTableTags, false, { returnWithStateAndMsg: true })
        if (get(r, 'state') !== 'success') {
            return new Error(`can not serialize tableTags: ${get(r, 'msg')}`)
        }
        try {
            fs.writeFileSync(fpTableTags, r.msg, 'utf8')
        }
        catch (err) {
            return err
        }
        lastWritten = r.msg
        isDirty = false
        return null
    }

    /**
     * 儲存各資料表時間資料,將目前之各資料表時間戳寫入檔案,失敗時發出error事件
     *
     * @memberof WSyncWebdataServer
     * @returns {Undefined} 無回傳
     * @example
     *
     * wsds.writeTableTags()
     *
     */
    function writeTableTags() {

        //check
        if (isCleared) {
            return
        }

        //writeTableTagsCore
        let err = writeTableTagsCore()
        if (err) {
            eeEmit('error', {
                msg: 'writeTableTags catch',
                err,
            })
        }

    }

    /**
     * 初始化各資料表時間資料
     *
     * @memberof WSyncWebdataServer
     * @param {Object} tableTags 輸入各資料表時間戳物件,null視同{},其他非物件則發出error事件且不作用;mode為'useInputOnly'時傳入之物件將直接作為內部狀態(之後之更新會就地修改該物件)
     * @param {String} [mode='useInputFirst'] 輸入使用設定方式字串,可有'useInputFirst'代表使用傳入設定優先再與既有JSON檔設定合併,為預設值,'useStorageFirst'代表使用既有JSON檔設定優先再與傳入設定合併,'useInputOnly'代表只使用傳入設定,'useStorageOnly'代表只使用既有JSON檔設定
     * @returns {Undefined} 無回傳
     * @example
     *
     * let tableTags = {...}
     * let mode = ''
     * wsds.initTableTags(tableTags, mode)
     *
     */
    function initTableTags(tableTags = {}, mode = 'useInputFirst') {
        // mode可有:
        // useInputFirst
        // useStorageFirst
        // useInputOnly
        // useStorageOnly

        //check
        if (isCleared) {
            return
        }

        //check, null視同{}, 其他非物件無法作為各表時間戳
        if (tableTags === null) {
            tableTags = {}
        }
        if (!isobj(tableTags)) {
            eeEmit('error', {
                msg: 'invalid tableTags for initTableTags',
                err: tableTags,
            })
            return
        }

        //mode
        if (mode === 'useInputOnly') {
            nowTableTags = tableTags
        }
        else if (mode === 'useStorageOnly') {
            nowTableTags = readTableTags()
        }
        else if (mode === 'useInputFirst') {
            nowTableTags = merge(readTableTags(), tableTags)
        }
        else if (mode === 'useStorageFirst') {
            nowTableTags = merge(tableTags, readTableTags())
        }
        else {
            throw new Error(`invalid mode[${mode}]`)
        }

        //writeTableTags
        writeTableTags()

    }

    /**
     * 直接設定各資料表時間資料
     *
     * @memberof WSyncWebdataServer
     * @param {Object} tableTags 輸入各資料表時間戳物件,null視同{},其他非物件則發出error事件且不作用
     * @returns {Undefined} 無回傳
     * @example
     *
     * let tableTags = {...}
     * wsds.setTableTags(tableTags)
     *
     */
    function setTableTags(tableTags = {}) {

        //check
        if (isCleared) {
            return
        }

        //check, null視同{}, 其他非物件無法合併
        if (tableTags === null) {
            tableTags = {}
        }
        if (!isobj(tableTags)) {
            eeEmit('error', {
                msg: 'invalid tableTags for setTableTags',
                err: tableTags,
            })
            return
        }

        //merge
        nowTableTags = merge(nowTableTags, tableTags)

        //writeTableTags
        writeTableTags()

    }

    /**
     * 直接取得各資料表時間資料,回傳者為內部狀態物件本身,不可修改
     *
     * @memberof WSyncWebdataServer
     * @returns {Object} 回傳各資料表時間戳物件
     * @example
     *
     * let tableTags = wsds.getTableTags()
     *
     */
    function getTableTags() {
        return nowTableTags
    }

    //updateTableTagCore, 避免大量更新時造成大量推播, 通過debounce合併故是回傳nowTableTags; 持續有更新且間隔皆小於200ms時推播會延至更新停止後(無上限)
    let updateTableTagCore = debounce(() => {

        //writeTableTags
        writeTableTags()

        //emit
        eeEmit('changeTableTags', nowTableTags)

    }, 200)

    /**
     * 更新指定資料表之時間戳,當資料表更新時需調用此函數,200ms內之多次更新合併為一次寫檔與推播;持續有更新且間隔皆小於200ms時,推播會延至更新停止後
     *
     * @memberof WSyncWebdataServer
     * @param {String} tableName 輸入欲更新指定資料表名稱字串,非有效字串時發出error事件且不作用
     * @returns {Undefined} 無回傳
     * @example
     *
     * let tableName = '...'
     * wsds.updateTableTag(tableName)
     *
     */
    function updateTableTag(tableName) {

        //check
        if (isCleared) {
            return
        }

        //check
        if (!isestr(tableName)) {
            eeEmit('error', {
                msg: 'invalid tableName',
                err: tableName,
            })
            return
        }

        //modify
        nowTableTags[tableName] = genTag()
        isDirty = true

        //updateTableTagCore
        updateTableTagCore()

    }

    /**
     * 監聽更新資料表事件,當外部監聽收到更新通知時再推播nowTableTags至前端
     *
     * @memberof WSyncWebdataServer
     * @param {Object} nowTableTags 各資料表時間戳物件
     * @example
     *
     * wo.on('changeTableTags', function(nowTableTags) {
     *     ...
     * })
     *
     */
    function onChangeUpdateTableTag() {} onChangeUpdateTableTag()

    /**
     * 釋放資源:取消待觸發之寫檔與推播及已排程之事件,並將尚未落檔之時間戳寫入檔案(不推播;檔案內容已非本實體最後寫入者代表已由其他實體接手,不覆寫),之後各寫入類函數皆不作用且不再派發任何事件,讀取類函數照常,可重複呼叫。落檔失敗時於本函數內同步發出error事件。同一時間戳檔重建實體時,宜先釋放舊實體再建立新實體
     *
     * @memberof WSyncWebdataServer
     * @returns {Undefined} 無回傳
     * @example
     *
     * wsds.clearSync()
     *
     */
    function clearSync() {

        //check
        if (isCleared) {
            return
        }

        //cancel, 取消待觸發之寫檔與推播
        updateTableTagCore.cancel()

        //writeTableTagsCore, 尚未落檔之時間戳須寫入, 否則以讀檔模式重建者會取得舊時間戳而使前端不重拉
        let err = null
        if (isDirty) {

            //c, 檔案目前內容, 讀取失敗時視為無法判斷而不覆寫
            let c = null
            try {
                if (fs.existsSync(fpTableTags)) {
                    c = fs.readFileSync(fpTableTags, 'utf8')
                }
            }
            catch (e) {
                c = undefined
            }

            //檔案不存在, 或仍為本實體最後寫入之內容者才寫入
            if (c === null || c === lastWritten) {
                err = writeTableTagsCore()
            }

        }

        //isCleared, 之後各寫入類函數皆不作用, 事件亦不再派發
        isCleared = true
        clearEmit()

        //落檔失敗於此同步通知, 因釋放後不再派發任何事件故不可延後
        if (err) {
            eeEmitNow('error', {
                msg: 'writeTableTags catch',
                err,
            })
        }

    }

    //save
    instWConverServer.readTableTags = readTableTags
    instWConverServer.writeTableTags = writeTableTags
    instWConverServer.initTableTags = initTableTags
    instWConverServer.setTableTags = setTableTags
    instWConverServer.getTableTags = getTableTags
    instWConverServer.updateTableTag = updateTableTag
    instWConverServer.clearSync = clearSync

    return instWConverServer
}


export default WSyncWebdataServer