Probe/Probe.js

// @ts-nocheck
/**
 * Set new probe model
 * - every new task has a set of requirements controlled by `statusStackOrder` in status setter. Once status is `complete` and data available, information is send and probe is blocked.
 * methods:`{get,all}` props: `{id,data,tasks,status}`
 */

// const messageCODE = require('./errors') // DISPLAY MESSAGES WITH CODE
const { isString, sq, isArray, warn, log, isNumber, onerror, last, copy, isObject, isFunction, dispatcher } = require("x-utils-es/umd")
const ProbeDataBank = require("./Probe.dataBank")

/**
 *
 * @callback emitter_cb
 * @param {Probe} probe
 * @param { 'open' | 'updated' | 'complete' | 'send' | 'error'} status
 * @returns {{}}
 */

/**
 * @class
 */
class Probe extends ProbeDataBank {
    /**
     * @param {object} props probe options `{id,task,campaign,data,status,emitter,completeOnNull}`
     * @param {string} props.id required, case sensitive, all will be toLowerCase()
     * @param {string} props.task once set cannot be changed
     * @param {string} props.campaign optional, once set cannot be changed
     * @param {any} props.data optional any value except undefined, cannot be change once status set to `complete` or send
     * @param {string} props.ref (optional) any type of string reference
     * @param {string} props.status required to control Probe actions
     * @param {emitter_cb} props.emitter optional, dispatcher/emitter available if not null
     * @param {boolean} props.completeOnNull override complete setting even if data was never set
     * @param {boolean} debug
     */
    constructor(props = undefined, opts = {}, debug) {
        super(props, opts, debug)

        this.debug = debug || false
        if (isNumber(props.id) || props.id) props.id = props.id.toString()
        if (!props.task || !isString(props.task)) throw "task as string is required"
        this._id = null
        this._error = [] // REVIEW should be add options for props.error ?
        this._ref = null
        this._task = null
        this._status = null
        this._data = null
        this._campaign = null
        this._dataIndex = 0
        this._statusIndex = 0
        this._statusAsync = [
            /** {timestamp:promise} */
        ] // dynamic promise changer
        this.task = props.task
        this.id = props.id
        this.status = "open"
        this._sealed = false // once the pocket is send of complete we set to true

        this._onChange = opts.onChange || null
        this._onchangeDispatch = undefined // loads dispatcher when `opts.onChange=true` is set
        this.emitter = opts.emitter || null
        // this will allow storing old data in to an array when current data gets updated via setter
        this.withDataBank = opts.withDataBank || false
        this.completeOnNull = opts.completeOnNull || null // when true allows completion on data still at initial null state
        this.disableWarnings = (opts || {}).disableWarnings // disable some less relevant warning messages

        // assign initial data if differs from default
        if (props.ref !== this._ref) this.ref = props.ref
        if (props.data !== this._data) this.data = props.data
        if (props.campaign) this.campaign = props.campaign

        this._completeAsync = sq()
    }

    /**
     * nice and easy, save some coding, and added security
     */
    get sq() {
        if (this._sq) return this._sq
        this._sq = sq()
        return this._sq
    }

