node-opcua-address-space 2.181.0 → 2.182.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.
Files changed (136) hide show
  1. package/api/helpers/ensure_secure_access.ts +53 -2
  2. package/api/index.ts +30 -1
  3. package/api/loader/generateAddressSpaceRaw.ts +44 -139
  4. package/api/loader/load_nodeset2.ts +5 -0
  5. package/api/loader/narrow_instance_datatypes.ts +264 -0
  6. package/api/loader/nodeset_builtin_formats.ts +143 -0
  7. package/api/loader/nodeset_format.ts +153 -0
  8. package/api/loader/nodeset_image.ts +37 -4
  9. package/api/loader/nodeset_image_codec.ts +153 -34
  10. package/api/loader/nodeset_record.ts +26 -2
  11. package/api/loader/nodeset_record_applier.ts +54 -3
  12. package/api/loader/nodeset_records_to_xml.ts +620 -0
  13. package/api/loader/nodeset_source.ts +185 -26
  14. package/api/loader/nodeset_to_image.ts +16 -5
  15. package/api/loader/nodeset_xml_header.ts +119 -0
  16. package/api/loader/nodeset_xml_primitives.ts +209 -0
  17. package/api/loader/nodeset_xml_producer.ts +61 -2
  18. package/api/loader/parsers/variant_parser.ts +9 -0
  19. package/bin/nodeset_equivalence.js +750 -0
  20. package/bin/nodeset_tool_common.js +72 -0
  21. package/bin/opcua-nodeset-image.js +93 -103
  22. package/bin/opcua-nodeset-live.js +139 -0
  23. package/dist/api/helpers/ensure_secure_access.d.ts +20 -0
  24. package/dist/api/helpers/ensure_secure_access.js +39 -0
  25. package/dist/api/helpers/ensure_secure_access.js.map +1 -1
  26. package/dist/api/index.d.ts +7 -2
  27. package/dist/api/index.js +21 -1
  28. package/dist/api/index.js.map +1 -1
  29. package/dist/api/loader/generateAddressSpaceRaw.d.ts +1 -7
  30. package/dist/api/loader/generateAddressSpaceRaw.js +32 -125
  31. package/dist/api/loader/generateAddressSpaceRaw.js.map +1 -1
  32. package/dist/api/loader/load_nodeset2.js +4 -0
  33. package/dist/api/loader/load_nodeset2.js.map +1 -1
  34. package/dist/api/loader/narrow_instance_datatypes.d.ts +30 -0
  35. package/dist/api/loader/narrow_instance_datatypes.js +217 -0
  36. package/dist/api/loader/narrow_instance_datatypes.js.map +1 -0
  37. package/dist/api/loader/nodeset_builtin_formats.d.ts +27 -0
  38. package/dist/api/loader/nodeset_builtin_formats.js +133 -0
  39. package/dist/api/loader/nodeset_builtin_formats.js.map +1 -0
  40. package/dist/api/loader/nodeset_format.d.ts +123 -0
  41. package/dist/api/loader/nodeset_format.js +63 -0
  42. package/dist/api/loader/nodeset_format.js.map +1 -0
  43. package/dist/api/loader/nodeset_image.d.ts +17 -1
  44. package/dist/api/loader/nodeset_image.js +32 -4
  45. package/dist/api/loader/nodeset_image.js.map +1 -1
  46. package/dist/api/loader/nodeset_image_codec.d.ts +4 -0
  47. package/dist/api/loader/nodeset_image_codec.js +142 -53
  48. package/dist/api/loader/nodeset_image_codec.js.map +1 -1
  49. package/dist/api/loader/nodeset_record.d.ts +26 -2
  50. package/dist/api/loader/nodeset_record.js +11 -2
  51. package/dist/api/loader/nodeset_record.js.map +1 -1
  52. package/dist/api/loader/nodeset_record_applier.js +52 -3
  53. package/dist/api/loader/nodeset_record_applier.js.map +1 -1
  54. package/dist/api/loader/nodeset_records_to_xml.d.ts +16 -0
  55. package/dist/api/loader/nodeset_records_to_xml.js +536 -0
  56. package/dist/api/loader/nodeset_records_to_xml.js.map +1 -0
  57. package/dist/api/loader/nodeset_source.d.ts +68 -7
  58. package/dist/api/loader/nodeset_source.js +157 -20
  59. package/dist/api/loader/nodeset_source.js.map +1 -1
  60. package/dist/api/loader/nodeset_to_image.d.ts +4 -4
  61. package/dist/api/loader/nodeset_to_image.js +14 -4
  62. package/dist/api/loader/nodeset_to_image.js.map +1 -1
  63. package/dist/api/loader/nodeset_xml_header.d.ts +22 -0
  64. package/dist/api/loader/nodeset_xml_header.js +113 -0
  65. package/dist/api/loader/nodeset_xml_header.js.map +1 -0
  66. package/dist/api/loader/nodeset_xml_primitives.d.ts +37 -0
  67. package/dist/api/loader/nodeset_xml_primitives.js +196 -0
  68. package/dist/api/loader/nodeset_xml_primitives.js.map +1 -0
  69. package/dist/api/loader/nodeset_xml_producer.js +56 -1
  70. package/dist/api/loader/nodeset_xml_producer.js.map +1 -1
  71. package/dist/api/loader/parsers/variant_parser.js +7 -0
  72. package/dist/api/loader/parsers/variant_parser.js.map +1 -1
  73. package/dist/impl/_instantiate_helpers.js +1 -0
  74. package/dist/impl/_instantiate_helpers.js.map +1 -1
  75. package/dist/impl/alarms_and_conditions/ua_alarm_condition_impl.js +4 -0
  76. package/dist/impl/alarms_and_conditions/ua_alarm_condition_impl.js.map +1 -1
  77. package/dist/impl/alarms_and_conditions/ua_certificate_expiration_alarm_impl.js +14 -0
  78. package/dist/impl/alarms_and_conditions/ua_certificate_expiration_alarm_impl.js.map +1 -1
  79. package/dist/impl/base_node_impl.d.ts +9 -0
  80. package/dist/impl/base_node_impl.js +11 -0
  81. package/dist/impl/base_node_impl.js.map +1 -1
  82. package/dist/impl/base_node_private.js +67 -97
  83. package/dist/impl/base_node_private.js.map +1 -1
  84. package/dist/impl/nodeset_tools/construct_namespace_dependency.js +5 -1
  85. package/dist/impl/nodeset_tools/construct_namespace_dependency.js.map +1 -1
  86. package/dist/impl/nodeset_tools/nodeset_to_records.js +61 -15
  87. package/dist/impl/nodeset_tools/nodeset_to_records.js.map +1 -1
  88. package/dist/impl/nodeset_tools/nodeset_to_xml.d.ts +1 -8
  89. package/dist/impl/nodeset_tools/nodeset_to_xml.js +174 -190
  90. package/dist/impl/nodeset_tools/nodeset_to_xml.js.map +1 -1
  91. package/dist/impl/ua_data_type_impl.d.ts +21 -0
  92. package/dist/impl/ua_data_type_impl.js +27 -2
  93. package/dist/impl/ua_data_type_impl.js.map +1 -1
  94. package/dist/impl/ua_object_type_impl.d.ts +9 -0
  95. package/dist/impl/ua_object_type_impl.js +12 -1
  96. package/dist/impl/ua_object_type_impl.js.map +1 -1
  97. package/dist/impl/ua_variable_impl.js +27 -10
  98. package/dist/impl/ua_variable_impl.js.map +1 -1
  99. package/dist/impl/ua_variable_impl_ext_obj.js +19 -2
  100. package/dist/impl/ua_variable_impl_ext_obj.js.map +1 -1
  101. package/dist/impl/ua_variable_type_impl.d.ts +8 -1
  102. package/dist/impl/ua_variable_type_impl.js +39 -2
  103. package/dist/impl/ua_variable_type_impl.js.map +1 -1
  104. package/dist/tsconfig_base.tsbuildinfo +1 -1
  105. package/distNodeJS/generate_address_space.js +4 -0
  106. package/distNodeJS/generate_address_space.js.map +1 -1
  107. package/impl/_instantiate_helpers.ts +3 -0
  108. package/impl/alarms_and_conditions/ua_acknowledgeable_condition_impl.ts +4 -4
  109. package/impl/alarms_and_conditions/ua_alarm_condition_impl.ts +19 -9
  110. package/impl/alarms_and_conditions/ua_base_event_impl.ts +9 -9
  111. package/impl/alarms_and_conditions/ua_certificate_expiration_alarm_impl.ts +17 -0
  112. package/impl/alarms_and_conditions/ua_condition_impl.ts +18 -18
  113. package/impl/alarms_and_conditions/ua_exclusive_deviation_alarm_impl.ts +2 -2
  114. package/impl/alarms_and_conditions/ua_exclusive_limit_alarm_impl.ts +1 -1
  115. package/impl/alarms_and_conditions/ua_limit_alarm_impl.ts +16 -16
  116. package/impl/alarms_and_conditions/ua_non_exclusive_deviation_alarm_impl.ts +2 -2
  117. package/impl/alarms_and_conditions/ua_non_exclusive_limit_alarm_impl.ts +4 -4
  118. package/impl/alarms_and_conditions/ua_off_normal_alarm_impl.ts +1 -1
  119. package/impl/base_node_impl.ts +15 -0
  120. package/impl/base_node_private.ts +97 -146
  121. package/impl/data_access/ua_multistate_discrete_impl.ts +1 -1
  122. package/impl/data_access/ua_multistate_value_discrete_impl.ts +2 -2
  123. package/impl/data_access/ua_two_state_discrete_impl.ts +2 -2
  124. package/impl/nodeset_tools/construct_namespace_dependency.ts +5 -1
  125. package/impl/nodeset_tools/nodeset_to_records.ts +66 -16
  126. package/impl/nodeset_tools/nodeset_to_xml.ts +127 -160
  127. package/impl/state_machine/finite_state_machine.ts +3 -3
  128. package/impl/state_machine/ua_shelving_state_machine_ex.ts +20 -20
  129. package/impl/ua_data_type_impl.ts +42 -4
  130. package/impl/ua_method_impl.ts +1 -1
  131. package/impl/ua_object_type_impl.ts +16 -3
  132. package/impl/ua_variable_impl.ts +27 -10
  133. package/impl/ua_variable_impl_ext_obj.ts +20 -2
  134. package/impl/ua_variable_type_impl.ts +49 -3
  135. package/package.json +29 -27
  136. package/source_nodejs/generate_address_space.ts +7 -1
