node-opcua-alias-name-server 2.176.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 (51) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +361 -0
  3. package/dist/add_alias.d.ts +73 -0
  4. package/dist/add_alias.js +262 -0
  5. package/dist/add_alias.js.map +1 -0
  6. package/dist/address_space_alias_store.d.ts +130 -0
  7. package/dist/address_space_alias_store.js +360 -0
  8. package/dist/address_space_alias_store.js.map +1 -0
  9. package/dist/alias_hierarchy.d.ts +45 -0
  10. package/dist/alias_hierarchy.js +135 -0
  11. package/dist/alias_hierarchy.js.map +1 -0
  12. package/dist/alias_index.d.ts +60 -0
  13. package/dist/alias_index.js +119 -0
  14. package/dist/alias_index.js.map +1 -0
  15. package/dist/alias_name_archive.d.ts +45 -0
  16. package/dist/alias_name_archive.js +75 -0
  17. package/dist/alias_name_archive.js.map +1 -0
  18. package/dist/bind_alias_category.d.ts +173 -0
  19. package/dist/bind_alias_category.js +417 -0
  20. package/dist/bind_alias_category.js.map +1 -0
  21. package/dist/bind_configuration_methods.d.ts +44 -0
  22. package/dist/bind_configuration_methods.js +175 -0
  23. package/dist/bind_configuration_methods.js.map +1 -0
  24. package/dist/bind_find_alias.d.ts +61 -0
  25. package/dist/bind_find_alias.js +227 -0
  26. package/dist/bind_find_alias.js.map +1 -0
  27. package/dist/index.d.ts +18 -0
  28. package/dist/index.js +64 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/install_alias_names.d.ts +229 -0
  31. package/dist/install_alias_names.js +146 -0
  32. package/dist/install_alias_names.js.map +1 -0
  33. package/dist/last_change.d.ts +87 -0
  34. package/dist/last_change.js +174 -0
  35. package/dist/last_change.js.map +1 -0
  36. package/dist/well_known.d.ts +88 -0
  37. package/dist/well_known.js +93 -0
  38. package/dist/well_known.js.map +1 -0
  39. package/package.json +54 -0
  40. package/source/add_alias.ts +318 -0
  41. package/source/address_space_alias_store.ts +417 -0
  42. package/source/alias_hierarchy.ts +141 -0
  43. package/source/alias_index.ts +127 -0
  44. package/source/alias_name_archive.ts +84 -0
  45. package/source/bind_alias_category.ts +546 -0
  46. package/source/bind_configuration_methods.ts +219 -0
  47. package/source/bind_find_alias.ts +290 -0
  48. package/source/index.ts +74 -0
  49. package/source/install_alias_names.ts +342 -0
  50. package/source/last_change.ts +201 -0
  51. package/source/well_known.ts +101 -0
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @module node-opcua-alias-name-server
3
+ *
4
+ * Persistence for `LastChange` (OPC 10000-17 clause 6.3.1).
5
+ *
6
+ * Clause 6.3.1 is blunt about why this exists: *"The LastChange shall be
7
+ * persisted. A Client that detects a LastChange that is older than what it has
8
+ * cached, shall clear all cached AliasNameCategories and related AliasNames."*
9
+ *
10
+ * So a restart that reset `LastChange` to zero would not merely lose
11
+ * information — it would order every connected Client to throw away a cache
12
+ * that is still perfectly valid, silently and on every restart. That is a
13
+ * Server-side bug whose only symptom is remote.
14
+ *
15
+ * The archive is plain JSON: a version and a map of category NodeId to
16
+ * VersionTime. There is nothing secret in it, so unlike the RoleSet archive it
17
+ * is not encrypted — it is a handful of integers describing when things last
18
+ * changed. Writes are atomic (temp file + rename) so a crash cannot leave a
19
+ * half-written archive, which would be worse than no archive at all.
20
+ */
21
+
22
+ import { promises as fs } from "node:fs";
23
+ import { dirname } from "node:path";
24
+
25
+ /** Bumped when the on-disk shape changes incompatibly. */
26
+ export const ALIAS_NAME_ARCHIVE_VERSION = 1;
27
+
28
+ /** The persisted form of a Server's `LastChange` state. */
29
+ export interface AliasNameArchive {
30
+ version: number;
31
+ /** Category NodeId (as a string) to VersionTime (UInt32 seconds since 2000-01-01Z). */
32
+ lastChange: Record<string, number>;
33
+ }
34
+
35
+ /**
36
+ * Read an archive, or return `null` when there is none.
37
+ *
38
+ * A missing file is normal — the first start. A corrupt or
39
+ * future-versioned file is **not** silently ignored: continuing with a zeroed
40
+ * `LastChange` is exactly the cache-clearing bug persistence exists to prevent,
41
+ * so the caller is told rather than left to discover it from a Client.
42
+ */
43
+ export async function readAliasNameArchive(path: string): Promise<AliasNameArchive | null> {
44
+ let raw: string;
45
+ try {
46
+ raw = await fs.readFile(path, "utf-8");
47
+ } catch (err) {
48
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
49
+ return null;
50
+ }
51
+ throw err;
52
+ }
53
+
54
+ let parsed: unknown;
55
+ try {
56
+ parsed = JSON.parse(raw);
57
+ } catch {
58
+ throw new Error(
59
+ `readAliasNameArchive: ${path} is not valid JSON. Delete it to start fresh, but note that a Client that has cached AliasNames will be told to clear its cache.`
60
+ );
61
+ }
62
+
63
+ const archive = parsed as Partial<AliasNameArchive>;
64
+ if (archive.version !== ALIAS_NAME_ARCHIVE_VERSION) {
65
+ throw new Error(
66
+ `readAliasNameArchive: ${path} has version ${String(archive.version)}, expected ${ALIAS_NAME_ARCHIVE_VERSION}`
67
+ );
68
+ }
69
+ return { version: archive.version, lastChange: archive.lastChange ?? {} };
70
+ }
71
+
72
+ /**
73
+ * Write an archive atomically.
74
+ *
75
+ * Temp file plus rename, so a crash mid-write leaves either the previous
76
+ * archive or the new one, never a truncated file that would fail to parse on
77
+ * the next start.
78
+ */
79
+ export async function writeAliasNameArchive(path: string, archive: AliasNameArchive): Promise<void> {
80
+ await fs.mkdir(dirname(path), { recursive: true });
81
+ const temporaryPath = `${path}.tmp`;
82
+ await fs.writeFile(temporaryPath, JSON.stringify(archive, null, 2), "utf-8");
83
+ await fs.rename(temporaryPath, path);
84
+ }
@@ -0,0 +1,546 @@
1
+ /**
2
+ * @module node-opcua-alias-name-server
3
+ *
4
+ * Binding the Methods of a single `AliasNameCategoryType` instance, and creating
5
+ * new categories at runtime.
6
+ *
7
+ * This is the one binding path. `installAliasNamesOnAddressSpace` calls
8
+ * {@link bindAliasCategory} in its loop rather than doing the work itself, so a
9
+ * category created after installation cannot end up bound differently from one
10
+ * that was there at install time — or, worse, not bound at all. An unbound
11
+ * MANDATORY `FindAlias` is the exact defect this package exists to remove, and
12
+ * it should not be able to reappear at runtime.
13
+ */
14
+
15
+ import type {
16
+ BaseNode,
17
+ IAddressSpace,
18
+ ISessionContext,
19
+ UAMethod,
20
+ UAObject,
21
+ UAObjectType,
22
+ UAVariable
23
+ } from "node-opcua-address-space-base";
24
+ import type { IAliasStore } from "node-opcua-alias-name-common";
25
+ import { BrowseDirection, NodeClass } from "node-opcua-data-model";
26
+ import { type NodeId, NodeId as NodeIdClass, NodeIdType } from "node-opcua-nodeid";
27
+ import type { RolePermissionTypeOptions } from "node-opcua-types";
28
+ import { DataType } from "node-opcua-variant";
29
+ import { makeAddAliasesToCategoryHandler, makeDeleteAliasesFromCategoryHandler } from "./bind_configuration_methods.js";
30
+ import { type AliasComparator, makeFindAliasHandler } from "./bind_find_alias.js";
31
+ import { LAST_CHANGE_BROWSE_NAME, type LastChangeTracker } from "./last_change.js";
32
+ import {
33
+ ALIAS_NAME_CATEGORY_TYPE,
34
+ DEFAULT_MAX_RESULTS,
35
+ MethodDeclarations,
36
+ VERSION_TIME_DATA_TYPE,
37
+ WellKnownCategories,
38
+ WellKnownOptionalMethods
39
+ } from "./well_known.js";
40
+
41
+ /** Everything a category needs in order to answer `FindAlias`. */
42
+ export interface BindAliasCategoryOptions {
43
+ /** Where aliases come from. */
44
+ store: IAliasStore;
45
+ /** Result cap per call (clause 6.3.2 Table 4). */
46
+ maxResults: number;
47
+ /** Also bind `FindAliasVerbose`, adding the Method if the instance lacks it. */
48
+ verbose?: boolean;
49
+ /** Result ordering (clause 6.3.2, "best match first"). */
50
+ comparator?: AliasComparator;
51
+ /** Read gate; see {@link FindAliasBindingOptions.isReadAllowed}. */
52
+ isReadAllowed?: (context: ISessionContext, categoryNodeId: NodeId) => boolean | Promise<boolean>;
53
+ /**
54
+ * Write gate for the configuration Methods, mirroring `isReadAllowed`.
55
+ * Defaults to denying everyone.
56
+ */
57
+ isWriteAllowed?: (context: ISessionContext, categoryNodeId: NodeId) => boolean | Promise<boolean>;
58
+ /** Also add and bind `AddAliasesToCategory` / `DeleteAliasesFromCategory`. */
59
+ configurationMethods?: boolean;
60
+ /** Ensure the category carries a `LastChange` Property (clause 6.3.1). */
61
+ lastChangeProperty?: boolean;
62
+ /** Called after a configuration Method changed the category. */
63
+ onChanged?: (categoryNodeId: NodeId) => void | Promise<void>;
64
+ }
65
+
66
+ /**
67
+ * Bind `FindAlias` — and, when `verbose`, `FindAliasVerbose` — on one
68
+ * `AliasNameCategoryType` instance.
69
+ *
70
+ * Safe to call on a category that is already bound: `bindMethod` replaces the
71
+ * handler, and the optional Method is only added when it is missing.
72
+ *
73
+ * Use this for a category created after `installAliasNames` has run. The options
74
+ * that installation used are on {@link InstallAliasNamesResult.bindingOptions},
75
+ * so a caller does not have to reassemble them and risk binding a late category
76
+ * with a different store or a different result cap.
77
+ */
78
+ export function bindAliasCategory(addressSpace: IAddressSpace, category: UAObject, options: BindAliasCategoryOptions): void {
79
+ const findAlias = findMethodByDeclaration(category, MethodDeclarations.FindAlias, "FindAlias");
80
+ findAlias?.bindMethod(makeFindAliasHandler(options, false));
81
+
82
+ if (options.verbose ?? true) {
83
+ const verbose = ensureOptionalMethod(addressSpace, category, "FindAliasVerbose");
84
+ verbose?.bindMethod(makeFindAliasHandler(options, true));
85
+ }
86
+
87
+ if (options.lastChangeProperty ?? true) {
88
+ ensureLastChangeProperty(addressSpace, category);
89
+ }
90
+
91
+ // The write surface only appears when asked for (clause 6.3.4 / 6.3.5 are
92
+ // both Optional), and even then every call is denied unless isWriteAllowed
93
+ // says otherwise.
94
+ if (options.configurationMethods) {
95
+ const configurationOptions = {
96
+ store: options.store,
97
+ isWriteAllowed: options.isWriteAllowed,
98
+ onChanged: options.onChanged
99
+ };
100
+ const add = ensureOptionalMethod(addressSpace, category, "AddAliasesToCategory");
101
+ add?.bindMethod(makeAddAliasesToCategoryHandler(configurationOptions));
102
+ const remove = ensureOptionalMethod(addressSpace, category, "DeleteAliasesFromCategory");
103
+ remove?.bindMethod(makeDeleteAliasesFromCategoryHandler(configurationOptions));
104
+ }
105
+ }
106
+
107
+ /**
108
+ * The default NodeId for a new category: a **string** NodeId spelling out its
109
+ * path under `Aliases`, for instance `ns=1;s=Aliases/TagVariables/Unit200`.
110
+ *
111
+ * The obvious alternative — letting the namespace assign the next free numeric
112
+ * id — is wrong here for two reasons.
113
+ *
114
+ * It is **not stable across restarts**. The counter depends on how many other
115
+ * Nodes were created first, so adding one unrelated Variable to a Server shifts
116
+ * every category's NodeId. That silently breaks `LastChange` persistence, which
117
+ * keys on the category NodeId: the restored values no longer match any
118
+ * category, `LastChange` reads 0, and clause 6.3.1 then requires every connected
119
+ * Client to clear a cache that was perfectly valid. A Server-side change with a
120
+ * purely remote symptom.
121
+ *
122
+ * It is also **not diagnosable**. `ns=1;i=1010` in a log or a
123
+ * `FindAliasVerbose` result says nothing; `ns=1;s=Aliases/TagVariables/Unit200`
124
+ * says which category it is without a lookup.
125
+ *
126
+ * The path is unique because two categories cannot share a parent and a
127
+ * BrowseName, and it is derived from the same information every run, so it is
128
+ * the same NodeId every run.
129
+ */
130
+ function defaultCategoryNodeId(parent: UAObject, browseName: string, namespaceIndex: number): NodeId {
131
+ const path = [...categoryPathOf(parent), browseName].join("/");
132
+ return new NodeIdClass(NodeIdType.STRING, path, namespaceIndex);
133
+ }
134
+
135
+ /**
136
+ * The BrowseNames from the `Aliases` root down to `category`, inclusive.
137
+ *
138
+ * Falls back to the category's own BrowseName when it is not under the root,
139
+ * which keeps the id derivable for a category modelled outside the standard
140
+ * hierarchy.
141
+ */
142
+ function categoryPathOf(category: UAObject): string[] {
143
+ const segments: string[] = [];
144
+ const seen = new Set<string>();
145
+ let current: UAObject | null = category;
146
+
147
+ while (current) {
148
+ const key: string = current.nodeId.toString();
149
+ if (seen.has(key)) {
150
+ break;
151
+ }
152
+ seen.add(key);
153
+ segments.unshift(current.browseName.name ?? key);
154
+
155
+ if (key === WellKnownCategories.Aliases.toString()) {
156
+ break;
157
+ }
158
+ const parents: BaseNode[] = current.findReferencesExAsObject("HierarchicalReferences", BrowseDirection.Inverse);
159
+ const next = parents.find((p) => p.nodeClass === NodeClass.Object);
160
+ current = next ? (next as UAObject) : null;
161
+ }
162
+ return segments;
163
+ }
164
+
165
+ /**
166
+ * Ensure a category has a `LastChange` Property.
167
+ *
168
+ * `LastChange` is Optional on `AliasNameCategoryType` and the shipped nodeset
169
+ * instantiates it only on the `Aliases` root, which clause 9.2 makes mandatory.
170
+ * Adding it to every category is conformant — Optional means may, not must not —
171
+ * and it is what makes the clause 6.3.1 rollup observable: without it, a Client
172
+ * watching one branch has nothing to watch.
173
+ */
174
+ export function ensureLastChangeProperty(addressSpace: IAddressSpace, category: UAObject): UAVariable | null {
175
+ const existing = category.getPropertyByName(LAST_CHANGE_BROWSE_NAME);
176
+ if (existing) {
177
+ return existing;
178
+ }
179
+ const namespace = addressSpace.getNamespace(
180
+ category.nodeId.namespace === 0 ? addressSpace.getOwnNamespace().index : category.nodeId.namespace
181
+ );
182
+ return namespace.addVariable({
183
+ propertyOf: category,
184
+ browseName: LAST_CHANGE_BROWSE_NAME,
185
+ // VersionTime (i=20998) is a UInt32 subtype, not a DateTime
186
+ dataType: VERSION_TIME_DATA_TYPE,
187
+ minimumSamplingInterval: 1000,
188
+ value: { dataType: DataType.UInt32, value: 0 }
189
+ }) as UAVariable;
190
+ }
191
+
192
+ export interface AddAliasCategoryOptions extends Partial<BindAliasCategoryOptions> {
193
+ /**
194
+ * Namespace for the new category's BrowseName. Defaults to the Server's own.
195
+ */
196
+ namespaceIndex?: number;
197
+ /**
198
+ * NodeId for the new category. Defaults to a server-assigned one, which is
199
+ * correct for any category the specification does not name.
200
+ */
201
+ nodeId?: NodeId;
202
+ /**
203
+ * ObjectType to instantiate. Defaults to `AliasNameCategoryType`; a subtype
204
+ * is accepted, since discovery and binding both already handle subtypes.
205
+ */
206
+ categoryType?: UAObjectType | NodeId;
207
+ /**
208
+ * RolePermissions for the new category.
209
+ *
210
+ * Worth setting deliberately. Namespace 0 declares no `RolePermissions` on
211
+ * any Part 17 node, so a category created without them inherits the
212
+ * namespace default silently — which is a decision either way, just an
213
+ * invisible one.
214
+ */
215
+ rolePermissions?: RolePermissionTypeOptions[];
216
+ }
217
+
218
+ /**
219
+ * Create a vendor `AliasNameCategoryType` instance under `parent` **and bind it**.
220
+ *
221
+ * Creating one by hand means instantiating the type, wiring the `Organizes`
222
+ * reference and then remembering to bind — and a category whose `FindAlias` is
223
+ * unbound fails conformance silently. This does all three.
224
+ *
225
+ * When `installAliasNames` has already run on this address space, the binding
226
+ * options it used are reused unless overridden, so a category added at runtime
227
+ * behaves exactly like one that was present at install time. Pass a `store`
228
+ * explicitly if installation has not run yet.
229
+ */
230
+ export function addAliasCategory(
231
+ addressSpace: IAddressSpace,
232
+ parent: UAObject | NodeId,
233
+ browseName: string,
234
+ options?: AddAliasCategoryOptions
235
+ ): UAObject {
236
+ const parentNode = coerceCategoryNode(addressSpace, parent);
237
+ const categoryType = resolveCategoryType(addressSpace, options?.categoryType);
238
+
239
+ const namespace = addressSpace.getNamespace(options?.namespaceIndex ?? addressSpace.getOwnNamespace().index);
240
+ const nodeId = options?.nodeId ?? defaultCategoryNodeId(parentNode, browseName, namespace.index);
241
+
242
+ if (addressSpace.findNode(nodeId)) {
243
+ throw new Error(
244
+ `addAliasCategory: ${nodeId.toString()} already exists. A category's default NodeId is derived from its ` +
245
+ "path under Aliases, so this means a category of the same name already exists under the same parent."
246
+ );
247
+ }
248
+
249
+ const category = categoryType.instantiate({
250
+ browseName: { name: browseName, namespaceIndex: namespace.index },
251
+ nodeId,
252
+ organizedBy: parentNode,
253
+ namespace
254
+ }) as UAObject;
255
+
256
+ if (options?.rolePermissions) {
257
+ // instantiate() does not take them, so they are applied after
258
+ category.setRolePermissions(options.rolePermissions);
259
+ }
260
+
261
+ const bindingOptions = resolveBindingOptions(addressSpace, options);
262
+ if (bindingOptions) {
263
+ bindAliasCategory(addressSpace, category, bindingOptions);
264
+ }
265
+ // clause 6.3.1: "The last time an AliasNameCategory was added or deleted"
266
+ notifyCategoryChanged(addressSpace, parentNode.nodeId);
267
+ return category;
268
+ }
269
+
270
+ /**
271
+ * Resolve the ObjectType to instantiate, defaulting to `AliasNameCategoryType`.
272
+ *
273
+ * A subtype is accepted: discovery matches on "is this an instance of
274
+ * AliasNameCategoryType *or a subtype*", and binding looks the Methods up by
275
+ * MethodDeclarationId, so neither cares which exact type was used.
276
+ */
277
+ function resolveCategoryType(addressSpace: IAddressSpace, categoryType?: UAObjectType | NodeId): UAObjectType {
278
+ const base = addressSpace.findObjectType(ALIAS_NAME_CATEGORY_TYPE);
279
+ if (!base) {
280
+ throw new Error("addAliasCategory: AliasNameCategoryType (i=23456) is not in the address space");
281
+ }
282
+ if (!categoryType) {
283
+ return base;
284
+ }
285
+ const resolved =
286
+ categoryType instanceof NodeIdClass ? addressSpace.findObjectType(categoryType) : (categoryType as UAObjectType);
287
+ if (!resolved) {
288
+ throw new Error(`addAliasCategory: unknown ObjectType ${String(categoryType)}`);
289
+ }
290
+ if (resolved.nodeId.value !== base.nodeId.value && !resolved.isSubtypeOf(base)) {
291
+ throw new Error(`addAliasCategory: ${resolved.browseName.toString()} is not AliasNameCategoryType or a subtype of it`);
292
+ }
293
+ return resolved;
294
+ }
295
+
296
+ /**
297
+ * Remove a category, and decide what happens to what it Organizes.
298
+ *
299
+ * The specification does not say, so the rule is stated here rather than left to
300
+ * whatever `deleteNode` happens to do:
301
+ *
302
+ * - **`reparent`** (the default) moves the category's aliases and subcategories
303
+ * to its parent before deleting it. Nothing disappears, so a Client that had
304
+ * resolved an alias keeps resolving it — the alias Object keeps its NodeId,
305
+ * and clause 6.2 makes a NodeId change mean "this is a different alias".
306
+ * - **`cascade`** deletes them with it. Correct when the category *is* the
307
+ * thing being retired, such as a tenant being removed.
308
+ *
309
+ * Refuses to remove one of the three well-known categories, which clause 9
310
+ * requires a Server to have.
311
+ *
312
+ * @returns the aliases and subcategories that were re-parented, or deleted.
313
+ */
314
+ export function removeAliasCategory(
315
+ addressSpace: IAddressSpace,
316
+ category: UAObject | NodeId,
317
+ options?: { orphans?: "reparent" | "cascade" }
318
+ ): { moved: NodeId[]; deleted: NodeId[] } {
319
+ const node = coerceCategoryNode(addressSpace, category);
320
+ for (const wellKnown of Object.values(WellKnownCategories)) {
321
+ if (wellKnown.value === node.nodeId.value && wellKnown.namespace === node.nodeId.namespace) {
322
+ throw new Error(
323
+ `removeAliasCategory: ${node.browseName.toString()} is a well-known category that ` +
324
+ "OPC 10000-17 clause 9 requires the Server to have"
325
+ );
326
+ }
327
+ }
328
+
329
+ const parents = node.findReferencesExAsObject("HierarchicalReferences", BrowseDirection.Inverse);
330
+ // Organizes only, not every hierarchical reference. A category's Methods are
331
+ // HasComponent children that belong to it and must go with it; re-parenting
332
+ // those would leave the parent with a second FindAlias.
333
+ const childReferences = node.findReferencesEx("Organizes", BrowseDirection.Forward);
334
+ const orphans = options?.orphans ?? "reparent";
335
+ const moved: NodeId[] = [];
336
+ const deleted: NodeId[] = [];
337
+
338
+ if (orphans === "reparent") {
339
+ const parent = parents[0];
340
+ if (!parent) {
341
+ throw new Error(
342
+ `removeAliasCategory: ${node.browseName.toString()} has no parent to re-parent its contents to; ` +
343
+ 'pass { orphans: "cascade" } to delete them instead'
344
+ );
345
+ }
346
+ for (const reference of childReferences) {
347
+ parent.addReference({ referenceType: reference.referenceType, nodeId: reference.nodeId });
348
+ // Detach before deleting: deleteNode cascades through Organizes, so
349
+ // a child still referenced here would be deleted along with the
350
+ // category despite having just been re-parented.
351
+ node.removeReference({ referenceType: reference.referenceType, nodeId: reference.nodeId });
352
+ moved.push(reference.nodeId);
353
+ }
354
+ } else {
355
+ for (const reference of childReferences) {
356
+ deleted.push(reference.nodeId);
357
+ }
358
+ }
359
+
360
+ // whatever is still attached goes with it
361
+ addressSpace.deleteNode(node.nodeId);
362
+ // clause 6.3.1: a category was deleted
363
+ for (const parent of parents) {
364
+ notifyCategoryChanged(addressSpace, parent.nodeId);
365
+ }
366
+ return { moved, deleted };
367
+ }
368
+
369
+ /**
370
+ * Tell the installed `LastChange` tracker that a category's contents changed.
371
+ *
372
+ * Fire and forget: the Property is written synchronously and only the
373
+ * persistence write is async, so a failure to persist must not fail the
374
+ * caller's structural change.
375
+ */
376
+ function notifyCategoryChanged(addressSpace: IAddressSpace, categoryNodeId: NodeId): void {
377
+ const installed = getInstalledAliasNames(addressSpace);
378
+ void installed?.lastChange?.touch(categoryNodeId);
379
+ }
380
+
381
+ /**
382
+ * Merge explicit options over whatever installation recorded, or return null
383
+ * when there is nothing to bind with.
384
+ */
385
+ function resolveBindingOptions(addressSpace: IAddressSpace, options?: AddAliasCategoryOptions): BindAliasCategoryOptions | null {
386
+ const installed = getInstalledAliasNames(addressSpace);
387
+ const inherited = installed?.bindingOptions;
388
+ const store = options?.store ?? inherited?.store;
389
+ if (!store) {
390
+ // nothing to bind against yet; installAliasNames will pick this category
391
+ // up when it runs, since it is Organized below its parent
392
+ return null;
393
+ }
394
+
395
+ // Inherit by spreading rather than by listing fields. Cherry-picking meant
396
+ // every new binding option had to be remembered here too, and forgetting one
397
+ // let a category created at runtime diverge from an installed one - silently,
398
+ // and in whichever direction was least safe.
399
+ const merged: BindAliasCategoryOptions = {
400
+ ...inherited,
401
+ store,
402
+ maxResults: options?.maxResults ?? inherited?.maxResults ?? DEFAULT_MAX_RESULTS
403
+ };
404
+
405
+ // Only keys the caller actually supplied override the inherited value; an
406
+ // absent key must not read as "false".
407
+ for (const key of Object.keys(options ?? {}) as Array<keyof AddAliasCategoryOptions>) {
408
+ const value = options?.[key];
409
+ if (
410
+ value === undefined ||
411
+ key === "namespaceIndex" ||
412
+ key === "nodeId" ||
413
+ key === "categoryType" ||
414
+ key === "rolePermissions"
415
+ ) {
416
+ continue;
417
+ }
418
+ Object.assign(merged, { [key]: value });
419
+ }
420
+ return merged;
421
+ }
422
+
423
+ /** Accept a category node or its NodeId. */
424
+ function coerceCategoryNode(addressSpace: IAddressSpace, node: UAObject | NodeId): UAObject {
425
+ if (!(node instanceof NodeIdClass)) {
426
+ return node;
427
+ }
428
+ const found = addressSpace.findNode(node);
429
+ if (!found || found.nodeClass !== NodeClass.Object) {
430
+ throw new Error(`addAliasCategory: ${node.toString()} is not an Object in this address space`);
431
+ }
432
+ return found as UAObject;
433
+ }
434
+
435
+ /**
436
+ * Find a Method on a category by its MethodDeclarationId, falling back to the
437
+ * BrowseName.
438
+ *
439
+ * The declaration id is the reliable key: a Server may publish the Method under
440
+ * a localised DisplayName, and the BrowseName is only unique within the
441
+ * namespace. The fallback covers instances built in code, which do not always
442
+ * carry a `methodDeclarationId`.
443
+ */
444
+ export function findMethodByDeclaration(category: UAObject, declarationId: NodeId, browseName: string): UAMethod | null {
445
+ for (const component of category.getComponents()) {
446
+ if (component.nodeClass !== NodeClass.Method) {
447
+ continue;
448
+ }
449
+ const method = component as UAMethod;
450
+ if (method.methodDeclarationId && method.methodDeclarationId.value === declarationId.value) {
451
+ return method;
452
+ }
453
+ if (method.browseName.name === browseName) {
454
+ return method;
455
+ }
456
+ }
457
+ return null;
458
+ }
459
+
460
+ /**
461
+ * Ensure an optional Method exists on a category, adding it when the nodeset
462
+ * only declared it on the type.
463
+ *
464
+ * The shipped `Opc.Ua.NodeSet2.xml` declares `FindAliasVerbose`,
465
+ * `AddAliasesToCategory` and `DeleteAliasesFromCategory` on
466
+ * `AliasNameCategoryType` but instantiates none of them on `Aliases`,
467
+ * `TagVariables` or `Topics`. Upstream nonetheless reserves fixed NodeIds for
468
+ * those instances, so where one exists it is used in preference to a
469
+ * server-assigned id; an aggregating Server then sees the NodeId it expects.
470
+ */
471
+ export function ensureOptionalMethod(
472
+ addressSpace: IAddressSpace,
473
+ category: UAObject,
474
+ name: "FindAliasVerbose" | "AddAliasesToCategory" | "DeleteAliasesFromCategory"
475
+ ): UAMethod | null {
476
+ const declarationId = MethodDeclarations[name];
477
+ const existing = findMethodByDeclaration(category, declarationId, name);
478
+ if (existing) {
479
+ return existing;
480
+ }
481
+
482
+ const declaration = addressSpace.findNode(declarationId);
483
+ if (!declaration || declaration.nodeClass !== NodeClass.Method) {
484
+ // an address space whose nodeset predates the optional Methods
485
+ return null;
486
+ }
487
+
488
+ const reservedNodeId = reservedMethodNodeId(category.nodeId, name);
489
+ // a reserved id already taken by something else means the address space is
490
+ // not what we think it is; fall back to a server-assigned id rather than
491
+ // colliding
492
+ const nodeId = reservedNodeId && !addressSpace.findNode(reservedNodeId) ? reservedNodeId : undefined;
493
+
494
+ return (declaration as UAMethod).clone({
495
+ namespace: addressSpace.getNamespace(category.nodeId.namespace),
496
+ nodeId,
497
+ componentOf: category,
498
+ methodDeclarationId: declarationId
499
+ });
500
+ }
501
+
502
+ /** The NodeId OPC 10000-17 reserves for an optional Method on a well-known category. */
503
+ function reservedMethodNodeId(
504
+ categoryNodeId: NodeId,
505
+ name: "FindAliasVerbose" | "AddAliasesToCategory" | "DeleteAliasesFromCategory"
506
+ ): NodeId | undefined {
507
+ for (const [key, wellKnownId] of Object.entries(WellKnownCategories)) {
508
+ if (wellKnownId.value === categoryNodeId.value && wellKnownId.namespace === categoryNodeId.namespace) {
509
+ return WellKnownOptionalMethods[key as keyof typeof WellKnownOptionalMethods][name];
510
+ }
511
+ }
512
+ return undefined;
513
+ }
514
+
515
+ /**
516
+ * Marks an address space as already carrying AliasName bindings, so a second
517
+ * `installAliasNames` is a no-op rather than a double binding.
518
+ */
519
+ export const INSTALLED = Symbol.for("node-opcua-alias-name-server.installed");
520
+
521
+ /** What installation recorded on the address space, if it has run. */
522
+ export interface InstalledAliasNames {
523
+ store: IAliasStore;
524
+ categories: NodeId[];
525
+ bindingOptions: BindAliasCategoryOptions;
526
+ /** Keeps `LastChange` correct across the hierarchy (clause 6.3.1). */
527
+ lastChange?: LastChangeTracker;
528
+ }
529
+
530
+ type MaybeInstalled = { [INSTALLED]?: InstalledAliasNames };
531
+
532
+ /**
533
+ * What `installAliasNames` recorded on this address space, or undefined if it
534
+ * has not run.
535
+ *
536
+ * Exposed so a caller can rebind a late category with exactly the options
537
+ * installation used, without having to keep the install result around.
538
+ */
539
+ export function getInstalledAliasNames(addressSpace: IAddressSpace): InstalledAliasNames | undefined {
540
+ return (addressSpace as IAddressSpace & MaybeInstalled)[INSTALLED];
541
+ }
542
+
543
+ /** Record the installation on the address space. */
544
+ export function setInstalledAliasNames(addressSpace: IAddressSpace, value: InstalledAliasNames): void {
545
+ (addressSpace as IAddressSpace & MaybeInstalled)[INSTALLED] = value;
546
+ }