using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
namespace ZB.MOM.WW.ScadaBridge.Transport.Export;
///
/// Expands an into the full set of entities that
/// must be in the bundle for the selection to be self-consistent. Walks the
/// dependency edges documented in §6.1 of the Transport design doc:
///
/// - Template composes Template (via TemplateComposition.ComposedTemplateId).
/// - Template references SharedScript (by name, scanned in script bodies).
/// - Template references ExternalSystem (by name, scanned in script bodies + attribute DataSourceReference).
/// - ApiMethod references SharedScript (by name, scanned in ApiMethod.Script).
/// - Selected Template's folder chain (ancestor folders included).
/// - ExternalSystemDefinition pulls its ExternalSystemMethods.
///
/// Templates are returned topologically sorted (base-before-derived) via Kahn's
/// algorithm so importers can apply them in order.
///
/// M8 adds site/instance-scoped expansion: selecting a site pulls its
/// DataConnections and Instances; selecting an instance (with
/// ) pulls its owning site, the
/// data connections it binds, and feeds its template into the existing template
/// closure so the template/shared-script/external-system graph expands too.
///
///
public sealed class DependencyResolver
{
private readonly ITemplateEngineRepository _templates;
private readonly IExternalSystemRepository _externalSystems;
private readonly INotificationRepository _notifications;
private readonly IInboundApiRepository _inboundApi;
private readonly ISiteRepository _siteRepository;
/// Initializes a new instance of .
/// Repository for template, instance and shared script access.
/// Repository for external system definitions and methods.
/// Repository for notification lists and SMTP configurations.
/// Repository for inbound API keys and methods.
/// Repository for sites and site-scoped data connections.
public DependencyResolver(
ITemplateEngineRepository templates,
IExternalSystemRepository externalSystems,
INotificationRepository notifications,
IInboundApiRepository inboundApi,
ISiteRepository siteRepository)
{
_templates = templates ?? throw new ArgumentNullException(nameof(templates));
_externalSystems = externalSystems ?? throw new ArgumentNullException(nameof(externalSystems));
_notifications = notifications ?? throw new ArgumentNullException(nameof(notifications));
_inboundApi = inboundApi ?? throw new ArgumentNullException(nameof(inboundApi));
_siteRepository = siteRepository ?? throw new ArgumentNullException(nameof(siteRepository));
}
/// Expands the selection into a fully self-consistent set of entities for bundling.
/// The user's export selection specifying which entities to include.
/// Cancellation token.
/// A with all transitively required entities.
public async Task ResolveAsync(ExportSelection selection, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(selection);
// ---- Seed: fetch the directly-selected entities ----
var templates = new Dictionary();
foreach (var id in selection.TemplateIds.Distinct())
{
var t = await _templates.GetTemplateWithChildrenAsync(id, ct).ConfigureAwait(false);
if (t is not null) templates[t.Id] = t;
}
var sharedScripts = new Dictionary();
foreach (var id in selection.SharedScriptIds.Distinct())
{
var s = await _templates.GetSharedScriptByIdAsync(id, ct).ConfigureAwait(false);
if (s is not null) sharedScripts[s.Id] = s;
}
var externalSystems = new Dictionary();
foreach (var id in selection.ExternalSystemIds.Distinct())
{
var es = await _externalSystems.GetExternalSystemByIdAsync(id, ct).ConfigureAwait(false);
if (es is not null) externalSystems[es.Id] = es;
}
var dbConnections = new Dictionary();
foreach (var id in selection.DatabaseConnectionIds.Distinct())
{
var db = await _externalSystems.GetDatabaseConnectionByIdAsync(id, ct).ConfigureAwait(false);
if (db is not null) dbConnections[db.Id] = db;
}
var notificationLists = new Dictionary();
foreach (var id in selection.NotificationListIds.Distinct())
{
var nl = await _notifications.GetNotificationListByIdAsync(id, ct).ConfigureAwait(false);
if (nl is not null) notificationLists[nl.Id] = nl;
}
var smtpConfigs = new Dictionary();
foreach (var id in selection.SmtpConfigurationIds.Distinct())
{
var sm = await _notifications.GetSmtpConfigurationByIdAsync(id, ct).ConfigureAwait(false);
if (sm is not null) smtpConfigs[sm.Id] = sm;
}
// SMS provider configs (S10b): mirror the SMTP seed exactly — resolve each
// selected id via the by-id repository accessor; missing ids are skipped.
var smsConfigs = new Dictionary();
foreach (var id in selection.SmsConfigurationIds.Distinct())
{
var sms = await _notifications.GetSmsConfigurationByIdAsync(id, ct).ConfigureAwait(false);
if (sms is not null) smsConfigs[sms.Id] = sms;
}
// Inbound API keys are intentionally NOT resolved into the bundle: per the
// inbound-API-key re-architecture (C4) keys are not transported between
// environments. Only API methods travel.
var apiMethods = new Dictionary();
foreach (var id in selection.ApiMethodIds.Distinct())
{
var m = await _inboundApi.GetApiMethodByIdAsync(id, ct).ConfigureAwait(false);
if (m is not null) apiMethods[m.Id] = m;
}
// ---- Seed: site/instance-scoped selection (M8) ----
// sites/dataConnections/instances are keyed by surrogate id for dedup.
var sites = new Dictionary();
var dataConnections = new Dictionary();
var instances = new Dictionary();
// Selecting a SITE pulls its data connections and all of its instances. The
// instance's overrides/bindings are loaded eagerly so the serializer can read
// them off the entity navigation collections.
foreach (var siteId in selection.SiteIds.Distinct())
{
var site = await _siteRepository.GetSiteByIdAsync(siteId, ct).ConfigureAwait(false);
if (site is null) continue;
sites[site.Id] = site;
foreach (var conn in await _siteRepository.GetDataConnectionsBySiteIdAsync(site.Id, ct).ConfigureAwait(false))
{
dataConnections[conn.Id] = conn;
}
foreach (var siteInstance in await _siteRepository.GetInstancesBySiteIdAsync(site.Id, ct).ConfigureAwait(false))
{
if (instances.ContainsKey(siteInstance.Id)) continue;
var loaded = await LoadInstanceWithChildrenAsync(siteInstance.Id, ct).ConfigureAwait(false);
if (loaded is not null) instances[loaded.Id] = loaded;
}
}
// Selecting an INSTANCE directly. Dedup against instances already pulled in
// via their owning site above (a site + one of its instances selected together
// must not double-add the instance).
foreach (var instanceId in selection.InstanceIds.Distinct())
{
if (instances.ContainsKey(instanceId)) continue;
var loaded = await LoadInstanceWithChildrenAsync(instanceId, ct).ConfigureAwait(false);
if (loaded is not null) instances[loaded.Id] = loaded;
}
// ---- Dependency expansion ----
if (selection.IncludeDependencies)
{
// Each gathered instance pulls in its owning site, the data connections it
// binds, and (by feeding instance.TemplateId into the template dictionary)
// its template — so the template/shared-script/external-system closure below
// expands transitively over instance templates too.
await ExpandSiteInstanceClosureAsync(instances, sites, dataConnections, templates, ct).ConfigureAwait(false);
await ExpandTemplateClosureAsync(templates, ct).ConfigureAwait(false);
await ExpandSharedScriptsFromTemplatesAsync(templates, sharedScripts, ct).ConfigureAwait(false);
await ExpandSharedScriptsFromApiMethodsAsync(apiMethods, sharedScripts, ct).ConfigureAwait(false);
await ExpandExternalSystemsFromTemplatesAsync(templates, externalSystems, ct).ConfigureAwait(false);
}
// ExternalSystemMethods always travel with their parent ExternalSystem.
var externalSystemMethods = new List();
foreach (var es in externalSystems.Values.OrderBy(x => x.Name, StringComparer.Ordinal))
{
var methods = await _externalSystems
.GetMethodsByExternalSystemIdAsync(es.Id, ct)
.ConfigureAwait(false);
externalSystemMethods.AddRange(methods);
}
// Folder ancestor chain — always pull, regardless of IncludeDependencies,
// so the imported tree never references missing parents.
var folders = await ResolveFolderChainAsync(templates.Values, ct).ConfigureAwait(false);
// ---- Topological sort of templates (base-before-derived) ----
var orderedTemplates = TopologicallySortTemplates(templates.Values);
// ---- Deterministic site/instance ordering (M8) ----
// siteId → SiteIdentifier map: every connection's/instance's owning site lands
// in `sites` after closure expansion, so ordering + dependsOn edges resolve names.
var siteIdentifierById = sites.Values.ToDictionary(s => s.Id, s => s.SiteIdentifier);
// I1: when an instance/data-connection is selected directly with
// IncludeDependencies=false, its owning site is not packed (so it never
// lands in `sites`) and the manifest dep-edge would degrade to the raw id
// (Site:) via SiteIdentifierOf's fallback. Resolve the identifier
// for any referenced-but-unpacked owning site so the manifest reads
// Site: regardless of the deps flag — readability only, the
// site row itself stays out of the bundle.
var referencedSiteIds = instances.Values.Select(i => i.SiteId)
.Concat(dataConnections.Values.Select(c => c.SiteId))
.Distinct()
.Where(id => !siteIdentifierById.ContainsKey(id));
foreach (var siteId in referencedSiteIds)
{
var site = await _siteRepository.GetSiteByIdAsync(siteId, ct).ConfigureAwait(false);
if (site is not null) siteIdentifierById[site.Id] = site.SiteIdentifier;
}
var orderedSites = sites.Values
.OrderBy(s => s.SiteIdentifier, StringComparer.Ordinal)
.ToList();
var orderedDataConnections = dataConnections.Values
.OrderBy(c => SiteIdentifierOf(c.SiteId, siteIdentifierById), StringComparer.Ordinal)
.ThenBy(c => c.Name, StringComparer.Ordinal)
.ToList();
var orderedInstances = instances.Values
.OrderBy(i => i.UniqueName, StringComparer.Ordinal)
.ToList();
// ---- Build deterministic content manifest ----
var manifest = BuildContentManifest(
folders,
orderedTemplates,
sharedScripts.Values,
externalSystems.Values,
externalSystemMethods,
dbConnections.Values,
notificationLists.Values,
smtpConfigs.Values,
smsConfigs.Values,
apiMethods.Values,
orderedSites,
orderedDataConnections,
orderedInstances,
siteIdentifierById,
templates);
return new ResolvedExport(
TemplateFolders: folders,
Templates: orderedTemplates,
SharedScripts: sharedScripts.Values.OrderBy(s => s.Name, StringComparer.Ordinal).ToList(),
ExternalSystems: externalSystems.Values.OrderBy(e => e.Name, StringComparer.Ordinal).ToList(),
ExternalSystemMethods: externalSystemMethods,
DatabaseConnections: dbConnections.Values.OrderBy(d => d.Name, StringComparer.Ordinal).ToList(),
NotificationLists: notificationLists.Values.OrderBy(n => n.Name, StringComparer.Ordinal).ToList(),
SmtpConfigs: smtpConfigs.Values.OrderBy(s => s.Host, StringComparer.Ordinal).ToList(),
ApiMethods: apiMethods.Values.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(),
ContentManifest: manifest)
{
Sites = orderedSites,
DataConnections = orderedDataConnections,
Instances = orderedInstances,
// SMS (S10b): ordered by AccountSid — the natural key the importer matches
// on, mirroring how SMTP is ordered by Host.
SmsConfigs = smsConfigs.Values.OrderBy(s => s.AccountSid, StringComparer.Ordinal).ToList(),
};
}
// ---- Instance child loading (M8) ----
// Loads an instance and attaches its overrides/bindings onto the entity's own
// navigation collections, so the serializer reads children off the entity (the
// same shape central-config entities use). Returns null if the instance is gone.
private async Task LoadInstanceWithChildrenAsync(int instanceId, CancellationToken ct)
{
var instance = await _templates.GetInstanceByIdAsync(instanceId, ct).ConfigureAwait(false);
if (instance is null) return null;
var attributeOverrides = await _templates.GetOverridesByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false);
var alarmOverrides = await _templates.GetAlarmOverridesByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false);
var nativeAlarmOverrides = await _templates.GetNativeAlarmSourceOverridesByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false);
var bindings = await _templates.GetBindingsByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false);
instance.AttributeOverrides = attributeOverrides.ToList();
instance.AlarmOverrides = alarmOverrides.ToList();
instance.NativeAlarmSourceOverrides = nativeAlarmOverrides.ToList();
instance.ConnectionBindings = bindings.ToList();
return instance;
}
// ---- Site/instance closure (M8) ----
// For every gathered instance: ensure its owning Site is in the bundle, include
// the DataConnections it binds (via ConnectionBinding.DataConnectionId resolved
// within the instance's site, plus NativeAlarmSourceOverride.ConnectionNameOverride
// resolved by name within that site), and feed its TemplateId into the template
// dictionary so the template closure expands over it.
private async Task ExpandSiteInstanceClosureAsync(
Dictionary instances,
Dictionary sites,
Dictionary dataConnections,
Dictionary templates,
CancellationToken ct)
{
// Snapshot the instances: the loop only grows sites/dataConnections/templates,
// never instances, so iterating a snapshot keeps the dictionary stable.
foreach (var instance in instances.Values.ToList())
{
// 1. Owning site.
if (!sites.ContainsKey(instance.SiteId))
{
var site = await _siteRepository.GetSiteByIdAsync(instance.SiteId, ct).ConfigureAwait(false);
if (site is not null) sites[site.Id] = site;
}
// 2. Template → the existing template closure picks it up below.
if (!templates.ContainsKey(instance.TemplateId))
{
var template = await _templates.GetTemplateWithChildrenAsync(instance.TemplateId, ct).ConfigureAwait(false);
if (template is not null) templates[template.Id] = template;
}
// 3. Data connections the instance references, resolved within its site.
// Connection bindings reference connections by id; native-alarm-source
// overrides reference them by name — resolve both against the site set.
var bindingConnectionIds = instance.ConnectionBindings
.Select(b => b.DataConnectionId)
.Where(id => !dataConnections.ContainsKey(id))
.Distinct()
.ToList();
var referencedConnectionNames = instance.NativeAlarmSourceOverrides
.Select(o => o.ConnectionNameOverride)
.Where(n => !string.IsNullOrEmpty(n))
.Select(n => n!)
.ToHashSet(StringComparer.Ordinal);
if (bindingConnectionIds.Count == 0 && referencedConnectionNames.Count == 0) continue;
var siteConnections = await _siteRepository
.GetDataConnectionsBySiteIdAsync(instance.SiteId, ct)
.ConfigureAwait(false);
foreach (var conn in siteConnections)
{
if (dataConnections.ContainsKey(conn.Id)) continue;
if (bindingConnectionIds.Contains(conn.Id) || referencedConnectionNames.Contains(conn.Name))
{
dataConnections[conn.Id] = conn;
}
}
}
}
private static string SiteIdentifierOf(int siteId, IReadOnlyDictionary siteIdentifierById) =>
siteIdentifierById.TryGetValue(siteId, out var id) ? id : siteId.ToString();
// ---- Template composition closure ----
private async Task ExpandTemplateClosureAsync(Dictionary templates, CancellationToken ct)
{
var queue = new Queue(templates.Values);
while (queue.Count > 0)
{
var current = queue.Dequeue();
foreach (var comp in current.Compositions)
{
if (templates.ContainsKey(comp.ComposedTemplateId)) continue;
var composed = await _templates
.GetTemplateWithChildrenAsync(comp.ComposedTemplateId, ct)
.ConfigureAwait(false);
if (composed is null) continue;
templates[composed.Id] = composed;
queue.Enqueue(composed);
}
}
}
// ---- SharedScript pulls from template script bodies + attributes ----
private async Task ExpandSharedScriptsFromTemplatesAsync(
Dictionary templates,
Dictionary sharedScripts,
CancellationToken ct)
{
var allShared = await _templates.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
foreach (var shared in allShared)
{
if (sharedScripts.ContainsKey(shared.Id)) continue;
if (TemplatesReferenceName(templates.Values, shared.Name))
{
sharedScripts[shared.Id] = shared;
}
}
}
// ---- SharedScript pulls from ApiMethod.Script bodies ----
private async Task ExpandSharedScriptsFromApiMethodsAsync(
Dictionary apiMethods,
Dictionary sharedScripts,
CancellationToken ct)
{
if (apiMethods.Count == 0) return;
var allShared = await _templates.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
foreach (var shared in allShared)
{
if (sharedScripts.ContainsKey(shared.Id)) continue;
if (apiMethods.Values.Any(m => ContainsIdentifier(m.Script, shared.Name)))
{
sharedScripts[shared.Id] = shared;
}
}
}
// ---- ExternalSystem pulls from template script/attribute bodies ----
private async Task ExpandExternalSystemsFromTemplatesAsync(
Dictionary templates,
Dictionary externalSystems,
CancellationToken ct)
{
var allSystems = await _externalSystems.GetAllExternalSystemsAsync(ct).ConfigureAwait(false);
foreach (var es in allSystems)
{
if (externalSystems.ContainsKey(es.Id)) continue;
if (TemplatesReferenceName(templates.Values, es.Name))
{
externalSystems[es.Id] = es;
}
}
}
private static bool TemplatesReferenceName(IEnumerable templates, string name)
{
foreach (var t in templates)
{
foreach (var s in t.Scripts)
{
if (ContainsIdentifier(s.Code, name)) return true;
}
foreach (var a in t.Attributes)
{
if (a.DataSourceReference is not null && ContainsIdentifier(a.DataSourceReference, name)) return true;
if (a.Value is not null && ContainsIdentifier(a.Value, name)) return true;
}
}
return false;
}
///
/// Substring scan with word-boundary guarding: the name must not be a partial
/// match of a longer identifier. Cheap, dependency-free, sufficient for the
/// v1 export-time scan (callers can always over-include with explicit ids).
///
private static bool ContainsIdentifier(string? haystack, string name)
{
if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(name)) return false;
var idx = 0;
while (true)
{
var hit = haystack.IndexOf(name, idx, StringComparison.Ordinal);
if (hit < 0) return false;
var before = hit == 0 ? '\0' : haystack[hit - 1];
var afterIdx = hit + name.Length;
var after = afterIdx >= haystack.Length ? '\0' : haystack[afterIdx];
if (!IsIdentifierChar(before) && !IsIdentifierChar(after)) return true;
idx = hit + 1;
}
}
private static bool IsIdentifierChar(char c) => c == '_' || char.IsLetterOrDigit(c);
// ---- Folder ancestor chain ----
private async Task> ResolveFolderChainAsync(
IEnumerable templates,
CancellationToken ct)
{
var allFolders = await _templates.GetAllFoldersAsync(ct).ConfigureAwait(false);
var byId = allFolders.ToDictionary(f => f.Id);
var needed = new Dictionary();
foreach (var t in templates)
{
var fid = t.FolderId;
while (fid is not null && byId.TryGetValue(fid.Value, out var folder))
{
if (!needed.TryAdd(folder.Id, folder)) break; // already walked this chain
fid = folder.ParentFolderId;
}
}
// Root-first ordering so importers create parents before children.
return needed.Values
.OrderBy(f => DepthOf(f, byId))
.ThenBy(f => f.SortOrder)
.ThenBy(f => f.Name, StringComparer.Ordinal)
.ToList();
}
private static int DepthOf(TemplateFolder folder, IReadOnlyDictionary byId)
{
var depth = 0;
var current = folder;
while (current.ParentFolderId is int pid && byId.TryGetValue(pid, out var parent))
{
depth++;
current = parent;
if (depth > 1024) break; // defensive: folder graph is acyclic by schema
}
return depth;
}
// ---- Kahn's algorithm: order templates base-before-derived ----
/// Sorts templates in base-before-derived order using Kahn's algorithm.
/// The templates to sort; must form an acyclic composition graph.
/// Templates sorted so composed (base) templates appear before their composing (derived) templates.
internal static List TopologicallySortTemplates(IEnumerable templates)
{
// Edge: composed --> composing (composed must appear first).
var nodes = templates.ToDictionary(t => t.Id);
var inDegree = nodes.ToDictionary(kv => kv.Key, _ => 0);
var outEdges = nodes.ToDictionary(kv => kv.Key, _ => new List());
foreach (var t in nodes.Values)
{
foreach (var comp in t.Compositions)
{
if (!nodes.ContainsKey(comp.ComposedTemplateId)) continue;
outEdges[comp.ComposedTemplateId].Add(t.Id);
inDegree[t.Id]++;
}
}
var queue = new Queue(
inDegree.Where(kv => kv.Value == 0)
.OrderBy(kv => nodes[kv.Key].Name, StringComparer.Ordinal)
.Select(kv => kv.Key));
var result = new List(nodes.Count);
while (queue.Count > 0)
{
var id = queue.Dequeue();
result.Add(nodes[id]);
foreach (var next in outEdges[id].OrderBy(x => nodes[x].Name, StringComparer.Ordinal))
{
if (--inDegree[next] == 0) queue.Enqueue(next);
}
}
if (result.Count != nodes.Count)
throw new InvalidOperationException("Template composition graph is cyclic");
return result;
}
private static IReadOnlyList BuildContentManifest(
IReadOnlyList folders,
IReadOnlyList templates,
IEnumerable sharedScripts,
IEnumerable externalSystems,
IEnumerable externalSystemMethods,
IEnumerable dbConnections,
IEnumerable notificationLists,
IEnumerable smtpConfigs,
IEnumerable smsConfigs,
IEnumerable apiMethods,
IReadOnlyList sites,
IReadOnlyList dataConnections,
IReadOnlyList instances,
IReadOnlyDictionary siteIdentifierById,
IReadOnlyDictionary templatesById)
{
var entries = new List();
foreach (var f in folders)
{
entries.Add(new ManifestContentEntry("TemplateFolder", f.Name, 1, Array.Empty()));
}
foreach (var t in templates)
{
var deps = t.Compositions
.Select(c => $"Template:{(templates.FirstOrDefault(x => x.Id == c.ComposedTemplateId)?.Name ?? c.ComposedTemplateId.ToString())}")
.OrderBy(x => x, StringComparer.Ordinal)
.ToList();
entries.Add(new ManifestContentEntry("Template", t.Name, 1, deps));
}
foreach (var s in sharedScripts.OrderBy(x => x.Name, StringComparer.Ordinal))
{
entries.Add(new ManifestContentEntry("SharedScript", s.Name, 1, Array.Empty()));
}
foreach (var es in externalSystems.OrderBy(x => x.Name, StringComparer.Ordinal))
{
entries.Add(new ManifestContentEntry("ExternalSystem", es.Name, 1, Array.Empty()));
}
var methodsBySystem = externalSystemMethods.GroupBy(m => m.ExternalSystemDefinitionId);
var systemById = externalSystems.ToDictionary(x => x.Id);
foreach (var grp in methodsBySystem.OrderBy(g => systemById.TryGetValue(g.Key, out var es) ? es.Name : string.Empty, StringComparer.Ordinal))
{
var systemName = systemById.TryGetValue(grp.Key, out var es) ? es.Name : grp.Key.ToString();
foreach (var m in grp.OrderBy(x => x.Name, StringComparer.Ordinal))
{
entries.Add(new ManifestContentEntry(
"ExternalSystemMethod",
$"{systemName}.{m.Name}",
1,
new[] { $"ExternalSystem:{systemName}" }));
}
}
foreach (var d in dbConnections.OrderBy(x => x.Name, StringComparer.Ordinal))
{
entries.Add(new ManifestContentEntry("DatabaseConnection", d.Name, 1, Array.Empty()));
}
foreach (var n in notificationLists.OrderBy(x => x.Name, StringComparer.Ordinal))
{
entries.Add(new ManifestContentEntry("NotificationList", n.Name, 1, Array.Empty()));
}
foreach (var s in smtpConfigs.OrderBy(x => x.Host, StringComparer.Ordinal))
{
entries.Add(new ManifestContentEntry("SmtpConfiguration", s.Host, 1, Array.Empty()));
}
// SMS (S10b): mirror SMTP — one manifest row per config, keyed by AccountSid
// (the natural key the importer matches on).
foreach (var s in smsConfigs.OrderBy(x => x.AccountSid, StringComparer.Ordinal))
{
entries.Add(new ManifestContentEntry("SmsConfiguration", s.AccountSid, 1, Array.Empty()));
}
// Inbound API keys are not transported (re-arch C4) — no ApiKey manifest entries.
foreach (var m in apiMethods.OrderBy(x => x.Name, StringComparer.Ordinal))
{
entries.Add(new ManifestContentEntry("ApiMethod", m.Name, 1, Array.Empty()));
}
// ---- M8: site/instance-scoped manifest rows ----
// Sites are roots (no dependsOn). DataConnections depend on their site.
// Instances depend on their template, their site, and each data connection
// they bind. Inputs arrive pre-ordered, so iterate as-is for determinism.
var connectionById = dataConnections.ToDictionary(c => c.Id);
var connectionBySiteAndName = dataConnections
.GroupBy(c => c.SiteId)
.ToDictionary(g => g.Key, g => g.ToDictionary(c => c.Name, StringComparer.Ordinal));
foreach (var site in sites)
{
entries.Add(new ManifestContentEntry("Site", site.SiteIdentifier, 1, Array.Empty()));
}
foreach (var conn in dataConnections)
{
var siteIdentifier = SiteIdentifierOf(conn.SiteId, siteIdentifierById);
entries.Add(new ManifestContentEntry(
"DataConnection",
$"{siteIdentifier}/{conn.Name}",
1,
new[] { $"Site:{siteIdentifier}" }));
}
foreach (var instance in instances)
{
var siteIdentifier = SiteIdentifierOf(instance.SiteId, siteIdentifierById);
var templateName = templatesById.TryGetValue(instance.TemplateId, out var tpl)
? tpl.Name
: instance.TemplateId.ToString();
var deps = new List
{
$"Template:{templateName}",
$"Site:{siteIdentifier}",
};
// Connection-binding edges (resolved by id within the export closure).
foreach (var binding in instance.ConnectionBindings)
{
if (connectionById.TryGetValue(binding.DataConnectionId, out var conn))
{
deps.Add($"DataConnection:{SiteIdentifierOf(conn.SiteId, siteIdentifierById)}/{conn.Name}");
}
}
// Native-alarm-source connection-name overrides (resolved by name within the site).
if (connectionBySiteAndName.TryGetValue(instance.SiteId, out var byName))
{
foreach (var ovr in instance.NativeAlarmSourceOverrides)
{
if (!string.IsNullOrEmpty(ovr.ConnectionNameOverride)
&& byName.ContainsKey(ovr.ConnectionNameOverride))
{
deps.Add($"DataConnection:{siteIdentifier}/{ovr.ConnectionNameOverride}");
}
}
}
var orderedDeps = deps.Distinct().OrderBy(x => x, StringComparer.Ordinal).ToList();
entries.Add(new ManifestContentEntry("Instance", instance.UniqueName, 1, orderedDeps));
}
return entries;
}
}