Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
84 commits
Select commit Hold shift + click to select a range
ae19a6b
Add store and unit tests
n-lark Sep 14, 2026
24990a6
Merge remote-tracking branch 'origin/main' into 249-data-store
n-lark Sep 15, 2026
647ef40
Fix clone-on-write for nested assignments and add ui-base store injec…
n-lark Sep 15, 2026
0cf6d39
Drop spike artifact from store PR
n-lark Sep 15, 2026
1a86cce
Add warning for uncached global context store
n-lark Sep 15, 2026
075fa31
Add additional specs + update warning
n-lark Sep 15, 2026
c63b81e
Update warn text
n-lark Sep 15, 2026
c2ee207
Add onOverwrite protection for users that replace the store
n-lark Sep 16, 2026
fbec5e5
Add fix for clearing keys from store & add write for simple properties
n-lark Sep 16, 2026
d0ed0e9
Add array support, reduce max history, wrap try catch
n-lark Sep 16, 2026
d39c4a8
Reduce maxHistory, update name, wrap attachToContext in a try catch
n-lark Sep 16, 2026
ae3bfac
Merge remote-tracking branch 'origin/249-data-store' into 249-data-st…
n-lark Sep 16, 2026
0af0121
Merge remote-tracking branch 'origin/249-data-store-cache-off' into 2…
n-lark Sep 16, 2026
7baf786
Fix issues with disabled stores & lost options
n-lark Sep 16, 2026
9bf8b8e
Update guard to not fire on every restart and only when a users repla…
n-lark Sep 17, 2026
fd1ed68
Merge remote-tracking branch 'origin/249-data-store-cache-off' into 2…
n-lark Sep 17, 2026
325aa62
Remove charts and update specs
n-lark Sep 17, 2026
c8e5cf7
Simplify fns in data.js for the store
n-lark Sep 17, 2026
e7d3618
Add additional specs + protect for non obj msg
n-lark Sep 17, 2026
f185270
Store entire msg in the dataStore with specs
n-lark Sep 21, 2026
153ad4f
Add charts surface to data store
n-lark Sep 22, 2026
3500129
Return a frozen projection from the $ view so writes can't bypass onC…
n-lark Sep 22, 2026
ef8c5aa
Wrap onChange in try catch
n-lark Sep 22, 2026
89d6e79
Guard deepReactive and deepClone against cycles so a self-referencing…
n-lark Sep 22, 2026
4a0a14c
Register the store marker symbol globally so two module copies share …
n-lark Sep 22, 2026
d39f273
Only rehydrate a plain object so an array at the namespace isn't spre…
n-lark Sep 22, 2026
6cd455e
Reserve toJSON as a key so a stored value can never shadow the serial…
n-lark Sep 22, 2026
df4f7a9
Ignore change events from a value that has already been replaced
n-lark Sep 22, 2026
912869e
Merge remote-tracking branch 'origin/249-data-store' into 249-data-st…
n-lark Sep 22, 2026
62e26bd
Merge remote-tracking branch 'origin/249-data-store-cache-off' into 2…
n-lark Sep 22, 2026
e2a3a11
Merge remote-tracking branch 'origin/249-data-store-populate' into 22…
n-lark Sep 22, 2026
2409269
Cut comments
n-lark Sep 22, 2026
8c8595f
Merge remote-tracking branch 'origin/2239-data-store-add-msg' into 22…
n-lark Sep 22, 2026
93c8c47
Merge remote-tracking branch 'origin/main' into 249-data-store
n-lark Sep 23, 2026
4a84e73
Merge remote-tracking branch 'origin/249-data-store' into 249-data-st…
n-lark Sep 23, 2026
d2e0331
Update check for isReactable"
n-lark Sep 23, 2026
cf1dfd6
Update wording of warn text for onReplaced
n-lark Sep 23, 2026
fb3af54
Remove isMemoryBacked, add checks directly to attachToContext with specs
n-lark Sep 23, 2026
75c21bd
Clear value in deleteProperty
n-lark Sep 23, 2026
e8fb897
Update comment for reserved names
n-lark Sep 23, 2026
9f2f265
Merge remote-tracking branch 'origin/249-data-store-cache-off' into 2…
n-lark Sep 23, 2026
c891745
Swap default to false for storeEnabled
n-lark Sep 23, 2026
3f7fa40
Merge remote-tracking branch 'origin/249-data-store-populate' into 22…
n-lark Sep 23, 2026
424b9dc
Add deepFreeze to msg and history
n-lark Sep 23, 2026
3c05e87
Synthesise msg from the value in writeValue so no write path leaves a…
n-lark Sep 23, 2026
a09492c
Derive the stored value from the cloned msg in SET_ENTRY so one clone…
n-lark Sep 23, 2026
8dc068d
Merge remote-tracking branch 'origin/2239-data-store-add-msg' into 22…
n-lark Sep 24, 2026
57c5fe0
Update fn names, protect against malformed append, extract helpers
n-lark Sep 25, 2026
8fddd98
Clone the points apart from the rest in SET_SERIES so freezing a mess…
n-lark Sep 25, 2026
33ed22e
Move trimcounts out of try and add guards
n-lark Sep 25, 2026
870c1bc
Ensure we are not freezing req + res
n-lark Sep 25, 2026
9be473a
Merge remote-tracking branch 'origin/2239-data-store-add-msg' into 22…
n-lark Sep 25, 2026
35578b3
Add error catch, reflect back symbol
n-lark Sep 28, 2026
a275c2a
Merge remote-tracking branch 'origin/main' into 249-data-store
n-lark Sep 28, 2026
c366959
Merge remote-tracking branch 'origin/249-data-store-cache-off' into 2…
n-lark Sep 28, 2026
73606f8
Merge remote-tracking branch 'origin/249-data-store' into 249-data-st…
n-lark Sep 28, 2026
9e45f53
Merge remote-tracking branch 'origin/249-data-store-populate' into 22…
n-lark Sep 28, 2026
0df6876
Merge remote-tracking branch 'origin/2239-data-store-add-msg' into 22…
n-lark Sep 28, 2026
708d327
Add seperate clones to prevent payload freezing
n-lark Sep 29, 2026
decda60
Merge remote-tracking branch 'origin/2239-data-store-add-msg' into 22…
n-lark Sep 29, 2026
59af60e
Add error handling for methods to node.warn
n-lark Oct 1, 2026
db7d963
Move options into smol helper to prevent drift
n-lark Oct 1, 2026
5e018a2
Remove cloning for existance check and point trim check
n-lark Oct 1, 2026
2890ed4
Add testing harness for legacy datastore
n-lark Oct 1, 2026
333ebdd
Merge saved messages inside the store and make clear(id) the single c…
n-lark Oct 2, 2026
5d39d11
Merge remote-tracking branch 'origin/main' into 249-data-store
n-lark Oct 8, 2026
2e2406a
Merge remote-tracking branch 'origin/249-data-store' into 2239-data-s…
n-lark Oct 9, 2026
b623cbb
Merge remote-tracking branch 'origin/249-data-store' into 2238-data-s…
n-lark Oct 9, 2026
9a4fea7
Merge remote-tracking branch 'origin/249-data-store' into 2239-data-s…
n-lark Oct 9, 2026
ab6f9b1
Merge remote-tracking branch 'origin/249-data-store' into 2271-data-s…
n-lark Oct 9, 2026
fd45995
Harden the differential harness with aliasing checks and scoped widge…
n-lark Oct 9, 2026
94a8904
Merge remote-tracking branch 'origin/249-data-store' into 2273-data-s…
n-lark Oct 9, 2026
8399fa0
Trim chart series inside the store through TRIM_SERIES and FLUSH_SERI…
n-lark Oct 9, 2026
b6252e3
Gate the harness on the store alone matching the reference, and apply…
n-lark Oct 14, 2026
88ffee1
Fix blank history entries and fix confusing ternary
n-lark Oct 15, 2026
f5967f8
Fall back to an in-memory store that survives redeploys when global c…
n-lark Oct 15, 2026
9dfe94e
Read get and has from the store and drop the proxies' pass-through ge…
n-lark Oct 16, 2026
23c12e6
Remove the legacy datastore, and send operation errors to the widget …
n-lark Oct 16, 2026
684af5e
Copy incoming fields before writing an entry so a failed save leaves …
n-lark Oct 16, 2026
6d585e6
Show the trimmed series in the $ view and drop FLUSH_SERIES
n-lark Oct 19, 2026
c7c870b
Merge remote-tracking branch 'origin/2273-data-store-computes' into 2…
n-lark Oct 19, 2026
8e0fba9
Update to getStoreOrWarn and remove fn layer
n-lark Oct 19, 2026
d3605d4
Have reload start fresh
n-lark Oct 20, 2026
9a0ce0f
Update to only warnedStoreUnavailable on start
n-lark Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 18 additions & 14 deletions nodes/config/ui_base.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const v = require('../../package.json').version
const { createClientStore } = require('../store/clients.js')
const datastore = require('../store/data.js')
const statestore = require('../store/state.js')
const { appendTopic, addConnectionCredentials, matchesClient, normalizeClientId, getThirdPartyWidgets } = require('../utils/index.js')
const { appendTopic, addConnectionCredentials, matchesClient, normalizeClientId, getThirdPartyWidgets, hasExtraProps } = require('../utils/index.js')

