diff --git a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyAttributeRow.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyAttributeRow.cs
new file mode 100644
index 0000000..e82cf2d
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyAttributeRow.cs
@@ -0,0 +1,41 @@
+namespace ZB.MOM.WW.GalaxyRepository;
+
+/// One row from .
+public sealed class GalaxyAttributeRow
+{
+ /// Gets the Galaxy object identifier.
+ public int GobjectId { get; init; }
+
+ /// Gets the tag name.
+ public string TagName { get; init; } = string.Empty;
+
+ /// Gets the attribute name.
+ public string AttributeName { get; init; } = string.Empty;
+
+ /// Gets the full tag reference.
+ public string FullTagReference { get; init; } = string.Empty;
+
+ /// Gets the MXAccess data type code.
+ public int MxDataType { get; init; }
+
+ /// Gets the data type name.
+ public string? DataTypeName { get; init; }
+
+ /// Gets a value indicating whether this is an array.
+ public bool IsArray { get; init; }
+
+ /// Gets the array dimension, if applicable.
+ public int? ArrayDimension { get; init; }
+
+ /// Gets the MXAccess attribute category code.
+ public int MxAttributeCategory { get; init; }
+
+ /// Gets the security classification code.
+ public int SecurityClassification { get; init; }
+
+ /// Gets a value indicating whether this is historized.
+ public bool IsHistorized { get; init; }
+
+ /// Gets a value indicating whether this is an alarm.
+ public bool IsAlarm { get; init; }
+}
diff --git a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyRow.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyRow.cs
new file mode 100644
index 0000000..5bbbaa3
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyRow.cs
@@ -0,0 +1,35 @@
+namespace ZB.MOM.WW.GalaxyRepository;
+
+///
+/// One row from : a deployed Galaxy
+/// gobject with its hierarchy parent and template-derivation chain.
+///
+public sealed class GalaxyHierarchyRow
+{
+ /// Gets the Galaxy object identifier.
+ public int GobjectId { get; init; }
+
+ /// Gets the tag name.
+ public string TagName { get; init; } = string.Empty;
+
+ /// Gets the contained name.
+ public string ContainedName { get; init; } = string.Empty;
+
+ /// Gets the browse name.
+ public string BrowseName { get; init; } = string.Empty;
+
+ /// Gets the parent Galaxy object identifier.
+ public int ParentGobjectId { get; init; }
+
+ /// Gets a value indicating whether this is an area.
+ public bool IsArea { get; init; }
+
+ /// Gets the category identifier.
+ public int CategoryId { get; init; }
+
+ /// Gets the Galaxy object identifier of the host.
+ public int HostedByGobjectId { get; init; }
+
+ /// Gets the template derivation chain.
+ public IReadOnlyList TemplateChain { get; init; } = Array.Empty();
+}
diff --git a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyRepository.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyRepository.cs
new file mode 100644
index 0000000..eb6224c
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyRepository.cs
@@ -0,0 +1,257 @@
+using Microsoft.Data.SqlClient;
+
+namespace ZB.MOM.WW.GalaxyRepository;
+
+///
+/// SQL access to the AVEVA System Platform Galaxy Repository database.
+///
+/// is the query originally ported from the OtOpcUa
+/// project. has diverged: it additionally enumerates the
+/// built-in attributes contributed by each object's primitives (from
+/// attribute_definition via primitive_instance), so engine/platform objects
+/// and extension sub-attributes (e.g. TestAlarm001.Acked) are surfaced. The
+/// OtOpcUa query is not kept in sync.
+///
+///
+public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyRepository
+{
+ /// Tests the connection to the Galaxy Repository database.
+ /// Token to cancel the asynchronous operation.
+ public async Task TestConnectionAsync(CancellationToken ct = default)
+ {
+ try
+ {
+ using SqlConnection conn = new(options.ConnectionString);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
+ using SqlCommand cmd = new("SELECT 1", conn) { CommandTimeout = options.CommandTimeoutSeconds };
+ object? result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
+ return result is int i && i == 1;
+ }
+ catch (SqlException) { return false; }
+ catch (InvalidOperationException) { return false; }
+ }
+
+ /// Retrieves the last deployment time from the Galaxy Repository.
+ /// Token to cancel the asynchronous operation.
+ public async Task GetLastDeployTimeAsync(CancellationToken ct = default)
+ {
+ using SqlConnection conn = new(options.ConnectionString);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
+ using SqlCommand cmd = new("SELECT time_of_last_deploy FROM galaxy", conn)
+ { CommandTimeout = options.CommandTimeoutSeconds };
+ object? result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
+ return result is DateTime dt ? dt : null;
+ }
+
+ /// Retrieves the complete hierarchy of Galaxy objects from the repository.
+ /// Token to cancel the asynchronous operation.
+ public async Task> GetHierarchyAsync(CancellationToken ct = default)
+ {
+ List rows = new();
+
+ using SqlConnection conn = new(options.ConnectionString);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
+
+ using SqlCommand cmd = new(HierarchySql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
+ using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
+
+ while (await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ string templateChainRaw = reader.IsDBNull(8) ? string.Empty : reader.GetString(8);
+ string[] templateChain = templateChainRaw.Length == 0
+ ? Array.Empty()
+ : templateChainRaw.Split(['|'], StringSplitOptions.RemoveEmptyEntries)
+ .Select(s => s.Trim())
+ .Where(s => s.Length > 0)
+ .ToArray();
+
+ rows.Add(new GalaxyHierarchyRow
+ {
+ GobjectId = Convert.ToInt32(reader.GetValue(0)),
+ TagName = reader.GetString(1),
+ ContainedName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
+ BrowseName = reader.GetString(3),
+ ParentGobjectId = Convert.ToInt32(reader.GetValue(4)),
+ IsArea = Convert.ToInt32(reader.GetValue(5)) == 1,
+ CategoryId = Convert.ToInt32(reader.GetValue(6)),
+ HostedByGobjectId = Convert.ToInt32(reader.GetValue(7)),
+ TemplateChain = templateChain,
+ });
+ }
+ return rows;
+ }
+
+ /// Retrieves all attributes for Galaxy objects from the repository.
+ /// Token to cancel the asynchronous operation.
+ public async Task> GetAttributesAsync(CancellationToken ct = default)
+ {
+ List rows = new();
+
+ using SqlConnection conn = new(options.ConnectionString);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
+
+ using SqlCommand cmd = new(AttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
+ using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
+
+ while (await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ rows.Add(new GalaxyAttributeRow
+ {
+ GobjectId = Convert.ToInt32(reader.GetValue(0)),
+ TagName = reader.GetString(1),
+ AttributeName = reader.GetString(2),
+ FullTagReference = reader.GetString(3),
+ MxDataType = Convert.ToInt32(reader.GetValue(4)),
+ DataTypeName = reader.IsDBNull(5) ? null : reader.GetString(5),
+ IsArray = Convert.ToInt32(reader.GetValue(6)) == 1,
+ ArrayDimension = reader.IsDBNull(7) ? null : Convert.ToInt32(reader.GetValue(7)),
+ MxAttributeCategory = Convert.ToInt32(reader.GetValue(8)),
+ SecurityClassification = Convert.ToInt32(reader.GetValue(9)),
+ IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1,
+ IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1,
+ });
+ }
+ return rows;
+ }
+
+ // Area objects (category 13) are returned even when undeployed (deployed_package_id = 0):
+ // they are organizational/model nodes that group deployed objects, so excluding them
+ // orphans every area whose containing area is not itself deployed. All non-area objects
+ // still require deployment. Orphans left by a missing/deleted parent area are re-rooted
+ // by GalaxyHierarchyIndex.Build so nothing disappears from browse.
+ private const string HierarchySql = @"
+;WITH template_chain AS (
+ SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id,
+ t.tag_name AS template_tag_name, t.derived_from_gobject_id, 0 AS depth
+ FROM gobject g
+ INNER JOIN gobject t ON t.gobject_id = g.derived_from_gobject_id
+ WHERE g.is_template = 0 AND g.deployed_package_id <> 0 AND g.derived_from_gobject_id <> 0
+ UNION ALL
+ SELECT tc.instance_gobject_id, t.gobject_id, t.tag_name, t.derived_from_gobject_id, tc.depth + 1
+ FROM template_chain tc
+ INNER JOIN gobject t ON t.gobject_id = tc.derived_from_gobject_id
+ WHERE tc.derived_from_gobject_id <> 0 AND tc.depth < 10
+)
+SELECT DISTINCT
+ g.gobject_id,
+ g.tag_name,
+ g.contained_name,
+ CASE WHEN g.contained_name IS NULL OR g.contained_name = ''
+ THEN g.tag_name
+ ELSE g.contained_name
+ END AS browse_name,
+ CASE WHEN g.contained_by_gobject_id = 0
+ THEN g.area_gobject_id
+ ELSE g.contained_by_gobject_id
+ END AS parent_gobject_id,
+ CASE WHEN td.category_id = 13
+ THEN 1
+ ELSE 0
+ END AS is_area,
+ td.category_id AS category_id,
+ g.hosted_by_gobject_id AS hosted_by_gobject_id,
+ ISNULL(
+ STUFF((
+ SELECT '|' + tc.template_tag_name
+ FROM template_chain tc
+ WHERE tc.instance_gobject_id = g.gobject_id
+ ORDER BY tc.depth
+ FOR XML PATH('')
+ ), 1, 1, ''),
+ ''
+ ) AS template_chain
+FROM gobject g
+INNER JOIN template_definition td
+ ON g.template_definition_id = td.template_definition_id
+WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
+ AND g.is_template = 0
+ AND (g.deployed_package_id <> 0 OR td.category_id = 13)
+ORDER BY parent_gobject_id, g.tag_name";
+
+ // Unlike HierarchySql, this query has diverged from the OtOpcUa original. It returns two
+ // kinds of attribute: user-configured dynamic attributes (the original `dynamic_attribute`
+ // body, src_pri 0) and the built-in attributes every object inherits from its primitives
+ // (`attribute_definition` joined through `primitive_instance`, src_pri 1). Built-in
+ // attributes are why engine/platform objects and extension sub-attributes such as
+ // `TestAlarm001.Acked` show up at all. Built-in rows carry no category filter (the
+ // `attribute_definition` category numbering differs from `dynamic_attribute`'s — only the
+ // `_`-prefix and `.Description` name exclusions apply) and are never flagged
+ // `is_historized`/`is_alarm`: those flags describe a user attribute that anchors an
+ // extension, not the extension's machinery leaves.
+ private const string AttributesSql = @"
+;WITH deployed_package_chain AS (
+ SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
+ FROM gobject g
+ INNER JOIN package p ON p.package_id = g.deployed_package_id
+ WHERE g.is_template = 0 AND g.deployed_package_id <> 0
+ UNION ALL
+ SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1
+ FROM deployed_package_chain dpc
+ INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
+ WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
+),
+candidate AS (
+ SELECT
+ dpc.gobject_id, g.tag_name, da.attribute_name, da.mx_data_type, da.is_array,
+ CASE WHEN da.is_array = 1
+ THEN CONVERT(int, CONVERT(varbinary(2),
+ SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2))
+ ELSE NULL END AS array_dimension,
+ da.mx_attribute_category, da.security_classification, dpc.depth, 0 AS src_pri
+ FROM deployed_package_chain dpc
+ INNER JOIN dynamic_attribute da ON da.package_id = dpc.package_id
+ INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id
+ INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id
+ WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
+ AND da.attribute_name NOT LIKE '[_]%'
+ AND da.attribute_name NOT LIKE '%.Description'
+ AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
+ UNION ALL
+ SELECT
+ dpc.gobject_id, g.tag_name,
+ CASE WHEN pi.primitive_name IS NULL OR pi.primitive_name = ''
+ THEN ad.attribute_name
+ ELSE pi.primitive_name + '.' + ad.attribute_name END AS attribute_name,
+ ad.mx_data_type, ad.is_array,
+ CASE WHEN ad.is_array = 1
+ THEN CONVERT(int, CONVERT(varbinary(2),
+ SUBSTRING(ad.mx_value, 15, 2) + SUBSTRING(ad.mx_value, 13, 2), 2))
+ ELSE NULL END AS array_dimension,
+ ad.mx_attribute_category, ad.security_classification, dpc.depth, 1 AS src_pri
+ FROM deployed_package_chain dpc
+ INNER JOIN primitive_instance pi ON pi.package_id = dpc.package_id
+ INNER JOIN attribute_definition ad ON ad.primitive_definition_id = pi.primitive_definition_id
+ INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id
+ INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id
+ WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
+ AND ad.attribute_name NOT LIKE '[_]%'
+ AND ad.attribute_name NOT LIKE '%.Description'
+),
+ranked AS (
+ SELECT c.*, ROW_NUMBER() OVER (
+ PARTITION BY c.gobject_id, c.attribute_name ORDER BY c.src_pri, c.depth) AS rn
+ FROM candidate c
+)
+SELECT
+ r.gobject_id, r.tag_name, r.attribute_name,
+ r.tag_name + '.' + r.attribute_name
+ + CASE WHEN r.is_array = 1 THEN '[]' ELSE '' END AS full_tag_reference,
+ r.mx_data_type, dt.description AS data_type_name, r.is_array, r.array_dimension,
+ r.mx_attribute_category, r.security_classification,
+ CASE WHEN r.src_pri = 0 AND EXISTS (
+ SELECT 1 FROM deployed_package_chain dpc2
+ INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = r.attribute_name
+ INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension'
+ WHERE dpc2.gobject_id = r.gobject_id
+ ) THEN 1 ELSE 0 END AS is_historized,
+ CASE WHEN r.src_pri = 0 AND EXISTS (
+ SELECT 1 FROM deployed_package_chain dpc2
+ INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = r.attribute_name
+ INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
+ WHERE dpc2.gobject_id = r.gobject_id
+ ) THEN 1 ELSE 0 END AS is_alarm
+FROM ranked r
+LEFT JOIN data_type dt ON dt.mx_data_type = r.mx_data_type
+WHERE r.rn = 1
+ORDER BY r.tag_name, r.attribute_name";
+}
diff --git a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyRepositoryOptions.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyRepositoryOptions.cs
new file mode 100644
index 0000000..6975f7d
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyRepositoryOptions.cs
@@ -0,0 +1,55 @@
+namespace ZB.MOM.WW.GalaxyRepository;
+
+///
+/// Connection settings for the AVEVA System Platform Galaxy Repository database.
+///
+/// is a generic default; the DI extension accepts an explicit
+/// configuration section path so a consumer can bind from its own section (e.g.
+/// MxGateway:Galaxy).
+///
+///
+public sealed class GalaxyRepositoryOptions
+{
+ ///
+ /// Generic default configuration section name. The DI extension accepts an explicit
+ /// section path, so a consumer may bind from a different section (e.g.
+ /// MxGateway:Galaxy).
+ ///
+ public const string SectionName = "GalaxyRepository";
+
+ ///
+ /// Default SQL Server connection string for the Galaxy Repository database.
+ /// Single source of truth shared with the integration-test fallback so the
+ /// production default and the live-test default cannot drift.
+ ///
+ public const string DefaultConnectionString =
+ "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;";
+
+ /// The SQL Server connection string for the Galaxy Repository database.
+ public string ConnectionString { get; init; } = DefaultConnectionString;
+
+ /// The timeout in seconds for SQL commands executed against the Galaxy Repository.
+ public int CommandTimeoutSeconds { get; init; } = 60;
+
+ ///
+ /// Interval (seconds) between background refreshes of the dashboard Galaxy summary
+ /// cache. SQL is hit at most once per interval regardless of dashboard render rate.
+ ///
+ public int DashboardRefreshIntervalSeconds { get; init; } = 30;
+
+ ///
+ /// Whether the latest successful Galaxy browse dataset is persisted to disk. When
+ /// enabled, the cache reloads that snapshot at startup so clients can still browse
+ /// last-known data while the Galaxy database is unreachable.
+ ///
+ public bool PersistSnapshot { get; init; } = true;
+
+ ///
+ /// File path for the persisted Galaxy browse snapshot. Ignored when
+ /// is . There is no built-in
+ /// default path — the consumer supplies a cross-platform-friendly path appropriate to
+ /// its host. When left empty and is enabled, the
+ /// snapshot store (a later task) decides where to write.
+ ///
+ public string SnapshotCachePath { get; init; } = string.Empty;
+}
diff --git a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/IGalaxyRepository.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/IGalaxyRepository.cs
new file mode 100644
index 0000000..8924e50
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/IGalaxyRepository.cs
@@ -0,0 +1,26 @@
+namespace ZB.MOM.WW.GalaxyRepository;
+
+///
+/// Abstraction over : the read-only SQL surface over the
+/// AVEVA System Platform Galaxy Repository database. Exists so consumers (and the cache
+/// layer, a later task) can be unit-tested against an in-memory fake without standing up a
+/// real Microsoft.Data.SqlClient SqlConnection against a bogus host/port.
+///
+public interface IGalaxyRepository
+{
+ /// Tests the connection to the Galaxy Repository database.
+ /// Token to cancel the asynchronous operation.
+ Task TestConnectionAsync(CancellationToken ct = default);
+
+ /// Retrieves the last deployment time from the Galaxy Repository.
+ /// Token to cancel the asynchronous operation.
+ Task GetLastDeployTimeAsync(CancellationToken ct = default);
+
+ /// Retrieves the complete hierarchy of Galaxy objects from the repository.
+ /// Token to cancel the asynchronous operation.
+ Task> GetHierarchyAsync(CancellationToken ct = default);
+
+ /// Retrieves all attributes for Galaxy objects from the repository.
+ /// Token to cancel the asynchronous operation.
+ Task> GetAttributesAsync(CancellationToken ct = default);
+}