diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260613022355_CleanupSystemPlatformNamespaces.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260613022355_CleanupSystemPlatformNamespaces.cs index b1327863..a15163e7 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260613022355_CleanupSystemPlatformNamespaces.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260613022355_CleanupSystemPlatformNamespaces.cs @@ -29,6 +29,15 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations /// is keyed off the set of SystemPlatform namespaces, so on a DB with zero such rows every /// DELETE simply affects 0 rows (idempotent / safe to run repeatedly). /// + /// + /// Two tables carry LOGICAL refs with no physical FK and so were easy to miss: NodeAcl + /// (per-scope grants keyed by ScopeKind + ScopeId) is cleaned for the + /// Namespace/Equipment/FolderSegment/Tag scopes that get deleted, and + /// ExternalIdReservation (fleet-wide ZTag/SAPID reservations keyed by + /// EquipmentUuid) is RELEASED — not deleted — for the affected equipment so the + /// reserved external IDs free up while the audit row survives. Both run before the entity + /// rows they reference are removed so the id subqueries still resolve. + /// /// /// public partial class CleanupSystemPlatformNamespaces : Migration @@ -44,6 +53,58 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations -- stray equipment-scoped tag / Equipment could be bound to a SystemPlatform-namespace driver. Clean -- both, plus anything that FKs into the affected Equipment (VirtualTag / ScriptedAlarm / state). +-- Logical-reference cleanup that must run FIRST ------------------------------------------- +-- NodeAcl rows carry purely LOGICAL scope refs (ScopeKind + ScopeId, no physical FK). When the +-- scoped entity is deleted below the ScopeId dangles and the ACL evaluator would still match it, +-- silently granting/denying on a node that no longer exists. ScopeKind persists as a string via +-- HasConversion, so the values below ('Namespace'/'Equipment'/'FolderSegment'/'Tag') are +-- the literal enum member names. These deletes must read the entity-id subqueries while those rows +-- still exist, hence they run before the entity DELETEs further down. + +-- Namespace-scoped grants pointing at the SystemPlatform namespaces themselves. +DELETE FROM [NodeAcl] +WHERE [ScopeKind] = 'Namespace' + AND [ScopeId] IN ( + SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform'); + +-- Equipment- and FolderSegment-scoped grants pointing at Equipment that hangs off the affected +-- DriverInstances (both share Equipment.EquipmentId as the ScopeId). +DELETE FROM [NodeAcl] +WHERE [ScopeKind] IN ('Equipment', 'FolderSegment') + AND [ScopeId] IN ( + SELECT [EquipmentId] FROM [Equipment] + WHERE [DriverInstanceId] IN ( + SELECT [DriverInstanceId] FROM [DriverInstance] + WHERE [NamespaceId] IN ( + SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform'))); + +-- Tag-scoped grants pointing at Tags bound to the affected DriverInstances (ScopeId = Tag.TagId). +DELETE FROM [NodeAcl] +WHERE [ScopeKind] = 'Tag' + AND [ScopeId] IN ( + SELECT [TagId] FROM [Tag] + WHERE [DriverInstanceId] IN ( + SELECT [DriverInstanceId] FROM [DriverInstance] + WHERE [NamespaceId] IN ( + SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform'))); + +-- ExternalIdReservation is NOT generation-versioned and must outlive equipment deletion as a +-- RELEASE, not a delete: active rows (ReleasedAt IS NULL) hold a fleet-wide unique reservation of +-- the ZTag/SAPID under a filtered unique index. If we left them active after dropping the +-- Equipment, the same external IDs could never be reused. Release them so the IDs free up while the +-- audit row survives. Run BEFORE the Equipment DELETE so the EquipmentUuid subquery still resolves. +UPDATE [ExternalIdReservation] +SET [ReleasedAt] = SYSUTCDATETIME(), + [ReleasedBy] = 'CleanupSystemPlatformNamespaces', + [ReleaseReason] = 'Equipment removed by SystemPlatform-namespace cleanup migration (retired NamespaceKind).' +WHERE [ReleasedAt] IS NULL + AND [EquipmentUuid] IN ( + SELECT [EquipmentUuid] FROM [Equipment] + WHERE [DriverInstanceId] IN ( + SELECT [DriverInstanceId] FROM [DriverInstance] + WHERE [NamespaceId] IN ( + SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform'))); + -- Grandchildren of the affected Equipment ------------------------------------------------ -- ScriptedAlarmState is keyed by ScriptedAlarm.ScriptedAlarmId; remove before its ScriptedAlarm. DELETE FROM [ScriptedAlarmState]