node-red-contrib-influxdb3 1.0.8 → 1.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 CHANGED
@@ -64,6 +64,18 @@ Writes data points to InfluxDB v3.
64
64
  - **Name**: Optional node name
65
65
  - **Measurement**: Default measurement name (can be overridden by `msg.measurement`)
66
66
  - **Database**: Optional database override (uses connection default if not set)
67
+ - **Partial writes**: Accept the valid lines of a batch even if other lines are rejected (InfluxDB 3 Core/Enterprise only)
68
+ - **No sync**: Respond without waiting for WAL persistence — faster writes without durability confirmation (InfluxDB 3 Core/Enterprise only)
69
+
70
+ #### Partial Writes
71
+
72
+ By default a batch write is all-or-nothing: one invalid line causes InfluxDB to reject the entire batch. With **Partial writes** enabled, InfluxDB writes the valid lines and reports the rejected ones. The node then:
73
+
74
+ - shows a yellow `partial write` status instead of an error
75
+ - logs a warning listing each rejected line and the reason
76
+ - forwards the message with the details attached as `msg.partialWriteErrors`, an array of `{ lineNumber, errorMessage, originalLine }` objects
77
+
78
+ Enabling **Partial writes** or **No sync** routes the write through the InfluxDB v3 API endpoint, which is only available on InfluxDB 3 Core and Enterprise. On other deployments (Cloud Serverless/Dedicated, Clustered) leave both options disabled — writes there use the v2-compatible endpoint, where these options are not supported and would cause writes to fail. When **No sync** is enabled without **Partial writes**, the node keeps the all-or-nothing write semantics.
67
79
 
68
80
  ## Usage
69
81
 
@@ -80,6 +92,8 @@ msg.payload = "temperature,location=room1 value=21.5";
80
92
  return msg;
81
93
  ```
82
94
 
95
+ > **Note:** raw line protocol strings are passed through unmodified (each line is only sanity-checked). When data comes from an untrusted source, prefer the object formats below — the node escapes measurement, tag and field values automatically.
96
+
83
97
  #### 2. Object with Fields and Tags
84
98
 
85
99
  Send an object with explicit `fields` and `tags` properties:
@@ -102,7 +116,7 @@ return msg;
102
116
 
103
117
  #### 3. Simplified Object Format
104
118
 
105
- Send an object where all properties (except 'tags' and 'timestamp') are treated as fields:
119
+ Send an object where all properties are treated as fields, except the reserved keys `measurement`, `fields`, `tags`, `timestamp` and `integers`:
106
120
 
107
121
  ```javascript
108
122
  msg.measurement = "environment";
@@ -416,7 +430,7 @@ The node will display error status and log details to the Node-RED debug panel:
416
430
 
417
431
  ## Requirements
418
432
 
419
- - Node.js v18.0.0 or higher
433
+ - Node.js v24.0.0 or higher
420
434
  - Node-RED v3.0.0 or higher
421
435
  - InfluxDB v3 instance (Cloud or Edge)
422
436
 
package/influxdb3.html CHANGED
@@ -6,7 +6,14 @@
6
6
  category: 'config',
7
7
  defaults: {
8
8
  name: { value: '' },
9
- host: { value: '', required: true },
9
+ host: {
10
+ value: '',
11
+ required: true,
12
+ validate: function(v) {
13
+ // Accept http(s) URLs or Node-RED ${ENV_VAR} substitution
14
+ return /^https?:\/\/\S+$/.test(v) || /\$\{[^}]+\}/.test(v);
15
+ }
16
+ },
10
17
  database: { value: '', required: true },
11
18
  tlsRejectUnauthorized: { value: true },
12
19
  caCertPath: { value: '' }
@@ -79,7 +86,9 @@
79
86
  influxdb: { type: 'influxdb3-config', required: true },
80
87
  name: { value: '' },
81
88
  measurement: { value: '' },
82
- database: { value: '' }
89
+ database: { value: '' },
90
+ allowPartialWrites: { value: false },
91
+ noSync: { value: false }
83
92
  },
84
93
  inputs: 1,
85
94
  outputs: 1,
@@ -110,6 +119,16 @@
110
119
  <label for="node-input-database"><i class="fa fa-database"></i> Database</label>
111
120
  <input type="text" id="node-input-database" placeholder="Override default database (optional)">
