node-red-contrib-join-wait 0.5.3 → 0.6.1

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/join-wait.html CHANGED
@@ -1,34 +1,33 @@
1
1
  <script type="text/x-red" data-template-name="join-wait">
2
2
  <div class="form-row">
3
- <label for="node-input-pathTopic"><i class="fa fa-bookmark-o"></i> Paths topic</label>
4
- <input type="text" id="node-input-pathTopic" placeholder="topic">
5
- <input type="hidden" id="node-input-pathTopicType">
6
- </div>
7
- <div class="form-row">
8
- <label for="node-input-paths"><i class="fa fa-code-fork"></i> Paths (Wait)</label>
9
- <input type="text" id="node-input-paths" placeholder="[&quot;path_1&quot;, &quot;path_2&quot;]">
3
+ <label for="node-input-name" title="Display name for this node on the canvas."><i class="fa fa-tag"></i> Name</label>
4
+ <input type="text" id="node-input-name" placeholder="Name">
10
5
  </div>
6
+
11
7
  <div class="form-row">
12
- <label for="node-input-pathsToExpire"><i class="fa fa-code-fork"></i> Paths (Expire)</label>
13
- <input type="text" id="node-input-pathsToExpire" placeholder="[&quot;not_path_3&quot;, &quot;not_path_4&quot;]">
8
+ <label for="node-input-pathTopic" title="Message property whose value identifies the incoming path."><i class="fa fa-bookmark-o"></i> Path field</label>
9
+ <input type="text" id="node-input-pathTopic" placeholder="topic">
10
+ <input type="hidden" id="node-input-pathTopicType">
14
11
  </div>
15
- <div class="form-row">
16
- <label for="node-input-useRegex">Use regex</label>
17
- <label for="node-input-useRegex" style="width:70%">
18
- <input type="checkbox" id="node-input-useRegex" style="display:inline-block; width:22px; vertical-align:baseline;">Interpret path names as regex</label>
12
+ <div class="form-tips" style="margin-bottom:12px">
13
+ The message property whose value identifies an incoming path —
14
+ e.g. <code>msg.topic</code> or <code>msg.paths</code>. May be a string
15
+ or an object with multiple keys.
19
16
  </div>
17
+
20
18
  <div class="form-row">
21
- <label for="node-input-warnUnmatched">Unmatched paths</label>
22
- <label for="node-input-warnUnmatched" style="width:70%">
23
- <input type="checkbox" id="node-input-warnUnmatched" style="display:inline-block; width:22px; vertical-align:baseline;">Warn on unmatched path</label>
19
+ <label title="Path names to wait for; repeat an entry to require it n times."><i class="fa fa-code-fork"></i> Wait paths</label>
20
+ <ol id="node-input-paths-container"></ol>
24
21
  </div>
25
- <div class="form-row">
26
- <label for="node-input-correlationTopic"><i class="fa fa-bookmark-o"></i> Correlation topic</label>
27
- <input type="text" id="node-input-correlationTopic" placeholder="_msgid">
28
- <input type="hidden" id="node-input-correlationTopicType">
22
+ <div class="form-tips" style="margin-bottom:6px">
23
+ The set of paths whose arrival within the time window emits a
24
+ merged success output. Press <kbd>Enter</kbd> in a row to add the
25
+ next one. Repeat an entry to require n-of-the-same.
29
26
  </div>
27
+ <div class="form-tips" id="join-wait-output-preview" style="margin-bottom:12px; font-family:monospace; color:#888"></div>
28
+
30
29
  <div class="form-row">
31
- <label for="node-input-timeout"><i class="fa fa-clock-o"></i> Timeout</label>
30
+ <label for="node-input-timeout" title="How long all wait paths have to arrive before the queue is expired."><i class="fa fa-clock-o"></i> Timeout</label>
32
31
  <input type="text" id="node-input-timeout" style="text-align:end; width:100px !important" placeholder="15">
33
32
  <select id="node-input-timeoutUnits" style="width:200px !important">
34
33
  <option value="1">Milliseconds</option>
@@ -38,224 +37,521 @@
38
37
  <option value="86400000">Days</option>
39
38
  </select>
40
39
  </div>
40
+ <div class="form-tips" id="join-wait-timeout-hint" style="margin-bottom:12px; color:#b07b00; display:none"></div>
41
+
41
42
  <div class="form-row">
42
- <label for="node-input-exactOrder"><i class="fa fa-sort"></i> Sequence order</label>
43
+ <label for="node-input-exactOrder" title="Any order (default) or strict sequence."><i class="fa fa-sort"></i> Match order</label>
43
44
  <select id="node-input-exactOrder" style="width:70%; margin-right:5px;">
44
45
  <option value="false">Any order</option>
45
- <option value="true">Exact order (strict)</option>
46
+ <option value="true">Exact order</option>
46
47
  </select>
47
48
  </div>
