diff --git a/nodes/config/ui_base.js b/nodes/config/ui_base.js index ebedfdd55..7fab5e2b0 100644 --- a/nodes/config/ui_base.js +++ b/nodes/config/ui_base.js @@ -34,6 +34,8 @@ module.exports = function (RED) { datastore.setConfig(RED) statestore.setConfig(RED) + let warnedStoreUnavailable = false + /** * @typedef {import('socket.io').Socket} Socket * @typedef {import('socket.io').Server} Server @@ -395,13 +397,12 @@ module.exports = function (RED) { onUnavailable: (err) => { unavailable = err }, onReplaced: () => node.warn('global.dashboardStore was replaced by a flow since the last deploy, discarding the stored value of every widget. The store has been re-created. To write your own data, set global.dashboardStore. rather than replacing global.dashboardStore itself.') }) - if (!store) { - datastore.disableStore() - node.warn('Dashboard data store disabled: the global context store doesn\'t hold objects by reference (e.g. cache: false), so live state can\'t work. Use the memory store or localfilesystem with cache: true.' + (unavailable ? ' Reading it failed with: ' + unavailable.message : '')) + if (!store && !warnedStoreUnavailable) { + warnedStoreUnavailable = true + node.warn('Dashboard data store: flows can\'t use global.dashboardStore, because the default context store doesn\'t keep values in memory (e.g. localfilesystem with cache: false). Dashboards still work normally. To use global.dashboardStore from flows, set contextStorage.default to memory, or localfilesystem with cache: true.' + (unavailable ? ' (Reason: ' + unavailable.message + ')' : '')) } } catch (err) { - datastore.disableStore() - node.warn('Dashboard data store disabled: could not initialise it in global context (' + err.message + ').') + node.warn('Dashboard data store: flows can\'t use global.dashboardStore, because saving it to global context failed (' + err.message + '). Dashboards still work normally.') } n.root = RED.settings.httpNodeRoot || '/' diff --git a/nodes/store/data.js b/nodes/store/data.js index 00799efbb..928103a11 100644 --- a/nodes/store/data.js +++ b/nodes/store/data.js @@ -1,23 +1,21 @@ const { isClientScoped } = require('../utils/index.js') -const { attachToContext, MERGE_ENTRY, APPEND_ENTRY, SET_SERIES, TRIM_SERIES } = require('./reactive.js') - -const data = {} +const { createDataStore, attachToContext, MERGE_ENTRY, APPEND_ENTRY, SET_SERIES, TRIM_SERIES, ENTRY_MSG } = require('./reactive.js') const config = { RED: null } let storeOptions = {} -let storeEnabled = false +let fallbackStore = null let storeContext = null let storeBase = null let warned = false -function warnOnce (node, problem) { - if (warned || !node) return +function warnOnce (problem) { + if (warned || !storeBase) return warned = true - node.warn(`Dashboard data store: ${problem}. Further store failures are not reported until the next full deploy or restart.`) + storeBase.warn(`Dashboard data store: ${problem}. Further store failures are not reported until the next full deploy or restart.`) } const attachOptions = () => ({ clone: config.RED.util.cloneMessage, ...storeOptions }) @@ -29,48 +27,28 @@ function getOrCreateStore (globalContext) { return store } +function getStoreOrWarn (action, id) { + try { + return fallbackStore || getOrCreateStore(storeContext) + } catch (err) { warnOnce(`${action} ${id} failed (${err.message})`) } +} + function initStore (globalContext, { node, ...opts } = {}) { storeOptions = opts - storeEnabled = true storeContext = globalContext storeBase = node warned = false - return attachToContext(globalContext, attachOptions()) -} - -function disableStore () { - storeEnabled = false -} - -function writeToStore (node, msg) { - if (!storeEnabled) return - if (!msg || typeof msg !== 'object') return - try { - getOrCreateStore(node.context().global)[MERGE_ENTRY](node.id, msg) - } catch (err) { warnOnce(node, `a store write failed (${err.message}), so global.dashboardStore may be out of date`) } -} - -function appendToStore (node, msg) { - if (!storeEnabled) return - if (!msg || typeof msg !== 'object') return - try { - getOrCreateStore(node.context().global)[APPEND_ENTRY](node.id, msg) - } catch (err) { warnOnce(node, `a store write failed (${err.message}), so global.dashboardStore may be out of date`) } -} - -function replaceInStore (node, msgs) { - if (!storeEnabled) return - if (!Array.isArray(msgs) || msgs.some((m) => !m || typeof m !== 'object')) return - try { - getOrCreateStore(node.context().global)[SET_SERIES](node.id, msgs) - } catch (err) { warnOnce(node, `a store write failed (${err.message}), so global.dashboardStore may be out of date`) } -} - -function trimInStore (node, trim) { - if (!storeEnabled) return + let store = null try { - getOrCreateStore(node.context().global)[TRIM_SERIES](node.id, trim) - } catch (err) { warnOnce(node, `a store write failed (${err.message}), so global.dashboardStore may be out of date`) } + store = attachToContext(globalContext, attachOptions()) + } finally { + if (store) { + fallbackStore = null + } else if (!fallbackStore) { + fallbackStore = createDataStore(attachOptions()) + } + } + return store } function latestPerTopic (msgs, maxPoints) { @@ -120,26 +98,17 @@ function canSaveInStore (base, node, msg) { return checks.length === 0 || !checks.includes(false) } -// Strip msg of properties that are not needed for storage -function stripMsg (msg) { - const newMsg = config.RED.util.cloneMessage(msg) - - // don't need to store ui_updates in the datastore, as this is handled in statestore - delete newMsg.ui_update - - return newMsg -} - const getters = { RED () { return config.RED }, // given a widget id, return the latest msg received msg (id) { - return config.RED.util.cloneMessage(data[id]) + return config.RED.util.cloneMessage(getStoreOrWarn('reading', id)?.[ENTRY_MSG](id)) }, has (id) { - return !!data[id] + const store = getStoreOrWarn('reading', id) + return !!store && id in store } } @@ -150,13 +119,10 @@ const setters = { }, // remove data associated to a given widget clear (id) { - delete data[id] - if (!storeEnabled) { - return + const store = getStoreOrWarn('clearing', id) + if (store) { + delete store[id] } - try { - delete getOrCreateStore(storeContext)[id] - } catch (err) { warnOnce(storeBase, `clearing the store entry for ${id} failed (${err.message}), so global.dashboardStore may still hold its old value`) } }, /** * @@ -166,35 +132,20 @@ const setters = { */ save (base, node, msg) { if (Array.isArray(msg)) { - /// need to check msg by msg - const filtered = [] - for (const m of msg) { - if (canSaveInStore(base, node, m)) { - filtered.push(config.RED.util.cloneMessage(m)) - } - } - data[node.id] = filtered - replaceInStore(node, filtered) - } else { - if (canSaveInStore(base, node, msg)) { - const newMsg = stripMsg(msg) - data[node.id] = { - ...data[node.id], - ...newMsg - } - writeToStore(node, newMsg) + const msgs = msg.filter((m) => canSaveInStore(base, node, m)) + if (msgs.every((m) => m && typeof m === 'object')) { + getStoreOrWarn('saving', node.id)?.[SET_SERIES](node.id, msgs) } + } else if (msg && typeof msg === 'object' && canSaveInStore(base, node, msg)) { + const { ui_update: uiUpdate, ...rest } = msg + getStoreOrWarn('saving', node.id)?.[MERGE_ENTRY](node.id, rest) } }, // given a widget id, and msg, store in an array of history of values // useful for charting widgets append (base, node, msg) { - if (canSaveInStore(base, node, msg)) { - if (!data[node.id]) { - data[node.id] = [] - } - data[node.id].push(config.RED.util.cloneMessage(msg)) - appendToStore(node, msg) + if (msg && typeof msg === 'object' && canSaveInStore(base, node, msg)) { + getStoreOrWarn('saving', node.id)?.[APPEND_ENTRY](node.id, msg) } }, /** @@ -204,28 +155,12 @@ const setters = { * @param {(msg) => Boolean} filterFunction */ filter (base, node, filterFunction) { - const currentData = data[node.id] - if (filterFunction && Array.isArray(currentData) && currentData.length) { - const filteredMessages = currentData.filter(filterFunction) - if (filteredMessages.length !== currentData.length) { - // no need for save operation to process messages - just apply them - data[node.id] = filteredMessages - } - } if (filterFunction) { - trimInStore(node, (msgs) => msgs.filter(filterFunction)) + getStoreOrWarn('trimming', node.id)?.[TRIM_SERIES](node.id, (msgs) => msgs.filter(filterFunction)) } }, keepLatestPerTopic (base, node, maxPoints) { - trimInStore(node, (msgs) => latestPerTopic(msgs, maxPoints)) - const currentData = data[node.id] - if (!Array.isArray(currentData)) { - return - } - const kept = latestPerTopic(currentData, maxPoints) - if (kept.length !== currentData.length) { - data[node.id] = kept - } + getStoreOrWarn('trimming', node.id)?.[TRIM_SERIES](node.id, (msgs) => latestPerTopic(msgs, maxPoints)) } } @@ -239,6 +174,5 @@ module.exports = { filter: setters.filter, keepLatestPerTopic: setters.keepLatestPerTopic, clear: setters.clear, - initStore, - disableStore + initStore } diff --git a/nodes/store/reactive.js b/nodes/store/reactive.js index 858a4fb74..a4ca049d6 100644 --- a/nodes/store/reactive.js +++ b/nodes/store/reactive.js @@ -3,6 +3,7 @@ const APPEND_ENTRY = Symbol.for('@flowfuse/node-red-dashboard/appendEntry') const SET_SERIES = Symbol.for('@flowfuse/node-red-dashboard/setSeries') const MERGE_ENTRY = Symbol.for('@flowfuse/node-red-dashboard/mergeEntry') const TRIM_SERIES = Symbol.for('@flowfuse/node-red-dashboard/trimSeries') +const ENTRY_MSG = Symbol.for('@flowfuse/node-red-dashboard/entryMsg') const TRIM_BATCH = 60 const NAMESPACE = 'dashboardStore' @@ -41,7 +42,6 @@ function deepReactive (value, notify, path, clone, seen = new WeakMap()) { if (!isReactable(value)) return value if (seen.has(value)) return seen.get(value) const proxy = new Proxy(value, { - get (t, p, r) { return Reflect.get(t, p, r) }, set (t, p, v) { if (typeof p === 'symbol') return Reflect.set(t, p, v) // a push writes the new index then grows length; only a shrink is a change worth reporting @@ -112,6 +112,12 @@ function createDataStore ({ maxHistory = 5, onChange, clone = deepClone, now = D } const trimmedSeries = (rec) => rec.trims ? rec.trims.kept.concat(rec.msg.slice(rec.trims.seen)) : rec.msg.slice() + const msgOf = (rec) => Array.isArray(rec.msg) ? Object.freeze(trimmedSeries(rec)) : rec.msg + + records[ENTRY_MSG] = (prop) => { + const rec = records[prop] + return rec ? msgOf(rec) : undefined + } records[MERGE_ENTRY] = (prop, msg) => { const current = records[prop] @@ -119,8 +125,10 @@ function createDataStore ({ maxHistory = 5, onChange, clone = deepClone, now = D const previous = Array.isArray(current?.msg) ? freezeOwned(cloneValue({ ...trimmedSeries(current) })) : current?.msg || {} const { payload: storedPayload, ...stored } = previous const keepValue = current && !Array.isArray(current.msg) && !('payload' in msg) - const rec = keepValue ? current : writeValue(prop, cloneValue(payload)) - const fields = { ...stored, ...freezeOwned(cloneValue(incoming)) } + const owned = freezeOwned(cloneValue(incoming)) + const value = keepValue ? undefined : cloneValue(payload) + const rec = keepValue ? current : writeValue(prop, value) + const fields = { ...stored, ...owned } const hasPayload = 'payload' in msg || (keepValue && 'payload' in current.msg) rec.msg = Object.freeze(hasPayload ? { ...fields, payload: rec.value } : fields) rec.timestamp = now() @@ -163,13 +171,13 @@ function createDataStore ({ maxHistory = 5, onChange, clone = deepClone, now = D records[APPEND_ENTRY] = (prop, msg) => { let rec = records[prop] const replaced = !!rec && !(Array.isArray(rec.value) && Array.isArray(rec.msg)) + // the proxy clones on push, so points must not be cloned again here + const { _datapoint, ...rest } = msg + const owned = cloneValue(rest) if (!rec || replaced) { rec = writeValue(prop, []) rec.msg = [] } - // the proxy clones on push, so points must not be cloned again here - const { _datapoint, ...rest } = msg - const owned = cloneValue(rest) const start = rec.value.length for (const p of pointsOf(_datapoint)) rec.value.push(p) const stored = toStoredDatapoint(rec.value, _datapoint, start, rec.value.length) @@ -208,7 +216,7 @@ function createDataStore ({ maxHistory = 5, onChange, clone = deepClone, now = D if (prop.startsWith('$')) { const rec = target[prop.slice(1)] if (!rec) return undefined - const msg = Array.isArray(rec.msg) ? Object.freeze(trimmedSeries(rec)) : rec.msg + const msg = msgOf(rec) return Object.freeze({ value: rec.value, msg, quality: rec.quality, timestamp: rec.timestamp, history: Object.freeze(rec.history.slice()) }) } const rec = target[prop] @@ -241,7 +249,7 @@ function createDataStore ({ maxHistory = 5, onChange, clone = deepClone, now = D const injected = new WeakMap() // Inject the store into global context once. On redeploy the existing store is reused; -// after a restart with a persistent context store, existing plain data is rehydrated. +// after a restart it starts fresh and plain data a flow put in its place is copied in. // Returns null when the context store can't hold a live proxy: a cache-off store serves // only async access so a sync get throws, and a serialising store hands back a copy. function attachToContext (globalContext, opts = {}) { @@ -261,7 +269,7 @@ function attachToContext (globalContext, opts = {}) { const previous = injected.get(globalContext) const store = createDataStore(opts) - if (isReactable(existing) && !Array.isArray(existing)) { + if (previous && isReactable(existing) && !Array.isArray(existing)) { for (const [k, v] of Object.entries(existing)) store[k] = v } globalContext.set(namespace, store) @@ -271,4 +279,4 @@ function attachToContext (globalContext, opts = {}) { return store } -module.exports = { createDataStore, attachToContext, NAMESPACE, STORE, APPEND_ENTRY, SET_SERIES, MERGE_ENTRY, TRIM_SERIES } +module.exports = { createDataStore, attachToContext, NAMESPACE, STORE, APPEND_ENTRY, SET_SERIES, MERGE_ENTRY, TRIM_SERIES, ENTRY_MSG } diff --git a/test/nodes/config-nodes/ui_base.spec.js b/test/nodes/config-nodes/ui_base.spec.js index 84d58f262..f6ef5ce72 100644 --- a/test/nodes/config-nodes/ui_base.spec.js +++ b/test/nodes/config-nodes/ui_base.spec.js @@ -153,12 +153,23 @@ describe('ui-base config node: cache-off context store', function () { await helper.load(nodeImports, flow) const warned = helper.log().args.filter((args) => - args[0].level === helper.log().WARN && /hold objects by reference/.test(args[0].msg) + args[0].level === helper.log().WARN && /doesn't keep values in memory/.test(args[0].msg) ) warned.should.have.length(1) touched.set.should.equal(0) }) + it('warns once per start, not on every full deploy', async function () { + await helper.load(nodeImports, flow) + + await helper.setFlows(flow, 'full') + + const warned = helper.log().args.filter((args) => + args[0].level === helper.log().WARN && /doesn't keep values in memory/.test(args[0].msg) + ) + warned.should.have.length(1) + }) + it('stops the write path instead of throwing on every message', async function () { await helper.load(nodeImports, flow) const text = helper.getNode('node-ui-text') diff --git a/test/store/data.spec.js b/test/store/data.spec.js index 2bc0acbc6..60bf94019 100644 --- a/test/store/data.spec.js +++ b/test/store/data.spec.js @@ -13,8 +13,14 @@ function fakeGlobal () { return { get: (k) => m[k], set: (k, v) => { m[k] = v } } } -function fakeNode (id, global) { - return { id, type: 'ui-text', context: () => ({ global }), warn: (m) => warnings.push(`${id}: ${m}`) } +function storeGlobal () { + const global = fakeGlobal() + datastore.initStore(global, {}) + return global +} + +function fakeNode (id) { + return { id, type: 'ui-text', warn: (m) => warnings.push(`${id}: ${m}`) } } const base = { acceptsClientConfig: [] } @@ -25,28 +31,27 @@ describe('store: data.js reactive-store mirror', function () { }) it('mirrors the clean payload into global.dashboardStore[id], not the whole message', function () { - const global = fakeGlobal() - const node = fakeNode('w1', global) + const global = storeGlobal() + const node = fakeNode('w1') datastore.save(base, node, { payload: 42, topic: 't', _msgid: 'm', extra: 'x' }) const store = global.get('dashboardStore') store.w1.should.equal(42) - // the reactive store holds only the value; the whole message stays in the legacy datastore datastore.get('w1').payload.should.equal(42) datastore.get('w1').topic.should.equal('t') }) it('keeps the mirrored value in sync on the latest write', function () { - const global = fakeGlobal() - const node = fakeNode('w2', global) + const global = storeGlobal() + const node = fakeNode('w2') datastore.save(base, node, { payload: 1 }) datastore.save(base, node, { payload: 2 }) global.get('dashboardStore').w2.should.equal(2) }) it('stores the whole message alongside the value', function () { - const global = fakeGlobal() - const node = fakeNode('m1', global) + const global = storeGlobal() + const node = fakeNode('m1') datastore.save(base, node, { payload: 42, topic: 't', extra: 'x' }) const store = global.get('dashboardStore') @@ -56,28 +61,18 @@ describe('store: data.js reactive-store mirror', function () { store.$m1.msg.extra.should.equal('x') }) - it('keeps the stored message in step with the legacy merge', function () { - const global = fakeGlobal() - const node = fakeNode('m3', global) + it('merges a later message onto the stored one', function () { + storeGlobal() + const node = fakeNode('m3') datastore.save(base, node, { payload: 1, topic: 'a', unit: 'bar' }) datastore.save(base, node, { payload: 2 }) - global.get('dashboardStore').$m3.msg.should.eql(datastore.get('m3')) - }) - - it('does not alias the legacy store into the mirror', function () { - const global = fakeGlobal() - const node = fakeNode('m4', global) - datastore.save(base, node, { payload: 1, topic: 'a' }) - - global.get('dashboardStore').$m4.msg.topic = 'mutated' - - datastore.get('m4').topic.should.equal('a') + datastore.get('m3').should.eql({ payload: 2, topic: 'a', unit: 'bar' }) }) it('does not alias the flow\'s message into the store', function () { - const global = fakeGlobal() - const node = fakeNode('m2', global) + const global = storeGlobal() + const node = fakeNode('m2') const sent = { payload: 1, nested: { a: 1 } } datastore.save(base, node, sent) @@ -87,16 +82,17 @@ describe('store: data.js reactive-store mirror', function () { }) it('does not mirror a client-scoped message (not stored centrally)', function () { - const global = fakeGlobal() - const node = fakeNode('w3', global) + const global = storeGlobal() + const node = fakeNode('w3') const scopedBase = { acceptsClientConfig: ['ui-text'] } datastore.save(scopedBase, node, { payload: 9, _client: { socketId: 's1' } }) - should(global.get('dashboardStore')).be.undefined() + should(global.get('dashboardStore').w3).be.undefined() + datastore.has('w3').should.equal(false) }) it('deep-copies a table rows array, so a flow reusing it cannot mutate stored state', function () { - const global = fakeGlobal() - const node = fakeNode('tbl-1', global) + const global = storeGlobal() + const node = fakeNode('tbl-1') const rows = [{ id: 1, name: 'a', meta: { site: { rack: 'r1' } } }] datastore.save(base, node, { payload: rows }) @@ -108,30 +104,30 @@ describe('store: data.js reactive-store mirror', function () { ]) }) - it('keeps the mirrored rows independent of the legacy datastore', function () { - const global = fakeGlobal() - const node = fakeNode('tbl-2', global) + it('reads a value only a flow wrote', function () { + const global = storeGlobal() - datastore.save(base, node, { payload: [{ id: 1, name: 'original' }] }) - global.get('dashboardStore')['tbl-2'][0].name = 'changed via store' + global.get('dashboardStore')['flow-only'] = 5 - datastore.get('tbl-2').payload.should.eql([{ id: 1, name: 'original' }]) + datastore.has('flow-only').should.equal(true) + datastore.get('flow-only').should.eql({ payload: 5 }) }) - it('does not throw or mirror when the context store cannot hold the proxy', function () { - const throwingGlobal = { - get: () => { throw new Error('File Store cache disabled - only asynchronous access supported') }, - set: () => {} - } - const node = fakeNode('w5', throwingGlobal) - should(() => datastore.save(base, node, { payload: 7 })).not.throw() - datastore.get('w5').payload.should.equal(7) // legacy datastore still works + it('reads what a flow wrote into the stored rows, as a copy the caller owns', function () { + const global = storeGlobal() + const node = fakeNode('tbl-2') + + datastore.save(base, node, { payload: [{ id: 1, name: 'original' }] }) + global.get('dashboardStore')['tbl-2'][0].name = 'changed via store' + datastore.get('tbl-2').payload[0].name = 'changed by the caller' + + datastore.get('tbl-2').payload.should.eql([{ id: 1, name: 'changed via store' }]) }) it('clear removes the widget key so a removed widget leaves no orphan', function () { const global = fakeGlobal() datastore.initStore(global, {}) - const node = fakeNode('w6', global) + const node = fakeNode('w6') datastore.save(base, node, { payload: 5 }) global.get('dashboardStore').w6.should.equal(5) @@ -140,8 +136,8 @@ describe('store: data.js reactive-store mirror', function () { }) it('keeps the mirrored value when a later message carries no payload', function () { - const global = fakeGlobal() - const node = fakeNode('w8', global) + const global = storeGlobal() + const node = fakeNode('w8') datastore.save(base, node, { payload: 'keep me' }) datastore.save(base, node, { topic: 'no payload' }) @@ -151,8 +147,8 @@ describe('store: data.js reactive-store mirror', function () { }) it('blanks the mirrored value when a message explicitly carries payload: undefined', function () { - const global = fakeGlobal() - const node = fakeNode('w11', global) + const global = storeGlobal() + const node = fakeNode('w11') datastore.save(base, node, { payload: 'keep me' }) datastore.save(base, node, { payload: undefined, topic: 'explicit' }) @@ -161,7 +157,7 @@ describe('store: data.js reactive-store mirror', function () { }) it('does not throw when a msg is not an object (unchanged from main)', function () { - const global = fakeGlobal() + const global = storeGlobal() for (const msg of ['hello', 42, true]) { const node = fakeNode('prim-' + String(msg), global) should(() => datastore.save(base, node, msg)).not.throw() @@ -173,7 +169,7 @@ describe('store: data.js reactive-store mirror', function () { get: () => { throw new Error('File Store cache disabled - only asynchronous access supported') }, set: () => {} } - const node = fakeNode('w7', throwingGlobal) + const node = fakeNode('w7') datastore.initStore(throwingGlobal, { node: { warn: () => {} } }) should(() => datastore.clear(node.id)).not.throw() @@ -187,7 +183,7 @@ describe('store: data.js store options', function () { it('re-injects with the registered options when a flow replaces the namespace', function () { const global = fakeGlobal() - const node = fakeNode('w9', global) + const node = fakeNode('w9') let replaced = 0 datastore.initStore(global, { onReplaced: () => { replaced++ } }) @@ -201,7 +197,7 @@ describe('store: data.js store options', function () { it('keeps onChange wired through a widget-triggered re-injection', function () { const global = fakeGlobal() - const node = fakeNode('w10', global) + const node = fakeNode('w10') const changes = [] datastore.initStore(global, { onChange: (key) => changes.push(key) }) @@ -212,7 +208,7 @@ describe('store: data.js store options', function () { }) }) -describe('store: data.js disabled store', function () { +describe('store: data.js when global context cannot hold the store', function () { const cacheOffGlobal = () => ({ gets: 0, get () { this.gets++; throw new Error('File Store cache disabled - only asynchronous access supported') }, @@ -223,37 +219,41 @@ describe('store: data.js disabled store', function () { datastore.initStore(fakeGlobal(), {}) }) - it('stops touching global context once the store is disabled', function () { + it('keeps writes off global context and reports no failures', function () { const global = cacheOffGlobal() - const scalar = fakeNode('w12', global) - const chart = fakeNode('w12-chart', global) - datastore.initStore(global, { node: { warn: () => {} } }) + const scalar = fakeNode('w12') + const chart = fakeNode('w12-chart') + should(datastore.initStore(global, { node: { warn: (m) => warnings.push(m) } })).be.null() + warnings.length = 0 global.gets = 0 - datastore.disableStore() datastore.save(base, scalar, { payload: 1 }) datastore.append(base, chart, { _datapoint: { category: 'a', x: 1, y: 1 } }) datastore.filter(base, chart, () => false) + datastore.keepLatestPerTopic(base, chart, 1) datastore.clear(scalar.id) global.gets.should.equal(0) + warnings.should.eql([]) }) - it('leaves the legacy datastore working while disabled', function () { - const global = cacheOffGlobal() - const node = fakeNode('w13', global) - datastore.disableStore() + it('falls back when writing to global context throws', function () { + const global = { get: () => undefined, set () { throw new Error('read-only context') } } + const node = fakeNode('w13') + should(() => datastore.initStore(global, { node: { warn: (m) => warnings.push(m) } })).throw('read-only context') + warnings.length = 0 - datastore.save(base, node, { payload: 'legacy still works' }) + datastore.save(base, node, { payload: 'still works' }) - datastore.get('w13').payload.should.equal('legacy still works') + warnings.should.eql([]) + datastore.get('w13').payload.should.equal('still works') }) - it('re-enables on a later initStore', function () { - datastore.disableStore() + it('goes back to global context on a later initStore', function () { + datastore.initStore(cacheOffGlobal(), {}) const global = fakeGlobal() - const node = fakeNode('w14', global) + const node = fakeNode('w14') datastore.initStore(global, {}) datastore.save(base, node, { payload: 'back on' }) @@ -262,17 +262,21 @@ describe('store: data.js disabled store', function () { }) describe('store: data.js chart writes', function () { + beforeEach(function () { + datastore.initStore(fakeGlobal(), {}) + }) + const pt = (x, y, category = 'a') => ({ category, x, y }) // an absent chart key only proves anything if the store is actually working function liveStore (global, id) { - datastore.save(base, fakeNode(id, global), { payload: 'live' }) + datastore.save(base, fakeNode(id), { payload: 'live' }) return global.get('dashboardStore')?.[id] } it('mirrors a chart append into the store', function () { - const global = fakeGlobal() - const node = fakeNode('chart-1', global) + const global = storeGlobal() + const node = fakeNode('chart-1') datastore.append(base, node, { payload: 1, _datapoint: pt(1, 1) }) datastore.append(base, node, { payload: 2, _datapoint: pt(2, 2) }) @@ -285,8 +289,8 @@ describe('store: data.js chart writes', function () { }) it('flattens a multi-series append into several points', function () { - const global = fakeGlobal() - const node = fakeNode('chart-multi', global) + const global = storeGlobal() + const node = fakeNode('chart-multi') datastore.append(base, node, { payload: [1, 5], _datapoint: [pt(1, 1, 'a'), pt(1, 5, 'b')] }) @@ -297,8 +301,8 @@ describe('store: data.js chart writes', function () { }) it('stores a message with no datapoint but adds no point', function () { - const global = fakeGlobal() - const node = fakeNode('chart-nodp', global) + const global = storeGlobal() + const node = fakeNode('chart-nodp') datastore.append(base, node, { payload: 'x' }) @@ -308,8 +312,8 @@ describe('store: data.js chart writes', function () { }) it('does not alias the flow\'s message into an appended series', function () { - const global = fakeGlobal() - const node = fakeNode('chart-alias', global) + const global = storeGlobal() + const node = fakeNode('chart-alias') const sent = { payload: 1, _datapoint: pt(1, 1), nested: { a: 1 } } datastore.append(base, node, sent) @@ -319,8 +323,8 @@ describe('store: data.js chart writes', function () { }) it('mirrors an array save into the store as a whole series', function () { - const global = fakeGlobal() - const node = fakeNode('chart-2', global) + const global = storeGlobal() + const node = fakeNode('chart-2') datastore.save(base, node, [ { payload: 1, _datapoint: pt(1, 1) }, @@ -334,8 +338,8 @@ describe('store: data.js chart writes', function () { }) it('replaces rather than appends on a second array save', function () { - const global = fakeGlobal() - const node = fakeNode('chart-replace', global) + const global = storeGlobal() + const node = fakeNode('chart-replace') datastore.save(base, node, [{ payload: 1, _datapoint: pt(1, 1) }]) datastore.save(base, node, [{ payload: 9, _datapoint: pt(9, 9) }]) @@ -346,8 +350,8 @@ describe('store: data.js chart writes', function () { }) it('empties the series when saved an empty array', function () { - const global = fakeGlobal() - const node = fakeNode('chart-clear', global) + const global = storeGlobal() + const node = fakeNode('chart-clear') datastore.append(base, node, { payload: 1, _datapoint: pt(1, 1) }) datastore.save(base, node, []) @@ -358,8 +362,8 @@ describe('store: data.js chart writes', function () { }) it('does not rebuild the stored series until the batch is reached', function () { - const global = fakeGlobal() - const node = fakeNode('chart-3', global) + const global = storeGlobal() + const node = fakeNode('chart-3') for (let i = 0; i < 100; i++) { datastore.append(base, node, { payload: i, _datapoint: pt(i, i) }) } @@ -369,13 +373,13 @@ describe('store: data.js chart writes', function () { } liveStore(global, 'canary-3').should.equal('live') - datastore.get('chart-3').should.have.length(41) global.get('dashboardStore')['chart-3'].should.have.length(100) + datastore.get('chart-3').should.have.length(41) }) it('rebuilds the stored series on the batch boundary', function () { - const global = fakeGlobal() - const node = fakeNode('chart-batch', global) + const global = storeGlobal() + const node = fakeNode('chart-batch') for (let i = 0; i < 100; i++) { datastore.append(base, node, { payload: i, _datapoint: pt(i, i) }) } @@ -393,9 +397,9 @@ describe('store: data.js chart writes', function () { }) it('does not create a key when the appended message is not an object', function () { - const global = fakeGlobal() - const node = fakeNode('chart-junk', global) - datastore.append(base, fakeNode('chart-ok', global), { payload: 1, _datapoint: pt(1, 1) }) + const global = storeGlobal() + const node = fakeNode('chart-junk') + datastore.append(base, fakeNode('chart-ok'), { payload: 1, _datapoint: pt(1, 1) }) datastore.append(base, node, 5) datastore.save(base, node, [5, { payload: 1, _datapoint: pt(1, 1) }]) @@ -405,8 +409,8 @@ describe('store: data.js chart writes', function () { }) it('leaves the stored series untouched when trims remove nothing', function () { - const global = fakeGlobal() - const node = fakeNode('chart-noop', global) + const global = storeGlobal() + const node = fakeNode('chart-noop') for (let i = 0; i < 100; i++) { datastore.append(base, node, { payload: i, _datapoint: pt(i, i) }) } @@ -419,9 +423,19 @@ describe('store: data.js chart writes', function () { global.get('dashboardStore')['$chart-noop'].msg.should.eql(before) }) - it('still keeps the legacy chart series intact', function () { - const global = fakeGlobal() - const node = fakeNode('chart-4', global) + it('ignores a filter call with no callback', function () { + storeGlobal() + const node = fakeNode('chart-nofilter') + datastore.append(base, node, { payload: 1, _datapoint: pt(1, 1) }) + + should(() => datastore.filter(base, node, undefined)).not.throw() + + datastore.get('chart-nofilter').should.have.length(1) + }) + + it('keeps the trimmed chart series', function () { + storeGlobal() + const node = fakeNode('chart-4') datastore.append(base, node, { _datapoint: pt(1, 1) }) datastore.append(base, node, { _datapoint: pt(2, 2) }) @@ -433,7 +447,7 @@ describe('store: data.js chart writes', function () { it('clears a stale key if a node id was previously used for a value', function () { const global = fakeGlobal() datastore.initStore(global, {}) - const node = fakeNode('chart-5', global) + const node = fakeNode('chart-5') datastore.save(base, node, { payload: 'scalar first' }) global.get('dashboardStore')['chart-5'].should.equal('scalar first') @@ -443,8 +457,8 @@ describe('store: data.js chart writes', function () { }) it('keeps history empty across many appends', function () { - const global = fakeGlobal() - const node = fakeNode('chart-hist', global) + const global = storeGlobal() + const node = fakeNode('chart-hist') for (let i = 0; i < 20; i++) { datastore.append(base, node, { payload: i, _datapoint: pt(i, i) }) } @@ -453,8 +467,8 @@ describe('store: data.js chart writes', function () { }) it('serialises a chart key to its point array and nothing else', function () { - const global = fakeGlobal() - const node = fakeNode('chart-json', global) + const global = storeGlobal() + const node = fakeNode('chart-json') datastore.append(base, node, { payload: 1, topic: 'a', _datapoint: pt(1, 1) }) JSON.parse(JSON.stringify(global.get('dashboardStore'))).should.eql({ @@ -462,10 +476,10 @@ describe('store: data.js chart writes', function () { }) }) - it('leaves the legacy series untouched when the store is disabled', function () { + it('leaves global context untouched when it cannot hold the store', function () { const global = fakeGlobal() - const node = fakeNode('chart-off', global) - datastore.disableStore() + const node = fakeNode('chart-off') + datastore.initStore({ get () { throw new Error('cache off') }, set () {} }, {}) try { datastore.append(base, node, { payload: 1, _datapoint: pt(1, 1) }) datastore.save(base, node, [{ payload: 2, _datapoint: pt(2, 2) }]) @@ -480,9 +494,13 @@ describe('store: data.js chart writes', function () { }) describe('store: data.js audit invariants', function () { + beforeEach(function () { + datastore.initStore(fakeGlobal(), {}) + }) + it('does not store a message carrying only dynamic properties', function () { - const global = fakeGlobal() - const node = fakeNode('a1', global) + const global = storeGlobal() + const node = fakeNode('a1') datastore.save(base, node, { payload: 'real' }) datastore.save(base, node, { ui_update: { class: 'red' } }) @@ -490,8 +508,8 @@ describe('store: data.js audit invariants', function () { }) it('keeps value and msg in step across writes', function () { - const global = fakeGlobal() - const node = fakeNode('a2', global) + const global = storeGlobal() + const node = fakeNode('a2') datastore.save(base, node, { payload: 1, topic: 'first' }) datastore.save(base, node, { payload: 2, topic: 'second' }) @@ -501,8 +519,8 @@ describe('store: data.js audit invariants', function () { }) it('leaves no key at all when a widget never stores', function () { - const global = fakeGlobal() - datastore.save(base, fakeNode('a3', global), { payload: 1 }) + const global = storeGlobal() + datastore.save(base, fakeNode('a3'), { payload: 1 }) const store = global.get('dashboardStore') store.a3.should.equal(1) @@ -512,7 +530,7 @@ describe('store: data.js audit invariants', function () { it('clears value and msg together when a widget is removed', function () { const global = fakeGlobal() datastore.initStore(global, {}) - const node = fakeNode('a4', global) + const node = fakeNode('a4') datastore.save(base, node, { payload: 5, topic: 't' }) global.get('dashboardStore').$a4.msg.should.be.an.Object() @@ -525,9 +543,13 @@ describe('store: data.js audit invariants', function () { }) describe('store: data.js client-scoped writes', function () { + beforeEach(function () { + datastore.initStore(fakeGlobal(), {}) + }) + describe('save', function () { const base = { acceptsClientConfig: ['ui-text'] } - const node = fakeNode('w1', fakeGlobal()) + const node = fakeNode('w1') beforeEach(function () { datastore.clear(node.id) @@ -572,6 +594,8 @@ describe('store: data.js client-scoped writes', function () { describe('store: data.js failure reporting', function () { const unreachable = () => { throw new Error('context store unavailable') } + const baseNode = { warn: (m) => warnings.push(`base: ${m}`) } + const SUFFIX = '. Further store failures are not reported until the next full deploy or restart.' beforeEach(function () { warnings.length = 0 @@ -581,111 +605,120 @@ describe('store: data.js failure reporting', function () { datastore.initStore(fakeGlobal(), {}) }) - it('warns once on the widget whose write failed, not once per write', function () { + it('warns once on the base node when the store cannot be reached, not once per call', function () { const global = fakeGlobal() - const node = fakeNode('fail-1', global) - datastore.initStore(global, {}) + datastore.initStore(global, { node: baseNode }) global.get = unreachable - datastore.save(base, node, { payload: 1 }) - datastore.save(base, node, { payload: 2 }) - datastore.append(base, fakeNode('fail-1-chart', global), { payload: 1, _datapoint: { category: 'a', x: 1, y: 1 } }) - datastore.clear(node.id) + datastore.save(base, fakeNode('fail-1'), { payload: 1 }) + datastore.save(base, fakeNode('fail-1'), { payload: 2 }) + datastore.append(base, fakeNode('fail-1-chart'), { payload: 1, _datapoint: { category: 'a', x: 1, y: 1 } }) + datastore.filter(base, fakeNode('fail-1-chart'), () => true) + datastore.clear('fail-1') + datastore.get('fail-1') - warnings.should.have.length(1) - warnings[0].should.equal('fail-1: Dashboard data store: a store write failed (context store unavailable), so global.dashboardStore may be out of date. Further store failures are not reported until the next full deploy or restart.') + warnings.should.eql([`base: Dashboard data store: saving fail-1 failed (context store unavailable)${SUFFIX}`]) }) - it('warns when an append fails', function () { - const global = fakeGlobal() - datastore.initStore(global, {}) - global.get = unreachable + it('names the operation that failed', function () { + for (const [action, call] of [ + ['saving', () => datastore.append(base, fakeNode('fail-a'), { payload: 1, _datapoint: { category: 'a', x: 1, y: 1 } })], + ['trimming', () => datastore.keepLatestPerTopic(base, fakeNode('fail-a'), 1)], + ['clearing', () => datastore.clear('fail-a')], + ['reading', () => datastore.has('fail-a')] + ]) { + const global = fakeGlobal() + datastore.initStore(global, { node: baseNode }) + global.get = unreachable + warnings.length = 0 - datastore.append(base, fakeNode('fail-a', global), { payload: 1, _datapoint: { category: 'a', x: 1, y: 1 } }) + call() - warnings.should.have.length(1) - warnings[0].should.startWith('fail-a: Dashboard data store: a store write failed (context store unavailable)') + warnings.should.eql([`base: Dashboard data store: ${action} fail-a failed (context store unavailable)${SUFFIX}`]) + } }) - it('does not throw from clear when there is no base node to warn on', function () { + it('does not throw when there is no base node to warn on', function () { const global = fakeGlobal() datastore.initStore(global, {}) global.get = unreachable should(() => datastore.clear('no-base')).not.throw() - }) - - it('warns on the base node when a clear fails, since a clear has no widget node', function () { - const global = fakeGlobal() - datastore.initStore(global, { node: { warn: (m) => warnings.push(`base: ${m}`) } }) - global.get = unreachable - - datastore.clear('fail-c') - - warnings.should.have.length(1) - warnings[0].should.startWith('base: Dashboard data store: clearing the store entry for fail-c failed (context store unavailable), so global.dashboardStore may still hold its old value.') + should(() => datastore.save(base, fakeNode('no-base'), { payload: 1 })).not.throw() }) it('explains a context store that stops holding the store by reference', function () { const m = {} let copying = false const global = { get: (k) => copying ? JSON.parse(JSON.stringify(m[k])) : m[k], set: (k, v) => { m[k] = v } } - datastore.initStore(global, {}) + datastore.initStore(global, { node: baseNode }) copying = true - datastore.save(base, fakeNode('fail-r', global), { payload: 1 }) + datastore.save(base, fakeNode('fail-r'), { payload: 1 }) - warnings.should.have.length(1) - warnings[0].should.startWith('fail-r: Dashboard data store: a store write failed (global context saved a copy of the store rather than the store itself, which happens with context stores that serialise values)') + warnings.should.eql([`base: Dashboard data store: saving fail-r failed (global context saved a copy of the store rather than the store itself, which happens with context stores that serialise values)${SUFFIX}`]) }) - it('still writes the legacy datastore when the store write fails', function () { + it('reads as empty when the store cannot be reached', function () { const global = fakeGlobal() - const node = fakeNode('fail-2', global) - datastore.initStore(global, {}) + datastore.initStore(global, { node: baseNode }) global.get = unreachable - datastore.save(base, node, { payload: 'kept' }) - - datastore.get('fail-2').payload.should.equal('kept') + should(datastore.get('fail-2')).be.undefined() + datastore.has('fail-2').should.equal(false) }) it('warns again after the next initStore', function () { const global = fakeGlobal() - datastore.initStore(global, {}) + datastore.initStore(global, { node: baseNode }) global.get = unreachable - datastore.save(base, fakeNode('fail-3', global), { payload: 1 }) + datastore.save(base, fakeNode('fail-3'), { payload: 1 }) const fresh = fakeGlobal() - datastore.initStore(fresh, {}) + datastore.initStore(fresh, { node: baseNode }) fresh.get = unreachable - datastore.save(base, fakeNode('fail-3', fresh), { payload: 2 }) + datastore.save(base, fakeNode('fail-3'), { payload: 2 }) warnings.should.have.length(2) }) - it('warns on the widget when a trim cannot reach the store', function () { - const global = fakeGlobal() - const node = fakeNode('fail-5', global) - datastore.initStore(global, {}) - for (let i = 0; i < 100; i++) datastore.append(base, node, { payload: i, _datapoint: { category: 'a', x: i, y: i } }) - global.get = unreachable + it('throws a failing filter to the widget without reporting a store failure', function () { + datastore.initStore(fakeGlobal(), { node: baseNode }) + const node = fakeNode('fail-6') + datastore.append(base, node, { payload: 1, _datapoint: null }) - datastore.filter(base, node, (m, idx) => idx > 0) + should.throws(() => datastore.filter(base, node, (m) => m._datapoint.x > 0)) - warnings.should.have.length(1) - warnings[0].should.startWith('fail-5: ') + warnings.should.eql([]) }) - it('throws a failing filter to the widget without reporting a store failure', function () { - const global = fakeGlobal() - const node = fakeNode('fail-6', global) - datastore.initStore(global, {}) - datastore.append(base, node, { payload: 1, _datapoint: null }) + it('leaves the stored message untouched when a save cannot be copied', function () { + datastore.initStore(fakeGlobal(), { node: baseNode }) + const node = fakeNode('fail-8') + datastore.save(base, node, { payload: 1, topic: 't', label: 'L' }) - should.throws(() => datastore.filter(base, node, (m) => m._datapoint.x > 0)) + should.throws(() => datastore.save(base, node, { payload: 2, meta: { get bad () { throw new Error('unreadable') } } })) - warnings.should.have.length(0) + datastore.get('fail-8').should.eql({ payload: 1, topic: 't', label: 'L' }) + }) + + it('leaves the stored message untouched when an append cannot be copied', function () { + datastore.initStore(fakeGlobal(), { node: baseNode }) + const node = fakeNode('fail-9') + datastore.save(base, node, { payload: 1, topic: 't' }) + + should.throws(() => datastore.append(base, node, { payload: 2, meta: { get bad () { throw new Error('unreadable') } }, _datapoint: { x: 1, y: 2 } })) + + datastore.get('fail-9').should.eql({ payload: 1, topic: 't' }) + }) + + it('throws a message the store cannot copy to the widget without reporting a store failure', function () { + datastore.initStore(fakeGlobal(), { node: baseNode }) + const unreadable = { get payload () { throw new Error('unreadable') } } + + should.throws(() => datastore.save(base, fakeNode('fail-7'), unreadable), /unreadable/) + + warnings.should.eql([]) }) }) @@ -701,7 +734,7 @@ describe('store: data reads that avoid cloning', function () { const topicsAndPayloads = (id) => datastore.get(id).map((m) => `${m.topic}:${m.payload}`) beforeEach(function () { - datastore.disableStore() + datastore.initStore({ get () { throw new Error('cache off') }, set () {} }, {}) datastore.setConfig(countingRED) }) @@ -803,7 +836,7 @@ describe('store: data.js merges into the store from the store itself', function it('stores a message without a payload, merged onto the stored one', function () { const global = fakeGlobal() datastore.initStore(global, {}) - const node = fakeNode('merge-1', global) + const node = fakeNode('merge-1') datastore.save(base, node, { payload: 1 }) datastore.save(base, node, { topic: 't' }) @@ -814,21 +847,21 @@ describe('store: data.js merges into the store from the store itself', function it('merges onto what the store holds, not what the legacy datastore holds', function () { const global = fakeGlobal() datastore.initStore(global, {}) - const node = fakeNode('merge-2', global) + const node = fakeNode('merge-2') datastore.save(base, node, { payload: 1 }) global.get('dashboardStore')['merge-2'] = 5 datastore.save(base, node, { topic: 't' }) global.get('dashboardStore')['$merge-2'].msg.should.eql({ payload: 5, topic: 't' }) - datastore.get('merge-2').should.eql({ payload: 1, topic: 't' }) + datastore.get('merge-2').should.eql({ payload: 5, topic: 't' }) }) it('ignores a message that is not an object, without warning', function () { const global = fakeGlobal() datastore.initStore(global, {}) const warned = [] - const node = { ...fakeNode('merge-4', global), warn: (m) => warned.push(m) } + const node = { ...fakeNode('merge-4'), warn: (m) => warned.push(m) } datastore.save(base, node, { payload: 1 }) datastore.save(base, node, 5) @@ -840,7 +873,7 @@ describe('store: data.js merges into the store from the store itself', function it('removes the store entry on clear(id), which has no node', function () { const global = fakeGlobal() datastore.initStore(global, {}) - datastore.save(base, fakeNode('merge-3', global), { payload: 'hello' }) + datastore.save(base, fakeNode('merge-3'), { payload: 'hello' }) datastore.clear('merge-3') @@ -858,7 +891,7 @@ describe('store: data.js trims the stored series itself', function () { it('trims what the store holds, not a copy of the legacy result', function () { const global = fakeGlobal() datastore.initStore(global, {}) - const node = fakeNode('own-trim', global) + const node = fakeNode('own-trim') datastore.save(base, node, []) for (let i = 0; i < 6; i++) { datastore.append(base, node, point(i, i % 2 ? 'b' : 'a')) @@ -868,13 +901,13 @@ describe('store: data.js trims the stored series itself', function () { datastore.keepLatestPerTopic(base, node, 2) global.get('dashboardStore')['$own-trim'].msg.map((m) => m.payload).should.eql([3, 4, 5, 'store-only']) - datastore.get('own-trim').map((m) => m.payload).should.eql([2, 3, 4, 5]) + datastore.get('own-trim').map((m) => m.payload).should.eql([3, 4, 5, 'store-only']) }) it('applies both a point limit and a time window', function () { const global = fakeGlobal() datastore.initStore(global, {}) - const node = fakeNode('both-limits', global) + const node = fakeNode('both-limits') datastore.save(base, node, []) for (let i = 0; i < 100; i++) { datastore.append(base, node, point(i)) @@ -885,3 +918,32 @@ describe('store: data.js trims the stored series itself', function () { global.get('dashboardStore')['$both-limits'].msg.map((m) => m.payload).should.eql(datastore.get('both-limits').map((m) => m.payload)) }) }) + +describe('store: data.js after a restart with a persistent context store', function () { + const restored = (values) => { + const global = fakeGlobal() + global.set('dashboardStore', JSON.parse(JSON.stringify(values))) + datastore.initStore(global, {}) + } + + afterEach(function () { + datastore.initStore(fakeGlobal(), {}) + }) + + it('starts empty, as main does', function () { + restored({ text: 'hello', rows: [{ id: 1 }], chart: [{ category: 'a', x: 1, y: 1 }] }) + + datastore.has('text').should.equal(false) + should(datastore.get('text')).be.undefined() + datastore.has('rows').should.equal(false) + datastore.has('chart').should.equal(false) + }) + + it('stores a chart\'s first point after a restart as a fresh series', function () { + restored({ chart: [{ category: 'a', x: 1, y: 1 }] }) + + datastore.append(base, fakeNode('chart'), { payload: 2, _datapoint: { category: 'a', x: 2, y: 2 } }) + + datastore.get('chart').should.eql([{ payload: 2, _datapoint: { category: 'a', x: 2, y: 2 } }]) + }) +}) diff --git a/test/store/differential.spec.js b/test/store/differential.spec.js index 6ec849eab..cd540769f 100644 --- a/test/store/differential.spec.js +++ b/test/store/differential.spec.js @@ -14,6 +14,10 @@ function fakeGlobal () { return { get: (k) => m[k], set: (k, v) => { m[k] = v } } } +function cacheOffGlobal () { + return { get () { throw new Error('File Store cache disabled - only asynchronous access supported') }, set () {} } +} + const runs = [ ...Object.entries(scenarios).map(([name, steps]) => ({ name, steps })), ...Array.from({ length: 200 }, (_, i) => ({ name: `seed ${i + 1}`, steps: generate(i + 1, 180) })), @@ -50,14 +54,17 @@ function storeHolds (global, id) { return store['$' + id]?.msg } -function compareAfterEachStep (steps, ids) { - const global = fakeGlobal() +function compareAfterEachStep (steps, ids, global = fakeGlobal()) { const warnings = [] - datastore.initStore(global, {}) + const baseNode = { warn: (m) => warnings.push(m) } + const shared = datastore.initStore(global, { node: baseNode }) const nodeFor = (id, type) => ({ id, type, context: () => ({ global }), warn: (m) => warnings.push(m) }) const legacy = createLegacy(RED) let comparedWithData = 0 - for (const step of steps) { + for (const [i, step] of steps.entries()) { + if (i === Math.floor(steps.length / 2)) { + datastore.initStore(global, { node: baseNode }) + } const given = structuredClone(step) runStep(datastore, given, { base, nodeFor }) runStep(legacy, structuredClone(step), { base, nodeFor }) @@ -67,22 +74,26 @@ function compareAfterEachStep (steps, ids) { scribble(datastore.get(step.id)) should(datastore.get(step.id)).eql(legacy.get(step.id), `${step.id} after scribbling what get returned`) datastore.has(step.id).should.equal(legacy.has(step.id), `${step.id} after ${step.op}`) - should(storeHolds(global, step.id)).eql(legacy.get(step.id), `store for ${step.id} after ${step.op}`) + if (shared) { + should(storeHolds(global, step.id)).eql(legacy.get(step.id), `store for ${step.id} after ${step.op}`) + } if (legacy.has(step.id)) { comparedWithData++ } } for (const id of ids) { should(datastore.get(id)).eql(legacy.get(id), id) - should(storeHolds(global, id)).eql(legacy.get(id), `store for ${id}`) + if (shared) { + should(storeHolds(global, id)).eql(legacy.get(id), `store for ${id}`) + } } - return { comparedWithData, warnings } + return { comparedWithData, warnings, shared } } function compareAtEnd (steps, ids) { const global = fakeGlobal() const warnings = [] - datastore.initStore(global, {}) + datastore.initStore(global, { node: { warn: (m) => warnings.push(m) } }) const nodeFor = (id, type) => ({ id, type, context: () => ({ global }), warn: (m) => warnings.push(m) }) const legacy = createLegacy(RED) for (const step of steps) { @@ -109,8 +120,22 @@ describe('store: differential against the legacy datastore', function () { for (const run of runs) { it(run.name, function () { const { steps, ids } = prepare(run.steps) - const { comparedWithData, warnings } = compareAfterEachStep(steps, ids) + const { comparedWithData, warnings, shared } = compareAfterEachStep(steps, ids) + + should(shared).not.be.null() + comparedWithData.should.be.above(0) + warnings.should.eql([]) + }) + } + }) + + describe('data.js matches the legacy reference when global context cannot hold the store', function () { + for (const run of runs) { + it(run.name, function () { + const { steps, ids } = prepare(run.steps) + const { comparedWithData, warnings, shared } = compareAfterEachStep(steps, ids, cacheOffGlobal()) + should(shared).be.null() comparedWithData.should.be.above(0) warnings.should.eql([]) }) diff --git a/test/store/reactive.spec.js b/test/store/reactive.spec.js index 703c57ba5..64c5d67b0 100644 --- a/test/store/reactive.spec.js +++ b/test/store/reactive.spec.js @@ -11,7 +11,7 @@ const Memory = require('@node-red/runtime/lib/nodes/context/memory.js') const { util } = require('@node-red/util') const should = require('should') // eslint-disable-line no-unused-vars -const { createDataStore, attachToContext, STORE, APPEND_ENTRY, SET_SERIES, MERGE_ENTRY, TRIM_SERIES } = require('../../nodes/store/reactive.js') +const { createDataStore, attachToContext, STORE, APPEND_ENTRY, SET_SERIES, MERGE_ENTRY, TRIM_SERIES, ENTRY_MSG } = require('../../nodes/store/reactive.js') function makeStore (opts = {}) { const log = [] @@ -963,9 +963,11 @@ describe('store: reactive data store', function () { }) describe('attachToContext', function () { - it('does not rehydrate an array into numeric keys', function () { - const m = { dashboardStore: ['a', 'b'] } + it('does not copy an array a flow put in its place into numeric keys', function () { + const m = {} const g = { get: (k) => m[k], set: (k, v) => { m[k] = v } } + attachToContext(g, {}) + m.dashboardStore = ['a', 'b'] const store = attachToContext(g, {}) @@ -973,18 +975,22 @@ describe('store: reactive data store', function () { JSON.stringify(store).should.equal('{}') }) - it('does not spread a class instance into keys', function () { - const m = { dashboardStore: new Date() } + it('does not spread a class instance a flow put in its place into keys', function () { + const m = {} const g = { get: (k) => m[k], set: (k, v) => { m[k] = v } } + attachToContext(g, {}) + m.dashboardStore = new Date() const store = attachToContext(g, {}) Object.keys(store).should.eql([]) }) - it('rehydrates an object a function node left at the namespace', function () { - const m = { dashboardStore: fnNodeObject('({ robot: "PLAIN", count: 3 })') } + it('copies in an object a function node put in its place', function () { + const m = {} const g = { get: (k) => m[k], set: (k, v) => { m[k] = v } } + attachToContext(g, {}) + m.dashboardStore = fnNodeObject('({ robot: "PLAIN", count: 3 })') const store = attachToContext(g, {}) @@ -992,9 +998,11 @@ describe('store: reactive data store', function () { store.count.should.equal(3) }) - it('still rehydrates a plain object', function () { - const m = { dashboardStore: { robot: { temp: 20 }, count: 3 } } + it('copies in a plain object a flow put in its place', function () { + const m = {} const g = { get: (k) => m[k], set: (k, v) => { m[k] = v } } + attachToContext(g, {}) + m.dashboardStore = { robot: { temp: 20 }, count: 3 } const store = attachToContext(g, {}) @@ -1036,8 +1044,9 @@ describe('store: reactive data store', function () { should(second).equal(first) }) - it('rehydrates around existing plain data and rewires reactivity', function () { + it('rewires reactivity around plain data a flow put in its place', function () { const g = fakeGlobal() + attachToContext(g) g.set('dashboardStore', { a: 5, robot: { temp: 1 } }) const log = [] const store = attachToContext(g, { onChange: (k, e, p) => log.push(p) }) @@ -1086,13 +1095,14 @@ describe('store: reactive data store', function () { warned.should.equal(0) }) - it('does not warn when a restart rehydrates plain data it never injected', function () { + it('starts fresh after a restart, without warning, rather than restoring plain data it never injected', function () { const g = fakeGlobal() g.set('dashboardStore', { widget: 'from disk' }) // what a persistent context store hands back let warned = 0 const store = attachToContext(g, { onReplaced: () => { warned++ } }) warned.should.equal(0) - store.widget.should.equal('from disk') + should(store.widget).be.undefined() + should(g.get('dashboardStore')).equal(store) }) const replacements = [ @@ -1348,6 +1358,49 @@ describe('store: MERGE_ENTRY', function () { }) }) +describe('store: ENTRY_MSG', function () { + const point = (x) => ({ payload: x, topic: 'a', _datapoint: { category: 'a', x, y: x } }) + + it('reads a stored message without building a new object', function () { + const { store } = makeStore({ clone: util.cloneMessage }) + store[MERGE_ENTRY]('k', { payload: 1, topic: 't' }) + store[MERGE_ENTRY]('k', { payload: 2 }) + + store[ENTRY_MSG]('k').should.equal(store[ENTRY_MSG]('k')) + store[ENTRY_MSG]('k').should.eql({ payload: 2, topic: 't' }) + }) + + it('reads a series with pending trims applied, as a frozen copy', function () { + const { store } = makeStore({ clone: util.cloneMessage }) + for (let x = 0; x < 5; x++) { + store[APPEND_ENTRY]('c', point(x)) + } + store[TRIM_SERIES]('c', (msgs) => msgs.filter((m) => m.payload > 2)) + + const msgs = store[ENTRY_MSG]('c') + + msgs.map((m) => m.payload).should.eql([3, 4]) + Object.isFrozen(msgs).should.equal(true) + store[ENTRY_MSG]('c').should.not.equal(msgs) + }) + + it('matches the $ view', function () { + const { store } = makeStore({ clone: util.cloneMessage }) + store[APPEND_ENTRY]('c', point(1)) + store[TRIM_SERIES]('c', (msgs) => msgs) + store[MERGE_ENTRY]('k', { payload: 1 }) + + store[ENTRY_MSG]('c').should.eql(store.$c.msg) + store[ENTRY_MSG]('k').should.equal(store.$k.msg) + }) + + it('reads nothing for a missing entry', function () { + const { store } = makeStore({ clone: util.cloneMessage }) + + should(store[ENTRY_MSG]('missing')).be.undefined() + }) +}) + describe('store: TRIM_SERIES', function () { const point = (x, topic = 'a') => ({ payload: x, topic, _datapoint: { category: topic, x, y: x } }) const keepLatest = (max) => (msgs) => msgs.filter((m, i) => msgs.slice(i + 1).filter((n) => n.topic === m.topic).length < max)