diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index b6647bd2..8b786bac 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -29,6 +29,9 @@
+
+
+
@@ -90,6 +93,9 @@
+
+
+
diff --git a/docker-dev/docker-compose.yml b/docker-dev/docker-compose.yml
index a76868d8..0dfe2a73 100644
--- a/docker-dev/docker-compose.yml
+++ b/docker-dev/docker-compose.yml
@@ -272,6 +272,12 @@ services:
OTOPCUA_ROLES: "admin,driver,cluster-MAIN"
ASPNETCORE_URLS: "http://+:9000"
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
+ # Sql poll driver — the named connection string a Sql driver instance references by
+ # `connectionStringRef: "DevSql"`. Resolved in-process by SqlConnectionStringResolver (driver
+ # node) and SqlDriverBrowser (AdminUI browse), env-only, never persisted to ConfigDb. Points at
+ # the rig's own `sql` container, a SEPARATE `DevSql` database seeded with dbo.TagValues (KeyValue)
+ # + dbo.LatestStatus (WideRow). Committed-dev-secret exception, same as ConfigDb above.
+ Sql__ConnectionStrings__DevSql: "Server=sql,1433;Database=DevSql;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
Cluster__Hostname: "0.0.0.0"
Cluster__Port: "4053"
Cluster__PublicHostname: "central-1"
@@ -391,6 +397,12 @@ services:
OTOPCUA_ROLES: "admin,driver,cluster-MAIN"
ASPNETCORE_URLS: "http://+:9000"
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
+ # Sql poll driver — the named connection string a Sql driver instance references by
+ # `connectionStringRef: "DevSql"`. Resolved in-process by SqlConnectionStringResolver (driver
+ # node) and SqlDriverBrowser (AdminUI browse), env-only, never persisted to ConfigDb. Points at
+ # the rig's own `sql` container, a SEPARATE `DevSql` database seeded with dbo.TagValues (KeyValue)
+ # + dbo.LatestStatus (WideRow). Committed-dev-secret exception, same as ConfigDb above.
+ Sql__ConnectionStrings__DevSql: "Server=sql,1433;Database=DevSql;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
Cluster__Hostname: "0.0.0.0"
Cluster__Port: "4053"
Cluster__PublicHostname: "central-2"
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs
index d4ba0417..bbebd080 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs
@@ -53,6 +53,9 @@ public static class DriverTypeNames
/// Calculation pseudo-driver — tags computed by C# scripts over other tags' live values.
public const string Calculation = "Calculation";
+ /// Read-only SQL Server table/view poller — publishes columns as OPC UA variables.
+ public const string Sql = "Sql";
+
///
/// Every driver-type string declared above, for callers that need to enumerate
/// the full set (e.g. validation of an authored DriverType).
@@ -68,5 +71,6 @@ public static class DriverTypeNames
OpcUaClient,
Galaxy,
Calculation,
+ Sql,
];
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseNodeId.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseNodeId.cs
new file mode 100644
index 00000000..e55c0bf6
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseNodeId.cs
@@ -0,0 +1,201 @@
+using System.Text;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
+
+/// Which catalog level a browse NodeId addresses.
+public enum SqlBrowseNodeKind
+{
+ /// A schema — the browse root level.
+ Schema,
+
+ /// A table or view inside a schema.
+ Table,
+
+ /// A column of a table or view — the terminal (leaf) level.
+ Column,
+}
+
+/// A decoded browse NodeId.
+/// Which catalog level this reference addresses.
+/// The schema name. Always present.
+/// The table/view name; null for .
+/// The column name; null unless .
+public readonly record struct SqlBrowseNodeRef(
+ SqlBrowseNodeKind Kind,
+ string Schema,
+ string? Table,
+ string? Column);
+
+///
+/// Codec for the browse NodeIds the SQL schema browser hands the picker, and the picker hands back
+/// on expand / attribute fetch / column commit.
+/// Why not the design's literal schema.table|column. That sketch assumes . and
+/// | cannot occur inside an identifier. They can: SQL Server permits essentially any character
+/// inside a quoted (bracketed) identifier, and SqlServerDialect.QuoteIdentifier deliberately passes
+/// both through — only ], control characters and Unicode format characters are special to it. So
+/// main.a.b|c decodes equally as schema main + table a.b and as schema main.a
+/// + table b, and a table named x|y loses its second half entirely. That is not a cosmetic
+/// bug: the operator clicks one column, the NodeId decodes to a different one, and the tag they author
+/// polls the wrong data forever — silently, because the wrong column usually exists and usually reads.
+///
+/// The encoding.<kind>:<part>[|<part>…], where kind is one
+/// of schema / table / column and every part is escaped so no identifier character
+/// can be mistaken for structure: a literal \ becomes \\ and a literal | becomes
+/// \|. . needs no escape at all — it is not structural here — so the common case stays
+/// readable (column:dbo|TagValues|num_value). The kind prefix carries the arity, so decoding never
+/// has to guess how many parts a name "should" have; a part count that disagrees with the kind is
+/// rejected rather than truncated or padded.
+/// Public on purpose, unlike the session itself: the AdminUI picker body decodes a committed
+/// column NodeId back into schema/table/column to compose the tag's TagConfig. Encode and decode
+/// must stay in one place — a second, hand-rolled split in the picker is exactly the defect this type
+/// exists to prevent.
+///
+public static class SqlBrowseNodeId
+{
+ private const char Separator = '|';
+ private const char Escape = '\\';
+ private const char KindTerminator = ':';
+
+ private const string SchemaKind = "schema";
+ private const string TableKind = "table";
+ private const string ColumnKind = "column";
+
+ /// Encodes a schema-level NodeId.
+ /// The schema name, exactly as the catalog reports it.
+ /// The encoded NodeId.
+ /// is null, empty or whitespace.
+ public static string ForSchema(string schema) => Encode(SchemaKind, schema);
+
+ /// Encodes a table-level NodeId.
+ /// The schema name, exactly as the catalog reports it.
+ /// The table or view name, exactly as the catalog reports it.
+ /// The encoded NodeId.
+ /// Either name is null, empty or whitespace.
+ public static string ForTable(string schema, string table) => Encode(TableKind, schema, table);
+
+ /// Encodes a column-level (leaf) NodeId.
+ /// The schema name, exactly as the catalog reports it.
+ /// The table or view name, exactly as the catalog reports it.
+ /// The column name, exactly as the catalog reports it.
+ /// The encoded NodeId.
+ /// Any name is null, empty or whitespace.
+ public static string ForColumn(string schema, string table, string column) =>
+ Encode(ColumnKind, schema, table, column);
+
+ /// Attempts to decode a NodeId.
+ /// The NodeId to decode.
+ /// The decoded reference on success; default otherwise.
+ /// true when is a well-formed SQL browse NodeId.
+ public static bool TryParse(string? nodeId, out SqlBrowseNodeRef reference)
+ {
+ reference = default;
+ if (string.IsNullOrWhiteSpace(nodeId)) return false;
+
+ var terminator = nodeId.IndexOf(KindTerminator, StringComparison.Ordinal);
+ if (terminator <= 0) return false;
+
+ var kind = nodeId[..terminator];
+ if (!TrySplit(nodeId[(terminator + 1)..], out var parts)) return false;
+ if (parts.Exists(string.IsNullOrWhiteSpace)) return false;
+
+ switch (kind)
+ {
+ case SchemaKind when parts.Count == 1:
+ reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Schema, parts[0], null, null);
+ return true;
+ case TableKind when parts.Count == 2:
+ reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Table, parts[0], parts[1], null);
+ return true;
+ case ColumnKind when parts.Count == 3:
+ reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Column, parts[0], parts[1], parts[2]);
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ ///
+ /// Decodes a NodeId, throwing when it is malformed.
+ /// The message deliberately does not echo the offending value: a NodeId can carry an
+ /// arbitrary authored identifier and this text reaches the AdminUI's inline error and the log.
+ ///
+ /// The NodeId to decode.
+ /// The decoded reference.
+ /// The value is not a well-formed SQL browse NodeId.
+ public static SqlBrowseNodeRef Parse(string? nodeId)
+ {
+ if (TryParse(nodeId, out var reference)) return reference;
+ throw new ArgumentException(
+ "Not a SQL schema-browse node. Expected 'schema:', 'table:|
' or " +
+ "'column:|
|'. Re-open the browser and expand from the root.",
+ nameof(nodeId));
+ }
+
+ private static string Encode(string kind, params string[] parts)
+ {
+ var builder = new StringBuilder(kind.Length + 1 + (parts.Length * 16));
+ builder.Append(kind).Append(KindTerminator);
+ for (var i = 0; i < parts.Length; i++)
+ {
+ if (string.IsNullOrWhiteSpace(parts[i]))
+ {
+ throw new ArgumentException(
+ "A SQL catalog name in a browse node may not be empty or whitespace.", nameof(parts));
+ }
+
+ if (i > 0) builder.Append(Separator);
+ AppendEscaped(builder, parts[i]);
+ }
+
+ return builder.ToString();
+ }
+
+ private static void AppendEscaped(StringBuilder builder, string part)
+ {
+ foreach (var ch in part)
+ {
+ if (ch is Separator or Escape) builder.Append(Escape);
+ builder.Append(ch);
+ }
+ }
+
+ ///
+ /// Splits an encoded payload on unescaped separators, unescaping as it goes. Returns
+ /// false on a dangling trailing escape, which cannot be produced by
+ /// and therefore means the value was hand-made or truncated.
+ ///
+ private static bool TrySplit(string payload, out List parts)
+ {
+ parts = [];
+ var current = new StringBuilder(payload.Length);
+ var escaped = false;
+
+ foreach (var ch in payload)
+ {
+ if (escaped)
+ {
+ current.Append(ch);
+ escaped = false;
+ continue;
+ }
+
+ switch (ch)
+ {
+ case Escape:
+ escaped = true;
+ break;
+ case Separator:
+ parts.Add(current.ToString());
+ current.Clear();
+ break;
+ default:
+ current.Append(ch);
+ break;
+ }
+ }
+
+ if (escaped) return false;
+ parts.Add(current.ToString());
+ return true;
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs
new file mode 100644
index 00000000..06c05d8f
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs
@@ -0,0 +1,304 @@
+using System.Data;
+using System.Data.Common;
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
+
+///
+/// Live, one-level-per-call walk of a relational catalog: schemas → tables/views → columns, with the
+/// column's mapped DriverDataType in the attribute side-panel (design §4.1, mirroring Galaxy's
+/// two-stage object-then-attribute pick). Created by SqlDriverBrowser on picker open and owned by
+/// the AdminUI's BrowseSessionRegistry, whose TTL reaper disposes idle sessions.
+/// Every level is the dialect's catalog SQL ( /
+/// / ) — nothing here
+/// knows what INFORMATION_SCHEMA is, because Oracle and SQLite do not have it. The catalog SQL is
+/// read verbatim and @schema / @table are bound as parameters; no name from a NodeId
+/// is ever concatenated into a command text, so a hostile or merely awkward catalog name is inert here.
+/// is deliberately not used on this path — the browse
+/// never needs an identifier in text.
+/// Connection ownership: this session owns the connection it is handed and closes it on
+/// . It does not open one, and it never re-opens a closed one. The handoff
+/// is the same as the Galaxy and OPC UA client sessions': the browser owns the connection only until
+/// construction succeeds, after which the registry-held session is the sole thing with a lifetime hook —
+/// if the session did not close it, nothing would, and every reaped picker would leak a pooled
+/// connection.
+/// No timeout of its own. The AdminUI already bounds each root/expand/attributes call with a
+/// 20-second linked CTS (BrowserSessionService.PerCallTimeout); this session's job is simply to
+/// honour the token it is handed, into the gate wait and into every ADO.NET call. Adding a second
+/// independent deadline here would only produce two competing, differently-worded failures.
+///
+internal sealed class SqlBrowseSession : IBrowseSession
+{
+ /// Column alias every dialect's projects.
+ private const string SchemaColumn = "TABLE_SCHEMA";
+
+ /// Column alias every dialect's projects.
+ private const string TableNameColumn = "TABLE_NAME";
+
+ /// Column alias carrying BASE TABLE / VIEW.
+ private const string TableTypeColumn = "TABLE_TYPE";
+
+ /// Column alias every dialect's projects.
+ private const string ColumnNameColumn = "COLUMN_NAME";
+
+ /// Column alias carrying the SQL type family name.
+ private const string DataTypeColumn = "DATA_TYPE";
+
+ /// The TABLE_TYPE value marking a view rather than a base table.
+ private const string ViewTableType = "VIEW";
+
+ ///
+ /// The security class every browsed column reports. The Sql driver is read-only in v1
+ /// (design §4.3), so there is nothing else a picked column could be.
+ ///
+ private const string ReadOnlySecurityClass = "ViewOnly";
+
+ private readonly DbConnection _connection;
+ private readonly ISqlDialect _dialect;
+
+ ///
+ /// Serializes catalog calls. One ADO.NET connection carries at most one active command/reader, and
+ /// the AdminUI tree happily fires several expands at once when an operator clicks quickly.
+ ///
+ private readonly SemaphoreSlim _gate = new(1, 1);
+
+ private volatile bool _disposed;
+
+ /// Constructs a session over an already-open connection, which it takes ownership of.
+ /// The open connection to browse. Closed by .
+ /// The dialect supplying the catalog SQL and the column-type map.
+ internal SqlBrowseSession(DbConnection connection, ISqlDialect dialect)
+ {
+ _connection = connection ?? throw new ArgumentNullException(nameof(connection));
+ _dialect = dialect ?? throw new ArgumentNullException(nameof(dialect));
+ }
+
+ ///
+ public Guid Token { get; } = Guid.NewGuid();
+
+ ///
+ public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
+
+ ///
+ public async Task> RootAsync(CancellationToken cancellationToken)
+ {
+ var schemas = await QueryAsync(
+ _dialect.ListSchemasSql,
+ static _ => { },
+ static reader => ReadString(reader, SchemaColumn),
+ cancellationToken).ConfigureAwait(false);
+
+ var nodes = new List(schemas.Count);
+ foreach (var schema in schemas)
+ {
+ if (string.IsNullOrWhiteSpace(schema)) continue;
+ nodes.Add(new BrowseNode(
+ NodeId: SqlBrowseNodeId.ForSchema(schema),
+ DisplayName: schema,
+ Kind: BrowseNodeKind.Folder,
+ HasChildrenHint: true));
+ }
+
+ return nodes;
+ }
+
+ ///
+ /// is not a SQL schema-browse node.
+ public async Task> ExpandAsync(string nodeId, CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ var reference = SqlBrowseNodeId.Parse(nodeId);
+ switch (reference.Kind)
+ {
+ case SqlBrowseNodeKind.Schema:
+ return await ExpandSchemaAsync(reference.Schema, cancellationToken).ConfigureAwait(false);
+ case SqlBrowseNodeKind.Table:
+ return await ExpandTableAsync(reference.Schema, reference.Table!, cancellationToken)
+ .ConfigureAwait(false);
+ default:
+ // A column is terminal. Expanding one is a UI no-op, never an error — the tree may ask before
+ // it has re-read the node's Kind.
+ LastUsedUtc = DateTime.UtcNow;
+ return Array.Empty();
+ }
+ }
+
+ ///
+ /// is not a SQL schema-browse node.
+ public async Task> AttributesAsync(
+ string nodeId, CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ var reference = SqlBrowseNodeId.Parse(nodeId);
+ if (reference.Kind != SqlBrowseNodeKind.Column)
+ {
+ // Schemas and tables have no side-panel — same shape as the OPC UA client browser, whose tree is
+ // uniform and returns empty for every node.
+ LastUsedUtc = DateTime.UtcNow;
+ return Array.Empty();
+ }
+
+ var columns = await ReadColumnsAsync(
+ reference.Schema, reference.Table!, cancellationToken).ConfigureAwait(false);
+
+ // Ordinal: a column NodeId is only ever minted from this same catalog output, so its case is the
+ // catalog's own. A case-insensitive match would pick the wrong column on a case-sensitive collation
+ // that legitimately carries both "Value" and "value".
+ foreach (var column in columns)
+ {
+ if (!string.Equals(column.Name, reference.Column, StringComparison.Ordinal)) continue;
+ return
+ [
+ new AttributeInfo(
+ Name: column.Name,
+ DriverDataType: _dialect.MapColumnType(column.DataType).ToString(),
+ IsArray: false,
+ SecurityClass: ReadOnlySecurityClass),
+ ];
+ }
+
+ // The column is gone (dropped since the expand, or the NodeId was hand-made). An empty side-panel is
+ // the honest answer; the picker simply has nothing to prefill.
+ return Array.Empty();
+ }
+
+ ///
+ /// Idempotently closes the owned connection and the gate. Errors are swallowed: the registry's reaper
+ /// may be racing a server-side disconnect, and a failed close must never surface in the AdminUI.
+ ///
+ /// A task that represents the asynchronous operation.
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ try { await _connection.DisposeAsync().ConfigureAwait(false); }
+ catch { /* best-effort: the connection may already be broken or closed. */ }
+
+ try { _gate.Dispose(); }
+ catch { /* best-effort: a concurrent second dispose already tore it down. */ }
+ }
+
+ private async Task> ExpandSchemaAsync(string schema, CancellationToken ct)
+ {
+ var rows = await QueryAsync(
+ _dialect.ListTablesSql,
+ command => Bind(command, "@schema", schema),
+ static reader => (
+ Name: ReadString(reader, TableNameColumn),
+ Type: ReadOptionalString(reader, TableTypeColumn)),
+ ct).ConfigureAwait(false);
+
+ var nodes = new List(rows.Count);
+ foreach (var (name, type) in rows)
+ {
+ if (string.IsNullOrWhiteSpace(name)) continue;
+ var isView = string.Equals(type, ViewTableType, StringComparison.OrdinalIgnoreCase);
+ nodes.Add(new BrowseNode(
+ NodeId: SqlBrowseNodeId.ForTable(schema, name),
+ // The label is decorated, never the NodeId — a view and a table of the same name in the same
+ // schema cannot exist, so the suffix is presentation only.
+ DisplayName: isView ? $"{name} (view)" : name,
+ Kind: BrowseNodeKind.Folder,
+ HasChildrenHint: true));
+ }
+
+ return nodes;
+ }
+
+ private async Task> ExpandTableAsync(
+ string schema, string table, CancellationToken ct)
+ {
+ var columns = await ReadColumnsAsync(schema, table, ct).ConfigureAwait(false);
+
+ var nodes = new List(columns.Count);
+ foreach (var column in columns)
+ {
+ if (string.IsNullOrWhiteSpace(column.Name)) continue;
+ nodes.Add(new BrowseNode(
+ NodeId: SqlBrowseNodeId.ForColumn(schema, table, column.Name),
+ DisplayName: column.Name,
+ Kind: BrowseNodeKind.Leaf,
+ HasChildrenHint: false));
+ }
+
+ return nodes;
+ }
+
+ ///
+ /// Reads one table's columns. A table that no longer exists (or never did) yields an empty list rather
+ /// than an error: the catalog answering "no rows" is indistinguishable from a genuinely column-less
+ /// relation, and neither is a reason to fail an operator's click.
+ ///
+ private Task> ReadColumnsAsync(
+ string schema, string table, CancellationToken ct) =>
+ QueryAsync(
+ _dialect.ListColumnsSql,
+ command =>
+ {
+ Bind(command, "@schema", schema);
+ Bind(command, "@table", table);
+ },
+ static reader => (
+ Name: ReadString(reader, ColumnNameColumn),
+ DataType: ReadOptionalString(reader, DataTypeColumn) ?? string.Empty),
+ ct);
+
+ ///
+ /// Runs one catalog query under the gate and projects every row. The disposed check happens
+ /// inside the gate wait so a dispose racing an in-flight call cannot be missed, and
+ /// only advances on a call that actually completed.
+ ///
+ private async Task> QueryAsync(
+ string sql,
+ Action bind,
+ Func project,
+ CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ var results = new List();
+ await using var command = _connection.CreateCommand();
+ command.CommandText = sql;
+ bind(command);
+
+ await using var reader = await command
+ .ExecuteReaderAsync(CommandBehavior.SingleResult, cancellationToken).ConfigureAwait(false);
+ while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
+ results.Add(project(reader));
+
+ LastUsedUtc = DateTime.UtcNow;
+ return results;
+ }
+ finally
+ {
+ // A dispose that raced this call has already disposed the gate; releasing it then is harmless to
+ // ignore and must not mask the real result.
+ try { _gate.Release(); }
+ catch (ObjectDisposedException) { }
+ }
+ }
+
+ /// Binds one catalog-query parameter. The only way a name reaches the database.
+ private static void Bind(DbCommand command, string name, string value)
+ {
+ var parameter = command.CreateParameter();
+ parameter.ParameterName = name;
+ parameter.DbType = DbType.String;
+ parameter.Value = value;
+ command.Parameters.Add(parameter);
+ }
+
+ private static string ReadString(DbDataReader reader, string columnName) =>
+ ReadOptionalString(reader, columnName) ?? string.Empty;
+
+ private static string? ReadOptionalString(DbDataReader reader, string columnName)
+ {
+ var ordinal = reader.GetOrdinal(columnName);
+ return reader.IsDBNull(ordinal) ? null : reader.GetValue(ordinal)?.ToString();
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs
new file mode 100644
index 00000000..bd43f089
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs
@@ -0,0 +1,401 @@
+using System.Data.Common;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
+
+///
+/// Opens a transient database connection from form-supplied JSON for the AdminUI schema picker,
+/// and hands it to a . The AdminUI's BrowseSessionRegistry (its idle
+/// TTL reaper included) owns the returned session unchanged — nothing about the SQL browse needs a
+/// registry of its own.
+/// Two ways to name the database, and only one of them is persisted.
+/// connectionStringRef is the deployed form: a name resolved in the AdminUI process from
+/// the environment as Sql__ConnectionStrings__<ref> (design §4.4), so a deployed artifact
+/// never carries credentials. connectionString is an ad-hoc, session-only literal an
+/// operator may paste to browse a database that has no ref yet; it is used for this one connection and
+/// then forgotten — there is deliberately no cached-config field on this type, and nothing writes it
+/// anywhere.
+/// Precedence: a pasted literal wins over a ref, and the ref is then not even read. The
+/// literal cannot arrive from a persisted blob — has no such property, so
+/// the driver factory would drop it — which makes its presence proof that an operator typed it just now.
+/// A config carrying both logs a warning (naming the shadowed ref, never any connection text) so
+/// the override is visible rather than silent.
+/// Credential hygiene is the point of this type. A connection string is a secret. It is
+/// passed to the provider and to nothing else: it never reaches a log line, an exception message, a field,
+/// or anything persisted. Because ADO.NET providers are free to echo connection-string content in their
+/// own errors, every failure on the open path goes through before it leaves this
+/// class.
+///
+public sealed class SqlDriverBrowser : IDriverBrowser
+{
+ ///
+ /// Prefix of the environment variable a connectionStringRef resolves through — the
+ /// double-underscore form .NET configuration uses for the Sql:ConnectionStrings:<ref>
+ /// key path.
+ ///
+ public const string ConnectionStringEnvironmentPrefix = "Sql__ConnectionStrings__";
+
+ ///
+ /// Hard cap on the connect phase, layered on the caller's token. The AdminUI already bounds each
+ /// browse call at 20 s; this only stops a pathological provider-side connect from outliving the
+ /// picker entirely.
+ ///
+ private static readonly TimeSpan ConnectBudget = TimeSpan.FromSeconds(30);
+
+ ///
+ /// Connection-string keys whose values are redacted out of any provider error text. The
+ /// whole connection string is redacted unconditionally; these are the parts that could survive as a
+ /// fragment. Server / database names are deliberately not redacted — "server X was not found"
+ /// is the diagnostic the operator needs, and a hostname is not a credential.
+ ///
+ private static readonly string[] CredentialKeys =
+ [
+ "password", "pwd", "user id", "uid", "user", "username", "accesstoken", "access token",
+ ];
+
+ ///
+ /// Kept deliberately identical in shape to the driver factory's options (design §5.1): enum-valued
+ /// knobs are authored as their names, so the browser parses a given DriverConfig blob
+ /// exactly as the runtime factory does. also accepts ordinals,
+ /// so a numeric config still binds.
+ ///
+ private static readonly JsonSerializerOptions JsonOpts = new()
+ {
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ PropertyNameCaseInsensitive = true,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ private readonly Func _dialectSelector;
+ private readonly Func _environmentReader;
+ private readonly ILogger _logger;
+
+ /// Creates a browser. Every dependency has a production default, so DI may construct it bare.
+ ///
+ /// Maps an authored onto the dialect that browses it, or
+ /// for a provider this build does not carry. Defaults to
+ /// (SqlServer only, in v1). A constructor parameter rather than a test hatch: it is also how a P2
+ /// provider gets added without touching this class.
+ ///
+ ///
+ /// Reads one environment variable. Defaults to .
+ /// Injectable because environment variables are process-global, and a test that sets one races every
+ /// other test in the assembly.
+ ///
+ /// Optional; defaults to .
+ public SqlDriverBrowser(
+ Func? dialectSelector = null,
+ Func? environmentReader = null,
+ ILogger? logger = null)
+ {
+ _dialectSelector = dialectSelector ?? DefaultDialectSelector;
+ _environmentReader = environmentReader ?? Environment.GetEnvironmentVariable;
+ _logger = logger ?? NullLogger.Instance;
+ }
+
+ ///
+ ///
+ /// Sourced from , the driver's interim local constant, rather
+ /// than from DriverTypeNames — the shared constant set is added by the driver-factory task,
+ /// because adding a member there reddens DriverTypeNamesGuardTests until a factory is
+ /// registered against it. Repointing that one constant repoints this too.
+ ///
+ public string DriverType => SqlDriver.DriverTypeName;
+
+ /// The environment variable a connectionStringRef resolves through.
+ /// The authored reference name.
+ /// The full environment-variable name, e.g. Sql__ConnectionStrings__MesStaging.
+ public static string EnvironmentVariableFor(string connectionStringRef) =>
+ ConnectionStringEnvironmentPrefix + connectionStringRef;
+
+ ///
+ /// The providers this build can browse. v1 constructs SQL Server only; every other
+ /// member is a reserved name, so it resolves to and
+ /// the caller reports it as unavailable rather than crashing on a null dialect.
+ ///
+ /// The authored provider.
+ /// The dialect, or when this build does not carry the provider.
+ public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) =>
+ provider == SqlProvider.SqlServer ? new SqlServerDialect() : null;
+
+ ///
+ ///
+ /// The configuration is absent / malformed, names a provider this build does not carry, names neither
+ /// a connectionStringRef nor a literal, resolves a ref that is not set in the environment, or
+ /// the connection could not be opened.
+ ///
+ public async Task OpenAsync(string configJson, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(configJson))
+ {
+ throw new InvalidOperationException(
+ "The Sql browser requires a driver configuration; none was supplied.");
+ }
+
+ var literal = ReadPastedConnectionString(configJson);
+ var config = Deserialize(configJson, literal);
+
+ // Provider first: "this build cannot browse Postgres" is true regardless of how the database is
+ // named, and answering it before touching the environment keeps the two failures from masking.
+ var dialect = _dialectSelector(config.Provider)
+ ?? throw new InvalidOperationException(
+ $"Sql provider '{config.Provider}' is not available in this build; v1 browses "
+ + $"'{SqlProvider.SqlServer}' only.");
+
+ var reference = config.ConnectionStringRef;
+ var connectionString = ResolveConnectionString(literal, reference);
+
+ _logger.LogInformation(
+ "AdminUI Sql browse session opening (provider {Provider}, connection from {Source})",
+ config.Provider,
+ DescribeSource(literal, reference));
+
+ return await OpenSessionAsync(dialect, connectionString, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Opens one transient connection and hands ownership to the session. The session closes the
+ /// connection on its own disposal, so the only disposal this method owns is the failure path —
+ /// disposing on success would double-close and leave the picker with a dead session.
+ ///
+ private static async Task OpenSessionAsync(
+ ISqlDialect dialect, string connectionString, CancellationToken cancellationToken)
+ {
+ var connection = dialect.Factory.CreateConnection()
+ ?? throw new InvalidOperationException(
+ $"The ADO.NET provider for '{dialect.Provider}' returned no connection object.");
+
+ try
+ {
+ connection.ConnectionString = connectionString;
+
+ using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ connectCts.CancelAfter(ConnectBudget);
+ await connection.OpenAsync(connectCts.Token).ConfigureAwait(false);
+
+ return new SqlBrowseSession(connection, dialect);
+ }
+ catch (Exception ex)
+ {
+ try { await connection.DisposeAsync().ConfigureAwait(false); }
+ catch { /* best-effort: the original failure is the useful one. */ }
+
+ if (ex is OperationCanceledException) throw;
+ throw Sanitize(ex, connectionString);
+ }
+ }
+
+ ///
+ /// Picks the connection string, literal first. A blank literal is treated as absent (an untouched
+ /// form field), never as an empty connection string.
+ ///
+ private string ResolveConnectionString(string? literal, string? reference)
+ {
+ if (!string.IsNullOrWhiteSpace(literal))
+ {
+ if (!string.IsNullOrWhiteSpace(reference))
+ {
+ _logger.LogWarning(
+ "Sql browse: a pasted connection string was supplied alongside connectionStringRef "
+ + "'{Ref}'. The pasted value wins for this session only and is not persisted.",
+ reference);
+ }
+
+ return literal;
+ }
+
+ if (string.IsNullOrWhiteSpace(reference))
+ {
+ throw new InvalidOperationException(
+ "The Sql browser needs a database to browse: set 'connectionStringRef' to a name resolved "
+ + $"from the environment as '{ConnectionStringEnvironmentPrefix}', or paste a "
+ + "connection string into 'connectionString' for an ad-hoc browse.");
+ }
+
+ var variable = EnvironmentVariableFor(reference);
+ var resolved = _environmentReader(variable);
+ if (string.IsNullOrWhiteSpace(resolved))
+ {
+ // Name the exact variable: "the ref did not resolve" is unactionable, and the operator's next
+ // move is to set this one name on the AdminUI host.
+ throw new InvalidOperationException(
+ $"connectionStringRef '{reference}' does not resolve: environment variable "
+ + $"'{variable}' is not set on the AdminUI host. Set it there (the AdminUI resolves browse "
+ + "connection strings in its own process), or paste a connection string for an ad-hoc "
+ + "browse.");
+ }
+
+ return resolved;
+ }
+
+ /// How the connection string was obtained, for the log. Carries no connection text.
+ private static string DescribeSource(string? literal, string? reference) =>
+ !string.IsNullOrWhiteSpace(literal)
+ ? "a pasted literal (session-only, not persisted)"
+ : $"connectionStringRef '{reference}'";
+
+ ///
+ /// Pulls the ad-hoc connectionString literal out of the raw JSON. It is read here rather than
+ /// off the DTO because deliberately has no such property — the
+ /// persisted contract must not be able to carry a credential — and the DTO's
+ /// would silently drop it.
+ ///
+ /// The literal, or when absent, blank, or not a JSON string.
+ private static string? ReadPastedConnectionString(string configJson)
+ {
+ using var document = ParseDocument(configJson);
+ if (document.RootElement.ValueKind != JsonValueKind.Object)
+ {
+ throw new InvalidOperationException(
+ "The Sql browser's driver configuration must be a JSON object.");
+ }
+
+ foreach (var property in document.RootElement.EnumerateObject())
+ {
+ if (!string.Equals(property.Name, "connectionString", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ if (property.Value.ValueKind != JsonValueKind.String) return null;
+ var value = property.Value.GetString();
+ return string.IsNullOrWhiteSpace(value) ? null : value;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Parses the raw configuration. The is not attached or echoed:
+ /// at this point nothing has been extracted, so there is no known secret to redact against, and the
+ /// malformed text may itself be a pasted connection string.
+ ///
+ private static JsonDocument ParseDocument(string configJson)
+ {
+ try
+ {
+ return JsonDocument.Parse(configJson);
+ }
+ catch (JsonException)
+ {
+ throw new InvalidOperationException(
+ "The Sql browser's driver configuration is not valid JSON. (The parser's message is "
+ + "withheld because the malformed text may carry a connection string.)");
+ }
+ }
+
+ ///
+ /// Binds the typed configuration. A bind failure (e.g. an unknown provider name) is reported
+ /// with the parser's own message redacted against the literal, which is known by now.
+ ///
+ private static SqlDriverConfigDto Deserialize(string configJson, string? literal)
+ {
+ try
+ {
+ return JsonSerializer.Deserialize(configJson, JsonOpts)
+ ?? throw new InvalidOperationException(
+ "The Sql browser's driver configuration deserialized to null.");
+ }
+ catch (JsonException ex)
+ {
+ throw new InvalidOperationException(
+ "The Sql browser's driver configuration could not be bound: "
+ + Redact(ex.Message, CollectSecrets(literal)));
+ }
+ }
+
+ ///
+ /// Turns a provider-side open failure into an exception that cannot carry the connection string.
+ /// Live-probed, not assumed.Microsoft.Data.SqlClient and
+ /// Microsoft.Data.Sqlite keep connection-string values out of their
+ /// SqlException/SqliteException text (a failed connect says "the server was not found",
+ /// never what it was handed). Their connection-string parser is the exception: it reports an
+ /// unrecognised keyword by quoting it, and a value carrying an unquoted ; — legal inside a
+ /// password, which ADO.NET expects you to quote — splits, so the tail of the password is parsed as a
+ /// keyword and echoed verbatim. Password=Sup3r;SecretTail yields
+ /// Keyword not supported: 'secrettail;connect timeout'. — the credential, in the message,
+ /// lower-cased and therefore invisible to a naive ordinal search for the value.
+ /// So the parser's message (an ) is never surfaced. Every
+ /// other failure is additionally passed through the substring redactor as a backstop, and when that
+ /// fires anywhere in the exception chain the inner exception is dropped too — keeping it
+ /// would re-expose through ToString() exactly what the message just removed.
+ ///
+ private static InvalidOperationException Sanitize(Exception ex, string connectionString)
+ {
+ if (ex is ArgumentException)
+ {
+ return new InvalidOperationException(
+ "The Sql browser could not open a connection: the connection string is malformed, or "
+ + "carries a keyword this provider does not support. (The provider's own message is "
+ + "withheld because it quotes connection-string text verbatim; check for an unquoted ';' "
+ + "or '=' inside a value such as the password.)");
+ }
+
+ var secrets = CollectSecrets(connectionString);
+ var leaked = ContainsAny(ex.ToString(), secrets);
+ var reason = Redact(ex.Message, secrets);
+
+ var message = leaked
+ ? "The Sql browser could not open a connection: " + reason
+ + " (the provider's error echoed the connection string, so it was redacted and the inner "
+ + "exception withheld)"
+ : "The Sql browser could not open a connection: " + reason;
+
+ return new InvalidOperationException(message, leaked ? null : ex);
+ }
+
+ ///
+ /// The substrings that must never leave this class: the whole connection string, and the values of
+ /// its credential-bearing keys (which could surface on their own).
+ ///
+ private static List CollectSecrets(string? connectionString)
+ {
+ var secrets = new List();
+ if (string.IsNullOrWhiteSpace(connectionString)) return secrets;
+
+ secrets.Add(connectionString);
+
+ try
+ {
+ var builder = new DbConnectionStringBuilder { ConnectionString = connectionString };
+ foreach (var key in CredentialKeys)
+ {
+ if (!builder.TryGetValue(key, out var value)) continue;
+ var text = value?.ToString();
+ if (!string.IsNullOrWhiteSpace(text)) secrets.Add(text);
+ }
+ }
+ catch (ArgumentException)
+ {
+ // A malformed connection string has no parseable parts; the whole-string entry still stands.
+ }
+
+ // Longest first, so redacting a short credential value cannot chop a longer secret into
+ // unredactable halves.
+ secrets.Sort(static (left, right) => right.Length.CompareTo(left.Length));
+ return secrets;
+ }
+
+ private static bool ContainsAny(string text, List secrets)
+ {
+ foreach (var secret in secrets)
+ {
+ if (text.Contains(secret, StringComparison.OrdinalIgnoreCase)) return true;
+ }
+
+ return false;
+ }
+
+ private static string Redact(string text, List secrets)
+ {
+ foreach (var secret in secrets)
+ {
+ text = text.Replace(secret, "[redacted]", StringComparison.OrdinalIgnoreCase);
+ }
+
+ return text;
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj
new file mode 100644
index 00000000..daa01255
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj
@@ -0,0 +1,27 @@
+
+
+ net10.0
+ enable
+ enable
+ true
+ true
+ $(NoWarn);CS1591
+ ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs
new file mode 100644
index 00000000..2bca5d24
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs
@@ -0,0 +1,64 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+///
+/// The DriverConfig blob shape for a Sql driver instance (design §5.1). Deserialised by the
+/// driver factory, which applies the documented defaults for every absent (null) field and maps the result
+/// onto the driver's typed options.
+/// No connection string lives here. Only — a name resolved at
+/// Initialize from the environment / secret store (e.g. Sql__ConnectionStrings__MesStaging) — so a
+/// deployed artifact never carries database credentials.
+/// Enum-valued fields are enum-typed (not strings) so a JsonStringEnumConverter round-trips
+/// their names rather than their ordinals. The converter is applied by the factory's
+/// JsonSerializerOptions (Task 9), not by an attribute on this DTO — mirroring the driver
+/// enum-serialization trap that bit the AdminUI driver pages.
+///
+public sealed class SqlDriverConfigDto
+{
+ /// Which ADO.NET provider/dialect backs this instance. Defaults to — the only provider v1 constructs.
+ public SqlProvider Provider { get; init; } = SqlProvider.SqlServer;
+
+ ///
+ /// Name of the connection string to resolve from the environment / secret store at Initialize —
+ /// never the connection string itself.
+ ///
+ public string? ConnectionStringRef { get; init; }
+
+ /// Poll interval applied to groups that do not carry their own. Absent ⇒ the factory default.
+ public TimeSpan? DefaultPollInterval { get; init; }
+
+ ///
+ /// Per-query wall-clock deadline enforced client-side (a breach surfaces BadTimeout). Must be
+ /// greater than , which is only the server-side backstop (design §8.3).
+ ///
+ public TimeSpan? OperationTimeout { get; init; }
+
+ /// ADO.NET CommandTimeout backstop (seconds granularity server-side).
+ public TimeSpan? CommandTimeout { get; init; }
+
+ /// Cap on concurrently executing group queries — the connection-pool guard.
+ public int? MaxConcurrentGroups { get; init; }
+
+ /// When , a NULL cell publishes Bad rather than the default Uncertain.
+ public bool? NullIsBad { get; init; }
+
+ ///
+ /// Master write kill-switch. v1 is read-only and this field is inert — the driver does not
+ /// implement IWritable, so no value here can produce a write. The factory warns when it
+ /// is authored rather than failing the driver, since the flag cannot do harm but
+ /// an operator who believes writes are enabled can.
+ ///
+ public bool? AllowWrites { get; init; }
+
+ ///
+ /// The authored raw tags the deploy artifact delivers for this driver instance — RawPath identity plus
+ /// the driver TagConfig blob, the same shape every other driver receives. The factory copies
+ /// these onto the driver's options and the driver maps each through
+ /// at Initialize.
+ /// A SQL source has no tag-discovery protocol, so this list is the complete set of tags the
+ /// instance serves — an absent or empty list is a driver with nothing to poll, not a driver that will
+ /// find its tags later.
+ ///
+ public List? RawTags { get; init; }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs
new file mode 100644
index 00000000..006089f8
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs
@@ -0,0 +1,131 @@
+using System.Text.Json;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+///
+/// Pure mapper from an authored raw tag's TagConfig JSON (design §5.2 / §5.3) to a
+/// , mirroring ModbusTagDefinitionFactory.FromTagConfig. The blob is
+/// recognised by a leading { plus a "driver":"Sql" marker or a "model" discriminator;
+/// the model and type enums are read with
+/// , so a present-but-invalid (typo'd) value rejects
+/// the whole tag — the driver then surfaces BadNodeIdUnknown instead of silently defaulting to a
+/// different model and publishing a misleading Good (R2-11).
+/// No SQL is built here. The parser only captures strings; the reader binds value fields as
+/// DbParameters and validates + quotes identifier fields. Tag input is never concatenated into a
+/// command text, so a hostile keyValue is stored — and must be stored — verbatim.
+///
+public static class SqlEquipmentTagParser
+{
+ ///
+ /// Maps an authored TagConfig blob to a typed definition keyed by .
+ /// Returns — never throws — for anything the driver must not serve: a
+ /// non-object / unparseable blob, a blob belonging to another driver, a typo'd model or
+ /// type enum, a missing model-required field, or the P3-deferred
+ /// model.
+ ///
+ /// The authored equipment-tag TagConfig JSON.
+ /// The tag's RawPath — becomes the definition's identity (Name).
+ /// The mapped definition when this returns .
+ /// when is a valid Sql tag blob.
+ public static bool TryParse(string reference, string rawPath, out SqlTagDefinition def)
+ {
+ def = null!;
+ if (string.IsNullOrWhiteSpace(reference)) return false;
+ if (reference.TrimStart().FirstOrDefault() != '{') return false;
+ try
+ {
+ using var doc = JsonDocument.Parse(reference);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object) return false;
+
+ // Recognition: either the explicit driver marker or the model discriminator must be present,
+ // so another driver's TagConfig blob is not mistaken for a Sql one.
+ var driver = ReadString(root, "driver");
+ var hasModel = root.TryGetProperty("model", out _);
+ if (!hasModel && !string.Equals(driver, "Sql", StringComparison.OrdinalIgnoreCase)) return false;
+
+ // Strict enum reads: absent ⇒ the fallback, present-but-invalid ⇒ reject the tag.
+ if (!TagConfigJson.TryReadEnumStrict(root, "model", SqlTagModel.KeyValue, out var model)) return false;
+ if (!TagConfigJson.TryReadEnumStrict(root, "type", default(DriverDataType), out var type)) return false;
+ DriverDataType? declaredType =
+ root.TryGetProperty("type", out var typeEl) && typeEl.ValueKind == JsonValueKind.String
+ ? type
+ : null;
+
+ var table = ReadString(root, "table");
+ if (string.IsNullOrWhiteSpace(table)) return false;
+ var timestampColumn = ReadString(root, "timestampColumn");
+
+ switch (model)
+ {
+ case SqlTagModel.KeyValue:
+ {
+ var keyColumn = ReadString(root, "keyColumn");
+ var keyValue = ReadString(root, "keyValue");
+ var valueColumn = ReadString(root, "valueColumn");
+ if (string.IsNullOrWhiteSpace(keyColumn)
+ || string.IsNullOrWhiteSpace(valueColumn)
+ || keyValue is null)
+ return false;
+ def = new SqlTagDefinition(
+ Name: rawPath, Model: model, Table: table!,
+ KeyColumn: keyColumn, KeyValue: keyValue, ValueColumn: valueColumn,
+ TimestampColumn: timestampColumn, DeclaredType: declaredType);
+ return true;
+ }
+
+ case SqlTagModel.WideRow:
+ {
+ var columnName = ReadString(root, "columnName");
+ if (string.IsNullOrWhiteSpace(columnName)) return false;
+ string? selectorColumn = null, selectorValue = null, topByTimestamp = null;
+ if (root.TryGetProperty("rowSelector", out var sel) && sel.ValueKind == JsonValueKind.Object)
+ {
+ selectorColumn = ReadString(sel, "whereColumn");
+ selectorValue = ReadScalar(sel, "whereValue");
+ topByTimestamp = ReadString(sel, "topByTimestamp");
+ }
+ // A wide-row tag must say WHICH row it reads — either a where-pair or newest-by-timestamp.
+ var hasWherePair = !string.IsNullOrWhiteSpace(selectorColumn) && selectorValue is not null;
+ if (!hasWherePair && string.IsNullOrWhiteSpace(topByTimestamp)) return false;
+ def = new SqlTagDefinition(
+ Name: rawPath, Model: model, Table: table!,
+ TimestampColumn: timestampColumn, ColumnName: columnName,
+ RowSelectorColumn: hasWherePair ? selectorColumn : null,
+ RowSelectorValue: hasWherePair ? selectorValue : null,
+ RowSelectorTopByTimestamp: hasWherePair ? null : topByTimestamp,
+ DeclaredType: declaredType);
+ return true;
+ }
+
+ default:
+ // SqlTagModel.Query is design §5.4 / P3 — deferred. Reject rather than serve a model
+ // the runtime cannot read.
+ return false;
+ }
+ }
+ catch (JsonException) { return false; }
+ catch (FormatException) { return false; }
+ }
+
+ /// Reads a string-valued property; null when absent or not a JSON string.
+ private static string? ReadString(JsonElement o, string name)
+ => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null;
+
+ ///
+ /// Reads a scalar property as its textual form — a JSON string yields its contents, a number or
+ /// boolean yields its raw token — so an authored whereValue of either shape is captured
+ /// verbatim for later parameter binding. Null when absent or not a scalar.
+ ///
+ private static string? ReadScalar(JsonElement o, string name)
+ {
+ if (!o.TryGetProperty(name, out var e)) return null;
+ return e.ValueKind switch
+ {
+ JsonValueKind.String => e.GetString(),
+ JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => e.GetRawText(),
+ _ => null,
+ };
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs
new file mode 100644
index 00000000..67879307
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs
@@ -0,0 +1,39 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+/// Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer.
+public enum SqlProvider
+{
+ /// Microsoft SQL Server via Microsoft.Data.SqlClient — the only provider v1 constructs.
+ SqlServer,
+
+ /// PostgreSQL (Npgsql) — reserved for P2; not constructed in v1.
+ Postgres,
+
+ /// MySQL / MariaDB — reserved; not constructed in v1.
+ MySql,
+
+ /// Generic ODBC — reserved for P2; not constructed in v1.
+ Odbc,
+
+ /// Oracle — reserved; not constructed in v1.
+ Oracle,
+}
+
+/// Tag→value mapping model. v1 ships KeyValue + WideRow; Query is deferred (design §5.4 / P3).
+public enum SqlTagModel
+{
+ ///
+ /// Key-value (EAV) table: one row per tag, selected by keyColumn = keyValue, value read from
+ /// valueColumn.
+ ///
+ KeyValue,
+
+ ///
+ /// Wide row: one row holds many signals as columns; the tag names its columnName and the row is
+ /// picked by a row selector (a whereColumn/whereValue pair, or newest-by-timestamp).
+ ///
+ WideRow,
+
+ /// Named arbitrary-SELECT query (design §5.4). Deferred to P3 — not accepted by v1's parser.
+ Query,
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs
new file mode 100644
index 00000000..755e2b9d
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs
@@ -0,0 +1,47 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+///
+/// One SQL-backed OPC UA variable — the typed form of an authored raw tag's TagConfig blob
+/// (design §5.2 / §5.3), produced by .
+/// Every string here is captured verbatim from the authored blob and is NEVER concatenated
+/// into SQL. Value-bearing fields (, ) are bound
+/// as DbParameters by the reader; identifier-bearing fields (,
+/// , , ,
+/// , ,
+/// ) must be validated against INFORMATION_SCHEMA and
+/// dialect-quoted by the reader before they reach a command text. Sanitising here would corrupt
+/// legitimate values, so the parser deliberately does not.
+///
+///
+/// The tag's identity — its RawPath, exactly as handed to the parser. The driver's forward
+/// router keys published values on this, so it must never be derived from the blob's contents.
+///
+/// Which tag→value mapping this tag uses. v1 accepts KeyValue and WideRow only.
+/// The table or view to read (e.g. dbo.TagValues).
+/// : the column matched against .
+/// : this tag's key — bound, never interpolated.
+/// : the column the value is read from.
+/// Optional source-timestamp column; absent ⇒ the driver stamps the poll time.
+/// : the column this tag reads from the selected row.
+/// : the whereColumn that picks the row.
+/// : the whereValue — bound, never interpolated.
+/// : newest-row selector — the timestamp column to order by, when no where-pair is authored.
+///
+/// Optional explicit type override from the blob's type field. ⇒ the
+/// driver infers the type from the result set's column metadata.
+///
+public sealed record SqlTagDefinition(
+ string Name,
+ SqlTagModel Model,
+ string Table,
+ string? KeyColumn = null,
+ string? KeyValue = null,
+ string? ValueColumn = null,
+ string? TimestampColumn = null,
+ string? ColumnName = null,
+ string? RowSelectorColumn = null,
+ string? RowSelectorValue = null,
+ string? RowSelectorTopByTimestamp = null,
+ DriverDataType? DeclaredType = null);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj
new file mode 100644
index 00000000..1c81c26e
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj
@@ -0,0 +1,14 @@
+
+
+ net10.0
+ enable
+ enable
+ true
+ true
+ $(NoWarn);CS1591
+ ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts
+
+
+
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs
new file mode 100644
index 00000000..9b15d22e
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs
@@ -0,0 +1,107 @@
+using System.Data.Common;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
+
+///
+/// The provider seam (design §2.2). ADO.NET's base types abstract
+/// connections, commands, parameters and readers; they do not abstract the three things that differ
+/// per backend — identifier quoting, the metadata-catalog SQL, and row-limit syntax.
+/// Those live here, so no dialect assumption leaks into the reader, the poll planner, or the schema browser.
+/// This interface is the driver's SQL-injection boundary. Every runtime value is
+/// bound as a and never reaches a command text. Identifiers cannot be
+/// parameterized in SQL, so the few that must appear as text are emitted through
+/// . Any code path that builds SQL by concatenating an authored tag field
+/// without going through is a defect (design §8.1).
+/// Not yet implemented: design §8.1 also specifies that an authored table/column is
+/// validated against the live catalog before it is ever quoted into text, so that an unknown identifier
+/// rejects the tag. No such gate exists yet — an identifier reaches
+/// straight from the authored TagConfig blob, so quoting is currently the only defence,
+/// not a backstop behind an upstream filter. Scrutinise it accordingly.
+/// Public because Driver.Sql.Browser consumes it — the catalog SQL is the browse
+/// engine, so it is shared rather than duplicated. Implementations own their provider package;
+/// no provider-specific type appears in this signature ( is the abstract
+/// ).
+///
+public interface ISqlDialect
+{
+ /// Which backend this dialect speaks. v1 constructs only.
+ SqlProvider Provider { get; }
+
+ ///
+ /// The provider's factory singleton, used to create connections/commands/parameters. Deliberately the
+ /// abstract base type so consumers never bind to a concrete provider package.
+ ///
+ DbProviderFactory Factory { get; }
+
+ ///
+ /// Quotes one identifier part so it can be safely embedded in a command text, escaping the
+ /// dialect's own quote character. Rejects anything that cannot be a real catalog identifier rather
+ /// than emitting it.
+ /// One part only. A multi-part name such as dbo.TagValues must be split by the
+ /// caller and each part quoted separately; passing the dotted form yields a single (nonexistent)
+ /// identifier — safe, but not what the caller meant.
+ ///
+ /// The bare, unquoted identifier — as returned by the catalog, not pre-quoted.
+ /// The quoted identifier, ready to concatenate into a command text.
+ /// The value cannot be a valid catalog identifier.
+ string QuoteIdentifier(string ident);
+
+ /// Cheapest statement that proves the connection is alive (SELECT 1; Oracle needs FROM DUAL).
+ string LivenessSql { get; }
+
+ ///
+ /// The fragment that goes immediately after SELECT to limit a statement to one row —
+ /// T-SQL's "TOP 1 ". Empty for a dialect that spells the limit at the end of the statement (see
+ /// ).
+ /// Include the trailing space when non-empty: the planner concatenates
+ /// "SELECT " + SingleRowLimitPrefix + columns with no separator of its own, so an empty fragment
+ /// must leave the statement byte-identical to an unlimited one.
+ ///
+ ///
+ /// Why a prefix/suffix pair rather than one ApplyRowLimit(sql, n) method. The two
+ /// spellings sit at opposite ends of the statement — SQL Server writes
+ /// SELECT TOP 1 … ORDER BY … DESC, while SQLite/Postgres/MySQL write
+ /// SELECT … ORDER BY … DESC LIMIT 1. A single method taking the SELECT list could only serve the
+ /// prefix position; a single method taking the whole statement would move statement assembly out of the
+ /// planner and into every dialect. Two fragments keep the planner the sole author of statement shape
+ /// and let each dialect fill in the end it uses.
+ /// Why "single row" rather than a row count. The only limit v1 emits is the wide-row
+ /// topByTimestamp selector's newest-row pick. Modelling an arbitrary n would be untested
+ /// surface, and FETCH FIRST/ROWNUM dialects need more than a substituted number anyway.
+ /// Widen this to a method when a feature actually needs n > 1.
+ ///
+ string SingleRowLimitPrefix { get; }
+
+ ///
+ /// The fragment appended to the very end of a statement to limit it to one row —
+ /// " LIMIT 1" for SQLite/Postgres/MySQL. Empty for a dialect that limits after SELECT
+ /// (see ).
+ /// Include the leading space when non-empty, for the same reason the prefix carries a
+ /// trailing one: the planner appends it directly to the finished statement.
+ /// Exactly one of the two fragments is non-empty in every dialect modelled so far, but the planner
+ /// emits both unconditionally — a dialect needing both ends (or neither) is expressible without
+ /// touching the call site.
+ ///
+ string SingleRowLimitSuffix { get; }
+
+ /// Catalog query listing schemas — the browser's RootAsync level. Takes no parameters.
+ string ListSchemasSql { get; }
+
+ /// Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind @schema.
+ string ListTablesSql { get; }
+
+ /// Catalog query listing columns of one table — the browser's table-expand / attributes level. Bind @schema and @table.
+ string ListColumnsSql { get; }
+
+ ///
+ /// Folds a catalog data-type family name (as INFORMATION_SCHEMA.COLUMNS.DATA_TYPE reports it —
+ /// e.g. nvarchar, never nvarchar(50)) onto a .
+ /// Must never throw: a browse over a table holding one exotic column must still render.
+ /// An unrecognised family falls back to .
+ ///
+ /// The bare SQL type family name; matched case-insensitively.
+ /// The mapped driver data type, or when unrecognised.
+ DriverDataType MapColumnType(string sqlDataType);
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlConnectionStringResolver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlConnectionStringResolver.cs
new file mode 100644
index 00000000..0e76e68d
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlConnectionStringResolver.cs
@@ -0,0 +1,62 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
+
+///
+/// Resolves a Sql driver config's connectionStringRef — a NAME — into the connection string
+/// it stands for (design §8.2). The deployed artifact carries only the name, so database credentials never
+/// ride in a config blob, never land in the central config DB, and never appear in a deployment diff.
+/// The environment is read directly, on purpose. A driver factory is invoked through
+/// DriverFactoryRegistry's (driverInstanceId, driverConfigJson) closure — there is no
+/// IConfiguration, no IServiceProvider and no ambient scope at that seam, and widening the
+/// registry's signature for one driver would change every driver in the tree. The
+/// Sql__ConnectionStrings__<ref> spelling is exactly the key .NET's environment-variable
+/// configuration provider would bind to Sql:ConnectionStrings:<ref>, so an operator can set it
+/// the same way they set every other secret and a later move onto IConfiguration needs no
+/// re-provisioning.
+/// Nothing here ever emits the resolved value. Messages name the environment
+/// variable, which is not a secret and is the operator's next action; the value itself is returned
+/// to exactly one caller and is otherwise unmentioned.
+///
+public static class SqlConnectionStringResolver
+{
+ ///
+ /// The environment-variable prefix a connectionStringRef is appended to. The double underscore
+ /// is .NET's configuration hierarchy separator, so this is the environment spelling of
+ /// Sql:ConnectionStrings:<ref>.
+ ///
+ public const string EnvironmentVariablePrefix = "Sql__ConnectionStrings__";
+
+ /// The environment variable a given connectionStringRef is read from.
+ /// The authored reference name.
+ /// The full environment-variable name.
+ /// is null, empty or whitespace.
+ public static string EnvironmentVariableFor(string connectionStringRef)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(connectionStringRef);
+ return string.Concat(EnvironmentVariablePrefix, connectionStringRef);
+ }
+
+ ///
+ /// Resolves to its connection string.
+ /// An absent or blank variable is a half-provisioned deployment and throws: handing an
+ /// empty string to the provider would surface as an unintelligible ADO.NET error at the first poll,
+ /// far from the missing secret that actually caused it.
+ ///
+ /// The authored reference name.
+ /// The resolved connection string. Treat as a secret — never log it.
+ /// is null, empty or whitespace.
+ /// The environment variable is absent or blank.
+ public static string Resolve(string connectionStringRef)
+ {
+ var variable = EnvironmentVariableFor(connectionStringRef);
+ var value = Environment.GetEnvironmentVariable(variable);
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new InvalidOperationException(
+ $"Sql connection string '{connectionStringRef}' is not provisioned: set the environment " +
+ $"variable '{variable}' on every node that runs this driver. The connection string is " +
+ $"deliberately not carried in the deployed configuration.");
+ }
+
+ return value;
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs
new file mode 100644
index 00000000..9d2396bf
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs
@@ -0,0 +1,757 @@
+using System.Data.Common;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
+
+///
+/// The Sql driver instance: a read-only Equipment-kind driver that polls SQL tables/views
+/// and publishes selected cells as OPC UA variables. Structurally the SQL analogue of
+/// ModbusDriver — this type owns lifecycle, the authored RawPath table, health, host
+/// connectivity, and the subscription overlay, while
+/// owns the read path.
+/// Read-only is structural, not configured. is deliberately not
+/// implemented, so no amount of config (and no WriteOperate role) can produce a write; every
+/// discovered variable is . A future write feature is a
+/// new capability, not a flag flip.
+/// Health is classified here, because nothing below does it. The reader honours
+/// literally: it throws only when the database cannot be reached, and turns
+/// every other failure — including a frozen database — into Bad-coded snapshots. Those return
+/// successfully, so sees a clean tick, does not back off, and does
+/// not call . Without below, a driver whose
+/// every value is would keep reporting
+/// . See that method for the exact rule and for why it does not
+/// manufacture an exception to force backoff.
+///
+public sealed class SqlDriver
+ : IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IAsyncDisposable
+{
+ ///
+ /// The driver-type string this driver registers under — , the single
+ /// source of truth the deploy pipeline stores in DriverInstance.DriverType and every dispatch
+ /// map keys by. Chained off the shared constant (Task 11 wired the factory + probe, so
+ /// DriverTypeNamesGuardTests' bidirectional-parity check is satisfied).
+ ///
+ public const string DriverTypeName = DriverTypeNames.Sql;
+
+ /// Shown for a connection string that names no recognisable server.
+ private const string UnknownEndpoint = "(unknown sql endpoint)";
+
+ /// Upper bound on the poll-loop failure backoff — the S7-proven 30 s cap adopted fleet-wide (05/STAB-8).
+ private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
+
+ /// Connection-string keys that name the server, in the order they are consulted.
+ private static readonly string[] ServerKeys =
+ ["server", "data source", "datasource", "host", "address", "addr", "network address"];
+
+ /// Connection-string keys that name the database.
+ private static readonly string[] DatabaseKeys = ["database", "initial catalog"];
+
+ // ---- instance fields (grouped at top for auditability) ----
+
+ private readonly SqlDriverOptions _options;
+ private readonly string _driverInstanceId;
+ private readonly ISqlDialect _dialect;
+ private readonly DbProviderFactory _factory;
+
+ ///
+ /// The resolved connection string — a secret. It is passed to the provider and to nothing
+ /// else: every log line, health message and host status carries instead.
+ ///
+ private readonly string _connectionString;
+
+ private readonly ILogger _logger;
+
+ ///
+ /// The authored RawPath → definition table, held as an immutable snapshot swapped atomically
+ /// rather than as a mutated dictionary (the shape ModbusDriver uses).
+ /// rebuilds this table while poll loops are reading it; clearing and
+ /// refilling a shared under that concurrency is a torn read at
+ /// best and an inside a poll at worst.
+ ///
+ private IReadOnlyDictionary _tagsByRawPath =
+ new Dictionary(StringComparer.Ordinal);
+
+ /// Resolves a read/subscribe RawPath to its authored definition — a single table hit, or a miss.
+ private readonly EquipmentTagRefResolver _resolver;
+
+ ///
+ /// The read path. Built once, in the constructor, and not injected: its resolve
+ /// delegate must read this driver's live authored table, which does not exist before the driver does.
+ ///
+ private readonly SqlPollReader _reader;
+
+ /// Polled subscriptions. The driver supplies the reader + change bridge; the engine owns the loop.
+ private readonly PollGroupEngine _poll;
+
+ // Single logical host: one driver instance dials one connection string. HostName is the endpoint
+ // description (server[/database]) so the Admin UI shows operators where to look.
+ private readonly object _probeLock = new();
+ private HostState _hostState = HostState.Unknown;
+ private DateTime _hostStateChangedUtc = DateTime.UtcNow;
+
+ private DriverHealth _health = new(DriverState.Unknown, null, null);
+
+ /// Occurs when a subscribed tag's value or quality changes.
+ public event EventHandler? OnDataChange;
+
+ /// Occurs when the database endpoint transitions between reachable and unreachable.
+ public event EventHandler? OnHostStatusChanged;
+
+ // ---- ctor + identity ----
+
+ /// Initializes a new Sql driver instance.
+ /// The driver's typed configuration (the factory applies the documented defaults).
+ /// The central config DB's identity for this driver instance.
+ ///
+ /// The provider seam — identifier quoting, liveness SQL, row-limit syntax and column-type mapping.
+ ///
+ ///
+ /// The already-resolved connection string. The driver never resolves a
+ /// connectionStringRef itself and never logs this value.
+ ///
+ ///
+ /// Creates the provider's connections. Defaults to 's factory; passed
+ /// explicitly by tests that need to observe or delay connection creation.
+ ///
+ /// Optional; defaults to a no-op logger.
+ /// A required reference argument is null.
+ /// or is blank.
+ public SqlDriver(
+ SqlDriverOptions options,
+ string driverInstanceId,
+ ISqlDialect dialect,
+ string connectionString,
+ DbProviderFactory? factory = null,
+ ILogger? logger = null)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentNullException.ThrowIfNull(dialect);
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
+ if (string.IsNullOrWhiteSpace(connectionString))
+ throw new ArgumentException("A Sql driver needs a resolved connection string.", nameof(connectionString));
+
+ _options = options;
+ _driverInstanceId = driverInstanceId;
+ _dialect = dialect;
+ _factory = factory ?? dialect.Factory;
+ _connectionString = connectionString;
+ _logger = logger ?? NullLogger.Instance;
+ Endpoint = DescribeEndpoint(connectionString);
+
+ _resolver = new EquipmentTagRefResolver(Lookup);
+ _reader = new SqlPollReader(
+ _factory,
+ connectionString,
+ dialect,
+ commandTimeout: options.CommandTimeout,
+ operationTimeout: options.OperationTimeout,
+ maxConcurrentGroups: options.MaxConcurrentGroups,
+ nullIsBad: options.NullIsBad,
+ resolve: rawPath => _resolver.TryResolve(rawPath, out var definition) ? definition : null,
+ logger: _logger);
+ _poll = new PollGroupEngine(
+ reader: ReadAsync,
+ onChange: (handle, tagRef, snapshot) =>
+ OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
+ onError: HandlePollError,
+ backoffCap: PollBackoffCap);
+ }
+
+ ///
+ public string DriverInstanceId => _driverInstanceId;
+
+ ///
+ public string DriverType => DriverTypeName;
+
+ ///
+ /// The credential-free description of the database this instance polls — server/database where
+ /// the connection string names both. This is the only rendering of the connection string that may
+ /// appear in a log, a health message, or the Admin UI.
+ ///
+ public string Endpoint { get; }
+
+ /// Host identifier surfaced through — the endpoint description.
+ public string HostName => Endpoint;
+
+ /// Active polled-subscription count. Diagnostics + disposal assertions.
+ internal int ActiveSubscriptionCount => _poll.ActiveSubscriptionCount;
+
+ /// The current authored-tag snapshot. Read through a barrier — swaps it.
+ private IReadOnlyDictionary Tags => Volatile.Read(ref _tagsByRawPath);
+
+ /// The resolver's lookup: RawPath → authored definition, or null on a miss.
+ private SqlTagDefinition? Lookup(string rawPath) => Tags.GetValueOrDefault(rawPath);
+
+ // ---- IDriver lifecycle ----
+
+ ///
+ /// Builds the authored RawPath table and proves the database is reachable.
+ /// is not re-parsed here: the driver serves the
+ /// typed it was constructed with, exactly as ModbusDriver does
+ /// (config parsing belongs to the factory, which builds a fresh instance).
+ /// The table is built first because it is pure and cannot fail; the liveness check is the only
+ /// I/O, and on failure this method records and rethrows —
+ /// DriverInstanceActor reads a throw as InitializeFailed and lands in Reconnecting with its
+ /// retry timer running, which is exactly the recovery a database that is merely down needs.
+ ///
+ /// The driver configuration JSON (unused; see remarks).
+ /// Cancellation token for the operation.
+ /// A task that represents the asynchronous operation.
+ /// The database could not be reached.
+ public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
+ {
+ WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
+ BuildTagTable();
+
+ try
+ {
+ await VerifyLivenessAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // The caller tore the initialization down; not a database verdict.
+ WriteHealth(new DriverHealth(DriverState.Unknown, null, null));
+ throw;
+ }
+ catch (Exception ex)
+ {
+ // Never interpolate _connectionString OR ex.Message onto the operator surface. The driver is
+ // dialect-agnostic over an arbitrary DbProviderFactory, so it cannot rely on any one provider's
+ // message discipline — Microsoft.Data.SqlClient's connection-string parser, for one, echoes an
+ // unrecognised keyword lower-cased, which a value-based password redactor misses entirely. Only
+ // the exception TYPE reaches LastError/host status; the full exception object goes to the log
+ // SINK via the structured logger's exception parameter (below), never into a rendered string.
+ var message =
+ $"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}. " +
+ $"Check the database is reachable and the connection string is valid.";
+ _logger.LogError(ex, "Sql driver {DriverInstanceId} liveness check against {Endpoint} failed.",
+ _driverInstanceId, Endpoint);
+ WriteHealth(new DriverHealth(DriverState.Faulted, null, message));
+ TransitionHostTo(HostState.Stopped);
+ throw new InvalidOperationException(message, ex);
+ }
+
+ WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
+ TransitionHostTo(HostState.Running);
+ _logger.LogInformation(
+ "Sql driver {DriverInstanceId} initialized against {Endpoint} with {TagCount} authored tag(s).",
+ _driverInstanceId, Endpoint, Tags.Count);
+ }
+
+ ///
+ public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
+ {
+ await ShutdownAsync(cancellationToken).ConfigureAwait(false);
+ await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task ShutdownAsync(CancellationToken cancellationToken)
+ {
+ var lastRead = ReadHealth().LastSuccessfulRead;
+ await TeardownAsync().ConfigureAwait(false);
+ WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
+ }
+
+ ///
+ public DriverHealth GetHealth() => ReadHealth();
+
+ /// No caches, no symbol table, no connection between polls — the footprint is the tag table.
+ /// Always zero.
+ public long GetMemoryFootprint() => 0;
+
+ /// Nothing optional to flush: the authored table is required for correctness.
+ /// Cancellation token for the operation.
+ /// A completed task.
+ public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ // ---- ITagDiscovery ----
+
+ ///
+ /// Exactly one pass: the tag set is the authored one, fixed at Initialize, so re-running discovery
+ /// could only produce the same nodes.
+ ///
+ public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
+
+ ///
+ /// False — replays authored tags rather than enumerating the backend, so
+ /// the universal discovery browser must not treat this driver as browsable. (Live schema browsing is
+ /// a separate surface: Driver.Sql.Browser.)
+ ///
+ public bool SupportsOnlineDiscovery => false;
+
+ ///
+ /// Streams the authored tags as read-only variables.
+ /// An undeclared tag materialises as — the same fallback
+ /// uses for an unrecognised column type — because a column's
+ /// real type is only known once a poll has returned result-set metadata, which has not happened at
+ /// discovery time. Author "type" on a numeric tag.
+ ///
+ /// The address-space builder to stream discovered nodes into.
+ /// Cancellation token for the operation.
+ /// A completed task — discovery is synchronous over the authored table.
+ public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ var folder = builder.Folder(DriverTypeName, DriverTypeName);
+ foreach (var tag in Tags.Values)
+ {
+ if (tag.DeclaredType is null)
+ {
+ // Discovery cannot infer a column's real type — that needs live result-set metadata, which
+ // only a poll returns — so an omitted-type tag materialises as String (the same fallback
+ // ISqlDialect.MapColumnType uses) while the reader coerces and publishes the column's actual,
+ // possibly numeric, CLR value against that String node. Nothing else signals the operator to
+ // this declared-vs-published mismatch, so warn and point them at the fix.
+ _logger.LogWarning(
+ "Sql tag '{Tag}' has no authored \"type\" and is discovered as String; a numeric column " +
+ "will then publish numeric values against a String node. Author an explicit \"type\" on " +
+ "numeric tags. Driver={DriverInstanceId}",
+ tag.Name, _driverInstanceId);
+ }
+
+ folder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
+ FullName: tag.Name,
+ DriverDataType: tag.DeclaredType ?? DriverDataType.String,
+ IsArray: false,
+ ArrayDim: null,
+ // v1 is read-only: no tag, and no configuration, can widen this.
+ SecurityClass: SecurityClassification.ViewOnly,
+ IsHistorized: false,
+ IsAlarm: false,
+ WriteIdempotent: false));
+ }
+
+ return Task.CompletedTask;
+ }
+
+ // ---- IReadable ----
+
+ ///
+ /// Reads a batch, delegating to and classifying what came back
+ /// ().
+ ///
+ /// The RawPaths to read.
+ /// Cancellation token for the operation.
+ /// One snapshot per reference, at the same index.
+ /// The database could not be reached — the engine backs off on this.
+ public async Task> ReadAsync(
+ IReadOnlyList fullReferences, CancellationToken cancellationToken)
+ {
+ // Guarded here rather than inside the try, so a caller's bug cannot be misread as "the database is
+ // unreachable" and degrade a perfectly healthy driver.
+ ArgumentNullException.ThrowIfNull(fullReferences);
+
+ try
+ {
+ var snapshots = await _reader.ReadAsync(fullReferences, cancellationToken).ConfigureAwait(false);
+ ObservePollOutcome(snapshots);
+ return snapshots;
+ }
+ catch (OperationCanceledException)
+ {
+ // Teardown, not a database verdict — leave health and host state exactly as they were.
+ throw;
+ }
+ catch (Exception ex)
+ {
+ // The reader throws for one reason only: opening a connection failed (IReadable's contract).
+ _logger.LogWarning(ex, "Sql driver {DriverInstanceId} could not reach {Endpoint} during a read.",
+ _driverInstanceId, Endpoint);
+ // Type name only — never ex.Message (see InitializeAsync for why); the full exception is already
+ // on the log sink via the LogWarning exception parameter above.
+ Degrade($"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}.");
+ TransitionHostTo(HostState.Stopped);
+ throw;
+ }
+ }
+
+ // ---- ISubscribable (polling overlay via the shared engine) ----
+
+ ///
+ /// Registers a polled subscription.
+ /// A non-positive falls back to the configured
+ /// ; anything faster than
+ /// is floored by the engine.
+ ///
+ /// The RawPaths to poll.
+ /// The requested publishing interval.
+ /// Cancellation token for the operation.
+ /// The subscription handle.
+ public Task SubscribeAsync(
+ IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
+ {
+ var interval = publishingInterval > TimeSpan.Zero ? publishingInterval : _options.DefaultPollInterval;
+ return Task.FromResult(_poll.Subscribe(fullReferences, interval));
+ }
+
+ ///
+ public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
+ {
+ _poll.Unsubscribe(handle);
+ return Task.CompletedTask;
+ }
+
+ // ---- IHostConnectivityProbe ----
+
+ ///
+ /// The single logical host this instance dials. There is no background probe loop: the state is a
+ /// by-product of the Initialize-time liveness check and of every poll, so the report is always a
+ /// statement about traffic that actually happened rather than about a synthetic ping.
+ ///
+ /// A one-element list describing the configured database endpoint.
+ public IReadOnlyList GetHostStatuses()
+ {
+ lock (_probeLock)
+ return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
+ }
+
+ // ---- health + host-state classification ----
+
+ ///
+ /// Classifies one poll's snapshots into a health verdict and a host state.
+ ///
+ /// Any Good snapshot ⇒ the database answered: LastSuccessfulRead advances and the
+ /// host is Running.
+ /// Any connection-class Bad snapshot ( /
+ /// ) ⇒ Degraded; when nothing at all was Good,
+ /// the host is Stopped.
+ /// Bad codes that are authoring facts — an unresolvable RawPath, an absent row, a
+ /// type mismatch, a rejected definition — change nothing. A tag typo must never report the
+ /// database as down and send an operator to the wrong system.
+ ///
+ /// Why this does not throw to force poll-engine backoff. Backoff would require turning
+ /// an all-BadTimeout batch into an exception, and the engine's exception path publishes
+ /// nothing — clients would lose the very BadTimeout quality the reader went out of its way to
+ /// produce, and would sit on stale Good values instead. Nor is hammering a real risk: each group is
+ /// already bounded by operationTimeout and the reader never holds more than
+ /// maxConcurrentGroups connections however long the database stays frozen. So a frozen
+ /// database is reported (Degraded + host Stopped + Bad-quality values) at the configured poll
+ /// cadence rather than backed off from.
+ ///
+ /// The snapshots the reader returned for one poll.
+ internal void ObservePollOutcome(IReadOnlyList snapshots)
+ {
+ if (snapshots.Count == 0) return;
+
+ var good = 0;
+ var unreachable = 0;
+ foreach (var snapshot in snapshots)
+ {
+ if (SqlStatusCodes.IsGood(snapshot.StatusCode)) good++;
+ else if (IsConnectionClass(snapshot.StatusCode)) unreachable++;
+ }
+
+ if (unreachable > 0)
+ {
+ Degrade(
+ $"{unreachable} of {snapshots.Count} Sql tag(s) timed out or failed communication against " +
+ $"{Endpoint} on the last poll.");
+ if (good > 0)
+ {
+ // Partial: something answered, so the endpoint is up — but the driver is not healthy.
+ TouchLastSuccessfulRead();
+ TransitionHostTo(HostState.Running);
+ }
+ else
+ {
+ TransitionHostTo(HostState.Stopped);
+ }
+
+ return;
+ }
+
+ if (good == 0) return; // authoring-class Bad only — not a statement about the database.
+
+ // Route through the Faulted guard, not a raw WriteHealth: a poll still in flight when a concurrent
+ // ReinitializeAsync has recorded Faulted (the engine's ~5 s teardown wait is shorter than the 15 s
+ // default operationTimeout, so a stale poll can still complete afterwards) must not flip Faulted back
+ // to Healthy with no real recovery. Only a successful (re)initialize clears Faulted.
+ SetHealthUnlessFaulted(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
+ TransitionHostTo(HostState.Running);
+ }
+
+ /// True for the status codes that mean "the database did not answer", as opposed to "the data is not there".
+ private static bool IsConnectionClass(uint statusCode)
+ => statusCode is SqlStatusCodes.BadTimeout or SqlStatusCodes.BadCommunicationError;
+
+ ///
+ /// Routes a poll-loop reader failure to the health surface (05/STAB-9). Deliberately does not
+ /// touch host state: the engine also reports its own contract violations here, which say nothing
+ /// about connectivity — the unreachable-database transition is raised by ,
+ /// which knows the exception came from the reader.
+ ///
+ /// The exception the poll engine caught.
+ internal void HandlePollError(Exception ex)
+ {
+ // A subscribed-read outage surfaces as a DbException the reader threw, which ReadAsync has ALREADY
+ // classified — good, endpoint-bearing LastError + host Stopped — before rethrowing. The poll engine
+ // then routes that same exception here. Re-degrading would overwrite the good LastError with a worse,
+ // guidance-free one and log the outage a second time, so skip the connection class ReadAsync owns.
+ if (ex is DbException) return;
+
+ // Whatever reaches here — an engine contract-violation, or a non-DbException the provider raised
+ // (e.g. an ArgumentException from a malformed connection string, which the parser echoes with
+ // credential fragments) — the operator surface carries only the exception TYPE, never ex.Message.
+ // The full exception, with its text, still reaches the log SINK via the exception parameter. This is
+ // the same unconditional rule InitializeAsync/ReadAsync follow; do not weaken it to a type check.
+ _logger.LogWarning(ex, "Sql poll failed. Driver={DriverInstanceId} Endpoint={Endpoint}",
+ _driverInstanceId, Endpoint);
+ Degrade($"Sql driver '{_driverInstanceId}' poll failed: {ex.GetType().Name}.");
+ }
+
+ ///
+ /// Degrades health, preserving LastSuccessfulRead and never downgrading
+ /// — a config-level verdict that only a successful read or an
+ /// operator reinitialize may clear.
+ ///
+ /// The operator-facing reason, which must never carry the connection string.
+ private void Degrade(string reason)
+ {
+ var current = ReadHealth();
+ SetHealthUnlessFaulted(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, reason));
+ }
+
+ ///
+ /// Publishes unless the driver is already
+ /// — a liveness/config verdict that only a successful (re)initialize may clear. Every non-Initialize
+ /// health write routes through here: and the poll's Healthy verdict in
+ /// , so a late in-flight poll cannot un-fault a driver a concurrent
+ /// just faulted.
+ ///
+ /// The health snapshot to publish when the driver is not Faulted.
+ private void SetHealthUnlessFaulted(DriverHealth value)
+ {
+ if (ReadHealth().State == DriverState.Faulted) return;
+ WriteHealth(value);
+ }
+
+ /// Advances LastSuccessfulRead without changing the current state or error.
+ private void TouchLastSuccessfulRead()
+ {
+ var current = ReadHealth();
+ WriteHealth(current with { LastSuccessfulRead = DateTime.UtcNow });
+ }
+
+ /// Barrier-protected read of the multi-threaded health field.
+ private DriverHealth ReadHealth() => Volatile.Read(ref _health);
+
+ /// Barrier-protected publish of a new health snapshot.
+ private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
+
+ /// Records a host-state change and raises — on transitions only.
+ private void TransitionHostTo(HostState newState)
+ {
+ HostState old;
+ lock (_probeLock)
+ {
+ old = _hostState;
+ if (old == newState) return;
+ _hostState = newState;
+ _hostStateChangedUtc = DateTime.UtcNow;
+ }
+
+ OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
+ }
+
+ // ---- authored tag table ----
+
+ ///
+ /// Maps every authored through
+ /// and publishes the result as one atomic snapshot. A blob that does not map is logged and
+ /// skipped, never thrown: one malformed tag must not take the whole driver — and therefore every
+ /// other tag on that database — down with it. The skipped RawPath then resolves to nothing, so it
+ /// publishes rather than someone else's row.
+ ///
+ private void BuildTagTable()
+ {
+ var table = new Dictionary(_options.RawTags.Count, StringComparer.Ordinal);
+ foreach (var entry in _options.RawTags)
+ {
+ try
+ {
+ if (SqlEquipmentTagParser.TryParse(entry.TagConfig, entry.RawPath, out var definition))
+ table[entry.RawPath] = definition;
+ else
+ _logger.LogWarning(
+ "Sql tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
+ _driverInstanceId, entry.RawPath);
+ }
+ catch (Exception ex)
+ {
+ // TryParse is contracted never to throw, but BuildTagTable runs BEFORE InitializeAsync's
+ // try/catch, so a stray throw here (a parser bug on one blob) would escape Initialize and
+ // strand health at Initializing across every DriverInstanceActor retry. Skip the offending
+ // tag — the same "one bad tag must not take the whole driver down" rule the malformed-blob
+ // branch above follows — rather than fault the driver over a single entry.
+ _logger.LogError(
+ ex, "Sql tag config threw while parsing; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
+ _driverInstanceId, entry.RawPath);
+ }
+ }
+
+ Volatile.Write(ref _tagsByRawPath, table);
+ }
+
+ // ---- liveness ----
+
+ ///
+ /// Opens ONE connection, runs the dialect's liveness statement, and disposes it. No connection is
+ /// held between this check and the first poll — the reader opens, uses and disposes per poll, so a
+ /// long-lived connection here would only be a second thing to keep alive.
+ /// Bounded by wall-clock, not only by the token (the R2-01 / STAB-14 lesson): some ADO.NET
+ /// providers implement the async path synchronously, and a wedged socket can hang inside the
+ /// provider's own cancellation handshake. If Initialize hung there, DriverInstanceActor's init
+ /// task would never complete and the driver would sit in Connecting forever with no retry. The work
+ /// runs on the thread pool so a synchronous provider blocks a pool thread rather than the caller, and
+ /// an abandoned attempt still owns and disposes its own connection.
+ ///
+ /// The caller's token.
+ /// A task that completes when the database has answered.
+ private async Task VerifyLivenessAsync(CancellationToken cancellationToken)
+ {
+ var budget = _options.CommandTimeout;
+ var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ Task work;
+ try
+ {
+ deadline.CancelAfter(budget);
+ work = Task.Run(
+ async () =>
+ {
+ try { await PingAsync(deadline.Token).ConfigureAwait(false); }
+ finally { deadline.Dispose(); }
+ },
+ CancellationToken.None);
+ }
+ catch
+ {
+ // Ownership of the CTS transfers to the work task; release it only if that task never started.
+ deadline.Dispose();
+ throw;
+ }
+
+ try
+ {
+ await work.WaitAsync(budget, cancellationToken).ConfigureAwait(false);
+ }
+ catch (TimeoutException)
+ {
+ Detach(work);
+ throw new TimeoutException(
+ $"the liveness statement did not return within {(int)budget.TotalMilliseconds} ms.");
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ // Our deadline fired and the provider DID honour the linked token.
+ Detach(work);
+ throw new TimeoutException(
+ $"the liveness statement did not return within {(int)budget.TotalMilliseconds} ms.");
+ }
+ }
+
+ /// Opens a connection and executes the dialect's cheapest proof-of-life statement.
+ private async Task PingAsync(CancellationToken cancellationToken)
+ {
+ var connection = _factory.CreateConnection()
+ ?? throw new InvalidOperationException(
+ $"the {_dialect.Provider} provider factory returned no connection.");
+
+ await using (connection.ConfigureAwait(false))
+ {
+ connection.ConnectionString = _connectionString;
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+
+ var command = connection.CreateCommand();
+ await using (command.ConfigureAwait(false))
+ {
+ command.CommandText = _dialect.LivenessSql;
+ // ADO.NET reads CommandTimeout = 0 as "wait forever" — the one value it must never become.
+ command.CommandTimeout = Math.Max(1, (int)Math.Ceiling(_options.CommandTimeout.TotalSeconds));
+ await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ ///
+ /// Observes an abandoned liveness attempt's eventual fault so it never surfaces as an unobserved
+ /// task exception. The task still owns — and disposes — its own connection.
+ /// TODO(M2): a verbatim duplicate of SqlPollReader.Detach; both live in Driver.Sql and
+ /// could hoist to one internal helper. Left in place here to keep this change off the reader.
+ ///
+ private static void Detach(Task work)
+ => _ = work.ContinueWith(
+ static t => _ = t.Exception,
+ CancellationToken.None,
+ TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
+ TaskScheduler.Default);
+
+ // ---- endpoint description ----
+
+ ///
+ /// Renders a connection string as server/database, reading only those two keys.
+ /// This is the only function permitted to look at the connection string outside the
+ /// provider call, and it exists so that no other code is ever tempted to log the string itself:
+ /// credentials live in User ID / Password / Authentication, which are never
+ /// read here. A string that cannot be parsed yields — an unusable
+ /// description must not become a crash at construction.
+ ///
+ /// The resolved connection string.
+ /// A credential-free description safe to log and display.
+ private static string DescribeEndpoint(string connectionString)
+ {
+ try
+ {
+ var builder = new DbConnectionStringBuilder { ConnectionString = connectionString };
+ var server = FirstValue(builder, ServerKeys);
+ if (string.IsNullOrWhiteSpace(server)) return UnknownEndpoint;
+ var database = FirstValue(builder, DatabaseKeys);
+ return string.IsNullOrWhiteSpace(database) ? server : $"{server}/{database}";
+ }
+ catch (ArgumentException)
+ {
+ return UnknownEndpoint;
+ }
+ }
+
+ /// Reads the first of the builder carries; null when it carries none.
+ private static string? FirstValue(DbConnectionStringBuilder builder, string[] keys)
+ {
+ foreach (var key in keys)
+ {
+ // DbConnectionStringBuilder's key comparison is case-insensitive.
+ if (builder.TryGetValue(key, out var value) && value is not null)
+ {
+ var text = value.ToString();
+ if (!string.IsNullOrWhiteSpace(text)) return text;
+ }
+ }
+
+ return null;
+ }
+
+ // ---- teardown ----
+
+ ///
+ /// Performs the same teardown as , so a caller that only uses
+ /// await using does not leak the poll loops.
+ ///
+ /// A task that represents the asynchronous operation.
+ public async ValueTask DisposeAsync() => await TeardownAsync().ConfigureAwait(false);
+
+ ///
+ /// Shared teardown: stops every polled subscription and drops the authored table. Idempotent — safe
+ /// to call any number of times, in any order, which is what makes
+ /// ShutdownAsync-then-DisposeAsync (and ) safe.
+ /// There is no connection to close: the reader opens and disposes one per poll.
+ ///
+ private async Task TeardownAsync()
+ {
+ await _poll.DisposeAsync().ConfigureAwait(false);
+ Volatile.Write(ref _tagsByRawPath, new Dictionary(StringComparer.Ordinal));
+ _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs
new file mode 100644
index 00000000..ac7479e3
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs
@@ -0,0 +1,252 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Hosting;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
+
+///
+/// Static factory registration helper for — the seam between a deployed
+/// DriverConfig blob and a live driver instance. The Server's composition root calls
+/// once at startup; the bootstrapper then materialises Sql DriverInstance
+/// rows into driver instances. Mirrors ModbusDriverFactoryExtensions.
+/// This type owns the config validation the layers below deliberately skip.
+/// and stay usable with an inverted
+/// operationTimeout/commandTimeout pair on purpose (the frozen-database tests need that
+/// shape), so the factory is the only place a deployment can be stopped before it silently loses one of
+/// its two independent deadlines. Every knob is checked here, once, at construction.
+/// Enum fields are read leniently and written as names. carries a
+/// , so a provider authored as an ordinal still parses and a
+/// round-trip emits "SqlServer" — the systemic AdminUI defect where a page serialised an enum
+/// numerically while the consuming DTO was string-typed, faulting the driver at deploy time.
+/// The resolved connection string is a secret and never leaves this method except into the
+/// driver. Every log line and every exception message here names the connectionStringRef, the
+/// environment variable, or 's credential-free server/database
+/// rendering — never the string itself.
+///
+public static class SqlDriverFactoryExtensions
+{
+ ///
+ /// The driver-type string this factory registers under — chained off
+ /// , which is
+ /// .
+ ///
+ public const string DriverTypeName = SqlDriver.DriverTypeName;
+
+ ///
+ /// How a Sql config blob is read.
+ ///
+ /// — accepts an enum written as a name OR as an
+ /// ordinal, and always writes the name (the enum-serialization guard).
+ /// — a blob authored against a different
+ /// schema version must not brick the driver.
+ /// — the authored spelling
+ /// (connectionStringRef, rawTags). Reads are case-insensitive anyway; the policy is
+ /// what makes a write round-trip to the authored shape.
+ ///
+ ///
+ internal static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ /// The serializer options, exposed so tests can assert the enum guard on a real round-trip.
+ internal static JsonSerializerOptions JsonOptionsForTest => JsonOptions;
+
+ ///
+ /// Registers the Sql factory with the driver registry. The optional
+ /// is captured at registration time and used to build a logger per
+ /// driver instance; without it the driver runs with the null logger.
+ ///
+ /// The driver factory registry to register with.
+ /// Optional logger factory for creating loggers per driver instance.
+ /// is null.
+ public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
+ {
+ ArgumentNullException.ThrowIfNull(registry);
+ registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
+ }
+
+ /// Builds one driver instance from its config blob. For the Server bootstrapper and tests.
+ /// The central config DB's identity for this driver instance.
+ /// The instance's DriverConfig JSON blob.
+ /// The constructed .
+ public static SqlDriver CreateInstance(string driverInstanceId, string driverConfigJson)
+ => CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
+
+ ///
+ /// Logger-aware overload — used by 's closure when wired through DI.
+ /// Constructs the driver only: the driver builds its own , because
+ /// the reader's resolve delegate must read the driver's live authored-tag table, which does not
+ /// exist until the driver does.
+ /// No I/O happens here. The database is first contacted by
+ /// , so a database that is merely down yields a driver in
+ /// Reconnecting rather than a deployment that cannot be constructed.
+ ///
+ /// The central config DB's identity for this driver instance.
+ /// The instance's DriverConfig JSON blob.
+ /// Optional logger factory for creating loggers per driver instance.
+ /// The constructed .
+ /// An argument is null, empty or whitespace.
+ ///
+ /// The blob is not valid JSON, omits connectionStringRef, names a provider this build cannot
+ /// construct, carries an invalid knob, or the referenced connection string is not provisioned.
+ ///
+ public static SqlDriver CreateInstance(
+ string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
+
+ var dto = Deserialize(driverInstanceId, driverConfigJson);
+
+ if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef))
+ {
+ throw new InvalidOperationException(
+ $"Sql driver config for '{driverInstanceId}' is missing the required connectionStringRef. " +
+ $"Author the NAME of a connection string (the value itself is supplied out-of-band through " +
+ $"the '{SqlConnectionStringResolver.EnvironmentVariablePrefix}' environment variable).");
+ }
+
+ var dialect = CreateDialect(dto.Provider, driverInstanceId);
+
+ // Resolved BEFORE the knob validation below, so that every subsequent throw is a path that HAS the
+ // secret in hand — which is exactly where a careless interpolation would leak it.
+ var connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!);
+ var options = BuildOptions(dto, driverInstanceId);
+
+ var logger = (ILogger?)loggerFactory?.CreateLogger(typeof(SqlDriverFactoryExtensions).FullName!)
+ ?? NullLogger.Instance;
+
+ if (dto.AllowWrites is true)
+ {
+ // Inert by construction (SqlDriver does not implement IWritable), so failing the driver over it
+ // would brick a deployment for a flag that cannot do harm. The operator who believes writes are
+ // on, however, can — so this is loud.
+ logger.LogWarning(
+ "Sql driver {DriverInstanceId} authored allowWrites=true, which this build ignores: the Sql " +
+ "driver is read-only (it implements no write capability), so no configuration can enable a " +
+ "write. Remove the flag, or expect reads only.",
+ driverInstanceId);
+ }
+
+ var driver = new SqlDriver(
+ options,
+ driverInstanceId,
+ dialect,
+ connectionString,
+ factory: null,
+ logger: loggerFactory?.CreateLogger());
+
+ // Endpoint is the credential-free server/database rendering — the ONLY form of the connection string
+ // permitted outside the provider call.
+ logger.LogInformation(
+ "Sql driver {DriverInstanceId} configured for {Provider} at {Endpoint} with {TagCount} " +
+ "authored tag(s), poll {PollInterval}, operation timeout {OperationTimeout}.",
+ driverInstanceId, dto.Provider, driver.Endpoint, options.RawTags.Count,
+ options.DefaultPollInterval, options.OperationTimeout);
+
+ return driver;
+ }
+
+ /// Deserialises the blob, turning a malformed one into an actionable, instance-named failure.
+ private static SqlDriverConfigDto Deserialize(string driverInstanceId, string driverConfigJson)
+ {
+ try
+ {
+ return JsonSerializer.Deserialize(driverConfigJson, JsonOptions)
+ ?? throw new InvalidOperationException(
+ $"Sql driver config for '{driverInstanceId}' deserialised to null");
+ }
+ catch (JsonException ex)
+ {
+ throw new InvalidOperationException(
+ $"Sql driver config for '{driverInstanceId}' is not valid JSON: {ex.Message}", ex);
+ }
+ }
+
+ ///
+ /// Selects the provider seam.
+ /// v1 constructs only. The other members exist on the shipped
+ /// enum as reserved names; authoring one is a config mistake that must fail at construction with a
+ /// message that says so, rather than silently degrading to T-SQL against a non-SQL-Server database.
+ ///
+ private static ISqlDialect CreateDialect(SqlProvider provider, string driverInstanceId)
+ => provider switch
+ {
+ SqlProvider.SqlServer => new SqlServerDialect(),
+ _ => throw new InvalidOperationException(
+ $"Sql driver config for '{driverInstanceId}' names provider '{provider}', which is not " +
+ $"available in this build. Only '{nameof(SqlProvider.SqlServer)}' is supported."),
+ };
+
+ ///
+ /// Applies the documented defaults and validates every knob (design §5.1 / §8.3).
+ /// Internal so the defaults and the rejections can be asserted directly, without a database or a
+ /// provisioned connection string standing between the test and the rule.
+ ///
+ /// The deserialised config blob.
+ /// Named in every rejection message, so a fleet-wide log identifies the row.
+ /// The typed options the driver is constructed with.
+ /// A knob is out of range, or the two deadlines are inverted.
+ internal static SqlDriverOptions BuildOptions(SqlDriverConfigDto dto, string driverInstanceId)
+ {
+ ArgumentNullException.ThrowIfNull(dto);
+
+ var defaultPollInterval = dto.DefaultPollInterval ?? TimeSpan.FromSeconds(5);
+ var operationTimeout = dto.OperationTimeout ?? TimeSpan.FromSeconds(15);
+ var commandTimeout = dto.CommandTimeout ?? TimeSpan.FromSeconds(10);
+ var maxConcurrentGroups = dto.MaxConcurrentGroups ?? 4;
+
+ RequirePositive(defaultPollInterval, "defaultPollInterval", driverInstanceId);
+ RequirePositive(operationTimeout, "operationTimeout", driverInstanceId);
+ RequirePositive(commandTimeout, "commandTimeout", driverInstanceId);
+ if (maxConcurrentGroups < 1)
+ {
+ throw new InvalidOperationException(
+ $"Sql driver config for '{driverInstanceId}' has maxConcurrentGroups {maxConcurrentGroups}; " +
+ $"it must be at least 1.");
+ }
+
+ // Design §8.3. The two deadlines are independent and BOTH are required: commandTimeout is the
+ // server-side backstop, operationTimeout the client-side wall-clock bound that alone survives a
+ // wedged socket (the R2-01/STAB-14 lesson). Inverted — or equal — the client-side abort always fires
+ // first and the backstop can never be reached, so a deployment quietly runs on one bound instead of
+ // two. Nothing downstream enforces this: the reader stays deliberately usable with it inverted.
+ if (operationTimeout <= commandTimeout)
+ {
+ throw new InvalidOperationException(
+ $"Sql driver config for '{driverInstanceId}' has operationTimeout {operationTimeout} which " +
+ $"is not greater than commandTimeout {commandTimeout}. The client-side operationTimeout must " +
+ $"be strictly greater than the server-side commandTimeout backstop, or the backstop can " +
+ $"never fire.");
+ }
+
+ return new SqlDriverOptions
+ {
+ RawTags = dto.RawTags is { Count: > 0 } rawTags ? [.. rawTags] : [],
+ DefaultPollInterval = defaultPollInterval,
+ OperationTimeout = operationTimeout,
+ CommandTimeout = commandTimeout,
+ MaxConcurrentGroups = maxConcurrentGroups,
+ NullIsBad = dto.NullIsBad ?? false,
+ };
+ }
+
+ /// Rejects a non-positive duration, naming the authored field.
+ private static void RequirePositive(TimeSpan value, string field, string driverInstanceId)
+ {
+ if (value <= TimeSpan.Zero)
+ {
+ throw new InvalidOperationException(
+ $"Sql driver config for '{driverInstanceId}' has {field} {value}; it must be greater than zero.");
+ }
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverOptions.cs
new file mode 100644
index 00000000..6157fb20
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverOptions.cs
@@ -0,0 +1,56 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
+
+///
+/// The typed form of a Sql driver instance's configuration — what
+/// becomes once the factory has applied its defaults (design §5.1).
+/// Mirrors ModbusDriverOptions: the driver instance is constructed with these, and its
+/// serves them rather than re-parsing the config JSON.
+/// No connection string here. The resolved connection string is a separate constructor
+/// argument, because it is a secret and these options are safe to log, diff and hold in a snapshot.
+///
+public sealed class SqlDriverOptions
+{
+ ///
+ /// The authored raw tags this driver serves. The deploy artifact hands each authored raw Tag
+ /// as a (RawPath identity + driver TagConfig blob); the driver maps
+ /// each through into its RawPath → definition table.
+ /// A SQL source has no tag-discovery protocol — the driver serves exactly these.
+ ///
+ public IReadOnlyList RawTags { get; init; } = [];
+
+ ///
+ /// Poll interval used when a subscriber does not ask for one (design §4: defaultPollInterval,
+ /// default 5 s). A subscriber that names an interval gets that interval, floored by
+ /// .
+ ///
+ public TimeSpan DefaultPollInterval { get; init; } = TimeSpan.FromSeconds(5);
+
+ ///
+ /// The client-side wall-clock ceiling on one group query (design §8.3, default 15 s). A breach
+ /// publishes for that group's tags.
+ ///
+ public TimeSpan OperationTimeout { get; init; } = TimeSpan.FromSeconds(15);
+
+ ///
+ /// The ADO.NET CommandTimeout server-side backstop (default 10 s), also the bound on the
+ /// Initialize-time liveness check.
+ /// Authoring must keep strictly greater than this (design §8.3);
+ /// inverted, the client-side abort always fires first and masks the backstop. That rule is
+ /// enforced by config validation in the factory, not here — the reader deliberately stays usable
+ /// with the order inverted so the frozen-database tests can prove the client-side bound fires.
+ ///
+ public TimeSpan CommandTimeout { get; init; } = TimeSpan.FromSeconds(10);
+
+ /// Cap on concurrently executing group queries — and therefore on concurrently open connections.
+ public int MaxConcurrentGroups { get; init; } = 4;
+
+ ///
+ /// When , a present row whose value cell is NULL publishes
+ /// instead of the default .
+ /// It never governs an absent row, which is always .
+ ///
+ public bool NullIsBad { get; init; }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs
new file mode 100644
index 00000000..79c5b986
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs
@@ -0,0 +1,171 @@
+using System.Data.Common;
+using System.Diagnostics;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
+
+///
+/// The AdminUI "Test Connect" liveness check for the Sql driver: open a connection, run the
+/// dialect's SELECT 1 () under a bounded deadline, and report
+/// green with latency or red with a reason. Mirrors ModbusDriverProbe.
+/// Parses the SAME factory DTO with the SAME converter as
+/// (R2-11, 05/CONV-2 — the OpcUaClient parity rule), so a
+/// config the operator Test-Connects is byte-for-byte the config that Deploys: a numerically serialised
+/// provider parses here exactly as it does at the factory, and the same
+/// connectionStringRef resolves the same way.
+/// Never throws (the contract). Every failure — malformed JSON, a
+/// missing connectionStringRef, an unprovisioned connection string, a provider open failure, a
+/// timeout, a cancellation — becomes a red , so the AdminUI has nothing to
+/// catch.
+/// The resolved connection string never reaches the result message. A red result names the
+/// failure class, never the string — the same credential discipline the factory and the driver keep.
+///
+public sealed class SqlDriverProbe : IDriverProbe
+{
+ ///
+ /// Selects the provider seam by . In production this constructs the real
+ /// ; injects a test dialect (and factory) so the
+ /// probe can run against the SQLite fixture with no SQL Server and no network.
+ ///
+ private readonly Func _dialectFor;
+
+ ///
+ /// Overrides the dialect's own when non-null — the injection point
+ /// the SQLite tests use to hand in SqliteFactory while keeping the test dialect's
+ /// catalog SQL. Null in production: the dialect's own factory is used.
+ ///
+ private readonly DbProviderFactory? _factoryOverride;
+
+ /// Initializes a new that constructs the real SQL Server dialect.
+ public SqlDriverProbe()
+ : this(DefaultDialectFor, factoryOverride: null)
+ {
+ }
+
+ private SqlDriverProbe(Func dialectFor, DbProviderFactory? factoryOverride)
+ {
+ _dialectFor = dialectFor;
+ _factoryOverride = factoryOverride;
+ }
+
+ ///
+ /// Test seam: a probe that always uses and opens connections from
+ /// , so the SQLite fixture's create → open → SELECT 1 → close cycle
+ /// runs unmodified.
+ ///
+ /// The provider factory the probe opens connections from.
+ /// The dialect whose the probe runs.
+ /// A probe wired to the supplied factory + dialect.
+ /// A required argument is null.
+ internal static SqlDriverProbe ForTest(DbProviderFactory factory, ISqlDialect dialect)
+ {
+ ArgumentNullException.ThrowIfNull(factory);
+ ArgumentNullException.ThrowIfNull(dialect);
+ return new SqlDriverProbe(_ => dialect, factory);
+ }
+
+ ///
+ public string DriverType => SqlDriver.DriverTypeName;
+
+ ///
+ public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
+ {
+ // Parse + resolve first: a config that cannot even be read is a red result, not a connection attempt.
+ SqlDriverConfigDto? dto;
+ try
+ {
+ dto = System.Text.Json.JsonSerializer.Deserialize(
+ configJson, SqlDriverFactoryExtensions.JsonOptions);
+ }
+ catch (Exception ex) when (ex is System.Text.Json.JsonException or ArgumentNullException)
+ {
+ return new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null);
+ }
+
+ if (dto is null)
+ return new DriverProbeResult(false, "Config JSON deserialized to null.", null);
+ if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef))
+ return new DriverProbeResult(false, "Config has no connectionStringRef to resolve.", null);
+
+ ISqlDialect dialect;
+ string connectionString;
+ try
+ {
+ dialect = _dialectFor(dto.Provider);
+ connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!);
+ }
+ catch (InvalidOperationException ex)
+ {
+ // Resolve throws with only the ref + env-var name — no secret in the message, so surfacing it is
+ // safe and actionable (it names the environment variable the operator must set).
+ return new DriverProbeResult(
+ false, $"Could not run the SQL liveness check ({ex.GetType().Name}).", null);
+ }
+
+ var factory = _factoryOverride ?? dialect.Factory;
+ return await RunLivenessAsync(factory, dialect, connectionString, timeout, ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Opens one connection under a linked CTS bounded by , runs the dialect's
+ /// liveness statement, and returns green with latency. Any failure becomes a red result whose message
+ /// names the failure class — never the connection string (a provider exception can embed the
+ /// data source, so its .Message is deliberately not surfaced).
+ ///
+ private static async Task RunLivenessAsync(
+ DbProviderFactory factory, ISqlDialect dialect, string connectionString, TimeSpan timeout,
+ CancellationToken ct)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ if (timeout > TimeSpan.Zero) cts.CancelAfter(timeout);
+
+ try
+ {
+ var connection = factory.CreateConnection()
+ ?? throw new InvalidOperationException(
+ $"the {dialect.Provider} provider factory returned no connection.");
+ await using (connection.ConfigureAwait(false))
+ {
+ connection.ConnectionString = connectionString;
+ await connection.OpenAsync(cts.Token).ConfigureAwait(false);
+
+ var command = connection.CreateCommand();
+ await using (command.ConfigureAwait(false))
+ {
+ command.CommandText = dialect.LivenessSql;
+ await command.ExecuteScalarAsync(cts.Token).ConfigureAwait(false);
+ }
+ }
+
+ stopwatch.Stop();
+ return new DriverProbeResult(true, "SQL SELECT 1 OK", stopwatch.Elapsed);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ return new DriverProbeResult(false, "Probe cancelled.", null);
+ }
+ catch (OperationCanceledException)
+ {
+ return new DriverProbeResult(
+ false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
+ }
+ catch (Exception ex)
+ {
+ // The provider's own message can carry the data source (host/database), which is not a secret but
+ // is more than the operator needs — and keeping the message to a fixed class is what guarantees no
+ // credential-bearing connection string can ever slip through. Name the exception TYPE only.
+ return new DriverProbeResult(
+ false, $"Could not run the SQL liveness check ({ex.GetType().Name}).", null);
+ }
+ }
+
+ /// Constructs the production dialect for a provider; v1 supports SQL Server only.
+ private static ISqlDialect DefaultDialectFor(SqlProvider provider) => provider switch
+ {
+ SqlProvider.SqlServer => new SqlServerDialect(),
+ _ => throw new InvalidOperationException(
+ $"provider '{provider}' is not available in this build (only SqlServer is supported)."),
+ };
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs
new file mode 100644
index 00000000..73510d1d
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs
@@ -0,0 +1,250 @@
+using System.Globalization;
+using System.Text;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
+
+///
+/// Folds a set of s into the minimum number of parameterized queries
+/// (design §3.6) — the driver's batching core. Pure and side-effect free: no connection, no I/O, no clock.
+/// The GroupKey is the correctness contract. Two tags share a query only when every
+/// field that shapes the result set is identical. Too coarse and a tag silently reads a neighbour's cell;
+/// too fine and batching degenerates to one round-trip per tag. Both directions are pinned by
+/// SqlGroupPlannerTests.
+/// Injection boundary (design §8.1). Identifiers reach the command text through
+/// and nothing else; every authored value becomes a
+/// @k0/@w marker with the value carried in . There is no
+/// interpolation of tag content into SQL anywhere in this type.
+///
+public static class SqlGroupPlanner
+{
+ /// The parameter-marker prefix for a key-value model's IN list.
+ private const string KeyMarkerPrefix = "@k";
+
+ /// The single parameter marker for a wide-row model's row selector.
+ private const string RowSelectorMarker = "@w";
+
+ ///
+ /// Groups by source and emits one per group.
+ /// Deterministic: groups appear in the input order of their first member, members keep their input
+ /// order within a group, and parameters keep the first-appearance order of their distinct values — so a
+ /// given tag list always yields byte-identical SQL.
+ ///
+ /// The tags to plan. An empty sequence yields no plans.
+ /// Supplies identifier quoting and the provider's row-limit syntax.
+ /// One plan per distinct group key.
+ /// or is null.
+ ///
+ /// A definition is missing a field its model requires (e.g. a wide-row tag with no row selector), or an
+ /// identifier cannot be quoted (an empty table-name part, a control character — see
+ /// ). rejects all of these
+ /// up front, so reaching one here means a caller built a definition by hand with a broken invariant.
+ ///
+ ///
+ /// A tag carries (design §5.4, deferred to P3).
+ /// Deliberately loud rather than skipped: silently dropping a tag would leave an authored node
+ /// permanently unfed with nothing in the log, and the parser makes this branch unreachable in practice.
+ ///
+ public static IReadOnlyList Plan(IEnumerable tags, ISqlDialect dialect)
+ {
+ ArgumentNullException.ThrowIfNull(tags);
+ ArgumentNullException.ThrowIfNull(dialect);
+
+ // Ordered grouping: the dictionary decides membership, the list decides emission order.
+ var order = new List