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/CHANGELOG.md +181 -0
- package/README.md +190 -1192
- package/examples/01-quickstart.json +96 -0
- package/examples/02-correlation.json +139 -0
- package/examples/03-reset.json +103 -0
- package/examples/04-regex.json +94 -0
- package/examples/05-exact-order.json +103 -0
- package/join-wait.html +481 -185
- package/join-wait.js +352 -361
- package/lib/config.js +26 -0
- package/lib/matcher.js +114 -0
- package/lib/persist.js +60 -0
- package/lib/store.js +64 -0
- package/package.json +91 -58
- package/.eslintrc.js +0 -24
- package/.github/FUNDING.yml +0 -12
- package/.nycrc.json +0 -11
- package/.prettierrc +0 -6
- package/.spelling +0 -20
- package/.travis.yml +0 -20
- package/docs/_config.yml +0 -1
- package/docs/example1.png +0 -0
- package/docs/example2.png +0 -0
- package/docs/example3.png +0 -0
- package/docs/example4.png +0 -0
- package/docs/example5.png +0 -0
- package/docs/example6.png +0 -0
- package/docs/venmo.png +0 -0
- package/test/flows.js +0 -33
- package/test/test_spec.js +0 -1411
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
60
|
+
node.correlator = jsonata(node.correlator);
|
|
32
61
|
} catch (err) {
|
|
33
|
-
|
|
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
|
-
|
|
39
|
-
this.pathTopicType = config.pathTopicType;
|
|
68
|
+
node.timeout = (Number(config.timeout) || 15000) * (Number(config.timeoutUnits) || 1);
|
|
40
69
|
|
|
41
|
-
|
|
42
|
-
this.firstMsg = config.firstMsg === 'true';
|
|
43
|
-
this.mapPayload = config.mapPayload === 'true';
|
|
70
|
+
// ---------- persistence ----------
|
|
44
71
|
|
|
45
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 (
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
169
|
+
const pathTopic = resolvePathTopic(msg);
|
|
170
|
+
if (!pathTopic) return null;
|
|
222
171
|
|
|
223
|
-
const
|
|
224
|
-
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
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 (
|
|
235
|
-
|
|
236
|
-
return;
|
|
179
|
+
if (!hasExpirePath) {
|
|
180
|
+
warnUnmatched(msg, pathKeys, foundKeys);
|
|
181
|
+
if (foundKeys.length === 0) return null;
|
|
237
182
|
}
|
|
238
183
|
|
|
239
|
-
|
|
184
|
+
const group = await resolveCorrelationGroup(msg);
|
|
185
|
+
if (group === RESOLVE_FAILED) return null;
|
|
240
186
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
return
|
|
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
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if (
|
|
318
|
-
return
|
|
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
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
333
|
+
// ---------- queue + timer management ----------
|
|
392
334
|
|
|
393
|
-
function initQueue(
|
|
394
|
-
if (!Object.prototype.hasOwnProperty.call(node.
|
|
395
|
-
node.
|
|
396
|
-
|
|
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
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
349
|
+
const next = entry.queue[0][0] + node.timeout - Date.now();
|
|
350
|
+
scheduleQueueTimer(group, next);
|
|
351
|
+
}, delayMs);
|
|
413
352
|
}
|
|
414
353
|
|
|
415
|
-
// returns
|
|
416
|
-
|
|
417
|
-
function
|
|
418
|
-
return
|
|
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
|
|
422
|
-
return
|
|
360
|
+
function flushQueueAsExpired(group) {
|
|
361
|
+
return drainQueue(group, { sendExpired: true, expireByTime: false, keep: 0 });
|
|
423
362
|
}
|
|
424
363
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
|
433
|
-
return
|
|
377
|
+
function flushTrailingEntries(group, keep) {
|
|
378
|
+
return drainQueue(group, { sendExpired: true, expireByTime: false, keep: keep });
|
|
434
379
|
}
|
|
435
380
|
|
|
436
|
-
function
|
|
437
|
-
return
|
|
381
|
+
function flushTimedOutEntries(group) {
|
|
382
|
+
return drainQueue(group, { sendExpired: true, expireByTime: true, keep: 0 });
|
|
438
383
|
}
|
|
439
384
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
|
450
|
-
node.
|
|
399
|
+
const out = popped[1];
|
|
400
|
+
out[node.pathField] = popped[2];
|
|
401
|
+
node.send([null, out]);
|
|
451
402
|
}
|
|
452
403
|
}
|
|
453
404
|
|
|
454
|
-
if (
|
|
455
|
-
return false;
|
|
456
|
-
}
|
|
405
|
+
if (entry.queue.length !== 0) return false;
|
|
457
406
|
|
|
458
|
-
clearTimeout(
|
|
459
|
-
delete node.
|
|
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
|
};
|