@@ -13,15 +13,21 @@
13
13
  * - QualifiedName: `[namespaceIndex, name]`; LocalizedText: `{ locale, text }`
14
14
  * - DateTime: ISO-8601 string, or `{ iso, picoseconds }` when sub-millisecond precision is carried
15
15
  * - ByteString: base64; Guid, String, XmlElement: the string
16
- * - Int64 and UInt64: `[high, low]`; Float and Double: JSON numbers, `NaN`, `Infinity` and
16
+ * - Int64 and UInt64: a decimal string; Float and Double: JSON numbers, `NaN`, `Infinity` and
17
17
  * `-Infinity` as strings; StatusCode: the numeric code
18
18
  * - the extension objects the XML reader decodes itself (Argument, EUInformation, Range,
19
19
  * EnumValueType): `{ "$class": name, ...fields }` with the rules above; every other extension
20
20
  * object is the XML fragment the reader captured: `{ "$xml": [typeId, body] }`
21
21
  *
22
22
  * A value is `{ dataType, arrayType?, dimensions?, value }`; `arrayType` is omitted for a scalar.
23
+ *
24
+ * Every key whose value is the one it would take anyway is left out; {@link NODE_DEFAULTS} is the
25
+ * table, and a reader that does not find a key applies the default from it. gzip already codes a
26
+ * key repeated verbatim in a couple of bits, so this is worth far less compressed than it looks
27
+ * on the raw text -- except for `displayName`, which repeats the browse name on 97% of the nodes
28
+ * of the published nodesets and is the one omission that carries real entropy away.
23
29
  */
