diff --git a/nodes/store/data.js b/nodes/store/data.js index 928103a11..453a369b5 100644 --- a/nodes/store/data.js +++ b/nodes/store/data.js @@ -104,7 +104,7 @@ const getters = { }, // given a widget id, return the latest msg received msg (id) { - return config.RED.util.cloneMessage(getStoreOrWarn('reading', id)?.[ENTRY_MSG](id)) + return getStoreOrWarn('reading', id)?.[ENTRY_MSG](id) }, has (id) { const store = getStoreOrWarn('reading', id) diff --git a/nodes/store/reactive.js b/nodes/store/reactive.js index a4ca049d6..5d4591784 100644 --- a/nodes/store/reactive.js +++ b/nodes/store/reactive.js @@ -4,7 +4,6 @@ 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' class Entry { @@ -14,7 +13,7 @@ class Entry { this.quality = 'GOOD' this.timestamp = 0 this.history = [] - this.trims = null + this.plainPoints = undefined } } @@ -38,15 +37,15 @@ function deepClone (v, seen = new WeakMap()) { return out } -function deepReactive (value, notify, path, clone, seen = new WeakMap()) { +function deepReactive (value, notify, path, clone, seen = new WeakMap(), wrapOnRead = false) { if (!isReactable(value)) return value if (seen.has(value)) return seen.get(value) - const proxy = new Proxy(value, { + const traps = { 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 const shrank = p === 'length' && Array.isArray(t) && v < t.length - t[p] = deepReactive(clone(v), notify, `${path}.${String(p)}`, clone) + t[p] = wrapOnRead ? clone(v) : deepReactive(clone(v), notify, `${path}.${String(p)}`, clone) if (p !== 'length') notify(`${path}.${String(p)}`) else if (shrank) notify(path) return true @@ -57,10 +56,23 @@ function deepReactive (value, notify, path, clone, seen = new WeakMap()) { if (existed) notify(`${path}.${String(p)}`) return ok } - }) + } + if (wrapOnRead) { + traps.get = (t, p, r) => { + const v = Reflect.get(t, p, r) + const descriptor = typeof p === 'symbol' ? undefined : Object.getOwnPropertyDescriptor(t, p) + if (!descriptor || (!descriptor.writable && !descriptor.configurable)) { + return v + } + return deepReactive(v, notify, `${path}.${p}`, clone, seen, true) + } + } + const proxy = new Proxy(value, traps) // registered before walking children so a cycle resolves to this proxy instead of recursing seen.set(value, proxy) - for (const k of Object.keys(value)) value[k] = deepReactive(value[k], notify, `${path}.${k}`, clone, seen) + if (!wrapOnRead) { + for (const k of Object.keys(value)) value[k] = deepReactive(value[k], notify, `${path}.${k}`, clone, seen) + } return proxy } @@ -87,7 +99,7 @@ function createDataStore ({ maxHistory = 5, onChange, clone = deepClone, now = D records[STORE] = true // returns the record without notifying, so a caller can finish writing it before onChange fires - const writeValue = (prop, value) => { + const writeValue = (prop, value, wrapOnRead = false) => { let rec = records[prop] if (!rec) { rec = new Entry() @@ -99,30 +111,45 @@ function createDataStore ({ maxHistory = 5, onChange, clone = deepClone, now = D } const notify = (path) => { // a replaced value keeps its proxies alive; they must not report into the record any more - if (rec.value !== wrapped) return + if (wrapOnRead ? rec.plainPoints !== value : rec.value !== wrapped) return rec.timestamp = now() emit(prop, rec, path) } - const wrapped = deepReactive(value, notify, prop, cloneValue) + const wrapped = deepReactive(value, notify, prop, cloneValue, new WeakMap(), wrapOnRead) rec.value = wrapped - rec.trims = null + rec.plainPoints = wrapOnRead ? value : undefined rec.msg = Object.freeze({ payload: wrapped }) rec.timestamp = now() return rec } - 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 + const pointsOf = (d) => (d === undefined || d === null) ? [] : (Array.isArray(d) ? d : [d]) + const pointCount = (d) => (d === undefined || d === null) ? 0 : (Array.isArray(d) ? d.length : 1) + + const messagesWithLivePoints = (rec) => { + let i = 0 + return Object.freeze(rec.msg.map((m) => { + const n = pointCount(m._datapoint) + if (n === 0) { + return m + } + const livePoints = Array.from({ length: n }, () => rec.value[i++]) + return Object.freeze({ ...m, _datapoint: Array.isArray(m._datapoint) ? Object.freeze(livePoints) : livePoints[0] }) + })) + } records[ENTRY_MSG] = (prop) => { const rec = records[prop] - return rec ? msgOf(rec) : undefined + if (!rec) { + return undefined + } + return clone(rec.msg) } records[MERGE_ENTRY] = (prop, msg) => { const current = records[prop] const { payload, ...incoming } = msg - const previous = Array.isArray(current?.msg) ? freezeOwned(cloneValue({ ...trimmedSeries(current) })) : current?.msg || {} + const previous = Array.isArray(current?.msg) ? freezeOwned(cloneValue({ ...current.msg })) : current?.msg || {} const { payload: storedPayload, ...stored } = previous const keepValue = current && !Array.isArray(current.msg) && !('payload' in msg) const owned = freezeOwned(cloneValue(incoming)) @@ -135,8 +162,6 @@ function createDataStore ({ maxHistory = 5, onChange, clone = deepClone, now = D emit(prop, rec, prop) } - const pointsOf = (d) => (d === undefined || d === null) ? [] : (Array.isArray(d) ? d : [d]) - const toStoredDatapoint = (series, datapoint, start, end) => { if (end === start) return undefined return Array.isArray(datapoint) ? Object.freeze(series.slice(start, end)) : series[start] @@ -159,9 +184,9 @@ function createDataStore ({ maxHistory = 5, onChange, clone = deepClone, now = D for (const p of pointsOf(datapoint)) points.push(p) return { msg, owned, datapoint, start, end: points.length } }) - const rec = writeValue(prop, points) + const rec = writeValue(prop, points, true) rec.msg = parts.map(({ msg, owned, datapoint, start, end }) => { - const stored = toStoredDatapoint(rec.value, datapoint, start, end) + const stored = toStoredDatapoint(points, datapoint, start, end) if (stored === undefined && '_datapoint' in msg) owned._datapoint = datapoint return toStoredMessage(owned, stored) }) @@ -175,12 +200,12 @@ function createDataStore ({ maxHistory = 5, onChange, clone = deepClone, now = D const { _datapoint, ...rest } = msg const owned = cloneValue(rest) if (!rec || replaced) { - rec = writeValue(prop, []) + rec = writeValue(prop, [], true) rec.msg = [] } 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) + const stored = toStoredDatapoint(rec.plainPoints, _datapoint, start, rec.value.length) if (stored === undefined && '_datapoint' in msg) owned._datapoint = cloneValue(_datapoint) rec.msg.push(toStoredMessage(owned, stored)) if (replaced) emit(prop, rec, prop) @@ -191,20 +216,37 @@ function createDataStore ({ maxHistory = 5, onChange, clone = deepClone, now = D if (!rec || !Array.isArray(rec.msg)) { return } - const pending = rec.trims || (rec.trims = { calls: 0, kept: [], seen: 0 }) - for (let i = pending.seen; i < rec.msg.length; i++) { - pending.kept.push(rec.msg[i]) - } - pending.seen = rec.msg.length - const before = pending.kept.length - pending.kept = trim(pending.kept) - if (pending.kept.length === before) { + const msgs = trim(rec.msg) + if (msgs.length === rec.msg.length) { return } - pending.calls++ - if (pending.calls >= TRIM_BATCH) { - records[SET_SERIES](prop, trimmedSeries(rec)) + const points = rec.plainPoints + const removedMessages = rec.msg.length - msgs.length + if (msgs.length === 0 || msgs[0] === rec.msg[removedMessages]) { + let removedPoints = 0 + for (let i = 0; i < removedMessages; i++) { + removedPoints += pointCount(rec.msg[i]._datapoint) + } + points.splice(0, removedPoints) + } else { + let readAt = 0 + let writeAt = 0 + let nextKept = 0 + for (const m of rec.msg) { + const n = pointCount(m._datapoint) + if (m === msgs[nextKept]) { + nextKept++ + for (let k = 0; k < n; k++) { + points[writeAt++] = points[readAt + k] + } + } + readAt += n + } + points.length = writeAt } + writeValue(prop, points, true) + rec.msg = msgs + emit(prop, rec, prop) } const handler = { @@ -216,7 +258,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 = msgOf(rec) + const msg = Array.isArray(rec.msg) ? messagesWithLivePoints(rec) : rec.msg return Object.freeze({ value: rec.value, msg, quality: rec.quality, timestamp: rec.timestamp, history: Object.freeze(rec.history.slice()) }) } const rec = target[prop] @@ -238,7 +280,10 @@ function createDataStore ({ maxHistory = 5, onChange, clone = deepClone, now = D const rec = target[prop] const existed = prop in target const ok = delete target[prop] - if (rec) rec.value = undefined + if (rec) { + rec.value = undefined + rec.plainPoints = undefined + } if (existed) emit(prop, undefined, prop) return ok } diff --git a/test/store/data.spec.js b/test/store/data.spec.js index 60bf94019..bbf85cdfd 100644 --- a/test/store/data.spec.js +++ b/test/store/data.spec.js @@ -361,7 +361,7 @@ describe('store: data.js chart writes', function () { store['$chart-clear'].msg.should.eql([]) }) - it('does not rebuild the stored series until the batch is reached', function () { + it('trims the stored series on every filter', function () { const global = storeGlobal() const node = fakeNode('chart-3') for (let i = 0; i < 100; i++) { @@ -373,11 +373,11 @@ describe('store: data.js chart writes', function () { } liveStore(global, 'canary-3').should.equal('live') - global.get('dashboardStore')['chart-3'].should.have.length(100) + global.get('dashboardStore')['chart-3'].should.have.length(41) datastore.get('chart-3').should.have.length(41) }) - it('rebuilds the stored series on the batch boundary', function () { + it('trims value and messages together', function () { const global = storeGlobal() const node = fakeNode('chart-batch') for (let i = 0; i < 100; i++) { diff --git a/test/store/differential.spec.js b/test/store/differential.spec.js index cd540769f..f6e31feba 100644 --- a/test/store/differential.spec.js +++ b/test/store/differential.spec.js @@ -54,6 +54,16 @@ function storeHolds (global, id) { return store['$' + id]?.msg } +function seriesInStep (global, id) { + const store = global.get('dashboardStore') + const msgs = store['$' + id]?.msg + if (!Array.isArray(msgs)) { + return + } + const points = msgs.flatMap((m) => (m._datapoint === undefined || m._datapoint === null) ? [] : [].concat(m._datapoint)) + should(store[id]).eql(points, `value of ${id} matches its messages' points`) +} + function compareAfterEachStep (steps, ids, global = fakeGlobal()) { const warnings = [] const baseNode = { warn: (m) => warnings.push(m) } @@ -76,6 +86,7 @@ function compareAfterEachStep (steps, ids, global = fakeGlobal()) { datastore.has(step.id).should.equal(legacy.has(step.id), `${step.id} after ${step.op}`) if (shared) { should(storeHolds(global, step.id)).eql(legacy.get(step.id), `store for ${step.id} after ${step.op}`) + seriesInStep(global, step.id) } if (legacy.has(step.id)) { comparedWithData++ @@ -85,6 +96,7 @@ function compareAfterEachStep (steps, ids, global = fakeGlobal()) { should(datastore.get(id)).eql(legacy.get(id), id) if (shared) { should(storeHolds(global, id)).eql(legacy.get(id), `store for ${id}`) + seriesInStep(global, id) } } return { comparedWithData, warnings, shared } @@ -103,6 +115,7 @@ function compareAtEnd (steps, ids) { const withData = ids.filter((id) => legacy.has(id)) for (const id of ids) { should(storeHolds(global, id)).eql(legacy.get(id), `store for ${id}`) + seriesInStep(global, id) } return { withData, warnings } } diff --git a/test/store/reactive.spec.js b/test/store/reactive.spec.js index 64c5d67b0..033590586 100644 --- a/test/store/reactive.spec.js +++ b/test/store/reactive.spec.js @@ -3,6 +3,7 @@ const http = require('http') const os = require('os') const path = require('path') +const { types } = require('util') const vm = require('vm') const LocalFileSystem = require('@node-red/runtime/lib/nodes/context/localfilesystem.js') @@ -1312,7 +1313,7 @@ describe('store: MERGE_ENTRY', function () { log.should.eql([]) }) - it('applies pending trims to a series before merging onto it, reporting once', function () { + it('merges onto the trimmed series, reporting once', function () { const { store, log } = merging() for (let x = 0; x < 5; x++) { store[APPEND_ENTRY]('k', { payload: x, _datapoint: { category: 'a', x, y: x } }) @@ -1361,16 +1362,19 @@ 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 () { + it('reads a stored message as a copy the caller owns', function () { const { store } = makeStore({ clone: util.cloneMessage }) - store[MERGE_ENTRY]('k', { payload: 1, topic: 't' }) - store[MERGE_ENTRY]('k', { payload: 2 }) + store[MERGE_ENTRY]('k', { payload: 1, topic: 't', meta: { a: 1 } }) - store[ENTRY_MSG]('k').should.equal(store[ENTRY_MSG]('k')) - store[ENTRY_MSG]('k').should.eql({ payload: 2, topic: 't' }) + const msg = store[ENTRY_MSG]('k') + msg.meta.a = 2 + msg.payload = 99 + + store[ENTRY_MSG]('k').should.eql({ topic: 't', meta: { a: 1 }, payload: 1 }) + store[ENTRY_MSG]('k').should.not.equal(msg) }) - it('reads a series with pending trims applied, as a frozen copy', function () { + it('reads a trimmed series as a copy the caller owns', function () { const { store } = makeStore({ clone: util.cloneMessage }) for (let x = 0; x < 5; x++) { store[APPEND_ENTRY]('c', point(x)) @@ -1378,20 +1382,21 @@ describe('store: ENTRY_MSG', function () { store[TRIM_SERIES]('c', (msgs) => msgs.filter((m) => m.payload > 2)) const msgs = store[ENTRY_MSG]('c') + msgs[0]._datapoint.y = 999 + msgs.push({ payload: 'extra' }) - msgs.map((m) => m.payload).should.eql([3, 4]) - Object.isFrozen(msgs).should.equal(true) - store[ENTRY_MSG]('c').should.not.equal(msgs) + msgs.slice(0, 2).map((m) => m.payload).should.eql([3, 4]) + store.c[0].y.should.equal(3) + store[ENTRY_MSG]('c').should.have.length(2) }) 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) + store[ENTRY_MSG]('k').should.eql(store.$k.msg) }) it('reads nothing for a missing entry', function () { @@ -1414,35 +1419,23 @@ describe('store: TRIM_SERIES', function () { } const payloads = (store) => store.$c.msg.map((m) => m.payload) - it('leaves the stored series alone for 59 trims and rebuilds it on the 60th', function () { + it('applies each trim to the stored series straight away', function () { const { store } = series(100) - for (let i = 0; i < 59; i++) { - store[TRIM_SERIES]('c', (msgs) => msgs.filter((m) => m.payload > i)) - } - store.c.should.have.length(100) - store[TRIM_SERIES]('c', (msgs) => msgs.filter((m) => m.payload > 59)) - - store.c.should.have.length(40) - payloads(store).should.eql(Array.from({ length: 40 }, (_, i) => i + 60)) - }) - - it('counts only trims that remove something towards the batch', function () { - const { store } = series(100) - for (let i = 0; i < 59; i++) { - store[TRIM_SERIES]('c', (msgs) => msgs.filter((m) => m.payload > i)) - store[TRIM_SERIES]('c', (msgs) => msgs) - } + store[TRIM_SERIES]('c', (msgs) => msgs.filter((m) => m.payload > 9)) - store.c.should.have.length(100) + store.c.should.have.length(90) + payloads(store).should.eql(Array.from({ length: 90 }, (_, i) => i + 10)) }) - it('shows pending trims in the $ view before the series is rebuilt', function () { + it('keeps the value and the stored messages in step after a trim', function () { const { store } = series(10) + store[TRIM_SERIES]('c', keepLatest(3)) - store.c.should.have.length(10) + store.c.map((p) => p.x).should.eql([7, 8, 9]) payloads(store).should.eql([7, 8, 9]) + store.$c.msg.forEach((m, i) => m._datapoint.should.equal(store.c[i])) }) it('matches trimming after every message when points arrive out of order', function () { @@ -1494,7 +1487,7 @@ describe('store: TRIM_SERIES', function () { payloads(store).should.eql([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) }) - it('drops pending trims when the series is replaced', function () { + it('trims the replacing series, not the old one', function () { const { store } = series(10) store[TRIM_SERIES]('c', keepLatest(1)) store[SET_SERIES]('c', [point(1), point(2)]) @@ -1504,7 +1497,7 @@ describe('store: TRIM_SERIES', function () { payloads(store).should.eql([2]) }) - it('drops pending trims when the entry is deleted', function () { + it('starts a fresh series after the entry is deleted', function () { const { store } = series(10) store[TRIM_SERIES]('c', keepLatest(1)) @@ -1525,9 +1518,53 @@ describe('store: TRIM_SERIES', function () { store.k.should.equal(5) }) - it('does not clone on trims that do not rebuild', function () { + it('trims the oldest points', function () { + const { store } = series(10) + + store[TRIM_SERIES]('c', (msgs) => msgs.filter((m) => m.payload > 3)) + + store.c.map((p) => p.x).should.eql([4, 5, 6, 7, 8, 9]) + store.$c.msg.forEach((m, i) => m._datapoint.should.equal(store.c[i])) + }) + + it('trims points from the middle of the series', function () { + const { store } = series(10, (i) => (i % 2 ? 'a' : 'b')) + + store[TRIM_SERIES]('c', (msgs) => msgs.filter((m) => m.topic === 'a' || m.payload > 6)) + + store.c.map((p) => p.x).should.eql([1, 3, 5, 7, 8, 9]) + store.$c.msg.forEach((m, i) => m._datapoint.should.equal(store.c[i])) + }) + + it('trims multi-point messages and messages without points exactly', function () { + const { store } = makeStore({ clone: util.cloneMessage }) + const two = (x) => ({ payload: x, topic: 'm', _datapoint: [{ category: 'a', x, y: 1 }, { category: 'b', x, y: 2 }] }) + store[SET_SERIES]('c', [two(1), { payload: 'none', topic: 'n' }, two(2), point(3), two(4)]) + + store[TRIM_SERIES]('c', (msgs) => msgs.slice(1)) + store.c.map((p) => p.x).should.eql([2, 2, 3, 4, 4]) + + store[TRIM_SERIES]('c', (msgs) => msgs.filter((m) => m.payload !== 3)) + store.c.map((p) => p.x).should.eql([2, 2, 4, 4]) + store.$c.msg.map((m) => m.payload).should.eql(['none', 2, 4]) + }) + + it('trims without cloning', function () { + let clones = 0 + const { store } = series(50, (i) => (i % 2 ? 'a' : 'b'), { clone: (v) => { clones++; return util.cloneMessage(v) } }) + clones = 0 + + store[TRIM_SERIES]('c', keepLatest(10)) + store[TRIM_SERIES]('c', (msgs) => msgs.filter((m) => m.topic === 'a')) + + clones.should.equal(0) + store.c.should.have.length(10) + }) + + it('does not clone on trims that remove nothing', function () { let clones = 0 const { store } = series(50, (i) => (i % 2 ? 'a' : 'b'), { clone: (v) => { clones++; return util.cloneMessage(v) } }) + store[TRIM_SERIES]('c', keepLatest(10)) clones = 0 for (let i = 0; i < 59; i++) { @@ -1537,7 +1574,7 @@ describe('store: TRIM_SERIES', function () { clones.should.equal(0) }) - it('reports the rebuild once', function () { + it('reports each trim that removes something once', function () { const { store, log } = series(100) log.length = 0 @@ -1545,6 +1582,197 @@ describe('store: TRIM_SERIES', function () { store[TRIM_SERIES]('c', (msgs) => msgs.filter((m) => m.payload > i)) } - log.should.eql(['c']) + log.should.eql(Array(60).fill('c')) + }) +}) + +describe('store: series points', function () { + const point = (x, topic = 'a') => ({ payload: x, topic, _datapoint: { category: topic, x, y: x } }) + + function series (n, topicOf = () => 'a', opts = {}) { + const made = makeStore({ clone: util.cloneMessage, ...opts }) + for (let i = 0; i < n; i++) { + made.store[APPEND_ENTRY]('c', point(i, topicOf(i))) + } + return made + } + + const storedMsgs = (store) => { + let msgs + store[TRIM_SERIES]('c', (given) => { msgs = given; return given }) + return msgs + } + + it('links stored messages to plain points', function () { + const { store } = series(3) + + types.isProxy(storedMsgs(store)[0]._datapoint).should.equal(false) + types.isProxy(store.c[0]).should.equal(true) + }) + + it('links a saved series\' messages to plain points', function () { + const { store } = makeStore({ clone: util.cloneMessage }) + store[SET_SERIES]('c', [point(1), { payload: [2, 3], _datapoint: [{ category: 'a', x: 2, y: 2 }, { category: 'b', x: 2, y: 3 }] }]) + + const msgs = storedMsgs(store) + + types.isProxy(msgs[0]._datapoint).should.equal(false) + types.isProxy(msgs[1]._datapoint[0]).should.equal(false) + }) + + it('keeps proxies out of the stored points after nested reads and writes', function () { + const { store } = series(1) + store.c[0].meta = { a: { b: 1 } } + + store.c[0].meta.a.b = 2 + + const dp = storedMsgs(store)[0]._datapoint + types.isProxy(dp.meta).should.equal(false) + types.isProxy(dp.meta.a).should.equal(false) + dp.meta.a.b.should.equal(2) + }) + + it('reports a write with the point\'s position after a trim', function () { + const { store, log } = series(5) + store.c[3].y.should.equal(3) + store[TRIM_SERIES]('c', (msgs) => msgs.filter((m) => m.payload > 1)) + log.length = 0 + + store.c[1].y = 30 + + log.should.eql(['c.1.y']) + }) + + it('moves the timestamp on a trim', function () { + const { store } = series(5) + const before = store.$c.timestamp + + store[TRIM_SERIES]('c', (msgs) => msgs.filter((m) => m.payload > 1)) + + store.$c.timestamp.should.be.above(before) + }) + + it('reads the same proxy for a point each time', function () { + const { store } = series(3) + + store.c[1].should.equal(store.c[1]) + }) + + it('reports a write through a point and shows it in the stored message', function () { + const { store, log } = series(3) + log.length = 0 + + store.c[0].y = 50 + + log.should.eql(['c.0.y']) + store[ENTRY_MSG]('c')[0]._datapoint.y.should.equal(50) + }) + + it('reports a nested write through a point', function () { + const { store, log } = series(1) + store.c[0].meta = { a: 1 } + log.length = 0 + + store.c[0].meta.a = 2 + + log.should.eql(['c.0.meta.a']) + }) + + it('stores a copy of a point pushed through the value', function () { + const { store } = series(1) + const p = { category: 'a', x: 9, y: 9 } + + store.c.push(p) + p.y = 999 + + store.c[1].y.should.equal(9) + }) + + it('links the $ view to the live points, including multi-point messages', function () { + const { store } = makeStore({ clone: util.cloneMessage }) + store[SET_SERIES]('c', [ + { payload: [1, 2], _datapoint: [{ category: 'a', x: 1, y: 1 }, { category: 'b', x: 1, y: 2 }] }, + { payload: 'none' }, + { payload: 3, _datapoint: { category: 'a', x: 2, y: 3 } } + ]) + + const msg = store.$c.msg + + msg[0]._datapoint[0].should.equal(store.c[0]) + msg[0]._datapoint[1].should.equal(store.c[1]) + should(msg[1]._datapoint).be.undefined() + msg[2]._datapoint.should.equal(store.c[2]) + }) + + it('reports a write through a $ view point', function () { + const { store, log } = series(2) + log.length = 0 + + store.$c.msg[1]._datapoint.y = 77 + + log.should.eql(['c.1.y']) + store.c[1].y.should.equal(77) + }) + + it('keeps a series proxy held across a trim live', function () { + const { store, log } = series(5) + const held = store.c + store[TRIM_SERIES]('c', (msgs) => msgs.filter((m) => m.payload > 1)) + log.length = 0 + + held[0].y = 40 + + log.should.eql(['c.0.y']) + store.c[0].y.should.equal(40) + held.should.have.length(3) + }) + + it('stops a series proxy reporting once the series is replaced', function () { + const { store, log } = series(2) + const held = store.c + store[SET_SERIES]('c', [point(5)]) + log.length = 0 + + held[0].y = 1 + + log.should.eql([]) + }) + + it('stops a series proxy reporting once the entry is deleted', function () { + const { store, log } = series(2) + const held = store.c + delete store.c + log.length = 0 + + held[0].y = 1 + + log.should.eql([]) + }) + + it('reads a point a flow has frozen', function () { + const { store } = series(2) + store.c[0].meta = { a: 1 } + + Object.freeze(store.c[0]) + + store.c[0].x.should.equal(0) + store.c[0].meta.a.should.equal(1) + }) + + it('reads a series a flow has frozen', function () { + const { store } = series(2) + + Object.freeze(store.c) + + store.c.map((p) => p.x).should.eql([0, 1]) + }) + + it('gives trim callbacks the stored messages with plain points', function () { + const { store } = series(2) + let seen + + store[TRIM_SERIES]('c', (msgs) => { seen = msgs[0]._datapoint; return msgs }) + + types.isProxy(seen).should.equal(false) }) })