    set id(v) {
        if (this._id) {
            if (this.debug) warn("[pocket]", `cannot update already set id: ${this._id}`)
            return
        }
        if (!v) throw "id is required"
        if (v.split(" ").length > 1) throw "each id cannot have spaces"
        if (v.indexOf(`::`) === -1) throw "each id must be of format id::taskName"
        if (v.indexOf(`:::`) !== -1) throw "each id must be of format id::taskName"

        // validate chars
        const pat = /[`~!@#$%^&*()\=?;'",.<>\{\}\[\]\\\/]/gi
        const regx = new RegExp(pat, "gi")
        if (regx.test(v)) throw `your id is invalid, allowed chars: ${pat}`

        v = v.replace(/ /gi, "_").toLowerCase()

        if (v.indexOf(this._task) === -1) {
            throw `wrong id setup, your id should make up the task name, example: id='cocacola::drink'`
        }

        this._id = v
    }

    /**
     * @returns {string}
     */
    get id() {
        return this._id
    }

    /**
     * - collect all errors in to an array
     * - no empty error values will be set
     */
    set error(v) {
        if (!v) return

        // in case data is in its initial status state = 'open' we need to update it to change `_dataIndex`
        //  if (this.data === null) this.data = false
        // NOTE  we now use `this.completeOnNull` so can ignore above logic
        this._error.push(isArray(v) ? v.toString() : v)
        this._error = this._error.filter((z) => !!z)
        this.dispatchChange("error")
    }

    /**
     * returns an arrays of errors or undefined
     * @returns {any[]| undefined}
     */
    get error() {
        if (!this._error.length) return undefined
        return this._error
    }

    /**
     * @returns {string}
     */
    get ref() {
        return this._ref
    }

    /**
     * - accept string, can only be set when status isnt complete
     * - can be used to find your Pocket by particular `ref`
     */
    set ref(v) {
        if (!v) return
        if (!isString(v)) {
            warn("[pocket]", `[ref] must be a string`)
            return
        }
        if (this.status === "complete" || this.status === "send") return

        this._ref = v
        this.dispatchChange("ref")
    }

    /**
     * @returns {string}
     */
    get campaign() {
        return this._campaign
    }

    set campaign(v) {
        if (v === undefined) return
        if (this._campaign) {
            if (this.debug && !this.disableWarnings) warn("[pocket]", `cannot update already set campaign ${this._campaign}`)
            return
        }
        if (!v) return
        if (!isString(v)) {
            if (this.debug) warn("[pocket]", `campaign must be a string`, v)
            return
        }

        this._campaign = v
        this.dispatchChange("campaign")
    }

    set task(v) {
        if (v === undefined) return
        if (this._task) {
            if (this.debug && !this.disableWarnings) warn("[pocket]", `cannot update already set task`)
            return
        }

        if (!v) return
        if (!isString(v)) {
            if (this.debug) warn("[pocket]", `task must be a string`)
            return
        }
        if (v.indexOf("::") !== -1) throw "task separator :: is restricted"
        if (v.split(" ").length > 1) throw "task cannot have spaces, use separators: _+"
        const pat = /[`~!@#$%^&*()\=?;'",.<>\{\}\[\]\\\/]/gi
        const regx = new RegExp(pat, "gi")
        if (regx.test(v)) throw `your task is invalid, allowed chars: ${pat}`

        this._task = v.replace(/ /gi, "_").toLowerCase() // every task must be valid with required
    }

    /**
     * @returns {string}
     */
    get task() {
        return this._task
    }

    set data(v) {
        if (v === undefined) return
        /**
         * cannot be updated uppon status is send || complete
         */
        const complete = this.status === "complete" || this.status === "send"
        if (complete) {
            // NOTE this can also happen if you are using $transfer().$to from `PocketModule` that is a delayed
            if (this.debug && !this.disableWarnings) warn("[pocket]", `you cannot update data once the status is complete or send`)
            return
        }

        this._dataIndex++
        if (this.status === "open" && this._data !== null && this._dataIndex > 1) this.status = "updated"

        this._data = v
        if (this.withDataBank) this.dataBank = v
        this.dispatchChange("data")
    }

    /**
     *
     *
     * @memberof Probe
     * @returns {any}
     */
    get data() {
        return this._data
    }

    /**
     * ### update
     * - update data of current Probe{}.data
     * @param {*} data:any, required
     * @param {*} merge:Boolean, optional for merging object to this.data
     */
    update(data, merge = null) {
        if (this.status === "complete" || this.status === "send") {
            if (this.debug && !this.disableWarnings) warn(`[Probe][update] cannot update data on complete status`)
            return this
        }
        if (!isObject(data) && merge) {
            if (this.debug) warn("[pocket]", `[Probe][update] cannot update none object 'data' with option 'merge=true' set`)
            return this
        }
        if (isObject(data) && merge) this.data = Object.assign({}, this.data, data)
        else if (data !== undefined) this.data = data
        return this
    }

    /**
     * forward motion `status` update is allowed
     * `value`: importance que
     * `set`: if status already set
     */
    get statusStackOrder() {
        return {
            open: { value: 1, set: false },
            updated: { value: 2, set: false },
            complete: { value: 3, set: false },
            send: { value: 4, set: false },
            error: { value: 5, set: false }
        }
    }

    /**
     * allow status: open | updated | complete | send | error
     * `open`: status is set when pocked is initialized
     * `updated`: status is set when data is updated
     * `complete`: status is set when you want to complete and discard probe
     * `send`: once the status was set `complete` data is resolved first then status is set as `send`.
     * and Probe is locked, cannot be interacted with. Follow the strategic order set by `statusStackOrder`
     * `error` acts like complete, it will resolve() last available data and block the Probe
     * @returns {'open' | 'updated' | 'complete' | 'send' | 'error'}
     */
    get status() {
        return this._status
    }

    set status(v) {
        // order of status and allowed values
        ;((stat) => {
            try {
                // meaning do not allow any status changes beyond `updated`
                if (this.statusStackOrder[stat].value > 2 && this.statusStackOrder[stat].set === true) return false
            } catch (err) {
                onerror("statusStackOrder invalid status")
            }

            // if (this._status === 'send' && (stat === 'complete' || stat ==='send')) {
            //     if (this.debug) warn(`cannot update status if already complete, id:${this.id}`)
            //     return false
            // }

            switch (stat) {
                case "open":
                    if (this._status === "updated" || this._status === "send" || this._status === "complete") {
                        if (this.debug) warn("[pocket]", `cannot set status back to open once set to updated/complete/send`)
                        break
                    }

                    this._status = stat
                    this.statusStackOrder[stat].set = true
                    this.onOpenStatus(v) // emit probe when status opens
                    this.setStatusAsync = stat
                    this.dispatchChange("status")
                    break

                case "updated":
                    if (this._status === "complete" || this._status === "send") {
                        if (this.debug) warn("[pocket]", `cannot update status to 'updated' then previously set to 'complete/send'`)
                        break
                    }

                    if (this._dataIndex > 0) {
                        this._status = stat
                        this.statusStackOrder[stat].set = true
                        this.setStatusAsync = stat
                        this.dispatchChange("status")
                        if (this.debug) log(`id:${this.id}, data updated`)
                    }

                    break

                case "complete":
                    if (this.data === null && this.completeOnNull !== true) {
                        if (this.debug) warn("[pocket]", `[status] cannot complete status because data is null, to complete you set data prop to false`)
                        break
                    }
                    this.statusStackOrder[stat].set = true
                    this.setStatusAsync = stat
                    // setTimeout(()=>{
                    this._status = stat
                    this.dispatchChange("status")
                    this.onComplete(v) // resolve probe when status complete
                    //  })

                    break

                case "send":
                    if (this._status !== "complete") {
                        if (this.debug) warn("[pocket]", `cannot update status to 'send' then previously not set to 'complete'`)
                        break
                    }

                    this._status = stat
                    this.statusStackOrder[stat].set = true
                    this.setStatusAsync = stat
                    this._completeAsync.resolve({ status: this._status, id: this.id })
                    this.dispatchChange("status")
                    break

                case "error":
                    if (this._status === "complete") return
                    // when we have error we need to inform what happen, and close the Probe

                    this.statusStackOrder[stat].set = true
                    this.setStatusAsync = stat
                    this.dispatchChange("status")
                    this.onComplete(v) // resolve probe when status complete
                    break

                default:
                    if (this.debug) warn("[pocket]", `id:${this.id},  you set invalid status: ${stat}, nothing changed`)
            }
        })(v)
    }

    /**
     * - works with `statusAsync`
     * - (1.) setter creates our new sq() promise every time, and allows use or resolve
     * - to use example: setStatusAsync.resolve()
     */
    // @ts-ignore
    set setStatusAsync(v) {
        // 'v'  set to anything to initiate setter
        const timestamp = new Date().getTime()
        const p = { timestamp, p: sq() }
        this._statusAsync.push(p)
    }

    /**
     * Resolve text promise and returns last status
     *
     * memberof Probe
     * @returns {Promise<any>}
     */
    get setStatusAsync() {
        const lastPromise = last(this._statusAsync.sort((a, b) => a.timestamp - b.timestamp).map((z) => z["p"]))
        lastPromise.resolve(copy(this.status)) // << we are only returning
        return lastPromise
    }

    /**
     * dynamic promise resolver with `Simple Q` from `eaglex.net`
     * - works with `setStatusAsync` setter/getter
     * - return last 'resolve' status from last `timestamp` setting
     * @returns {Promise<string>} status name
     */
    get getStatusAsync() {
        // @ts-ignore
        return this.setStatusAsync.promise
    }

    /**
     * when status is set to complete or send, the promise will then be resolved
     * @returns {Promise<{status, id}>}
     */
    get completeAsync() {
        return this._completeAsync.promise
    }

    /**
     * - alias of `getStatusAsync`
     * @readonly
     */
    get statusAsync() {
        return this.getStatusAsync
    }

    /**
     * @typedef {Object} allReturns
     * @prop {string} ref
     * @prop {any} data
     * @prop {string} id
     * @prop {string} task
     * @prop {string} status
     * @prop {any?} error
     * @prop {string?} campaign
     *
     */

    /**
     * returns copy of Prob properties
     * @returns {(allReturns)}
     */
    all() {
        const d = { error: this.error, ref: this.ref, campaign: this.campaign, data: this.data, id: this.id, task: this.task, status: this.status }
        if (this.withDataBank) d.dataBank = copy(this.dataBank)
        return d
    }

    /**
     *
     * @callback onChange_cb
     * @param {Probe} probe
     * @param {string} id
     * @returns {{}}
     */

    /**
     * can be used when `opts.onChange=true` is set
     * - changes are observed for `[ data,status,ref,error,campaign,status:complete]`
     * @param {onChange_cb} cb(data,id) callback returns updated value in real time
     */
    onChange(cb, watch = "all") {
        if (!this._onChange) {
            if (this.debug) warn("[pocket]", `[onChange] to use need to set opts.onChange=true`)
            return this
        }
        if (!isFunction(cb)) {
            if (this.debug) warn(`[onChange] cb must be a function`)
            return this
        }
        let availableWatch = ["all", "data", "status", "ref", "error", "campaign"]

        // allow lookout for status complete event only when selected to watch
        availableWatch = [].concat(availableWatch, ["status:complete"])

        if (!availableWatch.includes(watch)) {
            if (this.debug) warn("[pocket]", `[onChange] no watch available for ${watch}`)
            return this
        }

        let statIndex = availableWatch.indexOf("status:complete")
        availableWatch.splice(statIndex, 1)

        const self = this

        if (!this.onchangeDispatch) {
            if (this.debug) warn("[pocket]", `[onChange] onchangeDispatch no longer active`)
            return this
        }

        this.onchangeDispatch.subscribe(function (data, id) {
            // NOTE data['changed'] // returned in dispatch only provided name of asset changed
            // no point to carry data if we can access it direct
            if (data["changed"] && watch === "all") {
                cb.bind(self)(self, id)
                return
            }

            /**
             * on status complete return all data copy in callback
             */
            if (watch === "status:complete") {
                if (data["changed"] === "status" && (self["status"] === "send" || self["status"] === "complete") && !self._sealed) {
                    let d = {
                        ...copy(self),
                        status: "complete"
                    }
                    cb.bind(self)(d, id)
                    self._sealed = true
                }
                return this
            }

            if (data["changed"] === watch && self[watch] !== undefined) {
                cb.bind(self)(self[watch], id)
            }
        })
        return this
    }

    /**
     * - works with onchangeDispatch, onChange
     * - emits next value to `onchangeDispatch` listener
     * @param {*} changedName required, provide name of Probe prop to alert dispatcher what has changed
     * @returns self
     */
    dispatchChange(changedName) {
        if (!this._onChange) {
            return false
        }
        if (!this.onchangeDispatch) {
            return false
        }
        this.onchangeDispatch.next({ changed: changedName })
        return true
    }

    /**
     * initiates dispatcher to handle on change value of [data,status,ref,error,campaign]
     * @returns dispatcher instance
     */
    get onchangeDispatch() {
        if (!this._onChange) {
            if (this.debug) warn("[pocket]", `[onchangeDispatch] to use need to set opts.onChange=true`)
            return null
        }

        if (this._onchangeDispatch) {
            return this._onchangeDispatch
        }

        this._onchangeDispatch = dispatcher(this.id)
        return this._onchangeDispatch
    }

    /**
     * status watch, when current status changes execute send
     * @param {*} status
     */
    onComplete(status) {
        if ((status === "complete" || status === "error") && this._status !== "send" && (this._dataIndex > 0 || this.completeOnNull === true)) {
            if (this.emitter) {
                setTimeout(() => {
                    this.emitter({ probe: this, status })
                })
            }
            this._status = "send"
            this.sq.resolve({ probe: this.all() })

            setTimeout(() => {
                // in case delete listener when data complete
                // @ts-ignore
                if (this.onchangeDispatch) this.onchangeDispatch.del()
            })
        }
        return this
    }

    /**
     * do something on open task, this means we start request for data
     * @param {*} status
     */
    onOpenStatus(status) {
        if (status === "open") {
            // return this probe and update it when its complete
            if (this.emitter) {
                setTimeout(() => {
                    this.emitter({ probe: this, status: "open" })
                })
            }
        }
        return this
    }
}

module.exports = Probe