diff --git a/docs/plans/2026-06-16-ipsen-mes-movein.md b/docs/plans/2026-06-16-ipsen-mes-movein.md index d7e510e6..cb867522 100644 --- a/docs/plans/2026-06-16-ipsen-mes-movein.md +++ b/docs/plans/2026-06-16-ipsen-mes-movein.md @@ -4,7 +4,7 @@ **Goal:** Turn the stub `POST /api/IpsenMESMoveIn` into a real MoveIn that resolves the SAP number to a reactor instance via BTDB, then writes the MoveIn onto the correct Left/Right MES-receiver child — gated on that receiver's `MoveInReadyFlag`. -**Architecture:** The inbound `/api` script gains a scoped, read-only DB helper (`Database.QuerySingle`) so it can do the `dbo.Machine.SAPID → Code` lookup, derive the bare instance code, then `Route.To(instance).Call("IpsenMoveIn", {…params, side})`. A new on-demand template script `IpsenMoveIn` on the reactor template (`IpsenFurnaceTitan`, T1) gates on `MoveInReadyFlag` and writes the fields onto `Children["{side}MESReceiver"]` (DCL → Galaxy authorized write). +**Architecture:** The inbound `/api` script gains a scoped, async DB helper (`await Database.QuerySingleAsync`) so it can do the `dbo.Machine.SAPID → Code` lookup, derive the bare instance code, then `Route.To(instance).Call("IpsenMoveIn", {…params, side})`. A new on-demand template script `IpsenMoveIn` on the reactor template (`IpsenFurnaceTitan`, T1) gates on `MoveInReadyFlag` and writes the fields onto `Children["{side}MESReceiver"]` (DCL → Galaxy authorized write). **Tech Stack:** .NET 10, Roslyn CSharpScript (inbound + template scripts), Akka.NET (Instance/Script actors), `IDatabaseGateway`/ADO.NET (SQL Server BTDB), ScadaBridge management API (`POST /management`). @@ -62,19 +62,19 @@ public class InboundDatabaseHelperTests } [Fact] - public void QuerySingle_returns_first_column_with_bound_parameter() + public async Task QuerySingleAsync_returns_first_column_with_bound_parameter() { var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None); - var code = helper.QuerySingle("BTDB", + var code = await helper.QuerySingleAsync("BTDB", "SELECT Code FROM Machine WHERE SAPID=@s", new { s = "131453" }); Assert.Equal("Z28061A", code); } [Fact] - public void QuerySingle_returns_default_when_no_rows() + public async Task QuerySingleAsync_returns_default_when_no_rows() { var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None); - var code = helper.QuerySingle("BTDB", + var code = await helper.QuerySingleAsync("BTDB", "SELECT Code FROM Machine WHERE SAPID=@s", new { s = "999999" }); Assert.Null(code); } @@ -94,10 +94,11 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; namespace ZB.MOM.WW.ScadaBridge.InboundAPI; /// -/// Read-only database access exposed to inbound API scripts. All ADO.NET stays -/// internal here — scripts call QuerySingle/Query by name and never reference -/// System.Data. Named connections only; parameters are bound (anonymous-object -/// properties become @-prefixed SQL parameters), never string-concatenated. +/// Async database access exposed to inbound API scripts. All ADO.NET stays +/// internal here — scripts call (and await) QuerySingleAsync/QueryAsync by name and +/// never reference System.Data. Named connections only; parameters are bound +/// (anonymous-object properties become @-prefixed SQL parameters), never +/// string-concatenated. /// public sealed class InboundDatabaseHelper { @@ -108,29 +109,29 @@ public sealed class InboundDatabaseHelper { _gateway = gateway; _ct = ct; } /// First column of the first row converted to T (default if no rows). - public T? QuerySingle(string connectionName, string sql, object? parameters = null) + public async Task QuerySingleAsync(string connectionName, string sql, object? parameters = null) { - using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult(); - using var cmd = conn.CreateCommand(); + await using var conn = await _gateway.GetConnectionAsync(connectionName, _ct); + await using var cmd = conn.CreateCommand(); cmd.CommandText = sql; AddParameters(cmd, parameters); - var result = cmd.ExecuteScalar(); + var result = await cmd.ExecuteScalarAsync(_ct); if (result is null or DBNull) return default; if (result is T t) return t; return (T)Convert.ChangeType(result, typeof(T), CultureInfo.InvariantCulture); } /// All rows as column→value dictionaries (case-insensitive keys). - public IReadOnlyList> Query( + public async Task>> QueryAsync( string connectionName, string sql, object? parameters = null) { - using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult(); - using var cmd = conn.CreateCommand(); + await using var conn = await _gateway.GetConnectionAsync(connectionName, _ct); + await using var cmd = conn.CreateCommand(); cmd.CommandText = sql; AddParameters(cmd, parameters); - using var reader = cmd.ExecuteReader(); + await using var reader = await cmd.ExecuteReaderAsync(_ct); var rows = new List>(); - while (reader.Read()) + while (await reader.ReadAsync(_ct)) { var row = new Dictionary(StringComparer.OrdinalIgnoreCase); for (var i = 0; i < reader.FieldCount; i++) @@ -162,7 +163,7 @@ public sealed class InboundDatabaseHelper **Step 5 — Commit:** `git add src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperTests.cs && git commit -m "feat(inbound): read-only InboundDatabaseHelper for inbound scripts"` -**Acceptance:** `QuerySingle` binds anonymous-object params, returns first column or default; ADO.NET stays internal. +**Acceptance:** `QuerySingleAsync` binds anonymous-object params and (awaited) returns first column or default; ADO.NET stays internal. --- @@ -176,7 +177,7 @@ public sealed class InboundDatabaseHelper - Modify: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs` (the `InboundScriptContext` class ~321-353; the `ExecuteAsync` context construction ~246-249; add `using Microsoft.Extensions.DependencyInjection;`) - Test: `tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs` (add cases) -**Step 1 — Write failing tests:** (a) a script body `return new { v = Database.QuerySingle("BTDB","SELECT Code FROM Machine WHERE SAPID=@s", new { s = (string)Parameters["sap"] }) };` resolves via a seeded SQLite gateway; (b) an existing script that does NOT use `Database` still compiles + runs (no regression). Use the same SQLite-gateway fake from Task 1; construct the executor with a real `IServiceProvider` (e.g. `new ServiceCollection().AddScoped(_ => gateway).BuildServiceProvider()`). +**Step 1 — Write failing tests:** (a) a script body `return new { v = await Database.QuerySingleAsync("BTDB","SELECT Code FROM Machine WHERE SAPID=@s", new { s = (string)Parameters["sap"] }) };` resolves via a seeded SQLite gateway; (b) an existing script that does NOT use `Database` still compiles + runs (no regression). Use the same SQLite-gateway fake from Task 1; construct the executor with a real `IServiceProvider` (e.g. `new ServiceCollection().AddScoped(_ => gateway).BuildServiceProvider()`). **Step 2 — Run, expect FAIL** (`Database` not a member of context). @@ -218,14 +219,14 @@ var context = new InboundScriptContext( cts.Token); ``` -Add `using Microsoft.Extensions.DependencyInjection;` at the top. No `ScriptOptions.WithReferences` change is needed — `InboundDatabaseHelper` lives in the same assembly as `RouteHelper`, which is already referenced (line ~157). Leave imports as-is (script calls `Database.QuerySingle`, fully qualified through the globals object; no `using` required). +Add `using Microsoft.Extensions.DependencyInjection;` at the top. No `ScriptOptions.WithReferences` change is needed — `InboundDatabaseHelper` lives in the same assembly as `RouteHelper`, which is already referenced (line ~157). Leave imports as-is (script calls `await Database.QuerySingleAsync`, fully qualified through the globals object; no `using` required). **Step 4 — Run tests, expect PASS** (both the Database-using script and the no-Database script). **Step 5 — Commit:** `git add -p` the two files; `git commit -m "feat(inbound): expose read-only Database helper on InboundScriptContext"` -**Acceptance:** inbound scripts can call `Database.QuerySingle`; the scope is disposed after each execution; existing no-DB scripts still compile + run; `ScriptTrustValidator` still passes (no forbidden API in the helper-call text). +**Acceptance:** inbound scripts can call `await Database.QuerySingleAsync`; the scope is disposed after each execution; existing no-DB scripts still compile + run; `ScriptTrustValidator` still passes (no forbidden API in the helper-call text). --- @@ -296,7 +297,7 @@ try { if (side == null) return new { WasSuccessful = false, ErrorText = "Unsupported side suffix '" + suf + "' (only -A/-B supported)", BatchID = 0 }; - var code = Database.QuerySingle("BTDB", + var code = await Database.QuerySingleAsync("BTDB", "SELECT TOP 1 Code FROM dbo.Machine WHERE SAPID=@s", new { s = sap }); if (string.IsNullOrEmpty(code)) return new { WasSuccessful = false, ErrorText = "No machine found for SAP " + sap, BatchID = 0 };