node-red-contrib-influxdb3 1.0.4 → 1.0.6

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
@@ -118,6 +118,55 @@ msg.payload = {
118
118
  return msg;
119
119
  ```
120
120
 
121
+ #### 4. Array of Multiple Measurements
122
+
123
+ Send an array to write multiple measurements in a single message:
124
+
125
+ ```javascript
126
+ // Array of objects
127
+ msg.payload = [
128
+ {
129
+ measurement: "temperature",
130
+ fields: { value: 21.5 },
131
+ tags: { location: "room1" }
132
+ },
133
+ {
134
+ measurement: "temperature",
135
+ fields: { value: 19.8 },
136
+ tags: { location: "room2" }
137
+ },
138
+ {
139
+ measurement: "humidity",
140
+ fields: { value: 65 },
141
+ tags: { location: "room1" }
142
+ }
143
+ ];
144
+ return msg;
145
+ ```
146
+
147
+ ```javascript
148
+ // Array of line protocol strings
149
+ msg.payload = [
150
+ "temperature,location=room1 value=21.5",
151
+ "temperature,location=room2 value=19.8",
152
+ "humidity,location=room1 value=65"
153
+ ];
154
+ return msg;
155
+ ```
156
+
157
+ ```javascript
158
+ // Mixed array (objects and strings)
159
+ msg.payload = [
160
+ "temperature,location=room1 value=21.5",
161
+ {
162
+ measurement: "humidity",
163
+ fields: { value: 65 },
164
+ tags: { location: "room1" }
165
+ }
166
+ ];
167
+ return msg;
168
+ ```
169
+
121
170
  ### Data Types
122
171
 
123
172
  **Important:** By default, **all numbers are written as floats** to avoid schema conflicts in InfluxDB. This is because JavaScript doesn't distinguish between `1.0` and `1` (both equal `1`), which can cause issues when InfluxDB expects a float but receives an integer.
@@ -737,7 +737,20 @@ describe('buildLineProtocol – error cases', () => {
737
737
  expect(done.mock.calls[0][0].message).toContain('Line protocol string is empty');
738
738
  });
739
739
 
740
- test('invalid payload type (array) produces error', async () => {
740
+ test('empty array produces error', async () => {
741
+ const { writeNode } = createWriteNode();
742
+ const msg = {
743
+ payload: []
744
+ };
745
+ const send = jest.fn();
746
+ const done = jest.fn();
747
+ await writeNode._handlers.input(msg, send, done);
748
+
749
+ expect(done).toHaveBeenCalledWith(expect.any(Error));
750
+ expect(done.mock.calls[0][0].message).toContain('Payload array is empty');
751
+ });
752
+
753
+ test('array with invalid item type produces error', async () => {
741
754
  const { writeNode } = createWriteNode();
742
755
  const msg = {
743
756
  payload: [1, 2, 3]
@@ -747,7 +760,180 @@ describe('buildLineProtocol – error cases', () => {
747
760
  await writeNode._handlers.input(msg, send, done);
748
761
 
749
762
  expect(done).toHaveBeenCalledWith(expect.any(Error));
750
- expect(done.mock.calls[0][0].message).toContain('Invalid payload format');
763
+ expect(done.mock.calls[0][0].message).toContain('Array item 0 has invalid format');
764
+ });
765
+ });
766
+
767
+ describe('Array payload support', () => {
768
+ test('writes array of line protocol strings', async () => {
769
+ const { writeNode, influxModule } = createWriteNode();
770
+ const msg = {
771
+ payload: [
772
+ 'temperature,location=room1 value=21.5',
773
+ 'temperature,location=room2 value=19.8',
774
+ 'humidity,location=room1 value=65'
775
+ ]
776
+ };
777
+ const send = jest.fn();
778
+ const done = jest.fn();
779
+ await writeNode._handlers.input(msg, send, done);
780
+
781
+ const client = influxModule.__getLastClientInstance();
782
+ expect(client.write).toHaveBeenCalledWith(
783
+ 'temperature,location=room1 value=21.5\ntemperature,location=room2 value=19.8\nhumidity,location=room1 value=65',
784
+ 'metrics'
785
+ );
786
+ expect(send).toHaveBeenCalledWith(msg);
787
+ expect(done).toHaveBeenCalled();
788
+ });
789
+
790
+ test('writes array of object payloads', async () => {
791
+ const { writeNode, influxModule } = createWriteNode();
792
+ const msg = {
793
+ payload: [
794
+ {
795
+ measurement: 'temperature',
796
+ fields: { value: 21.5 },
797
+ tags: { location: 'room1' }
798
+ },
799
+ {
800
+ measurement: 'temperature',
801
+ fields: { value: 19.8 },
802
+ tags: { location: 'room2' }
803
+ },
804
+ {
805
+ measurement: 'humidity',
806
+ fields: { value: 65 },
807
+ tags: { location: 'room1' }
808
+ }
809
+ ]
810
+ };
811
+ const send = jest.fn();
812
+ const done = jest.fn();
813
+ await writeNode._handlers.input(msg, send, done);
814
+
815
+ const client = influxModule.__getLastClientInstance();
816
+ expect(client.write).toHaveBeenCalled();
817
+ expect(send).toHaveBeenCalledWith(msg);
818
+ expect(done).toHaveBeenCalled();
819
+ });
820
+
821
+ test('writes mixed array of objects and line protocol strings', async () => {
822
+ const { writeNode, influxModule } = createWriteNode();
823
+ const msg = {
824
+ payload: [
825
+ 'temperature,location=room1 value=21.5',
826
+ {
827
+ measurement: 'humidity',
828
+ fields: { value: 65 },
829
+ tags: { location: 'room1' }
830
+ },
831
+ 'pressure,location=room1 value=1013.25'
832
+ ]
833
+ };
834
+ const send = jest.fn();
835
+ const done = jest.fn();
836
+ await writeNode._handlers.input(msg, send, done);
837
+
838
+ const client = influxModule.__getLastClientInstance();
839
+ expect(client.write).toHaveBeenCalled();
840
+ expect(send).toHaveBeenCalledWith(msg);
841
+ expect(done).toHaveBeenCalled();
842
+ });
843
+
844
+ test('array objects can use msg.measurement as fallback', async () => {
845
+ const { writeNode, influxModule } = createWriteNode();
846
+ const msg = {
847
+ measurement: 'temperature',
848
+ payload: [
849
+ {
850
+ fields: { value: 21.5 },
851
+ tags: { location: 'room1' }
852
+ },
853
+ {
854
+ fields: { value: 19.8 },
855
+ tags: { location: 'room2' }
856
+ }
857
+ ]
858
+ };
859
+ const send = jest.fn();
860
+ const done = jest.fn();
861
+ await writeNode._handlers.input(msg, send, done);
862
+
863
+ const client = influxModule.__getLastClientInstance();
864
+ expect(client.write).toHaveBeenCalled();
865
+ expect(send).toHaveBeenCalledWith(msg);
866
+ expect(done).toHaveBeenCalled();
867
+ });
868
+
869
+ test('array item error includes item index', async () => {
870
+ const { RED, influxModule } = setup();
871
+ const ConfigCtor = RED._types['influxdb3-config'];
872
+ const WriteCtor = RED._types['influxdb3-write'];
873
+
874
+ const configNode = new ConfigCtor({
875
+ host: 'https://example.com',
876
+ database: 'metrics',
877
+ name: 'Test',
878
+ credentials: { token: 'token' }
879
+ });
880
+
881
+ // Create node without default measurement
882
+ const writeNode = new WriteCtor({
883
+ influxdb: configNode,
884
+ measurement: '', // No default measurement
885
+ database: ''
886
+ });
887
+
888
+ const msg = {
889
+ payload: [
890
+ 'temperature,location=room1 value=21.5',
891
+ {
892
+ // Missing measurement and no fallback
893
+ fields: { value: 19.8 }
894
+ }
895
+ ]
896
+ };
897
+ const send = jest.fn();
898
+ const done = jest.fn();
899
+ await writeNode._handlers.input(msg, send, done);
900
+
901
+ expect(done).toHaveBeenCalledWith(expect.any(Error));
902
+ expect(done.mock.calls[0][0].message).toContain('Array item 1');
903
+ });
904
+
905
+ test('array with empty string produces error', async () => {
906
+ const { writeNode } = createWriteNode();
907
+ const msg = {
908
+ payload: [
909
+ 'temperature,location=room1 value=21.5',
910
+ ' ',
911
+ 'humidity,location=room1 value=65'
912
+ ]
913
+ };
914
+ const send = jest.fn();
915
+ const done = jest.fn();
916
+ await writeNode._handlers.input(msg, send, done);
917
+
918
+ expect(done).toHaveBeenCalledWith(expect.any(Error));
919
+ expect(done.mock.calls[0][0].message).toContain('Array item 1 is an empty string');
920
+ });
921
+
922
+ test('array with invalid line protocol produces error with index', async () => {
923
+ const { writeNode } = createWriteNode();
924
+ const msg = {
925
+ payload: [
926
+ 'temperature,location=room1 value=21.5',
927
+ 'invalid line protocol no equals',
928
+ 'humidity,location=room1 value=65'
929
+ ]
930
+ };
931
+ const send = jest.fn();
932
+ const done = jest.fn();
933
+ await writeNode._handlers.input(msg, send, done);
934
+
935
+ expect(done).toHaveBeenCalledWith(expect.any(Error));
936
+ expect(done.mock.calls[0][0].message).toContain('Array item 1');
751
937
  });
752
938
  });
753
939
 
package/influxdb3.html CHANGED
@@ -117,11 +117,12 @@
117
117
 
118
118
  <h3>Inputs</h3>
119
119
  <dl class="message-properties">
120
- <dt>payload <span class="property-type">string | object</span></dt>
120
+ <dt>payload <span class="property-type">string | object | array</span></dt>
121
121
  <dd>The data to write. Can be either:
122
122
  <ul>
123
123
  <li>A string in line protocol format</li>
124
124
  <li>An object with <code>fields</code> (required) and optionally <code>tags</code> and <code>timestamp</code></li>
125
+ <li>An array of strings (line protocol) or objects (multiple measurements in one write)</li>
125
126
  </ul>
126
127
  </dd>
127
128
  <dt class="optional">measurement <span class="property-type">string</span></dt>
@@ -233,6 +234,34 @@ return msg;</pre>
233
234
  <pre>msg.payload = "weather,location=garden temperature=18.5,humidity=75";
234
235
  return msg;</pre>
235
236
 
237
+ <h4>Example 4: Array of multiple measurements</h4>
238
+ <pre>msg.payload = [
239
+ {
240
+ "measurement": "temperature",
241
+ "fields": { "value": 21.5 },
242
+ "tags": { "location": "room1" }
243
+ },
244
+ {
245
+ "measurement": "temperature",
246
+ "fields": { "value": 19.8 },
247
+ "tags": { "location": "room2" }
248
+ },
249
+ {
250
+ "measurement": "humidity",
251
+ "fields": { "value": 65 },
252
+ "tags": { "location": "room1" }
253
+ }
254
+ ];
255
+ return msg;</pre>
256
+
257
+ <h4>Example 5: Array of line protocol strings</h4>
258
+ <pre>msg.payload = [
259
+ "temperature,location=room1 value=21.5",
260
+ "temperature,location=room2 value=19.8",
261
+ "humidity,location=room1 value=65"
262
+ ];
263
+ return msg;</pre>
264
+
236
265
  <h3>References</h3>
237
266
  <ul>
238
267
  <li><a href="https://github.com/InfluxCommunity/influxdb3-js">InfluxDB v3 JavaScript Client</a></li>
package/influxdb3.js CHANGED
@@ -141,16 +141,54 @@ module.exports = function(RED) {
141
141
  * Safely serialize a value for diagnostic logging.
142
142
  * Handles circular references and large objects.
143
143
  * @param {*} value
144
+ * @param {number} [maxLength=200] - Maximum length before truncation
144
145
  * @returns {string}
145
146
  */
146
- function safeStringify(value) {
147
+ function safeStringify(value, maxLength) {
148
+ maxLength = maxLength || 200;
147
149
  try {
148
- return JSON.stringify(value);
150
+ const str = JSON.stringify(value);
151
+ if (str && str.length > maxLength) {
152
+ return str.substring(0, maxLength) + '...(truncated)';
153
+ }
154
+ return str;
149
155
  } catch (e) {
150
156
  return `[unserializable: ${e.message}]`;
151
157
  }
152
158
  }
153
159
 
160
+ /**
161
+ * Validate that a string looks like InfluxDB line protocol.
162
+ * Returns null if valid, or an error message string if invalid.
163
+ * @param {string} lp - Trimmed line protocol string
164
+ * @returns {string|null}
165
+ */
166
+ function validateLineProtocol(lp) {
167
+ // Detect JSON-like strings (both valid JSON and JS object notation)
168
+ if (/^\{[\s\S]*}$/.test(lp) || /^\[[\s\S]*]$/.test(lp)) {
169
+ const preview = lp.length > 100 ? lp.substring(0, 100) + '...' : lp;
170
+ return (
171
+ 'The payload appears to be a JSON/object string, not line protocol. ' +
172
+ 'If you are sending JSON, ensure msg.payload is a parsed object (not a string). ' +
173
+ 'Use a JSON parse node before this node to convert the string to an object. ' +
174
+ `Received string: ${preview}`
175
+ );
176
+ }
177
+
178
+ // Line protocol must have at least: measurement field=value
179
+ // i.e. at least one space and one '=' in the field set
180
+ if (!lp.includes(' ') || !lp.includes('=')) {
181
+ const preview = lp.length > 100 ? lp.substring(0, 100) + '...' : lp;
182
+ return (
183
+ 'The payload string does not appear to be valid line protocol. ' +
184
+ 'Expected format: measurement[,tag=val] field=val[,field=val] [timestamp]. ' +
185
+ `Received: ${preview}`
186
+ );
187
+ }
188
+
189
+ return null;
190
+ }
191
+
154
192
  /**
155
193
  * Process a field value and add it to a Point.
156
194
  * Returns true if the field was added, false if it was skipped.
@@ -363,14 +401,71 @@ module.exports = function(RED) {
363
401
  if (!lineProtocol) {
364
402
  throw new Error('Line protocol string is empty');
365
403
  }
366
- } else if (msg.payload && typeof msg.payload === 'object' && !Array.isArray(msg.payload)) {
404
+
405
+ // Validate line protocol format
406
+ const validationError = validateLineProtocol(lineProtocol);
407
+ if (validationError) {
408
+ throw new Error(validationError);
409
+ }
410
+ } else if (Array.isArray(msg.payload)) {
411
+ // Handle array of measurements
412
+ if (msg.payload.length === 0) {
413
+ throw new Error('Payload array is empty');
414
+ }
415
+
416
+ const lineProtocols = [];
417
+ for (let i = 0; i < msg.payload.length; i++) {
418
+ const item = msg.payload[i];
419
+
420
+ if (typeof item === 'string') {
421
+ // String line protocol
422
+ const lp = item.trim();
423
+ if (!lp) {
424
+ throw new Error(`Array item ${i} is an empty string`);
425
+ }
426
+ const validationError = validateLineProtocol(lp);
427
+ if (validationError) {
428
+ throw new Error(`Array item ${i}: ${validationError}`);
429
+ }
430
+ lineProtocols.push(lp);
431
+ } else if (item && typeof item === 'object' && !Array.isArray(item)) {
432
+ // Object payload - build line protocol
433
+ const tempMsg = {
434
+ ...msg,
435
+ payload: item,
436
+ measurement: item.measurement || msg.measurement || node.measurement
437
+ };
438
+ const result = buildLineProtocol(tempMsg);
439
+ if (result.error) {
440
+ throw new Error(`Array item ${i}: ${result.error}`);
441
+ }
442
+ lineProtocols.push(result.lineProtocol);
443
+ } else {
444
+ throw new Error(
445
+ `Array item ${i} has invalid format. Expected string (line protocol) or object with fields. ` +
446
+ `Received: ${typeof item}`
447
+ );
448
+ }
449
+ }
450
+
451
+ lineProtocol = lineProtocols.join('\n');
452
+ } else if (msg.payload && typeof msg.payload === 'object') {
367
453
  const result = buildLineProtocol(msg);
368
454
  if (result.error) {
369
455
  throw new Error(result.error);
370
456
  }
371
457
  lineProtocol = result.lineProtocol;
372
458
  } else {
373
- throw new Error('Invalid payload format. Expected string (line protocol) or object with fields');
459
+ const actualType = typeof msg.payload;
460
+ const detail = msg.payload === null
461
+ ? 'null'
462
+ : msg.payload === undefined
463
+ ? 'undefined'
464
+ : `${actualType}${msg.payload && msg.payload.constructor ? ` [${msg.payload.constructor.name}]` : ''}: ${safeStringify(msg.payload)}`;
465
+ throw new Error(
466
+ `Invalid payload format. Expected string (line protocol), object with fields, or array of objects/strings. ` +
467
+ `Received: ${detail}`
468
+ );
374
469
  }
375
470
 
376
471
  // Write to InfluxDB
@@ -382,7 +477,10 @@ module.exports = function(RED) {
382
477
  done();
383
478
 
384
479
  } catch (error) {
385
- setStatus({ fill: 'red', shape: 'dot', text: 'error' });
480
+ const shortMsg = error.message
481
+ ? (error.message.length > 80 ? error.message.substring(0, 80) + '...' : error.message)
482
+ : 'unknown error';
483
+ setStatus({ fill: 'red', shape: 'dot', text: shortMsg });
386
484
  done(error);
387
485
  }
388
486
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-influxdb3",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Node-RED nodes for InfluxDB v3 integration",
5
5
  "main": "influxdb3.js",
6
6
  "scripts": {
@@ -16,7 +16,7 @@
16
16
  "author": "Stuart B",
17
17
  "license": "MIT",
18
18
  "engines": {
19
- "node": ">=14.0.0"
19
+ "node": ">=18.0.0"
20
20
  },
21
21
  "repository": {
22
22
  "type": "git",
@@ -33,9 +33,9 @@
33
33
  }
34
34
  },
35
35
  "dependencies": {
36
- "@influxdata/influxdb3-client": "^2.0.0"
36
+ "@influxdata/influxdb3-client": "^2.1.0"
37
37
  },
38
38
  "devDependencies": {
39
- "jest": "^30.2.0"
39
+ "jest": "^30.3.0"
40
40
  }
41
41
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Tests for line protocol string validation.
3
+ * Mirrors the validateLineProtocol logic in influxdb3.js.
4
+ */
5
+
6
+ // Re-implement the validation logic here to test it in isolation
7
+ // (the function is not exported from influxdb3.js)
8
+ function validateLineProtocol(lp) {
9
+ if (/^\{[\s\S]*}$/.test(lp) || /^\[[\s\S]*]$/.test(lp)) {
10
+ const preview = lp.length > 100 ? lp.substring(0, 100) + '...' : lp;
11
+ return (
12
+ 'The payload appears to be a JSON/object string, not line protocol. ' +
13
+ 'If you are sending JSON, ensure msg.payload is a parsed object (not a string). ' +
14
+ 'Use a JSON parse node before this node to convert the string to an object. ' +
15
+ `Received string: ${preview}`
16
+ );
17
+ }
18
+
19
+ if (!lp.includes(' ') || !lp.includes('=')) {
20
+ const preview = lp.length > 100 ? lp.substring(0, 100) + '...' : lp;
21
+ return (
22
+ 'The payload string does not appear to be valid line protocol. ' +
23
+ 'Expected format: measurement[,tag=val] field=val[,field=val] [timestamp]. ' +
24
+ `Received: ${preview}`
25
+ );
26
+ }
27
+
28
+ return null;
29
+ }
30
+
31
+ describe('Line protocol string validation', () => {
32
+ test('valid line protocol returns null', () => {
33
+ expect(validateLineProtocol('weather,location=us-midwest temperature=82 1465839830100400200')).toBeNull();
34
+ });
35
+
36
+ test('valid line protocol without timestamp returns null', () => {
37
+ expect(validateLineProtocol('weather,location=us-midwest temperature=82')).toBeNull();
38
+ });
39
+
40
+ test('valid line protocol without tags returns null', () => {
41
+ expect(validateLineProtocol('weather temperature=82')).toBeNull();
42
+ });
43
+
44
+ test('valid line protocol with multiple fields returns null', () => {
45
+ expect(validateLineProtocol('weather temperature=82,humidity=71')).toBeNull();
46
+ });
47
+
48
+ test('detects JSON object string', () => {
49
+ const input = '{"fields":{"used":12.0},"tags":{"location":"office"}}';
50
+ const result = validateLineProtocol(input);
51
+ expect(result).toContain('JSON/object string');
52
+ expect(result).toContain('not line protocol');
53
+ expect(result).toContain('JSON parse node');
54
+ });
55
+
56
+ test('detects JS object notation string (unquoted keys)', () => {
57
+ const input = '{fields:{used:12.0,path:root},tags:{location:office,node:grafana2}}';
58
+ const result = validateLineProtocol(input);
59
+ expect(result).toContain('JSON/object string');
60
+ });
61
+
62
+ test('detects JSON array string', () => {
63
+ const input = '[{"measurement":"test","fields":{"value":1}}]';
64
+ const result = validateLineProtocol(input);
65
+ expect(result).toContain('JSON/object string');
66
+ });
67
+
68
+ test('detects string with no space (not line protocol)', () => {
69
+ const result = validateLineProtocol('justameasurement');
70
+ expect(result).toContain('does not appear to be valid line protocol');
71
+ });
72
+
73
+ test('detects string with no equals sign (not line protocol)', () => {
74
+ const result = validateLineProtocol('measurement nofields');
75
+ expect(result).toContain('does not appear to be valid line protocol');
76
+ });
77
+
78
+ test('truncates long strings in error message', () => {
79
+ const longJson = '{' + '"a":1,'.repeat(50) + '"b":2}';
80
+ const result = validateLineProtocol(longJson);
81
+ expect(result).toContain('...');
82
+ });
83
+ });