@sakibcoolz/core 0.1.2 → 0.2.0
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 +67 -7
- package/dist/acquisition.d.ts +29 -0
- package/dist/acquisition.d.ts.map +1 -0
- package/dist/acquisition.js +30 -0
- package/dist/acquisition.js.map +1 -0
- package/dist/assembly.d.ts +5 -2
- package/dist/assembly.d.ts.map +1 -1
- package/dist/assembly.js +7 -4
- package/dist/assembly.js.map +1 -1
- package/dist/attach.d.ts +4 -2
- package/dist/attach.d.ts.map +1 -1
- package/dist/attach.js +5 -3
- package/dist/attach.js.map +1 -1
- package/dist/clipboard.d.ts +6 -4
- package/dist/clipboard.d.ts.map +1 -1
- package/dist/clipboard.js +25 -4
- package/dist/clipboard.js.map +1 -1
- package/dist/collision.d.ts +2 -2
- package/dist/collision.js.map +1 -1
- package/dist/digest.d.ts +9 -0
- package/dist/digest.d.ts.map +1 -0
- package/dist/digest.js +68 -0
- package/dist/digest.js.map +1 -0
- package/dist/flow.d.ts +3 -2
- package/dist/flow.d.ts.map +1 -1
- package/dist/flow.js.map +1 -1
- package/dist/graph.d.ts +67 -7
- package/dist/graph.d.ts.map +1 -1
- package/dist/graph.js +195 -10
- package/dist/graph.js.map +1 -1
- package/dist/index.d.ts +13 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/observation.d.ts +1 -1
- package/dist/observation.d.ts.map +1 -1
- package/dist/observation.js +4 -1
- package/dist/observation.js.map +1 -1
- package/dist/routing.d.ts +6 -3
- package/dist/routing.d.ts.map +1 -1
- package/dist/routing.js +12 -2
- package/dist/routing.js.map +1 -1
- package/dist/scene.d.ts +245 -5
- package/dist/scene.d.ts.map +1 -1
- package/dist/scene.js +94 -3
- package/dist/scene.js.map +1 -1
- package/dist/serialization.d.ts +7 -7
- package/dist/serialization.d.ts.map +1 -1
- package/dist/serialization.js +120 -8
- package/dist/serialization.js.map +1 -1
- package/dist/snapping.d.ts +2 -2
- package/dist/snapping.d.ts.map +1 -1
- package/dist/snapping.js +10 -7
- package/dist/snapping.js.map +1 -1
- package/dist/status.d.ts +56 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +114 -0
- package/dist/status.js.map +1 -0
- package/dist/tags.d.ts +52 -0
- package/dist/tags.d.ts.map +1 -0
- package/dist/tags.js +103 -0
- package/dist/tags.js.map +1 -0
- package/dist/telemetry.d.ts +42 -4
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +84 -8
- package/dist/telemetry.js.map +1 -1
- package/dist/touching.d.ts +2 -2
- package/dist/touching.d.ts.map +1 -1
- package/dist/touching.js +7 -4
- package/dist/touching.js.map +1 -1
- package/dist/units.js +1 -1
- package/dist/units.js.map +1 -1
- package/dist/upgrade.d.ts +2 -2
- package/dist/upgrade.js.map +1 -1
- package/dist/validation.d.ts +33 -6
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +806 -39
- package/dist/validation.js.map +1 -1
- package/package.json +4 -2
- package/schema/fixtures/invalid/association-without-relationship.scene.json +69 -0
- package/schema/fixtures/invalid/binding-kind.scene.json +14 -0
- package/schema/fixtures/invalid/command-zero-step.scene.json +105 -0
- package/schema/fixtures/invalid/empty-name.scene.json +30 -0
- package/schema/fixtures/invalid/expected.json +70 -0
- package/schema/fixtures/invalid/extension-not-namespaced.scene.json +10 -0
- package/schema/fixtures/invalid/extension-prototype-key.scene.json +12 -0
- package/schema/fixtures/invalid/missing-document-type.scene.json +6 -0
- package/schema/fixtures/invalid/non-string-label.scene.json +32 -0
- package/schema/fixtures/invalid/underlay-opacity.scene.json +17 -0
- package/schema/fixtures/invalid/unknown-component-kind.scene.json +29 -0
- package/schema/fixtures/invalid/unknown-relationship.scene.json +63 -0
- package/schema/fixtures/invalid/unknown-root-field.scene.json +8 -0
- package/schema/fixtures/invalid/unsupported-version.scene.json +7 -0
- package/schema/fixtures/invalid/view-rotation.scene.json +139 -0
- package/schema/fixtures/invalid/writable-string.scene.json +93 -0
- package/schema/fixtures/invalid/writable-without-limits.scene.json +100 -0
- package/schema/fixtures/invalid/zero-scale.scene.json +29 -0
- package/schema/fixtures/migration/README.md +18 -0
- package/schema/fixtures/migration/v1-demo.scene.json +160 -0
- package/schema/fixtures/migration/v1-demo.v4.json +198 -0
- package/schema/fixtures/migration/v2-demo.scene.json +244 -0
- package/schema/fixtures/migration/v2-demo.v4.json +245 -0
- package/schema/fixtures/migration/v2-generated-12.scene.json +473 -0
- package/schema/fixtures/migration/v2-generated-12.v4.json +474 -0
- package/schema/fixtures/migration/v3-demo.scene.json +244 -0
- package/schema/fixtures/migration/v3-demo.v4.json +245 -0
- package/schema/fixtures/migration/v3-generated-12.scene.json +473 -0
- package/schema/fixtures/migration/v3-generated-12.v4.json +474 -0
- package/schema/fixtures/migration/v3-mostly-connected-plant.scene.json +386 -0
- package/schema/fixtures/migration/v3-mostly-connected-plant.v4.json +387 -0
- package/schema/fixtures/migration/v3-open-joints-plant.scene.json +288 -0
- package/schema/fixtures/migration/v3-open-joints-plant.v4.json +289 -0
- package/schema/fixtures/migration/v3-unconnected-plant.scene.json +14 -0
- package/schema/fixtures/migration/v3-unconnected-plant.v4.json +276 -0
- package/schema/fixtures/projection/acquisition.plan.json +50 -0
- package/schema/fixtures/projection/control-line.plan.json +86 -0
- package/schema/fixtures/valid/acquisition.scene.json +117 -0
- package/schema/fixtures/valid/associations.scene.json +79 -0
- package/schema/fixtures/valid/commands.scene.json +155 -0
- package/schema/fixtures/valid/control-line.scene.json +210 -0
- package/schema/fixtures/valid/empty.scene.json +7 -0
- package/schema/fixtures/valid/identity-and-extensions.scene.json +96 -0
- package/schema/fixtures/valid/tank-valve-line.scene.json +89 -0
- package/schema/fixtures/valid/views.scene.json +139 -0
- package/schema/fixtures/validator-only/association-id-reuses-connection.scene.json +70 -0
- package/schema/fixtures/validator-only/association-unknown-component.scene.json +70 -0
- package/schema/fixtures/validator-only/command-max-below-min.scene.json +104 -0
- package/schema/fixtures/validator-only/command-out-of-type-range.scene.json +97 -0
- package/schema/fixtures/validator-only/connection-domain-mismatch.scene.json +63 -0
- package/schema/fixtures/validator-only/credential-in-connection.scene.json +66 -0
- package/schema/fixtures/validator-only/credential-in-extension.scene.json +14 -0
- package/schema/fixtures/validator-only/credential-label.scene.json +32 -0
- package/schema/fixtures/validator-only/duplicate-component-id.scene.json +50 -0
- package/schema/fixtures/validator-only/endpoint-with-credentials.scene.json +74 -0
- package/schema/fixtures/validator-only/expected.json +66 -0
- package/schema/fixtures/validator-only/modbus-coil-float.scene.json +100 -0
- package/schema/fixtures/validator-only/non-unit-rotation.scene.json +29 -0
- package/schema/fixtures/validator-only/unknown-data-source.scene.json +100 -0
- package/schema/fixtures/validator-only/unknown-port.scene.json +62 -0
- package/schema/fixtures/validator-only/unregistered-host-type.scene.json +29 -0
- package/schema/fixtures/validator-only/view-unknown-component.scene.json +143 -0
- package/schema/industrial-scene.v4.schema.json +365 -0
package/dist/validation.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { checkPorts, portCapacity, portKinds, isRelationship, relationshipKinds, SCENE_DOCUMENT_TYPE, isBuiltInKind, isNamespacedType, dataTypes, modbusAreas, protocols, viewKinds, ownValue, } from './scene.js';
|
|
2
2
|
import { quaternionFromEuler } from './quaternion.js';
|
|
3
3
|
import { isKnownUnit } from './units.js';
|
|
4
|
+
import { valueFitsDataType } from './telemetry.js';
|
|
4
5
|
import { tupleToQuaternion, tupleToVector } from './vectors.js';
|
|
5
6
|
export const defaultSceneLimits = {
|
|
6
7
|
maxComponents: 10_000,
|
|
7
8
|
maxConnections: 20_000,
|
|
9
|
+
maxAssociations: 20_000,
|
|
10
|
+
maxDataSources: 1_000,
|
|
11
|
+
maxVariables: 20_000,
|
|
8
12
|
maxBindings: 20_000,
|
|
9
13
|
maxIdLength: 128,
|
|
10
14
|
maxProperties: 64,
|
|
@@ -15,16 +19,72 @@ const sceneKeys = {
|
|
|
15
19
|
1: ['version', 'components', 'connections'],
|
|
16
20
|
2: ['schemaVersion', 'units', 'components', 'connections', 'tagBindings'],
|
|
17
21
|
3: ['schemaVersion', 'units', 'components', 'connections', 'tagBindings', 'underlay'],
|
|
22
|
+
4: ['schemaVersion', 'documentType', 'units', 'project', 'components', 'connections', 'associations', 'dataSources', 'runtimeVariables', 'tagBindings', 'underlay', 'views', 'extensions'],
|
|
18
23
|
};
|
|
24
|
+
const projectKeys = ['id', 'name', 'revision', 'description'];
|
|
25
|
+
const viewLayoutKeys = ['symbols', 'routes'];
|
|
26
|
+
const symbolKeys = ['x', 'y', 'rotation', 'hidden'];
|
|
27
|
+
/** Drawing coordinates (ADR-0026) stay within ±1,000,000 units; a route has 2 to 1,000 points. */
|
|
28
|
+
const MAX_VIEW_COORDINATE = 1_000_000;
|
|
29
|
+
const MAX_ROUTE_POINTS = 1_000;
|
|
19
30
|
const underlayKeys = ['svg', 'position', 'width', 'rotation', 'opacity'];
|
|
20
31
|
const componentKeys = {
|
|
21
32
|
1: ['id', 'kind', 'position', 'rotation', 'properties'],
|
|
22
33
|
2: ['id', 'type', 'position', 'rotation', 'scale', 'properties'],
|
|
23
34
|
3: ['id', 'type', 'parentId', 'position', 'rotation', 'scale', 'properties'],
|
|
35
|
+
4: ['id', 'name', 'type', 'typeVersion', 'manufacturer', 'model', 'parentId', 'position', 'rotation', 'scale', 'properties', 'labels', 'extensions'],
|
|
24
36
|
};
|
|
37
|
+
/** Deepest nesting accepted inside one extension value, and most values in all extensions of a scene (ADR-0021). */
|
|
38
|
+
const MAX_EXTENSION_DEPTH = 16;
|
|
39
|
+
const MAX_EXTENSION_VALUES = 10_000;
|
|
40
|
+
/** Most JSON values in one variable address. Each address has its own budget, so `maxVariables` bounds the total. */
|
|
41
|
+
const MAX_ADDRESS_VALUES = 64;
|
|
42
|
+
/** Reverse-DNS namespace for extension keys, e.g. `com.example.app`. */
|
|
43
|
+
const NAMESPACE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
|
44
|
+
/** Keys that would reach an object's prototype if copied naively. */
|
|
45
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
46
|
+
const SECRET_WORDS = ['password', 'passwd', 'passphrase', 'secret', 'credential', 'apikey', 'accesskey', 'privatekey'];
|
|
47
|
+
/** True for a key that names a credential: scene documents carry references to secrets, never secrets (ADR-0021). */
|
|
48
|
+
export function isSecretKey(key) {
|
|
49
|
+
const k = key.toLowerCase().replace(/[-_.\s]/g, '');
|
|
50
|
+
// "auth" only as a whole name or "authorization…": as a substring it would catch "author" (T-096).
|
|
51
|
+
return k === 'pwd' || k === 'auth' || k.startsWith('authorization') || k.endsWith('token') || SECRET_WORDS.some((w) => k.includes(w));
|
|
52
|
+
}
|
|
25
53
|
/** Accepted deviation of a rotation quaternion's length from 1; larger values are rejected, never renormalised. */
|
|
26
54
|
const UNIT_TOLERANCE = 1e-6;
|
|
27
|
-
const connectionKeys =
|
|
55
|
+
const connectionKeys = {
|
|
56
|
+
1: ['id', 'from', 'to'],
|
|
57
|
+
2: ['id', 'from', 'to'],
|
|
58
|
+
3: ['id', 'from', 'to'],
|
|
59
|
+
4: ['id', 'domain', 'relationship', 'from', 'to', 'properties'],
|
|
60
|
+
};
|
|
61
|
+
const associationKeys = ['id', 'relationship', 'from', 'to', 'properties'];
|
|
62
|
+
const dataSourceKeys = ['id', 'name', 'protocol', 'deviceId', 'endpointRef', 'properties'];
|
|
63
|
+
const variableKeys = ['id', 'name', 'componentId', 'dataType', 'unit', 'access', 'source', 'scaling', 'command'];
|
|
64
|
+
const commandKeys = ['min', 'max', 'step', 'confirm'];
|
|
65
|
+
const variableSourceKeys = ['dataSourceId', 'variable', 'address'];
|
|
66
|
+
const scalingKeys = ['kind', 'rawMin', 'rawMax', 'min', 'max'];
|
|
67
|
+
const opcUaKeys = ['nodeId', 'namespaceUri'];
|
|
68
|
+
const modbusKeys = ['area', 'address', 'unitId', 'byteOrder', 'wordOrder', 'bit'];
|
|
69
|
+
/** Registers a Modbus value spans (ADR-0024); `string` is not addressable in Modbus. */
|
|
70
|
+
const MODBUS_REGISTERS = { bool: 1, int16: 1, uint16: 1, int32: 2, uint32: 2, float32: 2, float64: 4 };
|
|
71
|
+
/** `scheme://user:password@host`: an endpoint carrying credentials. */
|
|
72
|
+
const URL_WITH_USER_INFO = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*@/i;
|
|
73
|
+
/** A URL query or matrix parameter named like a credential, e.g. `?password=…` or `;token=…` (T-096). */
|
|
74
|
+
const SECRET_PARAMETER = /[?&;]([^=&;#]*)=/g;
|
|
75
|
+
/** Whether an endpoint reference carries a credential: user info (also after leading spaces) or a secret-named parameter. */
|
|
76
|
+
function endpointCarriesSecret(ref) {
|
|
77
|
+
return URL_WITH_USER_INFO.test(ref.trim()) || [...ref.matchAll(SECRET_PARAMETER)].some((m) => isSecretKey(decodeSafe(m[1])));
|
|
78
|
+
}
|
|
79
|
+
const decodeSafe = (text) => {
|
|
80
|
+
try {
|
|
81
|
+
return decodeURIComponent(text);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return text;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const MAX_UNIT_LENGTH = 32;
|
|
28
88
|
const bindingKeys = {
|
|
29
89
|
property: ['id', 'tagId', 'kind', 'instanceId', 'property', 'unit'],
|
|
30
90
|
flow: ['id', 'tagId', 'kind', 'connectionId', 'unit'],
|
|
@@ -148,6 +208,11 @@ function readProperties(value, path, limits, errors) {
|
|
|
148
208
|
let valid = true;
|
|
149
209
|
for (const [key, v] of entries) {
|
|
150
210
|
const propertyPath = `${path}.${key}`;
|
|
211
|
+
if (isSecretKey(key)) {
|
|
212
|
+
errors.push({ code: 'SECRET_FIELD', path: propertyPath, message: `"${key}" looks like a credential; store a reference in the host instead.` });
|
|
213
|
+
valid = false;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
151
216
|
if (typeof v === 'string') {
|
|
152
217
|
if (v.length > limits.maxStringLength) {
|
|
153
218
|
errors.push({ code: 'LIMIT_EXCEEDED', path: propertyPath, message: `String longer than ${limits.maxStringLength} characters.` });
|
|
@@ -165,12 +230,161 @@ function readProperties(value, path, limits, errors) {
|
|
|
165
230
|
}
|
|
166
231
|
return valid ? properties : undefined;
|
|
167
232
|
}
|
|
233
|
+
/** A non-empty string no longer than the string limit. */
|
|
234
|
+
function readText(value, path, limits, errors) {
|
|
235
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
236
|
+
errors.push({ code: 'INVALID_FIELD', path, message: 'Expected a non-empty string.' });
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
if (value.length > limits.maxStringLength) {
|
|
240
|
+
errors.push({ code: 'LIMIT_EXCEEDED', path, message: `String longer than ${limits.maxStringLength} characters.` });
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
return value;
|
|
244
|
+
}
|
|
245
|
+
/** Defines a key as plain data, so keys such as "__proto__" never touch the prototype. */
|
|
246
|
+
function put(target, key, value) {
|
|
247
|
+
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
|
|
248
|
+
}
|
|
249
|
+
function readProject(value, limits, errors) {
|
|
250
|
+
if (!isObject(value)) {
|
|
251
|
+
errors.push({ code: 'INVALID_FIELD', path: 'project', message: 'Expected a project object.' });
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
checkKeys(value, projectKeys, 'project', errors);
|
|
255
|
+
const project = {};
|
|
256
|
+
if (value.id !== undefined) {
|
|
257
|
+
const id = readId(value.id, 'project.id', limits, errors);
|
|
258
|
+
if (id !== undefined)
|
|
259
|
+
project.id = id;
|
|
260
|
+
}
|
|
261
|
+
for (const key of ['name', 'revision', 'description']) {
|
|
262
|
+
if (value[key] !== undefined) {
|
|
263
|
+
const text = readText(value[key], `project.${key}`, limits, errors);
|
|
264
|
+
if (text !== undefined)
|
|
265
|
+
project[key] = text;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return project;
|
|
269
|
+
}
|
|
270
|
+
/** Identification labels: string keys to string values, no credentials (ADR-0021). */
|
|
271
|
+
function readLabels(value, path, limits, errors) {
|
|
272
|
+
if (!isObject(value)) {
|
|
273
|
+
errors.push({ code: 'INVALID_FIELD', path, message: 'Expected an object of labels.' });
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
const entries = Object.entries(value);
|
|
277
|
+
if (entries.length > limits.maxProperties) {
|
|
278
|
+
errors.push({ code: 'LIMIT_EXCEEDED', path, message: `More than ${limits.maxProperties} labels.` });
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
const labels = {};
|
|
282
|
+
for (const [key, v] of entries) {
|
|
283
|
+
const labelPath = `${path}.${key}`;
|
|
284
|
+
if (key.length === 0 || key.length > limits.maxIdLength) {
|
|
285
|
+
errors.push({ code: 'INVALID_FIELD', path: labelPath, message: `Label names must be 1 to ${limits.maxIdLength} characters.` });
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (isSecretKey(key)) {
|
|
289
|
+
errors.push({ code: 'SECRET_FIELD', path: labelPath, message: `"${key}" looks like a credential; store a reference in the host instead.` });
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (typeof v !== 'string') {
|
|
293
|
+
errors.push({ code: 'INVALID_FIELD', path: labelPath, message: 'Expected a string label.' });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (v.length > limits.maxStringLength) {
|
|
297
|
+
errors.push({ code: 'LIMIT_EXCEEDED', path: labelPath, message: `String longer than ${limits.maxStringLength} characters.` });
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
put(labels, key, v);
|
|
301
|
+
}
|
|
302
|
+
return labels;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Extensions: reverse-DNS namespaces to plain JSON, bounded in depth and size, with no prototype
|
|
306
|
+
* keys and no credentials anywhere inside (ADR-0021). Returns a fresh copy.
|
|
307
|
+
*/
|
|
308
|
+
function readExtensions(value, path, budget, limits, errors) {
|
|
309
|
+
if (!isObject(value)) {
|
|
310
|
+
errors.push({ code: 'INVALID_FIELD', path, message: 'Expected an object keyed by namespace.' });
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
const out = {};
|
|
314
|
+
for (const [key, v] of Object.entries(value)) {
|
|
315
|
+
if (!NAMESPACE.test(key)) {
|
|
316
|
+
errors.push({ code: 'INVALID_FIELD', path: `${path}.${key}`, message: 'Extension keys must be reverse-DNS namespaces such as "com.example.app".' });
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const copy = readJson(v, `${path}.${key}`, 1, budget, limits, errors);
|
|
320
|
+
if (copy !== undefined)
|
|
321
|
+
put(out, key, copy);
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
function readJson(value, path, depth, budget, limits, errors) {
|
|
326
|
+
if (--budget.left < 0) {
|
|
327
|
+
if (budget.left === -1)
|
|
328
|
+
errors.push({ code: 'LIMIT_EXCEEDED', path, message: budget.exceeded });
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
if (depth > MAX_EXTENSION_DEPTH) {
|
|
332
|
+
errors.push({ code: 'LIMIT_EXCEEDED', path, message: `Extension values nest deeper than ${MAX_EXTENSION_DEPTH} levels.` });
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
if (value === null || typeof value === 'boolean')
|
|
336
|
+
return value;
|
|
337
|
+
if (typeof value === 'number') {
|
|
338
|
+
if (Number.isFinite(value))
|
|
339
|
+
return plusZero(value);
|
|
340
|
+
errors.push({ code: 'INVALID_FIELD', path, message: 'Expected a finite number.' });
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
if (typeof value === 'string') {
|
|
344
|
+
if (value.length <= limits.maxStringLength)
|
|
345
|
+
return value;
|
|
346
|
+
errors.push({ code: 'LIMIT_EXCEEDED', path, message: `String longer than ${limits.maxStringLength} characters.` });
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
if (Array.isArray(value)) {
|
|
350
|
+
const out = [];
|
|
351
|
+
for (let i = 0; i < value.length; i++) {
|
|
352
|
+
const item = readJson(value[i], `${path}[${i}]`, depth + 1, budget, limits, errors);
|
|
353
|
+
if (item === undefined)
|
|
354
|
+
return undefined;
|
|
355
|
+
out.push(item);
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
if (isObject(value)) {
|
|
360
|
+
const out = {};
|
|
361
|
+
for (const [key, v] of Object.entries(value)) {
|
|
362
|
+
const keyPath = `${path}.${key}`;
|
|
363
|
+
if (FORBIDDEN_KEYS.has(key)) {
|
|
364
|
+
errors.push({ code: 'INVALID_FIELD', path: keyPath, message: `"${key}" is not allowed as a key.` });
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
if (isSecretKey(key)) {
|
|
368
|
+
errors.push({ code: 'SECRET_FIELD', path: keyPath, message: `"${key}" looks like a credential; store a reference in the host instead.` });
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
const item = readJson(v, keyPath, depth + 1, budget, limits, errors);
|
|
372
|
+
if (item === undefined)
|
|
373
|
+
return undefined;
|
|
374
|
+
put(out, key, item);
|
|
375
|
+
}
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
errors.push({ code: 'INVALID_FIELD', path, message: 'Expected plain JSON.' });
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
168
381
|
/**
|
|
169
382
|
* Reads one component in the input's own schema version and returns it in the current model.
|
|
170
|
-
*
|
|
171
|
-
* `kind` and Euler XYZ radians and has no scale.
|
|
383
|
+
* Versions 3 and 4 use named { x, y, z } / { x, y, z, w } objects; version 4 adds identity, labels and
|
|
384
|
+
* extensions. Version 2 uses arrays. Version 1 uses `kind` and Euler XYZ radians and has no scale.
|
|
385
|
+
* Older versions migrate to the current model.
|
|
172
386
|
*/
|
|
173
|
-
function readComponent(value, path, version, limits, errors) {
|
|
387
|
+
function readComponent(value, path, version, limits, errors, extensionBudget, resolveType) {
|
|
174
388
|
if (!isObject(value)) {
|
|
175
389
|
errors.push({ code: 'INVALID_FIELD', path, message: 'Expected a component object.' });
|
|
176
390
|
return undefined;
|
|
@@ -180,14 +394,18 @@ function readComponent(value, path, version, limits, errors) {
|
|
|
180
394
|
const id = readId(value.id, `${path}.id`, limits, errors);
|
|
181
395
|
const typeField = version === 1 ? 'kind' : 'type';
|
|
182
396
|
const type = value[typeField];
|
|
183
|
-
if (!
|
|
184
|
-
|
|
397
|
+
if (!isBuiltInKind(type) && !isNamespacedType(type)) {
|
|
398
|
+
// Bare names belong to the SDK; a host's own types are namespaced (ADR-0022).
|
|
399
|
+
errors.push({ code: 'UNKNOWN_COMPONENT_KIND', path: `${path}.${typeField}`, message: `Unknown component type ${describe(type)}; custom types are namespaced, such as "com.example.part".` });
|
|
400
|
+
}
|
|
401
|
+
else if (resolveType ? !resolveType(type) : !isBuiltInKind(type)) {
|
|
402
|
+
errors.push({ code: 'UNKNOWN_COMPONENT_KIND', path: `${path}.${typeField}`, message: `Unknown component type ${describe(type)}; no definition is registered for it.` });
|
|
185
403
|
}
|
|
186
404
|
let position;
|
|
187
405
|
let rotation;
|
|
188
406
|
let scale;
|
|
189
|
-
const parentId = version
|
|
190
|
-
if (version
|
|
407
|
+
const parentId = version >= 3 && value.parentId !== undefined ? readId(value.parentId, `${path}.parentId`, limits, errors) : undefined;
|
|
408
|
+
if (version >= 3) {
|
|
191
409
|
position = readVectorObject(value.position, `${path}.position`, errors);
|
|
192
410
|
rotation = readQuaternionObject(value.rotation, `${path}.rotation`, errors);
|
|
193
411
|
scale = readVectorObject(value.scale, `${path}.scale`, errors, true);
|
|
@@ -208,9 +426,32 @@ function readComponent(value, path, version, limits, errors) {
|
|
|
208
426
|
}
|
|
209
427
|
}
|
|
210
428
|
const properties = readProperties(value.properties, `${path}.properties`, limits, errors);
|
|
429
|
+
const identity = {};
|
|
430
|
+
if (version === 4) {
|
|
431
|
+
for (const key of ['name', 'typeVersion', 'manufacturer', 'model']) {
|
|
432
|
+
if (value[key] !== undefined) {
|
|
433
|
+
const text = readText(value[key], `${path}.${key}`, limits, errors);
|
|
434
|
+
if (text !== undefined)
|
|
435
|
+
identity[key] = text;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (value.labels !== undefined)
|
|
439
|
+
identity.labels = readLabels(value.labels, `${path}.labels`, limits, errors);
|
|
440
|
+
if (value.extensions !== undefined)
|
|
441
|
+
identity.extensions = readExtensions(value.extensions, `${path}.extensions`, extensionBudget, limits, errors);
|
|
442
|
+
}
|
|
211
443
|
if (errors.length > before || !id || !position || !rotation || !scale || !properties)
|
|
212
444
|
return undefined;
|
|
213
|
-
return {
|
|
445
|
+
return {
|
|
446
|
+
id,
|
|
447
|
+
type: type,
|
|
448
|
+
...(parentId ? { parentId } : {}),
|
|
449
|
+
position,
|
|
450
|
+
rotation,
|
|
451
|
+
scale,
|
|
452
|
+
properties,
|
|
453
|
+
...identity,
|
|
454
|
+
};
|
|
214
455
|
}
|
|
215
456
|
function readEndpoint(value, path, limits, errors) {
|
|
216
457
|
if (!isObject(value)) {
|
|
@@ -225,19 +466,338 @@ function readEndpoint(value, path, limits, errors) {
|
|
|
225
466
|
return undefined;
|
|
226
467
|
return { instanceId, portId };
|
|
227
468
|
}
|
|
228
|
-
|
|
469
|
+
/** Edge data on a connection or association (ADR-0023): property value rules, and no credential-like names. */
|
|
470
|
+
function readEdgeProperties(value, path, limits, errors) {
|
|
471
|
+
const properties = readProperties(value, path, limits, errors);
|
|
472
|
+
for (const key of Object.keys(properties ?? {})) {
|
|
473
|
+
if (isSecretKey(key))
|
|
474
|
+
errors.push({ code: 'SECRET_FIELD', path: `${path}.${key}`, message: `"${key}" looks like a credential; store a reference in the host instead.` });
|
|
475
|
+
}
|
|
476
|
+
return properties;
|
|
477
|
+
}
|
|
478
|
+
function readAssociation(value, path, limits, errors) {
|
|
479
|
+
if (!isObject(value)) {
|
|
480
|
+
errors.push({ code: 'INVALID_FIELD', path, message: 'Expected an association object.' });
|
|
481
|
+
return undefined;
|
|
482
|
+
}
|
|
483
|
+
const before = errors.length;
|
|
484
|
+
checkKeys(value, associationKeys, path, errors);
|
|
485
|
+
const id = readId(value.id, `${path}.id`, limits, errors);
|
|
486
|
+
if (!isRelationship(value.relationship)) {
|
|
487
|
+
errors.push({ code: 'INVALID_FIELD', path: `${path}.relationship`, message: `Expected a relationship: one of ${relationshipKinds.join(', ')}, or a namespaced name such as "com.example.supervises".` });
|
|
488
|
+
}
|
|
489
|
+
const from = readId(value.from, `${path}.from`, limits, errors);
|
|
490
|
+
const to = readId(value.to, `${path}.to`, limits, errors);
|
|
491
|
+
const properties = value.properties !== undefined ? readEdgeProperties(value.properties, `${path}.properties`, limits, errors) : undefined;
|
|
492
|
+
if (errors.length > before || !id || !from || !to)
|
|
493
|
+
return undefined;
|
|
494
|
+
return { id, relationship: value.relationship, from, to, ...(properties ? { properties } : {}) };
|
|
495
|
+
}
|
|
496
|
+
function readDataSource(value, path, limits, errors) {
|
|
497
|
+
if (!isObject(value)) {
|
|
498
|
+
errors.push({ code: 'INVALID_FIELD', path, message: 'Expected a data source object.' });
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
501
|
+
const before = errors.length;
|
|
502
|
+
checkKeys(value, dataSourceKeys, path, errors);
|
|
503
|
+
const id = readId(value.id, `${path}.id`, limits, errors);
|
|
504
|
+
const source = {};
|
|
505
|
+
if (value.name !== undefined)
|
|
506
|
+
source.name = readText(value.name, `${path}.name`, limits, errors);
|
|
507
|
+
if (protocols.includes(value.protocol) || isNamespacedType(value.protocol))
|
|
508
|
+
source.protocol = value.protocol;
|
|
509
|
+
else
|
|
510
|
+
errors.push({ code: 'INVALID_FIELD', path: `${path}.protocol`, message: `Unknown protocol ${describe(value.protocol)}; expected one of ${protocols.join(', ')}, or a namespaced name such as "com.vendor.bus".` });
|
|
511
|
+
if (value.deviceId !== undefined)
|
|
512
|
+
source.deviceId = readId(value.deviceId, `${path}.deviceId`, limits, errors);
|
|
513
|
+
if (value.endpointRef !== undefined) {
|
|
514
|
+
const ref = readText(value.endpointRef, `${path}.endpointRef`, limits, errors);
|
|
515
|
+
if (ref !== undefined && endpointCarriesSecret(ref)) {
|
|
516
|
+
errors.push({ code: 'SECRET_FIELD', path: `${path}.endpointRef`, message: 'The endpoint carries a user name or password; store a reference the host resolves instead.' });
|
|
517
|
+
}
|
|
518
|
+
else if (ref !== undefined)
|
|
519
|
+
source.endpointRef = ref;
|
|
520
|
+
}
|
|
521
|
+
if (value.properties !== undefined)
|
|
522
|
+
source.properties = readEdgeProperties(value.properties, `${path}.properties`, limits, errors);
|
|
523
|
+
if (errors.length > before || !id)
|
|
524
|
+
return undefined;
|
|
525
|
+
return { id, ...source };
|
|
526
|
+
}
|
|
527
|
+
function readFinite(value, path, errors) {
|
|
528
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
529
|
+
return plusZero(value);
|
|
530
|
+
errors.push({ code: 'INVALID_FIELD', path, message: 'Expected a finite number.' });
|
|
531
|
+
return undefined;
|
|
532
|
+
}
|
|
533
|
+
/** Reads a variable; its address stays plain JSON here and is checked against its source's protocol in `checkAcquisition`. */
|
|
534
|
+
function readVariable(value, path, limits, errors) {
|
|
535
|
+
if (!isObject(value)) {
|
|
536
|
+
errors.push({ code: 'INVALID_FIELD', path, message: 'Expected a runtime variable object.' });
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
const before = errors.length;
|
|
540
|
+
checkKeys(value, variableKeys, path, errors);
|
|
541
|
+
const id = readId(value.id, `${path}.id`, limits, errors);
|
|
542
|
+
const variable = {};
|
|
543
|
+
if (value.name !== undefined)
|
|
544
|
+
variable.name = readText(value.name, `${path}.name`, limits, errors);
|
|
545
|
+
if (value.componentId !== undefined)
|
|
546
|
+
variable.componentId = readId(value.componentId, `${path}.componentId`, limits, errors);
|
|
547
|
+
if (dataTypes.includes(value.dataType))
|
|
548
|
+
variable.dataType = value.dataType;
|
|
549
|
+
else
|
|
550
|
+
errors.push({ code: 'INVALID_FIELD', path: `${path}.dataType`, message: `Unknown data type ${describe(value.dataType)}; expected one of ${dataTypes.join(', ')}.` });
|
|
551
|
+
if (value.unit !== undefined) {
|
|
552
|
+
if (typeof value.unit === 'string' && value.unit.length <= MAX_UNIT_LENGTH)
|
|
553
|
+
variable.unit = value.unit;
|
|
554
|
+
else
|
|
555
|
+
errors.push({ code: 'INVALID_FIELD', path: `${path}.unit`, message: `Expected a unit of at most ${MAX_UNIT_LENGTH} characters.` });
|
|
556
|
+
}
|
|
557
|
+
if (value.access === 'read' || value.access === 'readWrite')
|
|
558
|
+
variable.access = value.access;
|
|
559
|
+
else
|
|
560
|
+
errors.push({ code: 'INVALID_FIELD', path: `${path}.access`, message: `Expected access "read" or "readWrite".` });
|
|
561
|
+
if (value.source !== undefined) {
|
|
562
|
+
const s = value.source;
|
|
563
|
+
if (!isObject(s))
|
|
564
|
+
errors.push({ code: 'INVALID_FIELD', path: `${path}.source`, message: 'Expected a source object.' });
|
|
565
|
+
else {
|
|
566
|
+
checkKeys(s, variableSourceKeys, `${path}.source`, errors);
|
|
567
|
+
const dataSourceId = readId(s.dataSourceId, `${path}.source.dataSourceId`, limits, errors);
|
|
568
|
+
const name = s.variable !== undefined ? readText(s.variable, `${path}.source.variable`, limits, errors) : undefined;
|
|
569
|
+
let address;
|
|
570
|
+
if (s.address !== undefined) {
|
|
571
|
+
if (!isObject(s.address))
|
|
572
|
+
errors.push({ code: 'INVALID_ADDRESS', path: `${path}.source.address`, message: 'Expected an address object.' });
|
|
573
|
+
else
|
|
574
|
+
address = readJson(s.address, `${path}.source.address`, 1, { left: MAX_ADDRESS_VALUES, exceeded: `An address holds more than ${MAX_ADDRESS_VALUES} values.` }, limits, errors);
|
|
575
|
+
}
|
|
576
|
+
if (dataSourceId)
|
|
577
|
+
variable.source = { dataSourceId, ...(name !== undefined ? { variable: name } : {}), ...(address !== undefined ? { address: address } : {}) };
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (value.scaling !== undefined) {
|
|
581
|
+
const sc = value.scaling;
|
|
582
|
+
if (!isObject(sc))
|
|
583
|
+
errors.push({ code: 'INVALID_FIELD', path: `${path}.scaling`, message: 'Expected a scaling object.' });
|
|
584
|
+
else {
|
|
585
|
+
checkKeys(sc, scalingKeys, `${path}.scaling`, errors);
|
|
586
|
+
if (sc.kind !== 'linear')
|
|
587
|
+
errors.push({ code: 'INVALID_FIELD', path: `${path}.scaling.kind`, message: 'Expected scaling kind "linear".' });
|
|
588
|
+
const [rawMin, rawMax, min, max] = ['rawMin', 'rawMax', 'min', 'max'].map((k) => readFinite(sc[k], `${path}.scaling.${k}`, errors));
|
|
589
|
+
if (rawMin !== undefined && rawMax !== undefined && rawMin === rawMax)
|
|
590
|
+
errors.push({ code: 'INVALID_FIELD', path: `${path}.scaling.rawMax`, message: 'rawMin and rawMax must differ.' });
|
|
591
|
+
if (rawMin !== undefined && rawMax !== undefined && min !== undefined && max !== undefined)
|
|
592
|
+
variable.scaling = { kind: 'linear', rawMin, rawMax, min, max };
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (value.command !== undefined)
|
|
596
|
+
variable.command = readCommand(value.command, `${path}.command`, limits, errors);
|
|
597
|
+
if (variable.dataType && variable.access)
|
|
598
|
+
checkCommand(variable, value, path, errors);
|
|
599
|
+
if (errors.length > before || !id)
|
|
600
|
+
return undefined;
|
|
601
|
+
return { id, ...variable };
|
|
602
|
+
}
|
|
603
|
+
function readCommand(value, path, limits, errors) {
|
|
604
|
+
if (!isObject(value)) {
|
|
605
|
+
errors.push({ code: 'INVALID_FIELD', path, message: 'Expected a command limits object.' });
|
|
606
|
+
return undefined;
|
|
607
|
+
}
|
|
608
|
+
checkKeys(value, commandKeys, path, errors);
|
|
609
|
+
const command = {};
|
|
610
|
+
for (const key of ['min', 'max', 'step'])
|
|
611
|
+
if (value[key] !== undefined)
|
|
612
|
+
command[key] = readFinite(value[key], `${path}.${key}`, errors);
|
|
613
|
+
if (value.confirm !== undefined)
|
|
614
|
+
command.confirm = readText(value.confirm, `${path}.confirm`, limits, errors);
|
|
615
|
+
return command;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* The command boundary's document rules (ADR-0029 §2): only a `readWrite` variable has limits; it
|
|
619
|
+
* needs a source; strings cannot be commanded; numeric types are bounded within their data type.
|
|
620
|
+
*/
|
|
621
|
+
function checkCommand(variable, value, path, errors) {
|
|
622
|
+
const bad = (field, message) => {
|
|
623
|
+
errors.push({ code: 'INVALID_FIELD', path: `${path}${field}`, message });
|
|
624
|
+
};
|
|
625
|
+
if (variable.access === 'read') {
|
|
626
|
+
if (value.command !== undefined)
|
|
627
|
+
bad('.command', 'Only a "readWrite" variable takes command limits.');
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (variable.dataType === 'string')
|
|
631
|
+
return bad('.access', 'A string variable cannot be commanded (ADR-0029).');
|
|
632
|
+
if (value.source === undefined)
|
|
633
|
+
bad('.source', 'A writable variable needs a source the host writes to.');
|
|
634
|
+
const command = variable.command;
|
|
635
|
+
if (value.command === undefined)
|
|
636
|
+
return bad('.command', 'A writable variable needs command limits (ADR-0029).');
|
|
637
|
+
if (!command)
|
|
638
|
+
return;
|
|
639
|
+
if (command.step !== undefined && !(command.step > 0))
|
|
640
|
+
bad('.command.step', 'The step must be greater than zero.');
|
|
641
|
+
if (variable.dataType === 'bool') {
|
|
642
|
+
for (const key of ['min', 'max'])
|
|
643
|
+
if (command[key] !== undefined)
|
|
644
|
+
bad(`.command.${key}`, 'An on/off variable takes no minimum or maximum.');
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
// Unbounded setpoints are refused: both limits, in order, and within the data type.
|
|
648
|
+
for (const key of ['min', 'max']) {
|
|
649
|
+
const limit = command[key];
|
|
650
|
+
if (limit === undefined) {
|
|
651
|
+
if (value.command && isObject(value.command) && value.command[key] === undefined)
|
|
652
|
+
bad(`.command.${key}`, `A numeric writable variable needs a ${key === 'min' ? 'minimum' : 'maximum'}.`);
|
|
653
|
+
}
|
|
654
|
+
else if (!valueFitsDataType(limit, variable.dataType, !!variable.scaling))
|
|
655
|
+
bad(`.command.${key}`, `${limit} does not fit ${variable.dataType}.`);
|
|
656
|
+
}
|
|
657
|
+
if (command.min !== undefined && command.max !== undefined && !(command.min < command.max))
|
|
658
|
+
bad('.command.max', 'The maximum must be greater than the minimum.');
|
|
659
|
+
}
|
|
660
|
+
/** Keys anywhere in plain JSON that name a credential. */
|
|
661
|
+
function secretKeys(value, path, errors) {
|
|
662
|
+
if (Array.isArray(value))
|
|
663
|
+
value.forEach((v, i) => secretKeys(v, `${path}[${i}]`, errors));
|
|
664
|
+
else if (value !== null && typeof value === 'object') {
|
|
665
|
+
for (const [k, v] of Object.entries(value)) {
|
|
666
|
+
if (isSecretKey(k))
|
|
667
|
+
errors.push({ code: 'SECRET_FIELD', path: `${path}.${k}`, message: `"${k}" looks like a credential; store a reference in the host instead.` });
|
|
668
|
+
else
|
|
669
|
+
secretKeys(v, `${path}.${k}`, errors);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
/** Address rules per protocol (ADR-0024); every problem is `INVALID_ADDRESS` at the field's path. */
|
|
674
|
+
function checkAddress(protocol, variable, path, options, errors) {
|
|
675
|
+
const address = variable.source?.address;
|
|
676
|
+
const bad = (at, message) => {
|
|
677
|
+
errors.push({ code: 'INVALID_ADDRESS', path: at ? `${path}.${at}` : path, message });
|
|
678
|
+
};
|
|
679
|
+
const isInt = (v, lo, hi) => typeof v === 'number' && Number.isInteger(v) && v >= lo && v <= hi;
|
|
680
|
+
if (protocol === 'host') {
|
|
681
|
+
if (address !== undefined)
|
|
682
|
+
bad('', 'A host source reads by tag id or `variable`; it takes no address.');
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (protocol === 'opcua' || protocol === 'modbus-tcp' || protocol === 'modbus-rtu') {
|
|
686
|
+
if (address === undefined)
|
|
687
|
+
return bad('', `A ${protocol} source needs an address.`);
|
|
688
|
+
if (protocol === 'opcua') {
|
|
689
|
+
for (const key of Object.keys(address))
|
|
690
|
+
if (!opcUaKeys.includes(key))
|
|
691
|
+
bad(key, `Unknown OPC UA address field "${key}".`);
|
|
692
|
+
if (typeof address.nodeId !== 'string' || address.nodeId.length === 0)
|
|
693
|
+
bad('nodeId', 'Expected a node id such as "ns=2;s=Line2.Speed".');
|
|
694
|
+
if (address.namespaceUri !== undefined && (typeof address.namespaceUri !== 'string' || address.namespaceUri.length === 0))
|
|
695
|
+
bad('namespaceUri', 'Expected a namespace URI.');
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
for (const key of Object.keys(address))
|
|
699
|
+
if (!modbusKeys.includes(key))
|
|
700
|
+
bad(key, `Unknown Modbus address field "${key}".`);
|
|
701
|
+
const area = address.area;
|
|
702
|
+
if (!modbusAreas.includes(area))
|
|
703
|
+
return bad('area', `Expected an area: ${modbusAreas.join(', ')}.`);
|
|
704
|
+
if (!isInt(address.unitId, 0, 255))
|
|
705
|
+
bad('unitId', 'Expected a unit id 0–255.');
|
|
706
|
+
if (address.byteOrder !== undefined && address.byteOrder !== 'big' && address.byteOrder !== 'little')
|
|
707
|
+
bad('byteOrder', 'Expected "big" or "little".');
|
|
708
|
+
if (address.wordOrder !== undefined && address.wordOrder !== 'high-first' && address.wordOrder !== 'low-first')
|
|
709
|
+
bad('wordOrder', 'Expected "high-first" or "low-first".');
|
|
710
|
+
const registers = MODBUS_REGISTERS[variable.dataType];
|
|
711
|
+
if (registers === undefined)
|
|
712
|
+
return bad('', `A ${variable.dataType} cannot be read from Modbus.`);
|
|
713
|
+
if ((area === 'coil' || area === 'discrete') && variable.dataType !== 'bool')
|
|
714
|
+
bad('area', `A ${area} holds only bool, not ${variable.dataType}.`);
|
|
715
|
+
if (!isInt(address.address, 0, 65535))
|
|
716
|
+
bad('address', 'Expected an address 0–65535.');
|
|
717
|
+
else if (address.address + registers - 1 > 65535)
|
|
718
|
+
bad('address', `A ${variable.dataType} spans ${registers} registers and does not fit below 65536.`);
|
|
719
|
+
if (address.bit !== undefined) {
|
|
720
|
+
if (!isInt(address.bit, 0, 15))
|
|
721
|
+
bad('bit', 'Expected a bit 0–15.');
|
|
722
|
+
else if (variable.dataType !== 'bool' || (area !== 'holding' && area !== 'input'))
|
|
723
|
+
bad('bit', 'A bit applies only to a bool in a holding or input register.');
|
|
724
|
+
}
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
// A host's own protocol: plain JSON, checked by the host when it can.
|
|
728
|
+
if (address === undefined)
|
|
729
|
+
return;
|
|
730
|
+
secretKeys(address, `${path}`, errors);
|
|
731
|
+
for (const message of options.validateAddress?.(protocol, address) ?? [])
|
|
732
|
+
bad('', message);
|
|
733
|
+
}
|
|
734
|
+
/** References and address rules of data sources and runtime variables (ADR-0024). */
|
|
735
|
+
function checkAcquisition(scene, options, errors) {
|
|
736
|
+
const components = new Set(scene.components.map((c) => c.id));
|
|
737
|
+
const sources = new Map();
|
|
738
|
+
scene.dataSources?.forEach((s, i) => {
|
|
739
|
+
const path = `dataSources[${i}]`;
|
|
740
|
+
if (sources.has(s.id))
|
|
741
|
+
errors.push({ code: 'DUPLICATE_DATA_SOURCE_ID', path: `${path}.id`, message: `Duplicate data source id "${s.id}".` });
|
|
742
|
+
else
|
|
743
|
+
sources.set(s.id, s);
|
|
744
|
+
if (s.deviceId !== undefined && !components.has(s.deviceId))
|
|
745
|
+
errors.push({ code: 'UNKNOWN_INSTANCE', path: `${path}.deviceId`, message: `No component "${s.deviceId}".` });
|
|
746
|
+
});
|
|
747
|
+
const ids = new Set();
|
|
748
|
+
scene.runtimeVariables?.forEach((v, i) => {
|
|
749
|
+
const path = `runtimeVariables[${i}]`;
|
|
750
|
+
if (ids.has(v.id)) {
|
|
751
|
+
errors.push({ code: 'DUPLICATE_VARIABLE_ID', path: `${path}.id`, message: `Duplicate runtime variable id "${v.id}".` });
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
ids.add(v.id);
|
|
755
|
+
if (v.componentId !== undefined && !components.has(v.componentId))
|
|
756
|
+
errors.push({ code: 'UNKNOWN_INSTANCE', path: `${path}.componentId`, message: `No component "${v.componentId}".` });
|
|
757
|
+
if (!v.source)
|
|
758
|
+
return;
|
|
759
|
+
const source = sources.get(v.source.dataSourceId);
|
|
760
|
+
if (!source) {
|
|
761
|
+
errors.push({ code: 'UNKNOWN_DATA_SOURCE', path: `${path}.source.dataSourceId`, message: `No data source "${v.source.dataSourceId}".` });
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
checkAddress(source.protocol, v, `${path}.source.address`, options, errors);
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
function readConnection(value, path, version, limits, errors) {
|
|
229
768
|
if (!isObject(value)) {
|
|
230
769
|
errors.push({ code: 'INVALID_FIELD', path, message: 'Expected a connection object.' });
|
|
231
770
|
return undefined;
|
|
232
771
|
}
|
|
233
772
|
const before = errors.length;
|
|
234
|
-
checkKeys(value, connectionKeys, path, errors);
|
|
773
|
+
checkKeys(value, connectionKeys[version], path, errors);
|
|
235
774
|
const id = readId(value.id, `${path}.id`, limits, errors);
|
|
236
775
|
const from = readEndpoint(value.from, `${path}.from`, limits, errors);
|
|
237
776
|
const to = readEndpoint(value.to, `${path}.to`, limits, errors);
|
|
777
|
+
// Version 4 fields (ADR-0023); older versions reject them as unknown keys above.
|
|
778
|
+
const extra = {};
|
|
779
|
+
if (version === 4) {
|
|
780
|
+
if (value.domain !== undefined) {
|
|
781
|
+
if (portKinds.includes(value.domain))
|
|
782
|
+
extra.domain = value.domain;
|
|
783
|
+
else
|
|
784
|
+
errors.push({ code: 'INVALID_FIELD', path: `${path}.domain`, message: `Unknown connection domain ${describe(value.domain)}; expected one of ${portKinds.join(', ')}.` });
|
|
785
|
+
}
|
|
786
|
+
if (value.relationship !== undefined) {
|
|
787
|
+
if (isRelationship(value.relationship))
|
|
788
|
+
extra.relationship = value.relationship;
|
|
789
|
+
else
|
|
790
|
+
errors.push({ code: 'INVALID_FIELD', path: `${path}.relationship`, message: `Unknown relationship ${describe(value.relationship)}; expected one of ${relationshipKinds.join(', ')}, or a namespaced name such as "com.example.supervises".` });
|
|
791
|
+
}
|
|
792
|
+
if (value.properties !== undefined) {
|
|
793
|
+
const properties = readEdgeProperties(value.properties, `${path}.properties`, limits, errors);
|
|
794
|
+
if (properties)
|
|
795
|
+
extra.properties = properties;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
238
798
|
if (errors.length > before || !id || !from || !to)
|
|
239
799
|
return undefined;
|
|
240
|
-
return { id, from, to };
|
|
800
|
+
return { id, ...extra, from, to };
|
|
241
801
|
}
|
|
242
802
|
function readBinding(value, path, limits, errors) {
|
|
243
803
|
if (!isObject(value)) {
|
|
@@ -272,6 +832,7 @@ function readBinding(value, path, limits, errors) {
|
|
|
272
832
|
function checkBindings(scene, options, errors) {
|
|
273
833
|
const components = new Map(scene.components.map((c) => [c.id, c]));
|
|
274
834
|
const connections = new Set(scene.connections.map((c) => c.id));
|
|
835
|
+
const variables = new Map((scene.runtimeVariables ?? []).map((v) => [v.id, v]));
|
|
275
836
|
const ids = new Set();
|
|
276
837
|
const targets = new Set();
|
|
277
838
|
scene.tagBindings?.forEach((binding, i) => {
|
|
@@ -290,7 +851,7 @@ function checkBindings(scene, options, errors) {
|
|
|
290
851
|
return;
|
|
291
852
|
}
|
|
292
853
|
if (options.resolveBindable) {
|
|
293
|
-
quantity = options.resolveBindable(owner.type)
|
|
854
|
+
quantity = ownValue(options.resolveBindable(owner.type), binding.property);
|
|
294
855
|
if (!quantity) {
|
|
295
856
|
errors.push({ code: 'UNBINDABLE_PROPERTY', path: `${path}.property`, message: `A ${owner.type} has no bindable property "${binding.property}".` });
|
|
296
857
|
return;
|
|
@@ -306,6 +867,20 @@ function checkBindings(scene, options, errors) {
|
|
|
306
867
|
quantity = 'flow';
|
|
307
868
|
target = JSON.stringify(['flow', binding.connectionId]);
|
|
308
869
|
}
|
|
870
|
+
const variable = variables.get(binding.tagId);
|
|
871
|
+
if (variable) {
|
|
872
|
+
// The tag is described (ADR-0024): its data type must fit the property, and its unit applies when the binding gives none.
|
|
873
|
+
const fits = variable.dataType === 'string' ? false : quantity === undefined ? true : (quantity === 'boolean') === (variable.dataType === 'bool');
|
|
874
|
+
if (!fits) {
|
|
875
|
+
const needs = quantity === 'boolean' ? 'a bool' : quantity ? `a number (${quantity})` : 'a bool or a number';
|
|
876
|
+
errors.push({ code: 'TYPE_MISMATCH', path: `${path}.tagId`, message: `Tag "${binding.tagId}" is ${variable.dataType}, but this binding needs ${needs}.` });
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
if (quantity && binding.unit === undefined && variable.unit !== undefined && !isKnownUnit(quantity, variable.unit)) {
|
|
880
|
+
errors.push({ code: 'INVALID_UNIT', path: `${path}.tagId`, message: `Tag "${binding.tagId}" is in "${variable.unit}", which is not accepted for ${quantity}.` });
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
309
884
|
if (quantity && !isKnownUnit(quantity, binding.unit)) {
|
|
310
885
|
errors.push({ code: 'INVALID_UNIT', path: `${path}.unit`, message: `Unit "${binding.unit}" is not accepted for ${quantity}.` });
|
|
311
886
|
return;
|
|
@@ -327,11 +902,12 @@ function readList(value, path, max, errors, read) {
|
|
|
327
902
|
return undefined;
|
|
328
903
|
}
|
|
329
904
|
const items = [];
|
|
330
|
-
|
|
331
|
-
|
|
905
|
+
// Every index, not forEach: a hole in a sparse array (`[, x]`) is an invalid entry, as `null` is in JSON.
|
|
906
|
+
for (let i = 0; i < value.length; i++) {
|
|
907
|
+
const parsed = read(value[i], `${path}[${i}]`);
|
|
332
908
|
if (parsed)
|
|
333
909
|
items.push(parsed);
|
|
334
|
-
}
|
|
910
|
+
}
|
|
335
911
|
return items;
|
|
336
912
|
}
|
|
337
913
|
function checkReferences(scene, options, errors) {
|
|
@@ -368,7 +944,7 @@ function checkReferences(scene, options, errors) {
|
|
|
368
944
|
}
|
|
369
945
|
});
|
|
370
946
|
const connectionIds = new Set();
|
|
371
|
-
const occupied = new
|
|
947
|
+
const occupied = new Map();
|
|
372
948
|
const pairs = new Set();
|
|
373
949
|
const endpointKey = (e) => JSON.stringify([e.instanceId, e.portId]);
|
|
374
950
|
scene.connections.forEach((connection, i) => {
|
|
@@ -399,8 +975,13 @@ function checkReferences(scene, options, errors) {
|
|
|
399
975
|
errors.push({ code: 'SELF_CONNECTION', path, message: 'A component cannot connect to itself.' });
|
|
400
976
|
return;
|
|
401
977
|
}
|
|
402
|
-
|
|
403
|
-
|
|
978
|
+
const check = checkPorts(fromPort, toPort);
|
|
979
|
+
if (!check.ok) {
|
|
980
|
+
errors.push({ code: 'INCOMPATIBLE_PORTS', path, message: `Incompatible ports: ${check.reason}.` });
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
if (connection.domain !== undefined && connection.domain !== fromPort.kind) {
|
|
984
|
+
errors.push({ code: 'INCOMPATIBLE_PORTS', path: `${path}.domain`, message: `The connection is marked ${connection.domain}, but its ports are ${fromPort.kind} ports.` });
|
|
404
985
|
return;
|
|
405
986
|
}
|
|
406
987
|
const fromKey = endpointKey(connection.from);
|
|
@@ -411,28 +992,173 @@ function checkReferences(scene, options, errors) {
|
|
|
411
992
|
return;
|
|
412
993
|
}
|
|
413
994
|
let free = true;
|
|
414
|
-
for (const [side, key] of [['from', fromKey], ['to', toKey]]) {
|
|
415
|
-
|
|
416
|
-
|
|
995
|
+
for (const [side, key, port] of [['from', fromKey, fromPort], ['to', toKey, toPort]]) {
|
|
996
|
+
const capacity = portCapacity(port);
|
|
997
|
+
if ((occupied.get(key) ?? 0) >= capacity) {
|
|
998
|
+
errors.push({ code: 'PORT_OCCUPIED', path: `${path}.${side}`, message: capacity === 1 ? 'Port already has a connection.' : `Port already has ${capacity} connections, its maximum.` });
|
|
417
999
|
free = false;
|
|
418
1000
|
}
|
|
419
1001
|
}
|
|
420
1002
|
if (!free)
|
|
421
1003
|
return;
|
|
422
1004
|
pairs.add(pair);
|
|
423
|
-
occupied.
|
|
424
|
-
occupied.
|
|
1005
|
+
occupied.set(fromKey, (occupied.get(fromKey) ?? 0) + 1);
|
|
1006
|
+
occupied.set(toKey, (occupied.get(toKey) ?? 0) + 1);
|
|
1007
|
+
});
|
|
1008
|
+
// Associations (ADR-0023): ids share the connections' namespace; directed, so only an identical
|
|
1009
|
+
// (from, to, relationship) repeats.
|
|
1010
|
+
const facts = new Set();
|
|
1011
|
+
scene.associations?.forEach((a, i) => {
|
|
1012
|
+
const path = `associations[${i}]`;
|
|
1013
|
+
if (connectionIds.has(a.id)) {
|
|
1014
|
+
errors.push({ code: 'DUPLICATE_CONNECTION_ID', path: `${path}.id`, message: `Duplicate connection or association id "${a.id}".` });
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
connectionIds.add(a.id);
|
|
1018
|
+
let known = true;
|
|
1019
|
+
for (const side of ['from', 'to']) {
|
|
1020
|
+
if (!componentsById.has(a[side])) {
|
|
1021
|
+
errors.push({ code: 'UNKNOWN_INSTANCE', path: `${path}.${side}`, message: `No component "${a[side]}".` });
|
|
1022
|
+
known = false;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
if (!known)
|
|
1026
|
+
return;
|
|
1027
|
+
if (a.from === a.to) {
|
|
1028
|
+
errors.push({ code: 'SELF_CONNECTION', path, message: 'A component cannot be associated with itself.' });
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
const fact = JSON.stringify([a.from, a.to, a.relationship]);
|
|
1032
|
+
if (facts.has(fact)) {
|
|
1033
|
+
errors.push({ code: 'DUPLICATE_EDGE', path, message: `"${a.from}" already ${a.relationship} "${a.to}".` });
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
facts.add(fact);
|
|
425
1037
|
});
|
|
426
1038
|
}
|
|
427
1039
|
/**
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
* reported via `migratedFrom`.
|
|
431
|
-
* Never mutates or repairs the input: on success it returns a fresh copy, otherwise every error
|
|
432
|
-
* found, in input order, with paths in the input's own field names. Reference checks (ids, ports,
|
|
433
|
-
* compatibility, occupancy, scale policy) run only once the structure is valid. Each port accepts
|
|
434
|
-
* one connection.
|
|
1040
|
+
* Reads the optional 2D layouts (ADR-0026): per view, symbol placements by component id and routes by
|
|
1041
|
+
* connection id. Structure only; `checkViews` checks that the ids exist.
|
|
435
1042
|
*/
|
|
1043
|
+
function readViews(value, limits, errors) {
|
|
1044
|
+
if (!isObject(value)) {
|
|
1045
|
+
errors.push({ code: 'INVALID_FIELD', path: 'views', message: 'Expected an object of views.' });
|
|
1046
|
+
return undefined;
|
|
1047
|
+
}
|
|
1048
|
+
checkKeys(value, [...viewKinds], 'views', errors);
|
|
1049
|
+
const views = {};
|
|
1050
|
+
for (const kind of viewKinds) {
|
|
1051
|
+
if (value[kind] === undefined)
|
|
1052
|
+
continue;
|
|
1053
|
+
const layout = readViewLayout(value[kind], `views.${kind}`, limits, errors);
|
|
1054
|
+
if (layout)
|
|
1055
|
+
views[kind] = layout;
|
|
1056
|
+
}
|
|
1057
|
+
return views;
|
|
1058
|
+
}
|
|
1059
|
+
function readViewLayout(value, path, limits, errors) {
|
|
1060
|
+
if (!isObject(value)) {
|
|
1061
|
+
errors.push({ code: 'INVALID_FIELD', path, message: 'Expected a view layout object.' });
|
|
1062
|
+
return undefined;
|
|
1063
|
+
}
|
|
1064
|
+
checkKeys(value, viewLayoutKeys, path, errors);
|
|
1065
|
+
const layout = {};
|
|
1066
|
+
const coordinate = (v, at) => {
|
|
1067
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || Math.abs(v) > MAX_VIEW_COORDINATE) {
|
|
1068
|
+
errors.push({ code: 'INVALID_FIELD', path: at, message: `Expected a finite number within ±${MAX_VIEW_COORDINATE}.` });
|
|
1069
|
+
return undefined;
|
|
1070
|
+
}
|
|
1071
|
+
return v === 0 ? 0 : v; // -0 is written as 0, so read it as 0
|
|
1072
|
+
};
|
|
1073
|
+
const entries = (field, max) => {
|
|
1074
|
+
const record = value[field];
|
|
1075
|
+
if (record === undefined)
|
|
1076
|
+
return undefined;
|
|
1077
|
+
const at = `${path}.${field}`;
|
|
1078
|
+
if (!isObject(record)) {
|
|
1079
|
+
errors.push({ code: 'INVALID_FIELD', path: at, message: 'Expected an object keyed by id.' });
|
|
1080
|
+
return undefined;
|
|
1081
|
+
}
|
|
1082
|
+
const list = Object.entries(record);
|
|
1083
|
+
if (list.length > max) {
|
|
1084
|
+
errors.push({ code: 'LIMIT_EXCEEDED', path: at, message: `More than ${max} entries.` });
|
|
1085
|
+
return undefined;
|
|
1086
|
+
}
|
|
1087
|
+
return list.filter(([key]) => readId(key, `${at}.${key}`, limits, errors) !== undefined).map(([key, v]) => [key, v, `${at}.${key}`]);
|
|
1088
|
+
};
|
|
1089
|
+
const symbolEntries = entries('symbols', limits.maxComponents);
|
|
1090
|
+
if (symbolEntries) {
|
|
1091
|
+
const symbols = {};
|
|
1092
|
+
for (const [id, v, at] of symbolEntries) {
|
|
1093
|
+
if (!isObject(v)) {
|
|
1094
|
+
errors.push({ code: 'INVALID_FIELD', path: at, message: 'Expected a symbol placement object.' });
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
const before = errors.length;
|
|
1098
|
+
checkKeys(v, symbolKeys, at, errors);
|
|
1099
|
+
const x = coordinate(v.x, `${at}.x`), y = coordinate(v.y, `${at}.y`);
|
|
1100
|
+
if (v.rotation !== undefined && !(v.rotation === 0 || v.rotation === 90 || v.rotation === 180 || v.rotation === 270)) {
|
|
1101
|
+
errors.push({ code: 'INVALID_FIELD', path: `${at}.rotation`, message: 'Expected a rotation of 0, 90, 180 or 270.' });
|
|
1102
|
+
}
|
|
1103
|
+
if (v.hidden !== undefined && v.hidden !== true)
|
|
1104
|
+
errors.push({ code: 'INVALID_FIELD', path: `${at}.hidden`, message: 'Expected true, or no field.' });
|
|
1105
|
+
if (errors.length > before || x === undefined || y === undefined)
|
|
1106
|
+
continue;
|
|
1107
|
+
put(symbols, id, { x, y, ...(v.rotation !== undefined ? { rotation: v.rotation } : {}), ...(v.hidden ? { hidden: true } : {}) });
|
|
1108
|
+
}
|
|
1109
|
+
layout.symbols = symbols;
|
|
1110
|
+
}
|
|
1111
|
+
const routeEntries = entries('routes', limits.maxConnections + limits.maxAssociations);
|
|
1112
|
+
if (routeEntries) {
|
|
1113
|
+
const routes = {};
|
|
1114
|
+
for (const [id, v, at] of routeEntries) {
|
|
1115
|
+
if (!isObject(v)) {
|
|
1116
|
+
errors.push({ code: 'INVALID_FIELD', path: at, message: 'Expected a route object.' });
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
checkKeys(v, ['points'], at, errors);
|
|
1120
|
+
const points = v.points;
|
|
1121
|
+
if (!Array.isArray(points) || points.length < 2 || points.length > MAX_ROUTE_POINTS) {
|
|
1122
|
+
errors.push({ code: 'INVALID_FIELD', path: `${at}.points`, message: `Expected 2 to ${MAX_ROUTE_POINTS} points.` });
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
const read = [];
|
|
1126
|
+
for (let i = 0; i < points.length; i++) {
|
|
1127
|
+
const p = points[i];
|
|
1128
|
+
if (!Array.isArray(p) || p.length !== 2) {
|
|
1129
|
+
errors.push({ code: 'INVALID_FIELD', path: `${at}.points[${i}]`, message: 'Expected an [x, y] point.' });
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
const x = coordinate(p[0], `${at}.points[${i}][0]`), y = coordinate(p[1], `${at}.points[${i}][1]`);
|
|
1133
|
+
if (x !== undefined && y !== undefined)
|
|
1134
|
+
read.push([x, y]);
|
|
1135
|
+
}
|
|
1136
|
+
if (read.length === points.length)
|
|
1137
|
+
put(routes, id, { points: read });
|
|
1138
|
+
}
|
|
1139
|
+
layout.routes = routes;
|
|
1140
|
+
}
|
|
1141
|
+
return layout;
|
|
1142
|
+
}
|
|
1143
|
+
/** Every placed symbol is a component, and every route a connection or association (ADR-0026). */
|
|
1144
|
+
function checkViews(scene, errors) {
|
|
1145
|
+
if (!scene.views)
|
|
1146
|
+
return;
|
|
1147
|
+
const components = new Set(scene.components.map((c) => c.id));
|
|
1148
|
+
const edges = new Set([...scene.connections.map((c) => c.id), ...(scene.associations ?? []).map((a) => a.id)]);
|
|
1149
|
+
for (const kind of viewKinds) {
|
|
1150
|
+
const layout = scene.views[kind];
|
|
1151
|
+
for (const id of Object.keys(layout?.symbols ?? {})) {
|
|
1152
|
+
if (!components.has(id))
|
|
1153
|
+
errors.push({ code: 'UNKNOWN_VIEW_REFERENCE', path: `views.${kind}.symbols.${id}`, message: `No component "${id}" to place.` });
|
|
1154
|
+
}
|
|
1155
|
+
for (const id of Object.keys(layout?.routes ?? {})) {
|
|
1156
|
+
if (!edges.has(id))
|
|
1157
|
+
errors.push({ code: 'UNKNOWN_VIEW_REFERENCE', path: `views.${kind}.routes.${id}`, message: `No connection or association "${id}" to route.` });
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
/** Reads the optional reference drawing (ADR-0012); sanitizing is the host's `validateUnderlay`. */
|
|
436
1162
|
function readUnderlay(value, limits, options, errors) {
|
|
437
1163
|
if (!isObject(value)) {
|
|
438
1164
|
errors.push({ code: 'INVALID_FIELD', path: 'underlay', message: 'Expected an underlay object.' });
|
|
@@ -471,6 +1197,15 @@ function readUnderlay(value, limits, options, errors) {
|
|
|
471
1197
|
}
|
|
472
1198
|
return { svg, position: { x, z }, width, rotation, opacity };
|
|
473
1199
|
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Validates an untrusted value as a scene and returns it in the current (version 4) model.
|
|
1202
|
+
* Version 1 (`version: 1`), version 2 (array vectors) and version 3 input is migrated
|
|
1203
|
+
* deterministically and reported via `migratedFrom` (ADR-0021).
|
|
1204
|
+
* Never mutates or repairs the input: on success it returns a fresh copy, otherwise every error
|
|
1205
|
+
* found, in input order, with paths in the input's own field names. Reference checks (ids, ports,
|
|
1206
|
+
* compatibility, occupancy, scale policy) run only once the structure is valid. Each port accepts
|
|
1207
|
+
* one connection.
|
|
1208
|
+
*/
|
|
474
1209
|
export function validateScene(input, options) {
|
|
475
1210
|
const limits = { ...defaultSceneLimits, ...options.limits };
|
|
476
1211
|
const errors = [];
|
|
@@ -480,10 +1215,13 @@ export function validateScene(input, options) {
|
|
|
480
1215
|
let version;
|
|
481
1216
|
if ('schemaVersion' in input) {
|
|
482
1217
|
// Unsupported numbers are reported and read with the newest rules.
|
|
483
|
-
version = input.schemaVersion === 2 ?
|
|
484
|
-
if (input.schemaVersion !== 2 && input.schemaVersion !== 3) {
|
|
1218
|
+
version = input.schemaVersion === 2 || input.schemaVersion === 3 ? input.schemaVersion : 4;
|
|
1219
|
+
if (input.schemaVersion !== 2 && input.schemaVersion !== 3 && input.schemaVersion !== 4) {
|
|
485
1220
|
errors.push({ code: 'UNSUPPORTED_VERSION', path: 'schemaVersion', message: `Unsupported schemaVersion ${describe(input.schemaVersion)}.` });
|
|
486
1221
|
}
|
|
1222
|
+
if (input.schemaVersion === 4 && input.documentType !== SCENE_DOCUMENT_TYPE) {
|
|
1223
|
+
errors.push({ code: 'INVALID_FIELD', path: 'documentType', message: `Expected documentType "${SCENE_DOCUMENT_TYPE}".` });
|
|
1224
|
+
}
|
|
487
1225
|
if (input.units !== 'm') {
|
|
488
1226
|
errors.push({ code: 'INVALID_FIELD', path: 'units', message: 'Expected units "m".' });
|
|
489
1227
|
}
|
|
@@ -495,19 +1233,48 @@ export function validateScene(input, options) {
|
|
|
495
1233
|
}
|
|
496
1234
|
}
|
|
497
1235
|
checkKeys(input, sceneKeys[version], '', errors);
|
|
498
|
-
const
|
|
499
|
-
const
|
|
1236
|
+
const extensionBudget = { left: MAX_EXTENSION_VALUES, exceeded: `Extensions hold more than ${MAX_EXTENSION_VALUES} values.` };
|
|
1237
|
+
const project = version === 4 && input.project !== undefined ? readProject(input.project, limits, errors) : undefined;
|
|
1238
|
+
const components = readList(input.components, 'components', limits.maxComponents, errors, (item, path) => readComponent(item, path, version, limits, errors, extensionBudget, options.resolveType));
|
|
1239
|
+
const connections = readList(input.connections, 'connections', limits.maxConnections, errors, (item, path) => readConnection(item, path, version, limits, errors));
|
|
1240
|
+
const associations = version === 4 && input.associations !== undefined
|
|
1241
|
+
? readList(input.associations, 'associations', limits.maxAssociations, errors, (item, path) => readAssociation(item, path, limits, errors))
|
|
1242
|
+
: undefined;
|
|
1243
|
+
const dataSources = version === 4 && input.dataSources !== undefined
|
|
1244
|
+
? readList(input.dataSources, 'dataSources', limits.maxDataSources, errors, (item, path) => readDataSource(item, path, limits, errors))
|
|
1245
|
+
: undefined;
|
|
1246
|
+
const runtimeVariables = version === 4 && input.runtimeVariables !== undefined
|
|
1247
|
+
? readList(input.runtimeVariables, 'runtimeVariables', limits.maxVariables, errors, (item, path) => readVariable(item, path, limits, errors))
|
|
1248
|
+
: undefined;
|
|
500
1249
|
const tagBindings = version !== 1 && input.tagBindings !== undefined
|
|
501
1250
|
? readList(input.tagBindings, 'tagBindings', limits.maxBindings, errors, (item, path) => readBinding(item, path, limits, errors))
|
|
502
1251
|
: undefined;
|
|
503
|
-
const underlay = version
|
|
1252
|
+
const underlay = version >= 3 && input.underlay !== undefined ? readUnderlay(input.underlay, limits, options, errors) : undefined;
|
|
1253
|
+
const views = version === 4 && input.views !== undefined ? readViews(input.views, limits, errors) : undefined;
|
|
1254
|
+
const extensions = version === 4 && input.extensions !== undefined ? readExtensions(input.extensions, 'extensions', extensionBudget, limits, errors) : undefined;
|
|
504
1255
|
if (errors.length > 0 || !components || !connections)
|
|
505
1256
|
return { ok: false, errors };
|
|
506
|
-
const scene = {
|
|
1257
|
+
const scene = {
|
|
1258
|
+
schemaVersion: 4,
|
|
1259
|
+
documentType: SCENE_DOCUMENT_TYPE,
|
|
1260
|
+
units: 'm',
|
|
1261
|
+
...(project ? { project } : {}),
|
|
1262
|
+
components,
|
|
1263
|
+
connections,
|
|
1264
|
+
...(associations ? { associations } : {}),
|
|
1265
|
+
...(dataSources ? { dataSources } : {}),
|
|
1266
|
+
...(runtimeVariables ? { runtimeVariables } : {}),
|
|
1267
|
+
...(tagBindings ? { tagBindings } : {}),
|
|
1268
|
+
...(underlay ? { underlay } : {}),
|
|
1269
|
+
...(views ? { views } : {}),
|
|
1270
|
+
...(extensions ? { extensions } : {}),
|
|
1271
|
+
};
|
|
507
1272
|
checkReferences(scene, options, errors);
|
|
1273
|
+
checkViews(scene, errors);
|
|
508
1274
|
checkBindings(scene, options, errors);
|
|
1275
|
+
checkAcquisition(scene, options, errors);
|
|
509
1276
|
if (errors.length > 0)
|
|
510
1277
|
return { ok: false, errors };
|
|
511
|
-
return version ===
|
|
1278
|
+
return version === 4 ? { ok: true, scene } : { ok: true, scene, migratedFrom: version };
|
|
512
1279
|
}
|
|
513
1280
|
//# sourceMappingURL=validation.js.map
|