node-red-contrib-questdb 0.1.0

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.
package/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # node-red-contrib-questdb
2
+
3
+ Node-RED nodes for QuestDB time-series database.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install node-red-contrib-questdb
9
+ ```
10
+
11
+ ## Nodes
12
+
13
+ ### questdb
14
+
15
+ A node for interacting with QuestDB time-series database.
16
+
17
+ ## License
18
+
19
+ MIT
@@ -0,0 +1,205 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('questdb-mapper', {
3
+ category: 'questdb',
4
+ color: '#a23154',
5
+ defaults: {
6
+ name: {value: ""},
7
+ tableName: {value: ""},
8
+ timestampField: {value: ""},
9
+ symbolMappings: {value: []},
10
+ columnMappings: {value: []}
11
+ },
12
+ inputs: 1,
13
+ outputs: 1,
14
+ icon: "font-awesome/fa-exchange",
15
+ label: function() {
16
+ return this.name || "Mapper";
17
+ },
18
+ paletteLabel: "Mapper",
19
+ labelStyle: function() {
20
+ return this.name ? "node_label_italic questdb-white-text" : "questdb-white-text";
21
+ },
22
+ oneditprepare: function() {
23
+ const node = this;
24
+
25
+ // Symbol mappings list
26
+ const symbolList = $("#node-input-symbol-list").css({
27
+ 'min-height': '100px',
28
+ 'min-width': '400px'
29
+ }).editableList({
30
+ addItem: function(container, i, data) {
31
+ const row = $('<div/>').appendTo(container);
32
+ $('<input/>', {type: "text", placeholder: "msg.field", class: "symbol-source"})
33
+ .css({width: "45%", marginRight: "5px"})
34
+ .val(data.source || "")
35
+ .appendTo(row);
36
+ $('<span/>').text(" → ").appendTo(row);
37
+ $('<input/>', {type: "text", placeholder: "symbol name", class: "symbol-target"})
38
+ .css({width: "40%", marginLeft: "5px"})
39
+ .val(data.target || "")
40
+ .appendTo(row);
41
+ },
42
+ removable: true,
43
+ sortable: true
44
+ });
45
+
46
+ // Column mappings list
47
+ const columnList = $("#node-input-column-list").css({
48
+ 'min-height': '100px',
49
+ 'min-width': '400px'
50
+ }).editableList({
51
+ addItem: function(container, i, data) {
52
+ const row = $('<div/>').appendTo(container);
53
+ $('<input/>', {type: "text", placeholder: "msg.field", class: "column-source"})
54
+ .css({width: "35%", marginRight: "5px"})
55
+ .val(data.source || "")
56
+ .appendTo(row);
57
+ $('<span/>').text(" → ").appendTo(row);
58
+ $('<input/>', {type: "text", placeholder: "column name", class: "column-target"})
59
+ .css({width: "30%", marginLeft: "5px", marginRight: "5px"})
60
+ .val(data.target || "")
61
+ .appendTo(row);
62
+ const typeSelect = $('<select/>', {class: "column-type"})
63
+ .css({width: "20%"})
64
+ .appendTo(row);
65
+ ['auto', 'float', 'integer', 'string', 'boolean', 'timestamp'].forEach(function(t) {
66
+ $('<option/>').val(t).text(t).appendTo(typeSelect);
67
+ });
68
+ typeSelect.val(data.type || "auto");
69
+ },
70
+ removable: true,
71
+ sortable: true
72
+ });
73
+
74
+ // Load existing mappings
75
+ if (node.symbolMappings) {
76
+ node.symbolMappings.forEach(function(m) {
77
+ symbolList.editableList('addItem', m);
78
+ });
79
+ }
80
+ if (node.columnMappings) {
81
+ node.columnMappings.forEach(function(m) {
82
+ columnList.editableList('addItem', m);
83
+ });
84
+ }
85
+ },
86
+ oneditresize: function(size) {
87
+ // Resize editable lists if needed
88
+ },
89
+ oneditsave: function() {
90
+ const node = this;
91
+
92
+ // Save symbol mappings
93
+ node.symbolMappings = [];
94
+ $("#node-input-symbol-list").editableList('items').each(function() {
95
+ const source = $(this).find(".symbol-source").val().trim();
96
+ const target = $(this).find(".symbol-target").val().trim();
97
+ if (source && target) {
98
+ node.symbolMappings.push({source: source, target: target});
99
+ }
100
+ });
101
+
102
+ // Save column mappings
103
+ node.columnMappings = [];
104
+ $("#node-input-column-list").editableList('items').each(function() {
105
+ const source = $(this).find(".column-source").val().trim();
106
+ const target = $(this).find(".column-target").val().trim();
107
+ const type = $(this).find(".column-type").val();
108
+ if (source && target) {
109
+ node.columnMappings.push({source: source, target: target, type: type});
110
+ }
111
+ });
112
+ }
113
+ });
114
+ </script>
115
+
116
+ <script type="text/html" data-template-name="questdb-mapper">
117
+ <div class="form-row">
118
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
119
+ <input type="text" id="node-input-name" placeholder="Name">
120
+ </div>
121
+ <div class="form-row">
122
+ <label for="node-input-tableName"><i class="fa fa-table"></i> Table Name</label>
123
+ <input type="text" id="node-input-tableName" placeholder="Leave empty to use msg.topic">
124
+ </div>
125
+ <div class="form-row">
126
+ <label for="node-input-timestampField"><i class="fa fa-clock-o"></i> Timestamp Field</label>
127
+ <input type="text" id="node-input-timestampField" placeholder="e.g. payload.timestamp">
128
+ </div>
129
+ <div class="form-row">
130
+ <label style="width:100%;"><i class="fa fa-bookmark"></i> Symbol Mappings (indexed tags)</label>
131
+ <ol id="node-input-symbol-list"></ol>
132
+ </div>
133
+ <div class="form-row">
134
+ <label style="width:100%;"><i class="fa fa-columns"></i> Column Mappings (values)</label>
135
+ <ol id="node-input-column-list"></ol>
136
+ </div>
137
+ </script>
138
+
139
+ <script type="text/html" data-help-name="questdb-mapper">
140
+ <p>Maps message fields to QuestDB ILP structure for use with the Write node.</p>
141
+
142
+ <h3>Properties</h3>
143
+ <dl class="message-properties">
144
+ <dt>Table Name</dt>
145
+ <dd>Target table name. If empty, uses <code>msg.topic</code></dd>
146
+ <dt>Timestamp Field</dt>
147
+ <dd>Path to timestamp field (e.g. <code>payload.ts</code>)</dd>
148
+ <dt>Symbol Mappings</dt>
149
+ <dd>Map msg fields to QuestDB symbols (indexed string columns)</dd>
150
+ <dt>Column Mappings</dt>
151
+ <dd>Map msg fields to QuestDB columns with type conversion</dd>
152
+ </dl>
153
+
154
+ <h3>Field Path Syntax</h3>
155
+ <p>Use dot notation: <code>payload.sensor.value</code></p>
156
+ <p>Array access: <code>payload.readings[0]</code></p>
157
+
158
+ <h3>Output</h3>
159
+ <p>Produces a message ready for the QuestDB Write node:</p>
160
+ <pre>{
161
+ topic: "table_name",
162
+ payload: {
163
+ symbols: { ... },
164
+ columns: { ... },
165
+ timestamp: ...
166
+ }
167
+ }</pre>
168
+
169
+ <h3>Example</h3>
170
+ <p>Input message:</p>
171
+ <pre>{
172
+ topic: "sensors",
173
+ payload: {
174
+ device: "sensor1",
175
+ temp: 23.5,
176
+ ts: 1699999999000
177
+ }
178
+ }</pre>
179
+ <p>With mappings:</p>
180
+ <ul>
181
+ <li>Symbol: <code>payload.device</code> → <code>device_id</code></li>
182
+ <li>Column: <code>payload.temp</code> → <code>temperature</code> (float)</li>
183
+ <li>Timestamp: <code>payload.ts</code></li>
184
+ </ul>
185
+ <p>Output:</p>
186
+ <pre>{
187
+ topic: "sensors",
188
+ payload: {
189
+ symbols: { device_id: "sensor1" },
190
+ columns: { temperature: 23.5 },
191
+ timestamp: 1699999999000
192
+ }
193
+ }</pre>
194
+ </script>
195
+
196
+ <style>
197
+ /* White text on flow canvas */
198
+ .questdb-white-text {
199
+ fill: #ffffff !important;
200
+ }
201
+ /* White text in palette */
202
+ .red-ui-palette-node[data-palette-type="questdb-mapper"] .red-ui-palette-label {
203
+ color: #ffffff !important;
204
+ }
205
+ </style>
@@ -0,0 +1,113 @@
1
+ module.exports = function(RED) {
2
+ function QuestDBMapperNode(config) {
3
+ RED.nodes.createNode(this, config);
4
+ const node = this;
5
+
6
+ // Configuration from node properties
7
+ node.tableName = config.tableName;
8
+ node.timestampField = config.timestampField;
9
+ node.symbolMappings = config.symbolMappings || [];
10
+ node.columnMappings = config.columnMappings || [];
11
+
12
+ node.on('input', function(msg, send, done) {
13
+ try {
14
+ const output = {
15
+ topic: node.tableName || msg.topic,
16
+ payload: {
17
+ symbols: {},
18
+ columns: {}
19
+ }
20
+ };
21
+
22
+ // Map timestamp
23
+ if (node.timestampField) {
24
+ const tsValue = getNestedValue(msg, node.timestampField);
25
+ if (tsValue !== undefined) {
26
+ output.payload.timestamp = tsValue;
27
+ }
28
+ }
29
+
30
+ // Map symbols
31
+ for (const mapping of node.symbolMappings) {
32
+ if (mapping.source && mapping.target) {
33
+ const value = getNestedValue(msg, mapping.source);
34
+ if (value !== undefined && value !== null) {
35
+ output.payload.symbols[mapping.target] = String(value);
36
+ }
37
+ }
38
+ }
39
+
40
+ // Map columns
41
+ for (const mapping of node.columnMappings) {
42
+ if (mapping.source && mapping.target) {
43
+ let value = getNestedValue(msg, mapping.source);
44
+ if (value !== undefined && value !== null) {
45
+ // Type conversion based on mapping type
46
+ switch (mapping.type) {
47
+ case 'float':
48
+ value = parseFloat(value);
49
+ if (isNaN(value)) continue;
50
+ break;
51
+ case 'integer':
52
+ value = parseInt(value, 10);
53
+ if (isNaN(value)) continue;
54
+ break;
55
+ case 'boolean':
56
+ value = Boolean(value);
57
+ break;
58
+ case 'string':
59
+ value = String(value);
60
+ break;
61
+ case 'timestamp':
62
+ if (typeof value === 'string') {
63
+ value = new Date(value);
64
+ } else if (typeof value === 'number') {
65
+ value = new Date(value);
66
+ }
67
+ break;
68
+ // 'auto' - keep original type
69
+ }
70
+ output.payload.columns[mapping.target] = value;
71
+ }
72
+ }
73
+ }
74
+
75
+ node.status({fill:"green", shape:"dot", text:`mapped: ${output.topic}`});
76
+ send(output);
77
+ done();
78
+
79
+ } catch (err) {
80
+ node.status({fill:"red", shape:"ring", text:"error"});
81
+ done(err);
82
+ }
83
+ });
84
+
85
+ // Helper function to get nested property value
86
+ function getNestedValue(obj, path) {
87
+ // Strip 'msg.' prefix if present since we're already working with msg object
88
+ if (path.startsWith('msg.')) {
89
+ path = path.substring(4);
90
+ }
91
+ const parts = path.split('.');
92
+ let current = obj;
93
+ for (const part of parts) {
94
+ if (current === undefined || current === null) {
95
+ return undefined;
96
+ }
97
+ // Handle array notation like payload[0]
98
+ const match = part.match(/^(\w+)\[(\d+)\]$/);
99
+ if (match) {
100
+ current = current[match[1]];
101
+ if (Array.isArray(current)) {
102
+ current = current[parseInt(match[2], 10)];
103
+ }
104
+ } else {
105
+ current = current[part];
106
+ }
107
+ }
108
+ return current;
109
+ }
110
+ }
111
+
112
+ RED.nodes.registerType("questdb-mapper", QuestDBMapperNode);
113
+ };
@@ -0,0 +1,289 @@
1
+ <script type="text/javascript">
2
+ // Configuration node
3
+ RED.nodes.registerType('questdb-config',{
4
+ category: 'config',
5
+ defaults: {
6
+ name: {value:""},
7
+ protocol: {value:"http"},
8
+ host: {value:"localhost", required:true},
9
+ port: {value:9000, required:true, validate:RED.validators.number()},
10
+ tlsVerify: {value:true},
11
+ tlsCa: {value:""},
12
+ autoFlush: {value:true},
13
+ autoFlushRows: {value:75000, validate:RED.validators.number()},
14
+ autoFlushInterval: {value:1000, validate:RED.validators.number()},
15
+ requestTimeout: {value:10000, validate:RED.validators.number()},
16
+ retryTimeout: {value:10000, validate:RED.validators.number()},
17
+ initBufSize: {value:65536, validate:RED.validators.number()},
18
+ maxBufSize: {value:104857600, validate:RED.validators.number()},
19
+ useAuth: {value:false},
20
+ authType: {value:"basic"}
21
+ },
22
+ credentials: {
23
+ username: {type:"text"},
24
+ password: {type:"password"},
25
+ token: {type:"password"}
26
+ },
27
+ label: function() {
28
+ return this.name || `${this.host}:${this.port}`;
29
+ },
30
+ oneditprepare: function() {
31
+ var tabs = RED.tabs.create({
32
+ id: "questdb-config-tabs",
33
+ onchange: function(tab) {
34
+ $("#questdb-config-tabs-content").children().hide();
35
+ $("#" + tab.id).show();
36
+ }
37
+ });
38
+ tabs.addTab({
39
+ id: "questdb-tab-connection",
40
+ label: "Connection"
41
+ });
42
+ tabs.addTab({
43
+ id: "questdb-tab-security",
44
+ label: "Security"
45
+ });
46
+ tabs.addTab({
47
+ id: "questdb-tab-advanced",
48
+ label: "Advanced"
49
+ });
50
+
51
+ // Show/hide TLS options based on protocol
52
+ $("#node-config-input-protocol").on("change", function() {
53
+ var proto = $(this).val();
54
+ if (proto === "https" || proto === "tcps") {
55
+ $(".tls-row").show();
56
+ } else {
57
+ $(".tls-row").hide();
58
+ }
59
+ // Update default port
60
+ if (proto === "tcp" || proto === "tcps") {
61
+ if ($("#node-config-input-port").val() === "9000") {
62
+ $("#node-config-input-port").val("9009");
63
+ }
64
+ } else {
65
+ if ($("#node-config-input-port").val() === "9009") {
66
+ $("#node-config-input-port").val("9000");
67
+ }
68
+ }
69
+ });
70
+ $("#node-config-input-protocol").trigger("change");
71
+
72
+ // Show/hide auth options
73
+ $("#node-config-input-useAuth").on("change", function() {
74
+ if ($(this).is(":checked")) {
75
+ $(".auth-type-row").show();
76
+ $("#node-config-input-authType").trigger("change");
77
+ } else {
78
+ $(".auth-type-row").hide();
79
+ $(".auth-row").hide();
80
+ }
81
+ });
82
+ $("#node-config-input-useAuth").trigger("change");
83
+
84
+ // Show/hide auth fields based on auth type
85
+ $("#node-config-input-authType").on("change", function() {
86
+ var authType = $(this).val();
87
+ if (authType === "basic") {
88
+ $(".basic-auth-row").show();
89
+ $(".token-auth-row").hide();
90
+ } else {
91
+ $(".basic-auth-row").hide();
92
+ $(".token-auth-row").show();
93
+ }
94
+ });
95
+ $("#node-config-input-authType").trigger("change");
96
+ }
97
+ });
98
+ </script>
99
+
100
+ <script type="text/html" data-template-name="questdb-config">
101
+ <div class="form-row">
102
+ <ul style="min-width: 450px; margin-bottom: 20px;" id="questdb-config-tabs"></ul>
103
+ </div>
104
+ <div id="questdb-config-tabs-content">
105
+ <div id="questdb-tab-connection" style="display:block;">
106
+ <div class="form-row">
107
+ <label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
108
+ <input type="text" id="node-config-input-name" placeholder="QuestDB Server">
109
+ </div>
110
+ <div class="form-row">
111
+ <label for="node-config-input-protocol"><i class="fa fa-exchange"></i> Protocol</label>
112
+ <select id="node-config-input-protocol" style="width:70%;">
113
+ <option value="http">HTTP</option>
114
+ <option value="https">HTTPS (TLS)</option>
115
+ <option value="tcp">TCP</option>
116
+ <option value="tcps">TCPS (TLS)</option>
117
+ </select>
118
+ </div>
119
+ <div class="form-row">
120
+ <label for="node-config-input-host"><i class="fa fa-server"></i> Host</label>
121
+ <input type="text" id="node-config-input-host" placeholder="localhost or IP address">
122
+ </div>
123
+ <div class="form-row">
124
+ <label for="node-config-input-port"><i class="fa fa-plug"></i> Port</label>
125
+ <input type="text" id="node-config-input-port" placeholder="9000">
126
+ </div>
127
+ <div class="form-row tls-row">
128
+ <label for="node-config-input-tlsVerify"><i class="fa fa-certificate"></i> Verify TLS</label>
129
+ <input type="checkbox" id="node-config-input-tlsVerify" style="display:inline-block; width:auto; vertical-align:baseline;" checked>
130
+ <span style="margin-left:8px;">Verify server certificate</span>
131
+ </div>
132
+ <div class="form-row tls-row">
133
+ <label for="node-config-input-tlsCa"><i class="fa fa-file-text-o"></i> CA Cert Path</label>
134
+ <input type="text" id="node-config-input-tlsCa" placeholder="Path to CA certificate (PEM)">
135
+ </div>
136
+ </div>
137
+ <div id="questdb-tab-security" style="display:none;">
138
+ <div class="form-row">
139
+ <label for="node-config-input-useAuth"><i class="fa fa-lock"></i> Enable Auth</label>
140
+ <input type="checkbox" id="node-config-input-useAuth" style="display:inline-block; width:auto; vertical-align:baseline;">
141
+ <span style="margin-left:8px;">Enable authentication</span>
142
+ </div>
143
+ <div class="form-row auth-type-row">
144
+ <label for="node-config-input-authType"><i class="fa fa-id-card"></i> Auth Type</label>
145
+ <select id="node-config-input-authType" style="width:70%;">
146
+ <option value="basic">Username / Password</option>
147
+ <option value="token">Bearer Token</option>
148
+ </select>
149
+ </div>
150
+ <div class="form-row auth-row basic-auth-row">
151
+ <label for="node-config-input-username"><i class="fa fa-user"></i> Username</label>
152
+ <input type="text" id="node-config-input-username" placeholder="Username">
153
+ </div>
154
+ <div class="form-row auth-row basic-auth-row">
155
+ <label for="node-config-input-password"><i class="fa fa-key"></i> Password</label>
156
+ <input type="password" id="node-config-input-password" placeholder="Password">
157
+ </div>
158
+ <div class="form-row auth-row token-auth-row">
159
+ <label for="node-config-input-token"><i class="fa fa-ticket"></i> Token</label>
160
+ <input type="password" id="node-config-input-token" placeholder="Bearer token">
161
+ </div>
162
+ </div>
163
+ <div id="questdb-tab-advanced" style="display:none;">
164
+ <div class="form-row">
165
+ <label for="node-config-input-autoFlush"><i class="fa fa-refresh"></i> Auto Flush</label>
166
+ <input type="checkbox" id="node-config-input-autoFlush" style="display:inline-block; width:auto; vertical-align:baseline;" checked>
167
+ <span style="margin-left:8px;">Enable automatic flushing</span>
168
+ </div>
169
+ <div class="form-row">
170
+ <label for="node-config-input-autoFlushRows"><i class="fa fa-bars"></i> Flush Rows</label>
171
+ <input type="text" id="node-config-input-autoFlushRows" placeholder="75000">
172
+ <span style="margin-left:8px; font-size:0.9em; color:#888;">Rows before auto-flush</span>
173
+ </div>
174
+ <div class="form-row">
175
+ <label for="node-config-input-autoFlushInterval"><i class="fa fa-clock-o"></i> Flush Interval</label>
176
+ <input type="text" id="node-config-input-autoFlushInterval" placeholder="1000">
177
+ <span style="margin-left:8px; font-size:0.9em; color:#888;">ms</span>
178
+ </div>
179
+ <div class="form-row">
180
+ <label for="node-config-input-requestTimeout"><i class="fa fa-hourglass"></i> Request Timeout</label>
181
+ <input type="text" id="node-config-input-requestTimeout" placeholder="10000">
182
+ <span style="margin-left:8px; font-size:0.9em; color:#888;">ms</span>
183
+ </div>
184
+ <div class="form-row">
185
+ <label for="node-config-input-retryTimeout"><i class="fa fa-repeat"></i> Retry Timeout</label>
186
+ <input type="text" id="node-config-input-retryTimeout" placeholder="10000">
187
+ <span style="margin-left:8px; font-size:0.9em; color:#888;">ms</span>
188
+ </div>
189
+ <div class="form-row">
190
+ <label for="node-config-input-initBufSize"><i class="fa fa-database"></i> Init Buffer</label>
191
+ <input type="text" id="node-config-input-initBufSize" placeholder="65536">
192
+ <span style="margin-left:8px; font-size:0.9em; color:#888;">bytes (64 KiB)</span>
193
+ </div>
194
+ <div class="form-row">
195
+ <label for="node-config-input-maxBufSize"><i class="fa fa-database"></i> Max Buffer</label>
196
+ <input type="text" id="node-config-input-maxBufSize" placeholder="104857600">
197
+ <span style="margin-left:8px; font-size:0.9em; color:#888;">bytes (100 MiB)</span>
198
+ </div>
199
+ </div>
200
+ </div>
201
+ </script>
202
+
203
+ <script type="text/javascript">
204
+ // Main QuestDB node
205
+ RED.nodes.registerType('questdb',{
206
+ category: 'questdb',
207
+ color: '#a23154',
208
+ defaults: {
209
+ name: {value:""},
210
+ questdb: {value:"", type:"questdb-config", required:true},
211
+ autoFlush: {value:true},
212
+ flushInterval: {value:1000, validate:RED.validators.number()}
213
+ },
214
+ inputs:1,
215
+ outputs:1,
216
+ icon: "font-awesome/fa-database",
217
+ label: function() {
218
+ return this.name || "Write";
219
+ },
220
+ paletteLabel: "Write",
221
+ labelStyle: function() {
222
+ return (this.name ? "node_label_italic" : "") + " questdb-white-text";
223
+ }
224
+ });
225
+ </script>
226
+
227
+ <style>
228
+ /* White text on flow canvas */
229
+ .questdb-white-text {
230
+ fill: #ffffff !important;
231
+ }
232
+ /* White text in palette */
233
+ .red-ui-palette-node[data-palette-type="questdb"] .red-ui-palette-label {
234
+ color: #ffffff !important;
235
+ }
236
+ </style>
237
+
238
+ <script type="text/html" data-template-name="questdb">
239
+ <div class="form-row">
240
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
241
+ <input type="text" id="node-input-name" placeholder="Name">
242
+ </div>
243
+ <div class="form-row">
244
+ <label for="node-input-questdb"><i class="fa fa-server"></i> Server</label>
245
+ <input type="text" id="node-input-questdb" placeholder="QuestDB Server">
246
+ </div>
247
+ <div class="form-row">
248
+ <label for="node-input-autoFlush"><i class="fa fa-refresh"></i> Auto Flush</label>
249
+ <input type="checkbox" id="node-input-autoFlush" style="display:inline-block; width:auto; vertical-align:baseline;">
250
+ </div>
251
+ <div class="form-row">
252
+ <label for="node-input-flushInterval"><i class="fa fa-clock-o"></i> Flush Interval (ms)</label>
253
+ <input type="text" id="node-input-flushInterval" placeholder="1000">
254
+ </div>
255
+ </script>
256
+
257
+ <script type="text/html" data-help-name="questdb">
258
+ <p>Writes data to QuestDB using ILP protocol.</p>
259
+
260
+ <h3>Inputs</h3>
261
+ <dl class="message-properties">
262
+ <dt>topic <span class="property-type">string</span></dt>
263
+ <dd>Table name to write to (required)</dd>
264
+ <dt>payload <span class="property-type">object</span></dt>
265
+ <dd>Data to write:
266
+ <ul>
267
+ <li><code>symbols</code> - Tag columns (indexed)</li>
268
+ <li><code>columns</code> - Value columns</li>
269
+ <li><code>timestamp</code> - Optional (Date, number, or string)</li>
270
+ </ul>
271
+ </dd>
272
+ </dl>
273
+
274
+ <h3>Outputs</h3>
275
+ <dl class="message-properties">
276
+ <dt>payload <span class="property-type">object</span></dt>
277
+ <dd><code>{ success: true/false, table: "name" }</code></dd>
278
+ </dl>
279
+
280
+ <h3>Example</h3>
281
+ <pre>{
282
+ topic: "history_float",
283
+ payload: {
284
+ symbols: { tag_name: "temp1" },
285
+ columns: { value: 23.5 },
286
+ timestamp: new Date()
287
+ }
288
+ }</pre>
289
+ </script>
package/nodes/write.js ADDED
@@ -0,0 +1,403 @@
1
+ module.exports = function(RED) {
2
+ const { Sender } = require('@questdb/nodejs-client');
3
+
4
+ // Shared connection pool for config nodes
5
+ const connectionPool = new Map();
6
+
7
+ // Build connection string from config
8
+ function buildConnectionString(configNode) {
9
+ const protocol = configNode.protocol || 'http';
10
+ let connStr = `${protocol}::addr=${configNode.host}:${configNode.port};`;
11
+
12
+ // Authentication
13
+ if (configNode.useAuth) {
14
+ if (configNode.authType === 'token' && configNode.token) {
15
+ connStr += `token=${configNode.token};`;
16
+ } else if (configNode.username && configNode.password) {
17
+ connStr += `username=${configNode.username};password=${configNode.password};`;
18
+ }
19
+ }
20
+
21
+ // TLS options (for https/tcps)
22
+ if (protocol === 'https' || protocol === 'tcps') {
23
+ if (!configNode.tlsVerify) {
24
+ connStr += `tls_verify=unsafe_off;`;
25
+ }
26
+ if (configNode.tlsCa) {
27
+ connStr += `tls_ca=${configNode.tlsCa};`;
28
+ }
29
+ }
30
+
31
+ // Auto-flush options
32
+ if (configNode.autoFlush === false) {
33
+ connStr += `auto_flush=off;`;
34
+ } else {
35
+ if (configNode.autoFlushRows) {
36
+ connStr += `auto_flush_rows=${configNode.autoFlushRows};`;
37
+ }
38
+ if (configNode.autoFlushInterval) {
39
+ connStr += `auto_flush_interval=${configNode.autoFlushInterval};`;
40
+ }
41
+ }
42
+
43
+ // Buffer options
44
+ if (configNode.initBufSize) {
45
+ connStr += `init_buf_size=${configNode.initBufSize};`;
46
+ }
47
+ if (configNode.maxBufSize) {
48
+ connStr += `max_buf_size=${configNode.maxBufSize};`;
49
+ }
50
+
51
+ // HTTP options
52
+ if (protocol === 'http' || protocol === 'https') {
53
+ if (configNode.requestTimeout) {
54
+ connStr += `request_timeout=${configNode.requestTimeout};`;
55
+ }
56
+ if (configNode.retryTimeout) {
57
+ connStr += `retry_timeout=${configNode.retryTimeout};`;
58
+ }
59
+ }
60
+
61
+ return connStr;
62
+ }
63
+
64
+ // Configuration node for QuestDB connection
65
+ function QuestDBConfigNode(config) {
66
+ RED.nodes.createNode(this, config);
67
+ const configNode = this;
68
+
69
+ // Connection settings
70
+ configNode.protocol = config.protocol || 'http';
71
+ configNode.host = config.host;
72
+ configNode.port = parseInt(config.port) || (configNode.protocol.startsWith('tcp') ? 9009 : 9000);
73
+ configNode.name = config.name;
74
+
75
+ // TLS settings
76
+ configNode.tlsVerify = config.tlsVerify !== false;
77
+ configNode.tlsCa = config.tlsCa || '';
78
+
79
+ // Auth settings
80
+ configNode.useAuth = config.useAuth || false;
81
+ configNode.authType = config.authType || 'basic';
82
+ configNode.username = this.credentials ? this.credentials.username : '';
83
+ configNode.password = this.credentials ? this.credentials.password : '';
84
+ configNode.token = this.credentials ? this.credentials.token : '';
85
+
86
+ // Auto-flush settings
87
+ configNode.autoFlush = config.autoFlush !== false;
88
+ configNode.autoFlushRows = parseInt(config.autoFlushRows) || 75000;
89
+ configNode.autoFlushInterval = parseInt(config.autoFlushInterval) || 1000;
90
+
91
+ // Buffer settings
92
+ configNode.initBufSize = parseInt(config.initBufSize) || 65536;
93
+ configNode.maxBufSize = parseInt(config.maxBufSize) || 104857600;
94
+
95
+ // HTTP settings
96
+ configNode.requestTimeout = parseInt(config.requestTimeout) || 10000;
97
+ configNode.retryTimeout = parseInt(config.retryTimeout) || 10000;
98
+
99
+ const connectionKey = `${configNode.protocol}://${configNode.host}:${configNode.port}`;
100
+
101
+ // Initialize shared connection if not exists
102
+ if (!connectionPool.has(connectionKey)) {
103
+ const connectionState = {
104
+ sender: null,
105
+ connected: false,
106
+ connecting: false,
107
+ users: 0,
108
+ reconnectTimer: null
109
+ };
110
+
111
+ connectionState.connect = async function() {
112
+ if (connectionState.connecting || connectionState.connected) {
113
+ return;
114
+ }
115
+
116
+ connectionState.connecting = true;
117
+
118
+ try {
119
+ // Validate configuration
120
+ if (!configNode.host || isNaN(configNode.port)) {
121
+ throw new Error(`Invalid configuration: host=${configNode.host}, port=${configNode.port}`);
122
+ }
123
+
124
+ const connStr = buildConnectionString(configNode);
125
+ RED.log.info(`[QuestDB] Connecting with: ${connStr.replace(/password=[^;]+/, 'password=***').replace(/token=[^;]+/, 'token=***')}`);
126
+
127
+ connectionState.sender = Sender.fromConfig(connStr);
128
+ connectionState.connected = true;
129
+ connectionState.connecting = false;
130
+
131
+ RED.log.info(`[QuestDB] Connected to ${configNode.protocol}://${configNode.host}:${configNode.port}`);
132
+ } catch (err) {
133
+ connectionState.connected = false;
134
+ connectionState.connecting = false;
135
+ RED.log.error(`[QuestDB] Failed to connect to ${configNode.host}:${configNode.port}: ${err.message}`);
136
+
137
+ // Schedule reconnection
138
+ if (!connectionState.reconnectTimer) {
139
+ connectionState.reconnectTimer = setTimeout(() => {
140
+ connectionState.reconnectTimer = null;
141
+ if (connectionState.users > 0) {
142
+ connectionState.connect();
143
+ }
144
+ }, 5000); // Retry in 5 seconds
145
+ }
146
+ }
147
+ };
148
+
149
+ connectionState.disconnect = async function() {
150
+ if (connectionState.reconnectTimer) {
151
+ clearTimeout(connectionState.reconnectTimer);
152
+ connectionState.reconnectTimer = null;
153
+ }
154
+
155
+ if (connectionState.sender) {
156
+ try {
157
+ await connectionState.sender.flush();
158
+ await connectionState.sender.close();
159
+ RED.log.info(`[QuestDB] Disconnected from ${configNode.host}:${configNode.port}`);
160
+ } catch (err) {
161
+ RED.log.error(`[QuestDB] Error closing connection: ${err.message}`);
162
+ }
163
+ connectionState.sender = null;
164
+ }
165
+ connectionState.connected = false;
166
+ };
167
+
168
+ connectionPool.set(connectionKey, connectionState);
169
+ }
170
+
171
+ configNode.getConnection = function() {
172
+ return connectionPool.get(connectionKey);
173
+ };
174
+
175
+ configNode.on('close', async function(done) {
176
+ const conn = connectionPool.get(connectionKey);
177
+ if (conn) {
178
+ conn.users--;
179
+ if (conn.users <= 0) {
180
+ await conn.disconnect();
181
+ connectionPool.delete(connectionKey);
182
+ }
183
+ }
184
+ done();
185
+ });
186
+ }
187
+ RED.nodes.registerType("questdb-config", QuestDBConfigNode, {
188
+ credentials: {
189
+ username: {type: "text"},
190
+ password: {type: "password"},
191
+ token: {type: "password"}
192
+ }
193
+ });
194
+
195
+ // Main QuestDB node
196
+ function QuestDBNode(config) {
197
+ RED.nodes.createNode(this, config);
198
+ const node = this;
199
+
200
+ // Get configuration from config node
201
+ node.questdbConfig = RED.nodes.getNode(config.questdb);
202
+
203
+ if (!node.questdbConfig) {
204
+ node.error("QuestDB configuration not set");
205
+ node.status({fill:"red", shape:"ring", text:"no config"});
206
+ return;
207
+ }
208
+
209
+ // Configuration from the node properties
210
+ node.autoFlush = config.autoFlush !== false; // default true
211
+ node.flushInterval = config.flushInterval || 1000;
212
+
213
+ // Get shared connection
214
+ const connection = node.questdbConfig.getConnection();
215
+ if (!connection) {
216
+ node.error("Failed to get connection from config");
217
+ node.status({fill:"red", shape:"ring", text:"no connection"});
218
+ return;
219
+ }
220
+
221
+ connection.users++;
222
+
223
+ // Connect if not already connected
224
+ if (!connection.connected && !connection.connecting) {
225
+ connection.connect();
226
+ }
227
+
228
+ // Update status based on connection state
229
+ function updateStatus() {
230
+ if (connection.connected) {
231
+ node.status({fill:"green", shape:"dot", text:"connected"});
232
+ } else if (connection.connecting) {
233
+ node.status({fill:"yellow", shape:"ring", text:"connecting"});
234
+ } else {
235
+ node.status({fill:"red", shape:"ring", text:"disconnected"});
236
+ }
237
+ }
238
+
239
+ updateStatus();
240
+
241
+ // Periodic status check
242
+ const statusInterval = setInterval(updateStatus, 2000);
243
+
244
+ node.on('input', async function(msg) {
245
+ // Auto-reconnect if disconnected
246
+ if (!connection.connected && !connection.connecting) {
247
+ connection.connect();
248
+ node.error("Not connected to QuestDB, reconnecting...", msg);
249
+ updateStatus();
250
+ return;
251
+ }
252
+
253
+ if (!connection.connected || !connection.sender) {
254
+ node.error("Not connected to QuestDB", msg);
255
+ updateStatus();
256
+ return;
257
+ }
258
+
259
+ // Validate message
260
+ const tableName = msg.topic;
261
+ if (!tableName) {
262
+ node.error("Topic (table name) not specified", msg);
263
+ node.status({fill:"red", shape:"ring", text:"no topic"});
264
+ return;
265
+ }
266
+
267
+ const payload = msg.payload;
268
+ if (payload === undefined || payload === null) {
269
+ node.error("Payload is empty", msg);
270
+ node.status({fill:"red", shape:"ring", text:"no payload"});
271
+ return;
272
+ }
273
+
274
+ try {
275
+ connection.sender.table(tableName);
276
+
277
+ // Check if simple format (msg.topic + numeric payload)
278
+ if (typeof payload === 'number' || (typeof payload === 'string' && !isNaN(parseFloat(payload)))) {
279
+ // Simple format: use msg.topic as tag, payload as value
280
+ var tag = msg.topic || 'default';
281
+ var value = typeof payload === 'number' ? payload : parseFloat(payload);
282
+ connection.sender.symbol('tag_name', tag);
283
+ connection.sender.floatColumn('value', value);
284
+
285
+ // Handle timestamp
286
+ if (msg.timestamp) {
287
+ var ts = typeof msg.timestamp === 'number' ? msg.timestamp : Date.now();
288
+ connection.sender.at(BigInt(ts) * 1000n);
289
+ } else {
290
+ connection.sender.atNow();
291
+ }
292
+ } else {
293
+ // QuestDB format: payload.symbols + payload.columns
294
+ if (payload.symbols && typeof payload.symbols === 'object') {
295
+ for (const [key, value] of Object.entries(payload.symbols)) {
296
+ if (value === null || value === undefined) continue;
297
+ connection.sender.symbol(String(key), String(value));
298
+ }
299
+ }
300
+
301
+ if (payload.columns && typeof payload.columns === 'object') {
302
+ for (const [key, value] of Object.entries(payload.columns)) {
303
+ if (value === null || value === undefined) continue;
304
+ if (typeof value === 'number') {
305
+ if (!isFinite(value)) {
306
+ node.warn(`Skipping non-finite number for column '${key}'`);
307
+ continue;
308
+ }
309
+ connection.sender.floatColumn(key, value);
310
+ } else if (typeof value === 'boolean') {
311
+ connection.sender.booleanColumn(key, value);
312
+ } else if (typeof value === 'string') {
313
+ // Check if it's an ISO date string
314
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) {
315
+ const dateValue = new Date(value);
316
+ if (!isNaN(dateValue.getTime())) {
317
+ // Convert to microseconds for QuestDB
318
+ const microSeconds = BigInt(dateValue.getTime()) * 1000n;
319
+ connection.sender.timestampColumn(key, microSeconds);
320
+ } else {
321
+ connection.sender.stringColumn(key, value);
322
+ }
323
+ } else {
324
+ connection.sender.stringColumn(key, value);
325
+ }
326
+ } else if (value instanceof Date) {
327
+ // Convert to microseconds for QuestDB
328
+ const microSeconds = BigInt(value.getTime()) * 1000n;
329
+ connection.sender.timestampColumn(key, microSeconds);
330
+ } else {
331
+ node.warn(`Skipping unsupported type '${typeof value}' for column '${key}'`);
332
+ }
333
+ }
334
+ } else if (!payload.symbols) {
335
+ node.error("Payload must have 'symbols' and/or 'columns' properties, or be a number", msg);
336
+ node.status({fill:"red", shape:"ring", text:"bad format"});
337
+ return;
338
+ }
339
+
340
+ if (payload.timestamp) {
341
+ let timestampMicros;
342
+ if (payload.timestamp instanceof Date) {
343
+ timestampMicros = BigInt(payload.timestamp.getTime()) * 1000n;
344
+ } else if (typeof payload.timestamp === 'number') {
345
+ timestampMicros = BigInt(payload.timestamp) * 1000n;
346
+ } else if (typeof payload.timestamp === 'bigint') {
347
+ timestampMicros = payload.timestamp;
348
+ } else if (typeof payload.timestamp === 'string') {
349
+ const parsed = Date.parse(payload.timestamp);
350
+ if (isNaN(parsed)) {
351
+ node.error(`Invalid timestamp string: ${payload.timestamp}`);
352
+ connection.sender.atNow(); // Complete the row to avoid buffer corruption
353
+ return;
354
+ }
355
+ timestampMicros = BigInt(parsed) * 1000n;
356
+ } else {
357
+ node.error(`Invalid timestamp type: ${typeof payload.timestamp}`);
358
+ connection.sender.atNow(); // Complete the row to avoid buffer corruption
359
+ return;
360
+ }
361
+ connection.sender.at(timestampMicros);
362
+ } else {
363
+ connection.sender.atNow();
364
+ }
365
+ }
366
+
367
+ if (node.autoFlush) {
368
+ await connection.sender.flush();
369
+ }
370
+
371
+ node.status({fill:"green", shape:"dot", text:`sent: ${tableName}`});
372
+ msg.payload = { success: true, table: tableName };
373
+ node.send(msg);
374
+
375
+ } catch (err) {
376
+ const errMsg = err.message || String(err);
377
+
378
+ node.warn(`QuestDB write failed: ${errMsg}`);
379
+ node.status({fill:"yellow", shape:"ring", text:"write failed"});
380
+
381
+ // Recreate sender to clear bad state
382
+ try {
383
+ const connStr = buildConnectionString(node.questdbConfig);
384
+ connection.sender = Sender.fromConfig(connStr);
385
+ } catch (e) {
386
+ connection.connected = false;
387
+ connection.connect();
388
+ }
389
+
390
+ msg.payload = { success: false, error: errMsg };
391
+ node.send(msg);
392
+ }
393
+ });
394
+
395
+ node.on('close', function(done) {
396
+ clearInterval(statusInterval);
397
+ connection.users--;
398
+ done();
399
+ });
400
+ }
401
+
402
+ RED.nodes.registerType("questdb", QuestDBNode);
403
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "node-red-contrib-questdb",
3
+ "version": "0.1.0",
4
+ "description": "Node-RED nodes for QuestDB time-series database",
5
+ "keywords": [
6
+ "node-red",
7
+ "questdb",
8
+ "time-series",
9
+ "database"
10
+ ],
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": ""
15
+ },
16
+ "node-red": {
17
+ "version": ">=2.0.0",
18
+ "nodes": {
19
+ "questdb-write": "nodes/write.js",
20
+ "questdb-mapper": "nodes/mapper.js"
21
+ }
22
+ },
23
+ "engines": {
24
+ "node": ">=14.0.0"
25
+ },
26
+ "scripts": {
27
+ "prepublishOnly": "npm test --if-present",
28
+ "publish:dry": "npm publish --dry-run"
29
+ },
30
+ "dependencies": {
31
+ "@questdb/nodejs-client": "^3.0.0"
32
+ }
33
+ }