@yarkivaev/scada 2.3.90 → 2.3.112

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.
Files changed (35) hide show
  1. package/README.md +6 -1
  2. package/charts/scada/Chart.yaml +6 -0
  3. package/charts/scada/templates/_helpers.tpl +63 -0
  4. package/charts/scada/templates/clickhouse.yaml +57 -0
  5. package/charts/scada/templates/collector.yaml +115 -0
  6. package/charts/scada/templates/postgres.yaml +70 -0
  7. package/charts/scada/templates/rabbitmq.yaml +150 -0
  8. package/charts/scada/templates/site-server.yaml +110 -0
  9. package/charts/scada/templates/supervisor-sink.yaml +64 -0
  10. package/charts/scada/values.yaml +30 -0
  11. package/index.js +10 -0
  12. package/package.json +3 -1
  13. package/src/application/deploy/appReleases.js +50 -0
  14. package/src/application/deploy/applyIncludes.js +31 -0
  15. package/src/application/deploy/expand.js +49 -0
  16. package/src/application/deploy/expandDevices.js +135 -0
  17. package/src/application/deploy/expandFlows.js +281 -0
  18. package/src/application/deploy/expandNamespace.js +193 -0
  19. package/src/application/deploy/expandPattern.js +207 -0
  20. package/src/application/deploy/expandProfiles.js +70 -0
  21. package/src/application/deploy/frozen.js +23 -0
  22. package/src/application/deploy/mergeDocuments.js +98 -0
  23. package/src/application/deploy/paths.js +86 -0
  24. package/src/application/deploy/pointPaths.js +20 -0
  25. package/src/application/deploy/readDocument.js +169 -0
  26. package/src/application/deploy/releaseEdges.js +185 -0
  27. package/src/application/deploy/releaseNodes.js +118 -0
  28. package/src/application/deploy/releaseValues.js +200 -0
  29. package/src/application/deploy/releases.js +115 -0
  30. package/src/application/deploy/withCollectors.js +28 -0
  31. package/src/bin/modbus-to-mqtt.js +57 -0
  32. package/src/infrastructure/ingest/mqtt/addressWindow.js +79 -0
  33. package/src/infrastructure/ingest/mqtt/modbusPublish.js +277 -0
  34. package/src/infrastructure/ingest/mqtt/registerPoint.js +90 -0
  35. package/src/infrastructure/ingest/mqtt/registerPublish.js +84 -0
package/index.js CHANGED
@@ -70,6 +70,7 @@ export { default as stompAlerts } from './src/infrastructure/messaging/stomp/ale
70
70
  export { default as stompAlertsInit } from './src/infrastructure/messaging/stomp/alerts/stompAlertsInit.js';
71
71
  export { default as mqttMetrics } from './src/infrastructure/ingest/mqtt/mqttMetrics.js';
72
72
  export { default as modbusMqtt } from './src/infrastructure/ingest/mqtt/modbusMqtt.js';
73
+ export { default as modbusPublish } from './src/infrastructure/ingest/mqtt/modbusPublish.js';
73
74
  export { default as mx210Tcp } from './src/infrastructure/ingest/modbus/mx210Tcp.js';
74
75
  export { default as silentStreams } from './src/infrastructure/ingest/modbus/silentStreams.js';
75
76
  export { default as activityTracking } from './src/infrastructure/ingest/activity/activityTracking.js';
@@ -92,3 +93,12 @@ export { default as coverageSpans } from './src/domain/span/coverageSpans.js';
92
93
  export { default as coverageCursor } from './src/application/mcp/coverageCursor.js';
93
94
  export { default as sqlGuard } from './src/domain/sql/sqlGuard.js';
94
95
  export { default as analyticsMcp } from './src/application/mcp/analyticsMcp.js';
