node-red-contrib-questdb 0.6.9 → 0.6.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
1
+ <style>
2
+ .questdb-white-text { fill: #ffffff !important; }
3
+ .red-ui-palette-node[data-palette-type="questdb-value"] .red-ui-palette-label { color: #ffffff !important; }
4
+ .red-ui-palette-node[data-palette-type="questdb-value"] .red-ui-palette-icon-container { background-color: #f0f0f0 !important; }
5
+ </style>
6
+
7
+ <script type="text/javascript">
8
+ RED.nodes.registerType('questdb-value', {
9
+ category: 'questdb',
10
+ color: '#a23154',
11
+ defaults: {
12
+ name: { value: "" },
13
+ questdb: { value: "", type: "questdb-config", required: true },
14
+ table: { value: "" },
15
+ tagName: { value: "tag" },
16
+ tagSource: { value: "topic" },
17
+ valueName: { value: "value" },
18
+ extraFields: { value: [] }
19
+ },
20
+ inputs: 1,
21
+ outputs: 2,
22
+ outputLabels: ["success", "error"],
23
+ icon: "questdb-logo.png",
24
+ paletteLabel: "Value",
25
+ label: function () { return this.name || "Value"; },
26
+ labelStyle: function () { return (this.name ? "node_label_italic" : "") + " questdb-white-text"; },
27
+ oneditprepare: function () {
28
+ var node = this;
29
+ var fieldList = $("#node-input-extraFields-container").css("min-height", "100px").editableList({
30
+ sortable: true,
31
+ removable: true,
32
+ addButton: true,
33
+ addItem: function (container, i, field) {
34
+ var row = $('<div/>').appendTo(container);
35
+ $('<label style="width:35px">msg.</label>').appendTo(row);
36
+ $('<input/>', { class: "node-input-extra-msgProp", type: "text", style: "width:100px", placeholder: "property" })
37
+ .val(field.msgProperty || "")
38
+ .appendTo(row);
39
+ $('<label style="width:20px; text-align:center">&rarr;</label>').appendTo(row);
40
+ $('<input/>', { class: "node-input-extra-colName", type: "text", style: "width:100px", placeholder: "column name" })
41
+ .val(field.columnName || "")
42
+ .appendTo(row);
43
+ $('<select/>', { class: "node-input-extra-fieldType", style: "width:80px; margin-left:5px" })
44
+ .append($('<option value="column">column</option>'))
45
+ .append($('<option value="symbol">symbol</option>'))
46
+ .val(field.fieldType || "column")
47
+ .appendTo(row);
48
+ }
49
+ });
50
+ if (node.extraFields) {
51
+ node.extraFields.forEach(function (f) { fieldList.editableList('addItem', f); });
52
+ }
53
+ },
54
+ oneditsave: function () {
55
+ var fields = [];
56
+ $("#node-input-extraFields-container").editableList('items').each(function () {
57
+ var msgProp = $(this).find(".node-input-extra-msgProp").val().trim();
58
+ var colName = $(this).find(".node-input-extra-colName").val().trim();
59
+ var fieldType = $(this).find(".node-input-extra-fieldType").val();
60
+ if (msgProp && colName) {
61
+ fields.push({ msgProperty: msgProp, columnName: colName, fieldType: fieldType });
62
+ }
63
+ });
64
+ this.extraFields = fields;
65
+ }
66
+ });
67
+ </script>
68
+
69
+ <script type="text/html" data-template-name="questdb-value">
70
+ <div class="form-row">
71
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
72
+ <input type="text" id="node-input-name" placeholder="Name">
73
+ </div>
74
+ <div class="form-row">
75
+ <label for="node-input-questdb"><i class="fa fa-database"></i> Server</label>
76
+ <input type="text" id="node-input-questdb">
77
+ </div>
78
+ <div class="form-row">
79
+ <label for="node-input-table"><i class="fa fa-table"></i> Table</label>
80
+ <input type="text" id="node-input-table" placeholder="from msg.topic">
81
+ </div>
82
+ <div class="form-row">
83
+ <label for="node-input-tagName"><i class="fa fa-bookmark"></i> Tag Column</label>
84
+ <input type="text" id="node-input-tagName" placeholder="tag">
85
+ </div>
86
+ <div class="form-row">
87
+ <label for="node-input-tagSource"><i class="fa fa-sign-in"></i> Tag Source</label>
88
+ <input type="text" id="node-input-tagSource" placeholder="topic">
89
+ <div class="form-tips">msg property to use as tag value (e.g. <code>topic</code>)</div>
90
+ </div>
91
+ <div class="form-row">
92
+ <label for="node-input-valueName"><i class="fa fa-line-chart"></i> Value Column</label>
93
+ <input type="text" id="node-input-valueName" placeholder="value">
94
+ </div>
95
+ <div class="form-row">
96
+ <label><i class="fa fa-plus-square"></i> Extra Fields</label>
97
+ <ol id="node-input-extraFields-container"></ol>
98
+ </div>
99
+ </script>
100
+
101
+ <script type="text/html" data-help-name="questdb-value">
102
+ <p>Writes a single typed value to QuestDB with auto type detection.</p>
103
+
104
+ <h3>Inputs</h3>
105
+ <dl class="message-properties">
106
+ <dt>payload <span class="property-type">any</span></dt>
107
+ <dd>The value to write. Type is auto-detected (long, double, bool, string).</dd>
108
+ <dt class="optional">topic <span class="property-type">string</span></dt>
109
+ <dd>Table name (if not set in config). Also default source for the tag value.</dd>
110
+ <dt class="optional">timestamp <span class="property-type">number | Date | string</span></dt>
111
+ <dd>Row timestamp. Uses server time if not provided.</dd>
112
+ </dl>
113
+
114
+ <h3>Outputs</h3>
115
+ <ol class="node-ports">
116
+ <li>Success
117
+ <dl class="message-properties">
118
+ <dt>payload.success <span class="property-type">boolean</span></dt>
119
+ <dd><code>true</code></dd>
120
+ <dt>payload.type <span class="property-type">string</span></dt>
121
+ <dd>Detected type (long, double, bool, string)</dd>
122
+ </dl>
123
+ </li>
124
+ <li>Error
125
+ <dl class="message-properties">
126
+ <dt>payload.error <span class="property-type">string</span></dt>
127
+ <dd>Error description</dd>
128
+ </dl>
129
+ </li>
130
+ </ol>
131
+
132
+ <h3>Details</h3>
133
+ <p>Combines type detection and writing into a single node. Detects the type of
134
+ <code>msg.payload</code> and writes it to QuestDB with a tag (symbol) and timestamp.</p>
135
+ <p>Configure extra fields to include additional msg properties as symbols or columns.</p>
136
+
137
+ <h3>Example</h3>
138
+ <pre>// Input
139
+ msg.topic = "temperature"
140
+ msg.payload = 23.5
141
+ msg.timestamp = Date.now()
142
+
143
+ // Writes to QuestDB:
144
+ // table: "temperature", tag: "temperature", value: 23.5 (double)</pre>
145
+ </script>
@@ -0,0 +1,280 @@
1
+ module.exports = function (RED) {
2
+ const { normalizeTimestamp } = require('./lib/timestamp');
3
+ const { formatError, isDisconnectionError } = require('./lib/errors');
4
+
5
+ // Detect the canonical QuestDB type from a value.
6
+ function detectType(value) {
7
+ if (typeof value === 'boolean') return 'bool';
8
+ if (typeof value === 'number') {
9
+ if (!isFinite(value)) return null;
10
+ return Number.isInteger(value) ? 'long' : 'double';
11
+ }
12
+ if (typeof value === 'string') {
13
+ var trimmed = value.trim();
14
+ var lower = trimmed.toLowerCase();
15
+ if (lower === 'true' || lower === 'false') return 'bool';
16
+ if (trimmed !== '' && !isNaN(trimmed)) {
17
+ return trimmed.includes('.') ? 'double' : 'long';
18
+ }
19
+ return 'string';
20
+ }
21
+ return null;
22
+ }
23
+
24
+ // Cast value to the target type.
25
+ function castValue(value, type) {
26
+ if (type === 'long') {
27
+ var n = Number(value);
28
+ return isFinite(n) ? Math.trunc(n) : null;
29
+ }
30
+ if (type === 'double') {
31
+ var d = Number(value);
32
+ return isFinite(d) ? d : null;
33
+ }
34
+ if (type === 'bool') {
35
+ if (typeof value === 'boolean') return value;
36
+ if (typeof value === 'string') {
37
+ var t = value.trim().toLowerCase();
38
+ if (t === 'true') return true;
39
+ if (t === 'false') return false;
40
+ }
41
+ return null;
42
+ }
43
+ if (type === 'string') return String(value);
44
+ return null;
45
+ }
46
+
47
+ function QuestDBValueNode(config) {
48
+ RED.nodes.createNode(this, config);
49
+ var node = this;
50
+
51
+ node.questdbConfig = RED.nodes.getNode(config.questdb);
52
+ if (!node.questdbConfig) {
53
+ node.error('QuestDB configuration not set');
54
+ node.status({ fill: 'red', shape: 'ring', text: 'no config' });
55
+ return;
56
+ }
57
+
58
+ node.table = config.table || '';
59
+ node.tagName = config.tagName || 'tag';
60
+ node.tagSource = config.tagSource || 'topic';
61
+ node.valueName = config.valueName || 'value';
62
+ node.extraFields = config.extraFields || [];
63
+
64
+ var connection = node.questdbConfig.getConnection();
65
+ if (!connection) {
66
+ node.error('Failed to get connection from config');
67
+ node.status({ fill: 'red', shape: 'ring', text: 'no connection' });
68
+ return;
69
+ }
70
+
71
+ connection.users++;
72
+
73
+ if (!connection.connected && !connection.connecting) {
74
+ connection.connect();
75
+ }
76
+
77
+ var statusMap = {
78
+ connected: { fill: 'green', shape: 'dot', text: 'connected' },
79
+ connecting: { fill: 'yellow', shape: 'ring', text: 'connecting...' },
80
+ disconnected: { fill: 'red', shape: 'ring', text: 'disconnected' },
81
+ };
82
+
83
+ var stateChangeHandler = function (state) {
84
+ var statusConfig = statusMap[state.status] || {
85
+ fill: 'grey',
86
+ shape: 'ring',
87
+ text: 'unknown',
88
+ };
89
+ if (state.error) {
90
+ statusConfig.text = state.status + ': ' + state.error.substring(0, 30);
91
+ }
92
+ node.status(statusConfig);
93
+ };
94
+
95
+ connection.emitter.on('stateChange', stateChangeHandler);
96
+
97
+ var initialStatus = connection.connected
98
+ ? 'connected'
99
+ : connection.connecting
100
+ ? 'connecting'
101
+ : 'disconnected';
102
+ stateChangeHandler({ status: initialStatus, error: connection.lastError });
103
+
104
+ function sendError(msg, errorText) {
105
+ node.error(errorText, msg);
106
+ node.status({ fill: 'red', shape: 'ring', text: errorText.substring(0, 30) });
107
+ var errMsg = RED.util.cloneMessage(msg);
108
+ errMsg.payload = { success: false, error: errorText };
109
+ node.send([null, errMsg]);
110
+ }
111
+
112
+ node.on('input', async function (msg) {
113
+ // Connection checks (rate-limit error logging to once per 5s)
114
+ if (!connection.connected && !connection.connecting) {
115
+ connection.connect();
116
+ var now = Date.now();
117
+ if (now - connection.lastNotConnectedLog > 5000) {
118
+ connection.lastNotConnectedLog = now;
119
+ sendError(msg, 'Not connected to QuestDB, reconnecting...');
120
+ } else {
121
+ var errMsg = RED.util.cloneMessage(msg);
122
+ errMsg.payload = { success: false, error: 'Not connected to QuestDB, reconnecting...' };
123
+ node.send([null, errMsg]);
124
+ }
125
+ return;
126
+ }
127
+ if (!connection.connected || !connection.sender) {
128
+ var now2 = Date.now();
129
+ if (now2 - connection.lastNotConnectedLog > 5000) {
130
+ connection.lastNotConnectedLog = now2;
131
+ sendError(msg, 'Not connected to QuestDB');
132
+ } else {
133
+ var errMsg2 = RED.util.cloneMessage(msg);
134
+ errMsg2.payload = { success: false, error: 'Not connected to QuestDB' };
135
+ node.send([null, errMsg2]);
136
+ }
137
+ return;
138
+ }
139
+ if (
140
+ connection.sender.transport &&
141
+ typeof connection.sender.transport.connected === 'boolean' &&
142
+ !connection.sender.transport.connected
143
+ ) {
144
+ connection.updateState(false);
145
+ connection.connect();
146
+ sendError(msg, 'TCP transport disconnected, reconnecting...');
147
+ return;
148
+ }
149
+
150
+ // Validate table name
151
+ var tableName = node.table || msg.topic;
152
+ if (!tableName) {
153
+ sendError(msg, 'Table name not specified (set in config or msg.topic)');
154
+ return;
155
+ }
156
+
157
+ // Validate tag
158
+ var tagValue = msg[node.tagSource];
159
+ if (tagValue === null || tagValue === undefined || tagValue === '') {
160
+ sendError(msg, 'Tag value is empty (msg.' + node.tagSource + ')');
161
+ return;
162
+ }
163
+
164
+ // Validate payload
165
+ var value = msg.payload;
166
+ if (value === null || value === undefined) {
167
+ sendError(msg, 'Payload is null or undefined');
168
+ return;
169
+ }
170
+
171
+ // Detect and cast type
172
+ var type = detectType(value);
173
+ if (type === null) {
174
+ sendError(msg, 'Cannot detect type of payload: ' + typeof value);
175
+ return;
176
+ }
177
+ var castedValue = castValue(value, type);
178
+ if (castedValue === null) {
179
+ sendError(msg, 'Cannot cast payload to ' + type);
180
+ return;
181
+ }
182
+
183
+ try {
184
+ connection.sender.table(tableName);
185
+ connection.sender.symbol(node.tagName, String(tagValue));
186
+
187
+ // Write the value with the detected type
188
+ if (type === 'long') {
189
+ connection.sender.intColumn(node.valueName, castedValue);
190
+ } else if (type === 'double') {
191
+ connection.sender.floatColumn(node.valueName, castedValue);
192
+ } else if (type === 'bool') {
193
+ connection.sender.booleanColumn(node.valueName, castedValue);
194
+ } else if (type === 'string') {
195
+ connection.sender.stringColumn(node.valueName, castedValue);
196
+ }
197
+
198
+ // Process extra fields from msg
199
+ for (var i = 0; i < node.extraFields.length; i++) {
200
+ var field = node.extraFields[i];
201
+ var extraVal = msg[field.msgProperty];
202
+ if (extraVal === null || extraVal === undefined) {
203
+ sendError(msg, 'Extra field msg.' + field.msgProperty + ' is null or undefined (column: ' + field.columnName + ')');
204
+ return;
205
+ }
206
+
207
+ if (field.fieldType === 'symbol') {
208
+ connection.sender.symbol(field.columnName, String(extraVal));
209
+ } else {
210
+ // Auto-detect column type
211
+ var extraType = detectType(extraVal);
212
+ if (extraType === 'long') {
213
+ connection.sender.intColumn(field.columnName, Math.trunc(Number(extraVal)));
214
+ } else if (extraType === 'double') {
215
+ connection.sender.floatColumn(field.columnName, Number(extraVal));
216
+ } else if (extraType === 'bool') {
217
+ connection.sender.booleanColumn(field.columnName, castValue(extraVal, 'bool'));
218
+ } else {
219
+ connection.sender.stringColumn(field.columnName, String(extraVal));
220
+ }
221
+ }
222
+ }
223
+
224
+ // Timestamp
225
+ var timestampMicros = normalizeTimestamp(msg.timestamp, node);
226
+ if (timestampMicros === null) {
227
+ connection.sender.atNow();
228
+ } else {
229
+ connection.sender.at(timestampMicros);
230
+ }
231
+
232
+ // Track rows and flush at threshold
233
+ connection.rowCount++;
234
+ if (connection.flushRows && connection.rowCount >= connection.flushRows) {
235
+ try {
236
+ await connection.sender.flush();
237
+ connection.rowCount = 0;
238
+ } catch (flushErr) {
239
+ var flushErrMsg = formatError(flushErr, { operation: 'flush', table: tableName });
240
+ RED.log.error('[QuestDB] Flush failed: ' + flushErrMsg);
241
+ if (isDisconnectionError(flushErr)) {
242
+ connection.updateState(false, flushErr.message);
243
+ connection.sender = null;
244
+ connection.connect();
245
+ }
246
+ sendError(msg, flushErrMsg);
247
+ return;
248
+ }
249
+ }
250
+
251
+ var label = type + ': ' + String(castedValue);
252
+ if (label.length > 25) label = label.substring(0, 25) + '...';
253
+ node.status({ fill: 'green', shape: 'dot', text: tableName + ' ' + label });
254
+ msg.payload = { success: true, table: tableName, type: type, value: castedValue };
255
+ node.send([msg, null]);
256
+ } catch (err) {
257
+ var errMsg = formatError(err, { operation: 'write', table: tableName });
258
+
259
+ if (isDisconnectionError(err)) {
260
+ node.status({ fill: 'red', shape: 'ring', text: 'disconnected' });
261
+ connection.updateState(false, err.message);
262
+ connection.sender = null;
263
+ connection.connect();
264
+ } else {
265
+ node.status({ fill: 'yellow', shape: 'ring', text: 'write failed' });
266
+ }
267
+
268
+ sendError(msg, errMsg);
269
+ }
270
+ });
271
+
272
+ node.on('close', function (done) {
273
+ connection.emitter.removeListener('stateChange', stateChangeHandler);
274
+ connection.users--;
275
+ done();
276
+ });
277
+ }
278
+
279
+ RED.nodes.registerType('questdb-value', QuestDBValueNode);
280
+ };