24
- import { coerceInt64, coerceUInt64, type Int64, type UInt64 } from "node-opcua-basic-types";
30
+ import type { Int64, UInt64 } from "node-opcua-basic-types";
25
31
  import { Range } from "node-opcua-data-access";
26
32
  import {
27
33
  coerceLocalizedText,
@@ -47,6 +53,7 @@ import {
47
53
  type NodesetReferenceRecord,
48
54
  XmlExtensionObjectFragment
49
55
  } from "./nodeset_record.js";
56
+ import { int64ToDecimalString } from "./nodeset_xml_primitives.js";
50
57
 
51
58
  /**
52
59
  * a NodeId: the number alone when numeric in namespace 0 (nearly every id of a nodeset),
@@ -146,39 +153,113 @@ function decodeFloat(json: Json): number {
146
153
  return json as number;
147
154
  }
148
155
 
156
+ // #region defaults
157
+ /**
158
+ * what a key of a node record means when the image leaves it out: the value the reader would have
159
+ * produced anyway. A writer omits a key whose encoded value is its default here; a reader that
160
+ * does not find a key applies the default. The XML reader synthesizes every one of these from an
161
+ * absent attribute (see `nodeset_xml_producer`) and the address space export already omitted most
162
+ * of them, so this table is also what makes the two record producers agree.
163
+ *
164
+ * `displayName` is not in the table: its default is the node's own browse name rather than a
165
+ * constant, and it is the only key a reader must put back rather than infer, since a consumer
166
+ * reads it directly. It is also the only omission worth much once gzipped -- a constant repeated
167
+ * verbatim on every line costs a couple of bits, a browse name repeated does not.
168
+ */
169
+ const NODE_DEFAULTS: Readonly<Record<string, unknown>> = {
170
+ valueRank: -1,
171
+ arrayDimensions: null,
172
+ minimumSamplingInterval: 0,
173
+ historizing: false,
174
+ isAbstract: false,
175
+ symmetric: false,
176
+ containsNoLoops: false,
177
+ hasNoPermissions: false,
178
+ eventNotifier: 0,
179
+ parentNodeId: null,
180
+ dataType: null,
181
+ methodDeclarationId: null
182
+ };
183
+
184
+ /** the same, for one field of a DataType definition; an absent DataType attribute is BaseDataType */
185
+ const FIELD_DEFAULT_VALUE_RANK = -1;
186
+ const FIELD_DEFAULT_ALLOW_SUBTYPES = false;
187
+ const FIELD_DEFAULT_DATA_TYPE: JsonNodeId = 24; // i=24, BaseDataType
188
+
189
+ /** an absent ValueRank on an Argument, as on any other node */
190
+ const ARGUMENT_DEFAULT_VALUE_RANK = -1;
191
+
192
+ /** drop from `out` every key whose value is the one a reader would assume anyway */
193
+ function omitDefaults(out: Record<string, unknown>): void {
194
+ for (const [key, fallback] of Object.entries(NODE_DEFAULTS)) {
195
+ if (key in out && out[key] === fallback) {
196
+ delete out[key];
197
+ }
198
+ }
199
+ }
200
+
201
+ /** whether a localized text carries nothing: the reader's `{}` for an absent Description */
202
+ const isEmptyText = (text: JsonLocalizedText | null | undefined): boolean =>
203
+ text === null || text === undefined || (text.locale === undefined && text.text === undefined);
204
+
205
+ // #endregion
206
+
207
+ // #region 64-bit integers
208
+ //
209
+ // on the wire a 64-bit integer is a decimal string, the spelling Part 6 uses and the only one that
210
+ // says what it means. In memory it stays node-opcua's `[high, low]` pair, whose halves are
211
+ // unsigned: Int64 -1 and UInt64 18446744073709551615 are the *same* pair, so the pair cannot be
212
+ // read without knowing the signedness from somewhere else, and Int64 min has two equally defensible
213
+ // spellings ([2147483648,0] and [-2147483648,0]) which would give one value two documents.
214
+
215
+ const TWO_64 = 1n << 64n;
216
+
217
+ const encodeInt64 = int64ToDecimalString;
218
+
219
+ function decodeInt64(json: Json): Int64 {
220
+ let unsigned = BigInt(json as string);
221
+ if (unsigned < 0n) unsigned += TWO_64;
222
+ return [Number(unsigned >> 32n), Number(unsigned & 0xffffffffn)] as Int64;
223
+ }
224
+
225
+ // #endregion
226
+
149
227
  function encodeExtensionObject(value: unknown): Json {
150
228
  if (value instanceof XmlExtensionObjectFragment) {
151
229
  return { $xml: [encodeNodeId(value.typeId), value.bodyXML] };
152
230
  }
153
231
  if (value instanceof Argument) {
154
- return {
155
- $class: "Argument",
156
- name: value.name,
157
- dataType: encodeNodeId(value.dataType),
158
- valueRank: value.valueRank,
159
- arrayDimensions: value.arrayDimensions,
160
- description: encodeLocalizedText(value.description)
161
- };
232
+ const out: Record<string, Json> = { $class: "Argument", name: value.name, dataType: encodeNodeId(value.dataType) };
233
+ if (value.valueRank !== ARGUMENT_DEFAULT_VALUE_RANK) out.valueRank = value.valueRank;
234
+ // an empty arrayDimensions is what a scalar argument carries and is left out; a `null` one
235
+ // is not the same thing to the reader that put it there, and is written
236
+ if (value.arrayDimensions === null || value.arrayDimensions.length > 0) out.arrayDimensions = value.arrayDimensions;
237
+ const description = encodeLocalizedText(value.description);
238
+ if (!isEmptyText(description)) out.description = description;
239
+ return out;
162
240
  }
163
241
  if (value instanceof EUInformation) {
164
- return {
242
+ const out: Record<string, Json> = {
165
243
  $class: "EUInformation",
166
244
  namespaceUri: value.namespaceUri,
167
- unitId: value.unitId,
168
- displayName: encodeLocalizedText(value.displayName),
169
- description: encodeLocalizedText(value.description)
245
+ unitId: value.unitId
170
246
  };
247
+ const displayName = encodeLocalizedText(value.displayName);
248
+ const description = encodeLocalizedText(value.description);
249
+ if (!isEmptyText(displayName)) out.displayName = displayName;
250
+ if (!isEmptyText(description)) out.description = description;
251
+ return out;
171
252
  }
172
253
  if (value instanceof Range) {
173
254
  return { $class: "Range", low: encodeFloat(value.low), high: encodeFloat(value.high) };
174
255
  }
175
256
  if (value instanceof EnumValueType) {
176
- return {
177
- $class: "EnumValueType",
178
- value: value.value,
179
- displayName: encodeLocalizedText(value.displayName),
180
- description: encodeLocalizedText(value.description)
181
- };
257
+ const out: Record<string, Json> = { $class: "EnumValueType", value: encodeInt64(value.value, true) };
258
+ const displayName = encodeLocalizedText(value.displayName);
259
+ const description = encodeLocalizedText(value.description);
260
+ if (!isEmptyText(displayName)) out.displayName = displayName;
261
+ if (!isEmptyText(description)) out.description = description;
262
+ return out;
182
263
  }
183
264
  const name = (value as ExtensionObject).constructor?.name ?? typeof value;
184
265
  throw new NodesetImageError(
@@ -197,8 +278,10 @@ function decodeExtensionObject(json: Json): ExtensionObject | XmlExtensionObject
197
278
  const argument = new Argument({});
198
279
  argument.name = j.name as string;
199
280
  argument.dataType = decodeNodeId(j.dataType);
200
- argument.valueRank = j.valueRank as number;
201
- argument.arrayDimensions = (j.arrayDimensions as number[] | null) ?? null;
281
+ argument.valueRank = (j.valueRank as number | undefined) ?? ARGUMENT_DEFAULT_VALUE_RANK;
282
+ // an absent arrayDimensions is the empty one the reader leaves on a scalar argument;
283
+ // an explicit `null` is the one the address space export writes, and is kept apart
284
+ argument.arrayDimensions = j.arrayDimensions === undefined ? [] : (j.arrayDimensions as number[] | null);
202
285
  argument.description = decodeLocalizedText(j.description);
203
286
  return argument;
204
287
  }
@@ -217,7 +300,7 @@ function decodeExtensionObject(json: Json): ExtensionObject | XmlExtensionObject
217
300
  }
218
301
  case "EnumValueType":
219
302
  return new EnumValueType({
220
- value: coerceInt64(j.value as Int64),
303
+ value: decodeInt64(j.value as Json),
221
304
  displayName: decodeLocalizedText(j.displayName),
222
305
  description: decodeLocalizedText(j.description)
223
306
  });
@@ -258,8 +341,12 @@ function encodeElement(dataType: DataType, value: unknown): Json {
258
341
  case DataType.DataValue:
259
342
  case DataType.DiagnosticInfo:
260
343
  throw new NodesetImageError(`an image cannot carry a ${DataType[dataType]} value`);
344
+ case DataType.Int64:
345
+ return encodeInt64(value as Int64, true);
346
+ case DataType.UInt64:
347
+ return encodeInt64(value as UInt64, false);
261
348
  default:
262
- // Boolean, the integer families (Int64 and UInt64 are already [high, low]), String, Guid, XmlElement
349
+ // Boolean, the 32-bit-and-under integer families, String, Guid, XmlElement
263
350
  return value;
264
351
  }
265
352
  }
@@ -293,9 +380,8 @@ function decodeElement(dataType: DataType, json: Json): unknown {
293
380
  case DataType.Variant:
294
381
  return new Variant(decodeValue(json as JsonValue));
295
382
  case DataType.Int64:
296
- return coerceInt64(json as Int64);
297
383
  case DataType.UInt64:
298
- return coerceUInt64(json as UInt64);
384
+ return decodeInt64(json);
299
385
  default:
300
386
  return json;
301
387
  }
@@ -362,6 +448,7 @@ export interface NodesetImageHeader {
362
448
  accessRestrictions?: string;
363
449
  }>;
364
450
  aliases: Record<string, JsonNodeId>;
451
+ extensions?: string[];
365
452
  }