// from: https://stackoverflow.com/a/28592528/3016654
function join (...paths) {
Expand All @@ -27,26 +27,15 @@ function hasProperty (obj, prop) {
return Object.prototype.hasOwnProperty.call(obj, prop)
}

/**
* Test whether a message has any properties other than ui_update, class, visible, enabled and _msgid
* Properties with the value undefined are ignored
* @param {*} msg
* @returns true if other properties found
*/
function hasExtraProps (message) {
const allowed = ['_msgid', 'ui_update', 'class', 'visible', 'enabled']
const keys = Object.keys(message).filter(key => message[key] !== undefined)

return keys.length > 0 && keys.some(key => !allowed.includes(key))
}

module.exports = function (RED) {
const express = require('express')
const { Server } = require('socket.io')

datastore.setConfig(RED)
statestore.setConfig(RED)

let warnedStoreUnavailable = false

/**
* @typedef {import('socket.io').Socket} Socket
* @typedef {import('socket.io').Server} Server
Expand Down Expand Up @@ -401,6 +390,21 @@ module.exports = function (RED) {

node._created = Date.now()

try {
let unavailable = null
const store = datastore.initStore(node.context().global, {
node,
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.<key> rather than replacing global.dashboardStore itself.')
})
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) {
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 || '/'

/** @type {Object.<string, Socket>} */
Expand Down
140 changes: 79 additions & 61 deletions nodes/store/data.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,69 @@
const { isClientScoped } = require('../utils/index.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 fallbackStore = null
let storeContext = null
let storeBase = null
let warned = false

function warnOnce (problem) {
if (warned || !storeBase) return
warned = true
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 })

function getOrCreateStore (globalContext) {
let unavailable = null
const store = attachToContext(globalContext, { ...attachOptions(), onUnavailable: (err) => { unavailable = err } })
if (!store) throw unavailable || new Error('global context saved a copy of the store rather than the store itself, which happens with context stores that serialise values')
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
storeContext = globalContext
storeBase = node
warned = false
let store = null
try {
store = attachToContext(globalContext, attachOptions())
} finally {
if (store) {
fallbackStore = null
} else if (!fallbackStore) {
fallbackStore = createDataStore(attachOptions())
}
}
return store
}

function latestPerTopic (msgs, maxPoints) {
const counts = {}
const kept = []
for (let i = msgs.length - 1; i >= 0; i--) {
const topic = msgs[i].topic
counts[topic] = (counts[topic] || 0) + 1
if (counts[topic] <= maxPoints) {
kept.push(msgs[i])
}
}
return kept.reverse()
}

/**
* Checks if a Client/Socket ID has been assigned to this message,
* and whether the node type is being scoped to a specific client.
Expand Down Expand Up @@ -40,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
}
}

Expand All @@ -70,7 +119,10 @@ const setters = {
},
// remove data associated to a given widget
clear (id) {
delete data[id]
const store = getStoreOrWarn('clearing', id)
if (store) {
delete store[id]
}
},
/**
*
Expand All @@ -80,32 +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
} else {
if (canSaveInStore(base, node, msg)) {
const newMsg = stripMsg(msg)
data[node.id] = {
...data[node.id],
...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))
if (msg && typeof msg === 'object' && canSaveInStore(base, node, msg)) {
getStoreOrWarn('saving', node.id)?.[APPEND_ENTRY](node.id, msg)
}
},
/**
Expand All @@ -115,35 +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) {
getStoreOrWarn('trimming', node.id)?.[TRIM_SERIES](node.id, (msgs) => msgs.filter(filterFunction))
}
},
keepLatestPerTopic (base, node, maxPoints) {
const currentData = data[node.id]
if (!Array.isArray(currentData)) {
return
}
const counts = {}
const keep = []
let trimmed = false
for (let i = currentData.length - 1; i >= 0; i--) {
const topic = currentData[i].topic
counts[topic] = (counts[topic] || 0) + 1
if (counts[topic] <= maxPoints) {
keep[i] = true
} else {
trimmed = true
}
}
if (trimmed) {
setters.filter(base, node, (m, i) => keep[i])
}
getStoreOrWarn('trimming', node.id)?.[TRIM_SERIES](node.id, (msgs) => latestPerTopic(msgs, maxPoints))
}
}

Expand All @@ -156,5 +173,6 @@ module.exports = {
append: setters.append,
filter: setters.filter,
keepLatestPerTopic: setters.keepLatestPerTopic,
clear: setters.clear
clear: setters.clear,
initStore
}
Loading
Loading