@yarkivaev/scada 2.3.89 → 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.
- package/README.md +6 -1
- package/charts/scada/Chart.yaml +6 -0
- package/charts/scada/templates/_helpers.tpl +63 -0
- package/charts/scada/templates/clickhouse.yaml +57 -0
- package/charts/scada/templates/collector.yaml +115 -0
- package/charts/scada/templates/postgres.yaml +70 -0
- package/charts/scada/templates/rabbitmq.yaml +150 -0
- package/charts/scada/templates/site-server.yaml +110 -0
- package/charts/scada/templates/supervisor-sink.yaml +64 -0
- package/charts/scada/values.yaml +30 -0
- package/index.js +10 -0
- package/package.json +4 -2
- package/src/application/deploy/appReleases.js +50 -0
- package/src/application/deploy/applyIncludes.js +31 -0
- package/src/application/deploy/expand.js +49 -0
- package/src/application/deploy/expandDevices.js +135 -0
- package/src/application/deploy/expandFlows.js +281 -0
- package/src/application/deploy/expandNamespace.js +193 -0
- package/src/application/deploy/expandPattern.js +207 -0
- package/src/application/deploy/expandProfiles.js +70 -0
- package/src/application/deploy/frozen.js +23 -0
- package/src/application/deploy/mergeDocuments.js +98 -0
- package/src/application/deploy/paths.js +86 -0
- package/src/application/deploy/pointPaths.js +20 -0
- package/src/application/deploy/readDocument.js +169 -0
- package/src/application/deploy/releaseEdges.js +185 -0
- package/src/application/deploy/releaseNodes.js +118 -0
- package/src/application/deploy/releaseValues.js +200 -0
- package/src/application/deploy/releases.js +115 -0
- package/src/application/deploy/withCollectors.js +28 -0
- package/src/bin/modbus-to-mqtt.js +57 -0
- package/src/infrastructure/ingest/mqtt/addressWindow.js +79 -0
- package/src/infrastructure/ingest/mqtt/modbusPublish.js +277 -0
- package/src/infrastructure/ingest/mqtt/registerPoint.js +90 -0
- 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.
|
|
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": {
|
|
@@ -30,7 +31,7 @@
|
|
|
30
31
|
"@clickhouse/client": "^1.0.0",
|
|
31
32
|
"@modelcontextprotocol/sdk": "^1.24.3",
|
|
32
33
|
"@yarkivaev/simple-server": "1.0.2",
|
|
33
|
-
"@yarkivaev/source-to-sink": "1.3.
|
|
34
|
+
"@yarkivaev/source-to-sink": "1.3.8",
|
|
34
35
|
"amqplib": "^0.10.9",
|
|
35
36
|
"pg": "^8.0.0",
|
|
36
37
|
"stompit": "^1.0.0",
|
|
@@ -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
|
+
}
|