112
121
  </div>
122
+ <div class="form-row">
123
+ <label for="node-input-allowPartialWrites"><i class="fa fa-tasks"></i> Partial writes</label>
124
+ <input type="checkbox" id="node-input-allowPartialWrites" style="width:auto;">
125
+ <span>Allow partial writes (InfluxDB 3 Core/Enterprise only)</span>
126
+ </div>
127
+ <div class="form-row">
128
+ <label for="node-input-noSync"><i class="fa fa-bolt"></i> No sync</label>
129
+ <input type="checkbox" id="node-input-noSync" style="width:auto;">
130
+ <span>Don't wait for WAL persistence (InfluxDB 3 Core/Enterprise only)</span>
131
+ </div>
113
132
  </script>
114
133
 
115
134
  <script type="text/html" data-help-name="influxdb3-write">
@@ -130,13 +149,17 @@
130
149
  <dt class="optional">database <span class="property-type">string</span></dt>
131
150
  <dd>Override the database configured in the node or connection</dd>
132
151
  <dt class="optional">timestamp <span class="property-type">Date | number</span></dt>
133
- <dd>Timestamp for the data point (if not in payload)</dd>
152
+ <dd>Timestamp for the data point (if not in payload). Numbers are interpreted as <b>milliseconds</b> since the epoch</dd>
134
153
  </dl>
135
154
 
136
155
  <h3>Outputs</h3>
137
156
  <dl class="message-properties">
138
157
  <dt>payload <span class="property-type">object</span></dt>
139
158
  <dd>The original payload is passed through</dd>
159
+ <dt class="optional">partialWriteErrors <span class="property-type">array</span></dt>
160
+ <dd>Only set when a partial write occurred (see <b>Partial writes</b> below).
161
+ An array of <code>{ lineNumber, errorMessage, originalLine }</code> objects
162
+ describing the lines InfluxDB rejected</dd>
140
163
  </dl>
141
164
 
142
165
  <h3>Details</h3>
@@ -145,6 +168,9 @@
145
168
  <h4>Line Protocol Format</h4>
146
169
  <p>Send a string in line protocol format:</p>
147
170
  <pre>msg.payload = "temperature,location=room1 value=21.5";</pre>
171
+ <p><b>Note:</b> raw line protocol strings are passed through unmodified. When data comes from
172
+ an untrusted source, prefer the object format below — the node escapes measurement, tag and
173
+ field values automatically.</p>
148
174
 
149
175
  <h4>Object Format</h4>
150
176
  <p>Send an object with fields and optionally tags:</p>
@@ -161,7 +187,8 @@
161
187
  }</pre>
162
188
 
163
189
  <h4>Simplified Object Format</h4>
