v3 batch1 contracts: RawPaths identity helper + reshaped greenfield entities

Shared-type foundation for Batch 1 fan-out (coordinator contracts commit):
- New Commons/Types/RawPaths.cs — the v3 identity authority (Build/Combine/
  Split/Leaf/TryParent/ValidateSegment, ordinal comparer).
- New entities RawFolder, TagGroup, UnsTagReference.
- Reshape DriverInstance (drop NamespaceId, add nullable RawFolderId),
  Tag (require DeviceId, add nullable TagGroupId, drop DriverInstanceId/
  EquipmentId/FolderPath, keep WriteIdempotent), Equipment (drop
  DriverInstanceId/DeviceId), Device (doc: now universal), ServerCluster
  (nav Namespaces -> RawFolders).
- Delete Namespace, EquipmentImportBatch(+Row), NamespaceKind enum.
- DriverTypeMetadata: drop AllowedNamespaceKinds + NamespaceKindCompatibility.

Solution is intentionally red below the foundation projects until Wave C
integrates. Commons + Core.Abstractions compile green.
This commit is contained in:
Joseph Doherty
2026-07-15 18:52:19 -04:00
parent 10e23f8b6d
commit cb720bb8c3
13 changed files with 265 additions and 239 deletions
@@ -0,0 +1,135 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// <summary>
/// The v3 identity authority. A <c>RawPath</c> is the cluster-scoped, slash-separated raw device
/// path — <c>&lt;Folder/…&gt;/&lt;Driver&gt;/&lt;Device&gt;/&lt;TagGroup/…&gt;/&lt;Tag&gt;</c> — and is
/// the single string used at every identity seam: <c>ns=Raw</c> NodeIds, the driver wire-reference
/// (<c>EquipmentTagRefResolver</c> key), the value fan-out / write-routing maps
/// (<c>DriverHostActor</c>), the historian default tagname + mux interest key, the native-alarm
/// <c>ConditionId</c>, and <c>ctx.GetTag</c> script paths.
/// <para>
/// RawPaths are ALWAYS built through this helper — never string-concatenated ad hoc — so the
/// separator and the segment charset are enforced in exactly one place. Comparison is
/// case-sensitive ordinal everywhere (see <see cref="Comparer"/>).
/// </para>
/// </summary>
public static class RawPaths
{
/// <summary>The single path separator character. Forbidden inside any segment.</summary>
public const char Separator = '/';
/// <summary>The path separator as a string (join/split convenience).</summary>
public const string SeparatorString = "/";
/// <summary>The one comparer for RawPaths and their segments: case-sensitive ordinal.</summary>
public static StringComparer Comparer => StringComparer.Ordinal;
/// <summary>
/// Validate a single path segment. A segment must be non-empty, contain no
/// <see cref="Separator"/>, and carry no leading/trailing whitespace (the separator and
/// surrounding whitespace would corrupt the NodeId / round-trip).
/// </summary>
/// <param name="segment">The candidate segment (a folder / driver / device / group / tag name).</param>
/// <returns>A human-readable error when invalid, or <see langword="null"/> when the segment is valid.</returns>
public static string? ValidateSegment(string? segment)
{
if (string.IsNullOrEmpty(segment))
return "Name must not be empty.";
if (segment.IndexOf(Separator) >= 0)
return $"Name must not contain '{Separator}'.";
if (char.IsWhiteSpace(segment[0]) || char.IsWhiteSpace(segment[^1]))
return "Name must not have leading or trailing whitespace.";
return null;
}
/// <summary>True when <paramref name="segment"/> passes <see cref="ValidateSegment"/>.</summary>
/// <param name="segment">The candidate segment.</param>
/// <returns><see langword="true"/> when the segment is a legal RawPath segment.</returns>
public static bool IsValidSegment(string? segment) => ValidateSegment(segment) is null;
/// <summary>
/// Build a RawPath from ordered segments (root-first). Every segment is validated via
/// <see cref="ValidateSegment"/>; an invalid or empty segment list throws.
/// </summary>
/// <param name="segments">The ordered, non-empty segments (root → leaf).</param>
/// <returns>The slash-joined RawPath.</returns>
/// <exception cref="ArgumentException">A segment is invalid, or the sequence is empty.</exception>
public static string Build(IEnumerable<string> segments)
{
ArgumentNullException.ThrowIfNull(segments);
var list = segments as IReadOnlyList<string> ?? segments.ToList();
if (list.Count == 0)
throw new ArgumentException("A RawPath must have at least one segment.", nameof(segments));
for (var i = 0; i < list.Count; i++)
{
var error = ValidateSegment(list[i]);
if (error is not null)
throw new ArgumentException($"Invalid RawPath segment at index {i} ('{list[i]}'): {error}", nameof(segments));
}
return string.Join(Separator, list);
}
/// <summary>Build a RawPath from ordered segments (root-first). See <see cref="Build(IEnumerable{string})"/>.</summary>
/// <param name="segments">The ordered, non-empty segments (root → leaf).</param>
/// <returns>The slash-joined RawPath.</returns>
public static string Build(params string[] segments) => Build((IEnumerable<string>)segments);
/// <summary>
/// Append a child segment to an existing (already-built) parent RawPath. The child is
/// validated; a blank parent yields the (validated) child alone.
/// </summary>
/// <param name="parentPath">The parent RawPath (assumed already valid), or blank for a root segment.</param>
/// <param name="childSegment">The child segment to append.</param>
/// <returns>The combined RawPath.</returns>
/// <exception cref="ArgumentException">The child segment is invalid.</exception>
public static string Combine(string? parentPath, string childSegment)
{
var error = ValidateSegment(childSegment);
if (error is not null)
throw new ArgumentException($"Invalid RawPath segment ('{childSegment}'): {error}", nameof(childSegment));
return string.IsNullOrEmpty(parentPath) ? childSegment : parentPath + Separator + childSegment;
}
/// <summary>Split a RawPath into its ordered segments (root → leaf).</summary>
/// <param name="rawPath">The RawPath to split.</param>
/// <returns>The ordered segments; a single-element array for a one-segment path.</returns>
public static IReadOnlyList<string> Split(string rawPath)
{
ArgumentNullException.ThrowIfNull(rawPath);
return rawPath.Split(Separator);
}
/// <summary>The leaf (last) segment of a RawPath.</summary>
/// <param name="rawPath">The RawPath.</param>
/// <returns>The final segment.</returns>
public static string Leaf(string rawPath)
{
ArgumentNullException.ThrowIfNull(rawPath);
var idx = rawPath.LastIndexOf(Separator);
return idx < 0 ? rawPath : rawPath[(idx + 1)..];
}
/// <summary>
/// Get the parent RawPath (everything up to, not including, the leaf). Returns
/// <see langword="false"/> for a single-segment path (no parent).
/// </summary>
/// <param name="rawPath">The RawPath.</param>
/// <param name="parent">The parent RawPath when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when a parent exists.</returns>
public static bool TryParent(string rawPath, out string parent)
{
ArgumentNullException.ThrowIfNull(rawPath);
var idx = rawPath.LastIndexOf(Separator);
if (idx < 0)
{
parent = string.Empty;
return false;
}
parent = rawPath[..idx];
return true;
}
}