49
+
50
+ <div class="form-row">
51
+ <label for="node-input-correlationTopic" title="Optional. Property whose value groups related messages (e.g. msg._msgid)."><i class="fa fa-bookmark-o"></i> Group by</label>
52
+ <input type="text" id="node-input-correlationTopic" placeholder="(none)">
53
+ <input type="hidden" id="node-input-correlationTopicType">
54
+ </div>
55
+ <div class="form-tips" style="margin-bottom:12px">
56
+ Optional. Group messages so unrelated streams don't merge —
57
+ e.g. <code>msg._msgid</code> when waiting on a single split flow.
58
+ </div>
59
+
60
+ <div class="form-row" style="margin-bottom:14px">
61
+ <label></label>
62
+ <button type="button" id="join-wait-open-examples" class="red-ui-button">
63
+ <i class="fa fa-folder-open-o"></i> Open example flows
64
+ </button>
65
+ </div>
66
+
67
+ <details style="margin-top:14px">
68
+ <summary style="cursor:pointer; font-weight:bold; padding:6px 0">Advanced</summary>
69
+
70
+ <div class="form-row" style="margin-top:10px">
71
+ <label title="Paths that immediately drain the queue to the expired output."><i class="fa fa-times-circle-o"></i> Reset paths</label>
72
+ <ol id="node-input-pathsToExpire-container"></ol>
73
+ </div>
74
+ <div class="form-tips" style="margin-bottom:12px">
75
+ Optional. Any of these paths immediately drains the queue (with the
76
+ drained items going to the second output). Each entry must be unique.
77
+ </div>
78
+
48
79
  <div class="form-row">
49
- <label for="node-input-firstMsg"><i class="fa fa-comments-o"></i> Base message</label>
80
+ <label for="node-input-firstMsg" title="Use the first or last message in the matched window as the base of the merged output."><i class="fa fa-comments-o"></i> Output base</label>
50
81
  <select id="node-input-firstMsg" style="width:70%; margin-right:5px;">
51
- <option value="true">First received</option>
52
- <option value="false">Last received</option>
82
+ <option value="true">First received message</option>
83
+ <option value="false">Last received message</option>
53
84
  </select>
54
85
  </div>
86
+
55
87
  <div class="form-row">
56
- <label for="node-input-mapPayload"><i class="fa fa-arrow-right"></i> Merged data</label>
88
+ <label for="node-input-mapPayload" title="Keep the original Path-field values, or overwrite each with that message's payload."><i class="fa fa-arrow-right"></i> Merge values</label>
57
89
  <select id="node-input-mapPayload" style="width:70%; margin-right:5px;">
58
- <option value="false">Original Paths topic</option>
59
- <option value="true">Overwrite Paths topic with msg.payload</option>
90
+ <option value="false">Original Path field values</option>
91
+ <option value="true">Each msg.payload</option>
60
92
  </select>
61
93
  </div>
62
- <div class="form-row">
63
- <label for="node-input-disableComplete"><i class="fa fa-refresh"></i> Reset queue</label>
64
- <label for="node-input-disableComplete" style="width:70%">
65
- <input type="checkbox" id="node-input-disableComplete" style="display:inline-block; width:22px; vertical-align:baseline;">Ignore <code>msg.complete</code></label>
94
+
95
+ <div class="form-row" title="Treat each entry in Wait paths and Reset paths as a regular expression.">
96
+ <label for="node-input-useRegex"></label>
97
+ <input type="checkbox" id="node-input-useRegex" style="display:inline-block; width:22px; vertical-align:baseline; margin-right:6px;">
98
+ <label for="node-input-useRegex" style="width:auto">Treat path entries as regular expressions</label>
66
99
  </div>
67
- <div class="form-row">
68
- <label for="node-input-persistOnRestart"><i class="fa fa-floppy-o"></i> Persist</label>
69
- <label for="node-input-persistOnRestart" style="width:70%">
70
- <input type="checkbox" id="node-input-persistOnRestart" style="display:inline-block; width:22px; vertical-align:baseline;">Preserve queue on redeploy or restart</label>
100
+
101
+ <div class="form-row" title="Log a warning when a message arrives with a path that isn't in either list.">
102
+ <label for="node-input-warnUnmatched"></label>
103
+ <input type="checkbox" id="node-input-warnUnmatched" style="display:inline-block; width:22px; vertical-align:baseline; margin-right:6px;">
104
+ <label for="node-input-warnUnmatched" style="width:auto">Log a warning for unmatched paths</label>
105
+ </div>
106
+
107
+ <div class="form-row" title="Disable the msg.complete short-circuit.">
108
+ <label for="node-input-disableComplete"></label>
109
+ <input type="checkbox" id="node-input-disableComplete" style="display:inline-block; width:22px; vertical-align:baseline; margin-right:6px;">
110
+ <label for="node-input-disableComplete" style="width:auto">Ignore <code>msg.complete</code></label>
111
+ </div>
112
+
113
+ <div class="form-row" title="Save the queue to the context store on close so it survives redeploys (and full restarts when a persistent store is configured).">
114
+ <label for="node-input-persistOnRestart"></label>
115
+ <input type="checkbox" id="node-input-persistOnRestart" style="display:inline-block; width:22px; vertical-align:baseline; margin-right:6px;">
116
+ <label for="node-input-persistOnRestart" style="width:auto">Preserve queue across deploys / restarts</label>
71
117
  </div>
