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.js CHANGED
@@ -1,465 +1,456 @@
1
1
  module.exports = function (RED) {
2
2
  'use strict';
3
- const path = require('path');
4
- const storage = require('node-persist');
5
3
  const jsonata = require('jsonata');
6
4
 
5
+ const { normalizePaths, hasDuplicatePath, compileRegex } = require('./lib/config');
6
+ const { matchesAny, anyMatches, findAllPathsAnyOrder, findAllPathsExactOrder } = require('./lib/matcher');
7
+ const persist = require('./lib/persist');
8
+ const { resolveContextStore } = require('./lib/store');
9
+
10
+ const RESOLVE_FAILED = Symbol('resolve-failed');
11
+ const DEFAULT_GROUP = '_join-wait-node';
12
+
13
+ // HTML <select> stores `'true'`/`'false'` strings, but flows constructed
14
+ // programmatically may use real booleans. Accept both.
15
+ function asBool(v) {
16
+ return v === true || v === 'true';
17
+ }
18
+
7
19
  function JoinWaitNode(config) {
8
20
  RED.nodes.createNode(this, config);
21
+ const node = this;
22
+
23
+ // Config: paths can be a real array (new editor) or JSON string (legacy editor).
24
+ node.pathsToWait = normalizePaths(config.paths);
25
+ node.pathsToExpire = normalizePaths(config.pathsToExpire);
9
26
 
10
- try {
11
- this.pathsToWait = JSON.parse(config.paths);
12
- } catch (err) {
13
- this.pathsToWait = false;
27
+ if (Array.isArray(node.pathsToExpire) && hasDuplicatePath(node.pathsToExpire)) {
28
+ node.error(`join-wait pathsToExpire cannot have duplicate entries: ${node.pathsToExpire}`);
29
+ node.status({ fill: 'red', shape: 'ring', text: 'config error' });
30
+ return;
14
31
  }
15
32
 
16
- try {
17
- this.pathsToExpire = JSON.parse(config.pathsToExpire);
18
- if (hasDuplicatePath(this.pathsToExpire)) {
19
- this.error(`join-wait pathsToExpire cannot have duplicate entries: ${this.pathsToExpire}`);
20
- return;
21
- }
22
- } catch (err) {
23
- this.pathsToExpire = false;
33
+ // Config keys are kept verbatim from 0.5.x flow JSON for back-compat.
34
+ // Internally we use clearer names: pathField (vs pathTopic),
35
+ // ignoreMsgComplete (vs disableComplete), persistQueue (vs persistOnRestart).
36
+ node.exactOrder = asBool(config.exactOrder);
37
+ node.useFirstAsBase = config.firstMsg !== 'false' && config.firstMsg !== false;
38
+ node.mapPayload = asBool(config.mapPayload);
39
+ node.useRegex = config.useRegex === true;
40
+ node.warnUnmatched = config.warnUnmatched === true;
41
+ node.ignoreMsgComplete = config.disableComplete === true;
42
+ node.persistQueue = config.persistOnRestart === true;
43
+ // Resolve effective context store: explicit override wins; otherwise,
44
+ // if Preserve queue is on AND the default store is memory AND a
45
+ // persistent named store exists, auto-pick it so the user doesn't
46
+ // have to point every join-wait node at the same store manually.
47
+ const explicitStore = config.persistStore || undefined;
48
+ node.persistStore = resolveContextStore(RED.settings.contextStorage, explicitStore, node.persistQueue);
49
+ if (node.persistStore && node.persistStore !== explicitStore) {
50
+ node.log(`auto-selected context store '${node.persistStore}' for queue persistence`);
24
51
  }
25
52
 
26
- this.exactOrder = config.exactOrder === 'true';
27
- this.topic = config.correlationTopic || false;
28
- this.topicType = config.correlationTopicType;
29
- if (this.topicType === 'jsonata') {
53
+ node.pathField = config.pathTopic || 'topic';
54
+ node.pathFieldType = config.pathTopicType || 'msg';
55
+
56
+ node.correlatorType = config.correlationTopicType;
57
+ node.correlator = config.correlationTopic || false;
58
+ if (node.correlatorType === 'jsonata' && node.correlator) {
30
59
  try {
31
- this.topic = jsonata(this.topic);
60
+ node.correlator = jsonata(node.correlator);
32
61
  } catch (err) {
33
- this.error(`join-wait.invalid-expr topic ${err.message}`);
62
+ node.error(`join-wait.invalid-expr topic ${err.message}`);
63
+ node.status({ fill: 'red', shape: 'ring', text: 'invalid correlator' });
34
64
  return;
35
65
  }
36
66
  }
37
67
 
38
- this.pathTopic = config.pathTopic || 'topic';
39
- this.pathTopicType = config.pathTopicType;
68
+ node.timeout = (Number(config.timeout) || 15000) * (Number(config.timeoutUnits) || 1);
40
69
 
41
- this.timeout = (Number(config.timeout) || 15000) * (Number(config.timeoutUnits) || 1);
42
- this.firstMsg = config.firstMsg === 'true';
43
- this.mapPayload = config.mapPayload === 'true';
70
+ // ---------- persistence ----------
44
71
 
45
- this.useRegex = config.useRegex === true;
46
- this.warnUnmatched = config.warnUnmatched === true;
47
- this.disableComplete = config.disableComplete === true;
48
- this.persistOnRestart = config.persistOnRestart === true;
72
+ const nodeCtx = node.context();
49
73
 
50
- storage.initSync({
51
- dir: path.join(RED.settings.userDir, 'join-wait', config.id.toString()),
52
- forgiveParseErrors: true,
53
- });
54
-
55
- const savedPaths = storage.getItemSync('paths');
56
- storage.clear();
57
-
58
- this.paths = savedPaths ? JSON.parse(savedPaths) : {};
59
- let node = this;
60
-
61
- for (const topic in node.paths) {
62
- /* istanbul ignore else */
63
- if (Object.prototype.hasOwnProperty.call(node.paths, topic)) {
64
- if (node.persistOnRestart) {
65
- makeNewQueueTimer(topic, 10);
66
- } else {
67
- clearQueueAllNoOutput(topic);
74
+ // node.queues is the per-correlation-group queue dictionary, keyed by
75
+ // correlation value. Exposed for tests; populated asynchronously from
76
+ // the context store so a configured persistent store can be used.
77
+ node.queues = {};
78
+ node._ready = persist.load(nodeCtx, node.persistStore).then((saved) => {
79
+ node.queues = saved;
80
+ // Wipe what we just read; the close handler will re-write if persistOnRestart=true.
81
+ return persist.clear(nodeCtx, node.persistStore).then(() => {
82
+ for (const group of Object.keys(node.queues)) {
83
+ if (node.persistQueue) {
84
+ scheduleQueueTimer(group, 10);
85
+ } else {
86
+ dropQueue(group);
87
+ }
68
88
  }
69
- }
70
- }
89
+ updateStatus();
90
+ });
91
+ });
71
92
 
72
93
  node.on('close', function (removed, done) {
73
- for (const topic in node.paths) {
74
- /* istanbul ignore else */
75
- if (Object.prototype.hasOwnProperty.call(node.paths, topic)) {
76
- clearTimeout(node.paths[topic].timeOut);
77
- /* istanbul ignore else */
78
- if (!node.persistOnRestart) {
79
- clearQueueAllNoOutput(topic);
80
- }
94
+ for (const group of Object.keys(node.queues)) {
95
+ clearTimeout(node.queues[group].timeOut);
96
+ if (!node.persistQueue) {
97
+ dropQueue(group);
81
98
  }
82
99
  }
83
100
 
84
- if (node.persistOnRestart) {
85
- storage.setItemSync('paths', JSON.stringify(node.paths));
86
- }
87
-
88
- done();
101
+ const writeBack = node.persistQueue
102
+ ? persist.save(nodeCtx, node.persistStore, node.queues)
103
+ : persist.clear(nodeCtx, node.persistStore);
104
+
105
+ // Always call done — even on a context-store rejection — so
106
+ // Node-RED's shutdown isn't held up indefinitely.
107
+ writeBack.then(
108
+ () => done(),
109
+ /* c8 ignore next */
110
+ (err) => done(err),
111
+ );
89
112
  });
90
113
 
91
- node.on('input', function (msg) {
92
- //
93
- // error checking
94
- //
95
-
96
- // pathTopic & pathTopicType
97
-
98
- let pathTopic = RED.util.evaluateNodeProperty(node.pathTopic, node.pathTopicType, node, msg);
99
- const pathTopicName = `${node.pathTopicType}.${node.pathTopic}`;
100
-
101
- if (!pathTopic) {
102
- node.error(`join-wait "${pathTopicName}" is undefined or not set.`, [msg, null]);
103
- return;
104
- }
105
-
106
- if (typeof pathTopic === 'string') {
107
- pathTopic = {
108
- [pathTopic]: true,
109
- };
110
- } else if (typeof pathTopic !== 'object' || Array.isArray(pathTopic)) {
111
- node.error(
112
- `join-wait "${pathTopicName}" must be a string or an object, e.g., ${pathTopicName} = 'value'.`,
113
- [msg, null],
114
- );
115
- return;
116
- }
117
-
118
- // pathsToWait & pathsToExpire
119
-
120
- node.pathsToWait = msg.pathsToWait || node.pathsToWait; // update global setting
121
- if (!node.pathsToWait || !Array.isArray(node.pathsToWait) || !node.pathsToWait.length) {
122
- node.error('join-wait pathsToWait must be a defined array.', [msg, null]);
123
- return;
124
- }
125
-
126
- let pathsToWait = Object.assign([], node.pathsToWait);
127
- let pathsToExpire = false;
128
-
129
- node.pathsToExpire = msg.pathsToExpire || node.pathsToExpire; // update global setting
130
- if (node.pathsToExpire) {
131
- if (!Array.isArray(node.pathsToExpire) || !node.pathsToExpire.length) {
132
- node.error('join-wait pathsToExpire must be undefined or an array.', [msg, null]);
133
- return;
134
- }
135
-
136
- pathsToExpire = Object.assign([], node.pathsToExpire);
137
- }
138
-
139
- if (pathsToExpire && hasDuplicatePath(pathsToExpire)) {
140
- node.error(`join-wait pathsToExpire cannot have duplicate entries: ${pathsToExpire}`);
141
- return;
142
- }
114
+ // ---------- input handler ----------
115
+
116
+ // Modern Node-RED (msg, send, done) signature:
117
+ // - `send` is the per-invocation send fn — safer than node.send in
118
+ // async handlers because it can't fire after shutdown.
119
+ // - `done()` tells the runtime this message has finished
120
+ // processing (used for async-message tracking + graceful
121
+ // shutdown). For a join node, "finished" means *buffered* —
122
+ // we mark each input done when it's queued or rejected. The
123
+ // completing message's done() also covers the success emission;
124
+ // the earlier-queued msgs were already done() at intake.
125
+ node.on('input', async function (msg, send, done) {
126
+ try {
127
+ // Wait for the initial context load before processing — guarantees
128
+ // we don't race the persisted state read with a freshly-arriving msg.
129
+ await node._ready;
143
130
 
144
- node.useRegex = Object.prototype.hasOwnProperty.call(msg, 'useRegex')
145
- ? msg.useRegex === true
146
- : node.useRegex; // update global setting
147
- if (node.useRegex) {
148
- try {
149
- pathsToWait = convertToRegex(pathsToWait);
150
- pathsToExpire = convertToRegex(pathsToExpire);
151
- } catch (err) {
152
- node.error(`join-wait.regex-expr ${err.message}`, null);
131
+ const evalCtx = await buildEvalContext(msg);
132
+ if (!evalCtx) {
133
+ done();
153
134
  return;
154
135
  }
155
- }
156
-
157
- const pathKeys = Object.keys(pathTopic);
158
- const foundKeys = pathKeys.filter(function (val) {
159
- return pathsToWait.some(function (p) {
160
- return node.useRegex ? p.test(val) : p === val;
161
- });
162
- });
163
-
164
- const hasExpirePath = pathsToExpire && findAnyPath(pathKeys, pathsToExpire, node.useRegex);
165
-
166
- if (!hasExpirePath) {
167
- const notFoundKeys = pathKeys.filter(function (val) {
168
- return foundKeys.indexOf(val) === -1;
169
- });
170
-
171
- if (node.warnUnmatched && notFoundKeys.length > 0) {
172
- const unmatchedStr = notFoundKeys
173
- .map(function (key) {
174
- return `${pathTopicName}["${key}"]`;
175
- })
176
- .join(', ');
177
- node.warn(`join-wait ${unmatchedStr} doesn't exist in pathsToWait or pathsToExpire!`, [msg, null]);
178
- }
179
136
 
180
- if (foundKeys.length === 0) {
137
+ if (msg.reset === true) {
138
+ if (Object.prototype.hasOwnProperty.call(node.queues, evalCtx.group)) {
139
+ dropQueue(evalCtx.group);
140
+ }
141
+ updateStatus();
142
+ done();
181
143
  return;
182
144
  }
183
- }
184
145
 
185
- // correlation topic
186
-
187
- let topic;
188
- try {
189
- if (node.topicType === 'jsonata') {
190
- topic = node.topic.evaluate({
191
- msg: msg,
146
+ if (node.mapPayload) {
147
+ evalCtx.pathKeys.forEach((k) => {
148
+ evalCtx.pathTopic[k] = msg.payload;
192
149
  });
193
- } else {
194
- topic = node.topic
195
- ? RED.util.evaluateNodeProperty(node.topic, node.topicType, node, msg)
196
- : '_join-wait-node';
197
150
  }
198
- } catch (err) {
199
- node.error(`join-wait.invalid-expr topic ${err.message}`);
200
- return;
201
- }
202
-
203
- // map payload
204
151
 
205
- if (node.mapPayload) {
206
- pathKeys.forEach(function (item) {
207
- pathTopic[item] = msg.payload;
208
- });
152
+ enqueueAndEvaluate(msg, evalCtx, send);
153
+ updateStatus();
154
+ done();
155
+ /* c8 ignore next 5 */
156
+ } catch (err) {
157
+ node.error(`join-wait unhandled: ${err && err.message}`, msg);
158
+ node.status({ fill: 'red', shape: 'ring', text: 'error' });
159
+ done(err);
209
160
  }
161
+ });
210
162
 
211
- //
212
- // start processing
213
- //
214
-
215
- initQueue(topic);
216
- const group = node.paths[topic];
217
- group.queue.push([Date.now(), msg, pathTopic]);
163
+ // Builds the per-message evaluation context. Returns null after
164
+ // emitting an appropriate error (so the caller just bails).
165
+ async function buildEvalContext(msg) {
166
+ const overrides = resolveOverrides(msg);
167
+ if (!validateOverrides(msg, overrides)) return null;
218
168
 
219
- if ((hasExpirePath && clearQueueAllWithOutput(topic)) || clearQueueExpiredByTime(topic)) {
220
- return;
221
- }
169
+ const pathTopic = resolvePathTopic(msg);
170
+ if (!pathTopic) return null;
222
171
 
223
- const pathData = group.queue.map(function (q) {
224
- return q[2];
225
- });
226
- const allPathKeys = pathData.map(function (q) {
227
- return Object.keys(q);
228
- });
172
+ const patterns = compilePatterns(msg, overrides);
173
+ if (!patterns) return null;
229
174
 
230
- const numToKeep = node.exactOrder
231
- ? findAllPathsExactOrder(allPathKeys, pathsToWait, node.useRegex)
232
- : findAllPathsAnyOrder(allPathKeys, pathsToWait, node.useRegex);
175
+ const pathKeys = Object.keys(pathTopic);
176
+ const foundKeys = pathKeys.filter((k) => matchesAny(k, patterns.wait, overrides.useRegex));
177
+ const hasExpirePath = patterns.expire && anyMatches(pathKeys, patterns.expire, overrides.useRegex);
233
178
 
234
- if (numToKeep !== null) {
235
- clearQueueIfCompleteIsSet(topic, msg) || clearQueueExpiredByOrder(topic, numToKeep);
236
- return;
179
+ if (!hasExpirePath) {
180
+ warnUnmatched(msg, pathKeys, foundKeys);
181
+ if (foundKeys.length === 0) return null;
237
182
  }
238
183
 
239
- // all paths found
184
+ const group = await resolveCorrelationGroup(msg);
185
+ if (group === RESOLVE_FAILED) return null;
240
186
 
241
- const num = node.firstMsg ? 0 : group.queue.length - 1;
242
- let output = group.queue[num][1];
243
- output[node.pathTopic] = pathData.reduce(function (a, b) {
244
- return Object.assign(a, b);
245
- }, {});
246
- node.send([output, null]);
247
- clearQueueAllNoOutput(topic);
248
- });
249
-
250
- function convertToRegex(arr) {
251
- if (!Array.isArray(arr)) {
252
- return arr;
253
- }
254
-
255
- return arr.map(function (pattern) {
256
- return new RegExp(pattern);
257
- });
187
+ return {
188
+ pathTopic,
189
+ pathKeys,
190
+ patterns,
191
+ hasExpirePath,
192
+ useRegex: overrides.useRegex,
193
+ group,
194
+ };
258
195
  }
259
196
 
260
- function flatten(arr) {
261
- return [].concat.apply([], arr);
197
+ // Pick a per-message override array, or fall back to the node config.
198
+ function arrayOrFallback(msgValue, nodeValue) {
199
+ if (msgValue === undefined) return nodeValue;
200
+ return Array.isArray(msgValue) ? msgValue : false;
262
201
  }
263
202
 
264
- function condenseWithCount(arr) {
265
- arr = arr.reduce((map, key) => map.set(key, (map.get(key) || 0) + 1), new Map());
266
- return Array.from(arr, ([name, value]) => ({ name, value }));
203
+ // Per-message one-shot overrides. None of these mutate node-level state.
204
+ function resolveOverrides(msg) {
205
+ return {
206
+ pathsToWait: Array.isArray(msg.pathsToWait) ? msg.pathsToWait : node.pathsToWait,
207
+ pathsToExpire: arrayOrFallback(msg.pathsToExpire, node.pathsToExpire),
208
+ useRegex: Object.prototype.hasOwnProperty.call(msg, 'useRegex') ? msg.useRegex === true : node.useRegex,
209
+ };
267
210
  }
268
211
 
269
- function regexIndexOf(arr, needle) {
270
- let result = -1;
271
-
272
- arr.some(function (p, i) {
273
- if (p.test(needle)) {
274
- result = i;
275
- return true;
276
- }
277
- });
278
- return result;
212
+ function validateOverrides(msg, overrides) {
213
+ if (!Array.isArray(overrides.pathsToWait) || overrides.pathsToWait.length === 0) {
214
+ node.error('join-wait pathsToWait must be a defined array.', msg);
215
+ node.status({ fill: 'red', shape: 'ring', text: 'pathsToWait empty' });
216
+ return false;
217
+ }
218
+ if (
219
+ msg.pathsToExpire !== undefined &&
220
+ (!Array.isArray(msg.pathsToExpire) || msg.pathsToExpire.length === 0)
221
+ ) {
222
+ node.error('join-wait pathsToExpire must be undefined or an array.', msg);
223
+ node.status({ fill: 'red', shape: 'ring', text: 'pathsToExpire invalid' });
224
+ return false;
225
+ }
226
+ if (Array.isArray(overrides.pathsToExpire) && hasDuplicatePath(overrides.pathsToExpire)) {
227
+ node.error(`join-wait pathsToExpire cannot have duplicate entries: ${overrides.pathsToExpire}`, msg);
228
+ node.status({ fill: 'red', shape: 'ring', text: 'duplicate expire path' });
229
+ return false;
230
+ }
231
+ return true;
279
232
  }
280
233
 
281
- function hasDuplicatePath(arr) {
282
- return arr.some(function (p, index) {
283
- return arr.indexOf(p) !== index;
284
- });
285
- }
234
+ function resolvePathTopic(msg) {
235
+ const propName = `${node.pathFieldType}.${node.pathField}`;
236
+ const value = RED.util.evaluateNodeProperty(node.pathField, node.pathFieldType, node, msg);
286
237
 
287
- function findAnyPath(msgPaths, arr, useRegex) {
288
- return msgPaths.some(function (p) {
289
- if (useRegex) {
290
- return arr.some(function (pattern) {
291
- return pattern.test(p);
292
- });
293
- } else {
294
- return arr.includes(p);
295
- }
296
- });
238
+ if (!value) {
239
+ node.error(`join-wait "${propName}" is undefined or not set.`, msg);
240
+ node.status({ fill: 'red', shape: 'ring', text: `${propName} unset` });
241
+ return null;
242
+ }
243
+ if (typeof value === 'string') return { [value]: true };
244
+ if (typeof value !== 'object' || Array.isArray(value)) {
245
+ node.error(`join-wait "${propName}" must be a string or an object, e.g., ${propName} = 'value'.`, [
246
+ msg,
247
+ null,
248
+ ]);
249
+ node.status({ fill: 'red', shape: 'ring', text: `${propName} invalid` });
250
+ return null;
251
+ }
252
+ // Shallow-clone — mapPayload would otherwise mutate the caller's object.
253
+ return Object.assign({}, value);
297
254
  }
298
255
 
299
- function findAllPathsAnyOrder(arr, waitPaths, useRegex) {
300
- const waitMap = condenseWithCount(waitPaths);
301
- const keys = flatten(arr);
302
- const result = countPathsAnyOrder(keys, waitMap, useRegex);
303
-
304
- const allPathsFound = result.every(function (p) {
305
- return p === true;
306
- });
307
-
308
- if (allPathsFound) {
256
+ function compilePatterns(msg, overrides) {
257
+ if (!overrides.useRegex) {
258
+ return { wait: overrides.pathsToWait, expire: overrides.pathsToExpire };
259
+ }
260
+ try {
261
+ return {
262
+ wait: compileRegex(overrides.pathsToWait),
263
+ expire: compileRegex(overrides.pathsToExpire),
264
+ };
265
+ } catch (err) {
266
+ node.error(`join-wait.regex-expr ${err.message}`, msg);
267
+ node.status({ fill: 'red', shape: 'ring', text: 'invalid regex' });
309
268
  return null;
310
269
  }
270
+ }
311
271
 
312
- const originalString = result.toString();
272
+ function warnUnmatched(msg, pathKeys, foundKeys) {
273
+ if (!node.warnUnmatched) return;
274
+ const propName = `${node.pathFieldType}.${node.pathField}`;
275
+ const unknown = pathKeys.filter((k) => foundKeys.indexOf(k) === -1);
276
+ if (unknown.length === 0) return;
277
+ const list = unknown.map((k) => `${propName}["${k}"]`).join(', ');
278
+ node.warn(`join-wait ${list} doesn't exist in pathsToWait or pathsToExpire!`, msg);
279
+ }
313
280
 
314
- for (let i = 0; i < arr.length; i++) {
315
- const newKeys = flatten(arr.slice(i + 1));
316
- const expireByOne = countPathsAnyOrder(newKeys, waitMap, useRegex);
317
- if (originalString !== expireByOne.toString()) {
318
- return arr.length - i;
281
+ // jsonata v2 returns a Promise; msg/flow/global types resolve sync.
282
+ async function resolveCorrelationGroup(msg) {
283
+ try {
284
+ if (node.correlatorType === 'jsonata') {
285
+ return node.correlator ? await node.correlator.evaluate({ msg: msg }) : DEFAULT_GROUP;
286
+ }
287
+ if (node.correlator) {
288
+ return RED.util.evaluateNodeProperty(node.correlator, node.correlatorType, node, msg);
319
289
  }
290
+ return DEFAULT_GROUP;
291
+ } catch (err) {
292
+ node.error(`join-wait.invalid-expr topic ${err.message}`, msg);
293
+ node.status({ fill: 'red', shape: 'ring', text: 'invalid correlator' });
294
+ return RESOLVE_FAILED;
320
295
  }
321
-
322
- /* istanbul ignore next */
323
- return 0;
324
- }
325
-
326
- function countPathsAnyOrder(keys, waitMap, useRegex) {
327
- let used = [];
328
-
329
- return waitMap.map(function (p) {
330
- const count = keys.filter(function (val, i) {
331
- if (used.indexOf(i) !== -1) {
332
- return false;
333
- }
334
- const found = useRegex ? p.name.test(val) : p.name === val;
335
- if (!found) {
336
- return false;
337
- }
338
-
339
- used.push(i);
340
- return true;
341
- }).length;
342
-
343
- return count < p.value ? count : true;
344
- });
345
296
  }
346
297
 
347
- function findAllPathsExactOrder(arr, waitPaths, useRegex) {
348
- let start = 0;
349
- let marker = false;
350
-
351
- for (let i = 0; i < arr.length; i++) {
352
- for (let j = 0; j < arr[i].length; j++) {
353
- const path = arr[i][j];
354
-
355
- let offBy = marker === false ? 0 : marker + 1;
356
- const unusedWaitPaths = waitPaths.slice(offBy);
357
- let index = useRegex ? regexIndexOf(unusedWaitPaths, path) : unusedWaitPaths.indexOf(path);
358
-
359
- if (index === -1) {
360
- /* istanbul ignore else */
361
- if (offBy > 0) {
362
- index = useRegex ? regexIndexOf(waitPaths, path) : waitPaths.indexOf(path);
363
- if (index > 0) {
364
- marker = false;
365
- }
366
- }
367
- } else {
368
- index += offBy;
369
- }
370
-
371
- if (index === 0) {
372
- start = i;
373
- } else if (index === -1 || marker === false) {
374
- continue;
375
- } else if (index < marker || index > marker + 1) {
376
- marker = false;
377
- continue;
378
- }
298
+ function enqueueAndEvaluate(msg, evalCtx, send) {
299
+ initQueue(evalCtx.group);
300
+ const queue = node.queues[evalCtx.group];
301
+ queue.queue.push([Date.now(), msg, evalCtx.pathTopic]);
302
+
303
+ // If this message hit a reset path, drain everything to the expired
304
+ // output. Otherwise, age out anything that's already past the timeout
305
+ // window (keeps long-idle queues from snowballing). Either drain may
306
+ // empty the queue entirely, in which case we're done.
307
+ if (evalCtx.hasExpirePath) {
308
+ if (flushQueueAsExpired(evalCtx.group)) return;
309
+ } else if (flushTimedOutEntries(evalCtx.group)) {
310
+ return;
311
+ }
379
312
 
380
- if (index === waitPaths.length - 1) {
381
- return null;
382
- }
313
+ const allPathKeys = queue.queue.map((q) => Object.keys(q[2]));
314
+ const result = node.exactOrder
315
+ ? findAllPathsExactOrder(allPathKeys, evalCtx.patterns.wait, evalCtx.useRegex)
316
+ : findAllPathsAnyOrder(allPathKeys, evalCtx.patterns.wait, evalCtx.useRegex);
383
317
 
384
- marker = index;
318
+ if (!result.matched) {
319
+ if (!flushOnMsgComplete(evalCtx.group, msg)) {
320
+ flushTrailingEntries(evalCtx.group, result.keep);
385
321
  }
322
+ return;
386
323
  }
387
324
 
388
- return marker === false ? 0 : arr.length - start;
325
+ // All required paths matched emit success and clear.
326
+ const baseIndex = node.useFirstAsBase ? 0 : queue.queue.length - 1;
327
+ const output = queue.queue[baseIndex][1];
328
+ output[node.pathField] = queue.queue.map((q) => q[2]).reduce((a, b) => Object.assign(a, b), {});
329
+ send([output, null]);
330
+ dropQueue(evalCtx.group);
389
331
  }
390
332
 
391
- // queue & timer handling
333
+ // ---------- queue + timer management ----------
392
334
 
393
- function initQueue(topic) {
394
- if (!Object.prototype.hasOwnProperty.call(node.paths, topic)) {
395
- node.paths[topic] = {
396
- queue: [],
397
- };
398
- makeNewQueueTimer(topic, node.timeout);
335
+ function initQueue(group) {
336
+ if (!Object.prototype.hasOwnProperty.call(node.queues, group)) {
337
+ node.queues[group] = { queue: [] };
338
+ scheduleQueueTimer(group, node.timeout);
399
339
  }
400
340
  }
401
341
 
402
- function makeNewQueueTimer(topic, timeout) {
403
- const group = node.paths[topic];
404
-
405
- group.timeOut = setTimeout(function () {
406
- if (clearQueueExpiredByTime(topic)) {
342
+ function scheduleQueueTimer(group, delayMs) {
343
+ const entry = node.queues[group];
344
+ entry.timeOut = setTimeout(function () {
345
+ if (flushTimedOutEntries(group)) {
346
+ updateStatus();
407
347
  return;
408
- } else {
409
- const next = group.queue[0][0] + node.timeout - Date.now();
410
- makeNewQueueTimer(topic, next);
411
348
  }
412
- }, timeout);
349
+ const next = entry.queue[0][0] + node.timeout - Date.now();
350
+ scheduleQueueTimer(group, next);
351
+ }, delayMs);
413
352
  }
414
353
 
415
- // returns boolean if queue is empty (= true)
416
-
417
- function clearQueueAllNoOutput(topic) {
418
- return _queueDeletionHandler(topic, false, false, 0);
354
+ // Drain helpers — each returns true if the queue was fully emptied
355
+ // (and removed from node.queues), false if entries remain.
356
+ function dropQueue(group) {
357
+ return drainQueue(group, { sendExpired: false, expireByTime: false, keep: 0 });
419
358
  }
420
359
 
421
- function clearQueueAllWithOutput(topic) {
422
- return _queueDeletionHandler(topic, true, false, 0);
360
+ function flushQueueAsExpired(group) {
361
+ return drainQueue(group, { sendExpired: true, expireByTime: false, keep: 0 });
423
362
  }
424
363
 
425
- function clearQueueIfCompleteIsSet(topic, msg) {
426
- if (!node.disableComplete && Object.prototype.hasOwnProperty.call(msg, 'complete')) {
427
- return clearQueueAllWithOutput(topic);
364
+ // `msg.complete` is consulted only when the wait paths haven't yet
365
+ // matched (the call site sits under `if (numToKeep !== null)`). If
366
+ // they have matched, the success path emits + drains and `complete`
367
+ // is irrelevant. Net effect: `complete` short-circuits a partial
368
+ // queue to the expired output, but never overrides a successful
369
+ // match. Set Ignore msg.complete to disable.
370
+ function flushOnMsgComplete(group, msg) {
371
+ if (!node.ignoreMsgComplete && Object.prototype.hasOwnProperty.call(msg, 'complete')) {
372
+ return flushQueueAsExpired(group);
428
373
  }
429
374
  return false;
430
375
  }
431
376
 
432
- function clearQueueExpiredByOrder(topic, numToKeep) {
433
- return _queueDeletionHandler(topic, true, false, numToKeep);
377
+ function flushTrailingEntries(group, keep) {
378
+ return drainQueue(group, { sendExpired: true, expireByTime: false, keep: keep });
434
379
  }
435
380
 
436
- function clearQueueExpiredByTime(topic) {
437
- return _queueDeletionHandler(topic, true, true, 0);
381
+ function flushTimedOutEntries(group) {
382
+ return drainQueue(group, { sendExpired: true, expireByTime: true, keep: 0 });
438
383
  }
439
384
 
440
- function _queueDeletionHandler(topic, sendExpired, checkExpireTime, numToKeep) {
441
- const group = node.paths[topic];
442
- const isExpired = function () {
443
- return checkExpireTime ? group.queue[0][0] < Date.now() - node.timeout : true;
444
- };
445
-
446
- while (group.queue.length > numToKeep && isExpired()) {
447
- const expired = group.queue.shift();
385
+ // Pops entries off the front of the group's queue. With `sendExpired`,
386
+ // each popped entry is forwarded to the expired output. With
387
+ // `expireByTime`, popping stops once the head entry is still within
388
+ // the timeout window. Returns true once the queue is empty + removed.
389
+ function drainQueue(group, opts) {
390
+ const entry = node.queues[group];
391
+ const sendExpired = opts.sendExpired;
392
+ const expireByTime = opts.expireByTime;
393
+ const keep = opts.keep;
394
+ const isExpired = () => (expireByTime ? entry.queue[0][0] < Date.now() - node.timeout : true);
395
+
396
+ while (entry.queue.length > keep && isExpired()) {
397
+ const popped = entry.queue.shift();
448
398
  if (sendExpired) {
449
- const msg = Object.assign(expired[1], { paths: expired[2] });
450
- node.send([null, msg]);
399
+ const out = popped[1];
400
+ out[node.pathField] = popped[2];
401
+ node.send([null, out]);
451
402
  }
452
403
  }
453
404
 
454
- if (group.queue.length !== 0) {
455
- return false;
456
- }
405
+ if (entry.queue.length !== 0) return false;
457
406
 
458
- clearTimeout(group.timeOut);
459
- delete node.paths[topic];
407
+ clearTimeout(entry.timeOut);
408
+ delete node.queues[group];
460
409
  return true;
461
410
  }
411
+
412
+ function updateStatus() {
413
+ const groups = Object.keys(node.queues);
414
+ if (groups.length === 0) {
415
+ node.status({});
416
+ return;
417
+ }
418
+
419
+ // Single group: show progress toward completion ("2/3 received").
420
+ // Multiple groups: aggregate counts since per-group progress would
421
+ // be misleading.
422
+ if (groups.length === 1) {
423
+ const g = groups[0];
424
+ const queued = node.queues[g].queue.length;
425
+ const required = Array.isArray(node.pathsToWait) ? node.pathsToWait.length : 0;
426
+ const text = required > 0 ? `${Math.min(queued, required)}/${required} received` : `queued: ${queued}`;
427
+ node.status({ fill: 'blue', shape: 'dot', text: text });
428
+ return;
429
+ }
430
+
431
+ let total = 0;
432
+ for (const g of groups) total += node.queues[g].queue.length;
433
+ node.status({
434
+ fill: 'blue',
435
+ shape: 'dot',
436
+ text: `groups: ${groups.length}, queued: ${total}`,
437
+ });
438
+ }
462
439
  }
463
440
 
464
441
  RED.nodes.registerType('join-wait', JoinWaitNode);
442
+
443
+ // Admin endpoint backing the "Persist store" dropdown in the editor.
444
+ // Returns the configured context stores so the UI can render a typo-proof
445
+ // <select> rather than a free-text field.
446
+ RED.httpAdmin.get('/join-wait/stores', RED.auth.needsPermission('flows.read'), function (req, res) {
447
+ const cs = (RED.settings && RED.settings.contextStorage) || {};
448
+ const out = [];
449
+ for (const name of Object.keys(cs)) {
450
+ const entry = cs[name];
451
+ const m = typeof entry === 'string' ? entry : entry && entry.module;
452
+ out.push({ name: name, module: m || null });
453
+ }
454
+ res.json(out);
455
+ });
465
456
  };