Add a living progress tracker (docs/plans/2026-07-24-driver-expansion-tracking.md)
for the driver-expansion program's Waves 0-2, linked from the program design doc's
document map. One row per deliverable pointing at design + implementation plan + status.
Add executable implementation plans (plan .md + co-located .tasks.json) for the four
Wave 1/2 drivers, each authored by writing-plans methodology off its design doc and
mirroring the relevant existing driver:
- Modbus RTU 11 tasks (extends Driver.Modbus; RTU-over-TCP, direct-serial descoped)
- SQL poll 22 tasks (ISqlDialect seam, SQL Server only in v1; SQLite + central-SQL fixtures)
- MTConnect Agent 23 tasks (browse free via Wave-0 seam; TrakHound-vs-hand-rolled = Task 0)
- MQTT/Sparkplug B 27 tasks (P1 plain MQTT 0-14 then P2 Sparkplug 15-26; MQTTnet-5 pinning spike = Task 0)
Wave 0 (universal browser) remains merged (056887d6) with its live gate open (#468).
All four new plans are design-only -> plan-ready; nothing built yet.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
48 KiB
SQL Poll Driver Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
Goal: Ship a read-only Sql Equipment-kind driver that polls SQL Server tables/views on an interval and publishes selected columns/rows as OPC UA variable nodes, authored through the standard equipment Tags flow with a bespoke schema browser.
Architecture: Three new projects mirroring the Modbus split — Driver.Sql.Contracts (options/tag DTOs + parser, zero transport deps), Driver.Sql (the IDriver/ITagDiscovery/IReadable/ISubscribable/IHostConnectivityProbe runtime + ISqlDialect seam + SqlServerDialect, owning Microsoft.Data.SqlClient), and Driver.Sql.Browser (schema-walk IBrowseSession). The PollGroupEngine, EquipmentTagRefResolver<TDef>, health state machine, and factory shape are all reused from Core; the driver batches tags by query-group (one round-trip per group per poll) and slices result sets back to per-tag snapshots. Every value binds as a DbParameter; identifiers are dialect-quoted from catalog-validated names only.
Tech Stack: Microsoft.Data.SqlClient (6.1.1, already in Directory.Packages.props) via System.Data.Common base types behind a DbProviderFactory; ISqlDialect + SqlProvider enum seam (only SqlServer implemented in v1); Microsoft.Data.Sqlite (10.0.7, test-only) for the offline unit fixture; xunit.v3 + Shouldly. Zero new NuGet dependencies.
Source design: docs/plans/2026-07-15-sql-poll-driver-design.md Program design (shared contract): docs/plans/2026-07-15-driver-expansion-program-design.md §3, §7
Conventions for every task below: all projects net10.0, Nullable/ImplicitUsings enabled, TreatWarningsAsErrors on product projects. Every commit message ends with the repo trailer Claude-Session: <session-url> (omitted from the git commit examples for brevity). Run dotnet build ZB.MOM.WW.OtOpcUa.slnx green before each commit. New test projects use xunit.v3 + Shouldly (see tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/*.csproj).
Task 0: Scaffold Driver.Sql.Contracts project + register in slnx
Classification: small Estimated implement time: ~4 min Parallelizable with: none (root of the tree) Files:
- Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj - Modify:
ZB.MOM.WW.OtOpcUa.slnx(add the project under/src/Drivers/)
Steps:
- Create the csproj — refs
Core.AbstractionsONLY (no transport deps, so AdminUI + browser can reference it without draggingMicrosoft.Data.SqlClient).InternalsVisibleTothe Contracts is not needed.RootNamespace = ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
</ItemGroup>
</Project>
- Add to
ZB.MOM.WW.OtOpcUa.slnxunder the/src/Drivers/folder, beside theDriver.Modbus.Contractsline. - Run
dotnet build ZB.MOM.WW.OtOpcUa.slnx— expect PASS (empty project compiles). - Commit:
git commit -m "feat(sql): scaffold Driver.Sql.Contracts project"
Task 1: SqlProvider + SqlTagModel enums + SqlDriverConfigDto (string-enum round-trip)
Classification: standard Estimated implement time: ~5 min Parallelizable with: none (blocks most downstream) Files:
- Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs - Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs - Create:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/*is NOT created here — this task's test lives temporarily in the Contracts consumer; create the Tests project in Task 6. Instead, put this task's test in a throwawayContracts-adjacent xunit project? No — to avoid a project just for one test, fold this task's serialization assertion into Task 9's factory enum-serialization test. This task ships DTOs only; its guard is Task 9.
Note: Tasks 1–2 produce pure records/enums with no I/O. Their behavioural guard (string-enum round-trip, strict parse) is the Task 9 factory test + Task 2's parser test (Task 2 creates its own micro-test via the Tests project created in Task 6). To keep TDD honest without a premature test project, reorder locally: implement Task 1 DTOs, then Task 6 (Tests project) can be pulled forward if the implementer prefers. The
blockedBygraph permits this. The concrete failing-test-first discipline begins at Task 2.
Steps:
SqlProvider.cs:
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer.</summary>
public enum SqlProvider { SqlServer, Postgres, MySql, Odbc, Oracle }
/// <summary>Tag→value mapping model. v1 ships KeyValue + WideRow; Query is deferred (design §5.4 / P3).</summary>
public enum SqlTagModel { KeyValue, WideRow, Query }
SqlDriverConfigDto.cs— the driver-config blob shape (§5.1). Enum-typed fields (Provider) so the string converter round-trips names; timeouts asTimeSpan?;ConnectionStringRef(NOT the connection string). Fields:Provider,ConnectionStringRef,DefaultPollInterval,OperationTimeout,CommandTimeout,MaxConcurrentGroups,NullIsBad,AllowWrites,Probe(SqlProbeDto{Enabled, Interval}).[JsonStringEnumConverter]is applied at the factory (Task 9), not on the DTO.- Build — expect PASS.
- Commit:
git commit -m "feat(sql): SqlProvider/SqlTagModel enums + SqlDriverConfigDto"
Task 2: SqlTagDefinition + SqlEquipmentTagParser.TryParse (strict enum reads, malicious-input safe)
Classification: standard Estimated implement time: ~5 min Parallelizable with: none Files:
- Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs - Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs - Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs(Tests project scaffolded in Task 6 — pull the csproj creation forward from Task 6 for this task, or defer this task's test until Task 6 and mark Task 2 blockedBy nothing but verified-at-6. Recommended: create the Tests project now as part of this task's setup.)
TDD:
- Failing test — parse a KeyValue blob and a malicious
keyValue; assert the value is captured verbatim (parser does NOT sanitize — binding is the reader's job) and a typo'dmodelenum rejects:
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
public class SqlEquipmentTagParserTests
{
[Fact]
public void TryParse_KeyValue_readsFields_andKeepsMaliciousValueVerbatim()
{
var json = """
{"driver":"Sql","model":"KeyValue","table":"dbo.TagValues","keyColumn":"tag_name",
"keyValue":"'; DROP TABLE x --","valueColumn":"num_value","timestampColumn":"sample_ts","type":"Float64"}
""";
SqlEquipmentTagParser.TryParse(json, "Plant/Sql/dev1/Speed", out var def).ShouldBeTrue();
def.Model.ShouldBe(SqlTagModel.KeyValue);
def.Table.ShouldBe("dbo.TagValues");
def.KeyValue.ShouldBe("'; DROP TABLE x --"); // captured as-is; the reader BINDS it, never concatenates
def.DeclaredType.ShouldBe(DriverDataType.Float64);
def.Name.ShouldBe("Plant/Sql/dev1/Speed"); // Name == RawPath (forward-router key)
}
[Fact]
public void TryParse_invalidModelEnum_rejectsTag()
=> SqlEquipmentTagParser.TryParse(
"""{"driver":"Sql","model":"Nonsense","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""",
"raw", out _).ShouldBeFalse();
}
- Run
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests— expect FAIL (types don't exist). - Minimal impl —
SqlTagDefinitionrecord (Name= RawPath,Model,Table,KeyColumn,KeyValue,ValueColumn,TimestampColumn,ColumnName,RowSelectorColumn,RowSelectorValue,RowSelectorTopByTimestamp,DeclaredType=DriverDataType?).SqlEquipmentTagParser.TryParse(string reference, string rawPath, out SqlTagDefinition def)— mirrorModbusTagDefinitionFactory.FromTagConfig: recognize by leading{, read themodeldiscriminator viaTagConfigJson.TryReadEnumStrict(a present-but-invalid enum →false→ the driver surfacesBadNodeIdUnknown, per R2-11), read model-specific fields, read the optionaltypeoverride strictly, setName = rawPath. Wrap in try/catch(JsonException/FormatException) →false. No SQL is built here — the parser only captures strings. - Run test — expect PASS.
- Commit:
git commit -m "feat(sql): SqlTagDefinition + strict-enum equipment-tag parser"
Task 3: Scaffold Driver.Sql runtime project + register in slnx
Classification: small Estimated implement time: ~4 min Parallelizable with: Task 2 Files:
- Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj - Modify:
ZB.MOM.WW.OtOpcUa.slnx
Steps:
- Create the csproj per design §2.1 — refs
Core.Abstractions+Core(forDriverFactoryRegistryinZB.MOM.WW.OtOpcUa.Core.Hosting) +Driver.Sql.Contracts; packagesMicrosoft.Data.SqlClient+Microsoft.Extensions.Logging.Abstractions;InternalsVisibleTothe.Tests. - Add to
ZB.MOM.WW.OtOpcUa.slnxunder/src/Drivers/. - Build — expect PASS.
- Commit:
git commit -m "feat(sql): scaffold Driver.Sql runtime project"
Task 4: ISqlDialect seam + SqlServerDialect (QuoteIdentifier, catalog SQL, MapColumnType) — golden queries
Classification: high-risk (identifier quoting is the injection boundary) Estimated implement time: ~5 min Parallelizable with: Task 2 Files:
- Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs - Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs - Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs
TDD:
- Failing test — golden catalog SQL,
QuoteIdentifierescape + reject,MapColumnTypetable:
[Fact]
public void QuoteIdentifier_bracketsAndEscapesEmbeddedBrackets()
{
var d = new SqlServerDialect();
d.QuoteIdentifier("Speed").ShouldBe("[Speed]");
d.QuoteIdentifier("a]b").ShouldBe("[a]]b]"); // ] doubled per T-SQL rules
}
[Fact]
public void QuoteIdentifier_rejectsControlCharsAndNul()
=> Should.Throw<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier("x\0y"));
[Theory]
[InlineData("bit", DriverDataType.Boolean)]
[InlineData("int", DriverDataType.Int32)]
[InlineData("bigint", DriverDataType.Int64)]
[InlineData("real", DriverDataType.Float32)]
[InlineData("float", DriverDataType.Float64)]
[InlineData("decimal", DriverDataType.Float64)]
[InlineData("nvarchar", DriverDataType.String)]
[InlineData("datetime2", DriverDataType.DateTime)]
[InlineData("uniqueidentifier", DriverDataType.String)]
public void MapColumnType_mapsFamilies(string sql, DriverDataType expected)
=> new SqlServerDialect().MapColumnType(sql).ShouldBe(expected);
[Fact]
public void CatalogSql_isInformationSchemaAndParameterized()
{
var d = new SqlServerDialect();
d.LivenessSql.ShouldBe("SELECT 1");
d.ListTablesSql.ShouldContain("INFORMATION_SCHEMA.TABLES");
d.ListTablesSql.ShouldContain("@schema"); // never string-interpolated
d.ListColumnsSql.ShouldContain("@table");
}
- Run — expect FAIL.
- Minimal impl —
ISqlDialectper design §2.2 (Provider,Factory=DbProviderFactory,QuoteIdentifier,LivenessSql,ListSchemasSql,ListTablesSql,ListColumnsSql,MapColumnType).SqlServerDialect:Factory => SqlClientFactory.Instance;QuoteIdentifierdoubles], rejects embedded NUL/control chars viaArgumentException; catalog SQL usesINFORMATION_SCHEMAwith@schema/@tablemarkers;MapColumnTypefolds SQL type families toDriverDataTypeper design §3.7 (decimal/numeric/money→Float64with the documented precision caveat as an XML remark). - Run — expect PASS.
- Commit:
git commit -m "feat(sql): ISqlDialect + SqlServerDialect with golden catalog SQL"
Task 5: SqlQueryPlan grouping + parameter binding (pure, no DB)
Classification: high-risk (the query-mapping core; GroupKey correctness governs correctness of every read) Estimated implement time: ~5 min Parallelizable with: Task 10 Files:
- Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs - Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs - Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs
TDD:
- Failing test — KeyValue tags on the same
(table,keyColumn,valueColumn,timestampColumn)fold into ONE plan whose SQL is... WHERE [tag_name] IN (@k0,@k1)with two bound params; WideRow tags on the same(table,rowSelector)fold into oneSELECT <cols> ... WHERE [station_id]=@w; different tables → different plans:
[Fact]
public void KeyValueTags_sameSource_foldIntoOnePlan_withBoundInList()
{
var dialect = new SqlServerDialect();
var tags = new[] {
KvTag("A","dbo.TagValues","tag_name","Line1.Speed","num_value","sample_ts"),
KvTag("B","dbo.TagValues","tag_name","Line1.Temp","num_value","sample_ts"),
};
var plans = SqlGroupPlanner.Plan(tags, dialect).ToList();
plans.Count.ShouldBe(1);
plans[0].SqlText.ShouldContain("IN (@k0, @k1)");
plans[0].SqlText.ShouldContain("[tag_name]");
plans[0].Members.Count.ShouldBe(2);
plans[0].Parameters.ShouldBe(new object[] { "Line1.Speed", "Line1.Temp" }); // values are params, not text
}
- Run — expect FAIL.
- Minimal impl —
SqlQueryPlan { object GroupKey, string SqlText, IReadOnlyList<object?> Parameters, IReadOnlyList<SqlTagDefinition> Members, Func<DbDataReader, SqlTagDefinition, ...> }(indexer supplied by the reader in Task 7; here just the shape + SQL/param builder).SqlGroupPlanner.Plan(tags, dialect): KeyValue → GroupKey(table,keyColumn,valueColumn,timestampColumn), emitSELECT <keyColumn>,<valueColumn>[,<timestampColumn>] FROM <table> WHERE <keyColumn> IN (@k0..@kN)with distinct-key params; WideRow → GroupKey(table, rowSelector), emitSELECT <distinct member columns>[,<selector cols>] FROM <table> WHERE <whereColumn>=@w(orORDER BY <col> DESC+ dialect TOP-1 fortopByTimestamp). Identifiers viadialect.QuoteIdentifier;<table>split on.and each part quoted. Every value is a param; zero interpolation. - Run — expect PASS.
- Commit:
git commit -m "feat(sql): SqlGroupPlanner folds tags into one parameterized query per group"
Task 6: Scaffold Driver.Sql.Tests + SqliteDialect (test) + SqlitePollFixture
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 10, Task 12 Files:
- Create:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj - Create:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs - Create:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixture.cs - Modify:
ZB.MOM.WW.OtOpcUa.slnx
If Task 2 already created the csproj, this task adds the
SqliteDialect/fixture and the slnx entry only.
Steps:
- csproj —
xunit.v3+Shouldly+Microsoft.NET.Test.Sdk+xunit.runner.visualstudio;PackageReference Microsoft.Data.Sqlite(10.0.7, test-only — no product dep on SQLite); project-refsDriver.Sql+Driver.Sql.Contracts. Add to slnx under/tests/Drivers/. SqliteDialect : ISqlDialect—Provider = SqlServershim is wrong; add a test-onlyProvidervalue is not needed — implementISqlDialectdirectly withFactory => SqliteFactory.Instance,QuoteIdentifierdoubling",LivenessSql = "SELECT 1", catalog SQL oversqlite_schema+PRAGMA table_info(...),MapColumnTypemapping declared affinity (design §9 caveat: SQLite is dynamically typed — keep seed columns explicitly typed). This dialect is also linked by the Browser.Tests (Task 13) via<Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" />.SqlitePollFixture— opens an in-memory (or temp-file) SQLite connection, seeds a KeyValue tableTagValues(tag_name TEXT, num_value REAL, sample_ts TEXT)and a wide-rowLatestStatus(station_id INT, oven_temp REAL, sample_ts TEXT)with a few rows. Exposes the openSqliteConnection+ aDbProviderFactoryshim so the driver-under-test can be injected with an already-open connection provider (see Task 8's ctor seam).- Build + run (no tests yet) — expect PASS/empty.
- Commit:
git commit -m "test(sql): Sql.Tests project + SqliteDialect + poll fixture"
Task 7: SqlPollReader core — one query per group, bounded deadline, slice back in input order
Classification: high-risk (connection/timeout/result-slicing core) Estimated implement time: ~5 min Parallelizable with: none Files:
- Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs - Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs
TDD:
- Failing test (against
SqlitePollFixture) — read two KeyValue refs + one absent key; assert N-in/N-out order, correct values/types, absent key →BadNoData, NULL cell →Uncertain(defaultnullIsBad=false), source timestamp fromtimestampColumn:
[Fact]
public async Task ReadAsync_returnsSnapshotsInInputOrder_absentKeyIsBadNoData()
{
await using var fx = await SqlitePollFixture.CreateAsync(); // seeds Line1.Speed=42.0, Line1.Temp=NULL
var reader = new SqlPollReader(fx.Factory, fx.ConnectionString, new SqliteDialect(),
commandTimeout: TimeSpan.FromSeconds(10), operationTimeout: TimeSpan.FromSeconds(15),
maxConcurrentGroups: 4, nullIsBad: false, resolve: fx.Resolve);
var refs = new[] { "Speed", "Missing", "Temp" };
var snaps = await reader.ReadAsync(refs, CancellationToken.None);
snaps.Count.ShouldBe(3);
snaps[0].Value.ShouldBe(42.0);
snaps[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // key deleted/absent
snaps[2].Value.ShouldBeNull();
StatusCode.IsUncertain(snaps[2].StatusCode).ShouldBeTrue(); // NULL cell, nullIsBad=false
}
- Run — expect FAIL.
- Minimal impl —
SqlPollReader.ReadAsync(refs, ct): resolve each ref →SqlTagDefinition(miss →BadNodeIdUnknown);SqlGroupPlanner.Plan(...); per group under aSemaphoreSlim(maxConcurrentGroups):await using var conn = factory.CreateConnection(); conn.ConnectionString = connStr; await conn.OpenAsync(linkedCt); buildDbCommandwithCommandTimeout = (int)commandTimeout.TotalSecondsand bound params;using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); linked.CancelAfter(operationTimeout); await using var rdr = await cmd.ExecuteReaderAsync(linked.Token); index rows by key/selector; map each member's cell →DataValueSnapshot(type perDeclaredType??dialect.MapColumnType-inferred; NULL →nullIsBad ? Bad : Uncertain; absent key →BadNoData; source timestamp fromtimestampColumnelse read wall-clock). Reassemble in input order (DataValueSnapshot[refs.Count]). Per-tag failure = Bad-coded snapshot, NOT an exception; the whole call throws only if the connection itself is unreachable (→ engine backoff).await usingboth connection and reader — never leak. AddSqlStatusCodesstatic (BadNoData/BadTimeout/BadNodeIdUnknown/BadCommunicationError/Uncertain constants) if not reusing an existing helper. - Run — expect PASS. Add a NULL-cell test with
nullIsBad:true→Bad. - Commit:
git commit -m "feat(sql): SqlPollReader — grouped read, bounded deadline, ordered slice-back"
Task 8: SqlDriver shell — IDriver / ITagDiscovery / IReadable / ISubscribable / IHostConnectivityProbe
Classification: high-risk (lifecycle + poll-engine wiring + connection validation) Estimated implement time: ~5 min Parallelizable with: none Files:
- Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs - Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs
TDD:
- Failing test — construct
SqlDriverwith an injected dialect + factory + already-resolved connection string (SQLite fixture) + authoredRawTagEntrylist;InitializeAsync→Healthy;DiscoverAsyncstreams one Variable per authored tag withSecurityClass=ViewOnly+RediscoverPolicy=Once+SupportsOnlineDiscovery=false;ReadAsyncdelegates to the reader; a subscribe fires an initial change:
[Fact]
public async Task Initialize_thenDiscover_streamsAuthoredTagsReadOnly()
{
await using var fx = await SqlitePollFixture.CreateAsync();
var driver = SqlDriver.ForTest(fx, rawTags: fx.AuthoredRawTags);
await driver.InitializeAsync(fx.ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
((ITagDiscovery)driver).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
var cap = new CapturingBuilder();
await ((ITagDiscovery)driver).DiscoverAsync(cap, CancellationToken.None);
cap.Variables.ShouldContain(v => v.FullName == "Speed" && v.SecurityClass == SecurityClassification.ViewOnly);
}
- Run — expect FAIL.
- Minimal impl —
SqlDriver : IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IAsyncDisposable. MirrorModbusDriver: fields grouped up top;_resolver = new EquipmentTagRefResolver<SqlTagDefinition>(...);_poll = new PollGroupEngine(reader: ReadAsync, onChange: ..., onError: HandlePollError, backoffCap: 30s).InitializeAsync: build_tagsByRawPathfrom_options.RawTagsviaSqlEquipmentTagParser.TryParse(miss = logged skip, never throw); open ONE connection, rundialect.LivenessSqlunderCommandTimeout+ linked CTS, dispose; success →Healthy, failure →Faulted+LastError.ReadAsync→_reader.ReadAsync.SubscribeAsync→_poll.Subscribe;UnsubscribeAsync→_poll.Unsubscribe.IHostConnectivityProbe: single logical host (server host from the connection string) withRunning/Stoppedfrom the last poll/liveness outcome +OnHostStatusChangedon transitions.GetMemoryFootprint => 0;FlushOptionalCachesAsync => Task.CompletedTask;ReinitializeAsync= teardown+init.DriverType => DriverTypeNames.Sql. Add aForTest(...)internal factory that injects dialect+factory+connString (production path resolves those in the factory, Task 9). Pooling: open-use-dispose per poll (already in the reader); no long-lived connection. - Run — expect PASS.
- Commit:
git commit -m "feat(sql): SqlDriver shell wiring PollGroupEngine + probe + read-only discovery"
Task 9: SqlDriverFactoryExtensions + connectionStringRef env resolution + enum-serialization guard
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 10 Files:
- Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs - Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlConnectionStringResolver.cs - Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs
TDD:
- Failing test —
CreateInstancewith a config carrying"provider":"SqlServer"builds a driver; a missingconnectionStringRefthrows a clean error; the connection-string ref resolves from envSql__ConnectionStrings__<ref>; enum-serialization guard — a config author serializingprovideras a NUMBER still parses (JsonStringEnumConverteraccepts ordinals) AND the round-trip writes the NAME:
[Fact]
public void CreateInstance_missingConnectionStringRef_throwsActionable()
=> Should.Throw<InvalidOperationException>(() =>
SqlDriverFactoryExtensions.CreateInstance("d1", """{"provider":"SqlServer"}""", null))
.Message.ShouldContain("connectionStringRef");
[Fact]
public void ConnectionStringRef_resolvesFromEnv()
{
Environment.SetEnvironmentVariable("Sql__ConnectionStrings__MesStaging", "Server=x;Database=y;");
SqlConnectionStringResolver.Resolve("MesStaging").ShouldBe("Server=x;Database=y;");
}
[Fact]
public void Provider_serializesAsNameNotNumber()
{
var json = JsonSerializer.Serialize(new SqlDriverConfigDto { Provider = SqlProvider.SqlServer },
SqlDriverFactoryExtensions.JsonOptionsForTest);
json.ShouldContain("\"SqlServer\"");
json.ShouldNotContain("\"provider\":0");
}
- Run — expect FAIL.
- Minimal impl —
SqlConnectionStringResolver.Resolve(string @ref)→Environment.GetEnvironmentVariable($"Sql__ConnectionStrings__{@ref}")(direct env read, deliberate — the factory closure has noIConfiguration, per design §8.2), throws actionable if absent.SqlDriverFactoryExtensions:const DriverTypeName = DriverTypeNames.Sql;Register(registry, loggerFactory)→registry.Register(DriverTypeName, (id,json)=>CreateInstance(id,json,loggerFactory));CreateInstancedeserializesSqlDriverConfigDtowithJsonOptionscarryingJsonStringEnumConverter+UnmappedMemberHandling.Skip, validatesConnectionStringRefpresent, buildsSqlServerDialectfromProvider(P1: onlySqlServerconstructible; others throw "provider not available in this build"), resolves the connection string, constructs theSqlPollReader+SqlDriver. Never log the resolved connection string (log provider + server host + database only). ExposeJsonOptionsForTestinternal. - Run — expect PASS.
- Commit:
git commit -m "feat(sql): factory + connectionStringRef env resolution + string-enum guard"
Task 10: SqlDriverProbe (SELECT 1 liveness, bounded timeout)
Classification: small Estimated implement time: ~4 min Parallelizable with: Task 5, Task 9, Task 12 Files:
- Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs - Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs
TDD:
- Failing test — against the SQLite fixture (inject dialect+factory),
ProbeAsyncreturns green with latency; a bad connection string returns a red result with the error message (never throws):
[Fact]
public async Task Probe_liveConnection_returnsGreen()
{
await using var fx = await SqlitePollFixture.CreateAsync();
var probe = SqlDriverProbe.ForTest(fx.Factory, new SqliteDialect());
var r = await probe.ProbeAsync(fx.ConfigJson, TimeSpan.FromSeconds(5), CancellationToken.None);
r.Success.ShouldBeTrue();
}
- Run — expect FAIL.
- Minimal impl —
SqlDriverProbe : IDriverProbe,DriverType => DriverTypeNames.Sql. Parse config via the SAME factory DTO shape +JsonStringEnumConverter(R2-11 factory parity, mirroringModbusDriverProbe), resolve the connection string ref, open a connection under a linked CTS bounded bytimeout, rundialect.LivenessSql(SELECT 1), returnDriverProbeResult(true, "SQL SELECT 1 OK", latency); catch → red result naming the failure. Never throws.ForTestinjects factory+dialect for the SQLite path. - Run — expect PASS.
- Commit:
git commit -m "feat(sql): SqlDriverProbe SELECT-1 liveness check"
Task 11: Host registration — DriverTypeNames.Sql + factory + probe + guard test
Classification: small Estimated implement time: ~4 min Parallelizable with: none Files:
- Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj(project-refDriver.Sqlif not transitively present) - Test:
tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeNamesGuardTests.csis EXISTING — it asserts bidirectional parity betweenDriverTypeNamesconstants and the factory-registered set; this task makes it pass by adding both sides.
TDD:
- Failing step — add
public const string Sql = "Sql";toDriverTypeNames+ include inAll, but DON'T yet register the factory. RunDriverTypeNamesGuardTests→ expect FAIL (constant with no registered factory). - Impl — in
DriverFactoryBootstrap.Register(...)addDriver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory);; inAddOtOpcUaDriverProbesaddservices.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, SqlProbe>());with ausing SqlProbe = Driver.Sql.SqlDriverProbe;alias; add theDriver.Sqlproject-ref to the Host csproj. Default tierDriverTier.A(no explicit arg — SQL client is cross-platform managed, runs on macOS dev, soShouldStub()needs no change). - Run guard test +
dotnet build ZB.MOM.WW.OtOpcUa.slnx— expect PASS. - Commit:
git commit -m "feat(sql): register Sql factory + probe in Host, add DriverTypeNames.Sql"
Task 12: Scaffold Driver.Sql.Browser project + register in slnx
Classification: small Estimated implement time: ~4 min Parallelizable with: Task 5, Task 6, Task 9, Task 10 Files:
- Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj - Modify:
ZB.MOM.WW.OtOpcUa.slnx
Steps:
- csproj refs
Commons(ZB.MOM.WW.OtOpcUa.Commons—IDriverBrowser/IBrowseSession/BrowseNode) +Driver.Sql.Contracts+Driver.Sql(forISqlDialect/SqlServerDialect— the dialect's catalog SQL is the browse engine;Microsoft.Data.SqlClientcomes transitively). This runtime-project ref is the deliberate deviation fromOpcUaClient.Browserrecorded in design §2 — add an XML comment on the ref citing the design so nobody "fixes" it.InternalsVisibleTothe.Browser.Tests. - Add to slnx under
/src/Drivers/. - Build — expect PASS.
- Commit:
git commit -m "feat(sql): scaffold Driver.Sql.Browser project"
Task 13: SqlBrowseSession — schema walk over the dialect catalog
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 16, Task 17 Files:
- Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs - Create:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj(+ slnx entry, + linkSqliteDialect.csfrom Sql.Tests) - Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs
TDD:
- Failing test — over a seeded SQLite DB +
SqliteDialect:RootAsyncyields schema folder(s);ExpandAsync(schema)yields the seeded tables;ExpandAsync(table)yields columns as leaves;AttributesAsync(column)yields the mappedDriverDataType:
[Fact]
public async Task ExpandTable_yieldsColumnsAsLeaves_withMappedType()
{
await using var db = await SqliteBrowseFixture.CreateAsync(); // TagValues(tag_name TEXT, num_value REAL)
var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var tableNode = (await session.ExpandAsync(db.SchemaNodeId, default)).First(n => n.DisplayName.Contains("TagValues"));
var cols = await session.ExpandAsync(tableNode.NodeId, default);
cols.ShouldContain(c => c.DisplayName == "num_value" && c.IsLeaf);
var attrs = await session.AttributesAsync(cols.First(c => c.DisplayName == "num_value").NodeId, default);
attrs.DriverDataType.ShouldBe(nameof(DriverDataType.Float64));
}
- Run — expect FAIL.
- Minimal impl —
SqlBrowseSession : IBrowseSession(match theCommons.Browsinginterface shape used byOpcUaClientBrowseSession):RootAsync/ExpandAsync(nodeId)/AttributesAsync(nodeId)run the dialect catalog queries with bound@schema/@tableparams; encodeNodeIdasschema/schema.table/schema.table|column; aSemaphoreSlim _gateserializes (one ADO.NET connection is not concurrent); per-call work bounded by the AdminUI's existing 20 s per-call timeout. Column leafAttributesAsyncreturnsAttributeInfo(Name: column, DriverDataType: dialect.MapColumnType(dataType).ToString(), IsArray:false, SecurityClass:"ViewOnly")(mirrors Galaxy two-stage pick). - Run — expect PASS.
- Commit:
git commit -m "feat(sql): SqlBrowseSession schema-walk over dialect catalog"
Task 14: SqlDriverBrowser — IDriverBrowser open (env ref OR pasted literal, transient connection)
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 16, Task 17 Files:
- Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs - Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs
TDD:
- Failing test —
OpenAsyncwith a form JSON whoseconnectionStringRefresolves from env opens a session; an UNresolvable ref fails with a message naming the EXACT missing env var; a pasted literal connection string is used session-only (never persisted):
[Fact]
public async Task Open_unresolvableRef_failsNamingExactEnvVar()
{
var browser = new SqlDriverBrowser();
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""", default));
ex.Message.ShouldContain("Sql__ConnectionStrings__MesStaging");
}
- Run — expect FAIL.
- Minimal impl —
SqlDriverBrowser : IDriverBrowser,DriverType => DriverTypeNames.Sql.OpenAsync(configJson, ct): deserialize with the sameJsonStringEnumConverteroptions; resolveconnectionStringRefvia a direct env read (Sql__ConnectionStrings__<ref>) IN THE ADMINUI PROCESS — on miss throw an actionable message naming the exact missing env var (design §4.4); accept an optional pasted literalconnectionStringfor ad-hoc browse (session-only, never persisted, never logged, no_lastConfigJsoncached field); selectSqlServerDialect(P1); open ONE transientDbConnection, return aSqlBrowseSession. Reuses the AdminUIBrowseSessionRegistry+ reaper + idle TTL unchanged. - Run — expect PASS.
- Commit:
git commit -m "feat(sql): SqlDriverBrowser transient-connection open with env-ref + literal"
Task 15: AdminUI browser DI + SqlAddressPickerBody.razor (picker body)
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 16, Task 17 Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs(addservices.AddSingleton<IDriverBrowser, SqlDriverBrowser>();beside the OpcUaClient/Galaxy lines ~:75) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj(project-refDriver.Sql.Browser) - Create:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddressPickers/SqlAddressPickerBody.razor(reuseDriverBrowseTree.razorfor the tree + an attribute side-panel + model/selector fields; gated by the existingDriverOperatorpolicy; manual entry retained)
Steps (no unit test — Razor is live-verified in Task 21):
- Register the bespoke
IDriverBrowser(the universal-browser DI line is NOT added forSql— itsCanBrowseis false and bespoke wins by construction). SqlAddressPickerBody.razor— thin body:DriverBrowseTreein picker mode + attribute side-panel; on column pick, compose theTagConfigblob (DisplayName defaulttable.column, prefilltypefromAttributesAsync, operator picks model + fills key/row selector;SecurityClass=ViewOnly).dotnet build— expect PASS.- Commit:
git commit -m "feat(sql): AdminUI browser DI + Sql address picker body"
Task 16: Env-gated central-SQL integration fixture (Driver.Sql.IntegrationTests)
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 13, Task 14, Task 15, Task 17 Files:
- Create:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj(+ slnx entry) - Create:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlPollServerFixture.cs - Create:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs
TDD:
- Failing/skipping test — against the always-on central SQL Server (
10.100.0.35,14330), seed aSqlPollFixturedatabase with the two sample tables (KeyValue + wide-row), then validate the realMicrosoft.Data.SqlClientpath (read round-trip,INFORMATION_SCHEMAbrowse,CommandTimeout/cancellation, pooling). Env-gated viaSQL_TEST_ENDPOINT/ a fullSql__ConnectionStrings__Fixture— absent ⇒ skip cleanly (useAssert.Skip(...)/ xunit.v3[Fact(Skip=...)]dynamic skip), like every other*.IntegrationTests:
[Fact]
public async Task ReadAsync_realSqlServer_roundTripsSeededKeyValue()
{
var connStr = Environment.GetEnvironmentVariable("Sql__ConnectionStrings__Fixture");
Assert.SkipWhen(string.IsNullOrEmpty(connStr), "Sql__ConnectionStrings__Fixture not set — offline skip.");
await using var fx = await SqlPollServerFixture.EnsureSeededAsync(connStr!);
var driver = SqlDriver.ForProduction(connStr!, new SqlServerDialect(), fx.AuthoredRawTags);
await driver.InitializeAsync(fx.ConfigJson, default);
var snaps = await driver.ReadAsync(new[] { "Line1.Speed" }, default);
snaps[0].StatusCode.ShouldBe(0u);
}
- Run offline — expect SKIP (green).
- Impl — the fixture (create-if-missing DB + tables + seed rows via
Microsoft.Data.SqlClient) and the read tests. The seed DB isSqlPollFixture, NOTConfigDb— the shared central server hosts ConfigDb; the fixture uses its own database on that server. - Commit:
git commit -m "test(sql): env-gated central-SQL integration fixture + read round-trip"
Task 17: Injection regression test (bind-harmless value / reject identifier, never execute)
Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 13, Task 14, Task 15, Task 16 Files:
- Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlInjectionRegressionTests.cs
TDD:
- Failing test (offline, SQLite fixture) — a malicious
keyValue('; DROP TABLE TagValues; --) reads harmlessly as a bound param and the seed table still exists afterward; a malicioustable/columnidentifier that is not catalog-valid is REJECTED (tag →BadNodeIdUnknown), never executed:
[Fact]
public async Task MaliciousKeyValue_bindsHarmlessly_tableSurvives()
{
await using var fx = await SqlitePollFixture.CreateAsync();
var reader = fx.NewReader();
var snaps = await reader.ReadAsync(new[] { fx.RefWithKeyValue("'; DROP TABLE TagValues; --") }, default);
snaps[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // no such key — bound, not executed
(await fx.TableRowCountAsync("TagValues")).ShouldBeGreaterThan(0); // table intact
}
- Run — expect FAIL (until reader binds correctly; it should already PASS from Task 7 if binding is right — this test LOCKS the guarantee).
- Impl — none if Task 7 binds correctly; otherwise fix. Add a review-checklist note: "any code path building SQL by string-concatenating a tag field is a defect."
- Commit:
git commit -m "test(sql): injection regression — bind values, reject unknown identifiers"
Task 18: Blackhole / timeout live-gate against a DEDICATED mssql container
Classification: high-risk (frozen-peer resilience — the single highest-value integration test) Estimated implement time: ~5 min Parallelizable with: none Files:
- Create:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/docker-compose.yml(a dedicated disposablemssqlstack) - Create:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/seed.sql - Create:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs
CRITICAL: this gate
docker pauses a dedicatedmcr.microsoft.com/mssql/servercontainer it owns — NEVER the shared central SQL Server on10.100.0.35,14330, which hostsConfigDbfor the whole rig (design §9). The compose stack is deployed vialmxopcua-fix sync;lmxopcua-fixapplies theproject=lmxopcualabel host-side.
TDD:
- Failing/skipping test — mirror the R2-01 S7 blackhole (
S7_1500ConnectTimeoutOutageTests): start reading against the dedicated mssql, thendocker pauseit mid-poll; assert the next read surfacesBadTimeoutwithinoperationTimeout+ a small margin (client-side linked-CTS cancellation, not the poll thread wedging), the driver degrades, and the poll loop backs off;docker unpauserecovers. Env-gated (SQL_BLACKHOLE_ENDPOINT) — offline skip:
[Fact]
public async Task PausedServer_surfacesBadTimeout_withinDeadline_notWedged()
{
var ep = Environment.GetEnvironmentVariable("SQL_BLACKHOLE_ENDPOINT");
Assert.SkipWhen(string.IsNullOrEmpty(ep), "SQL_BLACKHOLE_ENDPOINT not set — offline skip.");
// ... start driver, docker pause <dedicated-container>, read, assert BadTimeout within ~operationTimeout, unpause ...
}
- Run offline — expect SKIP.
- Impl — dedicated
docker-compose.yml(mssqlservice, own container name,restart: "no"),seed.sql(the two sample tables), and the test that shellsdocker pause/docker unpauseagainst the OWNED container. Assert the wall-clock bound (the frozen-peer contract):CommandTimeout(server-side backstop) +ExecuteReaderAsync(linkedCt)bounded byoperationTimeout(client-side real cancellation) both fire. - Commit:
git commit -m "test(sql): blackhole/timeout live-gate on a dedicated mssql container"
Task 19: AdminUI typed editor model + validator (timeout-order rule, string enums)
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 16, Task 17, Task 18 Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/SqlTagConfigModel.cs - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs(add[DriverTypeNames.Sql] = typeof(...SqlTagConfigEditor)) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs(add[DriverTypeNames.Sql] = j => SqlTagConfigModel.FromJson(j).Validate()) - Test:
tests/.../AdminUI.Tests/.../SqlTagConfigModelTests.cs(locate the existing AdminUI tag-editor test project; mirrorModbusTagConfigModeltests)
TDD:
- Failing test —
FromJson→ToJsonround-trips the model, preserves unknown keys, writes enums (model/type) as NAME strings not numbers;Validate()rejects a WideRow without a row selector and enforces the driver-level timeout-order rule surrogate at the tag layer if the tag carries timeouts (the primary timeout-order check is at the driver page — the model validates model/selector shape):
[Fact]
public void ToJson_writesModelAndTypeAsStrings_preservesUnknownKeys()
{
var m = SqlTagConfigModel.FromJson("""{"driver":"Sql","model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Float64","customX":1}""");
var json = m.ToJson();
json.ShouldContain("\"KeyValue\"");
json.ShouldContain("\"Float64\"");
json.ShouldContain("customX"); // unknown key survives load→save
json.ShouldNotContain("\"model\":0");
}
- Run — expect FAIL.
- Minimal impl —
SqlTagConfigModel(thin, overTagConfigJson.ParseOrNew, preserving the_bag):Model,Table,KeyColumn/KeyValue/ValueColumn/TimestampColumn,ColumnName+RowSelector,Type(DriverDataType?),FromJson/ToJson(enums as name strings viaTagConfigJson.Set)/Validate(model discriminator ⇒ required-field shape; WideRow needs a selector). Register inTagConfigEditorMap+TagConfigValidator. Enum-serialization trap (systemic):model/typeMUST round-trip as strings on both sides — the editorToJsonand the factorySqlEquipmentTagParserboth use string enums; add the assertion above. - Run — expect PASS.
- Commit:
git commit -m "feat(sql): typed AdminUI Sql tag-config model + validator (string enums)"
Task 20: SqlTagConfigEditor.razor (thin shell over the model)
Classification: small Estimated implement time: ~5 min Parallelizable with: none Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor
Steps (Razor — live-verified in Task 21):
- Copy the Modbus editor template; bind over
SqlTagConfigModel: aModeldropdown (KeyValue/WideRow) that shows the matching field group,Table, the key/value/timestamp fields (KeyValue) ORColumnName+ row-selector fields (WideRow), and aTypeoverride dropdown.@bindstring component params need the@prefix (the Global-UNS gotcha). dotnet build— expect PASS.- Commit:
git commit -m "feat(sql): SqlTagConfigEditor razor shell"
Task 21: Live /run verification on docker-dev (picker + editor + deploy + read)
Classification: trivial (procedure — no product code; may surface fix-forward tasks) Estimated implement time: ~5 min (verification, excluding any bugs found) Parallelizable with: none (last) Files: none (verification only; any defect becomes a new fix-forward task)
Procedure (docker-dev rig, AdminUI auto-authenticated at http://localhost:9200 — login disabled, so drive it yourself, don't defer to the user):
- Set
Sql__ConnectionStrings__DevSqlon the docker-dev central + driver nodes pointing at a dev SQL Server + seeded table (the dedicated mssql from Task 18, or the central-SQL fixture DB). Rebuild BOTH central-1/central-2 (:9200round-robins). - In
/raw, add aSqldriver + device (paste a literal connection string or use the ref); Test-Connect → green (SELECT 1). - Open the Sql address picker → browse schema→table→column → commit a KeyValue tag and a WideRow tag; confirm the typed editor renders and validates.
- Reference the raw tags into an equipment on
/uns; deploy viaPOST :9200/api/deployments(X-Api-Key) or the deploy button; confirm the deployment seals green. - Read via Client.CLI (
read -u opc.tcp://localhost:4840 -n "ns=2;s=<RawPath>") — confirm the live SQL value + quality + source timestamp. - Record the run outcome in the plan's completion note. If anything is deploy-inert / mis-bound, open a fix-forward task (Razor binding + deploy-inertness bugs pass unit tests + review — this live gate is the real proof).
Deferred / out of scope (pointers to the design, NOT executable tasks here)
These are recorded so nobody re-derives them; each is a later phase in docs/plans/2026-07-15-sql-poll-driver-design.md. The ISqlDialect seam is built in from day one (Task 4) precisely so the provider tail below is additive and cheap.
- Named-query model (design §5.4, §3.6(c) / design-P3) — the arbitrary-
SELECTescape hatch (queries: {...}+ a tag referencing a query by name + projection). Not built in v1; theSqlTagModel.Queryenum member exists but the reader/planner path is deferred. - PostgreSQL + ODBC dialects (design §2.2, §8.5 / design-P3) —
PostgresDialect(Npgsql) +OdbcDialect(System.Data.Odbc), each a NEW gatedPackageVersion. v1 constructs onlySqlServerDialect; the otherSqlProvidermembers throw "provider not available in this build." - MySQL/MariaDB + native Oracle (design §10 / design-P5) —
MySqlConnector+ anALL_TAB_COLUMNSOracle dialect. Demand-driven. - Write mode (
IWritable) (design §3.4 / design-P4) — opt-in, triple-gated (WriteOperaterole + driverallowWritesmaster switch +TagConfig.writable), parameterized UPSERT/StoredProc,WriteIdempotentretry policy. v1 is read-only;SecurityClass=ViewOnlyeverywhere andallowWritesdefaultsfalse. - Split-role env-parity operational note (design §4.4) — admin nodes must carry the same
Sql__ConnectionStrings__<ref>env vars as driver nodes for any browseable ref; the browser-open failure names the exact missing env var. This is a deployment/runbook item, not a code task.