366
453
 
367
454
  export interface NodesetImageTrailer {
@@ -409,6 +496,7 @@ export function encodeHeader(record: NodesetHeaderRecord, options: EncodeHeaderO
409
496
  }),
410
497
  aliases
411
498
  };
499
+ if (record.extensions?.length) header.extensions = record.extensions;
412
500
  if (options.sourceLength !== undefined) header.sourceLength = options.sourceLength;
413
501
  return header;
414
502
  }
@@ -439,7 +527,9 @@ export function decodeHeader(json: NodesetImageHeader): NodesetHeaderRecord {
439
527
  if (m.accessRestrictions !== undefined) model.accessRestrictions = m.accessRestrictions;
440
528
  return model;
441
529
  });
442
- return { kind: "header", namespaceUris: json.namespaceUris || [], models, aliases };
530
+ const header: NodesetHeaderRecord = { kind: "header", namespaceUris: json.namespaceUris || [], models, aliases };
531
+ if (json.extensions?.length) header.extensions = json.extensions;
532
+ return header;
443
533
  }
444
534
 
445
535
  interface JsonField extends Omit<NodesetDefinitionField, "dataType"> {
@@ -451,6 +541,8 @@ export interface NodesetImageNode {
451
541
  browseName: JsonQualifiedName;
452
542
  displayName?: string;
453
543
  description?: string;
544
+ category?: string[];
545
+ documentation?: string;
454
546
  references: JsonReference[];
455
547
  releaseStatus?: "Draft" | "Deprecated";
456
548
  symbolicName?: string;
@@ -472,12 +564,14 @@ export interface NodesetImageNode {
472
564
  userAccessLevel?: string;
473
565
  value?: JsonValue;
474
566
  methodDeclarationId?: JsonNodeId | null;
475
- definition?: { name?: string; isUnion?: boolean; fields: JsonField[] };
567
+ definition?: { name?: string; isUnion?: boolean; isOptionSet?: boolean; fields: JsonField[] };
476
568
  }
477
569
 
478
570
  const OPTIONAL_PLAIN: Array<keyof NodesetNodeRecord & keyof NodesetImageNode> = [
479
571
  "displayName",
480
572
  "description",
573
+ "category",
574
+ "documentation",
481
575
  "releaseStatus",
482
576
  "symbolicName",
483
577
  "accessRestrictions",
@@ -521,15 +615,37 @@ export function encodeNode(record: NodesetNodeRecord): NodesetImageNode {
521
615
  if (record.methodDeclarationId !== undefined) out.methodDeclarationId = encodeNodeIdOrNull(record.methodDeclarationId);
522
616
  if (record.value !== undefined) out.value = encodeValue(record.value);
523
617
  if (record.definition) {
524
- out.definition = {
525
- fields: record.definition.fields.map((f) => ({ ...f, dataType: encodeNodeIdOrNull(f.dataType) }))
526
- };
618
+ out.definition = { fields: record.definition.fields.map(encodeDefinitionField) };
527
619
  if (record.definition.name !== undefined) out.definition.name = record.definition.name;
528
620
  if (record.definition.isUnion !== undefined) out.definition.isUnion = record.definition.isUnion;
621
+ if (record.definition.isOptionSet !== undefined) out.definition.isOptionSet = record.definition.isOptionSet;
622
+ }
623
+ if (out.displayName !== undefined && out.displayName === out.browseName[1]) {
624
+ // the browse name over again on nearly every node of a published nodeset; a reader puts it back
625
+ delete out.displayName;
529
626
  }
627
+ omitDefaults(out as unknown as Record<string, unknown>);
628
+ return out;
629
+ }
630
+
631
+ function encodeDefinitionField(field: NodesetDefinitionField): JsonField {
632
+ const out = { ...field, dataType: encodeNodeIdOrNull(field.dataType) } as JsonField & Record<string, unknown>;
633
+ if (out.valueRank === FIELD_DEFAULT_VALUE_RANK) delete out.valueRank;
634
+ if (out.allowSubTypes === FIELD_DEFAULT_ALLOW_SUBTYPES) delete out.allowSubTypes;
635
+ if (out.dataType === FIELD_DEFAULT_DATA_TYPE) delete out.dataType;
530
636
  return out;
531
637
  }
532
638
 
639
+ function decodeDefinitionField(json: JsonField): NodesetDefinitionField {
640
+ const field = {
641
+ ...json,
642
+ valueRank: json.valueRank ?? FIELD_DEFAULT_VALUE_RANK,
643
+ allowSubTypes: json.allowSubTypes ?? FIELD_DEFAULT_ALLOW_SUBTYPES,
644
+ dataType: decodeNodeIdOrNull(json.dataType ?? FIELD_DEFAULT_DATA_TYPE)
645
+ };
646
+ return field as unknown as NodesetDefinitionField;
647
+ }
648
+
533
649
  export function decodeNode(json: NodesetImageNode): NodesetNodeRecord {
534
650
  if (typeof json.nodeClass !== "number" || !(json.nodeClass in NodeClass)) {
535
651
  throw new NodesetImageError(`not a node record: ${JSON.stringify(json).slice(0, 80)}`);
@@ -554,6 +670,10 @@ export function decodeNode(json: NodesetImageNode): NodesetNodeRecord {
554
670
  (record as unknown as Record<string, unknown>)[key] = v;
555
671
  }
556
672
  }
673
+ // an absent displayName is the browse name: what the writer left out because it was the same
674
+ if (record.displayName === undefined) {
675
+ record.displayName = record.browseName.name ?? "";
676
+ }
557
677
  if (json.rolePermissions) {
558
678
  record.rolePermissions = json.rolePermissions.map((p) => ({ roleId: decodeNodeId(p.roleId), permissions: p.permissions }));
559
679
  }
@@ -563,12 +683,11 @@ export function decodeNode(json: NodesetImageNode): NodesetNodeRecord {
563
683
  if (json.value !== undefined) record.value = decodeValue(json.value);
564
684
  if (json.definition) {
565
685
  const definition: NodesetDataTypeDefinitionRecord = {
566
- fields: json.definition.fields.map(
567
- (f) => ({ ...f, dataType: decodeNodeIdOrNull(f.dataType) }) as NodesetDefinitionField
568
- )
686
+ fields: json.definition.fields.map(decodeDefinitionField)
569
687
  };
570
688
  if (json.definition.name !== undefined) definition.name = json.definition.name;
571
689
  if (json.definition.isUnion !== undefined) definition.isUnion = json.definition.isUnion;
690
+ if (json.definition.isOptionSet !== undefined) definition.isOptionSet = json.definition.isOptionSet;
572
691
  record.definition = definition;
573
692
  }
574
693
  return record;
@@ -19,8 +19,17 @@ import type { NodeId } from "node-opcua-nodeid";
19
19
  import type { VariantOptions } from "node-opcua-variant";
20
20
  import type { EnumFieldOptions, StructureFieldOptions } from "node-opcua-xml2json";
21
21
 
22
- /** bumped whenever the record shape changes; the precompiled image carries it */
23
- export const NODESET_RECORD_SCHEMA = 2;
22
+ /**
23
+ * bumped whenever the record shape changes; the precompiled image carries it, and it is part of
24
+ * the key an image is cached under, so a bump invalidates every stored image.
25
+ *
26
+ * It is bumped for a change in what the records *contain* as well as for a change in their shape:
27
+ * when the reader learns to read something it used to drop -- a DateTime value, say -- every image
28
+ * built before is still valid schema-N and still parses, but it is less faithful than the file it
29
+ * was built from, and nothing else would ever invalidate it. The source digest cannot: the source
30
+ * did not change. This number is the only lever.
31
+ */
32
+ export const NODESET_RECORD_SCHEMA = 3;
24
33
 
25
34
  export interface NodesetModelRecord {
26
35
  modelUri: string;
@@ -38,6 +47,12 @@ export interface NodesetHeaderRecord {
38
47
  models: NodesetModelRecord[];
39
48
  /** alias name to the id it stands for, file-local; no record refers to an alias by name */
40
49
  aliases: Record<string, NodeId>;
50
+ /**
51
+ * the `<Extension>` elements of the document's own `<Extensions>`, each as the XML it is.
52
+ * Their content is foreign to this schema by design -- a tool stamps whatever it likes there,
53
+ * such as UaModeler's `ModelInfo` -- so there is nothing to model and only the bytes to keep.
54
+ */
55
+ extensions?: string[];
41
56
  }
42
57
 
43
58
  export interface NodesetReferenceRecord {
@@ -64,6 +79,11 @@ export type NodesetDefinitionField = Omit<StructureFieldOptions, "dataType"> & E
64
79
  export interface NodesetDataTypeDefinitionRecord {
65
80
  name?: string;
66
81
  isUnion?: boolean;
82
+ /**
83
+ * the DataType is a bit mask: its fields are bit positions, not enumeration values. An
84
+ * OptionSet whose flag is lost is indistinguishable from an ordinary enumeration.
85
+ */
86
+ isOptionSet?: boolean;
67
87
  fields: NodesetDefinitionField[];
68
88
  }
69
89
 
@@ -87,6 +107,10 @@ export interface NodesetNodeRecord {
87
107
  browseName: QualifiedName;
88
108
  displayName?: string;
89
109
  description?: string;
110
+ /** the `<Category>` elements, in order; a node may declare several */
111
+ category?: string[];
112
+ /** the `<Documentation>` element: a URL into the specification text, at most one */
113
+ documentation?: string;
90
114
  references: NodesetReferenceRecord[];
91
115
  releaseStatus?: "Draft" | "Deprecated";
92
116
  symbolicName?: string;
@@ -119,8 +119,16 @@ function makeDefaultVariant(
119
119
  if (nodeDataType?.basicDataType) {
120
120
  const basicDataType = nodeDataType.basicDataType;
121
121
  if (basicDataType === DataType.Variant) {
122
- /// we don't now what is the variant
123
- return undefined;
122
+ // the DataType is BaseDataType, or an abstract type that resolves to it, so there is
123
+ // no concrete default to synthesise. That is the same situation as a Variable whose
124
+ // DataType the document never stated, and it now gets the same answer: a null value
125
+ // waiting for its first update.
126
+ //
127
+ // Returning undefined here left the variable on the constructor's
128
+ // UncertainInitialValue, so two documents that say the same thing disagreed about the
129
+ // status code of a value neither of them carries -- an omitted DataType against an
130
+ // explicit i=24, which is the XSD default for that very attribute.
131
+ return { dataType: DataType.Null };
124
132
  }
125
133
 
126
134
  if (basicDataType === DataType.ExtensionObject) {
@@ -185,11 +193,23 @@ function isUninitializedMatrix(value: VariantOptions): boolean {
185
193
  return product > 0;
186
194
  }
187
195
 
196
+ /**
197
+ * a <Definition> Name worth keeping on the node: one the document spelled bare. A "<n>:Name" is a
198
+ * namespace index into the writing document's own table, which means nothing in any other document,
199
+ * so it is dropped here and rebuilt from the browse name when a document is written back out.
200
+ */
201
+ function definitionNameToKeep(name: string | undefined): string | undefined {
202
+ if (!name || /^[0-9]+:/.test(name)) return undefined;
203
+ return name;
204
+ }
205
+
188
206
  const UA_NAMESPACE_URI = "http://opcfoundation.org/UA/";
189
207
  const reg = /ns=([0-9]+);(.*)/;
190
208
 
191
209
  interface INodePermissions {
192
210
  accessRestrictions?: AccessRestrictionsFlag;
211
+ /** what the document declared, whatever this loader enforces; see BaseNode */
212
+ declaredAccessRestrictions?: string;
193
213
  rolePermissions?: RolePermissionTypeOptions[];
194
214
  }
195
215
 
@@ -491,6 +511,9 @@ export class NodesetRecordApplier implements NodesetRecordConsumer {
491
511
  }
492
512
  return {
493
513
  accessRestrictions: this.applyAccessRestrictions ? convertAccessRestrictions(record.accessRestrictions) : undefined,
514
+ // kept whatever the option says: the option governs what is enforced, not what the
515
+ // document declared, and an exporter needs the latter
516
+ declaredAccessRestrictions: record.accessRestrictions,
494
517
  rolePermissions
495
518
  };
496
519
  }
@@ -501,6 +524,8 @@ export class NodesetRecordApplier implements NodesetRecordConsumer {
501
524
  nodeId: record.nodeId.isEmpty() ? null : this.translate(record.nodeId),
502
525
  browseName: this.translateQualifiedName(record.browseName),
503
526
  references: this.references(record),
527
+ // documentation, not behaviour, but the document said it and it must survive the trip
528
+ releaseStatus: record.releaseStatus,
504
529
  ...this.permissions(record)
505
530
  };
506
531
  }
@@ -560,6 +585,15 @@ export class NodesetRecordApplier implements NodesetRecordConsumer {
560
585
  } // already translated
561
586
  const namespace = this.addressSpace.getNamespace(params.nodeId.namespace);
562
587
  const node = namespace.internalCreateNode(params) as BaseNode;
588
+ // Whether the document declared ArrayDimensions at all. node-opcua synthesizes [0] on the
589
+ // variables it generates itself -- EnumStrings, the variables it materializes from a
590
+ // structure's fields -- so [0] on a node means either "the document said 0" or "nobody
591
+ // said anything", and an exporter that cannot tell them apart must either invent the
592
+ // attribute everywhere or drop it everywhere. Recorded here, where the document is still
593
+ // in hand, rather than guessed at afterwards
594
+ if (record.arrayDimensions !== undefined && record.arrayDimensions !== null) {
595
+ (node as { arrayDimensionsWereDeclared?: boolean }).arrayDimensionsWereDeclared = true;
596
+ }
563
597
  this.settle(node, record, params.references as UAReference[]);
564
598
  return node;
565
599
  }
@@ -659,7 +693,16 @@ export class NodesetRecordApplier implements NodesetRecordConsumer {
659
693
  displayName: record.displayName ?? "",
660
694
  description: record.description ?? "",
661
695
  symbolicName: record.symbolicName,
662
- partialDefinition: fields
696
+ partialDefinition: fields,
697
+ // the address space models no OptionSet, so the flag and the fields above are all that
698
+ // is left of the declaration; without them the DataType cannot be written back out
699
+ isOptionSet: record.definition?.isOptionSet,
700
+ isUnion: record.definition?.isUnion,
701
+ // the <Definition> Name, but only when the document spelled it bare. The catalogue uses
702
+ // both spellings, and a "<n>:Name" prefix is an index into the table of the document
703
+ // that wrote it -- carrying it into another document would name a different namespace,
704
+ // so a prefixed name is left to be recomputed from the browse name on the way out
705
+ definitionName: definitionNameToKeep(record.definition?.name)
663
706
  } as unknown as CreateNodeOptions;
664
707
 
665
708
  let capturedDataTypeNode: UADataType | undefined = this.createNode(params, record) as UADataType;
@@ -817,6 +860,12 @@ export class NodesetRecordApplier implements NodesetRecordConsumer {
817
860
  } else {
818
861
  (cv as UAVariableImpl)._setInitialDataValue(defaultValue, StatusCodes.Good);
819
862
  }
863
+ // this value was made up here, not read from the document: the document said
864
+ // nothing, and a usable default is served rather than a Bad status. An exporter
865
+ // must not write it back out as though the document had declared it, and the
866
+ // status code cannot tell it apart from a value the document really did declare
867
+ // -- a declared empty string is Good too. So the fact is recorded on the node
868
+ (cv as { valueWasSynthesized?: boolean }).valueWasSynthesized = true;
820
869
  }
821
870
  capturedVariable = undefined;
822
871
  };
@@ -860,6 +909,8 @@ export class NodesetRecordApplier implements NodesetRecordConsumer {
860
909
  } else {
861
910
  (variable as UAVariableImpl)._setInitialDataValue(defaultValue, StatusCodes.Good);
862
911
  }
912
+ // made up here, not read from the document: see the note on the other synthesis site
913
+ (variable as { valueWasSynthesized?: boolean }).valueWasSynthesized = true;
863
914
  }
864
915
  } catch (err) {
865
916
  errorLog(