118
+
72
119
  <div class="form-row">
73
- <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
74
- <input type="text" id="node-input-name" placeholder="Name">
120
+ <label for="node-input-persistStore" title="Override the context store used to persist the queue."><i class="fa fa-database"></i> Persist store</label>
121
+ <select id="node-input-persistStore" style="width:70%; margin-right:5px;">
122
+ <option value="">(default context store)</option>
123
+ </select>
75
124
  </div>
76
- <div class="form-row node-input-rule-container-row">
77
- <ol id="node-input-rule-container"></ol>
125
+ <div class="form-tips" style="margin-bottom:12px">
126
+ Override the context store this node uses. Leave on default — set
127
+ the default to a persistent store (e.g. <code>localfilesystem</code>)
128
+ in <code>settings.js</code> and every join-wait node automatically
129
+ survives full restarts.
78
130
  </div>
131
+ </details>
79
132
  </script>
80
133
 
81
134
  <script type="text/javascript">
82
- RED.nodes.registerType('join-wait', {
83
- category: 'function',
84
- color: '#8baac7',
85
- defaults: {
86
- name: {
87
- value: '',
88
- },
89
- paths: {
90
- value: '',
91
- validate: function (v) {
92
- if (v === '') {
93
- return true;
94
- }
135
+ (function () {
136
+ function arrayValidator(allowEmpty, requireUnique) {
137
+ return function (v) {
138
+ // Accept legacy JSON-string format too, for back-compat with old flows.
139
+ var arr = v;
140
+ if (typeof v === 'string') {
141
+ if (v === '') return allowEmpty;
95
142
  try {
96
- const json = JSON.parse(v);
97
- return Array.isArray(json) && json.length > 0;
143
+ arr = JSON.parse(v);
98
144
  } catch (err) {
99
- console.log(err);
145
+ return false;
100
146
  }
147
+ }
148
+ if (!Array.isArray(arr)) return false;
149
+ if (arr.length === 0) return allowEmpty;
150
+ if (
151
+ !arr.every(function (s) {
152
+ return typeof s === 'string' && s.length > 0;
153
+ })
154
+ )
101
155
  return false;
102
- },
103
- },
104
- pathsToExpire: {
105
- value: '',
106
- validate: function (v) {
107
- if (v === '') {
108
- return true;
156
+ if (requireUnique) {
157
+ var seen = Object.create(null);
158
+ for (var i = 0; i < arr.length; i++) {
159
+ if (seen[arr[i]]) return false;
160
+ seen[arr[i]] = true;
109
161
  }
110
- try {
111
- const json = JSON.parse(v);
112
- if (Array.isArray(json) && json.length > 0) {
113
- return !json.some(function (p, index) {
114
- return json.indexOf(p) !== index;
115
- });
162
+ }
163
+ return true;
164
+ };
165
+ }
166
+
167
+ function correlationValidator(v) {
168
+ // Empty / undefined / non-jsonata: nothing to validate here.
169
+ if (this.correlationTopicType !== 'jsonata' || !v) return true;
170
+ // jsonata is exposed as a global by the Node-RED editor.
171
+ if (typeof jsonata !== 'function') return true;
172
+ try {
173
+ jsonata(v);
174
+ return true;
175
+ } catch (err) {
176
+ return false;
177
+ }
178
+ }
179
+
180
+ function isValidRegex(s) {
181
+ try {
182
+ new RegExp(s);
183
+ return true;
184
+ } catch (err) {
185
+ return false;
186
+ }
187
+ }
188
+
189
+ function setRowValid(input, ok, message) {
190
+ input.css('border-color', ok ? '' : '#d9534f');
191
+ input.attr('title', ok ? '' : message || '');
192
+ }
193
+
194
+ function validateRow(input) {
195
+ var $useRegex = $('#node-input-useRegex');
196
+ var v = String(input.val() || '');
197
+ if (v === '') {
198
+ setRowValid(input, false, 'path name cannot be empty');
199
+ return false;
200
+ }
201
+ if ($useRegex.is(':checked') && !isValidRegex(v)) {
202
+ setRowValid(input, false, 'invalid regular expression');
203
+ return false;
204
+ }
205
+ setRowValid(input, true);
206
+ return true;
207
+ }
208
+
209
+ function initEditableList(containerId, items, opts) {
210
+ var $container = $('#' + containerId);
211
+ $container.css({ 'min-height': '100px' });
212
+ var placeholder = (opts && opts.placeholder) || 'e.g. sensor_1';
213
+ $container.editableList({
214
+ addItem: function (row, index, data) {
215
+ var value = (data && data.value) || '';
216
+ row.css({ overflow: 'hidden', whiteSpace: 'nowrap' });
217
+ var input = $('<input/>', {
218
+ type: 'text',
219
+ placeholder: placeholder,
220
+ style: 'width:100%',
221
+ }).val(value);
222
+ input.on('input change blur', function () {
223
+ validateRow(input);
224
+ if (opts && opts.onChange) opts.onChange();
225
+ });
226
+ // Pressing Enter in a row adds the next one.
227
+ input.on('keydown', function (e) {
228
+ if (e.key === 'Enter' || e.keyCode === 13) {
229
+ e.preventDefault();
230
+ $container.editableList('addItem', { value: '' });
231
+ // Focus the newly-added row.
232
+ setTimeout(function () {
233
+ $container.editableList('items').last().find('input').trigger('focus');
234
+ }, 0);
116
235
  }
117
- } catch (err) {
118
- console.log(err);
119
- }
120
- return false;
236
+ });
237
+ row.append(input);
238
+ validateRow(input);
239
+ if (opts && opts.onChange) opts.onChange();
121
240
  },
122
- },
123
- useRegex: {
124
- value: false,
125
- },
126
- warnUnmatched: {
127
- value: true,
128
- },
129
- pathTopic: {
130
- value: 'topic',
131
- },
132
- pathTopicType: {
133
- value: 'msg',
134
- },
135
- correlationTopic: {
136
- value: '',
137
- },
138
- correlationTopicType: {
139
- value: 'undefined',
140
- },
141
- timeout: {
142
- value: 15000,
143
- required: true,
144
- validate: function (v) {
145
- return (Number(v) || 0) > 0;
241
+ removeItem: function () {
242
+ if (opts && opts.onChange) opts.onChange();
146
243
  },
147
- },
148
- timeoutUnits: {
149
- value: 1,
150
- validate: RED.validators.number(),
151
- },
152
- exactOrder: {
153
- value: 'false',
154
- required: true,
155
- },
156
- firstMsg: {
157
- value: 'true',
158
- required: true,
159
- },
160
- mapPayload: {
161
- value: 'false',
162
- required: true,
163
- },
164
- disableComplete: {
165
- value: false,
166
- },
167
- persistOnRestart: {
168
- value: false,
169
- },
170
- },
171
- inputs: 1,
172
- outputs: 2,
173
- outputLabels: ['success', 'expired'],
174
- icon: 'node-red-contrib-join-wait.png',
175
- label: function () {
176
- return this.name || 'join-wait';
177
- },
178
- labelStyle: function () {
179
- return this.name ? 'node_label_italic' : '';
180
- },
181
- oneditprepare: function () {
182
- var node = this;
183
- var previousValueType = {
184
- value: 'prev',
185
- label: this._('join-wait.previous'),
186
- hasValue: false,
187
- };
188
- $('#node-input-pathTopic').typedInput({
189
- default: this.pathTopicType || 'msg',
190
- typeField: $('#node-input-pathTopicType'),
191
- types: ['msg', 'flow', 'global'],
244
+ removable: true,
245
+ sortable: true,
246
+ height: 'auto',
192
247
  });
193
- $('#node-input-correlationTopic').typedInput({
194
- default: this.correlationTopicType || types.Undefined,
195
- typeField: $('#node-input-correlationTopicType'),
196
- types: [
197
- { value: 'undefined', label: RED._('common.type.undefined'), hasValue: false },
198
- 'msg',
199
- 'flow',
200
- 'global',
201
- 'jsonata',
202
- ],
248
+ (items || []).forEach(function (v) {
249
+ $container.editableList('addItem', { value: v });
203
250
  });
204
- $('#node-input-timeout').spinner({
205
- min: 1,
251
+ }
252
+
253
+ // Re-validate every editableList row when the regex toggle flips.
254
+ function rebindRegexValidation() {
255
+ $('#node-input-useRegex').on('change', function () {
256
+ $(
257
+ '#node-input-paths-container input[type=text], ' +
258
+ '#node-input-pathsToExpire-container input[type=text]',
259
+ ).each(function () {
260
+ validateRow($(this));
261
+ });
206
262
  });
207
- },
208
- });
209
- </script>
263
+ }
210
264
 
211
- <script type="text/x-red" data-help-name="join-wait">
212
- <p>This node waits for messages from all items in the <code>Paths (Wait)</code> array, which must be received inside of a designated time window.</p>
265
+ function readEditableList(containerId) {
266
+ var values = [];
267
+ $('#' + containerId)
268
+ .editableList('items')
269
+ .each(function () {
270
+ var v = $(this).find('input').val();
271
+ if (v != null && v !== '') values.push(v);
272
+ });
273
+ return values;
274
+ }
213
275
 
214
- <p>If all of the messages are received in that interval, a merged output is sent to the <code>success</code> output. Otherwise, any expired messages are sent to the <code>timeout</code> output. Either output can be optionally connected for further processing.</p>
276
+ // Coerce legacy string-array values into a plain array for editing.
277
+ function toArray(v) {
278
+ if (Array.isArray(v)) return v;
279
+ if (typeof v === 'string' && v !== '') {
280
+ try {
281
+ var parsed = JSON.parse(v);
282
+ return Array.isArray(parsed) ? parsed : [];
283
+ } catch (err) {
284
+ return [];
285
+ }
286
+ }
287
+ return [];
288
+ }
215
289
 
216
- <p>In the event of multiple messages, the time window is adjusted as needed to continue evaluation on subsequent messages. This node has several potential applications, including home automation. For instance, to handle a case where the light turning on/off is also triggering a motion sensor: IF a) light turned OFF, b) motion sensor activated, c) light turned ON all occur within 10 seconds, then turn light OFF.</p>
290
+ // Render a tiny preview of what msg.<pathField> looks like on the
291
+ // success output, derived from the current Wait paths list.
292
+ function updateOutputPreview() {
293
+ var paths = readEditableList('node-input-paths-container');
294
+ var unique = [];
295
+ paths.forEach(function (p) {
296
+ if (unique.indexOf(p) === -1) unique.push(p);
297
+ });
298
+ var pathField = $('#node-input-pathTopic').val() || 'topic';
299
+ var $tip = $('#join-wait-output-preview');
300
+ if (unique.length === 0) {
301
+ $tip.text('');
302
+ return;
303
+ }
304
+ var entries = unique.slice(0, 4).map(function (p) {
305
+ return p + ': …';
306
+ });
307
+ if (unique.length > 4) entries.push('…');
308
+ $tip.text('→ msg.' + pathField + ' = { ' + entries.join(', ') + ' }');
309
+ }
217
310
 
218
- <p>Memory is managed to delete objects after they reach the <code>Timeout</code>.</p>
311
+ // Warn when the resolved timeout is impractically short (the README
312
+ // recommends padding ~5–10 ms for evaluation overhead).
313
+ function updateTimeoutHint() {
314
+ var t = Number($('#node-input-timeout').val()) || 0;
315
+ var u = Number($('#node-input-timeoutUnits').val()) || 1;
316
+ var ms = t * u;
317
+ var $hint = $('#join-wait-timeout-hint');
318
+ if (ms > 0 && ms < 50) {
319
+ $hint.text('Very short — pad ~10 ms to leave room for evaluation overhead.').show();
320
+ } else {
321
+ $hint.hide();
322
+ }
323
+ }
219
324
 
220
- <h3>Details</h3>
325
+ // Populate the Persist store <select> from the runtime via our
326
+ // /join-wait/stores admin route. Falls back gracefully if the route
327
+ // is unreachable or returns nothing useful.
328
+ function populatePersistStores(currentValue) {
329
+ var $sel = $('#node-input-persistStore');
330
+ $.getJSON('join-wait/stores')
331
+ .done(function (stores) {
332
+ if (!Array.isArray(stores) || stores.length === 0) return;
333
+ stores.forEach(function (s) {
334
+ if (s.name === 'default') return; // already represented as the blank option
335
+ var label = s.module ? s.name + ' (' + s.module + ')' : s.name;
336
+ $('<option/>').val(s.name).text(label).appendTo($sel);
337
+ });
338
+ // If the saved value isn't in the list, append it so we
339
+ // don't silently drop a user-provided override.
340
+ if (currentValue && !$sel.find('option[value="' + currentValue + '"]').length) {
341
+ $('<option/>')
342
+ .val(currentValue)
343
+ .text(currentValue + ' (not configured)')
344
+ .appendTo($sel);
345
+ }
346
+ $sel.val(currentValue || '');
347
+ })
348
+ .fail(function () {
349
+ // Admin route not reachable — leave the select with just
350
+ // its default option.
351
+ });
352
+ }
221
353
 
222
- <p>Each item in the <code>Paths (Wait)</code> array corresponds with an input path to wait for. E.g., <code>[&quot;path_1&quot;, &quot;path_2&quot;, &quot;other_path&quot;]</code>. This can also be configured at runtime by passing an array using <code>msg.pathsToWait</code>.</p>
354
+ function openExamplesDialog() {
355
+ // The edit tray is on top of the editor, so the import tray
356
+ // can't surface beneath it. Close this tray first (which cancels
357
+ // the in-flight edit), then invoke the core import dialog so the
358
+ // user can pick Examples → join-wait.
359
+ var invoke = function () {
360
+ if (RED.actions && typeof RED.actions.invoke === 'function') {
361
+ RED.actions.invoke('core:show-import-dialog');
362
+ }
363
+ };
364
+ if (RED.tray && typeof RED.tray.close === 'function') {
365
+ RED.tray.close(invoke);
366
+ } else {
367
+ invoke();
368
+ }
369
+ }
223
370
 
224
- <p>Each item in the <code>Paths (Expire)</code> array corresponds with an input path that will immediately expire all messages in the queue without further processing. This acts as a reset. This can also be configured at runtime by passing an array using <code>msg.pathsToExpire</code>. Each item must have a unique name.</p>
371
+ RED.nodes.registerType('join-wait', {
372
+ category: 'function',
373
+ color: '#8baac7',
374
+ defaults: {
375
+ name: { value: '' },
376
+ paths: { value: [], validate: arrayValidator(false, false) },
377
+ pathsToExpire: { value: [], validate: arrayValidator(true, true) },
378
+ useRegex: { value: false },
379
+ warnUnmatched: { value: true },
380
+ pathTopic: { value: 'topic', required: true },
381
+ pathTopicType: { value: 'msg' },
382
+ correlationTopic: { value: '', validate: correlationValidator },
383
+ correlationTopicType: { value: 'undefined' },
384
+ timeout: {
385
+ value: 15000,
386
+ required: true,
387
+ validate: function (v) {
388
+ return (Number(v) || 0) > 0;
389
+ },
390
+ },
391
+ timeoutUnits: { value: 1, validate: RED.validators.number() },
392
+ exactOrder: { value: 'false', required: true },
393
+ firstMsg: { value: 'true', required: true },
394
+ mapPayload: { value: 'false', required: true },
395
+ disableComplete: { value: false },
396
+ // Defaults to true so the queue survives a redeploy out of
397
+ // the box (uses the default in-memory context store).
398
+ // For full Node-RED restart persistence the user still needs
399
+ // to point Persist store at a configured persistent store.
400
+ persistOnRestart: { value: true },
401
+ persistStore: { value: '' },
402
+ },
403
+ inputs: 1,
404
+ outputs: 2,
405
+ outputLabels: ['success', 'expired'],
406
+ icon: 'node-red-contrib-join-wait.png',
407
+ label: function () {
408
+ return this.name || 'join-wait';
409
+ },
410
+ labelStyle: function () {
411
+ return this.name ? 'node_label_italic' : '';
412
+ },
413
+ oneditprepare: function () {
414
+ $('#node-input-pathTopic').typedInput({
415
+ default: this.pathTopicType || 'msg',
416
+ typeField: $('#node-input-pathTopicType'),
417
+ types: ['msg', 'flow', 'global'],
418
+ });
419
+ $('#node-input-correlationTopic').typedInput({
420
+ default: this.correlationTopicType || 'undefined',
421
+ typeField: $('#node-input-correlationTopicType'),
422
+ types: [
423
+ { value: 'undefined', label: RED._('common.type.undefined'), hasValue: false },
424
+ 'msg',
425
+ 'flow',
426
+ 'global',
427
+ 'jsonata',
428
+ ],
429
+ });
430
+ $('#node-input-timeout').spinner({ min: 1 });
225
431
 
226
- <p><code>Paths topic</code> must be set to a <code>msg</code> property, which is used to check each flow to see if all of the elements in <code>Paths (Wait)</code> are matched. This can be <code>msg.topic</code>, <code>msg.paths</code>, etc. If this is not specified, <code>msg.paths</code> is the default.</p>
432
+ initEditableList('node-input-paths-container', toArray(this.paths), {
433
+ placeholder: 'e.g. sensor_1',
434
+ onChange: updateOutputPreview,
435
+ });
436
+ initEditableList('node-input-pathsToExpire-container', toArray(this.pathsToExpire), {
437
+ placeholder: 'e.g. abort',
438
+ });
439
+ rebindRegexValidation();
227
440
 
228
- <p>Note that <code>Paths topic</code> can be set in one of two ways:</p>
229
- <p>1. As a string, set to the path to check, e.g., <code>msg.paths = &quot;path_1&quot;;</code></p>
230
- <p>2. As an object, set to any value (e.g., <code>msg.paths[&quot;path_1&quot;] = {&quot;example&quot;: &quot;data&quot;};</code> or <code>msg.paths[&quot;path_1&quot;] = 42;</code>). If the object format is used, multiple paths can be specified. For example, <code>msg.paths = {&quot;path_1&quot;: true, &quot;path_2&quot;: true};</code> This can be useful if one flow needs to trigger multiple paths.</p>
441
+ $('#node-input-pathTopic').on('change input', updateOutputPreview);
442
+ updateOutputPreview();
231
443
 
232
- <p><code>Correlation topic</code> can be set, if desired, to ensure that only related messages are grouped. E.g., <code>msg._msgid</code> can be used to ensure that only messages from a <i>single</i> split flow are grouped together. If left blank, all messages will be assumed to be related.</p>
444
+ $('#node-input-timeout, #node-input-timeoutUnits').on('change input', updateTimeoutHint);
445
+ updateTimeoutHint();
233
446
 
234
- <p><code>Timeout</code> is required to designate the time window to receive all of the messages from <code>Paths (Wait)</code>.</p>
447
+ populatePersistStores(this.persistStore);
235
448
 
236
- <p><code>Sequence order</code> defines the criteria to evaluate the received messages. An <i>exact</i> match can be specified, otherwise, it will match them in any order. To determine the order, the timestamp on the <i>latest</i> valid <code>Paths (Wait)</code> is used, even if multiple messages arrived earlier. In this case of waiting for <code>[&quot;path_1&quot;, &quot;path_2&quot;, &quot;path_3&quot;]</code>, the <code>*</code> indicates which messages are used: <code>[&quot;path_1&quot;, &quot;path_2&quot;, &quot;path_1&quot;*, &quot;path_2&quot;*, &quot;path_3&quot;*]</code>.</p>
449
+ $('#join-wait-open-examples').on('click', openExamplesDialog);
450
+ },
451
+ oneditsave: function () {
452
+ this.paths = readEditableList('node-input-paths-container');
453
+ this.pathsToExpire = readEditableList('node-input-pathsToExpire-container');
454
+ },
455
+ oneditresize: function () {
456
+ $('#node-input-paths-container').css('min-height', '100px');
457
+ $('#node-input-pathsToExpire-container').css('min-height', '60px');
458
+ },
459
+ });
460
+ })();
461
+ </script>
462
+
463
+ <script type="text/x-red" data-help-name="join-wait">
464
+ <p>Waits for a set of related messages to arrive on different paths within
465
+ a time window, then emits a single merged message. Optionally treats some
466
+ paths as resets that drain the queue.</p>
467
+
468
+ <h3>Inputs</h3>
469
+ <dl class="message-properties">
470
+ <dt>payload<span class="property-type">any</span></dt>
471
+ <dd>Forwarded as-is (and optionally used to merge values — see "Merge values").</dd>
472
+
473
+ <dt class="optional">path field<span class="property-type">string | object</span></dt>
474
+ <dd>The message property named in the <b>Path field</b> setting (default <code>msg.topic</code>).
475
+ Either a single path name (<code>"path_1"</code>) or an object whose
476
+ keys are path names (<code>{path_1: ..., path_2: ...}</code>) when one
477
+ message represents multiple paths.</dd>
237
478
 
238
- <p><code>Base message</code> defines which message object should be returned as the base message. Either the first message in a sequence or the last.</p>
479
+ <dt class="optional">complete<span class="property-type">any</span></dt>
480
+ <dd>If present, the queue is evaluated for completion and then drained.
481
+ Disable via "Ignore <code>msg.complete</code>".</dd>
239
482
 
240
- <p><code>Merged data</code> defines how the data from <code>msg.paths</code> (or, another designed <code>Paths topic</code>) will be returned. Either, it can be merged in its original form, or, it can be overwritten with each respective <code>msg.payload</code>. This merged data is then appended to the <code>Base message</code>. In the event that multiple messages arrive in this time interval with the same <code>Paths (Wait)</code>, only the data from the latest item is returned. For instance, if <code>Paths (Wait)</code> = <code>[&quot;path_1&quot;, &quot;path_2&quot;, &quot;path_3&quot;]</code>, the <code>*</code> indicates which messages are used in this sequence: <code>[&quot;path_1&quot;, &quot;path_2&quot;, &quot;path_1&quot;, &quot;path_2&quot;, &quot;path_1&quot;*, &quot;path_2&quot;*, &quot;path_3&quot;*]</code>. These additional messages will <b></b> be expired.</p>
483
+ <dt class="optional">reset<span class="property-type">boolean</span></dt>
484
+ <dd>If <code>true</code>, the queue for the current group is silently
485
+ cleared (no output emitted). The other message fields are ignored.</dd>
241
486
 
242
- <h3>Notes and Caveats</h3>
487
+ <dt class="optional">pathsToWait<span class="property-type">string[]</span></dt>
488
+ <dd>Per-message override of "Wait paths" (one-shot — does not change
489
+ the node's stored config).</dd>
490
+
491
+ <dt class="optional">pathsToExpire<span class="property-type">string[]</span></dt>
492
+ <dd>Per-message override of "Reset paths" (one-shot).</dd>
493
+
494
+ <dt class="optional">useRegex<span class="property-type">boolean</span></dt>
495
+ <dd>Per-message override of the regex setting (one-shot).</dd>
496
+ </dl>
497
+
498
+ <h3>Outputs</h3>
499
+ <ol class="node-ports">
500
+ <li>Success — emitted when all "Wait paths" arrive within the timeout.
501
+ The merged data is attached on the <b>Path field</b> property.</li>
502
+ <li>Expired — drained messages from queues that timed out, hit a reset
503
+ path, or that <code>msg.complete</code> evaluated.</li>
504
+ </ol>
505
+
506
+ <h3>Details</h3>
243
507
 
244
- <p>There is support for repeated paths. For example, <code>["path_1", "path_2", "path_1", "path_2"]</code>.</p>
508
+ <p><b>Path field</b> the property whose value identifies the path. Can
509
+ be set as a string (one path) or an object (multiple paths from one
510
+ message). Defaults to <code>msg.topic</code>.</p>
245
511
 
246
- <p>If any order is used, <code>Paths (Wait)</code> is evaluated to determine the count for repeated paths. If *regex* is used, paths will be counted in a greedy fashion from left to right. For example, <code>["path_[12]", "path_2"]</code> would never complete because all instances of "path_1" and "path_2" would be counted for the first path.</p>
512
+ <p><b>Wait paths</b> list of paths to wait for. Repeating an entry means
513
+ "this path must arrive that many times". Configurable at runtime via
514
+ <code>msg.pathsToWait</code>.</p>
247
515
 
248
- <p>If exact order is used, note that unexpected paths would still be tolerated.</p>
516
+ <p><b>Reset paths</b> paths that immediately drain the queue. Each must
517
+ be unique. Configurable at runtime via <code>msg.pathsToExpire</code>.</p>
249
518
 
250
- <p>In the case of duplicate paths, only the data from the latest path(s) will be used.</p>
519
+ <p><b>Group by</b> optional correlation property. When set, only
520
+ messages with the same value are joined together. Useful with
521
+ <code>msg._msgid</code> when waiting on a single split flow. Supports
522
+ <code>msg</code>, <code>flow</code>, <code>global</code>, and
523
+ <code>jsonata</code> property types.</p>
251
524
 
252
- <p>If the <code>regex</code> option is enabled, each path will be treated as a regular expression. So, <code>[&quot;^path\d+$&quot;]</code> would match any path1, path2, path3, etc. Note that <code>^$</code> are not required, and if omitted, would just perform a partial match. For example <code>[&quot;path\d+&quot;]</code> would match &quot;my_path1_test&quot;. This property can also be set at runtime by passing <code>msg.useRegex</code>.</p>
525
+ <p><b>Match order</b> "Any order" (default) or "Exact order". In exact
526
+ mode, unexpected paths between expected ones are tolerated. To determine
527
+ the matching window, the timestamp of the <i>latest</i> required path is
528
+ used. For example, with wait paths <code>[path_1, path_2, path_3]</code>
529
+ and arrivals <code>[path_1, path_2, path_1*, path_2*, path_3*]</code>,
530
+ the starred entries form the match.</p>
253
531
 
254
- <p>If the <code>msg.complete</code> property is set, the message queue will be evaluated for completion, and then any remaining items in the queue will be immediately expired. This feature can be disabled in the settings, if desired.</p>
532
+ <p><b>Output base</b> whether to use the first or last received message
533
+ as the base for the merged output.</p>
255
534
 
256
- <p>All values within <code>Paths topic</code> must be contained by either <code>Paths (Wait)</code> or <code>Paths (Expire)</code>, or an error will be thrown. The <code>Unmatched paths</code> error notification can be disabled within the settings.</p>
535
+ <p><b>Merge values</b> keep the original values from the path field, or
536
+ overwrite each with that message's <code>msg.payload</code>.</p>
257
537
 
258
- <p>If <code>msg.pathsToWait</code> is used instead of setting <code>Paths (Wait)</code>, note that each successive <code>msg.pathsToWait</code> will overwrite the previously stored global value. Due to the nature of the timeout, <code>Paths (Wait)</code> needs to be evaluated even after a message has arrived. Changing the value of <code>msg.pathsToWait</code> between messages may cause unexpected behavior.</p>
538
+ <h3>Notes</h3>
259
539
 
260
- <p><code>Timeout</code> should be padded with a small amount of overhead (i.e., ~5-10 ms or so) for the time it takes to evaluate all of the messages and conditions. This may become critical under very short timeouts.</p>
540
+ <ul>
541
+ <li>If <b>Use regex</b> is on, each entry in Wait/Reset paths is
542
+ compiled as a regular expression. <code>^</code>/<code>$</code>
543
+ anchors are not implicit — <code>path\d+</code> matches
544
+ <code>my_path1_test</code>.</li>
545
+ <li>For repeated paths in any-order mode with regex, paths are counted
546
+ greedily left-to-right. <code>["path_[12]", "path_2"]</code> never
547
+ completes because <code>path_2</code> arrivals get attributed to the
548
+ first regex.</li>
549
+ <li>When the same path arrives multiple times, only the latest value
550
+ is kept in the merged output.</li>
551
+ <li>Pad <b>Timeout</b> with a small overhead (~5–10&nbsp;ms) for
552
+ evaluation time when working with very short windows.</li>
553
+ <li>When <b>Preserve queue</b> is on, the queue is saved to a JSON
554
+ file under <code>{userDir}/join-wait/{nodeId}.json</code> on close
555
+ and reloaded on next start.</li>
556
+ </ul>
261
557
  </script>