96
+ export { default as expand } from './src/application/deploy/expand.js';
97
+ export { default as readDocument } from './src/application/deploy/readDocument.js';
98
+ export { default as mergeDocuments } from './src/application/deploy/mergeDocuments.js';
99
+ export { default as releases } from './src/application/deploy/releases.js';
100
+ export { default as paths } from './src/application/deploy/paths.js';
101
+ export { default as appReleases } from './src/application/deploy/appReleases.js';
102
+ export { default as withCollectors } from './src/application/deploy/withCollectors.js';
103
+ export { default as pointPaths } from './src/application/deploy/pointPaths.js';
104
+ export { clusterOf, serverFor } from './src/application/deploy/releaseNodes.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada",
3
- "version": "2.3.90",
3
+ "version": "2.3.112",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,6 +16,7 @@
16
16
  "./package.json": "./package.json"
17
17
  },
18
18
  "bin": {
19
+ "modbus-to-mqtt": "./src/bin/modbus-to-mqtt.js",
19
20
  "supervisor-sink": "./src/bin/supervisor-sink.js"
20
21
  },
21
22
  "scripts": {
@@ -48,6 +49,7 @@
48
49
  },
49
50
  "files": [
50
51
  "index.js",
52
+ "charts/",
51
53
  "db/",
52
54
  "src/"
53
55
  ]
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Values a profile app passes to its own chart.
3
+ *
4
+ * An app that already carries chart values keeps them. Otherwise the
5
+ * image, node port, and url are the values.
6
+ *
7
+ * @param {object} app - profile app
8
+ * @returns {object} chart values
9
+ * @example
10
+ * valuesOf({ image: 'example.invalid/hmi:1', name: 'frontend', nodePort: 30080 });
11
+ */
12
+ function valuesOf(app) {
13
+ if (app.values) {
14
+ return { ...app.values };
15
+ }
16
+ const values = {};
17
+ if (app.image) {
18
+ values.image = app.image;
19
+ }
20
+ if (app.nodePort) {
21
+ values.nodePort = app.nodePort;
22
+ }
23
+ if (app.url) {
24
+ values.url = app.url;
25
+ }
26
+ return values;
27
+ }
28
+
29
+ /**
30
+ * Helm releases for profile apps beside scada.
31
+ *
32
+ * The scada chart does not draw these. Each app is installed by its own
33
+ * chart into the same cluster.
34
+ *
35
+ * @example
36
+ * appReleases([{ image: 'example.invalid/hmi:1', name: 'frontend' }], 'cell-a');
37
+ *
38
+ * @param {object[]} apps - releases().apps
39
+ * @param {string} cluster - release cluster
40
+ * @returns {object[]} chart releases
41
+ */
42
+ export default function appReleases(apps, cluster) {
43
+ return (apps || []).map((app) => {
44
+ return {
45
+ chart: app.chart || app.name,
46
+ name: `${cluster}-${app.name}`,
47
+ values: valuesOf(app)
48
+ };
49
+ });
50
+ }
@@ -0,0 +1,31 @@
1
+ import mergeDocuments from './mergeDocuments.js';
2
+
3
+ /**
4
+ * Loads `include` entries, then overlays the rest of the document.
5
+ *
6
+ * @param {object} parsed - one parsed file
7
+ * @param {Function|undefined} load - file reader
8
+ * @param {string[]} stack - includes already being read
9
+ * @param {Function} read - document reader
10
+ * @returns {object} combined document
11
+ * @example
12
+ * applyIncludes({ include: ['maps.yaml'] }, () => { return 'mappings: {}\n'; }, [], readDocument);
13
+ */
14
+ export default function applyIncludes(parsed, load, stack, read) {
15
+ if (!Object.hasOwn(parsed, 'include')) {
16
+ return parsed;
17
+ }
18
+ if (typeof load !== 'function') {
19
+ throw new Error('system document include needs a reader');
20
+ }
21
+ const listed = Array.isArray(parsed.include) ? parsed.include : [parsed.include];
22
+ const rest = { ...parsed };
23
+ delete rest.include;
24
+ const catalog = listed.reduce((acc, name) => {
25
+ if (stack.includes(name)) {
26
+ throw new Error(`system document include cycles ${name}`);
27
+ }
28
+ return mergeDocuments(acc, read(load(name), load, stack.concat(name)));
29
+ }, {});
30
+ return mergeDocuments(catalog, rest);
31
+ }
@@ -0,0 +1,49 @@
1
+ import { expandTree, treePaths } from './expandNamespace.js';
2
+ import { expandProfiles } from './expandProfiles.js';
3
+ import { expandServers } from './expandFlows.js';
4
+ import frozen from './frozen.js';
5
+
6
+ /**
7
+ * Rejects a server whose profile is missing.
8
+ *
9
+ * @param {object[]} servers - declared servers
10
+ * @param {object} profiles - resolved profiles
11
+ * @returns {void}
12
+ * @example
13
+ * assertProfiles([], {});
14
+ */
15
+ function assertProfiles(servers, profiles) {
16
+ servers.forEach((server) => {
17
+ const label = server.profile && server.profile.id;
18
+ if (!label || !profiles[label]) {
19
+ throw new Error(`system document has no profile ${label}`);
20
+ }
21
+ });
22
+ }
23
+
24
+ /**
25
+ * Expands templates, mappings, patterns, and profile `extends`.
26
+ *
27
+ * The result is a concrete document. It does not install a cluster.
28
+ *
29
+ * @param {object} document - system document
30
+ * @returns {object} expanded document
31
+ * @example
32
+ * expand({ namespace: [{ id: 'mill' }], profiles: {}, servers: [] });
33
+ */
34
+ export default function expand(document) {
35
+ if (!document || typeof document !== 'object' || Array.isArray(document)) {
36
+ throw new Error('system document is empty');
37
+ }
38
+ const namespace = expandTree(document.namespace, document.templates || {});
39
+ const paths = treePaths(namespace);
40
+ const profiles = expandProfiles(document.profiles || {});
41
+ assertProfiles(document.servers || [], profiles);
42
+ const servers = expandServers(document.servers || [], paths, document.mappings || {});
43
+ return frozen({
44
+ mappings: document.mappings || {},
45
+ namespace,
46
+ profiles,
47
+ servers
48
+ });
49
+ }
@@ -0,0 +1,135 @@
1
+ import { picked, zipped } from './expandPattern.js';
2
+
3
+ /**
4
+ * Reads an object entry as a path.
5
+ *
6
+ * @param {string|object} entry - object path or record
7
+ * @returns {string} path
8
+ * @example
9
+ * asPath('mill/unit');
10
+ */
11
+ function asPath(entry) {
12
+ if (typeof entry === 'string' && entry.length > 0) {
13
+ return entry;
14
+ }
15
+ if (entry && typeof entry.id === 'string' && entry.id.length > 0) {
16
+ return entry.id;
17
+ }
18
+ throw new Error('system document object path is empty');
19
+ }
20
+
21
+ /**
22
+ * Rejects a flow name that is only an object path.
23
+ *
24
+ * @param {Array<string|object>} objects - server objects
25
+ * @param {string} path - flow name
26
+ * @returns {void}
27
+ * @example
28
+ * assertLeaf(['mill/unit'], 'mill/unit/electrical');
29
+ */
30
+ export function assertLeaf(objects, path) {
31
+ const listed = (objects || []).map((entry) => {
32
+ return asPath(entry);
33
+ });
34
+ if (listed.includes(path)) {
35
+ throw new Error(`system document flow name ${path} is an object path`);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Expands object patterns to namespace paths.
41
+ *
42
+ * @param {Array<string|object>|undefined} entries - declared objects
43
+ * @param {string[]} paths - namespace paths
44
+ * @returns {Array<string|object>} concrete objects
45
+ * @example
46
+ * expandObjects(['mill/*'], ['mill/unit']);
47
+ */
48
+ export function expandObjects(entries, paths) {
49
+ return (entries || []).flatMap((entry) => {
50
+ return picked(asPath(entry), paths, 'path', true).map((path) => {
51
+ if (typeof entry === 'string') {
52
+ return path;
53
+ }
54
+ return { ...entry, id: path };
55
+ });
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Copies a mapping id or an inline map onto one flow.
61
+ *
62
+ * @param {object} flow - one flow
63
+ * @param {string[]} paths - namespace paths
64
+ * @param {object} mappings - shared point maps
65
+ * @returns {object} flow with a concrete map
66
+ * @example
67
+ * withMapping({ mapping: 'drive', name: 'mill/electrical' }, [], {});
68
+ */
69
+ function withMapping(flow, paths, mappings) {
70
+ if (!Object.hasOwn(flow, 'mapping')) {
71
+ return flow;
72
+ }
73
+ const token = flow.mapping;
74
+ const resolved = typeof token === 'string' ? mappings[token] : token;
75
+ if (!resolved || typeof resolved !== 'object' || Array.isArray(resolved)) {
76
+ const label = typeof token === 'string' ? token : 'inline';
77
+ throw new Error(`system document has no mapping ${label}`);
78
+ }
79
+ Object.keys(resolved).forEach((key) => {
80
+ if (!paths.includes(`${flow.name}/${key}`)) {
81
+ throw new Error(`system document mapping key ${key} is not under ${flow.name}`);
82
+ }
83
+ });
84
+ return { ...flow, mapping: { ...resolved } };
85
+ }
86
+
87
+ /**
88
+ * Pairs device addresses with expanded names.
89
+ *
90
+ * A fixed address is repeated. Brace groups of equal length zip together.
91
+ *
92
+ * @param {string[]} origins - expanded addresses
93
+ * @param {string[]} names - expanded names
94
+ * @returns {{from: string, name: string}[]} pairs
95
+ * @example
96
+ * paired(['modbus://192.0.2.10:502'], ['mill/a', 'mill/b']);
97
+ */
98
+ function paired(origins, names) {
99
+ if (origins.length === 1) {
100
+ return names.map((name) => {
101
+ return { from: origins[0], name };
102
+ });
103
+ }
104
+ if (names.length === 1) {
105
+ return origins.map((from) => {
106
+ return { from, name: names[0] };
107
+ });
108
+ }
109
+ if (origins.length !== names.length) {
110
+ throw new Error('system document braces differ in length');
111
+ }
112
+ return origins.map((from, index) => {
113
+ return { from, name: names[index] };
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Expands one device fan-in against the namespace.
119
+ *
120
+ * @param {object} flow - device flow
121
+ * @param {string[]} paths - namespace paths
122
+ * @param {object} mappings - shared point maps
123
+ * @param {Array<string|object>} objects - server objects
124
+ * @returns {object[]} concrete flows
125
+ * @example
126
+ * deviceFlows({ from: 'modbus://192.0.2.10:502', name: 'mill/*' }, ['mill/a'], {}, []);
127
+ */
128
+ export function deviceFlows(flow, paths, mappings, objects) {
129
+ const names = picked(flow.name, paths, 'path', true);
130
+ const origins = zipped(flow.from);
131
+ return paired(origins, names).map((pair) => {
132
+ assertLeaf(objects, pair.name);
133
+ return withMapping({ ...flow, from: pair.from, name: pair.name }, paths, mappings);
134
+ });
135
+ }
@@ -0,0 +1,281 @@
1
+ import { assertLeaf, deviceFlows, expandObjects } from './expandDevices.js';
2
+ import { isAddress, picked } from './expandPattern.js';
3
+
4
+ /**
5
+ * Selects pattern matches, keeping a concrete name even when the pool lacks it.
6
+ *
7
+ * @param {string} pattern - flow name pattern
8
+ * @param {string[]} pool - candidate names
9
+ * @param {boolean} strict - whether a star that misses is an error
10
+ * @returns {string[]} names
11
+ * @example
12
+ * chosen('mill/unit', ['mill/unit'], true);
13
+ */
14
+ function chosen(pattern, pool, strict) {
15
+ if (!pattern.includes('*') && !pattern.includes('{')) {
16
+ if (pattern.split('/').some((part) => {
17
+ return part.length === 0;
18
+ })) {
19
+ throw new Error(`system document path ${pattern} has an empty segment`);
20
+ }
21
+ return [pattern];
22
+ }
23
+ return picked(pattern, pool, 'path', strict);
24
+ }
25
+
26
+ /**
27
+ * Names a source already sends to one receiver, or the streams it holds.
28
+ *
29
+ * @param {object} source - source draft
30
+ * @param {string} receiverId - receiver id
31
+ * @returns {string[]} candidate names
32
+ * @example
33
+ * poolOf({ fanIn: [], fanOut: [] }, 'archive');
34
+ */
35
+ function poolOf(source, receiverId) {
36
+ const aimed = source.fanOut.filter((flow) => {
37
+ return flow.to === receiverId;
38
+ }).map((flow) => {
39
+ return flow.name;
40
+ });
41
+ if (aimed.length > 0) {
42
+ return aimed;
43
+ }
44
+ return source.fanIn.map((flow) => {
45
+ return flow.name;
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Expands fan-out to streams this server already has.
51
+ *
52
+ * A star does not add names from the rest of the namespace.
53
+ *
54
+ * @param {object} server - server draft
55
+ * @param {string[]} ids - server ids
56
+ * @param {boolean} strict - whether a miss is an error
57
+ * @returns {object[]} concrete fan-out
58
+ * @example
59
+ * expandOut({ fanIn: [], objects: [], patternsOut: [] }, [], true);
60
+ */
61
+ function expandOut(server, ids, strict) {
62
+ const rows = [];
63
+ const streams = server.fanIn.map((flow) => {
64
+ return flow.name;
65
+ });
66
+ server.patternsOut.forEach((flow) => {
67
+ const destinations = picked(flow.to, ids, 'server', true);
68
+ const names = picked(flow.name, streams, 'path', strict);
69
+ destinations.forEach((dest) => {
70
+ names.forEach((path) => {
71
+ assertLeaf(server.objects, path);
72
+ rows.push({ name: path, to: dest });
73
+ });
74
+ });
75
+ });
76
+ return rows;
77
+ }
78
+
79
+ /**
80
+ * Expands fan-in whose source is a server.
81
+ *
82
+ * Each matching server contributes its own outgoing names.
83
+ *
84
+ * @param {object} server - receiver draft
85
+ * @param {object[]} drafts - drafts with fan-out already expanded
86
+ * @param {boolean} strict - whether a star that misses is an error
87
+ * @returns {object[]} concrete fan-in
88
+ * @example
89
+ * expandIn({ id: 'archive', objects: [], patternsIn: [] }, [], true);
90
+ */
91
+ function expandIn(server, drafts, strict) {
92
+ const rows = [];
93
+ server.patternsIn.forEach((flow) => {
94
+ if (Object.hasOwn(flow, 'mapping')) {
95
+ throw new Error(`system document flow from ${flow.from} names a mapping`);
96
+ }
97
+ const sources = picked(flow.from, drafts.map((item) => {
98
+ return item.id;
99
+ }), 'server', true);
100
+ sources.forEach((sourceId) => {
101
+ const source = drafts.find((item) => {
102
+ return item.id === sourceId;
103
+ });
104
+ chosen(flow.name, poolOf(source, server.id), strict).forEach((path) => {
105
+ assertLeaf(server.objects, path);
106
+ const copy = { from: sourceId, name: path };
107
+ if (flow.api) {
108
+ copy.api = flow.api;
109
+ }
110
+ rows.push(copy);
111
+ });
112
+ });
113
+ });
114
+ return rows;
115
+ }
116
+
117
+ /**
118
+ * Splits device fan-in from server fan-in.
119
+ *
120
+ * @param {object} server - declared server
121
+ * @param {string[]} paths - namespace paths
122
+ * @param {object} mappings - shared point maps
123
+ * @returns {object} server draft
124
+ * @example
125
+ * draftOne({ fan_in: [], id: 'cell', profile: { id: 'cell' } }, [], {});
126
+ */
127
+ function draftOne(server, paths, mappings) {
128
+ if (!server.profile || typeof server.profile.id !== 'string') {
129
+ throw new Error(`system document server ${server.id} has no profile`);
130
+ }
131
+ const local = [];
132
+ const objects = expandObjects(server.objects, paths);
133
+ const remote = [];
134
+ (server.fan_in || []).forEach((flow) => {
135
+ if (!flow.from) {
136
+ throw new Error(`system document server ${server.id} fan-in has no from`);
137
+ }
138
+ if (isAddress(flow.from)) {
139
+ deviceFlows(flow, paths, mappings, objects).forEach((item) => {
140
+ local.push(item);
141
+ });
142
+ return;
143
+ }
144
+ remote.push(flow);
145
+ });
146
+ return {
147
+ api: server.api,
148
+ fanIn: local,
149
+ id: server.id,
150
+ objects,
151
+ ownership: server.ownership,
152
+ patternsIn: remote,
153
+ patternsOut: server.fan_out || [],
154
+ profile: server.profile,
155
+ service: server.service
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Recomputes fan-out and server fan-in from the streams held so far.
161
+ *
162
+ * @param {object[]} drafts - server drafts
163
+ * @param {boolean} strict - whether a miss is an error
164
+ * @returns {object[]} next drafts
165
+ * @example
166
+ * wave([], false);
167
+ */
168
+ function wave(drafts, strict) {
169
+ const ids = drafts.map((server) => {
170
+ return server.id;
171
+ });
172
+ const withOut = drafts.map((server) => {
173
+ return { ...server, fanOut: expandOut(server, ids, strict) };
174
+ });
175
+ return withOut.map((server) => {
176
+ const incoming = expandIn(server, withOut, strict);
177
+ const local = server.fanIn.filter((flow) => {
178
+ return isAddress(flow.from);
179
+ });
180
+ return { ...server, fanIn: local.concat(incoming) };
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Fingerprints expanded flow names.
186
+ *
187
+ * @param {object[]} drafts - server drafts
188
+ * @returns {string} stable signature
189
+ * @example
190
+ * signature([]);
191
+ */
192
+ function signature(drafts) {
193
+ return JSON.stringify(drafts.map((server) => {
194
+ return {
195
+ fanIn: server.fanIn.map((flow) => {
196
+ return `${flow.from}|${flow.name}`;
197
+ }),
198
+ fanOut: (server.fanOut || []).map((flow) => {
199
+ return `${flow.to}|${flow.name}`;
200
+ })
201
+ };
202
+ }));
203
+ }
204
+
205
+ /**
206
+ * Copies a draft into a server record.
207
+ *
208
+ * @param {object} server - settled draft
209
+ * @returns {object} server
210
+ * @example
211
+ * materialize({ fanIn: [], fanOut: [], id: 'cell', objects: [], profile: { id: 'cell' } });
212
+ */
213
+ function materialize(server) {
214
+ const row = {
215
+ fan_in: server.fanIn,
216
+ fan_out: server.fanOut,
217
+ id: server.id,
218
+ objects: server.objects,
219
+ profile: server.profile
220
+ };
221
+ if (server.api) {
222
+ row.api = server.api;
223
+ }
224
+ if (server.ownership) {
225
+ row.ownership = server.ownership;
226
+ }
227
+ if (server.service) {
228
+ row.service = server.service;
229
+ }
230
+ return row;
231
+ }
232
+
233
+ /**
234
+ * Expands flows until forwarded names stop appearing.
235
+ *
236
+ * @param {object[]} drafts - initial drafts
237
+ * @returns {object[]} settled drafts
238
+ * @example
239
+ * settle([]);
240
+ */
241
+ function settle(drafts) {
242
+ let current = drafts;
243
+ const limit = drafts.length + 3;
244
+ let prior = '';
245
+ for (let turn = 0; turn < limit; turn += 1) {
246
+ const next = wave(current, false);
247
+ const sign = signature(next);
248
+ if (sign === prior) {
249
+ return wave(current, true);
250
+ }
251
+ prior = sign;
252
+ current = next;
253
+ }
254
+ throw new Error('system document flows did not settle');
255
+ }
256
+
257
+ /**
258
+ * Expands every server's objects, fan-in, and fan-out.
259
+ *
260
+ * @param {object[]} servers - declared servers
261
+ * @param {string[]} paths - namespace paths
262
+ * @param {object} mappings - shared point maps
263
+ * @returns {object[]} expanded servers
264
+ * @example
265
+ * expandServers([], [], {});
266
+ */
267
+ export function expandServers(servers, paths, mappings) {
268
+ const ids = servers.map((server) => {
269
+ return server.id;
270
+ });
271
+ ids.forEach((id, index) => {
272
+ if (ids.indexOf(id) !== index) {
273
+ throw new Error(`system document server ${id} is duplicated`);
274
+ }
275
+ });
276
+ return settle(servers.map((server) => {
277
+ return draftOne(server, paths, mappings);
278
+ })).map((server) => {
279
+ return materialize(server);
280
+ });
281
+ }