164
- <p>Send an object where all properties (except 'tags' and 'timestamp') are treated as fields:</p>
190
+ <p>Send an object where all properties are treated as fields, except the reserved keys
191
+ <code>measurement</code>, <code>fields</code>, <code>tags</code>, <code>timestamp</code> and <code>integers</code>:</p>
165
192
  <pre>{
166
193
  "temperature": 21.5,
167
194
  "humidity": 65,
@@ -202,8 +229,26 @@
202
229
  <dd>The measurement name to use. Can be overridden by <code>msg.measurement</code></dd>
203
230
  <dt>Database</dt>
204
231
  <dd>Optional database override. If not set, uses the database from the connection config</dd>
232
+ <dt>Partial writes</dt>
233
+ <dd>When enabled, InfluxDB accepts the valid lines of a batch even if other lines
234
+ are rejected (InfluxDB 3 Core/Enterprise only)</dd>
235
+ <dt>No sync</dt>
236
+ <dd>When enabled, InfluxDB responds without waiting for WAL persistence — faster
237
+ writes but no durability confirmation (InfluxDB 3 Core/Enterprise only)</dd>
205
238
  </dl>
206
239
 
240
+ <h3>Partial writes</h3>
241
+ <p>By default a batch write is all-or-nothing: one invalid line causes InfluxDB to
242
+ reject the entire batch. With <b>Partial writes</b> enabled, the valid lines are
243
+ written and the rejected lines are reported: the node shows a yellow
244
+ <i>partial write</i> status, logs a warning with the per-line errors, and forwards
245
+ the message with the details in <code>msg.partialWriteErrors</code>.</p>
246
+ <p>Enabling <b>Partial writes</b> or <b>No sync</b> sends the write through the
247
+ InfluxDB v3 API endpoint, which is only available on InfluxDB 3 Core and
248
+ Enterprise. On other deployments (Cloud Serverless/Dedicated, Clustered) leave
249
+ both options disabled — writes there use the v2-compatible endpoint, where these
250
+ options are not supported.</p>
251
+
207
252
  <h3>Examples</h3>
208
253
  <h4>Example 1: Simple temperature reading</h4>
209
254
  <pre>msg.measurement = "temperature";
package/influxdb3.js CHANGED
@@ -12,10 +12,15 @@ module.exports = function(RED) {
12
12
  // Point.setTag(name, value)
13
13
  // Point.setTimestamp(date)
14
14
  // Point.toLineProtocol()
15
- const { InfluxDBClient, Point } = require('@influxdata/influxdb3-client');
15
+ const { InfluxDBClient, Point, PartialWriteError } = require('@influxdata/influxdb3-client');
16
16
  const fs = require('fs');
17
17
  const { validateLineProtocol } = require('./lib/line-protocol');
18
18
 
19
+ // Heuristic bounds for plausible millisecond timestamps. Values outside this
20
+ // range usually mean the source supplied seconds or nanoseconds instead.
21
+ const MS_TIMESTAMP_PLAUSIBLE_MIN = Date.UTC(2000, 0, 1);
22
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
23
+
19
24
  /**
20
25
  * Normalize host URL to ensure it has trailing slash
21
26
  * @param {string} host
@@ -150,6 +155,10 @@ module.exports = function(RED) {
150
155
  this.influxdb = RED.nodes.getNode(config.influxdb);
151
156
  this.measurement = config.measurement;
152
157
  this.database = config.database;
158
+ /** @type {boolean} */
159
+ this.allowPartialWrites = config.allowPartialWrites === true;
160
+ /** @type {boolean} */
161
+ this.noSync = config.noSync === true;
153
162
 
154
163
  const node = this;
155
164
  let statusTimeout = null;
@@ -220,7 +229,14 @@ module.exports = function(RED) {
220
229
  if (typeof value === 'string') {
221
230
  // Check for integer suffix e.g. "42i"
222
231
  if (/^-?\d+i$/.test(value)) {
223
- point.setIntegerField(key, parseInt(value.slice(0, -1), 10));
232
+ const parsed = parseInt(value.slice(0, -1), 10);
233
+ if (!Number.isSafeInteger(parsed)) {
234
+ node.warn(
235
+ `Field '${key}': integer value '${value}' exceeds JavaScript's safe integer ` +
236
+ `range and loses precision (stored as ${parsed})${context}.`
237
+ );
238
+ }
239
+ point.setIntegerField(key, parsed);
224
240
  return true;
225
241
  }
226
242
  point.setStringField(key, value);
@@ -337,8 +353,19 @@ module.exports = function(RED) {
337
353
  }
338
354
  }
339
355
  } else {
340
- // Simplified format: treat all non-reserved properties as fields
341
- const reservedKeys = new Set(['tags', 'timestamp', 'integers', 'fields']);
356
+ if (msg.payload.fields !== null && msg.payload.fields !== undefined) {
357
+ const typeName = Array.isArray(msg.payload.fields)
358
+ ? 'Array'
359
+ : typeof msg.payload.fields;
360
+ node.warn(
361
+ `'fields' is present but is not a plain object (${typeName}) (measurement: '${measurement}'). ` +
362
+ `It will be ignored and the other payload properties will be treated as fields.`
363
+ );
364
+ }
365
+ // Simplified format: treat all non-reserved properties as fields.
366
+ // 'measurement' is reserved too so that array items like
367
+ // { measurement: 'temp', value: 1 } don't write it as a string field.
368
+ const reservedKeys = new Set(['measurement', 'tags', 'timestamp', 'integers', 'fields']);
342
369
  for (const [key, value] of Object.entries(msg.payload)) {
343
370
  if (!reservedKeys.has(key)) {
344
371
  if (addFieldToPoint(point, key, value, integerFields, measurement)) {
@@ -363,7 +390,27 @@ module.exports = function(RED) {
363
390
  if (ts instanceof Date && !isNaN(ts.getTime())) {
364
391
  point.setTimestamp(ts);
365
392
  } else if (typeof ts === 'number' && isFinite(ts) && ts >= 0) {
366
- point.setTimestamp(new Date(ts));
393
+ const date = new Date(ts);
394
+ if (isNaN(date.getTime())) {
395
+ // Beyond the representable Date range (±8.64e15 ms) — almost
396
+ // always a nanosecond timestamp passed where ms are expected.
397
+ node.warn(
398
+ `Invalid timestamp: ${ts} is outside the representable date range. ` +
399
+ `Numeric timestamps are interpreted as milliseconds - if the source ` +
400
+ `supplies nanoseconds, convert to milliseconds first. The timestamp ` +
401
+ `was ignored; InfluxDB will assign the write time.`
402
+ );
403
+ } else {
404
+ // 0 is deliberately allowed without a warning (explicit epoch).
405
+ if (ts !== 0 && (ts < MS_TIMESTAMP_PLAUSIBLE_MIN || ts > Date.now() + ONE_DAY_MS)) {
406
+ node.warn(
407
+ `Numeric timestamp ${ts} resolves to ${date.toISOString()}. ` +
408
+ `Numeric timestamps are interpreted as milliseconds - if the source ` +
409
+ `supplies seconds or nanoseconds, convert to milliseconds first.`
410
+ );
411
+ }
412
+ point.setTimestamp(date);
413
+ }
367
414
  } else if (typeof ts === 'string' && ts.trim() !== '') {
368
415
  const parsed = new Date(ts);
369
416
  if (!isNaN(parsed.getTime())) {
@@ -385,14 +432,7 @@ module.exports = function(RED) {
385
432
  return { lineProtocol: lp };
386
433
  }
387
434
 
388
- // Process incoming messages
389
435
  node.on('input', async function(msg, send, done) {
390
- // For Node-RED 0.x compatibility
391
- send = send || function(m) { node.send(m); };
392
- done = done || function(err) {
393
- if (err) { node.error(err, msg); }
394
- };
395
-
396
436
  try {
397
437
  const client = node.influxdb.getClient();
398
438
 
@@ -480,8 +520,29 @@ module.exports = function(RED) {
480
520
  );
481
521
  }
482
522
 
523
+ // Both acceptPartial and noSync exist only on the V3 API endpoint,
524
+ // so opting into either selects it. With neither enabled, no write
525
+ // options are passed and the client default (V2 endpoint) is used,
526
+ // preserving previous behaviour.
527
+ let writeOptions = null;
528
+ if (node.allowPartialWrites || node.noSync) {
529
+ writeOptions = { useV2Api: false };
530
+ if (node.noSync) {
531
+ writeOptions.noSync = true;
532
+ }
533
+ if (!node.allowPartialWrites) {
534
+ // noSync without partial writes: keep the all-or-nothing
535
+ // semantics the V2 endpoint would have provided.
536
+ writeOptions.acceptPartial = false;
537
+ }
538
+ }
539
+
483
540
  // Write to InfluxDB
484
- await client.write(lineProtocol, targetDatabase);
541
+ if (writeOptions) {
542
+ await client.write(lineProtocol, targetDatabase, undefined, writeOptions);
543
+ } else {
544
+ await client.write(lineProtocol, targetDatabase);
545
+ }
485
546
 
486
547
  setStatus({ fill: 'green', shape: 'dot', text: 'written' }, 3000);
487
548
 
@@ -489,6 +550,33 @@ module.exports = function(RED) {
489
550
  done();
490
551
 
491
552
  } catch (error) {
553
+ // The client raises PartialWriteError both when the server accepted
554
+ // the valid lines (partial write occurred) and when it rejected the
555
+ // whole batch (acceptPartial=false). Only the former is a partial
556
+ // success; the server signals it with this specific error text.
557
+ if (node.allowPartialWrites &&
558
+ error instanceof PartialWriteError &&
559
+ typeof error.message === 'string' &&
560
+ error.message.toLowerCase().includes('partial write')) {
561
+ const lineErrors = error.lineErrors || [];
562
+ const detail = lineErrors
563
+ .map((le) => `line ${le.lineNumber}: ${le.errorMessage}`)
564
+ .join('; ');
565
+ node.warn(
566
+ `Partial write: InfluxDB rejected ${lineErrors.length} line(s), ` +
567
+ `the remaining lines were written. ${detail}`
568
+ );
569
+ msg.partialWriteErrors = lineErrors;
570
+ setStatus({
571
+ fill: 'yellow',
572
+ shape: 'dot',
573
+ text: `partial write: ${lineErrors.length} line(s) rejected`
574
+ });
575
+ send(msg);
576
+ done();
577
+ return;
578
+ }
579
+
492
580
  const shortMsg = error.message
493
581
  ? (error.message.length > 80 ? error.message.substring(0, 80) + '...' : error.message)
494
582
  : 'unknown error';
@@ -6,33 +6,52 @@
6
6
 
7
7
  'use strict';
8
8
 
9
+ /**
10
+ * Truncate a string for use in error messages.
11
+ * @param {string} str
12
+ * @returns {string}
13
+ */
14
+ function preview(str) {
15
+ return str.length > 100 ? str.substring(0, 100) + '...' : str;
16
+ }
17
+
9
18
  /**
10
19
  * Validate that a string looks like InfluxDB line protocol.
20
+ * Multi-line strings (multiple points separated by newlines) are validated
21
+ * line by line; blank lines are allowed and skipped.
11
22
  * Returns null if valid, or an error message string if invalid.
12
23
  * @param {string} lp - Trimmed line protocol string
13
24
  * @returns {string|null}
14
25
  */
15
26
  function validateLineProtocol(lp) {
16
- // Detect JSON-like strings (both valid JSON and JS object notation)
27
+ // Detect JSON-like strings (both valid JSON and JS object notation).
28
+ // Checked against the whole string first, because pretty-printed JSON
29
+ // spans multiple lines.
17
30
  if (/^\{[\s\S]*}$/.test(lp) || /^\[[\s\S]*]$/.test(lp)) {
18
- const preview = lp.length > 100 ? lp.substring(0, 100) + '...' : lp;
19
31
  return (
20
32
  'The payload appears to be a JSON/object string, not line protocol. ' +
21
33
  'If you are sending JSON, ensure msg.payload is a parsed object (not a string). ' +
22
34
  'Use a JSON parse node before this node to convert the string to an object. ' +
23
- `Received string: ${preview}`
35
+ `Received string: ${preview(lp)}`
24
36
  );
25
37
  }
26
38
 
27
- // Line protocol must have at least: measurement field=value
39
+ // Each line must have at least: measurement field=value
28
40
  // i.e. at least one space and one '=' in the field set
29
- if (!lp.includes(' ') || !lp.includes('=')) {
30
- const preview = lp.length > 100 ? lp.substring(0, 100) + '...' : lp;
31
- return (
32
- 'The payload string does not appear to be valid line protocol. ' +
33
- 'Expected format: measurement[,tag=val] field=val[,field=val] [timestamp]. ' +
34
- `Received: ${preview}`
35
- );
41
+ const lines = lp.split('\n');
42
+ for (let i = 0; i < lines.length; i++) {
43
+ const line = lines[i].trim();
44
+ if (!line) {
45
+ continue;
46
+ }
47
+ if (!line.includes(' ') || !line.includes('=')) {
48
+ const where = lines.length > 1 ? `Line ${i + 1} of the payload` : 'The payload string';
49
+ return (
50
+ `${where} does not appear to be valid line protocol. ` +
51
+ 'Expected format: measurement[,tag=val] field=val[,field=val] [timestamp]. ' +
52
+ `Received: ${preview(line)}`
53
+ );
54
+ }
36
55
  }
37
56
 
38
57
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-influxdb3",
3
- "version": "1.0.8",
3
+ "version": "1.1.0",
4
4
  "description": "Node-RED nodes for InfluxDB v3 integration",
5
5
  "main": "influxdb3.js",
6
6
  "files": [
@@ -25,7 +25,7 @@
25
25
  "author": "Stuart B",
26
26
  "license": "MIT",
27
27
  "engines": {
28
- "node": ">=18.0.0"
28
+ "node": ">=24.0.0"
29
29
  },
30
30
  "repository": {
31
31
  "type": "git",
@@ -42,7 +42,7 @@
42
42
  }
43
43
  },
44
44
  "dependencies": {
45
- "@influxdata/influxdb3-client": "^2.2.0"
45
+ "@influxdata/influxdb3-client": "^2.3.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@eslint/js": "^10.0.1",